@anteros/core 0.0.25 → 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/database/db.cache.ts +197 -0
- package/database/decorator.ts +32 -0
- package/database/mongodbadapter.ts +244 -32
- package/index.ts +2 -0
- package/package.json +1 -1
- package/server/api.ts +1 -1
- package/server/boot.ts +65 -2
- package/server/hono.ts +63 -0
- package/server/mcp.ts +34 -12
- package/server/routes.ts +10 -0
- package/tests/api.test.ts +217 -3
- package/tests/db-cache.test.ts +214 -0
- package/tests/logger.test.ts +123 -0
- package/types/collection.d.ts +6 -0
- package/types/config.d.ts +37 -0
- package/types/mongo.d.ts +10 -0
- package/types/rest.d.ts +37 -0
- package/utils/cache.ts +2 -2
- package/utils/func.ts +3 -0
- package/utils/logger.ts +212 -0
package/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import * as crypto from "./utils/crypto";
|
|
|
13
13
|
|
|
14
14
|
// BentoCache-based caching (memory L1 + filesystem/Redis L2)
|
|
15
15
|
import { useMemoryCache, useFilesystemCache, useRedisCache } from "./utils/cache";
|
|
16
|
+
import { logger } from "./utils/logger";
|
|
16
17
|
const cache = {
|
|
17
18
|
useMemoryCache,
|
|
18
19
|
useFilesystemCache,
|
|
@@ -31,4 +32,5 @@ export {
|
|
|
31
32
|
utils,
|
|
32
33
|
cache,
|
|
33
34
|
crypto,
|
|
35
|
+
logger,
|
|
34
36
|
}
|
package/package.json
CHANGED
package/server/api.ts
CHANGED
|
@@ -174,7 +174,7 @@ type CrudHandler = (rest: InstanceType<typeof useRest>, collection: string, body
|
|
|
174
174
|
|
|
175
175
|
const crudHandlers: Record<string, CrudHandler> = {
|
|
176
176
|
aggregate: (rest, collection, body) => rest.aggregate(collection, body?.pipeline || []),
|
|
177
|
-
find: (rest, collection, body) => rest.find(collection, body?.params || {}),
|
|
177
|
+
find: (rest, collection, body) => rest.find(collection, body?.params || {}, { useCache: body?.options?.useCache }),
|
|
178
178
|
findOne: (rest, collection, body) => rest.findOne(collection, body?.id, body?.params || {}),
|
|
179
179
|
insertOne: (rest, collection, body) => rest.insertOne(collection, body?.data),
|
|
180
180
|
insertMany: (rest, collection, body) => rest.insertMany(collection, body?.data),
|
package/server/boot.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { createApp } from './hono'
|
|
2
|
+
import { createApp, collectHeaders } from './hono'
|
|
3
3
|
import type { ServerConfig } from '../types/config'
|
|
4
4
|
import dayjs from 'dayjs';
|
|
5
5
|
import pkg from '../package.json';
|
|
@@ -23,6 +23,7 @@ import os from 'node:os';
|
|
|
23
23
|
import { registerMetrics, getMetrics, trackRequest } from './metrics';
|
|
24
24
|
import { startMaster, aggregateMetrics } from './cluster';
|
|
25
25
|
import tcpPortUsed from 'tcp-port-used';
|
|
26
|
+
import { logger } from '../utils/logger';
|
|
26
27
|
|
|
27
28
|
type BootAppOptions = ServerConfig & {
|
|
28
29
|
|
|
@@ -34,6 +35,15 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
|
34
35
|
// Load required resources
|
|
35
36
|
//******************************* */
|
|
36
37
|
options = formatConfig(options);
|
|
38
|
+
|
|
39
|
+
// Logging: configure from cfg.server.logging (console + file, e.g. logs/anteros.log)
|
|
40
|
+
logger.configure(cfg.server.logging);
|
|
41
|
+
logger.info('Starting Anteros server', {
|
|
42
|
+
name: cfg.server.name ?? process.env.APP_NAME ?? 'SERVER',
|
|
43
|
+
port: cfg.server.port ?? 4000,
|
|
44
|
+
env: process.env.NODE_ENV || Bun.env.NODE_ENV || 'dev',
|
|
45
|
+
tenants: cfg.tenants?.map((t) => t.id),
|
|
46
|
+
});
|
|
37
47
|
await syncTenants(); // sync tenants and connect to database
|
|
38
48
|
await syncCollections(); // sync collections and create collections on database
|
|
39
49
|
await syncFileCollections(); // sync file collections
|
|
@@ -160,7 +170,39 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
|
160
170
|
});
|
|
161
171
|
|
|
162
172
|
if (url.pathname === "/socket.io/") {
|
|
163
|
-
|
|
173
|
+
const start = performance.now();
|
|
174
|
+
return respond(engineIo.handleRequest(req, server)).then((res) => {
|
|
175
|
+
// Access log for the Socket.IO upgrade path (handled outside Hono) —
|
|
176
|
+
// same Caddy-compatible structure as the API access log
|
|
177
|
+
const remote: any = (server as any).requestIP?.(req);
|
|
178
|
+
const remoteIp = remote?.address ?? null;
|
|
179
|
+
const remotePort = remote?.port ?? null;
|
|
180
|
+
logger.info(`${req.method} ${url.pathname}${url.search} → ${res.status}`, {
|
|
181
|
+
logger: 'http.log.access',
|
|
182
|
+
status: res.status,
|
|
183
|
+
duration: Math.round((performance.now() - start) * 10) / 10,
|
|
184
|
+
method: req.method,
|
|
185
|
+
uri: url.pathname + url.search,
|
|
186
|
+
remote_ip: remoteIp,
|
|
187
|
+
bytes_read: Number(req.headers.get('content-length')) || 0,
|
|
188
|
+
size: Number(res.headers.get('content-length')) || 0,
|
|
189
|
+
user_id: '',
|
|
190
|
+
ip: remoteIp ?? 'unknown',
|
|
191
|
+
traceId: null,
|
|
192
|
+
tenant: null,
|
|
193
|
+
request: {
|
|
194
|
+
headers: collectHeaders(req.headers),
|
|
195
|
+
client_ip: remoteIp ?? 'unknown',
|
|
196
|
+
host: req.headers.get('host') ?? '',
|
|
197
|
+
method: req.method,
|
|
198
|
+
remote_ip: remoteIp,
|
|
199
|
+
remote_port: remotePort,
|
|
200
|
+
uri: url.pathname + url.search,
|
|
201
|
+
},
|
|
202
|
+
resp_headers: collectHeaders(res.headers),
|
|
203
|
+
});
|
|
204
|
+
return res;
|
|
205
|
+
});
|
|
164
206
|
}
|
|
165
207
|
return respond(app.fetch(req, server));
|
|
166
208
|
},
|
|
@@ -224,8 +266,29 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
|
224
266
|
|
|
225
267
|
}, 150);
|
|
226
268
|
|
|
269
|
+
// Boot event — recorded in the log file only (the console banner already
|
|
270
|
+
// shows name / PID / role / URL, so this line would be redundant on screen)
|
|
271
|
+
logger.file('Server started', {
|
|
272
|
+
name: NAME,
|
|
273
|
+
pid: process.pid,
|
|
274
|
+
role,
|
|
275
|
+
url: `http://localhost:${PORT}`,
|
|
276
|
+
env: env || 'dev',
|
|
277
|
+
logFile: logger.filePath || undefined,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Uncaught errors → log file (MongoDB-style), then exit
|
|
281
|
+
process.on('uncaughtException', (err) => {
|
|
282
|
+
logger.error('Uncaught exception', { message: err?.message, stack: err?.stack });
|
|
283
|
+
process.exit(1);
|
|
284
|
+
});
|
|
285
|
+
process.on('unhandledRejection', (reason: any) => {
|
|
286
|
+
logger.error('Unhandled rejection', { message: reason?.message ?? String(reason), stack: reason?.stack });
|
|
287
|
+
});
|
|
288
|
+
|
|
227
289
|
return server;
|
|
228
290
|
} catch (err: any) {
|
|
291
|
+
logger.error('Failed to boot server', cfg.debug ? { error: err, message: err?.message } : { message: err?.message });
|
|
229
292
|
console.error('Failed to boot server', cfg.debug ? err : err?.message);
|
|
230
293
|
process.exit(1);
|
|
231
294
|
}
|
package/server/hono.ts
CHANGED
|
@@ -18,8 +18,24 @@ import { getGlobalMiddlewares } from "../lib/middleware";
|
|
|
18
18
|
import { jwt } from "../utils/func";
|
|
19
19
|
import { AppError } from "../lib/error";
|
|
20
20
|
import { getMetrics } from "./metrics";
|
|
21
|
+
import { logger } from "../utils/logger";
|
|
21
22
|
import type { HonoVariables } from "./env";
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Caddy-style header collection (array values); sensitive headers stripped
|
|
26
|
+
* (`authorization`, `cookie`, `proxy-authorization`, `set-cookie`).
|
|
27
|
+
*/
|
|
28
|
+
export function collectHeaders(h: Headers): Record<string, string[]> {
|
|
29
|
+
const EXCLUDE = ['authorization', 'cookie', 'proxy-authorization', 'set-cookie'];
|
|
30
|
+
const out: Record<string, string[]> = {};
|
|
31
|
+
for (const [name, value] of h.entries()) {
|
|
32
|
+
const key = name.toLowerCase();
|
|
33
|
+
if (EXCLUDE.includes(key)) continue;
|
|
34
|
+
out[key] = [value];
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
// ─── Redis-backed rate limiting (C3) ─────────────────────────────────────────
|
|
24
40
|
// Lazy singleton: created once per process and reused across createApp() calls.
|
|
25
41
|
// Each increment falls back to the in-memory store if Redis is unavailable,
|
|
@@ -200,6 +216,51 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
200
216
|
await next();
|
|
201
217
|
});
|
|
202
218
|
|
|
219
|
+
// Access log — registered before /health & routes so EVERY request is logged.
|
|
220
|
+
// Runs around the async-context middleware, so traceId/tenant are readable here.
|
|
221
|
+
// Caddy-compatible JSONL payload: `request`/`resp_headers` blocks + flat fields.
|
|
222
|
+
app.use(async (c, next) => {
|
|
223
|
+
const start = performance.now();
|
|
224
|
+
try {
|
|
225
|
+
await next();
|
|
226
|
+
} finally {
|
|
227
|
+
const duration = performance.now() - start;
|
|
228
|
+
const trace = requestCtxStorage.get<{ id: string }>('trace');
|
|
229
|
+
const conn = c.get('connInfo');
|
|
230
|
+
const token = c.get('token');
|
|
231
|
+
const url = new URL(c.req.raw.url);
|
|
232
|
+
const uri = url.pathname + url.search;
|
|
233
|
+
const ip = c.get('clientIp') ?? 'unknown';
|
|
234
|
+
const remoteIp = conn?.remote?.address ?? null;
|
|
235
|
+
const remotePort = conn?.remote?.port ?? null;
|
|
236
|
+
|
|
237
|
+
logger.info(`${c.req.method} ${uri} → ${c.res.status}`, {
|
|
238
|
+
logger: 'http.log.access',
|
|
239
|
+
status: c.res.status,
|
|
240
|
+
duration: Math.round(duration * 10) / 10,
|
|
241
|
+
method: c.req.method,
|
|
242
|
+
uri,
|
|
243
|
+
remote_ip: remoteIp,
|
|
244
|
+
bytes_read: Number(c.req.header('content-length')) || 0,
|
|
245
|
+
size: Number(c.res.headers.get('content-length')) || 0,
|
|
246
|
+
user_id: (token?.decoded as Record<string, unknown> | null)?.['sub'] as string ?? '',
|
|
247
|
+
ip,
|
|
248
|
+
traceId: trace?.id ?? crypto.randomUUID(),
|
|
249
|
+
tenant: requestCtxStorage.get<string>('tenant_id') ?? null,
|
|
250
|
+
request: {
|
|
251
|
+
headers: collectHeaders(c.req.raw.headers),
|
|
252
|
+
client_ip: ip,
|
|
253
|
+
host: c.req.header('host') ?? '',
|
|
254
|
+
method: c.req.method,
|
|
255
|
+
remote_ip: remoteIp,
|
|
256
|
+
remote_port: remotePort,
|
|
257
|
+
uri,
|
|
258
|
+
},
|
|
259
|
+
resp_headers: collectHeaders(c.res.headers),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
203
264
|
// Reuse the cached connInfo instead of re-querying the socket per middleware
|
|
204
265
|
const cachedGetConnInfo: GetConnInfo = (c) =>
|
|
205
266
|
(c as Context<{ Variables: HonoVariables }>).get('connInfo') ?? safeGetConnInfo(c);
|
|
@@ -320,6 +381,8 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
320
381
|
c.set('token', emptyToken);
|
|
321
382
|
}
|
|
322
383
|
|
|
384
|
+
// (access log is a dedicated middleware registered earlier — see top of createApp)
|
|
385
|
+
|
|
323
386
|
return await next();
|
|
324
387
|
})
|
|
325
388
|
})
|
package/server/mcp.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { StreamableHTTPTransport } from "@hono/mcp";
|
|
|
13
13
|
import type Joi from "joi";
|
|
14
14
|
import { cfg } from "./config";
|
|
15
15
|
import { useRest } from "../database/rest";
|
|
16
|
+
import { logger } from "../utils/logger";
|
|
16
17
|
import type { McpTool, McpResource } from "../types/mcp";
|
|
17
18
|
import type { HonoVariables } from "./env";
|
|
18
19
|
|
|
@@ -113,24 +114,32 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
|
|
|
113
114
|
{ capabilities: { tools: {}, resources: {} } }
|
|
114
115
|
);
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
const ms = (start: number) => Math.round((performance.now() - start) * 10) / 10;
|
|
118
|
+
|
|
119
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
120
|
+
const start = performance.now();
|
|
121
|
+
const toolsList = tools.map((t) => ({
|
|
118
122
|
name: t.name,
|
|
119
123
|
title: t.name,
|
|
120
124
|
description: t.description,
|
|
121
125
|
inputSchema: joiToJsonSchema(t.inputSchema),
|
|
122
|
-
}))
|
|
123
|
-
|
|
126
|
+
}));
|
|
127
|
+
logger.info('MCP tools/list', { method: 'tools/list', tenant: tenantId, count: toolsList.length, duration: ms(start) });
|
|
128
|
+
return { tools: toolsList };
|
|
129
|
+
});
|
|
124
130
|
|
|
125
131
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
132
|
+
const start = performance.now();
|
|
126
133
|
const tool = tools.find((t) => t.name === req.params.name);
|
|
127
134
|
if (!tool) {
|
|
135
|
+
logger.warn('MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, error: 'unknown tool', duration: ms(start) });
|
|
128
136
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${req.params.name}`);
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
const raw = req.params.arguments ?? {};
|
|
132
140
|
const { error, value } = tool.inputSchema.validate(raw, { allowUnknown: false });
|
|
133
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) });
|
|
134
143
|
return {
|
|
135
144
|
content: [{ type: 'text' as const, text: `Invalid arguments: ${error.message}` }],
|
|
136
145
|
isError: true,
|
|
@@ -140,10 +149,13 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
|
|
|
140
149
|
const rest = new useRest({ tenant_id: tenantId });
|
|
141
150
|
try {
|
|
142
151
|
const result = await tool.exec({ c: undefined as any, rest, args: value });
|
|
152
|
+
const isError = !!(result as any)?.isError;
|
|
153
|
+
logger[isError ? 'warn' : 'info']('MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, args: value, isError, duration: ms(start) });
|
|
143
154
|
if (result && Array.isArray(result.content)) return result;
|
|
144
155
|
const text = typeof result === 'string' ? result : JSON.stringify(result);
|
|
145
156
|
return { content: [{ type: 'text' as const, text }] };
|
|
146
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) });
|
|
147
159
|
return {
|
|
148
160
|
content: [{ type: 'text' as const, text: `Error: ${err?.message ?? 'unknown'}` }],
|
|
149
161
|
isError: true,
|
|
@@ -151,32 +163,40 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
|
|
|
151
163
|
}
|
|
152
164
|
});
|
|
153
165
|
|
|
154
|
-
server.setRequestHandler(ListResourcesRequestSchema, async () =>
|
|
155
|
-
|
|
166
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
167
|
+
const start = performance.now();
|
|
168
|
+
const resourcesList = resources
|
|
156
169
|
.filter((r) => !r.uri.includes('{')) // static only; templates are listed separately
|
|
157
170
|
.map((r) => ({
|
|
158
171
|
uri: r.uri,
|
|
159
172
|
name: r.name,
|
|
160
173
|
description: r.description,
|
|
161
174
|
mimeType: r.mimeType,
|
|
162
|
-
}))
|
|
163
|
-
|
|
175
|
+
}));
|
|
176
|
+
logger.info('MCP resources/list', { method: 'resources/list', tenant: tenantId, count: resourcesList.length, duration: ms(start) });
|
|
177
|
+
return { resources: resourcesList };
|
|
178
|
+
});
|
|
164
179
|
|
|
165
|
-
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () =>
|
|
166
|
-
|
|
180
|
+
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
|
|
181
|
+
const start = performance.now();
|
|
182
|
+
const templates = resources
|
|
167
183
|
.filter((r) => r.uri.includes('{'))
|
|
168
184
|
.map((r) => ({
|
|
169
185
|
uriTemplate: r.uri,
|
|
170
186
|
name: r.name,
|
|
171
187
|
description: r.description,
|
|
172
188
|
mimeType: r.mimeType,
|
|
173
|
-
}))
|
|
174
|
-
|
|
189
|
+
}));
|
|
190
|
+
logger.info('MCP resources/templates/list', { method: 'resources/templates/list', tenant: tenantId, count: templates.length, duration: ms(start) });
|
|
191
|
+
return { resourceTemplates: templates };
|
|
192
|
+
});
|
|
175
193
|
|
|
176
194
|
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
|
195
|
+
const start = performance.now();
|
|
177
196
|
const uri = req.params.uri;
|
|
178
197
|
const matched = matchUri(uri, resources);
|
|
179
198
|
if (!matched) {
|
|
199
|
+
logger.warn('MCP resources/read', { method: 'resources/read', tenant: tenantId, uri, error: 'unknown uri', duration: ms(start) });
|
|
180
200
|
throw new McpError(ErrorCode.InvalidParams, `Unknown resource URI: ${uri}`);
|
|
181
201
|
}
|
|
182
202
|
|
|
@@ -188,8 +208,10 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
|
|
|
188
208
|
params: matched.params,
|
|
189
209
|
uri,
|
|
190
210
|
});
|
|
211
|
+
logger.info('MCP resources/read', { method: 'resources/read', tenant: tenantId, uri, resource: matched.resource.name, params: matched.params, duration: ms(start) });
|
|
191
212
|
return result;
|
|
192
213
|
} catch (err: any) {
|
|
214
|
+
logger.warn('MCP resources/read', { method: 'resources/read', tenant: tenantId, uri, error: err?.message ?? 'unknown', duration: ms(start) });
|
|
193
215
|
throw new McpError(ErrorCode.InternalError, `Failed to read resource: ${err?.message ?? 'unknown'}`);
|
|
194
216
|
}
|
|
195
217
|
});
|
package/server/routes.ts
CHANGED
|
@@ -4,6 +4,7 @@ import path from "path";
|
|
|
4
4
|
import { jwt } from "../utils/func";
|
|
5
5
|
import { useRest } from "../database/rest";
|
|
6
6
|
import { io } from "./io";
|
|
7
|
+
import { requestCtxStorage } from "../lib/asyncContextStorage";
|
|
7
8
|
import type { HonoVariables } from "./env";
|
|
8
9
|
|
|
9
10
|
function initializeRoutes(app: Hono<{ Variables: HonoVariables }>) {
|
|
@@ -12,10 +13,17 @@ function initializeRoutes(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
12
13
|
let method = route.method.toLocaleLowerCase();
|
|
13
14
|
let routePath = path.posix.join(route._prefix_, route.path);
|
|
14
15
|
|
|
16
|
+
// Resolve the tenant in the request context so the access log,
|
|
17
|
+
// nested useRest() calls and audit see the right tenant_id
|
|
18
|
+
const resolveTenant = () => {
|
|
19
|
+
requestCtxStorage.set('tenant_id', route._tenant_);
|
|
20
|
+
};
|
|
21
|
+
|
|
15
22
|
|
|
16
23
|
|
|
17
24
|
if (route.method == "GET") {
|
|
18
25
|
app.get(routePath, async (c) => { //
|
|
26
|
+
resolveTenant();
|
|
19
27
|
return route.handler({
|
|
20
28
|
c,
|
|
21
29
|
rest: new useRest({
|
|
@@ -29,6 +37,7 @@ function initializeRoutes(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
29
37
|
|
|
30
38
|
if (route.method == 'POST') {
|
|
31
39
|
app.post(routePath, async (c) => {
|
|
40
|
+
resolveTenant();
|
|
32
41
|
return route.handler({
|
|
33
42
|
c,
|
|
34
43
|
rest: new useRest({
|
|
@@ -42,6 +51,7 @@ function initializeRoutes(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
42
51
|
|
|
43
52
|
if (route.method == "PUT") {
|
|
44
53
|
app.put(routePath, async (c) => { //
|
|
54
|
+
resolveTenant();
|
|
45
55
|
return route.handler({
|
|
46
56
|
c,
|
|
47
57
|
rest: new useRest({
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|