fieldlog 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,109 @@
1
+ # fieldlog changelog
2
+
3
+ All entries describe user-visible changes shipped under each tag, in tag order.
4
+ Untagged commits are folded into the next tag that shipped them.
5
+
6
+ ## Unreleased
7
+
8
+ (No unreleased changes yet.)
9
+
10
+ ## v0.15.0 — truncate guards, fail-closed gates, relay budgets
11
+
12
+ - Retention: `truncate()` guarded by `guardSeal` — sweep stops at the acked/applied prefix, skips legally-held events, and never splits a tombstone hide/show pair; report names held events and pairs that blocked the rest.
13
+ - Read-model: tamper gate fails closed on hash mismatch (no silent merge of edited payloads); swept UUIDs recorded in `_swept_ids` so re-appending an excised id throws instead of forking log vs store.
14
+ - Hash chain: `verifyChain` checks seq continuity plus hash/prev linkage, with `opts.base`/`opts.startSeq` to pin a post-sweep base (foreign `prev_hash` hints at `log.verify()` instead of failing cryptic).
15
+ - Quota wired: `createKernel({ quotaLimitBytes })` reserves per append and throws `ERR_QUOTA_EXCEEDED`/`ERR_QUOTA_UNKNOWN` fail-closed before anything is written; `kernel.quota()` reports usage.
16
+ - Relay budgets: per-batch event-count and per-event byte caps on push, revoke push, and raw frames; pull paginated with count + total-byte caps (client walks pages to the global cursor); oversized startup file refused loud instead of OOMing; byte sizes measured in UTF-8, not UTF-16 length.
17
+ - Auth: `CapToken.notBefore` activation floor (early token fails closed); corrupt delta-sync resume/dead-set meta throws instead of silently refetching or resurrecting poison.
18
+ - Surface: `health()`/`verifyLog()` expose `skipped` poison lines (open-time replay warns `WARN_REPLAY_SKIPPED`); CAS quarantine persisted in the manifest (`has` hides quarantined keys, `stat` reports the sidecar, `gc` skips them); tombstone `show` atomic with same-replica check; snapshot lock file serializes cross-process writers (`ERR_SNAPSHOT_IN_FLIGHT`); torn-tail repair fsyncs file + dir.
19
+ - Compat: interop-seal test skips when the sibling checkout is absent (green CI without it).
20
+ - Bench (2026-09-12, slice fa03a62, bun 1.4.0): append 262/s (p50 3.06 ms, p99 7.89 ms); that cost is fsync-per-append + SQLite apply — durability, not overhead. Full numbers in `docs/bench.md`, the single source of truth.
21
+ - Decisions: `docs/decisions.md` records five standing calls (epoch-less token reissue, `dist/` out of scope, plaintext relay behind WireGuard, no claim engine here, dated single-source bench pins).
22
+
23
+ ## v0.13.0–v0.14.26 — folded (no per-tag notes; untagged waves per the rule above)
24
+
25
+ - Auth: granular per-token capabilities (named TTLs) enforced per operation; revoke event log with convergent merge, revoke handshake on every relay connect/pull, revoke quarantine + retroactive purge on sync.
26
+ - Storage: sha-keyed CAS store (refcount, quarantine); tombstone soft-delete engine with GC guard + legal hold; quota guard with reserve + fail-closed admission.
27
+ - Sync: manifest-first delta sync (want-list resume, UUID idempotency); hash-chain-log facade with quarantine + re-anchor.
28
+ - Compat: v0.5 fixtures + conformance runner (writer superset rule).
29
+ - Rig & harness: two-device rig, seeded corpus + corruption generators, bench-honesty doc + check script, cold-drill log-only recovery, model oracle, soak runner, chaos-kill drill, flake-hunter, pre-merge conformance gate, watchdog, mismatch-stop, completion protocol, ff-only merge runner.
30
+ - Audit + perf waves: typecheck clean (`PushResult` exported, relay rng field, CAS writeSync narrowing); 30 audit suspects fixed (relay fail-closed persist — no ack for unwritten events, ack only stored ids; store seq-vs-id collision no longer swallowed; fractional value rejected; sync dead-letter cursors; retain empty-guard returns 0; revokelog convergent tie-break; tombstone guard covers show/target; quota remaining clamped; canonical payload key order; duplicate-id append rejected; seq-gap verification; device mismatch throws with first-explicit adoption; threshold misconfig throws; CAS orphan sweep + EEXIST tolerance + fstat + guarded quarantine; deltasync real dead-letter list + honest fetched metric); kernel split healing O(1) steady-state; relay liveBuf dedup via persistent Set, token verdicts cached per revoke size, incremental `purgeRevoked`, deterministic backoff jitter by default. Suite: 218 tests green, tsc clean.
31
+ ## v0.14.27 — docs, gallery, universal wire keys
32
+
33
+ - Wire keys universalized (no commerce terms): `entry`/`value`/`tally`/`resolve`, device ids; English CLI/demo/scripts/tests narrative (schema field `oleh` kept, fixtures kept).
34
+ - Docs: README is now the entry point + index; one concern per topic page (install,
35
+ quickstart, architecture, kernel-api, sync-protocol, relay, retention,
36
+ auth, contracts, cli, limits-troubleshooting), API tables verified against `src/`.
37
+ - New: `SECURITY.md`, `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md` (solo/small-team).
38
+ - Bench: full numbers in `docs/bench.md` (single source of truth for figures).
39
+ - Repo: lean tarball (no docs), OIDC tag-publish provenance, byte-exact fixtures via gitattributes.
40
+
41
+ - Gallery: 8 APNG explainers + logo in README (docs/gifs/, outside the tarball).
42
+ ## v0.1.0 — offline kernel
43
+
44
+ - Initial release: offline-first `createKernel({ file })` with `append` / `query` / `undo`, no network needed for local writes.
45
+ - Append-only JSONL log with a sha256 hash chain per event (`GENESIS` anchor, `tail -f` friendly).
46
+ - SQLite read-model via `bun:sqlite` (plain SQLite file, opens in external tools); money honesty rule (offline money is only RECORDED, resolution needs online ack) and fail-fast `checkAppend` so rejected writes leave no log line behind.
47
+ - Device identity: per-device keypairs, event signing/verification, scope grants, countersignatures with threshold check, revocation list.
48
+ - Sync core: `MemoryRelay`, `pushPending` / `pullRemote` / `syncKernel` with ack-cursor resume and backoff; open conflicts queryable via `kernel.conflicts()`; `undo` as compensating events.
49
+
50
+ ## v0.2.0 — real ws relay
51
+
52
+ - `WsRelayServer` / `WsRelayClient`: real websocket transport over `Bun.serve`, no extra dependencies.
53
+ - Client reconnects with backoff and jitter and resumes from the ack cursor; server live-broadcasts arrivals as a hint (pull stays the source of truth) with heartbeat ping/pong.
54
+
55
+ ## v0.3.0 — hardening and clock skew
56
+
57
+ - Corrupt log lines are quarantined to `<log>.quarantine` (with forensics) instead of failing open; torn tail writes from a mid-append kill are truncated on open and reported via `repairedTail`.
58
+ - Wall clocks are display-only: `ts_device` never decides order, `kernel` accepts an injectable `clock`, and ordering stays on the monotonic seq even with 30-minute device skew.
59
+ - New `kernel.health()` (`events`, `quarantined`, `repairedTail`, `gaps`) and `verifyLog()` gap reporting for re-anchored seqs.
60
+ - `kill9` tests fixed to run from any working directory; new `demo/two-node.ts` (20 offline sales, then two-device sync with equal totals).
61
+
62
+ ## v0.4.0 — relay survives restart
63
+
64
+ - Relay persists every stored event to a JSONL file before acking; kill and restart resumes exact-once by event UUID from the client ack cursor.
65
+ - Stress coverage with 10 concurrent clients; README rewritten as operator docs.
66
+
67
+ ## v0.5.0 — snapshot, truncate, atomic apply
68
+
69
+ - Retention: `kernel.snapshot(dest)` captures the read-model plus sealed seq; `kernel.truncate()` sweeps the sealed log prefix behind a `fieldlog-truncate` marker that chains the kept suffix to the removed prefix.
70
+ - Atomic apply: a kill between log append and read-model apply can no longer orphan an event; reopen replays the log into the store idempotently.
71
+
72
+ ## v0.6.0 — cli
73
+
74
+ - New `bin/fieldlog.ts`: `serve --port <n> --file <relay.log>` runs a file-backed ws relay, `sync --file <ledger.db> --relay <ws url>` pushes and pulls a kernel file's delta, `demo` runs the two-node roundtrip and proves equal totals.
75
+
76
+ ## v0.7.0 — relay capability tokens
77
+
78
+ - Relays can require capability tokens: `kernel.capToken(privateKeyPem, scopes, ttlMs)` mints push/pull-scoped tokens, the server verifies and enforces them per operation.
79
+ - Revoked devices are broadcast to connected clients and token state persists across relay restarts.
80
+
81
+ ## v0.8.0 — failover and bounded outbox
82
+
83
+ - `kernel.sync` accepts an ordered relay list: failing relays cool down with backoff, get re-probed, and the list order decides fail-back.
84
+ - Bounded outbox: `append` refuses with `ERR_OUTBOX_FULL` once unsynced events reach the cap (default 50_000, tunable via `maxPending`), so an offline device fails fast instead of growing unbounded.
85
+
86
+ ## v0.9.0 — v0.5 log compat
87
+
88
+ - Forward-compat guarantee: v0.5-era log files replay byte-for-byte on the current build (fixture test), with the writer superset rule documented in `docs/compat.md`.
89
+
90
+ ## v0.9.1 — poison, forgery, late undo
91
+
92
+ - Poison pull events are quarantined instead of wedging sync in a livelock; the cursor advances past them.
93
+ - Pull events whose origin signature does not verify against the trusted device key are rejected.
94
+ - `undo` (and `resolve`) targeting an event that has not arrived yet parks and resurrects: the compensation applies once the target arrives late instead of being lost.
95
+
96
+ ## v0.10.0 — ack implies stored
97
+
98
+ - The ack cursor only advances over events the relay actually acknowledged, closing the ack-without-store hole.
99
+ - `truncate` clamps the seal to the stored and acked prefix, so a sweep can no longer delete unacked or unapplied data (stale seals become safe no-ops).
100
+
101
+ ## v0.11.0 — review highs, failover stripe
102
+
103
+ - Kernel appends are signed; per-relay pull cursors; CLI signed by default.
104
+ - Failover stripe backfills the acked prefix on relay switch.
105
+
106
+ ## v0.12.0 — release hygiene
107
+
108
+ - MIT license, pack files list, README install quickstart + docs links.
109
+ - CLI die race fixed: sync stderr write before exit; signed serve documented.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 arfoux
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ <p align="center">
2
+ <img src="docs/gifs/logo.svg" alt="fieldlog logo — event log with verification stamp" width="128">
3
+ </p>
4
+
5
+ # fieldlog — Fieldlog
6
+
7
+ Write anywhere, resolve later.
8
+
9
+ Offline-first event log for apps that keep writing through outages and
10
+ sync later: append-only log (source of truth) + SQLite read-model + sync-later.
11
+ Works for game events, file versions, telemetry samples — a ledger entry
12
+ (below) is one domain, not the whole story.
13
+
14
+ ```js
15
+ import { createKernel } from 'fieldlog';
16
+
17
+ const k = await createKernel({ file: 'ledger.db' });
18
+ await k.append({ type: 'entry', value: 5000, actor: 'device-01' });
19
+ const rows = await k.query('SELECT SUM(value) AS total FROM entries WHERE voided = 0');
20
+ console.log(rows[0].total); // 5000 — RECORDED, not resolved
21
+ k.close();
22
+ ```
23
+
24
+ Any event shape is stored and synced — game kills, file versions, telemetry
25
+ samples ride the same log:
26
+
27
+ ```js
28
+ import { createKernel } from 'fieldlog';
29
+
30
+ const k = await createKernel({ file: 'app.db' });
31
+ await k.append({ type: 'kill', killer: 'player-1', victim: 'boss-3' });
32
+ await k.append({ type: 'version', file: 'notes.txt', rev: 3 });
33
+ await k.append({ type: 'sample', sensor: 'temp-1', celsius: 21.5 });
34
+ k.close();
35
+ ```
36
+
37
+ Offline writes are stored as `DRAFT`/`RECORDED` (pre-resolved); sync/ack happens when online.
38
+ `append`/`query`/`undo` never touch the network — only `sync` does. Any event
39
+ shape is stored and synced; the read-model projects `entry` / `tally` / `undo`
40
+ into queryable tables.
41
+
42
+ ## Getting started
43
+
44
+ - [quickstart](docs/quickstart.md) — 1 device offline, 2 devices syncing (dev + signed mode), runnable
45
+ - [cli](docs/cli.md) — `serve` / `sync` / `demo`, every flag verified against `bin/fieldlog.ts`
46
+ - Two demos, one regime each, no surviving state: `bun run demo`
47
+ (`demo/two-node.ts`, unsigned dev, fixed port 8091) vs
48
+ `bun bin/fieldlog.ts demo` (`bin/fieldlog.ts:cmdDemo`, signed, ephemeral
49
+ port). Either proves 20-entry totals then exits; neither graduates to
50
+ the other (unsigned rows carry no signatures). Real ledger example:
51
+ `example/ledger.mjs` (`bun example/ledger.mjs`)
52
+ - Single writer, one process per file: the kernel mutex is cooperative
53
+ in-process only (`src/kernel.ts:145-156`). Details:
54
+ [kernel-api](docs/kernel-api.md),
55
+ [limits-troubleshooting](docs/limits-troubleshooting.md).
56
+
57
+ ## Gallery
58
+
59
+ | | |
60
+ |---|---|
61
+ | <img src="docs/gifs/part1-log.png" alt="Every append seals to the previous entry, forming a hash chain." width="480"><br>hash chain — every append seals to the previous entry | <img src="docs/gifs/part2-sync.png" alt="Delta sync sends only the diff, resuming from the last ack." width="480"><br>delta sync — only the diff flies, resuming from the last ack |
62
+ | <img src="docs/gifs/part3-relay.png" alt="Offline devices exchange messages through the relay server." width="480"><br>relay — offline devices exchange messages via the server | <img src="docs/gifs/part4-retain.png" alt="Snapshot plus truncate trims the log without losing the trail." width="480"><br>snapshot+truncate — trim the log without losing the trail |
63
+ | <img src="docs/gifs/part5-auth.png" alt="Signed capability tokens gate access, and revocation is ruthless." width="480"><br>capability+revoke — signed tokens, ruthless revocation | <img src="docs/gifs/part6-quarantine.png" alt="Corrupt entries are quarantined, never silently dropped." width="480"><br>quarantine — corrupt entries jailed, never silently dropped |
64
+ | <img src="docs/gifs/part7-readmodel.png" alt="The SQLite read model is rebuilt from the append-only log." width="480"><br>read model — SQLite rebuilt from the log | <img src="docs/gifs/part8-tombstone.png" alt="Delete writes a tombstone, so history stays intact." width="480"><br>soft delete — delete = tombstone, history stays intact |
65
+
66
+ ## Concepts & architecture
67
+
68
+ - [architecture](docs/architecture.md) — module map: log/store/kernel/sync/relay/cas/retain
69
+ - [contracts](docs/contracts.md) — binding promises: dead-letter, blind compensators,
70
+ seal<=ack, device.explicit, incremental purge, deterministic jitter, v0.5 superset
71
+ - [kernel-api](docs/kernel-api.md) — `createKernel`, `Kernel`, `LogEvent`, `EventStore`
72
+ - [sync-protocol](docs/sync-protocol.md) — push/pull, failover, backoff, deltasync
73
+ - [relay](docs/relay.md) — `WsRelayServer` + `WsRelayClient`
74
+ - [retention](docs/retention.md) — snapshot + truncate
75
+ - [auth](docs/auth.md) — device key, grant, capability token, countersign, revoke
76
+
77
+ ## Subsystems (details)
78
+
79
+ - token: [capability-token](docs/capability-token.md) · revoke:
80
+ [revoke-handshake](docs/revoke-handshake.md),
81
+ [revoke-event-log](docs/revoke-event-log.md) — regrouped in [auth](docs/auth.md)
82
+ - delta-sync: [delta-sync](docs/delta-sync.md) · hash chain:
83
+ [hash-chain-log](docs/hash-chain-log.md) · quarantine: [quarantine](docs/quarantine.md)
84
+ - attachments: [cas-store](docs/cas-store.md) · soft-delete:
85
+ [tombstone-engine](docs/tombstone-engine.md) · quota: [quota-guard](docs/quota-guard.md)
86
+ - rig & harness: [multi-device-rig](docs/multi-device-rig.md),
87
+ [corpus-generator](docs/corpus-generator.md),
88
+ [corruption-generator](docs/corruption-generator.md),
89
+ [cold-drill](docs/cold-drill.md), [soak-runner](docs/soak-runner.md),
90
+ [chaos-kill](docs/chaos-kill.md), [flake-hunter](docs/flake-hunter.md),
91
+ [conformance-gate](docs/conformance-gate.md), [watchdog](docs/watchdog.md),
92
+ [mismatch-stop](docs/mismatch-stop.md), [completion-protocol](docs/completion-protocol.md),
93
+ [merge-runner](docs/merge-runner.md), [model-oracle](docs/model-oracle.md),
94
+ [compat-vectors](docs/compat-vectors.md), [decision-log](docs/decision-log.md)
95
+
96
+ ## Numbers, limits, contributing
97
+
98
+ - Benchmark: [bench](docs/bench.md) — all figures live there (single source
99
+ of truth). Re-run via `bun run bench:append | bench:query | bench:sync`.
100
+ - Log compat: [compat](docs/compat.md) · changelog: [CHANGELOG](CHANGELOG.md)
101
+ - Limits + troubleshooting: [limits-troubleshooting](docs/limits-troubleshooting.md)
102
+ - Contributing: [CONTRIBUTING](CONTRIBUTING.md) · security: [SECURITY](SECURITY.md) ·
103
+ conduct: [CODE_OF_CONDUCT](CODE_OF_CONDUCT.md)
104
+
105
+ CLI serve/sync default to signed mode: serve needs `--trust <id=pub.pem>`
106
+ (repeat per device), sync needs `--key <priv.pem> --as <device>`.
107
+ `--unsigned` open relay is for local dev only, not production.
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ // bin/fieldlog.js — npm bin shim (valid .js entry). Exec bun on sibling TS entry; clear fallback when bun missing.
3
+ import { spawnSync } from 'node:child_process';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const dir = dirname(fileURLToPath(import.meta.url));
8
+ const entry = join(dir, 'fieldlog.ts');
9
+ const r = spawnSync('bun', [entry, ...process.argv.slice(2)], { stdio: 'inherit' });
10
+ if (r.error) {
11
+ if (r.error.code === 'ENOENT') {
12
+ console.error('fieldlog needs the Bun runtime (https://bun.sh). Install Bun, then retry: bun bin/fieldlog.ts --help');
13
+ process.exit(1);
14
+ }
15
+ console.error(r.error.message);
16
+ process.exit(1);
17
+ }
18
+ process.exit(r.status ?? 1);
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env bun
2
+ // bin/fieldlog.ts — small CLI: serve the ws relay, sync a kernel file, run the two-node demo.
3
+ // Bun only. Signed mode by default: serve needs --trust id=pub.pem (repeatable)
4
+ // and sync needs --key priv.pem --as <device>; --unsigned selects the legacy
5
+ // open relay (accepts any device_id, local dev only).
6
+ // examples:
7
+ // bun bin/fieldlog.ts serve --port 8091 --file ./relay.log --trust device-01=./device-01.pub
8
+ // bun bin/fieldlog.ts sync --file ./ledger.db --relay ws://127.0.0.1:8091 --key ./device-01.priv --as device-01
9
+ // bun bin/fieldlog.ts serve --port 8091 --file ./relay.log --unsigned
10
+ // bun bin/fieldlog.ts demo
11
+ import { mkdtempSync, readFileSync, writeSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { createKernel, generateDeviceKey, WsRelayClient, WsRelayServer } from '../src/index.ts';
15
+
16
+ function usage(): string {
17
+ return [
18
+ 'usage: fieldlog <serve|sync|demo> [options]',
19
+ ' serve --port <n> --file <relay.log> --trust <id=pub.pem> [--trust ...]',
20
+ ' run the file-backed ws relay in signed mode (rejects unknown devices)',
21
+ ' serve --port <n> --file <relay.log> --unsigned open relay (dev only)',
22
+ ' sync --file <app.db> --relay <ws url> --key <priv.pem> --as <device>',
23
+ ' push+pull the kernel delta with a capability token',
24
+ ' sync --file <app.db> --relay <ws url> --unsigned unsigned (dev only)',
25
+ ' demo two-node: offline events, then signed-mode sync with matching totals',
26
+ ].join('\n');
27
+ }
28
+
29
+ function arg(args: string[], name: string, def?: string): string | undefined {
30
+ const i = args.indexOf(name);
31
+ if (i < 0) return def;
32
+ return args[i + 1] ?? def;
33
+ }
34
+
35
+ function argAll(args: string[], name: string): string[] {
36
+ const out: string[] = [];
37
+ for (let i = 0; i + 1 < args.length; i++) if (args[i] === name) out.push(args[i + 1]);
38
+ return out;
39
+ }
40
+
41
+ function die(msg: string): never {
42
+ // write synchronously: console.error + process.exit race when stderr is piped
43
+ // (the cli-auth test reads the child process stderr; async writes can be lost).
44
+ writeSync(2, msg + '\n' + usage() + '\n');
45
+ process.exit(2);
46
+ }
47
+
48
+ async function cmdServe(rest: string[]): Promise<void> {
49
+ const port = Number(arg(rest, '--port', '8091'));
50
+ const file = arg(rest, '--file', 'relay.log')!;
51
+ const unsigned = rest.includes('--unsigned');
52
+ const trustedDevices: Record<string, string> = {};
53
+ for (const t of argAll(rest, '--trust')) {
54
+ const eq = t.indexOf('=');
55
+ if (eq < 0) die(`--trust wants id=pubkey-path, got: ${t}`);
56
+ const id = t.slice(0, eq);
57
+ if (!id) die(`--trust wants id=pubkey-path, got: ${t}`);
58
+ try {
59
+ trustedDevices[id] = readFileSync(t.slice(eq + 1), 'utf8').trim();
60
+ } catch {
61
+ die(`cannot read pubkey for --trust ${id}: ${t.slice(eq + 1)}`);
62
+ }
63
+ }
64
+ if (Object.keys(trustedDevices).length === 0 && !unsigned) {
65
+ die('serve needs --trust <id=pub.pem> or --unsigned for an open relay');
66
+ }
67
+ const server = new WsRelayServer({ port, file, trustedDevices, allowUnsigned: unsigned });
68
+ const actual = await server.start();
69
+ console.log(`fieldlog relay listening ws://127.0.0.1:${actual} file=${file}`);
70
+ console.log(`ready port=${actual}`);
71
+ const stop = () => {
72
+ server.kill();
73
+ process.exit(0);
74
+ };
75
+ process.on('SIGINT', stop);
76
+ process.on('SIGTERM', stop);
77
+ await new Promise(() => {}); // run until killed
78
+ }
79
+
80
+ async function cmdSync(rest: string[]): Promise<void> {
81
+ const file = arg(rest, '--file');
82
+ const relay = arg(rest, '--relay');
83
+ if (!file || !relay) die(usage());
84
+ const unsigned = rest.includes('--unsigned');
85
+ const keyPath = arg(rest, '--key');
86
+ const asId = arg(rest, '--as');
87
+ if (!keyPath && !unsigned) die('sync needs --key <priv.pem> --as <device> or --unsigned for unsigned mode');
88
+ if (keyPath && !asId) die('sync --key needs a matching --as <device>');
89
+ const privateKeyPem = keyPath ? readFileSync(keyPath, 'utf8').trim() : undefined;
90
+ const kernel = await createKernel({ file: file as string, deviceId: asId, privateKeyPem });
91
+ const token = privateKeyPem ? kernel.capToken(privateKeyPem) : undefined;
92
+ const client = new WsRelayClient(relay as string, token ? { capToken: token } : {});
93
+ try {
94
+ const r = await kernel.sync(client);
95
+ console.log(`sync pushed=${r.pushed} acked=${r.acked} pulled=${r.pulled} applied=${r.applied}`);
96
+ } finally {
97
+ client.close();
98
+ kernel.close();
99
+ }
100
+ }
101
+
102
+ async function cmdDemo(): Promise<void> {
103
+ const dir = mkdtempSync(join(tmpdir(), 'fieldlog-demo-'));
104
+ const k1 = generateDeviceKey('device-01');
105
+ const k2 = generateDeviceKey('device-02');
106
+ const server = new WsRelayServer({ port: 0, file: join(dir, 'relay.log'), trustedDevices: { 'device-01': k1.publicKeyPem, 'device-02': k2.publicKeyPem } });
107
+ const port = await server.start();
108
+ const node1 = await createKernel({ file: join(dir, 'device-01.db'), deviceId: 'device-01', privateKeyPem: k1.privateKeyPem });
109
+ const node2 = await createKernel({ file: join(dir, 'device-02.db'), deviceId: 'device-02', privateKeyPem: k2.privateKeyPem });
110
+ const c1 = new WsRelayClient(`ws://127.0.0.1:${port}`, { capToken: node1.capToken(k1.privateKeyPem) });
111
+ const c2 = new WsRelayClient(`ws://127.0.0.1:${port}`, { capToken: node2.capToken(k2.privateKeyPem) });
112
+ try {
113
+ let expected = 0;
114
+ for (let i = 0; i < 20; i++) {
115
+ const value = 5000 + i * 250;
116
+ expected += value;
117
+ await node1.append({ type: 'entry', value, actor: 'device-01' });
118
+ }
119
+ await node1.sync(c1, { trustedDevices: { 'device-02': k2.publicKeyPem } });
120
+ await node2.sync(c2, { trustedDevices: { 'device-01': k1.publicKeyPem } });
121
+ const t1 = await node1.query<{ total: number }>(`SELECT SUM(value) AS total FROM entries WHERE voided = 0`);
122
+ const t2 = await node2.query<{ total: number }>(`SELECT SUM(value) AS total FROM entries WHERE voided = 0`);
123
+ console.log(`sync: device-01 = ${t1[0].total} | device-02 = ${t2[0].total} | expected = ${expected}`);
124
+ if (t1[0].total !== expected || t2[0].total !== expected) {
125
+ console.error(`totals differ: device-01=${t1[0].total} device-02=${t2[0].total} expected=${expected}`);
126
+ process.exit(1);
127
+ }
128
+ console.log('match on both sides, totals agree');
129
+ } finally {
130
+ c1.close();
131
+ c2.close();
132
+ node1.close();
133
+ node2.close();
134
+ server.kill();
135
+ }
136
+ }
137
+
138
+ const [cmd, ...rest] = Bun.argv.slice(2);
139
+ if (cmd === 'serve') await cmdServe(rest);
140
+ else if (cmd === 'sync') await cmdSync(rest);
141
+ else if (cmd === 'demo') await cmdDemo();
142
+ else {
143
+ console.error(usage());
144
+ process.exit(2);
145
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "fieldlog",
3
+ "version": "0.15.0",
4
+ "description": "Fieldlog — write anywhere, resolve later. Offline-first append-log + SQLite read-model + sync.",
5
+ "repository": { "type": "git", "url": "git+https://github.com/arfoux/fieldlog.git" },
6
+ "type": "module",
7
+ "main": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "bin",
14
+ "README.md",
15
+ "LICENSE",
16
+ "CHANGELOG.md"
17
+ ],
18
+ "bin": {
19
+ "fieldlog": "bin/fieldlog.js"
20
+ },
21
+ "scripts": {
22
+ "test": "bun test",
23
+ "build": "tsc -p tsconfig.json",
24
+ "demo": "bun demo/two-node.ts",
25
+ "bench:append": "bun bench/bench-append.ts",
26
+ "bench:query": "bun bench/bench-query.ts",
27
+ "bench:sync": "bun bench/bench-sync.ts"
28
+ },
29
+ "license": "MIT",
30
+ "devDependencies": {
31
+ "@types/node": "^26.4.1",
32
+ "bun-types": "^1.4.1",
33
+ "typescript": "^7.0.2"
34
+ }
35
+ }