store

package
v1.7.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: MIT Imports: 28 Imported by: 0

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

View Source
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.

View Source
const SimilarityFloor = 0.34

SimilarityFloor rejects FTS candidates sharing too little title vocabulary.

Variables

View Source
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.

View Source
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

func CompletionWarning(t board.Task) string

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 FtsQuery

func FtsQuery(raw string) string

FtsQuery converts untrusted text to a bounded OR of literal FTS phrases.

func LoadOrCreateSecret

func LoadOrCreateSecret(dataDir string) ([]byte, error)

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

func SameAIOrigin(a, b string) bool

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 SanitizeUser(user string) (string, error)

func Similarity

func Similarity(a, b string) float64

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

func ValidateTaskFields(t board.Task) error

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

type AISettings struct {
	BaseURL string
	Model   string
	HasKey  bool
}

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

type BoardSnapshot struct {
	Board    board.Board
	Exists   bool
	TaskIDs  []string
	Revision int64
}

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 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

func Open(path string, secret []byte) (*Store, error)

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) AIKey

func (s *Store) AIKey(user string) (string, error)

AIKey returns the user's decrypted API key, or "" when none is stored.

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

func (s *Store) AddComment(user, taskRef, author, body string) (Comment, error)

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

func (s *Store) AddTask(user string, t board.Task) (board.Task, error)

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 (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

func (s *Store) Board(user string) (board.Board, error)

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

func (s *Store) CancelTask(user, idPrefix string, reason *string) (board.Task, error)

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) Close

func (s *Store) Close() error

Close closes the underlying database.

func (*Store) Comments

func (s *Store) Comments(user, taskRef string) ([]Comment, error)

Comments lists a task's comments oldest-first.

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

func (s *Store) DeleteCancelledTask(user, idPrefix string) (board.Task, error)

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

func (s *Store) DeleteComment(user string, id int) (Comment, error)

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

func (s *Store) DeleteForgeSource(scope, name string) error

DeleteForgeSource deletes one scoped source. Deleting a missing source is a successful no-op.

func (*Store) DeleteTask

func (s *Store) DeleteTask(user, idPrefix string) (board.Task, error)

DeleteTask removes the task matching idPrefix and returns it.

func (*Store) DeleteTombstone

func (s *Store) DeleteTombstone(scope, taskID string) error

DeleteTombstone removes one scoped graveyard reason when it exists.

func (*Store) FilterTasks

func (s *Store) FilterTasks(user string, f TaskFilter) ([]board.Task, error)

FilterTasks lists the user's tasks matching every condition in f, in board order (columns in status order, tasks by position).

func (*Store) ForgePAT

func (s *Store) ForgePAT(scope, name string) (kind, baseURL, pat string, err error)

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

func (s *Store) HasBoard(user string) (bool, error)

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 (s *Store) ImportLinksByLink(scope, link string) ([]ImportLink, error)

ImportLinksByLink returns every scoped provenance row carrying an exact link.

func (*Store) ImportMarkdownDir

func (s *Store) ImportMarkdownDir(dir string) (int, error)

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

func (s *Store) ImportedAs(scope string, externalKeys []string) (map[string]ImportLink, error)

ImportedAs returns provenance rows keyed by external key.

func (*Store) Labels

func (s *Store) Labels(user string) ([]string, error)

Labels returns the user's distinct labels, most recently used first.

func (s *Store) Link(user, blockerRef, blockedRef string) (blocker, blocked board.Task, err error)

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

func (s *Store) ListTasks(user string, status board.Status) ([]board.Task, error)

ListTasks returns the user's tasks in status then position order; an empty status means all statuses.

func (*Store) MoveTask

func (s *Store) MoveTask(user, idPrefix string, to board.Status) (board.Task, error)

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 (s *Store) RecordImportLinks(scope string, links []ImportLink) error

RecordImportLinks atomically inserts or refreshes import provenance.

func (*Store) RecordTombstone

func (s *Store) RecordTombstone(scope, taskID, reason string) error

RecordTombstone inserts or refreshes the reason a task was killed.

func (*Store) ReplaceBoard

func (s *Store) ReplaceBoard(user string, b board.Board) error

ReplaceBoard replaces the user's whole board and discards the committed task IDs. See ReplaceBoardWithTaskIDs for replacement semantics.

func (*Store) ReplaceBoardWithTaskIDs

func (s *Store) ReplaceBoardWithTaskIDs(user string, b board.Board) ([]string, error)

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

func (s *Store) Task(user, ref string) (board.Task, error)

Task fetches one task by reference (sequence number, UUID, or unique prefix) without mutating anything.

func (s *Store) TaskLinks(user, id string) (TaskLinks, error)

TaskLinks returns the tasks id blocks and is blocked by, by exact UUID.

func (s *Store) TasksByLink(scope, link string) ([]SimilarHit, error)

TasksByLink returns cards carrying link as one complete tag.

func (*Store) Tombstone

func (s *Store) Tombstone(scope, taskID string) (Tombstone, bool, error)

Tombstone returns the scoped graveyard reason for taskID when one exists.

func (s *Store) Unlink(user, aRef, bRef string) error

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

func (s *Store) UpdateTask(user, idPrefix string, patch TaskPatch) (board.Task, error)

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.

func (*Store) Users

func (s *Store) Users() ([]UserTasks, error)

Users lists every board owner with their task count, sorted by name. A board that exists only as a saved title (no tasks yet) counts as zero, mirroring HasBoard's definition of existence.

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 TaskLinks struct {
	Blocks    []board.Task
	BlockedBy []board.Task
}

TaskLinks holds one task's outgoing and incoming blocks edges.

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 Tombstone

type Tombstone struct {
	TaskID   string
	Reason   string
	KilledAt string
}

Tombstone records why a task was moved to the cancelled column.

type UserTasks

type UserTasks struct {
	User  string `json:"user"`
	Tasks int    `json:"tasks"`
}

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL