From ea6a86055a6ac9d01820ed482456f8476db40e62 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 12:44:39 -0700 Subject: [PATCH 01/30] Add durable local artifact storage Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/local-artifact-storage.md | 88 ++ src/app/services.ts | 3 + .../artifact-storage/revisions.test.ts | 44 + src/features/artifact-storage/revisions.ts | 98 +++ src/features/artifact-storage/service.test.ts | 58 ++ src/features/artifact-storage/service.ts | 136 ++++ src/features/artifact-storage/store.ts | 764 ++++++++++++++++++ src/features/artifact-storage/types.ts | 113 +++ src/features/communities/device-state.test.ts | 17 +- src/features/relay/partition-purge.ts | 2 + src/plugins/author.ts | 14 + tests/browser/artifact-storage.spec.mjs | 561 +++++++++++++ tests/fixtures/artifact-storage.html | 1 + tests/fixtures/artifact-storage.ts | 47 ++ 14 files changed, 1945 insertions(+), 1 deletion(-) create mode 100644 docs/local-artifact-storage.md create mode 100644 src/features/artifact-storage/revisions.test.ts create mode 100644 src/features/artifact-storage/revisions.ts create mode 100644 src/features/artifact-storage/service.test.ts create mode 100644 src/features/artifact-storage/service.ts create mode 100644 src/features/artifact-storage/store.ts create mode 100644 src/features/artifact-storage/types.ts create mode 100644 tests/browser/artifact-storage.spec.mjs create mode 100644 tests/fixtures/artifact-storage.html create mode 100644 tests/fixtures/artifact-storage.ts diff --git a/docs/local-artifact-storage.md b/docs/local-artifact-storage.md new file mode 100644 index 0000000000..e14ea36002 --- /dev/null +++ b/docs/local-artifact-storage.md @@ -0,0 +1,88 @@ +# Local artifact storage + +`ctx.artifactStorage` gives plugins durable local reads. It uses IndexedDB in the +host window. It does not request relay data. Shared writes use the host delivery +service. The bounded channel cache remains separate. + +A plugin opens a collection for its current identity and community: + +```ts +const tasks = ctx.artifactStorage.open({ + name: "tasks", + type: "example.task", + version: 1, + project({ record, preference }) { + return [{ index: "all", value: "", order: record.id }]; + }, +}); +const stop = tasks.subscribe(() => { /* Read the affected page again. */ }); +const page = await tasks.page({ index: "all", value: "", limit: 100 }); +const next = page.next + ? await tasks.page({ index: "all", value: "", after: page.next, limit: 100 }) + : undefined; +``` + +The host derives the plugin identity from Cordis. The saved community selection +supplies the viewer and relay origin, including while the network is unavailable. +A collection captures that scope. A handle rejects calls after the identity or relay changes. +Close the handle when its view ends. Subscribe before the first read. Notifications +follow a committed transaction and reach other windows through BroadcastChannel. +They carry no saved content. Each window reads IndexedDB again. + +The projector defines index names, exact string values, and lexical order keys. +Several entries can index the same record. Pages use an exclusive `(order, id)` +cursor. Each page reads only its selected records, with a maximum of 1,000 rows. +Pages are independent transactions. A change between pages can move a row; restart +the query after a notification. All windows must use the same collection type, +version, and projector. Increase the version to rebuild saved indexes in one +transaction. An older handle then rejects further reads and writes. + +`get(id)` returns a saved artifact and its separate preference. A record contains +the current revision, conflicting heads, unresolved ancestry, known availability, +and an optional local snapshot. A cached body is not proof of current access. +A missing parent remains unresolved. An older revision cannot replace a known +descendant. Conflicting heads preserve the last usable view. A descendant of all +heads resolves the conflict. The host retains ancestry edges for comparison. + +`preferences.update(id, change)` reads the latest value, calls the synchronous +callback, saves its result, and rebuilds that row's indexes in one transaction. +Preferences remain in a separate store. Values such as `false` and `null` remain +explicit. The callback must return JSON and must not start asynchronous work. + +`importBatch` supports migration of device data. It inserts missing local snapshots +and preferences. It does not invent a shared revision or replace later edits. The +plugin owns the snapshot fields, including origin. Its optional completion marker +commits with the last batch. `imported(marker)` confirms that commit. Keep the +source data until this check succeeds. + +The host collection adds these operations for sync: + +- `applyBatch` saves verified revisions, availability changes, and a checkpoint + in one strict transaction. The checkpoint compares its expected value first. +- `checkpoint` reads the saved checkpoint for owner transfer. +- `beginSnapshot(source)` captures a local commit sequence and a new generation. +- `applyBatch({ source, snapshot, ... })` marks received IDs seen in that generation. + Its `seen` entries can reference revisions already saved on this device. + Snapshot authority can select a current head when history is missing. It keeps + unresolved ancestry and competing heads. A capture sequence fences later writes. + Superseded heads cannot become current through a late snapshot. +- Availability updates compare `expectedRevision` with the saved current revision. + Snapshot availability also uses the capture sequence. Validated current revisions + can update the saved channel home. +- `completeSnapshot(snapshot, checkpoint)` reconciles absent records for only that + source, then commits the checkpoint. Live and confirmed writes after capture + remain available. Content, local snapshots, and preferences remain saved. + +`source` is an exact authoritative query scope supplied by sync. The sync owner +must validate server completion and current access before it calls +`completeSnapshot`. Pass the live interval's AbortSignal to cancel the local +transaction if continuity fails or the owner closes. A failed or invalidated snapshot must not complete. A new +snapshot for the same source invalidates the old generation. Checkpoints are +opaque strings; this store does not require a durable server cursor. + +`ArtifactStorageService.onOpen` lets host sync attach to a collection and its type +descriptor and a lifetime AbortSignal. Closing the handle or changing identity or +community aborts that signal. Leaving a community purges only its saved partition. +Plugins receive the read, preference, and migration contract. They do +not receive `applyBatch` or transport access. The task plugin owns task fields, +parent rules, search tokens, and the meaning of device preferences. diff --git a/src/app/services.ts b/src/app/services.ts index 9d935dbaba..92c47d5cb9 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -1,3 +1,4 @@ +import { ArtifactStorageService } from "../features/artifact-storage/service"; import { createAdminAccess } from "../bundled/admin-access"; import { nativeRelayStaff } from "../features/relay-staff/native"; import { createLocalAgentActivity } from "../features/agents/local-activity"; @@ -75,6 +76,7 @@ export function createServices() { subscribe: communities.subscribe, }); const relay = communities.relay; + const artifactStorage = new ArtifactStorageService(ctx); const adminAccess = createAdminAccess(relay, communities, nativeRelayStaff()); ctx.provide("adminAccess", adminAccess); ctx.effect(() => () => adminAccess.dispose()); @@ -132,6 +134,7 @@ export function createServices() { accountActions, shortcutBindings, conversation, + artifactStorage, settingsCards, adminAccess, pages, diff --git a/src/features/artifact-storage/revisions.test.ts b/src/features/artifact-storage/revisions.test.ts new file mode 100644 index 0000000000..d2b6f94d7f --- /dev/null +++ b/src/features/artifact-storage/revisions.test.ts @@ -0,0 +1,44 @@ +import { expect, it } from "vitest"; +import { emptyArtifact, receiveRevision } from "./revisions"; +const revision = (id: string, parents: string[] = []) => ({ + id, + parents, + content: id, +}); + +it("keeps descendants when older confirmations arrive", () => { + let saved = receiveRevision(emptyArtifact("task"), revision("v0")); + saved = receiveRevision(saved, revision("v1", ["v0"])); + saved = receiveRevision(saved, revision("v0")); + expect(saved.record.current?.id).toBe("v1"); + expect(saved.record.conflicts).toEqual([]); +}); +it("retains conflicts and the usable view until a merge descends from both", () => { + let saved = receiveRevision(emptyArtifact("task"), revision("root")); + saved = receiveRevision(saved, revision("left", ["root"])); + saved = receiveRevision(saved, revision("right", ["root"])); + expect(saved.record.current?.id).toBe("left"); + expect(saved.record.conflicts.map((r) => r.id)).toEqual(["right"]); + saved = receiveRevision(saved, revision("merge", ["left", "right"])); + expect(saved.record.current?.id).toBe("merge"); + expect(saved.record.conflicts).toEqual([]); +}); +it("keeps unknown ancestry unresolved and joins it when missing parents arrive", () => { + let saved = receiveRevision(emptyArtifact("task"), revision("root")); + saved = receiveRevision(saved, revision("new", ["missing"])); + expect(saved.record.current?.id).toBe("root"); + expect(saved.record.unresolved).toBe(true); + saved = receiveRevision(saved, revision("missing", ["root"])); + expect(saved.record.current?.id).toBe("new"); + expect(saved.record.unresolved).toBe(false); +}); +it("rejects ancestry cycles and changed immutable revisions", () => { + const saved = receiveRevision(emptyArtifact("task"), revision("a", ["b"])); + expect(() => receiveRevision(saved, revision("b", ["a"]))).toThrow("cycle"); + expect(() => receiveRevision(saved, revision("a"))).toThrow( + "ancestry mismatch", + ); + expect(() => + receiveRevision(saved, { ...revision("a", ["b"]), content: "changed" }), + ).toThrow("content mismatch"); +}); diff --git a/src/features/artifact-storage/revisions.ts b/src/features/artifact-storage/revisions.ts new file mode 100644 index 0000000000..82f75b5273 --- /dev/null +++ b/src/features/artifact-storage/revisions.ts @@ -0,0 +1,98 @@ +import type { ArtifactRevision, SavedArtifact } from "./types"; + +export type StoredArtifact = { + record: SavedArtifact; + ancestry: Record; + heads: ArtifactRevision[]; + superseded: string[]; +}; +export function emptyArtifact(id: string): StoredArtifact { + return { + record: { + id, + conflicts: [], + unresolved: false, + availability: { state: "unknown" }, + }, + ancestry: {}, + heads: [], + superseded: [], + }; +} +export function receiveRevision( + stored: StoredArtifact, + revision: ArtifactRevision, +): StoredArtifact { + if ( + !revision.id || + typeof revision.content !== "string" || + revision.parents.some((id) => !id) + ) + throw new Error("Invalid artifact revision"); + const ancestry = { ...stored.ancestry }; + const prior = Object.hasOwn(ancestry, revision.id) + ? ancestry[revision.id] + : undefined; + if (prior && JSON.stringify(prior) !== JSON.stringify(revision.parents)) + throw new Error("Artifact revision ancestry mismatch"); + Object.defineProperty(ancestry, revision.id, { + value: [...revision.parents], + enumerable: true, + configurable: true, + }); + const parents = (id: string) => + Object.hasOwn(ancestry, id) ? (ancestry[id] ?? []) : []; + const reaches = (from: string, target: string) => { + const pending = [...parents(from)]; + const seen = new Set(); + while (pending.length) { + const id = pending.pop(); + if (id === undefined) break; + if (id === target) return true; + if (seen.has(id)) continue; + seen.add(id); + pending.push(...parents(id)); + } + return false; + }; + if (reaches(revision.id, revision.id)) + throw new Error("Artifact revision ancestry cycle"); + const candidates = new Map(stored.heads.map((head) => [head.id, head])); + const existing = + candidates.get(revision.id) ?? + (stored.record.current?.id === revision.id + ? stored.record.current + : undefined); + if (existing && JSON.stringify(existing) !== JSON.stringify(revision)) + throw new Error("Artifact revision content mismatch"); + candidates.set(revision.id, revision); + const heads = [...candidates.values()].filter( + (candidate) => + ![...candidates.keys()].some( + (id) => id !== candidate.id && reaches(id, candidate.id), + ), + ); + const unresolved = Object.values(ancestry).some((parents) => + parents.some((id) => !Object.hasOwn(ancestry, id)), + ); + let current = stored.record.current; + const successors = heads.filter( + (head) => + !current || head.id === current.id || reaches(head.id, current.id), + ); + if (successors.length === 1) current = successors[0]; + return { + ancestry, + heads, + superseded: + current?.id !== stored.record.current?.id && stored.record.current + ? [...new Set([...stored.superseded, stored.record.current.id])] + : stored.superseded, + record: { + ...stored.record, + ...(current ? { current } : {}), + unresolved, + conflicts: heads.filter((head) => head.id !== current?.id), + }, + }; +} diff --git a/src/features/artifact-storage/service.test.ts b/src/features/artifact-storage/service.test.ts new file mode 100644 index 0000000000..2441595346 --- /dev/null +++ b/src/features/artifact-storage/service.test.ts @@ -0,0 +1,58 @@ +import { Context } from "@deepseek-ai/cordis"; +import { expect, it, vi } from "vitest"; +import { ArtifactStorageService } from "./service"; + +it("binds collection ownership to the calling plugin and fences scope changes", async () => { + const root = new Context(); + let scope = { viewer: "viewer", selected: "https://relay.test" }; + const subscribers = new Set<() => void>(); + root.provide("communityReader", { + snapshot: () => scope, + subscribe: (listener: () => void) => { + subscribers.add(listener); + return () => { + subscribers.delete(listener); + }; + }, + }); + const service = new ArtifactStorageService(root); + const plugin = root.extend({ + pluginOwner: { id: "test.plugin", revision: "one" }, + }); + const definition = { + name: "tasks", + type: "test.task", + version: 1, + project: () => [], + }; + expect(() => root.artifactStorage.open(definition)).toThrow("needs a plugin"); + // Storage is the external boundary here; browser tests exercise its real transactions. + const get = vi.fn(async () => undefined); + const close = vi.fn(); + const collection = { get, close }; + const factory = vi.fn(() => collection); + const stores = service as unknown as { stores: Map }; + stores.stores.set(JSON.stringify(["viewer", "https://relay.test"]), { + collection: factory, + close: vi.fn(), + }); + const listener = vi.fn(); + service.onOpen(listener); + const handle = plugin.artifactStorage.open(definition); + expect(factory).toHaveBeenCalledWith("test.plugin", definition); + expect(listener.mock.calls[0]?.[0]).toMatchObject({ + owner: "test.plugin", + viewer: "viewer", + relay: "https://relay.test", + collection, + }); + expect(handle).not.toHaveProperty("applyBatch"); + await handle.get("task"); + expect(get).toHaveBeenCalledWith("task"); + scope = { viewer: "other", selected: "https://relay.test" }; + for (const listener of subscribers) listener(); + expect(listener.mock.calls[0]?.[0].signal.aborted).toBe(true); + expect(close).toHaveBeenCalled(); + expect(() => handle.get("task")).toThrow("scope changed"); + await root.fiber.dispose(); +}); diff --git a/src/features/artifact-storage/service.ts b/src/features/artifact-storage/service.ts new file mode 100644 index 0000000000..7ac2f675b5 --- /dev/null +++ b/src/features/artifact-storage/service.ts @@ -0,0 +1,136 @@ +import { Service, type Context } from "@deepseek-ai/cordis"; +import type {} from "../communities/service"; +import { communityDestination } from "../communities/destination"; +import { createArtifactStore, type ArtifactStore } from "./store"; +import type { + ArtifactCollection, + ArtifactCollectionDefinition, + HostArtifactCollection, +} from "./types"; + +export type ArtifactStorage = { + /** Captures the current identity and relay. Close the handle on scope changes. */ + open(definition: ArtifactCollectionDefinition): ArtifactCollection; +}; +export type OpenArtifactCollection = Readonly<{ + viewer: string; + relay: string; + owner: string; + definition: ArtifactCollectionDefinition; + collection: HostArtifactCollection; + signal: AbortSignal; +}>; +declare module "@deepseek-ai/cordis" { + interface Context { + artifactStorage: ArtifactStorage; + } +} + +export class ArtifactStorageService extends Service implements ArtifactStorage { + private stores = new Map(); + private opened = new Set<(entry: OpenArtifactCollection) => void>(); + constructor(ctx: Context) { + super(ctx, "artifactStorage"); + ctx.effect(() => () => { + for (const store of this.stores.values()) store.close(); + this.stores.clear(); + this.opened.clear(); + }); + } + /** Host-only attachment point for the shared sync service. */ + onOpen(listener: (entry: OpenArtifactCollection) => void) { + this.opened.add(listener); + return () => { + this.opened.delete(listener); + }; + } + open(definition: ArtifactCollectionDefinition): ArtifactCollection { + const owner = this.ctx.pluginOwner?.id; + const capture = () => { + const { viewer, selected } = this.ctx.communityReader.snapshot(); + return { + viewer, + origin: selected ? communityDestination(selected).url : undefined, + }; + }; + const { viewer, origin } = capture(); + if (!owner || !viewer || !origin) + throw new Error("Artifact storage needs a plugin, identity and relay"); + const scope = JSON.stringify([viewer, origin]); + let store = this.stores.get(scope); + if (!store) { + store = createArtifactStore(viewer, origin); + this.stores.set(scope, store); + } + const collection = store.collection(owner, definition); + const controller = new AbortController(); + let unsubscribe: (() => void) | undefined; + const close = () => { + controller.abort(); + unsubscribe?.(); + collection.close(); + }; + const valid = () => { + const now = capture(); + if ( + controller.signal.aborted || + now.viewer !== viewer || + now.origin !== origin + ) + throw new Error("Artifact storage scope changed"); + }; + unsubscribe = this.ctx.communityReader.subscribe(() => { + const now = capture(); + if (now.viewer !== viewer || now.origin !== origin) close(); + }); + const checked = async (operation: Promise) => { + const result = await operation; + valid(); + return result; + }; + const facade: ArtifactCollection = { + get(id) { + valid(); + return checked(collection.get(id)); + }, + page(query) { + valid(); + return checked(collection.page(query)); + }, + subscribe(listener) { + valid(); + return collection.subscribe(listener); + }, + preferences: { + get(id) { + valid(); + return checked(collection.preferences.get(id)); + }, + update(id, change) { + valid(); + return collection.preferences.update(id, change); + }, + }, + importBatch(batch) { + valid(); + return collection.importBatch(batch); + }, + imported(marker) { + valid(); + return checked(collection.imported(marker)); + }, + close, + }; + this.ctx.effect(() => close); + for (const listener of this.opened) + listener({ + viewer, + relay: origin, + owner, + definition, + collection, + signal: controller.signal, + }); + return facade; + } +} diff --git a/src/features/artifact-storage/store.ts b/src/features/artifact-storage/store.ts new file mode 100644 index 0000000000..9d242c4529 --- /dev/null +++ b/src/features/artifact-storage/store.ts @@ -0,0 +1,764 @@ +import type { JsonValue } from "../navigation/targets"; +import { relayPartition } from "../relay/partition"; +import { + emptyArtifact, + receiveRevision, + type StoredArtifact, +} from "./revisions"; +import type { + ArtifactBatch, + ArtifactCollectionDefinition, + ArtifactEntry, + ArtifactImport, + ArtifactPage, + ArtifactPageQuery, + HostArtifactCollection, + ArtifactSnapshot, +} from "./types"; + +const DB = "buzz-artifacts-v1"; +const STORES = ["records", "preferences", "entries", "metadata", "members"]; +type Row = StoredArtifact & { + scope: string; + collection: string; + id: string; + sequence?: number | undefined; +}; +type StoredPreference = { value: JsonValue }; + +function sameSnapshot( + left: ArtifactSnapshot | undefined, + right: ArtifactSnapshot, +) { + return ( + left?.source === right.source && + left.generation === right.generation && + left.captureSequence === right.captureSequence + ); +} + +/** Same IndexedDB transaction boundary as the relay journals. No head-cache eviction. */ +export function createArtifactStore(viewer: string, relay: string) { + if (!viewer || !relay) + throw new Error("Artifact storage needs an identity and relay"); + const scope = relayPartition(relay, viewer); + let closed = false; + const collections = new Set(); + let opening: Promise | undefined; + function database() { + if (closed) throw new Error("Artifact storage closed"); + opening ??= new Promise((resolve, reject) => { + const request = indexedDB.open(DB, 1); + let blocked = false; + request.onupgradeneeded = () => { + const db = request.result; + for (const name of STORES) { + const store = db.createObjectStore(name, { + keyPath: + name === "entries" + ? ["scope", "collection", "index", "value", "order", "id"] + : name === "members" + ? ["scope", "collection", "source", "id"] + : name === "metadata" + ? ["scope", "collection", "key"] + : ["scope", "collection", "id"], + }); + if (name === "entries") + store.createIndex("record", ["scope", "collection", "id"]); + } + }; + request.onerror = () => reject(request.error); + request.onblocked = () => { + blocked = true; + reject(new Error("Close other Buzz windows to open artifact storage")); + }; + request.onsuccess = () => { + const db = request.result; + if (closed || blocked) { + db.close(); + reject(new Error("Artifact storage closed")); + return; + } + db.onversionchange = () => db.close(); + resolve(db); + }; + }); + return opening; + } + type Then = (request: IDBRequest, next: (value: T) => void) => void; + async function transact( + mode: IDBTransactionMode, + work: (tx: IDBTransaction, then: Then, done: (value: T) => void) => void, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); + const db = await database(); + signal?.throwIfAborted(); + if (closed) throw new Error("Artifact storage closed"); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORES, mode, { durability: "strict" }); + let value: T; + let failure: unknown; + const stop = () => { + try { + tx.abort(); + } catch (error) { + if ( + !( + error instanceof DOMException && + error.name === "InvalidStateError" + ) + ) + throw error; + } + }; + const abort = () => { + failure = signal?.reason; + stop(); + }; + signal?.addEventListener("abort", abort, { once: true }); + const guard = (fn: () => void) => { + if (signal?.aborted) return; + try { + fn(); + } catch (error) { + if (!signal?.aborted) failure = error; + stop(); + } + }; + const then: Then = (request, next) => { + request.onsuccess = () => guard(() => next(request.result)); + }; + tx.oncomplete = () => { + signal?.removeEventListener("abort", abort); + resolve(value); + }; + tx.onabort = () => { + signal?.removeEventListener("abort", abort); + reject( + failure ?? tx.error ?? new Error("Artifact transaction aborted"), + ); + }; + guard(() => + work(tx, then, (result) => { + value = result; + }), + ); + }); + } + function collection( + owner: string, + definition: ArtifactCollectionDefinition, + ): HostArtifactCollection { + if (closed) throw new Error("Artifact storage closed"); + if ( + !owner || + !definition.name || + !definition.type || + !Number.isSafeInteger(definition.version) || + definition.version < 1 + ) + throw new Error("Invalid artifact collection"); + const collection = JSON.stringify([owner, definition.name]); + const prefix = [scope, collection]; + const key = (id: string) => [...prefix, id]; + const range = (parts: IDBValidKey[]) => + IDBKeyRange.bound(parts, [...parts, []]); + let disposed = false; + const listeners = new Set<() => void>(); + const channel = new BroadcastChannel(`${DB}:${JSON.stringify(prefix)}`); + const changed = () => { + for (const listener of listeners) + queueMicrotask(() => { + if (!disposed && listeners.has(listener)) listener(); + }); + }; + channel.onmessage = changed; + const publish = () => { + if (!disposed) { + changed(); + channel.postMessage(null); + } + }; + function reindex( + tx: IDBTransaction, + then: Then, + row: Row, + preference: JsonValue | undefined, + next: () => void, + ) { + const entries = tx.objectStore("entries"); + then( + entries.index("record").getAllKeys(IDBKeyRange.only(key(row.id))), + (keys) => { + for (const old of keys) entries.delete(old); + for (const index of definition.project({ + record: row.record, + ...(preference !== undefined ? { preference } : {}), + })) { + if ( + !index.index || + typeof index.value !== "string" || + typeof index.order !== "string" + ) + throw new Error("Invalid artifact index entry"); + entries.put({ ...index, scope, collection, id: row.id }); + } + next(); + }, + ); + } + const ready = transact("readwrite", (tx, then, done) => { + const metadata = tx.objectStore("metadata"); + then(metadata.get(key("schema")), (schema) => { + if (schema?.type && schema.type !== definition.type) + throw new Error("Artifact collection type changed"); + if (schema?.version === definition.version) { + done(); + return; + } + if (schema?.version > definition.version) + throw new Error("Artifact collection requires a newer plugin"); + const request = tx.objectStore("records").openCursor(range(prefix)); + then(request, (cursor) => { + if (!cursor) { + metadata.put({ + scope, + collection, + key: "schema", + version: definition.version, + type: definition.type, + }); + done(); + return; + } + then( + tx.objectStore("preferences").get(key(cursor.value.id)), + (pref: StoredPreference | undefined) => + reindex(tx, then, cursor.value, pref?.value, () => + cursor.continue(), + ), + ); + }); + }); + }); + // Keep an unopened handle's initialization rejection observable by its first operation. + void ready.catch(() => {}); + async function run( + mode: IDBTransactionMode, + work: Parameters>[1], + signal?: AbortSignal, + ) { + await ready; + if (disposed) throw new Error("Artifact collection closed"); + return transact( + mode, + (tx, then, done) => { + then(tx.objectStore("metadata").get(key("schema")), (schema) => { + if (schema?.version !== definition.version) + throw new Error("Artifact collection version changed"); + work(tx, then, done); + }); + }, + signal, + ); + } + function readEntry( + tx: IDBTransaction, + then: Then, + id: string, + done: (entry: ArtifactEntry | undefined) => void, + ) { + then(tx.objectStore("records").get(key(id)), (row: Row | undefined) => { + if (!row) { + done(undefined); + return; + } + then( + tx.objectStore("preferences").get(key(id)), + (pref: StoredPreference | undefined) => + done({ + record: row.record, + ...(pref ? { preference: pref.value } : {}), + }), + ); + }); + } + function mutate( + tx: IDBTransaction, + then: Then, + id: string, + change: ( + row: Row, + preference: JsonValue | undefined, + ) => { row: Row; preference?: JsonValue }, + next: () => void, + ) { + if (!id) throw new Error("Artifact identity is empty"); + then(tx.objectStore("records").get(key(id)), (saved: Row | undefined) => { + then( + tx.objectStore("preferences").get(key(id)), + (pref: StoredPreference | undefined) => { + const result = change( + saved ?? { ...emptyArtifact(id), scope, collection, id }, + pref?.value, + ); + tx.objectStore("records").put(result.row); + if (result.preference !== undefined) + tx.objectStore("preferences").put({ + scope, + collection, + id, + value: result.preference, + }); + reindex( + tx, + then, + result.row, + result.preference !== undefined ? result.preference : pref?.value, + next, + ); + }, + ); + }); + } + function series(items: readonly (() => void)[]) { + let at = 0; + return () => { + items[at++]?.(); + }; + } + const handle: HostArtifactCollection = { + get: (id) => + run("readonly", (tx, then, done) => + readEntry(tx, then, id, done), + ), + page(query: ArtifactPageQuery) { + const limit = query.limit ?? 100; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) + return Promise.reject( + new Error("Artifact page limit must be 1 to 1000"), + ); + return run("readonly", (tx, then, done) => { + const pagePrefix = [...prefix, query.index, query.value]; + const lower = query.after + ? [...pagePrefix, query.after.order, query.after.id] + : pagePrefix; + const request = tx + .objectStore("entries") + .openCursor( + IDBKeyRange.bound(lower, [...pagePrefix, []], !!query.after), + ); + const entries: ArtifactEntry[] = []; + let last: { order: string; id: string } | undefined; + then(request, function visit(cursor) { + if (!cursor || entries.length === limit) { + done({ entries, ...(cursor && last ? { next: last } : {}) }); + return; + } + const { id, order } = cursor.value; + readEntry(tx, then, id, (entry) => { + if (!entry) throw new Error("Artifact index has no record"); + entries.push(entry); + last = { id, order }; + cursor.continue(); + }); + }); + }); + }, + subscribe(listener) { + if (disposed) throw new Error("Artifact collection closed"); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + preferences: { + get: (id) => + run("readonly", (tx, then, done) => + then( + tx.objectStore("preferences").get(key(id)), + (pref: StoredPreference | undefined) => done(pref?.value), + ), + ), + async update(id, change) { + await run("readwrite", (tx, then, done) => + mutate( + tx, + then, + id, + (row, current) => ({ row, preference: change(current) }), + () => done(), + ), + ); + publish(); + }, + }, + async importBatch(batch: ArtifactImport) { + await run("readwrite", (tx, then, done) => { + const jobs: (() => void)[] = []; + const next = series(jobs); + for (const record of batch.records ?? []) + jobs.push(() => + mutate( + tx, + then, + record.id, + (row) => ({ + row: + row.record.local !== undefined || + row.record.current || + row.heads.length + ? row + : { + ...row, + record: { ...row.record, local: record.local }, + }, + }), + next, + ), + ); + for (const pref of batch.preferences ?? []) + jobs.push(() => + mutate( + tx, + then, + pref.id, + (row, current) => ({ + row, + preference: current === undefined ? pref.value : current, + }), + next, + ), + ); + jobs.push(() => { + if (batch.marker) + tx.objectStore("metadata").put({ + scope, + collection, + key: JSON.stringify(["import", batch.marker]), + value: true, + }); + done(); + }); + next(); + }); + publish(); + }, + imported: (marker) => + run("readonly", (tx, then, done) => + then( + tx + .objectStore("metadata") + .get(key(JSON.stringify(["import", marker]))), + (row) => done(row?.value === true), + ), + ), + checkpoint: () => + run("readonly", (tx, then, done) => + then(tx.objectStore("metadata").get(key("checkpoint")), (row) => + done(row?.value ?? null), + ), + ), + beginSnapshot(source) { + if (!source) + return Promise.reject(new Error("Artifact source is empty")); + return run("readwrite", (tx, then, done) => { + then(tx.objectStore("metadata").get(key("sequence")), (row) => { + const snapshot = { + source, + generation: crypto.randomUUID(), + captureSequence: row?.value ?? 0, + }; + tx.objectStore("metadata").put({ + scope, + collection, + key: JSON.stringify(["snapshot", source]), + value: snapshot, + }); + done(snapshot); + }); + }); + }, + async completeSnapshot(snapshot, checkpoint, signal) { + await run( + "readwrite", + (tx, then, done) => { + const metadata = tx.objectStore("metadata"); + then( + metadata.get(key(JSON.stringify(["snapshot", snapshot.source]))), + (saved) => { + if (!sameSnapshot(saved?.value, snapshot)) + throw new Error("Artifact snapshot changed"); + then(metadata.get(key("checkpoint")), (row) => { + if ((row?.value ?? null) !== checkpoint.expected) + throw new Error("Artifact checkpoint changed"); + const request = tx + .objectStore("members") + .openCursor(range([...prefix, snapshot.source])); + then(request, function visit(cursor) { + if (!cursor) { + metadata.put({ + scope, + collection, + key: "checkpoint", + value: checkpoint.next, + }); + metadata.delete( + key(JSON.stringify(["snapshot", snapshot.source])), + ); + done(); + return; + } + const member = cursor.value; + if (member.generation === snapshot.generation) { + cursor.continue(); + return; + } + mutate( + tx, + then, + member.id, + (row) => ({ + row: + (row.sequence ?? 0) > snapshot.captureSequence + ? row + : { + ...row, + record: { + ...row.record, + availability: { + state: "unavailable", + reason: "Absent from completed snapshot", + }, + }, + }, + }), + () => cursor.continue(), + ); + }); + }); + }, + ); + }, + signal, + ); + publish(); + }, + async applyBatch(batch: ArtifactBatch) { + await run("readwrite", (tx, then, done) => { + const jobs: (() => void)[] = []; + const next = series(jobs); + let sequence = 0; + if (!batch.source) throw new Error("Artifact source is empty"); + if (batch.snapshot) + jobs.push(() => { + const snapshot = batch.snapshot; + if (!snapshot || snapshot.source !== batch.source) + throw new Error("Artifact snapshot source mismatch"); + then( + tx + .objectStore("metadata") + .get(key(JSON.stringify(["snapshot", batch.source]))), + (row) => { + if (!sameSnapshot(row?.value, snapshot)) + throw new Error("Artifact snapshot changed"); + next(); + }, + ); + }); + jobs.push(() => + then(tx.objectStore("metadata").get(key("sequence")), (row) => { + sequence = (row?.value ?? 0) + 1; + tx.objectStore("metadata").put({ + scope, + collection, + key: "sequence", + value: sequence, + }); + next(); + }), + ); + jobs.push(() => + then(tx.objectStore("metadata").get(key("checkpoint")), (row) => { + if ( + batch.checkpoint && + (row?.value ?? null) !== batch.checkpoint.expected + ) + throw new Error("Artifact checkpoint changed"); + next(); + }), + ); + for (const incoming of batch.records ?? []) + jobs.push(() => + mutate( + tx, + then, + incoming.id, + (row) => { + if (row.record.type && row.record.type !== incoming.type) + throw new Error("Artifact type mismatch"); + if (incoming.type !== definition.type) + throw new Error("Artifact type does not match collection"); + tx.objectStore("members").put({ + scope, + collection, + source: batch.source, + id: incoming.id, + generation: batch.snapshot?.generation ?? null, + }); + const received = receiveRevision(row, incoming.revision); + if ( + batch.snapshot && + !row.superseded.includes(incoming.revision.id) && + received.heads.some( + (head) => head.id === incoming.revision.id, + ) && + (row.sequence ?? 0) <= batch.snapshot.captureSequence + ) { + if ( + row.record.current && + row.record.current.id !== incoming.revision.id + ) + received.superseded = [ + ...new Set([ + ...received.superseded, + row.record.current.id, + ]), + ]; + received.record = { + ...received.record, + current: incoming.revision, + conflicts: received.heads.filter( + (head) => head.id !== incoming.revision.id, + ), + }; + } + return { + row: { + ...row, + ...received, + sequence: batch.snapshot ? row.sequence : sequence, + record: { + ...received.record, + ...(received.record.current?.id === incoming.revision.id + ? { channelId: incoming.channelId } + : {}), + type: incoming.type, + }, + }, + }; + }, + next, + ), + ); + for (const seen of batch.seen ?? []) + jobs.push(() => { + if (!batch.snapshot) + throw new Error("Seen members require a snapshot"); + then( + tx.objectStore("records").get(key(seen.id)), + (row: Row | undefined) => { + if (!row || !Object.hasOwn(row.ancestry, seen.revisionId)) + throw new Error("Snapshot revision is not saved"); + tx.objectStore("members").put({ + scope, + collection, + source: batch.source, + id: seen.id, + generation: batch.snapshot?.generation, + }); + next(); + }, + ); + }); + for (const availability of batch.availability ?? []) + jobs.push(() => + mutate( + tx, + then, + availability.id, + (row) => ({ + row: + (row.record.current?.id ?? null) !== + availability.expectedRevision || + (batch.snapshot && + (row.sequence ?? 0) > batch.snapshot.captureSequence) + ? row + : { + ...row, + sequence: batch.snapshot ? row.sequence : sequence, + record: { + ...row.record, + availability: availability.value, + }, + }, + }), + next, + ), + ); + jobs.push(() => { + if (batch.checkpoint) + tx.objectStore("metadata").put({ + scope, + collection, + key: "checkpoint", + value: batch.checkpoint.next, + }); + done(); + }); + next(); + }); + publish(); + }, + close() { + disposed = true; + listeners.clear(); + channel.close(); + collections.delete(handle); + }, + }; + collections.add(handle); + return handle; + } + return { + collection, + async clear() { + await transact("readwrite", (tx, then, done) => { + let pending = STORES.length; + for (const name of STORES) { + const request = tx + .objectStore(name) + .openCursor(IDBKeyRange.bound([scope], [scope, []])); + then(request, function visit(cursor) { + if (!cursor) { + if (--pending === 0) done(); + return; + } + cursor.delete(); + cursor.continue(); + }); + } + }); + }, + close() { + closed = true; + for (const handle of collections) handle.close(); + void opening?.then( + (db) => db.close(), + () => {}, + ); + }, + }; +} +export type ArtifactStore = ReturnType; + +export async function purgeArtifactStorage(viewer: string, relay: string) { + if (typeof indexedDB === "undefined") return; + const store = createArtifactStore(viewer, relay); + try { + await store.clear(); + } finally { + store.close(); + } +} diff --git a/src/features/artifact-storage/types.ts b/src/features/artifact-storage/types.ts new file mode 100644 index 0000000000..4457ca4a9f --- /dev/null +++ b/src/features/artifact-storage/types.ts @@ -0,0 +1,113 @@ +import type { JsonValue } from "../navigation/targets"; +import type { RelayEvent } from "../relay/events"; + +export type ArtifactRevision = Readonly<{ + id: string; + parents: readonly string[]; + content: string; + event?: RelayEvent; +}>; +export type ArtifactAvailability = Readonly<{ + state: "unknown" | "available" | "unavailable" | "deleted"; + reason?: string; +}>; +export type SavedArtifact = Readonly<{ + id: string; + channelId?: string; + type?: string; + current?: ArtifactRevision; + conflicts: readonly ArtifactRevision[]; + /** At least one parent is missing from the saved ancestry. */ + unresolved: boolean; + /** Cached content does not establish current access. */ + availability: ArtifactAvailability; + /** A plugin-owned snapshot, without shared revision proof. */ + local?: JsonValue; +}>; +export type ArtifactEntry = Readonly<{ + record: SavedArtifact; + preference?: JsonValue; +}>; +export type ArtifactIndex = Readonly<{ + index: string; + value: string; + order: string; +}>; +export type ArtifactCollectionDefinition = Readonly<{ + name: string; + /** The shared artifact type admitted by this collection. */ + type: string; + /** Increase when the projection changes. Saved rows are reindexed atomically. */ + version: number; + project(entry: ArtifactEntry): readonly ArtifactIndex[]; +}>; +export type ArtifactPageQuery = Readonly<{ + index: string; + value: string; + limit?: number; + after?: Readonly<{ order: string; id: string }>; +}>; +export type ArtifactPage = Readonly<{ + entries: readonly ArtifactEntry[]; + next?: Readonly<{ order: string; id: string }>; +}>; +export type ArtifactImport = Readonly<{ + records?: readonly Readonly<{ id: string; local: JsonValue }>[]; + preferences?: readonly Readonly<{ id: string; value: JsonValue }>[]; + /** The final batch and this marker commit together. */ + marker?: string; +}>; +export type ArtifactCollection = { + get(id: string): Promise; + page(query: ArtifactPageQuery): Promise; + /** Subscribe before the first read. Each notification requires a fresh read. */ + subscribe(listener: () => void): () => void; + preferences: { + get(id: string): Promise; + update( + id: string, + change: (current: JsonValue | undefined) => JsonValue, + ): Promise; + }; + /** Inserts absent snapshots and preferences. Retries preserve later edits. */ + importBatch(batch: ArtifactImport): Promise; + imported(marker: string): Promise; + close(): void; +}; +export type ReceivedArtifact = Readonly<{ + id: string; + channelId: string; + type: string; + revision: ArtifactRevision; +}>; +export type ArtifactSnapshot = Readonly<{ + source: string; + generation: string; + captureSequence: number; +}>; +export type ArtifactBatch = Readonly<{ + /** Exact authoritative query scope, defined by the sync owner. */ + source: string; + snapshot?: ArtifactSnapshot; + records?: readonly ReceivedArtifact[]; + /** Snapshot members whose revision is already saved. */ + seen?: readonly Readonly<{ id: string; revisionId: string }>[]; + availability?: readonly Readonly<{ + id: string; + expectedRevision: string | null; + value: ArtifactAvailability; + }>[]; + checkpoint?: Readonly<{ expected: string | null; next: string }>; +}>; +/** Host-only writes. Plugins submit shared changes through the delivery service. */ +export type HostArtifactCollection = ArtifactCollection & { + applyBatch(batch: ArtifactBatch): Promise; + checkpoint(): Promise; + beginSnapshot(source: string): Promise; + /** The caller validates server completion before this local transaction. */ + completeSnapshot( + snapshot: ArtifactSnapshot, + checkpoint: { expected: string | null; next: string }, + signal?: AbortSignal, + ): Promise; +}; diff --git a/src/features/communities/device-state.test.ts b/src/features/communities/device-state.test.ts index 24dd6286b2..6400db8d57 100644 --- a/src/features/communities/device-state.test.ts +++ b/src/features/communities/device-state.test.ts @@ -32,6 +32,13 @@ vi.mock("../relay/outbox-storage", () => ({ purgeOutboxStorage: (scope: string) => record("outbox", scope), })); +vi.mock("../artifact-storage/store", () => ({ + purgeArtifactStorage: (viewer: string, origin: string) => + typeof indexedDB === "undefined" + ? Promise.resolve() + : record("artifacts", viewer, origin), +})); + const viewer = "a".repeat(64); const other = "b".repeat(64); const origin = "https://left.example"; @@ -128,6 +135,7 @@ it("purges the relay's heads, read state and outbox for the exact partition, in ["heads.close"], ["read state", `${origin}:${viewer}`], ["outbox", `${origin}:${viewer}`], + ["artifacts", viewer, origin], ]); }); @@ -144,10 +152,15 @@ it("reports each failed relay store by name, closes heads and still clears the o const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const heads = new Error("heads blocked"); const reads = new Error("read state blocked"); - relay.fail.set("heads.clear", heads).set("read state", reads); + const artifacts = new Error("artifacts blocked"); + relay.fail + .set("heads.clear", heads) + .set("read state", reads) + .set("artifacts", artifacts); expect(await purgeCommunityDeviceState(origin, viewer)).toEqual([ { store: "channel heads", error: heads }, { store: "read state", error: reads }, + { store: "artifacts", error: artifacts }, ]); expect(relay.calls.map(([name]) => name)).toEqual([ "heads", @@ -155,9 +168,11 @@ it("reports each failed relay store by name, closes heads and still clears the o "heads.close", "read state", "outbox", + "artifacts", ]); expect(warn.mock.calls).toEqual([ [`Couldn't clear channel heads for ${origin} on this device`, heads], [`Couldn't clear read state for ${origin} on this device`, reads], + [`Couldn't clear artifacts for ${origin} on this device`, artifacts], ]); }); diff --git a/src/features/relay/partition-purge.ts b/src/features/relay/partition-purge.ts index 89a2097a6c..e1b561314a 100644 --- a/src/features/relay/partition-purge.ts +++ b/src/features/relay/partition-purge.ts @@ -1,3 +1,4 @@ +import { purgeArtifactStorage } from "../artifact-storage/store"; import { purgeOutboxStorage } from "./outbox-storage"; import { relayPartition } from "./partition"; import { createHeadPersistence } from "./persistence"; @@ -25,4 +26,5 @@ export async function purgeRelayPartition( }); await attempt("read state", () => purgeReadStateStorage(partition)); await attempt("outbox", () => purgeOutboxStorage(partition)); + await attempt("artifacts", () => purgeArtifactStorage(viewer, origin)); } diff --git a/src/plugins/author.ts b/src/plugins/author.ts index 7397b75c31..cc836e744f 100644 --- a/src/plugins/author.ts +++ b/src/plugins/author.ts @@ -91,3 +91,17 @@ export type { AgentSession, AgentSessions, } from "../features/agents/service"; + +export type { ArtifactStorage } from "../features/artifact-storage/service"; +export type { + ArtifactCollection, + ArtifactCollectionDefinition, + ArtifactEntry, + ArtifactIndex, + ArtifactPage, + ArtifactPageQuery, + ArtifactRevision, + ArtifactAvailability, + SavedArtifact, + ArtifactImport, +} from "../features/artifact-storage/types"; diff --git a/tests/browser/artifact-storage.spec.mjs b/tests/browser/artifact-storage.spec.mjs new file mode 100644 index 0000000000..3b47bf43c0 --- /dev/null +++ b/tests/browser/artifact-storage.spec.mjs @@ -0,0 +1,561 @@ +import { fileURLToPath } from "node:url"; +import { test as base, expect } from "./playwright-test.mjs"; +import { createServer } from "./vite-server.mjs"; + +const test = base.extend({ + storageUrl: [ + async ({ browserName }, use) => { + if (!["chromium", "webkit"].includes(browserName)) + throw new Error("Unsupported storage test engine"); + const server = await createServer({ + root: fileURLToPath(new URL("../../", import.meta.url)), + configFile: false, + envDir: false, + logLevel: "error", + server: { host: "127.0.0.1", port: 0 }, + }); + await server.listen(); + try { + await use( + `${server.resolvedUrls.local[0]}tests/fixtures/artifact-storage.html`, + ); + } finally { + await server.close(); + } + }, + { scope: "worker" }, + ], +}); +async function open(page, url) { + await page.goto(url); + await page.waitForFunction(() => !!window.artifactTest); +} + +// Real IndexedDB completion, restart and BroadcastChannel cannot be proved in a DOM emulator. +test("commits across windows and survives offline restart with isolated scopes", async ({ + page, + context, + storageUrl, +}) => { + await open(page, storageUrl); + const other = await context.newPage(); + await open(other, storageUrl); + await other.evaluate(async () => { + window.collection = window.artifactTest.open(); + await window.collection.get("task"); + window.change = new Promise((resolve) => { + window.collection.subscribe(async () => + resolve(await window.collection.get("task")), + ); + }); + }); + await page.evaluate(async () => { + const collection = window.artifactTest.open(); + await collection.importBatch({ + records: [{ id: "task", local: { title: "saved" } }], + preferences: [{ id: "task", value: { pinned: false } }], + marker: "done", + }); + }); + expect((await other.evaluate(() => window.change)).preference).toEqual({ + pinned: false, + }); + await page.reload(); + await page.waitForFunction(() => !!window.artifactTest); + await context.setOffline(true); + const result = await page.evaluate(async () => { + const collection = window.artifactTest.open(); + return { + row: await collection.get("task"), + marker: await collection.imported("done"), + identities: await window.artifactTest.open("other").get("task"), + relays: await window.artifactTest.open("viewer", "other").get("task"), + plugins: await window.artifactTest + .open("viewer", "relay", "other") + .get("task"), + collections: await window.artifactTest + .open("viewer", "relay", "test", { name: "other" }) + .get("task"), + }; + }); + expect(result.row.record.local).toEqual({ title: "saved" }); + expect(result.marker).toBe(true); + for (const field of ["identities", "relays", "plugins", "collections"]) + expect(result[field]).toBeUndefined(); +}); + +test("rolls back received batches and migration failures without publishing changes", async ({ + page, + storageUrl, +}) => { + await open(page, storageUrl); + const result = await page.evaluate(async () => { + const c = window.artifactTest.open(); + const notices = []; + c.subscribe(() => notices.push("change")); + const records = [ + { + id: "good", + channelId: "channel", + type: "test.task", + revision: { id: "a", parents: [], content: "{}" }, + }, + { + id: "bad", + channelId: "channel", + type: "test.task", + revision: { id: "cycle", parents: ["cycle"], content: "{}" }, + }, + ]; + const failure = await c + .applyBatch({ + source: "all", + records, + checkpoint: { expected: null, next: "bad" }, + }) + .then( + () => "unexpected", + (error) => error.message, + ); + const after = await c.get("good"); + const checkpoint = await c.checkpoint(); + const failing = window.artifactTest.open("viewer", "relay", "test", { + name: "migration", + project: () => { + throw new Error("projection failed"); + }, + }); + const migration = await failing + .importBatch({ + records: [{ id: "one", local: {} }], + preferences: [{ id: "one", value: false }], + marker: "done", + }) + .then( + () => "unexpected", + (error) => error.message, + ); + return { + failure, + after, + checkpoint, + notices, + migration, + imported: await failing.imported("done"), + pref: await failing.preferences.get("one"), + }; + }); + expect(result).toEqual({ + failure: "Artifact revision ancestry cycle", + after: undefined, + checkpoint: null, + notices: [], + migration: "projection failed", + imported: false, + pref: undefined, + }); +}); + +test("atomically indexes preference changes and preserves newer values during migration retries", async ({ + page, + context, + storageUrl, +}) => { + await open(page, storageUrl); + const other = await context.newPage(); + await open(other, storageUrl); + await page.evaluate(async () => { + window.collection = window.artifactTest.open(); + await window.collection.importBatch({ + records: [{ id: "task", local: { archived: false } }], + preferences: [{ id: "task", value: { pinned: true } }], + }); + }); + await other.evaluate(async () => { + const c = window.artifactTest.open(); + await c.preferences.update("task", () => ({ + archived: true, + pinned: false, + })); + }); + const result = await page.evaluate(async () => { + const c = window.collection; + await c.importBatch({ + records: [{ id: "task", local: { lost: true } }], + preferences: [{ id: "task", value: { pinned: true } }], + marker: "done", + }); + return { + archived: await c.page({ index: "archive", value: "true" }), + active: await c.page({ index: "archive", value: "false" }), + row: await c.get("task"), + }; + }); + expect(result.active.entries).toHaveLength(0); + expect(result.archived.entries).toHaveLength(1); + expect(result.row.preference).toEqual({ archived: true, pinned: false }); + expect(result.row.record.local).toEqual({ archived: false }); +}); + +test("reconciles only completed snapshot scopes and fences newer live commits", async ({ + page, + storageUrl, +}) => { + await open(page, storageUrl); + const result = await page.evaluate(async () => { + const c = window.artifactTest.open(); + const row = (id, revision = id) => ({ + id, + channelId: "channel", + type: "test.task", + revision: { id: revision, parents: [], content: "{}" }, + }); + await c.applyBatch({ + source: "one", + records: [row("missing"), row("live"), row("seen")], + availability: ["missing", "live", "seen"].map((id) => ({ + id, + expectedRevision: id, + value: { state: "available" }, + })), + }); + await c.applyBatch({ source: "other", records: [row("outside")] }); + await c.preferences.update("missing", () => ({ pinned: false })); + const snapshot = await c.beginSnapshot("one"); + await c.applyBatch({ source: "one", snapshot, records: [row("seen")] }); + await c.applyBatch({ + source: "one", + records: [row("new"), row("live", "new-live")], + }); + const before = await c.get("missing"); + await c.completeSnapshot(snapshot, { expected: null, next: "complete" }); + const stale = await c + .completeSnapshot(snapshot, { expected: null, next: "wrong" }) + .then( + () => "unexpected", + (error) => error.message, + ); + const interrupted = await c.beginSnapshot("one"); + const replacement = await c.beginSnapshot("one"); + const invalidated = await c + .completeSnapshot(interrupted, { expected: "complete", next: "wrong" }) + .then( + () => "unexpected", + (error) => error.message, + ); + return { + before, + rows: await Promise.all( + ["missing", "live", "seen", "new", "outside"].map((id) => c.get(id)), + ), + checkpoint: await c.checkpoint(), + stale, + invalidated, + replacement: !!replacement, + }; + }); + expect(result.before.record.availability.state).toBe("available"); + expect(result.rows.map((entry) => entry.record.availability.state)).toEqual([ + "unavailable", + "available", + "available", + "unknown", + "unknown", + ]); + expect(result.rows[0].record.current.content).toBe("{}"); + expect(result.rows[0].preference).toEqual({ pinned: false }); + expect(result.checkpoint).toBe("complete"); + expect(result.stale).toBe("Artifact snapshot changed"); + expect(result.invalidated).toBe("Artifact snapshot changed"); +}); + +test("reads bounded indexed pages for 10,000 tasks and 40,000 archived records", async ({ + page, + storageUrl, +}) => { + await open(page, storageUrl); + const result = await page.evaluate(async () => { + const c = window.artifactTest.open(); + const start = performance.now(); + for (let offset = 0; offset < 50000; offset += 1000) { + await c.importBatch({ + records: Array.from({ length: 1000 }, (_, n) => ({ + id: String(offset + n).padStart(6, "0"), + local: { archived: offset + n >= 10000 }, + })), + }); + } + const savedMs = performance.now() - start; + let reads = 0; + const get = IDBObjectStore.prototype.get; + IDBObjectStore.prototype.get = function (...args) { + if (this.name === "records") reads++; + return get.apply(this, args); + }; + const begin = performance.now(); + const first = await c.page({ index: "archive", value: "false", limit: 75 }); + const second = await c.page({ + index: "archive", + value: "false", + limit: 75, + after: first.next, + }); + const archive = await c.page({ + index: "archive", + value: "true", + limit: 75, + }); + const elapsedMs = performance.now() - begin; + IDBObjectStore.prototype.get = get; + window.artifactTest.close(); + const reopened = window.artifactTest.open(); + return { + savedMs, + elapsedMs, + reads, + first: first.entries.map((e) => e.record.id), + second: second.entries.map((e) => e.record.id), + archive: archive.entries.map((e) => e.record.id), + last: (await reopened.get("049999")).record.local, + }; + }); + expect(result.first).toHaveLength(75); + expect(result.second[0]).toBe("000075"); + expect(result.archive[0]).toBe("010000"); + expect(result.reads).toBe(225); + expect(result.last).toEqual({ archived: true }); + console.log( + JSON.stringify({ + engine: test.info().project.name, + savedMs: result.savedMs, + pageMs: result.elapsedMs, + recordReads: result.reads, + }), + ); +}); + +test("fences authoritative snapshot heads, moved homes, and stale availability", async ({ + page, + storageUrl, +}) => { + await open(page, storageUrl); + const result = await page.evaluate(async () => { + const c = window.artifactTest.open(); + const record = (id, parents, channelId = "old-home") => ({ + id: "task", + channelId, + type: "test.task", + revision: { id, parents, content: "{}" }, + }); + const availability = (expectedRevision, state) => ({ + id: "task", + expectedRevision, + value: { state }, + }); + await c.applyBatch({ + source: "all", + records: [record("A", [])], + availability: [availability("A", "available")], + }); + const snapshot = await c.beginSnapshot("all"); + await c.applyBatch({ + source: "all", + snapshot, + records: [record("C", ["missing-B"], "moved-home")], + availability: [availability("C", "available")], + }); + const authoritative = await c.get("task"); + const next = await c.beginSnapshot("all"); + await c.applyBatch({ + source: "all", + records: [record("D", ["C"], "new-home")], + availability: [availability("D", "deleted")], + }); + await c.applyBatch({ + source: "all", + snapshot: next, + records: [record("C", ["missing-B"], "moved-home")], + availability: [availability("C", "available")], + }); + await c.applyBatch({ + source: "all", + records: [record("A", [])], + availability: [availability("A", "available")], + }); + const after = await c.get("task"); + const oldSnapshot = await c.beginSnapshot("all"); + await c.applyBatch({ + source: "all", + snapshot: oldSnapshot, + records: [record("A", [])], + }); + const oldHead = await c.get("task"); + await c.applyBatch({ + source: "all", + snapshot: oldSnapshot, + seen: [{ id: "task", revisionId: "C" }], + }); + await c.completeSnapshot(oldSnapshot, { expected: null, next: "seen" }); + return { authoritative, after, oldHead, final: await c.get("task") }; + }); + expect(result.authoritative.record.current.id).toBe("C"); + expect(result.authoritative.record.channelId).toBe("moved-home"); + expect(result.authoritative.record.unresolved).toBe(true); + expect(result.authoritative.record.conflicts.map((r) => r.id)).toEqual(["A"]); + for (const entry of [result.after, result.oldHead, result.final]) { + expect(entry.record.current.id).toBe("D"); + expect(entry.record.channelId).toBe("new-home"); + expect(entry.record.availability.state).toBe("deleted"); + } +}); + +test("aborts snapshot completion before and during the local transaction", async ({ + page, + storageUrl, +}) => { + await open(page, storageUrl); + const result = await page.evaluate(async () => { + let controller; + let abortOnProjection = false; + const c = window.artifactTest.open("viewer", "relay", "test", { + project() { + if (abortOnProjection) + controller.abort(new Error("live interval lost")); + return []; + }, + }); + await c.applyBatch({ + source: "all", + records: [ + { + id: "task", + channelId: "channel", + type: "test.task", + revision: { id: "A", parents: [], content: "{}" }, + }, + ], + availability: [ + { id: "task", expectedRevision: "A", value: { state: "available" } }, + ], + }); + const snapshot = await c.beginSnapshot("all"); + const pre = new AbortController(); + pre.abort(new Error("owner closed")); + const before = await c + .completeSnapshot(snapshot, { expected: null, next: "bad" }, pre.signal) + .then( + () => "unexpected", + (e) => e.message, + ); + controller = new AbortController(); + abortOnProjection = true; + const during = await c + .completeSnapshot( + snapshot, + { expected: null, next: "bad" }, + controller.signal, + ) + .then( + () => "unexpected", + (e) => e.message, + ); + return { + before, + during, + row: await c.get("task"), + checkpoint: await c.checkpoint(), + }; + }); + expect(result.before).toBe("owner closed"); + expect(result.during).toBe("live interval lost"); + expect(result.row.record.availability.state).toBe("available"); + expect(result.checkpoint).toBeNull(); +}); + +test("rebuilds projection versions atomically and preserves explicit null preferences", async ({ + page, + storageUrl, +}) => { + await open(page, storageUrl); + const result = await page.evaluate(async () => { + const c = window.artifactTest.open(); + await c.importBatch({ + records: [{ id: "task", local: {} }], + preferences: [{ id: "task", value: true }], + }); + const newer = window.artifactTest.open("viewer", "relay", "test", { + version: 2, + project: ({ preference }) => [ + { index: "value", value: JSON.stringify(preference), order: "" }, + ], + }); + await newer.preferences.update("task", () => null); + const page = await newer.page({ index: "value", value: "null" }); + const old = await c.get("task").then( + () => "unexpected", + (e) => e.message, + ); + const older = window.artifactTest.open(); + const downgrade = await older.get("task").then( + () => "unexpected", + (e) => e.message, + ); + return { page, old, downgrade }; + }); + expect(result.page.entries[0].preference).toBeNull(); + expect(result.old).toBe("Artifact collection version changed"); + expect(result.downgrade).toBe("Artifact collection requires a newer plugin"); +}); + +test("purges one identity and relay across collections", async ({ + page, + storageUrl, +}) => { + await open(page, storageUrl); + const result = await page.evaluate(async () => { + const { createArtifactStore, purgeArtifactStorage } = await import( + "/src/features/artifact-storage/store.ts" + ); + const definition = { + name: "one", + type: "test.task", + version: 1, + project: () => [], + }; + const first = createArtifactStore("viewer", "relay"); + const other = createArtifactStore("other", "relay"); + for (const c of [ + first.collection("test", definition), + first.collection("test", { ...definition, name: "two" }), + other.collection("test", definition), + ]) { + await c.importBatch({ + records: [{ id: "task", local: {} }], + preferences: [{ id: "task", value: false }], + marker: "done", + }); + } + first.close(); + other.close(); + await purgeArtifactStorage("viewer", "relay"); + const reopened = createArtifactStore("viewer", "relay"); + const a = reopened.collection("test", definition); + const b = reopened.collection("test", { ...definition, name: "two" }); + const retained = createArtifactStore("other", "relay"); + const values = { + one: await a.get("task"), + two: await b.get("task"), + pref: await a.preferences.get("task"), + marker: await a.imported("done"), + other: await retained.collection("test", definition).get("task"), + }; + reopened.close(); + retained.close(); + return values; + }); + expect(result.one).toBeUndefined(); + expect(result.two).toBeUndefined(); + expect(result.pref).toBeUndefined(); + expect(result.marker).toBe(false); + expect(result.other.preference).toBe(false); +}); diff --git a/tests/fixtures/artifact-storage.html b/tests/fixtures/artifact-storage.html new file mode 100644 index 0000000000..3f63918767 --- /dev/null +++ b/tests/fixtures/artifact-storage.html @@ -0,0 +1 @@ +Artifact storage checks

Artifact storage checks

diff --git a/tests/fixtures/artifact-storage.ts b/tests/fixtures/artifact-storage.ts new file mode 100644 index 0000000000..aacfa95dd8 --- /dev/null +++ b/tests/fixtures/artifact-storage.ts @@ -0,0 +1,47 @@ +import { createArtifactStore } from "../../src/features/artifact-storage/store"; +import type { + ArtifactCollectionDefinition, + HostArtifactCollection, +} from "../../src/features/artifact-storage/types"; +const stores: ReturnType[] = []; +const definition: ArtifactCollectionDefinition = { + name: "tasks", + type: "test.task", + version: 1, + project({ record, preference }) { + const data = record.current + ? JSON.parse(record.current.content) + : record.local; + const prefs = preference as + | { archived?: boolean; pinned?: boolean } + | undefined; + const value = prefs?.archived ?? data?.archived ?? false; + return [ + { index: "archive", value: String(value), order: record.id }, + { index: "pin", value: String(prefs?.pinned ?? false), order: record.id }, + ]; + }, +}; +const api = { + open( + viewer = "viewer", + relay = "relay", + owner = "test", + overrides: Partial = {}, + ) { + const store = createArtifactStore(viewer, relay); + stores.push(store); + return store.collection(owner, { ...definition, ...overrides }); + }, + close() { + for (const store of stores) store.close(); + }, +}; +declare global { + interface Window { + artifactTest: typeof api; + collection: HostArtifactCollection; + change: Promise; + } +} +window.artifactTest = api; From d9f76624927da76b3a2b0e39764e65aca5a8922a Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 12:58:55 -0700 Subject: [PATCH 02/30] Preserve artifact scope and revision homes Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/local-artifact-storage.md | 10 +- src/features/artifact-storage/service.test.ts | 16 +- src/features/artifact-storage/service.ts | 6 +- src/features/artifact-storage/store.ts | 161 ++++++++++++------ src/features/artifact-storage/types.ts | 5 + tests/browser/artifact-storage.spec.mjs | 96 +++++++++++ 6 files changed, 233 insertions(+), 61 deletions(-) diff --git a/docs/local-artifact-storage.md b/docs/local-artifact-storage.md index e14ea36002..a0779a875d 100644 --- a/docs/local-artifact-storage.md +++ b/docs/local-artifact-storage.md @@ -24,7 +24,9 @@ const next = page.next The host derives the plugin identity from Cordis. The saved community selection supplies the viewer and relay origin, including while the network is unavailable. -A collection captures that scope. A handle rejects calls after the identity or relay changes. +A collection captures that scope. Its read-only `scope` descriptor supplies +`viewer`, normalized `relay`, and the legacy `partition` string for migration. +A handle rejects calls after the identity or relay changes. Close the handle when its view ends. Subscribe before the first read. Notifications follow a committed transaction and reach other windows through BroadcastChannel. They carry no saved content. Each window reads IndexedDB again. @@ -35,7 +37,8 @@ cursor. Each page reads only its selected records, with a maximum of 1,000 rows. Pages are independent transactions. A change between pages can move a row; restart the query after a notification. All windows must use the same collection type, version, and projector. Increase the version to rebuild saved indexes in one -transaction. An older handle then rejects further reads and writes. +transaction. An older handle then rejects further reads and writes. If opening the database +fails, close the failed handle and open another after the cause clears. `get(id)` returns a saved artifact and its separate preference. A record contains the current revision, conflicting heads, unresolved ancestry, known availability, @@ -71,7 +74,8 @@ The host collection adds these operations for sync: can update the saved channel home. - `completeSnapshot(snapshot, checkpoint)` reconciles absent records for only that source, then commits the checkpoint. Live and confirmed writes after capture - remain available. Content, local snapshots, and preferences remain saved. + remain available. A record remains available while another source still contains + it. Content, local snapshots, and preferences remain saved. `source` is an exact authoritative query scope supplied by sync. The sync owner must validate server completion and current access before it calls diff --git a/src/features/artifact-storage/service.test.ts b/src/features/artifact-storage/service.test.ts index 2441595346..f76cb924b2 100644 --- a/src/features/artifact-storage/service.test.ts +++ b/src/features/artifact-storage/service.test.ts @@ -29,7 +29,12 @@ it("binds collection ownership to the calling plugin and fences scope changes", // Storage is the external boundary here; browser tests exercise its real transactions. const get = vi.fn(async () => undefined); const close = vi.fn(); - const collection = { get, close }; + const scopeDescriptor = Object.freeze({ + viewer: "viewer", + relay: "https://relay.test", + partition: "https://relay.test:viewer", + }); + const collection = { get, close, scope: scopeDescriptor }; const factory = vi.fn(() => collection); const stores = service as unknown as { stores: Map }; stores.stores.set(JSON.stringify(["viewer", "https://relay.test"]), { @@ -38,6 +43,14 @@ it("binds collection ownership to the calling plugin and fences scope changes", }); const listener = vi.fn(); service.onOpen(listener); + const effects = root.fiber.getEffects().length; + for (let i = 0; i < 50; i++) { + const temporary = plugin.artifactStorage.open(definition); + expect(root.fiber.getEffects()).toHaveLength(effects + 1); + temporary.close(); + expect(root.fiber.getEffects()).toHaveLength(effects); + } + listener.mockClear(); const handle = plugin.artifactStorage.open(definition); expect(factory).toHaveBeenCalledWith("test.plugin", definition); expect(listener.mock.calls[0]?.[0]).toMatchObject({ @@ -47,6 +60,7 @@ it("binds collection ownership to the calling plugin and fences scope changes", collection, }); expect(handle).not.toHaveProperty("applyBatch"); + expect(handle.scope).toBe(scopeDescriptor); await handle.get("task"); expect(get).toHaveBeenCalledWith("task"); scope = { viewer: "other", selected: "https://relay.test" }; diff --git a/src/features/artifact-storage/service.ts b/src/features/artifact-storage/service.ts index 7ac2f675b5..a8bc7c70f3 100644 --- a/src/features/artifact-storage/service.ts +++ b/src/features/artifact-storage/service.ts @@ -65,10 +65,13 @@ export class ArtifactStorageService extends Service implements ArtifactStorage { const collection = store.collection(owner, definition); const controller = new AbortController(); let unsubscribe: (() => void) | undefined; + let removeEffect: (() => void) | undefined; const close = () => { + if (controller.signal.aborted) return; controller.abort(); unsubscribe?.(); collection.close(); + removeEffect?.(); }; const valid = () => { const now = capture(); @@ -89,6 +92,7 @@ export class ArtifactStorageService extends Service implements ArtifactStorage { return result; }; const facade: ArtifactCollection = { + scope: collection.scope, get(id) { valid(); return checked(collection.get(id)); @@ -121,7 +125,7 @@ export class ArtifactStorageService extends Service implements ArtifactStorage { }, close, }; - this.ctx.effect(() => close); + removeEffect = this.ctx.effect(() => close); for (const listener of this.opened) listener({ viewer, diff --git a/src/features/artifact-storage/store.ts b/src/features/artifact-storage/store.ts index 9d242c4529..79c7ac00d4 100644 --- a/src/features/artifact-storage/store.ts +++ b/src/features/artifact-storage/store.ts @@ -23,6 +23,7 @@ type Row = StoredArtifact & { collection: string; id: string; sequence?: number | undefined; + homes?: Record; }; type StoredPreference = { value: JsonValue }; @@ -47,42 +48,53 @@ export function createArtifactStore(viewer: string, relay: string) { let opening: Promise | undefined; function database() { if (closed) throw new Error("Artifact storage closed"); - opening ??= new Promise((resolve, reject) => { - const request = indexedDB.open(DB, 1); - let blocked = false; - request.onupgradeneeded = () => { - const db = request.result; - for (const name of STORES) { - const store = db.createObjectStore(name, { - keyPath: - name === "entries" - ? ["scope", "collection", "index", "value", "order", "id"] - : name === "members" - ? ["scope", "collection", "source", "id"] - : name === "metadata" - ? ["scope", "collection", "key"] - : ["scope", "collection", "id"], - }); - if (name === "entries") - store.createIndex("record", ["scope", "collection", "id"]); - } - }; - request.onerror = () => reject(request.error); - request.onblocked = () => { - blocked = true; - reject(new Error("Close other Buzz windows to open artifact storage")); - }; - request.onsuccess = () => { - const db = request.result; - if (closed || blocked) { - db.close(); - reject(new Error("Artifact storage closed")); - return; - } - db.onversionchange = () => db.close(); - resolve(db); - }; - }); + if (!opening) { + const attempt = new Promise((resolve, reject) => { + const request = indexedDB.open(DB, 1); + let blocked = false; + request.onupgradeneeded = () => { + const db = request.result; + for (const name of STORES) { + const store = db.createObjectStore(name, { + keyPath: + name === "entries" + ? ["scope", "collection", "index", "value", "order", "id"] + : name === "members" + ? ["scope", "collection", "source", "id"] + : name === "metadata" + ? ["scope", "collection", "key"] + : ["scope", "collection", "id"], + }); + if (name === "entries" || name === "members") + store.createIndex("record", ["scope", "collection", "id"]); + } + }; + request.onerror = () => reject(request.error); + request.onblocked = () => { + blocked = true; + reject( + new Error("Close other Buzz windows to open artifact storage"), + ); + }; + request.onsuccess = () => { + const db = request.result; + if (closed || blocked) { + db.close(); + reject(new Error("Artifact storage closed")); + return; + } + db.onversionchange = () => { + db.close(); + opening = undefined; + }; + resolve(db); + }; + }); + opening = attempt; + void attempt.catch(() => { + if (opening === attempt) opening = undefined; + }); + } return opening; } type Then = (request: IDBRequest, next: (value: T) => void) => void; @@ -329,6 +341,7 @@ export function createArtifactStore(viewer: string, relay: string) { }; } const handle: HostArtifactCollection = { + scope: Object.freeze({ viewer, relay, partition: scope }), get: (id) => run("readonly", (tx, then, done) => readEntry(tx, then, id, done), @@ -515,26 +528,47 @@ export function createArtifactStore(viewer: string, relay: string) { cursor.continue(); return; } - mutate( - tx, - then, - member.id, - (row) => ({ - row: - (row.sequence ?? 0) > snapshot.captureSequence - ? row - : { - ...row, - record: { - ...row.record, - availability: { - state: "unavailable", - reason: "Absent from completed snapshot", + then( + tx.objectStore("records").get(key(member.id)), + (saved: Row | undefined) => { + if (!saved) + throw new Error("Snapshot member has no record"); + if ((saved.sequence ?? 0) > snapshot.captureSequence) { + cursor.continue(); + return; + } + cursor.delete(); + then( + tx + .objectStore("members") + .index("record") + .count(IDBKeyRange.only(key(member.id))), + (remaining) => { + if (remaining) { + cursor.continue(); + return; + } + mutate( + tx, + then, + member.id, + (row) => ({ + row: { + ...row, + record: { + ...row.record, + availability: { + state: "unavailable", + reason: "Absent from completed snapshot", + }, }, }, - }, - }), - () => cursor.continue(), + }), + () => cursor.continue(), + ); + }, + ); + }, ); }); }); @@ -607,6 +641,10 @@ export function createArtifactStore(viewer: string, relay: string) { id: incoming.id, generation: batch.snapshot?.generation ?? null, }); + const homes = { + ...row.homes, + [incoming.revision.id]: incoming.channelId, + }; const received = receiveRevision(row, incoming.revision); if ( batch.snapshot && @@ -638,11 +676,13 @@ export function createArtifactStore(viewer: string, relay: string) { row: { ...row, ...received, + homes, sequence: batch.snapshot ? row.sequence : sequence, record: { ...received.record, - ...(received.record.current?.id === incoming.revision.id - ? { channelId: incoming.channelId } + ...(received.record.current && + homes[received.record.current.id] + ? { channelId: homes[received.record.current.id] } : {}), type: incoming.type, }, @@ -724,6 +764,7 @@ export function createArtifactStore(viewer: string, relay: string) { return { collection, async clear() { + const affected = new Set(); await transact("readwrite", (tx, then, done) => { let pending = STORES.length; for (const name of STORES) { @@ -735,11 +776,19 @@ export function createArtifactStore(viewer: string, relay: string) { if (--pending === 0) done(); return; } + affected.add(cursor.value.collection); cursor.delete(); cursor.continue(); }); } }); + for (const collection of affected) { + const channel = new BroadcastChannel( + `${DB}:${JSON.stringify([scope, collection])}`, + ); + channel.postMessage(null); + channel.close(); + } }, close() { closed = true; diff --git a/src/features/artifact-storage/types.ts b/src/features/artifact-storage/types.ts index 4457ca4a9f..d881a11e56 100644 --- a/src/features/artifact-storage/types.ts +++ b/src/features/artifact-storage/types.ts @@ -58,6 +58,11 @@ export type ArtifactImport = Readonly<{ marker?: string; }>; export type ArtifactCollection = { + readonly scope: Readonly<{ + viewer: string; + relay: string; + partition: string; + }>; get(id: string): Promise; page(query: ArtifactPageQuery): Promise; /** Subscribe before the first read. Each notification requires a fresh read. */ diff --git a/tests/browser/artifact-storage.spec.mjs b/tests/browser/artifact-storage.spec.mjs index 3b47bf43c0..7b3aaf98ac 100644 --- a/tests/browser/artifact-storage.spec.mjs +++ b/tests/browser/artifact-storage.spec.mjs @@ -537,7 +537,13 @@ test("purges one identity and relay across collections", async ({ } first.close(); other.close(); + const observer = createArtifactStore("viewer", "relay"); + const observed = observer.collection("test", definition); + await observed.get("task"); + const notification = new Promise((resolve) => observed.subscribe(resolve)); await purgeArtifactStorage("viewer", "relay"); + await notification; + observer.close(); const reopened = createArtifactStore("viewer", "relay"); const a = reopened.collection("test", definition); const b = reopened.collection("test", { ...definition, name: "two" }); @@ -559,3 +565,93 @@ test("purges one identity and relay across collections", async ({ expect(result.marker).toBe(false); expect(result.other.preference).toBe(false); }); + +test("keeps a pending revision's home and reconciles overlapping sources separately", async ({ + page, + storageUrl, +}) => { + await open(page, storageUrl); + const result = await page.evaluate(async () => { + const c = window.artifactTest.open(); + const record = (id, parents, channelId) => ({ + id: "task", + channelId, + type: "test.task", + revision: { id, parents, content: "{}" }, + }); + await c.applyBatch({ + source: "old-home", + records: [record("A", [], "old-home")], + availability: [ + { id: "task", expectedRevision: "A", value: { state: "available" } }, + ], + }); + await c.applyBatch({ + source: "new-home", + records: [record("C", ["B"], "new-home")], + }); + await c.applyBatch({ + source: "new-home", + records: [record("B", ["A"], "old-home")], + }); + const moved = await c.get("task"); + const old = await c.beginSnapshot("old-home"); + await c.completeSnapshot(old, { expected: null, next: "old-complete" }); + const retained = await c.get("task"); + const next = await c.beginSnapshot("new-home"); + await c.completeSnapshot(next, { + expected: "old-complete", + next: "all-complete", + }); + return { moved, retained, absent: await c.get("task") }; + }); + expect(result.moved.record.current.id).toBe("C"); + expect(result.moved.record.channelId).toBe("new-home"); + expect(result.retained.record.availability.state).toBe("available"); + expect(result.absent.record.availability.state).toBe("unavailable"); +}); + +test("retries database opening with a new handle after a transient failure", async ({ + page, + storageUrl, +}) => { + await open(page, storageUrl); + const result = await page.evaluate(async () => { + const { createArtifactStore } = await import( + "/src/features/artifact-storage/store.ts" + ); + const store = createArtifactStore("viewer", "relay"); + const definition = { + name: "retry", + type: "test.task", + version: 1, + project: () => [], + }; + const original = IDBFactory.prototype.open; + let attempts = 0; + IDBFactory.prototype.open = function (...args) { + if (++attempts === 1) + throw new DOMException("Temporary open failure", "UnknownError"); + return original.apply(this, args); + }; + try { + const failed = store.collection("test", definition); + const error = await failed.get("task").then( + () => "unexpected", + (e) => e.message, + ); + failed.close(); + const retried = store.collection("test", definition); + await retried.importBatch({ records: [{ id: "task", local: {} }] }); + return { error, attempts, saved: !!(await retried.get("task")) }; + } finally { + IDBFactory.prototype.open = original; + store.close(); + } + }); + expect(result).toEqual({ + error: "Temporary open failure", + attempts: 2, + saved: true, + }); +}); From db4bb1b7c8b64912eb590989f5f55aa2fd5bb538 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 13:19:33 -0700 Subject: [PATCH 03/30] Verify collection upgrades across windows Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/local-artifact-storage.md | 17 ++++++-- tests/browser/artifact-storage.spec.mjs | 53 ++++++++++++++++++++----- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/docs/local-artifact-storage.md b/docs/local-artifact-storage.md index a0779a875d..f69b1760a9 100644 --- a/docs/local-artifact-storage.md +++ b/docs/local-artifact-storage.md @@ -36,8 +36,13 @@ Several entries can index the same record. Pages use an exclusive `(order, id)` cursor. Each page reads only its selected records, with a maximum of 1,000 rows. Pages are independent transactions. A change between pages can move a row; restart the query after a notification. All windows must use the same collection type, -version, and projector. Increase the version to rebuild saved indexes in one -transaction. An older handle then rejects further reads and writes. If opening the database +version, and projector. Projection behavior, index definitions, and query semantics +tied to saved state must stay fixed within a version. Changes require a version +increase. The host checks the declared version in each read or write transaction. +It cannot detect undeclared changes to a projector. Increase the version to rebuild +saved indexes in one transaction. A failed rebuild preserves the saved version and +data. An incompatible type fails explicitly. An older handle then rejects further +reads and writes. If opening the database fails, close the failed handle and open another after the cause clears. `get(id)` returns a saved artifact and its separate preference. A record contains @@ -86,7 +91,13 @@ opaque strings; this store does not require a durable server cursor. `ArtifactStorageService.onOpen` lets host sync attach to a collection and its type descriptor and a lifetime AbortSignal. Closing the handle or changing identity or -community aborts that signal. Leaving a community purges only its saved partition. +community aborts that signal. Sync uses one Web Lock per +`{viewer, relay, owner, collectionName}`. Only a window with the real collection +definition can own that work. Checkpoints and snapshot completion stay within that +collection and its exact source query. The owner cancels work before it releases +the lock. A late response cannot commit after the owner closes. Outbox ownership +uses its separate host service. Records, preferences, and source membership are +isolated by collection. Leaving a community purges only its saved partition. Plugins receive the read, preference, and migration contract. They do not receive `applyBatch` or transport access. The task plugin owns task fields, parent rules, search tokens, and the meaning of device preferences. diff --git a/tests/browser/artifact-storage.spec.mjs b/tests/browser/artifact-storage.spec.mjs index 7b3aaf98ac..3d3c81f3ac 100644 --- a/tests/browser/artifact-storage.spec.mjs +++ b/tests/browser/artifact-storage.spec.mjs @@ -472,39 +472,74 @@ test("aborts snapshot completion before and during the local transaction", async expect(result.checkpoint).toBeNull(); }); -test("rebuilds projection versions atomically and preserves explicit null preferences", async ({ +test("upgrades across windows and rejects stale writes and snapshot completion", async ({ page, + context, storageUrl, }) => { await open(page, storageUrl); - const result = await page.evaluate(async () => { - const c = window.artifactTest.open(); - await c.importBatch({ + const other = await context.newPage(); + await open(other, storageUrl); + const snapshot = await other.evaluate(async () => { + window.collection = window.artifactTest.open(); + await window.collection.importBatch({ records: [{ id: "task", local: {} }], preferences: [{ id: "task", value: true }], }); + return window.collection.beginSnapshot("all"); + }); + const result = await page.evaluate(async () => { const newer = window.artifactTest.open("viewer", "relay", "test", { version: 2, project: ({ preference }) => [ { index: "value", value: JSON.stringify(preference), order: "" }, ], }); + window.collection = newer; await newer.preferences.update("task", () => null); const page = await newer.page({ index: "value", value: "null" }); - const old = await c.get("task").then( + const older = window.artifactTest.open(); + const downgrade = await older.get("task").then( () => "unexpected", (e) => e.message, ); - const older = window.artifactTest.open(); - const downgrade = await older.get("task").then( + const incompatible = window.artifactTest.open("viewer", "relay", "test", { + version: 2, + type: "other.task", + }); + const type = await incompatible.get("task").then( () => "unexpected", (e) => e.message, ); - return { page, old, downgrade }; + return { page, downgrade, type }; }); + const stale = await other.evaluate(async (snapshot) => { + const error = (e) => e.message; + return Promise.all([ + window.collection.preferences + .update("task", () => false) + .then(() => "unexpected", error), + window.collection + .completeSnapshot(snapshot, { expected: null, next: "stale" }) + .then(() => "unexpected", error), + ]); + }, snapshot); expect(result.page.entries[0].preference).toBeNull(); - expect(result.old).toBe("Artifact collection version changed"); + expect(stale).toEqual([ + "Artifact collection version changed", + "Artifact collection version changed", + ]); expect(result.downgrade).toBe("Artifact collection requires a newer plugin"); + expect(result.type).toBe("Artifact collection type changed"); + expect( + await page.evaluate(async () => { + const c = window.collection; + return { + preference: await c.preferences.get("task"), + checkpoint: await c.checkpoint(), + }; + }), + ).toEqual({ preference: null, checkpoint: null }); }); test("purges one identity and relay across collections", async ({ From 0a3ed19f2bed65c057b72eabd4625dc284da04cf Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 13:29:29 -0700 Subject: [PATCH 04/30] Sync saved artifacts and track agent receipt Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- browser-host/relay-broker-live.test.mjs | 6 +- browser-host/relay-broker.mjs | 6 +- docs/artifact-sync-delivery.md | 39 ++ src/app/services.ts | 2 + src/features/agents/runner.ts | 9 + src/features/agents/service.ts | 14 + src/features/conversation/EmbeddedThread.tsx | 19 +- src/features/messages/DeliveryNotice.test.tsx | 58 ++- src/features/messages/DeliveryNotice.tsx | 14 + src/features/messages/MessageRow.tsx | 7 +- src/features/messages/Messages.module.css | 18 + src/features/relay/artifact-reconcile.test.ts | 196 +++++++++ src/features/relay/artifact-reconcile.ts | 167 ++++++++ src/features/relay/artifact-snapshot.test.ts | 238 +++++++++++ src/features/relay/artifact-snapshot.ts | 328 +++++++++++++++ src/features/relay/artifact-sync.test.ts | 163 ++++++++ src/features/relay/artifact-sync.ts | 331 +++++++++++++++ src/features/relay/broker-live.test.ts | 2 +- src/features/relay/broker-live.ts | 7 +- src/features/relay/contracts.ts | 2 + src/features/relay/live-recovery.ts | 5 +- src/features/relay/live.test.ts | 203 +++++++++- src/features/relay/live.ts | 153 +++++-- src/features/relay/managed-delivery.test.ts | 227 +++++++++++ src/features/relay/managed-delivery.ts | 145 +++++++ src/features/relay/message-projection.ts | 7 +- src/features/relay/messages.ts | 17 + src/features/relay/native.ts | 22 +- src/features/relay/outbox-storage.ts | 55 ++- src/features/relay/outbox.ts | 377 +++++++++++++++++- src/features/relay/session.ts | 155 +++++-- src/features/relay/transport.ts | 2 + tests/browser/managed-delivery.spec.mjs | 168 ++++++++ tests/fixtures/managed-delivery.html | 1 + tests/fixtures/managed-delivery.ts | 150 +++++++ 35 files changed, 3211 insertions(+), 102 deletions(-) create mode 100644 docs/artifact-sync-delivery.md create mode 100644 src/features/relay/artifact-reconcile.test.ts create mode 100644 src/features/relay/artifact-reconcile.ts create mode 100644 src/features/relay/artifact-snapshot.test.ts create mode 100644 src/features/relay/artifact-snapshot.ts create mode 100644 src/features/relay/artifact-sync.test.ts create mode 100644 src/features/relay/artifact-sync.ts create mode 100644 src/features/relay/managed-delivery.test.ts create mode 100644 src/features/relay/managed-delivery.ts create mode 100644 tests/browser/managed-delivery.spec.mjs create mode 100644 tests/fixtures/managed-delivery.html create mode 100644 tests/fixtures/managed-delivery.ts diff --git a/browser-host/relay-broker-live.test.mjs b/browser-host/relay-broker-live.test.mjs index ced5a002fc..2302a4a87d 100644 --- a/browser-host/relay-broker-live.test.mjs +++ b/browser-host/relay-broker-live.test.mjs @@ -258,7 +258,11 @@ test("real HTTP accepts the 1022-channel body and rejects invalid/oversized/orig large.abort(); await until(() => h.sockets.every((s) => s.readyState === 3)); const count = h.sockets.length; - for (const invalid of [[""], Array(1025).fill("a"), ["x".repeat(150001)]]) { + for (const invalid of [ + [""], + Array(100001).fill("a"), + ["x".repeat(150001)], + ]) { const { response } = await h.post(invalid); expect([400, 413]).toContain(response.status); await response.text(); diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs index e40931d9c5..48b8d1c21e 100644 --- a/browser-host/relay-broker.mjs +++ b/browser-host/relay-broker.mjs @@ -1490,7 +1490,7 @@ export function relayBrokerPlugin({ if ( Buffer.byteLength(raw) > (updating - ? 450000 + ? 32 * 1024 * 1024 : watchingPresence ? 20000 : prioritizing @@ -1607,7 +1607,7 @@ export function relayBrokerPlugin({ req.setEncoding("utf8"); for await (const part of req) { raw += part; - if (Buffer.byteLength(raw) > 300000) + if (Buffer.byteLength(raw) > 32 * 1024 * 1024) return json(res, 413, { error: "Live interests too large" }); } let channels, @@ -1655,7 +1655,7 @@ export function relayBrokerPlugin({ // Bound slow-client buffering rather than retaining unbounded traffic. const write = (kind, value) => { if (res.destroyed) return; - if (res.writableLength > 2 * 1024 * 1024) { + if (res.writableLength > 32 * 1024 * 1024) { res.destroy(); return; } diff --git a/docs/artifact-sync-delivery.md b/docs/artifact-sync-delivery.md new file mode 100644 index 0000000000..110c5529ae --- /dev/null +++ b/docs/artifact-sync-delivery.md @@ -0,0 +1,39 @@ +# Artifact sync and message delivery + +The host saves received artifacts through `ArtifactStorageService`. Plugins own each collection definition and projector. A Web Lock covers `{viewer, relay, owner, collectionName}`. Only a window with that definition can own the sync work. The owner cancels reads and waits for commits before it releases the lock. The message outbox has a separate lock for `{viewer, relay}`. + +## Snapshot contract + +Sync uses the existing relay session and socket. It needs the version 1 `artifacts.snapshot` capability. The relay must support a stable manifest of artifact IDs, exact revision IDs, and channel IDs. It must validate access and completion. Offset pages do not meet this contract. + +The client saves validated bodies during capture. It starts with 100 bodies per request. It splits a batch when the relay returns `snapshot_limit`. Saved bodies remain available after an interrupted capture. A retry can reuse them. Only valid completion permits absence reconciliation. Preferences, pending work, and records with newer local commits survive completion. A reset, expired token, changed access, or lost connection prevents completion. + +Live events and confirmed local artifact writes use the same revision checks and atomic store path. A late acceptance cannot replace a newer revision. Repairs run after reconnect, collection changes, and live hints. A 60-second repair covers homes outside active message routes. Repairs are sequential within a collection. Three short retries precede the next repair interval. Local reads do not wait for repair. + +The relay snapshot PR is a prerequisite. A relay without the capability keeps the saved view. This client does not invent a checkpoint from ordinary query results. + +## Live channel groups + +Large joined rosters use live groups of at most 1,000 channels. Each group stays open while recent replay reads run in groups of ten channels. At most four requests await EOSE. Each replay filter keeps its 500-event limit. EOSE establishes a route; it does not prove that history is complete. A full limit remains incomplete evidence. Existing finite catch-up owns older gaps. The agent runner retains its event-ID checks before dispatch. + +The transport accepts at most 100,000 channel interests. Singleton previews consume separate routes. Demand beyond route capacity gets a limited status. Grouped interests preserve ordinary chat and all-channel agent watches. They do not make 100,000 simultaneous agent runs a supported workload. + +The relay permits 1,024 subscriptions, ten filters per request, and 2,048 explicit channel values per request. The default frame limit is 524,288 bytes. Browser broker controls and status frames have a separate 32 MiB bound because their local status includes each channel. This bound does not change relay event limits. + +## Managed delivery + +`session.messagesForRecipient(agentPubkey)` uses the existing message validation and composer path. `EmbeddedThread.managedRecipient` selects this path for a task conversation. Ordinary chat keeps its existing behavior. `session.delivery.submit(input)` admits kind-45010 artifact operations. `delivery.get(id)` is asynchronous. `delivery.subscribe(listener)` reports durable state changes. + +The outbox saves intent and the signed event before publication. It retains the same event ID across uncertain retries. The owner makes at most three attempts before an error. A definitive relay refusal stops automatic retries. Acceptance and exact agent receipt are separate saved facts. Stale writes from another window cannot clear either fact. + +`AgentRun.received(messageIds)` reports only the exact original message IDs. The host binds the channel and agent identity. The plugin calls it at a verified harness receipt boundary. Process start, typing, and an unrelated reply do not prove receipt. The host saves receipt before it clears the opacity pulse. A save failure keeps accepted evidence and exposes an unconfirmed state. + +Normal managed delivery has no extra status label. Message opacity pulses until exact receipt. Reduced motion uses static opacity. After 30 seconds without receipt, the pulse stops and the message shows `Could not confirm delivery`. `Check again` rereads saved evidence and permits a late exact callback. It never republishes an accepted message. A later exact receipt clears the error. + +Exact receipt is local in v0. A crash before receipt persistence, or receipt on another device, can leave an accepted message unconfirmed. There is no remote receipt recovery protocol. Kind 44200 is a completed-turn usage metric and cannot carry a receipt-only event. A separate protocol is necessary to remove this ambiguity. + +## Verification limits + +Focused tests cover publication, exact receipt, restart, stale writes, lost confirmations, refusal, expiry, and completion fences. Browser checks use real IndexedDB, BroadcastChannel, and Web Locks in Chromium and WebKit. The socket scale check covers 10,000 active homes plus 50,000 archive homes and a normal chat. It verifies grouping and request bounds with a test socket. It does not measure the deployed relay's startup cost. + +Real snapshot verification requires the relay prerequisite and this client together. The current community does not advertise snapshot support. Browser fixtures do not prove native signing or relay access. Native runtime and theme checks remain required before readiness. diff --git a/src/app/services.ts b/src/app/services.ts index 92c47d5cb9..198cae92f7 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -1,4 +1,5 @@ import { ArtifactStorageService } from "../features/artifact-storage/service"; +import { provideArtifactSync } from "../features/relay/artifact-sync"; import { createAdminAccess } from "../bundled/admin-access"; import { nativeRelayStaff } from "../features/relay-staff/native"; import { createLocalAgentActivity } from "../features/agents/local-activity"; @@ -77,6 +78,7 @@ export function createServices() { }); const relay = communities.relay; const artifactStorage = new ArtifactStorageService(ctx); + provideArtifactSync(ctx, artifactStorage); const adminAccess = createAdminAccess(relay, communities, nativeRelayStaff()); ctx.provide("adminAccess", adminAccess); ctx.effect(() => () => adminAccess.dispose()); diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts index 793f7a304c..37b1d14d8e 100644 --- a/src/features/agents/runner.ts +++ b/src/features/agents/runner.ts @@ -311,6 +311,11 @@ const message = (error: unknown) => /** What the runner needs of its host. */ export type RunnerHost = { + received?( + agent: Agent, + channelId: string, + messageIds: readonly string[], + ): Promise; /** Session controls refresh when this copy gains or loses native custody. */ ownershipChanged?(): void; /** The agent, while its community is live to run it in. */ @@ -2000,6 +2005,10 @@ export class AgentRunners { workingUntil: working.hold, ignored: () => job.work?.remove(), acknowledged: () => job.work?.started(), + received: (messageIds: readonly string[]) => + job.channelId && this.host.received + ? this.host.received(agent, job.channelId, messageIds) + : Promise.reject(new Error("Message receipt has no channel")), superseded: () => job.work?.supersede(), interruptible: (stop: () => Promise) => job.work?.interruptible(stop), diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index c2d8f213a5..f06fc0479d 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -1,4 +1,5 @@ import { createLocalAgentActivity } from "./local-activity"; +import { receiveManagedDelivery } from "../relay/managed-delivery"; import { AgentConfigStore, type SettingsSchema, @@ -472,6 +473,8 @@ export type Delivery = Readonly<{ ignored?(): void; /** The harness acknowledged this exact request. Not proof of model consumption. */ acknowledged?(): void; + /** Exact prompt receipt. The caller waits for durable host confirmation. */ + received?(messageIds: readonly string[]): Promise; /** A newer acknowledged request owns this turn's activity; hide this message's * bubble without claiming success or changing the execution outcome. */ superseded?(): void; @@ -1237,6 +1240,17 @@ export class AgentsService extends Service implements Agents { starting: (agent, channelId) => this.bindingOf(agent)?.session.channelWrites.starting(channelId), community: (agent) => this.bindingOf(agent), + received: (agent, channelId, messageIds) => { + const binding = this.bindingOf(agent); + if (!binding) + return Promise.reject(new Error("Agent community is unavailable")); + return receiveManagedDelivery( + binding.scope, + channelId, + agent.pubkey, + messageIds, + ); + }, records: () => Object.values(this.records), checkpoints: { read: () => (savedProgress = readCheckpoints(this.storage)), diff --git a/src/features/conversation/EmbeddedThread.tsx b/src/features/conversation/EmbeddedThread.tsx index e1278ffa9d..d2cb6f1499 100644 --- a/src/features/conversation/EmbeddedThread.tsx +++ b/src/features/conversation/EmbeddedThread.tsx @@ -1,4 +1,4 @@ -import { useSyncExternalStore } from "react"; +import { useMemo, useSyncExternalStore } from "react"; import type { ThreadPanelProps } from "../messages/ThreadPanel"; import { messageViewKey } from "../messages/view-key"; import { ConversationChannel, ConversationThread } from "./Conversation"; @@ -8,7 +8,7 @@ import type { ConversationHostServices } from "./ConversationHost"; export type EmbeddedThreadProps = Pick< ThreadPanelProps, "session" | "scope" | "channelId" | "channelName" | "messageId" ->; +> & { managedRecipient?: string | undefined }; type Props = EmbeddedThreadProps & { host: ConversationHostServices; extensions: NonNullable; @@ -23,7 +23,18 @@ export function EmbeddedThread({ messageId, host, extensions, + managedRecipient, }: Props) { + const managedSession = useMemo( + () => + managedRecipient + ? Object.freeze({ + ...session, + messages: session.messagesForRecipient(managedRecipient), + }) + : session, + [session, managedRecipient], + ); const { channels } = useSyncExternalStore( session.channels.subscribeList, session.channels.list, @@ -31,8 +42,8 @@ export function EmbeddedThread({ const channel = channels.find((item) => item.id === channelId); return ( { + render( + , + ); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }, +); +it.each([false, true])( + "uses the recovery action for relay acceptance %s", + (relayAccepted) => { + const retry = vi.fn(); + render( + , + ); + expect(screen.getByRole("status")).toHaveTextContent( + relayAccepted ? "Could not confirm delivery" : "Could not send", + ); + fireEvent.click( + screen.getByRole("button", { + name: relayAccepted ? "Check again" : "Retry", + }), + ); + expect(retry).toHaveBeenCalledExactlyOnceWith(row.id); + }, +); it.each(["sending", "accepted", "unknown", "failed"] as const)( "keeps %s feedback visible without a retry handler", diff --git a/src/features/messages/DeliveryNotice.tsx b/src/features/messages/DeliveryNotice.tsx index 470b0e2f7c..3b95919c74 100644 --- a/src/features/messages/DeliveryNotice.tsx +++ b/src/features/messages/DeliveryNotice.tsx @@ -22,6 +22,20 @@ export function DeliveryNotice({ return () => clearTimeout(timer); }, [row.createdAt, row.delivery]); const feedback = deliveryFeedback(row, now); + if (row.managedDelivery) { + const managed = row.managedDelivery; + if (managed.phase !== "failed") return null; + return ( +
+ {managed.error} + {retry && ( + + )} +
+ ); + } if (!feedback) return null; const canRetry = (row.delivery === "failed" || row.delivery === "unknown") && retry; diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index 589d99c311..3d711840da 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -763,7 +763,12 @@ function MessageRowContent({ > {!renderMessage && } -
+
{!renderMessage && overlays}
+ signed(key, { + kind: 45010, + content: `{"task":${number}}`, + tags: [ + ["ar", "1"], + ["d", `22222222-2222-4222-8222-${String(number).padStart(12, "0")}`], + ["h", channelId], + ["type", "test.task"], + ["title", "Task"], + ["op", "create"], + ], + }); +function setup() { + const events = [event(1), event(2), event(3)]; + const entries = events.map((item) => ({ + artifactId: item.tags.find(([name]) => name === "d")?.[1] as string, + revisionId: item.id, + channelId, + })); + const snapshot = { + source: "source", + generation: "generation", + captureSequence: 4, + }; + const collection = { + checkpoint: vi.fn(async () => "old"), + beginSnapshot: vi.fn(async (source: string) => ({ ...snapshot, source })), + get: vi.fn( + async (_id: string) => + undefined as Awaited>, + ), + applyBatch: vi.fn(async () => {}), + completeSnapshot: vi.fn(async () => {}), + }; + const page = ( + start: number, + end: number, + nextToken: string | null, + ): ArtifactManifestPage => ({ + token: "token", + expiresAt: Math.floor(Date.now() / 1000) + 300, + entries: entries.slice(start, end), + nextToken, + }); + const reader = { + start: vi.fn(async () => page(0, 2, "next")), + page: vi.fn(async () => page(2, 3, null)), + bodies: vi.fn(async (_token, wanted) => + events.filter((item) => + wanted.some((entry) => entry.revisionId === item.id), + ), + ), + complete: vi.fn(async () => {}), + }; + const controller = new AbortController(); + const run = (beforeComplete?: () => Promise) => + reconcileArtifactSnapshot( + reader, + collection as unknown as HostArtifactCollection, + "test.task", + controller.signal, + beforeComplete, + ); + return { events, entries, collection, reader, controller, run }; +} +it("commits each page before validated completion and advances the captured checkpoint", async () => { + const h = setup(); + await h.run(); + expect(h.reader.bodies).toHaveBeenCalledTimes(2); + expect(h.reader.page).toHaveBeenCalledWith( + "token", + "next", + h.controller.signal, + ); + expect(h.collection.applyBatch.mock.invocationCallOrder[1]).toBeLessThan( + h.reader.complete.mock.invocationCallOrder[0] as number, + ); + expect(h.reader.complete.mock.invocationCallOrder[0]).toBeLessThan( + h.collection.completeSnapshot.mock.invocationCallOrder[0] as number, + ); + expect(h.collection.completeSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ captureSequence: 4 }), + { expected: "old", next: "token" }, + h.controller.signal, + ); +}); +it("reuses a verified cached body and marks its captured revision seen", async () => { + const h = setup(); + const first = h.events[0]; + h.collection.get.mockImplementation(async (id) => + id === h.entries[0]?.artifactId && first + ? { + record: { + id, + conflicts: [], + unresolved: false, + availability: { state: "available" }, + current: { + id: first.id, + parents: [], + content: first.content, + event: first, + }, + }, + } + : undefined, + ); + await h.run(); + expect(h.reader.bodies.mock.calls[0]?.[1]).toEqual([h.entries[1]]); + expect(h.collection.applyBatch).toHaveBeenCalledWith( + expect.objectContaining({ + seen: [{ id: h.entries[0]?.artifactId, revisionId: first?.id }], + }), + ); +}); +it("keeps received pages after an interrupted read without reconciling absence", async () => { + const h = setup(); + h.reader.page.mockRejectedValue(new Error("Disconnected")); + await expect(h.run()).rejects.toThrow("Disconnected"); + expect(h.collection.applyBatch).toHaveBeenCalledOnce(); + expect(h.collection.completeSnapshot).not.toHaveBeenCalled(); +}); +it.each(["snapshot_reset", "snapshot_limit"] as const)( + "does not reconcile absence after server %s", + async (code) => { + const h = setup(); + h.reader.complete.mockRejectedValue( + new ArtifactSnapshotError(code, "Snapshot invalid"), + ); + await expect(h.run()).rejects.toMatchObject({ code }); + expect(h.collection.completeSnapshot).not.toHaveBeenCalled(); + }, +); +it("waits for queued live commits before local completion", async () => { + const h = setup(); + let release = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const before = vi.fn(() => gate); + const work = h.run(before); + await vi.waitFor(() => expect(before).toHaveBeenCalledOnce()); + expect(h.collection.completeSnapshot).not.toHaveBeenCalled(); + release(); + await work; + expect(h.collection.completeSnapshot).toHaveBeenCalledOnce(); +}); +it("rejects local completion when the live interval is lost after server completion", async () => { + const h = setup(); + await expect( + h.run(async () => { + h.controller.abort(); + }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(h.collection.completeSnapshot).not.toHaveBeenCalled(); +}); +it("rejects repeated identities across pages", async () => { + const h = setup(); + h.reader.page.mockImplementation(async () => ({ + ...(await h.reader.start()), + nextToken: null, + })); + await expect(h.run()).rejects.toThrow("repeats"); + expect(h.collection.completeSnapshot).not.toHaveBeenCalled(); +}); +it("reduces a body batch on a byte limit and rejects an unsatisfiable single body", async () => { + const h = setup(); + h.reader.bodies.mockImplementation(async (_token, wanted) => { + if (wanted.length > 1) + throw new ArtifactSnapshotError("snapshot_limit", "Body batch too large"); + return h.events.filter((item) => + wanted.some((entry) => entry.revisionId === item.id), + ); + }); + await h.run(); + expect(h.reader.bodies).toHaveBeenCalledTimes(4); + h.reader.bodies.mockRejectedValue( + new ArtifactSnapshotError("snapshot_limit", "Body too large"), + ); + h.collection.completeSnapshot.mockClear(); + await expect(h.run()).rejects.toMatchObject({ code: "snapshot_limit" }); + expect(h.collection.completeSnapshot).not.toHaveBeenCalled(); +}); diff --git a/src/features/relay/artifact-reconcile.ts b/src/features/relay/artifact-reconcile.ts new file mode 100644 index 0000000000..0b96fc93fb --- /dev/null +++ b/src/features/relay/artifact-reconcile.ts @@ -0,0 +1,167 @@ +import type { + HostArtifactCollection, + ReceivedArtifact, +} from "../artifact-storage/types"; +import { eventDto, type RelayEvent } from "./events"; +import { + ARTIFACT_SNAPSHOT_LIMITS, + ArtifactSnapshotError, + type ArtifactManifestEntry, + type ArtifactSnapshotReader, +} from "./artifact-snapshot"; + +export function receivedArtifact( + event: RelayEvent, + type: string, +): ReceivedArtifact { + const one = (name: string) => { + const tags = event.tags.filter(([key]) => key === name); + if (tags.length !== 1 || tags[0]?.length !== 2) + throw new Error("Invalid artifact envelope"); + return tags[0][1] as string; + }; + const id = one("d"); + const channelId = one("h"); + const op = one("op"); + const previous = event.tags.filter(([key]) => key === "prev"); + if ( + event.kind !== 45010 || + one("ar") !== "1" || + one("type") !== type || + !["create", "update", "move", "delete", "restore"].includes(op) || + (op === "create" + ? previous.length !== 0 + : previous.length !== 1 || + previous[0]?.length !== 2 || + !/^[0-9a-f]{64}$/.test(previous[0]?.[1] ?? "")) + ) + throw new Error("Invalid artifact envelope"); + return { + id, + channelId, + type, + revision: { + id: event.id, + parents: previous.map((tag) => tag[1] as string), + content: event.content, + event, + }, + }; +} + +/** Pages can commit during a failed refresh. Only validated completion admits absence. */ +export async function reconcileArtifactSnapshot( + reader: ArtifactSnapshotReader, + collection: HostArtifactCollection, + type: string, + signal: AbortSignal, + beforeComplete: () => Promise = async () => {}, +) { + const source = JSON.stringify({ + artifact: "current", + kinds: [45010], + "#type": [type], + }); + const expected = await collection.checkpoint(); + signal.throwIfAborted(); + const snapshot = await collection.beginSnapshot(source); + let page = await reader.start( + { artifact: "current", kinds: [45010], "#type": [type] }, + signal, + ); + const token = page.token; + const seen = new Set(); + const cursors = new Set(); + async function saveBodies( + entries: readonly ArtifactManifestEntry[], + ): Promise { + if (!entries.length) return; + let events: readonly RelayEvent[]; + try { + events = await reader.bodies(token, entries, signal); + } catch (error) { + if ( + !(error instanceof ArtifactSnapshotError) || + error.code !== "snapshot_limit" || + entries.length === 1 + ) + throw error; + const middle = Math.ceil(entries.length / 2); + await saveBodies(entries.slice(0, middle)); + await saveBodies(entries.slice(middle)); + return; + } + signal.throwIfAborted(); + const records = events.map((event) => receivedArtifact(event, type)); + await collection.applyBatch({ + source, + snapshot, + records, + availability: records.map((record) => ({ + id: record.id, + expectedRevision: record.revision.id, + value: { state: "available" as const }, + })), + }); + signal.throwIfAborted(); + } + while (true) { + signal.throwIfAborted(); + const cached: { id: string; revisionId: string }[] = []; + const missing: ArtifactManifestEntry[] = []; + for (const entry of page.entries) { + if ( + seen.has(entry.artifactId) || + seen.size >= ARTIFACT_SNAPSHOT_LIMITS.maxRows + ) + throw new Error("Artifact manifest repeats or exceeds capacity"); + seen.add(entry.artifactId); + const saved = await collection.get(entry.artifactId); + signal.throwIfAborted(); + const revision = saved?.record.current; + if (revision?.id === entry.revisionId && revision.event) { + const event = eventDto(revision.event); + const record = receivedArtifact(event, type); + if ( + record.id !== entry.artifactId || + record.channelId !== entry.channelId || + event.content !== revision.content + ) + throw new Error("Saved artifact does not match its revision"); + cached.push({ id: entry.artifactId, revisionId: entry.revisionId }); + } else missing.push(entry); + } + if (cached.length) + await collection.applyBatch({ + source, + snapshot, + seen: cached, + availability: cached.map((entry) => ({ + id: entry.id, + expectedRevision: entry.revisionId, + value: { state: "available" as const }, + })), + }); + for ( + let offset = 0; + offset < missing.length; + offset += ARTIFACT_SNAPSHOT_LIMITS.bodyRows + ) + await saveBodies( + missing.slice(offset, offset + ARTIFACT_SNAPSHOT_LIMITS.bodyRows), + ); + if (page.nextToken === null) break; + if (cursors.has(page.nextToken)) + throw new Error("Artifact snapshot page cursor repeats"); + cursors.add(page.nextToken); + page = await reader.page(token, page.nextToken, signal); + } + await reader.complete(token, signal); + await beforeComplete(); + signal.throwIfAborted(); + await collection.completeSnapshot( + snapshot, + { expected, next: token }, + signal, + ); +} diff --git a/src/features/relay/artifact-snapshot.test.ts b/src/features/relay/artifact-snapshot.test.ts new file mode 100644 index 0000000000..797dcb3561 --- /dev/null +++ b/src/features/relay/artifact-snapshot.test.ts @@ -0,0 +1,238 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { + ARTIFACT_SNAPSHOT_LIMITS, + artifactSnapshotCapability, + createArtifactSnapshotReader, + type ArtifactManifestEntry, + type ArtifactSnapshotRequest, +} from "./artifact-snapshot"; +import { keypair, signed } from "./testing"; + +const artifactId = "11111111-1111-4111-8111-111111111111"; +const channelId = "22222222-2222-4222-8222-222222222222"; +const author = keypair(); +const revision = () => + signed(author, { + kind: 45010, + content: '{"version":0}', + tags: [ + ["ar", "1"], + ["d", artifactId], + ["h", channelId], + ["type", "test.task"], + ["title", "Task"], + ["op", "create"], + ], + }); +const query = { + artifact: "current", + kinds: [45010], + "#type": ["test.task"], +} as const; +const signal = () => new AbortController().signal; +const page = ( + entries: readonly ArtifactManifestEntry[] = [], + more: string | null = null, +) => ({ + version: 1, + token: "snapshot-token", + expiresAt: Math.floor(Date.now() / 1000) + 300, + entries, + nextToken: more, +}); +afterEach(() => vi.useRealTimers()); + +it("requires the complete advertised snapshot contract", () => { + expect( + artifactSnapshotCapability({ + artifacts: { snapshot: ARTIFACT_SNAPSHOT_LIMITS }, + }), + ).toBe(true); + expect( + artifactSnapshotCapability({ artifacts: { snapshot: { version: 1 } } }), + ).toBe(false); + expect(artifactSnapshotCapability({ supported_extensions: ["nip-ar"] })).toBe( + false, + ); +}); +it("uses stable snapshot tokens and separate page cursors", async () => { + const event = revision(); + const entry = { artifactId, revisionId: event.id, channelId }; + const request = vi.fn(async (filter: ArtifactSnapshotRequest) => { + if (filter.action === "start") + return Response.json(page([entry], "page-two")); + if (filter.action === "page") return Response.json(page()); + if (filter.action === "bodies") + return Response.json({ + version: 1, + token: "snapshot-token", + events: [event], + }); + return Response.json({ + version: 1, + token: "snapshot-token", + complete: true, + }); + }); + const reader = createArtifactSnapshotReader(request); + const first = await reader.start(query, signal()); + expect(first.entries).toEqual([entry]); + await reader.page(first.token, "page-two", signal()); + expect((await reader.bodies(first.token, [entry], signal()))[0]?.id).toBe( + event.id, + ); + await reader.complete(first.token, signal()); + expect(request.mock.calls.map(([filter]) => filter)).toEqual([ + { artifact: "snapshot", action: "start", query }, + { + artifact: "snapshot", + action: "page", + token: "snapshot-token", + cursor: "page-two", + }, + { + artifact: "snapshot", + action: "bodies", + token: "snapshot-token", + revisionIds: [event.id], + }, + { artifact: "snapshot", action: "complete", token: "snapshot-token" }, + ]); +}); +it.each([ + { version: 1, token: "snapshot-token", complete: false }, + { version: 1, token: "another-token", complete: true }, + { version: 2, token: "snapshot-token", complete: true }, +])("rejects invalid completion %j", async (raw) => { + const reader = createArtifactSnapshotReader(async () => Response.json(raw)); + await expect( + reader.complete("snapshot-token", signal()), + ).rejects.toMatchObject({ code: "snapshot_invalid" }); +}); +it("does not accept expired pages or duplicate artifact identities", async () => { + const entry = { artifactId, revisionId: "a".repeat(64), channelId }; + let value = { ...page(), expiresAt: Math.floor(Date.now() / 1000) - 1 }; + const reader = createArtifactSnapshotReader(async () => Response.json(value)); + await expect(reader.start(query, signal())).rejects.toMatchObject({ + code: "snapshot_reset", + }); + value = page([entry, entry]); + await expect(reader.start(query, signal())).rejects.toMatchObject({ + code: "snapshot_invalid", + }); +}); +it("rejects a page from another token and an empty page that claims more rows", async () => { + let value = page(); + const reader = createArtifactSnapshotReader(async () => Response.json(value)); + await expect( + reader.page("another-token", "cursor", signal()), + ).rejects.toMatchObject({ code: "snapshot_invalid" }); + value = page([], "cursor"); + await expect(reader.start(query, signal())).rejects.toMatchObject({ + code: "snapshot_invalid", + }); +}); +it.each(["snapshot_reset", "snapshot_limit", "snapshot_invalid"])( + "retains machine error %s without relay text", + async (code) => { + const reader = createArtifactSnapshotReader(async () => + Response.json({ code, error: "private relay detail" }, { status: 409 }), + ); + await expect(reader.start(query, signal())).rejects.toMatchObject({ code }); + await expect(reader.start(query, signal())).rejects.not.toThrow( + "private relay detail", + ); + }, +); +it("checks exact revision, identity, channel and signature before returning a body", async () => { + const event = revision(); + let events = [event]; + const reader = createArtifactSnapshotReader(async () => + Response.json({ version: 1, token: "snapshot-token", events }), + ); + const entry = { artifactId, revisionId: event.id, channelId }; + await expect( + reader.bodies( + "snapshot-token", + [{ ...entry, revisionId: "b".repeat(64) }], + signal(), + ), + ).rejects.toMatchObject({ code: "snapshot_invalid" }); + await expect( + reader.bodies( + "snapshot-token", + [{ ...entry, artifactId: channelId }], + signal(), + ), + ).rejects.toMatchObject({ code: "snapshot_invalid" }); + await expect( + reader.bodies( + "snapshot-token", + [{ ...entry, channelId: artifactId }], + signal(), + ), + ).rejects.toMatchObject({ code: "snapshot_invalid" }); + events = [{ ...event, content: "tampered" }]; + await expect( + reader.bodies("snapshot-token", [entry], signal()), + ).rejects.toThrow("invalidly signed"); +}); +it("rejects duplicate, missing, or deleted bodies", async () => { + const event = revision(); + const entry = { artifactId, revisionId: event.id, channelId }; + let events = [event, event]; + const reader = createArtifactSnapshotReader(async () => + Response.json({ version: 1, token: "snapshot-token", events }), + ); + await expect( + reader.bodies("snapshot-token", [entry], signal()), + ).rejects.toMatchObject({ code: "snapshot_invalid" }); + events = []; + await expect( + reader.bodies("snapshot-token", [entry], signal()), + ).rejects.toMatchObject({ code: "snapshot_invalid" }); + const deleted = signed(author, { + kind: 45010, + content: "", + tags: [ + ["ar", "1"], + ["d", artifactId], + ["h", channelId], + ["op", "delete"], + ], + }); + events = [deleted]; + await expect( + reader.bodies( + "snapshot-token", + [{ ...entry, revisionId: deleted.id }], + signal(), + ), + ).rejects.toMatchObject({ code: "snapshot_invalid" }); +}); +it("bounds manifest bytes before parsing and cancels the response stream", async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + new Uint8Array(ARTIFACT_SNAPSHOT_LIMITS.pageBytes + 1), + ); + }, + cancel, + }); + const reader = createArtifactSnapshotReader(async () => new Response(body)); + await expect(reader.start(query, signal())).rejects.toMatchObject({ + code: "snapshot_limit", + }); + expect(cancel).toHaveBeenCalledOnce(); +}); +it("fences cancellation before consuming a response", async () => { + const controller = new AbortController(); + const reader = createArtifactSnapshotReader(async () => { + controller.abort(); + return Response.json(page()); + }); + await expect(reader.start(query, controller.signal)).rejects.toMatchObject({ + name: "AbortError", + }); +}); diff --git a/src/features/relay/artifact-snapshot.ts b/src/features/relay/artifact-snapshot.ts new file mode 100644 index 0000000000..aa712f5644 --- /dev/null +++ b/src/features/relay/artifact-snapshot.ts @@ -0,0 +1,328 @@ +import { eventDto, type RelayEvent } from "./events.ts"; +import { yieldToHost } from "./yield.ts"; + +export const ARTIFACT_SNAPSHOT_LIMITS = Object.freeze({ + version: 1, + maxRows: 50_000, + pageRows: 1_000, + pageBytes: 262_144, + bodyRows: 100, + bodyBytes: 8_388_608, + lifetimeSeconds: 300, + permanentChannelsOnly: true, +}); +export type ArtifactManifestEntry = Readonly<{ + artifactId: string; + revisionId: string; + channelId: string; +}>; +export type ArtifactManifestPage = Readonly<{ + token: string; + expiresAt: number; + entries: readonly ArtifactManifestEntry[]; + nextToken: string | null; +}>; +export type ArtifactSnapshotQuery = Readonly<{ + artifact: "current"; + kinds: readonly [45010]; + [tag: `#${string}`]: readonly string[] | undefined; +}>; +export type ArtifactSnapshotRequest = + | Readonly<{ + artifact: "snapshot"; + action: "start"; + query: ArtifactSnapshotQuery; + }> + | Readonly<{ + artifact: "snapshot"; + action: "page"; + token: string; + cursor: string; + }> + | Readonly<{ + artifact: "snapshot"; + action: "bodies"; + token: string; + revisionIds: readonly string[]; + }> + | Readonly<{ artifact: "snapshot"; action: "complete"; token: string }>; +export type ArtifactSnapshotReader = { + start( + query: ArtifactSnapshotQuery, + signal: AbortSignal, + ): Promise; + page( + token: string, + cursor: string, + signal: AbortSignal, + ): Promise; + bodies( + token: string, + entries: readonly ArtifactManifestEntry[], + signal: AbortSignal, + ): Promise; + complete(token: string, signal: AbortSignal): Promise; +}; +export class ArtifactSnapshotError extends Error { + constructor( + readonly code: + | "snapshot_reset" + | "snapshot_limit" + | "snapshot_invalid" + | "snapshot_unsupported_scope", + message: string, + ) { + super(message); + } +} +const object = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); +const uuid = (value: unknown): value is string => + typeof value === "string" && + /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/.test(value) && + value !== "00000000-0000-0000-0000-000000000000"; +const eventId = (value: unknown): value is string => + typeof value === "string" && /^[0-9a-f]{64}$/.test(value); +const tokenValue = (value: unknown): value is string => + typeof value === "string" && + value.length > 0 && + value.length <= 4096 && + [...value].every( + (character) => + character.charCodeAt(0) > 32 && character.charCodeAt(0) < 127, + ); +const invalid = () => + new ArtifactSnapshotError( + "snapshot_invalid", + "Invalid artifact snapshot response", + ); + +export function artifactSnapshotCapability(info: unknown): boolean { + if ( + !object(info) || + !object(info.artifacts) || + !object(info.artifacts.snapshot) + ) + return false; + const snapshot = info.artifacts.snapshot; + return Object.entries(ARTIFACT_SNAPSHOT_LIMITS).every( + ([key, value]) => snapshot[key] === value, + ); +} + +async function responseValue( + response: Response, + limit: number, + signal: AbortSignal, +) { + if (!response.body) throw invalid(); + const reader = response.body.getReader(); + const abort = () => { + void reader.cancel(signal.reason).catch(() => {}); + }; + signal.addEventListener("abort", abort, { once: true }); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let size = 0; + let text = ""; + try { + while (true) { + signal.throwIfAborted(); + const { value, done } = await reader.read(); + if (done) { + text += decoder.decode(); + break; + } + size += value.byteLength; + if (size > limit) + throw new ArtifactSnapshotError( + "snapshot_limit", + "Artifact snapshot response exceeds capacity", + ); + text += decoder.decode(value, { stream: true }); + } + } finally { + signal.removeEventListener("abort", abort); + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } + signal.throwIfAborted(); + const raw: unknown = JSON.parse(text); + if (!response.ok) { + if ( + object(raw) && + [ + "snapshot_reset", + "snapshot_limit", + "snapshot_invalid", + "snapshot_unsupported_scope", + ].includes(String(raw.code)) + ) { + const code = raw.code as ArtifactSnapshotError["code"]; + throw new ArtifactSnapshotError( + code, + code === "snapshot_reset" + ? "Artifact snapshot must restart" + : code === "snapshot_limit" + ? "Artifact snapshot exceeds capacity" + : code === "snapshot_unsupported_scope" + ? "Artifact snapshots require permanent channels" + : "Invalid artifact snapshot request", + ); + } + throw new Error(`Artifact snapshot request fails (${response.status})`); + } + return raw; +} +function envelope( + raw: unknown, + token?: string, +): asserts raw is Record { + if ( + !object(raw) || + raw.version !== 1 || + !tokenValue(raw.token) || + (token !== undefined && raw.token !== token) + ) + throw invalid(); +} +function manifest(raw: unknown, expectedToken?: string): ArtifactManifestPage { + envelope(raw, expectedToken); + if ( + !Number.isSafeInteger(raw.expiresAt) || + (raw.expiresAt as number) < 0 || + !Array.isArray(raw.entries) || + raw.entries.length > ARTIFACT_SNAPSHOT_LIMITS.pageRows || + (raw.nextToken !== null && !tokenValue(raw.nextToken)) + ) + throw invalid(); + if ((raw.expiresAt as number) * 1000 <= Date.now()) + throw new ArtifactSnapshotError( + "snapshot_reset", + "Artifact snapshot expires", + ); + const seen = new Set(); + const entries = raw.entries.map((entry: unknown) => { + if ( + !object(entry) || + !uuid(entry.artifactId) || + !eventId(entry.revisionId) || + !uuid(entry.channelId) || + seen.has(entry.artifactId) + ) + throw invalid(); + seen.add(entry.artifactId); + return Object.freeze({ + artifactId: entry.artifactId, + revisionId: entry.revisionId, + channelId: entry.channelId, + }); + }); + if (raw.nextToken !== null && !entries.length) throw invalid(); + return Object.freeze({ + token: raw.token as string, + expiresAt: raw.expiresAt as number, + entries: Object.freeze(entries), + nextToken: raw.nextToken as string | null, + }); +} + +/** The authenticated transport owns requests. This reader admits exact signed revisions. */ +export function createArtifactSnapshotReader( + request: ( + filter: ArtifactSnapshotRequest, + signal: AbortSignal, + ) => Promise, +): ArtifactSnapshotReader { + async function send( + filter: ArtifactSnapshotRequest, + signal: AbortSignal, + limit: number, + ) { + if ("token" in filter && !tokenValue(filter.token)) throw invalid(); + return responseValue(await request(filter, signal), limit, signal); + } + return { + async start(query, signal) { + return manifest( + await send( + { artifact: "snapshot", action: "start", query }, + signal, + ARTIFACT_SNAPSHOT_LIMITS.pageBytes, + ), + ); + }, + async page(token, cursor, signal) { + if (!tokenValue(cursor)) throw invalid(); + return manifest( + await send( + { artifact: "snapshot", action: "page", token, cursor }, + signal, + ARTIFACT_SNAPSHOT_LIMITS.pageBytes, + ), + token, + ); + }, + async bodies(token, entries, signal) { + if ( + !entries.length || + entries.length > ARTIFACT_SNAPSHOT_LIMITS.bodyRows || + entries.some( + (entry) => + !uuid(entry.artifactId) || + !eventId(entry.revisionId) || + !uuid(entry.channelId), + ) + ) + throw invalid(); + const wanted = new Map(entries.map((entry) => [entry.revisionId, entry])); + if (wanted.size !== entries.length) throw invalid(); + const raw = await send( + { + artifact: "snapshot", + action: "bodies", + token, + revisionIds: entries.map((entry) => entry.revisionId), + }, + signal, + ARTIFACT_SNAPSHOT_LIMITS.bodyBytes, + ); + envelope(raw, token); + if (!Array.isArray(raw.events) || raw.events.length !== entries.length) + throw invalid(); + const events: RelayEvent[] = []; + for (const value of raw.events) { + signal.throwIfAborted(); + const event = eventDto(value); + const entry = wanted.get(event.id); + const tag = (name: string) => + event.tags.filter(([key]) => key === name); + if ( + !entry || + event.kind !== 45010 || + tag("d").length !== 1 || + tag("d")[0]?.[1] !== entry.artifactId || + tag("h").length !== 1 || + tag("h")[0]?.[1] !== entry.channelId || + tag("ar").length !== 1 || + tag("ar")[0]?.[1] !== "1" || + tag("op")[0]?.[1] === "delete" + ) + throw invalid(); + wanted.delete(event.id); + events.push(event); + if (events.length % 12 === 0) await yieldToHost(); + } + signal.throwIfAborted(); + return Object.freeze(events); + }, + async complete(token, signal) { + const raw = await send( + { artifact: "snapshot", action: "complete", token }, + signal, + ARTIFACT_SNAPSHOT_LIMITS.pageBytes, + ); + envelope(raw, token); + if (raw.complete !== true) throw invalid(); + }, + }; +} diff --git a/src/features/relay/artifact-sync.test.ts b/src/features/relay/artifact-sync.test.ts new file mode 100644 index 0000000000..6bac16d962 --- /dev/null +++ b/src/features/relay/artifact-sync.test.ts @@ -0,0 +1,163 @@ +import { afterEach, expect, it, vi } from "vitest"; +import type { Context } from "@deepseek-ai/cordis"; +import type { + ArtifactStorageService, + OpenArtifactCollection, +} from "../artifact-storage/service"; +import { provideArtifactSync } from "./artifact-sync"; +import { reconcileArtifactSnapshot } from "./artifact-reconcile"; +import { keypair, signed } from "./testing"; +import { eventDto, type RelayEvent } from "./events"; +vi.mock("./artifact-reconcile", async (original) => ({ + ...(await original()), + reconcileArtifactSnapshot: vi.fn(async () => {}), +})); +const stops: (() => void)[] = []; +afterEach(async () => { + for (const stop of stops.splice(0)) stop(); + await vi.advanceTimersByTimeAsync(0); + vi.clearAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); +function setup() { + vi.useFakeTimers(); + vi.stubGlobal("navigator", { + locks: { request: vi.fn(async (_key, _options, callback) => callback()) }, + }); + const listeners = new Set<() => void>(); + let confirmed: (events: readonly RelayEvent[]) => void = () => {}; + let opened: (entry: OpenArtifactCollection) => void = () => {}; + let online = true; + const session = { + artifactSnapshots: {}, + live: { + snapshot: () => ({ status: online ? "connected" : "retrying" }), + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }, + channels: { subscribeList: () => () => {} }, + subscribeLive: () => () => {}, + subscribeArtifactUpdates: (listener: typeof confirmed) => { + confirmed = listener; + return () => { + confirmed = () => {}; + }; + }, + }; + const snapshot = () => ({ + viewer: "viewer", + origin: "https://relay.test", + status: "ready", + cached: false, + session, + }); + const ctx = { + relay: { + snapshot, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }, + effect: (effect: () => () => void) => { + stops.push(effect()); + }, + }; + const storage = { + onOpen: (listener: typeof opened) => { + opened = listener; + return () => {}; + }, + }; + provideArtifactSync( + ctx as unknown as Context, + storage as unknown as ArtifactStorageService, + ); + const add = () => { + const controller = new AbortController(); + const collection = { applyBatch: vi.fn(async () => {}) }; + opened({ + viewer: "viewer", + relay: "https://relay.test", + owner: "test.plugin", + definition: { name: "tasks", type: "test.task" }, + collection, + signal: controller.signal, + } as unknown as OpenArtifactCollection); + return { controller, collection }; + }; + const connection = (value: boolean) => { + online = value; + for (const listener of listeners) listener(); + }; + return { + add, + confirmed: (event: RelayEvent) => confirmed([event]), + connection, + }; +} +it("uses distinct collection and outbox locks, coalesces repair, and retires closed collections", async () => { + const h = setup(); + const entry = h.add(); + await vi.advanceTimersByTimeAsync(0); + expect(navigator.locks.request).toHaveBeenCalledTimes(2); + expect(reconcileArtifactSnapshot).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(60_000); + expect(reconcileArtifactSnapshot).toHaveBeenCalledTimes(2); + entry.controller.abort(); + await vi.advanceTimersByTimeAsync(60_000); + expect(reconcileArtifactSnapshot).toHaveBeenCalledTimes(2); +}); +it("aborts reconciliation on disconnect and starts a new capture on reconnect", async () => { + const h = setup(); + vi.mocked(reconcileArtifactSnapshot).mockImplementationOnce( + async (_reader, _collection, _type, signal) => { + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }), + ); + }, + ); + h.add(); + await vi.advanceTimersByTimeAsync(0); + const signal = vi.mocked(reconcileArtifactSnapshot).mock.calls[0]?.[3]; + h.connection(false); + expect(signal?.aborted).toBe(true); + h.connection(true); + await vi.advanceTimersByTimeAsync(0); + expect(reconcileArtifactSnapshot).toHaveBeenCalledTimes(2); +}); +it("commits proven local acceptance through the same revision path as live records", async () => { + const h = setup(); + const entry = h.add(); + await vi.advanceTimersByTimeAsync(0); + const event = signed(keypair(), { + kind: 45010, + content: "{}", + tags: [ + ["d", "task"], + ["h", "channel"], + ["ar", "1"], + ["type", "test.task"], + ["op", "create"], + ], + }); + h.confirmed(eventDto(event)); + await vi.advanceTimersByTimeAsync(0); + expect(entry.collection.applyBatch).toHaveBeenCalledWith( + expect.objectContaining({ + records: [ + expect.objectContaining({ + id: "task", + revision: expect.objectContaining({ id: event.id }), + }), + ], + }), + ); +}); diff --git a/src/features/relay/artifact-sync.ts b/src/features/relay/artifact-sync.ts new file mode 100644 index 0000000000..01aeef73a6 --- /dev/null +++ b/src/features/relay/artifact-sync.ts @@ -0,0 +1,331 @@ +import type { Context } from "@deepseek-ai/cordis"; +import type { + ArtifactStorageService, + OpenArtifactCollection, +} from "../artifact-storage/service"; +import { hasEventProof } from "./events"; +import { ownManagedDelivery } from "./managed-delivery"; +import type { RelaySession } from "./session"; +import { + reconcileArtifactSnapshot, + receivedArtifact, +} from "./artifact-reconcile"; +import { transportPartition } from "./partition"; +import { RECOVERY_DELAYS_MS } from "./recovery"; + +const REPAIR_MS = 60_000; +type Group = { + key: string; + scope: string; + entries: Set; + lifetime: AbortController; + request(): void; +}; + +/** The existing session supplies live traffic. Web Locks transfer sync work between windows. */ +export function provideArtifactSync( + ctx: Context, + storage: ArtifactStorageService, +) { + const groups = new Map(); + const deliveryOwners = new Map(); + function ensureGroup( + viewer: string, + relay: string, + owner: string, + name: string, + ) { + const scope = transportPartition({ viewer, scope: relay, relayAuthor: "" }); + const key = JSON.stringify([viewer, relay, owner, name]); + let group = groups.get(key); + if (!group) { + const lifetime = new AbortController(); + group = { key, scope, entries: new Set(), lifetime, request: () => {} }; + groups.set(key, group); + const owned = group; + if (typeof navigator !== "undefined" && navigator.locks) { + void navigator.locks + .request( + `buzz-artifact-sync:${key}`, + { mode: "exclusive", signal: lifetime.signal }, + () => own(owned, viewer, relay), + ) + .catch((error) => { + if (!lifetime.signal.aborted) + console.error("Artifact sync ownership fails", error); + }); + } + } + return group; + } + const stopOpen = storage.onOpen((entry) => { + const group = ensureGroup( + entry.viewer, + entry.relay, + entry.owner, + entry.definition.name, + ); + group.entries.add(entry); + group.request(); + entry.signal.addEventListener( + "abort", + () => { + group.entries.delete(entry); + if (!group.entries.size) { + group.lifetime.abort(); + groups.delete(group.key); + } + }, + { once: true }, + ); + }); + const refreshScope = () => { + const { viewer, origin } = ctx.relay.snapshot(); + const scope = + viewer && origin + ? transportPartition({ viewer, scope: origin, relayAuthor: "" }) + : undefined; + for (const [key, group] of groups) + if (group.scope !== scope) { + group.lifetime.abort(); + groups.delete(key); + } + for (const [key, owner] of deliveryOwners) { + if (key !== scope) { + owner.abort(); + deliveryOwners.delete(key); + } + } + if (scope && !deliveryOwners.has(scope) && navigator.locks) { + const lifetime = new AbortController(); + deliveryOwners.set(scope, lifetime); + void navigator.locks + .request( + `buzz-outbox-owner:${scope}`, + { mode: "exclusive", signal: lifetime.signal }, + async () => { + await ownManagedDelivery(scope, true); + try { + await new Promise((resolve) => { + if (lifetime.signal.aborted) resolve(); + else + lifetime.signal.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + } finally { + await ownManagedDelivery(scope, false); + } + }, + ) + .catch((error) => { + if (!lifetime.signal.aborted) + console.error("Message outbox ownership fails", error); + }); + } + }; + const stopScope = ctx.relay.subscribe(refreshScope); + refreshScope(); + async function own(group: Group, viewer: string, relay: string) { + let session: RelaySession | undefined; + let stopSession = () => {}; + let stopLive = () => {}; + let stopConfirmed = () => {}; + let stopRoster = () => {}; + let connected = false; + let running = false; + let settled = Promise.resolve(); + let requested = true; + let retry = 0; + let timer: ReturnType | undefined; + let batch: AbortController | undefined; + let liveWrites = Promise.resolve(); + const active = () => { + const now = ctx.relay.snapshot(); + return ( + now.viewer === viewer && + now.origin === relay && + now.status === "ready" && + !now.cached && + now.session.live.snapshot().status === "connected" + ); + }; + const schedule = (delay: number) => { + clearTimeout(timer); + timer = setTimeout(() => { + timer = undefined; + requested = true; + void pump(); + }, delay); + }; + const request = () => { + requested = true; + if (!running) { + clearTimeout(timer); + timer = undefined; + void pump(); + } + }; + group.request = request; + async function pump() { + if (running || group.lifetime.signal.aborted || !requested || !active()) + return; + const current = session; + const reader = current?.artifactSnapshots; + if (!current || !reader) return; + running = true; + let finish!: () => void; + settled = new Promise((resolve) => { + finish = resolve; + }); + requested = false; + batch = new AbortController(); + const signal = AbortSignal.any([group.lifetime.signal, batch.signal]); + try { + for (const entry of [...group.entries].slice(0, 1)) { + if (entry.signal.aborted) continue; + const operation = AbortSignal.any([signal, entry.signal]); + await reconcileArtifactSnapshot( + reader, + entry.collection, + entry.definition.type, + operation, + () => liveWrites, + ); + } + retry = 0; + } catch { + if (!signal.aborted) { + retry++; + if (retry <= 3) { + requested = true; + schedule(RECOVERY_DELAYS_MS[retry - 1] ?? REPAIR_MS); + } + } + } finally { + finish(); + running = false; + batch = undefined; + if (!group.lifetime.signal.aborted) { + if (requested && retry === 0) void pump(); + else if (!timer) schedule(REPAIR_MS); + } + } + } + const update = () => { + const now = ctx.relay.snapshot(); + const next = + now.viewer === viewer && now.origin === relay ? now.session : undefined; + const changed = next !== session; + if (next !== session) { + batch?.abort(); + stopSession(); + stopLive(); + stopConfirmed(); + stopRoster(); + session = next; + if (next) { + stopSession = next.live.subscribe(update); + stopRoster = next.channels.subscribeList(request); + const receive = ( + events: readonly import("./events").RelayEvent[], + ) => { + for (const entry of [...group.entries].slice(0, 1)) { + const records = events.filter( + (event) => + hasEventProof(event) && + event.kind === 45010 && + event.tags.some( + ([name, value]) => + name === "type" && value === entry.definition.type, + ), + ); + if (!records.length) continue; + const source = JSON.stringify({ + artifact: "current", + kinds: [45010], + "#type": [entry.definition.type], + }); + liveWrites = liveWrites + .then(async () => { + if (entry.signal.aborted || group.lifetime.signal.aborted) + return; + const received = records.map((event) => + receivedArtifact(event, entry.definition.type), + ); + await entry.collection.applyBatch({ + source, + records: received, + availability: received.map((record) => ({ + id: record.id, + expectedRevision: record.revision.id, + value: { + state: record.revision.event?.tags.some( + ([name, value]) => + name === "op" && value === "delete", + ) + ? ("deleted" as const) + : ("available" as const), + }, + })), + }); + }) + .catch(() => { + request(); + }); + } + if ( + events.some((event) => + [45011, 9005, 44100, 44101].includes(event.kind), + ) + ) + request(); + }; + stopConfirmed = next.subscribeArtifactUpdates(receive); + stopLive = next.subscribeLive(({ events }) => receive(events), { + replay: true, + }); + } + } + const ready = active(); + if (!ready) { + batch?.abort(); + requested = true; + } else if (!connected || changed) { + retry = 0; + request(); + } + connected = ready; + }; + const stopRelay = ctx.relay.subscribe(update); + update(); + try { + await new Promise((resolve) => { + if (group.lifetime.signal.aborted) resolve(); + else + group.lifetime.signal.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + } finally { + batch?.abort(); + clearTimeout(timer); + stopRelay(); + stopSession(); + stopLive(); + stopConfirmed(); + stopRoster(); + group.request = () => {}; + await settled; + await liveWrites; + } + } + ctx.effect(() => () => { + stopOpen(); + stopScope(); + for (const group of groups.values()) group.lifetime.abort(); + for (const owner of deliveryOwners.values()) owner.abort(); + deliveryOwners.clear(); + groups.clear(); + }); +} diff --git a/src/features/relay/broker-live.test.ts b/src/features/relay/broker-live.test.ts index cdf5ec26a0..89310ece25 100644 --- a/src/features/relay/broker-live.test.ts +++ b/src/features/relay/broker-live.test.ts @@ -126,7 +126,7 @@ it("rejects status snapshots beyond channel interests plus both globals and obse await tick(); f.publish(0, { status: "connected", - routes: Array.from({ length: 1028 }, (_, i) => ({ + routes: Array.from({ length: 100005 }, (_, i) => ({ id: `route-${i}`, status: "pending", replay: "unknown", diff --git a/src/features/relay/broker-live.ts b/src/features/relay/broker-live.ts index 418be33d49..e1952d4bde 100644 --- a/src/features/relay/broker-live.ts +++ b/src/features/relay/broker-live.ts @@ -2,6 +2,7 @@ import { observerFrame, observerGeneration } from "../agents/observer"; import { eventDto } from "./events"; import { liveChannels, + LIVE_INTEREST_CAPACITY, livePresenceAuthors, liveJoined, liveProvenance, @@ -10,7 +11,7 @@ import { type LiveSubscription, } from "./live"; -const MAX_FRAME = 1024 * 1024; +const MAX_FRAME = 32 * 1024 * 1024; const MAX_RETRY_MS = 30_000; /** A generation owns its stream, parser, retry and heartbeat. Interest changes * use the same host owner so they cannot interrupt pending publications. */ @@ -24,7 +25,7 @@ export function subscribeBrokerTraffic( let channels: string[] = []; let joined: string[] = []; let sentChannels: string[] = []; - // Only IDs in the last dispatched snapshot can have a host wire (at most 1024). + // Only IDs in the last dispatched snapshot can have a host wire. const removed = new Set(); let interestRevision = 0; const addedAt = new Map(); @@ -526,7 +527,7 @@ function liveSnapshot(value: unknown): LiveSnapshot { ) || !Array.isArray(snapshot.routes) || // Keep limited channels visible alongside both globals and the optional observer. - snapshot.routes.length > 1027 || + snapshot.routes.length > LIVE_INTEREST_CAPACITY + 4 || (snapshot.error !== undefined && typeof snapshot.error !== "string") ) throw new Error("Invalid live broker status"); diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index a25f6a35c7..a060f3c8cb 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -2,6 +2,7 @@ import type { ReadFilter } from "./events"; import type { CustomEmoji } from "./emoji"; import type { ReadOptions } from "./reader"; import type { Delivery } from "./outbox"; +import type { DeliveryState } from "./managed-delivery"; export const MAX_ATTACHMENT_DURATION_SECONDS = 86_400; @@ -104,6 +105,7 @@ export type ChannelMessage = Readonly<{ channelId: string; delivery?: Delivery | undefined; deliveryError?: string | undefined; + managedDelivery?: DeliveryState | undefined; authorId: string; /** Actual signing key when relay attribution makes authorId a different identity. */ signerId?: string | undefined; diff --git a/src/features/relay/live-recovery.ts b/src/features/relay/live-recovery.ts index ff765ca214..4197a0057e 100644 --- a/src/features/relay/live-recovery.ts +++ b/src/features/relay/live-recovery.ts @@ -52,7 +52,10 @@ export function liveRecoveryStatus(view: LiveRecoveryView): RecoveryStatus { (view.roster.state === "error" && view.roster.transient) || view.heads.some((head) => head.state === "error" && head.transient) || routes.some( - (route) => route.status === "error" && isTransientLiveError(route.error), + (route) => + (route.status === "error" || + (route.replay === "limited" && route.error !== undefined)) && + isTransientLiveError(route.error), ) ) return "failed"; diff --git a/src/features/relay/live.test.ts b/src/features/relay/live.test.ts index 09e70c3a94..c03bb56ed5 100644 --- a/src/features/relay/live.test.ts +++ b/src/features/relay/live.test.ts @@ -3,6 +3,7 @@ import { assert, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { createLiveAdmission, liveChannels, + LIVE_INTEREST_CAPACITY, subscribeRelayTraffic, type LiveCallbacks, } from "./live"; @@ -10,6 +11,7 @@ import { keypair, message, roster, signed, scriptedTransport } from "./testing"; import { createRelaySession } from "./session"; import { liveRecoveryStatus } from "./live-recovery"; import { createPresence } from "../presence/presence"; +import { matchesEvent } from "./projection"; type WireFilter = { kinds: number[]; "#h"?: string[]; @@ -138,6 +140,79 @@ it("uses independent explicit channel routes and self-p globals; equal interests h.owner.dispose(); expect(vi.getTimerCount()).toBe(0); }); +it.each([false, true])( + "delivers artifact changes to session consumers through a batched route (%s)", + async (batched) => { + vi.useFakeTimers(); + const h = setup([]); + const relay = keypair(); + const wire = scriptedTransport(h.key.pubkey, relay.pubkey); + const owner = createRelaySession({ + ...wire.transport, + subscribe(callbacks) { + h.callbacks.receive.mockImplementation(callbacks.receive); + h.callbacks.state.mockImplementation(callbacks.state); + return { + ...h.owner, + update: (ids, joined) => h.owner.update(ids, batched ? joined : []), + }; + }, + }); + try { + h.callbacks.receive( + ["notes", "other"].map((id) => roster(relay, id, [h.key.pubkey])), + ); + const receive = vi.fn(); + owner.session.subscribeLive(receive); + await h.first.auth(); + const route = h.first + .requests() + .find((r) => scopeOf(r).includes("notes")); + assert.exists(route); + expect(scopeOf(route)).toHaveLength(batched ? 2 : 1); + await h.first.receive(["EOSE", route[1]]); + const author = keypair(); + const event = signed(author, { + kind: 45010, + created_at: Math.floor(Date.now() / 1000), + content: '{"text":"Shared note"}', + tags: [ + ["ar", "1"], + ["h", "notes"], + ["d", crypto.randomUUID()], + ["type", "example.note"], + ["title", "Note"], + ["op", "create"], + ], + }); + expect( + filtersOf(route).some((filter) => matchesEvent(event, filter)), + ).toBe(true); + await h.first.receive(["EVENT", route[1], event]); + expect(receive).toHaveBeenCalledWith({ + events: [event], + channelId: "notes", + }); + h.callbacks.receive([roster(relay, "notes", [], event.created_at + 1)]); + receive.mockClear(); + const afterRemoval = signed(author, { + kind: event.kind, + created_at: event.created_at + 2, + content: '{"text":"After removal"}', + tags: [ + ...event.tags.map((tag) => + tag[0] === "op" ? ["op", "update"] : tag, + ), + ["prev", event.id], + ], + }); + await h.first.receive(["EVENT", route[1], afterRemoval]); + expect(receive).not.toHaveBeenCalled(); + } finally { + owner.dispose(); + } + }, +); it("batches joined background interests without rebalance and fences retired batch wires", async () => { vi.useFakeTimers(); const h = setup([]); @@ -993,7 +1068,9 @@ it("bounds interests and exposes every omitted ID without exceeding 1024 subscri .filter((r) => r.status === "limited") .map((r) => r.channelId), ).toEqual(ids.slice(1022)); - expect(() => liveChannels([...ids, "excess"])).toThrow(); + expect(() => + liveChannels(Array(LIVE_INTEREST_CAPACITY + 1).fill("excess")), + ).toThrow(); for (const invalid of [[""], ["a b"], ["x".repeat(129)], [9], {}]) expect(() => liveChannels(invalid)).toThrow(); h.owner.dispose(); @@ -2418,3 +2495,127 @@ it("carries verified workflow attribution from live admission to notification ca h.owner.dispose(); } }); + +it("keeps 60000 joined homes live while four bounded replay reads recover each channel", async () => { + vi.useFakeTimers(); + const ids = Array.from( + { length: 60_000 }, + (_, i) => `task-${String(i).padStart(6, "0")}`, + ); + const h = setup([]); + h.callbacks.state.mockImplementation(() => {}); + h.owner.update([...ids, "normal-chat"], [...ids, "normal-chat"]); + h.owner.prioritize?.(["normal-chat", ids.at(-1) as string]); + await h.first.auth(); + const established = new Set(); + const replayed = new Set(); + const openWires = new Set(); + let maximum = 0; + let cursor = 0; + let replayRequests = 0; + let tailRequests = 0; + // Each EOSE synchronously admits the next bounded request. + while (cursor < h.first.sent.length) { + const frame = h.first.sent[cursor++]; + if (frame?.[0] === "CLOSE") { + openWires.delete(frame[1] as string); + continue; + } + if (frame?.[0] !== "REQ") continue; + const request = frame as WireRequest; + openWires.add(request[1]); + maximum = Math.max(maximum, openWires.size); + const filters = filtersOf(request); + const scope = scopeOf(request); + expect(filters.length).toBeLessThanOrEqual(10); + expect( + new TextEncoder().encode(JSON.stringify(request)).length, + ).toBeLessThan(524288); + if (scope.length) { + if (filters[0]?.limit === 0) { + tailRequests++; + expect(scope.length).toBeLessThanOrEqual(1000); + await h.first.receive(["EOSE", request[1]]); + for (const id of scope) established.add(id); + } else { + replayRequests++; + expect(scope.length).toBeLessThanOrEqual(10); + for (const id of scope) { + expect(established.has(id)).toBe(true); + expect(replayed.has(id)).toBe(false); + replayed.add(id); + } + await h.first.receive(["EOSE", request[1]]); + } + } else await h.first.receive(["EOSE", request[1]]); + } + expect(established.size).toBe(60_001); + expect(replayed.size).toBe(60_001); + expect(tailRequests).toBe(61); + expect(replayRequests).toBe(6001); + expect(maximum).toBeLessThanOrEqual(67); + expect( + h.callbacks.state.mock.lastCall?.[0].routes.every( + (route) => route.status === "live", + ), + ).toBe(true); + expect( + new TextEncoder().encode( + JSON.stringify(h.callbacks.state.mock.lastCall?.[0]), + ).length, + ).toBeLessThan(32 * 1024 * 1024); + h.owner.dispose(); + expect(vi.getTimerCount()).toBe(0); +}); + +it("keeps large tails active during replay and fences removed channel lifetimes", async () => { + vi.useFakeTimers(); + const ids = Array.from( + { length: 1200 }, + (_, i) => `task-${String(i).padStart(4, "0")}`, + ); + const h = setup([]); + h.owner.update(ids, ids); + await h.first.auth(); + const tail = h.first + .requests() + .find((request) => scopeOf(request).includes(ids[0] as string)); + assert.exists(tail); + const incoming = message( + h.key, + ids[0] as string, + "At the setup boundary", + 1700000000, + ); + await h.first.receive(["EVENT", tail[1], incoming]); + expect(h.callbacks.receive).toHaveBeenLastCalledWith( + incoming ? [incoming] : [], + { phase: "live", channelId: ids[0] }, + ); + await h.first.receive(["EOSE", tail[1]]); + const replay = h.first + .requests() + .find( + (request) => + request[1] !== tail[1] && scopeOf(request).includes(ids[0] as string), + ); + assert.exists(replay); + expect( + h.first.sent.some((frame) => frame[0] === "CLOSE" && frame[1] === tail[1]), + ).toBe(false); + await h.first.receive(["EVENT", replay[1], incoming]); + expect(h.callbacks.receive).toHaveBeenLastCalledWith([incoming], { + phase: "replay", + channelId: ids[0], + }); + h.owner.update(ids.slice(1), ids.slice(1)); + const before = h.callbacks.receive.mock.calls.length; + await h.first.receive(["EVENT", replay[1], incoming]); + await h.first.receive(["EVENT", tail[1], incoming]); + expect(h.callbacks.receive).toHaveBeenCalledTimes(before); + h.first.onclose?.(); + await h.first.receive(["EVENT", tail[1], incoming]); + expect(h.callbacks.receive).toHaveBeenCalledTimes(before); + h.owner.dispose(); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/src/features/relay/live.ts b/src/features/relay/live.ts index cd7d798290..7dacd49371 100644 --- a/src/features/relay/live.ts +++ b/src/features/relay/live.ts @@ -15,6 +15,8 @@ import { eventDto } from "./events.ts"; import { EMOJI_SET } from "./emoji.ts"; export const LIVE_CHANNEL_CAPACITY = 1022; +export const LIVE_INTEREST_CAPACITY = 100_000; +export const LIVE_GROUP_SIZE = 1000; export const LIVE_BATCH_SIZE = 10; // Relay's per-REQ filter cap; replay stays per channel. export const LIVE_REPLAY_LIMIT = 500; export const LIVE_RECOVERY_INTERVAL = 60_000; @@ -100,7 +102,7 @@ export function liveProvenance(value: unknown): LiveProvenance { : liveChannels(input.sourceChannels); if ( sourceChannels && - (!sourceChannels.length || sourceChannels.length > LIVE_BATCH_SIZE) + (!sourceChannels.length || sourceChannels.length > LIVE_GROUP_SIZE) ) throw new Error("Invalid live source scope"); return Object.freeze({ @@ -161,13 +163,13 @@ export function livePresenceAuthors(input: unknown): string[] { export function liveChannels(input: unknown): string[] { if ( !Array.isArray(input) || - input.length > 1024 || + input.length > LIVE_INTEREST_CAPACITY || input.some( (id) => typeof id !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(id), ) ) throw new Error( - "Invalid live channel interests (maximum 1024 bounded IDs)", + "Invalid live channel interests (maximum 100000 bounded IDs)", ); return [...new Set(input as string[])].sort(); } @@ -178,7 +180,8 @@ export function liveJoined( input: unknown, ): string[] { const joined = liveChannels(input); - if (joined.some((id) => !channels.includes(id))) + const allowed = new Set(channels); + if (joined.some((id) => !allowed.has(id))) throw new Error("Joined scope outside live interests"); return joined; } @@ -198,10 +201,13 @@ type Route = { retryRenewal?: boolean; quotaRetries: number; deadline?: ReturnType; + replayOffset?: number; + historyParent?: string; + priorityRank?: number; }; export const CHANNEL_KINDS = [ 9, 40002, 40008, 45001, 45003, 40099, 40100, 40003, 5, 9005, 7, 39000, 39002, - 39005, 20002, 48100, 48101, 48102, 48103, + 39005, 20002, 45010, 48100, 48101, 48102, 48103, ]; /** One authenticated socket, bounded joined-channel batches, singleton previews and two globals. * Recent replay is opportunistic: finite reads own catch-up and history bounds. */ @@ -254,8 +260,9 @@ export function subscribeRelayTraffic( Object.freeze({ status: connection, routes: Object.freeze( - [...routes.values()].flatMap( - ({ id, channelId, channelIds, status, replay, error }) => + [...routes.values()] + .filter((route) => !route.historyParent) + .flatMap(({ id, channelId, channelIds, status, replay, error }) => (channelIds ?? [channelId]).map((channelId) => Object.freeze({ id: channelId ? `channel:${channelId}` : id, @@ -265,7 +272,7 @@ export function subscribeRelayTraffic( ...(error ? { error } : {}), }), ), - ), + ), ), ...(connectionError ? { error: connectionError } : {}), }), @@ -290,6 +297,10 @@ export function subscribeRelayTraffic( delete route.previous; } function remove(route: Route) { + if (!route.historyParent) { + const history = routes.get(`replay:${route.id}`); + if (history) remove(history); + } closeWire(route); routes.delete(route.id); } @@ -298,6 +309,14 @@ export function subscribeRelayTraffic( function replace(route: Route, ids = scope(route)) { // A sent replacement is not yet established. Keep the established source // through repeated removals; retire the pending intermediate, not a survivor. + if ( + route.replayOffset !== undefined && + JSON.stringify(ids) !== JSON.stringify(scope(route)) + ) { + const history = routes.get(`replay:${route.id}`); + if (history) remove(history); + route.replayOffset = 0; + } const previous = route.previous ?? (route.wire ? route : undefined); delete route.previous; if (previous !== route) closeWire(route); // Never retain a third wire. @@ -352,14 +371,29 @@ export function subscribeRelayTraffic( ...interests, ]), ]; - const admitted = new Set( - ranked.slice( - 0, - LIVE_CHANNEL_CAPACITY - - (observer !== null ? 1 : 0) - - (presenceAuthors.length ? 1 : 0), - ), - ); + const joinedSet = new Set(joined); + const prioritySet = new Set(priority); + const staged = joined.length > LIVE_CHANNEL_CAPACITY; + const groupSize = staged ? LIVE_GROUP_SIZE : LIVE_BATCH_SIZE; + const capacity = + LIVE_CHANNEL_CAPACITY - + (observer !== null ? 1 : 0) - + (presenceAuthors.length ? 1 : 0) - + (staged ? SETUP_CONCURRENCY : 0); + const admitted = new Set(); + let singles = 0, + grouped = 0; + for (const id of ranked) { + const batchable = joinedSet.has(id) && (staged || !prioritySet.has(id)); + const cost = + singles + + (batchable ? 0 : 1) + + Math.ceil((grouped + (batchable ? 1 : 0)) / groupSize); + if (cost > capacity) continue; + admitted.add(id); + if (batchable) grouped++; + else singles++; + } const remaining = new Set(interests); const globals = new Set([ "profiles", @@ -370,6 +404,8 @@ export function subscribeRelayTraffic( // Scope is immutable for a wire. Retirement rebuilds only the affected batch; // navigation is scheduling, never a reason to rebalance healthy subscriptions. for (const route of routes.values()) { + delete route.priorityRank; + if (route.historyParent) continue; const ids = scope(route); if ( ids.length @@ -377,12 +413,12 @@ export function subscribeRelayTraffic( (id) => !remaining.has(id) || (route.status === "limited") === admitted.has(id) || - (!!route.channelIds && !joined.includes(id)), + (!!route.channelIds && !joinedSet.has(id)), ) : !globals.has(route.id) ) { const survivors = ids.filter( - (id) => remaining.has(id) && admitted.has(id) && joined.includes(id), + (id) => remaining.has(id) && admitted.has(id) && joinedSet.has(id), ); if ( route.channelIds && @@ -421,16 +457,32 @@ export function subscribeRelayTraffic( route.status = "limited"; route.error = "Live channel capacity reached; finite reads remain available"; - } else if (!joined.includes(id) || priority.includes(id)) + } else if (!joinedSet.has(id) || (!staged && prioritySet.has(id))) add(`channel:${id}`, [id]); else batch.push(id); } - for (let i = 0; i < batch.length; i += LIVE_BATCH_SIZE) - add(`batch:${++serial}`, batch.slice(i, i + LIVE_BATCH_SIZE), true); + for (let i = 0; i < batch.length; i += groupSize) { + const route = add( + `batch:${++serial}`, + batch.slice(i, i + groupSize), + true, + ); + if (staged) { + route.liveOnly = true; + route.replayOffset = 0; + } + } pump(); notify(); } function fail(route: Route, reason: string, transient = false) { + if (route.historyParent) { + const parent = routes.get(route.historyParent); + if (parent) { + parent.replay = "limited"; + parent.error = reason; + } + } const previous = route.previous; // Only an established, unchanged scope may outlive a failed renewal. Never // restore a retired channel lifetime or bypass denial/invalid-traffic cleanup. @@ -507,18 +559,41 @@ export function subscribeRelayTraffic( } requests.dispatch(request, send); } + for (const parent of [...routes.values()]) { + if ( + parent.replayOffset === undefined || + parent.status !== "live" || + parent.replayOffset >= scope(parent).length || + routes.has(`replay:${parent.id}`) + ) + continue; + const history: Route = { + id: `replay:${parent.id}`, + historyParent: parent.id, + channelIds: scope(parent).slice( + parent.replayOffset, + parent.replayOffset + LIVE_BATCH_SIZE, + ), + status: "pending", + replay: "unknown", + count: 0, + since: parent.since, + quotaRetries: 0, + }; + routes.set(history.id, history); + } let active = [...routes.values()].filter( (route) => route.wire && route.status === "pending", ).length; const rank = (route: Route) => - !scope(route).length + (route.priorityRank ??= !scope(route).length ? -2 : Math.min( priority.length, ...scope(route).map((id) => priority.includes(id) ? priority.indexOf(id) : priority.length, ), - ); + )); for (const route of [...routes.values()].sort( (a, b) => rank(a) - rank(b), )) { @@ -529,6 +604,13 @@ export function subscribeRelayTraffic( dispatchTimer = setTimeout(pump, delay); break; } + if (wires.size >= 1024) { + route.status = "limited"; + route.error = + "Live subscription capacity reached; retry after demand changes"; + notify(); + continue; + } // A retry being sent is not recovery. Retain its last failure until EOSE. const wire = `live-${++serial}`; route.wire = wire; @@ -833,7 +915,10 @@ export function subscribeRelayTraffic( [incoming], Object.freeze({ phase: - route.liveOnly || route.status === "live" ? "live" : "replay", + !route.historyParent && + (route.liveOnly || route.status === "live") + ? "live" + : "replay", ...(channelId ? { channelId } : ids.length @@ -843,6 +928,26 @@ export function subscribeRelayTraffic( ); } else if (data[0] === "EOSE" && route.status === "pending") { clearTimeout(route.deadline); + if (route.historyParent) { + const parent = routes.get(route.historyParent); + remove(route); + if (parent && parent.replayOffset !== undefined) { + parent.replayOffset += scope(route).length; + if (parent.error) { + delete parent.error; + notify(); + } + if ( + route.count >= LIVE_REPLAY_LIMIT && + parent.replay !== "limited" + ) { + parent.replay = "limited"; + notify(); + } + } + if (valid()) pump(); + return; + } if (route.previous) closeWire(route.previous); delete route.previous; route.status = "live"; diff --git a/src/features/relay/managed-delivery.test.ts b/src/features/relay/managed-delivery.test.ts new file mode 100644 index 0000000000..8d404e8d65 --- /dev/null +++ b/src/features/relay/managed-delivery.test.ts @@ -0,0 +1,227 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { + createOutbox, + PublishRejected, + type OutboxStorage, + type OutgoingEvent, +} from "./outbox"; +import { MANAGED_RECEIPT_MS } from "./managed-delivery"; +import { keypair, signed } from "./testing"; +import type { RelayWriter } from "./transport"; + +const viewer = keypair(); +const recipient = { channelId: "task-channel", agentId: "a".repeat(64) }; +const input = { + kind: 9, + content: "Check the task", + tags: [["h", recipient.channelId]], +}; +const owners: ReturnType[] = []; +afterEach(() => { + for (const owner of owners.splice(0)) owner.dispose(); + vi.useRealTimers(); +}); +function memory() { + let records: readonly OutgoingEvent[] = []; + return { + load: () => structuredClone(records), + save: (next: readonly OutgoingEvent[]) => { + records = structuredClone(next); + }, + }; +} +function setup( + storage: OutboxStorage = memory(), + publish: RelayWriter["publish"] = async () => {}, +) { + const sign = vi.fn(async (template) => signed(viewer, template)); + const writer = { sign, publish: vi.fn(publish) }; + const owner = createOutbox(viewer.pubkey, writer, storage); + owners.push(owner); + return { ...owner, ...writer, storage }; +} +const ticks = async (ms = 0) => { + await vi.advanceTimersByTimeAsync(ms); +}; + +it("saves managed intent before publication and only lets the sync owner publish", async () => { + vi.useFakeTimers(); + const h = setup(); + const { operationId: id } = await h.delivery.submit(input, recipient); + expect(h.publish).not.toHaveBeenCalled(); + expect(await h.storage.load()).toEqual([ + expect.objectContaining({ + event: expect.objectContaining({ id }), + managed: expect.objectContaining({ attempts: 0 }), + }), + ]); + await h.managedOwner(true); + await ticks(); + expect(h.publish).toHaveBeenCalledOnce(); + expect(await h.delivery.get(id)).toMatchObject({ + phase: "sending", + relayAccepted: true, + agentReceived: false, + }); +}); +it("does not republish accepted work after restart or receipt retry", async () => { + vi.useFakeTimers(); + const storage = memory(); + const first = setup(storage); + const { operationId: id } = await first.delivery.submit(input, recipient); + await first.managedOwner(true); + await ticks(); + first.dispose(); + const next = setup(storage); + await next.managedOwner(true); + await ticks(MANAGED_RECEIPT_MS); + expect(await next.delivery.get(id)).toMatchObject({ + phase: "failed", + relayAccepted: true, + error: "Could not confirm delivery", + }); + await next.delivery.retry(id); + await ticks(); + expect(next.publish).not.toHaveBeenCalled(); + expect(await next.delivery.get(id)).toMatchObject({ + phase: "sending", + relayAccepted: true, + agentReceived: false, + }); +}); +it("retains exact signed event identity through lost confirmations and bounded retries", async () => { + vi.useFakeTimers(); + const h = setup(memory(), async () => { + throw new Error("Confirmation lost"); + }); + const { operationId: id } = await h.delivery.submit(input, recipient); + await h.managedOwner(true); + await ticks(8000); + expect(h.publish).toHaveBeenCalledTimes(3); + expect(h.sign).toHaveBeenCalledOnce(); + expect(new Set(h.publish.mock.calls.map(([event]) => event.id))).toEqual( + new Set([id]), + ); + expect(await h.delivery.get(id)).toMatchObject({ + phase: "failed", + relayAccepted: false, + error: "Could not send", + }); +}); +it("transfers interrupted work to the next owner without a second signature", async () => { + vi.useFakeTimers(); + const storage = memory(); + const first = setup(storage, async () => { + throw new Error("Disconnected"); + }); + const { operationId: id } = await first.delivery.submit(input, recipient); + await first.managedOwner(true); + await ticks(); + await first.managedOwner(false); + first.dispose(); + const next = setup(storage); + await next.managedOwner(true); + await ticks(1000); + expect(next.publish).toHaveBeenCalledOnce(); + expect(next.publish.mock.calls[0]?.[0].id).toBe(id); + expect(next.sign).not.toHaveBeenCalled(); +}); +it("waits for durable exact receipt and ignores other agents, channels and duplicate IDs", async () => { + vi.useFakeTimers(); + const h = setup(); + const { operationId: id } = await h.delivery.submit(input, recipient); + await h.managedOwner(true); + await ticks(); + await h.receiveManaged("other", recipient.agentId, [id]); + await h.receiveManaged(recipient.channelId, "b".repeat(64), [id]); + expect(await h.delivery.get(id)).toMatchObject({ agentReceived: false }); + await h.receiveManaged(recipient.channelId, recipient.agentId, [id, id]); + expect(await h.delivery.get(id)).toMatchObject({ + phase: "received", + relayAccepted: true, + agentReceived: true, + }); + await ticks(); + await h.receiveManaged(recipient.channelId, recipient.agentId, [id]); + expect(h.publish).toHaveBeenCalledOnce(); +}); +it("keeps accepted evidence when receipt persistence fails and accepts a late receipt", async () => { + vi.useFakeTimers(); + const storage = memory(); + let fail = false; + const h = setup({ + ...storage, + save: async (records) => { + if (fail) throw new Error("Disk full"); + storage.save(records); + }, + }); + const { operationId: id } = await h.delivery.submit(input, recipient); + await h.managedOwner(true); + await ticks(); + fail = true; + await expect( + h.receiveManaged(recipient.channelId, recipient.agentId, [id]), + ).rejects.toThrow("Disk full"); + expect( + h.local.snapshot().find((item) => item.event.id === id)?.managed, + ).toMatchObject({ + relayAccepted: true, + agentReceived: false, + error: "receipt-save", + }); + fail = false; + await ticks(MANAGED_RECEIPT_MS); + await h.receiveManaged(recipient.channelId, recipient.agentId, [id]); + expect(await h.delivery.get(id)).toMatchObject({ + phase: "received", + agentReceived: true, + }); + expect(h.publish).toHaveBeenCalledOnce(); +}); +it("does not retry a definitive refusal or publish after storage failure", async () => { + vi.useFakeTimers(); + const h = setup(memory(), async () => { + throw new PublishRejected("conflict: artifact head changed"); + }); + const { operationId: id } = await h.delivery.submit({ + kind: 45010, + content: "Task", + tags: [], + }); + await h.managedOwner(true); + await ticks(8000); + expect(h.publish).toHaveBeenCalledOnce(); + expect(await h.delivery.get(id)).toMatchObject({ phase: "failed" }); + const failed = setup({ + load: () => [], + save: async () => { + throw new Error("Disk full"); + }, + }); + await expect(failed.delivery.submit(input, recipient)).rejects.toThrow( + "Disk full", + ); + expect(failed.publish).not.toHaveBeenCalled(); +}); +it("records a batch receipt against each original message", async () => { + vi.useFakeTimers(); + const h = setup(); + const one = await h.delivery.submit(input, recipient); + const two = await h.delivery.submit( + { ...input, content: "Second prompt" }, + recipient, + ); + await h.managedOwner(true); + await ticks(); + await h.receiveManaged(recipient.channelId, recipient.agentId, [ + one.eventId, + two.eventId, + ]); + expect(await h.delivery.get(one.operationId)).toMatchObject({ + agentReceived: true, + }); + expect(await h.delivery.get(two.operationId)).toMatchObject({ + agentReceived: true, + }); +}); diff --git a/src/features/relay/managed-delivery.ts b/src/features/relay/managed-delivery.ts new file mode 100644 index 0000000000..9cbb6a6882 --- /dev/null +++ b/src/features/relay/managed-delivery.ts @@ -0,0 +1,145 @@ +export type ManagedRecipient = Readonly<{ channelId: string; agentId: string }>; +export type ManagedDelivery = Readonly<{ + version: 1; + recipient?: ManagedRecipient; + round: number; + attempts: number; + relayAccepted: boolean; + agentReceived: boolean; + nextAttemptAt: number; + receiptDeadline: number; + error?: "send" | "receipt" | "receipt-save" | undefined; +}>; +export type DeliveryState = Readonly<{ + operationId: string; + eventId: string; + phase: "sending" | "received" | "failed"; + relayAccepted: boolean; + agentReceived: boolean; + error?: string; +}>; +export const MANAGED_RECEIPT_MS = 30_000; +export const MANAGED_MAX_ATTEMPTS = 3; +type ManagedHost = { + owner(owned: boolean): Promise; + receive( + channelId: string, + agentId: string, + messageIds: readonly string[], + ): Promise; +}; +const hosts = new Map(); +const owners = new Set(); +const transitions = new Map>(); +function transition(scope: string, work: () => Promise) { + const next = (transitions.get(scope) ?? Promise.resolve()) + .catch(() => {}) + .then(work); + transitions.set(scope, next); + return next; +} +export function registerManagedDelivery(scope: string, host: ManagedHost) { + const prior = hosts.get(scope); + hosts.set(scope, host); + void transition(scope, async () => { + await prior?.owner(false); + if (hosts.get(scope) === host && owners.has(scope)) await host.owner(true); + }).catch((error) => + console.error("Managed delivery owner fails", scope, error), + ); + return () => { + if (hosts.get(scope) === host) hosts.delete(scope); + void transition(scope, () => host.owner(false)).catch((error) => + console.error("Managed delivery release fails", scope, error), + ); + }; +} +export async function ownManagedDelivery(scope: string, owned: boolean) { + if (owned) owners.add(scope); + else owners.delete(scope); + await transition(scope, async () => { + await hosts.get(scope)?.owner(owners.has(scope)); + }); +} +export async function receiveManagedDelivery( + scope: string, + channelId: string, + agentId: string, + messageIds: readonly string[], +) { + const host = hosts.get(scope); + if (!host) throw new Error("Message delivery is unavailable"); + await host.receive(channelId, agentId, messageIds); +} +export function managedDelivery(value: unknown): ManagedDelivery { + const raw = value as ManagedDelivery | null; + if ( + raw?.version !== 1 || + ![raw.round, raw.attempts, raw.nextAttemptAt, raw.receiptDeadline].every( + (value) => Number.isSafeInteger(value) && value >= 0, + ) || + typeof raw.relayAccepted !== "boolean" || + typeof raw.agentReceived !== "boolean" || + (raw.error !== undefined && + !["send", "receipt", "receipt-save"].includes(raw.error)) || + (raw.recipient && + (!/^[0-9a-f]{64}$/.test(raw.recipient.agentId) || + !/^[a-zA-Z0-9_-]{1,128}$/.test(raw.recipient.channelId))) + ) + throw new Error("Invalid managed delivery state"); + return Object.freeze({ + ...raw, + ...(raw.recipient + ? { recipient: Object.freeze({ ...raw.recipient }) } + : {}), + }); +} +/** Acceptance and receipt survive a stale writer from another window. */ +export function mergeManagedDelivery( + old: ManagedDelivery, + next: ManagedDelivery, +): ManagedDelivery { + if (JSON.stringify(old.recipient) !== JSON.stringify(next.recipient)) + throw new Error("Managed recipient changed"); + const current = old.round > next.round ? old : next; + const relayAccepted = old.relayAccepted || next.relayAccepted; + const agentReceived = old.agentReceived || next.agentReceived; + return { + ...current, + relayAccepted, + agentReceived, + attempts: + old.round === next.round + ? Math.max(old.attempts, next.attempts) + : current.attempts, + ...(old.round === next.round && old.error === "receipt-save" + ? { error: old.error } + : {}), + ...(agentReceived || (!current.recipient && relayAccepted) + ? { error: undefined } + : relayAccepted && current.error === "send" + ? { error: undefined } + : {}), + }; +} +export function deliveryState( + id: string, + state: ManagedDelivery, +): DeliveryState { + const received = state.recipient ? state.agentReceived : state.relayAccepted; + return { + operationId: id, + eventId: id, + phase: received ? "received" : state.error ? "failed" : "sending", + relayAccepted: state.relayAccepted, + agentReceived: state.agentReceived, + ...(!received && state.error + ? { + error: + state.error === "send" + ? "Could not send" + : "Could not confirm delivery", + } + : {}), + }; +} diff --git a/src/features/relay/message-projection.ts b/src/features/relay/message-projection.ts index 4fc6b36835..1f46b3cc6c 100644 --- a/src/features/relay/message-projection.ts +++ b/src/features/relay/message-projection.ts @@ -1,4 +1,5 @@ import type { ChannelMessage } from "./contracts"; +import { deliveryState } from "./managed-delivery"; import type { EventData } from "./events"; import { collapseHuddleCards, foldMessages } from "./fold"; import type { OutgoingEvent } from "./outbox"; @@ -84,7 +85,8 @@ export class MessageProjection { const previous = this.deliveries.get(id); if ( previous?.delivery !== item.delivery || - previous?.error !== item.error + previous?.error !== item.error || + previous?.managed !== item.managed ) { if (messageKind(item.event.kind) && !affected.has(id)) this.delivery(id, item); @@ -157,6 +159,9 @@ export class MessageProjection { ...row, delivery: item?.delivery, deliveryError: item?.error, + managedDelivery: item?.managed + ? deliveryState(row.id, item.managed) + : undefined, }); } private delivery(id: string, item: OutgoingEvent | undefined) { diff --git a/src/features/relay/messages.ts b/src/features/relay/messages.ts index 2c395d2449..499396bab2 100644 --- a/src/features/relay/messages.ts +++ b/src/features/relay/messages.ts @@ -46,12 +46,29 @@ export function createMessages( publishReport?: (template: EventTemplate) => Promise, /** Explicit relay NIP-11 self; never inferred from contacts or a displayed row. */ signingAuthority?: string, + managedRecipient?: string, ) { const writer = (kind: number, channelId: string) => { if (!canParticipate(channelId)) throw new Error("Join the conversation before posting"); if (!outbox?.supports(kind)) throw new Error("This connection cannot publish that operation"); + if (managedRecipient && kind === 9) { + if (!/^[0-9a-f]{64}$/.test(managedRecipient)) + throw new Error("Invalid managed recipient"); + validateMentions(channelId, [managedRecipient]); + return { + ...outbox, + send: ( + input: Parameters[0], + recovery?: OutboxRecovery, + active?: () => boolean, + ) => + outbox.send(input, recovery, active, { + recipient: { channelId, agentId: managedRecipient }, + }), + }; + } return outbox; }; const ownedMessage = (event: EventData) => diff --git a/src/features/relay/native.ts b/src/features/relay/native.ts index 89ace13890..b872156bef 100644 --- a/src/features/relay/native.ts +++ b/src/features/relay/native.ts @@ -1,4 +1,8 @@ import { audienceAuthorityCommunity } from "../sessions/audience-authority"; +import { + artifactSnapshotCapability, + createArtifactSnapshotReader, +} from "./artifact-snapshot"; import { Channel, convertFileSrc, invoke } from "@tauri-apps/api/core"; import type { EventTemplate } from "nostr-tools"; import { communityDestination, relayOrigin } from "../communities/destination"; @@ -395,6 +399,19 @@ export async function connectNativeTransport( // Capabilities describe implemented host operations, not everything this key can sign. return { ...transport, + ...(artifactSnapshotCapability(info) + ? { + artifactSnapshots: createArtifactSnapshotReader((filter, signal) => + admitSignedRequest( + origin, + transport.viewer, + () => nativeRelayRequest(origin, "/query", [filter]), + signal, + "background", + ), + ), + } + : {}), audienceAuthorityCommunity: audienceAuthorityCommunity( info.audience_authority, ), @@ -784,7 +801,10 @@ export async function connectNativeTransport( async publish(event, signal) { // The relay rejects old events before deduplication. An explicit retry // can confirm the original ID, but must never silently re-date it. - if (event.created_at < Math.floor(Date.now() / 1000) - 15 * 60) { + if ( + event.kind !== ARTIFACT_KIND && + event.created_at < Math.floor(Date.now() / 1000) - 15 * 60 + ) { const found = await transport.query( [ { diff --git a/src/features/relay/outbox-storage.ts b/src/features/relay/outbox-storage.ts index eefb51d1e9..aeac8da4fa 100644 --- a/src/features/relay/outbox-storage.ts +++ b/src/features/relay/outbox-storage.ts @@ -1,4 +1,5 @@ import type { OutboxStorage, OutgoingEvent } from "./outbox"; +import { mergeManagedDelivery } from "./managed-delivery"; function openDatabase() { return new Promise((resolve, reject) => { @@ -52,9 +53,28 @@ export async function purgeOutboxStorage(scope: string) { export function browserOutboxStorage(scope: string): OutboxStorage { let database: Promise | undefined; let persisted = new Map(); - const open = () => (database ??= openDatabase()); + const open = () => + (database ??= openDatabase().catch((error) => { + database = undefined; + throw error; + })); + const listeners = new Set<() => void>(); + const changes = + typeof BroadcastChannel === "undefined" + ? undefined + : new BroadcastChannel(`buzz-outbox:${scope}`); + if (changes) + changes.onmessage = () => { + for (const listener of listeners) listener(); + }; return { - async load() { + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + async load(options) { const db = await open(); let migrated = false; const records = await new Promise( @@ -89,9 +109,10 @@ export function browserOutboxStorage(scope: string): OutboxStorage { }, ); // A first save migrates legacy records into the per-event store. - persisted = migrated - ? new Map(records.map((item) => [item.event.id, item])) - : new Map(); + if (!options?.refresh) + persisted = migrated + ? new Map(records.map((item) => [item.event.id, item])) + : new Map(); return records; }, async save(operations) { @@ -113,16 +134,34 @@ export function browserOutboxStorage(scope: string): OutboxStorage { old.signed?.id === operation.signed?.id && old.recovery?.key === operation.recovery?.key && old.recovery?.value === operation.recovery?.value && - old.guarded === operation.guarded + old.guarded === operation.guarded && + JSON.stringify(old.managed) === JSON.stringify(operation.managed) ) continue; - events.put({ scope, id, operation }); + if (operation.managed) { + const current = events.get([scope, id]); + current.onsuccess = () => { + try { + const prior = current.result?.operation as + | OutgoingEvent + | undefined; + const managed = + prior?.managed && operation.managed + ? mergeManagedDelivery(prior.managed, operation.managed) + : operation.managed; + events.put({ scope, id, operation: { ...operation, managed } }); + } catch { + tx.abort(); + } + }; + } else events.put({ scope, id, operation }); } for (const id of persisted.keys()) if (!next.has(id)) events.delete([scope, id]); tx.objectStore("partitions").put(true, scope); tx.oncomplete = () => { persisted = next; + changes?.postMessage(null); resolve(); }; tx.onabort = () => @@ -131,6 +170,8 @@ export function browserOutboxStorage(scope: string): OutboxStorage { }); }, close() { + changes?.close(); + listeners.clear(); void database?.then( (db) => db.close(), () => {}, diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts index 6a0bf0ed92..e2560afc84 100644 --- a/src/features/relay/outbox.ts +++ b/src/features/relay/outbox.ts @@ -1,4 +1,14 @@ +import { ARTIFACT_MAX_EVENT_BYTES } from "../agents/agent-config-artifacts"; import { isWorkflowOperation } from "../workflows/protocol"; +import { + managedDelivery, + mergeManagedDelivery, + deliveryState, + MANAGED_MAX_ATTEMPTS, + MANAGED_RECEIPT_MS, + type ManagedDelivery, + type ManagedRecipient, +} from "./managed-delivery"; import { yieldToHost } from "./yield"; import { getEventHash, type EventTemplate } from "nostr-tools"; import { eventDto, type EventData, type RelayEvent } from "./events"; @@ -38,6 +48,7 @@ export type OutgoingEvent = Readonly<{ acknowledged?: true; /** A caller-scoped admission requires renewed live eligibility for retry. */ guarded?: boolean; + managed?: ManagedDelivery; delivery: Delivery; error?: string | undefined; /** The relay refused the last attempt, rather than it failing on this @@ -60,6 +71,7 @@ export interface Outbox { }, recovery?: OutboxRecovery, active?: () => boolean, + managed?: Readonly<{ recipient?: ManagedRecipient }>, ): string; /** Attach caller-owned recovery to an existing receipt before resuming it. */ recover(id: string, recovery: OutboxRecovery): Promise; @@ -75,8 +87,11 @@ type SendObserver = ( ) => ((pending: readonly EventData[]) => void) | undefined; export type LocalEvents = Pick; export interface OutboxStorage { + subscribe?(listener: () => void): () => void; close?(): void; - load(): readonly OutgoingEvent[] | Promise; + load(options?: { + refresh: true; + }): readonly OutgoingEvent[] | Promise; save(operations: readonly OutgoingEvent[]): void | Promise; } export class PublishRejected extends Error { @@ -166,6 +181,7 @@ export function createOutbox( held?: true; }; const attempts = new Map(); + const deliveries = new Map, string>(); const dismissing = new Map>(); const lifetime = new AbortController(); const sendListeners = new Set(); @@ -229,6 +245,8 @@ export function createOutbox( let confirmedKeep: ((event: EventData) => boolean) | undefined; let storageError: string | undefined; let hydrated = false; + let managedOwner = false; + let managedTimer: ReturnType | undefined; let durable = Promise.resolve(); const notify = () => { pendingBytes = snapshot.reduce( @@ -249,7 +267,10 @@ export function createOutbox( for (const listener of localListeners) notifyListener(listener); for (const listener of listeners) notifyListener(listener); }; - const persist = (id: string, action?: "acknowledge" | "dismiss") => { + const persist = ( + id: string, + action?: "acknowledge" | "dismiss" | "receipt", + ) => { const queued = profiling.start("outbox.queue", id); const work = durable .catch(() => {}) @@ -265,17 +286,30 @@ export function createOutbox( const records = action === "dismiss" ? visible.filter((item) => item.event.id !== id) - : action === "acknowledge" + : action === "receipt" ? visible.map((item) => - item.event.id === id + item.event.id === id && item.managed ? { ...item, - recovery: undefined, - acknowledged: true as const, + managed: { + ...item.managed, + agentReceived: true, + error: undefined, + }, } : item, ) - : visible; + : action === "acknowledge" + ? visible.map((item) => + item.event.id === id + ? { + ...item, + recovery: undefined, + acknowledged: true as const, + } + : item, + ) + : visible; const bytes = action === "dismiss" ? byteSize(snapshot.filter((item) => item.event.id !== id)) @@ -284,6 +318,18 @@ export function createOutbox( if (bytes > 2 * 1024 * 1024) throw new Error("Outbox storage is full"); await storage.save(records); + if (action === "receipt") { + const current = find(id); + if (current?.managed) + replace({ + ...current, + managed: { + ...current.managed, + agentReceived: true, + error: undefined, + }, + }); + } if (action === "dismiss") { // Commit removal inside the serialized write, before a later save // can capture state. Failed storage never exposes a temporary absence. @@ -339,6 +385,101 @@ export function createOutbox( void persist(item.event.id).catch(() => { /* Durable intent restores as unknown. */ }); + if (item.managed) wakeManaged(); + } + const managedDone = (state: ManagedDelivery) => + state.relayAccepted && (!state.recipient || state.agentReceived); + function refreshManaged() { + const work = durable + .catch(() => {}) + .then(() => ready) + .then(async () => { + if (closed) return; + const records = await storage.load({ refresh: true }); + validateSaved(records); + const incoming = new Map( + records + .filter((item) => item.managed) + .map((item) => [item.event.id, decode(item)]), + ); + const next = new Map(snapshot.map((item) => [item.event.id, item])); + const before = JSON.stringify(visible); + for (const [id, item] of incoming) { + const prior = next.get(id) ?? completed.peek(id); + const managed = + prior?.managed && item.managed + ? mergeManagedDelivery(prior.managed, item.managed) + : item.managed; + const merged = { + ...item, + ...(attempts.has(id) && prior + ? { delivery: prior.delivery, error: prior.error } + : {}), + ...(managed ? { managed } : {}), + }; + if (managed && managedDone(managed) && !attempts.has(id)) { + completed.set(id, merged); + next.delete(id); + } else { + next.set(id, merged); + completed.delete(id); + } + } + for (const [id, item] of next) + if (item.managed && !incoming.has(id) && !attempts.has(id)) + next.delete(id); + snapshot = Object.freeze([...next.values()]); + if ( + before !== + JSON.stringify([ + ...completed.entries().map(([, item]) => item), + ...snapshot, + ]) + ) + notify(); + }); + durable = work; + return work; + } + function wakeManaged(delay = 0) { + if (closed || !managedOwner) return; + clearTimeout(managedTimer); + managedTimer = setTimeout(() => { + managedTimer = undefined; + void pumpManaged().catch(() => {}); + }, delay); + } + async function pumpManaged() { + if (closed || !managedOwner) return; + await refreshManaged(); + if (closed || !managedOwner) return; + let delay = Infinity; + for (const item of [...snapshot]) { + const state = item.managed; + const id = item.event.id; + if (!state || attempts.has(id)) continue; + if (managedDone(state)) { + completed.set(id, item); + snapshot = Object.freeze(snapshot.filter((row) => row.event.id !== id)); + notify(); + await persist(id); + continue; + } + if (state.error) continue; + if (state.relayAccepted) { + if (state.receiptDeadline <= Date.now()) + saveStatus({ ...item, managed: { ...state, error: "receipt" } }); + else delay = Math.min(delay, state.receiptDeadline - Date.now()); + } else if (item.refused || state.attempts >= MANAGED_MAX_ATTEMPTS) { + saveStatus({ ...item, managed: { ...state, error: "send" } }); + } else if (state.nextAttemptAt > Date.now()) + delay = Math.min(delay, state.nextAttemptAt - Date.now()); + else { + replace({ ...item, delivery: "sending", error: undefined }); + schedule(id, undefined, item.delivery); + } + } + if (Number.isFinite(delay)) wakeManaged(delay); } function validateSaved(loaded: readonly OutgoingEvent[]) { if ( @@ -366,8 +507,11 @@ export function createOutbox( ...(item.recovery ? { recovery: recoveryValue(item.recovery) } : {}), ...(item.acknowledged === true ? { acknowledged: true as const } : {}), ...(item.guarded ? { guarded: true } : {}), - delivery: - item.delivery === "seen" && signed + ...(item.managed ? { managed: managedDelivery(item.managed) } : {}), + ...(item.managed && item.refused ? { refused: true as const } : {}), + delivery: item.managed?.relayAccepted + ? "accepted" + : item.delivery === "seen" && signed ? "seen" : item.delivery === "failed" ? "failed" @@ -378,17 +522,22 @@ export function createOutbox( function restore(restored: readonly OutgoingEvent[]) { const pending = restored.filter( (item) => - (!(item.event.kind === 9000 && item.acknowledged) && - item.delivery !== "seen") || - !!item.recovery, + (item.managed + ? !managedDone(item.managed) + : !(item.event.kind === 9000 && item.acknowledged) && + item.delivery !== "seen") || + !!item.recovery || + (!!item.managed && !managedDone(item.managed)), ); if (pending.length > MAX_PENDING) throw new Error("Saved pending outbox exceeds its budget"); for (const item of restored) if ( - ((item.event.kind === 9000 && item.acknowledged) || + ((!!item.managed && managedDone(item.managed)) || + (item.event.kind === 9000 && item.acknowledged) || item.delivery === "seen") && !item.recovery && + (!item.managed || managedDone(item.managed)) && (!confirmedInvalidated || (item.event.kind === 9007 && confirmedKeep?.(item.event))) ) @@ -461,6 +610,24 @@ export function createOutbox( previousDelivery: Delivery = "failed", ) { if (closed || attempts.has(id)) return; + const item = find(id); + if (item?.managed) { + if ( + !managedOwner || + item.managed.relayAccepted || + item.managed.attempts >= MANAGED_MAX_ATTEMPTS || + item.managed.error + ) + return; + replace({ + ...item, + managed: { + ...item.managed, + attempts: item.managed.attempts + 1, + nextAttemptAt: Date.now() + 1000 * 2 ** item.managed.attempts, + }, + }); + } attempts.set(id, { expiresAt: Date.now() + timeoutMs, previousDelivery, @@ -469,7 +636,13 @@ export function createOutbox( }); void deliver(id); } - async function deliver(id: string) { + function deliver(id: string) { + const work = deliverAttempt(id); + deliveries.set(work, id); + void work.finally(() => deliveries.delete(work)); + return work; + } + async function deliverAttempt(id: string) { const attempt = attempts.get(id); if (closed || !attempt || attempt.controller || inflight() >= 3) return; if (!find(id)) return; @@ -552,6 +725,18 @@ export function createOutbox( if (latest) saveStatus({ ...latest, + ...(latest.managed + ? { + managed: { + ...latest.managed, + relayAccepted: true, + receiptDeadline: + latest.managed.receiptDeadline || + Date.now() + MANAGED_RECEIPT_MS, + error: undefined, + }, + } + : {}), delivery: latest.delivery === "seen" || attempt.previousDelivery === "seen" ? "seen" @@ -581,7 +766,7 @@ export function createOutbox( ...latest, delivery, refused: - delivery === "failed" && + (delivery === "failed" || !!latest.managed) && error instanceof PublishRejected && !(error instanceof PublishNotSent) ? true @@ -619,7 +804,8 @@ export function createOutbox( observed && ((observed.event.kind === 9000 && observed.acknowledged) || observed.delivery === "seen") && - !observed.recovery + !observed.recovery && + (!observed.managed || managedDone(observed.managed)) ) { completed.set(id, observed); snapshot = Object.freeze( @@ -629,6 +815,7 @@ export function createOutbox( void persist(id).catch(() => {}); } pumpDeliveries(); + wakeManaged(); } } const subscribe = (set: Set<() => void>, listener: () => void) => { @@ -690,6 +877,10 @@ export function createOutbox( async acknowledge(id: string) { await outbox.ready(); const item = visible.find((item) => item.event.id === id); + if (item?.managed && !managedDone(item.managed)) + return Promise.reject( + new Error("Resolve managed delivery before removing this message"), + ); if (!item?.recovery) return; // Member callers verify a fresh signed roster even when the receipt was lost. if ( @@ -712,12 +903,26 @@ export function createOutbox( }, recovery?: OutboxRecovery, active?: () => boolean, + managed?: Readonly<{ recipient?: ManagedRecipient }>, ) { if (active && !active()) throw new DOMException("Channel addition cancelled", "AbortError"); if (closed) throw abortError(); if (storageError) throw new Error(storageError); const savedRecovery = recoveryValue(recovery); + if (managed && savedRecovery) + throw new Error( + "Managed delivery cannot replace another recovery intent", + ); + if ( + managed?.recipient && + (input.kind !== 9 || + !input.tags.some( + ([name, value]) => + name === "h" && value === managed.recipient?.channelId, + )) + ) + throw new Error("Managed recipient does not match the message"); if ( savedRecovery && (!hydrated || @@ -738,7 +943,10 @@ export function createOutbox( (catalog ? bytes(input.content) > MAX_CONTENT_BYTES || byteSize(input) > MAX_EVENT_BYTES - : byteSize(input) > OUTBOX_INPUT_MAX_BYTES) + : byteSize(input) > + (input.kind === 45010 + ? ARTIFACT_MAX_EVENT_BYTES - 512 + : OUTBOX_INPUT_MAX_BYTES)) ) throw new Error("Message is empty or too large"); if (snapshot.length >= MAX_PENDING) @@ -821,6 +1029,20 @@ export function createOutbox( delivery: "sending" as const, ...(savedRecovery ? { recovery: savedRecovery } : {}), ...(active ? { guarded: true } : {}), + ...(managed + ? { + managed: managedDelivery({ + version: 1, + ...managed, + round: 0, + attempts: 0, + relayAccepted: false, + agentReceived: false, + nextAttemptAt: 0, + receiptDeadline: 0, + }), + } + : {}), }), ]); notify(); @@ -831,10 +1053,28 @@ export function createOutbox( }); void intent.catch(() => {}); // Delivery reports persistence errors; disposal still saves queued intent. schedule(event.id, intent); + wakeManaged(); return event.id; }, retry(id: string, active?: () => boolean) { const item = find(id); + if (item?.managed) { + replace({ + ...item, + managed: { + ...item.managed, + round: item.managed.round + 1, + attempts: 0, + nextAttemptAt: 0, + receiptDeadline: Date.now() + MANAGED_RECEIPT_MS, + error: undefined, + }, + }); + void persist(id) + .then(() => wakeManaged()) + .catch(() => {}); + return; + } if (item?.guarded && (active ?? admissionGates.get(id))?.() !== true) throw new DOMException("Channel addition cancelled", "AbortError"); // Workflow recovery is inspect/dismiss only, including restored intents. @@ -873,6 +1113,10 @@ export function createOutbox( if (pending) return pending; if (closed || attempts.has(id)) return Promise.resolve(); const item = visible.find((item) => item.event.id === id); + if (item?.managed && !managedDone(item.managed)) + return Promise.reject( + new Error("Resolve managed delivery before removing this message"), + ); if (item?.recovery && item.delivery !== "failed") return Promise.reject( new Error( @@ -890,6 +1134,11 @@ export function createOutbox( return work; }, }); + const stopStorage = storage.subscribe?.(() => { + void refreshManaged() + .then(() => wakeManaged()) + .catch(() => {}); + }); return { outbox, local: Object.freeze({ @@ -897,6 +1146,80 @@ export function createOutbox( subscribe: (listener: () => void) => subscribe(localListeners, listener), }), ready, + delivery: { + async get(id: string) { + await refreshManaged(); + const item = visible.find((item) => item.event.id === id); + return item?.managed ? deliveryState(id, item.managed) : undefined; + }, + subscribe: (listener: () => void) => subscribe(localListeners, listener), + retry: async (id: string) => { + await refreshManaged(); + outbox.retry(id); + await persist(id); + }, + async submit( + input: Parameters[0], + recipient?: ManagedRecipient, + ) { + await outbox.ready(); + const id = outbox.send(input, undefined, undefined, { + ...(recipient ? { recipient } : {}), + }); + await persist(id); + return { operationId: id, eventId: id }; + }, + }, + async managedOwner(owned: boolean) { + managedOwner = owned; + clearTimeout(managedTimer); + if (owned) { + await refreshManaged(); + wakeManaged(); + } else + for (const [id, attempt] of attempts) + if (find(id)?.managed) attempt.controller?.abort(abortError()); + if (!owned) { + await Promise.allSettled( + [...deliveries] + .filter(([, id]) => !!find(id)?.managed) + .map(([work]) => work), + ); + await durable.catch(() => {}); + } + }, + async receiveManaged( + channelId: string, + agentId: string, + messageIds: readonly string[], + ) { + if ( + messageIds.length > 256 || + messageIds.some((id) => !/^[0-9a-f]{64}$/.test(id)) + ) + throw new Error("Invalid message receipt IDs"); + await refreshManaged(); + for (const id of new Set(messageIds)) { + const item = find(id); + if ( + !item?.managed || + item.managed.recipient?.channelId !== channelId || + item.managed.recipient.agentId !== agentId || + item.managed.agentReceived + ) + continue; + try { + await persist(id, "receipt"); + } catch (error) { + replace({ + ...item, + managed: { ...item.managed, error: "receipt-save" }, + }); + throw error; + } + } + wakeManaged(); + }, /** Fetched/confirmed evidence is disposable; pending intent is not. A revoke * during async hydration fences that old confirmed cache, not its writes. */ purgeConfirmed(keep: (event: EventData) => boolean) { @@ -924,7 +1247,8 @@ export function createOutbox( delivered(event); if ( (awaitsReceipt(event) && attempts.has(event.id)) || - find(event.id)?.recovery + find(event.id)?.recovery || + find(event.id)?.managed ) { snapshot = Object.freeze( snapshot.map((item) => @@ -933,6 +1257,17 @@ export function createOutbox( ...item, signed: event, delivery: "seen" as const, + ...(item.managed + ? { + managed: { + ...item.managed, + relayAccepted: true, + receiptDeadline: + item.managed.receiptDeadline || + Date.now() + MANAGED_RECEIPT_MS, + }, + } + : {}), }) : item, ), @@ -953,7 +1288,8 @@ export function createOutbox( (item) => !byId.has(item.event.id) || (awaitsReceipt(item.event) && attempts.has(item.event.id)) || - !!item.recovery, + !!item.recovery || + (!!item.managed && !managedDone(item.managed)), ), ); for (const event of confirmed) { @@ -965,12 +1301,15 @@ export function createOutbox( } notify(); void persist(first.id).catch(() => {}); + wakeManaged(); }, dispose() { if (closed) return; finalSnapshot = snapshot; finalVisible = visible; closed = true; + stopStorage?.(); + clearTimeout(managedTimer); admissionGates.clear(); lifetime.abort(); for (const attempt of attempts.values()) { diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 5547ca93f8..45d09ee2eb 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -1,4 +1,5 @@ import type { LocalAgentSource } from "../agents/local-activity"; +import { registerManagedDelivery } from "./managed-delivery"; // FOUNDATION: One relay session owns reads, local intent, delivery and shared views. import { createSoloPreferences, soloGroups } from "./solo-preferences"; import { npubEncode } from "nostr-tools/nip19"; @@ -97,6 +98,7 @@ import { browserOutboxStorage, createOutbox, type OutboxStorage, + type Outbox, } from "./outbox"; import { relayPartition, transportPartition } from "./partition"; import { browserThreadFollows } from "./thread-follows"; @@ -184,6 +186,7 @@ export function createRelaySession( const threads = new Set>(); const writer = transport?.writer; const clock = new MessageClock(); + const artifactUpdates = new Set<(events: readonly RelayEvent[]) => void>(); const uploadAttachment = transport?.uploadAttachment; const writes = transport && writer @@ -230,6 +233,19 @@ export function createRelaySession( }, preparePublish: async (event, signal) => { workflows.validate(event); + const managed = rawLocal().find( + (item) => item.event.id === event.id, + )?.managed; + if (managed) { + const id = event.tags.find(([name]) => name === "h")?.[1]; + if (!id) throw new Error("Managed delivery has no channel"); + await channelWrites.refreshMembership(id); + signal.throwIfAborted(); + if (!channels.canParticipate(id)) + throw new Error("Channel access changed"); + if (managed.recipient) + validateMentions(id, [managed.recipient.agentId]); + } if (event.kind === 40100) { const id = event.tags.find((t) => t[0] === "h")?.[1]; if (!id) throw new Error("Canvas has no channel"); @@ -251,8 +267,38 @@ export function createRelaySession( ) : undefined; const rawLocal = () => writes?.local.snapshot() ?? []; + const confirmedArtifactIds = new Set(); + const confirmedArtifacts = () => + rawLocal().flatMap((item) => + item.event.kind === 45010 && + item.signed && + (item.managed?.relayAccepted || + item.delivery === "accepted" || + item.delivery === "seen") + ? [item.signed] + : [], + ); + const stopArtifactJournal = writes?.local.subscribe(() => { + const events = confirmedArtifacts().filter( + (event) => !confirmedArtifactIds.has(event.id), + ); + if (!events.length) return; + for (const event of events) confirmedArtifactIds.add(event.id); + while (confirmedArtifactIds.size > 2048) + confirmedArtifactIds.delete( + confirmedArtifactIds.values().next().value as string, + ); + for (const listener of artifactUpdates) notify(() => listener(events)); + }); let retainedChannelEvent: (id: string) => RelayEvent | undefined = () => undefined; + const stopManaged = + transport && writes + ? registerManagedDelivery(transportPartition(transport), { + owner: writes.managedOwner, + receive: writes.receiveManaged, + }) + : undefined; function retainedThreadEvent(id: string) { for (const thread of threads) { if (!canAccess(thread.channelId)) continue; @@ -1764,7 +1810,77 @@ export function createRelaySession( if (connected && !liveWasConnected) recovery?.retryNow(liveRecovery); liveWasConnected = connected; }); + function messageCommands(managedRecipient?: string) { + return createMessages( + writes?.outbox, + transport?.viewer, + (id) => + local().find((item) => item.event.id === id)?.event ?? + recent.peek(id)?.event ?? + retainedEvent(id), + emoji.tags, + validateMentions, + (id) => !closed && channels.canParticipate(id), + transport?.scope, + writer && (!writer.kinds || writer.kinds.includes(1984)) + ? async (template) => { + if (closed) throw new Error("Relay session closed"); + const signal = AbortSignal.any([ + lifetime.signal, + AbortSignal.timeout(10_000), + ]); + // Race each step so the deadline holds even if the writer ignores `signal`. + const aborted = new Promise((_, reject) => { + const fail = () => reject(signal.reason); + if (signal.aborted) fail(); + else signal.addEventListener("abort", fail, { once: true }); + }); + const signed = await Promise.race([ + writer.sign(template, signal), + aborted, + ]); + signal.throwIfAborted(); + await Promise.race([writer.publish(signed, signal), aborted]); + } + : undefined, + transport?.archiveAuthority, + managedRecipient, + ); + } const session = Object.freeze({ + artifactSnapshots: transport?.artifactSnapshots, + subscribeArtifactUpdates( + listener: (events: readonly RelayEvent[]) => void, + ) { + artifactUpdates.add(listener); + const events = confirmedArtifacts(); + if (events.length) notify(() => listener(events)); + return () => { + artifactUpdates.delete(listener); + }; + }, + delivery: writes + ? { + ...writes.delivery, + async submit(input: Parameters[0]) { + if (input.kind !== 45010) + throw new Error("Use message commands for managed messages"); + const homes = input.tags.filter(([name]) => name === "h"); + const id = homes[0]?.[1]; + if ( + homes.length !== 1 || + !id || + closed || + !channels.canParticipate(id) || + (transport?.subscribe && liveSnapshot.status !== "connected") + ) + throw new Error( + "Reconnect and confirm channel access before saving an artifact", + ); + return writes.delivery.submit(input); + }, + } + : undefined, memberAdditions, presence, viewer: transport?.viewer, @@ -2002,40 +2118,8 @@ export function createRelaySession( }, }) : undefined, - messages: createMessages( - writes?.outbox, - transport?.viewer, - (id) => - local().find((item) => item.event.id === id)?.event ?? - recent.peek(id)?.event ?? - retainedEvent(id), - emoji.tags, - validateMentions, - (id) => !closed && channels.canParticipate(id), - transport?.scope, - writer && (!writer.kinds || writer.kinds.includes(1984)) - ? async (template) => { - if (closed) throw new Error("Relay session closed"); - const signal = AbortSignal.any([ - lifetime.signal, - AbortSignal.timeout(10_000), - ]); - // Race each step so the deadline holds even if the writer ignores `signal`. - const aborted = new Promise((_, reject) => { - const fail = () => reject(signal.reason); - if (signal.aborted) fail(); - else signal.addEventListener("abort", fail, { once: true }); - }); - const signed = await Promise.race([ - writer.sign(template, signal), - aborted, - ]); - signal.throwIfAborted(); - await Promise.race([writer.publish(signed, signal), aborted]); - } - : undefined, - transport?.archiveAuthority, - ), + messages: messageCommands(), + messagesForRecipient: messageCommands, /** An owned bounded thread reader. Dispose on close; the session retains access/lifetime authority. */ thread( channelId: string, @@ -2962,6 +3046,9 @@ export function createRelaySession( } }, dispose() { + stopManaged?.(); + stopArtifactJournal?.(); + artifactUpdates.clear(); stopSoloGroups(); closed = true; stopLiveRecovery(); diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index afc6380f2a..ba8ee56cf0 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -1,4 +1,5 @@ import { audienceAuthorityCommunity } from "../sessions/audience-authority"; +import type { ArtifactSnapshotReader } from "./artifact-snapshot.ts"; import type { ReminderHost } from "./reminders"; import { memoryResponseText, @@ -83,6 +84,7 @@ export interface RelayWriter { ): Promise | Promise; } export interface ReadTransport { + readonly artifactSnapshots?: ArtifactSnapshotReader; readonly projectGit?: RepoGit; readonly readAgentMemories?: MemoryReader; /** Session-scoped owner proof, not an arbitrary signing capability. */ diff --git a/tests/browser/managed-delivery.spec.mjs b/tests/browser/managed-delivery.spec.mjs new file mode 100644 index 0000000000..b4fb84315c --- /dev/null +++ b/tests/browser/managed-delivery.spec.mjs @@ -0,0 +1,168 @@ +import { fileURLToPath } from "node:url"; +import { test as base, expect } from "./playwright-test.mjs"; +import { createServer } from "./vite-server.mjs"; + +const test = base.extend({ + deliveryUrl: [ + async ({ browserName }, use) => { + if (!["chromium", "webkit"].includes(browserName)) + throw new Error("Unsupported storage test engine"); + const server = await createServer({ + root: fileURLToPath(new URL("../../", import.meta.url)), + configFile: false, + envDir: false, + logLevel: "error", + server: { host: "127.0.0.1", port: 0 }, + }); + await server.listen(); + try { + await use( + `${server.resolvedUrls.local[0]}tests/fixtures/managed-delivery.html`, + ); + } finally { + await server.close(); + } + }, + { scope: "worker" }, + ], +}); +async function open(page, url, scope) { + await page.goto(url); + await page.waitForFunction(() => !!window.deliveryTest); + await page.evaluate((scope) => window.deliveryTest.open(scope), scope); +} + +// These checks use real IndexedDB, BroadcastChannel and Web Locks across windows. +test("transfers ownership on window close and never republishes accepted work", async ({ + page, + context, + deliveryUrl, +}) => { + const scope = "owner-transfer"; + await open(page, deliveryUrl, scope); + const second = await context.newPage(); + await open(second, deliveryUrl, scope); + await page.evaluate((scope) => window.deliveryTest.own(scope), scope); + const operation = await second.evaluate(() => window.deliveryTest.submit()); + await expect + .poll(() => page.evaluate(() => window.deliveryTest.publications.length)) + .toBe(1); + await expect + .poll(() => + second.evaluate((id) => window.deliveryTest.get(id), operation.eventId), + ) + .toMatchObject({ relayAccepted: true, agentReceived: false }); + const transfer = second.evaluate( + (scope) => window.deliveryTest.own(scope), + scope, + ); + await page.close(); + await transfer; + await second.evaluate( + (id) => window.deliveryTest.retry(id), + operation.eventId, + ); + await second.evaluate( + (id) => window.deliveryTest.receive(id), + operation.eventId, + ); + expect( + await second.evaluate( + (id) => window.deliveryTest.get(id), + operation.eventId, + ), + ).toMatchObject({ phase: "received" }); + expect(await second.evaluate(() => window.deliveryTest.publications)).toEqual( + [], + ); + await second.reload(); + await second.waitForFunction(() => !!window.deliveryTest); + await second.evaluate((scope) => window.deliveryTest.open(scope), scope); + expect( + await second.evaluate( + (id) => window.deliveryTest.get(id), + operation.eventId, + ), + ).toMatchObject({ relayAccepted: true, agentReceived: true }); +}); + +test("keeps accepted and received facts through a stale window save", async ({ + page, + context, + deliveryUrl, +}) => { + const scope = "stale-save"; + await open(page, deliveryUrl, scope); + const second = await context.newPage(); + await open(second, deliveryUrl, scope); + const operation = await page.evaluate(() => window.deliveryTest.submit()); + await second.evaluate(async (scope) => { + window.staleStorage = window.deliveryTest.storage(scope); + window.staleRecords = await window.staleStorage.load(); + }, scope); + await page.evaluate((scope) => window.deliveryTest.own(scope), scope); + await expect + .poll(() => + page.evaluate((id) => window.deliveryTest.get(id), operation.eventId), + ) + .toMatchObject({ relayAccepted: true }); + await page.evaluate( + (id) => window.deliveryTest.receive(id), + operation.eventId, + ); + await second.evaluate(async () => { + await window.staleStorage.save( + window.staleRecords.map((item) => ({ + ...item, + error: "stale", + managed: { ...item.managed, attempts: 1 }, + })), + ); + }); + expect( + await page.evaluate((id) => window.deliveryTest.get(id), operation.eventId), + ).toMatchObject({ + relayAccepted: true, + agentReceived: true, + phase: "received", + }); + await page.evaluate(() => window.deliveryTest.release()); +}); + +// This uses the production sync owner with real Web Locks. The relay reader is deterministic. +test("syncs a follower-only collection and transfers a shared collection on window close", async ({ + page, + context, + deliveryUrl, +}) => { + const scope = "collection-owner"; + await open(page, deliveryUrl, scope); + const second = await context.newPage(); + await open(second, deliveryUrl, scope); + await page.evaluate( + (scope) => window.deliveryTest.openCollection(scope, "main"), + scope, + ); + await expect + .poll(() => page.evaluate(() => window.deliveryTest.captures)) + .toEqual(["main"]); + await second.evaluate( + (scope) => window.deliveryTest.openCollection(scope, "archive"), + scope, + ); + await expect + .poll(() => second.evaluate(() => window.deliveryTest.captures)) + .toEqual(["archive"]); + await second.evaluate( + (scope) => window.deliveryTest.openCollection(scope, "main"), + scope, + ); + await page.close(); + await expect + .poll(() => second.evaluate(() => window.deliveryTest.captures)) + .toEqual(["archive", "main"]); + expect( + (await second.evaluate(() => window.deliveryTest.readCollection())) + .preference, + ).toEqual({ pinned: false }); +}); diff --git a/tests/fixtures/managed-delivery.html b/tests/fixtures/managed-delivery.html new file mode 100644 index 0000000000..75d7becacc --- /dev/null +++ b/tests/fixtures/managed-delivery.html @@ -0,0 +1 @@ +Delivery checks

Delivery checks

diff --git a/tests/fixtures/managed-delivery.ts b/tests/fixtures/managed-delivery.ts new file mode 100644 index 0000000000..843e7380de --- /dev/null +++ b/tests/fixtures/managed-delivery.ts @@ -0,0 +1,150 @@ +import type { OpenArtifactCollection } from "../../src/features/artifact-storage/service"; +import { provideArtifactSync } from "../../src/features/relay/artifact-sync"; +import { createArtifactStore } from "../../src/features/artifact-storage/store"; +import { finalizeEvent, getPublicKey } from "nostr-tools"; +import { createOutbox } from "../../src/features/relay/outbox"; +import { browserOutboxStorage } from "../../src/features/relay/outbox-storage"; +import { mergeManagedDelivery } from "../../src/features/relay/managed-delivery"; + +const secret = new Uint8Array(32).fill(7); +const viewer = getPublicKey(secret); +const recipient = { channelId: "task", agentId: "a".repeat(64) }; +const publications: string[] = []; +let owner: ReturnType; +let release: (() => void) | undefined; +let lock: Promise | undefined; +const open = async (scope: string) => { + owner = createOutbox( + viewer, + { + sign: async (template) => + finalizeEvent(structuredClone(template), secret), + publish: async (event) => { + publications.push(event.id); + }, + }, + browserOutboxStorage(scope), + ); + await owner.ready; +}; +const own = async (scope: string) => { + let acquired!: () => void; + const ready = new Promise((resolve) => { + acquired = resolve; + }); + lock = navigator.locks.request(`delivery-check:${scope}`, async () => { + await owner.managedOwner(true); + acquired(); + await new Promise((resolve) => { + release = resolve; + }); + await owner.managedOwner(false); + }); + await ready; +}; +const captures: string[] = []; +async function openCollection(scope: string, name: string) { + const relay = `https://${scope}.test`; + const store = createArtifactStore(viewer, relay); + const definition = { + name, + type: "test.delivery", + version: 1, + project: ({ record, preference }) => [ + { index: "pin", value: String(preference?.pinned), order: record.id }, + ], + }; + const collection = store.collection("test.plugin", definition); + await collection.importBatch({ + records: [{ id: "task", local: { title: "Saved" } }], + preferences: [{ id: "task", value: { pinned: false } }], + marker: "ready", + }); + const signal = new AbortController(); + let opened: (entry: OpenArtifactCollection) => void = () => {}; + const session = { + live: { + snapshot: () => ({ status: "connected" }), + subscribe: () => () => {}, + }, + channels: { subscribeList: () => () => {} }, + subscribeLive: () => () => {}, + subscribeArtifactUpdates: () => () => {}, + artifactSnapshots: { + start: async () => { + captures.push(name); + return { + token: crypto.randomUUID(), + expiresAt: Date.now() / 1000 + 300, + entries: [], + nextToken: null, + }; + }, + complete: async () => {}, + }, + }; + const effects = []; + const ctx = { + relay: { + snapshot: () => ({ + viewer, + origin: relay, + status: "ready", + cached: false, + session, + }), + subscribe: () => () => {}, + }, + effect: (effect) => effects.push(effect()), + }; + provideArtifactSync(ctx, { + onOpen: (listener) => { + opened = listener; + return () => {}; + }, + }); + opened({ + viewer, + relay, + owner: "test.plugin", + definition, + collection, + signal: signal.signal, + }); + return () => collection.get("task"); +} +let readCollection: Awaited>; +Object.assign(window, { + deliveryTest: { + captures, + openCollection: async (scope, name) => { + readCollection = await openCollection(scope, name); + }, + readCollection: () => readCollection(), + open, + own, + publications, + recipient, + mergeManagedDelivery, + storage: browserOutboxStorage, + submit: () => + owner.delivery.submit( + { kind: 9, content: "Task prompt", tags: [["h", "task"]] }, + recipient, + ), + debug: () => + owner.local.snapshot().map((item) => ({ + error: item.error, + delivery: item.delivery, + managed: item.managed, + })), + get: (id: string) => owner.delivery.get(id), + receive: (id: string) => + owner.receiveManaged(recipient.channelId, recipient.agentId, [id]), + retry: (id: string) => owner.delivery.retry(id), + release: async () => { + release?.(); + await lock; + }, + }, +}); From 1ada2a6e9fb7211c566acc49f7274d7fdd8bc24f Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 13:59:44 -0700 Subject: [PATCH 05/30] Preserve delivery intent and scope ownership Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/artifact-sync-delivery.md | 8 +- src/features/relay/artifact-snapshot.ts | 13 +++ src/features/relay/artifact-sync.test.ts | 48 ++++++++- src/features/relay/artifact-sync.ts | 84 ++++++++------- src/features/relay/http-admission.ts | 16 ++- src/features/relay/live.test.ts | 30 ++++++ src/features/relay/live.ts | 60 +++++++++-- src/features/relay/managed-delivery.test.ts | 103 ++++++++++++++++++- src/features/relay/managed-delivery.ts | 38 ++++++- src/features/relay/managed-ownership.test.ts | 30 ++++++ src/features/relay/native.test.ts | 22 ++++ src/features/relay/native.ts | 2 + src/features/relay/outbox.ts | 35 ++++++- src/features/relay/row-identity.ts | 8 ++ src/features/relay/thread-delivery.test.ts | 82 +++++++++++++++ src/features/relay/threads.ts | 4 + src/features/relay/transport.ts | 2 +- tests/fixtures/managed-delivery.ts | 42 +++++--- 18 files changed, 546 insertions(+), 81 deletions(-) create mode 100644 src/features/relay/managed-ownership.test.ts create mode 100644 src/features/relay/thread-delivery.test.ts diff --git a/docs/artifact-sync-delivery.md b/docs/artifact-sync-delivery.md index 110c5529ae..ee0bf1b670 100644 --- a/docs/artifact-sync-delivery.md +++ b/docs/artifact-sync-delivery.md @@ -1,6 +1,6 @@ # Artifact sync and message delivery -The host saves received artifacts through `ArtifactStorageService`. Plugins own each collection definition and projector. A Web Lock covers `{viewer, relay, owner, collectionName}`. Only a window with that definition can own the sync work. The owner cancels reads and waits for commits before it releases the lock. The message outbox has a separate lock for `{viewer, relay}`. +The host saves received artifacts through `ArtifactStorageService`. Plugins own each collection definition and projector. A Web Lock covers `{viewer, relay, owner, collectionName}`. Only a window with that definition can own the sync work. The owner cancels reads and waits for commits before it releases the lock. The message outbox has a separate lock for `{viewer, relay}`. Each retained relay session keeps its lock until disposal. Community selection does not stop pending delivery. ## Snapshot contract @@ -8,7 +8,7 @@ Sync uses the existing relay session and socket. It needs the version 1 `artifac The client saves validated bodies during capture. It starts with 100 bodies per request. It splits a batch when the relay returns `snapshot_limit`. Saved bodies remain available after an interrupted capture. A retry can reuse them. Only valid completion permits absence reconciliation. Preferences, pending work, and records with newer local commits survive completion. A reset, expired token, changed access, or lost connection prevents completion. -Live events and confirmed local artifact writes use the same revision checks and atomic store path. A late acceptance cannot replace a newer revision. Repairs run after reconnect, collection changes, and live hints. A 60-second repair covers homes outside active message routes. Repairs are sequential within a collection. Three short retries precede the next repair interval. Local reads do not wait for repair. +Live events and confirmed local artifact writes use the same revision checks and atomic store path. A late acceptance cannot replace a newer revision. Repairs run after reconnect, collection changes, roster changes, and live hints. Channel activity alone does not request repair. A 60-second repair covers homes outside active message routes. Repairs are sequential within a collection. Three short retries precede the next repair interval. Local reads do not wait for repair. The relay snapshot PR is a prerequisite. A relay without the capability keeps the saved view. This client does not invent a checkpoint from ordinary query results. @@ -16,7 +16,7 @@ The relay snapshot PR is a prerequisite. A relay without the capability keeps th Large joined rosters use live groups of at most 1,000 channels. Each group stays open while recent replay reads run in groups of ten channels. At most four requests await EOSE. Each replay filter keeps its 500-event limit. EOSE establishes a route; it does not prove that history is complete. A full limit remains incomplete evidence. Existing finite catch-up owns older gaps. The agent runner retains its event-ID checks before dispatch. -The transport accepts at most 100,000 channel interests. Singleton previews consume separate routes. Demand beyond route capacity gets a limited status. Grouped interests preserve ordinary chat and all-channel agent watches. They do not make 100,000 simultaneous agent runs a supported workload. +Admission counts retained routes. New joined channels fill groups with space. Retired membership scopes remain fenced. The transport accepts at most 100,000 channel interests. Singleton previews consume separate routes. Demand beyond route capacity gets a limited status. Grouped interests preserve ordinary chat and all-channel agent watches. They do not make 100,000 simultaneous agent runs a supported workload. The relay permits 1,024 subscriptions, ten filters per request, and 2,048 explicit channel values per request. The default frame limit is 524,288 bytes. Browser broker controls and status frames have a separate 32 MiB bound because their local status includes each channel. This bound does not change relay event limits. @@ -24,7 +24,7 @@ The relay permits 1,024 subscriptions, ten filters per request, and 2,048 explic `session.messagesForRecipient(agentPubkey)` uses the existing message validation and composer path. `EmbeddedThread.managedRecipient` selects this path for a task conversation. Ordinary chat keeps its existing behavior. `session.delivery.submit(input)` admits kind-45010 artifact operations. `delivery.get(id)` is asynchronous. `delivery.subscribe(listener)` reports durable state changes. -The outbox saves intent and the signed event before publication. It retains the same event ID across uncertain retries. The owner makes at most three attempts before an error. A definitive relay refusal stops automatic retries. Acceptance and exact agent receipt are separate saved facts. Stale writes from another window cannot clear either fact. +The outbox saves intent and the signed event before publication. It retains the same event ID across uncertain retries. The owner makes at most three attempts before an error. A definitive relay refusal stops automatic retries. An explicit retry clears the refusal. A settled refusal can be discarded. Acceptance and exact agent receipt are separate saved facts. Stale writes from another window cannot clear either fact. `AgentRun.received(messageIds)` reports only the exact original message IDs. The host binds the channel and agent identity. The plugin calls it at a verified harness receipt boundary. Process start, typing, and an unrelated reply do not prove receipt. The host saves receipt before it clears the opacity pulse. A save failure keeps accepted evidence and exposes an unconfirmed state. diff --git a/src/features/relay/artifact-snapshot.ts b/src/features/relay/artifact-snapshot.ts index aa712f5644..5dbf42bdf3 100644 --- a/src/features/relay/artifact-snapshot.ts +++ b/src/features/relay/artifact-snapshot.ts @@ -77,6 +77,19 @@ export class ArtifactSnapshotError extends Error { } const object = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); +export function artifactSnapshotFailure(value: unknown) { + if ( + !object(value) || + ![ + "snapshot_reset", + "snapshot_limit", + "snapshot_invalid", + "snapshot_unsupported_scope", + ].includes(String(value.code)) + ) + return; + return { error: "Artifact snapshot request fails", code: String(value.code) }; +} const uuid = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/.test(value) && diff --git a/src/features/relay/artifact-sync.test.ts b/src/features/relay/artifact-sync.test.ts index 6bac16d962..4d5f6b5f8c 100644 --- a/src/features/relay/artifact-sync.test.ts +++ b/src/features/relay/artifact-sync.test.ts @@ -29,6 +29,8 @@ function setup() { let confirmed: (events: readonly RelayEvent[]) => void = () => {}; let opened: (entry: OpenArtifactCollection) => void = () => {}; let online = true; + let rosterListener = () => {}; + let channels = [{ id: "one", lastActivityAt: 0 }]; const session = { artifactSnapshots: {}, live: { @@ -40,7 +42,13 @@ function setup() { }; }, }, - channels: { subscribeList: () => () => {} }, + channels: { + list: () => ({ channels }), + subscribeList: (listener: () => void) => { + rosterListener = listener; + return () => {}; + }, + }, subscribeLive: () => () => {}, subscribeArtifactUpdates: (listener: typeof confirmed) => { confirmed = listener; @@ -101,13 +109,17 @@ function setup() { add, confirmed: (event: RelayEvent) => confirmed([event]), connection, + roster: (value: typeof channels) => { + channels = value; + rosterListener(); + }, }; } -it("uses distinct collection and outbox locks, coalesces repair, and retires closed collections", async () => { +it("coalesces collection repair and retires closed collections", async () => { const h = setup(); const entry = h.add(); await vi.advanceTimersByTimeAsync(0); - expect(navigator.locks.request).toHaveBeenCalledTimes(2); + expect(navigator.locks.request).toHaveBeenCalledTimes(1); expect(reconcileArtifactSnapshot).toHaveBeenCalledOnce(); await vi.advanceTimersByTimeAsync(60_000); expect(reconcileArtifactSnapshot).toHaveBeenCalledTimes(2); @@ -161,3 +173,33 @@ it("commits proven local acceptance through the same revision path as live recor }), ); }); + +it("ignores channel activity but repairs when roster membership changes", async () => { + const h = setup(); + h.add(); + await vi.advanceTimersByTimeAsync(0); + h.roster([{ id: "one", lastActivityAt: 10 }]); + await vi.advanceTimersByTimeAsync(0); + expect(reconcileArtifactSnapshot).toHaveBeenCalledOnce(); + h.roster([ + { id: "one", lastActivityAt: 10 }, + { id: "two", lastActivityAt: 0 }, + ]); + await vi.advanceTimersByTimeAsync(0); + expect(reconcileArtifactSnapshot).toHaveBeenCalledTimes(2); +}); +it("releases an expired version so a current collection can own sync", async () => { + const h = setup(); + vi.mocked(reconcileArtifactSnapshot).mockRejectedValueOnce( + new Error("Artifact collection version changed"), + ); + const old = h.add(); + await vi.advanceTimersByTimeAsync(60_000); + expect(reconcileArtifactSnapshot).toHaveBeenCalledOnce(); + h.add(); + await vi.advanceTimersByTimeAsync(0); + old.controller.abort(); + h.add(); + await vi.advanceTimersByTimeAsync(0); + expect(navigator.locks.request).toHaveBeenCalledTimes(2); +}); diff --git a/src/features/relay/artifact-sync.ts b/src/features/relay/artifact-sync.ts index 01aeef73a6..e80157ad0e 100644 --- a/src/features/relay/artifact-sync.ts +++ b/src/features/relay/artifact-sync.ts @@ -4,7 +4,6 @@ import type { OpenArtifactCollection, } from "../artifact-storage/service"; import { hasEventProof } from "./events"; -import { ownManagedDelivery } from "./managed-delivery"; import type { RelaySession } from "./session"; import { reconcileArtifactSnapshot, @@ -28,7 +27,6 @@ export function provideArtifactSync( storage: ArtifactStorageService, ) { const groups = new Map(); - const deliveryOwners = new Map(); function ensureGroup( viewer: string, relay: string, @@ -73,7 +71,7 @@ export function provideArtifactSync( group.entries.delete(entry); if (!group.entries.size) { group.lifetime.abort(); - groups.delete(group.key); + if (groups.get(group.key) === group) groups.delete(group.key); } }, { once: true }, @@ -90,39 +88,6 @@ export function provideArtifactSync( group.lifetime.abort(); groups.delete(key); } - for (const [key, owner] of deliveryOwners) { - if (key !== scope) { - owner.abort(); - deliveryOwners.delete(key); - } - } - if (scope && !deliveryOwners.has(scope) && navigator.locks) { - const lifetime = new AbortController(); - deliveryOwners.set(scope, lifetime); - void navigator.locks - .request( - `buzz-outbox-owner:${scope}`, - { mode: "exclusive", signal: lifetime.signal }, - async () => { - await ownManagedDelivery(scope, true); - try { - await new Promise((resolve) => { - if (lifetime.signal.aborted) resolve(); - else - lifetime.signal.addEventListener("abort", () => resolve(), { - once: true, - }); - }); - } finally { - await ownManagedDelivery(scope, false); - } - }, - ) - .catch((error) => { - if (!lifetime.signal.aborted) - console.error("Message outbox ownership fails", error); - }); - } }; const stopScope = ctx.relay.subscribe(refreshScope); refreshScope(); @@ -194,7 +159,14 @@ export function provideArtifactSync( ); } retry = 0; - } catch { + } catch (error) { + if ( + error instanceof Error && + error.message === "Artifact collection version changed" + ) { + group.lifetime.abort(); + if (groups.get(group.key) === group) groups.delete(group.key); + } if (!signal.aborted) { retry++; if (retry <= 3) { @@ -226,7 +198,41 @@ export function provideArtifactSync( session = next; if (next) { stopSession = next.live.subscribe(update); - stopRoster = next.channels.subscribeList(request); + const rosterKey = () => + JSON.stringify( + next.channels + .list() + .channels.map( + ({ + id, + cached, + pending, + readOnly, + ttlSeconds, + private: restricted, + members, + }) => [ + id, + cached, + pending, + readOnly, + ttlSeconds, + restricted, + members, + ], + ) + .sort((left, right) => + String(left[0]).localeCompare(String(right[0])), + ), + ); + let roster = rosterKey(); + stopRoster = next.channels.subscribeList(() => { + const updated = rosterKey(); + if (updated !== roster) { + roster = updated; + request(); + } + }); const receive = ( events: readonly import("./events").RelayEvent[], ) => { @@ -324,8 +330,6 @@ export function provideArtifactSync( stopOpen(); stopScope(); for (const group of groups.values()) group.lifetime.abort(); - for (const owner of deliveryOwners.values()) owner.abort(); - deliveryOwners.clear(); groups.clear(); }); } diff --git a/src/features/relay/http-admission.ts b/src/features/relay/http-admission.ts index 2f4a46c64d..39a2a679a4 100644 --- a/src/features/relay/http-admission.ts +++ b/src/features/relay/http-admission.ts @@ -23,6 +23,7 @@ export class ApiCapacity extends ApiNotSent { } export type ApiFailure = Readonly<{ error: string; + code?: string; quota?: "api"; paused?: true; retryAfterMs?: number; @@ -84,7 +85,9 @@ export function apiFailure(status: number, value: unknown): ApiFailure { * `reason` may keep an exact, caller-allowed upstream refusal from that body. */ export async function readApiFailure( response: Response, - reason?: (body: unknown) => string | undefined, + reason?: ( + body: unknown, + ) => string | Pick | undefined, ): Promise { if (!response.body) return apiFailure(response.status, undefined); const reader = response.body.getReader(); @@ -109,7 +112,12 @@ export async function readApiFailure( } const failure = apiFailure(response.status, body); const allowed = failure.quota ? undefined : reason?.(body); - return allowed ? { ...failure, error: allowed } : failure; + return allowed + ? { + ...failure, + ...(typeof allowed === "string" ? { error: allowed } : allowed), + } + : failure; } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); @@ -248,7 +256,9 @@ export async function admittedApiRequest( request: () => Promise, signal?: AbortSignal, priority: Ticket["priority"] = "foreground", - reason?: (body: unknown) => string | undefined, + reason?: ( + body: unknown, + ) => string | Pick | undefined, ): Promise { return lane.run( async () => { diff --git a/src/features/relay/live.test.ts b/src/features/relay/live.test.ts index c03bb56ed5..09db957203 100644 --- a/src/features/relay/live.test.ts +++ b/src/features/relay/live.test.ts @@ -2619,3 +2619,33 @@ it("keeps large tails active during replay and fences removed channel lifetimes" h.owner.dispose(); expect(vi.getTimerCount()).toBe(0); }); + +it("fills retained groups as joined homes arrive in separate roster updates", async () => { + vi.useFakeTimers(); + const h = setup([]); + const ids: string[] = []; + for (let i = 0; i < 1150; i++) { + ids.push(`task-${i}`); + h.owner.update(ids, ids); + } + await h.first.auth(); + let cursor = 0; + const open = new Set(); + let maximum = 0; + while (cursor < h.first.sent.length) { + const frame = h.first.sent[cursor++]; + if (frame?.[0] === "CLOSE") open.delete(frame[1] as string); + if (frame?.[0] !== "REQ") continue; + open.add(frame[1] as string); + maximum = Math.max(maximum, open.size); + await h.first.receive(["EOSE", frame[1]]); + } + expect(maximum).toBeLessThan(125); + expect(h.callbacks.state.mock.lastCall?.[0].routes).toHaveLength(1152); + expect( + h.callbacks.state.mock.lastCall?.[0].routes.every( + (route) => route.status === "live", + ), + ).toBe(true); + h.owner.dispose(); +}); diff --git a/src/features/relay/live.ts b/src/features/relay/live.ts index 7dacd49371..4415e58008 100644 --- a/src/features/relay/live.ts +++ b/src/features/relay/live.ts @@ -380,19 +380,49 @@ export function subscribeRelayTraffic( (observer !== null ? 1 : 0) - (presenceAuthors.length ? 1 : 0) - (staged ? SETUP_CONCURRENCY : 0); + const interestSet = new Set(interests); + const retained = new Map(); + for (const route of routes.values()) { + if (route.historyParent || route.status === "limited") continue; + for (const id of scope(route)) { + if (interestSet.has(id) && (!route.channelIds || joinedSet.has(id))) + retained.set(id, route); + } + } const admitted = new Set(); + const kept = new Set(); let singles = 0, - grouped = 0; + grouped = 0, + free = 0; for (const id of ranked) { + const route = retained.get(id); const batchable = joinedSet.has(id) && (staged || !prioritySet.has(id)); + const newRoute = route && !kept.has(route) ? 1 : 0; + const nextFree = + free + + (newRoute && route?.channelIds && !route.previous + ? Math.max( + 0, + groupSize - + scope(route).filter( + (channel) => + interestSet.has(channel) && joinedSet.has(channel), + ).length, + ) + : 0); + const nextSingles = singles + (!route && !batchable ? 1 : 0); + const nextGrouped = grouped + (!route && batchable ? 1 : 0); const cost = - singles + - (batchable ? 0 : 1) + - Math.ceil((grouped + (batchable ? 1 : 0)) / groupSize); + kept.size + + newRoute + + nextSingles + + Math.ceil(Math.max(0, nextGrouped - nextFree) / groupSize); if (cost > capacity) continue; admitted.add(id); - if (batchable) grouped++; - else singles++; + if (route) kept.add(route); + singles = nextSingles; + grouped = nextGrouped; + free = nextFree; } const remaining = new Set(interests); const globals = new Set([ @@ -461,6 +491,24 @@ export function subscribeRelayTraffic( add(`channel:${id}`, [id]); else batch.push(id); } + for (const route of [...routes.values()]) { + if (!batch.length) break; + if ( + !route.channelIds || + route.historyParent || + route.status === "limited" || + route.status === "error" || + route.previous + ) + continue; + const extra = batch.splice( + 0, + Math.max(0, groupSize - route.channelIds.length), + ); + if (!extra.length) continue; + const next = replace(route, [...route.channelIds, ...extra]); + next.replayOffset = 0; + } for (let i = 0; i < batch.length; i += groupSize) { const route = add( `batch:${++serial}`, diff --git a/src/features/relay/managed-delivery.test.ts b/src/features/relay/managed-delivery.test.ts index 8d404e8d65..724607897c 100644 --- a/src/features/relay/managed-delivery.test.ts +++ b/src/features/relay/managed-delivery.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, assert, expect, it, vi } from "vitest"; import { createOutbox, PublishRejected, @@ -225,3 +225,104 @@ it("records a batch receipt against each original message", async () => { agentReceived: true, }); }); + +it("keeps local intent added during a journal read until its save completes", async () => { + vi.useFakeTimers(); + const storage = memory(); + let release: (() => void) | undefined; + let reading = false; + const h = setup({ + ...storage, + load: async (options) => { + const records = storage.load(); + if (options?.refresh && reading) + await new Promise((resolve) => { + release = resolve; + }); + return records; + }, + }); + await h.ready; + reading = true; + const refresh = h.delivery.get("missing"); + await ticks(); + const id = h.outbox.send(input, undefined, undefined, { recipient }); + reading = false; + release?.(); + await refresh; + await ticks(); + expect(await h.delivery.get(id)).toMatchObject({ phase: "sending" }); + expect(storage.load().some((item) => item.event.id === id)).toBe(true); +}); +it("retains completed artifact delivery when its echo precedes the publish response", async () => { + vi.useFakeTimers(); + let accept: (() => void) | undefined; + const h = setup( + memory(), + () => + new Promise((resolve) => { + accept = resolve; + }), + ); + const { eventId: id } = await h.delivery.submit({ + kind: 45010, + content: "{}", + tags: [ + ["d", crypto.randomUUID()], + ["h", recipient.channelId], + ["type", "task"], + ["op", "create"], + ["ar", "1"], + ], + }); + await h.managedOwner(true); + await ticks(); + const event = h.publish.mock.calls[0]?.[0]; + assert.exists(event); + h.observe([event]); + accept?.(); + await ticks(); + expect(await h.delivery.get(id)).toMatchObject({ + phase: "received", + relayAccepted: true, + }); + expect( + h.local.snapshot().find((item) => item.event.id === id)?.managed + ?.relayAccepted, + ).toBe(true); +}); +it("can retry and discard a definitive managed refusal", async () => { + vi.useFakeTimers(); + const h = setup(memory(), async () => { + throw new PublishRejected("artifact-head-conflict"); + }); + const { eventId: id } = await h.delivery.submit(input, recipient); + await h.managedOwner(true); + await ticks(1); + expect(await h.delivery.get(id)).toMatchObject({ phase: "failed" }); + await h.delivery.retry(id); + await ticks(1); + expect(h.publish).toHaveBeenCalledTimes(2); + await h.outbox.dismiss(id); + expect(await h.delivery.get(id)).toBeUndefined(); +}); +it("cancels queued managed attempts before it releases ownership", async () => { + vi.useFakeTimers(); + const h = setup( + memory(), + async (_event, signal) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }), + ); + for (let i = 0; i < 6; i++) + await h.delivery.submit({ ...input, content: `Prompt ${i}` }, recipient); + await h.managedOwner(true); + await ticks(); + expect(h.publish).toHaveBeenCalledTimes(3); + await h.managedOwner(false); + await ticks(60_000); + expect(h.publish).toHaveBeenCalledTimes(3); +}); diff --git a/src/features/relay/managed-delivery.ts b/src/features/relay/managed-delivery.ts index 9cbb6a6882..e474952b8e 100644 --- a/src/features/relay/managed-delivery.ts +++ b/src/features/relay/managed-delivery.ts @@ -30,6 +30,7 @@ type ManagedHost = { }; const hosts = new Map(); const owners = new Set(); +const locks = new Map(); const transitions = new Map>(); function transition(scope: string, work: () => Promise) { const next = (transitions.get(scope) ?? Promise.resolve()) @@ -41,6 +42,37 @@ function transition(scope: string, work: () => Promise) { export function registerManagedDelivery(scope: string, host: ManagedHost) { const prior = hosts.get(scope); hosts.set(scope, host); + if ( + typeof navigator !== "undefined" && + navigator.locks && + !locks.has(scope) + ) { + const lifetime = new AbortController(); + locks.set(scope, lifetime); + void navigator.locks + .request( + `buzz-outbox-owner:${scope}`, + { mode: "exclusive", signal: lifetime.signal }, + async () => { + await ownManagedDelivery(scope, true); + try { + await new Promise((resolve) => { + if (lifetime.signal.aborted) resolve(); + else + lifetime.signal.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + } finally { + await ownManagedDelivery(scope, false); + } + }, + ) + .catch((error) => { + if (!lifetime.signal.aborted) + console.error("Message outbox ownership fails", scope, error); + }); + } void transition(scope, async () => { await prior?.owner(false); if (hosts.get(scope) === host && owners.has(scope)) await host.owner(true); @@ -48,7 +80,11 @@ export function registerManagedDelivery(scope: string, host: ManagedHost) { console.error("Managed delivery owner fails", scope, error), ); return () => { - if (hosts.get(scope) === host) hosts.delete(scope); + if (hosts.get(scope) === host) { + hosts.delete(scope); + locks.get(scope)?.abort(); + locks.delete(scope); + } void transition(scope, () => host.owner(false)).catch((error) => console.error("Managed delivery release fails", scope, error), ); diff --git a/src/features/relay/managed-ownership.test.ts b/src/features/relay/managed-ownership.test.ts new file mode 100644 index 0000000000..e30e18ac29 --- /dev/null +++ b/src/features/relay/managed-ownership.test.ts @@ -0,0 +1,30 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { registerManagedDelivery } from "./managed-delivery"; + +afterEach(() => vi.unstubAllGlobals()); +it("keeps separate outbox owners until each retained session closes", async () => { + const requests: Promise[] = []; + vi.stubGlobal("navigator", { + locks: { + request: vi.fn((_name, _options, run) => { + const work = Promise.resolve().then(run); + requests.push(work); + return work; + }), + }, + }); + const a = { owner: vi.fn(async () => {}), receive: vi.fn(async () => {}) }; + const b = { owner: vi.fn(async () => {}), receive: vi.fn(async () => {}) }; + const stopA = registerManagedDelivery("community-a", a); + const stopB = registerManagedDelivery("community-b", b); + await vi.waitFor(() => { + expect(a.owner).toHaveBeenCalledWith(true); + expect(b.owner).toHaveBeenCalledWith(true); + }); + stopB(); + await vi.waitFor(() => expect(b.owner).toHaveBeenLastCalledWith(false)); + expect(a.owner).not.toHaveBeenCalledWith(false); + stopA(); + await Promise.all(requests); + expect(a.owner).toHaveBeenLastCalledWith(false); +}); diff --git a/src/features/relay/native.test.ts b/src/features/relay/native.test.ts index c97522fbaa..6d002c3eb0 100644 --- a/src/features/relay/native.test.ts +++ b/src/features/relay/native.test.ts @@ -1,3 +1,7 @@ +import { + ARTIFACT_SNAPSHOT_LIMITS, + ArtifactSnapshotError, +} from "./artifact-snapshot"; import { ReadStateTimestampRejected, READ_STATE_TIMESTAMP_REFUSAL, @@ -2082,3 +2086,21 @@ it("binds audience authority to the advertised community and rejects unknown ver (await connectNativeTransport(community)).audienceAuthorityCommunity, ).toBeUndefined(); }); + +it.each([ + "snapshot_limit", + "snapshot_reset", + "snapshot_invalid", + "snapshot_unsupported_scope", +])("preserves %s through native admission", async (code) => { + discovery = { artifacts: { snapshot: ARTIFACT_SNAPSHOT_LIMITS } }; + const transport = await connectNativeTransport(community); + respond = () => ({ status: 422, body: { error: "scope or capacity", code } }); + assert.exists(transport.artifactSnapshots); + const work = transport.artifactSnapshots.start( + { artifact: "current", kinds: [45010], "#type": ["task"] }, + new AbortController().signal, + ); + await expect(work).rejects.toBeInstanceOf(ArtifactSnapshotError); + await expect(work).rejects.toMatchObject({ code }); +}); diff --git a/src/features/relay/native.ts b/src/features/relay/native.ts index b872156bef..be8dd78b73 100644 --- a/src/features/relay/native.ts +++ b/src/features/relay/native.ts @@ -1,6 +1,7 @@ import { audienceAuthorityCommunity } from "../sessions/audience-authority"; import { artifactSnapshotCapability, + artifactSnapshotFailure, createArtifactSnapshotReader, } from "./artifact-snapshot"; import { Channel, convertFileSrc, invoke } from "@tauri-apps/api/core"; @@ -408,6 +409,7 @@ export async function connectNativeTransport( () => nativeRelayRequest(origin, "/query", [filter]), signal, "background", + artifactSnapshotFailure, ), ), } diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts index e2560afc84..ec552864af 100644 --- a/src/features/relay/outbox.ts +++ b/src/features/relay/outbox.ts @@ -245,6 +245,7 @@ export function createOutbox( let confirmedKeep: ((event: EventData) => boolean) | undefined; let storageError: string | undefined; let hydrated = false; + const unsavedManaged = new Set(); let managedOwner = false; let managedTimer: ReturnType | undefined; let durable = Promise.resolve(); @@ -318,6 +319,7 @@ export function createOutbox( if (bytes > 2 * 1024 * 1024) throw new Error("Outbox storage is full"); await storage.save(records); + for (const item of records) unsavedManaged.delete(item.event.id); if (action === "receipt") { const current = find(id); if (current?.managed) @@ -395,6 +397,9 @@ export function createOutbox( .then(() => ready) .then(async () => { if (closed) return; + const beforeLoad = new Map( + snapshot.map((item) => [item.event.id, item]), + ); const records = await storage.load({ refresh: true }); validateSaved(records); const incoming = new Map( @@ -426,7 +431,13 @@ export function createOutbox( } } for (const [id, item] of next) - if (item.managed && !incoming.has(id) && !attempts.has(id)) + if ( + item.managed && + !incoming.has(id) && + !attempts.has(id) && + !unsavedManaged.has(id) && + beforeLoad.get(id) === item + ) next.delete(id); snapshot = Object.freeze([...next.values()]); if ( @@ -645,7 +656,7 @@ export function createOutbox( async function deliverAttempt(id: string) { const attempt = attempts.get(id); if (closed || !attempt || attempt.controller || inflight() >= 3) return; - if (!find(id)) return; + if (!find(id) || (find(id)?.managed && !managedOwner)) return; if (Date.now() >= attempt.expiresAt) { expire(id); return; @@ -877,7 +888,7 @@ export function createOutbox( async acknowledge(id: string) { await outbox.ready(); const item = visible.find((item) => item.event.id === id); - if (item?.managed && !managedDone(item.managed)) + if (item?.managed && !managedDone(item.managed) && !item.refused) return Promise.reject( new Error("Resolve managed delivery before removing this message"), ); @@ -1019,6 +1030,7 @@ export function createOutbox( ) as unknown as string[][], id: getEventHash(template), }); + if (managed) unsavedManaged.add(event.id); if (active) admissionGates.set(event.id, active); captureSend(event); profiling.measure("send.local", event.id, () => { @@ -1061,6 +1073,8 @@ export function createOutbox( if (item?.managed) { replace({ ...item, + refused: undefined, + error: undefined, managed: { ...item.managed, round: item.managed.round + 1, @@ -1113,7 +1127,7 @@ export function createOutbox( if (pending) return pending; if (closed || attempts.has(id)) return Promise.resolve(); const item = visible.find((item) => item.event.id === id); - if (item?.managed && !managedDone(item.managed)) + if (item?.managed && !managedDone(item.managed) && !item.refused) return Promise.reject( new Error("Resolve managed delivery before removing this message"), ); @@ -1178,7 +1192,11 @@ export function createOutbox( wakeManaged(); } else for (const [id, attempt] of attempts) - if (find(id)?.managed) attempt.controller?.abort(abortError()); + if (find(id)?.managed) { + clearTimeout(attempt.timer); + attempts.delete(id); + attempt.controller?.abort(abortError()); + } if (!owned) { await Promise.allSettled( [...deliveries] @@ -1283,6 +1301,13 @@ export function createOutbox( }), ); } + for (const item of snapshot) + if ( + byId.has(item.event.id) && + item.managed && + managedDone(item.managed) + ) + completed.set(item.event.id, item); snapshot = Object.freeze( snapshot.filter( (item) => diff --git a/src/features/relay/row-identity.ts b/src/features/relay/row-identity.ts index da5bd3f8b6..586b126fbe 100644 --- a/src/features/relay/row-identity.ts +++ b/src/features/relay/row-identity.ts @@ -20,6 +20,14 @@ function sameRow(left: ChannelMessage, right: ChannelMessage) { left.channelId === right.channelId && left.delivery === right.delivery && left.deliveryError === right.deliveryError && + left.managedDelivery?.operationId === right.managedDelivery?.operationId && + left.managedDelivery?.eventId === right.managedDelivery?.eventId && + left.managedDelivery?.phase === right.managedDelivery?.phase && + left.managedDelivery?.relayAccepted === + right.managedDelivery?.relayAccepted && + left.managedDelivery?.agentReceived === + right.managedDelivery?.agentReceived && + left.managedDelivery?.error === right.managedDelivery?.error && left.authorId === right.authorId && left.signerId === right.signerId && left.workflowOwnerId === right.workflowOwnerId && diff --git a/src/features/relay/thread-delivery.test.ts b/src/features/relay/thread-delivery.test.ts new file mode 100644 index 0000000000..9dc5ea5c83 --- /dev/null +++ b/src/features/relay/thread-delivery.test.ts @@ -0,0 +1,82 @@ +import { assert, expect, it } from "vitest"; +import { createThreadView } from "./threads"; +import type { OutgoingEvent } from "./outbox"; +import { keypair, message } from "./testing"; + +it("projects exact receipt changes into an existing thread row", async () => { + const key = keypair(); + const root = message(key, "channel", "Managed prompt", 1); + let operations: readonly OutgoingEvent[] = [ + { + event: root, + signed: root, + delivery: "accepted", + managed: { + version: 1, + recipient: { channelId: "channel", agentId: key.pubkey }, + round: 0, + attempts: 1, + relayAccepted: true, + agentReceived: false, + nextAttemptAt: 0, + receiptDeadline: 30000, + }, + }, + ]; + const listeners = new Set<() => void>(); + const owned = createThreadView({ + channelId: "channel", + messageId: root.id, + relayAuthor: key.pubkey, + seed: root, + reader: { read: async () => [root] }, + local: { + snapshot: () => operations, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }, + canAccess: () => true, + visible: (events) => events, + notify: (listener) => listener(), + }); + const view = owned.view; + try { + await view.refresh(); + const before = view.snapshot().root; + expect(before?.managedDelivery).toMatchObject({ + phase: "sending", + relayAccepted: true, + agentReceived: false, + }); + owned.changed(); + expect(view.snapshot().root).toBe(before); + operations = operations.map((item) => { + assert.exists(item.managed); + return { + ...item, + managed: { ...item.managed, error: "receipt" as const }, + }; + }); + owned.changed(); + expect(view.snapshot().root?.managedDelivery).toMatchObject({ + phase: "failed", + error: "Could not confirm delivery", + }); + operations = operations.map((item) => { + assert.exists(item.managed); + return { ...item, managed: { ...item.managed, agentReceived: true } }; + }); + owned.changed(); + expect(view.snapshot().root).not.toBe(before); + expect(view.snapshot().root?.managedDelivery).toMatchObject({ + phase: "received", + agentReceived: true, + }); + } finally { + view.dispose(); + } +}); diff --git a/src/features/relay/threads.ts b/src/features/relay/threads.ts index f4e3fb6ef6..889be90a50 100644 --- a/src/features/relay/threads.ts +++ b/src/features/relay/threads.ts @@ -14,6 +14,7 @@ import { byteSize } from "./budget"; import { foldMessages } from "./fold"; import { compareMessages, MessageClock } from "./message-order"; import { shareMessageRows } from "./row-identity"; +import { deliveryState } from "./managed-delivery"; const AUX = new Set([5, 7, 9005, 40003, 39005, 39006]); const PAGE_SIZE = 50; @@ -223,6 +224,9 @@ export function createThreadView({ ...row, delivery: item.delivery, deliveryError: item.error, + managedDelivery: item.managed + ? deliveryState(row.id, item.managed) + : undefined, }) : row; }), diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index ba8ee56cf0..fb339605b0 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -1240,7 +1240,7 @@ export const admitSignedRequest = ( request: () => Promise, signal?: AbortSignal, priority: "foreground" | "background" = "foreground", - reason?: (body: unknown) => string | undefined, + reason?: Parameters[4], ) => admittedApiRequest( signedAdmissions(relayOrigin(origin), viewer).api, diff --git a/tests/fixtures/managed-delivery.ts b/tests/fixtures/managed-delivery.ts index 843e7380de..a8b0924404 100644 --- a/tests/fixtures/managed-delivery.ts +++ b/tests/fixtures/managed-delivery.ts @@ -4,15 +4,19 @@ import { createArtifactStore } from "../../src/features/artifact-storage/store"; import { finalizeEvent, getPublicKey } from "nostr-tools"; import { createOutbox } from "../../src/features/relay/outbox"; import { browserOutboxStorage } from "../../src/features/relay/outbox-storage"; -import { mergeManagedDelivery } from "../../src/features/relay/managed-delivery"; +import { + mergeManagedDelivery, + registerManagedDelivery, +} from "../../src/features/relay/managed-delivery"; const secret = new Uint8Array(32).fill(7); const viewer = getPublicKey(secret); const recipient = { channelId: "task", agentId: "a".repeat(64) }; const publications: string[] = []; let owner: ReturnType; -let release: (() => void) | undefined; -let lock: Promise | undefined; +let stopRegistration: (() => void) | undefined; +let ownership: Promise; +let release: Promise; const open = async (scope: string) => { owner = createOutbox( viewer, @@ -26,21 +30,25 @@ const open = async (scope: string) => { browserOutboxStorage(scope), ); await owner.ready; -}; -const own = async (scope: string) => { let acquired!: () => void; - const ready = new Promise((resolve) => { + let released!: () => void; + ownership = new Promise((resolve) => { acquired = resolve; }); - lock = navigator.locks.request(`delivery-check:${scope}`, async () => { - await owner.managedOwner(true); - acquired(); - await new Promise((resolve) => { - release = resolve; - }); - await owner.managedOwner(false); + release = new Promise((resolve) => { + released = resolve; + }); + stopRegistration = registerManagedDelivery(scope, { + owner: async (owned) => { + await owner.managedOwner(owned); + if (owned) acquired(); + else released(); + }, + receive: owner.receiveManaged, }); - await ready; +}; +const own = async (_scope: string) => { + await ownership; }; const captures: string[] = []; async function openCollection(scope: string, name: string) { @@ -67,7 +75,7 @@ async function openCollection(scope: string, name: string) { snapshot: () => ({ status: "connected" }), subscribe: () => () => {}, }, - channels: { subscribeList: () => () => {} }, + channels: { list: () => ({ channels: [] }), subscribeList: () => () => {} }, subscribeLive: () => () => {}, subscribeArtifactUpdates: () => () => {}, artifactSnapshots: { @@ -143,8 +151,8 @@ Object.assign(window, { owner.receiveManaged(recipient.channelId, recipient.agentId, [id]), retry: (id: string) => owner.delivery.retry(id), release: async () => { - release?.(); - await lock; + stopRegistration?.(); + await release; }, }, }); From ad1b12d15eacfcfd213456c7f4eda69fbf2f0a53 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 14:02:04 -0700 Subject: [PATCH 06/30] Preserve artifact envelopes during managed publication Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/artifact-sync-delivery.md | 2 +- src/features/relay/managed-delivery.test.ts | 34 +++++++++++++++++++++ src/features/relay/outbox.ts | 20 ++++++++++-- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/docs/artifact-sync-delivery.md b/docs/artifact-sync-delivery.md index ee0bf1b670..94230fc214 100644 --- a/docs/artifact-sync-delivery.md +++ b/docs/artifact-sync-delivery.md @@ -24,7 +24,7 @@ The relay permits 1,024 subscriptions, ten filters per request, and 2,048 explic `session.messagesForRecipient(agentPubkey)` uses the existing message validation and composer path. `EmbeddedThread.managedRecipient` selects this path for a task conversation. Ordinary chat keeps its existing behavior. `session.delivery.submit(input)` admits kind-45010 artifact operations. `delivery.get(id)` is asynchronous. `delivery.subscribe(listener)` reports durable state changes. -The outbox saves intent and the signed event before publication. It retains the same event ID across uncertain retries. The owner makes at most three attempts before an error. A definitive relay refusal stops automatic retries. An explicit retry clears the refusal. A settled refusal can be discarded. Acceptance and exact agent receipt are separate saved facts. Stale writes from another window cannot clear either fact. +The outbox saves intent and the signed event before publication. Artifact events keep their supplied envelope tags. Identical submissions with the same signed event ID share one operation. It retains the same event ID across uncertain retries. The owner makes at most three attempts before an error. A definitive relay refusal stops automatic retries. An explicit retry clears the refusal. A settled refusal can be discarded. Acceptance and exact agent receipt are separate saved facts. Stale writes from another window cannot clear either fact. `AgentRun.received(messageIds)` reports only the exact original message IDs. The host binds the channel and agent identity. The plugin calls it at a verified harness receipt boundary. Process start, typing, and an unrelated reply do not prove receipt. The host saves receipt before it clears the opacity pulse. A save failure keeps accepted evidence and exposes an unconfirmed state. diff --git a/src/features/relay/managed-delivery.test.ts b/src/features/relay/managed-delivery.test.ts index 724607897c..f473f14e63 100644 --- a/src/features/relay/managed-delivery.test.ts +++ b/src/features/relay/managed-delivery.test.ts @@ -326,3 +326,37 @@ it("cancels queued managed attempts before it releases ownership", async () => { await ticks(60_000); expect(h.publish).toHaveBeenCalledTimes(3); }); + +it("publishes a delete with only its allowed artifact envelope and coalesces identical intent", async () => { + vi.useFakeTimers(); + const allowed = new Set(["ar", "d", "h", "type", "op", "prev"]); + const h = setup(memory(), async (event) => { + if (event.tags.some(([key]) => !key || !allowed.has(key))) + throw new PublishRejected( + "delete allows only envelope and verified auth tags", + ); + }); + const artifact = { + kind: 45010, + content: "", + tags: [ + ["ar", "1"], + ["d", crypto.randomUUID()], + ["h", recipient.channelId], + ["type", "test.task"], + ["op", "delete"], + ["prev", "b".repeat(64)], + ], + }; + const first = await h.delivery.submit(artifact); + const second = await h.delivery.submit(artifact); + expect(first).toEqual(second); + expect(h.outbox.snapshot()).toHaveLength(1); + await h.managedOwner(true); + await ticks(1); + expect(await h.delivery.get(first.operationId)).toMatchObject({ + phase: "received", + relayAccepted: true, + }); + expect(h.publish).toHaveBeenCalledOnce(); +}); diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts index ec552864af..56e1fe4d63 100644 --- a/src/features/relay/outbox.ts +++ b/src/features/relay/outbox.ts @@ -1019,8 +1019,12 @@ export function createOutbox( created_at: createdAt, tags: [ ...input.tags.map((tag) => [...tag]), - ["client-id", crypto.randomUUID()], - ...(channelId ? [["ms", String(ms % 1000)]] : []), + ...(input.kind === 45010 + ? [] + : [ + ["client-id", crypto.randomUUID()], + ...(channelId ? [["ms", String(ms % 1000)]] : []), + ]), ], }; const event = Object.freeze({ @@ -1030,6 +1034,18 @@ export function createOutbox( ) as unknown as string[][], id: getEventHash(template), }); + if (input.kind === 45010) { + const prior = visible.find((item) => item.event.id === event.id); + if (prior) { + if ( + !!prior.managed !== !!managed || + JSON.stringify(prior.managed?.recipient) !== + JSON.stringify(managed?.recipient) + ) + throw new Error("Artifact delivery recipient changed"); + return event.id; + } + } if (managed) unsavedManaged.add(event.id); if (active) admissionGates.set(event.id, active); captureSend(event); From 675cfbb943348a57e5101e21362011a586348d24 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 14:08:49 -0700 Subject: [PATCH 07/30] Count live control bytes once per input chunk Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- browser-host/relay-broker-api.test.mjs | 2 +- browser-host/relay-broker.mjs | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs index 1713ff1f31..337e2b20f6 100644 --- a/browser-host/relay-broker-api.test.mjs +++ b/browser-host/relay-broker-api.test.mjs @@ -1231,7 +1231,7 @@ test.each([ ], ["/api/relay/archive", 4096, "bytes", "Archive request too large"], ["/api/relay/stream-presence", 256, "bytes", "Live control too large"], - ["/api/relay/stream", 300000, "bytes", "Live interests too large"], + ["/api/relay/stream", 32 * 1024 * 1024, "bytes", "Live interests too large"], ["/api/relay/query", 65536, "chars", "Filter body too large"], ["/api/relay/presence-snapshot", 20 * 1024, "bytes", "Filter body too large"], ])( diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs index 48b8d1c21e..144a9d1f11 100644 --- a/browser-host/relay-broker.mjs +++ b/browser-host/relay-broker.mjs @@ -1483,12 +1483,13 @@ export function relayBrokerPlugin({ const prioritizing = route === "/api/relay/stream-priority"; const observing = route === "/api/relay/stream-observer"; const updating = route === "/api/relay/stream-interests"; - let raw = ""; + let raw = "", + bytes = 0; req.setEncoding("utf8"); for await (const part of req) { - raw += part; + bytes += Buffer.byteLength(part); if ( - Buffer.byteLength(raw) > + bytes > (updating ? 32 * 1024 * 1024 : watchingPresence @@ -1498,6 +1499,7 @@ export function relayBrokerPlugin({ : 256) ) return json(res, 413, { error: "Live control too large" }); + raw += part; } let streamId, presenceAuthors, @@ -1603,12 +1605,14 @@ export function relayBrokerPlugin({ return json(res, 200, { accepted: true }); } if (route === "/api/relay/stream" && req.method === "POST") { - let raw = ""; + let raw = "", + bytes = 0; req.setEncoding("utf8"); for await (const part of req) { - raw += part; - if (Buffer.byteLength(raw) > 32 * 1024 * 1024) + bytes += Buffer.byteLength(part); + if (bytes > 32 * 1024 * 1024) return json(res, 413, { error: "Live interests too large" }); + raw += part; } let channels, joined, From 92d61c8ea9569be383ffa7c5aee537c492e30ef4 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 14:16:08 -0700 Subject: [PATCH 08/30] Preserve receipt deadlines across stale journal reads Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/artifact-sync-delivery.md | 6 +- src/features/relay/managed-delivery.test.ts | 84 +++++++++++++++++++++ src/features/relay/managed-delivery.ts | 25 ++++-- tests/browser/managed-delivery.spec.mjs | 6 +- 4 files changed, 110 insertions(+), 11 deletions(-) diff --git a/docs/artifact-sync-delivery.md b/docs/artifact-sync-delivery.md index 94230fc214..633ca36a1d 100644 --- a/docs/artifact-sync-delivery.md +++ b/docs/artifact-sync-delivery.md @@ -24,7 +24,7 @@ The relay permits 1,024 subscriptions, ten filters per request, and 2,048 explic `session.messagesForRecipient(agentPubkey)` uses the existing message validation and composer path. `EmbeddedThread.managedRecipient` selects this path for a task conversation. Ordinary chat keeps its existing behavior. `session.delivery.submit(input)` admits kind-45010 artifact operations. `delivery.get(id)` is asynchronous. `delivery.subscribe(listener)` reports durable state changes. -The outbox saves intent and the signed event before publication. Artifact events keep their supplied envelope tags. Identical submissions with the same signed event ID share one operation. It retains the same event ID across uncertain retries. The owner makes at most three attempts before an error. A definitive relay refusal stops automatic retries. An explicit retry clears the refusal. A settled refusal can be discarded. Acceptance and exact agent receipt are separate saved facts. Stale writes from another window cannot clear either fact. +The outbox saves intent and the signed event before publication. Artifact events keep their supplied envelope tags. Identical submissions with the same signed event ID share one operation. It retains the same event ID across uncertain retries. The owner makes at most three attempts before an error. A definitive relay refusal stops automatic retries. An explicit retry clears the refusal. A settled refusal can be discarded. Acceptance and exact agent receipt are separate saved facts. Stale writes from another window cannot clear either fact. Within one retry round, merges preserve receipt deadlines, retry delays, and errors. A new round permits an explicit retry. `AgentRun.received(messageIds)` reports only the exact original message IDs. The host binds the channel and agent identity. The plugin calls it at a verified harness receipt boundary. Process start, typing, and an unrelated reply do not prove receipt. The host saves receipt before it clears the opacity pulse. A save failure keeps accepted evidence and exposes an unconfirmed state. @@ -36,4 +36,6 @@ Exact receipt is local in v0. A crash before receipt persistence, or receipt on Focused tests cover publication, exact receipt, restart, stale writes, lost confirmations, refusal, expiry, and completion fences. Browser checks use real IndexedDB, BroadcastChannel, and Web Locks in Chromium and WebKit. The socket scale check covers 10,000 active homes plus 50,000 archive homes and a normal chat. It verifies grouping and request bounds with a test socket. It does not measure the deployed relay's startup cost. -Real snapshot verification requires the relay prerequisite and this client together. The current community does not advertise snapshot support. Browser fixtures do not prove native signing or relay access. Native runtime and theme checks remain required before readiness. +A separate macOS bundle uses the saved debug identity and the installed Nexus plugin. Native checks verify restart, indexed reads, explicit false preferences, committed notifications, signed artifact create/delete, and saved accepted revisions. DOM checks verify the pulse in both themes, the receipt timeout, and focus and activation of `Check again`. The accepted event ID stays the same. These checks do not include screenshots, width changes, or enlarged text. + +Real snapshot verification requires the relay prerequisite and this client together. The live community does not advertise snapshot support. Exact receipt from a real harness requires the Nexus adoption layer. Unit and browser checks verify the bound callback and saved receipt state. diff --git a/src/features/relay/managed-delivery.test.ts b/src/features/relay/managed-delivery.test.ts index f473f14e63..17bc4be524 100644 --- a/src/features/relay/managed-delivery.test.ts +++ b/src/features/relay/managed-delivery.test.ts @@ -360,3 +360,87 @@ it("publishes a delete with only its allowed artifact envelope and coalesces ide }); expect(h.publish).toHaveBeenCalledOnce(); }); + +it("keeps the receipt deadline when a stale journal read races relay acceptance", async () => { + vi.useFakeTimers(); + const storage = memory(); + let slow = false; + let release: (() => void) | undefined; + let accept: (() => void) | undefined; + const h = setup( + { + ...storage, + load: async (options) => { + const records = storage.load(); + if (options?.refresh && slow) + await new Promise((resolve) => { + release = resolve; + }); + return records; + }, + }, + () => + new Promise((resolve) => { + accept = resolve; + }), + ); + const { eventId: id } = await h.delivery.submit(input, recipient); + await h.managedOwner(true); + await ticks(); + slow = true; + const stale = h.delivery.get(id); + await ticks(); + accept?.(); + await ticks(); + expect( + h.local.snapshot().find((item) => item.event.id === id)?.managed + ?.relayAccepted, + ).toBe(true); + slow = false; + release?.(); + await stale; + await ticks(1); + expect(await h.delivery.get(id)).toMatchObject({ + phase: "sending", + relayAccepted: true, + }); + await ticks(MANAGED_RECEIPT_MS - 2); + expect(await h.delivery.get(id)).toMatchObject({ phase: "sending" }); + await ticks(2); + expect(await h.delivery.get(id)).toMatchObject({ + phase: "failed", + error: "Could not confirm delivery", + }); +}); + +it("keeps a receipt error when a stale journal record has no error", async () => { + vi.useFakeTimers(); + const storage = memory(); + const h = setup(storage); + const { eventId: id } = await h.delivery.submit(input, recipient); + await h.managedOwner(true); + await ticks(1); + const stale = storage.load(); + await ticks(MANAGED_RECEIPT_MS); + expect(await h.delivery.get(id)).toMatchObject({ phase: "failed" }); + storage.save(stale); + expect(await h.delivery.get(id)).toMatchObject({ phase: "failed" }); +}); +it("keeps retry backoff when a stale journal record has no attempt delay", async () => { + vi.useFakeTimers(); + const storage = memory(); + const h = setup(storage, async () => { + throw new Error("Confirmation lost"); + }); + const { eventId: id } = await h.delivery.submit(input, recipient); + const stale = storage.load(); + await h.managedOwner(true); + await ticks(1); + expect(h.publish).toHaveBeenCalledOnce(); + storage.save(stale); + await h.delivery.get(id); + await ticks(1); + expect(h.publish).toHaveBeenCalledOnce(); + await ticks(1000); + expect(h.publish).toHaveBeenCalledTimes(2); +}); diff --git a/src/features/relay/managed-delivery.ts b/src/features/relay/managed-delivery.ts index e474952b8e..b6b0029591 100644 --- a/src/features/relay/managed-delivery.ts +++ b/src/features/relay/managed-delivery.ts @@ -138,22 +138,31 @@ export function mergeManagedDelivery( if (JSON.stringify(old.recipient) !== JSON.stringify(next.recipient)) throw new Error("Managed recipient changed"); const current = old.round > next.round ? old : next; + const sameRound = old.round === next.round; + const error = sameRound + ? (["receipt-save", "receipt", "send"] as const).find( + (error) => old.error === error || next.error === error, + ) + : current.error; const relayAccepted = old.relayAccepted || next.relayAccepted; const agentReceived = old.agentReceived || next.agentReceived; return { ...current, relayAccepted, agentReceived, - attempts: - old.round === next.round - ? Math.max(old.attempts, next.attempts) - : current.attempts, - ...(old.round === next.round && old.error === "receipt-save" - ? { error: old.error } - : {}), + attempts: sameRound + ? Math.max(old.attempts, next.attempts) + : current.attempts, + nextAttemptAt: sameRound + ? Math.max(old.nextAttemptAt, next.nextAttemptAt) + : current.nextAttemptAt, + receiptDeadline: sameRound + ? Math.max(old.receiptDeadline, next.receiptDeadline) + : current.receiptDeadline, + error, ...(agentReceived || (!current.recipient && relayAccepted) ? { error: undefined } - : relayAccepted && current.error === "send" + : relayAccepted && error === "send" ? { error: undefined } : {}), }; diff --git a/tests/browser/managed-delivery.spec.mjs b/tests/browser/managed-delivery.spec.mjs index b4fb84315c..4b6aeb0b9f 100644 --- a/tests/browser/managed-delivery.spec.mjs +++ b/tests/browser/managed-delivery.spec.mjs @@ -51,7 +51,11 @@ test("transfers ownership on window close and never republishes accepted work", .poll(() => second.evaluate((id) => window.deliveryTest.get(id), operation.eventId), ) - .toMatchObject({ relayAccepted: true, agentReceived: false }); + .toMatchObject({ + phase: "sending", + relayAccepted: true, + agentReceived: false, + }); const transfer = second.evaluate( (scope) => window.deliveryTest.own(scope), scope, From b938440e33b184f294b41bc2d88918900a53200d Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 14:59:25 -0700 Subject: [PATCH 09/30] Simplify confirmed artifact snapshots Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/local-artifact-storage.md | 175 +++--- .../artifact-storage/revisions.test.ts | 44 -- src/features/artifact-storage/revisions.ts | 98 --- src/features/artifact-storage/service.test.ts | 5 +- src/features/artifact-storage/service.ts | 4 + src/features/artifact-storage/store.ts | 570 +++++++++--------- src/features/artifact-storage/types.ts | 17 +- tests/browser/artifact-storage.spec.mjs | 408 ++++++++----- 8 files changed, 652 insertions(+), 669 deletions(-) delete mode 100644 src/features/artifact-storage/revisions.test.ts delete mode 100644 src/features/artifact-storage/revisions.ts diff --git a/docs/local-artifact-storage.md b/docs/local-artifact-storage.md index f69b1760a9..011523b2c9 100644 --- a/docs/local-artifact-storage.md +++ b/docs/local-artifact-storage.md @@ -1,10 +1,8 @@ # Local artifact storage -`ctx.artifactStorage` gives plugins durable local reads. It uses IndexedDB in the -host window. It does not request relay data. Shared writes use the host delivery -service. The bounded channel cache remains separate. - -A plugin opens a collection for its current identity and community: +`ctx.artifactStorage` gives plugins durable local reads through IndexedDB. The +bounded channel cache stays separate. The host delivery service owns shared edits. +The plugin owns task fields, parent rules, search tokens, and device preferences. ```ts const tasks = ctx.artifactStorage.open({ @@ -22,82 +20,95 @@ const next = page.next : undefined; ``` -The host derives the plugin identity from Cordis. The saved community selection -supplies the viewer and relay origin, including while the network is unavailable. -A collection captures that scope. Its read-only `scope` descriptor supplies -`viewer`, normalized `relay`, and the legacy `partition` string for migration. -A handle rejects calls after the identity or relay changes. -Close the handle when its view ends. Subscribe before the first read. Notifications -follow a committed transaction and reach other windows through BroadcastChannel. -They carry no saved content. Each window reads IndexedDB again. - -The projector defines index names, exact string values, and lexical order keys. -Several entries can index the same record. Pages use an exclusive `(order, id)` -cursor. Each page reads only its selected records, with a maximum of 1,000 rows. -Pages are independent transactions. A change between pages can move a row; restart -the query after a notification. All windows must use the same collection type, -version, and projector. Projection behavior, index definitions, and query semantics -tied to saved state must stay fixed within a version. Changes require a version -increase. The host checks the declared version in each read or write transaction. -It cannot detect undeclared changes to a projector. Increase the version to rebuild -saved indexes in one transaction. A failed rebuild preserves the saved version and -data. An incompatible type fails explicitly. An older handle then rejects further -reads and writes. If opening the database -fails, close the failed handle and open another after the cause clears. - -`get(id)` returns a saved artifact and its separate preference. A record contains -the current revision, conflicting heads, unresolved ancestry, known availability, -and an optional local snapshot. A cached body is not proof of current access. -A missing parent remains unresolved. An older revision cannot replace a known -descendant. Conflicting heads preserve the last usable view. A descendant of all -heads resolves the conflict. The host retains ancestry edges for comparison. - -`preferences.update(id, change)` reads the latest value, calls the synchronous -callback, saves its result, and rebuilds that row's indexes in one transaction. -Preferences remain in a separate store. Values such as `false` and `null` remain +## Scope and indexes + +Cordis supplies the plugin identity. The saved community selection supplies the +viewer and normalized relay, including while offline. The read-only `scope` +contains `viewer`, `relay`, and the legacy `partition` string for migration. +A handle closes when its identity or community changes. Close it when its view ends. + +Records and preferences are isolated by viewer, relay, plugin, and collection. +Each collection admits one artifact type and one fixed authoritative query. Sync +binds that query through its opaque `source` string. A different source fails. +Change the collection version when projection behavior, index definitions, or +saved query semantics change. The host checks the declared version in each read +and write transaction. It cannot detect undeclared changes to projector behavior. +An incompatible type fails explicitly. An index rebuild commits atomically; a +failure preserves the saved version and data. Old handles then reject operations. + +The projector defines exact string index values and lexical order keys. Several +entries can index a record. Pages use an exclusive `(order, id)` cursor and read +only selected records, with a maximum of 1,000 rows. Pages are independent +transactions. Restart a query after a notification when rows can move between pages. +Notifications follow commit and reach other windows through BroadcastChannel. +They contain no saved content. Subscribe before the first read, then read again. + +## Device data + +`preferences.update(id, change)` reads the latest preference, calls the synchronous +callback, saves the result, and rebuilds that row's indexes in one transaction. +Preferences stay in a separate store. Values such as `false` and `null` remain explicit. The callback must return JSON and must not start asynchronous work. -`importBatch` supports migration of device data. It inserts missing local snapshots -and preferences. It does not invent a shared revision or replace later edits. The -plugin owns the snapshot fields, including origin. Its optional completion marker -commits with the last batch. `imported(marker)` confirms that commit. Keep the -source data until this check succeeds. - -The host collection adds these operations for sync: - -- `applyBatch` saves verified revisions, availability changes, and a checkpoint - in one strict transaction. The checkpoint compares its expected value first. -- `checkpoint` reads the saved checkpoint for owner transfer. -- `beginSnapshot(source)` captures a local commit sequence and a new generation. -- `applyBatch({ source, snapshot, ... })` marks received IDs seen in that generation. - Its `seen` entries can reference revisions already saved on this device. - Snapshot authority can select a current head when history is missing. It keeps - unresolved ancestry and competing heads. A capture sequence fences later writes. - Superseded heads cannot become current through a late snapshot. -- Availability updates compare `expectedRevision` with the saved current revision. - Snapshot availability also uses the capture sequence. Validated current revisions - can update the saved channel home. -- `completeSnapshot(snapshot, checkpoint)` reconciles absent records for only that - source, then commits the checkpoint. Live and confirmed writes after capture - remain available. A record remains available while another source still contains - it. Content, local snapshots, and preferences remain saved. - -`source` is an exact authoritative query scope supplied by sync. The sync owner -must validate server completion and current access before it calls -`completeSnapshot`. Pass the live interval's AbortSignal to cancel the local -transaction if continuity fails or the owner closes. A failed or invalidated snapshot must not complete. A new -snapshot for the same source invalidates the old generation. Checkpoints are -opaque strings; this store does not require a durable server cursor. - -`ArtifactStorageService.onOpen` lets host sync attach to a collection and its type -descriptor and a lifetime AbortSignal. Closing the handle or changing identity or -community aborts that signal. Sync uses one Web Lock per -`{viewer, relay, owner, collectionName}`. Only a window with the real collection -definition can own that work. Checkpoints and snapshot completion stay within that -collection and its exact source query. The owner cancels work before it releases -the lock. A late response cannot commit after the owner closes. Outbox ownership -uses its separate host service. Records, preferences, and source membership are -isolated by collection. Leaving a community purges only its saved partition. -Plugins receive the read, preference, and migration contract. They do -not receive `applyBatch` or transport access. The task plugin owns task fields, -parent rules, search tokens, and the meaning of device preferences. +`importBatch` inserts absent local snapshots and preferences. Retries preserve +later edits. It does not invent a shared revision. A completion marker commits +with the last batch. Keep migration source data until `imported(marker)` succeeds. + +## Confirmed shared state + +The relay supplies one authoritative current revision per artifact. The store +saves that result without an ancestry graph, inferred conflicts, revision homes, +or timestamp ordering. `get(id)` returns the current revision, availability, +optional plugin-owned local snapshot, and separate preference. Cached content +alone does not establish current access. Publication conflicts are delivery errors; +the delivery view retains the user's attempted edit for correction. + +`ArtifactStorageService.onOpen` supplies the host with the real collection +definition, its handle, scope, and lifetime AbortSignal. Sync uses one Web Lock per +`{viewer, relay, owner, collectionName}`. Only a window with that definition can +own its sync. Outbox ownership stays separate. The owner cancels its work before +it releases the lock. The collection also aborts its pending transactions on close. + +The host-only contract provides: + +- `beginSnapshot(source)` binds the fixed query and starts a fresh local generation. + A newer generation retires the previous one. +- `applyBatch({source, snapshot, records, seen, availability, checkpoint}, signal)` + commits a validated page atomically. It requires the active generation. `seen` + reuses a body only when its exact current revision ID is saved. Availability + checks the expected revision. An optional checkpoint compares its expected value. +- `completeSnapshot(snapshot, checkpoint, signal)` runs only after sync validates + server completion. In one transaction, it marks absent shared records + unavailable, commits the checkpoint, and clears the refresh error. Saved bodies, + local snapshots, and preferences remain. Device-only records stay device-only. +- `setRefreshError(snapshot, message, signal)` saves an error for the active + generation and publishes a notification. Plugins read `refreshError()` and show + a nonempty error. A new attempt retains that error until successful completion. +- `checkpoint()` reads the saved opaque checkpoint. + +No raw live event, accepted edit, or per-artifact write can change confirmed +shared state. Live events request an automatic background refresh. Reconnect also +starts a refresh. A failed capture may leave validated bodies for exact-ID reuse. +Only validated completion can infer absence. Generation, query, schema, checkpoint, +and cancellation checks run inside the transaction. An old owner cannot commit +late pages, completion, or errors after cancellation or a generation change. + +## Immediate local feedback + +Device preferences update immediately. Shared edits use the existing delivery +journal and appear immediately in the plugin's pending view. Relay acceptance +starts a fresh capture. A pre-acceptance capture cannot reconcile that pending +edit. A successful capture begun after acceptance can show the accepted edit, a +later revision, or legitimate absence. Reconciling an older operation must preserve +any newer pending edit. Sync and delivery own this boundary; the row store does not +merge optimistic edits into confirmed state. Offline shared edits remain undecided. + +## Storage costs + +Writes request strict durability. Transactions span all four object stores, so +large batches can delay other collections' reads or writes. A changed row rewrites +its indexes. Index upgrades and absence reconciliation scan the collection. +Ancestry comparison and separate source-membership writes are absent. The database +upgrade removes the obsolete membership store and preserves records and device +preferences. Leaving a community purges only its saved partition. If database +opening fails, close that handle and open another after the cause clears. diff --git a/src/features/artifact-storage/revisions.test.ts b/src/features/artifact-storage/revisions.test.ts deleted file mode 100644 index d2b6f94d7f..0000000000 --- a/src/features/artifact-storage/revisions.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { expect, it } from "vitest"; -import { emptyArtifact, receiveRevision } from "./revisions"; -const revision = (id: string, parents: string[] = []) => ({ - id, - parents, - content: id, -}); - -it("keeps descendants when older confirmations arrive", () => { - let saved = receiveRevision(emptyArtifact("task"), revision("v0")); - saved = receiveRevision(saved, revision("v1", ["v0"])); - saved = receiveRevision(saved, revision("v0")); - expect(saved.record.current?.id).toBe("v1"); - expect(saved.record.conflicts).toEqual([]); -}); -it("retains conflicts and the usable view until a merge descends from both", () => { - let saved = receiveRevision(emptyArtifact("task"), revision("root")); - saved = receiveRevision(saved, revision("left", ["root"])); - saved = receiveRevision(saved, revision("right", ["root"])); - expect(saved.record.current?.id).toBe("left"); - expect(saved.record.conflicts.map((r) => r.id)).toEqual(["right"]); - saved = receiveRevision(saved, revision("merge", ["left", "right"])); - expect(saved.record.current?.id).toBe("merge"); - expect(saved.record.conflicts).toEqual([]); -}); -it("keeps unknown ancestry unresolved and joins it when missing parents arrive", () => { - let saved = receiveRevision(emptyArtifact("task"), revision("root")); - saved = receiveRevision(saved, revision("new", ["missing"])); - expect(saved.record.current?.id).toBe("root"); - expect(saved.record.unresolved).toBe(true); - saved = receiveRevision(saved, revision("missing", ["root"])); - expect(saved.record.current?.id).toBe("new"); - expect(saved.record.unresolved).toBe(false); -}); -it("rejects ancestry cycles and changed immutable revisions", () => { - const saved = receiveRevision(emptyArtifact("task"), revision("a", ["b"])); - expect(() => receiveRevision(saved, revision("b", ["a"]))).toThrow("cycle"); - expect(() => receiveRevision(saved, revision("a"))).toThrow( - "ancestry mismatch", - ); - expect(() => - receiveRevision(saved, { ...revision("a", ["b"]), content: "changed" }), - ).toThrow("content mismatch"); -}); diff --git a/src/features/artifact-storage/revisions.ts b/src/features/artifact-storage/revisions.ts deleted file mode 100644 index 82f75b5273..0000000000 --- a/src/features/artifact-storage/revisions.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { ArtifactRevision, SavedArtifact } from "./types"; - -export type StoredArtifact = { - record: SavedArtifact; - ancestry: Record; - heads: ArtifactRevision[]; - superseded: string[]; -}; -export function emptyArtifact(id: string): StoredArtifact { - return { - record: { - id, - conflicts: [], - unresolved: false, - availability: { state: "unknown" }, - }, - ancestry: {}, - heads: [], - superseded: [], - }; -} -export function receiveRevision( - stored: StoredArtifact, - revision: ArtifactRevision, -): StoredArtifact { - if ( - !revision.id || - typeof revision.content !== "string" || - revision.parents.some((id) => !id) - ) - throw new Error("Invalid artifact revision"); - const ancestry = { ...stored.ancestry }; - const prior = Object.hasOwn(ancestry, revision.id) - ? ancestry[revision.id] - : undefined; - if (prior && JSON.stringify(prior) !== JSON.stringify(revision.parents)) - throw new Error("Artifact revision ancestry mismatch"); - Object.defineProperty(ancestry, revision.id, { - value: [...revision.parents], - enumerable: true, - configurable: true, - }); - const parents = (id: string) => - Object.hasOwn(ancestry, id) ? (ancestry[id] ?? []) : []; - const reaches = (from: string, target: string) => { - const pending = [...parents(from)]; - const seen = new Set(); - while (pending.length) { - const id = pending.pop(); - if (id === undefined) break; - if (id === target) return true; - if (seen.has(id)) continue; - seen.add(id); - pending.push(...parents(id)); - } - return false; - }; - if (reaches(revision.id, revision.id)) - throw new Error("Artifact revision ancestry cycle"); - const candidates = new Map(stored.heads.map((head) => [head.id, head])); - const existing = - candidates.get(revision.id) ?? - (stored.record.current?.id === revision.id - ? stored.record.current - : undefined); - if (existing && JSON.stringify(existing) !== JSON.stringify(revision)) - throw new Error("Artifact revision content mismatch"); - candidates.set(revision.id, revision); - const heads = [...candidates.values()].filter( - (candidate) => - ![...candidates.keys()].some( - (id) => id !== candidate.id && reaches(id, candidate.id), - ), - ); - const unresolved = Object.values(ancestry).some((parents) => - parents.some((id) => !Object.hasOwn(ancestry, id)), - ); - let current = stored.record.current; - const successors = heads.filter( - (head) => - !current || head.id === current.id || reaches(head.id, current.id), - ); - if (successors.length === 1) current = successors[0]; - return { - ancestry, - heads, - superseded: - current?.id !== stored.record.current?.id && stored.record.current - ? [...new Set([...stored.superseded, stored.record.current.id])] - : stored.superseded, - record: { - ...stored.record, - ...(current ? { current } : {}), - unresolved, - conflicts: heads.filter((head) => head.id !== current?.id), - }, - }; -} diff --git a/src/features/artifact-storage/service.test.ts b/src/features/artifact-storage/service.test.ts index f76cb924b2..d728b3c561 100644 --- a/src/features/artifact-storage/service.test.ts +++ b/src/features/artifact-storage/service.test.ts @@ -34,7 +34,8 @@ it("binds collection ownership to the calling plugin and fences scope changes", relay: "https://relay.test", partition: "https://relay.test:viewer", }); - const collection = { get, close, scope: scopeDescriptor }; + const refreshError = vi.fn(async () => null); + const collection = { get, close, refreshError, scope: scopeDescriptor }; const factory = vi.fn(() => collection); const stores = service as unknown as { stores: Map }; stores.stores.set(JSON.stringify(["viewer", "https://relay.test"]), { @@ -61,6 +62,8 @@ it("binds collection ownership to the calling plugin and fences scope changes", }); expect(handle).not.toHaveProperty("applyBatch"); expect(handle.scope).toBe(scopeDescriptor); + expect(await handle.refreshError()).toBeNull(); + expect(refreshError).toHaveBeenCalledOnce(); await handle.get("task"); expect(get).toHaveBeenCalledWith("task"); scope = { viewer: "other", selected: "https://relay.test" }; diff --git a/src/features/artifact-storage/service.ts b/src/features/artifact-storage/service.ts index a8bc7c70f3..060e6ad06e 100644 --- a/src/features/artifact-storage/service.ts +++ b/src/features/artifact-storage/service.ts @@ -93,6 +93,10 @@ export class ArtifactStorageService extends Service implements ArtifactStorage { }; const facade: ArtifactCollection = { scope: collection.scope, + refreshError() { + valid(); + return checked(collection.refreshError()); + }, get(id) { valid(); return checked(collection.get(id)); diff --git a/src/features/artifact-storage/store.ts b/src/features/artifact-storage/store.ts index 79c7ac00d4..76a6853ba7 100644 --- a/src/features/artifact-storage/store.ts +++ b/src/features/artifact-storage/store.ts @@ -1,10 +1,5 @@ import type { JsonValue } from "../navigation/targets"; import { relayPartition } from "../relay/partition"; -import { - emptyArtifact, - receiveRevision, - type StoredArtifact, -} from "./revisions"; import type { ArtifactBatch, ArtifactCollectionDefinition, @@ -14,16 +9,17 @@ import type { ArtifactPageQuery, HostArtifactCollection, ArtifactSnapshot, + SavedArtifact, } from "./types"; const DB = "buzz-artifacts-v1"; -const STORES = ["records", "preferences", "entries", "metadata", "members"]; -type Row = StoredArtifact & { +const STORES = ["records", "preferences", "entries", "metadata"]; +type Row = { + record: SavedArtifact; scope: string; collection: string; id: string; - sequence?: number | undefined; - homes?: Record; + generation?: string; }; type StoredPreference = { value: JsonValue }; @@ -31,11 +27,7 @@ function sameSnapshot( left: ArtifactSnapshot | undefined, right: ArtifactSnapshot, ) { - return ( - left?.source === right.source && - left.generation === right.generation && - left.captureSequence === right.captureSequence - ); + return left?.source === right.source && left.generation === right.generation; } /** Same IndexedDB transaction boundary as the relay journals. No head-cache eviction. */ @@ -50,22 +42,23 @@ export function createArtifactStore(viewer: string, relay: string) { if (closed) throw new Error("Artifact storage closed"); if (!opening) { const attempt = new Promise((resolve, reject) => { - const request = indexedDB.open(DB, 1); + const request = indexedDB.open(DB, 2); let blocked = false; request.onupgradeneeded = () => { const db = request.result; + if (db.objectStoreNames.contains("members")) + db.deleteObjectStore("members"); for (const name of STORES) { + if (db.objectStoreNames.contains(name)) continue; const store = db.createObjectStore(name, { keyPath: name === "entries" ? ["scope", "collection", "index", "value", "order", "id"] - : name === "members" - ? ["scope", "collection", "source", "id"] - : name === "metadata" - ? ["scope", "collection", "key"] - : ["scope", "collection", "id"], + : name === "metadata" + ? ["scope", "collection", "key"] + : ["scope", "collection", "id"], }); - if (name === "entries" || name === "members") + if (name === "entries") store.createIndex("record", ["scope", "collection", "id"]); } }; @@ -177,6 +170,7 @@ export function createArtifactStore(viewer: string, relay: string) { const range = (parts: IDBValidKey[]) => IDBKeyRange.bound(parts, [...parts, []]); let disposed = false; + const lifetime = new AbortController(); const listeners = new Set<() => void>(); const channel = new BroadcastChannel(`${DB}:${JSON.stringify(prefix)}`); const changed = () => { @@ -220,40 +214,47 @@ export function createArtifactStore(viewer: string, relay: string) { }, ); } - const ready = transact("readwrite", (tx, then, done) => { - const metadata = tx.objectStore("metadata"); - then(metadata.get(key("schema")), (schema) => { - if (schema?.type && schema.type !== definition.type) - throw new Error("Artifact collection type changed"); - if (schema?.version === definition.version) { - done(); - return; - } - if (schema?.version > definition.version) - throw new Error("Artifact collection requires a newer plugin"); - const request = tx.objectStore("records").openCursor(range(prefix)); - then(request, (cursor) => { - if (!cursor) { - metadata.put({ - scope, - collection, - key: "schema", - version: definition.version, - type: definition.type, - }); + const ready = transact( + "readwrite", + (tx, then, done) => { + const metadata = tx.objectStore("metadata"); + then(metadata.get(key("schema")), (schema) => { + if (schema?.type && schema.type !== definition.type) + throw new Error("Artifact collection type changed"); + if (schema?.version === definition.version) { done(); return; } - then( - tx.objectStore("preferences").get(key(cursor.value.id)), - (pref: StoredPreference | undefined) => - reindex(tx, then, cursor.value, pref?.value, () => - cursor.continue(), - ), - ); + if (schema?.version > definition.version) + throw new Error("Artifact collection requires a newer plugin"); + metadata.delete(key("source")); + metadata.delete(key("snapshot")); + metadata.delete(key("checkpoint")); + const request = tx.objectStore("records").openCursor(range(prefix)); + then(request, (cursor) => { + if (!cursor) { + metadata.put({ + scope, + collection, + key: "schema", + version: definition.version, + type: definition.type, + }); + done(); + return; + } + then( + tx.objectStore("preferences").get(key(cursor.value.id)), + (pref: StoredPreference | undefined) => + reindex(tx, then, cursor.value, pref?.value, () => + cursor.continue(), + ), + ); + }); }); - }); - }); + }, + lifetime.signal, + ); // Keep an unopened handle's initialization rejection observable by its first operation. void ready.catch(() => {}); async function run( @@ -272,9 +273,39 @@ export function createArtifactStore(viewer: string, relay: string) { work(tx, then, done); }); }, - signal, + signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal, ); } + function bindSource( + tx: IDBTransaction, + then: Then, + source: string, + next: () => void, + ) { + if (!source) throw new Error("Artifact source is empty"); + const metadata = tx.objectStore("metadata"); + then(metadata.get(key("source")), (saved) => { + if (saved && saved.value !== source) + throw new Error("Artifact collection query changed"); + if (!saved) + metadata.put({ scope, collection, key: "source", value: source }); + next(); + }); + } + function checkSnapshot( + tx: IDBTransaction, + then: Then, + snapshot: ArtifactSnapshot, + next: () => void, + ) { + bindSource(tx, then, snapshot.source, () => { + then(tx.objectStore("metadata").get(key("snapshot")), (saved) => { + if (!sameSnapshot(saved?.value, snapshot)) + throw new Error("Artifact snapshot changed"); + next(); + }); + }); + } function readEntry( tx: IDBTransaction, then: Then, @@ -312,7 +343,12 @@ export function createArtifactStore(viewer: string, relay: string) { tx.objectStore("preferences").get(key(id)), (pref: StoredPreference | undefined) => { const result = change( - saved ?? { ...emptyArtifact(id), scope, collection, id }, + saved ?? { + record: { id, availability: { state: "unknown" } }, + scope, + collection, + id, + }, pref?.value, ); tx.objectStore("records").put(result.row); @@ -419,9 +455,7 @@ export function createArtifactStore(viewer: string, relay: string) { record.id, (row) => ({ row: - row.record.local !== undefined || - row.record.current || - row.heads.length + row.record.local !== undefined || row.record.current ? row : { ...row, @@ -467,6 +501,31 @@ export function createArtifactStore(viewer: string, relay: string) { (row) => done(row?.value === true), ), ), + refreshError: () => + run("readonly", (tx, then, done) => + then(tx.objectStore("metadata").get(key("refresh-error")), (row) => + done(row?.value ?? null), + ), + ), + async setRefreshError(snapshot, message, signal) { + if (!message) throw new Error("Artifact refresh error is empty"); + await run( + "readwrite", + (tx, then, done) => { + checkSnapshot(tx, then, snapshot, () => { + tx.objectStore("metadata").put({ + scope, + collection, + key: "refresh-error", + value: message, + }); + done(); + }); + }, + signal, + ); + publish(); + }, checkpoint: () => run("readonly", (tx, then, done) => then(tx.objectStore("metadata").get(key("checkpoint")), (row) => @@ -477,16 +536,12 @@ export function createArtifactStore(viewer: string, relay: string) { if (!source) return Promise.reject(new Error("Artifact source is empty")); return run("readwrite", (tx, then, done) => { - then(tx.objectStore("metadata").get(key("sequence")), (row) => { - const snapshot = { - source, - generation: crypto.randomUUID(), - captureSequence: row?.value ?? 0, - }; + bindSource(tx, then, source, () => { + const snapshot = { source, generation: crypto.randomUUID() }; tx.objectStore("metadata").put({ scope, collection, - key: JSON.stringify(["snapshot", source]), + key: "snapshot", value: snapshot, }); done(snapshot); @@ -497,261 +552,178 @@ export function createArtifactStore(viewer: string, relay: string) { await run( "readwrite", (tx, then, done) => { - const metadata = tx.objectStore("metadata"); - then( - metadata.get(key(JSON.stringify(["snapshot", snapshot.source]))), - (saved) => { - if (!sameSnapshot(saved?.value, snapshot)) - throw new Error("Artifact snapshot changed"); - then(metadata.get(key("checkpoint")), (row) => { - if ((row?.value ?? null) !== checkpoint.expected) - throw new Error("Artifact checkpoint changed"); - const request = tx - .objectStore("members") - .openCursor(range([...prefix, snapshot.source])); - then(request, function visit(cursor) { - if (!cursor) { - metadata.put({ - scope, - collection, - key: "checkpoint", - value: checkpoint.next, - }); - metadata.delete( - key(JSON.stringify(["snapshot", snapshot.source])), - ); - done(); - return; - } - const member = cursor.value; - if (member.generation === snapshot.generation) { - cursor.continue(); - return; - } - then( - tx.objectStore("records").get(key(member.id)), - (saved: Row | undefined) => { - if (!saved) - throw new Error("Snapshot member has no record"); - if ((saved.sequence ?? 0) > snapshot.captureSequence) { - cursor.continue(); - return; - } - cursor.delete(); - then( - tx - .objectStore("members") - .index("record") - .count(IDBKeyRange.only(key(member.id))), - (remaining) => { - if (remaining) { - cursor.continue(); - return; - } - mutate( - tx, - then, - member.id, - (row) => ({ - row: { - ...row, - record: { - ...row.record, - availability: { - state: "unavailable", - reason: "Absent from completed snapshot", - }, - }, - }, - }), - () => cursor.continue(), - ); + checkSnapshot(tx, then, snapshot, () => { + const metadata = tx.objectStore("metadata"); + then(metadata.get(key("checkpoint")), (row) => { + if ((row?.value ?? null) !== checkpoint.expected) + throw new Error("Artifact checkpoint changed"); + const request = tx + .objectStore("records") + .openCursor(range(prefix)); + then(request, function visit(cursor) { + if (!cursor) { + metadata.put({ + scope, + collection, + key: "checkpoint", + value: checkpoint.next, + }); + metadata.delete(key("snapshot")); + metadata.delete(key("refresh-error")); + done(); + return; + } + const saved: Row = cursor.value; + if ( + !saved.record.current || + saved.generation === snapshot.generation || + saved.record.availability.state === "unavailable" + ) { + cursor.continue(); + return; + } + mutate( + tx, + then, + saved.id, + (row) => ({ + row: { + ...row, + record: { + ...row.record, + availability: { + state: "unavailable", + reason: "Absent from completed snapshot", }, - ); + }, }, - ); - }); + }), + () => cursor.continue(), + ); }); - }, - ); + }); + }); }, signal, ); publish(); }, - async applyBatch(batch: ArtifactBatch) { - await run("readwrite", (tx, then, done) => { - const jobs: (() => void)[] = []; - const next = series(jobs); - let sequence = 0; - if (!batch.source) throw new Error("Artifact source is empty"); - if (batch.snapshot) - jobs.push(() => { - const snapshot = batch.snapshot; - if (!snapshot || snapshot.source !== batch.source) - throw new Error("Artifact snapshot source mismatch"); - then( - tx - .objectStore("metadata") - .get(key(JSON.stringify(["snapshot", batch.source]))), - (row) => { - if (!sameSnapshot(row?.value, snapshot)) - throw new Error("Artifact snapshot changed"); - next(); - }, - ); - }); - jobs.push(() => - then(tx.objectStore("metadata").get(key("sequence")), (row) => { - sequence = (row?.value ?? 0) + 1; - tx.objectStore("metadata").put({ - scope, - collection, - key: "sequence", - value: sequence, - }); - next(); - }), - ); - jobs.push(() => - then(tx.objectStore("metadata").get(key("checkpoint")), (row) => { - if ( - batch.checkpoint && - (row?.value ?? null) !== batch.checkpoint.expected - ) - throw new Error("Artifact checkpoint changed"); - next(); - }), - ); - for (const incoming of batch.records ?? []) + async applyBatch(batch: ArtifactBatch, signal) { + await run( + "readwrite", + (tx, then, done) => { + const jobs: (() => void)[] = []; + const next = series(jobs); + if (!batch.snapshot || batch.source !== batch.snapshot.source) + throw new Error("Artifact snapshot source mismatch"); + jobs.push(() => checkSnapshot(tx, then, batch.snapshot, next)); jobs.push(() => - mutate( - tx, - then, - incoming.id, - (row) => { - if (row.record.type && row.record.type !== incoming.type) - throw new Error("Artifact type mismatch"); - if (incoming.type !== definition.type) - throw new Error("Artifact type does not match collection"); - tx.objectStore("members").put({ - scope, - collection, - source: batch.source, - id: incoming.id, - generation: batch.snapshot?.generation ?? null, - }); - const homes = { - ...row.homes, - [incoming.revision.id]: incoming.channelId, - }; - const received = receiveRevision(row, incoming.revision); - if ( - batch.snapshot && - !row.superseded.includes(incoming.revision.id) && - received.heads.some( - (head) => head.id === incoming.revision.id, - ) && - (row.sequence ?? 0) <= batch.snapshot.captureSequence - ) { + then(tx.objectStore("metadata").get(key("checkpoint")), (row) => { + if ( + batch.checkpoint && + (row?.value ?? null) !== batch.checkpoint.expected + ) + throw new Error("Artifact checkpoint changed"); + next(); + }), + ); + for (const incoming of batch.records ?? []) + jobs.push(() => + mutate( + tx, + then, + incoming.id, + (row) => { + if (incoming.type !== definition.type) + throw new Error( + "Artifact type does not match collection", + ); if ( - row.record.current && - row.record.current.id !== incoming.revision.id + !incoming.channelId || + !incoming.revision.id || + typeof incoming.revision.content !== "string" ) - received.superseded = [ - ...new Set([ - ...received.superseded, - row.record.current.id, - ]), - ]; - received.record = { - ...received.record, - current: incoming.revision, - conflicts: received.heads.filter( - (head) => head.id !== incoming.revision.id, - ), + throw new Error("Invalid authoritative artifact"); + return { + row: { + scope, + collection, + id: incoming.id, + generation: batch.snapshot.generation, + record: { + id: incoming.id, + channelId: incoming.channelId, + type: incoming.type, + current: incoming.revision, + availability: row.record.availability, + ...(row.record.local !== undefined + ? { local: row.record.local } + : {}), + }, + }, }; - } - return { - row: { + }, + next, + ), + ); + for (const seen of batch.seen ?? []) + jobs.push(() => + then( + tx.objectStore("records").get(key(seen.id)), + (row: Row | undefined) => { + if (!row || row.record.current?.id !== seen.revisionId) + throw new Error("Snapshot revision is not saved"); + tx.objectStore("records").put({ ...row, - ...received, - homes, - sequence: batch.snapshot ? row.sequence : sequence, - record: { - ...received.record, - ...(received.record.current && - homes[received.record.current.id] - ? { channelId: homes[received.record.current.id] } - : {}), - type: incoming.type, + generation: batch.snapshot.generation, + }); + next(); + }, + ), + ); + for (const availability of batch.availability ?? []) + jobs.push(() => + mutate( + tx, + then, + availability.id, + (row) => { + if ( + (row.record.current?.id ?? null) !== + availability.expectedRevision + ) + throw new Error("Artifact revision changed"); + return { + row: { + ...row, + record: { + ...row.record, + availability: availability.value, + }, }, - }, - }; - }, - next, - ), - ); - for (const seen of batch.seen ?? []) - jobs.push(() => { - if (!batch.snapshot) - throw new Error("Seen members require a snapshot"); - then( - tx.objectStore("records").get(key(seen.id)), - (row: Row | undefined) => { - if (!row || !Object.hasOwn(row.ancestry, seen.revisionId)) - throw new Error("Snapshot revision is not saved"); - tx.objectStore("members").put({ - scope, - collection, - source: batch.source, - id: seen.id, - generation: batch.snapshot?.generation, - }); - next(); - }, + }; + }, + next, + ), ); + jobs.push(() => { + if (batch.checkpoint) + tx.objectStore("metadata").put({ + scope, + collection, + key: "checkpoint", + value: batch.checkpoint.next, + }); + done(); }); - for (const availability of batch.availability ?? []) - jobs.push(() => - mutate( - tx, - then, - availability.id, - (row) => ({ - row: - (row.record.current?.id ?? null) !== - availability.expectedRevision || - (batch.snapshot && - (row.sequence ?? 0) > batch.snapshot.captureSequence) - ? row - : { - ...row, - sequence: batch.snapshot ? row.sequence : sequence, - record: { - ...row.record, - availability: availability.value, - }, - }, - }), - next, - ), - ); - jobs.push(() => { - if (batch.checkpoint) - tx.objectStore("metadata").put({ - scope, - collection, - key: "checkpoint", - value: batch.checkpoint.next, - }); - done(); - }); - next(); - }); + next(); + }, + signal, + ); publish(); }, close() { + lifetime.abort(new Error("Artifact collection closed")); disposed = true; listeners.clear(); channel.close(); diff --git a/src/features/artifact-storage/types.ts b/src/features/artifact-storage/types.ts index d881a11e56..c3486417a5 100644 --- a/src/features/artifact-storage/types.ts +++ b/src/features/artifact-storage/types.ts @@ -3,7 +3,6 @@ import type { RelayEvent } from "../relay/events"; export type ArtifactRevision = Readonly<{ id: string; - parents: readonly string[]; content: string; event?: RelayEvent; }>; @@ -16,9 +15,6 @@ export type SavedArtifact = Readonly<{ channelId?: string; type?: string; current?: ArtifactRevision; - conflicts: readonly ArtifactRevision[]; - /** At least one parent is missing from the saved ancestry. */ - unresolved: boolean; /** Cached content does not establish current access. */ availability: ArtifactAvailability; /** A plugin-owned snapshot, without shared revision proof. */ @@ -64,6 +60,7 @@ export type ArtifactCollection = { partition: string; }>; get(id: string): Promise; + refreshError(): Promise; page(query: ArtifactPageQuery): Promise; /** Subscribe before the first read. Each notification requires a fresh read. */ subscribe(listener: () => void): () => void; @@ -88,14 +85,13 @@ export type ReceivedArtifact = Readonly<{ export type ArtifactSnapshot = Readonly<{ source: string; generation: string; - captureSequence: number; }>; export type ArtifactBatch = Readonly<{ /** Exact authoritative query scope, defined by the sync owner. */ source: string; - snapshot?: ArtifactSnapshot; + snapshot: ArtifactSnapshot; records?: readonly ReceivedArtifact[]; - /** Snapshot members whose revision is already saved. */ + /** Snapshot members whose exact current revision is already saved. */ seen?: readonly Readonly<{ id: string; revisionId: string }>[]; availability?: readonly Readonly<{ id: string; @@ -106,8 +102,13 @@ export type ArtifactBatch = Readonly<{ }>; /** Host-only writes. Plugins submit shared changes through the delivery service. */ export type HostArtifactCollection = ArtifactCollection & { - applyBatch(batch: ArtifactBatch): Promise; + applyBatch(batch: ArtifactBatch, signal?: AbortSignal): Promise; checkpoint(): Promise; + setRefreshError( + snapshot: ArtifactSnapshot, + message: string, + signal?: AbortSignal, + ): Promise; beginSnapshot(source: string): Promise; /** The caller validates server completion before this local transaction. */ completeSnapshot( diff --git a/tests/browser/artifact-storage.spec.mjs b/tests/browser/artifact-storage.spec.mjs index 3d3c81f3ac..25e0976b4c 100644 --- a/tests/browser/artifact-storage.spec.mjs +++ b/tests/browser/artifact-storage.spec.mjs @@ -60,6 +60,25 @@ test("commits across windows and survives offline restart with isolated scopes", expect((await other.evaluate(() => window.change)).preference).toEqual({ pinned: false, }); + await other.evaluate(() => { + window.errorChange = new Promise((resolve) => { + const stop = window.collection.subscribe(async () => { + const error = await window.collection.refreshError(); + if (error) { + stop(); + resolve(error); + } + }); + }); + }); + await page.evaluate(async () => { + const c = window.artifactTest.open(); + const snapshot = await c.beginSnapshot("all"); + await c.setRefreshError(snapshot, "Refresh unavailable"); + }); + expect(await other.evaluate(() => window.errorChange)).toBe( + "Refresh unavailable", + ); await page.reload(); await page.waitForFunction(() => !!window.artifactTest); await context.setOffline(true); @@ -68,6 +87,7 @@ test("commits across windows and survives offline restart with isolated scopes", return { row: await collection.get("task"), marker: await collection.imported("done"), + refreshError: await collection.refreshError(), identities: await window.artifactTest.open("other").get("task"), relays: await window.artifactTest.open("viewer", "other").get("task"), plugins: await window.artifactTest @@ -80,6 +100,7 @@ test("commits across windows and survives offline restart with isolated scopes", }); expect(result.row.record.local).toEqual({ title: "saved" }); expect(result.marker).toBe(true); + expect(result.refreshError).toBe("Refresh unavailable"); for (const field of ["identities", "relays", "plugins", "collections"]) expect(result[field]).toBeUndefined(); }); @@ -98,18 +119,19 @@ test("rolls back received batches and migration failures without publishing chan id: "good", channelId: "channel", type: "test.task", - revision: { id: "a", parents: [], content: "{}" }, + revision: { id: "a", content: "{}" }, }, { id: "bad", - channelId: "channel", + channelId: "", type: "test.task", - revision: { id: "cycle", parents: ["cycle"], content: "{}" }, + revision: { id: "bad", content: "{}" }, }, ]; const failure = await c .applyBatch({ source: "all", + snapshot: await c.beginSnapshot("all"), records, checkpoint: { expected: null, next: "bad" }, }) @@ -146,7 +168,7 @@ test("rolls back received batches and migration failures without publishing chan }; }); expect(result).toEqual({ - failure: "Artifact revision ancestry cycle", + failure: "Invalid authoritative artifact", after: undefined, checkpoint: null, notices: [], @@ -197,76 +219,93 @@ test("atomically indexes preference changes and preserves newer values during mi expect(result.row.record.local).toEqual({ archived: false }); }); -test("reconciles only completed snapshot scopes and fences newer live commits", async ({ +test("reconciles one fixed query only after completion and preserves device data", async ({ page, storageUrl, }) => { await open(page, storageUrl); const result = await page.evaluate(async () => { const c = window.artifactTest.open(); - const row = (id, revision = id) => ({ + const record = (id) => ({ id, channelId: "channel", type: "test.task", - revision: { id: revision, parents: [], content: "{}" }, + revision: { id, content: "{}" }, }); + const seed = await c.beginSnapshot("all"); await c.applyBatch({ - source: "one", - records: [row("missing"), row("live"), row("seen")], - availability: ["missing", "live", "seen"].map((id) => ({ + source: "all", + snapshot: seed, + records: [record("missing"), record("seen")], + availability: ["missing", "seen"].map((id) => ({ id, expectedRevision: id, value: { state: "available" }, })), }); - await c.applyBatch({ source: "other", records: [row("outside")] }); + await c.completeSnapshot(seed, { expected: null, next: "seed" }); await c.preferences.update("missing", () => ({ pinned: false })); - const snapshot = await c.beginSnapshot("one"); - await c.applyBatch({ source: "one", snapshot, records: [row("seen")] }); + await c.importBatch({ + records: [{ id: "device-only", local: { title: "saved" } }], + }); + const partial = await c.beginSnapshot("all"); await c.applyBatch({ - source: "one", - records: [row("new"), row("live", "new-live")], + source: "all", + snapshot: partial, + seen: [{ id: "seen", revisionId: "seen" }], }); + await c.setRefreshError(partial, "Refresh limit reached"); const before = await c.get("missing"); - await c.completeSnapshot(snapshot, { expected: null, next: "complete" }); + const error = await c.refreshError(); + const snapshot = await c.beginSnapshot("all"); + const retainedError = await c.refreshError(); const stale = await c - .completeSnapshot(snapshot, { expected: null, next: "wrong" }) - .then( - () => "unexpected", - (error) => error.message, - ); - const interrupted = await c.beginSnapshot("one"); - const replacement = await c.beginSnapshot("one"); - const invalidated = await c - .completeSnapshot(interrupted, { expected: "complete", next: "wrong" }) + .completeSnapshot(partial, { expected: "seed", next: "wrong" }) .then( () => "unexpected", - (error) => error.message, + (e) => e.message, ); + const wrongQuery = await c.beginSnapshot("other").then( + () => "unexpected", + (e) => e.message, + ); + await c.applyBatch({ + source: "all", + snapshot, + seen: [{ id: "seen", revisionId: "seen" }], + }); + await c.completeSnapshot(snapshot, { expected: "seed", next: "complete" }); + const lateError = await c.setRefreshError(partial, "old error").then( + () => "unexpected", + (e) => e.message, + ); return { before, - rows: await Promise.all( - ["missing", "live", "seen", "new", "outside"].map((id) => c.get(id)), - ), - checkpoint: await c.checkpoint(), + error, + retainedError, stale, - invalidated, - replacement: !!replacement, + wrongQuery, + lateError, + missing: await c.get("missing"), + seen: await c.get("seen"), + local: await c.get("device-only"), + checkpoint: await c.checkpoint(), + clearedError: await c.refreshError(), }; }); expect(result.before.record.availability.state).toBe("available"); - expect(result.rows.map((entry) => entry.record.availability.state)).toEqual([ - "unavailable", - "available", - "available", - "unknown", - "unknown", - ]); - expect(result.rows[0].record.current.content).toBe("{}"); - expect(result.rows[0].preference).toEqual({ pinned: false }); - expect(result.checkpoint).toBe("complete"); + expect(result.error).toBe("Refresh limit reached"); + expect(result.retainedError).toBe(result.error); expect(result.stale).toBe("Artifact snapshot changed"); - expect(result.invalidated).toBe("Artifact snapshot changed"); + expect(result.wrongQuery).toBe("Artifact collection query changed"); + expect(result.lateError).toBe("Artifact snapshot changed"); + expect(result.missing.record.availability.state).toBe("unavailable"); + expect(result.missing.record.current.content).toBe("{}"); + expect(result.missing.preference).toEqual({ pinned: false }); + expect(result.seen.record.availability.state).toBe("available"); + expect(result.local.record.local).toEqual({ title: "saved" }); + expect(result.checkpoint).toBe("complete"); + expect(result.clearedError).toBeNull(); }); test("reads bounded indexed pages for 10,000 tasks and 40,000 archived records", async ({ @@ -334,79 +373,98 @@ test("reads bounded indexed pages for 10,000 tasks and 40,000 archived records", ); }); -test("fences authoritative snapshot heads, moved homes, and stale availability", async ({ +test("accepts authoritative heads without ancestry and rejects retired captures", async ({ page, storageUrl, }) => { await open(page, storageUrl); const result = await page.evaluate(async () => { const c = window.artifactTest.open(); - const record = (id, parents, channelId = "old-home") => ({ + const record = (id, channelId) => ({ id: "task", channelId, type: "test.task", - revision: { id, parents, content: "{}" }, - }); - const availability = (expectedRevision, state) => ({ - id: "task", - expectedRevision, - value: { state }, - }); - await c.applyBatch({ - source: "all", - records: [record("A", [])], - availability: [availability("A", "available")], - }); - const snapshot = await c.beginSnapshot("all"); - await c.applyBatch({ - source: "all", - snapshot, - records: [record("C", ["missing-B"], "moved-home")], - availability: [availability("C", "available")], - }); - const authoritative = await c.get("task"); - const next = await c.beginSnapshot("all"); - await c.applyBatch({ - source: "all", - records: [record("D", ["C"], "new-home")], - availability: [availability("D", "deleted")], + revision: { id, content: "{}" }, }); + const old = await c.beginSnapshot("all"); await c.applyBatch({ source: "all", - snapshot: next, - records: [record("C", ["missing-B"], "moved-home")], - availability: [availability("C", "available")], + snapshot: old, + records: [record("A", "old-home")], }); + const newer = await c.beginSnapshot("all"); await c.applyBatch({ source: "all", - records: [record("A", [])], - availability: [availability("A", "available")], - }); - const after = await c.get("task"); - const oldSnapshot = await c.beginSnapshot("all"); - await c.applyBatch({ - source: "all", - snapshot: oldSnapshot, - records: [record("A", [])], - }); - const oldHead = await c.get("task"); - await c.applyBatch({ - source: "all", - snapshot: oldSnapshot, - seen: [{ id: "task", revisionId: "C" }], + snapshot: newer, + records: [record("C", "new-home")], + availability: [ + { id: "task", expectedRevision: "C", value: { state: "available" } }, + ], }); - await c.completeSnapshot(oldSnapshot, { expected: null, next: "seen" }); - return { authoritative, after, oldHead, final: await c.get("task") }; + const stale = await c + .applyBatch({ + source: "all", + snapshot: old, + records: [record("A", "old-home")], + }) + .then( + () => "unexpected", + (e) => e.message, + ); + const revision = await c + .applyBatch({ + source: "all", + snapshot: newer, + availability: [ + { id: "task", expectedRevision: "A", value: { state: "deleted" } }, + ], + }) + .then( + () => "unexpected", + (e) => e.message, + ); + const mismatch = await c + .applyBatch({ source: "other", snapshot: newer }) + .then( + () => "unexpected", + (e) => e.message, + ); + const raw = await c + .applyBatch({ source: "all", records: [record("raw-live", "bad-home")] }) + .then( + () => "unexpected", + (e) => e.message, + ); + const notSaved = await c + .applyBatch({ + source: "all", + snapshot: newer, + seen: [{ id: "task", revisionId: "A" }], + }) + .then( + () => "unexpected", + (e) => e.message, + ); + await c.completeSnapshot(newer, { expected: null, next: "done" }); + return { + stale, + revision, + mismatch, + raw, + notSaved, + row: await c.get("task"), + }; }); - expect(result.authoritative.record.current.id).toBe("C"); - expect(result.authoritative.record.channelId).toBe("moved-home"); - expect(result.authoritative.record.unresolved).toBe(true); - expect(result.authoritative.record.conflicts.map((r) => r.id)).toEqual(["A"]); - for (const entry of [result.after, result.oldHead, result.final]) { - expect(entry.record.current.id).toBe("D"); - expect(entry.record.channelId).toBe("new-home"); - expect(entry.record.availability.state).toBe("deleted"); - } + expect(result.stale).toBe("Artifact snapshot changed"); + expect(result.revision).toBe("Artifact revision changed"); + expect(result.mismatch).toBe("Artifact snapshot source mismatch"); + expect(result.raw).toBe("Artifact snapshot source mismatch"); + expect(result.notSaved).toBe("Snapshot revision is not saved"); + expect(result.row.record.current.id).toBe("C"); + expect(result.row.record.channelId).toBe("new-home"); + expect(result.row.record.availability.state).toBe("available"); + expect(result.row.record).not.toHaveProperty("unresolved"); + expect(result.row.record).not.toHaveProperty("conflicts"); }); test("aborts snapshot completion before and during the local transaction", async ({ @@ -417,21 +475,24 @@ test("aborts snapshot completion before and during the local transaction", async const result = await page.evaluate(async () => { let controller; let abortOnProjection = false; + let closeOnProjection = false; const c = window.artifactTest.open("viewer", "relay", "test", { project() { if (abortOnProjection) controller.abort(new Error("live interval lost")); + if (closeOnProjection) c.close(); return []; }, }); await c.applyBatch({ source: "all", + snapshot: await c.beginSnapshot("all"), records: [ { id: "task", channelId: "channel", type: "test.task", - revision: { id: "A", parents: [], content: "{}" }, + revision: { id: "A", content: "{}" }, }, ], availability: [ @@ -459,15 +520,51 @@ test("aborts snapshot completion before and during the local transaction", async () => "unexpected", (e) => e.message, ); + controller = new AbortController(); + const batch = await c + .applyBatch( + { + source: "all", + snapshot, + records: [ + { + id: "task", + channelId: "new-home", + type: "test.task", + revision: { id: "B", content: "{}" }, + }, + ], + }, + controller.signal, + ) + .then( + () => "unexpected", + (e) => e.message, + ); + abortOnProjection = false; + const row = await c.get("task"); + const checkpoint = await c.checkpoint(); + closeOnProjection = true; + const closed = await c + .completeSnapshot(snapshot, { expected: null, next: "closed" }) + .then( + () => "unexpected", + (e) => e.message, + ); return { before, + batch, + closed, + row, + checkpoint, during, - row: await c.get("task"), - checkpoint: await c.checkpoint(), }; }); expect(result.before).toBe("owner closed"); expect(result.during).toBe("live interval lost"); + expect(result.batch).toBe("live interval lost"); + expect(result.closed).toBe("Artifact collection closed"); + expect(result.row.record.current.id).toBe("A"); expect(result.row.record.availability.state).toBe("available"); expect(result.checkpoint).toBeNull(); }); @@ -601,49 +698,86 @@ test("purges one identity and relay across collections", async ({ expect(result.other.preference).toBe(false); }); -test("keeps a pending revision's home and reconciles overlapping sources separately", async ({ +test("upgrades saved device data and removes the obsolete membership store", async ({ page, storageUrl, }) => { await open(page, storageUrl); const result = await page.evaluate(async () => { - const c = window.artifactTest.open(); - const record = (id, parents, channelId) => ({ - id: "task", - channelId, - type: "test.task", - revision: { id, parents, content: "{}" }, - }); - await c.applyBatch({ - source: "old-home", - records: [record("A", [], "old-home")], - availability: [ - { id: "task", expectedRevision: "A", value: { state: "available" } }, - ], - }); - await c.applyBatch({ - source: "new-home", - records: [record("C", ["B"], "new-home")], - }); - await c.applyBatch({ - source: "new-home", - records: [record("B", ["A"], "old-home")], + await new Promise((resolve, reject) => { + const request = indexedDB.open("buzz-artifacts-v1", 1); + request.onupgradeneeded = () => { + const db = request.result; + for (const name of [ + "records", + "preferences", + "entries", + "metadata", + "members", + ]) { + const store = db.createObjectStore(name, { + keyPath: + name === "entries" + ? ["scope", "collection", "index", "value", "order", "id"] + : name === "members" + ? ["scope", "collection", "source", "id"] + : name === "metadata" + ? ["scope", "collection", "key"] + : ["scope", "collection", "id"], + }); + if (name === "entries" || name === "members") + store.createIndex("record", ["scope", "collection", "id"]); + } + }; + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction(["records", "preferences"], "readwrite"); + const prefix = { + scope: "relay:viewer", + collection: JSON.stringify(["test", "tasks"]), + id: "task", + }; + tx.objectStore("records").put({ + ...prefix, + record: { + id: "task", + local: { title: "saved" }, + availability: { state: "unknown" }, + }, + }); + tx.objectStore("preferences").put({ + ...prefix, + value: { pinned: false }, + }); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onabort = () => reject(tx.error); + }; }); - const moved = await c.get("task"); - const old = await c.beginSnapshot("old-home"); - await c.completeSnapshot(old, { expected: null, next: "old-complete" }); - const retained = await c.get("task"); - const next = await c.beginSnapshot("new-home"); - await c.completeSnapshot(next, { - expected: "old-complete", - next: "all-complete", + const c = window.artifactTest.open(); + const saved = await c.get("task"); + const page = await c.page({ index: "pin", value: "false" }); + const schema = await new Promise((resolve, reject) => { + const request = indexedDB.open("buzz-artifacts-v1"); + request.onsuccess = () => { + const db = request.result; + resolve({ + version: db.version, + members: db.objectStoreNames.contains("members"), + }); + db.close(); + }; + request.onerror = () => reject(request.error); }); - return { moved, retained, absent: await c.get("task") }; + return { saved, rows: page.entries.length, schema }; }); - expect(result.moved.record.current.id).toBe("C"); - expect(result.moved.record.channelId).toBe("new-home"); - expect(result.retained.record.availability.state).toBe("available"); - expect(result.absent.record.availability.state).toBe("unavailable"); + expect(result.saved.record.local).toEqual({ title: "saved" }); + expect(result.saved.preference).toEqual({ pinned: false }); + expect(result.rows).toBe(1); + expect(result.schema).toEqual({ version: 2, members: false }); }); test("retries database opening with a new handle after a transient failure", async ({ From 44dee9cac0373ba83b31c7736d0a5d78c0f1348f Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 15:03:20 -0700 Subject: [PATCH 10/30] Refresh confirmed artifacts from one query Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/artifact-sync-delivery.md | 20 +- src/features/relay/artifact-delivery.ts | 24 ++ src/features/relay/artifact-reconcile.test.ts | 50 ++-- src/features/relay/artifact-reconcile.ts | 212 ++++++++------- src/features/relay/artifact-sync.test.ts | 93 ++++++- src/features/relay/artifact-sync.ts | 90 +++---- src/features/relay/managed-delivery.test.ts | 182 +++++++++++++ src/features/relay/managed-delivery.ts | 42 ++- src/features/relay/outbox.ts | 254 +++++++++++++++--- src/features/relay/session.ts | 13 +- tests/browser/managed-delivery.spec.mjs | 46 ++++ tests/fixtures/managed-delivery.ts | 28 ++ 12 files changed, 829 insertions(+), 225 deletions(-) create mode 100644 src/features/relay/artifact-delivery.ts diff --git a/docs/artifact-sync-delivery.md b/docs/artifact-sync-delivery.md index 633ca36a1d..ee5e57b321 100644 --- a/docs/artifact-sync-delivery.md +++ b/docs/artifact-sync-delivery.md @@ -1,17 +1,19 @@ # Artifact sync and message delivery -The host saves received artifacts through `ArtifactStorageService`. Plugins own each collection definition and projector. A Web Lock covers `{viewer, relay, owner, collectionName}`. Only a window with that definition can own the sync work. The owner cancels reads and waits for commits before it releases the lock. The message outbox has a separate lock for `{viewer, relay}`. Each retained relay session keeps its lock until disposal. Community selection does not stop pending delivery. +The host saves confirmed relay artifacts through `ArtifactStorageService`. Plugins own each collection definition and projector. A Web Lock covers `{viewer, relay, owner, collectionName}`. Only a window with that definition can own the sync work. The owner cancels reads and waits for commits before it releases the lock. The message outbox has a separate lock for `{viewer, relay}`. Each retained relay session keeps its lock until disposal. Community selection does not stop pending delivery. ## Snapshot contract Sync uses the existing relay session and socket. It needs the version 1 `artifacts.snapshot` capability. The relay must support a stable manifest of artifact IDs, exact revision IDs, and channel IDs. It must validate access and completion. Offset pages do not meet this contract. -The client saves validated bodies during capture. It starts with 100 bodies per request. It splits a batch when the relay returns `snapshot_limit`. Saved bodies remain available after an interrupted capture. A retry can reuse them. Only valid completion permits absence reconciliation. Preferences, pending work, and records with newer local commits survive completion. A reset, expired token, changed access, or lost connection prevents completion. +The client saves validated bodies during capture. It starts with 100 bodies per request. It splits a batch when the relay returns `snapshot_limit`. Saved bodies remain available after an interrupted capture. A retry can reuse them. Only valid completion permits absence reconciliation. Preferences and pending edits remain separate from confirmed rows. A reset, expired token, changed access, or lost connection prevents completion. -Live events and confirmed local artifact writes use the same revision checks and atomic store path. A late acceptance cannot replace a newer revision. Repairs run after reconnect, collection changes, roster changes, and live hints. Channel activity alone does not request repair. A 60-second repair covers homes outside active message routes. Repairs are sequential within a collection. Three short retries precede the next repair interval. Local reads do not wait for repair. +Each collection has one fixed query. Live events and accepted local writes request a background refresh. They do not write confirmed records. The store applies authoritative snapshot pages in generation order. The client has no revision ancestry graph. Repairs run after reconnect, collection changes, roster changes, and live hints. Channel activity alone does not request repair. A 60-second repair covers homes outside active message routes. Repairs are sequential within a collection. Transient failures get three short retries before the next repair interval. Capture admission failures use the repair interval directly. Live traffic cannot bypass that delay. A failed refresh saves an error after retries. A successful completion clears it. Local reads do not wait for repair. The relay snapshot PR is a prerequisite. A relay without the capability keeps the saved view. This client does not invent a checkpoint from ordinary query results. +The relay rejects capture when active memberships exceed 50,000, before query filters. Archived memberships count toward this limit. A narrower query does not remove it. The client retains saved data and pending edits on `snapshot_limit`. Body-batch limits use bounded batch reduction. They do not establish a higher capture limit. + ## Live channel groups Large joined rosters use live groups of at most 1,000 channels. Each group stays open while recent replay reads run in groups of ten channels. At most four requests await EOSE. Each replay filter keeps its 500-event limit. EOSE establishes a route; it does not prove that history is complete. A full limit remains incomplete evidence. Existing finite catch-up owns older gaps. The agent runner retains its event-ID checks before dispatch. @@ -22,7 +24,13 @@ The relay permits 1,024 subscriptions, ten filters per request, and 2,048 explic ## Managed delivery -`session.messagesForRecipient(agentPubkey)` uses the existing message validation and composer path. `EmbeddedThread.managedRecipient` selects this path for a task conversation. Ordinary chat keeps its existing behavior. `session.delivery.submit(input)` admits kind-45010 artifact operations. `delivery.get(id)` is asynchronous. `delivery.subscribe(listener)` reports durable state changes. +`session.messagesForRecipient(agentPubkey)` uses the existing message validation and composer path. `EmbeddedThread.managedRecipient` selects this path for a task conversation. Ordinary chat keeps its existing behavior. `session.delivery.submit(input)` admits kind-45010 artifact operations. `delivery.submit(input,{operationKey,collection:{owner,name}})` saves a caller key with artifact intent. Submits serialize across windows. `delivery.lookup(operationKey)` finds that retained intent after a restart. The same key rejects different input. This closes the gap before a caller saves the returned ID. Key lookup lasts while the journal retains the record. + +`delivery.artifact(id)` returns bounded, newest-first entries with `template`, `state`, and `confirmed`. Unconfirmed edits remain in the pending journal. They do not enter the bounded completed cache. A failed publication retains the attempted value and relay error. The client does not rebase stale writes automatically. A caller can display these entries over confirmed rows. + +Before each capture starts, sync freezes the accepted operation IDs for that collection. Only successful server and local completion can confirm those IDs. A later accepted operation needs another capture. A subsequent relay revision or legitimate absence can confirm an earlier edit. The accepted revision need not remain current. A local confirmation save failure retains acceptance and the pending value, then exposes an error. It never republishes the accepted event. + +`delivery.get(id)` is asynchronous. `delivery.subscribe(listener)` reports durable state changes. The outbox saves intent and the signed event before publication. Artifact events keep their supplied envelope tags. Identical submissions with the same signed event ID share one operation. It retains the same event ID across uncertain retries. The owner makes at most three attempts before an error. A definitive relay refusal stops automatic retries. An explicit retry clears the refusal. A settled refusal can be discarded. Acceptance and exact agent receipt are separate saved facts. Stale writes from another window cannot clear either fact. Within one retry round, merges preserve receipt deadlines, retry delays, and errors. A new round permits an explicit retry. @@ -34,8 +42,8 @@ Exact receipt is local in v0. A crash before receipt persistence, or receipt on ## Verification limits -Focused tests cover publication, exact receipt, restart, stale writes, lost confirmations, refusal, expiry, and completion fences. Browser checks use real IndexedDB, BroadcastChannel, and Web Locks in Chromium and WebKit. The socket scale check covers 10,000 active homes plus 50,000 archive homes and a normal chat. It verifies grouping and request bounds with a test socket. It does not measure the deployed relay's startup cost. +Focused tests cover publication, exact receipt, restart, stale writes, lost confirmations, refusal, expiry, and completion fences. Browser checks use real IndexedDB, BroadcastChannel, and Web Locks in Chromium and WebKit. They cover keyed submits across windows, restart, and a stale save after capture confirmation. The socket scale check covers 10,000 active homes plus 50,000 archive homes and a normal chat. It verifies grouping and request bounds with a test socket. It does not measure the deployed relay's startup cost or establish snapshot support above its membership limit. -A separate macOS bundle uses the saved debug identity and the installed Nexus plugin. Native checks verify restart, indexed reads, explicit false preferences, committed notifications, signed artifact create/delete, and saved accepted revisions. DOM checks verify the pulse in both themes, the receipt timeout, and focus and activation of `Check again`. The accepted event ID stays the same. These checks do not include screenshots, width changes, or enlarged text. +A separate macOS bundle uses the saved debug identity and the installed Nexus plugin. Native storage checks verify restart, indexed reads, explicit false preferences, and committed notifications. Native checks of pending artifact views under the single-query contract remain pending. DOM checks verify the pulse in both themes, the receipt timeout, and focus and activation of `Check again`. The accepted event ID stays the same. These checks do not include screenshots, width changes, or enlarged text. Real snapshot verification requires the relay prerequisite and this client together. The live community does not advertise snapshot support. Exact receipt from a real harness requires the Nexus adoption layer. Unit and browser checks verify the bound callback and saved receipt state. diff --git a/src/features/relay/artifact-delivery.ts b/src/features/relay/artifact-delivery.ts new file mode 100644 index 0000000000..5017b6297a --- /dev/null +++ b/src/features/relay/artifact-delivery.ts @@ -0,0 +1,24 @@ +type ArtifactDelivery = { + capture(type: string, collection: string): Promise; + complete(ids: readonly string[], signal: AbortSignal): Promise; +}; +const sessions = new WeakMap(); +export function registerArtifactDelivery( + session: object, + delivery: ArtifactDelivery, +) { + sessions.set(session, delivery); +} +export async function captureArtifactDelivery( + session: object, + type: string, + collection: string, +) { + const delivery = sessions.get(session); + // Freeze accepted IDs before starting the relay capture. Later acceptances need another capture. + const ids = (await delivery?.capture(type, collection)) ?? []; + return async (signal: AbortSignal) => { + signal.throwIfAborted(); + await delivery?.complete(ids, signal); + }; +} diff --git a/src/features/relay/artifact-reconcile.test.ts b/src/features/relay/artifact-reconcile.test.ts index 60a820d8f8..ea0d8b35c6 100644 --- a/src/features/relay/artifact-reconcile.test.ts +++ b/src/features/relay/artifact-reconcile.test.ts @@ -33,10 +33,10 @@ function setup() { const snapshot = { source: "source", generation: "generation", - captureSequence: 4, }; const collection = { checkpoint: vi.fn(async () => "old"), + setRefreshError: vi.fn(async () => {}), beginSnapshot: vi.fn(async (source: string) => ({ ...snapshot, source })), get: vi.fn( async (_id: string) => @@ -66,13 +66,12 @@ function setup() { complete: vi.fn(async () => {}), }; const controller = new AbortController(); - const run = (beforeComplete?: () => Promise) => + const run = () => reconcileArtifactSnapshot( reader, collection as unknown as HostArtifactCollection, "test.task", controller.signal, - beforeComplete, ); return { events, entries, collection, reader, controller, run }; } @@ -92,7 +91,7 @@ it("commits each page before validated completion and advances the captured chec h.collection.completeSnapshot.mock.invocationCallOrder[0] as number, ); expect(h.collection.completeSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ captureSequence: 4 }), + expect.objectContaining({ generation: "generation" }), { expected: "old", next: "token" }, h.controller.signal, ); @@ -105,12 +104,9 @@ it("reuses a verified cached body and marks its captured revision seen", async ( ? { record: { id, - conflicts: [], - unresolved: false, availability: { state: "available" }, current: { id: first.id, - parents: [], content: first.content, event: first, }, @@ -124,6 +120,7 @@ it("reuses a verified cached body and marks its captured revision seen", async ( expect.objectContaining({ seen: [{ id: h.entries[0]?.artifactId, revisionId: first?.id }], }), + h.controller.signal, ); }); it("keeps received pages after an interrupted read without reconciling absence", async () => { @@ -144,27 +141,13 @@ it.each(["snapshot_reset", "snapshot_limit"] as const)( expect(h.collection.completeSnapshot).not.toHaveBeenCalled(); }, ); -it("waits for queued live commits before local completion", async () => { - const h = setup(); - let release = () => {}; - const gate = new Promise((resolve) => { - release = resolve; - }); - const before = vi.fn(() => gate); - const work = h.run(before); - await vi.waitFor(() => expect(before).toHaveBeenCalledOnce()); - expect(h.collection.completeSnapshot).not.toHaveBeenCalled(); - release(); - await work; - expect(h.collection.completeSnapshot).toHaveBeenCalledOnce(); -}); + it("rejects local completion when the live interval is lost after server completion", async () => { const h = setup(); - await expect( - h.run(async () => { - h.controller.abort(); - }), - ).rejects.toMatchObject({ name: "AbortError" }); + h.reader.complete.mockImplementation(async () => { + h.controller.abort(); + }); + await expect(h.run()).rejects.toMatchObject({ name: "AbortError" }); expect(h.collection.completeSnapshot).not.toHaveBeenCalled(); }); it("rejects repeated identities across pages", async () => { @@ -194,3 +177,18 @@ it("reduces a body batch on a byte limit and rejects an unsatisfiable single bod await expect(h.run()).rejects.toMatchObject({ code: "snapshot_limit" }); expect(h.collection.completeSnapshot).not.toHaveBeenCalled(); }); + +it("saves an admission error without changing records or establishing absence", async () => { + const h = setup(); + h.reader.start.mockRejectedValue( + new ArtifactSnapshotError("snapshot_limit", "Too many memberships"), + ); + await expect(h.run()).rejects.toMatchObject({ code: "snapshot_limit" }); + expect(h.collection.applyBatch).not.toHaveBeenCalled(); + expect(h.collection.completeSnapshot).not.toHaveBeenCalled(); + expect(h.collection.setRefreshError).toHaveBeenCalledWith( + expect.objectContaining({ generation: "generation" }), + "The relay limit prevents a refresh of saved data", + h.controller.signal, + ); +}); diff --git a/src/features/relay/artifact-reconcile.ts b/src/features/relay/artifact-reconcile.ts index 0b96fc93fb..a18d850294 100644 --- a/src/features/relay/artifact-reconcile.ts +++ b/src/features/relay/artifact-reconcile.ts @@ -42,7 +42,6 @@ export function receivedArtifact( type, revision: { id: event.id, - parents: previous.map((tag) => tag[1] as string), content: event.content, event, }, @@ -55,7 +54,7 @@ export async function reconcileArtifactSnapshot( collection: HostArtifactCollection, type: string, signal: AbortSignal, - beforeComplete: () => Promise = async () => {}, + options: { reportFailure?: boolean; onComplete?: () => Promise } = {}, ) { const source = JSON.stringify({ artifact: "current", @@ -65,103 +64,130 @@ export async function reconcileArtifactSnapshot( const expected = await collection.checkpoint(); signal.throwIfAborted(); const snapshot = await collection.beginSnapshot(source); - let page = await reader.start( - { artifact: "current", kinds: [45010], "#type": [type] }, - signal, - ); - const token = page.token; - const seen = new Set(); - const cursors = new Set(); - async function saveBodies( - entries: readonly ArtifactManifestEntry[], - ): Promise { - if (!entries.length) return; - let events: readonly RelayEvent[]; - try { - events = await reader.bodies(token, entries, signal); - } catch (error) { - if ( - !(error instanceof ArtifactSnapshotError) || - error.code !== "snapshot_limit" || - entries.length === 1 - ) - throw error; - const middle = Math.ceil(entries.length / 2); - await saveBodies(entries.slice(0, middle)); - await saveBodies(entries.slice(middle)); - return; + try { + let page = await reader.start( + { artifact: "current", kinds: [45010], "#type": [type] }, + signal, + ); + const token = page.token; + const seen = new Set(); + const cursors = new Set(); + async function saveBodies( + entries: readonly ArtifactManifestEntry[], + ): Promise { + if (!entries.length) return; + let events: readonly RelayEvent[]; + try { + events = await reader.bodies(token, entries, signal); + } catch (error) { + if ( + !(error instanceof ArtifactSnapshotError) || + error.code !== "snapshot_limit" || + entries.length === 1 + ) + throw error; + const middle = Math.ceil(entries.length / 2); + await saveBodies(entries.slice(0, middle)); + await saveBodies(entries.slice(middle)); + return; + } + signal.throwIfAborted(); + const records = events.map((event) => receivedArtifact(event, type)); + await collection.applyBatch( + { + source, + snapshot, + records, + availability: records.map((record) => ({ + id: record.id, + expectedRevision: record.revision.id, + value: { state: "available" as const }, + })), + }, + signal, + ); + signal.throwIfAborted(); } - signal.throwIfAborted(); - const records = events.map((event) => receivedArtifact(event, type)); - await collection.applyBatch({ - source, - snapshot, - records, - availability: records.map((record) => ({ - id: record.id, - expectedRevision: record.revision.id, - value: { state: "available" as const }, - })), - }); - signal.throwIfAborted(); - } - while (true) { - signal.throwIfAborted(); - const cached: { id: string; revisionId: string }[] = []; - const missing: ArtifactManifestEntry[] = []; - for (const entry of page.entries) { - if ( - seen.has(entry.artifactId) || - seen.size >= ARTIFACT_SNAPSHOT_LIMITS.maxRows - ) - throw new Error("Artifact manifest repeats or exceeds capacity"); - seen.add(entry.artifactId); - const saved = await collection.get(entry.artifactId); + while (true) { signal.throwIfAborted(); - const revision = saved?.record.current; - if (revision?.id === entry.revisionId && revision.event) { - const event = eventDto(revision.event); - const record = receivedArtifact(event, type); + const cached: { id: string; revisionId: string }[] = []; + const missing: ArtifactManifestEntry[] = []; + for (const entry of page.entries) { if ( - record.id !== entry.artifactId || - record.channelId !== entry.channelId || - event.content !== revision.content + seen.has(entry.artifactId) || + seen.size >= ARTIFACT_SNAPSHOT_LIMITS.maxRows ) - throw new Error("Saved artifact does not match its revision"); - cached.push({ id: entry.artifactId, revisionId: entry.revisionId }); - } else missing.push(entry); + throw new Error("Artifact manifest repeats or exceeds capacity"); + seen.add(entry.artifactId); + const saved = await collection.get(entry.artifactId); + signal.throwIfAborted(); + const revision = saved?.record.current; + if (revision?.id === entry.revisionId && revision.event) { + const event = eventDto(revision.event); + const record = receivedArtifact(event, type); + if ( + record.id !== entry.artifactId || + record.channelId !== entry.channelId || + event.content !== revision.content + ) + throw new Error("Saved artifact does not match its revision"); + cached.push({ id: entry.artifactId, revisionId: entry.revisionId }); + } else missing.push(entry); + } + if (cached.length) + await collection.applyBatch( + { + source, + snapshot, + seen: cached, + availability: cached.map((entry) => ({ + id: entry.id, + expectedRevision: entry.revisionId, + value: { state: "available" as const }, + })), + }, + signal, + ); + for ( + let offset = 0; + offset < missing.length; + offset += ARTIFACT_SNAPSHOT_LIMITS.bodyRows + ) + await saveBodies( + missing.slice(offset, offset + ARTIFACT_SNAPSHOT_LIMITS.bodyRows), + ); + if (page.nextToken === null) break; + if (cursors.has(page.nextToken)) + throw new Error("Artifact snapshot page cursor repeats"); + cursors.add(page.nextToken); + page = await reader.page(token, page.nextToken, signal); } - if (cached.length) - await collection.applyBatch({ - source, + await reader.complete(token, signal); + signal.throwIfAborted(); + await collection.completeSnapshot( + snapshot, + { expected, next: token }, + signal, + ); + } catch (error) { + const permanent = + error instanceof ArtifactSnapshotError && + [ + "snapshot_limit", + "snapshot_unsupported_scope", + "snapshot_invalid", + ].includes(error.code); + if (!signal.aborted && (options.reportFailure || permanent)) { + await collection.setRefreshError( snapshot, - seen: cached, - availability: cached.map((entry) => ({ - id: entry.id, - expectedRevision: entry.revisionId, - value: { state: "available" as const }, - })), - }); - for ( - let offset = 0; - offset < missing.length; - offset += ARTIFACT_SNAPSHOT_LIMITS.bodyRows - ) - await saveBodies( - missing.slice(offset, offset + ARTIFACT_SNAPSHOT_LIMITS.bodyRows), + error instanceof ArtifactSnapshotError && + error.code === "snapshot_limit" + ? "The relay limit prevents a refresh of saved data" + : "Could not refresh saved data", + signal, ); - if (page.nextToken === null) break; - if (cursors.has(page.nextToken)) - throw new Error("Artifact snapshot page cursor repeats"); - cursors.add(page.nextToken); - page = await reader.page(token, page.nextToken, signal); + } + throw error; } - await reader.complete(token, signal); - await beforeComplete(); - signal.throwIfAborted(); - await collection.completeSnapshot( - snapshot, - { expected, next: token }, - signal, - ); + await options.onComplete?.(); } diff --git a/src/features/relay/artifact-sync.test.ts b/src/features/relay/artifact-sync.test.ts index 4d5f6b5f8c..584e2717ce 100644 --- a/src/features/relay/artifact-sync.test.ts +++ b/src/features/relay/artifact-sync.test.ts @@ -5,6 +5,8 @@ import type { OpenArtifactCollection, } from "../artifact-storage/service"; import { provideArtifactSync } from "./artifact-sync"; +import { ArtifactSnapshotError } from "./artifact-snapshot"; +import { registerArtifactDelivery } from "./artifact-delivery"; import { reconcileArtifactSnapshot } from "./artifact-reconcile"; import { keypair, signed } from "./testing"; import { eventDto, type RelayEvent } from "./events"; @@ -106,6 +108,7 @@ function setup() { for (const listener of listeners) listener(); }; return { + session, add, confirmed: (event: RelayEvent) => confirmed([event]), connection, @@ -145,7 +148,7 @@ it("aborts reconciliation on disconnect and starts a new capture on reconnect", await vi.advanceTimersByTimeAsync(0); expect(reconcileArtifactSnapshot).toHaveBeenCalledTimes(2); }); -it("commits proven local acceptance through the same revision path as live records", async () => { +it("refreshes after proven acceptance without writing raw revisions", async () => { const h = setup(); const entry = h.add(); await vi.advanceTimersByTimeAsync(0); @@ -162,16 +165,8 @@ it("commits proven local acceptance through the same revision path as live recor }); h.confirmed(eventDto(event)); await vi.advanceTimersByTimeAsync(0); - expect(entry.collection.applyBatch).toHaveBeenCalledWith( - expect.objectContaining({ - records: [ - expect.objectContaining({ - id: "task", - revision: expect.objectContaining({ id: event.id }), - }), - ], - }), - ); + expect(entry.collection.applyBatch).not.toHaveBeenCalled(); + expect(reconcileArtifactSnapshot).toHaveBeenCalledTimes(2); }); it("ignores channel activity but repairs when roster membership changes", async () => { @@ -203,3 +198,79 @@ it("releases an expired version so a current collection can own sync", async () await vi.advanceTimersByTimeAsync(0); expect(navigator.locks.request).toHaveBeenCalledTimes(2); }); + +it("coalesces live invalidations during a capture into one later refresh", async () => { + const h = setup(); + let release = () => {}; + vi.mocked(reconcileArtifactSnapshot).mockImplementationOnce(async () => { + await new Promise((resolve) => { + release = resolve; + }); + }); + const entry = h.add(); + await vi.advanceTimersByTimeAsync(0); + const event = eventDto( + signed(keypair(), { + kind: 45010, + content: "{}", + tags: [["type", "test.task"]], + }), + ); + for (let i = 0; i < 50; i++) h.confirmed(event); + expect(reconcileArtifactSnapshot).toHaveBeenCalledOnce(); + release(); + await vi.advanceTimersByTimeAsync(0); + expect(reconcileArtifactSnapshot).toHaveBeenCalledTimes(2); + expect(entry.collection.applyBatch).not.toHaveBeenCalled(); +}); + +it("keeps capture admission failures on the repair interval despite live traffic", async () => { + const h = setup(); + vi.mocked(reconcileArtifactSnapshot).mockRejectedValue( + new ArtifactSnapshotError("snapshot_limit", "Too many memberships"), + ); + h.add(); + await vi.advanceTimersByTimeAsync(0); + const event = eventDto( + signed(keypair(), { + kind: 45010, + content: "{}", + tags: [["type", "test.task"]], + }), + ); + for (let i = 0; i < 20; i++) { + h.confirmed(event); + await vi.advanceTimersByTimeAsync(1); + } + expect(reconcileArtifactSnapshot).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(60_000); + expect(reconcileArtifactSnapshot).toHaveBeenCalledTimes(2); +}); +it("captures accepted delivery before starting a snapshot and confirms only after completion", async () => { + const h = setup(); + const capture = vi.fn(async () => ["accepted-before-start"]); + const complete = vi.fn(async () => {}); + registerArtifactDelivery(h.session, { capture, complete }); + let finish = () => {}; + vi.mocked(reconcileArtifactSnapshot).mockImplementationOnce( + async (_reader, _collection, _type, _signal, options) => { + expect(capture).toHaveBeenCalledWith( + "test.task", + JSON.stringify(["test.plugin", "tasks"]), + ); + await new Promise((resolve) => { + finish = resolve; + }); + await options?.onComplete?.(); + }, + ); + h.add(); + await vi.advanceTimersByTimeAsync(0); + expect(complete).not.toHaveBeenCalled(); + finish(); + await vi.advanceTimersByTimeAsync(0); + expect(complete).toHaveBeenCalledWith( + ["accepted-before-start"], + expect.any(AbortSignal), + ); +}); diff --git a/src/features/relay/artifact-sync.ts b/src/features/relay/artifact-sync.ts index e80157ad0e..3e895fe367 100644 --- a/src/features/relay/artifact-sync.ts +++ b/src/features/relay/artifact-sync.ts @@ -5,10 +5,9 @@ import type { } from "../artifact-storage/service"; import { hasEventProof } from "./events"; import type { RelaySession } from "./session"; -import { - reconcileArtifactSnapshot, - receivedArtifact, -} from "./artifact-reconcile"; +import { reconcileArtifactSnapshot } from "./artifact-reconcile"; +import { ArtifactSnapshotError } from "./artifact-snapshot"; +import { captureArtifactDelivery } from "./artifact-delivery"; import { transportPartition } from "./partition"; import { RECOVERY_DELAYS_MS } from "./recovery"; @@ -102,9 +101,9 @@ export function provideArtifactSync( let settled = Promise.resolve(); let requested = true; let retry = 0; + let notBefore = 0; let timer: ReturnType | undefined; let batch: AbortController | undefined; - let liveWrites = Promise.resolve(); const active = () => { const now = ctx.relay.snapshot(); return ( @@ -126,6 +125,10 @@ export function provideArtifactSync( const request = () => { requested = true; if (!running) { + if (Date.now() < notBefore) { + schedule(notBefore - Date.now()); + return; + } clearTimeout(timer); timer = undefined; void pump(); @@ -150,15 +153,25 @@ export function provideArtifactSync( for (const entry of [...group.entries].slice(0, 1)) { if (entry.signal.aborted) continue; const operation = AbortSignal.any([signal, entry.signal]); + const completeDelivery = await captureArtifactDelivery( + current, + entry.definition.type, + JSON.stringify([entry.owner, entry.definition.name]), + ); + operation.throwIfAborted(); await reconcileArtifactSnapshot( reader, entry.collection, entry.definition.type, operation, - () => liveWrites, + { + reportFailure: retry >= 3, + onComplete: () => completeDelivery(operation), + }, ); } retry = 0; + notBefore = 0; } catch (error) { if ( error instanceof Error && @@ -169,9 +182,18 @@ export function provideArtifactSync( } if (!signal.aborted) { retry++; - if (retry <= 3) { + const permanent = + error instanceof ArtifactSnapshotError && + [ + "snapshot_limit", + "snapshot_unsupported_scope", + "snapshot_invalid", + ].includes(error.code); + if (retry <= 3 && !permanent) { requested = true; schedule(RECOVERY_DELAYS_MS[retry - 1] ?? REPAIR_MS); + } else { + notBefore = Date.now() + REPAIR_MS; } } } finally { @@ -236,53 +258,18 @@ export function provideArtifactSync( const receive = ( events: readonly import("./events").RelayEvent[], ) => { - for (const entry of [...group.entries].slice(0, 1)) { - const records = events.filter( + if ( + events.some( (event) => hasEventProof(event) && - event.kind === 45010 && - event.tags.some( - ([name, value]) => - name === "type" && value === entry.definition.type, - ), - ); - if (!records.length) continue; - const source = JSON.stringify({ - artifact: "current", - kinds: [45010], - "#type": [entry.definition.type], - }); - liveWrites = liveWrites - .then(async () => { - if (entry.signal.aborted || group.lifetime.signal.aborted) - return; - const received = records.map((event) => - receivedArtifact(event, entry.definition.type), - ); - await entry.collection.applyBatch({ - source, - records: received, - availability: received.map((record) => ({ - id: record.id, - expectedRevision: record.revision.id, - value: { - state: record.revision.event?.tags.some( + ([45011, 9005, 44100, 44101].includes(event.kind) || + (event.kind === 45010 && + [...group.entries].some((entry) => + event.tags.some( ([name, value]) => - name === "op" && value === "delete", - ) - ? ("deleted" as const) - : ("available" as const), - }, - })), - }); - }) - .catch(() => { - request(); - }); - } - if ( - events.some((event) => - [45011, 9005, 44100, 44101].includes(event.kind), + name === "type" && value === entry.definition.type, + ), + ))), ) ) request(); @@ -323,7 +310,6 @@ export function provideArtifactSync( stopRoster(); group.request = () => {}; await settled; - await liveWrites; } } ctx.effect(() => () => { diff --git a/src/features/relay/managed-delivery.test.ts b/src/features/relay/managed-delivery.test.ts index 17bc4be524..a86b38d90c 100644 --- a/src/features/relay/managed-delivery.test.ts +++ b/src/features/relay/managed-delivery.test.ts @@ -444,3 +444,185 @@ it("keeps retry backoff when a stale journal record has no attempt delay", async await ticks(1000); expect(h.publish).toHaveBeenCalledTimes(2); }); + +const artifactCollection = { owner: "nexus", name: "tasks" }; +const artifactCollectionKey = JSON.stringify(["nexus", "tasks"]); +const artifactInput = { + kind: 45010, + content: '{"title":"Attempted title"}', + tags: [ + ["ar", "1"], + ["d", "task-id"], + ["h", recipient.channelId], + ["type", "test.task"], + ["op", "create"], + ], +}; +it("keeps accepted edits until a capture starts after acceptance and preserves later pending edits", async () => { + vi.useFakeTimers(); + const storage = memory(); + const h = setup(storage); + const first = await h.delivery.submit(artifactInput, undefined, { + operationKey: "first", + collection: artifactCollection, + }); + const beforeAcceptance = await h.artifactDelivery.capture( + "test.task", + artifactCollectionKey, + ); + await h.managedOwner(true); + await ticks(1); + expect(await h.delivery.artifact("task-id")).toMatchObject([ + { confirmed: false, state: { relayAccepted: true } }, + ]); + await h.artifactDelivery.complete( + beforeAcceptance, + new AbortController().signal, + ); + expect(await h.delivery.artifact("task-id")).toMatchObject([ + { confirmed: false }, + ]); + expect( + await h.artifactDelivery.capture( + "test.task", + JSON.stringify(["other", "tasks"]), + ), + ).toEqual([]); + const captured = await h.artifactDelivery.capture( + "test.task", + artifactCollectionKey, + ); + expect(captured).toEqual([first.eventId]); + const second = await h.delivery.submit( + { ...artifactInput, content: '{"title":"Later edit"}' }, + undefined, + { operationKey: "second", collection: artifactCollection }, + ); + await ticks(1); + await h.artifactDelivery.complete(captured, new AbortController().signal); + expect(await h.delivery.artifact("task-id")).toMatchObject([ + { + confirmed: false, + template: { content: '{"title":"Later edit"}' }, + state: { eventId: second.eventId }, + }, + { confirmed: true, state: { eventId: first.eventId } }, + ]); + h.dispose(); + const restarted = setup(storage); + expect(await restarted.delivery.artifact("task-id")).toMatchObject([ + { confirmed: false }, + { confirmed: true }, + ]); + await restarted.managedOwner(true); + await ticks(60_000); + expect(restarted.publish).not.toHaveBeenCalled(); + expect(await restarted.delivery.get(second.eventId)).toMatchObject({ + phase: "received", + relayAccepted: true, + }); +}); +it("recovers a retained caller key after restart and rejects different content", async () => { + vi.useFakeTimers(); + const storage = memory(); + const h = setup(storage); + const options = { + operationKey: "setup/task-id", + collection: artifactCollection, + }; + const first = await h.delivery.submit(artifactInput, undefined, options); + h.dispose(); + await ticks(2_000); + const restarted = setup(storage); + expect(await restarted.delivery.lookup(options.operationKey)).toMatchObject({ + operationId: first.operationId, + }); + expect( + await restarted.delivery.submit(artifactInput, undefined, options), + ).toEqual(first); + await expect( + restarted.delivery.submit( + { ...artifactInput, content: "changed" }, + undefined, + options, + ), + ).rejects.toThrow("different artifact content"); + expect(restarted.outbox.snapshot()).toHaveLength(1); +}); +it("retains a refused artifact value and the relay error without automatic resubmission", async () => { + vi.useFakeTimers(); + const h = setup(memory(), async () => { + throw new PublishRejected("Artifact conflict"); + }); + await h.delivery.submit(artifactInput); + await h.managedOwner(true); + await ticks(60_000); + expect(h.publish).toHaveBeenCalledOnce(); + expect(await h.delivery.artifact("task-id")).toMatchObject([ + { + confirmed: false, + template: { content: artifactInput.content }, + state: { phase: "failed", error: "Retry blocked: Artifact conflict" }, + }, + ]); +}); +it("does not reconcile a pending edit after capture cancellation", async () => { + vi.useFakeTimers(); + const h = setup(); + await h.delivery.submit(artifactInput); + await h.managedOwner(true); + await ticks(1); + const ids = await h.artifactDelivery.capture( + "test.task", + artifactCollectionKey, + ); + const controller = new AbortController(); + controller.abort(); + await expect( + h.artifactDelivery.complete(ids, controller.signal), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(await h.delivery.artifact("task-id")).toMatchObject([ + { confirmed: false }, + ]); +}); + +it("keeps acceptance and the attempted value when saving capture confirmation fails", async () => { + vi.useFakeTimers(); + const storage = memory(); + let fail = false; + const h = setup({ + ...storage, + save: (records) => { + if (fail) throw new Error("Disk full"); + storage.save(records); + }, + }); + await h.delivery.submit(artifactInput); + await h.managedOwner(true); + await ticks(1); + const ids = await h.artifactDelivery.capture( + "test.task", + artifactCollectionKey, + ); + fail = true; + await expect( + h.artifactDelivery.complete(ids, new AbortController().signal), + ).rejects.toThrow("Disk full"); + expect(await h.delivery.artifact("task-id")).toMatchObject([ + { + confirmed: false, + state: { + phase: "failed", + relayAccepted: true, + error: "Could not confirm saved data", + }, + }, + ]); + fail = false; + await h.artifactDelivery.complete(ids, new AbortController().signal); + expect(await h.delivery.artifact("task-id")).toMatchObject([ + { confirmed: true, state: { phase: "received" } }, + ]); + await ticks(60_000); + expect(h.publish).toHaveBeenCalledOnce(); +}); diff --git a/src/features/relay/managed-delivery.ts b/src/features/relay/managed-delivery.ts index b6b0029591..fdd3f96567 100644 --- a/src/features/relay/managed-delivery.ts +++ b/src/features/relay/managed-delivery.ts @@ -1,6 +1,10 @@ export type ManagedRecipient = Readonly<{ channelId: string; agentId: string }>; export type ManagedDelivery = Readonly<{ version: 1; + operationKey?: string; + collection?: string; + artifactConfirmed?: boolean; + submittedAt?: number; recipient?: ManagedRecipient; round: number; attempts: number; @@ -8,7 +12,7 @@ export type ManagedDelivery = Readonly<{ agentReceived: boolean; nextAttemptAt: number; receiptDeadline: number; - error?: "send" | "receipt" | "receipt-save" | undefined; + error?: "send" | "receipt" | "receipt-save" | "artifact-save" | undefined; }>; export type DeliveryState = Readonly<{ operationId: string; @@ -114,10 +118,22 @@ export function managedDelivery(value: unknown): ManagedDelivery { ![raw.round, raw.attempts, raw.nextAttemptAt, raw.receiptDeadline].every( (value) => Number.isSafeInteger(value) && value >= 0, ) || + (raw.operationKey !== undefined && + (typeof raw.operationKey !== "string" || + !raw.operationKey.length || + raw.operationKey.length > 200)) || + (raw.collection !== undefined && + (typeof raw.collection !== "string" || raw.collection.length > 500)) || + (raw.artifactConfirmed !== undefined && + typeof raw.artifactConfirmed !== "boolean") || + (raw.submittedAt !== undefined && + (!Number.isSafeInteger(raw.submittedAt) || raw.submittedAt < 0)) || typeof raw.relayAccepted !== "boolean" || typeof raw.agentReceived !== "boolean" || (raw.error !== undefined && - !["send", "receipt", "receipt-save"].includes(raw.error)) || + !["send", "receipt", "receipt-save", "artifact-save"].includes( + raw.error, + )) || (raw.recipient && (!/^[0-9a-f]{64}$/.test(raw.recipient.agentId) || !/^[a-zA-Z0-9_-]{1,128}$/.test(raw.recipient.channelId))) @@ -137,10 +153,16 @@ export function mergeManagedDelivery( ): ManagedDelivery { if (JSON.stringify(old.recipient) !== JSON.stringify(next.recipient)) throw new Error("Managed recipient changed"); + if ( + old.collection !== next.collection || + old.operationKey !== next.operationKey || + old.submittedAt !== next.submittedAt + ) + throw new Error("Managed operation identity changed"); const current = old.round > next.round ? old : next; const sameRound = old.round === next.round; const error = sameRound - ? (["receipt-save", "receipt", "send"] as const).find( + ? (["artifact-save", "receipt-save", "receipt", "send"] as const).find( (error) => old.error === error || next.error === error, ) : current.error; @@ -150,6 +172,13 @@ export function mergeManagedDelivery( ...current, relayAccepted, agentReceived, + ...(old.artifactConfirmed !== undefined || + next.artifactConfirmed !== undefined + ? { + artifactConfirmed: + old.artifactConfirmed === true || next.artifactConfirmed === true, + } + : {}), attempts: sameRound ? Math.max(old.attempts, next.attempts) : current.attempts, @@ -160,7 +189,12 @@ export function mergeManagedDelivery( ? Math.max(old.receiptDeadline, next.receiptDeadline) : current.receiptDeadline, error, - ...(agentReceived || (!current.recipient && relayAccepted) + ...(agentReceived || + (!current.recipient && + relayAccepted && + (error !== "artifact-save" || + old.artifactConfirmed || + next.artifactConfirmed)) ? { error: undefined } : relayAccepted && error === "send" ? { error: undefined } diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts index 56e1fe4d63..970cd05348 100644 --- a/src/features/relay/outbox.ts +++ b/src/features/relay/outbox.ts @@ -71,7 +71,11 @@ export interface Outbox { }, recovery?: OutboxRecovery, active?: () => boolean, - managed?: Readonly<{ recipient?: ManagedRecipient }>, + managed?: Readonly<{ + recipient?: ManagedRecipient; + operationKey?: string; + collection?: string; + }>, ): string; /** Attach caller-owned recovery to an existing receipt before resuming it. */ recover(id: string, recovery: OutboxRecovery): Promise; @@ -129,9 +133,11 @@ export function createOutbox( needsReceipt = () => false, onReceipt = (_event: EventData, _message: string | undefined) => {}, clock = new MessageClock(), + deliveryScope, }: { /** The session's send clock, shared with its rendered views. */ clock?: MessageClock; + deliveryScope?: string; timeoutMs?: number; /** Commands await their receipt even after a verified echo. Never persisted. */ needsReceipt?: (event: EventData) => boolean; @@ -270,7 +276,7 @@ export function createOutbox( }; const persist = ( id: string, - action?: "acknowledge" | "dismiss" | "receipt", + action?: "acknowledge" | "dismiss" | "receipt" | "artifact", ) => { const queued = profiling.start("outbox.queue", id); const work = durable @@ -285,32 +291,45 @@ export function createOutbox( // Hydration may have added restored intent while this commit waited. // Commit current state so no intermediate transaction erases that intent. const records = - action === "dismiss" - ? visible.filter((item) => item.event.id !== id) - : action === "receipt" - ? visible.map((item) => - item.event.id === id && item.managed - ? { - ...item, - managed: { - ...item.managed, - agentReceived: true, - error: undefined, - }, - } - : item, - ) - : action === "acknowledge" + action === "artifact" + ? visible.map((item) => + item.event.id === id && item.managed?.relayAccepted + ? { + ...item, + managed: { + ...item.managed, + artifactConfirmed: true, + error: undefined, + }, + } + : item, + ) + : action === "dismiss" + ? visible.filter((item) => item.event.id !== id) + : action === "receipt" ? visible.map((item) => - item.event.id === id + item.event.id === id && item.managed ? { ...item, - recovery: undefined, - acknowledged: true as const, + managed: { + ...item.managed, + agentReceived: true, + error: undefined, + }, } : item, ) - : visible; + : action === "acknowledge" + ? visible.map((item) => + item.event.id === id + ? { + ...item, + recovery: undefined, + acknowledged: true as const, + } + : item, + ) + : visible; const bytes = action === "dismiss" ? byteSize(snapshot.filter((item) => item.event.id !== id)) @@ -320,6 +339,19 @@ export function createOutbox( throw new Error("Outbox storage is full"); await storage.save(records); for (const item of records) unsavedManaged.delete(item.event.id); + if (action === "artifact") { + const current = find(id); + if (current?.managed?.relayAccepted) + replace({ + ...current, + managed: { + ...current.managed, + artifactConfirmed: true, + error: undefined, + }, + }); + wakeManaged(); + } if (action === "receipt") { const current = find(id); if (current?.managed) @@ -390,7 +422,9 @@ export function createOutbox( if (item.managed) wakeManaged(); } const managedDone = (state: ManagedDelivery) => - state.relayAccepted && (!state.recipient || state.agentReceived); + state.relayAccepted && + (!state.recipient || state.agentReceived) && + state.artifactConfirmed !== false; function refreshManaged() { const work = durable .catch(() => {}) @@ -478,6 +512,7 @@ export function createOutbox( } if (state.error) continue; if (state.relayAccepted) { + if (!state.recipient) continue; if (state.receiptDeadline <= Date.now()) saveStatus({ ...item, managed: { ...state, error: "receipt" } }); else delay = Math.min(delay, state.receiptDeadline - Date.now()); @@ -914,7 +949,11 @@ export function createOutbox( }, recovery?: OutboxRecovery, active?: () => boolean, - managed?: Readonly<{ recipient?: ManagedRecipient }>, + managed?: Readonly<{ + recipient?: ManagedRecipient; + operationKey?: string; + collection?: string; + }>, ) { if (active && !active()) throw new DOMException("Channel addition cancelled", "AbortError"); @@ -1038,11 +1077,13 @@ export function createOutbox( const prior = visible.find((item) => item.event.id === event.id); if (prior) { if ( + prior.managed?.collection !== managed?.collection || + prior.managed?.operationKey !== managed?.operationKey || !!prior.managed !== !!managed || JSON.stringify(prior.managed?.recipient) !== JSON.stringify(managed?.recipient) ) - throw new Error("Artifact delivery recipient changed"); + throw new Error("Artifact delivery identity changed"); return event.id; } } @@ -1062,6 +1103,17 @@ export function createOutbox( managed: managedDelivery({ version: 1, ...managed, + ...(input.kind === 45010 + ? { + artifactConfirmed: false, + submittedAt: Math.max( + Date.now(), + ...visible.map( + (item) => (item.managed?.submittedAt ?? 0) + 1, + ), + ), + } + : {}), round: 0, attempts: 0, relayAccepted: false, @@ -1188,16 +1240,156 @@ export function createOutbox( outbox.retry(id); await persist(id); }, + async lookup(operationKey: string) { + await refreshManaged(); + const item = visible.find( + (item) => item.managed?.operationKey === operationKey, + ); + return item?.managed + ? deliveryState(item.event.id, item.managed) + : undefined; + }, + async artifact(id: string) { + await refreshManaged(); + return visible + .filter( + (item) => + item.managed && + item.event.kind === 45010 && + item.event.tags.some( + ([name, value]) => name === "d" && value === id, + ), + ) + .sort( + (a, b) => + (b.managed?.submittedAt ?? 0) - (a.managed?.submittedAt ?? 0), + ) + .map((item) => ({ + template: { + kind: item.event.kind, + content: item.event.content, + tags: item.event.tags.map((tag) => [...tag]), + }, + state: { + ...deliveryState(item.event.id, managedDelivery(item.managed)), + ...(item.error && !item.managed?.relayAccepted + ? { error: item.error } + : {}), + ...(item.managed?.error === "artifact-save" + ? { + phase: "failed" as const, + error: "Could not confirm saved data", + } + : {}), + }, + confirmed: item.managed?.artifactConfirmed === true, + })); + }, async submit( input: Parameters[0], recipient?: ManagedRecipient, + options?: { + operationKey?: string; + collection?: { owner: string; name: string }; + }, ) { - await outbox.ready(); - const id = outbox.send(input, undefined, undefined, { - ...(recipient ? { recipient } : {}), - }); - await persist(id); - return { operationId: id, eventId: id }; + const submit = async () => { + await outbox.ready(); + await refreshManaged(); + if (options?.operationKey !== undefined) { + managedDelivery({ + version: 1, + round: 0, + attempts: 0, + relayAccepted: false, + agentReceived: false, + nextAttemptAt: 0, + receiptDeadline: 0, + operationKey: options.operationKey, + }); + const prior = visible.find( + (item) => item.managed?.operationKey === options.operationKey, + ); + if (prior) { + if ( + prior.event.kind !== input.kind || + prior.event.content !== input.content || + JSON.stringify(prior.event.tags) !== + JSON.stringify(input.tags) || + prior.managed?.collection !== + (options.collection + ? JSON.stringify([ + options.collection.owner, + options.collection.name, + ]) + : undefined) + ) + throw new Error("Operation key has different artifact content"); + return { operationId: prior.event.id, eventId: prior.event.id }; + } + } + const id = outbox.send(input, undefined, undefined, { + ...(recipient ? { recipient } : {}), + ...(options?.operationKey + ? { operationKey: options.operationKey } + : {}), + ...(options?.collection + ? { + collection: JSON.stringify([ + options.collection.owner, + options.collection.name, + ]), + } + : {}), + }); + await persist(id); + return { operationId: id, eventId: id }; + }; + if (input.kind === 45010 && deliveryScope) { + if (!globalThis.navigator?.locks) + throw new Error("Artifact delivery requires Web Locks"); + return navigator.locks.request( + `buzz-artifact-submit:${deliveryScope}`, + submit, + ); + } + if (options && input.kind !== 45010) + throw new Error("Operation keys require an artifact"); + return submit(); + }, + }, + artifactDelivery: { + async capture(type: string, collection: string) { + await refreshManaged(); + return visible + .filter( + (item) => + item.event.kind === 45010 && + item.managed?.relayAccepted && + item.managed.artifactConfirmed !== true && + (!item.managed.collection || + item.managed.collection === collection) && + item.event.tags.some( + ([name, value]) => name === "type" && value === type, + ), + ) + .map((item) => item.event.id); + }, + async complete(ids: readonly string[], signal: AbortSignal) { + for (const id of ids) { + signal.throwIfAborted(); + try { + await persist(id, "artifact"); + } catch (error) { + const item = find(id); + if (item?.managed) + replace({ + ...item, + managed: { ...item.managed, error: "artifact-save" }, + }); + throw error; + } + } }, }, async managedOwner(owned: boolean) { diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 45d09ee2eb..486d514cb3 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -1,3 +1,4 @@ +import { registerArtifactDelivery } from "./artifact-delivery"; import type { LocalAgentSource } from "../agents/local-activity"; import { registerManagedDelivery } from "./managed-delivery"; // FOUNDATION: One relay session owns reads, local intent, delivery and shared views. @@ -212,6 +213,7 @@ export function createRelaySession( ? { timeoutMs: options.deliveryTimeoutMs } : {}), profiling, + deliveryScope: transportPartition(transport), clock, notifyListener: notify, onAccepted: (event) => { @@ -1862,7 +1864,13 @@ export function createRelaySession( delivery: writes ? { ...writes.delivery, - async submit(input: Parameters[0]) { + async submit( + input: Parameters[0], + options?: { + operationKey?: string; + collection?: { owner: string; name: string }; + }, + ) { if (input.kind !== 45010) throw new Error("Use message commands for managed messages"); const homes = input.tags.filter(([name]) => name === "h"); @@ -1877,7 +1885,7 @@ export function createRelaySession( throw new Error( "Reconnect and confirm channel access before saving an artifact", ); - return writes.delivery.submit(input); + return writes.delivery.submit(input, undefined, options); }, } : undefined, @@ -2997,6 +3005,7 @@ export function createRelaySession( updateInterests(); refreshChannelActivity(); + if (writes) registerArtifactDelivery(session, writes.artifactDelivery); return { session, async restore() { diff --git a/tests/browser/managed-delivery.spec.mjs b/tests/browser/managed-delivery.spec.mjs index 4b6aeb0b9f..e755724f0c 100644 --- a/tests/browser/managed-delivery.spec.mjs +++ b/tests/browser/managed-delivery.spec.mjs @@ -170,3 +170,49 @@ test("syncs a follower-only collection and transfers a shared collection on wind .preference, ).toEqual({ pinned: false }); }); + +test("serializes caller keys across windows and saves capture confirmation", async ({ + page, + context, + deliveryUrl, +}) => { + const scope = "artifact-operation-key"; + await open(page, deliveryUrl, scope); + const second = await context.newPage(); + await open(second, deliveryUrl, scope); + const [first, duplicate] = await Promise.all([ + page.evaluate(() => window.deliveryTest.submitArtifact()), + second.evaluate(() => window.deliveryTest.submitArtifact()), + ]); + expect(duplicate).toEqual(first); + await expect + .poll(() => page.evaluate(() => window.deliveryTest.artifact())) + .toMatchObject([{ confirmed: false, state: { relayAccepted: true } }]); + const captured = await page.evaluate(() => + window.deliveryTest.captureArtifact(), + ); + expect(captured).toEqual([first.eventId]); + await second.evaluate(async (scope) => { + window.staleStorage = window.deliveryTest.storage(scope); + window.staleRecords = await window.staleStorage.load(); + }, scope); + await page.evaluate( + (ids) => window.deliveryTest.completeArtifact(ids), + captured, + ); + await second.evaluate(() => window.staleStorage.save(window.staleRecords)); + expect( + await second.evaluate(() => window.deliveryTest.artifact()), + ).toMatchObject([{ confirmed: true }]); + await second.reload(); + await second.waitForFunction(() => !!window.deliveryTest); + await second.evaluate((scope) => window.deliveryTest.open(scope), scope); + expect( + await second.evaluate(() => window.deliveryTest.artifact()), + ).toMatchObject([ + { confirmed: true, template: { content: '{"title":"Pending"}' } }, + ]); + expect(await page.evaluate(() => window.deliveryTest.publications)).toEqual([ + first.eventId, + ]); +}); diff --git a/tests/fixtures/managed-delivery.ts b/tests/fixtures/managed-delivery.ts index a8b0924404..3f226d4f61 100644 --- a/tests/fixtures/managed-delivery.ts +++ b/tests/fixtures/managed-delivery.ts @@ -28,6 +28,7 @@ const open = async (scope: string) => { }, }, browserOutboxStorage(scope), + { deliveryScope: scope }, ); await owner.ready; let acquired!: () => void; @@ -146,6 +147,33 @@ Object.assign(window, { delivery: item.delivery, managed: item.managed, })), + submitArtifact: () => + owner.delivery.submit( + { + kind: 45010, + content: '{"title":"Pending"}', + tags: [ + ["ar", "1"], + ["d", "task"], + ["h", "task"], + ["type", "test.delivery"], + ["op", "create"], + ], + }, + undefined, + { + operationKey: "setup/task", + collection: { owner: "test.plugin", name: "main" }, + }, + ), + artifact: () => owner.delivery.artifact("task"), + captureArtifact: () => + owner.artifactDelivery.capture( + "test.delivery", + JSON.stringify(["test.plugin", "main"]), + ), + completeArtifact: (ids: string[]) => + owner.artifactDelivery.complete(ids, new AbortController().signal), get: (id: string) => owner.delivery.get(id), receive: (id: string) => owner.receiveManaged(recipient.channelId, recipient.agentId, [id]), From d836bbdd65788dab178b82dd434a389c6b3a0300 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 15:11:57 -0700 Subject: [PATCH 11/30] Verify artifact enqueue order at one clock tick Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- src/features/relay/managed-delivery.test.ts | 35 +++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/features/relay/managed-delivery.test.ts b/src/features/relay/managed-delivery.test.ts index a86b38d90c..e510d3a0fc 100644 --- a/src/features/relay/managed-delivery.test.ts +++ b/src/features/relay/managed-delivery.test.ts @@ -626,3 +626,38 @@ it("keeps acceptance and the attempted value when saving capture confirmation fa await ticks(60_000); expect(h.publish).toHaveBeenCalledOnce(); }); + +it("keeps newest-first enqueue order when the clock does not advance, including restart", async () => { + vi.useFakeTimers(); + const storage = memory(); + const firstHost = setup(storage); + const now = Date.now(); + const first = await firstHost.delivery.submit(artifactInput, undefined, { + operationKey: "same-ms/first", + collection: artifactCollection, + }); + const second = await firstHost.delivery.submit( + { ...artifactInput, content: '{"title":"Newest"}' }, + undefined, + { operationKey: "same-ms/second", collection: artifactCollection }, + ); + expect(Date.now()).toBe(now); + expect( + (await firstHost.delivery.artifact("task-id")).map( + (entry) => entry.state.eventId, + ), + ).toEqual([second.eventId, first.eventId]); + firstHost.dispose(); + const restarted = setup(storage); + const third = await restarted.delivery.submit( + { ...artifactInput, content: '{"title":"Newest after restart"}' }, + undefined, + { operationKey: "same-ms/third", collection: artifactCollection }, + ); + expect(Date.now()).toBe(now); + expect( + (await restarted.delivery.artifact("task-id")).map( + (entry) => entry.state.eventId, + ), + ).toEqual([third.eventId, second.eventId, first.eventId]); +}); From fac55a0972b823330b042cd05976f29fd1da7944 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 15:41:40 -0700 Subject: [PATCH 12/30] Preserve delivery and live route bounds Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/artifact-sync-delivery.md | 8 +- src/features/relay/artifact-sync.test.ts | 27 +- src/features/relay/artifact-sync.ts | 15 +- src/features/relay/live.test.ts | 122 +++++++ src/features/relay/live.ts | 43 ++- src/features/relay/managed-delivery.test.ts | 356 ++++++++++++++++++++ src/features/relay/outbox.ts | 202 +++++++---- src/features/relay/session.ts | 4 + 8 files changed, 686 insertions(+), 91 deletions(-) diff --git a/docs/artifact-sync-delivery.md b/docs/artifact-sync-delivery.md index ee5e57b321..a52a6de1be 100644 --- a/docs/artifact-sync-delivery.md +++ b/docs/artifact-sync-delivery.md @@ -10,7 +10,7 @@ The client saves validated bodies during capture. It starts with 100 bodies per Each collection has one fixed query. Live events and accepted local writes request a background refresh. They do not write confirmed records. The store applies authoritative snapshot pages in generation order. The client has no revision ancestry graph. Repairs run after reconnect, collection changes, roster changes, and live hints. Channel activity alone does not request repair. A 60-second repair covers homes outside active message routes. Repairs are sequential within a collection. Transient failures get three short retries before the next repair interval. Capture admission failures use the repair interval directly. Live traffic cannot bypass that delay. A failed refresh saves an error after retries. A successful completion clears it. Local reads do not wait for repair. -The relay snapshot PR is a prerequisite. A relay without the capability keeps the saved view. This client does not invent a checkpoint from ordinary query results. +The relay snapshot PR is a prerequisite. A relay without the validated version 1 capability keeps the saved view and rejects new managed artifact intent before persistence. Existing pending work remains saved. Temporary refresh failures do not close this capability gate. This client does not invent a checkpoint from ordinary query results. The relay rejects capture when active memberships exceed 50,000, before query filters. Archived memberships count toward this limit. A narrower query does not remove it. The client retains saved data and pending edits on `snapshot_limit`. Body-batch limits use bounded batch reduction. They do not establish a higher capture limit. @@ -18,13 +18,13 @@ The relay rejects capture when active memberships exceed 50,000, before query fi Large joined rosters use live groups of at most 1,000 channels. Each group stays open while recent replay reads run in groups of ten channels. At most four requests await EOSE. Each replay filter keeps its 500-event limit. EOSE establishes a route; it does not prove that history is complete. A full limit remains incomplete evidence. Existing finite catch-up owns older gaps. The agent runner retains its event-ID checks before dispatch. -Admission counts retained routes. New joined channels fill groups with space. Retired membership scopes remain fenced. The transport accepts at most 100,000 channel interests. Singleton previews consume separate routes. Demand beyond route capacity gets a limited status. Grouped interests preserve ordinary chat and all-channel agent watches. They do not make 100,000 simultaneous agent runs a supported workload. +Admission counts retained routes and reserves one wire for group replacement. An expanded group retains its established subset if setup fails transiently. Only added channels need replay when the scope grows. New joined channels fill groups with space. Retired membership scopes remain fenced. The transport accepts at most 100,000 channel interests. Singleton previews consume separate routes. Demand beyond route capacity gets a limited status. Grouped interests preserve ordinary chat and all-channel agent watches. They do not make 100,000 simultaneous agent runs a supported workload. The relay permits 1,024 subscriptions, ten filters per request, and 2,048 explicit channel values per request. The default frame limit is 524,288 bytes. Browser broker controls and status frames have a separate 32 MiB bound because their local status includes each channel. This bound does not change relay event limits. ## Managed delivery -`session.messagesForRecipient(agentPubkey)` uses the existing message validation and composer path. `EmbeddedThread.managedRecipient` selects this path for a task conversation. Ordinary chat keeps its existing behavior. `session.delivery.submit(input)` admits kind-45010 artifact operations. `delivery.submit(input,{operationKey,collection:{owner,name}})` saves a caller key with artifact intent. Submits serialize across windows. `delivery.lookup(operationKey)` finds that retained intent after a restart. The same key rejects different input. This closes the gap before a caller saves the returned ID. Key lookup lasts while the journal retains the record. +`session.messagesForRecipient(agentPubkey)` uses the existing message validation and composer path. `EmbeddedThread.managedRecipient` selects this path for a task conversation. Ordinary chat keeps its existing behavior. `session.delivery.submit(input)` admits kind-45010 artifact operations. `delivery.submit(input,{operationKey,collection:{owner,name}})` saves a caller key with artifact intent. Submits serialize across windows. `delivery.lookup(operationKey)` finds that retained intent after a restart. The same key rejects different input. A retained key saves any unsaved intent before it returns success. This closes the gap before a caller saves the returned ID. Key lookup lasts while the journal retains the record. `delivery.artifact(id)` returns bounded, newest-first entries with `template`, `state`, and `confirmed`. Unconfirmed edits remain in the pending journal. They do not enter the bounded completed cache. A failed publication retains the attempted value and relay error. The client does not rebase stale writes automatically. A caller can display these entries over confirmed rows. @@ -32,6 +32,8 @@ Before each capture starts, sync freezes the accepted operation IDs for that col `delivery.get(id)` is asynchronous. `delivery.subscribe(listener)` reports durable state changes. +Artifact intent and other outgoing intent each have a 256-record and 2 MiB pending quota. The combined pending bound is 512 records and 4 MiB. Completed retention stays at 2,048 records and 8 MiB. Artifact exhaustion does not consume chat admission. New writes reserve their signed envelope before enqueue. Restored rows use saved bytes. Signing checks the resulting row before it replaces pending state. If retry status in one full class cannot save, a commit for the other class retains the saved rows of that class. + The outbox saves intent and the signed event before publication. Artifact events keep their supplied envelope tags. Identical submissions with the same signed event ID share one operation. It retains the same event ID across uncertain retries. The owner makes at most three attempts before an error. A definitive relay refusal stops automatic retries. An explicit retry clears the refusal. A settled refusal can be discarded. Acceptance and exact agent receipt are separate saved facts. Stale writes from another window cannot clear either fact. Within one retry round, merges preserve receipt deadlines, retry delays, and errors. A new round permits an explicit retry. `AgentRun.received(messageIds)` reports only the exact original message IDs. The host binds the channel and agent identity. The plugin calls it at a verified harness receipt boundary. Process start, typing, and an unrelated reply do not prove receipt. The host saves receipt before it clears the opacity pulse. A save failure keeps accepted evidence and exposes an unconfirmed state. diff --git a/src/features/relay/artifact-sync.test.ts b/src/features/relay/artifact-sync.test.ts index 584e2717ce..1569e6e139 100644 --- a/src/features/relay/artifact-sync.test.ts +++ b/src/features/relay/artifact-sync.test.ts @@ -90,14 +90,14 @@ function setup() { ctx as unknown as Context, storage as unknown as ArtifactStorageService, ); - const add = () => { + const add = (version = 1) => { const controller = new AbortController(); const collection = { applyBatch: vi.fn(async () => {}) }; opened({ viewer: "viewer", relay: "https://relay.test", owner: "test.plugin", - definition: { name: "tasks", type: "test.task" }, + definition: { name: "tasks", type: "test.task", version }, collection, signal: controller.signal, } as unknown as OpenArtifactCollection); @@ -274,3 +274,26 @@ it("captures accepted delivery before starting a snapshot and confirms only afte expect.any(AbortSignal), ); }); + +it("keeps a current handle when an older in-flight version fails", async () => { + const h = setup(); + let fail = () => {}; + vi.mocked(reconcileArtifactSnapshot).mockImplementationOnce(async () => { + await new Promise((_resolve, reject) => { + fail = () => reject(new Error("Artifact collection version changed")); + }); + }); + const old = h.add(1); + await vi.advanceTimersByTimeAsync(0); + const current = h.add(2); + fail(); + await vi.advanceTimersByTimeAsync(0); + expect(reconcileArtifactSnapshot).toHaveBeenCalledTimes(2); + expect(vi.mocked(reconcileArtifactSnapshot).mock.calls[1]?.[1]).toBe( + current.collection, + ); + old.controller.abort(); + await vi.advanceTimersByTimeAsync(60_000); + expect(reconcileArtifactSnapshot).toHaveBeenCalledTimes(3); + expect(navigator.locks.request).toHaveBeenCalledOnce(); +}); diff --git a/src/features/relay/artifact-sync.ts b/src/features/relay/artifact-sync.ts index 3e895fe367..e85373dc6e 100644 --- a/src/features/relay/artifact-sync.ts +++ b/src/features/relay/artifact-sync.ts @@ -149,9 +149,11 @@ export function provideArtifactSync( requested = false; batch = new AbortController(); const signal = AbortSignal.any([group.lifetime.signal, batch.signal]); + let syncing: OpenArtifactCollection | undefined; try { for (const entry of [...group.entries].slice(0, 1)) { if (entry.signal.aborted) continue; + syncing = entry; const operation = AbortSignal.any([signal, entry.signal]); const completeDelivery = await captureArtifactDelivery( current, @@ -177,10 +179,15 @@ export function provideArtifactSync( error instanceof Error && error.message === "Artifact collection version changed" ) { - group.lifetime.abort(); - if (groups.get(group.key) === group) groups.delete(group.key); - } - if (!signal.aborted) { + if (syncing) group.entries.delete(syncing); + if (!group.entries.size) { + group.lifetime.abort(); + if (groups.get(group.key) === group) groups.delete(group.key); + } else { + retry = 0; + requested = true; + } + } else if (!signal.aborted) { retry++; const permanent = error instanceof ArtifactSnapshotError && diff --git a/src/features/relay/live.test.ts b/src/features/relay/live.test.ts index 09db957203..6145537ab2 100644 --- a/src/features/relay/live.test.ts +++ b/src/features/relay/live.test.ts @@ -2649,3 +2649,125 @@ it("fills retained groups as joined homes arrive in separate roster updates", as ).toBe(true); h.owner.dispose(); }); + +it("replays only added homes when an established large group grows", async () => { + vi.useFakeTimers(); + const ids = Array.from({ length: 1023 }, (_, i) => `growth-${i}`); + const h = setup([]); + h.owner.update(ids, ids); + await h.first.auth(); + let cursor = 0; + const replayed = new Set(); + async function drain() { + while (cursor < h.first.sent.length) { + const frame = h.first.sent[cursor++]; + if (frame?.[0] !== "REQ") continue; + const req = frame as WireRequest; + if (filtersOf(req)[0]?.limit !== 0) + for (const id of scopeOf(req)) { + expect(replayed.has(id)).toBe(false); + replayed.add(id); + } + await h.first.receive(["EOSE", req[1]]); + } + } + await drain(); + expect(replayed.size).toBe(1023); + for (let i = 0; i < 20; i++) { + ids.push(`added-${i}`); + h.owner.update(ids, ids); + await drain(); + } + expect(replayed.size).toBe(1043); + expect( + h.callbacks.state.mock.lastCall?.[0].routes.every( + (route) => route.status === "live", + ), + ).toBe(true); + h.owner.dispose(); +}); +it("keeps the established subset live after expansion timeout and recovers automatically", async () => { + vi.useFakeTimers(); + const ids = Array.from({ length: 1100 }, (_, i) => `timeout-${i}`); + const h = setup([]); + h.owner.update(ids, ids); + await h.first.auth(); + let cursor = 0; + while (cursor < h.first.sent.length) { + const frame = h.first.sent[cursor++]; + if (frame?.[0] === "REQ") await h.first.receive(["EOSE", frame[1]]); + } + const old = h.first + .requests() + .find( + (req) => + filtersOf(req)[0]?.limit === 0 && scopeOf(req).includes("timeout-1000"), + ); + assert.exists(old); + ids.push("added-timeout"); + h.owner.update(ids, ids); + await vi.advanceTimersByTimeAsync(10_000); + expect( + h.first.sent.some((frame) => frame[0] === "CLOSE" && frame[1] === old[1]), + ).toBe(false); + const state = h.callbacks.state.mock.lastCall?.[0]; + expect( + state?.routes.find((route) => route.channelId === "timeout-1000")?.status, + ).toBe("live"); + expect( + state?.routes.find((route) => route.channelId === "added-timeout")?.status, + ).toBe("error"); + await h.first.receive([ + "EVENT", + old[1], + message(h.key, "timeout-1000", "During recovery", 1700000000), + ]); + expect(h.callbacks.receive).toHaveBeenCalledOnce(); + const before = h.first.requests().length; + await vi.advanceTimersByTimeAsync(50_000); + const retry = h.first + .requests() + .slice(before) + .find((req) => scopeOf(req).includes("added-timeout")); + assert.exists(retry); + await h.first.receive(["EOSE", retry[1]]); + expect( + h.callbacks.state.mock.lastCall?.[0].routes.find( + (route) => route.channelId === "added-timeout", + )?.status, + ).toBe("live"); + h.owner.dispose(); +}); +it("reserves a wire for group expansion near subscription capacity", async () => { + vi.useFakeTimers(); + const previews = Array.from({ length: 1021 }, (_, i) => `preview-${i}`); + const h = setup([]); + h.owner.update(["joined-a", ...previews], ["joined-a"]); + await h.first.auth(); + let cursor = 0; + const open = new Set(); + let maximum = 0; + async function drain() { + while (cursor < h.first.sent.length) { + const frame = h.first.sent[cursor++]; + if (frame?.[0] === "CLOSE") open.delete(frame[1] as string); + if (frame?.[0] !== "REQ") continue; + open.add(frame[1] as string); + maximum = Math.max(maximum, open.size); + await h.first.receive(["EOSE", frame[1]]); + } + } + await drain(); + h.owner.update( + ["joined-a", "joined-b", ...previews], + ["joined-a", "joined-b"], + ); + await drain(); + expect(maximum).toBeLessThanOrEqual(1024); + expect( + h.callbacks.state.mock.lastCall?.[0].routes.find( + (route) => route.channelId === "joined-b", + )?.status, + ).toBe("live"); + h.owner.dispose(); +}); diff --git a/src/features/relay/live.ts b/src/features/relay/live.ts index 4415e58008..089ae85d3c 100644 --- a/src/features/relay/live.ts +++ b/src/features/relay/live.ts @@ -262,17 +262,27 @@ export function subscribeRelayTraffic( routes: Object.freeze( [...routes.values()] .filter((route) => !route.historyParent) - .flatMap(({ id, channelId, channelIds, status, replay, error }) => - (channelIds ?? [channelId]).map((channelId) => - Object.freeze({ + .flatMap((route) => { + const { id, channelId, channelIds, status, replay, error } = + route; + return (channelIds ?? [channelId]).map((channelId) => { + const retained = + channelId && + route.previous?.status === "live" && + route.previous.wire && + scope(route.previous).length < scope(route).length && + scope(route.previous).includes(channelId); + return Object.freeze({ id: channelId ? `channel:${channelId}` : id, ...(channelId ? { channelId } : {}), - status, - replay, - ...(error ? { error } : {}), - }), - ), - ), + status: retained ? ("live" as const) : status, + replay: retained + ? (route.previous?.replay ?? replay) + : replay, + ...(!retained && error ? { error } : {}), + }); + }); + }), ), ...(connectionError ? { error: connectionError } : {}), }), @@ -311,7 +321,8 @@ export function subscribeRelayTraffic( // through repeated removals; retire the pending intermediate, not a survivor. if ( route.replayOffset !== undefined && - JSON.stringify(ids) !== JSON.stringify(scope(route)) + JSON.stringify(ids) !== JSON.stringify(scope(route)) && + !scope(route).every((id, index) => ids[index] === id) ) { const history = routes.get(`replay:${route.id}`); if (history) remove(history); @@ -379,6 +390,7 @@ export function subscribeRelayTraffic( LIVE_CHANNEL_CAPACITY - (observer !== null ? 1 : 0) - (presenceAuthors.length ? 1 : 0) - + (joined.length ? 1 : 0) - (staged ? SETUP_CONCURRENCY : 0); const interestSet = new Set(interests); const retained = new Map(); @@ -507,7 +519,9 @@ export function subscribeRelayTraffic( ); if (!extra.length) continue; const next = replace(route, [...route.channelIds, ...extra]); - next.replayOffset = 0; + next.replayOffset = + route.replayOffset ?? + (route.status === "live" ? route.channelIds.length : 0); } for (let i = 0; i < batch.length; i += groupSize) { const route = add( @@ -532,14 +546,14 @@ export function subscribeRelayTraffic( } } const previous = route.previous; - // Only an established, unchanged scope may outlive a failed renewal. Never - // restore a retired channel lifetime or bypass denial/invalid-traffic cleanup. + // A transient failure can retain an established subset. Denial and invalid + // traffic still retire the wire, and removed channels never regain coverage. const retain = transient && previous?.status === "live" && previous.wire && wires.get(previous.wire) === previous && - JSON.stringify(scope(previous)) === JSON.stringify(scope(route)); + scope(previous).every((id) => scope(route).includes(id)); if (retain) delete route.previous; closeWire(route); delete route.retryRenewal; @@ -653,6 +667,7 @@ export function subscribeRelayTraffic( break; } if (wires.size >= 1024) { + if (route.previous?.wire) continue; route.status = "limited"; route.error = "Live subscription capacity reached; retry after demand changes"; diff --git a/src/features/relay/managed-delivery.test.ts b/src/features/relay/managed-delivery.test.ts index e510d3a0fc..2fb3fdae7b 100644 --- a/src/features/relay/managed-delivery.test.ts +++ b/src/features/relay/managed-delivery.test.ts @@ -1,3 +1,4 @@ +import { getEventHash } from "nostr-tools"; import { afterEach, assert, expect, it, vi } from "vitest"; import { createOutbox, @@ -5,6 +6,8 @@ import { type OutboxStorage, type OutgoingEvent, } from "./outbox"; +import { createRelaySession } from "./session"; +import { scriptedTransport } from "./testing"; import { MANAGED_RECEIPT_MS } from "./managed-delivery"; import { keypair, signed } from "./testing"; import type { RelayWriter } from "./transport"; @@ -661,3 +664,356 @@ it("keeps newest-first enqueue order when the clock does not advance, including ), ).toEqual([third.eventId, second.eventId, first.eventId]); }); + +it("saves retained keyed intent after storage recovers before returning its original ID", async () => { + vi.useFakeTimers(); + const storage = memory(); + let fail = true; + const follower = setup({ + ...storage, + save: (records) => { + if (fail) throw new Error("Disk full"); + storage.save(records); + }, + }); + const options = { + operationKey: "unsaved-key", + collection: artifactCollection, + }; + await expect( + follower.delivery.submit(artifactInput, undefined, options), + ).rejects.toThrow("Disk full"); + expect(storage.load()).toHaveLength(0); + fail = false; + const recovered = await follower.delivery.submit( + artifactInput, + undefined, + options, + ); + const otherWindow = setup(storage); + expect(await otherWindow.delivery.lookup(options.operationKey)).toMatchObject( + { operationId: recovered.operationId }, + ); + expect(storage.load()).toHaveLength(1); +}); +it("rejects unsupported artifact writes before saving intent", async () => { + const storage = memory(); + const t = scriptedTransport(viewer.pubkey, "relay"); + const h = createRelaySession( + { + ...t.transport, + writer: { + sign: async (template) => signed(viewer, template), + publish: async () => {}, + }, + }, + { outboxStorage: storage }, + ); + try { + await expect(h.session.delivery?.submit(artifactInput)).rejects.toThrow( + "requires relay snapshot support", + ); + expect(storage.load()).toHaveLength(0); + } finally { + h.dispose(); + } +}); +it("restores a full artifact journal without blocking normal chat admission", async () => { + vi.useFakeTimers(); + const storage = memory(); + const first = setup(storage); + for (let i = 0; i < 256; i++) + await first.delivery.submit({ + ...artifactInput, + content: String(i), + tags: artifactInput.tags.map((tag) => + tag[0] === "d" ? ["d", `task-${i}`] : tag, + ), + }); + first.dispose(); + const restored = setup(storage); + await restored.outbox.ready(); + await expect( + restored.delivery.submit({ ...artifactInput, content: "over capacity" }), + ).rejects.toThrow("Too many outstanding"); + const id = restored.outbox.send(input); + expect(restored.outbox.snapshot()).toHaveLength(257); + await ticks(1); + expect(storage.load().some((item) => item.event.id === id)).toBe(true); + expect( + storage.load().filter((item) => item.event.kind === 45010), + ).toHaveLength(256); +}); + +it.each([45010, 9])( + "isolates byte exhaustion in kind %s from the other class", + async (kind) => { + vi.useFakeTimers(); + const storage = memory(); + const h = setup(storage); + const template = kind === 45010 ? artifactInput : input; + const content = "x".repeat(kind === 45010 ? 400_000 : 30_000); + let count = 0; + for (; count < 256; count++) { + try { + await h.delivery.submit({ + ...template, + content: content + String(count), + }); + } catch (error) { + expect(String(error)).toContain("Outbox storage is full"); + break; + } + } + expect(count).toBeGreaterThan(0); + expect(count).toBeLessThan(256); + expect(storage.load()).toHaveLength(count); + const other = await h.delivery.submit( + kind === 45010 ? input : artifactInput, + ); + expect(storage.load()).toHaveLength(count + 1); + expect(storage.load().some((item) => item.event.id === other.eventId)).toBe( + true, + ); + h.dispose(); + const restored = setup(storage); + await restored.outbox.ready(); + expect(restored.local.snapshot()).toHaveLength(count + 1); + }, +); +it("preserves artifact intent while completed message retention is full", async () => { + vi.useFakeTimers(); + const storage = memory(); + const records: OutgoingEvent[] = Array.from({ length: 2048 }, (_, i) => { + const event = { + kind: 9, + content: `Complete ${i}`, + tags: [["h", recipient.channelId]], + pubkey: viewer.pubkey, + created_at: 1, + }; + return { + event: { ...event, id: getEventHash(event) }, + delivery: "accepted", + managed: { + version: 1, + round: 0, + attempts: 1, + relayAccepted: true, + agentReceived: false, + nextAttemptAt: 0, + receiptDeadline: 0, + }, + }; + }); + storage.save(records); + const h = setup(storage); + const artifact = await h.delivery.submit(artifactInput); + const message = await h.delivery.submit(input, recipient); + await h.managedOwner(true); + await ticks(1); + await h.receiveManaged(recipient.channelId, recipient.agentId, [ + message.eventId, + ]); + await ticks(1); + expect(await h.delivery.artifact("task-id")).toMatchObject([ + { confirmed: false, state: { eventId: artifact.eventId } }, + ]); + expect( + h.local.snapshot().filter((item) => item.event.kind === 9), + ).toHaveLength(2048); + expect( + h.local.snapshot().some((item) => item.event.id === artifact.eventId), + ).toBe(true); +}); + +it("restores legacy unsigned artifact bodies and preserves them if a signature exceeds the class budget", async () => { + vi.useFakeTimers(); + const storage = memory(); + storage.save( + Array.from({ length: 4 }, (_, i) => { + const event = { + kind: 45010, + content: "x".repeat(400_000), + tags: [["d", `legacy-${i}`]], + pubkey: viewer.pubkey, + created_at: 1, + }; + return { + event: { ...event, id: getEventHash(event) }, + delivery: "unknown" as const, + managed: { + version: 1 as const, + round: 0, + attempts: 0, + relayAccepted: false, + agentReceived: false, + nextAttemptAt: 0, + receiptDeadline: 0, + artifactConfirmed: false, + }, + }; + }), + ); + const h = setup(storage); + await h.outbox.ready(); + expect(h.outbox.snapshot()).toHaveLength(4); + await h.managedOwner(true); + await ticks(8_000); + const chat = await h.delivery.submit(input, recipient); + await ticks(1); + expect(storage.load().some((item) => item.event.id === chat.eventId)).toBe( + true, + ); + expect( + storage.load().filter((item) => item.event.kind === 45010), + ).toHaveLength(4); + h.dispose(); + const restarted = setup(storage); + await restarted.outbox.ready(); + expect( + restarted.local.snapshot().filter((item) => item.event.kind === 45010), + ).toHaveLength(4); +}); + +it("keeps chat durable when retry metadata exceeds a restored artifact byte budget", async () => { + vi.useFakeTimers(); + const storage = memory(); + const rows: OutgoingEvent[] = Array.from({ length: 5 }, (_, i) => { + const event = { + kind: 45010, + content: "x".repeat(400_000), + tags: [["d", `boundary-${i}`]], + pubkey: viewer.pubkey, + created_at: 1, + }; + return { + event: { ...event, id: getEventHash(event) }, + delivery: "unknown", + managed: { + version: 1, + round: 0, + attempts: 0, + relayAccepted: false, + agentReceived: false, + nextAttemptAt: 0, + receiptDeadline: 0, + artifactConfirmed: false, + }, + }; + }); + const last = rows[4]; + assert.exists(last); + const event = { + ...last.event, + content: + last.event.content + "x".repeat(2_097_127 - JSON.stringify(rows).length), + }; + rows[4] = { ...last, event: { ...event, id: getEventHash(event) } }; + expect(JSON.stringify(rows).length).toBe(2_097_127); + storage.save(rows); + const h = setup(storage); + await h.outbox.ready(); + await h.managedOwner(true); + await ticks(1); + const chat = await h.delivery.submit(input, recipient); + await ticks(1); + expect(storage.load().some((item) => item.event.id === chat.eventId)).toBe( + true, + ); + expect(storage.load().filter((item) => item.event.kind === 45010)).toEqual( + rows, + ); + h.dispose(); + const restarted = setup(storage); + await restarted.outbox.ready(); + expect( + restarted.local.snapshot().some((item) => item.event.id === chat.eventId), + ).toBe(true); +}); + +it("honors completed retention while preserving a full prior artifact class", async () => { + vi.useFakeTimers(); + const storage = memory(); + const pending: OutgoingEvent[] = Array.from({ length: 5 }, (_, i) => { + const event = { + kind: 45010, + content: "x".repeat(400_000), + tags: [["d", `retention-${i}`]], + pubkey: viewer.pubkey, + created_at: 1, + }; + return { + event: { ...event, id: getEventHash(event) }, + delivery: "unknown", + managed: { + version: 1, + round: 0, + attempts: 0, + relayAccepted: false, + agentReceived: false, + nextAttemptAt: 0, + receiptDeadline: 0, + artifactConfirmed: false, + }, + }; + }); + const last = pending[4]; + assert.exists(last); + const event = { + ...last.event, + content: + last.event.content + + "x".repeat(2_097_127 - JSON.stringify(pending).length), + }; + pending[4] = { ...last, event: { ...event, id: getEventHash(event) } }; + const completed: OutgoingEvent[] = Array.from({ length: 2048 }, (_, i) => { + const event = { + kind: 45010, + content: String(i), + tags: [["d", `complete-${i}`]], + pubkey: viewer.pubkey, + created_at: 1, + }; + return { + event: { ...event, id: getEventHash(event) }, + delivery: "accepted", + managed: { + version: 1, + round: 0, + attempts: 1, + relayAccepted: true, + agentReceived: false, + nextAttemptAt: 0, + receiptDeadline: 0, + artifactConfirmed: true, + }, + }; + }); + storage.save([...completed, ...pending]); + const h = setup(storage); + await h.managedOwner(true); + await ticks(1); + const chat = h.outbox.send(input); + await ticks(1); + const published = h.publish.mock.calls.find( + ([event]) => event.id === chat, + )?.[0]; + assert.exists(published); + h.observe([published]); + await ticks(1); + expect(storage.load()).toHaveLength(2053); + expect( + storage + .load() + .filter( + (item) => + item.event.kind === 45010 && + item.managed?.artifactConfirmed === false, + ), + ).toEqual(pending); + h.dispose(); + const restarted = setup(storage); + await restarted.outbox.ready(); + expect(restarted.local.snapshot()).toHaveLength(2053); +}); diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts index 970cd05348..e2847ba757 100644 --- a/src/features/relay/outbox.ts +++ b/src/features/relay/outbox.ts @@ -116,6 +116,7 @@ export class PublishNotSent extends PublishRejected {} export { browserOutboxStorage } from "./outbox-storage"; const abortError = () => new DOMException("Relay session closed", "AbortError"); const MAX_PENDING = 256; +const MAX_PENDING_BYTES = 2 * 1024 * 1024; const MAX_CONFIRMED = 2048; /** One journal owns pending delivery and bounded confirmed retention. Views receive one change stream. */ @@ -170,7 +171,6 @@ export function createOutbox( let finalVisible: readonly OutgoingEvent[] | undefined; const completed = new ByteLru(MAX_CONFIRMED, 8 * 1024 * 1024); const sizes = new WeakMap(); - let pendingBytes = 0; const listeners = new Set<() => void>(); const localListeners = new Set<() => void>(); const dismissListeners = new Set<(item: OutgoingEvent) => void>(); @@ -255,18 +255,23 @@ export function createOutbox( let managedOwner = false; let managedTimer: ReturnType | undefined; let durable = Promise.resolve(); + const pendingSize = (rows: readonly OutgoingEvent[]) => { + const totals = [2, 2]; + for (const item of rows) { + let size = sizes.get(item); + if (size === undefined) { + size = byteSize(item); + sizes.set(item, size); + } + const domain = item.event.kind === 45010 ? 1 : 0; + totals[domain] = (totals[domain] ?? 2) + size + 1; + } + return totals; + }; + const pendingCount = (rows: readonly OutgoingEvent[], kind: number) => + rows.filter((item) => (item.event.kind === 45010) === (kind === 45010)) + .length; const notify = () => { - pendingBytes = snapshot.reduce( - (sum, item) => { - let size = sizes.get(item); - if (size === undefined) { - size = byteSize(item); - sizes.set(item, size); - } - return sum + size; - }, - 2 + Math.max(0, snapshot.length - 1), - ); visible = Object.freeze([ ...completed.entries().map(([, item]) => item), ...snapshot, @@ -290,7 +295,7 @@ export function createOutbox( queued(); // Hydration may have added restored intent while this commit waited. // Commit current state so no intermediate transaction erases that intent. - const records = + const records = () => action === "artifact" ? visible.map((item) => item.event.id === id && item.managed?.relayAccepted @@ -330,15 +335,55 @@ export function createOutbox( : item, ) : visible; - const bytes = + const bytes = pendingSize( action === "dismiss" - ? byteSize(snapshot.filter((item) => item.event.id !== id)) - : pendingBytes; + ? snapshot.filter((item) => item.event.id !== id) + : snapshot, + ); return profiling.measureAsync("outbox.persist", id, async () => { - if (bytes > 2 * 1024 * 1024) + const target = visible.find((item) => item.event.id === id); + const domain = target + ? target.event.kind === 45010 + ? 1 + : 0 + : undefined; + if ( + domain === undefined + ? bytes.some((size) => size > MAX_PENDING_BYTES) + : (bytes[domain] ?? 0) > MAX_PENDING_BYTES + ) throw new Error("Outbox storage is full"); - await storage.save(records); - for (const item of records) unsavedManaged.delete(item.event.id); + let saved = records(); + if ( + domain !== undefined && + (bytes[1 - domain] ?? 0) > MAX_PENDING_BYTES + ) { + const prior = await storage.load({ refresh: true }); + validateSaved(prior); + // A failed status save in the other class must not join this commit. + const currentRecords = records(); + const retained = new Set( + currentRecords.map((item) => item.event.id), + ); + const priorById = new Map( + prior.map((item) => [item.event.id, item]), + ); + saved = currentRecords.flatMap((item) => { + if ((item.event.kind === 45010 ? 1 : 0) === domain) return [item]; + const durable = priorById.get(item.event.id); + return durable ? [durable] : []; + }); + saved.push( + ...prior.filter( + (item) => + (item.event.kind === 45010 ? 1 : 0) !== domain && + pendingOperation(item) && + !retained.has(item.event.id), + ), + ); + } + await storage.save(saved); + for (const item of saved) unsavedManaged.delete(item.event.id); if (action === "artifact") { const current = find(id); if (current?.managed?.relayAccepted) @@ -425,6 +470,12 @@ export function createOutbox( state.relayAccepted && (!state.recipient || state.agentReceived) && state.artifactConfirmed !== false; + const pendingOperation = (item: OutgoingEvent) => + !!item.recovery || + (item.managed + ? !managedDone(item.managed) + : !(item.event.kind === 9000 && item.acknowledged) && + item.delivery !== "seen"); function refreshManaged() { const work = durable .catch(() => {}) @@ -530,8 +581,8 @@ export function createOutbox( function validateSaved(loaded: readonly OutgoingEvent[]) { if ( !Array.isArray(loaded) || - loaded.length > MAX_PENDING + MAX_CONFIRMED || - byteSize(loaded) > 10 * 1024 * 1024 + loaded.length > 2 * MAX_PENDING + MAX_CONFIRMED || + byteSize(loaded) > 2 * MAX_PENDING_BYTES + 8 * 1024 * 1024 ) throw new Error("Saved outbox exceeds its budget"); } @@ -566,16 +617,12 @@ export function createOutbox( }); } function restore(restored: readonly OutgoingEvent[]) { - const pending = restored.filter( - (item) => - (item.managed - ? !managedDone(item.managed) - : !(item.event.kind === 9000 && item.acknowledged) && - item.delivery !== "seen") || - !!item.recovery || - (!!item.managed && !managedDone(item.managed)), - ); - if (pending.length > MAX_PENDING) + const pending = restored.filter(pendingOperation); + if ( + pendingCount(pending, 45010) > MAX_PENDING || + pendingCount(pending, 9) > MAX_PENDING || + pendingSize(pending).some((size) => size > MAX_PENDING_BYTES) + ) throw new Error("Saved pending outbox exceeds its budget"); for (const item of restored) if ( @@ -734,7 +781,16 @@ export function createOutbox( throw new Error("Signer changed the outgoing event"); const current = find(id); if (!current) return; // A verified echo already completed delivery. - replace({ ...current, signed }); + const signedOperation = { ...current, signed }; + if ( + (pendingSize( + snapshot.map((item) => + item.event.id === id ? signedOperation : item, + ), + )[current.event.kind === 45010 ? 1 : 0] ?? 0) > MAX_PENDING_BYTES + ) + throw new Error("Outbox storage is full"); + replace(signedOperation); await Promise.race([persist(id), aborted]); if (closed || signal.aborted || !find(id)) return; const hold = holdPublish?.(signed); @@ -905,7 +961,7 @@ export function createOutbox( ) ) throw new Error("Recover the earlier message before sending another"); - if (!find(id) && snapshot.length >= MAX_PENDING) + if (!find(id) && pendingCount(snapshot, item.event.kind) >= MAX_PENDING) throw new Error( "Too many outstanding operations; resolve a pending operation", ); @@ -999,7 +1055,7 @@ export function createOutbox( : OUTBOX_INPUT_MAX_BYTES)) ) throw new Error("Message is empty or too large"); - if (snapshot.length >= MAX_PENDING) + if (pendingCount(snapshot, input.kind) >= MAX_PENDING) throw new Error( "Too many outstanding operations; resolve or dismiss a pending operation", ); @@ -1087,44 +1143,53 @@ export function createOutbox( return event.id; } } + const operation = Object.freeze({ + event, + delivery: "sending" as const, + ...(savedRecovery ? { recovery: savedRecovery } : {}), + ...(active ? { guarded: true } : {}), + ...(managed + ? { + managed: managedDelivery({ + version: 1, + ...managed, + ...(input.kind === 45010 + ? { + artifactConfirmed: false, + submittedAt: Math.max( + Date.now(), + ...visible.map( + (item) => (item.managed?.submittedAt ?? 0) + 1, + ), + ), + } + : {}), + round: 0, + attempts: 0, + relayAccepted: false, + agentReceived: false, + nextAttemptAt: 0, + receiptDeadline: 0, + }), + } + : {}), + }); + const signedBudget = byteSize({ + ...operation, + signed: { ...event, sig: "0".repeat(128) }, + }); + if ( + (pendingSize(snapshot)[input.kind === 45010 ? 1 : 0] ?? 0) + + signedBudget + + 1 > + MAX_PENDING_BYTES + ) + throw new Error("Outbox storage is full"); if (managed) unsavedManaged.add(event.id); if (active) admissionGates.set(event.id, active); captureSend(event); profiling.measure("send.local", event.id, () => { - snapshot = Object.freeze([ - ...snapshot, - Object.freeze({ - event, - delivery: "sending" as const, - ...(savedRecovery ? { recovery: savedRecovery } : {}), - ...(active ? { guarded: true } : {}), - ...(managed - ? { - managed: managedDelivery({ - version: 1, - ...managed, - ...(input.kind === 45010 - ? { - artifactConfirmed: false, - submittedAt: Math.max( - Date.now(), - ...visible.map( - (item) => (item.managed?.submittedAt ?? 0) + 1, - ), - ), - } - : {}), - round: 0, - attempts: 0, - relayAccepted: false, - agentReceived: false, - nextAttemptAt: 0, - receiptDeadline: 0, - }), - } - : {}), - }), - ]); + snapshot = Object.freeze([...snapshot, operation]); notify(); }); const intent = ready.then(() => { @@ -1325,6 +1390,7 @@ export function createOutbox( : undefined) ) throw new Error("Operation key has different artifact content"); + await persist(prior.event.id); return { operationId: prior.event.id, eventId: prior.event.id }; } } diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 486d514cb3..16a94ff0d7 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -1873,6 +1873,10 @@ export function createRelaySession( ) { if (input.kind !== 45010) throw new Error("Use message commands for managed messages"); + if (!transport?.artifactSnapshots) + throw new Error( + "Artifact saving requires relay snapshot support", + ); const homes = input.tags.filter(([name]) => name === "h"); const id = homes[0]?.[1]; if ( From 17dead4cc294f8a81f8f2c0be428264fc8790405 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 15:50:54 -0700 Subject: [PATCH 13/30] Commit explicit artifact deltas Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/local-artifact-storage.md | 79 +++-- src/features/artifact-storage/store.ts | 200 ++++++------ src/features/artifact-storage/types.ts | 26 +- tests/browser/artifact-storage.spec.mjs | 413 +++++++++++++++++------- 4 files changed, 457 insertions(+), 261 deletions(-) diff --git a/docs/local-artifact-storage.md b/docs/local-artifact-storage.md index 011523b2c9..5dc437653d 100644 --- a/docs/local-artifact-storage.md +++ b/docs/local-artifact-storage.md @@ -71,44 +71,65 @@ it releases the lock. The collection also aborts its pending transactions on clo The host-only contract provides: -- `beginSnapshot(source)` binds the fixed query and starts a fresh local generation. +- `beginRefresh(source)` binds the fixed query and starts a fresh local generation. A newer generation retires the previous one. -- `applyBatch({source, snapshot, records, seen, availability, checkpoint}, signal)` - commits a validated page atomically. It requires the active generation. `seen` - reuses a body only when its exact current revision ID is saved. Availability - checks the expected revision. An optional checkpoint compares its expected value. -- `completeSnapshot(snapshot, checkpoint, signal)` runs only after sync validates - server completion. In one transaction, it marks absent shared records - unavailable, commits the checkpoint, and clears the refresh error. Saved bodies, - local snapshots, and preferences remain. Device-only records stay device-only. -- `setRefreshError(snapshot, message, signal)` saves an error for the active +- `applyBatch({source, refresh, records, availability}, signal)` commits explicit + authoritative changes atomically. It requires the active generation. An + availability change compares `expectedRevision` with the current ID observed + locally. That ID need not be the relay's previous revision. A mismatch throws + and rolls back the page; sync resolves it before it advances progress. +- `completeRefresh(refresh, checkpoint, signal)` commits progress after all pages + and their local transactions succeed. It compares the expected checkpoint and + clears the refresh error. It does not scan records or infer absence. +- `knownIds({after, limit, channelId})` gives host sync bounded pages of saved IDs. + A host channel index supports channel removals without projector assumptions. + Key cursors do not load record bodies. The result includes local-only and + imported records; presence does not establish shared origin or current access. +- `setRefreshError(refresh, message, signal)` saves an error for the active generation and publishes a notification. Plugins read `refreshError()` and show - a nonempty error. A new attempt retains that error until successful completion. -- `checkpoint()` reads the saved opaque checkpoint. - -No raw live event, accepted edit, or per-artifact write can change confirmed -shared state. Live events request an automatic background refresh. Reconnect also -starts a refresh. A failed capture may leave validated bodies for exact-ID reuse. -Only validated completion can infer absence. Generation, query, schema, checkpoint, -and cancellation checks run inside the transaction. An old owner cannot commit -late pages, completion, or errors after cancellation or a generation change. + a nonempty error. A new attempt retains that error until successful delta + completion. +- `checkpoint()` reads the saved opaque progress value. + +Sync serializes each whole paged delta and each bounded current-ID read from +request start through local commit. Raw live events request fresh authoritative +reads; they never write raw revisions. A duplicate or delayed push therefore does +not replace current state with its old payload. Generation, query, schema, +checkpoint, and cancellation checks run inside the commit path. An old owner cannot +commit late pages, completion, or errors after cancellation or a generation change. + +A current-ID read uses `beginRefresh` and `applyBatch`. It does not advance delta +progress or clear an error from a failed delta. Only a whole successful delta calls +`completeRefresh`. A failed read can leave already committed authoritative rows, +but progress stays unchanged. Replay must be idempotent. Explicit unavailable +results preserve saved content and preferences. An empty delta leaves all saved +rows unchanged. An error or truncated response is not an unavailable result. + +The relay's incremental protocol uses a 120-second overlap. Relay and sync own the +server timestamp, stable pagination, policy recovery, and retention-gap rules. +The overlap is a recovery assumption; the store does not treat it as proof that +no update can be missed. Recovery can revalidate known IDs through the relay's +uniform current-ID response. Explicit revalidation restores availability when +access returns. The store does not compare timestamps or construct ancestry. ## Immediate local feedback Device preferences update immediately. Shared edits use the existing delivery -journal and appear immediately in the plugin's pending view. Relay acceptance -starts a fresh capture. A pre-acceptance capture cannot reconcile that pending -edit. A successful capture begun after acceptance can show the accepted edit, a -later revision, or legitimate absence. Reconciling an older operation must preserve -any newer pending edit. Sync and delivery own this boundary; the row store does not -merge optimistic edits into confirmed state. Offline shared edits remain undecided. +journal and appear immediately in the plugin's pending view. An accepted edit needs +an authoritative current-ID read actually started after its acknowledgement. Sync +retains that confirmation obligation until success or an explicit error. It cannot +attach acceptance to an older in-flight response. An empty delta cannot reconcile +the pending view. The read may report the accepted edit, a later revision, or a +uniform unavailable result. Reconciling an older operation preserves newer pending +edits. Sync and delivery own this boundary; the row store does not merge optimistic +edits into confirmed state. Publication conflicts remain failed edits with the +attempted value retained. Offline shared edits remain undecided. ## Storage costs Writes request strict durability. Transactions span all four object stores, so large batches can delay other collections' reads or writes. A changed row rewrites -its indexes. Index upgrades and absence reconciliation scan the collection. -Ancestry comparison and separate source-membership writes are absent. The database -upgrade removes the obsolete membership store and preserves records and device -preferences. Leaving a community purges only its saved partition. If database +its indexes. Index upgrades scan the collection. Delta completion does not scan records. +Ancestry comparison and separate source-membership writes are absent. The database upgrade removes the obsolete membership store, builds the host +channel index, and preserves records and device preferences. Leaving a community purges only its saved partition. If database opening fails, close that handle and open another after the cause clears. diff --git a/src/features/artifact-storage/store.ts b/src/features/artifact-storage/store.ts index 76a6853ba7..8eba820339 100644 --- a/src/features/artifact-storage/store.ts +++ b/src/features/artifact-storage/store.ts @@ -8,7 +8,7 @@ import type { ArtifactPage, ArtifactPageQuery, HostArtifactCollection, - ArtifactSnapshot, + ArtifactRefresh, SavedArtifact, } from "./types"; @@ -19,13 +19,12 @@ type Row = { scope: string; collection: string; id: string; - generation?: string; }; type StoredPreference = { value: JsonValue }; -function sameSnapshot( - left: ArtifactSnapshot | undefined, - right: ArtifactSnapshot, +function sameRefresh( + left: ArtifactRefresh | undefined, + right: ArtifactRefresh, ) { return left?.source === right.source && left.generation === right.generation; } @@ -42,12 +41,25 @@ export function createArtifactStore(viewer: string, relay: string) { if (closed) throw new Error("Artifact storage closed"); if (!opening) { const attempt = new Promise((resolve, reject) => { - const request = indexedDB.open(DB, 2); + const request = indexedDB.open(DB, 3); let blocked = false; request.onupgradeneeded = () => { const db = request.result; if (db.objectStoreNames.contains("members")) db.deleteObjectStore("members"); + if (db.objectStoreNames.contains("records")) { + const transaction = request.transaction; + if (!transaction) + throw new Error("Artifact database upgrade has no transaction"); + const records = transaction.objectStore("records"); + if (!records.indexNames.contains("channel")) + records.createIndex("channel", [ + "scope", + "collection", + "record.channelId", + "id", + ]); + } for (const name of STORES) { if (db.objectStoreNames.contains(name)) continue; const store = db.createObjectStore(name, { @@ -60,6 +72,13 @@ export function createArtifactStore(viewer: string, relay: string) { }); if (name === "entries") store.createIndex("record", ["scope", "collection", "id"]); + if (name === "records") + store.createIndex("channel", [ + "scope", + "collection", + "record.channelId", + "id", + ]); } }; request.onerror = () => reject(request.error); @@ -228,7 +247,7 @@ export function createArtifactStore(viewer: string, relay: string) { if (schema?.version > definition.version) throw new Error("Artifact collection requires a newer plugin"); metadata.delete(key("source")); - metadata.delete(key("snapshot")); + metadata.delete(key("refresh")); metadata.delete(key("checkpoint")); const request = tx.objectStore("records").openCursor(range(prefix)); then(request, (cursor) => { @@ -292,16 +311,16 @@ export function createArtifactStore(viewer: string, relay: string) { next(); }); } - function checkSnapshot( + function checkRefresh( tx: IDBTransaction, then: Then, - snapshot: ArtifactSnapshot, + refresh: ArtifactRefresh, next: () => void, ) { - bindSource(tx, then, snapshot.source, () => { - then(tx.objectStore("metadata").get(key("snapshot")), (saved) => { - if (!sameSnapshot(saved?.value, snapshot)) - throw new Error("Artifact snapshot changed"); + bindSource(tx, then, refresh.source, () => { + then(tx.objectStore("metadata").get(key("refresh")), (saved) => { + if (!sameRefresh(saved?.value, refresh)) + throw new Error("Artifact refresh changed"); next(); }); }); @@ -507,12 +526,12 @@ export function createArtifactStore(viewer: string, relay: string) { done(row?.value ?? null), ), ), - async setRefreshError(snapshot, message, signal) { + async setRefreshError(refresh, message, signal) { if (!message) throw new Error("Artifact refresh error is empty"); await run( "readwrite", (tx, then, done) => { - checkSnapshot(tx, then, snapshot, () => { + checkRefresh(tx, then, refresh, () => { tx.objectStore("metadata").put({ scope, collection, @@ -526,81 +545,87 @@ export function createArtifactStore(viewer: string, relay: string) { ); publish(); }, + knownIds(query) { + const limit = query.limit ?? 100; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) + return Promise.reject( + new Error("Artifact page limit must be 1 to 1000"), + ); + return run<{ ids: string[]; next?: string }>( + "readonly", + (tx, then, done) => { + const records = tx.objectStore("records"); + const parts = + query.channelId === undefined + ? prefix + : [...prefix, query.channelId]; + const lower = + query.after === undefined ? parts : [...parts, query.after]; + const request = ( + query.channelId === undefined ? records : records.index("channel") + ).openKeyCursor( + IDBKeyRange.bound( + lower, + [...parts, []], + query.after !== undefined, + ), + ); + const ids: string[] = []; + then(request, function visit(cursor) { + if (!cursor || ids.length === limit) { + const last = ids.at(-1); + done({ + ids, + ...(cursor && last !== undefined ? { next: last } : {}), + }); + return; + } + ids.push(String((cursor.primaryKey as IDBValidKey[])[2])); + cursor.continue(); + }); + }, + ); + }, checkpoint: () => run("readonly", (tx, then, done) => then(tx.objectStore("metadata").get(key("checkpoint")), (row) => done(row?.value ?? null), ), ), - beginSnapshot(source) { + beginRefresh(source) { if (!source) return Promise.reject(new Error("Artifact source is empty")); - return run("readwrite", (tx, then, done) => { + return run("readwrite", (tx, then, done) => { bindSource(tx, then, source, () => { - const snapshot = { source, generation: crypto.randomUUID() }; + const refresh = { source, generation: crypto.randomUUID() }; tx.objectStore("metadata").put({ scope, collection, - key: "snapshot", - value: snapshot, + key: "refresh", + value: refresh, }); - done(snapshot); + done(refresh); }); }); }, - async completeSnapshot(snapshot, checkpoint, signal) { + async completeRefresh(refresh, checkpoint, signal) { await run( "readwrite", (tx, then, done) => { - checkSnapshot(tx, then, snapshot, () => { + checkRefresh(tx, then, refresh, () => { const metadata = tx.objectStore("metadata"); then(metadata.get(key("checkpoint")), (row) => { if ((row?.value ?? null) !== checkpoint.expected) throw new Error("Artifact checkpoint changed"); - const request = tx - .objectStore("records") - .openCursor(range(prefix)); - then(request, function visit(cursor) { - if (!cursor) { - metadata.put({ - scope, - collection, - key: "checkpoint", - value: checkpoint.next, - }); - metadata.delete(key("snapshot")); - metadata.delete(key("refresh-error")); - done(); - return; - } - const saved: Row = cursor.value; - if ( - !saved.record.current || - saved.generation === snapshot.generation || - saved.record.availability.state === "unavailable" - ) { - cursor.continue(); - return; - } - mutate( - tx, - then, - saved.id, - (row) => ({ - row: { - ...row, - record: { - ...row.record, - availability: { - state: "unavailable", - reason: "Absent from completed snapshot", - }, - }, - }, - }), - () => cursor.continue(), - ); + metadata.put({ + scope, + collection, + key: "checkpoint", + value: checkpoint.next, }); + metadata.delete(key("refresh")); + metadata.delete(key("refresh-error")); + done(); }); }); }, @@ -614,19 +639,9 @@ export function createArtifactStore(viewer: string, relay: string) { (tx, then, done) => { const jobs: (() => void)[] = []; const next = series(jobs); - if (!batch.snapshot || batch.source !== batch.snapshot.source) - throw new Error("Artifact snapshot source mismatch"); - jobs.push(() => checkSnapshot(tx, then, batch.snapshot, next)); - jobs.push(() => - then(tx.objectStore("metadata").get(key("checkpoint")), (row) => { - if ( - batch.checkpoint && - (row?.value ?? null) !== batch.checkpoint.expected - ) - throw new Error("Artifact checkpoint changed"); - next(); - }), - ); + if (!batch.refresh || batch.source !== batch.refresh.source) + throw new Error("Artifact refresh source mismatch"); + jobs.push(() => checkRefresh(tx, then, batch.refresh, next)); for (const incoming of batch.records ?? []) jobs.push(() => mutate( @@ -649,7 +664,6 @@ export function createArtifactStore(viewer: string, relay: string) { scope, collection, id: incoming.id, - generation: batch.snapshot.generation, record: { id: incoming.id, channelId: incoming.channelId, @@ -666,21 +680,6 @@ export function createArtifactStore(viewer: string, relay: string) { next, ), ); - for (const seen of batch.seen ?? []) - jobs.push(() => - then( - tx.objectStore("records").get(key(seen.id)), - (row: Row | undefined) => { - if (!row || row.record.current?.id !== seen.revisionId) - throw new Error("Snapshot revision is not saved"); - tx.objectStore("records").put({ - ...row, - generation: batch.snapshot.generation, - }); - next(); - }, - ), - ); for (const availability of batch.availability ?? []) jobs.push(() => mutate( @@ -706,16 +705,7 @@ export function createArtifactStore(viewer: string, relay: string) { next, ), ); - jobs.push(() => { - if (batch.checkpoint) - tx.objectStore("metadata").put({ - scope, - collection, - key: "checkpoint", - value: batch.checkpoint.next, - }); - done(); - }); + jobs.push(() => done()); next(); }, signal, diff --git a/src/features/artifact-storage/types.ts b/src/features/artifact-storage/types.ts index c3486417a5..53c41f8aa0 100644 --- a/src/features/artifact-storage/types.ts +++ b/src/features/artifact-storage/types.ts @@ -71,7 +71,7 @@ export type ArtifactCollection = { change: (current: JsonValue | undefined) => JsonValue, ): Promise; }; - /** Inserts absent snapshots and preferences. Retries preserve later edits. */ + /** Inserts absent refreshs and preferences. Retries preserve later edits. */ importBatch(batch: ArtifactImport): Promise; imported(marker: string): Promise; close(): void; @@ -82,37 +82,41 @@ export type ReceivedArtifact = Readonly<{ type: string; revision: ArtifactRevision; }>; -export type ArtifactSnapshot = Readonly<{ +export type ArtifactRefresh = Readonly<{ source: string; generation: string; }>; export type ArtifactBatch = Readonly<{ /** Exact authoritative query scope, defined by the sync owner. */ source: string; - snapshot: ArtifactSnapshot; + refresh: ArtifactRefresh; records?: readonly ReceivedArtifact[]; - /** Snapshot members whose exact current revision is already saved. */ - seen?: readonly Readonly<{ id: string; revisionId: string }>[]; availability?: readonly Readonly<{ id: string; + /** The current ID observed locally, regardless of missed relay revisions. */ expectedRevision: string | null; value: ArtifactAvailability; }>[]; - checkpoint?: Readonly<{ expected: string | null; next: string }>; }>; /** Host-only writes. Plugins submit shared changes through the delivery service. */ export type HostArtifactCollection = ArtifactCollection & { applyBatch(batch: ArtifactBatch, signal?: AbortSignal): Promise; checkpoint(): Promise; + knownIds(query: { + after?: string; + limit?: number; + channelId?: string; + }): Promise<{ ids: readonly string[]; next?: string }>; + setRefreshError( - snapshot: ArtifactSnapshot, + refresh: ArtifactRefresh, message: string, signal?: AbortSignal, ): Promise; - beginSnapshot(source: string): Promise; - /** The caller validates server completion before this local transaction. */ - completeSnapshot( - snapshot: ArtifactSnapshot, + beginRefresh(source: string): Promise; + /** Commit progress after all pages succeed. Unseen records stay unchanged. */ + completeRefresh( + refresh: ArtifactRefresh, checkpoint: { expected: string | null; next: string }, signal?: AbortSignal, ): Promise; diff --git a/tests/browser/artifact-storage.spec.mjs b/tests/browser/artifact-storage.spec.mjs index 25e0976b4c..4bd883bdcf 100644 --- a/tests/browser/artifact-storage.spec.mjs +++ b/tests/browser/artifact-storage.spec.mjs @@ -73,8 +73,8 @@ test("commits across windows and survives offline restart with isolated scopes", }); await page.evaluate(async () => { const c = window.artifactTest.open(); - const snapshot = await c.beginSnapshot("all"); - await c.setRefreshError(snapshot, "Refresh unavailable"); + const refresh = await c.beginRefresh("all"); + await c.setRefreshError(refresh, "Refresh unavailable"); }); expect(await other.evaluate(() => window.errorChange)).toBe( "Refresh unavailable", @@ -131,9 +131,8 @@ test("rolls back received batches and migration failures without publishing chan const failure = await c .applyBatch({ source: "all", - snapshot: await c.beginSnapshot("all"), + refresh: await c.beginRefresh("all"), records, - checkpoint: { expected: null, next: "bad" }, }) .then( () => "unexpected", @@ -219,7 +218,7 @@ test("atomically indexes preference changes and preserves newer values during mi expect(result.row.record.local).toEqual({ archived: false }); }); -test("reconciles one fixed query only after completion and preserves device data", async ({ +test("commits delta progress without inferring absence and preserves device data", async ({ page, storageUrl, }) => { @@ -232,10 +231,10 @@ test("reconciles one fixed query only after completion and preserves device data type: "test.task", revision: { id, content: "{}" }, }); - const seed = await c.beginSnapshot("all"); + const seed = await c.beginRefresh("all"); await c.applyBatch({ source: "all", - snapshot: seed, + refresh: seed, records: [record("missing"), record("seen")], availability: ["missing", "seen"].map((id) => ({ id, @@ -243,38 +242,36 @@ test("reconciles one fixed query only after completion and preserves device data value: { state: "available" }, })), }); - await c.completeSnapshot(seed, { expected: null, next: "seed" }); + await c.completeRefresh(seed, { expected: null, next: "seed" }); await c.preferences.update("missing", () => ({ pinned: false })); await c.importBatch({ records: [{ id: "device-only", local: { title: "saved" } }], }); - const partial = await c.beginSnapshot("all"); + const partial = await c.beginRefresh("all"); await c.applyBatch({ source: "all", - snapshot: partial, - seen: [{ id: "seen", revisionId: "seen" }], + refresh: partial, }); await c.setRefreshError(partial, "Refresh limit reached"); const before = await c.get("missing"); const error = await c.refreshError(); - const snapshot = await c.beginSnapshot("all"); + const refresh = await c.beginRefresh("all"); const retainedError = await c.refreshError(); const stale = await c - .completeSnapshot(partial, { expected: "seed", next: "wrong" }) + .completeRefresh(partial, { expected: "seed", next: "wrong" }) .then( () => "unexpected", (e) => e.message, ); - const wrongQuery = await c.beginSnapshot("other").then( + const wrongQuery = await c.beginRefresh("other").then( () => "unexpected", (e) => e.message, ); await c.applyBatch({ source: "all", - snapshot, - seen: [{ id: "seen", revisionId: "seen" }], + refresh, }); - await c.completeSnapshot(snapshot, { expected: "seed", next: "complete" }); + await c.completeRefresh(refresh, { expected: "seed", next: "complete" }); const lateError = await c.setRefreshError(partial, "old error").then( () => "unexpected", (e) => e.message, @@ -296,10 +293,10 @@ test("reconciles one fixed query only after completion and preserves device data expect(result.before.record.availability.state).toBe("available"); expect(result.error).toBe("Refresh limit reached"); expect(result.retainedError).toBe(result.error); - expect(result.stale).toBe("Artifact snapshot changed"); + expect(result.stale).toBe("Artifact refresh changed"); expect(result.wrongQuery).toBe("Artifact collection query changed"); - expect(result.lateError).toBe("Artifact snapshot changed"); - expect(result.missing.record.availability.state).toBe("unavailable"); + expect(result.lateError).toBe("Artifact refresh changed"); + expect(result.missing.record.availability.state).toBe("available"); expect(result.missing.record.current.content).toBe("{}"); expect(result.missing.preference).toEqual({ pinned: false }); expect(result.seen.record.availability.state).toBe("available"); @@ -308,6 +305,112 @@ test("reconciles one fixed query only after completion and preserves device data expect(result.clearedError).toBeNull(); }); +test("applies explicit removals after missed revisions and rejects a failed page", async ({ + page, + storageUrl, +}) => { + await open(page, storageUrl); + const result = await page.evaluate(async () => { + const c = window.artifactTest.open(); + const record = (id, revision) => ({ + id, + channelId: "channel", + type: "test.task", + revision: { id: revision, content: "{}" }, + }); + const initial = await c.beginRefresh("all"); + await c.applyBatch({ + source: "all", + refresh: initial, + records: [record("task", "local-A")], + availability: [ + { + id: "task", + expectedRevision: "local-A", + value: { state: "available" }, + }, + ], + }); + await c.completeRefresh(initial, { expected: null, next: "initial" }); + await c.preferences.update("task", () => false); + const refresh = await c.beginRefresh("all"); + const failure = await c + .applyBatch({ + source: "all", + refresh, + records: [record("new", "new-rev")], + availability: [ + { + id: "task", + expectedRevision: "relay-B-not-saved", + value: { state: "unavailable" }, + }, + ], + }) + .then( + () => "unexpected", + (e) => e.message, + ); + const progressAfterFailure = await c.checkpoint(); + const newAfterFailure = await c.get("new"); + const before = await c.get("task"); + await c.applyBatch({ + source: "all", + refresh, + availability: [ + { + id: "task", + expectedRevision: before.record.current.id, + value: { state: "unavailable" }, + }, + ], + }); + const mismatch = await c + .completeRefresh(refresh, { expected: "wrong", next: "bad" }) + .then( + () => "unexpected", + (e) => e.message, + ); + await c.completeRefresh(refresh, { expected: "initial", next: "removed" }); + const removed = await c.get("task"); + const restored = await c.beginRefresh("all"); + await c.applyBatch({ + source: "all", + refresh: restored, + records: [record("task", "relay-C")], + availability: [ + { + id: "task", + expectedRevision: "relay-C", + value: { state: "available" }, + }, + ], + }); + await c.completeRefresh(restored, { + expected: "removed", + next: "restored", + }); + return { + failure, + progressAfterFailure, + newAfterFailure, + mismatch, + removed, + restored: await c.get("task"), + }; + }); + expect(result.failure).toBe("Artifact revision changed"); + expect(result.progressAfterFailure).toBe("initial"); + expect(result.newAfterFailure).toBeUndefined(); + expect(result.mismatch).toBe("Artifact checkpoint changed"); + expect(result.removed.record.current.id).toBe("local-A"); + expect(result.removed.record.availability.state).toBe("unavailable"); + expect(result.removed.preference).toBe(false); + expect(result.restored.record.current.id).toBe("relay-C"); + expect(result.restored.record.availability.state).toBe("available"); + expect(result.restored.preference).toBe(false); +}); + test("reads bounded indexed pages for 10,000 tasks and 40,000 archived records", async ({ page, storageUrl, @@ -325,6 +428,20 @@ test("reads bounded indexed pages for 10,000 tasks and 40,000 archived records", }); } const savedMs = performance.now() - start; + let scans = 0; + const cursor = IDBObjectStore.prototype.openCursor; + IDBObjectStore.prototype.openCursor = function (...args) { + if (this.name === "records") scans++; + return cursor.apply(this, args); + }; + try { + const refresh = await c.beginRefresh("all"); + await c.completeRefresh(refresh, { expected: null, next: "complete" }); + } finally { + IDBObjectStore.prototype.openCursor = cursor; + } + const ids = await c.knownIds({ limit: 75 }); + const nextIds = await c.knownIds({ limit: 75, after: ids.next }); let reads = 0; const get = IDBObjectStore.prototype.get; IDBObjectStore.prototype.get = function (...args) { @@ -351,6 +468,9 @@ test("reads bounded indexed pages for 10,000 tasks and 40,000 archived records", return { savedMs, elapsedMs, + scans, + ids, + nextIds, reads, first: first.entries.map((e) => e.record.id), second: second.entries.map((e) => e.record.id), @@ -358,6 +478,9 @@ test("reads bounded indexed pages for 10,000 tasks and 40,000 archived records", last: (await reopened.get("049999")).record.local, }; }); + expect(result.scans).toBe(0); + expect(result.ids.ids).toHaveLength(75); + expect(result.nextIds.ids[0]).toBe("000075"); expect(result.first).toHaveLength(75); expect(result.second[0]).toBe("000075"); expect(result.archive[0]).toBe("010000"); @@ -386,16 +509,16 @@ test("accepts authoritative heads without ancestry and rejects retired captures" type: "test.task", revision: { id, content: "{}" }, }); - const old = await c.beginSnapshot("all"); + const old = await c.beginRefresh("all"); await c.applyBatch({ source: "all", - snapshot: old, + refresh: old, records: [record("A", "old-home")], }); - const newer = await c.beginSnapshot("all"); + const newer = await c.beginRefresh("all"); await c.applyBatch({ source: "all", - snapshot: newer, + refresh: newer, records: [record("C", "new-home")], availability: [ { id: "task", expectedRevision: "C", value: { state: "available" } }, @@ -404,7 +527,7 @@ test("accepts authoritative heads without ancestry and rejects retired captures" const stale = await c .applyBatch({ source: "all", - snapshot: old, + refresh: old, records: [record("A", "old-home")], }) .then( @@ -414,7 +537,7 @@ test("accepts authoritative heads without ancestry and rejects retired captures" const revision = await c .applyBatch({ source: "all", - snapshot: newer, + refresh: newer, availability: [ { id: "task", expectedRevision: "A", value: { state: "deleted" } }, ], @@ -424,7 +547,7 @@ test("accepts authoritative heads without ancestry and rejects retired captures" (e) => e.message, ); const mismatch = await c - .applyBatch({ source: "other", snapshot: newer }) + .applyBatch({ source: "other", refresh: newer }) .then( () => "unexpected", (e) => e.message, @@ -435,31 +558,19 @@ test("accepts authoritative heads without ancestry and rejects retired captures" () => "unexpected", (e) => e.message, ); - const notSaved = await c - .applyBatch({ - source: "all", - snapshot: newer, - seen: [{ id: "task", revisionId: "A" }], - }) - .then( - () => "unexpected", - (e) => e.message, - ); - await c.completeSnapshot(newer, { expected: null, next: "done" }); + await c.completeRefresh(newer, { expected: null, next: "done" }); return { stale, revision, mismatch, raw, - notSaved, row: await c.get("task"), }; }); - expect(result.stale).toBe("Artifact snapshot changed"); + expect(result.stale).toBe("Artifact refresh changed"); expect(result.revision).toBe("Artifact revision changed"); - expect(result.mismatch).toBe("Artifact snapshot source mismatch"); - expect(result.raw).toBe("Artifact snapshot source mismatch"); - expect(result.notSaved).toBe("Snapshot revision is not saved"); + expect(result.mismatch).toBe("Artifact refresh source mismatch"); + expect(result.raw).toBe("Artifact refresh source mismatch"); expect(result.row.record.current.id).toBe("C"); expect(result.row.record.channelId).toBe("new-home"); expect(result.row.record.availability.state).toBe("available"); @@ -467,26 +578,17 @@ test("accepts authoritative heads without ancestry and rejects retired captures" expect(result.row.record).not.toHaveProperty("conflicts"); }); -test("aborts snapshot completion before and during the local transaction", async ({ +test("aborts page and progress commits when the owner closes", async ({ page, storageUrl, }) => { await open(page, storageUrl); const result = await page.evaluate(async () => { - let controller; - let abortOnProjection = false; - let closeOnProjection = false; - const c = window.artifactTest.open("viewer", "relay", "test", { - project() { - if (abortOnProjection) - controller.abort(new Error("live interval lost")); - if (closeOnProjection) c.close(); - return []; - }, - }); + const c = window.artifactTest.open(); + const refresh = await c.beginRefresh("all"); await c.applyBatch({ source: "all", - snapshot: await c.beginSnapshot("all"), + refresh, records: [ { id: "task", @@ -499,77 +601,97 @@ test("aborts snapshot completion before and during the local transaction", async { id: "task", expectedRevision: "A", value: { state: "available" } }, ], }); - const snapshot = await c.beginSnapshot("all"); const pre = new AbortController(); pre.abort(new Error("owner closed")); const before = await c - .completeSnapshot(snapshot, { expected: null, next: "bad" }, pre.signal) + .completeRefresh(refresh, { expected: null, next: "bad" }, pre.signal) .then( () => "unexpected", (e) => e.message, ); - controller = new AbortController(); - abortOnProjection = true; - const during = await c - .completeSnapshot( - snapshot, - { expected: null, next: "bad" }, - controller.signal, + const put = IDBObjectStore.prototype.put; + let controller; + let abortCheckpoint = false; + let abortRecord = false; + let closeCheckpoint = false; + IDBObjectStore.prototype.put = function (value, ...args) { + const request = put.call(this, value, ...args); + if ( + (this.name === "metadata" && + value.key === "checkpoint" && + abortCheckpoint) || + (this.name === "records" && abortRecord) ) - .then( - () => "unexpected", - (e) => e.message, - ); - controller = new AbortController(); - const batch = await c - .applyBatch( - { - source: "all", - snapshot, - records: [ - { - id: "task", - channelId: "new-home", - type: "test.task", - revision: { id: "B", content: "{}" }, - }, - ], - }, - controller.signal, + controller.abort(new Error("owner retired")); + if ( + this.name === "metadata" && + value.key === "checkpoint" && + closeCheckpoint ) - .then( - () => "unexpected", - (e) => e.message, - ); - abortOnProjection = false; - const row = await c.get("task"); - const checkpoint = await c.checkpoint(); - closeOnProjection = true; - const closed = await c - .completeSnapshot(snapshot, { expected: null, next: "closed" }) - .then( - () => "unexpected", - (e) => e.message, - ); - return { - before, - batch, - closed, - row, - checkpoint, - during, + c.close(); + return request; }; + try { + controller = new AbortController(); + abortCheckpoint = true; + const during = await c + .completeRefresh( + refresh, + { expected: null, next: "bad" }, + controller.signal, + ) + .then( + () => "unexpected", + (e) => e.message, + ); + abortCheckpoint = false; + abortRecord = true; + controller = new AbortController(); + const batch = await c + .applyBatch( + { + source: "all", + refresh, + records: [ + { + id: "task", + channelId: "new-home", + type: "test.task", + revision: { id: "B", content: "{}" }, + }, + ], + }, + controller.signal, + ) + .then( + () => "unexpected", + (e) => e.message, + ); + abortRecord = false; + const row = await c.get("task"); + const checkpoint = await c.checkpoint(); + closeCheckpoint = true; + const closed = await c + .completeRefresh(refresh, { expected: null, next: "closed" }) + .then( + () => "unexpected", + (e) => e.message, + ); + return { before, during, batch, closed, row, checkpoint }; + } finally { + IDBObjectStore.prototype.put = put; + } }); expect(result.before).toBe("owner closed"); - expect(result.during).toBe("live interval lost"); - expect(result.batch).toBe("live interval lost"); + expect(result.during).toBe("owner retired"); + expect(result.batch).toBe("owner retired"); expect(result.closed).toBe("Artifact collection closed"); expect(result.row.record.current.id).toBe("A"); expect(result.row.record.availability.state).toBe("available"); expect(result.checkpoint).toBeNull(); }); -test("upgrades across windows and rejects stale writes and snapshot completion", async ({ +test("upgrades across windows and rejects stale writes and refresh completion", async ({ page, context, storageUrl, @@ -577,13 +699,13 @@ test("upgrades across windows and rejects stale writes and snapshot completion", await open(page, storageUrl); const other = await context.newPage(); await open(other, storageUrl); - const snapshot = await other.evaluate(async () => { + const refresh = await other.evaluate(async () => { window.collection = window.artifactTest.open(); await window.collection.importBatch({ records: [{ id: "task", local: {} }], preferences: [{ id: "task", value: true }], }); - return window.collection.beginSnapshot("all"); + return window.collection.beginRefresh("all"); }); const result = await page.evaluate(async () => { const newer = window.artifactTest.open("viewer", "relay", "test", { @@ -610,17 +732,17 @@ test("upgrades across windows and rejects stale writes and snapshot completion", ); return { page, downgrade, type }; }); - const stale = await other.evaluate(async (snapshot) => { + const stale = await other.evaluate(async (refresh) => { const error = (e) => e.message; return Promise.all([ window.collection.preferences .update("task", () => false) .then(() => "unexpected", error), window.collection - .completeSnapshot(snapshot, { expected: null, next: "stale" }) + .completeRefresh(refresh, { expected: null, next: "stale" }) .then(() => "unexpected", error), ]); - }, snapshot); + }, refresh); expect(result.page.entries[0].preference).toBeNull(); expect(stale).toEqual([ "Artifact collection version changed", @@ -743,6 +865,9 @@ test("upgrades saved device data and removes the obsolete membership store", asy record: { id: "task", local: { title: "saved" }, + current: { id: "A", content: "{}" }, + channelId: "channel", + type: "test.task", availability: { state: "unknown" }, }, }); @@ -772,12 +897,68 @@ test("upgrades saved device data and removes the obsolete membership store", asy }; request.onerror = () => reject(request.error); }); - return { saved, rows: page.entries.length, schema }; + return { + saved, + rows: page.entries.length, + schema, + channel: await c.knownIds({ channelId: "channel" }), + }; }); expect(result.saved.record.local).toEqual({ title: "saved" }); expect(result.saved.preference).toEqual({ pinned: false }); expect(result.rows).toBe(1); - expect(result.schema).toEqual({ version: 2, members: false }); + expect(result.channel.ids).toEqual(["task"]); + expect(result.schema).toEqual({ version: 3, members: false }); +}); + +test("pages raw known IDs by channel without plugin index assumptions", async ({ + page, + storageUrl, +}) => { + await open(page, storageUrl); + const result = await page.evaluate(async () => { + const c = window.artifactTest.open("viewer", "relay", "test", { + project: () => [], + }); + const record = (id, channelId, revision = id) => ({ + id, + channelId, + type: "test.task", + revision: { id: revision, content: "{}" }, + }); + const refresh = await c.beginRefresh("all"); + await c.applyBatch({ + source: "all", + refresh, + records: [record("a", "one"), record("b", "one"), record("c", "two")], + }); + await c.importBatch({ records: [{ id: "local", local: {} }] }); + const first = await c.knownIds({ channelId: "one", limit: 1 }); + const second = await c.knownIds({ + channelId: "one", + limit: 1, + after: first.next, + }); + await c.applyBatch({ + source: "all", + refresh, + records: [record("a", "two", "moved")], + }); + return { + first, + second, + all: await c.knownIds({}), + one: await c.knownIds({ channelId: "one" }), + two: await c.knownIds({ channelId: "two" }), + projected: await c.page({ index: "none", value: "" }), + }; + }); + expect(result.first).toEqual({ ids: ["a"], next: "a" }); + expect(result.second).toEqual({ ids: ["b"] }); + expect(result.all.ids).toEqual(["a", "b", "c", "local"]); + expect(result.one.ids).toEqual(["b"]); + expect(result.two.ids).toEqual(["a", "c"]); + expect(result.projected.entries).toEqual([]); }); test("retries database opening with a new handle after a transient failure", async ({ From ef501cde2be1ea13877ae5e60c87879424c0ec7d Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 16:04:42 -0700 Subject: [PATCH 14/30] Keep sync retries and repair timing separate Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/artifact-sync-delivery.md | 2 +- src/features/relay/artifact-reader.ts | 2 + src/features/relay/artifact-sync.test.ts | 119 +++++++++++++++++++++++ src/features/relay/artifact-sync.ts | 37 +++++-- 4 files changed, 151 insertions(+), 9 deletions(-) diff --git a/docs/artifact-sync-delivery.md b/docs/artifact-sync-delivery.md index 943ebb68e5..888d2397a7 100644 --- a/docs/artifact-sync-delivery.md +++ b/docs/artifact-sync-delivery.md @@ -4,7 +4,7 @@ The host saves confirmed relay artifacts through `ArtifactStorageService`. Plugi ## Sync contract -Sync uses the existing relay session and socket. The native transport exposes `session.artifactSync` only when NIP-11 advertises the exact version 1 `artifacts.sync` contract: `overlapSeconds:120`, `pageRows:100`, `currentRows:100`, and `responseBytes:8388608`. The relay prerequisite is [PR 8523](https://github.com/block/buzz/pull/8523). This client does not deploy it. +Sync uses the existing relay session and socket. The native transport exposes `session.artifactSync` only when NIP-11 advertises the exact version 1 `artifacts.sync` contract: `overlapSeconds:120`, `pageRows:100`, `currentRows:100`, and `responseBytes:8388608`, `permanentChannelsOnly:true`, and `checkpointLifetimeSeconds:2592000`. The relay prerequisite is [PR 8523](https://github.com/block/buzz/pull/8523). This client does not deploy it. Each collection has one fixed current query. An initial paged read discovers authorized artifacts. Later reads use an opaque checkpoint for changes since the last successful server start time, with a 120-second overlap. Live messages do not advance progress. The overlap is a recovery assumption. It does not prove recovery from every delayed database commit. Sync covers permanent channels. TTL retirement is outside this contract. diff --git a/src/features/relay/artifact-reader.ts b/src/features/relay/artifact-reader.ts index e5c51b796b..c2eaa345e6 100644 --- a/src/features/relay/artifact-reader.ts +++ b/src/features/relay/artifact-reader.ts @@ -7,6 +7,8 @@ export const ARTIFACT_SYNC_LIMITS = Object.freeze({ pageRows: 100, currentRows: 100, responseBytes: 8_388_608, + permanentChannelsOnly: true, + checkpointLifetimeSeconds: 2_592_000, }); export type ArtifactSyncQuery = Readonly<{ artifact: "current"; diff --git a/src/features/relay/artifact-sync.test.ts b/src/features/relay/artifact-sync.test.ts index b624f92062..761fca730f 100644 --- a/src/features/relay/artifact-sync.test.ts +++ b/src/features/relay/artifact-sync.test.ts @@ -432,6 +432,11 @@ it("uses bounded current reads and requests a delta after live overflow", async finish(); await vi.advanceTimersByTimeAsync(0); expect(reconcileArtifactCurrent).toHaveBeenCalledTimes(10); + expect( + vi.mocked(reconcileArtifactDelta).mock.invocationCallOrder[1], + ).toBeLessThan( + vi.mocked(reconcileArtifactCurrent).mock.invocationCallOrder[0] as number, + ); expect( vi .mocked(reconcileArtifactCurrent) @@ -439,3 +444,117 @@ it("uses bounded current reads and requests a delta after live overflow", async ).toBe(true); expect(reconcileArtifactDelta).toHaveBeenCalledTimes(2); }); + +it("keeps capacity backoff when a live read starts before the repair timer", async () => { + const h = setup(); + h.add(); + await vi.advanceTimersByTimeAsync(30_000); + vi.mocked(reconcileArtifactCurrent).mockRejectedValueOnce( + new ArtifactSyncError("sync_limit", "Large"), + ); + const event = eventDto( + signed(keypair(), { + kind: 45010, + content: "{}", + tags: [ + ["type", "test.task"], + ["d", "task"], + ], + }), + ); + h.confirmed(event); + await vi.advanceTimersByTimeAsync(0); + expect(reconcileArtifactCurrent).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(30_000); + h.confirmed(event); + await vi.advanceTimersByTimeAsync(0); + expect(reconcileArtifactCurrent).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(30_000); + expect(reconcileArtifactCurrent).toHaveBeenCalledTimes(2); +}); + +it("keeps transient retry times under live hints and reports a persistent current failure", async () => { + const h = setup(); + h.add(); + await vi.advanceTimersByTimeAsync(0); + vi.mocked(reconcileArtifactCurrent).mockRejectedValue( + new Error("Disconnected"), + ); + const event = eventDto( + signed(keypair(), { + kind: 45010, + content: "{}", + tags: [ + ["type", "test.task"], + ["d", "task"], + ], + }), + ); + h.confirmed(event); + await vi.advanceTimersByTimeAsync(0); + for (let i = 0; i < 10; i++) { + h.confirmed(event); + await vi.advanceTimersByTimeAsync(50); + } + expect(reconcileArtifactCurrent).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(500); + expect(reconcileArtifactCurrent).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(2000); + expect(reconcileArtifactCurrent).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(4000); + expect(reconcileArtifactCurrent).toHaveBeenCalledTimes(4); + expect(vi.mocked(reconcileArtifactCurrent).mock.calls[3]?.[5]).toEqual({ + reportFailure: true, + }); + expect(reconcileArtifactDelta).toHaveBeenCalledOnce(); +}); + +it("keeps the periodic delta deadline under continuing live hints", async () => { + const h = setup(); + h.add(); + await vi.advanceTimersByTimeAsync(0); + const event = eventDto( + signed(keypair(), { + kind: 45010, + content: "{}", + tags: [ + ["type", "test.task"], + ["d", "task"], + ], + }), + ); + for (let i = 0; i < 6; i++) { + await vi.advanceTimersByTimeAsync(9000); + h.confirmed(event); + await vi.advanceTimersByTimeAsync(0); + } + expect(reconcileArtifactDelta).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(6000); + expect(reconcileArtifactDelta).toHaveBeenCalledTimes(2); +}); + +it("keeps a failed delta retry round ahead of queued live reads", async () => { + const h = setup(); + vi.mocked(reconcileArtifactDelta).mockRejectedValue( + new Error("Disconnected"), + ); + h.add(); + await vi.advanceTimersByTimeAsync(0); + const event = eventDto( + signed(keypair(), { + kind: 45010, + content: "{}", + tags: [ + ["type", "test.task"], + ["d", "task"], + ], + }), + ); + h.confirmed(event); + await vi.advanceTimersByTimeAsync(7000); + expect(reconcileArtifactDelta).toHaveBeenCalledTimes(4); + expect(vi.mocked(reconcileArtifactDelta).mock.calls[3]?.[4]).toEqual({ + reportFailure: true, + }); + expect(reconcileArtifactCurrent).not.toHaveBeenCalled(); +}); diff --git a/src/features/relay/artifact-sync.ts b/src/features/relay/artifact-sync.ts index 0e4b60e002..87470602b1 100644 --- a/src/features/relay/artifact-sync.ts +++ b/src/features/relay/artifact-sync.ts @@ -105,6 +105,7 @@ export function provideArtifactSync( let requested = true; const dirty = new Set(); let retry = 0; + let retryKind: "delta" | "current" | undefined; let notBefore = 0; let timer: ReturnType | undefined; let batch: AbortController | undefined; @@ -118,11 +119,11 @@ export function provideArtifactSync( now.session.live.snapshot().status === "connected" ); }; - const schedule = (delay: number) => { + const schedule = (delay: number, delta = false) => { clearTimeout(timer); timer = setTimeout(() => { timer = undefined; - requested = true; + requested ||= delta; void pump(); }, delay); }; @@ -139,6 +140,7 @@ export function provideArtifactSync( } }; group.request = request; + const repair = setInterval(request, REPAIR_MS); async function pump() { if ( running || @@ -147,6 +149,10 @@ export function provideArtifactSync( !active() ) return; + if (Date.now() < notBefore) { + schedule(notBefore - Date.now()); + return; + } const current = session; const reader = current?.artifactSync; if (!current || !reader) return; @@ -160,6 +166,7 @@ export function provideArtifactSync( batch = new AbortController(); const signal = AbortSignal.any([group.lifetime.signal, batch.signal]); let syncing: OpenArtifactCollection | undefined; + let deltaWork = false; try { const entry = [...group.entries][0]; if (!entry || entry.signal.aborted) return; @@ -171,9 +178,14 @@ export function provideArtifactSync( JSON.stringify([entry.owner, entry.definition.name]), ); operation.throwIfAborted(); - const ids = accepted.artifactIds.length - ? accepted.artifactIds.slice(0, ARTIFACT_SYNC_LIMITS.currentRows) - : [...dirty].slice(0, ARTIFACT_SYNC_LIMITS.currentRows); + const ids = + retryKind === "delta" + ? [] + : accepted.artifactIds.length + ? accepted.artifactIds.slice(0, ARTIFACT_SYNC_LIMITS.currentRows) + : requested && retryKind !== "current" + ? [] + : [...dirty].slice(0, ARTIFACT_SYNC_LIMITS.currentRows); if (ids.length) { for (const id of ids) dirty.delete(id); try { @@ -194,6 +206,7 @@ export function provideArtifactSync( throw error; } } else if (requested) { + deltaWork = true; requested = false; await reconcileArtifactDelta( reader, @@ -204,6 +217,7 @@ export function provideArtifactSync( ); } else return; retry = 0; + retryKind = undefined; notBefore = 0; if (ids.length) queueMicrotask(() => { @@ -220,6 +234,7 @@ export function provideArtifactSync( if (groups.get(group.key) === group) groups.delete(group.key); } else { retry = 0; + retryKind = undefined; requested = true; } } else if (!signal.aborted) { @@ -227,10 +242,14 @@ export function provideArtifactSync( const permanent = error instanceof ArtifactSyncError && ["sync_limit", "sync_invalid"].includes(error.code); + requested ||= deltaWork; if (retry <= 3 && !permanent) { - requested = true; - schedule(RECOVERY_DELAYS_MS[retry - 1] ?? REPAIR_MS); + retryKind = deltaWork ? "delta" : "current"; + const delay = RECOVERY_DELAYS_MS[retry - 1] ?? REPAIR_MS; + notBefore = Date.now() + delay; + schedule(delay); } else { + retryKind = undefined; notBefore = Date.now() + REPAIR_MS; } } @@ -240,7 +259,7 @@ export function provideArtifactSync( batch = undefined; if (!group.lifetime.signal.aborted) { if (retry === 0 && (requested || dirty.size)) void pump(); - else if (!timer) schedule(REPAIR_MS); + else if (retry > 0 && !timer) schedule(REPAIR_MS, true); } } } @@ -334,6 +353,7 @@ export function provideArtifactSync( requested = true; } else if (!connected || changed) { retry = 0; + retryKind = undefined; request(); } connected = ready; @@ -351,6 +371,7 @@ export function provideArtifactSync( } finally { batch?.abort(); clearTimeout(timer); + clearInterval(repair); stopRelay(); stopSession(); stopLive(); From fd3ff777df69b8d4ea10e94e61498f8ad8c68083 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 16:20:27 -0700 Subject: [PATCH 15/30] Keep artifact repair independent of confirmation Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/artifact-sync-delivery.md | 2 +- src/features/relay/artifact-sync.test.ts | 55 +++++++++++++++++- src/features/relay/artifact-sync.ts | 64 ++++++++++++--------- src/features/relay/managed-delivery.test.ts | 59 +++++++++++++++++++ src/features/relay/outbox.test.ts | 29 +++++++++- src/features/relay/outbox.ts | 22 +++++-- 6 files changed, 194 insertions(+), 37 deletions(-) diff --git a/docs/artifact-sync-delivery.md b/docs/artifact-sync-delivery.md index 888d2397a7..564e46c838 100644 --- a/docs/artifact-sync-delivery.md +++ b/docs/artifact-sync-delivery.md @@ -34,7 +34,7 @@ The relay permits 1,024 subscriptions, ten filters per request, and 2,048 explic `delivery.artifact(id)` returns bounded, newest-first entries with `template`, `state`, and `confirmed`. Unconfirmed edits remain in the pending journal. They do not enter the bounded completed cache. A failed publication retains the attempted value and relay error. The client does not rebase stale writes automatically. A caller can display these entries over confirmed rows. -Sync freezes accepted operation IDs before starting an authoritative current-ID read. Only a successful local commit can confirm the covered operations. An acceptance during an in-flight delta or current read needs a later read. An empty delta cannot confirm an edit. A later revision or a uniform unavailable result can confirm an earlier edit. The accepted revision need not remain current. Confirmation preserves a newer pending edit. A local confirmation save failure retains acceptance and the attempted value, then exposes an error. It never republishes the accepted event. +Sync freezes accepted operation IDs before starting an authoritative current-ID read. Only a successful local commit can confirm the covered operations. An acceptance during an in-flight delta or current read needs a later read. An empty delta cannot confirm an edit. A later revision or a uniform unavailable result can confirm an earlier edit. The accepted revision need not remain current. Confirmation preserves a newer pending edit. A local confirmation save failure retains acceptance and the attempted value, then exposes an error. Confirmation waits 60 seconds before retry. Delta repair and current reads for other IDs continue. It never republishes the accepted event. `delivery.get(id)` is asynchronous. `delivery.subscribe(listener)` reports durable state changes. diff --git a/src/features/relay/artifact-sync.test.ts b/src/features/relay/artifact-sync.test.ts index 761fca730f..0004f13362 100644 --- a/src/features/relay/artifact-sync.test.ts +++ b/src/features/relay/artifact-sync.test.ts @@ -306,7 +306,7 @@ it("confirms only frozen accepted operations after a current read starts and com h.add(); await vi.advanceTimersByTimeAsync(0); expect(complete).not.toHaveBeenCalled(); - expect(reconcileArtifactDelta).not.toHaveBeenCalled(); + expect(reconcileArtifactDelta).toHaveBeenCalledOnce(); finish(); await vi.advanceTimersByTimeAsync(0); expect(complete).toHaveBeenCalledWith( @@ -558,3 +558,56 @@ it("keeps a failed delta retry round ahead of queued live reads", async () => { }); expect(reconcileArtifactCurrent).not.toHaveBeenCalled(); }); + +it("keeps delta and other live reads active when confirmation saves fail", async () => { + const h = setup(); + const complete = vi.fn(async () => { + throw new Error("Outbox storage is full"); + }); + registerArtifactDelivery(h.session, { + capture: async () => ["accepted-operation"], + targets: () => [ + { operationId: "accepted-operation", artifactId: "accepted-task" }, + ], + complete, + }); + h.add(); + await vi.advanceTimersByTimeAsync(0); + expect(complete).toHaveBeenCalledOnce(); + expect(reconcileArtifactDelta).toHaveBeenCalledTimes(2); + h.confirmed( + eventDto( + signed(keypair(), { + kind: 45010, + content: "{}", + tags: [ + ["type", "test.task"], + ["d", "other-task"], + ], + }), + ), + ); + await vi.advanceTimersByTimeAsync(0); + expect(vi.mocked(reconcileArtifactCurrent).mock.calls[1]?.[3]).toEqual([ + "other-task", + ]); + expect(complete).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(60_000); + expect(complete).toHaveBeenCalledTimes(2); + expect(reconcileArtifactDelta).toHaveBeenCalledTimes(4); +}); +it("runs a requested delta even when the delivery journal cannot be read", async () => { + const h = setup(); + registerArtifactDelivery(h.session, { + capture: async () => { + throw new Error("Journal unavailable"); + }, + targets: () => [], + complete: async () => {}, + }); + h.add(); + await vi.advanceTimersByTimeAsync(0); + expect(reconcileArtifactDelta).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(60_000 + 7000); + expect(reconcileArtifactDelta).toHaveBeenCalledTimes(2); +}); diff --git a/src/features/relay/artifact-sync.ts b/src/features/relay/artifact-sync.ts index 87470602b1..7a65642e4b 100644 --- a/src/features/relay/artifact-sync.ts +++ b/src/features/relay/artifact-sync.ts @@ -104,6 +104,7 @@ export function provideArtifactSync( let settled = Promise.resolve(); let requested = true; const dirty = new Set(); + let confirmAfter = 0; let retry = 0; let retryKind: "delta" | "current" | undefined; let notBefore = 0; @@ -172,21 +173,31 @@ export function provideArtifactSync( if (!entry || entry.signal.aborted) return; syncing = entry; const operation = AbortSignal.any([signal, entry.signal]); - const accepted = await captureArtifactDelivery( - current, - entry.definition.type, - JSON.stringify([entry.owner, entry.definition.name]), - ); - operation.throwIfAborted(); - const ids = - retryKind === "delta" - ? [] - : accepted.artifactIds.length + let currentWork = false; + if (requested && retryKind !== "current") { + deltaWork = true; + requested = false; + await reconcileArtifactDelta( + reader, + entry.collection, + entry.definition.type, + operation, + { reportFailure: retry >= 3 }, + ); + } else { + const accepted = await captureArtifactDelivery( + current, + entry.definition.type, + JSON.stringify([entry.owner, entry.definition.name]), + ); + operation.throwIfAborted(); + const canConfirm = Date.now() >= confirmAfter; + const ids = + canConfirm && accepted.artifactIds.length ? accepted.artifactIds.slice(0, ARTIFACT_SYNC_LIMITS.currentRows) - : requested && retryKind !== "current" - ? [] - : [...dirty].slice(0, ARTIFACT_SYNC_LIMITS.currentRows); - if (ids.length) { + : [...dirty].slice(0, ARTIFACT_SYNC_LIMITS.currentRows); + if (!ids.length) return; + currentWork = true; for (const id of ids) dirty.delete(id); try { await reconcileArtifactCurrent( @@ -197,7 +208,6 @@ export function provideArtifactSync( operation, { reportFailure: retry >= 3 }, ); - await accepted.complete(ids, operation); } catch (error) { for (const id of ids) { if (dirty.size < 1000) dirty.add(id); @@ -205,21 +215,21 @@ export function provideArtifactSync( } throw error; } - } else if (requested) { - deltaWork = true; - requested = false; - await reconcileArtifactDelta( - reader, - entry.collection, - entry.definition.type, - operation, - { reportFailure: retry >= 3 }, - ); - } else return; + if (canConfirm) { + try { + await accepted.complete(ids, operation); + } catch { + operation.throwIfAborted(); + // The delivery journal retains this error while shared reads continue. + confirmAfter = Date.now() + REPAIR_MS; + requested = true; + } + } + } retry = 0; retryKind = undefined; notBefore = 0; - if (ids.length) + if (currentWork || deltaWork) queueMicrotask(() => { if (!running) void pump(); }); diff --git a/src/features/relay/managed-delivery.test.ts b/src/features/relay/managed-delivery.test.ts index 281f567002..126f679214 100644 --- a/src/features/relay/managed-delivery.test.ts +++ b/src/features/relay/managed-delivery.test.ts @@ -1020,3 +1020,62 @@ it("honors completed retention while preserving a full prior artifact class", as await restarted.outbox.ready(); expect(restarted.local.snapshot()).toHaveLength(2053); }); + +it.each(["unknown", "seen"] as const)( + "restores signed %s pending rows at the saved byte bound", + async (delivery) => { + vi.useFakeTimers(); + const storage = memory(); + const row = (content: string, number: number): OutgoingEvent => { + const proof = signed(viewer, { + kind: 45010, + content, + tags: [["d", `signed-boundary-${number}`]], + }); + return { + event: { + id: proof.id, + pubkey: proof.pubkey, + created_at: proof.created_at, + kind: proof.kind, + content: proof.content, + tags: proof.tags, + }, + signed: proof, + delivery, + managed: { + version: 1, + round: 0, + attempts: 0, + relayAccepted: true, + agentReceived: false, + nextAttemptAt: 0, + receiptDeadline: 0, + artifactConfirmed: false, + }, + }; + }; + const rows = Array.from({ length: 5 }, (_, number) => + row("x".repeat(200_000), number), + ); + const padding = Math.floor((2_097_149 - JSON.stringify(rows).length) / 2); + rows[4] = row("x".repeat(200_000 + padding), 4); + expect(JSON.stringify(rows).length).toBeGreaterThanOrEqual(2_097_148); + expect(JSON.stringify(rows).length).toBeLessThanOrEqual(2_097_149); + storage.save(rows); + const h = setup(storage); + await h.outbox.ready(); + expect(h.outbox.snapshot()).toHaveLength(5); + expect(h.outbox.snapshot().every((item) => !("sig" in item.event))).toBe( + true, + ); + const chat = await h.delivery.submit(input, recipient); + expect(storage.load().some((item) => item.event.id === chat.eventId)).toBe( + true, + ); + h.dispose(); + const restarted = setup(storage); + await restarted.outbox.ready(); + expect(restarted.outbox.snapshot()).toHaveLength(6); + }, +); diff --git a/src/features/relay/outbox.test.ts b/src/features/relay/outbox.test.ts index b16aacbca2..f849f15910 100644 --- a/src/features/relay/outbox.test.ts +++ b/src/features/relay/outbox.test.ts @@ -815,7 +815,18 @@ it("restores a retained creation to retained storage when dismissal cannot persi content: "", tags: [["h", "11111111-1111-4111-8111-111111111111"]], }); - const retained: OutgoingEvent = { event, signed: event, delivery: "seen" }; + const retained: OutgoingEvent = { + event: { + id: event.id, + pubkey: event.pubkey, + created_at: event.created_at, + kind: event.kind, + content: event.content, + tags: event.tags, + }, + signed: event, + delivery: "seen", + }; let records: readonly OutgoingEvent[] = [retained]; const save = vi .fn() @@ -899,7 +910,18 @@ it.each(["commit", "reject", "echo"] as const)( content: "Retained edit", tags: [["e", "a".repeat(64)]], }); - const pending: OutgoingEvent = { event, signed: event, delivery: "failed" }; + const pending: OutgoingEvent = { + event: { + id: event.id, + pubkey: event.pubkey, + created_at: event.created_at, + kind: event.kind, + content: event.content, + tags: event.tags, + }, + signed: event, + delivery: "failed", + }; let records: readonly OutgoingEvent[] = [pending]; let release = () => {}; let reject = (_error: Error) => {}; @@ -986,7 +1008,8 @@ it.each(["commit", "reject", "echo"] as const)( if (outcome === "commit") expect(remaining).toBeUndefined(); else expect(remaining).toMatchObject({ - event, + event: pending.event, + signed: event, delivery: outcome === "echo" ? "seen" : "failed", }); expect( diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts index 5b1998fe46..69708f2460 100644 --- a/src/features/relay/outbox.ts +++ b/src/features/relay/outbox.ts @@ -596,7 +596,11 @@ export function createOutbox( throw new Error("Saved outbox event is invalid"); return Object.freeze({ event: Object.freeze({ - ...event, + id: event.id, + pubkey: event.pubkey, + created_at: event.created_at, + kind: event.kind, + content: event.content, tags: Object.freeze( event.tags.map((tag: string[]) => Object.freeze([...tag])), ) as unknown as string[][], @@ -617,12 +621,20 @@ export function createOutbox( ...(item.error ? { error: item.error } : {}), }); } - function restore(restored: readonly OutgoingEvent[]) { + function restore( + restored: readonly OutgoingEvent[], + saved: readonly OutgoingEvent[], + ) { const pending = restored.filter(pendingOperation); + // Decode can expand status text. Admission uses the saved byte count. + const savedPending = saved.filter((_item, index) => { + const item = restored[index]; + return item !== undefined && pendingOperation(item); + }); if ( pendingCount(pending, 45010) > MAX_PENDING || pendingCount(pending, 9) > MAX_PENDING || - pendingSize(pending).some((size) => size > MAX_PENDING_BYTES) + pendingSize(savedPending).some((size) => size > MAX_PENDING_BYTES) ) throw new Error("Saved pending outbox exceeds its budget"); for (const item of restored) @@ -651,11 +663,11 @@ export function createOutbox( decoded.push(...records.slice(offset, offset + 12).map(decode)); if (offset + 12 < records.length) await yieldToHost(); } - restore(decoded); + restore(decoded, records); }); else { validateSaved(loaded); - restore(loaded.map(decode)); + restore(loaded.map(decode), loaded); ready = Promise.resolve(); } } catch (error) { From 3a7b0d87065428afe7221c2ffa57b4abeb6159a9 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 16:35:49 -0700 Subject: [PATCH 16/30] Keep artifact sync within existing relay paths Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- browser-host/relay-broker-api.test.mjs | 2 +- browser-host/relay-broker-live.test.mjs | 6 +- browser-host/relay-broker.mjs | 20 +- docs/artifact-sync-delivery.md | 57 - docs/artifact-sync.md | 15 + src/features/agents/runner.ts | 9 - src/features/agents/service.ts | 14 - src/features/conversation/EmbeddedThread.tsx | 19 +- src/features/messages/DeliveryNotice.test.tsx | 58 +- src/features/messages/DeliveryNotice.tsx | 14 - src/features/messages/MessageRow.tsx | 7 +- src/features/messages/Messages.module.css | 18 - src/features/relay/artifact-delivery.ts | 36 - src/features/relay/artifact-sync.test.ts | 255 +--- src/features/relay/artifact-sync.ts | 320 ++--- src/features/relay/broker-live.test.ts | 2 +- src/features/relay/broker-live.ts | 7 +- src/features/relay/contracts.ts | 2 - src/features/relay/live-recovery.ts | 5 +- src/features/relay/live.test.ts | 281 +---- src/features/relay/live.ts | 234 +--- src/features/relay/managed-delivery.test.ts | 1081 ----------------- src/features/relay/managed-delivery.ts | 224 ---- src/features/relay/managed-ownership.test.ts | 30 - src/features/relay/message-projection.ts | 7 +- src/features/relay/messages.ts | 17 - src/features/relay/native.ts | 5 +- src/features/relay/outbox-storage.ts | 55 +- src/features/relay/outbox.test.ts | 29 +- src/features/relay/outbox.ts | 802 ++---------- src/features/relay/row-identity.ts | 8 - src/features/relay/session.ts | 165 +-- src/features/relay/thread-delivery.test.ts | 82 -- src/features/relay/threads.ts | 4 - tests/browser/artifact-sync.spec.mjs | 87 ++ tests/browser/managed-delivery.spec.mjs | 218 ---- tests/fixtures/artifact-sync.html | 1 + tests/fixtures/artifact-sync.ts | 126 ++ tests/fixtures/managed-delivery.html | 1 - tests/fixtures/managed-delivery.ts | 193 --- 40 files changed, 568 insertions(+), 3948 deletions(-) delete mode 100644 docs/artifact-sync-delivery.md create mode 100644 docs/artifact-sync.md delete mode 100644 src/features/relay/artifact-delivery.ts delete mode 100644 src/features/relay/managed-delivery.test.ts delete mode 100644 src/features/relay/managed-delivery.ts delete mode 100644 src/features/relay/managed-ownership.test.ts delete mode 100644 src/features/relay/thread-delivery.test.ts create mode 100644 tests/browser/artifact-sync.spec.mjs delete mode 100644 tests/browser/managed-delivery.spec.mjs create mode 100644 tests/fixtures/artifact-sync.html create mode 100644 tests/fixtures/artifact-sync.ts delete mode 100644 tests/fixtures/managed-delivery.html delete mode 100644 tests/fixtures/managed-delivery.ts diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs index 337e2b20f6..1713ff1f31 100644 --- a/browser-host/relay-broker-api.test.mjs +++ b/browser-host/relay-broker-api.test.mjs @@ -1231,7 +1231,7 @@ test.each([ ], ["/api/relay/archive", 4096, "bytes", "Archive request too large"], ["/api/relay/stream-presence", 256, "bytes", "Live control too large"], - ["/api/relay/stream", 32 * 1024 * 1024, "bytes", "Live interests too large"], + ["/api/relay/stream", 300000, "bytes", "Live interests too large"], ["/api/relay/query", 65536, "chars", "Filter body too large"], ["/api/relay/presence-snapshot", 20 * 1024, "bytes", "Filter body too large"], ])( diff --git a/browser-host/relay-broker-live.test.mjs b/browser-host/relay-broker-live.test.mjs index 2302a4a87d..ced5a002fc 100644 --- a/browser-host/relay-broker-live.test.mjs +++ b/browser-host/relay-broker-live.test.mjs @@ -258,11 +258,7 @@ test("real HTTP accepts the 1022-channel body and rejects invalid/oversized/orig large.abort(); await until(() => h.sockets.every((s) => s.readyState === 3)); const count = h.sockets.length; - for (const invalid of [ - [""], - Array(100001).fill("a"), - ["x".repeat(150001)], - ]) { + for (const invalid of [[""], Array(1025).fill("a"), ["x".repeat(150001)]]) { const { response } = await h.post(invalid); expect([400, 413]).toContain(response.status); await response.text(); diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs index 144a9d1f11..e40931d9c5 100644 --- a/browser-host/relay-broker.mjs +++ b/browser-host/relay-broker.mjs @@ -1483,15 +1483,14 @@ export function relayBrokerPlugin({ const prioritizing = route === "/api/relay/stream-priority"; const observing = route === "/api/relay/stream-observer"; const updating = route === "/api/relay/stream-interests"; - let raw = "", - bytes = 0; + let raw = ""; req.setEncoding("utf8"); for await (const part of req) { - bytes += Buffer.byteLength(part); + raw += part; if ( - bytes > + Buffer.byteLength(raw) > (updating - ? 32 * 1024 * 1024 + ? 450000 : watchingPresence ? 20000 : prioritizing @@ -1499,7 +1498,6 @@ export function relayBrokerPlugin({ : 256) ) return json(res, 413, { error: "Live control too large" }); - raw += part; } let streamId, presenceAuthors, @@ -1605,14 +1603,12 @@ export function relayBrokerPlugin({ return json(res, 200, { accepted: true }); } if (route === "/api/relay/stream" && req.method === "POST") { - let raw = "", - bytes = 0; + let raw = ""; req.setEncoding("utf8"); for await (const part of req) { - bytes += Buffer.byteLength(part); - if (bytes > 32 * 1024 * 1024) - return json(res, 413, { error: "Live interests too large" }); raw += part; + if (Buffer.byteLength(raw) > 300000) + return json(res, 413, { error: "Live interests too large" }); } let channels, joined, @@ -1659,7 +1655,7 @@ export function relayBrokerPlugin({ // Bound slow-client buffering rather than retaining unbounded traffic. const write = (kind, value) => { if (res.destroyed) return; - if (res.writableLength > 32 * 1024 * 1024) { + if (res.writableLength > 2 * 1024 * 1024) { res.destroy(); return; } diff --git a/docs/artifact-sync-delivery.md b/docs/artifact-sync-delivery.md deleted file mode 100644 index 564e46c838..0000000000 --- a/docs/artifact-sync-delivery.md +++ /dev/null @@ -1,57 +0,0 @@ -# Artifact sync and message delivery - -The host saves confirmed relay artifacts through `ArtifactStorageService`. Plugins own each collection definition and projector. A Web Lock covers `{viewer, relay, owner, collectionName}`. Only a window with that definition can own the sync work. The owner cancels reads and waits for commits before it releases the lock. The message outbox has a separate lock for `{viewer, relay}`. Each retained relay session keeps its lock until disposal. Community selection does not stop pending delivery. - -## Sync contract - -Sync uses the existing relay session and socket. The native transport exposes `session.artifactSync` only when NIP-11 advertises the exact version 1 `artifacts.sync` contract: `overlapSeconds:120`, `pageRows:100`, `currentRows:100`, and `responseBytes:8388608`, `permanentChannelsOnly:true`, and `checkpointLifetimeSeconds:2592000`. The relay prerequisite is [PR 8523](https://github.com/block/buzz/pull/8523). This client does not deploy it. - -Each collection has one fixed current query. An initial paged read discovers authorized artifacts. Later reads use an opaque checkpoint for changes since the last successful server start time, with a 120-second overlap. Live messages do not advance progress. The overlap is a recovery assumption. It does not prove recovery from every delayed database commit. Sync covers permanent channels. TTL retirement is outside this contract. - -The relay returns signed current upserts, explicit removals, and retained channel removals. The client validates each response before applying it. Removal keeps cached content and preferences. It marks the row unavailable. A conditional removal uses the locally observed revision. A mismatch fails the commit and prevents progress. Empty or partial replies never establish absence. - -One collection owner serializes each whole delta pass and each bounded current-ID read from request start through local commit. Raw live payloads request current-ID reads. They do not replace confirmed rows. Duplicate hints share a bounded set of 1,000 IDs. Overflow requests another delta. Accepted edits remain in the durable delivery journal and do not depend on that set. The client has no revision ancestry graph or local timestamp comparator. - -Checkpoint tokens bind the reader, community, exact query, and server start time. A policy change or a checkpoint older than 30 days requires recovery. The client runs a fresh authorized query and revalidates saved IDs in bounded current reads. Missing, unmatched, and unauthorized IDs all receive the same unavailable result. Local-only IDs use that same result. No hidden roster is returned. Both recovery steps must succeed before progress is saved. A failed recovery repeats from saved progress. - -The store checks query, schema, refresh generation, owner cancellation, and checkpoint comparison. Pages can save during a failed pass. Only successful completion saves progress and clears the persistent refresh error. A current-ID read does not advance delta progress or clear that error. A failed page cannot advance progress. Late owners cannot commit after cancellation. - -Repairs run after reconnect, collection changes, roster changes, and every 60 seconds. Channel activity alone does not request repair. Transient failures get three short retries before the next repair interval. Invalid requests and capacity errors use that interval directly. Live traffic cannot bypass the delay. Current-ID capacity errors split the bounded batch; one oversized result remains an error. Delta pages use the relay byte bound. The sync path has no roster admission limit. - -A relay without the exact capability keeps the saved view and rejects new managed artifact intent before persistence. Existing pending work remains saved. A temporary refresh failure does not close the capability gate. Local reads do not wait for repair. - -## Live channel groups - -Large joined rosters use live groups of at most 1,000 channels. Each group stays open while recent replay reads run in groups of ten channels. At most four requests await EOSE. Each replay filter keeps its 500-event limit. EOSE establishes a route; it does not prove that history is complete. A full limit remains incomplete evidence. Existing finite catch-up owns older gaps. The agent runner retains its event-ID checks before dispatch. - -Admission counts retained routes and reserves one wire for group replacement. An expanded group retains its established subset if setup fails transiently. Only added channels need replay when the scope grows. New joined channels fill groups with space. Retired membership scopes remain fenced. The transport accepts at most 100,000 channel interests. Singleton previews consume separate routes. Demand beyond route capacity gets a limited status. Grouped interests preserve ordinary chat and all-channel agent watches. They do not make 100,000 simultaneous agent runs a supported workload. - -The relay permits 1,024 subscriptions, ten filters per request, and 2,048 explicit channel values per request. The default frame limit is 524,288 bytes. Browser broker controls and status frames have a separate 32 MiB bound because their local status includes each channel. This bound does not change relay event limits. - -## Managed delivery - -`session.messagesForRecipient(agentPubkey)` uses the existing message validation and composer path. `EmbeddedThread.managedRecipient` selects this path for a task conversation. Ordinary chat keeps its existing behavior. `session.delivery.submit(input)` admits kind-45010 artifact operations. `delivery.submit(input,{operationKey,collection:{owner,name}})` saves a caller key with artifact intent. Submits serialize across windows. `delivery.lookup(operationKey)` finds that retained intent after a restart. The same key rejects different input. A retained key saves any unsaved intent before it returns success. This closes the gap before a caller saves the returned ID. Key lookup lasts while the journal retains the record. - -`delivery.artifact(id)` returns bounded, newest-first entries with `template`, `state`, and `confirmed`. Unconfirmed edits remain in the pending journal. They do not enter the bounded completed cache. A failed publication retains the attempted value and relay error. The client does not rebase stale writes automatically. A caller can display these entries over confirmed rows. - -Sync freezes accepted operation IDs before starting an authoritative current-ID read. Only a successful local commit can confirm the covered operations. An acceptance during an in-flight delta or current read needs a later read. An empty delta cannot confirm an edit. A later revision or a uniform unavailable result can confirm an earlier edit. The accepted revision need not remain current. Confirmation preserves a newer pending edit. A local confirmation save failure retains acceptance and the attempted value, then exposes an error. Confirmation waits 60 seconds before retry. Delta repair and current reads for other IDs continue. It never republishes the accepted event. - -`delivery.get(id)` is asynchronous. `delivery.subscribe(listener)` reports durable state changes. - -Artifact intent and other outgoing intent each have a 256-record and 2 MiB pending quota. The combined pending bound is 512 records and 4 MiB. Completed retention stays at 2,048 records and 8 MiB. Artifact exhaustion does not consume chat admission. New writes reserve their signed envelope before enqueue. Restored rows use saved bytes. Signing checks the resulting row before it replaces pending state. If retry status in one full class cannot save, a commit for the other class retains the saved rows of that class. - -The outbox saves intent and the signed event before publication. Artifact events keep their supplied envelope tags. Identical submissions with the same signed event ID share one operation. It retains the same event ID across uncertain retries. The owner makes at most three attempts before an error. A definitive relay refusal stops automatic retries. An explicit retry clears the refusal. A settled refusal can be discarded. Acceptance and exact agent receipt are separate saved facts. Stale writes from another window cannot clear either fact. Within one retry round, merges preserve receipt deadlines, retry delays, and errors. A new round permits an explicit retry. - -`AgentRun.received(messageIds)` reports only the exact original message IDs. The host binds the channel and agent identity. The plugin calls it at a verified harness receipt boundary. Process start, typing, and an unrelated reply do not prove receipt. The host saves receipt before it clears the opacity pulse. A save failure keeps accepted evidence and exposes an unconfirmed state. - -Normal managed delivery has no extra status label. Message opacity pulses until exact receipt. Reduced motion uses static opacity. After 30 seconds without receipt, the pulse stops and the message shows `Could not confirm delivery`. `Check again` rereads saved evidence and permits a late exact callback. It never republishes an accepted message. A later exact receipt clears the error. - -Exact receipt is local in v0. A crash before receipt persistence, or receipt on another device, can leave an accepted message unconfirmed. There is no remote receipt recovery protocol. Kind 44200 is a completed-turn usage metric and cannot carry a receipt-only event. A separate protocol is necessary to remove this ambiguity. - -## Verification limits - -Focused tests cover publication, exact receipt, restart, stale writes, lost confirmations, refusal, expiry, and completion fences. Browser checks use real IndexedDB, BroadcastChannel, and Web Locks in Chromium and WebKit. They cover keyed submits across windows, restart, and a stale save after authoritative confirmation. The socket scale check covers 10,000 active homes plus 50,000 archive homes and a normal chat. It verifies grouping and request bounds with a test socket. It does not measure the deployed relay's startup cost or prove native sync at that scale. - -A separate macOS bundle uses the saved debug identity and the installed Nexus plugin. Native storage checks verify restart, indexed reads, explicit false preferences, and committed notifications. Native checks of pending artifact views under the single-query contract remain pending. DOM checks verify the pulse in both themes, the receipt timeout, and focus and activation of `Check again`. The accepted event ID stays the same. These checks do not include screenshots, width changes, or enlarged text. - -Native incremental sync verification requires the compatible relay prerequisite and this client together. The last checked live community does not advertise this sync capability. Exact receipt from a real harness requires the Nexus adoption layer. Unit and browser checks verify the bound callback and saved receipt state. diff --git a/docs/artifact-sync.md b/docs/artifact-sync.md new file mode 100644 index 0000000000..a7ab82bfb2 --- /dev/null +++ b/docs/artifact-sync.md @@ -0,0 +1,15 @@ +# Local artifact sync + +The host saves relay records through `ArtifactStorageService`. Each plugin defines one collection and one fixed query. A Web Lock covers `{viewer,relay,owner,collectionName}`. Its owner cancels reads and waits for commits before it releases the lock. + +The native transport exposes `session.artifactSync` when the relay advertises the exact `artifacts.sync` v1 contract. It covers permanent channels, 100-row pages, 100-ID current reads, an 8 MiB response bound, and a 30-day checkpoint lifetime. The relay prerequisite is [PR 8523](https://github.com/block/buzz/pull/8523). + +The first read discovers authorized records. Later reads use a checkpoint with a 120-second overlap from the last successful server start time. Only a full paged pass saves progress. Live pushes request current-ID reads through the same collection queue. They do not advance progress or directly write saved rows. The existing outbox acceptance observer also requests a current read. + +The client applies signed upserts and explicit removals. A removal uses the locally observed revision for a conditional write. It retains the body and preferences, and marks the record unavailable. A failed commit prevents progress. Empty replies never establish absence. + +A policy change or an expired checkpoint requires a fresh query plus bounded revalidation of saved IDs. Missing, unmatched, and unauthorized IDs return the same unavailable result. Recovery saves progress only after both steps succeed. It does not return a hidden roster. + +Refresh generations, schema checks, and owner cancellation fence each commit. Failed reads keep saved progress and data. Transient failures get three short retries, then a 60-second delay. Invalid replies and capacity failures use that delay directly. A persistent failure exposes `refreshError()`. Successful delta completion clears it. A 60-second repair runs independently of live traffic. A bounded set holds up to 1,000 live IDs; overflow requests another delta. + +The 120-second overlap assumes bounded database delay. It does not prove recovery from every delayed commit. TTL retirement is outside this contract. Native end-to-end verification needs the compatible relay and client together. diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts index 37b1d14d8e..793f7a304c 100644 --- a/src/features/agents/runner.ts +++ b/src/features/agents/runner.ts @@ -311,11 +311,6 @@ const message = (error: unknown) => /** What the runner needs of its host. */ export type RunnerHost = { - received?( - agent: Agent, - channelId: string, - messageIds: readonly string[], - ): Promise; /** Session controls refresh when this copy gains or loses native custody. */ ownershipChanged?(): void; /** The agent, while its community is live to run it in. */ @@ -2005,10 +2000,6 @@ export class AgentRunners { workingUntil: working.hold, ignored: () => job.work?.remove(), acknowledged: () => job.work?.started(), - received: (messageIds: readonly string[]) => - job.channelId && this.host.received - ? this.host.received(agent, job.channelId, messageIds) - : Promise.reject(new Error("Message receipt has no channel")), superseded: () => job.work?.supersede(), interruptible: (stop: () => Promise) => job.work?.interruptible(stop), diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index f06fc0479d..c2d8f213a5 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -1,5 +1,4 @@ import { createLocalAgentActivity } from "./local-activity"; -import { receiveManagedDelivery } from "../relay/managed-delivery"; import { AgentConfigStore, type SettingsSchema, @@ -473,8 +472,6 @@ export type Delivery = Readonly<{ ignored?(): void; /** The harness acknowledged this exact request. Not proof of model consumption. */ acknowledged?(): void; - /** Exact prompt receipt. The caller waits for durable host confirmation. */ - received?(messageIds: readonly string[]): Promise; /** A newer acknowledged request owns this turn's activity; hide this message's * bubble without claiming success or changing the execution outcome. */ superseded?(): void; @@ -1240,17 +1237,6 @@ export class AgentsService extends Service implements Agents { starting: (agent, channelId) => this.bindingOf(agent)?.session.channelWrites.starting(channelId), community: (agent) => this.bindingOf(agent), - received: (agent, channelId, messageIds) => { - const binding = this.bindingOf(agent); - if (!binding) - return Promise.reject(new Error("Agent community is unavailable")); - return receiveManagedDelivery( - binding.scope, - channelId, - agent.pubkey, - messageIds, - ); - }, records: () => Object.values(this.records), checkpoints: { read: () => (savedProgress = readCheckpoints(this.storage)), diff --git a/src/features/conversation/EmbeddedThread.tsx b/src/features/conversation/EmbeddedThread.tsx index d2cb6f1499..e1278ffa9d 100644 --- a/src/features/conversation/EmbeddedThread.tsx +++ b/src/features/conversation/EmbeddedThread.tsx @@ -1,4 +1,4 @@ -import { useMemo, useSyncExternalStore } from "react"; +import { useSyncExternalStore } from "react"; import type { ThreadPanelProps } from "../messages/ThreadPanel"; import { messageViewKey } from "../messages/view-key"; import { ConversationChannel, ConversationThread } from "./Conversation"; @@ -8,7 +8,7 @@ import type { ConversationHostServices } from "./ConversationHost"; export type EmbeddedThreadProps = Pick< ThreadPanelProps, "session" | "scope" | "channelId" | "channelName" | "messageId" -> & { managedRecipient?: string | undefined }; +>; type Props = EmbeddedThreadProps & { host: ConversationHostServices; extensions: NonNullable; @@ -23,18 +23,7 @@ export function EmbeddedThread({ messageId, host, extensions, - managedRecipient, }: Props) { - const managedSession = useMemo( - () => - managedRecipient - ? Object.freeze({ - ...session, - messages: session.messagesForRecipient(managedRecipient), - }) - : session, - [session, managedRecipient], - ); const { channels } = useSyncExternalStore( session.channels.subscribeList, session.channels.list, @@ -42,8 +31,8 @@ export function EmbeddedThread({ const channel = channels.find((item) => item.id === channelId); return ( { - render( - , - ); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - expect(screen.queryByRole("button")).not.toBeInTheDocument(); - }, -); -it.each([false, true])( - "uses the recovery action for relay acceptance %s", - (relayAccepted) => { - const retry = vi.fn(); - render( - , - ); - expect(screen.getByRole("status")).toHaveTextContent( - relayAccepted ? "Could not confirm delivery" : "Could not send", - ); - fireEvent.click( - screen.getByRole("button", { - name: relayAccepted ? "Check again" : "Retry", - }), - ); - expect(retry).toHaveBeenCalledExactlyOnceWith(row.id); - }, -); it.each(["sending", "accepted", "unknown", "failed"] as const)( "keeps %s feedback visible without a retry handler", diff --git a/src/features/messages/DeliveryNotice.tsx b/src/features/messages/DeliveryNotice.tsx index 3b95919c74..470b0e2f7c 100644 --- a/src/features/messages/DeliveryNotice.tsx +++ b/src/features/messages/DeliveryNotice.tsx @@ -22,20 +22,6 @@ export function DeliveryNotice({ return () => clearTimeout(timer); }, [row.createdAt, row.delivery]); const feedback = deliveryFeedback(row, now); - if (row.managedDelivery) { - const managed = row.managedDelivery; - if (managed.phase !== "failed") return null; - return ( -
- {managed.error} - {retry && ( - - )} -
- ); - } if (!feedback) return null; const canRetry = (row.delivery === "failed" || row.delivery === "unknown") && retry; diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index 3d711840da..589d99c311 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -763,12 +763,7 @@ function MessageRowContent({ > {!renderMessage && } -
+
{!renderMessage && overlays}
; - targets( - ids: readonly string[], - ): readonly { operationId: string; artifactId: string }[]; - complete(ids: readonly string[], signal: AbortSignal): Promise; -}; -const sessions = new WeakMap(); -export function registerArtifactDelivery( - session: object, - delivery: ArtifactDelivery, -) { - sessions.set(session, delivery); -} -export async function captureArtifactDelivery( - session: object, - type: string, - collection: string, -) { - const delivery = sessions.get(session); - const ids = (await delivery?.capture(type, collection)) ?? []; - const targets = delivery?.targets(ids) ?? []; - return { - artifactIds: [...new Set(targets.map((target) => target.artifactId))], - async complete(artifactIds: readonly string[], signal: AbortSignal) { - signal.throwIfAborted(); - const covered = new Set(artifactIds); - await delivery?.complete( - targets - .filter((target) => covered.has(target.artifactId)) - .map((target) => target.operationId), - signal, - ); - }, - }; -} diff --git a/src/features/relay/artifact-sync.test.ts b/src/features/relay/artifact-sync.test.ts index 0004f13362..e3d8d6daa5 100644 --- a/src/features/relay/artifact-sync.test.ts +++ b/src/features/relay/artifact-sync.test.ts @@ -6,13 +6,12 @@ import type { } from "../artifact-storage/service"; import { provideArtifactSync } from "./artifact-sync"; import { ArtifactSyncError } from "./artifact-reader"; -import { registerArtifactDelivery } from "./artifact-delivery"; import { reconcileArtifactDelta, reconcileArtifactCurrent, } from "./artifact-reconcile"; import { keypair, signed } from "./testing"; -import { eventDto, type RelayEvent } from "./events"; +import { eventDto, type EventData, type RelayEvent } from "./events"; vi.mock("./artifact-reconcile", async (original) => ({ ...(await original()), reconcileArtifactDelta: vi.fn(async () => {}), @@ -22,7 +21,6 @@ const stops: (() => void)[] = []; afterEach(async () => { for (const stop of stops.splice(0)) stop(); await vi.advanceTimersByTimeAsync(0); - vi.clearAllMocks(); vi.mocked(reconcileArtifactDelta).mockReset().mockResolvedValue(undefined); vi.mocked(reconcileArtifactCurrent).mockReset().mockResolvedValue(undefined); vi.unstubAllGlobals(); @@ -34,11 +32,10 @@ function setup() { locks: { request: vi.fn(async (_key, _options, callback) => callback()) }, }); const listeners = new Set<() => void>(); - let confirmed: (events: readonly RelayEvent[]) => void = () => {}; + let receive: (value: { events: readonly RelayEvent[] }) => void = () => {}; + let accepted: (event: EventData) => void = () => {}; let opened: (entry: OpenArtifactCollection) => void = () => {}; let online = true; - let rosterListener = () => {}; - let channels = [{ id: "one", lastActivityAt: 0 }]; const session = { artifactSync: {}, live: { @@ -50,31 +47,34 @@ function setup() { }; }, }, - channels: { - list: () => ({ channels }), - subscribeList: (listener: () => void) => { - rosterListener = listener; - return () => {}; - }, - }, - subscribeLive: () => () => {}, - subscribeArtifactUpdates: (listener: typeof confirmed) => { - confirmed = listener; + subscribeLive: (listener: typeof receive) => { + receive = listener; return () => { - confirmed = () => {}; + receive = () => {}; }; }, + outbox: { + observeSend: ( + listener: ( + event: EventData, + ) => ((pending: readonly EventData[]) => void) | undefined, + ) => { + accepted = (event) => listener(event)?.([]); + return () => { + accepted = () => {}; + }; + }, + }, }; - const snapshot = () => ({ - viewer: "viewer", - origin: "https://relay.test", - status: "ready", - cached: false, - session, - }); const ctx = { relay: { - snapshot, + snapshot: () => ({ + viewer: "viewer", + origin: "https://relay.test", + status: "ready", + cached: false, + session, + }), subscribe: (listener: () => void) => { listeners.add(listener); return () => { @@ -86,15 +86,14 @@ function setup() { stops.push(effect()); }, }; - const storage = { - onOpen: (listener: typeof opened) => { - opened = listener; - return () => {}; - }, - }; provideArtifactSync( ctx as unknown as Context, - storage as unknown as ArtifactStorageService, + { + onOpen: (listener: typeof opened) => { + opened = listener; + return () => {}; + }, + } as unknown as ArtifactStorageService, ); const add = (version = 1) => { const controller = new AbortController(); @@ -109,18 +108,13 @@ function setup() { } as unknown as OpenArtifactCollection); return { controller, collection }; }; - const connection = (value: boolean) => { - online = value; - for (const listener of listeners) listener(); - }; return { - session, add, - confirmed: (event: RelayEvent) => confirmed([event]), - connection, - roster: (value: typeof channels) => { - channels = value; - rosterListener(); + confirmed: (event: RelayEvent) => receive({ events: [event] }), + accepted: (event: EventData) => accepted(event), + connection: (value: boolean) => { + online = value; + for (const listener of listeners) listener(); }, }; } @@ -176,20 +170,6 @@ it("refreshes after proven acceptance without writing raw revisions", async () = expect(reconcileArtifactCurrent).toHaveBeenCalledOnce(); }); -it("ignores channel activity but repairs when roster membership changes", async () => { - const h = setup(); - h.add(); - await vi.advanceTimersByTimeAsync(0); - h.roster([{ id: "one", lastActivityAt: 10 }]); - await vi.advanceTimersByTimeAsync(0); - expect(reconcileArtifactDelta).toHaveBeenCalledOnce(); - h.roster([ - { id: "one", lastActivityAt: 10 }, - { id: "two", lastActivityAt: 0 }, - ]); - await vi.advanceTimersByTimeAsync(0); - expect(reconcileArtifactDelta).toHaveBeenCalledTimes(2); -}); it("releases an expired version so a current collection can own sync", async () => { const h = setup(); vi.mocked(reconcileArtifactDelta).mockRejectedValueOnce( @@ -282,100 +262,6 @@ it("keeps a current handle when an older in-flight version fails", async () => { expect(reconcileArtifactDelta).toHaveBeenCalledTimes(3); expect(navigator.locks.request).toHaveBeenCalledOnce(); }); -it("confirms only frozen accepted operations after a current read starts and commits", async () => { - const h = setup(); - let pending = true; - const capture = vi.fn(async () => (pending ? ["accepted-operation"] : [])); - const targets = vi.fn(() => - pending ? [{ operationId: "accepted-operation", artifactId: "task" }] : [], - ); - const complete = vi.fn(async () => { - pending = false; - }); - registerArtifactDelivery(h.session, { capture, targets, complete }); - let finish = () => {}; - vi.mocked(reconcileArtifactCurrent).mockImplementationOnce(async () => { - expect(capture).toHaveBeenCalledWith( - "test.task", - JSON.stringify(["test.plugin", "tasks"]), - ); - await new Promise((resolve) => { - finish = resolve; - }); - }); - h.add(); - await vi.advanceTimersByTimeAsync(0); - expect(complete).not.toHaveBeenCalled(); - expect(reconcileArtifactDelta).toHaveBeenCalledOnce(); - finish(); - await vi.advanceTimersByTimeAsync(0); - expect(complete).toHaveBeenCalledWith( - ["accepted-operation"], - expect.any(AbortSignal), - ); - expect(reconcileArtifactDelta).toHaveBeenCalledOnce(); -}); -it("does not attach acceptance during a delta to that in-flight response", async () => { - const h = setup(); - let pending = false; - const complete = vi.fn(async () => { - pending = false; - }); - registerArtifactDelivery(h.session, { - capture: async () => (pending ? ["operation"] : []), - targets: () => - pending ? [{ operationId: "operation", artifactId: "task" }] : [], - complete, - }); - let finish = () => {}; - vi.mocked(reconcileArtifactDelta).mockImplementationOnce(async () => { - await new Promise((resolve) => { - finish = resolve; - }); - }); - h.add(); - await vi.advanceTimersByTimeAsync(0); - pending = true; - h.confirmed( - eventDto( - signed(keypair(), { - kind: 45010, - content: "{}", - tags: [ - ["type", "test.task"], - ["d", "task"], - ], - }), - ), - ); - expect(reconcileArtifactCurrent).not.toHaveBeenCalled(); - expect(complete).not.toHaveBeenCalled(); - finish(); - await vi.advanceTimersByTimeAsync(0); - expect(reconcileArtifactCurrent).toHaveBeenCalledOnce(); - expect(complete).toHaveBeenCalledWith(["operation"], expect.any(AbortSignal)); -}); -it("retains an accepted obligation after a failed current read", async () => { - const h = setup(); - let pending = true; - const complete = vi.fn(async () => { - pending = false; - }); - registerArtifactDelivery(h.session, { - capture: async () => (pending ? ["operation"] : []), - targets: () => - pending ? [{ operationId: "operation", artifactId: "task" }] : [], - complete, - }); - vi.mocked(reconcileArtifactCurrent).mockRejectedValueOnce( - new Error("Disconnected"), - ); - h.add(); - await vi.advanceTimersByTimeAsync(0); - expect(complete).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(60_000); - expect(complete).toHaveBeenCalledWith(["operation"], expect.any(AbortSignal)); -}); it("keeps duplicate delayed hints behind an in-flight current read", async () => { const h = setup(); h.add(); @@ -432,11 +318,6 @@ it("uses bounded current reads and requests a delta after live overflow", async finish(); await vi.advanceTimersByTimeAsync(0); expect(reconcileArtifactCurrent).toHaveBeenCalledTimes(10); - expect( - vi.mocked(reconcileArtifactDelta).mock.invocationCallOrder[1], - ).toBeLessThan( - vi.mocked(reconcileArtifactCurrent).mock.invocationCallOrder[0] as number, - ); expect( vi .mocked(reconcileArtifactCurrent) @@ -559,55 +440,27 @@ it("keeps a failed delta retry round ahead of queued live reads", async () => { expect(reconcileArtifactCurrent).not.toHaveBeenCalled(); }); -it("keeps delta and other live reads active when confirmation saves fail", async () => { +it("requests a current read from the existing outbox acceptance observer", async () => { const h = setup(); - const complete = vi.fn(async () => { - throw new Error("Outbox storage is full"); - }); - registerArtifactDelivery(h.session, { - capture: async () => ["accepted-operation"], - targets: () => [ - { operationId: "accepted-operation", artifactId: "accepted-task" }, - ], - complete, - }); h.add(); await vi.advanceTimersByTimeAsync(0); - expect(complete).toHaveBeenCalledOnce(); - expect(reconcileArtifactDelta).toHaveBeenCalledTimes(2); - h.confirmed( - eventDto( - signed(keypair(), { - kind: 45010, - content: "{}", - tags: [ - ["type", "test.task"], - ["d", "other-task"], - ], - }), - ), + h.accepted( + signed(keypair(), { + kind: 45010, + content: "{}", + tags: [ + ["type", "test.task"], + ["d", "task"], + ], + }), ); await vi.advanceTimersByTimeAsync(0); - expect(vi.mocked(reconcileArtifactCurrent).mock.calls[1]?.[3]).toEqual([ - "other-task", - ]); - expect(complete).toHaveBeenCalledOnce(); - await vi.advanceTimersByTimeAsync(60_000); - expect(complete).toHaveBeenCalledTimes(2); - expect(reconcileArtifactDelta).toHaveBeenCalledTimes(4); -}); -it("runs a requested delta even when the delivery journal cannot be read", async () => { - const h = setup(); - registerArtifactDelivery(h.session, { - capture: async () => { - throw new Error("Journal unavailable"); - }, - targets: () => [], - complete: async () => {}, - }); - h.add(); - await vi.advanceTimersByTimeAsync(0); - expect(reconcileArtifactDelta).toHaveBeenCalledOnce(); - await vi.advanceTimersByTimeAsync(60_000 + 7000); - expect(reconcileArtifactDelta).toHaveBeenCalledTimes(2); + expect(reconcileArtifactCurrent).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + "test.task", + ["task"], + expect.any(AbortSignal), + expect.anything(), + ); }); diff --git a/src/features/relay/artifact-sync.ts b/src/features/relay/artifact-sync.ts index 7a65642e4b..eb319e4ad1 100644 --- a/src/features/relay/artifact-sync.ts +++ b/src/features/relay/artifact-sync.ts @@ -10,7 +10,6 @@ import { reconcileArtifactCurrent, } from "./artifact-reconcile"; import { ArtifactSyncError, ARTIFACT_SYNC_LIMITS } from "./artifact-reader"; -import { captureArtifactDelivery } from "./artifact-delivery"; import { transportPartition } from "./partition"; import { RECOVERY_DELAYS_MS } from "./recovery"; @@ -97,16 +96,12 @@ export function provideArtifactSync( let session: RelaySession | undefined; let stopSession = () => {}; let stopLive = () => {}; - let stopConfirmed = () => {}; - let stopRoster = () => {}; + let stopWrites = () => {}; let connected = false; - let running = false; - let settled = Promise.resolve(); + let running: Promise | undefined; let requested = true; const dirty = new Set(); - let confirmAfter = 0; let retry = 0; - let retryKind: "delta" | "current" | undefined; let notBefore = 0; let timer: ReturnType | undefined; let batch: AbortController | undefined; @@ -120,241 +115,155 @@ export function provideArtifactSync( now.session.live.snapshot().status === "connected" ); }; - const schedule = (delay: number, delta = false) => { + const request = () => { + requested = true; + pump(); + }; + const hint = (event: import("./events").EventData) => { + if ( + event.kind === 45010 && + [...group.entries].some((entry) => + event.tags.some( + ([key, value]) => key === "type" && value === entry.definition.type, + ), + ) + ) { + const id = event.tags.find(([key]) => key === "d")?.[1]; + if (id && dirty.size < 1000) dirty.add(id); + else requested = true; + pump(); + } else if ([45011, 9005, 44100, 44101].includes(event.kind)) request(); + }; + const schedule = (delay: number) => { clearTimeout(timer); timer = setTimeout(() => { timer = undefined; - requested ||= delta; - void pump(); + pump(); }, delay); }; - const request = () => { - requested = true; - if (!running) { - if (Date.now() < notBefore) { - schedule(notBefore - Date.now()); - return; - } - clearTimeout(timer); - timer = undefined; - void pump(); - } - }; - group.request = request; - const repair = setInterval(request, REPAIR_MS); - async function pump() { + function pump() { if ( running || !group.entries.size || group.lifetime.signal.aborted || - !active() + !active() || + !session?.artifactSync || + (!requested && !dirty.size) ) return; if (Date.now() < notBefore) { schedule(notBefore - Date.now()); return; } - const current = session; - const reader = current?.artifactSync; - if (!current || !reader) return; clearTimeout(timer); timer = undefined; - running = true; - let finish!: () => void; - settled = new Promise((resolve) => { - finish = resolve; - }); + const current = session; + const reader = current.artifactSync; + if (!reader) return; + const entry = [...group.entries][0]; + if (!entry || entry.signal.aborted) return; batch = new AbortController(); - const signal = AbortSignal.any([group.lifetime.signal, batch.signal]); - let syncing: OpenArtifactCollection | undefined; - let deltaWork = false; - try { - const entry = [...group.entries][0]; - if (!entry || entry.signal.aborted) return; - syncing = entry; - const operation = AbortSignal.any([signal, entry.signal]); - let currentWork = false; - if (requested && retryKind !== "current") { - deltaWork = true; - requested = false; - await reconcileArtifactDelta( - reader, - entry.collection, - entry.definition.type, - operation, - { reportFailure: retry >= 3 }, - ); - } else { - const accepted = await captureArtifactDelivery( - current, - entry.definition.type, - JSON.stringify([entry.owner, entry.definition.name]), - ); - operation.throwIfAborted(); - const canConfirm = Date.now() >= confirmAfter; - const ids = - canConfirm && accepted.artifactIds.length - ? accepted.artifactIds.slice(0, ARTIFACT_SYNC_LIMITS.currentRows) - : [...dirty].slice(0, ARTIFACT_SYNC_LIMITS.currentRows); - if (!ids.length) return; - currentWork = true; - for (const id of ids) dirty.delete(id); - try { - await reconcileArtifactCurrent( - reader, - entry.collection, - entry.definition.type, - ids, - operation, - { reportFailure: retry >= 3 }, - ); - } catch (error) { - for (const id of ids) { - if (dirty.size < 1000) dirty.add(id); - else requested = true; - } - throw error; - } - if (canConfirm) { + const signal = AbortSignal.any([ + group.lifetime.signal, + entry.signal, + batch.signal, + ]); + running = (async () => { + try { + if (requested) { + requested = false; try { - await accepted.complete(ids, operation); - } catch { - operation.throwIfAborted(); - // The delivery journal retains this error while shared reads continue. - confirmAfter = Date.now() + REPAIR_MS; + await reconcileArtifactDelta( + reader, + entry.collection, + entry.definition.type, + signal, + { reportFailure: retry >= 3 }, + ); + } catch (error) { requested = true; + throw error; } } - } - retry = 0; - retryKind = undefined; - notBefore = 0; - if (currentWork || deltaWork) - queueMicrotask(() => { - if (!running) void pump(); - }); - } catch (error) { - if ( - error instanceof Error && - error.message === "Artifact collection version changed" - ) { - if (syncing) group.entries.delete(syncing); - if (!group.entries.size) { - group.lifetime.abort(); - if (groups.get(group.key) === group) groups.delete(group.key); - } else { - retry = 0; - retryKind = undefined; - requested = true; + const ids = [...dirty].slice(0, ARTIFACT_SYNC_LIMITS.currentRows); + if (ids.length) { + for (const id of ids) dirty.delete(id); + try { + await reconcileArtifactCurrent( + reader, + entry.collection, + entry.definition.type, + ids, + signal, + { reportFailure: retry >= 3 }, + ); + } catch (error) { + for (const id of ids) { + if (dirty.size < 1000) dirty.add(id); + else requested = true; + } + throw error; + } } - } else if (!signal.aborted) { - retry++; - const permanent = - error instanceof ArtifactSyncError && - ["sync_limit", "sync_invalid"].includes(error.code); - requested ||= deltaWork; - if (retry <= 3 && !permanent) { - retryKind = deltaWork ? "delta" : "current"; - const delay = RECOVERY_DELAYS_MS[retry - 1] ?? REPAIR_MS; + retry = 0; + notBefore = 0; + } catch (error) { + if ( + error instanceof Error && + error.message === "Artifact collection version changed" + ) { + group.entries.delete(entry); + if (!group.entries.size) { + group.lifetime.abort(); + if (groups.get(group.key) === group) groups.delete(group.key); + } else { + retry = 0; + requested = true; + } + } else if (!signal.aborted) { + retry++; + const permanent = + error instanceof ArtifactSyncError && + ["sync_limit", "sync_invalid"].includes(error.code); + const delay = + !permanent && retry <= 3 + ? (RECOVERY_DELAYS_MS[retry - 1] ?? REPAIR_MS) + : REPAIR_MS; notBefore = Date.now() + delay; schedule(delay); - } else { - retryKind = undefined; - notBefore = Date.now() + REPAIR_MS; } } - } finally { - finish(); - running = false; + })().finally(() => { + running = undefined; batch = undefined; - if (!group.lifetime.signal.aborted) { - if (retry === 0 && (requested || dirty.size)) void pump(); - else if (retry > 0 && !timer) schedule(REPAIR_MS, true); - } - } + if (!group.lifetime.signal.aborted && (requested || dirty.size)) pump(); + }); } + group.request = request; + const repair = setInterval(request, REPAIR_MS); const update = () => { const now = ctx.relay.snapshot(); const next = now.viewer === viewer && now.origin === relay ? now.session : undefined; const changed = next !== session; - if (next !== session) { + if (changed) { batch?.abort(); stopSession(); stopLive(); - stopConfirmed(); - stopRoster(); + stopWrites(); session = next; if (next) { stopSession = next.live.subscribe(update); - const rosterKey = () => - JSON.stringify( - next.channels - .list() - .channels.map( - ({ - id, - cached, - pending, - readOnly, - ttlSeconds, - private: restricted, - members, - }) => [ - id, - cached, - pending, - readOnly, - ttlSeconds, - restricted, - members, - ], - ) - .sort((left, right) => - String(left[0]).localeCompare(String(right[0])), - ), - ); - let roster = rosterKey(); - stopRoster = next.channels.subscribeList(() => { - const updated = rosterKey(); - if (updated !== roster) { - roster = updated; - request(); - } - }); - const receive = ( - events: readonly import("./events").RelayEvent[], - ) => { - let changed = false; - for (const event of events) { - if (!hasEventProof(event)) continue; - if ( - event.kind === 45010 && - [...group.entries].some((entry) => - event.tags.some( - ([name, value]) => - name === "type" && value === entry.definition.type, - ), - ) - ) { - const id = event.tags.find(([name]) => name === "d")?.[1]; - if (id && dirty.size < 1000) dirty.add(id); - else requested = true; - changed = true; - } else if ([45011, 9005, 44100, 44101].includes(event.kind)) { - requested = true; - changed = true; - } - } - if (changed && !running) { - if (Date.now() < notBefore) schedule(notBefore - Date.now()); - else void pump(); - } - }; - stopConfirmed = next.subscribeArtifactUpdates(receive); - stopLive = next.subscribeLive(({ events }) => receive(events), { - replay: true, - }); + stopLive = next.subscribeLive( + ({ events }) => { + for (const event of events) if (hasEventProof(event)) hint(event); + }, + { replay: true }, + ); + stopWrites = + next.outbox?.observeSend((event) => + event.kind === 45010 ? () => hint(event) : undefined, + ) ?? (() => {}); } } const ready = active(); @@ -363,7 +272,7 @@ export function provideArtifactSync( requested = true; } else if (!connected || changed) { retry = 0; - retryKind = undefined; + notBefore = 0; request(); } connected = ready; @@ -385,10 +294,9 @@ export function provideArtifactSync( stopRelay(); stopSession(); stopLive(); - stopConfirmed(); - stopRoster(); + stopWrites(); group.request = () => {}; - await settled; + await running; } } ctx.effect(() => () => { diff --git a/src/features/relay/broker-live.test.ts b/src/features/relay/broker-live.test.ts index 89310ece25..cdf5ec26a0 100644 --- a/src/features/relay/broker-live.test.ts +++ b/src/features/relay/broker-live.test.ts @@ -126,7 +126,7 @@ it("rejects status snapshots beyond channel interests plus both globals and obse await tick(); f.publish(0, { status: "connected", - routes: Array.from({ length: 100005 }, (_, i) => ({ + routes: Array.from({ length: 1028 }, (_, i) => ({ id: `route-${i}`, status: "pending", replay: "unknown", diff --git a/src/features/relay/broker-live.ts b/src/features/relay/broker-live.ts index e1952d4bde..418be33d49 100644 --- a/src/features/relay/broker-live.ts +++ b/src/features/relay/broker-live.ts @@ -2,7 +2,6 @@ import { observerFrame, observerGeneration } from "../agents/observer"; import { eventDto } from "./events"; import { liveChannels, - LIVE_INTEREST_CAPACITY, livePresenceAuthors, liveJoined, liveProvenance, @@ -11,7 +10,7 @@ import { type LiveSubscription, } from "./live"; -const MAX_FRAME = 32 * 1024 * 1024; +const MAX_FRAME = 1024 * 1024; const MAX_RETRY_MS = 30_000; /** A generation owns its stream, parser, retry and heartbeat. Interest changes * use the same host owner so they cannot interrupt pending publications. */ @@ -25,7 +24,7 @@ export function subscribeBrokerTraffic( let channels: string[] = []; let joined: string[] = []; let sentChannels: string[] = []; - // Only IDs in the last dispatched snapshot can have a host wire. + // Only IDs in the last dispatched snapshot can have a host wire (at most 1024). const removed = new Set(); let interestRevision = 0; const addedAt = new Map(); @@ -527,7 +526,7 @@ function liveSnapshot(value: unknown): LiveSnapshot { ) || !Array.isArray(snapshot.routes) || // Keep limited channels visible alongside both globals and the optional observer. - snapshot.routes.length > LIVE_INTEREST_CAPACITY + 4 || + snapshot.routes.length > 1027 || (snapshot.error !== undefined && typeof snapshot.error !== "string") ) throw new Error("Invalid live broker status"); diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index a060f3c8cb..a25f6a35c7 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -2,7 +2,6 @@ import type { ReadFilter } from "./events"; import type { CustomEmoji } from "./emoji"; import type { ReadOptions } from "./reader"; import type { Delivery } from "./outbox"; -import type { DeliveryState } from "./managed-delivery"; export const MAX_ATTACHMENT_DURATION_SECONDS = 86_400; @@ -105,7 +104,6 @@ export type ChannelMessage = Readonly<{ channelId: string; delivery?: Delivery | undefined; deliveryError?: string | undefined; - managedDelivery?: DeliveryState | undefined; authorId: string; /** Actual signing key when relay attribution makes authorId a different identity. */ signerId?: string | undefined; diff --git a/src/features/relay/live-recovery.ts b/src/features/relay/live-recovery.ts index 4197a0057e..ff765ca214 100644 --- a/src/features/relay/live-recovery.ts +++ b/src/features/relay/live-recovery.ts @@ -52,10 +52,7 @@ export function liveRecoveryStatus(view: LiveRecoveryView): RecoveryStatus { (view.roster.state === "error" && view.roster.transient) || view.heads.some((head) => head.state === "error" && head.transient) || routes.some( - (route) => - (route.status === "error" || - (route.replay === "limited" && route.error !== undefined)) && - isTransientLiveError(route.error), + (route) => route.status === "error" && isTransientLiveError(route.error), ) ) return "failed"; diff --git a/src/features/relay/live.test.ts b/src/features/relay/live.test.ts index 6145537ab2..da6bbefa19 100644 --- a/src/features/relay/live.test.ts +++ b/src/features/relay/live.test.ts @@ -3,7 +3,6 @@ import { assert, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { createLiveAdmission, liveChannels, - LIVE_INTEREST_CAPACITY, subscribeRelayTraffic, type LiveCallbacks, } from "./live"; @@ -1068,9 +1067,7 @@ it("bounds interests and exposes every omitted ID without exceeding 1024 subscri .filter((r) => r.status === "limited") .map((r) => r.channelId), ).toEqual(ids.slice(1022)); - expect(() => - liveChannels(Array(LIVE_INTEREST_CAPACITY + 1).fill("excess")), - ).toThrow(); + expect(() => liveChannels([...ids, "excess"])).toThrow(); for (const invalid of [[""], ["a b"], ["x".repeat(129)], [9], {}]) expect(() => liveChannels(invalid)).toThrow(); h.owner.dispose(); @@ -2495,279 +2492,3 @@ it("carries verified workflow attribution from live admission to notification ca h.owner.dispose(); } }); - -it("keeps 60000 joined homes live while four bounded replay reads recover each channel", async () => { - vi.useFakeTimers(); - const ids = Array.from( - { length: 60_000 }, - (_, i) => `task-${String(i).padStart(6, "0")}`, - ); - const h = setup([]); - h.callbacks.state.mockImplementation(() => {}); - h.owner.update([...ids, "normal-chat"], [...ids, "normal-chat"]); - h.owner.prioritize?.(["normal-chat", ids.at(-1) as string]); - await h.first.auth(); - const established = new Set(); - const replayed = new Set(); - const openWires = new Set(); - let maximum = 0; - let cursor = 0; - let replayRequests = 0; - let tailRequests = 0; - // Each EOSE synchronously admits the next bounded request. - while (cursor < h.first.sent.length) { - const frame = h.first.sent[cursor++]; - if (frame?.[0] === "CLOSE") { - openWires.delete(frame[1] as string); - continue; - } - if (frame?.[0] !== "REQ") continue; - const request = frame as WireRequest; - openWires.add(request[1]); - maximum = Math.max(maximum, openWires.size); - const filters = filtersOf(request); - const scope = scopeOf(request); - expect(filters.length).toBeLessThanOrEqual(10); - expect( - new TextEncoder().encode(JSON.stringify(request)).length, - ).toBeLessThan(524288); - if (scope.length) { - if (filters[0]?.limit === 0) { - tailRequests++; - expect(scope.length).toBeLessThanOrEqual(1000); - await h.first.receive(["EOSE", request[1]]); - for (const id of scope) established.add(id); - } else { - replayRequests++; - expect(scope.length).toBeLessThanOrEqual(10); - for (const id of scope) { - expect(established.has(id)).toBe(true); - expect(replayed.has(id)).toBe(false); - replayed.add(id); - } - await h.first.receive(["EOSE", request[1]]); - } - } else await h.first.receive(["EOSE", request[1]]); - } - expect(established.size).toBe(60_001); - expect(replayed.size).toBe(60_001); - expect(tailRequests).toBe(61); - expect(replayRequests).toBe(6001); - expect(maximum).toBeLessThanOrEqual(67); - expect( - h.callbacks.state.mock.lastCall?.[0].routes.every( - (route) => route.status === "live", - ), - ).toBe(true); - expect( - new TextEncoder().encode( - JSON.stringify(h.callbacks.state.mock.lastCall?.[0]), - ).length, - ).toBeLessThan(32 * 1024 * 1024); - h.owner.dispose(); - expect(vi.getTimerCount()).toBe(0); -}); - -it("keeps large tails active during replay and fences removed channel lifetimes", async () => { - vi.useFakeTimers(); - const ids = Array.from( - { length: 1200 }, - (_, i) => `task-${String(i).padStart(4, "0")}`, - ); - const h = setup([]); - h.owner.update(ids, ids); - await h.first.auth(); - const tail = h.first - .requests() - .find((request) => scopeOf(request).includes(ids[0] as string)); - assert.exists(tail); - const incoming = message( - h.key, - ids[0] as string, - "At the setup boundary", - 1700000000, - ); - await h.first.receive(["EVENT", tail[1], incoming]); - expect(h.callbacks.receive).toHaveBeenLastCalledWith( - incoming ? [incoming] : [], - { phase: "live", channelId: ids[0] }, - ); - await h.first.receive(["EOSE", tail[1]]); - const replay = h.first - .requests() - .find( - (request) => - request[1] !== tail[1] && scopeOf(request).includes(ids[0] as string), - ); - assert.exists(replay); - expect( - h.first.sent.some((frame) => frame[0] === "CLOSE" && frame[1] === tail[1]), - ).toBe(false); - await h.first.receive(["EVENT", replay[1], incoming]); - expect(h.callbacks.receive).toHaveBeenLastCalledWith([incoming], { - phase: "replay", - channelId: ids[0], - }); - h.owner.update(ids.slice(1), ids.slice(1)); - const before = h.callbacks.receive.mock.calls.length; - await h.first.receive(["EVENT", replay[1], incoming]); - await h.first.receive(["EVENT", tail[1], incoming]); - expect(h.callbacks.receive).toHaveBeenCalledTimes(before); - h.first.onclose?.(); - await h.first.receive(["EVENT", tail[1], incoming]); - expect(h.callbacks.receive).toHaveBeenCalledTimes(before); - h.owner.dispose(); - expect(vi.getTimerCount()).toBe(0); -}); - -it("fills retained groups as joined homes arrive in separate roster updates", async () => { - vi.useFakeTimers(); - const h = setup([]); - const ids: string[] = []; - for (let i = 0; i < 1150; i++) { - ids.push(`task-${i}`); - h.owner.update(ids, ids); - } - await h.first.auth(); - let cursor = 0; - const open = new Set(); - let maximum = 0; - while (cursor < h.first.sent.length) { - const frame = h.first.sent[cursor++]; - if (frame?.[0] === "CLOSE") open.delete(frame[1] as string); - if (frame?.[0] !== "REQ") continue; - open.add(frame[1] as string); - maximum = Math.max(maximum, open.size); - await h.first.receive(["EOSE", frame[1]]); - } - expect(maximum).toBeLessThan(125); - expect(h.callbacks.state.mock.lastCall?.[0].routes).toHaveLength(1152); - expect( - h.callbacks.state.mock.lastCall?.[0].routes.every( - (route) => route.status === "live", - ), - ).toBe(true); - h.owner.dispose(); -}); - -it("replays only added homes when an established large group grows", async () => { - vi.useFakeTimers(); - const ids = Array.from({ length: 1023 }, (_, i) => `growth-${i}`); - const h = setup([]); - h.owner.update(ids, ids); - await h.first.auth(); - let cursor = 0; - const replayed = new Set(); - async function drain() { - while (cursor < h.first.sent.length) { - const frame = h.first.sent[cursor++]; - if (frame?.[0] !== "REQ") continue; - const req = frame as WireRequest; - if (filtersOf(req)[0]?.limit !== 0) - for (const id of scopeOf(req)) { - expect(replayed.has(id)).toBe(false); - replayed.add(id); - } - await h.first.receive(["EOSE", req[1]]); - } - } - await drain(); - expect(replayed.size).toBe(1023); - for (let i = 0; i < 20; i++) { - ids.push(`added-${i}`); - h.owner.update(ids, ids); - await drain(); - } - expect(replayed.size).toBe(1043); - expect( - h.callbacks.state.mock.lastCall?.[0].routes.every( - (route) => route.status === "live", - ), - ).toBe(true); - h.owner.dispose(); -}); -it("keeps the established subset live after expansion timeout and recovers automatically", async () => { - vi.useFakeTimers(); - const ids = Array.from({ length: 1100 }, (_, i) => `timeout-${i}`); - const h = setup([]); - h.owner.update(ids, ids); - await h.first.auth(); - let cursor = 0; - while (cursor < h.first.sent.length) { - const frame = h.first.sent[cursor++]; - if (frame?.[0] === "REQ") await h.first.receive(["EOSE", frame[1]]); - } - const old = h.first - .requests() - .find( - (req) => - filtersOf(req)[0]?.limit === 0 && scopeOf(req).includes("timeout-1000"), - ); - assert.exists(old); - ids.push("added-timeout"); - h.owner.update(ids, ids); - await vi.advanceTimersByTimeAsync(10_000); - expect( - h.first.sent.some((frame) => frame[0] === "CLOSE" && frame[1] === old[1]), - ).toBe(false); - const state = h.callbacks.state.mock.lastCall?.[0]; - expect( - state?.routes.find((route) => route.channelId === "timeout-1000")?.status, - ).toBe("live"); - expect( - state?.routes.find((route) => route.channelId === "added-timeout")?.status, - ).toBe("error"); - await h.first.receive([ - "EVENT", - old[1], - message(h.key, "timeout-1000", "During recovery", 1700000000), - ]); - expect(h.callbacks.receive).toHaveBeenCalledOnce(); - const before = h.first.requests().length; - await vi.advanceTimersByTimeAsync(50_000); - const retry = h.first - .requests() - .slice(before) - .find((req) => scopeOf(req).includes("added-timeout")); - assert.exists(retry); - await h.first.receive(["EOSE", retry[1]]); - expect( - h.callbacks.state.mock.lastCall?.[0].routes.find( - (route) => route.channelId === "added-timeout", - )?.status, - ).toBe("live"); - h.owner.dispose(); -}); -it("reserves a wire for group expansion near subscription capacity", async () => { - vi.useFakeTimers(); - const previews = Array.from({ length: 1021 }, (_, i) => `preview-${i}`); - const h = setup([]); - h.owner.update(["joined-a", ...previews], ["joined-a"]); - await h.first.auth(); - let cursor = 0; - const open = new Set(); - let maximum = 0; - async function drain() { - while (cursor < h.first.sent.length) { - const frame = h.first.sent[cursor++]; - if (frame?.[0] === "CLOSE") open.delete(frame[1] as string); - if (frame?.[0] !== "REQ") continue; - open.add(frame[1] as string); - maximum = Math.max(maximum, open.size); - await h.first.receive(["EOSE", frame[1]]); - } - } - await drain(); - h.owner.update( - ["joined-a", "joined-b", ...previews], - ["joined-a", "joined-b"], - ); - await drain(); - expect(maximum).toBeLessThanOrEqual(1024); - expect( - h.callbacks.state.mock.lastCall?.[0].routes.find( - (route) => route.channelId === "joined-b", - )?.status, - ).toBe("live"); - h.owner.dispose(); -}); diff --git a/src/features/relay/live.ts b/src/features/relay/live.ts index 089ae85d3c..c95155bf4e 100644 --- a/src/features/relay/live.ts +++ b/src/features/relay/live.ts @@ -15,8 +15,6 @@ import { eventDto } from "./events.ts"; import { EMOJI_SET } from "./emoji.ts"; export const LIVE_CHANNEL_CAPACITY = 1022; -export const LIVE_INTEREST_CAPACITY = 100_000; -export const LIVE_GROUP_SIZE = 1000; export const LIVE_BATCH_SIZE = 10; // Relay's per-REQ filter cap; replay stays per channel. export const LIVE_REPLAY_LIMIT = 500; export const LIVE_RECOVERY_INTERVAL = 60_000; @@ -102,7 +100,7 @@ export function liveProvenance(value: unknown): LiveProvenance { : liveChannels(input.sourceChannels); if ( sourceChannels && - (!sourceChannels.length || sourceChannels.length > LIVE_GROUP_SIZE) + (!sourceChannels.length || sourceChannels.length > LIVE_BATCH_SIZE) ) throw new Error("Invalid live source scope"); return Object.freeze({ @@ -163,13 +161,13 @@ export function livePresenceAuthors(input: unknown): string[] { export function liveChannels(input: unknown): string[] { if ( !Array.isArray(input) || - input.length > LIVE_INTEREST_CAPACITY || + input.length > 1024 || input.some( (id) => typeof id !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(id), ) ) throw new Error( - "Invalid live channel interests (maximum 100000 bounded IDs)", + "Invalid live channel interests (maximum 1024 bounded IDs)", ); return [...new Set(input as string[])].sort(); } @@ -180,8 +178,7 @@ export function liveJoined( input: unknown, ): string[] { const joined = liveChannels(input); - const allowed = new Set(channels); - if (joined.some((id) => !allowed.has(id))) + if (joined.some((id) => !channels.includes(id))) throw new Error("Joined scope outside live interests"); return joined; } @@ -201,9 +198,6 @@ type Route = { retryRenewal?: boolean; quotaRetries: number; deadline?: ReturnType; - replayOffset?: number; - historyParent?: string; - priorityRank?: number; }; export const CHANNEL_KINDS = [ 9, 40002, 40008, 45001, 45003, 40099, 40100, 40003, 5, 9005, 7, 39000, 39002, @@ -260,29 +254,18 @@ export function subscribeRelayTraffic( Object.freeze({ status: connection, routes: Object.freeze( - [...routes.values()] - .filter((route) => !route.historyParent) - .flatMap((route) => { - const { id, channelId, channelIds, status, replay, error } = - route; - return (channelIds ?? [channelId]).map((channelId) => { - const retained = - channelId && - route.previous?.status === "live" && - route.previous.wire && - scope(route.previous).length < scope(route).length && - scope(route.previous).includes(channelId); - return Object.freeze({ + [...routes.values()].flatMap( + ({ id, channelId, channelIds, status, replay, error }) => + (channelIds ?? [channelId]).map((channelId) => + Object.freeze({ id: channelId ? `channel:${channelId}` : id, ...(channelId ? { channelId } : {}), - status: retained ? ("live" as const) : status, - replay: retained - ? (route.previous?.replay ?? replay) - : replay, - ...(!retained && error ? { error } : {}), - }); - }); - }), + status, + replay, + ...(error ? { error } : {}), + }), + ), + ), ), ...(connectionError ? { error: connectionError } : {}), }), @@ -307,10 +290,6 @@ export function subscribeRelayTraffic( delete route.previous; } function remove(route: Route) { - if (!route.historyParent) { - const history = routes.get(`replay:${route.id}`); - if (history) remove(history); - } closeWire(route); routes.delete(route.id); } @@ -319,15 +298,6 @@ export function subscribeRelayTraffic( function replace(route: Route, ids = scope(route)) { // A sent replacement is not yet established. Keep the established source // through repeated removals; retire the pending intermediate, not a survivor. - if ( - route.replayOffset !== undefined && - JSON.stringify(ids) !== JSON.stringify(scope(route)) && - !scope(route).every((id, index) => ids[index] === id) - ) { - const history = routes.get(`replay:${route.id}`); - if (history) remove(history); - route.replayOffset = 0; - } const previous = route.previous ?? (route.wire ? route : undefined); delete route.previous; if (previous !== route) closeWire(route); // Never retain a third wire. @@ -382,60 +352,14 @@ export function subscribeRelayTraffic( ...interests, ]), ]; - const joinedSet = new Set(joined); - const prioritySet = new Set(priority); - const staged = joined.length > LIVE_CHANNEL_CAPACITY; - const groupSize = staged ? LIVE_GROUP_SIZE : LIVE_BATCH_SIZE; - const capacity = - LIVE_CHANNEL_CAPACITY - - (observer !== null ? 1 : 0) - - (presenceAuthors.length ? 1 : 0) - - (joined.length ? 1 : 0) - - (staged ? SETUP_CONCURRENCY : 0); - const interestSet = new Set(interests); - const retained = new Map(); - for (const route of routes.values()) { - if (route.historyParent || route.status === "limited") continue; - for (const id of scope(route)) { - if (interestSet.has(id) && (!route.channelIds || joinedSet.has(id))) - retained.set(id, route); - } - } - const admitted = new Set(); - const kept = new Set(); - let singles = 0, - grouped = 0, - free = 0; - for (const id of ranked) { - const route = retained.get(id); - const batchable = joinedSet.has(id) && (staged || !prioritySet.has(id)); - const newRoute = route && !kept.has(route) ? 1 : 0; - const nextFree = - free + - (newRoute && route?.channelIds && !route.previous - ? Math.max( - 0, - groupSize - - scope(route).filter( - (channel) => - interestSet.has(channel) && joinedSet.has(channel), - ).length, - ) - : 0); - const nextSingles = singles + (!route && !batchable ? 1 : 0); - const nextGrouped = grouped + (!route && batchable ? 1 : 0); - const cost = - kept.size + - newRoute + - nextSingles + - Math.ceil(Math.max(0, nextGrouped - nextFree) / groupSize); - if (cost > capacity) continue; - admitted.add(id); - if (route) kept.add(route); - singles = nextSingles; - grouped = nextGrouped; - free = nextFree; - } + const admitted = new Set( + ranked.slice( + 0, + LIVE_CHANNEL_CAPACITY - + (observer !== null ? 1 : 0) - + (presenceAuthors.length ? 1 : 0), + ), + ); const remaining = new Set(interests); const globals = new Set([ "profiles", @@ -446,8 +370,6 @@ export function subscribeRelayTraffic( // Scope is immutable for a wire. Retirement rebuilds only the affected batch; // navigation is scheduling, never a reason to rebalance healthy subscriptions. for (const route of routes.values()) { - delete route.priorityRank; - if (route.historyParent) continue; const ids = scope(route); if ( ids.length @@ -455,12 +377,12 @@ export function subscribeRelayTraffic( (id) => !remaining.has(id) || (route.status === "limited") === admitted.has(id) || - (!!route.channelIds && !joinedSet.has(id)), + (!!route.channelIds && !joined.includes(id)), ) : !globals.has(route.id) ) { const survivors = ids.filter( - (id) => remaining.has(id) && admitted.has(id) && joinedSet.has(id), + (id) => remaining.has(id) && admitted.has(id) && joined.includes(id), ); if ( route.channelIds && @@ -499,61 +421,25 @@ export function subscribeRelayTraffic( route.status = "limited"; route.error = "Live channel capacity reached; finite reads remain available"; - } else if (!joinedSet.has(id) || (!staged && prioritySet.has(id))) + } else if (!joined.includes(id) || priority.includes(id)) add(`channel:${id}`, [id]); else batch.push(id); } - for (const route of [...routes.values()]) { - if (!batch.length) break; - if ( - !route.channelIds || - route.historyParent || - route.status === "limited" || - route.status === "error" || - route.previous - ) - continue; - const extra = batch.splice( - 0, - Math.max(0, groupSize - route.channelIds.length), - ); - if (!extra.length) continue; - const next = replace(route, [...route.channelIds, ...extra]); - next.replayOffset = - route.replayOffset ?? - (route.status === "live" ? route.channelIds.length : 0); - } - for (let i = 0; i < batch.length; i += groupSize) { - const route = add( - `batch:${++serial}`, - batch.slice(i, i + groupSize), - true, - ); - if (staged) { - route.liveOnly = true; - route.replayOffset = 0; - } - } + for (let i = 0; i < batch.length; i += LIVE_BATCH_SIZE) + add(`batch:${++serial}`, batch.slice(i, i + LIVE_BATCH_SIZE), true); pump(); notify(); } function fail(route: Route, reason: string, transient = false) { - if (route.historyParent) { - const parent = routes.get(route.historyParent); - if (parent) { - parent.replay = "limited"; - parent.error = reason; - } - } const previous = route.previous; - // A transient failure can retain an established subset. Denial and invalid - // traffic still retire the wire, and removed channels never regain coverage. + // Only an established, unchanged scope may outlive a failed renewal. Never + // restore a retired channel lifetime or bypass denial/invalid-traffic cleanup. const retain = transient && previous?.status === "live" && previous.wire && wires.get(previous.wire) === previous && - scope(previous).every((id) => scope(route).includes(id)); + JSON.stringify(scope(previous)) === JSON.stringify(scope(route)); if (retain) delete route.previous; closeWire(route); delete route.retryRenewal; @@ -621,41 +507,18 @@ export function subscribeRelayTraffic( } requests.dispatch(request, send); } - for (const parent of [...routes.values()]) { - if ( - parent.replayOffset === undefined || - parent.status !== "live" || - parent.replayOffset >= scope(parent).length || - routes.has(`replay:${parent.id}`) - ) - continue; - const history: Route = { - id: `replay:${parent.id}`, - historyParent: parent.id, - channelIds: scope(parent).slice( - parent.replayOffset, - parent.replayOffset + LIVE_BATCH_SIZE, - ), - status: "pending", - replay: "unknown", - count: 0, - since: parent.since, - quotaRetries: 0, - }; - routes.set(history.id, history); - } let active = [...routes.values()].filter( (route) => route.wire && route.status === "pending", ).length; const rank = (route: Route) => - (route.priorityRank ??= !scope(route).length + !scope(route).length ? -2 : Math.min( priority.length, ...scope(route).map((id) => priority.includes(id) ? priority.indexOf(id) : priority.length, ), - )); + ); for (const route of [...routes.values()].sort( (a, b) => rank(a) - rank(b), )) { @@ -666,14 +529,6 @@ export function subscribeRelayTraffic( dispatchTimer = setTimeout(pump, delay); break; } - if (wires.size >= 1024) { - if (route.previous?.wire) continue; - route.status = "limited"; - route.error = - "Live subscription capacity reached; retry after demand changes"; - notify(); - continue; - } // A retry being sent is not recovery. Retain its last failure until EOSE. const wire = `live-${++serial}`; route.wire = wire; @@ -978,10 +833,7 @@ export function subscribeRelayTraffic( [incoming], Object.freeze({ phase: - !route.historyParent && - (route.liveOnly || route.status === "live") - ? "live" - : "replay", + route.liveOnly || route.status === "live" ? "live" : "replay", ...(channelId ? { channelId } : ids.length @@ -991,26 +843,6 @@ export function subscribeRelayTraffic( ); } else if (data[0] === "EOSE" && route.status === "pending") { clearTimeout(route.deadline); - if (route.historyParent) { - const parent = routes.get(route.historyParent); - remove(route); - if (parent && parent.replayOffset !== undefined) { - parent.replayOffset += scope(route).length; - if (parent.error) { - delete parent.error; - notify(); - } - if ( - route.count >= LIVE_REPLAY_LIMIT && - parent.replay !== "limited" - ) { - parent.replay = "limited"; - notify(); - } - } - if (valid()) pump(); - return; - } if (route.previous) closeWire(route.previous); delete route.previous; route.status = "live"; diff --git a/src/features/relay/managed-delivery.test.ts b/src/features/relay/managed-delivery.test.ts deleted file mode 100644 index 126f679214..0000000000 --- a/src/features/relay/managed-delivery.test.ts +++ /dev/null @@ -1,1081 +0,0 @@ -import { getEventHash } from "nostr-tools"; -import { afterEach, assert, expect, it, vi } from "vitest"; -import { - createOutbox, - PublishRejected, - type OutboxStorage, - type OutgoingEvent, -} from "./outbox"; -import { createRelaySession } from "./session"; -import { scriptedTransport } from "./testing"; -import { MANAGED_RECEIPT_MS } from "./managed-delivery"; -import { keypair, signed } from "./testing"; -import type { RelayWriter } from "./transport"; - -const viewer = keypair(); -const recipient = { channelId: "task-channel", agentId: "a".repeat(64) }; -const input = { - kind: 9, - content: "Check the task", - tags: [["h", recipient.channelId]], -}; -const owners: ReturnType[] = []; -afterEach(() => { - for (const owner of owners.splice(0)) owner.dispose(); - vi.useRealTimers(); -}); -function memory() { - let records: readonly OutgoingEvent[] = []; - return { - load: () => structuredClone(records), - save: (next: readonly OutgoingEvent[]) => { - records = structuredClone(next); - }, - }; -} -function setup( - storage: OutboxStorage = memory(), - publish: RelayWriter["publish"] = async () => {}, -) { - const sign = vi.fn(async (template) => signed(viewer, template)); - const writer = { sign, publish: vi.fn(publish) }; - const owner = createOutbox(viewer.pubkey, writer, storage); - owners.push(owner); - return { ...owner, ...writer, storage }; -} -const ticks = async (ms = 0) => { - await vi.advanceTimersByTimeAsync(ms); -}; - -it("saves managed intent before publication and only lets the sync owner publish", async () => { - vi.useFakeTimers(); - const h = setup(); - const { operationId: id } = await h.delivery.submit(input, recipient); - expect(h.publish).not.toHaveBeenCalled(); - expect(await h.storage.load()).toEqual([ - expect.objectContaining({ - event: expect.objectContaining({ id }), - managed: expect.objectContaining({ attempts: 0 }), - }), - ]); - await h.managedOwner(true); - await ticks(); - expect(h.publish).toHaveBeenCalledOnce(); - expect(await h.delivery.get(id)).toMatchObject({ - phase: "sending", - relayAccepted: true, - agentReceived: false, - }); -}); -it("does not republish accepted work after restart or receipt retry", async () => { - vi.useFakeTimers(); - const storage = memory(); - const first = setup(storage); - const { operationId: id } = await first.delivery.submit(input, recipient); - await first.managedOwner(true); - await ticks(); - first.dispose(); - const next = setup(storage); - await next.managedOwner(true); - await ticks(MANAGED_RECEIPT_MS); - expect(await next.delivery.get(id)).toMatchObject({ - phase: "failed", - relayAccepted: true, - error: "Could not confirm delivery", - }); - await next.delivery.retry(id); - await ticks(); - expect(next.publish).not.toHaveBeenCalled(); - expect(await next.delivery.get(id)).toMatchObject({ - phase: "sending", - relayAccepted: true, - agentReceived: false, - }); -}); -it("retains exact signed event identity through lost confirmations and bounded retries", async () => { - vi.useFakeTimers(); - const h = setup(memory(), async () => { - throw new Error("Confirmation lost"); - }); - const { operationId: id } = await h.delivery.submit(input, recipient); - await h.managedOwner(true); - await ticks(8000); - expect(h.publish).toHaveBeenCalledTimes(3); - expect(h.sign).toHaveBeenCalledOnce(); - expect(new Set(h.publish.mock.calls.map(([event]) => event.id))).toEqual( - new Set([id]), - ); - expect(await h.delivery.get(id)).toMatchObject({ - phase: "failed", - relayAccepted: false, - error: "Could not send", - }); -}); -it("transfers interrupted work to the next owner without a second signature", async () => { - vi.useFakeTimers(); - const storage = memory(); - const first = setup(storage, async () => { - throw new Error("Disconnected"); - }); - const { operationId: id } = await first.delivery.submit(input, recipient); - await first.managedOwner(true); - await ticks(); - await first.managedOwner(false); - first.dispose(); - const next = setup(storage); - await next.managedOwner(true); - await ticks(1000); - expect(next.publish).toHaveBeenCalledOnce(); - expect(next.publish.mock.calls[0]?.[0].id).toBe(id); - expect(next.sign).not.toHaveBeenCalled(); -}); -it("waits for durable exact receipt and ignores other agents, channels and duplicate IDs", async () => { - vi.useFakeTimers(); - const h = setup(); - const { operationId: id } = await h.delivery.submit(input, recipient); - await h.managedOwner(true); - await ticks(); - await h.receiveManaged("other", recipient.agentId, [id]); - await h.receiveManaged(recipient.channelId, "b".repeat(64), [id]); - expect(await h.delivery.get(id)).toMatchObject({ agentReceived: false }); - await h.receiveManaged(recipient.channelId, recipient.agentId, [id, id]); - expect(await h.delivery.get(id)).toMatchObject({ - phase: "received", - relayAccepted: true, - agentReceived: true, - }); - await ticks(); - await h.receiveManaged(recipient.channelId, recipient.agentId, [id]); - expect(h.publish).toHaveBeenCalledOnce(); -}); -it("keeps accepted evidence when receipt persistence fails and accepts a late receipt", async () => { - vi.useFakeTimers(); - const storage = memory(); - let fail = false; - const h = setup({ - ...storage, - save: async (records) => { - if (fail) throw new Error("Disk full"); - storage.save(records); - }, - }); - const { operationId: id } = await h.delivery.submit(input, recipient); - await h.managedOwner(true); - await ticks(); - fail = true; - await expect( - h.receiveManaged(recipient.channelId, recipient.agentId, [id]), - ).rejects.toThrow("Disk full"); - expect( - h.local.snapshot().find((item) => item.event.id === id)?.managed, - ).toMatchObject({ - relayAccepted: true, - agentReceived: false, - error: "receipt-save", - }); - fail = false; - await ticks(MANAGED_RECEIPT_MS); - await h.receiveManaged(recipient.channelId, recipient.agentId, [id]); - expect(await h.delivery.get(id)).toMatchObject({ - phase: "received", - agentReceived: true, - }); - expect(h.publish).toHaveBeenCalledOnce(); -}); -it("does not retry a definitive refusal or publish after storage failure", async () => { - vi.useFakeTimers(); - const h = setup(memory(), async () => { - throw new PublishRejected("conflict: artifact head changed"); - }); - const { operationId: id } = await h.delivery.submit({ - kind: 45010, - content: "Task", - tags: [], - }); - await h.managedOwner(true); - await ticks(8000); - expect(h.publish).toHaveBeenCalledOnce(); - expect(await h.delivery.get(id)).toMatchObject({ phase: "failed" }); - const failed = setup({ - load: () => [], - save: async () => { - throw new Error("Disk full"); - }, - }); - await expect(failed.delivery.submit(input, recipient)).rejects.toThrow( - "Disk full", - ); - expect(failed.publish).not.toHaveBeenCalled(); -}); -it("records a batch receipt against each original message", async () => { - vi.useFakeTimers(); - const h = setup(); - const one = await h.delivery.submit(input, recipient); - const two = await h.delivery.submit( - { ...input, content: "Second prompt" }, - recipient, - ); - await h.managedOwner(true); - await ticks(); - await h.receiveManaged(recipient.channelId, recipient.agentId, [ - one.eventId, - two.eventId, - ]); - expect(await h.delivery.get(one.operationId)).toMatchObject({ - agentReceived: true, - }); - expect(await h.delivery.get(two.operationId)).toMatchObject({ - agentReceived: true, - }); -}); - -it("keeps local intent added during a journal read until its save completes", async () => { - vi.useFakeTimers(); - const storage = memory(); - let release: (() => void) | undefined; - let reading = false; - const h = setup({ - ...storage, - load: async (options) => { - const records = storage.load(); - if (options?.refresh && reading) - await new Promise((resolve) => { - release = resolve; - }); - return records; - }, - }); - await h.ready; - reading = true; - const refresh = h.delivery.get("missing"); - await ticks(); - const id = h.outbox.send(input, undefined, undefined, { recipient }); - reading = false; - release?.(); - await refresh; - await ticks(); - expect(await h.delivery.get(id)).toMatchObject({ phase: "sending" }); - expect(storage.load().some((item) => item.event.id === id)).toBe(true); -}); -it("retains completed artifact delivery when its echo precedes the publish response", async () => { - vi.useFakeTimers(); - let accept: (() => void) | undefined; - const h = setup( - memory(), - () => - new Promise((resolve) => { - accept = resolve; - }), - ); - const { eventId: id } = await h.delivery.submit({ - kind: 45010, - content: "{}", - tags: [ - ["d", crypto.randomUUID()], - ["h", recipient.channelId], - ["type", "task"], - ["op", "create"], - ["ar", "1"], - ], - }); - await h.managedOwner(true); - await ticks(); - const event = h.publish.mock.calls[0]?.[0]; - assert.exists(event); - h.observe([event]); - accept?.(); - await ticks(); - expect(await h.delivery.get(id)).toMatchObject({ - phase: "received", - relayAccepted: true, - }); - expect( - h.local.snapshot().find((item) => item.event.id === id)?.managed - ?.relayAccepted, - ).toBe(true); -}); -it("can retry and discard a definitive managed refusal", async () => { - vi.useFakeTimers(); - const h = setup(memory(), async () => { - throw new PublishRejected("artifact-head-conflict"); - }); - const { eventId: id } = await h.delivery.submit(input, recipient); - await h.managedOwner(true); - await ticks(1); - expect(await h.delivery.get(id)).toMatchObject({ phase: "failed" }); - await h.delivery.retry(id); - await ticks(1); - expect(h.publish).toHaveBeenCalledTimes(2); - await h.outbox.dismiss(id); - expect(await h.delivery.get(id)).toBeUndefined(); -}); -it("cancels queued managed attempts before it releases ownership", async () => { - vi.useFakeTimers(); - const h = setup( - memory(), - async (_event, signal) => - new Promise((_resolve, reject) => { - signal.addEventListener("abort", () => reject(signal.reason), { - once: true, - }); - }), - ); - for (let i = 0; i < 6; i++) - await h.delivery.submit({ ...input, content: `Prompt ${i}` }, recipient); - await h.managedOwner(true); - await ticks(); - expect(h.publish).toHaveBeenCalledTimes(3); - await h.managedOwner(false); - await ticks(60_000); - expect(h.publish).toHaveBeenCalledTimes(3); -}); - -it("publishes a delete with only its allowed artifact envelope and coalesces identical intent", async () => { - vi.useFakeTimers(); - const allowed = new Set(["ar", "d", "h", "type", "op", "prev"]); - const h = setup(memory(), async (event) => { - if (event.tags.some(([key]) => !key || !allowed.has(key))) - throw new PublishRejected( - "delete allows only envelope and verified auth tags", - ); - }); - const artifact = { - kind: 45010, - content: "", - tags: [ - ["ar", "1"], - ["d", crypto.randomUUID()], - ["h", recipient.channelId], - ["type", "test.task"], - ["op", "delete"], - ["prev", "b".repeat(64)], - ], - }; - const first = await h.delivery.submit(artifact); - const second = await h.delivery.submit(artifact); - expect(first).toEqual(second); - expect(h.outbox.snapshot()).toHaveLength(1); - await h.managedOwner(true); - await ticks(1); - expect(await h.delivery.get(first.operationId)).toMatchObject({ - phase: "received", - relayAccepted: true, - }); - expect(h.publish).toHaveBeenCalledOnce(); -}); - -it("keeps the receipt deadline when a stale journal read races relay acceptance", async () => { - vi.useFakeTimers(); - const storage = memory(); - let slow = false; - let release: (() => void) | undefined; - let accept: (() => void) | undefined; - const h = setup( - { - ...storage, - load: async (options) => { - const records = storage.load(); - if (options?.refresh && slow) - await new Promise((resolve) => { - release = resolve; - }); - return records; - }, - }, - () => - new Promise((resolve) => { - accept = resolve; - }), - ); - const { eventId: id } = await h.delivery.submit(input, recipient); - await h.managedOwner(true); - await ticks(); - slow = true; - const stale = h.delivery.get(id); - await ticks(); - accept?.(); - await ticks(); - expect( - h.local.snapshot().find((item) => item.event.id === id)?.managed - ?.relayAccepted, - ).toBe(true); - slow = false; - release?.(); - await stale; - await ticks(1); - expect(await h.delivery.get(id)).toMatchObject({ - phase: "sending", - relayAccepted: true, - }); - await ticks(MANAGED_RECEIPT_MS - 2); - expect(await h.delivery.get(id)).toMatchObject({ phase: "sending" }); - await ticks(2); - expect(await h.delivery.get(id)).toMatchObject({ - phase: "failed", - error: "Could not confirm delivery", - }); -}); - -it("keeps a receipt error when a stale journal record has no error", async () => { - vi.useFakeTimers(); - const storage = memory(); - const h = setup(storage); - const { eventId: id } = await h.delivery.submit(input, recipient); - await h.managedOwner(true); - await ticks(1); - const stale = storage.load(); - await ticks(MANAGED_RECEIPT_MS); - expect(await h.delivery.get(id)).toMatchObject({ phase: "failed" }); - storage.save(stale); - expect(await h.delivery.get(id)).toMatchObject({ phase: "failed" }); -}); -it("keeps retry backoff when a stale journal record has no attempt delay", async () => { - vi.useFakeTimers(); - const storage = memory(); - const h = setup(storage, async () => { - throw new Error("Confirmation lost"); - }); - const { eventId: id } = await h.delivery.submit(input, recipient); - const stale = storage.load(); - await h.managedOwner(true); - await ticks(1); - expect(h.publish).toHaveBeenCalledOnce(); - storage.save(stale); - await h.delivery.get(id); - await ticks(1); - expect(h.publish).toHaveBeenCalledOnce(); - await ticks(1000); - expect(h.publish).toHaveBeenCalledTimes(2); -}); - -const artifactCollection = { owner: "nexus", name: "tasks" }; -const artifactCollectionKey = JSON.stringify(["nexus", "tasks"]); -const artifactInput = { - kind: 45010, - content: '{"title":"Attempted title"}', - tags: [ - ["ar", "1"], - ["d", "task-id"], - ["h", recipient.channelId], - ["type", "test.task"], - ["op", "create"], - ], -}; -it("keeps accepted edits until a capture starts after acceptance and preserves later pending edits", async () => { - vi.useFakeTimers(); - const storage = memory(); - const h = setup(storage); - const first = await h.delivery.submit(artifactInput, undefined, { - operationKey: "first", - collection: artifactCollection, - }); - const beforeAcceptance = await h.artifactDelivery.capture( - "test.task", - artifactCollectionKey, - ); - await h.managedOwner(true); - await ticks(1); - expect(await h.delivery.artifact("task-id")).toMatchObject([ - { confirmed: false, state: { relayAccepted: true } }, - ]); - await h.artifactDelivery.complete( - beforeAcceptance, - new AbortController().signal, - ); - expect(await h.delivery.artifact("task-id")).toMatchObject([ - { confirmed: false }, - ]); - expect( - await h.artifactDelivery.capture( - "test.task", - JSON.stringify(["other", "tasks"]), - ), - ).toEqual([]); - const captured = await h.artifactDelivery.capture( - "test.task", - artifactCollectionKey, - ); - expect(captured).toEqual([first.eventId]); - expect(h.artifactDelivery.targets(captured)).toEqual([ - { operationId: first.eventId, artifactId: "task-id" }, - ]); - const second = await h.delivery.submit( - { ...artifactInput, content: '{"title":"Later edit"}' }, - undefined, - { operationKey: "second", collection: artifactCollection }, - ); - await ticks(1); - await h.artifactDelivery.complete(captured, new AbortController().signal); - expect(await h.delivery.artifact("task-id")).toMatchObject([ - { - confirmed: false, - template: { content: '{"title":"Later edit"}' }, - state: { eventId: second.eventId }, - }, - { confirmed: true, state: { eventId: first.eventId } }, - ]); - h.dispose(); - const restarted = setup(storage); - expect(await restarted.delivery.artifact("task-id")).toMatchObject([ - { confirmed: false }, - { confirmed: true }, - ]); - await restarted.managedOwner(true); - await ticks(60_000); - expect(restarted.publish).not.toHaveBeenCalled(); - expect(await restarted.delivery.get(second.eventId)).toMatchObject({ - phase: "received", - relayAccepted: true, - }); -}); -it("recovers a retained caller key after restart and rejects different content", async () => { - vi.useFakeTimers(); - const storage = memory(); - const h = setup(storage); - const options = { - operationKey: "setup/task-id", - collection: artifactCollection, - }; - const first = await h.delivery.submit(artifactInput, undefined, options); - h.dispose(); - await ticks(2_000); - const restarted = setup(storage); - expect(await restarted.delivery.lookup(options.operationKey)).toMatchObject({ - operationId: first.operationId, - }); - expect( - await restarted.delivery.submit(artifactInput, undefined, options), - ).toEqual(first); - await expect( - restarted.delivery.submit( - { ...artifactInput, content: "changed" }, - undefined, - options, - ), - ).rejects.toThrow("different artifact content"); - expect(restarted.outbox.snapshot()).toHaveLength(1); -}); -it("retains a refused artifact value and the relay error without automatic resubmission", async () => { - vi.useFakeTimers(); - const h = setup(memory(), async () => { - throw new PublishRejected("Artifact conflict"); - }); - await h.delivery.submit(artifactInput); - await h.managedOwner(true); - await ticks(60_000); - expect(h.publish).toHaveBeenCalledOnce(); - expect(await h.delivery.artifact("task-id")).toMatchObject([ - { - confirmed: false, - template: { content: artifactInput.content }, - state: { phase: "failed", error: "Retry blocked: Artifact conflict" }, - }, - ]); -}); -it("does not reconcile a pending edit after capture cancellation", async () => { - vi.useFakeTimers(); - const h = setup(); - await h.delivery.submit(artifactInput); - await h.managedOwner(true); - await ticks(1); - const ids = await h.artifactDelivery.capture( - "test.task", - artifactCollectionKey, - ); - const controller = new AbortController(); - controller.abort(); - await expect( - h.artifactDelivery.complete(ids, controller.signal), - ).rejects.toMatchObject({ name: "AbortError" }); - expect(await h.delivery.artifact("task-id")).toMatchObject([ - { confirmed: false }, - ]); -}); - -it("keeps acceptance and the attempted value when saving capture confirmation fails", async () => { - vi.useFakeTimers(); - const storage = memory(); - let fail = false; - const h = setup({ - ...storage, - save: (records) => { - if (fail) throw new Error("Disk full"); - storage.save(records); - }, - }); - await h.delivery.submit(artifactInput); - await h.managedOwner(true); - await ticks(1); - const ids = await h.artifactDelivery.capture( - "test.task", - artifactCollectionKey, - ); - fail = true; - await expect( - h.artifactDelivery.complete(ids, new AbortController().signal), - ).rejects.toThrow("Disk full"); - expect(await h.delivery.artifact("task-id")).toMatchObject([ - { - confirmed: false, - state: { - phase: "failed", - relayAccepted: true, - error: "Could not confirm saved data", - }, - }, - ]); - fail = false; - await h.artifactDelivery.complete(ids, new AbortController().signal); - expect(await h.delivery.artifact("task-id")).toMatchObject([ - { confirmed: true, state: { phase: "received" } }, - ]); - await ticks(60_000); - expect(h.publish).toHaveBeenCalledOnce(); -}); - -it("keeps newest-first enqueue order when the clock does not advance, including restart", async () => { - vi.useFakeTimers(); - const storage = memory(); - const firstHost = setup(storage); - const now = Date.now(); - const first = await firstHost.delivery.submit(artifactInput, undefined, { - operationKey: "same-ms/first", - collection: artifactCollection, - }); - const second = await firstHost.delivery.submit( - { ...artifactInput, content: '{"title":"Newest"}' }, - undefined, - { operationKey: "same-ms/second", collection: artifactCollection }, - ); - expect(Date.now()).toBe(now); - expect( - (await firstHost.delivery.artifact("task-id")).map( - (entry) => entry.state.eventId, - ), - ).toEqual([second.eventId, first.eventId]); - firstHost.dispose(); - const restarted = setup(storage); - const third = await restarted.delivery.submit( - { ...artifactInput, content: '{"title":"Newest after restart"}' }, - undefined, - { operationKey: "same-ms/third", collection: artifactCollection }, - ); - expect(Date.now()).toBe(now); - expect( - (await restarted.delivery.artifact("task-id")).map( - (entry) => entry.state.eventId, - ), - ).toEqual([third.eventId, second.eventId, first.eventId]); -}); - -it("saves retained keyed intent after storage recovers before returning its original ID", async () => { - vi.useFakeTimers(); - const storage = memory(); - let fail = true; - const follower = setup({ - ...storage, - save: (records) => { - if (fail) throw new Error("Disk full"); - storage.save(records); - }, - }); - const options = { - operationKey: "unsaved-key", - collection: artifactCollection, - }; - await expect( - follower.delivery.submit(artifactInput, undefined, options), - ).rejects.toThrow("Disk full"); - expect(storage.load()).toHaveLength(0); - fail = false; - const recovered = await follower.delivery.submit( - artifactInput, - undefined, - options, - ); - const otherWindow = setup(storage); - expect(await otherWindow.delivery.lookup(options.operationKey)).toMatchObject( - { operationId: recovered.operationId }, - ); - expect(storage.load()).toHaveLength(1); -}); -it("rejects unsupported artifact writes before saving intent", async () => { - const storage = memory(); - const t = scriptedTransport(viewer.pubkey, "relay"); - const h = createRelaySession( - { - ...t.transport, - writer: { - sign: async (template) => signed(viewer, template), - publish: async () => {}, - }, - }, - { outboxStorage: storage }, - ); - try { - await expect(h.session.delivery?.submit(artifactInput)).rejects.toThrow( - "requires relay sync support", - ); - expect(storage.load()).toHaveLength(0); - } finally { - h.dispose(); - } -}); -it("restores a full artifact journal without blocking normal chat admission", async () => { - vi.useFakeTimers(); - const storage = memory(); - const first = setup(storage); - for (let i = 0; i < 256; i++) - await first.delivery.submit({ - ...artifactInput, - content: String(i), - tags: artifactInput.tags.map((tag) => - tag[0] === "d" ? ["d", `task-${i}`] : tag, - ), - }); - first.dispose(); - const restored = setup(storage); - await restored.outbox.ready(); - await expect( - restored.delivery.submit({ ...artifactInput, content: "over capacity" }), - ).rejects.toThrow("Too many outstanding"); - const id = restored.outbox.send(input); - expect(restored.outbox.snapshot()).toHaveLength(257); - await ticks(1); - expect(storage.load().some((item) => item.event.id === id)).toBe(true); - expect( - storage.load().filter((item) => item.event.kind === 45010), - ).toHaveLength(256); -}); - -it.each([45010, 9])( - "isolates byte exhaustion in kind %s from the other class", - async (kind) => { - vi.useFakeTimers(); - const storage = memory(); - const h = setup(storage); - const template = kind === 45010 ? artifactInput : input; - const content = "x".repeat(kind === 45010 ? 400_000 : 30_000); - let count = 0; - for (; count < 256; count++) { - try { - await h.delivery.submit({ - ...template, - content: content + String(count), - }); - } catch (error) { - expect(String(error)).toContain("Outbox storage is full"); - break; - } - } - expect(count).toBeGreaterThan(0); - expect(count).toBeLessThan(256); - expect(storage.load()).toHaveLength(count); - const other = await h.delivery.submit( - kind === 45010 ? input : artifactInput, - ); - expect(storage.load()).toHaveLength(count + 1); - expect(storage.load().some((item) => item.event.id === other.eventId)).toBe( - true, - ); - h.dispose(); - const restored = setup(storage); - await restored.outbox.ready(); - expect(restored.local.snapshot()).toHaveLength(count + 1); - }, -); -it("preserves artifact intent while completed message retention is full", async () => { - vi.useFakeTimers(); - const storage = memory(); - const records: OutgoingEvent[] = Array.from({ length: 2048 }, (_, i) => { - const event = { - kind: 9, - content: `Complete ${i}`, - tags: [["h", recipient.channelId]], - pubkey: viewer.pubkey, - created_at: 1, - }; - return { - event: { ...event, id: getEventHash(event) }, - delivery: "accepted", - managed: { - version: 1, - round: 0, - attempts: 1, - relayAccepted: true, - agentReceived: false, - nextAttemptAt: 0, - receiptDeadline: 0, - }, - }; - }); - storage.save(records); - const h = setup(storage); - const artifact = await h.delivery.submit(artifactInput); - const message = await h.delivery.submit(input, recipient); - await h.managedOwner(true); - await ticks(1); - await h.receiveManaged(recipient.channelId, recipient.agentId, [ - message.eventId, - ]); - await ticks(1); - expect(await h.delivery.artifact("task-id")).toMatchObject([ - { confirmed: false, state: { eventId: artifact.eventId } }, - ]); - expect( - h.local.snapshot().filter((item) => item.event.kind === 9), - ).toHaveLength(2048); - expect( - h.local.snapshot().some((item) => item.event.id === artifact.eventId), - ).toBe(true); -}); - -it("restores legacy unsigned artifact bodies and preserves them if a signature exceeds the class budget", async () => { - vi.useFakeTimers(); - const storage = memory(); - storage.save( - Array.from({ length: 4 }, (_, i) => { - const event = { - kind: 45010, - content: "x".repeat(400_000), - tags: [["d", `legacy-${i}`]], - pubkey: viewer.pubkey, - created_at: 1, - }; - return { - event: { ...event, id: getEventHash(event) }, - delivery: "unknown" as const, - managed: { - version: 1 as const, - round: 0, - attempts: 0, - relayAccepted: false, - agentReceived: false, - nextAttemptAt: 0, - receiptDeadline: 0, - artifactConfirmed: false, - }, - }; - }), - ); - const h = setup(storage); - await h.outbox.ready(); - expect(h.outbox.snapshot()).toHaveLength(4); - await h.managedOwner(true); - await ticks(8_000); - const chat = await h.delivery.submit(input, recipient); - await ticks(1); - expect(storage.load().some((item) => item.event.id === chat.eventId)).toBe( - true, - ); - expect( - storage.load().filter((item) => item.event.kind === 45010), - ).toHaveLength(4); - h.dispose(); - const restarted = setup(storage); - await restarted.outbox.ready(); - expect( - restarted.local.snapshot().filter((item) => item.event.kind === 45010), - ).toHaveLength(4); -}); - -it("keeps chat durable when retry metadata exceeds a restored artifact byte budget", async () => { - vi.useFakeTimers(); - const storage = memory(); - const rows: OutgoingEvent[] = Array.from({ length: 5 }, (_, i) => { - const event = { - kind: 45010, - content: "x".repeat(400_000), - tags: [["d", `boundary-${i}`]], - pubkey: viewer.pubkey, - created_at: 1, - }; - return { - event: { ...event, id: getEventHash(event) }, - delivery: "unknown", - managed: { - version: 1, - round: 0, - attempts: 0, - relayAccepted: false, - agentReceived: false, - nextAttemptAt: 0, - receiptDeadline: 0, - artifactConfirmed: false, - }, - }; - }); - const last = rows[4]; - assert.exists(last); - const event = { - ...last.event, - content: - last.event.content + "x".repeat(2_097_127 - JSON.stringify(rows).length), - }; - rows[4] = { ...last, event: { ...event, id: getEventHash(event) } }; - expect(JSON.stringify(rows).length).toBe(2_097_127); - storage.save(rows); - const h = setup(storage); - await h.outbox.ready(); - await h.managedOwner(true); - await ticks(1); - const chat = await h.delivery.submit(input, recipient); - await ticks(1); - expect(storage.load().some((item) => item.event.id === chat.eventId)).toBe( - true, - ); - expect(storage.load().filter((item) => item.event.kind === 45010)).toEqual( - rows, - ); - h.dispose(); - const restarted = setup(storage); - await restarted.outbox.ready(); - expect( - restarted.local.snapshot().some((item) => item.event.id === chat.eventId), - ).toBe(true); -}); - -it("honors completed retention while preserving a full prior artifact class", async () => { - vi.useFakeTimers(); - const storage = memory(); - const pending: OutgoingEvent[] = Array.from({ length: 5 }, (_, i) => { - const event = { - kind: 45010, - content: "x".repeat(400_000), - tags: [["d", `retention-${i}`]], - pubkey: viewer.pubkey, - created_at: 1, - }; - return { - event: { ...event, id: getEventHash(event) }, - delivery: "unknown", - managed: { - version: 1, - round: 0, - attempts: 0, - relayAccepted: false, - agentReceived: false, - nextAttemptAt: 0, - receiptDeadline: 0, - artifactConfirmed: false, - }, - }; - }); - const last = pending[4]; - assert.exists(last); - const event = { - ...last.event, - content: - last.event.content + - "x".repeat(2_097_127 - JSON.stringify(pending).length), - }; - pending[4] = { ...last, event: { ...event, id: getEventHash(event) } }; - const completed: OutgoingEvent[] = Array.from({ length: 2048 }, (_, i) => { - const event = { - kind: 45010, - content: String(i), - tags: [["d", `complete-${i}`]], - pubkey: viewer.pubkey, - created_at: 1, - }; - return { - event: { ...event, id: getEventHash(event) }, - delivery: "accepted", - managed: { - version: 1, - round: 0, - attempts: 1, - relayAccepted: true, - agentReceived: false, - nextAttemptAt: 0, - receiptDeadline: 0, - artifactConfirmed: true, - }, - }; - }); - storage.save([...completed, ...pending]); - const h = setup(storage); - await h.managedOwner(true); - await ticks(1); - const chat = h.outbox.send(input); - await ticks(1); - const published = h.publish.mock.calls.find( - ([event]) => event.id === chat, - )?.[0]; - assert.exists(published); - h.observe([published]); - await ticks(1); - expect(storage.load()).toHaveLength(2053); - expect( - storage - .load() - .filter( - (item) => - item.event.kind === 45010 && - item.managed?.artifactConfirmed === false, - ), - ).toEqual(pending); - h.dispose(); - const restarted = setup(storage); - await restarted.outbox.ready(); - expect(restarted.local.snapshot()).toHaveLength(2053); -}); - -it.each(["unknown", "seen"] as const)( - "restores signed %s pending rows at the saved byte bound", - async (delivery) => { - vi.useFakeTimers(); - const storage = memory(); - const row = (content: string, number: number): OutgoingEvent => { - const proof = signed(viewer, { - kind: 45010, - content, - tags: [["d", `signed-boundary-${number}`]], - }); - return { - event: { - id: proof.id, - pubkey: proof.pubkey, - created_at: proof.created_at, - kind: proof.kind, - content: proof.content, - tags: proof.tags, - }, - signed: proof, - delivery, - managed: { - version: 1, - round: 0, - attempts: 0, - relayAccepted: true, - agentReceived: false, - nextAttemptAt: 0, - receiptDeadline: 0, - artifactConfirmed: false, - }, - }; - }; - const rows = Array.from({ length: 5 }, (_, number) => - row("x".repeat(200_000), number), - ); - const padding = Math.floor((2_097_149 - JSON.stringify(rows).length) / 2); - rows[4] = row("x".repeat(200_000 + padding), 4); - expect(JSON.stringify(rows).length).toBeGreaterThanOrEqual(2_097_148); - expect(JSON.stringify(rows).length).toBeLessThanOrEqual(2_097_149); - storage.save(rows); - const h = setup(storage); - await h.outbox.ready(); - expect(h.outbox.snapshot()).toHaveLength(5); - expect(h.outbox.snapshot().every((item) => !("sig" in item.event))).toBe( - true, - ); - const chat = await h.delivery.submit(input, recipient); - expect(storage.load().some((item) => item.event.id === chat.eventId)).toBe( - true, - ); - h.dispose(); - const restarted = setup(storage); - await restarted.outbox.ready(); - expect(restarted.outbox.snapshot()).toHaveLength(6); - }, -); diff --git a/src/features/relay/managed-delivery.ts b/src/features/relay/managed-delivery.ts deleted file mode 100644 index fdd3f96567..0000000000 --- a/src/features/relay/managed-delivery.ts +++ /dev/null @@ -1,224 +0,0 @@ -export type ManagedRecipient = Readonly<{ channelId: string; agentId: string }>; -export type ManagedDelivery = Readonly<{ - version: 1; - operationKey?: string; - collection?: string; - artifactConfirmed?: boolean; - submittedAt?: number; - recipient?: ManagedRecipient; - round: number; - attempts: number; - relayAccepted: boolean; - agentReceived: boolean; - nextAttemptAt: number; - receiptDeadline: number; - error?: "send" | "receipt" | "receipt-save" | "artifact-save" | undefined; -}>; -export type DeliveryState = Readonly<{ - operationId: string; - eventId: string; - phase: "sending" | "received" | "failed"; - relayAccepted: boolean; - agentReceived: boolean; - error?: string; -}>; -export const MANAGED_RECEIPT_MS = 30_000; -export const MANAGED_MAX_ATTEMPTS = 3; -type ManagedHost = { - owner(owned: boolean): Promise; - receive( - channelId: string, - agentId: string, - messageIds: readonly string[], - ): Promise; -}; -const hosts = new Map(); -const owners = new Set(); -const locks = new Map(); -const transitions = new Map>(); -function transition(scope: string, work: () => Promise) { - const next = (transitions.get(scope) ?? Promise.resolve()) - .catch(() => {}) - .then(work); - transitions.set(scope, next); - return next; -} -export function registerManagedDelivery(scope: string, host: ManagedHost) { - const prior = hosts.get(scope); - hosts.set(scope, host); - if ( - typeof navigator !== "undefined" && - navigator.locks && - !locks.has(scope) - ) { - const lifetime = new AbortController(); - locks.set(scope, lifetime); - void navigator.locks - .request( - `buzz-outbox-owner:${scope}`, - { mode: "exclusive", signal: lifetime.signal }, - async () => { - await ownManagedDelivery(scope, true); - try { - await new Promise((resolve) => { - if (lifetime.signal.aborted) resolve(); - else - lifetime.signal.addEventListener("abort", () => resolve(), { - once: true, - }); - }); - } finally { - await ownManagedDelivery(scope, false); - } - }, - ) - .catch((error) => { - if (!lifetime.signal.aborted) - console.error("Message outbox ownership fails", scope, error); - }); - } - void transition(scope, async () => { - await prior?.owner(false); - if (hosts.get(scope) === host && owners.has(scope)) await host.owner(true); - }).catch((error) => - console.error("Managed delivery owner fails", scope, error), - ); - return () => { - if (hosts.get(scope) === host) { - hosts.delete(scope); - locks.get(scope)?.abort(); - locks.delete(scope); - } - void transition(scope, () => host.owner(false)).catch((error) => - console.error("Managed delivery release fails", scope, error), - ); - }; -} -export async function ownManagedDelivery(scope: string, owned: boolean) { - if (owned) owners.add(scope); - else owners.delete(scope); - await transition(scope, async () => { - await hosts.get(scope)?.owner(owners.has(scope)); - }); -} -export async function receiveManagedDelivery( - scope: string, - channelId: string, - agentId: string, - messageIds: readonly string[], -) { - const host = hosts.get(scope); - if (!host) throw new Error("Message delivery is unavailable"); - await host.receive(channelId, agentId, messageIds); -} -export function managedDelivery(value: unknown): ManagedDelivery { - const raw = value as ManagedDelivery | null; - if ( - raw?.version !== 1 || - ![raw.round, raw.attempts, raw.nextAttemptAt, raw.receiptDeadline].every( - (value) => Number.isSafeInteger(value) && value >= 0, - ) || - (raw.operationKey !== undefined && - (typeof raw.operationKey !== "string" || - !raw.operationKey.length || - raw.operationKey.length > 200)) || - (raw.collection !== undefined && - (typeof raw.collection !== "string" || raw.collection.length > 500)) || - (raw.artifactConfirmed !== undefined && - typeof raw.artifactConfirmed !== "boolean") || - (raw.submittedAt !== undefined && - (!Number.isSafeInteger(raw.submittedAt) || raw.submittedAt < 0)) || - typeof raw.relayAccepted !== "boolean" || - typeof raw.agentReceived !== "boolean" || - (raw.error !== undefined && - !["send", "receipt", "receipt-save", "artifact-save"].includes( - raw.error, - )) || - (raw.recipient && - (!/^[0-9a-f]{64}$/.test(raw.recipient.agentId) || - !/^[a-zA-Z0-9_-]{1,128}$/.test(raw.recipient.channelId))) - ) - throw new Error("Invalid managed delivery state"); - return Object.freeze({ - ...raw, - ...(raw.recipient - ? { recipient: Object.freeze({ ...raw.recipient }) } - : {}), - }); -} -/** Acceptance and receipt survive a stale writer from another window. */ -export function mergeManagedDelivery( - old: ManagedDelivery, - next: ManagedDelivery, -): ManagedDelivery { - if (JSON.stringify(old.recipient) !== JSON.stringify(next.recipient)) - throw new Error("Managed recipient changed"); - if ( - old.collection !== next.collection || - old.operationKey !== next.operationKey || - old.submittedAt !== next.submittedAt - ) - throw new Error("Managed operation identity changed"); - const current = old.round > next.round ? old : next; - const sameRound = old.round === next.round; - const error = sameRound - ? (["artifact-save", "receipt-save", "receipt", "send"] as const).find( - (error) => old.error === error || next.error === error, - ) - : current.error; - const relayAccepted = old.relayAccepted || next.relayAccepted; - const agentReceived = old.agentReceived || next.agentReceived; - return { - ...current, - relayAccepted, - agentReceived, - ...(old.artifactConfirmed !== undefined || - next.artifactConfirmed !== undefined - ? { - artifactConfirmed: - old.artifactConfirmed === true || next.artifactConfirmed === true, - } - : {}), - attempts: sameRound - ? Math.max(old.attempts, next.attempts) - : current.attempts, - nextAttemptAt: sameRound - ? Math.max(old.nextAttemptAt, next.nextAttemptAt) - : current.nextAttemptAt, - receiptDeadline: sameRound - ? Math.max(old.receiptDeadline, next.receiptDeadline) - : current.receiptDeadline, - error, - ...(agentReceived || - (!current.recipient && - relayAccepted && - (error !== "artifact-save" || - old.artifactConfirmed || - next.artifactConfirmed)) - ? { error: undefined } - : relayAccepted && error === "send" - ? { error: undefined } - : {}), - }; -} -export function deliveryState( - id: string, - state: ManagedDelivery, -): DeliveryState { - const received = state.recipient ? state.agentReceived : state.relayAccepted; - return { - operationId: id, - eventId: id, - phase: received ? "received" : state.error ? "failed" : "sending", - relayAccepted: state.relayAccepted, - agentReceived: state.agentReceived, - ...(!received && state.error - ? { - error: - state.error === "send" - ? "Could not send" - : "Could not confirm delivery", - } - : {}), - }; -} diff --git a/src/features/relay/managed-ownership.test.ts b/src/features/relay/managed-ownership.test.ts deleted file mode 100644 index e30e18ac29..0000000000 --- a/src/features/relay/managed-ownership.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { afterEach, expect, it, vi } from "vitest"; -import { registerManagedDelivery } from "./managed-delivery"; - -afterEach(() => vi.unstubAllGlobals()); -it("keeps separate outbox owners until each retained session closes", async () => { - const requests: Promise[] = []; - vi.stubGlobal("navigator", { - locks: { - request: vi.fn((_name, _options, run) => { - const work = Promise.resolve().then(run); - requests.push(work); - return work; - }), - }, - }); - const a = { owner: vi.fn(async () => {}), receive: vi.fn(async () => {}) }; - const b = { owner: vi.fn(async () => {}), receive: vi.fn(async () => {}) }; - const stopA = registerManagedDelivery("community-a", a); - const stopB = registerManagedDelivery("community-b", b); - await vi.waitFor(() => { - expect(a.owner).toHaveBeenCalledWith(true); - expect(b.owner).toHaveBeenCalledWith(true); - }); - stopB(); - await vi.waitFor(() => expect(b.owner).toHaveBeenLastCalledWith(false)); - expect(a.owner).not.toHaveBeenCalledWith(false); - stopA(); - await Promise.all(requests); - expect(a.owner).toHaveBeenLastCalledWith(false); -}); diff --git a/src/features/relay/message-projection.ts b/src/features/relay/message-projection.ts index 1f46b3cc6c..4fc6b36835 100644 --- a/src/features/relay/message-projection.ts +++ b/src/features/relay/message-projection.ts @@ -1,5 +1,4 @@ import type { ChannelMessage } from "./contracts"; -import { deliveryState } from "./managed-delivery"; import type { EventData } from "./events"; import { collapseHuddleCards, foldMessages } from "./fold"; import type { OutgoingEvent } from "./outbox"; @@ -85,8 +84,7 @@ export class MessageProjection { const previous = this.deliveries.get(id); if ( previous?.delivery !== item.delivery || - previous?.error !== item.error || - previous?.managed !== item.managed + previous?.error !== item.error ) { if (messageKind(item.event.kind) && !affected.has(id)) this.delivery(id, item); @@ -159,9 +157,6 @@ export class MessageProjection { ...row, delivery: item?.delivery, deliveryError: item?.error, - managedDelivery: item?.managed - ? deliveryState(row.id, item.managed) - : undefined, }); } private delivery(id: string, item: OutgoingEvent | undefined) { diff --git a/src/features/relay/messages.ts b/src/features/relay/messages.ts index 499396bab2..2c395d2449 100644 --- a/src/features/relay/messages.ts +++ b/src/features/relay/messages.ts @@ -46,29 +46,12 @@ export function createMessages( publishReport?: (template: EventTemplate) => Promise, /** Explicit relay NIP-11 self; never inferred from contacts or a displayed row. */ signingAuthority?: string, - managedRecipient?: string, ) { const writer = (kind: number, channelId: string) => { if (!canParticipate(channelId)) throw new Error("Join the conversation before posting"); if (!outbox?.supports(kind)) throw new Error("This connection cannot publish that operation"); - if (managedRecipient && kind === 9) { - if (!/^[0-9a-f]{64}$/.test(managedRecipient)) - throw new Error("Invalid managed recipient"); - validateMentions(channelId, [managedRecipient]); - return { - ...outbox, - send: ( - input: Parameters[0], - recovery?: OutboxRecovery, - active?: () => boolean, - ) => - outbox.send(input, recovery, active, { - recipient: { channelId, agentId: managedRecipient }, - }), - }; - } return outbox; }; const ownedMessage = (event: EventData) => diff --git a/src/features/relay/native.ts b/src/features/relay/native.ts index 84a849fdc7..7c0d1ff7ae 100644 --- a/src/features/relay/native.ts +++ b/src/features/relay/native.ts @@ -803,10 +803,7 @@ export async function connectNativeTransport( async publish(event, signal) { // The relay rejects old events before deduplication. An explicit retry // can confirm the original ID, but must never silently re-date it. - if ( - event.kind !== ARTIFACT_KIND && - event.created_at < Math.floor(Date.now() / 1000) - 15 * 60 - ) { + if (event.created_at < Math.floor(Date.now() / 1000) - 15 * 60) { const found = await transport.query( [ { diff --git a/src/features/relay/outbox-storage.ts b/src/features/relay/outbox-storage.ts index aeac8da4fa..eefb51d1e9 100644 --- a/src/features/relay/outbox-storage.ts +++ b/src/features/relay/outbox-storage.ts @@ -1,5 +1,4 @@ import type { OutboxStorage, OutgoingEvent } from "./outbox"; -import { mergeManagedDelivery } from "./managed-delivery"; function openDatabase() { return new Promise((resolve, reject) => { @@ -53,28 +52,9 @@ export async function purgeOutboxStorage(scope: string) { export function browserOutboxStorage(scope: string): OutboxStorage { let database: Promise | undefined; let persisted = new Map(); - const open = () => - (database ??= openDatabase().catch((error) => { - database = undefined; - throw error; - })); - const listeners = new Set<() => void>(); - const changes = - typeof BroadcastChannel === "undefined" - ? undefined - : new BroadcastChannel(`buzz-outbox:${scope}`); - if (changes) - changes.onmessage = () => { - for (const listener of listeners) listener(); - }; + const open = () => (database ??= openDatabase()); return { - subscribe(listener) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - async load(options) { + async load() { const db = await open(); let migrated = false; const records = await new Promise( @@ -109,10 +89,9 @@ export function browserOutboxStorage(scope: string): OutboxStorage { }, ); // A first save migrates legacy records into the per-event store. - if (!options?.refresh) - persisted = migrated - ? new Map(records.map((item) => [item.event.id, item])) - : new Map(); + persisted = migrated + ? new Map(records.map((item) => [item.event.id, item])) + : new Map(); return records; }, async save(operations) { @@ -134,34 +113,16 @@ export function browserOutboxStorage(scope: string): OutboxStorage { old.signed?.id === operation.signed?.id && old.recovery?.key === operation.recovery?.key && old.recovery?.value === operation.recovery?.value && - old.guarded === operation.guarded && - JSON.stringify(old.managed) === JSON.stringify(operation.managed) + old.guarded === operation.guarded ) continue; - if (operation.managed) { - const current = events.get([scope, id]); - current.onsuccess = () => { - try { - const prior = current.result?.operation as - | OutgoingEvent - | undefined; - const managed = - prior?.managed && operation.managed - ? mergeManagedDelivery(prior.managed, operation.managed) - : operation.managed; - events.put({ scope, id, operation: { ...operation, managed } }); - } catch { - tx.abort(); - } - }; - } else events.put({ scope, id, operation }); + events.put({ scope, id, operation }); } for (const id of persisted.keys()) if (!next.has(id)) events.delete([scope, id]); tx.objectStore("partitions").put(true, scope); tx.oncomplete = () => { persisted = next; - changes?.postMessage(null); resolve(); }; tx.onabort = () => @@ -170,8 +131,6 @@ export function browserOutboxStorage(scope: string): OutboxStorage { }); }, close() { - changes?.close(); - listeners.clear(); void database?.then( (db) => db.close(), () => {}, diff --git a/src/features/relay/outbox.test.ts b/src/features/relay/outbox.test.ts index f849f15910..b16aacbca2 100644 --- a/src/features/relay/outbox.test.ts +++ b/src/features/relay/outbox.test.ts @@ -815,18 +815,7 @@ it("restores a retained creation to retained storage when dismissal cannot persi content: "", tags: [["h", "11111111-1111-4111-8111-111111111111"]], }); - const retained: OutgoingEvent = { - event: { - id: event.id, - pubkey: event.pubkey, - created_at: event.created_at, - kind: event.kind, - content: event.content, - tags: event.tags, - }, - signed: event, - delivery: "seen", - }; + const retained: OutgoingEvent = { event, signed: event, delivery: "seen" }; let records: readonly OutgoingEvent[] = [retained]; const save = vi .fn() @@ -910,18 +899,7 @@ it.each(["commit", "reject", "echo"] as const)( content: "Retained edit", tags: [["e", "a".repeat(64)]], }); - const pending: OutgoingEvent = { - event: { - id: event.id, - pubkey: event.pubkey, - created_at: event.created_at, - kind: event.kind, - content: event.content, - tags: event.tags, - }, - signed: event, - delivery: "failed", - }; + const pending: OutgoingEvent = { event, signed: event, delivery: "failed" }; let records: readonly OutgoingEvent[] = [pending]; let release = () => {}; let reject = (_error: Error) => {}; @@ -1008,8 +986,7 @@ it.each(["commit", "reject", "echo"] as const)( if (outcome === "commit") expect(remaining).toBeUndefined(); else expect(remaining).toMatchObject({ - event: pending.event, - signed: event, + event, delivery: outcome === "echo" ? "seen" : "failed", }); expect( diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts index 69708f2460..6a0bf0ed92 100644 --- a/src/features/relay/outbox.ts +++ b/src/features/relay/outbox.ts @@ -1,14 +1,4 @@ -import { ARTIFACT_MAX_EVENT_BYTES } from "../agents/agent-config-artifacts"; import { isWorkflowOperation } from "../workflows/protocol"; -import { - managedDelivery, - mergeManagedDelivery, - deliveryState, - MANAGED_MAX_ATTEMPTS, - MANAGED_RECEIPT_MS, - type ManagedDelivery, - type ManagedRecipient, -} from "./managed-delivery"; import { yieldToHost } from "./yield"; import { getEventHash, type EventTemplate } from "nostr-tools"; import { eventDto, type EventData, type RelayEvent } from "./events"; @@ -48,7 +38,6 @@ export type OutgoingEvent = Readonly<{ acknowledged?: true; /** A caller-scoped admission requires renewed live eligibility for retry. */ guarded?: boolean; - managed?: ManagedDelivery; delivery: Delivery; error?: string | undefined; /** The relay refused the last attempt, rather than it failing on this @@ -71,11 +60,6 @@ export interface Outbox { }, recovery?: OutboxRecovery, active?: () => boolean, - managed?: Readonly<{ - recipient?: ManagedRecipient; - operationKey?: string; - collection?: string; - }>, ): string; /** Attach caller-owned recovery to an existing receipt before resuming it. */ recover(id: string, recovery: OutboxRecovery): Promise; @@ -91,11 +75,8 @@ type SendObserver = ( ) => ((pending: readonly EventData[]) => void) | undefined; export type LocalEvents = Pick; export interface OutboxStorage { - subscribe?(listener: () => void): () => void; close?(): void; - load(options?: { - refresh: true; - }): readonly OutgoingEvent[] | Promise; + load(): readonly OutgoingEvent[] | Promise; save(operations: readonly OutgoingEvent[]): void | Promise; } export class PublishRejected extends Error { @@ -116,7 +97,6 @@ export class PublishNotSent extends PublishRejected {} export { browserOutboxStorage } from "./outbox-storage"; const abortError = () => new DOMException("Relay session closed", "AbortError"); const MAX_PENDING = 256; -const MAX_PENDING_BYTES = 2 * 1024 * 1024; const MAX_CONFIRMED = 2048; /** One journal owns pending delivery and bounded confirmed retention. Views receive one change stream. */ @@ -134,11 +114,9 @@ export function createOutbox( needsReceipt = () => false, onReceipt = (_event: EventData, _message: string | undefined) => {}, clock = new MessageClock(), - deliveryScope, }: { /** The session's send clock, shared with its rendered views. */ clock?: MessageClock; - deliveryScope?: string; timeoutMs?: number; /** Commands await their receipt even after a verified echo. Never persisted. */ needsReceipt?: (event: EventData) => boolean; @@ -171,6 +149,7 @@ export function createOutbox( let finalVisible: readonly OutgoingEvent[] | undefined; const completed = new ByteLru(MAX_CONFIRMED, 8 * 1024 * 1024); const sizes = new WeakMap(); + let pendingBytes = 0; const listeners = new Set<() => void>(); const localListeners = new Set<() => void>(); const dismissListeners = new Set<(item: OutgoingEvent) => void>(); @@ -187,7 +166,6 @@ export function createOutbox( held?: true; }; const attempts = new Map(); - const deliveries = new Map, string>(); const dismissing = new Map>(); const lifetime = new AbortController(); const sendListeners = new Set(); @@ -251,27 +229,19 @@ export function createOutbox( let confirmedKeep: ((event: EventData) => boolean) | undefined; let storageError: string | undefined; let hydrated = false; - const unsavedManaged = new Set(); - let managedOwner = false; - let managedTimer: ReturnType | undefined; let durable = Promise.resolve(); - const pendingSize = (rows: readonly OutgoingEvent[]) => { - const totals = [2, 2]; - for (const item of rows) { - let size = sizes.get(item); - if (size === undefined) { - size = byteSize(item); - sizes.set(item, size); - } - const domain = item.event.kind === 45010 ? 1 : 0; - totals[domain] = (totals[domain] ?? 2) + size + 1; - } - return totals; - }; - const pendingCount = (rows: readonly OutgoingEvent[], kind: number) => - rows.filter((item) => (item.event.kind === 45010) === (kind === 45010)) - .length; const notify = () => { + pendingBytes = snapshot.reduce( + (sum, item) => { + let size = sizes.get(item); + if (size === undefined) { + size = byteSize(item); + sizes.set(item, size); + } + return sum + size; + }, + 2 + Math.max(0, snapshot.length - 1), + ); visible = Object.freeze([ ...completed.entries().map(([, item]) => item), ...snapshot, @@ -279,10 +249,7 @@ export function createOutbox( for (const listener of localListeners) notifyListener(listener); for (const listener of listeners) notifyListener(listener); }; - const persist = ( - id: string, - action?: "acknowledge" | "dismiss" | "receipt" | "artifact", - ) => { + const persist = (id: string, action?: "acknowledge" | "dismiss") => { const queued = profiling.start("outbox.queue", id); const work = durable .catch(() => {}) @@ -295,121 +262,28 @@ export function createOutbox( queued(); // Hydration may have added restored intent while this commit waited. // Commit current state so no intermediate transaction erases that intent. - const records = () => - action === "artifact" - ? visible.map((item) => - item.event.id === id && item.managed?.relayAccepted - ? { - ...item, - managed: { - ...item.managed, - artifactConfirmed: true, - error: undefined, - }, - } - : item, - ) - : action === "dismiss" - ? visible.filter((item) => item.event.id !== id) - : action === "receipt" - ? visible.map((item) => - item.event.id === id && item.managed - ? { - ...item, - managed: { - ...item.managed, - agentReceived: true, - error: undefined, - }, - } - : item, - ) - : action === "acknowledge" - ? visible.map((item) => - item.event.id === id - ? { - ...item, - recovery: undefined, - acknowledged: true as const, - } - : item, - ) - : visible; - const bytes = pendingSize( + const records = action === "dismiss" - ? snapshot.filter((item) => item.event.id !== id) - : snapshot, - ); + ? visible.filter((item) => item.event.id !== id) + : action === "acknowledge" + ? visible.map((item) => + item.event.id === id + ? { + ...item, + recovery: undefined, + acknowledged: true as const, + } + : item, + ) + : visible; + const bytes = + action === "dismiss" + ? byteSize(snapshot.filter((item) => item.event.id !== id)) + : pendingBytes; return profiling.measureAsync("outbox.persist", id, async () => { - const target = visible.find((item) => item.event.id === id); - const domain = target - ? target.event.kind === 45010 - ? 1 - : 0 - : undefined; - if ( - domain === undefined - ? bytes.some((size) => size > MAX_PENDING_BYTES) - : (bytes[domain] ?? 0) > MAX_PENDING_BYTES - ) + if (bytes > 2 * 1024 * 1024) throw new Error("Outbox storage is full"); - let saved = records(); - if ( - domain !== undefined && - (bytes[1 - domain] ?? 0) > MAX_PENDING_BYTES - ) { - const prior = await storage.load({ refresh: true }); - validateSaved(prior); - // A failed status save in the other class must not join this commit. - const currentRecords = records(); - const retained = new Set( - currentRecords.map((item) => item.event.id), - ); - const priorById = new Map( - prior.map((item) => [item.event.id, item]), - ); - saved = currentRecords.flatMap((item) => { - if ((item.event.kind === 45010 ? 1 : 0) === domain) return [item]; - const durable = priorById.get(item.event.id); - return durable ? [durable] : []; - }); - saved = [ - ...saved, - ...prior.filter( - (item) => - (item.event.kind === 45010 ? 1 : 0) !== domain && - pendingOperation(item) && - !retained.has(item.event.id), - ), - ]; - } - await storage.save(saved); - for (const item of saved) unsavedManaged.delete(item.event.id); - if (action === "artifact") { - const current = find(id); - if (current?.managed?.relayAccepted) - replace({ - ...current, - managed: { - ...current.managed, - artifactConfirmed: true, - error: undefined, - }, - }); - wakeManaged(); - } - if (action === "receipt") { - const current = find(id); - if (current?.managed) - replace({ - ...current, - managed: { - ...current.managed, - agentReceived: true, - error: undefined, - }, - }); - } + await storage.save(records); if (action === "dismiss") { // Commit removal inside the serialized write, before a later save // can capture state. Failed storage never exposes a temporary absence. @@ -465,125 +339,12 @@ export function createOutbox( void persist(item.event.id).catch(() => { /* Durable intent restores as unknown. */ }); - if (item.managed) wakeManaged(); - } - const managedDone = (state: ManagedDelivery) => - state.relayAccepted && - (!state.recipient || state.agentReceived) && - state.artifactConfirmed !== false; - const pendingOperation = (item: OutgoingEvent) => - !!item.recovery || - (item.managed - ? !managedDone(item.managed) - : !(item.event.kind === 9000 && item.acknowledged) && - item.delivery !== "seen"); - function refreshManaged() { - const work = durable - .catch(() => {}) - .then(() => ready) - .then(async () => { - if (closed) return; - const beforeLoad = new Map( - snapshot.map((item) => [item.event.id, item]), - ); - const records = await storage.load({ refresh: true }); - validateSaved(records); - const incoming = new Map( - records - .filter((item) => item.managed) - .map((item) => [item.event.id, decode(item)]), - ); - const next = new Map(snapshot.map((item) => [item.event.id, item])); - const before = JSON.stringify(visible); - for (const [id, item] of incoming) { - const prior = next.get(id) ?? completed.peek(id); - const managed = - prior?.managed && item.managed - ? mergeManagedDelivery(prior.managed, item.managed) - : item.managed; - const merged = { - ...item, - ...(attempts.has(id) && prior - ? { delivery: prior.delivery, error: prior.error } - : {}), - ...(managed ? { managed } : {}), - }; - if (managed && managedDone(managed) && !attempts.has(id)) { - completed.set(id, merged); - next.delete(id); - } else { - next.set(id, merged); - completed.delete(id); - } - } - for (const [id, item] of next) - if ( - item.managed && - !incoming.has(id) && - !attempts.has(id) && - !unsavedManaged.has(id) && - beforeLoad.get(id) === item - ) - next.delete(id); - snapshot = Object.freeze([...next.values()]); - if ( - before !== - JSON.stringify([ - ...completed.entries().map(([, item]) => item), - ...snapshot, - ]) - ) - notify(); - }); - durable = work; - return work; - } - function wakeManaged(delay = 0) { - if (closed || !managedOwner) return; - clearTimeout(managedTimer); - managedTimer = setTimeout(() => { - managedTimer = undefined; - void pumpManaged().catch(() => {}); - }, delay); - } - async function pumpManaged() { - if (closed || !managedOwner) return; - await refreshManaged(); - if (closed || !managedOwner) return; - let delay = Infinity; - for (const item of [...snapshot]) { - const state = item.managed; - const id = item.event.id; - if (!state || attempts.has(id)) continue; - if (managedDone(state)) { - completed.set(id, item); - snapshot = Object.freeze(snapshot.filter((row) => row.event.id !== id)); - notify(); - await persist(id); - continue; - } - if (state.error) continue; - if (state.relayAccepted) { - if (!state.recipient) continue; - if (state.receiptDeadline <= Date.now()) - saveStatus({ ...item, managed: { ...state, error: "receipt" } }); - else delay = Math.min(delay, state.receiptDeadline - Date.now()); - } else if (item.refused || state.attempts >= MANAGED_MAX_ATTEMPTS) { - saveStatus({ ...item, managed: { ...state, error: "send" } }); - } else if (state.nextAttemptAt > Date.now()) - delay = Math.min(delay, state.nextAttemptAt - Date.now()); - else { - replace({ ...item, delivery: "sending", error: undefined }); - schedule(id, undefined, item.delivery); - } - } - if (Number.isFinite(delay)) wakeManaged(delay); } function validateSaved(loaded: readonly OutgoingEvent[]) { if ( !Array.isArray(loaded) || - loaded.length > 2 * MAX_PENDING + MAX_CONFIRMED || - byteSize(loaded) > 2 * MAX_PENDING_BYTES + 8 * 1024 * 1024 + loaded.length > MAX_PENDING + MAX_CONFIRMED || + byteSize(loaded) > 10 * 1024 * 1024 ) throw new Error("Saved outbox exceeds its budget"); } @@ -596,11 +357,7 @@ export function createOutbox( throw new Error("Saved outbox event is invalid"); return Object.freeze({ event: Object.freeze({ - id: event.id, - pubkey: event.pubkey, - created_at: event.created_at, - kind: event.kind, - content: event.content, + ...event, tags: Object.freeze( event.tags.map((tag: string[]) => Object.freeze([...tag])), ) as unknown as string[][], @@ -609,11 +366,8 @@ export function createOutbox( ...(item.recovery ? { recovery: recoveryValue(item.recovery) } : {}), ...(item.acknowledged === true ? { acknowledged: true as const } : {}), ...(item.guarded ? { guarded: true } : {}), - ...(item.managed ? { managed: managedDelivery(item.managed) } : {}), - ...(item.managed && item.refused ? { refused: true as const } : {}), - delivery: item.managed?.relayAccepted - ? "accepted" - : item.delivery === "seen" && signed + delivery: + item.delivery === "seen" && signed ? "seen" : item.delivery === "failed" ? "failed" @@ -621,29 +375,20 @@ export function createOutbox( ...(item.error ? { error: item.error } : {}), }); } - function restore( - restored: readonly OutgoingEvent[], - saved: readonly OutgoingEvent[], - ) { - const pending = restored.filter(pendingOperation); - // Decode can expand status text. Admission uses the saved byte count. - const savedPending = saved.filter((_item, index) => { - const item = restored[index]; - return item !== undefined && pendingOperation(item); - }); - if ( - pendingCount(pending, 45010) > MAX_PENDING || - pendingCount(pending, 9) > MAX_PENDING || - pendingSize(savedPending).some((size) => size > MAX_PENDING_BYTES) - ) + function restore(restored: readonly OutgoingEvent[]) { + const pending = restored.filter( + (item) => + (!(item.event.kind === 9000 && item.acknowledged) && + item.delivery !== "seen") || + !!item.recovery, + ); + if (pending.length > MAX_PENDING) throw new Error("Saved pending outbox exceeds its budget"); for (const item of restored) if ( - ((!!item.managed && managedDone(item.managed)) || - (item.event.kind === 9000 && item.acknowledged) || + ((item.event.kind === 9000 && item.acknowledged) || item.delivery === "seen") && !item.recovery && - (!item.managed || managedDone(item.managed)) && (!confirmedInvalidated || (item.event.kind === 9007 && confirmedKeep?.(item.event))) ) @@ -663,11 +408,11 @@ export function createOutbox( decoded.push(...records.slice(offset, offset + 12).map(decode)); if (offset + 12 < records.length) await yieldToHost(); } - restore(decoded, records); + restore(decoded); }); else { validateSaved(loaded); - restore(loaded.map(decode), loaded); + restore(loaded.map(decode)); ready = Promise.resolve(); } } catch (error) { @@ -716,24 +461,6 @@ export function createOutbox( previousDelivery: Delivery = "failed", ) { if (closed || attempts.has(id)) return; - const item = find(id); - if (item?.managed) { - if ( - !managedOwner || - item.managed.relayAccepted || - item.managed.attempts >= MANAGED_MAX_ATTEMPTS || - item.managed.error - ) - return; - replace({ - ...item, - managed: { - ...item.managed, - attempts: item.managed.attempts + 1, - nextAttemptAt: Date.now() + 1000 * 2 ** item.managed.attempts, - }, - }); - } attempts.set(id, { expiresAt: Date.now() + timeoutMs, previousDelivery, @@ -742,16 +469,10 @@ export function createOutbox( }); void deliver(id); } - function deliver(id: string) { - const work = deliverAttempt(id); - deliveries.set(work, id); - void work.finally(() => deliveries.delete(work)); - return work; - } - async function deliverAttempt(id: string) { + async function deliver(id: string) { const attempt = attempts.get(id); if (closed || !attempt || attempt.controller || inflight() >= 3) return; - if (!find(id) || (find(id)?.managed && !managedOwner)) return; + if (!find(id)) return; if (Date.now() >= attempt.expiresAt) { expire(id); return; @@ -794,16 +515,7 @@ export function createOutbox( throw new Error("Signer changed the outgoing event"); const current = find(id); if (!current) return; // A verified echo already completed delivery. - const signedOperation = { ...current, signed }; - if ( - (pendingSize( - snapshot.map((item) => - item.event.id === id ? signedOperation : item, - ), - )[current.event.kind === 45010 ? 1 : 0] ?? 0) > MAX_PENDING_BYTES - ) - throw new Error("Outbox storage is full"); - replace(signedOperation); + replace({ ...current, signed }); await Promise.race([persist(id), aborted]); if (closed || signal.aborted || !find(id)) return; const hold = holdPublish?.(signed); @@ -840,18 +552,6 @@ export function createOutbox( if (latest) saveStatus({ ...latest, - ...(latest.managed - ? { - managed: { - ...latest.managed, - relayAccepted: true, - receiptDeadline: - latest.managed.receiptDeadline || - Date.now() + MANAGED_RECEIPT_MS, - error: undefined, - }, - } - : {}), delivery: latest.delivery === "seen" || attempt.previousDelivery === "seen" ? "seen" @@ -881,7 +581,7 @@ export function createOutbox( ...latest, delivery, refused: - (delivery === "failed" || !!latest.managed) && + delivery === "failed" && error instanceof PublishRejected && !(error instanceof PublishNotSent) ? true @@ -919,8 +619,7 @@ export function createOutbox( observed && ((observed.event.kind === 9000 && observed.acknowledged) || observed.delivery === "seen") && - !observed.recovery && - (!observed.managed || managedDone(observed.managed)) + !observed.recovery ) { completed.set(id, observed); snapshot = Object.freeze( @@ -930,7 +629,6 @@ export function createOutbox( void persist(id).catch(() => {}); } pumpDeliveries(); - wakeManaged(); } } const subscribe = (set: Set<() => void>, listener: () => void) => { @@ -974,7 +672,7 @@ export function createOutbox( ) ) throw new Error("Recover the earlier message before sending another"); - if (!find(id) && pendingCount(snapshot, item.event.kind) >= MAX_PENDING) + if (!find(id) && snapshot.length >= MAX_PENDING) throw new Error( "Too many outstanding operations; resolve a pending operation", ); @@ -992,10 +690,6 @@ export function createOutbox( async acknowledge(id: string) { await outbox.ready(); const item = visible.find((item) => item.event.id === id); - if (item?.managed && !managedDone(item.managed) && !item.refused) - return Promise.reject( - new Error("Resolve managed delivery before removing this message"), - ); if (!item?.recovery) return; // Member callers verify a fresh signed roster even when the receipt was lost. if ( @@ -1018,30 +712,12 @@ export function createOutbox( }, recovery?: OutboxRecovery, active?: () => boolean, - managed?: Readonly<{ - recipient?: ManagedRecipient; - operationKey?: string; - collection?: string; - }>, ) { if (active && !active()) throw new DOMException("Channel addition cancelled", "AbortError"); if (closed) throw abortError(); if (storageError) throw new Error(storageError); const savedRecovery = recoveryValue(recovery); - if (managed && savedRecovery) - throw new Error( - "Managed delivery cannot replace another recovery intent", - ); - if ( - managed?.recipient && - (input.kind !== 9 || - !input.tags.some( - ([name, value]) => - name === "h" && value === managed.recipient?.channelId, - )) - ) - throw new Error("Managed recipient does not match the message"); if ( savedRecovery && (!hydrated || @@ -1062,13 +738,10 @@ export function createOutbox( (catalog ? bytes(input.content) > MAX_CONTENT_BYTES || byteSize(input) > MAX_EVENT_BYTES - : byteSize(input) > - (input.kind === 45010 - ? ARTIFACT_MAX_EVENT_BYTES - 512 - : OUTBOX_INPUT_MAX_BYTES)) + : byteSize(input) > OUTBOX_INPUT_MAX_BYTES) ) throw new Error("Message is empty or too large"); - if (pendingCount(snapshot, input.kind) >= MAX_PENDING) + if (snapshot.length >= MAX_PENDING) throw new Error( "Too many outstanding operations; resolve or dismiss a pending operation", ); @@ -1127,12 +800,8 @@ export function createOutbox( created_at: createdAt, tags: [ ...input.tags.map((tag) => [...tag]), - ...(input.kind === 45010 - ? [] - : [ - ["client-id", crypto.randomUUID()], - ...(channelId ? [["ms", String(ms % 1000)]] : []), - ]), + ["client-id", crypto.randomUUID()], + ...(channelId ? [["ms", String(ms % 1000)]] : []), ], }; const event = Object.freeze({ @@ -1142,67 +811,18 @@ export function createOutbox( ) as unknown as string[][], id: getEventHash(template), }); - if (input.kind === 45010) { - const prior = visible.find((item) => item.event.id === event.id); - if (prior) { - if ( - prior.managed?.collection !== managed?.collection || - prior.managed?.operationKey !== managed?.operationKey || - !!prior.managed !== !!managed || - JSON.stringify(prior.managed?.recipient) !== - JSON.stringify(managed?.recipient) - ) - throw new Error("Artifact delivery identity changed"); - return event.id; - } - } - const operation = Object.freeze({ - event, - delivery: "sending" as const, - ...(savedRecovery ? { recovery: savedRecovery } : {}), - ...(active ? { guarded: true } : {}), - ...(managed - ? { - managed: managedDelivery({ - version: 1, - ...managed, - ...(input.kind === 45010 - ? { - artifactConfirmed: false, - submittedAt: Math.max( - Date.now(), - ...visible.map( - (item) => (item.managed?.submittedAt ?? 0) + 1, - ), - ), - } - : {}), - round: 0, - attempts: 0, - relayAccepted: false, - agentReceived: false, - nextAttemptAt: 0, - receiptDeadline: 0, - }), - } - : {}), - }); - const signedBudget = byteSize({ - ...operation, - signed: { ...event, sig: "0".repeat(128) }, - }); - if ( - (pendingSize(snapshot)[input.kind === 45010 ? 1 : 0] ?? 0) + - signedBudget + - 1 > - MAX_PENDING_BYTES - ) - throw new Error("Outbox storage is full"); - if (managed) unsavedManaged.add(event.id); if (active) admissionGates.set(event.id, active); captureSend(event); profiling.measure("send.local", event.id, () => { - snapshot = Object.freeze([...snapshot, operation]); + snapshot = Object.freeze([ + ...snapshot, + Object.freeze({ + event, + delivery: "sending" as const, + ...(savedRecovery ? { recovery: savedRecovery } : {}), + ...(active ? { guarded: true } : {}), + }), + ]); notify(); }); const intent = ready.then(() => { @@ -1211,30 +831,10 @@ export function createOutbox( }); void intent.catch(() => {}); // Delivery reports persistence errors; disposal still saves queued intent. schedule(event.id, intent); - wakeManaged(); return event.id; }, retry(id: string, active?: () => boolean) { const item = find(id); - if (item?.managed) { - replace({ - ...item, - refused: undefined, - error: undefined, - managed: { - ...item.managed, - round: item.managed.round + 1, - attempts: 0, - nextAttemptAt: 0, - receiptDeadline: Date.now() + MANAGED_RECEIPT_MS, - error: undefined, - }, - }); - void persist(id) - .then(() => wakeManaged()) - .catch(() => {}); - return; - } if (item?.guarded && (active ?? admissionGates.get(id))?.() !== true) throw new DOMException("Channel addition cancelled", "AbortError"); // Workflow recovery is inspect/dismiss only, including restored intents. @@ -1273,10 +873,6 @@ export function createOutbox( if (pending) return pending; if (closed || attempts.has(id)) return Promise.resolve(); const item = visible.find((item) => item.event.id === id); - if (item?.managed && !managedDone(item.managed) && !item.refused) - return Promise.reject( - new Error("Resolve managed delivery before removing this message"), - ); if (item?.recovery && item.delivery !== "failed") return Promise.reject( new Error( @@ -1294,11 +890,6 @@ export function createOutbox( return work; }, }); - const stopStorage = storage.subscribe?.(() => { - void refreshManaged() - .then(() => wakeManaged()) - .catch(() => {}); - }); return { outbox, local: Object.freeze({ @@ -1306,236 +897,6 @@ export function createOutbox( subscribe: (listener: () => void) => subscribe(localListeners, listener), }), ready, - delivery: { - async get(id: string) { - await refreshManaged(); - const item = visible.find((item) => item.event.id === id); - return item?.managed ? deliveryState(id, item.managed) : undefined; - }, - subscribe: (listener: () => void) => subscribe(localListeners, listener), - retry: async (id: string) => { - await refreshManaged(); - outbox.retry(id); - await persist(id); - }, - async lookup(operationKey: string) { - await refreshManaged(); - const item = visible.find( - (item) => item.managed?.operationKey === operationKey, - ); - return item?.managed - ? deliveryState(item.event.id, item.managed) - : undefined; - }, - async artifact(id: string) { - await refreshManaged(); - return visible - .filter( - (item) => - item.managed && - item.event.kind === 45010 && - item.event.tags.some( - ([name, value]) => name === "d" && value === id, - ), - ) - .sort( - (a, b) => - (b.managed?.submittedAt ?? 0) - (a.managed?.submittedAt ?? 0), - ) - .map((item) => ({ - template: { - kind: item.event.kind, - content: item.event.content, - tags: item.event.tags.map((tag) => [...tag]), - }, - state: { - ...deliveryState(item.event.id, managedDelivery(item.managed)), - ...(item.error && !item.managed?.relayAccepted - ? { error: item.error } - : {}), - ...(item.managed?.error === "artifact-save" - ? { - phase: "failed" as const, - error: "Could not confirm saved data", - } - : {}), - }, - confirmed: item.managed?.artifactConfirmed === true, - })); - }, - async submit( - input: Parameters[0], - recipient?: ManagedRecipient, - options?: { - operationKey?: string; - collection?: { owner: string; name: string }; - }, - ) { - const submit = async () => { - await outbox.ready(); - await refreshManaged(); - if (options?.operationKey !== undefined) { - managedDelivery({ - version: 1, - round: 0, - attempts: 0, - relayAccepted: false, - agentReceived: false, - nextAttemptAt: 0, - receiptDeadline: 0, - operationKey: options.operationKey, - }); - const prior = visible.find( - (item) => item.managed?.operationKey === options.operationKey, - ); - if (prior) { - if ( - prior.event.kind !== input.kind || - prior.event.content !== input.content || - JSON.stringify(prior.event.tags) !== - JSON.stringify(input.tags) || - prior.managed?.collection !== - (options.collection - ? JSON.stringify([ - options.collection.owner, - options.collection.name, - ]) - : undefined) - ) - throw new Error("Operation key has different artifact content"); - await persist(prior.event.id); - return { operationId: prior.event.id, eventId: prior.event.id }; - } - } - const id = outbox.send(input, undefined, undefined, { - ...(recipient ? { recipient } : {}), - ...(options?.operationKey - ? { operationKey: options.operationKey } - : {}), - ...(options?.collection - ? { - collection: JSON.stringify([ - options.collection.owner, - options.collection.name, - ]), - } - : {}), - }); - await persist(id); - return { operationId: id, eventId: id }; - }; - if (input.kind === 45010 && deliveryScope) { - if (!globalThis.navigator?.locks) - throw new Error("Artifact delivery requires Web Locks"); - return navigator.locks.request( - `buzz-artifact-submit:${deliveryScope}`, - submit, - ); - } - if (options && input.kind !== 45010) - throw new Error("Operation keys require an artifact"); - return submit(); - }, - }, - artifactDelivery: { - async capture(type: string, collection: string) { - await refreshManaged(); - return visible - .filter( - (item) => - item.event.kind === 45010 && - item.managed?.relayAccepted && - item.managed.artifactConfirmed !== true && - (!item.managed.collection || - item.managed.collection === collection) && - item.event.tags.some( - ([name, value]) => name === "type" && value === type, - ), - ) - .map((item) => item.event.id); - }, - targets(ids: readonly string[]) { - return ids.map((id) => { - const item = find(id); - const artifactId = item?.event.tags.find( - ([name]) => name === "d", - )?.[1]; - if (!artifactId) - throw new Error("Artifact intent has no artifact ID"); - return { operationId: id, artifactId }; - }); - }, - async complete(ids: readonly string[], signal: AbortSignal) { - for (const id of ids) { - signal.throwIfAborted(); - try { - await persist(id, "artifact"); - } catch (error) { - const item = find(id); - if (item?.managed) - replace({ - ...item, - managed: { ...item.managed, error: "artifact-save" }, - }); - throw error; - } - } - }, - }, - async managedOwner(owned: boolean) { - managedOwner = owned; - clearTimeout(managedTimer); - if (owned) { - await refreshManaged(); - wakeManaged(); - } else - for (const [id, attempt] of attempts) - if (find(id)?.managed) { - clearTimeout(attempt.timer); - attempts.delete(id); - attempt.controller?.abort(abortError()); - } - if (!owned) { - await Promise.allSettled( - [...deliveries] - .filter(([, id]) => !!find(id)?.managed) - .map(([work]) => work), - ); - await durable.catch(() => {}); - } - }, - async receiveManaged( - channelId: string, - agentId: string, - messageIds: readonly string[], - ) { - if ( - messageIds.length > 256 || - messageIds.some((id) => !/^[0-9a-f]{64}$/.test(id)) - ) - throw new Error("Invalid message receipt IDs"); - await refreshManaged(); - for (const id of new Set(messageIds)) { - const item = find(id); - if ( - !item?.managed || - item.managed.recipient?.channelId !== channelId || - item.managed.recipient.agentId !== agentId || - item.managed.agentReceived - ) - continue; - try { - await persist(id, "receipt"); - } catch (error) { - replace({ - ...item, - managed: { ...item.managed, error: "receipt-save" }, - }); - throw error; - } - } - wakeManaged(); - }, /** Fetched/confirmed evidence is disposable; pending intent is not. A revoke * during async hydration fences that old confirmed cache, not its writes. */ purgeConfirmed(keep: (event: EventData) => boolean) { @@ -1563,8 +924,7 @@ export function createOutbox( delivered(event); if ( (awaitsReceipt(event) && attempts.has(event.id)) || - find(event.id)?.recovery || - find(event.id)?.managed + find(event.id)?.recovery ) { snapshot = Object.freeze( snapshot.map((item) => @@ -1573,17 +933,6 @@ export function createOutbox( ...item, signed: event, delivery: "seen" as const, - ...(item.managed - ? { - managed: { - ...item.managed, - relayAccepted: true, - receiptDeadline: - item.managed.receiptDeadline || - Date.now() + MANAGED_RECEIPT_MS, - }, - } - : {}), }) : item, ), @@ -1599,20 +948,12 @@ export function createOutbox( }), ); } - for (const item of snapshot) - if ( - byId.has(item.event.id) && - item.managed && - managedDone(item.managed) - ) - completed.set(item.event.id, item); snapshot = Object.freeze( snapshot.filter( (item) => !byId.has(item.event.id) || (awaitsReceipt(item.event) && attempts.has(item.event.id)) || - !!item.recovery || - (!!item.managed && !managedDone(item.managed)), + !!item.recovery, ), ); for (const event of confirmed) { @@ -1624,15 +965,12 @@ export function createOutbox( } notify(); void persist(first.id).catch(() => {}); - wakeManaged(); }, dispose() { if (closed) return; finalSnapshot = snapshot; finalVisible = visible; closed = true; - stopStorage?.(); - clearTimeout(managedTimer); admissionGates.clear(); lifetime.abort(); for (const attempt of attempts.values()) { diff --git a/src/features/relay/row-identity.ts b/src/features/relay/row-identity.ts index 586b126fbe..da5bd3f8b6 100644 --- a/src/features/relay/row-identity.ts +++ b/src/features/relay/row-identity.ts @@ -20,14 +20,6 @@ function sameRow(left: ChannelMessage, right: ChannelMessage) { left.channelId === right.channelId && left.delivery === right.delivery && left.deliveryError === right.deliveryError && - left.managedDelivery?.operationId === right.managedDelivery?.operationId && - left.managedDelivery?.eventId === right.managedDelivery?.eventId && - left.managedDelivery?.phase === right.managedDelivery?.phase && - left.managedDelivery?.relayAccepted === - right.managedDelivery?.relayAccepted && - left.managedDelivery?.agentReceived === - right.managedDelivery?.agentReceived && - left.managedDelivery?.error === right.managedDelivery?.error && left.authorId === right.authorId && left.signerId === right.signerId && left.workflowOwnerId === right.workflowOwnerId && diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 5e5fd10ec1..5628954704 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -1,6 +1,4 @@ -import { registerArtifactDelivery } from "./artifact-delivery"; import type { LocalAgentSource } from "../agents/local-activity"; -import { registerManagedDelivery } from "./managed-delivery"; // FOUNDATION: One relay session owns reads, local intent, delivery and shared views. import { createSoloPreferences, soloGroups } from "./solo-preferences"; import { npubEncode } from "nostr-tools/nip19"; @@ -99,7 +97,6 @@ import { browserOutboxStorage, createOutbox, type OutboxStorage, - type Outbox, } from "./outbox"; import { relayPartition, transportPartition } from "./partition"; import { browserThreadFollows } from "./thread-follows"; @@ -187,7 +184,6 @@ export function createRelaySession( const threads = new Set>(); const writer = transport?.writer; const clock = new MessageClock(); - const artifactUpdates = new Set<(events: readonly RelayEvent[]) => void>(); const uploadAttachment = transport?.uploadAttachment; const writes = transport && writer @@ -213,7 +209,6 @@ export function createRelaySession( ? { timeoutMs: options.deliveryTimeoutMs } : {}), profiling, - deliveryScope: transportPartition(transport), clock, notifyListener: notify, onAccepted: (event) => { @@ -235,19 +230,6 @@ export function createRelaySession( }, preparePublish: async (event, signal) => { workflows.validate(event); - const managed = rawLocal().find( - (item) => item.event.id === event.id, - )?.managed; - if (managed) { - const id = event.tags.find(([name]) => name === "h")?.[1]; - if (!id) throw new Error("Managed delivery has no channel"); - await channelWrites.refreshMembership(id); - signal.throwIfAborted(); - if (!channels.canParticipate(id)) - throw new Error("Channel access changed"); - if (managed.recipient) - validateMentions(id, [managed.recipient.agentId]); - } if (event.kind === 40100) { const id = event.tags.find((t) => t[0] === "h")?.[1]; if (!id) throw new Error("Canvas has no channel"); @@ -269,38 +251,8 @@ export function createRelaySession( ) : undefined; const rawLocal = () => writes?.local.snapshot() ?? []; - const confirmedArtifactIds = new Set(); - const confirmedArtifacts = () => - rawLocal().flatMap((item) => - item.event.kind === 45010 && - item.signed && - (item.managed?.relayAccepted || - item.delivery === "accepted" || - item.delivery === "seen") - ? [item.signed] - : [], - ); - const stopArtifactJournal = writes?.local.subscribe(() => { - const events = confirmedArtifacts().filter( - (event) => !confirmedArtifactIds.has(event.id), - ); - if (!events.length) return; - for (const event of events) confirmedArtifactIds.add(event.id); - while (confirmedArtifactIds.size > 2048) - confirmedArtifactIds.delete( - confirmedArtifactIds.values().next().value as string, - ); - for (const listener of artifactUpdates) notify(() => listener(events)); - }); let retainedChannelEvent: (id: string) => RelayEvent | undefined = () => undefined; - const stopManaged = - transport && writes - ? registerManagedDelivery(transportPartition(transport), { - owner: writes.managedOwner, - receive: writes.receiveManaged, - }) - : undefined; function retainedThreadEvent(id: string) { for (const thread of threads) { if (!canAccess(thread.channelId)) continue; @@ -1812,85 +1764,8 @@ export function createRelaySession( if (connected && !liveWasConnected) recovery?.retryNow(liveRecovery); liveWasConnected = connected; }); - function messageCommands(managedRecipient?: string) { - return createMessages( - writes?.outbox, - transport?.viewer, - (id) => - local().find((item) => item.event.id === id)?.event ?? - recent.peek(id)?.event ?? - retainedEvent(id), - emoji.tags, - validateMentions, - (id) => !closed && channels.canParticipate(id), - transport?.scope, - writer && (!writer.kinds || writer.kinds.includes(1984)) - ? async (template) => { - if (closed) throw new Error("Relay session closed"); - const signal = AbortSignal.any([ - lifetime.signal, - AbortSignal.timeout(10_000), - ]); - // Race each step so the deadline holds even if the writer ignores `signal`. - const aborted = new Promise((_, reject) => { - const fail = () => reject(signal.reason); - if (signal.aborted) fail(); - else signal.addEventListener("abort", fail, { once: true }); - }); - const signed = await Promise.race([ - writer.sign(template, signal), - aborted, - ]); - signal.throwIfAborted(); - await Promise.race([writer.publish(signed, signal), aborted]); - } - : undefined, - transport?.archiveAuthority, - managedRecipient, - ); - } const session = Object.freeze({ artifactSync: transport?.artifactSync, - subscribeArtifactUpdates( - listener: (events: readonly RelayEvent[]) => void, - ) { - artifactUpdates.add(listener); - const events = confirmedArtifacts(); - if (events.length) notify(() => listener(events)); - return () => { - artifactUpdates.delete(listener); - }; - }, - delivery: writes - ? { - ...writes.delivery, - async submit( - input: Parameters[0], - options?: { - operationKey?: string; - collection?: { owner: string; name: string }; - }, - ) { - if (input.kind !== 45010) - throw new Error("Use message commands for managed messages"); - if (!transport?.artifactSync) - throw new Error("Artifact saving requires relay sync support"); - const homes = input.tags.filter(([name]) => name === "h"); - const id = homes[0]?.[1]; - if ( - homes.length !== 1 || - !id || - closed || - !channels.canParticipate(id) || - (transport?.subscribe && liveSnapshot.status !== "connected") - ) - throw new Error( - "Reconnect and confirm channel access before saving an artifact", - ); - return writes.delivery.submit(input, undefined, options); - }, - } - : undefined, memberAdditions, presence, viewer: transport?.viewer, @@ -2128,8 +2003,40 @@ export function createRelaySession( }, }) : undefined, - messages: messageCommands(), - messagesForRecipient: messageCommands, + messages: createMessages( + writes?.outbox, + transport?.viewer, + (id) => + local().find((item) => item.event.id === id)?.event ?? + recent.peek(id)?.event ?? + retainedEvent(id), + emoji.tags, + validateMentions, + (id) => !closed && channels.canParticipate(id), + transport?.scope, + writer && (!writer.kinds || writer.kinds.includes(1984)) + ? async (template) => { + if (closed) throw new Error("Relay session closed"); + const signal = AbortSignal.any([ + lifetime.signal, + AbortSignal.timeout(10_000), + ]); + // Race each step so the deadline holds even if the writer ignores `signal`. + const aborted = new Promise((_, reject) => { + const fail = () => reject(signal.reason); + if (signal.aborted) fail(); + else signal.addEventListener("abort", fail, { once: true }); + }); + const signed = await Promise.race([ + writer.sign(template, signal), + aborted, + ]); + signal.throwIfAborted(); + await Promise.race([writer.publish(signed, signal), aborted]); + } + : undefined, + transport?.archiveAuthority, + ), /** An owned bounded thread reader. Dispose on close; the session retains access/lifetime authority. */ thread( channelId: string, @@ -3007,7 +2914,6 @@ export function createRelaySession( updateInterests(); refreshChannelActivity(); - if (writes) registerArtifactDelivery(session, writes.artifactDelivery); return { session, async restore() { @@ -3057,9 +2963,6 @@ export function createRelaySession( } }, dispose() { - stopManaged?.(); - stopArtifactJournal?.(); - artifactUpdates.clear(); stopSoloGroups(); closed = true; stopLiveRecovery(); diff --git a/src/features/relay/thread-delivery.test.ts b/src/features/relay/thread-delivery.test.ts deleted file mode 100644 index 9dc5ea5c83..0000000000 --- a/src/features/relay/thread-delivery.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { assert, expect, it } from "vitest"; -import { createThreadView } from "./threads"; -import type { OutgoingEvent } from "./outbox"; -import { keypair, message } from "./testing"; - -it("projects exact receipt changes into an existing thread row", async () => { - const key = keypair(); - const root = message(key, "channel", "Managed prompt", 1); - let operations: readonly OutgoingEvent[] = [ - { - event: root, - signed: root, - delivery: "accepted", - managed: { - version: 1, - recipient: { channelId: "channel", agentId: key.pubkey }, - round: 0, - attempts: 1, - relayAccepted: true, - agentReceived: false, - nextAttemptAt: 0, - receiptDeadline: 30000, - }, - }, - ]; - const listeners = new Set<() => void>(); - const owned = createThreadView({ - channelId: "channel", - messageId: root.id, - relayAuthor: key.pubkey, - seed: root, - reader: { read: async () => [root] }, - local: { - snapshot: () => operations, - subscribe: (listener) => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - }, - canAccess: () => true, - visible: (events) => events, - notify: (listener) => listener(), - }); - const view = owned.view; - try { - await view.refresh(); - const before = view.snapshot().root; - expect(before?.managedDelivery).toMatchObject({ - phase: "sending", - relayAccepted: true, - agentReceived: false, - }); - owned.changed(); - expect(view.snapshot().root).toBe(before); - operations = operations.map((item) => { - assert.exists(item.managed); - return { - ...item, - managed: { ...item.managed, error: "receipt" as const }, - }; - }); - owned.changed(); - expect(view.snapshot().root?.managedDelivery).toMatchObject({ - phase: "failed", - error: "Could not confirm delivery", - }); - operations = operations.map((item) => { - assert.exists(item.managed); - return { ...item, managed: { ...item.managed, agentReceived: true } }; - }); - owned.changed(); - expect(view.snapshot().root).not.toBe(before); - expect(view.snapshot().root?.managedDelivery).toMatchObject({ - phase: "received", - agentReceived: true, - }); - } finally { - view.dispose(); - } -}); diff --git a/src/features/relay/threads.ts b/src/features/relay/threads.ts index 889be90a50..f4e3fb6ef6 100644 --- a/src/features/relay/threads.ts +++ b/src/features/relay/threads.ts @@ -14,7 +14,6 @@ import { byteSize } from "./budget"; import { foldMessages } from "./fold"; import { compareMessages, MessageClock } from "./message-order"; import { shareMessageRows } from "./row-identity"; -import { deliveryState } from "./managed-delivery"; const AUX = new Set([5, 7, 9005, 40003, 39005, 39006]); const PAGE_SIZE = 50; @@ -224,9 +223,6 @@ export function createThreadView({ ...row, delivery: item.delivery, deliveryError: item.error, - managedDelivery: item.managed - ? deliveryState(row.id, item.managed) - : undefined, }) : row; }), diff --git a/tests/browser/artifact-sync.spec.mjs b/tests/browser/artifact-sync.spec.mjs new file mode 100644 index 0000000000..dc50831ba0 --- /dev/null +++ b/tests/browser/artifact-sync.spec.mjs @@ -0,0 +1,87 @@ +import { fileURLToPath } from "node:url"; +import { test as base, expect } from "./playwright-test.mjs"; +import { createServer } from "./vite-server.mjs"; + +const test = base.extend({ + syncUrl: [ + async ({ browserName }, use) => { + if (!["chromium", "webkit"].includes(browserName)) + throw new Error("Unsupported sync test engine"); + const server = await createServer({ + root: fileURLToPath(new URL("../../", import.meta.url)), + configFile: false, + envDir: false, + logLevel: "error", + server: { host: "127.0.0.1", port: 0 }, + }); + await server.listen(); + try { + await use( + `${server.resolvedUrls.local[0]}tests/fixtures/artifact-sync.html`, + ); + } finally { + await server.close(); + } + }, + { scope: "worker" }, + ], +}); +async function open(page, url) { + await page.goto(url); + await page.waitForFunction(() => !!window.artifactSyncTest); + await page.evaluate(() => window.artifactSyncTest.open("collection-owner")); +} + +// Real Web Locks and window teardown establish ownership. IndexedDB keeps shared progress. +test("syncs a follower-only collection and transfers a shared collection on window close", async ({ + page, + context, + syncUrl, +}) => { + await open(page, syncUrl); + await page.evaluate(() => window.artifactSyncTest.openCollection("main")); + await expect + .poll(() => page.evaluate(() => window.artifactSyncTest.checkpoint("main"))) + .not.toBeNull(); + const follower = await context.newPage(); + await open(follower, syncUrl); + await follower.evaluate(() => + window.artifactSyncTest.openCollection("archive"), + ); + await expect + .poll(() => + follower.evaluate(() => window.artifactSyncTest.checkpoint("archive")), + ) + .not.toBeNull(); + await follower.evaluate(() => window.artifactSyncTest.openCollection("main")); + await expect + .poll(() => + follower.evaluate(async () => { + const locks = await navigator.locks.query(); + return { held: locks.held.length, pending: locks.pending.length }; + }), + ) + .toEqual({ held: 2, pending: 1 }); + expect(await page.evaluate(() => window.artifactSyncTest.captures)).toEqual([ + "main", + ]); + expect( + await follower.evaluate(() => window.artifactSyncTest.captures), + ).toEqual(["archive"]); + const before = await follower.evaluate(() => + window.artifactSyncTest.checkpoint("main"), + ); + await page.close(); + await expect + .poll(() => follower.evaluate(() => window.artifactSyncTest.captures)) + .toEqual(["archive", "main"]); + await expect + .poll(() => + follower.evaluate(() => window.artifactSyncTest.checkpoint("main")), + ) + .not.toBe(before); + expect( + (await follower.evaluate(() => window.artifactSyncTest.read("main"))) + .preference, + ).toEqual({ pinned: false }); +}); diff --git a/tests/browser/managed-delivery.spec.mjs b/tests/browser/managed-delivery.spec.mjs deleted file mode 100644 index e755724f0c..0000000000 --- a/tests/browser/managed-delivery.spec.mjs +++ /dev/null @@ -1,218 +0,0 @@ -import { fileURLToPath } from "node:url"; -import { test as base, expect } from "./playwright-test.mjs"; -import { createServer } from "./vite-server.mjs"; - -const test = base.extend({ - deliveryUrl: [ - async ({ browserName }, use) => { - if (!["chromium", "webkit"].includes(browserName)) - throw new Error("Unsupported storage test engine"); - const server = await createServer({ - root: fileURLToPath(new URL("../../", import.meta.url)), - configFile: false, - envDir: false, - logLevel: "error", - server: { host: "127.0.0.1", port: 0 }, - }); - await server.listen(); - try { - await use( - `${server.resolvedUrls.local[0]}tests/fixtures/managed-delivery.html`, - ); - } finally { - await server.close(); - } - }, - { scope: "worker" }, - ], -}); -async function open(page, url, scope) { - await page.goto(url); - await page.waitForFunction(() => !!window.deliveryTest); - await page.evaluate((scope) => window.deliveryTest.open(scope), scope); -} - -// These checks use real IndexedDB, BroadcastChannel and Web Locks across windows. -test("transfers ownership on window close and never republishes accepted work", async ({ - page, - context, - deliveryUrl, -}) => { - const scope = "owner-transfer"; - await open(page, deliveryUrl, scope); - const second = await context.newPage(); - await open(second, deliveryUrl, scope); - await page.evaluate((scope) => window.deliveryTest.own(scope), scope); - const operation = await second.evaluate(() => window.deliveryTest.submit()); - await expect - .poll(() => page.evaluate(() => window.deliveryTest.publications.length)) - .toBe(1); - await expect - .poll(() => - second.evaluate((id) => window.deliveryTest.get(id), operation.eventId), - ) - .toMatchObject({ - phase: "sending", - relayAccepted: true, - agentReceived: false, - }); - const transfer = second.evaluate( - (scope) => window.deliveryTest.own(scope), - scope, - ); - await page.close(); - await transfer; - await second.evaluate( - (id) => window.deliveryTest.retry(id), - operation.eventId, - ); - await second.evaluate( - (id) => window.deliveryTest.receive(id), - operation.eventId, - ); - expect( - await second.evaluate( - (id) => window.deliveryTest.get(id), - operation.eventId, - ), - ).toMatchObject({ phase: "received" }); - expect(await second.evaluate(() => window.deliveryTest.publications)).toEqual( - [], - ); - await second.reload(); - await second.waitForFunction(() => !!window.deliveryTest); - await second.evaluate((scope) => window.deliveryTest.open(scope), scope); - expect( - await second.evaluate( - (id) => window.deliveryTest.get(id), - operation.eventId, - ), - ).toMatchObject({ relayAccepted: true, agentReceived: true }); -}); - -test("keeps accepted and received facts through a stale window save", async ({ - page, - context, - deliveryUrl, -}) => { - const scope = "stale-save"; - await open(page, deliveryUrl, scope); - const second = await context.newPage(); - await open(second, deliveryUrl, scope); - const operation = await page.evaluate(() => window.deliveryTest.submit()); - await second.evaluate(async (scope) => { - window.staleStorage = window.deliveryTest.storage(scope); - window.staleRecords = await window.staleStorage.load(); - }, scope); - await page.evaluate((scope) => window.deliveryTest.own(scope), scope); - await expect - .poll(() => - page.evaluate((id) => window.deliveryTest.get(id), operation.eventId), - ) - .toMatchObject({ relayAccepted: true }); - await page.evaluate( - (id) => window.deliveryTest.receive(id), - operation.eventId, - ); - await second.evaluate(async () => { - await window.staleStorage.save( - window.staleRecords.map((item) => ({ - ...item, - error: "stale", - managed: { ...item.managed, attempts: 1 }, - })), - ); - }); - expect( - await page.evaluate((id) => window.deliveryTest.get(id), operation.eventId), - ).toMatchObject({ - relayAccepted: true, - agentReceived: true, - phase: "received", - }); - await page.evaluate(() => window.deliveryTest.release()); -}); - -// This uses the production sync owner with real Web Locks. The relay reader is deterministic. -test("syncs a follower-only collection and transfers a shared collection on window close", async ({ - page, - context, - deliveryUrl, -}) => { - const scope = "collection-owner"; - await open(page, deliveryUrl, scope); - const second = await context.newPage(); - await open(second, deliveryUrl, scope); - await page.evaluate( - (scope) => window.deliveryTest.openCollection(scope, "main"), - scope, - ); - await expect - .poll(() => page.evaluate(() => window.deliveryTest.captures)) - .toEqual(["main"]); - await second.evaluate( - (scope) => window.deliveryTest.openCollection(scope, "archive"), - scope, - ); - await expect - .poll(() => second.evaluate(() => window.deliveryTest.captures)) - .toEqual(["archive"]); - await second.evaluate( - (scope) => window.deliveryTest.openCollection(scope, "main"), - scope, - ); - await page.close(); - await expect - .poll(() => second.evaluate(() => window.deliveryTest.captures)) - .toEqual(["archive", "main"]); - expect( - (await second.evaluate(() => window.deliveryTest.readCollection())) - .preference, - ).toEqual({ pinned: false }); -}); - -test("serializes caller keys across windows and saves capture confirmation", async ({ - page, - context, - deliveryUrl, -}) => { - const scope = "artifact-operation-key"; - await open(page, deliveryUrl, scope); - const second = await context.newPage(); - await open(second, deliveryUrl, scope); - const [first, duplicate] = await Promise.all([ - page.evaluate(() => window.deliveryTest.submitArtifact()), - second.evaluate(() => window.deliveryTest.submitArtifact()), - ]); - expect(duplicate).toEqual(first); - await expect - .poll(() => page.evaluate(() => window.deliveryTest.artifact())) - .toMatchObject([{ confirmed: false, state: { relayAccepted: true } }]); - const captured = await page.evaluate(() => - window.deliveryTest.captureArtifact(), - ); - expect(captured).toEqual([first.eventId]); - await second.evaluate(async (scope) => { - window.staleStorage = window.deliveryTest.storage(scope); - window.staleRecords = await window.staleStorage.load(); - }, scope); - await page.evaluate( - (ids) => window.deliveryTest.completeArtifact(ids), - captured, - ); - await second.evaluate(() => window.staleStorage.save(window.staleRecords)); - expect( - await second.evaluate(() => window.deliveryTest.artifact()), - ).toMatchObject([{ confirmed: true }]); - await second.reload(); - await second.waitForFunction(() => !!window.deliveryTest); - await second.evaluate((scope) => window.deliveryTest.open(scope), scope); - expect( - await second.evaluate(() => window.deliveryTest.artifact()), - ).toMatchObject([ - { confirmed: true, template: { content: '{"title":"Pending"}' } }, - ]); - expect(await page.evaluate(() => window.deliveryTest.publications)).toEqual([ - first.eventId, - ]); -}); diff --git a/tests/fixtures/artifact-sync.html b/tests/fixtures/artifact-sync.html new file mode 100644 index 0000000000..dbf0ed5792 --- /dev/null +++ b/tests/fixtures/artifact-sync.html @@ -0,0 +1 @@ +Artifact sync checks

Artifact sync checks

diff --git a/tests/fixtures/artifact-sync.ts b/tests/fixtures/artifact-sync.ts new file mode 100644 index 0000000000..82d40a8146 --- /dev/null +++ b/tests/fixtures/artifact-sync.ts @@ -0,0 +1,126 @@ +import type { Context } from "@deepseek-ai/cordis"; +import type { + ArtifactStorageService, + OpenArtifactCollection, +} from "../../src/features/artifact-storage/service"; +import { createArtifactStore } from "../../src/features/artifact-storage/store"; +import type { HostArtifactCollection } from "../../src/features/artifact-storage/types"; +import type { ArtifactSyncReader } from "../../src/features/relay/artifact-reader"; +import { provideArtifactSync } from "../../src/features/relay/artifact-sync"; + +const viewer = "a".repeat(64); +const captures: string[] = []; +const collections = new Map< + string, + { collection: HostArtifactCollection; controller: AbortController } +>(); +let store: ReturnType; +let relay: string; +let opened: (entry: OpenArtifactCollection) => void; +let stop = () => {}; +const api = { + captures, + open(scope: string) { + relay = `https://${scope}.test`; + store = createArtifactStore(viewer, relay); + const reader: ArtifactSyncReader = { + async delta(query) { + captures.push(query["#type"][0]?.slice("test.sync.".length) ?? ""); + return { + startedAt: "2026-10-21T12:00:00.000001Z", + overlapSeconds: 120, + changes: [], + nextCursor: null, + checkpoint: crypto.randomUUID(), + }; + }, + async current(_query, artifactIds) { + return { + startedAt: "2026-10-21T12:00:00.000002Z", + changes: artifactIds.map((artifactId) => ({ + type: "remove" as const, + artifactId, + })), + }; + }, + }; + const session = { + artifactSync: reader, + live: { + snapshot: () => ({ status: "connected" }), + subscribe: () => () => {}, + }, + subscribeLive: () => () => {}, + }; + const ctx = { + relay: { + snapshot: () => ({ + viewer, + origin: relay, + status: "ready", + cached: false, + session, + }), + subscribe: () => () => {}, + }, + effect: (effect: () => () => void) => { + stop = effect(); + }, + }; + provideArtifactSync( + ctx as unknown as Context, + { + onOpen: (listener: typeof opened) => { + opened = listener; + return () => {}; + }, + } as unknown as ArtifactStorageService, + ); + }, + async openCollection(name: string) { + const definition = { + name, + type: `test.sync.${name}`, + version: 1, + project: () => [], + }; + const collection = store.collection("test.plugin", definition); + const controller = new AbortController(); + collections.set(name, { collection, controller }); + await collection.importBatch({ + records: [{ id: "task", local: { title: "Saved" } }], + preferences: [{ id: "task", value: { pinned: false } }], + marker: "ready", + }); + opened({ + viewer, + relay, + owner: "test.plugin", + definition, + collection, + signal: controller.signal, + }); + }, + checkpoint(name: string) { + const entry = collections.get(name); + if (!entry) throw new Error("Collection is not open"); + return entry.collection.checkpoint(); + }, + read(name: string) { + const entry = collections.get(name); + if (!entry) throw new Error("Collection is not open"); + return entry.collection.get("task"); + }, + close() { + stop(); + for (const entry of collections.values()) entry.controller.abort(); + store?.close(); + }, +}; +declare global { + interface Window { + artifactSyncTest: typeof api; + } +} +window.artifactSyncTest = api; +window.addEventListener("pagehide", () => api.close()); diff --git a/tests/fixtures/managed-delivery.html b/tests/fixtures/managed-delivery.html deleted file mode 100644 index 75d7becacc..0000000000 --- a/tests/fixtures/managed-delivery.html +++ /dev/null @@ -1 +0,0 @@ -Delivery checks

Delivery checks

diff --git a/tests/fixtures/managed-delivery.ts b/tests/fixtures/managed-delivery.ts deleted file mode 100644 index dbe6084408..0000000000 --- a/tests/fixtures/managed-delivery.ts +++ /dev/null @@ -1,193 +0,0 @@ -import type { OpenArtifactCollection } from "../../src/features/artifact-storage/service"; -import { provideArtifactSync } from "../../src/features/relay/artifact-sync"; -import { createArtifactStore } from "../../src/features/artifact-storage/store"; -import { finalizeEvent, getPublicKey } from "nostr-tools"; -import { createOutbox } from "../../src/features/relay/outbox"; -import { browserOutboxStorage } from "../../src/features/relay/outbox-storage"; -import { - mergeManagedDelivery, - registerManagedDelivery, -} from "../../src/features/relay/managed-delivery"; - -const secret = new Uint8Array(32).fill(7); -const viewer = getPublicKey(secret); -const recipient = { channelId: "task", agentId: "a".repeat(64) }; -const publications: string[] = []; -let owner: ReturnType; -let stopRegistration: (() => void) | undefined; -let ownership: Promise; -let release: Promise; -const open = async (scope: string) => { - owner = createOutbox( - viewer, - { - sign: async (template) => - finalizeEvent(structuredClone(template), secret), - publish: async (event) => { - publications.push(event.id); - }, - }, - browserOutboxStorage(scope), - { deliveryScope: scope }, - ); - await owner.ready; - let acquired!: () => void; - let released!: () => void; - ownership = new Promise((resolve) => { - acquired = resolve; - }); - release = new Promise((resolve) => { - released = resolve; - }); - stopRegistration = registerManagedDelivery(scope, { - owner: async (owned) => { - await owner.managedOwner(owned); - if (owned) acquired(); - else released(); - }, - receive: owner.receiveManaged, - }); -}; -const own = async (_scope: string) => { - await ownership; -}; -const captures: string[] = []; -async function openCollection(scope: string, name: string) { - const relay = `https://${scope}.test`; - const store = createArtifactStore(viewer, relay); - const definition = { - name, - type: "test.delivery", - version: 1, - project: ({ record, preference }) => [ - { index: "pin", value: String(preference?.pinned), order: record.id }, - ], - }; - const collection = store.collection("test.plugin", definition); - await collection.importBatch({ - records: [{ id: "task", local: { title: "Saved" } }], - preferences: [{ id: "task", value: { pinned: false } }], - marker: "ready", - }); - const signal = new AbortController(); - let opened: (entry: OpenArtifactCollection) => void = () => {}; - const session = { - live: { - snapshot: () => ({ status: "connected" }), - subscribe: () => () => {}, - }, - channels: { list: () => ({ channels: [] }), subscribeList: () => () => {} }, - subscribeLive: () => () => {}, - subscribeArtifactUpdates: () => () => {}, - artifactSync: { - delta: async () => { - captures.push(name); - return { - startedAt: "2026-10-21T12:00:00.000001Z", - overlapSeconds: 120, - changes: [], - nextCursor: null, - checkpoint: crypto.randomUUID(), - }; - }, - current: async (_query, artifactIds) => ({ - startedAt: "2026-10-21T12:00:00.000002Z", - changes: artifactIds.map((artifactId) => ({ - type: "remove", - artifactId, - })), - }), - }, - }; - const effects = []; - const ctx = { - relay: { - snapshot: () => ({ - viewer, - origin: relay, - status: "ready", - cached: false, - session, - }), - subscribe: () => () => {}, - }, - effect: (effect) => effects.push(effect()), - }; - provideArtifactSync(ctx, { - onOpen: (listener) => { - opened = listener; - return () => {}; - }, - }); - opened({ - viewer, - relay, - owner: "test.plugin", - definition, - collection, - signal: signal.signal, - }); - return () => collection.get("task"); -} -let readCollection: Awaited>; -Object.assign(window, { - deliveryTest: { - captures, - openCollection: async (scope, name) => { - readCollection = await openCollection(scope, name); - }, - readCollection: () => readCollection(), - open, - own, - publications, - recipient, - mergeManagedDelivery, - storage: browserOutboxStorage, - submit: () => - owner.delivery.submit( - { kind: 9, content: "Task prompt", tags: [["h", "task"]] }, - recipient, - ), - debug: () => - owner.local.snapshot().map((item) => ({ - error: item.error, - delivery: item.delivery, - managed: item.managed, - })), - submitArtifact: () => - owner.delivery.submit( - { - kind: 45010, - content: '{"title":"Pending"}', - tags: [ - ["ar", "1"], - ["d", "task"], - ["h", "task"], - ["type", "test.delivery"], - ["op", "create"], - ], - }, - undefined, - { - operationKey: "setup/task", - collection: { owner: "test.plugin", name: "main" }, - }, - ), - artifact: () => owner.delivery.artifact("task"), - captureArtifact: () => - owner.artifactDelivery.capture( - "test.delivery", - JSON.stringify(["test.plugin", "main"]), - ), - completeArtifact: (ids: string[]) => - owner.artifactDelivery.complete(ids, new AbortController().signal), - get: (id: string) => owner.delivery.get(id), - receive: (id: string) => - owner.receiveManaged(recipient.channelId, recipient.agentId, [id]), - retry: (id: string) => owner.delivery.retry(id), - release: async () => { - stopRegistration?.(); - await release; - }, - }, -}); From 48e7d9ab9c11b8a754eb740eb1edd400efe4e4aa Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 16:52:58 -0700 Subject: [PATCH 17/30] Narrow artifact transaction scopes Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/local-artifact-storage.md | 5 +- src/features/artifact-storage/store.ts | 71 +++++++++------ tests/browser/artifact-storage.spec.mjs | 109 ++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 29 deletions(-) diff --git a/docs/local-artifact-storage.md b/docs/local-artifact-storage.md index 5dc437653d..13bec1824c 100644 --- a/docs/local-artifact-storage.md +++ b/docs/local-artifact-storage.md @@ -127,8 +127,9 @@ attempted value retained. Offline shared edits remain undecided. ## Storage costs -Writes request strict durability. Transactions span all four object stores, so -large batches can delay other collections' reads or writes. A changed row rewrites +Writes request strict durability. Transactions include only the object stores that each operation uses. Row writes +and indexed pages use all four stores. Metadata operations use only metadata. +Large row batches can still delay other collections' reads or writes. A changed row rewrites its indexes. Index upgrades scan the collection. Delta completion does not scan records. Ancestry comparison and separate source-membership writes are absent. The database upgrade removes the obsolete membership store, builds the host channel index, and preserves records and device preferences. Leaving a community purges only its saved partition. If database diff --git a/src/features/artifact-storage/store.ts b/src/features/artifact-storage/store.ts index 8eba820339..05428edcca 100644 --- a/src/features/artifact-storage/store.ts +++ b/src/features/artifact-storage/store.ts @@ -112,6 +112,7 @@ export function createArtifactStore(viewer: string, relay: string) { type Then = (request: IDBRequest, next: (value: T) => void) => void; async function transact( mode: IDBTransactionMode, + stores: string[], work: (tx: IDBTransaction, then: Then, done: (value: T) => void) => void, signal?: AbortSignal, ): Promise { @@ -120,7 +121,7 @@ export function createArtifactStore(viewer: string, relay: string) { signal?.throwIfAborted(); if (closed) throw new Error("Artifact storage closed"); return new Promise((resolve, reject) => { - const tx = db.transaction(STORES, mode, { durability: "strict" }); + const tx = db.transaction(stores, mode, { durability: "strict" }); let value: T; let failure: unknown; const stop = () => { @@ -235,6 +236,7 @@ export function createArtifactStore(viewer: string, relay: string) { } const ready = transact( "readwrite", + STORES, (tx, then, done) => { const metadata = tx.objectStore("metadata"); then(metadata.get(key("schema")), (schema) => { @@ -278,13 +280,15 @@ export function createArtifactStore(viewer: string, relay: string) { void ready.catch(() => {}); async function run( mode: IDBTransactionMode, - work: Parameters>[1], + stores: string[], + work: Parameters>[2], signal?: AbortSignal, ) { await ready; if (disposed) throw new Error("Artifact collection closed"); return transact( mode, + stores, (tx, then, done) => { then(tx.objectStore("metadata").get(key("schema")), (schema) => { if (schema?.version !== definition.version) @@ -398,8 +402,10 @@ export function createArtifactStore(viewer: string, relay: string) { const handle: HostArtifactCollection = { scope: Object.freeze({ viewer, relay, partition: scope }), get: (id) => - run("readonly", (tx, then, done) => - readEntry(tx, then, id, done), + run( + "readonly", + ["metadata", "records", "preferences"], + (tx, then, done) => readEntry(tx, then, id, done), ), page(query: ArtifactPageQuery) { const limit = query.limit ?? 100; @@ -407,7 +413,7 @@ export function createArtifactStore(viewer: string, relay: string) { return Promise.reject( new Error("Artifact page limit must be 1 to 1000"), ); - return run("readonly", (tx, then, done) => { + return run("readonly", STORES, (tx, then, done) => { const pagePrefix = [...prefix, query.index, query.value]; const lower = query.after ? [...pagePrefix, query.after.order, query.after.id] @@ -443,14 +449,17 @@ export function createArtifactStore(viewer: string, relay: string) { }, preferences: { get: (id) => - run("readonly", (tx, then, done) => - then( - tx.objectStore("preferences").get(key(id)), - (pref: StoredPreference | undefined) => done(pref?.value), - ), + run( + "readonly", + ["metadata", "preferences"], + (tx, then, done) => + then( + tx.objectStore("preferences").get(key(id)), + (pref: StoredPreference | undefined) => done(pref?.value), + ), ), async update(id, change) { - await run("readwrite", (tx, then, done) => + await run("readwrite", STORES, (tx, then, done) => mutate( tx, then, @@ -463,7 +472,7 @@ export function createArtifactStore(viewer: string, relay: string) { }, }, async importBatch(batch: ArtifactImport) { - await run("readwrite", (tx, then, done) => { + await run("readwrite", STORES, (tx, then, done) => { const jobs: (() => void)[] = []; const next = series(jobs); for (const record of batch.records ?? []) @@ -512,7 +521,7 @@ export function createArtifactStore(viewer: string, relay: string) { publish(); }, imported: (marker) => - run("readonly", (tx, then, done) => + run("readonly", ["metadata"], (tx, then, done) => then( tx .objectStore("metadata") @@ -521,7 +530,7 @@ export function createArtifactStore(viewer: string, relay: string) { ), ), refreshError: () => - run("readonly", (tx, then, done) => + run("readonly", ["metadata"], (tx, then, done) => then(tx.objectStore("metadata").get(key("refresh-error")), (row) => done(row?.value ?? null), ), @@ -530,6 +539,7 @@ export function createArtifactStore(viewer: string, relay: string) { if (!message) throw new Error("Artifact refresh error is empty"); await run( "readwrite", + ["metadata"], (tx, then, done) => { checkRefresh(tx, then, refresh, () => { tx.objectStore("metadata").put({ @@ -553,6 +563,7 @@ export function createArtifactStore(viewer: string, relay: string) { ); return run<{ ids: string[]; next?: string }>( "readonly", + ["metadata", "records"], (tx, then, done) => { const records = tx.objectStore("records"); const parts = @@ -587,7 +598,7 @@ export function createArtifactStore(viewer: string, relay: string) { ); }, checkpoint: () => - run("readonly", (tx, then, done) => + run("readonly", ["metadata"], (tx, then, done) => then(tx.objectStore("metadata").get(key("checkpoint")), (row) => done(row?.value ?? null), ), @@ -595,22 +606,27 @@ export function createArtifactStore(viewer: string, relay: string) { beginRefresh(source) { if (!source) return Promise.reject(new Error("Artifact source is empty")); - return run("readwrite", (tx, then, done) => { - bindSource(tx, then, source, () => { - const refresh = { source, generation: crypto.randomUUID() }; - tx.objectStore("metadata").put({ - scope, - collection, - key: "refresh", - value: refresh, + return run( + "readwrite", + ["metadata"], + (tx, then, done) => { + bindSource(tx, then, source, () => { + const refresh = { source, generation: crypto.randomUUID() }; + tx.objectStore("metadata").put({ + scope, + collection, + key: "refresh", + value: refresh, + }); + done(refresh); }); - done(refresh); - }); - }); + }, + ); }, async completeRefresh(refresh, checkpoint, signal) { await run( "readwrite", + ["metadata"], (tx, then, done) => { checkRefresh(tx, then, refresh, () => { const metadata = tx.objectStore("metadata"); @@ -636,6 +652,7 @@ export function createArtifactStore(viewer: string, relay: string) { async applyBatch(batch: ArtifactBatch, signal) { await run( "readwrite", + STORES, (tx, then, done) => { const jobs: (() => void)[] = []; const next = series(jobs); @@ -727,7 +744,7 @@ export function createArtifactStore(viewer: string, relay: string) { collection, async clear() { const affected = new Set(); - await transact("readwrite", (tx, then, done) => { + await transact("readwrite", STORES, (tx, then, done) => { let pending = STORES.length; for (const name of STORES) { const request = tx diff --git a/tests/browser/artifact-storage.spec.mjs b/tests/browser/artifact-storage.spec.mjs index 4bd883bdcf..0efdf77936 100644 --- a/tests/browser/artifact-storage.spec.mjs +++ b/tests/browser/artifact-storage.spec.mjs @@ -463,10 +463,70 @@ test("reads bounded indexed pages for 10,000 tasks and 40,000 archived records", }); const elapsedMs = performance.now() - begin; IDBObjectStore.prototype.get = get; + const idleWrites = []; + for (let n = 0; n < 20; n++) { + const start = performance.now(); + await c.preferences.update("000000", () => ({ pinned: n % 2 === 0 })); + idleWrites.push(performance.now() - start); + } + const contendedWrites = []; + const refresh = await c.beginRefresh("all"); + for (const batchSize of [100, 1000]) { + for (let n = 0; n < 5; n++) { + let started; + const active = new Promise((resolve) => { + started = resolve; + }); + const put = IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put = function (...args) { + const request = put.apply(this, args); + if (this.name === "records") { + IDBObjectStore.prototype.put = put; + started(); + } + return request; + }; + const backgroundStart = performance.now(); + const background = c.applyBatch({ + source: "all", + refresh, + records: Array.from({ length: batchSize }, (_, i) => ({ + id: String(10000 + i).padStart(6, "0"), + channelId: "channel", + type: "test.task", + revision: { + id: `rev-${batchSize}-${n}-${i}`, + content: JSON.stringify({ + archived: true, + title: "x".repeat(500), + }), + }, + })), + }); + try { + await active; + const start = performance.now(); + await c.preferences.update("000000", () => ({ pinned: true })); + const writeMs = performance.now() - start; + await background; + contendedWrites.push({ + batchSize, + writeMs, + batchMs: performance.now() - backgroundStart, + }); + } finally { + IDBObjectStore.prototype.put = put; + } + } + } + const preference = await c.preferences.get("000000"); window.artifactTest.close(); const reopened = window.artifactTest.open(); return { savedMs, + idleWrites, + contendedWrites, + preference, elapsedMs, scans, ids, @@ -478,6 +538,7 @@ test("reads bounded indexed pages for 10,000 tasks and 40,000 archived records", last: (await reopened.get("049999")).record.local, }; }); + expect(result.preference).toEqual({ pinned: true }); expect(result.scans).toBe(0); expect(result.ids.ids).toHaveLength(75); expect(result.nextIds.ids[0]).toBe("000075"); @@ -492,6 +553,8 @@ test("reads bounded indexed pages for 10,000 tasks and 40,000 archived records", savedMs: result.savedMs, pageMs: result.elapsedMs, recordReads: result.reads, + idleWrites: result.idleWrites, + contendedWrites: result.contendedWrites, }), ); }); @@ -1005,3 +1068,49 @@ test("retries database opening with a new handle after a transient failure", asy saved: true, }); }); + +test("direct reads do not wait for an index-only transaction", async ({ + page, + storageUrl, +}) => { + await open(page, storageUrl); + const result = await page.evaluate(async () => { + const c = window.artifactTest.open(); + await c.importBatch({ + records: [{ id: "task", local: { title: "saved" } }], + }); + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open("buzz-artifacts-v1", 3); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const tx = db.transaction("entries", "readwrite"); + let held = true; + const release = () => { + held = false; + tx.abort(); + }; + const timeout = setTimeout(release, 5000); + const keepActive = () => { + if (held) tx.objectStore("entries").count().onsuccess = keepActive; + }; + keepActive(); + try { + const row = await c.get("task"); + await c.preferences.get("task"); + const ids = await c.knownIds({ limit: 10 }); + const error = await c.refreshError(); + const checkpoint = await c.checkpoint(); + return { held, row, ids, error, checkpoint }; + } finally { + clearTimeout(timeout); + if (held) release(); + db.close(); + } + }); + expect(result.held).toBe(true); + expect(result.row.record.local).toEqual({ title: "saved" }); + expect(result.ids.ids).toEqual(["task"]); + expect(result.error).toBeNull(); + expect(result.checkpoint).toBeNull(); +}); From 513638a755fbaf63850b66493ca32bea4de3b92b Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 16:56:15 -0700 Subject: [PATCH 18/30] Allow time for signed overflow fixtures Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- src/features/relay/artifact-sync.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/relay/artifact-sync.test.ts b/src/features/relay/artifact-sync.test.ts index e3d8d6daa5..a03ed23563 100644 --- a/src/features/relay/artifact-sync.test.ts +++ b/src/features/relay/artifact-sync.test.ts @@ -324,7 +324,7 @@ it("uses bounded current reads and requests a delta after live overflow", async .mock.calls.every((call) => call[3].length <= 100), ).toBe(true); expect(reconcileArtifactDelta).toHaveBeenCalledTimes(2); -}); +}, 15_000); // This case signs and verifies 1,002 distinct live events. it("keeps capacity backoff when a live read starts before the repair timer", async () => { const h = setup(); From 5ee2df40119406b2404caa976e7346eff4029b78 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 18:46:02 -0700 Subject: [PATCH 19/30] Fix artifact sync protocol and reconnect Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- src/features/relay/artifact-reader.test.ts | 30 +++++++--- src/features/relay/artifact-reader.ts | 3 +- src/features/relay/artifact-reconcile.test.ts | 47 ++++++++++++++- src/features/relay/artifact-reconcile.ts | 9 +-- src/features/relay/artifact-sync.test.ts | 57 +++++++++++++++++-- src/features/relay/artifact-sync.ts | 1 + 6 files changed, 125 insertions(+), 22 deletions(-) diff --git a/src/features/relay/artifact-reader.test.ts b/src/features/relay/artifact-reader.test.ts index 0a3d3e42d2..4409d44057 100644 --- a/src/features/relay/artifact-reader.test.ts +++ b/src/features/relay/artifact-reader.test.ts @@ -87,18 +87,13 @@ it("reads signed changes and gives a checkpoint only on the final page", async ( expect(change?.type).toBe("upsert"); if (change?.type === "upsert") expect(hasEventProof(change.event)).toBe(true); request.mockImplementationOnce(async () => Response.json(page())); - const last = await reader.delta( - query, - { checkpoint: "prior-checkpoint", cursor: "page-two" }, - signal(), - ); + const last = await reader.delta(query, { cursor: "page-two" }, signal()); expect(last.checkpoint).toBe("checkpoint-final"); expect(request.mock.calls.map(([filter]) => filter)).toEqual([ { artifact: "sync", query, checkpoint: "prior-checkpoint" }, { artifact: "sync", query, - checkpoint: "prior-checkpoint", cursor: "page-two", }, ]); @@ -130,7 +125,8 @@ it.each([ "2026-02-30T22:00:01.123456Z", "2026-10-21T24:00:01.123456Z", "2026-10-21T22:60:01.123456Z", - "2026-10-21T22:00:01.123Z", + "2026-10-21T22:00:01.Z", + "2026-10-21T22:00:01.1234567890Z", "2026-10-21T22:00:01.123456", "2026-10-21T22:00:01.123456+24:00", ])("rejects invalid sync time %s", async (value) => { @@ -141,12 +137,27 @@ it.each([ code: "sync_invalid", }); }); -it("accepts a leap day with a numeric time offset", async () => { - const time = "2024-02-29T22:00:01.123456+05:30"; +it.each([ + "2026-10-21T22:00:01Z", + "2026-10-21T22:00:01.123Z", + "2026-10-21T22:00:01.123456Z", + "2026-10-21T22:00:01.123456789Z", + "2024-02-29T22:00:01.123456+05:30", +])("accepts relay sync time %s", async (time) => { const reader = createArtifactSyncReader(async () => Response.json({ ...page(), startedAt: time }), ); expect((await reader.delta(query, {}, signal())).startedAt).toBe(time); + const current = createArtifactSyncReader(async () => + Response.json({ + version: 1, + startedAt: time, + changes: [{ type: "remove", artifactId }], + }), + ); + expect((await current.current(query, [artifactId], signal())).startedAt).toBe( + time, + ); }); it.each( [ @@ -293,6 +304,7 @@ it.each([ expect(request).not.toHaveBeenCalled(); }); it.each([ + { checkpoint: "prior-checkpoint", cursor: "page-two" }, { checkpoint: "" }, { cursor: "has space" }, { checkpoint: "x".repeat(4097) }, diff --git a/src/features/relay/artifact-reader.ts b/src/features/relay/artifact-reader.ts index c2eaa345e6..7b49085b31 100644 --- a/src/features/relay/artifact-reader.ts +++ b/src/features/relay/artifact-reader.ts @@ -125,7 +125,7 @@ function queryType(query: ArtifactSyncQuery): string { function timestamp(value: unknown): value is string { if (typeof value !== "string") return false; const parts = - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.\d{6}(?:Z|[+-](\d{2}):(\d{2}))$/.exec( + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|[+-](\d{2}):(\d{2}))$/.exec( value, ); if (!parts) return false; @@ -297,6 +297,7 @@ export function createArtifactSyncReader( async delta(query, position, signal) { const type = queryType(query); if ( + (position.checkpoint !== undefined && position.cursor !== undefined) || (position.checkpoint !== undefined && !token(position.checkpoint)) || (position.cursor !== undefined && !token(position.cursor)) ) diff --git a/src/features/relay/artifact-reconcile.test.ts b/src/features/relay/artifact-reconcile.test.ts index 16ad8e2c60..1e6b3225ef 100644 --- a/src/features/relay/artifact-reconcile.test.ts +++ b/src/features/relay/artifact-reconcile.test.ts @@ -7,7 +7,11 @@ import { reconcileArtifactDelta, reconcileArtifactCurrent, } from "./artifact-reconcile"; -import { ArtifactSyncError, type ArtifactSyncReader } from "./artifact-reader"; +import { + ArtifactSyncError, + createArtifactSyncReader, + type ArtifactSyncReader, +} from "./artifact-reader"; import { keypair, signed } from "./testing"; import { eventDto } from "./events"; const channel = "11111111-1111-4111-8111-111111111111"; @@ -110,7 +114,6 @@ it("saves delta progress only after all pages commit", async () => { }); await h.delta(); expect(h.reader.delta.mock.calls[1]?.[1]).toEqual({ - checkpoint: "old", cursor: "second", }); expect(h.collection.applyBatch.mock.invocationCallOrder[0]).toBeLessThan( @@ -125,6 +128,46 @@ it("saves delta progress only after all pages commit", async () => { h.controller.signal, ); }); +it("continues an incremental pass through the relay request validator", async () => { + const h = setup(); + const request = vi.fn( + async (filter: import("./artifact-reader").ArtifactSyncRequest) => { + if ("checkpoint" in filter && "cursor" in filter) + return Response.json({ code: "sync_invalid" }, { status: 400 }); + if ("cursor" in filter) + return Response.json({ + ...h.page, + changes: [{ type: "remove", artifactId: id }], + }); + return Response.json({ + ...h.page, + changes: [{ type: "upsert", artifactId: id, event }], + nextCursor: "second", + checkpoint: null, + }); + }, + ); + await reconcileArtifactDelta( + createArtifactSyncReader(request), + h.collection as unknown as HostArtifactCollection, + "test.task", + h.controller.signal, + ); + expect(request.mock.calls.map(([filter]) => filter)).toEqual([ + { + artifact: "sync", + query: { artifact: "current", kinds: [45010], "#type": ["test.task"] }, + checkpoint: "old", + }, + { + artifact: "sync", + query: { artifact: "current", kinds: [45010], "#type": ["test.task"] }, + cursor: "second", + }, + ]); + expect(h.rows.get(id)?.record.availability.state).toBe("unavailable"); + expect(h.collection.completeRefresh).toHaveBeenCalledOnce(); +}); it("does not infer absence from an empty delta", async () => { const h = setup(); h.rows.set(id, { diff --git a/src/features/relay/artifact-reconcile.ts b/src/features/relay/artifact-reconcile.ts index abc05f8cb6..e49c7f5c79 100644 --- a/src/features/relay/artifact-reconcile.ts +++ b/src/features/relay/artifact-reconcile.ts @@ -266,10 +266,11 @@ export async function reconcileArtifactDelta( while (true) { const page = await reader.delta( queryFor(type), - { - ...(checkpoint !== undefined ? { checkpoint } : {}), - ...(cursor !== undefined ? { cursor } : {}), - }, + cursor !== undefined + ? { cursor } + : checkpoint !== undefined + ? { checkpoint } + : {}, signal, ); signal.throwIfAborted(); diff --git a/src/features/relay/artifact-sync.test.ts b/src/features/relay/artifact-sync.test.ts index a03ed23563..4ff9a287fa 100644 --- a/src/features/relay/artifact-sync.test.ts +++ b/src/features/relay/artifact-sync.test.ts @@ -32,18 +32,20 @@ function setup() { locks: { request: vi.fn(async (_key, _options, callback) => callback()) }, }); const listeners = new Set<() => void>(); + const liveListeners = new Set<() => void>(); let receive: (value: { events: readonly RelayEvent[] }) => void = () => {}; let accepted: (event: EventData) => void = () => {}; let opened: (entry: OpenArtifactCollection) => void = () => {}; let online = true; + let scoped = true; const session = { artifactSync: {}, live: { snapshot: () => ({ status: online ? "connected" : "retrying" }), subscribe: (listener: () => void) => { - listeners.add(listener); + liveListeners.add(listener); return () => { - listeners.delete(listener); + liveListeners.delete(listener); }; }, }, @@ -69,9 +71,8 @@ function setup() { const ctx = { relay: { snapshot: () => ({ - viewer: "viewer", - origin: "https://relay.test", - status: "ready", + ...(scoped ? { viewer: "viewer", origin: "https://relay.test" } : {}), + status: scoped ? "ready" : "connecting", cached: false, session, }), @@ -110,11 +111,15 @@ function setup() { }; return { add, + scope: (value: boolean) => { + scoped = value; + for (const listener of listeners) listener(); + }, confirmed: (event: RelayEvent) => receive({ events: [event] }), accepted: (event: EventData) => accepted(event), connection: (value: boolean) => { online = value; - for (const listener of listeners) listener(); + for (const listener of liveListeners) listener(); }, }; } @@ -148,6 +153,46 @@ it("aborts reconciliation on disconnect and starts a new capture on reconnect", await vi.advanceTimersByTimeAsync(0); expect(reconcileArtifactDelta).toHaveBeenCalledTimes(2); }); +it("keeps an open collection through a reconnect without relay scope", async () => { + const h = setup(); + vi.mocked(reconcileArtifactDelta).mockImplementationOnce( + async (_reader, _collection, _type, signal) => { + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }), + ); + }, + ); + const entry = h.add(); + await vi.advanceTimersByTimeAsync(0); + const signal = vi.mocked(reconcileArtifactDelta).mock.calls[0]?.[3]; + h.scope(false); + expect(signal?.aborted).toBe(true); + expect(entry.controller.signal.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(60_000); + expect(reconcileArtifactDelta).toHaveBeenCalledOnce(); + h.scope(true); + await vi.advanceTimersByTimeAsync(0); + expect(reconcileArtifactDelta).toHaveBeenCalledTimes(2); + const event = eventDto( + signed(keypair(), { + kind: 45010, + content: "{}", + tags: [ + ["d", "task"], + ["type", "test.task"], + ], + }), + ); + h.confirmed(event); + await vi.advanceTimersByTimeAsync(0); + expect(reconcileArtifactCurrent).toHaveBeenCalledOnce(); + h.accepted(event); + await vi.advanceTimersByTimeAsync(0); + expect(reconcileArtifactCurrent).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(60_000); + expect(reconcileArtifactDelta).toHaveBeenCalledTimes(3); + expect(navigator.locks.request).toHaveBeenCalledOnce(); +}); it("refreshes after proven acceptance without writing raw revisions", async () => { const h = setup(); const entry = h.add(); diff --git a/src/features/relay/artifact-sync.ts b/src/features/relay/artifact-sync.ts index eb319e4ad1..f49b4462e2 100644 --- a/src/features/relay/artifact-sync.ts +++ b/src/features/relay/artifact-sync.ts @@ -84,6 +84,7 @@ export function provideArtifactSync( viewer && origin ? transportPartition({ viewer, scope: origin, relayAuthor: "" }) : undefined; + if (scope === undefined) return; for (const [key, group] of groups) if (group.scope !== scope) { group.lifetime.abort(); From f59cb0df396196f99e4d72052703903eae0ab24f Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 28 Oct 2026 15:04:53 -0700 Subject: [PATCH 20/30] Use the incremental artifact protocol Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/artifact-sync.md | 2 +- src/app/services.ts | 4 +- ...ts => artifact-incremental-reader.test.ts} | 143 ++++++++++-------- ...ader.ts => artifact-incremental-reader.ts} | 122 +++++++++------ src/features/relay/artifact-reconcile.test.ts | 51 ++++--- src/features/relay/artifact-reconcile.ts | 48 +++--- ...nc.test.ts => artifact-store-sync.test.ts} | 12 +- ...rtifact-sync.ts => artifact-store-sync.ts} | 24 ++- src/features/relay/native.test.ts | 46 +++--- src/features/relay/native.ts | 29 ++-- src/features/relay/session.ts | 2 +- src/features/relay/transport.ts | 4 +- tests/fixtures/artifact-sync.ts | 10 +- 13 files changed, 280 insertions(+), 217 deletions(-) rename src/features/relay/{artifact-reader.test.ts => artifact-incremental-reader.test.ts} (74%) rename src/features/relay/{artifact-reader.ts => artifact-incremental-reader.ts} (76%) rename src/features/relay/{artifact-sync.test.ts => artifact-store-sync.test.ts} (97%) rename src/features/relay/{artifact-sync.ts => artifact-store-sync.ts} (93%) diff --git a/docs/artifact-sync.md b/docs/artifact-sync.md index a7ab82bfb2..af1ffa568f 100644 --- a/docs/artifact-sync.md +++ b/docs/artifact-sync.md @@ -2,7 +2,7 @@ The host saves relay records through `ArtifactStorageService`. Each plugin defines one collection and one fixed query. A Web Lock covers `{viewer,relay,owner,collectionName}`. Its owner cancels reads and waits for commits before it releases the lock. -The native transport exposes `session.artifactSync` when the relay advertises the exact `artifacts.sync` v1 contract. It covers permanent channels, 100-row pages, 100-ID current reads, an 8 MiB response bound, and a 30-day checkpoint lifetime. The relay prerequisite is [PR 8523](https://github.com/block/buzz/pull/8523). +The native transport exposes `session.artifactIncremental` when the relay advertises the exact `artifacts.incremental` v1 contract. It covers permanent channels, 100-row pages, 100-ID current reads, an 8 MiB response bound, and a 30-day checkpoint lifetime. The relay prerequisite is [PR 8523](https://github.com/block/buzz/pull/8523). The first read discovers authorized records. Later reads use a checkpoint with a 120-second overlap from the last successful server start time. Only a full paged pass saves progress. Live pushes request current-ID reads through the same collection queue. They do not advance progress or directly write saved rows. The existing outbox acceptance observer also requests a current read. diff --git a/src/app/services.ts b/src/app/services.ts index 198cae92f7..ebed321ec8 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -1,5 +1,5 @@ import { ArtifactStorageService } from "../features/artifact-storage/service"; -import { provideArtifactSync } from "../features/relay/artifact-sync"; +import { provideArtifactStoreSync } from "../features/relay/artifact-store-sync"; import { createAdminAccess } from "../bundled/admin-access"; import { nativeRelayStaff } from "../features/relay-staff/native"; import { createLocalAgentActivity } from "../features/agents/local-activity"; @@ -78,7 +78,7 @@ export function createServices() { }); const relay = communities.relay; const artifactStorage = new ArtifactStorageService(ctx); - provideArtifactSync(ctx, artifactStorage); + provideArtifactStoreSync(ctx, artifactStorage); const adminAccess = createAdminAccess(relay, communities, nativeRelayStaff()); ctx.provide("adminAccess", adminAccess); ctx.effect(() => () => adminAccess.dispose()); diff --git a/src/features/relay/artifact-reader.test.ts b/src/features/relay/artifact-incremental-reader.test.ts similarity index 74% rename from src/features/relay/artifact-reader.test.ts rename to src/features/relay/artifact-incremental-reader.test.ts index 4409d44057..0a92761557 100644 --- a/src/features/relay/artifact-reader.test.ts +++ b/src/features/relay/artifact-incremental-reader.test.ts @@ -1,13 +1,13 @@ import { expect, it, vi } from "vitest"; import { - ARTIFACT_SYNC_LIMITS, - ArtifactSyncError, - artifactSyncCapability, - artifactSyncFailure, - createArtifactSyncReader, - type ArtifactSyncQuery, - type ArtifactSyncRequest, -} from "./artifact-reader"; + ARTIFACT_INCREMENTAL_LIMITS, + ArtifactIncrementalError, + artifactIncrementalCapability, + artifactIncrementalFailure, + createArtifactIncrementalReader, + type ArtifactIncrementalQuery, + type ArtifactIncrementalRequest, +} from "./artifact-incremental-reader"; import { hasEventProof } from "./events"; import { keypair, signed } from "./testing"; @@ -41,25 +41,31 @@ const page = (changes: unknown[] = []) => ({ it("requires each advertised sync limit", () => { expect( - artifactSyncCapability({ artifacts: { sync: ARTIFACT_SYNC_LIMITS } }), + artifactIncrementalCapability({ + artifacts: { incremental: ARTIFACT_INCREMENTAL_LIMITS }, + }), ).toBe(true); - for (const key of Object.keys(ARTIFACT_SYNC_LIMITS)) { - const limits: Record = { ...ARTIFACT_SYNC_LIMITS }; + for (const key of Object.keys(ARTIFACT_INCREMENTAL_LIMITS)) { + const limits: Record = { ...ARTIFACT_INCREMENTAL_LIMITS }; delete limits[key]; - expect(artifactSyncCapability({ artifacts: { sync: limits } })).toBe(false); + expect( + artifactIncrementalCapability({ artifacts: { incremental: limits } }), + ).toBe(false); } expect( - artifactSyncCapability({ - artifacts: { sync: { ...ARTIFACT_SYNC_LIMITS, overlapSeconds: 60 } }, + artifactIncrementalCapability({ + artifacts: { + incremental: { ...ARTIFACT_INCREMENTAL_LIMITS, overlapSeconds: 60 }, + }, }), ).toBe(false); - expect(artifactSyncCapability({ supported_extensions: ["nip-ar"] })).toBe( - false, - ); + expect( + artifactIncrementalCapability({ supported_extensions: ["nip-ar"] }), + ).toBe(false); }); it("reads signed changes and gives a checkpoint only on the final page", async () => { const event = revision(); - const request = vi.fn(async (_filter: ArtifactSyncRequest) => + const request = vi.fn(async (_filter: ArtifactIncrementalRequest) => Response.json({ ...page([ { type: "upsert", artifactId, event }, @@ -70,7 +76,7 @@ it("reads signed changes and gives a checkpoint only on the final page", async ( checkpoint: null, }), ); - const reader = createArtifactSyncReader(request); + const reader = createArtifactIncrementalReader(request); const first = await reader.delta( query, { checkpoint: "prior-checkpoint" }, @@ -90,9 +96,9 @@ it("reads signed changes and gives a checkpoint only on the final page", async ( const last = await reader.delta(query, { cursor: "page-two" }, signal()); expect(last.checkpoint).toBe("checkpoint-final"); expect(request.mock.calls.map(([filter]) => filter)).toEqual([ - { artifact: "sync", query, checkpoint: "prior-checkpoint" }, + { artifact: "incremental", query, checkpoint: "prior-checkpoint" }, { - artifact: "sync", + artifact: "incremental", query, cursor: "page-two", }, @@ -114,11 +120,11 @@ it.each([ })), }, ])("rejects invalid page fields %j", async (fields) => { - const reader = createArtifactSyncReader(async () => + const reader = createArtifactIncrementalReader(async () => Response.json({ ...page(), ...fields }), ); await expect(reader.delta(query, {}, signal())).rejects.toMatchObject({ - code: "sync_invalid", + code: "incremental_invalid", }); }); it.each([ @@ -130,11 +136,11 @@ it.each([ "2026-10-21T22:00:01.123456", "2026-10-21T22:00:01.123456+24:00", ])("rejects invalid sync time %s", async (value) => { - const reader = createArtifactSyncReader(async () => + const reader = createArtifactIncrementalReader(async () => Response.json({ ...page(), startedAt: value }), ); await expect(reader.delta(query, {}, signal())).rejects.toMatchObject({ - code: "sync_invalid", + code: "incremental_invalid", }); }); it.each([ @@ -144,11 +150,11 @@ it.each([ "2026-10-21T22:00:01.123456789Z", "2024-02-29T22:00:01.123456+05:30", ])("accepts relay sync time %s", async (time) => { - const reader = createArtifactSyncReader(async () => + const reader = createArtifactIncrementalReader(async () => Response.json({ ...page(), startedAt: time }), ); expect((await reader.delta(query, {}, signal())).startedAt).toBe(time); - const current = createArtifactSyncReader(async () => + const current = createArtifactIncrementalReader(async () => Response.json({ version: 1, startedAt: time, @@ -176,13 +182,13 @@ it.each( )( "rejects signed events with invalid artifact tags %j", async ({ eventTags }) => { - const reader = createArtifactSyncReader(async () => + const reader = createArtifactIncrementalReader(async () => Response.json( page([{ type: "upsert", artifactId, event: revision(eventTags) }]), ), ); await expect(reader.delta(query, {}, signal())).rejects.toMatchObject({ - code: "sync_invalid", + code: "incremental_invalid", }); }, ); @@ -191,11 +197,11 @@ it("rejects a wrong event kind or a modified signed body", async () => { revision(tags, 9), { ...revision(), content: "modified" }, ]) { - const reader = createArtifactSyncReader(async () => + const reader = createArtifactIncrementalReader(async () => Response.json(page([{ type: "upsert", artifactId, event }])), ); await expect(reader.delta(query, {}, signal())).rejects.toMatchObject({ - code: "sync_invalid", + code: "incremental_invalid", }); } }); @@ -205,16 +211,16 @@ it.each([ { type: "removeChannel", channelId: "local-channel" }, { type: "unknown", artifactId }, ])("rejects invalid delta identities %j", async (change) => { - const reader = createArtifactSyncReader(async () => + const reader = createArtifactIncrementalReader(async () => Response.json(page([change])), ); await expect(reader.delta(query, {}, signal())).rejects.toMatchObject({ - code: "sync_invalid", + code: "incremental_invalid", }); }); it("requires one current result for each requested ID and accepts a uniform remove for a local ID", async () => { const event = revision(); - const request = vi.fn(async (_filter: ArtifactSyncRequest) => + const request = vi.fn(async (_filter: ArtifactIncrementalRequest) => Response.json({ version: 1, startedAt, @@ -224,14 +230,14 @@ it("requires one current result for each requested ID and accepts a uniform remo ], }), ); - const rows = await createArtifactSyncReader(request).current( + const rows = await createArtifactIncrementalReader(request).current( query, [artifactId, "local-id"], signal(), ); expect(rows.changes).toHaveLength(2); expect(request.mock.calls[0]?.[0]).toEqual({ - artifact: "sync", + artifact: "incremental", action: "current", query, artifactIds: [artifactId, "local-id"], @@ -250,12 +256,12 @@ it.each( )( "rejects missing, duplicate or unrelated current results %j", async ({ rows }) => { - const reader = createArtifactSyncReader(async () => + const reader = createArtifactIncrementalReader(async () => Response.json({ version: 1, startedAt, changes: rows }), ); await expect( reader.current(query, [artifactId], signal()), - ).rejects.toMatchObject({ code: "sync_invalid" }); + ).rejects.toMatchObject({ code: "incremental_invalid" }); }, ); it.each( @@ -269,13 +275,13 @@ it.each( )("rejects invalid current input before a request %j", async ({ ids }) => { const request = vi.fn(); await expect( - createArtifactSyncReader(request).current(query, ids, signal()), - ).rejects.toMatchObject({ code: "sync_invalid" }); + createArtifactIncrementalReader(request).current(query, ids, signal()), + ).rejects.toMatchObject({ code: "incremental_invalid" }); expect(request).not.toHaveBeenCalled(); }); it("admits 100 bounded current IDs", async () => { const ids = Array.from({ length: 100 }, (_, i) => `${i}-${"é".repeat(126)}`); - const reader = createArtifactSyncReader(async () => + const reader = createArtifactIncrementalReader(async () => Response.json({ version: 1, startedAt, @@ -295,12 +301,12 @@ it.each([ ])("rejects unsupported queries before a request %j", async (value) => { const request = vi.fn(); await expect( - createArtifactSyncReader(request).delta( - value as ArtifactSyncQuery, + createArtifactIncrementalReader(request).delta( + value as ArtifactIncrementalQuery, {}, signal(), ), - ).rejects.toMatchObject({ code: "sync_invalid" }); + ).rejects.toMatchObject({ code: "incremental_invalid" }); expect(request).not.toHaveBeenCalled(); }); it.each([ @@ -311,17 +317,17 @@ it.each([ ])("rejects invalid input tokens %j", async (position) => { const request = vi.fn(); await expect( - createArtifactSyncReader(request).delta(query, position, signal()), - ).rejects.toMatchObject({ code: "sync_invalid" }); + createArtifactIncrementalReader(request).delta(query, position, signal()), + ).rejects.toMatchObject({ code: "incremental_invalid" }); expect(request).not.toHaveBeenCalled(); }); it.each([ - "sync_invalid", - "sync_limit", - "sync_reset", - "sync_unavailable", + "incremental_invalid", + "incremental_limit", + "incremental_reset", + "incremental_unavailable", ] as const)("preserves error code %s without relay text", async (code) => { - const reader = createArtifactSyncReader(async () => + const reader = createArtifactIncrementalReader(async () => Response.json( { code, reason: "policy_changed", error: "private relay detail" }, { status: 409 }, @@ -334,49 +340,56 @@ it.each([ await expect(reader.delta(query, {}, signal())).rejects.not.toThrow( "private relay detail", ); - expect(artifactSyncFailure({ code, reason: "checkpoint_expired" })).toEqual({ + expect( + artifactIncrementalFailure({ code, reason: "checkpoint_expired" }), + ).toEqual({ error: "Artifact sync request fails", code, reason: "checkpoint_expired", }); }); it("rejects unknown error metadata and hides request failures", async () => { - expect(artifactSyncFailure({ code: "unknown" })).toBeUndefined(); + expect(artifactIncrementalFailure({ code: "unknown" })).toBeUndefined(); expect( - artifactSyncFailure({ code: "sync_reset", reason: "unknown" }), + artifactIncrementalFailure({ + code: "incremental_reset", + reason: "unknown", + }), ).toBeUndefined(); - const reader = createArtifactSyncReader(async () => { + const reader = createArtifactIncrementalReader(async () => { throw new Error("private transport detail"); }); await expect(reader.delta(query, {}, signal())).rejects.toBeInstanceOf( - ArtifactSyncError, + ArtifactIncrementalError, ); await expect(reader.delta(query, {}, signal())).rejects.toMatchObject({ - code: "sync_unavailable", + code: "incremental_unavailable", message: "Artifact sync request fails", }); }); it.each(["not json", new Uint8Array([0xff])])( "rejects invalid response encoding", async (body) => { - const reader = createArtifactSyncReader(async () => new Response(body)); + const reader = createArtifactIncrementalReader( + async () => new Response(body), + ); await expect(reader.delta(query, {}, signal())).rejects.toMatchObject({ - code: "sync_invalid", + code: "incremental_invalid", }); }, ); it("bounds response bytes and cancels the stream", async () => { const cancel = vi.fn(); - const reader = createArtifactSyncReader( + const reader = createArtifactIncrementalReader( async () => new Response( new ReadableStream({ start(controller) { controller.enqueue( - new Uint8Array(ARTIFACT_SYNC_LIMITS.responseBytes / 2), + new Uint8Array(ARTIFACT_INCREMENTAL_LIMITS.responseBytes / 2), ); controller.enqueue( - new Uint8Array(ARTIFACT_SYNC_LIMITS.responseBytes / 2 + 1), + new Uint8Array(ARTIFACT_INCREMENTAL_LIMITS.responseBytes / 2 + 1), ); }, cancel, @@ -384,7 +397,7 @@ it("bounds response bytes and cancels the stream", async () => { ), ); await expect(reader.delta(query, {}, signal())).rejects.toMatchObject({ - code: "sync_limit", + code: "incremental_limit", }); expect(cancel).toHaveBeenCalledOnce(); }); @@ -393,12 +406,12 @@ it("fences cancellation before requests and during response consumption", async stopped.abort(); const request = vi.fn(); await expect( - createArtifactSyncReader(request).delta(query, {}, stopped.signal), + createArtifactIncrementalReader(request).delta(query, {}, stopped.signal), ).rejects.toMatchObject({ name: "AbortError" }); expect(request).not.toHaveBeenCalled(); const controller = new AbortController(); const cancel = vi.fn(); - const reader = createArtifactSyncReader( + const reader = createArtifactIncrementalReader( async () => new Response( new ReadableStream( diff --git a/src/features/relay/artifact-reader.ts b/src/features/relay/artifact-incremental-reader.ts similarity index 76% rename from src/features/relay/artifact-reader.ts rename to src/features/relay/artifact-incremental-reader.ts index 7b49085b31..c80fd46073 100644 --- a/src/features/relay/artifact-reader.ts +++ b/src/features/relay/artifact-incremental-reader.ts @@ -1,7 +1,7 @@ import { eventDto, type RelayEvent } from "./events"; import { yieldToHost } from "./yield"; -export const ARTIFACT_SYNC_LIMITS = Object.freeze({ +export const ARTIFACT_INCREMENTAL_LIMITS = Object.freeze({ version: 1, overlapSeconds: 120, pageRows: 100, @@ -10,58 +10,62 @@ export const ARTIFACT_SYNC_LIMITS = Object.freeze({ permanentChannelsOnly: true, checkpointLifetimeSeconds: 2_592_000, }); -export type ArtifactSyncQuery = Readonly<{ +export type ArtifactIncrementalQuery = Readonly<{ artifact: "current"; kinds: readonly [45010]; "#type": readonly string[]; }>; -export type ArtifactSyncChange = +export type ArtifactIncrementalChange = | Readonly<{ type: "upsert"; artifactId: string; event: RelayEvent }> | Readonly<{ type: "remove"; artifactId: string }> | Readonly<{ type: "removeChannel"; channelId: string }>; -export type ArtifactSyncPage = Readonly<{ +export type ArtifactIncrementalPage = Readonly<{ startedAt: string; overlapSeconds: 120; - changes: readonly ArtifactSyncChange[]; + changes: readonly ArtifactIncrementalChange[]; nextCursor: string | null; checkpoint: string | null; }>; export type ArtifactCurrentPage = Readonly<{ startedAt: string; - changes: readonly Exclude[]; + changes: readonly Exclude< + ArtifactIncrementalChange, + { type: "removeChannel" } + >[]; }>; -export type ArtifactSyncRequest = +export type ArtifactIncrementalRequest = | Readonly<{ - artifact: "sync"; - query: ArtifactSyncQuery; + artifact: "incremental"; + query: ArtifactIncrementalQuery; checkpoint?: string; cursor?: string; }> | Readonly<{ - artifact: "sync"; + artifact: "incremental"; action: "current"; - query: ArtifactSyncQuery; + query: ArtifactIncrementalQuery; artifactIds: readonly string[]; }>; -export type ArtifactSyncReader = { +export type ArtifactIncrementalReader = { delta( - query: ArtifactSyncQuery, + query: ArtifactIncrementalQuery, position: { checkpoint?: string; cursor?: string }, signal: AbortSignal, - ): Promise; + ): Promise; current( - query: ArtifactSyncQuery, + query: ArtifactIncrementalQuery, artifactIds: readonly string[], signal: AbortSignal, ): Promise; }; type ErrorCode = - | "sync_invalid" - | "sync_limit" - | "sync_reset" - | "sync_unavailable"; + | "incremental_invalid" + | "incremental_limit" + | "incremental_reset" + | "incremental_unsupported_scope" + | "incremental_unavailable"; type ResetReason = "policy_changed" | "checkpoint_expired"; -export class ArtifactSyncError extends Error { +export class ArtifactIncrementalError extends Error { constructor( readonly code: ErrorCode, message: string, @@ -79,14 +83,21 @@ const uuid = (value: unknown): value is string => const token = (value: unknown): value is string => typeof value === "string" && /^[\x21-\x7e]{1,4096}$/.test(value); const invalid = () => - new ArtifactSyncError("sync_invalid", "Invalid artifact sync response"); + new ArtifactIncrementalError( + "incremental_invalid", + "Invalid artifact sync response", + ); -export function artifactSyncFailure(value: unknown) { +export function artifactIncrementalFailure(value: unknown) { if ( !object(value) || - !["sync_invalid", "sync_limit", "sync_reset", "sync_unavailable"].includes( - String(value.code), - ) || + ![ + "incremental_invalid", + "incremental_limit", + "incremental_reset", + "incremental_unsupported_scope", + "incremental_unavailable", + ].includes(String(value.code)) || (value.reason !== undefined && value.reason !== "policy_changed" && value.reason !== "checkpoint_expired") @@ -98,15 +109,19 @@ export function artifactSyncFailure(value: unknown) { ...(value.reason ? { reason: value.reason as ResetReason } : {}), }; } -export function artifactSyncCapability(info: unknown): boolean { - if (!object(info) || !object(info.artifacts) || !object(info.artifacts.sync)) +export function artifactIncrementalCapability(info: unknown): boolean { + if ( + !object(info) || + !object(info.artifacts) || + !object(info.artifacts.incremental) + ) return false; - const sync = info.artifacts.sync; - return Object.entries(ARTIFACT_SYNC_LIMITS).every( + const sync = info.artifacts.incremental; + return Object.entries(ARTIFACT_INCREMENTAL_LIMITS).every( ([key, value]) => sync[key] === value, ); } -function queryType(query: ArtifactSyncQuery): string { +function queryType(query: ArtifactIncrementalQuery): string { if ( !object(query) || Object.keys(query).length !== 3 || @@ -180,9 +195,9 @@ async function responseValue(response: Response, signal: AbortSignal) { break; } bytes += value.byteLength; - if (bytes > ARTIFACT_SYNC_LIMITS.responseBytes) - throw new ArtifactSyncError( - "sync_limit", + if (bytes > ARTIFACT_INCREMENTAL_LIMITS.responseBytes) + throw new ArtifactIncrementalError( + "incremental_limit", "Artifact sync response exceeds capacity", ); text += decoder.decode(value, { stream: true }); @@ -190,18 +205,22 @@ async function responseValue(response: Response, signal: AbortSignal) { signal.throwIfAborted(); const raw: unknown = JSON.parse(text); if (!response.ok) { - const failure = artifactSyncFailure(raw); + const failure = artifactIncrementalFailure(raw); throw failure - ? new ArtifactSyncError(failure.code, failure.error, failure.reason) - : new ArtifactSyncError( - "sync_unavailable", + ? new ArtifactIncrementalError( + failure.code, + failure.error, + failure.reason, + ) + : new ArtifactIncrementalError( + "incremental_unavailable", "Artifact sync request fails", ); } return raw; } catch (error) { signal.throwIfAborted(); - if (error instanceof ArtifactSyncError) throw error; + if (error instanceof ArtifactIncrementalError) throw error; throw invalid(); } finally { signal.removeEventListener("abort", abort); @@ -214,10 +233,10 @@ async function changes( type: string, signal: AbortSignal, currentIds?: ReadonlySet, -): Promise { - if (!Array.isArray(raw) || raw.length > ARTIFACT_SYNC_LIMITS.pageRows) +): Promise { + if (!Array.isArray(raw) || raw.length > ARTIFACT_INCREMENTAL_LIMITS.pageRows) throw invalid(); - const result: ArtifactSyncChange[] = []; + const result: ArtifactIncrementalChange[] = []; for (const change of raw) { signal.throwIfAborted(); if (!object(change)) throw invalid(); @@ -270,21 +289,24 @@ async function changes( } /** Sync covers permanent channels only. The authenticated transport owns POST requests. */ -export function createArtifactSyncReader( +export function createArtifactIncrementalReader( request: ( - filter: ArtifactSyncRequest, + filter: ArtifactIncrementalRequest, signal: AbortSignal, ) => Promise, -): ArtifactSyncReader { - const send = async (filter: ArtifactSyncRequest, signal: AbortSignal) => { +): ArtifactIncrementalReader { + const send = async ( + filter: ArtifactIncrementalRequest, + signal: AbortSignal, + ) => { signal.throwIfAborted(); let response: Response; try { response = await request(filter, signal); } catch { signal.throwIfAborted(); - throw new ArtifactSyncError( - "sync_unavailable", + throw new ArtifactIncrementalError( + "incremental_unavailable", "Artifact sync request fails", ); } @@ -304,7 +326,7 @@ export function createArtifactSyncReader( throw invalid(); const raw = await send( { - artifact: "sync", + artifact: "incremental", query, ...(position.checkpoint !== undefined ? { checkpoint: position.checkpoint } @@ -334,7 +356,7 @@ export function createArtifactSyncReader( if ( !Array.isArray(artifactIds) || !artifactIds.length || - artifactIds.length > ARTIFACT_SYNC_LIMITS.currentRows || + artifactIds.length > ARTIFACT_INCREMENTAL_LIMITS.currentRows || artifactIds.some( (id) => typeof id !== "string" || @@ -345,7 +367,7 @@ export function createArtifactSyncReader( ) throw invalid(); const raw = await send( - { artifact: "sync", action: "current", query, artifactIds }, + { artifact: "incremental", action: "current", query, artifactIds }, signal, ); const rows = await changes( diff --git a/src/features/relay/artifact-reconcile.test.ts b/src/features/relay/artifact-reconcile.test.ts index 1e6b3225ef..1759ddede1 100644 --- a/src/features/relay/artifact-reconcile.test.ts +++ b/src/features/relay/artifact-reconcile.test.ts @@ -8,10 +8,10 @@ import { reconcileArtifactCurrent, } from "./artifact-reconcile"; import { - ArtifactSyncError, - createArtifactSyncReader, - type ArtifactSyncReader, -} from "./artifact-reader"; + ArtifactIncrementalError, + createArtifactIncrementalReader, + type ArtifactIncrementalReader, +} from "./artifact-incremental-reader"; import { keypair, signed } from "./testing"; import { eventDto } from "./events"; const channel = "11111111-1111-4111-8111-111111111111"; @@ -29,7 +29,7 @@ const event = eventDto( ], }), ); -const old = JSON.stringify({ protocol: "sync-v1", checkpoint: "old" }); +const old = JSON.stringify({ protocol: "incremental-v1", checkpoint: "old" }); function setup(checkpoint: string | null = old) { const rows = new Map(); const controller = new AbortController(); @@ -78,12 +78,14 @@ function setup(checkpoint: string | null = old) { checkpoint: "next", }; const reader = { - delta: vi.fn(async () => page), - current: vi.fn(async (_query, ids) => ({ - version: 1, - startedAt: page.startedAt, - changes: ids.map((artifactId) => ({ type: "remove", artifactId })), - })), + delta: vi.fn(async () => page), + current: vi.fn( + async (_query, ids) => ({ + version: 1, + startedAt: page.startedAt, + changes: ids.map((artifactId) => ({ type: "remove", artifactId })), + }), + ), }; const host = collection as unknown as HostArtifactCollection; return { @@ -123,7 +125,7 @@ it("saves delta progress only after all pages commit", async () => { expect.objectContaining({ generation: "generation" }), { expected: old, - next: JSON.stringify({ protocol: "sync-v1", checkpoint: "next" }), + next: JSON.stringify({ protocol: "incremental-v1", checkpoint: "next" }), }, h.controller.signal, ); @@ -131,9 +133,11 @@ it("saves delta progress only after all pages commit", async () => { it("continues an incremental pass through the relay request validator", async () => { const h = setup(); const request = vi.fn( - async (filter: import("./artifact-reader").ArtifactSyncRequest) => { + async ( + filter: import("./artifact-incremental-reader").ArtifactIncrementalRequest, + ) => { if ("checkpoint" in filter && "cursor" in filter) - return Response.json({ code: "sync_invalid" }, { status: 400 }); + return Response.json({ code: "incremental_invalid" }, { status: 400 }); if ("cursor" in filter) return Response.json({ ...h.page, @@ -148,19 +152,19 @@ it("continues an incremental pass through the relay request validator", async () }, ); await reconcileArtifactDelta( - createArtifactSyncReader(request), + createArtifactIncrementalReader(request), h.collection as unknown as HostArtifactCollection, "test.task", h.controller.signal, ); expect(request.mock.calls.map(([filter]) => filter)).toEqual([ { - artifact: "sync", + artifact: "incremental", query: { artifact: "current", kinds: [45010], "#type": ["test.task"] }, checkpoint: "old", }, { - artifact: "sync", + artifact: "incremental", query: { artifact: "current", kinds: [45010], "#type": ["test.task"] }, cursor: "second", }, @@ -249,8 +253,8 @@ it.each(["policy_changed", "checkpoint_expired"])( }, }); h.reader.delta.mockRejectedValueOnce( - new ArtifactSyncError( - "sync_reset", + new ArtifactIncrementalError( + "incremental_reset", "Reset", reason as "policy_changed" | "checkpoint_expired", ), @@ -327,7 +331,8 @@ it("does not advance delta progress or clear its error on a current read", async it("splits a current batch on capacity and rejects an oversized single result", async () => { const h = setup(); h.reader.current.mockImplementation(async (_query, ids) => { - if (ids.length > 1) throw new ArtifactSyncError("sync_limit", "Large"); + if (ids.length > 1) + throw new ArtifactIncrementalError("incremental_limit", "Large"); return { version: 1, startedAt: h.page.startedAt, @@ -337,9 +342,11 @@ it("splits a current batch on capacity and rejects an oversized single result", await h.current([id, "imported"]); expect(h.reader.current).toHaveBeenCalledTimes(3); h.reader.current.mockRejectedValue( - new ArtifactSyncError("sync_limit", "Large"), + new ArtifactIncrementalError("incremental_limit", "Large"), ); - await expect(h.current([id])).rejects.toMatchObject({ code: "sync_limit" }); + await expect(h.current([id])).rejects.toMatchObject({ + code: "incremental_limit", + }); }); it("rejects repeated cursors and changed capture times", async () => { const h = setup(); diff --git a/src/features/relay/artifact-reconcile.ts b/src/features/relay/artifact-reconcile.ts index e49c7f5c79..caca718c53 100644 --- a/src/features/relay/artifact-reconcile.ts +++ b/src/features/relay/artifact-reconcile.ts @@ -4,11 +4,11 @@ import type { } from "../artifact-storage/types"; import type { RelayEvent } from "./events"; import { - ARTIFACT_SYNC_LIMITS, - ArtifactSyncError, - type ArtifactSyncChange, - type ArtifactSyncReader, -} from "./artifact-reader"; + ARTIFACT_INCREMENTAL_LIMITS, + ArtifactIncrementalError, + type ArtifactIncrementalChange, + type ArtifactIncrementalReader, +} from "./artifact-incremental-reader"; import type { ArtifactRefresh } from "../artifact-storage/types"; export function receivedArtifact( @@ -59,14 +59,17 @@ function savedCheckpoint(value: string | null): string | undefined { if (!value) return; try { const saved = JSON.parse(value); - if (saved?.protocol === "sync-v1" && typeof saved.checkpoint === "string") + if ( + saved?.protocol === "incremental-v1" && + typeof saved.checkpoint === "string" + ) return saved.checkpoint; } catch {} } async function applyChanges( collection: HostArtifactCollection, refresh: ArtifactRefresh, - changes: readonly ArtifactSyncChange[], + changes: readonly ArtifactIncrementalChange[], type: string, signal: AbortSignal, ) { @@ -134,7 +137,7 @@ async function applyChanges( await flush(); } async function currentBatch( - reader: ArtifactSyncReader, + reader: ArtifactIncrementalReader, collection: HostArtifactCollection, refresh: ArtifactRefresh, type: string, @@ -147,8 +150,8 @@ async function currentBatch( await applyChanges(collection, refresh, result.changes, type, signal); } catch (error) { if ( - !(error instanceof ArtifactSyncError) || - error.code !== "sync_limit" || + !(error instanceof ArtifactIncrementalError) || + error.code !== "incremental_limit" || ids.length === 1 ) throw error; @@ -172,7 +175,7 @@ async function currentBatch( } } async function revalidateKnown( - reader: ArtifactSyncReader, + reader: ArtifactIncrementalReader, collection: HostArtifactCollection, refresh: ArtifactRefresh, type: string, @@ -182,7 +185,7 @@ async function revalidateKnown( do { const page = await collection.knownIds({ ...(after !== undefined ? { after } : {}), - limit: ARTIFACT_SYNC_LIMITS.currentRows, + limit: ARTIFACT_INCREMENTAL_LIMITS.currentRows, }); signal.throwIfAborted(); if (page.ids.length) @@ -198,19 +201,20 @@ async function reportFailure( requested: boolean, ) { const permanent = - error instanceof ArtifactSyncError && - ["sync_limit", "sync_invalid"].includes(error.code); + error instanceof ArtifactIncrementalError && + ["incremental_limit", "incremental_invalid"].includes(error.code); if (!signal.aborted && (requested || permanent)) await collection.setRefreshError( refresh, - error instanceof ArtifactSyncError && error.code === "sync_limit" + error instanceof ArtifactIncrementalError && + error.code === "incremental_limit" ? "The relay limit prevents a refresh of saved data" : "Could not refresh saved data", signal, ); } export async function reconcileArtifactCurrent( - reader: ArtifactSyncReader, + reader: ArtifactIncrementalReader, collection: HostArtifactCollection, type: string, ids: readonly string[], @@ -222,14 +226,14 @@ export async function reconcileArtifactCurrent( for ( let offset = 0; offset < ids.length; - offset += ARTIFACT_SYNC_LIMITS.currentRows + offset += ARTIFACT_INCREMENTAL_LIMITS.currentRows ) await currentBatch( reader, collection, refresh, type, - ids.slice(offset, offset + ARTIFACT_SYNC_LIMITS.currentRows), + ids.slice(offset, offset + ARTIFACT_INCREMENTAL_LIMITS.currentRows), signal, ); signal.throwIfAborted(); @@ -245,7 +249,7 @@ export async function reconcileArtifactCurrent( } } export async function reconcileArtifactDelta( - reader: ArtifactSyncReader, + reader: ArtifactIncrementalReader, collection: HostArtifactCollection, type: string, signal: AbortSignal, @@ -292,8 +296,8 @@ export async function reconcileArtifactDelta( break; } catch (error) { if ( - !(error instanceof ArtifactSyncError) || - error.code !== "sync_reset" || + !(error instanceof ArtifactIncrementalError) || + error.code !== "incremental_reset" || recovery ) throw error; @@ -308,7 +312,7 @@ export async function reconcileArtifactDelta( refresh, { expected, - next: JSON.stringify({ protocol: "sync-v1", checkpoint: next }), + next: JSON.stringify({ protocol: "incremental-v1", checkpoint: next }), }, signal, ); diff --git a/src/features/relay/artifact-sync.test.ts b/src/features/relay/artifact-store-sync.test.ts similarity index 97% rename from src/features/relay/artifact-sync.test.ts rename to src/features/relay/artifact-store-sync.test.ts index 4ff9a287fa..2be5af7e8c 100644 --- a/src/features/relay/artifact-sync.test.ts +++ b/src/features/relay/artifact-store-sync.test.ts @@ -4,8 +4,8 @@ import type { ArtifactStorageService, OpenArtifactCollection, } from "../artifact-storage/service"; -import { provideArtifactSync } from "./artifact-sync"; -import { ArtifactSyncError } from "./artifact-reader"; +import { provideArtifactStoreSync } from "./artifact-store-sync"; +import { ArtifactIncrementalError } from "./artifact-incremental-reader"; import { reconcileArtifactDelta, reconcileArtifactCurrent, @@ -39,7 +39,7 @@ function setup() { let online = true; let scoped = true; const session = { - artifactSync: {}, + artifactIncremental: {}, live: { snapshot: () => ({ status: online ? "connected" : "retrying" }), subscribe: (listener: () => void) => { @@ -87,7 +87,7 @@ function setup() { stops.push(effect()); }, }; - provideArtifactSync( + provideArtifactStoreSync( ctx as unknown as Context, { onOpen: (listener: typeof opened) => { @@ -263,7 +263,7 @@ it("coalesces live invalidations during a capture into one later refresh", async it("keeps capture admission failures on the repair interval despite live traffic", async () => { const h = setup(); vi.mocked(reconcileArtifactDelta).mockRejectedValue( - new ArtifactSyncError("sync_limit", "Too many memberships"), + new ArtifactIncrementalError("incremental_limit", "Too many memberships"), ); h.add(); await vi.advanceTimersByTimeAsync(0); @@ -376,7 +376,7 @@ it("keeps capacity backoff when a live read starts before the repair timer", asy h.add(); await vi.advanceTimersByTimeAsync(30_000); vi.mocked(reconcileArtifactCurrent).mockRejectedValueOnce( - new ArtifactSyncError("sync_limit", "Large"), + new ArtifactIncrementalError("incremental_limit", "Large"), ); const event = eventDto( signed(keypair(), { diff --git a/src/features/relay/artifact-sync.ts b/src/features/relay/artifact-store-sync.ts similarity index 93% rename from src/features/relay/artifact-sync.ts rename to src/features/relay/artifact-store-sync.ts index f49b4462e2..312fdce6e6 100644 --- a/src/features/relay/artifact-sync.ts +++ b/src/features/relay/artifact-store-sync.ts @@ -9,7 +9,10 @@ import { reconcileArtifactDelta, reconcileArtifactCurrent, } from "./artifact-reconcile"; -import { ArtifactSyncError, ARTIFACT_SYNC_LIMITS } from "./artifact-reader"; +import { + ArtifactIncrementalError, + ARTIFACT_INCREMENTAL_LIMITS, +} from "./artifact-incremental-reader"; import { transportPartition } from "./partition"; import { RECOVERY_DELAYS_MS } from "./recovery"; @@ -23,7 +26,7 @@ type Group = { }; /** The existing session supplies live traffic. Web Locks transfer sync work between windows. */ -export function provideArtifactSync( +export function provideArtifactStoreSync( ctx: Context, storage: ArtifactStorageService, ) { @@ -148,7 +151,7 @@ export function provideArtifactSync( !group.entries.size || group.lifetime.signal.aborted || !active() || - !session?.artifactSync || + !session?.artifactIncremental || (!requested && !dirty.size) ) return; @@ -159,7 +162,7 @@ export function provideArtifactSync( clearTimeout(timer); timer = undefined; const current = session; - const reader = current.artifactSync; + const reader = current.artifactIncremental; if (!reader) return; const entry = [...group.entries][0]; if (!entry || entry.signal.aborted) return; @@ -186,7 +189,10 @@ export function provideArtifactSync( throw error; } } - const ids = [...dirty].slice(0, ARTIFACT_SYNC_LIMITS.currentRows); + const ids = [...dirty].slice( + 0, + ARTIFACT_INCREMENTAL_LIMITS.currentRows, + ); if (ids.length) { for (const id of ids) dirty.delete(id); try { @@ -224,8 +230,12 @@ export function provideArtifactSync( } else if (!signal.aborted) { retry++; const permanent = - error instanceof ArtifactSyncError && - ["sync_limit", "sync_invalid"].includes(error.code); + error instanceof ArtifactIncrementalError && + [ + "incremental_limit", + "incremental_invalid", + "incremental_unsupported_scope", + ].includes(error.code); const delay = !permanent && retry <= 3 ? (RECOVERY_DELAYS_MS[retry - 1] ?? REPAIR_MS) diff --git a/src/features/relay/native.test.ts b/src/features/relay/native.test.ts index 4879ed2069..f620460360 100644 --- a/src/features/relay/native.test.ts +++ b/src/features/relay/native.test.ts @@ -1,4 +1,7 @@ -import { ARTIFACT_SYNC_LIMITS, ArtifactSyncError } from "./artifact-reader"; +import { + ARTIFACT_INCREMENTAL_LIMITS, + ArtifactIncrementalError, +} from "./artifact-incremental-reader"; import { ReadStateTimestampRejected, READ_STATE_TIMESTAMP_REFUSAL, @@ -2084,22 +2087,25 @@ it("binds audience authority to the advertised community and rejects unknown ver ).toBeUndefined(); }); -it.each(["sync_limit", "sync_reset", "sync_invalid", "sync_unavailable"])( - "preserves %s through native admission", - async (code) => { - discovery = { artifacts: { sync: ARTIFACT_SYNC_LIMITS } }; - const transport = await connectNativeTransport(community); - respond = () => ({ - status: 422, - body: { error: "scope or capacity", code }, - }); - assert.exists(transport.artifactSync); - const work = transport.artifactSync.delta( - { artifact: "current", kinds: [45010], "#type": ["task"] }, - {}, - new AbortController().signal, - ); - await expect(work).rejects.toBeInstanceOf(ArtifactSyncError); - await expect(work).rejects.toMatchObject({ code }); - }, -); +it.each([ + "incremental_limit", + "incremental_reset", + "incremental_invalid", + "incremental_unsupported_scope", + "incremental_unavailable", +])("preserves %s through native admission", async (code) => { + discovery = { artifacts: { incremental: ARTIFACT_INCREMENTAL_LIMITS } }; + const transport = await connectNativeTransport(community); + respond = () => ({ + status: 422, + body: { error: "scope or capacity", code }, + }); + assert.exists(transport.artifactIncremental); + const work = transport.artifactIncremental.delta( + { artifact: "current", kinds: [45010], "#type": ["task"] }, + {}, + new AbortController().signal, + ); + await expect(work).rejects.toBeInstanceOf(ArtifactIncrementalError); + await expect(work).rejects.toMatchObject({ code }); +}); diff --git a/src/features/relay/native.ts b/src/features/relay/native.ts index 7c0d1ff7ae..af4173bf01 100644 --- a/src/features/relay/native.ts +++ b/src/features/relay/native.ts @@ -1,9 +1,9 @@ import { audienceAuthorityCommunity } from "../sessions/audience-authority"; import { - artifactSyncCapability, - artifactSyncFailure, - createArtifactSyncReader, -} from "./artifact-reader"; + artifactIncrementalCapability, + artifactIncrementalFailure, + createArtifactIncrementalReader, +} from "./artifact-incremental-reader"; import { Channel, convertFileSrc, invoke } from "@tauri-apps/api/core"; import type { EventTemplate } from "nostr-tools"; import { communityDestination, relayOrigin } from "../communities/destination"; @@ -400,17 +400,18 @@ export async function connectNativeTransport( // Capabilities describe implemented host operations, not everything this key can sign. return { ...transport, - ...(artifactSyncCapability(info) + ...(artifactIncrementalCapability(info) ? { - artifactSync: createArtifactSyncReader((filter, signal) => - admitSignedRequest( - origin, - transport.viewer, - () => nativeRelayRequest(origin, "/query", [filter]), - signal, - "background", - artifactSyncFailure, - ), + artifactIncremental: createArtifactIncrementalReader( + (filter, signal) => + admitSignedRequest( + origin, + transport.viewer, + () => nativeRelayRequest(origin, "/query", [filter]), + signal, + "background", + artifactIncrementalFailure, + ), ), } : {}), diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 5628954704..cc5e602903 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -1765,7 +1765,7 @@ export function createRelaySession( liveWasConnected = connected; }); const session = Object.freeze({ - artifactSync: transport?.artifactSync, + artifactIncremental: transport?.artifactIncremental, memberAdditions, presence, viewer: transport?.viewer, diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index fa39c89e50..af9997b22a 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -1,5 +1,5 @@ import { audienceAuthorityCommunity } from "../sessions/audience-authority"; -import type { ArtifactSyncReader } from "./artifact-reader.ts"; +import type { ArtifactIncrementalReader } from "./artifact-incremental-reader.ts"; import type { ReminderHost } from "./reminders"; import { memoryResponseText, @@ -84,7 +84,7 @@ export interface RelayWriter { ): Promise | Promise; } export interface ReadTransport { - readonly artifactSync?: ArtifactSyncReader; + readonly artifactIncremental?: ArtifactIncrementalReader; readonly projectGit?: RepoGit; readonly readAgentMemories?: MemoryReader; /** Session-scoped owner proof, not an arbitrary signing capability. */ diff --git a/tests/fixtures/artifact-sync.ts b/tests/fixtures/artifact-sync.ts index 82d40a8146..769acda3b3 100644 --- a/tests/fixtures/artifact-sync.ts +++ b/tests/fixtures/artifact-sync.ts @@ -5,8 +5,8 @@ import type { } from "../../src/features/artifact-storage/service"; import { createArtifactStore } from "../../src/features/artifact-storage/store"; import type { HostArtifactCollection } from "../../src/features/artifact-storage/types"; -import type { ArtifactSyncReader } from "../../src/features/relay/artifact-reader"; -import { provideArtifactSync } from "../../src/features/relay/artifact-sync"; +import type { ArtifactIncrementalReader } from "../../src/features/relay/artifact-incremental-reader"; +import { provideArtifactStoreSync } from "../../src/features/relay/artifact-store-sync"; const viewer = "a".repeat(64); const captures: string[] = []; @@ -23,7 +23,7 @@ const api = { open(scope: string) { relay = `https://${scope}.test`; store = createArtifactStore(viewer, relay); - const reader: ArtifactSyncReader = { + const reader: ArtifactIncrementalReader = { async delta(query) { captures.push(query["#type"][0]?.slice("test.sync.".length) ?? ""); return { @@ -45,7 +45,7 @@ const api = { }, }; const session = { - artifactSync: reader, + artifactIncremental: reader, live: { snapshot: () => ({ status: "connected" }), subscribe: () => () => {}, @@ -67,7 +67,7 @@ const api = { stop = effect(); }, }; - provideArtifactSync( + provideArtifactStoreSync( ctx as unknown as Context, { onOpen: (listener: typeof opened) => { From 0355d159ce95456788c9a3fc8e01c12f68364263 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 28 Oct 2026 15:05:46 -0700 Subject: [PATCH 21/30] Test failed artifact index upgrades Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- tests/browser/artifact-storage.spec.mjs | 41 ++++++++++++++++--------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/tests/browser/artifact-storage.spec.mjs b/tests/browser/artifact-storage.spec.mjs index 0efdf77936..b99d7d902d 100644 --- a/tests/browser/artifact-storage.spec.mjs +++ b/tests/browser/artifact-storage.spec.mjs @@ -140,30 +140,40 @@ test("rolls back received batches and migration failures without publishing chan ); const after = await c.get("good"); const checkpoint = await c.checkpoint(); + const original = window.artifactTest.open("viewer", "relay", "test", { + name: "migration", + }); + await original.importBatch({ + records: [{ id: "one", local: { title: "saved" } }], + preferences: [{ id: "one", value: false }], + marker: "done", + }); + const refresh = await original.beginRefresh("all"); + await original.completeRefresh(refresh, { expected: null, next: "saved" }); const failing = window.artifactTest.open("viewer", "relay", "test", { name: "migration", + version: 2, project: () => { throw new Error("projection failed"); }, }); - const migration = await failing - .importBatch({ - records: [{ id: "one", local: {} }], - preferences: [{ id: "one", value: false }], - marker: "done", - }) - .then( - () => "unexpected", - (error) => error.message, - ); + const migration = await failing.get("one").then( + () => "unexpected", + (error) => error.message, + ); return { failure, after, checkpoint, notices, migration, - imported: await failing.imported("done"), - pref: await failing.preferences.get("one"), + imported: await original.imported("done"), + pref: await original.preferences.get("one"), + saved: (await original.get("one")).record.local, + progress: await original.checkpoint(), + indexed: ( + await original.page({ index: "archive", value: "false" }) + ).entries.map((entry) => entry.record.id), }; }); expect(result).toEqual({ @@ -172,8 +182,11 @@ test("rolls back received batches and migration failures without publishing chan checkpoint: null, notices: [], migration: "projection failed", - imported: false, - pref: undefined, + imported: true, + pref: false, + saved: { title: "saved" }, + progress: "saved", + indexed: ["one"], }); }); From cf2efd7bde7d69756e6a48033270f96da55c0a83 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 28 Oct 2026 15:07:30 -0700 Subject: [PATCH 22/30] Keep artifact import rollback coverage Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- tests/browser/artifact-storage.spec.mjs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/browser/artifact-storage.spec.mjs b/tests/browser/artifact-storage.spec.mjs index b99d7d902d..cd06b0c8f2 100644 --- a/tests/browser/artifact-storage.spec.mjs +++ b/tests/browser/artifact-storage.spec.mjs @@ -140,6 +140,22 @@ test("rolls back received batches and migration failures without publishing chan ); const after = await c.get("good"); const checkpoint = await c.checkpoint(); + const failedImport = window.artifactTest.open("viewer", "relay", "test", { + name: "failed-import", + project: () => { + throw new Error("import projection failed"); + }, + }); + const importFailure = await failedImport + .importBatch({ + records: [{ id: "one", local: {} }], + preferences: [{ id: "one", value: false }], + marker: "done", + }) + .then( + () => "unexpected", + (error) => error.message, + ); const original = window.artifactTest.open("viewer", "relay", "test", { name: "migration", }); @@ -167,6 +183,12 @@ test("rolls back received batches and migration failures without publishing chan checkpoint, notices, migration, + importFailure, + failedImport: { + row: await failedImport.get("one"), + marker: await failedImport.imported("done"), + preference: await failedImport.preferences.get("one"), + }, imported: await original.imported("done"), pref: await original.preferences.get("one"), saved: (await original.get("one")).record.local, @@ -182,6 +204,8 @@ test("rolls back received batches and migration failures without publishing chan checkpoint: null, notices: [], migration: "projection failed", + importFailure: "import projection failed", + failedImport: { row: undefined, marker: false, preference: undefined }, imported: true, pref: false, saved: { title: "saved" }, From 0bfc54005ce7f032b26f4edd8cfa43d0a5a3c448 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 28 Oct 2026 15:33:52 -0700 Subject: [PATCH 23/30] Keep artifact scale checks local Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/browser-testing.md | 2 ++ tests/browser/artifact-storage.spec.mjs | 2 +- tests/browser/playwright.ci.config.mjs | 14 +++++++------- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/browser-testing.md b/docs/browser-testing.md index 9cca523a84..3c49964393 100644 --- a/docs/browser-testing.md +++ b/docs/browser-testing.md @@ -116,6 +116,8 @@ verified clean commit or source manifest. ## CI coverage and local-only WebKit checks +The artifact storage scale case has the `@local-only` tag. CI excludes it in both engines. It seeds 50,000 records and measures indexed reads and write latency. Run it locally with `bin/pnpm test:browser tests/browser/artifact-storage.spec.mjs --project chromium --project webkit --no-deps --workers=1 --grep @local-only`. The other 13 artifact storage cases remain in CI in both engines. + Most CI runs on `ubuntu-24.04`. `pnpm test:browser:ci` inherits the ordinary config and excludes tests tagged `@local-webkit` from `webkit-measurements`. Their Chromium instances remain required. WebKit cases tagged `@media-recorder` run instead in diff --git a/tests/browser/artifact-storage.spec.mjs b/tests/browser/artifact-storage.spec.mjs index cd06b0c8f2..a67e63cf4b 100644 --- a/tests/browser/artifact-storage.spec.mjs +++ b/tests/browser/artifact-storage.spec.mjs @@ -448,7 +448,7 @@ test("applies explicit removals after missed revisions and rejects a failed page expect(result.restored.preference).toBe(false); }); -test("reads bounded indexed pages for 10,000 tasks and 40,000 archived records", async ({ +test("reads bounded indexed pages for 10,000 tasks and 40,000 archived records @local-only", async ({ page, storageUrl, }) => { diff --git a/tests/browser/playwright.ci.config.mjs b/tests/browser/playwright.ci.config.mjs index 4df8e18a42..961b1f959a 100644 --- a/tests/browser/playwright.ci.config.mjs +++ b/tests/browser/playwright.ci.config.mjs @@ -6,11 +6,11 @@ import config from "./playwright.config.mjs"; // The documented @local-webkit measurements remain local-only. export default defineConfig({ ...config, - projects: config.projects.map((project) => - project.name === "webkit-measurements" - ? { ...project, grepInvert: /@local-webkit/ } - : project.name === "webkit" - ? { ...project, grepInvert: /@classic-scrollbars|@media-recorder/ } - : project, - ), + projects: config.projects.map((project) => { + const excluded = [/@local-only/]; + if (project.grepInvert) excluded.push(...[project.grepInvert].flat()); + if (project.name === "webkit-measurements") excluded.push(/@local-webkit/); + if (project.name === "webkit") excluded.push(/@media-recorder/); + return { ...project, grepInvert: excluded }; + }), }); From 59bae1ccead85d38d40e4aa35e03a505f89cd27e Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 28 Oct 2026 15:44:25 -0700 Subject: [PATCH 24/30] Align CI discovery with local scale checks Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- tests/browser/playwright.ci.config.mjs | 19 ++++++++++++------- tests/integration/browser-ci.test.mjs | 6 ++++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/browser/playwright.ci.config.mjs b/tests/browser/playwright.ci.config.mjs index 961b1f959a..c4c52737f0 100644 --- a/tests/browser/playwright.ci.config.mjs +++ b/tests/browser/playwright.ci.config.mjs @@ -6,11 +6,16 @@ import config from "./playwright.config.mjs"; // The documented @local-webkit measurements remain local-only. export default defineConfig({ ...config, - projects: config.projects.map((project) => { - const excluded = [/@local-only/]; - if (project.grepInvert) excluded.push(...[project.grepInvert].flat()); - if (project.name === "webkit-measurements") excluded.push(/@local-webkit/); - if (project.name === "webkit") excluded.push(/@media-recorder/); - return { ...project, grepInvert: excluded }; - }), + projects: config.projects.map((project) => + project.name === "webkit-measurements" + ? { ...project, grepInvert: /@local-webkit/ } + : project.name === "webkit" + ? { + ...project, + grepInvert: /@classic-scrollbars|@media-recorder|@local-only/, + } + : project.name === "chromium" + ? { ...project, grepInvert: /@classic-scrollbars|@local-only/ } + : project, + ), }); diff --git a/tests/integration/browser-ci.test.mjs b/tests/integration/browser-ci.test.mjs index 22812f6eb2..1c76bb21a9 100644 --- a/tests/integration/browser-ci.test.mjs +++ b/tests/integration/browser-ci.test.mjs @@ -54,7 +54,7 @@ const browserJobs = () => { }); }; -test("Linux shards and macOS recording discover every functional test/project exactly once", (t) => { +test("Linux shards and macOS recording discover every CI functional test/project exactly once", (t) => { const command = browser.match( /^ {8}run: .+ -- (\.\/bin\/pnpm test:browser:ci .+)$/m, )?.[1]; @@ -86,6 +86,8 @@ test("Linux shards and macOS recording discover every functional test/project ex "--project", "webkit", "--no-deps", + "--grep-invert", + "@classic-scrollbars|@local-only", ]); assert.ok(expected.length > 0); const actual = []; @@ -127,7 +129,7 @@ test("Linux shards and macOS recording discover every functional test/project ex assert.deepEqual( actual.sort(), expected.sort(), - "Linux and macOS must cover full local functional discovery", + "Linux and macOS must cover functional discovery without local-only cases", ); }); From 1f91e2a8b54ce4882b68804c151f585abc107197 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 28 Oct 2026 16:09:54 -0700 Subject: [PATCH 25/30] Publish artifact commits after handle close Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/browser-testing.md | 2 +- src/features/artifact-storage/store.ts | 7 +- tests/browser/artifact-storage.spec.mjs | 101 ++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/docs/browser-testing.md b/docs/browser-testing.md index 3c49964393..aaaf13bfda 100644 --- a/docs/browser-testing.md +++ b/docs/browser-testing.md @@ -116,7 +116,7 @@ verified clean commit or source manifest. ## CI coverage and local-only WebKit checks -The artifact storage scale case has the `@local-only` tag. CI excludes it in both engines. It seeds 50,000 records and measures indexed reads and write latency. Run it locally with `bin/pnpm test:browser tests/browser/artifact-storage.spec.mjs --project chromium --project webkit --no-deps --workers=1 --grep @local-only`. The other 13 artifact storage cases remain in CI in both engines. +The artifact storage scale case has the `@local-only` tag. CI excludes it in both engines. It seeds 50,000 records and measures indexed reads and write latency. Run it locally with `bin/pnpm test:browser tests/browser/artifact-storage.spec.mjs --project chromium --project webkit --no-deps --workers=1 --grep @local-only`. The other 15 artifact storage cases remain in CI in both engines. Most CI runs on `ubuntu-24.04`. `pnpm test:browser:ci` inherits the ordinary config and excludes tests tagged `@local-webkit` from `webkit-measurements`. Their Chromium diff --git a/src/features/artifact-storage/store.ts b/src/features/artifact-storage/store.ts index 05428edcca..ab3d54bd1b 100644 --- a/src/features/artifact-storage/store.ts +++ b/src/features/artifact-storage/store.ts @@ -192,7 +192,8 @@ export function createArtifactStore(viewer: string, relay: string) { let disposed = false; const lifetime = new AbortController(); const listeners = new Set<() => void>(); - const channel = new BroadcastChannel(`${DB}:${JSON.stringify(prefix)}`); + const channelName = `${DB}:${JSON.stringify(prefix)}`; + const channel = new BroadcastChannel(channelName); const changed = () => { for (const listener of listeners) queueMicrotask(() => { @@ -204,6 +205,10 @@ export function createArtifactStore(viewer: string, relay: string) { if (!disposed) { changed(); channel.postMessage(null); + } else { + const notification = new BroadcastChannel(channelName); + notification.postMessage(null); + notification.close(); } }; function reindex( diff --git a/tests/browser/artifact-storage.spec.mjs b/tests/browser/artifact-storage.spec.mjs index a67e63cf4b..4575f70988 100644 --- a/tests/browser/artifact-storage.spec.mjs +++ b/tests/browser/artifact-storage.spec.mjs @@ -105,6 +105,107 @@ test("commits across windows and survives offline restart with isolated scopes", expect(result[field]).toBeUndefined(); }); +test("notifies another window when a committed writer closes", async ({ + page, + context, + storageUrl, +}) => { + await open(page, storageUrl); + const other = await context.newPage(); + await open(other, storageUrl); + await other.evaluate(async () => { + const c = window.artifactTest.open(); + await c.get("saved"); + window.committedValue = null; + c.subscribe(async () => { + window.committedValue = (await c.get("saved"))?.record.local; + }); + }); + const closed = await page.evaluate(async () => { + const c = window.artifactTest.open(); + await c.get("saved"); + const transaction = IDBDatabase.prototype.transaction; + let closed = false; + IDBDatabase.prototype.transaction = function (...args) { + const tx = transaction.apply(this, args); + IDBDatabase.prototype.transaction = transaction; + tx.addEventListener( + "complete", + () => { + c.close(); + closed = true; + }, + { once: true }, + ); + return tx; + }; + try { + await c.importBatch({ + records: [{ id: "saved", local: { title: "saved" } }], + }); + return closed; + } finally { + IDBDatabase.prototype.transaction = transaction; + } + }); + expect(closed).toBe(true); + await expect + .poll(() => other.evaluate(() => window.committedValue)) + .toEqual({ title: "saved" }); +}); + +test("continues indexed pages and raw ID pages through their final cursors", async ({ + page, + storageUrl, +}) => { + await open(page, storageUrl); + const result = await page.evaluate(async () => { + const c = window.artifactTest.open("viewer", "relay", "test", { + project: () => [{ index: "all", value: "", order: "same" }], + }); + await c.importBatch({ + records: ["a", "b", "c"].map((id) => ({ id, local: {} })), + }); + const first = await c.page({ index: "all", value: "", limit: 2 }); + const last = await c.page({ + index: "all", + value: "", + limit: 2, + after: first.next, + }); + const empty = await c.page({ + index: "all", + value: "", + after: { order: "same", id: "c" }, + }); + const ids = await c.knownIds({ limit: 2 }); + const lastIds = await c.knownIds({ limit: 2, after: ids.next }); + const emptyIds = await c.knownIds({ after: "c" }); + return { + first: { + ids: first.entries.map((entry) => entry.record.id), + next: first.next, + }, + last: { + ids: last.entries.map((entry) => entry.record.id), + next: last.next, + }, + empty, + ids, + lastIds, + emptyIds, + }; + }); + expect(result).toEqual({ + first: { ids: ["a", "b"], next: { order: "same", id: "b" } }, + last: { ids: ["c"], next: undefined }, + empty: { entries: [] }, + ids: { ids: ["a", "b"], next: "b" }, + lastIds: { ids: ["c"] }, + emptyIds: { ids: [] }, + }); +}); + test("rolls back received batches and migration failures without publishing changes", async ({ page, storageUrl, From 02c9cb765fb2176d8a68294a77cf9dffd92bc1ac Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 28 Oct 2026 20:08:20 -0700 Subject: [PATCH 26/30] Report unsupported incremental scope at once Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- src/features/relay/artifact-reconcile.test.ts | 22 +++++++++++++++++++ src/features/relay/artifact-reconcile.ts | 6 ++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/features/relay/artifact-reconcile.test.ts b/src/features/relay/artifact-reconcile.test.ts index 1759ddede1..e707ca58ee 100644 --- a/src/features/relay/artifact-reconcile.test.ts +++ b/src/features/relay/artifact-reconcile.test.ts @@ -328,6 +328,28 @@ it("does not advance delta progress or clear its error on a current read", async expect(h.collection.completeRefresh).not.toHaveBeenCalled(); expect(h.collection.checkpoint).not.toHaveBeenCalled(); }); +it.each(["delta", "current"] as const)( + "reports unsupported scope on the first %s failure and preserves progress", + async (operation) => { + const h = setup(); + const error = new ArtifactIncrementalError( + "incremental_unsupported_scope", + "Unsupported scope", + ); + h.reader[operation].mockRejectedValue(error); + await expect( + operation === "delta" ? h.delta() : h.current([id]), + ).rejects.toBe(error); + expect(h.reader[operation]).toHaveBeenCalledOnce(); + expect(h.collection.setRefreshError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ generation: "generation" }), + "Could not refresh saved data", + h.controller.signal, + ); + expect(h.collection.completeRefresh).not.toHaveBeenCalled(); + expect(await h.collection.checkpoint()).toBe(old); + }, +); it("splits a current batch on capacity and rejects an oversized single result", async () => { const h = setup(); h.reader.current.mockImplementation(async (_query, ids) => { diff --git a/src/features/relay/artifact-reconcile.ts b/src/features/relay/artifact-reconcile.ts index caca718c53..02e6bb8c04 100644 --- a/src/features/relay/artifact-reconcile.ts +++ b/src/features/relay/artifact-reconcile.ts @@ -202,7 +202,11 @@ async function reportFailure( ) { const permanent = error instanceof ArtifactIncrementalError && - ["incremental_limit", "incremental_invalid"].includes(error.code); + [ + "incremental_limit", + "incremental_invalid", + "incremental_unsupported_scope", + ].includes(error.code); if (!signal.aborted && (requested || permanent)) await collection.setRefreshError( refresh, From 5c5a7383bde5fc3a3cd3b265b25dbbb2be44b4b9 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 29 Oct 2026 13:28:14 -0700 Subject: [PATCH 27/30] Limit channel removal batches to 100 records Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- src/features/relay/artifact-reconcile.test.ts | 89 +++++++++++++++++-- src/features/relay/artifact-reconcile.ts | 2 +- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/features/relay/artifact-reconcile.test.ts b/src/features/relay/artifact-reconcile.test.ts index e707ca58ee..e6a3a890a6 100644 --- a/src/features/relay/artifact-reconcile.test.ts +++ b/src/features/relay/artifact-reconcile.test.ts @@ -41,11 +41,21 @@ function setup(checkpoint: string | null = old) { })), setRefreshError: vi.fn(async () => {}), get: vi.fn(async (id: string) => rows.get(id)), - knownIds: vi.fn(async ({ channelId }: { channelId?: string }) => ({ - ids: [...rows] - .filter(([, row]) => !channelId || row.record.channelId === channelId) - .map(([key]) => key), - })), + knownIds: vi.fn( + async ({ channelId, after, limit }) => { + const ids = [...rows] + .filter(([, row]) => !channelId || row.record.channelId === channelId) + .map(([key]) => key) + .sort() + .filter((key) => after === undefined || key > after); + const page = ids.slice(0, limit); + const next = ids.length > page.length ? page.at(-1) : undefined; + return { + ids: page, + ...(next !== undefined ? { next } : {}), + }; + }, + ), applyBatch: vi.fn(async (batch) => { for (const item of batch.records ?? []) rows.set(item.id, { @@ -307,6 +317,75 @@ it("keeps a moved artifact when the old channel is removed later", async () => { availability: { state: "available" }, }); }); +it.each([100, 101, 201])( + "removes %s saved channel records in batches of at most 100", + async (count) => { + const h = setup(); + for (let index = 0; index < count; index++) { + const id = `saved-${String(index).padStart(3, "0")}`; + h.rows.set(id, { + record: { + id, + channelId: channel, + current: { id: `revision-${id}`, content: "saved" }, + availability: { state: "available" }, + }, + preference: { pinned: true }, + }); + } + h.reader.delta.mockResolvedValue({ + ...h.page, + changes: [{ type: "removeChannel", channelId: channel }], + }); + await h.delta(); + const batches = h.collection.applyBatch.mock.calls.map(([batch]) => batch); + expect(batches.map((batch) => batch.availability?.length)).toEqual( + count === 100 ? [100] : count === 101 ? [100, 1] : [100, 100, 1], + ); + expect(batches.flatMap((batch) => batch.availability ?? [])).toEqual( + [...h.rows.keys()].map((id) => ({ + id, + expectedRevision: `revision-${id}`, + value: { state: "unavailable" }, + })), + ); + for (const row of h.rows.values()) { + expect(row.record.availability.state).toBe("unavailable"); + expect(row.record.current?.content).toBe("saved"); + expect(row.preference).toEqual({ pinned: true }); + } + expect( + h.collection.applyBatch.mock.invocationCallOrder.at(-1), + ).toBeLessThan( + h.collection.completeRefresh.mock.invocationCallOrder[0] as number, + ); + }, +); +it("keeps progress when a later channel removal batch fails", async () => { + const h = setup(); + for (let index = 0; index < 101; index++) { + const id = `saved-${String(index).padStart(3, "0")}`; + h.rows.set(id, { + record: { id, channelId: channel, availability: { state: "available" } }, + }); + } + h.reader.delta.mockResolvedValue({ + ...h.page, + changes: [{ type: "removeChannel", channelId: channel }], + }); + const apply = h.collection.applyBatch; + h.collection.applyBatch = vi + .fn() + .mockImplementationOnce(apply) + .mockRejectedValueOnce(new Error("Artifact revision changed")); + await expect(h.delta()).rejects.toThrow("Artifact revision changed"); + expect(h.rows.get("saved-099")?.record.availability.state).toBe( + "unavailable", + ); + expect(h.rows.get("saved-100")?.record.availability.state).toBe("available"); + expect(h.collection.completeRefresh).not.toHaveBeenCalled(); + expect(await h.collection.checkpoint()).toBe(old); +}); it("restores an upsert after removal of its old channel", async () => { const h = setup(); h.rows.set(id, { diff --git a/src/features/relay/artifact-reconcile.ts b/src/features/relay/artifact-reconcile.ts index 02e6bb8c04..8cf7f24578 100644 --- a/src/features/relay/artifact-reconcile.ts +++ b/src/features/relay/artifact-reconcile.ts @@ -125,7 +125,7 @@ async function applyChanges( const page = await collection.knownIds({ channelId: change.channelId, ...(after !== undefined ? { after } : {}), - limit: 1000, + limit: 100, }); signal.throwIfAborted(); for (const id of page.ids) await remove(id, change.channelId); From eb518c3f7c022394cc014e74a9b908b522aa2b07 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Fri, 30 Oct 2026 10:13:56 -0700 Subject: [PATCH 28/30] Spread long artifact sync retries over time Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/artifact-sync.md | 2 +- .../relay/artifact-store-sync.test.ts | 60 +++++++++++++++++++ src/features/relay/artifact-store-sync.ts | 2 +- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/docs/artifact-sync.md b/docs/artifact-sync.md index 13080d2e18..8a8e0709c7 100644 --- a/docs/artifact-sync.md +++ b/docs/artifact-sync.md @@ -12,6 +12,6 @@ The client applies signed upserts and explicit removals. A removal uses the loca A policy change or an expired checkpoint requires a fresh query plus bounded revalidation of saved IDs. Missing, unmatched, and unauthorized IDs return the same unavailable result. Recovery saves progress only after both steps succeed. It does not return a hidden roster. -Refresh generations, schema checks, and owner cancellation fence each commit. Failed reads keep saved progress and data. Transient failures get three short retries, then a 60-second delay. Invalid replies and capacity failures use that delay directly. A persistent failure exposes `refreshError()`. Successful delta completion clears it. A 60-second repair runs independently of live traffic. A bounded set holds up to 1,000 live IDs; overflow requests another delta. +Refresh generations, schema checks, and owner cancellation fence each commit. Failed reads keep saved progress and data. Transient failures get three short retries, then a delay of 60–90 seconds. Invalid replies and capacity failures use that delay directly. Each long retry adds a fresh random delay of less than 30 seconds to the 60-second minimum. A persistent failure exposes `refreshError()`. Successful delta completion clears it. A 60-second repair runs independently of live traffic and respects a pending retry delay. A bounded set holds up to 1,000 live IDs; overflow requests another delta. The 120-second overlap assumes bounded database delay. It does not prove recovery from every delayed commit. TTL retirement is outside this contract. Native end-to-end verification needs the compatible relay and client together. diff --git a/src/features/relay/artifact-store-sync.test.ts b/src/features/relay/artifact-store-sync.test.ts index 2be5af7e8c..2460b8fc66 100644 --- a/src/features/relay/artifact-store-sync.test.ts +++ b/src/features/relay/artifact-store-sync.test.ts @@ -24,10 +24,12 @@ afterEach(async () => { vi.mocked(reconcileArtifactDelta).mockReset().mockResolvedValue(undefined); vi.mocked(reconcileArtifactCurrent).mockReset().mockResolvedValue(undefined); vi.unstubAllGlobals(); + vi.restoreAllMocks(); vi.useRealTimers(); }); function setup() { vi.useFakeTimers(); + vi.spyOn(Math, "random").mockReturnValue(0); vi.stubGlobal("navigator", { locks: { request: vi.fn(async (_key, _options, callback) => callback()) }, }); @@ -285,6 +287,64 @@ it("keeps capture admission failures on the repair interval despite live traffic await vi.advanceTimersByTimeAsync(60_000); expect(reconcileArtifactDelta).toHaveBeenCalledTimes(2); }); +it.each([ + [0, 60_000], + [0.5, 75_000], + [0.99999, 89_999], +])( + "keeps random draw %s behind its %s ms retry deadline", + async (draw, delay) => { + const h = setup(); + vi.spyOn(Math, "random").mockReturnValueOnce(draw).mockReturnValue(0); + vi.mocked(reconcileArtifactDelta).mockRejectedValue( + new ArtifactIncrementalError("incremental_limit", "Capture limit"), + ); + h.add(); + await vi.advanceTimersByTimeAsync(0); + const event = eventDto( + signed(keypair(), { + kind: 45010, + content: "{}", + tags: [ + ["type", "test.task"], + ["d", "task"], + ], + }), + ); + await vi.advanceTimersByTimeAsync(delay - 1); + h.confirmed(event); + await vi.advanceTimersByTimeAsync(0); + expect(reconcileArtifactDelta).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1); + expect(reconcileArtifactDelta).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(59_999); + expect(reconcileArtifactDelta).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(reconcileArtifactDelta).toHaveBeenCalledTimes(3); + }, +); +it("spreads retries across owners after simultaneous capture refusals", async () => { + const owners = Array.from({ length: 32 }, () => setup()); + const random = vi.spyOn(Math, "random"); + for (let index = 0; index < owners.length; index++) + random.mockReturnValueOnce(index / owners.length); + vi.mocked(reconcileArtifactDelta).mockRejectedValue( + new ArtifactIncrementalError("incremental_unavailable", "Capture busy"), + ); + for (const owner of owners) owner.add(); + await vi.advanceTimersByTimeAsync(7000); + expect(reconcileArtifactDelta).toHaveBeenCalledTimes(128); + vi.mocked(reconcileArtifactDelta).mockResolvedValue(undefined); + await vi.advanceTimersByTimeAsync(59_999); + expect(reconcileArtifactDelta).toHaveBeenCalledTimes(128); + let elapsed = 59_999; + for (let index = 0; index < owners.length; index++) { + const deadline = 60_000 + Math.floor((index / owners.length) * 30_000); + await vi.advanceTimersByTimeAsync(deadline - elapsed); + expect(reconcileArtifactDelta).toHaveBeenCalledTimes(129 + index); + elapsed = deadline; + } +}); it("keeps a current handle when an older in-flight version fails", async () => { const h = setup(); let fail = () => {}; diff --git a/src/features/relay/artifact-store-sync.ts b/src/features/relay/artifact-store-sync.ts index 312fdce6e6..d748b44b5c 100644 --- a/src/features/relay/artifact-store-sync.ts +++ b/src/features/relay/artifact-store-sync.ts @@ -239,7 +239,7 @@ export function provideArtifactStoreSync( const delay = !permanent && retry <= 3 ? (RECOVERY_DELAYS_MS[retry - 1] ?? REPAIR_MS) - : REPAIR_MS; + : REPAIR_MS + Math.floor(Math.random() * 30_000); notBefore = Date.now() + delay; schedule(delay); } From 3739f6d3b29f331899e939a5c7111a3c2799731f Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Mon, 2 Nov 2026 08:02:44 -0800 Subject: [PATCH 29/30] Match the agent claim revision in source test Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- src/features/agents/service.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 78221e577d..1a61da50d0 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -9946,7 +9946,7 @@ describe("Attention plugin sources", () => { }, }); await service.create({ type: "example/echo", name: "Echo" }); - await vi.waitFor(() => expect(native.claim).toHaveBeenCalledWith(bot)); + await vi.waitFor(() => expect(native.claim).toHaveBeenCalledWith(bot, 0)); await service.save(bot, { attentionEnabled: true, attention: watches }); await vi.waitFor(() => expect(texts(service)).toEqual([ From d99a1a35350c50c44f1c2505ad4470e34abf90e7 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Mon, 2 Nov 2026 14:20:51 -0800 Subject: [PATCH 30/30] Recover rejected saved sync checkpoints Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- .../relay/artifact-incremental-reader.ts | 2 + src/features/relay/artifact-reconcile.test.ts | 104 ++++++++++++++++++ src/features/relay/artifact-reconcile.ts | 10 +- 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/src/features/relay/artifact-incremental-reader.ts b/src/features/relay/artifact-incremental-reader.ts index c80fd46073..386f90040c 100644 --- a/src/features/relay/artifact-incremental-reader.ts +++ b/src/features/relay/artifact-incremental-reader.ts @@ -70,6 +70,7 @@ export class ArtifactIncrementalError extends Error { readonly code: ErrorCode, message: string, readonly reason?: ResetReason, + readonly status?: number, ) { super(message); } @@ -211,6 +212,7 @@ async function responseValue(response: Response, signal: AbortSignal) { failure.code, failure.error, failure.reason, + response.status, ) : new ArtifactIncrementalError( "incremental_unavailable", diff --git a/src/features/relay/artifact-reconcile.test.ts b/src/features/relay/artifact-reconcile.test.ts index e6a3a890a6..3994afd52a 100644 --- a/src/features/relay/artifact-reconcile.test.ts +++ b/src/features/relay/artifact-reconcile.test.ts @@ -283,6 +283,110 @@ it.each(["policy_changed", "checkpoint_expired"])( ); }, ); +it.each(["success", "bootstrap failure", "revalidation failure"])( + "recovers a rejected saved checkpoint with %s", + async (outcome) => { + const h = setup(); + h.rows.set(id, { record: { id, availability: { state: "available" } } }); + const requests: import("./artifact-incremental-reader").ArtifactIncrementalRequest[] = + []; + const reader = createArtifactIncrementalReader(async (filter) => { + requests.push(filter); + if ("checkpoint" in filter || outcome === "bootstrap failure") + return Response.json({ code: "incremental_invalid" }, { status: 400 }); + if ("action" in filter) { + if (outcome === "revalidation failure") + return Response.json( + { code: "incremental_unavailable" }, + { status: 503 }, + ); + return Response.json({ + ...h.page, + changes: [{ type: "remove", artifactId: id }], + }); + } + return Response.json(h.page); + }); + const pass = reconcileArtifactDelta( + reader, + h.collection as unknown as HostArtifactCollection, + "test.task", + h.controller.signal, + ); + if (outcome === "success") { + await pass; + expect(h.rows.get(id)?.record.availability.state).toBe("unavailable"); + expect(h.collection.completeRefresh).toHaveBeenCalledWith( + expect.anything(), + { + expected: old, + next: JSON.stringify({ + protocol: "incremental-v1", + checkpoint: "next", + }), + }, + h.controller.signal, + ); + } else { + await expect(pass).rejects.toBeInstanceOf(ArtifactIncrementalError); + expect(h.collection.completeRefresh).not.toHaveBeenCalled(); + expect(h.rows.get(id)?.record.availability.state).toBe("available"); + } + expect( + requests.map((request) => + "checkpoint" in request + ? "saved" + : "action" in request + ? "current" + : "fresh", + ), + ).toEqual( + outcome === "bootstrap failure" + ? ["saved", "fresh"] + : ["saved", "fresh", "current"], + ); + }, +); +it.each(["cursor", "response", "request"])( + "does not reset an invalid %s", + async (failure) => { + const h = setup(); + const request = vi.fn( + async ( + filter: import("./artifact-incremental-reader").ArtifactIncrementalRequest, + ) => { + if (failure === "response") return Response.json({ version: 1 }); + if ("checkpoint" in filter) + return Response.json({ + ...h.page, + nextCursor: "second", + checkpoint: null, + }); + return Response.json({ code: "incremental_invalid" }, { status: 400 }); + }, + ); + const reader = + failure === "request" + ? h.reader + : createArtifactIncrementalReader(request); + if (failure === "request") + h.reader.delta.mockRejectedValue( + new ArtifactIncrementalError("incremental_invalid", "Invalid request"), + ); + await expect( + reconcileArtifactDelta( + reader, + h.collection as unknown as HostArtifactCollection, + "test.task", + h.controller.signal, + ), + ).rejects.toMatchObject({ code: "incremental_invalid" }); + expect( + failure === "request" ? h.reader.delta : request, + ).toHaveBeenCalledTimes(failure === "cursor" ? 2 : 1); + expect(h.collection.completeRefresh).not.toHaveBeenCalled(); + }, +); it("keeps recovery incomplete after failed revalidation", async () => { const h = setup(null); h.rows.set(id, { record: { id, availability: { state: "available" } } }); diff --git a/src/features/relay/artifact-reconcile.ts b/src/features/relay/artifact-reconcile.ts index 8cf7f24578..b0f6f7c74c 100644 --- a/src/features/relay/artifact-reconcile.ts +++ b/src/features/relay/artifact-reconcile.ts @@ -267,8 +267,8 @@ export async function reconcileArtifactDelta( try { let next: string; while (true) { + let cursor: string | undefined; try { - let cursor: string | undefined; let startedAt: string | undefined; const cursors = new Set(); while (true) { @@ -301,7 +301,13 @@ export async function reconcileArtifactDelta( } catch (error) { if ( !(error instanceof ArtifactIncrementalError) || - error.code !== "incremental_reset" || + (error.code !== "incremental_reset" && + !( + error.code === "incremental_invalid" && + error.status === 400 && + checkpoint !== undefined && + cursor === undefined + )) || recovery ) throw error;