Repository navigation
fix(activity-log): stop writing conversation ids into activity_logs.pageId - #2240
Conversation
…ageId Global Assistant message edits and deletes were passing the conversation id as `pageId`. `conversations` has no relationship to `pages`, so every one of those inserts failed the `activity_logs_pageId_pages_id_fk` constraint and the audit row was dropped — inside the transaction that holds the global `ACTIVITY_CHAIN_LOCK_KEY` advisory lock, burning a chainSeq per failure. Both global route handlers now pass `pageId: null`; conversation linkage was already carried by `aiConversationId` + `metadata.conversationType`, and `pickConversationTable` keys off `conversationType === 'global'` first, so rollback/redo/preview routing is unchanged. `logMessageActivity` accepts `pageId: string | null` and forwards it as undefined, mirroring `driveId`. The FK-retry fallback in `logActivity` never fired: Drizzle throws a "Failed query: ..." wrapper with the pg error under `.cause`, and the guard read `code`/`constraint`/`detail` off the top level only. Extracted `isPageIdForeignKeyError`, which walks a bounded, cycle-safe `.cause` chain. The existing tests passed only because they built flat errors — the new nested-cause cases fail against the old guard. Out of scope, noted for follow-up: `logActivityWithTx` has no FK retry at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AiqgtMFz5y6gJTLn3aLJRd
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (9)
📝 WalkthroughWalkthroughGlobal AI message activity logging now uses ChangesActivity log foreign-key handling
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration. 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 |
…d routing Self-review follow-ups on the FK fix: - `isPageIdForeignKeyError` tracked visited links in a Set to survive cyclic `.cause` chains. The depth cap already bounds the walk, and a repeated link was necessarily checked (and rejected) on its first visit, so the Set could only return false earlier — never change the verdict. Removed; the cyclic test still passes and branch coverage stays at 100%. - Added the `conversationType: 'global'` + no-pageId case to pickConversationTable's tests. The suite covered global-with-pageId and no-type-without-pageId, but not the exact shape these routes now emit. - Moved the new sibling import below the parent imports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AiqgtMFz5y6gJTLn3aLJRd
|
@coderabbitai review |
✅ Action performedReview finished.
|
Findings recorded per the review skill's no-board fallback (no PR/gate page id was supplied for this branch). 0 blockers / 0 majors / 3 nits / 2 informational. The nits are standards conflicts where the surrounding code and the repo-wide skills disagree (ALL_CAPS constants, riteway vs vitest assertions, loop vs recursion); each records the recommendation and why it is deferred rather than changed here. Also documents what was verified clean: OWASP pass, the user-level authorization branch these rows now land in, chainSeq gap semantics, retry re-entrancy, and the rollback/realtime consumer chain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AiqgtMFz5y6gJTLn3aLJRd
…and-built error The unit tests construct the Drizzle wrapper error by hand, so they can only show the predicate matches the shape we *believe* the driver throws. That is precisely the gap that let this bug ship: the previous top-level-only guard passed every hand-built test while never firing in production, and #1319's `detail` fallback was written against the same mistaken shape. These integration tests provoke a real 23503 against the real `activity_logs` table and let the real driver wrap it: - a real page id round-trips and keeps its pageId - a well-formed but absent page id (a page deleted mid-log) still records the activity, dropping only the pageId - a global-assistant delete with no backing page records with pageId null, preserving aiConversationId and metadata.conversationType Verified as a genuine regression test: temporarily restoring the old top-level guard fails the middle case with "expected undefined to be defined" — the audit row is dropped entirely, which is the production bug. CI runs these for real: ci.yml provisions postgres:17-alpine and runs db:migrate before `turbo run test:coverage`, so they do not silently skip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AiqgtMFz5y6gJTLn3aLJRd
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AiqgtMFz5y6gJTLn3aLJRd
The integration test read the row back after `setTimeout(0)`. That yields to the macrotask queue but does not wait on a database round-trip, so the fire-and-forget global-assistant case only passed because the local database answered inside the same tick — it would flake on CI under load. Polls to a 5s budget instead, returning undefined once exhausted so the "row is absent" case stays assertable. The happy path is unchanged at ~100ms; only a genuine failure pays the full budget. Re-verified as a regression test after the change: with the old top-level guard restored, the page-deleted case now fails at 5001ms having never seen the row, and passes in 101ms with the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AiqgtMFz5y6gJTLn3aLJRd
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
Two stacked defects were silently dropping the audit trail for every Global Assistant message edit and delete.
The failing INSERT runs inside the transaction holding
pg_advisory_xact_lock(ACTIVITY_CHAIN_LOCK_KEY)— the global serialization point for all activity logging — and burns achainSeqon every failure.Defect 1 — conversation id written into
activity_logs.pageIdapps/web/src/app/api/ai/global/[id]/messages/[messageId]/route.tspassedpageId: conversationIdin both PATCH and DELETE.conversationIdis validated against theconversationstable, which has no relationship topages, so the FK violation was guaranteed — not a race.Both call sites now pass
pageId: nullwith a corrected comment.logMessageActivityacceptspageId: string | null, mirroring thedriveId: string | nullfield beside it, and forwardsmessage.pageId ?? undefined.A repo-wide grep for
pageId: conversationIdreturned exactly these two lines; every other message call site already passes a real page id and is untouched.pageId: null+driveId: nullis the platform's existing shape for user-level activityThis is not a fallback — the consumers already model it as a first-class case, which I traced end to end:
api/activities/[activityId]/route.ts:64-86) branchespageId→ page-view check,driveId→ drive-member check, elseactivity.userId !== userId→ 403. Global-assistant rows land in that third branch, so the owner can view and roll back their own activity and everyone else gets 403 — fail-closed, no new exposure.api/integrations/providers/install/route.ts:57already writes user-level rows in exactly this shape.pickConversationTable,page-mutation-plan.ts:198-207) computesisGlobalfromconversationType === 'global'before consultinghasPageId, so rollback/redo/preview keep resolving to themessagestable. All three consumers passhasPageId: !!activity.pageId.planMessageRollback) never readspageId; the!activity.pageIdguards inrollback-plans.tsare on page-oriented plans only.broadcastActivityEvent) derives its channels from truthydriveId/pageId, so a user-level row broadcasts to nothing — a graceful no-op, not an error, and already the case for existing user-level rows. The message edit/delete broadcasts on this route (broadcastAiMessageEdited/Deleted) are separate and unaffected, so the chat UI still updates live; only the activity-feed event is absent, which is inherent to user-level activity and out of scope here.Conversation linkage is carried by
aiConversationId+metadata.conversationType: 'global', both already passed today.Defect 2 — the FK-retry fallback never fired in production
logActivityreadcode/constraint/detailoff the top level of the caught error. Drizzle 0.45.2 (drizzle-orm/errors.ts) throws:The wrapper carries no
code,constraint, ordetailof its own — they are only on.cause. So the guard readundefinedfor all three and skipped the retry. The production log confirms it: it printed the no-retry branch, not... after FK retry:.Notably #1319 ("harden FK error detection") already half-diagnosed this — it observed that Drizzle wraps the error, but concluded it "wraps without forwarding
.constraint" and added adetailfallback at the top level. Given the constructor above,detailwasundefinedthere too, so that fallback never fired either. This PR supersedes that fix while keeping thedetailsignal, now evaluated at whatever depth the pg error actually sits.Consequence: the page-deleted-mid-log race the fallback was written for (
ae2cc0a0a) was unhandled for every caller, not just this route.New pure module
packages/lib/src/monitoring/activity-log-errors.tsexportsisPageIdForeignKeyError, which walks a bounded (5-link).causechain — the cap also terminates cyclic chains without tracking visited nodes.logActivitynow calls it; the&& input.pageIdcondition is unchanged.Tests (RED first)
Every test below was written and observed failing before the implementation:
activity-log-errors.test.ts— 16 cases at 100% branch coverage: the exact production nested shape, flat legacy shape, two-level nesting,detailfallback, wrong constraint, wrong code, non-stringdetail,null/undefined/string/plain-object/bare-Error, a cycliccausechain, and past the depth limit.activity-logger.test.ts— added a nested-cause retry case (expected 2, received 1against the old guard — the retry was provably skipped) and apageId: nullforwarding case. The five existing flat-error cases are intact and still pass, since flat errors remain supported.pageId: mockConversationIdfailed after the route fix and were corrected topageId: null.page-mutation-plan.test.ts— added theconversationType: 'global'+ no pageId case. The suite covered global-with-pageId and no-type-without-pageId, but not the exact shape this PR now emits.And an integration test, because hand-built errors are what let this ship
Every unit test above constructs the Drizzle wrapper by hand, so it can only show the predicate matches
the shape we believe the driver throws — exactly the gap that hid this bug for two attempts.
activity-logger-fk-retry.integration.test.tsprovokes a real23503against the realactivity_logstable and lets the real driver wrap it: a real page id round-trips; a well-formed but absent page id
(a page deleted mid-log) still records the activity with only the
pageIddropped; and aglobal-assistant delete records with
pageIdnull while preservingaiConversationIdandmetadata.conversationType.Confirmed to be a genuine regression test: temporarily restoring the old top-level guard fails the
middle case with
expected undefined to be defined— the audit row is dropped entirely, which is theproduction bug reproduced without synthesizing anything.
ci.ymlprovisionspostgres:17-alpineandruns
db:migratebeforeturbo run test:coverage, so these execute in CI rather than skipping.Notes
logActivityWithTxhas no FK retry at all.Validation
bun run typecheck— 16/16 packages passbun run lint— 14/14 passbun run test:unit— lib 9230 passed / 0 failed; web 15704 passed / 0 failed (run withTZ=UTC;src/lib/messages/__tests__/grouping.test.tsholds a pre-existing timezone-dependent case unrelated to this change)bun run --filter '@pagespace/lib' test:coverage— passes the package's gate (branches ≥94) at96.06% statements / 94.84% branches / 88.82% functions. This is what CI's Unit Tests job enforces;
activity-log-errors.tsitself is at 100% on all four metrics, so it lifts rather than dilutes thebranch figure, whose margin over the threshold is thin.
bun x knip— no new findingsmasterwith zero conflicts; see the checks tab for current CI stateA full review of this diff (OWASP pass, consumer-chain verification, and three deferred
standards nits) is recorded at
tasks/reviews/pu-activity-log-fk.md.🤖 Generated with Claude Code
https://claude.ai/code/session_01AiqgtMFz5y6gJTLn3aLJRd
Summary by CodeRabbit
Bug Fixes
Tests