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
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@
},
"files": [
"bin",
"dist"
"dist",
"FORMAT.md",
"pacts"
],
"dependencies": {},
"scripts": {
Expand Down
21 changes: 19 additions & 2 deletions src/cli/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,7 @@ export async function syncOnce(options: LyncSyncOptions): Promise<LyncSyncResult
socket.addEventListener("error", (event) => {
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", () => {
Expand Down Expand Up @@ -269,6 +268,24 @@ export async function syncOnce(options: LyncSyncOptions): Promise<LyncSyncResult
return result;
}

/**
* A non-empty, human-readable reason for a socket `error` event. Node's
* built-in WebSocket fires an ErrorEvent whose `message` can be the empty
* string (a dead relay used to print "socket error from ws://...:" with
* nothing after the colon). Prefer the event message, then the underlying
* error's message, code (e.g. ECONNREFUSED), or name, and never return "".
*/
export function socketErrorReason(event: unknown): string {
const { message, error } = (event ?? {}) as { message?: unknown; error?: unknown };
if (typeof message === "string" && message.trim().length > 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$/, "");
}
Expand Down
36 changes: 36 additions & 0 deletions test/cli/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading