@nanobpm/urban 0.90.1 → 0.91.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.90.1",
3
+ "version": "0.91.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",
package/src/cli.test.ts CHANGED
@@ -1,7 +1,14 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { spawn } from "node:child_process";
4
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
3
8
  import { cmdData } from "./cli.ts";
4
9
  import { isRecord } from "./runtime/core/guards.ts";
10
+ import { createNodeHost } from "./runtime/adapters/node.ts";
11
+ import { runDataOp } from "./runtime/core/modules/dataops.ts";
5
12
 
6
13
  // The `urban data` gateway's contract (see cmdData's docstring): *every* reply — including an
7
14
  // unreadable request — is a parseable `{ ok: false, error }` envelope on stdout AND the process
@@ -41,3 +48,85 @@ test("urban data exits 0 with a parseable envelope on an unreadable request", as
41
48
  if (typeof reply.error !== "string") throw new TypeError("expected string error");
42
49
  assert.match(reply.error, /^bad request: /);
43
50
  });
51
+
52
+ // Regression guard for nanobpm/nano-ide#549: `urban data`'s success path prints the full reply
53
+ // with `console.log`, then the top-level runner calls `process.exit(code)`. In Node, stdout
54
+ // connected to a *pipe* is asynchronous — an immediate `process.exit` abandons anything still
55
+ // buffered, truncating the reply at the OS pipe-buffer boundary (65536 bytes on Linux) so the
56
+ // console host fails to `JSON.parse` it ("EOF … column 65536"). The fix drains stdout before the
57
+ // explicit exit. This test spawns the real CLI on a datasource whose query result serialises to
58
+ // well over 64 KB, reads the piped stdout, and asserts the parent parses a complete envelope whose
59
+ // byte length matches the untruncated JSON. It FAILS before the fix (truncated at 65536) and
60
+ // passes after.
61
+ test("urban data streams a > 64 KB reply without truncation through an explicit process.exit", async () => {
62
+ const dir = await mkdtemp(join(tmpdir(), "urban-data-large-"));
63
+ try {
64
+ await mkdir(join(dir, "db", "migrations"), { recursive: true });
65
+ await writeFile(
66
+ join(dir, "nano.app.json"),
67
+ JSON.stringify({
68
+ schemaVersion: 1,
69
+ id: "big",
70
+ name: "Big",
71
+ data: {
72
+ default: "app",
73
+ sources: { app: { driver: "sqlite", url: "file:./db/app.db", migrations: "db/migrations" } },
74
+ },
75
+ }),
76
+ );
77
+ await writeFile(
78
+ join(dir, "db", "migrations", "001_big.sql"),
79
+ "CREATE TABLE big (id INTEGER PRIMARY KEY, payload TEXT NOT NULL);",
80
+ );
81
+ // 2000 rows × ~128-char payload → a query reply of roughly 300 KB, comfortably past the 64 KB
82
+ // pipe boundary. A single recursive-CTE INSERT (no inner `;`) so the migration splitter keeps
83
+ // it as one statement.
84
+ await writeFile(
85
+ join(dir, "db", "migrations", "002_seed.sql"),
86
+ "INSERT INTO big (id, payload) WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 2000) SELECT n, printf('%0128d', n) FROM seq;",
87
+ );
88
+
89
+ const host = createNodeHost({ cwd: dir });
90
+ await runDataOp(host, ".", "nano.app.json", { op: "migrate" });
91
+ const query = { op: "query", sql: "SELECT * FROM big ORDER BY id" } as const;
92
+ const result = await runDataOp(host, ".", "nano.app.json", query);
93
+ const expected = `${JSON.stringify({ ok: true, ...result })}\n`;
94
+ const expectedBytes = Buffer.byteLength(expected);
95
+ assert.ok(
96
+ expectedBytes > 65536,
97
+ `fixture too small (${expectedBytes} bytes) to exercise the pipe-buffer boundary`,
98
+ );
99
+
100
+ const cli = fileURLToPath(new URL("./cli.ts", import.meta.url));
101
+ const { stdout, code, signal } = await new Promise<{
102
+ stdout: Buffer;
103
+ code: number | null;
104
+ signal: NodeJS.Signals | null;
105
+ }>((resolve, reject) => {
106
+ const child = spawn(
107
+ process.execPath,
108
+ ["--experimental-strip-types", cli, "data", "--root", dir],
109
+ { stdio: ["pipe", "pipe", "inherit"] },
110
+ );
111
+ const chunks: Buffer[] = [];
112
+ child.stdout.on("data", (c: Buffer) => chunks.push(c));
113
+ child.on("error", reject);
114
+ child.on("close", (code, signal) => resolve({ stdout: Buffer.concat(chunks), code, signal }));
115
+ child.stdin.end(JSON.stringify(query));
116
+ });
117
+
118
+ assert.equal(signal, null, `CLI killed by signal ${signal}`);
119
+ assert.equal(code, 0, `CLI exited ${code}, expected 0`);
120
+ assert.equal(
121
+ stdout.byteLength,
122
+ expectedBytes,
123
+ `stdout truncated: got ${stdout.byteLength} bytes, expected ${expectedBytes}`,
124
+ );
125
+ const reply: unknown = JSON.parse(stdout.toString("utf8"));
126
+ assert.ok(isRecord(reply));
127
+ assert.equal(reply.ok, true);
128
+ assert.deepEqual(reply, { ok: true, ...result });
129
+ } finally {
130
+ await rm(dir, { recursive: true, force: true });
131
+ }
132
+ });
package/src/cli.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  selectHost,
23
23
  } from "./runtime/index.ts";
24
24
  import { denoGlobal, processGlobal } from "./runtime/adapters/globals.ts";
25
+ import type { WritableStdio } from "./runtime/adapters/globals.ts";
25
26
  import { errorMessage, isRecord } from "./runtime/core/guards.ts";
26
27
  import { scaffold, slugify } from "create-urban-app";
27
28
  import type { DataRequest } from "./runtime/index.ts";
@@ -446,6 +447,31 @@ export async function main(argv: string[]): Promise<number> {
446
447
  }
447
448
  }
448
449
 
450
+ /**
451
+ * Flush a Node writable stdio stream, resolving once its buffered writes have drained to the OS.
452
+ * `process.stdout`/`stderr` connected to a *pipe* is asynchronous in Node: `console.log` buffers
453
+ * the write and an immediate `process.exit()` abandons anything not yet handed to the OS, so a
454
+ * large reply (> the 64 KB pipe buffer on Linux) is silently truncated. Writing an empty chunk and
455
+ * waiting for its completion callback drains the prior writes first. Deno's stdio has no such
456
+ * `write(chunk, cb)` shape (its `console.log` is synchronous), so its absence is a no-op flush.
457
+ */
458
+ function flushStream(stream: WritableStdio | undefined): Promise<void> {
459
+ return new Promise((resolve) => {
460
+ if (!stream || typeof stream.write !== "function") {
461
+ resolve();
462
+ return;
463
+ }
464
+ stream.write("", () => resolve());
465
+ });
466
+ }
467
+
468
+ /** Drain both stdout and stderr before an explicit `process.exit` so no buffered output is lost. */
469
+ async function flushStdio(): Promise<void> {
470
+ const p = processGlobal();
471
+ if (!p) return;
472
+ await Promise.all([flushStream(p.stdout), flushStream(p.stderr)]);
473
+ }
474
+
449
475
  const proc = processGlobal();
450
476
  const deno = denoGlobal();
451
477
  const importMetaMain: unknown = Reflect.get(import.meta, "main");
@@ -462,11 +488,15 @@ if (importMetaMain === true || nodeMain) {
462
488
  else if (deno?.exit) deno.exit(code);
463
489
  };
464
490
  main(argv).then(
465
- (code) => {
466
- if (code >= 0) exit(code);
491
+ async (code) => {
492
+ if (code >= 0) {
493
+ await flushStdio();
494
+ exit(code);
495
+ }
467
496
  },
468
- (err) => {
497
+ async (err) => {
469
498
  console.error(errorMessage(err));
499
+ await flushStdio();
470
500
  exit(1);
471
501
  },
472
502
  );
@@ -8,10 +8,22 @@ export interface DenoRuntimeGlobal {
8
8
  addSignalListener?: (sig: string, cb: () => void) => void;
9
9
  }
10
10
 
11
+ /**
12
+ * The subset of a Node writable stdio stream (`process.stdout` / `process.stderr`) we depend on:
13
+ * a `write` that accepts a completion callback fired once the chunk has flushed to the OS. Deno's
14
+ * stdio streams do not expose this shape; callers treat its absence as "already synchronous, no
15
+ * flush needed" (see `flushStdio` in cli.ts).
16
+ */
17
+ export interface WritableStdio {
18
+ write?: (chunk: string, cb: () => void) => void;
19
+ }
20
+
11
21
  export interface ProcessRuntimeGlobal {
12
22
  argv?: string[];
13
23
  exit?: (code: number) => void;
14
24
  stdin?: AsyncIterable<Uint8Array | string> & { setEncoding?(encoding: string): void };
25
+ stdout?: WritableStdio;
26
+ stderr?: WritableStdio;
15
27
  on?: (sig: string, cb: () => void) => void;
16
28
  }
17
29
 
@@ -1955,9 +1955,17 @@ function buildEngineForm(cfg, row, onSuccess) {
1955
1955
  const formKey = row[cfg.formKeyField || "form_key"];
1956
1956
  const box = el("div", { class: "pc-subform" });
1957
1957
  const msg = el("p", { class: "njf-msg", role: "status", "aria-live": "polite" });
1958
+ // Optional page-author knob: route completion through the app's own canonical
1959
+ // door instead of the runtime's generic seam. Defaults to /app/actions/complete
1960
+ // when unset (existing pages unchanged). Same trust class as detail.form's
1961
+ // action.path; rebased through apiUrl() by getJSON like every other page fetch.
1962
+ const completePath =
1963
+ typeof cfg.completePath === "string" && cfg.completePath.trim() !== ""
1964
+ ? cfg.completePath.trim()
1965
+ : "/app/actions/complete";
1958
1966
  /** @param {Record<string, any>} variables */
1959
1967
  const complete = (variables) =>
1960
- getJSON("/app/actions/complete", {
1968
+ getJSON(completePath, {
1961
1969
  method: "POST",
1962
1970
  headers: { "content-type": "application/json" },
1963
1971
  body: JSON.stringify({ userTaskKey, variables }),
@@ -84,6 +84,7 @@ test("buildEngineForm resolves the row's formKey and completes with the entered
84
84
  assert.ok(input, "the deployed form-js schema was rendered by the shared renderer");
85
85
  input!.value = "done";
86
86
  const form = created.find((n) => n.tagName === "FORM");
87
+ assert.ok(form, "the deployed schema rendered a form element to submit");
87
88
  await form!.fire("submit");
88
89
  await new Promise((r) => setTimeout(r, 0));
89
90
  const completeReq = calls.find((c) => c.url.includes("/app/actions/complete"));
@@ -116,6 +117,7 @@ test("buildEngineForm surfaces a completion failure in the rendered-form path",
116
117
  assert.ok(msg, "a failed completion is surfaced to the operator (not silent)");
117
118
  assert.notEqual(msg!.textContent, "", "the error message element has text");
118
119
  const submit = created.find((n) => n.tagName === "BUTTON" && n.type === "submit");
120
+ assert.ok(submit, "the completion path rendered a submit button");
119
121
  assert.equal(submit!.disabled, false, "the submit button is re-enabled so the operator can retry");
120
122
  });
121
123
 
@@ -156,3 +158,101 @@ test("buildEngineForm degrades to bare completion when the formKey resolves to n
156
158
  const button = created.find((n) => n.tagName === "BUTTON" && n.textContent === "Complete");
157
159
  assert.ok(button, "a 204 (no form) falls back to the bare completion button");
158
160
  });
161
+
162
+ test("buildEngineForm routes the rendered-form completion through a configured completePath", async (t) => {
163
+ const created: FakeElement[] = [];
164
+ const calls: FetchCall[] = [];
165
+ t.after(installFakeDom(created));
166
+ t.after(
167
+ installFakeFetch((url) => {
168
+ if (url.includes("/app/actions/form")) {
169
+ return { status: 200, json: { schema: { components: [{ type: "textfield", key: "note" }] } } };
170
+ }
171
+ return { status: 200, json: { ok: true } };
172
+ }, calls),
173
+ );
174
+ const box = buildEngineForm(
175
+ { ...CFG, completePath: "/app/api/actions/complete-user-task" },
176
+ { form_key: "form-c", user_task_key: "ut-c" },
177
+ () => {},
178
+ );
179
+ assert.ok(box);
180
+ await new Promise((r) => setTimeout(r, 0));
181
+ const input = created.find((n) => n.tagName === "INPUT");
182
+ assert.ok(input, "the deployed form-js schema was rendered by the shared renderer");
183
+ input!.value = "done";
184
+ const form = created.find((n) => n.tagName === "FORM");
185
+ assert.ok(form, "the deployed schema rendered a form element to submit");
186
+ await form!.fire("submit");
187
+ await new Promise((r) => setTimeout(r, 0));
188
+ const completeReq = calls.find((c) => c.url.includes("/app/api/actions/complete-user-task"));
189
+ assert.ok(completeReq, "completion POSTs to the configured completePath, not the generic seam");
190
+ assert.equal(completeReq!.method, "POST");
191
+ assert.deepEqual(completeReq!.body, { userTaskKey: "ut-c", variables: { note: "done" } });
192
+ assert.equal(
193
+ calls.some((c) => c.url.includes("/app/actions/complete")),
194
+ false,
195
+ "the generic seam is not called when completePath is set",
196
+ );
197
+ });
198
+
199
+ test("buildEngineForm routes the bare completion through a configured completePath", async (t) => {
200
+ const created: FakeElement[] = [];
201
+ const calls: FetchCall[] = [];
202
+ t.after(installFakeDom(created));
203
+ t.after(installFakeFetch(() => ({ status: 200, json: { ok: true } }), calls));
204
+ const box = buildEngineForm(
205
+ { ...CFG, completePath: "/app/custom-complete" },
206
+ { user_task_key: "ut-b" },
207
+ () => {},
208
+ );
209
+ assert.ok(box);
210
+ const button = created.find((n) => n.tagName === "BUTTON");
211
+ assert.ok(button, "the bare completion path rendered a Complete button");
212
+ await button!.fire("click");
213
+ await new Promise((r) => setTimeout(r, 0));
214
+ const completeReq = calls.find((c) => c.url.includes("/app/custom-complete"));
215
+ assert.ok(completeReq, "the bare button completes via the configured completePath");
216
+ assert.equal(completeReq!.body.userTaskKey, "ut-b");
217
+ assert.deepEqual(completeReq!.body.variables, {}, "bare completion sends empty variables");
218
+ assert.equal(
219
+ calls.some((c) => c.url.includes("/app/actions/complete")),
220
+ false,
221
+ "the generic seam is not called when completePath is set",
222
+ );
223
+ });
224
+
225
+ test("buildEngineForm falls back to the generic seam when completePath is empty/absent", async (t) => {
226
+ const created: FakeElement[] = [];
227
+ const calls: FetchCall[] = [];
228
+ t.after(installFakeDom(created));
229
+ t.after(installFakeFetch(() => ({ status: 200, json: { ok: true } }), calls));
230
+ // An empty-string completePath is treated as absent (default seam preserved).
231
+ const box = buildEngineForm({ ...CFG, completePath: "" }, { user_task_key: "ut-d" }, () => {});
232
+ assert.ok(box);
233
+ const button = created.find((n) => n.tagName === "BUTTON");
234
+ assert.ok(button, "the bare completion path rendered a Complete button");
235
+ await button!.fire("click");
236
+ await new Promise((r) => setTimeout(r, 0));
237
+ const completeReq = calls.find((c) => c.url.includes("/app/actions/complete"));
238
+ assert.ok(completeReq, "an empty completePath still targets the default /app/actions/complete seam");
239
+ assert.equal(completeReq!.body.userTaskKey, "ut-d");
240
+ });
241
+
242
+ test("buildEngineForm treats a whitespace-only completePath as absent", async (t) => {
243
+ const created: FakeElement[] = [];
244
+ const calls: FetchCall[] = [];
245
+ t.after(installFakeDom(created));
246
+ t.after(installFakeFetch(() => ({ status: 200, json: { ok: true } }), calls));
247
+ // A whitespace-only completePath is normalized away so it never becomes an
248
+ // unintended relative fetch target — the default seam is preserved.
249
+ const box = buildEngineForm({ ...CFG, completePath: " " }, { user_task_key: "ut-e" }, () => {});
250
+ assert.ok(box);
251
+ const button = created.find((n) => n.tagName === "BUTTON");
252
+ assert.ok(button, "the bare completion path rendered a Complete button");
253
+ await button!.fire("click");
254
+ await new Promise((r) => setTimeout(r, 0));
255
+ const completeReq = calls.find((c) => c.url.includes("/app/actions/complete"));
256
+ assert.ok(completeReq, "a whitespace-only completePath still targets the default /app/actions/complete seam");
257
+ assert.equal(completeReq!.body.userTaskKey, "ut-e");
258
+ });