@nanobpm/urban 0.38.0 → 0.40.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.
Files changed (53) hide show
  1. package/README.md +37 -0
  2. package/dist/cli.js +6 -0
  3. package/dist/runtime/adapters/deno.js +18 -2
  4. package/dist/runtime/adapters/node.js +17 -5
  5. package/dist/runtime/core/context.d.ts +10 -1
  6. package/dist/runtime/core/host.d.ts +9 -2
  7. package/dist/runtime/core/logger.d.ts +69 -0
  8. package/dist/runtime/core/logger.js +134 -0
  9. package/dist/runtime/core/modules/api.js +7 -1
  10. package/dist/runtime/core/modules/pages.js +52 -4
  11. package/dist/runtime/core/modules/workers.js +16 -1
  12. package/dist/runtime/core/runtime.d.ts +5 -0
  13. package/dist/runtime/core/runtime.js +7 -1
  14. package/dist/runtime/engine/nanosdk.d.ts +1 -1
  15. package/dist/runtime/index.d.ts +1 -0
  16. package/dist/toolkit/artifact.d.ts +11 -0
  17. package/dist/toolkit/artifact.js +18 -0
  18. package/dist/toolkit/gen.d.ts +2 -0
  19. package/dist/toolkit/gen.js +24 -2
  20. package/dist/toolkit/scaffold/operations.js +3 -1
  21. package/dist/toolkit/scaffold/workers.js +2 -0
  22. package/dist/worker.d.ts +1 -0
  23. package/package.json +2 -2
  24. package/src/cli.ts +6 -0
  25. package/src/runtime/adapters/deno.ts +17 -4
  26. package/src/runtime/adapters/node.ts +17 -7
  27. package/src/runtime/core/context.ts +10 -1
  28. package/src/runtime/core/host.ts +9 -2
  29. package/src/runtime/core/logger.test.ts +221 -0
  30. package/src/runtime/core/logger.ts +177 -0
  31. package/src/runtime/core/modules/actions.test.ts +2 -1
  32. package/src/runtime/core/modules/api.test.ts +38 -2
  33. package/src/runtime/core/modules/api.ts +10 -1
  34. package/src/runtime/core/modules/cancel.test.ts +4 -1
  35. package/src/runtime/core/modules/connectors.test.ts +2 -1
  36. package/src/runtime/core/modules/instance-tracking.test.ts +4 -1
  37. package/src/runtime/core/modules/pages.test.ts +31 -0
  38. package/src/runtime/core/modules/pages.ts +52 -4
  39. package/src/runtime/core/modules/surfaces.test.ts +2 -1
  40. package/src/runtime/core/modules/triggers.test.ts +4 -1
  41. package/src/runtime/core/modules/workers.test.ts +59 -2
  42. package/src/runtime/core/modules/workers.ts +15 -1
  43. package/src/runtime/core/runtime.test.ts +22 -0
  44. package/src/runtime/core/runtime.ts +12 -1
  45. package/src/runtime/devserver.test.ts +2 -0
  46. package/src/runtime/engine/nanosdk.ts +1 -1
  47. package/src/runtime/index.ts +2 -0
  48. package/src/toolkit/artifact.ts +19 -0
  49. package/src/toolkit/gen.test.ts +53 -0
  50. package/src/toolkit/gen.ts +27 -2
  51. package/src/toolkit/scaffold/operations.ts +3 -1
  52. package/src/toolkit/scaffold/workers.ts +2 -0
  53. package/src/worker.ts +1 -0
@@ -2,7 +2,7 @@
2
2
  // models, runs the pure derivers, and either writes the artifacts to disk (`urban gen`) or
3
3
  // compares them against what's on disk and reports drift (`urban gen --check`). The derivers stay
4
4
  // pure; all IO is confined here behind a tiny FS port so the same code runs on Node and Deno.
5
- import { isAbsolutePath, sortArtifacts } from "./artifact.js";
5
+ import { GENERATED_DIR, isAbsolutePath, RUNTIME_MATERIALIZED_ARTIFACTS, sortArtifacts } from "./artifact.js";
6
6
  import { deriveMigrations } from "./derivers/migrations.js";
7
7
  import { deriveDomain } from "./derivers/domain.js";
8
8
  import { deriveWorkerBindings } from "./derivers/worker-io.js";
@@ -138,7 +138,29 @@ export async function runGen(opts) {
138
138
  await io.remove(join(root, rel));
139
139
  }
140
140
  }
141
- return { artifacts, drift, incomplete: derived.incomplete, modelErrors: derived.errors };
141
+ // Stale-artifact sweep of `nano-generated/` (clean break, nano-bpm#: gen owns its output dir).
142
+ // The dir is gitignored and fully codegen-owned, so any file this run did not (re)write is stale
143
+ // from a prior gen — a renamed or removed artifact (e.g. an old `data_sdk.ts`) whose orphan the
144
+ // app might still import. Remove it. The one exception is the runtime-materialized set: the SDK
145
+ // shims + the `urban data`/console dataops wrappers gen does NOT emit but the app needs at runtime
146
+ // (RUNTIME_MATERIALIZED_ARTIFACTS). Protect those by name. Write mode only; skipped when io.remove
147
+ // is absent (the sweep is best-effort, mirroring the `.bpmn` sweep above). NOT on `modelsOnly`
148
+ // (`urban derive`): that path derives only models, so `artifacts` carries no `nano-generated/*` and
149
+ // sweeping would wrongly wipe the type-contract outputs a prior `urban gen` wrote.
150
+ const swept = [];
151
+ if (!opts.check && !opts.modelsOnly && !derived.incomplete && io.remove) {
152
+ const keep = new Set(artifacts.map((a) => a.path).filter((p) => p.startsWith(`${GENERATED_DIR}/`)));
153
+ for (const name of RUNTIME_MATERIALIZED_ARTIFACTS)
154
+ keep.add(`${GENERATED_DIR}/${name}`);
155
+ for (const name of await io.listDir(join(root, GENERATED_DIR))) {
156
+ const rel = `${GENERATED_DIR}/${name}`;
157
+ if (keep.has(rel))
158
+ continue;
159
+ await io.remove(join(root, rel));
160
+ swept.push(rel);
161
+ }
162
+ }
163
+ return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors };
142
164
  }
143
165
  export { dirOf, join as joinPath };
144
166
  /** Derive code-first models in-memory (no writes) — backs `urban derive --stdout`, the
@@ -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.38.0",
3
+ "version": "0.40.0",
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.12.0",
70
70
  "yaml": "^2.9.0"
71
71
  },
72
72
  "devDependencies": {
package/src/cli.ts CHANGED
@@ -171,6 +171,12 @@ async function cmdGen(f: Flags): Promise<number> {
171
171
  console.log(`✔ generated ${res.artifacts.length} artifact(s):`);
172
172
  for (const a of res.artifacts) console.log(` • ${a.path}`);
173
173
 
174
+ // Stale generated files removed from nano-generated/ (renamed/removed artifacts from a prior gen).
175
+ if (res.swept.length > 0) {
176
+ console.log(`✔ removed ${res.swept.length} stale generated file(s):`);
177
+ for (const p of res.swept) console.log(` - ${p}`);
178
+ }
179
+
174
180
  // Write-once handler stubs scaffolded as part of generation (workers from the model, operation
175
181
  // delegates from the OpenAPI spec). Existing, human-owned stubs are kept verbatim.
176
182
  const created = [
@@ -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
@@ -0,0 +1,221 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ createLogger,
5
+ formatLogLine,
6
+ formatLogRecord,
7
+ levelEnabled,
8
+ LOG_LEVELS,
9
+ parseLogLevel,
10
+ type LogFields,
11
+ type LogLevel,
12
+ } from "./logger.ts";
13
+
14
+ type Rec = { level: LogLevel; msg: string; fields?: LogFields };
15
+
16
+ function recorder() {
17
+ const records: Rec[] = [];
18
+ const sink = (level: LogLevel, msg: string, fields?: LogFields) => {
19
+ records.push({ level, msg, fields });
20
+ };
21
+ return { records, sink };
22
+ }
23
+
24
+ test("per-level methods forward the level and message to the sink", () => {
25
+ const { records, sink } = recorder();
26
+ const log = createLogger(sink);
27
+ log.debug("d");
28
+ log.info("i");
29
+ log.warn("w");
30
+ log.error("e");
31
+ assert.deepEqual(
32
+ records.map((r) => [r.level, r.msg]),
33
+ [
34
+ ["debug", "d"],
35
+ ["info", "i"],
36
+ ["warn", "w"],
37
+ ["error", "e"],
38
+ ],
39
+ );
40
+ });
41
+
42
+ test("the callable form still works for back-compat", () => {
43
+ const { records, sink } = recorder();
44
+ const log = createLogger(sink);
45
+ log("info", "hello", { a: 1 });
46
+ assert.equal(records.length, 1);
47
+ assert.equal(records[0].level, "info");
48
+ assert.equal(records[0].msg, "hello");
49
+ assert.deepEqual({ ...records[0].fields }, { a: 1 });
50
+ });
51
+
52
+ test("a context-free line carries no fields object", () => {
53
+ const { records, sink } = recorder();
54
+ createLogger(sink).info("plain");
55
+ assert.deepEqual(records, [{ level: "info", msg: "plain", fields: undefined }]);
56
+ });
57
+
58
+ test("child() bindings are merged into every record", () => {
59
+ const { records, sink } = recorder();
60
+ const log = createLogger(sink).child({ jobType: "charge", jobKey: "7" });
61
+ log.info("started");
62
+ log.warn("retrying", { attempt: 2 });
63
+ assert.deepEqual({ ...records[0].fields }, { jobType: "charge", jobKey: "7" });
64
+ assert.deepEqual({ ...records[1].fields }, { jobType: "charge", jobKey: "7", attempt: 2 });
65
+ });
66
+
67
+ test("per-call fields win over bound context on key clash", () => {
68
+ const { records, sink } = recorder();
69
+ createLogger(sink).child({ attempt: 1 }).info("x", { attempt: 9 });
70
+ assert.deepEqual({ ...records[0].fields }, { attempt: 9 });
71
+ });
72
+
73
+ test("child() accumulates bindings down a chain and does not mutate the parent", () => {
74
+ const { records, sink } = recorder();
75
+ const base = createLogger(sink).child({ app: "demo" });
76
+ const child = base.child({ jobKey: "1" });
77
+ base.info("base");
78
+ child.info("child");
79
+ assert.deepEqual({ ...records[0].fields }, { app: "demo" });
80
+ assert.deepEqual({ ...records[1].fields }, { app: "demo", jobKey: "1" });
81
+ });
82
+
83
+ test("parseLogLevel is case-insensitive and falls back for unknown/absent input", () => {
84
+ assert.equal(parseLogLevel("DEBUG"), "debug");
85
+ assert.equal(parseLogLevel(" Warn "), "warn");
86
+ assert.equal(parseLogLevel(undefined), "info");
87
+ assert.equal(parseLogLevel("verbose"), "info");
88
+ assert.equal(parseLogLevel("verbose", "debug"), "debug");
89
+ });
90
+
91
+ test("levelEnabled gates records below the configured minimum", () => {
92
+ assert.equal(levelEnabled("debug", "info"), false);
93
+ assert.equal(levelEnabled("info", "info"), true);
94
+ assert.equal(levelEnabled("error", "warn"), true);
95
+ assert.equal(levelEnabled("warn", "error"), false);
96
+ });
97
+
98
+ test("LOG_LEVELS is ordered from most to least verbose", () => {
99
+ assert.deepEqual([...LOG_LEVELS], ["debug", "info", "warn", "error"]);
100
+ });
101
+
102
+ test("formatLogRecord emits ts/level/msg plus flattened fields", () => {
103
+ const rec = JSON.parse(formatLogRecord("info", "charged", { amount: 10, currency: "EUR" }, 1234));
104
+ assert.deepEqual(rec, { ts: 1234, level: "info", msg: "charged", amount: 10, currency: "EUR" });
105
+ });
106
+
107
+ test("formatLogLine appends exactly one trailing newline", () => {
108
+ const line = formatLogLine("warn", "x", undefined, 1);
109
+ assert.ok(line.endsWith("\n"));
110
+ assert.equal(line.indexOf("\n"), line.length - 1);
111
+ assert.deepEqual(JSON.parse(line), { ts: 1, level: "warn", msg: "x" });
112
+ });
113
+
114
+ test("formatLogRecord reserves ts/level/msg — a same-named field cannot shadow them", () => {
115
+ const rec = JSON.parse(formatLogRecord("error", "real", { ts: 0, level: "info", msg: "fake", ok: true }, 999));
116
+ assert.deepEqual(rec, { ts: 999, level: "error", msg: "real", ok: true });
117
+ });
118
+
119
+ test("formatLogRecord serializes a bigint field as its string form", () => {
120
+ const rec = JSON.parse(formatLogRecord("info", "job", { jobKey: 9007199254740993n }, 5));
121
+ assert.equal(rec.jobKey, "9007199254740993");
122
+ });
123
+
124
+ test("formatLogRecord never throws on an unserializable (circular) field", () => {
125
+ const circular: Record<string, unknown> = {};
126
+ circular.self = circular;
127
+ const rec = JSON.parse(formatLogRecord("info", "loop", { circular }, 7));
128
+ assert.deepEqual(rec, { ts: 7, level: "info", msg: "loop", fieldsError: "unserializable log fields" });
129
+ });
130
+
131
+ test("formatLogRecord never throws when enumerating a field throws (getter/proxy trap)", () => {
132
+ const hostile = new Proxy(
133
+ {},
134
+ {
135
+ ownKeys() {
136
+ throw new Error("trap");
137
+ },
138
+ },
139
+ );
140
+ const rec = JSON.parse(formatLogRecord("warn", "boom", hostile, 8));
141
+ assert.deepEqual(rec, { ts: 8, level: "warn", msg: "boom", fieldsError: "unserializable log fields" });
142
+ });
143
+
144
+ test("formatLogRecord writes an untrusted __proto__ field key as a plain own property", () => {
145
+ // JSON.parse builds a genuine own "__proto__" property (unlike the `{ __proto__: … }` literal,
146
+ // which sets the prototype) — the untrusted-key case the null-prototype record must survive.
147
+ const fields: LogFields = JSON.parse('{"__proto__":{"polluted":true}}');
148
+ const rec = JSON.parse(formatLogRecord("info", "hardened", fields, 3));
149
+ assert.equal(rec.ts, 3);
150
+ assert.equal(rec.msg, "hardened");
151
+ assert.deepEqual(Object.getOwnPropertyDescriptor(rec, "__proto__")?.value, { polluted: true });
152
+ });
153
+
154
+ test("child() with an untrusted __proto__ binding key does not pollute Object.prototype", () => {
155
+ const { records, sink } = recorder();
156
+ const bindings: LogFields = JSON.parse('{"__proto__":{"polluted":true}}');
157
+ createLogger(sink).child(bindings).info("x");
158
+ const fields = records[0].fields ?? {};
159
+ assert.deepEqual(Object.getOwnPropertyDescriptor(fields, "__proto__")?.value, { polluted: true });
160
+ });
161
+
162
+ test("an untrusted __proto__ per-call field with no bound context still merges into a null-proto bag", () => {
163
+ // The no-bindings merge path must normalize too, so a custom sink that later spreads the bag can't
164
+ // be tripped by the magic prototype setter. JSON.parse builds a genuine own "__proto__" key.
165
+ const { records, sink } = recorder();
166
+ const fields: LogFields = JSON.parse('{"__proto__":{"polluted":true}}');
167
+ createLogger(sink).info("x", fields);
168
+ const merged = records[0].fields;
169
+ assert.ok(merged !== undefined);
170
+ assert.equal(Object.getPrototypeOf(merged), null);
171
+ assert.deepEqual(Object.getOwnPropertyDescriptor(merged, "__proto__")?.value, { polluted: true });
172
+ });
173
+
174
+ test("a log call never throws when a hostile fields object defeats the merge", () => {
175
+ // A Proxy whose ownKeys trap throws breaks mergeFields before it can reach the sink; the emit guard
176
+ // must degrade to a field-free error line rather than propagate into the caller.
177
+ const { records, sink } = recorder();
178
+ const hostile = new Proxy(
179
+ {},
180
+ {
181
+ ownKeys() {
182
+ throw new Error("trap");
183
+ },
184
+ },
185
+ );
186
+ assert.doesNotThrow(() => createLogger(sink).info("boom", hostile));
187
+ assert.equal(records.length, 1);
188
+ assert.equal(records[0].msg, "boom");
189
+ assert.deepEqual({ ...records[0].fields }, { fieldsError: "unmergeable log fields" });
190
+ });
191
+
192
+ test("a log call never throws when the sink itself throws", () => {
193
+ const throwingSink = () => {
194
+ throw new Error("sink down");
195
+ };
196
+ assert.doesNotThrow(() => createLogger(throwingSink).info("x", { a: 1 }));
197
+ });
198
+
199
+ test("child() never throws when the added bindings are hostile — degrades to the parent context", () => {
200
+ // A Proxy whose ownKeys trap throws would break the Object.assign merge in child(); like emit(),
201
+ // child() must not propagate into the caller. It degrades to a child bound to just the parent
202
+ // context, and that child still logs normally with its inherited bindings.
203
+ const { records, sink } = recorder();
204
+ const hostile = new Proxy(
205
+ {},
206
+ {
207
+ ownKeys() {
208
+ throw new Error("trap");
209
+ },
210
+ },
211
+ );
212
+ const parent = createLogger(sink).child({ orderId: "o1" });
213
+ let child: ReturnType<typeof parent.child> | undefined;
214
+ assert.doesNotThrow(() => {
215
+ child = parent.child(hostile);
216
+ });
217
+ child?.info("shipped");
218
+ assert.equal(records.length, 1);
219
+ assert.equal(records[0].msg, "shipped");
220
+ assert.deepEqual({ ...records[0].fields }, { orderId: "o1" });
221
+ });
@@ -0,0 +1,177 @@
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
+
20
+ /** Severity of a log record, from most to least verbose. */
21
+ export type LogLevel = "debug" | "info" | "warn" | "error";
22
+
23
+ /** A structured context bag attached to a log record. Values should be JSON-serializable. */
24
+ export type LogFields = Record<string, unknown>;
25
+
26
+ /**
27
+ * The low-level output seam a {@link Logger} writes to. The host adapter supplies it and owns
28
+ * encoding (NDJSON), stream routing (warn/error → stderr, else stdout) and level filtering. `fields`
29
+ * is the merged bag (a logger's bound context overlaid with the per-call fields).
30
+ */
31
+ export type LogSink = (level: LogLevel, msg: string, fields?: LogFields) => void;
32
+
33
+ /**
34
+ * The logger surface injected as `AppApi.log`. Callable for back-compat (`log(level, msg, fields)`),
35
+ * with per-level methods and `child()` for bound context. Prefer the methods:
36
+ *
37
+ * app.log.info("charge captured", { amount, currency });
38
+ * const jobLog = app.log.child({ orderId }); // every line below carries orderId
39
+ * jobLog.warn("retrying", { attempt });
40
+ */
41
+ export interface Logger {
42
+ /** Back-compat callable form. Prefer `log.info(...)` etc. */
43
+ (level: LogLevel, msg: string, fields?: LogFields): void;
44
+ debug(msg: string, fields?: LogFields): void;
45
+ info(msg: string, fields?: LogFields): void;
46
+ warn(msg: string, fields?: LogFields): void;
47
+ error(msg: string, fields?: LogFields): void;
48
+ /** A logger that merges `bindings` into every record it emits (per-call fields win on key clash). */
49
+ child(bindings: LogFields): Logger;
50
+ }
51
+
52
+ /** Levels ordered by increasing severity; the index is the numeric threshold used for filtering. */
53
+ export const LOG_LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error"];
54
+
55
+ function isLogLevel(v: unknown): v is LogLevel {
56
+ return v === "debug" || v === "info" || v === "warn" || v === "error";
57
+ }
58
+
59
+ /**
60
+ * Parse a min-level string (e.g. from `URBAN_LOG_LEVEL`) into a {@link LogLevel}, case-insensitively,
61
+ * falling back to `fallback` (default `"info"`) for an absent or unrecognized value.
62
+ */
63
+ export function parseLogLevel(value: string | undefined, fallback: LogLevel = "info"): LogLevel {
64
+ const v = value?.trim().toLowerCase();
65
+ return isLogLevel(v) ? v : fallback;
66
+ }
67
+
68
+ /** True when a record at `level` should be emitted given the configured `min` level. */
69
+ export function levelEnabled(level: LogLevel, min: LogLevel): boolean {
70
+ return LOG_LEVELS.indexOf(level) >= LOG_LEVELS.indexOf(min);
71
+ }
72
+
73
+ /** Merge bound context with per-call fields (per-call wins), omitting the `fields` key entirely when
74
+ * the result is empty so a context-free line stays `{ level, msg }` clean. Always merges into a
75
+ * null-prototype object — even on the no-bindings fast path — and returns `undefined` for an empty
76
+ * bag: log field keys are user-controlled, so an untrusted key like `__proto__` must set a plain own
77
+ * property rather than trip the magic prototype setter a normal `{}` inherits from `Object.prototype`
78
+ * (same treatment as untrusted-key dictionaries elsewhere in this repo). Normalizing here — not just
79
+ * in {@link formatLogRecord} — keeps the merged bag safe for any custom {@link LogSink} that later
80
+ * spreads it into a plain object. */
81
+ function mergeFields(bindings: LogFields, fields?: LogFields): LogFields | undefined {
82
+ const boundKeys = Object.keys(bindings);
83
+ const callKeys = fields ? Object.keys(fields) : [];
84
+ if (boundKeys.length === 0 && callKeys.length === 0) return undefined;
85
+ if (boundKeys.length === 0) return Object.assign(Object.create(null), fields);
86
+ if (callKeys.length === 0) return Object.assign(Object.create(null), bindings);
87
+ return Object.assign(Object.create(null), bindings, fields);
88
+ }
89
+
90
+ /** A single structured log record, as serialized to NDJSON. Reserved keys (`ts`, `level`, `msg`)
91
+ * lead; the flattened `fields` follow. A field named `ts`/`level`/`msg` cannot shadow a reserved
92
+ * key (the reserved value wins), so correlation on those keys stays reliable. */
93
+ export interface LogRecord {
94
+ /** Epoch milliseconds when the line was emitted. */
95
+ ts: number;
96
+ level: LogLevel;
97
+ msg: string;
98
+ [field: string]: unknown;
99
+ }
100
+
101
+ /**
102
+ * Encode one structured log record as a single-line JSON string (no trailing newline). The reserved
103
+ * `ts`/`level`/`msg` keys are written first and cannot be overwritten by a same-named field. Values
104
+ * that don't survive `JSON.stringify` (a `bigint`, a circular object) are coerced so a bad field
105
+ * never throws in the logging hot path or corrupts the stream. The whole build+encode path is
106
+ * guarded (not just `JSON.stringify`), so a field whose enumeration itself throws (e.g. a getter or
107
+ * Proxy trap) still yields a clean fallback line. The record is a null-prototype object so an
108
+ * untrusted field key like `__proto__` sets a plain own property rather than tripping the magic
109
+ * prototype setter.
110
+ */
111
+ export function formatLogRecord(level: LogLevel, msg: string, fields: LogFields | undefined, now: number): string {
112
+ try {
113
+ const record: LogRecord = Object.create(null);
114
+ record.ts = now;
115
+ record.level = level;
116
+ record.msg = msg;
117
+ if (fields) {
118
+ for (const [k, v] of Object.entries(fields)) {
119
+ if (k === "ts" || k === "level" || k === "msg") continue;
120
+ record[k] = v;
121
+ }
122
+ }
123
+ return JSON.stringify(record, (_key, value) => (typeof value === "bigint" ? value.toString() : value));
124
+ } catch {
125
+ // A field that can't be built or serialized (circular ref, throwing getter) must not break logging.
126
+ return JSON.stringify({ ts: now, level, msg, fieldsError: "unserializable log fields" });
127
+ }
128
+ }
129
+
130
+ /** {@link formatLogRecord} plus a trailing newline — one NDJSON line for a raw stream write. */
131
+ export function formatLogLine(level: LogLevel, msg: string, fields: LogFields | undefined, now: number): string {
132
+ return `${formatLogRecord(level, msg, fields, now)}\n`;
133
+ }
134
+
135
+ /**
136
+ * Build a {@link Logger} over `sink`, optionally pre-bound to `bindings`. The returned value is a
137
+ * callable object: it forwards `log(level, msg, fields)` to the sink and exposes `debug/info/warn/
138
+ * error` plus `child()`. `bindings` is normalized into a null-prototype object (log field keys are
139
+ * user-controlled, so an untrusted key like `__proto__` must not trip the magic prototype setter),
140
+ * and `child(more)` returns a new logger whose bindings are `bindings` merged with `more` into a
141
+ * fresh null-prototype object, so bound context accumulates safely down a call chain.
142
+ */
143
+ export function createLogger(sink: LogSink, bindings: LogFields = {}): Logger {
144
+ const bound: LogFields = Object.assign(Object.create(null), bindings);
145
+ const emit = (level: LogLevel, msg: string, fields?: LogFields): void => {
146
+ try {
147
+ sink(level, msg, mergeFields(bound, fields));
148
+ } catch {
149
+ // A log call must never throw into its caller. A hostile `fields` object (e.g. a Proxy whose
150
+ // `ownKeys` trap throws, defeating mergeFields before the sink's own formatLogRecord guard)
151
+ // or a throwing sink degrades to a best-effort, field-free line rather than break the handler.
152
+ try {
153
+ sink(level, msg, Object.assign(Object.create(null), { fieldsError: "unmergeable log fields" }));
154
+ } catch {
155
+ // The sink itself is broken — nothing more we can safely do without re-throwing.
156
+ }
157
+ }
158
+ };
159
+ return Object.assign(emit, {
160
+ debug: (msg: string, fields?: LogFields) => emit("debug", msg, fields),
161
+ info: (msg: string, fields?: LogFields) => emit("info", msg, fields),
162
+ warn: (msg: string, fields?: LogFields) => emit("warn", msg, fields),
163
+ error: (msg: string, fields?: LogFields) => emit("error", msg, fields),
164
+ child: (more: LogFields) => {
165
+ // child() must not throw even when `more` is hostile (e.g. a Proxy whose key enumeration
166
+ // throws), mirroring emit()'s "logging never throws" guarantee: degrade to a child bound to
167
+ // just the parent context rather than let the merge bubble into the worker/route handler.
168
+ let childBindings: LogFields = bound;
169
+ try {
170
+ childBindings = Object.assign(Object.create(null), bound, more);
171
+ } catch {
172
+ // `more` could not be enumerated/merged — keep the parent bindings, drop the bad additions.
173
+ }
174
+ return createLogger(sink, childBindings);
175
+ },
176
+ });
177
+ }
@@ -1,4 +1,5 @@
1
1
  import { test } from "node:test";
2
+ import { createLogger } from "../logger.ts";
2
3
  import assert from "node:assert/strict";
3
4
  import type { AppApi, RuntimeContext } from "../context.ts";
4
5
  import type { EngineClient, HostContext, HttpRequest } from "../host.ts";
@@ -42,7 +43,7 @@ function appFixture(over: Partial<AppApi> = {}): AppApi {
42
43
  data,
43
44
  engine,
44
45
  env: () => undefined,
45
- log: () => {},
46
+ log: createLogger(() => {}),
46
47
  ...over,
47
48
  };
48
49
  }