@nanobpm/urban 0.20.0 → 0.22.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 (97) hide show
  1. package/README.md +35 -0
  2. package/dist/cli.d.ts +26 -0
  3. package/dist/cli.js +80 -16
  4. package/dist/runtime/adapters/deno.js +19 -3
  5. package/dist/runtime/adapters/detect.js +2 -2
  6. package/dist/runtime/adapters/globals.d.ts +19 -0
  7. package/dist/runtime/adapters/globals.js +9 -0
  8. package/dist/runtime/adapters/node.js +25 -5
  9. package/dist/runtime/core/context.d.ts +4 -0
  10. package/dist/runtime/core/guards.d.ts +2 -0
  11. package/dist/runtime/core/guards.js +10 -0
  12. package/dist/runtime/core/host.js +5 -4
  13. package/dist/runtime/core/manifest.d.ts +8 -0
  14. package/dist/runtime/core/manifest.js +0 -1
  15. package/dist/runtime/core/modules/actions.js +9 -5
  16. package/dist/runtime/core/modules/connectors.js +6 -4
  17. package/dist/runtime/core/modules/dataops.d.ts +36 -0
  18. package/dist/runtime/core/modules/dataops.js +231 -0
  19. package/dist/runtime/core/modules/datasource.d.ts +51 -1
  20. package/dist/runtime/core/modules/datasource.js +98 -21
  21. package/dist/runtime/core/modules/deploy.d.ts +3 -1
  22. package/dist/runtime/core/modules/deploy.js +29 -7
  23. package/dist/runtime/core/modules/gateway.d.ts +1 -1
  24. package/dist/runtime/core/modules/gateway.js +16 -17
  25. package/dist/runtime/core/modules/llm.js +9 -7
  26. package/dist/runtime/core/modules/pages.js +86 -20
  27. package/dist/runtime/core/modules/security.js +5 -5
  28. package/dist/runtime/core/modules/templates.d.ts +18 -0
  29. package/dist/runtime/core/modules/templates.js +133 -0
  30. package/dist/runtime/core/modules/triggers.js +14 -5
  31. package/dist/runtime/core/modules/workers.js +15 -9
  32. package/dist/runtime/core/runtime.d.ts +5 -0
  33. package/dist/runtime/core/runtime.js +5 -5
  34. package/dist/runtime/core/validate.d.ts +2 -1
  35. package/dist/runtime/core/validate.js +38 -28
  36. package/dist/runtime/devserver.js +2 -1
  37. package/dist/runtime/engine/nanosdk.d.ts +1 -1
  38. package/dist/runtime/engine/nanosdk.js +15 -9
  39. package/dist/runtime/index.d.ts +5 -0
  40. package/dist/runtime/index.js +4 -0
  41. package/dist/runtime/run.js +10 -7
  42. package/dist/toolkit/addConnector.js +4 -3
  43. package/dist/toolkit/fsio.js +2 -1
  44. package/dist/toolkit/models.js +3 -5
  45. package/package.json +1 -1
  46. package/src/cli.test.ts +44 -0
  47. package/src/cli.ts +82 -16
  48. package/src/runtime/adapters/deno.ts +23 -3
  49. package/src/runtime/adapters/detect.ts +2 -2
  50. package/src/runtime/adapters/globals.ts +27 -0
  51. package/src/runtime/adapters/node.ts +27 -4
  52. package/src/runtime/connector-worker-sdk.test.ts +2 -1
  53. package/src/runtime/core/context.ts +4 -0
  54. package/src/runtime/core/guards.ts +10 -0
  55. package/src/runtime/core/host.ts +4 -4
  56. package/src/runtime/core/manifest.ts +19 -5
  57. package/src/runtime/core/modules/actions.test.ts +82 -31
  58. package/src/runtime/core/modules/actions.ts +11 -7
  59. package/src/runtime/core/modules/connectors.test.ts +29 -8
  60. package/src/runtime/core/modules/connectors.ts +6 -4
  61. package/src/runtime/core/modules/dataops.test.ts +306 -0
  62. package/src/runtime/core/modules/dataops.ts +292 -0
  63. package/src/runtime/core/modules/datasource.migrations.test.ts +12 -3
  64. package/src/runtime/core/modules/datasource.test.ts +129 -0
  65. package/src/runtime/core/modules/datasource.ts +105 -23
  66. package/src/runtime/core/modules/deploy.test.ts +258 -0
  67. package/src/runtime/core/modules/deploy.ts +29 -7
  68. package/src/runtime/core/modules/gateway.test.ts +5 -4
  69. package/src/runtime/core/modules/gateway.ts +29 -30
  70. package/src/runtime/core/modules/llm.test.ts +10 -12
  71. package/src/runtime/core/modules/llm.ts +11 -9
  72. package/src/runtime/core/modules/pages.test.ts +38 -2
  73. package/src/runtime/core/modules/pages.ts +89 -27
  74. package/src/runtime/core/modules/security.ts +5 -13
  75. package/src/runtime/core/modules/surfaces.test.ts +53 -10
  76. package/src/runtime/core/modules/templates.ts +153 -0
  77. package/src/runtime/core/modules/triggers.test.ts +51 -19
  78. package/src/runtime/core/modules/triggers.ts +14 -6
  79. package/src/runtime/core/modules/workers.test.ts +43 -20
  80. package/src/runtime/core/modules/workers.ts +18 -10
  81. package/src/runtime/core/runtime.test.ts +23 -10
  82. package/src/runtime/core/runtime.ts +15 -2
  83. package/src/runtime/core/validate.ts +43 -32
  84. package/src/runtime/devserver.test.ts +18 -5
  85. package/src/runtime/devserver.ts +2 -1
  86. package/src/runtime/engine/nanosdk.test.ts +16 -11
  87. package/src/runtime/engine/nanosdk.ts +22 -15
  88. package/src/runtime/index.ts +7 -0
  89. package/src/runtime/run.ts +10 -10
  90. package/src/toolkit/addConnector.ts +6 -5
  91. package/src/toolkit/derivers/messages.test.ts +4 -2
  92. package/src/toolkit/derivers/worker-io.test.ts +4 -2
  93. package/src/toolkit/fsio.ts +2 -1
  94. package/src/toolkit/gen.ts +4 -4
  95. package/src/toolkit/models.test.ts +1 -1
  96. package/src/toolkit/models.ts +3 -4
  97. package/src/toolkit/scaffold.ts +1 -1
@@ -8,7 +8,7 @@
8
8
  // schema JSON means the envelope check tracks the published schema and can't drift from it
9
9
  // silently — the whole point of consuming the package rather than vendoring a copy.
10
10
  import schema from "@nanobpm/nano-app-schema/schema" with { type: "json" };
11
- import { workerJobType } from "./manifest.js";
11
+ import { isRecord } from "./guards.js";
12
12
  export class ManifestValidationError extends Error {
13
13
  issues;
14
14
  constructor(issues) {
@@ -22,7 +22,7 @@ const S = schema;
22
22
  /** Validate a parsed manifest. Returns the list of issues (empty === valid). */
23
23
  export function collectManifestIssues(m) {
24
24
  const issues = [];
25
- if (m === null || typeof m !== "object" || Array.isArray(m)) {
25
+ if (!isRecord(m)) {
26
26
  return [{ path: "$", message: "manifest must be a JSON object" }];
27
27
  }
28
28
  const obj = m;
@@ -50,10 +50,11 @@ export function collectManifestIssues(m) {
50
50
  issues.push({ path: "name", message: "must be a non-empty string" });
51
51
  }
52
52
  // — Runtime binding rules —
53
- const man = obj;
54
- if (Array.isArray(man.workers)) {
55
- man.workers.forEach((w, i) => {
56
- if (!workerJobType(w)) {
53
+ const workers = Array.isArray(obj.workers) ? obj.workers : undefined;
54
+ if (workers) {
55
+ workers.forEach((w, i) => {
56
+ const worker = isRecord(w) ? w : undefined;
57
+ if (typeof worker?.taskType !== "string" || worker.taskType.length === 0) {
57
58
  issues.push({ path: `workers[${i}]`, message: "missing taskType" });
58
59
  }
59
60
  // A worker is backed by exactly one of: a `handler` file, an `llm` binding,
@@ -62,11 +63,11 @@ export function collectManifestIssues(m) {
62
63
  // `connector` actually resolves to an installed pack is a runtime seam check
63
64
  // (mountConnectors), not something this static manifest check can see.
64
65
  const backings = [];
65
- if (typeof w?.handler === "string" && w.handler.length > 0)
66
+ if (typeof worker?.handler === "string" && worker.handler.length > 0)
66
67
  backings.push("handler");
67
- if (typeof w?.llm === "string" && w.llm.length > 0)
68
+ if (typeof worker?.llm === "string" && worker.llm.length > 0)
68
69
  backings.push("llm");
69
- if (typeof w?.connector === "string" && w.connector.length > 0)
70
+ if (typeof worker?.connector === "string" && worker.connector.length > 0)
70
71
  backings.push("connector");
71
72
  if (backings.length === 0) {
72
73
  issues.push({
@@ -81,34 +82,38 @@ export function collectManifestIssues(m) {
81
82
  });
82
83
  }
83
84
  // A `connection` must reference a declared top-level `connections` entry.
84
- if (typeof w?.connection === "string" && w.connection.length > 0) {
85
- const conns = man.connections && typeof man.connections === "object" && !Array.isArray(man.connections)
86
- ? man.connections
85
+ if (typeof worker?.connection === "string" && worker.connection.length > 0) {
86
+ const conns = isRecord(obj.connections)
87
+ ? obj.connections
87
88
  : undefined;
88
- if (!conns || !(w.connection in conns)) {
89
+ if (!conns || !(worker.connection in conns)) {
89
90
  issues.push({
90
91
  path: `workers[${i}].connection`,
91
- message: `no such connection "${w.connection}" (add it to connections)`,
92
+ message: `no such connection "${worker.connection}" (add it to connections)`,
92
93
  });
93
94
  }
94
95
  }
95
96
  });
96
97
  }
97
- if (man.data?.sources) {
98
- for (const [name, src] of Object.entries(man.data.sources)) {
99
- if (!src || typeof src.driver !== "string") {
98
+ const data = isRecord(obj.data) ? obj.data : undefined;
99
+ const sources = isRecord(data?.sources) ? data.sources : undefined;
100
+ if (sources) {
101
+ for (const [name, src] of Object.entries(sources)) {
102
+ const source = isRecord(src) ? src : undefined;
103
+ if (!source || typeof source.driver !== "string") {
100
104
  issues.push({ path: `data.sources.${name}.driver`, message: "missing driver" });
101
105
  }
102
- if (!src || typeof src.url !== "string") {
106
+ if (!source || typeof source.url !== "string") {
103
107
  issues.push({ path: `data.sources.${name}.url`, message: "missing url" });
104
108
  }
105
109
  }
106
- if (man.data.default && !man.data.sources[man.data.default]) {
107
- issues.push({ path: "data.default", message: `no such source "${man.data.default}"` });
110
+ if (typeof data?.default === "string" && !sources[data.default]) {
111
+ issues.push({ path: "data.default", message: `no such source "${data.default}"` });
108
112
  }
109
113
  }
110
- if (man.types) {
111
- for (const [name, t] of Object.entries(man.types)) {
114
+ const types = isRecord(obj.types) ? obj.types : undefined;
115
+ if (types) {
116
+ for (const [name, t] of Object.entries(types)) {
112
117
  // `table` is optional in the schema: a type may declare `fields` without a
113
118
  // `table` (a transient / non-persisted domain type). Don't require it.
114
119
  if (t && typeof t !== "object") {
@@ -116,20 +121,25 @@ export function collectManifestIssues(m) {
116
121
  }
117
122
  }
118
123
  }
119
- if (Array.isArray(man.triggers)) {
120
- man.triggers.forEach((t, i) => {
121
- if (!t?.id)
124
+ const triggers = Array.isArray(obj.triggers) ? obj.triggers : undefined;
125
+ if (triggers) {
126
+ triggers.forEach((t, i) => {
127
+ const trigger = isRecord(t) ? t : undefined;
128
+ if (!trigger?.id)
122
129
  issues.push({ path: `triggers[${i}].id`, message: "missing id" });
123
- if (!t?.type)
130
+ if (!trigger?.type)
124
131
  issues.push({ path: `triggers[${i}].type`, message: "missing type" });
125
132
  });
126
133
  }
127
134
  return issues;
128
135
  }
129
- /** Throw ManifestValidationError if the manifest is invalid; otherwise return it typed. */
130
- export function validateManifest(m) {
136
+ export function assertValidManifest(m) {
131
137
  const issues = collectManifestIssues(m);
132
138
  if (issues.length > 0)
133
139
  throw new ManifestValidationError(issues);
140
+ }
141
+ /** Throw ManifestValidationError if the manifest is invalid; otherwise return it typed. */
142
+ export function validateManifest(m) {
143
+ assertValidManifest(m);
134
144
  return m;
135
145
  }
@@ -12,6 +12,7 @@
12
12
  // live-reload can layer on later behind this same seam.
13
13
  import { runFromEnv } from "./run.js";
14
14
  import { selectHost } from "./adapters/detect.js";
15
+ import { errorMessage } from "./core/guards.js";
15
16
  import { createNodeGenIO, runGen } from "../toolkit/index.js";
16
17
  const SOURCE_EXT = [".bpmn", ".dmn", ".form", ".ts", ".js", ".mjs", ".sql"];
17
18
  // Directories whose churn must never trigger a reload: generated output (gen writes here —
@@ -109,7 +110,7 @@ export async function runDev(opts = {}, deps) {
109
110
  }
110
111
  catch (err) {
111
112
  // Keep the dev server alive on a bad edit; the next save will retry.
112
- log(`✖ reload failed: ${String(err?.message ?? err)}`);
113
+ log(`✖ reload failed: ${errorMessage(err)}`);
113
114
  }
114
115
  finally {
115
116
  reloading = false;
@@ -6,7 +6,7 @@ export declare function requireProcessInstanceKey(key: string | number | null |
6
6
  /** A job as delivered to a nano-sdk job handler: the frame fields plus the
7
7
  * acknowledgement actions the handler must call. */
8
8
  export interface NanoSdkActivatedJob {
9
- jobKey: string;
9
+ jobKey: string | number;
10
10
  type?: string;
11
11
  processInstanceKey?: string | number;
12
12
  elementId?: string;
@@ -19,6 +19,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
19
19
  return path;
20
20
  };
21
21
  import { isBpmnError } from "../core/host.js";
22
+ import { isRecord } from "../core/guards.js";
22
23
  /** Coerce an engine response's process-instance key to a non-empty string, or
23
24
  * throw — a missing key means a malformed/partial response, not a real instance. */
24
25
  export function requireProcessInstanceKey(key) {
@@ -47,6 +48,7 @@ export class SdkEngineClient {
47
48
  * the same connection. Surfaced to handlers as `AppApi.sdk`.
48
49
  */
49
50
  get sdk() {
51
+ // biome-ignore lint/plugin: `this.client` is the same `@nanobpm/nano-sdk` client at runtime; exposing its full EngineSdkClient surface is the adapter boundary (see EngineSdkClient in ./sdk.ts).
50
52
  return this.client;
51
53
  }
52
54
  async deployResources(resources) {
@@ -60,11 +62,12 @@ export class SdkEngineClient {
60
62
  variables: input.variables ?? {},
61
63
  awaitCompletion: input.awaitCompletion ?? false,
62
64
  });
63
- const key = body.processInstanceKey ??
64
- body.key;
65
+ const rawKey = body.processInstanceKey ?? body.key;
66
+ const key = typeof rawKey === "string" || typeof rawKey === "number" ? rawKey : undefined;
67
+ const variables = isRecord(body.variables) ? body.variables : undefined;
65
68
  return {
66
69
  processInstanceKey: requireProcessInstanceKey(key),
67
- variables: body.variables ?? undefined,
70
+ variables,
68
71
  };
69
72
  }
70
73
  async cancelInstance(input) {
@@ -80,8 +83,8 @@ export class SdkEngineClient {
80
83
  async searchUserTasks(filter) {
81
84
  // User tasks are an eventually consistent read; ask for zero-wait consistency so
82
85
  // the search reflects what is currently visible without blocking.
83
- const body = await this.client.searchUserTasks({ filter: (filter ?? {}) }, { consistency: { waitUpToMs: 0 } });
84
- const items = body.items ?? [];
86
+ const body = await this.client.searchUserTasks({ filter: { ...(filter ?? {}) } }, { consistency: { waitUpToMs: 0 } });
87
+ const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
85
88
  return items.flatMap((it) => {
86
89
  const userTaskKey = it.userTaskKey ?? it.key;
87
90
  if (userTaskKey == null || userTaskKey === "") {
@@ -90,8 +93,8 @@ export class SdkEngineClient {
90
93
  }
91
94
  return [{
92
95
  userTaskKey: String(userTaskKey),
93
- elementId: it.elementId,
94
- variables: it.variables,
96
+ elementId: typeof it.elementId === "string" ? it.elementId : undefined,
97
+ variables: isRecord(it.variables) ? it.variables : undefined,
95
98
  }];
96
99
  });
97
100
  }
@@ -210,12 +213,15 @@ async function importNanoSdk() {
210
213
  // Indirect the specifier so the (lazily loaded) module is not resolved at
211
214
  // typecheck time and stays out of the import graph for injected-client paths.
212
215
  const spec = "@nanobpm/nano-sdk";
213
- const mod = (await import(__rewriteRelativeImportExtension(spec)));
214
- if (typeof mod.createCamundaClient !== "function") {
216
+ const mod = await import(__rewriteRelativeImportExtension(spec));
217
+ if (!isNanoSdkModule(mod)) {
215
218
  throw new Error("@nanobpm/nano-sdk does not export createCamundaClient");
216
219
  }
217
220
  return mod.createCamundaClient;
218
221
  }
222
+ function isNanoSdkModule(value) {
223
+ return isRecord(value) && typeof value.createCamundaClient === "function";
224
+ }
219
225
  /**
220
226
  * Build the Urban runtime's `EngineClient`, backed by a single `@nanobpm/nano-sdk`
221
227
  * client. Provide a `client`/`createClient` seam to inject a fake (tests, embedded
@@ -11,6 +11,8 @@ export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } f
11
11
  export type { ConnectorsHandle } from "./core/modules/connectors.ts";
12
12
  export { DataLayer, TypeRepo } from "./core/modules/datasource.ts";
13
13
  export type { ProvisionedSource } from "./core/modules/datasource.ts";
14
+ export { runDataOp } from "./core/modules/dataops.ts";
15
+ export type { DataOp, DataRequest, ResolvedSource } from "./core/modules/dataops.ts";
14
16
  export { makeGateway, Table } from "./core/modules/gateway.ts";
15
17
  export type { ColumnMeta, DataSource as GatewayDataSource, ExecResult, ForeignKeyMeta, Row, TableMeta, } from "./core/modules/gateway.ts";
16
18
  export { createPagesRoutes, mountPages } from "./core/modules/pages.ts";
@@ -22,6 +24,9 @@ export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.ts"
22
24
  export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.ts";
23
25
  export type { ChatMessage, DecisionEvaluator, EnvLookup, LlmRuntime, LlmVars, ProviderConfig, } from "./core/modules/llm.ts";
24
26
  export { evalCorrelation } from "./core/modules/triggers.ts";
27
+ export { deployModels } from "./core/modules/deploy.ts";
28
+ export { applyTemplates, resolveTemplates } from "./core/modules/templates.ts";
29
+ export type { TemplateApplication, TemplateSource } from "./core/modules/templates.ts";
25
30
  export { createNodeHost } from "./adapters/node.ts";
26
31
  export { createDenoHost } from "./adapters/deno.ts";
27
32
  export { isDeno, selectHost } from "./adapters/detect.ts";
@@ -12,12 +12,16 @@ export { BpmnError, isBpmnError } from "./core/host.js";
12
12
  export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.js";
13
13
  // Data layer
14
14
  export { DataLayer, TypeRepo } from "./core/modules/datasource.js";
15
+ export { runDataOp } from "./core/modules/dataops.js";
15
16
  export { makeGateway, Table } from "./core/modules/gateway.js";
16
17
  export { createPagesRoutes, mountPages } from "./core/modules/pages.js";
17
18
  export { mountActions, resolveActionHandler } from "./core/modules/actions.js";
18
19
  export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.js";
19
20
  export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.js";
20
21
  export { evalCorrelation } from "./core/modules/triggers.js";
22
+ // Model deploy + `{{name}}` template substitution
23
+ export { deployModels } from "./core/modules/deploy.js";
24
+ export { applyTemplates, resolveTemplates } from "./core/modules/templates.js";
21
25
  // Adapters + engine + run entrypoint
22
26
  export { createNodeHost } from "./adapters/node.js";
23
27
  export { createDenoHost } from "./adapters/deno.js";
@@ -3,6 +3,7 @@
3
3
  // create the Urban app, start it, and install a graceful-shutdown handler.
4
4
  import { createUrbanApp } from "./core/runtime.js";
5
5
  import { selectHost } from "./adapters/detect.js";
6
+ import { denoGlobal, processGlobal } from "./adapters/globals.js";
6
7
  import { createNanoSdkEngineClient } from "./engine/nanosdk.js";
7
8
  export async function runFromEnv(opts = {}) {
8
9
  const host = opts.host ?? selectHost({ cwd: opts.root });
@@ -26,6 +27,7 @@ export async function runFromEnv(opts = {}) {
26
27
  manifestPath: opts.manifestPath,
27
28
  port: opts.port,
28
29
  mount: opts.mount,
30
+ templates: opts.templates,
29
31
  });
30
32
  await app.start();
31
33
  if (opts.handleSignals !== false)
@@ -52,18 +54,19 @@ export function installSignalHandlers(stop) {
52
54
  }
53
55
  });
54
56
  };
55
- const g = globalThis;
56
- if (g.Deno) {
57
+ const deno = denoGlobal();
58
+ const proc = processGlobal();
59
+ if (deno?.addSignalListener) {
57
60
  try {
58
- g.Deno.addSignalListener("SIGINT", onSignal);
59
- g.Deno.addSignalListener("SIGTERM", onSignal);
61
+ deno.addSignalListener("SIGINT", onSignal);
62
+ deno.addSignalListener("SIGTERM", onSignal);
60
63
  }
61
64
  catch {
62
65
  /* signal listeners may be unavailable (e.g. Windows) */
63
66
  }
64
67
  }
65
- else if (g.process) {
66
- g.process.on("SIGINT", onSignal);
67
- g.process.on("SIGTERM", onSignal);
68
+ else if (proc?.on) {
69
+ proc.on("SIGINT", onSignal);
70
+ proc.on("SIGTERM", onSignal);
68
71
  }
69
72
  }
@@ -11,6 +11,7 @@
11
11
  // This module only reads/writes files through the GenIO port — installing the
12
12
  // package (a subprocess) is the CLI's job, so this stays runtime-agnostic and
13
13
  // unit-testable.
14
+ import { errorMessage, isRecord } from "../runtime/core/guards.js";
14
15
  const MANIFEST_FILE = "nano-ide.ext.json";
15
16
  function joinPath(a, b) {
16
17
  // Trim both slash styles (as toolkit/gen.ts joinPath does) so a Windows-style
@@ -40,7 +41,7 @@ export async function addConnector(opts) {
40
41
  pack = JSON.parse(await io.readText(packManifestPath));
41
42
  }
42
43
  catch (err) {
43
- throw new Error(`failed to parse ${packManifestPath}: ${err.message}`);
44
+ throw new Error(`failed to parse ${packManifestPath}: ${errorMessage(err)}`);
44
45
  }
45
46
  if (typeof pack.id !== "string" || !pack.id) {
46
47
  throw new Error(`${packManifestPath} has no string "id" (a pack must declare its id).`);
@@ -65,7 +66,7 @@ export async function addConnector(opts) {
65
66
  app = JSON.parse(await io.readText(appManifestPath));
66
67
  }
67
68
  catch (err) {
68
- throw new Error(`failed to parse ${appManifestPath}: ${err.message}`);
69
+ throw new Error(`failed to parse ${appManifestPath}: ${errorMessage(err)}`);
69
70
  }
70
71
  if (app === null || typeof app !== "object" || Array.isArray(app)) {
71
72
  throw new Error(`${appManifestPath} is not a JSON object.`);
@@ -109,7 +110,7 @@ export async function addConnector(opts) {
109
110
  // A pre-existing connection of this name must be a compatible object of the
110
111
  // same pack `type`; otherwise wiring workers to it would produce an invalid
111
112
  // or misleading manifest. Fail loudly instead of silently reusing it.
112
- if (existing === null || typeof existing !== "object" || Array.isArray(existing)) {
113
+ if (!isRecord(existing)) {
113
114
  throw new Error(`connection "${connection}" already exists and is not an object.`);
114
115
  }
115
116
  const conn = existing;
@@ -58,7 +58,8 @@ export function createNodeGenIO() {
58
58
  // stat failed (e.g. a virtual path); fall back to no cache key and let import() surface it.
59
59
  }
60
60
  const href = `${pathToFileURL(abs).href}${version}`;
61
- return import(__rewriteRelativeImportExtension(href));
61
+ const mod = import(__rewriteRelativeImportExtension(href));
62
+ return mod;
62
63
  },
63
64
  };
64
65
  }
@@ -9,6 +9,7 @@
9
9
  // no-op and the rest of `urban gen` proceeds on the authored/derived models unchanged.
10
10
  import { toDeployableBpmn } from "@nanobpm/workflow";
11
11
  import { expandPattern, joinPath } from "./gen.js";
12
+ import { isRecord } from "../runtime/core/guards.js";
12
13
  /** Default source glob for code-first flows (matches the code-first scaffold layout). */
13
14
  export const DEFAULT_WORKFLOW_PATTERNS = ["workflows/*.ts"];
14
15
  /** Default output dir for derived models (matches the model-first scaffold, so a derived
@@ -19,10 +20,7 @@ export const PROCESSES_DIR = "processes";
19
20
  export const MODEL_PROVENANCE = "Generated by @nanobpm/urban from workflows/*.ts — do not edit; edit the flow and re-run `urban gen`.";
20
21
  /** A `defineFlow` export: has a string `id` and an `imperative`/`declarative` `kind`. */
21
22
  export function isWorkflow(v) {
22
- if (!v || typeof v !== "object")
23
- return false;
24
- const o = v;
25
- return typeof o.id === "string" && (o.kind === "imperative" || o.kind === "declarative");
23
+ return isRecord(v) && typeof v.id === "string" && (v.kind === "imperative" || v.kind === "declarative");
26
24
  }
27
25
  /** Sanitize a flow id into a safe `.bpmn` filename (mirrors the host generator). */
28
26
  export function bpmnFilename(id) {
@@ -65,7 +63,7 @@ function messageOf(e) {
65
63
  /** Append an actionable hint when a `.ts` import fails because the runtime can't strip types. */
66
64
  function importHint(rel, e) {
67
65
  const base = messageOf(e);
68
- const code = e?.code ?? "";
66
+ const code = isRecord(e) && typeof e.code === "string" ? e.code : "";
69
67
  const looksLikeNoTsLoader = rel.endsWith(".ts") &&
70
68
  (code === "ERR_UNKNOWN_FILE_EXTENSION" || /Unknown file extension "\.ts"/.test(base));
71
69
  if (!looksLikeNoTsLoader)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.20.0",
3
+ "version": "0.22.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",
@@ -0,0 +1,44 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { cmdData } from "./cli.ts";
4
+ import { isRecord } from "./runtime/core/guards.ts";
5
+
6
+ // The `urban data` gateway's contract (see cmdData's docstring): *every* reply — including an
7
+ // unreadable request — is a parseable `{ ok: false, error }` envelope on stdout AND the process
8
+ // exits 0, so the Nano console can always parse the reply off stdout and never mistakes a
9
+ // malformed request for a subprocess crash. This guards the unreadable-request path, which used
10
+ // to exit non-zero and could make a caller that keys off the exit code discard the (parseable)
11
+ // error payload.
12
+ test("urban data exits 0 with a parseable envelope on an unreadable request", async () => {
13
+ const flags: Parameters<typeof cmdData>[0] = {
14
+ root: ".",
15
+ manifest: "nano.app.json",
16
+ check: false,
17
+ write: false,
18
+ deno: false,
19
+ models: true,
20
+ stdout: false,
21
+ install: true,
22
+ help: false,
23
+ version: false,
24
+ _: ["data"],
25
+ };
26
+ const lines: string[] = [];
27
+ const orig = console.log;
28
+ console.log = (...args: unknown[]) => {
29
+ lines.push(args.map(String).join(" "));
30
+ };
31
+ let code: number;
32
+ try {
33
+ code = await cmdData(flags, async () => "this is not json {");
34
+ } finally {
35
+ console.log = orig;
36
+ }
37
+ assert.equal(code, 0);
38
+ assert.equal(lines.length, 1);
39
+ const reply: unknown = JSON.parse(lines[0]);
40
+ assert.ok(isRecord(reply));
41
+ assert.equal(reply.ok, false);
42
+ if (typeof reply.error !== "string") throw new TypeError("expected string error");
43
+ assert.match(reply.error, /^bad request: /);
44
+ });
package/src/cli.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  // urban run materialize + serve the app (deploy, data, workers, surfaces, triggers)
8
8
  // urban dev like run, plus watch sources and hot-reload on change
9
9
  // urban deploy deploy models only, then exit
10
+ // urban data DB-manager op gateway (stdin JSON → stdout JSON)
10
11
  //
11
12
  // Global flags: --root <dir> (default "."), --manifest <file> (default nano.app.json),
12
13
  // --port <n>, -h/--help, -v/--version.
@@ -15,11 +16,15 @@ import {
15
16
  collectManifestIssues,
16
17
  installSignalHandlers,
17
18
  loadManifest,
19
+ runDataOp,
18
20
  runDev,
19
21
  runFromEnv,
20
22
  selectHost,
21
23
  } from "./runtime/index.ts";
24
+ import { denoGlobal, processGlobal } from "./runtime/adapters/globals.ts";
25
+ import { errorMessage, isRecord } from "./runtime/core/guards.ts";
22
26
  import { scaffold, slugify } from "create-urban-app";
27
+ import type { DataRequest } from "./runtime/index.ts";
23
28
  import { addConnector, createNodeGenIO, previewModels, runGen, scaffoldWorkers } from "./toolkit/index.ts";
24
29
  import { pathToFileURL } from "node:url";
25
30
  import { readFileSync } from "node:fs";
@@ -30,7 +35,8 @@ import { spawnSync } from "node:child_process";
30
35
  function readVersion(): string {
31
36
  try {
32
37
  const url = new URL("../package.json", import.meta.url);
33
- return (JSON.parse(readFileSync(url, "utf8")) as { version?: string }).version ?? "0.0.0";
38
+ const pkg: unknown = JSON.parse(readFileSync(url, "utf8"));
39
+ return isRecord(pkg) && typeof pkg.version === "string" ? pkg.version : "0.0.0";
34
40
  } catch {
35
41
  return "0.0.0";
36
42
  }
@@ -111,6 +117,9 @@ Usage:
111
117
  urban run materialize + serve the app
112
118
  urban dev run + watch sources, hot-reload on change
113
119
  urban deploy deploy models only, then exit
120
+ urban data DB-manager op gateway: read a JSON request on stdin
121
+ ({op:sources|schema|query|exec|script|migrations|migrate,…}),
122
+ write a JSON result on stdout
114
123
 
115
124
  Global flags:
116
125
  --root <dir> app root (default ".")
@@ -270,6 +279,63 @@ async function cmdDeploy(f: Flags): Promise<number> {
270
279
  return 0;
271
280
  }
272
281
 
282
+ /** Read all of stdin as UTF-8 text (Node or Deno). Returns "" if stdin is empty/closed. */
283
+ async function readStdin(): Promise<string> {
284
+ const deno = denoGlobal();
285
+ const proc = processGlobal();
286
+ const chunks: string[] = [];
287
+ const dec = new TextDecoder();
288
+ if (deno?.stdin?.readable) {
289
+ for await (const chunk of deno.stdin.readable) chunks.push(dec.decode(chunk, { stream: true }));
290
+ chunks.push(dec.decode());
291
+ return chunks.join("");
292
+ }
293
+ if (proc?.stdin) {
294
+ for await (const chunk of proc.stdin) {
295
+ chunks.push(typeof chunk === "string" ? chunk : dec.decode(chunk, { stream: true }));
296
+ }
297
+ chunks.push(dec.decode());
298
+ return chunks.join("");
299
+ }
300
+ return "";
301
+ }
302
+
303
+ /**
304
+ * `urban data` — the DB-manager op gateway. Reads a single JSON request from stdin
305
+ * (`{ op, source?, sql?, params?, statements? }`) and writes a single JSON response to stdout
306
+ * (`{ ok: true, ...result }` or `{ ok: false, error }`). Every response — a handled op error *or*
307
+ * an unreadable request — is a parseable `{ ok: false, error }` envelope and still exits 0, so the
308
+ * caller can always parse the reply off stdout and never has to treat a non-zero exit as a lost
309
+ * error; the process only exits non-zero on an unexpected crash. This is the seam the Nano console
310
+ * re-points its Data panel at (host dry-out nano-bpm#576), replacing the vendored data-cli.ts.
311
+ */
312
+ export async function cmdData(f: Flags, readInput: () => Promise<string> = readStdin): Promise<number> {
313
+ let req: DataRequest;
314
+ try {
315
+ req = JSON.parse(await readInput());
316
+ } catch (err) {
317
+ console.log(
318
+ JSON.stringify({
319
+ ok: false,
320
+ error: `bad request: ${err instanceof Error ? err.message : String(err)}`,
321
+ }),
322
+ );
323
+ return 0;
324
+ }
325
+ const host = selectHost({ cwd: f.root });
326
+ try {
327
+ // Mirror runFromEnv: the host is anchored at f.root, so pass root "." to avoid
328
+ // double-prefixing every manifest/db path (host.abs already resolves against f.root).
329
+ const result = await runDataOp(host, ".", f.manifest, req);
330
+ console.log(JSON.stringify({ ok: true, ...result }));
331
+ } catch (err) {
332
+ console.log(
333
+ JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }),
334
+ );
335
+ }
336
+ return 0;
337
+ }
338
+
273
339
  async function cmdNew(f: Flags): Promise<number> {
274
340
  const name = f._[1];
275
341
  if (!name) {
@@ -332,7 +398,7 @@ async function cmdAdd(f: Flags): Promise<number> {
332
398
  }
333
399
  return 0;
334
400
  } catch (err) {
335
- console.error(`✖ ${(err as Error).message}`);
401
+ console.error(`✖ ${errorMessage(err)}`);
336
402
  return 1;
337
403
  }
338
404
  }
@@ -342,7 +408,7 @@ export async function main(argv: string[]): Promise<number> {
342
408
  try {
343
409
  f = parse(argv);
344
410
  } catch (err) {
345
- console.error(String((err as Error).message));
411
+ console.error(errorMessage(err));
346
412
  return 1;
347
413
  }
348
414
  if (f.version) {
@@ -368,6 +434,8 @@ export async function main(argv: string[]): Promise<number> {
368
434
  return cmdStubs(f);
369
435
  case "add":
370
436
  return cmdAdd(f);
437
+ case "data":
438
+ return cmdData(f);
371
439
  case "run":
372
440
  return cmdRun(f);
373
441
  case "dev":
@@ -381,29 +449,27 @@ export async function main(argv: string[]): Promise<number> {
381
449
  }
382
450
  }
383
451
 
384
- const g = globalThis as {
385
- process?: { argv: string[]; exit(code: number): void };
386
- Deno?: { args?: string[]; exit?: (code: number) => void };
387
- };
388
- const meta = import.meta as unknown as { main?: boolean; url: string };
389
- const argv1 = g.process?.argv?.[1];
390
- const nodeMain = argv1 ? meta.url === pathToFileURL(argv1).href : false;
391
- if (meta.main === true || nodeMain) {
452
+ const proc = processGlobal();
453
+ const deno = denoGlobal();
454
+ const importMetaMain: unknown = Reflect.get(import.meta, "main");
455
+ const argv1 = proc?.argv?.[1];
456
+ const nodeMain = argv1 ? import.meta.url === pathToFileURL(argv1).href : false;
457
+ if (importMetaMain === true || nodeMain) {
392
458
  // Node passes args via process.argv (slice off exec+script); Deno (run directly)
393
459
  // exposes them on Deno.args. Prefer whichever actually carries args.
394
- const fromNode = g.process?.argv?.slice(2);
395
- const fromDeno = g.Deno?.args;
460
+ const fromNode = proc?.argv?.slice(2);
461
+ const fromDeno = deno?.args;
396
462
  const argv = (fromNode && fromNode.length ? fromNode : fromDeno) ?? fromNode ?? [];
397
463
  const exit = (code: number) => {
398
- if (g.process?.exit) g.process.exit(code);
399
- else if (g.Deno?.exit) g.Deno.exit(code);
464
+ if (proc?.exit) proc.exit(code);
465
+ else if (deno?.exit) deno.exit(code);
400
466
  };
401
467
  main(argv).then(
402
468
  (code) => {
403
469
  if (code >= 0) exit(code);
404
470
  },
405
471
  (err) => {
406
- console.error(String((err as Error)?.message ?? err));
472
+ console.error(errorMessage(err));
407
473
  exit(1);
408
474
  },
409
475
  );