@nanobpm/urban 0.39.0 → 0.40.1

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.
Files changed (51) hide show
  1. package/README.md +37 -0
  2. package/dist/openapi/spec.d.ts +16 -1
  3. package/dist/openapi/spec.js +53 -12
  4. package/dist/runtime/adapters/deno.js +18 -2
  5. package/dist/runtime/adapters/node.js +17 -5
  6. package/dist/runtime/core/context.d.ts +10 -1
  7. package/dist/runtime/core/host.d.ts +9 -2
  8. package/dist/runtime/core/logger.d.ts +69 -0
  9. package/dist/runtime/core/logger.js +134 -0
  10. package/dist/runtime/core/modules/api.js +30 -16
  11. package/dist/runtime/core/modules/pages.js +52 -4
  12. package/dist/runtime/core/modules/workers.js +16 -1
  13. package/dist/runtime/core/runtime.d.ts +5 -0
  14. package/dist/runtime/core/runtime.js +7 -1
  15. package/dist/runtime/engine/nanosdk.d.ts +1 -1
  16. package/dist/runtime/index.d.ts +1 -0
  17. package/dist/toolkit/derivers/api.js +33 -7
  18. package/dist/toolkit/scaffold/operations.js +3 -1
  19. package/dist/toolkit/scaffold/workers.js +2 -0
  20. package/dist/worker.d.ts +1 -0
  21. package/package.json +2 -2
  22. package/src/openapi/spec.test.ts +47 -0
  23. package/src/openapi/spec.ts +62 -13
  24. package/src/runtime/adapters/deno.ts +17 -4
  25. package/src/runtime/adapters/node.ts +17 -7
  26. package/src/runtime/core/context.ts +10 -1
  27. package/src/runtime/core/host.ts +9 -2
  28. package/src/runtime/core/logger.test.ts +221 -0
  29. package/src/runtime/core/logger.ts +177 -0
  30. package/src/runtime/core/modules/actions.test.ts +2 -1
  31. package/src/runtime/core/modules/api.test.ts +119 -2
  32. package/src/runtime/core/modules/api.ts +33 -15
  33. package/src/runtime/core/modules/cancel.test.ts +4 -1
  34. package/src/runtime/core/modules/connectors.test.ts +2 -1
  35. package/src/runtime/core/modules/instance-tracking.test.ts +4 -1
  36. package/src/runtime/core/modules/pages.test.ts +31 -0
  37. package/src/runtime/core/modules/pages.ts +52 -4
  38. package/src/runtime/core/modules/surfaces.test.ts +2 -1
  39. package/src/runtime/core/modules/triggers.test.ts +4 -1
  40. package/src/runtime/core/modules/workers.test.ts +59 -2
  41. package/src/runtime/core/modules/workers.ts +15 -1
  42. package/src/runtime/core/runtime.test.ts +22 -0
  43. package/src/runtime/core/runtime.ts +12 -1
  44. package/src/runtime/devserver.test.ts +2 -0
  45. package/src/runtime/engine/nanosdk.ts +1 -1
  46. package/src/runtime/index.ts +2 -0
  47. package/src/toolkit/derivers/api.test.ts +20 -0
  48. package/src/toolkit/derivers/api.ts +35 -6
  49. package/src/toolkit/scaffold/operations.ts +3 -1
  50. package/src/toolkit/scaffold/workers.ts +2 -0
  51. package/src/worker.ts +1 -0
package/README.md CHANGED
@@ -100,6 +100,43 @@ The runtime has a single engine client, `SdkEngineClient`, backed by one
100
100
  selects the wire transport via `CAMUNDA_TRANSPORT`: `auto` (default) upgrades to
101
101
  Falcon on a Nano server and falls back to REST elsewhere.
102
102
 
103
+ ### Structured logging
104
+
105
+ Every worker handler and API route delegate receives an `AppApi` whose `log` is a
106
+ level-tagged structured logger (ADR 0061):
107
+
108
+ ```ts
109
+ app.log.info("charge captured", { amount, currency });
110
+ app.log.warn("retrying", { attempt });
111
+ app.log.error("charge failed", { code });
112
+ app.log.debug("gateway response", { raw }); // hidden unless URBAN_LOG_LEVEL=debug
113
+
114
+ const orderLog = app.log.child({ orderId }); // bind context for a scope
115
+ orderLog.info("shipped"); // every line carries orderId
116
+ ```
117
+
118
+ The runtime **auto-correlates**: a worker handler's `app.log` is pre-bound to
119
+ `{ jobKey, jobType, processInstanceKey, elementId }` and a route delegate's to
120
+ `{ method, path, operationId }`, so every line you emit is tied to its job/request
121
+ for free.
122
+
123
+ The `UrbanApp` handle returned by `runFromEnv`/`createUrbanApp` also carries an
124
+ app-level `app.log` (no per-request correlation) for the entrypoint's boot/shutdown
125
+ lines:
126
+
127
+ ```ts
128
+ const app = await runFromEnv();
129
+ app.log.info("started", { httpPort: app.httpPort });
130
+ ```
131
+
132
+ Records are written as **NDJSON** — one JSON object per line,
133
+ `{"ts":…,"level":…,"msg":…, …fields}` — with `warn`/`error` on stderr and
134
+ `debug`/`info` on stdout. `URBAN_LOG_LEVEL` (default `info`) sets the minimum level.
135
+
136
+ > **Custom hosts:** the `HostContext.log` sink now accepts `"debug"` in addition to
137
+ > `"info" | "warn" | "error"`. A custom host that typed `log` with the narrower union must add a
138
+ > `"debug"` branch to keep satisfying the contract — a breaking change for that surface only.
139
+
103
140
  ### Toolkit — derive artifacts (`urban gen`)
104
141
 
105
142
  ```ts
@@ -2,7 +2,7 @@
2
2
  * are ignored by the validator and fall back to `unknown` in the type emitter. */
3
3
  export interface OpenApiSchema {
4
4
  $ref?: string;
5
- type?: string;
5
+ type?: string | string[];
6
6
  nullable?: boolean;
7
7
  enum?: unknown[];
8
8
  const?: unknown;
@@ -25,6 +25,21 @@ export interface OpenApiSchema {
25
25
  anyOf?: OpenApiSchema[];
26
26
  description?: string;
27
27
  }
28
+ /**
29
+ * The declared JSON-Schema `type`(s) as a list, normalizing OpenAPI 3.0 (a single string) and
30
+ * 3.1 (a `string[]`) into one shape. Returns `[]` when no `type` keyword is present, so callers
31
+ * distinguish "typeless" (infer from other keywords) from "typed".
32
+ */
33
+ export declare function schemaTypeList(schema: OpenApiSchema): string[];
34
+ /** The declared non-"null" value types — what a 3.1 `type: [T, "null"]` permits as a value. */
35
+ export declare function schemaValueTypes(schema: OpenApiSchema): string[];
36
+ /** The first declared non-"null" value type (for single-type scalar logic), or `undefined`. */
37
+ export declare function firstValueType(schema: OpenApiSchema): string | undefined;
38
+ /** Whether the schema permits an explicit `null` — via OpenAPI 3.0 `nullable: true` or the 3.1
39
+ * idiom `type: [..., "null"]`. */
40
+ export declare function schemaAllowsNull(schema: OpenApiSchema): boolean;
41
+ /** Whether the schema declares (permits) the given non-"null" value type. */
42
+ export declare function schemaHasType(schema: OpenApiSchema, t: string): boolean;
28
43
  /**
29
44
  * Whether a schema describes an object shape. OpenAPI/JSON-Schema let the `type`
30
45
  * keyword be omitted when `properties`/`required`/`additionalProperties` already imply
@@ -8,9 +8,38 @@
8
8
  //
9
9
  // Scope: the ADR 0058 "supported profile" — JSON bodies, path/query parameters, a JSON
10
10
  // requestBody, response codes, and a JSON-Schema subset validator (type/required/enum/numeric &
11
- // string bounds/pattern/array & object shape/nullable/$ref). Exotic OpenAPI (callbacks, links,
12
- // XML, discriminated oneOf composition) is intentionally out of scope for this slice.
11
+ // string bounds/pattern/array & object shape/nullable/$ref). The `type` keyword is read in both
12
+ // OpenAPI 3.0 (a single string, `nullable: true` for null) and 3.1 (a `string[]`, e.g. the
13
+ // `type: [T, "null"]` nullable idiom or a multi-type union) dialects. Exotic OpenAPI (callbacks,
14
+ // links, XML, discriminated oneOf composition) is intentionally out of scope for this slice.
13
15
  import { parse as parseYaml } from "yaml";
16
+ /**
17
+ * The declared JSON-Schema `type`(s) as a list, normalizing OpenAPI 3.0 (a single string) and
18
+ * 3.1 (a `string[]`) into one shape. Returns `[]` when no `type` keyword is present, so callers
19
+ * distinguish "typeless" (infer from other keywords) from "typed".
20
+ */
21
+ export function schemaTypeList(schema) {
22
+ if (schema.type === undefined)
23
+ return [];
24
+ return Array.isArray(schema.type) ? schema.type : [schema.type];
25
+ }
26
+ /** The declared non-"null" value types — what a 3.1 `type: [T, "null"]` permits as a value. */
27
+ export function schemaValueTypes(schema) {
28
+ return schemaTypeList(schema).filter((t) => t !== "null");
29
+ }
30
+ /** The first declared non-"null" value type (for single-type scalar logic), or `undefined`. */
31
+ export function firstValueType(schema) {
32
+ return schemaValueTypes(schema)[0];
33
+ }
34
+ /** Whether the schema permits an explicit `null` — via OpenAPI 3.0 `nullable: true` or the 3.1
35
+ * idiom `type: [..., "null"]`. */
36
+ export function schemaAllowsNull(schema) {
37
+ return schema.nullable === true || schemaTypeList(schema).includes("null");
38
+ }
39
+ /** Whether the schema declares (permits) the given non-"null" value type. */
40
+ export function schemaHasType(schema, t) {
41
+ return schemaValueTypes(schema).includes(t);
42
+ }
14
43
  /**
15
44
  * Whether a schema describes an object shape. OpenAPI/JSON-Schema let the `type`
16
45
  * keyword be omitted when `properties`/`required`/`additionalProperties` already imply
@@ -19,9 +48,10 @@ import { parse as parseYaml } from "yaml";
19
48
  * the emitted type calls an object is the same body the validator shape-checks.
20
49
  */
21
50
  export function isObjectSchema(schema) {
22
- if (schema.type === "object")
51
+ const valueTypes = schemaValueTypes(schema);
52
+ if (valueTypes.includes("object"))
23
53
  return true;
24
- if (schema.type !== undefined)
54
+ if (valueTypes.length > 0)
25
55
  return false;
26
56
  return (schema.properties !== undefined ||
27
57
  schema.required !== undefined ||
@@ -494,8 +524,14 @@ function matchesType(declared, value) {
494
524
  return actual === "number" || actual === "integer";
495
525
  if (declared === "integer")
496
526
  return actual === "integer";
527
+ if (declared === "null")
528
+ return value === null;
497
529
  return actual === declared;
498
530
  }
531
+ /** Whether `value` matches any of the declared value types (a 3.1 `type` list is a union). */
532
+ function matchesAnyType(declared, value) {
533
+ return declared.some((t) => matchesType(t, value));
534
+ }
499
535
  /** Validate `value` against `schema` (resolving local $refs against `doc`), returning every issue
500
536
  * found (empty = valid). A pragmatic JSON-Schema subset; unknown keywords are ignored. */
501
537
  export function validateValue(doc, schemaIn, value, path = "") {
@@ -505,7 +541,7 @@ export function validateValue(doc, schemaIn, value, path = "") {
505
541
  const issues = [];
506
542
  const at = path || "(root)";
507
543
  if (value === null) {
508
- if (schema.nullable === true || schema.type === "null")
544
+ if (schemaAllowsNull(schema))
509
545
  return issues;
510
546
  // enum/const may explicitly permit null even without `nullable`.
511
547
  if (schema.enum && schema.enum.some((e) => e === null))
@@ -514,14 +550,15 @@ export function validateValue(doc, schemaIn, value, path = "") {
514
550
  return issues;
515
551
  // Otherwise null is invalid for a typed schema, an object schema (properties/required imply
516
552
  // object), or an enum/const that doesn't list null. A truly empty schema ({}) allows anything.
553
+ const valueTypes = schemaValueTypes(schema);
517
554
  if (schema.enum) {
518
555
  issues.push({ path: at, message: `must be one of ${JSON.stringify(schema.enum)}` });
519
556
  }
520
557
  else if (schema.const !== undefined) {
521
558
  issues.push({ path: at, message: `must equal ${JSON.stringify(schema.const)}` });
522
559
  }
523
- else if (schema.type) {
524
- issues.push({ path: at, message: `expected ${schema.type}, got null` });
560
+ else if (valueTypes.length > 0) {
561
+ issues.push({ path: at, message: `expected ${valueTypes.join(",")}, got null` });
525
562
  }
526
563
  else if (isObjectSchema(schema)) {
527
564
  issues.push({ path: at, message: "expected object, got null" });
@@ -550,11 +587,15 @@ export function validateValue(doc, schemaIn, value, path = "") {
550
587
  issues.push({ path: at, message: `must match exactly one schema in oneOf (matched ${matched})` });
551
588
  }
552
589
  }
553
- if (schema.type && !matchesType(schema.type, value)) {
554
- issues.push({ path: at, message: `expected ${schema.type}, got ${typeOfValue(value)}` });
590
+ // `value` is non-null here (the null branch returned above), so match against the declared value
591
+ // types. Use the full `type` list — not `schemaValueTypes` — so a null-only schema (`["null"]`)
592
+ // still rejects non-null values instead of skipping the check on an empty value-type list.
593
+ const declaredTypes = schemaTypeList(schema);
594
+ if (declaredTypes.length > 0 && !matchesAnyType(declaredTypes, value)) {
595
+ issues.push({ path: at, message: `expected ${declaredTypes.join(",")}, got ${typeOfValue(value)}` });
555
596
  return issues; // the shape checks below assume the declared type held
556
597
  }
557
- if (schema.type === "string" && typeof value === "string") {
598
+ if (schemaHasType(schema, "string") && typeof value === "string") {
558
599
  if (schema.minLength !== undefined && value.length < schema.minLength) {
559
600
  issues.push({ path: at, message: `shorter than minLength ${schema.minLength}` });
560
601
  }
@@ -578,7 +619,7 @@ export function validateValue(doc, schemaIn, value, path = "") {
578
619
  }
579
620
  }
580
621
  }
581
- if ((schema.type === "number" || schema.type === "integer") && typeof value === "number") {
622
+ if ((schemaHasType(schema, "number") || schemaHasType(schema, "integer")) && typeof value === "number") {
582
623
  // OpenAPI 3.0 uses the boolean form (`exclusiveMinimum: true` makes `minimum` exclusive);
583
624
  // JSON Schema 2020-12 (OpenAPI 3.1) uses the numeric form (`exclusiveMinimum: <number>`).
584
625
  // Support both so specs in either dialect are validated, not silently under-checked.
@@ -603,7 +644,7 @@ export function validateValue(doc, schemaIn, value, path = "") {
603
644
  issues.push({ path: at, message: `must be < ${schema.exclusiveMaximum}` });
604
645
  }
605
646
  }
606
- if (schema.type === "array" && Array.isArray(value)) {
647
+ if (schemaHasType(schema, "array") && Array.isArray(value)) {
607
648
  if (schema.minItems !== undefined && value.length < schema.minItems) {
608
649
  issues.push({ path: at, message: `fewer than minItems ${schema.minItems}` });
609
650
  }
@@ -16,6 +16,7 @@ import { existsSync } from "node:fs";
16
16
  import { DatabaseSync } from "node:sqlite";
17
17
  import { AsyncLocalStorage } from "node:async_hooks";
18
18
  import { resolveModulePath } from "../core/module-path.js";
19
+ import { formatLogRecord, levelEnabled, parseLogLevel } from "../core/logger.js";
19
20
  function sqliteParams(params) {
20
21
  return params.map((param) => {
21
22
  if (param === null ||
@@ -30,13 +31,28 @@ function sqliteParams(params) {
30
31
  throw new TypeError(`unsupported SQLite parameter type: ${typeof param}`);
31
32
  });
32
33
  }
34
+ /** Read `URBAN_LOG_LEVEL` without requiring `--allow-env`. `Deno.env.get` throws when env access is
35
+ * not granted, but an app that only logs shouldn't be forced to open env permission just to emit a
36
+ * line — so treat a permission (or any) error as an unset value and let logging fall back to the
37
+ * default `info` level. Logging must stay safe by default. */
38
+ function readLogLevelEnv() {
39
+ try {
40
+ return Deno.env.get("URBAN_LOG_LEVEL");
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
33
46
  export function createDenoHost(opts = {}) {
34
47
  const cwd = opts.cwd ?? Deno.cwd();
35
48
  const abs = (p) => (isAbsolute(p) ? p : resolve(cwd, p));
36
49
  const log = opts.log ??
37
50
  ((level, msg, fields) => {
38
- const line = fields ? `${msg} ${JSON.stringify(fields)}` : msg;
39
- (level === "error" ? console.error : level === "warn" ? console.warn : console.log)(`[urban] ${line}`);
51
+ if (!levelEnabled(level, parseLogLevel(readLogLevelEnv())))
52
+ return;
53
+ // console.* appends the newline; NDJSON stays one object per line. warn/error → stderr.
54
+ const line = formatLogRecord(level, msg, fields, Date.now());
55
+ (level === "warn" || level === "error" ? console.error : console.log)(line);
40
56
  });
41
57
  return {
42
58
  runtime: "deno",
@@ -18,6 +18,22 @@ import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import { DatabaseSync } from "node:sqlite";
19
19
  import { AsyncLocalStorage } from "node:async_hooks";
20
20
  import { resolveModulePath } from "../core/module-path.js";
21
+ import { formatLogLine, levelEnabled, parseLogLevel } from "../core/logger.js";
22
+ /**
23
+ * The default host log sink: NDJSON to stdout (`debug`/`info`) and stderr (`warn`/`error`), gated by
24
+ * a minimum level read from `URBAN_LOG_LEVEL` (default `info`) per record so a level change takes
25
+ * effect without a restart. Writing straight to the stream (not `console.*`) keeps one JSON object
26
+ * per line so downstream tooling can ingest them (the Nano host does not yet parse or surface these
27
+ * lines — see ADR 0061).
28
+ */
29
+ function makeNdjsonLog(minLevel) {
30
+ return (level, msg, fields) => {
31
+ if (!levelEnabled(level, parseLogLevel(minLevel())))
32
+ return;
33
+ const line = formatLogLine(level, msg, fields, Date.now());
34
+ (level === "warn" || level === "error" ? process.stderr : process.stdout).write(line);
35
+ };
36
+ }
21
37
  function sqliteParams(params) {
22
38
  return params.map((param) => {
23
39
  if (param === null ||
@@ -84,11 +100,7 @@ function ensureConnectorHooks() {
84
100
  export function createNodeHost(opts = {}) {
85
101
  const cwd = opts.cwd ?? process.cwd();
86
102
  const abs = (p) => (isAbsolute(p) ? p : resolve(cwd, p));
87
- const log = opts.log ??
88
- ((level, msg, fields) => {
89
- const line = fields ? `${msg} ${JSON.stringify(fields)}` : msg;
90
- (level === "error" ? console.error : level === "warn" ? console.warn : console.log)(`[urban] ${line}`);
91
- });
103
+ const log = opts.log ?? makeNdjsonLog(() => process.env.URBAN_LOG_LEVEL);
92
104
  return {
93
105
  runtime: "node",
94
106
  env: (name) => process.env[name],
@@ -21,6 +21,7 @@ export interface Mounted {
21
21
  describe?(): Record<string, unknown>;
22
22
  }
23
23
  import type { DataLayer } from "./modules/datasource.ts";
24
+ import type { Logger } from "./logger.ts";
24
25
  /**
25
26
  * The surface injected into worker/trigger/surface handlers — the app's runtime API.
26
27
  * This is how a handler reaches the datasource (typed accessors), the engine, and host
@@ -40,5 +41,13 @@ export interface AppApi {
40
41
  */
41
42
  sdk?: EngineSdkClient;
42
43
  env(name: string): string | undefined;
43
- log(level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>): void;
44
+ /**
45
+ * Structured logger (see {@link Logger}). Callable for back-compat
46
+ * (`log("info", msg, fields)`), but prefer the level methods and bound context:
47
+ * `app.log.info(msg, fields)`, `app.log.child({ … })`. The runtime hands worker
48
+ * handlers a logger already bound to the job's `{ jobKey, jobType,
49
+ * processInstanceKey, elementId }`, and operation delegates one bound to the
50
+ * request's `{ method, path, operationId }`, so lines self-correlate.
51
+ */
52
+ log: Logger;
44
53
  }
@@ -97,8 +97,15 @@ export interface HostContext {
97
97
  * a host that cannot provide one leaves provenance capture disabled (absent-safe).
98
98
  */
99
99
  createAsyncStore?<T>(): AsyncStore<T>;
100
- /** Structured log sink. */
101
- log(level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>): void;
100
+ /**
101
+ * Structured log sink. Adds `debug` below `info`; the host adapter owns filtering + encoding.
102
+ *
103
+ * NOTE: widening this level union to include `"debug"` is a **breaking change** for any custom
104
+ * `HostContext` implementation whose `log` was typed with the narrower `"info" | "warn" | "error"`
105
+ * union — such a host must add a `"debug"` branch to keep satisfying the contract. See the
106
+ * "Structured logging" custom-host note in the runtime README.
107
+ */
108
+ log(level: "debug" | "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>): void;
102
109
  }
103
110
  /** A job handed to a worker handler, plus the ack/fail callbacks. `In` types the process
104
111
  * variables carried on the job; it defaults to an open record when a handler doesn't
@@ -0,0 +1,69 @@
1
+ /** Severity of a log record, from most to least verbose. */
2
+ export type LogLevel = "debug" | "info" | "warn" | "error";
3
+ /** A structured context bag attached to a log record. Values should be JSON-serializable. */
4
+ export type LogFields = Record<string, unknown>;
5
+ /**
6
+ * The low-level output seam a {@link Logger} writes to. The host adapter supplies it and owns
7
+ * encoding (NDJSON), stream routing (warn/error → stderr, else stdout) and level filtering. `fields`
8
+ * is the merged bag (a logger's bound context overlaid with the per-call fields).
9
+ */
10
+ export type LogSink = (level: LogLevel, msg: string, fields?: LogFields) => void;
11
+ /**
12
+ * The logger surface injected as `AppApi.log`. Callable for back-compat (`log(level, msg, fields)`),
13
+ * with per-level methods and `child()` for bound context. Prefer the methods:
14
+ *
15
+ * app.log.info("charge captured", { amount, currency });
16
+ * const jobLog = app.log.child({ orderId }); // every line below carries orderId
17
+ * jobLog.warn("retrying", { attempt });
18
+ */
19
+ export interface Logger {
20
+ /** Back-compat callable form. Prefer `log.info(...)` etc. */
21
+ (level: LogLevel, msg: string, fields?: LogFields): void;
22
+ debug(msg: string, fields?: LogFields): void;
23
+ info(msg: string, fields?: LogFields): void;
24
+ warn(msg: string, fields?: LogFields): void;
25
+ error(msg: string, fields?: LogFields): void;
26
+ /** A logger that merges `bindings` into every record it emits (per-call fields win on key clash). */
27
+ child(bindings: LogFields): Logger;
28
+ }
29
+ /** Levels ordered by increasing severity; the index is the numeric threshold used for filtering. */
30
+ export declare const LOG_LEVELS: readonly LogLevel[];
31
+ /**
32
+ * Parse a min-level string (e.g. from `URBAN_LOG_LEVEL`) into a {@link LogLevel}, case-insensitively,
33
+ * falling back to `fallback` (default `"info"`) for an absent or unrecognized value.
34
+ */
35
+ export declare function parseLogLevel(value: string | undefined, fallback?: LogLevel): LogLevel;
36
+ /** True when a record at `level` should be emitted given the configured `min` level. */
37
+ export declare function levelEnabled(level: LogLevel, min: LogLevel): boolean;
38
+ /** A single structured log record, as serialized to NDJSON. Reserved keys (`ts`, `level`, `msg`)
39
+ * lead; the flattened `fields` follow. A field named `ts`/`level`/`msg` cannot shadow a reserved
40
+ * key (the reserved value wins), so correlation on those keys stays reliable. */
41
+ export interface LogRecord {
42
+ /** Epoch milliseconds when the line was emitted. */
43
+ ts: number;
44
+ level: LogLevel;
45
+ msg: string;
46
+ [field: string]: unknown;
47
+ }
48
+ /**
49
+ * Encode one structured log record as a single-line JSON string (no trailing newline). The reserved
50
+ * `ts`/`level`/`msg` keys are written first and cannot be overwritten by a same-named field. Values
51
+ * that don't survive `JSON.stringify` (a `bigint`, a circular object) are coerced so a bad field
52
+ * never throws in the logging hot path or corrupts the stream. The whole build+encode path is
53
+ * guarded (not just `JSON.stringify`), so a field whose enumeration itself throws (e.g. a getter or
54
+ * Proxy trap) still yields a clean fallback line. The record is a null-prototype object so an
55
+ * untrusted field key like `__proto__` sets a plain own property rather than tripping the magic
56
+ * prototype setter.
57
+ */
58
+ export declare function formatLogRecord(level: LogLevel, msg: string, fields: LogFields | undefined, now: number): string;
59
+ /** {@link formatLogRecord} plus a trailing newline — one NDJSON line for a raw stream write. */
60
+ export declare function formatLogLine(level: LogLevel, msg: string, fields: LogFields | undefined, now: number): string;
61
+ /**
62
+ * Build a {@link Logger} over `sink`, optionally pre-bound to `bindings`. The returned value is a
63
+ * callable object: it forwards `log(level, msg, fields)` to the sink and exposes `debug/info/warn/
64
+ * error` plus `child()`. `bindings` is normalized into a null-prototype object (log field keys are
65
+ * user-controlled, so an untrusted key like `__proto__` must not trip the magic prototype setter),
66
+ * and `child(more)` returns a new logger whose bindings are `bindings` merged with `more` into a
67
+ * fresh null-prototype object, so bound context accumulates safely down a call chain.
68
+ */
69
+ export declare function createLogger(sink: LogSink, bindings?: LogFields): Logger;
@@ -0,0 +1,134 @@
1
+ // Structured logging for Urban apps. A worker handler and an API route delegate both receive an
2
+ // `AppApi` whose `log` is a {@link Logger}: an ergonomic, level-tagged sink that emits structured
3
+ // records (the host serializes them as NDJSON — one JSON object per line — to stdout/stderr).
4
+ //
5
+ // The value proposition over a bare `console.log`:
6
+ // • Levels: `debug` / `info` / `warn` / `error`, each `(msg, fields?)`.
7
+ // • Structured fields: pass a `{ … }` bag of context alongside the message rather than
8
+ // string-concatenating; it lands as JSON keys a log processor can filter on.
9
+ // • Bound context via `child(bindings)`: the runtime hands a worker handler a logger already
10
+ // bound to `{ jobKey, jobType, processInstanceKey, elementId }`, and an operation delegate one
11
+ // bound to `{ method, path, operationId }` — so every line a handler emits carries the
12
+ // correlation context for free. Authors can bind more with their own `child()`.
13
+ //
14
+ // This module is pure (no `node:*`, no `Deno`, no `globalThis` output): it turns level+msg+fields
15
+ // into a call on an injected {@link LogSink}. The host adapter (node.ts / deno.ts) owns the sink —
16
+ // NDJSON encoding, the stdout/stderr split, and level filtering — so the same Logger works in every
17
+ // runtime and is trivially unit-testable with a fake sink. Kept `node:*`-free per the ADR 0052
18
+ // core-purity invariant (core/purity.test.ts).
19
+ /** Levels ordered by increasing severity; the index is the numeric threshold used for filtering. */
20
+ export const LOG_LEVELS = ["debug", "info", "warn", "error"];
21
+ function isLogLevel(v) {
22
+ return v === "debug" || v === "info" || v === "warn" || v === "error";
23
+ }
24
+ /**
25
+ * Parse a min-level string (e.g. from `URBAN_LOG_LEVEL`) into a {@link LogLevel}, case-insensitively,
26
+ * falling back to `fallback` (default `"info"`) for an absent or unrecognized value.
27
+ */
28
+ export function parseLogLevel(value, fallback = "info") {
29
+ const v = value?.trim().toLowerCase();
30
+ return isLogLevel(v) ? v : fallback;
31
+ }
32
+ /** True when a record at `level` should be emitted given the configured `min` level. */
33
+ export function levelEnabled(level, min) {
34
+ return LOG_LEVELS.indexOf(level) >= LOG_LEVELS.indexOf(min);
35
+ }
36
+ /** Merge bound context with per-call fields (per-call wins), omitting the `fields` key entirely when
37
+ * the result is empty so a context-free line stays `{ level, msg }` clean. Always merges into a
38
+ * null-prototype object — even on the no-bindings fast path — and returns `undefined` for an empty
39
+ * bag: log field keys are user-controlled, so an untrusted key like `__proto__` must set a plain own
40
+ * property rather than trip the magic prototype setter a normal `{}` inherits from `Object.prototype`
41
+ * (same treatment as untrusted-key dictionaries elsewhere in this repo). Normalizing here — not just
42
+ * in {@link formatLogRecord} — keeps the merged bag safe for any custom {@link LogSink} that later
43
+ * spreads it into a plain object. */
44
+ function mergeFields(bindings, fields) {
45
+ const boundKeys = Object.keys(bindings);
46
+ const callKeys = fields ? Object.keys(fields) : [];
47
+ if (boundKeys.length === 0 && callKeys.length === 0)
48
+ return undefined;
49
+ if (boundKeys.length === 0)
50
+ return Object.assign(Object.create(null), fields);
51
+ if (callKeys.length === 0)
52
+ return Object.assign(Object.create(null), bindings);
53
+ return Object.assign(Object.create(null), bindings, fields);
54
+ }
55
+ /**
56
+ * Encode one structured log record as a single-line JSON string (no trailing newline). The reserved
57
+ * `ts`/`level`/`msg` keys are written first and cannot be overwritten by a same-named field. Values
58
+ * that don't survive `JSON.stringify` (a `bigint`, a circular object) are coerced so a bad field
59
+ * never throws in the logging hot path or corrupts the stream. The whole build+encode path is
60
+ * guarded (not just `JSON.stringify`), so a field whose enumeration itself throws (e.g. a getter or
61
+ * Proxy trap) still yields a clean fallback line. The record is a null-prototype object so an
62
+ * untrusted field key like `__proto__` sets a plain own property rather than tripping the magic
63
+ * prototype setter.
64
+ */
65
+ export function formatLogRecord(level, msg, fields, now) {
66
+ try {
67
+ const record = Object.create(null);
68
+ record.ts = now;
69
+ record.level = level;
70
+ record.msg = msg;
71
+ if (fields) {
72
+ for (const [k, v] of Object.entries(fields)) {
73
+ if (k === "ts" || k === "level" || k === "msg")
74
+ continue;
75
+ record[k] = v;
76
+ }
77
+ }
78
+ return JSON.stringify(record, (_key, value) => (typeof value === "bigint" ? value.toString() : value));
79
+ }
80
+ catch {
81
+ // A field that can't be built or serialized (circular ref, throwing getter) must not break logging.
82
+ return JSON.stringify({ ts: now, level, msg, fieldsError: "unserializable log fields" });
83
+ }
84
+ }
85
+ /** {@link formatLogRecord} plus a trailing newline — one NDJSON line for a raw stream write. */
86
+ export function formatLogLine(level, msg, fields, now) {
87
+ return `${formatLogRecord(level, msg, fields, now)}\n`;
88
+ }
89
+ /**
90
+ * Build a {@link Logger} over `sink`, optionally pre-bound to `bindings`. The returned value is a
91
+ * callable object: it forwards `log(level, msg, fields)` to the sink and exposes `debug/info/warn/
92
+ * error` plus `child()`. `bindings` is normalized into a null-prototype object (log field keys are
93
+ * user-controlled, so an untrusted key like `__proto__` must not trip the magic prototype setter),
94
+ * and `child(more)` returns a new logger whose bindings are `bindings` merged with `more` into a
95
+ * fresh null-prototype object, so bound context accumulates safely down a call chain.
96
+ */
97
+ export function createLogger(sink, bindings = {}) {
98
+ const bound = Object.assign(Object.create(null), bindings);
99
+ const emit = (level, msg, fields) => {
100
+ try {
101
+ sink(level, msg, mergeFields(bound, fields));
102
+ }
103
+ catch {
104
+ // A log call must never throw into its caller. A hostile `fields` object (e.g. a Proxy whose
105
+ // `ownKeys` trap throws, defeating mergeFields before the sink's own formatLogRecord guard)
106
+ // or a throwing sink degrades to a best-effort, field-free line rather than break the handler.
107
+ try {
108
+ sink(level, msg, Object.assign(Object.create(null), { fieldsError: "unmergeable log fields" }));
109
+ }
110
+ catch {
111
+ // The sink itself is broken — nothing more we can safely do without re-throwing.
112
+ }
113
+ }
114
+ };
115
+ return Object.assign(emit, {
116
+ debug: (msg, fields) => emit("debug", msg, fields),
117
+ info: (msg, fields) => emit("info", msg, fields),
118
+ warn: (msg, fields) => emit("warn", msg, fields),
119
+ error: (msg, fields) => emit("error", msg, fields),
120
+ child: (more) => {
121
+ // child() must not throw even when `more` is hostile (e.g. a Proxy whose key enumeration
122
+ // throws), mirroring emit()'s "logging never throws" guarantee: degrade to a child bound to
123
+ // just the parent context rather than let the merge bubble into the worker/route handler.
124
+ let childBindings = bound;
125
+ try {
126
+ childBindings = Object.assign(Object.create(null), bound, more);
127
+ }
128
+ catch {
129
+ // `more` could not be enumerated/merged — keep the parent bindings, drop the bad additions.
130
+ }
131
+ return createLogger(sink, childBindings);
132
+ },
133
+ });
134
+ }
@@ -15,7 +15,7 @@ import { errorMessage } from "../guards.js";
15
15
  import { MODULE_EXTENSION_CANDIDATES } from "../module-path.js";
16
16
  import { html, json, normalizeRoutePath } from "../router.js";
17
17
  import { resolveAppPath } from "./datasource.js";
18
- import { collectOperations, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, resolveSchema, responseSchemaForStatus, toRouteMatcher, undeclaredPathParams, undeclaredSecuritySchemes, evaluateSecurity, validateValue, } from "../../../openapi/spec.js";
18
+ import { collectOperations, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, resolveSchema, responseSchemaForStatus, schemaHasType, schemaValueTypes, toRouteMatcher, undeclaredPathParams, undeclaredSecuritySchemes, evaluateSecurity, validateValue, } from "../../../openapi/spec.js";
19
19
  /** The canonical, non-configurable mount prefix for an app's OpenAPI operations. Operations always
20
20
  * live under `/app/api` so page actions, links, and any `callRoute` can name an operation path as a
21
21
  * stable framework constant (`/app/api/<op path>`) — there is no per-app `base` knob to drift. */
@@ -146,28 +146,36 @@ function coerceParam(doc, schema, raw) {
146
146
  const s = resolveSchema(doc, schema);
147
147
  if (!s)
148
148
  return raw;
149
- // Effective scalar type: the explicit `type`, else inferred from a typeless `const`/`enum` whose
150
- // values are all numbers or all booleans. Without this, a typeless numeric/boolean const/enum
151
- // param (whose generated type is a number/boolean literal) could never be satisfied by the wire
152
- // string — every request would 400 against a type the runtime otherwise never produces.
153
- let t = s.type;
154
- if (t === undefined) {
149
+ // Which value types the schema permits coercion into: the explicit `type`(s), else inferred from a
150
+ // typeless `const`/`enum` whose values are all numbers or all booleans. Without the inference, a
151
+ // typeless numeric/boolean const/enum param (whose generated type is a number/boolean literal)
152
+ // could never be satisfied by the wire string — every request would 400 against a type the runtime
153
+ // otherwise never produces.
154
+ let valueTypes = schemaValueTypes(s);
155
+ if (valueTypes.length === 0) {
155
156
  if (typeof s.const === "number")
156
- t = "number";
157
+ valueTypes = ["number"];
157
158
  else if (typeof s.const === "boolean")
158
- t = "boolean";
159
+ valueTypes = ["boolean"];
159
160
  else if (Array.isArray(s.enum) && s.enum.length > 0) {
160
161
  if (s.enum.every((v) => typeof v === "number"))
161
- t = "number";
162
+ valueTypes = ["number"];
162
163
  else if (s.enum.every((v) => typeof v === "boolean"))
163
- t = "boolean";
164
+ valueTypes = ["boolean"];
164
165
  }
165
166
  }
166
- if (t === "integer" || t === "number") {
167
+ // Coerce order-independently for OpenAPI 3.1 multi-type unions: a schema that *allows* number or
168
+ // boolean gets that coercion attempted regardless of where it sits in the `type` array (so
169
+ // `["string","integer"]` and `["integer","string"]` behave identically). Each attempt falls back
170
+ // to the raw string when it doesn't parse, preserving a value that only fits the union's `string`
171
+ // arm. Number is tried before boolean so a numeric-looking value in a `number|boolean` union
172
+ // becomes a number (`"true"`/`"false"` aren't numeric, so they still reach the boolean arm).
173
+ if (valueTypes.includes("integer") || valueTypes.includes("number")) {
167
174
  const n = Number(raw);
168
- return raw.trim() !== "" && !Number.isNaN(n) ? n : raw;
175
+ if (raw.trim() !== "" && !Number.isNaN(n))
176
+ return n;
169
177
  }
170
- if (t === "boolean") {
178
+ if (valueTypes.includes("boolean")) {
171
179
  if (raw === "true")
172
180
  return true;
173
181
  if (raw === "false")
@@ -438,7 +446,7 @@ export function mountApi(ctx, app) {
438
446
  continue;
439
447
  const values = Array.isArray(val) ? val : [val];
440
448
  const ps = resolveSchema(d, p.schema);
441
- if (ps?.type === "array") {
449
+ if (ps && schemaHasType(ps, "array")) {
442
450
  // Repeated keys (?tag=a&tag=b) form the array; coerce + validate each item, then the
443
451
  // whole array against the schema's array constraints (minItems, items, ...). The coerced
444
452
  // array is what the delegate receives.
@@ -507,8 +515,14 @@ export function mountApi(ctx, app) {
507
515
  // required), so pass `undefined` to those handlers rather than leaking a parsed value through
508
516
  // a body-less type.
509
517
  const handlerBody = op.requestBodySchema !== undefined || op.requestBodyRequired ? body : undefined;
518
+ // Hand the delegate an AppApi whose `log` is bound to this request's correlation context, so its
519
+ // lines self-tag with `{ method, path, operationId }` without the author threading it.
520
+ const requestApp = {
521
+ ...app,
522
+ log: app.log.child({ method, path: op.path, operationId: op.operationId }),
523
+ };
510
524
  try {
511
- const result = await handler({ req, params: coercedParams, query: coercedQuery, body: handlerBody }, app);
525
+ const result = await handler({ req, params: coercedParams, query: coercedQuery, body: handlerBody }, requestApp);
512
526
  if (!result)
513
527
  return { status: 204 };
514
528
  const status = result.status ?? 200;