Skip to content

Make saveAttendeeAnswers truly atomic (transactional delete + intern + insert) - #1686

Merged
stefan-burke merged 1 commit into
split-questionsfrom
save-attendee-answers-tx
Jul 10, 2026
Merged

Make saveAttendeeAnswers truly atomic (transactional delete + intern + insert)#1686
stefan-burke merged 1 commit into
split-questionsfrom
save-attendee-answers-tx

Conversation

@stefan-burke

@stefan-burke stefan-burke commented Jul 9, 2026

Copy link
Copy Markdown
Member

Picks up the deferred follow-up from PR #1678's CodeRabbit review (comment #2: "Delete and insert should be atomic").

What this changes

saveAttendeeAnswers replaces every listed attendee's answers — delete the old ones, then insert the new ones. It used to run as two separate committed batches (a DELETE batch, then an INSERT batch), with string interning in between. If the INSERT batch failed after the DELETE had already committed, the attendee was left with no answers until a manual re-save.

Now the whole flow runs inside one interactive write transaction (withTransaction), so it commits or rolls back as a single unit. An INSERT failure rolls the DELETE back; the attendee's prior answers survive.

How

The save runs in five phases, all but the first inside one withTransaction:

  1. Precompute the encrypted string rows (prepareStringRows) — pure CPU: HMAC + hybrid encryption, no IO. Done before opening the transaction so the crypto work doesn't hold the SQLite writer open with no statement running.
  2. DELETE — one IN (...) statement for every attendee at once. SQLite triggers are implicitly FOR EACH ROW (there is no statement-level trigger form), so strings.used_count is decremented once per row regardless of whether the DELETE matches one row or many.
  3. Read answer_id → question_id and which text questions still exist, on the tx so they share the save's snapshot. A question or answer deleted between checkout and finalize is skipped rather than producing an orphan row.
  4. Intern free-text answers via internStringRows(rows, tx) — one batched multi-row INSERT OR IGNORE + refresh created + one read-your-writes SELECT, all on the tx. Fixed 3 round trips regardless of how many unique texts are saved.
  5. INSERT — at most two multi-row VALUES batches: one for every attendee's choice answers, one for every text answer. Batching across attendees keeps the statement count at a handful regardless of attendee count.

Total round-trips for a save is now fixed at ~8 (1 DELETE + 2 reads + 3 intern + ≤2 INSERT), well under the 30-statement transaction round-trip guard.

The delete → intern → insert ordering is preserved inside the transaction: the DELETE's trigger decrements strings.used_count first, then the interning refreshes created on the strings this save re-references, so the pruner sees a consistent snapshot.

strings.ts is split into prepareStringRows (pure CPU) and internStringRows (the DB statements), with getOrCreateStringIds kept as a convenience entry point that does both for the standalone path (e.g. ticket-submit/parse.ts) where the crypto ordering doesn't matter.

Also fixed (review follow-ups)

  • parsing.ts: replaced Number.parseInt(raw, 10) with the repo's shared parsePositiveIntId (Valibot v.digits()-based), which rejects any non-digit string before coercing. Number.parseInt("12xyz", 10) returned 12, so a malformed submission could select a real answer by accident. Added two regression tests.
  • Test util: extracted a shared withPoisonedTransactionExecute to test/test-utils/db-poison.ts (poisoning db.transaction's execute), since saveAttendeeAnswers moved from db.batch to db.transaction + tx.execute and the old withPoisonedBatch no longer reached its writes. Migrated the servicing atomicity tests to it.

Regression tests

  1. Choice rollback: poisons tx.execute to reject the first INSERT INTO attendee_answers mid-save. The attendee's prior choice answer survives — the DELETE rolled back with the failed INSERT.
  2. Free-text rollback: seeds a free-text answer, poisons the INSERT, and asserts the decrypted text survives the rollback via getAttendeeTextAnswers. The choice-only test couldn't catch free-text loss because free-text is interned into the strings table and referenced by string_id.
  3. Malformed answer IDs: "12xyz" and "abc" are both rejected as invalid (previously "12xyz" matched answer 12 via parseInt's prefix parsing).

Verified the rollback tests catch the real bug by temporarily moving the DELETE back to its own committed batch before the transaction (the old non-atomic shape): the tests failed for the right reason, then passed again once the DELETE moved back inside the transaction.

Verification

  • typecheck (including test files): pass
  • lint:ci (strict, read-only): pass
  • cpd (0% threshold): pass
  • Directly-affected test suites: attendee-answers (24 tests), servicing atomicity (5), both custom-questions webhook suites (7), server-attendees (183), parse-question-answers (14), full stripe suite (138) — 371 tests total
  • Mutation testing: deferred — too slow for this environment. The regression tests were verified by hand (temp-break → test fails → restore → test passes), which is the manual equivalent of a mutation run on the changed lines.

Branch context

Based on split-questions (PR #1678). Once that merges to main, this branch can rebase onto main.

Summary by CodeRabbit

  • Bug Fixes

    • Saving attendee answers is now atomic: if an answer update fails, existing answers remain intact.
    • Free-text answers are rolled back correctly when an update cannot be completed.
    • Malformed answer values, such as partially numeric text, are now rejected instead of being incorrectly accepted.
  • Tests

    • Added regression coverage for answer rollback and malformed input handling.

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8900c37f-86db-45df-9f18-adc2e579469a

📥 Commits

Reviewing files that changed from the base of the PR and between 03ec748 and 5388f4b.

📒 Files selected for processing (9)
  • TASK.md
  • src/shared/db/questions/attendee-answers/save.ts
  • src/shared/db/questions/parsing.ts
  • src/shared/db/questions/strings.ts
  • test/lib/parse-question-answers.test.ts
  • test/lib/servicing/atomicity.test.ts
  • test/shared/db/questions/attendee-answers.test.ts
  • test/test-utils.ts
  • test/test-utils/db-poison.ts

📝 Walkthrough

Walkthrough

Attendee answer persistence now performs deletion, reads, string interning, and replacement inserts in one transaction. Transactional rollback tests cover choice and free-text answers. Submitted answer IDs now require strict positive-integer parsing.

Changes

Atomic attendee answer persistence

Layer / File(s) Summary
Transaction-scoped string interning
TASK.md, src/shared/db/questions/strings.ts
String preparation is separated from database execution, and interning supports caller transactions with read-your-writes visibility.
Atomic attendee-answer save pipeline
src/shared/db/questions/attendee-answers/save.ts
saveAttendeeAnswers performs deletion, transaction-scoped reads, string interning, and multi-row inserts within one transaction.
Transactional rollback coverage
test/test-utils/*, test/lib/servicing/atomicity.test.ts, test/shared/db/questions/attendee-answers.test.ts
Shared transaction poisoning and regression tests verify rollback after choice and free-text insert failures.

Strict answer parsing

Layer / File(s) Summary
Strict answer ID validation
src/shared/db/questions/parsing.ts, test/lib/parse-question-answers.test.ts
Malformed and numeric-prefix answer values are rejected instead of being partially parsed as IDs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant saveAttendeeAnswers
  participant TxScope
  participant StringInterning
  participant attendee_answers

  Caller->>saveAttendeeAnswers: submit normalized answers
  saveAttendeeAnswers->>TxScope: start write transaction
  TxScope->>attendee_answers: delete existing answers
  TxScope->>attendee_answers: read answer and question mappings
  TxScope->>StringInterning: intern prepared free-text rows
  StringInterning->>TxScope: execute INSERT, UPDATE, and SELECT
  TxScope->>attendee_answers: insert replacement answers
  TxScope-->>saveAttendeeAnswers: commit or roll back
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: making saveAttendeeAnswers atomic across delete, interning, and insert steps.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch save-attendee-answers-tx

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6e924c9779

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/shared/db/questions/attendee-answers.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/shared/db/questions/attendee-answers.ts`:
- Around line 168-178: The attendee answer cleanup in attendee-answers.ts is
doing one DELETE per attendee inside withTransaction, which adds unnecessary
round trips. Update the delete phase to use a single batched DELETE with an IN
clause (or equivalent one-statement multi-id delete) while keeping the existing
delete → intern → insert order in attendee-answers save flow. Please also verify
the strings.used_count trigger is defined FOR EACH ROW so the consolidated
DELETE still fires per affected row and preserves counting semantics.

In `@src/shared/db/questions/parsing.ts`:
- Around line 36-40: The question answer parsing in parsing.ts is too permissive
because parseInt accepts numeric prefixes like "12xyz" and can match a real
answer by accident. Update the logic around the raw form value, answerId
parsing, and findAnswerById to validate the entire submitted string before
converting it to a number, ideally using the repository’s Valibot helpers, and
reject any malformed input as invalid rather than continuing to lookup an
answer.

In `@test/shared/db/questions/attendee-answers.test.ts`:
- Around line 27-50: The rollback regression currently only seeds a choice
answer in seedColourAttendeeWithRed, so it cannot catch free-text loss. Update
the shared test setup to also create a free-text question and saved answer
alongside the existing Colour?/Red path, then extend the replace/rollback
assertions to verify both the choice answer and the free-text answer still exist
after the forced insert failure using choiceAnswersFor and the shared seed
helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ccd6b40d-bd63-4e88-92af-170b1d898f47

📥 Commits

Reviewing files that changed from the base of the PR and between 2735cb4 and 6e924c9.

📒 Files selected for processing (92)
  • TASK.md
  • biome.json
  • src/features/admin/attendee-form-routes.ts
  • src/features/admin/attendee-page-data.ts
  • src/features/admin/attendees-csv.ts
  • src/features/admin/attendees-merge.ts
  • src/features/admin/calendar.ts
  • src/features/admin/group-page-data.ts
  • src/features/admin/listing-page-data.ts
  • src/features/admin/listings-view.ts
  • src/features/admin/modifiers.ts
  • src/features/admin/questions.ts
  • src/features/api/payment-processing.ts
  • src/features/public/ticket-form.ts
  • src/features/public/ticket-payment.ts
  • src/features/public/ticket-submit/parse.ts
  • src/features/public/ticket-submit/paths.ts
  • src/features/public/ticket-submit/prepare.ts
  • src/features/public/types.ts
  • src/shared/db/attendees/servicing.ts
  • src/shared/db/question-types.ts
  • src/shared/db/questions.ts
  • src/shared/db/questions/aggregates.ts
  • src/shared/db/questions/attendee-answers.ts
  • src/shared/db/questions/delete.ts
  • src/shared/db/questions/parsing.ts
  • src/shared/db/questions/queries.ts
  • src/shared/db/questions/sort-order.ts
  • src/shared/db/questions/strings.ts
  • src/shared/db/questions/tables.ts
  • src/shared/merge/attendee-merge.ts
  • src/shared/qr.ts
  • src/ui/templates/admin/attendee-detail.tsx
  • src/ui/templates/admin/attendee-form.tsx
  • src/ui/templates/admin/attendees.tsx
  • src/ui/templates/admin/questions.tsx
  • src/ui/templates/attendee-table.tsx
  • src/ui/templates/components/question-text.tsx
  • src/ui/templates/public/reservations.tsx
  • test/e2e/n-plus-one-guard.test.ts
  • test/lib/checkout-pricing-consistency.test.ts
  • test/lib/column-order/attendee-answers.test.ts
  • test/lib/csv-questions.test.ts
  • test/lib/db/free-text-migration.test.ts
  • test/lib/db/legacy-migration.test.ts
  • test/lib/db/with-transaction.test.ts
  • test/lib/parse-question-answers.test.ts
  • test/lib/render-questions.test.ts
  • test/lib/server-attendee-form/questions.test.ts
  • test/lib/server-attendees.test.ts
  • test/lib/server-booking-preserve.test.ts
  • test/lib/server-calculate.test.ts
  • test/lib/server-groups/attendees.test.ts
  • test/lib/server-listing-export-checkin.test.ts
  • test/lib/server-listings/export.test.ts
  • test/lib/server-listings/show-groups-and-answers.test.ts
  • test/lib/server-misc-admin-handlers.test.ts
  • test/lib/server-modifiers/scope-links.test.ts
  • test/lib/server-parents-gate.test.ts
  • test/lib/server-public/custom-questions-multi.test.ts
  • test/lib/server-public/custom-questions-single.test.ts
  • test/lib/server-questions/answer-edit.test.ts
  • test/lib/server-questions/answers.test.ts
  • test/lib/server-questions/helpers.ts
  • test/lib/server-questions/listing-questions.test.ts
  • test/lib/server-questions/question-delete.test.ts
  • test/lib/server-questions/questions.test.ts
  • test/lib/server-webhooks/custom-questions-multi.test.ts
  • test/lib/server-webhooks/custom-questions-single.test.ts
  • test/lib/servicing/atomicity.test.ts
  • test/lib/servicing/custom-questions.test.ts
  • test/lib/test-utils/stubs-and-mocks.test.ts
  • test/lib/test-utils/temp-db-files.test.ts
  • test/lib/ticket-form.test.ts
  • test/shared/db/listings/delete.test.ts
  • test/shared/db/modifier-resolve.test.ts
  • test/shared/db/questions/attendee-answers.test.ts
  • test/shared/db/questions/crud.test.ts
  • test/shared/db/questions/helpers.ts
  • test/shared/db/questions/listing-mapping.test.ts
  • test/shared/db/questions/with-listings.test.ts
  • test/shared/merge/attendee-merge/apply.test.ts
  • test/shared/merge/attendee-merge/diff.test.ts
  • test/shared/merge/attendee-merge/helpers.ts
  • test/test-utils.ts
  • test/test-utils/db-poison.ts
  • test/test-utils/db.ts
  • test/test-utils/factories.ts
  • test/test-utils/temp-db-files.ts
  • test/ui/templates/admin/attendee-detail.test.ts
  • test/ui/templates/admin/attendees.test.tsx
  • test/ui/templates/components/question-text.test.tsx
💤 Files with no reviewable changes (2)
  • biome.json
  • src/shared/db/questions.ts

Comment thread src/shared/db/questions/attendee-answers.ts Outdated
Comment thread src/shared/db/questions/parsing.ts
Comment thread test/shared/db/questions/attendee-answers.test.ts
@stefan-burke
stefan-burke force-pushed the save-attendee-answers-tx branch from 6e924c9 to 8cbf9f0 Compare July 9, 2026 22:11

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8cbf9f00c2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/shared/db/questions/attendee-answers.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/shared/db/questions/strings.ts`:
- Around line 93-113: The `strings.ts` interning path is still issuing one
`INSERT OR IGNORE` per text inside `runInternStatements`, which causes extra
transactional round trips. Update the statement construction in this section so
the `rows.map(...)` insertions are combined into a single multi-row `INSERT OR
IGNORE` statement, matching the batched shape used by the non-transactional path
and the pattern already applied in `attendee-answers.ts`. Keep the `UPDATE
strings` and `SELECT id, text_index` statements as-is, but ensure the insert
batch still feeds `runInternStatements` correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e4a4ab09-8153-4d44-99ee-636f702e63d1

📥 Commits

Reviewing files that changed from the base of the PR and between 6e924c9 and 8cbf9f0.

📒 Files selected for processing (8)
  • TASK.md
  • src/features/admin/attendees-merge.ts
  • src/shared/db/questions/attendee-answers.ts
  • src/shared/db/questions/strings.ts
  • test/lib/servicing/atomicity.test.ts
  • test/shared/db/questions/attendee-answers.test.ts
  • test/test-utils.ts
  • test/test-utils/db-poison.ts

Comment thread src/shared/db/questions/strings.ts
@stefan-burke
stefan-burke force-pushed the save-attendee-answers-tx branch 2 times, most recently from f2a8c16 to 03ec748 Compare July 9, 2026 22:25

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/lib/parse-question-answers.test.ts`:
- Around line 171-173: Update the numeric-prefix regression test for
parseQuestionAnswers to include both radio(1) and radio(10) in the available
answers, ensuring the prior parseInt("10xyz", 10) behavior would incorrectly
match answer 10 and the test meaningfully detects the regression.

In `@test/test-utils/db-poison.ts`:
- Around line 16-39: Update withPoisonedTransactionExecute to avoid mutating the
shared getDb() singleton during parallel tests: use an isolated database
instance for the transaction/execute hooks, or mark all tests using this helper
as serial. Ensure cleanup restores any modified state and prevents concurrent
tests from observing the poison behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: cbb1de05-76f8-4d94-accd-83b589343355

📥 Commits

Reviewing files that changed from the base of the PR and between 8cbf9f0 and f2a8c16.

📒 Files selected for processing (10)
  • TASK.md
  • src/features/admin/attendees-merge.ts
  • src/shared/db/questions/attendee-answers.ts
  • src/shared/db/questions/parsing.ts
  • src/shared/db/questions/strings.ts
  • test/lib/parse-question-answers.test.ts
  • test/lib/servicing/atomicity.test.ts
  • test/shared/db/questions/attendee-answers.test.ts
  • test/test-utils.ts
  • test/test-utils/db-poison.ts

Comment thread test/lib/parse-question-answers.test.ts
Comment thread test/test-utils/db-poison.ts
@stefan-burke

Copy link
Copy Markdown
Member Author

Replying to each open thread — all addressed in the latest commit (03ec748).

Codex P1 (batch attendee-answer writes inside the transaction) — Fixed. The per-attendee DELETE loop is now one DELETE ... WHERE attendee_id IN (...) statement, and the per-attendee INSERTs are collapsed into at most two multi-row VALUES batches (one for every attendee's choice answers, one for every text answer), regardless of attendee count. The string interning INSERT OR IGNORE statements are likewise batched into one multi-row statement (see thread on strings.ts). Total round-trips for a save is now fixed: 1 DELETE + 2 reads + 3 intern + at most 2 INSERT = ~8, well under the 30-statement guard.

Codex P2 (precompute free-text encryption before opening the transaction) — Fixed. strings.ts is split into prepareStringRows (pure CPU: HMAC + hybrid encryption, no IO) and internStringRows (the DB statements). saveAttendeeAnswers now calls prepareStringRows before withTransaction, so the crypto work no longer holds the SQLite writer open with no statement running. Only the actual INSERT/UPDATE/SELECT land on the tx.

CodeRabbit (batch the per-attendee DELETE) — Fixed as above; confirmed the strings.used_count trigger is AFTER DELETE ON attendee_answers (SQLite triggers are implicitly FOR EACH ROW — there is no statement-level trigger form), so consolidating to an IN (...) DELETE preserves per-row firing semantics.

CodeRabbit (reject malformed answer IDs in parsing.ts) — Fixed. Replaced Number.parseInt(raw, 10) with the repo's shared parsePositiveIntId (Valibot v.digits()-based), which rejects any non-digit string — so "12xyz" is now invalid rather than matching answer 12. Added two regression tests (numeric-prefix rejection, non-numeric rejection).

CodeRabbit (include a free-text answer in the rollback regression) — Fixed. Added a dedicated test that seeds a free-text answer, poisons the INSERT, and asserts the decrypted text survives the rollback via getAttendeeTextAnswers — the choice-only test couldn't catch free-text loss because free-text is interned into the strings table and referenced by string_id.

All non-mutation precommit steps pass: typecheck (incl. test files), lint:ci, cpd (0%), and the directly-affected suites (attendee-answers, servicing atomicity, both webhook suites, server-attendees, parse-question-answers, full stripe suite — 250 tests).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/shared/db/questions/strings.ts (1)

118-151: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider collapsing the intern sequence into one INSERT ... ON CONFLICT(text_index) DO UPDATE SET created = excluded.created RETURNING id, text_index. This keeps the timestamp refresh and id lookup in a single round trip.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shared/db/questions/strings.ts` around lines 118 - 151, Refactor
internStringRows to replace the separate INSERT, timestamp UPDATE, and SELECT
statements with one INSERT ... ON CONFLICT(text_index) DO UPDATE SET created =
excluded.created RETURNING id, text_index statement. Build values for all rows,
execute it through runInternStatements, extract the returned rows, and continue
passing them to pairStringIds.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/shared/db/questions/strings.ts`:
- Around line 118-151: Refactor internStringRows to replace the separate INSERT,
timestamp UPDATE, and SELECT statements with one INSERT ... ON
CONFLICT(text_index) DO UPDATE SET created = excluded.created RETURNING id,
text_index statement. Build values for all rows, execute it through
runInternStatements, extract the returned rows, and continue passing them to
pairStringIds.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 788012ac-7a9c-4805-a6be-de71ae777cd3

📥 Commits

Reviewing files that changed from the base of the PR and between f2a8c16 and 03ec748.

📒 Files selected for processing (10)
  • TASK.md
  • src/features/admin/attendees-merge.ts
  • src/shared/db/questions/attendee-answers.ts
  • src/shared/db/questions/parsing.ts
  • src/shared/db/questions/strings.ts
  • test/lib/parse-question-answers.test.ts
  • test/lib/servicing/atomicity.test.ts
  • test/shared/db/questions/attendee-answers.test.ts
  • test/test-utils.ts
  • test/test-utils/db-poison.ts

Closes the deferred follow-up from PR #1678's CodeRabbit review: a
save that failed during the INSERT phase left the attendee with no
answers (the DELETE had already committed in its own batch).

saveAttendeeAnswers now wraps its whole body in one withTransaction:
the DELETE, the in-between reads (answer→question, which text
questions still exist), the string interning, and the INSERT all run
as one interactive write transaction, committing or rolling back
together.

To make that possible, getOrCreateStringIds now accepts an optional
TxScope — when given, its INSERT OR IGNORE + refresh created +
read-your-writes SELECT each run on the caller's open transaction via
tx.execute (instead of a separate executeBatchWithResults batch), so
the SELECT still sees the rows the INSERT just wrote in the same
transaction. The standalone path (no tx) is unchanged.

The read helpers (questionIdsByAnswerId, existingQuestionIds) are
replaced with tx-scoped variants so they share the save's snapshot
rather than starting their own read transactions.

Regression test: a poisoned tx.execute on INSERT INTO attendee_answers
rejects the save mid-flow; the attendee's prior answers survive (the
DELETE rolled back), not empty. Verified the test catches the original
bug by temporarily moving the DELETE before the transaction — the test
fails for the right reason, then passes once restored.

The servicing atomicity tests (withPoisonedBatch in atomicity.test.ts)
are migrated to a shared withPoisonedTransactionExecute test util, since
saveAttendeeAnswers moved from db.batch to db.transaction + tx.execute
and the old batch-poison no longer reached its writes.

Rebased onto origin/main to pick up the stripe-mock harness isolation
fix (#1676).

Mutation testing deferred — too slow for this environment; all other
precommit steps pass: typecheck (incl. test files), lint:ci, cpd (0%
threshold), and the directly-affected test suites (attendee-answers,
servicing atomicity, both custom-questions webhook suites,
server-attendees, the full stripe suite).
@stefan-burke
stefan-burke force-pushed the save-attendee-answers-tx branch from 03ec748 to 5388f4b Compare July 10, 2026 00:41
@stefan-burke
stefan-burke merged commit 107fc04 into split-questions Jul 10, 2026
3 checks passed
@stefan-burke
stefan-burke deleted the save-attendee-answers-tx branch July 10, 2026 00:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant