// Package store is maven's persistent state layer. // // The layer is append-only: a wrong fact is superseded, not overwritten. // Current value for a key = the latest non-voided fact row. // // Schema lives in schema.sql and is applied idempotently on Open. package store import ( "context" "database/sql" _ "embed" // schema.sql "errors" "fmt" "time" _ "modernc.org/sqlite" ) //go:embed schema.sql var schemaSQL string // FactKind — self | env | config. The loop only evaluates predicates against // `self` and `config` rows; `env` feeds env predicates (calendar/weather/health). type FactKind string const ( KindSelf FactKind = "self" KindEnv FactKind = "env" KindConfig FactKind = "config" ) // Fact — one observation. ts is valid-time (true-as-of), not insert-time. type Fact struct { ID int64 Ts time.Time Kind FactKind Key string Value string // raw json if structured Source string // tap:* | infer:* | poll:* | ambient | promote | feedback Confidence float64 VoidsID sql.NullInt64 // Subject, EntityID, ResolutionState — entity-aware memory (Vikunja #279). // Subject is the free-text "who/what this fact is about" supplied at write // time by WriteFactAboutSubject; empty means the fact isn't about a // resolvable entity (ResolutionState stays "none"). EntityID is the // canonical Nexus entity_id once the enrichment worker resolves Subject. Subject string EntityID sql.NullString ResolutionState FactResolutionState } // FactResolutionState — where a fact's Subject stands in Nexus entity // resolution. "none" = no subject given (most facts). "pending" = subject // given, not yet resolved. Terminal states: "resolved", "ambiguous" (Nexus // returned candidates, not stored — matches the ecosystem's ambiguity-blocks // invariant), "not_found". type FactResolutionState string const ( ResolutionNone FactResolutionState = "none" ResolutionPending FactResolutionState = "pending" ResolutionResolved FactResolutionState = "resolved" ResolutionAmbiguous FactResolutionState = "ambiguous" ResolutionNotFound FactResolutionState = "not_found" ) // Bucket — presence hysteresis state. type Bucket string const ( Present Bucket = "present" Away Bucket = "away" ) // Store is the persistent state layer. All writes are append-only; nothing // here performs an UPDATE of a fact value (status-flips on reminders/nudges // are the documented exceptions — they mutate small state-machine columns). type Store struct { db *sql.DB enc *encState // nil ⇒ plaintext store (dev/CI); set ⇒ re-encrypt on Close } // openAt opens or creates the sqlite database at path and applies schema + // migrations. This is the raw plaintext open used by both Open (plaintext // store) and OpenEncrypted (the tmpfs working copy). // // Pragmas (WAL, NORMAL, FK on, busy_timeout) are set in schema.sql and re-applied // per connection on open via the modernc driver DSN. func openAt(ctx context.Context, path string) (*sql.DB, error) { // `_pragma=busy_timeout(5000)` etc. embed cleanly; schema.sql sets them too. dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)", path) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open %s: %w", path, err) } // single writer expected; the daemon is the only process touching the db. db.SetMaxOpenConns(1) if _, err := db.ExecContext(ctx, schemaSQL); err != nil { if closeErr := db.Close(); closeErr != nil { return nil, fmt.Errorf("apply schema: %w (close: %v)", err, closeErr) } return nil, fmt.Errorf("apply schema: %w", err) } if err := migrate(ctx, db); err != nil { if closeErr := db.Close(); closeErr != nil { return nil, fmt.Errorf("migrate: %w (close: %v)", err, closeErr) } return nil, fmt.Errorf("migrate: %w", err) } return db, nil } // Open opens or creates a PLAINTEXT sqlite database at path. Used by tests and // by any deployment that keeps the db unencrypted (CI, dev). Production goes // through OpenEncrypted. func Open(ctx context.Context, path string) (*Store, error) { db, err := openAt(ctx, path) if err != nil { return nil, err } return &Store{db: db}, nil } // Close checkpoints, releases the database handle, and — for an encrypted // store — re-encrypts the tmpfs working copy back to the on-disk ciphertext // file atomically, then wipes the plaintext copy and zeroes the key. func (s *Store) Close() error { if s.enc == nil { return s.db.Close() } return s.enc.closeAndSeal(s.db) } // DB exposes the underlying handle for internal read-only snapshots. // Used by the loop to take a consistent read under a single transaction. // Modules never receive this handle — core mediates. func (s *Store) DB(ctx context.Context) (*sql.Tx, error) { return s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) } var ( // ErrNoFact — no non-voided row exists for this key. ErrNoFact = errors.New("store: no fact for key") // ErrConfidence — a write attempted to use a confidence outside (0,1.0]. ErrConfidence = errors.New("store: confidence must be in (0.0, 1.0]") // ErrVoidsMissing — a correction pointed at a nonexistent fact. ErrVoidsMissing = errors.New("store: voids_id does not reference an existing fact") )