@tangentfeed/core 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sreeraj T A
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,9 @@
1
+ # @tangentfeed/core
2
+
3
+ Protocol engine for tangentfeed: Hybrid Logical Clocks, the cell-level LWW operation log, replication, compaction, and operation signatures.
4
+
5
+ Two dependencies, both audited and both from the same family: `@noble/curves` for Ed25519 and `@noble/hashes` for SHA-256. It was dependency-free until signing landed in v0.2; rolling our own curve arithmetic to keep that claim would have been a bad trade.
6
+
7
+ Use this directly if you are supplying your own storage adapter or transport, or implementing against [PROTOCOL.md](https://github.com/sreerajta/tangentfeed/blob/main/PROTOCOL.md).
8
+
9
+ Part of [tangentfeed](https://github.com/sreerajta/tangentfeed). MIT licensed.
@@ -0,0 +1,609 @@
1
+ /**
2
+ * Hybrid Logical Clock — PROTOCOL.md §4
3
+ *
4
+ * State: (millis, counter, deviceId).
5
+ * String form: 12-hex millis + "-" + 4-hex counter + "-" + 32-hex deviceId,
6
+ * fixed 34 chars, lexicographic order === logical order.
7
+ */
8
+ declare const MAX_COUNTER = 65535;
9
+ declare const MAX_MILLIS: number;
10
+ declare const MAX_DRIFT_MS = 300000;
11
+ interface Hlc {
12
+ readonly millis: number;
13
+ readonly counter: number;
14
+ readonly deviceId: string;
15
+ }
16
+ declare class ClockDriftError extends Error {
17
+ readonly code = "CLOCK_DRIFT";
18
+ constructor(remoteMillis: number, physicalNow: number);
19
+ }
20
+ /** deviceId is 32 lowercase hex characters — 128 bits. §4.3 */
21
+ declare const DEVICE_ID_HEX = 32;
22
+ /** 12 + 1 + 4 + 1 + 32. §4.2 */
23
+ declare const HLC_LENGTH = 50;
24
+ declare function isValidDeviceId(s: string): boolean;
25
+ /** Canonical string encoding. §4.2 */
26
+ declare function encodeHlc(h: Hlc): string;
27
+ /** Parse canonical string form; throws on malformed input. */
28
+ declare function decodeHlc(s: string): Hlc;
29
+ /**
30
+ * Total order over HLCs: millis, then counter, then deviceId.
31
+ * Guaranteed to agree with bytewise comparison of encodeHlc output;
32
+ * the property tests verify this equivalence.
33
+ */
34
+ declare function compareHlc(a: Hlc, b: Hlc): number;
35
+ /**
36
+ * The clock itself. Owns mutable (millis, counter) state for one device in
37
+ * one space. Inject `physicalClock` for tests; defaults to Date.now.
38
+ *
39
+ * Persistence: callers should persist state() after issuing ops and restore
40
+ * with the constructor, so a restart never reissues a timestamp.
41
+ */
42
+ declare class HybridLogicalClock {
43
+ private millis;
44
+ private counter;
45
+ readonly deviceId: string;
46
+ private readonly physicalClock;
47
+ constructor(opts: {
48
+ deviceId: string;
49
+ physicalClock?: () => number;
50
+ /** restore persisted state; defaults to zero */
51
+ millis?: number;
52
+ counter?: number;
53
+ });
54
+ /** Current state, for persistence. Does not advance the clock. */
55
+ state(): Hlc;
56
+ /**
57
+ * Issue a timestamp for a new local op. §4.1 "send/local event".
58
+ * Strictly greater than every timestamp this clock has issued or observed.
59
+ */
60
+ now(): Hlc;
61
+ /**
62
+ * Observe a remote timestamp. §4.1 "receive".
63
+ * Throws ClockDriftError if the remote is > MAX_DRIFT_MS ahead of our
64
+ * physical clock (§4.5). On success the local clock becomes strictly
65
+ * greater than both its previous state and the remote timestamp.
66
+ */
67
+ receive(remote: Hlc): Hlc;
68
+ private checkBounds;
69
+ }
70
+
71
+ /**
72
+ * Operation format and validation — PROTOCOL.md §3, §11.
73
+ */
74
+ /** JSON-representable value. */
75
+ type Json = null | boolean | number | string | Json[] | {
76
+ [k: string]: Json;
77
+ };
78
+ declare const TOMBSTONE_COLUMN = "-";
79
+ interface Op {
80
+ readonly id: string;
81
+ readonly table: string;
82
+ readonly row: string;
83
+ readonly column: string;
84
+ readonly value: Json;
85
+ readonly hlc: string;
86
+ /** Base64 Ed25519 signature over signedPayload(op). §12. */
87
+ readonly sig: string;
88
+ readonly device: string;
89
+ }
90
+ /** deviceId → highest HLC string seen from that device. §6 step 2. */
91
+ type Frontier = Readonly<Record<string, string>>;
92
+ declare class BadOpError extends Error {
93
+ readonly code = "BAD_OP";
94
+ constructor(msg: string);
95
+ }
96
+ declare const MAX_OP_BYTES: number;
97
+ declare const MAX_BATCH_OPS = 1000;
98
+ /** Validate an op's shape. Throws BadOpError. §11. */
99
+ declare function validateOp(op: unknown): asserts op is Op;
100
+ /** Is `op` above `frontier` (i.e., not yet seen by its holder)? */
101
+ declare function aboveFrontier(op: Op, frontier: Frontier): boolean;
102
+ /** Merge an op's hlc into a frontier, returning the (possibly new) frontier. */
103
+ declare function advanceFrontier(frontier: Frontier, op: Op): Frontier;
104
+ /**
105
+ * The exact bytes a signature covers: the domain, then the canonical JSON of
106
+ * every field except `sig` itself.
107
+ *
108
+ * `sig` is excluded because including it would require knowing the signature
109
+ * before computing it. Excluding it also means a verifier reconstructs the
110
+ * payload from the op it received, with no separate encoding to agree on.
111
+ */
112
+ declare function signedPayload(op: Omit<Op, "sig"> | Op): Uint8Array;
113
+ /** Whether `op.sig` is a valid signature by `publicKey`. §12. */
114
+ declare function verifyOp(op: Op, publicKey: Uint8Array): boolean;
115
+
116
+ /**
117
+ * ULID — PROTOCOL.md §4.4. 48-bit timestamp + 80-bit randomness,
118
+ * Crockford base32, 26 chars, canonical uppercase. No dependencies.
119
+ */
120
+ declare function ulid(time?: number, randomBytes?: (n: number) => Uint8Array): string;
121
+
122
+ /**
123
+ * Canonical JSON and operation signing — PROTOCOL.md §8.1 and §12.
124
+ *
125
+ * These live in core rather than crypto because core needs them to validate an
126
+ * op, and crypto already depends on core. Putting them the other way round
127
+ * would make the graph cyclic. Crypto re-exports both, so its public API is
128
+ * unchanged.
129
+ *
130
+ * Everything here is bytes in, bytes out — it knows nothing about operations,
131
+ * which is what lets it be tested without an engine.
132
+ */
133
+
134
+ /**
135
+ * Prefixed to every signed payload so a signature can never be replayed as
136
+ * valid in some other context.
137
+ */
138
+ declare const SIGNING_DOMAIN = "tangentfeed/v2/op";
139
+ interface DeviceKey {
140
+ readonly publicKey: Uint8Array;
141
+ readonly privateKey: Uint8Array;
142
+ }
143
+ /** Canonical JSON per RFC 8785. §8.1. */
144
+ declare function canonicalJson(value: Json): string;
145
+ declare function generateDeviceKey(): DeviceKey;
146
+ /**
147
+ * deviceId is the first 16 bytes of SHA-256(publicKey), lowercase hex.
148
+ *
149
+ * 128 bits rather than v0.1's 64: this identifier became a security boundary
150
+ * when it started deciding whose signature counts, and a targeted
151
+ * impersonation at 64 bits is within reach of a determined adversary.
152
+ */
153
+ declare function deviceIdFromPublicKey(publicKey: Uint8Array): string;
154
+ declare function signPayload(payload: Uint8Array, privateKey: Uint8Array): string;
155
+ /**
156
+ * Returns false rather than throwing on malformed input. A bad signature from
157
+ * a peer is a routine condition on an open network, not an exceptional one.
158
+ */
159
+ declare function verifyPayload(payload: Uint8Array, signature: string, publicKey: Uint8Array): boolean;
160
+
161
+ /**
162
+ * Storage abstraction — PROTOCOL.md §8.
163
+ *
164
+ * The engine talks ONLY to this interface. Adapters are dumb byte movers with
165
+ * one hard obligation: applyBatch is atomic (§8.2). MemoryAdapter is the
166
+ * reference used by tests; IndexedDB/SQLite adapters come in M2+.
167
+ */
168
+
169
+ interface ClockState {
170
+ readonly millis: number;
171
+ readonly counter: number;
172
+ }
173
+ interface BatchWrite {
174
+ /** ops to append to the log (already deduped by the engine) */
175
+ readonly ops: readonly Op[];
176
+ /** new winning op per affected cell, keyed by cellKey() */
177
+ readonly winners: ReadonlyMap<string, Op>;
178
+ /** frontier after this batch */
179
+ readonly frontier: Frontier;
180
+ /** clock state after this batch */
181
+ readonly clock: ClockState;
182
+ }
183
+ /** Ops and cells to physically remove during compaction (§9). */
184
+ interface CompactionWrite {
185
+ /** op ids to delete from the log */
186
+ readonly opIds: readonly string[];
187
+ /** materialized cell keys to forget (tombstone GC only) */
188
+ readonly cellKeys: readonly string[];
189
+ }
190
+ interface StorageAdapter {
191
+ /** All winning cells of a row: column → winning op. undefined if none. */
192
+ getRow(table: string, row: string): Promise<ReadonlyMap<string, Op> | undefined>;
193
+ /** rowIds that have at least one cell op in this table (incl. tombstoned). */
194
+ listRows(table: string): Promise<string[]>;
195
+ /** Table names that have at least one op. */
196
+ listTables(): Promise<string[]>;
197
+ hasOp(id: string): Promise<boolean>;
198
+ /** Current winner for one cell. */
199
+ getWinner(table: string, row: string, column: string): Promise<Op | undefined>;
200
+ /** Every stored op strictly above `frontier`, sorted by hlc. §6 step 3. */
201
+ opsSince(frontier: Frontier): Promise<Op[]>;
202
+ getFrontier(): Promise<Frontier>;
203
+ getClock(): Promise<ClockState | undefined>;
204
+ /**
205
+ * The device's signing keypair, or undefined on a fresh store. Section 12.
206
+ *
207
+ * Stored in the clear beside the data it protects. On a device this belongs
208
+ * in Keychain or Keystore; recorded as follow-up rather than solved here,
209
+ * since the space secret already carries the same exposure.
210
+ */
211
+ getDeviceKey(): Promise<DeviceKey | undefined>;
212
+ setDeviceKey(key: DeviceKey): Promise<void>;
213
+ /** Atomic, all-or-nothing. §8.2. */
214
+ applyBatch(batch: BatchWrite): Promise<void>;
215
+ /** Total ops currently retained in the log. */
216
+ opCount(): Promise<number>;
217
+ /** Every op in the log, ascending by hlc. Used by compaction scans. */
218
+ allOps(): Promise<Op[]>;
219
+ /** Last known frontier of each peer, from since/ack exchanges (§6). */
220
+ getPeerFrontiers(): Promise<Record<string, Frontier>>;
221
+ /** Record a peer's frontier. */
222
+ setPeerFrontier(peer: string, frontier: Frontier): Promise<void>;
223
+ /** Atomically remove ops (and, for tombstone GC, cells). */
224
+ compact(write: CompactionWrite): Promise<void>;
225
+ }
226
+ declare function cellKey(table: string, row: string, column: string): string;
227
+ declare class MemoryAdapter implements StorageAdapter {
228
+ private opsById;
229
+ /** table → row → column → winning op */
230
+ private tables;
231
+ private frontier;
232
+ private clock;
233
+ private deviceKey;
234
+ private peerFrontiers;
235
+ getRow(table: string, row: string): Promise<ReadonlyMap<string, Op> | undefined>;
236
+ listRows(table: string): Promise<string[]>;
237
+ listTables(): Promise<string[]>;
238
+ hasOp(id: string): Promise<boolean>;
239
+ getWinner(table: string, row: string, column: string): Promise<Op | undefined>;
240
+ opsSince(frontier: Frontier): Promise<Op[]>;
241
+ getFrontier(): Promise<Frontier>;
242
+ getDeviceKey(): Promise<DeviceKey | undefined>;
243
+ setDeviceKey(key: DeviceKey): Promise<void>;
244
+ getClock(): Promise<ClockState | undefined>;
245
+ opCount(): Promise<number>;
246
+ allOps(): Promise<Op[]>;
247
+ getPeerFrontiers(): Promise<Record<string, Frontier>>;
248
+ setPeerFrontier(peer: string, frontier: Frontier): Promise<void>;
249
+ compact(write: CompactionWrite): Promise<void>;
250
+ applyBatch(batch: BatchWrite): Promise<void>;
251
+ }
252
+
253
+ /**
254
+ * Compaction — PROTOCOL.md §9.
255
+ *
256
+ * The op log grows forever unless superseded ops are reclaimed. An op is
257
+ * droppable when BOTH hold:
258
+ *
259
+ * (a) it is not the winning op for its cell, and
260
+ * (b) every KNOWN peer's frontier has passed it.
261
+ *
262
+ * Condition (b) uses the "compaction horizon": per writer device, the MINIMUM
263
+ * highest-seen HLC across our own frontier and every peer frontier we have
264
+ * recorded from since/ack exchanges. Any op at or below the horizon has
265
+ * demonstrably reached everyone we know about.
266
+ *
267
+ * Why (a) alone would seem sufficient — and why it isn't:
268
+ * dropping a superseded cell value is harmless for LWW convergence, because a
269
+ * peer that never receives it will still receive the winner. The reason (b)
270
+ * exists is TOMBSTONES. Per §5 a row stays hidden because the tombstone is the
271
+ * winning op on the "-" cell, even when later cell writes carry higher HLCs.
272
+ * If a tombstone were forgotten while some peer still held un-synced writes
273
+ * for that row, those writes would arrive later, find no tombstone, and
274
+ * RESURRECT deleted data.
275
+ *
276
+ * Tombstone GC is therefore doubly guarded, and reclaims the row WHOLE:
277
+ * - the winning "-" op must be a tombstone the horizon has passed, and
278
+ * - every op belonging to that row must also be below the horizon, and
279
+ * - all of the row's ops AND materialized cells are removed together.
280
+ * Removing the tombstone while leaving the row's other cells behind would
281
+ * resurrect the row locally on the very next read — the exact failure this
282
+ * module's tests exist to prevent.
283
+ *
284
+ * Consequence worth surfacing to users: one long-absent peer pins the horizon
285
+ * and blocks reclamation. `compact()` reports which peers hold it back rather
286
+ * than silently doing nothing. And because the horizon is only as good as the
287
+ * peer frontiers we have recorded, a replica that has never completed a sync
288
+ * treats itself as alone — correct for a genuinely single-device user, and a
289
+ * further reason tombstone GC requires an explicit opt-in.
290
+ */
291
+
292
+ interface CompactionOptions {
293
+ /**
294
+ * Also reclaim tombstoned rows entirely once the horizon has passed every
295
+ * op of the row. Default false: §9 advises v1 implementations not to GC
296
+ * tombstones by default, because a peer offline beyond the horizon can no
297
+ * longer learn that the row was deleted.
298
+ */
299
+ includeTombstones?: boolean;
300
+ /** Report what would be removed without touching storage. */
301
+ dryRun?: boolean;
302
+ }
303
+ interface CompactionStats {
304
+ /** ops examined */
305
+ scanned: number;
306
+ /** ops removed (superseded ops, plus all ops of reclaimed rows) */
307
+ removed: number;
308
+ /** tombstoned rows reclaimed whole (0 unless includeTombstones) */
309
+ rowsReclaimed: number;
310
+ /** ops retained because they are winners */
311
+ retainedWinners: number;
312
+ /** ops retained because the horizon has not passed them */
313
+ retainedAboveHorizon: number;
314
+ /** peers whose lagging frontier pins the horizon; empty means unblocked */
315
+ blockedBy: string[];
316
+ }
317
+ /**
318
+ * Per-writer-device minimum of (our frontier, every known peer frontier).
319
+ * A missing entry means that peer has seen nothing from that device, which
320
+ * pins the horizon to zero for it.
321
+ */
322
+ declare function compactionHorizon(own: Frontier, peerFrontiers: Record<string, Frontier>): Frontier;
323
+ /** Peers whose frontier lags ours (and therefore hold the horizon back). */
324
+ declare function blockingPeers(own: Frontier, peerFrontiers: Record<string, Frontier>): string[];
325
+ /**
326
+ * Decide what to reclaim. Pure: takes a snapshot, returns a plan. Keeping the
327
+ * safety rules out of the storage layer makes them directly testable.
328
+ *
329
+ * @param winningCells cellKey → winning op (i.e. the materialized state)
330
+ */
331
+ declare function planCompaction(ops: readonly Op[], winningCells: ReadonlyMap<string, Op>, horizon: Frontier, opts: CompactionOptions): {
332
+ opIds: string[];
333
+ cellKeys: string[];
334
+ stats: Omit<CompactionStats, "blockedBy">;
335
+ };
336
+ /** Snapshot of materialized state: cellKey → winning op. */
337
+ declare function winningCells(storage: StorageAdapter): Promise<Map<string, Op>>;
338
+
339
+ /**
340
+ * Cipher interface — PROTOCOL.md §7.
341
+ *
342
+ * Core stays dependency-free: this file defines the contract, and a concrete
343
+ * implementation lives in @tangentfeed/crypto. Other-language implementations are
344
+ * therefore free to use whatever XChaCha20-Poly1305 binding they like, as
345
+ * long as they match the spec'd scheme.
346
+ *
347
+ * What is encrypted: cell VALUES only. table, row, column, hlc, and device
348
+ * stay plaintext in v1 (§7 documents this as a known metadata leak). The
349
+ * tombstone column ("-") is also exempt, because §5 merge rules must be
350
+ * evaluable by any peer — including one that cannot decrypt — to decide
351
+ * whether a row is deleted. A relay therefore learns that a row was deleted,
352
+ * but never what it contained.
353
+ *
354
+ * AAD binds each ciphertext to its op id, so a ciphertext lifted from one op
355
+ * and pasted into another fails authentication rather than silently moving
356
+ * data between cells.
357
+ */
358
+
359
+ interface Cipher {
360
+ /** Encrypt a cell value. `opId` is bound in as AAD. Returns "e1:<base64>". */
361
+ encrypt(value: Json, opId: string): string;
362
+ /**
363
+ * Decrypt a cell value produced by `encrypt`. Values that are not in the
364
+ * "e1:" envelope MUST be returned unchanged (a space may contain plaintext
365
+ * ops written before encryption was enabled).
366
+ */
367
+ decrypt(value: Json, opId: string): Json;
368
+ }
369
+ /** Prefix marking an encrypted value envelope. §3.2 */
370
+ declare const CIPHER_PREFIX = "e1:";
371
+ declare function isEncryptedValue(v: Json): v is string;
372
+ declare class DecryptError extends Error {
373
+ readonly code = "DECRYPT_FAIL";
374
+ constructor(msg: string);
375
+ }
376
+
377
+ /**
378
+ * SyncEngine — the heart of the library.
379
+ *
380
+ * Owns: one space's replica. Local writes become ops; remote ops merge by
381
+ * cell-level LWW (PROTOCOL.md §5); all mutation flows through one atomic
382
+ * batch path. Storage and transport are injected; this file knows nothing
383
+ * about platforms.
384
+ */
385
+
386
+ interface RowChange {
387
+ readonly table: string;
388
+ readonly row: string;
389
+ }
390
+ /** What subscribers see after every committed batch. */
391
+ interface ChangeEvent {
392
+ readonly changes: readonly RowChange[];
393
+ readonly ops: readonly Op[];
394
+ /** "local" = written via insert/update/delete here; "remote" = applied via applyRemoteOps */
395
+ readonly origin: "local" | "remote";
396
+ }
397
+ type Subscriber = (event: ChangeEvent) => void;
398
+ interface EngineOptions {
399
+ storage: StorageAdapter;
400
+ physicalClock?: () => number;
401
+ /**
402
+ * Optional end-to-end encryption (PROTOCOL.md §7). When present, local
403
+ * writes are encrypted before entering the op log, so ciphertext is what
404
+ * gets stored, replicated, and relayed. Reads decrypt transparently.
405
+ */
406
+ cipher?: Cipher;
407
+ }
408
+ type RowData = {
409
+ readonly id: string;
410
+ } & Readonly<Record<string, Json>>;
411
+ declare class SyncEngine {
412
+ readonly deviceId: string;
413
+ private readonly storage;
414
+ private readonly clock;
415
+ private readonly cipher;
416
+ private readonly subscribers;
417
+ private readonly deviceKey;
418
+ /** deviceId -> public key. Seeded with our own so we can verify our own ops. */
419
+ private readonly keys;
420
+ /** serializes all mutations; JS is single-threaded but ops are async */
421
+ private mutex;
422
+ private constructor();
423
+ /** This device's public key, for the `hello` message. Section 6.1. */
424
+ get publicKey(): Uint8Array;
425
+ /** Every device key known to this replica, for the `keys` message. */
426
+ knownKeys(): ReadonlyMap<string, Uint8Array>;
427
+ /**
428
+ * Records a peer's public key.
429
+ *
430
+ * Returns false when the key does not hash to the claimed id. That check is
431
+ * what makes the directory self-validating: a peer can relay keys it learned
432
+ * from others, but cannot invent one for somebody else.
433
+ */
434
+ learnKey(deviceId: string, publicKey: Uint8Array): boolean;
435
+ /**
436
+ * Opens a replica. Identity comes from the stored keypair, so a restart keeps
437
+ * the same device rather than minting a new one. Section 4.3.
438
+ */
439
+ static open(opts: EngineOptions): Promise<SyncEngine>;
440
+ /** Materialized row, or undefined if absent/tombstoned. §5. */
441
+ get(table: string, row: string): Promise<RowData | undefined>;
442
+ /** All visible rows of a table, sorted by rowId (ULID = insertion order). */
443
+ list(table: string): Promise<RowData[]>;
444
+ /** Full materialized state; used by tests and conformance vectors. */
445
+ dump(): Promise<Record<string, Record<string, Record<string, Json>>>>;
446
+ /** Insert a row; returns its generated rowId. */
447
+ insert(table: string, values: Record<string, Json>): Promise<string>;
448
+ /** Write one op per column. */
449
+ update(table: string, row: string, values: Record<string, Json>): Promise<void>;
450
+ /** Row tombstone. §5. */
451
+ delete(table: string, row: string): Promise<void>;
452
+ frontier(): Promise<Frontier>;
453
+ /** Ops the caller is missing, given their frontier. §6 step 3. */
454
+ opsSince(frontier: Frontier): Promise<Op[]>;
455
+ /**
456
+ * Apply a batch of remote ops. Validates, drift-checks, dedupes, merges,
457
+ * persists atomically, notifies. Returns number of newly applied ops.
458
+ * Throws BadOpError / ClockDriftError; on throw, nothing was applied.
459
+ */
460
+ applyRemoteOps(remoteOps: readonly unknown[]): Promise<number>;
461
+ /**
462
+ * Observe a peer's clock from a hello message (§6 step 1). Drift-checks and
463
+ * advances + persists our clock without applying any ops.
464
+ */
465
+ observeRemoteClock(hlc: string): Promise<void>;
466
+ /**
467
+ * Record a peer's frontier, learned from a since/ack exchange. This is the
468
+ * input that lets compaction know what has safely reached everyone.
469
+ */
470
+ recordPeerFrontier(peer: string, frontier: Frontier): Promise<void>;
471
+ peerFrontiers(): Promise<Record<string, Frontier>>;
472
+ /** Number of ops currently retained in the log. */
473
+ opCount(): Promise<number>;
474
+ /**
475
+ * Reclaim superseded ops (§9). Safe by construction: winners are never
476
+ * dropped, and nothing above the compaction horizon is touched. Tombstone
477
+ * GC is opt-in via `includeTombstones` — see compaction.ts for why.
478
+ */
479
+ compact(opts?: CompactionOptions): Promise<CompactionStats>;
480
+ subscribe(cb: Subscriber): () => void;
481
+ private makeLocalOp;
482
+ /**
483
+ * The single mutation path (local and remote both land here):
484
+ * compute LWW winners for affected cells, advance frontier, persist
485
+ * atomically, notify subscribers. §5, §8.2.
486
+ */
487
+ private commit;
488
+ /** Decrypt (if needed) and shape stored cells into a row. */
489
+ private materialize;
490
+ private clockMillisForUlid;
491
+ private locked;
492
+ }
493
+ /**
494
+ * Test/utility helper: one full bidirectional sync between two engines
495
+ * (§6 steps 2–4 without a wire), including the frontier recording that a real
496
+ * ack performs. That recording is not cosmetic: compaction's safety horizon
497
+ * (§9) is computed from known peer frontiers, so an exchange that skipped it
498
+ * would leave each engine believing it were a lone replica and permit
499
+ * unsafe tombstone reclamation. Repeat until both frontiers are equal for
500
+ * multi-peer quiescence.
501
+ */
502
+ declare function syncOnce(a: SyncEngine, b: SyncEngine): Promise<void>;
503
+
504
+ /**
505
+ * Replication — PROTOCOL.md §6 as reusable logic over any transport.
506
+ *
507
+ * A Transport is deliberately dumb: a lossy-ok message bus. `send` delivers to
508
+ * everyone reachable (bus semantics); point-to-point transports implement the
509
+ * same interface by fanning out and letting `to`-filtering discard the rest.
510
+ * The Replicator supplies everything protocol-shaped: hello, frontier
511
+ * exchange, batched op transfer, acks, and live tail.
512
+ *
513
+ * Robustness comes from the engine, not the transport: ops are idempotent and
514
+ * merge is commutative, so lost, duplicated, or reordered messages can only
515
+ * delay convergence, never corrupt it. Any gap heals on the next hello/since
516
+ * exchange.
517
+ */
518
+
519
+ declare const WIRE_VERSION = 2;
520
+ /** Conservative default; well under MAX_BATCH_OPS and typical message limits. */
521
+ declare const OPS_PER_MESSAGE = 500;
522
+ type WireMsg = {
523
+ t: "hello";
524
+ v: number;
525
+ space: string;
526
+ from: string;
527
+ clock: string;
528
+ key: string;
529
+ } | {
530
+ t: "keys";
531
+ v: number;
532
+ space: string;
533
+ from: string;
534
+ to?: string;
535
+ keys: Record<string, string>;
536
+ } | {
537
+ t: "since";
538
+ v: number;
539
+ space: string;
540
+ from: string;
541
+ to: string;
542
+ have: Frontier;
543
+ } | {
544
+ t: "ops";
545
+ v: number;
546
+ space: string;
547
+ from: string;
548
+ to?: string;
549
+ ops: Op[];
550
+ } | {
551
+ t: "ack";
552
+ v: number;
553
+ space: string;
554
+ from: string;
555
+ to: string;
556
+ frontier: Frontier;
557
+ };
558
+ interface Transport {
559
+ /** Deliver to all reachable peers. Fire-and-forget; loss is acceptable. */
560
+ send(msg: WireMsg): void;
561
+ /** Register receive callback; returns unsubscribe. */
562
+ onMessage(cb: (msg: WireMsg) => void): () => void;
563
+ /**
564
+ * Optional: fires when a new peer link becomes ready (e.g. a DataChannel
565
+ * opens). Lets the Replicator (re-)send hello to late-connecting peers.
566
+ * Bus transports with no link concept (BroadcastChannel) omit this.
567
+ */
568
+ onPeerConnect?(cb: (peerId?: string) => void): () => void;
569
+ close(): void;
570
+ }
571
+ interface ReplicatorEvents {
572
+ onPeersChange?: (peers: ReadonlySet<string>) => void;
573
+ /** Protocol-level problems (drift, bad ops). Replication continues with other peers. */
574
+ onError?: (err: unknown, context: {
575
+ from?: string;
576
+ }) => void;
577
+ }
578
+ declare class Replicator {
579
+ private readonly engine;
580
+ private readonly transport;
581
+ private readonly space;
582
+ private readonly events;
583
+ private readonly peers;
584
+ private unsubs;
585
+ private started;
586
+ constructor(opts: {
587
+ engine: SyncEngine;
588
+ transport: Transport;
589
+ space: string;
590
+ events?: ReplicatorEvents;
591
+ });
592
+ get peerIds(): ReadonlySet<string>;
593
+ start(): Promise<void>;
594
+ stop(): void;
595
+ private sendHello;
596
+ /**
597
+ * Every device key we hold, sent before any ops so a peer never receives an
598
+ * op it cannot verify. Section 6.1.
599
+ *
600
+ * Relaying keys we learned from others is what lets an op reach a peer that
601
+ * never met its author. It is safe because learnKey discards any entry that
602
+ * does not hash to its claimed id.
603
+ */
604
+ private sendKeys;
605
+ private handle;
606
+ private msg;
607
+ }
608
+
609
+ export { BadOpError, type BatchWrite, CIPHER_PREFIX, type ChangeEvent, type Cipher, ClockDriftError, type ClockState, type CompactionOptions, type CompactionStats, type CompactionWrite, DEVICE_ID_HEX, DecryptError, type DeviceKey, type EngineOptions, type Frontier, HLC_LENGTH, type Hlc, HybridLogicalClock, type Json, MAX_BATCH_OPS, MAX_COUNTER, MAX_DRIFT_MS, MAX_MILLIS, MAX_OP_BYTES, MemoryAdapter, OPS_PER_MESSAGE, type Op, Replicator, type ReplicatorEvents, type RowChange, type RowData, SIGNING_DOMAIN, type StorageAdapter, type Subscriber, SyncEngine, TOMBSTONE_COLUMN, type Transport, WIRE_VERSION, type WireMsg, aboveFrontier, advanceFrontier, blockingPeers, canonicalJson, cellKey, compactionHorizon, compareHlc, decodeHlc, deviceIdFromPublicKey, encodeHlc, generateDeviceKey, isEncryptedValue, isValidDeviceId, planCompaction, signPayload, signedPayload, syncOnce, ulid, validateOp, verifyOp, verifyPayload, winningCells };