@anteros/core 0.0.30 → 0.0.32

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.
@@ -8,6 +8,22 @@ import type { Field } from "../types/field"
8
8
  import { getTenant } from "./tenant"
9
9
  import { buildSchema } from "./schema"
10
10
  import type { FileCollection } from "../types/file"
11
+
12
+ /**
13
+ * Mongo index options must be real booleans: `unique`/`sparse`/… accept
14
+ * `1`/`0`/`true`/`false` but NEVER `null`/`undefined` (otherwise the server
15
+ * throws `The field 'sparse' has value null, which is not convertible to bool`).
16
+ * Coerce truthiness (`1` → `true`, `0` → `false`) and drop nullish values entirely.
17
+ */
18
+ const BOOLEAN_INDEX_OPTIONS = ['unique', 'sparse', 'background', 'hidden'];
19
+ function applyIndexOptions(spec: IndexDescription, options?: Field['indexOptions']): IndexDescription {
20
+ for (const [k, v] of Object.entries(options ?? {})) {
21
+ if (v === undefined || v === null) continue; // never send null/undefined on the wire
22
+ (spec as unknown as Record<string, unknown>)[k] = BOOLEAN_INDEX_OPTIONS.includes(k) ? !!v : v;
23
+ }
24
+ return spec;
25
+ }
26
+
11
27
  async function syncCollections() {
12
28
  try {
13
29
 
@@ -115,17 +131,17 @@ async function initializeOnDatabase(collections: Collection[]) {
115
131
  }
116
132
  }
117
133
 
118
- let isSparse = field.type === 'random'
119
- || (field.unique && field.required !== true);
134
+ // !! : field.unique peut être 1/0 (Mongo-style) → coercer en vrai booléen
135
+ let isSparse = !!(field.type === 'random'
136
+ || (field.unique && field.required !== true));
120
137
 
121
- specsFieldsIndexes.push({
138
+ specsFieldsIndexes.push(applyIndexOptions({
122
139
  key: {
123
140
  [field.name]: indexValue as any
124
141
  },
125
- unique: field.unique ?? false,
142
+ unique: !!field.unique, // 1/0/true/false → real boolean
126
143
  sparse: isSparse,
127
- ...field.indexOptions, // explicit overrides win
128
- })
144
+ }, field.indexOptions)) // explicit overrides win — sanitized
129
145
 
130
146
  }
131
147
 
@@ -22,7 +22,7 @@ export function LogSlowQuery(): any {
22
22
  const duration = performance.now() - start;
23
23
  const slowMs = cfg.server.logging?.slowQueryMs ?? 200;
24
24
  if (duration >= slowMs) {
25
- logger.warn(`Slow ${propertyKey} (${Math.round(duration)}ms)`, {
25
+ logger.file('warn', `Slow ${propertyKey} (${Math.round(duration)}ms)`, {
26
26
  collection: args[0],
27
27
  tenant: this.tenant_id,
28
28
  duration: Math.round(duration),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anteros/core",
3
3
  "module": "index.ts",
4
- "version": "0.0.30",
4
+ "version": "0.0.32",
5
5
  "type": "module",
6
6
  "description": "Anteros Core",
7
7
  "private": false,
package/server/boot.ts CHANGED
@@ -177,7 +177,8 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
177
177
  const remote: any = (server as any).requestIP?.(req);
178
178
  const remoteIp = remote?.address ?? null;
179
179
  const remotePort = remote?.port ?? null;
180
- logger.info(`${req.method} ${url.pathname}${url.search} → ${res.status}`, {
180
+ // File-only: same as the API access log, no console noise.
181
+ logger.file(`${req.method} ${url.pathname}${url.search} → ${res.status}`, {
181
182
  logger: 'http.log.access',
182
183
  status: res.status,
183
184
  duration: Math.round((performance.now() - start) * 10) / 10,
package/server/hono.ts CHANGED
@@ -234,7 +234,8 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
234
234
  const remoteIp = conn?.remote?.address ?? null;
235
235
  const remotePort = conn?.remote?.port ?? null;
236
236
 
237
- logger.info(`${c.req.method} ${uri} → ${c.res.status}`, {
237
+ // File-only: the per-request access log would flood the console.
238
+ logger.file(`${c.req.method} ${uri} → ${c.res.status}`, {
238
239
  logger: 'http.log.access',
239
240
  status: c.res.status,
240
241
  duration: Math.round(duration * 10) / 10,
package/server/mcp.ts CHANGED
@@ -124,7 +124,7 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
124
124
  description: t.description,
125
125
  inputSchema: joiToJsonSchema(t.inputSchema),
126
126
  }));
127
- logger.info('MCP tools/list', { method: 'tools/list', tenant: tenantId, count: toolsList.length, duration: ms(start) });
127
+ logger.file('MCP tools/list', { method: 'tools/list', tenant: tenantId, count: toolsList.length, duration: ms(start) });
128
128
  return { tools: toolsList };
129
129
  });
130
130
 
@@ -132,14 +132,14 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
132
132
  const start = performance.now();
133
133
  const tool = tools.find((t) => t.name === req.params.name);
134
134
  if (!tool) {
135
- logger.warn('MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, error: 'unknown tool', duration: ms(start) });
135
+ logger.file('warn', 'MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, error: 'unknown tool', duration: ms(start) });
136
136
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${req.params.name}`);
137
137
  }
138
138
 
139
139
  const raw = req.params.arguments ?? {};
140
140
  const { error, value } = tool.inputSchema.validate(raw, { allowUnknown: false });
141
141
  if (error) {
142
- logger.warn('MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, args: raw, error: 'invalid arguments', duration: ms(start) });
142
+ logger.file('warn', 'MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, args: raw, error: 'invalid arguments', duration: ms(start) });
143
143
  return {
144
144
  content: [{ type: 'text' as const, text: `Invalid arguments: ${error.message}` }],
145
145
  isError: true,
@@ -155,7 +155,7 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
155
155
  const text = typeof result === 'string' ? result : JSON.stringify(result);
156
156
  return { content: [{ type: 'text' as const, text }] };
157
157
  } catch (err: any) {
158
- logger.warn('MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, args: value, error: err?.message ?? 'unknown', duration: ms(start) });
158
+ logger.file('warn', 'MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, args: value, error: err?.message ?? 'unknown', duration: ms(start) });
159
159
  return {
160
160
  content: [{ type: 'text' as const, text: `Error: ${err?.message ?? 'unknown'}` }],
161
161
  isError: true,
@@ -173,7 +173,7 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
173
173
  description: r.description,
174
174
  mimeType: r.mimeType,
175
175
  }));
176
- logger.info('MCP resources/list', { method: 'resources/list', tenant: tenantId, count: resourcesList.length, duration: ms(start) });
176
+ logger.file('MCP resources/list', { method: 'resources/list', tenant: tenantId, count: resourcesList.length, duration: ms(start) });
177
177
  return { resources: resourcesList };
178
178
  });
179
179
 
@@ -187,7 +187,7 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
187
187
  description: r.description,
188
188
  mimeType: r.mimeType,
189
189
  }));
190
- logger.info('MCP resources/templates/list', { method: 'resources/templates/list', tenant: tenantId, count: templates.length, duration: ms(start) });
190
+ logger.file('MCP resources/templates/list', { method: 'resources/templates/list', tenant: tenantId, count: templates.length, duration: ms(start) });
191
191
  return { resourceTemplates: templates };
192
192
  });
193
193
 
@@ -196,7 +196,7 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
196
196
  const uri = req.params.uri;
197
197
  const matched = matchUri(uri, resources);
198
198
  if (!matched) {
199
- logger.warn('MCP resources/read', { method: 'resources/read', tenant: tenantId, uri, error: 'unknown uri', duration: ms(start) });
199
+ logger.file('warn', 'MCP resources/read', { method: 'resources/read', tenant: tenantId, uri, error: 'unknown uri', duration: ms(start) });
200
200
  throw new McpError(ErrorCode.InvalidParams, `Unknown resource URI: ${uri}`);
201
201
  }
202
202
 
@@ -208,10 +208,10 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
208
208
  params: matched.params,
209
209
  uri,
210
210
  });
211
- logger.info('MCP resources/read', { method: 'resources/read', tenant: tenantId, uri, resource: matched.resource.name, params: matched.params, duration: ms(start) });
211
+ logger.file('MCP resources/read', { method: 'resources/read', tenant: tenantId, uri, resource: matched.resource.name, params: matched.params, duration: ms(start) });
212
212
  return result;
213
213
  } catch (err: any) {
214
- logger.warn('MCP resources/read', { method: 'resources/read', tenant: tenantId, uri, error: err?.message ?? 'unknown', duration: ms(start) });
214
+ logger.file('warn', 'MCP resources/read', { method: 'resources/read', tenant: tenantId, uri, error: err?.message ?? 'unknown', duration: ms(start) });
215
215
  throw new McpError(ErrorCode.InternalError, `Failed to read resource: ${err?.message ?? 'unknown'}`);
216
216
  }
217
217
  });
@@ -0,0 +1,14 @@
1
+ import { define } from "../../../../index";
2
+
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).
6
+ export default define.Collection({
7
+ slug: "variants",
8
+ fields: [
9
+ { name: "name", type: "string", index: true, indexOptions: { sparse: null as any } },
10
+ { name: "sku", type: "string", unique: 1 },
11
+ { name: "price", type: "number" },
12
+ ],
13
+ api: { access: { "*": true } },
14
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { formatConfig, cfg } from "../server/config";
3
+ import { syncTenants } from "../database/tenant";
4
+ import { syncCollections } from "../database/collection";
5
+
6
+ const TENANT = "idx-test";
7
+ const DIR = "packages/core/tests/fixtures/index-tenant";
8
+ const DB = "mongodb://localhost:27017/_DB_IDX_TEST";
9
+
10
+ // initializeOnDatabase est fire-and-forget dans syncCollections — on attend
11
+ // que les index existent réellement en base.
12
+ async function waitForIndexes(colName: string): Promise<any[]> {
13
+ const db = cfg.tenants?.[0]?.database?.db as any;
14
+ const col = db.collection(colName);
15
+ const deadline = Date.now() + 5000;
16
+ while (Date.now() < deadline) {
17
+ try {
18
+ const idxs = await col.listIndexes().toArray();
19
+ if (idxs.length) return idxs;
20
+ } catch {}
21
+ await Bun.sleep(50);
22
+ }
23
+ throw new Error(`indexes not created for ${colName}`);
24
+ }
25
+
26
+ beforeAll(async () => {
27
+ formatConfig({
28
+ server: { port: 4000 },
29
+ tenants: [{ id: TENANT, dir: DIR, database: { uri: DB } }],
30
+ });
31
+ await syncTenants();
32
+ await syncCollections();
33
+ });
34
+
35
+ afterAll(async () => {
36
+ try { await (cfg.tenants?.[0]?.database?.db as any)?.collection("variants").drop(); } catch {}
37
+ });
38
+
39
+ describe("index spec construction", () => {
40
+ it("drops null/undefined options and coerces Mongo-style 1/0 booleans", async () => {
41
+ const idxs = await waitForIndexes("variants");
42
+
43
+ // name : indexOptions.sparse = null → ignoré (pas de sparse:null), index créé
44
+ const nameIdx = idxs.find((i) => i.name === "name_1");
45
+ expect(nameIdx).toBeDefined();
46
+ expect(nameIdx!.sparse).not.toBe(null);
47
+ expect(nameIdx!.sparse).not.toBe(true); // false / undefined — jamais null
48
+
49
+ // sku : unique: 1 (Mongo-style) → index unique réel
50
+ const skuIdx = idxs.find((i) => i.name === "sku_1");
51
+ expect(skuIdx).toBeDefined();
52
+ expect(skuIdx!.unique).toBe(true);
53
+ });
54
+ });
@@ -121,4 +121,27 @@ describe("logger", () => {
121
121
  expect(out.some((l) => l.includes("visible info"))).toBe(true);
122
122
  expect(out.some((l) => l.includes("Server started"))).toBe(false);
123
123
  });
124
+
125
+ it("logger.file keeps the level when passed explicitly (never on console)", async () => {
126
+ const out: string[] = [];
127
+ const orig = console.log;
128
+ console.log = (...a: any[]) => { out.push(a.join(" ")); };
129
+
130
+ logger.configure({ dir: path.join(DIR, "filelevel"), file: true, console: true, level: "info" });
131
+ logger.file("default level", { a: 1 }); // 2-arg form → level 'info'
132
+ logger.file("warn", "explicit warn", { b: 2 }); // explicit level kept
133
+ logger.file("error", "explicit error", { c: 3 });
134
+ await flush();
135
+ console.log = orig;
136
+
137
+ const lines = readLogLines("filelevel/anteros.log");
138
+ expect(lines.find((l) => l.msg === "default level")!.level).toBe("info");
139
+ expect(lines.find((l) => l.msg === "explicit warn")!.level).toBe("warn");
140
+ expect(lines.find((l) => l.msg === "explicit warn")!.b).toBe(2);
141
+ expect(lines.find((l) => l.msg === "explicit error")!.level).toBe("error");
142
+ // never on the console, whatever the level
143
+ expect(out.some((l) => l.includes("default level"))).toBe(false);
144
+ expect(out.some((l) => l.includes("explicit warn"))).toBe(false);
145
+ expect(out.some((l) => l.includes("explicit error"))).toBe(false);
146
+ });
124
147
  });
package/types/field.d.ts CHANGED
@@ -52,7 +52,7 @@ export type Field = {
52
52
  required?: boolean;
53
53
  enumOptions?: enumOptions;
54
54
  slugOptions?: SlugOptions;
55
- unique?: boolean;
55
+ unique?: boolean | 1 | 0; // Mongo-style: true/1 → unique index, false/0 → non-unique
56
56
  nullable?: boolean;
57
57
  empty?: boolean;
58
58
  defaultValue?: number | string | boolean | object | array<any>;
@@ -63,9 +63,9 @@ export type Field = {
63
63
  | "2d"
64
64
  indexOptions?: {
65
65
  expireAfterSeconds?: number;
66
- sparse?: boolean;
66
+ sparse?: boolean | 1 | 0;
67
67
  version?: number;
68
- unique?: boolean;
68
+ unique?: boolean | 1 | 0;
69
69
  };
70
70
  relation?: {
71
71
  to: string;
package/utils/logger.ts CHANGED
@@ -186,8 +186,20 @@ class Logger {
186
186
  warn(msg: string, meta?: unknown) { this.#write('warn', msg, meta); }
187
187
  error(msg: string, meta?: unknown) { this.#write('error', msg, meta); }
188
188
 
189
- /** Write to the file only (no console output) — for events that would clutter the terminal */
190
- file(msg: string, meta?: unknown) { this.#write('info', msg, meta, { console: false }); }
189
+ /**
190
+ * Write to the file only (no console output) — for framework-internal events
191
+ * (access log, MCP operations, slow queries) that would clutter the terminal.
192
+ * Level defaults to 'info'; `file('warn', msg, meta)` keeps the level.
193
+ */
194
+ file(msg: string, meta?: unknown): void;
195
+ file(level: LogLevel, msg: string, meta?: unknown): void;
196
+ file(levelOrMsg: LogLevel | string, msgOrMeta?: unknown, meta?: unknown): void {
197
+ if (levelOrMsg === 'debug' || levelOrMsg === 'info' || levelOrMsg === 'warn' || levelOrMsg === 'error') {
198
+ this.#write(levelOrMsg, msgOrMeta as string, meta, { console: false });
199
+ } else {
200
+ this.#write('info', levelOrMsg, msgOrMeta, { console: false });
201
+ }
202
+ }
191
203
 
192
204
  /** Current log file path (empty when file logging is disabled) */
193
205
  get filePath(): string { return this.#filePath; }