diff --git a/apps/writer/package.json b/apps/writer/package.json index 9e413aa..bdbe6b3 100644 --- a/apps/writer/package.json +++ b/apps/writer/package.json @@ -32,6 +32,7 @@ "@lezer/highlight": "^1.2.3", "@lezer/markdown": "^1.4.3", "@mdbase-dev/connect": "0.1.0-beta.125", + "@mdbase-dev/sdk": "file:vendor/mdbase-dev-sdk-c573c96.tgz", "@mdbase-dev/ui": "0.1.0-beta.125", "@mdbase-writer/core": "workspace:*", "@myriaddreamin/typst-ts-renderer": "0.7.0", diff --git a/apps/writer/src/App.tsx b/apps/writer/src/App.tsx index 3758875..70f8e9d 100644 --- a/apps/writer/src/App.tsx +++ b/apps/writer/src/App.tsx @@ -1,10 +1,13 @@ -// Top level: pick the backend (Connect, or the development demo), then show +// Top level: pick the backend (Connect, the development demo, or the opt-in +// mdbase-next backend with `?next` / `?demo=next`), then show // the manuscript list or an open manuscript. The open manuscript lives in the // URL (`?manuscript=path`) so reloads and links land in the same place. import { externalStore } from "@mdbase-dev/connect"; import { Component, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; import { ConnectBackend } from "./backend/connect.js"; +import type { NextBackend } from "./backend/next.js"; +import type { NextProblem } from "./backend/next-errors.js"; import type { WriterBackend } from "./backend/types.js"; import { createWriterSession, type WriterSession } from "./connect/session.js"; import { appUrls } from "./apps.js"; @@ -12,6 +15,7 @@ import { AppSwitcher } from "@mdbase-dev/ui/app-switcher"; import { FeedbackButton, useFeedback } from "@mdbase-dev/ui/feedback"; import { useWriterFeedbackContext } from "./FeedbackRoot.js"; import { ConnectGate } from "./ui/ConnectGate.js"; +import { NextGate, NextStatus } from "./ui/NextGate.js"; import { CollectionPicker } from "./ui/CollectionPicker.js"; import { Home } from "./ui/Home.js"; import { Dialog } from "@mdbase-dev/ui/dialog"; @@ -26,6 +30,8 @@ import { ManuscriptWorkspace } from "./workspace/workspace.js"; const params = new URL(location.href).searchParams; const demoRequested = (import.meta.env.DEV || import.meta.env.VITE_WRITER_DEMO === "1") && params.has("demo"); +// Opt-in: an mdbase-next collection over the relay. The default stays Connect. +const nextRequested = (import.meta.env.DEV || import.meta.env.VITE_WRITER_NEXT === "1") && params.has("next"); function useManuscriptParam(mayLeave: () => Promise): [string | null, (path: string | null, force?: boolean) => void] { const [value, setValue] = useState(() => new URL(location.href).searchParams.get("manuscript")); @@ -82,7 +88,7 @@ export function App() { - reportError({ code: "unknown_error" })}>{demoRequested ? : } + reportError({ code: "unknown_error" })}>{demoRequested ? : nextRequested ? : } @@ -169,7 +175,9 @@ function DemoRoot() { if (!live) return; const b = import.meta.env.DEV && params.get("demo") === "large" ? await (await import("./backend/large-demo.js")).createLargeDemoBackend() - : await createDemoBackend(); + : params.get("demo") === "next" + ? await (await import("./backend/next-demo.js")).createNextDemoBackend() + : await createDemoBackend(); if (live) { owned = b; setBackend(b); } else b.dispose(); }).catch((error: unknown) => { if (live) setProblem(error instanceof Error ? error.message : String(error)); }); @@ -180,7 +188,41 @@ function DemoRoot() { }, []); if (problem) return ; if (!backend) return ; - return ; + return <>{backend.kind === "next" && }; +} + +/** + * `?next&collection=&grant=`: a collection on mdbase-next. The route + * comes from a proposed control-plane endpoint (connect/next-control.ts). + */ +function NextRoot() { + const [backend, setBackend] = useState(null); + const [waiting, setWaiting] = useState(); + const [problem, setProblem] = useState(); + useEffect(() => { + const abort = new AbortController(); + let owned: NextBackend | undefined; + const collection = params.get("collection"); + // The SDK loads only in this mode. + const errors = import("./backend/next-errors.js"); + void (async () => { + if (!collection) throw new Error("Open Writer with ?next&collection=&grant=."); + const [{ connectNext, proposedControlPlane }, { NextBackend }, { problemFrom }] = await Promise.all([import("./connect/next-control.js"), import("./backend/next.js"), errors]); + const control = proposedControlPlane({ serverUrl: params.get("server") ?? import.meta.env.VITE_MDBASE_CONNECT_URL ?? "https://connect.mdbase.dev", grant: params.get("grant") }); + const client = await connectNext({ collection, control, signal: abort.signal, onWaiting: (why) => setWaiting(problemFrom(why)) }); + if (abort.signal.aborted) { client.close(); return; } + owned = new NextBackend(client, { collectionName: params.get("name") ?? "Collection", draftNamespace: `next:${collection}`, ownsClient: true }); + setBackend(owned); + })().catch(async (error: unknown) => { + const { problemFrom } = await errors; + if (abort.signal.aborted) return; + const mapped = problemFrom(error); + setProblem(error instanceof Error && !("code" in error) ? { ...mapped, message: error.message } : mapped); + }); + return () => { abort.abort(); owned?.dispose(); }; + }, []); + if (!backend) return ; + return <>; } function downloadLocalDrafts(workspace: ManuscriptWorkspace): void { @@ -204,6 +246,8 @@ function Manuscripts({ backend, collectionPicker }: { backend: WriterBackend; co return new Promise((finish) => setNavigation({ message: saved.message, finish })); }); const workspace = useOwned(() => (path ? new ManuscriptWorkspace(backend, path) : null), (w) => void w.dispose(), [backend, path]); + // A new manuscript whose path was taken at the log moved: follow it. + useEffect(() => backend.onRecordMoved?.(({ from, to }) => { if (from === path) setPath(to, true); }), [backend, path]); useWriterFeedbackContext(path ? "manuscript" : "manuscripts", backend.collectionName); currentWorkspace.current = workspace; (window as unknown as { writer?: unknown }).writer = { backend, workspace }; diff --git a/apps/writer/src/backend/next-demo.ts b/apps/writer/src/backend/next-demo.ts new file mode 100644 index 0000000..ae94c1c --- /dev/null +++ b/apps/writer/src/backend/next-demo.ts @@ -0,0 +1,54 @@ +// The `?demo=next` collection: the bundled demo manuscripts on the SDK's +// in-memory replica, read and written through NextBackend. No account, relay +// or device. Writes stay pending for a moment before they confirm, as they +// would on a real replica. +import { connect, toValue, type MdbaseClient, type PlainValue } from "@mdbase-dev/sdk"; +import { MemoryReplica } from "@mdbase-dev/sdk/testing"; +import type { CslItem } from "@mdbase-writer/core"; +import { splitFrontmatter } from "@mdbase-writer/core/records"; + +import { WRITER_CLIENT } from "../connect/next-control.js"; +import { NextBackend } from "./next.js"; + +const markdown = import.meta.glob("../../demo/**/*.md", { query: "?raw", import: "default", eager: true }) as Record; +const assets = import.meta.glob("../../demo/**/*.{svg,png,jpg}", { query: "?url", import: "default", eager: true }) as Record; +const relative = (key: string) => key.replace(/^\.\.\/\.\.\/demo\//, ""); + +const values = (frontmatter: Record) => + Object.fromEntries(Object.entries(frontmatter).map(([key, value]) => [key, toValue(value as PlainValue)])); + +function typesOf(frontmatter: Record): string[] { + const type = frontmatter["type"]; + return Array.isArray(type) ? type.filter((t): t is string => typeof t === "string") : typeof type === "string" ? [type] : []; +} + +export interface NextDemoBackend extends NextBackend { + /** The in-memory replica, for poking at from the console. */ + readonly replica: MemoryReplica; + /** Another client on the same replica, to make concurrent edits. */ + otherClient(): Promise; +} + +export async function createNextDemoBackend(): Promise { + const replica = new MemoryReplica({ confirmDelayMs: 400 }); + for (const [key, text] of Object.entries(markdown)) { + const { body, frontmatter } = splitFrontmatter(text); + replica.seed({ path: relative(key), types: typesOf(frontmatter), frontmatter: values(frontmatter), body }); + } + const library = (await import("../../demo/library.json")).default as CslItem[]; + for (const item of library) { + replica.seed({ path: `sources/${item.id}.md`, types: ["reader-source"], frontmatter: values({ type: "reader-source", csl: item }), body: "" }); + } + const client = await connect({ app: WRITER_CLIENT, connector: replica.connector() }); + await Promise.all(Object.entries(assets).map(async ([key, url]) => { + try { + const response = await fetch(url); + if (response.ok) await client.files.upload(relative(key), new Uint8Array(await response.arrayBuffer())); + } catch { /* A missing figure shows as a diagnostic, as in the other demos. */ } + })); + const backend = new NextBackend(client, { collectionName: "Demo collection (mdbase-next)", draftNamespace: "next-demo", ownsClient: true }); + return Object.assign(backend, { + replica, + otherClient: () => connect({ app: { ...WRITER_CLIENT, name: "mdbase-writer-demo-peer" }, connector: replica.connector() }), + }); +} diff --git a/apps/writer/src/backend/next-errors.ts b/apps/writer/src/backend/next-errors.ts new file mode 100644 index 0000000..8b2a3d7 --- /dev/null +++ b/apps/writer/src/backend/next-errors.ts @@ -0,0 +1,69 @@ +// The mdbase-next SDK's 15 error codes, as Writer shows them. Each code has +// one recovery action (replica-client-api.md §9); Writer adds its own text. +import { isMdbaseError, type ErrorCode } from "@mdbase-dev/sdk"; + +import type { SessionProblem } from "./types.js"; + +/** What the person can do about a problem. */ +export type NextRecovery = "fix" | "refresh" | "resolve" | "reconnect" | "repair" | "wait" | "free-space" | "upgrade" | "check" | "none" | "report"; + +export interface NextProblem extends SessionProblem { + readonly code: ErrorCode; + readonly message: string; + readonly reason?: string; + readonly recovery: NextRecovery; + /** A private collection with none of the person's devices online: a state, not an error. */ + readonly waitingForDevice?: boolean; +} + +const TEXT: Record = { + invalid_request: { message: "Writer sent a request this collection could not accept.", recovery: "report" }, + invalid_record: { message: "The collection's type rules do not accept this change.", recovery: "fix" }, + not_found: { message: "This record no longer exists in the collection.", recovery: "refresh" }, + conflict: { message: "This changed elsewhere at the same time.", recovery: "resolve" }, + unauthenticated: { message: "Writer's access to this collection has ended. Connect again to continue.", recovery: "reconnect" }, + forbidden: { message: "Writer is not allowed to make this change in this collection.", recovery: "reconnect" }, + collection_invalid: { message: "The collection needs to be repaired before Writer can use it.", recovery: "repair" }, + unavailable: { message: "The collection cannot be reached right now. Writer keeps trying.", recovery: "wait" }, + rate_limited: { message: "The collection is busy. Writer will try again shortly.", recovery: "wait" }, + quota_exceeded: { message: "The collection is out of space.", recovery: "free-space" }, + too_large: { message: "This is too large for the collection to store.", recovery: "fix" }, + upgrade_required: { message: "This version of Writer is too old for the collection. Reload to update it.", recovery: "upgrade" }, + outcome_unknown: { message: "Writer could not confirm whether the last save went through. It will check again before saving more.", recovery: "check" }, + cancelled: { message: "Cancelled.", recovery: "none" }, + internal: { message: "Something went wrong in the collection.", recovery: "report" }, +}; + +const REASONS: Readonly> = { + no_device_online: "Waiting for one of your devices to come online.", + path_taken: "A record already exists at that path.", + reconnecting: "Reconnecting to the collection.", +}; + +/** Writer's text for an SDK code and optional reason. */ +export function nextProblem(code: ErrorCode, reason?: string): NextProblem { + const known = TEXT[code]; + const message = (reason && REASONS[reason]) || known.message; + return { + code, message, recovery: known.recovery, + ...(reason ? { reason } : {}), + ...(code === "unavailable" && reason === "no_device_online" ? { waitingForDevice: true } : {}), + }; +} + +/** Any thrown value as a Writer problem: SDK errors by code, anything else as `internal`. */ +export function problemFrom(error: unknown): NextProblem { + if (isMdbaseError(error)) return nextProblem(error.code, error.reason); + const problem = nextProblem("internal"); + return error instanceof Error && error.message ? { ...problem, message: `${problem.message} (${error.message})` } : problem; +} + +/** A problem from a rejected receipt's problem, which carries a code that may be newer than this SDK. */ +export function problemFromWire(problem: { readonly code: string; readonly reason?: string } | undefined, fallback: ErrorCode = "internal"): NextProblem { + const code = problem && problem.code in TEXT ? problem.code as ErrorCode : fallback; + return nextProblem(code, problem?.reason); +} + +/** A create may be retried at another path. */ +export const isPathTaken = (problem: { readonly code: string; readonly reason?: string } | undefined) => + problem?.code === "conflict" && (problem.reason === undefined || problem.reason === "path_taken"); diff --git a/apps/writer/src/backend/next-session.ts b/apps/writer/src/backend/next-session.ts new file mode 100644 index 0000000..fc3e907 --- /dev/null +++ b/apps/writer/src/backend/next-session.ts @@ -0,0 +1,366 @@ +// One open record on mdbase-next: the workspace's record session on top of +// `MdbaseClient.update`. Each save sends the view it was edited from, so the +// SDK adds `base` fields and body edits and the replica merges concurrent +// edits instead of the last writer winning. +// +// - A save is accepted when the replica answers `pending` with the optimistic +// record; it is confirmed later (`Write.confirmed`). The state stays +// `saving` until then, but the change is no longer `dirty`: the replica has it. +// - A rejected `conflict` receipt, a confirmed receipt whose entry was +// `conflicted`, and a hold on the record all become the workspace's +// `conflict` state, with the other side as `remote`. +// - An `outcome_unknown` write is `recovery`: its receipt is looked up by +// mutation ID before anything else is sent. +import { isMdbaseError, toPlain, type ConflictEntry, type Hold, type MdbaseClient, type PlainValue, type Receipt, type RecordView, type Write } from "@mdbase-dev/sdk"; +import type { JsonObject } from "@mdbase-dev/connect"; + +import { nextProblem, problemFrom, problemFromWire, type NextProblem } from "./next-errors.js"; +import type { RecordResolution, RecordSession, RecordSessionSnapshot, RecordSessionState, SessionProblem, SessionRecord } from "./types.js"; + +const WITH_BODY = { body: true } as const; + +export const plainFrontmatter = (view: Pick): JsonObject => toPlain(view.frontmatter) as JsonObject; + +/** Writer's JSON frontmatter as SDK values (JSON is a subset of them). */ +export const asPlain = (value: JsonObject): { [key: string]: PlainValue } => value as { [key: string]: PlainValue }; + +export function sessionRecord(view: RecordView): SessionRecord { + return { path: view.path, revision: view.revision, types: view.types, frontmatter: plainFrontmatter(view), ...(view.body !== undefined ? { body: view.body } : {}) }; +} + +const same = (a: unknown, b: unknown) => a === b || JSON.stringify(a) === JSON.stringify(b); +const holdText = (value: Hold["mine"] | undefined) => (typeof value === "string" ? value : undefined); + +type Outcome = { readonly ok: true } | { readonly ok: false; readonly problem: SessionProblem }; + +export class NextRecordSession implements RecordSession { + /** The record the local text descends from (always read with its body). */ + private base: RecordView & { body: string }; + private body: string; + private patch: JsonObject = {}; + private submitting: Promise | null = null; + /** Accepted writes waiting for confirmation, with the text each sent. */ + private readonly pending = new Map(); + private remote: RecordView | null = null; + private conflicts: ConflictEntry[] = []; + private hold: Hold | null = null; + private problem: NextProblem | null = null; + private unknownMutation: string | undefined; + private deleted = false; + private stale = false; + private timer: ReturnType | undefined; + private readonly listeners = new Set<() => void>(); + private disposed = false; + private current: RecordSessionSnapshot; + + constructor(private readonly client: MdbaseClient, view: RecordView & { body: string }, private readonly idleMs: number | false) { + this.base = view; + this.body = view.body; + this.current = this.compute(); + } + + get id(): string { return this.base.id; } + get path(): string { return this.base.path; } + + getSnapshot = (): RecordSessionSnapshot => this.current; + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + setBody(body: string): void { + if (body === this.body) return; + this.body = body; + this.edited(); + } + + patchFrontmatter(patch: JsonObject): void { + this.patch = { ...this.patch, ...patch }; + this.edited(); + } + + /** A newer view from the live query (without a body). */ + receive(view: RecordView): void { + if (this.disposed) return; + if (this.deleted) { this.deleted = false; this.emit(); } + if (view.revision === this.base.revision) { + if (view.path !== this.base.path) { this.base = { ...this.base, path: view.path }; this.emit(); } + return; + } + // Our own optimistic records come back through the live query too. + for (const write of this.pending.keys()) if (write.records.some((r) => r.revision === view.revision)) return; + if (this.dirtyLocally() || this.submitting || this.pending.size || this.remote || this.hold) { + // Local edits keep their base: the next save merges on the replica. + this.stale = true; + return; + } + void this.refresh(); + } + + markDeleted(): void { + if (this.deleted) return; + this.deleted = true; + clearTimeout(this.timer); + this.emit(); + } + + /** A hold the replica placed on this record (or `null` when it is gone). */ + setHold(hold: Hold | null): void { + if (this.hold?.id === hold?.id && this.hold?.saves === hold?.saves) return; + const released = this.hold && !hold; + this.hold = hold; + this.emit(); + if (released) void this.refresh(); + } + + /** Read the record again; adopt it when there is nothing local to keep. */ + async refresh(): Promise { + let view: RecordView; + try { view = await this.client.get(this.base.id, WITH_BODY); } + catch (error) { + if (isMdbaseError(error, "not_found")) this.markDeleted(); + return; + } + if (this.disposed) return; + this.stale = false; + if (this.dirtyLocally() || this.submitting || this.pending.size || this.remote || this.hold) return; + this.base = withBody(view, view.body ?? ""); + this.body = this.base.body; + this.patch = {}; + this.emit(); + } + + resolve(choice: RecordResolution): void { + if (this.hold) { + const hold = this.hold; + const how = "body" in choice ? "use" : choice.keep === "mine" ? "keep_mine" : "take_theirs"; + void this.client.resolveHold(hold.id, how, "body" in choice ? choice.body : undefined) + .then(() => { if (this.hold?.id === hold.id) this.setHold(null); }) + .catch((error: unknown) => { this.problem = problemFrom(error); this.emit(); }); + return; + } + const remote = this.remote; + if (!remote || remote.body === undefined) return; + const dismissed = this.conflicts; + this.remote = null; + this.conflicts = []; + this.problem = null; + this.base = withBody(remote, remote.body ?? ""); + if ("body" in choice) this.body = choice.body; + else if (choice.keep === "theirs") { this.body = this.base.body; this.patch = {}; } + for (const entry of dismissed) void this.client.dismissConflict(entry).catch(() => {}); + this.emit(); + if (this.dirtyLocally()) void this.save(); + } + + /** Write local changes now (joins a save in flight). */ + save(): Promise { + clearTimeout(this.timer); + if (this.submitting) return this.submitting; + if (this.disposed || this.deleted || this.remote || this.hold) return Promise.resolve(); + if (!this.unknownMutation && !this.dirtyLocally()) return Promise.resolve(); + const job = this.write().finally(() => { + if (this.submitting === job) this.submitting = null; + this.emit(); + if (this.dirtyLocally() && !this.problem) this.schedule(); + }); + this.submitting = job; + this.emit(); + return job; + } + + async flush(options: { timeoutMs?: number } = {}): Promise { + const deadline = Date.now() + (options.timeoutMs ?? 15_000); + for (;;) { + if (this.deleted) return { ok: false, problem: nextProblem("not_found") }; + if (this.remote || this.hold) return { ok: false, problem: nextProblem("conflict") }; + await this.save(); + if (this.problem) return { ok: false, problem: this.problem }; + if (this.unknownMutation) return { ok: false, problem: nextProblem("outcome_unknown") }; + if (!this.dirtyLocally() && !this.unknownMutation && !this.submitting) return { ok: true }; + if (Date.now() > deadline) return { ok: false, problem: nextProblem("unavailable") }; + } + } + + dispose(): void { + this.disposed = true; + clearTimeout(this.timer); + this.listeners.clear(); + } + + /** Nothing waits to be written or confirmed. */ + get idle(): boolean { + return !this.dirtyLocally() && !this.submitting && !this.pending.size && !this.unknownMutation; + } + + private edited(): void { + if (this.problem && this.problem.recovery !== "wait") this.problem = null; + this.emit(); + this.schedule(); + } + + private schedule(): void { + clearTimeout(this.timer); + if (this.idleMs === false || this.disposed) return; + this.timer = setTimeout(() => void this.save(), this.idleMs); + } + + private dirtyLocally(): boolean { + if (this.body !== this.base.body) return true; + const saved = plainFrontmatter(this.base); + return Object.entries(this.patch).some(([key, value]) => !same(saved[key], value)); + } + + private async write(): Promise { + if (this.unknownMutation && !(await this.recover(this.unknownMutation))) return; + if (!this.dirtyLocally()) return; + const base = this.base, sentBody = this.body, sentPatch = { ...this.patch }; + let write: Write; + try { + write = await this.client.update(base, { + ...(sentBody !== base.body ? { body: sentBody } : {}), + ...(Object.keys(sentPatch).length ? { patch: asPlain(sentPatch) } : {}), + }, { include: WITH_BODY }); + } catch (error) { + await this.failed(error, undefined); + return; + } + if (write.state === "rejected" || write.state === "unknown") { + await this.failed(write.receipt.problem ? { wire: write.receipt.problem } : nextProblem(write.state === "unknown" ? "outcome_unknown" : "internal"), write.state === "unknown" ? write.mutationId : undefined); + return; + } + this.problem = null; + const optimistic = write.records.find((r) => r.id === base.id); + this.adopt(optimistic ? withBody(optimistic, optimistic.body ?? sentBody) : { ...base, body: sentBody }, sentPatch); + if (write.state === "confirmed") { void this.confirmed(write.receipt, sentBody); return; } + this.pending.set(write, sentBody); + write.confirmed.then( + (receipt) => { this.pending.delete(write); return this.confirmed(receipt, sentBody); }, + (error: unknown) => { this.pending.delete(write); return this.failed(error, isMdbaseError(error, "outcome_unknown") ? write.mutationId : undefined); }, + ).finally(() => this.emit()); + } + + /** The replica accepted a write: the local text now descends from what it returned. */ + private adopt(view: RecordView & { body: string }, sentPatch: JsonObject): void { + this.base = view; + for (const [key, value] of Object.entries(sentPatch)) if (same(this.patch[key], value)) delete this.patch[key]; + } + + private async confirmed(receipt: Receipt, sentBody: string): Promise { + const conflicts = (receipt.conflicts ?? []).filter((c) => c.id === this.base.id); + if (receipt.status === "conflicted" && conflicts.length) { + // The replica kept another side and recorded ours as lost. + const remote = await this.client.get(this.base.id, WITH_BODY).catch(() => null); + if (!remote) return; + this.remote = remote; + this.conflicts = conflicts.map((conflict) => ({ mutation: receipt.mutation, seq: receipt.seq ?? 0, conflict })); + if (this.body === this.base.body) this.body = sentBody; + this.emit(); + return; + } + if (this.pending.size || this.submitting) return; + // A merged result differs from what we sent: take it when nothing was typed since. + const confirmed = receipt.records?.find((r) => r.id === this.base.id); + if (!confirmed || confirmed.revision === this.base.revision) { + if (this.stale && !this.dirtyLocally()) void this.refresh(); + return; + } + if (this.dirtyLocally()) return; + const view = confirmed.body !== undefined ? confirmed : await this.client.get(this.base.id, WITH_BODY).catch(() => null); + if (!view || this.dirtyLocally() || this.pending.size || this.submitting) return; + this.base = withBody(view, view.body ?? ""); + this.body = this.base.body; + this.stale = false; + this.emit(); + } + + /** Look up an interrupted write's receipt; true when it is safe to write again. */ + private async recover(mutation: string): Promise { + let receipt: Receipt; + try { receipt = await this.client.receipt(mutation); } + catch (error) { + if (isMdbaseError(error, "not_found")) { this.unknownMutation = undefined; return true; } // It never arrived. + this.problem = problemFrom(error); + return false; + } + if (receipt.state === "unknown") return false; + this.unknownMutation = undefined; + if (receipt.state === "rejected") { await this.failed({ wire: receipt.problem }, undefined); return false; } + const view = await this.client.get(this.base.id, WITH_BODY).catch(() => null); + if (view) this.base = withBody(view, view.body ?? ""); + this.problem = null; + return true; + } + + private async failed(error: unknown, unknownMutation: string | undefined): Promise { + const problem = isWire(error) ? problemFromWire(error.wire) : isNextProblem(error) ? error : problemFrom(error); + if (problem.code === "outcome_unknown" && unknownMutation) { + this.unknownMutation = unknownMutation; + this.problem = null; + this.emit(); + this.schedule(); + return; + } + // The optimistic record was rolled back: read what the record is now. + let current: RecordView | null = null; + try { current = await this.client.get(this.base.id, WITH_BODY); } + catch (readError) { if (isMdbaseError(readError, "not_found")) { this.markDeleted(); return; } } + if (problem.code === "not_found") { this.markDeleted(); return; } + if (problem.code === "conflict" && current) { + this.remote = current; + this.problem = null; + } else { + if (current && !this.pending.size) this.base = withBody(current, current.body ?? ""); + this.problem = problem; + if (problem.recovery === "wait") this.retryLater(); + } + this.emit(); + } + + private retryLater(): void { + clearTimeout(this.timer); + if (this.disposed) return; + this.timer = setTimeout(() => { this.problem = null; void this.save(); }, 5_000); + } + + private compute(): RecordSessionSnapshot { + const remote = this.hold + ? { path: this.base.path, revision: this.base.revision, types: this.base.types, frontmatter: plainFrontmatter(this.base), body: holdText(this.hold.theirs) ?? this.base.body } + : this.remote ? sessionRecord(this.remote) : null; + const local = this.dirtyLocally(); + const state: RecordSessionState = this.deleted ? "deleted" + : remote ? "conflict" + : this.unknownMutation ? "recovery" + : this.problem ? "error" + : this.submitting || this.pending.size ? "saving" + : local ? "unsaved" : "saved"; + return { + state, + body: this.body, + frontmatter: { ...plainFrontmatter(this.base), ...this.patch }, + record: sessionRecord(this.base), + remote, + dirty: local || Boolean(this.submitting) || Boolean(this.unknownMutation), + problem: this.problem, + }; + } + + private emit(): void { + if (this.disposed) return; + this.current = this.compute(); + for (const listener of [...this.listeners]) listener(); + } +} + +function withBody(view: RecordView, body: string): RecordView & { body: string } { + return { ...view, body }; +} + +function isWire(value: unknown): value is { wire: { code: string; reason?: string } | undefined } { + return typeof value === "object" && value !== null && "wire" in value; +} + +function isNextProblem(value: unknown): value is NextProblem { + return typeof value === "object" && value !== null && "recovery" in value && "code" in value && !(value instanceof Error); +} diff --git a/apps/writer/src/backend/next.test.ts b/apps/writer/src/backend/next.test.ts new file mode 100644 index 0000000..12210ed --- /dev/null +++ b/apps/writer/src/backend/next.test.ts @@ -0,0 +1,360 @@ +import { connect, ERROR_CODES, mdbaseError, type ErrorCode, type MdbaseClient, type Write } from "@mdbase-dev/sdk"; +import { MemoryReplica } from "@mdbase-dev/sdk/testing"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ManuscriptWorkspace } from "../workspace/workspace.js"; +import { NextBackend } from "./next.js"; +import { nextProblem, problemFrom } from "./next-errors.js"; +import type { CollectionDelta, RecordLease } from "./types.js"; + +vi.mock("../compile/client.js", () => ({ CompileClient: class { + ready = Promise.resolve(0); + send = vi.fn(); + onResult = () => () => {}; + onFailure = () => () => {}; + terminate() {} +} })); + +const owned: { dispose(): void }[] = []; +const app = { name: "writer-test", version: "0" }; + +async function client(replica: MemoryReplica): Promise { + const c = await connect({ app, connector: replica.connector(), reconnect: false }); + owned.push({ dispose: () => c.close() }); + return c; +} + +async function fixture() { + // Confirmation is explicit (`confirmAll`) so pending and confirmed can be told apart. + const replica = new MemoryReplica({ confirmDelayMs: null }); + replica.seed({ path: "manuscripts/paper.md", types: ["writer-manuscript"], frontmatter: { type: "writer-manuscript", title: "Paper", csl: "apa" }, body: "# Intro\n\n![[chapters/one]]\n" }); + replica.seed({ path: "chapters/one.md", body: "# One\n\nFirst paragraph.\n\nSecond paragraph.\n" }); + replica.seed({ path: "sources/darwin.md", types: ["reader-source"], frontmatter: { type: "reader-source", csl: new Map([["id", "darwin1859"], ["title", "Origin"], ["type", "book"]]) }, body: "" }); + replica.seed({ path: "people/jordan.md", types: ["person"], frontmatter: { type: "person", name: "Jordan Ellis" }, body: "" }); + replica.seed({ path: "comments/c1.md", types: ["comment"], frontmatter: { type: "comment", document: "[[chapters/one]]", motivation: "commenting", status: "open", created_at: "2026-10-01T00:00:00Z" }, body: "Expand this.\n" }); + const db = await client(replica); + await db.files.upload("figures/plot.svg", new TextEncoder().encode("")); + replica.confirmAll(); + const backend = new NextBackend(db, { collectionName: "Test" }); + owned.push(backend); + return { replica, db, backend }; +} + +async function open(backend: NextBackend, path: string, idleMs: number | false = 10): Promise { + const opened = await backend.records.open(path, { autosave: idleMs === false ? false : { idleMs } }); + if (!opened.ok) throw new Error(opened.problem.message ?? opened.problem.code); + owned.push({ dispose: () => opened.value.release() }); + return opened.value; +} + +beforeEach(() => { + const values = new Map(); + vi.stubGlobal("localStorage", { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value), removeItem: (key: string) => values.delete(key) }); +}); +afterEach(() => { + for (const item of owned.splice(0).reverse()) item.dispose(); + vi.unstubAllGlobals(); +}); + +describe("NextBackend discovery", () => { + it("lists manuscripts, sources and files from a live query without reading bodies", async () => { + const { db, backend } = await fixture(); + const get = vi.spyOn(db, "get"); + const manuscripts = await backend.listManuscripts(); + expect(manuscripts).toEqual({ ok: true, value: [expect.objectContaining({ path: "manuscripts/paper.md", title: "Paper", style: "apa" })] }); + const index = await backend.index(); + expect(index.ok && [...index.value.recordPaths].sort()).toEqual(["chapters/one.md", "comments/c1.md", "manuscripts/paper.md", "people/jordan.md", "sources/darwin.md"]); + expect(index.ok && index.value.filePaths).toEqual(["figures/plot.svg"]); + expect(index.ok && index.value.notePaths).toContain("chapters/one.md"); + const library = await backend.library(); + expect(library.ok && library.value.map((e) => e.key)).toEqual(["darwin1859"]); + // The discovery subscription carries frontmatter only. + expect(backend["live"].records.length).toBe(5); + expect(backend["live"].records.every((r) => r.body === undefined)).toBe(true); + expect(get).not.toHaveBeenCalled(); + }); + + it("applies live diffs to the lists and tells listeners which paths changed", async () => { + const { replica, backend } = await fixture(); + await backend.listManuscripts(); + const deltas: CollectionDelta[] = []; + const paths: string[][] = []; + backend.onCollectionChange((delta) => deltas.push(delta)); + backend.onExternalChange((changed) => paths.push([...changed])); + const other = await client(replica); + await other.create({ path: "manuscripts/second.md", type: "writer-manuscript", frontmatter: { type: "writer-manuscript", title: "A second" }, body: "" }); + await vi.waitFor(() => expect(deltas.some((d) => d.manuscripts?.some((m) => m.title === "A second"))).toBe(true)); + expect(paths).toContainEqual(["manuscripts/second.md"]); + const listed = await backend.listManuscripts(); + expect(listed.ok && listed.value.map((m) => m.title)).toEqual(["A second", "Paper"]); + }); +}); + +describe("NextBackend bodies", () => { + it("reads a body on demand, once, and again after it changes", async () => { + const { replica, db, backend } = await fixture(); + const get = vi.spyOn(db, "get"); + expect(await backend.readBody("chapters/one.md")).toEqual({ ok: true, value: "# One\n\nFirst paragraph.\n\nSecond paragraph.\n" }); + await backend.readBody("chapters/one.md"); + expect(get).toHaveBeenCalledTimes(1); + expect(get.mock.calls[0]?.[1]).toEqual({ body: true }); + const other = await client(replica); + const view = await other.get({ path: "chapters/one.md" }, { body: true }); + await other.update(view, { body: "# One\n\nRewritten.\n" }); + await vi.waitFor(async () => expect(await backend.readBody("chapters/one.md")).toEqual({ ok: true, value: "# One\n\nRewritten.\n" })); + }); + + it("reads comment bodies only for the requested manuscript records", async () => { + const { db, backend } = await fixture(); + const get = vi.spyOn(db, "find"); + const comments = await backend.comments(["chapters/one.md"]); + expect(comments.ok && comments.value.map((c) => [c.path, c.text.trim()])).toEqual([["comments/c1.md", "Expand this."]]); + expect(get).toHaveBeenCalledTimes(1); + expect(await backend.comments(["manuscripts/paper.md"])).toEqual({ ok: true, value: [] }); + }); + + it("lists and downloads files", async () => { + const { backend } = await fixture(); + await backend.index(); + const bytes = await backend.readFile("figures/plot.svg"); + expect(bytes.ok && new TextDecoder().decode(bytes.value)).toBe(""); + expect(await backend.readFile("figures/missing.png")).toEqual({ ok: false, message: "No file at figures/missing.png." }); + }); +}); + +describe("NextBackend record sessions", () => { + it("autosaves an edit, is pending until confirmed, then saved", async () => { + const { replica, backend } = await fixture(); + const { session } = await open(backend, "chapters/one.md"); + expect(session.getSnapshot()).toMatchObject({ state: "saved", dirty: false, body: "# One\n\nFirst paragraph.\n\nSecond paragraph.\n" }); + session.setBody("# One\n\nFirst paragraph, edited.\n\nSecond paragraph.\n"); + expect(session.getSnapshot()).toMatchObject({ state: "unsaved", dirty: true }); + // Accepted by the replica (pending): no longer dirty, not yet confirmed. + await vi.waitFor(() => expect(session.getSnapshot()).toMatchObject({ state: "saving", dirty: false })); + expect(replica.allRecords.find((r) => r.path === "chapters/one.md")).toMatchObject({ pending: true, body: "# One\n\nFirst paragraph, edited.\n\nSecond paragraph.\n" }); + replica.confirmAll(); + await vi.waitFor(() => expect(session.getSnapshot().state).toBe("saved")); + expect(replica.allRecords.find((r) => r.path === "chapters/one.md")).toMatchObject({ pending: false }); + expect(session.getSnapshot().record.body).toBe("# One\n\nFirst paragraph, edited.\n\nSecond paragraph.\n"); + }); + + it("sends frontmatter patches with the view they were made from", async () => { + const { replica, db, backend } = await fixture(); + const update = vi.spyOn(db, "update"); + const { session } = await open(backend, "manuscripts/paper.md", false); + session.patchFrontmatter({ csl: "chicago-notes-bibliography" }); + expect(await session.flush()).toEqual({ ok: true }); + expect(update.mock.calls[0]?.[0]).toMatchObject({ path: "manuscripts/paper.md" }); + expect(update.mock.calls[0]?.[1]).toEqual({ patch: { csl: "chicago-notes-bibliography" } }); + replica.confirmAll(); + await vi.waitFor(() => expect(session.getSnapshot()).toMatchObject({ state: "saved", frontmatter: expect.objectContaining({ csl: "chicago-notes-bibliography" }) })); + }); + + it("shows a second client's edit live when there is nothing local", async () => { + const { replica, backend } = await fixture(); + const { session } = await open(backend, "chapters/one.md"); + const seen = vi.fn(); + session.subscribe(seen); + const other = await client(replica); + const view = await other.get({ path: "chapters/one.md" }, { body: true }); + await other.update(view, { body: "# One\n\nFirst paragraph.\n\nSecond paragraph, from elsewhere.\n" }); + await vi.waitFor(() => expect(session.getSnapshot().body).toBe("# One\n\nFirst paragraph.\n\nSecond paragraph, from elsewhere.\n")); + expect(session.getSnapshot()).toMatchObject({ state: "saved", dirty: false }); + expect(seen).toHaveBeenCalled(); + }); + + it("keeps local edits over a concurrent change, and surfaces a rejected merge as a conflict", async () => { + const { replica, backend } = await fixture(); + const { session } = await open(backend, "chapters/one.md", false); + session.setBody("# One\n\nFirst paragraph, mine.\n\nSecond paragraph.\n"); + const other = await client(replica); + const view = await other.get({ path: "chapters/one.md" }, { body: true }); + await (await other.update(view, { body: "# One\n\nFirst paragraph.\n\nSecond paragraph, theirs.\n" })).receipt; + replica.confirmAll(); + // The live change arrives but does not replace unsaved text. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(session.getSnapshot()).toMatchObject({ state: "unsaved", body: "# One\n\nFirst paragraph, mine.\n\nSecond paragraph.\n" }); + // MemoryReplica does not merge: the body edit's base no longer matches, so the receipt is a `conflict` rejection. + const flushed = await session.flush(); + expect(flushed).toMatchObject({ ok: false, problem: { code: "conflict" } }); + expect(session.getSnapshot()).toMatchObject({ state: "conflict", body: "# One\n\nFirst paragraph, mine.\n\nSecond paragraph.\n", remote: { body: "# One\n\nFirst paragraph.\n\nSecond paragraph, theirs.\n" } }); + session.resolve({ body: "# One\n\nFirst paragraph, mine.\n\nSecond paragraph, theirs.\n" }); + await vi.waitFor(() => expect(session.getSnapshot()).toMatchObject({ state: "saving", dirty: false })); + replica.confirmAll(); + await vi.waitFor(() => expect(session.getSnapshot().state).toBe("saved")); + expect(replica.allRecords.find((r) => r.path === "chapters/one.md")?.body).toBe("# One\n\nFirst paragraph, mine.\n\nSecond paragraph, theirs.\n"); + }); + + it("maps a rejected receipt to an error the workspace shows, keeping the text", async () => { + const { replica, db, backend } = await fixture(); + const update = vi.spyOn(db, "update"); + const { session } = await open(backend, "chapters/one.md", false); + session.setBody("# One\n\nNot valid here.\n"); + expect(await session.flush()).toEqual({ ok: true }); + const write = await (update.mock.results[0]?.value as Promise); + replica.reject(write.mutationId, { code: "invalid_record", recovery: "fix_request", message: "missing field" }); + await vi.waitFor(() => expect(session.getSnapshot().state).toBe("error")); + // (MemoryReplica does not roll a rejected record back, so the base is not compared here.) + expect(session.getSnapshot()).toMatchObject({ body: "# One\n\nNot valid here.\n", problem: { code: "invalid_record", message: nextProblem("invalid_record").message } }); + }); + + it("marks a session deleted when the record goes away", async () => { + const { replica, backend } = await fixture(); + const { session } = await open(backend, "chapters/one.md"); + const other = await client(replica); + await other.delete(await other.get({ path: "chapters/one.md" })); + await vi.waitFor(() => expect(session.getSnapshot().state).toBe("deleted")); + }); + + it("opens one shared session per record", async () => { + const { backend } = await fixture(); + const a = await open(backend, "chapters/one.md"); + const b = await open(backend, "chapters/one.md"); + expect(a.session).toBe(b.session); + expect(await backend.records.open("chapters/missing.md")).toMatchObject({ ok: false, problem: { code: "not_found" } }); + }); + + it("drives a manuscript workspace", async () => { + const { replica, backend } = await fixture(); + const workspace = new ManuscriptWorkspace(backend, "manuscripts/paper.md"); + owned.push({ dispose: () => void workspace.dispose() }); + await vi.waitFor(() => expect(workspace.getSnapshot().phase).toBe("ready")); + await vi.waitFor(() => expect(workspace.getSnapshot().records.has("chapters/one.md")).toBe(true)); + workspace.setBody("chapters/one.md", "# One\n\nFrom the workspace.\n"); + expect(await workspace.retrySave()).toEqual({ ok: true, value: undefined }); + replica.confirmAll(); + await vi.waitFor(() => expect(workspace.getSnapshot().records.get("chapters/one.md")?.snapshot.state).toBe("saved")); + expect(workspace.hasUnsavedChanges()).toBe(false); + }); +}); + +describe("NextBackend writes", () => { + it("retries a create at a numbered path when the path is taken", async () => { + const { replica, backend } = await fixture(); + expect(await backend.createManuscript({ title: "Paper", template: "article", style: "apa" })).toEqual({ ok: true, value: "manuscripts/paper-2.md" }); + expect(await backend.createRecord("chapters/one.md", "# Another\n")).toEqual({ ok: true, value: "chapters/one-2.md" }); + expect(replica.allRecords.find((r) => r.path === "manuscripts/paper-2.md")).toMatchObject({ types: ["writer-manuscript"], pending: true }); + }); + + it("retries at the next numbered path when the log rejects the path later, carrying edits made meanwhile", async () => { + const { replica, db, backend } = await fixture(); + const create = vi.spyOn(db, "create"); + const moves: { from: string; to: string }[] = []; + backend.onRecordMoved((move) => moves.push(move)); + expect(await backend.createManuscript({ title: "Late", template: "article", style: "apa" })).toEqual({ ok: true, value: "manuscripts/late.md" }); + const first = await (create.mock.results[0]?.value as Promise); + const { session } = await open(backend, "manuscripts/late.md", false); + session.setBody("# Introduction {#sec-intro}\n\nWritten before the log answered.\n"); + await vi.waitFor(async () => { const listed = await backend.listManuscripts(); expect(listed.ok && listed.value.map((m) => m.path)).toContain("manuscripts/late.md"); }); + // The log finds the path taken after the replica accepted the create. + replica.reject(first.mutationId, { code: "conflict", recovery: "resolve_conflict", message: "taken at head", reason: "path_taken" }); + await vi.waitFor(() => expect(moves).toEqual([{ from: "manuscripts/late.md", to: "manuscripts/late-2.md" }])); + expect(create).toHaveBeenCalledTimes(2); + const retry = await (create.mock.results[1]?.value as Promise); + expect(retry.mutationId).not.toBe(first.mutationId); + expect(retry.records[0]?.id).not.toBe(first.records[0]?.id); + // MemoryReplica (c573c96) rolls the rejected optimistic create back. + expect(replica.allRecords.find((r) => r.path === "manuscripts/late.md")).toBeUndefined(); + expect(replica.allRecords.find((r) => r.path === "manuscripts/late-2.md")).toMatchObject({ body: "# Introduction {#sec-intro}\n\nWritten before the log answered.\n", types: ["writer-manuscript"] }); + await vi.waitFor(async () => { + const listed = await backend.listManuscripts(); + expect(listed.ok && listed.value.map((m) => m.path)).toEqual(["manuscripts/late-2.md", "manuscripts/paper.md"]); + }); + await vi.waitFor(() => expect(session.getSnapshot().state).toBe("deleted")); + }); + + it("moves a new chapter's embed and session to the retried path", async () => { + const { replica, db, backend } = await fixture(); + const create = vi.spyOn(db, "create"); + const workspace = new ManuscriptWorkspace(backend, "manuscripts/paper.md"); + owned.push({ dispose: () => void workspace.dispose() }); + await vi.waitFor(() => expect(workspace.getSnapshot().phase).toBe("ready")); + expect(await workspace.addChapter("Two")).toEqual({ ok: true, value: "chapters/two.md" }); + const first = await (create.mock.results[0]?.value as Promise); + replica.reject(first.mutationId, { code: "conflict", recovery: "resolve_conflict", message: "taken at head", reason: "path_taken" }); + await vi.waitFor(() => expect(workspace.getSnapshot().records.has("chapters/two-2.md")).toBe(true)); + const snap = workspace.getSnapshot(); + expect(snap.records.has("chapters/two.md")).toBe(false); + expect(snap.records.get("manuscripts/paper.md")?.snapshot.body).toContain("![[chapters/two-2]]"); + expect(snap.records.get("manuscripts/paper.md")?.snapshot.body).not.toContain("![[chapters/two]]"); + expect(snap.recordPaths).toContain("chapters/two-2.md"); + }); + + it("reports a late rejection that is not a path collision", async () => { + const { replica, db, backend } = await fixture(); + const create = vi.spyOn(db, "create"); + expect(await backend.createRecord("notes/new.md", "")).toEqual({ ok: true, value: "notes/new.md" }); + const first = await (create.mock.results[0]?.value as Promise); + replica.reject(first.mutationId, { code: "forbidden", recovery: "reauthorize", message: "no" }); + await vi.waitFor(() => expect(backend["sync"].writeProblem).toBe(`notes/new.md: ${nextProblem("forbidden").message}`)); + expect(create).toHaveBeenCalledTimes(1); + }); + + it("does not retry a create for other problems", async () => { + const { db, backend } = await fixture(); + const create = vi.spyOn(db, "create").mockRejectedValue(mdbaseError("forbidden", "no")); + expect(await backend.createRecord("notes/new.md", "")).toEqual({ ok: false, message: nextProblem("forbidden").message }); + expect(create).toHaveBeenCalledTimes(1); + }); + + it("writes comments and changes them with a patch", async () => { + const { replica, backend } = await fixture(); + const created = await backend.createComment({ document: "chapters/one.md", text: "A new thought" }); + expect(created.ok && created.value).toMatchObject({ document: "[[chapters/one]]", status: "open" }); + const path = created.ok ? created.value.path : ""; + expect(replica.allRecords.find((r) => r.path === path)).toMatchObject({ types: ["comment"], body: "A new thought\n" }); + const thread = await backend.comments(["chapters/one.md"]); + const comment = thread.ok ? thread.value.find((c) => c.path === "comments/c1.md") : undefined; + const resolved = await backend.changeComment(comment!, { kind: "resolve" }); + expect(resolved.ok && resolved.value.status).toBe("resolved"); + expect(replica.allRecords.find((r) => r.path === "comments/c1.md")?.frontmatter.get("status")).toBe("resolved"); + }); + + it("reads person names but signs nothing (no people directory yet)", async () => { + const { backend } = await fixture(); + const people = await backend.people(); + expect(people.names.get("people/jordan")).toBe("Jordan Ellis"); + expect(people.me).toBeUndefined(); + expect(people.signing?.kind).toBe("unavailable"); + }); +}); + +describe("mdbase-next problems", () => { + it("has Writer text and a recovery for every SDK error code", () => { + for (const code of Object.keys(ERROR_CODES) as ErrorCode[]) { + const problem = nextProblem(code); + expect(problem.code).toBe(code); + expect(problem.message.length).toBeGreaterThan(5); + expect(problem.recovery).toBeTruthy(); + } + expect(Object.keys(ERROR_CODES)).toHaveLength(15); + }); + + it("treats no device online as waiting, not an error", () => { + expect(problemFrom(mdbaseError("unavailable", "x", "no_device_online"))).toMatchObject({ code: "unavailable", waitingForDevice: true, recovery: "wait", message: "Waiting for one of your devices to come online." }); + expect(problemFrom(mdbaseError("unavailable", "x"))).not.toHaveProperty("waitingForDevice"); + expect(problemFrom(mdbaseError("unauthenticated", "x"))).toMatchObject({ recovery: "reconnect" }); + expect(problemFrom(new Error("boom"))).toMatchObject({ code: "internal", recovery: "report" }); + }); + + it("maps a refused connection", async () => { + const replica = new MemoryReplica({ refuse: { code: "upgrade_required", recovery: "upgrade", message: "too old" } }); + const failed = await connect({ app, connector: replica.connector(), reconnect: false }).catch((error: unknown) => error); + expect(problemFrom(failed)).toMatchObject({ code: "upgrade_required", recovery: "upgrade" }); + }); +}); + +describe("next-demo", () => { + it("serves the bundled demo manuscripts through NextBackend", async () => { + vi.stubGlobal("fetch", async () => new Response(null, { status: 404 })); + const { createNextDemoBackend } = await import("./next-demo.js"); + const backend = await createNextDemoBackend(); + owned.push(backend); + const manuscripts = await backend.listManuscripts(); + expect(manuscripts.ok && manuscripts.value.map((m) => m.path)).toContain("manuscripts/patient-observation.md"); + const library = await backend.library(); + expect(library.ok && library.value.length).toBeGreaterThan(0); + const comments = await backend.comments(); + expect(comments.ok && comments.value.length).toBe(3); + }); +}); diff --git a/apps/writer/src/backend/next.ts b/apps/writer/src/backend/next.ts new file mode 100644 index 0000000..4f4731a --- /dev/null +++ b/apps/writer/src/backend/next.ts @@ -0,0 +1,566 @@ +// The writer on an mdbase-next replica through `@mdbase-dev/sdk` (opt-in). +// +// - Discovery is one live query over the collection without bodies: its +// snapshot fills the same CollectionStore as Connect discovery, and its +// diffs replace watch polling and the `readMany` refetch. +// - Bodies are read on demand with `get(id, { body: true })`, only for the +// records a view needs (open records, comment threads, a source's +// annotations). +// - Record sessions save with `update(view, { patch, body })`, so concurrent +// edits merge on the replica (see next-session.ts). +// - Files are listed once and kept current from the change feed. +import { isMdbaseError, toPlain, type CreateInput, type FileView, type Hold, type LinkState, type LiveQuery, type LiveQueryState, type MdbaseClient, type QueryUpdate, type RecordView, type SyncStatus, type Write } from "@mdbase-dev/sdk"; +import type { CollectionDescription, JsonObject, QueryRecord } from "@mdbase-dev/connect"; +import { commentFromRecord, type CommentRecord } from "@mdbase-writer/core/comments"; + +import { mapConcurrent } from "../async.js"; +import { CollectionBodies } from "./bodies.js"; +import { CollectionCache } from "./cache.js"; +import { CollectionSchema, CollectionStore, starterSchema, type Binding, type CollectionRow, type StoreDelta } from "./collection.js"; +import { changeFields, commentPath, newCommentFields, toContract, toLocal, type CommentChange, type NewComment, type People } from "./comments.js"; +import { isPathTaken, problemFrom, problemFromWire, type NextProblem } from "./next-errors.js"; +import { asPlain, NextRecordSession, plainFrontmatter } from "./next-session.js"; +import { + fail, + manuscriptBody, + manuscriptSlug, + numberedPath, + ok, + titleFromNote, + withType, + type CollectionDelta, + type CollectionIndex, + type LibraryEntry, + type ManuscriptSummary, + type NewManuscript, + type RecordOpenOptions, + type Result, + type SessionProblem, + type SourceAnnotation, + type WriterBackend, + type WriterRecords, +} from "./types.js"; + +const WITH_BODY = { body: true } as const; +type ChangesResult = Parameters[1]>[0]; +const BODY_READS = 8; +const CREATE_ATTEMPTS = 20; + +export interface NextBackendOptions { + readonly collectionName?: string; + /** Stable, non-secret identity for local drafts. Defaults to the collection ID. */ + readonly draftNamespace?: string; + /** Close the client when the backend is disposed. */ + readonly ownsClient?: boolean; +} + +/** The link, sync status and holds, for a status line or a waiting screen. */ +export interface NextSync { + readonly link: LinkState; + /** Why the link is not open. `waitingForDevice` is a state, not an error. */ + readonly problem: NextProblem | null; + readonly status: SyncStatus; + readonly holds: number; + /** The last write that the log rejected after the replica accepted it. */ + readonly writeProblem?: string; +} + +const fieldsOf = (fm: JsonObject, binding: Binding) => (Object.keys(binding.fields).length ? toContract(fm, binding.fields) : fm); + +function row(view: RecordView): CollectionRow { + const frontmatter = plainFrontmatter(view); + return { path: view.path, types: view.types, frontmatter, effectiveFrontmatter: view.effective ? (toPlain(view.effective) as JsonObject) : frontmatter }; +} + +/** + * Writer's type bindings from `describe()`. A replica that reports contract + * implementations gets them; one that does not (MemoryReplica, and replicas + * until `describe` carries contracts) is read with the starter type names. + */ +export function nextSchema(described: unknown): CollectionSchema { + const d = (described && typeof described === "object" && !Array.isArray(described) ? described : {}) as Record; + const list = (value: unknown) => (Array.isArray(value) ? value.filter((v): v is Record => !!v && typeof v === "object") : []); + const text = (value: unknown) => (typeof value === "string" ? value : undefined); + const contracts: CollectionDescription["contracts"] = list(d["contracts"]).flatMap((c) => { + const id = text(c["id"]), version = text(c["version"]); + if (!id || !version) return []; + const implementations = list(c["implementations"]).flatMap((i) => { + const typeName = text(i["typeName"] ?? i["type_name"] ?? i["type"]); + const fields = i["fields"] && typeof i["fields"] === "object" ? Object.fromEntries(Object.entries(i["fields"] as Record).filter((e): e is [string, string] => typeof e[1] === "string")) : {}; + return typeName ? [{ typeName, typeVersion: 1, digest: "next", fields }] : []; + }); + return [{ id, version, contractType: "record" as const, digest: "next", schema: {}, implementations }]; + }); + if (!contracts.length) return starterSchema(); + const types = (Array.isArray(d["types"]) ? d["types"] : []).flatMap((t: unknown) => { + const name = typeof t === "string" ? t : t && typeof t === "object" ? text((t as Record)["name"]) : undefined; + return name ? [{ name, schema: {}, extensions: {} }] : []; + }); + const configuration = d["configuration"] && typeof d["configuration"] === "object" ? (d["configuration"] as JsonObject) : undefined; + return new CollectionSchema({ types, contracts, ...(configuration ? { configuration } : {}) }); +} + +interface SessionEntry { readonly session: NextRecordSession; holders: number } + +export class NextBackend implements WriterBackend { + readonly kind = "next"; + private readonly store = new CollectionStore(starterSchema()); + get setupStatus() { return this.store.schema.setupStatus; } + private readonly bodies = new CollectionBodies(() => this.store, (metadata) => this.selectedBodies(metadata)); + private readonly bodyCache = new Map>(); + private readonly live: LiveQuery; + private readonly pathOf = new Map(); + private readonly idOf = new Map(); + private readonly files = new Map(); + private readonly sessions = new Map(); + private readonly listeners = new Set<(paths: readonly string[]) => void>(); + private readonly dataListeners = new Set<(delta: CollectionDelta) => void>(); + private readonly syncListeners = new Set<(sync: NextSync) => void>(); + private readonly moveListeners = new Set<(move: { readonly from: string; readonly to: string }) => void>(); + private readonly cleanups: (() => void)[] = []; + private readonly ready: Promise>; + private holds: readonly Hold[] = []; + private described = false; + private discovered = false; + private disposed = false; + private sync: NextSync; + + readonly records: WriterRecords = { open: (path, options) => this.openRecord(path, options) }; + + constructor(private readonly client: MdbaseClient, private readonly options: NextBackendOptions = {}) { + this.sync = { link: client.link, problem: client.linkProblem ? problemFrom(client.linkProblem) : null, status: client.status, holds: 0 }; + // Every record, frontmatter only: lists and classification never need bodies. + this.live = client.live({}, { effective: true }); + this.cleanups.push(this.live.subscribe((state, update) => this.onLive(state, update))); + this.ready = this.start(); + } + + get collectionName(): string { return this.options.collectionName ?? "Collection"; } + get draftNamespace(): string { return this.options.draftNamespace ?? this.client.collection; } + + /** Current link and sync state; `listener` runs now and on every change. */ + onSync(listener: (sync: NextSync) => void): () => void { + this.syncListeners.add(listener); + listener(this.sync); + return () => this.syncListeners.delete(listener); + } + + private setSync(next: Partial): void { + this.sync = { ...this.sync, ...next }; + for (const listener of [...this.syncListeners]) listener(this.sync); + } + + private async start(): Promise> { + this.cleanups.push( + this.client.onLink((link, why) => this.setSync({ link, problem: why ? problemFrom(why) : null })), + this.client.onStatus((status) => this.setSync({ status })), + ); + try { this.store.schema = nextSchema(toPlain(await this.client.describe())); } + catch (error) { return fail(problemFrom(error).message); } + // Classification needs the schema: a snapshot that came first is applied now. + this.described = true; + if (!this.live.stale) this.applySnapshot(this.live.records); + try { await this.live.ready; } + catch (error) { return fail(problemFrom(error).message); } + const files = await this.listFiles(); + if (!files.ok) return files; + if (this.disposed) return ok(undefined); + this.cleanups.push( + this.client.watchChanges(undefined, (batch) => void this.onChanges(batch)), + this.client.onHolds((holds) => this.onHolds(holds)), + ); + return ok(undefined); + } + + // ---------------------------------------------------------------- discovery + + private onLive(state: LiveQueryState, update: QueryUpdate | null): void { + // Stale marks and resubscribe errors keep the last good result. + if (this.disposed || !this.described || !update || update.kind === "reset") return; + if (update.kind === "snapshot") this.applySnapshot(state.records); + else this.applyDiff(update); + } + + private applySnapshot(records: readonly RecordView[]): void { + const present = new Set(records.map((r) => r.id)); + const removed = [...this.pathOf].filter(([id]) => !present.has(id)).map(([, path]) => path); + this.pathOf.clear(); + this.idOf.clear(); + for (const r of records) { this.pathOf.set(r.id, r.path); this.idOf.set(r.path, r.id); } + if (!this.discovered) { + this.store.upsert(records.map(row), true); + this.discovered = true; + return; + } + // A snapshot after a reset or reconnect: reconcile everything cached. + this.bodyCache.clear(); + this.bodies.clear(); + const upsert = this.store.upsert(records.map(row)), gone = this.store.remove(removed); + this.acceptDelta({ ...upsert, ...gone, paths: [], reset: true, annotationSources: null }); + for (const listener of this.listeners) listener([]); + for (const [id, entry] of this.sessions) { + const view = records.find((r) => r.id === id); + if (view) entry.session.receive(view); else entry.session.markDeleted(); + } + } + + private applyDiff(update: QueryUpdate): void { + const views = [...update.added ?? [], ...update.changed ?? []]; + const removed: string[] = []; + for (const id of update.removed ?? []) { + const path = this.pathOf.get(id); + if (path === undefined) continue; + removed.push(path); + this.pathOf.delete(id); + if (this.idOf.get(path) === id) this.idOf.delete(path); + } + for (const view of views) { + const before = this.pathOf.get(view.id); + if (before !== undefined && before !== view.path) { removed.push(before); this.idOf.delete(before); } + this.pathOf.set(view.id, view.path); + this.idOf.set(view.path, view.id); + } + const paths = [...new Set([...views.map((v) => v.path), ...removed])]; + if (!paths.length) return; + for (const path of paths) this.bodyCache.delete(path); + const upsert = this.store.upsert(views.map(row)), gone = this.store.remove(removed.filter((p) => !this.idOf.has(p))); + this.acceptDelta({ ...upsert, ...gone, paths, + annotationSources: upsert.annotationSources === null || gone.annotationSources === null + ? null : [...upsert.annotationSources ?? [], ...gone.annotationSources ?? []] }); + for (const listener of this.listeners) listener(paths); + for (const view of views) this.sessions.get(view.id)?.session.receive(view); + for (const id of update.removed ?? []) this.sessions.get(id)?.session.markDeleted(); + } + + private acceptDelta(delta: StoreDelta): void { + this.bodies.changed(delta); + const { annotationSources: _sources, ...rest } = delta; + this.publish(rest); + } + + private publish(delta: CollectionDelta): void { + if (!this.disposed && this.discovered) for (const listener of this.dataListeners) listener(delta); + } + + // ---------------------------------------------------------------- files + + private async listFiles(): Promise> { + const files = new Map(); + try { for await (const file of this.client.files.list()) files.set(file.path, file); } + catch (error) { return fail(problemFrom(error).message); } + const removed = [...this.files.keys()].filter((p) => !files.has(p)); + this.files.clear(); + for (const [path, file] of files) this.files.set(path, file); + const delta = this.store.files([...files.keys()], removed); + if (delta.index) this.acceptDelta(delta); + return ok(undefined); + } + + /** The change feed is a fallback here: records follow the live query, files follow this. */ + private async onChanges(batch: ChangesResult): Promise { + if (this.disposed) return; + if (batch.reset) { await this.listFiles(); return; } + const present: string[] = [], removed: string[] = []; + for (const change of batch.changes) { + if (/\.md$/i.test(change.path)) continue; + if (change.kind === "remove") { if (this.files.delete(change.path)) removed.push(change.path); continue; } + try { + const file = await this.client.files.get(change.path); + this.files.set(file.path, file); + present.push(file.path); + } catch (error) { + if (isMdbaseError(error, "not_found") && this.files.delete(change.path)) removed.push(change.path); + } + } + if (!present.length && !removed.length) return; + this.acceptDelta(this.store.files(present, removed)); + for (const listener of this.listeners) listener([...present, ...removed]); + } + + async readFile(path: string): Promise> { + const file = this.files.get(path); + if (!file) return fail(`No file at ${path}.`); + try { return ok(await this.client.files.download(file)); } + catch (error) { return fail(problemFrom(error).message); } + } + + // ---------------------------------------------------------------- holds + + private onHolds(holds: readonly Hold[]): void { + this.holds = holds; + this.setSync({ holds: holds.length }); + for (const { session } of this.sessions.values()) session.setHold(holds.find((h) => h.path === session.path) ?? null); + } + + // ---------------------------------------------------------------- record sessions + + private async openRecord(path: string, options: RecordOpenOptions = {}): Promise<{ ok: true; value: { session: NextRecordSession; release(): void } } | { ok: false; problem: SessionProblem }> { + const started = await this.ready; + if (!started.ok) return { ok: false, problem: { code: "unavailable", message: started.message } }; + const known = this.idOf.get(path); + let entry = known ? this.sessions.get(known) : undefined; + if (!entry) { + let view: RecordView; + try { view = await this.client.get(known ?? { path }, WITH_BODY, options.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : undefined); } + catch (error) { return { ok: false, problem: problemFrom(error) }; } + entry = this.sessions.get(view.id); + if (!entry) { + const idleMs = options.autosave === false ? false : options.autosave?.idleMs ?? 1_000; + const session = new NextRecordSession(this.client, { ...view, body: view.body ?? "" }, idleMs); + session.setHold(this.holds.find((h) => h.path === view.path) ?? null); + entry = { session, holders: 0 }; + this.sessions.set(view.id, entry); + } + } + const held = entry; + held.holders++; + let released = false; + return { ok: true, value: { session: held.session, release: () => { + if (released) return; + released = true; + held.holders--; + void this.retire(held); + } } }; + } + + /** Drop an unheld session once it has nothing left to save. */ + private async retire(entry: SessionEntry): Promise { + if (entry.holders > 0) return; + if (!entry.session.idle) await entry.session.flush(); + if (entry.holders > 0 || !entry.session.idle) return; + entry.session.dispose(); + if (this.sessions.get(entry.session.id) === entry) this.sessions.delete(entry.session.id); + } + + // ---------------------------------------------------------------- domains + + private async afterDiscovery(value: () => T): Promise> { + const started = await this.ready; + return started.ok ? ok(value()) : started; + } + + manuscriptBindings() { return this.afterDiscovery(() => this.store.schema.bindings("manuscript", true)).then((r) => (r.ok ? r.value : r)); } + listManuscripts(): Promise> { return this.afterDiscovery(() => this.store.manuscripts); } + index(): Promise> { return this.afterDiscovery(() => this.store.index); } + library(): Promise> { return this.afterDiscovery(() => this.store.library); } + annotationPaths(): Promise> { return this.afterDiscovery(() => [...this.store.annotations.keys()]); } + + annotationFields(path: string, types: readonly string[] = []): Readonly> { + return this.store.annotations.get(path)?.fields ?? this.store.schema.annotationFields(types); + } + + async annotationsForSource(path: string): Promise> { + const started = await this.ready; + return started.ok ? this.bodies.annotationsForSource(path) : started; + } + + async comments(scope?: readonly string[]): Promise> { + const started = await this.ready; + if (!started.ok) return started; + if (!this.setupStatus.comments) return ok([]); + return this.bodies.commentsForScope(scope); + } + + /** Bodies for the selected records only, a few reads at a time. */ + private async selectedBodies(metadata: ReadonlyMap): Promise[]>> { + try { + const rows = await mapConcurrent([...metadata.keys()], BODY_READS, async (path) => { + const view = await this.client.find(this.idOf.get(path) ?? { path }, WITH_BODY); + if (!view) return null; + const frontmatter = plainFrontmatter(view); + return { path: view.path, types: view.types, frontmatter, effectiveFrontmatter: frontmatter, body: view.body ?? "", file: { path: view.path } } satisfies QueryRecord; + }); + return ok(rows.filter((r): r is NonNullable => r !== null)); + } catch (error) { return fail(problemFrom(error).message); } + } + + readBody(path: string): Promise> { + let cache = this.bodyCache.get(path); + if (!cache) this.bodyCache.set(path, cache = new CollectionCache()); + return cache.load(async () => { + try { return ok((await this.client.get(this.idOf.get(path) ?? { path }, WITH_BODY)).body ?? ""); } + catch (error) { return fail(problemFrom(error).message); } + }); + } + + // ---------------------------------------------------------------- writes + + /** + * Creates at `pathFor(1)`, then numbered paths while the replica answers + * `conflict` (`path_taken`). Resolves once the replica holds the record + * (pending). The same applies when the log rejects the path later: the + * rejected record is rolled back, a fresh create (new record and mutation + * IDs) tries the next numbered path, and `onRecordMoved` reports the move. + */ + private async createAt(pathFor: (n: number) => string, input: Omit, from = 1): Promise> { + let last = ""; + for (let n = from; n <= CREATE_ATTEMPTS; n++) { + const path = pathFor(n); + try { + const write = await this.client.create({ ...input, path }); + if (write.state === "rejected" || write.state === "unknown") { + const problem = problemFromWire(write.receipt.problem, write.state === "unknown" ? "outcome_unknown" : "internal"); + if (isPathTaken(write.receipt.problem)) { last = problem.message; continue; } + return fail(problem.message); + } + const created = write.records[0]?.path ?? path; + write.confirmed.catch((error: unknown) => this.lateCreateFailure(error, write, created, pathFor, input, n)); + return ok(created); + } catch (error) { + if (isMdbaseError(error) && isPathTaken(error)) { last = problemFrom(error).message; continue; } + return fail(problemFrom(error).message); + } + } + return fail(last); + } + + private async lateCreateFailure(error: unknown, write: Write, path: string, pathFor: (n: number) => string, input: Omit, n: number): Promise { + if (this.disposed) return; + if (!isMdbaseError(error) || !isPathTaken(error) || n >= CREATE_ATTEMPTS) { this.writeProblem(path, error); return; } + // Keep what was written into the optimistic record meanwhile. + const id = write.records[0]?.id; + const local = id ? this.sessions.get(id)?.session.getSnapshot() : undefined; + const carried = local ? { ...input, frontmatter: asPlain(local.frontmatter), body: local.body } : input; + const retried = await this.createAt(pathFor, carried, n + 1); + if (!retried.ok) { this.writeProblem(path, retried.message); return; } + if (this.disposed) return; + for (const listener of [...this.moveListeners]) listener({ from: path, to: retried.value }); + } + + /** A write that failed after it was accepted; shown on the status line. */ + private writeProblem(path: string, error: unknown): void { + this.setSync({ writeProblem: `${path}: ${typeof error === "string" ? error : problemFrom(error).message}` }); + } + + /** A record created here moved to another path (its first path was taken at the log). */ + onRecordMoved(listener: (move: { readonly from: string; readonly to: string }) => void): () => void { + this.moveListeners.add(listener); + return () => this.moveListeners.delete(listener); + } + + async createManuscript(input: NewManuscript): Promise> { + const started = await this.ready; + if (!started.ok) return started; + const bindings = this.store.schema.bindings("manuscript", true); + if (!bindings.ok) return bindings; + const { name, fields, typeKey } = bindings.value[0] as Binding; + const frontmatter: JsonObject = { [typeKey]: name, [fields["title"] ?? "title"]: input.title }; + if (fields["template"]) frontmatter[fields["template"]] = input.template; + if (fields["csl"]) frontmatter[fields["csl"]] = input.style; + const slug = manuscriptSlug(input.title); + const body = manuscriptBody(input.starter, input.starter ? this.store.library[0]?.key : undefined); + return this.createAt((n) => `manuscripts/${slug}${n > 1 ? `-${n}` : ""}.md`, { type: name, frontmatter: asPlain(frontmatter), body }); + } + + createRecord(path: string, body: string): Promise> { + return this.createAt((n) => numberedPath(path, n), { frontmatter: {}, body }); + } + + async adoptManuscript(path: string): Promise> { + const started = await this.ready; + if (!started.ok) return started; + const bindings = this.store.schema.bindings("manuscript", true); + if (!bindings.ok) return bindings; + const { name, fields, typeKey } = bindings.value[0] as Binding; + try { + const view = await this.client.get(this.idOf.get(path) ?? { path }, WITH_BODY); + const fm = plainFrontmatter(view); + const titleField = fields["title"] ?? "title"; + const patch: JsonObject = { [typeKey]: withType(fm[typeKey], name) }; + if (typeof fm[titleField] !== "string" || !fm[titleField]) patch[titleField] = titleFromNote(path, view.body ?? ""); + const write = await this.client.update(view, { patch: asPlain(patch) }); + if (write.state === "rejected" || write.state === "unknown") return fail(problemFromWire(write.receipt.problem).message); + write.confirmed.catch((error: unknown) => this.writeProblem(path, error)); + return ok(write.records[0]?.path ?? path); + } catch (error) { return fail(problemFrom(error).message); } + } + + async createComment(input: NewComment): Promise> { + const started = await this.ready; + if (!started.ok) return started; + const bindings = this.store.schema.bindings("comment", true); + if (!bindings.ok) return bindings; + const type = bindings.value[0] as Binding; + const now = new Date(); + const fields = newCommentFields(input, now, (await this.people()).me?.link); + const text = input.text.trim(); + const created = await this.createAt(() => commentPath(now), { + type: type.name, + frontmatter: asPlain({ [type.typeKey]: type.name, ...toLocal(fields, type.fields) }), + body: text ? `${text}\n` : "", + }); + if (!created.ok) return created; + const comment = commentFromRecord(created.value, fields, input.text); + return comment ? ok(comment) : fail("The comment was written but could not be read back."); + } + + async changeComment(comment: CommentRecord, change: CommentChange): Promise> { + const started = await this.ready; + if (!started.ok) return started; + const bindings = this.store.schema.bindings("comment", true); + if (!bindings.ok) return bindings; + try { + const view = await this.client.get(this.idOf.get(comment.path) ?? { path: comment.path }, WITH_BODY); + const type = bindings.value.find((t) => view.types.includes(t.name)) ?? (bindings.value[0] as Binding); + const { fields, body } = changeFields(comment, change, new Date(), (await this.people()).me?.link); + const write = await this.client.update(view, { patch: asPlain(toLocal(fields, type.fields)), ...(body !== undefined ? { body } : {}) }); + if (write.state === "rejected" || write.state === "unknown") return fail(problemFromWire(write.receipt.problem).message); + write.confirmed.catch((error: unknown) => this.writeProblem(comment.path, error)); + const after = write.records.find((r) => r.id === view.id); + const next = commentFromRecord(comment.path, fieldsOf(after ? plainFrontmatter(after) : { ...plainFrontmatter(view), ...toLocal(fields, type.fields) }, type), body ?? after?.body ?? comment.text); + return next ? ok(next) : fail("The comment was changed but could not be read back."); + } catch (error) { return fail(problemFrom(error).message); } + } + + /** + * Person records' names from discovery. mdbase-next has no people directory + * (account → person record) yet, so new comments are not signed. + */ + async people(): Promise { + await this.ready; + return { names: this.store.people, signing: { kind: "unavailable", reason: "mdbase-next has no people directory yet, so new comments are not signed." } }; + } + + // ---------------------------------------------------------------- changes + + onExternalChange(listener: (paths: readonly string[]) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + onCollectionChange(listener: (delta: CollectionDelta) => void): () => void { + this.dataListeners.add(listener); + return () => this.dataListeners.delete(listener); + } + + /** Live diffs are applied as they arrive: there is no queue to drain. */ + async flushChanges(): Promise> { + const started = await this.ready; + return started.ok ? ok(undefined) : started; + } + + /** Ask for a fresh snapshot; it replaces everything cached. */ + async reconcile(): Promise> { + const started = await this.ready; + if (!started.ok) return started; + try { + await this.live.setQuery({}, { effective: true }); + await this.listFiles(); + return ok(undefined); + } catch (error) { return fail(problemFrom(error).message); } + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.live.close(); + for (const cleanup of this.cleanups.splice(0)) cleanup(); + for (const { session } of this.sessions.values()) session.dispose(); + this.sessions.clear(); + this.listeners.clear(); + this.dataListeners.clear(); + this.syncListeners.clear(); + this.moveListeners.clear(); + if (this.options.ownsClient) this.client.close(); + } +} diff --git a/apps/writer/src/backend/types.ts b/apps/writer/src/backend/types.ts index dcf8c16..bf895dd 100644 --- a/apps/writer/src/backend/types.ts +++ b/apps/writer/src/backend/types.ts @@ -1,6 +1,7 @@ -// What the writer needs from a collection. Two implementations: Connect (real -// collections) and a demo on the SDK's in-memory record authority. -import type { JsonObject, MdbaseRecords } from "@mdbase-dev/connect"; +// What the writer needs from a collection. Implementations: Connect (real +// collections), a demo on the SDK's in-memory record authority, and the opt-in +// mdbase-next backend (`next.ts`). +import type { JsonObject } from "@mdbase-dev/connect"; import type { CslItem, StyleId, TemplateName } from "@mdbase-writer/core"; import type { SourceAnnotation } from "@mdbase-writer/core/annotations"; import type { CommentRecord } from "@mdbase-writer/core/comments"; @@ -80,14 +81,85 @@ export interface NewManuscript { readonly starter?: boolean; } +/** + * - `saved`: the draft is the acknowledged record. + * - `unsaved`: local changes wait for autosave or `flush()`. + * - `saving`: a write is in flight (or, on mdbase-next, pending confirmation). + * - `conflict`: a newer record changed what was edited locally; see `remote`. + * - `recovery`: a write's outcome is unknown. + * - `error`: a write or refresh failed; local changes are kept. + * - `deleted`: the record no longer exists; local changes are kept. + */ +export type RecordSessionState = "saved" | "unsaved" | "saving" | "conflict" | "recovery" | "error" | "deleted"; + +export interface SessionProblem { + readonly code: string; + readonly message?: string; +} + +/** The parts of a record the workspace reads from a session. */ +export interface SessionRecord { + readonly path: string; + readonly revision: string; + readonly types: readonly string[]; + readonly frontmatter: JsonObject; + readonly body?: string; +} + +export interface RecordSessionSnapshot { + readonly state: RecordSessionState; + readonly body: string; + /** The record's frontmatter with local patches applied. */ + readonly frontmatter: JsonObject; + /** The latest record the session accepted as its base. */ + readonly record: SessionRecord; + /** The newer record that conflicts with local changes, while `state` is `conflict`. */ + readonly remote: SessionRecord | null; + /** Local changes not yet acknowledged. */ + readonly dirty: boolean; + readonly problem: SessionProblem | null; +} + +export type RecordResolution = { readonly keep: "mine" | "theirs" } | { readonly body: string }; + +/** + * One open, editable record, as the workspace uses it. Connect's + * `MdbaseRecordSession` satisfies it, as does `NextRecordSession`. + */ +export interface RecordSession { + getSnapshot(): RecordSessionSnapshot; + /** Listeners run synchronously after every change. */ + subscribe(listener: () => void): () => void; + setBody(body: string): void; + patchFrontmatter(patch: JsonObject): void; + resolve(choice: RecordResolution): void; + /** Save until every local change is acknowledged, or report why it cannot be. */ + flush(options?: { timeoutMs?: number }): Promise<{ readonly ok: true } | { readonly ok: false; readonly problem: SessionProblem }>; +} + +export interface RecordLease { + readonly session: RecordSession; + release(): void; +} + +export interface RecordOpenOptions { + readonly autosave?: { readonly idleMs: number } | false; + readonly timeoutMs?: number; +} + +/** Shared record sessions: one per path, whoever opens it. */ +export interface WriterRecords { + open(path: string, options?: RecordOpenOptions): Promise<{ readonly ok: true; readonly value: RecordLease } | { readonly ok: false; readonly problem: SessionProblem }>; +} + export interface WriterBackend { readonly setupStatus?: { readonly sources: boolean; readonly annotations: boolean; readonly comments: boolean } | undefined; - readonly kind: "connect" | "demo"; + readonly kind: "connect" | "demo" | "next"; readonly collectionName: string; /** Stable, non-secret collection identity for local draft isolation. */ readonly draftNamespace?: string; manuscriptBindings?(): Promise>; - readonly records: Pick, "open">; + readonly records: WriterRecords; listManuscripts(): Promise>; createManuscript(input: NewManuscript): Promise>; /** Marks an existing note as a manuscript (adds the manuscript type; keeps its other types). */ @@ -116,6 +188,11 @@ export interface WriterBackend { reviewIdentityAccess?(): Promise>; /** Changed paths; an empty list requests reconciliation after a watch gap or schema change. */ onExternalChange(listener: (paths: readonly string[]) => void): () => void; + /** + * A record this backend created moved to another path after it was + * accepted (its first path turned out to be taken); open views follow it. + */ + onRecordMoved?(listener: (move: { readonly from: string; readonly to: string }) => void): () => void; /** Applied, coalesced collection changes (after the shared caches are updated). */ onCollectionChange?(listener: (delta: CollectionDelta) => void): () => void; /** Await queued path reads, e.g. before navigation or export. */ diff --git a/apps/writer/src/connect/next-control.test.ts b/apps/writer/src/connect/next-control.test.ts new file mode 100644 index 0000000..e13d021 --- /dev/null +++ b/apps/writer/src/connect/next-control.test.ts @@ -0,0 +1,48 @@ +import { isMdbaseError, mdbaseError } from "@mdbase-dev/sdk"; +import { describe, expect, it, vi } from "vitest"; + +import { problemFrom } from "../backend/next-errors.js"; +import { nextStatusText } from "../ui/next-status.js"; +import { proposedControlPlane, routeFromResponse } from "./next-control.js"; + +const key = btoa(String.fromCharCode(...new Uint8Array(32).fill(7))); +const device = "0192f3a0-0000-7000-8000-000000000001"; + +describe("proposed control-plane route", () => { + it("uses the first well-formed target", () => { + const route = routeFromResponse({ targets: [{ url: "wss://relay.example/x" }, { url: "wss://relay.example/s/1", device, noise_pk: key }] }); + expect(route).toMatchObject({ url: "wss://relay.example/s/1", targetDevice: device }); + expect(route?.noisePublicKey).toEqual(new Uint8Array(32).fill(7)); + }); + + it("reads no targets as no device online", () => { + expect(routeFromResponse({ targets: [] })).toBeNull(); + expect(() => routeFromResponse({})).toThrow(); + }); + + it("calls the proposed endpoint and maps HTTP failures to SDK codes", async () => { + const fetch = vi.fn(async () => Response.json({ targets: [{ url: "wss://relay.example/s/1", device, noise_pk: key.replace(/=+$/, "") }] })); + const control = proposedControlPlane({ serverUrl: "https://connect.example", grant: "g", fetch }); + expect(await control.grant("c", new Uint8Array(32))).toBe("g"); + expect((await control.route("c 1"))?.targetDevice).toBe(device); + expect(String((fetch.mock.calls[0] as unknown[])[0])).toBe("https://connect.example/v1/next/collections/c%201/route"); + for (const [status, code] of [[401, "unauthenticated"], [403, "forbidden"], [503, "unavailable"]] as const) { + const failing = proposedControlPlane({ serverUrl: "https://connect.example", grant: null, fetch: async () => new Response(null, { status }) }); + const error = await failing.route("c").catch((e: unknown) => e); + expect(isMdbaseError(error, code)).toBe(true); + } + }); +}); + +describe("mdbase-next status line", () => { + const status = { mode: "synced" as const, confirmedThrough: 4, headKnown: 4, pending: 0, holds: 0, unresolved: 0, connection: "online" as const, incidents: [] }; + it("says nothing while everything is synced", () => { + expect(nextStatusText({ link: "open", problem: null, status, holds: 0 })).toBeNull(); + }); + it("shows waiting for a device, pending changes and holds", () => { + const waiting = problemFrom(mdbaseError("unavailable", "x", "no_device_online")); + expect(nextStatusText({ link: "reconnecting", problem: waiting, status, holds: 0 })).toMatch(/Waiting for one of your devices/); + expect(nextStatusText({ link: "open", problem: null, status: { ...status, connection: "offline", pending: 2 }, holds: 1 })) + .toBe("2 changes are saved on the replica and waiting to sync; 1 record needs a decision."); + }); +}); diff --git a/apps/writer/src/connect/next-control.ts b/apps/writer/src/connect/next-control.ts new file mode 100644 index 0000000..7801425 --- /dev/null +++ b/apps/writer/src/connect/next-control.ts @@ -0,0 +1,89 @@ +// How Writer reaches an mdbase-next replica from the browser. +// +// DEPENDENCY ON THE CONTROL WORKSTREAM: none of the control-plane pieces exist +// yet. Everything here that talks to a server is behind `NextControlPlane`, +// and `proposedControlPlane` implements it against a PROPOSED endpoint: +// +// GET {serverUrl}/v1/next/collections/:id/route +// → { "targets": [{ "url": string, "device": uuid, "noise_pk": base64 }] } +// +// An empty `targets` list means none of the account's devices is online. How a +// web app obtains a grant (registering its `client_pk` at consent) is not +// designed yet either: until it is, the grant ID is passed in (`?grant=`). +import { connect, loadOrCreateClientKey, mdbaseError, relayConnector, type MdbaseClient, type MdbaseError, type RelayRoute } from "@mdbase-dev/sdk"; + +export interface NextControlPlane { + /** + * The grant this browser holds for the collection. At consent the control + * plane must register `clientPublicKey` (the grant's `client_pk`). + */ + grant(collection: string, clientPublicKey: Uint8Array): Promise; + /** Where to connect now, or `null` when no target (device or hosted replica) is online. */ + route(collection: string): Promise; +} + +function base64Bytes(text: string): Uint8Array { + const normal = text.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(normal.padEnd(Math.ceil(normal.length / 4) * 4, "=")); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)); +} + +/** Parses the proposed route response; the first target is used. */ +export function routeFromResponse(body: unknown): RelayRoute | null { + const targets = body && typeof body === "object" ? (body as { targets?: unknown }).targets : undefined; + if (!Array.isArray(targets)) throw mdbaseError("internal", "the route response has no targets list", "invalid_route"); + for (const target of targets as unknown[]) { + if (!target || typeof target !== "object") continue; + const { url, device, noise_pk: key } = target as Record; + if (typeof url !== "string" || typeof device !== "string" || typeof key !== "string") continue; + const noisePublicKey = base64Bytes(key); + if (noisePublicKey.length !== 32) continue; + return { url, targetDevice: device, noisePublicKey }; + } + return null; +} + +/** PROPOSED: the control plane's route endpoint (see the module comment). */ +export function proposedControlPlane(options: { serverUrl: string; grant: string | null; fetch?: typeof fetch }): NextControlPlane { + const request = options.fetch ?? globalThis.fetch.bind(globalThis); + return { + // Stub: the consent flow that registers client_pk does not exist yet. + grant: async () => options.grant, + async route(collection) { + let response: Response; + try { + response = await request(new URL(`/v1/next/collections/${encodeURIComponent(collection)}/route`, options.serverUrl), { credentials: "include" }); + } catch { + throw mdbaseError("unavailable", "the control plane is unreachable", "control_unreachable"); + } + if (response.status === 401) throw mdbaseError("unauthenticated", "sign in again"); + if (response.status === 403) throw mdbaseError("forbidden", "no access to this collection"); + if (response.status === 404) throw mdbaseError("not_found", "no such collection"); + if (!response.ok) throw mdbaseError("unavailable", `route lookup failed (${response.status})`, "control_unavailable"); + return routeFromResponse(await response.json()); + }, + }; +} + +export interface ConnectNextOptions { + readonly collection: string; + readonly control: NextControlPlane; + /** Called while a private collection waits for one of the person's devices. */ + readonly onWaiting?: (why: MdbaseError) => void; + readonly signal?: AbortSignal; +} + +export const WRITER_CLIENT = { name: "mdbase-writer", version: "0.1.0-beta.0" } as const; + +/** A client for one collection over the relay, with this browser's client key. */ +export async function connectNext(options: ConnectNextOptions): Promise { + const staticKey = await loadOrCreateClientKey(WRITER_CLIENT.name); + const grant = await options.control.grant(options.collection, staticKey.publicKey); + return connect({ + app: WRITER_CLIENT, + connector: relayConnector({ collection: options.collection, grant, staticKey, resolveRoute: () => options.control.route(options.collection) }), + waitForDevice: true, + ...(options.onWaiting ? { onWaiting: options.onWaiting } : {}), + ...(options.signal ? { signal: options.signal } : {}), + }); +} diff --git a/apps/writer/src/env.d.ts b/apps/writer/src/env.d.ts index eeb25e1..e65ec9d 100644 --- a/apps/writer/src/env.d.ts +++ b/apps/writer/src/env.d.ts @@ -7,6 +7,8 @@ interface ImportMetaEnv { readonly VITE_MDBASE_CONNECT_URL?: string; readonly VITE_MDBASE_CONNECT_LOOPBACK_URL?: string; readonly VITE_WRITER_DEMO?: string; + /** "1" allows the opt-in mdbase-next backend (`?next`) outside development builds. */ + readonly VITE_WRITER_NEXT?: string; readonly VITE_MDBASE_EDITOR_URL?: string; readonly VITE_MDBASE_READER_URL?: string; readonly VITE_MDBASE_WRITER_URL?: string; diff --git a/apps/writer/src/ui/NextGate.tsx b/apps/writer/src/ui/NextGate.tsx new file mode 100644 index 0000000..163a8da --- /dev/null +++ b/apps/writer/src/ui/NextGate.tsx @@ -0,0 +1,45 @@ +// Before and around an mdbase-next collection: connecting, waiting for one of +// the person's devices, connection problems, and a sync status line. +import { ConnectLayout, OpeningScreen } from "@mdbase-dev/ui/screens"; +import { FeedbackButton } from "@mdbase-dev/ui/feedback"; +import { useEffect, useState } from "react"; + +import type { NextBackend, NextSync } from "../backend/next.js"; +import type { NextProblem, NextRecovery } from "../backend/next-errors.js"; +import { nextStatusText } from "./next-status.js"; + +const ACTION: Partial> = { + reconnect: "Connect again", + upgrade: "Reload Writer", + wait: "Try again now", + refresh: "Reload", + report: "Reload", +}; + +export function NextGate({ waiting, problem }: { waiting?: NextProblem | undefined; problem?: NextProblem | undefined }) { + if (problem && !problem.waitingForDevice) { + const action = ACTION[problem.recovery]; + return ( + +

{problem.message}

+ {action && } + +
+ ); + } + if (waiting || problem?.waitingForDevice) { + return ( + + ); + } + return ; +} + +export function NextStatus({ backend }: { backend: NextBackend }) { + const [sync, setSync] = useState(null); + useEffect(() => backend.onSync(setSync), [backend]); + const text = sync && nextStatusText(sync); + if (!text) return null; + return
{text}
; +} diff --git a/apps/writer/src/ui/next-status.ts b/apps/writer/src/ui/next-status.ts new file mode 100644 index 0000000..ac807c7 --- /dev/null +++ b/apps/writer/src/ui/next-status.ts @@ -0,0 +1,16 @@ +// The sync status line for an mdbase-next collection. +import type { NextSync } from "../backend/next.js"; + +/** One line about the link and sync state, shown only when there is something to say. */ +export function nextStatusText(sync: NextSync): string | null { + if (sync.link !== "open") { + if (sync.problem?.waitingForDevice) return "Waiting for one of your devices. Your edits are kept here and saved when one comes online."; + if (sync.link === "closed") return sync.problem?.message ?? "Disconnected from the collection."; + return "Reconnecting to the collection. Your edits are kept here."; + } + const parts: string[] = []; + if (sync.status.connection !== "online" && sync.status.pending) parts.push(`${sync.status.pending} ${sync.status.pending === 1 ? "change is" : "changes are"} saved on the replica and waiting to sync`); + if (sync.writeProblem) parts.push(`Not saved: ${sync.writeProblem}`); + if (sync.holds) parts.push(`${sync.holds} ${sync.holds === 1 ? "record needs" : "records need"} a decision`); + return parts.length ? `${parts.join("; ")}.` : null; +} diff --git a/apps/writer/src/workspace/chapters.ts b/apps/writer/src/workspace/chapters.ts index f09939a..fb95606 100644 --- a/apps/writer/src/workspace/chapters.ts +++ b/apps/writer/src/workspace/chapters.ts @@ -54,3 +54,13 @@ export function appendEmbed(body: string, target: string, isChapter?: IsChapter) lines.splice(last + 1, 0, ...(tight ? [embed] : ["", embed])); return lines.join("\n"); } + +/** Points embeds of `from` (written with or without `.md`) at `to`, keeping how it was written. */ +export function retargetEmbeds(body: string, from: string, to: string): string { + const bare = (p: string) => p.replace(/\.md$/i, ""); + return body.split("\n").map((line) => { + const target = embedTarget(line); + if (!target || bare(target) !== bare(from)) return line; + return line.replace(target, target.endsWith(".md") ? to : bare(to)); + }).join("\n"); +} diff --git a/apps/writer/src/workspace/workspace.ts b/apps/writer/src/workspace/workspace.ts index 72e5105..607b0ba 100644 --- a/apps/writer/src/workspace/workspace.ts +++ b/apps/writer/src/workspace/workspace.ts @@ -5,7 +5,7 @@ // (autosave, conflict detection, exact recovery — all from the SDK). Every // content change is forwarded to the worker, which assembles and typesets // the whole manuscript and reports which embedded records it still needs. -import type { JsonObject, MdbaseRecordLease, MdbaseRecordSessionSnapshot, RecordDocument } from "@mdbase-dev/connect"; +import type { JsonObject } from "@mdbase-dev/connect"; import { ManuscriptAssembler, translateRecord, type WriterRecord, type AssemblyInput } from "@mdbase-writer/core"; import { applySuggestion, bodyHash, linkPath, targetFor, type CommentRecord, type CommentThread } from "@mdbase-writer/core/comments"; import { BUNDLE_README, materialize } from "@mdbase-writer/core/materialize"; @@ -14,7 +14,7 @@ import { PathIndex, pathIndex, resolveLinkTarget } from "@mdbase-writer/core/rec import { LOCALES, STYLES } from "@mdbase-writer/core/styles"; import { NO_PEOPLE, type CommentChange, type People } from "../backend/comments.js"; -import { fail, manuscriptSlug, manuscriptFrontmatter, ok, type ManuscriptBinding, type LibraryEntry, type CollectionDelta, type Result, type WriterBackend } from "../backend/types.js"; +import { fail, manuscriptSlug, manuscriptFrontmatter, ok, type ManuscriptBinding, type LibraryEntry, type CollectionDelta, type RecordLease, type RecordSessionSnapshot, type Result, type WriterBackend } from "../backend/types.js"; import { toLocal } from "../backend/comments.js"; import { errorMessage, fetchChecked, mapConcurrent } from "../async.js"; import { DraftStore, draftPatch, type LocalDraft } from "./drafts.js"; @@ -22,7 +22,7 @@ import { CompileClient } from "../compile/client.js"; import type { CompileResult, WriterDiagnostic } from "../compile/protocol.js"; import { toDocx } from "../export/pandoc.js"; import { zip } from "../export/zip.js"; -import { appendEmbed, chapterEmbeds, moveEmbed } from "./chapters.js"; +import { appendEmbed, chapterEmbeds, moveEmbed, retargetEmbeds } from "./chapters.js"; // Styles and locales are fetched, not bundled: only the worker needs most of them. const cslUrls = import.meta.glob("../../../../packages/core/assets/csl/*.{csl,xml}", { query: "?url", import: "default", eager: true }) as Record; @@ -51,7 +51,7 @@ export function sourceKeys(library: readonly LibraryEntry[]): ReadonlyMap>; +export type SessionSnapshot = RecordSessionSnapshot; export interface RecordView { readonly path: string; @@ -121,7 +121,7 @@ export class ManuscriptWorkspace { private readonly assetJobs = new Map>(); private readonly assetAttempts = new Map(); private readonly retryTimers = new Set>(); - private readonly leases = new Map; unsubscribe: () => void }>(); + private readonly leases = new Map void }>(); private readonly opening = new Map>(); private readonly sent = new Map(); private readonly translations = new Map }>(); @@ -155,6 +155,7 @@ export class ManuscriptWorkspace { this.followCompiler(); this.cleanups.push(backend.onExternalChange((paths) => this.onExternalChange(paths))); if (backend.onCollectionChange) this.cleanups.push(backend.onCollectionChange((delta) => this.onCollectionChange(delta))); + if (backend.onRecordMoved) this.cleanups.push(backend.onRecordMoved(({ from, to }) => this.onRecordMoved(from, to))); // Linking a person record happens in another tab (mdbase Editor): look again on return. if (typeof window !== "undefined") { const onFocus = () => { @@ -652,6 +653,32 @@ export class ManuscriptWorkspace { } } + /** + * A record created here now lives at another path. Its old session is + * dropped with its draft (the backend carried the text over); embeds of it + * follow. A moved main record is reopened by the app at its new path. + */ + private onRecordMoved(from: string, to: string): void { + if (this.disposed) return; + const held = this.leases.get(from); + if (held) { held.unsubscribe(); held.lease.release(); this.leases.delete(from); } + this.drafts.remove(from); + this.sent.delete(from); + const records = new Map(this.current.records), recordProblems = new Map(this.current.recordProblems), recoveredDrafts = new Map(this.current.recoveredDrafts); + records.delete(from); recordProblems.delete(from); recoveredDrafts.delete(from); + const recordPaths = [...this.current.recordPaths.filter((p) => p !== from && p !== to), to]; + this.update({ records, recordProblems, recoveredDrafts, recordPaths }); + if (this.previewInitialized) this.compile.send({ type: "records", upsert: [], remove: [from] }); + this.sendCollection(); + if (from === this.main) return; + const body = this.current.records.get(this.main)?.snapshot.body; + if (body !== undefined) { + const next = retargetEmbeds(body, from, to); + if (next !== body) this.setBody(this.main, next); + } + void this.open(to); + } + private onExternalChange(paths: readonly string[]): void { if (this.backend.onCollectionChange) { const assets = paths.filter((p) => this.requestedAssets.has(p)); diff --git a/apps/writer/vendor/mdbase-dev-sdk-c573c96.tgz b/apps/writer/vendor/mdbase-dev-sdk-c573c96.tgz new file mode 100644 index 0000000..9221cb9 Binary files /dev/null and b/apps/writer/vendor/mdbase-dev-sdk-c573c96.tgz differ diff --git a/docs/development.md b/docs/development.md index 981fba2..d8a64a1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -21,6 +21,14 @@ The `?demo` collection runs on the SDK's in-memory record authority same SDK paths as a real collection. It is enabled in development and LAB builds, not staging or production. Demo changes are not durable collection data. +`?demo=next` serves the same demo collection through the opt-in mdbase-next backend +(`src/backend/next.ts`) on the `@mdbase-dev/sdk` in-memory replica (`MemoryReplica`). Writes stay +pending for 400 ms before they confirm. In the console, `writer.backend.replica` is the replica and +`await writer.backend.otherClient()` is a second client for concurrent edits. `?next&collection=…&grant=…` +connects to a real mdbase-next replica (development builds, or `VITE_WRITER_NEXT=1`); its route comes +from a proposed control-plane endpoint (`src/connect/next-control.ts`). The SDK is vendored in +`apps/writer/vendor/` until it is published. + The browser suite needs Chromium plus the `pandoc` and `unzip` CLIs to inspect downloaded Word and bundle exports. CI installs all three; install the CLIs with your system package manager and Chromium with `pnpm --filter @mdbase-writer/app exec playwright install chromium`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de75dee..9e9c0ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@mdbase-dev/connect': specifier: 0.1.0-beta.125 version: 0.1.0-beta.125 + '@mdbase-dev/sdk': + specifier: file:vendor/mdbase-dev-sdk-c573c96.tgz + version: file:apps/writer/vendor/mdbase-dev-sdk-c573c96.tgz '@mdbase-dev/ui': specifier: 0.1.0-beta.125 version: 0.1.0-beta.125(@codemirror/view@6.43.13)(react@19.3.0) @@ -324,6 +327,11 @@ packages: '@mdbase-dev/connect@0.1.0-beta.125': resolution: {integrity: sha512-vEH4NQfu3dBQgMw0YQxG7LDtOOLxDyxET4DQhrWuHqtgr9qoch/H0QLzel9tFSqkyH+QWxuvW3Bet6ae1+qVyQ==} + '@mdbase-dev/sdk@file:apps/writer/vendor/mdbase-dev-sdk-c573c96.tgz': + resolution: {integrity: sha512-AnSmYSVUQJpXofFuN5QuZX0XHL1y/LBhkkechQhvvqiFixs+xA/xxu6rjxHOt7atx67fUH8VvvRjwelw47eQJg==, tarball: file:apps/writer/vendor/mdbase-dev-sdk-c573c96.tgz} + version: 0.0.0 + engines: {node: '>=20'} + '@mdbase-dev/ui@0.1.0-beta.125': resolution: {integrity: sha512-K2UAAzwW4yuPJxx65LofVDQXKLgs4lOgrAB61YMuiNwV/jOc1yuNhGugy7Fx++VwxBx5bZyG+Pk7tMQlOat+tg==} peerDependencies: @@ -350,6 +358,18 @@ packages: '@myriaddreamin/typst-ts-web-compiler': optional: true + '@noble/ciphers@2.4.0': + resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} + engines: {node: '>= 20.19.0'} + + '@noble/curves@2.4.0': + resolution: {integrity: sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@oxc-project/types@0.151.0': resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} @@ -1319,6 +1339,12 @@ snapshots: dependencies: '@mdbase-dev/connect-protocol': 0.1.0-beta.125 + '@mdbase-dev/sdk@file:apps/writer/vendor/mdbase-dev-sdk-c573c96.tgz': + dependencies: + '@noble/ciphers': 2.4.0 + '@noble/curves': 2.4.0 + '@noble/hashes': 2.4.0 + '@mdbase-dev/ui@0.1.0-beta.125(@codemirror/view@6.43.13)(react@19.3.0)': dependencies: '@fontsource/atkinson-hyperlegible': 5.3.0 @@ -1338,6 +1364,14 @@ snapshots: '@myriaddreamin/typst-ts-renderer': 0.7.0 '@myriaddreamin/typst-ts-web-compiler': 0.7.0 + '@noble/ciphers@2.4.0': {} + + '@noble/curves@2.4.0': + dependencies: + '@noble/hashes': 2.4.0 + + '@noble/hashes@2.4.0': {} + '@oxc-project/types@0.151.0': {} '@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':