@zintrust/trace 0.4.96 → 0.5.1

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.
@@ -3,6 +3,107 @@ const TABLE_ENTRIES = 'zin_trace_entries';
3
3
  const TABLE_TAGS = 'zin_trace_entries_tags';
4
4
  const TABLE_MONITORING = 'zin_trace_monitoring';
5
5
  const generateUuid = () => crypto.randomUUID();
6
+ const decodeJsonStringLiteral = (value) => {
7
+ try {
8
+ return JSON.parse(`"${value}"`);
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ };
14
+ const matchJsonStringField = (content, key) => {
15
+ const match = new RegExp(String.raw `"${key}"\s*:\s*"((?:\\.|[^"\\])*)"`, 's').exec(content);
16
+ return match ? decodeJsonStringLiteral(match[1]) : undefined;
17
+ };
18
+ const matchJsonNumberField = (content, key) => {
19
+ const match = new RegExp(String.raw `"${key}"\s*:\s*(-?\d+(?:\.\d+)?)`, 's').exec(content);
20
+ if (!match)
21
+ return undefined;
22
+ const parsed = Number(match[1]);
23
+ return Number.isFinite(parsed) ? parsed : undefined;
24
+ };
25
+ const matchJsonNullOrNumberField = (content, key) => {
26
+ const nullMatch = new RegExp(String.raw `"${key}"\s*:\s*null`, 's').exec(content);
27
+ if (nullMatch)
28
+ return null;
29
+ return matchJsonNumberField(content, key);
30
+ };
31
+ const matchJsonStringArrayField = (content, key) => {
32
+ const match = new RegExp(String.raw `"${key}"\s*:\s*(\[.*?\])`, 's').exec(content);
33
+ if (!match)
34
+ return undefined;
35
+ try {
36
+ const parsed = JSON.parse(match[1]);
37
+ return Array.isArray(parsed)
38
+ ? parsed.filter((value) => typeof value === 'string')
39
+ : undefined;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ };
45
+ const compactRequestContent = (content) => {
46
+ const compact = {};
47
+ const method = matchJsonStringField(content, 'method');
48
+ const uri = matchJsonStringField(content, 'uri');
49
+ const responseStatus = matchJsonNumberField(content, 'responseStatus');
50
+ const duration = matchJsonNumberField(content, 'duration');
51
+ const memory = matchJsonNullOrNumberField(content, 'memory');
52
+ const middleware = matchJsonStringArrayField(content, 'middleware');
53
+ const hostname = matchJsonStringField(content, 'hostname');
54
+ const userId = matchJsonStringField(content, 'userId');
55
+ if (method !== undefined)
56
+ compact['method'] = method;
57
+ if (uri !== undefined)
58
+ compact['uri'] = uri;
59
+ if (responseStatus !== undefined)
60
+ compact['responseStatus'] = responseStatus;
61
+ if (duration !== undefined)
62
+ compact['duration'] = duration;
63
+ if (memory !== undefined)
64
+ compact['memory'] = memory;
65
+ if (middleware !== undefined)
66
+ compact['middleware'] = middleware;
67
+ if (hostname !== undefined)
68
+ compact['hostname'] = hostname;
69
+ if (userId !== undefined)
70
+ compact['userId'] = userId;
71
+ return Object.keys(compact).length > 0 ? compact : undefined;
72
+ };
73
+ const compactClientRequestContent = (content) => {
74
+ const compact = {};
75
+ const source = matchJsonStringField(content, 'source');
76
+ const method = matchJsonStringField(content, 'method');
77
+ const url = matchJsonStringField(content, 'url');
78
+ const responseStatus = matchJsonNumberField(content, 'responseStatus');
79
+ const error = matchJsonStringField(content, 'error');
80
+ const duration = matchJsonNumberField(content, 'duration');
81
+ const hostname = matchJsonStringField(content, 'hostname');
82
+ if (source !== undefined)
83
+ compact['source'] = source;
84
+ if (method !== undefined)
85
+ compact['method'] = method;
86
+ if (url !== undefined)
87
+ compact['url'] = url;
88
+ if (responseStatus !== undefined)
89
+ compact['responseStatus'] = responseStatus;
90
+ if (error !== undefined)
91
+ compact['error'] = error;
92
+ if (duration !== undefined)
93
+ compact['duration'] = duration;
94
+ if (hostname !== undefined)
95
+ compact['hostname'] = hostname;
96
+ return Object.keys(compact).length > 0 ? compact : undefined;
97
+ };
98
+ const summarizeEntryContent = (row) => {
99
+ if (row.type === 'request') {
100
+ return compactRequestContent(row.content) ?? JSON.parse(row.content);
101
+ }
102
+ if (row.type === 'client_request') {
103
+ return compactClientRequestContent(row.content) ?? JSON.parse(row.content);
104
+ }
105
+ return JSON.parse(row.content);
106
+ };
6
107
  const buildIgnoreInsert = (db, table, columns, conflictColumns) => {
7
108
  const columnList = columns.join(', ');
8
109
  const placeholders = columns.map(() => '?').join(', ');
@@ -35,12 +136,12 @@ const buildIgnoreInsert = (db, table, columns, conflictColumns) => {
35
136
  }
36
137
  return `INSERT INTO ${table} (${columnList}) VALUES (${placeholders})`;
37
138
  };
38
- const rowToEntry = (row, tags) => ({
139
+ const rowToEntry = (row, tags, summary = false) => ({
39
140
  uuid: row.uuid,
40
141
  batchId: row.batch_id,
41
142
  familyHash: row.family_hash ?? undefined,
42
143
  type: row.type,
43
- content: JSON.parse(row.content),
144
+ content: summary ? summarizeEntryContent(row) : JSON.parse(row.content),
44
145
  tags,
45
146
  isLatest: Boolean(row.is_latest),
46
147
  createdAt: row.created_at,
@@ -81,6 +182,29 @@ const buildEntryFilters = (opts) => {
81
182
  const countParams = opts.tag ? [opts.tag, ...params.slice(1)] : [...params];
82
183
  return { joinClause, whereClause, params, countParams };
83
184
  };
185
+ const buildBatchCounts = async (db, batchId) => {
186
+ const rows = (await db.query(`SELECT type, COUNT(*) as cnt FROM ${TABLE_ENTRIES} WHERE batch_id = ? GROUP BY type`, [batchId]));
187
+ const counts = {};
188
+ for (const row of rows) {
189
+ counts[row.type] = row.cnt;
190
+ }
191
+ return counts;
192
+ };
193
+ const buildBatchEntryFilters = (batchId, opts) => {
194
+ const conditions = ['batch_id = ?'];
195
+ const params = [batchId];
196
+ if (opts.type) {
197
+ conditions.push('type = ?');
198
+ params.push(opts.type);
199
+ }
200
+ const excludeTypes = opts.excludeTypes ?? [];
201
+ if (excludeTypes.length > 0) {
202
+ const placeholders = excludeTypes.map(() => '?').join(', ');
203
+ conditions.push(`type NOT IN (${placeholders})`);
204
+ params.push(...excludeTypes);
205
+ }
206
+ return { whereClause: `WHERE ${conditions.join(' AND ')}`, params };
207
+ };
84
208
  const loadTagsByUuid = async (db, uuids) => {
85
209
  const tagsByUuid = new Map();
86
210
  if (uuids.length === 0)
@@ -143,7 +267,7 @@ const createStorage = (db) => {
143
267
  LIMIT ? OFFSET ?`, [...params, perPage, offset]));
144
268
  const tagsByUuid = await loadTagsByUuid(db, rows.map((row) => row.uuid));
145
269
  return {
146
- data: rows.map((row) => rowToEntry(row, tagsByUuid.get(row.uuid) ?? [])),
270
+ data: rows.map((row) => rowToEntry(row, tagsByUuid.get(row.uuid) ?? [], opts.summary)),
147
271
  total,
148
272
  };
149
273
  };
@@ -168,6 +292,35 @@ const createStorage = (db) => {
168
292
  const tagsByUuid = await loadTagsByUuid(db, rows.map((row) => row.uuid));
169
293
  return rows.map((row) => rowToEntry(row, tagsByUuid.get(row.uuid) ?? []));
170
294
  };
295
+ const queryBatchEntries = async (batchId, opts = {}) => {
296
+ const page = opts.page ?? 1;
297
+ const perPage = opts.perPage ?? 10;
298
+ const offset = (page - 1) * perPage;
299
+ const counts = await buildBatchCounts(db, batchId);
300
+ if (opts.countsOnly) {
301
+ const total = Object.values(counts).reduce((sum, value) => sum + Number(value ?? 0), 0);
302
+ return { entries: [], total, counts, page, perPage };
303
+ }
304
+ const { whereClause, params } = buildBatchEntryFilters(batchId, opts);
305
+ const countResult = (await db.queryOne(`SELECT COUNT(*) as cnt FROM ${TABLE_ENTRIES} ${whereClause}`, params));
306
+ const total = countResult?.cnt ?? 0;
307
+ if (total === 0) {
308
+ return { entries: [], total: 0, counts, page, perPage };
309
+ }
310
+ const rows = (await db.query(`SELECT id, uuid, batch_id, family_hash, type, content, is_latest, created_at
311
+ FROM ${TABLE_ENTRIES}
312
+ ${whereClause}
313
+ ORDER BY created_at ASC, id ASC
314
+ LIMIT ? OFFSET ?`, [...params, perPage, offset]));
315
+ const tagsByUuid = await loadTagsByUuid(db, rows.map((row) => row.uuid));
316
+ return {
317
+ entries: rows.map((row) => rowToEntry(row, tagsByUuid.get(row.uuid) ?? [], opts.summary)),
318
+ total,
319
+ counts,
320
+ page,
321
+ perPage,
322
+ };
323
+ };
171
324
  const prune = async (olderThanMs, keepExceptions = false) => {
172
325
  const countResult = (await db.queryOne(`SELECT COUNT(*) as cnt FROM ${TABLE_ENTRIES}
173
326
  WHERE created_at < ?
@@ -208,6 +361,7 @@ const createStorage = (db) => {
208
361
  queryEntries,
209
362
  getEntry,
210
363
  getBatch,
364
+ queryBatchEntries,
211
365
  prune,
212
366
  clear,
213
367
  getMonitoring,
package/dist/types.d.ts CHANGED
@@ -231,6 +231,22 @@ export interface QueryEntriesOptions {
231
231
  to?: number;
232
232
  page?: number;
233
233
  perPage?: number;
234
+ summary?: boolean;
235
+ }
236
+ export interface QueryBatchEntriesOptions {
237
+ type?: EntryTypeValue;
238
+ excludeTypes?: EntryTypeValue[];
239
+ page?: number;
240
+ perPage?: number;
241
+ summary?: boolean;
242
+ countsOnly?: boolean;
243
+ }
244
+ export interface QueryBatchEntriesResult {
245
+ entries: ITraceEntry[];
246
+ total: number;
247
+ counts: Partial<Record<EntryTypeValue, number>>;
248
+ page: number;
249
+ perPage: number;
234
250
  }
235
251
  export interface ITraceStorage {
236
252
  writeEntry(entry: ITraceEntry): Promise<void>;
@@ -242,6 +258,7 @@ export interface ITraceStorage {
242
258
  }>;
243
259
  getEntry(uuid: string): Promise<ITraceEntry | null>;
244
260
  getBatch(batchId: string): Promise<ITraceEntry[]>;
261
+ queryBatchEntries(batchId: string, opts?: QueryBatchEntriesOptions): Promise<QueryBatchEntriesResult>;
245
262
  prune(olderThanMs: number, keepExceptions?: boolean): Promise<number>;
246
263
  clear(): Promise<void>;
247
264
  getMonitoring(): Promise<string[]>;
@@ -1,4 +1,4 @@
1
- import type { ClientRequestTraceInput, ITraceWatcher } from '../types';
1
+ import { type ClientRequestTraceInput, type ITraceWatcher } from '../types';
2
2
  declare const emit: ({ source, method, url, requestHeaders, responseStatus, duration, requestBody, responseHeaders, responseBody, error, }: ClientRequestTraceInput) => void;
3
3
  export declare const HttpClientWatcher: ITraceWatcher & {
4
4
  emit: typeof emit;
@@ -1,5 +1,5 @@
1
1
  import { TraceContext } from '../context.js';
2
- import { EntryType } from '../types.js';
2
+ import { EntryType, } from '../types.js';
3
3
  import { AuthTag } from '../utils/authTag.js';
4
4
  import { redactHeaders, redactUnknown } from '../utils/redact.js';
5
5
  import { RequestFilter } from '../utils/requestFilter.js';
@@ -57,20 +57,40 @@ const buildResponseBody = (responseBody, sourceRule) => {
57
57
  return {};
58
58
  return { responseBody: redactUnknown(responseBody, _redactBodyFields) };
59
59
  };
60
+ const applySource = (content, normalizedSource) => {
61
+ if (normalizedSource !== undefined) {
62
+ content.source = normalizedSource;
63
+ }
64
+ };
65
+ const applyResponseStatus = (content, responseStatus) => {
66
+ if (responseStatus !== undefined) {
67
+ content.responseStatus = responseStatus;
68
+ }
69
+ };
70
+ const applyError = (content, error) => {
71
+ if (typeof error === 'string' && error !== '') {
72
+ content.error = error;
73
+ }
74
+ };
75
+ const mergePartialContent = (content, partial) => {
76
+ Object.assign(content, partial);
77
+ };
60
78
  const buildClientRequestContent = (input, sourceRule, normalizedSource) => {
61
- return {
62
- ...(normalizedSource === undefined ? {} : { source: normalizedSource }),
79
+ const content = {
63
80
  method: input.method.toUpperCase(),
64
81
  url: input.url,
65
- ...buildRequestHeaders(input.requestHeaders, sourceRule),
66
- ...buildRequestBody(input.requestBody, sourceRule),
67
- ...(input.responseStatus === undefined ? {} : { responseStatus: input.responseStatus }),
68
- ...buildResponseHeaders(input.responseHeaders, sourceRule),
69
- ...buildResponseBody(input.responseBody, sourceRule),
70
- ...(typeof input.error === 'string' && input.error !== '' ? { error: input.error } : {}),
82
+ requestHeaders: {},
71
83
  duration: input.duration,
72
84
  hostname: TraceContext.getHostname(),
73
85
  };
86
+ applySource(content, normalizedSource);
87
+ mergePartialContent(content, buildRequestHeaders(input.requestHeaders, sourceRule));
88
+ mergePartialContent(content, buildRequestBody(input.requestBody, sourceRule));
89
+ applyResponseStatus(content, input.responseStatus);
90
+ mergePartialContent(content, buildResponseHeaders(input.responseHeaders, sourceRule));
91
+ mergePartialContent(content, buildResponseBody(input.responseBody, sourceRule));
92
+ applyError(content, input.error);
93
+ return content;
74
94
  };
75
95
  const isWatcherEnabled = (value) => {
76
96
  if (value === false)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zintrust/trace",
3
- "version": "0.4.96",
3
+ "version": "0.5.1",
4
4
  "description": "Trace assistant for ZinTrust: logs requests, queries, exceptions, jobs, and more.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  "node": ">=20.0.0"
41
41
  },
42
42
  "peerDependencies": {
43
- "@zintrust/core": "^0.4.95"
43
+ "@zintrust/core": "^0.5.0"
44
44
  },
45
45
  "publishConfig": {
46
46
  "access": "public"
@@ -56,4 +56,4 @@
56
56
  "build": "tsc -p tsconfig.json && tsc -p tsconfig.migrations.json && node ../../scripts/fix-dist-esm-imports.mjs dist",
57
57
  "prepublishOnly": "npm run build"
58
58
  }
59
- }
59
+ }
package/src/config.ts CHANGED
@@ -134,7 +134,7 @@ const collectClientRequestSourceKeys = (
134
134
  ): string[] => {
135
135
  const overrideSources = override?.sources ?? {};
136
136
  const sourceKeys = new Set<string>([
137
- ...Object.keys(isObjectValue(base) ? base.sources ?? {} : {}),
137
+ ...Object.keys(isObjectValue(base) ? (base.sources ?? {}) : {}),
138
138
  ...Object.keys(overrideSources),
139
139
  ]);
140
140
 
@@ -3,7 +3,7 @@
3
3
  * No auth in this layer — caller mounts middleware as needed.
4
4
  */
5
5
  import type { IRequest, IResponse } from '@zintrust/core';
6
- import type { EntryTypeValue, ITraceStorage } from '../types';
6
+ import type { EntryTypeValue, ITraceEntry, ITraceStorage } from '../types';
7
7
 
8
8
  // ---------------------------------------------------------------------------
9
9
  // Storage holder (set once from routes.ts)
@@ -57,6 +57,140 @@ const getNumericQueryParam = (req: IRequest, key: string): number | undefined =>
57
57
  return undefined;
58
58
  };
59
59
 
60
+ const DEFAULT_PER_PAGE = 50;
61
+ const MAX_PER_PAGE = 100;
62
+ const DEFAULT_REQUEST_PER_PAGE = 25;
63
+ const MAX_REQUEST_PER_PAGE = 50;
64
+ const DEFAULT_BATCH_PER_PAGE = 10;
65
+ const MAX_BATCH_PER_PAGE = 25;
66
+ const SUMMARY_TEXT_LIMIT = 280;
67
+ const SUMMARY_ARRAY_LIMIT = 10;
68
+ const REQUEST_BATCH_DEFAULT_EXCLUDED_TYPES: EntryTypeValue[] = [
69
+ 'request',
70
+ 'query',
71
+ 'middleware',
72
+ 'model',
73
+ 'log',
74
+ 'exception',
75
+ 'client_request',
76
+ 'cache',
77
+ ];
78
+
79
+ type CompactTraceEntry = ITraceEntry<Record<string, unknown>> & {
80
+ hasDetails: true;
81
+ contentBytes?: number;
82
+ };
83
+
84
+ const truncateText = (value: string, limit = SUMMARY_TEXT_LIMIT): string =>
85
+ value.length <= limit ? value : `${value.slice(0, Math.max(0, limit - 3))}...`;
86
+
87
+ const compactValue = (value: unknown): unknown => {
88
+ if (typeof value === 'string') {
89
+ return truncateText(value);
90
+ }
91
+
92
+ if (
93
+ typeof value === 'number' ||
94
+ typeof value === 'boolean' ||
95
+ value === null ||
96
+ value === undefined
97
+ ) {
98
+ return value;
99
+ }
100
+
101
+ if (Array.isArray(value)) {
102
+ return value.slice(0, SUMMARY_ARRAY_LIMIT).map((item) => {
103
+ if (typeof item === 'string') {
104
+ return truncateText(item);
105
+ }
106
+
107
+ if (typeof item === 'number' || typeof item === 'boolean' || item === null) {
108
+ return item;
109
+ }
110
+
111
+ return '[complex]';
112
+ });
113
+ }
114
+
115
+ return undefined;
116
+ };
117
+
118
+ const pickCompactContent = (content: unknown, keys: readonly string[]): Record<string, unknown> => {
119
+ if (typeof content !== 'object' || content === null || Array.isArray(content)) {
120
+ return {};
121
+ }
122
+
123
+ const source = content as Record<string, unknown>;
124
+ const compact: Record<string, unknown> = {};
125
+
126
+ for (const key of keys) {
127
+ const value = compactValue(source[key]);
128
+ if (value !== undefined) {
129
+ compact[key] = value;
130
+ }
131
+ }
132
+
133
+ return compact;
134
+ };
135
+
136
+ const COMPACT_ENTRY_KEYS: Record<EntryTypeValue, readonly string[]> = {
137
+ request: [
138
+ 'method',
139
+ 'uri',
140
+ 'responseStatus',
141
+ 'duration',
142
+ 'memory',
143
+ 'middleware',
144
+ 'hostname',
145
+ 'userId',
146
+ ],
147
+ query: ['connection', 'sql', 'time', 'duration', 'slow', 'hash', 'hostname'],
148
+ exception: ['class', 'file', 'line', 'message', 'occurrences', 'hostname', 'userId'],
149
+ log: ['level', 'message', 'hostname'],
150
+ job: ['status', 'connection', 'queue', 'name', 'tries', 'timeout', 'hostname'],
151
+ cache: ['operation', 'key', 'hit', 'store', 'payloadLogged', 'ttl', 'duration', 'hostname'],
152
+ schedule: ['name', 'expression', 'status', 'duration', 'hostname'],
153
+ mail: ['to', 'subject', 'template', 'hostname'],
154
+ auth: ['event', 'userId', 'hostname'],
155
+ event: ['name', 'listenerCount', 'hostname'],
156
+ model: ['action', 'model', 'id', 'hostname'],
157
+ notification: ['channels', 'notifiable', 'notification', 'message', 'hostname'],
158
+ redis: ['command', 'duration', 'hostname'],
159
+ gate: ['ability', 'result', 'userId', 'subject', 'hostname'],
160
+ middleware: ['name', 'event', 'duration', 'hostname'],
161
+ command: ['name', 'exitCode', 'duration', 'hostname'],
162
+ batch: ['name', 'total', 'processed', 'failed', 'status', 'hostname'],
163
+ dump: ['file', 'line', 'hostname'],
164
+ view: ['template', 'duration', 'hostname'],
165
+ client_request: ['source', 'method', 'url', 'responseStatus', 'error', 'duration', 'hostname'],
166
+ };
167
+
168
+ const compactEntryContent = (entry: ITraceEntry): Record<string, unknown> =>
169
+ pickCompactContent(entry.content, COMPACT_ENTRY_KEYS[entry.type]);
170
+
171
+ const estimateContentBytes = (content: unknown): number | undefined => {
172
+ try {
173
+ return new TextEncoder().encode(JSON.stringify(content)).length;
174
+ } catch {
175
+ return undefined;
176
+ }
177
+ };
178
+
179
+ const compactListEntry = (entry: ITraceEntry): CompactTraceEntry => ({
180
+ ...entry,
181
+ content: compactEntryContent(entry),
182
+ hasDetails: true,
183
+ contentBytes: estimateContentBytes(entry.content),
184
+ });
185
+
186
+ const resolvePerPage = (req: IRequest, type?: EntryTypeValue): number => {
187
+ const isRequestList = type === 'request';
188
+ const fallback = isRequestList ? DEFAULT_REQUEST_PER_PAGE : DEFAULT_PER_PAGE;
189
+ const limit = isRequestList ? MAX_REQUEST_PER_PAGE : MAX_PER_PAGE;
190
+
191
+ return Math.max(1, Math.min(qpInt(req, 'perPage', fallback), limit));
192
+ };
193
+
60
194
  // ---------------------------------------------------------------------------
61
195
  // Entry handlers
62
196
  // ---------------------------------------------------------------------------
@@ -64,18 +198,26 @@ const getNumericQueryParam = (req: IRequest, key: string): number | undefined =>
64
198
  export async function listEntries(req: IRequest, res: IResponse): Promise<void> {
65
199
  const storage = getStorage(res);
66
200
  if (storage !== null) {
201
+ const type = qp(req, 'type') as EntryTypeValue | undefined;
67
202
  const opts = {
68
- type: qp(req, 'type') as EntryTypeValue | undefined,
203
+ type,
69
204
  tag: qp(req, 'tag'),
70
205
  batchId: qp(req, 'batchId'),
71
206
  from: getNumericQueryParam(req, 'from'),
72
207
  to: getNumericQueryParam(req, 'to'),
73
- page: qpInt(req, 'page', 1),
74
- perPage: Math.min(qpInt(req, 'perPage', 50), 200),
208
+ page: Math.max(1, qpInt(req, 'page', 1)),
209
+ perPage: resolvePerPage(req, type),
210
+ summary: true,
75
211
  };
76
212
  try {
77
213
  const result = await storage.queryEntries(opts);
78
- res.json({ ok: true, ...result, page: opts.page, perPage: opts.perPage });
214
+ res.json({
215
+ ok: true,
216
+ data: result.data.map(compactListEntry),
217
+ total: result.total,
218
+ page: opts.page,
219
+ perPage: opts.perPage,
220
+ });
79
221
  } catch (err) {
80
222
  res.setStatus(500).json({ error: (err as Error).message });
81
223
  }
@@ -111,8 +253,22 @@ export async function getBatch(req: IRequest, res: IResponse): Promise<void> {
111
253
  const batchId = req.getParam('batchId');
112
254
  if (batchId) {
113
255
  try {
114
- const entries = await storage.getBatch(batchId);
115
- res.json({ ok: true, entries });
256
+ const scope = qp(req, 'scope');
257
+ const type = qp(req, 'type') as EntryTypeValue | undefined;
258
+ const countsOnly = qp(req, 'countsOnly') === 'true';
259
+ const page = Math.max(1, qpInt(req, 'page', 1));
260
+ const perPage = Math.max(
261
+ 1,
262
+ Math.min(qpInt(req, 'perPage', DEFAULT_BATCH_PER_PAGE), MAX_BATCH_PER_PAGE)
263
+ );
264
+ const result = await storage.queryBatchEntries(batchId, {
265
+ type,
266
+ excludeTypes: scope === 'other' ? REQUEST_BATCH_DEFAULT_EXCLUDED_TYPES : undefined,
267
+ page,
268
+ perPage,
269
+ countsOnly,
270
+ });
271
+ res.json({ ok: true, ...result });
116
272
  return;
117
273
  } catch (err) {
118
274
  res.setStatus(500).json({ error: (err as Error).message });