@zenera/gym 0.0.0-stage → 1.1.38
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/LICENSE +21 -0
- package/README.md +200 -2
- package/dist/box.d.ts +66 -0
- package/dist/box.js +259 -0
- package/dist/cache.d.ts +87 -0
- package/dist/cache.js +275 -0
- package/dist/command.d.ts +3 -0
- package/dist/command.js +414 -0
- package/dist/envelope.d.ts +13 -0
- package/dist/envelope.js +9 -0
- package/dist/generate.d.ts +57 -0
- package/dist/generate.js +236 -0
- package/dist/image.d.ts +25 -0
- package/dist/image.js +81 -0
- package/dist/logger.d.ts +89 -0
- package/dist/logger.js +321 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +363 -0
- package/dist/page.d.ts +3 -0
- package/dist/page.js +499 -0
- package/dist/paging.d.ts +79 -0
- package/dist/paging.js +518 -0
- package/dist/probe.d.ts +12 -0
- package/dist/probe.js +307 -0
- package/dist/prompt.d.ts +25 -0
- package/dist/prompt.js +188 -0
- package/dist/router.d.ts +20 -0
- package/dist/router.js +101 -0
- package/dist/schema.d.ts +21 -0
- package/dist/schema.js +296 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +626 -0
- package/dist/setup.d.ts +53 -0
- package/dist/setup.js +123 -0
- package/dist/spec.d.ts +53 -0
- package/dist/spec.js +303 -0
- package/dist/validate.d.ts +21 -0
- package/dist/validate.js +83 -0
- package/package.json +53 -5
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
|
package/dist/probe.d.ts
ADDED
|
@@ -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
|