From 238c8d57ea0d0260c47d2264d415824d8ec5d18e Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sat, 5 Sep 2026 19:40:28 -0500 Subject: [PATCH 1/3] feat(tags): wire re-anchoring into the page mutation transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hard prerequisite the epic says must land before any write path ships. Phase 3 built `reanchorPageTags` and left it with no caller, which is safe only while nothing writes tags — forward-porting is the PRIMARY anchoring mechanism, and without this hook every edit falls through to quote repair. That is the accuracy floor rather than the target, and it decays in the wrong direction: actively-edited pages would shed their tags while stale pages kept theirs. `applyPageMutation` is the only possible call site. `previousContent` and `nextContent` exist together in exactly one transaction in the codebase, and this is it. The sweep runs on `transaction`, beside `syncMentions`, under the same `updates.content !== undefined` condition — so anchors commit with the content they describe, rather than porting to a revision that then rolls back or leaving a committed page with only some of its anchors moved. BOTH CONTENT MODES ARE PASSED EXPLICITLY, and that is not defensive padding. The `pages` row is already updated by the time the hook runs, so letting the service read the stored mode hands it the NEW mode for BOTH revisions — which is the broken case for convert-content-mode: the old HTML then projects as raw text and every correctly built anchor fails its hash check. `currentPage` is read before the write, so it still carries the old mode. A failed sweep does NOT fail the save. `reanchorPageTags` returns a result rather than throwing, so it cannot roll the caller's transaction back, and that is deliberate: degraded anchors are recoverable by a later repair pass, a refused page save is not. The failure is logged instead. Tests pin the seam, because every way it can break is silent — the call removed, the wrong executor, the wrong modes. None of those fails anything on its own. The porting BEHAVIOUR stays covered against real Postgres by the lib integration suite; this file only asserts the wiring. MY FIRST VERSION OF THE MODE TEST WAS GREEN FOR THE WRONG REASON. The fixture page was markdown and the conversion case also converted to markdown, so the two modes were identical and the mutation that swaps old for new survived untouched. The page is now HTML converting to markdown, and that mutation is caught. Three mutation checks: swapping the executor for the db singleton, removing the hook entirely, and reading the post-update mode for both revisions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MuVE5VZCUJ1RBqAVXXmQxa --- .../__tests__/page-mutation-reanchor.test.ts | 150 ++++++++++++++++++ .../src/services/api/page-mutation-service.ts | 47 ++++++ 2 files changed, 197 insertions(+) create mode 100644 apps/web/src/services/api/__tests__/page-mutation-reanchor.test.ts diff --git a/apps/web/src/services/api/__tests__/page-mutation-reanchor.test.ts b/apps/web/src/services/api/__tests__/page-mutation-reanchor.test.ts new file mode 100644 index 0000000000..a2550b0c5c --- /dev/null +++ b/apps/web/src/services/api/__tests__/page-mutation-reanchor.test.ts @@ -0,0 +1,150 @@ +/** + * The content-tag re-anchoring hook inside `applyPageMutation`. + * + * WHY THIS TEST EXISTS. Forward-porting is the PRIMARY anchoring mechanism, and + * `applyPageMutation` is its only possible call site: `previousContent` and + * `nextContent` exist together in exactly one transaction in the codebase. Every + * way this wiring can break is silent — + * + * - the call removed -> every edit degrades to quote repair, the + * accuracy floor, and nothing fails + * - the wrong executor -> anchors commit independently of the content + * they describe (both reviewers of #2494 raised + * this one) + * - the wrong content modes -> convert-content-mode projects the old revision + * with the new mode and orphans every anchor + * + * None of those makes a test go red on their own, so they are asserted here + * directly. The porting BEHAVIOUR is covered against real Postgres by + * packages/lib/src/tags/__tests__/tag-service.integration.test.ts; this file + * only pins the seam. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockReanchor = vi.fn(async () => ({ ok: true as const, data: { considered: 0, updated: 0, orphaned: 0, skippedStaleHash: 0, skippedFormatFlip: 0 } })); +const mockSyncMentions = vi.fn(async () => undefined); +const mockLogError = vi.fn(); + +/** + * The row `applyPageMutation` reads before it writes. + * + * HTML on purpose. The conversion case below converts to markdown, so the old + * and new modes DIFFER — without that the two are identical and swapping one + * for the other is invisible, which is exactly how the first version of this + * test passed while the mutation that swaps them survived. + */ +const currentPage = { + id: 'page-1', + driveId: 'drive-1', + revision: 1, + type: 'DOCUMENT', + content: 'the original content', + contentMode: 'html', + title: 'Doc', +}; + +function queryBuilder(rows: unknown[]) { + const chain: Record = {}; + for (const method of ['select', 'from', 'where', 'limit', 'update', 'set', 'returning', 'insert', 'values']) { + chain[method] = vi.fn(() => chain); + } + chain.limit = vi.fn(async () => rows); + chain.returning = vi.fn(async () => rows); + return chain; +} + +/** + * The transaction handed to the mutation body. + * + * A full query builder, because the mutation writes through it — but ONE stable + * object, so the assertion that the sweep received this exact executor is an + * identity check rather than a shape check. A shape check would pass for the db + * singleton too, which is the bug being guarded against. + */ +const transaction = queryBuilder([currentPage]); + +vi.mock('@pagespace/db/db', () => ({ + db: { + ...queryBuilder([currentPage]), + transaction: vi.fn(async (fn: (tx: unknown) => Promise) => fn(transaction)), + }, +})); +vi.mock('@pagespace/db/operators', () => ({ eq: vi.fn(), and: vi.fn() })); +vi.mock('@pagespace/db/schema/core', () => ({ pages: { id: 'id', revision: 'revision' } })); +vi.mock('@pagespace/lib/tags/tag-service', () => ({ reanchorPageTags: (...a: unknown[]) => mockReanchor(...(a as [])) })); +vi.mock('@/services/api/page-mention-service', () => ({ syncMentions: (...a: unknown[]) => mockSyncMentions(...(a as [])) })); +vi.mock('@pagespace/lib/logging/logger-config', () => ({ loggers: { api: { error: (...a: unknown[]) => mockLogError(...(a as [])), warn: vi.fn(), info: vi.fn() } } })); +vi.mock('@pagespace/lib/monitoring/activity-logger', () => ({ logActivityWithTx: vi.fn(async () => undefined) })); +vi.mock('@pagespace/lib/monitoring/change-group', () => ({ inferChangeGroupType: vi.fn(() => 'edit'), createChangeGroupId: vi.fn(() => 'cg-1') })); +vi.mock('@pagespace/lib/services/page-version-service', () => ({ computePageStateHash: vi.fn(() => 'hash'), createPageVersion: vi.fn(async () => undefined) })); +vi.mock('@pagespace/lib/services/page-content-store', () => ({ writePageContent: vi.fn(async () => ({ ref: 'stored-ref' })) })); +vi.mock('@pagespace/lib/content/page-content-format', () => ({ detectPageContentFormat: vi.fn(() => 'markdown') })); +vi.mock('@pagespace/lib/utils/hash-utils', () => ({ hashWithPrefix: vi.fn(() => 'ref') })); +vi.mock('@pagespace/lib/sheets/sheet', () => ({ isSheetType: vi.fn(() => false) })); +vi.mock('@pagespace/lib/sheets/store', () => ({ replaceFromDocument: vi.fn(), readSheetDocument: vi.fn(async () => null) })); +vi.mock('@pagespace/lib/utils/enums', () => ({ PageType: { DOCUMENT: 'DOCUMENT' } })); +vi.mock('@pagespace/lib/notifications/notifications', () => ({ createMentionNotification: vi.fn(async () => undefined) })); + +const { applyPageMutation } = await import('../page-mutation-service'); + +const baseInput = { + pageId: 'page-1', + operation: 'update' as const, + updatedFields: ['content'], + expectedRevision: 1, + context: { userId: 'user-1' }, + source: 'user' as const, +}; + +describe('applyPageMutation re-anchors content tags', () => { + beforeEach(() => { + mockReanchor.mockClear(); + mockLogError.mockClear(); + }); + + it('sweeps with BOTH revisions and the caller transaction', async () => { + await applyPageMutation({ ...baseInput, updates: { content: 'the edited content' } } as never); + + expect(mockReanchor).toHaveBeenCalledTimes(1); + const [pageId, oldContent, newContent, options] = mockReanchor.mock.calls[0] as unknown as [string, string, string, Record]; + expect(pageId).toBe('page-1'); + expect(oldContent).toBe('the original content'); + expect(newContent).toBe('the edited content'); + // Identity check, not a truthiness check: the sweep must run on the SAME + // transaction as the content write, not on the db singleton. + expect(options.executor).toBe(transaction); + }); + + it('passes the PRE-update mode as the old one across a conversion', async () => { + // The pages row is already updated when the hook runs, so a service reading + // the stored mode would see the new one for both revisions — which projects + // the old HTML as raw text and orphans every anchor. + await applyPageMutation({ + ...baseInput, + updatedFields: ['content', 'contentMode'], + updates: { content: '# converted', contentMode: 'markdown' }, + } as never); + + const [, , , options] = mockReanchor.mock.calls[0] as unknown as [string, string, string, Record]; + expect(options.oldContentMode, 'the mode the old revision was written in').toBe('html'); + expect(options.newContentMode, 'the mode the page now has').toBe('markdown'); + }); + + it('does not sweep when the content did not change', async () => { + await applyPageMutation({ ...baseInput, updatedFields: ['title'], updates: { title: 'Renamed' } } as never); + expect(mockReanchor).not.toHaveBeenCalled(); + }); + + it('logs a failed sweep instead of failing the save', async () => { + // Degraded anchors are recoverable by a later repair pass; a refused page + // save is not. reanchorPageTags returns a result rather than throwing, so + // it cannot roll the caller's transaction back. + mockReanchor.mockResolvedValueOnce({ ok: false as const, error: 'internal_error' } as never); + + await expect( + applyPageMutation({ ...baseInput, updates: { content: 'still saves' } } as never), + ).resolves.toBeDefined(); + + expect(mockLogError).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/services/api/page-mutation-service.ts b/apps/web/src/services/api/page-mutation-service.ts index 7a417ac31a..9a4b1908bb 100644 --- a/apps/web/src/services/api/page-mutation-service.ts +++ b/apps/web/src/services/api/page-mutation-service.ts @@ -6,6 +6,7 @@ import { inferChangeGroupType, createChangeGroupId } from '@pagespace/lib/monito import { computePageStateHash, createPageVersion, type PageVersionSource } from '@pagespace/lib/services/page-version-service' import { loggers } from '@pagespace/lib/logging/logger-config'; import { writePageContent } from '@pagespace/lib/services/page-content-store'; +import { reanchorPageTags } from '@pagespace/lib/tags/tag-service'; import { detectPageContentFormat, type PageContentFormat } from '@pagespace/lib/content/page-content-format'; import { hashWithPrefix } from '@pagespace/lib/utils/hash-utils'; import { isSheetType } from '@pagespace/lib/sheets/sheet'; @@ -297,6 +298,52 @@ export async function applyPageMutation({ mentionedByUserId: context.userId, driveId: currentPage.driveId, }); + + // FORWARD-PORT CONTENT TAG ANCHORS. This is the primary anchoring + // mechanism and this is its only call site: `previousContent` and + // `nextContent` exist together in exactly one place in the codebase, and + // that place is this transaction. Without the hook every edit falls + // through to quote repair, which is the accuracy floor rather than the + // target — tags on actively-edited pages would decay while tags on stale + // pages kept working, which is backwards for retrieval. + // + // IN the transaction, on `transaction`: the sweep must commit with the + // content it describes. Ported outside it, anchors can point at a + // revision that then rolls back, or the page can commit with only some + // of its anchors moved. + // + // A sweep failure does NOT fail the save. `reanchorPageTags` never throws + // — it returns a result — so it cannot roll this transaction back, and + // that is deliberate: degraded anchors are recoverable by a later repair + // pass, a refused page save is not. The failure is logged instead. + // + // BOTH MODES ARE PASSED EXPLICITLY, always. The `pages` row has already + // been updated by this point, so letting the service read the stored mode + // would give it the NEW mode for BOTH revisions — which is precisely the + // broken case for `convert-content-mode`: the old HTML then projects as + // raw text and every correctly built anchor fails its hash check. + // + // `currentPage` is a full row read before the update, so it still holds + // the old mode. For an ordinary edit the two are equal and the service + // treats it as a normal transition; when they differ it knows the change + // is a DECLARED conversion rather than the accidental format flip its + // guard exists to catch, and routes the anchors to quote repair. + const previousContentMode = currentPage.contentMode; + const nextContentMode = + typeof updates.contentMode === 'string' ? updates.contentMode : previousContentMode; + + const reanchored = await reanchorPageTags(pageId, previousContent, nextContent, { + executor: transaction, + oldContentMode: previousContentMode, + newContentMode: nextContentMode, + }); + if (!reanchored.ok) { + loggers.api.error( + 'Failed to re-anchor content tags after a page mutation', + undefined, + { pageId, error: reanchored.error }, + ); + } } // Create page version BEFORE acquiring the activity chain lock, From 96534a75d489ccafd4782234c615e6cc6c2aeb7d Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 6 Sep 2026 10:02:05 -0500 Subject: [PATCH 2/3] fix(tags): isolate the sweep in a savepoint, and unbreak web#build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TWO PROBLEMS, BOTH SURFACED BY WIRING THE HOOK UP. CI went red on all three jobs with one root cause: `web#build` failed with "BigInt literals are not available when targeting lower than ES2020", pointing at packages/lib/src/content/anchoring/anchor.ts — a Phase 0 file, untouched here, and green on master. It was latent. `apps/web` compiles at ES2018 and had never reached that module; importing tag-service from the mutation service is what first pulled it in transitively. Fixed by replacing the three BigInt LITERALS with BigInt() CALLS, which are not literal syntax and compile at ES2018. Verified the values are byte-identical rather than assuming: the constants compare equal, and hashText output matches across empty, ASCII, Unicode and 5000-char inputs. No stored anchor is invalidated. Raising the web app's target would also work, but that is a build-wide change to fix one constant. CATCHING THE SWEEP'S ERROR WAS NOT ENOUGH TO MAKE IT BEST-EFFORT, which review caught and I had asserted the opposite of in a comment. Postgres marks the ENTIRE transaction aborted on any statement error, so a failed UPDATE inside `reanchorPageTags` poisons the caller's transaction even though the service swallows the exception and returns a result. Every later statement — createPageVersion, the activity log — would then fail with "current transaction is aborted" and roll back the page edit. The previous comment claimed "a sweep failure does NOT fail the save"; for a SQL-level failure that was false. The sweep now runs in a SAVEPOINT (drizzle's nested transaction). Released on success, so ported anchors still commit with the content they describe; rolled back on failure, which clears the aborted state and leaves the outer transaction healthy to finish the save. The throw inside the savepoint is what triggers the unwind, since reanchorPageTags returns a result rather than throwing. The mode declarations sit OUTSIDE the try on purpose: only the savepoint call is guarded, so a programming error above it propagates instead of being logged away. That distinction was not theoretical — an edit of mine dropped those declarations and the catch turned the ReferenceError into a log line with the sweep silently never running. The test caught it because it asserts the sweep was CALLED, not merely that the save resolved. Mutation-checked: replacing the savepoint with a direct call on the outer transaction fails both the executor-identity test and the rollback test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MuVE5VZCUJ1RBqAVXXmQxa --- .../__tests__/page-mutation-reanchor.test.ts | 38 +++++++++++---- .../src/services/api/page-mutation-service.ts | 46 +++++++++++++------ packages/lib/src/content/anchoring/anchor.ts | 21 +++++++-- 3 files changed, 80 insertions(+), 25 deletions(-) diff --git a/apps/web/src/services/api/__tests__/page-mutation-reanchor.test.ts b/apps/web/src/services/api/__tests__/page-mutation-reanchor.test.ts index a2550b0c5c..b19cff858f 100644 --- a/apps/web/src/services/api/__tests__/page-mutation-reanchor.test.ts +++ b/apps/web/src/services/api/__tests__/page-mutation-reanchor.test.ts @@ -53,6 +53,9 @@ function queryBuilder(rows: unknown[]) { return chain; } +/** The nested executor the sweep receives — a SAVEPOINT, not the outer tx. */ +const savepoint = { __savepoint: true } as unknown as Record; + /** * The transaction handed to the mutation body. * @@ -61,7 +64,21 @@ function queryBuilder(rows: unknown[]) { * identity check rather than a shape check. A shape check would pass for the db * singleton too, which is the bug being guarded against. */ -const transaction = queryBuilder([currentPage]); +const transaction = queryBuilder([currentPage]) as Record & { + transaction: ReturnType; +}; +/** Records whether the savepoint body threw, i.e. whether it would have rolled back. */ +const savepointRolledBack = { value: false }; +transaction.transaction = vi.fn(async (fn: (sp: unknown) => Promise) => { + savepointRolledBack.value = false; + try { + return await fn(savepoint); + } catch (error) { + // A real SAVEPOINT unwinds here, clearing the aborted transaction state. + savepointRolledBack.value = true; + throw error; + } +}); vi.mock('@pagespace/db/db', () => ({ db: { @@ -110,9 +127,12 @@ describe('applyPageMutation re-anchors content tags', () => { expect(pageId).toBe('page-1'); expect(oldContent).toBe('the original content'); expect(newContent).toBe('the edited content'); - // Identity check, not a truthiness check: the sweep must run on the SAME - // transaction as the content write, not on the db singleton. - expect(options.executor).toBe(transaction); + // The sweep runs on the SAVEPOINT, not the outer transaction and not the db + // singleton. A savepoint is still inside the caller's transaction — so the + // anchors commit with the content — but a failure inside it can be rolled + // back without poisoning the outer one. + expect(options.executor).toBe(savepoint); + expect(transaction.transaction).toHaveBeenCalledTimes(1); }); it('passes the PRE-update mode as the old one across a conversion', async () => { @@ -135,16 +155,18 @@ describe('applyPageMutation re-anchors content tags', () => { expect(mockReanchor).not.toHaveBeenCalled(); }); - it('logs a failed sweep instead of failing the save', async () => { - // Degraded anchors are recoverable by a later repair pass; a refused page - // save is not. reanchorPageTags returns a result rather than throwing, so - // it cannot roll the caller's transaction back. + it('rolls the savepoint back on a failed sweep and still completes the save', async () => { + // Catching the exception is NOT enough on its own: Postgres marks the whole + // transaction aborted on any statement error, so a failed UPDATE inside the + // sweep would make every LATER statement fail and roll back the page edit. + // The savepoint has to unwind for the outer transaction to stay usable. mockReanchor.mockResolvedValueOnce({ ok: false as const, error: 'internal_error' } as never); await expect( applyPageMutation({ ...baseInput, updates: { content: 'still saves' } } as never), ).resolves.toBeDefined(); + expect(savepointRolledBack.value, 'the savepoint must unwind so the outer tx survives').toBe(true); expect(mockLogError).toHaveBeenCalled(); }); }); diff --git a/apps/web/src/services/api/page-mutation-service.ts b/apps/web/src/services/api/page-mutation-service.ts index 9a4b1908bb..fa9e5548ff 100644 --- a/apps/web/src/services/api/page-mutation-service.ts +++ b/apps/web/src/services/api/page-mutation-service.ts @@ -312,11 +312,6 @@ export async function applyPageMutation({ // revision that then rolls back, or the page can commit with only some // of its anchors moved. // - // A sweep failure does NOT fail the save. `reanchorPageTags` never throws - // — it returns a result — so it cannot roll this transaction back, and - // that is deliberate: degraded anchors are recoverable by a later repair - // pass, a refused page save is not. The failure is logged instead. - // // BOTH MODES ARE PASSED EXPLICITLY, always. The `pages` row has already // been updated by this point, so letting the service read the stored mode // would give it the NEW mode for BOTH revisions — which is precisely the @@ -332,16 +327,39 @@ export async function applyPageMutation({ const nextContentMode = typeof updates.contentMode === 'string' ? updates.contentMode : previousContentMode; - const reanchored = await reanchorPageTags(pageId, previousContent, nextContent, { - executor: transaction, - oldContentMode: previousContentMode, - newContentMode: nextContentMode, - }); - if (!reanchored.ok) { + // A SWEEP FAILURE MUST NOT FAIL THE SAVE — and catching the exception is + // NOT enough to achieve that. Postgres marks the ENTIRE transaction + // aborted on any statement error, so a failed UPDATE inside the sweep + // poisons this transaction even though `reanchorPageTags` swallows the + // error and returns a result. Every statement after it — createPageVersion, + // the activity log — would then fail with "current transaction is aborted" + // and roll back the page edit, which is the opposite of best-effort. + // + // The sweep therefore runs in a SAVEPOINT (drizzle's nested transaction). + // On success it is released and the ported anchors commit with the + // content. On failure it is rolled back, which clears the aborted state + // and leaves the outer transaction healthy to finish the save. Anchors are + // recoverable by a later repair pass; a refused page save is not. + // + // The throw is what triggers the rollback: `reanchorPageTags` returns a + // result rather than throwing, so a non-ok result has to be converted into + // one for the savepoint to unwind. + try { + await transaction.transaction(async (savepoint) => { + const reanchored = await reanchorPageTags(pageId, previousContent, nextContent, { + executor: savepoint, + oldContentMode: previousContentMode, + newContentMode: nextContentMode, + }); + if (!reanchored.ok) { + throw new Error(`reanchorPageTags returned ${reanchored.error}`); + } + }); + } catch (error) { loggers.api.error( - 'Failed to re-anchor content tags after a page mutation', - undefined, - { pageId, error: reanchored.error }, + 'Failed to re-anchor content tags after a page mutation; the save continues', + error as Error, + { pageId }, ); } } diff --git a/packages/lib/src/content/anchoring/anchor.ts b/packages/lib/src/content/anchoring/anchor.ts index af579e71c7..d24006b174 100644 --- a/packages/lib/src/content/anchoring/anchor.ts +++ b/packages/lib/src/content/anchoring/anchor.ts @@ -19,9 +19,24 @@ import type { TextAnchor } from './types'; /** How much surrounding text a TextAnchor carries on each side. ~64 bytes total. */ export const ANCHOR_CONTEXT_LENGTH = 32; -const FNV_OFFSET_BASIS = 0xcbf29ce484222325n; -const FNV_PRIME = 0x100000001b3n; -const FNV_MASK = 0xffffffffffffffffn; +/* + * BigInt CALLS, not BigInt literals. + * + * `0xcbf29ce484222325n` requires an ES2020 target. This module is pure and was + * only ever imported by other lib code until `tag-service` reached it, and + * `apps/web` compiles at ES2018 — so the moment anything in the web app pulled + * this file in transitively, `web#build` failed with "BigInt literals are not + * available when targeting lower than ES2020". The literal was latent, not + * safe. + * + * `BigInt('0x…')` is a runtime call rather than literal syntax, compiles at + * ES2018, and produces the identical value — so `hashText` output is unchanged + * and every anchor already stored still verifies. Raising the web app's target + * would also work, but that is a build-wide change to fix one constant here. + */ +const FNV_OFFSET_BASIS = BigInt('0xcbf29ce484222325'); +const FNV_PRIME = BigInt('0x100000001b3'); +const FNV_MASK = BigInt('0xffffffffffffffff'); /** * FNV-1a over the UTF-16 code units of `text`, as 16 lowercase hex chars. From dae19f72667fad097636979c5350433d5a1e716b Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 6 Sep 2026 16:02:23 -0500 Subject: [PATCH 3/3] test(tags): clear the savepoint spy between cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `transaction.transaction` is a module-level spy, so its call count accumulates across cases. The savepoint assertion — called exactly once — therefore held only while its test happened to run first; a case added above it, or randomised order, would have failed it for a reason unrelated to the savepoint. Verified rather than assumed: inserting a sweeping case ABOVE it reproduces the break without the clear ('expected 1, got 2') and passes with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MuVE5VZCUJ1RBqAVXXmQxa --- .../services/api/__tests__/page-mutation-reanchor.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/services/api/__tests__/page-mutation-reanchor.test.ts b/apps/web/src/services/api/__tests__/page-mutation-reanchor.test.ts index b19cff858f..4e6fe7a853 100644 --- a/apps/web/src/services/api/__tests__/page-mutation-reanchor.test.ts +++ b/apps/web/src/services/api/__tests__/page-mutation-reanchor.test.ts @@ -117,6 +117,13 @@ describe('applyPageMutation re-anchors content tags', () => { beforeEach(() => { mockReanchor.mockClear(); mockLogError.mockClear(); + // `transaction.transaction` is a module-level spy, so its call count + // ACCUMULATES across cases. Without this clear, the savepoint assertion + // below ("called exactly once") holds only while its test happens to run + // first — a case added above it, or randomised order, would fail it for a + // reason that has nothing to do with the savepoint. + transaction.transaction.mockClear(); + savepointRolledBack.value = false; }); it('sweeps with BOTH revisions and the caller transaction', async () => {