@deepfates/lync 0.3.0: one package, durable sync, ready to publish - #5
Merged
Conversation
Sweep confirmed no /Users, /home, /private/tmp, /var/folders, or file:// home paths remain in test vectors, fixtures, docs, or configs (the one CI caught in lore-views.test.ts was already fixed in 54aa178). Add scripts/no-machine-local-paths.sh: git-greps tracked files for machine-local absolute paths and exits non-zero if any reappear. Bare /tmp stays allowed (mktemp/os.tmpdir use it portably). Wired into 'pnpm verify' so it runs locally and in CI before tests.
A cold reader who clones and runs pnpm install && pnpm build has no 'lync' on PATH, so every bare 'lync <verb>' in the headline first-mile example failed with 'command not found'. Switch the block to 'pnpm exec lync' (the same invocation the Development section and fresh-clone-smoke.sh already use) and state up front that a global/published install drops the prefix. Re-ran the whole block verbatim from a fresh worktree: init, append, verify, view transcript, append to imported, merge, view tree all succeed; merged.lync is a 2-line deduplicated union.
Replace the escape-hatch typings in idb-log storage with concrete row types. getAll now annotates IDBRequest<T[]> (no 'as T[]' cast), clearAndPut is generic over the row type (no 'unknown[]'), and conflict/pending rows carry an explicit ConflictRow/PendingRow type modeling the composite [id, digest] key. dumpRecords() is called once per persist instead of three times. tsc --noEmit clean, 94 tests green.
- add publishConfig.access=public to both scoped packages (scoped packages default to restricted; publish would fail without this) - fix @lync/cli bin path (drop leading ./) so npm publish stops auto-correcting bin[lync] and warning it was removed - add repository/homepage/bugs metadata for usable npm pages - add per-package README so npm package pages are not bare Proven in a clean-room: pnpm-packed tarballs installed in a fresh npm project (no workspace links) exercise append/verify/lossless divergent merge via @lync/core and all five CLI verbs via the lync bin.
The browser lore bundle pulled node:crypto, node:fs, and node:path. Two
independent leaks, both fixed at the source (no fs shims, no fakes):
- node:crypto: events.ts and store.ts hashed via createHash("sha256").
Replaced with a dependency-free, synchronous pure-TS SHA-256 (lore/sha256.ts)
that runs identically in Node and the browser. Verified byte-for-byte against
node:crypto across NIST vectors, every padding-block boundary (len 0..200),
random fuzz, and subarray views (lore-sha256.test.ts).
- node:fs/node:path: file-log.ts is a genuinely node-only file store, but
looms.ts statically imported it (for createFileLoreLooms) and index.ts
re-exported it, poisoning every browser-reachable entry and the main barrel.
Moved createFileLoreLooms into file-log.ts (its node-only home) and dropped
file-log from the index barrel. The node file store now lives only at the
explicit @lync/core/lore/file-log subpath.
Result: @lync/core (barrel), /lore/events, /store, /idb-log, /views, /looms,
/memory-log, and @lync/client/browser all bundle for platform=browser with
zero node builtins. Wire kind strings (lore/*) untouched. README updated to
import createFileLoreLooms from the file-log subpath.
…7-08): @Lync npm org unavailable; prefix-as-namespace across workspace, docs, imports
Pre-handshake, re-send the idempotent join on a bounded interval; if the handshake still hasn't completed, fail loudly and close so the reconnect path takes over. Duplicate peer replies no longer re-announce the peer. Honest status: fixes a real silent-hang path (join or peer-reply lost on an OPEN socket left the adapter waiting forever), but does NOT cure the suite flake — 3/30 full-suite failures remain, all correlated with '[Lync] repo shutdown failed: DocHandle is not ready' bleeding from the Automerge repo teardown (sync-server/src/index.ts:198-204 swallows it).
- front window is lync-core + lync-cli only; the Automerge transport (client ws-sync, sync-server, index), browser repo wiring, and the migration script move to branch attic/automerge-transport - full public-API rename: parseLyncFiles, LyncUnion, serializeLyncEvent, createLyncLooms, lync* views; core subpaths flatten (lync-core/events, /store, /views, /looms, /file-log, /idb-log, /memory-log) - wire cutover: kinds are lync/* (spec, vectors regenerated with fresh digests, tests); loom ids mint lync:; file store reads .lync only; no legacy .lore reading, no migration story - lync-core has zero runtime dependencies; root automerge deps removed - FORMAT.md drops the legacy-vocabulary caveat; README describes exactly what ships and nothing else
The dumb event-union relay from the line-sync design: five frames (sub/ev/live/presence/err), per-root append-only .lync storage, seq as resume cursor, echo-safe under union, same-id conflicts surfaced to both sides and never resolved, truncated tails sealed as damaged and surfaced, presence relayed never stored, optional bearer-token auth. Client verb converges any file one-shot or stays live with --follow (pushes local appends, appends remote events, persists the cursor). Protocol codecs are pure and dependency-free in lync-core/sync-protocol; the ws wiring lives in the CLI. Acceptance from the design ticket holds: two clients converge, offline resume is exact, kill-9 recovery surfaces, unreachable relays fail loudly instead of hanging. 71 tests, 30 consecutive green full-suite runs including the live relay tests.
The CLI file-sync shipped earlier served terminal users; it did not serve the real consumer story — an app that embeds lync, opens looms through an index, and shows live collaborative edits in-process. textile is exactly that (its data path is already union-based; its /lync socket was only a heartbeat). This builds the missing piece. lync-core: - createSyncedStore(store, transport): wraps any EventStore. Local appends push to the relay; remote lines ingest through union. Looms and indexes recompute reactively via the store's existing subscribe — no merge logic, because immutable events + union-by-id make redundancy harmless. - createWebSocketTransport: zero-dep, browser + Node (global WebSocket), reconnecting, queues sends while offline. Transport is an interface, so the decorator is testable without sockets. - loomRootId(loomId): the store root for a loom, so apps can sync a loom's root before opening it. Revived on the native protocol (Automerge dropped entirely): - lync-index: the memory loom index (reactive subscribe), no automerge. - lync-client: createLoomClient facade (looms + indexes + reference resolution), the automerge repo coupling and transport files removed. Proof: - core unit tests (mock transport): push, reactive remote ingest with no echo loop, status, reconnect re-push. - cli integration test (real relay, two clients over global WebSocket): a turn on client A reaches client B's loom reactively, both directions. - client facade test; index memory test. - 83 tests, 30 consecutive green full-suite runs including live sockets; all four packages typecheck; fresh-clone smoke green. - clean-room from tarballs: all four packages installed in a fresh project, a relay started from the bin, two embedded clients converge reactively. Published surface is now four packages: lync-core, lync-cli, lync-index, lync-client.
Extract the relay from the CLI into lync-server so apps can embed it:
- createLyncRelay({ dir, token?, authenticate?, onConnection? }): handleUpgrade
/ handleConnection / close — mount on any Node server.
- attachLyncServer(server, { storageDir, path, authenticate, keepAliveInterval,
maxConnections }): adds an upgrade listener for one path, passes the rest
through. This is the shape textile's server mounts.
- startLyncServe(): standalone, used by the CLI.
The CLI now imports startLyncServe from lync-server instead of its own copy.
lync-client gains createTestLoomClient (memory looms + index, deterministic
with createId/now) for app tests.
Tests: relay mounted on an app server converges two clients; attach filters
its path and passes others through; authenticate rejection blocks a client.
Published surface is now five packages. 86 tests, all typecheck.
Socket teardown and wss.close()/httpServer.close() callbacks can block indefinitely on some runtimes (bun). Relay close now races an orderly shutdown against a hard cap and flushes pending appends first; standalone serve drops lingering connections, unrefs the listen handle, and caps the close wait. Production embedding via attachLyncServer is unaffected (it owns no server), but this keeps graceful shutdown honest everywhere. 86 tests, 20 consecutive green.
…ew fixes) A rival-team review found a silent-black-hole path and two minor gaps: - MAJOR: a single appendFile failure permanently wedged a room — the rejected writeChain skipped every future write, and the throw skipped the broadcast, so the room silently accepted events into memory while persisting and delivering nothing. Now appendSerialized clears prior rejections, keeps the chain resolved (recoverable), and reports ok/fail; the ev handler broadcasts regardless of persistence and emits a loud 'persist-failed' err on durability failure. Nothing fails invisibly. - recoverRoom no longer replays a damaged/sealed-truncated line as a phantom event: it stays on disk (never eaten) but is not served. - close() flushes every pending append BEFORE the capped socket teardown, honoring the interface's durability promise. Regression test: a read-only relay dir forces append failures; the room still broadcasts both events and surfaces a persist-failed err for each, proving live delivery survives and the failure is never hidden. Cosmetic: test fixtures/descriptions cleaned of lore/automerge vocabulary. 87 tests, 15 consecutive green.
CI runs 'pnpm verify' = guard && test && typecheck with no build step, so bare-specifier imports must resolve to src. lync-core and lync-index were aliased but lync-server (imported by the CLI serve/sync tests) and lync-client were not, so vitest failed to resolve lync-server's dist entry in CI. Added the missing src aliases; verify now passes with no dist.
…nesty (dee-inzc) A rival-family (codex) pre-publish review found what same-family review missed, both with working repros: BLOCKER — fractional resume cursors skipped the backlog silently: decodeFrame accepted any nonnegative number for sub.since, the relay indexed lines[0.5] = undefined (malformed ev frames), the client appended nothing, then persisted the live seq — a PERMANENT missed backlog. Fixed at every entry: isCursor() (nonnegative integer) enforced in decodeFrame for sub.since / live.seq / ev.seq, and readCursor() resets an unusable cursor file to 0 (re-receiving the backlog is a harmless union no-op). MAJOR — same-id conflict variants could be silently dropped: the sidecar append's failure result was ignored, so clients were told same-id-conflict (implying retention) while the bytes vanished with only a server log. The relay now emits conflict-persist-failed to sender and subscribers when the sidecar write fails. Regressions added for both (fractional cursor file → full backlog received, cursor repaired; read-only dir → conflict-persist-failed surfaced). 90 tests, 10/10 consecutive green.
Docs (adopter-facing truth): - README: resolve the TODO(positioning) marker that shipped on the front page; seven verbs (serve/sync were omitted — the flagship feature); lync-server added to the Packages section (it was absent entirely). - lync-cli README: seven verbs, serve/sync described. - ROADMAP rewritten to post-sync reality: no internal ticket ids, no Automerge-transition language, no publish-sequencing that contradicts the release. Spec+writers (close the conformance gap both ways): - FORMAT.md id row: generators mint UUIDv7; importers transcribing pre-existing events may derive deterministic UUIDv8 from source identity (re-import = union no-op; upstream edit = loud same-id conflict). - Shipped writers now actually mint UUIDv7: new zero-dep lync-core/uuid, wired into looms' default createId and the CLI append fallback (was UUIDv4 randomUUID, nonconforming with our own spec). Publish process hardening: - engines: node >=22 on all five packages (synced-store needs global WebSocket; CI pins 22) and prepublishOnly build scripts so a stale or missing dist cannot ship. - rm stray packages/sync-server dir (node_modules-only leftover). 91 tests green (adds uuidv7 shape/order/uniqueness test).
import.md writes down the shipped splice producer convention: deterministic UUIDv8 from source identity (re-import = union no-op, upstream edit = same-id conflict, and that's a feature), the five-axis author envelope with imported_by never actor, source-namespaced kinds, complete-source payloads, RFC 3339 at with surfaced repairs, opt-in marked for byte determinism, zero silent drops with a reconciliation invariant, and verify-clean output. Grounded in splice src/outputs/lync.ts + tests; example id is reproducible from the stated recipe. export.md codifies exports as regenerable projections that say what they dropped: the two blessed families (readable transcripts via branch-tree/ transcript views; training data via the leaderboard view over score/selection annotations, matching FORMAT.md's worked example), provenance columns back to event ids, and the never-list (invent content, silently resolve conflicts, leak suppressed payloads, claim authority). Honest v0: column schemas listed as open points.
A stranger's first contact with lync is a package page on npm, not the repo README. That surface was half-built: - Per-package READMEs were stubs (152-731 bytes); core's example didn't even run. All five are now real pages: pitch, install, runnable examples, guarantees, links to the spec and pacts. - No tarball shipped a LICENSE (root LICENSE doesn't ride files:[dist]). Every package now carries its own. - Zero keywords anywhere; lync-core's description still said 'addressable looms' (pre-cutover vocabulary). Fixed both. - The repo README named pacts without linking them; import/export pacts are now discoverable. Enforcement: scripts/readme-examples-smoke.mjs extracts the fenced examples from every package README and executes them against the built packages (self-reference resolution, scratch cwd). It already caught four lies in my own drafts (upsert->addLoom, open->openReference, missing toUrl location arg, wrong lync-index import path) before they shipped. Wired into pnpm verify, so CI now fails if a doc example rots.
Replaces readme-examples-smoke. The old script carried a hardcoded list of which fenced blocks to run and which to skip — configuration that would drift the moment anyone adds an example. Now the READMEs carry their own contract: every fenced ts/bash block executes by default; a block that can't run alone declares itself with an <!-- example: fragment — reason --> comment directly above the code (invisible on npm), and long-running examples declare the output line that proves startup. The checker is a dumb executor with no special cases, and it also fails if a published package has no README or no examples at all. cli merge story made self-contained (append creates other.lync in-story) instead of the runner conjuring the file.
Refutation from the dee-07pu adversarial review (splice session-importer port): parseLyncFiles ran an unmemoized recursive findCycle from EVERY accepted id — per-step path copying and linear path scans made deep parent chains (the exact shape codex session importers emit) ~10x slower per doubling. Measured: n=1000 1.8s, n=2000 15.4s, n=4000 152.6s; the largest real corpus file (107,711 chained events) extrapolated to hours-to-days. Recursion also meant stack overflow was one deep loom away. Rewrite: one shared iterative three-color DFS over the whole graph. O(events + parent edges); the 107k chain now parses in 1.6s (n=4000: 75ms). Each distinct cycle is reported once in canonical rotation (smallest id first) instead of one rotation per member in file-line order — cycle output is now order-independent, same disease family as the leaderboard float-order note. 93/93 with the gauntlet shapes as regression tests.
…ences carry chosen/shown dee-aeq1: lyncLeaderboardView accumulated scoreTotal in file-line order, so scoreMean's float addition order (and its low bits) depended on input file order. The view now walks annotations by sorted id, making means bit-stable across shuffled files; regression test parses the same events in two file orders and asserts Object.is on scoreMean. dee-oh98: LyncSelectionReference gains additive chosen/shown string-array fields populated from the annotation payload (empty arrays when absent), so consumers no longer re-read the annotation event to enumerate alternatives.
Owner
Author
|
...should this be one package no? |
…ncies — truthfully
Design ruling dee-6prz (2026-07-12, 4-lens adversarial panel):
- Fold lync-index -> lync-core/indexes{,/entries,/memory,/types},
lync-client -> lync-core/client{,/testing,/types},
lync-server -> lync-core/relay, lync-cli -> the lync bin.
Every prior core subpath kept; subpath parity is a consumer contract.
- NO ws declaration at all (an optional peer ws@^8 hard-ERESOLVEs consumers
carrying ws@7; no declaration = that failure class cannot exist). The relay
acquires ws lazily via createRequire inside the still-synchronous factory;
a missing ws throws one helpful error. Bundlers must mark ws external.
- Kill the type leak: ws ships no .d.ts, so the relay's public types are
structural (LyncRelaySocket) — tsc for a consumer without ws passes.
- cli sync rewritten onto Node's built-in WebSocket (engines >=22):
EventTarget listeners, MessageEvent.data (arraybuffer, never Blob),
close()-based hard-abort instead of terminate(). Sync tests unmodified
in what they assert.
- One README telling one story: format first, conformance vectors as product,
then library / command / relay. FORMAT.md gains the layering paragraph —
sync protocols stay deliberately absent from the format.
- Version 0.3.0 deliberately: same-version tarball swaps defeat npm caching.
…ghost, no packages/ path)
…eration that issued it (dee-u6tq)
recoverRoom mints a random generation id (never persisted: every restart is
a new generation), carried additively on ev/live frames. The CLI sync cursor
file stores {seq, generation}; a mismatch resets to 0 and resubscribes —
union makes the re-download duplicate no-ops. Stale lives from superseded
subs are counted, not trusted. Regression test is the bug's exact shape:
persisted events, a broadcast whose disk write failed consuming a seq, a
restart, a stale cursor — the client ends with every persisted event
(verified failing with the reset neutered).
…(dee-s6dc) Frames apply strictly in arrival order (serialized chain). A store-write failure freezes the root's cursor — live frames cannot leapfrog it — and screams through the new additive SyncStatus.failures channel; the line is re-fetched on the next resubscribe. Conflicts and garbage are surfaced too. Also carries the generation reset (dee-u6tq) on the synced-store side, with stale-live counting so a superseded sub cannot re-poison a reset cursor. Neuter-verified: freezing disabled makes the regression tests fail.
…(dee-i1wc) The synced store swallowed all relay errs except same-id-conflict — a persist-failed durability failure fanned out but never reached the client's status channel, so a store consumer could not see the relay lose a write. Route every non-conflict relay err (persist-failed, conflict-persist-failed, recovered-damaged-tail, line-without-id, server-error, ...) into the failures channel. Nothing fails invisibly. Unit test asserts persist-failed surfaces and is not miscategorised as a conflict.
A real relay plus real synced stores over the global WebSocket, run through the full gauntlet against one shared root: (a) a client's socket drops mid-stream and auto-reconnects; (b) the relay's .lync file goes read-only, so a write is fanned out but refused by disk and surfaced as persist-failed; (c) the server restarts into a new log generation. Final invariant: every event a client successfully appended — including the one the dead disk refused — ends up in every other client's store AND on the relay's on-disk .lync file, and every failure (persist-failed, generation-changed) was surfaced on every client's status channel. Neuter-verified: dropping the err surfacing fails leg (b); neutering the generation reset fails leg (c).
A relay whose disk write failed kept the line in memory and broadcast it, but a same-generation re-push was a byId duplicate no-op that never retried the write. The durable log stayed silently incomplete until a restart rebuilt byId from disk -- undercutting the format's thesis that the saved log is the truth. Now each room tracks its unpersisted lines (ordered by id = append order). persistPending() drains them, in order, as one serialized unit before the next append to the room, and a same-line re-push retries the write instead of no-oping. A successful flush clears the line; a still-dead disk stops the drain (later lines stay pending, never reordered) and re-surfaces persist-failed. The on-disk log heals on its own, no restart required. Proof: test/cli/loss-free-trial.heal-without-restart.integration.test.ts -- disk fails for X (X reaches all clients, off disk, persist-failed surfaces), disk heals, another client appends Y to the same root with NO restart, and both X and Y land on disk in append order and in every client's store. The retry is load-bearing: neuter the pending flush and the leg fails.
Give operators a way to see inside a running relay. status() returns a
per-room snapshot { root, generation, seq, subscribers, pendingUnpersisted }
where pendingUnpersisted is the durability lag the dee-1pfp retry-persist
work introduced (lines in memory but not yet on disk).
Strictly additive and read-only: it reads existing room state via a side
map of resolved rooms and mutates nothing — no touch to the write path,
sync protocol, union path, or close(). Threaded through startLyncServe
(LyncSyncServer.status) and attachLyncServer (AttachedLyncServer.status);
LyncRoomStatus exported from the relay subpath.
Tests: N-room subscriber/seq accuracy; the durability-observability case
(disk write fails -> pendingUnpersisted >= 1, heal + next append -> back to
0); and that repeated status() calls leave seq/subscribers unchanged.
README relay section shows status() as a runnable daemon example.
The npm landing page opened with the dense technical summary. It now opens with the human version: most software forgets; lync keeps every version by only ever adding, never editing; and everything good (full history, safe merging, honesty on conflicts, a file that outlives its tools, training data from your own choices) falls out of that one rule. The precise spec, the conformance vectors, and every runnable example are unchanged below it. check-readme-examples still passes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
lync is a file format for keeping interaction history. Conversations, stories, and the notes people make about them. A
.lyncfile is plain text, one JSON object per line, and each line is one event that never changes. You correct or retract something by adding a new event that points back at the old one, never by editing in place. Merging two copies means keeping every event that appears in either copy. There is no merge logic to get wrong, because two copies of the same event are the same event, and two different events that claim the same id are both kept and flagged out loud.@deepfates/lyncis the reference implementation, and after this change it is one package with no dependencies. It holds the parser, the event stores, the computed views, the loom and turn API, live sync, thelynccommand, and the sync relay. A program that only wants to read a.lyncfile installs nothing extra. The relay needs one library called ws, and it asks for that only if you actually run a relay.Merging this says: this is @deepfates/lync 0.3.0, one name, one story, ready to publish.
What is in the box
lynccommand. init, append, verify, merge, view, serve, and sync..lyncfile you can open with any lync tool. You can also ask a running relay for its status: for each room, how many people are connected and how many writes are still waiting to reach disk.Durable sync, and how we proved it
The point of sync is that what one person sees saved is really saved. This change makes that true and proves it. Three fixes and one proof.
The relay used to treat sending an event and saving an event as the same thing. If a disk write failed, the relay could still tell a reconnecting client it was up to date, and the client would skip the event forever. The relay now tracks which log it is on, and a client that reconnects after a restart starts over from a safe point instead of trusting a stale marker.
The receiving side used to record how far it had gotten before it had actually stored the event. Now it stores the event first, checks that the store succeeded, and only then records progress. A failed store freezes progress and reports the failure out loud instead of losing the event.
The relay used to keep a line only in memory if its disk write failed and the disk later healed without a restart. That line would stay off disk until the next restart. Now the relay retries the write on the next activity, so its saved log catches up on its own.
The proof is a test called the loss-free trial. It runs a real relay and three real clients, then disconnects a client and reconnects it, fails the relay's disk and heals it with no restart, and restarts the whole server. After each step it checks that every event anyone successfully wrote is in every client and on the relay's disk, and that every failure was reported out loud. It runs clean five times in a row with no flake. A reviewer confirmed each fix is load-bearing by removing it and watching the trial fail.
How it was checked
111 tests, green on every commit. Every code example in the README is run by CI, so a broken example fails the build. A fresh-clone script proves the install and first use. The whole change went through an adversarial panel on the package shape, a steel-on-steel debate, and repeated hostile review of the sync code, which is the code most able to lose data quietly. Each real bug it found is now a test.
The edges we looked at, and where they landed
Both loose ends from the durability work are now settled, not left hanging. The relay had no way to show its own state, so we added the read-only status method above. And we looked at one more shutdown edge, where a graceful shutdown during a disk outage leaves a line to reach disk on the next restart, and chose not to touch it. Nothing is lost in that case because every client still holds the line, and the fix would have added risk to the shutdown path that the loss-free proof depends on. It is recorded as a deliberate decision, not an oversight.
After merge
You log in to npm, then the one package publishes. After that, splice and textile move from the vendored copy to the published package.