@delali/sirannon-db 0.1.4 → 0.1.5

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.
@@ -0,0 +1,3784 @@
1
+ import { Transaction } from '../chunk-3MCMONVP.mjs';
2
+ import { decodeTaggedValues } from '../chunk-GS7T5YMI.mjs';
3
+ import { compatibilityAllowsPromotion } from '../chunk-ER7ODTDA.mjs';
4
+ import { ReplicationError, WriteConcernError, AuthorityError, SyncError, TopologyError, CoordinatorError, StalePrimaryError, NodeDrainingError, NodeNotInSyncError, ReadConcernError, ProtocolVersionMismatchError, BatchValidationError } from '../chunk-UVMVN3OT.mjs';
5
+ export { AuthorityError, BatchValidationError, ConflictError, CoordinatorError, FailoverError, NoSafePrimaryError, NodeDrainingError, NodeNotInSyncError, ProtocolVersionMismatchError, ReadConcernError, ReplicationError, StalePrimaryError, SyncError, TopologyError, TransportError, UnsafeRecoveryRequiredError, WriteConcernError } from '../chunk-UVMVN3OT.mjs';
6
+ import '../chunk-O7BHI3CF.mjs';
7
+ import { randomBytes, randomUUID, createHash } from 'crypto';
8
+ import { EventEmitter } from 'events';
9
+
10
+ // src/replication/hlc.ts
11
+ var MAX_LOGICAL = 65535;
12
+ var HLC = class _HLC {
13
+ wallMs;
14
+ logical;
15
+ nodeId;
16
+ constructor(nodeId) {
17
+ this.nodeId = nodeId;
18
+ this.wallMs = 0;
19
+ this.logical = 0;
20
+ }
21
+ /** Generate a new HLC timestamp, advancing the clock. */
22
+ now() {
23
+ const physicalMs = Date.now();
24
+ if (physicalMs > this.wallMs) {
25
+ this.wallMs = physicalMs;
26
+ this.logical = 0;
27
+ } else {
28
+ this.logical += 1;
29
+ if (this.logical > MAX_LOGICAL) {
30
+ throw new ReplicationError("HLC logical counter overflow");
31
+ }
32
+ }
33
+ return this.encode(this.wallMs, this.logical, this.nodeId);
34
+ }
35
+ /** Merge a remote HLC timestamp into the local clock and return the updated value. */
36
+ receive(remote) {
37
+ const r = _HLC.decode(remote);
38
+ const physicalMs = Date.now();
39
+ if (physicalMs > this.wallMs && physicalMs > r.wallMs) {
40
+ this.wallMs = physicalMs;
41
+ this.logical = 0;
42
+ } else if (r.wallMs > this.wallMs) {
43
+ this.wallMs = r.wallMs;
44
+ this.logical = r.logical + 1;
45
+ } else if (this.wallMs > r.wallMs) {
46
+ this.logical += 1;
47
+ } else {
48
+ this.logical = Math.max(this.logical, r.logical) + 1;
49
+ }
50
+ if (this.logical > MAX_LOGICAL) {
51
+ throw new ReplicationError("HLC logical counter overflow");
52
+ }
53
+ return this.encode(this.wallMs, this.logical, this.nodeId);
54
+ }
55
+ /** Lexicographic comparison of two encoded HLC strings. Returns -1, 0, or 1. */
56
+ static compare(a, b) {
57
+ if (a < b) return -1;
58
+ if (a > b) return 1;
59
+ return 0;
60
+ }
61
+ /** Parse an encoded HLC string into its wall-clock, logical, and nodeId components. */
62
+ static decode(hlc) {
63
+ const parts = hlc.split("-");
64
+ if (parts.length < 3) {
65
+ throw new ReplicationError(`Invalid HLC format: ${hlc}`);
66
+ }
67
+ return {
68
+ wallMs: Number.parseInt(parts[0], 16),
69
+ logical: Number.parseInt(parts[1], 16),
70
+ nodeId: parts.slice(2).join("-")
71
+ };
72
+ }
73
+ encode(wallMs, logical, nodeId) {
74
+ const wallHex = wallMs.toString(16).padStart(12, "0");
75
+ const logicalHex = logical.toString(16).padStart(4, "0");
76
+ return `${wallHex}-${logicalHex}-${nodeId}`;
77
+ }
78
+ };
79
+
80
+ // src/replication/conflict/lww.ts
81
+ var LWWResolver = class {
82
+ resolve(ctx) {
83
+ if (ctx.localHlc === null) {
84
+ return { action: "accept_remote" };
85
+ }
86
+ const cmp = HLC.compare(ctx.remoteHlc, ctx.localHlc);
87
+ if (cmp > 0) {
88
+ return { action: "accept_remote" };
89
+ }
90
+ if (cmp < 0) {
91
+ return { action: "keep_local" };
92
+ }
93
+ if (ctx.remoteChange.nodeId > (ctx.localChange?.nodeId ?? "")) {
94
+ return { action: "accept_remote" };
95
+ }
96
+ return { action: "keep_local" };
97
+ }
98
+ };
99
+
100
+ // src/replication/conflict/field-merge.ts
101
+ var FieldMergeResolver = class {
102
+ getColumnVersions;
103
+ lww = new LWWResolver();
104
+ constructor(getColumnVersions) {
105
+ this.getColumnVersions = getColumnVersions;
106
+ }
107
+ async resolve(ctx) {
108
+ const columnVersions = await this.getColumnVersions(ctx.table, ctx.rowId);
109
+ if (columnVersions.size === 0) {
110
+ return this.lww.resolve(ctx);
111
+ }
112
+ const localData = ctx.localChange?.newData ?? ctx.localChange?.oldData ?? {};
113
+ const remoteData = ctx.remoteChange.newData ?? {};
114
+ const oldData = ctx.remoteChange.oldData ?? {};
115
+ const localChanged = /* @__PURE__ */ new Set();
116
+ const remoteChanged = /* @__PURE__ */ new Set();
117
+ for (const key of Object.keys(localData)) {
118
+ if (JSON.stringify(localData[key]) !== JSON.stringify(oldData[key])) {
119
+ localChanged.add(key);
120
+ }
121
+ }
122
+ for (const key of Object.keys(remoteData)) {
123
+ if (JSON.stringify(remoteData[key]) !== JSON.stringify(oldData[key])) {
124
+ remoteChanged.add(key);
125
+ }
126
+ }
127
+ const overlapping = /* @__PURE__ */ new Set();
128
+ for (const key of remoteChanged) {
129
+ if (localChanged.has(key)) {
130
+ overlapping.add(key);
131
+ }
132
+ }
133
+ if (overlapping.size === 0 && (localChanged.size > 0 || remoteChanged.size > 0)) {
134
+ const merged2 = { ...localData };
135
+ for (const key of remoteChanged) {
136
+ merged2[key] = remoteData[key];
137
+ }
138
+ return { action: "merge", mergedData: merged2 };
139
+ }
140
+ const merged = { ...localData };
141
+ let anyRemoteWins = false;
142
+ for (const key of overlapping) {
143
+ const cv = columnVersions.get(key);
144
+ if (!cv) {
145
+ const rowLww = this.lww.resolve(ctx);
146
+ if (rowLww.action === "accept_remote") {
147
+ merged[key] = remoteData[key];
148
+ anyRemoteWins = true;
149
+ }
150
+ continue;
151
+ }
152
+ const cmp = HLC.compare(ctx.remoteHlc, cv.hlc);
153
+ if (cmp > 0) {
154
+ merged[key] = remoteData[key];
155
+ anyRemoteWins = true;
156
+ } else if (cmp === 0 && ctx.remoteChange.nodeId > cv.nodeId) {
157
+ merged[key] = remoteData[key];
158
+ anyRemoteWins = true;
159
+ }
160
+ }
161
+ for (const key of remoteChanged) {
162
+ if (!overlapping.has(key)) {
163
+ merged[key] = remoteData[key];
164
+ anyRemoteWins = true;
165
+ }
166
+ }
167
+ if (anyRemoteWins) {
168
+ return { action: "merge", mergedData: merged };
169
+ }
170
+ return { action: "keep_local" };
171
+ }
172
+ };
173
+
174
+ // src/replication/conflict/primary-wins.ts
175
+ var PrimaryWinsResolver = class {
176
+ primaryNodeId;
177
+ lww = new LWWResolver();
178
+ constructor(primaryNodeId) {
179
+ this.primaryNodeId = primaryNodeId;
180
+ }
181
+ resolve(ctx) {
182
+ if (ctx.remoteChange.nodeId === this.primaryNodeId) {
183
+ return { action: "accept_remote" };
184
+ }
185
+ if (ctx.localChange?.nodeId === this.primaryNodeId) {
186
+ return { action: "keep_local" };
187
+ }
188
+ return this.lww.resolve(ctx);
189
+ }
190
+ };
191
+
192
+ // src/replication/log/canonicalise.ts
193
+ function canonicaliseForChecksum(value) {
194
+ return canonicaliseValue(value);
195
+ }
196
+ function canonicaliseValue(v) {
197
+ if (v === null || v === void 0) return "null";
198
+ if (typeof v === "boolean") return v ? "true" : "false";
199
+ if (typeof v === "string") return JSON.stringify(v);
200
+ if (typeof v === "bigint") return `{"__sint":"${v.toString()}"}`;
201
+ if (typeof v === "number") {
202
+ if (Number.isInteger(v)) return `{"__sint":"${v.toString()}"}`;
203
+ if (Number.isFinite(v)) return JSON.stringify(v);
204
+ return "null";
205
+ }
206
+ if (typeof v === "object") {
207
+ if (v instanceof Uint8Array) {
208
+ const buf = Buffer.isBuffer(v) ? v : Buffer.from(v);
209
+ return `{"__blob":"${buf.toString("base64")}"}`;
210
+ }
211
+ if (typeof Buffer !== "undefined" && Buffer.isBuffer(v)) {
212
+ return `{"__blob":"${v.toString("base64")}"}`;
213
+ }
214
+ if (Array.isArray(v)) {
215
+ return `[${v.map(canonicaliseValue).join(",")}]`;
216
+ }
217
+ const obj = v;
218
+ const parts = [];
219
+ for (const key of Object.keys(obj).sort()) {
220
+ const val = obj[key];
221
+ if (val === void 0) continue;
222
+ parts.push(`${JSON.stringify(key)}:${canonicaliseValue(val)}`);
223
+ }
224
+ return `{${parts.join(",")}}`;
225
+ }
226
+ return "null";
227
+ }
228
+
229
+ // src/replication/engine/constants.ts
230
+ var DEFAULT_BATCH_SIZE = 100;
231
+ var DEFAULT_BATCH_INTERVAL_MS = 100;
232
+ var DEFAULT_MAX_CLOCK_DRIFT_MS = 6e4;
233
+ var DEFAULT_MAX_PENDING_BATCHES = 10;
234
+ var DEFAULT_MAX_BATCH_CHANGES = 1e3;
235
+ var DEFAULT_ACK_TIMEOUT_MS = 5e3;
236
+ var DEFAULT_SYNC_BATCH_SIZE = 1e4;
237
+ var DEFAULT_MAX_CONCURRENT_SYNCS = 2;
238
+ var DEFAULT_MAX_SYNC_DURATION_MS = 18e5;
239
+ var DEFAULT_MAX_SYNC_LAG_BEFORE_READY = 100;
240
+ var DEFAULT_SYNC_ACK_TIMEOUT_MS = 3e4;
241
+ var DEFAULT_CATCH_UP_DEADLINE_MS = 6e5;
242
+ var DDL_PREFIX_RE = /^\s*(CREATE\s+TABLE|ALTER\s+TABLE\s+\S+\s+ADD\s+COLUMN|DROP\s+TABLE|CREATE\s+INDEX|DROP\s+INDEX)\b/i;
243
+ var SAFE_SQL_PREFIX_RE = /^\s*(INSERT|UPDATE|DELETE|SELECT|CREATE\s+TABLE|ALTER\s+TABLE|DROP\s+TABLE|CREATE\s+INDEX|DROP\s+INDEX)\b/i;
244
+ var IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
245
+ var DDL_DENY_RE = /\b(load_extension|ATTACH|randomblob|zeroblob|writefile|readfile|fts3_tokenizer)\b/i;
246
+ var DROP_TABLE_RE = /^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?"?([A-Za-z_][A-Za-z0-9_]*)"?\s*;?\s*$/i;
247
+ function extractDroppedTable(sql) {
248
+ const m = DROP_TABLE_RE.exec(sql);
249
+ return m?.[1] ?? null;
250
+ }
251
+ var SYNC_SAFE_DDL_PREFIX_RE = /^\s*(CREATE\s+TABLE|ALTER\s+TABLE\s+\S+\s+ADD\s+COLUMN|DROP\s+TABLE|CREATE\s+INDEX|DROP\s+INDEX)\b/i;
252
+ function isSyncSafeDdl(sql) {
253
+ if (!SYNC_SAFE_DDL_PREFIX_RE.test(sql)) return false;
254
+ if (sql.includes(";")) return false;
255
+ if (/\bAS\s+SELECT\b/i.test(sql)) return false;
256
+ if (DDL_DENY_RE.test(sql)) return false;
257
+ const body = sql.replace(SYNC_SAFE_DDL_PREFIX_RE, "");
258
+ if (/\bSELECT\b/i.test(body)) return false;
259
+ return true;
260
+ }
261
+ var BatchReader = class {
262
+ constructor(conn, localNodeId, hlc, changesTable, pkResolver) {
263
+ this.conn = conn;
264
+ this.localNodeId = localNodeId;
265
+ this.hlc = hlc;
266
+ this.changesTable = changesTable;
267
+ this.pkResolver = pkResolver;
268
+ }
269
+ async readBatch(afterSeq, batchSize) {
270
+ const stmt = await this.conn.prepare(
271
+ `SELECT seq, table_name, operation, row_id, changed_at, old_data, new_data, node_id, tx_id, hlc
272
+ FROM "${this.changesTable}"
273
+ WHERE seq > ? AND node_id = ?
274
+ ORDER BY seq ASC
275
+ LIMIT ?`
276
+ );
277
+ const rows = await stmt.all(afterSeq.toString(), this.localNodeId, batchSize);
278
+ if (rows.length === 0) {
279
+ return null;
280
+ }
281
+ const changes = [];
282
+ let minHlc = rows[0].hlc;
283
+ let maxHlc = rows[0].hlc;
284
+ for (const row of rows) {
285
+ const operation = row.operation.toLowerCase();
286
+ const isDdl = operation === "ddl";
287
+ const rawNewData = row.new_data ? decodeTaggedValues(JSON.parse(row.new_data)) : null;
288
+ const rawOldData = row.old_data ? decodeTaggedValues(JSON.parse(row.old_data)) : null;
289
+ let ddlStatement;
290
+ let newData = rawNewData;
291
+ let oldData = rawOldData;
292
+ if (isDdl) {
293
+ const candidate = rawNewData?.ddlStatement;
294
+ if (typeof candidate === "string") {
295
+ ddlStatement = candidate;
296
+ }
297
+ newData = null;
298
+ oldData = null;
299
+ }
300
+ const pkColumns = isDdl ? [] : await this.pkResolver.forTable(row.table_name);
301
+ const primaryKey = {};
302
+ if (!isDdl) {
303
+ const sourceData = rawNewData ?? rawOldData ?? {};
304
+ for (const col of pkColumns) {
305
+ if (col in sourceData) {
306
+ primaryKey[col] = sourceData[col];
307
+ }
308
+ }
309
+ }
310
+ const change = {
311
+ table: row.table_name,
312
+ operation,
313
+ rowId: String(row.row_id),
314
+ primaryKey,
315
+ hlc: row.hlc,
316
+ txId: row.tx_id,
317
+ nodeId: row.node_id,
318
+ newData,
319
+ oldData
320
+ };
321
+ if (ddlStatement !== void 0) {
322
+ change.ddlStatement = ddlStatement;
323
+ }
324
+ changes.push(change);
325
+ if (HLC.compare(row.hlc, minHlc) < 0) {
326
+ minHlc = row.hlc;
327
+ }
328
+ if (HLC.compare(row.hlc, maxHlc) > 0) {
329
+ maxHlc = row.hlc;
330
+ }
331
+ }
332
+ const fromSeq = BigInt(rows[0].seq);
333
+ const toSeq = BigInt(rows[rows.length - 1].seq);
334
+ const checksum = computeChecksum(changes);
335
+ return {
336
+ sourceNodeId: this.localNodeId,
337
+ batchId: `${this.localNodeId}-${fromSeq}-${toSeq}`,
338
+ fromSeq,
339
+ toSeq,
340
+ hlcRange: { min: minHlc, max: maxHlc },
341
+ changes,
342
+ checksum
343
+ };
344
+ }
345
+ async stampChanges(tx, afterSeq, txId) {
346
+ const hlcValue = this.hlc.now();
347
+ const stmt = await tx.prepare(
348
+ `UPDATE "${this.changesTable}" SET node_id = ?, tx_id = ?, hlc = ? WHERE seq > ? AND node_id = ''`
349
+ );
350
+ await stmt.run(this.localNodeId, txId, hlcValue, afterSeq.toString());
351
+ }
352
+ async updateColumnVersions(tx, afterSeq) {
353
+ const selectStmt = await tx.prepare(
354
+ `SELECT seq, table_name, operation, row_id, old_data, new_data, hlc, node_id
355
+ FROM "${this.changesTable}"
356
+ WHERE seq > ? AND node_id = ?
357
+ ORDER BY seq ASC`
358
+ );
359
+ const rows = await selectStmt.all(afterSeq.toString(), this.localNodeId);
360
+ for (const row of rows) {
361
+ if (row.operation === "DELETE") {
362
+ const delStmt = await tx.prepare("DELETE FROM _sirannon_column_versions WHERE table_name = ? AND row_id = ?");
363
+ await delStmt.run(row.table_name, String(row.row_id));
364
+ continue;
365
+ }
366
+ const oldData = row.old_data ? decodeTaggedValues(JSON.parse(row.old_data)) : {};
367
+ const newData = row.new_data ? decodeTaggedValues(JSON.parse(row.new_data)) : {};
368
+ const changedCols = [];
369
+ if (row.operation === "INSERT") {
370
+ for (const key of Object.keys(newData)) {
371
+ changedCols.push(key);
372
+ }
373
+ } else {
374
+ for (const key of Object.keys(newData)) {
375
+ if (canonicaliseForChecksum(newData[key]) !== canonicaliseForChecksum(oldData[key])) {
376
+ changedCols.push(key);
377
+ }
378
+ }
379
+ }
380
+ const upsertStmt = await tx.prepare(
381
+ `INSERT INTO _sirannon_column_versions (table_name, row_id, column_name, hlc, node_id)
382
+ VALUES (?, ?, ?, ?, ?)
383
+ ON CONFLICT(table_name, row_id, column_name)
384
+ DO UPDATE SET hlc = excluded.hlc, node_id = excluded.node_id`
385
+ );
386
+ for (const col of changedCols) {
387
+ await upsertStmt.run(row.table_name, String(row.row_id), col, row.hlc, row.node_id);
388
+ }
389
+ }
390
+ }
391
+ };
392
+ function computeChecksum(changes) {
393
+ const hash = createHash("sha256");
394
+ hash.update(canonicaliseForChecksum(changes));
395
+ return hash.digest("hex");
396
+ }
397
+
398
+ // src/replication/log/validators.ts
399
+ var IDENTIFIER_RE2 = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
400
+ var SAFE_DDL_RE = /^\s*(CREATE\s+TABLE|ALTER\s+TABLE\s+\S+\s+ADD\s+COLUMN|DROP\s+TABLE|CREATE\s+INDEX|DROP\s+INDEX)\b/i;
401
+ var DDL_DENY_RE2 = /\b(load_extension|ATTACH|randomblob|zeroblob|writefile|readfile|fts3_tokenizer)\b/i;
402
+ function validateIdentifier(name) {
403
+ return IDENTIFIER_RE2.test(name);
404
+ }
405
+ function validateDdlSafety(sql) {
406
+ if (!SAFE_DDL_RE.test(sql)) return false;
407
+ if (sql.includes(";")) return false;
408
+ if (/\bAS\s+SELECT\b/i.test(sql)) return false;
409
+ if (DDL_DENY_RE2.test(sql)) return false;
410
+ const body = sql.replace(SAFE_DDL_RE, "");
411
+ if (/\bSELECT\b/i.test(body)) return false;
412
+ return true;
413
+ }
414
+
415
+ // src/replication/log/batch-applier.ts
416
+ var BatchApplier = class {
417
+ constructor(conn, localNodeId, hlc, pkResolver, getLastAppliedSeq, tracker) {
418
+ this.conn = conn;
419
+ this.localNodeId = localNodeId;
420
+ this.hlc = hlc;
421
+ this.pkResolver = pkResolver;
422
+ this.getLastAppliedSeq = getLastAppliedSeq;
423
+ this.tracker = tracker;
424
+ }
425
+ async applyBatch(batch, resolver) {
426
+ const expectedChecksum = computeChecksum(batch.changes);
427
+ if (batch.checksum !== expectedChecksum) {
428
+ throw new BatchValidationError(`Checksum mismatch: expected ${expectedChecksum}, got ${batch.checksum}`);
429
+ }
430
+ for (const change of batch.changes) {
431
+ if (change.operation !== "ddl") {
432
+ if (!IDENTIFIER_RE2.test(change.table)) {
433
+ throw new BatchValidationError(`Invalid table name: ${change.table}`);
434
+ }
435
+ }
436
+ }
437
+ const lastApplied = await this.getLastAppliedSeq(batch.sourceNodeId);
438
+ if (batch.toSeq <= lastApplied) {
439
+ return { applied: 0, skipped: batch.changes.length, conflicts: 0, droppedTables: [] };
440
+ }
441
+ const needsPartialDedup = batch.fromSeq <= lastApplied;
442
+ let appliedSeqSet = null;
443
+ if (needsPartialDedup) {
444
+ appliedSeqSet = /* @__PURE__ */ new Set();
445
+ const checkStmt = await this.conn.prepare(
446
+ "SELECT source_seq FROM _sirannon_applied_changes WHERE source_node_id = ? AND source_seq >= ? AND source_seq <= ?"
447
+ );
448
+ const applied2 = await checkStmt.all(
449
+ batch.sourceNodeId,
450
+ batch.fromSeq.toString(),
451
+ batch.toSeq.toString()
452
+ );
453
+ for (const row of applied2) {
454
+ appliedSeqSet.add(String(row.source_seq));
455
+ }
456
+ }
457
+ let applied = 0;
458
+ let skipped = 0;
459
+ let conflicts = 0;
460
+ const droppedTables = [];
461
+ const changesByTx = /* @__PURE__ */ new Map();
462
+ for (const change of batch.changes) {
463
+ const txGroup = changesByTx.get(change.txId);
464
+ if (txGroup) {
465
+ txGroup.push(change);
466
+ } else {
467
+ changesByTx.set(change.txId, [change]);
468
+ }
469
+ }
470
+ for (const [_txId, txChanges] of changesByTx) {
471
+ const txDroppedTables = [];
472
+ const result = await this.conn.transaction(async (tx) => {
473
+ let txApplied = 0;
474
+ let txSkipped = 0;
475
+ let txConflicts = 0;
476
+ const ddlChanges = txChanges.filter((c) => c.operation === "ddl");
477
+ const dataChanges = txChanges.filter((c) => c.operation !== "ddl");
478
+ for (const ddl of ddlChanges) {
479
+ const ddlSql = ddl.ddlStatement;
480
+ if (!ddlSql || !validateDdlSafety(ddlSql)) {
481
+ throw new BatchValidationError(`Unsafe or missing DDL statement: ${ddlSql ?? "none"}`);
482
+ }
483
+ await tx.exec(ddlSql);
484
+ const droppedTable = extractDroppedTable(ddlSql);
485
+ if (droppedTable !== null) {
486
+ txDroppedTables.push(droppedTable);
487
+ }
488
+ if (this.tracker) {
489
+ await this.tracker.refreshAllTriggersUsingConnection(tx);
490
+ }
491
+ txApplied += 1;
492
+ }
493
+ for (const change of dataChanges) {
494
+ const existingRow = await this.findExistingRow(tx, change);
495
+ if (existingRow === void 0) {
496
+ if (change.operation === "insert" && change.newData) {
497
+ await this.insertRow(tx, change);
498
+ await this.recordColumnVersions(tx, change, change.newData);
499
+ txApplied += 1;
500
+ } else if (change.operation === "delete") {
501
+ txApplied += 1;
502
+ } else {
503
+ txSkipped += 1;
504
+ }
505
+ } else {
506
+ txConflicts += 1;
507
+ const localHlc = await this.getLocalHlcForRow(tx, change.table, change.rowId);
508
+ const localChange = {
509
+ table: change.table,
510
+ operation: "update",
511
+ rowId: change.rowId,
512
+ primaryKey: change.primaryKey,
513
+ hlc: localHlc ?? "",
514
+ txId: "",
515
+ nodeId: this.localNodeId,
516
+ newData: existingRow,
517
+ oldData: null
518
+ };
519
+ const changeResolver = typeof resolver === "function" ? resolver(change.table) : resolver;
520
+ const resolution = await changeResolver.resolve({
521
+ table: change.table,
522
+ rowId: change.rowId,
523
+ localChange,
524
+ remoteChange: change,
525
+ localHlc,
526
+ remoteHlc: change.hlc
527
+ });
528
+ if (resolution.action === "accept_remote") {
529
+ await this.applyRemoteChange(tx, change);
530
+ await this.recordColumnVersions(tx, change, change.newData);
531
+ txApplied += 1;
532
+ } else if (resolution.action === "merge" && resolution.mergedData) {
533
+ await this.applyMergedData(tx, change, resolution.mergedData);
534
+ await this.recordColumnVersions(tx, change, resolution.mergedData);
535
+ txApplied += 1;
536
+ } else {
537
+ txSkipped += 1;
538
+ }
539
+ }
540
+ }
541
+ return { txApplied, txSkipped, txConflicts };
542
+ });
543
+ applied += result.txApplied;
544
+ skipped += result.txSkipped;
545
+ conflicts += result.txConflicts;
546
+ if (txDroppedTables.length > 0) {
547
+ droppedTables.push(...txDroppedTables);
548
+ }
549
+ }
550
+ const recordStmt = await this.conn.prepare(
551
+ "INSERT OR IGNORE INTO _sirannon_applied_changes (source_node_id, source_seq, applied_at) VALUES (?, ?, ?)"
552
+ );
553
+ const nowSec = Date.now() / 1e3;
554
+ for (let seq = batch.fromSeq; seq <= batch.toSeq; seq += 1n) {
555
+ if (appliedSeqSet?.has(seq.toString())) continue;
556
+ await recordStmt.run(batch.sourceNodeId, seq.toString(), nowSec);
557
+ }
558
+ try {
559
+ this.hlc.receive(batch.hlcRange.max);
560
+ } catch {
561
+ }
562
+ return { applied, skipped, conflicts, droppedTables };
563
+ }
564
+ async findExistingRow(tx, change) {
565
+ if (!IDENTIFIER_RE2.test(change.table)) return void 0;
566
+ const pkColumns = await this.pkResolver.forTable(change.table);
567
+ const result = await findRowByPk(tx, change.table, pkColumns, change.newData ?? change.oldData ?? {});
568
+ if (result) return result;
569
+ if (change.operation === "update" && change.oldData) {
570
+ return findRowByPk(tx, change.table, pkColumns, change.oldData);
571
+ }
572
+ return void 0;
573
+ }
574
+ async getLocalHlcForRow(tx, table, rowId) {
575
+ const stmt = await tx.prepare(
576
+ "SELECT MAX(hlc) as max_hlc FROM _sirannon_column_versions WHERE table_name = ? AND row_id = ?"
577
+ );
578
+ const row = await stmt.get(table, rowId);
579
+ return row?.max_hlc ?? null;
580
+ }
581
+ async insertRow(tx, change) {
582
+ if (!change.newData) return;
583
+ const columns = Object.keys(change.newData).filter(validateIdentifier);
584
+ if (columns.length === 0) return;
585
+ const placeholders = columns.map(() => "?").join(", ");
586
+ const colNames = columns.map((c) => `"${c}"`).join(", ");
587
+ const values = columns.map((c) => change.newData?.[c]);
588
+ const stmt = await tx.prepare(`INSERT INTO "${change.table}" (${colNames}) VALUES (${placeholders})`);
589
+ await stmt.run(...values);
590
+ }
591
+ async applyRemoteChange(tx, change) {
592
+ if (change.operation === "delete") {
593
+ await this.deleteRow(tx, change);
594
+ return;
595
+ }
596
+ if (!change.newData) return;
597
+ const pkColumns = await this.pkResolver.forTable(change.table);
598
+ const sourceData = change.newData;
599
+ const wherePkSource = change.oldData ?? sourceData;
600
+ const setClauses = [];
601
+ const setValues = [];
602
+ const whereConditions = [];
603
+ const whereValues = [];
604
+ const pkSet = new Set(pkColumns);
605
+ for (const [col, val] of Object.entries(sourceData)) {
606
+ if (!validateIdentifier(col)) continue;
607
+ if (pkSet.has(col)) continue;
608
+ setClauses.push(`"${col}" = ?`);
609
+ setValues.push(val);
610
+ }
611
+ for (const col of pkColumns) {
612
+ if (!validateIdentifier(col)) continue;
613
+ whereConditions.push(`"${col}" = ?`);
614
+ whereValues.push(wherePkSource[col]);
615
+ }
616
+ if (setClauses.length === 0 || whereConditions.length === 0) return;
617
+ const stmt = await tx.prepare(
618
+ `UPDATE "${change.table}" SET ${setClauses.join(", ")} WHERE ${whereConditions.join(" AND ")}`
619
+ );
620
+ await stmt.run(...setValues, ...whereValues);
621
+ }
622
+ async applyMergedData(tx, change, mergedData) {
623
+ const pkColumns = await this.pkResolver.forTable(change.table);
624
+ const sourceData = change.newData ?? change.oldData ?? {};
625
+ const setClauses = [];
626
+ const setValues = [];
627
+ const whereConditions = [];
628
+ const whereValues = [];
629
+ for (const [col, val] of Object.entries(mergedData)) {
630
+ if (!validateIdentifier(col)) continue;
631
+ if (!pkColumns.includes(col)) {
632
+ setClauses.push(`"${col}" = ?`);
633
+ setValues.push(val);
634
+ }
635
+ }
636
+ for (const col of pkColumns) {
637
+ if (!validateIdentifier(col)) continue;
638
+ whereConditions.push(`"${col}" = ?`);
639
+ whereValues.push(sourceData[col]);
640
+ }
641
+ if (setClauses.length === 0 || whereConditions.length === 0) return;
642
+ const stmt = await tx.prepare(
643
+ `UPDATE "${change.table}" SET ${setClauses.join(", ")} WHERE ${whereConditions.join(" AND ")}`
644
+ );
645
+ await stmt.run(...setValues, ...whereValues);
646
+ }
647
+ async deleteRow(tx, change) {
648
+ const pkColumns = await this.pkResolver.forTable(change.table);
649
+ const sourceData = change.oldData ?? change.newData ?? {};
650
+ const conditions = [];
651
+ const values = [];
652
+ for (const col of pkColumns) {
653
+ if (!validateIdentifier(col)) continue;
654
+ conditions.push(`"${col}" = ?`);
655
+ values.push(sourceData[col]);
656
+ }
657
+ if (conditions.length === 0) return;
658
+ const stmt = await tx.prepare(`DELETE FROM "${change.table}" WHERE ${conditions.join(" AND ")}`);
659
+ await stmt.run(...values);
660
+ }
661
+ async recordColumnVersions(tx, change, data) {
662
+ if (change.operation === "delete") {
663
+ const delStmt = await tx.prepare("DELETE FROM _sirannon_column_versions WHERE table_name = ? AND row_id = ?");
664
+ await delStmt.run(change.table, change.rowId);
665
+ return;
666
+ }
667
+ if (!data) return;
668
+ const upsertStmt = await tx.prepare(
669
+ `INSERT INTO _sirannon_column_versions (table_name, row_id, column_name, hlc, node_id)
670
+ VALUES (?, ?, ?, ?, ?)
671
+ ON CONFLICT(table_name, row_id, column_name)
672
+ DO UPDATE SET hlc = excluded.hlc, node_id = excluded.node_id`
673
+ );
674
+ for (const col of Object.keys(data)) {
675
+ if (!validateIdentifier(col)) continue;
676
+ await upsertStmt.run(change.table, change.rowId, col, change.hlc, change.nodeId);
677
+ }
678
+ }
679
+ };
680
+ async function findRowByPk(tx, table, pkColumns, sourceData) {
681
+ const conditions = [];
682
+ const values = [];
683
+ for (const col of pkColumns) {
684
+ if (!validateIdentifier(col)) return void 0;
685
+ if (!(col in sourceData)) return void 0;
686
+ conditions.push(`"${col}" = ?`);
687
+ values.push(sourceData[col]);
688
+ }
689
+ if (conditions.length === 0) return void 0;
690
+ const stmt = await tx.prepare(`SELECT * FROM "${table}" WHERE ${conditions.join(" AND ")} LIMIT 1`);
691
+ return stmt.get(...values);
692
+ }
693
+ var DumpOps = class {
694
+ constructor(conn, localNodeId, hlc, pkResolver) {
695
+ this.conn = conn;
696
+ this.localNodeId = localNodeId;
697
+ this.hlc = hlc;
698
+ this.pkResolver = pkResolver;
699
+ }
700
+ async *dumpTable(table, batchSize) {
701
+ if (!IDENTIFIER_RE2.test(table)) {
702
+ throw new ReplicationError(`Invalid table name: ${table}`);
703
+ }
704
+ const countStmt = await this.conn.prepare(`SELECT COUNT(*) as cnt FROM "${table}"`);
705
+ const countRow = await countStmt.get();
706
+ const total = countRow?.cnt ?? 0;
707
+ if (total === 0) return;
708
+ const pkColumns = await this.pkResolver.forTable(table);
709
+ let offset = 0;
710
+ let batchNum = 0;
711
+ const needsRowid = pkColumns.length === 1 && pkColumns[0] === "rowid";
712
+ while (offset < total) {
713
+ const selectSql = needsRowid ? `SELECT rowid, * FROM "${table}" LIMIT ? OFFSET ?` : `SELECT * FROM "${table}" LIMIT ? OFFSET ?`;
714
+ const selectStmt = await this.conn.prepare(selectSql);
715
+ const rows = await selectStmt.all(batchSize, offset);
716
+ if (rows.length === 0) break;
717
+ const changes = [];
718
+ const hlcValue = this.hlc.now();
719
+ for (const row of rows) {
720
+ const primaryKey = {};
721
+ for (const col of pkColumns) {
722
+ if (col in row) {
723
+ primaryKey[col] = row[col];
724
+ }
725
+ }
726
+ const rowId = pkColumns.map((col) => String(row[col] ?? "")).join("-");
727
+ changes.push({
728
+ table,
729
+ operation: "insert",
730
+ rowId,
731
+ primaryKey,
732
+ hlc: hlcValue,
733
+ txId: `dump-${table}-${batchNum}`,
734
+ nodeId: this.localNodeId,
735
+ newData: row,
736
+ oldData: null
737
+ });
738
+ }
739
+ const checksum = computeChecksum(changes);
740
+ const dumpEpoch = BigInt(Date.now()) * 1000000n;
741
+ const fromSeq = dumpEpoch + BigInt(offset);
742
+ const toSeq = dumpEpoch + BigInt(offset + rows.length - 1);
743
+ const dumpNodeId = `dump-${this.localNodeId}`;
744
+ yield {
745
+ sourceNodeId: dumpNodeId,
746
+ batchId: `dump-${table}-${batchNum}`,
747
+ fromSeq,
748
+ toSeq,
749
+ hlcRange: { min: hlcValue, max: hlcValue },
750
+ changes,
751
+ checksum
752
+ };
753
+ offset += rows.length;
754
+ batchNum += 1;
755
+ }
756
+ }
757
+ async *dumpTableOnConnection(conn, table, batchSize) {
758
+ if (!IDENTIFIER_RE2.test(table)) {
759
+ throw new ReplicationError(`Invalid table name: ${table}`);
760
+ }
761
+ const pkColumns = await this.pkResolver.forTableOnConnection(conn, table);
762
+ const needsRowid = pkColumns.length === 1 && pkColumns[0] === "rowid";
763
+ let lastPkValues = null;
764
+ let done = false;
765
+ while (!done) {
766
+ let selectSql;
767
+ const params = [];
768
+ if (lastPkValues === null) {
769
+ selectSql = needsRowid ? `SELECT rowid, * FROM "${table}" ORDER BY rowid LIMIT ?` : `SELECT * FROM "${table}" ORDER BY ${pkColumns.map((c) => `"${c}"`).join(", ")} LIMIT ?`;
770
+ params.push(batchSize);
771
+ } else if (needsRowid) {
772
+ selectSql = `SELECT rowid, * FROM "${table}" WHERE rowid > ? ORDER BY rowid LIMIT ?`;
773
+ params.push(lastPkValues[0], batchSize);
774
+ } else if (pkColumns.length === 1) {
775
+ selectSql = `SELECT * FROM "${table}" WHERE "${pkColumns[0]}" > ? ORDER BY "${pkColumns[0]}" LIMIT ?`;
776
+ params.push(lastPkValues[0], batchSize);
777
+ } else {
778
+ const colList = pkColumns.map((c) => `"${c}"`).join(", ");
779
+ const placeholders = pkColumns.map(() => "?").join(", ");
780
+ selectSql = `SELECT * FROM "${table}" WHERE (${colList}) > (${placeholders}) ORDER BY ${colList} LIMIT ?`;
781
+ params.push(...lastPkValues, batchSize);
782
+ }
783
+ const stmt = await conn.prepare(selectSql);
784
+ const rows = await stmt.all(...params);
785
+ if (rows.length === 0) {
786
+ break;
787
+ }
788
+ const lastRow = rows[rows.length - 1];
789
+ lastPkValues = pkColumns.map((col) => lastRow[col]);
790
+ let isLast = rows.length < batchSize;
791
+ if (!isLast) {
792
+ let peekSql;
793
+ const peekParams = [];
794
+ if (needsRowid) {
795
+ peekSql = `SELECT 1 FROM "${table}" WHERE rowid > ? LIMIT 1`;
796
+ peekParams.push(lastPkValues[0]);
797
+ } else if (pkColumns.length === 1) {
798
+ peekSql = `SELECT 1 FROM "${table}" WHERE "${pkColumns[0]}" > ? LIMIT 1`;
799
+ peekParams.push(lastPkValues[0]);
800
+ } else {
801
+ const colList = pkColumns.map((c) => `"${c}"`).join(", ");
802
+ const placeholders = pkColumns.map(() => "?").join(", ");
803
+ peekSql = `SELECT 1 FROM "${table}" WHERE (${colList}) > (${placeholders}) LIMIT 1`;
804
+ peekParams.push(...lastPkValues);
805
+ }
806
+ const peekStmt = await conn.prepare(peekSql);
807
+ const peekRow = await peekStmt.get(...peekParams);
808
+ if (!peekRow) {
809
+ isLast = true;
810
+ }
811
+ }
812
+ done = isLast;
813
+ const checksum = createHash("sha256").update(canonicaliseForChecksum(rows)).digest("hex");
814
+ yield { rows, checksum, isLast };
815
+ }
816
+ }
817
+ async generateManifest(conn, table) {
818
+ if (!IDENTIFIER_RE2.test(table)) {
819
+ throw new ReplicationError(`Invalid table name: ${table}`);
820
+ }
821
+ const countStmt = await conn.prepare(`SELECT COUNT(*) as cnt FROM "${table}"`);
822
+ const countRow = await countStmt.get();
823
+ const rowCount = countRow?.cnt ?? 0;
824
+ const hash = createHash("sha256");
825
+ const pkColumns = await this.pkResolver.forTableOnConnection(conn, table);
826
+ const needsRowid = pkColumns.length === 1 && pkColumns[0] === "rowid";
827
+ let lastPkValues = null;
828
+ const PK_BATCH_SIZE = 1e4;
829
+ let hasRows = true;
830
+ while (hasRows) {
831
+ let selectSql;
832
+ const params = [];
833
+ if (needsRowid) {
834
+ if (lastPkValues === null) {
835
+ selectSql = `SELECT rowid FROM "${table}" ORDER BY rowid LIMIT ?`;
836
+ } else {
837
+ selectSql = `SELECT rowid FROM "${table}" WHERE rowid > ? ORDER BY rowid LIMIT ?`;
838
+ params.push(lastPkValues[0]);
839
+ }
840
+ } else if (pkColumns.length === 1) {
841
+ const col = pkColumns[0];
842
+ if (lastPkValues === null) {
843
+ selectSql = `SELECT "${col}" FROM "${table}" ORDER BY "${col}" LIMIT ?`;
844
+ } else {
845
+ selectSql = `SELECT "${col}" FROM "${table}" WHERE "${col}" > ? ORDER BY "${col}" LIMIT ?`;
846
+ params.push(lastPkValues[0]);
847
+ }
848
+ } else {
849
+ const colList = pkColumns.map((c) => `"${c}"`).join(", ");
850
+ if (lastPkValues === null) {
851
+ selectSql = `SELECT ${colList} FROM "${table}" ORDER BY ${colList} LIMIT ?`;
852
+ } else {
853
+ const placeholders = pkColumns.map(() => "?").join(", ");
854
+ selectSql = `SELECT ${colList} FROM "${table}" WHERE (${colList}) > (${placeholders}) ORDER BY ${colList} LIMIT ?`;
855
+ params.push(...lastPkValues);
856
+ }
857
+ }
858
+ params.push(PK_BATCH_SIZE);
859
+ const stmt = await conn.prepare(selectSql);
860
+ const rows = await stmt.all(...params);
861
+ if (rows.length === 0) {
862
+ hasRows = false;
863
+ break;
864
+ }
865
+ for (const row of rows) {
866
+ const pkVal = pkColumns.map((col) => String(row[col] ?? "")).join("-");
867
+ hash.update(pkVal);
868
+ }
869
+ if (rows.length < PK_BATCH_SIZE) {
870
+ hasRows = false;
871
+ } else {
872
+ const lastRow = rows[rows.length - 1];
873
+ lastPkValues = pkColumns.map((col) => lastRow[col]);
874
+ }
875
+ }
876
+ return { table, rowCount, pkHash: hash.digest("hex") };
877
+ }
878
+ async verifyManifest(manifest) {
879
+ const local = await this.generateManifest(this.conn, manifest.table);
880
+ return local.rowCount === manifest.rowCount && local.pkHash === manifest.pkHash;
881
+ }
882
+ };
883
+
884
+ // src/replication/log/pk.ts
885
+ var PkResolver = class {
886
+ constructor(conn) {
887
+ this.conn = conn;
888
+ }
889
+ cache = /* @__PURE__ */ new Map();
890
+ async forTable(table) {
891
+ const cached = this.cache.get(table);
892
+ if (cached) return cached;
893
+ const stmt = await this.conn.prepare(`PRAGMA table_info("${table}")`);
894
+ const info = await stmt.all();
895
+ const pkCols = info.filter((col) => col.pk > 0).sort((a, b) => a.pk - b.pk).map((col) => col.name);
896
+ if (pkCols.length === 0) {
897
+ pkCols.push("rowid");
898
+ }
899
+ this.cache.set(table, pkCols);
900
+ return pkCols;
901
+ }
902
+ async forTableOnConnection(conn, table) {
903
+ const stmt = await conn.prepare(`PRAGMA table_info("${table}")`);
904
+ const info = await stmt.all();
905
+ const pkCols = info.filter((col) => col.pk > 0).sort((a, b) => a.pk - b.pk).map((col) => col.name);
906
+ if (pkCols.length === 0) {
907
+ pkCols.push("rowid");
908
+ }
909
+ return pkCols;
910
+ }
911
+ };
912
+
913
+ // src/replication/log/schema.ts
914
+ var SchemaOps = class {
915
+ constructor(conn, changesTable) {
916
+ this.conn = conn;
917
+ this.changesTable = changesTable;
918
+ }
919
+ async ensureReplicationTables() {
920
+ await this.conn.exec(`
921
+ CREATE TABLE IF NOT EXISTS "${this.changesTable}" (
922
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
923
+ table_name TEXT NOT NULL,
924
+ operation TEXT NOT NULL,
925
+ row_id TEXT NOT NULL,
926
+ changed_at REAL NOT NULL DEFAULT (unixepoch('subsec')),
927
+ old_data TEXT,
928
+ new_data TEXT,
929
+ node_id TEXT NOT NULL DEFAULT '',
930
+ tx_id TEXT NOT NULL DEFAULT '',
931
+ hlc TEXT NOT NULL DEFAULT ''
932
+ )`);
933
+ await this.conn.exec(`
934
+ CREATE TABLE IF NOT EXISTS _sirannon_peer_state (
935
+ peer_node_id TEXT PRIMARY KEY,
936
+ last_acked_seq INTEGER NOT NULL DEFAULT 0,
937
+ last_received_hlc TEXT NOT NULL DEFAULT '',
938
+ updated_at REAL NOT NULL
939
+ )`);
940
+ await this.conn.exec(`
941
+ CREATE TABLE IF NOT EXISTS _sirannon_applied_changes (
942
+ source_node_id TEXT NOT NULL,
943
+ source_seq INTEGER NOT NULL,
944
+ applied_at REAL NOT NULL,
945
+ PRIMARY KEY (source_node_id, source_seq)
946
+ )`);
947
+ await this.conn.exec(`
948
+ CREATE TABLE IF NOT EXISTS _sirannon_column_versions (
949
+ table_name TEXT NOT NULL,
950
+ row_id TEXT NOT NULL,
951
+ column_name TEXT NOT NULL,
952
+ hlc TEXT NOT NULL,
953
+ node_id TEXT NOT NULL,
954
+ PRIMARY KEY (table_name, row_id, column_name)
955
+ )`);
956
+ await this.conn.exec(`
957
+ CREATE TABLE IF NOT EXISTS _sirannon_sync_state (
958
+ table_name TEXT PRIMARY KEY,
959
+ status TEXT NOT NULL DEFAULT 'pending',
960
+ row_count INTEGER NOT NULL DEFAULT 0,
961
+ pk_hash TEXT NOT NULL DEFAULT '',
962
+ snapshot_seq INTEGER,
963
+ source_peer_id TEXT,
964
+ started_at REAL,
965
+ completed_at REAL,
966
+ request_id TEXT
967
+ )`);
968
+ }
969
+ async dumpSchema(conn, excludePrefix = "_sirannon_") {
970
+ const stmt = await conn.prepare(
971
+ `SELECT type, name, sql FROM sqlite_master
972
+ WHERE type IN ('table', 'index') AND name NOT LIKE ? AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL`
973
+ );
974
+ const rows = await stmt.all(`${excludePrefix}%`);
975
+ const filtered = rows.filter((row) => {
976
+ if (row.name.startsWith(excludePrefix)) return false;
977
+ if (row.name.startsWith("sqlite_")) return false;
978
+ return validateDdlSafety(row.sql);
979
+ });
980
+ const tables = [];
981
+ const indexes = [];
982
+ for (const row of filtered) {
983
+ if (row.type === "table") {
984
+ tables.push({ name: row.name, sql: row.sql });
985
+ } else {
986
+ indexes.push({ sql: row.sql });
987
+ }
988
+ }
989
+ const tableOrder = await this.getTablesInFkOrder(conn);
990
+ const orderMap = /* @__PURE__ */ new Map();
991
+ for (let i = 0; i < tableOrder.length; i++) {
992
+ orderMap.set(tableOrder[i], i);
993
+ }
994
+ tables.sort((a, b) => (orderMap.get(a.name) ?? 999) - (orderMap.get(b.name) ?? 999));
995
+ const result = [];
996
+ for (const t of tables) {
997
+ result.push(t.sql);
998
+ }
999
+ for (const idx of indexes) {
1000
+ result.push(idx.sql);
1001
+ }
1002
+ return result;
1003
+ }
1004
+ async getTablesInFkOrder(conn) {
1005
+ const stmt = await conn.prepare(
1006
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_sirannon_%' AND name NOT LIKE 'sqlite_%'`
1007
+ );
1008
+ const tableRows = await stmt.all();
1009
+ const tableNames = tableRows.map((r) => r.name);
1010
+ const adjacency = /* @__PURE__ */ new Map();
1011
+ const inDegree = /* @__PURE__ */ new Map();
1012
+ for (const name of tableNames) {
1013
+ adjacency.set(name, /* @__PURE__ */ new Set());
1014
+ inDegree.set(name, 0);
1015
+ }
1016
+ for (const name of tableNames) {
1017
+ const fkStmt = await conn.prepare(`PRAGMA foreign_key_list("${name}")`);
1018
+ const fks = await fkStmt.all();
1019
+ for (const fk of fks) {
1020
+ if (tableNames.includes(fk.table) && fk.table !== name) {
1021
+ const deps = adjacency.get(fk.table);
1022
+ if (deps && !deps.has(name)) {
1023
+ deps.add(name);
1024
+ inDegree.set(name, (inDegree.get(name) ?? 0) + 1);
1025
+ }
1026
+ }
1027
+ }
1028
+ }
1029
+ const queue = [];
1030
+ for (const name of tableNames) {
1031
+ if ((inDegree.get(name) ?? 0) === 0) {
1032
+ queue.push(name);
1033
+ }
1034
+ }
1035
+ const sorted = [];
1036
+ while (queue.length > 0) {
1037
+ const current = queue.shift();
1038
+ if (current === void 0) break;
1039
+ sorted.push(current);
1040
+ const deps = adjacency.get(current);
1041
+ if (deps) {
1042
+ for (const dep of deps) {
1043
+ const newDeg = (inDegree.get(dep) ?? 1) - 1;
1044
+ inDegree.set(dep, newDeg);
1045
+ if (newDeg === 0) {
1046
+ queue.push(dep);
1047
+ }
1048
+ }
1049
+ }
1050
+ }
1051
+ if (sorted.length < tableNames.length) {
1052
+ const remaining = tableNames.filter((n) => !sorted.includes(n)).sort();
1053
+ sorted.push(...remaining);
1054
+ }
1055
+ return sorted;
1056
+ }
1057
+ async wipeTables(conn, tables, tracker) {
1058
+ for (const table of tables) {
1059
+ if (!IDENTIFIER_RE2.test(table)) {
1060
+ throw new ReplicationError(`Invalid table name: ${table}`);
1061
+ }
1062
+ }
1063
+ await conn.exec("PRAGMA foreign_keys = OFF");
1064
+ try {
1065
+ await conn.transaction(async (tx) => {
1066
+ for (const table of tables) {
1067
+ await tracker.unwatch(tx, table);
1068
+ }
1069
+ const reversed = [...tables].reverse();
1070
+ for (const table of reversed) {
1071
+ await tx.exec(`DELETE FROM "${table}"`);
1072
+ }
1073
+ await tx.exec(`DELETE FROM _sirannon_sync_state WHERE table_name != '__sync_meta__'`);
1074
+ });
1075
+ } finally {
1076
+ await conn.exec("PRAGMA foreign_keys = ON");
1077
+ }
1078
+ }
1079
+ };
1080
+
1081
+ // src/replication/log/state.ts
1082
+ var StateOps = class {
1083
+ constructor(conn) {
1084
+ this.conn = conn;
1085
+ }
1086
+ activeSyncSnapshotSeqs = /* @__PURE__ */ new Set();
1087
+ /**
1088
+ * Returns the highest valid HLC observed in this database, or `null` when
1089
+ * no replication evidence exists yet.
1090
+ *
1091
+ * Reads two durable sources and takes the lexicographic maximum:
1092
+ * - `MAX(hlc) FROM <changes table>` covers HLCs stamped onto local writes.
1093
+ * - `MAX(hlc) FROM _sirannon_column_versions` covers HLCs applied from
1094
+ * remote batches and persisted alongside per-column versions.
1095
+ *
1096
+ * Combining both is necessary: a primary that has never received remote
1097
+ * batches has no rows in `_sirannon_column_versions` for inbound HLCs, and
1098
+ * a replica that has never written locally has no rows in the changes
1099
+ * table. Either source can independently hold the highest value depending
1100
+ * on traffic patterns.
1101
+ *
1102
+ * The changes table is subject to retention pruning by `ChangeTracker`,
1103
+ * which means its `MAX(hlc)` can lag behind the highest HLC ever emitted.
1104
+ * Cross-checking against `_sirannon_column_versions` (which is upserted
1105
+ * per `(table, row, column)` and not subject to time-based retention)
1106
+ * keeps the recovered value above any HLC currently still observable by
1107
+ * any peer's conflict-resolution decisions.
1108
+ *
1109
+ * Filters out the empty-string sentinel that the CDC triggers write before
1110
+ * the local-stamp pass fills in a real HLC, and rejects any decoded
1111
+ * timestamp whose components are not finite, non-negative integers. The
1112
+ * SQL filter narrows the result set; the JavaScript-level guard catches a
1113
+ * future producer that writes a malformed HLC.
1114
+ */
1115
+ async recoverMaxObservedHlc(changesTable) {
1116
+ let best = null;
1117
+ const changesStmt = await this.conn.prepare(`SELECT MAX(hlc) AS max_hlc FROM "${changesTable}" WHERE hlc != ''`);
1118
+ const changesRow = await changesStmt.get();
1119
+ best = mergeCandidate(best, changesRow?.max_hlc ?? null);
1120
+ const versionsStmt = await this.conn.prepare(
1121
+ `SELECT MAX(hlc) AS max_hlc FROM _sirannon_column_versions WHERE hlc != ''`
1122
+ );
1123
+ const versionsRow = await versionsStmt.get();
1124
+ best = mergeCandidate(best, versionsRow?.max_hlc ?? null);
1125
+ return best;
1126
+ }
1127
+ async getLastAppliedSeq(fromNodeId) {
1128
+ const stmt = await this.conn.prepare(
1129
+ "SELECT MAX(source_seq) as max_seq FROM _sirannon_applied_changes WHERE source_node_id = ?"
1130
+ );
1131
+ const row = await stmt.get(fromNodeId);
1132
+ if (!row || row.max_seq === null) {
1133
+ return 0n;
1134
+ }
1135
+ return BigInt(row.max_seq);
1136
+ }
1137
+ async setLastAppliedSeq(fromNodeId, seq) {
1138
+ const stmt = await this.conn.prepare(
1139
+ `INSERT INTO _sirannon_peer_state (peer_node_id, last_acked_seq, updated_at)
1140
+ VALUES (?, ?, ?)
1141
+ ON CONFLICT(peer_node_id)
1142
+ DO UPDATE SET
1143
+ last_acked_seq = max(_sirannon_peer_state.last_acked_seq, excluded.last_acked_seq),
1144
+ updated_at = CASE
1145
+ WHEN excluded.last_acked_seq >= _sirannon_peer_state.last_acked_seq THEN excluded.updated_at
1146
+ ELSE _sirannon_peer_state.updated_at
1147
+ END`
1148
+ );
1149
+ await stmt.run(fromNodeId, seq.toString(), Date.now() / 1e3);
1150
+ }
1151
+ async getPeerAckedSeq(peerNodeId) {
1152
+ const stmt = await this.conn.prepare("SELECT last_acked_seq FROM _sirannon_peer_state WHERE peer_node_id = ?");
1153
+ const row = await stmt.get(peerNodeId);
1154
+ if (!row || row.last_acked_seq === null) {
1155
+ return 0n;
1156
+ }
1157
+ return BigInt(row.last_acked_seq);
1158
+ }
1159
+ async getLocalSeq(changesTable) {
1160
+ const stmt = await this.conn.prepare(`SELECT MAX(seq) as max_seq FROM "${changesTable}"`);
1161
+ const row = await stmt.get();
1162
+ if (!row || row.max_seq === null) {
1163
+ return 0n;
1164
+ }
1165
+ return BigInt(row.max_seq);
1166
+ }
1167
+ async getMinAckedSeq() {
1168
+ const stmt = await this.conn.prepare(
1169
+ "SELECT MIN(last_acked_seq) as min_seq, COUNT(*) as cnt FROM _sirannon_peer_state"
1170
+ );
1171
+ const row = await stmt.get();
1172
+ const hasPeers = row !== void 0 && row.cnt > 0;
1173
+ let result = null;
1174
+ if (hasPeers && row.min_seq !== null) {
1175
+ result = BigInt(row.min_seq);
1176
+ } else if (hasPeers) {
1177
+ result = 0n;
1178
+ }
1179
+ for (const syncSeq of this.activeSyncSnapshotSeqs) {
1180
+ if (result === null || syncSeq < result) {
1181
+ result = syncSeq;
1182
+ }
1183
+ }
1184
+ return result;
1185
+ }
1186
+ registerActiveSyncSeq(seq) {
1187
+ this.activeSyncSnapshotSeqs.add(seq);
1188
+ }
1189
+ unregisterActiveSyncSeq(seq) {
1190
+ this.activeSyncSnapshotSeqs.delete(seq);
1191
+ }
1192
+ async setSyncTableStatus(table, status, rowCount, pkHash) {
1193
+ const stmt = await this.conn.prepare(
1194
+ `INSERT INTO _sirannon_sync_state (table_name, status, row_count, pk_hash, completed_at)
1195
+ VALUES (?, ?, ?, ?, ?)
1196
+ ON CONFLICT(table_name) DO UPDATE SET
1197
+ status = excluded.status,
1198
+ row_count = COALESCE(excluded.row_count, row_count),
1199
+ pk_hash = COALESCE(excluded.pk_hash, pk_hash),
1200
+ completed_at = excluded.completed_at`
1201
+ );
1202
+ await stmt.run(table, status, rowCount ?? 0, pkHash ?? "", status === "completed" ? Date.now() / 1e3 : null);
1203
+ }
1204
+ async setSyncMeta(phase, snapshotSeq, sourcePeerId, requestId) {
1205
+ const stmt = await this.conn.prepare(
1206
+ `INSERT INTO _sirannon_sync_state (table_name, status, snapshot_seq, source_peer_id, started_at, request_id)
1207
+ VALUES ('__sync_meta__', ?, ?, ?, ?, ?)
1208
+ ON CONFLICT(table_name) DO UPDATE SET
1209
+ status = excluded.status,
1210
+ snapshot_seq = COALESCE(excluded.snapshot_seq, snapshot_seq),
1211
+ source_peer_id = COALESCE(excluded.source_peer_id, source_peer_id),
1212
+ started_at = COALESCE(excluded.started_at, started_at),
1213
+ request_id = COALESCE(excluded.request_id, request_id)`
1214
+ );
1215
+ await stmt.run(
1216
+ phase,
1217
+ snapshotSeq !== void 0 ? snapshotSeq.toString() : null,
1218
+ sourcePeerId ?? null,
1219
+ phase === "syncing" ? Date.now() / 1e3 : null,
1220
+ requestId ?? null
1221
+ );
1222
+ }
1223
+ async getSyncState() {
1224
+ const metaStmt = await this.conn.prepare(
1225
+ `SELECT status, snapshot_seq, source_peer_id FROM _sirannon_sync_state WHERE table_name = '__sync_meta__'`
1226
+ );
1227
+ const meta = await metaStmt.get();
1228
+ if (!meta) {
1229
+ return { phase: "ready", completedTables: [], snapshotSeq: null, sourcePeerId: null };
1230
+ }
1231
+ const completedStmt = await this.conn.prepare(
1232
+ `SELECT table_name FROM _sirannon_sync_state WHERE table_name != '__sync_meta__' AND status = 'completed'`
1233
+ );
1234
+ const completedRows = await completedStmt.all();
1235
+ return {
1236
+ phase: meta.status,
1237
+ completedTables: completedRows.map((r) => r.table_name),
1238
+ snapshotSeq: meta.snapshot_seq !== null ? BigInt(meta.snapshot_seq) : null,
1239
+ sourcePeerId: meta.source_peer_id
1240
+ };
1241
+ }
1242
+ async isSyncCompleted() {
1243
+ const stmt = await this.conn.prepare(`SELECT status FROM _sirannon_sync_state WHERE table_name = '__sync_meta__'`);
1244
+ const row = await stmt.get();
1245
+ return row?.status === "ready";
1246
+ }
1247
+ };
1248
+ function isWellFormedHlc(candidate) {
1249
+ if (candidate.length === 0) return false;
1250
+ let decoded;
1251
+ try {
1252
+ decoded = HLC.decode(candidate);
1253
+ } catch {
1254
+ return false;
1255
+ }
1256
+ if (!Number.isInteger(decoded.wallMs) || decoded.wallMs < 0) return false;
1257
+ if (!Number.isInteger(decoded.logical) || decoded.logical < 0) return false;
1258
+ if (decoded.nodeId.length === 0) return false;
1259
+ return true;
1260
+ }
1261
+ function mergeCandidate(current, candidate) {
1262
+ if (candidate === null || candidate.length === 0) return current;
1263
+ if (!isWellFormedHlc(candidate)) return current;
1264
+ if (current === null) return candidate;
1265
+ return HLC.compare(candidate, current) > 0 ? candidate : current;
1266
+ }
1267
+
1268
+ // src/replication/log/log.ts
1269
+ var ReplicationLog = class {
1270
+ constructor(conn, localNodeId, hlc, changesTable = "_sirannon_changes", tracker) {
1271
+ this.changesTable = changesTable;
1272
+ this.pkResolver = new PkResolver(conn);
1273
+ this.state = new StateOps(conn);
1274
+ this.schema = new SchemaOps(conn, changesTable);
1275
+ this.batchReader = new BatchReader(conn, localNodeId, hlc, changesTable, this.pkResolver);
1276
+ this.batchApplier = new BatchApplier(
1277
+ conn,
1278
+ localNodeId,
1279
+ hlc,
1280
+ this.pkResolver,
1281
+ (fromNodeId) => this.state.getLastAppliedSeq(fromNodeId),
1282
+ tracker
1283
+ );
1284
+ this.dump = new DumpOps(conn, localNodeId, hlc, this.pkResolver);
1285
+ }
1286
+ pkResolver;
1287
+ state;
1288
+ schema;
1289
+ batchReader;
1290
+ batchApplier;
1291
+ dump;
1292
+ ensureReplicationTables() {
1293
+ return this.schema.ensureReplicationTables();
1294
+ }
1295
+ readBatch(afterSeq, batchSize) {
1296
+ return this.batchReader.readBatch(afterSeq, batchSize);
1297
+ }
1298
+ stampChanges(tx, afterSeq, txId) {
1299
+ return this.batchReader.stampChanges(tx, afterSeq, txId);
1300
+ }
1301
+ updateColumnVersions(tx, afterSeq) {
1302
+ return this.batchReader.updateColumnVersions(tx, afterSeq);
1303
+ }
1304
+ applyBatch(batch, resolver) {
1305
+ return this.batchApplier.applyBatch(batch, resolver);
1306
+ }
1307
+ getLastAppliedSeq(fromNodeId) {
1308
+ return this.state.getLastAppliedSeq(fromNodeId);
1309
+ }
1310
+ setLastAppliedSeq(fromNodeId, seq) {
1311
+ return this.state.setLastAppliedSeq(fromNodeId, seq);
1312
+ }
1313
+ getPeerAckedSeq(peerNodeId) {
1314
+ return this.state.getPeerAckedSeq(peerNodeId);
1315
+ }
1316
+ getLocalSeq() {
1317
+ return this.state.getLocalSeq(this.changesTable);
1318
+ }
1319
+ recoverMaxObservedHlc() {
1320
+ return this.state.recoverMaxObservedHlc(this.changesTable);
1321
+ }
1322
+ getMinAckedSeq() {
1323
+ return this.state.getMinAckedSeq();
1324
+ }
1325
+ registerActiveSyncSeq(seq) {
1326
+ this.state.registerActiveSyncSeq(seq);
1327
+ }
1328
+ unregisterActiveSyncSeq(seq) {
1329
+ this.state.unregisterActiveSyncSeq(seq);
1330
+ }
1331
+ dumpTable(table, batchSize) {
1332
+ return this.dump.dumpTable(table, batchSize);
1333
+ }
1334
+ dumpTableOnConnection(conn, table, batchSize) {
1335
+ return this.dump.dumpTableOnConnection(conn, table, batchSize);
1336
+ }
1337
+ dumpSchema(conn, excludePrefix) {
1338
+ return this.schema.dumpSchema(conn, excludePrefix);
1339
+ }
1340
+ getTablesInFkOrder(conn) {
1341
+ return this.schema.getTablesInFkOrder(conn);
1342
+ }
1343
+ wipeTables(conn, tables, tracker) {
1344
+ return this.schema.wipeTables(conn, tables, tracker);
1345
+ }
1346
+ setSyncTableStatus(table, status, rowCount, pkHash) {
1347
+ return this.state.setSyncTableStatus(table, status, rowCount, pkHash);
1348
+ }
1349
+ setSyncMeta(phase, snapshotSeq, sourcePeerId, requestId) {
1350
+ return this.state.setSyncMeta(phase, snapshotSeq, sourcePeerId, requestId);
1351
+ }
1352
+ getSyncState() {
1353
+ return this.state.getSyncState();
1354
+ }
1355
+ isSyncCompleted() {
1356
+ return this.state.isSyncCompleted();
1357
+ }
1358
+ generateManifest(conn, table) {
1359
+ return this.dump.generateManifest(conn, table);
1360
+ }
1361
+ verifyManifest(manifest) {
1362
+ return this.dump.verifyManifest(manifest);
1363
+ }
1364
+ };
1365
+ var NODE_ID_RE = /^[0-9a-f]{32}$/;
1366
+ function generateNodeId() {
1367
+ return randomBytes(16).toString("hex");
1368
+ }
1369
+ function validateNodeId(id) {
1370
+ if (!NODE_ID_RE.test(id)) {
1371
+ throw new ReplicationError(`Invalid node ID '${id}': must be 32 lowercase hex characters`);
1372
+ }
1373
+ }
1374
+
1375
+ // src/replication/peer-tracker.ts
1376
+ var PeerTracker = class {
1377
+ peers = /* @__PURE__ */ new Map();
1378
+ waiters = /* @__PURE__ */ new Set();
1379
+ addPeer(nodeId) {
1380
+ if (this.peers.has(nodeId)) {
1381
+ const existing = this.peers.get(nodeId);
1382
+ if (existing) {
1383
+ existing.connected = true;
1384
+ }
1385
+ return;
1386
+ }
1387
+ this.peers.set(nodeId, {
1388
+ nodeId,
1389
+ lastAckedSeq: 0n,
1390
+ lastSentSeq: 0n,
1391
+ lastReceivedHlc: "",
1392
+ connected: true,
1393
+ pendingBatches: 0,
1394
+ inFlightBatches: []
1395
+ });
1396
+ }
1397
+ removePeer(nodeId) {
1398
+ const peer = this.peers.get(nodeId);
1399
+ if (peer) {
1400
+ peer.connected = false;
1401
+ }
1402
+ this.checkWaiters();
1403
+ }
1404
+ onAckReceived(nodeId, ackedSeq) {
1405
+ const peer = this.peers.get(nodeId);
1406
+ if (peer && ackedSeq > peer.lastAckedSeq) {
1407
+ peer.lastAckedSeq = ackedSeq;
1408
+ const ackedCount = peer.inFlightBatches.filter((b) => b.toSeq <= ackedSeq).length;
1409
+ peer.inFlightBatches = peer.inFlightBatches.filter((b) => b.toSeq > ackedSeq);
1410
+ peer.pendingBatches = Math.max(0, peer.pendingBatches - Math.max(1, ackedCount));
1411
+ }
1412
+ this.checkWaiters();
1413
+ }
1414
+ recordInFlightBatch(nodeId, batch) {
1415
+ const peer = this.peers.get(nodeId);
1416
+ if (peer) {
1417
+ peer.inFlightBatches.push(batch);
1418
+ }
1419
+ }
1420
+ expireTimedOutBatches(nodeId, nowMs, timeoutMs) {
1421
+ const peer = this.peers.get(nodeId);
1422
+ if (!peer || peer.inFlightBatches.length === 0) return false;
1423
+ const timedOut = peer.inFlightBatches.filter((b) => nowMs - b.sentAt >= timeoutMs);
1424
+ if (timedOut.length === 0) return false;
1425
+ let earliestFromSeq = timedOut[0].fromSeq;
1426
+ for (let i = 1; i < timedOut.length; i++) {
1427
+ if (timedOut[i].fromSeq < earliestFromSeq) {
1428
+ earliestFromSeq = timedOut[i].fromSeq;
1429
+ }
1430
+ }
1431
+ peer.inFlightBatches = peer.inFlightBatches.filter((b) => nowMs - b.sentAt < timeoutMs);
1432
+ peer.pendingBatches = Math.max(0, peer.pendingBatches - timedOut.length);
1433
+ const resetTarget = earliestFromSeq > 0n ? earliestFromSeq - 1n : 0n;
1434
+ if (peer.lastSentSeq > resetTarget) {
1435
+ peer.lastSentSeq = resetTarget;
1436
+ }
1437
+ return true;
1438
+ }
1439
+ getPeerState(nodeId) {
1440
+ return this.peers.get(nodeId);
1441
+ }
1442
+ connectedPeerCount() {
1443
+ let count = 0;
1444
+ for (const peer of this.peers.values()) {
1445
+ if (peer.connected) {
1446
+ count += 1;
1447
+ }
1448
+ }
1449
+ return count;
1450
+ }
1451
+ waitForMajority(seq, timeoutMs) {
1452
+ const connected = this.connectedPeerCount();
1453
+ const needed = Math.floor(connected / 2) + 1;
1454
+ if (this.countAcked(seq) >= needed) {
1455
+ return Promise.resolve();
1456
+ }
1457
+ return new Promise((resolve, reject) => {
1458
+ const timer = setTimeout(() => {
1459
+ this.waiters.delete(waiter);
1460
+ reject(new WriteConcernError(`Timed out waiting for majority ACK of seq ${seq}`));
1461
+ }, timeoutMs);
1462
+ timer.unref?.();
1463
+ const waiter = { seq, kind: "majority", resolve, reject, timer };
1464
+ this.waiters.add(waiter);
1465
+ });
1466
+ }
1467
+ waitForAll(seq, timeoutMs) {
1468
+ const connected = this.connectedPeerCount();
1469
+ if (connected === 0 || this.countAcked(seq) >= connected) {
1470
+ return Promise.resolve();
1471
+ }
1472
+ return new Promise((resolve, reject) => {
1473
+ const timer = setTimeout(() => {
1474
+ this.waiters.delete(waiter);
1475
+ reject(new WriteConcernError(`Timed out waiting for all peers to ACK seq ${seq}`));
1476
+ }, timeoutMs);
1477
+ timer.unref?.();
1478
+ const waiter = { seq, kind: "all", resolve, reject, timer };
1479
+ this.waiters.add(waiter);
1480
+ });
1481
+ }
1482
+ waitForConfiguredMajority(seq, localNodeId, votingNodeIds, timeoutMs) {
1483
+ const needed = Math.floor(votingNodeIds.length / 2) + 1;
1484
+ if (this.countAckedConfigured(seq, localNodeId, votingNodeIds) >= needed) {
1485
+ return Promise.resolve();
1486
+ }
1487
+ return new Promise((resolve, reject) => {
1488
+ const timer = setTimeout(() => {
1489
+ this.waiters.delete(waiter);
1490
+ reject(new WriteConcernError(`Timed out waiting for configured majority ACK of seq ${seq}`));
1491
+ }, timeoutMs);
1492
+ timer.unref?.();
1493
+ const waiter = {
1494
+ seq,
1495
+ kind: "configured-majority",
1496
+ localNodeId,
1497
+ votingNodeIds: [...votingNodeIds],
1498
+ resolve,
1499
+ reject,
1500
+ timer
1501
+ };
1502
+ this.waiters.add(waiter);
1503
+ });
1504
+ }
1505
+ waitForConfiguredAll(seq, localNodeId, votingNodeIds, timeoutMs) {
1506
+ if (this.countAckedConfigured(seq, localNodeId, votingNodeIds) >= votingNodeIds.length) {
1507
+ return Promise.resolve();
1508
+ }
1509
+ return new Promise((resolve, reject) => {
1510
+ const timer = setTimeout(() => {
1511
+ this.waiters.delete(waiter);
1512
+ reject(new WriteConcernError(`Timed out waiting for all configured voters to ACK seq ${seq}`));
1513
+ }, timeoutMs);
1514
+ timer.unref?.();
1515
+ const waiter = {
1516
+ seq,
1517
+ kind: "configured-all",
1518
+ localNodeId,
1519
+ votingNodeIds: [...votingNodeIds],
1520
+ resolve,
1521
+ reject,
1522
+ timer
1523
+ };
1524
+ this.waiters.add(waiter);
1525
+ });
1526
+ }
1527
+ ackedConfiguredNodeIds(seq, localNodeId, votingNodeIds) {
1528
+ const acked = /* @__PURE__ */ new Set();
1529
+ if (votingNodeIds.includes(localNodeId)) {
1530
+ acked.add(localNodeId);
1531
+ }
1532
+ for (const nodeId of votingNodeIds) {
1533
+ const peer = this.peers.get(nodeId);
1534
+ if (peer && peer.lastAckedSeq >= seq) {
1535
+ acked.add(nodeId);
1536
+ }
1537
+ }
1538
+ return votingNodeIds.filter((nodeId) => acked.has(nodeId));
1539
+ }
1540
+ allPeerStates() {
1541
+ return Array.from(this.peers.values());
1542
+ }
1543
+ countAcked(seq) {
1544
+ let count = 0;
1545
+ for (const peer of this.peers.values()) {
1546
+ if (peer.connected && peer.lastAckedSeq >= seq) {
1547
+ count += 1;
1548
+ }
1549
+ }
1550
+ return count;
1551
+ }
1552
+ countAckedConfigured(seq, localNodeId, votingNodeIds) {
1553
+ return this.ackedConfiguredNodeIds(seq, localNodeId, votingNodeIds).length;
1554
+ }
1555
+ checkWaiters() {
1556
+ const connected = this.connectedPeerCount();
1557
+ for (const waiter of this.waiters) {
1558
+ let needed;
1559
+ if (waiter.kind === "majority") {
1560
+ needed = Math.floor(connected / 2) + 1;
1561
+ } else if (waiter.kind === "configured-majority") {
1562
+ const votingNodeIds = waiter.votingNodeIds ?? [];
1563
+ const localNodeId = waiter.localNodeId ?? "";
1564
+ needed = Math.floor(votingNodeIds.length / 2) + 1;
1565
+ if (this.countAckedConfigured(waiter.seq, localNodeId, votingNodeIds) >= needed) {
1566
+ clearTimeout(waiter.timer);
1567
+ this.waiters.delete(waiter);
1568
+ waiter.resolve();
1569
+ }
1570
+ continue;
1571
+ } else if (waiter.kind === "configured-all") {
1572
+ const votingNodeIds = waiter.votingNodeIds ?? [];
1573
+ const localNodeId = waiter.localNodeId ?? "";
1574
+ needed = votingNodeIds.length;
1575
+ if (this.countAckedConfigured(waiter.seq, localNodeId, votingNodeIds) >= needed) {
1576
+ clearTimeout(waiter.timer);
1577
+ this.waiters.delete(waiter);
1578
+ waiter.resolve();
1579
+ }
1580
+ continue;
1581
+ } else {
1582
+ needed = connected;
1583
+ }
1584
+ if (connected === 0 || this.countAcked(waiter.seq) >= needed) {
1585
+ clearTimeout(waiter.timer);
1586
+ this.waiters.delete(waiter);
1587
+ waiter.resolve();
1588
+ continue;
1589
+ }
1590
+ if (waiter.kind === "all" && connected < this.peers.size) {
1591
+ clearTimeout(waiter.timer);
1592
+ this.waiters.delete(waiter);
1593
+ waiter.reject(
1594
+ new WriteConcernError(
1595
+ `Cannot satisfy 'all' write concern: only ${connected}/${this.peers.size} peers connected`
1596
+ )
1597
+ );
1598
+ continue;
1599
+ }
1600
+ const totalNodes = this.peers.size + 1;
1601
+ const majorityNeeded = Math.floor(totalNodes / 2) + 1;
1602
+ if (waiter.kind === "majority" && connected + 1 < majorityNeeded) {
1603
+ clearTimeout(waiter.timer);
1604
+ this.waiters.delete(waiter);
1605
+ waiter.reject(
1606
+ new WriteConcernError(
1607
+ `Cannot satisfy 'majority' write concern: only ${connected + 1}/${totalNodes} nodes reachable`
1608
+ )
1609
+ );
1610
+ }
1611
+ }
1612
+ }
1613
+ };
1614
+
1615
+ // src/replication/engine/replication-transaction.ts
1616
+ var ReplicationTransaction = class extends Transaction {
1617
+ constructor(txConn, hooks) {
1618
+ super(txConn);
1619
+ this.hooks = hooks;
1620
+ }
1621
+ async execute(sql, params) {
1622
+ const isDdl = DDL_PREFIX_RE.test(sql);
1623
+ if (isDdl && sql.includes(";")) {
1624
+ throw new ReplicationError("DDL statements containing semicolons are not allowed for replication safety");
1625
+ }
1626
+ const result = await super.execute(sql, params);
1627
+ if (isDdl) {
1628
+ this.hooks.sawDdl = true;
1629
+ await this.hooks.onDdl(sql);
1630
+ }
1631
+ return result;
1632
+ }
1633
+ async executeBatch(sql, paramsBatch) {
1634
+ if (DDL_PREFIX_RE.test(sql)) {
1635
+ throw new ReplicationError("DDL statements are not allowed via executeBatch inside a replication transaction");
1636
+ }
1637
+ return super.executeBatch(sql, paramsBatch);
1638
+ }
1639
+ };
1640
+
1641
+ // src/replication/engine/local-executor.ts
1642
+ var LocalExecutor = class {
1643
+ constructor(engine) {
1644
+ this.engine = engine;
1645
+ }
1646
+ /**
1647
+ * Serialises every `executeTransactionLocally` call against itself.
1648
+ *
1649
+ * Reason: SQLite only allows one active transaction per connection; the
1650
+ * writer pool exposes a single writer connection (see `ConnectionPool`).
1651
+ * Two parallel `engine.transaction(fn)` callers therefore both reach
1652
+ * `await conn.exec('BEGIN')` on the same connection, and the second
1653
+ * `BEGIN` errors with "cannot start a transaction within a transaction".
1654
+ * Chaining onto this promise turns the second caller into a strict
1655
+ * follow-on without changing the public API. A rejection is swallowed at
1656
+ * the chain level so a failed transaction never poisons the queue; the
1657
+ * original error still surfaces to the caller that initiated it.
1658
+ */
1659
+ transactionQueue = Promise.resolve();
1660
+ async executeLocally(sql, params, options) {
1661
+ const engine = this.engine;
1662
+ const isDdl = DDL_PREFIX_RE.test(sql);
1663
+ if (isDdl && sql.includes(";")) {
1664
+ throw new ReplicationError("DDL statements containing semicolons are not allowed for replication safety");
1665
+ }
1666
+ const txId = randomUUID();
1667
+ const droppedTable = isDdl ? extractDroppedTable(sql) : null;
1668
+ const result = await engine.writerConn.transaction(async (tx) => {
1669
+ const seqBefore = await engine.log.getLocalSeq();
1670
+ const bindValues = params ? Array.isArray(params) ? params : [params] : [];
1671
+ const stmt = await tx.prepare(sql);
1672
+ const r = await stmt.run(...bindValues);
1673
+ if (isDdl) {
1674
+ const ddlStmt = await tx.prepare(
1675
+ `INSERT INTO "_sirannon_changes" (table_name, operation, row_id, new_data, node_id, tx_id, hlc)
1676
+ VALUES ('__ddl__', 'DDL', '', ?, ?, ?, ?)`
1677
+ );
1678
+ const hlcVal = engine.hlc.now();
1679
+ await ddlStmt.run(JSON.stringify({ ddlStatement: sql }), engine.nodeId, txId, hlcVal);
1680
+ } else {
1681
+ await engine.log.stampChanges(tx, seqBefore, txId);
1682
+ await engine.log.updateColumnVersions(tx, seqBefore);
1683
+ }
1684
+ return { changes: r.changes, lastInsertRowId: r.lastInsertRowId };
1685
+ });
1686
+ const newSeq = await engine.log.getLocalSeq();
1687
+ if (newSeq > engine.lastLocalSeq) {
1688
+ engine.lastLocalSeq = newSeq;
1689
+ }
1690
+ if (isDdl) {
1691
+ if (droppedTable !== null && engine.tracker) {
1692
+ await engine.tracker.pruneDroppedTables(engine.writerConn, [droppedTable]);
1693
+ }
1694
+ await engine.refreshTriggersAfterDdl();
1695
+ }
1696
+ const writeConcern = engine.resolveWriteConcern(options?.writeConcern);
1697
+ if (writeConcern) {
1698
+ await engine.waitForWriteConcern(newSeq, writeConcern);
1699
+ }
1700
+ return result;
1701
+ }
1702
+ async executeForwardedLocally(statements) {
1703
+ const engine = this.engine;
1704
+ const requestId = randomUUID();
1705
+ const results = [];
1706
+ const txId = randomUUID();
1707
+ const hook = engine.config.onBeforeForwardedQuery;
1708
+ for (const { sql } of statements) {
1709
+ if (!SAFE_SQL_PREFIX_RE.test(sql)) {
1710
+ throw new ReplicationError("Forwarded statement rejected: only DML and safe DDL are allowed");
1711
+ }
1712
+ }
1713
+ if (hook) {
1714
+ for (const { sql, params } of statements) {
1715
+ hook(sql, params);
1716
+ }
1717
+ }
1718
+ let sawDdl = false;
1719
+ const droppedTables = [];
1720
+ await engine.writerConn.transaction(async (tx) => {
1721
+ const seqBefore = await engine.log.getLocalSeq();
1722
+ for (const { sql, params } of statements) {
1723
+ const isDdl = DDL_PREFIX_RE.test(sql);
1724
+ if (isDdl && sql.includes(";")) {
1725
+ throw new ReplicationError("DDL statements containing semicolons are not allowed for replication safety");
1726
+ }
1727
+ const bindValues = params ? Array.isArray(params) ? params : [params] : [];
1728
+ const stmt = await tx.prepare(sql);
1729
+ const r = await stmt.run(...bindValues);
1730
+ results.push({
1731
+ changes: r.changes,
1732
+ lastInsertRowId: typeof r.lastInsertRowId === "bigint" ? r.lastInsertRowId.toString() : r.lastInsertRowId
1733
+ });
1734
+ if (isDdl) {
1735
+ sawDdl = true;
1736
+ const droppedTable = extractDroppedTable(sql);
1737
+ if (droppedTable !== null) {
1738
+ droppedTables.push(droppedTable);
1739
+ }
1740
+ const ddlStmt = await tx.prepare(
1741
+ `INSERT INTO "_sirannon_changes" (table_name, operation, row_id, new_data, node_id, tx_id, hlc)
1742
+ VALUES ('__ddl__', 'DDL', '', ?, ?, ?, ?)`
1743
+ );
1744
+ const hlcVal = engine.hlc.now();
1745
+ await ddlStmt.run(JSON.stringify({ ddlStatement: sql }), engine.nodeId, txId, hlcVal);
1746
+ if (engine.tracker) {
1747
+ await engine.tracker.refreshAllTriggersUsingConnection(tx);
1748
+ }
1749
+ }
1750
+ }
1751
+ await engine.log.stampChanges(tx, seqBefore, txId);
1752
+ await engine.log.updateColumnVersions(tx, seqBefore);
1753
+ });
1754
+ const newSeq = await engine.log.getLocalSeq();
1755
+ if (newSeq > engine.lastLocalSeq) {
1756
+ engine.lastLocalSeq = newSeq;
1757
+ }
1758
+ if (sawDdl) {
1759
+ if (droppedTables.length > 0 && engine.tracker) {
1760
+ await engine.tracker.pruneDroppedTables(engine.writerConn, droppedTables);
1761
+ }
1762
+ await engine.refreshTriggersAfterDdl();
1763
+ }
1764
+ return { results, requestId };
1765
+ }
1766
+ async executeTransactionLocally(fn, options) {
1767
+ const ticket = this.transactionQueue.then(
1768
+ () => this.runTransaction(fn, options),
1769
+ () => this.runTransaction(fn, options)
1770
+ );
1771
+ this.transactionQueue = ticket.catch(() => void 0);
1772
+ return ticket;
1773
+ }
1774
+ async runTransaction(fn, options) {
1775
+ const engine = this.engine;
1776
+ const txId = randomUUID();
1777
+ const hooks = {
1778
+ sawDdl: false,
1779
+ droppedTables: [],
1780
+ onDdl: () => {
1781
+ throw new ReplicationError("Internal error: DDL hook invoked outside an active transaction");
1782
+ }
1783
+ };
1784
+ const userResult = await engine.writerConn.transaction(async (tx) => {
1785
+ const seqBefore = await engine.log.getLocalSeq();
1786
+ hooks.onDdl = async (sql) => {
1787
+ const droppedTable = extractDroppedTable(sql);
1788
+ if (droppedTable !== null) {
1789
+ hooks.droppedTables.push(droppedTable);
1790
+ }
1791
+ const ddlStmt = await tx.prepare(
1792
+ `INSERT INTO "_sirannon_changes" (table_name, operation, row_id, new_data, node_id, tx_id, hlc)
1793
+ VALUES ('__ddl__', 'DDL', '', ?, ?, ?, ?)`
1794
+ );
1795
+ const hlcVal = engine.hlc.now();
1796
+ await ddlStmt.run(JSON.stringify({ ddlStatement: sql }), engine.nodeId, txId, hlcVal);
1797
+ if (engine.tracker) {
1798
+ await engine.tracker.refreshAllTriggersUsingConnection(tx);
1799
+ }
1800
+ };
1801
+ const replicationTx = new ReplicationTransaction(tx, hooks);
1802
+ const result = await fn(replicationTx);
1803
+ await engine.log.stampChanges(tx, seqBefore, txId);
1804
+ await engine.log.updateColumnVersions(tx, seqBefore);
1805
+ return result;
1806
+ });
1807
+ const newSeq = await engine.log.getLocalSeq();
1808
+ if (newSeq > engine.lastLocalSeq) {
1809
+ engine.lastLocalSeq = newSeq;
1810
+ }
1811
+ if (hooks.sawDdl) {
1812
+ if (hooks.droppedTables.length > 0 && engine.tracker) {
1813
+ await engine.tracker.pruneDroppedTables(engine.writerConn, hooks.droppedTables);
1814
+ }
1815
+ await engine.refreshTriggersAfterDdl();
1816
+ }
1817
+ const writeConcern = engine.resolveWriteConcern(options?.writeConcern);
1818
+ if (writeConcern) {
1819
+ await engine.waitForWriteConcern(newSeq, writeConcern);
1820
+ }
1821
+ return userResult;
1822
+ }
1823
+ };
1824
+
1825
+ // src/replication/engine/sender-loop.ts
1826
+ var SenderLoop = class {
1827
+ constructor(engine) {
1828
+ this.engine = engine;
1829
+ }
1830
+ senderTimer = null;
1831
+ start() {
1832
+ const engine = this.engine;
1833
+ if (!engine.running) return;
1834
+ const timer = setTimeout(async () => {
1835
+ if (!engine.running) return;
1836
+ try {
1837
+ engine.lastSentSeq = await engine.log.getLocalSeq();
1838
+ await this.sendPendingBatches();
1839
+ await this.updatePruneBoundary();
1840
+ } catch (err) {
1841
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
1842
+ engine.emitError({ error: wrappedErr, operation: "sender-loop", recoverable: true });
1843
+ }
1844
+ this.start();
1845
+ }, engine.batchIntervalMs);
1846
+ timer.unref?.();
1847
+ this.senderTimer = timer;
1848
+ }
1849
+ stop() {
1850
+ if (this.senderTimer !== null) {
1851
+ clearTimeout(this.senderTimer);
1852
+ this.senderTimer = null;
1853
+ }
1854
+ }
1855
+ async updatePruneBoundary() {
1856
+ const engine = this.engine;
1857
+ if (!engine.tracker) return;
1858
+ const minAcked = await engine.log.getMinAckedSeq();
1859
+ if (minAcked === null) {
1860
+ engine.tracker.clearPruneBoundary();
1861
+ } else {
1862
+ engine.tracker.setPruneBoundary(minAcked);
1863
+ }
1864
+ }
1865
+ async sendPendingBatches() {
1866
+ const engine = this.engine;
1867
+ const peers = engine.config.transport.peers();
1868
+ const nowMs = Date.now();
1869
+ for (const [peerId, peerInfo] of peers) {
1870
+ if (!this.shouldReplicateTo(peerId, peerInfo.role)) {
1871
+ continue;
1872
+ }
1873
+ const peerState = engine.peerTracker.getPeerState(peerId);
1874
+ if (peerState) {
1875
+ engine.peerTracker.expireTimedOutBatches(peerId, nowMs, engine.ackTimeoutMs);
1876
+ }
1877
+ if (peerState && peerState.pendingBatches >= engine.maxPendingBatches) {
1878
+ continue;
1879
+ }
1880
+ const fromSeq = peerState?.lastSentSeq ?? engine.lastSentSeq;
1881
+ const rawBatch = await engine.log.readBatch(fromSeq, engine.batchSize);
1882
+ if (!rawBatch) continue;
1883
+ const batch = engine.decorateBatch(rawBatch);
1884
+ const previousSeq = peerState?.lastSentSeq ?? 0n;
1885
+ if (peerState) {
1886
+ peerState.pendingBatches += 1;
1887
+ peerState.lastSentSeq = batch.toSeq;
1888
+ engine.peerTracker.recordInFlightBatch(peerId, {
1889
+ batchId: batch.batchId,
1890
+ fromSeq: batch.fromSeq,
1891
+ toSeq: batch.toSeq,
1892
+ sentAt: nowMs
1893
+ });
1894
+ }
1895
+ engine.config.transport.send(peerId, batch).catch((err) => {
1896
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
1897
+ engine.emitError({ error: wrappedErr, operation: "transport-send", peerId, recoverable: true });
1898
+ if (peerState) {
1899
+ const idx = peerState.inFlightBatches.findIndex((b) => b.batchId === batch.batchId);
1900
+ if (idx >= 0) {
1901
+ peerState.inFlightBatches.splice(idx, 1);
1902
+ }
1903
+ if (peerState.pendingBatches > 0) {
1904
+ peerState.pendingBatches -= 1;
1905
+ }
1906
+ if (previousSeq < peerState.lastSentSeq) {
1907
+ peerState.lastSentSeq = previousSeq;
1908
+ }
1909
+ }
1910
+ });
1911
+ if (engine.config.flowControl?.maxLagSeconds && peerState) {
1912
+ const lagMs = Number(batch.toSeq - peerState.lastAckedSeq) * engine.batchIntervalMs;
1913
+ const maxLagMs = engine.config.flowControl.maxLagSeconds * 1e3;
1914
+ if (lagMs > maxLagMs && engine.config.flowControl.onLagExceeded) {
1915
+ engine.config.flowControl.onLagExceeded(peerId, lagMs);
1916
+ }
1917
+ }
1918
+ }
1919
+ }
1920
+ shouldReplicateTo(peerId, peerRole) {
1921
+ const engine = this.engine;
1922
+ if (!engine.isCoordinatorMode()) {
1923
+ return engine.config.topology.shouldReplicateTo(peerId, peerRole);
1924
+ }
1925
+ return engine.coordinatorAuthority && peerId !== engine.nodeId;
1926
+ }
1927
+ };
1928
+
1929
+ // src/replication/engine/test-hooks.ts
1930
+ var ackDelayByEngine = /* @__PURE__ */ new WeakMap();
1931
+ function installTestHooks(engine) {
1932
+ Object.defineProperty(engine, "__setAckDelayMs", {
1933
+ value(ms) {
1934
+ if (!Number.isFinite(ms) || ms < 0) {
1935
+ throw new RangeError("ACK delay must be a non-negative finite number");
1936
+ }
1937
+ ackDelayByEngine.set(engine, Math.floor(ms));
1938
+ },
1939
+ enumerable: false
1940
+ });
1941
+ }
1942
+ async function delayAckIfConfigured(engine) {
1943
+ const delayMs = ackDelayByEngine.get(engine) ?? 0;
1944
+ if (delayMs === 0) {
1945
+ return;
1946
+ }
1947
+ await new Promise((resolve) => {
1948
+ const timer = setTimeout(resolve, delayMs);
1949
+ timer.unref?.();
1950
+ });
1951
+ }
1952
+
1953
+ // src/replication/engine/transport-wiring.ts
1954
+ function persistAckProgress(engine, nodeId, ackedSeq) {
1955
+ engine.log.setLastAppliedSeq(nodeId, ackedSeq).then(() => engine.handleCoordinatorAckProgress(nodeId, ackedSeq)).catch((err) => {
1956
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
1957
+ engine.emitError({ error: wrappedErr, operation: "ack-state-persist", peerId: nodeId, recoverable: true });
1958
+ });
1959
+ }
1960
+ function wireTransportHandlers(engine) {
1961
+ engine.config.transport.onBatchReceived(async (batch, fromPeerId) => {
1962
+ if (!engine.running) return;
1963
+ if (engine.syncState.phase !== "ready" && engine.syncState.phase !== "catching-up") return;
1964
+ try {
1965
+ if (batch.sourceNodeId !== fromPeerId) {
1966
+ throw new BatchValidationError(
1967
+ `Batch sourceNodeId '${batch.sourceNodeId}' does not match sender '${fromPeerId}'`
1968
+ );
1969
+ }
1970
+ const knownPeers = engine.config.transport.peers();
1971
+ if (!knownPeers.has(fromPeerId)) {
1972
+ throw new BatchValidationError(`Batch from unknown peer: ${fromPeerId}`);
1973
+ }
1974
+ const peerInfo = knownPeers.get(fromPeerId);
1975
+ if (!peerInfo || !engine.config.topology.shouldAcceptFrom(fromPeerId, peerInfo.role)) {
1976
+ if (!engine.isCoordinatorMode()) {
1977
+ throw new ReplicationError(`Rejected batch from unauthorized peer: ${fromPeerId}`);
1978
+ }
1979
+ }
1980
+ await engine.assertInboundCoordinatorMessage(batch, fromPeerId, "batch");
1981
+ if (batch.changes.length > engine.maxBatchChanges) {
1982
+ throw new BatchValidationError(
1983
+ `Batch too large: ${batch.changes.length} changes exceeds max ${engine.maxBatchChanges}`
1984
+ );
1985
+ }
1986
+ const drift = engine.checkClockDrift(batch.hlcRange.max);
1987
+ if (drift > engine.maxClockDriftMs) {
1988
+ throw new BatchValidationError(`Clock drift too high: ${drift}ms exceeds max ${engine.maxClockDriftMs}ms`);
1989
+ }
1990
+ const applyResult = await engine.log.applyBatch(batch, (table) => engine.getResolver(table));
1991
+ const batchContainedDdl = batch.changes.some((c) => c.operation === "ddl");
1992
+ if (batchContainedDdl) {
1993
+ if (applyResult.droppedTables.length > 0 && engine.tracker) {
1994
+ await engine.tracker.pruneDroppedTables(engine.writerConn, applyResult.droppedTables);
1995
+ }
1996
+ await engine.refreshTriggersAfterDdl();
1997
+ }
1998
+ await engine.log.setLastAppliedSeq(fromPeerId, batch.toSeq);
1999
+ const previousApplied = engine.appliedSeqByPeer.get(fromPeerId) ?? 0n;
2000
+ if (batch.toSeq > previousApplied) {
2001
+ engine.appliedSeqByPeer.set(fromPeerId, batch.toSeq);
2002
+ }
2003
+ if (batch.toSeq > engine.highestSourceSeqSeen) {
2004
+ engine.highestSourceSeqSeen = batch.toSeq;
2005
+ }
2006
+ await delayAckIfConfigured(engine);
2007
+ await engine.config.transport.sendAck(
2008
+ fromPeerId,
2009
+ engine.decorateAck({
2010
+ batchId: batch.batchId,
2011
+ ackedSeq: batch.toSeq,
2012
+ nodeId: engine.nodeId
2013
+ })
2014
+ );
2015
+ } catch (err) {
2016
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2017
+ engine.emitError({ error: wrappedErr, operation: "batch-received", peerId: fromPeerId, recoverable: true });
2018
+ }
2019
+ });
2020
+ engine.config.transport.onAckReceived((ack, fromPeerId) => {
2021
+ if (!engine.running) return;
2022
+ if (engine.syncState.phase !== "ready" && engine.syncState.phase !== "catching-up") return;
2023
+ if (!engine.isCoordinatorMode()) {
2024
+ if (ack.nodeId !== fromPeerId) {
2025
+ engine.emitError({
2026
+ error: new ReplicationError(
2027
+ `Ack nodeId '${ack.nodeId}' does not match sender '${fromPeerId}'`,
2028
+ "ACK_NODE_ID_MISMATCH"
2029
+ ),
2030
+ operation: "ack-identity-mismatch",
2031
+ peerId: fromPeerId,
2032
+ recoverable: true
2033
+ });
2034
+ return;
2035
+ }
2036
+ engine.peerTracker.onAckReceived(ack.nodeId, ack.ackedSeq);
2037
+ persistAckProgress(engine, ack.nodeId, ack.ackedSeq);
2038
+ return;
2039
+ }
2040
+ engine.assertInboundCoordinatorMessage(ack, fromPeerId, "ack").then(() => {
2041
+ engine.peerTracker.onAckReceived(ack.nodeId, ack.ackedSeq);
2042
+ persistAckProgress(engine, ack.nodeId, ack.ackedSeq);
2043
+ }).catch((err) => {
2044
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2045
+ engine.emitError({ error: wrappedErr, operation: "ack-received", peerId: fromPeerId, recoverable: true });
2046
+ });
2047
+ });
2048
+ if (engine.config.topology.canWrite() || engine.isCoordinatorMode()) {
2049
+ engine.config.transport.onForwardReceived(async (request, fromPeerId) => {
2050
+ if (engine.syncState.phase !== "ready" && engine.syncState.phase !== "catching-up") {
2051
+ throw new ReplicationError("Node is not ready to handle forwarded requests");
2052
+ }
2053
+ await engine.assertInboundCoordinatorMessage(request, fromPeerId, "forward");
2054
+ const knownPeers = engine.config.transport.peers();
2055
+ if (!knownPeers.has(fromPeerId)) {
2056
+ throw new ReplicationError(`Rejected forward from unknown peer: ${fromPeerId}`);
2057
+ }
2058
+ try {
2059
+ const result = await engine.localExecutor.executeForwardedLocally(request.statements);
2060
+ return engine.decorateForwardResult(result);
2061
+ } catch (err) {
2062
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2063
+ engine.emitError({ error: wrappedErr, operation: "forward-execution", peerId: fromPeerId, recoverable: true });
2064
+ throw wrappedErr;
2065
+ }
2066
+ });
2067
+ }
2068
+ engine.config.transport.onPeerConnected((peer) => {
2069
+ engine.peerTracker.addPeer(peer.id);
2070
+ engine.log.getPeerAckedSeq(peer.id).then(async (ackedSeq) => {
2071
+ if (!engine.running) return;
2072
+ engine.peerTracker.onAckReceived(peer.id, ackedSeq);
2073
+ await engine.log.setLastAppliedSeq(peer.id, ackedSeq);
2074
+ await engine.handleCoordinatorAckProgress(peer.id, ackedSeq);
2075
+ }).catch((err) => {
2076
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2077
+ engine.emitError({ error: wrappedErr, operation: "peer-state-initialise", peerId: peer.id, recoverable: true });
2078
+ });
2079
+ if (engine.syncState.phase === "pending") {
2080
+ engine.syncJoiner.initiateSync().catch((err) => {
2081
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2082
+ engine.emitError({ error: wrappedErr, operation: "sync-initiation", peerId: peer.id, recoverable: true });
2083
+ });
2084
+ }
2085
+ });
2086
+ engine.config.transport.onPeerDisconnected((peerId) => {
2087
+ engine.peerTracker.removePeer(peerId);
2088
+ engine.syncServer.abortSessionsForPeer(peerId);
2089
+ if (engine.syncState.phase === "syncing" && engine.syncState.sourcePeerId === peerId) {
2090
+ engine.syncState.phase = "pending";
2091
+ engine.syncState.sourcePeerId = null;
2092
+ engine.writerConn.exec("PRAGMA foreign_keys = ON").catch((err) => {
2093
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2094
+ engine.emitError({ error: wrappedErr, operation: "peer-disconnect-pragma-restore", peerId, recoverable: false });
2095
+ });
2096
+ engine.expectedBatchIndex.clear();
2097
+ engine.log.setSyncMeta("pending").catch((err) => {
2098
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2099
+ engine.emitError({ error: wrappedErr, operation: "sync-meta-write", peerId, recoverable: false });
2100
+ });
2101
+ }
2102
+ });
2103
+ engine.config.transport.onSyncRequested(async (request, fromPeerId) => {
2104
+ if (!engine.running) return;
2105
+ await engine.assertInboundCoordinatorMessage(request, fromPeerId, "sync-request");
2106
+ await engine.syncServer.handleSyncRequest(request, fromPeerId);
2107
+ });
2108
+ engine.config.transport.onSyncBatchReceived(async (batch, fromPeerId) => {
2109
+ if (!engine.running) return;
2110
+ await engine.assertInboundCoordinatorMessage(batch, fromPeerId, "sync-data");
2111
+ await engine.syncJoiner.handleSyncBatchReceived(batch, fromPeerId);
2112
+ });
2113
+ engine.config.transport.onSyncCompleteReceived(async (complete, fromPeerId) => {
2114
+ if (!engine.running) return;
2115
+ await engine.assertInboundCoordinatorMessage(complete, fromPeerId, "sync-data");
2116
+ await engine.syncJoiner.handleSyncCompleteReceived(complete, fromPeerId);
2117
+ });
2118
+ engine.config.transport.onSyncAckReceived((ack, fromPeerId) => {
2119
+ if (!engine.running) return;
2120
+ if (!engine.isCoordinatorMode()) {
2121
+ if (ack.joinerNodeId !== fromPeerId) {
2122
+ engine.emitError({
2123
+ error: new ReplicationError(
2124
+ `SyncAck joinerNodeId '${ack.joinerNodeId}' does not match sender '${fromPeerId}'`,
2125
+ "SYNC_ACK_NODE_ID_MISMATCH"
2126
+ ),
2127
+ operation: "sync-ack-identity-mismatch",
2128
+ peerId: fromPeerId,
2129
+ recoverable: true
2130
+ });
2131
+ return;
2132
+ }
2133
+ engine.syncServer.handleSyncAckReceived(ack);
2134
+ return;
2135
+ }
2136
+ engine.assertInboundCoordinatorMessage(ack, fromPeerId, "ack").then(() => {
2137
+ engine.syncServer.handleSyncAckReceived(ack);
2138
+ }).catch((err) => {
2139
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2140
+ engine.emitError({ error: wrappedErr, operation: "sync-ack-received", peerId: fromPeerId, recoverable: true });
2141
+ });
2142
+ });
2143
+ }
2144
+
2145
+ // src/replication/engine/startup.ts
2146
+ async function recoverHlcFromDurableState(engine) {
2147
+ const maxObserved = await engine.log.recoverMaxObservedHlc();
2148
+ if (maxObserved === null) return;
2149
+ engine.hlc.receive(maxObserved);
2150
+ }
2151
+ async function startEngine(engine) {
2152
+ if (engine.running) return;
2153
+ engine.running = true;
2154
+ await engine.log.ensureReplicationTables();
2155
+ await recoverHlcFromDurableState(engine);
2156
+ engine.lastSentSeq = await engine.log.getLocalSeq();
2157
+ engine.lastLocalSeq = engine.lastSentSeq;
2158
+ await engine.loadAppliedSeqs();
2159
+ await engine.startCoordinatorMode();
2160
+ await engine.prepareCoordinatorRejoinIfNeeded();
2161
+ wireTransportHandlers(engine);
2162
+ const transportConfig = {
2163
+ ...engine.config.transportConfig,
2164
+ localRole: engine.config.topology.role,
2165
+ groupId: engine.config.coordinator?.groupId ?? engine.config.transportConfig?.groupId,
2166
+ primaryTerm: engine.coordinatorState?.primaryTerm ?? engine.config.transportConfig?.primaryTerm,
2167
+ protocolVersion: engine.config.coordinator?.compatibility?.protocolVersion ?? engine.config.transportConfig?.protocolVersion
2168
+ };
2169
+ await engine.config.transport.connect(engine.nodeId, transportConfig);
2170
+ const isPrimary = engine.isCoordinatorMode() ? engine.hasCoordinatorWriteAuthority() : engine.config.topology.role === "primary";
2171
+ const syncCompleted = await engine.log.isSyncCompleted();
2172
+ const requiresCoordinatorRejoinSync = engine.requiresCoordinatorRejoinSync();
2173
+ if (engine.initialSync && !isPrimary && (!syncCompleted || requiresCoordinatorRejoinSync)) {
2174
+ const savedState = await engine.log.getSyncState();
2175
+ if (savedState.phase === "syncing") {
2176
+ if (!engine.tracker) {
2177
+ throw new SyncError("Initial sync requires a ChangeTracker in ReplicationConfig");
2178
+ }
2179
+ await engine.log.wipeTables(
2180
+ engine.writerConn,
2181
+ await engine.log.getTablesInFkOrder(engine.writerConn),
2182
+ engine.tracker
2183
+ );
2184
+ }
2185
+ engine.syncState = {
2186
+ phase: "pending",
2187
+ sourcePeerId: null,
2188
+ snapshotSeq: null,
2189
+ completedTables: [],
2190
+ totalTables: 0,
2191
+ startedAt: null,
2192
+ error: null
2193
+ };
2194
+ await engine.log.setSyncMeta("pending");
2195
+ await engine.syncJoiner.initiateSync();
2196
+ return;
2197
+ }
2198
+ if (engine.initialSync && !isPrimary && syncCompleted) {
2199
+ const savedState = await engine.log.getSyncState();
2200
+ if (savedState.phase === "catching-up") {
2201
+ engine.syncState = {
2202
+ phase: "catching-up",
2203
+ sourcePeerId: savedState.sourcePeerId,
2204
+ snapshotSeq: savedState.snapshotSeq,
2205
+ completedTables: [],
2206
+ totalTables: 0,
2207
+ startedAt: null,
2208
+ error: null
2209
+ };
2210
+ engine.startSenderLoop();
2211
+ engine.syncJoiner.startCatchUpCheck();
2212
+ return;
2213
+ }
2214
+ engine.syncState.phase = "ready";
2215
+ }
2216
+ if (!engine.initialSync && engine.resumeFromSeq !== void 0) {
2217
+ engine.lastSentSeq = engine.resumeFromSeq;
2218
+ await engine.log.setSyncMeta("ready", engine.resumeFromSeq);
2219
+ engine.syncState.phase = "ready";
2220
+ } else if (!engine.initialSync && !syncCompleted) {
2221
+ const localSeq = await engine.log.getLocalSeq();
2222
+ if (localSeq > 0n) {
2223
+ await engine.log.setSyncMeta("ready", localSeq);
2224
+ }
2225
+ engine.syncState.phase = "ready";
2226
+ } else {
2227
+ engine.syncState.phase = "ready";
2228
+ }
2229
+ engine.startSenderLoop();
2230
+ }
2231
+ var SyncJoiner = class {
2232
+ constructor(engine) {
2233
+ this.engine = engine;
2234
+ }
2235
+ catchUpCheckTimer = null;
2236
+ async initiateSync() {
2237
+ const engine = this.engine;
2238
+ if (engine.syncState.phase !== "pending") return;
2239
+ if (!engine.tracker) {
2240
+ throw new SyncError("Initial sync requires a ChangeTracker in ReplicationConfig");
2241
+ }
2242
+ const peers = engine.config.transport.peers();
2243
+ let sourcePeerId = engine.isCoordinatorMode() ? engine.getCurrentPrimaryPeerId() : null;
2244
+ if (engine.isCoordinatorMode() && sourcePeerId === null) {
2245
+ return;
2246
+ }
2247
+ if (sourcePeerId === null) {
2248
+ for (const [peerId, info] of peers) {
2249
+ if (info.role === "primary") {
2250
+ sourcePeerId = peerId;
2251
+ break;
2252
+ }
2253
+ }
2254
+ }
2255
+ if (sourcePeerId === null) {
2256
+ for (const [peerId] of peers) {
2257
+ sourcePeerId = peerId;
2258
+ break;
2259
+ }
2260
+ }
2261
+ if (sourcePeerId === null) {
2262
+ return;
2263
+ }
2264
+ engine.syncState.phase = "syncing";
2265
+ engine.syncState.sourcePeerId = sourcePeerId;
2266
+ engine.syncState.startedAt = Date.now();
2267
+ engine.syncState.error = null;
2268
+ try {
2269
+ const savedState = await engine.log.getSyncState();
2270
+ const completedTables = savedState.phase === "pending" ? [] : savedState.completedTables;
2271
+ const requestId = randomUUID();
2272
+ await engine.log.setSyncMeta("syncing", void 0, sourcePeerId, requestId);
2273
+ await engine.config.transport.requestSync(
2274
+ sourcePeerId,
2275
+ engine.decorateSyncRequest({
2276
+ requestId,
2277
+ joinerNodeId: engine.nodeId,
2278
+ completedTables
2279
+ })
2280
+ );
2281
+ } catch (err) {
2282
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2283
+ if (engine.syncState.phase === "syncing" && engine.syncState.sourcePeerId === sourcePeerId) {
2284
+ engine.syncState.phase = "pending";
2285
+ engine.syncState.sourcePeerId = null;
2286
+ engine.syncState.startedAt = null;
2287
+ engine.syncState.error = wrappedErr.message;
2288
+ try {
2289
+ await engine.log.setSyncMeta("pending");
2290
+ } catch (rollbackErr) {
2291
+ const wrappedRollbackErr = rollbackErr instanceof Error ? rollbackErr : new Error(String(rollbackErr));
2292
+ engine.emitError({
2293
+ error: wrappedRollbackErr,
2294
+ operation: "sync-request-state-rollback",
2295
+ peerId: sourcePeerId,
2296
+ recoverable: false
2297
+ });
2298
+ }
2299
+ }
2300
+ throw wrappedErr;
2301
+ }
2302
+ }
2303
+ async handleSyncBatchReceived(batch, fromPeerId) {
2304
+ const engine = this.engine;
2305
+ if (engine.syncState.phase !== "syncing") return;
2306
+ if (fromPeerId !== engine.syncState.sourcePeerId) return;
2307
+ if (!engine.tracker) {
2308
+ throw new SyncError("Initial sync requires a ChangeTracker in ReplicationConfig");
2309
+ }
2310
+ const expectedIndex = engine.expectedBatchIndex.get(batch.table) ?? 0;
2311
+ if (batch.batchIndex !== expectedIndex) {
2312
+ await this.sendSyncAck(fromPeerId, {
2313
+ requestId: batch.requestId,
2314
+ joinerNodeId: engine.nodeId,
2315
+ table: batch.table,
2316
+ batchIndex: batch.batchIndex,
2317
+ success: false,
2318
+ error: `Out of order batch: expected ${expectedIndex}, got ${batch.batchIndex}`
2319
+ });
2320
+ return;
2321
+ }
2322
+ engine.expectedBatchIndex.set(batch.table, expectedIndex + 1);
2323
+ try {
2324
+ if (batch.table === "__schema__") {
2325
+ if (batch.schema) {
2326
+ for (const ddl of batch.schema) {
2327
+ if (!isSyncSafeDdl(ddl)) {
2328
+ throw new SyncError(`Unsafe DDL in schema batch: ${ddl}`);
2329
+ }
2330
+ }
2331
+ await engine.writerConn.exec("PRAGMA foreign_keys = OFF");
2332
+ for (const ddl of batch.schema) {
2333
+ let saferDdl = ddl.replace(/^\s*CREATE\s+TABLE\b/i, "CREATE TABLE IF NOT EXISTS");
2334
+ saferDdl = saferDdl.replace(/^\s*CREATE\s+INDEX\b/i, "CREATE INDEX IF NOT EXISTS");
2335
+ await engine.writerConn.exec(saferDdl);
2336
+ }
2337
+ }
2338
+ await this.sendSyncAck(fromPeerId, {
2339
+ requestId: batch.requestId,
2340
+ joinerNodeId: engine.nodeId,
2341
+ table: batch.table,
2342
+ batchIndex: batch.batchIndex,
2343
+ success: true
2344
+ });
2345
+ return;
2346
+ }
2347
+ if (!IDENTIFIER_RE.test(batch.table)) {
2348
+ throw new SyncError(`Invalid table name in sync batch: ${batch.table}`);
2349
+ }
2350
+ const expectedChecksum = createHash("sha256").update(canonicaliseForChecksum(batch.rows)).digest("hex");
2351
+ if (batch.checksum !== expectedChecksum) {
2352
+ throw new SyncError(`Checksum mismatch for ${batch.table} batch ${batch.batchIndex}`);
2353
+ }
2354
+ if (batch.batchIndex === 0 && !engine.syncState.completedTables.includes(batch.table)) {
2355
+ await engine.tracker.unwatch(engine.writerConn, batch.table);
2356
+ await engine.writerConn.exec(`DELETE FROM "${batch.table}"`);
2357
+ }
2358
+ if (batch.rows.length > 0) {
2359
+ const columns = Object.keys(batch.rows[0]).filter((c) => IDENTIFIER_RE.test(c));
2360
+ if (columns.length > 0) {
2361
+ const placeholders = columns.map(() => "?").join(", ");
2362
+ const colNames = columns.map((c) => `"${c}"`).join(", ");
2363
+ const insertSql = `INSERT INTO "${batch.table}" (${colNames}) VALUES (${placeholders})`;
2364
+ await engine.writerConn.transaction(async (tx) => {
2365
+ const stmt = await tx.prepare(insertSql);
2366
+ for (const row of batch.rows) {
2367
+ const values = columns.map((c) => row[c]);
2368
+ await stmt.run(...values);
2369
+ }
2370
+ });
2371
+ }
2372
+ }
2373
+ if (batch.isLastBatchForTable) {
2374
+ await engine.log.setSyncTableStatus(batch.table, "completed");
2375
+ engine.syncState.completedTables.push(batch.table);
2376
+ engine.expectedBatchIndex.delete(batch.table);
2377
+ }
2378
+ await this.sendSyncAck(fromPeerId, {
2379
+ requestId: batch.requestId,
2380
+ joinerNodeId: engine.nodeId,
2381
+ table: batch.table,
2382
+ batchIndex: batch.batchIndex,
2383
+ success: true
2384
+ });
2385
+ } catch (err) {
2386
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2387
+ engine.emitError({ error: wrappedErr, operation: "sync-batch-processing", peerId: fromPeerId, recoverable: true });
2388
+ await this.sendSyncAck(fromPeerId, {
2389
+ requestId: batch.requestId,
2390
+ joinerNodeId: engine.nodeId,
2391
+ table: batch.table,
2392
+ batchIndex: batch.batchIndex,
2393
+ success: false,
2394
+ error: wrappedErr.message
2395
+ }).catch((ackErr) => {
2396
+ const ackWrapped = ackErr instanceof Error ? ackErr : new Error(String(ackErr));
2397
+ engine.emitError({ error: ackWrapped, operation: "sync-ack-send", peerId: fromPeerId, recoverable: true });
2398
+ });
2399
+ }
2400
+ }
2401
+ async handleSyncCompleteReceived(complete, fromPeerId) {
2402
+ const engine = this.engine;
2403
+ if (engine.syncState.phase !== "syncing") return;
2404
+ if (fromPeerId !== engine.syncState.sourcePeerId) return;
2405
+ if (!engine.tracker) {
2406
+ throw new SyncError("Initial sync requires a ChangeTracker in ReplicationConfig");
2407
+ }
2408
+ try {
2409
+ for (const manifest of complete.manifests) {
2410
+ if (IDENTIFIER_RE.test(manifest.table)) {
2411
+ await engine.tracker.watch(engine.writerConn, manifest.table);
2412
+ }
2413
+ }
2414
+ await engine.writerConn.exec("PRAGMA foreign_keys = ON");
2415
+ for (const manifest of complete.manifests) {
2416
+ const valid = await engine.log.verifyManifest(manifest);
2417
+ if (!valid) {
2418
+ await engine.log.wipeTables(
2419
+ engine.writerConn,
2420
+ complete.manifests.map((m) => m.table).filter((t) => IDENTIFIER_RE.test(t)),
2421
+ engine.tracker
2422
+ );
2423
+ engine.syncState.phase = "pending";
2424
+ engine.syncState.completedTables = [];
2425
+ engine.expectedBatchIndex.clear();
2426
+ await engine.log.setSyncMeta("pending");
2427
+ await this.initiateSync();
2428
+ return;
2429
+ }
2430
+ }
2431
+ await engine.log.setLastAppliedSeq(fromPeerId, complete.snapshotSeq);
2432
+ const recordStmt = await engine.writerConn.prepare(
2433
+ "INSERT OR IGNORE INTO _sirannon_applied_changes (source_node_id, source_seq, applied_at) VALUES (?, ?, ?)"
2434
+ );
2435
+ await recordStmt.run(fromPeerId, complete.snapshotSeq.toString(), Date.now() / 1e3);
2436
+ const previousApplied = engine.appliedSeqByPeer.get(fromPeerId) ?? 0n;
2437
+ if (complete.snapshotSeq > previousApplied) {
2438
+ engine.appliedSeqByPeer.set(fromPeerId, complete.snapshotSeq);
2439
+ }
2440
+ engine.syncState.phase = "catching-up";
2441
+ engine.syncState.snapshotSeq = complete.snapshotSeq;
2442
+ await engine.log.setSyncMeta("catching-up", complete.snapshotSeq);
2443
+ engine.startSenderLoop();
2444
+ this.startCatchUpCheck();
2445
+ } catch {
2446
+ try {
2447
+ await engine.writerConn.exec("PRAGMA foreign_keys = ON");
2448
+ } catch (pragmaErr) {
2449
+ const wrappedErr = pragmaErr instanceof Error ? pragmaErr : new Error(String(pragmaErr));
2450
+ engine.emitError({ error: wrappedErr, operation: "sync-complete-pragma-restore", recoverable: false });
2451
+ }
2452
+ engine.emitError({
2453
+ error: new SyncError("Failed during sync completion handling"),
2454
+ operation: "sync-complete-handling",
2455
+ recoverable: false
2456
+ });
2457
+ return;
2458
+ }
2459
+ }
2460
+ startCatchUpCheck() {
2461
+ const engine = this.engine;
2462
+ const catchUpStartedAt = Date.now();
2463
+ this.catchUpCheckTimer = setInterval(async () => {
2464
+ if (engine.syncState.phase !== "catching-up") {
2465
+ this.stopCatchUpCheck();
2466
+ return;
2467
+ }
2468
+ if (Date.now() - catchUpStartedAt > engine.catchUpDeadlineMs) {
2469
+ await this.finishCatchUpAsReady();
2470
+ return;
2471
+ }
2472
+ const sourcePeerId = engine.syncState.sourcePeerId;
2473
+ if (!sourcePeerId) return;
2474
+ if (engine.highestSourceSeqSeen === 0n) {
2475
+ const snapshotSeq = engine.syncState.snapshotSeq ?? 0n;
2476
+ if (snapshotSeq === 0n) {
2477
+ await this.finishCatchUpAsReady();
2478
+ return;
2479
+ }
2480
+ const localSeq = await engine.log.getLocalSeq();
2481
+ if (localSeq === 0n) {
2482
+ await this.finishCatchUpAsReady();
2483
+ return;
2484
+ }
2485
+ return;
2486
+ }
2487
+ const appliedSeq = await engine.log.getLastAppliedSeq(sourcePeerId);
2488
+ const lag = engine.highestSourceSeqSeen - appliedSeq;
2489
+ if (lag <= BigInt(engine.maxSyncLagBeforeReady)) {
2490
+ await this.finishCatchUpAsReady();
2491
+ }
2492
+ }, engine.batchIntervalMs * 2);
2493
+ }
2494
+ stopCatchUpCheck() {
2495
+ if (this.catchUpCheckTimer) {
2496
+ clearInterval(this.catchUpCheckTimer);
2497
+ this.catchUpCheckTimer = null;
2498
+ }
2499
+ }
2500
+ async sendSyncAck(peerId, ack) {
2501
+ await delayAckIfConfigured(this.engine);
2502
+ await this.engine.config.transport.sendSyncAck(peerId, this.engine.decorateSyncAck(ack));
2503
+ }
2504
+ async finishCatchUpAsReady() {
2505
+ const engine = this.engine;
2506
+ await engine.log.setSyncMeta("ready");
2507
+ await engine.markCoordinatorSyncReady();
2508
+ engine.syncState.phase = "ready";
2509
+ this.stopCatchUpCheck();
2510
+ }
2511
+ };
2512
+ var SyncServer = class {
2513
+ constructor(engine) {
2514
+ this.engine = engine;
2515
+ }
2516
+ activeSyncs = /* @__PURE__ */ new Map();
2517
+ syncAckWaiters = /* @__PURE__ */ new Map();
2518
+ async handleSyncRequest(request, fromPeerId) {
2519
+ const engine = this.engine;
2520
+ const knownPeers = engine.config.transport.peers();
2521
+ if (!knownPeers.has(fromPeerId)) return;
2522
+ if (engine.isCoordinatorMode()) {
2523
+ try {
2524
+ await engine.verifyPrimaryAuthority();
2525
+ } catch (err) {
2526
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2527
+ await engine.config.transport.sendSyncAck(
2528
+ fromPeerId,
2529
+ engine.decorateSyncAck({
2530
+ requestId: request.requestId,
2531
+ joinerNodeId: request.joinerNodeId,
2532
+ table: "__schema__",
2533
+ batchIndex: -1,
2534
+ success: false,
2535
+ error: wrappedErr.message
2536
+ })
2537
+ );
2538
+ return;
2539
+ }
2540
+ } else if (!engine.config.topology.canWrite()) {
2541
+ await engine.config.transport.sendSyncAck(
2542
+ fromPeerId,
2543
+ engine.decorateSyncAck({
2544
+ requestId: request.requestId,
2545
+ joinerNodeId: request.joinerNodeId,
2546
+ table: "__schema__",
2547
+ batchIndex: -1,
2548
+ success: false,
2549
+ error: "This node cannot serve syncs"
2550
+ })
2551
+ );
2552
+ return;
2553
+ }
2554
+ if (this.activeSyncs.has(request.requestId)) {
2555
+ await engine.config.transport.sendSyncAck(
2556
+ fromPeerId,
2557
+ engine.decorateSyncAck({
2558
+ requestId: request.requestId,
2559
+ joinerNodeId: request.joinerNodeId,
2560
+ table: "__schema__",
2561
+ batchIndex: -1,
2562
+ success: false,
2563
+ error: "Duplicate requestId"
2564
+ })
2565
+ );
2566
+ return;
2567
+ }
2568
+ if (this.activeSyncs.size >= engine.maxConcurrentSyncs) {
2569
+ await engine.config.transport.sendSyncAck(
2570
+ fromPeerId,
2571
+ engine.decorateSyncAck({
2572
+ requestId: request.requestId,
2573
+ joinerNodeId: request.joinerNodeId,
2574
+ table: "__schema__",
2575
+ batchIndex: -1,
2576
+ success: false,
2577
+ error: "Sync capacity reached, retry later"
2578
+ })
2579
+ );
2580
+ return;
2581
+ }
2582
+ if (!engine.snapshotConnectionFactory) {
2583
+ await engine.config.transport.sendSyncAck(
2584
+ fromPeerId,
2585
+ engine.decorateSyncAck({
2586
+ requestId: request.requestId,
2587
+ joinerNodeId: request.joinerNodeId,
2588
+ table: "__schema__",
2589
+ batchIndex: -1,
2590
+ success: false,
2591
+ error: "No snapshot connection factory configured"
2592
+ })
2593
+ );
2594
+ return;
2595
+ }
2596
+ for (const table of request.completedTables) {
2597
+ if (!IDENTIFIER_RE.test(table)) {
2598
+ await engine.config.transport.sendSyncAck(
2599
+ fromPeerId,
2600
+ engine.decorateSyncAck({
2601
+ requestId: request.requestId,
2602
+ joinerNodeId: request.joinerNodeId,
2603
+ table: "__schema__",
2604
+ batchIndex: -1,
2605
+ success: false,
2606
+ error: `Invalid table name in completedTables: ${table}`
2607
+ })
2608
+ );
2609
+ return;
2610
+ }
2611
+ }
2612
+ let readConn;
2613
+ try {
2614
+ readConn = await engine.snapshotConnectionFactory();
2615
+ } catch (err) {
2616
+ await engine.config.transport.sendSyncAck(
2617
+ fromPeerId,
2618
+ engine.decorateSyncAck({
2619
+ requestId: request.requestId,
2620
+ joinerNodeId: request.joinerNodeId,
2621
+ table: "__schema__",
2622
+ batchIndex: -1,
2623
+ success: false,
2624
+ error: `Failed to open snapshot connection: ${err instanceof Error ? err.message : String(err)}`
2625
+ })
2626
+ );
2627
+ return;
2628
+ }
2629
+ const snapshotSeq = await engine.log.getLocalSeq();
2630
+ await readConn.exec("BEGIN");
2631
+ const lockStmt = await readConn.prepare("SELECT 1 FROM sqlite_master LIMIT 1");
2632
+ await lockStmt.get();
2633
+ engine.log.registerActiveSyncSeq(snapshotSeq);
2634
+ const allTables = await engine.log.getTablesInFkOrder(readConn);
2635
+ const completedSet = new Set(request.completedTables);
2636
+ const tables = allTables.filter((t) => !completedSet.has(t));
2637
+ const timeoutTimer = setTimeout(
2638
+ () => this.abortSyncSession(request.requestId),
2639
+ engine.maxSyncDurationMs
2640
+ );
2641
+ timeoutTimer.unref?.();
2642
+ const session = {
2643
+ requestId: request.requestId,
2644
+ joinerNodeId: request.joinerNodeId,
2645
+ readConn,
2646
+ snapshotSeq,
2647
+ tables,
2648
+ completedTables: new Set(request.completedTables),
2649
+ startedAt: Date.now(),
2650
+ timeoutTimer,
2651
+ aborted: false
2652
+ };
2653
+ this.activeSyncs.set(request.requestId, session);
2654
+ this.serveSyncSession(session).catch((err) => {
2655
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2656
+ engine.emitError({ error: wrappedErr, operation: "sync-session-serve", peerId: fromPeerId, recoverable: true });
2657
+ this.abortSyncSession(request.requestId);
2658
+ });
2659
+ }
2660
+ handleSyncAckReceived(ack) {
2661
+ const key = `${ack.requestId}:${ack.table}:${ack.batchIndex}`;
2662
+ const waiter = this.syncAckWaiters.get(key);
2663
+ if (waiter) {
2664
+ this.syncAckWaiters.delete(key);
2665
+ clearTimeout(waiter.timer);
2666
+ waiter.resolve(ack);
2667
+ }
2668
+ }
2669
+ abortSyncSession(requestId) {
2670
+ const session = this.activeSyncs.get(requestId);
2671
+ if (!session) return;
2672
+ session.aborted = true;
2673
+ for (const [key, waiter] of this.syncAckWaiters) {
2674
+ if (key.startsWith(`${requestId}:`)) {
2675
+ this.syncAckWaiters.delete(key);
2676
+ clearTimeout(waiter.timer);
2677
+ waiter.resolve({
2678
+ requestId,
2679
+ joinerNodeId: session.joinerNodeId,
2680
+ table: "",
2681
+ batchIndex: -1,
2682
+ success: false,
2683
+ error: "Session aborted"
2684
+ });
2685
+ }
2686
+ }
2687
+ try {
2688
+ session.readConn.close().catch((err) => {
2689
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2690
+ this.engine.emitError({
2691
+ error: wrappedErr,
2692
+ operation: "sync-session-abort-close",
2693
+ peerId: session.joinerNodeId,
2694
+ recoverable: true
2695
+ });
2696
+ });
2697
+ } catch (err) {
2698
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2699
+ this.engine.emitError({
2700
+ error: wrappedErr,
2701
+ operation: "sync-session-abort-close",
2702
+ peerId: session.joinerNodeId,
2703
+ recoverable: true
2704
+ });
2705
+ }
2706
+ this.engine.log.unregisterActiveSyncSeq(session.snapshotSeq);
2707
+ this.activeSyncs.delete(requestId);
2708
+ clearTimeout(session.timeoutTimer);
2709
+ }
2710
+ abortAll() {
2711
+ for (const [requestId] of this.activeSyncs) {
2712
+ this.abortSyncSession(requestId);
2713
+ }
2714
+ }
2715
+ abortSessionsForPeer(peerId) {
2716
+ for (const [requestId, session] of this.activeSyncs) {
2717
+ if (session.joinerNodeId === peerId) {
2718
+ this.abortSyncSession(requestId);
2719
+ }
2720
+ }
2721
+ }
2722
+ waitForSyncAck(requestId, table, batchIndex) {
2723
+ return new Promise((resolve, reject) => {
2724
+ const key = `${requestId}:${table}:${batchIndex}`;
2725
+ const timer = setTimeout(() => {
2726
+ this.syncAckWaiters.delete(key);
2727
+ reject(new SyncError(`Sync ack timeout for ${table} batch ${batchIndex}`, requestId));
2728
+ }, this.engine.syncAckTimeoutMs);
2729
+ timer.unref?.();
2730
+ this.syncAckWaiters.set(key, {
2731
+ resolve: (ack) => {
2732
+ resolve(ack);
2733
+ },
2734
+ timer
2735
+ });
2736
+ });
2737
+ }
2738
+ async sendSyncBatchAndWaitForAck(peerId, batch) {
2739
+ const ackPromise = this.waitForSyncAck(batch.requestId, batch.table, batch.batchIndex);
2740
+ try {
2741
+ await this.engine.config.transport.sendSyncBatch(peerId, this.engine.decorateSyncBatch(batch));
2742
+ } catch (err) {
2743
+ const key = `${batch.requestId}:${batch.table}:${batch.batchIndex}`;
2744
+ const waiter = this.syncAckWaiters.get(key);
2745
+ if (waiter) {
2746
+ this.syncAckWaiters.delete(key);
2747
+ clearTimeout(waiter.timer);
2748
+ waiter.resolve({
2749
+ requestId: batch.requestId,
2750
+ joinerNodeId: peerId,
2751
+ table: batch.table,
2752
+ batchIndex: batch.batchIndex,
2753
+ success: false,
2754
+ error: err instanceof Error ? err.message : String(err)
2755
+ });
2756
+ }
2757
+ await ackPromise;
2758
+ throw err;
2759
+ }
2760
+ return ackPromise;
2761
+ }
2762
+ async serveSyncSession(session) {
2763
+ const engine = this.engine;
2764
+ const schemaDdl = await engine.log.dumpSchema(session.readConn);
2765
+ const schemaChecksum = createHash("sha256").update(canonicaliseForChecksum(schemaDdl)).digest("hex");
2766
+ const schemaAck = await this.sendSyncBatchAndWaitForAck(session.joinerNodeId, {
2767
+ requestId: session.requestId,
2768
+ table: "__schema__",
2769
+ batchIndex: 0,
2770
+ rows: [],
2771
+ schema: schemaDdl,
2772
+ checksum: schemaChecksum,
2773
+ isLastBatchForTable: true
2774
+ });
2775
+ if (!schemaAck.success) {
2776
+ this.abortSyncSession(session.requestId);
2777
+ return;
2778
+ }
2779
+ for (const table of session.tables) {
2780
+ if (session.aborted) return;
2781
+ let batchIndex = 0;
2782
+ for await (const { rows, checksum, isLast } of engine.log.dumpTableOnConnection(
2783
+ session.readConn,
2784
+ table,
2785
+ engine.syncBatchSize
2786
+ )) {
2787
+ if (session.aborted) return;
2788
+ const ack = await this.sendSyncBatchAndWaitForAck(session.joinerNodeId, {
2789
+ requestId: session.requestId,
2790
+ table,
2791
+ batchIndex,
2792
+ rows,
2793
+ checksum,
2794
+ isLastBatchForTable: isLast
2795
+ });
2796
+ if (!ack.success) {
2797
+ this.abortSyncSession(session.requestId);
2798
+ return;
2799
+ }
2800
+ batchIndex += 1;
2801
+ }
2802
+ if (batchIndex === 0) {
2803
+ const emptyAck = await this.sendSyncBatchAndWaitForAck(session.joinerNodeId, {
2804
+ requestId: session.requestId,
2805
+ table,
2806
+ batchIndex: 0,
2807
+ rows: [],
2808
+ checksum: createHash("sha256").update(canonicaliseForChecksum([])).digest("hex"),
2809
+ isLastBatchForTable: true
2810
+ });
2811
+ if (!emptyAck.success) {
2812
+ this.abortSyncSession(session.requestId);
2813
+ return;
2814
+ }
2815
+ }
2816
+ session.completedTables.add(table);
2817
+ }
2818
+ if (session.aborted) return;
2819
+ const manifests = [];
2820
+ for (const table of session.tables) {
2821
+ manifests.push(await engine.log.generateManifest(session.readConn, table));
2822
+ }
2823
+ await engine.config.transport.sendSyncComplete(
2824
+ session.joinerNodeId,
2825
+ engine.decorateSyncComplete({
2826
+ requestId: session.requestId,
2827
+ snapshotSeq: session.snapshotSeq,
2828
+ manifests
2829
+ })
2830
+ );
2831
+ try {
2832
+ await session.readConn.exec("COMMIT");
2833
+ } catch (err) {
2834
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2835
+ engine.emitError({
2836
+ error: wrappedErr,
2837
+ operation: "sync-session-commit",
2838
+ peerId: session.joinerNodeId,
2839
+ recoverable: true
2840
+ });
2841
+ }
2842
+ try {
2843
+ await session.readConn.close();
2844
+ } catch (err) {
2845
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2846
+ engine.emitError({
2847
+ error: wrappedErr,
2848
+ operation: "sync-session-close",
2849
+ peerId: session.joinerNodeId,
2850
+ recoverable: true
2851
+ });
2852
+ }
2853
+ engine.log.unregisterActiveSyncSeq(session.snapshotSeq);
2854
+ clearTimeout(session.timeoutTimer);
2855
+ this.activeSyncs.delete(session.requestId);
2856
+ }
2857
+ };
2858
+
2859
+ // src/replication/engine/engine.ts
2860
+ var DEFAULT_COORDINATOR_SESSION_TTL_MS = 1e4;
2861
+ var DEFAULT_CONTROLLER_LEASE_TTL_MS = 1e4;
2862
+ var DEFAULT_CONTROLLER_TICK_INTERVAL_MS = 1e3;
2863
+ var ReplicationEngine = class extends EventEmitter {
2864
+ database;
2865
+ writerConn;
2866
+ config;
2867
+ nodeId;
2868
+ hlc;
2869
+ log;
2870
+ peerTracker = new PeerTracker();
2871
+ defaultResolver;
2872
+ tracker;
2873
+ snapshotConnectionFactory;
2874
+ batchSize;
2875
+ batchIntervalMs;
2876
+ maxClockDriftMs;
2877
+ maxPendingBatches;
2878
+ maxBatchChanges;
2879
+ ackTimeoutMs;
2880
+ initialSync;
2881
+ syncBatchSize;
2882
+ maxConcurrentSyncs;
2883
+ maxSyncDurationMs;
2884
+ maxSyncLagBeforeReady;
2885
+ syncAckTimeoutMs;
2886
+ catchUpDeadlineMs;
2887
+ resumeFromSeq;
2888
+ running = false;
2889
+ coordinatorState = null;
2890
+ coordinatorAuthority = false;
2891
+ controllerState = "disabled";
2892
+ nodeSessionLeaseId = null;
2893
+ controllerLeaseId = null;
2894
+ coordinatorWatchDisposer = null;
2895
+ coordinatorLeaseTimer = null;
2896
+ controllerTimer = null;
2897
+ coordinatorRejoinSyncStarting = false;
2898
+ lastSentSeq = 0n;
2899
+ lastLocalSeq = 0n;
2900
+ highestSourceSeqSeen = 0n;
2901
+ appliedSeqByPeer = /* @__PURE__ */ new Map();
2902
+ expectedBatchIndex = /* @__PURE__ */ new Map();
2903
+ syncState = {
2904
+ phase: "ready",
2905
+ sourcePeerId: null,
2906
+ snapshotSeq: null,
2907
+ completedTables: [],
2908
+ totalTables: 0,
2909
+ startedAt: null,
2910
+ error: null
2911
+ };
2912
+ localExecutor;
2913
+ syncServer;
2914
+ syncJoiner;
2915
+ senderLoop;
2916
+ constructor(database, writerConn, config) {
2917
+ super();
2918
+ this.database = database;
2919
+ this.writerConn = writerConn;
2920
+ this.config = config;
2921
+ if (config.coordinator && !config.nodeId) {
2922
+ throw new AuthorityError("Coordinator mode requires a stable persisted nodeId");
2923
+ }
2924
+ this.nodeId = config.nodeId ?? generateNodeId();
2925
+ this.hlc = new HLC(this.nodeId);
2926
+ this.tracker = config.changeTracker;
2927
+ this.log = new ReplicationLog(writerConn, this.nodeId, this.hlc, "_sirannon_changes", this.tracker);
2928
+ this.defaultResolver = config.defaultConflictResolver ?? new LWWResolver();
2929
+ this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE;
2930
+ this.batchIntervalMs = config.batchIntervalMs ?? DEFAULT_BATCH_INTERVAL_MS;
2931
+ this.maxClockDriftMs = config.maxClockDriftMs ?? DEFAULT_MAX_CLOCK_DRIFT_MS;
2932
+ this.maxPendingBatches = config.maxPendingBatches ?? DEFAULT_MAX_PENDING_BATCHES;
2933
+ this.maxBatchChanges = config.maxBatchChanges ?? DEFAULT_MAX_BATCH_CHANGES;
2934
+ this.ackTimeoutMs = config.ackTimeoutMs ?? DEFAULT_ACK_TIMEOUT_MS;
2935
+ this.initialSync = config.initialSync ?? true;
2936
+ this.syncBatchSize = config.syncBatchSize ?? DEFAULT_SYNC_BATCH_SIZE;
2937
+ this.maxConcurrentSyncs = config.maxConcurrentSyncs ?? DEFAULT_MAX_CONCURRENT_SYNCS;
2938
+ this.maxSyncDurationMs = config.maxSyncDurationMs ?? DEFAULT_MAX_SYNC_DURATION_MS;
2939
+ this.maxSyncLagBeforeReady = config.maxSyncLagBeforeReady ?? DEFAULT_MAX_SYNC_LAG_BEFORE_READY;
2940
+ this.syncAckTimeoutMs = config.syncAckTimeoutMs ?? DEFAULT_SYNC_ACK_TIMEOUT_MS;
2941
+ this.catchUpDeadlineMs = config.catchUpDeadlineMs ?? DEFAULT_CATCH_UP_DEADLINE_MS;
2942
+ this.resumeFromSeq = config.resumeFromSeq;
2943
+ this.snapshotConnectionFactory = config.snapshotConnectionFactory;
2944
+ this.localExecutor = new LocalExecutor(this);
2945
+ this.syncServer = new SyncServer(this);
2946
+ this.syncJoiner = new SyncJoiner(this);
2947
+ this.senderLoop = new SenderLoop(this);
2948
+ installTestHooks(this);
2949
+ }
2950
+ start() {
2951
+ return startEngine(this);
2952
+ }
2953
+ async stop() {
2954
+ if (!this.running) return;
2955
+ this.running = false;
2956
+ this.stopCoordinatorTimers();
2957
+ this.syncJoiner.stopCatchUpCheck();
2958
+ this.syncServer.abortAll();
2959
+ if (this.syncState.phase === "syncing") {
2960
+ try {
2961
+ await this.writerConn.exec("PRAGMA foreign_keys = ON");
2962
+ } catch (err) {
2963
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
2964
+ this.emitError({ error: wrappedErr, operation: "engine-stop-pragma-restore", recoverable: false });
2965
+ }
2966
+ }
2967
+ this.senderLoop.stop();
2968
+ if (this.tracker) {
2969
+ this.tracker.clearPruneBoundary();
2970
+ }
2971
+ await this.stopCoordinatorMode();
2972
+ await this.config.transport.disconnect();
2973
+ }
2974
+ status() {
2975
+ return {
2976
+ nodeId: this.nodeId,
2977
+ role: this.config.topology.role,
2978
+ peers: this.peerTracker.allPeerStates(),
2979
+ localSeq: this.lastSentSeq,
2980
+ replicating: this.running,
2981
+ syncState: { ...this.syncState },
2982
+ coordinator: this.getCoordinatorRuntimeStatus()
2983
+ };
2984
+ }
2985
+ getCurrentSeq() {
2986
+ return this.lastLocalSeq;
2987
+ }
2988
+ getAppliedSeq(peerId) {
2989
+ return this.appliedSeqByPeer.get(peerId) ?? 0n;
2990
+ }
2991
+ async query(sql, params, options) {
2992
+ if (this.syncState.phase !== "ready") {
2993
+ throw new SyncError(`Node is in '${this.syncState.phase}' phase and cannot serve reads`);
2994
+ }
2995
+ await this.assertReadConcern(options?.readConcern?.level);
2996
+ return this.database.query(sql, params, options);
2997
+ }
2998
+ async execute(sql, params, options) {
2999
+ if (this.syncState.phase !== "ready") {
3000
+ throw new SyncError(`Node is in '${this.syncState.phase}' phase and cannot accept operations`);
3001
+ }
3002
+ if (!await this.canAcceptLocalWrite()) {
3003
+ if (this.config.writeForwarding) {
3004
+ const result = await this.forwardStatements([{ sql, params }], options);
3005
+ const first = result.results[0];
3006
+ if (!first) {
3007
+ return { changes: 0, lastInsertRowId: 0 };
3008
+ }
3009
+ return {
3010
+ changes: first.changes,
3011
+ lastInsertRowId: typeof first.lastInsertRowId === "string" ? BigInt(first.lastInsertRowId) : first.lastInsertRowId
3012
+ };
3013
+ }
3014
+ this.throwNotCurrentPrimary();
3015
+ }
3016
+ return this.localExecutor.executeLocally(sql, params, options);
3017
+ }
3018
+ async executeBatch(sql, paramsBatch, options) {
3019
+ if (this.syncState.phase !== "ready") {
3020
+ throw new SyncError(`Node is in '${this.syncState.phase}' phase and cannot accept operations`);
3021
+ }
3022
+ if (!await this.canAcceptLocalWrite()) {
3023
+ if (this.config.writeForwarding) {
3024
+ const statements = paramsBatch.map((p) => ({ sql, params: p }));
3025
+ const result = await this.forwardStatements(statements, options);
3026
+ return result.results.map((r) => ({
3027
+ changes: r.changes,
3028
+ lastInsertRowId: typeof r.lastInsertRowId === "string" ? BigInt(r.lastInsertRowId) : Number(r.lastInsertRowId)
3029
+ }));
3030
+ }
3031
+ this.throwNotCurrentPrimary();
3032
+ }
3033
+ const results = [];
3034
+ for (const params of paramsBatch) {
3035
+ const r = await this.localExecutor.executeLocally(sql, params, options);
3036
+ results.push(r);
3037
+ }
3038
+ return results;
3039
+ }
3040
+ async transaction(fn, options) {
3041
+ if (this.syncState.phase !== "ready") {
3042
+ throw new SyncError(`Node is in '${this.syncState.phase}' phase and cannot accept operations`);
3043
+ }
3044
+ if (!await this.canAcceptLocalWrite()) {
3045
+ throw new TopologyError("This node cannot accept writes in transaction mode");
3046
+ }
3047
+ return this.localExecutor.executeTransactionLocally(fn, options);
3048
+ }
3049
+ async forwardStatements(statements, _options) {
3050
+ if (await this.canAcceptLocalWrite()) {
3051
+ return this.localExecutor.executeForwardedLocally(statements);
3052
+ }
3053
+ const primaryPeerId = this.getForwardingPrimaryPeerId();
3054
+ if (primaryPeerId === null) {
3055
+ throw new TopologyError("No primary node available for write forwarding");
3056
+ }
3057
+ return this.config.transport.forward(primaryPeerId, {
3058
+ statements,
3059
+ requestId: randomUUID(),
3060
+ ...this.getCoordinatorMessageFields()
3061
+ });
3062
+ }
3063
+ startSenderLoop() {
3064
+ this.senderLoop.start();
3065
+ }
3066
+ emitError(event) {
3067
+ if (this.listenerCount("replication-error") > 0) {
3068
+ try {
3069
+ this.emit("replication-error", event);
3070
+ } catch {
3071
+ }
3072
+ }
3073
+ }
3074
+ getResolver(table) {
3075
+ if (table && this.config.conflictResolvers) {
3076
+ const specific = this.config.conflictResolvers[table];
3077
+ if (specific) return specific;
3078
+ }
3079
+ return this.defaultResolver;
3080
+ }
3081
+ checkClockDrift(remoteHlc) {
3082
+ const decoded = HLC.decode(remoteHlc);
3083
+ return Math.abs(Date.now() - decoded.wallMs);
3084
+ }
3085
+ async refreshTriggersAfterDdl() {
3086
+ if (!this.tracker) return;
3087
+ const tables = Array.from(this.tracker.watchedTables);
3088
+ for (const table of tables) {
3089
+ try {
3090
+ await this.tracker.watch(this.writerConn, table);
3091
+ } catch {
3092
+ }
3093
+ }
3094
+ }
3095
+ async waitForWriteConcern(seq, wc) {
3096
+ const timeout = wc.timeoutMs ?? 5e3;
3097
+ if (this.isCoordinatorMode()) {
3098
+ const state = this.coordinatorState ?? await this.refreshCoordinatorState();
3099
+ if (!state) {
3100
+ throw new CoordinatorError("Cannot satisfy write concern without replication-group state");
3101
+ }
3102
+ if (wc.level === "majority") {
3103
+ await this.peerTracker.waitForConfiguredMajority(seq, this.nodeId, state.votingDataBearingNodeIds, timeout);
3104
+ await this.updateCoordinatorProgressAfterWrite(seq, state);
3105
+ } else if (wc.level === "all") {
3106
+ const eligibleVoters = state.votingDataBearingNodeIds.filter((nodeId) => !state.drainingNodeIds.includes(nodeId));
3107
+ await this.peerTracker.waitForConfiguredAll(seq, this.nodeId, eligibleVoters, timeout);
3108
+ await this.updateCoordinatorProgressAfterWrite(seq, state);
3109
+ }
3110
+ return;
3111
+ }
3112
+ if (wc.level === "majority") {
3113
+ await this.peerTracker.waitForMajority(seq, timeout);
3114
+ } else if (wc.level === "all") {
3115
+ await this.peerTracker.waitForAll(seq, timeout);
3116
+ }
3117
+ }
3118
+ async loadAppliedSeqs() {
3119
+ const stmt = await this.writerConn.prepare(
3120
+ "SELECT source_node_id, MAX(source_seq) AS max_seq FROM _sirannon_applied_changes GROUP BY source_node_id"
3121
+ );
3122
+ const rows = await stmt.all();
3123
+ for (const row of rows) {
3124
+ if (row.max_seq === null) continue;
3125
+ this.appliedSeqByPeer.set(row.source_node_id, BigInt(row.max_seq));
3126
+ }
3127
+ }
3128
+ isCoordinatorMode() {
3129
+ return this.config.coordinator !== void 0;
3130
+ }
3131
+ async startCoordinatorMode() {
3132
+ const config = this.config.coordinator;
3133
+ if (!config) return;
3134
+ const coordinator = config.coordinator;
3135
+ let state = await coordinator.getReplicationGroupState(config.clusterId, config.groupId);
3136
+ if (!state && config.votingDataBearingNodeIds) {
3137
+ state = await coordinator.setReplicationGroupState({
3138
+ clusterId: config.clusterId,
3139
+ groupId: config.groupId,
3140
+ votingDataBearingNodeIds: config.votingDataBearingNodeIds,
3141
+ currentPrimary: this.config.topology.role === "primary" ? this.localCoordinatorPrimary() : null,
3142
+ primaryTerm: 1n,
3143
+ inSyncNodeIds: [this.nodeId],
3144
+ compatibility: config.compatibility
3145
+ });
3146
+ }
3147
+ if (!state) {
3148
+ throw new CoordinatorError(`Replication group '${config.groupId}' is not registered`);
3149
+ }
3150
+ this.coordinatorState = state;
3151
+ this.coordinatorAuthority = this.hasCurrentPrimaryAuthorityFor(state);
3152
+ const session = await coordinator.registerNodeSession({
3153
+ clusterId: config.clusterId,
3154
+ nodeId: this.nodeId,
3155
+ ttlMs: config.sessionTtlMs ?? DEFAULT_COORDINATOR_SESSION_TTL_MS,
3156
+ endpoint: config.endpoint,
3157
+ groupIds: [config.groupId],
3158
+ dataBearing: true,
3159
+ voting: state.votingDataBearingNodeIds.includes(this.nodeId),
3160
+ compatibility: config.compatibility
3161
+ });
3162
+ this.nodeSessionLeaseId = session.lease.id;
3163
+ this.coordinatorWatchDisposer = await coordinator.watchReplicationGroup(config.clusterId, config.groupId, (next) => {
3164
+ this.handleCoordinatorStateUpdate(next);
3165
+ });
3166
+ this.startCoordinatorLeaseRenewal();
3167
+ this.startControllerLoop();
3168
+ }
3169
+ async prepareCoordinatorRejoinIfNeeded() {
3170
+ const state = this.coordinatorState ?? await this.refreshCoordinatorState();
3171
+ if (!state || !this.requiresCoordinatorRejoin(state)) return;
3172
+ await this.evaluateFormerPrimaryHistory(state);
3173
+ }
3174
+ hasCoordinatorWriteAuthority() {
3175
+ return this.coordinatorAuthority;
3176
+ }
3177
+ requiresCoordinatorRejoinSync(state = this.coordinatorState) {
3178
+ if (!state || state.currentPrimary?.nodeId === this.nodeId) return false;
3179
+ if (state.faultedNodeIds.includes(this.nodeId)) return false;
3180
+ return state.repairingNodeIds.includes(this.nodeId);
3181
+ }
3182
+ async markCoordinatorSyncReady() {
3183
+ const config = this.config.coordinator;
3184
+ const state = this.coordinatorState ?? await this.refreshCoordinatorState();
3185
+ if (!config || !state || state.currentPrimary?.nodeId === this.nodeId) return;
3186
+ if (!state.votingDataBearingNodeIds.includes(this.nodeId)) return;
3187
+ if (state.faultedNodeIds.includes(this.nodeId)) return;
3188
+ if (state.drainingNodeIds.includes(this.nodeId)) {
3189
+ await this.removeLocalNodeFromCoordinatorInSyncSet(state);
3190
+ return;
3191
+ }
3192
+ const needsAdmission = !state.inSyncNodeIds.includes(this.nodeId) || state.repairingNodeIds.includes(this.nodeId);
3193
+ if (!needsAdmission) {
3194
+ this.coordinatorState = state;
3195
+ this.coordinatorAuthority = this.hasCurrentPrimaryAuthorityFor(state);
3196
+ return;
3197
+ }
3198
+ const sourceNodeId = state.currentPrimary?.nodeId;
3199
+ if (!sourceNodeId || this.syncState.sourcePeerId !== sourceNodeId) {
3200
+ this.coordinatorState = state;
3201
+ this.coordinatorAuthority = this.hasCurrentPrimaryAuthorityFor(state);
3202
+ return;
3203
+ }
3204
+ const appliedSeq = await this.log.getLastAppliedSeq(sourceNodeId);
3205
+ if (appliedSeq < state.durabilityPointSeq) {
3206
+ this.coordinatorState = state;
3207
+ this.coordinatorAuthority = this.hasCurrentPrimaryAuthorityFor(state);
3208
+ return;
3209
+ }
3210
+ const admitted = await config.coordinator.admitNodeToInSyncSet({
3211
+ clusterId: config.clusterId,
3212
+ groupId: config.groupId,
3213
+ nodeId: this.nodeId,
3214
+ sourceNodeId,
3215
+ appliedSeq
3216
+ });
3217
+ this.coordinatorState = admitted ?? state;
3218
+ this.coordinatorAuthority = this.hasCurrentPrimaryAuthorityFor(this.coordinatorState);
3219
+ }
3220
+ async handleCoordinatorAckProgress(nodeId, ackedSeq) {
3221
+ const config = this.config.coordinator;
3222
+ const state = this.coordinatorState;
3223
+ if (!config || !state) return;
3224
+ if (nodeId === this.nodeId) return;
3225
+ if (!this.hasCurrentPrimaryAuthorityFor(state)) return;
3226
+ if (state.inSyncNodeIds.includes(nodeId)) return;
3227
+ if (!state.votingDataBearingNodeIds.includes(nodeId)) return;
3228
+ if (ackedSeq < state.durabilityPointSeq) return;
3229
+ const admitted = await config.coordinator.admitNodeToInSyncSet({
3230
+ clusterId: config.clusterId,
3231
+ groupId: config.groupId,
3232
+ nodeId,
3233
+ sourceNodeId: this.nodeId,
3234
+ appliedSeq: ackedSeq
3235
+ });
3236
+ if (!admitted) return;
3237
+ this.coordinatorState = admitted;
3238
+ this.coordinatorAuthority = this.hasCurrentPrimaryAuthorityFor(admitted);
3239
+ }
3240
+ async removeLocalNodeFromCoordinatorInSyncSet(state) {
3241
+ const config = this.config.coordinator;
3242
+ if (!config) return;
3243
+ const inSyncNodeIds = state.inSyncNodeIds.filter((nodeId) => nodeId !== this.nodeId);
3244
+ let nextState = state;
3245
+ if (!arraysEqual(inSyncNodeIds, state.inSyncNodeIds)) {
3246
+ const updated = await config.coordinator.updateInSyncSet({
3247
+ clusterId: config.clusterId,
3248
+ groupId: config.groupId,
3249
+ inSyncNodeIds
3250
+ });
3251
+ if (!updated) {
3252
+ throw new CoordinatorError("Failed to mark repaired node as in sync");
3253
+ }
3254
+ nextState = updated;
3255
+ }
3256
+ this.coordinatorState = nextState;
3257
+ this.coordinatorAuthority = this.hasCurrentPrimaryAuthorityFor(this.coordinatorState);
3258
+ }
3259
+ async verifyPrimaryAuthority() {
3260
+ const state = await this.refreshCoordinatorState();
3261
+ if (!state) {
3262
+ throw new CoordinatorError("Cannot prove primary authority without replication-group state");
3263
+ }
3264
+ if (!this.hasCurrentPrimaryAuthorityFor(state)) {
3265
+ throw new StalePrimaryError(
3266
+ "Node is not the current primary for this replication group",
3267
+ this.errorDetails(state)
3268
+ );
3269
+ }
3270
+ this.assertLocalCompatibility(state);
3271
+ if (state.drainingNodeIds.includes(this.nodeId)) {
3272
+ throw new NodeDrainingError("Node is draining and cannot accept writes", this.errorDetails(state));
3273
+ }
3274
+ if (state.repairingNodeIds.includes(this.nodeId) || state.faultedNodeIds.includes(this.nodeId)) {
3275
+ throw new AuthorityError(
3276
+ "Node is not eligible to accept writes while repair or fault state is active",
3277
+ "AUTHORITY_LOST",
3278
+ this.errorDetails(state)
3279
+ );
3280
+ }
3281
+ return state;
3282
+ }
3283
+ async assertInboundCoordinatorMessage(message, fromPeerId, direction) {
3284
+ const config = this.config.coordinator;
3285
+ if (!config) return;
3286
+ const state = this.coordinatorState ?? await this.refreshCoordinatorState();
3287
+ if (!state) {
3288
+ throw new CoordinatorError("Cannot validate coordinator message without replication-group state");
3289
+ }
3290
+ if (message.groupId !== config.groupId || message.primaryTerm !== state.primaryTerm) {
3291
+ throw new StalePrimaryError(
3292
+ `Rejected ${direction} message for stale or wrong replication group term`,
3293
+ this.errorDetails(state)
3294
+ );
3295
+ }
3296
+ if ((direction === "batch" || direction === "sync-data") && fromPeerId !== state.currentPrimary?.nodeId) {
3297
+ throw new StalePrimaryError(
3298
+ `Rejected ${direction} message from non-current primary '${fromPeerId}'`,
3299
+ this.errorDetails(state)
3300
+ );
3301
+ }
3302
+ if ((direction === "ack" || direction === "forward" || direction === "sync-request") && !this.hasCurrentPrimaryAuthorityFor(state)) {
3303
+ throw new StalePrimaryError(
3304
+ `Rejected ${direction} message because this node is not current primary`,
3305
+ this.errorDetails(state)
3306
+ );
3307
+ }
3308
+ }
3309
+ decorateBatch(batch) {
3310
+ return { ...batch, ...this.getCoordinatorMessageFields() };
3311
+ }
3312
+ decorateAck(ack) {
3313
+ return { ...ack, ...this.getCoordinatorMessageFields() };
3314
+ }
3315
+ decorateForwardResult(result) {
3316
+ return { ...result, ...this.getCoordinatorMessageFields() };
3317
+ }
3318
+ decorateSyncRequest(request) {
3319
+ return { ...request, ...this.getCoordinatorMessageFields() };
3320
+ }
3321
+ decorateSyncBatch(batch) {
3322
+ return { ...batch, ...this.getCoordinatorMessageFields() };
3323
+ }
3324
+ decorateSyncComplete(complete) {
3325
+ return { ...complete, ...this.getCoordinatorMessageFields() };
3326
+ }
3327
+ decorateSyncAck(ack) {
3328
+ return { ...ack, ...this.getCoordinatorMessageFields() };
3329
+ }
3330
+ resolveWriteConcern(wc) {
3331
+ if (wc) return wc;
3332
+ if (this.isCoordinatorMode()) {
3333
+ return { level: "majority" };
3334
+ }
3335
+ return void 0;
3336
+ }
3337
+ getCurrentPrimaryPeerId() {
3338
+ return this.getForwardingPrimaryPeerId();
3339
+ }
3340
+ async canAcceptLocalWrite() {
3341
+ if (!this.isCoordinatorMode()) {
3342
+ return this.config.topology.canWrite();
3343
+ }
3344
+ const state = await this.refreshCoordinatorState();
3345
+ if (!state) {
3346
+ throw new CoordinatorError("Cannot prove write authority without replication-group state");
3347
+ }
3348
+ if (!this.hasCurrentPrimaryAuthorityFor(state)) {
3349
+ return false;
3350
+ }
3351
+ this.assertLocalCompatibility(state);
3352
+ if (state.drainingNodeIds.includes(this.nodeId)) {
3353
+ throw new NodeDrainingError("Node is draining and cannot accept writes", this.errorDetails(state));
3354
+ }
3355
+ if (state.repairingNodeIds.includes(this.nodeId) || state.faultedNodeIds.includes(this.nodeId)) {
3356
+ throw new AuthorityError(
3357
+ "Node is not eligible to accept writes while repair or fault state is active",
3358
+ "AUTHORITY_LOST",
3359
+ this.errorDetails(state)
3360
+ );
3361
+ }
3362
+ return true;
3363
+ }
3364
+ throwNotCurrentPrimary() {
3365
+ if (this.isCoordinatorMode()) {
3366
+ throw new StalePrimaryError("This node is not the current primary", this.errorDetails(this.coordinatorState));
3367
+ }
3368
+ throw new TopologyError("This node cannot accept writes");
3369
+ }
3370
+ async assertReadConcern(level) {
3371
+ if (!this.isCoordinatorMode()) return;
3372
+ const readConcern = level ?? "majority";
3373
+ if (readConcern === "local") return;
3374
+ const state = await this.refreshCoordinatorState();
3375
+ if (!state) {
3376
+ throw new CoordinatorError("Cannot satisfy read concern without replication-group state");
3377
+ }
3378
+ if (readConcern === "linearizable") {
3379
+ await this.verifyPrimaryAuthority();
3380
+ return;
3381
+ }
3382
+ if (readConcern === "majority") {
3383
+ if (!state.inSyncNodeIds.includes(this.nodeId)) {
3384
+ throw new NodeNotInSyncError("Node is not in the in-sync set for majority reads", this.errorDetails(state));
3385
+ }
3386
+ if (state.drainingNodeIds.includes(this.nodeId) || state.repairingNodeIds.includes(this.nodeId)) {
3387
+ throw new ReadConcernError(
3388
+ "Node cannot serve majority reads while draining or repairing",
3389
+ this.errorDetails(state)
3390
+ );
3391
+ }
3392
+ return;
3393
+ }
3394
+ throw new ReadConcernError(`Unsupported read concern '${readConcern}'`, this.errorDetails(state));
3395
+ }
3396
+ async refreshCoordinatorState() {
3397
+ const config = this.config.coordinator;
3398
+ if (!config) return null;
3399
+ try {
3400
+ const state = await config.coordinator.getReplicationGroupState(config.clusterId, config.groupId);
3401
+ this.coordinatorState = state;
3402
+ this.coordinatorAuthority = state ? this.hasCurrentPrimaryAuthorityFor(state) : false;
3403
+ return state;
3404
+ } catch {
3405
+ this.coordinatorAuthority = false;
3406
+ throw new CoordinatorError(
3407
+ "Coordinator is unavailable while refreshing replication-group authority",
3408
+ this.errorDetails(this.coordinatorState)
3409
+ );
3410
+ }
3411
+ }
3412
+ hasCurrentPrimaryAuthorityFor(state) {
3413
+ return state.currentPrimary?.nodeId === this.nodeId;
3414
+ }
3415
+ getCoordinatorMessageFields() {
3416
+ const config = this.config.coordinator;
3417
+ if (!config) return {};
3418
+ return {
3419
+ groupId: config.groupId,
3420
+ primaryTerm: this.coordinatorState?.primaryTerm ?? this.config.transportConfig?.primaryTerm
3421
+ };
3422
+ }
3423
+ getForwardingPrimaryPeerId() {
3424
+ if (this.isCoordinatorMode()) {
3425
+ const primaryId = this.coordinatorState?.currentPrimary?.nodeId;
3426
+ if (primaryId && this.config.transport.peers().has(primaryId)) {
3427
+ return primaryId;
3428
+ }
3429
+ return null;
3430
+ }
3431
+ for (const [peerId, info] of this.config.transport.peers()) {
3432
+ if (info.role === "primary") {
3433
+ return peerId;
3434
+ }
3435
+ }
3436
+ return null;
3437
+ }
3438
+ async updateCoordinatorProgressAfterWrite(seq, state) {
3439
+ const config = this.config.coordinator;
3440
+ if (!config) return;
3441
+ const ackedNodeIds = this.peerTracker.ackedConfiguredNodeIds(seq, this.nodeId, state.votingDataBearingNodeIds);
3442
+ const retainedInSync = state.inSyncNodeIds.filter((nodeId) => ackedNodeIds.includes(nodeId));
3443
+ let nextState = state;
3444
+ if (!arraysEqual(retainedInSync, state.inSyncNodeIds) || state.durabilityPointSeq < seq) {
3445
+ const updated = await config.coordinator.updateInSyncSet({
3446
+ clusterId: config.clusterId,
3447
+ groupId: config.groupId,
3448
+ inSyncNodeIds: retainedInSync,
3449
+ durabilityPointSeq: seq
3450
+ });
3451
+ if (!updated) {
3452
+ throw new CoordinatorError("Failed to update in-sync set before acknowledging majority write");
3453
+ }
3454
+ nextState = updated;
3455
+ }
3456
+ for (const nodeId of ackedNodeIds) {
3457
+ if (nextState.inSyncNodeIds.includes(nodeId)) continue;
3458
+ if (nodeId === this.nodeId) continue;
3459
+ const admitted = await config.coordinator.admitNodeToInSyncSet({
3460
+ clusterId: config.clusterId,
3461
+ groupId: config.groupId,
3462
+ nodeId,
3463
+ sourceNodeId: this.nodeId,
3464
+ appliedSeq: seq
3465
+ });
3466
+ if (!admitted) {
3467
+ throw new CoordinatorError("Failed to admit ACKing node to in-sync set before acknowledging majority write");
3468
+ }
3469
+ nextState = admitted;
3470
+ }
3471
+ this.coordinatorState = nextState;
3472
+ }
3473
+ startCoordinatorLeaseRenewal() {
3474
+ const config = this.config.coordinator;
3475
+ const leaseId = this.nodeSessionLeaseId;
3476
+ if (!config || !leaseId) return;
3477
+ const ttlMs = config.sessionTtlMs ?? DEFAULT_COORDINATOR_SESSION_TTL_MS;
3478
+ const timer = setInterval(
3479
+ () => {
3480
+ config.coordinator.renewLease(leaseId, ttlMs).then((renewed) => {
3481
+ if (!renewed) {
3482
+ this.coordinatorAuthority = false;
3483
+ }
3484
+ }).catch((err) => {
3485
+ this.coordinatorAuthority = false;
3486
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
3487
+ this.emitError({ error: wrappedErr, operation: "coordinator-session-renew", recoverable: false });
3488
+ });
3489
+ },
3490
+ Math.max(1e3, Math.floor(ttlMs / 3))
3491
+ );
3492
+ this.unrefTimer(timer);
3493
+ this.coordinatorLeaseTimer = timer;
3494
+ }
3495
+ startControllerLoop() {
3496
+ const config = this.config.coordinator;
3497
+ if (!config) return;
3498
+ const controllerConfig = typeof config.controller === "object" ? config.controller : {};
3499
+ const enabled = typeof config.controller === "boolean" ? config.controller : controllerConfig.enabled ?? true;
3500
+ if (!enabled) {
3501
+ this.controllerState = "disabled";
3502
+ return;
3503
+ }
3504
+ this.controllerState = "standby";
3505
+ const ttlMs = controllerConfig.leaseTtlMs ?? DEFAULT_CONTROLLER_LEASE_TTL_MS;
3506
+ const holderId = controllerConfig.holderId ?? this.nodeId;
3507
+ const tickMs = controllerConfig.tickIntervalMs ?? DEFAULT_CONTROLLER_TICK_INTERVAL_MS;
3508
+ const timer = setInterval(() => {
3509
+ this.controllerTick(holderId, ttlMs).catch((err) => {
3510
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
3511
+ this.controllerState = "lost";
3512
+ this.emitError({ error: wrappedErr, operation: "coordinator-controller", recoverable: true });
3513
+ });
3514
+ }, tickMs);
3515
+ this.unrefTimer(timer);
3516
+ this.controllerTimer = timer;
3517
+ }
3518
+ async controllerTick(holderId, ttlMs) {
3519
+ const config = this.config.coordinator;
3520
+ if (!config) return;
3521
+ if (this.controllerLeaseId) {
3522
+ const renewed = await config.coordinator.renewLease(this.controllerLeaseId, ttlMs);
3523
+ if (!renewed) {
3524
+ this.controllerLeaseId = null;
3525
+ this.controllerState = "lost";
3526
+ return;
3527
+ }
3528
+ this.controllerState = "active";
3529
+ await this.runControllerPromotionCheck();
3530
+ return;
3531
+ }
3532
+ const acquired = await config.coordinator.tryAcquireControllerLease({
3533
+ clusterId: config.clusterId,
3534
+ holderId,
3535
+ ttlMs
3536
+ });
3537
+ if (acquired.acquired) {
3538
+ this.controllerLeaseId = acquired.lease.id;
3539
+ this.controllerState = "active";
3540
+ await this.runControllerPromotionCheck();
3541
+ } else {
3542
+ this.controllerState = "standby";
3543
+ }
3544
+ }
3545
+ async runControllerPromotionCheck() {
3546
+ const config = this.config.coordinator;
3547
+ if (!config) return;
3548
+ const state = await this.refreshCoordinatorState();
3549
+ if (!state) return;
3550
+ const primaryNodeId = state.currentPrimary?.nodeId;
3551
+ const primaryLive = primaryNodeId ? await config.coordinator.getLiveNodeSession(config.clusterId, primaryNodeId) : null;
3552
+ const primaryCanKeepDuty = primaryNodeId && primaryLive && compatibilityAllowsPromotion(state.compatibility, primaryLive.compatibility) && !state.drainingNodeIds.includes(primaryNodeId) && !state.repairingNodeIds.includes(primaryNodeId) && !state.faultedNodeIds.includes(primaryNodeId);
3553
+ if (primaryCanKeepDuty) {
3554
+ return;
3555
+ }
3556
+ try {
3557
+ const promoted = await config.coordinator.promoteEligibleReplica({
3558
+ clusterId: config.clusterId,
3559
+ groupId: config.groupId,
3560
+ excludeNodeIds: primaryNodeId ? [primaryNodeId] : []
3561
+ });
3562
+ this.coordinatorState = promoted;
3563
+ this.coordinatorAuthority = this.hasCurrentPrimaryAuthorityFor(promoted);
3564
+ } catch (err) {
3565
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
3566
+ this.emitError({ error: wrappedErr, operation: "coordinator-promotion", recoverable: true });
3567
+ }
3568
+ }
3569
+ handleCoordinatorStateUpdate(next) {
3570
+ const previous = this.coordinatorState;
3571
+ const wasPrimary = previous ? this.hasCurrentPrimaryAuthorityFor(previous) : this.coordinatorAuthority;
3572
+ this.coordinatorState = next;
3573
+ this.coordinatorAuthority = this.hasCurrentPrimaryAuthorityFor(next);
3574
+ if (wasPrimary && !this.coordinatorAuthority && next.primaryTerm > (previous?.primaryTerm ?? 0n)) {
3575
+ this.handleFormerPrimaryDemotion(next).catch((err) => {
3576
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
3577
+ this.emitError({ error: wrappedErr, operation: "coordinator-former-primary-demotion", recoverable: false });
3578
+ });
3579
+ return;
3580
+ }
3581
+ this.startCoordinatorRejoinSyncIfReady(next);
3582
+ }
3583
+ startCoordinatorRejoinSyncIfReady(state) {
3584
+ if (!this.running) return;
3585
+ if (this.coordinatorRejoinSyncStarting) return;
3586
+ if (!this.requiresCoordinatorRejoinSync(state)) return;
3587
+ if (this.syncState.phase !== "pending") return;
3588
+ const sourceNodeId = state.currentPrimary?.nodeId;
3589
+ if (!sourceNodeId || !this.config.transport.peers().has(sourceNodeId)) return;
3590
+ this.coordinatorRejoinSyncStarting = true;
3591
+ this.syncJoiner.initiateSync().catch((err) => {
3592
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
3593
+ this.emitError({ error: wrappedErr, operation: "coordinator-rejoin-sync", recoverable: true });
3594
+ }).finally(() => {
3595
+ this.coordinatorRejoinSyncStarting = false;
3596
+ });
3597
+ }
3598
+ async handleFormerPrimaryDemotion(state) {
3599
+ const repairingState = await this.markLocalNodeRepairing(state);
3600
+ const evaluatedState = await this.evaluateFormerPrimaryHistory(repairingState);
3601
+ if (!this.requiresCoordinatorRejoinSync(evaluatedState)) return;
3602
+ if (!this.running || this.syncState.phase === "syncing" || this.syncState.phase === "catching-up") return;
3603
+ this.syncState = {
3604
+ phase: "pending",
3605
+ sourcePeerId: null,
3606
+ snapshotSeq: null,
3607
+ completedTables: [],
3608
+ totalTables: 0,
3609
+ startedAt: null,
3610
+ error: null
3611
+ };
3612
+ await this.log.setSyncMeta("pending");
3613
+ await this.syncJoiner.initiateSync();
3614
+ }
3615
+ async markLocalNodeRepairing(state) {
3616
+ const config = this.config.coordinator;
3617
+ if (!config || state.repairingNodeIds.includes(this.nodeId) || state.faultedNodeIds.includes(this.nodeId)) {
3618
+ return state;
3619
+ }
3620
+ const updated = await config.coordinator.updateNodeMaintenance({
3621
+ clusterId: config.clusterId,
3622
+ groupId: config.groupId,
3623
+ nodeId: this.nodeId,
3624
+ repairing: true
3625
+ });
3626
+ if (!updated) {
3627
+ throw new CoordinatorError("Failed to mark former primary for repair");
3628
+ }
3629
+ this.coordinatorState = updated;
3630
+ this.coordinatorAuthority = this.hasCurrentPrimaryAuthorityFor(updated);
3631
+ return updated;
3632
+ }
3633
+ requiresCoordinatorRejoin(state) {
3634
+ if (state.currentPrimary?.nodeId === this.nodeId) return false;
3635
+ return state.repairingNodeIds.includes(this.nodeId) || state.faultedNodeIds.includes(this.nodeId);
3636
+ }
3637
+ assertLocalCompatibility(state) {
3638
+ if (compatibilityAllowsPromotion(state.compatibility, this.config.coordinator?.compatibility)) return;
3639
+ throw new ProtocolVersionMismatchError("Node compatibility metadata is incompatible with the replication group", {
3640
+ ...this.errorDetails(state),
3641
+ groupCompatibility: state.compatibility,
3642
+ localCompatibility: this.config.coordinator?.compatibility
3643
+ });
3644
+ }
3645
+ async evaluateFormerPrimaryHistory(state) {
3646
+ const config = this.config.coordinator;
3647
+ const currentPrimary = state.currentPrimary;
3648
+ if (!config || !currentPrimary || state.faultedNodeIds.includes(this.nodeId)) return state;
3649
+ const localSeq = await this.log.getLocalSeq();
3650
+ const currentPrimaryAckedSeq = await this.log.getPeerAckedSeq(currentPrimary.nodeId);
3651
+ if (localSeq <= currentPrimaryAckedSeq) {
3652
+ return state;
3653
+ }
3654
+ const inSyncNodeIds = state.inSyncNodeIds.filter((nodeId) => nodeId !== this.nodeId);
3655
+ let nextState = state;
3656
+ if (inSyncNodeIds.length !== state.inSyncNodeIds.length) {
3657
+ const updated = await config.coordinator.updateInSyncSet({
3658
+ clusterId: config.clusterId,
3659
+ groupId: config.groupId,
3660
+ inSyncNodeIds
3661
+ });
3662
+ if (!updated) {
3663
+ throw new CoordinatorError("Failed to remove divergent former primary from in-sync set");
3664
+ }
3665
+ nextState = updated;
3666
+ }
3667
+ const faulted = await config.coordinator.updateNodeMaintenance({
3668
+ clusterId: config.clusterId,
3669
+ groupId: config.groupId,
3670
+ nodeId: this.nodeId,
3671
+ repairing: false,
3672
+ faulted: true
3673
+ });
3674
+ nextState = faulted ?? nextState;
3675
+ this.coordinatorState = nextState;
3676
+ this.coordinatorAuthority = false;
3677
+ this.emitError({
3678
+ error: new AuthorityError(
3679
+ "Former primary returned with local-only writes and was quarantined",
3680
+ "AUTHORITY_LOST",
3681
+ {
3682
+ ...this.errorDetails(nextState),
3683
+ localSeq: localSeq.toString(),
3684
+ currentPrimaryAckedSeq: currentPrimaryAckedSeq.toString()
3685
+ }
3686
+ ),
3687
+ operation: "coordinator-former-primary-divergence",
3688
+ recoverable: false
3689
+ });
3690
+ return nextState;
3691
+ }
3692
+ stopCoordinatorTimers() {
3693
+ if (this.coordinatorLeaseTimer) {
3694
+ clearInterval(this.coordinatorLeaseTimer);
3695
+ this.coordinatorLeaseTimer = null;
3696
+ }
3697
+ if (this.controllerTimer) {
3698
+ clearInterval(this.controllerTimer);
3699
+ this.controllerTimer = null;
3700
+ }
3701
+ }
3702
+ async stopCoordinatorMode() {
3703
+ const config = this.config.coordinator;
3704
+ if (!config) return;
3705
+ if (this.coordinatorWatchDisposer) {
3706
+ await this.coordinatorWatchDisposer();
3707
+ this.coordinatorWatchDisposer = null;
3708
+ }
3709
+ if (this.controllerLeaseId) {
3710
+ const leaseId = this.controllerLeaseId;
3711
+ this.controllerLeaseId = null;
3712
+ await config.coordinator.releaseLease(leaseId).catch((err) => {
3713
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
3714
+ this.emitError({ error: wrappedErr, operation: "coordinator-controller-release", recoverable: true });
3715
+ });
3716
+ }
3717
+ await config.coordinator.deregisterNodeSession(config.clusterId, this.nodeId).catch((err) => {
3718
+ const wrappedErr = err instanceof Error ? err : new Error(String(err));
3719
+ this.emitError({ error: wrappedErr, operation: "coordinator-session-deregister", recoverable: true });
3720
+ });
3721
+ }
3722
+ localCoordinatorPrimary() {
3723
+ const endpoint = this.config.coordinator?.endpoint;
3724
+ return endpoint ? { nodeId: this.nodeId, endpoint } : { nodeId: this.nodeId };
3725
+ }
3726
+ unrefTimer(timer) {
3727
+ const unref = timer.unref;
3728
+ unref?.call(timer);
3729
+ }
3730
+ getCoordinatorRuntimeStatus() {
3731
+ const config = this.config.coordinator;
3732
+ const state = this.coordinatorState;
3733
+ if (!config || !state) return void 0;
3734
+ return {
3735
+ clusterId: config.clusterId,
3736
+ groupId: config.groupId,
3737
+ currentPrimary: state.currentPrimary ? { ...state.currentPrimary } : null,
3738
+ primaryTerm: state.primaryTerm,
3739
+ inSyncNodeIds: [...state.inSyncNodeIds],
3740
+ drainingNodeIds: [...state.drainingNodeIds],
3741
+ repairingNodeIds: [...state.repairingNodeIds],
3742
+ faultedNodeIds: [...state.faultedNodeIds],
3743
+ votingDataBearingNodeIds: [...state.votingDataBearingNodeIds],
3744
+ authority: this.coordinatorAuthority,
3745
+ controllerState: this.controllerState
3746
+ };
3747
+ }
3748
+ errorDetails(state) {
3749
+ return {
3750
+ currentPrimary: state?.currentPrimary ?? null,
3751
+ primaryTerm: state?.primaryTerm.toString(),
3752
+ replicationGroupId: this.config.coordinator?.groupId
3753
+ };
3754
+ }
3755
+ };
3756
+ function arraysEqual(left, right) {
3757
+ if (left.length !== right.length) return false;
3758
+ for (let i = 0; i < left.length; i++) {
3759
+ if (left[i] !== right[i]) return false;
3760
+ }
3761
+ return true;
3762
+ }
3763
+
3764
+ // src/replication/topology/primary-replica.ts
3765
+ var PrimaryReplicaTopology = class {
3766
+ role;
3767
+ constructor(role) {
3768
+ this.role = role;
3769
+ }
3770
+ canWrite() {
3771
+ return this.role === "primary";
3772
+ }
3773
+ shouldReplicateTo(_peerId, peerRole) {
3774
+ return this.role === "primary" && peerRole === "replica";
3775
+ }
3776
+ shouldAcceptFrom(_peerId, peerRole) {
3777
+ return this.role === "replica" && peerRole === "primary";
3778
+ }
3779
+ requiresConflictResolution() {
3780
+ return false;
3781
+ }
3782
+ };
3783
+
3784
+ export { FieldMergeResolver, HLC, LWWResolver, PeerTracker, PrimaryReplicaTopology, PrimaryWinsResolver, ReplicationEngine, ReplicationLog, generateNodeId, validateNodeId };