Guard the threading edge from the replied-to comment, not only the reply (#60)#118
Conversation
…not only the reply The four triggers from #29 all open their WHEN with `NEW.parent_id IS NOT NULL`, so they only ever see the row that is a reply. Updating a comment that *has* replies never touches that column, so nothing objected to `update comments set thread_id = ...` on a root — its replies stayed on the old page pointing at a parent that had left — or to turning a root into a reply, which puts everything under it three levels deep while the replies' own `depth` column still reads 1, so no CHECK notices either. Two more triggers close it from that end, keyed on `EXISTS (... WHERE parent_id = OLD.id)`. The thread guard compares NEW.thread_id with OLD.thread_id rather than reading the replies' own thread_id: cheaper, but chosen for correctness, since the data-driven form would accept or reject a multi-row `UPDATE ... WHERE thread_id = ?` according to the order SQLite happened to visit rows in. The price, recorded in the migration because it lands on whoever builds merge-threads: no ordering of UPDATEs now moves a comment and its replies to another page in one pass. Detach, move, re-attach is the route, and a test holds it open. A cold review of the branch then found a hole neither pair can see: `update comments set parent_id = id, depth = 1` makes a row a reply and a replied-to comment atomically, and both depth guards read the pre-update row, where nothing points at it yet. The row then renders as nothing at all — src/render files it under its parent and emits no root for it. A CHECK constraint answers that, and no trigger has to. Cost, measured rather than argued: D1 reports the same rows_read with the new triggers as without them, on a page with 1 reply and on one with 60. The test pins the instrument first — an update that fires no trigger reads 2 rows, one whose trigger reads a row reads 5 — because "the guards read nothing" and "rows_read cannot see a trigger" otherwise produce the same number. `0001` is amended rather than followed by `0002_`: #16 is open, wrangler.jsonc still carries the placeholder database_id, and there are no tags or releases, so no database has ever had this schema applied. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Coordinator verificationThe vanishing-subtree hole is the find here, and my kill-shot confirms itRemoving the The attack is That is beyond the letter of #60. Including it is right: the migration comment now claims the edge is closed from both ends, and a comment asserting a property the code does not enforce is the specific defect CLAUDE.md warns about — it sits exactly where a reader goes to check. The measurement had a control, which is what makes it a measurementThe cost claim is Pinning the instrument first — a no-trigger update reads 2, a trigger that reads a row reads 5 — is what turns 3-vs-3 into evidence. That is the difference between a benchmark and a number. The departure from the issue's sketch is the right call#60 proposed reading the replies' own The
|
Closes #60.
The four triggers from #29 all open their
WHENwithNEW.parent_id IS NOT NULL, so they only ever see the row that is a reply. Updating a comment that has replies never touches that column, so nothing objected to either half of the mirror image:Two more triggers close it from that end, keyed on
EXISTS (SELECT 1 FROM comments WHERE parent_id = OLD.id).The decision, and why the guard is worth it
The cost is a seek on
comments_by_parentperUPDATEwhoseSETclause names the guarded columns. It is affordable because of what does not reach it:src/dbwrites exactly twoUPDATEs tocomments— moderation (status,moderated_at, on a comment and its replies) and the nightlyip_hashpurge (whole table). Neither namesparent_id,thread_idordepth, soUPDATE OFdoes not fire these triggers at all. The one whole-table statement in the codebase pays nothing.Measured, not argued. D1's
rows_readfor a guarded-but-allowed update:So on the allowed path the guards cost zero extra rows read, and the figure does not move as the page grows.
That measurement needed its own control, because "the guards read nothing" and "
rows_readcannot see a trigger" produce identical numbers and only one is good news.test/worker/db/threading-triggers.test.tsnow pins the instrument first: an update firing no trigger reads 2 rows, an update whose trigger subquery must read the parent row reads 5. The metric does see trigger work, so the 3-vs-3 above means what it says.The price, stated plainly
With these in place there is no ordering of
UPDATEstatements that moves a comment and its replies to another page in one pass — the parent is refused while its replies are behind, and each reply while its parent is. A merge-threads or move-comment feature has to detach the replies (parent_id NULL, depth 0), move every row, then re-attach. That is three steps,2N+1statements, and a test holds that route open so the guards cannot quietly become a frozen table.This is deliberate. The alternative — reading the replies' own
thread_idinstead of comparingNEW.thread_idwithOLD.thread_id, as #60 sketched — would accept or reject a multi-rowUPDATE ... WHERE thread_id = ?according to the order SQLite happened to visit rows in. That is a guard that passes in a test and fails in production.Found during review: a hole neither pair can see
A cold-context adversarial review of the branch constructed this, and I reproduced it:
The row becomes a reply and a replied-to comment atomically. Both depth guards read the pre-update row, where nothing points at it yet; both thread guards are inert; both
depthCHECKs pass; the self-FK is satisfied.src/renderthen files it under its parent and emits no root for it, so the comment and its subtree leave the page in silence.A
CHECK (parent_id IS NULL OR parent_id <> id)answers it, with no trigger and no runtime cost. Slightly beyond the letter of #60, but the migration comment this PR writes claims the edge is closed from both ends, and leaving the hole would have made that comment the exact defect CLAUDE.md warns about.The same review verified these do not get through, with the new triggers in place: a multi-row
set parent_id = case ... endbuilding a 3-deep chain; two roots swapping into a 2-cycle;update comments set thread_id = ? where thread_id = ?;INSERT ... ON CONFLICT(id) DO UPDATE;UPDATE comments SET id = ?(self-FK,NO ACTION);INSERT OR REPLACE(replies cascade-delete rather than strand);UPDATE OR REPLACE ... SET depth = NULL.SQLite semantics, cited
From https://sqlite.org/lang_createtrigger.html (checked 2026-07-25):
Syntactic presence, not a value change. This is why moderation never reaches these triggers, and why a test asserts that writing a comment's existing
thread_idback is allowed — the guard is asked, and has to decide it is harmless.So a rename disarms a guard with no error. Re-confirmed empirically in workerd below (kill-shot 5): the migration applies clean, and only the tests notice.
Also confirmed on this branch: BEFORE triggers run ahead of constraint checking. A depth-only
set depth = 1on a root with replies is rejected by the trigger's message, not the CHECK's. My first draft of the migration comment asserted the opposite ordering; the review caught it, and it is now corrected with a test that pins which mechanism answers.EXPLAIN QUERY PLANcannot be used here — it reports the plan of theUPDATEand says nothing about the trigger subprograms it runs (verified: the plan is unchanged even after droppingcomments_by_parent). That is why the cost evidence isrows_read.Kill-shots (card rule 6)
Each guard disabled in turn,
pnpm vitest run --project worker test/worker/db/threading-triggers.test.ts, then restored.comments_depth_guard_on_update_with_repliesTests 3 failed | 18 passed (21)— refuses to turn a comment that has replies into a reply itself, refuses a depth-only update…, leaves the comment a root when that update is refusedcomments_parent_thread_guard_on_update_with_repliesTests 2 failed | 19 passed (21)— refuses to move a comment that has replies onto another page, leaves the comment and its replies on their own page when the move is refusedCHECK (parent_id IS NULL OR parent_id <> id)Tests 1 failed | 20 passed (21)— refuses to make a comment its own parentdepthfrom the depth guard'sOFlistTests 1 failed | 20 passed (21)— refuses a depth-only update on a comment that has replies, from the triggerthread_id→thread_id_renamed_by_a_future_migrationin theOFlistTests 2 failed | 19 passed (21)— same two as row 2. The migration applied without error; nothing but the tests noticed.And the other half of rule 6 — the guards must not have frozen legitimate work. Every one of these passed in all five kill-shot runs and in the final green run:
status,moderated_at)Amending
0001rather than adding0002_Checked on 2026-07-25, all four still true, so no database anywhere has ever had this schema applied:
wrangler.jsoncstill carries the placeholder"database_id": "00000000-0000-0000-0000-000000000000"git tag -l— emptygh release list— emptyTests read the migrations directly (
readD1Migrationsinvitest.config.ts), so the amended file is what the suite ran against.pnpm checkSkills used:
test-driven-development(every guard here had a failing test first),source-driven-development(the SQLite semantics above),security-and-hardening,performance-optimization(the cost decision),improveandcode-review-and-qualityover the branch — the latter with a cold-context reviewer, which is what found the self-parent hole and the backwards ordering claim — andsuperpowers:verification-before-completion.🤖 Generated with Claude Code