Changeset 0.29.0 (#195)

This commit is contained in:
2026-03-17 16:28:47 +00:00
parent 128cbb8174
commit 5d637d3a90
129 changed files with 9418 additions and 3016 deletions

View File

@@ -2,6 +2,7 @@ package store
import (
"context"
"encoding/json"
"errors"
"time"
@@ -55,6 +56,11 @@ type Stores struct {
Packages PackageStore // v0.28.7: Unified package registry (surfaces + extensions)
Workflows WorkflowStore // v0.26.1: Workflow definitions + stages
Tasks TaskStore // v0.27.1: Task scheduling + run history
Presence PresenceStore // v0.29.0: User online/offline status
PersonaGroups PersonaGroupStore // v0.29.0: Persona group (roster template) CRUD
Folders FolderStore // v0.29.0: Chat folder CRUD
Health HealthStore // v0.29.0: Provider health window management
ExtPermissions ExtensionPermissionStore // v0.29.0: Extension declared/granted capabilities
}
// =========================================
@@ -75,6 +81,31 @@ type ProviderStore interface {
// Access check
UserCanAccess(ctx context.Context, userID, configID string) (bool, error)
// ── CS4 additions (v0.29.0) ──
// DeletePersonalByOwner removes all personal-scope provider configs for a user.
DeletePersonalByOwner(ctx context.Context, ownerID string) (int64, error)
// ── CS6 additions (v0.29.0) ──
// ListAllForTeam returns all provider configs (including inactive) for a team.
ListAllForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error)
// DeleteByIDAndTeam deletes a team-scoped provider by ID and team. Returns rows affected.
DeleteByIDAndTeam(ctx context.Context, id, teamID string) (int64, error)
// ── CS7a additions (v0.29.0) ──
// FindFirstForUser returns the ID of the first active provider config
// accessible to a user (personal first, then global; excludes team).
// Returns sql.ErrNoRows if none found.
FindFirstForUser(ctx context.Context, userID string) (string, error)
// LoadAccessible loads a provider config by ID, verifying the user has access
// (global, personal owned by user, or team the user belongs to).
// Returns sql.ErrNoRows if not found or not accessible.
LoadAccessible(ctx context.Context, configID, userID string) (*models.ProviderConfig, error)
}
// =========================================
@@ -103,6 +134,41 @@ type CatalogStore interface {
// Delete
Delete(ctx context.Context, id string) error
DeleteForProvider(ctx context.Context, providerConfigID string) error
// ── Mention resolution (v0.29.0 — moved from handler raw SQL) ──
// FindEnabledByModelID finds an enabled catalog entry by exact model_id
// (case-insensitive). Returns (modelID, providerConfigID, nil).
// Prefers global > team > personal scope providers.
FindEnabledByModelID(ctx context.Context, modelID string) (string, string, error)
// FindEnabledByModelIDPrefix finds by unambiguous model_id prefix.
// Returns (modelID, configID, matchCount, nil).
FindEnabledByModelIDPrefix(ctx context.Context, prefix string) (string, string, int, error)
// ── CS2 additions (v0.29.0) ──
// GetCapabilities returns the raw capabilities JSON for a specific provider+model.
GetCapabilities(ctx context.Context, modelID, configID string) ([]byte, error)
// GetCapabilitiesAny returns capabilities for a model from any provider (most recent sync).
GetCapabilitiesAny(ctx context.Context, modelID string) ([]byte, error)
// ── CS6 additions (v0.29.0) ──
// ListTeamAvailable returns catalog entries with visibility 'enabled' or 'team'
// from active global providers. Used by team model selector.
ListTeamAvailable(ctx context.Context) ([]TeamAvailableModel, error)
}
// TeamAvailableModel is returned by CatalogStore.ListTeamAvailable.
type TeamAvailableModel struct {
ID string
ModelID string
DisplayName *string
Visibility string
Provider string
ProviderName string
}
// CatalogSyncEntry is the input format from provider FetchModels.
@@ -141,6 +207,25 @@ type PersonaStore interface {
SetKBs(ctx context.Context, personaID string, kbIDs []string, autoSearch map[string]bool) error
GetKBs(ctx context.Context, personaID string) ([]models.PersonaKB, error)
GetKBIDs(ctx context.Context, personaID string) ([]string, error)
// ── Mention resolution (v0.29.0 — moved from handler raw SQL) ──
// FindActiveByHandle returns an active persona's ID by exact handle match (case-insensitive).
// Returns "" if not found.
FindActiveByHandle(ctx context.Context, handle string) (string, error)
// FindActiveByHandlePrefix returns a persona ID by unambiguous handle prefix.
// Returns ("", count, nil) if ambiguous or no match.
FindActiveByHandlePrefix(ctx context.Context, prefix string) (string, int, error)
// GetNameByID returns the persona's display name. Returns "" if not found.
GetNameByID(ctx context.Context, id string) (string, error)
// GetNamesByIDs returns a map of persona ID → name for batch resolution.
GetNamesByIDs(ctx context.Context, ids []string) (map[string]string, error)
// GetDisplayInfoByIDs returns name + avatar for batch sender resolution.
GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]UserDisplayInfo, error)
}
// =========================================
@@ -190,6 +275,61 @@ type UserStore interface {
RevokeRefreshToken(ctx context.Context, tokenHash string) error
RevokeAllRefreshTokens(ctx context.Context, userID string) error
CleanExpiredTokens(ctx context.Context) error
// ── Mention resolution (v0.29.0 — moved from handler raw SQL) ──
// FindActiveByHandle returns a user ID by exact handle match (case-insensitive).
// excludeUserID prevents self-mention resolution.
FindActiveByHandle(ctx context.Context, handle, excludeUserID string) (string, error)
// FindActiveByHandlePrefix returns a user ID by unambiguous handle prefix.
// Returns ("", count, nil) if ambiguous or no match.
FindActiveByHandlePrefix(ctx context.Context, prefix, excludeUserID string) (string, int, error)
// GetDisplayInfoByIDs returns name + avatar for batch sender resolution.
GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]UserDisplayInfo, error)
// ── CS1 additions (v0.29.0) ──
// Exists returns true if a user with the given ID exists and is active.
Exists(ctx context.Context, userID string) (bool, error)
// SearchActive returns users matching a query (username, display_name, handle).
// Excludes the calling user. Max 20 results.
SearchActive(ctx context.Context, excludeUserID, query string) ([]UserSearchResult, error)
// ── CS2 additions (v0.29.0) ──
// CountByRole returns the number of users with a given role.
CountByRole(ctx context.Context, role string) (int, error)
// CountAll returns the total number of users.
CountAll(ctx context.Context) (int, error)
// MergeSettings performs a dialect-aware JSON merge into the user's settings column.
MergeSettings(ctx context.Context, userID string, patch []byte) error
// GetVaultKeys returns the user's vault encryption state.
GetVaultKeys(ctx context.Context, userID string) (vaultSet bool, encUEK, salt, nonce []byte, err error)
// UpdateVaultKeys re-wraps the user's vault keys (password change).
UpdateVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error
// ── CS4 additions (v0.29.0) ──
// ClearVaultKeys nulls out vault columns and sets vault_set = false.
ClearVaultKeys(ctx context.Context, userID string) error
// InitVaultKeys stores vault keys and sets vault_set = true (first-time init).
InitVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error
}
// UserSearchResult is a lightweight result for user search.
type UserSearchResult struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Handle string `json:"handle"`
}
// =========================================
@@ -213,6 +353,37 @@ type TeamStore interface {
GetUserTeamIDs(ctx context.Context, userID string) ([]string, error)
IsTeamAdmin(ctx context.Context, teamID, userID string) (bool, error)
IsMember(ctx context.Context, teamID, userID string) (bool, error)
// ── CS1 additions (v0.29.0) ──
// Exists returns true if a team with the given ID exists.
Exists(ctx context.Context, teamID string) (bool, error)
// UpdateMemberRoleByID updates a member's role using the member row ID.
UpdateMemberRoleByID(ctx context.Context, memberID, teamID, role string) (int64, error)
// DeleteMemberByID deletes a member using the member row ID.
DeleteMemberByID(ctx context.Context, memberID, teamID string) (int64, error)
// ListTeamAuditActions returns distinct audit actions for a team's members.
ListTeamAuditActions(ctx context.Context, teamID string) ([]string, error)
// ── CS5b additions (v0.29.0) ──
// GetFirstTeamIDForUser returns the first team_id the user belongs to, or "" if none.
GetFirstTeamIDForUser(ctx context.Context, userID string) (string, error)
// ── CS6 additions (v0.29.0) ──
// AddMemberReturningID inserts a team member and returns the row ID.
AddMemberReturningID(ctx context.Context, teamID, userID, role string) (string, error)
// HasPrivateProviderRequirement checks if a user belongs to any active team
// with the require_private_providers setting enabled.
HasPrivateProviderRequirement(ctx context.Context, userID string) (bool, error)
// MergeSettings merges a JSON string into the team's settings column.
MergeSettings(ctx context.Context, teamID, settingsJSON string) error
}
// =========================================
@@ -260,6 +431,169 @@ type ChannelStore interface {
// Admin: archived channel management (v0.23.2)
ListArchived(ctx context.Context, opts ListOptions) ([]ArchivedChannel, int, error)
Purge(ctx context.Context, id string) error // hard-delete; channel must be archived
// ── Single-field helpers (v0.29.0 — moved from handler raw SQL) ──
// GetAIMode returns the channel's ai_mode ("auto", "off", "mention_only").
GetAIMode(ctx context.Context, channelID string) (string, error)
// GetTypeAndTeamID returns (channel_type, team_id) in one query.
GetTypeAndTeamID(ctx context.Context, channelID string) (string, *string, error)
// GetSystemPrompt returns the channel's system_prompt (may be nil).
GetSystemPrompt(ctx context.Context, channelID string) (*string, error)
// GetDefaultModel returns the channel's model field (may be nil).
GetDefaultModel(ctx context.Context, channelID string) (*string, error)
// TouchUpdatedAt bumps the channel's updated_at to now.
TouchUpdatedAt(ctx context.Context, channelID string) error
// ListUserParticipantIDs returns user participant IDs, excluding excludeUserID.
ListUserParticipantIDs(ctx context.Context, channelID, excludeUserID string) ([]string, error)
// ListPersonaParticipantIDs returns persona participant IDs ordered by created_at.
ListPersonaParticipantIDs(ctx context.Context, channelID string) ([]string, error)
// GetLeaderPersonaID returns the persona group leader for a channel.
// Falls back to the first persona participant if no leader flag is set.
// Returns empty string if no persona participants exist.
GetLeaderPersonaID(ctx context.Context, channelID string) (string, error)
// GetWorkflowInfo returns (workflow_id, current_stage) for workflow-type channels.
// Returns nil workflow_id if the channel is not a workflow channel.
GetWorkflowInfo(ctx context.Context, channelID string) (*string, int, error)
// ── DM + lifecycle helpers (v0.29.0-cs1) ──
// FindExistingDM returns the channel ID of an existing DM between two users.
// Returns "" if no DM exists.
FindExistingDM(ctx context.Context, userID1, userID2 string) (string, error)
// GetUnreadCount returns the number of unread messages for a user in a channel.
GetUnreadCount(ctx context.Context, channelID, userID string) (int, error)
// DeleteByOwner deletes a channel if the user is the owner.
// Returns rows affected (0 = not found or not owner).
DeleteByOwner(ctx context.Context, channelID, userID string) (int64, error)
// MarkRead updates last_read_at and last_read_message_id for a user.
MarkRead(ctx context.Context, channelID, userID string) error
// CountParticipantsByType returns count of participants of a given type.
CountParticipantsByType(ctx context.Context, channelID, pType string) (int, error)
// CountAll returns the total number of channels.
CountAll(ctx context.Context) (int, error)
// ── Workflow instance state (v0.29.0-cs3) ──
// SetWorkflowInstance initializes workflow columns on a channel.
SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error
// GetWorkflowStatus returns the full workflow state for a workflow channel.
GetWorkflowStatus(ctx context.Context, channelID string) (*WorkflowChannelStatus, error)
// AdvanceWorkflowStage updates current_stage + stage_data + last_activity_at.
AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error
// CompleteWorkflow sets workflow_status='completed' and ai_mode='off'.
CompleteWorkflow(ctx context.Context, channelID string, finalStage int, stageData json.RawMessage) error
// RejectWorkflowToStage resets current_stage (no stage_data change).
RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error
// GetStageData returns the current stage_data JSON from a channel.
GetStageData(ctx context.Context, channelID string) (json.RawMessage, error)
// ── Background job helpers (v0.29.0-cs4) ──
// MarkStaleWorkflows sets workflow_status='stale' on active workflow channels
// with last_activity_at before cutoff. Returns rows affected.
MarkStaleWorkflows(ctx context.Context, cutoff time.Time) (int64, error)
// EnforceWorkflowRetention deletes completed workflow channels whose parent
// workflow has retention.mode="delete" and the retention period has elapsed.
// PG-only (uses JSON operators). Returns rows affected. No-op on SQLite.
EnforceWorkflowRetention(ctx context.Context) (int64, error)
// GetTypeAndAllowAnonymous returns (type, allow_anonymous) for session auth.
GetTypeAndAllowAnonymous(ctx context.Context, channelID string) (string, bool, error)
// ── CS5b additions (v0.29.0) ──
// FindCompactionCandidates returns direct channels eligible for auto-compaction.
// Filters by activity gap, max age, minimum message count, and minimum total chars.
// PG-only (uses settings::text cast). Returns empty slice on SQLite.
FindCompactionCandidates(ctx context.Context, activityBefore, createdAfter time.Time, minMessages, minChars, limit int) ([]models.Channel, error)
// ── CS7a additions (v0.29.0) ──
// GetProviderConfigID returns the channel's provider_config_id (may be nil).
GetProviderConfigID(ctx context.Context, channelID string) (*string, error)
// UserCanAccess checks if a user is the channel owner or a participant.
UserCanAccess(ctx context.Context, channelID, userID string) (bool, error)
// ── CS7b additions (v0.29.0) ──
// ListFiltered returns paginated channels with message counts, applying
// complex filters (types, folder, search, project). Includes channels
// owned by the user or where the user is a participant.
ListFiltered(ctx context.Context, userID string, filter ChannelListFilter) ([]ChannelListItem, int, error)
// GetForUser loads a single channel with message count, verifying the
// user is the owner or a participant. Returns sql.ErrNoRows if not found.
GetForUser(ctx context.Context, channelID, userID string) (*ChannelListItem, error)
// CreateFull atomically creates a channel, adds the owner as a participant,
// optionally adds DM partner participants, and creates a default channel_model.
// The channel model (ch) is populated with ID, CreatedAt, UpdatedAt on return.
// folder, tags, and aiMode are passed separately as they're not in models.Channel.
CreateFull(ctx context.Context, ch *models.Channel, folder string, tags []string, aiMode string,
ownerUserID string, dmPartnerIDs []string, defaultModel, defaultConfigID string) error
// MergeSettings merges a JSON object into the channel's settings column.
MergeSettings(ctx context.Context, channelID string, settingsJSON json.RawMessage) error
}
// ChannelListFilter holds filter options for ChannelStore.ListFiltered.
type ChannelListFilter struct {
ListOptions
Archived bool
Types []string // empty = all
Folder string
FolderID string
Search string // title ILIKE/LIKE
ProjectID string // "none" = NULL, uuid = specific, "" = no filter
}
// ChannelListItem is returned by ListFiltered and GetForUser.
type ChannelListItem struct {
ID string
UserID string
Title string
Type string
AiMode string
Topic *string
Description *string
Model *string
ProviderConfigID *string
SystemPrompt *string
IsArchived bool
IsPinned bool
Folder *string
FolderID *string
ProjectID *string
WorkspaceID *string
Tags []string
Settings json.RawMessage
MessageCount int
UnreadCount int
CreatedAt string
UpdatedAt string
CreatedAtTime time.Time `json:"-"` // SQLite scan target
UpdatedAtTime time.Time `json:"-"` // SQLite scan target
}
// ArchivedChannel is a view model for the admin archived channels list.
@@ -291,6 +625,89 @@ type MessageStore interface {
// Count
CountForChannel(ctx context.Context, channelID string) (int, error)
// ── Tree operations (v0.29.0 — moved from treepath package) ──
// GetActiveLeaf returns the cursor's active_leaf_id for a user in a channel.
// Falls back to the chronologically latest live message if no cursor exists.
GetActiveLeaf(ctx context.Context, channelID, userID string) (*string, error)
// GetPathToLeaf walks from a leaf up to root via parent_id, returning
// root-first order with sibling counts and sender info resolved.
GetPathToLeaf(ctx context.Context, channelID, leafID string) ([]PathMessage, error)
// GetActivePath is GetActiveLeaf + GetPathToLeaf combined.
GetActivePath(ctx context.Context, channelID, userID string) ([]PathMessage, error)
// GetSiblingsList returns all live siblings of a message with the current index.
GetSiblingsList(ctx context.Context, messageID string) ([]SiblingInfo, int, error)
// GetSiblingCount returns how many live siblings share the same parent.
GetSiblingCount(ctx context.Context, channelID string, parentID *string) (int, error)
// FindLeafFromMessage walks down from a message to find the deepest descendant.
FindLeafFromMessage(ctx context.Context, messageID string) (string, error)
// NextSiblingIndexForParent returns the next sibling_index for children of parentID.
NextSiblingIndexForParent(ctx context.Context, channelID string, parentID *string) (int, error)
// HasPersonaMessages checks if any assistant message in the channel was
// generated by a persona.
HasPersonaMessages(ctx context.Context, channelID string) (bool, error)
// CreateWithCursor inserts a message, updates the cursor, and touches
// channel.updated_at. Handles dialect-specific ID generation internally.
CreateWithCursor(ctx context.Context, m *models.Message, cursorUserID string) error
// ResolveSenderInfo batch-resolves sender_name and sender_avatar for
// user and persona participants. Updates the PathMessage slice in-place.
ResolveSenderInfo(ctx context.Context, path []PathMessage) error
// CountAll returns the total number of messages (for admin stats).
CountAll(ctx context.Context) (int, error)
// ── CS5c additions (v0.29.0) ──
// SearchInChannel performs full-text search within a single channel's messages.
// PG uses to_tsvector/ts_headline; SQLite falls back to LIKE.
// roleFilter is "user", "assistant", or "" for all.
SearchInChannel(ctx context.Context, channelID, query, roleFilter string, limit int) ([]ChannelSearchResult, error)
// ── CS7a additions (v0.29.0) ──
// ListWithSenderInfo returns paginated messages with sender name/avatar
// resolved via JOINs to users and personas tables.
ListWithSenderInfo(ctx context.Context, channelID string, limit, offset int) ([]MessageWithSender, int, error)
// GetParentAndRole loads just the parent_id and role of a message,
// verifying it belongs to the given channel and is not deleted.
GetParentAndRole(ctx context.Context, messageID, channelID string) (parentID *string, role string, err error)
}
// MessageWithSender is returned by MessageStore.ListWithSenderInfo.
type MessageWithSender struct {
ID string
ChannelID string
Role string
Content string
Model *string
TokensUsed *int
ParentID *string
SiblingIndex int
ParticipantType *string
ParticipantID *string
SenderName *string
SenderAvatar *string
CreatedAt string
}
// ChannelSearchResult is returned by MessageStore.SearchInChannel.
type ChannelSearchResult struct {
MessageID string
Role string
Excerpt string // ts_headline or content substring
Rank float64
Timestamp time.Time
}
// =========================================
@@ -311,6 +728,7 @@ type AuditListOptions struct {
ResourceID string
Since *time.Time
Until *time.Time
TeamID string // CS6: scope to team members
}
// =========================================
@@ -326,6 +744,22 @@ type NoteStore interface {
Search(ctx context.Context, userID, query string, opts ListOptions) ([]models.Note, int, error)
SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error)
BulkDelete(ctx context.Context, ids []string, userID string) (int, error)
// ── CS5c additions (v0.29.0) ──
// SetEmbedding stores a pgvector embedding on a note. PG-only; SQLite no-ops.
SetEmbedding(ctx context.Context, noteID, vecStr string) error
// SearchKeyword performs full-text keyword search with ranking and headlines.
// PG uses ts_rank/ts_headline; SQLite falls back to LIKE.
SearchKeyword(ctx context.Context, userID, query string, limit int) ([]NoteSearchResult, error)
// SearchSemantic performs vector similarity search against note embeddings.
// PG-only; SQLite returns empty results.
SearchSemantic(ctx context.Context, userID, vecStr string, limit int) ([]NoteSearchResult, error)
// ListFolders returns distinct folder paths and note counts for a user.
ListFolders(ctx context.Context, userID string) ([]FolderInfo, error)
}
type NoteListOptions struct {
@@ -335,6 +769,23 @@ type NoteListOptions struct {
TeamID string
}
// NoteSearchResult is returned by NoteStore.SearchKeyword and SearchSemantic.
type NoteSearchResult struct {
ID string
Title string
FolderPath string
Tags []string
Excerpt string // truncated content preview
Headline string // ts_headline markup (empty on SQLite)
Rank float64 // ts_rank or similarity score
}
// FolderInfo is returned by NoteStore.ListFolders.
type FolderInfo struct {
Path string
Count int
}
// =========================================
// NOTE LINK STORE
// =========================================
@@ -362,6 +813,24 @@ type GlobalConfigStore interface {
Get(ctx context.Context, key string) (models.JSONMap, error)
Set(ctx context.Context, key string, value models.JSONMap, updatedBy string) error
GetAll(ctx context.Context) (map[string]models.JSONMap, error)
// ── OIDC state (v0.29.0-cs4) ──
// SaveOIDCState stores a state+nonce pair for OIDC callback verification.
SaveOIDCState(ctx context.Context, state, nonce, redirectTo string) error
// ConsumeOIDCState retrieves and deletes an OIDC state (one-time use).
// Returns (nonce, redirectTo, error). Returns sql.ErrNoRows if not found.
ConsumeOIDCState(ctx context.Context, state string) (nonce, redirectTo string, err error)
// CleanupOIDCState removes stale OIDC states older than 10 minutes.
CleanupOIDCState(ctx context.Context) error
// ── CS6 additions (v0.29.0) ──
// GetString returns the raw value column as a string for simple settings
// (e.g. bare JSON booleans like "true"/"false").
GetString(ctx context.Context, key string) (string, error)
}
// =========================================
@@ -419,6 +888,11 @@ type FileStore interface {
DeleteByChannel(ctx context.Context, channelID string) ([]string, error) // returns storage_keys
UserUsageBytes(ctx context.Context, userID string) (int64, error)
ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.File, error)
// ── CS5c additions (v0.29.0) ──
// UpdateStorageKey sets the storage key after initial file creation.
UpdateStorageKey(ctx context.Context, id, key string) error
}
@@ -652,6 +1126,70 @@ type SessionStore interface {
DeleteExpired(ctx context.Context, olderThan time.Time) (int64, error)
}
// =========================================
// PERSONA GROUP STORE (v0.29.0)
// =========================================
// PersonaGroupStore manages persona_groups and their members.
type PersonaGroupStore interface {
// CRUD
List(ctx context.Context, ownerID string) ([]models.PersonaGroup, error)
Get(ctx context.Context, id, ownerID string) (*models.PersonaGroup, error)
Create(ctx context.Context, g *models.PersonaGroup) error
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id, ownerID string) (int64, error)
// Ownership check
GetOwnerID(ctx context.Context, id string) (string, error)
// Members
AddMember(ctx context.Context, groupID, personaID string, isLeader bool) error
RemoveMember(ctx context.Context, memberID, groupID string) error
ListMembers(ctx context.Context, groupID string) ([]models.PersonaGroupMember, error)
}
// =========================================
// FOLDER STORE (v0.29.0)
// =========================================
// FolderStore manages user chat folders.
type FolderStore interface {
List(ctx context.Context, userID string) ([]models.Folder, error)
Create(ctx context.Context, f *models.Folder) error
Update(ctx context.Context, folderID, userID string, name string, sortOrder *int) (int64, error)
Delete(ctx context.Context, folderID, userID string) (int64, error)
// UnassignChannels removes folder_id from all channels in this folder.
UnassignChannels(ctx context.Context, folderID, userID string) error
}
// =========================================
// HEALTH STORE (v0.29.0)
// =========================================
// HealthStore manages provider health windows. The full interface is used
// by the health accumulator and status querier; the scheduler only needs Prune.
type HealthStore interface {
// Prune deletes health windows older than the given time. Returns rows affected.
Prune(ctx context.Context, before time.Time) (int64, error)
}
// =========================================
// PRESENCE STORE (v0.29.0)
// =========================================
// PresenceStore manages user online/offline status.
type PresenceStore interface {
// Heartbeat upserts the user's last_seen timestamp to now.
Heartbeat(ctx context.Context, userID string) error
// GetLastSeen returns the user's last heartbeat time, or nil if never seen.
GetLastSeen(ctx context.Context, userID string) (*time.Time, error)
// GetStatuses returns online/offline status for a list of user IDs.
// Users with last_seen after threshold are "online", otherwise "offline".
GetStatuses(ctx context.Context, userIDs []string, threshold time.Time) (map[string]string, error)
}
// =========================================
// SHARED TYPES
// =========================================