@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 +21 -0
- package/README.md +9 -0
- package/dist/index.d.ts +609 -0
- package/dist/index.js +966 -0
- package/package.json +44 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|