@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.
Files changed (59) hide show
  1. package/database/audit.ts +193 -0
  2. package/database/collection.ts +3 -3
  3. package/database/deleteLog.ts +34 -0
  4. package/database/file.ts +3 -3
  5. package/database/mongodbadapter.ts +321 -147
  6. package/database/replication.ts +780 -0
  7. package/database/schema.ts +140 -125
  8. package/database/tenant.ts +4 -0
  9. package/database/vars.ts +405 -0
  10. package/database/workflow.ts +6 -6
  11. package/index.ts +10 -0
  12. package/lib/audit.ts +271 -0
  13. package/lib/auditResult.ts +120 -0
  14. package/lib/define.ts +20 -1
  15. package/lib/files.ts +64 -11
  16. package/lib/lifecycle.ts +141 -0
  17. package/lib/redact.ts +117 -0
  18. package/lib/workflow.ts +1 -1
  19. package/package.json +1 -1
  20. package/server/api.ts +109 -25
  21. package/server/boot.ts +185 -53
  22. package/server/flags.ts +136 -0
  23. package/server/hono.ts +1 -1
  24. package/tests/audit-file.test.ts +296 -0
  25. package/tests/audit-operations.test.ts +184 -0
  26. package/tests/audit-redact.test.ts +233 -0
  27. package/tests/audit-results.test.ts +188 -0
  28. package/tests/audit-retention-config.test.ts +76 -0
  29. package/tests/audit-run-action.test.ts +120 -0
  30. package/tests/audit.test.ts +320 -0
  31. package/tests/db-cache.test.ts +7 -7
  32. package/tests/file-collection-replication.test.ts +98 -0
  33. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  34. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  35. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  36. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  37. package/tests/fixtures/repl-only/app.ts +26 -0
  38. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  39. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  40. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  41. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  42. package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
  43. package/tests/lifecycle.test.ts +96 -0
  44. package/tests/replication-control.test.ts +128 -0
  45. package/tests/replication-initial-sync.test.ts +86 -0
  46. package/tests/replication-only.test.ts +358 -0
  47. package/tests/replication.test.ts +188 -0
  48. package/tests/upload.test.ts +102 -1
  49. package/tests/vars-api.test.ts +123 -0
  50. package/tests/vars-replication.test.ts +95 -0
  51. package/tests/vars.test.ts +216 -0
  52. package/types/collection.d.ts +3 -0
  53. package/types/config.d.ts +64 -0
  54. package/types/file.d.ts +4 -1
  55. package/types/lifecycle.d.ts +71 -0
  56. package/types/replication.d.ts +199 -0
  57. package/types/tenant.d.ts +23 -0
  58. package/types/vars.d.ts +147 -0
  59. package/utils/func.ts +67 -52
@@ -0,0 +1,320 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { useRest } from "../database/rest";
3
+ import { formatConfig, cfg } from "../server/config";
4
+ import { syncTenants, getTenant } from "../database/tenant";
5
+ import {
6
+ AUDIT_COLLECTION,
7
+ LEGACY_AUDIT_COLLECTION,
8
+ AUDIT_TTL_INDEX,
9
+ migrateAuditCollection,
10
+ syncAuditRetention,
11
+ } from "../database/audit";
12
+
13
+ const TEST_TENANT = "audit";
14
+ const TEST_DB = "mongodb://localhost:27017/_AUDIT_TEST";
15
+
16
+ // Second tenant: proves `tenant.audit.retention` is wired end-to-end
17
+ const TTL_TENANT = "audit-ttl";
18
+ const TTL_DB = "mongodb://localhost:27017/_AUDIT_TTL_TEST";
19
+
20
+ let rest: InstanceType<typeof useRest>;
21
+
22
+ /**
23
+ * Latest audit entry for a (collection, action) pair.
24
+ *
25
+ * `#logActivity` is fire-and-forget, so we poll — and when the same pair was
26
+ * already logged by an earlier test, `since` (captured *before* the operation)
27
+ * makes sure we wait for the new entry instead of reading the stale one.
28
+ */
29
+ async function lastActivity(collection: string, action: string, since?: Date) {
30
+ for (let i = 0; i < 40; i++) {
31
+ const found: any = await rest.db.collection(AUDIT_COLLECTION).findOne(
32
+ {
33
+ "operation.collection": collection,
34
+ "operation.action": action,
35
+ ...(since ? { ts: { $gte: since } } : {}),
36
+ },
37
+ { sort: { ts: -1 } },
38
+ );
39
+ if (found) return found;
40
+ await Bun.sleep(25);
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /** Minimal activity document (same shape as `ActivityInput`). */
46
+ function activity(action: string) {
47
+ return {
48
+ internal: true,
49
+ trace: { id: crypto.randomUUID() },
50
+ meta: {},
51
+ operation: {
52
+ tenant: TEST_TENANT,
53
+ action,
54
+ collection: "items",
55
+ status: "success",
56
+ input: null,
57
+ result: null,
58
+ error: null,
59
+ duration: 0,
60
+ transaction: false,
61
+ },
62
+ ts: new Date(),
63
+ } as any;
64
+ }
65
+
66
+ /** Index document, or undefined when absent. */
67
+ async function index(name: string): Promise<any> {
68
+ return (await rest.db.collection(AUDIT_COLLECTION).listIndexes().toArray())
69
+ .find((i: any) => i.name === name);
70
+ }
71
+
72
+ beforeAll(async () => {
73
+ formatConfig({
74
+ server: { port: 4000 },
75
+ tenants: [
76
+ { id: TEST_TENANT, dir: "src", database: { uri: TEST_DB } },
77
+ { id: TTL_TENANT, dir: "src", database: { uri: TTL_DB }, audit: { retention: "7d" } },
78
+ ],
79
+ });
80
+ await syncTenants();
81
+
82
+ // Declared collections — needed to exercise the `$lookup` allow-list and `collectionType`
83
+ (cfg as any).collections = [
84
+ {
85
+ _tenant_: TEST_TENANT,
86
+ slug: "items",
87
+ fields: [{ name: "title", type: "string" }],
88
+ api: { access: { "*": true } },
89
+ },
90
+ ];
91
+ (cfg as any).fileCollections = [
92
+ {
93
+ _tenant_: TEST_TENANT,
94
+ _isFileCollection_: true,
95
+ slug: "photos",
96
+ fields: [{ name: "label", type: "string" }],
97
+ api: { access: { "*": true } },
98
+ },
99
+ ];
100
+
101
+ rest = new useRest({ internal: true, tenant_id: TEST_TENANT });
102
+ });
103
+
104
+ afterAll(async () => {
105
+ try { await rest.db.collection("items").drop(); } catch (_) {}
106
+ try { await rest.db.collection("photos").drop(); } catch (_) {}
107
+ try { await rest.db.collection(AUDIT_COLLECTION).drop(); } catch (_) {}
108
+ try { await rest.db.collection(LEGACY_AUDIT_COLLECTION).drop(); } catch (_) {}
109
+ try { await getTenant(TTL_TENANT)?.database?.db?.collection(AUDIT_COLLECTION).drop(); } catch (_) {}
110
+ });
111
+
112
+ describe("audit collection", () => {
113
+ it("writes to `_audit_`", async () => {
114
+ await rest.audit.addActivities([activity("unit")]);
115
+
116
+ const stored: any = await rest.db.collection(AUDIT_COLLECTION)
117
+ .findOne({ "operation.action": "unit" });
118
+ expect(stored).toBeDefined();
119
+ expect(stored.operation.collection).toBe("items");
120
+ });
121
+
122
+ it("reads back through getActivities", async () => {
123
+ const logs = await rest.audit.getActivities({
124
+ $match: { "operation.action": "unit" },
125
+ $limit: 10,
126
+ } as any);
127
+ expect(logs.length).toBeGreaterThanOrEqual(1);
128
+ });
129
+
130
+ it("does not use the legacy `_activities_` name anymore", async () => {
131
+ const legacy = await rest.db.listCollections({ name: LEGACY_AUDIT_COLLECTION }).toArray();
132
+ expect(legacy.length).toBe(0);
133
+ });
134
+
135
+ it("is on disk as soon as a mutation resolves (no polling needed)", async () => {
136
+ const doc: any = await rest.insertOne("items", { title: "awaited" });
137
+
138
+ // Deliberately **no** polling: mutations await their audit entry, so it is
139
+ // already there — a successful write can never lose its trace.
140
+ const logged: any = await rest.db.collection(AUDIT_COLLECTION).findOne({
141
+ "operation.action": "insertOne",
142
+ "operation.result._id": doc._id,
143
+ });
144
+ expect(logged).not.toBeNull();
145
+ expect(logged.operation.status).toBe("success");
146
+ });
147
+
148
+ it("accepts `_audit_` as a $lookup target from a declared collection", async () => {
149
+ const rows = await rest.aggregate("items", [
150
+ { $lookup: { from: AUDIT_COLLECTION, localField: "_id", foreignField: "_id", as: "audit" } },
151
+ { $limit: 1 },
152
+ ]);
153
+ expect(Array.isArray(rows)).toBe(true);
154
+ });
155
+ });
156
+
157
+ describe("audit collectionType", () => {
158
+ it("labels a document collection as `document`", async () => {
159
+ await rest.insertOne("items", { title: "collectionType" });
160
+
161
+ const logged = await lastActivity("items", "insertOne");
162
+ expect(logged).not.toBeNull();
163
+ expect(logged.operation.collectionType).toBe("document");
164
+ });
165
+
166
+ it("labels a file collection as `file`", async () => {
167
+ await rest.insertOne("photos", { label: "ct", _file: { filename: "ct.png" } });
168
+
169
+ const logged = await lastActivity("photos", "insertOne");
170
+ expect(logged).not.toBeNull();
171
+ expect(logged.operation.collectionType).toBe("file");
172
+ });
173
+
174
+ it("leaves `collectionType` undefined for an internal collection", async () => {
175
+ // `_audit_` is not declared anywhere — no type to report
176
+ const logged: any = await rest.db.collection(AUDIT_COLLECTION)
177
+ .findOne({ "operation.collection": "_locks_" });
178
+ expect(logged?.operation?.collectionType).toBeUndefined();
179
+ });
180
+ });
181
+
182
+ describe("audit read operations", () => {
183
+ it("logs `find` with its parameters, never the result", async () => {
184
+ const since = new Date();
185
+ const docs = await rest.find("items", { $match: { title: "collectionType" }, $limit: 5 });
186
+ expect(docs.length).toBeGreaterThanOrEqual(1); // the read really returned data
187
+
188
+ const logged = await lastActivity("items", "find", since);
189
+ expect(logged).not.toBeNull();
190
+ expect(logged.operation.input.params).toEqual({ $match: { title: "collectionType" }, $limit: 5 });
191
+ expect(logged.operation.result).toBeNull(); // ← nothing returned is stored
192
+ expect(logged.operation.status).toBe("success");
193
+ });
194
+
195
+ it("logs `findOne` with the id", async () => {
196
+ const since = new Date();
197
+ const [first] = await rest.find("items", { $limit: 1 });
198
+ const doc: any = await rest.findOne("items", first!._id);
199
+ expect(doc).not.toBeNull();
200
+
201
+ const logged = await lastActivity("items", "findOne", since);
202
+ expect(logged.operation.input.id).toBeString();
203
+ expect(logged.operation.result).toBeNull();
204
+ });
205
+
206
+ it("logs `aggregate` with its pipeline", async () => {
207
+ const since = new Date();
208
+ const pipeline = [{ $match: { title: "collectionType" } }, { $limit: 1 }];
209
+ const rows = await rest.aggregate("items", pipeline);
210
+ expect(rows.length).toBeGreaterThanOrEqual(1);
211
+
212
+ const logged = await lastActivity("items", "aggregate", since);
213
+ expect(logged.operation.input.pipeline).toEqual(pipeline);
214
+ expect(logged.operation.result).toBeNull();
215
+ });
216
+
217
+ it("logs a failed read with its error and still no result", async () => {
218
+ const since = new Date();
219
+ await expect(rest.aggregate("items", [
220
+ { $lookup: { from: "not_declared", localField: "_id", foreignField: "_id", as: "x" } },
221
+ ])).rejects.toThrow();
222
+
223
+ const logged = await lastActivity("items", "aggregate", since);
224
+ expect(logged.operation.status).toBe("error");
225
+ expect(logged.operation.error.message).toBeString();
226
+ expect(logged.operation.result).toBeNull();
227
+ });
228
+ });
229
+
230
+ describe("audit query indexes", () => {
231
+ it("are ensured at boot", async () => {
232
+ const names = (await rest.db.collection(AUDIT_COLLECTION).listIndexes().toArray())
233
+ .map((i: any) => i.name);
234
+ expect(names).toContain("_audit_ts_");
235
+ expect(names).toContain("_audit_collection_ts_");
236
+ expect(names).toContain("_audit_status_ts_");
237
+ expect(names).toContain("_audit_trace_");
238
+ });
239
+
240
+ it("serve the documented sorts and filters", async () => {
241
+ expect((await index("_audit_ts_")).key).toEqual({ ts: -1 });
242
+ expect((await index("_audit_collection_ts_")).key).toEqual({ "operation.collection": 1, ts: -1 });
243
+ expect((await index("_audit_status_ts_")).key).toEqual({ "operation.status": 1, ts: -1 });
244
+ expect((await index("_audit_trace_")).key).toEqual({ "trace.id": 1 });
245
+ });
246
+ });
247
+
248
+ describe("audit retention (TTL)", () => {
249
+ it("stays untouched — no TTL index — when retention is not configured", async () => {
250
+ expect(await syncAuditRetention(rest.db, undefined)).toBe("skipped");
251
+ expect(await index(AUDIT_TTL_INDEX)).toBeUndefined();
252
+ });
253
+
254
+ it("creates a TTL index when a duration is configured", async () => {
255
+ expect(await syncAuditRetention(rest.db, "90d")).toBe("applied");
256
+
257
+ const ttl = await index(AUDIT_TTL_INDEX);
258
+ expect(ttl.key).toEqual({ ts: 1 });
259
+ expect(ttl.expireAfterSeconds).toBe(90 * 86400);
260
+ });
261
+
262
+ it("is idempotent — the same duration is a no-op", async () => {
263
+ expect(await syncAuditRetention(rest.db, "90d")).toBe("unchanged");
264
+ });
265
+
266
+ it("updates expireAfterSeconds when the duration changes", async () => {
267
+ expect(await syncAuditRetention(rest.db, "24h")).toBe("applied");
268
+ expect((await index(AUDIT_TTL_INDEX)).expireAfterSeconds).toBe(24 * 3600);
269
+ });
270
+
271
+ it("drops the TTL index when retention is explicitly disabled", async () => {
272
+ expect(await syncAuditRetention(rest.db, false)).toBe("dropped");
273
+ expect(await index(AUDIT_TTL_INDEX)).toBeUndefined();
274
+ });
275
+
276
+ it("ignores an invalid duration", async () => {
277
+ expect(await syncAuditRetention(rest.db, "banana")).toBe("skipped");
278
+ expect(await index(AUDIT_TTL_INDEX)).toBeUndefined();
279
+ });
280
+
281
+ it("is wired through `tenant.audit.retention`", async () => {
282
+ const db = getTenant(TTL_TENANT)!.database!.db!;
283
+ const ttl: any = (await db.collection(AUDIT_COLLECTION).listIndexes().toArray())
284
+ .find((i: any) => i.name === AUDIT_TTL_INDEX);
285
+
286
+ expect(ttl).toBeDefined();
287
+ expect(ttl.expireAfterSeconds).toBe(7 * 86400);
288
+ });
289
+ });
290
+
291
+ describe("audit collection migration (`_activities_` → `_audit_`)", () => {
292
+ it("renames an existing legacy collection, without losing documents", async () => {
293
+ await rest.db.collection(AUDIT_COLLECTION).drop().catch(() => {});
294
+ await rest.db.collection(LEGACY_AUDIT_COLLECTION).insertOne(activity("legacy") as any);
295
+
296
+ expect(await migrateAuditCollection(rest.db)).toBe(true);
297
+
298
+ const legacy = await rest.db.listCollections({ name: LEGACY_AUDIT_COLLECTION }).toArray();
299
+ expect(legacy.length).toBe(0);
300
+
301
+ const moved: any = await rest.db.collection(AUDIT_COLLECTION)
302
+ .findOne({ "operation.action": "legacy" });
303
+ expect(moved).toBeDefined();
304
+ });
305
+
306
+ it("is idempotent — a second run is a no-op", async () => {
307
+ expect(await migrateAuditCollection(rest.db)).toBe(false);
308
+ });
309
+
310
+ it("never touches the legacy collection when `_audit_` already exists", async () => {
311
+ await rest.db.collection(AUDIT_COLLECTION).insertOne(activity("keep") as any);
312
+ await rest.db.collection(LEGACY_AUDIT_COLLECTION).insertOne(activity("orphan") as any);
313
+
314
+ expect(await migrateAuditCollection(rest.db)).toBe(false);
315
+
316
+ // neither merged nor dropped — both collections survive untouched
317
+ expect(await rest.db.collection(AUDIT_COLLECTION).countDocuments({ "operation.action": "orphan" })).toBe(0);
318
+ expect(await rest.db.collection(LEGACY_AUDIT_COLLECTION).countDocuments({})).toBe(1);
319
+ });
320
+ });
@@ -152,10 +152,10 @@ describe("DB query cache (useCache)", () => {
152
152
  const r1: any = await rest.find("items", params, { useCache: true });
153
153
  expect(r1[0]!.count).toBe(1);
154
154
 
155
- // update avec un ObjectId NATIF (pas un string) — la normalisation String(id) doit matcher l'index
155
+ // update with a NATIVE ObjectId (not a string) — String(id) normalization must match the index
156
156
  await rest.updateOne("items", new ObjectId(a._id), { $set: { count: 77 } });
157
157
  const r2: any = await rest.find("items", params, { useCache: true });
158
- expect(r2[0]!.count).toBe(77); // invalidé + refetch
158
+ expect(r2[0]!.count).toBe(77); // invalidated + refetch
159
159
 
160
160
  await rest.db.collection("items").deleteMany({ title: marker });
161
161
  });
@@ -168,15 +168,15 @@ describe("DB query cache (useCache)", () => {
168
168
  const b = await rest.insertOne("items", { title: marker, count: 2 });
169
169
  const c = await rest.insertOne("items", { title: marker, count: 3 });
170
170
 
171
- const params = { $match: { title: marker }, $sort: { count: 1 } }; // ordre garanti: a(1), b(2), c(3)
172
- await rest.find("items", params, { useCache: true }); // cache peuplé (3 docs)
171
+ const params = { $match: { title: marker }, $sort: { count: 1 } }; // guaranteed order: a(1), b(2), c(3)
172
+ await rest.find("items", params, { useCache: true }); // cache populated (3 docs)
173
173
 
174
- // le 3e id (c) n'est PAS taggé (maxTags: 2) → pas d'invalidation ciblée
174
+ // the 3rd id (c) is NOT tagged (maxTags: 2) → no targeted invalidation
175
175
  await rest.updateOne("items", c._id, { $set: { count: 33 } });
176
176
  const r1: any = await rest.find("items", params, { useCache: true });
177
- expect(r1.find((d: any) => d._id === c._id)!.count).toBe(3); // cache conservé
177
+ expect(r1.find((d: any) => d._id === c._id)!.count).toBe(3); // cache kept
178
178
 
179
- // le 1er id (a) est taggé → invalidation ciblée
179
+ // the 1st id (a) is tagged → targeted invalidation
180
180
  await rest.updateOne("items", a._id, { $set: { count: 11 } });
181
181
  const r2: any = await rest.find("items", params, { useCache: true });
182
182
  expect(r2.find((d: any) => d._id === a._id)!.count).toBe(11); // refetch
@@ -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
+ });
@@ -0,0 +1,11 @@
1
+ // Test fixture — disabled lifecycle (must be skipped).
2
+ const calls: string[] = [];
3
+ (globalThis as any).__lcDisabled = calls;
4
+
5
+ export default {
6
+ _isLifecycle_: true,
7
+ enabled: false,
8
+ beforeBoot: async () => { calls.push("beforeBoot"); },
9
+ afterBoot: async () => { calls.push("afterBoot"); },
10
+ onDestroy: async () => { calls.push("onDestroy"); },
11
+ };
@@ -0,0 +1,13 @@
1
+ // Test fixture — `onDestroy` never resolves (used to test destroyTimeout).
2
+ const calls: string[] = [];
3
+ (globalThis as any).__lcHang = calls;
4
+
5
+ export default {
6
+ _isLifecycle_: true,
7
+ enabled: true,
8
+ destroyTimeout: 100,
9
+ onDestroy: async () => {
10
+ calls.push("onDestroy:start");
11
+ await new Promise(() => { /* never resolves */ });
12
+ },
13
+ };
@@ -0,0 +1,23 @@
1
+ // Test fixture — records lifecycle calls on globalThis.
2
+ const calls: string[] = [];
3
+ (globalThis as any).__lcNormal = calls;
4
+
5
+ export default {
6
+ _isLifecycle_: true,
7
+ enabled: true,
8
+ destroyTimeout: 200,
9
+ beforeBoot: async ({ replication }: any) => {
10
+ calls.push("beforeBoot");
11
+ // The API is tenant-bound (like `rest`) — no tenant id is passed, and the
12
+ // implicit tenant must match this lifecycle's tenant.
13
+ const seeded = await replication.seed("latest");
14
+ (globalThis as any).__lcReplicationApi =
15
+ typeof replication?.seed === "function" &&
16
+ typeof replication?.reset === "function" &&
17
+ typeof replication?.now === "function" &&
18
+ typeof replication?.state === "function" &&
19
+ seeded?.tenant === "normal";
20
+ },
21
+ afterBoot: async () => { calls.push("afterBoot"); },
22
+ onDestroy: async ({ reason }: any) => { calls.push(`onDestroy:${reason}`); },
23
+ };
@@ -0,0 +1,6 @@
1
+ // Test fixture — `beforeBoot` throws.
2
+ export default {
3
+ _isLifecycle_: true,
4
+ enabled: true,
5
+ beforeBoot: async () => { throw new Error("boom"); },
6
+ };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Fixture — boots the server through `bootApp`, to be launched with a boot mode
3
+ * flag (see `packages/core/tests/replication-only.test.ts`).
4
+ * Port and database URIs are overridable through the environment so the same
5
+ * fixture serves every mode without clashes.
6
+ *
7
+ * Run manually:
8
+ * bun packages/core/tests/fixtures/repl-only/app.ts --replication-only
9
+ * bun packages/core/tests/fixtures/repl-only/app.ts --no-replication
10
+ */
11
+ import { bootApp } from "../../../server/boot";
12
+
13
+ await bootApp({
14
+ server: { port: Number(Bun.env.RO_PORT ?? 5556) },
15
+ tenants: [{
16
+ id: "ro",
17
+ dir: "packages/core/tests/fixtures/repl-only/tenant",
18
+ database: { uri: Bun.env.RO_SRC ?? "mongodb://localhost:27017/_RO_SRC" },
19
+ replication: {
20
+ enabled: true,
21
+ runOnBoot: true,
22
+ schedule: { interval: "1h" },
23
+ destinations: [{ id: "backup", uri: Bun.env.RO_DST ?? "mongodb://localhost:27017/_RO_DST" }],
24
+ },
25
+ }],
26
+ });
@@ -0,0 +1,14 @@
1
+ // Test fixture — a replicable collection (plain object + marker, no import needed).
2
+ export default {
3
+ _isCollection_: true,
4
+ _isTimeSerie_: false,
5
+ type: "document",
6
+ slug: "orders",
7
+ fields: [
8
+ { name: "ref", type: "string" },
9
+ { name: "total", type: "number" },
10
+ ],
11
+ api: { access: { "*": true } },
12
+ // Opt this collection into the data replication engine
13
+ replication: { enabled: true },
14
+ };
@@ -0,0 +1,14 @@
1
+ // Test fixture — `beforeBoot` / `onDestroy` must still run in replication-only
2
+ // mode (the documented place to seed or reset a replication before it starts).
3
+ // Each hook drops a marker file the test can assert on.
4
+ // (`enabled` mirrors what `define.Lifecycle` sets by default.)
5
+ export default {
6
+ _isLifecycle_: true,
7
+ enabled: true,
8
+ beforeBoot: async () => {
9
+ await Bun.write(new URL("./.markers/lifecycle.txt", import.meta.url), "beforeBoot");
10
+ },
11
+ onDestroy: async () => {
12
+ await Bun.write(new URL("./.markers/onDestroy.txt", import.meta.url), "onDestroy");
13
+ },
14
+ };
@@ -0,0 +1,8 @@
1
+ // Test fixture — must NOT run in replication-only mode (no `runScripts`).
2
+ export default {
3
+ _isScript_: true,
4
+ enabled: true,
5
+ exec: async () => {
6
+ await Bun.write(new URL("../.markers/script.txt", import.meta.url), "ran");
7
+ },
8
+ };
@@ -0,0 +1,23 @@
1
+ // Test fixture — a `define.Vars` definition (plain object + marker).
2
+ export default {
3
+ _isVars_: true,
4
+ namespace: "config",
5
+ scope: { name: "company", type: "relationship", relation: { to: "companies" } },
6
+ meta: [
7
+ { name: "note", type: "string" },
8
+ { name: "expiresAt", type: "datetime-local" },
9
+ ],
10
+ vars: {
11
+ licence: "RDX00",
12
+ maxUsers: { type: "number", defaultValue: 100 },
13
+ session: { type: "string", ttl: "1h" },
14
+ },
15
+ api: {
16
+ access: {
17
+ "*": true,
18
+ set: true,
19
+ },
20
+ },
21
+ // Opt this namespace into replication (per-namespace filter on `ns`).
22
+ replication: { enabled: true },
23
+ };
@@ -0,0 +1,6 @@
1
+ // Test fixture — a namespace WITHOUT `api.access` (must be denied over HTTP).
2
+ export default {
3
+ _isVars_: true,
4
+ namespace: "private",
5
+ vars: { secret: "shh" },
6
+ };