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

8.1 KiB
Raw Blame History

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_referencesgrep -r "modernc.org/sqlite" internal/ returns nothing.
  2. a2_no_bare_placeholdersgrep -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_passesgo 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_roundtripspawn 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 · internal/store/store.go, internal/spawn/spawn.go (agent-manager, add-headless-spawn branch)