@anteros/core 0.0.40 → 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 +3 -3
- package/database/deleteLog.ts +34 -0
- 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 +109 -25
- 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/db-cache.test.ts +7 -7
- package/tests/file-collection-replication.test.ts +98 -0
- 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/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 +102 -1
- 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,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
|
+
});
|
|
@@ -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
|
+
});
|