@zenera/faker 1.1.10 → 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,9 +60,11 @@ 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`.
@@ -88,7 +90,7 @@ happen, all in the operation's own names:
88
90
  - at request time a token identical to the one just sent is **cut** — nulled or
89
91
  dropped, whichever the schema allows — and the request line says so. Nothing
90
92
  is invented in its place; a generator written before this rule existed is
91
- still on disk, and a cache is not rebuilt because a rule changed.
93
+ still cached, and a cache is not rebuilt because a rule changed.
92
94
 
93
95
  Only paginated operations are affected. Their cache keys changed once, so they
94
96
  are written again on first use; everything else keeps the key it had.
@@ -110,8 +112,8 @@ zen faker cache ls | clear What has been generated, or throw it away.
110
112
 
111
113
  Useful options: `--port`, `--host` (reachable only from this machine by
112
114
  default), `--model`, `--seed` (same request, same answer), `--rebuild`,
113
- `--attempts`, `--concurrency`, `--timeout`, `--cache <dir>`, `--quiet`.
114
- `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.
115
117
 
116
118
  `GET /__faker/routes` lists what is being served; `GET /__faker/health` is a
117
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/faker",
3
- "version": "1.1.10",
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.10",
49
- "@zenera/neo": "^1.1.10"
48
+ "@zenera/cli": "^1.1.11",
49
+ "@zenera/neo": "^1.1.11"
50
50
  }
51
51
  }