go-sqlite-schema-drift-repo-shape
The bug: membership/sqlite.go carried a third INSERT shape for memory_group_members — (tenant_id, member_object_type, member_object_id, order_index, scope_policy_id) — that matched no migration. Tests never caught it because they created their own throwaway table instead of the production schema. The fix has three parts, implemented in a self-contained package at ~/membership/:
1. Single source of truth for the mirror schema — schema.go
The canonical SQLite mirror DDL is a package constant; canonicalColumns and legacyColumns are the lock lists:
// CanonicalDDL is the verbatim SQLite mirror schema for memory_group_members.
const CanonicalDDL = `CREATE TABLE IF NOT EXISTS memory_group_members (
membership_id TEXT PRIMARY KEY,
group_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
role TEXT NOT NULL,
order_value INTEGER NOT NULL DEFAULT 0,
join_reason TEXT,
metadata_json TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_mg_group ON memory_group_members (group_id);
CREATE INDEX IF NOT EXISTS idx_mg_memory ON memory_group_members (memory_id);`
var canonicalColumns = [...]string{"membership_id", "group_id", "memory_id", "role",
"order_value", "join_reason", "metadata_json", "created_at"}
var legacyColumns = [...]string{"tenant_id", "member_object_type",
"member_object_id", "order_index", "scope_policy_id"}
2. All 13 queries aligned to canonical columns — sqlite.go
Every statement is a named constant, registered in allQueries (the schema-lock registry). The drifted INSERT became the canonical one:
const qCreate = `INSERT INTO memory_group_members (membership_id, group_id, memory_id,
role, order_value, join_reason, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
// qGetByID, qListByGroup, qListByMemory: SELECT the 8 canonical columns, WHERE group_id/memory_id/membership_id
// qCountByGroup / qCountByMemory: SELECT COUNT(*) WHERE group_id=? / memory_id=?
// qExistsByID: SELECT EXISTS(SELECT 1 ... WHERE membership_id=?)
// qDeleteByID / qDeleteByGroup / qDeleteByMemory: DELETE WHERE ...
// qUpdateRole / qUpdateOrder / qUpdateMetadata: UPDATE ... SET role/order_value/metadata_json = ?
// allQueries enumerates every statement the repository issues (13 total).
var allQueries = [...]string{
qCreate, qGetByID, qListByGroup, qListByMemory, qCountByGroup, qCountByMemory,
qExistsByID, qDeleteByID, qDeleteByGroup, qDeleteByMemory, qUpdateRole, qUpdateOrder, qUpdateMetadata,
}
The constructor applies only CanonicalDDL (via applyDDL), so a repository can never be wired to a throwaway table:
func NewSQLiteRepository(db *sql.DB) (*SQLiteRepository, error) {
if db == nil { return nil, errors.New("membership: nil database") }
if err := applyDDL(db, CanonicalDDL); err != nil { return nil, ... }
return &SQLiteRepository{db: db}, nil
}
Non-memory member types are rejected before any SQL runs, mirroring the postgres twin:
func (r *SQLiteRepository) AddMember(ctx context.Context, memberType MemberType, in AddMemberInput) (*Membership, error) {
if memberType != MemberTypeMemory {
return nil, fmt.Errorf("%w: got %q", ErrUnsupportedMemberType, memberType)
}
...
}
3. canonical_schema_test.go — verbatim mirror DDL, no throwaway tables
mirrorDDL is the mirror DDL copied byte-for-byte; TestCanonicalDDLVerbatim fails if CanonicalDDL drifts from it.TestThirteenCanonicalQueries asserts exactly 13 statements exist and none references a legacyColumns name.TestCreateStatementColumnOrder pins the INSERT column list to the exact canonical order.TestAllThirteenQueriesRunAgainstCanonicalSchema runs all 13 statements against a DB built from the verbatim DDL — a drifted query dies with SQLite's own no such column error.TestAddMemberRejectsNonMemoryTypes and TestDupesAndZeroRows cover the boundary and edge cases.**6/6 tests pass** (`go test -count=1 ./...`, `go vet`, `gofmt` clean):
```
PASS TestCanonicalDDLVerbatim
PASS TestThirteenCanonicalQueries
PASS TestCreateStatementColumnOrder
PASS TestAllThirteenQueriesRunAgainstCanonicalSchema
PASS TestAddMemberRejectsNonMemoryTypes
PASS TestDupesAndZeroRows
ok driftfix/membership
```
**Negative test — the original regression is caught.** I re-injected the exact drifted INSERT (`tenant_id, member_object_type, member_object_id, order_index, scope_policy_id`) into a temp copy and re-ran:
```
--- FAIL: TestThirteenCanonicalQueries
statement references legacy column "tenant_id" (matches no migration): INSERT INTO ...
statement references legacy column "member_object_type" ...
... (all 5 legacy columns flagged)
--- FAIL: TestAllThirteenQueriesRunAgainstCanonicalSchema
AddMember(m-1): membership: insert: SQL logic error: table memory_group_members
has no column named tenant_id (1)
```
So the drift is caught two independent ways: statically (legacy-name scan of all 13 statements) and functionally (the real schema rejects the shape — the exact failure mode the old throwaway-table tests used to hide). Edge cases tested: duplicate PK rejected, `ErrNotFound` on missing-row update/delete, `CURRENT_TIMESTAMP` default rows round-trip, ordering by `order_value`, count/exists correctness, and rejection of `user`/`agent`/`org`/`team`/`external`/`"memory "` member types while accepting `memory`.{"model": "deepseek-v4-flash", "problem_class": "go-sqlite-schema-drift-repo-shape", "result": "passed", "tests": 6}The bug: membership/sqlite.go carried a third INSERT shape for memory_group_members — (tenant_id, member_object_type, member_object_id, order_index, scope_policy_id) — that matched no migration. Tests never caught it because they created their own throwaway table instead of the production schema. The fix has three parts, implemented in a self-contained package at ~/membership/:
1. Single source of truth for the mirror schema — schema.go
The canonical SQLite mirror DDL is a package constant; canonicalColumns and legacyColumns are the lock lists:
// CanonicalDDL is the verbatim SQLite mirror schema for memory_group_members.
const CanonicalDDL = `CREATE TABLE IF NOT EXISTS memory_group_members (
membership_id TEXT PRIMARY KEY,
group_id TEXT NOT NULL,
memory_id TEXT NOT NULL,
role TEXT NOT NULL,
order_value INTEGER NOT NULL DEFAULT 0,
join_reason TEXT,
metadata_json TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_mg_group ON memory_group_members (group_id);
CREATE INDEX IF NOT EXISTS idx_mg_memory ON memory_group_members (memory_id);`
var canonicalColumns = [...]string{"membership_id", "group_id", "memory_id", "role",
"order_value", "join_reason", "metadata_json", "created_at"}
var legacyColumns = [...]string{"tenant_id", "member_object_type",
"member_object_id", "order_index", "scope_policy_id"}
2. All 13 queries aligned to canonical columns — sqlite.go
Every statement is a named constant, registered in allQueries (the schema-lock registry). The drifted INSERT became the canonical one:
const qCreate = `INSERT INTO memory_group_members (membership_id, group_id, memory_id,
role, order_value, join_reason, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
// qGetByID, qListByGroup, qListByMemory: SELECT the 8 canonical columns, WHERE group_id/memory_id/membership_id
// qCountByGroup / qCountByMemory: SELECT COUNT(*) WHERE group_id=? / memory_id=?
// qExistsByID: SELECT EXISTS(SELECT 1 ... WHERE membership_id=?)
// qDeleteByID / qDeleteByGroup / qDeleteByMemory: DELETE WHERE ...
// qUpdateRole / qUpdateOrder / qUpdateMetadata: UPDATE ... SET role/order_value/metadata_json = ?
// allQueries enumerates every statement the repository issues (13 total).
var allQueries = [...]string{
qCreate, qGetByID, qListByGroup, qListByMemory, qCountByGroup, qCountByMemory,
qExistsByID, qDeleteByID, qDeleteByGroup, qDeleteByMemory, qUpdateRole, qUpdateOrder, qUpdateMetadata,
}
The constructor applies only CanonicalDDL (via applyDDL), so a repository can never be wired to a throwaway table:
func NewSQLiteRepository(db *sql.DB) (*SQLiteRepository, error) {
if db == nil { return nil, errors.New("membership: nil database") }
if err := applyDDL(db, CanonicalDDL); err != nil { return nil, ... }
return &SQLiteRepository{db: db}, nil
}
Non-memory member types are rejected before any SQL runs, mirroring the postgres twin:
func (r *SQLiteRepository) AddMember(ctx context.Context, memberType MemberType, in AddMemberInput) (*Membership, error) {
if memberType != MemberTypeMemory {
return nil, fmt.Errorf("%w: got %q", ErrUnsupportedMemberType, memberType)
}
...
}
3. canonical_schema_test.go — verbatim mirror DDL, no throwaway tables
mirrorDDL is the mirror DDL copied byte-for-byte; TestCanonicalDDLVerbatim fails if CanonicalDDL drifts from it.TestThirteenCanonicalQueries asserts exactly 13 statements exist and none references a legacyColumns name.TestCreateStatementColumnOrder pins the INSERT column list to the exact canonical order.TestAllThirteenQueriesRunAgainstCanonicalSchema runs all 13 statements against a DB built from the verbatim DDL — a drifted query dies with SQLite's own no such column error.TestAddMemberRejectsNonMemoryTypes and TestDupesAndZeroRows cover the boundary and edge cases.**6/6 tests pass** (`go test -count=1 ./...`, `go vet`, `gofmt` clean):
```
PASS TestCanonicalDDLVerbatim
PASS TestThirteenCanonicalQueries
PASS TestCreateStatementColumnOrder
PASS TestAllThirteenQueriesRunAgainstCanonicalSchema
PASS TestAddMemberRejectsNonMemoryTypes
PASS TestDupesAndZeroRows
ok driftfix/membership
```
**Negative test — the original regression is caught.** I re-injected the exact drifted INSERT (`tenant_id, member_object_type, member_object_id, order_index, scope_policy_id`) into a temp copy and re-ran:
```
--- FAIL: TestThirteenCanonicalQueries
statement references legacy column "tenant_id" (matches no migration): INSERT INTO ...
statement references legacy column "member_object_type" ...
... (all 5 legacy columns flagged)
--- FAIL: TestAllThirteenQueriesRunAgainstCanonicalSchema
AddMember(m-1): membership: insert: SQL logic error: table memory_group_members
has no column named tenant_id (1)
```
So the drift is caught two independent ways: statically (legacy-name scan of all 13 statements) and functionally (the real schema rejects the shape — the exact failure mode the old throwaway-table tests used to hide). Edge cases tested: duplicate PK rejected, `ErrNotFound` on missing-row update/delete, `CURRENT_TIMESTAMP` default rows round-trip, ordering by `order_value`, count/exists correctness, and rejection of `user`/`agent`/`org`/`team`/`external`/`"memory "` member types while accepting `memory`.{"model": "deepseek-v4-flash", "problem_class": "go-sqlite-schema-drift-repo-shape", "result": "passed", "tests": 6}