Documentation
¶
Overview ¶
Package store persists kanban boards, labels, and per-user AI settings in a single SQLite database via modernc.org/sqlite (pure Go, WAL mode).
Every operation is scoped by user. Task lookups accept a unique ID prefix; ErrNotFound and ErrAmbiguous report failed resolution. API keys are stored AES-GCM encrypted with the secret handed to Open.
Index ¶
- Constants
- Variables
- func CompletionWarning(t board.Task) string
- func FtsQuery(raw string) string
- func LoadOrCreateSecret(dataDir string) ([]byte, error)
- func SameAIOrigin(a, b string) bool
- func SanitizeUser(user string) (string, error)
- func Similarity(a, b string) float64
- func ValidateTaskFields(t board.Task) error
- type AISettings
- type BoardSnapshot
- type Comment
- type CompletionBlockedError
- type ForgeSource
- type ImportBaseline
- type ImportLink
- type SimilarHit
- type Store
- func (s *Store) AIKey(user string) (string, error)
- func (s *Store) AISettings(user string) (AISettings, error)
- func (s *Store) AddComment(user, taskRef, author, body string) (Comment, error)
- func (s *Store) AddTask(user string, t board.Task) (board.Task, error)
- func (s *Store) AddTaskWithImportLink(user string, t board.Task, link ImportLink, baseline ImportBaseline) (board.Task, error)
- func (s *Store) Board(user string) (board.Board, error)
- func (s *Store) CancelTask(user, idPrefix string, reason *string) (board.Task, error)
- func (s *Store) Close() error
- func (s *Store) Comments(user, taskRef string) ([]Comment, error)
- func (s *Store) CompareAndSwapImportBaseline(scope, externalKey string, expected, next ImportBaseline) (bool, error)
- func (s *Store) CreateImportBaseline(scope, externalKey string, baseline ImportBaseline) (ImportBaseline, bool, error)
- func (s *Store) DeleteCancelledTask(user, idPrefix string) (board.Task, error)
- func (s *Store) DeleteComment(user string, id int) (Comment, error)
- func (s *Store) DeleteForgeSource(scope, name string) error
- func (s *Store) DeleteTask(user, idPrefix string) (board.Task, error)
- func (s *Store) DeleteTombstone(scope, taskID string) error
- func (s *Store) FilterTasks(user string, f TaskFilter) ([]board.Task, error)
- func (s *Store) ForgePAT(scope, name string) (kind, baseURL, pat string, err error)
- func (s *Store) ForgeSources(scope string) ([]ForgeSource, error)
- func (s *Store) HasBoard(user string) (bool, error)
- func (s *Store) ImportBaseline(scope, externalKey string) (ImportBaseline, bool, error)
- func (s *Store) ImportLinksByLink(scope, link string) ([]ImportLink, error)
- func (s *Store) ImportMarkdownDir(dir string) (int, error)
- func (s *Store) ImportedAs(scope string, externalKeys []string) (map[string]ImportLink, error)
- func (s *Store) Labels(user string) ([]string, error)
- func (s *Store) Link(user, blockerRef, blockedRef string) (blocker, blocked board.Task, err error)
- func (s *Store) ListTasks(user string, status board.Status) ([]board.Task, error)
- func (s *Store) MoveTask(user, idPrefix string, to board.Status) (board.Task, error)
- func (s *Store) ReadBoardSnapshot(user string) (BoardSnapshot, error)
- func (s *Store) RecordImportLinks(scope string, links []ImportLink) error
- func (s *Store) RecordTombstone(scope, taskID, reason string) error
- func (s *Store) ReplaceBoard(user string, b board.Board) error
- func (s *Store) ReplaceBoardWithTaskIDs(user string, b board.Board) ([]string, error)
- func (s *Store) SearchSimilar(scope, query, excludeID string, excludeLinks []string, limit int) ([]SimilarHit, error)
- func (s *Store) SetAISettings(user string, baseURL, model *string, apiKey *string) (keyCleared bool, err error)
- func (s *Store) SetForgeSource(scope, name, kind string, baseURL, pat *string) (tokenCleared bool, err error)
- func (s *Store) SetImportBaseline(scope, externalKey string, baseline ImportBaseline) error
- func (s *Store) Task(user, ref string) (board.Task, error)
- func (s *Store) TaskLinks(user, id string) (TaskLinks, error)
- func (s *Store) TasksByLink(scope, link string) ([]SimilarHit, error)
- func (s *Store) Tombstone(scope, taskID string) (Tombstone, bool, error)
- func (s *Store) Unlink(user, aRef, bRef string) error
- func (s *Store) UpdateAndMoveTask(user, idPrefix string, patch TaskPatch, moveTo *board.Status, index *int, ...) (board.Task, error)
- func (s *Store) UpdateAndMoveTaskIfFieldsMatch(user, idPrefix string, expected, patch TaskPatch, moveTo *board.Status, ...) (board.Task, error)
- func (s *Store) UpdateTask(user, idPrefix string, patch TaskPatch) (board.Task, error)
- func (s *Store) UpdateTaskIfFieldsMatch(user, idPrefix string, expected, patch TaskPatch) (board.Task, error)
- func (s *Store) Users() ([]UserTasks, error)
- type TaskFieldsConflictError
- type TaskFilter
- type TaskLinks
- type TaskPatch
- type Tombstone
- type UserTasks
Constants ¶
const EnvSecretMinBytes = 16
EnvSecretMinBytes is the shortest KB_SECRET worth trusting. It is a warning threshold because refusing the local CLI or MCP process would lock a user out of AI keys already encrypted under a short secret.
const SimilarityFloor = 0.34
SimilarityFloor rejects FTS candidates sharing too little title vocabulary.
Variables ¶
var ( ErrNotFound = errors.New("task not found") ErrAmbiguous = errors.New("ambiguous task id prefix") ErrTaskNotCancelled = errors.New("task is not cancelled") )
Sentinel errors for task ID prefix resolution.
var ErrTombstoneTaskNotCancelled = errors.New("tombstone task is not cancelled")
ErrTombstoneTaskNotCancelled is returned for missing, cross-scope, or active task IDs. Callers must keep those cases indistinguishable.
Functions ¶
func CompletionWarning ¶
CompletionWarning explains why finishing t deserves a second look — open checklist items, a blocked flag, or both — and returns "" when the task is clear to ship. Shared by the CLI and TUI so the refusal reads identically everywhere.
func LoadOrCreateSecret ¶
LoadOrCreateSecret returns the AES secret: the raw bytes of the KB_SECRET environment variable when set and non-empty, otherwise the contents of <dataDir>/secret, which is created with 32 random bytes and mode 0600 (dataDir included, mode 0700) when absent.
A short or empty secret file is an error rather than something to work around. An empty one derives the AES key from SHA-256("") — a key anyone can compute — and silently regenerating instead would orphan every AI key already encrypted under the old secret, so the caller has to decide.
A short KB_SECRET only warns, on stderr and once. Every local entry point shares this path, so the CLI, TUI, and MCP process report the same warning.
func SameAIOrigin ¶
SameAIOrigin reports whether two base URLs share scheme and host (incl. port) — the condition under which a stored API key may be kept across a base-URL change. Unparsable URLs count as a different origin.
Exported because the same rule has to hold for a base URL that is never saved: POST /api/ai/test would otherwise send the stored key to any host a caller names.
func SanitizeUser ¶
func Similarity ¶
Similarity is the Sorensen-Dice coefficient over normalized token sets. It is pure and deterministic so the duplicate threshold can be tested without a database.
func ValidateTaskFields ¶
ValidateTaskFields enforces, for direct task writers such as AddTask and UpdateTask, the field formats a task must satisfy to survive the Markdown codec unchanged: a non-blank title, a real YYYY-MM-DD due date, S/M/L effort, single-token tags without a leading '#', and a single-emoji Emoji. ReplaceBoard deliberately skips these checks: it ingests board.Parse output, which is defined to be tolerant of odd-but-representable values.
Types ¶
type AISettings ¶
AISettings is the client-visible view of a user's AI configuration; the API key itself is never exposed, only whether one is stored.
type BoardSnapshot ¶
BoardSnapshot is one transactionally consistent view of a user's board.
type Comment ¶
type Comment struct {
ID int
TaskID string
TaskSeq int
Author string
Body string
CreatedAt time.Time
}
Comment is one comment on a task. ID is the per-board stable comment number (displayed "c<n>"), assigned once and never reused. TaskSeq is the owning task's stable number, carried for display.
type CompletionBlockedError ¶
type CompletionBlockedError struct {
// contains filtered or unexported fields
}
CompletionBlockedError is the typed refusal for finishing a task that still has open work: open checklist items, a blocked flag, or open blockers. Callers that force the move never see it; local interfaces print it verbatim.
func NewCompletionBlockedError ¶
func NewCompletionBlockedError(reason, ref, title string) *CompletionBlockedError
NewCompletionBlockedError builds the refusal with the standard suffix.
func (*CompletionBlockedError) Error ¶
func (e *CompletionBlockedError) Error() string
type ForgeSource ¶
type ForgeSource struct {
Name string
Kind string
BaseURL string
HasToken bool
CreatedAt time.Time
}
ForgeSource is the client-visible view of a configured forge. The PAT is deliberately represented only by HasToken so list callers cannot expose it.
type ImportBaseline ¶
type ImportBaseline struct{ Title, Hash, Excerpt, At string }
ImportBaseline records what an imported item looked like when last checked.
func NewImportBaseline ¶
func NewImportBaseline(title, body, at string) ImportBaseline
NewImportBaseline hashes the complete body before keeping a bounded, rune-safe excerpt, so exact comparison does not depend on lossy storage.
type ImportLink ¶
type ImportLink struct {
Source string
Kind string
ExternalKey string
Link string
URL string
Title string
}
ImportLink is the durable provenance recorded for an imported forge item.
type SimilarHit ¶
type SimilarHit struct {
ID string
Title string
Status string
Via string
Link string
Reason string
KilledAt string
}
SimilarHit is a cheap card or import-provenance match.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is a SQLite-backed board store. It is safe for concurrent use; the pool is capped at one connection so writers serialize instead of hitting SQLITE_BUSY.
func Open ¶
Open opens (creating if needed) the SQLite database at path, applies pending schema migrations, and prepares the AES-GCM cipher derived from secret (any length; the key is its SHA-256).
func (*Store) AISettings ¶
func (s *Store) AISettings(user string) (AISettings, error)
AISettings returns the user's AI settings; a user with no stored row gets the zero value.
func (*Store) AddComment ¶
AddComment appends a comment to the task matching taskRef (sequence number, UUID, or unique prefix) and returns it with its assigned id.
func (*Store) AddTask ¶
AddTask inserts t for user, assigning a fresh UUID and timestamps and appending it to its column. An empty status defaults to todo; a Prio outside the three-value scale defaults to 3 (low). Field values the markdown wire cannot represent are rejected (see ValidateTaskFields). Labels are upserted from t.Tags.
func (*Store) AddTaskWithImportLink ¶
func (s *Store) AddTaskWithImportLink(user string, t board.Task, link ImportLink, baseline ImportBaseline) (board.Task, error)
AddTaskWithImportLink inserts one task, its provenance, and the previewed upstream baseline in the same transaction. An established baseline wins over a later import of the same provenance key.
func (*Store) Board ¶
Board returns the user's board with tasks ordered by status column order then position. The title defaults to "Board" when none has been stored.
func (*Store) CancelTask ¶
CancelTask soft-deletes a task and optionally records its kill reason in one transaction. The status transition happens before the tombstone insert, as required by the tombstone invariant, but neither write can escape alone.
func (*Store) CompareAndSwapImportBaseline ¶
func (s *Store) CompareAndSwapImportBaseline(scope, externalKey string, expected, next ImportBaseline) (bool, error)
CompareAndSwapImportBaseline updates a baseline only if it still equals the caller's observed value. This is the cross-service drift lock.
func (*Store) CreateImportBaseline ¶
func (s *Store) CreateImportBaseline(scope, externalKey string, baseline ImportBaseline) (ImportBaseline, bool, error)
CreateImportBaseline records the first observed baseline atomically. If a competing caller already recorded one, it returns that winner.
func (*Store) DeleteCancelledTask ¶
DeleteCancelledTask permanently deletes a task only while it is in the Cancelled column. This is the direct-store hard-delete seam used by the TUI: checking the status and removing the row happen in one transaction, so a concurrent restore cannot race an already-confirmed purge.
func (*Store) DeleteComment ¶
DeleteComment removes comment id ("7" or "c7" accepted by callers, parsed to the integer here) and returns it. ErrNotFound when no such comment.
func (*Store) DeleteForgeSource ¶
DeleteForgeSource deletes one scoped source. Deleting a missing source is a successful no-op.
func (*Store) DeleteTask ¶
DeleteTask removes the task matching idPrefix and returns it.
func (*Store) DeleteTombstone ¶
DeleteTombstone removes one scoped graveyard reason when it exists.
func (*Store) FilterTasks ¶
FilterTasks lists the user's tasks matching every condition in f, in board order (columns in status order, tasks by position).
func (*Store) ForgePAT ¶
ForgePAT returns one source and its decrypted PAT. Missing sources and decryption failures return no partial values, and errors never include the URL, ciphertext, or plaintext credential.
func (*Store) ForgeSources ¶
func (s *Store) ForgeSources(scope string) ([]ForgeSource, error)
ForgeSources returns the scope's configured forges ordered by canonical name. It checks only whether ciphertext exists; listing never decrypts a PAT.
func (*Store) HasBoard ¶
HasBoard reports whether the user has any tasks or has ever had a board saved (a stored title, written by ReplaceBoard and the markdown import).
func (*Store) ImportBaseline ¶
func (s *Store) ImportBaseline(scope, externalKey string) (ImportBaseline, bool, error)
ImportBaseline returns the scoped baseline for externalKey when one exists.
func (*Store) ImportLinksByLink ¶
func (s *Store) ImportLinksByLink(scope, link string) ([]ImportLink, error)
ImportLinksByLink returns every scoped provenance row carrying an exact link.
func (*Store) ImportMarkdownDir ¶
ImportMarkdownDir seeds the database from legacy per-user markdown boards. For each <user>.md in dir the board is parsed and inserted, but only when that user has never been imported before and has zero tasks in the database; users with existing tasks are skipped (and marked so they are never reimported either). The original files are left untouched. It returns the number of boards imported.
func (*Store) ImportedAs ¶
ImportedAs returns provenance rows keyed by external key.
func (*Store) Link ¶
Link records "blocker blocks blocked". Both refs accept sequence numbers, UUIDs, or unique prefixes. Self-references, duplicate edges, and edges that would close a cycle are rejected.
func (*Store) ListTasks ¶
ListTasks returns the user's tasks in status then position order; an empty status means all statuses.
func (*Store) MoveTask ¶
MoveTask moves the task matching idPrefix to status to, appending it to that column and stamping MovedAt.
func (*Store) ReadBoardSnapshot ¶
func (s *Store) ReadBoardSnapshot(user string) (BoardSnapshot, error)
ReadBoardSnapshot returns the board, existence flag, canonical task IDs, and revision from one SQLite read transaction. A missing board has the default in-memory title, no IDs, Exists false, and revision zero unless a prior board was deleted (in which case its revision remains monotonic).
func (*Store) RecordImportLinks ¶
func (s *Store) RecordImportLinks(scope string, links []ImportLink) error
RecordImportLinks atomically inserts or refreshes import provenance.
func (*Store) RecordTombstone ¶
RecordTombstone inserts or refreshes the reason a task was killed.
func (*Store) ReplaceBoard ¶
ReplaceBoard replaces the user's whole board and discards the committed task IDs. See ReplaceBoardWithTaskIDs for replacement semantics.
func (*Store) ReplaceBoardWithTaskIDs ¶
ReplaceBoardWithTaskIDs replaces the user's whole board for legacy import compatibility and returns each committed task ID in b.Tasks order. Incoming tasks are matched against existing ones first by (Status, Title), then by Title alone; matches keep their ID and CreatedAt, and a status change stamps MovedAt. Unmatched incoming tasks get fresh UUIDs; existing tasks absent from b are deleted. Positions are recomputed from slice order per status and labels are upserted from all task tags.
func (*Store) SearchSimilar ¶
func (s *Store) SearchSimilar(scope, query, excludeID string, excludeLinks []string, limit int) ([]SimilarHit, error)
SearchSimilar returns scoped card and import-provenance hits ranked by title similarity.
func (*Store) SetAISettings ¶
func (s *Store) SetAISettings(user string, baseURL, model *string, apiKey *string) (keyCleared bool, err error)
SetAISettings patches the user's AI settings; nil fields are left unchanged. A non-nil empty apiKey clears the stored key; a non-empty one is AES-GCM encrypted with the store secret before it is written.
Changing the base URL to a different scheme or host without supplying a key in the same call also clears the stored key (reported via keyCleared): a stored credential must never follow a re-pointed endpoint, or whoever can write settings could route the decrypted key to a host they control.
func (*Store) SetForgeSource ¶
func (s *Store) SetForgeSource(scope, name, kind string, baseURL, pat *string) (tokenCleared bool, err error)
SetForgeSource creates or patches one scoped forge source. A nil baseURL or PAT keeps the stored value. An empty PAT explicitly clears it, while a non-empty PAT is sealed before storage.
A stored credential may not follow a base URL to another origin. When the origin changes without a PAT in the same call, the old ciphertext is cleared atomically and tokenCleared reports that the caller must request a new PAT.
func (*Store) SetImportBaseline ¶
func (s *Store) SetImportBaseline(scope, externalKey string, baseline ImportBaseline) error
SetImportBaseline updates one existing scoped import baseline.
func (*Store) Task ¶
Task fetches one task by reference (sequence number, UUID, or unique prefix) without mutating anything.
func (*Store) TasksByLink ¶
func (s *Store) TasksByLink(scope, link string) ([]SimilarHit, error)
TasksByLink returns cards carrying link as one complete tag.
func (*Store) Unlink ¶
Unlink removes the blocks edge between two tasks, whichever direction it points. ErrNotFound when no edge exists.
func (*Store) UpdateAndMoveTask ¶
func (s *Store) UpdateAndMoveTask(user, idPrefix string, patch TaskPatch, moveTo *board.Status, index *int, guard func(board.Task) error) (board.Task, error)
UpdateAndMoveTask applies patch and then, when moveTo is non-nil, moves the task to that column — both inside a single transaction.
index, when non-nil, is the target slot within the destination column, clamped to [0, column length]; a negative index is an error. With moveTo it replaces the default append; without moveTo it reorders the task inside its current column and leaves MovedAt alone, matching ReplaceBoard, where a task that stays in its status keeps its MovedAt. An index paired with a moveTo naming the column the task already occupies is that same reorder, so a client that always sends the destination column does not reset MovedAt by dragging a card within its column. A patch carrying nothing but an index is a reorder, not an empty update.
guard, when non-nil, is called with the post-patch task after the patch is written but before the move. The ordering is deliberate: a patch can itself be what clears the way for the move (checking off the last checklist item while sending the task to done in one call), so the guard must judge the state the caller is actually asking for, not the state it started from. A non-nil error from guard aborts the transaction, rolling back the patch and any repositioning too: a refused move never leaves a partial update behind.
func (*Store) UpdateAndMoveTaskIfFieldsMatch ¶
func (s *Store) UpdateAndMoveTaskIfFieldsMatch( user, idPrefix string, expected, patch TaskPatch, moveTo *board.Status, index *int, guard func(board.Task) error, ) (board.Task, error)
UpdateAndMoveTaskIfFieldsMatch applies patch and move only when every non-nil expected field still matches. The comparison, patch, guard, and move share one transaction, so a stale modal cannot overwrite a concurrent edit.
func (*Store) UpdateTask ¶
UpdateTask applies patch to the task matching idPrefix and returns the updated task. The merged task must pass ValidateTaskFields. Setting Tags upserts labels.
func (*Store) UpdateTaskIfFieldsMatch ¶
func (s *Store) UpdateTaskIfFieldsMatch(user, idPrefix string, expected, patch TaskPatch) (board.Task, error)
UpdateTaskIfFieldsMatch applies patch only when every non-nil field in expected still matches the stored task. The comparison and patch happen in one transaction. Fields omitted from expected remain mergeable, so a concurrent update to an unrelated field is preserved.
type TaskFieldsConflictError ¶
type TaskFieldsConflictError struct {
Fields []string
}
TaskFieldsConflictError reports task fields that no longer match a caller's expected values. Callers should keep their local edits and refresh the task instead of retrying the stale patch unconditionally.
func (*TaskFieldsConflictError) Error ¶
func (e *TaskFieldsConflictError) Error() string
type TaskFilter ¶
type TaskFilter struct {
// Status keeps one column only.
Status board.Status
// Search is free text matched against title, description, and tags via
// the FTS index: every word must appear, the last one as a prefix.
Search string
// Tags are exact label matches; a task must carry every one (AND).
Tags []string
}
TaskFilter narrows a task listing. The zero value lists every task.
type TaskPatch ¶
type TaskPatch struct {
Emoji, Title, Desc, Due, Effort *string
Prio *int
Blocked *bool
Tags *[]string
Checks *[]board.Check
}
TaskPatch is a partial task update; nil fields are left unchanged.
type UserTasks ¶
SanitizeUser maps a user identity to the safe storage key local interfaces share: lowercased, and any char outside [a-z0-9._@-] is rejected (never substituted — substitution would collapse distinct identities onto the same board). Empty identities, names starting with '.', and over-long names are rejected. Path separators are outside the allowed set, so traversal is impossible. UserTasks pairs a board owner with their task count.