@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,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
|
|
5
|
-
//
|
|
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,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,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
|
+
};
|
package/tests/index-spec.test.ts
CHANGED
|
@@ -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
|
|
11
|
-
//
|
|
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
|
|
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 —
|
|
47
|
+
expect(nameIdx!.sparse).not.toBe(true); // false / undefined — never null
|
|
48
48
|
|
|
49
|
-
// sku
|
|
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
|
+
});
|