@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,98 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { MongoClient } from "mongodb";
3
+ import { useRest } from "../database/rest";
4
+ import { formatConfig, cfg } from "../server/config";
5
+ import { startReplication, stopReplication, replicateTenant } from "../database/replication";
6
+
7
+ const TEST_TENANT = "files-repl";
8
+ const SRC_DB = "mongodb://localhost:27017/_FILES_REPL_SRC";
9
+ const DEST_DB = "mongodb://localhost:27017/_FILES_REPL_DEST";
10
+ const SLUG = "photos";
11
+
12
+ let rest: InstanceType<typeof useRest>;
13
+ let sourceClient: MongoClient;
14
+ let destClient: MongoClient;
15
+
16
+ beforeAll(async () => {
17
+ formatConfig({
18
+ server: { port: 4000 },
19
+ tenants: [{
20
+ id: TEST_TENANT,
21
+ dir: "src",
22
+ database: { uri: SRC_DB },
23
+ replication: {
24
+ runOnBoot: false,
25
+ schedule: { interval: "1h" },
26
+ destinations: [{ id: "backup", uri: DEST_DB }],
27
+ },
28
+ }],
29
+ });
30
+
31
+ sourceClient = new MongoClient(SRC_DB, { serverSelectionTimeoutMS: 3000 });
32
+ await sourceClient.connect();
33
+ const tenant: any = cfg.tenants?.[0];
34
+ tenant.database.client = sourceClient;
35
+ tenant.database.db = sourceClient.db();
36
+
37
+ (cfg as any).fileCollections = [{
38
+ _tenant_: TEST_TENANT,
39
+ _isFileCollection_: true,
40
+ slug: SLUG,
41
+ fields: [{ name: "label", type: "string" }],
42
+ api: { access: { "*": true } },
43
+ // Opt the file collection's documents into the data replication engine.
44
+ replication: { enabled: true },
45
+ }];
46
+
47
+ await startReplication();
48
+
49
+ rest = new useRest({ tenant_id: TEST_TENANT });
50
+ destClient = new MongoClient(DEST_DB, { serverSelectionTimeoutMS: 3000 });
51
+ await destClient.connect();
52
+ });
53
+
54
+ afterAll(async () => {
55
+ await stopReplication();
56
+ try { await sourceClient.db().dropDatabase(); } catch (_) {}
57
+ try { await destClient.db().dropDatabase(); } catch (_) {}
58
+ try { await destClient.close(); } catch (_) {}
59
+ try { await sourceClient.close(); } catch (_) {}
60
+ });
61
+
62
+ const destDoc = (label: string) => destClient.db().collection(SLUG).findOne({ label });
63
+
64
+ describe("file collection replication", () => {
65
+ it("replicates file-collection documents", async () => {
66
+ await rest.insertOne(SLUG, { label: "a", _file: { filename: "a.png" } });
67
+ await replicateTenant(TEST_TENANT);
68
+
69
+ const dest = await destDoc("a");
70
+ expect(dest).toBeDefined();
71
+ expect((dest as any)._file.filename).toBe("a.png");
72
+ });
73
+
74
+ it("propagates deletions via tombstones", async () => {
75
+ const doc: any = await rest.insertOne(SLUG, { label: "del-me" });
76
+ await replicateTenant(TEST_TENANT);
77
+ expect(await destDoc("del-me")).toBeDefined();
78
+
79
+ await rest.deleteOne(SLUG, doc._id);
80
+ await replicateTenant(TEST_TENANT);
81
+ expect(await destDoc("del-me")).toBeNull();
82
+ });
83
+
84
+ it("does not replicate a file collection that did not opt in", async () => {
85
+ (cfg as any).fileCollections.push({
86
+ _tenant_: TEST_TENANT,
87
+ _isFileCollection_: true,
88
+ slug: "private-files",
89
+ fields: [],
90
+ api: { access: { "*": true } },
91
+ });
92
+
93
+ await rest.insertOne("private-files", { label: "secret" });
94
+ await replicateTenant(TEST_TENANT);
95
+
96
+ expect(await destClient.db().collection("private-files").findOne({ label: "secret" })).toBeNull();
97
+ });
98
+ });
@@ -1,8 +1,8 @@
1
1
  import { define } from "../../../../index";
2
2
 
3
3
  // Reproduces the Mongo error `sparse: null is not convertible to bool`:
4
- // `indexOptions.sparse` explicit à null doit être ignoré (jamais envoyé à Mongo),
5
- // et `unique` accepte le style Mongo 1/0 (coercé en vrai booléen).
4
+ // an explicit `indexOptions.sparse: null` must be ignored (never sent to Mongo),
5
+ // and `unique` accepts the Mongo 1/0 style (coerced to a real boolean).
6
6
  export default define.Collection({
7
7
  slug: "variants",
8
8
  fields: [
@@ -0,0 +1,11 @@
1
+ // Test fixture — disabled lifecycle (must be skipped).
2
+ const calls: string[] = [];
3
+ (globalThis as any).__lcDisabled = calls;
4
+
5
+ export default {
6
+ _isLifecycle_: true,
7
+ enabled: false,
8
+ beforeBoot: async () => { calls.push("beforeBoot"); },
9
+ afterBoot: async () => { calls.push("afterBoot"); },
10
+ onDestroy: async () => { calls.push("onDestroy"); },
11
+ };
@@ -0,0 +1,13 @@
1
+ // Test fixture — `onDestroy` never resolves (used to test destroyTimeout).
2
+ const calls: string[] = [];
3
+ (globalThis as any).__lcHang = calls;
4
+
5
+ export default {
6
+ _isLifecycle_: true,
7
+ enabled: true,
8
+ destroyTimeout: 100,
9
+ onDestroy: async () => {
10
+ calls.push("onDestroy:start");
11
+ await new Promise(() => { /* never resolves */ });
12
+ },
13
+ };
@@ -0,0 +1,23 @@
1
+ // Test fixture — records lifecycle calls on globalThis.
2
+ const calls: string[] = [];
3
+ (globalThis as any).__lcNormal = calls;
4
+
5
+ export default {
6
+ _isLifecycle_: true,
7
+ enabled: true,
8
+ destroyTimeout: 200,
9
+ beforeBoot: async ({ replication }: any) => {
10
+ calls.push("beforeBoot");
11
+ // The API is tenant-bound (like `rest`) — no tenant id is passed, and the
12
+ // implicit tenant must match this lifecycle's tenant.
13
+ const seeded = await replication.seed("latest");
14
+ (globalThis as any).__lcReplicationApi =
15
+ typeof replication?.seed === "function" &&
16
+ typeof replication?.reset === "function" &&
17
+ typeof replication?.now === "function" &&
18
+ typeof replication?.state === "function" &&
19
+ seeded?.tenant === "normal";
20
+ },
21
+ afterBoot: async () => { calls.push("afterBoot"); },
22
+ onDestroy: async ({ reason }: any) => { calls.push(`onDestroy:${reason}`); },
23
+ };
@@ -0,0 +1,6 @@
1
+ // Test fixture — `beforeBoot` throws.
2
+ export default {
3
+ _isLifecycle_: true,
4
+ enabled: true,
5
+ beforeBoot: async () => { throw new Error("boom"); },
6
+ };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Fixture — boots the server through `bootApp`, to be launched with a boot mode
3
+ * flag (see `packages/core/tests/replication-only.test.ts`).
4
+ * Port and database URIs are overridable through the environment so the same
5
+ * fixture serves every mode without clashes.
6
+ *
7
+ * Run manually:
8
+ * bun packages/core/tests/fixtures/repl-only/app.ts --replication-only
9
+ * bun packages/core/tests/fixtures/repl-only/app.ts --no-replication
10
+ */
11
+ import { bootApp } from "../../../server/boot";
12
+
13
+ await bootApp({
14
+ server: { port: Number(Bun.env.RO_PORT ?? 5556) },
15
+ tenants: [{
16
+ id: "ro",
17
+ dir: "packages/core/tests/fixtures/repl-only/tenant",
18
+ database: { uri: Bun.env.RO_SRC ?? "mongodb://localhost:27017/_RO_SRC" },
19
+ replication: {
20
+ enabled: true,
21
+ runOnBoot: true,
22
+ schedule: { interval: "1h" },
23
+ destinations: [{ id: "backup", uri: Bun.env.RO_DST ?? "mongodb://localhost:27017/_RO_DST" }],
24
+ },
25
+ }],
26
+ });
@@ -0,0 +1,14 @@
1
+ // Test fixture — a replicable collection (plain object + marker, no import needed).
2
+ export default {
3
+ _isCollection_: true,
4
+ _isTimeSerie_: false,
5
+ type: "document",
6
+ slug: "orders",
7
+ fields: [
8
+ { name: "ref", type: "string" },
9
+ { name: "total", type: "number" },
10
+ ],
11
+ api: { access: { "*": true } },
12
+ // Opt this collection into the data replication engine
13
+ replication: { enabled: true },
14
+ };
@@ -0,0 +1,14 @@
1
+ // Test fixture — `beforeBoot` / `onDestroy` must still run in replication-only
2
+ // mode (the documented place to seed or reset a replication before it starts).
3
+ // Each hook drops a marker file the test can assert on.
4
+ // (`enabled` mirrors what `define.Lifecycle` sets by default.)
5
+ export default {
6
+ _isLifecycle_: true,
7
+ enabled: true,
8
+ beforeBoot: async () => {
9
+ await Bun.write(new URL("./.markers/lifecycle.txt", import.meta.url), "beforeBoot");
10
+ },
11
+ onDestroy: async () => {
12
+ await Bun.write(new URL("./.markers/onDestroy.txt", import.meta.url), "onDestroy");
13
+ },
14
+ };
@@ -0,0 +1,8 @@
1
+ // Test fixture — must NOT run in replication-only mode (no `runScripts`).
2
+ export default {
3
+ _isScript_: true,
4
+ enabled: true,
5
+ exec: async () => {
6
+ await Bun.write(new URL("../.markers/script.txt", import.meta.url), "ran");
7
+ },
8
+ };
@@ -0,0 +1,23 @@
1
+ // Test fixture — a `define.Vars` definition (plain object + marker).
2
+ export default {
3
+ _isVars_: true,
4
+ namespace: "config",
5
+ scope: { name: "company", type: "relationship", relation: { to: "companies" } },
6
+ meta: [
7
+ { name: "note", type: "string" },
8
+ { name: "expiresAt", type: "datetime-local" },
9
+ ],
10
+ vars: {
11
+ licence: "RDX00",
12
+ maxUsers: { type: "number", defaultValue: 100 },
13
+ session: { type: "string", ttl: "1h" },
14
+ },
15
+ api: {
16
+ access: {
17
+ "*": true,
18
+ set: true,
19
+ },
20
+ },
21
+ // Opt this namespace into replication (per-namespace filter on `ns`).
22
+ replication: { enabled: true },
23
+ };
@@ -0,0 +1,6 @@
1
+ // Test fixture — a namespace WITHOUT `api.access` (must be denied over HTTP).
2
+ export default {
3
+ _isVars_: true,
4
+ namespace: "private",
5
+ vars: { secret: "shh" },
6
+ };
@@ -7,8 +7,8 @@ const TENANT = "idx-test";
7
7
  const DIR = "packages/core/tests/fixtures/index-tenant";
8
8
  const DB = "mongodb://localhost:27017/_DB_IDX_TEST";
9
9
 
10
- // initializeOnDatabase est fire-and-forget dans syncCollections — on attend
11
- // que les index existent réellement en base.
10
+ // initializeOnDatabase is fire-and-forget inside syncCollections — wait until the
11
+ // indexes really exist in the database.
12
12
  async function waitForIndexes(colName: string): Promise<any[]> {
13
13
  const db = cfg.tenants?.[0]?.database?.db as any;
14
14
  const col = db.collection(colName);
@@ -40,13 +40,13 @@ describe("index spec construction", () => {
40
40
  it("drops null/undefined options and coerces Mongo-style 1/0 booleans", async () => {
41
41
  const idxs = await waitForIndexes("variants");
42
42
 
43
- // name : indexOptions.sparse = null → ignoré (pas de sparse:null), index créé
43
+ // name: indexOptions.sparse = null → ignored (no sparse:null sent), index created
44
44
  const nameIdx = idxs.find((i) => i.name === "name_1");
45
45
  expect(nameIdx).toBeDefined();
46
46
  expect(nameIdx!.sparse).not.toBe(null);
47
- expect(nameIdx!.sparse).not.toBe(true); // false / undefined — jamais null
47
+ expect(nameIdx!.sparse).not.toBe(true); // false / undefined — never null
48
48
 
49
- // sku : unique: 1 (Mongo-style) → index unique réel
49
+ // sku: unique: 1 (Mongo-style) → a real unique index
50
50
  const skuIdx = idxs.find((i) => i.name === "sku_1");
51
51
  expect(skuIdx).toBeDefined();
52
52
  expect(skuIdx!.unique).toBe(true);
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { formatConfig } from "../server/config";
3
+ import { loadLifecycles, runBeforeBoot, runAfterBoot, runOnDestroy } from "../lib/lifecycle";
4
+ import { define } from "../lib/define";
5
+
6
+ const DIRS: Record<string, string> = {
7
+ normal: "packages/core/tests/fixtures/lc-normal",
8
+ thrower: "packages/core/tests/fixtures/lc-throw",
9
+ hang: "packages/core/tests/fixtures/lc-hang",
10
+ disabled: "packages/core/tests/fixtures/lc-disabled",
11
+ };
12
+
13
+ /** Configure cfg with a single tenant pointing at the given fixture dir. */
14
+ function useFixture(id: string) {
15
+ formatConfig({
16
+ server: { port: 4000 },
17
+ tenants: [{ id, dir: DIRS[id]!, database: { uri: "mongodb://localhost:27017/_LC_TEST" } }],
18
+ });
19
+ }
20
+
21
+ const callsOf = (key: string): string[] => ((globalThis as any)[key] ??= []);
22
+
23
+ beforeEach(() => {
24
+ (globalThis as any).__lcNormal = [];
25
+ (globalThis as any).__lcHang = [];
26
+ (globalThis as any).__lcDisabled = [];
27
+ });
28
+
29
+ describe("define.Lifecycle", () => {
30
+ it("marks the config and defaults enabled to true", () => {
31
+ const lc = define.Lifecycle({ beforeBoot: () => {} });
32
+ expect(lc._isLifecycle_).toBe(true);
33
+ expect(lc.enabled).toBe(true);
34
+ });
35
+
36
+ it("keeps an explicit enabled: false", () => {
37
+ const lc = define.Lifecycle({ enabled: false });
38
+ expect(lc.enabled).toBe(false);
39
+ });
40
+ });
41
+
42
+ describe("lifecycle hooks", () => {
43
+ it("runs beforeBoot, afterBoot and onDestroy in order", async () => {
44
+ useFixture("normal");
45
+ await loadLifecycles();
46
+
47
+ await runBeforeBoot();
48
+ await runAfterBoot({ port: 4000 } as any, {} as any);
49
+ await runOnDestroy("SIGTERM");
50
+
51
+ expect(callsOf("__lcNormal")).toEqual(["beforeBoot", "afterBoot", "onDestroy:SIGTERM"]);
52
+ // `replication` is tenant-bound in the hook context (like `rest`)
53
+ expect((globalThis as any).__lcReplicationApi).toBe(true);
54
+ });
55
+
56
+ it("aborts the boot when beforeBoot throws (fail-fast)", async () => {
57
+ useFixture("thrower");
58
+ await loadLifecycles();
59
+
60
+ await expect(runBeforeBoot()).rejects.toThrow(/boom/);
61
+ });
62
+
63
+ it("bounds onDestroy with destroyTimeout instead of hanging", async () => {
64
+ useFixture("hang");
65
+ await loadLifecycles();
66
+
67
+ const started = Date.now();
68
+ await runOnDestroy("SIGINT"); // must resolve despite a hook that never resolves
69
+ const elapsed = Date.now() - started;
70
+
71
+ expect(callsOf("__lcHang")).toEqual(["onDestroy:start"]);
72
+ expect(elapsed).toBeGreaterThanOrEqual(100);
73
+ expect(elapsed).toBeLessThan(2000);
74
+ });
75
+
76
+ it("skips a disabled lifecycle", async () => {
77
+ useFixture("disabled");
78
+ await loadLifecycles();
79
+
80
+ await runBeforeBoot();
81
+ await runAfterBoot({} as any, {} as any);
82
+ await runOnDestroy("SIGINT");
83
+
84
+ expect(callsOf("__lcDisabled")).toEqual([]);
85
+ });
86
+
87
+ it("tolerates a tenant without a lifecycle.ts file", async () => {
88
+ formatConfig({
89
+ server: { port: 4000 },
90
+ tenants: [{ id: "nofile", dir: "packages/core/tests/fixtures/does-not-exist", database: { uri: "mongodb://localhost:27017/_LC_TEST" } }],
91
+ });
92
+ await loadLifecycles();
93
+ await expect(runBeforeBoot()).resolves.toBeUndefined();
94
+ await expect(runOnDestroy("SIGTERM")).resolves.toBeUndefined();
95
+ });
96
+ });
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { MongoClient } 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
+ resetReplication,
11
+ seedReplication,
12
+ } from "../database/replication";
13
+
14
+ const TEST_TENANT = "replreset";
15
+ const SRC_DB = "mongodb://localhost:27017/_REPL_RESET_SRC";
16
+ const DEST_DB = "mongodb://localhost:27017/_REPL_RESET_DEST";
17
+ const DEST_ID = "backup";
18
+
19
+ let rest: InstanceType<typeof useRest>;
20
+ let sourceClient: MongoClient;
21
+ let destClient: MongoClient;
22
+
23
+ beforeAll(async () => {
24
+ formatConfig({
25
+ server: { port: 4000 },
26
+ tenants: [{
27
+ id: TEST_TENANT,
28
+ dir: "src",
29
+ database: { uri: SRC_DB },
30
+ replication: {
31
+ runOnBoot: false,
32
+ schedule: { interval: "1h" },
33
+ destinations: [{ id: DEST_ID, uri: DEST_DB }],
34
+ },
35
+ }],
36
+ });
37
+
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: [{ name: "title", type: "string" }],
49
+ replication: { enabled: true },
50
+ },
51
+ ];
52
+
53
+ await startReplication();
54
+
55
+ rest = new useRest({ tenant_id: TEST_TENANT });
56
+ destClient = new MongoClient(DEST_DB, { serverSelectionTimeoutMS: 3000 });
57
+ await destClient.connect();
58
+ });
59
+
60
+ afterAll(async () => {
61
+ await stopReplication();
62
+ try { await sourceClient.db().dropDatabase(); } catch (_) {}
63
+ try { await destClient.db().dropDatabase(); } catch (_) {}
64
+ try { await destClient.close(); } catch (_) {}
65
+ try { await sourceClient.close(); } catch (_) {}
66
+ });
67
+
68
+ describe("replication reset", () => {
69
+ it("clears state and re-runs from scratch", async () => {
70
+ await rest.insertOne("orders", { title: `reset-${crypto.randomUUID()}` });
71
+ await replicateTenant(TEST_TENANT);
72
+
73
+ const before = await getReplicationState(TEST_TENANT, DEST_ID, "orders");
74
+ expect(before.length).toBe(1);
75
+ expect(before[0]!.status).toBe("success");
76
+
77
+ const result = await resetReplication(TEST_TENANT);
78
+ expect(result.state).toBeGreaterThanOrEqual(1);
79
+
80
+ const after = await getReplicationState(TEST_TENANT, DEST_ID, "orders");
81
+ expect(after.length).toBe(0);
82
+
83
+ // Next run re-creates the state (fresh cursor)
84
+ await replicateTenant(TEST_TENANT);
85
+ const recreated = await getReplicationState(TEST_TENANT, DEST_ID, "orders");
86
+ expect(recreated.length).toBe(1);
87
+ expect(recreated[0]!.cursor.value).not.toBeNull();
88
+ });
89
+
90
+ it("supports scoping by destination", async () => {
91
+ await resetReplication(TEST_TENANT, { destination: DEST_ID });
92
+ const state = await getReplicationState(TEST_TENANT, DEST_ID);
93
+ expect(state.length).toBe(0);
94
+ });
95
+ });
96
+
97
+ describe("replication seed", () => {
98
+ const findDest = (title: string) => destClient.db().collection("orders").findOne({ title });
99
+
100
+ it("seeds 'latest' so pre-existing documents are skipped", async () => {
101
+ await resetReplication(TEST_TENANT);
102
+ const preMarker = `pre-${crypto.randomUUID()}`;
103
+ await rest.insertOne("orders", { title: preMarker });
104
+
105
+ const res = await seedReplication(TEST_TENANT, "latest");
106
+ expect(res.seeded).toBe(1);
107
+
108
+ await replicateTenant(TEST_TENANT);
109
+ expect(await findDest(preMarker)).toBeNull(); // nothing backfilled
110
+
111
+ await Bun.sleep(5);
112
+ const postMarker = `post-${crypto.randomUUID()}`;
113
+ await rest.insertOne("orders", { title: postMarker });
114
+ await replicateTenant(TEST_TENANT);
115
+
116
+ expect(await findDest(postMarker)).toBeDefined(); // only the new document
117
+ });
118
+
119
+ it("seeds from an explicit date", async () => {
120
+ await resetReplication(TEST_TENANT);
121
+ const res = await seedReplication(TEST_TENANT, "2020-01-01T00:00:00Z");
122
+ expect(res.seeded).toBe(1);
123
+
124
+ const state = await getReplicationState(TEST_TENANT, DEST_ID, "orders");
125
+ expect(state.length).toBe(1);
126
+ expect((state[0]!.cursor.value as any).toISOString()).toBe("2020-01-01T00:00:00.000Z");
127
+ });
128
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { MongoClient } from "mongodb";
3
+ import { useRest } from "../database/rest";
4
+ import { formatConfig, cfg } from "../server/config";
5
+ import { startReplication, stopReplication, replicateTenant } from "../database/replication";
6
+
7
+ const TEST_TENANT = "replinit";
8
+ const SRC_DB = "mongodb://localhost:27017/_REPL_INIT_SRC";
9
+ const DEST_DB = "mongodb://localhost:27017/_REPL_INIT_DEST";
10
+ const DEST_ID = "backup";
11
+
12
+ let rest: InstanceType<typeof useRest>;
13
+ let sourceClient: MongoClient;
14
+ let destClient: MongoClient;
15
+
16
+ beforeAll(async () => {
17
+ formatConfig({
18
+ server: { port: 4000 },
19
+ tenants: [{
20
+ id: TEST_TENANT,
21
+ dir: "src",
22
+ database: { uri: SRC_DB },
23
+ replication: {
24
+ runOnBoot: false,
25
+ schedule: { interval: "1h" },
26
+ destinations: [{ id: DEST_ID, uri: DEST_DB }],
27
+ },
28
+ }],
29
+ });
30
+
31
+ sourceClient = new MongoClient(SRC_DB, { serverSelectionTimeoutMS: 3000 });
32
+ await sourceClient.connect();
33
+ const tenant: any = cfg.tenants?.[0];
34
+ tenant.database.client = sourceClient;
35
+ tenant.database.db = sourceClient.db();
36
+
37
+ (cfg as any).collections = [
38
+ {
39
+ _tenant_: TEST_TENANT,
40
+ slug: "orders",
41
+ fields: [
42
+ { name: "title", type: "string" },
43
+ ],
44
+ // Start at the latest document — skip everything that already exists.
45
+ replication: { enabled: true, initialSync: "latest" },
46
+ },
47
+ ];
48
+
49
+ await startReplication();
50
+
51
+ rest = new useRest({ tenant_id: TEST_TENANT });
52
+ destClient = new MongoClient(DEST_DB, { serverSelectionTimeoutMS: 3000 });
53
+ await destClient.connect();
54
+ });
55
+
56
+ afterAll(async () => {
57
+ await stopReplication();
58
+ try { await sourceClient.db().dropDatabase(); } catch (_) {}
59
+ try { await destClient.db().dropDatabase(); } catch (_) {}
60
+ try { await destClient.close(); } catch (_) {}
61
+ try { await sourceClient.close(); } catch (_) {}
62
+ });
63
+
64
+ describe("replication initialSync", () => {
65
+ it("skips pre-existing documents with initialSync: 'latest'", async () => {
66
+ await rest.insertOne("orders", { title: `old-1-${crypto.randomUUID()}` });
67
+ await rest.insertOne("orders", { title: `old-2-${crypto.randomUUID()}` });
68
+
69
+ await replicateTenant(TEST_TENANT);
70
+
71
+ const count = await destClient.db().collection("orders").countDocuments();
72
+ expect(count).toBe(0); // existing docs are NOT backfilled
73
+ });
74
+
75
+ it("replicates documents added after the initial sync", async () => {
76
+ await Bun.sleep(5); // ensure the new doc has a strictly newer updatedAt
77
+
78
+ const marker = `new-${crypto.randomUUID()}`;
79
+ await rest.insertOne("orders", { title: marker });
80
+
81
+ await replicateTenant(TEST_TENANT);
82
+
83
+ const dest = await destClient.db().collection("orders").findOne({ title: marker });
84
+ expect(dest).toBeDefined();
85
+ });
86
+ });