@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.
- package/README.md +37 -0
- package/dist/openapi/spec.d.ts +16 -1
- package/dist/openapi/spec.js +53 -12
- package/dist/runtime/adapters/deno.js +18 -2
- package/dist/runtime/adapters/node.js +17 -5
- package/dist/runtime/core/context.d.ts +10 -1
- package/dist/runtime/core/host.d.ts +9 -2
- package/dist/runtime/core/logger.d.ts +69 -0
- package/dist/runtime/core/logger.js +134 -0
- package/dist/runtime/core/modules/api.js +30 -16
- package/dist/runtime/core/modules/pages.js +52 -4
- package/dist/runtime/core/modules/workers.js +16 -1
- package/dist/runtime/core/runtime.d.ts +5 -0
- package/dist/runtime/core/runtime.js +7 -1
- package/dist/runtime/engine/nanosdk.d.ts +1 -1
- package/dist/runtime/index.d.ts +1 -0
- package/dist/toolkit/derivers/api.js +33 -7
- package/dist/toolkit/scaffold/operations.js +3 -1
- package/dist/toolkit/scaffold/workers.js +2 -0
- package/dist/worker.d.ts +1 -0
- package/package.json +2 -2
- package/src/openapi/spec.test.ts +47 -0
- package/src/openapi/spec.ts +62 -13
- package/src/runtime/adapters/deno.ts +17 -4
- package/src/runtime/adapters/node.ts +17 -7
- package/src/runtime/core/context.ts +10 -1
- package/src/runtime/core/host.ts +9 -2
- package/src/runtime/core/logger.test.ts +221 -0
- package/src/runtime/core/logger.ts +177 -0
- package/src/runtime/core/modules/actions.test.ts +2 -1
- package/src/runtime/core/modules/api.test.ts +119 -2
- package/src/runtime/core/modules/api.ts +33 -15
- package/src/runtime/core/modules/cancel.test.ts +4 -1
- package/src/runtime/core/modules/connectors.test.ts +2 -1
- package/src/runtime/core/modules/instance-tracking.test.ts +4 -1
- package/src/runtime/core/modules/pages.test.ts +31 -0
- package/src/runtime/core/modules/pages.ts +52 -4
- package/src/runtime/core/modules/surfaces.test.ts +2 -1
- package/src/runtime/core/modules/triggers.test.ts +4 -1
- package/src/runtime/core/modules/workers.test.ts +59 -2
- package/src/runtime/core/modules/workers.ts +15 -1
- package/src/runtime/core/runtime.test.ts +22 -0
- package/src/runtime/core/runtime.ts +12 -1
- package/src/runtime/devserver.test.ts +2 -0
- package/src/runtime/engine/nanosdk.ts +1 -1
- package/src/runtime/index.ts +2 -0
- package/src/toolkit/derivers/api.test.ts +20 -0
- package/src/toolkit/derivers/api.ts +35 -6
- package/src/toolkit/scaffold/operations.ts +3 -1
- package/src/toolkit/scaffold/workers.ts +2 -0
- package/src/worker.ts +1 -0
|
@@ -560,7 +560,36 @@ function applyTheme(msg) {
|
|
|
560
560
|
}
|
|
561
561
|
}
|
|
562
562
|
}
|
|
563
|
-
|
|
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
|
-
//
|
|
679
|
-
//
|
|
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", {
|
|
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:
|
|
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
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
70
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
69
|
+
"create-urban-app": "^0.13.0",
|
|
70
70
|
"yaml": "^2.9.0"
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
package/src/openapi/spec.test.ts
CHANGED
|
@@ -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.
|
package/src/openapi/spec.ts
CHANGED
|
@@ -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).
|
|
12
|
-
//
|
|
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
|
|
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
|
-
|
|
58
|
-
if (
|
|
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
|
|
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 (
|
|
681
|
-
issues.push({ path: at, message: `expected ${
|
|
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
|
-
|
|
712
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -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
|
-
/**
|
|
110
|
-
|
|
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
|