go-sqlite-schema-mirror-drift
Root cause (two independent bugs stacking on a fresh install):
isNew never true — EnsureSQLiteSchema hardcoded isNew := false, so seedSQLiteDefaults only ran in a branch that was dead code. Fresh DBs had zero rows in memory_groups, so every insert referencing a group died with SQLite extended error 787 = SQLITE_CONSTRAINT_FOREIGNKEY.CREATE TABLE mirror diverged from the goose migrations: memory_entries listed idempotency_key in INSERTs but not in the mirror DDL; memory_groups was missing normalized_tags_json; memory_group_members had shape drift.Fix — remove isNew entirely, make seeding unconditional and idempotent, heal drifted tables in place, and enforce mirror↔INSERT parity by test:
// sqlite.go — boot path, same function the serve handler calls
func EnsureSQLiteSchema(db *sql.DB) error {
if _, err := db.Exec(mirrorTables); err != nil { // CREATE TABLE IF NOT EXISTS
return fmt.Errorf("apply schema mirror: %w", err)
}
// CREATE IF NOT EXISTS never alters an existing table: heal pre-drifted
// databases by adding the columns the old mirror omitted.
if err := ensureColumn(db, "memory_entries", "idempotency_key", "idempotency_key TEXT"); err != nil {
return err
}
if err := ensureColumn(db, "memory_groups", "normalized_tags_json",
"normalized_tags_json TEXT NOT NULL DEFAULT '[]'"); err != nil {
return err
}
if _, err := db.Exec(mirrorIndexes); err != nil { // partial unique index, after columns exist
return fmt.Errorf("apply schema indexes: %w", err)
}
return seedSQLiteDefaults(db) // unconditional; INSERT OR IGNORE is a no-op on existing DBs
}
func seedSQLiteDefaults(db *sql.DB) error {
_, err := db.Exec(
`INSERT OR IGNORE INTO memory_groups (name, normalized_tags_json)
VALUES ('default', '[]'), ('trash', '[]')`)
return err
}
// ensureColumn is a no-op when the column exists, ALTERs when it does not.
func ensureColumn(db *sql.DB, table, column, ddl string) error {
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil { return fmt.Errorf("pragma table_info(%s): %w", table, err) }
defer rows.Close()
for rows.Next() {
var cid, notnull, pk int
var name, ctype string
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { return err }
if name == column { return nil }
}
if err := rows.Err(); err != nil { return err }
if _, err := db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s", table, ddl)); err != nil {
return fmt.Errorf("add column %s.%s: %w", table, column, err)
}
return nil
}
The mirror itself (order matters: tables → column migration → indexes, because a partial index cannot reference a column that doesn't exist on disk yet):
-- mirrorTables
CREATE TABLE IF NOT EXISTS memory_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
normalized_tags_json TEXT NOT NULL DEFAULT '[]', -- was missing from mirror
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS memory_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES memory_groups(id) ON DELETE CASCADE,
content TEXT NOT NULL,
idempotency_key TEXT, -- was missing from mirror
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS memory_group_members (
group_id INTEGER NOT NULL REFERENCES memory_groups(id) ON DELETE CASCADE,
member_id INTEGER NOT NULL REFERENCES memory_entries(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (group_id, member_id)
);
-- mirrorIndexes (applied after ensureColumn)
CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_entries_idempotency_key
ON memory_entries (idempotency_key)
WHERE idempotency_key IS NOT NULL; -- NULL keys stay non-unique
Every INSERT list in sqlite.go was audited against the mirror — memory_entries (group_id, content, idempotency_key), memory_groups (name, normalized_tags_json), memory_group_members (group_id, member_id, role) — and a regex-based parity test (test 6) now fails the build if any INSERT names a column the mirror lacks.
Built a minimal module (`modernc.org/sqlite`, no CGO) containing the buggy snapshot, the fixed code, and the regression suite; ran `go vet`, `go test -race -count=2`. **Bug reproduction — both failure modes confirmed on a nonexistent DB file:** ``` bug #1 error: constraint failed: FOREIGN KEY constraint failed (787) (code=787) bug #2 error: SQL logic error: table memory_entries has no column named idempotency_key (1) (code=1) ``` 787 is exactly SQLite's `SQLITE_CONSTRAINT_FOREIGNKEY` extended code from the incident. **7/7 tests pass, including:** 1. `TestBugRepro_FreshDB_CannotWrite` — reproduces FK-787 (no seeds) and missing-column drift, each as a distinct sub-test proving both root causes. 2. `TestFreshServePath_BootAndWrite` — boots the exact serve path against a file that did **not** exist at open time; asserts 2 seed groups, an insert with `idempotency_key` round-trips, and FK enforcement is still live (unknown `group_id` → 787). 3. `TestDriftedExistingDB_MigratedInPlace` — pre-creates tables with the *old* drifted mirror + a legacy row, then boots: `ALTER TABLE` adds the columns, insert works, legacy row survives, seeds added exactly once (3 groups). 4. `TestReboot_IsIdempotent` — three boots: no seed duplication, no data clobbering (`INSERT OR IGNORE`). 5. `TestPartialUniqueIndex_OnIdempotencyKey` — duplicate non-NULL key rejected; genuine `NULL` keys (passed as `nil`, not `""` — an empty string is a value) are exempt. 6. `TestInsertListsMatchSchemaMirror` — automated audit: 8 INSERT column references cross-checked against mirror DDL; the exact guard that catches `idempotency_key` / `normalized_tags_json` drift at test time. 7. `TestGroupMembers_ShapeAndFK` — `memory_group_members` write path works; FK on `member_id` enforced. **Edge cases handled/verified:** existing DBs with the drifted schema (in-place heal, no data loss), repeated boots (idempotent), NULL idempotency keys (partial-index semantics), FK enforcement on (per-connection `PRAGMA foreign_keys = ON` with `SetMaxOpenConns(1)`), and index creation ordering (index DDL must follow column migration or it fails on pre-drifted tables — caught by my own first test run). Coverage 68.8%; the untested remainder is the `ensureColumn` error branches and the buggy snapshot.
{"model": "deepseek-v4-flash", "problem_class": "go-sqlite-schema-mirror-drift", "result": "passed", "tests": 7}