diff --git a/README.md b/README.md index 5707ec6..6b67a4d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # lync +> **Status:** the lync format is a v0 draft (see [FORMAT.md](./FORMAT.md)); +> the `v:1` event envelope has not changed since first publication and any +> change would come with a version bump. This package is the reference +> implementation. + Most software forgets. Edit a document and yesterday's version is gone. Write with an AI that offers three options and the two you do not pick vanish. Let a tool merge two people's edits and you get one result with no memory of who did @@ -68,6 +73,19 @@ deterministic ids, provenance preserved, zero silent drops) and [pacts/export.md](./pacts/export.md) (exports are projections of the event log, including training data). +## Why not Yjs / Automerge? + +Because there is nothing to merge. CRDT libraries solve concurrent mutation +of shared state — two people editing the same paragraph — and they solve it +well; if your data mutates, use one. lync events never mutate: correction, +judgment, and retraction are new events pointing at old ones, so two replicas +combine by plain set union of ids, with no operational transform, no vector +clocks, no merge algorithm at all. The same id with different bytes is not +resolved cleverly — both variants are kept and surfaced loudly. That trade +buys a durable plain-text format any transport converges (a relay, `rsync`, +an email attachment), at the cost of not being a live shared document. The +one ephemeral surface, presence, uses last-writer-wins clocks, not a CRDT. + ## Conformance Vectors The format is meant to be implemented in other languages, and the test @@ -235,6 +253,45 @@ unflagged only on **Node >=21** (the browser always has it); on older Node, the `ws` package). This is stricter than the package's `engines.node` (>=19), which is set for the browser-safe core alone. +### Presence: who is here right now + +Presence is the one ephemeral thing in lync: the relay fans out `presence` +frames and never stores them, so every client keeps its OWN roster of who is +present. `@deepfates/lync/presence-awareness` is that roster — a small +state machine with last-writer-wins clocks per participant, TTL sweeps for +peers that go quiet, and heartbeats so late joiners recover you. It is keyed +by client (one person on two devices is two participants), and `state: null` +is a graceful leave. + +The machine is pure and transport-agnostic. Below, two participants are wired +directly to each other; in an app you wire `send` to `SyncedStore.presence` +and feed `SyncedStore.onPresence` into `receive`, then call `start()` for +real heartbeat and sweep timers: + +```ts +import { createPresenceAwareness } from "@deepfates/lync/presence-awareness"; + +const alice = createPresenceAwareness({ + client: "alice-laptop", + send: (root, client, data) => bob.receive(root, client, data), +}); +const bob = createPresenceAwareness({ + client: "bob-phone", + send: (root, client, data) => alice.receive(root, client, data), + onDelta: (root, delta) => + console.log(root, "joined:", delta.added.map((p) => p.state.actor)), +}); + +alice.setLocal("story", { actor: "alice", typing: true }); +console.log(bob.roster("story").map((p) => `${p.state.actor} typing=${p.state.typing}`)); +alice.setLocal("story", null); // graceful leave: bob's roster drops alice at once +console.log("after leave:", bob.roster("story").length); // 0 +``` + +`roster(root)` is the current view, `onDelta` fires on every add, update, and +remove (including TTL timeouts). Clocks order states; liveness is separate, +so a heartbeat refreshes a peer without burning a new clock. + ### Subpath exports - `@deepfates/lync/events` — line parsing, carried-byte export, incremental union @@ -246,6 +303,7 @@ which is set for the browser-safe core alone. - `@deepfates/lync/references` — loom/turn/thread/index references and URLs - `@deepfates/lync/synced-store` — live sync decorator and WebSocket transport - `@deepfates/lync/sync-protocol` — the five sync frames, encode/decode +- `@deepfates/lync/presence-awareness` — client-side who-is-here roster over presence frames - `@deepfates/lync/uuid` — zero-dep UUIDv7 for event ids - `@deepfates/lync/indexes`, `@deepfates/lync/indexes/entries`, `@deepfates/lync/indexes/memory`, `@deepfates/lync/indexes/types` — loom indexes diff --git a/package.json b/package.json index f887a5b..c279fe8 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,9 @@ }, "files": [ "bin", - "dist" + "dist", + "FORMAT.md", + "pacts" ], "dependencies": {}, "scripts": { diff --git a/src/cli/sync.ts b/src/cli/sync.ts index 795e11d..26ab113 100644 --- a/src/cli/sync.ts +++ b/src/cli/sync.ts @@ -176,8 +176,7 @@ export async function syncOnce(options: LyncSyncOptions): Promise { clearTimeout(timeout); watcher?.close(); - const detail = (event as { message?: unknown }).message; - reject(new Error(`lync sync: socket error from ${options.url}${typeof detail === "string" ? `: ${detail}` : ""}`)); + reject(new Error(`lync sync: socket error from ${options.url}: ${socketErrorReason(event)}`)); }); socket.addEventListener("open", () => { @@ -269,6 +268,24 @@ export async function syncOnce(options: LyncSyncOptions): Promise 0) return message.trim(); + if (error instanceof Error) { + const code = (error as NodeJS.ErrnoException).code; + const detail = error.message.trim() || (typeof code === "string" ? code : "") || error.name; + if (detail.length > 0) return detail; + } + return "connection failed (the socket reported no reason)"; +} + function defaultRoot(file: string): string { return basename(file).replace(/\.lync$/, ""); } diff --git a/test/cli/sync.test.ts b/test/cli/sync.test.ts index f9fa612..d55ca8b 100644 --- a/test/cli/sync.test.ts +++ b/test/cli/sync.test.ts @@ -396,4 +396,40 @@ describe("conflict sidecar durability (dee-inzc major)", () => { expect(errs.text()).toContain("conflict-persist-failed"); expect(existsSync(path.join(serverDir, "duel.conflicts"))).toBe(false); }); + + it("reports a non-empty reason when the relay is dead (never a bare trailing colon)", async () => { + // A port with nothing listening: start a relay, note its port, kill it. + const serverDir = await mkdtemp(path.join(os.tmpdir(), "lync-serve-")); + const clientDir = await mkdtemp(path.join(os.tmpdir(), "lync-client-")); + const doomed = await startLyncServe({ dir: serverDir, log: () => {} }); + const url = `ws://localhost:${doomed.port}`; + await doomed.close(); + + const file = path.join(clientDir, "story.lync"); + await writeFile(file, `${eventLine("root", [], "once")}\n`); + + const rejection = await syncOnce({ file, url, root: "story", out: quiet, err: quiet }).then( + () => undefined, + (error: unknown) => error as Error, + ); + expect(rejection).toBeInstanceOf(Error); + expect(rejection!.message).toContain(`socket error from ${url}: `); + // The reason after the colon must never be empty. + const reason = rejection!.message.split(`socket error from ${url}: `)[1]; + expect(reason.trim().length).toBeGreaterThan(0); + }); +}); + +describe("socketErrorReason", () => { + it("is non-empty for every shape a socket error event takes", async () => { + const { socketErrorReason } = await import("../../src/cli/sync.js"); + expect(socketErrorReason({ message: "boom" })).toBe("boom"); + // Node's ErrorEvent with an empty message but an underlying error. + const refused = Object.assign(new Error(""), { code: "ECONNREFUSED" }); + expect(socketErrorReason({ message: "", error: refused })).toBe("ECONNREFUSED"); + expect(socketErrorReason({ message: "", error: new Error("dial failed") })).toBe("dial failed"); + // Nothing usable at all: still says something. + expect(socketErrorReason({ message: "" }).length).toBeGreaterThan(0); + expect(socketErrorReason(undefined).length).toBeGreaterThan(0); + }); });