@zenera/faker 1.1.9 → 1.1.11

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/README.md CHANGED
@@ -60,13 +60,48 @@ for. `GET /users/12324` answering with somebody else's id validates perfectly
60
60
  and is still wrong.
61
61
 
62
62
  If it fails, the diagnostics go back to the model and it tries again, up to
63
- `--attempts`. If it passes, the file is cached under `~/.zenera/neo/faker` and
63
+ `--attempts`. If it passes, the generator is kept in this machine's shared cache
64
+ under `~/.zenera/neo/cache/faker-generator/`, keyed by the operation's shape, and
64
65
  every later request is just `podman exec python3 gen.py in.json out.json` — no
65
- model, no tokens.
66
+ model, no tokens. The store is the machine's, so the same document served from
67
+ another directory costs nothing the second time.
66
68
 
67
69
  Generators run in a container with **no network**, on an image baked once with
68
70
  `faker`, `exrex`, `jsonschema` and `python-dateutil`.
69
71
 
72
+ ## Pages that end
73
+
74
+ A list endpoint is the one place a mock can hang a real client. Given
75
+ `?cursor=abc`, the honest-looking answer is a body that validates, echoes
76
+ nothing it shouldn't, and hands back `abc` again — so the client asks for the
77
+ same page forever.
78
+
79
+ The faker reads the document for this. Where an operation has a paging
80
+ parameter (`cursor`, `page`, `offset`, `page_token`, …) and a response property
81
+ that carries the next one (`next`, `next_cursor`, `has_more`, …), three things
82
+ happen, all in the operation's own names:
83
+
84
+ - the model is told to fabricate **three pages** in total, to build the token
85
+ out of the paging parameter rather than the seed, and to end the list — null,
86
+ absent, or `has_more: false` where the schema leaves no other room;
87
+ - the generator is then **walked**: the faker calls it with no cursor, follows
88
+ the token it gets back, and rejects the file if the token repeats, cycles, or
89
+ never runs out. The diagnostics say which, and the model gets another go;
90
+ - at request time a token identical to the one just sent is **cut** — nulled or
91
+ dropped, whichever the schema allows — and the request line says so. Nothing
92
+ is invented in its place; a generator written before this rule existed is
93
+ still cached, and a cache is not rebuilt because a rule changed.
94
+
95
+ Only paginated operations are affected. Their cache keys changed once, so they
96
+ are written again on first use; everything else keeps the key it had.
97
+ `GET /__faker/routes` reports the shape that was recognised, per operation.
98
+
99
+ Plenty of documents describe the envelope and never write down the parameter
100
+ that reads it back. The first two steps cannot help there — nothing static can
101
+ see a parameter that is not declared — but the cut still applies: it takes the
102
+ paging parameter from the request itself, since a client only sends `?cursor=X`
103
+ because a body handed it X.
104
+
70
105
  ## Commands
71
106
 
72
107
  ```
@@ -77,8 +112,8 @@ zen faker cache ls | clear What has been generated, or throw it away.
77
112
 
78
113
  Useful options: `--port`, `--host` (reachable only from this machine by
79
114
  default), `--model`, `--seed` (same request, same answer), `--rebuild`,
80
- `--attempts`, `--concurrency`, `--timeout`, `--cache <dir>`, `--quiet`.
81
- `zen help faker` prints the full table.
115
+ `--attempts`, `--concurrency`, `--timeout`, `--cache <dir>` (the container's
116
+ workspace), `--quiet`. `zen help faker` prints the full table.
82
117
 
83
118
  `GET /__faker/routes` lists what is being served; `GET /__faker/health` is a
84
119
  health check.
package/dist/cache.d.ts CHANGED
@@ -1,12 +1,23 @@
1
1
  import type { Model } from '@zenera/neo';
2
- import { type Box } from './box.ts';
2
+ import type { Box } from './box.ts';
3
3
  import { BuildFailed } from './generate.ts';
4
4
  import type { Operation } from './spec.ts';
5
5
  import type { Checks } from './validate.ts';
6
+ export declare const FAKER_KIND = "faker-generator";
7
+ /** What is kept about a generator besides the code, for `zen faker cache ls`. */
8
+ export interface GeneratorMeta {
9
+ operationId?: string;
10
+ method?: string;
11
+ path?: string;
12
+ source?: string;
13
+ model?: string;
14
+ attempts?: number;
15
+ createdAt?: string;
16
+ }
6
17
  export interface Generator {
7
18
  key: string;
8
19
  source: string;
9
- /** whether it came off disk rather than out of a model */
20
+ /** whether it came out of the cache rather than out of a model */
10
21
  cached: boolean;
11
22
  }
12
23
  export interface CacheEvent {
@@ -26,10 +37,12 @@ export interface CacheOptions {
26
37
  * on all of them.
27
38
  */
28
39
  concurrency?: number;
29
- /** ignore what is on disk and write fresh */
40
+ /** ignore what is cached and write fresh */
30
41
  rebuild?: boolean;
31
42
  /** run generators but keep nothing */
32
43
  ephemeral?: boolean;
44
+ /** keep them somewhere other than the shared store */
45
+ cacheDir?: string;
33
46
  onStart?: (e: CacheEvent) => void;
34
47
  onAttempt?: (e: CacheEvent) => void;
35
48
  onReady?: (e: CacheEvent & {
package/dist/cache.js CHANGED
@@ -1,12 +1,28 @@
1
- import { existsSync } from 'node:fs';
2
- import { readFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { writeJson } from '@zenera/cli/lib';
5
- import { GENERATORS } from "./box.js";
1
+ import { Cache as Store } from '@zenera/cli/lib';
6
2
  import { build, BuildFailed } from "./generate.js";
3
+ // ---------------------------------------------------------------------------
4
+ // The cache
5
+ //
6
+ // Two layers over one identity. `Operation.key` is a function of the
7
+ // operation's shape, so a spec edit produces a new key and the old artefact is
8
+ // simply never asked for again — there is nothing to invalidate, which is the
9
+ // part of a cache that is usually wrong.
10
+ //
11
+ // The generator itself lives in the machine's shared cache rather than beside
12
+ // the container's workspace, so the same spec served from two directories is
13
+ // written once. The box root is scratch: a hit is copied back into it, because
14
+ // the container can only run what is under its mount.
15
+ //
16
+ // The in-flight map is the other half and matters more than it looks: ten
17
+ // requests arriving together for an uncached operation must produce one build,
18
+ // not ten. A failed build is remembered too, for the same reason — an operation
19
+ // the model could not write for should not re-ask on every request.
20
+ // ---------------------------------------------------------------------------
21
+ export const FAKER_KIND = 'faker-generator';
7
22
  const DEFAULT_CONCURRENCY = 4;
8
23
  export class Cache {
9
24
  #opts;
25
+ #store;
10
26
  #live = new Map();
11
27
  #settled = new Set();
12
28
  #slots;
@@ -14,6 +30,7 @@ export class Cache {
14
30
  #running = 0;
15
31
  constructor(opts) {
16
32
  this.#opts = opts;
33
+ this.#store = new Store(FAKER_KIND, { dir: opts.cacheDir, mode: 0o600 });
17
34
  this.#slots = Math.max(1, opts.concurrency ?? DEFAULT_CONCURRENCY);
18
35
  }
19
36
  /**
@@ -51,7 +68,7 @@ export class Cache {
51
68
  const { box, model, checks, rebuild, ephemeral } = this.#opts;
52
69
  // Read before queueing: a cache hit costs nothing and must not wait
53
70
  // behind somebody else's model call.
54
- const source = rebuild ? undefined : await read(box, operation.key);
71
+ const source = rebuild ? undefined : await this.#read(box, operation.key);
55
72
  if (source !== undefined) {
56
73
  this.#opts.onReady?.({ operation, cached: true, attempts: 0 });
57
74
  return { key: operation.key, source, cached: true };
@@ -67,17 +84,18 @@ export class Cache {
67
84
  onAttempt: (attempt, diagnostics) => this.#opts.onAttempt?.({ operation, attempt, diagnostics }),
68
85
  });
69
86
  if (!ephemeral) {
70
- writeJson(join(box.root, GENERATORS, operation.key, 'meta.json'), {
71
- version: 1,
72
- operationId: operation.operationId,
73
- method: operation.method,
74
- path: operation.path,
75
- source: operation.source,
76
- model: model.id,
77
- attempts: built.attempts,
78
- rebuilt: Boolean(rebuild),
79
- createdAt: new Date().toISOString(),
80
- }, 0o644);
87
+ this.#store.put(operation.key, {
88
+ source: built.source,
89
+ meta: {
90
+ operationId: operation.operationId,
91
+ method: operation.method,
92
+ path: operation.path,
93
+ source: operation.source,
94
+ model: model.id,
95
+ attempts: built.attempts,
96
+ createdAt: new Date().toISOString(),
97
+ },
98
+ });
81
99
  }
82
100
  this.#opts.onReady?.({ operation, cached: false, attempts: built.attempts });
83
101
  return { key: operation.key, source: built.source, cached: false };
@@ -91,6 +109,25 @@ export class Cache {
91
109
  this.#leave();
92
110
  }
93
111
  }
112
+ /**
113
+ * A hit is written into the box before it is returned. The container runs
114
+ * files under its mount and nothing else, and the mount is scratch that any
115
+ * `cache clear` is free to delete.
116
+ */
117
+ async #read(box, key) {
118
+ const found = this.#store.get(key);
119
+ if (!found?.source?.trim()) {
120
+ return undefined;
121
+ }
122
+ try {
123
+ await box.write(key, found.source);
124
+ }
125
+ catch {
126
+ // The mount went away. Writing it again is the model's job.
127
+ return undefined;
128
+ }
129
+ return found.source;
130
+ }
94
131
  #enter() {
95
132
  if (this.#running < this.#slots) {
96
133
  this.#running++;
@@ -108,18 +145,5 @@ export class Cache {
108
145
  this.#running--;
109
146
  }
110
147
  }
111
- async function read(box, key) {
112
- const path = box.sourceOf(key);
113
- if (!existsSync(path)) {
114
- return undefined;
115
- }
116
- try {
117
- const source = await readFile(path, 'utf8');
118
- return source.trim() ? source : undefined;
119
- }
120
- catch {
121
- return undefined;
122
- }
123
- }
124
148
  export { BuildFailed };
125
149
  //# sourceMappingURL=cache.js.map
package/dist/command.js CHANGED
@@ -1,8 +1,8 @@
1
+ import { bold, cacheItems, clearCache, CliError, cyan, dim, EXIT, green, json, note, ownedContainers, parse, paths, red, removeContainers, table, usageError, write, writeAll, yellow, } from '@zenera/cli/lib';
1
2
  import { rmSync } from 'node:fs';
2
- import { readdir, readFile } from 'node:fs/promises';
3
3
  import { join, relative, resolve } from 'node:path';
4
- import { bold, CliError, cyan, dim, EXIT, green, json, note, ownedContainers, parse, paths, red, removeContainers, table, usageError, write, writeAll, yellow, } from '@zenera/cli/lib';
5
4
  import { GENERATORS } from "./box.js";
5
+ import { FAKER_KIND } from "./cache.js";
6
6
  import { reason } from "./generate.js";
7
7
  import { listen } from "./server.js";
8
8
  import { open } from "./setup.js";
@@ -53,7 +53,7 @@ export const command = {
53
53
  [' --host <h>', dim('Default 127.0.0.1. Anything else is reachable off-machine.')],
54
54
  [' --model <ref>', dim('Which model writes the generators.')],
55
55
  [' --image <ref>', dim('Skip the baked image and use this one.')],
56
- [' --cache <dir>', dim('Where generators live. Default ~/.zenera/neo/faker.')],
56
+ [' --cache <dir>', dim("The container's workspace. Default ~/.zenera/neo/faker.")],
57
57
  [' --seed <n>', dim('Answer the same request the same way every time.')],
58
58
  [' --attempts <n>', dim('Tries per generator before giving up. Default 3.')],
59
59
  [' --concurrency <n>', dim('Generators written at once. Default 4.')],
@@ -190,7 +190,7 @@ async function cache(args, ctx) {
190
190
  const root = values.cache ? resolve(ctx.cwd, values.cache) : paths.faker();
191
191
  const sub = positionals[0] ?? 'ls';
192
192
  if (sub === 'ls') {
193
- const entries = await listGenerators(root);
193
+ const entries = listGenerators();
194
194
  if (ctx.json) {
195
195
  json(entries);
196
196
  return;
@@ -211,6 +211,9 @@ async function cache(args, ctx) {
211
211
  return;
212
212
  }
213
213
  if (sub === 'clear') {
214
+ clearCache({ kind: FAKER_KIND });
215
+ // The workspace is scratch — whatever a hit was copied into it is
216
+ // written again from the cache, or by the model.
214
217
  rmSync(join(root, GENERATORS), { recursive: true, force: true });
215
218
  // The container is named after its configuration, so a stale one would
216
219
  // otherwise sit there stopped forever with nothing pointing at it.
@@ -219,30 +222,21 @@ async function cache(args, ctx) {
219
222
  if (mine.length > 0) {
220
223
  await removeContainers(mine.map((c) => c.name));
221
224
  }
222
- note(`${green('cleared')} ${dim(root)}`);
225
+ note(`${green('cleared')} ${dim(paths.cache())}`);
223
226
  return;
224
227
  }
225
228
  throw usageError(`unknown cache command "${sub}"`, 'zen faker cache <ls|clear>');
226
229
  }
227
- async function listGenerators(root) {
228
- let keys;
229
- try {
230
- keys = await readdir(join(root, GENERATORS));
231
- }
232
- catch {
233
- return [];
234
- }
235
- const out = [];
236
- for (const key of keys.sort()) {
237
- try {
238
- const meta = JSON.parse(await readFile(join(root, GENERATORS, key, 'meta.json'), 'utf8'));
239
- out.push({ key, ...meta });
240
- }
241
- catch {
242
- out.push({ key });
243
- }
244
- }
245
- return out;
230
+ /**
231
+ * The keys are the operation keys, written verbatim — which is why they can be
232
+ * listed at all. Anything the store cannot parse is left out rather than shown
233
+ * as a row with nothing in it.
234
+ */
235
+ function listGenerators() {
236
+ const { rows } = cacheItems(FAKER_KIND);
237
+ return rows
238
+ .map((row) => ({ key: row.key, ...row.value?.meta }))
239
+ .sort((a, b) => a.key.localeCompare(b.key));
246
240
  }
247
241
  function summarize(operations) {
248
242
  const by = new Map();
package/dist/generate.js CHANGED
@@ -1,4 +1,5 @@
1
- import { echoIssues, probesFor } from "./probe.js";
1
+ import { tokenOf } from "./paging.js";
2
+ import { echoIssues, nextPage, probesFor, walkStart } from "./probe.js";
2
3
  import { instruction, retry, SYSTEM } from "./prompt.js";
3
4
  import { describeIssues, issues } from "./validate.js";
4
5
  export class BuildFailed extends Error {
@@ -106,8 +107,57 @@ async function judge(operation, probes, response, box) {
106
107
  out.push(`- ${called}: ${describeIssues(echo)}.`);
107
108
  }
108
109
  }
110
+ // Only worth the container round trips once the file answers at all, and
111
+ // only for an operation that hands out a token somebody could follow.
112
+ if (out.length === 0 && operation.paging?.next) {
113
+ out.push(...(await walk(operation, operation.paging, response, box)));
114
+ }
109
115
  return out;
110
116
  }
117
+ /** How many pages a mock may offer before it is simply not terminating. */
118
+ const MAX_PAGES = 8;
119
+ /**
120
+ * Follows the operation's own cursor and reports the ways that walk fails to
121
+ * end. A schema cannot express this and neither can any one response: the bug
122
+ * is a relation between two of them.
123
+ */
124
+ async function walk(operation, paging, response, box) {
125
+ let input = walkStart(operation, paging);
126
+ const seen = new Set();
127
+ let sent;
128
+ for (let page = 1; page <= MAX_PAGES; page++) {
129
+ const outcome = await box.run(operation.key, input);
130
+ const called = `page ${page} of ${operation.method.toUpperCase()} ${operation.path}`;
131
+ if (!outcome.ok) {
132
+ return [`- ${called}: the file ${outcome.fault}.`];
133
+ }
134
+ if (response && !response(outcome.value)) {
135
+ return [
136
+ `- ${called}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`,
137
+ ];
138
+ }
139
+ const token = tokenOf(outcome.value, paging);
140
+ if (token === undefined) {
141
+ return [];
142
+ }
143
+ if (token === sent) {
144
+ return [
145
+ `- ${called}: \`${paging.next}\` came back as ${JSON.stringify(token)}, the very token the request carried in \`${paging.param}\`. A client following it never advances. Build the token from \`${paging.param}\` so it counts up, and stop after three pages.`,
146
+ ];
147
+ }
148
+ if (seen.has(token)) {
149
+ return [
150
+ `- ${called}: the page tokens cycle — ${JSON.stringify(token)} was handed out earlier in this walk. Every page must offer a token no page has offered before, and the last one must offer none.`,
151
+ ];
152
+ }
153
+ seen.add(token);
154
+ sent = token;
155
+ input = nextPage(input, paging, token);
156
+ }
157
+ return [
158
+ `- ${operation.method.toUpperCase()} ${operation.path}: the pages never run out — after ${MAX_PAGES} of them \`${paging.next}\` is still set. Fabricate three pages in total and set it to null on the last.`,
159
+ ];
160
+ }
111
161
  /**
112
162
  * Models fence code even when told not to, and a stray ```python line is a
113
163
  * syntax error rather than a bad answer — not worth a round trip.
@@ -0,0 +1,51 @@
1
+ import { type Schema } from './schema.ts';
2
+ import type { ParamSpec } from './spec.ts';
3
+ export type PagingStyle = 'cursor' | 'offset';
4
+ export interface Paging {
5
+ style: PagingStyle;
6
+ /** the query parameter that turns the page */
7
+ param: string;
8
+ /** the page-size parameter, when the operation takes one */
9
+ size?: string;
10
+ /** the response property carrying the token for the page after this one */
11
+ next?: string;
12
+ /** whether `next` may be set to null */
13
+ nextNullable?: boolean;
14
+ /** whether the object declaring `next` lists it as required */
15
+ nextRequired?: boolean;
16
+ /** a boolean response property — `has_more` and friends */
17
+ more?: string;
18
+ /** the array of things being paged over */
19
+ items?: string;
20
+ }
21
+ /**
22
+ * The paging shape of an operation, or nothing when it does not page.
23
+ *
24
+ * A page-size parameter on its own is not pagination — plenty of endpoints cap
25
+ * a one-shot list — so a control that turns the page *and* a property that says
26
+ * where the next one is are both required.
27
+ */
28
+ export declare function pagingOf(params: readonly ParamSpec[], schema: Schema | undefined): Paging | undefined;
29
+ /**
30
+ * The paging an actual request reveals, for a document that declared none.
31
+ *
32
+ * Plenty of specs describe the response envelope — `cursor`, `has_more` — and
33
+ * never write down the parameter that reads it back. A client only sends
34
+ * `?cursor=X` because a body handed it X, so the exchange is pagination on the
35
+ * evidence even when the document is silent, and silence is exactly the case
36
+ * nothing else here can catch.
37
+ */
38
+ export declare function pagingSeen(params: readonly ParamSpec[], schema: Schema | undefined, query: Iterable<[string, string]>): Paging | undefined;
39
+ /** The token a body offers for the next page, or nothing when it offers none. */
40
+ export declare function tokenOf(value: unknown, paging: Paging): string | undefined;
41
+ /**
42
+ * A last line of defence, for the generator that is already on disk: a body
43
+ * offering the very token it was given is cut back to "no more pages".
44
+ *
45
+ * Deliberately timid. Nothing re-validates a generator's output on the way to
46
+ * the client, so writing `null` into a required, non-nullable property would
47
+ * trade a client that hangs for a mock that lies — and a hang is at least
48
+ * obvious. Where the schema leaves no room, this changes nothing and says so.
49
+ */
50
+ export declare function cutLoop(value: unknown, paging: Paging, sent: string): boolean;
51
+ //# sourceMappingURL=paging.d.ts.map
package/dist/paging.js ADDED
@@ -0,0 +1,209 @@
1
+ import { properties } from "./schema.js";
2
+ const squash = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, '');
3
+ const CURSOR_PARAMS = new Set([
4
+ 'cursor',
5
+ 'nextcursor',
6
+ 'pagetoken',
7
+ 'nextpagetoken',
8
+ 'continuationtoken',
9
+ 'nexttoken',
10
+ 'pagecursor',
11
+ 'after',
12
+ 'marker',
13
+ 'startkey',
14
+ ]);
15
+ const OFFSET_PARAMS = new Set([
16
+ 'offset',
17
+ 'page',
18
+ 'pagenumber',
19
+ 'pageindex',
20
+ 'start',
21
+ 'startindex',
22
+ 'skip',
23
+ ]);
24
+ const SIZE_PARAMS = new Set([
25
+ 'pagesize',
26
+ 'perpage',
27
+ 'limit',
28
+ 'maxresults',
29
+ 'maxitems',
30
+ 'count',
31
+ 'size',
32
+ ]);
33
+ const NEXT_PROPS = new Set([
34
+ 'nextcursor',
35
+ 'nextpagetoken',
36
+ 'nexttoken',
37
+ 'nextoffset',
38
+ 'nextpage',
39
+ 'nextlink',
40
+ 'nexturl',
41
+ 'next',
42
+ 'cursor',
43
+ 'pagetoken',
44
+ 'continuationtoken',
45
+ 'marker',
46
+ ]);
47
+ const MORE_PROPS = new Set([
48
+ 'hasmore',
49
+ 'hasnext',
50
+ 'hasnextpage',
51
+ 'more',
52
+ 'islast',
53
+ 'islastpage',
54
+ 'istruncated',
55
+ 'truncated',
56
+ ]);
57
+ const ITEMS_PROPS = new Set([
58
+ 'items',
59
+ 'results',
60
+ 'data',
61
+ 'values',
62
+ 'records',
63
+ 'entries',
64
+ 'objects',
65
+ 'content',
66
+ 'list',
67
+ 'edges',
68
+ ]);
69
+ /**
70
+ * The paging shape of an operation, or nothing when it does not page.
71
+ *
72
+ * A page-size parameter on its own is not pagination — plenty of endpoints cap
73
+ * a one-shot list — so a control that turns the page *and* a property that says
74
+ * where the next one is are both required.
75
+ */
76
+ export function pagingOf(params, schema) {
77
+ if (!schema) {
78
+ return undefined;
79
+ }
80
+ const query = params.filter((p) => p.in === 'query');
81
+ const cursor = query.find((p) => CURSOR_PARAMS.has(squash(p.name)));
82
+ const offset = query.find((p) => OFFSET_PARAMS.has(squash(p.name)) && numeric(p.schema));
83
+ const param = cursor ?? offset;
84
+ if (!param) {
85
+ return undefined;
86
+ }
87
+ const declared = properties(schema);
88
+ const next = declared.find((d) => NEXT_PROPS.has(squash(d.name)));
89
+ const more = declared.find((d) => MORE_PROPS.has(squash(d.name)) && boolish(d.schema));
90
+ if (!next && !more) {
91
+ return undefined;
92
+ }
93
+ return {
94
+ style: cursor ? 'cursor' : 'offset',
95
+ param: param.name,
96
+ size: query.find((p) => SIZE_PARAMS.has(squash(p.name)))?.name,
97
+ next: next?.name,
98
+ nextNullable: next ? nullable(next.schema) : undefined,
99
+ nextRequired: next?.required,
100
+ more: more?.name,
101
+ items: itemsOf(declared),
102
+ };
103
+ }
104
+ /**
105
+ * The paging an actual request reveals, for a document that declared none.
106
+ *
107
+ * Plenty of specs describe the response envelope — `cursor`, `has_more` — and
108
+ * never write down the parameter that reads it back. A client only sends
109
+ * `?cursor=X` because a body handed it X, so the exchange is pagination on the
110
+ * evidence even when the document is silent, and silence is exactly the case
111
+ * nothing else here can catch.
112
+ */
113
+ export function pagingSeen(params, schema, query) {
114
+ const declared = new Set(params.map((p) => p.name));
115
+ const extra = [];
116
+ for (const [name, value] of query) {
117
+ if (value !== '' && !declared.has(name)) {
118
+ declared.add(name);
119
+ extra.push({ name, in: 'query', required: false, schema: guess(value) });
120
+ }
121
+ }
122
+ return extra.length === 0 ? undefined : pagingOf([...params, ...extra], schema);
123
+ }
124
+ /** The token a body offers for the next page, or nothing when it offers none. */
125
+ export function tokenOf(value, paging) {
126
+ if (!paging.next) {
127
+ return undefined;
128
+ }
129
+ const holder = holderOf(value, paging.next);
130
+ const token = holder?.[paging.next];
131
+ if (token === null || token === undefined || token === '') {
132
+ return undefined;
133
+ }
134
+ return typeof token === 'object' ? JSON.stringify(token) : String(token);
135
+ }
136
+ /**
137
+ * A last line of defence, for the generator that is already on disk: a body
138
+ * offering the very token it was given is cut back to "no more pages".
139
+ *
140
+ * Deliberately timid. Nothing re-validates a generator's output on the way to
141
+ * the client, so writing `null` into a required, non-nullable property would
142
+ * trade a client that hangs for a mock that lies — and a hang is at least
143
+ * obvious. Where the schema leaves no room, this changes nothing and says so.
144
+ */
145
+ export function cutLoop(value, paging, sent) {
146
+ if (!paging.next || tokenOf(value, paging) !== sent) {
147
+ return false;
148
+ }
149
+ const holder = holderOf(value, paging.next);
150
+ if (!holder) {
151
+ return false;
152
+ }
153
+ if (paging.nextNullable) {
154
+ holder[paging.next] = null;
155
+ }
156
+ else if (!paging.nextRequired) {
157
+ delete holder[paging.next];
158
+ }
159
+ else {
160
+ return false;
161
+ }
162
+ const more = paging.more ? holderOf(value, paging.more) : undefined;
163
+ if (more && paging.more) {
164
+ more[paging.more] = false;
165
+ }
166
+ return true;
167
+ }
168
+ /** The nearest object carrying `name`; a real body nests its envelope. */
169
+ function holderOf(value, name) {
170
+ const seen = new Set();
171
+ let level = [value];
172
+ while (level.length > 0) {
173
+ const next = [];
174
+ for (const node of level) {
175
+ if (typeof node !== 'object' || node === null || seen.has(node)) {
176
+ continue;
177
+ }
178
+ seen.add(node);
179
+ if (Array.isArray(node)) {
180
+ next.push(...node);
181
+ continue;
182
+ }
183
+ const record = node;
184
+ if (name in record) {
185
+ return record;
186
+ }
187
+ next.push(...Object.values(record));
188
+ }
189
+ level = next;
190
+ }
191
+ return undefined;
192
+ }
193
+ function itemsOf(declared) {
194
+ const named = declared.find((d) => ITEMS_PROPS.has(squash(d.name)) && listish(d.schema));
195
+ return (named ?? declared.find((d) => listish(d.schema)))?.name;
196
+ }
197
+ const types = (schema) => {
198
+ const type = schema.type;
199
+ return typeof type === 'string' ? [type] : Array.isArray(type) ? type : [];
200
+ };
201
+ const numeric = (schema) => types(schema).some((t) => t === 'integer' || t === 'number');
202
+ const boolish = (schema) => types(schema).includes('boolean');
203
+ const listish = (schema) => types(schema).includes('array') || schema.items !== undefined;
204
+ const nullable = (schema) => types(schema).includes('null');
205
+ /** An undeclared parameter has only its value to be typed by. */
206
+ const guess = (value) => ({
207
+ type: /^-?\d+$/.test(value) ? 'integer' : 'string',
208
+ });
209
+ //# sourceMappingURL=paging.js.map
package/dist/probe.d.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import type { GeneratorInput } from './envelope.ts';
2
- import type { Schema } from './schema.ts';
2
+ import type { Paging } from './paging.ts';
3
+ import { type Schema } from './schema.ts';
3
4
  import type { Operation } from './spec.ts';
4
5
  import type { Issue } from './validate.ts';
5
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;
6
11
  export declare function echoIssues(input: GeneratorInput, value: unknown, schema: Schema | undefined): Issue[];
7
12
  //# sourceMappingURL=probe.d.ts.map
package/dist/probe.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { propertyNames } from "./schema.js";
1
2
  // ---------------------------------------------------------------------------
2
3
  // Probes
3
4
  //
@@ -165,6 +166,30 @@ function text(schema, variant) {
165
166
  return base.length >= min ? base : base.padEnd(min, 'x');
166
167
  }
167
168
  // ---------------------------------------------------------------------------
169
+ // The walk
170
+ //
171
+ // The probes above are independent, which is the right shape for everything one
172
+ // response can be wrong about and the wrong shape for pagination: a cursor only
173
+ // means anything in the answer it arrived with, so the pages have to be asked
174
+ // for in order.
175
+ //
176
+ // The seed is held still across the whole walk on purpose. A generator that
177
+ // mints its token out of `seed` rather than out of the request is the exact
178
+ // mistake being looked for, and a still seed makes it a fixed point — visible
179
+ // on the second page here instead of on somebody's client.
180
+ // ---------------------------------------------------------------------------
181
+ /** The first page: an ordinary probe with the paging control taken back off. */
182
+ export function walkStart(operation, paging) {
183
+ const input = probesFor(operation)[0];
184
+ const query = { ...input.query };
185
+ delete query[paging.param];
186
+ return { ...input, query };
187
+ }
188
+ /** The same request again, asking for whatever the last answer pointed at. */
189
+ export function nextPage(previous, paging, token) {
190
+ return { ...previous, query: { ...previous.query, [paging.param]: token } };
191
+ }
192
+ // ---------------------------------------------------------------------------
168
193
  // The echo rule
169
194
  //
170
195
  // `get_user_by_id(12324)` answering `{ user_id: 999 }` validates perfectly and
@@ -199,47 +224,6 @@ export function echoIssues(input, value, schema) {
199
224
  }
200
225
  return out;
201
226
  }
202
- /** Every property name the schema mentions, at any depth. */
203
- function propertyNames(schema) {
204
- const out = new Set();
205
- const seen = new Set();
206
- const stack = [schema];
207
- while (stack.length > 0) {
208
- const node = stack.pop();
209
- if (typeof node !== 'object' || node === null) {
210
- continue;
211
- }
212
- if (Array.isArray(node)) {
213
- stack.push(...node);
214
- continue;
215
- }
216
- if (seen.has(node)) {
217
- continue;
218
- }
219
- seen.add(node);
220
- const record = node;
221
- const properties = record.properties;
222
- if (typeof properties === 'object' && properties !== null) {
223
- for (const [name, sub] of Object.entries(properties)) {
224
- out.add(name);
225
- stack.push(sub);
226
- }
227
- }
228
- // `$defs` holds schemas under arbitrary names, so its *values* are the
229
- // subschemas — pushing the map itself would walk one level and stop,
230
- // which is where every hoisted recursive schema lives.
231
- for (const key of ['$defs', 'patternProperties']) {
232
- const map = record[key];
233
- if (typeof map === 'object' && map !== null) {
234
- stack.push(...Object.values(map));
235
- }
236
- }
237
- for (const key of ['items', 'allOf', 'anyOf', 'oneOf', 'prefixItems', 'not']) {
238
- stack.push(record[key]);
239
- }
240
- }
241
- return out;
242
- }
243
227
  /** Whether `name` anywhere in the value holds something equal to `expected`. */
244
228
  function carries(value, name, expected) {
245
229
  const stack = [value];
package/dist/prompt.js CHANGED
@@ -37,7 +37,9 @@ export const SYSTEM = [
37
37
  ' with `user_id=12324` answers with `user_id` 12324, not a random one. Do',
38
38
  ' this generically, by looking the names up at run time. Do the same for a',
39
39
  ' query parameter where it plainly describes the content rather than',
40
- ' controlling the call.',
40
+ ' controlling the call. A paging control — a cursor, page, offset or',
41
+ ' page-size parameter — is never content and must not be copied into the',
42
+ ' body; see PAGINATION below where the operation has one.',
41
43
  '3. Every required property must be present. Optional ones may be omitted',
42
44
  ' sometimes; that is what makes a mock useful.',
43
45
  '4. Values must suit their names, not just their types. Use `faker` for anything',
@@ -71,10 +73,62 @@ export function brief(operation) {
71
73
  if (operation.requestBody) {
72
74
  lines.push('', 'REQUEST BODY SCHEMA (arrives as `body`)', json(operation.requestBody.schema));
73
75
  }
76
+ if (operation.paging) {
77
+ lines.push('', ...pagination(operation.paging));
78
+ }
74
79
  lines.push('', `RESPONSE SCHEMA (status ${operation.success.status})`);
75
80
  lines.push(operation.success.schema ? json(operation.success.schema) : ' (no body)');
76
81
  return lines.join('\n');
77
82
  }
83
+ /**
84
+ * Said only to the operations that page, and said in terms of their own
85
+ * property names. The rule that earns the paragraph is the third one: a body
86
+ * offering the token it was just given passes the schema, passes the echo rule,
87
+ * and hangs every client that walks the list.
88
+ */
89
+ function pagination(paging) {
90
+ const advance = paging.style === 'cursor'
91
+ ? 'the base64 of a small JSON object holding the next page index, such as {"p": 2}'
92
+ : "the offset of the next page — this page's offset plus its size";
93
+ const lines = [
94
+ 'PAGINATION',
95
+ ` This operation is paged. \`${paging.param}\` asks for a page;`,
96
+ ' absent or empty means the first one.',
97
+ ' - Fabricate three pages in total and no more.',
98
+ ];
99
+ if (paging.next) {
100
+ lines.push(` - \`${paging.next}\` carries the token for the page after this one.`, ` Build it out of \`${paging.param}\`:`, ` ${advance}.`, ' - Never build it out of `seed`. Unpinned, the seed changes on every', ' request; pinned, it is a function of the query. A token made from it', ' either wanders or never changes.', ' - It must strictly advance. Answering with the token you were given is', ' the one failure that matters: a client following it loops forever.');
101
+ }
102
+ if (paging.more && !stuck(paging)) {
103
+ lines.push(` - \`${paging.more}\` is false on the last page and true before it.`);
104
+ }
105
+ if (paging.next) {
106
+ lines.push(...last(paging));
107
+ }
108
+ lines.push(' - A token you cannot read, or one past the end, is the last page,', ` ended the same way and with ${paging.items ? `\`${paging.items}\` empty` : 'nothing listed'}.`, ' Never an error, and never the first page again.');
109
+ return lines;
110
+ }
111
+ /**
112
+ * How the last page says so. A token that is required and cannot be null has
113
+ * nowhere to put the ending, so the ending has to be said some other way —
114
+ * telling the model to null it anyway would only ask for an invalid body.
115
+ */
116
+ const stuck = (paging) => !paging.nextNullable && paging.nextRequired === true;
117
+ function last(paging) {
118
+ if (paging.nextNullable) {
119
+ return [` - On the last page set \`${paging.next}\` to null.`];
120
+ }
121
+ if (!stuck(paging)) {
122
+ return [` - On the last page leave \`${paging.next}\` out.`];
123
+ }
124
+ const otherwise = [paging.more && `\`${paging.more}\` false`, paging.items && 'nothing listed']
125
+ .filter(Boolean)
126
+ .join(' and ');
127
+ return [
128
+ ` - The schema requires \`${paging.next}\` on every page, so the last page`,
129
+ ` ends the list the other way: ${otherwise || 'an empty page'}.`,
130
+ ];
131
+ }
78
132
  /**
79
133
  * Repeated in the file the model writes, so it is worth spelling out: the
80
134
  * schema it validates against must be the one it was shown, embedded, not
package/dist/schema.d.ts CHANGED
@@ -5,4 +5,17 @@ export type Dialect = 'swagger-2.0' | 'openapi-3.0' | 'openapi-3.1';
5
5
  * than once into `$defs`. The result is acyclic and safe to stringify.
6
6
  */
7
7
  export declare function normalize(root: unknown, dialect: Dialect): Schema;
8
+ export interface Declared {
9
+ name: string;
10
+ /** the property's own schema, with a `$defs` pointer already followed */
11
+ schema: Schema;
12
+ /** whether the object declaring it lists it in `required` */
13
+ required: boolean;
14
+ /** how many objects deep it sits; 0 is the top level */
15
+ depth: number;
16
+ }
17
+ /** Every property a schema declares, at any depth, nearest first. */
18
+ export declare function properties(root: Schema): Declared[];
19
+ /** Every property name a schema mentions, at any depth. */
20
+ export declare const propertyNames: (root: Schema) => Set<string>;
8
21
  //# sourceMappingURL=schema.d.ts.map
package/dist/schema.js CHANGED
@@ -218,4 +218,79 @@ function repeated(root) {
218
218
  }
219
219
  return twice;
220
220
  }
221
+ /** Every property a schema declares, at any depth, nearest first. */
222
+ export function properties(root) {
223
+ const out = [];
224
+ const seen = new Set();
225
+ let level = [root];
226
+ for (let depth = 0; level.length > 0; depth++) {
227
+ const next = [];
228
+ for (const raw of level) {
229
+ const node = resolve(raw, root);
230
+ if (node === undefined || seen.has(node)) {
231
+ continue;
232
+ }
233
+ seen.add(node);
234
+ const required = new Set(Array.isArray(node.required)
235
+ ? node.required.filter((n) => typeof n === 'string')
236
+ : []);
237
+ const props = node.properties;
238
+ if (isObject(props)) {
239
+ for (const [name, sub] of Object.entries(props)) {
240
+ const target = resolve(sub, root);
241
+ if (target === undefined) {
242
+ continue;
243
+ }
244
+ out.push({ name, schema: target, required: required.has(name), depth });
245
+ next.push(sub);
246
+ }
247
+ }
248
+ for (const key of ONE) {
249
+ next.push(node[key]);
250
+ }
251
+ for (const key of LIST) {
252
+ const value = node[key];
253
+ if (Array.isArray(value)) {
254
+ next.push(...value);
255
+ }
256
+ }
257
+ for (const key of MAP) {
258
+ if (key === 'properties') {
259
+ continue;
260
+ }
261
+ const value = node[key];
262
+ if (isObject(value)) {
263
+ next.push(...Object.values(value));
264
+ }
265
+ }
266
+ const items = node.items;
267
+ next.push(...(Array.isArray(items) ? items : [items]));
268
+ }
269
+ level = next;
270
+ }
271
+ return out;
272
+ }
273
+ /** Every property name a schema mentions, at any depth. */
274
+ export const propertyNames = (root) => new Set(properties(root).map((p) => p.name));
275
+ /** A schema, with a local `#/$defs/...` pointer followed as far as it goes. */
276
+ function resolve(value, root) {
277
+ let at = value;
278
+ for (let hop = 0; hop < MAX_HOPS; hop++) {
279
+ if (!isObject(at)) {
280
+ return undefined;
281
+ }
282
+ const ref = at.$ref;
283
+ if (typeof ref !== 'string' || !ref.startsWith(DEFS)) {
284
+ return at;
285
+ }
286
+ const defs = root.$defs;
287
+ if (!isObject(defs)) {
288
+ return at;
289
+ }
290
+ at = defs[ref.slice(DEFS.length)];
291
+ }
292
+ return undefined;
293
+ }
294
+ const DEFS = '#/$defs/';
295
+ const MAX_HOPS = 8;
221
296
  //# sourceMappingURL=schema.js.map
package/dist/server.js CHANGED
@@ -2,6 +2,7 @@ import { createHash, randomInt } from 'node:crypto';
2
2
  import { createServer } from 'node:http';
3
3
  import { BuildFailed } from "./cache.js";
4
4
  import { reason } from "./generate.js";
5
+ import { cutLoop, pagingSeen } from "./paging.js";
5
6
  import { describeIssues, issues } from "./validate.js";
6
7
  // ---------------------------------------------------------------------------
7
8
  // The server
@@ -134,8 +135,24 @@ async function handle(req, res, opts) {
134
135
  say(502, 'generator faulted');
135
136
  return;
136
137
  }
138
+ const note = generator.cached ? 'hit' : 'miss';
139
+ const looped = cut(operation, outcome.value, url.searchParams);
137
140
  send(res, operation.success.status, outcome.value);
138
- say(operation.success.status, generator.cached ? 'hit' : 'miss');
141
+ say(operation.success.status, looped ? `${note} · cut a looping page token` : note);
142
+ }
143
+ /**
144
+ * The generator on disk was written before the pagination rule existed, and a
145
+ * cache is not rebuilt just because the rule changed. A body offering back the
146
+ * token it was handed is therefore still possible, and it is the one bug here
147
+ * that costs the client rather than the mock: it hangs.
148
+ */
149
+ function cut(operation, value, query) {
150
+ const paging = operation.paging ?? pagingSeen(operation.params, operation.success.schema, query);
151
+ if (!paging) {
152
+ return false;
153
+ }
154
+ const sent = query.get(paging.param);
155
+ return sent !== null && cutLoop(value, paging, sent);
139
156
  }
140
157
  function check(operation, checks, pathParams, query, body) {
141
158
  const compiled = checks.for(operation);
@@ -235,6 +252,7 @@ function introspect(pathname, res, opts) {
235
252
  operationId: o.operationId,
236
253
  status: o.success.status,
237
254
  body: Boolean(o.success.schema),
255
+ paging: o.paging,
238
256
  key: o.key,
239
257
  source: o.source,
240
258
  })));
package/dist/spec.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CliError } from '@zenera/cli/lib';
2
+ import { type Paging } from './paging.ts';
2
3
  import { type Schema } from './schema.ts';
3
4
  export declare const METHODS: readonly ["get", "put", "post", "delete", "patch", "head", "options"];
4
5
  export type Method = (typeof METHODS)[number];
@@ -31,6 +32,8 @@ export interface Operation {
31
32
  status: number;
32
33
  schema?: Schema;
33
34
  };
35
+ /** how the operation turns pages, when it turns pages at all */
36
+ paging?: Paging;
34
37
  }
35
38
  /** A `CliError` so an unreadable document exits 3 wherever it is raised. */
36
39
  export declare class SpecError extends CliError {
package/dist/spec.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import SwaggerParser from '@apidevtools/swagger-parser';
2
- import { createHash } from 'node:crypto';
3
2
  import { CliError, EXIT } from '@zenera/cli/lib';
3
+ import { createHash } from 'node:crypto';
4
+ import { pagingOf } from "./paging.js";
4
5
  import { normalize } from "./schema.js";
5
6
  // ---------------------------------------------------------------------------
6
7
  // Documents, flattened
@@ -103,6 +104,7 @@ function build(b) {
103
104
  params: b.params,
104
105
  requestBody: b.body,
105
106
  success,
107
+ paging: pagingOf(b.params, success.schema),
106
108
  };
107
109
  return { ...operation, key: keyOf(operation) };
108
110
  }
@@ -123,6 +125,12 @@ function keyOf(op) {
123
125
  .map((p) => [p.in, p.name, p.required, canonical(p.schema)]),
124
126
  body: op.requestBody ? [op.requestBody.required, canonical(op.requestBody.schema)] : null,
125
127
  success: [op.success.status, op.success.schema ? canonical(op.success.schema) : null],
128
+ // Derived from the two above, so it adds nothing to the identity — it is
129
+ // here to *change* it, once, for the operations whose generator now has
130
+ // a pagination rule to obey. Absent rather than null when there is no
131
+ // paging, so that everything else hashes to exactly what it did before
132
+ // and no one else is asked to rebuild.
133
+ ...(op.paging ? { paging: canonical(op.paging) } : {}),
126
134
  };
127
135
  return createHash('sha256').update(JSON.stringify(shape)).digest('hex').slice(0, 16);
128
136
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/faker",
3
- "version": "1.1.9",
3
+ "version": "1.1.11",
4
4
  "description": "Mock HTTP server for swagger/OpenAPI documents, with response bodies generated by a model.",
5
5
  "keywords": [
6
6
  "openapi",
@@ -45,7 +45,7 @@
45
45
  "@apidevtools/swagger-parser": "^12.0.0",
46
46
  "ajv": "^8.17.1",
47
47
  "ajv-formats": "^3.0.1",
48
- "@zenera/cli": "^1.1.9",
49
- "@zenera/neo": "^1.1.9"
48
+ "@zenera/cli": "^1.1.11",
49
+ "@zenera/neo": "^1.1.11"
50
50
  }
51
51
  }