From ddb6613c4039b5b3a806065c902f3f92742e4b2d Mon Sep 17 00:00:00 2001 From: Luan Nguyen Date: Wed, 11 Nov 2026 21:29:49 +1100 Subject: [PATCH] feat(study): admit source cards through verified owners --- web/src/study/sourceStore.ts | 387 +++++++++ web/tests/data/study-source-migration.test.ts | 308 +++++++ web/tests/data/study-source.test.ts | 775 ++++++++++++++++++ 3 files changed, 1470 insertions(+) create mode 100644 web/src/study/sourceStore.ts create mode 100644 web/tests/data/study-source-migration.test.ts create mode 100644 web/tests/data/study-source.test.ts diff --git a/web/src/study/sourceStore.ts b/web/src/study/sourceStore.ts new file mode 100644 index 0000000..1fb079b --- /dev/null +++ b/web/src/study/sourceStore.ts @@ -0,0 +1,387 @@ +import type { CollectionsStore } from "../collections/contracts"; +import { + assertCollectionsAccess, + onCollectionsEpochChange, +} from "../collections/store"; +import { lockVersion, onLock } from "../account/session"; +import { studySourceOwner } from "../library/store"; +import type { NotesLibraryStore } from "../library/contracts"; +import { + canonicalSourceDocument, + captureSourceSelection, + fingerprintSourceDocument, + SOURCE_CODEC_LIMITS, +} from "./sourceCodec"; +import { resolveSource, sourceTokens } from "./sourceModel"; +import { SourceError } from "./sourceContracts"; +import type { + MakeCardIntent, + UndoMakeCardIntent, + SourceCommand, + SourceOwnerSnapshot, + StudySourceService, + MakeCardResult, +} from "./sourceContracts"; +import { + id, + validateMakeCardDraft, + validateSavedSelection, + validateSourceExpected, +} from "./sourceValidation"; + +export function createStudySourceService( + notes: NotesLibraryStore, + collections: CollectionsStore, +): StudySourceService { + assertCollectionsAccess(collections); + const owner = studySourceOwner(notes), + openedLock = lockVersion(); + let closed = false, + generation = 0, + observedEpoch: string | undefined; + let held = new WeakMap< + MakeCardIntent | UndoMakeCardIntent, + { command: SourceCommand; generation: number } + >(); + const listeners = new Set<() => void>(), + invalidations = new Set<(reason: "epoch" | "closed") => void>(); + const stopLock = onLock(close); + let stopEpoch: () => void, stopNotes: () => void, stopCollections: () => void; + try { + stopEpoch = onCollectionsEpochChange(collections, () => + invalidate("epoch"), + ); + stopNotes = notes.subscribe(refresh); + stopCollections = collections.subscribe(refresh); + } catch (error) { + stopLock(); + stopEpoch!?.(); + stopNotes!?.(); + throw error; + } + function invalidate(reason: "epoch" | "closed") { + generation++; + held = new WeakMap(); + for (const listener of invalidations) { + try { + listener(reason); + } catch { + /* Complete all revocations. */ + } + } + } + function close() { + if (closed) return; + closed = true; + invalidate("closed"); + stopLock(); + stopEpoch?.(); + stopNotes?.(); + stopCollections?.(); + listeners.clear(); + invalidations.clear(); + } + function guard(captured = generation) { + if (closed || lockVersion() !== openedLock || captured !== generation) + throw new SourceError( + "source_revoked", + "This source operation is no longer authorized.", + ); + assertCollectionsAccess(collections); + } + function observe(state: SourceOwnerSnapshot) { + const epoch = `${state.library.epoch}:${state.collections.epoch}`; + if (observedEpoch !== undefined && epoch !== observedEpoch) + invalidate("epoch"); + observedEpoch = epoch; + } + async function read(captured = generation) { + guard(captured); + const state = await owner.read(); + guard(captured); + observe(state); + guard(captured); + return state; + } + function refresh() { + if (closed) return; + void read() + .then(() => { + for (const listener of listeners) { + try { + listener(); + } catch { + /* Observer isolation. */ + } + } + }) + .catch(() => {}); + } + async function hash(value: unknown, captured: number) { + const bytes = new TextEncoder().encode(JSON.stringify(value)); + try { + const result = await crypto.subtle.digest("SHA-256", bytes); + guard(captured); + return Array.from(new Uint8Array(result), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + } finally { + bytes.fill(0); + } + } + async function resolve(entryId: string) { + id(entryId); + const captured = generation, + first = await read(captured); + const entry = first.collections.study.entries.find( + (entry) => entry.id === entryId, + ); + const note = first.notes.find((note) => note.id === entry?.source?.noteId); + if (!note) return resolveSource(first, entryId, ""); + const fingerprint = await fingerprintSourceDocument(note.document); + guard(captured); + const latest = await read(captured), + currentNote = latest.notes.find((row) => row.id === note.id); + if ( + !currentNote || + currentNote.revision !== note.revision || + canonicalSourceDocument(currentNote.document).canonicalJSON !== + fingerprint.canonicalJSON + ) + return { status: "unavailable" as const, reason: "stale" as const }; + return resolveSource(latest, entryId, fingerprint.digest); + } + return { + async prepareCreate(selectionInput, expectedInput, draftInput) { + guard(); + const captured = generation, + selection = validateSavedSelection(selectionInput), + expected = validateSourceExpected(expectedInput), + draft = validateMakeCardDraft(draftInput); + const operationId = crypto.randomUUID(), + entryId = crypto.randomUUID(), + anchorId = crypto.randomUUID(), + preparedAt = new Date().toISOString(); + const cardIds = Array.from( + { + length: + draft.type === "reversed" + ? 2 + : draft.type === "cloze" + ? draft.blanks.length + : 1, + }, + () => crypto.randomUUID(), + ); + const fingerprint = await fingerprintSourceDocument( + selection.fingerprint.document, + ); + guard(captured); + if ( + fingerprint.digest !== selection.fingerprint.digest || + fingerprint.canonicalJSON !== selection.fingerprint.canonicalJSON + ) + throw new SourceError( + "source_invalid", + "The captured selection fingerprint is invalid.", + ); + if ( + captureSourceSelection( + fingerprint.document, + selection.passage.from, + selection.passage.to, + ).passage.quote !== selection.passage.quote + ) + throw new SourceError( + "source_invalid", + "The selected quote does not match its captured range.", + ); + const state = await read(captured); + if ( + state.library.epoch !== expected.library.epoch || + state.collections.epoch !== expected.collections.epoch + ) + throw new SourceError("source_revoked", "The workspace was erased."); + const body = { + kind: "create" as const, + operationId, + expected, + preparedAt, + entryId, + anchorId, + cardIds, + selection, + draft, + }; + const digest = await hash(body, captured); + await read(captured); + const intent = Object.freeze({ + kind: "create", + operationId, + entryId, + }) as MakeCardIntent; + held.set(intent, { command: { ...body, digest }, generation: captured }); + return intent; + }, + async prepareUndo(expectedInput, receiptOperationId) { + guard(); + const captured = generation, + expected = validateSourceExpected(expectedInput); + id(receiptOperationId); + const operationId = crypto.randomUUID(), + preparedAt = new Date().toISOString(), + state = await read(captured); + if ( + state.library.epoch !== expected.library.epoch || + state.collections.epoch !== expected.collections.epoch + ) + throw new SourceError("source_revoked", "The workspace was erased."); + const original = state.library.sourceReceipts.find( + (receipt) => + receipt.operationId === receiptOperationId && + receipt.kind === "create", + ); + if (!original) + throw new SourceError( + "source_undo_unavailable", + "The original card receipt is no longer available.", + ); + const body = { + kind: "undo" as const, + operationId, + expected, + preparedAt, + entryId: original.entryId, + original: structuredClone(original), + }; + const digest = await hash(body, captured); + await read(captured); + const intent = Object.freeze({ + kind: "undo", + operationId, + entryId: original.entryId, + }) as UndoMakeCardIntent; + held.set(intent, { command: { ...body, digest }, generation: captured }); + return intent; + }, + async commit(intent): Promise { + guard(); + const prepared = intent && held.get(intent); + if (!prepared) + throw new SourceError( + "source_revoked", + "Prepare a new authorized source action.", + ); + const fresh = () => guard(prepared.generation); + fresh(); + const result = await owner.commit(prepared.command, fresh); + fresh(); + observe(result.snapshot); + fresh(); + const receipt = result.snapshot.library.sourceReceipts.find( + (row) => row.operationId === prepared.command.operationId, + ); + if (!receipt || receipt.digest !== prepared.command.digest) + throw new SourceError( + "source_operation_reused", + "The source operation result does not match.", + ); + const anchor = result.snapshot.library.anchors.find( + (anchor) => anchor.id === receipt.anchorId, + ); + return { + entryId: receipt.entryId, + cardIds: receipt.cardIds.slice(), + receipt: structuredClone(receipt), + changed: true, + replayed: result.replayed, + tokens: sourceTokens(result.snapshot), + source: resolveSource( + result.snapshot, + receipt.entryId, + anchor?.documentDigest ?? "", + ), + }; + }, + resolve, + async annotations(noteId) { + const captured = generation, + state = await read(captured), + entries = state.collections.study.entries.filter( + (entry) => entry.source?.noteId === noteId, + ); + const note = state.notes.find((note) => note.id === noteId); + if (!note || note.trashedAt !== null || !entries.length) return []; + const fingerprint = await fingerprintSourceDocument(note.document); + guard(captured); + const latest = await read(captured), + current = latest.notes.find((row) => row.id === note.id); + if ( + !current || + current.revision !== note.revision || + canonicalSourceDocument(current.document).canonicalJSON !== + fingerprint.canonicalJSON + ) + return []; + const latestEntries = latest.collections.study.entries.filter( + (entry) => entry.source?.noteId === noteId, + ), + anchorsById = new Map( + latest.library.anchors.map((anchor) => [anchor.id, anchor]), + ); + const work = latestEntries.reduce((sum, entry) => { + const source = entry.source; + const anchor = + source && "version" in source + ? anchorsById.get(source.anchorId) + : undefined; + return ( + sum + + (anchor?.status === "attached" + ? fingerprint.canonicalJSON.length + + (anchor.to - anchor.from) * 2 + + 64 + : 64) + ); + }, 0); + if (work > SOURCE_CODEC_LIMITS.replayUnits) + throw new SourceError( + "source_limit", + "This annotation projection exceeds its supported work limit.", + ); + const results = []; + for (const entry of latestEntries) { + const source = entry.source, + anchor = + source && "version" in source + ? anchorsById.get(source.anchorId) + : undefined; + // Bounded single-entity views avoid quadratic repeated ID scans. + const projection = { + ...latest, + notes: [current], + library: { ...latest.library, anchors: anchor ? [anchor] : [] }, + collections: { + ...latest.collections, + study: { ...latest.collections.study, entries: [entry] }, + }, + }; + const result = resolveSource(projection, entry.id, fingerprint.digest); + if (result.status === "attached") results.push(result); + } + return results; + }, + subscribe(listener) { + guard(); + listeners.add(listener); + return () => listeners.delete(listener); + }, + onInvalidate(listener) { + guard(); + invalidations.add(listener); + return () => invalidations.delete(listener); + }, + persistence: () => notes.persistence(), + close, + }; +} diff --git a/web/tests/data/study-source-migration.test.ts b/web/tests/data/study-source-migration.test.ts new file mode 100644 index 0000000..db06cc6 --- /dev/null +++ b/web/tests/data/study-source-migration.test.ts @@ -0,0 +1,308 @@ +// @vitest-environment node +import { beforeAll, beforeEach, afterEach, it, expect, vi } from "vitest"; +import { readFile } from "node:fs/promises"; +import { IDBFactory, IDBObjectStore } from "fake-indexeddb"; +import { + initSync, + DeviceKeys, + SyncEngine, +} from "../../src/generated/wasm/orbyn_wasm.js"; +import { + newWrappingKey, + wrappingSecret, + mutateRecord, + readRecord, +} from "../../src/data/database"; +import type { DeviceRecord } from "../../src/data/database"; +import { establish, lock } from "../../src/account/session"; +import { openNotesStore } from "../../src/data"; +import { openCollectionsStore } from "../../src/collections/store"; +import { createStudySourceService } from "../../src/study/sourceStore"; +import { + fingerprintSourceDocument, + captureSourceSelection, +} from "../../src/study/sourceCodec"; +import { adoptLibrary } from "../../src/library/model"; +import { + normalizeLibrary, + validateLegacyLibrary, + LEGACY_LIBRARY_BYTES, + SOURCE_LIBRARY_OVERHEAD, + SOURCE_LIBRARY_BYTES, + LINK_LIBRARY_OVERHEAD, + LIBRARY_LIMITS, +} from "../../src/library/validation"; +import { + emptyStudy, + validateCollectionsV2, + validateLegacyCollections, +} from "../../src/collections/validation"; +import { createDeck } from "../../src/study/model"; +import type { Note } from "../../src/domain/contracts"; + +const identity = { + accountId: "11111111-1111-4111-8111-111111111111", + deviceId: "22222222-2222-4222-8222-222222222222", + email: "migration@example.test", + deviceState: "trusted" as const, +}; +const at = "2026-11-11T00:00:00.000Z", + handles = new Set<{ close(): void }>(); +const note: Note = { + id: "legacy opaque source note", + title: "Existing source", + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Selected source" }], + }, + ], + }, + tags: [], + createdAt: at, + updatedAt: at, + trashedAt: null, + revision: 7, +}; +beforeAll(async () => { + initSync({ + module: await readFile( + new URL("../../src/generated/wasm/orbyn_wasm_bg.wasm", import.meta.url), + ), + }); +}); +beforeEach(() => { + lock(); + vi.stubGlobal("indexedDB", new IDBFactory()); + establish(identity); +}); +afterEach(() => { + for (const handle of handles) handle.close(); + handles.clear(); + vi.restoreAllMocks(); + lock(); +}); +async function seed(version: 1 | 2, corrupt = false) { + const wrapKey = await newWrappingKey(), + secret = await wrappingSecret(wrapKey), + keys = DeviceKeys.create(), + engine = SyncEngine.create(keys, identity.accountId, identity.deviceId); + const { + anchors: _, + sourceReceipts: __, + ...current + } = adoptLibrary([note], [note], at); + const library = { ...current, version: 1 }; + const study = createDeck(emptyStudy("UTC"), "Existing Study", new Date(at)); + const clip = { + id: crypto.randomUUID(), + revision: 1, + originalUrl: "https://example.test/actual", + canonicalUrl: "https://example.test/actual", + title: "Actual saved Clip", + text: "Existing pasted text", + createdAt: at, + updatedAt: at, + readAt: null, + readLater: true, + archivedAt: null, + }; + const journal = { + version: 1, + kdf: { + name: "PBKDF2-SHA256", + iterations: 600000, + salt: btoa("s".repeat(16)), + }, + iv: btoa("i".repeat(12)), + ciphertext: btoa("c".repeat(16)), + }; + const collections = { + version, + epoch: crypto.randomUUID(), + revision: 9, + study, + journal, + ...(version === 2 ? { clips: { items: [clip], receipts: [] } } : {}), + ...(corrupt ? { foreign: true } : {}), + }; + try { + for (const [zone, type, recordId, field, value] of [ + ["notes-local", "notes_library", "library", "notes", [note]], + [ + "notes-library-v1", + "notes_library_metadata", + "workspace", + "library", + library, + ], + [ + "notes-collections-v1", + "notes_collections", + "workspace", + "collections", + collections, + ], + ] as const) { + engine.createZoneKey(zone); + engine.stage( + zone, + type, + recordId, + JSON.stringify({ + [field]: { value, stamp: JSON.parse(engine.tick(Date.now())) }, + }), + ); + } + await mutateRecord("devices", identity.email, () => ({ + ...identity, + wrapKey, + sealedKeys: keys.export(secret), + snapshot: engine.snapshot(secret), + generation: 12, + verifiedTrust: true, + })); + } finally { + engine.free(); + keys.free(); + secret.fill(0); + } + return { library, collections }; +} +async function raw() { + const stored = (await readRecord("devices", identity.email))!, + secret = await wrappingSecret(stored.wrapKey), + keys = DeviceKeys.import(stored.sealedKeys, secret), + engine = SyncEngine.restore(keys, stored.snapshot!, secret, Date.now()); + try { + return { + library: JSON.parse(engine.record("notes-library-v1", "workspace")!) + .fields.library.value, + collections: JSON.parse( + engine.record("notes-collections-v1", "workspace")!, + ).fields.collections.value, + }; + } finally { + engine.free(); + keys.free(); + secret.fill(0); + } +} +it.each([1, 2] as const)( + "strictly migrates raw Collections v%s and Library1 without rewriting; atomic source write preserves prior domains", + async (version) => { + const seeded = await seed(version), + before = (await readRecord("devices", identity.email))!; + const put = vi.spyOn(IDBObjectStore.prototype, "put"); + const notes = await openNotesStore(identity), + collections = await openCollectionsStore(identity); + handles.add(notes); + handles.add(collections); + expect(put).not.toHaveBeenCalled(); + put.mockRestore(); + const current = await collections.read(); + expect(current.version).toBe(3); + expect(current.study).toEqual(seeded.collections.study); + expect(current.journal).toEqual(seeded.collections.journal); + expect(current.clips.items).toHaveLength(version === 2 ? 1 : 0); + const untouched = await raw(); + expect(untouched.library.version).toBe(1); + expect(untouched.collections.version).toBe(version); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before.snapshot); + const service = createStudySourceService(notes, collections); + handles.add(service); + const library = await notes.library.read(); + if (library.mode !== "ready") throw new Error("fixture library"); + const fingerprint = await fingerprintSourceDocument(note.document), + selection = { + noteId: note.id, + noteRevision: note.revision, + libraryEpoch: library.snapshot.token.epoch, + fingerprint, + passage: captureSourceSelection(note.document, 1, 9).passage, + }; + const intent = await service.prepareCreate( + selection, + { + library: library.snapshot.token, + collections: { epoch: current.epoch, revision: current.revision }, + }, + { + type: "basic", + deckId: current.study.decks[0]!.id, + tags: [], + front: "Question", + back: "Selected", + }, + ); + const failingPut = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException( + "Synthetic migration quota", + "QuotaExceededError", + ); + }); + await expect(service.commit(intent)).rejects.toThrow("migration quota"); + failingPut.mockRestore(); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before.snapshot); + const result = await service.commit(intent), + persisted = await raw(); + expect(result.source).toMatchObject({ + status: "attached", + noteId: note.id, + noteRevision: 7, + }); + expect(persisted.library.version).toBe(2); + expect(persisted.collections.version).toBe(3); + expect(persisted.collections.clips).toEqual(current.clips); + expect(persisted.collections.journal).toEqual(current.journal); + expect(() => validateLegacyLibrary(persisted.library, [note])).toThrow( + "library_invalid", + ); + expect(() => validateCollectionsV2(persisted.collections)).toThrow( + "collections_invalid", + ); + expect(() => validateLegacyCollections(persisted.collections)).toThrow( + "collections_invalid", + ); + }, +); +it.each([1, 2] as const)( + "refuses malformed raw v%s without clearing or rewriting it", + async (version) => { + await seed(version, true); + const before = (await readRecord("devices", identity.email))!; + await expect(openCollectionsStore(identity)).rejects.toThrow( + "collections_invalid", + ); + const after = (await readRecord("devices", identity.email))!; + expect(after.snapshot).toEqual(before.snapshot); + expect(after.generation).toBe(before.generation); + }, +); +it("uses exact bounded structural overhead for Library1 normalization and validates strict legacy input first", async () => { + const seeded = await seed(2), + normalized = normalizeLibrary(seeded.library, [note]); + const bytes = (value: unknown) => + new TextEncoder().encode(JSON.stringify(value)).byteLength; + expect(bytes(normalized) - bytes(seeded.library)).toBe( + SOURCE_LIBRARY_OVERHEAD, + ); + expect(LEGACY_LIBRARY_BYTES).toBe(64 * 1024 * 1024); + expect(SOURCE_LIBRARY_BYTES).toBe( + LEGACY_LIBRARY_BYTES + SOURCE_LIBRARY_OVERHEAD, + ); + expect(LIBRARY_LIMITS.bytes).toBe( + SOURCE_LIBRARY_BYTES + LINK_LIBRARY_OVERHEAD, + ); + expect(() => + normalizeLibrary({ ...seeded.library, unsupported: [] }, [note]), + ).toThrow("library_invalid"); +}); diff --git a/web/tests/data/study-source.test.ts b/web/tests/data/study-source.test.ts new file mode 100644 index 0000000..138b20e --- /dev/null +++ b/web/tests/data/study-source.test.ts @@ -0,0 +1,775 @@ +// @vitest-environment node +import { beforeAll, beforeEach, afterEach, it, expect, vi } from "vitest"; +import { readFile } from "node:fs/promises"; +import { IDBFactory, IDBObjectStore } from "fake-indexeddb"; +import { Transform } from "@tiptap/pm/transform"; +import { + initSync, + DeviceKeys, + SyncEngine, +} from "../../src/generated/wasm/orbyn_wasm.js"; +import { establish, lock } from "../../src/account/session"; +import { + newWrappingKey, + wrappingSecret, + mutateRecord, + readRecord, +} from "../../src/data/database"; +import type { DeviceRecord } from "../../src/data/database"; +import { openNotesStore } from "../../src/data"; +import { openCollectionsStore } from "../../src/collections/store"; +import { protectOperations } from "../../src/desktop/closeGuard"; +import { createStudySourceService } from "../../src/study/sourceStore"; +import { studySourceOwner } from "../../src/library/store"; +import { + fingerprintSourceDocument, + captureSourceSelection, +} from "../../src/study/sourceCodec"; +import { createNoteSchema } from "../../src/editor/schema"; +import { createDeck, gradeCard } from "../../src/study/model"; +import type { NoteDocument } from "../../src/domain/contracts"; +import type { + MakeCardDraft, + SourceExpected, +} from "../../src/study/sourceContracts"; +const identity = { + accountId: "11111111-1111-4111-8111-111111111111", + deviceId: "22222222-2222-4222-8222-222222222222", + email: "sources@example.test", + deviceState: "trusted" as const, +}; +const handles = new Set<{ close(): void }>(); +const document: NoteDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Before alpha beta After alpha beta" }], + }, + ], +}; +beforeAll(async () => { + initSync({ + module: await readFile( + new URL("../../src/generated/wasm/orbyn_wasm_bg.wasm", import.meta.url), + ), + }); +}); +beforeEach(async () => { + lock(); + vi.stubGlobal("indexedDB", new IDBFactory()); + const wrapKey = await newWrappingKey(), + secret = await wrappingSecret(wrapKey), + keys = DeviceKeys.create(); + try { + await mutateRecord("devices", identity.email, () => ({ + ...identity, + wrapKey, + sealedKeys: keys.export(secret), + generation: 0, + verifiedTrust: true, + })); + } finally { + keys.free(); + secret.fill(0); + } + establish(identity); +}); +afterEach(() => { + for (const handle of handles) handle.close(); + handles.clear(); + vi.restoreAllMocks(); + vi.useRealTimers(); + lock(); +}); +async function setup() { + const raw = await openNotesStore(identity); + handles.add(raw); + const notes = protectOperations(raw, ["create", "update", "erase"]); + const note = await notes.create({ title: "Source note", document, tags: [] }); + const collections = await openCollectionsStore(identity); + handles.add(collections); + const current = await collections.read(); + const state = await collections.update(current, (current) => ({ + ...current, + study: createDeck(current.study, "Actual source deck", new Date()), + })); + const service = createStudySourceService(notes, collections); + handles.add(service); + async function expected(): Promise { + const library = await notes.library.read(); + if (library.mode !== "ready") throw new Error("fixture library"); + const state = await collections.read(); + return { + library: library.snapshot.token, + collections: { epoch: state.epoch, revision: state.revision }, + }; + } + async function selection() { + const current = (await notes.get(note.id))!, + fingerprint = await fingerprintSourceDocument(current.document); + return { + noteId: note.id, + noteRevision: current.revision, + libraryEpoch: (await expected()).library.epoch, + fingerprint, + passage: captureSourceSelection(fingerprint.document, 8, 18).passage, + }; + } + async function make(type: "basic" | "reversed" | "cloze" = "basic") { + const draft: MakeCardDraft = + type === "cloze" + ? { + type, + deckId: state.study.decks[0]!.id, + tags: [], + text: "Before {{answer}} After", + blanks: [{ id: "answer", answer: "alpha beta" }], + } + : { + type, + deckId: state.study.decks[0]!.id, + tags: [], + front: "Which actual passage?", + back: "alpha beta", + }; + const intent = await service.prepareCreate( + await selection(), + await expected(), + draft, + ); + return { intent, saved: await service.commit(intent) }; + } + async function edit(change: (tr: Transform) => void) { + const current = (await notes.get(note.id))!, + base = await fingerprintSourceDocument(current.document), + schema = createNoteSchema(), + tr = new Transform(schema.nodeFromJSON(base.document)); + change(tr); + const final = await fingerprintSourceDocument(tr.doc.toJSON()); + return notes.library.updateWithSourceSteps( + note.id, + { document: final.document }, + current.revision, + { + baseDigest: base.digest, + finalDigest: final.digest, + steps: tr.steps.map((step) => step.toJSON()), + }, + ); + } + return { notes, collections, service, note, expected, selection, make, edit }; +} +async function libraryEdit(change: (value: any) => void) { + const stored = (await readRecord("devices", identity.email))!, + secret = await wrappingSecret(stored.wrapKey), + keys = DeviceKeys.import(stored.sealedKeys, secret), + engine = SyncEngine.restore(keys, stored.snapshot!, secret, Date.now()); + try { + const state = JSON.parse(engine.record("notes-library-v1", "workspace")!) + .fields.library.value; + change(state); + engine.stage( + "notes-library-v1", + "notes_library_metadata", + "workspace", + JSON.stringify({ + library: { value: state, stamp: JSON.parse(engine.tick(Date.now())) }, + }), + ); + const snapshot = engine.snapshot(secret); + await mutateRecord("devices", identity.email, (current) => ({ + ...current!, + snapshot, + generation: current!.generation + 1, + })); + } finally { + engine.free(); + keys.free(); + secret.fill(0); + } +} +it.each(["basic", "reversed", "cloze"] as const)( + "persists actual %s source cards through the real protected Notes Proxy", + async (type) => { + const f = await setup(), + { saved } = await f.make(type); + expect(saved.cardIds).toHaveLength(type === "reversed" ? 2 : 1); + expect(saved.source).toMatchObject({ + status: "attached", + noteId: f.note.id, + noteRevision: 1, + from: 8, + to: 18, + quote: "alpha beta", + }); + const source = (await f.collections.read()).study.entries[0]!.source!; + expect(source).toMatchObject({ + version: 2, + noteRevision: 1, + anchorId: saved.receipt.anchorId, + }); + expect((await f.service.annotations(f.note.id))[0]).toEqual(saved.source); + const fresh = createStudySourceService(f.notes, f.collections); + handles.add(fresh); + expect(await fresh.resolve(saved.entryId)).toEqual(saved.source); + }, +); +it("maps proven surrounding edits/formatting while birth remains immutable and duplicate quote is never searched", async () => { + const f = await setup(), + { saved } = await f.make(); + await f.edit((tr) => { + tr.insert(1, tr.doc.type.schema.text("New ")); + }); + expect(JSON.stringify((await f.notes.get(f.note.id))!.document)).toContain( + "New ", + ); + const resolved = await f.service.resolve(saved.entryId); + expect(resolved).toMatchObject({ + status: "attached", + from: 12, + to: 22, + quote: "alpha beta", + noteRevision: 2, + }); + await f.edit((tr) => { + tr.addMark(12, 22, tr.doc.type.schema.marks.bold!.create()); + }); + expect(await f.service.resolve(saved.entryId)).toMatchObject({ + status: "attached", + from: 12, + to: 22, + noteRevision: 3, + }); + const note = (await f.notes.get(f.note.id))!; + await f.notes.update(note.id, { title: "Only title" }, note.revision); + expect(await f.service.resolve(saved.entryId)).toMatchObject({ + status: "attached", + from: 12, + to: 22, + noteRevision: 4, + }); + expect( + (await f.collections.read()).study.entries[0]!.source!.noteRevision, + ).toBe(1); + await libraryEdit((state) => { + state.sourceReceipts = []; + }); + expect(await f.service.resolve(saved.entryId)).toMatchObject({ + status: "attached", + noteRevision: 4, + }); +}); +it("stales and persists an actual identical-byte ReplaceStep without adding duplicate history", async () => { + const f = await setup(), + { saved } = await f.make(), + beforeHistory = await f.notes.library.history(f.note.id); + await f.edit((tr) => { + tr.replaceWith(8, 18, tr.doc.type.schema.text("alpha beta")); + }); + expect(await f.service.resolve(saved.entryId)).toEqual({ + status: "unavailable", + reason: "stale", + }); + expect(await f.notes.library.history(f.note.id)).toEqual(beforeHistory); + const fresh = createStudySourceService(f.notes, f.collections); + handles.add(fresh); + expect(await fresh.resolve(saved.entryId)).toEqual({ + status: "unavailable", + reason: "stale", + }); +}); +it("carries source identity through atomic central tag rename and deletion", async () => { + const f = await setup(), + { saved } = await f.make(); + await f.notes.update(f.note.id, { tags: ["original"] }, 1); + let library = await f.notes.library.read(); + if (library.mode !== "ready") throw new Error("fixture"); + const tagId = library.snapshot.tags[0]!.id; + await f.notes.library.renameTag( + tagId, + "renamed", + library.snapshot.token, + crypto.randomUUID(), + ); + expect(await f.service.resolve(saved.entryId)).toMatchObject({ + status: "attached", + noteRevision: 3, + }); + library = await f.notes.library.read(); + if (library.mode !== "ready") throw new Error("fixture"); + await f.notes.library.deleteTag( + tagId, + library.snapshot.token, + crypto.randomUUID(), + ); + expect(await f.service.resolve(saved.entryId)).toMatchObject({ + status: "attached", + noteRevision: 4, + }); +}); +it("detaches source anchors for confirmed same-document replacement recovery", async () => { + const f = await setup(), + { saved } = await f.make(), + second = (await f.make()).saved; + let current = (await f.notes.get(f.note.id))!; + await f.notes.update( + current.id, + { title: current.title, document: current.document, tags: current.tags }, + current.revision, + ); + expect(await f.service.resolve(saved.entryId)).toMatchObject({ + status: "attached", + }); + current = (await f.notes.get(f.note.id))!; + const history = await f.notes.library.history(current.id); + await f.notes.library.replaceDocument( + current.id, + { title: current.title, document: current.document, tags: current.tags }, + current.revision, + ); + expect(await f.service.resolve(saved.entryId)).toEqual({ + status: "unavailable", + reason: "stale", + }); + expect(await f.service.resolve(second.entryId)).toEqual({ + status: "unavailable", + reason: "stale", + }); + expect(await f.service.annotations(current.id)).toEqual([]); + expect(await f.notes.library.history(current.id)).toEqual(history); +}); +it.each(["quota", "stale-revision", "reauth", "erase"] as const)( + "preserves the authoritative snapshot when explicit replacement fails at %s", + async (failure) => { + const f = await setup(), + { saved } = await f.make(), + current = (await f.notes.get(f.note.id))!; + if (failure === "stale-revision") + await f.notes.update( + current.id, + { title: "Newer saved title" }, + current.revision, + ); + const input = { + title: current.title, + document: current.document, + tags: current.tags, + }; + let before = (await readRecord("devices", identity.email))!; + if (failure === "quota") { + const put = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException( + "Synthetic replacement quota", + "QuotaExceededError", + ); + }); + try { + await expect( + f.notes.library.replaceDocument(current.id, input, current.revision), + ).rejects.toThrow("replacement quota"); + } finally { + put.mockRestore(); + } + } else if (failure === "stale-revision") { + await expect( + f.notes.library.replaceDocument(current.id, input, current.revision), + ).rejects.toMatchObject({ code: "revision_conflict" }); + } else { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const sign = crypto.subtle.sign.bind(crypto.subtle); + const signing = vi + .spyOn(crypto.subtle, "sign") + .mockImplementationOnce(async (...args) => { + await gate; + return sign(...args); + }); + const pending = f.notes.library.replaceDocument( + current.id, + input, + current.revision, + ); + await vi.waitFor(() => expect(signing).toHaveBeenCalled()); + if (failure === "reauth") establish(identity); + else await f.notes.erase(); + before = (await readRecord("devices", identity.email))!; + release(); + await expect(pending).rejects.toThrow(); + } + const after = (await readRecord("devices", identity.email))!; + expect(after.snapshot).toEqual(before.snapshot); + expect(after.generation).toBe(before.generation); + if (failure === "quota" || failure === "stale-revision") + expect(await f.service.resolve(saved.entryId)).toMatchObject({ + status: "attached", + }); + }, +); +it("captures the full explicit replacement draft before wrapping crypto awaits", async () => { + const f = await setup(), + { saved } = await f.make(), + current = (await f.notes.get(f.note.id))!; + const input = { + title: "Captured title", + document: structuredClone(current.document), + tags: ["captured"], + }; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const sign = crypto.subtle.sign.bind(crypto.subtle), + signing = vi + .spyOn(crypto.subtle, "sign") + .mockImplementationOnce(async (...args) => { + await gate; + return sign(...args); + }); + const pending = f.notes.library.replaceDocument( + current.id, + input, + current.revision, + ); + await vi.waitFor(() => expect(signing).toHaveBeenCalled()); + input.title = "Changed later"; + input.tags.push("not captured"); + input.document.content = []; + release(); + const savedNote = await pending; + expect(savedNote.title).toBe("Captured title"); + expect(savedNote.tags).toEqual(["captured"]); + expect(savedNote.document).toEqual(current.document); + expect(await f.service.resolve(saved.entryId)).toEqual({ + status: "unavailable", + reason: "stale", + }); +}); +it("stales attached anchors on an unproved history restore even when document bytes are identical", async () => { + const f = await setup(); + await f.notes.update(f.note.id, { title: "A later title" }, f.note.revision); + const version = (await f.notes.library.history(f.note.id))[0]!; + const { saved } = await f.make(); + expect(await f.service.resolve(saved.entryId)).toMatchObject({ + status: "attached", + noteRevision: 2, + }); + const before = (await f.notes.get(f.note.id))!; + expect(version.document).toEqual(before.document); + await f.notes.library.restoreVersion( + f.note.id, + version.id, + before.revision, + crypto.randomUUID(), + ); + expect((await f.notes.get(f.note.id))!.document).toEqual(before.document); + expect(await f.service.resolve(saved.entryId)).toEqual({ + status: "unavailable", + reason: "stale", + }); +}); +it("never jumps to another identical quote after deletion or unverified replacement/history restore", async () => { + const f = await setup(), + { saved } = await f.make(); + await f.edit((tr) => { + tr.delete(8, 18); + }); + expect(await f.service.resolve(saved.entryId)).toEqual({ + status: "unavailable", + reason: "stale", + }); + const current = (await f.notes.get(f.note.id))!; + await f.notes.update(current.id, { document }, current.revision); + expect(await f.service.resolve(saved.entryId)).toEqual({ + status: "unavailable", + reason: "stale", + }); +}); +it("reconciles unknown committed create and Undo without duplicate cards or a second deletion", async () => { + const f = await setup(); + const selection = await f.selection(), + draft: MakeCardDraft = { + type: "basic", + deckId: (await f.collections.read()).study.decks[0]!.id, + tags: [], + front: "Actual question", + back: "Actual answer", + }; + const intent = await f.service.prepareCreate( + selection, + await f.expected(), + draft, + ); + const snapshotBefore = (await readRecord( + "devices", + identity.email, + ))!; + const put = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException("Synthetic source quota", "QuotaExceededError"); + }); + await expect(f.service.commit(intent)).rejects.toThrow("source quota"); + put.mockRestore(); + const afterFailure = (await readRecord( + "devices", + identity.email, + ))!; + expect(afterFailure.snapshot).toEqual(snapshotBefore.snapshot); + expect(afterFailure.generation).toBe(snapshotBefore.generation); + const owner = studySourceOwner(f.notes), + realCommit = owner.commit.bind(owner); + vi.spyOn(owner, "commit").mockImplementationOnce(async (...args) => { + await realCommit(...args); + throw new Error("Synthetic unknown create result"); + }); + await expect(f.service.commit(intent)).rejects.toThrow( + "unknown create result", + ); + const saved = await f.service.commit(intent), + replay = await f.service.commit(intent); + expect(replay.replayed).toBe(true); + expect(replay.receipt).toEqual(saved.receipt); + const undo = await f.service.prepareUndo( + saved.tokens, + saved.receipt.operationId, + ); + vi.spyOn(owner, "commit").mockImplementationOnce(async (...args) => { + await realCommit(...args); + throw new Error("Synthetic unknown Undo result"); + }); + await expect(f.service.commit(undo)).rejects.toThrow("unknown Undo result"); + const undone = await f.service.commit(undo); + expect(undone.source).toEqual({ status: "unavailable", reason: "missing" }); + expect((await f.collections.read()).study.entries).toHaveLength(0); + const beforeReplay = (await readRecord( + "devices", + identity.email, + ))!; + const retry = await f.service.commit(undo); + expect(retry.replayed).toBe(true); + expect(retry.receipt).toEqual(undone.receipt); + const afterReplay = (await readRecord( + "devices", + identity.email, + ))!; + expect(afterReplay.snapshot).toEqual(beforeReplay.snapshot); + expect(afterReplay.generation).toBe(beforeReplay.generation); +}); +it.each(["review", "source-edit", "expiry"] as const)( + "refuses create Undo after %s", + async (reason) => { + const f = await setup(), + { saved } = await f.make(); + if (reason === "review") { + const current = await f.collections.read(); + await f.collections.update(current, (current) => ({ + ...current, + study: gradeCard( + current.study, + saved.cardIds[0]!, + "good", + crypto.randomUUID(), + crypto.randomUUID(), + new Date(), + ), + })); + } else if (reason === "source-edit") + await f.edit((tr) => { + tr.insert(1, tr.doc.type.schema.text("Outside ")); + }); + else { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 5001); + } + const undo = await f.service.prepareUndo( + await f.expected(), + saved.receipt.operationId, + ); + await expect(f.service.commit(undo)).rejects.toThrow( + "source_undo_unavailable", + ); + expect((await f.collections.read()).study.entries).toHaveLength(1); + }, +); +it("rejects forged source birth through the ordinary Collections writer", async () => { + const f = await setup(); + await f.make(); + const current = await f.collections.read(); + await expect( + f.collections.update(current, (current) => { + current.study.entries[0]!.source!.noteRevision++; + return current; + }), + ).rejects.toThrow("Anchored source birth"); +}); +it.each(["lock", "erase", "close"] as const)( + "revokes prepared work on %s", + async (reason) => { + const f = await setup(), + selection = await f.selection(), + expected = await f.expected(); + const intent = await f.service.prepareCreate(selection, expected, { + type: "basic", + deckId: (await f.collections.read()).study.decks[0]!.id, + tags: [], + front: "Question", + back: "Answer", + }); + if (reason === "lock") establish(identity); + else if (reason === "close") f.service.close(); + else await f.notes.erase(); + await expect(f.service.commit(intent)).rejects.toThrow(); + const current = await openCollectionsStore(identity); + handles.add(current); + expect((await current.read()).study.entries).toHaveLength(0); + }, +); +it("revokes hashing across erase and rejects source-aware note proof hashing across same-handle erase", async () => { + const f = await setup(), + selection = await f.selection(), + expected = await f.expected(), + draft: MakeCardDraft = { + type: "basic", + deckId: (await f.collections.read()).study.decks[0]!.id, + tags: [], + front: "Question", + back: "Answer", + }; + let release!: () => void; + let gate = new Promise((resolve) => { + release = resolve; + }); + const digest = crypto.subtle.digest.bind(crypto.subtle); + const hashing = vi + .spyOn(crypto.subtle, "digest") + .mockImplementationOnce(async (...args) => { + await gate; + return digest(...args); + }); + const pending = f.service.prepareCreate(selection, expected, draft); + await vi.waitFor(() => expect(hashing).toHaveBeenCalled()); + await f.notes.erase(); + release(); + await expect(pending).rejects.toThrow("source_revoked"); + hashing.mockRestore(); + const fresh = await f.notes.create({ + title: "New epoch", + document, + tags: [], + }), + base = await fingerprintSourceDocument(fresh.document); + gate = new Promise((resolve) => { + release = resolve; + }); + const proofHash = vi + .spyOn(crypto.subtle, "digest") + .mockImplementationOnce(async (...args) => { + await gate; + return digest(...args); + }); + const updating = f.notes.library.updateWithSourceSteps( + fresh.id, + { title: "Old intent" }, + fresh.revision, + { baseDigest: base.digest, finalDigest: base.digest, steps: [] }, + ); + await vi.waitFor(() => expect(proofHash).toHaveBeenCalled()); + await f.notes.erase(); + release(); + await expect(updating).rejects.toThrow("epoch"); + expect(await f.notes.list()).toEqual([]); +}); +it("revokes a pending atomic writer when the source facade closes while wrapping crypto is delayed", async () => { + const f = await setup(), + intent = await f.service.prepareCreate( + await f.selection(), + await f.expected(), + { + type: "basic", + deckId: (await f.collections.read()).study.decks[0]!.id, + tags: [], + front: "Question", + back: "Answer", + }, + ); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const sign = crypto.subtle.sign.bind(crypto.subtle), + signing = vi + .spyOn(crypto.subtle, "sign") + .mockImplementationOnce(async (...args) => { + await gate; + return sign(...args); + }); + const pending = f.service.commit(intent); + await vi.waitFor(() => expect(signing).toHaveBeenCalled()); + f.service.close(); + release(); + await expect(pending).rejects.toThrow("source_revoked"); + expect((await f.collections.read()).study.entries).toEqual([]); +}); +it("bounds many short annotations in a large nested document before batch mapping, while title-only changes remain usable", async () => { + const f = await setup(), + large: NoteDocument = { + type: "doc", + content: Array.from({ length: 2000 }, () => ({ + type: "blockquote", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "a".repeat(400) }], + }, + ], + })), + }; + await f.notes.update(f.note.id, { document: large }, f.note.revision); + await f.make(); + await libraryEdit((state) => { + const anchor = state.anchors[0]; + // Valid orphan sidecars can remain after independently deleting Study entries. + for (let i = 0; i < 50; i++) + state.anchors.push({ + ...structuredClone(anchor), + id: crypto.randomUUID(), + entryId: crypto.randomUUID(), + }); + }); + const current = (await f.notes.get(f.note.id))!, + fingerprint = await fingerprintSourceDocument(current.document), + schema = createNoteSchema(), + tr = new Transform(schema.nodeFromJSON(fingerprint.document)); + tr.addMark(8, 18, schema.marks.bold!.create()); + const final = await fingerprintSourceDocument(tr.doc.toJSON()), + before = (await readRecord("devices", identity.email))!; + await expect( + f.notes.library.updateWithSourceSteps( + current.id, + { document: final.document }, + current.revision, + { + baseDigest: fingerprint.digest, + finalDigest: final.digest, + steps: tr.steps.map((step) => step.toJSON()), + }, + ), + ).rejects.toThrow("work limit"); + const after = (await readRecord("devices", identity.email))!; + expect(after.snapshot).toEqual(before.snapshot); + expect(after.generation).toBe(before.generation); + expect( + ( + await f.notes.update( + current.id, + { title: "Metadata remains editable" }, + current.revision, + ) + ).title, + ).toBe("Metadata remains editable"); +}, 15000);