@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.
- package/database/collection.ts +22 -6
- package/database/decorator.ts +1 -1
- package/package.json +1 -1
- package/server/boot.ts +2 -1
- package/server/hono.ts +2 -1
- package/server/mcp.ts +9 -9
- package/tests/fixtures/index-tenant/collections/variants.model.ts +14 -0
- package/tests/index-spec.test.ts +54 -0
- package/tests/logger.test.ts +23 -0
- package/types/field.d.ts +3 -3
- package/utils/logger.ts +14 -2
package/database/collection.ts
CHANGED
|
@@ -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
|
-
|
|
119
|
-
|
|
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
|
|
142
|
+
unique: !!field.unique, // 1/0/true/false → real boolean
|
|
126
143
|
sparse: isSparse,
|
|
127
|
-
|
|
128
|
-
})
|
|
144
|
+
}, field.indexOptions)) // explicit overrides win — sanitized
|
|
129
145
|
|
|
130
146
|
}
|
|
131
147
|
|
package/database/decorator.ts
CHANGED
|
@@ -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
|
|
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
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
});
|
package/tests/logger.test.ts
CHANGED
|
@@ -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
|
-
/**
|
|
190
|
-
|
|
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; }
|