Status: the lync format is a v0 draft (see FORMAT.md); the
v:1event 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 what or what was dropped. The story of how a thing came to be is thrown away at every step.
lync keeps it. A .lync file is a list of events, one per line, in plain text.
Each line records one thing that happened. Someone wrote a sentence, marked a
version as good, or chose one branch over another. You never edit a line. To
change something you add a new line that points at the old one and says what
changed. The old line stays.
That one rule, only ever add and never edit, is where everything good comes from. You can always see the whole history, every version and every branch you did not take. Merging two copies is safe and boring, because you keep every event from both and there are no edits to fight over. If the same event ever shows up two different ways, lync keeps both and says so out loud instead of quietly picking one. And because the file is plain text with a written-down spec, you can still read it in twenty years, on a computer that has never heard of this software.
A lync file is more than a transcript, because it keeps the branches and the choices. From the same file you can make a readable document to hand someone, or training data built from the exact versions you marked as good.
@deepfates/lync is the reference implementation. One package, zero runtime
dependencies, holding the parser, the event stores, the computed views, the
loom API, live sync, the lync command, and the sync relay. The format is the
durable center. Everything else is a tool that reads and writes it.
The normative specification is FORMAT.md. It is self-contained: no code in this repository is required to implement it.
The short version:
- A
.lyncfile is UTF-8 JSONL, no BOM, one event object per LF-terminated line. - The required event fields are
v,id,kind,at,author,parents, andpayload. author.actoris the content producer;author.viais the app or runtime; converters useimported_byand preserve the original actor.- Events are immutable. Correction, judgment, selection, and retraction are new events that point at prior events.
- Merge is union by event id. Same id plus same body bytes is one event seen twice; same id plus different body bytes is a surfaced conflict variant and is excluded from ordinary views.
- Stored line metadata may splice
digestandsigat the end of the line.digestandsigare reserved top-level body names; payloads may use those names freely. - Views are computed. This package ships branch tree, transcript, memory, and leaderboard helpers.
Every line has the same envelope:
{"v":1,"id":"root","kind":"notes/text","at":"2026-07-06T04:12:31Z","author":{"actor":"deepfates","via":"example@0.1"},"parents":[],"payload":{"text":"Once..."}}That line can be copied to another file, merged back later, verified byte for
byte, and read by software that has never heard of notes/text. Unknown
kinds are carried and traversed; meaning belongs to pacts layered above the
format — see pacts/import.md (imports are transcription:
deterministic ids, provenance preserved, zero silent drops) and
pacts/export.md (exports are projections of the event
log, including training data).
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.
The format is meant to be implemented in other languages, and the test
vectors are the product that makes a port checkable:
test/vectors/v0. Each case is a directory holding a raw
input file (input.lync, or a.lync + b.lync for the merge case) and an
expected.json with the required classification of every physical line —
accepted, nonconforming, garbage, damaged, conflict-variant — plus union ids,
pending parents, and graph diagnostics. The thirteen cases cover valid events,
splice anchoring, damaged digests, garbage classes, same-id conflicts and
duplicates, graph obstacles, critical suppression, spelling-versus-value
equality, marked/at semantics, merge union, carried nonconforming lines,
and invalid signature splices.
A new implementation ports the vector suite first, this package's test suite
second, and its design never. test/vectors/v0/README.md documents the
expected-output schema; generate.py regenerates digests deterministically.
npm install @deepfates/lyncRuns 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
WebSocketImplto run them on anything older. See Live sync inside an app and The Command.
import { parseLyncFiles } from "@deepfates/lync/events";
import { lyncBranchTreeView, lyncTranscriptView } from "@deepfates/lync/views";
const bytes = new TextEncoder().encode(
'{"v":1,"id":"root","kind":"notes/text","at":"2026-07-06T04:12:31Z","author":{"actor":"you"},"parents":[],"payload":{"text":"Once..."}}\n',
);
const parsed = parseLyncFiles([{ file: "story.lync", bytes }]);
console.log(parsed.lines[0].class); // "accepted"
console.log(lyncBranchTreeView(parsed).roots);
console.log(lyncTranscriptView(parsed, "root").entries.map((entry) => entry.id));parseLyncFiles classifies every physical line and keeps the original bytes —
accepted events, nonconforming-but-carried lines, damaged lines, garbage, and
conflict variants (same id, different bytes) are all preserved and reported,
never silently dropped. exportCarriedLyncBytes(parsed) re-emits the carried
bytes. LyncUnion performs the same union incrementally and can buffer
children until their first missing parent arrives.
Event stores share one contract over memory, file, and IndexedDB backends. The loom API gives programs turns and threads instead of raw events, on top of any store:
import { createLyncLooms } from "@deepfates/lync/looms";
import { createMemoryEventStore } from "@deepfates/lync/memory-log";
const looms = createLyncLooms({
store: createMemoryEventStore(),
author: { actor: "you", via: "my-app@0.1" },
});
const info = await looms.create({ title: "Story" });
const loom = await looms.open(info.id);
const first = await loom.appendTurn(null, { text: "Once..." });
await loom.appendTurn(first.id, { text: "Then..." });The store API accepts raw lines through union(line) and structured event
bodies through append(event). It reports conflicts, pending parents, garbage,
and accepted events without making file order meaningful.
An index tracks a collection of looms — upsert entries, subscribe to changes:
import { loomRef } from "@deepfates/lync";
import { createMemoryLoomIndexes } from "@deepfates/lync/indexes/memory";
const indexes = createMemoryLoomIndexes();
const index = await indexes.create({ title: "My looms" });
index.subscribe((event) => console.log("index changed:", event.type));
await index.addLoom(loomRef("loom-1"), { title: "Story" });
console.log((await index.entries()).map((entry) => entry.title));Entries carry a loom reference, optional title/kind/meta, and timestamps.
export()/import() round-trip a whole index as a snapshot.
One object that pairs looms with an index and resolves loom/turn/thread/index references to and from URLs:
import { createLyncLooms } from "@deepfates/lync/looms";
import { createMemoryEventStore } from "@deepfates/lync/memory-log";
import { createMemoryLoomIndexes } from "@deepfates/lync/indexes/memory";
import { createLoomClient } from "@deepfates/lync/client";
const client = createLoomClient({
looms: createLyncLooms({ store: createMemoryEventStore(), author: { actor: "you" } }),
indexes: createMemoryLoomIndexes(),
});
const info = await client.looms.create({ title: "Story" });
const ref = client.references.loom(info.id);
// Round-trip a reference through a shareable URL (?ref=...).
// In the browser, pass `window.location` instead of a URL object.
const url = client.references.toUrl(ref, new URL("https://example.com/story"));
const opened = await client.openReference(client.references.fromUrl(new URL(url)));
console.log(opened.kind); // "loom" — opened.loom is ready to appendTurn@deepfates/lync/client/testing ships createTestLoomClient, a fully in-memory
client for tests and embedded experiments — deterministic when you pass
createId and now.
The sync protocol runs in a browser or Node app with no CLI. Wrap any event
store in createSyncedStore; looms and indexes built over it update live as
collaborators append, because they already recompute through the store's
subscribe:
import { createMemoryEventStore } from "@deepfates/lync/memory-log";
import { createLyncLooms } from "@deepfates/lync/looms";
import { createSyncedStore, createWebSocketTransport } from "@deepfates/lync/synced-store";
const transport = createWebSocketTransport("wss://host/lync");
const store = createSyncedStore(createMemoryEventStore(), transport, {
onStatus: (s) => console.log("sync:", s.connection, "live:", s.liveRoots),
});
const looms = createLyncLooms({ store, author: { actor: "alice" } });
const loom = await looms.open(loomId);
loom.subscribe(() => render(loom)); // fires on local AND remote turns
await loom.appendTurn(parentId, { text: "typed live" });Local appends are pushed to the relay; remote lines are ingested through the
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. 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.
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:
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); // 0roster(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.
@deepfates/lync/events— line parsing, carried-byte export, incremental union@deepfates/lync/store— the event-store contract and serialization@deepfates/lync/memory-log,@deepfates/lync/file-log,@deepfates/lync/idb-log— stores (file-logis node-only; it keepsnode:fsoff the browser path)@deepfates/lync/views— branch tree, transcript, memory, leaderboard@deepfates/lync/looms— the loom/turn API@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@deepfates/lync/client,@deepfates/lync/client/testing,@deepfates/lync/client/types— the loom client@deepfates/lync/relay— the sync relay (see The Relay)
The package installs a lync bin with seven verbs: init, append,
verify, merge, view, serve, and sync.
npm install -g @deepfates/lynclync init story.lync
printf '%s\n' '{"kind":"notes/text","author":{"actor":"you"},"payload":{"text":"Once..."}}' | lync append story.lync
lync verify story.lync
lync view story.lync --as transcript
printf '%s\n' '{"kind":"notes/text","author":{"actor":"friend"},"payload":{"text":"Then..."}}' | lync append other.lync
lync merge story.lync other.lync -o merged.lync
lync view merged.lync --as treeappend fills the envelope for you: a UUIDv7 id, the current timestamp, v,
and parents default in; anything you supply is kept. verify reports what
every physical line is — accepted, nonconforming, damaged, garbage, or
conflict variant — and never drops bytes; it exits 0 only when every line is
accepted. view renders transcript or tree.
Any lync file can converge with any other copy through a relay:
lync serve ./rooms --port 8787 # the relay: one append-only file per root
lync sync story.lync ws://host:8787 # one-shot: push what it lacks, pull what you lack
lync sync story.lync ws://host:8787 --follow # stay live until Ctrl-Clync 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 is deliberately dumb. Events are immutable and merge is union by
id, so the protocol has no merge logic: five JSON frames (sub, ev,
live, presence, err) that move canonical line bytes. The server never
parses a line beyond extracting its id, stores each root as a plain .lync
file you can read with any lync tool, and echoes accepted events to every
subscriber — echoes are duplicate no-ops under union. seq is a per-root
arrival counter used as a resume cursor (<file>.sync.json), so an offline
client reconnects exactly where it left off.
Running a relay is the one thing that needs a WebSocket server, and Node does
not ship one — so the relay acquires ws
lazily at the moment you construct it. @deepfates/lync declares no dependency on
ws at all: install it yourself next to your server
(npm install ws), and everything else in the package works without it.
If you bundle a server that runs the relay, mark ws as external — the
acquisition is a dynamic require that bundlers cannot see through.
Standalone:
import { startLyncServe } from "@deepfates/lync/relay";
const server = await startLyncServe({ dir: "./rooms", port: 8787 });
console.log("relay on", server.port);
// Look inside a running relay — read-only, mutates nothing. Each record is
// { root, generation, seq, subscribers, pendingUnpersisted }, where
// pendingUnpersisted is the durability lag: lines in memory but not yet on disk.
for (const room of server.status()) {
console.log(room.root, "seq", room.seq, "subs", room.subscribers, "lag", room.pendingUnpersisted);
}
// later: await server.close();On an existing HTTP server:
import { createServer } from "node:http";
import { attachLyncServer } from "@deepfates/lync/relay";
const httpServer = createServer(app);
const lync = attachLyncServer(httpServer, {
storageDir: "./rooms",
path: "/lync", // default
keepAliveInterval: 30_000, // optional: ping through idle proxies
maxConnections: 500, // optional
authenticate: (req) => checkSession(req), // optional, after token check
});
httpServer.listen(3000);For full control, createLyncRelay gives you handleUpgrade to call from
your own upgrade listener. All three (createLyncRelay, startLyncServe,
attachLyncServer) expose status() — a read-only snapshot of every live
room's seq, subscriber count, and pending-unpersisted durability lag.
Guarantees: same-id-different-bytes is never resolved — both variants are
kept (a .conflicts sidecar) and both sides are told loudly. Persist failures
are broadcast, never swallowed. A truncated final line after a crash is
sealed and surfaced as damaged, never eaten. Presence frames are relayed,
never stored. --token T (or token in the API) requires
Authorization: Bearer T on every upgrade.
The relay is a tool shipped beside the format, not part of it: FORMAT.md deliberately excludes sync protocols, and any transport that moves canonical line bytes and unions by id converges the same files without this relay.
pnpm install
pnpm build
node bin/lync.js --help
pnpm test
pnpm verifypnpm verify runs the path guard, tests, build + typecheck, and executes
every fenced example in this README against the built package
(pnpm check:examples). README examples are contract: a block runs
as-written unless an <!-- example: fragment --> comment above it declares
why it can't run alone.
For a fresh clone, pnpm install && pnpm build is the supported setup
sequence. After that, node bin/lync.js --help prints the CLI help.
scripts/fresh-clone-smoke.sh verifies that sequence in a temporary clone
and runs the CLI story path: init, append, view, concatenate, merge, and
verify.