@anteros/core 0.0.40 → 0.0.41

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 (59) hide show
  1. package/database/audit.ts +193 -0
  2. package/database/collection.ts +3 -3
  3. package/database/deleteLog.ts +34 -0
  4. package/database/file.ts +3 -3
  5. package/database/mongodbadapter.ts +321 -147
  6. package/database/replication.ts +780 -0
  7. package/database/schema.ts +140 -125
  8. package/database/tenant.ts +4 -0
  9. package/database/vars.ts +405 -0
  10. package/database/workflow.ts +6 -6
  11. package/index.ts +10 -0
  12. package/lib/audit.ts +271 -0
  13. package/lib/auditResult.ts +120 -0
  14. package/lib/define.ts +20 -1
  15. package/lib/files.ts +64 -11
  16. package/lib/lifecycle.ts +141 -0
  17. package/lib/redact.ts +117 -0
  18. package/lib/workflow.ts +1 -1
  19. package/package.json +1 -1
  20. package/server/api.ts +109 -25
  21. package/server/boot.ts +185 -53
  22. package/server/flags.ts +136 -0
  23. package/server/hono.ts +1 -1
  24. package/tests/audit-file.test.ts +296 -0
  25. package/tests/audit-operations.test.ts +184 -0
  26. package/tests/audit-redact.test.ts +233 -0
  27. package/tests/audit-results.test.ts +188 -0
  28. package/tests/audit-retention-config.test.ts +76 -0
  29. package/tests/audit-run-action.test.ts +120 -0
  30. package/tests/audit.test.ts +320 -0
  31. package/tests/db-cache.test.ts +7 -7
  32. package/tests/file-collection-replication.test.ts +98 -0
  33. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  34. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  35. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  36. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  37. package/tests/fixtures/repl-only/app.ts +26 -0
  38. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  39. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  40. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  41. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  42. package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
  43. package/tests/lifecycle.test.ts +96 -0
  44. package/tests/replication-control.test.ts +128 -0
  45. package/tests/replication-initial-sync.test.ts +86 -0
  46. package/tests/replication-only.test.ts +358 -0
  47. package/tests/replication.test.ts +188 -0
  48. package/tests/upload.test.ts +102 -1
  49. package/tests/vars-api.test.ts +123 -0
  50. package/tests/vars-replication.test.ts +95 -0
  51. package/tests/vars.test.ts +216 -0
  52. package/types/collection.d.ts +3 -0
  53. package/types/config.d.ts +64 -0
  54. package/types/file.d.ts +4 -1
  55. package/types/lifecycle.d.ts +71 -0
  56. package/types/replication.d.ts +199 -0
  57. package/types/tenant.d.ts +23 -0
  58. package/types/vars.d.ts +147 -0
  59. package/utils/func.ts +67 -52
@@ -0,0 +1,780 @@
1
+ import { MongoClient, ObjectId, type Db } from "mongodb";
2
+ import { cfg } from "../server/config";
3
+ import { getTenant } from "./tenant";
4
+ import { useRest } from "./rest";
5
+ import { logger } from "../utils/logger";
6
+ import * as func from "../utils/func";
7
+ import type { Collection } from "../types/collection";
8
+ import type { Tenant } from "../types/tenant";
9
+ import type { HookContext } from "../types/hook";
10
+ import type {
11
+ ReplicationConfig,
12
+ ReplicationDestination,
13
+ ReplicationInitialSync,
14
+ ReplicationResetOptions,
15
+ ReplicationResetResult,
16
+ ReplicationRunResult,
17
+ ReplicationSeedOptions,
18
+ ReplicationSeedResult,
19
+ ReplicationSeedValue,
20
+ ReplicationState,
21
+ ReplicationStats,
22
+ } from "../types/replication";
23
+
24
+ const META_COLLECTION = "_replication_";
25
+ const DEFAULT_KEY = "updatedAt";
26
+ const DEFAULT_BATCH = 1000;
27
+ /** MongoDB `bulkWrite` is capped per call — split batches into chunks of this size. */
28
+ const BULK_CHUNK = 1000;
29
+ /** Distributed-lock TTL used to guard a run across cluster workers. */
30
+ const LOCK_TTL = 5 * 60_000;
31
+
32
+ type Runtime = {
33
+ tenantId: string;
34
+ destination: ReplicationDestination;
35
+ client: MongoClient;
36
+ db: Db;
37
+ running: boolean;
38
+ };
39
+
40
+ type Job = { tenant: string; stop: () => void };
41
+
42
+ const runtimes = new Map<string, Runtime>();
43
+ const jobs: Job[] = [];
44
+ /** Ensured indexes / collections — avoids re-creating them on every run. */
45
+ const ensured = new Set<string>();
46
+ let started = false;
47
+
48
+ /* ------------------------------------------------------------------ */
49
+ /* Config */
50
+ /* ------------------------------------------------------------------ */
51
+
52
+ function resolveConfig(tenant: Tenant): ReplicationConfig | null {
53
+ const config = tenant.replication;
54
+ if (!config) return null;
55
+ if (config.enabled === false) return null;
56
+ if (!config.destinations?.length) return null;
57
+ validateReplication(config);
58
+ return config;
59
+ }
60
+
61
+ function validateReplication(config: ReplicationConfig): void {
62
+ for (const destination of config.destinations ?? []) {
63
+ if (!destination.id || !String(destination.id).trim()) {
64
+ throw new Error("replication.destinations[].id is required");
65
+ }
66
+ if (!destination.uri || !String(destination.uri).trim()) {
67
+ throw new Error(`replication.destinations['${destination.id}'].uri is required`);
68
+ }
69
+ }
70
+ }
71
+
72
+ function resolveKey(collection: Collection, config: ReplicationConfig): string {
73
+ return collection.replication?.key ?? config.key ?? DEFAULT_KEY;
74
+ }
75
+
76
+ function stateIdFor(tenantId: string, destinationId: string, collection: string): string {
77
+ return `rep:${tenantId}:${destinationId}:${collection}`;
78
+ }
79
+
80
+ function deleteMarkerId(collection: Collection, docId: string): string {
81
+ return `rep.delete:${collection._tenant_}:${collection.slug}:${docId}`;
82
+ }
83
+
84
+ function filterCollections(collections: Collection[], destination: ReplicationDestination): Collection[] {
85
+ if (!destination.collections?.length) return collections;
86
+ const wanted = new Set(destination.collections);
87
+ return collections.filter((c) => wanted.has(c.slug));
88
+ }
89
+
90
+ function runtimesFor(tenantId: string): Runtime[] {
91
+ return [...runtimes.values()].filter((rt) => rt.tenantId === tenantId && rt.destination.enabled !== false);
92
+ }
93
+
94
+ /** Active destination ids that must apply deletes for the given collection. */
95
+ function deleteDestinationsFor(config: ReplicationConfig, collection: Collection): string[] {
96
+ return (config.destinations ?? [])
97
+ .filter((d) => d.enabled !== false && d.id && d.uri)
98
+ .filter((d) => !d.collections?.length || d.collections.includes(collection.slug))
99
+ .map((d) => d.id);
100
+ }
101
+
102
+ /* ------------------------------------------------------------------ */
103
+ /* Incremental helpers */
104
+ /* ------------------------------------------------------------------ */
105
+
106
+ /**
107
+ * `key > cursor.value` OR (`key == cursor.value` AND `_id > cursor.id`).
108
+ * The `_id` tie-breaker guarantees no document is skipped or duplicated when
109
+ * several documents share the same timestamp.
110
+ */
111
+ function incrementalFilter(key: string, cursor: { value: any; id: any } | null): Record<string, any> {
112
+ if (!cursor || cursor.value === null || cursor.value === undefined) return {};
113
+ return {
114
+ $or: [
115
+ { [key]: { $gt: cursor.value } },
116
+ { [key]: cursor.value, _id: { $gt: cursor.id } },
117
+ ],
118
+ };
119
+ }
120
+
121
+ /** Combine a collection's base filter (if any) with the incremental cursor filter. */
122
+ function mergedFilter(base: Record<string, any> | undefined, incremental: Record<string, any>): Record<string, any> {
123
+ if (!base) return incremental;
124
+ return Object.keys(incremental).length ? { $and: [base, incremental] } : base;
125
+ }
126
+
127
+ /** Rewind the cursor by `lookback` ms to catch out-of-order / clock-skewed writes. */
128
+ function applyLookback(cursor: { value: any; id: any } | null, lookback: number): { value: any; id: any } | null {
129
+ if (!cursor || cursor.value === null || cursor.value === undefined || !lookback) return cursor;
130
+ const value = cursor.value instanceof Date ? new Date(cursor.value.getTime() - lookback) : cursor.value;
131
+ return { value, id: cursor.id };
132
+ }
133
+
134
+ /**
135
+ * Resolve the initial cursor from `initialSync` — used only when no
136
+ * `_replication_` state exists yet (first run), so a pre-loaded destination can
137
+ * skip the full backfill.
138
+ * - `"full"` → no filter (backfill everything)
139
+ * - `"skip"` → start from now
140
+ * - `"latest"` → max `(key, _id)` of the source
141
+ * - ISO string / `Date` → start from that date
142
+ */
143
+ async function resolveInitialSync(source: any, key: string, initialSync: ReplicationInitialSync): Promise<{ value: any; id: any } | null> {
144
+ if (initialSync === "full") return null;
145
+ if (initialSync === "skip") return { value: new Date(), id: null };
146
+ if (initialSync === "latest") {
147
+ const docs = await source.find().sort({ [key]: -1, _id: -1 } as any).limit(1).toArray();
148
+ const last = docs[0];
149
+ if (last) return { value: last[key] ?? null, id: last._id ?? null };
150
+ return null;
151
+ }
152
+ const value = initialSync instanceof Date ? initialSync : new Date(initialSync);
153
+ return { value, id: null };
154
+ }
155
+
156
+ function toId(value: any): any {
157
+ if (value instanceof ObjectId) return value;
158
+ if (typeof value === "string" && ObjectId.isValid(value)) return new ObjectId(value);
159
+ return value;
160
+ }
161
+
162
+ /** Retry a transient operation with exponential backoff. */
163
+ async function withRetry<T>(fn: () => Promise<T>, attempts = 3, baseMs = 250): Promise<T> {
164
+ let lastError: any;
165
+ for (let i = 0; i < attempts; i++) {
166
+ try {
167
+ return await fn();
168
+ } catch (err: any) {
169
+ lastError = err;
170
+ if (i < attempts - 1) await Bun.sleep(baseMs * Math.pow(2, i));
171
+ }
172
+ }
173
+ throw lastError;
174
+ }
175
+
176
+ async function ensureIndex(db: Db, slug: string, key: string, scope: string): Promise<void> {
177
+ const cacheKey = `${scope}:${db.databaseName}:${slug}:${key}`;
178
+ if (ensured.has(cacheKey)) return;
179
+ ensured.add(cacheKey);
180
+ try {
181
+ const exists = await db.listCollections({ name: slug }).toArray();
182
+ if (!exists.length) {
183
+ try { await db.createCollection(slug); } catch { /* created concurrently */ }
184
+ }
185
+ const col = db.collection(slug);
186
+ try { await col.createIndex({ [key]: 1, _id: 1 }); } catch { /* ignore */ }
187
+ try { await col.createIndex({ createdAt: -1, updatedAt: -1 }); } catch { /* ignore */ }
188
+ } catch (err: any) {
189
+ logger.file("warn", "replication: failed to ensure collection/index", { collection: slug, scope, error: err?.message });
190
+ }
191
+ }
192
+
193
+ /* ------------------------------------------------------------------ */
194
+ /* State (`_replication_`) */
195
+ /* ------------------------------------------------------------------ */
196
+
197
+ async function saveState(
198
+ meta: any,
199
+ stateId: string,
200
+ tenant: Tenant,
201
+ runtime: Runtime,
202
+ collection: Collection,
203
+ key: string,
204
+ cursor: { value: any; id: any },
205
+ stats: ReplicationStats,
206
+ status: ReplicationState["status"],
207
+ error: string | null,
208
+ ): Promise<void> {
209
+ const now = new Date();
210
+ await meta.updateOne(
211
+ { _id: stateId },
212
+ {
213
+ $set: {
214
+ type: "state",
215
+ tenant: tenant.id,
216
+ destination: runtime.destination.id,
217
+ collection: collection.slug,
218
+ key,
219
+ cursor,
220
+ lastRunAt: now,
221
+ status,
222
+ error,
223
+ stats: { ...stats },
224
+ updatedAt: now,
225
+ },
226
+ $setOnInsert: { createdAt: now },
227
+ },
228
+ { upsert: true },
229
+ );
230
+ }
231
+
232
+ async function markError(meta: any, stateId: string, error: string): Promise<void> {
233
+ const now = new Date();
234
+ await meta.updateOne(
235
+ { _id: stateId },
236
+ { $set: { status: "error", error, updatedAt: now }, $setOnInsert: { createdAt: now } },
237
+ { upsert: true },
238
+ ).catch(() => {});
239
+ }
240
+
241
+ /* ------------------------------------------------------------------ */
242
+ /* Deletes (hook + tombstone) */
243
+ /* ------------------------------------------------------------------ */
244
+
245
+ async function recordDeletes(collection: Collection, ctx: HookContext): Promise<void> {
246
+ if (ctx.action !== "deleteOne" && ctx.action !== "deleteMany") return;
247
+ const ids: string[] = ctx.action === "deleteOne"
248
+ ? (ctx.meta.id ? [String(ctx.meta.id)] : [])
249
+ : (ctx.meta.ids ?? []).map((id) => String(id));
250
+ if (!ids.length) return;
251
+
252
+ const db = (ctx.rest as any)?.db as Db | undefined;
253
+ if (!db) return;
254
+
255
+ const meta = db.collection(META_COLLECTION);
256
+ const now = new Date();
257
+ const ops = ids.map((docId) => {
258
+ const _id = deleteMarkerId(collection, docId);
259
+ return {
260
+ replaceOne: {
261
+ filter: { _id },
262
+ replacement: {
263
+ _id,
264
+ type: "delete",
265
+ tenant: collection._tenant_,
266
+ collection: collection.slug,
267
+ docId,
268
+ deletedAt: now,
269
+ synced: [],
270
+ updatedAt: now,
271
+ },
272
+ upsert: true,
273
+ },
274
+ };
275
+ });
276
+ await meta.bulkWrite(ops as any, { ordered: false });
277
+ }
278
+
279
+ function instrumentCollection(collection: Collection): void {
280
+ if (!collection.hooks) collection.hooks = {};
281
+ const hooks: any = collection.hooks;
282
+ if (hooks._replicationDeleteHook) return;
283
+ const previous = collection.hooks.afterOperation;
284
+ collection.hooks.afterOperation = async (ctx: HookContext) => {
285
+ if (previous) await previous(ctx);
286
+ await recordDeletes(collection, ctx).catch((err) =>
287
+ logger.file("warn", "replication: failed to record delete", { collection: collection.slug, error: err?.message }),
288
+ );
289
+ };
290
+ hooks._replicationDeleteHook = true;
291
+ }
292
+
293
+ /**
294
+ * Apply pending delete tombstones on the destination. The `key` guard prevents
295
+ * deleting a document that was re-inserted/updated after the deletion occurred.
296
+ */
297
+ async function flushDeletes(
298
+ meta: any,
299
+ runtime: Runtime,
300
+ collection: Collection,
301
+ key: string,
302
+ batchSize: number,
303
+ expectedDestinations: string[],
304
+ ): Promise<{ deleted: number; processed: number }> {
305
+ const markers = await meta.find({
306
+ type: "delete",
307
+ collection: collection.slug,
308
+ synced: { $ne: runtime.destination.id },
309
+ }).limit(batchSize).toArray();
310
+ if (!markers.length) return { deleted: 0, processed: 0 };
311
+
312
+ const ops = markers.map((m: any) => ({
313
+ deleteOne: {
314
+ filter: { _id: toId(m.docId), [key]: { $lte: m.deletedAt } },
315
+ },
316
+ }));
317
+ const res = await withRetry(() => runtime.db.collection(collection.slug).bulkWrite(ops as any, { ordered: false }));
318
+ const applied = (res as any).deletedCount ?? 0;
319
+
320
+ await meta.updateMany(
321
+ { _id: { $in: markers.map((m: any) => m._id) } },
322
+ { $addToSet: { synced: runtime.destination.id }, $set: { updatedAt: new Date() } },
323
+ );
324
+
325
+ // Purge tombstones fully applied by every expected destination.
326
+ if (expectedDestinations.length) {
327
+ await meta.deleteMany({
328
+ type: "delete",
329
+ collection: collection.slug,
330
+ synced: { $all: expectedDestinations },
331
+ }).catch(() => {});
332
+ }
333
+
334
+ return { deleted: applied, processed: markers.length };
335
+ }
336
+
337
+ /* ------------------------------------------------------------------ */
338
+ /* Bulk upsert */
339
+ /* ------------------------------------------------------------------ */
340
+
341
+ async function bulkUpsert(target: any, docs: any[]): Promise<{ inserted: number; updated: number; failed: number }> {
342
+ let inserted = 0;
343
+ let updated = 0;
344
+ let failed = 0;
345
+ for (let i = 0; i < docs.length; i += BULK_CHUNK) {
346
+ const slice = docs.slice(i, i + BULK_CHUNK);
347
+ const ops = slice.map((doc) => ({
348
+ replaceOne: { filter: { _id: doc._id }, replacement: doc, upsert: true },
349
+ }));
350
+ const res: any = await withRetry(() => target.bulkWrite(ops as any, { ordered: false }));
351
+ inserted += res.upsertedCount ?? 0;
352
+ updated += res.matchedCount ?? 0;
353
+ const writeErrors = (res as any).getWriteErrors?.() ?? [];
354
+ failed += writeErrors.length;
355
+ if (writeErrors.length) {
356
+ logger.file("warn", "replication: bulkWrite errors", {
357
+ count: writeErrors.length,
358
+ sample: writeErrors.slice(0, 5).map((e: any) => e?.errmsg),
359
+ });
360
+ }
361
+ }
362
+ return { inserted, updated, failed };
363
+ }
364
+
365
+ /* ------------------------------------------------------------------ */
366
+ /* Replication */
367
+ /* ------------------------------------------------------------------ */
368
+
369
+ async function replicateCollection(
370
+ tenant: Tenant,
371
+ runtime: Runtime,
372
+ collection: Collection,
373
+ config: ReplicationConfig,
374
+ key: string,
375
+ meta: any,
376
+ ): Promise<ReplicationStats> {
377
+ const stateId = stateIdFor(tenant.id, runtime.destination.id, collection.slug);
378
+ const state = (await meta.findOne({ _id: stateId })) as ReplicationState | null;
379
+ let cursor = state?.cursor ?? { value: null, id: null };
380
+
381
+ const source = tenant.database.db!.collection(collection.slug);
382
+ const target = runtime.db.collection(collection.slug);
383
+ const batchSize = config.batchSize ?? DEFAULT_BATCH;
384
+ const lookback = config.lookback ?? 0;
385
+ const startedAt = Date.now();
386
+ const stats: ReplicationStats = { inserted: 0, updated: 0, deleted: 0, failed: 0, batches: 0, durationMs: 0 };
387
+
388
+ await ensureIndex(tenant.database.db!, collection.slug, key, "source");
389
+ await ensureIndex(runtime.db, collection.slug, key, `destination:${runtime.destination.id}`);
390
+
391
+ // Resolve the initial cursor on the first run — `initialSync` lets a
392
+ // pre-loaded destination skip the full backfill (`full` = default).
393
+ if (!state) {
394
+ const initialSync = collection.replication?.initialSync ?? config.initialSync;
395
+ if (initialSync !== undefined && initialSync !== "full") {
396
+ const seeded = await resolveInitialSync(source, key, initialSync);
397
+ if (seeded) cursor = seeded;
398
+ }
399
+ }
400
+
401
+ // Optional per-collection document filter (used by `_vars_` to replicate only
402
+ // the opted-in namespaces).
403
+ const baseFilter = (collection as any)._baseFilter_ as Record<string, any> | undefined;
404
+
405
+ // Inserts and updates are always replicated: both refresh the date key, so
406
+ // the incremental cursor picks them up — there is no way (nor need) to tell
407
+ // them apart.
408
+ while (true) {
409
+ const docs = await source
410
+ .find(mergedFilter(baseFilter, incrementalFilter(key, applyLookback(cursor, lookback))) as any)
411
+ .sort({ [key]: 1, _id: 1 } as any)
412
+ .limit(batchSize)
413
+ .toArray();
414
+ if (!docs.length) break;
415
+
416
+ const { inserted, updated, failed } = await bulkUpsert(target, docs);
417
+ stats.inserted += inserted;
418
+ stats.updated += updated;
419
+ stats.failed += failed;
420
+ stats.batches += 1;
421
+
422
+ const last: any = docs[docs.length - 1];
423
+ if (!last) break;
424
+ cursor = { value: last[key] ?? null, id: last._id ?? null };
425
+
426
+ await saveState(meta, stateId, tenant, runtime, collection, key, cursor, stats, "running", null);
427
+ if (docs.length < batchSize) break;
428
+ }
429
+
430
+ // Deletes are always propagated too — a deleted document no longer exists, so
431
+ // a date cursor can never see it; they are captured by the hook + tombstone.
432
+ {
433
+ const expected = deleteDestinationsFor(config, collection);
434
+ while (true) {
435
+ const { deleted, processed } = await flushDeletes(meta, runtime, collection, key, batchSize, expected);
436
+ stats.deleted += deleted;
437
+ if (processed < batchSize) break;
438
+ }
439
+ }
440
+
441
+ stats.durationMs = Date.now() - startedAt;
442
+ await saveState(meta, stateId, tenant, runtime, collection, key, cursor, stats, "success", null);
443
+ return stats;
444
+ }
445
+
446
+ async function replicateTenant(
447
+ tenantId: string,
448
+ opts?: { destinationId?: string; collection?: string },
449
+ ): Promise<ReplicationRunResult[]> {
450
+ const tenant = getTenant(tenantId);
451
+ const config = tenant ? resolveConfig(tenant) : null;
452
+ const mainDb = tenant?.database?.db;
453
+ if (!tenant || !config || !mainDb) return [];
454
+
455
+ const collections = (cfg.collections ?? []).filter((c) => c._tenant_ === tenantId && c.replication?.enabled);
456
+
457
+ // File collections opt in the same way (`define.FileCollection({ replication })`).
458
+ collections.push(
459
+ ...(cfg.fileCollections ?? [])
460
+ .filter((c) => c._tenant_ === tenantId && c.replication?.enabled)
461
+ .map((c) => ({ slug: c.slug, _tenant_: tenantId, replication: c.replication }) as any),
462
+ );
463
+
464
+ // `define.Vars({ replication: { enabled: true } })` → replicate the opted-in
465
+ // namespaces of `_vars_`. A per-document filter keeps the namespaces that
466
+ // did NOT opt in (e.g. secrets) out of the destination.
467
+ const varsNamespaces = (cfg.vars ?? [])
468
+ .filter((v) => v._tenant_ === tenantId && v.replication?.enabled)
469
+ .map((v) => v.namespace);
470
+ if (varsNamespaces.length) {
471
+ collections.push({
472
+ slug: "_vars_",
473
+ _tenant_: tenantId,
474
+ replication: { enabled: true },
475
+ _baseFilter_: { ns: { $in: varsNamespaces } },
476
+ } as any);
477
+ }
478
+ const results: ReplicationRunResult[] = [];
479
+ const meta = mainDb.collection(META_COLLECTION);
480
+ const rest = new useRest({ tenant_id: tenantId });
481
+
482
+ for (const runtime of runtimesFor(tenantId)) {
483
+ if (opts?.destinationId && runtime.destination.id !== opts.destinationId) continue;
484
+ if (runtime.running) continue;
485
+
486
+ const lockName = `replication:${runtime.destination.id}`;
487
+ let locked = false;
488
+ try {
489
+ await rest.lock(lockName, LOCK_TTL);
490
+ locked = true;
491
+ } catch {
492
+ continue; // another node holds the lock
493
+ }
494
+
495
+ runtime.running = true;
496
+ try {
497
+ for (const collection of filterCollections(collections, runtime.destination)) {
498
+ if (opts?.collection && collection.slug !== opts.collection) continue;
499
+ const key = resolveKey(collection, config);
500
+ const startedAt = Date.now();
501
+ const totals: ReplicationStats = { inserted: 0, updated: 0, deleted: 0, failed: 0, batches: 0, durationMs: 0 };
502
+
503
+ try {
504
+ const stats = await replicateCollection(tenant, runtime, collection, config, key, meta);
505
+ Object.assign(totals, stats);
506
+ totals.durationMs = Date.now() - startedAt;
507
+ results.push({ tenant: tenantId, destination: runtime.destination.id, collection: collection.slug, ...totals });
508
+ } catch (err: any) {
509
+ const message = err?.message ?? String(err);
510
+ await markError(meta, stateIdFor(tenantId, runtime.destination.id, collection.slug), message);
511
+ logger.file("error", "replication: collection failed", {
512
+ tenant: tenantId, destination: runtime.destination.id, collection: collection.slug, error: message,
513
+ });
514
+ totals.durationMs = Date.now() - startedAt;
515
+ results.push({ tenant: tenantId, destination: runtime.destination.id, collection: collection.slug, ...totals, error: message });
516
+ }
517
+ }
518
+ } finally {
519
+ runtime.running = false;
520
+ if (locked) await rest.unlock(lockName).catch(() => {});
521
+ }
522
+ }
523
+
524
+ return results;
525
+ }
526
+
527
+ async function replicationNow(tenantId?: string): Promise<ReplicationRunResult[]> {
528
+ if (tenantId) return replicateTenant(tenantId);
529
+ const all: ReplicationRunResult[] = [];
530
+ for (const tenant of cfg.tenants ?? []) {
531
+ all.push(...(await replicateTenant(tenant.id)));
532
+ }
533
+ return all;
534
+ }
535
+
536
+ async function getReplicationState(tenantId: string, destinationId?: string, collection?: string): Promise<ReplicationState[]> {
537
+ const tenant = getTenant(tenantId);
538
+ const db = tenant?.database?.db;
539
+ if (!tenant || !db) return [];
540
+ const filter: Record<string, any> = { type: "state", tenant: tenantId };
541
+ if (destinationId) filter.destination = destinationId;
542
+ if (collection) filter.collection = collection;
543
+ return (await db.collection(META_COLLECTION).find(filter).toArray()) as unknown as ReplicationState[];
544
+ }
545
+
546
+ /**
547
+ * Forget replication progress for a tenant — deletes the `_replication_` state
548
+ * (cursor, stats, status), so the next run starts over. Destination data is
549
+ * never touched: only the bookkeeping is cleared.
550
+ *
551
+ * Scope it with `destination` and/or `collection`. Pending delete tombstones are
552
+ * cleared too, unless the reset is scoped to a single destination (tombstones
553
+ * carry a shared `synced[]` list) or `tombstones: false`.
554
+ */
555
+ async function resetReplication(
556
+ tenantId: string,
557
+ opts?: ReplicationResetOptions,
558
+ ): Promise<ReplicationResetResult> {
559
+ const tenant = getTenant(tenantId);
560
+ const db = tenant?.database?.db;
561
+ if (!tenant || !db) return { tenant: tenantId, state: 0, tombstones: 0 };
562
+
563
+ const meta = db.collection(META_COLLECTION);
564
+
565
+ const stateFilter: Record<string, any> = { type: "state", tenant: tenantId };
566
+ if (opts?.destination) stateFilter.destination = opts.destination;
567
+ if (opts?.collection) stateFilter.collection = opts.collection;
568
+ const stateRes = await meta.deleteMany(stateFilter);
569
+ const state = stateRes.deletedCount ?? 0;
570
+
571
+ let tombstones = 0;
572
+ if (opts?.tombstones !== false && !opts?.destination) {
573
+ const tombFilter: Record<string, any> = { type: "delete", tenant: tenantId };
574
+ if (opts?.collection) tombFilter.collection = opts.collection;
575
+ const tombRes = await meta.deleteMany(tombFilter);
576
+ tombstones = tombRes.deletedCount ?? 0;
577
+ }
578
+
579
+ logger.file("replication: reset", {
580
+ tenant: tenantId,
581
+ destination: opts?.destination ?? null,
582
+ collection: opts?.collection ?? null,
583
+ state,
584
+ tombstones,
585
+ });
586
+
587
+ return { tenant: tenantId, state, tombstones };
588
+ }
589
+
590
+ /**
591
+ * Set the starting point of replication for a tenant by writing the `_replication_`
592
+ * cursor — the counterpart of `reset()`. Use it from `beforeBoot` to start a
593
+ * replication from a given date (or `'now'` / `'latest'`) instead of backfilling.
594
+ *
595
+ * The date field is the collection's replication key (`replication.key`, default
596
+ * `updatedAt`). Scope with `destination` and/or `collection`.
597
+ */
598
+ async function seedReplication(
599
+ tenantId: string,
600
+ value: ReplicationSeedValue,
601
+ opts?: ReplicationSeedOptions,
602
+ ): Promise<ReplicationSeedResult> {
603
+ const tenant = getTenant(tenantId);
604
+ const config = tenant ? resolveConfig(tenant) : null;
605
+ const mainDb = tenant?.database?.db;
606
+ if (!tenant || !config || !mainDb) return { tenant: tenantId, seeded: 0 };
607
+
608
+ const meta: any = mainDb.collection(META_COLLECTION);
609
+ const collections = (cfg.collections ?? []).filter(
610
+ (c) => c._tenant_ === tenantId && c.replication?.enabled && (!opts?.collection || c.slug === opts.collection),
611
+ );
612
+ collections.push(
613
+ ...(cfg.fileCollections ?? [])
614
+ .filter((c) => c._tenant_ === tenantId && c.replication?.enabled && (!opts?.collection || c.slug === opts.collection))
615
+ .map((c) => ({ slug: c.slug, _tenant_: tenantId, replication: c.replication }) as any),
616
+ );
617
+ const destinations = (config.destinations ?? []).filter(
618
+ (d) => d.enabled !== false && d.id && d.uri && (!opts?.destination || d.id === opts.destination),
619
+ );
620
+
621
+ let seeded = 0;
622
+ for (const destination of destinations) {
623
+ for (const collection of filterCollections(collections, destination)) {
624
+ const key = resolveKey(collection, config);
625
+ const source = mainDb.collection(collection.slug);
626
+
627
+ // Resolve the cursor value (and its tie-breaker).
628
+ let cursor: { value: any; id: any };
629
+ if (value === "latest") {
630
+ const resolved = await resolveInitialSync(source, key, "latest");
631
+ if (!resolved) continue; // empty collection — nothing to seed from
632
+ cursor = { value: resolved.value, id: opts?.id ?? resolved.id };
633
+ } else if (value === "now") {
634
+ cursor = { value: new Date(), id: opts?.id ?? null };
635
+ } else {
636
+ cursor = { value: value instanceof Date ? value : new Date(value), id: opts?.id ?? null };
637
+ }
638
+
639
+ const stateId = stateIdFor(tenantId, destination.id, collection.slug);
640
+ const now = new Date();
641
+ await meta.updateOne(
642
+ { _id: stateId },
643
+ {
644
+ $set: {
645
+ type: "state",
646
+ tenant: tenantId,
647
+ destination: destination.id,
648
+ collection: collection.slug,
649
+ key,
650
+ cursor,
651
+ status: "idle",
652
+ lastError: null,
653
+ updatedAt: now,
654
+ },
655
+ $setOnInsert: { createdAt: now },
656
+ },
657
+ { upsert: true },
658
+ );
659
+ seeded++;
660
+ }
661
+ }
662
+
663
+ logger.file("replication: seeded", {
664
+ tenant: tenantId,
665
+ destinations: destinations.map((d) => d.id),
666
+ collection: opts?.collection ?? null,
667
+ value: value instanceof Date ? value.toISOString() : value,
668
+ seeded,
669
+ });
670
+
671
+ return { tenant: tenantId, seeded };
672
+ }
673
+
674
+ /* ------------------------------------------------------------------ */
675
+ /* Scheduling / lifecycle */
676
+ /* ------------------------------------------------------------------ */
677
+
678
+ function scheduleTenant(tenant: Tenant, config: ReplicationConfig): void {
679
+ const run = () => {
680
+ replicateTenant(tenant.id).catch((err) =>
681
+ logger.file("error", "replication: scheduled run failed", { tenant: tenant.id, error: err?.message }),
682
+ );
683
+ };
684
+
685
+ if (config.schedule && "cron" in config.schedule) {
686
+ try {
687
+ const job: any = (Bun as any).cron(config.schedule.cron, run);
688
+ jobs.push({ tenant: tenant.id, stop: () => { try { job?.stop?.(); } catch { /* ignore */ } } });
689
+ logger.file("replication: scheduled", { tenant: tenant.id, cron: config.schedule.cron });
690
+ return;
691
+ } catch (err: any) {
692
+ logger.file("error", "replication: invalid cron expression", { tenant: tenant.id, cron: config.schedule.cron, error: err?.message });
693
+ }
694
+ }
695
+
696
+ const intervalValue = config.schedule && "interval" in config.schedule ? config.schedule.interval : undefined;
697
+ const interval = Math.max(1000, func.parseDuration(intervalValue ?? "5m") ?? 5 * 60_000);
698
+ const timer = setInterval(run, interval);
699
+ (timer as any)?.unref?.();
700
+ jobs.push({ tenant: tenant.id, stop: () => clearInterval(timer) });
701
+ logger.file("replication: scheduled", { tenant: tenant.id, intervalMs: interval });
702
+ }
703
+
704
+ async function startReplication(): Promise<void> {
705
+ if (started) return;
706
+ // In cluster mode (`reusePort` + workers), only the master process runs
707
+ // replication. Workers skip to avoid duplicate connections and timers — the
708
+ // distributed lock already serializes runs across processes.
709
+ if (process.env.BUN_WORKER !== undefined) return;
710
+ started = true;
711
+
712
+ for (const tenant of cfg.tenants ?? []) {
713
+ const config = resolveConfig(tenant);
714
+ const mainDb = tenant.database?.db;
715
+ if (!config || !mainDb) continue;
716
+
717
+ let connected = false;
718
+ for (const destination of config.destinations ?? []) {
719
+ if (destination.enabled === false || !destination.id || !destination.uri) continue;
720
+ const key = `${tenant.id}:${destination.id}`;
721
+ try {
722
+ if (runtimes.has(key)) { connected = true; continue; }
723
+ const client = new MongoClient(destination.uri, {
724
+ serverSelectionTimeoutMS: 5000,
725
+ connectTimeoutMS: 10000,
726
+ ...(destination.options ?? {}),
727
+ });
728
+ await client.connect();
729
+ const db = destination.name ? client.db(destination.name) : client.db();
730
+ runtimes.set(key, { tenantId: tenant.id, destination, client, db, running: false });
731
+ connected = true;
732
+ logger.file("replication: destination connected", { tenant: tenant.id, destination: destination.id, database: db.databaseName });
733
+ } catch (err: any) {
734
+ logger.file("error", "replication: destination connection failed", { tenant: tenant.id, destination: destination.id, error: err?.message });
735
+ }
736
+ }
737
+
738
+ if (!connected) continue;
739
+
740
+ // Deletes are always propagated: wrap `afterOperation` on every
741
+ // sync-enabled collection (file collections opt in the same way)
742
+ // so hard deletes leave a tombstone.
743
+ for (const collection of [...(cfg.collections ?? []), ...((cfg.fileCollections ?? []) as any[])]) {
744
+ if (collection._tenant_ === tenant.id && collection.replication?.enabled) {
745
+ instrumentCollection(collection);
746
+ }
747
+ }
748
+
749
+ scheduleTenant(tenant, config);
750
+
751
+ if (config.runOnBoot !== false) {
752
+ replicateTenant(tenant.id).catch((err) =>
753
+ logger.file("error", "replication: initial run failed", { tenant: tenant.id, error: err?.message }),
754
+ );
755
+ }
756
+ }
757
+ }
758
+
759
+ async function stopReplication(): Promise<void> {
760
+ for (const job of jobs) {
761
+ try { job.stop(); } catch { /* ignore */ }
762
+ }
763
+ jobs.length = 0;
764
+ for (const runtime of runtimes.values()) {
765
+ try { await runtime.client.close(); } catch { /* ignore */ }
766
+ }
767
+ runtimes.clear();
768
+ ensured.clear();
769
+ started = false;
770
+ }
771
+
772
+ export {
773
+ startReplication,
774
+ stopReplication,
775
+ replicationNow,
776
+ replicateTenant,
777
+ getReplicationState,
778
+ resetReplication,
779
+ seedReplication,
780
+ };