@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,258 @@
1
+ import { describe, it, expect, afterAll } from "bun:test";
2
+ import { normalizeMessages } from "../lib/messages";
3
+ import { MAX_FILE_BYTES } from "../lib/attachment";
4
+
5
+ /** A tiny local file server — the `file: { url }` path downloads through `fetch`. */
6
+ const servers: Array<ReturnType<typeof Bun.serve>> = [];
7
+ function serve(body: Uint8Array | string, contentType = "application/pdf") {
8
+ const server = Bun.serve({
9
+ port: 0,
10
+ hostname: "127.0.0.1",
11
+ fetch: () => new Response(body as any, { headers: { "Content-Type": contentType } }),
12
+ });
13
+ servers.push(server);
14
+ return `http://127.0.0.1:${server.port}/report.pdf`;
15
+ }
16
+
17
+ afterAll(() => {
18
+ for (const server of servers) server.stop(true);
19
+ });
20
+
21
+ const first = async (input: any) => (await normalizeMessages(input))[0] as any;
22
+
23
+ describe("normalizeMessages — the shapes it accepts", () => {
24
+ it("turns a string into one user turn", async () => {
25
+ expect(await normalizeMessages("hi")).toEqual([{ role: "user", content: "hi" }]);
26
+ });
27
+
28
+ it("accepts a single message — with or without an array", async () => {
29
+ const message = { role: "user", content: "hi" } as const;
30
+ expect(await normalizeMessages(message)).toEqual([{ role: "user", content: "hi" }]);
31
+ expect(await normalizeMessages([message])).toEqual([{ role: "user", content: "hi" }]);
32
+ });
33
+
34
+ it("keeps a whole conversation in order", async () => {
35
+ const messages = await normalizeMessages([
36
+ { role: "user", content: "hi" },
37
+ { role: "assistant", content: "hello" },
38
+ { role: "user", content: "and?" },
39
+ ]);
40
+ expect(messages.map((message) => message.role)).toEqual(["user", "assistant", "user"]);
41
+ expect((messages[1] as any).content).toBe("hello");
42
+ });
43
+
44
+ it("keeps only the canonical fields — a stray key never reaches the wire", async () => {
45
+ const [message] = await normalizeMessages([{ role: "user", content: "hi", threadId: "t", bogus: 1 } as any]);
46
+ expect(message).toEqual({ role: "user", content: "hi" });
47
+ });
48
+ });
49
+
50
+ describe("normalizeMessages — content parts", () => {
51
+ it("keeps text, image and file parts, url or base64", async () => {
52
+ const [message] = await normalizeMessages([{
53
+ role: "user",
54
+ content: [
55
+ { type: "text", text: "look" },
56
+ { type: "image", url: "https://example.com/a.png" },
57
+ { type: "image", data: "AAAA", mimeType: "image/png" },
58
+ { type: "file", name: "a.pdf", data: "BBBB", mimeType: "application/pdf" },
59
+ ],
60
+ }] as any);
61
+
62
+ expect((message as any).content).toEqual([
63
+ { type: "text", text: "look" },
64
+ { type: "image", url: "https://example.com/a.png" },
65
+ { type: "image", mimeType: "image/png", data: "AAAA" },
66
+ { type: "file", name: "a.pdf", mimeType: "application/pdf", data: "BBBB" },
67
+ ]);
68
+ });
69
+
70
+ it("joins a system message written as parts", async () => {
71
+ const [message] = await normalizeMessages([
72
+ { role: "system", content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] },
73
+ ] as any);
74
+ expect(message).toEqual({ role: "system", content: "a\nb" });
75
+ });
76
+
77
+ it("downloads a remote file and inlines it as base64", async () => {
78
+ const url = serve("%PDF-1.7 payload");
79
+ const [message] = await normalizeMessages([
80
+ { role: "user", content: [{ type: "file", url, name: "invoice.pdf" }] },
81
+ ] as any);
82
+
83
+ const part: any = (message as any).content[0];
84
+ expect(part.type).toBe("file");
85
+ expect(part.name).toBe("invoice.pdf");
86
+ // The URL is gone — the payload travels as base64, which every provider takes
87
+ expect(part.url).toBeUndefined();
88
+ expect(Buffer.from(part.data, "base64").toString()).toBe("%PDF-1.7 payload");
89
+ });
90
+
91
+ it("leaves a remote image as a URL — the provider fetches it", async () => {
92
+ const message: any = await first([{ role: "user", content: [{ type: "image", url: "https://example.com/a.png" }] }]);
93
+ expect(message.content[0].url).toBe("https://example.com/a.png");
94
+ expect(message.content[0].data).toBeUndefined();
95
+ });
96
+
97
+ it("refuses a part without a payload, an unknown address, or an unsupported scheme", async () => {
98
+ const cases = [
99
+ [{ type: "image" }],
100
+ [{ type: "file", name: "a.pdf" }],
101
+ [{ type: "image", url: "ftp://example.com/a.png" }],
102
+ [{ type: "image", url: "./local.png" }],
103
+ ];
104
+ for (const content of cases) {
105
+ const error: any = await normalizeMessages([{ role: "user", content }] as any).catch((err) => err);
106
+ expect(error.code).toBe("INVALID_INPUT");
107
+ }
108
+ });
109
+
110
+ it("refuses a file that is too large", async () => {
111
+ const url = serve(new Uint8Array(MAX_FILE_BYTES + 1));
112
+ const error: any = await normalizeMessages([
113
+ { role: "user", content: [{ type: "file", url, name: "huge.pdf" }] },
114
+ ] as any).catch((err) => err);
115
+ expect(error.code).toBe("AGENT_FILE_TOO_LARGE");
116
+ });
117
+ });
118
+
119
+ describe("normalizeMessages — tool parts", () => {
120
+ it("hoists `tool-call` parts onto the assistant message", async () => {
121
+ const [message] = await normalizeMessages([{
122
+ role: "assistant",
123
+ content: [{ type: "tool-call", id: "call_1", name: "forecast", arguments: { city: "Paris" } }],
124
+ }] as any);
125
+
126
+ expect(message).toEqual({
127
+ role: "assistant",
128
+ content: null,
129
+ toolCalls: [{ id: "call_1", name: "forecast", args: { city: "Paris" } }],
130
+ });
131
+ });
132
+
133
+ it("merges inline calls with the ones declared as `toolCalls`", async () => {
134
+ const [message] = await normalizeMessages([{
135
+ role: "assistant",
136
+ content: [{ type: "text", text: "let me check" }, { type: "tool-call", id: "b", name: "two" }],
137
+ toolCalls: [{ id: "a", name: "one", args: {} }],
138
+ }] as any) as any[];
139
+
140
+ expect(message.content).toBe("let me check");
141
+ expect(message.toolCalls.map((call: any) => call.id)).toEqual(["a", "b"]);
142
+ });
143
+
144
+ it("keeps a raw `argsText` when the caller had one", async () => {
145
+ const [message] = await normalizeMessages([{
146
+ role: "assistant",
147
+ content: [{ type: "tool-call", id: "c", name: "f", argsText: '{"city":"Lyon"}', arguments: { city: "Lyon" } }],
148
+ }] as any) as any[];
149
+ expect(message.toolCalls[0]).toEqual({ id: "c", name: "f", args: { city: "Lyon" }, argsText: '{"city":"Lyon"}' });
150
+ });
151
+
152
+ it("turns a `tool-result` part into the `tool` message it means", async () => {
153
+ const messages = await normalizeMessages([{
154
+ role: "user",
155
+ content: [
156
+ { type: "text", text: "and?" },
157
+ { type: "tool-result", id: "call_1", name: "forecast", result: { celsius: 21 } },
158
+ { type: "tool-result", id: "call_2", name: "boom", error: "timeout" },
159
+ ],
160
+ }] as any) as any[];
161
+
162
+ expect(messages.map((message) => message.role)).toEqual(["user", "tool", "tool"]);
163
+ expect(messages[0].content).toEqual([{ type: "text", text: "and?" }]);
164
+ expect(messages[1]).toEqual({
165
+ role: "tool", toolCallId: "call_1", name: "forecast", content: '{"celsius":21}',
166
+ });
167
+ expect(messages[2]).toEqual({
168
+ role: "tool", toolCallId: "call_2", name: "boom", content: "Error: timeout", isError: true,
169
+ });
170
+ });
171
+
172
+ it("expands a `tool` message carrying several results", async () => {
173
+ const messages = await normalizeMessages([{
174
+ role: "tool",
175
+ content: [
176
+ { type: "tool-result", id: "a", result: "one" },
177
+ { type: "tool-result", id: "b", result: "two" },
178
+ ],
179
+ }] as any) as any[];
180
+
181
+ expect(messages).toEqual([
182
+ { role: "tool", toolCallId: "a", name: "tool", content: "one" },
183
+ { role: "tool", toolCallId: "b", name: "tool", content: "two" },
184
+ ]);
185
+ });
186
+
187
+ it("keeps a `tool` message written the classic way", async () => {
188
+ const [message] = await normalizeMessages([
189
+ { role: "tool", toolCallId: "call_1", name: "forecast", content: "21°C" },
190
+ ] as any);
191
+ expect(message).toEqual({ role: "tool", toolCallId: "call_1", name: "forecast", content: "21°C" });
192
+ });
193
+ });
194
+
195
+ describe("normalizeMessages — what it refuses", () => {
196
+ it("refuses an unknown role and a message without one", async () => {
197
+ for (const input of [[{ role: "robot", content: "hi" }], [{ content: "hi" }], [null]]) {
198
+ const error: any = await normalizeMessages(input as any).catch((err) => err);
199
+ expect(error.code).toBe("INVALID_INPUT");
200
+ expect(error.status).toBe(400);
201
+ }
202
+ });
203
+
204
+ it("refuses a part the role cannot carry", async () => {
205
+ const cases: any[] = [
206
+ [{ role: "user", content: [{ type: "tool-call", id: "a", name: "f" }] }],
207
+ [{ role: "assistant", content: [{ type: "image", url: "https://example.com/a.png" }] }],
208
+ [{ role: "system", content: [{ type: "image", url: "https://example.com/a.png" }] }],
209
+ [{ role: "tool", content: [{ type: "text", text: "no" }] }],
210
+ [{ role: "tool", content: "orphan" }], // no `toolCallId`
211
+ [{ role: "user", content: [{ type: "tool-result" }] }], // no id
212
+ ];
213
+ for (const input of cases) {
214
+ const error: any = await normalizeMessages(input as any).catch((err) => err);
215
+ expect(error.code).toBe("INVALID_INPUT");
216
+ }
217
+ });
218
+
219
+ it("refuses a message without content", async () => {
220
+ for (const role of ["user", "system", "tool"]) {
221
+ const error: any = await normalizeMessages([{ role }] as any).catch((err) => err);
222
+ expect(error.code).toBe("INVALID_INPUT");
223
+ }
224
+ });
225
+
226
+ it("refuses an empty content array rather than sending an empty turn", async () => {
227
+ for (const input of [
228
+ [{ role: "user", content: [] }],
229
+ [{ role: "tool", content: [] }],
230
+ [{ role: "developer", content: [] }],
231
+ ]) {
232
+ const error: any = await normalizeMessages(input as any).catch((err) => err);
233
+ expect(error.code).toBe("INVALID_INPUT");
234
+ }
235
+
236
+ // An assistant turn with no text is legitimate — a tool call carries the turn
237
+ expect(await normalizeMessages([{ role: "assistant", content: [] }] as any))
238
+ .toEqual([{ role: "assistant", content: null }]);
239
+ });
240
+ });
241
+
242
+ describe("normalizeMessages — developer role and names", () => {
243
+ it("keeps the `developer` role and a message name", async () => {
244
+ const messages = await normalizeMessages([
245
+ { role: "developer", content: "answer concisely", name: "policy" },
246
+ { role: "user", content: "hi", name: "ada" },
247
+ ] as any);
248
+
249
+ expect(messages[0]).toEqual({ role: "developer", content: "answer concisely", name: "policy" });
250
+ expect(messages[1]).toEqual({ role: "user", content: "hi", name: "ada" });
251
+ });
252
+
253
+ it("keeps the raw thinking blocks of a replayed assistant turn", async () => {
254
+ const thinking = [{ type: "thinking", thinking: "…", signature: "sig" }];
255
+ const [message] = await normalizeMessages([{ role: "assistant", content: "done", thinking }] as any) as any[];
256
+ expect(message.thinking).toEqual(thinking);
257
+ });
258
+ });
@@ -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", { collection: "orders" });
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", { collection: "orders" });
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
+ });