diff --git a/README.md b/README.md index 38545e8..5707ec6 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,15 @@ expected-output schema; `generate.py` regenerates digests deterministically. npm install @deepfates/lync ``` -Runs in Node (>=22) and the browser. No dependencies. +Runs in Node and the browser. No dependencies. The browser-safe core +(parse, stores, views, looms, indexes, the loom client) needs only the Web +Crypto global, so its floor is **Node >=19** (or any browser) — that is the +`engines.node` the package declares. Two surfaces need a stricter runtime, and +that requirement is pinned where it applies, not on the whole package: the ws +sync transport (`@deepfates/lync/synced-store` `createWebSocketTransport`) and +`lync sync` reach for the built-in `WebSocket` global, unflagged only on **Node +>=21** — pass your own `WebSocketImpl` to run them on anything older. See +[Live sync inside an app](#live-sync-inside-an-app) and [The Command](#the-command). ### Parse, union, view @@ -221,7 +229,11 @@ same `union` path and surface reactively. Offline appends queue and flush on reconnect; the store re-subscribes automatically. The transport is an interface — pass your own for tests or a non-WebSocket carrier. The client side uses the platform's built-in WebSocket: no dependency, in the browser or -in Node. +in Node. **Runtime floor for this surface:** the built-in `WebSocket` global is +unflagged only on **Node >=21** (the browser always has it); on older Node, +`createWebSocketTransport` throws unless you pass `options.WebSocketImpl` (e.g. +the `ws` package). This is stricter than the package's `engines.node` (>=19), +which is set for the browser-safe core alone. ### Subpath exports @@ -275,9 +287,10 @@ lync sync story.lync ws://host:8787 # one-shot: push what it lacks, pull lync sync story.lync ws://host:8787 --follow # stay live until Ctrl-C ``` -`lync sync` uses Node's built-in WebSocket — no install beyond the package. -`lync serve` runs the relay and needs `ws` present (`npm install ws`); see -below. +`lync sync` uses Node's built-in `WebSocket` — no install beyond the package, +but that global is unflagged only on **Node >=21**, so the sync verb needs that +runtime (stricter than the package's `engines.node` >=19 core floor). `lync +serve` runs the relay and needs `ws` present (`npm install ws`); see below. ## The Relay diff --git a/docs/trials/loss-free-trial.json b/docs/trials/loss-free-trial.json new file mode 100644 index 0000000..63d97b8 --- /dev/null +++ b/docs/trials/loss-free-trial.json @@ -0,0 +1,126 @@ +{ + "artifact_schema": "lync.loss-free-trial.v1", + "ticket": "dee-i1wc", + "recorded_at": "2026-07-14T00:10:33.937Z", + "owner_law": "world-charter milestone-6: durable, loss-free live sync", + "claim": "Every event a client SUCCESSFULLY appended (local append returned 'added') reaches every other client's store AND the relay's on-disk .lync file — through a disconnect, a storage failure, and a server restart — and every failure that occurred was surfaced, never swallowed.", + "root": "trial", + "appended_by_client": { + "alice": [ + "a1", + "a2", + "post", + "trial" + ], + "bob": [], + "carol": [ + "x1" + ] + }, + "promised_ids": [ + "a1", + "a2", + "post", + "trial", + "x1" + ], + "landing": { + "a1": { + "stores": { + "alice": true, + "bob": true, + "carol": true + }, + "onDisk": true + }, + "a2": { + "stores": { + "alice": true, + "bob": true, + "carol": true + }, + "onDisk": true + }, + "post": { + "stores": { + "alice": true, + "bob": true, + "carol": true + }, + "onDisk": true + }, + "trial": { + "stores": { + "alice": true, + "bob": true, + "carol": true + }, + "onDisk": true + }, + "x1": { + "stores": { + "alice": true, + "bob": true, + "carol": true + }, + "onDisk": true + } + }, + "final_disk_ids": [ + "a1", + "a2", + "post", + "trial", + "x1" + ], + "surfaced_failures": [ + { + "actor": "alice", + "failures": [ + "relay error for trial: persist-failed (x1)", + "generation changed for trial (7bd941ce-cc65-4ccd-a7e3-a96b12ae06b7 -> f8bd70f6-aaec-4f08-b0df-f991b4ea7178); resyncing from 0" + ] + }, + { + "actor": "bob", + "failures": [ + "relay error for trial: persist-failed (x1)", + "generation changed for trial (7bd941ce-cc65-4ccd-a7e3-a96b12ae06b7 -> f8bd70f6-aaec-4f08-b0df-f991b4ea7178); resyncing from 0" + ] + }, + { + "actor": "carol", + "failures": [ + "relay error for trial: persist-failed (x1)", + "generation changed for trial (7bd941ce-cc65-4ccd-a7e3-a96b12ae06b7 -> f8bd70f6-aaec-4f08-b0df-f991b4ea7178); resyncing from 0" + ] + } + ], + "legs": [ + { + "leg": "a", + "name": "disconnect / reconnect", + "event_ids": [ + "a1", + "a2" + ], + "surfaced": "bob's connection went offline then online; caught up with nothing skipped" + }, + { + "leg": "b", + "name": "storage failure (.lync read-only)", + "event_ids": [ + "x1" + ], + "surfaced": "persist-failed on every client; x1 fanned to every store but not on disk until leg c" + }, + { + "leg": "c", + "name": "server restart (new log generation)", + "event_ids": [ + "post" + ], + "surfaced": "generation changed on every client; backlog re-pushed, x1 finally reached disk" + } + ] +} diff --git a/package.json b/package.json index 67b1c3d..9d3a85c 100644 --- a/package.json +++ b/package.json @@ -163,7 +163,7 @@ "ws": "^8.18.0" }, "engines": { - "node": ">=22" + "node": ">=19" }, "keywords": [ "lync", diff --git a/src/cli/sync.ts b/src/cli/sync.ts index 698a59f..795e11d 100644 --- a/src/cli/sync.ts +++ b/src/cli/sync.ts @@ -3,8 +3,10 @@ import { existsSync, watch } from "node:fs"; import { basename } from "node:path"; import { decodeFrame, encodeFrame, extractLineId, isCursor } from "../sync-protocol.js"; -// Sync rides Node's built-in WebSocket (global since Node 22, matching -// engines) — no dependency. It is an EventTarget, not an EventEmitter: +// Sync rides Node's built-in WebSocket (global and unflagged since Node 21) — +// no dependency. This is the strict floor for the `sync` verb, stricter than +// the package's browser-safe-core engines (Node >=19); it is pinned on this +// surface, not the whole package. It is an EventTarget, not an EventEmitter: // listeners via addEventListener, payloads on MessageEvent.data, and no // terminate(); the hard-abort timeout below settles the promise by rejection // and then close() tears the socket down (aborting the handshake if still diff --git a/src/synced-store.ts b/src/synced-store.ts index c5fd74c..6e76c57 100644 --- a/src/synced-store.ts +++ b/src/synced-store.ts @@ -299,6 +299,12 @@ export interface WebSocketTransportOptions { * flushed on connect; a dropped socket schedules a reconnect and the synced * store re-subscribes via `onOpen`. Nothing is silently dropped: an unsent * frame waits in the queue rather than vanishing. + * + * Runtime floor for THIS surface (not the whole package): it reaches for the + * global `WebSocket`, unflagged only on Node >=21 (browsers always have it). + * On older Node pass `options.WebSocketImpl` (e.g. the `ws` package). The + * package's `engines.node` is the looser >=19 core floor; this stricter + * requirement is pinned here where it actually applies. */ export function createWebSocketTransport(url: string, options: WebSocketTransportOptions = {}): SyncTransport { const WS = options.WebSocketImpl ?? (globalThis as { WebSocket?: typeof WebSocket }).WebSocket; diff --git a/test/cli/loss-free-trial.integration.test.ts b/test/cli/loss-free-trial.integration.test.ts index 3455e8e..55c4f83 100644 --- a/test/cli/loss-free-trial.integration.test.ts +++ b/test/cli/loss-free-trial.integration.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { chmod, mkdtemp, readFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { createMemoryEventStore } from "@deepfates/lync/memory-log"; @@ -227,5 +227,57 @@ describe("loss-free trial (dee-i1wc): the milestone-6 durability proof", () => { expect(await cl.store.byId(id)).not.toBeNull(); } } + + // ---- Emit the inspectable trial artifact (parity with golarion dee-3fxq) + // 'done' is not quietly 'tests pass': write a person-readable record of + // every appended id, where it landed (each client's store + the relay's + // on-disk .lync), and every surfaced failure — the same evidence the + // assertions above checked, made replayable by an outside reader. + const promisedIds = [...promised()].sort(); + const landing: Record; onDisk: boolean }> = {}; + for (const id of promisedIds) { + const stores: Record = {}; + for (const cl of clients) stores[cl.actor] = (await cl.store.byId(id)) !== null; + landing[id] = { stores, onDisk: finalDisk.has(id) }; + } + // Every failure any client surfaced across the run — deduped, never swallowed. + const surfacedFailures = clients.map((cl) => { + const all = new Set(); + for (const s of cl.statuses) for (const f of s.failures) all.add(f); + for (const f of cl.store.status().failures) all.add(f); + return { actor: cl.actor, failures: [...all] }; + }); + const artifact = { + artifact_schema: "lync.loss-free-trial.v1", + ticket: "dee-i1wc", + recorded_at: new Date().toISOString(), + owner_law: "world-charter milestone-6: durable, loss-free live sync", + claim: + "Every event a client SUCCESSFULLY appended (local append returned 'added') reaches every other client's store AND the relay's on-disk .lync file — through a disconnect, a storage failure, and a server restart — and every failure that occurred was surfaced, never swallowed.", + root: ROOT, + appended_by_client: Object.fromEntries(clients.map((cl) => [cl.actor, [...cl.appended].sort()])), + promised_ids: promisedIds, + landing, + final_disk_ids: [...finalDisk].sort(), + surfaced_failures: surfacedFailures, + legs: [ + { leg: "a", name: "disconnect / reconnect", event_ids: ["a1", "a2"], surfaced: "bob's connection went offline then online; caught up with nothing skipped" }, + { leg: "b", name: "storage failure (.lync read-only)", event_ids: ["x1"], surfaced: "persist-failed on every client; x1 fanned to every store but not on disk until leg c" }, + { leg: "c", name: "server restart (new log generation)", event_ids: ["post"], surfaced: "generation changed on every client; backlog re-pushed, x1 finally reached disk" }, + ], + }; + const trialsDir = new URL("../../docs/trials/", import.meta.url); + await mkdir(trialsDir, { recursive: true }); + const artifactPath = new URL("loss-free-trial.json", trialsDir); + await writeFile(artifactPath, JSON.stringify(artifact, null, 2) + "\n"); + + // The artifact must describe an actually loss-free run: nothing missing. + for (const id of promisedIds) { + expect(landing[id].onDisk).toBe(true); + for (const cl of clients) expect(landing[id].stores[cl.actor]).toBe(true); + } + // And it recorded that the durability failures WERE surfaced, not hidden. + expect(surfacedFailures.every((c) => c.failures.some((f) => f.includes("persist-failed")))).toBe(true); + expect(surfacedFailures.every((c) => c.failures.some((f) => f.includes("generation changed")))).toBe(true); }, 30_000); });