From c09c322f6824d827e008238e7605c0468d1b2b0a Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 4 Oct 2026 20:28:05 +1100 Subject: [PATCH 1/8] writer: own the record-session interface the workspace uses Replace the Connect MdbaseRecordLease/MdbaseRecordSessionSnapshot types in WriterBackend and the workspace with a small structural interface that Connect's sessions already satisfy, so a backend on another SDK can supply its own sessions. --- apps/writer/src/backend/types.ts | 82 ++++++++++++++++++++++++-- apps/writer/src/workspace/workspace.ts | 8 +-- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/apps/writer/src/backend/types.ts b/apps/writer/src/backend/types.ts index dcf8c16..ce89811 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). */ diff --git a/apps/writer/src/workspace/workspace.ts b/apps/writer/src/workspace/workspace.ts index 72e5105..e636110 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"; @@ -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 }>(); From d46b0f785f1d6fdab81a1500b5324a795cb19768 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 4 Oct 2026 20:28:09 +1100 Subject: [PATCH 2/8] writer: vendor the unpublished @mdbase-dev/sdk build (c573c96) The mdbase-next TS SDK is not published yet. Depend on a packed build through a file: spec until it is. --- apps/writer/package.json | 1 + apps/writer/vendor/mdbase-dev-sdk-c573c96.tgz | Bin 0 -> 112626 bytes pnpm-lock.yaml | 34 ++++++++++++++++++ 3 files changed, 35 insertions(+) create mode 100644 apps/writer/vendor/mdbase-dev-sdk-c573c96.tgz 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/vendor/mdbase-dev-sdk-c573c96.tgz b/apps/writer/vendor/mdbase-dev-sdk-c573c96.tgz new file mode 100644 index 0000000000000000000000000000000000000000..9221cb941c276d832ea9671ab94debe205524e99 GIT binary patch literal 112626 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
    {@DZO@;KnB-GI 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嚿mD%eoF}f`i3>ZcvX)YV@WNICUigFQ!n)7x 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 literal 0 HcmV?d00001 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)': From 73f11ef78e79a9bbc8c5464b639df9846ec6dde0 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 4 Oct 2026 20:37:20 +1100 Subject: [PATCH 3/8] writer: NextBackend on the mdbase-next SDK Discovery is one live query without bodies feeding the existing CollectionStore; bodies are read on demand with get(id, {body: true}); record sessions save with update(view, {patch, body}) so concurrent edits merge on the replica, with pending/confirmed receipts, conflict and hold states mapped onto the workspace's conflict UI; creates retry at numbered paths on conflict/path_taken; files are listed and downloaded and kept current from the change feed. The 15 SDK error codes map to Writer text and a recovery, including a waiting-for-a-device state. --- apps/writer/src/backend/next-errors.ts | 69 +++ apps/writer/src/backend/next-session.ts | 366 ++++++++++++++++ apps/writer/src/backend/next.ts | 534 ++++++++++++++++++++++++ 3 files changed, 969 insertions(+) create mode 100644 apps/writer/src/backend/next-errors.ts create mode 100644 apps/writer/src/backend/next-session.ts create mode 100644 apps/writer/src/backend/next.ts 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.ts b/apps/writer/src/backend/next.ts new file mode 100644 index 0000000..9d639b4 --- /dev/null +++ b/apps/writer/src/backend/next.ts @@ -0,0 +1,534 @@ +// 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 } 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; + +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; +} + +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 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); a later rejection at the log is reported as a collection problem. + */ + private async createAt(pathFor: (n: number) => string, input: Omit): Promise> { + let last = ""; + for (let n = 1; n <= 20; 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); + } + write.confirmed.catch((error: unknown) => this.publish({ paths: [path], problem: `${path}: ${problemFrom(error).message}` })); + return ok(write.records[0]?.path ?? path); + } catch (error) { + if (isMdbaseError(error) && isPathTaken(error)) { last = problemFrom(error).message; continue; } + return fail(problemFrom(error).message); + } + } + return fail(last); + } + + 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.publish({ paths: [path], problem: `${path}: ${problemFrom(error).message}` })); + 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.publish({ paths: [comment.path], problem: `${comment.path}: ${problemFrom(error).message}` })); + 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(); + if (this.options.ownsClient) this.client.close(); + } +} From 92e92edd01534de0f34360edf59246905934f887 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 4 Oct 2026 20:37:20 +1100 Subject: [PATCH 4/8] writer: test NextBackend against MemoryReplica --- apps/writer/src/backend/next.test.ts | 291 +++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 apps/writer/src/backend/next.test.ts diff --git a/apps/writer/src/backend/next.test.ts b/apps/writer/src/backend/next.test.ts new file mode 100644 index 0000000..9fdd34f --- /dev/null +++ b/apps/writer/src/backend/next.test.ts @@ -0,0 +1,291 @@ +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("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" }); + }); +}); From 8ad4d9c996726eba699c71e777bc82cd08b4ef36 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 4 Oct 2026 20:42:49 +1100 Subject: [PATCH 5/8] writer: reach mdbase-next replicas through a proposed control-plane route connectNext() loads this browser's client key and connects over the relay. The grant and route come from one small NextControlPlane interface; proposedControlPlane() implements it against a proposed endpoint (GET /v1/next/collections/:id/route) that the control workstream has not built yet. The grant is passed in until consent registers client_pk. --- apps/writer/src/connect/next-control.test.ts | 48 +++++++++++ apps/writer/src/connect/next-control.ts | 89 ++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 apps/writer/src/connect/next-control.test.ts create mode 100644 apps/writer/src/connect/next-control.ts 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 } : {}), + }); +} From 83b672cd5ebd4b8b7e1daa1fa6dfd518807e27d1 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 4 Oct 2026 20:42:49 +1100 Subject: [PATCH 6/8] writer: opt-in ?next and ?demo=next modes for NextBackend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ?demo=next serves the bundled demo manuscripts from the SDK's MemoryReplica through NextBackend. ?next&collection=…&grant=… (development builds, or VITE_WRITER_NEXT=1) connects to a real replica, with a waiting-for-a-device screen and a sync status line. The default backend stays Connect, and the default production bundle does not include the SDK. --- apps/writer/src/App.tsx | 50 +++++++++++++++++++++++--- apps/writer/src/backend/next-demo.ts | 54 ++++++++++++++++++++++++++++ apps/writer/src/backend/next.test.ts | 15 ++++++++ apps/writer/src/env.d.ts | 2 ++ apps/writer/src/ui/NextGate.tsx | 45 +++++++++++++++++++++++ apps/writer/src/ui/next-status.ts | 15 ++++++++ 6 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 apps/writer/src/backend/next-demo.ts create mode 100644 apps/writer/src/ui/NextGate.tsx create mode 100644 apps/writer/src/ui/next-status.ts diff --git a/apps/writer/src/App.tsx b/apps/writer/src/App.tsx index 3758875..40b0ba6 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 { 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.test.ts b/apps/writer/src/backend/next.test.ts index 9fdd34f..0cb99b9 100644 --- a/apps/writer/src/backend/next.test.ts +++ b/apps/writer/src/backend/next.test.ts @@ -289,3 +289,18 @@ describe("mdbase-next problems", () => { 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/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..62c0171 --- /dev/null +++ b/apps/writer/src/ui/next-status.ts @@ -0,0 +1,15 @@ +// 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.holds) parts.push(`${sync.holds} ${sync.holds === 1 ? "record needs" : "records need"} a decision`); + return parts.length ? `${parts.join("; ")}.` : null; +} From 8f39de8530b1e22b02f25f6ae3a6352f50c31681 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 4 Oct 2026 20:43:02 +1100 Subject: [PATCH 7/8] docs: the ?demo=next and ?next modes --- docs/development.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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`. From 04c4e94b13a222f801fe34cb7275f2eef71490a7 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 4 Oct 2026 23:22:16 +1100 Subject: [PATCH 8/8] writer: retry a create at the next numbered path when the log rejects it late A create the replica accepted but the log later rejected with conflict/path_taken is retried as a fresh create (new record and mutation IDs) at the next numbered path, under the same 20-attempt policy, carrying any text written into the optimistic record meanwhile. NextBackend reports the move through WriterBackend.onRecordMoved: the workspace drops the rolled back record's session and draft and re-points its embed, and the app reopens a moved manuscript. Other late rejections show on the status line instead of being sent as a collection-load problem. --- apps/writer/src/App.tsx | 2 + apps/writer/src/backend/next.test.ts | 54 ++++++++++++++++++++++++++ apps/writer/src/backend/next.ts | 48 +++++++++++++++++++---- apps/writer/src/backend/types.ts | 5 +++ apps/writer/src/ui/next-status.ts | 1 + apps/writer/src/workspace/chapters.ts | 10 +++++ apps/writer/src/workspace/workspace.ts | 29 +++++++++++++- 7 files changed, 140 insertions(+), 9 deletions(-) diff --git a/apps/writer/src/App.tsx b/apps/writer/src/App.tsx index 40b0ba6..70f8e9d 100644 --- a/apps/writer/src/App.tsx +++ b/apps/writer/src/App.tsx @@ -246,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.test.ts b/apps/writer/src/backend/next.test.ts index 0cb99b9..12210ed 100644 --- a/apps/writer/src/backend/next.test.ts +++ b/apps/writer/src/backend/next.test.ts @@ -236,6 +236,60 @@ describe("NextBackend writes", () => { 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")); diff --git a/apps/writer/src/backend/next.ts b/apps/writer/src/backend/next.ts index 9d639b4..4f4731a 100644 --- a/apps/writer/src/backend/next.ts +++ b/apps/writer/src/backend/next.ts @@ -9,7 +9,7 @@ // - 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 } from "@mdbase-dev/sdk"; +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"; @@ -44,6 +44,7 @@ import { 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; @@ -60,6 +61,8 @@ export interface NextSync { 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); @@ -113,6 +116,7 @@ export class NextBackend implements WriterBackend { 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[] = []; @@ -384,11 +388,13 @@ export class NextBackend implements WriterBackend { /** * Creates at `pathFor(1)`, then numbered paths while the replica answers * `conflict` (`path_taken`). Resolves once the replica holds the record - * (pending); a later rejection at the log is reported as a collection problem. + * (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): Promise> { + private async createAt(pathFor: (n: number) => string, input: Omit, from = 1): Promise> { let last = ""; - for (let n = 1; n <= 20; n++) { + for (let n = from; n <= CREATE_ATTEMPTS; n++) { const path = pathFor(n); try { const write = await this.client.create({ ...input, path }); @@ -397,8 +403,9 @@ export class NextBackend implements WriterBackend { if (isPathTaken(write.receipt.problem)) { last = problem.message; continue; } return fail(problem.message); } - write.confirmed.catch((error: unknown) => this.publish({ paths: [path], problem: `${path}: ${problemFrom(error).message}` })); - return ok(write.records[0]?.path ?? path); + 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); @@ -407,6 +414,30 @@ export class NextBackend implements WriterBackend { 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; @@ -439,7 +470,7 @@ export class NextBackend implements WriterBackend { 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.publish({ paths: [path], problem: `${path}: ${problemFrom(error).message}` })); + write.confirmed.catch((error: unknown) => this.writeProblem(path, error)); return ok(write.records[0]?.path ?? path); } catch (error) { return fail(problemFrom(error).message); } } @@ -474,7 +505,7 @@ export class NextBackend implements WriterBackend { 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.publish({ paths: [comment.path], problem: `${comment.path}: ${problemFrom(error).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."); @@ -529,6 +560,7 @@ export class NextBackend implements WriterBackend { 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 ce89811..bf895dd 100644 --- a/apps/writer/src/backend/types.ts +++ b/apps/writer/src/backend/types.ts @@ -188,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/ui/next-status.ts b/apps/writer/src/ui/next-status.ts index 62c0171..ac807c7 100644 --- a/apps/writer/src/ui/next-status.ts +++ b/apps/writer/src/ui/next-status.ts @@ -10,6 +10,7 @@ export function nextStatusText(sync: NextSync): string | null { } 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 e636110..607b0ba 100644 --- a/apps/writer/src/workspace/workspace.ts +++ b/apps/writer/src/workspace/workspace.ts @@ -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; @@ -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));