@anteros/core 0.0.24 → 0.0.26

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/tests/api.test.ts CHANGED
@@ -73,6 +73,216 @@ describe("find", () => {
73
73
  });
74
74
  });
75
75
 
76
+ describe("cursors / streaming", () => {
77
+ it("findStream iterates all matching docs (no default limit)", async () => {
78
+ const marker = `stream-${crypto.randomUUID()}`;
79
+ await rest.bulkWrite("items", Array.from({ length: 150 }, (_, i) => ({
80
+ insertOne: { document: { title: marker, count: i } },
81
+ })) as any);
82
+
83
+ const docs: any[] = [];
84
+ for await (const doc of rest.findStream("items", { $match: { title: marker } })) {
85
+ docs.push(doc);
86
+ }
87
+ expect(docs.length).toBe(150); // no 100-cap (unlike find())
88
+ expect(docs.every((d) => d.title === marker)).toBe(true);
89
+
90
+ await rest.db.collection("items").deleteMany({ title: marker });
91
+ });
92
+
93
+ it("aggregateStream yields JSON-converted docs", async () => {
94
+ const marker = `astream-${crypto.randomUUID()}`;
95
+ await rest.insertMany("items", [
96
+ { title: marker, count: 5 },
97
+ { title: marker, count: 7 },
98
+ ]);
99
+
100
+ const docs: any[] = [];
101
+ for await (const doc of rest.aggregateStream("items", [{ $match: { title: marker } }])) {
102
+ docs.push(doc);
103
+ }
104
+ expect(docs.length).toBe(2);
105
+
106
+ await rest.db.collection("items").deleteMany({ title: marker });
107
+ });
108
+
109
+ it("findStream honors the batchSize option", async () => {
110
+ const marker = `bsize-${crypto.randomUUID()}`;
111
+ await rest.bulkWrite("items", Array.from({ length: 50 }, (_, i) => ({
112
+ insertOne: { document: { title: marker, count: i } },
113
+ })) as any);
114
+
115
+ const docs: any[] = [];
116
+ for await (const doc of rest.findStream("items", { $match: { title: marker } }, { batchSize: 10 })) {
117
+ docs.push(doc);
118
+ }
119
+ expect(docs.length).toBe(50);
120
+ // JSON-converted (id as string, ISO dates)
121
+ expect(typeof docs[0]!._id).toBe("string");
122
+ expect(docs[0]!.createdAt).toMatch(/\d{4}-\d{2}-\d{2}/);
123
+
124
+ await rest.db.collection("items").deleteMany({ title: marker });
125
+ });
126
+
127
+ it("cursor options: maxTimeMS + comment + hint", async () => {
128
+ const marker = `copts-${crypto.randomUUID()}`;
129
+ await rest.insertMany("items", [{ title: marker }, { title: marker }]);
130
+
131
+ const docs: any[] = [];
132
+ for await (const doc of rest.findStream("items", { $match: { title: marker } }, {
133
+ batchSize: 5,
134
+ maxTimeMS: 30000,
135
+ comment: `test-${marker}`,
136
+ hint: { _id: 1 },
137
+ })) {
138
+ docs.push(doc);
139
+ }
140
+ expect(docs.length).toBe(2);
141
+
142
+ await rest.db.collection("items").deleteMany({ title: marker });
143
+ });
144
+
145
+ it("findStream withCount yields { count, doc } per document", async () => {
146
+ const marker = `wcount-${crypto.randomUUID()}`;
147
+ await rest.bulkWrite("items", Array.from({ length: 25 }, (_, i) => ({
148
+ insertOne: { document: { title: marker, count: i } },
149
+ })) as any);
150
+ // non-matching doc to prove the count only covers the $match
151
+ await rest.insertOne("items", { title: "other" });
152
+
153
+ let iterations = 0;
154
+ for await (const d of rest.findStream("items", { $match: { title: marker } }, { withCount: true })) {
155
+ expect(d.count).toBe(25);
156
+ expect(d.doc.title).toBe(marker);
157
+ iterations++;
158
+ }
159
+ expect(iterations).toBe(25);
160
+
161
+ await rest.db.collection("items").deleteMany({ title: { $in: [marker, "other"] } });
162
+ });
163
+
164
+ it("findStream pageSize yields { count, docs, hasNext } per page", async () => {
165
+ const marker = `pages-${crypto.randomUUID()}`;
166
+ await rest.bulkWrite("items", Array.from({ length: 25 }, (_, i) => ({
167
+ insertOne: { document: { title: marker, count: i } },
168
+ })) as any);
169
+ await rest.insertOne("items", { title: "other" });
170
+
171
+ const pages: any[] = [];
172
+ for await (const page of rest.findStream("items", { $match: { title: marker } }, { pageSize: 10 })) {
173
+ pages.push(page);
174
+ expect(page.count).toBe(25); // total matching docs, not the page size
175
+ expect(page.docs.length).toBeLessThanOrEqual(10); // pageSize
176
+ expect(page.docs.every((d: any) => d.title === marker)).toBe(true);
177
+ }
178
+ // 25 docs / pageSize 10 → 3 pages: 10 + 10 + 5
179
+ expect(pages.length).toBe(3);
180
+ expect(pages[0]!.docs.length).toBe(10);
181
+ expect(pages[0]!.hasNext()).toBe(true);
182
+ expect(pages[1]!.docs.length).toBe(10);
183
+ expect(pages[1]!.hasNext()).toBe(true);
184
+ expect(pages[2]!.docs.length).toBe(5);
185
+ expect(pages[2]!.hasNext()).toBe(false);
186
+ const total = pages.reduce((acc: number, p: any) => acc + p.docs.length, 0);
187
+ expect(total).toBe(25);
188
+
189
+ await rest.db.collection("items").deleteMany({ title: { $in: [marker, "other"] } });
190
+ });
191
+
192
+ it("findStream pageSize withCount: false skips the count query", async () => {
193
+ const marker = `nocount-${crypto.randomUUID()}`;
194
+ await rest.bulkWrite("items", Array.from({ length: 12 }, (_, i) => ({
195
+ insertOne: { document: { title: marker, count: i } },
196
+ })) as any);
197
+
198
+ const pages: any[] = [];
199
+ for await (const page of rest.findStream("items", { $match: { title: marker } }, { pageSize: 10, withCount: false })) {
200
+ pages.push(page);
201
+ expect("count" in page).toBe(false); // no count field
202
+ expect(page.docs.length).toBeLessThanOrEqual(10);
203
+ expect(typeof page.hasNext).toBe("function");
204
+ }
205
+ expect(pages.length).toBe(2); // 10 + 2
206
+ expect(pages[0]!.hasNext()).toBe(true);
207
+ expect(pages[1]!.hasNext()).toBe(false);
208
+
209
+ await rest.db.collection("items").deleteMany({ title: marker });
210
+ });
211
+
212
+ it("findStream map transforms each doc on the fly", async () => {
213
+ const marker = `map-${crypto.randomUUID()}`;
214
+ await rest.insertMany("items", [{ title: marker, count: 1 }, { title: marker, count: 2 }]);
215
+
216
+ const out: any[] = [];
217
+ for await (const doc of rest.findStream("items", { $match: { title: marker } }, {
218
+ map: (d: any) => ({ id: d._id, n: d.count }),
219
+ })) {
220
+ out.push(doc);
221
+ }
222
+ expect(out.length).toBe(2);
223
+ expect(out.every((m) => "id" in m && "n" in m && !("title" in m))).toBe(true);
224
+
225
+ await rest.db.collection("items").deleteMany({ title: marker });
226
+ });
227
+
228
+ it("findStream signal aborts the stream and closes the cursor", async () => {
229
+ const marker = `sig-${crypto.randomUUID()}`;
230
+ await rest.bulkWrite("items", Array.from({ length: 20 }, (_, i) => ({
231
+ insertOne: { document: { title: marker, count: i } },
232
+ })) as any);
233
+
234
+ const ac = new AbortController();
235
+ let seen = 0;
236
+ for await (const doc of rest.findStream("items", { $match: { title: marker } }, { signal: ac.signal })) {
237
+ seen++;
238
+ if (seen === 3) ac.abort();
239
+ }
240
+ expect(seen).toBe(3); // stopped early, cursor closed by the generator
241
+
242
+ await rest.db.collection("items").deleteMany({ title: marker });
243
+ });
244
+
245
+ it("findStream pageSize map + signal", async () => {
246
+ const marker = `psig-${crypto.randomUUID()}`;
247
+ await rest.bulkWrite("items", Array.from({ length: 30 }, (_, i) => ({
248
+ insertOne: { document: { title: marker, count: i } },
249
+ })) as any);
250
+
251
+ const ac = new AbortController();
252
+ let pages = 0;
253
+ for await (const page of rest.findStream("items", { $match: { title: marker } }, {
254
+ pageSize: 10,
255
+ map: (d: any) => ({ id: d._id }),
256
+ signal: ac.signal,
257
+ })) {
258
+ pages++;
259
+ expect(page.docs.every((d: any) => "id" in d && !("title" in d))).toBe(true);
260
+ if (pages === 2) ac.abort();
261
+ }
262
+ expect(pages).toBe(2); // stopped after 2 pages
263
+
264
+ await rest.db.collection("items").deleteMany({ title: marker });
265
+ });
266
+
267
+ it("aggregateStream withCount counts the pipeline output", async () => {
268
+ const marker = `acount-${crypto.randomUUID()}`;
269
+ await rest.insertMany("items", [
270
+ { title: marker, count: 1 },
271
+ { title: marker, count: 2 },
272
+ { title: marker, count: 3 },
273
+ ]);
274
+
275
+ const out: any[] = [];
276
+ for await (const d of rest.aggregateStream("items", [{ $match: { title: marker } }], { withCount: true })) {
277
+ expect(d.count).toBe(3);
278
+ out.push(d.doc);
279
+ }
280
+ expect(out.length).toBe(3);
281
+
282
+ await rest.db.collection("items").deleteMany({ title: marker });
283
+ });
284
+ });
285
+
76
286
  describe("$limit", () => {
77
287
  it("$limit: 0 means no limit (returns all matching docs)", async () => {
78
288
  const marker = `limit-zero-${crypto.randomUUID()}`;
@@ -196,9 +406,13 @@ describe("countDocuments", () => {
196
406
  expect(typeof n).toBe("number");
197
407
  });
198
408
  it("counts with filter", async () => {
199
- await rest.insertOne("items", { title: "count-me" });
200
- const n = await rest.countDocuments("items", { title: "count-me" });
201
- expect(n).toBeGreaterThanOrEqual(1);
409
+ const marker = `count-me-${crypto.randomUUID()}`;
410
+ await rest.insertOne("items", { title: marker });
411
+ await rest.insertOne("items", { title: "count-other" });
412
+ const n = await rest.countDocuments("items", { title: marker });
413
+ expect(n).toBe(1); // only the matching doc — not the whole collection
414
+
415
+ await rest.db.collection("items").deleteMany({ title: marker });
202
416
  });
203
417
  });
204
418
 
@@ -0,0 +1,214 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { ObjectId } from "mongodb";
3
+ import { useRest } from "../database/rest";
4
+ import { formatConfig, cfg } from "../server/config";
5
+ import { syncTenants } from "../database/tenant";
6
+ import { syncCollections } from "../database/collection";
7
+
8
+ const TEST_TENANT = "cache-test";
9
+ const TEST_DB = "mongodb://localhost:27017/_DB_CACHE_TEST";
10
+
11
+ let rest: InstanceType<typeof useRest>;
12
+
13
+ beforeAll(async () => {
14
+ formatConfig({
15
+ server: { port: 4000, cache: { driver: "memory", enabled: true } },
16
+ tenants: [
17
+ { id: TEST_TENANT, dir: "src", database: { uri: TEST_DB } },
18
+ { id: "cache-test-2", dir: "src", database: { uri: TEST_DB } },
19
+ ],
20
+ });
21
+ await syncTenants();
22
+ await syncCollections();
23
+ (cfg as any).collections = [
24
+ { _tenant_: TEST_TENANT, slug: "items", fields: [{ name: "title", type: "string" }, { name: "count", type: "number" }], api: { access: { "*": true } } },
25
+ { _tenant_: TEST_TENANT, slug: "orders", fields: [{ name: "code", type: "string" }, { name: "author", type: "relationship", relation: { to: "clients" } }], api: { access: { "*": true } } },
26
+ { _tenant_: TEST_TENANT, slug: "clients", fields: [{ name: "name", type: "string" }], api: { access: { "*": true } } },
27
+ ];
28
+ rest = new useRest({ internal: false, tenant_id: TEST_TENANT });
29
+ });
30
+
31
+ afterAll(async () => {
32
+ try { await rest.db.collection("items").drop(); } catch {}
33
+ try { await rest.db.collection("orders").drop(); } catch {}
34
+ try { await rest.db.collection("clients").drop(); } catch {}
35
+ });
36
+
37
+ describe("DB query cache (useCache)", () => {
38
+ it("serves from cache and invalidates on framework writes", async () => {
39
+ const marker = `dc-${crypto.randomUUID()}`;
40
+ await rest.insertMany("items", [{ title: marker }, { title: marker }]);
41
+ const params = { $match: { title: marker } };
42
+
43
+ const r1 = await rest.find("items", params, { useCache: true });
44
+ expect(r1.length).toBe(2);
45
+
46
+ // direct DB write (bypasses the framework) → cache is still served (stale)
47
+ await rest.db.collection("items").insertOne({ title: marker });
48
+ const r2 = await rest.find("items", params, { useCache: true });
49
+ expect(r2.length).toBe(2); // from cache
50
+
51
+ // framework insert → invalidates → refetches (now 4 docs: 2 + direct + framework)
52
+ await rest.insertOne("items", { title: marker });
53
+ const r3 = await rest.find("items", params, { useCache: true });
54
+ expect(r3.length).toBe(4); // refetched
55
+
56
+ // framework delete → invalidates
57
+ await rest.deleteMany("items", [r3[0]!._id]);
58
+ const r4 = await rest.find("items", params, { useCache: true });
59
+ expect(r4.length).toBe(3);
60
+
61
+ await rest.db.collection("items").deleteMany({ title: marker });
62
+ });
63
+
64
+ it("different params produce different cache entries", async () => {
65
+ const marker = `dp-${crypto.randomUUID()}`;
66
+ await rest.insertMany("items", [{ title: marker, count: 1 }, { title: marker, count: 2 }]);
67
+ await rest.find("items", { $match: { title: marker, count: 1 } }, { useCache: true });
68
+ const r = await rest.find("items", { $match: { title: marker, count: 2 } }, { useCache: true });
69
+ expect(r.length).toBe(1);
70
+ expect(r[0]!.count).toBe(2);
71
+ await rest.db.collection("items").deleteMany({ title: marker });
72
+ });
73
+
74
+ it("relations: modifying the included collection invalidates the parent cache", async () => {
75
+ const client = await rest.insertOne("clients", { name: "Old" });
76
+ const order = await rest.insertOne("orders", { code: "O1", author: client._id });
77
+
78
+ const params = { $match: { _id: order._id }, $include: ["author"] };
79
+ const r1: any = await rest.find("orders", params, { useCache: true });
80
+ expect(r1[0]!.author.name).toBe("Old");
81
+
82
+ // direct DB write on clients (bypass) → orders cache still stale
83
+ await rest.db.collection("clients").updateOne({ _id: new ObjectId(client._id) }, { $set: { name: "Direct" } });
84
+ const r2: any = await rest.find("orders", params, { useCache: true });
85
+ expect(r2[0]!.author.name).toBe("Old"); // from cache
86
+
87
+ // framework write on clients → invalidates orders cache too (relation)
88
+ await rest.updateOne("clients", client._id, { $set: { name: "New" } });
89
+ const r3: any = await rest.find("orders", params, { useCache: true });
90
+ expect(r3[0]!.author.name).toBe("New"); // refetched
91
+
92
+ await rest.db.collection("orders").deleteMany({ _id: order._id });
93
+ await rest.db.collection("clients").deleteMany({ _id: client._id });
94
+ });
95
+
96
+ it("collection cache.ttl default applies without the option", async () => {
97
+ (cfg.collections as any)[0]!.cache = { ttl: "1m" };
98
+ const marker = `cttl-${crypto.randomUUID()}`;
99
+ await rest.insertOne("items", { title: marker });
100
+ const r1 = await rest.find("items", { $match: { title: marker } }, { useCache: true });
101
+ expect(r1.length).toBe(1);
102
+ delete (cfg.collections as any)[0]!.cache;
103
+ await rest.db.collection("items").deleteMany({ title: marker });
104
+ });
105
+
106
+ it("targeted invalidation: an id NOT in the cached result keeps the cache", async () => {
107
+ const marker = `ti-out-${crypto.randomUUID()}`;
108
+ const feb1 = await rest.insertOne("items", { title: marker, month: "feb" });
109
+ const feb2 = await rest.insertOne("items", { title: marker, month: "feb" });
110
+ const jan = await rest.insertOne("items", { title: marker, month: "jan" });
111
+
112
+ const params = { $match: { title: marker, month: "feb" } };
113
+ const r1 = await rest.find("items", params, { useCache: true });
114
+ expect(r1.length).toBe(2); // cached (ids: feb1, feb2)
115
+
116
+ // update a JANUARY doc (id not in the cached result) → no invalidation
117
+ await rest.updateOne("items", jan._id, { $set: { month: "feb" } });
118
+ const r2 = await rest.find("items", params, { useCache: true });
119
+ expect(r2.length).toBe(2); // served from cache — jan's doc NOT in the result
120
+
121
+ await rest.db.collection("items").deleteMany({ title: marker });
122
+ });
123
+
124
+ it("targeted invalidation: an id IN the cached result invalidates it", async () => {
125
+ const marker = `ti-in-${crypto.randomUUID()}`;
126
+ const a = await rest.insertOne("items", { title: marker, count: 1 });
127
+ const b = await rest.insertOne("items", { title: marker, count: 2 });
128
+
129
+ const params = { $match: { title: marker } };
130
+ const r1: any = await rest.find("items", params, { useCache: true });
131
+ expect(r1.find((d: any) => d._id === a._id)!.count).toBe(1);
132
+
133
+ // update a doc that IS in the cached result → targeted invalidation → refetch
134
+ await rest.updateOne("items", a._id, { $set: { count: 99 } });
135
+ const r2: any = await rest.find("items", params, { useCache: true });
136
+ expect(r2.find((d: any) => d._id === a._id)!.count).toBe(99);
137
+ expect(r2.length).toBe(2);
138
+
139
+ // delete a doc in the cache → invalidated → refetch
140
+ await rest.deleteOne("items", b._id);
141
+ const r3 = await rest.find("items", params, { useCache: true });
142
+ expect(r3.length).toBe(1);
143
+
144
+ await rest.db.collection("items").deleteMany({ title: marker });
145
+ });
146
+
147
+ it("targeted invalidation works with native ObjectId ids", async () => {
148
+ const marker = `ti-oid-${crypto.randomUUID()}`;
149
+ const a = await rest.insertOne("items", { title: marker, count: 1 });
150
+
151
+ const params = { $match: { title: marker } };
152
+ const r1: any = await rest.find("items", params, { useCache: true });
153
+ expect(r1[0]!.count).toBe(1);
154
+
155
+ // update avec un ObjectId NATIF (pas un string) — la normalisation String(id) doit matcher l'index
156
+ await rest.updateOne("items", new ObjectId(a._id), { $set: { count: 77 } });
157
+ const r2: any = await rest.find("items", params, { useCache: true });
158
+ expect(r2[0]!.count).toBe(77); // invalidé + refetch
159
+
160
+ await rest.db.collection("items").deleteMany({ title: marker });
161
+ });
162
+
163
+ it("maxTags configurable caps the tagged ids", async () => {
164
+ const cacheConf = (cfg.server as any).cache;
165
+ (cfg.server as any).cache = { ...cacheConf, maxTags: 2 };
166
+ const marker = `mt-${crypto.randomUUID()}`;
167
+ const a = await rest.insertOne("items", { title: marker, count: 1 });
168
+ const b = await rest.insertOne("items", { title: marker, count: 2 });
169
+ const c = await rest.insertOne("items", { title: marker, count: 3 });
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)
173
+
174
+ // le 3e id (c) n'est PAS taggé (maxTags: 2) → pas d'invalidation ciblée
175
+ await rest.updateOne("items", c._id, { $set: { count: 33 } });
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é
178
+
179
+ // le 1er id (a) est taggé → invalidation ciblée
180
+ await rest.updateOne("items", a._id, { $set: { count: 11 } });
181
+ const r2: any = await rest.find("items", params, { useCache: true });
182
+ expect(r2.find((d: any) => d._id === a._id)!.count).toBe(11); // refetch
183
+
184
+ (cfg.server as any).cache = cacheConf; // restore
185
+ await rest.db.collection("items").deleteMany({ title: marker });
186
+ });
187
+
188
+ it("rest.cache is tenant-scoped", async () => {
189
+ // write from tenant A
190
+ await rest.cache.set("price:42", { total: 99 }, "5m");
191
+ expect(await rest.cache.get("price:42")).toEqual({ total: 99 });
192
+ expect(await rest.cache.has("price:42")).toBe(true);
193
+
194
+ // tenant B does NOT see tenant A's keys
195
+ const restB = new useRest({ tenant_id: "cache-test-2" });
196
+ expect(await restB.cache.get("price:42")).toBeUndefined();
197
+ await restB.cache.set("price:42", { total: 0 });
198
+ expect(await restB.cache.get("price:42")).toEqual({ total: 0 });
199
+ // tenant A unchanged
200
+ expect(await rest.cache.get("price:42")).toEqual({ total: 99 });
201
+
202
+ // getOrSet + delete
203
+ let calls = 0;
204
+ const v = await rest.cache.getOrSet("stats", async () => { calls++; return { n: 1 }; });
205
+ expect(v).toEqual({ n: 1 });
206
+ const v2 = await rest.cache.getOrSet("stats", async () => { calls++; return { n: 2 }; });
207
+ expect(v2).toEqual({ n: 1 });
208
+ expect(calls).toBe(1);
209
+
210
+ await rest.cache.delete("price:42");
211
+ await rest.cache.delete("stats");
212
+ expect(await rest.cache.get("price:42")).toBeUndefined();
213
+ });
214
+ });
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect, afterAll } from "bun:test";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { logger } from "../utils/logger";
5
+
6
+ const DIR = path.join(import.meta.dir, ".tmp-logger");
7
+
8
+ /** Read the log file and parse every line as JSONL */
9
+ function readLogLines(rel: string): any[] {
10
+ return fs.readFileSync(path.join(DIR, rel), "utf-8")
11
+ .trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
12
+ }
13
+
14
+ async function flush() {
15
+ await logger.flush(); // wait for all pending writes
16
+ await Bun.sleep(10);
17
+ }
18
+
19
+ afterAll(() => {
20
+ try { fs.rmSync(DIR, { recursive: true, force: true }); } catch {}
21
+ logger.configure({ console: false, file: false });
22
+ });
23
+
24
+ describe("logger", () => {
25
+ it("writes JSONL lines with the Caddy envelope", async () => {
26
+ logger.configure({ dir: path.join(DIR, "basic"), file: true, console: false, level: "debug" });
27
+ logger.debug("dbg", { a: 1 });
28
+ logger.info("hello", { port: 4000 });
29
+ logger.warn("careful", { code: "X" });
30
+ logger.error("boom");
31
+ await flush();
32
+ const lines = readLogLines("basic/anteros.log");
33
+ expect(lines.length).toBe(4);
34
+ const hello = lines.find((l) => l.msg === "hello")!;
35
+ expect(hello.level).toBe("info");
36
+ expect(hello.port).toBe(4000);
37
+ expect(typeof hello.ts).toBe("number");
38
+ expect(lines.find((l) => l.msg === "boom")!.level).toBe("error");
39
+ expect(lines.find((l) => l.msg === "dbg")!.level).toBe("debug");
40
+ });
41
+
42
+ it("respects the level filter", async () => {
43
+ logger.configure({ dir: path.join(DIR, "filter"), file: true, console: false, level: "warn" });
44
+ logger.info("hidden-info");
45
+ logger.debug("hidden-debug");
46
+ logger.warn("shown-warn");
47
+ logger.error("shown-error");
48
+ await flush();
49
+ const lines = readLogLines("filter/anteros.log");
50
+ expect(lines.some((l) => l.msg === "hidden-info")).toBe(false);
51
+ expect(lines.some((l) => l.msg === "hidden-debug")).toBe(false);
52
+ expect(lines.some((l) => l.msg === "shown-warn")).toBe(true);
53
+ expect(lines.some((l) => l.msg === "shown-error")).toBe(true);
54
+ });
55
+
56
+ it("supports a custom file path", async () => {
57
+ const custom = path.join(DIR, "custom", "my-app.log");
58
+ logger.configure({ file: custom, console: false });
59
+ logger.info("custom path", { ok: true });
60
+ await flush();
61
+ const lines = fs.readFileSync(custom, "utf-8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
62
+ expect(lines[0]!.msg).toBe("custom path");
63
+ expect(lines[0]!.ok).toBe(true);
64
+ });
65
+
66
+ it("rotates the file when maxSize is exceeded", async () => {
67
+ const dir = path.join(DIR, "rotate");
68
+ logger.configure({ dir, file: true, console: false, maxSize: 200, maxFiles: 3 });
69
+ for (let i = 0; i < 20; i++) logger.info(`line ${i} with some padding ${'x'.repeat(30)}`);
70
+ await flush();
71
+ const rotated = fs.readdirSync(dir).filter((f) => f !== "anteros.log");
72
+ // Dated naming: anteros-YYYY-MM-DD-N.log
73
+ const dated = /^anteros-\d{4}-\d{2}-\d{2}-\d+\.log$/;
74
+ expect(rotated.length).toBeGreaterThan(0);
75
+ expect(rotated.every((f) => dated.test(f))).toBe(true);
76
+ // maxFiles: 3 → never more than 3 rotated files kept
77
+ expect(rotated.length).toBeLessThanOrEqual(3);
78
+ // the live file is never removed
79
+ expect(fs.existsSync(path.join(dir, "anteros.log"))).toBe(true);
80
+ });
81
+
82
+ it("handles circular / non-JSON meta safely", async () => {
83
+ logger.configure({ dir: path.join(DIR, "safe"), file: true, console: false });
84
+ const circular: any = { name: "x" };
85
+ circular.self = circular;
86
+ logger.info("circular meta", circular);
87
+ logger.info("bigint meta", { n: 123n });
88
+ await flush();
89
+ const lines = readLogLines("safe/anteros.log");
90
+ expect(lines.some((l) => l.msg === "circular meta")).toBe(true);
91
+ const raw = fs.readFileSync(path.join(DIR, "safe", "anteros.log"), "utf-8");
92
+ expect(raw).toContain("[Circular]");
93
+ expect(lines.find((l) => l.msg === "bigint meta")!.n).toBe("123");
94
+ });
95
+
96
+ it("keeps user-provided envelope keys", async () => {
97
+ logger.configure({ dir: path.join(DIR, "ts"), file: true, console: false });
98
+ logger.info("custom ts", { ts: 123.5, customField: 1 });
99
+ await flush();
100
+ const lines = readLogLines("ts/anteros.log");
101
+ expect(lines[0]!.ts).toBe(123.5);
102
+ expect(lines[0]!.customField).toBe(1);
103
+ expect(lines[0]!.msg).toBe("custom ts");
104
+ });
105
+
106
+ it("logger.file writes to the file but never to the console", async () => {
107
+ const out: string[] = [];
108
+ const orig = console.log;
109
+ console.log = (...a: any[]) => { out.push(a.join(" ")); };
110
+
111
+ logger.configure({ dir: path.join(DIR, "fileonly"), file: true, console: true });
112
+ logger.file("Server started", { pid: 1 });
113
+ logger.info("visible info", { a: 1 });
114
+ await flush();
115
+ console.log = orig;
116
+
117
+ const lines = readLogLines("fileonly/anteros.log");
118
+ expect(lines.some((l) => l.msg === "Server started")).toBe(true);
119
+ expect(lines.some((l) => l.msg === "visible info")).toBe(true);
120
+ expect(out.some((l) => l.includes("visible info"))).toBe(true);
121
+ expect(out.some((l) => l.includes("Server started"))).toBe(false);
122
+ });
123
+ });
@@ -79,6 +79,12 @@ export type Collection = {
79
79
  actions?: {
80
80
  [key: string]: CollectionAction;
81
81
  };
82
+ /** Per-collection query caching — default TTL for `useCache` queries on this collection */
83
+ cache?: {
84
+ enabled?: boolean;
85
+ /** Default TTL for cached queries — human string ('5m', '2h') or ms */
86
+ ttl?: string | number;
87
+ };
82
88
  /**
83
89
  * The tenant id of the collection
84
90
  * @type {string}
package/types/config.d.ts CHANGED
@@ -58,6 +58,43 @@ export type ServerConfig = {
58
58
  max?: number;
59
59
  };
60
60
  };
61
+ logging?: {
62
+ /** Minimum level emitted (default: 'info') */
63
+ level?: 'debug' | 'info' | 'warn' | 'error';
64
+ /** Also write to the console (default: true) */
65
+ console?: boolean;
66
+ /** File logging: true → <dir>/anteros.log, string → custom path, false → disabled (default: true) */
67
+ file?: boolean | string;
68
+ /** Directory used when `file` is true (default: '.logs') */
69
+ dir?: string;
70
+ /** Rotate when the log file exceeds this size in bytes (default: 10MB) */
71
+ maxSize?: number;
72
+ /** Keep this many rotated files (default: 5) */
73
+ maxFiles?: number;
74
+ /** Log database operations slower than this many ms (default: 200) */
75
+ slowQueryMs?: number;
76
+ };
77
+ /** DB query caching (find + useCache) — driver + defaults for the query cache */
78
+ cache?: {
79
+ enabled?: boolean;
80
+ /** Driver: 'memory' (default), 'filesystem' (.cache/ folder), 'redis' */
81
+ driver?: 'memory' | 'filesystem' | 'redis';
82
+ /** Filesystem driver directory (default: ./.cache) */
83
+ directory?: string;
84
+ /** Redis connection (driver: 'redis') */
85
+ redis?: {
86
+ url?: string;
87
+ host?: string;
88
+ port?: number;
89
+ password?: string;
90
+ };
91
+ /** Filesystem prune interval (default: '1h') */
92
+ pruneInterval?: string;
93
+ /** Default TTL for cached queries — human string ('5m') or ms (default: '5m') */
94
+ ttl?: string | number;
95
+ /** Max `_id` tags per cached entry — large results are tagged up to this count (default: 50000; each tag costs ~0.18µs per cache hit) */
96
+ maxTags?: number;
97
+ };
61
98
  }
62
99
  tenants: Tenant[];
63
100
  }
package/types/mongo.d.ts CHANGED
@@ -50,6 +50,16 @@ export type findOneOptions = {
50
50
  $project?: Record<string, unknown>;
51
51
  }
52
52
 
53
+ /** Third parameter of `find`/`findOne` — query options */
54
+ export type FindCallOptions = {
55
+ /** Serve from the DB query cache — cache-first, populated on miss, invalidated on any write */
56
+ useCache?: boolean;
57
+ /** TTL override for this query — human string ('5m', '2h') or ms (default: collection/server config, else '5m') */
58
+ ttl?: string | number;
59
+ /** Clean undefined/null/empty values from the result (SDK parity) */
60
+ cleanDeep?: boolean;
61
+ }
62
+
53
63
  export type LookupOptions = {
54
64
  from: string;
55
65
  localField: string;