fix(server): [YW-267] atomic auto-draft dedup — close TOCTOU race#132
Conversation
📝 WalkthroughWalkthroughDeduplication of unmodified draft insights is moved from a client-side early-return in ChangesAtomic auto-draft dedup for createInsight
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes 🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
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 `@apps/server/src/functions/app-artifacts.ts`:
- Around line 805-827: The dedup logic in the createInsight function performs a
full table scan by fetching all insight rows with `.all()` and filtering in
JavaScript by baseTableId nested in the unindexed JSONB definition column. While
this is correct and acceptable at current scale (10-100 insights), add a clear
code comment near the tx.from(insights).all() call explaining this known
scalability limitation, noting that if the insights table grows to 1000+ rows,
consider optimizing by either adding baseTableId as a top-level indexed column
or using Postgres JSONB indexing. This documents the concern for future
maintainers to revisit when scale becomes a bottleneck.
🪄 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
Run ID: d9c55a3d-8589-4980-b543-fc3bc4f57dee
📒 Files selected for processing (6)
apps/server/src/functions/app-artifacts.test.tsapps/server/src/functions/app-artifacts.tspackages/app-data/src/insights.tspackages/app/src/hooks/useCreateInsight.test.tsxpackages/app/src/hooks/useCreateInsight.tspackages/types/src/insights.ts
1fb228a to
25c5a66
Compare
…t createInsight Server wraps check-and-insert in a single transaction; concurrent calls for the same baseTableId converge on one unmodified draft. Client defers dedup to the server and reads existing insights only to compute a numeric suffix for the modified-insight case. Adds skipDedup option so createInsightFromInsight always produces a fresh derived row rather than being silently rerouted to an existing draft. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address review findings: - Replace public skipDedup escape hatch with an opt-in reuseUnmodifiedDraft intent on create. Dedup defaults off (create means create); the auto-draft path opts in, the derived path simply omits it — no internal mechanism leaked onto the public InsightMutations interface. - Extract isUnmodifiedDraft to one shared predicate in @dashframe/types, imported by both the renderer hook and the server dedup gate — no more convention-synced copies that can drift. - Document the full-table-scan constraint: baseTableId lives in a JSONB column with no DB-layer filtering in @wystack/db, so the scan is filtered in JS. Trigger noted to promote baseTableId to an indexed column at scale. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a table already has modified insights, createInsightFromTable computes a
disambiguating suffix name ("orders (2)") for an explicit new draft. Passing
reuseUnmodifiedDraft unconditionally would let the server reroute that call to
an existing unmodified "orders" draft — discarding the suffix and landing the
user on the wrong insight. Gate the flag on !hasModifiedInsights so reuse only
fires on the base-name path; the suffix path always inserts a fresh row.
Also narrow the server-side reuse predicate to the draft-shape fields rather
than the full opts bag (which carries the reuse flag itself).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address Greptile P2 findings (both non-blocking, documentation): - State the dedup transaction's correctness invariant explicitly: it closes the race only while the backend is single-connection (PGlite). Note the trigger and remedy (unique index / SELECT FOR UPDATE) for a future multi-connection backend. - Note that the client suffix-naming path is not server-race-protected — a pre-existing, non-destructive behavior out of scope for this fix — with a trigger to move suffix assignment server-side if it becomes a problem. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
25c5a66 to
9231c4d
Compare
Summary
createInsightcheck-and-insert in a singlectx.db.transactionso two concurrent calls for the samebaseTableIdalways converge on one unmodified draft — no TOCTOU window.if existingDraft navigate + return) fromcreateInsightFromTable; the client now reads existing insights only to compute a gap-free numeric suffix for the modified-insight name, then always delegates to the server.skipDedup: trueoption forcreateInsightFromInsightcallers so derived insights are never silently rerouted to an existing unmodified draft for the samebaseTableId.Tracked internally as YW-267.
Test plan
turbo typecheck— 44/44 tasks passturbo lint— cleanprettier --check— cleanapps/serverapp-artifacts tests — 9/9 pass (4 new dedup contract tests + 1 skipDedup test)packages/appuseCreateInsight tests — 33/33 pass (TOCTOU simulation, derived-insight skipDedup contract)Promise.allfires twocreateInsightcalls for the samebaseTableIdwithout awaiting the first; both resolve to the sameidand exactly one row in the DB🤖 Generated with Claude Code
Greptile Summary
This PR closes the TOCTOU race in auto-draft deduplication by moving the check-and-insert into a single server-side
ctx.db.transaction, and replaces the now-redundant client-side early-return gate with a pure UX read (suffix computation only). TheisUnmodifiedDraftpredicate is extracted to@dashframe/typesas a single source of truth shared by both the renderer hook and the server gate.createInsightwraps the look-up + insert in one transaction; concurrent auto-draft calls for the samebaseTableIdconverge on one row viareuseUnmodifiedDraft: true(opt-in, default is always-insert).createInsightFromTablenow reads existing insights only to compute gap-free numeric suffixes for the modified-insight path; it no longer short-circuits before reaching the server.isUnmodifiedDraft/InsightDraftShapemoved to@dashframe/types, eliminating the previously duplicated client/server logic.Confidence Score: 5/5
Safe to merge. The transaction wrapping is correct for the single-connection PGLite target and the limitations for multi-connection stores are explicitly documented with a revisit trigger.
The change is well-scoped: the server transaction closes the specific TOCTOU window it targets, the client-side gate removal is backed by the server taking over responsibility, the shared isUnmodifiedDraft predicate eliminates the previously noted drift risk, and the opt-in semantics (reuseUnmodifiedDraft defaults to always-insert) mean callers that omit the flag can never accidentally trigger the dedup path. Test coverage is thorough across sequential, concurrent, and edge-case scenarios.
No files require special attention.
Important Files Changed
Reviews (3): Last reviewed commit: "docs(insight): record single-connection ..." | Re-trigger Greptile