feat(cli): add storage resync-identity-sequences admin subcommand - #1157
Conversation
Wires ResyncPostgresIdentitySequences to an operator entry point so the resync can run after an explicit-id bulk load, before the server resumes default inserts. Connects to storage directly (works with the server down); the DSN comes from --dsn or from the server config, failing closed unless the storage dialect is postgres.
There was a problem hiding this comment.
Pull request overview
Adds an operator-facing CLI entry point to run api.ResyncPostgresIdentitySequences directly against SchemaBot’s PostgreSQL storage database (useful during maintenance when the server is down) to advance IDENTITY sequences after explicit-id bulk loads/restores.
Changes:
- Adds a new top-level
storageCLI command group withresync-identity-sequences. - Implements DSN resolution from
--dsnor a server config file (with$SCHEMABOT_CONFIG_FILEfallback) and enforcespostgresstorage dialect for config-based runs. - Adds unit tests for DSN resolution and integration tests proving the command unblocks default inserts after explicit-id loads.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| pkg/cmd/main.go | Registers the new storage command group in the root CLI. |
| pkg/cmd/commands/storage.go | Implements storage resync-identity-sequences command behavior and DSN resolution. |
| pkg/cmd/commands/storage_test.go | Unit tests for storage DSN resolution behavior and error cases. |
| pkg/cmd/commands/storage_integration_test.go | Integration tests validating end-to-end sequence resync behavior against a real Postgres storage DB. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review follow-ups: refuse to run when no storage tables exist in the target, report the DSN's real source (config, STORAGE_DSN, or MYSQL_DSN), summarize examined/advanced/skipped counts, log to stderr with the running version, trim DSN whitespace, prove the pre-resync duplicate-key collision, and document the operator workflow.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Adversarial correctness review, requested by @aparajon and performed by their agent. Reviewed at head ceda099a, in a worktree, with the unit lane and the new integration tests run locally against a real PostgreSQL, plus a mutation battery over every guard.
Verdict: clean — nothing blocks. This is the best-tested PR in the batch: every guard I tried to disable died, including the ones I expected to be decorative, and the integration test proves the actual story — a real 23505 collision before the resync, id = 4 after. The DSN resolution fails closed at each step and the "this doesn't look like SchemaBot's storage database" guard is the right shape and is pinned. Two things, both about what the operator sees rather than what the command does.
| # | Finding | Severity |
|---|---|---|
| 1 | The confirmation line names a config file, never the database it is about to modify | observability |
| 2 | The sample output shows a line the command no longer prints at default level | doc |
1. The one line before the mutation doesn't say what it's pointed at
logger.Info("resolved storage DSN", "source", source)source is deliberately DSN-free, and that's right — there's a test asserting the password never reaches it, which is exactly the discipline I'd want here. But the consequence is that the only pre-mutation line an operator sees says server config /etc/schemabot/config.yaml or STORAGE_DSN environment variable. Neither answers the question they actually have, which is which database am I about to advance sequences on.
That question is the whole safety story for this command. It runs in a maintenance window, with the server down, typically with more than one storage DSN in play — and the resolution chain is deep enough (--config → $SCHEMABOT_CONFIG_FILE → storage.dsn → storage.dsn_from → STORAGE_DSN → MYSQL_DSN) that "which one won" is genuinely non-obvious. The missing-tables guard catches a target that isn't a storage database at all; it does not catch staging when production was meant, because both pass.
pgx's ParseConfig already gives Host, Port and Database with no credentials attached, so "target", "storage-host:5432/schemabot" alongside the source is one line and turns a source label into a confirmation. Given the command's own docs say "Confirm the target and complete the resync before restarting the server", this is the line that lets an operator do the confirming.
2. The sample output doesn't match what the command prints
The body's sample block shows:
level=INFO msg="advanced identity sequence past stored maximum" table=settings column=id sequence_value=3
That case now logs at Debug, so a default-level run never prints it; and the block omits the identity sequence resync summary line that replaced it. So the one artifact a reviewer or operator would use to know what a successful run looks like shows a line they won't see and hides the line they will.
Worth reconsidering the downgrade itself rather than just fixing the sample. The three sequenceSkipped* cases at Debug is clearly right — they're the no-op majority. sequenceAdvanced is different in kind: it's the record of a mutation, in a one-shot admin command that runs a handful of times per database, not a hot loop. advanced=3 tells an operator that three sequences moved; it doesn't tell them which, or to what, which is the thing they'd want in the terminal scrollback if a default insert still collides afterwards. Keeping sequenceAdvanced at Info and keeping the new summary gives both, at a volume bounded by the number of identity columns in the storage schema.
Also
(nit) The env-var source label reimplements StorageDSN()'s precedence — Storage.DSN == "" && DSNFrom == nil, then STORAGE_DSN, then MYSQL_DSN. I traced both and they agree on every input today, including the whitespace case (StorageDSN would return a whitespace-only STORAGE_DSN, and the trim here catches it). Copilot asked for this and the answer is correct. The durable version is for StorageDSN() to report where it got the value, so the two can't drift; failing that, a comment on each side naming the other, because a divergence here is silent and produces a confidently wrong label on the very line Finding 1 is about.
(nit) --dsn skips the dialect gate that --config enforces. Nothing unsafe follows — pgx won't open a Go MySQL driver DSN, and a wrong PostgreSQL target is caught by the missing-tables guard — but the docs present the postgres-only check as a property of the command, and it holds on one of the two routes.
(nit) resolveStorageDSN splits on cmd.Config == "" to choose api.LoadServerConfig() over LoadServerConfigFromFile(configPath), but configPath has already been resolved from $SCHEMABOT_CONFIG_FILE at that point and LoadServerConfig() just re-reads the same variable. One call to LoadServerConfigFromFile(configPath) covers both branches and removes a second copy of the same resolution.
Action items
- (Finding 1) Log the sanitized target (host, port, database — never credentials) next to the source.
- (Finding 2) Update the sample output, and keep
sequenceAdvancedatInfoso a run records which sequences moved. - (optional) Have
StorageDSN()report its own source; apply the dialect gate to--dsn; collapse the duplicate config-load branch.
Verified — tried to break, couldn't
Every guard is pinned. All six mutations died:
| Mutation | Result |
|---|---|
| the "doesn't look like SchemaBot's storage database" guard never fires | 🔴 TestResyncPostgresIdentitySequences_RejectsTargetWithoutStorageTables (integration) |
| the postgres-dialect gate never fires | 🔴 TestResolveStorageDSN_RejectsNonPostgresStorageDialect |
--dsn and --config stop being mutually exclusive |
🔴 TestResolveStorageDSN_DSNAndConfigAreMutuallyExclusive |
| the env-var source label is never applied | 🔴 TestResolveStorageDSN_ReportsEnvironmentSource |
| the connection check is skipped | 🔴 TestResyncIdentitySequencesCmd_PingFailure |
| the resync itself becomes a no-op | 🔴 both TestResyncIdentitySequencesCmd_* (integration) |
I went in expecting the storage-tables guard to be decorative — it's the kind of check that usually ships untested — and it isn't.
The integration tests prove the behavior, not the plumbing. requireDefaultInsertCollides asserts a real pgconn.PgError with code 23505 before the resync, and requireDefaultInsertResumes asserts the first default insert draws exactly 4 — max+1, not merely "no error". Both the --dsn and --config routes are exercised end to end against a real postgres:16 with the full storage schema bootstrapped by EnsureSchema. That's the whole user-facing claim, tested as the user experiences it.
The schema scoping is consistent across all three queries, which is the failure mode I went looking for. missingPostgresTables and postgresIdentityColumns both filter on table_schema = current_schema(), and advancePostgresIdentitySequence's MAX(...) and pg_get_serial_sequence both resolve unqualified names through the same search_path. So there is no DSN — including one that sets a non-default search_path — where the guard inspects one schema and the resync mutates another.
Credentials can't reach the log. The source label is built from the config path and env var names, never the value, and TestResolveStorageDSN_ConfigResolvesPostgresStorageDSN asserts a distinctive password string is absent from it. Finding 1 asks for more target information, not for the DSN.
The connection handling follows the repo's rules exactly — postgresconn.Open rather than OpenReloadable (correct: this is a short-lived command pool, not the long-lived storage pool), PingContext immediately after open under a bounded 10s timeout so an unreachable target fails promptly, and utils.CloseAndLog on the handle it owns.
The advance-only claim holds at the SQL level. The setval is gated by a WHERE comparing against the sequence's next draw read from the sequence relation — correctly handling both is_called states — so a sequence already ahead returns no row and is left alone, and a descending sequence is skipped before any write. Rerunning is a no-op, which is what makes "safe to rerun" in the docs true rather than aspirational.
Error wrapping got materially better in the same pass. The bare return errs in ResyncPostgresIdentitySequences now carry what was being attempted and which column, so a failure mid-resync names the table and column rather than surfacing a naked driver error.
Copilot's thread was answered properly and resolved — the reply names commit 8cf9e6f4 and the source-detection behavior it describes really is in the tree with a test on it. The nit above is about the duplication that fix introduced, not a re-raise.
Ran locally at head: go build ./..., ./pkg/cmd/... ./pkg/api/... green, and the new integration tests passing against a real container. CI 34/34. No test deletions or weakened assertions. Leak check on the body, diff and docs clean, terminology clean.
This review was generated by Claude Code (claude-opus-5).
An advance mutates production storage; the per-table audit record (table, column, new value) must be visible at default verbosity, not only the summary counts. The text handler choice is documented as deliberate for this one-shot operator command.
|
🤖 Review response — created by Kiran's code review agent (Amp, Claude Opus 4.6) — pull/1157, follow-up commit
|
Adds a
schemabot storage resync-identity-sequencesadmin subcommand that wiresResyncPostgresIdentitySequencesto an operator entry point.Why
PostgreSQL's
GENERATED BY DEFAULT AS IDENTITYaccepts explicit ids without advancing the backing sequence, so after an id-preserving bulk load into the storage database — a cross-dialect data move, or a restore from a dump without sequence state — the next default insert collides with a loaded row. The resync function landed with the storage layer but had no caller an operator could run.What
A new
storagecommand group for operations that connect to SchemaBot's own storage database directly (they work while the server is down, which is exactly when a data-move resync runs). Its first subcommand resolves the storage DSN from--dsn, or from the server config (--config, falling back to$SCHEMABOT_CONFIG_FILE) — the config route fails closed unless the configured storage dialect ispostgres— then runs the advance-only, idempotent resync and logs one outcome per identity column.Before / after
Sample output