@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
@@ -560,7 +560,36 @@ function applyTheme(msg) {
560
560
  }
561
561
  }
562
562
  }
563
- if (window.parent && window.parent !== window) {
563
+ // True when this app runs inside the Nano console's same-origin iframe (vs a
564
+ // standalone CLI run). Gates the theme bridge below AND in-host link navigation:
565
+ // embedded, a processExplorer link routes the console in place; standalone, the
566
+ // link keeps its native new-window behavior. The same-origin probe matters: a
567
+ // cross-origin framer is also window.parent, but postMessage to our own origin
568
+ // would never reach it and hostNavigate would still preventDefault the link —
569
+ // so a cross-origin embed must fall through to the native _blank anchor. Reading
570
+ // parent.location.origin throws under the same-origin policy for a cross-origin
571
+ // parent, so the try/catch is the origin gate.
572
+ const NANO_EMBEDDED = (() => {
573
+ if (!window.parent || window.parent === window) return false;
574
+ try {
575
+ return window.parent.location.origin === window.location.origin;
576
+ } catch {
577
+ return false;
578
+ }
579
+ })();
580
+
581
+ // Ask the framing console to navigate in-host. Returns true when a message was
582
+ // posted (embedded) so the caller can suppress the native anchor; false
583
+ // standalone so it falls through to the new-window <a>. The payload is
584
+ // structured (target + params), never a raw href — the console constructs its
585
+ // own route, so app/row data can't smuggle a path or scheme across the boundary.
586
+ function hostNavigate(target, params) {
587
+ if (!NANO_EMBEDDED) return false;
588
+ window.parent.postMessage({ type: "nano-navigate", target: target, params: params }, window.location.origin);
589
+ return true;
590
+ }
591
+
592
+ if (NANO_EMBEDDED) {
564
593
  window.addEventListener("message", (ev) => {
565
594
  // Same-origin proxy (posture A): only trust a message from the framing
566
595
  // parent AND from our own origin. ev.source alone is insufficient — a
@@ -675,8 +704,12 @@ function renderText(node) {
675
704
  // non-empty key (after trimming surrounding whitespace) produces a link.
676
705
  // Unknown link kinds fall back to plain text so an unrecognised schema
677
706
  // can't render a broken anchor.
678
- // Both open in a new tab with rel=noopener noreferrer so the opened page can't
679
- // reach window.opener. Shared by the top-level grid and child grids.
707
+ // The external linkField anchor always opens in a new tab (rel=noopener
708
+ // noreferrer). The processExplorer anchor does too when the app runs
709
+ // standalone, but when embedded in the console a plain click instead routes the
710
+ // host's own explorer view in place via the nano-navigate postMessage bridge
711
+ // (a modified/middle click keeps the new-tab behavior). Shared by the top-level
712
+ // grid and child grids.
680
713
  function gridCell(col, row) {
681
714
  const text = row[col.field] == null ? "" : String(row[col.field]);
682
715
  if (col.linkField) {
@@ -697,7 +730,22 @@ function gridCell(col, row) {
697
730
  return el(
698
731
  "td",
699
732
  {},
700
- el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text),
733
+ el("a", {
734
+ class: "pc-link",
735
+ href,
736
+ target: "_blank",
737
+ rel: "noopener noreferrer",
738
+ // Embedded in the console a plain primary click routes the host's
739
+ // explorer in place (via the nano-navigate bridge) instead of opening
740
+ // a new window. A non-primary button (middle/right = new-tab/context
741
+ // intent), a modified click (⌘/ctrl/shift/alt = new-tab intent) and
742
+ // any standalone run fall through to the native anchor above.
743
+ onclick: (ev) => {
744
+ if (ev.button !== 0) return;
745
+ if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
746
+ if (hostNavigate("processExplorer", { instance: keyStr })) ev.preventDefault();
747
+ },
748
+ }, text),
701
749
  );
702
750
  }
703
751
  }
@@ -33,6 +33,21 @@ function hasDecisionCapableSdk(sdk) {
33
33
  function isAppJobHandler(value) {
34
34
  return typeof value === "function";
35
35
  }
36
+ /**
37
+ * Hand a handler an {@link AppApi} whose `log` is bound to the job's correlation context, so every
38
+ * line the handler emits self-tags with `{ jobKey, jobType, processInstanceKey, elementId }` without
39
+ * the author threading it. Undefined fields (e.g. a job dispatched without an instance) are omitted
40
+ * so the bound context stays clean. The rest of the API is shared by reference — only `log` is
41
+ * per-job.
42
+ */
43
+ function withJobLog(app, job) {
44
+ const bindings = { jobKey: job.jobKey, jobType: job.jobType };
45
+ if (job.processInstanceKey !== undefined)
46
+ bindings.processInstanceKey = job.processInstanceKey;
47
+ if (job.elementId !== undefined)
48
+ bindings.elementId = job.elementId;
49
+ return { ...app, log: app.log.child(bindings) };
50
+ }
36
51
  /**
37
52
  * Resolve a handler for `jobType` from a loaded module, in priority order:
38
53
  * 1. `handlers[jobType]` (a map keyed by job type — the multi-type module case)
@@ -114,7 +129,7 @@ export async function mountWorkers(ctx, app) {
114
129
  throw new Error(`worker "${jobType}": ${decl.handler} exports no handler for it ` +
115
130
  `(expected handlers["${jobType}"], a named export, or a default function)`);
116
131
  }
117
- const wrapped = (job) => runInJobContext({ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType }, () => handler(job, app));
132
+ const wrapped = (job) => runInJobContext({ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType }, () => handler(job, withJobLog(app, job)));
118
133
  const sub = await ctx.engine.registerWorker(jobType, wrapped, {
119
134
  workerName: `${ctx.manifest.id}:${jobType}`,
120
135
  });
@@ -2,6 +2,7 @@ import type { EngineClient, HostContext } from "./host.ts";
2
2
  import { type AppManifest } from "./manifest.ts";
3
3
  import type { TemplateSource } from "./modules/templates.ts";
4
4
  import { DataLayer } from "./modules/datasource.ts";
5
+ import { type Logger } from "./logger.ts";
5
6
  import type { SchedulerDeps } from "./modules/scheduler.ts";
6
7
  import { type SecurityPolicy } from "./modules/security.ts";
7
8
  /** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
@@ -56,6 +57,10 @@ export interface UrbanApp {
56
57
  stop(): Promise<void>;
57
58
  /** A structured snapshot of what is mounted. */
58
59
  inspect(): Record<string, unknown>;
60
+ /** An app-level structured {@link Logger} for the entrypoint (`main.ts`). It carries no
61
+ * per-request/per-job correlation (worker handlers and route delegates get a child logger bound
62
+ * to their job/request context via `AppApi.log`); use it for boot/shutdown lifecycle lines. */
63
+ readonly log: Logger;
59
64
  /** The provisioned data layer (available after start when `data` is mounted). */
60
65
  readonly data: DataLayer | undefined;
61
66
  readonly security: SecurityPolicy | undefined;
@@ -8,6 +8,7 @@ import { makeRouter } from "./router.js";
8
8
  import { deployModels } from "./modules/deploy.js";
9
9
  import { provisionData, DataLayer } from "./modules/datasource.js";
10
10
  import { installExecStore } from "./execContext.js";
11
+ import { createLogger } from "./logger.js";
11
12
  import { mountWorkers } from "./modules/workers.js";
12
13
  import { mountConnectors } from "./modules/connectors.js";
13
14
  import { mountSurfaces } from "./modules/surfaces.js";
@@ -48,6 +49,10 @@ export async function createUrbanApp(opts) {
48
49
  instanceTracking: opts.mount?.instanceTracking ?? true,
49
50
  };
50
51
  const ctx = { manifest, host, engine, root, templates: opts.templates };
52
+ // One app-level structured logger over the host sink, shared by the entrypoint (exposed as
53
+ // `app.log`) and the per-invocation `AppApi.log` (workers/route delegates `child()` it with their
54
+ // correlation context).
55
+ const appLog = createLogger((l, m, f) => host.log(l, m, f));
51
56
  // Install the ambient job-execution store once per process (idempotent) so worker dispatch can
52
57
  // stamp write-provenance onto DataLayer inserts. Absent-safe: a host without `createAsyncStore`
53
58
  // leaves provenance capture disabled.
@@ -99,6 +104,7 @@ export async function createUrbanApp(opts) {
99
104
  const app = {
100
105
  manifest,
101
106
  root,
107
+ log: appLog,
102
108
  get data() {
103
109
  return data;
104
110
  },
@@ -124,7 +130,7 @@ export async function createUrbanApp(opts) {
124
130
  engine,
125
131
  sdk: engineSdk(engine),
126
132
  env: (n) => host.env(n),
127
- log: (l, m, f) => host.log(l, m, f),
133
+ log: appLog,
128
134
  };
129
135
  if (flags.workers) {
130
136
  const w = await mountWorkers(ctx, api);
@@ -92,7 +92,7 @@ export interface NanoSdkClient {
92
92
  stopAllWorkers?(): void | Promise<void>;
93
93
  close?(): void | Promise<void>;
94
94
  }
95
- type Log = (level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>) => void;
95
+ type Log = (level: "debug" | "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>) => void;
96
96
  /**
97
97
  * An `EngineClient` backed entirely by a `@nanobpm/nano-sdk` client. Deploy, create,
98
98
  * message, and user-task calls map one-to-one onto the SDK's client methods; workers
@@ -6,6 +6,7 @@ export { collectManifestIssues, ManifestValidationError, validateManifest, } fro
6
6
  export type { ValidationIssue } from "./core/validate.ts";
7
7
  export type { EngineClient, EngineJob, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, SqliteDb, WatchHandle, WorkerSubscription, } from "./core/host.ts";
8
8
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
9
+ export type { Logger, LogLevel, LogFields } from "./core/logger.ts";
9
10
  export { BpmnError, isBpmnError } from "./core/host.ts";
10
11
  export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.ts";
11
12
  export type { ConnectorsHandle } from "./core/modules/connectors.ts";
@@ -8,7 +8,7 @@
8
8
  // the shared `openapi/spec.ts`, so types and runtime never drift.
9
9
  import { GENERATED_DIR } from "../artifact.js";
10
10
  import { DEFAULT_OPERATIONS_DIR, normalizeApiDir } from "../api-dir.js";
11
- import { collectOperations, assertUniqueOperationIds, isObjectSchema, refName, } from "../../openapi/spec.js";
11
+ import { collectOperations, assertUniqueOperationIds, isObjectSchema, refName, schemaAllowsNull, schemaTypeList, schemaValueTypes, } from "../../openapi/spec.js";
12
12
  /** Filenames of the emitted artifacts (siblings of worker-io.d.ts in nano-generated/). */
13
13
  export const API_BINDINGS_DTS = "api-io.d.ts";
14
14
  export const API_BINDINGS_TS = "operations.ts";
@@ -35,7 +35,7 @@ export function schemaToTs(schema, depth = 0) {
35
35
  const name = refName(schema.$ref);
36
36
  return name ? typeStem(name) : "unknown";
37
37
  }
38
- const nullable = schema.nullable === true;
38
+ const nullable = schemaAllowsNull(schema);
39
39
  const wrap = (t) => (nullable ? `${t} | null` : t);
40
40
  if (Array.isArray(schema.enum) && schema.enum.length > 0) {
41
41
  return wrap(schema.enum.map((v) => JSON.stringify(v)).join(" | "));
@@ -51,7 +51,13 @@ export function schemaToTs(schema, depth = 0) {
51
51
  if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
52
52
  return wrap(schema.allOf.map((s) => schemaToTs(s, depth + 1)).join(" & "));
53
53
  }
54
- switch (schema.type) {
54
+ // Value types, normalized from OpenAPI 3.0 (single string) or 3.1 (array, e.g. `[T, "null"]` or a
55
+ // multi-type union). `null` is stripped here and re-applied by `wrap` via `schemaAllowsNull`.
56
+ const valueTypes = schemaValueTypes(schema);
57
+ if (valueTypes.length > 1) {
58
+ return wrap(valueTypes.map((t) => tsForScalarType(t, schema, depth)).join(" | "));
59
+ }
60
+ switch (valueTypes[0]) {
55
61
  case "string":
56
62
  return wrap("string");
57
63
  case "integer":
@@ -59,20 +65,40 @@ export function schemaToTs(schema, depth = 0) {
59
65
  return wrap("number");
60
66
  case "boolean":
61
67
  return wrap("boolean");
62
- case "null":
63
- return "null";
64
68
  case "array":
65
69
  return wrap(`Array<${schemaToTs(schema.items, depth + 1)}>`);
66
70
  case "object":
67
71
  return wrap(objectToTs(schema, depth));
68
72
  default:
69
- // No `type`: infer object-ness from object-only keywords (shared with the runtime
70
- // validator via isObjectSchema so types and validation agree); otherwise unknown.
73
+ // No value type. A schema whose only declared type is "null" is exactly `null`; otherwise
74
+ // infer object-ness from object-only keywords (shared with the runtime validator via
75
+ // isObjectSchema so types and validation agree), else fall back to unknown.
76
+ if (schemaTypeList(schema).length > 0 && schemaValueTypes(schema).length === 0)
77
+ return "null";
71
78
  if (isObjectSchema(schema))
72
79
  return wrap(objectToTs(schema, depth));
73
80
  return "unknown";
74
81
  }
75
82
  }
83
+ /** The TS expression for a single (non-"null") JSON-Schema value type. Nullability is applied by
84
+ * the caller's `wrap`, so this never emits `| null`. */
85
+ function tsForScalarType(t, schema, depth) {
86
+ switch (t) {
87
+ case "string":
88
+ return "string";
89
+ case "integer":
90
+ case "number":
91
+ return "number";
92
+ case "boolean":
93
+ return "boolean";
94
+ case "array":
95
+ return `Array<${schemaToTs(schema.items, depth + 1)}>`;
96
+ case "object":
97
+ return objectToTs(schema, depth);
98
+ default:
99
+ return "unknown";
100
+ }
101
+ }
76
102
  function objectToTs(schema, depth) {
77
103
  const props = schema.properties ?? {};
78
104
  const required = new Set(schema.required ?? []);
@@ -35,7 +35,9 @@ export function renderOperationStub(operationId, dir = DEFAULT_OPERATIONS_DIR) {
35
35
  `\n` +
36
36
  `export default defineOperation(${key}, async (input, app) => {\n` +
37
37
  ` // \`input\` is the validated request (params/query/body/req); reach app state + services\n` +
38
- ` // through the injected \`app\` API. Return \`{ status?, headers?, body? }\` (or nothing → 204).\n` +
38
+ ` // through the injected \`app\` API. Structured logs via \`app.log.info("…", { … })\` are\n` +
39
+ ` // auto-tagged with this request's { method, path, operationId } (ADR 0061).\n` +
40
+ ` // Return \`{ status?, headers?, body? }\` (or nothing → 204).\n` +
39
41
  ` throw new NotImplemented(${key});\n` +
40
42
  `});\n`);
41
43
  }
@@ -50,6 +50,8 @@ export function renderWorkerStub(taskType, typedIn, typedOut) {
50
50
  varsNote +
51
51
  `\n` +
52
52
  ` // Reach app state and services through the injected \`app\` API.\n` +
53
+ ` // Structured logs: \`app.log.info("done", { … })\` — auto-tagged with this job's\n` +
54
+ ` // { jobKey, jobType, processInstanceKey, elementId } (ADR 0061).\n` +
53
55
  ` throw new Error(${JSON.stringify(`worker not implemented: ${taskType}`)});\n` +
54
56
  `};\n` +
55
57
  `\n` +
package/dist/worker.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export type { AppJobHandler } from "./runtime/core/modules/workers.ts";
2
2
  export type { EngineJob, JobHandler } from "./runtime/core/host.ts";
3
3
  export type { AppApi } from "./runtime/core/context.ts";
4
+ export type { Logger, LogLevel, LogFields } from "./runtime/core/logger.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.39.0",
3
+ "version": "0.40.1",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -66,7 +66,7 @@
66
66
  "@nanobpm/nano-sdk": "^1.2.3",
67
67
  "@nanobpm/workflow": "^0.8.0",
68
68
  "bpmn-auto-layout": "^2.0.0-alpha.2",
69
- "create-urban-app": "^0.9.0",
69
+ "create-urban-app": "^0.13.0",
70
70
  "yaml": "^2.9.0"
71
71
  },
72
72
  "devDependencies": {
@@ -208,6 +208,53 @@ test("isObjectSchema treats typeless property/required/additionalProperties sche
208
208
  assert.equal(isObjectSchema({}), false); // no shape keywords → not an object
209
209
  });
210
210
 
211
+ test("isObjectSchema understands OpenAPI 3.1 nullable type-arrays", () => {
212
+ assert.equal(isObjectSchema({ type: ["object", "null"] }), true); // nullable object is an object
213
+ assert.equal(isObjectSchema({ type: ["string", "null"] }), false); // nullable scalar is not
214
+ });
215
+
216
+ test("validateValue accepts OpenAPI 3.1 nullable type-arrays (type: [T, null])", () => {
217
+ // The 3.1 idiom `type: [T, "null"]` must be treated exactly like 3.0 `nullable: true`: the value
218
+ // type is accepted, an explicit null is accepted, and a wrong type is still rejected.
219
+ const nullableStr: OpenApiSchema = { type: ["string", "null"] };
220
+ assert.deepEqual(validateValue(doc, nullableStr, "hi"), []);
221
+ assert.deepEqual(validateValue(doc, nullableStr, null), []);
222
+ assert.equal(validateValue(doc, nullableStr, 42).length, 1);
223
+
224
+ const nullableInt: OpenApiSchema = { type: ["integer", "null"] };
225
+ assert.deepEqual(validateValue(doc, nullableInt, 7), []);
226
+ assert.deepEqual(validateValue(doc, nullableInt, null), []);
227
+ assert.equal(validateValue(doc, nullableInt, "no").length, 1);
228
+ });
229
+
230
+ test("validateValue keeps bounds + multi-type unions under 3.1 type-arrays", () => {
231
+ // String bounds still apply to the string arm of a nullable string.
232
+ const bounded: OpenApiSchema = { type: ["string", "null"], minLength: 2 };
233
+ assert.deepEqual(validateValue(doc, bounded, "ok"), []);
234
+ assert.deepEqual(validateValue(doc, bounded, null), []);
235
+ assert.equal(validateValue(doc, bounded, "x").length, 1); // shorter than minLength
236
+
237
+ // A genuine multi-type union (no null) accepts any listed type, rejects others.
238
+ const strOrNum: OpenApiSchema = { type: ["string", "number"] };
239
+ assert.deepEqual(validateValue(doc, strOrNum, "a"), []);
240
+ assert.deepEqual(validateValue(doc, strOrNum, 1), []);
241
+ assert.equal(validateValue(doc, strOrNum, true).length, 1);
242
+ assert.equal(validateValue(doc, strOrNum, null).length, 1); // null not permitted without "null"
243
+ });
244
+
245
+ test("validateValue rejects non-null values for a null-only schema (type: [null] / type: null)", () => {
246
+ // A schema whose only declared type is "null" must accept null and reject everything else — the
247
+ // value-type list is empty, so the type check must fall back to the full `type` list.
248
+ const nullOnlyArr: OpenApiSchema = { type: ["null"] };
249
+ assert.deepEqual(validateValue(doc, nullOnlyArr, null), []);
250
+ assert.equal(validateValue(doc, nullOnlyArr, "x").length, 1);
251
+ assert.equal(validateValue(doc, nullOnlyArr, 0).length, 1);
252
+
253
+ const nullOnlyStr: OpenApiSchema = { type: "null" };
254
+ assert.deepEqual(validateValue(doc, nullOnlyStr, null), []);
255
+ assert.equal(validateValue(doc, nullOnlyStr, "x").length, 1);
256
+ });
257
+
211
258
  test("validateValue shape-checks typeless object schemas (no type/runtime drift)", () => {
212
259
  // A schema with properties/required but no explicit `type: "object"` is emitted as an object
213
260
  // by schemaToTs, so the validator must shape-check it too — otherwise invalid bodies pass.
@@ -8,8 +8,10 @@
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
 
14
16
  import { parse as parseYaml } from "yaml";
15
17
 
@@ -17,7 +19,11 @@ import { parse as parseYaml } from "yaml";
17
19
  * are ignored by the validator and fall back to `unknown` in the type emitter. */
18
20
  export interface OpenApiSchema {
19
21
  $ref?: string;
20
- type?: string;
22
+ // OpenAPI 3.0 uses a single `type` string; JSON Schema 2020-12 (OpenAPI 3.1) also permits an
23
+ // array of types, most commonly the nullable idiom `type: [T, "null"]`. Both dialects are read
24
+ // via the `schemaTypeList`/`schemaValueTypes`/`schemaAllowsNull`/`schemaHasType` helpers so the
25
+ // type emitter and the runtime validator treat a 3.1 type-array the same way.
26
+ type?: string | string[];
21
27
  nullable?: boolean;
22
28
  enum?: unknown[];
23
29
  const?: unknown;
@@ -46,6 +52,37 @@ export interface OpenApiSchema {
46
52
  description?: string;
47
53
  }
48
54
 
55
+ /**
56
+ * The declared JSON-Schema `type`(s) as a list, normalizing OpenAPI 3.0 (a single string) and
57
+ * 3.1 (a `string[]`) into one shape. Returns `[]` when no `type` keyword is present, so callers
58
+ * distinguish "typeless" (infer from other keywords) from "typed".
59
+ */
60
+ export function schemaTypeList(schema: OpenApiSchema): string[] {
61
+ if (schema.type === undefined) return [];
62
+ return Array.isArray(schema.type) ? schema.type : [schema.type];
63
+ }
64
+
65
+ /** The declared non-"null" value types — what a 3.1 `type: [T, "null"]` permits as a value. */
66
+ export function schemaValueTypes(schema: OpenApiSchema): string[] {
67
+ return schemaTypeList(schema).filter((t) => t !== "null");
68
+ }
69
+
70
+ /** The first declared non-"null" value type (for single-type scalar logic), or `undefined`. */
71
+ export function firstValueType(schema: OpenApiSchema): string | undefined {
72
+ return schemaValueTypes(schema)[0];
73
+ }
74
+
75
+ /** Whether the schema permits an explicit `null` — via OpenAPI 3.0 `nullable: true` or the 3.1
76
+ * idiom `type: [..., "null"]`. */
77
+ export function schemaAllowsNull(schema: OpenApiSchema): boolean {
78
+ return schema.nullable === true || schemaTypeList(schema).includes("null");
79
+ }
80
+
81
+ /** Whether the schema declares (permits) the given non-"null" value type. */
82
+ export function schemaHasType(schema: OpenApiSchema, t: string): boolean {
83
+ return schemaValueTypes(schema).includes(t);
84
+ }
85
+
49
86
  /**
50
87
  * Whether a schema describes an object shape. OpenAPI/JSON-Schema let the `type`
51
88
  * keyword be omitted when `properties`/`required`/`additionalProperties` already imply
@@ -54,8 +91,9 @@ export interface OpenApiSchema {
54
91
  * the emitted type calls an object is the same body the validator shape-checks.
55
92
  */
56
93
  export function isObjectSchema(schema: OpenApiSchema): boolean {
57
- if (schema.type === "object") return true;
58
- if (schema.type !== undefined) return false;
94
+ const valueTypes = schemaValueTypes(schema);
95
+ if (valueTypes.includes("object")) return true;
96
+ if (valueTypes.length > 0) return false;
59
97
  return (
60
98
  schema.properties !== undefined ||
61
99
  schema.required !== undefined ||
@@ -650,9 +688,15 @@ function matchesType(declared: string, value: unknown): boolean {
650
688
  const actual = typeOfValue(value);
651
689
  if (declared === "number") return actual === "number" || actual === "integer";
652
690
  if (declared === "integer") return actual === "integer";
691
+ if (declared === "null") return value === null;
653
692
  return actual === declared;
654
693
  }
655
694
 
695
+ /** Whether `value` matches any of the declared value types (a 3.1 `type` list is a union). */
696
+ function matchesAnyType(declared: string[], value: unknown): boolean {
697
+ return declared.some((t) => matchesType(t, value));
698
+ }
699
+
656
700
  /** Validate `value` against `schema` (resolving local $refs against `doc`), returning every issue
657
701
  * found (empty = valid). A pragmatic JSON-Schema subset; unknown keywords are ignored. */
658
702
  export function validateValue(
@@ -667,18 +711,19 @@ export function validateValue(
667
711
  const at = path || "(root)";
668
712
 
669
713
  if (value === null) {
670
- if (schema.nullable === true || schema.type === "null") return issues;
714
+ if (schemaAllowsNull(schema)) return issues;
671
715
  // enum/const may explicitly permit null even without `nullable`.
672
716
  if (schema.enum && schema.enum.some((e) => e === null)) return issues;
673
717
  if (schema.const === null) return issues;
674
718
  // Otherwise null is invalid for a typed schema, an object schema (properties/required imply
675
719
  // object), or an enum/const that doesn't list null. A truly empty schema ({}) allows anything.
720
+ const valueTypes = schemaValueTypes(schema);
676
721
  if (schema.enum) {
677
722
  issues.push({ path: at, message: `must be one of ${JSON.stringify(schema.enum)}` });
678
723
  } else if (schema.const !== undefined) {
679
724
  issues.push({ path: at, message: `must equal ${JSON.stringify(schema.const)}` });
680
- } else if (schema.type) {
681
- issues.push({ path: at, message: `expected ${schema.type}, got null` });
725
+ } else if (valueTypes.length > 0) {
726
+ issues.push({ path: at, message: `expected ${valueTypes.join(",")}, got null` });
682
727
  } else if (isObjectSchema(schema)) {
683
728
  issues.push({ path: at, message: "expected object, got null" });
684
729
  }
@@ -708,12 +753,16 @@ export function validateValue(
708
753
  }
709
754
  }
710
755
 
711
- if (schema.type && !matchesType(schema.type, value)) {
712
- issues.push({ path: at, message: `expected ${schema.type}, got ${typeOfValue(value)}` });
756
+ // `value` is non-null here (the null branch returned above), so match against the declared value
757
+ // types. Use the full `type` list — not `schemaValueTypes` — so a null-only schema (`["null"]`)
758
+ // still rejects non-null values instead of skipping the check on an empty value-type list.
759
+ const declaredTypes = schemaTypeList(schema);
760
+ if (declaredTypes.length > 0 && !matchesAnyType(declaredTypes, value)) {
761
+ issues.push({ path: at, message: `expected ${declaredTypes.join(",")}, got ${typeOfValue(value)}` });
713
762
  return issues; // the shape checks below assume the declared type held
714
763
  }
715
764
 
716
- if (schema.type === "string" && typeof value === "string") {
765
+ if (schemaHasType(schema, "string") && typeof value === "string") {
717
766
  if (schema.minLength !== undefined && value.length < schema.minLength) {
718
767
  issues.push({ path: at, message: `shorter than minLength ${schema.minLength}` });
719
768
  }
@@ -737,7 +786,7 @@ export function validateValue(
737
786
  }
738
787
  }
739
788
 
740
- if ((schema.type === "number" || schema.type === "integer") && typeof value === "number") {
789
+ if ((schemaHasType(schema, "number") || schemaHasType(schema, "integer")) && typeof value === "number") {
741
790
  // OpenAPI 3.0 uses the boolean form (`exclusiveMinimum: true` makes `minimum` exclusive);
742
791
  // JSON Schema 2020-12 (OpenAPI 3.1) uses the numeric form (`exclusiveMinimum: <number>`).
743
792
  // Support both so specs in either dialect are validated, not silently under-checked.
@@ -763,7 +812,7 @@ export function validateValue(
763
812
  }
764
813
  }
765
814
 
766
- if (schema.type === "array" && Array.isArray(value)) {
815
+ if (schemaHasType(schema, "array") && Array.isArray(value)) {
767
816
  if (schema.minItems !== undefined && value.length < schema.minItems) {
768
817
  issues.push({ path: at, message: `fewer than minItems ${schema.minItems}` });
769
818
  }
@@ -17,6 +17,7 @@ import type {
17
17
  SqliteDb,
18
18
  } from "../core/host.ts";
19
19
  import { resolveModulePath } from "../core/module-path.ts";
20
+ import { formatLogRecord, levelEnabled, parseLogLevel } from "../core/logger.ts";
20
21
 
21
22
  type SqliteParam = string | number | bigint | Uint8Array | null;
22
23
 
@@ -64,16 +65,28 @@ export interface DenoHostOptions {
64
65
  importNonce?: string;
65
66
  }
66
67
 
68
+ /** Read `URBAN_LOG_LEVEL` without requiring `--allow-env`. `Deno.env.get` throws when env access is
69
+ * not granted, but an app that only logs shouldn't be forced to open env permission just to emit a
70
+ * line — so treat a permission (or any) error as an unset value and let logging fall back to the
71
+ * default `info` level. Logging must stay safe by default. */
72
+ function readLogLevelEnv(): string | undefined {
73
+ try {
74
+ return Deno.env.get("URBAN_LOG_LEVEL");
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ }
79
+
67
80
  export function createDenoHost(opts: DenoHostOptions = {}): HostContext {
68
81
  const cwd = opts.cwd ?? Deno.cwd();
69
82
  const abs = (p: string) => (isAbsolute(p) ? p : resolve(cwd, p));
70
83
  const log: HostContext["log"] =
71
84
  opts.log ??
72
85
  ((level, msg, fields) => {
73
- const line = fields ? `${msg} ${JSON.stringify(fields)}` : msg;
74
- (level === "error" ? console.error : level === "warn" ? console.warn : console.log)(
75
- `[urban] ${line}`,
76
- );
86
+ if (!levelEnabled(level, parseLogLevel(readLogLevelEnv()))) return;
87
+ // console.* appends the newline; NDJSON stays one object per line. warn/error → stderr.
88
+ const line = formatLogRecord(level, msg, fields, Date.now());
89
+ (level === "warn" || level === "error" ? console.error : console.log)(line);
77
90
  });
78
91
 
79
92
  return {
@@ -20,6 +20,22 @@ import type {
20
20
  WatchHandle,
21
21
  } from "../core/host.ts";
22
22
  import { resolveModulePath } from "../core/module-path.ts";
23
+ import { formatLogLine, levelEnabled, parseLogLevel } from "../core/logger.ts";
24
+
25
+ /**
26
+ * The default host log sink: NDJSON to stdout (`debug`/`info`) and stderr (`warn`/`error`), gated by
27
+ * a minimum level read from `URBAN_LOG_LEVEL` (default `info`) per record so a level change takes
28
+ * effect without a restart. Writing straight to the stream (not `console.*`) keeps one JSON object
29
+ * per line so downstream tooling can ingest them (the Nano host does not yet parse or surface these
30
+ * lines — see ADR 0061).
31
+ */
32
+ function makeNdjsonLog(minLevel: () => string | undefined): HostContext["log"] {
33
+ return (level, msg, fields) => {
34
+ if (!levelEnabled(level, parseLogLevel(minLevel()))) return;
35
+ const line = formatLogLine(level, msg, fields, Date.now());
36
+ (level === "warn" || level === "error" ? process.stderr : process.stdout).write(line);
37
+ };
38
+ }
23
39
 
24
40
  type SqliteParam = string | number | bigint | Uint8Array | null;
25
41
 
@@ -106,13 +122,7 @@ export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
106
122
  const cwd = opts.cwd ?? process.cwd();
107
123
  const abs = (p: string) => (isAbsolute(p) ? p : resolve(cwd, p));
108
124
  const log: HostContext["log"] =
109
- opts.log ??
110
- ((level, msg, fields) => {
111
- const line = fields ? `${msg} ${JSON.stringify(fields)}` : msg;
112
- (level === "error" ? console.error : level === "warn" ? console.warn : console.log)(
113
- `[urban] ${line}`,
114
- );
115
- });
125
+ opts.log ?? makeNdjsonLog(() => process.env.URBAN_LOG_LEVEL);
116
126
 
117
127
  return {
118
128
  runtime: "node",
@@ -25,6 +25,7 @@ export interface Mounted {
25
25
 
26
26
  // AppApi is imported lazily to avoid a cycle; declared here as the injected handler surface.
27
27
  import type { DataLayer } from "./modules/datasource.ts";
28
+ import type { Logger } from "./logger.ts";
28
29
 
29
30
  /**
30
31
  * The surface injected into worker/trigger/surface handlers — the app's runtime API.
@@ -45,5 +46,13 @@ export interface AppApi {
45
46
  */
46
47
  sdk?: EngineSdkClient;
47
48
  env(name: string): string | undefined;
48
- log(level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>): void;
49
+ /**
50
+ * Structured logger (see {@link Logger}). Callable for back-compat
51
+ * (`log("info", msg, fields)`), but prefer the level methods and bound context:
52
+ * `app.log.info(msg, fields)`, `app.log.child({ … })`. The runtime hands worker
53
+ * handlers a logger already bound to the job's `{ jobKey, jobType,
54
+ * processInstanceKey, elementId }`, and operation delegates one bound to the
55
+ * request's `{ method, path, operationId }`, so lines self-correlate.
56
+ */
57
+ log: Logger;
49
58
  }
@@ -106,8 +106,15 @@ export interface HostContext {
106
106
  * a host that cannot provide one leaves provenance capture disabled (absent-safe).
107
107
  */
108
108
  createAsyncStore?<T>(): AsyncStore<T>;
109
- /** Structured log sink. */
110
- log(level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>): void;
109
+ /**
110
+ * Structured log sink. Adds `debug` below `info`; the host adapter owns filtering + encoding.
111
+ *
112
+ * NOTE: widening this level union to include `"debug"` is a **breaking change** for any custom
113
+ * `HostContext` implementation whose `log` was typed with the narrower `"info" | "warn" | "error"`
114
+ * union — such a host must add a `"debug"` branch to keep satisfying the contract. See the
115
+ * "Structured logging" custom-host note in the runtime README.
116
+ */
117
+ log(level: "debug" | "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>): void;
111
118
  }
112
119
 
113
120
  /** A job handed to a worker handler, plus the ack/fail callbacks. `In` types the process