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