@zenera/gym 0.0.0-stage → 1.1.39

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/dist/paging.js ADDED
@@ -0,0 +1,518 @@
1
+ import { randomInt } from 'node:crypto';
2
+ import { properties } from "./schema.js";
3
+ const squash = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, '');
4
+ const CURSOR_PARAMS = new Set([
5
+ 'cursor',
6
+ 'nextcursor',
7
+ 'pagetoken',
8
+ 'nextpagetoken',
9
+ 'continuationtoken',
10
+ 'nexttoken',
11
+ 'pagecursor',
12
+ 'after',
13
+ 'marker',
14
+ 'startkey',
15
+ ]);
16
+ const OFFSET_PARAMS = new Set([
17
+ 'offset',
18
+ 'page',
19
+ 'pagenumber',
20
+ 'pageindex',
21
+ 'start',
22
+ 'startindex',
23
+ 'skip',
24
+ ]);
25
+ const SIZE_PARAMS = new Set([
26
+ 'pagesize',
27
+ 'perpage',
28
+ 'limit',
29
+ 'maxresults',
30
+ 'maxitems',
31
+ 'count',
32
+ 'size',
33
+ ]);
34
+ const NEXT_PROPS = new Set([
35
+ 'nextcursor',
36
+ 'nextpagetoken',
37
+ 'nexttoken',
38
+ 'nextoffset',
39
+ 'nextpage',
40
+ 'nextlink',
41
+ 'nexturl',
42
+ 'next',
43
+ 'cursor',
44
+ 'pagetoken',
45
+ 'continuationtoken',
46
+ 'marker',
47
+ ]);
48
+ const MORE_PROPS = new Set([
49
+ 'hasmore',
50
+ 'hasnext',
51
+ 'hasnextpage',
52
+ 'more',
53
+ 'islast',
54
+ 'islastpage',
55
+ 'istruncated',
56
+ 'truncated',
57
+ ]);
58
+ const ITEMS_PROPS = new Set([
59
+ 'items',
60
+ 'results',
61
+ 'data',
62
+ 'values',
63
+ 'records',
64
+ 'entries',
65
+ 'objects',
66
+ 'content',
67
+ 'list',
68
+ 'edges',
69
+ ]);
70
+ /**
71
+ * The paging shape of an operation, or nothing when it does not page.
72
+ *
73
+ * A page-size parameter on its own is not pagination — plenty of endpoints cap
74
+ * a one-shot list — so a control that turns the page *and* a property that says
75
+ * where the next one is are both required.
76
+ */
77
+ export function pagingOf(params, schema, bodySchema) {
78
+ if (!schema) {
79
+ return undefined;
80
+ }
81
+ const query = params.filter((p) => p.in === 'query');
82
+ const queryCursor = query.find((p) => CURSOR_PARAMS.has(squash(p.name)));
83
+ const queryOffset = query.find((p) => OFFSET_PARAMS.has(squash(p.name)) && numeric(p.schema));
84
+ let bodyCursor;
85
+ let bodyOffset;
86
+ if (!queryCursor && !queryOffset && bodySchema) {
87
+ const bodyProps = properties(bodySchema);
88
+ bodyCursor = bodyProps.find((p) => CURSOR_PARAMS.has(squash(p.name)));
89
+ bodyOffset = bodyProps.find((p) => OFFSET_PARAMS.has(squash(p.name)) && numeric(p.schema));
90
+ }
91
+ const cursor = queryCursor ?? bodyCursor;
92
+ const offset = queryOffset ?? bodyOffset;
93
+ const param = cursor ?? offset;
94
+ if (!param) {
95
+ return undefined;
96
+ }
97
+ const declared = properties(schema);
98
+ const next = declared.find((d) => NEXT_PROPS.has(squash(d.name)));
99
+ const more = declared.find((d) => MORE_PROPS.has(squash(d.name)) && boolish(d.schema));
100
+ if (!next && !more) {
101
+ return undefined;
102
+ }
103
+ const isBody = Boolean(!queryCursor && !queryOffset && (bodyCursor || bodyOffset));
104
+ const size = query.find((p) => SIZE_PARAMS.has(squash(p.name)))?.name ??
105
+ (bodySchema
106
+ ? properties(bodySchema).find((p) => SIZE_PARAMS.has(squash(p.name)))?.name
107
+ : undefined);
108
+ return {
109
+ style: cursor ? 'cursor' : 'offset',
110
+ param: param.name,
111
+ ...(isBody ? { paramIn: 'body' } : {}),
112
+ size,
113
+ next: next?.name,
114
+ nextNullable: next ? nullable(next.schema) : undefined,
115
+ nextRequired: next?.required,
116
+ more: more?.name,
117
+ items: itemsOf(declared),
118
+ };
119
+ }
120
+ /**
121
+ * The paging an actual request reveals, for a document that declared none.
122
+ *
123
+ * Plenty of specs describe the response envelope — `cursor`, `has_more` — and
124
+ * never write down the parameter that reads it back. A client only sends
125
+ * `?cursor=X` because a body handed it X, so the exchange is pagination on the
126
+ * evidence even when the document is silent, and silence is exactly the case
127
+ * nothing else here can catch.
128
+ */
129
+ export function pagingSeen(params, schema, query, body) {
130
+ const declared = new Set(params.map((p) => p.name));
131
+ const extra = [];
132
+ for (const [name, value] of query) {
133
+ if (value !== '' && !declared.has(name)) {
134
+ declared.add(name);
135
+ extra.push({ name, in: 'query', required: false, schema: guess(value) });
136
+ }
137
+ }
138
+ if (extra.length > 0) {
139
+ const detected = pagingOf([...params, ...extra], schema);
140
+ if (detected) {
141
+ return detected;
142
+ }
143
+ }
144
+ if (isObject(body)) {
145
+ for (const [name, value] of Object.entries(body)) {
146
+ if (value !== null && value !== undefined && value !== '') {
147
+ if (CURSOR_PARAMS.has(squash(name)) || OFFSET_PARAMS.has(squash(name))) {
148
+ const syntheticBody = {
149
+ type: 'object',
150
+ properties: {
151
+ [name]: guess(typeof value === 'string' ? value : JSON.stringify(value)),
152
+ },
153
+ };
154
+ const detected = pagingOf(params, schema, syntheticBody);
155
+ if (detected) {
156
+ return detected;
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+ return undefined;
163
+ }
164
+ /** The token a body offers for the next page, or nothing when it offers none. */
165
+ export function tokenOf(value, paging) {
166
+ if (!paging.next) {
167
+ return undefined;
168
+ }
169
+ const holder = holderOf(value, paging.next);
170
+ const token = holder?.[paging.next];
171
+ if (token === null || token === undefined || token === '') {
172
+ return undefined;
173
+ }
174
+ return typeof token === 'object' ? JSON.stringify(token) : String(token);
175
+ }
176
+ /**
177
+ * A last line of defence, for the generator that is already on disk: a body
178
+ * offering the very token it was given is cut back to "no more pages".
179
+ *
180
+ * Deliberately timid. Nothing re-validates a generator's output on the way to
181
+ * the client, so writing `null` into a required, non-nullable property would
182
+ * trade a client that hangs for a mock that lies — and a hang is at least
183
+ * obvious. Where the schema leaves no room, this changes nothing and says so.
184
+ */
185
+ export function cutLoop(value, paging, sent) {
186
+ if (!paging.next || tokenOf(value, paging) !== sent) {
187
+ return false;
188
+ }
189
+ const holder = holderOf(value, paging.next);
190
+ if (!holder) {
191
+ return false;
192
+ }
193
+ if (paging.nextNullable) {
194
+ holder[paging.next] = null;
195
+ }
196
+ else if (!paging.nextRequired) {
197
+ delete holder[paging.next];
198
+ }
199
+ else {
200
+ return false;
201
+ }
202
+ const more = paging.more ? holderOf(value, paging.more) : undefined;
203
+ if (more && paging.more) {
204
+ more[paging.more] = false;
205
+ }
206
+ return true;
207
+ }
208
+ /**
209
+ * Terminates pagination unconditionally on a response object: nullifies or deletes
210
+ * the next token if allowed, or empties items if the token is required non-nullable.
211
+ */
212
+ export function cutPaging(value, paging) {
213
+ let cut = false;
214
+ if (paging.next) {
215
+ const holder = holderOf(value, paging.next);
216
+ if (holder) {
217
+ if (paging.nextNullable) {
218
+ holder[paging.next] = null;
219
+ cut = true;
220
+ }
221
+ else if (!paging.nextRequired) {
222
+ delete holder[paging.next];
223
+ cut = true;
224
+ }
225
+ else if (paging.items) {
226
+ const itemsHolder = holderOf(value, paging.items);
227
+ if (itemsHolder && Array.isArray(itemsHolder[paging.items])) {
228
+ itemsHolder[paging.items] = [];
229
+ cut = true;
230
+ }
231
+ }
232
+ }
233
+ }
234
+ if (paging.more) {
235
+ const moreHolder = holderOf(value, paging.more);
236
+ if (moreHolder) {
237
+ moreHolder[paging.more] = false;
238
+ cut = true;
239
+ }
240
+ }
241
+ return cut;
242
+ }
243
+ /** Sets the items array to empty and sets more to false. */
244
+ export function emptyItems(value, paging) {
245
+ let cut = false;
246
+ if (paging.items) {
247
+ const itemsHolder = holderOf(value, paging.items);
248
+ if (itemsHolder && Array.isArray(itemsHolder[paging.items])) {
249
+ itemsHolder[paging.items] = [];
250
+ cut = true;
251
+ }
252
+ }
253
+ if (paging.next) {
254
+ const holder = holderOf(value, paging.next);
255
+ if (holder) {
256
+ if (paging.nextNullable) {
257
+ holder[paging.next] = null;
258
+ cut = true;
259
+ }
260
+ else if (!paging.nextRequired) {
261
+ delete holder[paging.next];
262
+ cut = true;
263
+ }
264
+ }
265
+ }
266
+ if (paging.more) {
267
+ const moreHolder = holderOf(value, paging.more);
268
+ if (moreHolder) {
269
+ moreHolder[paging.more] = false;
270
+ cut = true;
271
+ }
272
+ }
273
+ return cut;
274
+ }
275
+ /** Retrieves the sent pagination token from query parameters or request body. */
276
+ export function getSentToken(query, body, paging) {
277
+ if (paging.paramIn === 'body' && isObject(body)) {
278
+ const val = body[paging.param];
279
+ if (val !== undefined && val !== null && val !== '') {
280
+ return typeof val === 'object' ? JSON.stringify(val) : String(val);
281
+ }
282
+ }
283
+ const qVal = query.get(paging.param);
284
+ if (qVal !== null && qVal !== '') {
285
+ return qVal;
286
+ }
287
+ if (isObject(body)) {
288
+ const val = body[paging.param];
289
+ if (val !== undefined && val !== null && val !== '') {
290
+ return typeof val === 'object' ? JSON.stringify(val) : String(val);
291
+ }
292
+ if (paging.next) {
293
+ const nextVal = body[paging.next];
294
+ if (nextVal !== undefined && nextVal !== null && nextVal !== '') {
295
+ return typeof nextVal === 'object' ? JSON.stringify(nextVal) : String(nextVal);
296
+ }
297
+ }
298
+ }
299
+ return undefined;
300
+ }
301
+ /** Decodes the 1-based page index from base64 JSON token `{"p": n}` if present. */
302
+ export function parsePageFromToken(token, style = 'cursor') {
303
+ if (style !== 'cursor') {
304
+ return undefined;
305
+ }
306
+ try {
307
+ const decoded = Buffer.from(token, 'base64').toString('utf8');
308
+ const parsed = JSON.parse(decoded);
309
+ if (typeof parsed?.p === 'number')
310
+ return parsed.p;
311
+ if (typeof parsed?.page === 'number')
312
+ return parsed.page;
313
+ }
314
+ catch {
315
+ // Not base64 JSON
316
+ }
317
+ return undefined;
318
+ }
319
+ /**
320
+ * Tracks active pagination streams to bound iterations and prevent infinite loops.
321
+ *
322
+ * Randomly bounds pagination to 1-10 pages by default, intercepts cycle loops and
323
+ * echoed tokens, and empties responses when clients loop past the end.
324
+ */
325
+ export class PaginationLimiter {
326
+ #tokens = new Map();
327
+ #maxPagesFactory;
328
+ #maxEntries;
329
+ constructor(maxPages = () => randomInt(1, 11), maxEntries = 5000) {
330
+ this.#maxPagesFactory = typeof maxPages === 'function' ? maxPages : () => maxPages;
331
+ this.#maxEntries = maxEntries;
332
+ }
333
+ process(operationKey, streamKey, sent, value, paging) {
334
+ this.#prune();
335
+ const nextToken = tokenOf(value, paging);
336
+ // Case 1: First page (no cursor sent)
337
+ if (sent === undefined) {
338
+ const maxPages = Math.max(1, this.#maxPagesFactory());
339
+ if (maxPages === 1) {
340
+ if (nextToken !== undefined) {
341
+ cutPaging(value, paging);
342
+ return { cut: true, reason: 'cut pagination at max pages (1)' };
343
+ }
344
+ return undefined;
345
+ }
346
+ if (nextToken !== undefined) {
347
+ this.#tokens.set(nextToken, {
348
+ operationKey,
349
+ page: 2,
350
+ maxPages,
351
+ seenTokens: new Set([nextToken]),
352
+ streamKey,
353
+ createdAt: Date.now(),
354
+ });
355
+ }
356
+ return undefined;
357
+ }
358
+ // Case 2: Cursor was sent
359
+ const state = this.#tokens.get(sent);
360
+ // Subcase 2A: Re-requesting an already-terminated token
361
+ if (state?.terminated) {
362
+ emptyItems(value, paging);
363
+ return {
364
+ cut: true,
365
+ reason: `cut pagination at max pages (${state.maxPages})`,
366
+ };
367
+ }
368
+ // Subcase 2B: Echo loop (server returned the exact token that was sent)
369
+ if (nextToken !== undefined && nextToken === sent) {
370
+ const didCut = cutLoop(value, paging, sent);
371
+ if (state) {
372
+ state.terminated = true;
373
+ }
374
+ if (didCut) {
375
+ return { cut: true, reason: 'cut a looping page token' };
376
+ }
377
+ // Where the schema leaves no room to null or delete, leave alone
378
+ return undefined;
379
+ }
380
+ // Subcase 2C: Cycle loop (server returned a token seen earlier in this stream)
381
+ if (state && nextToken !== undefined && state.seenTokens.has(nextToken)) {
382
+ cutPaging(value, paging);
383
+ state.terminated = true;
384
+ return { cut: true, reason: 'cut a cycling page token' };
385
+ }
386
+ // Subcase 2D: Tracked stream reached maxPages
387
+ if (state) {
388
+ if (state.page >= state.maxPages) {
389
+ cutPaging(value, paging);
390
+ state.terminated = true;
391
+ if (nextToken !== undefined) {
392
+ this.#tokens.set(nextToken, {
393
+ operationKey,
394
+ page: state.page + 1,
395
+ maxPages: state.maxPages,
396
+ seenTokens: state.seenTokens,
397
+ streamKey,
398
+ terminated: true,
399
+ createdAt: Date.now(),
400
+ });
401
+ }
402
+ return {
403
+ cut: true,
404
+ reason: `cut pagination at max pages (${state.maxPages})`,
405
+ };
406
+ }
407
+ // Not yet at maxPages: advance to next page
408
+ if (nextToken !== undefined) {
409
+ state.seenTokens.add(nextToken);
410
+ this.#tokens.set(nextToken, {
411
+ operationKey,
412
+ page: state.page + 1,
413
+ maxPages: state.maxPages,
414
+ seenTokens: state.seenTokens,
415
+ streamKey,
416
+ createdAt: Date.now(),
417
+ });
418
+ }
419
+ return undefined;
420
+ }
421
+ // Subcase 2E: Untracked token (first time seen)
422
+ const decodedPage = parsePageFromToken(sent, paging.style);
423
+ if (decodedPage !== undefined) {
424
+ const maxPages = Math.max(decodedPage, this.#maxPagesFactory());
425
+ if (decodedPage >= maxPages) {
426
+ cutPaging(value, paging);
427
+ return {
428
+ cut: true,
429
+ reason: `cut pagination at max pages (${maxPages})`,
430
+ };
431
+ }
432
+ if (nextToken !== undefined) {
433
+ this.#tokens.set(nextToken, {
434
+ operationKey,
435
+ page: decodedPage + 1,
436
+ maxPages,
437
+ seenTokens: new Set([sent, nextToken]),
438
+ streamKey,
439
+ createdAt: Date.now(),
440
+ });
441
+ }
442
+ return undefined;
443
+ }
444
+ // Untracked arbitrary string token (e.g. 'page-1' in server.test.ts)
445
+ const maxPages = Math.max(2, this.#maxPagesFactory());
446
+ if (nextToken !== undefined) {
447
+ this.#tokens.set(nextToken, {
448
+ operationKey,
449
+ page: 2,
450
+ maxPages,
451
+ seenTokens: new Set([sent, nextToken]),
452
+ streamKey,
453
+ createdAt: Date.now(),
454
+ });
455
+ }
456
+ return undefined;
457
+ }
458
+ #prune() {
459
+ const now = Date.now();
460
+ for (const [token, entry] of this.#tokens) {
461
+ if (now - entry.createdAt > 10 * 60 * 1000) {
462
+ this.#tokens.delete(token);
463
+ }
464
+ }
465
+ while (this.#tokens.size > this.#maxEntries) {
466
+ const first = this.#tokens.keys().next().value;
467
+ if (first !== undefined) {
468
+ this.#tokens.delete(first);
469
+ }
470
+ else {
471
+ break;
472
+ }
473
+ }
474
+ }
475
+ }
476
+ /** The nearest object carrying `name`; a real body nests its envelope. */
477
+ function holderOf(value, name) {
478
+ const seen = new Set();
479
+ let level = [value];
480
+ while (level.length > 0) {
481
+ const next = [];
482
+ for (const node of level) {
483
+ if (typeof node !== 'object' || node === null || seen.has(node)) {
484
+ continue;
485
+ }
486
+ seen.add(node);
487
+ if (Array.isArray(node)) {
488
+ next.push(...node);
489
+ continue;
490
+ }
491
+ const record = node;
492
+ if (name in record) {
493
+ return record;
494
+ }
495
+ next.push(...Object.values(record));
496
+ }
497
+ level = next;
498
+ }
499
+ return undefined;
500
+ }
501
+ function itemsOf(declared) {
502
+ const named = declared.find((d) => ITEMS_PROPS.has(squash(d.name)) && listish(d.schema));
503
+ return (named ?? declared.find((d) => listish(d.schema)))?.name;
504
+ }
505
+ const types = (schema) => {
506
+ const type = schema.type;
507
+ return typeof type === 'string' ? [type] : Array.isArray(type) ? type : [];
508
+ };
509
+ const numeric = (schema) => types(schema).some((t) => t === 'integer' || t === 'number');
510
+ const boolish = (schema) => types(schema).includes('boolean');
511
+ const listish = (schema) => types(schema).includes('array') || schema.items !== undefined;
512
+ const nullable = (schema) => types(schema).includes('null');
513
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
514
+ /** An undeclared parameter has only its value to be typed by. */
515
+ const guess = (value) => ({
516
+ type: /^-?\d+$/.test(value) ? 'integer' : 'string',
517
+ });
518
+ //# sourceMappingURL=paging.js.map
@@ -0,0 +1,12 @@
1
+ import type { GeneratorInput } from './envelope.ts';
2
+ import type { Paging } from './paging.ts';
3
+ import { type Schema } from './schema.ts';
4
+ import type { Operation } from './spec.ts';
5
+ import type { Issue } from './validate.ts';
6
+ export declare function probesFor(operation: Operation): GeneratorInput[];
7
+ /** The first page: an ordinary probe with the paging control taken back off. */
8
+ export declare function walkStart(operation: Operation, paging: Paging): GeneratorInput;
9
+ /** The same request again, asking for whatever the last answer pointed at. */
10
+ export declare function nextPage(previous: GeneratorInput, paging: Paging, token: string): GeneratorInput;
11
+ export declare function echoIssues(input: GeneratorInput, value: unknown, schema: Schema | undefined): Issue[];
12
+ //# sourceMappingURL=probe.d.ts.map