@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/dist/index.js
ADDED
|
@@ -0,0 +1,966 @@
|
|
|
1
|
+
// src/hlc.ts
|
|
2
|
+
var MAX_COUNTER = 65535;
|
|
3
|
+
var MAX_MILLIS = 2 ** 48 - 1;
|
|
4
|
+
var MAX_DRIFT_MS = 3e5;
|
|
5
|
+
var ClockDriftError = class extends Error {
|
|
6
|
+
code = "CLOCK_DRIFT";
|
|
7
|
+
constructor(remoteMillis, physicalNow) {
|
|
8
|
+
super(
|
|
9
|
+
`remote HLC is ${remoteMillis - physicalNow}ms ahead of local clock (max allowed ${MAX_DRIFT_MS}ms); check system time`
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
var DEVICE_ID_HEX = 32;
|
|
14
|
+
var HLC_LENGTH = 50;
|
|
15
|
+
var DEVICE_ID_RE = /^[0-9a-f]{32}$/;
|
|
16
|
+
var HLC_STRING_RE = /^[0-9a-f]{12}-[0-9a-f]{4}-[0-9a-f]{32}$/;
|
|
17
|
+
function isValidDeviceId(s) {
|
|
18
|
+
return DEVICE_ID_RE.test(s);
|
|
19
|
+
}
|
|
20
|
+
function encodeHlc(h) {
|
|
21
|
+
return h.millis.toString(16).padStart(12, "0") + "-" + h.counter.toString(16).padStart(4, "0") + "-" + h.deviceId;
|
|
22
|
+
}
|
|
23
|
+
function decodeHlc(s) {
|
|
24
|
+
if (!HLC_STRING_RE.test(s)) {
|
|
25
|
+
throw new Error(`malformed HLC string: ${JSON.stringify(s)}`);
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
millis: parseInt(s.slice(0, 12), 16),
|
|
29
|
+
counter: parseInt(s.slice(13, 17), 16),
|
|
30
|
+
deviceId: s.slice(18)
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function compareHlc(a, b) {
|
|
34
|
+
if (a.millis !== b.millis) return a.millis < b.millis ? -1 : 1;
|
|
35
|
+
if (a.counter !== b.counter) return a.counter < b.counter ? -1 : 1;
|
|
36
|
+
if (a.deviceId !== b.deviceId) return a.deviceId < b.deviceId ? -1 : 1;
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
var HybridLogicalClock = class {
|
|
40
|
+
millis;
|
|
41
|
+
counter;
|
|
42
|
+
deviceId;
|
|
43
|
+
physicalClock;
|
|
44
|
+
constructor(opts) {
|
|
45
|
+
if (!isValidDeviceId(opts.deviceId)) {
|
|
46
|
+
throw new Error(`invalid deviceId: ${JSON.stringify(opts.deviceId)}`);
|
|
47
|
+
}
|
|
48
|
+
this.deviceId = opts.deviceId;
|
|
49
|
+
this.physicalClock = opts.physicalClock ?? Date.now;
|
|
50
|
+
this.millis = opts.millis ?? 0;
|
|
51
|
+
this.counter = opts.counter ?? 0;
|
|
52
|
+
this.checkBounds();
|
|
53
|
+
}
|
|
54
|
+
/** Current state, for persistence. Does not advance the clock. */
|
|
55
|
+
state() {
|
|
56
|
+
return { millis: this.millis, counter: this.counter, deviceId: this.deviceId };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Issue a timestamp for a new local op. §4.1 "send/local event".
|
|
60
|
+
* Strictly greater than every timestamp this clock has issued or observed.
|
|
61
|
+
*/
|
|
62
|
+
now() {
|
|
63
|
+
const pt = this.physicalClock();
|
|
64
|
+
if (pt > this.millis) {
|
|
65
|
+
this.millis = pt;
|
|
66
|
+
this.counter = 0;
|
|
67
|
+
} else {
|
|
68
|
+
this.counter += 1;
|
|
69
|
+
if (this.counter > MAX_COUNTER) {
|
|
70
|
+
this.millis += 1;
|
|
71
|
+
this.counter = 0;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
this.checkBounds();
|
|
75
|
+
return this.state();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Observe a remote timestamp. §4.1 "receive".
|
|
79
|
+
* Throws ClockDriftError if the remote is > MAX_DRIFT_MS ahead of our
|
|
80
|
+
* physical clock (§4.5). On success the local clock becomes strictly
|
|
81
|
+
* greater than both its previous state and the remote timestamp.
|
|
82
|
+
*/
|
|
83
|
+
receive(remote) {
|
|
84
|
+
const pt = this.physicalClock();
|
|
85
|
+
if (remote.millis > pt + MAX_DRIFT_MS) {
|
|
86
|
+
throw new ClockDriftError(remote.millis, pt);
|
|
87
|
+
}
|
|
88
|
+
const m = Math.max(this.millis, remote.millis, pt);
|
|
89
|
+
let c;
|
|
90
|
+
if (m === this.millis && m === remote.millis) {
|
|
91
|
+
c = Math.max(this.counter, remote.counter) + 1;
|
|
92
|
+
} else if (m === this.millis) {
|
|
93
|
+
c = this.counter + 1;
|
|
94
|
+
} else if (m === remote.millis) {
|
|
95
|
+
c = remote.counter + 1;
|
|
96
|
+
} else {
|
|
97
|
+
c = 0;
|
|
98
|
+
}
|
|
99
|
+
this.millis = m;
|
|
100
|
+
this.counter = c;
|
|
101
|
+
if (this.counter > MAX_COUNTER) {
|
|
102
|
+
this.millis += 1;
|
|
103
|
+
this.counter = 0;
|
|
104
|
+
}
|
|
105
|
+
this.checkBounds();
|
|
106
|
+
return this.state();
|
|
107
|
+
}
|
|
108
|
+
checkBounds() {
|
|
109
|
+
if (this.millis > MAX_MILLIS) {
|
|
110
|
+
throw new Error("HLC millis exceeded 48-bit range");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// src/signing.ts
|
|
116
|
+
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
117
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
118
|
+
var SIGNING_DOMAIN = "tangentfeed/v2/op";
|
|
119
|
+
function canonicalJson(value) {
|
|
120
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
121
|
+
if (Array.isArray(value)) return "[" + value.map(canonicalJson).join(",") + "]";
|
|
122
|
+
const keys = Object.keys(value).sort();
|
|
123
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalJson(value[k])).join(",") + "}";
|
|
124
|
+
}
|
|
125
|
+
function generateDeviceKey() {
|
|
126
|
+
const { secretKey, publicKey } = ed25519.keygen();
|
|
127
|
+
return { publicKey, privateKey: secretKey };
|
|
128
|
+
}
|
|
129
|
+
function deviceIdFromPublicKey(publicKey) {
|
|
130
|
+
const digest = sha256(publicKey);
|
|
131
|
+
let out = "";
|
|
132
|
+
for (let i = 0; i < 16; i++) out += digest[i].toString(16).padStart(2, "0");
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
function signPayload(payload, privateKey) {
|
|
136
|
+
return base64Encode(ed25519.sign(payload, privateKey));
|
|
137
|
+
}
|
|
138
|
+
function verifyPayload(payload, signature, publicKey) {
|
|
139
|
+
try {
|
|
140
|
+
const sig = base64Decode(signature);
|
|
141
|
+
if (sig.length !== 64) return false;
|
|
142
|
+
return ed25519.verify(sig, payload, publicKey);
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function base64Encode(bytes) {
|
|
148
|
+
if (typeof btoa === "function") {
|
|
149
|
+
let bin = "";
|
|
150
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
151
|
+
return btoa(bin);
|
|
152
|
+
}
|
|
153
|
+
return Buffer.from(bytes).toString("base64");
|
|
154
|
+
}
|
|
155
|
+
function base64Decode(s) {
|
|
156
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(s)) throw new Error("not base64");
|
|
157
|
+
if (typeof atob === "function") {
|
|
158
|
+
const bin = atob(s);
|
|
159
|
+
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
160
|
+
}
|
|
161
|
+
return new Uint8Array(Buffer.from(s, "base64"));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/op.ts
|
|
165
|
+
var TOMBSTONE_COLUMN = "-";
|
|
166
|
+
var BadOpError = class extends Error {
|
|
167
|
+
code = "BAD_OP";
|
|
168
|
+
constructor(msg) {
|
|
169
|
+
super(`BAD_OP: ${msg}`);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
var NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/;
|
|
173
|
+
var ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
|
|
174
|
+
var MAX_OP_BYTES = 64 * 1024;
|
|
175
|
+
var MAX_BATCH_OPS = 1e3;
|
|
176
|
+
function validateOp(op) {
|
|
177
|
+
if (typeof op !== "object" || op === null) throw new BadOpError("not an object");
|
|
178
|
+
const o = op;
|
|
179
|
+
for (const f of ["id", "table", "row", "column", "hlc", "device", "sig"]) {
|
|
180
|
+
if (typeof o[f] !== "string") throw new BadOpError(`field ${f} must be a string`);
|
|
181
|
+
}
|
|
182
|
+
if (!("value" in o)) throw new BadOpError("missing value");
|
|
183
|
+
const { id, table, row, column, hlc, device } = o;
|
|
184
|
+
let decoded;
|
|
185
|
+
try {
|
|
186
|
+
decoded = decodeHlc(hlc);
|
|
187
|
+
} catch {
|
|
188
|
+
throw new BadOpError(`malformed hlc: ${hlc}`);
|
|
189
|
+
}
|
|
190
|
+
if (id !== hlc) throw new BadOpError("id must equal hlc in v0.1");
|
|
191
|
+
if (device !== decoded.deviceId) throw new BadOpError("device does not match hlc suffix");
|
|
192
|
+
if (!NAME_RE.test(table)) throw new BadOpError(`bad table name: ${table}`);
|
|
193
|
+
if (column !== TOMBSTONE_COLUMN && !NAME_RE.test(column)) {
|
|
194
|
+
throw new BadOpError(`bad column name: ${column}`);
|
|
195
|
+
}
|
|
196
|
+
if (!ULID_RE.test(row)) throw new BadOpError(`row is not a ULID: ${row}`);
|
|
197
|
+
assertJson(o["value"], "value");
|
|
198
|
+
if (JSON.stringify(op).length > MAX_OP_BYTES) throw new BadOpError("op exceeds 64 KiB");
|
|
199
|
+
}
|
|
200
|
+
function assertJson(v, path) {
|
|
201
|
+
switch (typeof v) {
|
|
202
|
+
case "boolean":
|
|
203
|
+
case "string":
|
|
204
|
+
return;
|
|
205
|
+
case "number":
|
|
206
|
+
if (!Number.isFinite(v)) throw new BadOpError(`${path}: non-finite number`);
|
|
207
|
+
return;
|
|
208
|
+
case "object": {
|
|
209
|
+
if (v === null) return;
|
|
210
|
+
if (Array.isArray(v)) {
|
|
211
|
+
v.forEach((x, i) => assertJson(x, `${path}[${i}]`));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
for (const [k, x] of Object.entries(v)) assertJson(x, `${path}.${k}`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
default:
|
|
218
|
+
throw new BadOpError(`${path}: unsupported type ${typeof v}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function aboveFrontier(op, frontier) {
|
|
222
|
+
const seen = frontier[op.device];
|
|
223
|
+
return seen === void 0 || op.hlc > seen;
|
|
224
|
+
}
|
|
225
|
+
function advanceFrontier(frontier, op) {
|
|
226
|
+
const seen = frontier[op.device];
|
|
227
|
+
if (seen !== void 0 && seen >= op.hlc) return frontier;
|
|
228
|
+
return { ...frontier, [op.device]: op.hlc };
|
|
229
|
+
}
|
|
230
|
+
function signedPayload(op) {
|
|
231
|
+
const { id, table, row, column, value, hlc, device } = op;
|
|
232
|
+
return new TextEncoder().encode(
|
|
233
|
+
SIGNING_DOMAIN + canonicalJson({ id, table, row, column, value, hlc, device })
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
function verifyOp(op, publicKey) {
|
|
237
|
+
return verifyPayload(signedPayload(op), op.sig, publicKey);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// src/ulid.ts
|
|
241
|
+
var B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
242
|
+
function ulid(time = Date.now(), randomBytes = defaultRandomBytes) {
|
|
243
|
+
if (!Number.isInteger(time) || time < 0 || time > 2 ** 48 - 1) {
|
|
244
|
+
throw new Error(`ulid: time out of range: ${time}`);
|
|
245
|
+
}
|
|
246
|
+
let t = time;
|
|
247
|
+
const timeChars = new Array(10);
|
|
248
|
+
for (let i = 9; i >= 0; i--) {
|
|
249
|
+
timeChars[i] = B32[t % 32];
|
|
250
|
+
t = Math.floor(t / 32);
|
|
251
|
+
}
|
|
252
|
+
const rand = randomBytes(10);
|
|
253
|
+
let bits = 0;
|
|
254
|
+
let acc = 0;
|
|
255
|
+
let out = "";
|
|
256
|
+
for (const byte of rand) {
|
|
257
|
+
acc = acc << 8 | byte;
|
|
258
|
+
bits += 8;
|
|
259
|
+
while (bits >= 5) {
|
|
260
|
+
out += B32[acc >>> bits - 5 & 31];
|
|
261
|
+
bits -= 5;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return timeChars.join("") + out;
|
|
265
|
+
}
|
|
266
|
+
function defaultRandomBytes(n) {
|
|
267
|
+
const b = new Uint8Array(n);
|
|
268
|
+
globalThis.crypto.getRandomValues(b);
|
|
269
|
+
return b;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/storage.ts
|
|
273
|
+
var SEP = "\0";
|
|
274
|
+
function cellKey(table, row, column) {
|
|
275
|
+
return table + SEP + row + SEP + column;
|
|
276
|
+
}
|
|
277
|
+
var MemoryAdapter = class {
|
|
278
|
+
opsById = /* @__PURE__ */ new Map();
|
|
279
|
+
/** table → row → column → winning op */
|
|
280
|
+
tables = /* @__PURE__ */ new Map();
|
|
281
|
+
frontier = {};
|
|
282
|
+
clock;
|
|
283
|
+
deviceKey;
|
|
284
|
+
peerFrontiers = {};
|
|
285
|
+
async getRow(table, row) {
|
|
286
|
+
return this.tables.get(table)?.get(row);
|
|
287
|
+
}
|
|
288
|
+
async listRows(table) {
|
|
289
|
+
return [...this.tables.get(table)?.keys() ?? []];
|
|
290
|
+
}
|
|
291
|
+
async listTables() {
|
|
292
|
+
return [...this.tables.keys()];
|
|
293
|
+
}
|
|
294
|
+
async hasOp(id) {
|
|
295
|
+
return this.opsById.has(id);
|
|
296
|
+
}
|
|
297
|
+
async getWinner(table, row, column) {
|
|
298
|
+
return this.tables.get(table)?.get(row)?.get(column);
|
|
299
|
+
}
|
|
300
|
+
async opsSince(frontier) {
|
|
301
|
+
const out = [];
|
|
302
|
+
for (const op of this.opsById.values()) {
|
|
303
|
+
const seen = frontier[op.device];
|
|
304
|
+
if (seen === void 0 || op.hlc > seen) out.push(op);
|
|
305
|
+
}
|
|
306
|
+
out.sort((a, b) => a.hlc < b.hlc ? -1 : a.hlc > b.hlc ? 1 : 0);
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
async getFrontier() {
|
|
310
|
+
return this.frontier;
|
|
311
|
+
}
|
|
312
|
+
async getDeviceKey() {
|
|
313
|
+
return this.deviceKey;
|
|
314
|
+
}
|
|
315
|
+
async setDeviceKey(key) {
|
|
316
|
+
this.deviceKey = key;
|
|
317
|
+
}
|
|
318
|
+
async getClock() {
|
|
319
|
+
return this.clock;
|
|
320
|
+
}
|
|
321
|
+
async opCount() {
|
|
322
|
+
return this.opsById.size;
|
|
323
|
+
}
|
|
324
|
+
async allOps() {
|
|
325
|
+
return [...this.opsById.values()].sort(
|
|
326
|
+
(a, b) => a.hlc < b.hlc ? -1 : a.hlc > b.hlc ? 1 : 0
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
async getPeerFrontiers() {
|
|
330
|
+
return this.peerFrontiers;
|
|
331
|
+
}
|
|
332
|
+
async setPeerFrontier(peer, frontier) {
|
|
333
|
+
this.peerFrontiers = { ...this.peerFrontiers, [peer]: frontier };
|
|
334
|
+
}
|
|
335
|
+
async compact(write) {
|
|
336
|
+
const newOps = new Map(this.opsById);
|
|
337
|
+
for (const id of write.opIds) newOps.delete(id);
|
|
338
|
+
const newTables = new Map(this.tables);
|
|
339
|
+
for (const key of write.cellKeys) {
|
|
340
|
+
const [table, row, column] = key.split(SEP);
|
|
341
|
+
const rows = new Map(newTables.get(table) ?? []);
|
|
342
|
+
const cells = new Map(rows.get(row) ?? []);
|
|
343
|
+
cells.delete(column);
|
|
344
|
+
if (cells.size === 0) rows.delete(row);
|
|
345
|
+
else rows.set(row, cells);
|
|
346
|
+
if (rows.size === 0) newTables.delete(table);
|
|
347
|
+
else newTables.set(table, rows);
|
|
348
|
+
}
|
|
349
|
+
this.opsById = newOps;
|
|
350
|
+
this.tables = newTables;
|
|
351
|
+
}
|
|
352
|
+
async applyBatch(batch) {
|
|
353
|
+
const newOps = new Map(this.opsById);
|
|
354
|
+
for (const op of batch.ops) newOps.set(op.id, op);
|
|
355
|
+
const newTables = new Map(this.tables);
|
|
356
|
+
for (const [key, op] of batch.winners) {
|
|
357
|
+
const [table, row, column] = key.split(SEP);
|
|
358
|
+
const rows = new Map(newTables.get(table) ?? []);
|
|
359
|
+
const cells = new Map(rows.get(row) ?? []);
|
|
360
|
+
cells.set(column, op);
|
|
361
|
+
rows.set(row, cells);
|
|
362
|
+
newTables.set(table, rows);
|
|
363
|
+
}
|
|
364
|
+
this.opsById = newOps;
|
|
365
|
+
this.tables = newTables;
|
|
366
|
+
this.frontier = batch.frontier;
|
|
367
|
+
this.clock = batch.clock;
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// src/compaction.ts
|
|
372
|
+
var ZERO_HLC = "";
|
|
373
|
+
var SEP2 = "\0";
|
|
374
|
+
function compactionHorizon(own, peerFrontiers) {
|
|
375
|
+
const horizon = { ...own };
|
|
376
|
+
for (const frontier of Object.values(peerFrontiers)) {
|
|
377
|
+
for (const device of Object.keys(horizon)) {
|
|
378
|
+
const seen = frontier[device] ?? ZERO_HLC;
|
|
379
|
+
if (seen < horizon[device]) horizon[device] = seen;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return horizon;
|
|
383
|
+
}
|
|
384
|
+
function blockingPeers(own, peerFrontiers) {
|
|
385
|
+
const blockers = [];
|
|
386
|
+
for (const [peer, frontier] of Object.entries(peerFrontiers)) {
|
|
387
|
+
for (const [device, ours] of Object.entries(own)) {
|
|
388
|
+
if ((frontier[device] ?? ZERO_HLC) < ours) {
|
|
389
|
+
blockers.push(peer);
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return blockers.sort();
|
|
395
|
+
}
|
|
396
|
+
function rowKeyOf(op) {
|
|
397
|
+
return op.table + SEP2 + op.row;
|
|
398
|
+
}
|
|
399
|
+
function planCompaction(ops, winningCells2, horizon, opts) {
|
|
400
|
+
const winnerIds = /* @__PURE__ */ new Set();
|
|
401
|
+
for (const op of winningCells2.values()) winnerIds.add(op.id);
|
|
402
|
+
const below = (op) => op.hlc <= (horizon[op.device] ?? ZERO_HLC);
|
|
403
|
+
const doomedRows = /* @__PURE__ */ new Set();
|
|
404
|
+
if (opts.includeTombstones) {
|
|
405
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
406
|
+
for (const [key, winner] of winningCells2) {
|
|
407
|
+
if (winner.column === TOMBSTONE_COLUMN && winner.value === true && below(winner)) {
|
|
408
|
+
candidates.add(key.slice(0, key.lastIndexOf(SEP2)));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const blockedRows = /* @__PURE__ */ new Set();
|
|
412
|
+
for (const op of ops) {
|
|
413
|
+
if (!below(op)) blockedRows.add(rowKeyOf(op));
|
|
414
|
+
}
|
|
415
|
+
for (const row of candidates) {
|
|
416
|
+
if (!blockedRows.has(row)) doomedRows.add(row);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const opIds = [];
|
|
420
|
+
const cellKeys = /* @__PURE__ */ new Set();
|
|
421
|
+
let removed = 0;
|
|
422
|
+
let retainedWinners = 0;
|
|
423
|
+
let retainedAboveHorizon = 0;
|
|
424
|
+
for (const op of ops) {
|
|
425
|
+
if (doomedRows.has(rowKeyOf(op))) {
|
|
426
|
+
opIds.push(op.id);
|
|
427
|
+
cellKeys.add(cellKey(op.table, op.row, op.column));
|
|
428
|
+
removed += 1;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (!below(op)) {
|
|
432
|
+
retainedAboveHorizon += 1;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (!winnerIds.has(op.id)) {
|
|
436
|
+
opIds.push(op.id);
|
|
437
|
+
removed += 1;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
retainedWinners += 1;
|
|
441
|
+
}
|
|
442
|
+
for (const key of winningCells2.keys()) {
|
|
443
|
+
if (doomedRows.has(key.slice(0, key.lastIndexOf(SEP2)))) cellKeys.add(key);
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
opIds,
|
|
447
|
+
cellKeys: [...cellKeys],
|
|
448
|
+
stats: {
|
|
449
|
+
scanned: ops.length,
|
|
450
|
+
removed,
|
|
451
|
+
rowsReclaimed: doomedRows.size,
|
|
452
|
+
retainedWinners,
|
|
453
|
+
retainedAboveHorizon
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
async function winningCells(storage) {
|
|
458
|
+
const out = /* @__PURE__ */ new Map();
|
|
459
|
+
for (const table of await storage.listTables()) {
|
|
460
|
+
for (const row of await storage.listRows(table)) {
|
|
461
|
+
const cells = await storage.getRow(table, row);
|
|
462
|
+
for (const [column, op] of cells ?? []) {
|
|
463
|
+
out.set(cellKey(table, row, column), op);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/cipher.ts
|
|
471
|
+
var CIPHER_PREFIX = "e1:";
|
|
472
|
+
function isEncryptedValue(v) {
|
|
473
|
+
return typeof v === "string" && v.startsWith(CIPHER_PREFIX);
|
|
474
|
+
}
|
|
475
|
+
var DecryptError = class extends Error {
|
|
476
|
+
code = "DECRYPT_FAIL";
|
|
477
|
+
constructor(msg) {
|
|
478
|
+
super(`DECRYPT_FAIL: ${msg}`);
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// src/engine.ts
|
|
483
|
+
var SyncEngine = class _SyncEngine {
|
|
484
|
+
deviceId;
|
|
485
|
+
storage;
|
|
486
|
+
clock;
|
|
487
|
+
cipher;
|
|
488
|
+
subscribers = /* @__PURE__ */ new Set();
|
|
489
|
+
deviceKey;
|
|
490
|
+
/** deviceId -> public key. Seeded with our own so we can verify our own ops. */
|
|
491
|
+
keys = /* @__PURE__ */ new Map();
|
|
492
|
+
/** serializes all mutations; JS is single-threaded but ops are async */
|
|
493
|
+
mutex = Promise.resolve();
|
|
494
|
+
constructor(opts, clock, key) {
|
|
495
|
+
this.deviceId = clock.deviceId;
|
|
496
|
+
this.storage = opts.storage;
|
|
497
|
+
this.clock = clock;
|
|
498
|
+
this.cipher = opts.cipher;
|
|
499
|
+
this.deviceKey = key;
|
|
500
|
+
this.keys.set(clock.deviceId, key.publicKey);
|
|
501
|
+
}
|
|
502
|
+
/** This device's public key, for the `hello` message. Section 6.1. */
|
|
503
|
+
get publicKey() {
|
|
504
|
+
return this.deviceKey.publicKey;
|
|
505
|
+
}
|
|
506
|
+
/** Every device key known to this replica, for the `keys` message. */
|
|
507
|
+
knownKeys() {
|
|
508
|
+
return new Map(this.keys);
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Records a peer's public key.
|
|
512
|
+
*
|
|
513
|
+
* Returns false when the key does not hash to the claimed id. That check is
|
|
514
|
+
* what makes the directory self-validating: a peer can relay keys it learned
|
|
515
|
+
* from others, but cannot invent one for somebody else.
|
|
516
|
+
*/
|
|
517
|
+
learnKey(deviceId, publicKey) {
|
|
518
|
+
if (deviceIdFromPublicKey(publicKey) !== deviceId) return false;
|
|
519
|
+
this.keys.set(deviceId, publicKey);
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Opens a replica. Identity comes from the stored keypair, so a restart keeps
|
|
524
|
+
* the same device rather than minting a new one. Section 4.3.
|
|
525
|
+
*/
|
|
526
|
+
static async open(opts) {
|
|
527
|
+
let key = await opts.storage.getDeviceKey();
|
|
528
|
+
if (!key) {
|
|
529
|
+
key = generateDeviceKey();
|
|
530
|
+
await opts.storage.setDeviceKey(key);
|
|
531
|
+
}
|
|
532
|
+
const persisted = await opts.storage.getClock();
|
|
533
|
+
const clock = new HybridLogicalClock({
|
|
534
|
+
deviceId: deviceIdFromPublicKey(key.publicKey),
|
|
535
|
+
...opts.physicalClock ? { physicalClock: opts.physicalClock } : {},
|
|
536
|
+
...persisted ? { millis: persisted.millis, counter: persisted.counter } : {}
|
|
537
|
+
});
|
|
538
|
+
return new _SyncEngine(opts, clock, key);
|
|
539
|
+
}
|
|
540
|
+
// ---------- reads ----------
|
|
541
|
+
/** Materialized row, or undefined if absent/tombstoned. §5. */
|
|
542
|
+
async get(table, row) {
|
|
543
|
+
const cells = await this.storage.getRow(table, row);
|
|
544
|
+
if (!cells) return void 0;
|
|
545
|
+
if (isTombstoned(cells)) return void 0;
|
|
546
|
+
return this.materialize(row, cells);
|
|
547
|
+
}
|
|
548
|
+
/** All visible rows of a table, sorted by rowId (ULID = insertion order). */
|
|
549
|
+
async list(table) {
|
|
550
|
+
const out = [];
|
|
551
|
+
for (const row of (await this.storage.listRows(table)).sort()) {
|
|
552
|
+
const cells = await this.storage.getRow(table, row);
|
|
553
|
+
if (cells && !isTombstoned(cells)) out.push(this.materialize(row, cells));
|
|
554
|
+
}
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
557
|
+
/** Full materialized state; used by tests and conformance vectors. */
|
|
558
|
+
async dump() {
|
|
559
|
+
const state = {};
|
|
560
|
+
for (const table of (await this.storage.listTables()).sort()) {
|
|
561
|
+
for (const row of (await this.storage.listRows(table)).sort()) {
|
|
562
|
+
const cells = await this.storage.getRow(table, row);
|
|
563
|
+
if (!cells || isTombstoned(cells)) continue;
|
|
564
|
+
const { id: _id, ...cols } = this.materialize(row, cells);
|
|
565
|
+
(state[table] ??= {})[row] = cols;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return state;
|
|
569
|
+
}
|
|
570
|
+
// ---------- local writes ----------
|
|
571
|
+
/** Insert a row; returns its generated rowId. */
|
|
572
|
+
async insert(table, values) {
|
|
573
|
+
const row = ulid(this.clockMillisForUlid());
|
|
574
|
+
await this.update(table, row, values);
|
|
575
|
+
return row;
|
|
576
|
+
}
|
|
577
|
+
/** Write one op per column. */
|
|
578
|
+
async update(table, row, values) {
|
|
579
|
+
return this.locked(async () => {
|
|
580
|
+
const ops = Object.entries(values).map(
|
|
581
|
+
([column, value]) => this.makeLocalOp(table, row, column, value)
|
|
582
|
+
);
|
|
583
|
+
await this.commit(ops, "local");
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
/** Row tombstone. §5. */
|
|
587
|
+
async delete(table, row) {
|
|
588
|
+
return this.locked(async () => {
|
|
589
|
+
await this.commit([this.makeLocalOp(table, row, TOMBSTONE_COLUMN, true)], "local");
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
// ---------- sync surface (§6) ----------
|
|
593
|
+
async frontier() {
|
|
594
|
+
return this.storage.getFrontier();
|
|
595
|
+
}
|
|
596
|
+
/** Ops the caller is missing, given their frontier. §6 step 3. */
|
|
597
|
+
async opsSince(frontier) {
|
|
598
|
+
return this.storage.opsSince(frontier);
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Apply a batch of remote ops. Validates, drift-checks, dedupes, merges,
|
|
602
|
+
* persists atomically, notifies. Returns number of newly applied ops.
|
|
603
|
+
* Throws BadOpError / ClockDriftError; on throw, nothing was applied.
|
|
604
|
+
*/
|
|
605
|
+
async applyRemoteOps(remoteOps) {
|
|
606
|
+
if (remoteOps.length > MAX_BATCH_OPS) {
|
|
607
|
+
throw new BadOpError(`batch exceeds ${MAX_BATCH_OPS} ops`);
|
|
608
|
+
}
|
|
609
|
+
for (const op of remoteOps) validateOp(op);
|
|
610
|
+
const ops = remoteOps;
|
|
611
|
+
for (const op of ops) {
|
|
612
|
+
const publicKey = this.keys.get(op.device);
|
|
613
|
+
if (!publicKey) {
|
|
614
|
+
throw new BadOpError(`unknown device ${op.device}; no key to verify against`);
|
|
615
|
+
}
|
|
616
|
+
if (!verifyOp(op, publicKey)) {
|
|
617
|
+
throw new BadOpError(`bad signature on op ${op.id}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return this.locked(async () => {
|
|
621
|
+
const maxOp = ops.reduce(
|
|
622
|
+
(m, o) => m === null || o.hlc > m.hlc ? o : m,
|
|
623
|
+
null
|
|
624
|
+
);
|
|
625
|
+
if (maxOp) this.clock.receive(decodeHlc(maxOp.hlc));
|
|
626
|
+
const fresh = [];
|
|
627
|
+
for (const op of ops) {
|
|
628
|
+
if (!await this.storage.hasOp(op.id) && !fresh.some((f) => f.id === op.id)) {
|
|
629
|
+
fresh.push(op);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (fresh.length === 0) {
|
|
633
|
+
await this.storage.applyBatch({
|
|
634
|
+
ops: [],
|
|
635
|
+
winners: /* @__PURE__ */ new Map(),
|
|
636
|
+
frontier: await this.storage.getFrontier(),
|
|
637
|
+
clock: this.clock.state()
|
|
638
|
+
});
|
|
639
|
+
return 0;
|
|
640
|
+
}
|
|
641
|
+
await this.commit(fresh, "remote");
|
|
642
|
+
return fresh.length;
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Observe a peer's clock from a hello message (§6 step 1). Drift-checks and
|
|
647
|
+
* advances + persists our clock without applying any ops.
|
|
648
|
+
*/
|
|
649
|
+
async observeRemoteClock(hlc) {
|
|
650
|
+
return this.locked(async () => {
|
|
651
|
+
this.clock.receive(decodeHlc(hlc));
|
|
652
|
+
await this.storage.applyBatch({
|
|
653
|
+
ops: [],
|
|
654
|
+
winners: /* @__PURE__ */ new Map(),
|
|
655
|
+
frontier: await this.storage.getFrontier(),
|
|
656
|
+
clock: this.clock.state()
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
// ---------- compaction (§9) ----------
|
|
661
|
+
/**
|
|
662
|
+
* Record a peer's frontier, learned from a since/ack exchange. This is the
|
|
663
|
+
* input that lets compaction know what has safely reached everyone.
|
|
664
|
+
*/
|
|
665
|
+
async recordPeerFrontier(peer, frontier) {
|
|
666
|
+
if (peer === this.deviceId) return;
|
|
667
|
+
await this.storage.setPeerFrontier(peer, frontier);
|
|
668
|
+
}
|
|
669
|
+
async peerFrontiers() {
|
|
670
|
+
return this.storage.getPeerFrontiers();
|
|
671
|
+
}
|
|
672
|
+
/** Number of ops currently retained in the log. */
|
|
673
|
+
async opCount() {
|
|
674
|
+
return this.storage.opCount();
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Reclaim superseded ops (§9). Safe by construction: winners are never
|
|
678
|
+
* dropped, and nothing above the compaction horizon is touched. Tombstone
|
|
679
|
+
* GC is opt-in via `includeTombstones` — see compaction.ts for why.
|
|
680
|
+
*/
|
|
681
|
+
async compact(opts = {}) {
|
|
682
|
+
return this.locked(async () => {
|
|
683
|
+
const own = await this.storage.getFrontier();
|
|
684
|
+
const peers = await this.storage.getPeerFrontiers();
|
|
685
|
+
const horizon = compactionHorizon(own, peers);
|
|
686
|
+
const winners = await winningCells(this.storage);
|
|
687
|
+
const ops = await this.storage.allOps();
|
|
688
|
+
const plan = planCompaction(ops, winners, horizon, opts);
|
|
689
|
+
if (!opts.dryRun && (plan.opIds.length > 0 || plan.cellKeys.length > 0)) {
|
|
690
|
+
await this.storage.compact({ opIds: plan.opIds, cellKeys: plan.cellKeys });
|
|
691
|
+
}
|
|
692
|
+
return { ...plan.stats, blockedBy: blockingPeers(own, peers) };
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
// ---------- subscriptions ----------
|
|
696
|
+
subscribe(cb) {
|
|
697
|
+
this.subscribers.add(cb);
|
|
698
|
+
return () => this.subscribers.delete(cb);
|
|
699
|
+
}
|
|
700
|
+
// ---------- internals ----------
|
|
701
|
+
makeLocalOp(table, row, column, value) {
|
|
702
|
+
const hlc = encodeHlc(this.clock.now());
|
|
703
|
+
const stored = this.cipher && column !== TOMBSTONE_COLUMN ? this.cipher.encrypt(value, hlc) : value;
|
|
704
|
+
const unsigned = { id: hlc, table, row, column, value: stored, hlc, device: this.deviceId };
|
|
705
|
+
const op = {
|
|
706
|
+
...unsigned,
|
|
707
|
+
sig: signPayload(signedPayload(unsigned), this.deviceKey.privateKey)
|
|
708
|
+
};
|
|
709
|
+
validateOp(op);
|
|
710
|
+
return op;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* The single mutation path (local and remote both land here):
|
|
714
|
+
* compute LWW winners for affected cells, advance frontier, persist
|
|
715
|
+
* atomically, notify subscribers. §5, §8.2.
|
|
716
|
+
*/
|
|
717
|
+
async commit(ops, origin) {
|
|
718
|
+
const winners = /* @__PURE__ */ new Map();
|
|
719
|
+
let frontier = await this.storage.getFrontier();
|
|
720
|
+
for (const op of ops) {
|
|
721
|
+
const key = cellKey(op.table, op.row, op.column);
|
|
722
|
+
const pending = winners.get(key);
|
|
723
|
+
const current = pending ?? await this.storage.getWinner(op.table, op.row, op.column);
|
|
724
|
+
if (current === void 0 || op.hlc > current.hlc) {
|
|
725
|
+
winners.set(key, op);
|
|
726
|
+
} else if (pending === void 0) {
|
|
727
|
+
winners.set(key, current);
|
|
728
|
+
}
|
|
729
|
+
frontier = advanceFrontier(frontier, op);
|
|
730
|
+
}
|
|
731
|
+
await this.storage.applyBatch({
|
|
732
|
+
ops,
|
|
733
|
+
winners,
|
|
734
|
+
frontier,
|
|
735
|
+
clock: this.clock.state()
|
|
736
|
+
});
|
|
737
|
+
const changed = [...new Set(ops.map((o) => o.table + "\0" + o.row))].map((k) => {
|
|
738
|
+
const [table, row] = k.split("\0");
|
|
739
|
+
return { table, row };
|
|
740
|
+
});
|
|
741
|
+
const event = { changes: changed, ops, origin };
|
|
742
|
+
for (const cb of this.subscribers) cb(event);
|
|
743
|
+
}
|
|
744
|
+
/** Decrypt (if needed) and shape stored cells into a row. */
|
|
745
|
+
materialize(row, cells) {
|
|
746
|
+
const out = {};
|
|
747
|
+
for (const [column, op] of cells) {
|
|
748
|
+
if (column === TOMBSTONE_COLUMN) continue;
|
|
749
|
+
const value = this.cipher && isEncryptedValue(op.value) ? this.cipher.decrypt(op.value, op.id) : op.value;
|
|
750
|
+
if (value !== null) out[column] = value;
|
|
751
|
+
}
|
|
752
|
+
return { id: row, ...out };
|
|
753
|
+
}
|
|
754
|
+
clockMillisForUlid() {
|
|
755
|
+
const s = this.clock.state();
|
|
756
|
+
return Math.max(s.millis, Date.now()) % 2 ** 48;
|
|
757
|
+
}
|
|
758
|
+
locked(fn) {
|
|
759
|
+
const run = this.mutex.then(fn, fn);
|
|
760
|
+
this.mutex = run.catch(() => void 0);
|
|
761
|
+
return run;
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
function isTombstoned(cells) {
|
|
765
|
+
return cells.get(TOMBSTONE_COLUMN)?.value === true;
|
|
766
|
+
}
|
|
767
|
+
async function syncOnce(a, b) {
|
|
768
|
+
for (const [id, k] of a.knownKeys()) b.learnKey(id, k);
|
|
769
|
+
for (const [id, k] of b.knownKeys()) a.learnKey(id, k);
|
|
770
|
+
const [fa, fb] = [await a.frontier(), await b.frontier()];
|
|
771
|
+
const aToB = await a.opsSince(fb);
|
|
772
|
+
const bToA = await b.opsSince(fa);
|
|
773
|
+
if (aToB.length) await b.applyRemoteOps(aToB);
|
|
774
|
+
if (bToA.length) await a.applyRemoteOps(bToA);
|
|
775
|
+
await a.recordPeerFrontier(b.deviceId, await b.frontier());
|
|
776
|
+
await b.recordPeerFrontier(a.deviceId, await a.frontier());
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// src/replicator.ts
|
|
780
|
+
var WIRE_VERSION = 2;
|
|
781
|
+
var OPS_PER_MESSAGE = 500;
|
|
782
|
+
var Replicator = class {
|
|
783
|
+
engine;
|
|
784
|
+
transport;
|
|
785
|
+
space;
|
|
786
|
+
events;
|
|
787
|
+
peers = /* @__PURE__ */ new Map();
|
|
788
|
+
unsubs = [];
|
|
789
|
+
started = false;
|
|
790
|
+
constructor(opts) {
|
|
791
|
+
this.engine = opts.engine;
|
|
792
|
+
this.transport = opts.transport;
|
|
793
|
+
this.space = opts.space;
|
|
794
|
+
this.events = opts.events ?? {};
|
|
795
|
+
}
|
|
796
|
+
get peerIds() {
|
|
797
|
+
return new Set(this.peers.keys());
|
|
798
|
+
}
|
|
799
|
+
async start() {
|
|
800
|
+
if (this.started) return;
|
|
801
|
+
this.started = true;
|
|
802
|
+
this.unsubs.push(this.transport.onMessage((msg) => void this.handle(msg)));
|
|
803
|
+
if (this.transport.onPeerConnect) {
|
|
804
|
+
this.unsubs.push(
|
|
805
|
+
this.transport.onPeerConnect(() => void this.sendHello())
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
this.unsubs.push(
|
|
809
|
+
this.engine.subscribe((ev) => {
|
|
810
|
+
if (ev.origin !== "local" || ev.ops.length === 0) return;
|
|
811
|
+
for (const chunk of chunks(ev.ops, OPS_PER_MESSAGE)) {
|
|
812
|
+
this.transport.send(this.msg({ t: "ops", ops: chunk }));
|
|
813
|
+
}
|
|
814
|
+
})
|
|
815
|
+
);
|
|
816
|
+
await this.sendHello();
|
|
817
|
+
}
|
|
818
|
+
stop() {
|
|
819
|
+
this.started = false;
|
|
820
|
+
for (const u of this.unsubs) u();
|
|
821
|
+
this.unsubs = [];
|
|
822
|
+
this.peers.clear();
|
|
823
|
+
this.events.onPeersChange?.(this.peerIds);
|
|
824
|
+
}
|
|
825
|
+
async sendHello() {
|
|
826
|
+
const latest = (await this.engine.opsSince({})).at(-1)?.hlc;
|
|
827
|
+
this.transport.send(
|
|
828
|
+
this.msg({
|
|
829
|
+
t: "hello",
|
|
830
|
+
clock: latest ?? zeroClock(this.engine.deviceId),
|
|
831
|
+
key: hex(this.engine.publicKey)
|
|
832
|
+
})
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Every device key we hold, sent before any ops so a peer never receives an
|
|
837
|
+
* op it cannot verify. Section 6.1.
|
|
838
|
+
*
|
|
839
|
+
* Relaying keys we learned from others is what lets an op reach a peer that
|
|
840
|
+
* never met its author. It is safe because learnKey discards any entry that
|
|
841
|
+
* does not hash to its claimed id.
|
|
842
|
+
*/
|
|
843
|
+
async sendKeys(to) {
|
|
844
|
+
const keys = {};
|
|
845
|
+
for (const [id, k] of this.engine.knownKeys()) keys[id] = hex(k);
|
|
846
|
+
this.transport.send(this.msg(to === void 0 ? { t: "keys", keys } : { t: "keys", to, keys }));
|
|
847
|
+
}
|
|
848
|
+
// ---------- inbound ----------
|
|
849
|
+
async handle(msg) {
|
|
850
|
+
try {
|
|
851
|
+
if (!this.started) return;
|
|
852
|
+
if (msg.space !== this.space || msg.v !== WIRE_VERSION) return;
|
|
853
|
+
if (msg.from === this.engine.deviceId) return;
|
|
854
|
+
if ("to" in msg && msg.to !== void 0 && msg.to !== this.engine.deviceId) return;
|
|
855
|
+
switch (msg.t) {
|
|
856
|
+
case "hello": {
|
|
857
|
+
if (typeof msg.key === "string") this.engine.learnKey(msg.from, unhex(msg.key));
|
|
858
|
+
await this.engine.observeRemoteClock(msg.clock);
|
|
859
|
+
const isNew = !this.peers.has(msg.from);
|
|
860
|
+
if (isNew) {
|
|
861
|
+
this.peers.set(msg.from, {});
|
|
862
|
+
this.events.onPeersChange?.(this.peerIds);
|
|
863
|
+
await this.sendHello();
|
|
864
|
+
}
|
|
865
|
+
await this.sendKeys(msg.from);
|
|
866
|
+
this.transport.send(
|
|
867
|
+
this.msg({ t: "since", to: msg.from, have: await this.engine.frontier() })
|
|
868
|
+
);
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
case "keys": {
|
|
872
|
+
for (const [id, k] of Object.entries(msg.keys)) {
|
|
873
|
+
this.engine.learnKey(id, unhex(k));
|
|
874
|
+
}
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
case "since": {
|
|
878
|
+
this.peers.set(msg.from, { frontier: msg.have });
|
|
879
|
+
await this.engine.recordPeerFrontier(msg.from, msg.have);
|
|
880
|
+
const missing = await this.engine.opsSince(msg.have);
|
|
881
|
+
if (missing.length > 0) await this.sendKeys(msg.from);
|
|
882
|
+
for (const chunk of chunks(missing, OPS_PER_MESSAGE)) {
|
|
883
|
+
this.transport.send(this.msg({ t: "ops", to: msg.from, ops: chunk }));
|
|
884
|
+
}
|
|
885
|
+
break;
|
|
886
|
+
}
|
|
887
|
+
case "ops": {
|
|
888
|
+
if (msg.ops.length === 0) return;
|
|
889
|
+
const applied = await this.engine.applyRemoteOps(msg.ops);
|
|
890
|
+
if (applied > 0) {
|
|
891
|
+
this.transport.send(
|
|
892
|
+
this.msg({ t: "ack", to: msg.from, frontier: await this.engine.frontier() })
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
case "ack": {
|
|
898
|
+
this.peers.set(msg.from, { frontier: msg.frontier });
|
|
899
|
+
await this.engine.recordPeerFrontier(msg.from, msg.frontier);
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
} catch (err) {
|
|
904
|
+
this.events.onError?.(err, { from: msg.from });
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
msg(body) {
|
|
908
|
+
return {
|
|
909
|
+
v: WIRE_VERSION,
|
|
910
|
+
space: this.space,
|
|
911
|
+
from: this.engine.deviceId,
|
|
912
|
+
...body
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
function* chunks(arr, size) {
|
|
917
|
+
for (let i = 0; i < arr.length; i += size) yield arr.slice(i, i + size);
|
|
918
|
+
}
|
|
919
|
+
function zeroClock(deviceId) {
|
|
920
|
+
return "0".repeat(12) + "-0000-" + deviceId;
|
|
921
|
+
}
|
|
922
|
+
var hex = (b) => [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
|
923
|
+
var unhex = (s) => new Uint8Array((s.match(/../g) ?? []).map((h) => parseInt(h, 16)));
|
|
924
|
+
export {
|
|
925
|
+
BadOpError,
|
|
926
|
+
CIPHER_PREFIX,
|
|
927
|
+
ClockDriftError,
|
|
928
|
+
DEVICE_ID_HEX,
|
|
929
|
+
DecryptError,
|
|
930
|
+
HLC_LENGTH,
|
|
931
|
+
HybridLogicalClock,
|
|
932
|
+
MAX_BATCH_OPS,
|
|
933
|
+
MAX_COUNTER,
|
|
934
|
+
MAX_DRIFT_MS,
|
|
935
|
+
MAX_MILLIS,
|
|
936
|
+
MAX_OP_BYTES,
|
|
937
|
+
MemoryAdapter,
|
|
938
|
+
OPS_PER_MESSAGE,
|
|
939
|
+
Replicator,
|
|
940
|
+
SIGNING_DOMAIN,
|
|
941
|
+
SyncEngine,
|
|
942
|
+
TOMBSTONE_COLUMN,
|
|
943
|
+
WIRE_VERSION,
|
|
944
|
+
aboveFrontier,
|
|
945
|
+
advanceFrontier,
|
|
946
|
+
blockingPeers,
|
|
947
|
+
canonicalJson,
|
|
948
|
+
cellKey,
|
|
949
|
+
compactionHorizon,
|
|
950
|
+
compareHlc,
|
|
951
|
+
decodeHlc,
|
|
952
|
+
deviceIdFromPublicKey,
|
|
953
|
+
encodeHlc,
|
|
954
|
+
generateDeviceKey,
|
|
955
|
+
isEncryptedValue,
|
|
956
|
+
isValidDeviceId,
|
|
957
|
+
planCompaction,
|
|
958
|
+
signPayload,
|
|
959
|
+
signedPayload,
|
|
960
|
+
syncOnce,
|
|
961
|
+
ulid,
|
|
962
|
+
validateOp,
|
|
963
|
+
verifyOp,
|
|
964
|
+
verifyPayload,
|
|
965
|
+
winningCells
|
|
966
|
+
};
|