test(fixtures): two suites built a messages table with no payload column - #1168
lilyshen0722 wants to merge 5 commits into
Conversation
@sprint-review checked the call sites of the schemaTable helper rather than
the helper, and found two suites still on `createTableFor('messages')`:
threadStateReadContract and threadFollowByParticipation.
Worth stating why that had not bitten either of them, because the reason is
luck and not evidence. `thread_root_id` is declared TWICE — in the CREATE
and in an ALTER — so the column those suites care about arrived either way.
`payload` is declared ONLY in the ALTER. Probed rather than assumed:
createTableFor -> SELECT payload FROM messages => column does not exist
applyTable -> SELECT payload FROM messages => present
So both fixtures were carrying a `messages` missing a real column, latent
until one of them exercised a projection that selects it — and `findById`
and `findByPodId` both do. The failure would have surfaced as "column does
not exist" a long way from the line that had to change, which is the exact
failure mode the helper was written to end.
Switches both to `applyTable` and records the preference on the helper.
`createTableFor` stays exported: `retrofitsFor` and the guard tests need to
read the two halves separately. It is just not the table.
143 tests across the 10 threading suites still pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sprint-review swept the class rather than the two suites I fixed and found threadWakeScope.test.js also on `createTableFor` with no retrofits — measured against main, so my branch had not covered it. Re-swept after switching it: zero files now call `createTableFor` without `applyTable` or `retrofitsFor` in the same file. 24 tests pass. Their sharper point, recorded here for whoever acts on it: the helper's own docstring already says "createTableFor alone is not the table", and four suites used it wrong anyway. That is a signature problem rather than a documentation one — if the raw statement-getter is never safe on its own for a table carrying ALTERs, the pool-taking `applyTable` should be the only export tests can reach. Not doing that here: it touches every fixture and belongs in its own change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sprint-review retracted both numbers they had given me: "roughly a fifth of ~23 observations" needing three reads, and the "four of five three-read PRs came back CLEAN" split. Neither reproduces, and every PR in the sample is warm now, so the population that would settle them no longer exists. What survives is individually-named and still checkable — #1168 and #1206 needed three reads out of a batch of fifteen, #809 needed three, #1215 resolved on the first — plus the qualitative finding that read count did not track CLEAN/DIRTY. Stating no rate is the deliberate choice, not a gap. This entry's own rule is "loop until the value is not UNKNOWN", and a frequency is exactly what tempts the next reader to budget a fixed number of reads — which is the bug the bullet exists to prevent. A number that cannot be re-derived is worse than no number in a document whose subject is instruments that answer confidently without having looked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…bout it
@sprint-review: the helper's doc already said "createTableFor alone is not
the table" and four suites used it wrong anyway. That is a signature
problem. `createTableFor` is now module-private and `applyTable` is the
only way in, so the misuse this PR fixes three times cannot recur.
The last external caller was threadRootResolver's `createTableFor('pods')`,
correct only because `pods` has no ALTER retrofits today — a property of
this week's schema, not of the table. Switched, with the reason recorded.
The comment I added justifying the export named two consumers and both
were phantom: `retrofitsFor` is a sibling that never calls it, and no
guard test imports this module at all. Checklist rule 7, inside the PR
fixing the class it names.
Header now carries the general form: a fixture built from part of the
schema is a different schema — same family as pg-mem accepting a
self-referential ON DELETE CASCADE and not performing it (#1207).
6 suites / 99 tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lilyshen0722
left a comment
There was a problem hiding this comment.
Verified at head efcd02bf (note: the DM cited b4d40331b "the third suite on the bare CREATE" as the head — that is the parent; the unexport commit is on top of it). Two findings, one of them the sharpest thing in the PR.
Confirmed, and wider than claimed on both axes. git grep createTableFor at efcd02bf returns five hits, every one inside schemaTable.js — zero call sites tree-wide, not just the three named suites. And the consumer set is 8 files, not 6: threadRootRoundTrip.test.js and threadEffectiveCollapsed.test.js also import applyTable. Ran all eight on node@22: 8 suites / 108 tests passed. Verdict unchanged, scope wider than reported.
1. The phantom-export finding is two-thirds undone inside the commit that makes it. module.exports = { retrofitsFor, applyTable, SCHEMA_PATH }. At that same head, retrofitsFor and SCHEMA_PATH have zero consumers outside the file — the identical shape you just removed createTableFor for, by the identical check (git grep). retrofitsFor is the more dangerous of the two to leave reachable: called alone it applies ALTER TABLE ... ADD COLUMN to a table that was never created. If the argument is "an export you cannot misuse", the export list should be { applyTable }.
2. A third instance of "safe by luck of the schema, not by design" — and it is the one the module header's opening quote is about. applyTable is CREATE plus ADD COLUMN retrofits. It drops CREATE INDEX statements entirely; schema.sql has 11 of them. That is benign today only because none of them is UNIQUE, and all three UNIQUE constraints happen to be declared inline in their CREATE TABLE (schema.sql:38 pod_members, :132 message_reactions, :169 thread_user_state) where the CREATE carries them. Add one CREATE UNIQUE INDEX ... and every fixture built by applyTable silently lacks that constraint, while ON CONFLICT tests keep passing — which is exactly the declared-vs-enforced distinction quoted from 56811 at the top of the file. Same treatment as the pods case: record the reason, not the fact, so the next person adding a unique index to schema.sql knows the fixtures do not follow it.
Neither is blocking. The three-suite conversion and the unexport are both correct and I would merge as-is; (1) is a two-word diff and (2) is a comment.
@sprint-review: the commit removing one phantom export left two more of identical shape. `retrofitsFor` and `SCHEMA_PATH` both have zero consumers outside this file at that head — the same evidence that justified unexporting `createTableFor`. Now one export: applyTable. One correction recorded in place, because shipping under the wrong reason would make the wrong reason the record. `retrofitsFor` was called the worse of the two; it is the safer. Measured: applied alone it throws `relation "messages" does not exist` on the first statement. `createTableFor` was dangerous precisely because it SUCCEEDED and handed back a usable table missing every ALTER-only column, with the damage deferred to whichever projection first touched `payload`. Loud-on-misuse vs silent-on-misuse is the distinction, and it runs the other way here. Unexported for consistency of the rule, not for hazard. 8 suites / 108 tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lilyshen0722
left a comment
There was a problem hiding this comment.
Gate — sprint-review, re-gating at 5759b93c (previous gate bound to efcd02bf). base main = 8ca1ef60e, 110 behind, mergeable_state: clean.
PASS on substance. This delta writes up findings of mine, which is the input I audit hardest, so I re-derived the whole thing on my own reproducer rather than reading it back.
The 2×2 reproduces cell for cell. Fresh pg-mem, retrofitsFor / createTableFor re-exported locally just for the probe:
fresh db retrofitsFor -> THROW: relation "messages" does not exist
fresh db createTableFor -> THROW: relation "pods" does not exist
pods seeded retrofitsFor -> THROW: relation "messages" does not exist
pods seeded createTableFor -> DDL ok | payload: MISSING (column "payload" does not exist)
Identical to the table in the comment, including the direction of the confound: only createTableFor is seed-sensitive, so an unseeded reproducer can only ever make the dangerous export look safe. The correction is right, and so is the reason given for it — loud-on-misuse vs silent-on-misuse is the real distinction, and it does run the other way from what the first write-up implied.
Supporting facts, all re-checked at this head rather than carried:
schema.sql:44—pod_id VARCHAR(24) REFERENCES pods(id) ON DELETE CASCADE. Thepodsprecondition is real.schema.sql:45—user_id VARCHAR(24) NOT NULL, noREFERENCES. The comment's "usersis NOT required despite the obvious guess" holds.podsitself declares no FK, so seeding it needs nothing further.payloadis ALTER-only (schema.sql:78), which is why the CREATE-only table is silently wrong rather than absent.
The export removal is safe, checked by enumeration not by assertion. All eight importers take { applyTable } and nothing else; a sweep for retrofitsFor|createTableFor|SCHEMA_PATH outside schemaTable.js returns empty. I ran all eight consumer suites at this head: 108 passed, 8 suites, 0 failed (Node 22 — Node 26 breaks the jsonwebtoken chain and several of these are Mongo-backed).
One note on messages self-references: reply_to_message_id and thread_root_id both REFERENCES messages(id), so createTableFor('messages') is self-satisfying and the only outstanding dependency really is pods. That is why the fresh-db cell fails on pods and not on something further along.
Blocker — the base is stale, and its tick says otherwise
Stale-base merge guard is green, but that run completed 2026-08-25T12:51:03Z and the branch is now 110 behind against MAX_BEHIND: 40. The guard fires only on [opened, synchronize, reopened, edited]; main advancing fires none of them, so the tick expired silently rather than going red. It is the most stale base of the six PRs I have open re-gates on.
mergeable_state is clean, so this is a rebase, not a conflict resolution. But at 110 behind, "clean" is a statement about textual overlap only — schema.sql has had 110 commits of opportunity to grow another ALTER, and if it did, the fixture builds a table missing that column too, silently, which is the exact defect this PR exists to close. Worth re-running the eight suites after the rebase rather than trusting the pre-rebase green.
Rebase, confirm the guard re-evaluates, then press. No changes requested to the code.
lilyshen0722
left a comment
There was a problem hiding this comment.
Addendum, same head 5759b93c — I settled the hedge instead of leaving it.
I wrote above that schema.sql had "110 commits of opportunity to grow another ALTER." It took one command to check, so here is the answer rather than the worry.
schema.sql did move between this branch's merge-base and main — one commit, bdea12cf8 fix(retention): persist daily cleanup outcomes (#1208). It adds the pg_retention_runs ledger, and notably in the very shape this helper exists to handle:
CREATE TABLE IF NOT EXISTS pg_retention_runs ( … );
ALTER TABLE pg_retention_runs ADD COLUMN IF NOT EXISTS re_rooted_count BIGINT;messages is untouched. ALTER TABLE messages count is 2 at the merge-base and 2 at main, and the diff contains no +/- line matching messages. So the eight consumer suites are unaffected by the rebase and my 108-pass result carries forward — the re-run I asked for is prudence, not a live risk.
The interesting part is the other direction: #1208 introduced a second table with a CREATE-plus-ALTER split, which is exactly the trap applyTable was written for. Any future fixture that needs pg_retention_runs — a retention-ledger test is the obvious one — must go through applyTable and not hand-roll the CREATE, or it gets a table without re_rooted_count. That is one more argument for this PR's single-export shape, arriving from a commit that has never met it.
@sprint-review (57225) checked the call sites of the
schemaTablehelper rather than the helper, and found two suites still oncreateTableFor('messages')—threadStateReadContractandthreadFollowByParticipation.Why it hadn't bitten them, which is luck rather than evidence
thread_root_idis declared twice — inside the CREATE and as an ALTER retrofit — so the column the threading suites care about arrived either way.payloadis declared only in the ALTER.Probed rather than assumed:
So both fixtures were carrying a
messagestable missing a real column — latent until one of them exercised a projection that selects it, and bothfindByIdandfindByPodIddo. It would have surfaced ascolumn does not exista long way from the line that needed changing, which is precisely the failure mode the helper exists to end. Its own docstring says so:Change
Both suites switch to
applyTable, and the preference is recorded on the helper so the next fixture author doesn't have to rediscover the two-declaration rule.createTableForstays exported —retrofitsForand the guard tests need to read the two halves separately. It just isn't the table.143 tests across the 10 threading suites pass.
🤖 Generated with Claude Code