Skip to content
Closed
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
66 changes: 33 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# lync

lync is a file format for append-only interaction history, and `lync-core` is
lync is a file format for append-only interaction history, and `@deepfates/lync` is
its reference implementation — one package that ships the parser, event
stores, computed views, the loom API, live sync, the `lync` command, and the
sync relay. Zero runtime dependencies.
Expand Down Expand Up @@ -70,16 +70,16 @@ expected-output schema; `generate.py` regenerates digests deterministically.
## The Library

```bash
npm install lync-core
npm install @deepfates/lync
```

Runs in Node (>=22) and the browser. No dependencies.

### Parse, union, view

```ts
import { parseLyncFiles } from "lync-core/events";
import { lyncBranchTreeView, lyncTranscriptView } from "lync-core/views";
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',
Expand All @@ -105,8 +105,8 @@ loom API gives programs turns and threads instead of raw events, on top of any
store:

```ts
import { createLyncLooms } from "lync-core/looms";
import { createMemoryEventStore } from "lync-core/memory-log";
import { createLyncLooms } from "@deepfates/lync/looms";
import { createMemoryEventStore } from "@deepfates/lync/memory-log";

const looms = createLyncLooms({
store: createMemoryEventStore(),
Expand All @@ -128,8 +128,8 @@ and accepted events without making file order meaningful.
An index tracks a collection of looms — upsert entries, subscribe to changes:

```ts
import { loomRef } from "lync-core";
import { createMemoryLoomIndexes } from "lync-core/indexes/memory";
import { loomRef } from "@deepfates/lync";
import { createMemoryLoomIndexes } from "@deepfates/lync/indexes/memory";

const indexes = createMemoryLoomIndexes();
const index = await indexes.create({ title: "My looms" });
Expand All @@ -149,10 +149,10 @@ One object that pairs looms with an index and resolves
loom/turn/thread/index references to and from URLs:

```ts
import { createLyncLooms } from "lync-core/looms";
import { createMemoryEventStore } from "lync-core/memory-log";
import { createMemoryLoomIndexes } from "lync-core/indexes/memory";
import { createLoomClient } from "lync-core/client";
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" } }),
Expand All @@ -169,7 +169,7 @@ const opened = await client.openReference(client.references.fromUrl(new URL(url)
console.log(opened.kind); // "loom" — opened.loom is ready to appendTurn
```

`lync-core/client/testing` ships `createTestLoomClient`, a fully in-memory
`@deepfates/lync/client/testing` ships `createTestLoomClient`, a fully in-memory
client for tests and embedded experiments — deterministic when you pass
`createId` and `now`.

Expand All @@ -182,9 +182,9 @@ collaborators append, because they already recompute through the store's

<!-- example: fragment — needs a live relay and an app render loop; covered by the synced-store tests -->
```ts
import { createMemoryEventStore } from "lync-core/memory-log";
import { createLyncLooms } from "lync-core/looms";
import { createSyncedStore, createWebSocketTransport } from "lync-core/synced-store";
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, {
Expand All @@ -206,29 +206,29 @@ in Node.

### Subpath exports

- `lync-core/events` — line parsing, carried-byte export, incremental union
- `lync-core/store` — the event-store contract and serialization
- `lync-core/memory-log`, `lync-core/file-log`, `lync-core/idb-log` — stores
- `@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-log` is node-only; it keeps `node:fs` off the browser path)
- `lync-core/views` — branch tree, transcript, memory, leaderboard
- `lync-core/looms` — the loom/turn API
- `lync-core/references` — loom/turn/thread/index references and URLs
- `lync-core/synced-store` — live sync decorator and WebSocket transport
- `lync-core/sync-protocol` — the five sync frames, encode/decode
- `lync-core/uuid` — zero-dep UUIDv7 for event ids
- `lync-core/indexes`, `lync-core/indexes/entries`,
`lync-core/indexes/memory`, `lync-core/indexes/types` — loom indexes
- `lync-core/client`, `lync-core/client/testing`, `lync-core/client/types` —
- `@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/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
- `lync-core/relay` — the sync relay (see [The Relay](#the-relay))
- `@deepfates/lync/relay` — the sync relay (see [The Relay](#the-relay))

## The Command

The package installs a `lync` bin with seven verbs: `init`, `append`,
`verify`, `merge`, `view`, `serve`, and `sync`.

```bash
npm install -g lync-core
npm install -g @deepfates/lync
```

```bash
Expand Down Expand Up @@ -273,7 +273,7 @@ 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`](https://www.npmjs.com/package/ws)
lazily at the moment you construct it. `lync-core` declares no dependency on
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
Expand All @@ -283,7 +283,7 @@ Standalone:

<!-- example: daemon — expect "relay on" -->
```ts
import { startLyncServe } from "lync-core/relay";
import { startLyncServe } from "@deepfates/lync/relay";

const server = await startLyncServe({ dir: "./rooms", port: 8787 });
console.log("relay on", server.port);
Expand All @@ -295,7 +295,7 @@ On an existing HTTP server:
<!-- example: fragment — embeds into an existing app server (free variables: app, checkSession) -->
```ts
import { createServer } from "node:http";
import { attachLyncServer } from "lync-core/relay";
import { attachLyncServer } from "@deepfates/lync/relay";

const httpServer = createServer(app);
const lync = attachLyncServer(httpServer, {
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ meaning should land in pacts, not in the envelope.

## Now

- First public release of the one package: `lync-core` (library, indexes,
- First public release of the one package: `@deepfates/lync` (library, indexes,
client, relay, and the `lync` command).
- Keep `FORMAT.md` and the test vectors aligned as the reference other
languages can port.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "lync-core",
"name": "@deepfates/lync",
"version": "0.3.0",
"description": "The lync format: append-only JSONL event logs merged by set union. Parsing, stores, views, looms, live sync, loom client, indexes, the sync relay, and the lync command. Zero dependencies.",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-readme-examples.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
// There is no other configuration: a new example is checked by default.
//
// ts blocks execute from inside the repo root — Node's package self-reference
// resolves "lync-core" and its subpaths exactly like an installed consumer —
// resolves "@deepfates/lync" and its subpaths exactly like an installed consumer —
// with cwd in a scratch dir so relative paths never touch the repo. bash
// blocks run with the `lync` command token rewritten to the workspace bin;
// `npm install` lines are skipped (noted), since installing is the reader's
Expand Down
62 changes: 61 additions & 1 deletion src/cli/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ import { decodeFrame, encodeFrame, extractLineId, isCursor } from "../sync-proto
* advances only after a received line has reached a durable local state —
* appended, recognized as a duplicate, or surfaced as unusable. A sync that
* cannot reach `live` within the timeout fails loudly; nothing hangs.
*
* The cursor stores the server's log generation alongside seq: a seq is only
* meaningful inside the generation that issued it (a broadcast whose disk
* write failed still consumed a seq, so after a server restart the recovered
* log can sit behind our cursor). When the server's `gen` differs from the
* stored one, the cursor resets to 0 and we resubscribe from scratch — union
* makes the re-download a set of duplicate no-ops, and nothing is skipped.
*/

export interface LyncSyncOptions {
Expand Down Expand Up @@ -50,6 +57,8 @@ interface Cursor {
url: string;
root: string;
seq: number;
/** Server log generation the seq belongs to. Absent: old cursor file or old server. */
generation?: string;
}

export async function syncOnce(options: LyncSyncOptions): Promise<LyncSyncResult> {
Expand Down Expand Up @@ -83,8 +92,43 @@ export async function syncOnce(options: LyncSyncOptions): Promise<LyncSyncResult
let watcher: import("node:fs").FSWatcher | undefined;
let pushChain = Promise.resolve();

// The generation our cursor belongs to. Adopted from the first gen-bearing
// frame; a later mismatch (server restarted) resets the cursor to 0 and
// resubscribes — duplicates are no-ops, silent skips are not.
let generation = cursor.generation;
// Every `sub` we send is answered by exactly one `live`, in order. After a
// generation reset re-subscribes, the earlier sub's live is stale — treating
// it as "live" would end a one-shot sync before the re-fetched backlog
// arrives. Count outstanding subs; only the last live counts.
let awaitedLives = 1; // the initial sub, sent on open

const persistCursor = () =>
writeFile(cursorPath, `${JSON.stringify({ url: options.url, root, seq: result.seq } satisfies Cursor, null, 2)}\n`);
writeFile(
cursorPath,
`${JSON.stringify({ url: options.url, root, seq: result.seq, ...(generation !== undefined ? { generation } : {}) } satisfies Cursor, null, 2)}\n`,
);

/**
* Returns true when the server's generation differs from the one our cursor
* was saved under — in which case the cursor has been reset to 0 and a fresh
* `sub` from 0 is already on the wire. Frames without gen (old server) never
* trigger a reset.
*/
const generationChanged = (gen: string | undefined): boolean => {
if (gen === undefined || gen === generation) return false;
if (generation === undefined) {
generation = gen; // first sighting: adopt, nothing to reset
return false;
}
options.err.write(
`lync sync: server log generation changed (${generation} -> ${gen}); resyncing ${root} from 0\n`,
);
generation = gen;
result.seq = 0;
awaitedLives += 1;
socket.send(encodeFrame({ t: "sub", root, since: 0 }));
return true;
};

await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
Expand Down Expand Up @@ -151,6 +195,10 @@ export async function syncOnce(options: LyncSyncOptions): Promise<LyncSyncResult
const frame = decodeFrame(typeof raw === "string" ? raw : new TextDecoder().decode(raw as ArrayBuffer));
switch (frame.t) {
case "ev": {
// A generation change resets the cursor and resubscribes; this
// frame's line is still ingested below (it is from the live
// generation), and the resubscribe re-covers everything else.
generationChanged(frame.gen);
const id = extractLineId(frame.line);
if (id === undefined) {
options.err.write(`lync sync: server sent a line without an id; surfaced, not appended\n`);
Expand All @@ -172,6 +220,13 @@ export async function syncOnce(options: LyncSyncOptions): Promise<LyncSyncResult
return;
}
case "live": {
// A stale live is not live: either its generation is dead (the
// resubscribe from 0 is already on the wire) or it answers a sub
// that a generation reset has since superseded. Wait for the real
// one — the backlog, then live, follows.
const changed = generationChanged(frame.gen);
awaitedLives -= 1;
if (changed || awaitedLives > 0) return;
clearTimeout(timeout);
result.seq = Math.max(result.seq, frame.seq);
if (!options.follow) {
Expand Down Expand Up @@ -225,6 +280,11 @@ async function readCursor(path: string, url: string, root: string): Promise<Curs
// get persisted as live — a permanent silent miss. Reset to 0 instead;
// re-receiving the backlog is a harmless union no-op.
if (stored.url === url && stored.root === root && isCursor(stored.seq)) {
// A non-string generation is noise from a hand-edited file: drop just
// the generation (the first gen-bearing frame re-adopts), keep the seq.
if (stored.generation !== undefined && typeof stored.generation !== "string") {
return { url, root, seq: stored.seq };
}
return stored;
}
// Different server/root, or an unusable cursor: start from 0.
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export * from "./errors.js";
// The node:fs-backed file store lives only at the explicit "lync-core/file-log"
// The node:fs-backed file store lives only at the explicit "@deepfates/lync/file-log"
// subpath so the main barrel stays importable in the browser with zero node builtins.
export * from "./idb-log.js";
export * from "./looms.js";
Expand Down
Loading
Loading