From bcdc182975f230eb47e57f7376542246245c8ad9 Mon Sep 17 00:00:00 2001 From: Luan Nguyen Date: Wed, 11 Nov 2026 21:28:01 +1100 Subject: [PATCH] feat(storage): add encrypted content owners and migrations --- docs/architecture.md | 4 + web/src/collections/store.ts | 544 ++++++ web/src/data/database.ts | 20 +- web/src/data/index.ts | 214 +-- web/src/library/storage.ts | 111 ++ web/src/library/store.ts | 1574 +++++++++++++++++ web/src/search/service.ts | 659 +++++++ web/src/search/source.ts | 678 +++++++ web/tests/data/collections-close.test.ts | 118 ++ web/tests/data/collections-linear.test.ts | 179 ++ web/tests/data/collections.test.ts | 712 ++++++++ web/tests/data/library-migration.test.ts | 404 +++++ web/tests/data/library.test.ts | 725 ++++++++ .../data/search-service-integration.test.ts | 193 ++ web/tests/data/search-service.test.ts | 364 ++++ web/tsconfig.data.json | 1 + 16 files changed, 6284 insertions(+), 216 deletions(-) create mode 100644 web/src/collections/store.ts create mode 100644 web/src/library/storage.ts create mode 100644 web/src/library/store.ts create mode 100644 web/src/search/service.ts create mode 100644 web/src/search/source.ts create mode 100644 web/tests/data/collections-close.test.ts create mode 100644 web/tests/data/collections-linear.test.ts create mode 100644 web/tests/data/collections.test.ts create mode 100644 web/tests/data/library-migration.test.ts create mode 100644 web/tests/data/library.test.ts create mode 100644 web/tests/data/search-service-integration.test.ts create mode 100644 web/tests/data/search-service.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 24ff2d6..608f494 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,3 +72,7 @@ matching close-request acknowledgement. The WebView profile remains separate from browser profiles; builds do not migrate or erase it. This is an unsigned local build, with no installer or public release qualification. See [Windows build and boundaries](windows.md). + +## Delivery boundary: encrypted-owners + +Versioned library and Collections owners capture account/epoch authority for every commit. Migration and workspace erase use the pinned engine adaptation and preserve the previous encrypted snapshot on failure. Account identity, trust, approval checkpoints and unrelated zones are retained. These domain adapters precede workspace wiring in this delivery stack. diff --git a/web/src/collections/store.ts b/web/src/collections/store.ts new file mode 100644 index 0000000..eec6d21 --- /dev/null +++ b/web/src/collections/store.ts @@ -0,0 +1,544 @@ +import { SearchSourceError } from "../search/contracts"; +import { + createSearchAdmission, + collectionsSearchData, + type SearchOwnerAccess, + type CollectionsSearchData, +} from "../search/source"; +import type { AccountIdentity, PersistenceState } from "../domain/contracts"; +import { lockVersion, onLock, requireSession } from "../account/session"; +import { loadEngine, DeviceKeys, SyncEngine } from "../data/engine"; +import { + mutateRecord, + readRecord, + wrappingSecret, + UNCHANGED_RECORD, +} from "../data/database"; +import type { DeviceRecord } from "../data/database"; +import type { + CollectionsState, + CollectionsRevision, + CollectionsStore, + JournalEnvelope, +} from "./contracts"; +import { CollectionsRevisionConflictError } from "./contracts"; +import { + COLLECTIONS_LIMITS, + emptyStudy, + validateCollections, + validateJournalEnvelope, + normalizeCollections, +} from "./validation"; +import { emptyClips } from "../clips/validation"; + +export const COLLECTIONS_ZONE = "notes-collections-v1"; +const RECORD = "workspace"; +type Internal = { + assertOpen(): void; + search: SearchOwnerAccess; + replace( + expected: CollectionsRevision, + envelope: JournalEnvelope | null, + current?: () => void, + ): Promise; + epochListeners: Set<(epoch: string) => void>; + closeListeners: Set<() => void>; +}; +const internals = new WeakMap(); + +export function collectionsSearchAdmission( + store: CollectionsStore, +): SearchOwnerAccess { + const owner = internals.get(store); + if (!owner) + throw new Error( + "search_source_invalid: Genuine Collections owner required.", + ); + return owner.search; +} +function freshState(revision = 0): CollectionsState { + let timezone = "UTC"; + try { + const local = Intl.DateTimeFormat().resolvedOptions().timeZone; + new Intl.DateTimeFormat("en", { timeZone: local }); + if (typeof local === "string" && local) timezone = local; + } catch { + /* A host without valid timezone data uses the explicit UTC fallback. */ + } + return { + version: 3, + epoch: crypto.randomUUID(), + revision, + study: emptyStudy(timezone), + journal: null, + clips: emptyClips(), + }; +} +function readEngine(engine: SyncEngine): CollectionsState | null { + const raw = engine.record(COLLECTIONS_ZONE, RECORD); + if (!raw) return null; + if (raw.length > COLLECTIONS_LIMITS.serializedBytes * 2) + throw new Error("collections_invalid: Collections payload is too large."); + const decoded: unknown = JSON.parse(raw); + const wrapper = decoded as { + record_type?: unknown; + fields?: { collections?: { value?: unknown } }; + }; + if (wrapper?.record_type !== "notes_collections") + throw new Error( + "collections_invalid: Collections record owner is not valid.", + ); + return normalizeCollections(wrapper?.fields?.collections?.value); +} +function stage( + engine: SyncEngine, + state: CollectionsState, + exists: boolean, +): void { + validateCollections(state); + if (!exists) engine.createZoneKey(COLLECTIONS_ZONE); + engine.stage( + COLLECTIONS_ZONE, + "notes_collections", + RECORD, + JSON.stringify({ + collections: { value: state, stamp: JSON.parse(engine.tick(Date.now())) }, + }), + ); +} +/** Capture before zone purge; invoke after purge in the SAME synchronous snapshot transaction. */ +export function prepareCollectionsErase(engine: SyncEngine): () => void { + const current = readEngine(engine); + if (current?.revision === Number.MAX_SAFE_INTEGER) + throw new Error( + "collections_revision_exhausted: Collections cannot be changed safely.", + ); + const replacement = freshState((current?.revision ?? 0) + 1); + return () => stage(engine, replacement, false); +} +/** Internal cross-domain writer hooks. No independent snapshot or asynchronous callback. */ +export function readCollectionsForTransaction( + engine: SyncEngine, +): CollectionsState { + const state = readEngine(engine); + if (!state) + throw new Error( + "collections_missing: Open Study before creating a source card.", + ); + return state; +} +export function stageCollectionsForTransaction( + engine: SyncEngine, + state: CollectionsState, +): void { + stage(engine, validateCollections(state), true); +} +export function assertCollectionsAccess(store: CollectionsStore): void { + const owner = internals.get(store); + if (!owner) throw new Error("locked: Collections owner is unavailable."); + owner.assertOpen(); +} +/** Private consumers revoke immediately when their borrowed genuine owner closes. */ +export function onCollectionsClose( + store: CollectionsStore, + listener: () => void, +): () => void { + const owner = internals.get(store); + if (!owner) throw new Error("locked: Collections owner is unavailable."); + owner.assertOpen(); + owner.closeListeners.add(listener); + return () => owner.closeListeners.delete(listener); +} +/** Internal JournalVault boundary; intentionally absent from the general store interface. */ +export function replaceJournal( + store: CollectionsStore, + expected: CollectionsRevision, + envelope: JournalEnvelope | null, + current?: () => void, +): Promise { + const internal = internals.get(store); + if (!internal) + return Promise.reject(new Error("locked: Collections are closed.")); + return internal.replace(expected, envelope, current); +} +/** Vault revokes leases synchronously before ordinary change subscribers see a new epoch. */ +export function onCollectionsEpochChange( + store: CollectionsStore, + listener: (epoch: string) => void, +): () => void { + const internal = internals.get(store); + if (!internal) throw new Error("locked: Collections are closed."); + internal.assertOpen(); + internal.epochListeners.add(listener); + return () => internal.epochListeners.delete(listener); +} +function sameEnvelope( + a: JournalEnvelope | null, + b: JournalEnvelope | null, +): boolean { + return a === null || b === null + ? a === b + : a.version === b.version && + a.kdf.name === b.kdf.name && + a.kdf.iterations === b.kdf.iterations && + a.kdf.salt === b.kdf.salt && + a.iv === b.iv && + a.ciphertext === b.ciphertext; +} +function sameHistory( + current: CollectionsState, + next: CollectionsState, +): boolean { + const events = new Map(next.study.events.map((event) => [event.id, event])); + return current.study.events.every((event) => { + const after = events.get(event.id); + return ( + after && + sameValue({ ...after, undoneAt: event.undoneAt }, event) && + (event.undoneAt === null || after.undoneAt === event.undoneAt) + ); + }); +} +function sameValue(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (!a || !b || typeof a !== "object" || typeof b !== "object") return false; + const left = a as Record, + right = b as Record; + const keys = Object.keys(left); + return ( + keys.length === Object.keys(right).length && + keys.every( + (key) => Object.hasOwn(right, key) && sameValue(left[key], right[key]), + ) + ); +} + +export async function openCollectionsStore( + input: AccountIdentity, +): Promise { + const identity = structuredClone(input); + const openedVersion = lockVersion(); + requireSession(identity.accountId, identity.deviceId); + let closed = false; + let searchAdmission: ReturnType | null = null; + let observedEpoch: string | undefined; + let persistence: PersistenceState = { status: "ready" }; + const listeners = new Set<() => void>(); + const epochListeners = new Set<(epoch: string) => void>(); + const closeListeners = new Set<() => void>(); + let channel: BroadcastChannel | null = null; + const stopLock = onLock(close); + function assertOpen() { + if (closed || lockVersion() !== openedVersion) + throw new Error("locked: Collections are closed."); + requireSession(identity.accountId, identity.deviceId); + } + function close() { + if (closed) return; + closed = true; + searchAdmission?.close(); + for (const listener of closeListeners) { + try { + listener(); + } catch { + /* One consumer cannot prevent the remaining consumers from revoking. */ + } + } + closeListeners.clear(); + stopLock(); + channel?.close(); + listeners.clear(); + epochListeners.clear(); + } + function notify() { + if (closed) return; + for (const listener of listeners) { + try { + listener(); + } catch { + /* An observer cannot change commit results. */ + } + } + } + function observe(state: CollectionsState) { + assertOpen(); + if (observedEpoch !== undefined && observedEpoch !== state.epoch) { + for (const listener of epochListeners) listener(state.epoch); + } + observedEpoch = state.epoch; + } + let initial: DeviceRecord; + try { + await loadEngine(); + assertOpen(); + const found = await readRecord("devices", identity.email); + assertOpen(); + if ( + !found || + found.accountId !== identity.accountId || + found.deviceId !== identity.deviceId + ) + throw new Error("keys_missing: Device keys are unavailable."); + initial = found; + searchAdmission = createSearchAdmission( + identity, + initial.wrapKey, + assertOpen, + async () => { + await access(null); + }, + ); + channel = + typeof BroadcastChannel === "undefined" + ? null + : new BroadcastChannel(`orbyn-notes-library:${identity.accountId}`); + channel?.addEventListener("message", () => { + searchAdmission?.unverified(); + void access(null) + .then(() => notify()) + .catch(() => { + if (!closed) { + persistence = { + status: "failed", + message: "Collections could not be refreshed.", + }; + notify(); + } + }); + }); + // Persist exactly one initial epoch atomically and authenticate before reporting readiness. + await access(null, true); + } catch (error) { + close(); + throw error; + } + + async function access( + change: ((current: CollectionsState) => CollectionsState) | null, + initialize = false, + beforeStage?: () => void, + ): Promise { + assertOpen(); + const secret = await wrappingSecret(initial.wrapKey); + try { + assertOpen(); + let result: CollectionsState | undefined; + let searchCandidate: { + record: DeviceRecord; + before?: DeviceRecord; + data: CollectionsSearchData | null; + } | null = null; + function apply( + record: DeviceRecord | undefined, + ): DeviceRecord | typeof UNCHANGED_RECORD { + assertOpen(); + if ( + !record || + record.accountId !== identity.accountId || + record.deviceId !== identity.deviceId + ) + throw new Error("keys_missing: Device keys changed."); + const keys = DeviceKeys.import(record.sealedKeys, secret); + let engine: SyncEngine | undefined; + try { + engine = record.snapshot + ? SyncEngine.restore(keys, record.snapshot, secret, Date.now()) + : SyncEngine.create(keys, record.accountId, record.deviceId); + const persisted = readEngine(engine); + if (!persisted && !initialize) + throw new Error( + "collections_missing: Reopen the workspace before retrying.", + ); + const current = persisted ?? freshState(); + result = change + ? validateCollections(change(structuredClone(current))) + : current; + assertOpen(); + if (!persisted || change) { + const guarded: unknown = beforeStage?.(); + if ( + guarded && + typeof (guarded as { then?: unknown }).then === "function" + ) { + void Promise.resolve(guarded).catch(() => {}); + throw new Error( + "collections_invalid: Commit freshness checks must be synchronous.", + ); + } + stage(engine, result, persisted !== null); + const nextRecord = { + ...record, + snapshot: engine.snapshot(secret), + generation: record.generation + 1, + }; + if (searchAdmission) + searchCandidate = { + record: nextRecord, + before: record, + data: collectionsSearchData(result), + }; + return nextRecord; + } + if (searchAdmission) + searchCandidate = { + record, + data: searchAdmission.sameImage(record) + ? null + : collectionsSearchData(result), + }; + return UNCHANGED_RECORD; + } finally { + engine?.free(); + keys.free(); + } + } + if (change || initialize) + await mutateRecord("devices", identity.email, apply); + else { + const record = await readRecord( + "devices", + identity.email, + ); + assertOpen(); + apply(record); + } + if (searchCandidate && searchAdmission) { + const candidate = searchCandidate as { + record: DeviceRecord; + before?: DeviceRecord; + data: CollectionsSearchData | null; + }; + try { + await searchAdmission.publish( + candidate.record, + candidate.data, + candidate.before, + ); + } catch { + /* Search failure cannot change an already committed Collections outcome. */ + } + } + assertOpen(); + observe(result!); + assertOpen(); + return structuredClone(result!); + } finally { + secret.fill(0); + } + } + async function commit( + expectedInput: CollectionsRevision, + change: (current: CollectionsState) => CollectionsState, + journalAllowed: boolean, + currentGuard?: () => void, + ): Promise { + const expected = structuredClone(expectedInput); + if ( + !expected || + typeof expected.epoch !== "string" || + !Number.isSafeInteger(expected.revision) || + expected.revision < 0 || + typeof change !== "function" + ) + throw new Error("collections_invalid: Invalid mutation request."); + assertOpen(); + persistence = { status: "saving" }; + notify(); + try { + const result = await access( + (current) => { + if ( + current.epoch !== expected.epoch || + current.revision !== expected.revision + ) + throw new CollectionsRevisionConflictError( + structuredClone(current), + ); + const attempted = change(structuredClone(current)); + if ( + attempted && + typeof (attempted as unknown as { then?: unknown }).then === + "function" + ) { + void Promise.resolve(attempted).catch(() => {}); + throw new Error( + "collections_invalid: Collection mutations must be synchronous.", + ); + } + const next = validateCollections(attempted); + const currentSources = new Map( + current.study.entries.map((entry) => [entry.id, entry.source]), + ); + for (const entry of next.study.entries) { + if (entry.source && "version" in entry.source) { + const original = currentSources.get(entry.id); + if (JSON.stringify(entry.source) !== JSON.stringify(original)) + throw new Error( + "collections_invalid: Anchored source birth belongs to the source writer.", + ); + } + } + if ( + next.version !== current.version || + next.epoch !== current.epoch || + next.revision !== current.revision || + (!journalAllowed && !sameEnvelope(current.journal, next.journal)) || + !sameHistory(current, next) + ) + throw new Error( + "collections_invalid: Mutation changed store-owned data.", + ); + if (current.revision === Number.MAX_SAFE_INTEGER) + throw new Error( + "collections_revision_exhausted: Collections cannot be changed safely.", + ); + return { ...next, revision: current.revision + 1 }; + }, + false, + currentGuard, + ); + assertOpen(); + persistence = { status: "ready" }; + channel?.postMessage("changed"); + notify(); + assertOpen(); + return result; + } catch (error) { + if (!closed) { + persistence = { + status: "failed", + message: "Collections could not be saved. Reload before retrying.", + }; + notify(); + } + throw error; + } + } + const store: CollectionsStore = { + read: () => access(null), + update: (expected, change) => commit(expected, change, false), + subscribe: (listener) => { + assertOpen(); + listeners.add(listener); + return () => listeners.delete(listener); + }, + persistence: () => ({ ...persistence }), + close, + }; + internals.set(store, { + search: searchAdmission!.access, + assertOpen, + epochListeners, + closeListeners, + replace: (expected, envelope, current) => { + const stable = validateJournalEnvelope(envelope); + return commit( + expected, + (current) => ({ ...current, journal: stable }), + true, + current, + ); + }, + }); + return store; +} diff --git a/web/src/data/database.ts b/web/src/data/database.ts index 8646bab..a397e53 100644 --- a/web/src/data/database.ts +++ b/web/src/data/database.ts @@ -58,8 +58,12 @@ export type LocalAccessGrant = { export function openDatabase(): Promise { return new Promise((resolve, reject) => { let failed = false; - const request = indexedDB.open("orbyn-notes-local-v1", 2); + const request = indexedDB.open("orbyn-notes-local-v1", 3); request.onupgradeneeded = () => { + if (failed) { + request.transaction?.abort(); + return; + } for (const store of ["devices", "signups", "access"]) if (!request.result.objectStoreNames.contains(store)) request.result.createObjectStore(store); @@ -105,10 +109,12 @@ export async function readRecord( } } // Callback must be synchronous: WebCrypto/WASM preparation happens before this atomic CAS. +// Explicitly skip a put without changing the existing in-place callback contract. +export const UNCHANGED_RECORD: unique symbol = Symbol("unchanged-record"); export async function mutateRecord( store: string, key: string, - change: (current: T | undefined) => T | undefined, + change: (current: T | undefined) => T | undefined | typeof UNCHANGED_RECORD, ): Promise { const db = await openDatabase(); try { @@ -119,7 +125,15 @@ export async function mutateRecord( let failure: unknown; req.onsuccess = () => { try { - const next = change(req.result as T | undefined); + const current = req.result as T | undefined; + const next = change(current); + if (next && typeof (next as { then?: unknown }).then === "function") { + void Promise.resolve(next).catch(() => {}); + throw new Error( + "storage_failed: Transaction callbacks must be synchronous.", + ); + } + if (next === UNCHANGED_RECORD) return; if (next === undefined) objectStore.delete(key); else objectStore.put(next, key); } catch (error) { diff --git a/web/src/data/index.ts b/web/src/data/index.ts index f3fb118..f5d0d82 100644 --- a/web/src/data/index.ts +++ b/web/src/data/index.ts @@ -1,213 +1 @@ -import type { - AccountIdentity, - Note, - NoteDocument, - NotesStore, - PersistenceState, -} from "../domain/contracts"; -import { RevisionConflictError } from "../domain/contracts"; -import { validateInput, validateNotes } from "../domain/validation"; -import { requireSession, onLock } from "../account/session"; -import { loadEngine, DeviceKeys, SyncEngine } from "./engine"; -import { readRecord, mutateRecord, wrappingSecret } from "./database"; -import type { DeviceRecord } from "./database"; -const ZONE = "notes-local"; -const RECORD = "library"; -export async function openNotesStore( - identity: AccountIdentity, -): Promise { - requireSession(identity.accountId, identity.deviceId); - await loadEngine(); - const initial = await readRecord("devices", identity.email); - if ( - !initial || - initial.accountId !== identity.accountId || - initial.deviceId !== identity.deviceId - ) - throw new Error("keys_missing: This browser has no keys for this account."); - const listeners = new Set<() => void>(); - let closed = false; - let state: PersistenceState = { status: "ready" }; - const channel = - typeof BroadcastChannel === "undefined" - ? null - : new BroadcastChannel(`orbyn-notes-library:${identity.accountId}`); - function notify() { - for (const listener of listeners) listener(); - } - function assertOpen() { - if (closed) throw new Error("locked: This library is closed."); - requireSession(identity.accountId, identity.deviceId); - } - const unsubscribeLock = onLock(close); - function close() { - if (closed) return; - closed = true; - unsubscribeLock(); - channel?.close(); - listeners.clear(); - } - channel?.addEventListener("message", notify); - async function access( - change: ((notes: Note[]) => { notes: Note[]; result: T }) | null, - project?: (notes: Note[]) => T, - ): Promise { - assertOpen(); - const secret = await wrappingSecret(initial!.wrapKey); - let result: T | undefined; - function apply(record: DeviceRecord | undefined): DeviceRecord { - assertOpen(); - if ( - !record || - record.accountId !== identity.accountId || - record.deviceId !== identity.deviceId - ) - throw new Error("keys_missing: Account device changed. Sign in again."); - const keys = DeviceKeys.import(record.sealedKeys, secret); - let engine: SyncEngine | undefined; - try { - engine = record.snapshot - ? SyncEngine.restore(keys, record.snapshot, secret, Date.now()) - : SyncEngine.create(keys, record.accountId, record.deviceId); - const raw = engine.record(ZONE, RECORD); - const notes = raw - ? validateNotes(JSON.parse(raw).fields.notes.value) - : []; - if (!change) { - result = project!(notes); - return record; - } - const next = change(notes); - validateNotes(next.notes); - if (!raw) engine.createZoneKey(ZONE); - engine.stage( - ZONE, - "notes_library", - RECORD, - JSON.stringify({ - notes: { - value: next.notes, - stamp: JSON.parse(engine.tick(Date.now())), - }, - }), - ); - result = next.result; - return { - ...record, - snapshot: engine.snapshot(secret), - generation: record.generation + 1, - }; - } finally { - engine?.free(); - keys.free(); - } - } - try { - if (change) { - state = { status: "saving" }; - notify(); - await mutateRecord("devices", identity.email, apply); - state = { status: "ready" }; - channel?.postMessage("changed"); - notify(); - } else { - apply(await readRecord("devices", identity.email)); - } - return result as T; - } catch (error) { - if (change) { - state = { - status: "failed", - message: - error instanceof Error - ? error.message - : "The library could not be saved.", - }; - notify(); - } - throw error; - } finally { - secret.fill(0); - } - } - function revise( - id: string, - expectedRevision: number, - transform: (note: Note) => Note, - ): Promise { - return access((notes) => { - const index = notes.findIndex((n) => n.id === id); - if (index < 0) - throw new Error("note_missing: This note no longer exists."); - const current = notes[index]!; - const attempted = transform({ - ...current, - revision: expectedRevision + 1, - updatedAt: new Date().toISOString(), - }); - if (current.revision !== expectedRevision) - throw new RevisionConflictError( - structuredClone(current), - structuredClone(attempted), - ); - notes[index] = attempted; - return { notes, result: structuredClone(attempted) }; - }); - } - // Read and authenticate the existing sealed snapshot before reporting ready. - try { - await access(null, (notes) => notes.length); - } catch (error) { - close(); - throw error; - } - return { - list: () => access(null, (notes) => structuredClone(notes)), - get: (id) => - access(null, (notes) => - structuredClone(notes.find((n) => n.id === id) ?? null), - ), - create: async (input) => { - const checked = validateInput(input); - return access((notes) => { - const now = new Date().toISOString(); - const note: Note = { - ...checked, - id: crypto.randomUUID(), - createdAt: now, - updatedAt: now, - trashedAt: null, - revision: 1, - }; - notes.push(note); - return { notes, result: structuredClone(note) }; - }); - }, - update: async (id, patch, revision) => { - const stable = structuredClone(patch); - return revise(id, revision, (n) => ({ - ...n, - ...validateInput({ - title: stable.title ?? n.title, - document: stable.document ?? n.document, - tags: stable.tags ?? n.tags, - }), - })); - }, - trash: (id, revision) => - revise(id, revision, (n) => ({ - ...n, - trashedAt: new Date().toISOString(), - })), - restore: (id, revision) => - revise(id, revision, (n) => ({ ...n, trashedAt: null })), - erase: () => access((notes) => ({ notes: [], result: undefined })), - subscribe: (listener) => { - assertOpen(); - listeners.add(listener); - return () => listeners.delete(listener); - }, - persistence: () => ({ ...state }), - close, - }; -} +export { openNotesStore } from "../library/store"; diff --git a/web/src/library/storage.ts b/web/src/library/storage.ts new file mode 100644 index 0000000..9a8fdba --- /dev/null +++ b/web/src/library/storage.ts @@ -0,0 +1,111 @@ +import { SyncEngine } from "../data/engine"; +import { validateNotes } from "../domain/validation"; +import type { Note } from "../domain/contracts"; +import type { LibraryState, LibraryCommonState } from "./contracts"; +import { LibraryCapacityError } from "./contracts"; +import { adoptLibrary } from "./model"; +import { + invalid, + LIBRARY_LIMITS, + validateLibrary, + normalizeLibrary, + validateLibraryCommon, +} from "./validation"; +export const CORE_ZONE = "notes-local"; +const CORE_RECORD = "library"; +export const LIBRARY_ZONE = "notes-library-v1"; +const LIBRARY_RECORD = "workspace"; +export function readCore( + engine: SyncEngine, + policy: "plain" | "notes" = "plain", +): { + notes: Note[]; + original: unknown[]; + exists: boolean; + identity: string; +} { + const raw = engine.record(CORE_ZONE, CORE_RECORD); + const record = raw ? JSON.parse(raw) : null; + if (record && record.record_type !== "notes_library") invalid(); + const original = record ? record.fields.notes.value : []; + return { + notes: validateNotes(original, policy), + original, + exists: raw !== undefined, + identity: raw ?? "", + }; +} +export function readLibraryEnvelope(engine: SyncEngine): { + value: unknown | null; + identity: string; +} { + const raw = engine.record(LIBRARY_ZONE, LIBRARY_RECORD); + if (!raw) return { value: null, identity: "" }; + if (raw.length > LIBRARY_LIMITS.bytes * 2) throw new LibraryCapacityError(); + const record = JSON.parse(raw); + if (record.record_type !== "notes_library_metadata") invalid(); + const value: unknown = record.fields?.library?.value; + if (!value || typeof value !== "object" || Array.isArray(value)) invalid(); + return { value, identity: raw }; +} +export function readLibraryHeader(engine: SyncEngine): unknown | null { + return readLibraryEnvelope(engine).value; +} +export function readLibrary( + engine: SyncEngine, + notes: Note[], +): LibraryState | null { + const value = readLibraryHeader(engine); + return value === null ? null : normalizeLibrary(value, notes); +} +export function readLibraryCommon( + value: unknown, + notes: Note[], +): LibraryCommonState | null { + if (value === null) return null; + return (value as { version?: unknown }).version === 3 + ? validateLibraryCommon(value, notes) + : normalizeLibrary(value, notes); +} +export function adopt( + engine: SyncEngine, + notes: Note[], + original: unknown[], + now: string, +): LibraryState { + return validateLibrary(adoptLibrary(notes, original, now), notes); +} +export function stageCore( + engine: SyncEngine, + notes: Note[], + exists: boolean, + policy: "plain" | "notes" = "plain", +): void { + validateNotes(notes, policy); + if (!exists) engine.createZoneKey(CORE_ZONE); + engine.stage( + CORE_ZONE, + "notes_library", + CORE_RECORD, + JSON.stringify({ + notes: { value: notes, stamp: JSON.parse(engine.tick(Date.now())) }, + }), + ); +} +export function stageLibrary( + engine: SyncEngine, + state: LibraryState, + notes: Note[], + exists: boolean, +): void { + validateLibrary(state, notes); + if (!exists) engine.createZoneKey(LIBRARY_ZONE); + engine.stage( + LIBRARY_ZONE, + "notes_library_metadata", + LIBRARY_RECORD, + JSON.stringify({ + library: { value: state, stamp: JSON.parse(engine.tick(Date.now())) }, + }), + ); +} diff --git a/web/src/library/store.ts b/web/src/library/store.ts new file mode 100644 index 0000000..787272c --- /dev/null +++ b/web/src/library/store.ts @@ -0,0 +1,1574 @@ +import { SearchSourceError } from "../search/contracts"; +import { + createSearchAdmission, + notesSearchData, + type SearchOwnerAccess, + type NotesSearchData, +} from "../search/source"; +import { NoteLinkError, NoteLinkConflictError } from "../links/contracts"; +import type { NoteLinksRow } from "../links/contracts"; +import type { + LinkOwnerAccess, + LinkOwnerSnapshot, + LinkCertificate, +} from "../links/model"; +import { sameOwnerState, linkToken } from "../links/model"; +import { + charge, + inspectLinkDocument, + scanLinkDocument, + sameTargets, + validateLinkRows, + NOTE_LINK_LIMITS, +} from "../links/scanner"; +import type { + AccountIdentity, + Note, + PersistenceState, +} from "../domain/contracts"; +import { RevisionConflictError } from "../domain/contracts"; +import { + validateNoteInput as validateInput, + validateNotes, +} from "../domain/validation"; +import { requireSession, onLock, lockVersion } from "../account/session"; +import { loadEngine, DeviceKeys, SyncEngine } from "../data/engine"; +import { + readRecord, + mutateRecord, + wrappingSecret, + UNCHANGED_RECORD, +} from "../data/database"; +import type { DeviceRecord } from "../data/database"; +import { + COLLECTIONS_ZONE, + prepareCollectionsErase, + readCollectionsForTransaction, + stageCollectionsForTransaction, +} from "../collections/store"; +import type { CollectionsState } from "../collections/contracts"; +import type { + SourceOwnerAccess, + SourceOwnerSnapshot, +} from "../study/sourceContracts"; +import { SourceConflictError, SourceError } from "../study/sourceContracts"; +import { applySourceCommand, sourceTokens } from "../study/sourceModel"; +import { + canonicalSourceDocument, + fingerprintSourceDocument, + verifySourceEdit, + mapSourcePassage, + sourceEditCost, + SOURCE_CODEC_LIMITS, +} from "../study/sourceCodec"; +import type { + LibraryAction, + LibraryMutationResult, + LibraryOperationReceipt, + LibraryState, + LibraryCommonState, + LibraryToken, + NotesLibrary, + NotesLibraryStore, +} from "./contracts"; +import { + LibraryCapacityError, + LibraryConflictError, + LibraryEpochError, + LibraryReadOnlyError, +} from "./contracts"; +import { + activeTag, + captureVersion, + deleteTag, + emptyLibrary, + internTag, + mergeTags, + removeNotes, + renameTag, + retainHistory, + retainReceipts, + sameDocument, + snapshot, + syncMembership, +} from "./model"; +import { + adopt, + CORE_ZONE, + LIBRARY_ZONE, + readCore, + readLibraryEnvelope, + readLibraryCommon, + stageCore, + stageLibrary, +} from "./storage"; +import { + color, + invalid, + integer, + LIBRARY_LIMITS, + noteId, + object, + sort, + tagName, + token, + undo, + uuid, +} from "./validation"; + +type Context = { + notes: Note[]; + library: LibraryState | null; + recoveryCommon: LibraryCommonState | null; + generation: number; + rawVersion: 1 | 2 | 3; + rawIndex: string; + recordIdentity: { core: string; library: string }; + index: LinkOwnerSnapshot["index"]; + skipWrite: boolean; + protectedVersion?: string; + protectedReceipt?: string; + collections?: CollectionsState; + sourceWorkUnits?: number; + sourceEdit?: { + baseJSON: string; + finalJSON: string; + baseDigest: string; + finalDigest: string; + steps: readonly unknown[]; + }; +}; +const searchOwners = new WeakMap(); +export function notesSearchAdmission( + notes: NotesLibraryStore, +): SearchOwnerAccess { + const owner = searchOwners.get(notes.library); + if (!owner) throw new SearchSourceError("search_source_invalid"); + return owner; +} +const linkOwners = new WeakMap(); +export function noteLinkOwner(notes: NotesLibraryStore): LinkOwnerAccess { + const owner = linkOwners.get(notes.library); + if (!owner) throw new NoteLinkError("note_link_revoked"); + return owner; +} +const sourceOwners = new WeakMap(); +export function studySourceOwner(notes: NotesLibraryStore): SourceOwnerAccess { + const owner = sourceOwners.get(notes.library); + if (!owner) + throw new SourceError( + "source_revoked", + "The real Notes source owner is unavailable.", + ); + return owner; +} +export async function openNotesStore( + input: AccountIdentity, +): Promise { + const identity = structuredClone(input), + openedVersion = lockVersion(); + requireSession(identity.accountId, identity.deviceId); + let closed = false, + handleEpoch: string | null = null, + legacyReadOnly = false; + let legacyCertificate: LinkCertificate | null = null, + knownMismatch: { + epoch: string; + revision: number; + rawIndex: string; + } | null = null; + let searchAdmission: ReturnType | null = null; + let state: PersistenceState = { status: "ready" }; + let channel: BroadcastChannel | null = null; + const listeners = new Set<() => void>(); + const stopLock = onLock(close); + function close() { + if (closed) return; + closed = true; + searchAdmission?.close(); + legacyCertificate = null; + knownMismatch = null; + stopLock(); + channel?.close(); + listeners.clear(); + } + function assertOpen() { + if (closed || lockVersion() !== openedVersion) + throw new Error("locked: This library is closed."); + requireSession(identity.accountId, identity.deviceId); + } + function notify() { + if (!closed) + for (const listener of listeners) { + try { + listener(); + } catch { + /* Observers do not control persistence outcomes. */ + } + } + } + let initial: DeviceRecord; + try { + await loadEngine(); + assertOpen(); + const found = await readRecord("devices", identity.email); + assertOpen(); + if ( + !found || + found.accountId !== identity.accountId || + found.deviceId !== identity.deviceId + ) + throw new Error( + "keys_missing: This browser has no keys for this account.", + ); + initial = found; + searchAdmission = createSearchAdmission( + identity, + initial.wrapKey, + assertOpen, + async () => { + await access({ project: () => undefined }); + }, + ); + channel = + typeof BroadcastChannel === "undefined" + ? null + : new BroadcastChannel("orbyn-notes-library:" + identity.accountId); + channel?.addEventListener("message", () => { + searchAdmission?.unverified(); + notify(); + }); + const ready = await access({ + initialize: true, + project: (ctx) => (ctx.library ?? ctx.recoveryCommon)?.epoch ?? null, + }); + handleEpoch = ready; + legacyReadOnly = ready === null; + } catch (error) { + close(); + throw error; + } + + async function access(options: { + initialize?: boolean; + change?: (ctx: Context) => void; + project: (ctx: Context) => T; + erase?: boolean; + legacyErase?: boolean; + collections?: boolean; + repair?: boolean; + beforeStage?: () => void; + }): Promise { + // Immutable invocation fence: adopting an erase result must not admit queued old work. + const operationEpoch = handleEpoch, + operationLockVersion = lockVersion(); + assertOpen(); + if (options.change && legacyReadOnly && !options.legacyErase) + throw new LibraryReadOnlyError(); + const secret = await wrappingSecret(initial.wrapKey); + let result: T | undefined, + resultingEpoch: string | null = null, + wrote = false; + let searchCandidate: { + record: DeviceRecord; + before?: DeviceRecord; + data: NotesSearchData | null; + } | null = null; + async function publishSearch() { + if (searchCandidate && searchAdmission) { + try { + await searchAdmission.publish( + searchCandidate.record, + searchCandidate.data, + searchCandidate.before, + ); + } catch { + /* Optional search admission never changes a committed Notes outcome. */ + } + } + } + const guard = () => { + assertOpen(); + if (lockVersion() !== operationLockVersion) + throw new Error("locked: This library is closed."); + }; + try { + guard(); + function apply( + record: DeviceRecord | undefined, + ): DeviceRecord | typeof UNCHANGED_RECORD { + guard(); + if ( + !record || + record.accountId !== identity.accountId || + record.deviceId !== identity.deviceId + ) + throw new Error( + "keys_missing: Account device changed. Sign in again.", + ); + integer(record.generation); + const keys = DeviceKeys.import(record.sealedKeys, secret); + let engine: SyncEngine | undefined; + try { + engine = record.snapshot + ? SyncEngine.restore(keys, record.snapshot, secret, Date.now()) + : SyncEngine.create(keys, record.accountId, record.deviceId); + const envelope = readLibraryEnvelope(engine), + header = envelope.value; + const rawVersion = + (header as { version?: unknown } | null)?.version ?? 2; + if (rawVersion !== 1 && rawVersion !== 2 && rawVersion !== 3) + invalid(); + const core = readCore(engine, rawVersion === 3 ? "notes" : "plain"); + const common = readLibraryCommon(header, core.notes); + let library: LibraryState | null = null, + recoveryCommon: LibraryCommonState | null = null; + const rawIndex = + rawVersion === 3 + ? (JSON.stringify( + (header as { noteLinks?: unknown }).noteLinks, + ) ?? "") + : ""; + let index: LinkOwnerSnapshot["index"] = { status: "valid", rows: [] }; + if (rawVersion === 3) { + try { + index = { + status: "valid", + rows: validateLinkRows( + (header as { noteLinks?: unknown }).noteLinks, + core.notes, + ), + }; + } catch (error) { + if (!(error instanceof NoteLinkError)) throw error; + index = { status: "invalid" }; + } + } + if (common) { + if (common.version === 2) library = { ...common, version: 2 }; + else if (index.status === "valid") + library = { ...common, version: 3, noteLinks: index.rows }; + else recoveryCommon = common; + } + if ( + options.change && + !options.repair && + !options.erase && + common && + (index.status === "invalid" || + (knownMismatch?.epoch === common.epoch && + knownMismatch.revision === common.revision && + knownMismatch.rawIndex === rawIndex)) + ) + throw new NoteLinkError("note_link_index_invalid"); + const existed = header !== null; + if (!library && !recoveryCommon && options.initialize) { + try { + library = adopt( + engine, + core.notes, + core.original, + new Date().toISOString(), + ); + } catch (error) { + if (!(error instanceof LibraryCapacityError)) throw error; + } + } + if ( + !library && + !recoveryCommon && + !options.initialize && + !legacyReadOnly + ) + throw new Error( + "library_missing: Reopen the library before retrying.", + ); + if ( + options.change && + (library ?? recoveryCommon) && + operationEpoch !== (library ?? recoveryCommon)!.epoch + ) + throw new LibraryEpochError(); + const ctx: Context = { + notes: core.notes, + library, + recoveryCommon, + generation: record.generation, + rawVersion, + rawIndex, + recordIdentity: { core: core.identity, library: envelope.identity }, + index, + skipWrite: false, + collections: options.collections + ? readCollectionsForTransaction(engine) + : undefined, + }; + const annotationBases = + options.change && library?.anchors.length + ? new Map( + core.notes.map((note) => [ + note.id, + { revision: note.revision, document: note.document }, + ]), + ) + : null; + const linkBase = + options.change && library + ? new Map( + core.notes.map((note) => [ + note.id, + { revision: note.revision, document: note.document }, + ]), + ) + : null; + const linkSnapshot = library ? ownerSnapshot(ctx) : null; + options.change?.(ctx); + if ( + options.change && + !ctx.skipWrite && + ctx.library && + !options.repair && + !options.erase + ) { + const work = { utf8Units: ctx.sourceWorkUnits ?? 0, nodeVisits: 0 }; + if ( + ctx.library.version === 2 && + legacyCertificate && + linkSnapshot && + sameOwnerState(legacyCertificate.snapshot, linkSnapshot) + ) { + ctx.library = { ...ctx.library, version: 3, noteLinks: [] }; + } + if (ctx.library.version === 3) { + const rows = new Map( + ctx.library.noteLinks.map((row) => [row.noteId, row]), + ); + const oldRows = new Map( + index.status === "valid" + ? index.rows.map((row) => [row.noteId, row]) + : [], + ); + for (const note of ctx.notes) { + const before = linkBase?.get(note.id); + if ( + before && + before.revision === note.revision && + before.document === note.document + ) + continue; + if (before && before.document !== note.document) { + const old = scanLinkDocument( + inspectLinkDocument(before.document, "notes", work), + work, + ); + if (!sameTargets(old, oldRows.get(note.id)?.targets ?? [])) + throw new NoteLinkError("note_link_index_invalid"); + } + if (before && before.document === note.document) { + const carried = rows.get(note.id); + if (carried) carried.noteRevision = note.revision; + } else { + const targets = scanLinkDocument( + inspectLinkDocument(note.document, "notes", work), + work, + ); + if (targets.length) + rows.set(note.id, { + noteId: note.id, + noteRevision: note.revision, + targets, + }); + else rows.delete(note.id); + } + } + const ids = new Set(ctx.notes.map((note) => note.id)); + ctx.library.noteLinks = [...rows.values()].filter((row) => + ids.has(row.noteId), + ); + validateLinkRows(ctx.library.noteLinks, ctx.notes); + } + } + // Central tag operations revise Notes outside changeNote, but retain + // the exact same document object. Carry those annotations once here. + const annotationNotes = annotationBases + ? new Map(ctx.notes.map((note) => [note.id, note])) + : null; + for (const anchor of annotationBases + ? (ctx.library?.anchors ?? []) + : []) { + const base = annotationBases!.get(anchor.noteId), + note = annotationNotes!.get(anchor.noteId); + if ( + anchor.status !== "attached" || + !base || + !note || + anchor.noteRevision !== base.revision || + note.revision === base.revision + ) + continue; + if (anchor.revision >= Number.MAX_SAFE_INTEGER) + throw new SourceError( + "source_limit", + "Source revisions are exhausted.", + ); + anchor.revision++; + if (base.document === note.document) + anchor.noteRevision = note.revision; + else { + anchor.status = "stale"; + anchor.staleReason = "document-replaced"; + } + } + guard(); + options.beforeStage?.(); + const changed = Boolean(options.change && !ctx.skipWrite); + if (changed && !ctx.library) throw new LibraryReadOnlyError(); + // Capture the old collections revision while its authenticated owner + // still exists. Purge and all fresh-key replacements share this IDB put. + const finishCollectionsErase = + changed && options.erase ? prepareCollectionsErase(engine) : null; + if (changed) { + if (ctx.library!.revision >= Number.MAX_SAFE_INTEGER) + throw new Error( + "library_revision_exhausted: Library revisions are exhausted.", + ); + ctx.library!.revision++; + retainHistory(ctx.library!, Date.now(), ctx.protectedVersion); + retainReceipts(ctx.library!, Date.now(), ctx.protectedReceipt); + if (finishCollectionsErase) + engine.purgeLocalZones( + JSON.stringify([CORE_ZONE, COLLECTIONS_ZONE, LIBRARY_ZONE]), + ); + stageCore( + engine, + ctx.notes, + !options.erase && core.exists, + ctx.library!.version === 3 ? "notes" : "plain", + ); + } + if ( + (changed || (!existed && ctx.library && options.initialize)) && + ctx.library + ) { + stageLibrary( + engine, + ctx.library, + ctx.notes, + !options.erase && existed, + ); + if (changed && ctx.collections) + stageCollectionsForTransaction(engine, ctx.collections); + finishCollectionsErase?.(); + wrote = true; + } + resultingEpoch = (ctx.library ?? ctx.recoveryCommon)?.epoch ?? null; + result = structuredClone(options.project(ctx)); + if (wrote && record.generation >= Number.MAX_SAFE_INTEGER) + throw new Error( + "device_generation_exhausted: Device generations are exhausted.", + ); + const nextRecord: DeviceRecord = wrote + ? { + ...record, + snapshot: engine.snapshot(secret), + generation: record.generation + 1, + } + : record; + const commonState = ctx.library ?? ctx.recoveryCommon; + if (commonState && searchAdmission) + searchCandidate = { + record: nextRecord, + before: wrote ? record : undefined, + data: searchAdmission.sameImage(nextRecord) + ? null + : notesSearchData(ctx.notes, { + epoch: commonState.epoch, + revision: commonState.revision, + }), + }; + return wrote ? nextRecord : UNCHANGED_RECORD; + } finally { + engine?.free(); + keys.free(); + } + } + if (options.change || options.initialize) { + if (options.change) { + state = { status: "saving" }; + notify(); + } + await mutateRecord("devices", identity.email, apply); + guard(); + if (options.erase && wrote) { + handleEpoch = resultingEpoch; + legacyReadOnly = false; + } + await publishSearch(); + guard(); + if (options.change) { + state = { status: "ready" }; + if (wrote) channel?.postMessage("changed"); + notify(); + } + } else { + const record = await readRecord( + "devices", + identity.email, + ); + guard(); + apply(record); + await publishSearch(); + } + guard(); + return result as T; + } catch (error) { + if (options.change && !closed) { + state = { + status: "failed", + message: + error instanceof Error + ? error.message + : "The library could not be saved.", + }; + notify(); + } + throw error; + } finally { + secret.fill(0); + } + } + function readable(ctx: Context): LibraryCommonState { + const state = ctx.library ?? ctx.recoveryCommon; + if (!state) throw new LibraryReadOnlyError(); + return state; + } + function writable(ctx: Context): LibraryState { + if (ctx.recoveryCommon) throw new NoteLinkError("note_link_index_invalid"); + if (!ctx.library) throw new LibraryReadOnlyError(); + return ctx.library; + } + function findNote(ctx: Context, id: string): Note { + const note = ctx.notes.find((note) => note.id === id); + if (!note) throw new Error("note_missing: This note no longer exists."); + return note; + } + function checkedNote( + ctx: Context, + id: string, + revision: number, + attempted?: Note, + ): Note { + const current = findNote(ctx, id); + if (current.revision !== revision) + throw new RevisionConflictError( + structuredClone(current), + structuredClone(attempted ?? current), + ); + return current; + } + function changeNote( + ctx: Context, + id: string, + expected: number, + transform: (note: Note) => Note, + reason: "edit" | "history-restore" = "edit", + sourceDisposition: "ordinary" | "replacement" = "ordinary", + ): Note { + const library = writable(ctx), + current = findNote(ctx, id); + if (current.revision >= Number.MAX_SAFE_INTEGER) + throw new Error("note_revision_exhausted: Note revisions are exhausted."); + const attempted = transform({ + ...structuredClone(current), + revision: expected + 1, + updatedAt: new Date().toISOString(), + }); + checkedNote(ctx, id, expected, attempted); + validateNotes( + [attempted], + library.version === 3 || legacyCertificate ? "notes" : "plain", + ); + const proof = ctx.sourceEdit; + const documentChanged = !sameDocument(attempted.document, current.document); + if ( + proof && + (canonicalSourceDocument(current.document).canonicalJSON !== + proof.baseJSON || + canonicalSourceDocument(attempted.document).canonicalJSON !== + proof.finalJSON) + ) + throw new SourceError( + "source_stale", + "The editor proof no longer matches this note.", + ); + const attached = library.anchors.filter( + (anchor) => anchor.noteId === id && anchor.status === "attached", + ); + const edit = proof + ? verifySourceEdit(current.document, attempted.document, proof.steps) + : null; + if (edit) { + const cost = sourceEditCost(edit); + // Zero-step metadata carry below does no per-anchor tree/range walk. + const mappingUnits = cost.stepCount + ? attached.reduce( + (sum, anchor) => + sum + + (cost.maxDocumentUnits + (anchor.to - anchor.from) * 2 + 64) * + (cost.stepCount + 1), + 0, + ) + : 0; + const graphUnits = + documentChanged && (library.version === 3 || legacyCertificate) + ? (JSON.stringify(current.document).length + + JSON.stringify(attempted.document).length) * + 6 + : 0; + ctx.sourceWorkUnits = cost.replayUnits + mappingUnits; + if ( + cost.replayUnits + mappingUnits + graphUnits > + SOURCE_CODEC_LIMITS.replayUnits + ) + throw new SourceError( + "source_limit", + "This annotation mapping exceeds its supported work limit.", + ); + } + for (const anchor of attached) { + if (anchor.revision === Number.MAX_SAFE_INTEGER) + throw new SourceError( + "source_limit", + "Source revisions are exhausted.", + ); + if ( + sourceDisposition !== "replacement" && + reason !== "history-restore" && + !documentChanged && + !proof?.steps.length + ) { + anchor.noteRevision = attempted.revision; + anchor.revision++; + continue; + } + anchor.revision++; + if ( + !proof || + !edit || + anchor.documentDigest !== proof.baseDigest || + anchor.noteRevision !== current.revision + ) { + anchor.status = "stale"; + anchor.staleReason = "document-replaced"; + } else { + const mapped = mapSourcePassage(edit, { + from: anchor.from, + to: anchor.to, + quote: anchor.quote, + }); + if (mapped.status === "stale") { + anchor.status = "stale"; + anchor.staleReason = "passage-edited"; + } else { + anchor.from = mapped.passage.from; + anchor.to = mapped.passage.to; + anchor.documentDigest = proof.finalDigest; + anchor.noteRevision = attempted.revision; + } + } + } + if ( + attempted.title !== current.title || + !sameDocument(attempted.document, current.document) || + reason === "history-restore" + ) + ctx.protectedVersion = captureVersion( + library, + current, + reason, + new Date().toISOString(), + ); + if (!documentChanged) attempted.document = current.document; + ctx.notes[ctx.notes.findIndex((note) => note.id === id)] = attempted; + syncMembership(library, attempted, new Date().toISOString()); + return attempted; + } + function materialize( + ctx: Context, + receipt: LibraryOperationReceipt, + replayed: boolean, + ): LibraryMutationResult { + const state = writable(ctx), + r = receipt.result; + const note = + r.type === "note" + ? (ctx.notes.find((note) => note.id === r.noteId) ?? null) + : null; + const canUndo = + r.type === "note" && + r.undo && + note?.revision === r.resultRevision && + note.trashedAt === r.undo.resultTrashedAt && + Date.now() < Date.parse(r.undo.expiresAt); + return { + snapshot: snapshot(state, ctx.notes), + note: structuredClone(note), + tag: + r.type === "tag" + ? structuredClone( + state.tags.find((tag) => tag.id === r.tagId) ?? null, + ) + : null, + undo: canUndo ? structuredClone(r.type === "note" ? r.undo : null) : null, + removedCount: r.type === "empty-trash" ? r.removedCount : 0, + replayed, + }; + } + function command( + kind: LibraryAction, + args: unknown[], + operationId: string, + expected: LibraryToken | null, + effect: (ctx: Context) => LibraryOperationReceipt["result"], + ): Promise { + uuid(operationId); + const serialized = JSON.stringify(args); + if (serialized.length > 2048) throw new LibraryCapacityError(); + let receipt!: LibraryOperationReceipt, + replayed = false; + return access({ + change: (ctx) => { + const state = writable(ctx), + previous = state.receipts.find( + (receipt) => receipt.id === operationId, + ); + if (previous) { + if (previous.kind !== kind || previous.arguments !== serialized) + throw new Error( + "operation_conflict: This operation identifier was already used.", + ); + receipt = previous; + replayed = true; + ctx.skipWrite = true; + return; + } + if ( + expected && + (expected.epoch !== state.epoch || + expected.revision !== state.revision) + ) + throw new LibraryConflictError(snapshot(state, ctx.notes)); + const result = effect(ctx); + receipt = { + id: operationId, + epoch: state.epoch, + committedAt: new Date().toISOString(), + libraryRevision: state.revision + 1, + kind, + arguments: serialized, + result, + }; + state.receipts.push(receipt); + ctx.protectedReceipt = operationId; + }, + project: (ctx) => materialize(ctx, receipt, replayed), + }); + } + function changeTrash( + note: string, + action: "trash" | "restore", + revision: number, + operationId: string, + ): Promise { + noteId(note); + integer(revision, 1); + if (action !== "trash" && action !== "restore") + throw new Error("library_invalid: Invalid trash action."); + return command( + action, + [note, action, revision], + operationId, + null, + (ctx) => { + const state = writable(ctx), + before = checkedNote(ctx, note, revision), + previousTrashedAt = before.trashedAt; + if ( + action === "trash" + ? previousTrashedAt !== null + : previousTrashedAt === null + ) + throw new Error( + "trash_state_changed: The note is no longer in the expected place.", + ); + const moved = changeNote(ctx, note, revision, (current) => ({ + ...current, + trashedAt: action === "trash" ? new Date().toISOString() : null, + })); + return { + type: "note", + noteId: note, + resultRevision: moved.revision, + undo: { + epoch: state.epoch, + operationId, + noteId: note, + resultRevision: moved.revision, + previousTrashedAt, + resultTrashedAt: moved.trashedAt, + expiresAt: new Date( + Date.now() + LIBRARY_LIMITS.undoMs, + ).toISOString(), + }, + }; + }, + ); + } + const library: NotesLibrary = { + replaceDocument: async (id, input, revision) => { + noteId(id); + integer(revision, 1); + const stable = validateInput(structuredClone(input)); + return access({ + change: (ctx) => { + changeNote( + ctx, + id, + revision, + (current) => ({ ...current, ...stable }), + "edit", + "replacement", + ); + }, + project: (ctx) => findNote(ctx, id), + }); + }, + updateWithSourceSteps: async (id, patch, revision, proof) => { + const invocationEpoch = handleEpoch; + noteId(id); + integer(revision, 1); + const stable = structuredClone(patch), + capturedProof = structuredClone(proof); + const current = await access({ project: (ctx) => findNote(ctx, id) }); + const base = await fingerprintSourceDocument(current.document); + const final = await fingerprintSourceDocument( + stable.document ?? current.document, + ); + assertOpen(); + if ( + capturedProof.baseDigest !== base.digest || + capturedProof.finalDigest !== final.digest + ) + throw new SourceError( + "source_invalid", + "The editor proof digest does not match its document.", + ); + return access({ + change: (ctx) => { + if (ctx.library?.epoch !== invocationEpoch) + throw new LibraryEpochError(); + ctx.sourceEdit = { + baseJSON: base.canonicalJSON, + finalJSON: final.canonicalJSON, + baseDigest: base.digest, + finalDigest: final.digest, + steps: capturedProof.steps, + }; + changeNote(ctx, id, revision, (current) => ({ + ...current, + ...validateInput({ + title: stable.title ?? current.title, + document: stable.document ?? current.document, + tags: stable.tags ?? current.tags, + }), + })); + }, + project: (ctx) => findNote(ctx, id), + }); + }, + read: () => + access({ + project: (ctx) => + (ctx.library ?? ctx.recoveryCommon) + ? { + mode: "ready" as const, + snapshot: snapshot(readable(ctx), ctx.notes), + } + : { + mode: "legacy-read-only" as const, + notes: structuredClone(ctx.notes), + reason: "metadata-limit" as const, + recovery: { + kind: "export-or-confirm-erase" as const, + deviceGeneration: ctx.generation, + }, + }, + }), + setPinned: async (id, pinned, expectedInput, operationId) => { + noteId(id); + if (typeof pinned !== "boolean") + throw new Error("library_invalid: Invalid pin state."); + const expected = token(expectedInput); + return command( + "pin", + [id, pinned, expected], + operationId, + expected, + (ctx) => { + const meta = writable(ctx).noteMetadata.find( + (meta) => meta.noteId === id, + ); + if (!meta) + throw new Error("note_missing: This note no longer exists."); + meta.pinned = pinned; + meta.revision++; + return { type: "metadata" }; + }, + ); + }, + setSort: async (order, expectedInput, operationId) => { + sort(order); + const expected = token(expectedInput); + return command( + "sort", + [order, expected], + operationId, + expected, + (ctx) => { + writable(ctx).preferences.sort = order; + return { type: "metadata" }; + }, + ); + }, + createTag: async (input, expectedInput, operationId) => { + const checked = object(input, ["name", "color"]), + name = tagName(checked.name); + color(checked.color); + const chosen = checked.color, + expected = token(expectedInput); + return command( + "tag-create", + [name, chosen, expected], + operationId, + expected, + (ctx) => { + const state = writable(ctx); + if ( + state.tags.some( + (tag) => tag.state === "active" && tag.name === name, + ) + ) + throw new Error("tag_name_conflict: This tag already exists."); + return { + type: "tag", + tagId: internTag(state, name, new Date().toISOString(), chosen).id, + }; + }, + ); + }, + renameTag: async (id, inputName, expectedInput, operationId) => { + uuid(id); + const name = tagName(inputName), + expected = token(expectedInput); + return command( + "tag-rename", + [id, name, expected], + operationId, + expected, + (ctx) => { + renameTag( + writable(ctx), + ctx.notes, + id, + name, + new Date().toISOString(), + ); + return { type: "tag", tagId: id }; + }, + ); + }, + recolorTag: async (id, chosen, expectedInput, operationId) => { + uuid(id); + color(chosen); + const expected = token(expectedInput); + return command( + "tag-color", + [id, chosen, expected], + operationId, + expected, + (ctx) => { + const tag = activeTag(writable(ctx), id); + tag.color = chosen; + tag.revision++; + tag.updatedAt = new Date().toISOString(); + return { type: "tag", tagId: id }; + }, + ); + }, + mergeTag: async (source, target, expectedInput, operationId) => { + uuid(source); + uuid(target); + const expected = token(expectedInput); + return command( + "tag-merge", + [source, target, expected], + operationId, + expected, + (ctx) => { + mergeTags( + writable(ctx), + ctx.notes, + source, + target, + new Date().toISOString(), + ); + return { type: "tag", tagId: target }; + }, + ); + }, + deleteTag: async (id, expectedInput, operationId) => { + uuid(id); + const expected = token(expectedInput); + return command( + "tag-delete", + [id, expected], + operationId, + expected, + (ctx) => { + deleteTag(writable(ctx), ctx.notes, id, new Date().toISOString()); + return { type: "tag", tagId: id }; + }, + ); + }, + history: async (id) => { + noteId(id); + return access({ + project: (ctx) => { + findNote(ctx, id); + return readable(ctx) + .versions.filter( + (version) => + version.noteId === id && + Date.parse(version.capturedAt) >= + Date.now() - LIBRARY_LIMITS.historyAgeMs, + ) + .sort( + (a, b) => + b.capturedAt.localeCompare(a.capturedAt) || + b.noteRevision - a.noteRevision, + ); + }, + }); + }, + version: async (id, versionId) => { + noteId(id); + uuid(versionId); + return access({ + project: (ctx) => + readable(ctx).versions.find( + (version) => + version.noteId === id && + version.id === versionId && + Date.parse(version.capturedAt) >= + Date.now() - LIBRARY_LIMITS.historyAgeMs, + ) ?? null, + }); + }, + restoreVersion: async (id, versionId, revision, operationId) => { + noteId(id); + uuid(versionId); + integer(revision, 1); + return command( + "history-restore", + [id, versionId, revision], + operationId, + null, + (ctx) => { + const current = checkedNote(ctx, id, revision), + version = writable(ctx).versions.find( + (version) => + version.noteId === id && + version.id === versionId && + Date.parse(version.capturedAt) >= + Date.now() - LIBRARY_LIMITS.historyAgeMs, + ); + if (current.trashedAt || !version) + throw new Error( + "version_unavailable: This version cannot be restored.", + ); + const restored = changeNote( + ctx, + id, + revision, + (note) => ({ + ...note, + title: version.title, + document: structuredClone(version.document), + }), + "history-restore", + ); + return { + type: "note", + noteId: id, + resultRevision: restored.revision, + undo: null, + }; + }, + ); + }, + changeTrash: async (...args) => changeTrash(...args), + undoTrash: async (input, operationId) => { + const checked = undo(input); + return command("trash-undo", [checked], operationId, null, (ctx) => { + const state = writable(ctx), + original = state.receipts.find( + (receipt) => receipt.id === checked.operationId, + ); + if ( + checked.epoch !== state.epoch || + !original || + original.result.type !== "note" || + !original.result.undo || + JSON.stringify(undo(original.result.undo)) !== + JSON.stringify(checked) || + Date.now() >= Date.parse(checked.expiresAt) + ) + throw new Error( + "undo_unavailable: This action can no longer be undone.", + ); + const note = checkedNote(ctx, checked.noteId, checked.resultRevision); + if (note.trashedAt !== checked.resultTrashedAt) + throw new Error( + "undo_unavailable: The note changed after that action.", + ); + const restored = changeNote(ctx, note.id, note.revision, (note) => ({ + ...note, + trashedAt: checked.previousTrashedAt, + })); + original.result.undo = null; + return { + type: "note", + noteId: note.id, + resultRevision: restored.revision, + undo: null, + }; + }); + }, + emptyTrash: async (expectedInput, operationId) => { + const expected = token(expectedInput); + return command( + "empty-trash", + [expected], + operationId, + expected, + (ctx) => { + const ids = new Set( + ctx.notes + .filter((note) => note.trashedAt !== null) + .map((note) => note.id), + ); + removeNotes(writable(ctx), ids); + ctx.notes = ctx.notes.filter((note) => !ids.has(note.id)); + return { type: "empty-trash", removedCount: ids.size }; + }, + ); + }, + eraseLegacy: async (input, operationId) => { + const checked = object(input, ["deviceGeneration", "confirmation"]); + integer(checked.deviceGeneration); + if (checked.confirmation !== "ERASE") + throw new Error( + "erase_confirmation_required: Confirm erasing this workspace.", + ); + uuid(operationId); + const expectedGeneration = checked.deviceGeneration, + args = JSON.stringify([expectedGeneration, "ERASE"]); + let receipt!: LibraryOperationReceipt, + replayed = false; + return access({ + legacyErase: true, + erase: true, + change: (ctx) => { + if (ctx.library) { + const old = ctx.library.receipts.find( + (receipt) => receipt.id === operationId, + ); + if (old?.kind === "legacy-erase" && old.arguments === args) { + receipt = old; + replayed = true; + ctx.skipWrite = true; + return; + } + throw new Error( + "erase_confirmation_stale: Reload the library before confirming erase.", + ); + } + if (ctx.generation !== expectedGeneration) + throw new Error( + "erase_confirmation_stale: The device data changed. Confirm erase again.", + ); + const count = ctx.notes.length; + ctx.notes = []; + ctx.library = emptyLibrary(); + receipt = { + id: operationId, + epoch: ctx.library.epoch, + committedAt: new Date().toISOString(), + libraryRevision: 1, + kind: "legacy-erase", + arguments: args, + result: { type: "empty-trash", removedCount: count }, + }; + ctx.library.receipts.push(receipt); + }, + project: (ctx) => materialize(ctx, receipt, replayed), + }); + }, + }; + if (searchAdmission) searchOwners.set(library, searchAdmission.access); + const store: NotesLibraryStore = { + library, + list: () => access({ project: (ctx) => ctx.notes }), + get: (id) => + access({ + project: (ctx) => ctx.notes.find((note) => note.id === id) ?? null, + }), + create: async (input) => { + const checked = validateInput(structuredClone(input)); + let id: string; + return access({ + change: (ctx) => { + const now = new Date().toISOString(); + id = crypto.randomUUID(); + const note: Note = { + ...checked, + id, + createdAt: now, + updatedAt: now, + trashedAt: null, + revision: 1, + }; + ctx.notes.push(note); + syncMembership(writable(ctx), note, now); + }, + project: (ctx) => findNote(ctx, id), + }); + }, + update: async (id, patch, revision) => { + noteId(id); + integer(revision, 1); + const stable = structuredClone(patch); + return access({ + change: (ctx) => { + changeNote(ctx, id, revision, (current) => ({ + ...current, + ...validateInput({ + title: stable.title ?? current.title, + document: stable.document ?? current.document, + tags: stable.tags ?? current.tags, + }), + })); + }, + project: (ctx) => findNote(ctx, id), + }); + }, + trash: async (id, revision) => + (await changeTrash(id, "trash", revision, crypto.randomUUID())).note!, + restore: async (id, revision) => + (await changeTrash(id, "restore", revision, crypto.randomUUID())).note!, + erase: () => + access({ + erase: true, + change: (ctx) => { + const previous = readable(ctx); + ctx.recoveryCommon = null; + ctx.notes = []; + ctx.library = emptyLibrary(previous.revision); + }, + project: () => undefined, + }), + subscribe: (listener) => { + assertOpen(); + listeners.add(listener); + return () => listeners.delete(listener); + }, + persistence: () => ({ ...state }), + close, + }; + function ownerSnapshot(ctx: Context): LinkOwnerSnapshot { + return { + accountId: identity.accountId, + generation: ctx.generation, + rawVersion: ctx.rawVersion, + notes: ctx.notes, + library: readable(ctx), + index: ctx.index, + rawIndex: ctx.rawIndex, + recordIdentity: ctx.recordIdentity, + knownMismatch: + knownMismatch?.epoch === readable(ctx).epoch && + knownMismatch?.revision === readable(ctx).revision && + knownMismatch?.rawIndex === ctx.rawIndex, + }; + } + linkOwners.set(library, { + read: () => access({ project: ownerSnapshot }), + authorizeLegacy: async (certificate, fresh) => { + fresh(); + const current = await access({ project: ownerSnapshot }); + assertOpen(); + fresh(); + if ( + current.rawVersion === 3 || + !sameOwnerState(current, certificate.snapshot) || + certificate.rows.length + ) + throw new NoteLinkConflictError(linkToken(current)); + legacyCertificate = structuredClone(certificate); + }, + mismatch: (snapshot) => { + assertOpen(); + knownMismatch = { + epoch: snapshot.library.epoch, + revision: snapshot.library.revision, + rawIndex: snapshot.rawIndex, + }; + }, + repair: async (command, fresh) => { + let replayed = false, + committedToken = linkToken(command.certificate.snapshot); + await access({ + repair: true, + beforeStage: fresh, + change: (ctx) => { + fresh(); + const current = ownerSnapshot(ctx), + common = readable(ctx); + if (common.epoch !== command.certificate.snapshot.library.epoch) + throw new NoteLinkError("note_link_revoked"); + const prior = common.receipts.find( + (row) => row.id === command.operationId, + ); + if (prior) { + if ( + prior.kind !== "note-links-repair" || + prior.arguments !== command.arguments || + prior.result.type !== "note-links-repair" || + prior.result.indexDigest !== command.indexDigest || + prior.epoch !== command.certificate.snapshot.library.epoch + ) + throw new NoteLinkError("note_link_invalid"); + committedToken = { + epoch: prior.epoch, + revision: prior.libraryRevision, + }; + replayed = true; + ctx.skipWrite = true; + return; + } + if ( + current.rawVersion !== 3 || + !sameOwnerState(current, command.certificate.snapshot) + ) + throw new NoteLinkConflictError(linkToken(current)); + const state = { + ...common, + version: 3 as const, + noteLinks: structuredClone(command.certificate.rows), + }; + ctx.library = state; + ctx.recoveryCommon = null; + validateLinkRows(state.noteLinks, ctx.notes); + state.receipts.push({ + id: command.operationId, + epoch: state.epoch, + committedAt: new Date().toISOString(), + libraryRevision: state.revision + 1, + kind: "note-links-repair", + arguments: command.arguments, + result: { + type: "note-links-repair", + indexDigest: command.indexDigest, + }, + }); + ctx.protectedReceipt = command.operationId; + committedToken = { epoch: state.epoch, revision: state.revision + 1 }; + }, + project: () => undefined, + }); + if ( + knownMismatch?.epoch === command.certificate.snapshot.library.epoch && + knownMismatch.revision === + command.certificate.snapshot.library.revision && + knownMismatch.rawIndex === command.certificate.snapshot.rawIndex + ) + knownMismatch = null; + legacyCertificate = null; + return { committedToken, replayed }; + }, + }); + function sourceSnapshot(ctx: Context): SourceOwnerSnapshot { + if (!ctx.library || !ctx.collections) + throw new SourceError( + "source_revoked", + "Open the real Notes and Study owners before continuing.", + ); + return { + notes: ctx.notes, + library: ctx.library, + collections: ctx.collections, + }; + } + sourceOwners.set(library, { + read: () => access({ collections: true, project: sourceSnapshot }), + commit: async (command, fresh) => { + let replayed = false; + const snapshot = await access({ + collections: true, + beforeStage: fresh, + change: (ctx) => { + fresh(); + const current = sourceSnapshot(ctx); + if ( + current.library.epoch !== command.expected.library.epoch || + current.collections.epoch !== command.expected.collections.epoch + ) + throw new SourceError( + "source_revoked", + "The workspace was erased.", + ); + const receipt = current.library.sourceReceipts.find( + (receipt) => receipt.operationId === command.operationId, + ); + if (receipt) { + if (receipt.digest !== command.digest) + throw new SourceError( + "source_operation_reused", + "This source operation has different saved content.", + ); + ctx.skipWrite = true; + replayed = true; + return; + } + const tokens = sourceTokens(current); + if ( + tokens.library.revision !== command.expected.library.revision || + tokens.collections.revision !== + command.expected.collections.revision + ) + throw new SourceConflictError(tokens); + if (current.collections.revision >= Number.MAX_SAFE_INTEGER) + throw new SourceError( + "source_limit", + "Collections revisions are exhausted.", + ); + applySourceCommand(current, command); + current.collections.revision++; + }, + project: sourceSnapshot, + }); + return { snapshot, replayed }; + }, + }); + return store; +} diff --git a/web/src/search/service.ts b/web/src/search/service.ts new file mode 100644 index 0000000..e85a3ba --- /dev/null +++ b/web/src/search/service.ts @@ -0,0 +1,659 @@ +import type { NotesLibraryStore } from "../library/contracts"; +import type { CollectionsStore } from "../collections/contracts"; +import { onLock } from "../account/session"; +import type { + SearchInvalidation, + SearchLease, + SearchSourceReader, +} from "./contracts"; +import { SearchSourceError } from "./contracts"; +import { + acquireSearchSource, + assertSearchSourceFresh, + onSearchSourceInvalidated, + releaseSearchSource, + searchSourceReader, +} from "./source"; +import { + buildSearchIndex, + disposeSearchIndex, + projectSearchPage, + searchIndexCounts, + type SearchIndex, +} from "./model"; +import { compileSearchQuery, SearchQueryError, type SearchQuery } from "./text"; +import { SearchWork, type WorkEvent } from "./work"; +import type { + LocalSearchService, + SearchCursor, + SearchGroup, + SearchHandle, + SearchHit, + SearchKind, + SearchPageIntent, + SearchProgress, + SearchState, +} from "./results"; + +type Ready = Extract; +type InitialResult = Readonly<{ + groups: readonly Readonly<{ + kind: SearchKind; + offset: number; + items: readonly SearchHit[]; + }>[]; +}>; +type Job = { + generation: number; + raw: string; + initialPages: SearchPageIntent; + lease: SearchLease; + index: SearchIndex | null; + handle: SearchHandle; + reader: SearchSourceReader; + progress: SearchProgress | null; + busy: object | null; + work: SearchWork | null; + page: { + handle: SearchHandle; + kind: SearchKind; + offset: number; + work: SearchWork; + } | null; + unsubscribe: () => void; +}; +type CursorState = { + job: Job; + kind: SearchKind; + origin: number; + offset: number; + consumed: boolean; +}; +const kinds: readonly SearchKind[] = ["note", "clip", "card"]; +const pageSize = 50; +function capturePageIntent(input?: SearchPageIntent): SearchPageIntent { + const result: Partial> = {}; + for (const kind of kinds) { + const value = input?.[kind]; + if (value === undefined) continue; + if ( + !Number.isSafeInteger(value) || + value < 0 || + value > 20000 || + value % pageSize !== 0 + ) + throw new SearchActionError("invalid-handle"); + result[kind] = value; + } + return Object.freeze(result); +} +const idle = (): SearchState => Object.freeze({ status: "idle", raw: "" }); +const handle = (): SearchHandle => Object.freeze({}) as SearchHandle; +const countFor = (ready: Ready, kind: SearchKind) => + ready.counts[kind === "note" ? "notes" : kind === "clip" ? "clips" : "cards"]; + +export class SearchActionError extends Error { + constructor(public readonly code: "invalid-handle" | "busy" | "closed") { + super( + code === "busy" + ? "Search work is already running." + : "This search action is no longer available.", + ); + this.name = "SearchActionError"; + } +} + +/** Real owner inputs only; there is no fixture mode or caller-admitted corpus. + * A ready certificate belongs to one exact independently verified version pair. */ +export function createLocalSearchService( + notes: NotesLibraryStore, + collections: CollectionsStore, +): LocalSearchService { + let state = idle(); + let generation = 0, + closed = false; + let job: Job | null = null; + let cursors = new WeakMap(); + const listeners = new Set<() => void>(); + const invalidations = new Set<(reason: SearchInvalidation) => void>(); + function notify(): void { + for (const listener of [...listeners]) { + try { + listener(); + } catch { + /* One observer cannot block revocation. */ + } + } + } + function publish(next: SearchState): void { + state = Object.freeze(next); + notify(); + } + function current(candidate: Job): void { + if (closed || job !== candidate || candidate.generation !== generation) + throw new DOMException("Search stopped.", "AbortError"); + } + function dispose(): void { + const old = job; + job = null; + cursors = new WeakMap(); + if (!old) return; + old.unsubscribe(); + old.work?.close(); + old.page?.work.close(); + old.work = null; + old.page = null; + if (old.index) disposeSearchIndex(old.index); + old.index = null; + releaseSearchSource(old.lease); + } + function invalidate(reason: SearchInvalidation): void { + const raw = reason === "changed" ? state.raw : ""; + generation++; + dispose(); + state = + reason === "changed" ? Object.freeze({ status: "changed", raw }) : idle(); + // Privacy listeners clear held route/query projections before ordinary UI + // subscriptions can observe the new state. + for (const listener of [...invalidations]) { + try { + listener(reason); + } catch { + /* Continue clearing all consumers. */ + } + } + notify(); + } + function cursor( + candidate: Job, + kind: SearchKind, + origin: number, + offset: number, + total: number, + ): SearchCursor | null { + if (offset < 0 || offset >= total) return null; + const value = Object.freeze({}) as SearchCursor; + cursors.set(value, { + job: candidate, + kind, + origin, + offset, + consumed: false, + }); + return value; + } + function retainedPage(candidate: Job, ready: Ready, kind: SearchKind): Ready { + return Object.freeze({ + ...ready, + page: null, + groups: Object.freeze( + ready.groups.map((group) => + group.kind !== kind + ? group + : Object.freeze({ + ...group, + previous: cursor( + candidate, + group.kind, + group.offset, + group.offset - pageSize, + countFor(ready, group.kind), + ), + next: cursor( + candidate, + group.kind, + group.offset, + group.offset + group.items.length, + countFor(ready, group.kind), + ), + }), + ), + ), + }); + } + function* initial( + candidate: Job, + query: SearchQuery, + ): Generator { + candidate.index = yield* buildSearchIndex( + candidate.reader, + query, + (progress) => { + candidate.progress = progress; + }, + ); + const counts = searchIndexCounts(candidate.index); + const groups: { + kind: SearchKind; + offset: number; + items: readonly SearchHit[]; + }[] = []; + for (const kind of kinds) { + const total = + counts[kind === "note" ? "notes" : kind === "clip" ? "clips" : "cards"]; + const lastPage = + total === 0 ? 0 : Math.floor((total - 1) / pageSize) * pageSize; + const offset = Math.min(candidate.initialPages[kind] ?? 0, lastPage); + const items = yield* projectSearchPage( + candidate.index, + kind, + offset, + pageSize, + ); + groups.push(Object.freeze({ kind, offset, items })); + } + return Object.freeze({ groups: Object.freeze(groups) }); + } + function fail(candidate: Job, error: unknown, aborted = false): void { + if (job !== candidate || candidate.generation !== generation) return; + const raw = candidate.raw; + generation++; + dispose(); + publish( + aborted || (error instanceof DOMException && error.name === "AbortError") + ? idle() + : { status: "error", raw, code: "source-unavailable" }, + ); + } + async function runInitial( + candidate: Job, + signal?: AbortSignal, + ): Promise { + if (candidate.busy) throw new SearchActionError("busy"); + const operation = {}; + candidate.busy = operation; + let proving = false; + try { + current(candidate); + publish({ + status: "pending", + raw: candidate.raw, + progress: candidate.progress, + }); + proving = true; + await assertSearchSourceFresh(candidate.lease, signal); + current(candidate); + proving = false; + const result = await candidate.work!.round(signal); + current(candidate); + proving = true; + await assertSearchSourceFresh(candidate.lease, signal); + current(candidate); + proving = false; + if (result.status === "paused") { + publish({ + status: "paused-limit", + raw: candidate.raw, + handle: candidate.handle, + progress: candidate.progress, + }); + return; + } + const counts = Object.freeze(searchIndexCounts(candidate.index!)); + const groups: readonly SearchGroup[] = Object.freeze( + result.value.groups.map((group) => + Object.freeze({ + ...group, + previous: cursor( + candidate, + group.kind, + group.offset, + group.offset - pageSize, + counts[ + group.kind === "note" + ? "notes" + : group.kind === "clip" + ? "clips" + : "cards" + ], + ), + next: cursor( + candidate, + group.kind, + group.offset, + group.offset + group.items.length, + counts[ + group.kind === "note" + ? "notes" + : group.kind === "clip" + ? "clips" + : "cards" + ], + ), + }), + ), + ); + candidate.work = null; + publish({ + status: "ready", + raw: candidate.raw, + handle: candidate.handle, + versions: candidate.reader.versions, + counts, + groups, + page: null, + }); + } catch (error) { + if ( + proving && + !signal?.aborted && + job === candidate && + candidate.generation === generation && + !(error instanceof DOMException && error.name === "AbortError") + ) + invalidate("unverified"); + else fail(candidate, error, signal?.aborted); + } finally { + if (candidate.busy === operation) candidate.busy = null; + } + } + async function runPage(candidate: Job, signal?: AbortSignal): Promise { + if (candidate.busy) throw new SearchActionError("busy"); + if (!candidate.page || state.status !== "ready") + throw new SearchActionError("invalid-handle"); + const operation = {}; + candidate.busy = operation; + const pending = candidate.page; + let proving = false; + const pageCurrent = () => { + current(candidate); + if (candidate.page !== pending) + throw new DOMException("Search page stopped.", "AbortError"); + }; + try { + pageCurrent(); + publish({ + ...state, + page: Object.freeze({ status: "pending", kind: pending.kind }), + }); + proving = true; + await assertSearchSourceFresh(candidate.lease, signal); + pageCurrent(); + proving = false; + const result = await pending.work.round(signal); + pageCurrent(); + proving = true; + await assertSearchSourceFresh(candidate.lease, signal); + pageCurrent(); + proving = false; + if (state.status !== "ready") + throw new SearchActionError("invalid-handle"); + if (result.status === "paused") { + publish({ + ...state, + page: Object.freeze({ + status: "paused", + kind: pending.kind, + handle: pending.handle, + }), + }); + return; + } + const ready = state; + const groups = Object.freeze( + ready.groups.map((group) => + group.kind !== pending.kind + ? group + : Object.freeze({ + kind: group.kind, + items: result.value, + offset: pending.offset, + previous: cursor( + candidate, + group.kind, + pending.offset, + pending.offset - pageSize, + countFor(ready, group.kind), + ), + next: cursor( + candidate, + group.kind, + pending.offset, + pending.offset + result.value.length, + countFor(ready, group.kind), + ), + }), + ), + ); + candidate.page = null; + publish({ ...ready, groups, page: null }); + } catch (error) { + if ( + job !== candidate || + candidate.generation !== generation || + candidate.page !== pending + ) + return; + if ( + proving && + !signal?.aborted && + !(error instanceof DOMException && error.name === "AbortError") + ) { + invalidate("unverified"); + return; + } + if (state.status !== "ready") { + fail(candidate, error); + return; + } + pending.work.close(); + if ( + signal?.aborted || + (error instanceof DOMException && error.name === "AbortError") + ) { + candidate.page = null; + publish(retainedPage(candidate, state, pending.kind)); + } else { + // Retain the completed page/certificate. Retry gets a new iterator over + // this same captured index; it never restarts the entire query budget. + pending.work = new SearchWork( + projectSearchPage( + candidate.index!, + pending.kind, + pending.offset, + pageSize, + ), + undefined, + undefined, + () => current(candidate), + ); + publish({ + ...state, + page: Object.freeze({ + status: "error", + kind: pending.kind, + handle: pending.handle, + }), + }); + } + } finally { + if (candidate.busy === operation) candidate.busy = null; + } + } + const unsubscribeLock = onLock(() => { + if (!closed && (job || state.status !== "idle")) invalidate("closed"); + }); + return { + getSnapshot: () => state, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + onInvalidate(listener) { + invalidations.add(listener); + return () => { + invalidations.delete(listener); + }; + }, + async query(raw, signal, initialPages) { + if (closed) throw new SearchActionError("closed"); + generation++; + const ownGeneration = generation; + dispose(); + let query: SearchQuery | null; + let pages: SearchPageIntent; + try { + pages = capturePageIntent(initialPages); + initialPages = undefined; + query = compileSearchQuery(raw); + } catch (error) { + if (!(error instanceof SearchQueryError)) { + publish(idle()); + throw error; + } + publish({ status: "error", raw, code: error.code }); + return; + } + if (!query || signal?.aborted) { + publish(idle()); + return; + } + publish({ status: "pending", raw, progress: null }); + let acquired: SearchLease | null = null; + try { + acquired = await acquireSearchSource(notes, collections, signal); + if (closed || generation !== ownGeneration || signal?.aborted) { + releaseSearchSource(acquired); + acquired = null; + if (!closed && generation === ownGeneration) publish(idle()); + return; + } + const reader = searchSourceReader(acquired); + const candidate: Job = { + generation: ownGeneration, + raw, + initialPages: pages, + lease: acquired, + reader, + index: null, + handle: handle(), + progress: null, + busy: null, + work: null, + page: null, + unsubscribe: () => {}, + }; + job = candidate; + acquired = null; + candidate.unsubscribe = onSearchSourceInvalidated( + candidate.lease, + (reason) => { + if (job === candidate && generation === candidate.generation) + invalidate(reason); + }, + ); + candidate.work = new SearchWork( + initial(candidate, query), + undefined, + undefined, + () => current(candidate), + ); + await runInitial(candidate, signal); + } catch (error) { + if (acquired) releaseSearchSource(acquired); + if (job?.generation === ownGeneration) { + fail(job, error, signal?.aborted); + return; + } + if (!closed && generation === ownGeneration) { + if ( + signal?.aborted || + (error instanceof DOMException && error.name === "AbortError") + ) + publish(idle()); + else if ( + error instanceof SearchSourceError && + (error.code === "search_source_revoked" || + error.code === "search_source_changed" || + error.code === "search_source_invalid") + ) + invalidate("unverified"); + else publish({ status: "error", raw, code: "source-unavailable" }); + } + } + }, + async continue(value, signal) { + if (closed) throw new SearchActionError("closed"); + const candidate = job; + if (!candidate) throw new SearchActionError("invalid-handle"); + if (candidate.busy) throw new SearchActionError("busy"); + if ( + candidate.page?.handle === value && + state.status === "ready" && + (state.page?.status === "paused" || state.page?.status === "error") + ) { + await runPage(candidate, signal); + return; + } + if ( + candidate.handle !== value || + state.status !== "paused-limit" || + !candidate.work + ) + throw new SearchActionError("invalid-handle"); + await runInitial(candidate, signal); + }, + async page(value, signal) { + if (closed) throw new SearchActionError("closed"); + const candidate = job, + selected = cursors.get(value); + if ( + !candidate || + !selected || + selected.job !== candidate || + selected.consumed || + state.status !== "ready" || + !candidate.index || + state.groups.find((group) => group.kind === selected.kind)?.offset !== + selected.origin + ) + throw new SearchActionError("invalid-handle"); + if (candidate.busy || candidate.page) throw new SearchActionError("busy"); + selected.consumed = true; + const pageHandle = handle(); + candidate.page = { + handle: pageHandle, + kind: selected.kind, + offset: selected.offset, + work: new SearchWork( + projectSearchPage( + candidate.index, + selected.kind, + selected.offset, + pageSize, + ), + undefined, + undefined, + () => current(candidate), + ), + }; + await runPage(candidate, signal); + }, + cancelPage() { + if (closed || !job?.page || state.status !== "ready") return; + const pending = job.page; + job.page = null; + // Retire this operation's admission immediately. Its late proof/result + // and finally are fenced by the page identity and operation token. + job.busy = null; + pending.work.close(); + publish(retainedPage(job, state, pending.kind)); + }, + cancel() { + if (closed) return; + generation++; + dispose(); + publish(idle()); + }, + close() { + if (closed) return; + closed = true; + unsubscribeLock(); + invalidate("closed"); + listeners.clear(); + invalidations.clear(); + }, + }; +} diff --git a/web/src/search/source.ts b/web/src/search/source.ts new file mode 100644 index 0000000..9ce12bb --- /dev/null +++ b/web/src/search/source.ts @@ -0,0 +1,678 @@ +import type { AccountIdentity, Note, RichNode } from "../domain/contracts"; +import type { CollectionsState } from "../collections/contracts"; +import type { NotesLibraryStore } from "../library/contracts"; +import type { CollectionsStore } from "../collections/contracts"; +import { lockVersion, onLock } from "../account/session"; +import { + readRecord, + wrappingSecret, + type DeviceRecord, +} from "../data/database"; +import { validateNoteTarget } from "../links/reference"; +import { SearchSourceError } from "./contracts"; +import type { + SearchLease, + SearchInvalidation, + SearchSourceReader, + SearchNodeCursor, + SearchEntryCursor, + SearchVersions, + SearchClipView, + SearchDeckView, + SearchCardView, +} from "./contracts"; + +type Token = { epoch: string; revision: number }; +type NoteData = { + id: string; + revision: number; + title: string; + tags: string[]; + updatedAt: string; + document: Note["document"]; +}; +type EntryData = { + id: string; + revision: number; + deckId: string; + tags: string[]; +} & ( + | { type: "basic" | "reversed"; front: string; back: string } + | { type: "cloze"; text: string; blanks: { id: string; answer: string }[] } +); +export type NotesSearchData = { + kind: "notes"; + token: Token; + notes: NoteData[]; +}; +export type CollectionsSearchData = { + kind: "collections"; + token: Token; + clips: SearchClipView[]; + decks: SearchDeckView[]; + entries: EntryData[]; + cards: SearchCardView[]; +}; +type Data = NotesSearchData | CollectionsSearchData; +type Proof = { + record: Pick< + DeviceRecord, + | "email" + | "accountId" + | "deviceId" + | "generation" + | "sealedKeys" + | "snapshot" + | "verifiedTrust" + | "approvalCheckpointId" + >; +}; +type OwnerNotice = Readonly<{ reason: SearchInvalidation; before?: Proof }>; +declare const partBrand: unique symbol; +type Part = Readonly<{ [partBrand]: true }>; +type PartState = { + owner: SearchOwnerAccess; + data: Data | null; + proof: Proof; + identity: AccountIdentity; + reason: SearchInvalidation | null; + nodes: WeakMap; + entries: WeakMap>; + listeners: Set<(notice: OwnerNotice) => void>; +}; +const parts = new WeakMap(); +export interface SearchOwnerAccess { + capture(signal?: AbortSignal): Promise; + fresh(part: Part, signal?: AbortSignal): Promise; + release(part: Part): void; +} +class UnverifiedImage extends SearchSourceError { + constructor() { + super("search_source_revoked"); + } +} +function fail( + code: + | "search_source_invalid" + | "search_source_unavailable" + | "search_source_corrupt" + | "search_source_changed" + | "search_source_revoked", +): never { + throw new SearchSourceError(code); +} +function bytes(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean { + if (a === undefined || b === undefined) return a === b; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} +function sameProof(a: Proof, b: Proof): boolean { + const x = a.record, + y = b.record; + return ( + x.email === y.email && + x.accountId === y.accountId && + x.deviceId === y.deviceId && + x.generation === y.generation && + x.verifiedTrust === y.verifiedTrust && + x.approvalCheckpointId === y.approvalCheckpointId && + bytes(x.snapshot, y.snapshot) && + bytes(x.sealedKeys, y.sealedKeys) + ); +} +function sameAuthority(a: Proof, b: Proof): boolean { + const x = a.record, + y = b.record; + return ( + x.email === y.email && + x.accountId === y.accountId && + x.deviceId === y.deviceId && + x.verifiedTrust === y.verifiedTrust && + x.approvalCheckpointId === y.approvalCheckpointId && + bytes(x.sealedKeys, y.sealedKeys) + ); +} +function proof(record: DeviceRecord): Proof { + return { + record: { + email: record.email, + accountId: record.accountId, + deviceId: record.deviceId, + generation: record.generation, + sealedKeys: record.sealedKeys, + snapshot: record.snapshot, + verifiedTrust: record.verifiedTrust, + approvalCheckpointId: record.approvalCheckpointId, + }, + }; +} +function aborted(signal?: AbortSignal) { + if (signal?.aborted) fail("search_source_revoked"); +} +function partState(part: Part): PartState & { data: Data } { + const state = parts.get(part); + if (!state || !state.data || state.reason) + fail( + state?.reason === "changed" + ? "search_source_changed" + : "search_source_revoked", + ); + return state as PartState & { data: Data }; +} +function revokePart(part: Part, notice: OwnerNotice) { + const state = parts.get(part); + if (!state || state.reason) return; + state.reason = notice.reason; + state.data = null; + state.nodes = new WeakMap(); + state.entries = new WeakMap(); + for (const callback of state.listeners) { + try { + callback(notice); + } catch { + /* Revoke all borrowers. */ + } + } + state.listeners.clear(); +} + +/** Internal owner helper; never registered by a caller. Genuine registries live + * inside library/store and collections/store, and expose only frozen access. */ +export function createSearchAdmission( + identity: AccountIdentity, + initialKey: CryptoKey, + assertOpen: () => void, + load: () => Promise, +) { + const scope = structuredClone(identity), + openedLock = lockVersion(); + let current: { data: Data; proof: Proof } | null = null, + closed = false, + authorityLost = false, + loading: Promise | null = null; + const captures = new Set(); + let failedImage: Proof | null = null; + const getCurrent = () => current; + function guard() { + if (closed || authorityLost || lockVersion() !== openedLock) + fail("search_source_revoked"); + try { + assertOpen(); + } catch { + close(); + fail("search_source_revoked"); + } + } + function invalidate(reason: SearchInvalidation, before?: Proof) { + for (const part of captures) + revokePart(part, Object.freeze({ reason, before })); + captures.clear(); + current = null; + } + function close() { + if (closed) return; + closed = true; + failedImage = null; + invalidate("closed"); + } + async function keysMatch(record: DeviceRecord) { + let expected: Uint8Array | undefined, actual: Uint8Array | undefined; + try { + expected = await wrappingSecret(initialKey); + guard(); + actual = await wrappingSecret(record.wrapKey); + guard(); + return bytes(expected, actual); + } finally { + expected?.fill(0); + actual?.fill(0); + } + } + async function verify(expected: Proof, signal?: AbortSignal) { + guard(); + aborted(signal); + const record = await readRecord("devices", scope.email); + guard(); + aborted(signal); + if (!record || !sameAuthority(expected, proof(record))) { + authorityLost = true; + invalidate("unverified"); + fail("search_source_revoked"); + } + if (!sameProof(expected, proof(record))) { + invalidate("unverified"); + throw new UnverifiedImage(); + } + try { + if (!(await keysMatch(record))) { + authorityLost = true; + invalidate("unverified"); + fail("search_source_revoked"); + } + } catch (error) { + if (!closed) invalidate("unverified"); + throw error; + } + guard(); + aborted(signal); + } + const access: SearchOwnerAccess = Object.freeze({ + async capture(signal?: AbortSignal) { + guard(); + aborted(signal); + if (current) { + try { + await verify(current.proof, signal); + } catch (error) { + if (!(error instanceof UnverifiedImage) || closed || signal?.aborted) + throw error; + } + } + if (!current) { + if (!loading) { + loading = (async () => { + const record = await readRecord( + "devices", + scope.email, + ); + guard(); + if (!record) fail("search_source_unavailable"); + const image = proof(record); + if (failedImage && sameProof(failedImage, image)) + fail("search_source_unavailable"); + failedImage = image; + await load(); + guard(); + if (!getCurrent()) fail("search_source_unavailable"); + failedImage = null; + })().finally(() => { + loading = null; + }); + } + await loading; + guard(); + aborted(signal); + const admitted = getCurrent(); + if (!admitted) fail("search_source_unavailable"); + await verify(admitted.proof, signal); + } + guard(); + aborted(signal); + if (!current) fail("search_source_unavailable"); + const part = Object.freeze({}) as Part; + parts.set(part, { + owner: access, + data: current.data, + proof: current.proof, + identity: scope, + reason: null, + nodes: new WeakMap(), + entries: new WeakMap(), + listeners: new Set(), + }); + captures.add(part); + return part; + }, + async fresh(part: Part, signal?: AbortSignal) { + const state = partState(part); + if (state.owner !== access) fail("search_source_invalid"); + guard(); + await verify(state.proof, signal); + partState(part); + }, + release(part: Part) { + if (parts.get(part)?.owner !== access) fail("search_source_invalid"); + revokePart(part, { reason: "closed" }); + captures.delete(part); + }, + }); + return { + access, + close, + unverified() { + if (!closed) invalidate("unverified"); + }, + sameImage(record: DeviceRecord) { + return current !== null && sameProof(current.proof, proof(record)); + }, + async publish( + record: DeviceRecord, + data: Data | null, + before?: DeviceRecord, + ) { + guard(); + // No lease is granted by publication: capture/fresh always check cloned + // key material. Only active borrowers require that work before observers. + try { + if (captures.size > 0 && !(await keysMatch(record))) { + authorityLost = true; + fail("search_source_revoked"); + } + } catch (error) { + invalidate("unverified"); + throw error; + } + guard(); + const after = proof(record); + if (current && sameProof(current.proof, after)) return; + const prior = current, + reason: SearchInvalidation = + prior && data && prior.data.token.epoch !== data.token.epoch + ? "epoch" + : before && prior && sameProof(prior.proof, proof(before)) + ? "changed" + : "unverified"; + if (prior) invalidate(reason, before ? proof(before) : undefined); + if (!data) fail("search_source_unavailable"); + current = { data, proof: after }; + }, + }; +} +export function notesSearchData(notes: Note[], token: Token): NotesSearchData { + return { + kind: "notes", + token: { ...token }, + notes: notes + .filter((note) => note.trashedAt === null) + .map((note) => ({ + id: note.id, + revision: note.revision, + title: note.title, + tags: note.tags.slice(), + updatedAt: note.updatedAt, + document: note.document, + })), + }; +} +export function collectionsSearchData( + state: CollectionsState, +): CollectionsSearchData { + return { + kind: "collections", + token: { epoch: state.epoch, revision: state.revision }, + clips: state.clips.items.map((clip) => ({ + id: clip.id, + revision: clip.revision, + title: clip.title, + originalUrl: clip.originalUrl, + canonicalUrl: clip.canonicalUrl, + text: clip.text, + updatedAt: clip.updatedAt, + archivedAt: clip.archivedAt, + })), + decks: state.study.decks.map((deck) => ({ + id: deck.id, + revision: deck.revision, + title: deck.title, + })), + entries: state.study.entries.map((entry) => { + const base = { + id: entry.id, + revision: entry.revision, + deckId: entry.deckId, + tags: entry.tags.slice(), + }; + return entry.type === "cloze" + ? { + ...base, + type: entry.type, + text: entry.text, + blanks: entry.blanks.map((blank) => ({ + id: blank.id, + answer: blank.answer, + })), + } + : { ...base, type: entry.type, front: entry.front, back: entry.back }; + }), + cards: state.study.cards.map((card) => ({ + id: card.id, + revision: card.revision, + entryId: card.entryId, + key: card.key, + })), + }; +} + +type LeaseState = { + notes: Part; + collections: Part; + reason: SearchInvalidation | null; + listeners: Set<(reason: SearchInvalidation) => void>; + stops: (() => void)[]; +}; +const leases = new WeakMap(); +function leaseState(lease: SearchLease) { + const state = leases.get(lease); + if (!state) fail("search_source_invalid"); + if (state.reason) + fail( + state?.reason === "changed" + ? "search_source_changed" + : "search_source_revoked", + ); + partState(state.notes); + partState(state.collections); + return state; +} +function revokeLease(state: LeaseState, reason: SearchInvalidation) { + if (state.reason) return; + state.reason = reason; + for (const stop of state.stops) stop(); + state.stops = []; + parts.get(state.notes)?.owner.release(state.notes); + parts.get(state.collections)?.owner.release(state.collections); + for (const callback of state.listeners) { + try { + callback(reason); + } catch { + /* Clear every consumer. */ + } + } + state.listeners.clear(); +} +export async function acquireSearchSource( + notes: NotesLibraryStore, + collections: CollectionsStore, + signal?: AbortSignal, +): Promise { + aborted(signal); + const [{ notesSearchAdmission }, { collectionsSearchAdmission }] = + await Promise.all([ + import("../library/store"), + import("../collections/store"), + ]); + aborted(signal); + const left = notesSearchAdmission(notes), + right = collectionsSearchAdmission(collections); + let a: Part | undefined, b: Part | undefined; + try { + const captured = await Promise.allSettled([ + left.capture(signal), + right.capture(signal), + ]); + const first = captured[0]!, + second = captured[1]!; + if (first.status === "fulfilled") a = first.value; + if (second.status === "fulfilled") b = second.value; + if (first.status === "rejected") throw first.reason; + if (second.status === "rejected") throw second.reason; + aborted(signal); + if (!a || !b) fail("search_source_unavailable"); + const ns = partState(a), + cs = partState(b); + if ( + ns.identity.accountId !== cs.identity.accountId || + ns.identity.deviceId !== cs.identity.deviceId || + ns.identity.email !== cs.identity.email + ) + fail("search_source_revoked"); + await Promise.all([left.fresh(a, signal), right.fresh(b, signal)]); + const lease = Object.freeze({}) as SearchLease, + state: LeaseState = { + notes: a, + collections: b, + reason: null, + listeners: new Set(), + stops: [], + }; + leases.set(lease, state); + for (const part of [a, b]) { + const ps = partState(part), + callback = (notice: OwnerNotice) => { + let reason = notice.reason; + if ( + reason === "changed" && + (!notice.before || + !sameProof(parts.get(a!)!.proof, notice.before) || + !sameProof(parts.get(b!)!.proof, notice.before)) + ) + reason = "unverified"; + revokeLease(state, reason); + }; + ps.listeners.add(callback); + state.stops.push(() => ps.listeners.delete(callback)); + } + return lease; + } catch (error) { + if (a) left.release(a); + if (b) right.release(b); + throw error; + } +} +export async function assertSearchSourceFresh( + lease: SearchLease, + signal?: AbortSignal, +): Promise { + const state = leaseState(lease); + aborted(signal); + await Promise.all([ + parts.get(state.notes)!.owner.fresh(state.notes, signal), + parts.get(state.collections)!.owner.fresh(state.collections, signal), + ]); + leaseState(lease); + aborted(signal); +} +export function onSearchSourceInvalidated( + lease: SearchLease, + listener: (reason: SearchInvalidation) => void, +): () => void { + const state = leaseState(lease); + if (typeof listener !== "function") fail("search_source_invalid"); + state.listeners.add(listener); + return () => state.listeners.delete(listener); +} +export function releaseSearchSource(lease: SearchLease): void { + const state = leases.get(lease); + if (!state) fail("search_source_invalid"); + revokeLease(state, "closed"); +} +function item(items: readonly T[], index: number): T { + if (!Number.isSafeInteger(index) || index < 0 || index >= items.length) + fail("search_source_invalid"); + return items[index]!; +} +export function searchSourceReader(lease: SearchLease): SearchSourceReader { + const state = leaseState(lease), + left = partState(state.notes), + right = partState(state.collections); + if (left.data!.kind !== "notes" || right.data!.kind !== "collections") + fail("search_source_invalid"); + const versions: SearchVersions = Object.freeze({ + accountId: left.identity.accountId, + library: Object.freeze({ ...left.data!.token }), + collections: Object.freeze({ ...right.data!.token }), + }); + function data() { + leaseState(lease); + const n = partState(state.notes), + c = partState(state.collections); + if (n.data!.kind !== "notes" || c.data!.kind !== "collections") + fail("search_source_invalid"); + return { n: n.data!, c: c.data!, part: n, entryPart: c }; + } + function cursor(node: RichNode) { + const value = Object.freeze({}) as SearchNodeCursor; + partState(state.notes).nodes.set(value, node); + return value; + } + return Object.freeze({ + versions, + counts: Object.freeze({ + notes: left.data.notes.length, + clips: right.data.clips.length, + decks: right.data.decks.length, + entries: right.data.entries.length, + cards: right.data.cards.length, + }), + note(index) { + const value = item(data().n.notes, index); + return Object.freeze({ + id: value.id, + revision: value.revision, + title: value.title, + tags: Object.freeze(value.tags.slice()), + updatedAt: value.updatedAt, + root: cursor(value.document), + }); + }, + node(handle) { + const value = data().part.nodes.get(handle); + if (!value) fail("search_source_invalid"); + return Object.freeze({ + type: value.type, + text: value.text ?? null, + target: + value.type === "noteLink" + ? validateNoteTarget(value.attrs?.target) + : null, + children: value.content?.length ?? 0, + }); + }, + child(handle, index) { + const value = data().part.nodes.get(handle); + if (!value) fail("search_source_invalid"); + return cursor(item(value.content ?? [], index)); + }, + clip(index) { + return Object.freeze({ ...item(data().c.clips, index) }); + }, + deck(index) { + return Object.freeze({ ...item(data().c.decks, index) }); + }, + card(index) { + return Object.freeze({ ...item(data().c.cards, index) }); + }, + entry(index) { + const { c, entryPart } = data(), + value = item(c.entries, index), + base = { + id: value.id, + revision: value.revision, + deckId: value.deckId, + tags: Object.freeze(value.tags.slice()), + }; + if (value.type !== "cloze") + return Object.freeze({ + ...base, + type: value.type, + front: value.front, + back: value.back, + }); + const handle = Object.freeze({}) as SearchEntryCursor; + entryPart.entries.set(handle, value); + return Object.freeze({ + ...base, + type: value.type, + text: value.text, + blankCount: value.blanks.length, + cursor: handle, + }); + }, + blank(handle, index) { + const value = data().entryPart.entries.get(handle); + if (!value) fail("search_source_invalid"); + return Object.freeze({ ...item(value.blanks, index) }); + }, + }); +} diff --git a/web/tests/data/collections-close.test.ts b/web/tests/data/collections-close.test.ts new file mode 100644 index 0000000..819e29b --- /dev/null +++ b/web/tests/data/collections-close.test.ts @@ -0,0 +1,118 @@ +// @vitest-environment node +import { beforeAll, beforeEach, afterEach, expect, it, vi } from "vitest"; +import { readFile } from "node:fs/promises"; +import { IDBFactory } from "fake-indexeddb"; +import { initSync, DeviceKeys } from "../../src/generated/wasm/orbyn_wasm.js"; +import { establish, lock } from "../../src/account/session"; +import { + mutateRecord, + newWrappingKey, + wrappingSecret, +} from "../../src/data/database"; +import type { DeviceRecord } from "../../src/data/database"; +import type { CollectionsStore } from "../../src/collections/contracts"; +import { + openCollectionsStore, + onCollectionsClose, + assertCollectionsAccess, +} from "../../src/collections/store"; + +const identity = { + accountId: "11111111-1111-4111-8111-111111111111", + deviceId: "22222222-2222-4222-8222-222222222222", + email: "collections-close@example.test", + deviceState: "trusted" as const, +}; +const handles = new Set(); +async function open() { + const store = await openCollectionsStore(identity); + handles.add(store); + return store; +} +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(); + const secret = await wrappingSecret(wrapKey); + const keys = DeviceKeys.create(); + try { + await mutateRecord("devices", identity.email, () => ({ + ...identity, + wrapKey, + sealedKeys: keys.export(secret), + generation: 0, + verifiedTrust: true, + })); + } finally { + secret.fill(0); + keys.free(); + } + establish(identity); +}); +afterEach(() => { + for (const store of handles) store.close(); + handles.clear(); + lock(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it.each(["close", "lock", "same-identity reauthentication"] as const)( + "%s synchronously revokes a genuine borrowed owner exactly once", + async (cause) => { + const store = await open(); + let draft: string | null = "Unsaved private settings"; + const ordinary = vi.fn(); + store.subscribe(ordinary); + const revoked = vi.fn(() => { + expect(() => assertCollectionsAccess(store)).toThrow(/locked/); + draft = null; + }); + onCollectionsClose(store, revoked); + if (cause === "close") store.close(); + else if (cause === "lock") lock(); + else establish(identity); + expect(draft).toBeNull(); + expect(revoked).toHaveBeenCalledTimes(1); + expect(ordinary).not.toHaveBeenCalled(); + store.close(); + expect(revoked).toHaveBeenCalledTimes(1); + await expect(store.read()).rejects.toThrow(/locked/); + expect(() => onCollectionsClose(store, vi.fn())).toThrow(/locked/); + }, +); + +it("an unsubscribed close observer receives no notification", async () => { + const store = await open(); + const observer = vi.fn(); + const stop = onCollectionsClose(store, observer); + stop(); + stop(); + store.close(); + expect(observer).not.toHaveBeenCalled(); +}); + +it("one failed observer cannot prevent other private consumers from revoking", async () => { + const store = await open(); + onCollectionsClose(store, () => { + throw new Error("synthetic observer error"); + }); + const second = vi.fn(); + onCollectionsClose(store, second); + expect(() => store.close()).not.toThrow(); + expect(second).toHaveBeenCalledTimes(1); + await expect(store.read()).rejects.toThrow(/locked/); +}); + +it("rejects a structural imitation rather than registering against another lifetime", () => { + expect(() => onCollectionsClose({} as CollectionsStore, vi.fn())).toThrow( + /locked/, + ); +}); diff --git a/web/tests/data/collections-linear.test.ts b/web/tests/data/collections-linear.test.ts new file mode 100644 index 0000000..8c7f268 --- /dev/null +++ b/web/tests/data/collections-linear.test.ts @@ -0,0 +1,179 @@ +// @vitest-environment node +import { beforeAll, afterEach, it, expect, vi } from "vitest"; +import { readFile } from "node:fs/promises"; +import { IDBFactory } from "fake-indexeddb"; +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 { openCollectionsStore } from "../../src/collections/store"; +import { emptyStudy } from "../../src/collections/validation"; +import type { + CollectionsState, + CollectionsStore, +} from "../../src/collections/contracts"; + +const identity = { + accountId: "11111111-1111-4111-8111-111111111111", + deviceId: "22222222-2222-4222-8222-222222222222", + email: "linear@example.test", + deviceState: "trusted" as const, +}; +let store: CollectionsStore | undefined; +beforeAll(async () => { + initSync({ + module: await readFile( + new URL("../../src/generated/wasm/orbyn_wasm_bg.wasm", import.meta.url), + ), + }); +}); +afterEach(() => { + store?.close(); + store = undefined; + vi.restoreAllMocks(); + lock(); +}); +it("keeps source admission linear at all 10000 anchored entries and still rejects forged birth", async () => { + lock(); + vi.stubGlobal("indexedDB", new IDBFactory()); + establish(identity); + const at = new Date().toISOString(), + deckId = crypto.randomUUID(), + libraryEpoch = crypto.randomUUID(); + const state: CollectionsState = { + version: 3, + epoch: crypto.randomUUID(), + revision: 1, + study: emptyStudy("UTC"), + journal: null, + clips: { items: [], receipts: [] }, + }; + state.study.decks.push({ + id: deckId, + title: "Large real source profile", + revision: 1, + createdAt: at, + updatedAt: at, + }); + for (let index = 0; index < 10000; index++) { + const entryId = crypto.randomUUID(); + state.study.entries.push({ + id: entryId, + deckId, + revision: 1, + type: "basic", + front: "Question", + back: "Answer", + tags: [], + source: { + version: 2, + noteId: "actual-source-profile", + noteRevision: 1, + quote: "Actual source", + libraryEpoch, + anchorId: crypto.randomUUID(), + }, + }); + state.study.cards.push({ + id: crypto.randomUUID(), + entryId, + revision: 1, + key: "basic:forward", + schedule: { + version: 1, + state: "new", + due: at, + intervalDays: 0, + ease: 2.5, + repetitions: 0, + lapses: 0, + lastReviewedAt: null, + }, + }); + } + const wrapKey = await newWrappingKey(), + secret = await wrappingSecret(wrapKey), + keys = DeviceKeys.create(), + engine = SyncEngine.create(keys, identity.accountId, identity.deviceId); + try { + engine.createZoneKey("notes-collections-v1"); + engine.stage( + "notes-collections-v1", + "notes_collections", + "workspace", + JSON.stringify({ + collections: { + value: state, + stamp: JSON.parse(engine.tick(Date.now())), + }, + }), + ); + await mutateRecord("devices", identity.email, () => ({ + ...identity, + wrapKey, + sealedKeys: keys.export(secret), + snapshot: engine.snapshot(secret), + generation: 1, + verifiedTrust: true, + })); + } finally { + engine.free(); + keys.free(); + secret.fill(0); + } + store = await openCollectionsStore(identity); + const before = await store.read(), + originalFind = Array.prototype.find; + let predicateComparisons = 0; + const finding = vi + .spyOn(Array.prototype, "find") + .mockImplementation(function (this: unknown[], predicate, thisArg) { + const sourceEntries = + this.length === 10000 && + (this[0] as { type?: unknown })?.type === "basic"; + if (!sourceEntries) return originalFind.call(this, predicate, thisArg); + return originalFind.call(this, (value, index, array) => { + predicateComparisons++; + return predicate.call(thisArg, value, index, array); + }); + }); + let updated: CollectionsState; + try { + updated = await store.update(before, (current) => ({ + ...current, + study: { + ...current.study, + preferences: { + ...current.study.preferences, + showStreak: !current.study.preferences.showStreak, + }, + }, + })); + } finally { + finding.mockRestore(); + } + expect(predicateComparisons).toBeLessThanOrEqual(20000); + expect(updated!.study.entries).toHaveLength(10000); + expect(updated!.study.entries[9999]!.source).toEqual( + before.study.entries[9999]!.source, + ); + const stored = (await readRecord("devices", identity.email))!; + await expect( + store.update(updated!, (current) => { + current.study.entries[9999]!.source!.noteRevision++; + return current; + }), + ).rejects.toThrow("Anchored source birth"); + const after = (await readRecord("devices", identity.email))!; + expect(after.snapshot).toEqual(stored.snapshot); + expect(after.generation).toBe(stored.generation); +}, 30000); diff --git a/web/tests/data/collections.test.ts b/web/tests/data/collections.test.ts new file mode 100644 index 0000000..70287c0 --- /dev/null +++ b/web/tests/data/collections.test.ts @@ -0,0 +1,712 @@ +// @vitest-environment node +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + 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 { + openCollectionsStore, + replaceJournal, + onCollectionsEpochChange, + COLLECTIONS_ZONE, +} from "../../src/collections/store"; +import { CollectionsRevisionConflictError } from "../../src/collections/contracts"; +import type { + CollectionsState, + CollectionsStore, + JournalEnvelope, +} from "../../src/collections/contracts"; +import { + validateCollections, + validateJournalEnvelope, +} from "../../src/collections/validation"; +import { openNotesStore } from "../../src/data"; +import { + newWrappingKey, + wrappingSecret, + mutateRecord, + readRecord, +} from "../../src/data/database"; +import type { DeviceRecord } from "../../src/data/database"; +import * as database from "../../src/data/database"; +import { establish, lock } from "../../src/account/session"; + +const identity = { + accountId: "11111111-1111-4111-8111-111111111111", + deviceId: "22222222-2222-4222-8222-222222222222", + email: "collections@example.test", + deviceState: "trusted" as const, +}; +const deckId = "33333333-3333-4333-8333-333333333333"; +const entryId = "44444444-4444-4444-8444-444444444444"; +const cardId = "55555555-5555-4555-8555-555555555555"; +const at = "2026-11-11T00:00:00.000Z"; +const handles = new Set<{ close(): void }>(); +async function open() { + const store = await openCollectionsStore(identity); + handles.add(store); + return store; +} +function deck( + state: CollectionsState, + title = "Private synthetic deck", +): CollectionsState { + return { + ...state, + study: { + ...state.study, + decks: [{ id: deckId, title, createdAt: at, updatedAt: at, revision: 1 }], + }, + }; +} +function card(state: CollectionsState): CollectionsState { + const next = deck(state); + next.study.entries = [ + { + id: entryId, + deckId, + revision: 1, + tags: [], + source: null, + type: "basic", + front: "Secret question", + back: "Secret answer", + }, + ]; + next.study.cards = [ + { + id: cardId, + entryId, + key: "basic:forward", + revision: 1, + schedule: { + version: 1, + state: "new", + due: at, + intervalDays: 0, + ease: 2.5, + repetitions: 0, + lapses: 0, + lastReviewedAt: null, + }, + }, + ]; + return next; +} +async function writePayload(value: unknown) { + const record = (await readRecord("devices", identity.email))!; + const secret = await wrappingSecret(record.wrapKey); + try { + await mutateRecord("devices", identity.email, (current) => { + const keys = DeviceKeys.import(current!.sealedKeys, secret); + const engine = SyncEngine.restore( + keys, + current!.snapshot!, + secret, + Date.now(), + ); + try { + engine.stage( + COLLECTIONS_ZONE, + "notes_collections", + "workspace", + JSON.stringify({ + collections: { value, stamp: JSON.parse(engine.tick(Date.now())) }, + }), + ); + return { + ...current!, + snapshot: engine.snapshot(secret), + generation: current!.generation + 1, + }; + } finally { + engine.free(); + keys.free(); + } + }); + } finally { + secret.fill(0); + } +} +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(); + const secret = await wrappingSecret(wrapKey); + const keys = DeviceKeys.create(); + try { + await mutateRecord("devices", identity.email, () => ({ + ...identity, + wrapKey, + sealedKeys: keys.export(secret), + generation: 0, + verifiedTrust: true, + })); + } finally { + secret.fill(0); + keys.free(); + } + establish(identity); +}); +afterEach(() => { + for (const store of handles) store.close(); + handles.clear(); + vi.restoreAllMocks(); + lock(); +}); + +describe("real WASM encrypted collections", () => { + it("captures a non-UTC local timezone once and retains it after host timezone changes", async () => { + const Original = Intl.DateTimeFormat; + let hostZone = "Australia/Sydney"; + vi.spyOn(Intl, "DateTimeFormat").mockImplementation( + function (locales, options) { + const formatter = new Original(locales, options); + if (locales === undefined && options === undefined) { + const resolved = formatter.resolvedOptions(); + formatter.resolvedOptions = () => ({ + ...resolved, + timeZone: hostZone, + }); + } + return formatter; + }, + ); + const store = await open(); + expect((await store.read()).study.preferences.timezone).toBe( + "Australia/Sydney", + ); + store.close(); + hostZone = "UTC"; + expect((await (await open()).read()).study.preferences.timezone).toBe( + "Australia/Sydney", + ); + }); + it("persists one initial epoch across simultaneous open and reopen", async () => { + const [a, b] = await Promise.all([open(), open()]); + const initial = await a.read(); + expect(await b.read()).toEqual(initial); + expect(initial.revision).toBe(0); + a.close(); + expect(await (await open()).read()).toEqual(initial); + }); + it("coexists with Notes writes and stores only encrypted feature bytes", async () => { + const notes = await openNotesStore(identity); + handles.add(notes); + const note = await notes.create({ + title: "Secret note", + document: { type: "doc", content: [] }, + tags: [], + }); + const store = await open(); + const initial = await store.read(); + const saved = await store.update(initial, card); + await notes.update(note.id, { title: "Updated note" }, note.revision); + expect(await store.read()).toEqual(saved); + const raw = (await readRecord("devices", identity.email))!; + const bytes = new TextDecoder().decode(raw.snapshot); + for (const secret of [ + "Private synthetic deck", + "Secret question", + "Secret answer", + "Updated note", + ]) + expect(bytes).not.toContain(secret); + expect(Object.keys(raw)).not.toContain("study"); + expect((await notes.get(note.id))?.title).toBe("Updated note"); + }); + it("uses epoch+revision CAS across competing writers and preserves current state on conflict", async () => { + const [a, b] = await Promise.all([open(), open()]); + const initial = await a.read(); + const outcomes = await Promise.allSettled([ + a.update(initial, (state) => deck(state, "A")), + b.update(initial, (state) => deck(state, "B")), + ]); + expect( + outcomes.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + const failure = outcomes.find( + (result) => result.status === "rejected", + ) as PromiseRejectedResult; + expect(failure.reason).toBeInstanceOf(CollectionsRevisionConflictError); + expect(failure.reason.current).toEqual(await a.read()); + expect((await a.read()).revision).toBe(1); + }); + it("clones mutator results and rejects async, envelope, revision and malformed changes", async () => { + const store = await open(); + const initial = await store.read(); + let returned: CollectionsState | undefined; + const result = await store.update(initial, (state) => { + returned = deck(state); + return returned; + }); + returned!.study.decks[0]!.title = "Changed afterward"; + result.study.decks[0]!.title = "Changed result"; + const persisted = await store.read(); + expect(persisted.study.decks[0]!.title).toBe("Private synthetic deck"); + await expect( + store.update( + persisted, + (async (state: CollectionsState) => state) as unknown as ( + state: CollectionsState, + ) => CollectionsState, + ), + ).rejects.toThrow("synchronous"); + await expect( + store.update(persisted, (state) => ({ ...state, revision: 999 })), + ).rejects.toThrow("store-owned"); + await expect( + store.update(persisted, (state) => ({ + ...state, + journal: { version: 2 } as unknown as JournalEnvelope, + })), + ).rejects.toThrow("collections_invalid"); + await expect( + store.update(persisted, (state) => ({ + ...state, + study: { ...state.study, entries: [card(state).study.entries[0]!] }, + })), + ).rejects.toThrow("collections_invalid"); + expect(await store.read()).toEqual(persisted); + }); + it("restricts valid envelope replacement to the internal CAS boundary", async () => { + const store = await open(); + const initial = await store.read(); + const envelope: JournalEnvelope = { + version: 1, + kdf: { + name: "PBKDF2-SHA256", + iterations: 600000, + salt: btoa("s".repeat(16)), + }, + iv: btoa("i".repeat(12)), + ciphertext: btoa("c".repeat(16)), + }; + await expect( + store.update(initial, (state) => ({ ...state, journal: envelope })), + ).rejects.toThrow("store-owned"); + const saved = await replaceJournal(store, initial, envelope); + envelope.iv = btoa("z".repeat(12)); + expect((await store.read()).journal).toEqual(saved.journal); + const studied = await store.update(saved, deck); + expect(studied.journal).toEqual(saved.journal); + await expect(replaceJournal(store, initial, null)).rejects.toBeInstanceOf( + CollectionsRevisionConflictError, + ); + }); + it("rolls back quota failures and can reload before a new attempt", async () => { + const store = await open(); + const initial = await store.read(); + const put = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException("Synthetic quota", "QuotaExceededError"); + }); + await expect(store.update(initial, deck)).rejects.toThrow( + "Synthetic quota", + ); + put.mockRestore(); + expect(store.persistence().status).toBe("failed"); + expect(await store.read()).toEqual(initial); + expect((await store.update(initial, deck)).revision).toBe(1); + }); + it("reloads a committed unknown-result write instead of replaying over it", async () => { + const store = await open(); + const initial = await store.read(); + const broadcast = vi + .spyOn(BroadcastChannel.prototype, "postMessage") + .mockImplementationOnce(() => { + throw new Error("Synthetic notification failure after commit"); + }); + await expect(store.update(initial, deck)).rejects.toThrow( + "notification failure", + ); + broadcast.mockRestore(); + const committed = await store.read(); + expect(committed.revision).toBe(1); + expect(committed.study.decks).toHaveLength(1); + await expect(store.update(initial, deck)).rejects.toBeInstanceOf( + CollectionsRevisionConflictError, + ); + expect(await store.read()).toEqual(committed); + }); + it("keeps review history immutable except for the one-way undo marker", async () => { + const store = await open(); + const first = await store.update(await store.read(), card); + const reviewed = await store.update(first, (state) => { + const before = structuredClone(state.study.cards[0]!); + const target = state.study.cards[0]!; + target.revision++; + target.schedule = { + ...target.schedule, + state: "review", + due: "2026-11-12T00:00:00.000Z", + intervalDays: 1, + repetitions: 1, + lastReviewedAt: at, + }; + state.study.events.push({ + id: crypto.randomUUID(), + cardId, + sessionId: crypto.randomUUID(), + at, + grade: "good", + algorithm: "orbyn-interval-v1", + before: { schedule: before.schedule, cardRevision: before.revision }, + after: { + schedule: structuredClone(target.schedule), + cardRevision: target.revision, + }, + undoneAt: null, + }); + return state; + }); + await expect( + store.update(reviewed, (state) => { + state.study.events = []; + return state; + }), + ).rejects.toThrow("store-owned"); + await expect( + store.update(reviewed, (state) => { + state.study.events[0]!.grade = "easy"; + return state; + }), + ).rejects.toThrow("store-owned"); + const undone = await store.update(reviewed, (state) => { + state.study.events[0]!.undoneAt = at; + return state; + }); + await expect( + store.update(undone, (state) => { + state.study.events[0]!.undoneAt = null; + return state; + }), + ).rejects.toThrow("store-owned"); + }); + it("closes old handles across same-ID account reauthentication and locks late setup", async () => { + const store = await open(); + const initial = await store.read(); + establish(identity); + await expect(store.update(initial, deck)).rejects.toThrow("closed"); + let resume!: () => void; + const gate = new Promise((resolve) => { + resume = resolve; + }); + const sign = crypto.subtle.sign.bind(crypto.subtle); + vi.spyOn(crypto.subtle, "sign").mockImplementationOnce( + async (algorithm, key, data) => { + await gate; + return sign(algorithm, key, data); + }, + ); + const opening = openCollectionsStore(identity); + await vi.waitFor(() => expect(crypto.subtle.sign).toHaveBeenCalled()); + establish(identity); + resume(); + await expect(opening).rejects.toThrow("closed"); + expect(await (await open()).read()).toEqual(initial); + await expect( + openCollectionsStore({ ...identity, accountId: crypto.randomUUID() }), + ).rejects.toThrow("Sign in"); + }); + it("does not commit a write that was awaiting wrapping crypto when account locks", async () => { + const store = await open(); + const initial = await store.read(); + let resume!: () => void; + const gate = new Promise((resolve) => { + resume = resolve; + }); + const sign = crypto.subtle.sign.bind(crypto.subtle); + vi.spyOn(crypto.subtle, "sign").mockImplementationOnce( + async (algorithm, key, data) => { + await gate; + return sign(algorithm, key, data); + }, + ); + const pending = store.update(initial, deck); + lock(); + establish(identity); + resume(); + await expect(pending).rejects.toThrow("closed"); + expect(await (await open()).read()).toEqual(initial); + }); + it("rejects a stale Notes open before it can become an erase-capable handle", async () => { + let resume!: () => void; + const gate = new Promise((resolve) => { + resume = resolve; + }); + const original = database.readRecord; + const reading = vi + .spyOn(database, "readRecord") + .mockImplementationOnce(async (store, key) => { + const value = await original(store, key); + await gate; + return value; + }); + const pending = openNotesStore(identity); + await vi.waitFor(() => expect(reading).toHaveBeenCalled()); + establish(identity); + resume(); + await expect(pending).rejects.toThrow("closed"); + }); + it.each(["future", "corrupt"])( + "fails closed on authenticated %s payload without reset", + async (kind) => { + const store = await open(); + const initial = await store.read(); + store.close(); + await writePayload( + kind === "future" + ? { ...initial, version: 4 } + : { + ...initial, + study: { ...initial.study, decks: [{ id: deckId }] }, + }, + ); + const before = (await readRecord( + "devices", + identity.email, + ))!.snapshot; + await expect(openCollectionsStore(identity)).rejects.toThrow( + "collections_invalid", + ); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before); + }, + ); + it("atomically erases Notes and advances the encrypted collections tombstone", async () => { + const notes = await openNotesStore(identity); + handles.add(notes); + await notes.create({ + title: "Erase fixture", + document: { type: "doc", content: [] }, + tags: [], + }); + const store = await open(); + const initial = await store.read(); + const old = await store.update(initial, card); + const before = (await readRecord("devices", identity.email))!; + const ordering: string[] = []; + onCollectionsEpochChange(store, () => ordering.push("revoke")); + store.subscribe(() => ordering.push("publish")); + await notes.erase(); + const erased = await store.read(); + expect(erased.epoch).not.toBe(old.epoch); + expect(erased.revision).toBe(old.revision + 1); + expect(erased.study.decks).toEqual([]); + expect(erased.journal).toBeNull(); + expect(await notes.list()).toEqual([]); + expect(ordering[0]).toBe("revoke"); + await expect(store.update(old, card)).rejects.toBeInstanceOf( + CollectionsRevisionConflictError, + ); + const after = (await readRecord("devices", identity.email))!; + expect(after.sealedKeys).toEqual(before.sealedKeys); + expect(after.verifiedTrust).toBe(true); + await notes.erase(); + expect((await store.read()).revision).toBe(erased.revision + 1); + }); + it("rolls back BOTH erase effects when the snapshot transaction fails", async () => { + const notes = await openNotesStore(identity); + handles.add(notes); + const note = await notes.create({ + title: "Retained", + document: { type: "doc", content: [] }, + tags: [], + }); + const store = await open(); + const initial = await store.update(await store.read(), card); + const put = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException("Synthetic quota", "QuotaExceededError"); + }); + await expect(notes.erase()).rejects.toThrow("Synthetic quota"); + put.mockRestore(); + expect(await notes.get(note.id)).toEqual(note); + expect(await store.read()).toEqual(initial); + }); + it("revokes a stale pending Journal replacement and preserves unrelated encrypted zones on erase", async () => { + const notes = await openNotesStore(identity); + handles.add(notes); + const store = await open(); + const initial = await store.read(); + const record = (await readRecord("devices", identity.email))!; + const secret = await wrappingSecret(record.wrapKey); + try { + await mutateRecord("devices", identity.email, (current) => { + const keys = DeviceKeys.import(current!.sealedKeys, secret); + const engine = SyncEngine.restore( + keys, + current!.snapshot!, + secret, + Date.now(), + ); + try { + engine.createZoneKey("unrelated-fixture"); + engine.stage( + "unrelated-fixture", + "fixture", + "sentinel", + JSON.stringify({ + marker: { + value: "preserve synthetic unrelated zone", + stamp: JSON.parse(engine.tick(Date.now())), + }, + }), + ); + return { + ...current!, + snapshot: engine.snapshot(secret), + generation: current!.generation + 1, + }; + } finally { + engine.free(); + keys.free(); + } + }); + let resume!: () => void; + const gate = new Promise((resolve) => { + resume = resolve; + }); + const sign = crypto.subtle.sign.bind(crypto.subtle); + vi.spyOn(crypto.subtle, "sign").mockImplementationOnce( + async (algorithm, key, data) => { + await gate; + return sign(algorithm, key, data); + }, + ); + const envelope: JournalEnvelope = { + version: 1, + kdf: { + name: "PBKDF2-SHA256", + iterations: 600000, + salt: btoa("s".repeat(16)), + }, + iv: btoa("i".repeat(12)), + ciphertext: btoa("c".repeat(16)), + }; + const pending = replaceJournal(store, initial, envelope); + await notes.erase(); + resume(); + await expect(pending).rejects.toBeInstanceOf( + CollectionsRevisionConflictError, + ); + expect((await store.read()).journal).toBeNull(); + const final = (await readRecord( + "devices", + identity.email, + ))!; + const keys = DeviceKeys.import(final.sealedKeys, secret); + const engine = SyncEngine.restore( + keys, + final.snapshot!, + secret, + Date.now(), + ); + try { + expect(engine.record("unrelated-fixture", "sentinel")).toContain( + "preserve synthetic unrelated zone", + ); + } finally { + engine.free(); + keys.free(); + } + } finally { + secret.fill(0); + } + }); + it("validates cloze/card namespaces, exact schema, date, preferences and envelope bounds", async () => { + const state = card(await (await open()).read()); + const invalid = [ + { ...state, unknown: "plaintext" }, + { ...state, revision: -1 }, + { + ...state, + study: { + ...state.study, + decks: [...state.study.decks, ...state.study.decks], + }, + }, + { + ...state, + study: { + ...state.study, + preferences: { ...state.study.preferences, timezone: "Not/AZone" }, + }, + }, + { + ...state, + study: { + ...state.study, + cards: [{ ...state.study.cards[0]!, key: "reversed:forward" }], + }, + }, + { + ...state, + study: { + ...state.study, + cards: [ + { + ...state.study.cards[0]!, + schedule: { ...state.study.cards[0]!.schedule, due: "tomorrow" }, + }, + ], + }, + }, + ]; + for (const value of invalid) + expect(() => validateCollections(value)).toThrow("collections_invalid"); + const cloze = structuredClone(state); + cloze.study.entries = [ + { + id: entryId, + deckId, + revision: 1, + source: null, + tags: [], + type: "cloze", + text: "One {{forward}}", + blanks: [{ id: "forward", answer: "answer" }], + }, + ]; + expect(() => validateCollections(cloze)).toThrow(); + cloze.study.cards[0]!.key = "cloze:forward"; + expect(validateCollections(cloze)).toEqual(cloze); + cloze.study.entries[0] = { + ...cloze.study.entries[0]!, + type: "cloze", + text: "{{forward}} {{forward}}", + blanks: [{ id: "forward", answer: "answer" }], + }; + expect(() => validateCollections(cloze)).toThrow(); + expect(() => + validateJournalEnvelope({ + version: 1, + kdf: { name: "PBKDF2-SHA256", iterations: 1, salt: "AA==" }, + iv: "AA==", + ciphertext: "AA==", + }), + ).toThrow(); + }); +}); diff --git a/web/tests/data/library-migration.test.ts b/web/tests/data/library-migration.test.ts new file mode 100644 index 0000000..78d5a06 --- /dev/null +++ b/web/tests/data/library-migration.test.ts @@ -0,0 +1,404 @@ +// @vitest-environment node +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + 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 { openNotesStore } from "../../src/data"; +import { establish, lock } from "../../src/account/session"; +import { + newWrappingKey, + wrappingSecret, + openDatabase, + readRecord, + mutateRecord, +} from "../../src/data/database"; +import type { DeviceRecord } from "../../src/data/database"; +import { LibraryReadOnlyError } from "../../src/library/contracts"; +import { openCollectionsStore } from "../../src/collections/store"; +import { LIBRARY_ZONE } from "../../src/library/storage"; +import { exportMarkdown } from "../../src/transfer"; +import { adoptLibrary } from "../../src/library/model"; +import { validateNotes } from "../../src/domain/validation"; +const identity = { + accountId: "11111111-1111-4111-8111-111111111111", + deviceId: "22222222-2222-4222-8222-222222222222", + email: "legacy@example.test", + deviceState: "trusted" as const, +}; +const at = "2026-11-11T00:00:00.000Z"; +const legacy = { + id: "legacy opaque identity", + title: "Legacy synthetic note", + document: { type: "doc", content: [] }, + tags: ["Trip", "Trip", "trip", " padded ", "é", "é"], + createdAt: "2026-11-10T10:00:00+11:00", + updatedAt: at, + trashedAt: null, + revision: 7, + unknownLegacyField: { preserve: true }, +}; +const handles = new Set<{ close(): void }>(); +function oldDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open("orbyn-notes-local-v1", 2); + request.onupgradeneeded = () => { + for (const store of ["devices", "signups", "access"]) + request.result.createObjectStore(store); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} +async function seed( + notes: unknown[], + recordType = "notes_library", + metadata?: { recordType: string; value: unknown }, +) { + const wrapKey = await newWrappingKey(), + secret = await wrappingSecret(wrapKey), + keys = DeviceKeys.create(); + const engine = SyncEngine.create(keys, identity.accountId, identity.deviceId); + try { + engine.createZoneKey("notes-local"); + engine.stage( + "notes-local", + recordType, + "library", + JSON.stringify({ + notes: { value: notes, stamp: JSON.parse(engine.tick(Date.now())) }, + }), + ); + if (metadata) { + engine.createZoneKey(LIBRARY_ZONE); + engine.stage( + LIBRARY_ZONE, + metadata.recordType, + "workspace", + JSON.stringify({ + library: { + value: metadata.value, + stamp: JSON.parse(engine.tick(Date.now())), + }, + }), + ); + } + const record: DeviceRecord = { + ...identity, + wrapKey, + sealedKeys: keys.export(secret), + snapshot: engine.snapshot(secret), + generation: 0, + verifiedTrust: true, + }; + const db = await oldDatabase(); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction("devices", "readwrite"); + tx.objectStore("devices").put(record, identity.email); + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error); + }); + } finally { + db.close(); + } + return record; + } finally { + engine.free(); + keys.free(); + secret.fill(0); + } +} +async function raw(record: DeviceRecord, zone = "notes-local", id = "library") { + const secret = await wrappingSecret(record.wrapKey), + keys = DeviceKeys.import(record.sealedKeys, secret), + engine = SyncEngine.restore(keys, record.snapshot!, secret, Date.now()); + try { + return engine.record(zone, id); + } finally { + engine.free(); + keys.free(); + secret.fill(0); + } +} +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(); +}); +describe("version3 writer admission and non-destructive adoption", () => { + it.each(["core", "metadata"] as const)( + "rejects valid payload under a foreign authenticated %s record owner", + async (owner) => { + const metadata = adoptLibrary(validateNotes([legacy]), [legacy], at); + const before = await seed( + [legacy], + owner === "core" ? "foreign_core_owner" : "notes_library", + owner === "metadata" + ? { recordType: "foreign_metadata_owner", value: metadata } + : undefined, + ); + await expect(openNotesStore(identity)).rejects.toThrow("library_invalid"); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before.snapshot); + }, + ); + it("preserves old Trash on adoption and restores its exact original timestamp on Undo", async () => { + const trashedAt = "2019-01-01T12:00:00+11:00"; + await seed([{ ...legacy, trashedAt }]); + const store = await openNotesStore(identity); + handles.add(store); + expect((await store.get(legacy.id))!.trashedAt).toBe(trashedAt); + const restored = await store.library.changeTrash( + legacy.id, + "restore", + legacy.revision, + crypto.randomUUID(), + ); + const undone = await store.library.undoTrash( + restored.undo!, + crypto.randomUUID(), + ); + expect(undone.note!.trashedAt).toBe(trashedAt); + }); + it("adopts exact legacy labels once without rewriting core bytes, dates, IDs or unknown fields", async () => { + const before = await seed([legacy]), + beforeCore = await raw(before); + const store = await openNotesStore(identity); + handles.add(store); + const result = await store.library.read(); + expect(result.mode).toBe("ready"); + if (result.mode !== "ready") return; + expect(result.snapshot.tags.map((tag) => tag.name)).toEqual([ + ...new Set(legacy.tags), + ]); + expect(result.snapshot.tags.every((tag) => tag.color === null)).toBe(true); + const after = (await readRecord("devices", identity.email))!; + expect(await raw(after)).toBe(beforeCore); + expect(after.sealedKeys).toEqual(before.sealedKeys); + const metadata = JSON.parse((await raw(after, LIBRARY_ZONE, "workspace"))!) + .fields.library.value; + expect(metadata.legacyDuplicateTags).toEqual([ + { noteId: legacy.id, values: legacy.tags }, + ]); + expect(await store.get(legacy.id)).toMatchObject({ + id: legacy.id, + createdAt: legacy.createdAt, + unknownLegacyField: legacy.unknownLegacyField, + }); + store.close(); + const reopened = await openNotesStore(identity); + handles.add(reopened); + expect(await reopened.library.read()).toEqual(result); + expect( + (await readRecord("devices", identity.email))!.generation, + ).toBe(after.generation); + }); + it("drains the old transaction, then rejects old reopen/cached-connection effects after the fence", async () => { + const record = await seed([legacy]), + old = await oldDatabase(); + old.onversionchange = () => old.close(); + const transaction = old.transaction("devices", "readwrite"); + transaction + .objectStore("devices") + .put({ ...record, generation: 7 }, identity.email); + const drained = new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onabort = () => reject(transaction.error); + }); + const opening = openNotesStore(identity); + await drained; + const store = await opening; + handles.add(store); + expect( + (await readRecord("devices", identity.email))!.generation, + ).toBe(8); + await expect(oldDatabase()).rejects.toMatchObject({ name: "VersionError" }); + expect(() => old.transaction("devices", "readwrite")).toThrow(); + const before = (await readRecord("devices", identity.email))!; + // A delayed legacy task must reopen with its hardcoded2 before it can write. + await expect( + (async () => { + const stale = await oldDatabase(); + stale + .transaction("devices", "readwrite") + .objectStore("devices") + .put(record, identity.email); + })(), + ).rejects.toMatchObject({ name: "VersionError" }); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before.snapshot); + expect( + ( + await store.create({ + title: "Current writer control", + document: { type: "doc", content: [] }, + tags: [], + }) + ).revision, + ).toBe(1); + }); + it("fails a blocked upgrade without later silently converting after the caller has failed", async () => { + await seed([legacy]); + const old = await oldDatabase(); + await expect(openDatabase()).rejects.toThrow("storage_blocked"); + old.close(); + const retained = await oldDatabase(); + expect(retained.version).toBe(2); + retained.close(); + const current = await openDatabase(); + expect(current.version).toBe(3); + current.close(); + }); + it("opens oversized metadata profiles as real read/export-only data and requires fresh generation confirmation to erase", async () => { + const notes = Array.from({ length: 101 }, (_, i) => ({ + ...legacy, + id: "legacy-" + i, + tags: Array.from({ length: 100 }, (_, j) => "tag-" + i + "-" + j), + })); + const before = await seed(notes), + store = await openNotesStore(identity); + handles.add(store); + const result = await store.library.read(); + expect(result.mode).toBe("legacy-read-only"); + if (result.mode !== "legacy-read-only") return; + expect(result).not.toHaveProperty("token"); + expect(result).not.toHaveProperty("snapshot"); + expect(await store.list()).toHaveLength(101); + expect(exportMarkdown(await store.list())).toHaveLength(101); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before.snapshot); + const put = vi.spyOn(IDBObjectStore.prototype, "put"); + await expect( + store.create({ + title: "Must not create", + document: { type: "doc", content: [] }, + tags: [], + }), + ).rejects.toBeInstanceOf(LibraryReadOnlyError); + await expect( + store.update(notes[0]!.id, { title: "Must not update" }, 7), + ).rejects.toBeInstanceOf(LibraryReadOnlyError); + await expect(store.erase()).rejects.toBeInstanceOf(LibraryReadOnlyError); + expect(put).not.toHaveBeenCalled(); + put.mockRestore(); + const collections = await openCollectionsStore(identity); + handles.add(collections); + const previousCollections = await collections.read(); + await expect( + store.library.eraseLegacy( + { + deviceGeneration: result.recovery.deviceGeneration, + confirmation: "ERASE", + }, + crypto.randomUUID(), + ), + ).rejects.toThrow("confirmation_stale"); + expect(await store.list()).toHaveLength(101); + const fresh = await store.library.read(); + if (fresh.mode !== "legacy-read-only") throw new Error("fixture changed"); + const quota = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException( + "Synthetic recovery quota", + "QuotaExceededError", + ); + }); + await expect( + store.library.eraseLegacy( + { + deviceGeneration: fresh.recovery.deviceGeneration, + confirmation: "ERASE", + }, + crypto.randomUUID(), + ), + ).rejects.toThrow("recovery quota"); + quota.mockRestore(); + expect(await store.list()).toHaveLength(101); + expect((await collections.read()).epoch).toBe(previousCollections.epoch); + const id = crypto.randomUUID(), + erased = await store.library.eraseLegacy( + { + deviceGeneration: fresh.recovery.deviceGeneration, + confirmation: "ERASE", + }, + id, + ); + expect(erased.snapshot.notes).toEqual([]); + expect(erased.removedCount).toBe(101); + expect(erased.snapshot.token.revision).toBe(1); + const replay = await store.library.eraseLegacy( + { + deviceGeneration: fresh.recovery.deviceGeneration, + confirmation: "ERASE", + }, + id, + ); + expect(replay.replayed).toBe(true); + const currentCollections = await collections.read(); + expect(currentCollections.epoch).not.toBe(previousCollections.epoch); + const after = (await readRecord("devices", identity.email))!; + expect(after.sealedKeys).toEqual(before.sealedKeys); + expect(after.verifiedTrust).toBe(true); + expect( + ( + await store.create({ + title: "After explicit recovery", + document: { type: "doc", content: [] }, + tags: [], + }) + ).title, + ).toBe("After explicit recovery"); + }); + it("rejects asynchronous transaction callbacks without writing or leaking an unhandled rejection", async () => { + await seed([legacy]); + const before = (await readRecord("devices", identity.email))!; + const change = (async () => { + throw new Error("Synthetic async callback"); + }) as unknown as (record: DeviceRecord | undefined) => DeviceRecord; + await expect( + mutateRecord("devices", identity.email, change), + ).rejects.toThrow("must be synchronous"); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before.snapshot); + }); + it("preserves the pre-existing in-place transaction callback contract", async () => { + await seed([legacy]); + await mutateRecord("devices", identity.email, (current) => { + current!.generation = 12; + return current; + }); + expect( + (await readRecord("devices", identity.email))!.generation, + ).toBe(12); + }); +}); diff --git a/web/tests/data/library.test.ts b/web/tests/data/library.test.ts new file mode 100644 index 0000000..58e281e --- /dev/null +++ b/web/tests/data/library.test.ts @@ -0,0 +1,725 @@ +// @vitest-environment node +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + 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 { openNotesStore } from "../../src/data"; +import { establish, lock } from "../../src/account/session"; +import { + mutateRecord, + newWrappingKey, + readRecord, + wrappingSecret, +} from "../../src/data/database"; +import type { DeviceRecord } from "../../src/data/database"; +import type { + LibrarySnapshot, + LibraryState, + NotesLibraryStore, +} from "../../src/library/contracts"; +import { + LibraryConflictError, + LibraryEpochError, +} from "../../src/library/contracts"; +import { LIBRARY_ZONE } from "../../src/library/storage"; +import { retainReceipts } from "../../src/library/model"; +import { + LIBRARY_LIMITS, + jsonBytes, + validateLibrary, +} from "../../src/library/validation"; +import { + emptyLibrary, + projectLibraryNotes, + retainHistory, +} from "../../src/library/model"; +import { openCollectionsStore } from "../../src/collections/store"; +const identity = { + accountId: "11111111-1111-4111-8111-111111111111", + deviceId: "22222222-2222-4222-8222-222222222222", + email: "library@example.test", + deviceState: "trusted" as const, +}; +const input = { + title: "Private synthetic note", + document: { + type: "doc" as const, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Private synthetic body" }], + }, + ], + }, + tags: ["source"], +}; +const handles = new Set<{ close(): void }>(); +async function open() { + const store = await openNotesStore(identity); + handles.add(store); + return store; +} +async function ready(store: NotesLibraryStore): Promise { + const result = await store.library.read(); + if (result.mode !== "ready") throw new Error("fixture not ready"); + return result.snapshot; +} +async function inspect(change?: (state: LibraryState) => unknown) { + const record = (await readRecord("devices", identity.email))!, + secret = await wrappingSecret(record.wrapKey); + const keys = DeviceKeys.import(record.sealedKeys, secret), + engine = SyncEngine.restore(keys, record.snapshot!, secret, Date.now()); + try { + const state: LibraryState = JSON.parse( + engine.record(LIBRARY_ZONE, "workspace")!, + ).fields.library.value; + if (!change) return state; + const value = change(state); + engine.stage( + LIBRARY_ZONE, + "notes_library_metadata", + "workspace", + JSON.stringify({ + library: { value, stamp: JSON.parse(engine.tick(Date.now())) }, + }), + ); + const snapshot = engine.snapshot(secret); + await mutateRecord("devices", identity.email, (current) => ({ + ...current!, + snapshot, + generation: current!.generation + 1, + })); + return state; + } finally { + engine.free(); + keys.free(); + secret.fill(0); + } +} +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 store of handles) store.close(); + handles.clear(); + vi.restoreAllMocks(); + vi.useRealTimers(); + lock(); +}); +describe("encrypted library metadata owner", () => { + it("bounds receipt count/age while retaining a just-committed operation at the same instant", () => { + const state = emptyLibrary(), + now = Date.now(); + state.revision = 501; + state.receipts = Array.from({ length: 501 }, (_, index) => ({ + id: String(index).padStart(8, "0") + "-1111-4111-8111-111111111111", + epoch: state.epoch, + committedAt: new Date(now).toISOString(), + libraryRevision: index + 1, + kind: "sort" as const, + arguments: "[]", + result: { type: "metadata" as const }, + })); + const protectedId = state.receipts[0]!.id; + retainReceipts(state, now, protectedId); + expect(state.receipts).toHaveLength(500); + expect(state.receipts.some((receipt) => receipt.id === protectedId)).toBe( + true, + ); + retainReceipts(state, now + 600001); + expect(state.receipts).toEqual([]); + }); + it("persists real pin/sort without changing document revision/date and composes filters", async () => { + const store = await open(), + a = await store.create({ ...input, title: "Zulu" }), + b = await store.create({ ...input, title: "Alpha" }); + let state = await ready(store); + state = ( + await store.library.setPinned( + a.id, + true, + state.token, + crypto.randomUUID(), + ) + ).snapshot; + state = ( + await store.library.setSort("title", state.token, crypto.randomUUID()) + ).snapshot; + expect(await store.get(a.id)).toEqual(a); + expect(await store.library.history(a.id)).toEqual([]); + expect(projectLibraryNotes(state).map((note) => note.id)).toEqual([ + a.id, + b.id, + ]); + expect( + projectLibraryNotes(state, { + pinnedOnly: true, + query: "Zulu", + tagId: state.tags[0]!.id, + }), + ).toHaveLength(1); + store.close(); + const reopened = await ready(await open()); + expect(reopened.preferences.sort).toBe("title"); + expect(reopened.metadata.find((meta) => meta.noteId === a.id)!.pinned).toBe( + true, + ); + const record = (await readRecord("devices", identity.email))!; + expect(new TextDecoder().decode(record.snapshot)).not.toContain( + "Private synthetic", + ); + expect(Object.keys(record)).not.toContain("library"); + }); + it("atomically renames live and trashed references and rejects competing editor autosave", async () => { + const a = await open(), + b = await open(); + const first = await a.create(input), + second = await a.create({ ...input, title: "Trashed" }); + await a.trash(second.id, second.revision); + const state = await ready(a), + tag = state.tags[0]!; + const results = await Promise.allSettled([ + a.library.renameTag(tag.id, "renamed", state.token, crypto.randomUUID()), + b.update( + first.id, + { tags: ["source"], title: "Autosave" }, + first.revision, + ), + ]); + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + const final = await ready(a), + renamed = + final.tags.find((tag) => tag.id === state.tags[0]!.id)!.name === + "renamed"; + expect( + final.notes.every((note) => + note.tags.includes(renamed ? "renamed" : "source"), + ), + ).toBe(true); + if (!renamed) { + const committed = await a.library.renameTag( + tag.id, + "renamed", + final.token, + crypto.randomUUID(), + ); + expect( + committed.snapshot.notes.every((note) => note.tags[0] === "renamed"), + ).toBe(true); + } + }); + it("flattens A→B→C aliases, preserves colors/history and deletes aliases without deleting notes", async () => { + const store = await open(); + let note = await store.create({ ...input, tags: ["A", "B", "C"] }); + let state = await ready(store); + const [a, b, c] = ["A", "B", "C"].map((name) => + state.tags.find((tag) => tag.name === name)!, + ); + state = ( + await store.library.recolorTag( + a!.id, + "#ff0000", + state.token, + crypto.randomUUID(), + ) + ).snapshot; + note = await store.update( + note.id, + { title: "Capture colored tags" }, + note.revision, + ); + const history = await store.library.history(note.id); + state = await ready(store); + state = ( + await store.library.mergeTag( + a!.id, + b!.id, + state.token, + crypto.randomUUID(), + ) + ).snapshot; + const stale = state.token; + state = ( + await store.library.mergeTag( + b!.id, + c!.id, + state.token, + crypto.randomUUID(), + ) + ).snapshot; + expect( + state.tags + .filter((tag) => tag.state === "merged") + .every((tag) => tag.mergedInto === c!.id), + ).toBe(true); + expect(state.notes[0]!.tags).toEqual(["C"]); + await expect( + store.library.deleteTag(c!.id, stale, crypto.randomUUID()), + ).rejects.toBeInstanceOf(LibraryConflictError); + state = ( + await store.library.deleteTag(c!.id, state.token, crypto.randomUUID()) + ).snapshot; + expect(state.notes).toHaveLength(1); + expect(state.notes[0]!.tags).toEqual([]); + expect( + state.tags.every( + (tag) => tag.state === "deleted" && tag.mergedInto === null, + ), + ).toBe(true); + expect(state.tags.find((tag) => tag.id === a!.id)!.color).toBe("#ff0000"); + expect(await store.library.history(note.id)).toEqual(history); + const replacement = await store.library.createTag( + { name: "A", color: null }, + state.token, + crypto.randomUUID(), + ); + expect(replacement.tag!.id).not.toBe(a!.id); + expect(replacement.snapshot.metadata[0]!.tagIds).toEqual([]); + }); + it("rolls back both records when a multi-note catalog mutation fails quota", async () => { + const store = await open(); + await store.create(input); + await store.create(input); + const before = await ready(store); + const put = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException("Synthetic quota", "QuotaExceededError"); + }); + await expect( + store.library.renameTag( + before.tags[0]!.id, + "Must not commit", + before.token, + crypto.randomUUID(), + ), + ).rejects.toThrow("Synthetic quota"); + put.mockRestore(); + expect(await ready(store)).toEqual(before); + }); + it("recognizes an unknown-result receipt without replaying and rejects mismatching operation reuse", async () => { + const store = await open(), + note = await store.create(input), + state = await ready(store), + id = crypto.randomUUID(); + const post = vi + .spyOn(BroadcastChannel.prototype, "postMessage") + .mockImplementationOnce(() => { + throw new Error("Synthetic post-commit notification failure"); + }); + await expect( + store.library.setPinned(note.id, true, state.token, id), + ).rejects.toThrow("notification failure"); + post.mockRestore(); + const before = (await readRecord("devices", identity.email))!; + const replay = await store.library.setPinned( + note.id, + true, + state.token, + id, + ); + expect(replay.replayed).toBe(true); + expect(replay.snapshot.metadata[0]!.pinned).toBe(true); + expect( + (await readRecord("devices", identity.email))!.generation, + ).toBe(before.generation); + await expect( + store.library.setPinned(note.id, false, state.token, id), + ).rejects.toThrow("operation_conflict"); + }); + it("restores immutable title/document versions as a new revision while retaining current metadata and previous current", async () => { + const store = await open(); + let note = await store.create(input); + note = await store.update( + note.id, + { + title: "New current", + document: { type: "doc", content: [] }, + tags: ["new-tag"], + }, + note.revision, + ); + const original = (await store.library.history(note.id))[0]!; + const value = await store.library.version(note.id, original.id); + value!.title = "Mutated preview"; + expect((await store.library.version(note.id, original.id))!.title).toBe( + input.title, + ); + await store.library.setPinned( + note.id, + true, + (await ready(store)).token, + crypto.randomUUID(), + ); + const result = await store.library.restoreVersion( + note.id, + original.id, + note.revision, + crypto.randomUUID(), + ); + expect(result.note).toMatchObject({ + title: input.title, + document: input.document, + tags: ["new-tag"], + revision: note.revision + 1, + createdAt: note.createdAt, + }); + expect(result.snapshot.metadata[0]!.pinned).toBe(true); + expect( + (await store.library.history(note.id)).some( + (version) => version.title === "New current", + ), + ).toBe(true); + const before = await store.library.history(note.id); + await expect( + store.library.restoreVersion( + note.id, + original.id, + note.revision, + crypto.randomUUID(), + ), + ).rejects.toThrow("another window"); + expect(await store.library.history(note.id)).toEqual(before); + }); + it("enforces bounded history count and age without deleting current or legacy trash", async () => { + const store = await open(); + let note = await store.create(input); + for (let i = 0; i < 55; i++) + note = await store.update(note.id, { title: String(i) }, note.revision); + expect(await store.library.history(note.id)).toHaveLength(50); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 31 * 86400000); + expect(await store.library.history(note.id)).toEqual([]); + note = await store.update( + note.id, + { title: "After age pruning" }, + note.revision, + ); + expect(await store.library.history(note.id)).toHaveLength(1); + const moved = await store.trash(note.id, note.revision); + vi.setSystemTime(Date.now() + 365 * 86400000); + expect(await store.get(note.id)).toEqual(moved); + }); + it("bounds UTF-8 history bytes and protects the latest capture when trimming", async () => { + const now = Date.now(), + state = emptyLibrary(), + body = { + type: "doc" as const, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "界".repeat(650000) }], + }, + ], + }; + const note = { + ...input, + id: "opaque-legacy-id", + createdAt: new Date(now).toISOString(), + updatedAt: new Date(now).toISOString(), + trashedAt: null, + revision: 20, + tags: [], + }; + state.noteMetadata = [ + { noteId: note.id, revision: 1, pinned: false, tagIds: [] }, + ]; + state.versions = Array.from({ length: 6 }, (_, i) => ({ + id: crypto.randomUUID(), + noteId: note.id, + noteRevision: i + 1, + capturedAt: new Date(now + i).toISOString(), + reason: "edit" as const, + title: String(i), + document: body, + tagsAtCapture: [], + })); + const protectedId = state.versions[5]!.id; + retainHistory(state, now, protectedId); + expect(state.versions.some((version) => version.id === protectedId)).toBe( + true, + ); + expect(state.versions.length).toBeLessThan(6); + expect(jsonBytes(state.versions)).toBeLessThanOrEqual( + LIBRARY_LIMITS.noteHistoryBytes, + ); + expect(validateLibrary(state, [note]).versions).toHaveLength( + state.versions.length, + ); + }); + it("enforces aggregate history byte limits without evicting current notes or the protected version", () => { + const now = Date.now(), + state = emptyLibrary(), + body = { + type: "doc" as const, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "界".repeat(650000) }], + }, + ], + }; + const notes = Array.from({ length: 18 }, (_, i) => ({ + ...input, + id: "global-history-" + i, + createdAt: new Date(now).toISOString(), + updatedAt: new Date(now).toISOString(), + trashedAt: null, + revision: 2, + tags: [], + })); + state.noteMetadata = notes.map((note) => ({ + noteId: note.id, + revision: 1, + pinned: false, + tagIds: [], + })); + state.versions = notes.map((note, i) => ({ + id: crypto.randomUUID(), + noteId: note.id, + noteRevision: 1, + capturedAt: new Date(now + i).toISOString(), + reason: "edit" as const, + title: "Old", + document: body, + tagsAtCapture: [], + })); + const protectedId = state.versions[0]!.id; + retainHistory(state, now, protectedId); + expect(state.versions.some((version) => version.id === protectedId)).toBe( + true, + ); + expect(jsonBytes(state.versions)).toBeLessThanOrEqual( + LIBRARY_LIMITS.historyBytes, + ); + expect(state.versions.length).toBeLessThan(18); + expect(notes).toHaveLength(18); + }); + it("does not create content history from equivalent object-key ordering", async () => { + const store = await open(); + const note = await store.create(input); + await store.update( + note.id, + { + document: { + content: [ + { + content: [{ text: "Private synthetic body", type: "text" }], + type: "paragraph", + }, + ], + type: "doc", + }, + }, + note.revision, + ); + expect(await store.library.history(note.id)).toEqual([]); + }); + it("supports trash and restore Undo with the exact previous timestamp and rejects stale/expired reuse", async () => { + const store = await open(), + note = await store.create(input); + const moved = await store.library.changeTrash( + note.id, + "trash", + note.revision, + crypto.randomUUID(), + ); + const restored = await store.library.changeTrash( + note.id, + "restore", + moved.note!.revision, + crypto.randomUUID(), + ); + const undidRestore = await store.library.undoTrash( + restored.undo!, + crypto.randomUUID(), + ); + expect(undidRestore.note!.trashedAt).toBe(moved.note!.trashedAt); + await expect( + store.library.undoTrash(restored.undo!, crypto.randomUUID()), + ).rejects.toThrow("undo_unavailable"); + const again = await store.library.changeTrash( + note.id, + "restore", + undidRestore.note!.revision, + crypto.randomUUID(), + ); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 5001); + await expect( + store.library.undoTrash(again.undo!, crypto.randomUUID()), + ).rejects.toThrow("undo_unavailable"); + }); + it("confirms exact Empty Trash revision, removes history and preserves collections data/epoch", async () => { + const store = await open(); + let note = await store.create(input); + note = await store.update(note.id, { title: "Has history" }, note.revision); + await store.trash(note.id, note.revision); + const collections = await openCollectionsStore(identity); + handles.add(collections); + const beforeCollections = await collections.read(); + const old = (await ready(store)).token; + const keep = await store.create({ ...input, title: "Keep" }); + await expect( + store.library.emptyTrash(old, crypto.randomUUID()), + ).rejects.toBeInstanceOf(LibraryConflictError); + const result = await store.library.emptyTrash( + (await ready(store)).token, + crypto.randomUUID(), + ); + expect(result.removedCount).toBe(1); + expect(await store.get(note.id)).toBeNull(); + expect(await store.get(keep.id)).toEqual(keep); + expect( + await store.library.version(note.id, crypto.randomUUID()), + ).toBeNull(); + expect((await inspect()).versions).toEqual([]); + expect(await collections.read()).toEqual(beforeCollections); + }); + it.each(["create", "tag", "update", "history"] as const)( + "fences delayed same-handle %s across erase before any effect", + async (kind) => { + const store = await open(); + let note = await store.create(input); + note = await store.update(note.id, { title: "Current" }, note.revision); + const state = await ready(store), + version = (await store.library.history(note.id))[0]!; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const real = crypto.subtle.sign.bind(crypto.subtle); + vi.spyOn(crypto.subtle, "sign").mockImplementationOnce( + async (algorithm, key, data) => { + await gate; + return real(algorithm, key, data); + }, + ); + const pending = + kind === "create" + ? store.create(input) + : kind === "tag" + ? store.library.createTag( + { name: "Old intent", color: null }, + state.token, + crypto.randomUUID(), + ) + : kind === "update" + ? store.update(note.id, { title: "Old draft" }, note.revision) + : store.library.restoreVersion( + note.id, + version.id, + note.revision, + crypto.randomUUID(), + ); + await store.erase(); + release(); + await expect(pending).rejects.toBeInstanceOf(LibraryEpochError); + expect(await store.list()).toEqual([]); + expect((await ready(store)).tags).toEqual([]); + expect( + (await store.create({ ...input, title: "Fresh new epoch" })).title, + ).toBe("Fresh new epoch"); + }, + ); + it("keeps foreign erased handles read-only until reopened and rejects future/corrupt metadata", async () => { + const a = await open(), + b = await open(); + await a.create(input); + await a.erase(); + expect(await b.list()).toEqual([]); + await expect(b.create(input)).rejects.toBeInstanceOf(LibraryEpochError); + const reopened = await open(); + await reopened.create(input); + reopened.close(); + await inspect((state) => ({ ...state, version: 4 })); + await expect(openNotesStore(identity)).rejects.toThrow("library_invalid"); + }); + it("rolls back Empty Trash and whole-workspace erase on quota failure", async () => { + const store = await open(), + note = await store.create(input); + await store.trash(note.id, note.revision); + const before = await ready(store); + const collections = await openCollectionsStore(identity); + handles.add(collections); + const collectionsBefore = await collections.read(); + for (const action of [ + () => store.library.emptyTrash(before.token, crypto.randomUUID()), + () => store.erase(), + ]) { + const put = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException("Synthetic quota", "QuotaExceededError"); + }); + await expect(action()).rejects.toThrow("Synthetic quota"); + put.mockRestore(); + expect(await ready(store)).toEqual(before); + expect(await collections.read()).toEqual(collectionsBefore); + } + }); + it("revokes a metadata command awaiting wrapping crypto across same-ID reauthentication", async () => { + const store = await open(); + await store.create(input); + const before = await ready(store); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const sign = crypto.subtle.sign.bind(crypto.subtle); + vi.spyOn(crypto.subtle, "sign").mockImplementationOnce( + async (algorithm, key, data) => { + await gate; + return sign(algorithm, key, data); + }, + ); + const pending = store.library.renameTag( + before.tags[0]!.id, + "Revoked rename", + before.token, + crypto.randomUUID(), + ); + establish(identity); + release(); + await expect(pending).rejects.toThrow("closed"); + expect(await ready(await open())).toEqual(before); + }); +}); diff --git a/web/tests/data/search-service-integration.test.ts b/web/tests/data/search-service-integration.test.ts new file mode 100644 index 0000000..680dc56 --- /dev/null +++ b/web/tests/data/search-service-integration.test.ts @@ -0,0 +1,193 @@ +// @vitest-environment node +import { afterEach, beforeAll, expect, it, vi } from "vitest"; +import { readFile } from "node:fs/promises"; +import { IDBFactory } from "fake-indexeddb"; +import { + initSync, + DeviceKeys, + SyncEngine, +} from "../../src/generated/wasm/orbyn_wasm.js"; +import { + newWrappingKey, + wrappingSecret, + mutateRecord, + 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 { createDeck, saveEntry } from "../../src/study/model"; +import { createLocalSearchService } from "../../src/search/service"; +import type { SearchState } from "../../src/search/results"; + +const identity = { + accountId: "31111111-1111-4111-8111-111111111111", + deviceId: "32222222-2222-4222-8222-222222222222", + email: "search-service@example.test", + deviceState: "trusted" as const, +}; +const handles = new Set<{ close(): void }>(); +beforeAll(async () => + initSync({ + module: await readFile( + new URL("../../src/generated/wasm/orbyn_wasm_bg.wasm", import.meta.url), + ), + }), +); +afterEach(() => { + for (const handle of handles) handle.close(); + handles.clear(); + lock(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +const ready = (state: SearchState) => { + expect(state.status).toBe("ready"); + if (state.status !== "ready") + throw new Error("Expected real completed search"); + return state; +}; +async function owners() { + 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); + const notes = await openNotesStore(identity), + collections = await openCollectionsStore(identity); + handles.add(notes); + handles.add(collections); + return { notes, collections }; +} +it("rejects a genuine changed wrapping key during acquisition without retaining the query", async () => { + const { notes, collections } = await owners(); + const replacement = await newWrappingKey(); + await mutateRecord("devices", identity.email, (current) => { + if (!current) throw new Error("Missing real fixture record"); + return { ...current, wrapKey: replacement }; + }); + const search = createLocalSearchService(notes, collections); + handles.add(search); + const revoked = vi.fn(); + search.onInvalidate(revoked); + await search.query("private before source admission"); + expect(search.getSnapshot()).toEqual({ status: "idle", raw: "" }); + expect(revoked).toHaveBeenCalledWith("unverified"); +}); +it("searches a real encrypted mixed corpus, reuses the engine projection and revokes on save/lock", async () => { + const { notes, collections } = await owners(); + const note = await notes.create({ + title: "Lisbon budget", + tags: ["travel"], + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Café and Straße" }], + }, + ], + }, + }); + const trashed = await notes.create({ + title: "Lisbon hidden", + tags: [], + document: { type: "doc", content: [] }, + }); + await notes.trash(trashed.id, 1); + const current = await collections.read(), + at = new Date("2026-01-01T00:00:00.000Z"); + await collections.update(current, (state) => { + let study = createDeck(state.study, "Lisbon deck", at); + study = saveEntry( + study, + { + id: crypto.randomUUID(), + deckId: study.decks[0]!.id, + revision: 1, + tags: [], + source: null, + type: "reversed", + front: "Lisbon front", + back: "Lisbon back", + }, + at, + ); + return { + ...state, + study, + clips: { + ...state.clips, + items: [ + { + id: crypto.randomUUID(), + title: "Lisbon saved", + originalUrl: "https://example.test/lisbon", + canonicalUrl: "https://example.test/lisbon", + text: "saved writing", + createdAt: at.toISOString(), + updatedAt: at.toISOString(), + archivedAt: at.toISOString(), + readAt: null, + readLater: true, + revision: 1, + }, + ], + }, + }; + }); + const search = createLocalSearchService(notes, collections); + handles.add(search); + const restores = vi.spyOn(SyncEngine, "restore"); + await search.query("lisbon"); + const first = ready(search.getSnapshot()); + expect(first.counts).toEqual({ notes: 1, clips: 1, cards: 2, all: 4 }); + expect( + first.groups.find((group) => group.kind === "clip")!.items[0]!.archived, + ).toBe(true); + expect( + first.groups + .find((group) => group.kind === "card")! + .items.map((hit) => + hit.locator.kind === "card" ? hit.locator.key : "wrong", + ), + ).toEqual(["reversed:forward", "reversed:reverse"]); + // Collections was written after Notes last read. The first explicit admission + // may refresh that owner once; unchanged subsequent queries must not restore. + expect(restores).toHaveBeenCalledTimes(1); + restores.mockClear(); + await search.query("strasse"); + expect(ready(search.getSnapshot()).counts.all).toBe(1); + expect(restores).not.toHaveBeenCalled(); + const events: string[] = []; + search.onInvalidate((reason) => { + events.push(reason); + expect(search.getSnapshot()).not.toHaveProperty("counts"); + }); + await notes.update(note.id, { title: "Porto budget" }, note.revision); + expect(search.getSnapshot()).toEqual({ status: "changed", raw: "strasse" }); + await search.query("lisbon"); + expect(ready(search.getSnapshot()).counts).toEqual({ + notes: 0, + clips: 1, + cards: 2, + all: 3, + }); + lock(false); + expect(search.getSnapshot()).toEqual({ status: "idle", raw: "" }); + expect(events).toContain("changed"); + expect(events).toContain("closed"); +}); diff --git a/web/tests/data/search-service.test.ts b/web/tests/data/search-service.test.ts new file mode 100644 index 0000000..654a08b --- /dev/null +++ b/web/tests/data/search-service.test.ts @@ -0,0 +1,364 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { NotesLibraryStore } from "../../src/library/contracts"; +import type { CollectionsStore } from "../../src/collections/contracts"; +import type { SearchHandle, SearchState } from "../../src/search/results"; +import { createLocalSearchService } from "../../src/search/service"; +import { lock } from "../../src/account/session"; +import { SearchSourceError } from "../../src/search/contracts"; + +// These mocks isolate service lifecycle/round/cursor contracts. Real encrypted +// source and corpus matching are qualified in their own integration suites. +const mock = vi.hoisted(() => ({ + acquire: vi.fn(), + fresh: vi.fn(), + release: vi.fn(), + dispose: vi.fn(), + listener: null as + null | ((reason: "changed" | "epoch" | "closed" | "unverified") => void), + pause: false, + pageError: false, + noteCount: 101, +})); +vi.mock("../../src/search/source", () => ({ + acquireSearchSource: mock.acquire, + assertSearchSourceFresh: mock.fresh, + releaseSearchSource: mock.release, + onSearchSourceInvalidated: ( + _lease: unknown, + listener: typeof mock.listener, + ) => { + mock.listener = listener; + return () => { + mock.listener = null; + }; + }, + searchSourceReader: () => ({ + versions: { + accountId: "account", + library: { epoch: "notes-epoch", revision: 1 }, + collections: { epoch: "different-collections-epoch", revision: 2 }, + }, + }), +})); +vi.mock("../../src/search/model", () => ({ + buildSearchIndex: function* () { + for (const cost of mock.pause + ? [32 * 1024 * 1024, 40 * 1024 * 1024] + : [1]) { + yield { type: "reserve", bytes: cost, visits: 1, scalars: 0 }; + yield { type: "settle", bytes: cost, visits: 1, scalars: 0 }; + } + return {}; + }, + searchIndexCounts: () => ({ + notes: mock.noteCount, + clips: 0, + cards: 0, + all: mock.noteCount, + }), + disposeSearchIndex: mock.dispose, + projectSearchPage: function* ( + _index: unknown, + kind: string, + offset: number, + limit: number, + ) { + if (mock.pageError && offset > 0) throw new Error("Projection failed"); + yield { type: "reserve", bytes: 1, visits: 1, scalars: 0 }; + yield { type: "settle", bytes: 1, visits: 1, scalars: 0 }; + return Object.freeze( + kind === "note" + ? Array.from( + { length: Math.min(limit, mock.noteCount - offset) }, + (_, i) => + Object.freeze({ + locator: { + kind: "note", + accountId: "account", + libraryEpoch: "notes-epoch", + noteId: `note-${offset + i}`, + }, + entityRevision: 1, + primary: { + text: `Note ${offset + i}`, + matches: [], + before: false, + after: false, + }, + secondary: [], + }), + ) + : [], + ); + }, +})); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +const service = () => + createLocalSearchService({} as NotesLibraryStore, {} as CollectionsStore); +const ready = (state: SearchState) => { + expect(state.status).toBe("ready"); + if (state.status !== "ready") + throw new Error("Expected a completed certificate"); + return state; +}; +beforeEach(() => { + mock.noteCount = 101; + vi.clearAllMocks(); + mock.listener = null; + mock.pause = false; + mock.pageError = false; + mock.acquire.mockResolvedValue({}); + mock.fresh.mockResolvedValue(undefined); +}); +describe("local search service lifetime and certificates", () => { + it("rejects invalid page intent without retaining an old certificate or acquiring source", async () => { + const search = service(); + await search.query("note"); + mock.acquire.mockClear(); + await expect( + search.query("note", undefined, { note: -50 }), + ).rejects.toMatchObject({ code: "invalid-handle" }); + expect(search.getSnapshot()).toEqual({ status: "idle", raw: "" }); + expect(mock.acquire).not.toHaveBeenCalled(); + search.close(); + }); + it("restores numeric page intent through a fresh query without reusing an old certificate/cursor", async () => { + const search = service(); + await search.query("note"); + await search.page(ready(search.getSnapshot()).groups[0]!.next!); + const old = ready(search.getSnapshot()), + oldCursor = old.groups[0]!.next!; + const held = deferred(); + mock.acquire.mockReturnValueOnce(held.promise); + const intent = { note: 50 }; + const pending = search.query("note", undefined, intent); + expect(search.getSnapshot()).toMatchObject({ status: "pending" }); + expect(search.getSnapshot()).not.toHaveProperty("counts"); + intent.note = 0; + held.resolve({}); + await pending; + const fresh = ready(search.getSnapshot()); + expect(fresh.handle).not.toBe(old.handle); + expect(fresh.groups[0]!.offset).toBe(50); + expect(fresh.groups[0]!.items[0]!.locator).toMatchObject({ + noteId: "note-50", + }); + await expect(search.page(oldCursor)).rejects.toMatchObject({ + code: "invalid-handle", + }); + // A shorter genuine new result set clamps the old intent to its last page. + mock.noteCount = 13; + await search.query("note", undefined, { note: 100 }); + expect(ready(search.getSnapshot()).groups[0]).toMatchObject({ + offset: 0, + next: null, + previous: null, + }); + expect(ready(search.getSnapshot()).groups[0]!.items).toHaveLength(13); + search.close(); + }); + it("clears authority rejection before admission and treats SourceLease abort as cancellation", async () => { + const search = service(); + const revoked = vi.fn(); + search.onInvalidate(revoked); + mock.acquire.mockRejectedValueOnce( + new SearchSourceError("search_source_revoked"), + ); + await search.query("private acquisition query"); + expect(search.getSnapshot()).toEqual({ status: "idle", raw: "" }); + expect(revoked).toHaveBeenCalledWith("unverified"); + revoked.mockClear(); + let reject!: (error: unknown) => void; + mock.acquire.mockReturnValueOnce( + new Promise((_resolve, fail) => { + reject = fail; + }), + ); + const controller = new AbortController(); + const pending = search.query("cancel query", controller.signal); + controller.abort(); + reject(new SearchSourceError("search_source_revoked")); + await pending; + expect(search.getSnapshot()).toEqual({ status: "idle", raw: "" }); + expect(revoked).not.toHaveBeenCalled(); + search.close(); + }); + it("clears pending-only query intent on account lock before acquisition resolves", async () => { + const hold = deferred(); + mock.acquire.mockReturnValueOnce(hold.promise); + const search = service(); + const pending = search.query("private pending query"); + lock(false); + expect(search.getSnapshot()).toEqual({ status: "idle", raw: "" }); + const lease = {}; + hold.resolve(lease); + await pending; + expect(mock.release).toHaveBeenCalledWith(lease); + expect(search.getSnapshot()).toEqual({ status: "idle", raw: "" }); + search.close(); + }); + it("treats a failed freshness read as unverified, clearing query and held routes", async () => { + mock.fresh.mockRejectedValueOnce(new Error("IDB read denied")); + const search = service(); + const revoked = vi.fn(); + search.onInvalidate(revoked); + await search.query("private query"); + expect(search.getSnapshot()).toEqual({ status: "idle", raw: "" }); + expect(revoked).toHaveBeenCalledWith("unverified"); + search.close(); + }); + it("keeps blank idle and invalid input visible without acquiring a source", async () => { + const search = service(); + await search.query(" \t "); + expect(search.getSnapshot()).toEqual({ status: "idle", raw: "" }); + await search.query("x".repeat(201)); + expect(search.getSnapshot()).toMatchObject({ + status: "error", + raw: "x".repeat(201), + code: "query-too-long", + }); + expect(mock.acquire).not.toHaveBeenCalled(); + search.close(); + }); + it("keeps complete counts while replacing bounded pages and rejects stale/forged cursors", async () => { + const search = service(); + await search.query("note"); + const first = ready(search.getSnapshot()); + expect(first.counts.all).toBe(101); + expect(first.groups[0]!.items).toHaveLength(50); + const next = first.groups[0]!.next!; + await search.page(next); + const second = ready(search.getSnapshot()); + expect(second.counts).toEqual(first.counts); + expect(second.groups[0]).toMatchObject({ offset: 50 }); + expect(second.groups[0]!.items).toHaveLength(50); + await expect(search.page(next)).rejects.toMatchObject({ + code: "invalid-handle", + }); + await search.page(second.groups[0]!.previous!); + expect(ready(search.getSnapshot()).groups[0]!.offset).toBe(0); + await expect(search.page(second.groups[0]!.next!)).rejects.toMatchObject({ + code: "invalid-handle", + }); + await expect(search.continue({} as SearchHandle)).rejects.toMatchObject({ + code: "invalid-handle", + }); + search.close(); + }); + it("runs only one automatic round and prevents overlapping Continue", async () => { + mock.pause = true; + const search = service(); + await search.query("note"); + const paused = search.getSnapshot(); + expect(paused.status).toBe("paused-limit"); + expect(paused).not.toHaveProperty("counts"); + if (paused.status !== "paused-limit") throw new Error("Expected pause"); + const hold = deferred(); + mock.fresh.mockReturnValueOnce(hold.promise); + const continuing = search.continue(paused.handle); + await expect(search.continue(paused.handle)).rejects.toMatchObject({ + code: "busy", + }); + hold.resolve(); + await continuing; + expect(search.getSnapshot().status).toBe("ready"); + search.close(); + }); + it("clears certificates before privacy listeners and distinguishes known change from unverified", async () => { + const search = service(); + await search.query("secret query"); + const order: string[] = []; + search.onInvalidate(() => { + order.push("privacy"); + expect(mock.dispose).toHaveBeenCalled(); + expect(search.getSnapshot()).not.toHaveProperty("counts"); + }); + search.subscribe(() => { + order.push("ordinary"); + }); + mock.listener!("changed"); + expect(search.getSnapshot()).toEqual({ + status: "changed", + raw: "secret query", + }); + expect(order).toEqual(["privacy", "ordinary"]); + await search.query("secret query"); + order.length = 0; + mock.listener!("unverified"); + expect(search.getSnapshot()).toEqual({ status: "idle", raw: "" }); + expect(order).toEqual(["privacy", "ordinary"]); + search.close(); + }); + it("releases a late acquisition after cancel without resurrecting its query", async () => { + const hold = deferred(); + mock.acquire.mockReturnValueOnce(hold.promise); + const search = service(); + const pending = search.query("old"); + search.cancel(); + const lease = {}; + hold.resolve(lease); + await pending; + expect(mock.release).toHaveBeenCalledWith(lease); + expect(search.getSnapshot()).toEqual({ status: "idle", raw: "" }); + search.close(); + }); + it("does not publish a completed query after revocation during freshness await", async () => { + const search = service(); + await search.query("note"); + const hold = deferred(); + mock.fresh.mockReturnValueOnce(hold.promise); + const cursor = ready(search.getSnapshot()).groups[0]!.next!; + const pending = search.page(cursor); + mock.listener!("epoch"); + hold.resolve(); + await pending; + expect(search.getSnapshot()).toEqual({ status: "idle", raw: "" }); + search.close(); + }); + it("retains the prior completed page on projection error, retry and cancellation", async () => { + const search = service(); + await search.query("note"); + const first = ready(search.getSnapshot()); + mock.pageError = true; + await search.page(first.groups[0]!.next!); + const failed = ready(search.getSnapshot()); + expect(failed.page?.status).toBe("error"); + expect(failed.groups).toBe(first.groups); + expect(failed.counts).toBe(first.counts); + if (failed.page?.status !== "error") throw new Error("Expected retry"); + mock.pageError = false; + await search.continue(failed.page.handle); + expect(ready(search.getSnapshot()).groups[0]!.offset).toBe(50); + const hold = deferred(); + mock.fresh.mockReturnValueOnce(hold.promise); + const pending = search.page(ready(search.getSnapshot()).groups[0]!.next!); + search.cancelPage(); + expect(ready(search.getSnapshot()).page).toBe(null); + const newer = deferred(); + mock.fresh.mockReturnValueOnce(newer.promise); + const newerPage = search + .page(ready(search.getSnapshot()).groups[0]!.next!) + .then( + () => null, + (error) => error, + ); + hold.resolve(); + await pending; + expect(ready(search.getSnapshot()).groups[0]!.offset).toBe(50); + expect(ready(search.getSnapshot()).page?.status).toBe("pending"); + // Late old finally cannot clear a newer operation's admission guard. + await expect(search.continue({} as SearchHandle)).rejects.toMatchObject({ + code: "busy", + }); + newer.resolve(); + expect(await newerPage).toBe(null); + expect(ready(search.getSnapshot()).groups[0]!.offset).toBe(100); + search.close(); + }); +}); diff --git a/web/tsconfig.data.json b/web/tsconfig.data.json index 4a817e9..5edaeef 100644 --- a/web/tsconfig.data.json +++ b/web/tsconfig.data.json @@ -20,6 +20,7 @@ }, "include": [ "src/domain/**/*.ts", + "src/collections/**/*.ts", "src/data/**/*.ts", "src/account/**/*.ts", "src/transfer/**/*.ts",