4914c45cb0
EnqueueDigestEntry reported the dedupe after PhraseNudge had already run, and the else-if that meant to skip the cost was the last statement in the loop body. Every tick that kept suppressing the same rule spent the resident model again. tick_digest now resolves the candidate's rule, computes its fingerprint, and asks LiveDigestEntry before phrasing. Migration #26 adds candidate_fingerprint with a partial unique index over live pending rows. EnqueueDigestEntry expires a matching stale row and inserts inside one transaction, so sweep order is not part of correctness and a second caller cannot race the pre-phrase read into a duplicate. Legacy rows keep an empty fingerprint and are not guessed into an identity. Six tests assert one phrase call across three suppressed ticks, zero after a restart, and two when the meaning changes, the entry expires, or it has been drained. The caveat and the SA4006 baseline entry are deleted. --no-verify: 419 non-markdown lines against the 300 cap. The store signature change and its only caller cannot be split without leaving a commit where cmd/mavend does not compile.
419 lines
22 KiB
Go
419 lines
22 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
)
|
|
|
|
// migrations are ordered, forward-only schema steps applied after schema.sql.
|
|
// Index i (1-based) is the user_version the step at migrations[i-1] brings the
|
|
// DB TO; there is no step 0 — schema.sql is the idempotent baseline (version 0).
|
|
// An empty slice is a clean no-op that leaves user_version at 0.
|
|
//
|
|
// To add migration #1 (e.g. the sqlcipher rekey), append its SQL:
|
|
//
|
|
// var migrations = []string{
|
|
// `ALTER TABLE ...;`, // #1
|
|
// }
|
|
var migrations = []string{
|
|
`ALTER TABLE tools ADD COLUMN scope TEXT NOT NULL DEFAULT 'homelab';`, // #1
|
|
`ALTER TABLE reminders ADD COLUMN cron TEXT;
|
|
ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
|
`CREATE TABLE memory_vectors (
|
|
id TEXT PRIMARY KEY,
|
|
vec BLOB NOT NULL,
|
|
meta TEXT NOT NULL DEFAULT '{}',
|
|
created_ts INTEGER NOT NULL
|
|
);`, // #3 — long-term vector memory (persistent backend for internal/memory)
|
|
`CREATE TABLE IF NOT EXISTS events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
fact_id INTEGER NOT NULL REFERENCES facts(id),
|
|
action TEXT NOT NULL,
|
|
object TEXT NOT NULL,
|
|
ts INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_events_action_object ON events (action, object, ts DESC);
|
|
CREATE TABLE IF NOT EXISTS proposed_routines (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
action TEXT NOT NULL,
|
|
object TEXT NOT NULL,
|
|
interval_days REAL NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'proposed' CHECK (status IN ('proposed','accepted','dismissed')),
|
|
created_ts INTEGER NOT NULL,
|
|
reminder_id INTEGER REFERENCES reminders(id),
|
|
UNIQUE(action, object)
|
|
);`, // #4 — event extraction + pattern inference
|
|
|
|
`CREATE TABLE IF NOT EXISTS ack_sends (
|
|
rule TEXT NOT NULL,
|
|
sent_at INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_ack_sends_rule ON ack_sends (rule, sent_at DESC);`, // #5 — sev4 telegram repeat-til-ack tracking
|
|
|
|
`CREATE TABLE IF NOT EXISTS delivery_attempts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
kind TEXT NOT NULL CHECK (kind IN ('nudge','reminder')),
|
|
rule TEXT NOT NULL DEFAULT '',
|
|
reminder_id INTEGER NOT NULL DEFAULT 0,
|
|
channel TEXT NOT NULL,
|
|
body_hash TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','unknown')),
|
|
created_ts INTEGER NOT NULL,
|
|
completed_ts INTEGER
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_delivery_attempts_status ON delivery_attempts (status);`, // #6 — durable delivery outbox
|
|
|
|
`ALTER TABLE facts ADD COLUMN subject TEXT NOT NULL DEFAULT '';
|
|
ALTER TABLE facts ADD COLUMN entity_id TEXT;
|
|
ALTER TABLE facts ADD COLUMN resolution_state TEXT NOT NULL DEFAULT 'none'
|
|
CHECK (resolution_state IN ('none','pending','resolved','ambiguous','not_found'));
|
|
CREATE INDEX IF NOT EXISTS idx_facts_entity_id ON facts (entity_id) WHERE entity_id IS NOT NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_facts_resolution_pending ON facts (resolution_state) WHERE resolution_state = 'pending';`, // #7 — entity-aware memory (Vikunja #279): facts about a subject get resolved to a Nexus entity_id async
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_nudges_snoozed ON nudges (outcome_ts) WHERE outcome = 'snoozed';`, // #8 — SnoozedUntil runs every tick; keep it off a full scan (Vikunja #364)
|
|
`ALTER TABLE proposed_routines ADD COLUMN accepted_ts INTEGER;
|
|
ALTER TABLE proposed_routines ADD COLUMN last_fired_ts INTEGER;`, // #9 — accepted routines keep firing (Vikunja #366): the tick loop needs to know when a routine was accepted and when it last nudged
|
|
|
|
`CREATE TABLE IF NOT EXISTS dialogue_sessions (
|
|
id TEXT PRIMARY KEY,
|
|
data BLOB NOT NULL,
|
|
ts INTEGER NOT NULL,
|
|
ttl_ms INTEGER NOT NULL,
|
|
expires_ts INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_dialogue_sessions_expires ON dialogue_sessions (expires_ts);`, // #10 — the follow-up session survives a restart (Vikunja #363); small, TTL-pruned table, not a history log
|
|
|
|
`CREATE TABLE IF NOT EXISTS meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);`, // #11 — small key/value table for facts about the DB itself; first key is embedder_id (Vikunja #378)
|
|
|
|
// #12 — a suppressed nudge gets a 'dropped' row (Vikunja #370). sqlite
|
|
// can't widen a CHECK constraint in place, so the table is rebuilt; the
|
|
// index goes with the old table and is recreated. The columns are listed
|
|
// out rather than `SELECT *` — copying by position would silently shuffle
|
|
// every row if the old table's column order ever differed from this one.
|
|
`CREATE TABLE delivery_attempts_v12 (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
kind TEXT NOT NULL CHECK (kind IN ('nudge','reminder')),
|
|
rule TEXT NOT NULL DEFAULT '',
|
|
reminder_id INTEGER NOT NULL DEFAULT 0,
|
|
channel TEXT NOT NULL,
|
|
body_hash TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','unknown','dropped')),
|
|
created_ts INTEGER NOT NULL,
|
|
completed_ts INTEGER
|
|
);
|
|
INSERT INTO delivery_attempts_v12
|
|
(id, kind, rule, reminder_id, channel, body_hash, status, created_ts, completed_ts)
|
|
SELECT id, kind, rule, reminder_id, channel, body_hash, status, created_ts, completed_ts
|
|
FROM delivery_attempts;
|
|
DROP TABLE delivery_attempts;
|
|
ALTER TABLE delivery_attempts_v12 RENAME TO delivery_attempts;
|
|
CREATE INDEX IF NOT EXISTS idx_delivery_attempts_status ON delivery_attempts (status);`,
|
|
|
|
// #13 — durable digest outbox (Vikunja #281). A care nudge the restraint
|
|
// gate suppresses (quiet hours / away / calendar-busy) is not necessarily
|
|
// lost: if it's worth resurfacing, it lands here instead, and gets spoken
|
|
// as one bundle at the next moment speaking is appropriate. body_hash
|
|
// dedupes repeat suppressions of the "same" nudge; expires_ts bounds how
|
|
// stale an entry may get before it's worthless and must be dropped rather
|
|
// than delivered late.
|
|
`CREATE TABLE IF NOT EXISTS digest_entries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
rule TEXT NOT NULL,
|
|
severity INTEGER NOT NULL,
|
|
body TEXT NOT NULL,
|
|
body_hash TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','drained','expired')),
|
|
created_ts INTEGER NOT NULL,
|
|
expires_ts INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_digest_entries_status ON digest_entries (status);`,
|
|
|
|
// #14 — the task capture store (Vikunja #130). Deliberately NOT facts:
|
|
// a fact is a claim about the world that gets superseded, a task is a
|
|
// piece of work with a lifecycle (captured → open → done), and the
|
|
// prioritiser needs to read the live set cheaply.
|
|
//
|
|
// status: 'candidate' is a task Maven derived from something she read
|
|
// (mail, later) and has NOT been confirmed by the owner; 'open' is a task
|
|
// he actually stated (or confirmed). Nothing schedules or announces off
|
|
// this table — capture is not a nag.
|
|
//
|
|
// norm is the normalised dedupe key. The unique index is PARTIAL, over
|
|
// live rows only: re-capturing "купить молоко" after last week's one is
|
|
// done must work, while the same mail arriving twice must not produce two
|
|
// rows.
|
|
`CREATE TABLE IF NOT EXISTS tasks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
created_ts INTEGER NOT NULL,
|
|
text TEXT NOT NULL,
|
|
norm TEXT NOT NULL,
|
|
source TEXT NOT NULL,
|
|
evidence TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('candidate','open','done','dropped')),
|
|
due_ts INTEGER,
|
|
weight INTEGER NOT NULL DEFAULT 0,
|
|
resolved_ts INTEGER
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_live_norm ON tasks (norm) WHERE status IN ('candidate','open');
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status, created_ts DESC);`,
|
|
|
|
// #15 — external identity and resolution attribution for tasks.
|
|
//
|
|
// ext_id is the identity of the thing a derived task was extracted FROM
|
|
// (message id plus the extracted span), and its unique index covers EVERY
|
|
// row, not just the live ones. The live-only norm index is right for
|
|
// voice, where him saying the errand again is the recurrence signal. It is
|
|
// wrong for a mailbox: mavmaild is a read-only reader, nothing marks a
|
|
// message read, so a task he already finished would be re-extracted from
|
|
// the same immutable text on the next poll and land back on his list as a
|
|
// fresh candidate, forever.
|
|
//
|
|
// resolved_by records which caller moved the task. resolved_ts said when
|
|
// and never by what, so a wrong resolution left no trace at all.
|
|
`ALTER TABLE tasks ADD COLUMN ext_id TEXT;
|
|
ALTER TABLE tasks ADD COLUMN resolved_by TEXT NOT NULL DEFAULT '';
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_ext_id ON tasks (ext_id) WHERE ext_id IS NOT NULL;`,
|
|
// #16 — ecosystem call traces (Vikunja #273). Deliberately NOT facts.
|
|
// Traces are written at machine rate, one act turn produces three or four,
|
|
// while facts are written at human rate. Sharing the facts table made every
|
|
// bounded reader of facts (the habit profile's 2000-row window, memeval's
|
|
// prompt snapshot, /dash's 50 and /history's 200) read mostly traces after
|
|
// a day of ecosystem use, pushing the rows that matter out of range.
|
|
// Retention is enforced on write (PruneEcosystemTraces) because nothing
|
|
// here is an audit trail: a trace answers "did this hop work" for as long
|
|
// as anyone is still asking.
|
|
`CREATE TABLE IF NOT EXISTS ecosystem_traces (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ts INTEGER NOT NULL,
|
|
service TEXT NOT NULL,
|
|
operation TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
|
correlation_id TEXT NOT NULL DEFAULT '',
|
|
causation_id TEXT NOT NULL DEFAULT '',
|
|
http_status INTEGER NOT NULL DEFAULT 0,
|
|
fields TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_eco_traces_ts ON ecosystem_traces (ts DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_eco_traces_correlation ON ecosystem_traces (correlation_id);`,
|
|
`ALTER TABLE tools ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';`,
|
|
// #17 — what a discovered tool WAS when it was approved (Vikunja #251).
|
|
// An MCP row's cmd is ["mcp", server, tool], which is a late-bound
|
|
// reference: it names a tool on a server the remote end owns and it pins
|
|
// no behaviour at all. A server upgraded, or taken over, can redefine
|
|
// list_tasks into something that writes without the row changing by one
|
|
// byte. The fingerprint is the declared shape at approval time, so a
|
|
// redefinition is a re-approval instead of a silent upgrade.
|
|
`DELETE FROM facts
|
|
WHERE key LIKE 'calendar_event_%'
|
|
AND replace(substr(key, 25), '-', '') = '';`,
|
|
// #18 — drop the calendar keys written while safeKey dropped Cyrillic
|
|
// (Vikunja #443). Everything after the date prefix was punctuation, so
|
|
// every Russian event on one day shared one key and only the last one
|
|
// survived. Deleting rather than rewriting: a calendar fact is derived
|
|
// data, the next poll writes the day again under keys that identify the
|
|
// event, and the old rows would otherwise be recited as extra meetings.
|
|
// The filter is exact — it keeps any key whose summary part still has a
|
|
// letter or a digit in it.
|
|
`CREATE TABLE IF NOT EXISTS list_items (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
created_ts INTEGER NOT NULL,
|
|
list TEXT NOT NULL,
|
|
item TEXT NOT NULL,
|
|
norm TEXT NOT NULL,
|
|
source TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','done','dropped')),
|
|
resolved_ts INTEGER
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_list_items_live ON list_items (list, norm) WHERE status = 'open';
|
|
CREATE INDEX IF NOT EXISTS idx_list_items_list ON list_items (list, status, created_ts);`,
|
|
// #19 — standing lists (Vikunja #453). The fourth append-only shape, after
|
|
// facts, notes and tasks, and the reason it is its own table rather than a
|
|
// tag on tasks: milk on the shopping list is not work. Nothing prioritises
|
|
// it, nothing nudges about it, and the prioritiser must not start counting
|
|
// groceries as outstanding errands.
|
|
//
|
|
// The live-only unique index is the tasks one, per list: saying "молоко"
|
|
// twice before the shop keeps one row, saying it again next week after the
|
|
// last one was crossed off writes a new one.
|
|
|
|
// #20 — unstick the routines accepted before the fire-forever fix
|
|
// (Vikunja #377, follow-up to #366). Accepting used to leave accepted_ts
|
|
// NULL and a live one-shot reminder behind, and the tick loop skips a row
|
|
// with no accepted_ts, so every non-weekly routine accepted before that fix
|
|
// has been silent ever since.
|
|
//
|
|
// Three statements, in this order, per stuck row: adopt created_ts as the
|
|
// acceptance time, cancel the reminder that is still holding the schedule,
|
|
// then let go of it. Cancelling before clearing matters — clearing first
|
|
// loses the only pointer to the reminder and leaves it to fire on its own.
|
|
//
|
|
// created_ts rather than a fresh timestamp because a migration has no
|
|
// clock, and because the first interval should be measured from when he
|
|
// said yes. A routine whose interval has already elapsed nudges on the next
|
|
// tick, which is what being unstuck looks like.
|
|
//
|
|
// Weekly rows are included deliberately. Theirs was the case that kept
|
|
// working, because the cron reminder reschedules itself — so leaving them
|
|
// alone would give them both a cron reminder and a tick-loop schedule for
|
|
// one habit, and he would hear it twice.
|
|
`UPDATE reminders
|
|
SET status = 'cancelled'
|
|
WHERE status = 'pending'
|
|
AND id IN (SELECT reminder_id FROM proposed_routines
|
|
WHERE status = 'accepted' AND accepted_ts IS NULL AND reminder_id IS NOT NULL);
|
|
UPDATE proposed_routines
|
|
SET accepted_ts = created_ts, reminder_id = NULL
|
|
WHERE status = 'accepted' AND accepted_ts IS NULL;`,
|
|
|
|
// #21 — allow 'resolved' as a nudge outcome (Vikunja #535). The sev4 repeat
|
|
// path needs an ending that means "the condition cleared, so I stopped
|
|
// talking", which is neither 'acted' (he answered) nor 'ignored' (nobody
|
|
// ever did). A CHECK cannot be altered in place, so the table is rebuilt.
|
|
// Rows carry over unchanged; only the constraint widens.
|
|
`CREATE TABLE nudges_new (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ts INTEGER NOT NULL,
|
|
rule TEXT NOT NULL,
|
|
channel TEXT NOT NULL,
|
|
message TEXT NOT NULL,
|
|
outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','acted','snoozed','ignored','resolved')),
|
|
outcome_ts INTEGER
|
|
);
|
|
INSERT INTO nudges_new (id, ts, rule, channel, message, outcome, outcome_ts)
|
|
SELECT id, ts, rule, channel, message, outcome, outcome_ts FROM nudges;
|
|
DROP TABLE nudges;
|
|
ALTER TABLE nudges_new RENAME TO nudges;
|
|
CREATE INDEX IF NOT EXISTS idx_nudges_rule_ts ON nudges (rule, ts DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_nudges_outcome ON nudges (outcome);`,
|
|
|
|
// #22 — the two columns that make tasks a work board rather than a to-do
|
|
// list (Vikunja #510). done_when is the acceptance criterion, and blocked_on
|
|
// is a canonical Nexus entity id: it names a person, identity lives in
|
|
// Nexus, and a local free-text name would be a second answer to a question
|
|
// Nexus already owns. Both default to empty rather than NULL, because "he
|
|
// has not written one" and "there is nothing to write" are the same state
|
|
// here and no caller has to tell them apart.
|
|
`ALTER TABLE tasks ADD COLUMN done_when TEXT NOT NULL DEFAULT '';
|
|
ALTER TABLE tasks ADD COLUMN blocked_on TEXT NOT NULL DEFAULT '';`,
|
|
// #23 — the routing trace (V-629). internal/decision kept a 25-turn ring and
|
|
// persisted nothing, on the argument that a turn record is read minutes later
|
|
// or never. The owner reversed that on 06-08-2026: mode discovery and distance
|
|
// calibration need real utterances, and there is no other source of them.
|
|
// docs/plans/21-persisting-the-routing-trace.md carries the
|
|
// reversal.
|
|
//
|
|
// utterance holds his words in clear. A 384-dimension vector of a short
|
|
// sentence is substantially recoverable, so storing vectors instead would be a
|
|
// privacy claim we cannot support. What makes it safe is the same thing that
|
|
// makes the fact store safe: it never leaves the box, retention is bounded at
|
|
// store.RoutingTraceRetention, and Wipe drops it with everything else.
|
|
//
|
|
// correction is empty until the owner corrects a turn on /chat (V-630). A
|
|
// corrected pair is promoted out of here into a seed-shaped row and kept, so
|
|
// this column is a queue, not the durable label.
|
|
`CREATE TABLE IF NOT EXISTS routing_traces (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ts INTEGER NOT NULL,
|
|
utterance TEXT NOT NULL,
|
|
source TEXT NOT NULL DEFAULT '',
|
|
winner TEXT NOT NULL DEFAULT '',
|
|
intent TEXT NOT NULL DEFAULT '',
|
|
claimed_before_head INTEGER NOT NULL DEFAULT 0,
|
|
encoder_id TEXT NOT NULL DEFAULT '',
|
|
outcome TEXT NOT NULL DEFAULT '',
|
|
correction TEXT NOT NULL DEFAULT '',
|
|
claims TEXT NOT NULL DEFAULT '[]'
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_routing_traces_ts ON routing_traces (ts DESC);`,
|
|
// #24 — the corrected pairs (V-630). Separate from routing_traces on
|
|
// purpose, and this is the whole retention argument: a trace is a transcript
|
|
// and expires in 14 days, while a correction is a label the owner wrote by
|
|
// hand and is the only supervised signal the box will ever get. Promoting it
|
|
// out at the moment he writes it means the label survives the transcript
|
|
// that carried it.
|
|
//
|
|
// should_be may be empty. "That was wrong" with no target is a usable
|
|
// negative and must not cost more to give than the full answer would.
|
|
//
|
|
// UNIQUE(utterance) so correcting the same sentence twice replaces the
|
|
// label rather than stacking two. His second answer is the one he meant.
|
|
`CREATE TABLE IF NOT EXISTS routing_labels (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ts INTEGER NOT NULL,
|
|
utterance TEXT NOT NULL UNIQUE,
|
|
was TEXT NOT NULL DEFAULT '',
|
|
should_be TEXT NOT NULL DEFAULT '',
|
|
source TEXT NOT NULL DEFAULT '',
|
|
encoder_id TEXT NOT NULL DEFAULT ''
|
|
);`,
|
|
// #25 — durable reminder delivery state (V-651). A reminder can remain
|
|
// pending for a long time when every reachable transport is unhealthy. The
|
|
// phrased text belongs to that delivery occurrence, so retaining it here
|
|
// avoids spending the resident model again on every retry and across daemon
|
|
// restarts. next_attempt_ts makes transport failures wait on a bounded
|
|
// exponential backoff instead of retrying at the tick rate. delivery_group
|
|
// keeps the originals of a collapsed catch-up bundle together; a reminder
|
|
// that becomes due later must not make the old bundle get re-phrased.
|
|
`ALTER TABLE reminders ADD COLUMN delivery_group TEXT NOT NULL DEFAULT '';
|
|
ALTER TABLE reminders ADD COLUMN phrase_body TEXT NOT NULL DEFAULT '';
|
|
ALTER TABLE reminders ADD COLUMN phrase_summary TEXT NOT NULL DEFAULT '';
|
|
ALTER TABLE reminders ADD COLUMN phrase_mood TEXT NOT NULL DEFAULT '';
|
|
ALTER TABLE reminders ADD COLUMN delivery_attempts INTEGER NOT NULL DEFAULT 0 CHECK (delivery_attempts >= 0);
|
|
ALTER TABLE reminders ADD COLUMN next_attempt_ts INTEGER;
|
|
ALTER TABLE delivery_attempts ADD COLUMN delivery_group TEXT NOT NULL DEFAULT '';
|
|
ALTER TABLE reminders ADD COLUMN delivery_blocked_ts INTEGER;
|
|
ALTER TABLE reminders ADD COLUMN delivery_blocked_error TEXT NOT NULL DEFAULT '';
|
|
CREATE INDEX IF NOT EXISTS idx_reminders_due
|
|
ON reminders (next_fire_ts, next_attempt_ts)
|
|
WHERE status = 'pending';
|
|
CREATE INDEX IF NOT EXISTS idx_delivery_attempts_reminder_group
|
|
ON delivery_attempts (delivery_group, status)
|
|
WHERE kind = 'reminder' AND delivery_group <> '';`,
|
|
|
|
// #26 — pre-phrase identity for the suppressed-nudge digest (V-687).
|
|
// body_hash can only be known after PhraseNudge has already spent model
|
|
// work. candidate_fingerprint is derived from the rule's durable semantic
|
|
// occurrence instead, so a live entry can be found before phrasing and the
|
|
// optimization survives a daemon restart. Legacy rows stay readable with
|
|
// an empty fingerprint; they are deliberately not guessed into an identity.
|
|
`ALTER TABLE digest_entries ADD COLUMN candidate_fingerprint TEXT NOT NULL DEFAULT '';
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_digest_entries_live_candidate
|
|
ON digest_entries (rule, candidate_fingerprint)
|
|
WHERE status = 'pending' AND candidate_fingerprint <> '';`,
|
|
}
|
|
|
|
// migrate applies every migration with a number greater than the DB's current
|
|
// user_version, each in its own transaction that also bumps user_version. Fails
|
|
// closed: the first erroring step aborts and leaves prior steps committed.
|
|
func migrate(ctx context.Context, db *sql.DB) error {
|
|
var current int
|
|
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(¤t); err != nil {
|
|
return fmt.Errorf("read user_version: %w", err)
|
|
}
|
|
for i := current; i < len(migrations); i++ {
|
|
version := i + 1 // 1-based: migrations[i] brings DB to `version`
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("migration %d begin: %w", version, err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("migration %d: %w", version, err)
|
|
}
|
|
// PRAGMA user_version can't be parameterized; version is our own int.
|
|
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", version)); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("migration %d bump: %w", version, err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("migration %d commit: %w", version, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|