@crvouga/mockingbird-service-rxvortex 1.0.0 → 1.1.0

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/CHANGELOG.md CHANGED
@@ -1,15 +1,17 @@
1
1
  # Changelog — @crvouga/mockingbird-service-rxvortex
2
2
 
3
+ ## 1.1.0 (2026-09-25)
4
+
5
+ ### Features
6
+
7
+ - load a preset catalog into a served mock and promote to ready ([471288e](https://github.com/crvouga/mockingbird/commit/471288e46b1f34a62f0c1b7e7a76dc30920c39e7))
8
+
3
9
  ## 1.0.0 (2026-09-24)
4
10
 
5
11
  ### ⚠️ Breaking changes
6
12
 
7
13
  - vendor branding for every service; purge internal mocks and consumer names ([f689534](https://github.com/crvouga/mockingbird/commit/f6895343543550dafcb7d76b426cae7635e03226))
8
14
 
9
- ### Dependencies
10
-
11
- - `@crvouga/mockingbird-service-sqlite`
12
-
13
15
  ## 0.2.0 (2026-09-23)
14
16
 
15
17
  ### Features
package/README.md CHANGED
@@ -29,9 +29,15 @@ value as `--webhook-secret`.
29
29
  npx mockingbird-rxvortex serve --port 8791 \
30
30
  --webhook-url http://127.0.0.1:3000/prescriptions/webhooks/rxvortex \
31
31
  --webhook-secret "$RXVORTEX_WEBHOOK_SECRET" \
32
- --auto-advance "2000:Fill,Shipping,Delivered"
32
+ --auto-advance "2000:Fill,Shipping,Delivered" \
33
+ --catalog ./preset-catalog.json
33
34
  ```
34
35
 
36
+ `--catalog` loads your own preset catalog into every namespace: a JSON array of rows, or the
37
+ `{data: [...]}` body `GET /api/v1/preset-catalog-items` answers with, so a recorded sandbox
38
+ response loads as-is. Only `catalog_id` and `medication_name` are required. `POST /__admin/reset`
39
+ returns to these rows. Pass `--unknown-presets accept` to take any well-formed preset id instead.
40
+
35
41
  ```ts
36
42
  import { createRuntime } from "@crvouga/mockingbird-service-rxvortex"
37
43
 
@@ -62,7 +68,7 @@ await post("/__admin/orders/pay_123/transition", { to: "Delivered" })
62
68
  | Route | Behaviour |
63
69
  | --- | --- |
64
70
  | `POST /api/v1/generate-access-token` | JSON `{client_id, client_secret}` → `{access_token, token_type: "Bearer", expires_in: 86400}`. Any pair works unless `clients` is set (`PUT /__admin/settings`). Tokens stay valid 24 h on the mock clock; our client caches for 24 h and never refreshes on 401. |
65
- | `POST /api/v1/orders` | Validates the submit payload against the contract; a violation is 422 `{message, errors: {"patient.phone": ["…"]}}`. An inactive or unknown `preset_catalog_id` is 422. A repeated `order.sender_order_id` is 409. Success: `{success: true, order_tracking_id: "RXV-…", sender_order_id, status: "Created"}` (the tracking id is always a string). |
71
+ | `POST /api/v1/orders` | Validates the submit payload against the contract; a violation is 422 `{message, errors: {"patient.phone": ["…"]}}`. An inactive or unknown `preset_catalog_id` is 422 (under `unknownPresets: "accept"` an unknown UUID becomes an active catalog row named after the request's `medication_name`). A repeated `order.sender_order_id` is 409. Success: `{success: true, order_tracking_id: "RXV-…", sender_order_id, status: "Created"}` (the tracking id is always a string). |
66
72
  | `GET /api/v1/orders/{id}` | `id` is the tracking id **or** the sender order id (our payment id, the recovery lookup). Returns `rxstatus`, `orderstatus`, `shipping_status`, `delivered_date`, `trackingnumber`, `shippingservice`, `shippingcarrier`, `shipmenttrackingurl`, `cancellable`, and the id under `order_tracking_id`, `tracking_id` and `orderReferenceID`. |
67
73
  | `DELETE /api/v1/orders/{id}` | Cancels while `cancellable` (until shipped), emitting the webhook; otherwise 409. |
68
74
  | `GET /api/v1/preset-catalog-items` | `{data: [...]}` rows with `catalog_id`, `medication_name`, `medication_strength`, `package_size`, `quantity`, `quantity_units`, `medication_form`, `route`, `states`, `status`. Includes the custom-cream anchor preset `e404ad76-0f82-4b04-8f25-841650e2e819` and one inactive row. |
@@ -82,7 +88,9 @@ now, and `PUT /__admin/webhook-endpoints` sets per-namespace receivers.
82
88
  | Route | Effect |
83
89
  | --- | --- |
84
90
  | `POST /__admin/orders/:id/transition` | `{to, trackingnumber?, shippingcarrier?, shippingservice?, delivered_date?}`. `to` is a vendor status: `Fill`, `PV1 Complete`, `Compound`, `Out of Stock`, `On Hold`, `Shipping`, `Delivered`, `Cancelled`, `Rejected`, `Error`, or any string (used verbatim). The three status fields move together; shipping generates tracking when none is given. |
85
- | `PUT /__admin/settings` | `{tokenTtlSeconds?, staticTokens?, clients?, autoAdvance?: {afterMs, path} \| null}` for the calling namespace. `staticTokens` admits `RXVORTEX_API_TOKEN` (the catalog client's static bearer). |
91
+ | `PUT /__admin/catalog` | `{items: [{catalog_id, medication_name, medication_strength?, package_size?, quantity?, quantity_units?, medication_form?, route?, states?, status?}], mode?: "replace" \| "merge"}` for the calling namespace (`replace` is the default; `merge` upserts by `catalog_id`). Answers `{count}`, the namespace's row count. `items` may also be sent as `data`. |
92
+ | `GET /__admin/catalog` | The namespace's rows, as `{data: [...]}`. |
93
+ | `PUT /__admin/settings` | `{tokenTtlSeconds?, staticTokens?, clients?, autoAdvance?: {afterMs, path} \| null, unknownPresets?: "reject" \| "accept"}` for the calling namespace. `unknownPresets` defaults to `reject`, as the sandbox does. `staticTokens` admits `RXVORTEX_API_TOKEN` (the catalog client's static bearer). |
86
94
  | `POST /__admin/tick` | Apply every auto-advance step that is due on the mock clock (the served mock also ticks every 100 ms). |
87
95
  | `GET /__admin/orders` | The namespace's orders. |
88
96
 
@@ -104,7 +112,8 @@ the client id they were issued to).
104
112
  - Real fulfilment timing: nothing moves on its own unless `autoAdvance` is set.
105
113
  - Patient and prescriber details are validated, never stored or echoed back.
106
114
  - The live catalog: the default rows are synthesised in the live client's field names (no
107
- sandbox recording exists); pass `catalog` to load recorded rows.
115
+ sandbox recording exists). Load recorded rows with `serve --catalog`, `PUT /__admin/catalog`,
116
+ or the `catalog` option.
108
117
  - Refills and multi-order shipments.
109
118
 
110
119
  ## API
@@ -118,7 +127,8 @@ the client id they were issued to).
118
127
  | `tokenCredential` | function | The client id a bearer token was issued to (how credentials map to namespaces). |
119
128
  | `CUSTOM_CREAM_ANCHOR_PRESET_ID` | string | The sandbox custom-cream anchor preset id. |
120
129
  | `DEFAULT_CATALOG` | array | The default preset catalog rows. |
130
+ | `parseCatalog`, `parseCatalogItem` | functions | Catalog rows from a JSON array or a recorded `{data: [...]}` body (missing optional fields default to `null`, `states` to `[]`, `status` to `"active"`); throw on a malformed row. |
121
131
  | `document`, `operationIds`, `supportedOperationIds` | values | The vendored OpenAPI contract and its operation ids. |
122
- | `createServer`, `serveTarget`, `DEFAULT_PORT` (`./server`) | Node | Serve over `node:http` (auto-advance ticks every 100 ms); the `serve` CLI target; port 8791. |
132
+ | `createServer`, `serveTarget`, `DEFAULT_PORT`, `loadCatalogFile` (`./server`) | Node | Serve over `node:http` (auto-advance ticks every 100 ms); the `serve` CLI target; port 8791; read a `--catalog` file. |
123
133
 
124
134
  Part of [mockingbird](https://github.com/crvouga/mockingbird).
@@ -1,6 +1,10 @@
1
1
  import {
2
- createRuntime
3
- } from "./chunk-DNELVPK4.js";
2
+ createRuntime,
3
+ parseCatalog
4
+ } from "./chunk-VQAJFAUO.js";
5
+
6
+ // src/server.ts
7
+ import { readFileSync } from "node:fs";
4
8
 
5
9
  // ../../adapters/node/dist/cli.js
6
10
  import { readFile } from "node:fs/promises";
@@ -318,6 +322,19 @@ var createServer2 = async (options = {}) => {
318
322
  };
319
323
  };
320
324
  var text = (value) => typeof value === "string" ? value : void 0;
325
+ var loadCatalogFile = (path) => {
326
+ let raw;
327
+ try {
328
+ raw = readFileSync(path, "utf8");
329
+ } catch {
330
+ throw new Error(`rxvortex catalog not found: ${path}`);
331
+ }
332
+ try {
333
+ return parseCatalog(JSON.parse(raw));
334
+ } catch (error) {
335
+ throw new Error(`rxvortex catalog ${path}: ${error.message}`);
336
+ }
337
+ };
321
338
  var serveTarget = {
322
339
  name: "rxvortex",
323
340
  defaultPort: DEFAULT_PORT,
@@ -337,6 +354,16 @@ var serveTarget = {
337
354
  value: "<token>",
338
355
  description: "A static bearer token to accept (the app's RXVORTEX_API_TOKEN)"
339
356
  },
357
+ catalog: {
358
+ type: "string",
359
+ value: "<file.json>",
360
+ description: "Preset catalog every namespace starts with and resets to: a JSON array, or {data: [...]} as GET /api/v1/preset-catalog-items returns"
361
+ },
362
+ "unknown-presets": {
363
+ type: "string",
364
+ value: "<reject|accept>",
365
+ description: "An unknown preset_catalog_id: 422 like the sandbox (reject, default) or add it (accept)"
366
+ },
340
367
  "auto-advance": {
341
368
  type: "string",
342
369
  value: "<ms:Status,Status,\u2026>",
@@ -348,13 +375,19 @@ var serveTarget = {
348
375
  const secret = text(values["webhook-secret"]);
349
376
  const token = text(values["api-token"]);
350
377
  const auto = text(values["auto-advance"]);
378
+ const catalogPath = text(values.catalog);
379
+ const unknownPresets = text(values["unknown-presets"]);
380
+ if (unknownPresets !== void 0 && unknownPresets !== "reject" && unknownPresets !== "accept")
381
+ throw new Error('--unknown-presets must be "reject" or "accept"');
351
382
  const plan = auto ? /^(\d+):(.+)$/.exec(auto) : null;
352
383
  if (auto && !plan)
353
384
  throw new Error('--auto-advance must look like "2000:Fill,Shipping,Delivered"');
354
385
  return createRuntime({
355
386
  tickMs: 100,
356
387
  ...url ? { webhooks: { url, ...secret ? { secret } : {} } } : {},
388
+ ...catalogPath ? { catalog: loadCatalogFile(catalogPath) } : {},
357
389
  settings: {
390
+ ...unknownPresets ? { unknownPresets } : {},
358
391
  ...token ? { staticTokens: [token] } : {},
359
392
  ...plan ? {
360
393
  autoAdvance: {
@@ -379,6 +412,7 @@ export {
379
412
  serveCommand,
380
413
  DEFAULT_PORT,
381
414
  createServer2 as createServer,
415
+ loadCatalogFile,
382
416
  serveTarget
383
417
  };
384
- //# sourceMappingURL=chunk-ZAM35JQO.js.map
418
+ //# sourceMappingURL=chunk-SNW7NYY6.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/server.ts", "../../../adapters/node/src/cli.ts", "../../../adapters/node/src/serve.ts", "../../../adapters/node/src/listen.ts"],
4
+ "sourcesContent": ["/// <reference types=\"node\" />\nimport { readFileSync } from \"node:fs\"\nimport { type Listening, listen, type ServeTarget } from \"@crvouga/mockingbird-adapter-node\"\nimport { type CatalogItem, parseCatalog } from \"./catalog.js\"\nimport { createRuntime, type RxVortexRuntime, type RxVortexRuntimeOptions } from \"./runtime.js\"\n\n/** Port `mockingbird-rxvortex serve` listens on when none is given. */\nexport const DEFAULT_PORT = 8791\n\nexport type RxVortexServerOptions = RxVortexRuntimeOptions & {\n /** Default `0`: the OS picks a free port. */\n port?: number\n /** Default `127.0.0.1`. */\n host?: string\n}\n\nexport type RxVortexServer = Listening & { runtime: RxVortexRuntime }\n\n/** Serve the RxVortex mock over `node:http`, with auto-advance ticking every 100 ms. */\nexport const createServer = async (\n options: RxVortexServerOptions = {},\n): Promise<RxVortexServer> => {\n const { port, host, ...rest } = options\n const runtime = createRuntime({ tickMs: 100, ...rest })\n const listening = await listen(runtime, {\n port: port ?? 0,\n ...(host !== undefined ? { host } : {}),\n })\n return {\n ...listening,\n runtime,\n close: async () => {\n runtime.stop()\n await listening.close()\n },\n }\n}\n\nconst text = (value: string | boolean | undefined) =>\n typeof value === \"string\" ? value : undefined\n\n/**\n * Read a catalog file: a JSON array of rows, or the `{data: [...]}` body\n * `GET /api/v1/preset-catalog-items` answers with, so a recorded response loads as-is.\n */\nexport const loadCatalogFile = (path: string): CatalogItem[] => {\n let raw: string\n try {\n raw = readFileSync(path, \"utf8\")\n } catch {\n throw new Error(`rxvortex catalog not found: ${path}`)\n }\n try {\n return parseCatalog(JSON.parse(raw))\n } catch (error) {\n throw new Error(`rxvortex catalog ${path}: ${(error as Error).message}`)\n }\n}\n\n/** How `serve` (and `serve --config`) builds the RxVortex mock from flags. */\nexport const serveTarget: ServeTarget = {\n name: \"rxvortex\",\n defaultPort: DEFAULT_PORT,\n options: {\n \"webhook-url\": {\n type: \"string\",\n value: \"<url>\",\n description:\n \"Deliver status webhooks here (e.g. http://127.0.0.1:3000/prescriptions/webhooks/rxvortex)\",\n },\n \"webhook-secret\": {\n type: \"string\",\n value: \"<secret>\",\n description: \"Sent as x-rxvortex-webhook-secret (the app's RXVORTEX_WEBHOOK_SECRET)\",\n },\n \"api-token\": {\n type: \"string\",\n value: \"<token>\",\n description: \"A static bearer token to accept (the app's RXVORTEX_API_TOKEN)\",\n },\n catalog: {\n type: \"string\",\n value: \"<file.json>\",\n description:\n \"Preset catalog every namespace starts with and resets to: a JSON array, or {data: [...]} as GET /api/v1/preset-catalog-items returns\",\n },\n \"unknown-presets\": {\n type: \"string\",\n value: \"<reject|accept>\",\n description:\n \"An unknown preset_catalog_id: 422 like the sandbox (reject, default) or add it (accept)\",\n },\n \"auto-advance\": {\n type: \"string\",\n value: \"<ms:Status,Status,\u2026>\",\n description: 'Walk every new order along a path, e.g. \"2000:Fill,Shipping,Delivered\"',\n },\n },\n create: (values, common) => {\n const url = text(values[\"webhook-url\"])\n const secret = text(values[\"webhook-secret\"])\n const token = text(values[\"api-token\"])\n const auto = text(values[\"auto-advance\"])\n const catalogPath = text(values.catalog)\n const unknownPresets = text(values[\"unknown-presets\"])\n if (unknownPresets !== undefined && unknownPresets !== \"reject\" && unknownPresets !== \"accept\")\n throw new Error('--unknown-presets must be \"reject\" or \"accept\"')\n const plan = auto ? /^(\\d+):(.+)$/.exec(auto) : null\n if (auto && !plan)\n throw new Error('--auto-advance must look like \"2000:Fill,Shipping,Delivered\"')\n return createRuntime({\n tickMs: 100,\n ...(url ? { webhooks: { url, ...(secret ? { secret } : {}) } } : {}),\n ...(catalogPath ? { catalog: loadCatalogFile(catalogPath) } : {}),\n settings: {\n ...(unknownPresets ? { unknownPresets } : {}),\n ...(token ? { staticTokens: [token] } : {}),\n ...(plan\n ? {\n autoAdvance: {\n afterMs: Number(plan[1]),\n path: (plan[2] as string).split(\",\").map((s) => s.trim()),\n },\n }\n : {}),\n },\n ...(common.adminKey !== undefined ? { adminKey: common.adminKey } : {}),\n ...(common.seed !== undefined ? { seed: common.seed } : {}),\n ...(common.onLog ? { onLog: common.onLog } : {}),\n })\n },\n banner: () => [\n \"auth: POST /api/v1/generate-access-token {client_id, client_secret}, then Authorization: Bearer <token>\",\n \"namespaces: x-mockingbird-namespace, /ns/<name>/\u2026, or PUT /__admin/credentials {<client_id>: <ns>}\",\n ],\n}\n", "import { readFile } from \"node:fs/promises\"\nimport { type ParseArgsConfig, parseArgs } from \"node:util\"\nimport type { RequestLog, ServiceInstance, ServiceRuntime } from \"@crvouga/mockingbird-service\"\nimport { type Listening, listen } from \"./listen.js\"\n\nexport type CliOption = {\n type: \"string\" | \"boolean\"\n description: string\n /** Shown in help; the value placeholder, e.g. `<port>`. */\n value?: string\n default?: string | boolean\n}\n\nexport type CliValues = Record<string, string | boolean | undefined>\n\nexport type CliCommand = {\n summary: string\n usage?: string\n options?: Record<string, CliOption>\n /** Resolves to an exit code; a server command resolves only when it stops. */\n run(values: CliValues, positionals: string[]): Promise<number>\n}\n\nexport type CliSpec = {\n bin: string\n description: string\n commands: Record<string, CliCommand>\n}\n\nconst optionHelp = (options: Record<string, CliOption>): string[] =>\n Object.entries(options).map(([name, option]) => {\n const flag = `--${name}${option.type === \"string\" ? ` ${option.value ?? \"<value>\"}` : \"\"}`\n const fallback = option.default !== undefined ? ` (default: ${String(option.default)})` : \"\"\n return ` ${flag.padEnd(30)} ${option.description}${fallback}`\n })\n\nconst help = (spec: CliSpec): string =>\n [\n `${spec.bin} \u2014 ${spec.description}`,\n \"\",\n \"Usage:\",\n ` ${spec.bin} <command> [options]`,\n \"\",\n \"Commands:\",\n ...Object.entries(spec.commands).map(([name, c]) => ` ${name.padEnd(30)} ${c.summary}`),\n \"\",\n `Run \\`${spec.bin} <command> --help\\` for a command's options.`,\n ].join(\"\\n\")\n\nconst commandHelp = (spec: CliSpec, name: string, command: CliCommand): string =>\n [\n `${spec.bin} ${name} \u2014 ${command.summary}`,\n \"\",\n \"Usage:\",\n ` ${command.usage ?? `${spec.bin} ${name} [options]`}`,\n ...(command.options ? [\"\", \"Options:\", ...optionHelp(command.options)] : []),\n ].join(\"\\n\")\n\n/** Parse `argv` against `spec` and run the chosen command. Resolves to an exit code. */\nexport const runCli = async (spec: CliSpec, argv: string[]): Promise<number> => {\n // Two-word commands (`corpus pull`) win over one-word ones.\n const pair = argv.length >= 2 ? `${argv[0]} ${argv[1]}` : undefined\n const words = pair !== undefined && spec.commands[pair] ? 2 : 1\n const name = words === 2 ? pair : argv[0]\n const rest = argv.slice(words)\n if (name === undefined || name === \"--help\" || name === \"-h\" || name === \"help\") {\n console.log(help(spec))\n return 0\n }\n const command = spec.commands[name]\n if (!command) {\n console.error(`${spec.bin}: unknown command ${JSON.stringify(name)}\\n\\n${help(spec)}`)\n return 2\n }\n if (rest.includes(\"--help\") || rest.includes(\"-h\")) {\n console.log(commandHelp(spec, name, command))\n return 0\n }\n let parsed: { values: CliValues; positionals: string[] }\n try {\n parsed = parseArgs({\n args: rest,\n allowPositionals: true,\n strict: true,\n options: Object.fromEntries(\n Object.entries(command.options ?? {}).map(([key, option]) => [\n key,\n {\n type: option.type,\n ...(option.default !== undefined ? { default: option.default } : {}),\n },\n ]),\n ) as ParseArgsConfig[\"options\"],\n }) as { values: CliValues; positionals: string[] }\n } catch (error) {\n console.error(\n `${spec.bin} ${name}: ${error instanceof Error ? error.message : String(error)}\\n\\n${commandHelp(spec, name, command)}`,\n )\n return 2\n }\n return command.run(parsed.values, parsed.positionals)\n}\n\n// \u2500\u2500 serve \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport type LogFormat = \"pretty\" | \"json\" | \"off\"\n\nexport type CommonServeOptions = {\n adminKey: string | undefined\n seed: string | undefined\n onLog: ((entry: RequestLog) => void) | undefined\n}\n\n/**\n * What a service contributes to `serve`: how to build its runtime from CLI flags,\n * and what to say at startup. Every service's `./server` entry exports one as\n * `serveTarget`, which is also how `serve --config` finds services by name.\n */\nexport type ServeTarget = {\n name: string\n defaultPort: number\n /** Serve flags beyond the common ones. */\n options?: Record<string, CliOption>\n create(\n values: CliValues,\n common: CommonServeOptions,\n ): Promise<ServiceRuntime<ServiceInstance>> | ServiceRuntime<ServiceInstance>\n /** Startup lines after the listen address, e.g. the loaded corpus version. */\n banner?(runtime: ServiceRuntime<ServiceInstance>): string[]\n}\n\nconst COMMON_SERVE_OPTIONS: Record<string, CliOption> = {\n port: { type: \"string\", value: \"<port>\", description: \"Port to listen on\" },\n host: { type: \"string\", value: \"<host>\", description: \"Interface to bind\", default: \"127.0.0.1\" },\n \"admin-key\": {\n type: \"string\",\n value: \"<key>\",\n description: \"Require x-mockingbird-admin-key on /__admin/* (env MOCKINGBIRD_ADMIN_KEY)\",\n },\n seed: { type: \"string\", value: \"<seed>\", description: \"Seed for every random choice\" },\n log: {\n type: \"string\",\n value: \"<pretty|json|off>\",\n description: \"Request log format\",\n default: \"pretty\",\n },\n \"log-requests\": {\n type: \"boolean\",\n description:\n \"One JSON line per request: namespace, operationId, status, ids touched (never bodies). Same as --log json\",\n },\n config: {\n type: \"string\",\n value: \"<file>\",\n description: \"Serve every service in a mockingbird.json config instead\",\n },\n}\n\nconst formatLog = (format: LogFormat) => {\n if (format === \"off\") return undefined\n if (format === \"json\") return (entry: RequestLog) => console.log(JSON.stringify(entry))\n return (entry: RequestLog) => {\n const op = entry.operationId ?? (entry.unmatched ? \"UNMATCHED\" : \"-\")\n const ns = entry.namespace === \"default\" ? \"\" : ` [${entry.namespace}]`\n const fault = entry.faultId ? ` fault=${entry.faultId}` : \"\"\n const adopted = entry.adopted ? \" adopted\" : \"\"\n console.log(\n `${entry.service} ${entry.method} ${entry.path} ${entry.status} ${op} ${entry.durationMs}ms${ns}${fault}${adopted}`,\n )\n }\n}\n\nconst asString = (value: string | boolean | undefined): string | undefined =>\n typeof value === \"string\" ? value : undefined\n\n/** A service entry in `mockingbird.json`. */\nexport type ConfigService = {\n port?: number\n host?: string\n adminKey?: string\n seed?: string\n /** Service-specific serve flags, by long name: `{ \"webhook-url\": \"\u2026\" }`. */\n options?: Record<string, string | boolean>\n}\n\nexport type MockingbirdConfig = {\n /** Keyed by service name: `junction` loads `@crvouga/mockingbird-service-junction`. */\n services: Record<string, ConfigService>\n log?: LogFormat\n}\n\nconst loadTarget = async (name: string, own: ServeTarget): Promise<ServeTarget> => {\n if (name === own.name) return own\n const specifier = `@crvouga/mockingbird-service-${name}/server`\n try {\n const mod = (await import(specifier)) as { serveTarget?: ServeTarget }\n if (!mod.serveTarget) throw new Error(`${specifier} exports no serveTarget`)\n return mod.serveTarget\n } catch (error) {\n const reason = error instanceof Error ? error.message : String(error)\n throw new Error(\n `cannot load service \"${name}\": ${reason}. Install @crvouga/mockingbird-service-${name}.`,\n )\n }\n}\n\nconst start = async (\n target: ServeTarget,\n values: CliValues,\n config: { port: number; host: string; adminKey?: string; seed?: string; log: LogFormat },\n): Promise<Listening> => {\n const runtime = await target.create(values, {\n adminKey: config.adminKey,\n seed: config.seed,\n onLog: formatLog(config.log),\n })\n const listening = await listen(runtime, { port: config.port, host: config.host })\n console.log(`${target.name} mock listening on ${listening.url}`)\n console.log(`${target.name} health: GET ${listening.url}/health`)\n console.log(\n `${target.name} admin: ${listening.url}/__admin (${config.adminKey ? \"x-mockingbird-admin-key required\" : \"open \u2014 pass --admin-key to lock\"})`,\n )\n for (const line of target.banner?.(runtime) ?? []) console.log(`${target.name} ${line}`)\n return listening\n}\n\nconst untilSignal = async (servers: Listening[]): Promise<number> =>\n new Promise((resolve) => {\n const stop = async () => {\n await Promise.allSettled(servers.map((s) => s.close()))\n resolve(0)\n }\n process.once(\"SIGINT\", stop)\n process.once(\"SIGTERM\", stop)\n })\n\n/** The standard `serve` command for a service, including multi-service `--config`. */\nexport const serveCommand = (target: ServeTarget): CliCommand => ({\n summary: `Serve the ${target.name} mock over HTTP`,\n options: { ...COMMON_SERVE_OPTIONS, ...target.options },\n async run(values) {\n const log = (\n values[\"log-requests\"] === true ? \"json\" : (asString(values.log) ?? \"pretty\")\n ) as LogFormat\n if (![\"pretty\", \"json\", \"off\"].includes(log)) {\n console.error(`--log must be pretty, json or off (got ${log})`)\n return 2\n }\n const configPath = asString(values.config)\n if (configPath !== undefined) {\n const config = JSON.parse(await readFile(configPath, \"utf8\")) as MockingbirdConfig\n const servers: Listening[] = []\n try {\n for (const [name, entry] of Object.entries(config.services ?? {})) {\n const each = await loadTarget(name, target)\n servers.push(\n await start(each, entry.options ?? {}, {\n port: entry.port ?? each.defaultPort,\n host: entry.host ?? \"127.0.0.1\",\n ...(entry.adminKey !== undefined ? { adminKey: entry.adminKey } : {}),\n ...(entry.seed !== undefined ? { seed: entry.seed } : {}),\n log: config.log ?? log,\n }),\n )\n }\n } catch (error) {\n await Promise.allSettled(servers.map((s) => s.close()))\n console.error(error instanceof Error ? error.message : String(error))\n return 1\n }\n return untilSignal(servers)\n }\n const port = asString(values.port)\n const adminKey = asString(values[\"admin-key\"]) ?? process.env.MOCKINGBIRD_ADMIN_KEY\n const seed = asString(values.seed)\n let listening: Listening\n try {\n listening = await start(target, values, {\n port: port === undefined ? target.defaultPort : Number.parseInt(port, 10),\n host: asString(values.host) ?? \"127.0.0.1\",\n ...(adminKey !== undefined ? { adminKey } : {}),\n ...(seed !== undefined ? { seed } : {}),\n log,\n })\n } catch (error) {\n console.error(error instanceof Error ? error.message : String(error))\n return 1\n }\n return untilSignal([listening])\n },\n})\n", "import { createServer } from \"node:http\"\nimport type { FetchAPI } from \"@crvouga/mockingbird-core\"\n\n/** Options for {@link serve}. */\nexport type NodeServeOptions = {\n port?: number\n host?: string\n}\n\n/**\n * Serve any Mockingbird {@link FetchAPI} over `node:http`.\n * Port defaults to `0`, so the OS assigns an ephemeral port (read from `server.address()`).\n */\nexport const serve = async (api: FetchAPI, options: NodeServeOptions = {}) => {\n const server = createServer(async (req, res) => {\n const chunks: Buffer[] = []\n for await (const chunk of req) {\n chunks.push(Buffer.from(chunk))\n }\n const body = Buffer.concat(chunks)\n const address = server.address()\n const port = typeof address === \"object\" && address !== null ? address.port : undefined\n const base = `http://${req.headers.host ?? `localhost:${port ?? 80}`}`\n const raw = req.url ?? \"/\"\n const url = new URL(raw.replace(/^\\/+/, \"/\"), base)\n const method = req.method ?? \"GET\"\n const init: RequestInit = { method, headers: req.headers as Record<string, string> }\n if (method !== \"GET\" && method !== \"HEAD\" && body.length > 0) {\n init.body = body\n }\n const aborted = new AbortController()\n res.once(\"close\", () => {\n if (!res.writableFinished) aborted.abort()\n })\n init.signal = aborted.signal\n const request = new Request(url, init)\n let response: Response\n try {\n response = await api.fetch(request)\n } catch (error) {\n // A `drop` fault: destroy the socket so the client sees the connection die.\n if ((error as { code?: string }).code === \"MOCKINGBIRD_DROP\") {\n req.socket.destroy()\n return\n }\n res.writeHead(500, { \"content-type\": \"application/json\" })\n res.end(\n JSON.stringify({\n error: {\n type: \"mockingbird_internal\",\n message: error instanceof Error ? error.message : String(error),\n },\n }),\n )\n return\n }\n // Headers#entries() joins repeated headers; Set-Cookie must stay one header per cookie.\n const headers: Record<string, string | string[]> = Object.fromEntries(response.headers)\n const cookies = response.headers.getSetCookie()\n if (cookies.length > 0) headers[\"set-cookie\"] = cookies\n if (!response.body) {\n res.writeHead(response.status, headers)\n res.end()\n return\n }\n // Stream the body chunk by chunk: event streams and long-polls must not be buffered.\n res.writeHead(response.status, headers)\n res.flushHeaders()\n const reader = response.body.getReader()\n try {\n for (;;) {\n const { done, value } = await reader.read()\n if (done) break\n if (!res.write(value)) await new Promise<void>((resolve) => res.once(\"drain\", resolve))\n }\n res.end()\n } catch {\n res.destroy()\n } finally {\n reader.releaseLock()\n }\n })\n await new Promise<void>((resolve, reject) => {\n server.once(\"error\", reject)\n server.listen(options.port ?? 0, options.host, resolve)\n })\n return server\n}\n", "import type { Server } from \"node:http\"\nimport type { FetchAPI } from \"@crvouga/mockingbird-core\"\nimport { serve } from \"./serve.js\"\n\nexport type ListenOptions = {\n /** Default `0`: the OS picks a free port. */\n port?: number\n /** Default `127.0.0.1`: a mock should not be reachable off the machine by accident. */\n host?: string\n}\n\n/** A running server, with the address it actually bound. */\nexport type Listening = {\n url: string\n port: number\n host: string\n server: Server\n close(): Promise<void>\n}\n\nexport const listen = async (api: FetchAPI, options: ListenOptions = {}): Promise<Listening> => {\n const host = options.host ?? \"127.0.0.1\"\n const server = await serve(api, { port: options.port ?? 0, host })\n const address = server.address()\n const port = typeof address === \"object\" && address !== null ? address.port : (options.port ?? 0)\n const shown = host.includes(\":\") ? `[${host}]` : host\n return {\n url: `http://${shown}:${port}`,\n port,\n host,\n server,\n close: () =>\n new Promise<void>((resolve, reject) => {\n // Stop accepting first, then drop keep-alive sockets so close() can finish.\n server.close((error) => {\n if (error && (error as { code?: string }).code !== \"ERR_SERVER_NOT_RUNNING\") reject(error)\n else resolve()\n })\n server.closeAllConnections?.()\n }),\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["createServer"]
7
+ }
@@ -1490,7 +1490,7 @@ var restoreNamespace = (sqlite, namespace, snapshot) => {
1490
1490
  };
1491
1491
 
1492
1492
  // ../core/dist/version.js
1493
- var PACKAGE_VERSION = true ? "1.0.0" : UNRELEASED_VERSION;
1493
+ var PACKAGE_VERSION = true ? "1.1.0" : UNRELEASED_VERSION;
1494
1494
 
1495
1495
  // ../core/dist/signing.js
1496
1496
  var encoder = new TextEncoder();
@@ -2587,13 +2587,61 @@ var DEFAULT_CATALOG = [
2587
2587
  status: "inactive"
2588
2588
  }
2589
2589
  ];
2590
+ var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2591
+ var optionalString = (row, key) => {
2592
+ const value = row[key];
2593
+ if (value === void 0 || value === null) return null;
2594
+ if (typeof value !== "string") throw new Error(`${key} must be a string or null`);
2595
+ return value;
2596
+ };
2597
+ var parseCatalogItem = (value) => {
2598
+ if (!isRecord4(value)) throw new Error("a catalog row must be an object");
2599
+ const { catalog_id, medication_name, quantity, states, status } = value;
2600
+ if (typeof catalog_id !== "string" || catalog_id.length === 0) {
2601
+ throw new Error("catalog_id must be a non-empty string");
2602
+ }
2603
+ if (typeof medication_name !== "string") {
2604
+ throw new Error(`${catalog_id}: medication_name must be a string`);
2605
+ }
2606
+ if (quantity !== void 0 && quantity !== null && typeof quantity !== "number") {
2607
+ throw new Error(`${catalog_id}: quantity must be a number or null`);
2608
+ }
2609
+ if (states !== void 0 && (!Array.isArray(states) || states.some((s) => typeof s !== "string"))) {
2610
+ throw new Error(`${catalog_id}: states must be a list of state codes`);
2611
+ }
2612
+ if (status !== void 0 && status !== "active" && status !== "inactive") {
2613
+ throw new Error(`${catalog_id}: status must be "active" or "inactive"`);
2614
+ }
2615
+ try {
2616
+ return {
2617
+ catalog_id,
2618
+ medication_name,
2619
+ medication_strength: optionalString(value, "medication_strength"),
2620
+ package_size: optionalString(value, "package_size"),
2621
+ quantity: typeof quantity === "number" ? quantity : null,
2622
+ quantity_units: optionalString(value, "quantity_units"),
2623
+ medication_form: optionalString(value, "medication_form"),
2624
+ route: optionalString(value, "route"),
2625
+ states: states ?? [],
2626
+ status: status ?? "active"
2627
+ };
2628
+ } catch (error) {
2629
+ throw new Error(`${catalog_id}: ${error.message}`);
2630
+ }
2631
+ };
2632
+ var parseCatalog = (value) => {
2633
+ const rows = Array.isArray(value) ? value : isRecord4(value) ? value.data : void 0;
2634
+ if (!Array.isArray(rows)) throw new Error("a catalog must be an array or {data: [...]}");
2635
+ return rows.map(parseCatalogItem);
2636
+ };
2590
2637
 
2591
2638
  // src/state.ts
2592
2639
  var DEFAULT_SETTINGS = {
2593
2640
  tokenTtlSeconds: 86400,
2594
2641
  staticTokens: [],
2595
2642
  clients: [],
2596
- autoAdvance: null
2643
+ autoAdvance: null,
2644
+ unknownPresets: "reject"
2597
2645
  };
2598
2646
  var RxVortexState = class {
2599
2647
  constructor(sqlite, namespace, seed) {
@@ -2611,15 +2659,25 @@ var RxVortexState = class {
2611
2659
  ids;
2612
2660
  /** Re-apply the catalog and settings after a reset. */
2613
2661
  ensureSeeded() {
2614
- if (this.catalog.count() === 0) {
2615
- for (const item of this.seed.catalog.length > 0 ? this.seed.catalog : DEFAULT_CATALOG) {
2616
- this.catalog.insert(item.catalog_id, item);
2617
- }
2618
- }
2662
+ if (this.catalog.count() === 0) this.putCatalog(this.seed.catalog ?? DEFAULT_CATALOG, "merge");
2619
2663
  if (!this.settings.has("settings")) {
2620
2664
  this.settings.insert("settings", { ...DEFAULT_SETTINGS, ...this.seed.settings });
2621
2665
  }
2622
2666
  }
2667
+ /** Load rows: `replace` drops every other row first, `merge` upserts by `catalog_id`. */
2668
+ putCatalog(items, mode) {
2669
+ if (mode === "replace") {
2670
+ for (const row of this.catalog.list()) this.catalog.delete(row.id);
2671
+ }
2672
+ for (const item of items) {
2673
+ if (this.catalog.has(item.catalog_id)) this.catalog.update(item.catalog_id, item);
2674
+ else this.catalog.insert(item.catalog_id, item);
2675
+ }
2676
+ return this.catalog.count();
2677
+ }
2678
+ catalogRows() {
2679
+ return this.catalog.list({ order: "oldest" }).map((row) => row.value);
2680
+ }
2623
2681
  current() {
2624
2682
  return this.settings.get("settings") ?? DEFAULT_SETTINGS;
2625
2683
  }
@@ -2730,10 +2788,10 @@ var RXVORTEX_PRESETS = {
2730
2788
  };
2731
2789
  var json3 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
2732
2790
  var adminError3 = (status, message) => json3(status, { error: { type: "mockingbird_admin", message } });
2733
- var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2791
+ var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2734
2792
  var parseAutoAdvance = (value) => {
2735
2793
  if (value === null) return null;
2736
- if (!isRecord4(value)) return "autoAdvance must be {afterMs, path} or null";
2794
+ if (!isRecord5(value)) return "autoAdvance must be {afterMs, path} or null";
2737
2795
  if (typeof value.afterMs !== "number" || value.afterMs < 0)
2738
2796
  return "autoAdvance.afterMs must be ms";
2739
2797
  if (!Array.isArray(value.path) || value.path.some((s) => typeof s !== "string")) {
@@ -2744,7 +2802,7 @@ var parseAutoAdvance = (value) => {
2744
2802
  var adminRoutes = (runtime) => ({
2745
2803
  "GET /orders": ({ namespace }) => json3(200, { orders: runtime.instance(namespace).orders() }),
2746
2804
  "POST /orders/:id/transition": ({ params, body, namespace }) => {
2747
- if (!isRecord4(body) || typeof body.to !== "string") {
2805
+ if (!isRecord5(body) || typeof body.to !== "string") {
2748
2806
  return adminError3(
2749
2807
  400,
2750
2808
  'expected {"to": "<vendor status>", "trackingnumber"?, "shippingcarrier"?}'
@@ -2760,9 +2818,27 @@ var adminRoutes = (runtime) => ({
2760
2818
  });
2761
2819
  return order ? json3(200, order) : adminError3(404, `no order ${params.id}`);
2762
2820
  },
2821
+ "GET /catalog": ({ namespace }) => json3(200, { data: runtime.instance(namespace).state.catalogRows() }),
2822
+ "PUT /catalog": ({ body, namespace }) => {
2823
+ const rows = isRecord5(body) ? body.items ?? body.data : void 0;
2824
+ if (!isRecord5(body) || !Array.isArray(rows)) {
2825
+ return adminError3(400, 'expected {"items": [{catalog_id, medication_name, \u2026}], "mode"?}');
2826
+ }
2827
+ const mode = body.mode ?? "replace";
2828
+ if (mode !== "replace" && mode !== "merge") {
2829
+ return adminError3(400, 'mode must be "replace" or "merge"');
2830
+ }
2831
+ let items;
2832
+ try {
2833
+ items = rows.map(parseCatalogItem);
2834
+ } catch (error) {
2835
+ return adminError3(400, `items: ${error.message}`);
2836
+ }
2837
+ return json3(200, { count: runtime.instance(namespace).state.putCatalog(items, mode) });
2838
+ },
2763
2839
  "GET /settings": ({ namespace }) => json3(200, runtime.instance(namespace).state.current()),
2764
2840
  "PUT /settings": ({ body, namespace }) => {
2765
- if (!isRecord4(body)) return adminError3(400, "expected a JSON object");
2841
+ if (!isRecord5(body)) return adminError3(400, "expected a JSON object");
2766
2842
  const patch = {};
2767
2843
  if (body.tokenTtlSeconds !== void 0) {
2768
2844
  if (typeof body.tokenTtlSeconds !== "number")
@@ -2776,7 +2852,7 @@ var adminRoutes = (runtime) => ({
2776
2852
  if (body.clients !== void 0) {
2777
2853
  if (!Array.isArray(body.clients))
2778
2854
  return adminError3(400, "clients: [{client_id, client_secret}]");
2779
- patch.clients = body.clients.filter(isRecord4).map((c) => ({
2855
+ patch.clients = body.clients.filter(isRecord5).map((c) => ({
2780
2856
  client_id: String(c.client_id),
2781
2857
  client_secret: String(c.client_secret)
2782
2858
  }));
@@ -2786,6 +2862,12 @@ var adminRoutes = (runtime) => ({
2786
2862
  if (typeof parsed === "string") return adminError3(400, parsed);
2787
2863
  patch.autoAdvance = parsed;
2788
2864
  }
2865
+ if (body.unknownPresets !== void 0) {
2866
+ if (body.unknownPresets !== "reject" && body.unknownPresets !== "accept") {
2867
+ return adminError3(400, 'unknownPresets: "reject" | "accept"');
2868
+ }
2869
+ patch.unknownPresets = body.unknownPresets;
2870
+ }
2789
2871
  return json3(200, runtime.instance(namespace).state.update(patch));
2790
2872
  },
2791
2873
  "POST /tick": ({ namespace }) => json3(200, { applied: runtime.instance(namespace).tick() })
@@ -2843,6 +2925,7 @@ var createRuntime2 = (options = {}) => {
2843
2925
  // src/index.ts
2844
2926
  var RXVORTEX_NAMESPACE = "rxvortex";
2845
2927
  var TOKEN_PREFIX = "rxv_";
2928
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2846
2929
  var base64url = (value) => toBase64(new TextEncoder().encode(value)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2847
2930
  var fromBase64url = (value) => {
2848
2931
  try {
@@ -2920,7 +3003,7 @@ var RxVortexAPI = class {
2920
3003
  this.now = options.now ?? (() => Date.now());
2921
3004
  this.onWebhook = options.onWebhook;
2922
3005
  this.state = new RxVortexState(sqlite, namespace, {
2923
- catalog: options.catalog ?? [],
3006
+ catalog: options.catalog,
2924
3007
  settings: options.settings ?? {}
2925
3008
  });
2926
3009
  const handlers = defineOperations({
@@ -2928,9 +3011,7 @@ var RxVortexAPI = class {
2928
3011
  CreateOrder: (context) => this.createOrder(context),
2929
3012
  GetOrder: (context) => this.getOrder(context),
2930
3013
  CancelOrder: (context) => this.cancelOrder(context),
2931
- ListPresetCatalogItems: () => jsonRes(200, {
2932
- data: this.state.catalog.list({ order: "oldest" }).map((row) => row.value)
2933
- })
3014
+ ListPresetCatalogItems: () => jsonRes(200, { data: this.state.catalogRows() })
2934
3015
  });
2935
3016
  this.service = createService({
2936
3017
  document,
@@ -3009,9 +3090,10 @@ var RxVortexAPI = class {
3009
3090
  }
3010
3091
  const order = body.order;
3011
3092
  const meds = body.medication_requests;
3093
+ const adoptable = (med) => this.state.current().unknownPresets === "accept" && UUID.test(med.preset_catalog_id);
3012
3094
  const unknown = meds.find((med) => {
3013
3095
  const item = this.state.catalog.get(med.preset_catalog_id);
3014
- return !item || item.status !== "active";
3096
+ return item ? item.status !== "active" : !adoptable(med);
3015
3097
  });
3016
3098
  if (unknown) {
3017
3099
  return jsonRes(422, {
@@ -3033,6 +3115,9 @@ var RxVortexAPI = class {
3033
3115
  { ids: { orderId: existing.order_tracking_id } }
3034
3116
  );
3035
3117
  }
3118
+ for (const med of meds) {
3119
+ if (!this.state.catalog.has(med.preset_catalog_id)) this.adoptPreset(med);
3120
+ }
3036
3121
  const now = this.iso();
3037
3122
  const created = {
3038
3123
  order_tracking_id: this.state.nextTrackingId(),
@@ -3076,6 +3161,25 @@ var RxVortexAPI = class {
3076
3161
  { ids }
3077
3162
  );
3078
3163
  }
3164
+ /**
3165
+ * Under `unknownPresets: "accept"`, a well-formed preset id the catalog lacks becomes an
3166
+ * active row described by the request, so later submits and catalog reads see it.
3167
+ */
3168
+ adoptPreset(med) {
3169
+ const item = {
3170
+ catalog_id: med.preset_catalog_id,
3171
+ medication_name: med.medication_name,
3172
+ medication_strength: med.medication_strength ?? null,
3173
+ package_size: null,
3174
+ quantity: med.quantity,
3175
+ quantity_units: med.quantity_units,
3176
+ medication_form: med.medication_form ?? null,
3177
+ route: null,
3178
+ states: [],
3179
+ status: "active"
3180
+ };
3181
+ this.state.putCatalog([item], "merge");
3182
+ }
3079
3183
  getOrder(context) {
3080
3184
  const order = this.state.findOrder(context.params.orderId ?? "");
3081
3185
  if (!order) return jsonRes(404, { message: "Order not found." });
@@ -3182,10 +3286,12 @@ export {
3182
3286
  supportedOperationIds,
3183
3287
  CUSTOM_CREAM_ANCHOR_PRESET_ID,
3184
3288
  DEFAULT_CATALOG,
3289
+ parseCatalogItem,
3290
+ parseCatalog,
3185
3291
  RXVORTEX_PRESETS,
3186
3292
  createRuntime2 as createRuntime,
3187
3293
  RXVORTEX_NAMESPACE,
3188
3294
  tokenCredential,
3189
3295
  RxVortexAPI
3190
3296
  };
3191
- //# sourceMappingURL=chunk-DNELVPK4.js.map
3297
+ //# sourceMappingURL=chunk-VQAJFAUO.js.map