@anteros/core 0.0.38 → 0.0.40

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.
@@ -131,7 +131,7 @@ async function initializeOnDatabase(collections: Collection[]) {
131
131
  }
132
132
  }
133
133
 
134
- // !! : field.unique peut être 1/0 (Mongo-style) → coercer en vrai booléen
134
+ // !! : field.unique may be 1/0 (Mongo-style) → coerce to a real boolean
135
135
  let isSparse = !!(field.type === 'random'
136
136
  || (field.unique && field.required !== true));
137
137
 
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import path from "node:path";
3
3
  import type { FindOptions, FindCallOptions } from "../types/mongo";
4
- import { Cache, useFilesystemCache, useMemoryCache, useRedisCache } from "../utils/cache";
4
+ import { type Cache, useFilesystemCache, useMemoryCache, useRedisCache } from "../utils/cache";
5
5
  import { cfg } from "../server/config";
6
6
 
7
7
  /**
@@ -128,7 +128,7 @@ export async function encryptUpdate(col: Collection | null, update: Record<strin
128
128
  const pf = pathFields.find((p) => k === p.name || k.startsWith(p.name + "."));
129
129
  if (pf) {
130
130
  const sub = k === pf.name ? null : k.slice(pf.name.length + 1);
131
- // chemin exact, descendant ('zip.sub') ou à travers un index de array ('0.zip')
131
+ // exact path, descendant ('zip.sub') or through an array index ('0.zip')
132
132
  if (sub === null || pf.paths.some((p) => sub === p || sub.startsWith(p + ".") || sub.endsWith("." + p))) {
133
133
  return { kind: "path", field: pf.name, sub, paths: pf.paths };
134
134
  }
@@ -1144,8 +1144,8 @@ class MongoRest {
1144
1144
  missing: (key) => ns().missing({ key }),
1145
1145
  delete: (key) => ns().delete({ key }),
1146
1146
  deleteMany: (keys) => ns().deleteMany({ keys }),
1147
- pull: (key) => ns().pull(key),
1148
- expire: (key, ttl) => ns().expire({ key, ttl }),
1147
+ pull: (key) => ns().pull(key) as Promise<any>, // bentocache types are stricter than the TenantCache contract
1148
+ expire: (key) => ns().expire({ key }), // bentocache soft-expire takes { key } only (grace is a getOrSet option)
1149
1149
  clear: () => ns().clear(),
1150
1150
  namespace: (name) => cache.namespace(`app:${tenantId}:${name}`),
1151
1151
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anteros/core",
3
3
  "module": "index.ts",
4
- "version": "0.0.38",
4
+ "version": "0.0.40",
5
5
  "type": "module",
6
6
  "description": "Anteros Core",
7
7
  "private": false,
package/probe-cache.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { Cache } from "./utils/cache";
1
+ import { useMemoryCache } from "./utils/cache";
2
2
 
3
3
  async function probe() {
4
- const c = new Cache("memory");
4
+ const c = useMemoryCache();
5
5
  const v = await c.get({ key: "k" });
6
- const p = await c.pull({ key: "p" });
6
+ const p = await c.pull("p");
7
7
  const g = await c.getOrSet({ key: "g", factory: () => 42 });
8
8
  const s = await c.set({ key: "a", value: 1 });
9
9
  const h = await c.has({ key: "a" });
package/server/api.ts CHANGED
@@ -15,7 +15,7 @@ import { getTenantMiddlewares } from "../lib/middleware";
15
15
  import type { ActivityInput } from "../types/activity";
16
16
  import * as os from 'node:os';
17
17
  import { basename } from 'node:path';
18
- import { handleUpload, handleServe, handleDelete } from "../lib/files";
18
+ import { handleUpload, handleServe, handleDelete, type FileResult } from "../lib/files";
19
19
  const API_PREFIX = '/api/:tenant_id/:collection/:action';
20
20
  const SERVICE_PREFIX = '/services/:tenant_id/:service/:action';
21
21
  const UPLOAD_PREFIX = '/upload/:tenant_id/:collection';
@@ -457,7 +457,9 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
457
457
  throw new AppError('Content-Type must be multipart/form-data', { code: 'INVALID_CONTENT_TYPE', status: 400 });
458
458
  }
459
459
 
460
- // Faille 11: reject oversized bodies BEFORE parseBody buffers them in memory
460
+ // Security: reject oversized bodies BEFORE parseBody buffers them in memory.
461
+ // `maxSize` doubles as the TOTAL request allowance → a multi-file upload must
462
+ // fit within it (each file is also validated individually in handleUpload).
461
463
  const contentLength = Number(c.req.header('Content-Length') ?? 0);
462
464
  const maxUploadSize = colUpload.upload?.maxSize ?? 10 * 1024 * 1024; // 10MB default
463
465
  // multipart overhead (boundaries/headers) ≈ 64KB max
@@ -467,21 +469,36 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
467
469
  });
468
470
  }
469
471
 
470
- const formData = await c.req.parseBody();
471
- const fileField = formData['file'] || formData['upload'];
472
- if (!fileField || !(fileField instanceof File)) {
472
+ const formData = await c.req.parseBody({ all: true }); // all: true → every value is an array (several files can share the same field name)
473
+
474
+ // Collect files from both accepted field names (single or multiple)
475
+ const files: File[] = [];
476
+ for (const key of ['file', 'upload']) {
477
+ for (const value of formData[key] ?? []) {
478
+ if (value instanceof File) files.push(value);
479
+ }
480
+ }
481
+ if (!files.length) {
473
482
  throw new AppError('No file provided. Use field name "file" or "upload".', { code: 'FILE_REQUIRED', status: 400 });
474
483
  }
475
484
 
485
+ // Metadata: non-file fields. Single value → scalar (unchanged contract),
486
+ // repeated under the same name → array.
476
487
  const data: Record<string, any> = {};
477
488
  for (const [key, value] of Object.entries(formData)) {
478
- if (key !== 'file' && key !== 'upload' && !(value instanceof File)) {
479
- data[key] = value;
480
- }
489
+ if (key === 'file' || key === 'upload') continue;
490
+ const list = (Array.isArray(value) ? value : [value]) as any[];
491
+ if (list.some((v) => v instanceof File)) continue;
492
+ data[key] = list.length === 1 ? list[0] : list;
481
493
  }
482
494
 
483
- const result = await handleUpload({ collection, tenant_id, file: fileField, data });
484
- return c.json(result);
495
+ // One document per file (per-file validation: MIME, size, storage, replication).
496
+ // 1 file → single object (backward compatible), N files → array (SDK contract).
497
+ const results: FileResult[] = [];
498
+ for (const file of files) {
499
+ results.push(await handleUpload({ collection, tenant_id, file, data }));
500
+ }
501
+ return c.json(results.length === 1 ? results[0] : results);
485
502
  } catch (err: any) {
486
503
  if (cfg?.debug) console.error(err)
487
504
  return errorResponse(c, err);
package/server/boot.ts CHANGED
@@ -249,7 +249,7 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
249
249
 
250
250
  // DB query cache status (server.cache) — driver: memory | filesystem | redis
251
251
  const cacheCfg = cfg.server.cache;
252
- // any: @colors/colors type les styles chaînés (.red.bold) comme des fonctions Color
252
+ // any: @colors/colors types chained styles (.red.bold) as Color functions
253
253
  let cacheInfo: any = 'Off'.red.bold;
254
254
  if (cacheCfg?.enabled) {
255
255
  const driver = cacheCfg.driver ?? 'memory';
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach } from "bun:test";
2
- import { Cache, useMemoryCache, useFilesystemCache } from "../utils/cache";
2
+ import { type Cache, useMemoryCache, useFilesystemCache } from "../utils/cache";
3
3
 
4
4
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
5
5
 
@@ -12,16 +12,19 @@ describe("Cache (memory)", () => {
12
12
 
13
13
  it("set + get round-trip", async () => {
14
14
  await c.set({ key: "k", value: { id: 1 }, ttl: "5m" });
15
- expect(await c.get({ key: "k" })).toEqual({ id: 1 });
15
+ const v = await c.get({ key: "k" });
16
+ expect(v).toEqual({ id: 1 });
16
17
  });
17
18
 
18
19
  it("setForever never expires", async () => {
19
20
  await c.setForever({ key: "cfg", value: { theme: "dark" } });
20
- expect(await c.get({ key: "cfg" })).toEqual({ theme: "dark" });
21
+ const v = await c.get({ key: "cfg" });
22
+ expect(v).toEqual({ theme: "dark" });
21
23
  });
22
24
 
23
- it("get returns null on miss", async () => {
24
- expect(await c.get({ key: "nope" })).toBeUndefined();
25
+ it("get returns undefined on miss", async () => {
26
+ const v = await c.get({ key: "nope" });
27
+ expect(v).toBeUndefined();
25
28
  });
26
29
 
27
30
  it("has / missing", async () => {
@@ -33,9 +36,11 @@ describe("Cache (memory)", () => {
33
36
 
34
37
  it("ttl expiry (ms)", async () => {
35
38
  await c.set({ key: "x", value: 1, ttl: 50 });
36
- expect(await c.get({ key: "x" })).toBe(1);
39
+ const v1 = await c.get({ key: "x" });
40
+ expect(v1).toBe(1);
37
41
  await sleep(80);
38
- expect(await c.get({ key: "x" })).toBeUndefined();
42
+ const v2 = await c.get({ key: "x" });
43
+ expect(v2).toBeUndefined();
39
44
  });
40
45
 
41
46
  it("getOrSet uses factory and caches", async () => {
@@ -44,21 +49,25 @@ describe("Cache (memory)", () => {
44
49
  calls++;
45
50
  return { n: calls };
46
51
  };
47
- expect(await c.getOrSet({ key: "g", factory })).toEqual({ n: 1 });
48
- expect(await c.getOrSet({ key: "g", factory })).toEqual({ n: 1 });
52
+ const a = await c.getOrSet({ key: "g", factory });
53
+ expect(a).toEqual({ n: 1 });
54
+ const b = await c.getOrSet({ key: "g", factory });
55
+ expect(b).toEqual({ n: 1 });
49
56
  expect(calls).toBe(1);
50
57
  });
51
58
 
52
59
  it("getOrSetForever", async () => {
53
60
  const value = await c.getOrSetForever({ key: "f", factory: () => 42 });
54
61
  expect(value).toBe(42);
55
- expect(await c.get({ key: "f" })).toBe(42);
62
+ const v = await c.get({ key: "f" });
63
+ expect(v).toBe(42);
56
64
  });
57
65
 
58
66
  it("delete removes entry", async () => {
59
67
  await c.set({ key: "d", value: 1 });
60
68
  await c.delete({ key: "d" });
61
- expect(await c.get({ key: "d" })).toBeUndefined();
69
+ const v = await c.get({ key: "d" });
70
+ expect(v).toBeUndefined();
62
71
  });
63
72
 
64
73
  it("deleteMany removes several keys", async () => {
@@ -71,16 +80,20 @@ describe("Cache (memory)", () => {
71
80
 
72
81
  it("pull returns and removes", async () => {
73
82
  await c.set({ key: "p", value: 7 });
74
- expect(await c.pull("p")).toBe(7);
75
- expect(await c.get({ key: "p" })).toBeUndefined();
83
+ const p = await c.pull("p");
84
+ expect(p).toBe(7);
85
+ const v = await c.get({ key: "p" });
86
+ expect(v).toBeUndefined();
76
87
  });
77
88
 
78
89
  it("clear empties everything", async () => {
79
90
  await c.set({ key: "a", value: 1 });
80
91
  await c.set({ key: "b", value: 2 });
81
92
  await c.clear();
82
- expect(await c.get({ key: "a" })).toBeUndefined();
83
- expect(await c.get({ key: "b" })).toBeUndefined();
93
+ const va = await c.get({ key: "a" });
94
+ const vb = await c.get({ key: "b" });
95
+ expect(va).toBeUndefined();
96
+ expect(vb).toBeUndefined();
84
97
  });
85
98
 
86
99
  it("getOrSet recovers after ttl expiry (grace accepted)", async () => {
@@ -89,19 +102,22 @@ describe("Cache (memory)", () => {
89
102
  expect(v1).toBe("fresh");
90
103
 
91
104
  await sleep(60); // past ttl
92
- expect(await c.get({ key: "e" })).toBeUndefined(); // expired
105
+ const v2 = await c.get({ key: "e" });
106
+ expect(v2).toBeUndefined(); // expired
93
107
 
94
108
  // getOrSet re-runs the factory after expiry
95
- const v2 = await c.getOrSet({ key: "e", factory: () => "fresh2" });
96
- expect(v2).toBe("fresh2");
109
+ const v3 = await c.getOrSet({ key: "e", factory: () => "fresh2" });
110
+ expect(v3).toBe("fresh2");
97
111
  });
98
112
 
99
113
  it("namespace groups keys", async () => {
100
114
  const users = c.namespace("users");
101
115
  await users.set({ key: "1", value: { name: "John" }, ttl: "5m" });
102
116
  await users.set({ key: "2", value: { name: "Jane" } });
103
- expect(await users.get({ key: "1" })).toEqual({ name: "John" });
104
- expect(await c.get({ key: "1" })).toBeUndefined(); // not in root
117
+ const u1 = await users.get({ key: "1" });
118
+ expect(u1).toEqual({ name: "John" });
119
+ const root = await c.get({ key: "1" });
120
+ expect(root).toBeUndefined(); // not in root
105
121
  await users.clear();
106
122
  expect(await users.missing({ key: "1" })).toBe(true);
107
123
  expect(await users.missing({ key: "2" })).toBe(true);
@@ -116,9 +132,11 @@ describe("Cache (filesystem)", () => {
116
132
  await a.disconnect();
117
133
 
118
134
  const b = useFilesystemCache({ directory: dir });
119
- expect(await b.get({ key: "persist" })).toEqual({ ok: true });
135
+ const v = await b.get({ key: "persist" });
136
+ expect(v).toEqual({ ok: true });
120
137
  await b.delete({ key: "persist" });
121
- expect(await b.get({ key: "persist" })).toBeUndefined();
138
+ const gone = await b.get({ key: "persist" });
139
+ expect(gone).toBeUndefined();
122
140
  await b.disconnect();
123
141
  });
124
142
  });
@@ -62,7 +62,7 @@ describe("crypto — symmetric AES-256-GCM", () => {
62
62
 
63
63
  it("decrypt(cipher, true, fields) decrypts the listed sub-fields inside the JSON", async () => {
64
64
  const c = useSymCrypt({ secret: "s" });
65
- // json contenant des sous-champs chiffrés (convention field-level : JSON.stringify)
65
+ // json containing encrypted sub-fields (field-level convention: JSON.stringify)
66
66
  const encZip = await c.encrypt(JSON.stringify("75000"));
67
67
  const encPhone = await c.encrypt(JSON.stringify("0600000000"));
68
68
  const container = await c.encrypt({ city: "Paris", zip: encZip, contact: { phone: encPhone } });
@@ -72,7 +72,7 @@ describe("crypto — symmetric AES-256-GCM", () => {
72
72
  expect(dec.zip).toBe("75000");
73
73
  expect(dec.contact.phone).toBe("0600000000");
74
74
 
75
- // sans les champs → sous-champs toujours chiffrés
75
+ // without the fields → sub-fields stay encrypted
76
76
  const raw = await c.decrypt<any>(container, true);
77
77
  expect(raw.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
78
78
  expect(raw.city).toBe("Paris");
@@ -94,7 +94,7 @@ describe("crypto — symmetric AES-256-GCM", () => {
94
94
  const json = { city: "Paris", note: "plain text", zip: await c.encrypt(JSON.stringify("75000")) };
95
95
  const dec = await c.decrypt<any>(json, true, ["zip", "note", "missing"]);
96
96
  expect(dec.city).toBe("Paris");
97
- expect(dec.note).toBe("plain text"); // pas un ciphertext → inchangé
97
+ expect(dec.note).toBe("plain text"); // not a ciphertext → kept as-is
98
98
  expect(dec.zip).toBe("75000");
99
99
  });
100
100
 
@@ -103,7 +103,7 @@ describe("crypto — symmetric AES-256-GCM", () => {
103
103
  const encZip = await c.encrypt(JSON.stringify("69001"));
104
104
  const encOther = await c.encrypt(JSON.stringify("69002"));
105
105
 
106
- // array conteneur direct (ex: doc.addresses renvoyé par une requête)
106
+ // direct array container (e.g. doc.addresses returned by a query)
107
107
  const arr = [
108
108
  { city: "Lyon", zip: encZip },
109
109
  { city: "Villeurbanne", zip: encOther },
@@ -111,11 +111,11 @@ describe("crypto — symmetric AES-256-GCM", () => {
111
111
  const dec = await c.decrypt<any>(arr, true, ["zip"]);
112
112
  expect(dec[0].zip).toBe("69001");
113
113
  expect(dec[1].zip).toBe("69002");
114
- expect(dec[0].city).toBe("Lyon"); // non-chiffré conservé
115
- expect(dec).not.toBe(arr); // copie, pas de mutation
114
+ expect(dec[0].city).toBe("Lyon"); // non-encrypted value kept
115
+ expect(dec).not.toBe(arr); // copy, no mutation
116
116
  expect(arr[0]!.zip).toMatch(/^[A-Za-z0-9+/=]+$/); // input intact
117
117
 
118
- // ciphertext dont le plaintext est un array de json
118
+ // ciphertext whose plaintext is an array of json
119
119
  const encContainer = await c.encrypt([{ zip: encZip }, { zip: encOther }]);
120
120
  const dec2 = await c.decrypt<any>(encContainer, true, ["zip"]);
121
121
  expect(dec2[0].zip).toBe("69001");
@@ -125,10 +125,10 @@ describe("crypto — symmetric AES-256-GCM", () => {
125
125
 
126
126
  describe("crypto — key rotation (versioned secrets)", () => {
127
127
  it("decrypts old ciphertexts after rotating the secret", async () => {
128
- const old = useSymCrypt({ secret: "v1-secret" }); // version 1 par défaut
128
+ const old = useSymCrypt({ secret: "v1-secret" }); // version 1 by default
129
129
  const enc = await old.encrypt({ apiKey: "sk-rot" });
130
130
 
131
- // Rotation : nouvelle clé active v2, l'ancienne conservée en decrypt-only
131
+ // Rotation: new active key v2, the old one kept for decrypt-only
132
132
  const rotated = useSymCrypt({ secret: "v2-secret", version: 2, previousSecrets: { 1: "v1-secret" } });
133
133
  const dec = await rotated.decrypt<{ apiKey: string }>(enc, true);
134
134
  expect(dec.apiKey).toBe("sk-rot");
@@ -139,7 +139,7 @@ describe("crypto — key rotation (versioned secrets)", () => {
139
139
  const enc = await c.encrypt("data");
140
140
  const buf = Buffer.from(enc, "base64");
141
141
  expect(buf[0]).toBe(2);
142
- // round-trip avec la même instance
142
+ // round-trip with the same instance
143
143
  const dec = await c.decrypt(enc);
144
144
  expect(dec).toBe("data");
145
145
  });
@@ -147,7 +147,7 @@ describe("crypto — key rotation (versioned secrets)", () => {
147
147
  it("still decrypts legacy ciphertexts (no version byte)", async () => {
148
148
  const c = useSymCrypt({ secret: "s" });
149
149
  const enc = await c.encrypt("legacy");
150
- // Simule un ciphertext produit AVANT la rotation (format sans octet de version)
150
+ // Simulates a ciphertext produced BEFORE the rotation (format without a version byte)
151
151
  const legacy = Buffer.from(Buffer.from(enc, "base64").subarray(1)).toString("base64");
152
152
  const dec = await c.decrypt(legacy);
153
153
  expect(dec).toBe("legacy");
@@ -164,11 +164,11 @@ describe("crypto — key rotation (versioned secrets)", () => {
164
164
  const c = useSymCrypt({ secret: "s", aad: "myapp:prod" });
165
165
  const enc = await c.encrypt({ apiKey: "sk-aad" });
166
166
 
167
- // même instance → OK (défaut appliqué au decrypt aussi)
167
+ // same instance → OK (default applied on decrypt too)
168
168
  const dec = await c.decrypt<{ apiKey: string }>(enc, true);
169
169
  expect(dec.apiKey).toBe("sk-aad");
170
170
 
171
- // sans l'AAD config → échec (tag invalide)
171
+ // without the config AAD → fails (invalid tag)
172
172
  const other = useSymCrypt({ secret: "s" });
173
173
  await expect(other.decrypt(enc, true)).rejects.toThrow();
174
174
  });
@@ -178,15 +178,15 @@ describe("crypto — key rotation (versioned secrets)", () => {
178
178
  const enc = await c.encrypt("x", "explicit:ctx");
179
179
  const dec = await c.decrypt(enc, false, "explicit:ctx");
180
180
  expect(dec).toBe("x");
181
- // le défaut ne déchiffre pas le chiffré lié à l'explicite
181
+ // the default cannot decrypt the ciphertext bound to the explicit AAD
182
182
  await expect(c.decrypt(enc, false, "default:ctx")).rejects.toThrow();
183
183
  });
184
184
 
185
185
  it("legacy ciphertexts (no AAD) still decrypt after a default AAD is configured", async () => {
186
- const legacy = useSymCrypt({ secret: "s" }); // chiffré SANS AAD (ancien comportement)
186
+ const legacy = useSymCrypt({ secret: "s" }); // encrypted WITHOUT AAD (legacy behavior)
187
187
  const enc = await legacy.encrypt("old-data");
188
188
 
189
- const c = useSymCrypt({ secret: "s", aad: "myapp:prod" }); // config avec AAD par défaut
189
+ const c = useSymCrypt({ secret: "s", aad: "myapp:prod" }); // config with a default AAD
190
190
  const dec = await c.decrypt(enc);
191
191
  expect(dec).toBe("old-data");
192
192
  });
@@ -223,7 +223,7 @@ describe("crypto — encryption mode (server.encryption.mode)", () => {
223
223
  it("resolve() returns the instance matching the configured mode", async () => {
224
224
  delete (cfg.server as any).encryption;
225
225
  formatConfig({ server: { port: 4000 }, tenants: [] });
226
- const priv = await useAsymCrypt().exportPrivateKey(); // pair fraîche, pas de mode config
226
+ const priv = await useAsymCrypt().exportPrivateKey(); // fresh pair, no configured mode
227
227
 
228
228
  delete (cfg.server as any).encryption;
229
229
  formatConfig({ server: { port: 4000, encryption: { mode: "symmetric", secret: "cfg" } }, tenants: [] });
@@ -24,12 +24,12 @@ beforeAll(async () => {
24
24
  await syncCollections();
25
25
  (cfg as any).collections = [encCollection([
26
26
  { name: "name", type: "string" },
27
- { name: "ssn", type: "string", encryption: true }, // champ chiffré
28
- { name: "profile", type: "json", encryption: true }, // objet chiffré
27
+ { name: "ssn", type: "string", encryption: true }, // whole value encrypted
28
+ { name: "profile", type: "json", encryption: true }, // whole json encrypted
29
29
  { name: "age", type: "number" },
30
- { name: "address", type: "json", encryptionOptions: { path: "zip" } }, // json : seul address.zip est chiffré
31
- { name: "contacts", type: "json", encryptionOptions: { path: ["phone", "email"] } }, // plusieurs chemins
32
- { name: "addresses", type: "json", encryptionOptions: { path: "zip" } }, // array de json
30
+ { name: "address", type: "json", encryptionOptions: { path: "zip" } }, // json: only address.zip encrypted
31
+ { name: "contacts", type: "json", encryptionOptions: { path: ["phone", "email"] } }, // several paths
32
+ { name: "addresses", type: "json", encryptionOptions: { path: "zip" } }, // array of json
33
33
  ])];
34
34
  rest = new useRest({ internal: false, tenant_id: TENANT });
35
35
  });
@@ -44,35 +44,35 @@ describe("field-level encryption", () => {
44
44
  it("insert stores ciphertext in Mongo but returns plaintext", async () => {
45
45
  const doc: any = await rest.insertOne("patients", { name: "John", ssn: "123-45-6789", profile: { city: "Paris", zip: 75000 }, age: 42 });
46
46
 
47
- // réponse API = plaintext
47
+ // API response = plaintext
48
48
  expect(doc.ssn).toBe("123-45-6789");
49
49
  expect(doc.profile).toEqual({ city: "Paris", zip: 75000 });
50
50
 
51
- // en base = ciphertext (version byte + IV + data)
51
+ // in Mongo = ciphertext (version byte + IV + data)
52
52
  const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
53
53
  expect(raw).not.toBeNull();
54
54
  expect(raw!.ssn).not.toBe("123-45-6789");
55
55
  expect(typeof raw!.ssn).toBe("string");
56
56
  expect(raw!.ssn).toMatch(/^[A-Za-z0-9+/=]+$/); // base64
57
57
  expect(raw!.profile).not.toEqual({ city: "Paris" });
58
- // champ non chiffré intact
58
+ // non-encrypted field untouched
59
59
  expect(raw!.name).toBe("John");
60
60
  expect(raw!.age).toBe(42);
61
61
  });
62
62
 
63
63
  it("find / findOne return ciphertext (no decrypt on output)", async () => {
64
64
  const list: any = await rest.find("patients", { $match: { name: "John" } });
65
- // lecture = ciphertext (jamais décrypté automatiquement)
65
+ // read = ciphertext (never decrypted automatically)
66
66
  expect(list[0].ssn).not.toBe("123-45-6789");
67
67
  expect(list[0].ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
68
68
  expect(list[0].profile).not.toEqual({ city: "Paris", zip: 75000 });
69
- // champs non chiffrés intacts
69
+ // non-encrypted fields untouched
70
70
  expect(list[0].name).toBe("John");
71
71
 
72
72
  const one: any = await rest.findOne("patients", list[0]._id);
73
73
  expect(one.ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
74
74
 
75
- // décryptage MANUEL explicite (utils.crypt.resolve())
75
+ // explicit MANUAL decryption (utils.crypt.resolve())
76
76
  const cipher = resolve();
77
77
  const dec = await cipher.decrypt<string>(one.ssn, true);
78
78
  expect(dec).toBe("123-45-6789");
@@ -82,10 +82,10 @@ describe("field-level encryption", () => {
82
82
  const doc: any = await rest.findOne("patients", (await rest.find("patients", { $match: { name: "John" } }))[0]!._id);
83
83
  const updated: any = await rest.updateOne("patients", doc._id, { $set: { ssn: "987-65-4321" } });
84
84
 
85
- // retour = ciphertext (plus de décryptage automatique)
85
+ // response = ciphertext (no automatic decryption anymore)
86
86
  expect(updated.ssn).not.toBe("987-65-4321");
87
87
  expect(updated.ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
88
- // base = nouveau ciphertext
88
+ // stored = new ciphertext
89
89
  const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
90
90
  expect(raw).not.toBeNull();
91
91
  expect(raw!.ssn).not.toBe("987-65-4321");
@@ -99,17 +99,17 @@ describe("field-level encryption", () => {
99
99
  const raw = await rest.db.collection("patients").find({ name: { $in: ["A", "B"] } }).toArray();
100
100
  for (const r of raw) expect(r.ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
101
101
  const list: any = await rest.find("patients", { $match: { name: { $in: ["A", "B"] } } });
102
- // lecture = ciphertext, décrypté manuellement
102
+ // read = ciphertext, decrypted manually
103
103
  for (const d of list) expect(d.ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
104
104
  });
105
105
 
106
106
  it("rejects operators that cannot work on ciphertext ($inc on an encrypted field)", async () => {
107
107
  const doc: any = await rest.findOne("patients", (await rest.find("patients", { $match: { name: "John" } }))[0]!._id);
108
108
  await expect(
109
- rest.updateOne("patients", doc._id, { $set: { ssn: "x" }, $inc: { age: 1 } }) // $inc sur age (non chiffré) OK
109
+ rest.updateOne("patients", doc._id, { $set: { ssn: "x" }, $inc: { age: 1 } }) // $inc on age (not encrypted) OK
110
110
  ).resolves.toBeTruthy();
111
111
  await expect(
112
- rest.updateOne("patients", doc._id, { $set: { ssn: "y" }, $inc: { ssn: 1 } as any }) // $inc sur champ chiffré → rejeté
112
+ rest.updateOne("patients", doc._id, { $set: { ssn: "y" }, $inc: { ssn: 1 } as any }) // $inc on an encrypted field → rejected
113
113
  ).rejects.toThrow(/ENCRYPTED_FIELD_OPERATOR|not supported/);
114
114
  });
115
115
 
@@ -119,7 +119,7 @@ describe("field-level encryption", () => {
119
119
  await expect(rest.insertOne("patients", { name: "NoCfg", ssn: "000-00-0000" }))
120
120
  .rejects.toThrow(/ENCRYPTION_NOT_CONFIGURED|server\.encryption/);
121
121
 
122
- // restore config pour les tests suivants
122
+ // restore the config for the following tests
123
123
  formatConfig({ server: { port: 4000, encryption: { mode: "symmetric", secret: "field-enc-secret" } }, tenants: [] });
124
124
  });
125
125
  });
@@ -130,22 +130,22 @@ describe("field-level encryption — encryptionOptions.path (json sub-paths)", (
130
130
  name: "Alice",
131
131
  address: { street: "Rue X", city: "Paris", zip: "75000" },
132
132
  });
133
- // réponse = plaintext partout
133
+ // response = plaintext everywhere
134
134
  expect(doc.address.zip).toBe("75000");
135
135
  expect(doc.address.city).toBe("Paris");
136
136
 
137
- // en base : seul zip est ciphertext, le reste est en clair
137
+ // in Mongo: only zip is ciphertext, the rest stays in clear
138
138
  const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
139
139
  expect(raw!.address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
140
140
  expect(raw!.address.city).toBe("Paris");
141
141
  expect(raw!.address.street).toBe("Rue X");
142
142
 
143
- // lecture rest = ciphertext au path, plaintext ailleurs
143
+ // rest read = ciphertext at the path, plaintext elsewhere
144
144
  const list: any = await rest.find("patients", { $match: { name: "Alice" } });
145
145
  expect(list[0].address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
146
146
  expect(list[0].address.city).toBe("Paris");
147
147
 
148
- // décryptage manuel
148
+ // manual decryption
149
149
  const cipher = resolve();
150
150
  const zip = await cipher.decrypt<string>(list[0].address.zip, true);
151
151
  expect(zip).toBe("75000");
@@ -158,10 +158,10 @@ describe("field-level encryption — encryptionOptions.path (json sub-paths)", (
158
158
  addresses: [{ zip: "69001" }, { zip: "69002", city: "Lyon" }],
159
159
  });
160
160
  const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
161
- // array de json : chaque élément chiffré sur son zip
161
+ // array of json: every element encrypted on its zip
162
162
  expect(raw!.addresses[0].zip).toMatch(/^[A-Za-z0-9+/=]+$/);
163
163
  expect(raw!.addresses[1].zip).toMatch(/^[A-Za-z0-9+/=]+$/);
164
- expect(raw!.addresses[1].city).toBe("Lyon"); // non-chiffré intact
164
+ expect(raw!.addresses[1].city).toBe("Lyon"); // non-encrypted value untouched
165
165
  // multiple paths
166
166
  expect(raw!.contacts.phone).toMatch(/^[A-Za-z0-9+/=]+$/);
167
167
  expect(raw!.contacts.email).toMatch(/^[A-Za-z0-9+/=]+$/);
@@ -171,12 +171,12 @@ describe("field-level encryption — encryptionOptions.path (json sub-paths)", (
171
171
  it("encrypts the path on $set replacement and dot-notation $set", async () => {
172
172
  const doc: any = await rest.findOne("patients", (await rest.find("patients", { $match: { name: "Alice" } }))[0]!._id);
173
173
 
174
- // $set du json complet → zip re-chiffré dans le remplacement
174
+ // $set of the whole json → zip re-encrypted inside the replacement
175
175
  const up1: any = await rest.updateOne("patients", doc._id, { $set: { address: { city: "Nice", zip: "06000" } } });
176
176
  expect(up1.address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
177
177
  expect(up1.address.city).toBe("Nice");
178
178
 
179
- // dot-notation $set sur le path exact → valeur chiffrée
179
+ // dot-notation $set on the exact path → value encrypted
180
180
  const up2: any = await rest.updateOne("patients", doc._id, { $set: { "address.zip": "06100" } });
181
181
  expect(up2.address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
182
182
  const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
@@ -1,8 +1,8 @@
1
1
  import { define } from "../../../../index";
2
2
 
3
3
  // Reproduces the Mongo error `sparse: null is not convertible to bool`:
4
- // `indexOptions.sparse` explicit à null doit être ignoré (jamais envoyé à Mongo),
5
- // et `unique` accepte le style Mongo 1/0 (coercé en vrai booléen).
4
+ // an explicit `indexOptions.sparse: null` must be ignored (never sent to Mongo),
5
+ // and `unique` accepts the Mongo 1/0 style (coerced to a real boolean).
6
6
  export default define.Collection({
7
7
  slug: "variants",
8
8
  fields: [
@@ -7,8 +7,8 @@ const TENANT = "idx-test";
7
7
  const DIR = "packages/core/tests/fixtures/index-tenant";
8
8
  const DB = "mongodb://localhost:27017/_DB_IDX_TEST";
9
9
 
10
- // initializeOnDatabase est fire-and-forget dans syncCollections — on attend
11
- // que les index existent réellement en base.
10
+ // initializeOnDatabase is fire-and-forget inside syncCollections — wait until the
11
+ // indexes really exist in the database.
12
12
  async function waitForIndexes(colName: string): Promise<any[]> {
13
13
  const db = cfg.tenants?.[0]?.database?.db as any;
14
14
  const col = db.collection(colName);
@@ -40,13 +40,13 @@ describe("index spec construction", () => {
40
40
  it("drops null/undefined options and coerces Mongo-style 1/0 booleans", async () => {
41
41
  const idxs = await waitForIndexes("variants");
42
42
 
43
- // name : indexOptions.sparse = null → ignoré (pas de sparse:null), index créé
43
+ // name: indexOptions.sparse = null → ignored (no sparse:null sent), index created
44
44
  const nameIdx = idxs.find((i) => i.name === "name_1");
45
45
  expect(nameIdx).toBeDefined();
46
46
  expect(nameIdx!.sparse).not.toBe(null);
47
- expect(nameIdx!.sparse).not.toBe(true); // false / undefined — jamais null
47
+ expect(nameIdx!.sparse).not.toBe(true); // false / undefined — never null
48
48
 
49
- // sku : unique: 1 (Mongo-style) → index unique réel
49
+ // sku: unique: 1 (Mongo-style) → a real unique index
50
50
  const skuIdx = idxs.find((i) => i.name === "sku_1");
51
51
  expect(skuIdx).toBeDefined();
52
52
  expect(skuIdx!.unique).toBe(true);
@@ -0,0 +1,138 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { createApp } from "../server/hono";
3
+ import { formatConfig, cfg } from "../server/config";
4
+ import { syncTenants } from "../database/tenant";
5
+ import { getFileCollection } from "../database/file";
6
+ import { useRest } from "../database/rest";
7
+
8
+ const TENANT = "up-test";
9
+ const DB = "mongodb://localhost:27017/_DB_UP_TEST";
10
+ const SLUG = "photos";
11
+
12
+ let rest: InstanceType<typeof useRest>;
13
+ let server: any;
14
+ let url = "";
15
+
16
+ beforeAll(async () => {
17
+ formatConfig({
18
+ server: { port: 4000 },
19
+ tenants: [{ id: TENANT, dir: "up-tenant", database: { uri: DB } }],
20
+ });
21
+ await syncTenants();
22
+
23
+ // File collection (disk storage) — no syncing from disk, declared directly
24
+ (cfg as any).fileCollections = [{
25
+ _tenant_: TENANT,
26
+ _isFileCollection_: true,
27
+ slug: SLUG,
28
+ fields: [{ name: "label", type: "string" }],
29
+ upload: { allowedMimeTypes: ["image/png", "text/plain"], maxSize: 5 * 1024 * 1024 },
30
+ storage: { driver: "disk" },
31
+ api: { access: { "*": true } },
32
+ }];
33
+
34
+ rest = new useRest({ internal: false, tenant_id: TENANT });
35
+ const app = createApp();
36
+ server = Bun.serve({ port: 0, fetch: app.fetch });
37
+ url = server.url.href.replace(/\/$/, "");
38
+ });
39
+
40
+ afterAll(async () => {
41
+ try { await rest.db.collection(SLUG).drop(); } catch {}
42
+ // clean the uploaded files
43
+ try {
44
+ const fs = await import("node:fs/promises");
45
+ await fs.rm(`${process.cwd()}/storage/${TENANT}/${SLUG}`, { recursive: true, force: true });
46
+ } catch {}
47
+ try { server.stop(true); } catch {}
48
+ });
49
+
50
+ function pngFile(name: string) {
51
+ // minimal valid PNG (1x1) so content detection sees a real image
52
+ const bytes = Buffer.from(
53
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
54
+ "base64"
55
+ );
56
+ return new File([bytes], name, { type: "image/png" });
57
+ }
58
+
59
+ describe("upload — single file (unchanged contract)", () => {
60
+ it("returns a single object", async () => {
61
+ const form = new FormData();
62
+ form.append("file", pngFile("one.png"));
63
+ form.append("label", "single");
64
+
65
+ const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
66
+ expect(res.status).toBe(200);
67
+
68
+ const body: any = await res.json();
69
+ expect(Array.isArray(body)).toBe(false); // ← object, not an array
70
+ expect(body._id).toBeString();
71
+ expect(body._file.name).toBe("one.png");
72
+ expect(body._file.mimetype).toBe("image/png");
73
+ expect(body._file.url).toContain(`/files/${TENANT}/${SLUG}/`);
74
+
75
+ // metadata persisted on the document
76
+ const doc: any = await rest.findOne(SLUG, body._id);
77
+ expect(doc.label).toBe("single");
78
+ });
79
+
80
+ it("accepts the 'upload' field name too", async () => {
81
+ const form = new FormData();
82
+ form.append("upload", pngFile("alt.png"));
83
+
84
+ const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
85
+ expect(res.status).toBe(200);
86
+ const body: any = await res.json();
87
+ expect(Array.isArray(body)).toBe(false);
88
+ expect(body._file.name).toBe("alt.png");
89
+ });
90
+ });
91
+
92
+ describe("upload — multiple files", () => {
93
+ it("returns an array and creates one document per file", async () => {
94
+ const form = new FormData();
95
+ form.append("file", pngFile("a.png"));
96
+ form.append("file", pngFile("b.png"));
97
+ form.append("file", pngFile("c.png"));
98
+ form.append("label", "batch");
99
+
100
+ const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
101
+ expect(res.status).toBe(200);
102
+
103
+ const body: any = await res.json();
104
+ expect(Array.isArray(body)).toBe(true);
105
+ expect(body.length).toBe(3);
106
+ expect(body.map((f: any) => f._file.name).sort()).toEqual(["a.png", "b.png", "c.png"]);
107
+
108
+ // every file is its own document, all ids distinct, metadata shared
109
+ const ids = body.map((f: any) => f._id);
110
+ expect(new Set(ids).size).toBe(3);
111
+ for (const id of ids) {
112
+ const doc: any = await rest.findOne(SLUG, id);
113
+ expect(doc.label).toBe("batch");
114
+ expect(doc._file.url).toContain(`/files/${TENANT}/${SLUG}/`);
115
+ }
116
+ });
117
+
118
+ it("still rejects a request without any file", async () => {
119
+ const form = new FormData();
120
+ form.append("label", "nofile");
121
+
122
+ const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
123
+ expect(res.status).toBe(400);
124
+ const body: any = await res.json();
125
+ expect(body.code).toBe("FILE_REQUIRED");
126
+ });
127
+
128
+ it("rejects the whole batch on an invalid file (per-file validation)", async () => {
129
+ const form = new FormData();
130
+ form.append("file", pngFile("ok.png"));
131
+ form.append("file", new File([Buffer.from("nope")], "bad.exe", { type: "application/x-msdownload" }));
132
+
133
+ const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
134
+ expect(res.status).toBe(400);
135
+ const body: any = await res.json();
136
+ expect(body.code).toBe("MIMETYPE_NOT_ALLOWED");
137
+ });
138
+ });
package/utils/cache.ts CHANGED
@@ -3,11 +3,9 @@ import { memoryDriver } from "bentocache/drivers/memory";
3
3
  import { redisDriver } from "bentocache/drivers/redis";
4
4
  import { fileDriver } from "bentocache/drivers/file";
5
5
  import path from "path";
6
- const drivers = {
7
- memoryDriver,
8
- redisDriver,
9
- fileDriver,
10
- }
6
+
7
+ /** The underlying BentoCache instance — full provider API, with autocompletion */
8
+ export type Cache = InstanceType<typeof BentoCache>;
11
9
 
12
10
  type Driver = 'memory' | 'redis' | 'filesystem';
13
11
 
@@ -25,50 +23,27 @@ type CacheOptions = {
25
23
  pruneInterval?: string;
26
24
  }
27
25
 
28
- class Cache {
29
- #cache: InstanceType<typeof BentoCache>;
30
- constructor(driver: Driver, options?: CacheOptions) {
31
- let store = bentostore().useL1Layer(memoryDriver({
32
- }))
33
-
34
- if (driver == 'filesystem') {
35
- store = store.useL2Layer(fileDriver({
36
- directory: options?.filesystem?.directory ?? path.join(process.cwd(), '.cache'),
37
- pruneInterval: options?.pruneInterval ?? '1h'
38
- }))
39
- }
26
+ function createCache(driver: Driver, options?: CacheOptions): Cache {
27
+ let store = bentostore().useL1Layer(memoryDriver({}))
40
28
 
41
- if (driver == 'redis' && options?.redis?.connection) {
42
- store = store.useL2Layer(redisDriver({
43
- connection: options.redis.connection
44
- }))
45
- }
46
- this.#cache = new BentoCache({
47
- default: driver,
48
- stores: {
49
- [driver]: store,
50
- }
51
- })
29
+ if (driver == 'filesystem') {
30
+ store = store.useL2Layer(fileDriver({
31
+ directory: options?.filesystem?.directory ?? path.join(process.cwd(), '.cache'),
32
+ pruneInterval: options?.pruneInterval ?? '1h'
33
+ }))
52
34
  }
53
35
 
54
- // ─── BentoCache provider API (delegated to the underlying instance) ───
55
-
56
- set(options: any): Promise<boolean> { return this.#cache.set(options); }
57
- setForever(options: any): Promise<boolean> { return this.#cache.setForever(options); }
58
- get(options: any): Promise<unknown> { return this.#cache.get(options); }
59
- getOrSet(options: any): Promise<unknown> { return this.#cache.getOrSet(options); }
60
- getOrSetForever(options: any): Promise<unknown> { return this.#cache.getOrSetForever(options); }
61
- has(options: any): Promise<boolean> { return this.#cache.has(options); }
62
- missing(options: any): Promise<boolean> { return this.#cache.missing(options); }
63
- pull(key: string): Promise<unknown> { return this.#cache.pull(key); }
64
- delete(options: any): Promise<boolean> { return this.#cache.delete(options); }
65
- deleteMany(options: any): Promise<boolean> { return this.#cache.deleteMany(options); }
66
- deleteByTag(options: any): Promise<boolean> { return this.#cache.deleteByTag(options); }
67
- expire(options: any): Promise<boolean> { return this.#cache.expire(options); }
68
- clear(options?: any): Promise<void> { return this.#cache.clear(options); }
69
- prune(): Promise<void> { return this.#cache.prune(); }
70
- namespace(namespace: string): any { return this.#cache.namespace(namespace); }
71
- disconnect(): Promise<void> { return this.#cache.disconnect(); }
36
+ if (driver == 'redis' && options?.redis?.connection) {
37
+ store = store.useL2Layer(redisDriver({
38
+ connection: options.redis.connection
39
+ }))
40
+ }
41
+ return new BentoCache({
42
+ default: driver,
43
+ stores: {
44
+ [driver]: store,
45
+ }
46
+ })
72
47
  }
73
48
 
74
49
 
@@ -79,8 +54,8 @@ function useRedisCache(options: {
79
54
  password: string;
80
55
  };
81
56
  pruneInterval?: string;
82
- }) {
83
- return new Cache('redis', {
57
+ }): Cache {
58
+ return createCache('redis', {
84
59
  redis: {
85
60
  connection: options.connection,
86
61
  },
@@ -88,15 +63,15 @@ function useRedisCache(options: {
88
63
  });
89
64
  }
90
65
 
91
- function useMemoryCache() {
92
- return new Cache('memory');
66
+ function useMemoryCache(): Cache {
67
+ return createCache('memory');
93
68
  }
94
69
 
95
70
  function useFilesystemCache(options: {
96
71
  directory: string;
97
72
  pruneInterval?: string;
98
- }) {
99
- return new Cache('filesystem', {
73
+ }): Cache {
74
+ return createCache('filesystem', {
100
75
  filesystem: {
101
76
  directory: options.directory,
102
77
  },
@@ -106,9 +81,8 @@ function useFilesystemCache(options: {
106
81
 
107
82
 
108
83
 
109
- export default Cache;
110
84
  export {
111
- Cache,
85
+
112
86
  useFilesystemCache,
113
87
  useMemoryCache,
114
88
  useRedisCache
package/bench-cache.ts DELETED
@@ -1,70 +0,0 @@
1
- // Scratch benchmark — `bun run bench-cache.ts` (depuis packages/core)
2
- // Vérifie que useCache fonctionne vraiment et mesure les ms (miss vs hit vs sans cache).
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 TENANT = "bench";
9
- const DB = "mongodb://localhost:27017/_DB_BENCH";
10
- const N = 10000; // docs seedés
11
-
12
- async function main() {
13
- formatConfig({
14
- server: { port: 4000, cache: { enabled: true, driver: "memory" } },
15
- tenants: [{ id: TENANT, dir: "bench-tenant", database: { uri: DB } }],
16
- });
17
- await syncTenants();
18
- await syncCollections();
19
-
20
- (cfg as any).collections = [
21
- { _tenant_: TENANT, slug: "items", fields: [{ name: "title", type: "string" }, { name: "count", type: "number" }], api: { access: { "*": true } } },
22
- ];
23
- const rest = new useRest({ internal: false, tenant_id: TENANT });
24
- const col = rest.db.collection("items");
25
-
26
- // Seed
27
- console.log(`Seeding ${N} docs…`);
28
- const batch = [];
29
- for (let i = 0; i < N; i++) batch.push({ title: `item-${i % 100}`, count: i });
30
- for (let i = 0; i < batch.length; i += 1000) await col.insertMany(batch.slice(i, i + 1000));
31
- console.log("Seeded ✓");
32
-
33
- const params = { $match: { count: { $gte: 1000, $lte: 9000 } }, $sort: { count: -1 }, $limit: 50 };
34
-
35
- // 1. Sans cache — moyenne sur 100 requêtes
36
- const t0 = performance.now();
37
- const ITERS = 100;
38
- for (let i = 0; i < ITERS; i++) await rest.find("items", params);
39
- const noCacheAvg = (performance.now() - t0) / ITERS;
40
-
41
- // 2. Avec cache — 1er appel (MISS → requête DB) puis hits
42
- const tMiss = performance.now();
43
- const r1 = await rest.find("items", params, { useCache: true });
44
- const missMs = performance.now() - tMiss;
45
- console.log(`MISS: ${r1.length} docs retournés (requête DB réelle)`);
46
-
47
- const tHits = performance.now();
48
- for (let i = 0; i < ITERS; i++) await rest.find("items", params, { useCache: true });
49
- const hitAvg = (performance.now() - tHits) / ITERS;
50
-
51
- // 3. Preuve que le hit sert BIEN depuis le cache (pas la DB) :
52
- // on mute DIRECTEMENT en DB (bypass framework) un doc présent dans le résultat.
53
- await col.updateMany({ count: 9000 }, { $set: { title: "MUTATED-DIRECT-DB" } });
54
- const cached = await rest.find("items", params, { useCache: true });
55
- const dbFresh = await rest.find("items", params);
56
- const stale = cached.some((d: any) => d.title === "MUTATED-DIRECT-DB");
57
- const fresh = dbFresh.some((d: any) => d.title === "MUTATED-DIRECT-DB");
58
-
59
- console.log("\n─── RÉSULTATS (driver memory, Mongo local) ───");
60
- console.log(`Sans cache : ${noCacheAvg.toFixed(3)} ms/requête (${ITERS} requêtes)`);
61
- console.log(`MISS (cache): ${missMs.toFixed(3)} ms (1er appel → requête DB, résultat mis en cache)`);
62
- console.log(`HIT (cache) : ${hitAvg.toFixed(3)} ms/requête (${ITERS} requêtes)`);
63
- console.log(`Gain : ${(noCacheAvg / hitAvg).toFixed(0)}× plus rapide`);
64
- console.log(`Preuve cache: cache sert ${stale ? "la VALEUR MUTÉE (bug! cache pas servi)" : "l'ANCIENNE valeur (cache servi ✓)"}, requête fraîche = ${fresh ? "nouvelle valeur (DB bien mutée ✓)" : "?"}`);
65
-
66
- await col.drop();
67
- process.exit(0);
68
- }
69
-
70
- main().catch((e) => { console.error(e); process.exit(1); });