@anteros/core 0.0.41 → 0.0.44

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 (133) hide show
  1. package/database/audit.ts +9 -59
  2. package/database/collection.ts +3 -1
  3. package/database/deleteLog.ts +67 -3
  4. package/database/file.ts +4 -3
  5. package/database/mongodbadapter.ts +103 -42
  6. package/database/replication.ts +724 -58
  7. package/database/ttl.ts +68 -0
  8. package/database/workflow.ts +466 -240
  9. package/index.ts +6 -0
  10. package/lib/agent.ts +1567 -0
  11. package/lib/agentMemory.ts +689 -0
  12. package/lib/agents.ts +180 -0
  13. package/lib/ai.ts +0 -0
  14. package/lib/api.ts +246 -0
  15. package/lib/attachment.ts +351 -0
  16. package/lib/auditResult.ts +13 -0
  17. package/lib/define.ts +43 -1
  18. package/lib/endpoints.ts +80 -0
  19. package/lib/files.ts +2 -1
  20. package/lib/jsonSchema.ts +183 -0
  21. package/lib/lifecycle.ts +7 -1
  22. package/lib/load.ts +17 -0
  23. package/lib/mcp.ts +3 -2
  24. package/lib/mcpClient.ts +128 -0
  25. package/lib/messages.ts +299 -0
  26. package/lib/providers.ts +970 -0
  27. package/lib/routes.ts +14 -1
  28. package/lib/scriptWorker.ts +4 -1
  29. package/lib/scripts.ts +6 -1
  30. package/lib/skills.ts +481 -0
  31. package/lib/sockets.ts +3 -1
  32. package/lib/workflow.ts +209 -37
  33. package/lib/zip.ts +109 -0
  34. package/package.json +1 -1
  35. package/server/access.ts +96 -0
  36. package/server/agents.ts +472 -0
  37. package/server/api.ts +80 -65
  38. package/server/boot.ts +11 -0
  39. package/server/hono.ts +8 -3
  40. package/server/mcp.ts +17 -63
  41. package/server/routes.ts +19 -24
  42. package/tests/agent-mcp.test.ts +116 -0
  43. package/tests/agent-memory.test.ts +617 -0
  44. package/tests/agent-provider.test.ts +288 -0
  45. package/tests/agent-runtime.test.ts +369 -0
  46. package/tests/agent-skills.test.ts +177 -0
  47. package/tests/agent-summarize.test.ts +124 -0
  48. package/tests/agent-working-memory.test.ts +404 -0
  49. package/tests/agent.test.ts +1241 -0
  50. package/tests/agents-api.test.ts +593 -0
  51. package/tests/api-private-fields.test.ts +196 -0
  52. package/tests/api.test.ts +51 -0
  53. package/tests/attachment.test.ts +241 -0
  54. package/tests/audit-operations.test.ts +1 -1
  55. package/tests/audit-redact.test.ts +29 -3
  56. package/tests/audit-run-action.test.ts +1 -1
  57. package/tests/endpoints.test.ts +80 -0
  58. package/tests/file-collection-replication.test.ts +2 -0
  59. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  60. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  61. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  62. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  63. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  64. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  65. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  66. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  67. package/tests/fixtures/attachment/data.csv +2 -0
  68. package/tests/fixtures/fake-provider.ts +128 -0
  69. package/tests/fixtures/repl-only/app.ts +3 -0
  70. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  71. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  72. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  73. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  74. package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
  75. package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
  76. package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
  77. package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
  78. package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
  79. package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
  80. package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
  81. package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
  82. package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
  83. package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
  84. package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
  85. package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
  86. package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
  87. package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
  88. package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
  89. package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
  90. package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
  91. package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
  92. package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
  93. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  94. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  95. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  96. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  97. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  98. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  99. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  100. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  101. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  102. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  103. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  104. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  105. package/tests/hono.test.ts +6 -3
  106. package/tests/locks.test.ts +120 -0
  107. package/tests/mcp.test.ts +11 -11
  108. package/tests/messages.test.ts +258 -0
  109. package/tests/replication-change-stream.test.ts +240 -0
  110. package/tests/replication-control.test.ts +4 -2
  111. package/tests/replication-initial-sync.test.ts +2 -0
  112. package/tests/replication-meta.test.ts +246 -0
  113. package/tests/replication.test.ts +2 -0
  114. package/tests/upload.test.ts +44 -12
  115. package/tests/vars-api.test.ts +1 -1
  116. package/tests/vars-replication.test.ts +5 -2
  117. package/tests/workflow-engine.test.ts +340 -0
  118. package/tests/workflow-retention.test.ts +103 -0
  119. package/tests/workflow.test.ts +186 -0
  120. package/types/agent.d.ts +1067 -0
  121. package/types/config.d.ts +16 -0
  122. package/types/file.d.ts +7 -7
  123. package/types/hook.d.ts +6 -0
  124. package/types/lifecycle.d.ts +6 -0
  125. package/types/mcp.d.ts +11 -1
  126. package/types/replication.d.ts +70 -4
  127. package/types/route.d.ts +7 -1
  128. package/types/scripts.d.ts +6 -0
  129. package/types/service.d.ts +7 -1
  130. package/types/tenant.d.ts +13 -0
  131. package/types/vars.d.ts +2 -2
  132. package/types/websocket.d.ts +6 -0
  133. package/types/workflow.d.ts +50 -3
@@ -4,6 +4,7 @@ import { getTenant } from "./tenant";
4
4
  import { useRest } from "./rest";
5
5
  import { logger } from "../utils/logger";
6
6
  import * as func from "../utils/func";
7
+ import * as os from "node:os";
7
8
  import type { Collection } from "../types/collection";
8
9
  import type { Tenant } from "../types/tenant";
9
10
  import type { HookContext } from "../types/hook";
@@ -11,6 +12,8 @@ import type {
11
12
  ReplicationConfig,
12
13
  ReplicationDestination,
13
14
  ReplicationInitialSync,
15
+ ReplicationMetaName,
16
+ ReplicationMode,
14
17
  ReplicationResetOptions,
15
18
  ReplicationResetResult,
16
19
  ReplicationRunResult,
@@ -20,6 +23,10 @@ import type {
20
23
  ReplicationState,
21
24
  ReplicationStats,
22
25
  } from "../types/replication";
26
+ import { AUDIT_TTL_INDEX, resolveAuditRetention } from "./audit";
27
+ import { WORKFLOW_RUNS_TTL_INDEX, resolveWorkflowsRetention } from "../lib/workflow";
28
+ import { agentMemoryCollections } from "../lib/agents";
29
+ import { syncTtlRetention } from "./ttl";
23
30
 
24
31
  const META_COLLECTION = "_replication_";
25
32
  const DEFAULT_KEY = "updatedAt";
@@ -28,6 +35,97 @@ const DEFAULT_BATCH = 1000;
28
35
  const BULK_CHUNK = 1000;
29
36
  /** Distributed-lock TTL used to guard a run across cluster workers. */
30
37
  const LOCK_TTL = 5 * 60_000;
38
+ /** Change detection out of the box — a date scan has to be opted into. */
39
+ const DEFAULT_MODE: ReplicationMode = "changeStream";
40
+ /** How often a watcher persists its resume token (bounded write rate on `_replication_`). */
41
+ const STATE_FLUSH_MS = 5_000;
42
+ /** Reopen delay of a failed change stream, doubling up to `WATCH_MAX_BACKOFF_MS`. */
43
+ const WATCH_BACKOFF_MS = 1_000;
44
+ const WATCH_MAX_BACKOFF_MS = 30_000;
45
+ /**
46
+ * Driver / server errors that mean the resume token is unusable and the stream must
47
+ * start from a fresh position: `CursorNotFound`, `ChangeStreamHistoryLost`,
48
+ * `ChangeStreamInvalidated`, `ChangeStreamFatalError`.
49
+ */
50
+ const RESUME_TOKEN_ERRORS = new Set([43, 280, 286, 40573]);
51
+ /**
52
+ * How long a boot gives the collections it could not take at once — the ones a
53
+ * catch-up run is holding for the time of its pass. Shared by all of them, and
54
+ * **after** a first pass that takes everything free: a collection another node's
55
+ * stream owns must delay neither the boot nor its neighbours.
56
+ */
57
+ const WATCH_LOCK_WAIT_MS = 5_000;
58
+ const WATCH_LOCK_RETRY_MS = 500;
59
+
60
+ /**
61
+ * Framework collections replication covers **by default**, with the date key each
62
+ * one is read with (the engine's cursor). `'vars'` is handled apart, per namespace,
63
+ * and `'memory'` covers the agent memory collections — whose names the tenant
64
+ * chooses, so they come from the agent registry instead (see below).
65
+ */
66
+ const META_COLLECTIONS: Record<Exclude<ReplicationMetaName, 'vars' | 'memory'>, { slug: string; key: string }> = {
67
+ audit: { slug: "_audit_", key: "ts" },
68
+ workflows: { slug: "_workflows_", key: "updatedAt" },
69
+ locks: { slug: "_locks_", key: "expiresAt" },
70
+ replication: { slug: "_replication_", key: "updatedAt" },
71
+ };
72
+
73
+ /**
74
+ * Everything replicated for a tenant: the collections that opted in, plus the
75
+ * framework's meta collections — which are replicated **by default** and left out
76
+ * with `replication.exclude: ['audit', 'locks', …]`.
77
+ */
78
+ function replicatedCollectionsFor(tenantId: string, config: ReplicationConfig): Collection[] {
79
+ const excluded = new Set<string>(config.exclude ?? []);
80
+
81
+ const collections: Collection[] = [
82
+ ...(cfg.collections ?? []).filter((c) => c._tenant_ === tenantId && c.replication?.enabled),
83
+ ...(cfg.fileCollections ?? [])
84
+ .filter((c) => c._tenant_ === tenantId && c.replication?.enabled)
85
+ .map((c) => ({ slug: c.slug, _tenant_: tenantId, replication: c.replication }) as any),
86
+ ];
87
+
88
+ for (const [name, meta] of Object.entries(META_COLLECTIONS)) {
89
+ if (excluded.has(name)) continue;
90
+ // Each meta collection knows its own date key (`_audit_` is indexed on `ts`),
91
+ // so a tenant-level `key` never breaks it
92
+ collections.push({
93
+ slug: meta.slug,
94
+ _tenant_: tenantId,
95
+ replication: { enabled: true, key: meta.key },
96
+ } as any);
97
+ }
98
+
99
+ if (!excluded.has('vars')) {
100
+ // Every namespace, minus the ones explicitly opting out — a per-document
101
+ // filter: `replication.exclude: ['vars']` is the blunt way out.
102
+ const optedOut = (cfg.vars ?? [])
103
+ .filter((v) => v._tenant_ === tenantId && v.replication?.enabled === false)
104
+ .map((v) => v.namespace);
105
+ collections.push({
106
+ slug: "_vars_",
107
+ _tenant_: tenantId,
108
+ replication: { enabled: true },
109
+ _baseFilter_: { ns: { $nin: optedOut } },
110
+ } as any);
111
+ }
112
+
113
+ // Agent memory collections — the tenant names them, the agent loader publishes
114
+ // them (`cfg.agentMemories`). `exclude: ['memory']` covers all of them.
115
+ if (!excluded.has('memory')) {
116
+ for (const slug of agentMemoryCollections(tenantId)) {
117
+ // A memory collection may also be declared as a collection: one entry only
118
+ if (collections.some((collection) => collection.slug === slug)) continue;
119
+ collections.push({
120
+ slug,
121
+ _tenant_: tenantId,
122
+ replication: { enabled: true, key: "updatedAt" },
123
+ } as any);
124
+ }
125
+ }
126
+
127
+ return collections;
128
+ }
31
129
 
32
130
  type Runtime = {
33
131
  tenantId: string;
@@ -69,6 +167,15 @@ function validateReplication(config: ReplicationConfig): void {
69
167
  }
70
168
  }
71
169
 
170
+ /**
171
+ * Change detection for a tenant. Deliberately tenant-wide (not per collection): the
172
+ * mode decides how the *runs* are scheduled, and a mixed mode would need a schedule
173
+ * and a stream at once for the same destination.
174
+ */
175
+ function resolveMode(config: ReplicationConfig): ReplicationMode {
176
+ return config.mode ?? DEFAULT_MODE;
177
+ }
178
+
72
179
  function resolveKey(collection: Collection, config: ReplicationConfig): string {
73
180
  return collection.replication?.key ?? config.key ?? DEFAULT_KEY;
74
181
  }
@@ -77,6 +184,15 @@ function stateIdFor(tenantId: string, destinationId: string, collection: string)
77
184
  return `rep:${tenantId}:${destinationId}:${collection}`;
78
185
  }
79
186
 
187
+ /**
188
+ * The single lock of a (destination, collection) pair — taken by whichever writer
189
+ * owns that collection: a one-shot run for the duration of its pass, or a change
190
+ * stream for as long as it watches.
191
+ */
192
+ function watchLockName(destinationId: string, slug: string): string {
193
+ return `replication:${destinationId}:${slug}`;
194
+ }
195
+
80
196
  function deleteMarkerId(collection: Collection, docId: string): string {
81
197
  return `rep.delete:${collection._tenant_}:${collection.slug}:${docId}`;
82
198
  }
@@ -205,6 +321,7 @@ async function saveState(
205
321
  stats: ReplicationStats,
206
322
  status: ReplicationState["status"],
207
323
  error: string | null,
324
+ mode: ReplicationMode = DEFAULT_MODE,
208
325
  ): Promise<void> {
209
326
  const now = new Date();
210
327
  await meta.updateOne(
@@ -216,11 +333,17 @@ async function saveState(
216
333
  destination: runtime.destination.id,
217
334
  collection: collection.slug,
218
335
  key,
336
+ mode,
219
337
  cursor,
220
338
  lastRunAt: now,
221
339
  status,
222
340
  error,
223
341
  stats: { ...stats },
342
+ // Which process owns this run — `reusePort`/forked deployments can
343
+ // see at a glance which worker is replicating (the lock holds the
344
+ // same information)
345
+ pid: process.pid,
346
+ hostname: os.hostname(),
224
347
  updatedAt: now,
225
348
  },
226
349
  $setOnInsert: { createdAt: now },
@@ -311,7 +434,15 @@ async function flushDeletes(
311
434
 
312
435
  const ops = markers.map((m: any) => ({
313
436
  deleteOne: {
314
- filter: { _id: toId(m.docId), [key]: { $lte: m.deletedAt } },
437
+ filter: {
438
+ _id: toId(m.docId),
439
+ // The key is a Date on most collections, but a number on `_locks_`:
440
+ // accept either, so a numeric key never silently skips the delete
441
+ $or: [
442
+ { [key]: { $lte: m.deletedAt } },
443
+ { [key]: { $lte: m.deletedAt?.getTime?.() ?? m.deletedAt } },
444
+ ],
445
+ },
315
446
  },
316
447
  }));
317
448
  const res = await withRetry(() => runtime.db.collection(collection.slug).bulkWrite(ops as any, { ordered: false }));
@@ -374,6 +505,71 @@ async function replicateCollection(
374
505
  key: string,
375
506
  meta: any,
376
507
  ): Promise<ReplicationStats> {
508
+ const startedAt = Date.now();
509
+
510
+ // In `changeStream` mode the stream is the change detector: a run only seeds a
511
+ // destination that has never been backfilled (a fresh tenant, or after
512
+ // `replication.reset()`). Scanning on top of a live stream would let a stale
513
+ // read overwrite a newer event — see `startWatcher` for the per-collection lock
514
+ // that keeps the two apart.
515
+ if (resolveMode(config) === "changeStream") {
516
+ const stateId = stateIdFor(tenant.id, runtime.destination.id, collection.slug);
517
+ if (await meta.findOne({ _id: stateId })) {
518
+ return { inserted: 0, updated: 0, deleted: 0, failed: 0, batches: 0, durationMs: Date.now() - startedAt };
519
+ }
520
+ }
521
+
522
+ const { stats, cursor } = await scanCollection(tenant, runtime, collection, config, key, meta);
523
+
524
+ // Deletes are always propagated too — a deleted document no longer exists, so
525
+ // a date cursor can never see it; they are captured by the hook + tombstone.
526
+ // In `changeStream` mode the stream carries deletes natively, so this is only a
527
+ // safety net for a backfill that raced with an applicative delete.
528
+ {
529
+ const expected = deleteDestinationsFor(config, collection);
530
+ while (true) {
531
+ const { deleted, processed } = await flushDeletes(meta, runtime, collection, key, config.batchSize ?? DEFAULT_BATCH, expected);
532
+ stats.deleted += deleted;
533
+ if (processed < (config.batchSize ?? DEFAULT_BATCH)) break;
534
+ }
535
+ }
536
+
537
+ stats.durationMs = Date.now() - startedAt;
538
+ await saveState(
539
+ meta,
540
+ stateIdFor(tenant.id, runtime.destination.id, collection.slug),
541
+ tenant,
542
+ runtime,
543
+ collection,
544
+ key,
545
+ cursor,
546
+ stats,
547
+ "success",
548
+ null,
549
+ resolveMode(config),
550
+ );
551
+ return stats;
552
+ }
553
+
554
+ /**
555
+ * One pass over the source, from the position stored in `_replication_`.
556
+ *
557
+ * This is the scan engine's core **and** the backfill a change stream runs on its
558
+ * first run — which is why it is shared: the indexes, the mirrored
559
+ * audit/workflow retention, the `initialSync` seeding and the batched upserts are
560
+ * identical, only the change *detection* differs.
561
+ *
562
+ * It leaves the state `running` and never flushes delete tombstones: the caller
563
+ * owns the run's final status.
564
+ */
565
+ async function scanCollection(
566
+ tenant: Tenant,
567
+ runtime: Runtime,
568
+ collection: Collection,
569
+ config: ReplicationConfig,
570
+ key: string,
571
+ meta: any,
572
+ ): Promise<{ stats: ReplicationStats; cursor: { value: any; id: any } }> {
377
573
  const stateId = stateIdFor(tenant.id, runtime.destination.id, collection.slug);
378
574
  const state = (await meta.findOne({ _id: stateId })) as ReplicationState | null;
379
575
  let cursor = state?.cursor ?? { value: null, id: null };
@@ -388,6 +584,23 @@ async function replicateCollection(
388
584
  await ensureIndex(tenant.database.db!, collection.slug, key, "source");
389
585
  await ensureIndex(runtime.db, collection.slug, key, `destination:${runtime.destination.id}`);
390
586
 
587
+ // Meta collections have no hooks, so their deletions never reach the
588
+ // destination (an audit TTL expiry is invisible to a date cursor): mirror the
589
+ // source's retention on the destination, otherwise the copy grows forever.
590
+ if (collection.slug === '_audit_' || collection.slug === '_workflows_') {
591
+ const isAudit = collection.slug === '_audit_';
592
+ const retention = isAudit ? resolveAuditRetention(tenant) : resolveWorkflowsRetention(tenant);
593
+ if (retention !== undefined) {
594
+ await syncTtlRetention(runtime.db, {
595
+ collection: collection.slug,
596
+ index: isAudit ? AUDIT_TTL_INDEX : WORKFLOW_RUNS_TTL_INDEX,
597
+ field: isAudit ? 'ts' : 'completedAt',
598
+ retention,
599
+ label: `${collection.slug} (destination)`,
600
+ });
601
+ }
602
+ }
603
+
391
604
  // Resolve the initial cursor on the first run — `initialSync` lets a
392
605
  // pre-loaded destination skip the full backfill (`full` = default).
393
606
  if (!state) {
@@ -423,24 +636,405 @@ async function replicateCollection(
423
636
  if (!last) break;
424
637
  cursor = { value: last[key] ?? null, id: last._id ?? null };
425
638
 
426
- await saveState(meta, stateId, tenant, runtime, collection, key, cursor, stats, "running", null);
639
+ await saveState(meta, stateId, tenant, runtime, collection, key, cursor, stats, "running", null, resolveMode(config));
427
640
  if (docs.length < batchSize) break;
428
641
  }
429
642
 
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;
643
+ stats.durationMs = Date.now() - startedAt;
644
+ return { stats, cursor };
645
+ }
646
+
647
+ /* ------------------------------------------------------------------ */
648
+ /* Change streams (`mode: 'changeStream'`) */
649
+ /* ------------------------------------------------------------------ */
650
+
651
+ /** One watcher per (tenant, destination) — it tails every collection in parallel. */
652
+ type Watcher = { stop: () => Promise<void> };
653
+
654
+ const watchers = new Map<string, Watcher>();
655
+ /** Change-stream support, probed once per (tenant, database). */
656
+ const changeStreamSupport = new Map<string, boolean>();
657
+
658
+ /**
659
+ * Does this source serve change streams? They only exist on a replica set or a
660
+ * sharded cluster — a standalone `mongod` raises `$changeStream is only supported
661
+ * on replica sets` on the first `watch()`.
662
+ */
663
+ async function supportsChangeStreams(tenantId: string, db: Db): Promise<boolean> {
664
+ const cacheKey = `${tenantId}:${db.databaseName}`;
665
+ const cached = changeStreamSupport.get(cacheKey);
666
+ if (cached !== undefined) return cached;
667
+
668
+ let supported = false;
669
+ try {
670
+ const hello: any = await db.admin().command({ hello: 1 });
671
+ supported = Boolean(hello?.setName) || hello?.msg === "isdbgrid";
672
+ } catch (err: any) {
673
+ logger.file("warn", "replication: change-stream capability probe failed", { tenant: tenantId, error: err?.message });
674
+ }
675
+ changeStreamSupport.set(cacheKey, supported);
676
+ return supported;
677
+ }
678
+
679
+ /**
680
+ * The source's current operation time — the `startAtOperationTime` of a fresh
681
+ * stream, so a first run replays from "now" rather than from the oplog head at
682
+ * whatever instant the cursor happened to be created.
683
+ */
684
+ async function currentOperationTime(db: Db): Promise<any | null> {
685
+ try {
686
+ const hello: any = await db.admin().command({ hello: 1 });
687
+ return hello?.operationTime ?? null;
688
+ } catch {
689
+ return null;
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Change-stream pipeline: the collection's base filter, read off the **post-image**.
695
+ *
696
+ * Change streams `$match` the *event*, whose fields are not the document's (`ns` is
697
+ * the namespace of the event, not the `ns` field of a `_vars_` document), so every
698
+ * condition is prefixed with `fullDocument`. A `delete` event has no post-image and
699
+ * `$nin` / `$eq` match a missing field, so a delete of a filtered-out document
700
+ * simply becomes a no-op `deleteOne` on the target.
701
+ */
702
+ function watchPipeline(collection: Collection): Record<string, any>[] {
703
+ const base = (collection as any)._baseFilter_ as Record<string, any> | undefined;
704
+ if (!base || !Object.keys(base).length) return [];
705
+ const match: Record<string, any> = {};
706
+ for (const [field, condition] of Object.entries(base)) match[`fullDocument.${field}`] = condition;
707
+ return [{ $match: match }];
708
+ }
709
+
710
+ /** Persist the watcher's position — the resume token, and nothing else. */
711
+ async function saveWatchState(
712
+ meta: any,
713
+ stateId: string,
714
+ tenant: Tenant,
715
+ runtime: Runtime,
716
+ collection: Collection,
717
+ key: string,
718
+ resumeToken: any,
719
+ stats: ReplicationStats,
720
+ status: ReplicationState["status"],
721
+ ): Promise<void> {
722
+ const now = new Date();
723
+ const set: Record<string, any> = {
724
+ type: "state",
725
+ tenant: tenant.id,
726
+ destination: runtime.destination.id,
727
+ collection: collection.slug,
728
+ key,
729
+ mode: "changeStream",
730
+ status,
731
+ lastRunAt: now,
732
+ stats: { ...stats },
733
+ pid: process.pid,
734
+ hostname: os.hostname(),
735
+ updatedAt: now,
736
+ };
737
+ if (resumeToken) set.resumeToken = resumeToken;
738
+
739
+ await meta.updateOne(
740
+ { _id: stateId },
741
+ // `cursor` belongs to the scan and `resumeToken` to the stream: neither
742
+ // overwrites the other, so `replication.now()` stays usable in this mode
743
+ { $set: set, $setOnInsert: { createdAt: now, cursor: { value: null, id: null } } },
744
+ { upsert: true },
745
+ );
746
+ }
747
+
748
+ /** Mirror one change-stream event on the destination. */
749
+ async function applyChangeEvent(
750
+ target: any,
751
+ collection: Collection,
752
+ event: any,
753
+ ): Promise<{ written: boolean; deleted: boolean }> {
754
+ switch (event?.operationType) {
755
+ case "insert":
756
+ case "update":
757
+ case "replace": {
758
+ const doc = event.fullDocument;
759
+ // `updateLookup` could not resolve the document (it was deleted in the
760
+ // meantime): the `delete` event that follows removes it from the target
761
+ if (!doc) return { written: false, deleted: false };
762
+ await withRetry(() => target.replaceOne({ _id: doc._id }, doc, { upsert: true }));
763
+ return { written: true, deleted: false };
764
+ }
765
+ case "delete": {
766
+ const id = event.documentKey?._id;
767
+ if (id === undefined) return { written: false, deleted: false };
768
+ // `_id` comes back typed from the oplog: no string round-trip like the
769
+ // tombstone path, so an `ObjectId` is deleted as an `ObjectId`
770
+ await withRetry(() => target.deleteOne({ _id: toId(id) }));
771
+ return { written: false, deleted: true };
772
+ }
773
+ default:
774
+ // `drop` / `rename` / `dropDatabase` / `invalidate`: applying them would
775
+ // destroy data on the destination on a whim — report instead
776
+ logger.file("warn", "replication: unhandled change-stream event", {
777
+ collection: collection.slug,
778
+ operationType: event?.operationType,
779
+ ns: event?.ns,
780
+ });
781
+ return { written: false, deleted: false };
782
+ }
783
+ }
784
+
785
+ /**
786
+ * Tail one collection and mirror every event on the destination, until `stopped`
787
+ * is set.
788
+ *
789
+ * Deletes come straight from the oplog, which is the one thing a date cursor can
790
+ * never observe — including the TTL expiries of `_audit_` / `_workflows_`, which no
791
+ * hook sees.
792
+ *
793
+ * A transient driver error reopens the stream from the last **persisted** resume
794
+ * token: at most `STATE_FLUSH_MS` of events are replayed, and every write is an
795
+ * idempotent `replaceOne` / `deleteOne`, so replaying is harmless.
796
+ */
797
+ async function watchCollection(
798
+ tenant: Tenant,
799
+ runtime: Runtime,
800
+ collection: Collection,
801
+ config: ReplicationConfig,
802
+ key: string,
803
+ meta: any,
804
+ stopped: { value: boolean },
805
+ openStreams: Set<any>,
806
+ ): Promise<void> {
807
+ const stateId = stateIdFor(tenant.id, runtime.destination.id, collection.slug);
808
+ const source = tenant.database.db!.collection(collection.slug);
809
+ const target = runtime.db.collection(collection.slug);
810
+ const pipeline = watchPipeline(collection);
811
+ const stats: ReplicationStats = { inserted: 0, updated: 0, deleted: 0, failed: 0, batches: 0, durationMs: 0 };
812
+
813
+ let state = (await meta.findOne({ _id: stateId })) as ReplicationState | null;
814
+ let token: any = state?.resumeToken ?? null;
815
+ let startAt: any = null;
816
+ let lastFlush = 0;
817
+
818
+ // Nothing to resume from (first run, `seed()`, `reset()`, or a state left by a
819
+ // scan-mode pass): backfill first. The stream's starting point is taken
820
+ // **before** it, so a write that lands while the backfill runs is replayed by the
821
+ // stream afterwards instead of being lost — the upsert is idempotent, a lost
822
+ // event is not.
823
+ if (!token) {
824
+ startAt = await currentOperationTime(tenant.database.db!);
825
+ try {
826
+ const { stats: backfill } = await scanCollection(tenant, runtime, collection, config, key, meta);
827
+ Object.assign(stats, { ...backfill, deleted: 0 });
828
+ } catch (err: any) {
829
+ logger.file("error", "replication: backfill failed, watching from now", {
830
+ tenant: tenant.id,
831
+ destination: runtime.destination.id,
832
+ collection: collection.slug,
833
+ error: err?.message,
834
+ });
438
835
  }
836
+ state = (await meta.findOne({ _id: stateId })) as ReplicationState | null;
837
+ token = state?.resumeToken ?? null;
439
838
  }
440
839
 
441
- stats.durationMs = Date.now() - startedAt;
442
- await saveState(meta, stateId, tenant, runtime, collection, key, cursor, stats, "success", null);
443
- return stats;
840
+ let backoff = WATCH_BACKOFF_MS;
841
+ while (!stopped.value) {
842
+ let stream: any = null;
843
+ try {
844
+ stream = source.watch(pipeline as any, {
845
+ // We replace the whole document on the target, so an update must carry
846
+ // its post-image rather than just the delta
847
+ fullDocument: "updateLookup",
848
+ // No `maxAwaitTimeMS` here: it must stay below the client's `timeoutMS`
849
+ // (the driver refuses a tailable awaitData cursor otherwise) and the
850
+ // default 1s server-side wait is what we want anyway
851
+ ...(token ? { resumeAfter: token } : startAt ? { startAtOperationTime: startAt } : {}),
852
+ } as any);
853
+ openStreams.add(stream);
854
+ backoff = WATCH_BACKOFF_MS;
855
+
856
+ for await (const event of stream) {
857
+ if (stopped.value) break;
858
+ const { written, deleted } = await applyChangeEvent(target, collection, event);
859
+ if (written) stats.inserted += 1;
860
+ if (deleted) stats.deleted += 1;
861
+ if (event?._id) token = event._id;
862
+
863
+ const now = Date.now();
864
+ if (now - lastFlush >= STATE_FLUSH_MS) {
865
+ lastFlush = now;
866
+ await saveWatchState(meta, stateId, tenant, runtime, collection, key, token, stats, "watching");
867
+ }
868
+ }
869
+ } catch (err: any) {
870
+ if (stopped.value) break;
871
+ const message = err?.message ?? String(err);
872
+
873
+ // The resume token outlived the oplog window (or the replica set was
874
+ // rolled back): the stream must restart from a fresh position, and what
875
+ // happened in between is gone — only a full rescan brings it back
876
+ if (RESUME_TOKEN_ERRORS.has(err?.code)) {
877
+ logger.file("error", "replication: resume token invalid, restarting from now — run replication.reset() for a full rescan", {
878
+ tenant: tenant.id,
879
+ destination: runtime.destination.id,
880
+ collection: collection.slug,
881
+ code: err?.code,
882
+ });
883
+ token = null;
884
+ startAt = await currentOperationTime(tenant.database.db!);
885
+ } else {
886
+ logger.file("error", "replication: change stream failed, reopening", {
887
+ tenant: tenant.id,
888
+ destination: runtime.destination.id,
889
+ collection: collection.slug,
890
+ retryInMs: backoff,
891
+ error: message,
892
+ });
893
+ }
894
+
895
+ await markError(meta, stateId, message);
896
+ await Bun.sleep(backoff);
897
+ backoff = Math.min(backoff * 2, WATCH_MAX_BACKOFF_MS);
898
+ } finally {
899
+ if (stream) {
900
+ openStreams.delete(stream);
901
+ try { await stream.close(); } catch { /* already closed */ }
902
+ }
903
+ }
904
+ }
905
+
906
+ // Do not resurrect a state deleted underneath us (`reset()`): only refresh a
907
+ // position that is still there — the next run replays from its own last durable
908
+ // token, and every write is idempotent
909
+ const alive = await meta.findOne({ _id: stateId }, { projection: { _id: 1 } });
910
+ if (alive) {
911
+ await saveWatchState(meta, stateId, tenant, runtime, collection, key, token, stats, "idle").catch(() => {});
912
+ }
913
+ }
914
+
915
+ /**
916
+ * Watch one destination: tail every replicated collection in parallel, each under its
917
+ * own **`replication:<destination>:<collection>`** lock.
918
+ *
919
+ * That lock is the whole arbitration: a one-shot run (`replication.now()`, the boot
920
+ * catch-up) takes the same one for the duration of its pass, so the stream and the
921
+ * scan can never write the same collection at the same time — and two cluster nodes
922
+ * can never tail the same collection onto the same destination.
923
+ */
924
+ async function startWatcher(tenant: Tenant, runtime: Runtime, config: ReplicationConfig, meta: any): Promise<void> {
925
+ const watcherKey = `${tenant.id}:${runtime.destination.id}`;
926
+ if (watchers.has(watcherKey)) return;
927
+
928
+ const rest = new useRest({ tenant_id: tenant.id });
929
+ const stopped = { value: false };
930
+ const openStreams = new Set<any>();
931
+ const collections = filterCollections(replicatedCollectionsFor(tenant.id, config), runtime.destination);
932
+ const locks: string[] = [];
933
+ const running: Promise<void>[] = [];
934
+
935
+ let heartbeat: any = null;
936
+ const stop = async () => {
937
+ if (stopped.value) return;
938
+ stopped.value = true;
939
+ if (heartbeat) clearInterval(heartbeat);
940
+ for (const stream of openStreams) {
941
+ try { await stream.close(); } catch { /* already closed */ }
942
+ }
943
+ openStreams.clear();
944
+ await Promise.allSettled(running);
945
+ for (const name of locks) await rest.unlock(name).catch(() => {});
946
+ locks.length = 0;
947
+ watchers.delete(watcherKey);
948
+ };
949
+
950
+ const launch = (collection: Collection) => {
951
+ running.push(
952
+ watchCollection(tenant, runtime, collection, config, resolveKey(collection, config), meta, stopped, openStreams)
953
+ .catch((err) => logger.file("error", "replication: watcher stopped", {
954
+ tenant: tenant.id,
955
+ destination: runtime.destination.id,
956
+ collection: collection.slug,
957
+ error: err?.message,
958
+ })),
959
+ );
960
+ };
961
+
962
+ // First pass — no waiting: a boot never delays on a lock, and one collection held
963
+ // elsewhere must not starve the others
964
+ const held: Collection[] = [];
965
+ for (const collection of collections) {
966
+ const name = watchLockName(runtime.destination.id, collection.slug);
967
+ try {
968
+ await rest.lock(name, LOCK_TTL);
969
+ locks.push(name);
970
+ launch(collection);
971
+ } catch {
972
+ held.push(collection);
973
+ }
974
+ }
975
+
976
+ // Second pass — what is left is held by a catch-up run, whose pass ends soon
977
+ if (held.length && !stopped.value) {
978
+ const deadline = Date.now() + WATCH_LOCK_WAIT_MS;
979
+ for (const collection of held) {
980
+ if (stopped.value) break;
981
+ const name = watchLockName(runtime.destination.id, collection.slug);
982
+ let acquired = false;
983
+ while (!stopped.value && !acquired && Date.now() < deadline) {
984
+ try {
985
+ await rest.lock(name, LOCK_TTL);
986
+ locks.push(name);
987
+ acquired = true;
988
+ launch(collection);
989
+ } catch {
990
+ await Bun.sleep(WATCH_LOCK_RETRY_MS);
991
+ }
992
+ }
993
+ if (!acquired) {
994
+ logger.file("warn", "replication: collection lock unavailable, not watched here", {
995
+ tenant: tenant.id,
996
+ destination: runtime.destination.id,
997
+ collection: collection.slug,
998
+ });
999
+ }
1000
+ }
1001
+ }
1002
+
1003
+ if (!running.length) {
1004
+ await stop();
1005
+ return;
1006
+ }
1007
+
1008
+ // The locks are leases, not gifts: renew them, and stand down if one is lost —
1009
+ // another node took it over and replaying the same oplog twice would be worse
1010
+ heartbeat = setInterval(async () => {
1011
+ for (const name of [...locks]) {
1012
+ const res = await rest.db.collection("_locks_")
1013
+ .updateOne(
1014
+ { _id: `${tenant.id}:${name}` as any, pid: process.pid },
1015
+ { $set: { expiresAt: Date.now() + LOCK_TTL } },
1016
+ )
1017
+ .catch(() => null);
1018
+ if (!res || (res.matchedCount ?? 0) === 0) {
1019
+ logger.file("error", "replication: watcher lost a collection lock, stopping", {
1020
+ tenant: tenant.id,
1021
+ destination: runtime.destination.id,
1022
+ lock: name,
1023
+ });
1024
+ await stop();
1025
+ return;
1026
+ }
1027
+ }
1028
+ }, Math.floor(LOCK_TTL / 3));
1029
+ (heartbeat as any)?.unref?.();
1030
+
1031
+ watchers.set(watcherKey, { stop });
1032
+ logger.file("replication: watching", {
1033
+ tenant: tenant.id,
1034
+ destination: runtime.destination.id,
1035
+ collections: collections.map((collection) => collection.slug),
1036
+ pid: process.pid,
1037
+ });
444
1038
  }
445
1039
 
446
1040
  async function replicateTenant(
@@ -452,29 +1046,7 @@ async function replicateTenant(
452
1046
  const mainDb = tenant?.database?.db;
453
1047
  if (!tenant || !config || !mainDb) return [];
454
1048
 
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
- }
1049
+ const collections = replicatedCollectionsFor(tenantId, config);
478
1050
  const results: ReplicationRunResult[] = [];
479
1051
  const meta = mainDb.collection(META_COLLECTION);
480
1052
  const rest = new useRest({ tenant_id: tenantId });
@@ -483,15 +1055,6 @@ async function replicateTenant(
483
1055
  if (opts?.destinationId && runtime.destination.id !== opts.destinationId) continue;
484
1056
  if (runtime.running) continue;
485
1057
 
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
1058
  runtime.running = true;
496
1059
  try {
497
1060
  for (const collection of filterCollections(collections, runtime.destination)) {
@@ -500,6 +1063,26 @@ async function replicateTenant(
500
1063
  const startedAt = Date.now();
501
1064
  const totals: ReplicationStats = { inserted: 0, updated: 0, deleted: 0, failed: 0, batches: 0, durationMs: 0 };
502
1065
 
1066
+ // One writer per (destination, collection): a change stream holds this
1067
+ // lock for as long as it watches, so a run neither writes the same
1068
+ // collection concurrently nor queues behind a long-lived stream
1069
+ const lockName = watchLockName(runtime.destination.id, collection.slug);
1070
+ let locked = false;
1071
+ try {
1072
+ await rest.lock(lockName, LOCK_TTL);
1073
+ locked = true;
1074
+ } catch {
1075
+ // The stream (or another node) owns this collection: it is already up
1076
+ // to date, and letting two writers share it is how a stale scan
1077
+ // overwrites a fresh event
1078
+ logger.file("replication: collection owned by another writer, skipped", {
1079
+ tenant: tenantId,
1080
+ destination: runtime.destination.id,
1081
+ collection: collection.slug,
1082
+ });
1083
+ continue;
1084
+ }
1085
+
503
1086
  try {
504
1087
  const stats = await replicateCollection(tenant, runtime, collection, config, key, meta);
505
1088
  Object.assign(totals, stats);
@@ -513,17 +1096,56 @@ async function replicateTenant(
513
1096
  });
514
1097
  totals.durationMs = Date.now() - startedAt;
515
1098
  results.push({ tenant: tenantId, destination: runtime.destination.id, collection: collection.slug, ...totals, error: message });
1099
+ } finally {
1100
+ if (locked) await rest.unlock(lockName).catch(() => {});
516
1101
  }
517
1102
  }
518
1103
  } finally {
519
1104
  runtime.running = false;
520
- if (locked) await rest.unlock(lockName).catch(() => {});
521
1105
  }
522
1106
  }
523
1107
 
524
1108
  return results;
525
1109
  }
526
1110
 
1111
+ /**
1112
+ * Stand a tenant's watchers down, returning the keys it stopped — so
1113
+ * `startWatchers()` can re-arm exactly those, once the position has been rewritten.
1114
+ *
1115
+ * `reset()` and `seed()` need that order: a running stream holds its position in
1116
+ * memory and would overwrite the new one on its next flush.
1117
+ */
1118
+ async function stopWatchers(tenantId: string, opts?: { destinationId?: string }): Promise<string[]> {
1119
+ const stopped: string[] = [];
1120
+ for (const [key, watcher] of [...watchers]) {
1121
+ if (!key.startsWith(`${tenantId}:`)) continue;
1122
+ if (opts?.destinationId && key !== `${tenantId}:${opts.destinationId}`) continue;
1123
+ await watcher.stop().catch(() => {});
1124
+ stopped.push(key);
1125
+ }
1126
+ return stopped;
1127
+ }
1128
+
1129
+ /** Re-arm the watchers `stopWatchers()` stopped, after the position was rewritten. */
1130
+ async function startWatchers(tenantId: string, keys: string[]): Promise<void> {
1131
+ if (!keys.length) return;
1132
+
1133
+ const tenant = getTenant(tenantId);
1134
+ const config = tenant ? resolveConfig(tenant) : null;
1135
+ const mainDb = tenant?.database?.db;
1136
+ if (!tenant || !config || !mainDb) return;
1137
+
1138
+ const meta = mainDb.collection(META_COLLECTION);
1139
+ for (const key of keys) {
1140
+ const destinationId = key.slice(`${tenantId}:`.length);
1141
+ const runtime = runtimes.get(key);
1142
+ if (!runtime) continue;
1143
+ await startWatcher(tenant, runtime, config, meta).catch((err) =>
1144
+ logger.file("error", "replication: watcher restart failed", { tenant: tenantId, destination: destinationId, error: err?.message }),
1145
+ );
1146
+ }
1147
+ }
1148
+
527
1149
  async function replicationNow(tenantId?: string): Promise<ReplicationRunResult[]> {
528
1150
  if (tenantId) return replicateTenant(tenantId);
529
1151
  const all: ReplicationRunResult[] = [];
@@ -562,6 +1184,11 @@ async function resetReplication(
562
1184
 
563
1185
  const meta = db.collection(META_COLLECTION);
564
1186
 
1187
+ // A running stream holds its position in memory: standing it down **before**
1188
+ // clearing is what makes the reset stick (its next flush would restore it), and
1189
+ // re-arming it afterwards is what makes it cold-start again
1190
+ const stoppedWatchers = await stopWatchers(tenantId, { destinationId: opts?.destination }).catch(() => []);
1191
+
565
1192
  const stateFilter: Record<string, any> = { type: "state", tenant: tenantId };
566
1193
  if (opts?.destination) stateFilter.destination = opts.destination;
567
1194
  if (opts?.collection) stateFilter.collection = opts.collection;
@@ -584,6 +1211,10 @@ async function resetReplication(
584
1211
  tombstones,
585
1212
  });
586
1213
 
1214
+ // A running stream keeps its position in memory and would silently undo the reset
1215
+ // on its next flush: restart it, so it cold-starts from the cleared position
1216
+ await startWatchers(tenantId, stoppedWatchers).catch(() => {});
1217
+
587
1218
  return { tenant: tenantId, state, tombstones };
588
1219
  }
589
1220
 
@@ -606,19 +1237,15 @@ async function seedReplication(
606
1237
  if (!tenant || !config || !mainDb) return { tenant: tenantId, seeded: 0 };
607
1238
 
608
1239
  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
- );
1240
+ const collections = replicatedCollectionsFor(tenantId, config)
1241
+ .filter((c) => !opts?.collection || c.slug === opts.collection);
617
1242
  const destinations = (config.destinations ?? []).filter(
618
1243
  (d) => d.enabled !== false && d.id && d.uri && (!opts?.destination || d.id === opts.destination),
619
1244
  );
620
1245
 
621
1246
  let seeded = 0;
1247
+ // Same order as `reset()`: a live stream must not overwrite the seeded position
1248
+ const stoppedWatchers = await stopWatchers(tenantId, { destinationId: opts?.destination }).catch(() => []);
622
1249
  for (const destination of destinations) {
623
1250
  for (const collection of filterCollections(collections, destination)) {
624
1251
  const key = resolveKey(collection, config);
@@ -652,6 +1279,10 @@ async function seedReplication(
652
1279
  lastError: null,
653
1280
  updatedAt: now,
654
1281
  },
1282
+ // The seeded position **replaces** the stream's: a leftover resume
1283
+ // token would make the watcher resume where it was instead of
1284
+ // cold-starting from what was just seeded
1285
+ $unset: { resumeToken: "" },
655
1286
  $setOnInsert: { createdAt: now },
656
1287
  },
657
1288
  { upsert: true },
@@ -668,6 +1299,10 @@ async function seedReplication(
668
1299
  seeded,
669
1300
  });
670
1301
 
1302
+ // A running stream does not re-read the state it holds in memory: restart it so
1303
+ // the seeded position becomes its new cold-start point
1304
+ await startWatchers(tenantId, stoppedWatchers).catch(() => {});
1305
+
671
1306
  return { tenant: tenantId, seeded };
672
1307
  }
673
1308
 
@@ -714,6 +1349,16 @@ async function startReplication(): Promise<void> {
714
1349
  const mainDb = tenant.database?.db;
715
1350
  if (!config || !mainDb) continue;
716
1351
 
1352
+ // Change detection: change streams unless the tenant asked for a scan, or the
1353
+ // source is a standalone mongod — they are a replica-set/sharded feature
1354
+ const requested = config.mode ?? DEFAULT_MODE;
1355
+ const streaming = requested === "changeStream" && await supportsChangeStreams(tenant.id, mainDb);
1356
+ if (requested === "changeStream" && !streaming) {
1357
+ logger.file("warn", "replication: change streams unavailable on this source, falling back to scan mode", {
1358
+ tenant: tenant.id,
1359
+ });
1360
+ }
1361
+
717
1362
  let connected = false;
718
1363
  for (const destination of config.destinations ?? []) {
719
1364
  if (destination.enabled === false || !destination.id || !destination.uri) continue;
@@ -737,16 +1382,31 @@ async function startReplication(): Promise<void> {
737
1382
 
738
1383
  if (!connected) continue;
739
1384
 
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.
1385
+ // In `changeStream` mode the stream carries deletes natively — including the TTL
1386
+ // expiries no hook can see — so a tombstone would only pile up in
1387
+ // `_replication_` without anyone flushing it (one-shot runs leave the streamed
1388
+ // collections alone). The hook is therefore installed for the scan mode only.
1389
+ if (!streaming) {
743
1390
  for (const collection of [...(cfg.collections ?? []), ...((cfg.fileCollections ?? []) as any[])]) {
744
1391
  if (collection._tenant_ === tenant.id && collection.replication?.enabled) {
745
1392
  instrumentCollection(collection);
746
1393
  }
747
1394
  }
1395
+ }
748
1396
 
749
- scheduleTenant(tenant, config);
1397
+ if (streaming) {
1398
+ // The stream is continuous: a schedule would only add load on top of it.
1399
+ // `runOnBoot` (below) is what catches up a destination that was down.
1400
+ if (config.schedule) {
1401
+ logger.file("warn", "replication: schedule ignored in changeStream mode", { tenant: tenant.id });
1402
+ }
1403
+ const meta = mainDb.collection(META_COLLECTION);
1404
+ for (const runtime of runtimesFor(tenant.id)) {
1405
+ await startWatcher(tenant, runtime, config, meta);
1406
+ }
1407
+ } else {
1408
+ scheduleTenant(tenant, config);
1409
+ }
750
1410
 
751
1411
  if (config.runOnBoot !== false) {
752
1412
  replicateTenant(tenant.id).catch((err) =>
@@ -757,6 +1417,11 @@ async function startReplication(): Promise<void> {
757
1417
  }
758
1418
 
759
1419
  async function stopReplication(): Promise<void> {
1420
+ // Watchers first: their streams must stop writing before the clients go away
1421
+ for (const watcher of [...watchers.values()]) {
1422
+ try { await watcher.stop(); } catch { /* already stopped */ }
1423
+ }
1424
+ watchers.clear();
760
1425
  for (const job of jobs) {
761
1426
  try { job.stop(); } catch { /* ignore */ }
762
1427
  }
@@ -766,6 +1431,7 @@ async function stopReplication(): Promise<void> {
766
1431
  }
767
1432
  runtimes.clear();
768
1433
  ensured.clear();
1434
+ changeStreamSupport.clear();
769
1435
  started = false;
770
1436
  }
771
1437