Files

161 lines
8.1 KiB
Markdown
Raw Permalink Normal View History

2026-08-19 09:52:07 -07:00
# M6.3 — store.go query port to Postgres
| Field | Value |
|---|---|
| Phase | M6 — agent-manager migration |
| Size | L — 3+ days |
| Status | ⬜ Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | M6.2 |
## Goal
Every query in `internal/store/store.go` rewritten against the M6.2 schema,
sqlite dropped entirely (decided: full port, not a dual sqlite/Postgres
backend — this is single-machine usage, no standalone-without-cluster
requirement to preserve).
## Facts (inlined — no spec read needed)
Read directly from `internal/store/store.go` (1397 lines) on the
`add-headless-spawn` branch:
- **65 call sites** (`db.Exec`, `db.QueryRow`, `db.Query`, `tx.Exec`,
`tx.QueryRow`) use sqlite `?` positional placeholders. Postgres
(`pgx` or `lib/pq`) needs `$1, $2, ...` — mechanical but must be done
per-statement since arg count varies 116 across sites.
- **Upserts are already Postgres-compatible.** All 9 upsert sites
(`SetReviewRepo`, `SetReviewBase`, `SetReviewScope`, `SetSetting`,
`ensureGroup`, `CreateGroup`, `AddGroup`, `createSession`'s group
insert, `PlaceSession`'s group insert) already use `ON
CONFLICT(...) DO UPDATE SET ... = excluded....` / `ON CONFLICT(...) DO
NOTHING`, valid as-is in Postgres. No rewrite beyond the placeholder
swap — this corrects an earlier assumption that these were sqlite
`INSERT OR REPLACE` and needed real rework.
- `boolToInt()`/`archived != 0`-style conversions at ~8 sites go away —
scan `int archived`/`int acked`/`int pendingClaimed`/`int` locals in
`ListSessions`, `Get` and replace with `bool` directly.
- `encodeTime()`/`decodeTime()` calls at every session-timestamp
read/write go away — pass `time.Time` straight through; `pgx` handles
`TIMESTAMPTZ` natively. Delete `timeenc_test.go` along with the
functions it tests (M6.2 already noted its coverage folds into the
schema tests instead).
- `db.SetMaxOpenConns(1)` + `PRAGMA journal_mode=WAL`
(store.go:76-79) gave sqlite serialized writes for free. Some call
sites lean on that implicit serialization — most notably
`createSession`'s `sort_order = (SELECT COALESCE(MAX(sort_order)+1, 0)
FROM sessions WHERE group_name = ? AND parent_id = ?)` subquery, which
races under concurrent Postgres writers with a real connection pool.
Wrap it in `SELECT ... FOR UPDATE` inside the existing transaction (the
function already opens one) rather than trusting single-writer
semantics that no longer exist. Same pattern applies to
`ReorderSession`/`SwapSessionOrder`/`ReorderGroup`/`SwapGroupOrder`'s
read-then-renumber-then-write sequences.
- FK violations are new failure modes M6.2 introduced (group/parent FKs).
`createSession` and `PlaceSession` already validate `group_name`/
`parent_id` through `validParent`/`ensureGroup` before writing, so those
paths shouldn't hit a live constraint in practice — but wrap the actual
`INSERT`/`UPDATE` error and translate a `23503` (foreign_key_violation)
SQLSTATE into the same descriptive errors the pre-validation already
produces, so a race between the check and the write degrades to a clear
error instead of a raw driver error reaching the CLI.
- `driver: modernc.org/sqlite` import and the blank `_ "modernc.org/
sqlite"` in `Open()` are deleted; replaced with `pgx` (`github.com/
jackc/pgx/v5/stdlib` for `database/sql` compatibility, keeping the rest
of the file's `*sql.DB`-based code unchanged) or a native `pgx.Pool` —
pick `pgx/v5/stdlib` unless a later task needs pgx-native features
(e.g. `COPY`), since it's the smaller diff against the existing
`database/sql` code.
## Steps
1. Swap the driver import and `Open()`'s connection string handling
(sqlite file path -> Postgres DSN, likely from an env var or flag the
CLI already has a slot for — check `cmd/` for how `Open()` is called
today).
2. Delete `db.SetMaxOpenConns(1)` / `PRAGMA journal_mode=WAL`; size the
pool deliberately instead (`SetMaxOpenConns` to something sane for a
single-machine client, e.g. 510).
3. Delete `init()`'s `CREATE TABLE IF NOT EXISTS` + 14-migration list —
M6.2's migration owns schema creation now; `Open()` just connects and
optionally runs a `schema_version` sanity check.
4. Mechanically convert every `?` to `$N` across the 65 call sites, in
file order, verifying arg count against placeholder count each time
(this is where an off-by-one is easiest to introduce silently).
5. Remove `boolToInt`/`archived != 0` conversions; scan struct fields
directly as `bool`.
6. Remove `encodeTime`/`decodeTime`/`secondsCeiling`; pass `time.Time`
directly. Delete `timeenc_test.go`.
7. Add `SELECT ... FOR UPDATE` (or equivalent explicit locking) to the
sort-order read-then-write sequences named above.
8. Add FK-violation (`23503`) error translation at the write sites that
can theoretically race past their own pre-validation.
9. Run agent-manager's full existing test suite against a real Postgres
(M6.1's cluster, or local `postgres:16` for iteration) — every current
test should still pass unmodified in intent, only in backing store.
## Acceptance
- Zero references to `modernc.org/sqlite`, `?` placeholders,
`boolToInt`, `encodeTime`/`decodeTime` remain in `internal/store/`.
- Full existing store test suite passes against Postgres.
- Concurrent `CreateSession` calls (simulated) never produce duplicate
`sort_order` values within the same `group_name`/`parent_id`.
- A `spawn` CLI round trip (create session, update status, delete) works
end-to-end against the M6.1 cluster.
## Verify
**Harness:** `internal/store/*_test.go` running against the disposable
Postgres from M6.2's harness.
**Integration test** — extend/rename the existing store test files:
1. `a1_no_sqlite_references` — `grep -r "modernc.org/sqlite" internal/`
returns nothing.
2. `a2_no_bare_placeholders` — `grep -rE '\?[,)]' internal/store/store.go`
inside SQL string literals returns nothing (manual review of any
`?` that's part of a non-SQL string, e.g. a Go format verb, to avoid a
false positive).
3. `a3_existing_suite_passes` — `go test ./internal/store/...` green
against Postgres.
4. `a4_concurrent_sort_order` — spawn N goroutines each calling
`CreateSession` into the same group concurrently; assert the resulting
`sort_order` values are a dense 0..N-1 permutation with no duplicates.
5. `a5_fk_violation_translated` — attempt `PlaceSession` with a
`parentID` deleted between the read and the write (simulate via a
second connection); assert the returned error is the existing
descriptive one, not a raw pgx driver error.
6. `a6_spawn_roundtrip` — `spawn` CLI subcommand creates a session,
`UpdateStatus`, then `Delete`; assert no error and the row is gone.
**Command:** `go test ./internal/... -run TestStore -v`
**False pass:**
- Running the suite against sqlite still (leftover build tag or import)
while believing it validated Postgres. Assertion 1 is the guard —
without it, the whole task could report green while the driver swap
never actually happened.
- Skipping assertion 4. A `sort_order` race is invisible in every
single-threaded test and only shows up as duplicate/out-of-order
sessions under real concurrent use, which is exactly the class of bug
removing `SetMaxOpenConns(1)` introduces.
## Traps
- Converting `?` to `$N` by simple find-and-replace in file order without
re-checking each statement's actual arg list — a multi-arg statement
reordered during earlier edits (e.g. `createSession`'s 15-arg INSERT)
will silently bind the wrong value to the wrong column, and Go's
`database/sql` won't catch a type-compatible mismatch (e.g. two `TEXT`
columns swapped).
- Forgetting `pending_inputs` is now `JSONB` (M6.2), not `TEXT` — the
existing `json.Marshal`/`Unmarshal` round-trip in
`encodePendingInputs`/`pendingInputState` still works unchanged (pgx
scans `JSONB` into `[]byte` the same as `TEXT`), but don't add an extra
marshal layer thinking the column type changed the Go-side contract.
---
Background: [M6.2](M6.2-schema-port.md) · `internal/store/store.go`, `internal/spawn/spawn.go` (agent-manager, `add-headless-spawn` branch)