@objy/server 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/API.md +1469 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +9 -0
  4. package/README.md +21 -2
  5. package/objydb.config.json +48 -0
  6. package/package.json +42 -4
  7. package/public/index.html +4134 -0
  8. package/public/objy-db-logo.png +0 -0
  9. package/src/aggregate.js +438 -0
  10. package/src/api.js +15 -0
  11. package/src/auth.js +472 -0
  12. package/src/clock.js +18 -0
  13. package/src/cluster-secret.js +25 -0
  14. package/src/cluster-status.js +562 -0
  15. package/src/compression-stats.js +32 -0
  16. package/src/config.js +360 -0
  17. package/src/crdt.js +1076 -0
  18. package/src/derived-state-scheduler.js +128 -0
  19. package/src/files.js +324 -0
  20. package/src/hnsw.js +269 -0
  21. package/src/http-cors.js +31 -0
  22. package/src/http-router.js +162 -0
  23. package/src/index.js +137 -0
  24. package/src/interfaces/auth.js +123 -0
  25. package/src/interfaces/cluster.js +296 -0
  26. package/src/interfaces/public-object.js +2452 -0
  27. package/src/interfaces/ui.js +38 -0
  28. package/src/lib/crypto.cjs +54 -0
  29. package/src/lib/jwt.cjs +231 -0
  30. package/src/local-wal.js +122 -0
  31. package/src/memory-guard.js +152 -0
  32. package/src/ndjson.js +117 -0
  33. package/src/node-runtime.js +29 -0
  34. package/src/object-hooks.js +282 -0
  35. package/src/object-id.js +7 -0
  36. package/src/object-storage.js +263 -0
  37. package/src/object-store.js +288 -0
  38. package/src/patch.js +87 -0
  39. package/src/permissions.js +178 -0
  40. package/src/persistence.js +1918 -0
  41. package/src/plugin-loader.js +21 -0
  42. package/src/query-planner.js +266 -0
  43. package/src/query.js +416 -0
  44. package/src/references.js +349 -0
  45. package/src/replication-scheduler.js +303 -0
  46. package/src/restore-status.js +77 -0
  47. package/src/runtime.js +47 -0
  48. package/src/server.js +242 -0
  49. package/src/shape.js +286 -0
  50. package/src/shard-map-store.js +85 -0
  51. package/src/shard-map.js +333 -0
  52. package/src/shard-router.js +737 -0
  53. package/src/store.js +3410 -0
  54. package/src/system-secrets.js +107 -0
  55. package/src/vector.js +83 -0
@@ -0,0 +1,1918 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { setImmediate as yieldToEventLoop } from "node:timers/promises";
3
+ import {
4
+ DEFAULT_SHARD,
5
+ GLOBAL_COLLECTION,
6
+ GLOBAL_DATABASE,
7
+ isTopologyResharding,
8
+ topologySourceShards,
9
+ } from "./shard-map.js";
10
+ import { LocalWalStore } from "./local-wal.js";
11
+ import { createObjectStorage } from "./object-storage.js";
12
+ import { DEFAULT_DATA_PREFIX, DEFAULT_LOCAL_WAL_DIR } from "./config.js";
13
+
14
+ const DEFAULT_SEGMENT_BYTES = 16 * 1024 * 1024;
15
+ const DEFAULT_JOURNAL_BYTES = 4 * 1024 * 1024;
16
+ const RESTORE_APPLY_BATCH_SIZE = 10_000;
17
+ const RESTORE_PARSE_YIELD_LINES = 1000;
18
+ let snapshotSequence = 0;
19
+
20
+ export class PersistenceManager {
21
+ constructor({
22
+ node,
23
+ storage,
24
+ prefix = DEFAULT_DATA_PREFIX,
25
+ flushMs = 5000,
26
+ opBatchSize = 1000,
27
+ journalMaxBytes = DEFAULT_JOURNAL_BYTES,
28
+ snapshotMs = 60_000,
29
+ segmentBytes = DEFAULT_SEGMENT_BYTES,
30
+ compactJournals = true,
31
+ snapshotRetention,
32
+ journalRetention,
33
+ snapshotDirtyOnly = true,
34
+ snapshotRole = "primary",
35
+ restoreScopes,
36
+ restoreSnapshotId,
37
+ ack = "memory",
38
+ localWal,
39
+ durable = false,
40
+ } = {}) {
41
+ if (!node) throw new Error("node is required");
42
+ if (!storage) throw new Error("storage is required");
43
+ this.node = node;
44
+ this.storage = storage;
45
+ this.prefix = normalizePrefix(prefix);
46
+ this.flushMs = Math.max(0, Number(flushMs) || 0);
47
+ this.opBatchSize = Math.max(1, Number(opBatchSize) || 1000);
48
+ this.journalMaxBytes = Math.max(1024, Number(journalMaxBytes) || DEFAULT_JOURNAL_BYTES);
49
+ this.snapshotMs = Math.max(0, Number(snapshotMs) || 0);
50
+ this.segmentBytes = Math.max(1024, Number(segmentBytes) || DEFAULT_SEGMENT_BYTES);
51
+ this.compactJournals = compactJournals !== false;
52
+ this.snapshotRetention = normalizeRetention(snapshotRetention);
53
+ this.journalRetention = normalizeRetention(journalRetention);
54
+ this.snapshotDirtyOnly = snapshotDirtyOnly !== false;
55
+ this.snapshotRole = normalizeSnapshotRole(snapshotRole);
56
+ this.restoreScopes = restoreScopes;
57
+ this.restoreSnapshotId = normalizeSnapshotId(restoreSnapshotId);
58
+ this.durable = durable === true;
59
+ this.localWal = createLocalWalStore(localWal, node.id);
60
+ this.ack = normalizeAckMode(ack);
61
+ if (this.ack === "localWal" && !this.localWal) {
62
+ throw new Error("persistence.ack localWal requires persistence.localWal.mode on");
63
+ }
64
+ this.pending = new Map();
65
+ this.pendingBytes = 0;
66
+ this.dirtyScopes = new Map();
67
+ this.pendingDatabases = new Map();
68
+ this.pendingDatabaseDrops = new Set();
69
+ this.pendingMeta = new Map();
70
+ this.pendingCollectionDrops = new Set();
71
+ this.localWalPending = [];
72
+ this.localWalPendingBytes = 0;
73
+ this.flushTimer = null;
74
+ this.localWalTimer = null;
75
+ this.snapshotTimer = null;
76
+ this.unsubscribe = null;
77
+ this.flushing = null;
78
+ this.localWalFlushing = null;
79
+ this.checkpointing = null;
80
+ this.flushPaused = false;
81
+ this.lastError = null;
82
+ this.reshardCollections = null;
83
+ }
84
+
85
+ async restore(options = {}) {
86
+ const progress = options.progress || null;
87
+ progress?.start?.({
88
+ mode: this._restoreMode(),
89
+ snapshotId: this.restoreSnapshotId || null,
90
+ });
91
+ const root = this._rootPrefix();
92
+ const stats = restoreStats();
93
+ let maxLocalSeq = this.node.seq;
94
+
95
+ try {
96
+ progress?.update?.({ phase: "listing" });
97
+ const keys = await this.storage.list(root);
98
+ const deletedDatabases = databaseTombstones(keys, this.prefix);
99
+ const tombstones = collectionTombstones(keys, this.prefix);
100
+ const scopes = this._filterRestoreScopes(uniqueScopes(keys, this.prefix))
101
+ .filter((scope) => !deletedDatabases.has(scope.database))
102
+ .filter((scope) => !tombstones.has(collectionKey(scope.database, scope.collection)));
103
+ if (this._isManagedReshard()) this._rememberReshardCollections(scopes);
104
+ progress?.update?.({
105
+ phase: "metadata",
106
+ scopesTotal: scopes.length,
107
+ totalKeys: keys.length,
108
+ });
109
+
110
+ this.node.db.beginBulkLoad?.();
111
+ stats.databaseTombstones = this._restoreDatabaseTombstones(deletedDatabases);
112
+ stats.collectionTombstones = this._restoreCollectionTombstones(tombstones);
113
+ stats.databases = await this._restoreDatabaseMetadata(keys);
114
+ stats.metadata = await this._restoreMetadata(keys);
115
+ maxLocalSeq = await this._restoreScopeSet(keys, scopes, stats, maxLocalSeq, progress);
116
+
117
+ if (this.localWal && !this.restoreSnapshotId) {
118
+ progress?.update?.({ phase: "local-wal" });
119
+ const local = await this._restoreLocalWal();
120
+ stats.localWalRecords = local.records;
121
+ stats.localWalOps = local.ops;
122
+ stats.localWalDatabases = local.databases;
123
+ stats.localWalDatabaseDrops = local.databaseDrops;
124
+ stats.localWalMetadata = local.metadata;
125
+ stats.localWalCollectionDrops = local.collectionDrops;
126
+ stats.localWalSkippedPartialRecords = local.skippedPartialRecords;
127
+ progress?.add?.("localWalOps", local.ops);
128
+ maxLocalSeq = Math.max(maxLocalSeq, local.maxLocalSeq);
129
+ }
130
+ } catch (err) {
131
+ progress?.fail?.(err);
132
+ throw err;
133
+ } finally {
134
+ this.node.db.endBulkLoad?.();
135
+ }
136
+
137
+ this.node.seq = Math.max(this.node.seq, maxLocalSeq);
138
+ if (this.node.log.length === 0) this.node.trimmed = Math.max(this.node.trimmed, this.node.seq);
139
+ if (this._isManagedReshard()) {
140
+ this._assertDisjointReshardShards();
141
+ stats.reshardCheckpoint = await this.checkpoint({
142
+ primaryOnly: true,
143
+ shards: this._restoreTargetShardNames(),
144
+ });
145
+ }
146
+ progress?.done?.(stats);
147
+ return stats;
148
+ }
149
+
150
+ async restoreScope(scope, options = {}) {
151
+ const progress = options.progress || null;
152
+ const root = this._rootPrefix();
153
+ const keys = await this.storage.list(root);
154
+ const deletedDatabases = databaseTombstones(keys, this.prefix);
155
+ const tombstones = collectionTombstones(keys, this.prefix);
156
+ const scopes = this._filterCatchUpScopes(uniqueScopes(keys, this.prefix), scope)
157
+ .filter((candidate) => !deletedDatabases.has(candidate.database))
158
+ .filter((candidate) => !tombstones.has(collectionKey(candidate.database, candidate.collection)));
159
+ const stats = restoreStats();
160
+ let maxLocalSeq = this.node.seq;
161
+
162
+ this.node.db.beginBulkLoad?.();
163
+ try {
164
+ stats.databaseTombstones = this._restoreDatabaseTombstones(deletedDatabases);
165
+ stats.collectionTombstones = this._restoreCollectionTombstones(tombstones);
166
+ stats.databases = await this._restoreDatabaseMetadata(keys);
167
+ stats.metadata = await this._restoreMetadata(keys);
168
+ maxLocalSeq = await this._restoreScopeSet(keys, scopes, stats, maxLocalSeq, progress);
169
+ if (!scope) this._rememberUnscopedCursors(scopes);
170
+ else this._rememberScopeSetCursors(scope, scopes);
171
+ } finally {
172
+ this.node.db.endBulkLoad?.();
173
+ this.node.startIndexWarmup?.();
174
+ }
175
+
176
+ this.node.seq = Math.max(this.node.seq, maxLocalSeq);
177
+ if (this.node.log.length === 0) this.node.trimmed = Math.max(this.node.trimmed, this.node.seq);
178
+ return stats;
179
+ }
180
+
181
+ start() {
182
+ if (!this.unsubscribe) {
183
+ this.unsubscribe = this.node.onChange((change) => this._onChange(change));
184
+ }
185
+ if (this.flushMs && !this.flushTimer) {
186
+ this.flushTimer = setInterval(() => {
187
+ this.flush().catch((err) => (this.lastError = err));
188
+ }, this.flushMs);
189
+ this.flushTimer.unref?.();
190
+ }
191
+ if (this.localWal?.flushMs && !this.localWalTimer) {
192
+ this.localWalTimer = setInterval(() => {
193
+ this.flushLocal().catch((err) => (this.lastError = err));
194
+ }, this.localWal.flushMs);
195
+ this.localWalTimer.unref?.();
196
+ }
197
+ if (this.snapshotMs && !this.snapshotTimer) {
198
+ this.snapshotTimer = setInterval(() => {
199
+ this.checkpoint({ automatic: true }).catch((err) => (this.lastError = err));
200
+ }, this.snapshotMs);
201
+ this.snapshotTimer.unref?.();
202
+ }
203
+ }
204
+
205
+ async stop() {
206
+ this.unsubscribe?.();
207
+ this.unsubscribe = null;
208
+ if (this.flushTimer) clearInterval(this.flushTimer);
209
+ if (this.localWalTimer) clearInterval(this.localWalTimer);
210
+ if (this.snapshotTimer) clearInterval(this.snapshotTimer);
211
+ this.flushTimer = null;
212
+ this.localWalTimer = null;
213
+ this.snapshotTimer = null;
214
+ await this.flushLocal();
215
+ await this.flush();
216
+ }
217
+
218
+ async afterWrite() {
219
+ if (this.ack !== "localWal") return { records: 0, ops: 0 };
220
+ return this.flushLocal();
221
+ }
222
+
223
+ async flush() {
224
+ if (this.flushing) return this.flushing;
225
+ this.flushing = this._flushOnce().finally(() => {
226
+ this.flushing = null;
227
+ });
228
+ return this.flushing;
229
+ }
230
+
231
+ async flushLocal() {
232
+ if (!this.localWal) return { records: 0, ops: 0 };
233
+ const total = emptyLocalFlushStats();
234
+ while (this.localWalPending.length || this.localWalFlushing) {
235
+ if (this.localWalFlushing) {
236
+ mergeLocalFlushStats(total, await this.localWalFlushing);
237
+ continue;
238
+ }
239
+ this.localWalFlushing = this._flushLocalOnce().finally(() => {
240
+ this.localWalFlushing = null;
241
+ });
242
+ mergeLocalFlushStats(total, await this.localWalFlushing);
243
+ }
244
+ return total;
245
+ }
246
+
247
+ async checkpoint(options = {}) {
248
+ if (this.checkpointing) return this.checkpointing;
249
+ this.checkpointing = this._checkpointOnce(options).finally(() => {
250
+ this.checkpointing = null;
251
+ });
252
+ return this.checkpointing;
253
+ }
254
+
255
+ async reshardReadiness() {
256
+ const shardMap = this.node.shardMapSnapshot();
257
+ if (!isTopologyResharding(shardMap)) {
258
+ return { ready: true, topologyId: shardMap.topology?.id || null, expectedScopes: 0, completedScopes: 0, missing: [] };
259
+ }
260
+
261
+ const collections = this.reshardCollections || await this._loadReshardCollections(shardMap);
262
+
263
+ const missing = [];
264
+ const targetShards = Object.keys(shardMap.shards).sort();
265
+ const expected = [];
266
+ for (const collection of collections.values()) {
267
+ for (const shard of targetShards) {
268
+ const scope = { ...collection, shard };
269
+ expected.push({ scope, key: `${scopePrefix(this.prefix, scope)}/latest.json` });
270
+ }
271
+ }
272
+ let completedScopes = 0;
273
+ for (let i = 0; i < expected.length; i += 32) {
274
+ const batch = expected.slice(i, i + 32);
275
+ const found = await Promise.all(batch.map(({ key }) => this.storage.get(key)));
276
+ for (let j = 0; j < batch.length; j++) {
277
+ if (!isCompletedTargetManifest(found[j], shardMap.topology.id, batch[j].scope)) missing.push(batch[j].scope);
278
+ else completedScopes++;
279
+ }
280
+ }
281
+
282
+ const expectedScopes = expected.length;
283
+ return {
284
+ ready: missing.length === 0,
285
+ topologyId: shardMap.topology.id,
286
+ sourceScopes: collections.size,
287
+ expectedScopes,
288
+ completedScopes,
289
+ missing,
290
+ };
291
+ }
292
+
293
+ _rememberReshardCollections(scopes) {
294
+ this.reshardCollections = new Map();
295
+ for (const scope of scopes) {
296
+ this.reshardCollections.set(collectionKey(scope.database, scope.collection), {
297
+ database: scope.database,
298
+ collection: scope.collection,
299
+ });
300
+ }
301
+ }
302
+
303
+ async _loadReshardCollections(shardMap) {
304
+ const keys = await this.storage.list(this._rootPrefix());
305
+ const sourceShards = new Set(topologySourceShards(shardMap));
306
+ const deletedDatabases = databaseTombstones(keys, this.prefix);
307
+ const tombstones = collectionTombstones(keys, this.prefix);
308
+ const scopes = uniqueScopes(keys, this.prefix)
309
+ .filter((scope) => sourceShards.has(scope.shard))
310
+ .filter((scope) => !deletedDatabases.has(scope.database))
311
+ .filter((scope) => !tombstones.has(collectionKey(scope.database, scope.collection)));
312
+ this._rememberReshardCollections(scopes);
313
+ return this.reshardCollections;
314
+ }
315
+
316
+ getStats() {
317
+ return {
318
+ pendingScopes: this.pending.size,
319
+ pendingOps: [...this.pending.values()].reduce((sum, ops) => sum + ops.length, 0),
320
+ pendingBytes: this.pendingBytes,
321
+ dirtyScopes: this.dirtyScopes.size,
322
+ dirtyOps: [...this.dirtyScopes.values()].reduce((sum, dirty) => sum + dirty.ops, 0),
323
+ pendingDatabases: this.pendingDatabases.size,
324
+ pendingDatabaseDrops: this.pendingDatabaseDrops.size,
325
+ pendingMetadata: this.pendingMeta.size,
326
+ pendingCollectionDrops: this.pendingCollectionDrops.size,
327
+ ack: this.ack,
328
+ localWal: this.localWal ? {
329
+ path: this.localWal.filePath,
330
+ fsync: this.localWal.fsync,
331
+ pendingRecords: this.localWalPending.length,
332
+ pendingBytes: this.localWalPendingBytes,
333
+ flushActive: this.localWalFlushing !== null,
334
+ } : null,
335
+ lastError: this.lastError?.message || null,
336
+ };
337
+ }
338
+
339
+ _onChange(change) {
340
+ if (change.kind === "database") {
341
+ this._queueDatabase(change);
342
+ return;
343
+ }
344
+ if (change.kind === "dropDatabase") {
345
+ this._queueDatabaseDrop(change);
346
+ return;
347
+ }
348
+ if (change.kind === "dropCollection") {
349
+ this._queueCollectionDrop(change);
350
+ return;
351
+ }
352
+ if (change.kind === "meta") {
353
+ this._queueMeta(change);
354
+ return;
355
+ }
356
+ if (change.kind === "ops") {
357
+ this._queueOps(change.ops || []);
358
+ return;
359
+ }
360
+ }
361
+
362
+ _queueDatabase(change) {
363
+ if (!change.database || !change.meta) return;
364
+ this.pendingDatabases.set(change.database, clone(change.meta));
365
+ this.pendingDatabaseDrops.delete(change.database);
366
+ this._queueLocalWal({
367
+ type: "database",
368
+ database: change.database,
369
+ meta: clone(change.meta),
370
+ });
371
+ }
372
+
373
+ _queueDatabaseDrop(change) {
374
+ if (!change.database) return;
375
+ this.pendingDatabaseDrops.add(change.database);
376
+ this.pendingDatabases.delete(change.database);
377
+ for (const key of [...this.pendingMeta.keys()]) {
378
+ if (parseCollectionKey(key).database === change.database) this.pendingMeta.delete(key);
379
+ }
380
+ for (const key of [...this.pendingCollectionDrops]) {
381
+ if (parseCollectionKey(key).database === change.database) this.pendingCollectionDrops.delete(key);
382
+ }
383
+ for (const key of [...this.pending.keys()]) {
384
+ if (parseScopeKey(key).database === change.database) this.pending.delete(key);
385
+ }
386
+ this.pendingBytes = pendingOpsJsonBytes(this.pending);
387
+ this._removePendingLocalWalForDatabase(change.database);
388
+ this._queueLocalWal({
389
+ type: "dropDatabase",
390
+ database: change.database,
391
+ });
392
+ }
393
+
394
+ _queueMeta(change) {
395
+ if (!change.database || !change.collection || !change.meta) return;
396
+ this.pendingMeta.set(collectionKey(change.database, change.collection), clone(change.meta));
397
+ this._queueLocalWal({
398
+ type: "meta",
399
+ database: change.database,
400
+ collection: change.collection,
401
+ meta: clone(change.meta),
402
+ });
403
+ }
404
+
405
+ _queueCollectionDrop(change) {
406
+ if (!change.database || !change.collection) return;
407
+ const key = collectionKey(change.database, change.collection);
408
+ this.pendingCollectionDrops.add(key);
409
+ this.pendingMeta.delete(key);
410
+ this._removePendingLocalWalForCollection(change.database, change.collection);
411
+ for (const pendingKey of [...this.pending.keys()]) {
412
+ const scope = parseScopeKey(pendingKey);
413
+ if (scope.database === change.database && scope.collection === change.collection) {
414
+ this.pending.delete(pendingKey);
415
+ }
416
+ }
417
+ this.pendingBytes = pendingOpsJsonBytes(this.pending);
418
+ this._queueLocalWal({
419
+ type: "dropCollection",
420
+ database: change.database,
421
+ collection: change.collection,
422
+ });
423
+ }
424
+
425
+ _queueOps(ops) {
426
+ const cloned = [];
427
+ for (const op of ops || []) {
428
+ const queued = this._queueOp(op);
429
+ if (queued) cloned.push(queued);
430
+ }
431
+ if (!cloned.length) return;
432
+ if (!this.localWal) return;
433
+ const maxBytes = this.localWal?.maxRecordBytes || this.journalMaxBytes;
434
+ const maxOps = this.localWal?.maxOpsPerRecord || this.opBatchSize;
435
+ for (const chunk of chunkOpsForRecord(cloned, { maxBytes, maxOps, label: "local WAL ops record" })) {
436
+ this._queueLocalWal({
437
+ type: "ops",
438
+ ...commonScope(chunk),
439
+ ...seqRange(chunk),
440
+ count: chunk.length,
441
+ ops: chunk,
442
+ });
443
+ }
444
+ }
445
+
446
+ _queueLocalWal(record) {
447
+ if (!this.localWal) return;
448
+ const entry = {
449
+ version: 1,
450
+ node: this.node.id,
451
+ createdAt: new Date().toISOString(),
452
+ ...record,
453
+ };
454
+ this.localWalPending.push(entry);
455
+ this.localWalPendingBytes += jsonBytes(entry, "pending local WAL record") + 1;
456
+ }
457
+
458
+ _queueOp(op) {
459
+ if (!op?.db || !op.coll || !op.id || !op.shard) return;
460
+ const cloned = clone(op);
461
+ const scope = { database: op.db, collection: op.coll, shard: op.shard };
462
+ const key = scopeKey(scope);
463
+ const pending = this.pending.get(key) || [];
464
+ pending.push(cloned);
465
+ this.pending.set(key, pending);
466
+ this.pendingBytes += jsonBytes(cloned, "pending journal op");
467
+ this._markDirty(key, cloned);
468
+ if (pending.length >= this.opBatchSize) {
469
+ this.flush().catch((err) => (this.lastError = err));
470
+ }
471
+ return cloned;
472
+ }
473
+
474
+ _removePendingLocalWalForCollection(database, collection) {
475
+ if (!this.localWalPending.length) return;
476
+ this.localWalPending = this.localWalPending.filter((record) => {
477
+ if (record.type === "ops") {
478
+ record.ops = (record.ops || []).filter((op) => op.db !== database || op.coll !== collection);
479
+ record.count = record.ops.length;
480
+ delete record.database;
481
+ delete record.collection;
482
+ delete record.shard;
483
+ Object.assign(record, commonScope(record.ops), seqRange(record.ops));
484
+ return record.ops.length > 0;
485
+ }
486
+ if (record.database !== database || record.collection !== collection) return true;
487
+ return record.type !== "meta" && record.type !== "dropCollection";
488
+ });
489
+ this.localWalPendingBytes = pendingWalJsonBytes(this.localWalPending);
490
+ }
491
+
492
+ _removePendingLocalWalForDatabase(database) {
493
+ if (!this.localWalPending.length) return;
494
+ this.localWalPending = this.localWalPending.filter((record) => {
495
+ if (record.type === "ops") {
496
+ record.ops = (record.ops || []).filter((op) => op.db !== database);
497
+ record.count = record.ops.length;
498
+ delete record.database;
499
+ delete record.collection;
500
+ delete record.shard;
501
+ Object.assign(record, commonScope(record.ops), seqRange(record.ops));
502
+ return record.ops.length > 0;
503
+ }
504
+ return record.database !== database;
505
+ });
506
+ this.localWalPendingBytes = pendingWalJsonBytes(this.localWalPending);
507
+ }
508
+
509
+ _markDirty(key, op) {
510
+ const dirty = this.dirtyScopes.get(key) || { ops: 0, version: 0, lastSeq: 0 };
511
+ dirty.ops++;
512
+ dirty.version++;
513
+ const seq = Number(op.seq);
514
+ if (Number.isFinite(seq) && seq > dirty.lastSeq) dirty.lastSeq = seq;
515
+ this.dirtyScopes.set(key, dirty);
516
+ }
517
+
518
+ async _flushOnce() {
519
+ if (this.flushPaused) return { scopes: 0, ops: 0 };
520
+ if (!this.pending.size && !this.pendingDatabases.size && !this.pendingDatabaseDrops.size && !this.pendingMeta.size && !this.pendingCollectionDrops.size) {
521
+ return { scopes: 0, ops: 0, databases: 0, databaseDrops: 0, metadata: 0, collectionDrops: 0 };
522
+ }
523
+
524
+ const batches = this.pending;
525
+ const databases = this.pendingDatabases;
526
+ const databaseDrops = this.pendingDatabaseDrops;
527
+ const metadata = this.pendingMeta;
528
+ const collectionDrops = this.pendingCollectionDrops;
529
+ this.pending = new Map();
530
+ this.pendingBytes = 0;
531
+ this.pendingDatabases = new Map();
532
+ this.pendingDatabaseDrops = new Set();
533
+ this.pendingMeta = new Map();
534
+ this.pendingCollectionDrops = new Set();
535
+ let scopeCount = 0;
536
+ let opCount = 0;
537
+ let databaseCount = 0;
538
+ let databaseDropCount = 0;
539
+ let metadataCount = 0;
540
+ let collectionDropCount = 0;
541
+
542
+ try {
543
+ for (const database of databaseDrops) {
544
+ await this._deleteDatabase(database);
545
+ databaseDropCount++;
546
+ }
547
+ for (const key of collectionDrops) {
548
+ const target = parseCollectionKey(key);
549
+ await this._deleteCollection(target);
550
+ collectionDropCount++;
551
+ }
552
+ for (const [database, meta] of databases) {
553
+ await this._writeDatabaseMeta(database, meta);
554
+ databaseCount++;
555
+ }
556
+ for (const [key, meta] of metadata) {
557
+ await this._writeCollectionMeta(parseCollectionKey(key), meta);
558
+ metadataCount++;
559
+ }
560
+ for (const [key, ops] of batches) {
561
+ if (!ops.length) continue;
562
+ await this._writeJournalBatch(parseScopeKey(key), ops);
563
+ scopeCount++;
564
+ opCount += ops.length;
565
+ }
566
+ await this._truncateLocalWalIfDurable();
567
+ return {
568
+ scopes: scopeCount,
569
+ ops: opCount,
570
+ databases: databaseCount,
571
+ databaseDrops: databaseDropCount,
572
+ metadata: metadataCount,
573
+ collectionDrops: collectionDropCount,
574
+ };
575
+ } catch (err) {
576
+ this._requeueBatches(batches);
577
+ this._requeueDatabases(databases);
578
+ this._requeueDatabaseDrops(databaseDrops);
579
+ this._requeueMetadata(metadata);
580
+ this._requeueCollectionDrops(collectionDrops);
581
+ this.lastError = err;
582
+ throw err;
583
+ }
584
+ }
585
+
586
+ async _flushLocalOnce() {
587
+ if (!this.localWal || !this.localWalPending.length) return emptyLocalFlushStats();
588
+ const records = this.localWalPending;
589
+ this.localWalPending = [];
590
+ this.localWalPendingBytes = 0;
591
+
592
+ try {
593
+ const written = await this.localWal.append(records);
594
+ return {
595
+ records: records.length,
596
+ ops: records.reduce((sum, record) => sum + (record.type === "ops" ? Number(record.count) || 0 : 0), 0),
597
+ databases: records.filter((record) => record.type === "database").length,
598
+ databaseDrops: records.filter((record) => record.type === "dropDatabase").length,
599
+ metadata: records.filter((record) => record.type === "meta").length,
600
+ collectionDrops: records.filter((record) => record.type === "dropCollection").length,
601
+ bytes: written.bytes,
602
+ };
603
+ } catch (err) {
604
+ this.localWalPending = [...records, ...this.localWalPending];
605
+ this.localWalPendingBytes = pendingWalJsonBytes(this.localWalPending);
606
+ this.lastError = err;
607
+ throw err;
608
+ }
609
+ }
610
+
611
+ async _truncateLocalWalIfDurable() {
612
+ if (!this.localWal || this.localWalPending.length || this.localWalFlushing) return;
613
+ if (this.pending.size || this.pendingDatabases.size || this.pendingDatabaseDrops.size || this.pendingMeta.size || this.pendingCollectionDrops.size) return;
614
+ await this.localWal.truncate();
615
+ }
616
+
617
+ _requeueBatches(batches) {
618
+ for (const [key, ops] of batches) {
619
+ const current = this.pending.get(key) || [];
620
+ this.pending.set(key, [...ops, ...current]);
621
+ }
622
+ this.pendingBytes = pendingOpsJsonBytes(this.pending);
623
+ }
624
+
625
+ _requeueDatabases(databases) {
626
+ for (const [database, meta] of databases) {
627
+ if (!this.pendingDatabases.has(database)) this.pendingDatabases.set(database, meta);
628
+ }
629
+ }
630
+
631
+ _requeueDatabaseDrops(databaseDrops) {
632
+ for (const database of databaseDrops) this.pendingDatabaseDrops.add(database);
633
+ }
634
+
635
+ _requeueMetadata(metadata) {
636
+ for (const [key, meta] of metadata) {
637
+ if (!this.pendingMeta.has(key)) this.pendingMeta.set(key, meta);
638
+ }
639
+ }
640
+
641
+ _requeueCollectionDrops(collectionDrops) {
642
+ for (const key of collectionDrops) this.pendingCollectionDrops.add(key);
643
+ }
644
+
645
+ async _writeDatabaseMeta(database, meta) {
646
+ await this.storage.delete([databaseTombstoneKey(this.prefix, database)]);
647
+ await this.storage.put(
648
+ `${databasePrefix(this.prefix, database)}/meta.json`,
649
+ JSON.stringify(meta),
650
+ "application/json",
651
+ );
652
+ }
653
+
654
+ async _writeCollectionMeta({ database, collection }, meta) {
655
+ await this.storage.delete([collectionTombstoneKey(this.prefix, database, collection)]);
656
+ await this.storage.put(
657
+ `${collectionPrefix(this.prefix, database, collection)}/meta.json`,
658
+ JSON.stringify(meta),
659
+ "application/json",
660
+ );
661
+ }
662
+
663
+ async _deleteCollection({ database, collection }) {
664
+ await this.storage.deletePrefix(`${collectionPrefix(this.prefix, database, collection)}/`);
665
+ await this.storage.put(
666
+ collectionTombstoneKey(this.prefix, database, collection),
667
+ JSON.stringify({ database, collection, deletedAt: new Date().toISOString() }),
668
+ "application/json",
669
+ );
670
+ }
671
+
672
+ async _deleteDatabase(database) {
673
+ await this.storage.deletePrefix(`${databasePrefix(this.prefix, database)}/`);
674
+ await this.storage.put(
675
+ databaseTombstoneKey(this.prefix, database),
676
+ JSON.stringify({ database, deletedAt: new Date().toISOString() }),
677
+ "application/json",
678
+ );
679
+ }
680
+
681
+ async _writeJournalBatch(scope, ops) {
682
+ for (const chunk of chunkOpsForRecord(ops, {
683
+ maxBytes: this.journalMaxBytes,
684
+ maxOps: this.opBatchSize,
685
+ label: `${scopeKey(scope)} journal`,
686
+ })) {
687
+ await this._writeJournalSegment(scope, chunk);
688
+ }
689
+ }
690
+
691
+ async _writeJournalSegment(scope, ops) {
692
+ const seqs = ops.map((op) => Number(op.seq)).filter(Number.isFinite);
693
+ const firstSeq = seqs.length ? Math.min(...seqs) : 0;
694
+ const lastSeq = seqs.length ? Math.max(...seqs) : 0;
695
+ const key = `${scopePrefix(this.prefix, scope)}/journal/${safe(this.node.id)}/${Date.now()}-${pad(firstSeq)}-${pad(lastSeq)}-${randomUUID()}.jsonl`;
696
+ await this.storage.put(key, jsonlBody(ops, `${scopeKey(scope)} journal`), "application/x-ndjson");
697
+ }
698
+
699
+ async _checkpointOnce(options = {}) {
700
+ await this.flush();
701
+ this.flushPaused = true;
702
+ const coverage = this._snapshotCoverage();
703
+ const scopes = this._snapshotScopes(options);
704
+ const checkpointId = snapshotId();
705
+ const stats = { id: checkpointId, scopes: 0, objects: 0, segments: 0, retainedSnapshotsDeleted: 0, retainedJournalsDeleted: 0 };
706
+ try {
707
+ for (const scope of scopes) {
708
+ const written = await this._writeSnapshot(scope, checkpointId, coverage);
709
+ this._markCheckpointed(scope);
710
+ stats.scopes++;
711
+ stats.objects += written.objects;
712
+ stats.segments += written.segments;
713
+ stats.retainedSnapshotsDeleted += written.retainedSnapshotsDeleted || 0;
714
+ stats.retainedJournalsDeleted += written.retainedJournalsDeleted || 0;
715
+ }
716
+ } finally {
717
+ this.flushPaused = false;
718
+ }
719
+ return stats;
720
+ }
721
+
722
+ _snapshotScopes({ automatic = false, primaryOnly = false, shards: selectedShards } = {}) {
723
+ const scopes = [];
724
+ const selected = selectedShards?.length ? new Set(selectedShards) : null;
725
+ const ownedShards = this._ownedShardNames().filter((shard) => !selected || selected.has(shard));
726
+ for (const collection of this._collectionScopes()) {
727
+ const shards = ownedShards.length
728
+ ? ownedShards
729
+ : selected
730
+ ? []
731
+ : this.node.shardMap.shardsForCollection(collection.database, collection.collection);
732
+ for (const shard of shards.length ? shards : [DEFAULT_SHARD]) {
733
+ if (primaryOnly && !this._shouldSnapshotForRole(shard)) continue;
734
+ const scope = { database: collection.database, collection: collection.collection, shard };
735
+ if (automatic && !this._shouldAutoSnapshot(scope)) continue;
736
+ const dirty = this.dirtyScopes.get(scopeKey(scope));
737
+ if (dirty) scope.dirtyVersion = dirty.version;
738
+ scopes.push(scope);
739
+ }
740
+ }
741
+ return scopes;
742
+ }
743
+
744
+ _collectionScopes() {
745
+ const scopes = [];
746
+ for (const database of this.node.listDatabases()) {
747
+ for (const collection of this.node.listCollections(database)) {
748
+ scopes.push({ database, collection });
749
+ }
750
+ }
751
+ return scopes;
752
+ }
753
+
754
+ _shouldAutoSnapshot(scope) {
755
+ if (!this._shouldSnapshotForRole(scope.shard)) return false;
756
+ if (!this.snapshotDirtyOnly) return true;
757
+ return this.dirtyScopes.has(scopeKey(scope));
758
+ }
759
+
760
+ _shouldSnapshotForRole(shard) {
761
+ if (this.snapshotRole === "all") return true;
762
+ if (this.snapshotRole === "none") return false;
763
+ return this.node.isPrimaryForShard(shard);
764
+ }
765
+
766
+ _markCheckpointed(scope) {
767
+ const key = scopeKey(scope);
768
+ const dirty = this.dirtyScopes.get(key);
769
+ if (dirty && scope.dirtyVersion !== undefined && dirty.version > scope.dirtyVersion) return;
770
+ this.dirtyScopes.delete(key);
771
+ }
772
+
773
+ _filterRestoreScopes(scopes) {
774
+ const targets = this._restoreTargets();
775
+ if (this._restoreMode() === "reshard") {
776
+ const sourceShards = new Set(this._reshardSourceShards());
777
+ return scopes.filter((scope) => sourceShards.has(scope.shard)
778
+ && (this._restoreAllTargets(targets) || targets.some((target) => matchesRestoreCollection(scope, target))));
779
+ }
780
+ if (this._restoreAllTargets(targets)) return scopes;
781
+ return scopes.filter((scope) => targets.some((target) => matchesRestoreTarget(scope, target)));
782
+ }
783
+
784
+ _filterCatchUpScopes(scopes, scope) {
785
+ if (!scope) return this._filterRestoreScopes(scopes);
786
+ return scopes.filter((candidate) => matchesRestoreTarget(candidate, scope));
787
+ }
788
+
789
+ _filterRestoreObjects(scope, objects) {
790
+ if (this._restoreMode() !== "reshard") return objects;
791
+ const targets = this._restoreTargets();
792
+ const out = [];
793
+ for (const object of objects || []) {
794
+ const row = this._filterRestoreObject(scope, object, targets);
795
+ if (row) out.push(row);
796
+ }
797
+ return out;
798
+ }
799
+
800
+ _filterRestoreObject(scope, object, targets = null) {
801
+ if (!object) return null;
802
+ if (this._restoreMode() !== "reshard") return object;
803
+ const restoreTargets = targets || this._restoreTargets();
804
+ if (!this._restoreAllTargets(restoreTargets)
805
+ && !this._objectMatchesRestoreTargets(scope.database, scope.collection, object.id, restoreTargets)) return null;
806
+ return {
807
+ ...object,
808
+ shard: this.node.shardForObject(scope.database, scope.collection, object.id),
809
+ };
810
+ }
811
+
812
+ _filterRestoreOps(scope, ops) {
813
+ if (this._restoreMode() !== "reshard") return ops;
814
+ const targets = this._restoreTargets();
815
+ return ops
816
+ .filter((op) => this._restoreAllTargets(targets)
817
+ || this._objectMatchesRestoreTargets(op.db || scope.database, op.coll || scope.collection, op.id, targets))
818
+ .map((op) => ({
819
+ ...op,
820
+ shard: this.node.shardForObject(op.db || scope.database, op.coll || scope.collection, op.id),
821
+ }));
822
+ }
823
+
824
+ _objectMatchesRestoreTargets(database, collection, objectId, targets) {
825
+ const routedShard = this.node.shardForObject(database, collection, objectId);
826
+ return targets.some((target) => matchesRestoreTarget({ database, collection, shard: routedShard }, target));
827
+ }
828
+
829
+ _ownedShardNames() {
830
+ return [...new Set(this.node.ownedShardTargets().map((target) => target.shard))];
831
+ }
832
+
833
+ _restoreTargets() {
834
+ return this.restoreScopes || this.node.ownedShardTargets();
835
+ }
836
+
837
+ _restoreTargetShardNames() {
838
+ return [...new Set(this._restoreTargets().map((target) => target.shard).filter(Boolean))];
839
+ }
840
+
841
+ _restoreMode() {
842
+ return this._isManagedReshard() ? "reshard" : "scoped";
843
+ }
844
+
845
+ _isManagedReshard() {
846
+ return isTopologyResharding(this.node.shardMapSnapshot());
847
+ }
848
+
849
+ _reshardSourceShards() {
850
+ return topologySourceShards(this.node.shardMapSnapshot());
851
+ }
852
+
853
+ _assertDisjointReshardShards() {
854
+ const source = new Set(this._reshardSourceShards());
855
+ for (const target of Object.keys(this.node.shardMapSnapshot().shards || {})) {
856
+ if (source.has(target)) throw new Error(`source and target shard names overlap: ${target}`);
857
+ }
858
+ }
859
+
860
+ _restoreAllTargets(targets) {
861
+ if (targets?.length) return false;
862
+ if (!this.node.url) return true;
863
+ return this.node.shardMap.syncTargets().length === 0;
864
+ }
865
+
866
+ async _writeSnapshot(scope, id = snapshotId(), coverage = this._snapshotCoverage()) {
867
+ const base = `${scopePrefix(this.prefix, scope)}/snapshots/${id}`;
868
+ const segments = [];
869
+ let objectCount = 0;
870
+
871
+ for (const chunk of chunkJsonlIterable(this.node.snapshotScopeObjects(scope), this.segmentBytes, `${scopeKey(scope)} snapshot`)) {
872
+ const key = `${base}/segment-${String(segments.length).padStart(6, "0")}.jsonl`;
873
+ await this.storage.put(key, chunk.body, "application/x-ndjson");
874
+ segments.push({ key, count: chunk.count, bytes: chunk.bytes, format: "jsonl" });
875
+ objectCount += chunk.count;
876
+ }
877
+
878
+ const manifest = {
879
+ version: 1,
880
+ id,
881
+ node: this.node.id,
882
+ seq: coverage.seq,
883
+ createdAt: new Date().toISOString(),
884
+ database: scope.database,
885
+ collection: scope.collection,
886
+ shard: scope.shard,
887
+ topologyId: this.node.shardMapSnapshot().topology?.id || null,
888
+ databaseMeta: clone(this.node.getDatabaseMeta(scope.database) || {}),
889
+ collectionMeta: clone(this.node.getCollectionMeta(scope.database, scope.collection) || {}),
890
+ cursors: this._snapshotCursors(scope, coverage),
891
+ format: "jsonl",
892
+ objectCount,
893
+ segments,
894
+ };
895
+ await this.storage.put(`${base}/manifest.json`, JSON.stringify(manifest), "application/json");
896
+ await this.storage.put(`${scopePrefix(this.prefix, scope)}/latest.json`, JSON.stringify(manifest), "application/json");
897
+ const retainedJournalsDeleted = this.compactJournals ? await this._compactJournals(scope, manifest.cursors) : 0;
898
+ const retainedSnapshotsDeleted = await this._applySnapshotRetention(scope, id);
899
+ return { objects: objectCount, segments: segments.length, retainedJournalsDeleted, retainedSnapshotsDeleted };
900
+ }
901
+
902
+ _snapshotCoverage() {
903
+ return {
904
+ seq: this.node.seq,
905
+ lastSeen: new Map(this.node.lastSeen || []),
906
+ };
907
+ }
908
+
909
+ async _compactJournals(scope, cursors = {}) {
910
+ const prefix = `${scopePrefix(this.prefix, scope)}/journal/`;
911
+ const keys = await this.storage.list(prefix);
912
+ if (!keys.length) return 0;
913
+ const covered = keys.filter((key) => {
914
+ const info = parseJournalKey(key, this.prefix);
915
+ if (!info?.node || !info.lastSeq) return false;
916
+ return (Number(cursors[info.node]) || 0) >= info.lastSeq;
917
+ });
918
+ if (!covered.length) return 0;
919
+ const removable = filterRetentionKeys(covered, this.journalRetention, (key) => {
920
+ const info = parseJournalKey(key, this.prefix);
921
+ return info?.createdAtMs || 0;
922
+ });
923
+ if (!removable.length) return 0;
924
+ return this.storage.delete(removable);
925
+ }
926
+
927
+ async _applySnapshotRetention(scope, latestId) {
928
+ if (!retentionEnabled(this.snapshotRetention)) return 0;
929
+ const prefix = `${scopePrefix(this.prefix, scope)}/snapshots/`;
930
+ const keys = await this.storage.list(prefix);
931
+ const snapshots = snapshotEntries(keys, prefix);
932
+ const latest = snapshots.find((entry) => entry.id === latestId);
933
+ const removable = filterSnapshotRetention(snapshots, this.snapshotRetention, latest || { id: latestId })
934
+ .flatMap((entry) => entry.keys);
935
+ if (!removable.length) return 0;
936
+ return this.storage.delete(removable);
937
+ }
938
+
939
+ async _restoreSnapshot(scope, progress = null) {
940
+ const manifestKey = this.restoreSnapshotId
941
+ ? `${scopePrefix(this.prefix, scope)}/snapshots/${this.restoreSnapshotId}/manifest.json`
942
+ : `${scopePrefix(this.prefix, scope)}/latest.json`;
943
+ const raw = await this.storage.get(manifestKey);
944
+ if (!raw) return null;
945
+ const manifest = JSON.parse(raw);
946
+ let objects = 0;
947
+ if (this.restoreSnapshotId) this._restoreManifestMetadata(scope, manifest);
948
+ else this._ensureRestoreManifestContext(scope, manifest);
949
+
950
+ for (const segment of manifest.segments || []) {
951
+ objects += await this._restoreSnapshotSegment(scope, manifest, segment, progress);
952
+ }
953
+
954
+ return {
955
+ node: manifest.node,
956
+ seq: Number(manifest.seq) || 0,
957
+ cursors: normalizeSnapshotCursors(manifest),
958
+ objects,
959
+ };
960
+ }
961
+
962
+ async _restoreSnapshotSegment(scope, manifest, segment = {}, progress = null) {
963
+ if (!String(segment.key || "").endsWith(".jsonl")) {
964
+ throw new Error(`unsupported snapshot segment format: ${segment.key || "missing key"}`);
965
+ }
966
+ const sourceNode = manifest.node || "persistence";
967
+ const cursorKey = this.node._cursorKey ? this.node._cursorKey(sourceNode, scope) : `persistence:${scopeKey(scope)}:snapshot`;
968
+ const lastSeq = Number(manifest.seq) || 0;
969
+ let objects = 0;
970
+
971
+ const applyRows = async (rows) => {
972
+ if (!rows.length) return;
973
+ const applied = this.node.applySnapshotObjects(sourceNode, {
974
+ database: scope.database,
975
+ collection: scope.collection,
976
+ scope,
977
+ lastSeq,
978
+ objects: rows,
979
+ }, cursorKey);
980
+ objects += applied;
981
+ progress?.add?.("snapshotObjects", applied);
982
+ await yieldToEventLoop();
983
+ };
984
+
985
+ let batch = [];
986
+ for await (const object of jsonlObjectsFromStorage(this.storage, segment.key, `${scopeKey(scope)} snapshot`)) {
987
+ const row = this._filterRestoreObject(scope, object);
988
+ if (!row) continue;
989
+ batch.push(row);
990
+ if (batch.length < RESTORE_APPLY_BATCH_SIZE) continue;
991
+ await applyRows(batch);
992
+ batch = [];
993
+ }
994
+ await applyRows(batch);
995
+ return objects;
996
+ }
997
+
998
+ async _restoreJournalSegment(scope, key, snapshotCursors = {}, progress = null) {
999
+ if (!key.endsWith(".jsonl")) throw new Error(`unsupported journal segment format: ${key}`);
1000
+ return this._restoreJsonlJournalSegment(scope, key, snapshotCursors, progress);
1001
+ }
1002
+
1003
+ async _restoreJsonlJournalSegment(scope, key, snapshotCursors = {}, progress = null) {
1004
+ const journalInfo = parseJournalKey(key, this.prefix);
1005
+ const sourceNode = journalInfo?.node || "persistence";
1006
+ const coveredSeq = Number(snapshotCursors[sourceNode]) || 0;
1007
+ const stats = emptyJournalRestoreStats(this.node.seq);
1008
+ let batch = [];
1009
+ let segmentOps = 0;
1010
+
1011
+ const flush = async () => {
1012
+ if (!batch.length) return;
1013
+ const applied = this._applyRestoredJournalOps(sourceNode, scope, key, batch);
1014
+ stats.ops += applied;
1015
+ progress?.add?.("journalOps", applied);
1016
+ stats.maxLocalSeq = Math.max(stats.maxLocalSeq, maxLocalSeqForOps(batch, this.node.id));
1017
+ batch = [];
1018
+ await yieldToEventLoop();
1019
+ };
1020
+
1021
+ for await (const op of jsonlObjectsFromStorage(this.storage, key, `${scopeKey(scope)} journal`)) {
1022
+ segmentOps++;
1023
+ const seq = Number(op?.seq);
1024
+ if (Number.isFinite(seq) && seq <= coveredSeq) continue;
1025
+ batch.push(op);
1026
+ if (batch.length >= this.opBatchSize) await flush();
1027
+ }
1028
+ await flush();
1029
+
1030
+ if (stats.ops > 0) stats.journals = 1;
1031
+ else if (segmentOps > 0) {
1032
+ stats.skippedJournals = 1;
1033
+ stats.skippedOps = segmentOps;
1034
+ }
1035
+ return stats;
1036
+ }
1037
+
1038
+ _applyRestoredJournalOps(sourceNode, scope, key, ops) {
1039
+ const filtered = this._filterRestoreOps(scope, ops);
1040
+ if (!filtered.length) return 0;
1041
+ this.node.applyRemote(sourceNode, filtered, `persistence:${key}`);
1042
+ rememberRestoredCursor(this.node, sourceNode, scope, filtered);
1043
+ return filtered.length;
1044
+ }
1045
+
1046
+ async _restoreMetadata(keys) {
1047
+ let count = 0;
1048
+ for (const key of keys) {
1049
+ const target = parseMetaFromKey(key, this.prefix);
1050
+ if (!target) continue;
1051
+ if (this.node.db.isDatabaseDeleted(target.database)) continue;
1052
+ if (this.node.db.isCollectionDeleted(target.database, target.collection)) continue;
1053
+ const raw = await this.storage.get(key);
1054
+ if (!raw) continue;
1055
+ this.node.db.setCollectionMeta(target.database, target.collection, JSON.parse(raw));
1056
+ count++;
1057
+ }
1058
+ return count;
1059
+ }
1060
+
1061
+ _restoreCollectionTombstones(tombstones) {
1062
+ for (const key of tombstones) {
1063
+ const target = parseCollectionKey(key);
1064
+ this.node.db.deleteCollection(target.database, target.collection);
1065
+ }
1066
+ return tombstones.size;
1067
+ }
1068
+
1069
+ _restoreDatabaseTombstones(tombstones) {
1070
+ for (const database of tombstones) this.node.db.deleteDatabase(database);
1071
+ return tombstones.size;
1072
+ }
1073
+
1074
+ async _restoreDatabaseMetadata(keys) {
1075
+ let count = 0;
1076
+ for (const key of keys) {
1077
+ const target = parseDatabaseMetaFromKey(key, this.prefix);
1078
+ if (!target) continue;
1079
+ if (this.node.db.isDatabaseDeleted(target.database)) continue;
1080
+ const raw = await this.storage.get(key);
1081
+ if (!raw) continue;
1082
+ this.node.db.setDatabaseMeta(target.database, JSON.parse(raw));
1083
+ count++;
1084
+ }
1085
+ return count;
1086
+ }
1087
+
1088
+ async _restoreLocalWal() {
1089
+ const { records, skippedPartialRecords } = await this.localWal.readRecords();
1090
+ const stats = {
1091
+ records: records.length,
1092
+ ops: 0,
1093
+ databases: 0,
1094
+ databaseDrops: 0,
1095
+ metadata: 0,
1096
+ collectionDrops: 0,
1097
+ skippedPartialRecords,
1098
+ maxLocalSeq: this.node.seq,
1099
+ };
1100
+
1101
+ let line = 0;
1102
+ for (const record of records) {
1103
+ line++;
1104
+ if (line % RESTORE_PARSE_YIELD_LINES === 0) await yieldToEventLoop();
1105
+ if (!record || record.version !== 1) continue;
1106
+ if (record.type === "ops") {
1107
+ const ops = this._filterLocalWalOps(Array.isArray(record.ops) ? record.ops : []);
1108
+ const sourceNode = record.node || "local-wal";
1109
+ this.node.applyRemote(sourceNode, ops, `local-wal:${line}`);
1110
+ rememberRestoredCursors(this.node, sourceNode, ops);
1111
+ for (const op of ops) {
1112
+ if (op.from === this.node.id && Number.isFinite(op.seq)) {
1113
+ stats.maxLocalSeq = Math.max(stats.maxLocalSeq, op.seq);
1114
+ }
1115
+ }
1116
+ stats.ops += ops.length;
1117
+ continue;
1118
+ }
1119
+
1120
+ if (record.type === "database" && record.database && record.meta && isPlainObject(record.meta)) {
1121
+ this.node.db.setDatabaseMeta(record.database, clone(record.meta));
1122
+ stats.databases++;
1123
+ continue;
1124
+ }
1125
+
1126
+ if (record.type === "dropDatabase" && record.database) {
1127
+ this.node.db.deleteDatabase(record.database);
1128
+ stats.databaseDrops++;
1129
+ continue;
1130
+ }
1131
+
1132
+ if (record.type === "meta" && record.database && record.collection && record.meta && isPlainObject(record.meta)) {
1133
+ this.node.db.setCollectionMeta(record.database, record.collection, clone(record.meta));
1134
+ stats.metadata++;
1135
+ continue;
1136
+ }
1137
+
1138
+ if (record.type === "dropCollection" && record.database && record.collection) {
1139
+ this.node.db.deleteCollection(record.database, record.collection);
1140
+ stats.collectionDrops++;
1141
+ }
1142
+ }
1143
+
1144
+ return stats;
1145
+ }
1146
+
1147
+ _filterLocalWalOps(ops) {
1148
+ const targets = this._restoreTargets();
1149
+ if (this._restoreMode() === "reshard") {
1150
+ return ops
1151
+ .filter((op) => this._restoreAllTargets(targets)
1152
+ || this._objectMatchesRestoreTargets(op.db, op.coll, op.id, targets))
1153
+ .map((op) => ({
1154
+ ...op,
1155
+ shard: this.node.shardForObject(op.db, op.coll, op.id),
1156
+ }));
1157
+ }
1158
+ if (this._restoreAllTargets(targets)) return ops;
1159
+ return ops.filter((op) => targets.some((target) => matchesRestoreTarget(opScope(op, this.node), target)));
1160
+ }
1161
+
1162
+ _rootPrefix() {
1163
+ return this.prefix ? `${this.prefix}/` : "";
1164
+ }
1165
+
1166
+ _restoreManifestMetadata(scope, manifest = {}) {
1167
+ if (isPlainObject(manifest.databaseMeta)) {
1168
+ this.node.db.setDatabaseMeta(scope.database, clone(manifest.databaseMeta));
1169
+ }
1170
+ if (isPlainObject(manifest.collectionMeta)) {
1171
+ this.node.db.setCollectionMeta(scope.database, scope.collection, clone(manifest.collectionMeta));
1172
+ }
1173
+ }
1174
+
1175
+ _ensureRestoreManifestContext(scope, manifest = {}) {
1176
+ if (!this.node.getDatabaseMeta(scope.database)) {
1177
+ this.node.db.setDatabaseMeta(scope.database, clone(manifest.databaseMeta || {}));
1178
+ }
1179
+ if (!this.node.getCollectionMeta(scope.database, scope.collection)) {
1180
+ this.node.db.setCollectionMeta(scope.database, scope.collection, clone(manifest.collectionMeta || {}));
1181
+ }
1182
+ }
1183
+
1184
+ _snapshotCursors(scope, coverage = this._snapshotCoverage()) {
1185
+ const cursors = { [this.node.id]: Number(coverage.seq) || 0 };
1186
+ const suffix = this.node._cursorKey ? `|${scope.database}/${scope.collection}/${scope.shard}` : null;
1187
+ if (!suffix) return cursors;
1188
+ for (const [key, seen] of coverage.lastSeen || []) {
1189
+ if (typeof key !== "string" || !key.endsWith(suffix)) continue;
1190
+ const sourceNode = key.slice(0, -suffix.length);
1191
+ if (!sourceNode) continue;
1192
+ cursors[sourceNode] = Math.max(cursors[sourceNode] || 0, Number(seen) || 0);
1193
+ }
1194
+ return cursors;
1195
+ }
1196
+
1197
+ async _restoreScopeSet(keys, scopes, stats, maxLocalSeq, progress = null) {
1198
+ const restoredByScope = new Map();
1199
+ let nextMaxLocalSeq = maxLocalSeq;
1200
+
1201
+ let scopeIndex = 0;
1202
+ for (const scope of scopes) {
1203
+ progress?.scope?.(scope, scopeIndex, scopes.length);
1204
+ stats.scopes++;
1205
+ const restored = await this._restoreSnapshot(scope, progress);
1206
+ restoredByScope.set(scopeKey(scope), restored);
1207
+ if (restored) {
1208
+ stats.snapshots++;
1209
+ stats.snapshotObjects += restored.objects;
1210
+ this._rememberSnapshotCursors(scope, restored.cursors);
1211
+ if (restored.node === this.node.id) nextMaxLocalSeq = Math.max(nextMaxLocalSeq, restored.seq);
1212
+ }
1213
+ scopeIndex++;
1214
+ progress?.scope?.(scope, scopeIndex, scopes.length);
1215
+ await yieldToEventLoop();
1216
+ }
1217
+
1218
+ scopeIndex = 0;
1219
+ for (const scope of scopes) {
1220
+ if (this.restoreSnapshotId) continue;
1221
+ progress?.scope?.(scope, scopeIndex, scopes.length);
1222
+ const snapshotCursors = restoredByScope.get(scopeKey(scope))?.cursors || {};
1223
+ const journalKeys = keys.filter((key) =>
1224
+ key.startsWith(`${scopePrefix(this.prefix, scope)}/journal/`) && key.endsWith(".jsonl"));
1225
+ for (const key of journalKeys.sort()) {
1226
+ const journalInfo = parseJournalKey(key, this.prefix);
1227
+ const sourceFromKey = journalInfo?.node;
1228
+ const coveredFromKey = Number(sourceFromKey ? snapshotCursors[sourceFromKey] : 0) || 0;
1229
+ if (journalInfo?.lastSeq && coveredFromKey >= journalInfo.lastSeq) {
1230
+ stats.skippedJournals++;
1231
+ stats.skippedJournalOps += journalInfo.count || 0;
1232
+ progress?.add?.("skippedJournals", 1);
1233
+ progress?.add?.("skippedJournalOps", journalInfo.count || 0);
1234
+ continue;
1235
+ }
1236
+
1237
+ const restored = await this._restoreJournalSegment(scope, key, snapshotCursors, progress);
1238
+ stats.journals += restored.journals;
1239
+ stats.journalOps += restored.ops;
1240
+ stats.skippedJournals += restored.skippedJournals;
1241
+ stats.skippedJournalOps += restored.skippedOps;
1242
+ progress?.add?.("skippedJournals", restored.skippedJournals);
1243
+ progress?.add?.("skippedJournalOps", restored.skippedOps);
1244
+ nextMaxLocalSeq = Math.max(nextMaxLocalSeq, restored.maxLocalSeq);
1245
+ }
1246
+ scopeIndex++;
1247
+ progress?.scope?.(scope, scopeIndex, scopes.length);
1248
+ await yieldToEventLoop();
1249
+ }
1250
+
1251
+ return nextMaxLocalSeq;
1252
+ }
1253
+
1254
+ _rememberSnapshotCursors(scope, cursors = {}) {
1255
+ if (!this.node._cursorKey) return;
1256
+ for (const [sourceNode, seq] of Object.entries(cursors || {})) {
1257
+ if (!sourceNode || sourceNode === "persistence") continue;
1258
+ const cursorKey = this.node._cursorKey(sourceNode, scope);
1259
+ this.node.lastSeen.set(cursorKey, Math.max(this.node.lastSeen.get(cursorKey) || 0, Number(seq) || 0));
1260
+ }
1261
+ }
1262
+
1263
+ _rememberUnscopedCursors(scopes) {
1264
+ if (!this.node._cursorKey || !scopes?.length) return;
1265
+ const bySource = new Map();
1266
+ for (const [key, seen] of this.node.lastSeen || []) {
1267
+ if (typeof key !== "string") continue;
1268
+ for (const scope of scopes) {
1269
+ const suffix = `|${scope.database}/${scope.collection}/${scope.shard}`;
1270
+ if (!key.endsWith(suffix)) continue;
1271
+ const sourceNode = key.slice(0, -suffix.length);
1272
+ if (!sourceNode) continue;
1273
+ const entry = bySource.get(sourceNode) || { count: 0, min: Infinity };
1274
+ entry.count++;
1275
+ entry.min = Math.min(entry.min, Number(seen) || 0);
1276
+ bySource.set(sourceNode, entry);
1277
+ }
1278
+ }
1279
+ for (const [sourceNode, entry] of bySource) {
1280
+ if (entry.count < scopes.length || !Number.isFinite(entry.min) || entry.min <= 0) continue;
1281
+ this.node.lastSeen.set(this.node._cursorKey(sourceNode), Math.max(this.node.lastSeen.get(sourceNode) || 0, entry.min));
1282
+ }
1283
+ }
1284
+
1285
+ _rememberScopeSetCursors(target, scopes) {
1286
+ if (!this.node._cursorKey || !target || !scopes?.length) return;
1287
+ const matchingScopes = scopes.filter((scope) => matchesRestoreTarget(scope, target));
1288
+ if (!matchingScopes.length) return;
1289
+ const bySource = new Map();
1290
+
1291
+ for (const [key, seen] of this.node.lastSeen || []) {
1292
+ if (typeof key !== "string") continue;
1293
+ for (const scope of matchingScopes) {
1294
+ const suffix = `|${scope.database}/${scope.collection}/${scope.shard}`;
1295
+ if (!key.endsWith(suffix)) continue;
1296
+ const sourceNode = key.slice(0, -suffix.length);
1297
+ if (!sourceNode) continue;
1298
+ const entry = bySource.get(sourceNode) || { count: 0, min: Infinity };
1299
+ entry.count++;
1300
+ entry.min = Math.min(entry.min, Number(seen) || 0);
1301
+ bySource.set(sourceNode, entry);
1302
+ }
1303
+ }
1304
+
1305
+ for (const [sourceNode, entry] of bySource) {
1306
+ if (entry.count < matchingScopes.length || !Number.isFinite(entry.min) || entry.min <= 0) continue;
1307
+ const cursorKey = this.node._cursorKey(sourceNode, target);
1308
+ this.node.lastSeen.set(cursorKey, Math.max(this.node.lastSeen.get(cursorKey) || 0, entry.min));
1309
+ }
1310
+ }
1311
+ }
1312
+
1313
+ export function createPersistence(node, config = {}) {
1314
+ const mode = config.mode || "off";
1315
+ if (mode === "off" || mode === false) return null;
1316
+ if (mode !== "s3" && mode !== "memory") {
1317
+ throw new Error(`unsupported persistence mode: ${mode}`);
1318
+ }
1319
+
1320
+ const storage = createObjectStorage({ mode, s3: config.s3 || {} });
1321
+
1322
+ return new PersistenceManager({
1323
+ node,
1324
+ storage,
1325
+ prefix: config.prefix || DEFAULT_DATA_PREFIX,
1326
+ flushMs: config.flushMs ?? 5000,
1327
+ opBatchSize: config.opBatchSize ?? 1000,
1328
+ journalMaxBytes: config.journalMaxBytes ?? DEFAULT_JOURNAL_BYTES,
1329
+ snapshotMs: config.snapshotMs ?? 60_000,
1330
+ segmentBytes: config.segmentBytes ?? DEFAULT_SEGMENT_BYTES,
1331
+ compactJournals: config.compactJournals !== false,
1332
+ snapshotRetention: config.snapshotRetention,
1333
+ journalRetention: config.journalRetention,
1334
+ snapshotDirtyOnly: config.snapshotDirtyOnly !== false,
1335
+ snapshotRole: config.snapshotRole || "primary",
1336
+ restoreSnapshotId: config.restoreSnapshotId,
1337
+ restoreScopes: config.restoreScopes,
1338
+ ack: config.ack || "memory",
1339
+ localWal: config.localWal,
1340
+ durable: mode === "s3",
1341
+ });
1342
+ }
1343
+
1344
+ function* chunkJsonlIterable(rows, maxBytes, label) {
1345
+ let current = [];
1346
+ let bytes = 0;
1347
+
1348
+ function makeChunk() {
1349
+ return { body: current.length ? `${current.join("\n")}\n` : "", count: current.length, bytes };
1350
+ }
1351
+
1352
+ let i = 0;
1353
+ for (const value of rows) {
1354
+ const row = stringifyJson(value, `${label} row ${i}`);
1355
+ i++;
1356
+ const rowBytes = Buffer.byteLength(row);
1357
+ const nextBytes = bytes + rowBytes + 1;
1358
+ if (current.length && nextBytes > maxBytes) {
1359
+ yield makeChunk();
1360
+ current = [];
1361
+ bytes = 0;
1362
+ }
1363
+ current.push(row);
1364
+ bytes += rowBytes + 1;
1365
+ }
1366
+
1367
+ if (current.length) {
1368
+ yield makeChunk();
1369
+ return;
1370
+ }
1371
+ yield { body: "", count: 0, bytes: 0 };
1372
+ }
1373
+
1374
+ function jsonlBody(values, label) {
1375
+ if (!values?.length) return "";
1376
+ return `${values.map((value, i) => stringifyJson(value, `${label} row ${i}`)).join("\n")}\n`;
1377
+ }
1378
+
1379
+ async function* jsonlObjectsFromStorage(storage, key, label) {
1380
+ if (typeof storage.getStream === "function") {
1381
+ const stream = await storage.getStream(key);
1382
+ if (stream === null) throw new Error(`segment missing: ${key}`);
1383
+ yield* parseJsonlStream(stream, label);
1384
+ return;
1385
+ }
1386
+
1387
+ const body = await storage.get(key);
1388
+ if (body === null) throw new Error(`segment missing: ${key}`);
1389
+ yield* parseJsonlText(body, label);
1390
+ }
1391
+
1392
+ async function* parseJsonlStream(stream, label) {
1393
+ let buffer = "";
1394
+ let line = 0;
1395
+ let yielded = 0;
1396
+ for await (const chunk of stream) {
1397
+ buffer += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
1398
+ const lines = buffer.split(/\r?\n/);
1399
+ buffer = lines.pop() || "";
1400
+ for (const raw of lines) {
1401
+ line++;
1402
+ const trimmed = raw.trim();
1403
+ if (trimmed) {
1404
+ yield parseJsonLine(trimmed, label, line);
1405
+ yielded++;
1406
+ if (yielded % RESTORE_PARSE_YIELD_LINES === 0) await yieldToEventLoop();
1407
+ }
1408
+ }
1409
+ }
1410
+ const trimmed = buffer.trim();
1411
+ if (trimmed) yield parseJsonLine(trimmed, label, line + 1);
1412
+ }
1413
+
1414
+ function* parseJsonlText(body, label) {
1415
+ const lines = String(body || "").split(/\r?\n/);
1416
+ for (let i = 0; i < lines.length; i++) {
1417
+ const trimmed = lines[i].trim();
1418
+ if (trimmed) yield parseJsonLine(trimmed, label, i + 1);
1419
+ }
1420
+ }
1421
+
1422
+ function parseJsonLine(line, label, number) {
1423
+ try {
1424
+ return JSON.parse(line);
1425
+ } catch (err) {
1426
+ throw new Error(`invalid JSONL in ${label} at line ${number}: ${err.message}`);
1427
+ }
1428
+ }
1429
+
1430
+ function emptyJournalRestoreStats(maxLocalSeq = 0) {
1431
+ return { journals: 0, ops: 0, skippedJournals: 0, skippedOps: 0, maxLocalSeq };
1432
+ }
1433
+
1434
+ function isCompletedTargetManifest(raw, topologyId, scope) {
1435
+ if (raw === null) return false;
1436
+ try {
1437
+ const manifest = JSON.parse(raw);
1438
+ return manifest.topologyId === topologyId
1439
+ && manifest.database === scope.database
1440
+ && manifest.collection === scope.collection
1441
+ && manifest.shard === scope.shard;
1442
+ } catch {
1443
+ return false;
1444
+ }
1445
+ }
1446
+
1447
+ function restoreStats() {
1448
+ return {
1449
+ scopes: 0,
1450
+ databases: 0,
1451
+ metadata: 0,
1452
+ databaseTombstones: 0,
1453
+ collectionTombstones: 0,
1454
+ snapshots: 0,
1455
+ snapshotObjects: 0,
1456
+ journals: 0,
1457
+ journalOps: 0,
1458
+ localWalRecords: 0,
1459
+ localWalOps: 0,
1460
+ localWalDatabases: 0,
1461
+ localWalDatabaseDrops: 0,
1462
+ localWalMetadata: 0,
1463
+ localWalCollectionDrops: 0,
1464
+ localWalSkippedPartialRecords: 0,
1465
+ skippedJournals: 0,
1466
+ skippedJournalOps: 0,
1467
+ };
1468
+ }
1469
+
1470
+ function maxLocalSeqForOps(ops, nodeId) {
1471
+ let max = 0;
1472
+ for (const op of ops || []) {
1473
+ if (op?.from !== nodeId) continue;
1474
+ const seq = Number(op.seq);
1475
+ if (Number.isFinite(seq) && seq > max) max = seq;
1476
+ }
1477
+ return max;
1478
+ }
1479
+
1480
+ function stringifyJson(value, label) {
1481
+ try {
1482
+ return JSON.stringify(value);
1483
+ } catch (err) {
1484
+ if (err instanceof RangeError) {
1485
+ throw new Error(`JSON too large while serializing ${label}; reduce segment size or split oversized objects`);
1486
+ }
1487
+ throw err;
1488
+ }
1489
+ }
1490
+
1491
+ function jsonBytes(value, label) {
1492
+ return Buffer.byteLength(stringifyJson(value, label));
1493
+ }
1494
+
1495
+ function pendingOpsJsonBytes(pending) {
1496
+ let total = 0;
1497
+ for (const ops of pending.values()) total += jsonBytes(ops, "pending journal ops");
1498
+ return total;
1499
+ }
1500
+
1501
+ function pendingWalJsonBytes(records) {
1502
+ let total = 0;
1503
+ for (const record of records || []) total += jsonBytes(record, "pending local WAL record") + 1;
1504
+ return total;
1505
+ }
1506
+
1507
+ function* chunkOpsForRecord(ops, { maxBytes, maxOps, label }) {
1508
+ let current = [];
1509
+ let bytes = 2;
1510
+
1511
+ for (let i = 0; i < ops.length; i++) {
1512
+ const op = stringifyJson(ops[i], `${label} op ${i}`);
1513
+ const opBytes = Buffer.byteLength(op);
1514
+ const nextBytes = bytes + (current.length ? 1 : 0) + opBytes;
1515
+ if (current.length && (current.length >= maxOps || nextBytes > maxBytes)) {
1516
+ yield current;
1517
+ current = [];
1518
+ bytes = 2;
1519
+ }
1520
+ current.push(ops[i]);
1521
+ bytes += (current.length > 1 ? 1 : 0) + opBytes;
1522
+ }
1523
+
1524
+ if (current.length) yield current;
1525
+ }
1526
+
1527
+ function uniqueScopes(keys, prefix) {
1528
+ const scopes = new Map();
1529
+ for (const key of keys) {
1530
+ const scope = parseScopeFromKey(key, prefix);
1531
+ if (scope) scopes.set(scopeKey(scope), scope);
1532
+ }
1533
+ return [...scopes.values()].sort((a, b) => scopeKey(a).localeCompare(scopeKey(b)));
1534
+ }
1535
+
1536
+ function collectionTombstones(keys, prefix) {
1537
+ const deleted = new Set();
1538
+ for (const key of keys) {
1539
+ const target = parseCollectionTombstoneFromKey(key, prefix);
1540
+ if (target) deleted.add(collectionKey(target.database, target.collection));
1541
+ }
1542
+ return deleted;
1543
+ }
1544
+
1545
+ function databaseTombstones(keys, prefix) {
1546
+ const deleted = new Set();
1547
+ for (const key of keys) {
1548
+ const database = parseDatabaseTombstoneFromKey(key, prefix);
1549
+ if (database) deleted.add(database);
1550
+ }
1551
+ return deleted;
1552
+ }
1553
+
1554
+ function rememberRestoredCursor(node, sourceNode, scope, ops) {
1555
+ if (!sourceNode || sourceNode === "persistence" || !node._cursorKey) return;
1556
+ const cursorKey = node._cursorKey(sourceNode, scope);
1557
+ let seen = node.lastSeen.get(cursorKey) || 0;
1558
+ for (const op of ops || []) {
1559
+ const seq = Number(op.seq);
1560
+ if (Number.isFinite(seq) && seq > seen) seen = seq;
1561
+ }
1562
+ if (seen) node.lastSeen.set(cursorKey, seen);
1563
+ }
1564
+
1565
+ function rememberRestoredCursors(node, sourceNode, ops) {
1566
+ const byScope = new Map();
1567
+ for (const op of ops || []) {
1568
+ const scope = opScope(op, node);
1569
+ const key = scopeKey(scope);
1570
+ const scoped = byScope.get(key) || { scope, ops: [] };
1571
+ scoped.ops.push(op);
1572
+ byScope.set(key, scoped);
1573
+ }
1574
+ for (const { scope, ops: scopedOps } of byScope.values()) {
1575
+ rememberRestoredCursor(node, sourceNode, scope, scopedOps);
1576
+ }
1577
+ }
1578
+
1579
+ function emptyLocalFlushStats() {
1580
+ return { records: 0, ops: 0, databases: 0, databaseDrops: 0, metadata: 0, collectionDrops: 0, bytes: 0 };
1581
+ }
1582
+
1583
+ function mergeLocalFlushStats(target, source = {}) {
1584
+ target.records += Number(source.records) || 0;
1585
+ target.ops += Number(source.ops) || 0;
1586
+ target.databases += Number(source.databases) || 0;
1587
+ target.databaseDrops += Number(source.databaseDrops) || 0;
1588
+ target.metadata += Number(source.metadata) || 0;
1589
+ target.collectionDrops += Number(source.collectionDrops) || 0;
1590
+ target.bytes += Number(source.bytes) || 0;
1591
+ return target;
1592
+ }
1593
+
1594
+ function createLocalWalStore(config, nodeId) {
1595
+ const mode = localWalMode(config);
1596
+ if (mode === "off") return null;
1597
+ if (mode !== "on") throw new Error(`unsupported local WAL mode: ${mode}`);
1598
+ return new LocalWalStore({
1599
+ nodeId,
1600
+ dir: config?.dir || DEFAULT_LOCAL_WAL_DIR,
1601
+ fsync: config?.fsync !== false,
1602
+ flushMs: config?.flushMs ?? 0,
1603
+ maxRecordBytes: config?.maxRecordBytes,
1604
+ maxOpsPerRecord: config?.maxOpsPerRecord,
1605
+ });
1606
+ }
1607
+
1608
+ function localWalMode(config = {}) {
1609
+ const mode = config?.mode ?? "off";
1610
+ if (mode === true) return "on";
1611
+ if (mode === false) return "off";
1612
+ return mode;
1613
+ }
1614
+
1615
+ function normalizeAckMode(value) {
1616
+ const mode = value || "memory";
1617
+ if (mode === "memory") return "memory";
1618
+ if (mode === "localWal" || mode === "local-wal" || mode === "wal") return "localWal";
1619
+ throw new Error("persistence.ack must be one of: memory, localWal");
1620
+ }
1621
+
1622
+ function commonScope(ops) {
1623
+ if (!ops.length) return {};
1624
+ const first = opScope(ops[0]);
1625
+ for (const op of ops.slice(1)) {
1626
+ const next = opScope(op);
1627
+ if (
1628
+ next.database !== first.database ||
1629
+ next.collection !== first.collection ||
1630
+ next.shard !== first.shard
1631
+ ) {
1632
+ return {};
1633
+ }
1634
+ }
1635
+ return first;
1636
+ }
1637
+
1638
+ function seqRange(ops) {
1639
+ const seqs = (ops || []).map((op) => Number(op.seq)).filter(Number.isFinite);
1640
+ return {
1641
+ firstSeq: seqs.length ? Math.min(...seqs) : 0,
1642
+ lastSeq: seqs.length ? Math.max(...seqs) : 0,
1643
+ };
1644
+ }
1645
+
1646
+ function opScope(op, node) {
1647
+ return {
1648
+ database: op.db,
1649
+ collection: op.coll,
1650
+ shard: op.shard || node?.shardForObject(op.db, op.coll, op.id) || DEFAULT_SHARD,
1651
+ };
1652
+ }
1653
+
1654
+ function matchesRestoreTarget(scope, target) {
1655
+ return (
1656
+ (target.database === GLOBAL_DATABASE || target.database === scope.database) &&
1657
+ (target.collection === GLOBAL_COLLECTION || target.collection === scope.collection) &&
1658
+ target.shard === scope.shard
1659
+ );
1660
+ }
1661
+
1662
+ function matchesRestoreCollection(scope, target) {
1663
+ return (
1664
+ (target.database === GLOBAL_DATABASE || target.database === scope.database) &&
1665
+ (target.collection === GLOBAL_COLLECTION || target.collection === scope.collection)
1666
+ );
1667
+ }
1668
+
1669
+ function parseScopeFromKey(key, prefix) {
1670
+ const root = normalizePrefix(prefix);
1671
+ const expected = root ? `${root}/` : "";
1672
+ if (!key.startsWith(expected)) return null;
1673
+ const parts = key.slice(expected.length).split("/");
1674
+ if (parts[0] !== "db" || parts[2] !== "collection" || parts[4] !== "shard") return null;
1675
+ if (!parts[1] || !parts[3] || !parts[5]) return null;
1676
+ return {
1677
+ database: decodeURIComponent(parts[1]),
1678
+ collection: decodeURIComponent(parts[3]),
1679
+ shard: decodeURIComponent(parts[5]),
1680
+ };
1681
+ }
1682
+
1683
+ function parseJournalKey(key, prefix) {
1684
+ const scope = parseScopeFromKey(key, prefix);
1685
+ if (!scope) return null;
1686
+ const root = normalizePrefix(prefix);
1687
+ const expected = root ? `${root}/` : "";
1688
+ const parts = key.slice(expected.length).split("/");
1689
+ if (parts[6] !== "journal" || !parts[7] || !parts[8]) return null;
1690
+ const file = parts[8];
1691
+ const match = file.match(/^(\d+)-(\d+)-(\d+)-.+\.jsonl$/);
1692
+ const createdAtMs = match ? Number(match[1]) : 0;
1693
+ const firstSeq = match ? Number(match[2]) : 0;
1694
+ const lastSeq = match ? Number(match[3]) : 0;
1695
+ const count = lastSeq >= firstSeq && firstSeq > 0 ? lastSeq - firstSeq + 1 : 0;
1696
+ return {
1697
+ ...scope,
1698
+ node: decodeURIComponent(parts[7]),
1699
+ createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : 0,
1700
+ firstSeq: Number.isFinite(firstSeq) ? firstSeq : 0,
1701
+ lastSeq: Number.isFinite(lastSeq) ? lastSeq : 0,
1702
+ count,
1703
+ };
1704
+ }
1705
+
1706
+ function parseMetaFromKey(key, prefix) {
1707
+ const root = normalizePrefix(prefix);
1708
+ const expected = root ? `${root}/` : "";
1709
+ if (!key.startsWith(expected)) return null;
1710
+ const parts = key.slice(expected.length).split("/");
1711
+ if (parts[0] !== "db" || parts[2] !== "collection" || parts[4] !== "meta.json") return null;
1712
+ if (!parts[1] || !parts[3]) return null;
1713
+ return {
1714
+ database: decodeURIComponent(parts[1]),
1715
+ collection: decodeURIComponent(parts[3]),
1716
+ };
1717
+ }
1718
+
1719
+ function parseCollectionTombstoneFromKey(key, prefix) {
1720
+ const root = normalizePrefix(prefix);
1721
+ const expected = root ? `${root}/` : "";
1722
+ if (!key.startsWith(expected)) return null;
1723
+ const parts = key.slice(expected.length).split("/");
1724
+ if (parts[0] !== "db" || parts[2] !== "collection-tombstones" || !parts[3]?.endsWith(".json")) return null;
1725
+ if (!parts[1]) return null;
1726
+ return {
1727
+ database: decodeURIComponent(parts[1]),
1728
+ collection: decodeURIComponent(parts[3].slice(0, -5)),
1729
+ };
1730
+ }
1731
+
1732
+ function parseDatabaseMetaFromKey(key, prefix) {
1733
+ const root = normalizePrefix(prefix);
1734
+ const expected = root ? `${root}/` : "";
1735
+ if (!key.startsWith(expected)) return null;
1736
+ const parts = key.slice(expected.length).split("/");
1737
+ if (parts[0] !== "db" || parts[2] !== "meta.json") return null;
1738
+ if (!parts[1]) return null;
1739
+ return {
1740
+ database: decodeURIComponent(parts[1]),
1741
+ };
1742
+ }
1743
+
1744
+ function parseDatabaseTombstoneFromKey(key, prefix) {
1745
+ const root = normalizePrefix(prefix);
1746
+ const expected = root ? `${root}/` : "";
1747
+ if (!key.startsWith(expected)) return null;
1748
+ const parts = key.slice(expected.length).split("/");
1749
+ if (parts[0] !== "database-tombstones" || !parts[1]?.endsWith(".json")) return null;
1750
+ return decodeURIComponent(parts[1].slice(0, -5));
1751
+ }
1752
+
1753
+ function snapshotEntries(keys, prefix) {
1754
+ const byId = new Map();
1755
+ for (const key of keys) {
1756
+ if (!key.startsWith(prefix)) continue;
1757
+ const rest = key.slice(prefix.length);
1758
+ const id = rest.split("/")[0];
1759
+ if (!id || rest === id) continue;
1760
+ const entry = byId.get(id) || {
1761
+ id,
1762
+ createdAtMs: snapshotCreatedAtMs(id),
1763
+ keys: [],
1764
+ };
1765
+ entry.keys.push(key);
1766
+ byId.set(id, entry);
1767
+ }
1768
+ return [...byId.values()].sort((left, right) => {
1769
+ const created = right.createdAtMs - left.createdAtMs;
1770
+ return created || right.id.localeCompare(left.id);
1771
+ });
1772
+ }
1773
+
1774
+ function filterSnapshotRetention(snapshots, retention, latest = null) {
1775
+ if (!retentionEnabled(retention)) return [];
1776
+ const keep = new Set(latest?.id ? [latest.id] : []);
1777
+ const keepLatest = Math.max(0, Number(retention.keepLatest) || 0);
1778
+ for (const snapshot of snapshots.slice(0, keepLatest)) keep.add(snapshot.id);
1779
+
1780
+ const keepSince = retention.keepForMs > 0 ? Date.now() - retention.keepForMs : null;
1781
+ if (keepSince !== null) {
1782
+ for (const snapshot of snapshots) {
1783
+ if (!snapshot.createdAtMs || snapshot.createdAtMs >= keepSince) keep.add(snapshot.id);
1784
+ }
1785
+ }
1786
+
1787
+ return snapshots.filter((snapshot) => !keep.has(snapshot.id));
1788
+ }
1789
+
1790
+ function filterRetentionKeys(keys, retention, createdAtMsForKey) {
1791
+ if (!retentionEnabled(retention)) return keys;
1792
+ const keepLatest = Math.max(0, Number(retention.keepLatest) || 0);
1793
+ const sorted = [...keys].sort((left, right) => {
1794
+ const created = (createdAtMsForKey(right) || 0) - (createdAtMsForKey(left) || 0);
1795
+ return created || right.localeCompare(left);
1796
+ });
1797
+ const keep = new Set(sorted.slice(0, keepLatest));
1798
+ const keepSince = retention.keepForMs > 0 ? Date.now() - retention.keepForMs : null;
1799
+ if (keepSince !== null) {
1800
+ for (const key of sorted) {
1801
+ const createdAtMs = createdAtMsForKey(key) || 0;
1802
+ if (!createdAtMs || createdAtMs >= keepSince) keep.add(key);
1803
+ }
1804
+ }
1805
+ return sorted.filter((key) => !keep.has(key));
1806
+ }
1807
+
1808
+ function parseScopeKey(key) {
1809
+ const [database, collection, shard] = key.split("\n");
1810
+ return { database, collection, shard };
1811
+ }
1812
+
1813
+ function parseCollectionKey(key) {
1814
+ const [database, collection] = key.split("\n");
1815
+ return { database, collection };
1816
+ }
1817
+
1818
+ function scopeKey(scope) {
1819
+ return `${scope.database}\n${scope.collection}\n${scope.shard}`;
1820
+ }
1821
+
1822
+ function collectionKey(database, collection) {
1823
+ return `${database}\n${collection}`;
1824
+ }
1825
+
1826
+ function scopePrefix(prefix, scope) {
1827
+ return `${normalizePrefix(prefix)}/db/${safe(scope.database)}/collection/${safe(scope.collection)}/shard/${safe(scope.shard)}`.replace(/^\/+/, "");
1828
+ }
1829
+
1830
+ function collectionPrefix(prefix, database, collection) {
1831
+ return `${normalizePrefix(prefix)}/db/${safe(database)}/collection/${safe(collection)}`.replace(/^\/+/, "");
1832
+ }
1833
+
1834
+ function collectionTombstoneKey(prefix, database, collection) {
1835
+ return `${databasePrefix(prefix, database)}/collection-tombstones/${safe(collection)}.json`;
1836
+ }
1837
+
1838
+ function databasePrefix(prefix, database) {
1839
+ return `${normalizePrefix(prefix)}/db/${safe(database)}`.replace(/^\/+/, "");
1840
+ }
1841
+
1842
+ function databaseTombstoneKey(prefix, database) {
1843
+ return `${normalizePrefix(prefix)}/database-tombstones/${safe(database)}.json`.replace(/^\/+/, "");
1844
+ }
1845
+
1846
+ function snapshotId() {
1847
+ snapshotSequence = (snapshotSequence + 1) % 1_000_000;
1848
+ const seq = String(snapshotSequence).padStart(6, "0");
1849
+ return `${new Date().toISOString().replace(/[-:.]/g, "")}-${seq}-${randomUUID()}`;
1850
+ }
1851
+
1852
+ function snapshotCreatedAtMs(id) {
1853
+ const match = String(id || "").match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z/);
1854
+ if (!match) return 0;
1855
+ const [, year, month, day, hour, minute, second, ms] = match;
1856
+ return Date.UTC(
1857
+ Number(year),
1858
+ Number(month) - 1,
1859
+ Number(day),
1860
+ Number(hour),
1861
+ Number(minute),
1862
+ Number(second),
1863
+ Number(ms),
1864
+ );
1865
+ }
1866
+
1867
+ function safe(value) {
1868
+ return encodeURIComponent(String(value));
1869
+ }
1870
+
1871
+ function pad(value) {
1872
+ return String(Math.max(0, value)).padStart(16, "0");
1873
+ }
1874
+
1875
+ function clone(value) {
1876
+ return JSON.parse(JSON.stringify(value));
1877
+ }
1878
+
1879
+ function isPlainObject(value) {
1880
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1881
+ }
1882
+
1883
+ function normalizePrefix(prefix) {
1884
+ return String(prefix || "").replace(/^\/+|\/+$/g, "");
1885
+ }
1886
+
1887
+ function normalizeSnapshotRole(value) {
1888
+ if (value === "all" || value === "none" || value === "primary") return value;
1889
+ throw new Error("persistence.snapshotRole must be one of: primary, all, none");
1890
+ }
1891
+
1892
+ function normalizeRetention(config = {}) {
1893
+ return {
1894
+ keepLatest: positiveInt(config?.keepLatest, 0),
1895
+ keepForMs: positiveInt(config?.keepForMs, 0),
1896
+ };
1897
+ }
1898
+
1899
+ function retentionEnabled(retention = {}) {
1900
+ return (Number(retention.keepLatest) || 0) > 0 || (Number(retention.keepForMs) || 0) > 0;
1901
+ }
1902
+
1903
+ function normalizeSnapshotId(id) {
1904
+ if (typeof id !== "string" || id.length === 0) return null;
1905
+ if (id.includes("/")) throw new Error("persistence.restoreSnapshotId must not contain slashes");
1906
+ return id;
1907
+ }
1908
+
1909
+ function normalizeSnapshotCursors(manifest = {}) {
1910
+ const cursors = isPlainObject(manifest.cursors) ? { ...manifest.cursors } : {};
1911
+ if (manifest.node) cursors[manifest.node] = Math.max(Number(cursors[manifest.node]) || 0, Number(manifest.seq) || 0);
1912
+ return cursors;
1913
+ }
1914
+
1915
+ function positiveInt(value, fallback) {
1916
+ const n = Number(value);
1917
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
1918
+ }