@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.
Files changed (64) hide show
  1. package/database/audit.ts +193 -0
  2. package/database/collection.ts +4 -4
  3. package/database/deleteLog.ts +34 -0
  4. package/database/field.crypto.ts +1 -1
  5. package/database/file.ts +3 -3
  6. package/database/mongodbadapter.ts +321 -147
  7. package/database/replication.ts +780 -0
  8. package/database/schema.ts +140 -125
  9. package/database/tenant.ts +4 -0
  10. package/database/vars.ts +405 -0
  11. package/database/workflow.ts +6 -6
  12. package/index.ts +10 -0
  13. package/lib/audit.ts +271 -0
  14. package/lib/auditResult.ts +120 -0
  15. package/lib/define.ts +20 -1
  16. package/lib/files.ts +64 -11
  17. package/lib/lifecycle.ts +141 -0
  18. package/lib/redact.ts +117 -0
  19. package/lib/workflow.ts +1 -1
  20. package/package.json +1 -1
  21. package/server/api.ts +134 -33
  22. package/server/boot.ts +185 -53
  23. package/server/flags.ts +136 -0
  24. package/server/hono.ts +1 -1
  25. package/tests/audit-file.test.ts +296 -0
  26. package/tests/audit-operations.test.ts +184 -0
  27. package/tests/audit-redact.test.ts +233 -0
  28. package/tests/audit-results.test.ts +188 -0
  29. package/tests/audit-retention-config.test.ts +76 -0
  30. package/tests/audit-run-action.test.ts +120 -0
  31. package/tests/audit.test.ts +320 -0
  32. package/tests/crypto.test.ts +17 -17
  33. package/tests/db-cache.test.ts +7 -7
  34. package/tests/field-encryption.test.ts +25 -25
  35. package/tests/file-collection-replication.test.ts +98 -0
  36. package/tests/fixtures/index-tenant/collections/variants.model.ts +2 -2
  37. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  38. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  39. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  40. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  41. package/tests/fixtures/repl-only/app.ts +26 -0
  42. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  43. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  44. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  45. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  46. package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
  47. package/tests/index-spec.test.ts +5 -5
  48. package/tests/lifecycle.test.ts +96 -0
  49. package/tests/replication-control.test.ts +128 -0
  50. package/tests/replication-initial-sync.test.ts +86 -0
  51. package/tests/replication-only.test.ts +358 -0
  52. package/tests/replication.test.ts +188 -0
  53. package/tests/upload.test.ts +239 -0
  54. package/tests/vars-api.test.ts +123 -0
  55. package/tests/vars-replication.test.ts +95 -0
  56. package/tests/vars.test.ts +216 -0
  57. package/types/collection.d.ts +3 -0
  58. package/types/config.d.ts +64 -0
  59. package/types/file.d.ts +4 -1
  60. package/types/lifecycle.d.ts +71 -0
  61. package/types/replication.d.ts +199 -0
  62. package/types/tenant.d.ts +23 -0
  63. package/types/vars.d.ts +147 -0
  64. package/utils/func.ts +67 -52
@@ -0,0 +1,358 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { MongoClient } from "mongodb";
5
+ import { parseBootFlags, resolveCapabilities, capabilitiesLabel, conflictingBootFlags, FULL_CAPABILITIES } from "../server/flags";
6
+
7
+ const FIXTURE = "packages/core/tests/fixtures/repl-only";
8
+ const MARKERS = path.join(FIXTURE, "tenant", ".markers");
9
+ const SRC = "mongodb://localhost:27017/_RO_SRC";
10
+ const DST = "mongodb://localhost:27017/_RO_DST";
11
+ const PORT = 5556;
12
+
13
+ let proc: any;
14
+ let src: MongoClient;
15
+ let dst: MongoClient;
16
+ let output = "";
17
+
18
+ /** Poll `check` until it returns true (or throw after `timeout`) */
19
+ async function waitFor(check: () => Promise<boolean>, timeout = 20_000, step = 250): Promise<boolean> {
20
+ const deadline = Date.now() + timeout;
21
+ while (Date.now() < deadline) {
22
+ if (await check()) return true;
23
+ await Bun.sleep(step);
24
+ }
25
+ return false;
26
+ }
27
+
28
+ describe("boot capabilities", () => {
29
+ const flags = (over: Partial<ReturnType<typeof parseBootFlags>> = {}) => ({
30
+ replicationOnly: false, noReplication: false, apiOnly: false, noScripts: false, noSockets: false,
31
+ ...over,
32
+ });
33
+
34
+ it("reads every flag (and its camelCase spelling)", () => {
35
+ expect(parseBootFlags(["bun", "index.ts", "--replication-only"]).replicationOnly).toBe(true);
36
+ expect(parseBootFlags(["bun", "index.ts", "--replicationOnly"]).replicationOnly).toBe(true);
37
+ expect(parseBootFlags(["bun", "index.ts", "--no-replication"]).noReplication).toBe(true);
38
+ expect(parseBootFlags(["bun", "index.ts", "--api-only"]).apiOnly).toBe(true);
39
+ expect(parseBootFlags(["bun", "index.ts", "--noScripts"]).noScripts).toBe(true);
40
+ expect(parseBootFlags(["bun", "index.ts", "--no-sockets"]).noSockets).toBe(true);
41
+
42
+ expect(parseBootFlags(["bun", "index.ts"])).toEqual(flags());
43
+ });
44
+
45
+ it("resolves the named modes", () => {
46
+ expect(resolveCapabilities(flags())).toEqual(FULL_CAPABILITIES);
47
+
48
+ expect(resolveCapabilities(flags(), "replication-only")).toEqual({
49
+ api: false, replication: true, scripts: false, sockets: false,
50
+ });
51
+ expect(resolveCapabilities(flags(), "api-only")).toEqual({
52
+ api: true, replication: false, scripts: false, sockets: false,
53
+ });
54
+ expect(resolveCapabilities(flags(), "no-replication")).toEqual({
55
+ api: true, replication: false, scripts: true, sockets: true,
56
+ });
57
+ });
58
+
59
+ it("lets the flags win over the configured mode", () => {
60
+ expect(resolveCapabilities(flags({ apiOnly: true }), "full")).toEqual({
61
+ api: true, replication: false, scripts: false, sockets: false,
62
+ });
63
+ expect(resolveCapabilities(flags({ replicationOnly: true }), "no-replication")).toEqual({
64
+ api: false, replication: true, scripts: false, sockets: false,
65
+ });
66
+ // the most restrictive flag wins
67
+ expect(resolveCapabilities(flags({ replicationOnly: true, apiOnly: true })).api).toBe(false);
68
+ });
69
+
70
+ it("disables one capability at a time", () => {
71
+ expect(resolveCapabilities(flags({ noScripts: true }))).toEqual({
72
+ api: true, replication: true, scripts: false, sockets: true,
73
+ });
74
+ expect(resolveCapabilities(flags({ noSockets: true }))).toEqual({
75
+ api: true, replication: true, scripts: true, sockets: false,
76
+ });
77
+ expect(resolveCapabilities(flags({ noReplication: true }))).toEqual({
78
+ api: true, replication: false, scripts: true, sockets: true,
79
+ });
80
+ });
81
+
82
+ it("labels the mode for the banner", () => {
83
+ expect(capabilitiesLabel(FULL_CAPABILITIES)).toBe("full");
84
+ expect(capabilitiesLabel({ api: false, replication: true, scripts: false, sockets: false })).toBe("replication-only");
85
+ expect(capabilitiesLabel({ api: true, replication: false, scripts: false, sockets: false })).toBe("api-only");
86
+ // granular flags stay in `full` — the banner lists what is off
87
+ expect(capabilitiesLabel({ api: true, replication: true, scripts: false, sockets: true })).toBe("full");
88
+ });
89
+
90
+ it("reports contradictory flags", () => {
91
+ expect(conflictingBootFlags(flags({ replicationOnly: true, noReplication: true }))).toBe(true);
92
+ expect(conflictingBootFlags(flags({ replicationOnly: true, apiOnly: true }))).toBe(true);
93
+ expect(conflictingBootFlags(flags({ replicationOnly: true }))).toBe(false);
94
+ expect(conflictingBootFlags(flags({ apiOnly: true, noScripts: true }))).toBe(false);
95
+ });
96
+ });
97
+
98
+ describe("replication-only boot", () => {
99
+ beforeAll(async () => {
100
+ fs.rmSync(MARKERS, { recursive: true, force: true });
101
+
102
+ src = new MongoClient(SRC);
103
+ dst = new MongoClient(DST);
104
+ await src.connect();
105
+ await dst.connect();
106
+ await src.db().collection("orders").drop().catch(() => {});
107
+ await dst.db().collection("orders").drop().catch(() => {});
108
+ await dst.db().collection("_replication_").drop().catch(() => {});
109
+
110
+ proc = Bun.spawn(["bun", `${FIXTURE}/app.ts`, "--replication-only"], {
111
+ cwd: process.cwd(),
112
+ stdout: "pipe",
113
+ stderr: "pipe",
114
+ env: { ...process.env, RO_PORT: String(PORT), RO_SRC: SRC, RO_DST: DST },
115
+ });
116
+
117
+ // Drain the pipes (a full buffer would block the child)
118
+ for (const stream of [proc.stdout, proc.stderr]) {
119
+ (async () => {
120
+ const reader = (stream as ReadableStream<Uint8Array>).getReader();
121
+ const decoder = new TextDecoder();
122
+ while (true) {
123
+ const { done, value } = await reader.read();
124
+ if (done) break;
125
+ output += decoder.decode(value, { stream: true });
126
+ }
127
+ })().catch(() => {});
128
+ }
129
+
130
+ // A document written to the source must reach the destination on its own
131
+ await src.db().collection("orders").insertOne({ ref: "RO-1", total: 42 } as any);
132
+ });
133
+
134
+ afterAll(async () => {
135
+ try { proc?.kill(); } catch {}
136
+ try { await src?.close(); } catch {}
137
+ try { await dst?.close(); } catch {}
138
+ fs.rmSync(MARKERS, { recursive: true, force: true });
139
+ });
140
+
141
+ it("replicates the tenant data", async () => {
142
+ const replicated = await waitFor(async () =>
143
+ (await dst.db().collection("orders").countDocuments({ ref: "RO-1" })) === 1,
144
+ );
145
+ expect(replicated, `no replication after 20s — app output:\n${output}`).toBe(true);
146
+
147
+ const onTarget: any = await dst.db().collection("orders").findOne({ ref: "RO-1" });
148
+ expect(onTarget.total).toBe(42);
149
+ }, 30_000);
150
+
151
+ it("exposes no HTTP API", async () => {
152
+ // `/health` is registered by the framework — if anything were listening, it would answer
153
+ const refused = await fetch(`http://localhost:${PORT}/health`)
154
+ .then(() => false)
155
+ .catch(() => true);
156
+ expect(refused, "a server is listening on the configured port").toBe(true);
157
+ });
158
+
159
+ it("still runs the lifecycle hooks", async () => {
160
+ const ran = await waitFor(async () => fs.existsSync(path.join(MARKERS, "lifecycle.txt")));
161
+ expect(ran, `beforeBoot did not run — app output:\n${output}`).toBe(true);
162
+ }, 30_000);
163
+
164
+ it("does not run the tenant scripts", async () => {
165
+ // give the (skipped) scripts path a chance to fire
166
+ await Bun.sleep(500);
167
+ expect(fs.existsSync(path.join(MARKERS, "script.txt"))).toBe(false);
168
+ });
169
+
170
+ it("keeps the process alive (replication timers are unref'd)", async () => {
171
+ await Bun.sleep(300);
172
+ expect(proc.exitCode, `the process exited — output:\n${output}`).toBeNull();
173
+ });
174
+
175
+ it("stops replication cleanly on SIGTERM (onDestroy runs)", async () => {
176
+ proc.kill("SIGTERM");
177
+
178
+ const exited = await waitFor(async () => proc.exitCode !== null, 10_000, 100);
179
+ expect(exited, `the process did not exit — output:\n${output}`).toBe(true);
180
+ expect(proc.exitCode).toBe(0);
181
+
182
+ const destroyed = await waitFor(async () => fs.existsSync(path.join(MARKERS, "onDestroy.txt")));
183
+ expect(destroyed, `onDestroy did not run — output:\n${output}`).toBe(true);
184
+ }, 30_000);
185
+ });
186
+
187
+ describe("no-replication boot", () => {
188
+ const PORT = 5557;
189
+ const SRC = "mongodb://localhost:27017/_NOREP_SRC";
190
+ const DST = "mongodb://localhost:27017/_NOREP_DST";
191
+
192
+ let procNo: any;
193
+ let srcNo: MongoClient;
194
+ let dstNo: MongoClient;
195
+ let outputNo = "";
196
+
197
+ beforeAll(async () => {
198
+ fs.rmSync(MARKERS, { recursive: true, force: true });
199
+
200
+ srcNo = new MongoClient(SRC);
201
+ dstNo = new MongoClient(DST);
202
+ await srcNo.connect();
203
+ await dstNo.connect();
204
+ await srcNo.db().collection("orders").drop().catch(() => {});
205
+ await dstNo.db().collection("orders").drop().catch(() => {});
206
+
207
+ procNo = Bun.spawn(["bun", `${FIXTURE}/app.ts`, "--no-replication"], {
208
+ cwd: process.cwd(),
209
+ stdout: "pipe",
210
+ stderr: "pipe",
211
+ env: { ...process.env, RO_PORT: String(PORT), RO_SRC: SRC, RO_DST: DST },
212
+ });
213
+
214
+ for (const stream of [procNo.stdout, procNo.stderr]) {
215
+ (async () => {
216
+ const reader = (stream as ReadableStream<Uint8Array>).getReader();
217
+ const decoder = new TextDecoder();
218
+ while (true) {
219
+ const { done, value } = await reader.read();
220
+ if (done) break;
221
+ outputNo += decoder.decode(value, { stream: true });
222
+ }
223
+ })().catch(() => {});
224
+ }
225
+
226
+ // Wait for the server to listen before writing to the source
227
+ await waitFor(async () => {
228
+ try {
229
+ await fetch(`http://localhost:${PORT}/health`);
230
+ return true;
231
+ } catch {
232
+ return false;
233
+ }
234
+ }, 20_000);
235
+
236
+ await srcNo.db().collection("orders").insertOne({ ref: "NOREP-1", total: 7 } as any);
237
+ });
238
+
239
+ afterAll(async () => {
240
+ try { procNo?.kill(); } catch {}
241
+ try { await srcNo?.close(); } catch {}
242
+ try { await dstNo?.close(); } catch {}
243
+ fs.rmSync(MARKERS, { recursive: true, force: true });
244
+ });
245
+
246
+ it("serves the HTTP API", async () => {
247
+ const res = await fetch(`http://localhost:${PORT}/health`);
248
+ expect(res.status).toBe(200);
249
+ expect(await res.json()).toBeDefined();
250
+ });
251
+
252
+ it("never starts the replication engine", async () => {
253
+ // give it more than enough time to replicate, if it were going to
254
+ await Bun.sleep(2_000);
255
+
256
+ expect(await dstNo.db().collection("orders").countDocuments({})).toBe(0);
257
+ // …and no replication state was created either
258
+ const state = await srcNo.db().listCollections({ name: "_replication_" }).toArray();
259
+ expect(state.length).toBe(0);
260
+ }, 15_000);
261
+
262
+ it("still serves the Socket.IO endpoint", async () => {
263
+ const res = await fetch(`http://localhost:${PORT}/socket.io/?EIO=4&transport=polling`);
264
+ expect(res.status).toBe(200);
265
+ }, 15_000);
266
+
267
+ it("is otherwise a full server (scripts run, lifecycle hooks run)", async () => {
268
+ const scripted = await waitFor(async () => fs.existsSync(path.join(MARKERS, "script.txt")));
269
+ const lifecycle = await waitFor(async () => fs.existsSync(path.join(MARKERS, "lifecycle.txt")));
270
+
271
+ expect(lifecycle, `beforeBoot did not run — output:\n${outputNo}`).toBe(true);
272
+ expect(scripted, `scripts did not run — output:\n${outputNo}`).toBe(true);
273
+ }, 20_000);
274
+ });
275
+
276
+ describe("api-only boot", () => {
277
+ const PORT = 5558;
278
+ const SRC = "mongodb://localhost:27017/_APIONLY_SRC";
279
+ const DST = "mongodb://localhost:27017/_APIONLY_DST";
280
+
281
+ let procApi: any;
282
+ let srcApi: MongoClient;
283
+ let dstApi: MongoClient;
284
+ let outputApi = "";
285
+
286
+ beforeAll(async () => {
287
+ fs.rmSync(MARKERS, { recursive: true, force: true });
288
+
289
+ srcApi = new MongoClient(SRC);
290
+ dstApi = new MongoClient(DST);
291
+ await srcApi.connect();
292
+ await dstApi.connect();
293
+ await srcApi.db().collection("orders").drop().catch(() => {});
294
+ await dstApi.db().collection("orders").drop().catch(() => {});
295
+
296
+ procApi = Bun.spawn(["bun", `${FIXTURE}/app.ts`, "--api-only"], {
297
+ cwd: process.cwd(),
298
+ stdout: "pipe",
299
+ stderr: "pipe",
300
+ env: { ...process.env, RO_PORT: String(PORT), RO_SRC: SRC, RO_DST: DST },
301
+ });
302
+
303
+ for (const stream of [procApi.stdout, procApi.stderr]) {
304
+ (async () => {
305
+ const reader = (stream as ReadableStream<Uint8Array>).getReader();
306
+ const decoder = new TextDecoder();
307
+ while (true) {
308
+ const { done, value } = await reader.read();
309
+ if (done) break;
310
+ outputApi += decoder.decode(value, { stream: true });
311
+ }
312
+ })().catch(() => {});
313
+ }
314
+
315
+ await waitFor(async () => {
316
+ try {
317
+ await fetch(`http://localhost:${PORT}/health`);
318
+ return true;
319
+ } catch {
320
+ return false;
321
+ }
322
+ }, 20_000);
323
+
324
+ await srcApi.db().collection("orders").insertOne({ ref: "APIONLY-1", total: 3 } as any);
325
+ });
326
+
327
+ afterAll(async () => {
328
+ try { procApi?.kill(); } catch {}
329
+ try { await srcApi?.close(); } catch {}
330
+ try { await dstApi?.close(); } catch {}
331
+ fs.rmSync(MARKERS, { recursive: true, force: true });
332
+ });
333
+
334
+ it("serves the HTTP API", async () => {
335
+ const res = await fetch(`http://localhost:${PORT}/health`);
336
+ expect(res.status).toBe(200);
337
+ });
338
+
339
+ it("does not serve the Socket.IO endpoint", async () => {
340
+ const res = await fetch(`http://localhost:${PORT}/socket.io/?EIO=4&transport=polling`);
341
+ expect(res.status, `the socket endpoint answered — output:\n${outputApi}`).not.toBe(200);
342
+ }, 15_000);
343
+
344
+ it("does not run the tenant scripts", async () => {
345
+ // the scripts path would have fired ~150ms after boot — give it more
346
+ await Bun.sleep(1_500);
347
+ expect(fs.existsSync(path.join(MARKERS, "script.txt"))).toBe(false);
348
+ // …while the lifecycle still runs (it is part of a normal boot)
349
+ expect(fs.existsSync(path.join(MARKERS, "lifecycle.txt"))).toBe(true);
350
+ }, 15_000);
351
+
352
+ it("never starts the replication engine", async () => {
353
+ await Bun.sleep(1_500);
354
+ expect(await dstApi.db().collection("orders").countDocuments({})).toBe(0);
355
+ const state = await srcApi.db().listCollections({ name: "_replication_" }).toArray();
356
+ expect(state.length).toBe(0);
357
+ }, 15_000);
358
+ });
@@ -0,0 +1,188 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { MongoClient, ObjectId } from "mongodb";
3
+ import { useRest } from "../database/rest";
4
+ import { formatConfig, cfg } from "../server/config";
5
+ import {
6
+ startReplication,
7
+ stopReplication,
8
+ replicateTenant,
9
+ getReplicationState,
10
+ } from "../database/replication";
11
+
12
+ const TEST_TENANT = "repl";
13
+ const SRC_DB = "mongodb://localhost:27017/_REPL_TEST_SRC";
14
+ const DEST_DB = "mongodb://localhost:27017/_REPL_TEST_DEST";
15
+ const DEST_ID = "backup";
16
+
17
+ let rest: InstanceType<typeof useRest>;
18
+ let sourceClient: MongoClient;
19
+ let destClient: MongoClient;
20
+
21
+ beforeAll(async () => {
22
+ formatConfig({
23
+ server: { port: 4000 },
24
+ tenants: [{
25
+ id: TEST_TENANT,
26
+ dir: "src",
27
+ database: { uri: SRC_DB },
28
+ replication: {
29
+ runOnBoot: false,
30
+ schedule: { interval: "1h" },
31
+ destinations: [{ id: DEST_ID, uri: DEST_DB }],
32
+ },
33
+ }],
34
+ });
35
+
36
+ // Connect the source DB manually with a short timeout so a missing MongoDB
37
+ // fails fast and clearly (instead of hanging or process.exit(1)).
38
+ sourceClient = new MongoClient(SRC_DB, { serverSelectionTimeoutMS: 3000 });
39
+ await sourceClient.connect();
40
+ const tenant: any = cfg.tenants?.[0];
41
+ tenant.database.client = sourceClient;
42
+ tenant.database.db = sourceClient.db();
43
+
44
+ (cfg as any).collections = [
45
+ {
46
+ _tenant_: TEST_TENANT,
47
+ slug: "orders",
48
+ fields: [
49
+ { name: "title", type: "string" },
50
+ { name: "status", type: "string" },
51
+ { name: "customer", type: "relationship", relation: { to: "customers" } },
52
+ { name: "paidAt", type: "date" },
53
+ ],
54
+ replication: { enabled: true },
55
+ },
56
+ ];
57
+
58
+ await startReplication();
59
+
60
+ rest = new useRest({ tenant_id: TEST_TENANT });
61
+ destClient = new MongoClient(DEST_DB, { serverSelectionTimeoutMS: 3000 });
62
+ await destClient.connect();
63
+ });
64
+
65
+ afterAll(async () => {
66
+ await stopReplication();
67
+ try { await sourceClient.db().dropDatabase(); } catch (_) {}
68
+ try { await destClient.db().dropDatabase(); } catch (_) {}
69
+ try { await destClient.close(); } catch (_) {}
70
+ try { await sourceClient.close(); } catch (_) {}
71
+ });
72
+
73
+ describe("replication", () => {
74
+ it("replicates inserted documents", async () => {
75
+ const marker = `ins-${crypto.randomUUID()}`;
76
+ const source: any = await rest.insertOne("orders", { title: marker, status: "new" });
77
+ await replicateTenant(TEST_TENANT);
78
+
79
+ const dest = await destClient.db().collection("orders").findOne({ title: marker });
80
+ expect(dest).toBeDefined();
81
+ expect((dest as any)?.status).toBe("new");
82
+ // The destination keeps the **same `_id`** (the upsert is keyed on it), same BSON type
83
+ expect((dest as any)?._id.toString()).toBe(source._id);
84
+ expect((dest as any)?._id).toBeInstanceOf(ObjectId);
85
+ });
86
+
87
+ it("keeps a custom `_id` type (string) as-is", async () => {
88
+ const marker = `custom-${crypto.randomUUID()}`;
89
+ const customId = `order-${crypto.randomUUID()}`; // not a 24-hex string
90
+ await sourceClient.db().collection("orders").insertOne({
91
+ _id: customId as any,
92
+ title: marker,
93
+ status: "new",
94
+ updatedAt: new Date(),
95
+ });
96
+ await replicateTenant(TEST_TENANT);
97
+
98
+ const dest: any = await destClient.db().collection("orders").findOne({ title: marker });
99
+ expect(dest).toBeDefined();
100
+ expect(dest._id).toBe(customId);
101
+ expect(typeof dest._id).toBe("string");
102
+ });
103
+
104
+ it("writes BSON types on the destination — ObjectId relations and Dates stay typed", async () => {
105
+ const marker = `bson-${crypto.randomUUID()}`;
106
+ const customerId = new ObjectId();
107
+ const paidAt = new Date("2026-03-04T05:06:07.000Z");
108
+
109
+ // Through the framework: the relation string and the ISO date are converted
110
+ // to BSON by `toBson` before hitting MongoDB.
111
+ const source: any = await rest.insertOne("orders", {
112
+ title: marker,
113
+ status: "paid",
114
+ customer: customerId.toHexString(),
115
+ paidAt: paidAt.toISOString(),
116
+ });
117
+ await replicateTenant(TEST_TENANT);
118
+
119
+ // …and the destination must hold the same BSON types, not strings
120
+ const dest: any = await destClient.db().collection("orders").findOne({ title: marker });
121
+ expect(dest).toBeDefined();
122
+ expect(dest._id).toBeInstanceOf(ObjectId);
123
+ expect(dest._id.toString()).toBe(source._id);
124
+ expect(dest.customer).toBeInstanceOf(ObjectId);
125
+ expect(dest.customer.toString()).toBe(customerId.toHexString());
126
+ expect(dest.paidAt).toBeInstanceOf(Date);
127
+ expect(dest.paidAt.getTime()).toBe(paidAt.getTime());
128
+ expect(dest.createdAt).toBeInstanceOf(Date);
129
+ expect(dest.updatedAt).toBeInstanceOf(Date);
130
+
131
+ // The source itself stores the very same types (the copy is faithful)
132
+ const raw: any = await sourceClient.db().collection("orders").findOne({ title: marker });
133
+ expect(raw.customer).toBeInstanceOf(ObjectId);
134
+ expect(raw.paidAt).toBeInstanceOf(Date);
135
+ expect(typeof raw.customer).toBe(typeof dest.customer);
136
+ });
137
+
138
+ it("replicates updates", async () => {
139
+ const marker = `upd-${crypto.randomUUID()}`;
140
+ const doc: any = await rest.insertOne("orders", { title: marker, status: "draft" });
141
+ await replicateTenant(TEST_TENANT);
142
+
143
+ await rest.updateOne("orders", doc._id, { $set: { status: "paid" } });
144
+ await replicateTenant(TEST_TENANT);
145
+
146
+ const dest = await destClient.db().collection("orders").findOne({ title: marker });
147
+ expect((dest as any)?.status).toBe("paid");
148
+ });
149
+
150
+ it("replicates deletes via tombstone", async () => {
151
+ const marker = `del-${crypto.randomUUID()}`;
152
+ const doc: any = await rest.insertOne("orders", { title: marker, status: "x" });
153
+ await replicateTenant(TEST_TENANT);
154
+
155
+ const before = await destClient.db().collection("orders").findOne({ title: marker });
156
+ expect(before).toBeDefined();
157
+
158
+ await rest.deleteOne("orders", doc._id);
159
+ await replicateTenant(TEST_TENANT);
160
+
161
+ const after = await destClient.db().collection("orders").findOne({ title: marker });
162
+ expect(after).toBeNull();
163
+ });
164
+
165
+ it("does not re-copy unchanged documents (cursor is persisted)", async () => {
166
+ const marker = `resume-${crypto.randomUUID()}`;
167
+ await rest.insertOne("orders", { title: marker, status: "new" });
168
+
169
+ const first = await replicateTenant(TEST_TENANT);
170
+ const second = await replicateTenant(TEST_TENANT);
171
+
172
+ const r1 = first.find((r) => r.collection === "orders" && r.destination === DEST_ID);
173
+ const r2 = second.find((r) => r.collection === "orders" && r.destination === DEST_ID);
174
+ expect(r1?.inserted).toBeGreaterThanOrEqual(1);
175
+ expect(r2?.inserted).toBe(0);
176
+ expect(r2?.updated).toBe(0);
177
+ });
178
+
179
+ it("stores state in _replication_", async () => {
180
+ await rest.insertOne("orders", { title: `state-${crypto.randomUUID()}`, status: "new" });
181
+ await replicateTenant(TEST_TENANT);
182
+
183
+ const state = await getReplicationState(TEST_TENANT, DEST_ID, "orders");
184
+ expect(state.length).toBe(1);
185
+ expect(state[0]!.status).toBe("success");
186
+ expect(state[0]!.cursor).toBeDefined();
187
+ });
188
+ });