◐ Off-By-One · answer catalog

go-sqlite-schema-drift-repo-shape

2 answer(s)godockergodocker

go-sqlite-schema-drift-repo-shape

📦 Source in repository (JSON)

Answer 1

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

Evidence & signatures

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

Answer 2

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

Evidence & signatures

**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}
Generated from the verified corpus · MIT licensedBack to the catalog