@anteros/core 0.0.40 → 0.0.42

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 (130) hide show
  1. package/database/audit.ts +143 -0
  2. package/database/collection.ts +6 -4
  3. package/database/deleteLog.ts +98 -0
  4. package/database/file.ts +7 -6
  5. package/database/mongodbadapter.ts +420 -185
  6. package/database/replication.ts +858 -0
  7. package/database/schema.ts +140 -125
  8. package/database/tenant.ts +4 -0
  9. package/database/ttl.ts +68 -0
  10. package/database/vars.ts +405 -0
  11. package/database/workflow.ts +466 -240
  12. package/index.ts +16 -0
  13. package/lib/agent.ts +1327 -0
  14. package/lib/agentMemory.ts +586 -0
  15. package/lib/agents.ts +157 -0
  16. package/lib/ai.ts +0 -0
  17. package/lib/api.ts +246 -0
  18. package/lib/attachment.ts +351 -0
  19. package/lib/audit.ts +271 -0
  20. package/lib/auditResult.ts +133 -0
  21. package/lib/define.ts +62 -1
  22. package/lib/endpoints.ts +80 -0
  23. package/lib/files.ts +66 -12
  24. package/lib/jsonSchema.ts +183 -0
  25. package/lib/lifecycle.ts +147 -0
  26. package/lib/load.ts +17 -0
  27. package/lib/mcp.ts +3 -2
  28. package/lib/messages.ts +299 -0
  29. package/lib/providers.ts +867 -0
  30. package/lib/redact.ts +117 -0
  31. package/lib/routes.ts +14 -1
  32. package/lib/scriptWorker.ts +4 -1
  33. package/lib/scripts.ts +6 -1
  34. package/lib/sockets.ts +3 -1
  35. package/lib/workflow.ts +209 -37
  36. package/lib/zip.ts +109 -0
  37. package/package.json +1 -1
  38. package/server/access.ts +96 -0
  39. package/server/agents.ts +451 -0
  40. package/server/api.ts +121 -95
  41. package/server/boot.ts +196 -53
  42. package/server/flags.ts +136 -0
  43. package/server/hono.ts +9 -4
  44. package/server/mcp.ts +17 -63
  45. package/server/routes.ts +19 -24
  46. package/tests/agent-memory.test.ts +617 -0
  47. package/tests/agent-working-memory.test.ts +402 -0
  48. package/tests/agent.test.ts +1239 -0
  49. package/tests/agents-api.test.ts +593 -0
  50. package/tests/api.test.ts +51 -0
  51. package/tests/attachment.test.ts +241 -0
  52. package/tests/audit-file.test.ts +296 -0
  53. package/tests/audit-operations.test.ts +184 -0
  54. package/tests/audit-redact.test.ts +259 -0
  55. package/tests/audit-results.test.ts +188 -0
  56. package/tests/audit-retention-config.test.ts +76 -0
  57. package/tests/audit-run-action.test.ts +120 -0
  58. package/tests/audit.test.ts +320 -0
  59. package/tests/db-cache.test.ts +7 -7
  60. package/tests/endpoints.test.ts +80 -0
  61. package/tests/file-collection-replication.test.ts +98 -0
  62. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  63. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  64. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  65. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  66. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  67. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  68. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  69. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  70. package/tests/fixtures/attachment/data.csv +2 -0
  71. package/tests/fixtures/fake-provider.ts +128 -0
  72. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  73. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  74. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  75. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  76. package/tests/fixtures/repl-only/app.ts +26 -0
  77. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  78. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  79. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  80. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  81. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  82. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  83. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  84. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  85. package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
  86. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  87. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  88. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  89. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  90. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  91. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  92. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  93. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  94. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  95. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  96. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  97. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  98. package/tests/hono.test.ts +6 -3
  99. package/tests/lifecycle.test.ts +96 -0
  100. package/tests/locks.test.ts +120 -0
  101. package/tests/mcp.test.ts +11 -11
  102. package/tests/messages.test.ts +258 -0
  103. package/tests/replication-control.test.ts +128 -0
  104. package/tests/replication-initial-sync.test.ts +86 -0
  105. package/tests/replication-meta.test.ts +243 -0
  106. package/tests/replication-only.test.ts +358 -0
  107. package/tests/replication.test.ts +188 -0
  108. package/tests/upload.test.ts +141 -8
  109. package/tests/vars-api.test.ts +123 -0
  110. package/tests/vars-replication.test.ts +96 -0
  111. package/tests/vars.test.ts +216 -0
  112. package/tests/workflow-engine.test.ts +340 -0
  113. package/tests/workflow-retention.test.ts +103 -0
  114. package/tests/workflow.test.ts +186 -0
  115. package/types/agent.d.ts +837 -0
  116. package/types/collection.d.ts +3 -0
  117. package/types/config.d.ts +80 -0
  118. package/types/file.d.ts +11 -8
  119. package/types/hook.d.ts +6 -0
  120. package/types/lifecycle.d.ts +77 -0
  121. package/types/mcp.d.ts +11 -1
  122. package/types/replication.d.ts +228 -0
  123. package/types/route.d.ts +7 -1
  124. package/types/scripts.d.ts +6 -0
  125. package/types/service.d.ts +7 -1
  126. package/types/tenant.d.ts +36 -0
  127. package/types/vars.d.ts +147 -0
  128. package/types/websocket.d.ts +6 -0
  129. package/types/workflow.d.ts +50 -3
  130. package/utils/func.ts +67 -52
@@ -0,0 +1,243 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import os from "node:os";
3
+ import { MongoClient } from "mongodb";
4
+ import { useRest } from "../database/rest";
5
+ import { formatConfig, cfg } from "../server/config";
6
+ import { syncTenants } from "../database/tenant";
7
+ import { syncCollections } from "../database/collection";
8
+ import { syncWorkflows } from "../lib/workflow";
9
+ import { startReplication, stopReplication, replicateTenant } from "../database/replication";
10
+ import { syncAgents } from "../lib/agents";
11
+ import { MongoAgentMemory } from "../lib/agentMemory";
12
+ import { AGENT_THREADS } from "./fixtures/replication-meta/agents/chat.agent";
13
+ import type { ReplicationMetaName } from "../types/replication";
14
+
15
+ const DIR = "packages/core/tests/fixtures/replication-meta";
16
+
17
+ /** Everything replicated: declared collection + the six framework collections */
18
+ const ALL_T = "rm-default";
19
+ const ALL_T_DB = "mongodb://localhost:27017/_RM_DEFAULT_SRC";
20
+ const ALL_T_DEST = "mongodb://localhost:27017/_RM_DEFAULT_DEST";
21
+
22
+ /** Nothing but the declared collection */
23
+ const OFF_T = "rm-alloff";
24
+ const OFF_T_DB = "mongodb://localhost:27017/_RM_ALLOFF_SRC";
25
+ const OFF_T_DEST = "mongodb://localhost:27017/_RM_ALLOFF_DEST";
26
+
27
+ let rest: InstanceType<typeof useRest>;
28
+ let dest: MongoClient;
29
+ let destOff: MongoClient;
30
+
31
+ const target = (slug: string) => dest.db().collection(slug);
32
+ const targetOff = (slug: string) => destOff.db().collection(slug);
33
+
34
+ beforeAll(async () => {
35
+ formatConfig({
36
+ server: { port: 4000 },
37
+ tenants: [
38
+ {
39
+ id: ALL_T,
40
+ dir: DIR,
41
+ database: { uri: ALL_T_DB },
42
+ // Opt-in retention, so the destination's copy is pruned the same way
43
+ audit: { retention: "30d" },
44
+ replication: {
45
+ runOnBoot: false,
46
+ schedule: { interval: "1h" },
47
+ destinations: [{ id: "backup", uri: ALL_T_DEST }],
48
+ },
49
+ },
50
+ {
51
+ id: OFF_T,
52
+ dir: DIR,
53
+ database: { uri: OFF_T_DB },
54
+ replication: {
55
+ runOnBoot: false,
56
+ schedule: { interval: "1h" },
57
+ exclude: ["audit", "workflows", "locks", "replication", "vars", "memory"],
58
+ destinations: [{ id: "backup", uri: OFF_T_DEST }],
59
+ },
60
+ },
61
+ ],
62
+ });
63
+
64
+ await syncTenants();
65
+ await syncCollections();
66
+ await syncWorkflows();
67
+ await syncAgents(); // publishes the agent memory collections (`cfg.agentMemories`)
68
+ await startReplication();
69
+
70
+ rest = new useRest({ tenant_id: ALL_T });
71
+ dest = new MongoClient(ALL_T_DEST, { serverSelectionTimeoutMS: 3000 });
72
+ destOff = new MongoClient(OFF_T_DEST, { serverSelectionTimeoutMS: 3000 });
73
+ await dest.connect();
74
+ await destOff.connect();
75
+ });
76
+
77
+ afterAll(async () => {
78
+ await stopReplication();
79
+ for (const uri of [ALL_T_DB, ALL_T_DEST, OFF_T_DB, OFF_T_DEST]) {
80
+ try {
81
+ const client = new MongoClient(uri);
82
+ await client.connect();
83
+ await client.db().dropDatabase();
84
+ await client.close();
85
+ } catch (_) {}
86
+ }
87
+ cfg.agents = [];
88
+ cfg.agentMemories = [];
89
+ try { await dest.close(); } catch (_) {}
90
+ try { await destOff.close(); } catch (_) {}
91
+ });
92
+
93
+ describe("replication — framework collections", () => {
94
+ it("replicates them by default (audit, workflows, locks, replication, vars, memory)", async () => {
95
+ const marker = `meta-${crypto.randomUUID()}`;
96
+
97
+ await rest.audit.addActivities([{
98
+ internal: true,
99
+ trace: { id: crypto.randomUUID() },
100
+ meta: {},
101
+ operation: {
102
+ tenant: ALL_T, action: marker, collection: "orders",
103
+ status: "success", input: null, result: null, error: null, duration: 0, transaction: false,
104
+ },
105
+ ts: new Date(),
106
+ } as any]);
107
+ await rest.workflow.run("run", { marker });
108
+ await rest.vars.set("config", marker, "META-1");
109
+ await rest.lock(`meta-${marker}`);
110
+ await rest.insertOne("orders", { title: marker });
111
+ // An agent conversation — the collection this tenant named for its memory
112
+ await new MongoAgentMemory({ collection: AGENT_THREADS }).save(
113
+ "chat-1",
114
+ [{ role: "user", content: marker }] as any,
115
+ { rest, tenant: ALL_T, resourceId: "u1" },
116
+ );
117
+
118
+ // A first run picks the state document it writes, hence the second one
119
+ await replicateTenant(ALL_T);
120
+ await replicateTenant(ALL_T);
121
+
122
+ expect(await target("_audit_").findOne({ "operation.action": marker })).toBeDefined();
123
+ expect(await target("_workflows_").findOne({ workflowId: "run" })).toBeDefined();
124
+ expect(await target("_vars_").findOne({ ns: "config", key: marker })).toBeDefined();
125
+ expect(await target("_locks_").findOne({ name: `meta-${marker}` })).toBeDefined();
126
+ expect(await target("_replication_").findOne({ type: "state", tenant: ALL_T })).toBeDefined();
127
+ expect(await target(AGENT_THREADS).findOne({ _id: "u1:chat-1" as any })).toBeDefined();
128
+ expect(await target("orders").findOne({ title: marker })).toBeDefined();
129
+
130
+ await rest.unlock(`meta-${marker}`);
131
+ }, 30_000);
132
+
133
+ it("propagates a cleared thread to the destination (tombstone)", async () => {
134
+ // The thread replicated by the previous test is deleted at the source: no
135
+ // date cursor can see a deletion, so the store leaves a tombstone.
136
+ await new MongoAgentMemory({ collection: AGENT_THREADS }).clear("chat-1", { rest, tenant: ALL_T, resourceId: "u1" });
137
+
138
+ await replicateTenant(ALL_T);
139
+
140
+ expect(await target(AGENT_THREADS).findOne({ _id: "u1:chat-1" as any })).toBeNull();
141
+ // The tombstone is consumed and purged
142
+ expect(await rest.db.collection("_replication_").countDocuments({ type: "delete", collection: AGENT_THREADS })).toBe(0);
143
+ }, 30_000);
144
+
145
+ it("uses each collection's own date key (audit is read on `ts`)", async () => {
146
+ const state: any = await rest.db.collection("_replication_")
147
+ .findOne({ type: "state", tenant: ALL_T, collection: "_audit_" });
148
+ expect(state).toBeDefined();
149
+ expect(state.key).toBe("ts");
150
+ // The state says which process is doing the work (`reusePort` / forked workers)
151
+ expect(state.pid).toBe(process.pid);
152
+ expect(state.hostname).toBe(os.hostname());
153
+ });
154
+
155
+ it("mirrors the audit retention on the destination", async () => {
156
+ const ttl: any = (await target("_audit_").listIndexes().toArray())
157
+ .find((i: any) => i.name === "_audit_ttl_");
158
+ expect(ttl).toBeDefined();
159
+ expect(ttl.expireAfterSeconds).toBe(30 * 86400);
160
+ }, 30_000);
161
+
162
+ it("leaves out only what `exclude` names", async () => {
163
+ const tenant: any = cfg.tenants?.find((t) => t.id === ALL_T);
164
+ const marker = `noaudit-${crypto.randomUUID()}`;
165
+ tenant.replication.exclude = ["audit", "memory"];
166
+
167
+ await rest.audit.addActivities([{
168
+ internal: true, trace: { id: crypto.randomUUID() }, meta: {},
169
+ operation: {
170
+ tenant: ALL_T, action: marker, collection: "orders",
171
+ status: "success", input: null, result: null, error: null, duration: 0, transaction: false,
172
+ },
173
+ ts: new Date(),
174
+ } as any]);
175
+ await rest.vars.set("config", marker, "still-replicated");
176
+ await new MongoAgentMemory({ collection: AGENT_THREADS }).save(
177
+ "chat-2",
178
+ [{ role: "user", content: marker }] as any,
179
+ { rest, tenant: ALL_T, resourceId: "u1" },
180
+ );
181
+
182
+ await replicateTenant(ALL_T);
183
+
184
+ expect(await target("_audit_").findOne({ "operation.action": marker })).toBeNull(); // excluded
185
+ expect(await target(AGENT_THREADS).findOne({ _id: "u1:chat-2" as any })).toBeNull(); // excluded
186
+ expect(await target("_vars_").findOne({ ns: "config", key: marker })).toBeDefined(); // still there
187
+
188
+ delete tenant.replication.exclude;
189
+ }, 30_000);
190
+
191
+ it("replicates nothing from the framework when every name is excluded", async () => {
192
+ const offRest = new useRest({ tenant_id: OFF_T });
193
+ const marker = `alloff-${crypto.randomUUID()}`;
194
+
195
+ await offRest.audit.addActivities([{
196
+ internal: true, trace: { id: crypto.randomUUID() }, meta: {},
197
+ operation: {
198
+ tenant: OFF_T, action: marker, collection: "orders",
199
+ status: "success", input: null, result: null, error: null, duration: 0, transaction: false,
200
+ },
201
+ ts: new Date(),
202
+ } as any]);
203
+ await offRest.vars.set("config", marker, "not-replicated");
204
+ await offRest.insertOne("orders", { title: marker });
205
+ await new MongoAgentMemory({ collection: AGENT_THREADS }).save(
206
+ "chat-3",
207
+ [{ role: "user", content: marker }] as any,
208
+ { rest: offRest, tenant: OFF_T, resourceId: "u1" },
209
+ );
210
+
211
+ await replicateTenant(OFF_T);
212
+ await replicateTenant(OFF_T);
213
+
214
+ // The declared collection still replicates…
215
+ expect(await targetOff("orders").findOne({ title: marker })).toBeDefined();
216
+ // …but the framework collections do not exist on that destination
217
+ expect(await targetOff("_audit_").countDocuments({})).toBe(0);
218
+ expect(await targetOff("_vars_").countDocuments({})).toBe(0);
219
+ expect(await targetOff("_workflows_").countDocuments({})).toBe(0);
220
+ expect(await targetOff(AGENT_THREADS).countDocuments({})).toBe(0);
221
+ }, 30_000);
222
+
223
+ it("propagates a delete on a collection whose key is a number", async () => {
224
+ const doc: any = await rest.insertOne("counters", { seq: 100, title: "numeric-key" });
225
+ await replicateTenant(ALL_T);
226
+ expect(await target("counters").findOne({ title: "numeric-key" })).toBeDefined();
227
+
228
+ await rest.deleteOne("counters", doc._id);
229
+ await replicateTenant(ALL_T);
230
+ // The tombstone guard compares the key to a date by default: a numeric key
231
+ // used to silently skip the delete
232
+ expect(await target("counters").findOne({ title: "numeric-key" })).toBeNull();
233
+ }, 30_000);
234
+
235
+ it("keeps the `exclude` list typed", () => {
236
+ const names: ReplicationMetaName[] = ["audit", "workflows", "locks", "replication", "vars", "memory"];
237
+ expect(names.length).toBe(6);
238
+
239
+ // @ts-expect-error — a declared collection slug is not a framework collection
240
+ const wrong: ReplicationMetaName[] = ["orders"];
241
+ expect(wrong.length).toBe(1);
242
+ });
243
+ });
@@ -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
+ });