Files
poimen-memory/tasks/M6.3-store-query-port.md
T

161 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)