@anteros/core 0.0.42 → 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 (55) hide show
  1. package/database/replication.ts +614 -26
  2. package/lib/agent.ts +279 -39
  3. package/lib/agentMemory.ts +103 -0
  4. package/lib/agents.ts +25 -2
  5. package/lib/define.ts +1 -1
  6. package/lib/mcpClient.ts +128 -0
  7. package/lib/providers.ts +169 -66
  8. package/lib/skills.ts +481 -0
  9. package/package.json +1 -1
  10. package/server/agents.ts +27 -6
  11. package/server/api.ts +74 -1
  12. package/tests/agent-mcp.test.ts +116 -0
  13. package/tests/agent-memory.test.ts +6 -6
  14. package/tests/agent-provider.test.ts +288 -0
  15. package/tests/agent-runtime.test.ts +369 -0
  16. package/tests/agent-skills.test.ts +177 -0
  17. package/tests/agent-summarize.test.ts +124 -0
  18. package/tests/agent-working-memory.test.ts +5 -3
  19. package/tests/agent.test.ts +18 -16
  20. package/tests/agents-api.test.ts +1 -1
  21. package/tests/api-private-fields.test.ts +196 -0
  22. package/tests/file-collection-replication.test.ts +2 -0
  23. package/tests/fixtures/agent-tenant/agents/support.agent.ts +1 -1
  24. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +1 -1
  25. package/tests/fixtures/agents-api/agents/assistant.agent.ts +1 -1
  26. package/tests/fixtures/agents-api/agents/plain.agent.ts +1 -1
  27. package/tests/fixtures/agents-api/agents/private.agent.ts +1 -1
  28. package/tests/fixtures/repl-only/app.ts +3 -0
  29. package/tests/fixtures/replication-meta/agents/chat.agent.ts +1 -1
  30. package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
  31. package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
  32. package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
  33. package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
  34. package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
  35. package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
  36. package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
  37. package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
  38. package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
  39. package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
  40. package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
  41. package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
  42. package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
  43. package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
  44. package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
  45. package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
  46. package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
  47. package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
  48. package/tests/replication-change-stream.test.ts +240 -0
  49. package/tests/replication-control.test.ts +2 -0
  50. package/tests/replication-initial-sync.test.ts +2 -0
  51. package/tests/replication-meta.test.ts +3 -0
  52. package/tests/replication.test.ts +2 -0
  53. package/tests/vars-replication.test.ts +2 -0
  54. package/types/agent.d.ts +237 -7
  55. package/types/replication.d.ts +41 -4
@@ -13,6 +13,7 @@ import type {
13
13
  ReplicationDestination,
14
14
  ReplicationInitialSync,
15
15
  ReplicationMetaName,
16
+ ReplicationMode,
16
17
  ReplicationResetOptions,
17
18
  ReplicationResetResult,
18
19
  ReplicationRunResult,
@@ -34,6 +35,27 @@ const DEFAULT_BATCH = 1000;
34
35
  const BULK_CHUNK = 1000;
35
36
  /** Distributed-lock TTL used to guard a run across cluster workers. */
36
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;
37
59
 
38
60
  /**
39
61
  * Framework collections replication covers **by default**, with the date key each
@@ -145,6 +167,15 @@ function validateReplication(config: ReplicationConfig): void {
145
167
  }
146
168
  }
147
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
+
148
179
  function resolveKey(collection: Collection, config: ReplicationConfig): string {
149
180
  return collection.replication?.key ?? config.key ?? DEFAULT_KEY;
150
181
  }
@@ -153,6 +184,15 @@ function stateIdFor(tenantId: string, destinationId: string, collection: string)
153
184
  return `rep:${tenantId}:${destinationId}:${collection}`;
154
185
  }
155
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
+
156
196
  function deleteMarkerId(collection: Collection, docId: string): string {
157
197
  return `rep.delete:${collection._tenant_}:${collection.slug}:${docId}`;
158
198
  }
@@ -281,6 +321,7 @@ async function saveState(
281
321
  stats: ReplicationStats,
282
322
  status: ReplicationState["status"],
283
323
  error: string | null,
324
+ mode: ReplicationMode = DEFAULT_MODE,
284
325
  ): Promise<void> {
285
326
  const now = new Date();
286
327
  await meta.updateOne(
@@ -292,6 +333,7 @@ async function saveState(
292
333
  destination: runtime.destination.id,
293
334
  collection: collection.slug,
294
335
  key,
336
+ mode,
295
337
  cursor,
296
338
  lastRunAt: now,
297
339
  status,
@@ -463,6 +505,71 @@ async function replicateCollection(
463
505
  key: string,
464
506
  meta: any,
465
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 } }> {
466
573
  const stateId = stateIdFor(tenant.id, runtime.destination.id, collection.slug);
467
574
  const state = (await meta.findOne({ _id: stateId })) as ReplicationState | null;
468
575
  let cursor = state?.cursor ?? { value: null, id: null };
@@ -529,24 +636,405 @@ async function replicateCollection(
529
636
  if (!last) break;
530
637
  cursor = { value: last[key] ?? null, id: last._id ?? null };
531
638
 
532
- 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));
533
640
  if (docs.length < batchSize) break;
534
641
  }
535
642
 
536
- // Deletes are always propagated too — a deleted document no longer exists, so
537
- // a date cursor can never see it; they are captured by the hook + tombstone.
538
- {
539
- const expected = deleteDestinationsFor(config, collection);
540
- while (true) {
541
- const { deleted, processed } = await flushDeletes(meta, runtime, collection, key, batchSize, expected);
542
- stats.deleted += deleted;
543
- 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 };
544
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 };
545
782
  }
783
+ }
546
784
 
547
- stats.durationMs = Date.now() - startedAt;
548
- await saveState(meta, stateId, tenant, runtime, collection, key, cursor, stats, "success", null);
549
- return stats;
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
+ });
835
+ }
836
+ state = (await meta.findOne({ _id: stateId })) as ReplicationState | null;
837
+ token = state?.resumeToken ?? null;
838
+ }
839
+
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
+ });
550
1038
  }
551
1039
 
552
1040
  async function replicateTenant(
@@ -567,15 +1055,6 @@ async function replicateTenant(
567
1055
  if (opts?.destinationId && runtime.destination.id !== opts.destinationId) continue;
568
1056
  if (runtime.running) continue;
569
1057
 
570
- const lockName = `replication:${runtime.destination.id}`;
571
- let locked = false;
572
- try {
573
- await rest.lock(lockName, LOCK_TTL);
574
- locked = true;
575
- } catch {
576
- continue; // another node holds the lock
577
- }
578
-
579
1058
  runtime.running = true;
580
1059
  try {
581
1060
  for (const collection of filterCollections(collections, runtime.destination)) {
@@ -584,6 +1063,26 @@ async function replicateTenant(
584
1063
  const startedAt = Date.now();
585
1064
  const totals: ReplicationStats = { inserted: 0, updated: 0, deleted: 0, failed: 0, batches: 0, durationMs: 0 };
586
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
+
587
1086
  try {
588
1087
  const stats = await replicateCollection(tenant, runtime, collection, config, key, meta);
589
1088
  Object.assign(totals, stats);
@@ -597,17 +1096,56 @@ async function replicateTenant(
597
1096
  });
598
1097
  totals.durationMs = Date.now() - startedAt;
599
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(() => {});
600
1101
  }
601
1102
  }
602
1103
  } finally {
603
1104
  runtime.running = false;
604
- if (locked) await rest.unlock(lockName).catch(() => {});
605
1105
  }
606
1106
  }
607
1107
 
608
1108
  return results;
609
1109
  }
610
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
+
611
1149
  async function replicationNow(tenantId?: string): Promise<ReplicationRunResult[]> {
612
1150
  if (tenantId) return replicateTenant(tenantId);
613
1151
  const all: ReplicationRunResult[] = [];
@@ -646,6 +1184,11 @@ async function resetReplication(
646
1184
 
647
1185
  const meta = db.collection(META_COLLECTION);
648
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
+
649
1192
  const stateFilter: Record<string, any> = { type: "state", tenant: tenantId };
650
1193
  if (opts?.destination) stateFilter.destination = opts.destination;
651
1194
  if (opts?.collection) stateFilter.collection = opts.collection;
@@ -668,6 +1211,10 @@ async function resetReplication(
668
1211
  tombstones,
669
1212
  });
670
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
+
671
1218
  return { tenant: tenantId, state, tombstones };
672
1219
  }
673
1220
 
@@ -697,6 +1244,8 @@ async function seedReplication(
697
1244
  );
698
1245
 
699
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(() => []);
700
1249
  for (const destination of destinations) {
701
1250
  for (const collection of filterCollections(collections, destination)) {
702
1251
  const key = resolveKey(collection, config);
@@ -730,6 +1279,10 @@ async function seedReplication(
730
1279
  lastError: null,
731
1280
  updatedAt: now,
732
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: "" },
733
1286
  $setOnInsert: { createdAt: now },
734
1287
  },
735
1288
  { upsert: true },
@@ -746,6 +1299,10 @@ async function seedReplication(
746
1299
  seeded,
747
1300
  });
748
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
+
749
1306
  return { tenant: tenantId, seeded };
750
1307
  }
751
1308
 
@@ -792,6 +1349,16 @@ async function startReplication(): Promise<void> {
792
1349
  const mainDb = tenant.database?.db;
793
1350
  if (!config || !mainDb) continue;
794
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
+
795
1362
  let connected = false;
796
1363
  for (const destination of config.destinations ?? []) {
797
1364
  if (destination.enabled === false || !destination.id || !destination.uri) continue;
@@ -815,16 +1382,31 @@ async function startReplication(): Promise<void> {
815
1382
 
816
1383
  if (!connected) continue;
817
1384
 
818
- // Deletes are always propagated: wrap `afterOperation` on every
819
- // sync-enabled collection (file collections opt in the same way)
820
- // 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) {
821
1390
  for (const collection of [...(cfg.collections ?? []), ...((cfg.fileCollections ?? []) as any[])]) {
822
1391
  if (collection._tenant_ === tenant.id && collection.replication?.enabled) {
823
1392
  instrumentCollection(collection);
824
1393
  }
825
1394
  }
1395
+ }
826
1396
 
827
- 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
+ }
828
1410
 
829
1411
  if (config.runOnBoot !== false) {
830
1412
  replicateTenant(tenant.id).catch((err) =>
@@ -835,6 +1417,11 @@ async function startReplication(): Promise<void> {
835
1417
  }
836
1418
 
837
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();
838
1425
  for (const job of jobs) {
839
1426
  try { job.stop(); } catch { /* ignore */ }
840
1427
  }
@@ -844,6 +1431,7 @@ async function stopReplication(): Promise<void> {
844
1431
  }
845
1432
  runtimes.clear();
846
1433
  ensured.clear();
1434
+ changeStreamSupport.clear();
847
1435
  started = false;
848
1436
  }
849
1437