@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/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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anteros/core",
3
3
  "module": "index.ts",
4
- "version": "0.0.25",
4
+ "version": "0.0.26",
5
5
  "type": "module",
6
6
  "description": "Anteros Core",
7
7
  "private": false,
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
- return respond(engineIo.handleRequest(req, server));
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
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
117
- tools: tools.map((t) => ({
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
- resources: resources
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
- resourceTemplates: resources
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
- await rest.insertOne("items", { title: "count-me" });
200
- const n = await rest.countDocuments("items", { title: "count-me" });
201
- expect(n).toBeGreaterThanOrEqual(1);
409
+ const marker = `count-me-${crypto.randomUUID()}`;
410
+ await rest.insertOne("items", { title: marker });
411
+ await rest.insertOne("items", { title: "count-other" });
412
+ const n = await rest.countDocuments("items", { title: marker });
413
+ expect(n).toBe(1); // only the matching doc — not the whole collection
414
+
415
+ await rest.db.collection("items").deleteMany({ title: marker });
202
416
  });
203
417
  });
204
418