@anteros/core 0.0.40 → 0.0.42

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