From 3d55cab00cfa75263ec7223f61cb7cffb76da188 Mon Sep 17 00:00:00 2001 From: Luan Nguyen Date: Wed, 11 Nov 2026 21:29:33 +1100 Subject: [PATCH] feat(links): resolve local note references and backlinks --- web/src/links/entry.ts | 177 ++++ web/src/links/navigation.ts | 22 + web/src/links/store.ts | 844 ++++++++++++++++ web/tests/data/note-link-entry.test.ts | 214 ++++ web/tests/data/note-links-store.test.ts | 1197 +++++++++++++++++++++++ web/tests/data/search-model.test.ts | 611 ++++++++++++ 6 files changed, 3065 insertions(+) create mode 100644 web/src/links/entry.ts create mode 100644 web/src/links/navigation.ts create mode 100644 web/src/links/store.ts create mode 100644 web/tests/data/note-link-entry.test.ts create mode 100644 web/tests/data/note-links-store.test.ts create mode 100644 web/tests/data/search-model.test.ts diff --git a/web/src/links/entry.ts b/web/src/links/entry.ts new file mode 100644 index 0000000..40d6ae2 --- /dev/null +++ b/web/src/links/entry.ts @@ -0,0 +1,177 @@ +import type { NoteTarget } from "./contracts"; +import type { NoteLinkCopyValue, NoteLinkNavigator } from "./navigation"; +import { + decodeNoteReference, + encodeNoteReference, + NOTE_REFERENCE_LIMITS, +} from "./reference"; + +const HASH_PREFIX = "#note/v1/"; +const URI_PREFIX = "orbyn-notes://note/v1/"; +export type IncomingNoteLinkState = + "opening" | "blocked" | "unavailable" | "invalid" | null; +export type ParsedNoteLinkHash = + | { kind: "target"; target: NoteTarget } + | { kind: "invalid" } + | { kind: "unrelated" }; + +/** Parse the raw fragment. URI normalization/percent decoding is deliberately absent. */ +export function parseNoteLinkHash(hash: string): ParsedNoteLinkHash { + if (hash !== "#note" && !hash.startsWith("#note/")) + return { kind: "unrelated" }; + if (!hash.startsWith(HASH_PREFIX) || hash.length > NOTE_REFERENCE_LIMITS.uri) + return { kind: "invalid" }; + try { + return { + kind: "target", + target: decodeNoteReference(URI_PREFIX + hash.slice(HASH_PREFIX.length)), + }; + } catch { + return { kind: "invalid" }; + } +} + +/** Formatting only. Callers must obtain the current authorized reference first. */ +export function formatWebNoteLink( + target: NoteTarget, + pageUrl: string, +): NoteLinkCopyValue { + if (pageUrl.length > 16384) throw new Error("The app address is too long."); + const base = new URL(pageUrl); + if (base.protocol !== "https:" && base.protocol !== "http:") + throw new Error("Web links require an HTTP app address."); + base.username = ""; + base.password = ""; + base.search = ""; + base.hash = ""; + const reference = encodeNoteReference(target); + base.hash = HASH_PREFIX.slice(1) + reference.slice(URI_PREFIX.length); + return { text: base.href, kind: "web-link" }; +} + +/** One bounded pending intent; the registered Workspace remains the only authority. */ +export class IncomingNoteLinkController { + private owner: object | null = null; + private navigator: NoteLinkNavigator | null = null; + private pending: NoteTarget | null = null; + private status: IncomingNoteLinkState = null; + private ticket = 0; + private attempted = false; + private lastHash: string | null = null; + private opening: AbortController | null = null; + private openingStarted = false; + private listeners = new Set<() => void>(); + constructor(private readonly onRevoke: () => void = () => {}) {} + state = (): IncomingNoteLinkState => this.status; + hasPending = (): boolean => this.pending !== null; + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + private publish(status: IncomingNoteLinkState) { + this.status = status; + for (const listener of this.listeners) listener(); + } + receive(hash: string) { + if (hash === this.lastHash) return; + this.lastHash = hash; + this.offer(parseNoteLinkHash(hash)); + } + /** Native transport deduplicates broker sequence; a new activation may have + * the same target. It still enters the identical guarded navigation path. */ + receiveTarget(target: NoteTarget) { + this.lastHash = null; + try { + const bounded = decodeNoteReference(encodeNoteReference(target)); + this.offer({ kind: "target", target: bounded }); + } catch { + this.offer({ kind: "invalid" }); + } + } + receiveInvalid() { + this.lastHash = null; + this.offer({ kind: "invalid" }); + } + private offer(parsed: ParsedNoteLinkHash) { + this.opening?.abort(); + this.ticket++; + this.attempted = false; + this.pending = parsed.kind === "target" ? parsed.target : null; + this.publish(parsed.kind === "invalid" ? "invalid" : null); + this.run(); + } + bind(owner: object | null) { + if (owner === this.owner) return; + if (this.owner) this.revoke(); + this.owner = owner; + this.navigator = null; + } + register(owner: object, navigator: NoteLinkNavigator | null) { + if (owner !== this.owner) return; + if (!navigator && this.navigator && this.status === "opening") { + this.opening?.abort(); + this.ticket++; + // A React mount cleanup may occur before the queued invocation starts. + // Re-arm only an invocation that never began. + if (!this.openingStarted) this.attempted = false; + this.publish(this.openingStarted ? "unavailable" : null); + } + this.navigator = navigator; + // A re-registration never retries a failed/blocked attempt. + this.run(); + } + revoke() { + this.opening?.abort(); + this.ticket++; + this.pending = null; + this.navigator = null; + this.lastHash = null; + this.attempted = false; + this.publish(null); + this.onRevoke(); + } + dismiss() { + this.opening?.abort(); + this.ticket++; + this.pending = null; + this.lastHash = null; + this.attempted = false; + this.publish(null); + } + retry() { + if (this.status !== "blocked" && this.status !== "unavailable") return; + this.attempted = false; + this.run(); + } + private run() { + if (!this.owner || !this.navigator || !this.pending || this.attempted) + return; + const ticket = ++this.ticket; + const target = this.pending; + const navigate = this.navigator; + const opening = new AbortController(); + this.opening = opening; + this.openingStarted = false; + this.attempted = true; + this.publish("opening"); + void Promise.resolve() + .then(() => { + // Revocation before this microtask must prevent even starting navigation. + if (ticket !== this.ticket) return null; + this.openingStarted = true; + return navigate(target, opening.signal); + }) + .then((result) => { + if (ticket !== this.ticket || !result) return; + if (result.status === "opened") { + this.pending = null; + this.publish(null); + } else this.publish(result.status); + }) + .catch(() => { + if (ticket === this.ticket) this.publish("unavailable"); + }); + } +} diff --git a/web/src/links/navigation.ts b/web/src/links/navigation.ts new file mode 100644 index 0000000..9ea9855 --- /dev/null +++ b/web/src/links/navigation.ts @@ -0,0 +1,22 @@ +import type { NoteTarget } from "./contracts"; + +/** Outcome of one guarded Workspace admission, never an authorization grant. */ +export type NoteLinkNavigationResult = + { status: "opened" } | { status: "blocked" } | { status: "unavailable" }; + +export type NoteLinkNavigator = ( + target: NoteTarget, + signal?: AbortSignal, +) => Promise; + +/** Register the current account's one navigation path; null revokes it. */ +export type NoteLinkNavigatorRegistration = ( + navigator: NoteLinkNavigator | null, +) => void; + +export type NoteLinkCopyValue = Readonly<{ + text: string; + kind: "web-link" | "native-link" | "local-reference"; +}>; +/** Formatting only; the UI obtains an authorized reference before calling it. */ +export type NoteLinkCopyFormatter = (target: NoteTarget) => NoteLinkCopyValue; diff --git a/web/src/links/store.ts b/web/src/links/store.ts new file mode 100644 index 0000000..cc7deb3 --- /dev/null +++ b/web/src/links/store.ts @@ -0,0 +1,844 @@ +import { renderNoteExport } from "../transfer/noteLinks"; +import type { NotesLibraryStore } from "../library/contracts"; +import type { Note, NoteDocument } from "../domain/contracts"; +import { lockVersion, onLock } from "../account/session"; +import { noteLinkOwner } from "../library/store"; +import { NoteLinkError, NoteLinkConflictError } from "./contracts"; +import type { + NoteLinkService, + NoteTarget, + LinkToken, + LinkProgress, + LinkNotReady, + LinkRepairIntent, + LinkSearchCursor, + NoteExportIntent, + NoteExportResult, + NoteLinksRow, + NoteLinkResolution, +} from "./contracts"; +import { linkToken, sameLinkToken, sameOwnerState } from "./model"; +import type { + LinkCertificate, + LinkOwnerSnapshot, + LinkRepairCommand, +} from "./model"; +import { + NOTE_LINK_LIMITS, + inspectLinkDocument, + scanLinkDocumentAsync, + sameTargets, + charge, + targetKey, +} from "./scanner"; +import { validateNoteTarget } from "./reference"; + +type Cache = { + units: number; + digest: string; + snippet: string; + targets: NoteLinksRow["targets"]; +}; +export function createNoteLinkService( + notes: NotesLibraryStore, +): NoteLinkService { + const owner = noteLinkOwner(notes), + openedLock = lockVersion(); + let closed = false, + epoch: string | undefined, + generation = 0, + job = 0; + let cache = new Map(), + verified: LinkCertificate | null = null; + let mismatch: LinkOwnerSnapshot | null = null; + let inFlightRead: { + generation: number; + promise: Promise; + } | null = null; + let progress: LinkProgress = { + verifiedNotes: 0, + totalNotes: 0, + verifiedHistory: 0, + totalHistory: 0, + utf8Units: 0, + nodeVisits: 0, + }; + let paused = false, + cacheUnits = 0; + type DocumentJob = { + key: string; + noteId: string; + revision: number; + document: NoteDocument; + history: boolean; + }; + let round: { + state: LinkOwnerSnapshot; + documents: DocumentJob[]; + cursor: number; + rows: NoteLinksRow[]; + documentDigests: [string, number, string][]; + snippets: [string, string][]; + rowUnits: number; + seen: Set; + } | null = null; + const listeners = new Set<() => void>(), + invalidations = new Set<(reason: "epoch" | "closed") => void>(); + let repairs = new WeakMap< + LinkRepairIntent, + { command: LinkRepairCommand; generation: number } + >(); + let cursors = new WeakMap< + LinkSearchCursor, + { + query: string; + sourceId: string | undefined; + token: LinkToken; + offset: number; + } + >(); + let exports = new WeakMap< + NoteExportIntent, + { state: LinkOwnerSnapshot; ids: string[]; generation: number } + >(); + let exportResults = new WeakMap< + NoteExportResult, + { state: LinkOwnerSnapshot; generation: number } + >(); + const stopLock = onLock(close); + let stopNotes: () => void; + try { + stopNotes = notes.subscribe(refresh); + } catch (error) { + stopLock(); + throw error; + } + function emit() { + for (const listener of listeners) { + try { + listener(); + } catch { + /* Observer isolation. */ + } + } + } + function invalidate(reason: "epoch" | "closed") { + generation++; + inFlightRead = null; + job++; + round = null; + verified = null; + mismatch = null; + cache.clear(); + cacheUnits = 0; + repairs = new WeakMap(); + cursors = new WeakMap(); + exports = new WeakMap(); + exportResults = new WeakMap(); + for (const listener of invalidations) { + try { + listener(reason); + } catch { + /* Revoke all consumers. */ + } + } + } + function close() { + if (closed) return; + closed = true; + invalidate("closed"); + stopLock(); + stopNotes?.(); + listeners.clear(); + invalidations.clear(); + } + function guard(captured = generation) { + if (closed || openedLock !== lockVersion() || captured !== generation) + throw new NoteLinkError("note_link_revoked"); + } + function observe(state: LinkOwnerSnapshot) { + if (epoch !== undefined && state.library.epoch !== epoch) + invalidate("epoch"); + epoch = state.library.epoch; + if (verified && !sameOwnerState(verified.snapshot, state)) verified = null; + if (mismatch && !sameOwnerState(mismatch, state)) mismatch = null; + } + async function read(captured = generation) { + guard(captured); + let pending = inFlightRead; + if (!pending || pending.generation !== captured) { + pending = { generation: captured, promise: owner.read() }; + inFlightRead = pending; + const clear = () => { + if (inFlightRead === pending) inFlightRead = null; + }; + // Share only the current synchronous burst. A later microtask/event + // must read afresh even if this older snapshot is still unresolved. + queueMicrotask(clear); + // Successful and failed authorization is never cached after settlement. + void pending.promise.then(clear, clear); + } + const state = await pending.promise; + guard(captured); + observe(state); + guard(captured); + return state; + } + function refresh() { + if (!closed) { + // A genuine owner-change notification must read after that change, + // rather than joining an older snapshot still held by a consumer. + inFlightRead = null; + void read() + .then(emit) + .catch(() => {}); + } + } + function nonready(state: LinkOwnerSnapshot): LinkNotReady { + if (state.index.status === "invalid") + return { status: "invalid", code: "index-invalid" }; + if (state.knownMismatch || (mismatch && sameOwnerState(mismatch, state))) + return { status: "invalid", code: "index-mismatch" }; + return { + status: paused ? "paused-limit" : "pending", + progress: { ...progress }, + }; + } + function target(state: LinkOwnerSnapshot, noteId: string): NoteTarget { + return { + kind: "note", + accountId: state.accountId, + libraryEpoch: state.library.epoch, + noteId, + }; + } + function resolveIn( + state: LinkOwnerSnapshot, + input: NoteTarget, + byId?: Map, + ): NoteLinkResolution { + if ( + input.accountId !== state.accountId || + input.libraryEpoch !== state.library.epoch + ) + return { status: "unavailable", reason: "scope" }; + const note = byId + ? byId.get(input.noteId) + : state.notes.find((note) => note.id === input.noteId); + if (!note) return { status: "unavailable", reason: "missing" }; + if (note.trashedAt !== null) + return { status: "unavailable", reason: "trashed" }; + return { + status: "available", + token: linkToken(state), + target: { ...input }, + noteRevision: note.revision, + title: note.title, + }; + } + async function hashDocument(json: string, captured: number) { + guard(captured); + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(json), + ); + guard(captured); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + } + async function verify(): Promise< + { status: "ready"; token: LinkToken } | LinkNotReady + > { + const captured = generation, + currentJob = ++job, + state = await read(captured); + if (verified && sameOwnerState(verified.snapshot, state)) + return state.index.status === "invalid" || state.knownMismatch || mismatch + ? nonready(state) + : { status: "ready", token: linkToken(state) }; + const work = { utf8Units: 0, nodeVisits: 0 }; + if (!round || !sameOwnerState(round.state, state)) { + round = { + state, + documents: [ + ...state.notes.map((note) => ({ + key: "note:" + note.id, + noteId: note.id, + revision: note.revision, + document: note.document, + history: false, + })), + ...(state.rawVersion === 3 + ? [] + : state.library.versions.map((version) => ({ + key: "history:" + version.id, + noteId: version.noteId, + revision: version.noteRevision, + document: version.document, + history: true, + }))), + ], + cursor: 0, + rows: [], + documentDigests: [], + snippets: [], + rowUnits: 0, + seen: new Set(), + }; + progress = { + verifiedNotes: 0, + totalNotes: state.notes.length, + verifiedHistory: 0, + totalHistory: + state.rawVersion === 3 ? 0 : state.library.versions.length, + utf8Units: 0, + nodeVisits: 0, + }; + } + const active = round, + rows = active.rows, + seen = active.seen; + paused = false; + let lastYieldNodes = 0, + lastYieldAt = performance.now(); + try { + for (; active.cursor < active.documents.length;) { + const item = active.documents[active.cursor]!; + guard(captured); + if (job !== currentJob) throw new NoteLinkError("note_link_revoked"); + // Serialization is charged even on a cache hit: revision-only cache + // reuse would allow a forged same-revision document to certify edges. + const json = JSON.stringify(item.document); + charge(work, json.length * 6, 0); + seen.add(item.key); + const digest = await hashDocument(json, captured); + guard(captured); + if (job !== currentJob) throw new NoteLinkError("note_link_revoked"); + let entry = cache.get(item.key); + if (!entry || entry.digest !== digest) { + const inspected = inspectLinkDocument( + item.document, + state.rawVersion === 3 ? "notes" : "plain", + work, + ); + const snippet = { text: "" }; + const targets = await scanLinkDocumentAsync( + inspected, + work, + () => { + guard(captured); + if (job !== currentJob) + throw new NoteLinkError("note_link_revoked"); + }, + snippet, + ); + const targetJSON = JSON.stringify(targets); + charge(work, targetJSON.length * 3, 0); + entry = { + digest, + targets, + snippet: snippet.text, + units: + (item.key.length + targetJSON.length + snippet.text.length) * 3 + + 128, + }; + const old = cache.get(item.key); + if (old) { + cacheUnits -= old.units; + cache.delete(item.key); + } + while ( + cache.size && + cacheUnits + entry.units > NOTE_LINK_LIMITS.workBytes + ) { + const oldest = cache.keys().next().value!; + cacheUnits -= cache.get(oldest)!.units; + cache.delete(oldest); + } + if (entry.units <= NOTE_LINK_LIMITS.workBytes) { + cache.set(item.key, entry); + cacheUnits += entry.units; + } + } + if (item.history) progress.verifiedHistory++; + else { + if (entry.targets.length) { + const row = { + noteId: item.noteId, + noteRevision: item.revision, + targets: structuredClone(entry.targets), + }, + units = JSON.stringify(row).length * 3; + if (active.rowUnits + units > NOTE_LINK_LIMITS.workBytes) + throw new NoteLinkError("note_link_limit"); + charge(work, units, 0); + active.rowUnits += units; + rows.push(row); + } + progress.verifiedNotes++; + active.documentDigests.push([ + item.noteId, + item.revision, + entry.digest, + ]); + active.snippets.push([item.noteId, entry.snippet]); + } + active.cursor++; + progress.utf8Units = work.utf8Units; + progress.nodeVisits = work.nodeVisits; + if ( + work.nodeVisits - lastYieldNodes >= NOTE_LINK_LIMITS.yieldNodes || + performance.now() - lastYieldAt >= 8 + ) { + await new Promise((resolve) => setTimeout(resolve, 0)); + lastYieldNodes = work.nodeVisits; + lastYieldAt = performance.now(); + } + guard(captured); + if (job !== currentJob) throw new NoteLinkError("note_link_revoked"); + } + for (const key of cache.keys()) + if (!seen.has(key)) { + cacheUnits -= cache.get(key)!.units; + cache.delete(key); + } + const latest = await read(captured); + if (job !== currentJob) throw new NoteLinkError("note_link_revoked"); + if (!sameOwnerState(state, latest)) + throw new NoteLinkConflictError(linkToken(latest)); + const total = rows.reduce( + (n, row) => + n + row.targets.reduce((n, entry) => n + entry.occurrences, 0), + 0, + ); + if (total > NOTE_LINK_LIMITS.totalReferences) + throw new NoteLinkError("note_link_limit"); + const certificate = { + snapshot: state, + rows: structuredClone(rows), + documentDigests: active.documentDigests, + snippets: active.snippets, + }; + round = null; + if (state.rawVersion !== 3) { + await owner.authorizeLegacy(certificate, () => { + guard(captured); + if (job !== currentJob) throw new NoteLinkError("note_link_revoked"); + }); + guard(captured); + if (job !== currentJob) throw new NoteLinkError("note_link_revoked"); + verified = certificate; + } else { + // Keep complete document proof for repair even if the stored cache is + // malformed; it is never published as a ready graph before repair. + verified = certificate; + if (state.index.status === "invalid") + return { status: "invalid", code: "index-invalid" }; + const indexed = new Map( + state.index.rows.map((row) => [row.noteId, row]), + ); + if ( + indexed.size !== rows.length || + rows.some( + (row) => + !sameTargets(row.targets, indexed.get(row.noteId)?.targets ?? []), + ) + ) { + mismatch = state; + owner.mismatch(state); + return { status: "invalid", code: "index-mismatch" }; + } + } + emit(); + return { status: "ready", token: linkToken(state) }; + } catch (error) { + if ( + error instanceof NoteLinkError && + error.code === "note_link_verification_limit" + ) { + paused = true; + progress.utf8Units = work.utf8Units; + progress.nodeVisits = work.nodeVisits; + emit(); + return nonready(state); + } + throw error; + } + } + async function graph() { + const state = await read(); + if ( + verified && + sameOwnerState(verified.snapshot, state) && + state.index.status !== "invalid" && + !state.knownMismatch && + !mismatch + ) + return { state, certificate: verified }; + return { state, certificate: null }; + } + const service: NoteLinkService = { + async admission() { + const state = await read(); + if (state.rawVersion !== 3) + return { + mode: "legacy", + rawVersion: state.rawVersion, + token: linkToken(state), + premise: + verified && sameOwnerState(verified.snapshot, state) + ? "verified" + : paused + ? "paused-limit" + : "pending", + }; + const status = nonready(state); + if (status.status === "invalid") + return { + mode: "index-recovery", + token: linkToken(state), + reason: + status.code === "index-mismatch" + ? "index-mismatch" + : "index-invalid", + }; + return { + mode: "current", + token: linkToken(state), + graph: + verified && sameOwnerState(verified.snapshot, state) + ? "ready" + : paused + ? "paused-limit" + : "pending", + }; + }, + async currentToken() { + return linkToken(await read()); + }, + async search(query, sourceId, cursor) { + if ( + typeof query !== "string" || + query.length > 200 || + (sourceId !== undefined && typeof sourceId !== "string") + ) + throw new NoteLinkError("note_link_invalid"); + const captured = generation, + state = await read(captured), + token = linkToken(state), + prior = cursor ? cursors.get(cursor) : null; + if ( + cursor && + (!prior || + prior.query !== query || + prior.sourceId !== sourceId || + !sameLinkToken(prior.token, token)) + ) + throw new NoteLinkConflictError(token); + if ( + state.notes.reduce((n, note) => n + note.title.length, 0) > + NOTE_LINK_LIMITS.searchUnits + ) + throw new NoteLinkError("note_link_limit"); + const matches = state.notes.filter( + (note) => + note.trashedAt === null && + note.title.toLocaleLowerCase().includes(query.toLocaleLowerCase()), + ), + offset = prior?.offset ?? 0; + const items = matches.slice(offset, offset + 50).map((note) => ({ + target: target(state, note.id), + title: note.title, + noteRevision: note.revision, + updatedAt: note.updatedAt, + thisNote: note.id === sourceId, + })); + let next: LinkSearchCursor | null = null; + if (offset + items.length < matches.length) { + next = Object.freeze({}) as LinkSearchCursor; + cursors.set(next, { + query, + sourceId, + token, + offset: offset + items.length, + }); + } + return { token, items, next }; + }, + async reference(noteId) { + if (typeof noteId !== "string") + throw new NoteLinkError("note_link_invalid"); + const state = await read(); + if ( + state.rawVersion !== 3 && + !(verified && sameOwnerState(verified.snapshot, state)) + ) + throw new NoteLinkError("note_link_migration_pending"); + if (state.index.status === "invalid" || state.knownMismatch || mismatch) + throw new NoteLinkError("note_link_index_invalid"); + const ref = target(state, noteId); + if (resolveIn(state, ref).status !== "available") + throw new NoteLinkError("note_link_unavailable"); + return { token: linkToken(state), target: ref }; + }, + async resolve(input) { + const ref = validateNoteTarget(input); + if (closed || openedLock !== lockVersion()) + return { status: "unavailable", reason: "locked" }; + return resolveIn(await read(), ref); + }, + async outgoing(noteId) { + if (typeof noteId !== "string") + throw new NoteLinkError("note_link_invalid"); + const captured = generation, + state = await read(captured); + if (state.rawVersion !== 3) { + const result = await graph(); + if (!result.certificate) return nonready(state); + return { status: "ready", token: linkToken(state), items: [] }; + } + if (state.index.status === "invalid" || state.knownMismatch || mismatch) + return nonready(state); + const note = state.notes.find((note) => note.id === noteId); + if (!note || note.trashedAt !== null) + return { status: "ready", token: linkToken(state), items: [] }; + const work = { utf8Units: 0, nodeVisits: 0 }; + const doc = inspectLinkDocument(note.document, "notes", work), + targets = await scanLinkDocumentAsync(doc, work, () => guard(captured)); + const latest = await read(captured); + if (!sameOwnerState(state, latest)) + throw new NoteLinkConflictError(linkToken(latest)); + const indexed = state.index.rows.find((row) => row.noteId === noteId); + if (!sameTargets(targets, indexed?.targets ?? [])) { + mismatch = state; + owner.mismatch(state); + return { status: "invalid", code: "index-mismatch" }; + } + const byId = new Map(state.notes.map((note) => [note.id, note])); + return { + status: "ready", + token: linkToken(state), + items: targets.map((entry) => ({ + ...entry, + resolution: resolveIn(state, entry.target, byId), + })), + }; + }, + async backlinks(noteId) { + if (typeof noteId !== "string") + throw new NoteLinkError("note_link_invalid"); + const { state, certificate } = await graph(); + if (!certificate) return nonready(state); + const notesById = new Map(state.notes.map((note) => [note.id, note])), + snippets = new Map(certificate.snippets); + return { + status: "ready", + token: linkToken(state), + items: certificate.rows.flatMap((row) => { + const source = notesById.get(row.noteId); + if (!source || source.trashedAt !== null) return []; + const entry = row.targets.find( + (entry) => + entry.target.accountId === state.accountId && + entry.target.libraryEpoch === state.library.epoch && + entry.target.noteId === noteId, + ); + return entry + ? [ + { + sourceId: source.id, + sourceRevision: source.revision, + title: source.title, + occurrences: entry.occurrences, + snippet: snippets.get(source.id) ?? "", + }, + ] + : []; + }), + }; + }, + async linkedNoteIds() { + const { state, certificate } = await graph(); + if (!certificate) return nonready(state); + const active = new Set( + state.notes + .filter((note) => note.trashedAt === null) + .map((note) => note.id), + ), + linked = new Set(); + for (const row of certificate.rows) { + if (!active.has(row.noteId)) continue; + linked.add(row.noteId); + for (const entry of row.targets) + if ( + entry.target.accountId === state.accountId && + entry.target.libraryEpoch === state.library.epoch && + active.has(entry.target.noteId) + ) + linked.add(entry.target.noteId); + } + return { status: "ready", token: linkToken(state), items: [...linked] }; + }, + verifyGraph: verify, + cancelVerification() { + job++; + paused = true; + emit(); + }, + async prepareRepair(expected) { + if ( + !expected || + typeof expected !== "object" || + Object.keys(expected).length !== 2 || + !Object.hasOwn(expected, "epoch") || + !Object.hasOwn(expected, "revision") || + typeof expected.epoch !== "string" || + !Number.isSafeInteger(expected.revision) || + expected.revision < 0 + ) + throw new NoteLinkError("note_link_invalid"); + const stable = { epoch: expected.epoch, revision: expected.revision }, + captured = generation, + state = await read(captured); + if (!sameLinkToken(stable, linkToken(state))) + throw new NoteLinkConflictError(linkToken(state)); + if (state.rawVersion !== 3) + throw new NoteLinkError("note_link_migration_pending"); + if (!verified || !sameOwnerState(verified.snapshot, state)) + throw new NoteLinkError("note_link_verification_limit"); + const certificate = structuredClone(verified), + operationId = crypto.randomUUID(); + const rowJSON = JSON.stringify(certificate.rows), + digestsJSON = JSON.stringify(certificate.documentDigests); + if ( + (rowJSON.length + state.rawIndex.length + digestsJSON.length) * 3 > + NOTE_LINK_LIMITS.workBytes + ) + throw new NoteLinkError("note_link_verification_limit"); + const indexDigest = await hashDocument(rowJSON, captured), + rawIndexDigest = await hashDocument(state.rawIndex, captured), + documentsDigest = await hashDocument(digestsJSON, captured); + const latest = await read(captured); + if (!sameOwnerState(state, latest)) + throw new NoteLinkConflictError(linkToken(latest)); + const args = JSON.stringify({ + revision: state.library.revision, + rawIndexDigest, + documentsDigest, + indexDigest, + }); + const intent = Object.freeze({ operationId }) as LinkRepairIntent; + repairs.set(intent, { + command: { operationId, arguments: args, indexDigest, certificate }, + generation: captured, + }); + return intent; + }, + async commitRepair(intent) { + guard(); + const prepared = repairs.get(intent); + if (!prepared) throw new NoteLinkError("note_link_revoked"); + const fresh = () => guard(prepared.generation); + const result = await owner.repair(prepared.command, fresh); + fresh(); + verified = null; + mismatch = null; + emit(); + return { + operationId: prepared.command.operationId, + indexDigest: prepared.command.indexDigest, + ...result, + }; + }, + async prepareExport(noteIds) { + guard(); + if ( + !Array.isArray(noteIds) || + noteIds.length > 10000 || + noteIds.some((id) => typeof id !== "string") + ) + throw new NoteLinkError("note_link_invalid"); + const captured = generation, + ids = [...new Set(noteIds)], + state = await read(captured), + intent = Object.freeze({}) as NoteExportIntent; + exports.set(intent, { state, ids, generation: captured }); + return intent; + }, + async exportFiles(intent) { + guard(); + const held = exports.get(intent); + if (!held) throw new NoteLinkError("note_link_revoked"); + const captured = held.generation, + state = await read(captured); + if (!sameOwnerState(held.state, state)) + throw new NoteLinkConflictError(linkToken(state)); + const byId = new Map(state.notes.map((note) => [note.id, note])), + selected: Note[] = [], + references = new Map< + string, + { target: NoteTarget; resolution: NoteLinkResolution } + >(), + work = { utf8Units: 0, nodeVisits: 0 }; + for (const id of held.ids) { + const note = byId.get(id); + if (!note) throw new NoteLinkError("note_link_unavailable"); + const doc = inspectLinkDocument( + note.document, + state.rawVersion === 3 ? "notes" : "plain", + work, + ); + const targets = await scanLinkDocumentAsync(doc, work, () => + guard(captured), + ); + selected.push(note); + for (const row of targets) + references.set(targetKey(row.target), { + target: row.target, + resolution: resolveIn(state, row.target, byId), + }); + if (references.size > NOTE_LINK_LIMITS.totalReferences) + throw new NoteLinkError("note_link_limit"); + } + const projection = { + accountId: state.accountId, + token: linkToken(state), + references: [...references.values()], + consumedWork: { ...work }, + }; + const rendered = renderNoteExport(selected, projection); + guard(captured); + const latest = await read(captured); + if (!sameOwnerState(state, latest)) + throw new NoteLinkConflictError(linkToken(latest)); + const result = Object.freeze({ + token: Object.freeze(linkToken(state)), + files: Object.freeze(rendered.files.map((file) => Object.freeze(file))), + warnings: Object.freeze(rendered.warnings.slice()), + }); + exportResults.set(result, { state, generation: captured }); + return result; + }, + async assertFreshExport(result) { + guard(); + const held = exportResults.get(result); + if (!held) throw new NoteLinkError("note_link_revoked"); + const state = await read(held.generation); + if (!sameOwnerState(held.state, state)) + throw new NoteLinkConflictError(linkToken(state)); + }, + subscribe(listener) { + guard(); + listeners.add(listener); + return () => listeners.delete(listener); + }, + onInvalidate(listener) { + guard(); + invalidations.add(listener); + return () => invalidations.delete(listener); + }, + close, + }; + return service; +} diff --git a/web/tests/data/note-link-entry.test.ts b/web/tests/data/note-link-entry.test.ts new file mode 100644 index 0000000..f65854f --- /dev/null +++ b/web/tests/data/note-link-entry.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from "vitest"; +import { encodeNoteReference } from "../../src/links/reference"; +import { + IncomingNoteLinkController, + formatWebNoteLink, + parseNoteLinkHash, +} from "../../src/links/entry"; +import type { NoteTarget } from "../../src/links/contracts"; + +const target: NoteTarget = { + kind: "note", + accountId: "11111111-1111-4111-8111-111111111111", + libraryEpoch: "22222222-2222-4222-8222-222222222222", + noteId: "opaque /?#🪴", +}; +const hash = () => "#note/v1/" + encodeNoteReference(target).split("/v1/")[1]; +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("web note-link ingress", () => { + it("exposes a pending-only intent before a workspace is available", () => { + const controller = new IncomingNoteLinkController(); + controller.receive(hash()); + expect(controller.state()).toBe(null); + expect(controller.hasPending()).toBe(true); + controller.revoke(); + expect(controller.hasPending()).toBe(false); + }); + it("accepts a bounded native target as a new deliberate intent while retaining the shared navigator", async () => { + const controller = new IncomingNoteLinkController(); + const owner = {}; + const navigate = vi.fn().mockResolvedValue({ status: "blocked" }); + controller.bind(owner); + controller.register(owner, navigate); + controller.receiveTarget(target); + await tick(); + expect(controller.state()).toBe("blocked"); + controller.receiveTarget(target); + await tick(); + expect(navigate).toHaveBeenCalledTimes(2); + controller.receiveInvalid(); + expect(controller.state()).toBe("invalid"); + }); + it("never installs an untransportable or malformed native target", async () => { + const controller = new IncomingNoteLinkController(); + const owner = {}; + const navigate = vi.fn(); + controller.bind(owner); + controller.register(owner, navigate); + controller.receiveTarget({ ...target, noteId: "x".repeat(5000) }); + await tick(); + expect(navigate).not.toHaveBeenCalled(); + expect(controller.state()).toBe("invalid"); + }); + it("re-arms only an invocation canceled before it began, including mount cleanup", async () => { + const controller = new IncomingNoteLinkController(); + const owner = {}; + const navigate = vi.fn().mockResolvedValue({ status: "opened" }); + controller.bind(owner); + controller.receive(hash()); + controller.register(owner, navigate); + controller.register(owner, null); + controller.register(owner, navigate); + await tick(); + expect(navigate).toHaveBeenCalledTimes(1); + expect(controller.state()).toBe(null); + }); + it("aborts a started invocation on revocation and does not replay it on re-registration", async () => { + const controller = new IncomingNoteLinkController(); + const owner = {}; + let signal: AbortSignal | undefined; + const navigate = vi.fn((_target, incoming: AbortSignal | undefined) => { + signal = incoming; + return new Promise<{ status: "opened" }>(() => {}); + }); + controller.bind(owner); + controller.receive(hash()); + controller.register(owner, navigate); + await tick(); + controller.register(owner, null); + expect(signal?.aborted).toBe(true); + expect(controller.state()).toBe("unavailable"); + controller.register(owner, navigate); + await tick(); + expect(navigate).toHaveBeenCalledTimes(1); + }); + it("round trips the exact target without copying authentication or invite parameters", () => { + const copy = formatWebNoteLink( + target, + "https://user:secret@example.test/notes/?invite=private&token=secret#old", + ); + expect(copy.kind).toBe("web-link"); + expect(copy.text).toBe("https://example.test/notes/" + hash()); + expect(parseNoteLinkHash(new URL(copy.text).hash)).toEqual({ + kind: "target", + target, + }); + expect(() => formatWebNoteLink(target, "file:///notes.html")).toThrow(); + expect(() => formatWebNoteLink(target, "javascript:alert(1)")).toThrow(); + }); + it("does not normalize malformed, encoded, oversized or unknown-version hashes", () => { + expect(parseNoteLinkHash("#tags")).toEqual({ kind: "unrelated" }); + for (const value of [ + "#note", + "#note/v2/a", + hash() + "?x=1", + hash() + "/..", + hash().replace("v1", "%761"), + "#note/v1/" + "a".repeat(9000), + ]) { + expect(parseNoteLinkHash(value)).toEqual({ kind: "invalid" }); + } + }); + it("holds one initial intent until an actual workspace navigator is registered", async () => { + const controller = new IncomingNoteLinkController(); + const navigate = vi.fn().mockResolvedValue({ status: "opened" }); + controller.receive(hash()); + expect(controller.state()).toBe(null); + controller.bind(null); + const owner = {}; + controller.bind(owner); + controller.register(owner, navigate); + await tick(); + expect(navigate).toHaveBeenCalledExactlyOnceWith( + target, + expect.any(AbortSignal), + ); + expect(controller.state()).toBe(null); + controller.register(owner, navigate); + await tick(); + expect(navigate).toHaveBeenCalledTimes(1); + }); + it("does not retry a blocked navigation as editors or navigators rerender", async () => { + const controller = new IncomingNoteLinkController(); + const owner = {}; + const navigate = vi + .fn() + .mockResolvedValueOnce({ status: "blocked" }) + .mockResolvedValue({ status: "opened" }); + controller.bind(owner); + controller.register(owner, navigate); + controller.receive(hash()); + await tick(); + expect(controller.state()).toBe("blocked"); + controller.register(owner, navigate); + await tick(); + expect(navigate).toHaveBeenCalledTimes(1); + controller.retry(); + await tick(); + expect(navigate).toHaveBeenCalledTimes(2); + expect(controller.state()).toBe(null); + }); + it("revokes pending results and old intents across A to B to A and lock", async () => { + const controller = new IncomingNoteLinkController(); + const a = {}, + b = {}; + let finish!: (value: { status: "blocked" }) => void; + const navigate = vi.fn( + () => + new Promise<{ status: "blocked" }>((resolve) => { + finish = resolve; + }), + ); + controller.bind(a); + controller.register(a, navigate); + controller.receive(hash()); + await tick(); + controller.bind(b); + controller.bind(a); + controller.register(a, navigate); + finish({ status: "blocked" }); + await tick(); + expect(controller.state()).toBe(null); + expect(navigate).toHaveBeenCalledTimes(1); + controller.receive(hash()); + await tick(); + controller.revoke(); + finish({ status: "blocked" }); + await tick(); + controller.register(a, navigate); + controller.retry(); + expect(controller.state()).toBe(null); + expect(navigate).toHaveBeenCalledTimes(2); + }); + it("ignores stale registration cleanup and only the latest incoming request may publish", async () => { + const controller = new IncomingNoteLinkController(); + const old = {}, + owner = {}; + let finish!: (value: { status: "unavailable" }) => void; + controller.bind(old); + controller.bind(owner); + const navigate = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue({ status: "opened" }); + controller.register(owner, navigate); + controller.register(old, null); + controller.receive(hash()); + await tick(); + controller.receive("#note/v2/invalid"); + finish({ status: "unavailable" }); + await tick(); + expect(controller.state()).toBe("invalid"); + controller.dismiss(); + expect(controller.state()).toBe(null); + controller.receive(hash()); + await tick(); + expect(navigate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/web/tests/data/note-links-store.test.ts b/web/tests/data/note-links-store.test.ts new file mode 100644 index 0000000..2ee14f1 --- /dev/null +++ b/web/tests/data/note-links-store.test.ts @@ -0,0 +1,1197 @@ +import { protectOperations } from "../../src/desktop/closeGuard"; +// @vitest-environment node +import { beforeAll, beforeEach, afterEach, it, expect, vi } from "vitest"; +import { readFile } from "node:fs/promises"; +import { IDBFactory, IDBObjectStore } from "fake-indexeddb"; +import { + initSync, + DeviceKeys, + SyncEngine, +} from "../../src/generated/wasm/orbyn_wasm.js"; +import { establish, lock } from "../../src/account/session"; +import { + newWrappingKey, + wrappingSecret, + mutateRecord, + readRecord, +} from "../../src/data/database"; +import type { DeviceRecord } from "../../src/data/database"; +import { openNotesStore } from "../../src/data"; +import type { NoteDocument } from "../../src/domain/contracts"; +import { createNoteLinkService } from "../../src/links/store"; +import { noteLinkOwner } from "../../src/library/store"; +const identity = { + accountId: "11111111-1111-4111-8111-111111111111", + deviceId: "22222222-2222-4222-8222-222222222222", + email: "links@example.test", + deviceState: "trusted" as const, +}; +const handles = new Set<{ close(): void }>(); +const document: NoteDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Before alpha beta After alpha beta" }], + }, + ], +}; +beforeAll(async () => { + initSync({ + module: await readFile( + new URL("../../src/generated/wasm/orbyn_wasm_bg.wasm", import.meta.url), + ), + }); +}); +beforeEach(async () => { + lock(); + vi.stubGlobal("indexedDB", new IDBFactory()); + const wrapKey = await newWrappingKey(), + secret = await wrappingSecret(wrapKey), + keys = DeviceKeys.create(); + try { + await mutateRecord("devices", identity.email, () => ({ + ...identity, + wrapKey, + sealedKeys: keys.export(secret), + generation: 0, + verifiedTrust: true, + })); + } finally { + keys.free(); + secret.fill(0); + } + establish(identity); +}); +afterEach(() => { + for (const handle of handles) handle.close(); + handles.clear(); + vi.restoreAllMocks(); + vi.useRealTimers(); + lock(); +}); + +async function setup() { + const notes = await openNotesStore(identity); + handles.add(notes); + const a = await notes.create({ title: "Alpha", document, tags: [] }); + const b = await notes.create({ title: "Beta", document, tags: [] }); + const links = createNoteLinkService(notes); + handles.add(links); + return { notes, links, a, b }; +} +function linked(target: unknown): NoteDocument { + return { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Link " }, + { type: "noteLink", attrs: { version: 1, target } }, + ], + }, + ], + }; +} +async function mutateLibrary(change: (state: any, engine: SyncEngine) => void) { + const record = (await readRecord("devices", identity.email))!, + secret = await wrappingSecret(record.wrapKey), + keys = DeviceKeys.import(record.sealedKeys, secret), + engine = SyncEngine.restore(keys, record.snapshot!, secret, Date.now()); + try { + const raw = JSON.parse(engine.record("notes-library-v1", "workspace")!), + value = raw.fields.library.value; + change(value, engine); + engine.stage( + "notes-library-v1", + "notes_library_metadata", + "workspace", + JSON.stringify({ + library: { value, stamp: JSON.parse(engine.tick(Date.now())) }, + }), + ); + await mutateRecord("devices", identity.email, (current) => ({ + ...current!, + snapshot: engine.snapshot(secret), + generation: current!.generation + 1, + })); + } finally { + engine.free(); + keys.free(); + secret.fill(0); + } +} +it("keeps strict2 editing and refuses new atoms until complete legacy premise", async () => { + const { notes, links, a, b } = await setup(); + const state = await noteLinkOwner(notes).read(); + expect(state.library.version).toBe(2); + expect((await links.linkedNoteIds()).status).toBe("pending"); + await expect(links.reference(b.id)).rejects.toMatchObject({ + code: "note_link_migration_pending", + }); + const target = { + kind: "note", + accountId: identity.accountId, + libraryEpoch: state.library.epoch, + noteId: b.id, + }; + await expect( + notes.update(a.id, { document: linked(target) }, a.revision), + ).rejects.toThrow(); + const edited = await notes.update( + a.id, + { title: "Still editable" }, + a.revision, + ); + expect((await noteLinkOwner(notes).read()).library.version).toBe(2); + expect((await links.verifyGraph()).status).toBe("ready"); + const reference = await links.reference(b.id); + await notes.update( + a.id, + { document: linked(reference.target) }, + edited.revision, + ); + const current = await noteLinkOwner(notes).read(); + expect(current.library.version).toBe(3); + expect(current.index.status).toBe("valid"); + expect((await links.backlinks(b.id)).status).toBe("pending"); + await links.verifyGraph(); + expect(await links.backlinks(b.id)).toMatchObject({ + status: "ready", + items: [{ sourceId: a.id, occurrences: 1, title: "Still editable" }], + }); +}); +it("isolates malformed index, blocks writers and repairs once with durable replay", async () => { + const { notes, links, a } = await setup(); + await links.verifyGraph(); + await notes.update(a.id, { title: "Admit3" }, a.revision); + await mutateLibrary((state) => { + state.noteLinks = { bad: true }; + }); + const other = await openNotesStore(identity); + handles.add(other); + const service = createNoteLinkService(other); + handles.add(service); + expect(await service.admission()).toMatchObject({ + mode: "index-recovery", + reason: "index-invalid", + }); + const before = (await readRecord("devices", identity.email))!; + await expect( + other.update(a.id, { title: "blocked" }, a.revision + 1), + ).rejects.toMatchObject({ code: "note_link_index_invalid" }); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before.snapshot); + expect((await service.verifyGraph()).status).toBe("invalid"); + const intent = await service.prepareRepair(await service.currentToken()); + const result = await service.commitRepair(intent); + expect(result.replayed).toBe(false); + expect((await service.commitRepair(intent)).replayed).toBe(true); + expect( + (await readRecord("devices", identity.email))!.generation, + ).toBe(before.generation + 1); + expect((await service.verifyGraph()).status).toBe("ready"); +}); +it("detects plausible false rows and quarantines the genuine owner before mutation", async () => { + const { notes, links, a, b } = await setup(); + await links.verifyGraph(); + await notes.update( + a.id, + { document: linked((await links.reference(b.id)).target) }, + 1, + ); + await mutateLibrary((state) => { + state.noteLinks = []; + }); + expect(await links.verifyGraph()).toMatchObject({ + status: "invalid", + code: "index-mismatch", + }); + await expect( + notes.library.setSort( + "title", + await links.currentToken(), + crypto.randomUUID(), + ), + ).rejects.toMatchObject({ code: "note_link_index_invalid" }); + const repair = await links.prepareRepair(await links.currentToken()); + await links.commitRepair(repair); + await links.verifyGraph(); + expect(await links.backlinks(b.id)).toMatchObject({ + status: "ready", + items: [{ sourceId: a.id }], + }); +}); +it("revokes prepared migration/repair work across erase and same-account reauth", async () => { + const { notes, links, a } = await setup(); + await links.verifyGraph(); + await notes.erase(); + await expect(links.reference(a.id)).rejects.toMatchObject({ + code: "note_link_revoked", + }); + const current = createNoteLinkService(notes); + handles.add(current); + await current.verifyGraph(); + establish(identity); + await expect(current.currentToken()).rejects.toMatchObject({ + code: "note_link_revoked", + }); +}); +it("rolls back graph/document/history together on quota error", async () => { + const { notes, links, a, b } = await setup(); + await links.verifyGraph(); + const ref = await links.reference(b.id), + before = (await readRecord("devices", identity.email))!; + vi.spyOn(IDBObjectStore.prototype, "put").mockImplementation(() => { + throw new DOMException("full", "QuotaExceededError"); + }); + await expect( + notes.update(a.id, { document: linked(ref.target) }, a.revision), + ).rejects.toThrow(); + vi.restoreAllMocks(); + const after = (await readRecord("devices", identity.email))!; + expect(after.generation).toBe(before.generation); + expect(after.snapshot).toEqual(before.snapshot); +}); + +it("resumes bounded legacy verification without fake ready or blocked ordinary editing", async () => { + const { notes, links, a } = await setup(), + now = new Date().toISOString(); + const huge = Array.from({ length: 12 }, (_, i) => ({ + ...a, + id: "large-" + i, + title: "Large " + i, + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "x".repeat(1000000) }], + }, + ], + }, + })); + await mutateLibrary((state, engine) => { + state.noteMetadata = huge.map((note) => ({ + noteId: note.id, + revision: 1, + pinned: false, + tagIds: [], + })); + engine.stage( + "notes-local", + "notes_library", + "library", + JSON.stringify({ + notes: { value: huge, stamp: JSON.parse(engine.tick(Date.now())) }, + }), + ); + }); + const before = (await readRecord("devices", identity.email))!; + const first = await links.verifyGraph(); + expect(first.status).toBe("paused-limit"); + if (first.status !== "paused-limit") + throw new Error("expected bounded pause"); + expect(first.progress.verifiedNotes).toBeGreaterThan(0); + expect(first.progress.verifiedNotes).toBeLessThan(12); + expect(first.progress.utf8Units).toBeLessThanOrEqual(64 * 1024 * 1024); + expect(first.progress.nodeVisits).toBeLessThanOrEqual(250000); + expect((await links.linkedNoteIds()).status).toBe("paused-limit"); + await expect(links.reference(huge[0]!.id)).rejects.toMatchObject({ + code: "note_link_migration_pending", + }); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before.snapshot); + expect((await links.verifyGraph()).status).toBe("ready"); + expect((await noteLinkOwner(notes).read()).library.version).toBe(2); + const edited = await notes.update( + huge[0]!.id, + { title: "Actual complete migration" }, + 1, + ); + expect(edited.title).toBe("Actual complete migration"); + expect((await noteLinkOwner(notes).read()).library.version).toBe(3); +}, 15000); +it("bounds actual node visits and permits explicit continuation", async () => { + const { notes, links, a } = await setup(); + const many = Array.from({ length: 50 }, (_, i) => ({ + ...a, + id: "nodes-" + i, + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: Array.from({ length: 3000 }, () => ({ + type: "text", + text: "v", + })), + }, + ], + }, + })); + await mutateLibrary((state, engine) => { + state.noteMetadata = many.map((note) => ({ + noteId: note.id, + revision: 1, + pinned: false, + tagIds: [], + })); + engine.stage( + "notes-local", + "notes_library", + "library", + JSON.stringify({ + notes: { value: many, stamp: JSON.parse(engine.tick(Date.now())) }, + }), + ); + }); + const first = await links.verifyGraph(); + expect(first.status).toBe("paused-limit"); + if (first.status !== "paused-limit") throw new Error("pause"); + expect(first.progress.nodeVisits).toBeLessThanOrEqual(250000); + expect((await links.verifyGraph()).status).toBe("ready"); +}, 15000); +it("does not publish after cancellation or account lock during async hashing", async () => { + const { links } = await setup(); + let release!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; + }); + const real = crypto.subtle.digest.bind(crypto.subtle); + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + vi.spyOn(crypto.subtle, "digest").mockImplementation(async (...args) => { + entered(); + await barrier; + return real(...args); + }); + const work = links.verifyGraph(); + await started; + links.cancelVerification(); + release(); + await expect(work).rejects.toMatchObject({ code: "note_link_revoked" }); + vi.restoreAllMocks(); + expect((await links.linkedNoteIds()).status).not.toBe("ready"); + lock(); + await expect(links.verifyGraph()).rejects.toMatchObject({ + code: "note_link_revoked", + }); +}); +it("unaware handles detect their changed-source mismatch without claiming global quarantine", async () => { + const { notes, links, a, b } = await setup(); + await links.verifyGraph(); + await notes.update( + a.id, + { document: linked((await links.reference(b.id)).target) }, + 1, + ); + await mutateLibrary((state) => { + state.noteLinks = []; + }); + const other = await openNotesStore(identity); + handles.add(other); + const unaware = createNoteLinkService(other); + handles.add(unaware); + expect((await links.verifyGraph()).status).toBe("invalid"); + // Structural shape is valid; no persisted quarantine was invented by the first service. + const metadata = await other.library.setSort( + "title", + await unaware.currentToken(), + crypto.randomUUID(), + ); + expect(metadata.snapshot.preferences.sort).toBe("title"); + const before = (await readRecord("devices", identity.email))!; + await expect(other.update(a.id, { document }, 2)).rejects.toMatchObject({ + code: "note_link_index_invalid", + }); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before.snapshot); +}); + +it("rejects stale repair CAS and quota without modifying the authenticated saved bytes", async () => { + const { notes, links, a } = await setup(); + await links.verifyGraph(); + await notes.update(a.id, { title: "Admit" }, 1); + await mutateLibrary((state) => { + state.noteLinks = null; + }); + await links.verifyGraph(); + const intent = await links.prepareRepair(await links.currentToken()), + before = (await readRecord("devices", identity.email))!; + vi.spyOn(IDBObjectStore.prototype, "put").mockImplementation(() => { + throw new DOMException("full", "QuotaExceededError"); + }); + await expect(links.commitRepair(intent)).rejects.toThrow(); + vi.restoreAllMocks(); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before.snapshot); + await mutateLibrary((state) => { + state.revision++; + }); + const latest = (await readRecord("devices", identity.email))!; + await expect(links.commitRepair(intent)).rejects.toMatchObject({ + code: "note_link_conflict", + }); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(latest.snapshot); +}); +it("preserves receipt replay after unrelated commit and fails closed after receipt pruning", async () => { + const { notes, links, a } = await setup(); + await links.verifyGraph(); + await notes.update(a.id, { title: "Admit" }, 1); + await mutateLibrary((state) => { + state.noteLinks = null; + }); + await links.verifyGraph(); + const intent = await links.prepareRepair(await links.currentToken()); + const first = await links.commitRepair(intent); + const changed = await notes.update(a.id, { title: "Later unrelated" }, 2), + before = (await readRecord("devices", identity.email))!; + const replay = await links.commitRepair(intent); + expect(replay).toMatchObject({ ...first, replayed: true }); + expect(replay.committedToken.revision).toBeLessThan( + (await links.currentToken()).revision, + ); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before.snapshot); + await mutateLibrary((state) => { + state.receipts = state.receipts.filter( + (row: any) => row.id !== intent.operationId, + ); + }); + const pruned = (await readRecord("devices", identity.email))!; + await expect(links.commitRepair(intent)).rejects.toMatchObject({ + code: "note_link_conflict", + }); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(pruned.snapshot); +}); +it("projects current authorized labels without storing them and handles Trash/restore/deletion", async () => { + const { notes, links, a, b } = await setup(); + await links.verifyGraph(); + const target = (await links.reference(b.id)).target; + await notes.update( + a.id, + { + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Actual source excerpt " }, + { type: "noteLink", attrs: { version: 1, target } }, + { type: "noteLink", attrs: { version: 1, target } }, + ], + }, + ], + }, + }, + 1, + ); + await links.verifyGraph(); + expect(await links.linkedNoteIds()).toMatchObject({ + status: "ready", + items: expect.arrayContaining([a.id, b.id]), + }); + expect(await links.backlinks(b.id)).toMatchObject({ + status: "ready", + items: [ + { + sourceId: a.id, + occurrences: 2, + snippet: "Actual source excerpt Note referenceNote reference", + }, + ], + }); + await notes.update(b.id, { title: "Renamed target" }, 1); + expect(await links.resolve(target)).toMatchObject({ + status: "available", + title: "Renamed target", + }); + expect(JSON.stringify((await notes.get(a.id))!.document)).not.toContain( + "Renamed target", + ); + const trashed = await notes.trash(b.id, 2); + expect(await links.resolve(target)).toEqual({ + status: "unavailable", + reason: "trashed", + }); + await notes.restore(b.id, trashed.revision); + expect((await links.resolve(target)).status).toBe("available"); + const source = await notes.get(a.id); + await notes.trash(a.id, source!.revision); + await links.verifyGraph(); + expect(await links.backlinks(b.id)).toMatchObject({ + status: "ready", + items: [], + }); + const token = await links.currentToken(); + await notes.library.emptyTrash(token, crypto.randomUUID()); + expect((await noteLinkOwner(notes).read()).index).toMatchObject({ + status: "valid", + rows: [], + }); + expect( + await links.resolve({ + ...target, + accountId: "99999999-9999-4999-8999-999999999999", + }), + ).toEqual({ status: "unavailable", reason: "scope" }); +}); + +it("verifies the full 10000-note/100000-reference graph with bounded rounds and linear row admission", async () => { + const { notes, links, a } = await setup(), + state = await noteLinkOwner(notes).read(), + target = { + kind: "note", + accountId: identity.accountId, + libraryEpoch: state.library.epoch, + noteId: "scale-0", + }; + const many = Array.from({ length: 10000 }, (_, i) => ({ + ...a, + id: "scale-" + i, + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: Array.from({ length: 10 }, () => ({ + type: "noteLink", + attrs: { version: 1, target }, + })), + }, + ], + }, + })); + await mutateLibrary((library, engine) => { + library.version = 3; + library.noteMetadata = many.map((note) => ({ + noteId: note.id, + revision: 1, + pinned: false, + tagIds: [], + })); + library.noteLinks = many.map((note) => ({ + noteId: note.id, + noteRevision: 1, + targets: [{ target, occurrences: 10 }], + })); + engine.stage( + "notes-local", + "notes_library", + "library", + JSON.stringify({ + notes: { value: many, stamp: JSON.parse(engine.tick(Date.now())) }, + }), + ); + }); + let rounds = 0, + result; + do { + result = await links.verifyGraph(); + rounds++; + if (result.status === "paused-limit") { + expect(result.progress.utf8Units).toBeLessThanOrEqual(64 * 1024 * 1024); + expect(result.progress.nodeVisits).toBeLessThanOrEqual(250000); + } + } while (result.status === "paused-limit" && rounds < 8); + expect(result.status).toBe("ready"); + expect(rounds).toBeGreaterThan(1); + const backlinks = await links.backlinks("scale-0"); + expect(backlinks.status).toBe("ready"); + if (backlinks.status !== "ready") throw new Error("graph"); + expect(backlinks.items).toHaveLength(10000); + expect(backlinks.items.every((row) => row.occurrences === 10)).toBe(true); + const linked = await links.linkedNoteIds(); + expect(linked.status).toBe("ready"); + if (linked.status === "ready") expect(linked.items).toHaveLength(10000); +}, 30000); + +it("exports only captured authorized selected notes and rejects stale held files", async () => { + const { notes, links, a, b } = await setup(); + await links.verifyGraph(); + const target = (await links.reference(b.id)).target; + const foreign = { + ...target, + accountId: "99999999-9999-4999-8999-999999999999", + }; + await notes.update( + a.id, + { + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "noteLink", attrs: { version: 1, target } }, + { type: "noteLink", attrs: { version: 1, target: foreign } }, + ], + }, + ], + }, + }, + 1, + ); + const ids = [a.id], + prepared = links.prepareExport(ids); + ids.push(b.id); + const intent = await prepared; + const result = await links.exportFiles(intent); + await links.assertFreshExport(result); + const json = JSON.parse( + result.files.find((file) => file.filename === "notes.json")!.content, + ); + expect(json.notes).toHaveLength(1); + expect(json.notes[0].document).toEqual((await notes.get(a.id))!.document); + const md = result.files + .filter((file) => file.filename.endsWith(".md")) + .map((file) => file.content) + .join("\n"); + expect(md).toContain("Beta"); + expect(md).toContain("Note reference"); + expect(md).toContain("orbyn-notes://note/v1/"); + expect(Object.isFrozen(result.files)).toBe(true); + await notes.update(b.id, { title: "Later target title" }, 1); + await expect(links.assertFreshExport(result)).rejects.toMatchObject({ + code: "note_link_conflict", + }); + await expect(links.exportFiles(intent)).rejects.toMatchObject({ + code: "note_link_conflict", + }); + const newer = await links.prepareExport([a.id]); + await notes.erase(); + await expect(links.exportFiles(newer)).rejects.toMatchObject({ + code: "note_link_revoked", + }); +}); +it("exports remain available during structural-index recovery without trusting its rows", async () => { + const { notes, links, a, b } = await setup(); + await links.verifyGraph(); + const target = (await links.reference(b.id)).target; + await notes.update(a.id, { document: linked(target) }, 1); + await mutateLibrary((state) => { + state.noteLinks = { cachedTitle: "MUST NOT EXPORT" }; + }); + const result = await links.exportFiles(await links.prepareExport([a.id])); + expect(result.files.map((file) => file.content).join("\n")).toContain("Beta"); + expect(result.files.map((file) => file.content).join("\n")).not.toContain( + "MUST NOT EXPORT", + ); +}); + +it("uses the actual protected Notes proxy and closes without closing its borrowed owner", async () => { + const { notes } = await setup(); + const protectedOwner = protectOperations(notes, [ + "create", + "update", + "erase", + ]), + service = createNoteLinkService(protectedOwner); + handles.add(service); + expect((await service.verifyGraph()).status).toBe("ready"); + const held = await service.prepareExport([(await notes.list())[0]!.id]); + service.close(); + await expect(service.exportFiles(held)).rejects.toMatchObject({ + code: "note_link_revoked", + }); + expect(await notes.list()).toHaveLength(2); +}); + +it("prunes a repair receipt only by the real shared500 cap and never reexecutes its expired intent", async () => { + const { notes, links, a } = await setup(); + await links.verifyGraph(); + await notes.update(a.id, { title: "Admit" }, 1); + await mutateLibrary((state) => { + state.noteLinks = null; + }); + await links.verifyGraph(); + const intent = await links.prepareRepair(await links.currentToken()); + await links.commitRepair(intent); + await mutateLibrary((state) => { + state.receipts.find( + (row: any) => row.id === intent.operationId, + ).committedAt = "2000-01-01T00:00:00.000Z"; + for (let i = 0; i < 499; i++) + state.receipts.push({ + id: crypto.randomUUID(), + epoch: state.epoch, + committedAt: new Date(Date.now() + i).toISOString(), + libraryRevision: state.revision, + kind: "sort", + arguments: "[]", + result: { type: "metadata" }, + }); + }); + await notes.library.setSort( + "title", + await links.currentToken(), + crypto.randomUUID(), + ); + const state = await noteLinkOwner(notes).read(); + expect(state.library.receipts).toHaveLength(500); + expect( + state.library.receipts.some((row) => row.id === intent.operationId), + ).toBe(false); + const before = (await readRecord("devices", identity.email))!; + await expect(links.commitRepair(intent)).rejects.toMatchObject({ + code: "note_link_conflict", + }); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before.snapshot); +}); + +it("refuses new refs smuggled into legacy current OR history documents without read rewrite", async () => { + const { notes, links, a, b } = await setup(), + state = await noteLinkOwner(notes).read(), + target = { + kind: "note", + accountId: identity.accountId, + libraryEpoch: state.library.epoch, + noteId: b.id, + }; + const stage = (engine: SyncEngine, values: unknown[]) => + engine.stage( + "notes-local", + "notes_library", + "library", + JSON.stringify({ + notes: { value: values, stamp: JSON.parse(engine.tick(Date.now())) }, + }), + ); + await mutateLibrary((library, engine) => + stage(engine, [{ ...a, document: linked(target) }, b]), + ); + let before = (await readRecord("devices", identity.email))!; + await expect(openNotesStore(identity)).rejects.toThrow( + "Unsupported document node", + ); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before.snapshot); + await mutateLibrary((library, engine) => { + stage(engine, [a, b]); + library.versions = [ + { + id: crypto.randomUUID(), + noteId: a.id, + noteRevision: 1, + capturedAt: new Date().toISOString(), + reason: "edit", + title: a.title, + document: linked(target), + tagsAtCapture: [], + }, + ]; + }); + before = (await readRecord("devices", identity.email))!; + await expect(openNotesStore(identity)).rejects.toThrow( + "Unsupported document node", + ); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before.snapshot); +}); + +async function sameGenerationChange(effect: (engine: SyncEngine) => void) { + const record = (await readRecord("devices", identity.email))!, + secret = await wrappingSecret(record.wrapKey), + keys = DeviceKeys.import(record.sealedKeys, secret), + engine = SyncEngine.restore(keys, record.snapshot!, secret, Date.now()); + try { + effect(engine); + await mutateRecord("devices", identity.email, (current) => ({ + ...current!, + snapshot: engine.snapshot(secret), + })); + } finally { + engine.free(); + keys.free(); + secret.fill(0); + } +} +it("NL-D1 never reuses ready proof after authenticated same-generation current content changes", async () => { + const { notes, links, a, b } = await setup(); + await links.verifyGraph(); + await notes.update( + a.id, + { document: linked((await links.reference(b.id)).target) }, + 1, + ); + await links.verifyGraph(); + const token = await links.currentToken(), + generation = (await readRecord("devices", identity.email))! + .generation; + await sameGenerationChange((engine) => { + const raw = JSON.parse(engine.record("notes-local", "library")!); + raw.fields.notes.value.find((note: any) => note.id === a.id).document = + document; + raw.fields.notes.stamp = JSON.parse(engine.tick(Date.now())); + engine.stage( + "notes-local", + "notes_library", + "library", + JSON.stringify(raw.fields), + ); + }); + expect(await links.currentToken()).toEqual(token); + expect( + (await readRecord("devices", identity.email))!.generation, + ).toBe(generation); + expect(await links.verifyGraph()).toMatchObject({ + status: "invalid", + code: "index-mismatch", + }); +}); +it.each(["core", "history"])( + "NL-D1 rejects same-generation %s content changes inside prepared repair", + async (kind) => { + const { notes, links, a, b } = await setup(); + await links.verifyGraph(); + await notes.update( + a.id, + { document: linked((await links.reference(b.id)).target) }, + 1, + ); + await mutateLibrary((state) => { + state.noteLinks = []; + }); + await links.verifyGraph(); + const intent = await links.prepareRepair(await links.currentToken()); + await sameGenerationChange((engine) => { + if (kind === "core") { + const raw = JSON.parse(engine.record("notes-local", "library")!); + raw.fields.notes.value.find((note: any) => note.id === a.id).document = + document; + raw.fields.notes.stamp = JSON.parse(engine.tick(Date.now())); + engine.stage( + "notes-local", + "notes_library", + "library", + JSON.stringify(raw.fields), + ); + } else { + const raw = JSON.parse(engine.record("notes-library-v1", "workspace")!); + raw.fields.library.value.versions[0].document = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Changed historical passage" }], + }, + ], + }; + raw.fields.library.stamp = JSON.parse(engine.tick(Date.now())); + engine.stage( + "notes-library-v1", + "notes_library_metadata", + "workspace", + JSON.stringify(raw.fields), + ); + } + }); + const before = (await readRecord("devices", identity.email))!; + await expect(links.commitRepair(intent)).rejects.toMatchObject({ + code: "note_link_conflict", + }); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before.snapshot); + expect( + (await readRecord("devices", identity.email))!.generation, + ).toBe(before.generation); + }, +); +it("NL-D1 fences legacy history changes while verification hashing is pending", async () => { + const { notes, links, a } = await setup(); + let entered!: () => void, release!: () => void; + const started = new Promise((resolve) => (entered = resolve)), + barrier = new Promise((resolve) => (release = resolve)), + real = crypto.subtle.digest.bind(crypto.subtle); + let first = true; + vi.spyOn(crypto.subtle, "digest").mockImplementation(async (...args) => { + if (first) { + first = false; + entered(); + await barrier; + } + return real(...args); + }); + const job = links.verifyGraph(); + await started; + await sameGenerationChange((engine) => { + const raw = JSON.parse(engine.record("notes-library-v1", "workspace")!); + raw.fields.library.value.versions = [ + { + id: crypto.randomUUID(), + noteId: a.id, + noteRevision: 1, + capturedAt: new Date().toISOString(), + reason: "edit", + title: a.title, + document, + tagsAtCapture: [], + }, + ]; + raw.fields.library.stamp = JSON.parse(engine.tick(Date.now())); + engine.stage( + "notes-library-v1", + "notes_library_metadata", + "workspace", + JSON.stringify(raw.fields), + ); + }); + release(); + await expect(job).rejects.toMatchObject({ code: "note_link_conflict" }); +}); + +it("coalesces forty concurrent real-owner resolutions into one owner restore/read", async () => { + const { notes, links, a, b } = await setup(), + owner = noteLinkOwner(notes), + state = await owner.read(), + spy = vi.spyOn(owner, "read"); + const results = await Promise.all( + Array.from({ length: 40 }, (_, i) => + links.resolve({ + kind: "note", + accountId: identity.accountId, + libraryEpoch: state.library.epoch, + noteId: i % 2 ? a.id : b.id, + }), + ), + ); + expect(results.every((result) => result.status === "available")).toBe(true); + expect(spy).toHaveBeenCalledTimes(1); +}); +it("does not reuse settled authorization after same-generation external content changes", async () => { + const { notes, links, b } = await setup(), + owner = noteLinkOwner(notes), + state = await owner.read(), + target = { + kind: "note" as const, + accountId: identity.accountId, + libraryEpoch: state.library.epoch, + noteId: b.id, + }, + spy = vi.spyOn(owner, "read"); + expect(await links.resolve(target)).toMatchObject({ + status: "available", + title: "Beta", + }); + await sameGenerationChange((engine) => { + const raw = JSON.parse(engine.record("notes-local", "library")!); + raw.fields.notes.value.find((note: any) => note.id === b.id).title = + "External same-generation title"; + raw.fields.notes.stamp = JSON.parse(engine.tick(Date.now())); + engine.stage( + "notes-local", + "notes_library", + "library", + JSON.stringify(raw.fields), + ); + }); + expect(await links.resolve(target)).toMatchObject({ + status: "available", + title: "External same-generation title", + }); + expect(spy).toHaveBeenCalledTimes(2); +}); +it("revokes every coalesced waiter on account lock without late labels", async () => { + const { notes, links, b } = await setup(), + owner = noteLinkOwner(notes), + state = await owner.read(), + real = owner.read.bind(owner); + let entered!: () => void, release!: () => void; + const started = new Promise((resolve) => (entered = resolve)), + barrier = new Promise((resolve) => (release = resolve)); + const spy = vi.spyOn(owner, "read").mockImplementation(async () => { + const value = await real(); + entered(); + await barrier; + return value; + }); + const pending = Promise.allSettled( + Array.from({ length: 20 }, () => + links.resolve({ + kind: "note", + accountId: identity.accountId, + libraryEpoch: state.library.epoch, + noteId: b.id, + }), + ), + ); + await started; + lock(); + release(); + const results = await pending; + expect( + results.every( + (row) => + row.status === "rejected" && + (row.reason as { code: string }).code === "note_link_revoked", + ), + ).toBe(true); + expect(spy).toHaveBeenCalledTimes(1); +}); + +it("owner notifications bypass an older held read for later target resolutions", async () => { + const { notes, links, b } = await setup(), + owner = noteLinkOwner(notes), + state = await owner.read(), + target = { + kind: "note" as const, + accountId: identity.accountId, + libraryEpoch: state.library.epoch, + noteId: b.id, + }, + real = owner.read.bind(owner); + let entered!: () => void, + release!: () => void, + first = true; + const started = new Promise((resolve) => (entered = resolve)), + barrier = new Promise((resolve) => (release = resolve)); + vi.spyOn(owner, "read").mockImplementation(async () => { + const value = await real(); + if (first) { + first = false; + entered(); + await barrier; + } + return value; + }); + const older = links.resolve(target); + await started; + await notes.update(b.id, { title: "Changed while older read waits" }, 1); + const newer = links.resolve(target); + release(); + expect(await newer).toMatchObject({ + status: "available", + title: "Changed while older read waits", + }); + expect((await older).status).toBe("available"); +}); +it("erase notifications revoke older coalesced waiters and never return removed labels", async () => { + const { notes, links, b } = await setup(), + owner = noteLinkOwner(notes), + state = await owner.read(), + target = { + kind: "note" as const, + accountId: identity.accountId, + libraryEpoch: state.library.epoch, + noteId: b.id, + }, + real = owner.read.bind(owner); + let entered!: () => void, + release!: () => void, + first = true; + const started = new Promise((resolve) => (entered = resolve)), + barrier = new Promise((resolve) => (release = resolve)); + await links.resolve(target); + vi.spyOn(owner, "read").mockImplementation(async () => { + const value = await real(); + if (first) { + first = false; + entered(); + await barrier; + } + return value; + }); + const pending = Promise.allSettled( + Array.from({ length: 10 }, () => links.resolve(target)), + ); + await started; + await notes.erase(); + await links.currentToken().catch(() => {}); + release(); + const results = await pending; + expect( + results.every( + (row) => + row.status === "rejected" && + (row.reason as { code: string }).code === "note_link_revoked", + ), + ).toBe(true); +}); +it("clears failed in-flight reads so later authorization retries the real owner", async () => { + const { notes, links, b } = await setup(), + owner = noteLinkOwner(notes), + state = await owner.read(), + target = { + kind: "note" as const, + accountId: identity.accountId, + libraryEpoch: state.library.epoch, + noteId: b.id, + }; + const spy = vi + .spyOn(owner, "read") + .mockRejectedValueOnce(new Error("synthetic owner read failure")); + const failed = await Promise.allSettled( + Array.from({ length: 10 }, () => links.resolve(target)), + ); + expect(failed.every((row) => row.status === "rejected")).toBe(true); + expect(spy).toHaveBeenCalledTimes(1); + expect((await links.resolve(target)).status).toBe("available"); + expect(spy).toHaveBeenCalledTimes(2); +}); + +it("later-turn resolutions do not join an older held authenticated snapshot", async () => { + const { notes, links, b } = await setup(), + owner = noteLinkOwner(notes), + state = await owner.read(), + target = { + kind: "note" as const, + accountId: identity.accountId, + libraryEpoch: state.library.epoch, + noteId: b.id, + }, + real = owner.read.bind(owner); + let entered!: () => void, + release!: () => void, + first = true; + const started = new Promise((resolve) => (entered = resolve)), + barrier = new Promise((resolve) => (release = resolve)); + vi.spyOn(owner, "read").mockImplementation(async () => { + const value = await real(); + if (first) { + first = false; + entered(); + await barrier; + } + return value; + }); + const older = links.resolve(target); + await started; + await sameGenerationChange((engine) => { + const raw = JSON.parse(engine.record("notes-local", "library")!); + raw.fields.notes.value.find((note: any) => note.id === b.id).title = + "External change while older read waits"; + raw.fields.notes.stamp = JSON.parse(engine.tick(Date.now())); + engine.stage( + "notes-local", + "notes_library", + "library", + JSON.stringify(raw.fields), + ); + }); + const later = links.resolve(target); + release(); + expect(await later).toMatchObject({ + status: "available", + title: "External change while older read waits", + }); + await older; +}); diff --git a/web/tests/data/search-model.test.ts b/web/tests/data/search-model.test.ts new file mode 100644 index 0000000..6f2541c --- /dev/null +++ b/web/tests/data/search-model.test.ts @@ -0,0 +1,611 @@ +// @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 { + newWrappingKey, + wrappingSecret, + mutateRecord, + type DeviceRecord, +} from "../../src/data/database"; +import { establish, lock } from "../../src/account/session"; +import { openNotesStore } from "../../src/data"; +import { + openCollectionsStore, + replaceJournal, +} from "../../src/collections/store"; +import { + emptyJournal, + createJournalEnvelope, +} from "../../src/collections/journal"; +import { createDeck, saveEntry } from "../../src/study/model"; +import { + acquireSearchSource, + searchSourceReader, + releaseSearchSource, + assertSearchSourceFresh, +} from "../../src/search/source"; +import { compileSearchQuery } from "../../src/search/text"; +import { + buildSearchIndex, + searchIndexCounts, + projectSearchPage, + disposeSearchIndex, + type SearchIndex, +} from "../../src/search/model"; +import { createNoteLinkService } from "../../src/links/store"; +import { SearchWork, type WorkEvent } from "../../src/search/work"; +import type { NoteDocument } from "../../src/domain/contracts"; +import type { SearchLease } from "../../src/search/contracts"; +const identity = { + accountId: "11111111-1111-4111-8111-111111111111", + deviceId: "22222222-2222-4222-8222-222222222222", + email: "model@example.test", + deviceState: "trusted" as const, +}; +const handles = new Set<{ close(): void }>(), + leases = new Set(), + indexes = new Set(); +beforeAll(async () => + initSync({ + module: await readFile( + new URL("../../src/generated/wasm/orbyn_wasm_bg.wasm", import.meta.url), + ), + }), +); +afterEach(() => { + for (const index of indexes) disposeSearchIndex(index); + indexes.clear(); + for (const lease of leases) releaseSearchSource(lease); + leases.clear(); + for (const h of handles) h.close(); + handles.clear(); + lock(); + vi.restoreAllMocks(); +}); +const doc = (text: string): NoteDocument => ({ + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text }] }], +}); +async function fixture() { + 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); + const note = await notes.create({ + title: "Common Zulu", + document: doc("body needle"), + tags: ["alpha"], + }); + const target = await notes.create({ + title: "Current Authorized Label", + document: doc("other"), + tags: [], + }); + const trash = await notes.create({ + title: "Common trash-secret", + document: doc("hidden-trash"), + tags: [], + }); + await notes.trash(trash.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, "Deck Metadata", at); + const deckId = study.decks[0]!.id; + for (const type of ["basic", "reversed", "cloze"] as const) { + const base = { + id: crypto.randomUUID(), + deckId, + revision: 1, + tags: ["tag-value"], + source: { + noteId: note.id, + noteRevision: 1, + quote: "hidden-source-quote", + }, + }; + study = saveEntry( + study, + type === "cloze" + ? { + ...base, + type, + text: "Common {{one}} then {{two}}", + blanks: [ + { id: "one", answer: "First Answer" }, + { id: "two", answer: "Second Answer" }, + ], + } + : { + ...base, + type, + front: "Common Question " + type, + back: "Common Answer " + type, + }, + at, + ); + } + return { + ...state, + study, + clips: { + ...state.clips, + items: [ + { + id: crypto.randomUUID(), + revision: 1, + title: "Common archive", + originalUrl: "https://example.test/path", + canonicalUrl: "https://example.test/path", + text: "saved needle", + createdAt: at.toISOString(), + updatedAt: at.toISOString(), + readAt: null, + readLater: false, + archivedAt: at.toISOString(), + }, + ], + }, + }; + }); + return { notes, collections, note, target }; +} +async function source(f: Awaited>) { + const lease = await acquireSearchSource(f.notes, f.collections); + leases.add(lease); + return { lease, reader: searchSourceReader(lease) }; +} +async function done(iterator: Generator) { + const runner = new SearchWork(iterator); + for (let i = 0; i < 100; i++) { + const result = await runner.round(); + if (result.status === "done") return result.value; + } + throw new Error("Did not finish bounded search."); +} +async function build(f: Awaited>, raw: string) { + const { lease, reader } = await source(f), + index = await done(buildSearchIndex(reader, compileSearchQuery(raw)!)); + indexes.add(index); + return { index, lease }; +} +it("matches real Notes/archived Clips/actual Reversed2+ClozeN cards with exact counts and lazy pages", async () => { + const f = await fixture(); + await source(f); + const restore = vi.spyOn(SyncEngine, "restore"), + { index, lease } = await build(f, "common"); + expect(searchIndexCounts(index)).toEqual({ + notes: 1, + clips: 1, + cards: 5, + all: 7, + }); + const notes = await done(projectSearchPage(index, "note", 0, 50)), + clips = await done(projectSearchPage(index, "clip", 0, 50)), + cards = await done(projectSearchPage(index, "card", 0, 50)); + expect(notes[0]!.primary.text).toBe("Common Zulu"); + expect(notes[0]!.primary.matches).toEqual([{ from: 0, to: 6 }]); + expect(clips[0]).toMatchObject({ archived: true, clipContent: "saved-text" }); + expect( + cards.map((card) => (card.locator.kind === "card" ? card.locator.key : "")), + ).toEqual([ + "basic:forward", + "reversed:forward", + "reversed:reverse", + "cloze:one", + "cloze:two", + ]); + expect(cards.at(-2)!.primary.text).toContain("[...]"); + await assertSearchSourceFresh(lease); + expect(restore).not.toHaveBeenCalled(); +}); +it("ANDs distinct terms across actual eligible fields, uses primary rank, and excludes source/trash/Journal", async () => { + const f = await fixture(); + expect( + searchIndexCounts((await build(f, "common alpha needle")).index), + ).toEqual({ notes: 1, clips: 0, cards: 0, all: 1 }); + expect( + searchIndexCounts((await build(f, "common example.test saved")).index), + ).toEqual({ notes: 0, clips: 1, cards: 0, all: 1 }); + expect( + searchIndexCounts((await build(f, "common metadata tag-value")).index) + .cards, + ).toBe(5); + const current = await f.collections.read(), + journal = emptyJournal(); + journal.habits.push({ + id: crypto.randomUUID(), + name: "hidden-journal", + order: 0, + createdAt: new Date().toISOString(), + createdOn: "2026-01-01", + archivedAt: null, + archivedOn: null, + revision: 1, + }); + const sealed = await createJournalEnvelope( + "synthetic passphrase", + journal, + { + accountId: identity.accountId, + epoch: current.epoch, + }, + () => {}, + ); + await replaceJournal(f.collections, current, sealed.envelope); + for (const query of [ + "hidden-source-quote", + "hidden-trash", + "trash-secret", + "hidden-journal", + ]) + expect(searchIndexCounts((await build(f, query)).index).all).toBe(0); +}); +it("uses only current authorized typed-reference labels and preserves marked-node continuity/block boundaries", async () => { + const f = await fixture(), + links = createNoteLinkService(f.notes); + handles.add(links); + await links.verifyGraph(); + const library = await f.notes.library.read(); + if (library.mode !== "ready") throw new Error("fixture"); + const target = { + kind: "note" as const, + accountId: identity.accountId, + libraryEpoch: library.snapshot.token.epoch, + noteId: f.target.id, + }; + await f.notes.update( + f.note.id, + { + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "stra" }, + { type: "text", text: "ße", marks: [{ type: "bold" }] }, + { type: "text", text: " before " }, + { type: "noteLink", attrs: { version: 1, target } }, + { type: "text", text: " after" }, + ], + }, + { type: "paragraph", content: [{ type: "text", text: "separate" }] }, + ], + }, + }, + 1, + ); + expect(searchIndexCounts((await build(f, "strasse")).index).notes).toBe(1); + expect(searchIndexCounts((await build(f, "afterseparate")).index).notes).toBe( + 0, + ); + expect( + searchIndexCounts((await build(f, "authorized label")).index).notes, + ).toBe(2); + await f.notes.update(f.target.id, { title: "Renamed Current" }, 1); + expect(searchIndexCounts((await build(f, "authorized")).index).notes).toBe(0); + expect(searchIndexCounts((await build(f, "renamed")).index).notes).toBe(2); + const n = (await f.notes.get(f.note.id))!; + await f.notes.update( + n.id, + { + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "before" }, + { + type: "noteLink", + attrs: { + version: 1, + target: { + ...target, + accountId: "33333333-3333-4333-8333-333333333333", + }, + }, + }, + { type: "text", text: "after" }, + ], + }, + ], + }, + }, + n.revision, + ); + expect(searchIndexCounts((await build(f, "beforeafter")).index).notes).toBe( + 0, + ); + expect(searchIndexCounts((await build(f, "renamed")).index).notes).toBe(1); +}); +it("streams Cloze marker boundaries and hidden answers without searching synthetic placeholders", async () => { + const f = await fixture(), + current = await f.collections.read(), + entry = current.study.entries.find((entry) => entry.type === "cloze")!; + await f.collections.update(current, (state) => ({ + ...state, + study: saveEntry( + state.study, + { + ...entry, + type: "cloze", + revision: entry.revision + 1, + text: "x".repeat(4094) + "{{{one}}} and {{two}}", + blanks: [ + { id: "one", answer: "cross-answer" }, + { id: "two", answer: "second-answer" }, + ], + }, + new Date(), + ), + })); + const { index } = await build(f, "cross-answer second-answer"); + expect(searchIndexCounts(index).cards).toBe(2); + const cards = await done(projectSearchPage(index, "card", 0, 50)); + expect(cards.every((card) => card.primary.text.length <= 240)).toBe(true); + expect(searchIndexCounts((await build(f, "[...]")).index).cards).toBe(0); +}); +it("resumes work without top-N counts and pages exact ordered matches beyond50", async () => { + const f = await fixture(); + for (let i = 0; i < 65; i++) + await f.notes.create({ + title: "Common " + String(i).padStart(3, "0"), + document: doc("body"), + tags: [], + }); + const { reader } = await source(f); + const work = new SearchWork( + buildSearchIndex(reader, compileSearchQuery("common")!), + { bytes: 3000000, visits: 1000 }, + async () => {}, + ); + let rounds = 0, + index: SearchIndex | undefined; + for (; rounds < 100; rounds++) { + const result = await work.round(); + if (result.status === "done") { + index = result.value; + break; + } + } + expect(index).toBeDefined(); + indexes.add(index!); + expect(rounds).toBeGreaterThan(0); + expect(searchIndexCounts(index!).notes).toBe(66); + const first = await done(projectSearchPage(index!, "note", 0, 50)), + second = await done(projectSearchPage(index!, "note", 50, 50)); + expect(first).toHaveLength(50); + expect(second).toHaveLength(16); + expect( + new Set( + [...first, ...second].map((hit) => + hit.locator.kind === "note" ? hit.locator.noteId : "", + ), + ), + ).toHaveLength(66); +}); +it("bounds huge fields within continuation, cancels pending work, and rejects forged/disposed indexes", async () => { + const f = await fixture(); + await f.notes.update( + f.note.id, + { document: doc("a".repeat(300000) + "needle") }, + 1, + ); + const { reader } = await source(f), + iterator = buildSearchIndex(reader, compileSearchQuery("needle")!), + work = new SearchWork( + iterator, + { bytes: 2000000, visits: 1000 }, + async () => {}, + ); + expect((await work.round()).status).toBe("paused"); + work.close(); + await expect(work.round()).rejects.toMatchObject({ name: "AbortError" }); + const { index } = await build(f, "needle"); + expect(searchIndexCounts(index).notes).toBe(1); + const pageWork = new SearchWork( + projectSearchPage(index, "note", 0, 50), + { bytes: 2000000, visits: 1000 }, + async () => {}, + ); + expect((await pageWork.round()).status).toBe("paused"); + expect(searchIndexCounts(index).notes).toBe(1); + let page: readonly import("../../src/search/results").SearchHit[] = []; + for (let rounds = 0; rounds < 100; rounds++) { + const result = await pageWork.round(); + if (result.status === "done") { + page = result.value; + break; + } + } + expect(page).toHaveLength(1); + expect(page[0]!.secondary[0]!.text.length).toBeLessThanOrEqual(240); + disposeSearchIndex(index); + expect(() => searchIndexCounts(index)).toThrow(); + expect(() => searchIndexCounts({} as SearchIndex)).toThrow(); + expect(() => projectSearchPage(index, "note", 0, 50).next()).toThrow(); +}); + +it("orders primary hits first, then real timestamps/title and stable card provenance", async () => { + const f = await fixture(); + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date("2030-01-01T00:00:00Z")); + const z = await f.notes.create({ + title: "needle zeta", + document: doc("none"), + tags: [], + }); + const a = await f.notes.create({ + title: "needle Alpha", + document: doc("none"), + tags: [], + }); + vi.setSystemTime(new Date("2030-01-02T00:00:00Z")); + const newer = await f.notes.create({ + title: "needle newest", + document: doc("none"), + tags: [], + }); + vi.setSystemTime(new Date("2030-01-03T00:00:00Z")); + const body = await f.notes.create({ + title: "Body only", + document: doc("needle"), + tags: [], + }); + const { index } = await build(f, "needle"); + const page = await done(projectSearchPage(index, "note", 0, 50)); + const ids = page.map((hit) => + hit.locator.kind === "note" ? hit.locator.noteId : "", + ); + expect(ids.slice(0, 3)).toEqual([newer.id, a.id, z.id]); + expect(ids.slice(3)).toContain(body.id); + } finally { + vi.useRealTimers(); + } +}); +it("keeps full500-unit actual title and marks late Unicode hits without splitting scalars", async () => { + const f = await fixture(), + title = "x".repeat(450) + " Straße " + "y".repeat(42); + await f.notes.update(f.note.id, { title }, 1); + const { index } = await build(f, "strasse"); + const page = await done(projectSearchPage(index, "note", 0, 50)); + expect(page[0]!.primary.text).toBe(title); + expect( + page[0]!.primary.matches.map((range) => title.slice(range.from, range.to)), + ).toEqual(["Straße"]); +}); +it("does not mark a display-only selected Cloze placeholder when identical literal text is searchable", async () => { + const f = await fixture(), + current = await f.collections.read(), + entry = current.study.entries.find((entry) => entry.type === "cloze")!; + await f.collections.update(current, (state) => ({ + ...state, + study: saveEntry( + state.study, + { + ...entry, + type: "cloze", + revision: entry.revision + 1, + text: "{{one}} literal [...] {{two}}", + blanks: [ + { id: "one", answer: "first" }, + { id: "two", answer: "second" }, + ], + }, + new Date(), + ), + })); + const { index } = await build(f, "[...]"); + const cards = await done(projectSearchPage(index, "card", 0, 50)); + const first = cards.find( + (hit) => hit.locator.kind === "card" && hit.locator.key === "cloze:one", + )!; + expect(first.primary.text).toBe("[...] literal [...] second"); + expect(first.primary.matches).toEqual([{ from: 14, to: 19 }]); +}); +it("disposal terminates a held page iterator and rejects future readers", async () => { + const f = await fixture(), + { index } = await build(f, "common"), + page = projectSearchPage(index, "card", 0, 50); + expect(page.next().done).toBe(false); + disposeSearchIndex(index); + expect(page.next().done).toBe(true); + expect(() => projectSearchPage(index, "card", 0, 50)).toThrow(); +}); + +it("keeps an authorized empty-title reference as a nonsearchable boundary", async () => { + const f = await fixture(), + links = createNoteLinkService(f.notes); + handles.add(links); + await links.verifyGraph(); + await f.notes.update(f.target.id, { title: "" }, 1); + const library = await f.notes.library.read(); + if (library.mode !== "ready") throw new Error("fixture"); + const target = { + kind: "note" as const, + accountId: identity.accountId, + libraryEpoch: library.snapshot.token.epoch, + noteId: f.target.id, + }; + await f.notes.update( + f.note.id, + { + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "a" }, + { type: "noteLink", attrs: { version: 1, target } }, + { type: "text", text: "b" }, + ], + }, + { + type: "paragraph", + content: [{ type: "text", text: "actual source passage" }], + }, + ], + }, + }, + 1, + ); + expect(searchIndexCounts((await build(f, "ab")).index).notes).toBe(0); + const positive = await build(f, "a b actual source passage"); + expect(searchIndexCounts(positive.index).notes).toBe(1); + const page = await done(projectSearchPage(positive.index, "note", 0, 50)); + expect(page[0]!.secondary.length).toBeGreaterThan(0); +}); + +it("projects the exact1000-unit legal deck title and late genuine highlight", async () => { + const f = await fixture(), + current = await f.collections.read(), + title = "x".repeat(950) + "DeckNeedle" + "y".repeat(40); + await f.collections.update(current, (state) => ({ + ...state, + study: { + ...state.study, + decks: state.study.decks.map((deck) => ({ + ...deck, + title, + revision: deck.revision + 1, + })), + }, + })); + const { index } = await build(f, "deckneedle"); + expect(searchIndexCounts(index).cards).toBe(5); + const cards = await done(projectSearchPage(index, "card", 0, 50)); + for (const card of cards) { + expect(card.deckTitle).toBe(title); + const deck = card.secondary.find((part) => part.text === title)!; + expect(deck).toBeDefined(); + expect(deck.matches).toEqual([{ from: 950, to: 960 }]); + } +});