Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
126 changes: 126 additions & 0 deletions docs/trials/loss-free-trial.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@
"ws": "^8.18.0"
},
"engines": {
"node": ">=22"
"node": ">=19"
},
"keywords": [
"lync",
Expand Down
6 changes: 4 additions & 2 deletions src/cli/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/synced-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
54 changes: 53 additions & 1 deletion test/cli/loss-free-trial.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, { stores: Record<string, boolean>; onDisk: boolean }> = {};
for (const id of promisedIds) {
const stores: Record<string, boolean> = {};
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<string>();
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);
});
Loading