@nanobpm/urban 0.96.0 → 0.96.2

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/dist/cli.d.ts CHANGED
@@ -23,4 +23,12 @@ interface Flags {
23
23
  */
24
24
  export declare function cmdData(f: Flags, readInput?: () => Promise<string>): Promise<number>;
25
25
  export declare function main(argv: string[]): Promise<number>;
26
+ /**
27
+ * Run the CLI as the process entrypoint: `main(argv)`, then drain stdio and exit with its code.
28
+ * A negative code means a long-running command (`run`, `dev`) owns the process, so it is left
29
+ * alive. Exported so the `urban` bin (`bin.mjs`) can run the CLI **in-process** via `import()`
30
+ * instead of spawning a second Node (nano-ide#589): every `urban` command, notably each
31
+ * gateway `urban data` op, used to pay two Node start-ups.
32
+ */
33
+ export declare function runCli(argv: string[]): Promise<void>;
26
34
  export {};
package/dist/cli.js CHANGED
@@ -11,11 +11,15 @@
11
11
  //
12
12
  // Global flags: --root <dir> (default "."), --manifest <file> (default nano.app.json),
13
13
  // --port <n>, -h/--help, -v/--version.
14
- import { collectManifestIssues, installSignalHandlers, loadManifest, runDataOp, runDev, runFromEnv, selectHost, } from "./runtime/index.js";
14
+ //
15
+ // Module-load laziness (nano-ide#592): only argument parsing, the stdio/guard helpers and the
16
+ // dispatcher load eagerly. Every command's modules are dynamically imported inside its handler —
17
+ // a top-level import of the toolkit, the scaffold package or the runtime barrel made EVERY
18
+ // command (including the per-data-op `urban data` gateway) pay the full ~2s load on small
19
+ // hosts before dispatching. `urban data` loads only the host detector and the data-op runner.
20
+ // Guarded by cli-lazy.test.ts, which records the URLs each command resolves.
15
21
  import { denoGlobal, processGlobal } from "./runtime/adapters/globals.js";
16
22
  import { errorMessage, isRecord } from "./runtime/core/guards.js";
17
- import { scaffold, slugify } from "create-urban-app";
18
- import { addConnector, createNodeGenIO, previewModels, generate, runGen } from "./toolkit/index.js";
19
23
  import { pathToFileURL } from "node:url";
20
24
  import { readFileSync } from "node:fs";
21
25
  import { spawnSync } from "node:child_process";
@@ -119,6 +123,9 @@ Global flags:
119
123
  Engine address: $CAMUNDA_REST_ADDRESS (default http://localhost:8080/v2).
120
124
  `;
121
125
  async function cmdCheck(f) {
126
+ const { selectHost } = await import("./runtime/adapters/detect.js");
127
+ const { loadManifest } = await import("./runtime/core/manifest.js");
128
+ const { collectManifestIssues } = await import("./runtime/core/validate.js");
122
129
  // The host is anchored at f.root, so manifest paths are already root-relative —
123
130
  // pass the bare manifest filename (prefixing root again would double it).
124
131
  const host = selectHost({ cwd: f.root });
@@ -134,6 +141,7 @@ async function cmdCheck(f) {
134
141
  return 1;
135
142
  }
136
143
  async function cmdGen(f) {
144
+ const { createNodeGenIO, generate } = await import("./toolkit/index.js");
137
145
  const io = createNodeGenIO();
138
146
  const res = await generate({ root: f.root, io, manifestFile: f.manifest, check: f.check, emitModels: f.models });
139
147
  if (res.incomplete) {
@@ -198,6 +206,7 @@ async function cmdDerive(f) {
198
206
  console.error("✖ `urban derive` derives models — `--no-models` is contradictory. Use `urban gen --no-models` for type-contracts only.");
199
207
  return 2;
200
208
  }
209
+ const { createNodeGenIO, previewModels, runGen } = await import("./toolkit/index.js");
201
210
  const io = createNodeGenIO();
202
211
  // Non-writing preview: emit `{ models: [{ id, kind, xml }], incomplete }` to stdout for the
203
212
  // console's read-only model viewer (never touches `processes/`).
@@ -238,6 +247,7 @@ async function cmdDerive(f) {
238
247
  return 0;
239
248
  }
240
249
  async function cmdRun(f, mount) {
250
+ const { runFromEnv } = await import("./runtime/run.js");
241
251
  const app = await runFromEnv({
242
252
  root: f.root,
243
253
  manifestPath: f.manifest,
@@ -249,11 +259,14 @@ async function cmdRun(f, mount) {
249
259
  return -1; // keep the process alive; signal handlers stop it
250
260
  }
251
261
  async function cmdDev(f) {
262
+ const { runDev } = await import("./runtime/devserver.js");
263
+ const { installSignalHandlers } = await import("./runtime/run.js");
252
264
  const dev = await runDev({ root: f.root, manifestPath: f.manifest, port: f.port });
253
265
  installSignalHandlers(() => dev.stop());
254
266
  return -1; // keep the process alive; signal handlers stop the dev server
255
267
  }
256
268
  async function cmdDeploy(f) {
269
+ const { runFromEnv } = await import("./runtime/run.js");
257
270
  const app = await runFromEnv({
258
271
  root: f.root,
259
272
  manifestPath: f.manifest,
@@ -306,6 +319,12 @@ export async function cmdData(f, readInput = readStdin) {
306
319
  }));
307
320
  return 0;
308
321
  }
322
+ // Lazily the only modules `urban data` loads (nano-ide#592): the host detector (both
323
+ // adapters, no toolkit) and the data-op runner (gateway + SQLite adapter; the toolkit
324
+ // derivers behind the `domaintypes` op load only when that op is dispatched). Imported
325
+ // directly rather than via the runtime barrel, which would pull in the whole runtime.
326
+ const { selectHost } = await import("./runtime/adapters/detect.js");
327
+ const { runDataOp } = await import("./runtime/core/modules/dataops.js");
309
328
  const host = selectHost({ cwd: f.root });
310
329
  try {
311
330
  // Mirror runFromEnv: the host is anchored at f.root, so pass root "." to avoid
@@ -324,6 +343,7 @@ async function cmdNew(f) {
324
343
  console.error("usage: urban new <name> [--root <path>] [--deno] [--style model|code] (or --code-first)");
325
344
  return 1;
326
345
  }
346
+ const { scaffold, slugify } = await import("create-urban-app");
327
347
  const dir = f.root !== "." ? f.root : `./${slugify(name)}`;
328
348
  const res = await scaffold({ name, dir, deno: f.deno, style: f.style });
329
349
  console.log(`✔ scaffolded "${res.id}" in ${res.dir} (${res.files.length} files)`);
@@ -362,6 +382,7 @@ async function cmdAdd(f) {
362
382
  }
363
383
  }
364
384
  try {
385
+ const { addConnector, createNodeGenIO } = await import("./toolkit/index.js");
365
386
  const result = await addConnector({
366
387
  root: f.root,
367
388
  pkg,
@@ -457,24 +478,23 @@ async function flushStdio() {
457
478
  return;
458
479
  await Promise.all([flushStream(p.stdout), flushStream(p.stderr)]);
459
480
  }
460
- const proc = processGlobal();
461
- const deno = denoGlobal();
462
- const importMetaMain = Reflect.get(import.meta, "main");
463
- const argv1 = proc?.argv?.[1];
464
- const nodeMain = argv1 ? import.meta.url === pathToFileURL(argv1).href : false;
465
- if (importMetaMain === true || nodeMain) {
466
- // Node passes args via process.argv (slice off exec+script); Deno (run directly)
467
- // exposes them on Deno.args. Prefer whichever actually carries args.
468
- const fromNode = proc?.argv?.slice(2);
469
- const fromDeno = deno?.args;
470
- const argv = (fromNode && fromNode.length ? fromNode : fromDeno) ?? fromNode ?? [];
481
+ /**
482
+ * Run the CLI as the process entrypoint: `main(argv)`, then drain stdio and exit with its code.
483
+ * A negative code means a long-running command (`run`, `dev`) owns the process, so it is left
484
+ * alive. Exported so the `urban` bin (`bin.mjs`) can run the CLI **in-process** via `import()`
485
+ * instead of spawning a second Node (nano-ide#589): every `urban` command, notably each
486
+ * gateway `urban data` op, used to pay two Node start-ups.
487
+ */
488
+ export function runCli(argv) {
489
+ const proc = processGlobal();
490
+ const deno = denoGlobal();
471
491
  const exit = (code) => {
472
492
  if (proc?.exit)
473
493
  proc.exit(code);
474
494
  else if (deno?.exit)
475
495
  deno.exit(code);
476
496
  };
477
- main(argv).then(async (code) => {
497
+ return main(argv).then(async (code) => {
478
498
  if (code >= 0) {
479
499
  await flushStdio();
480
500
  exit(code);
@@ -485,3 +505,16 @@ if (importMetaMain === true || nodeMain) {
485
505
  exit(1);
486
506
  });
487
507
  }
508
+ const proc = processGlobal();
509
+ const deno = denoGlobal();
510
+ const importMetaMain = Reflect.get(import.meta, "main");
511
+ const argv1 = proc?.argv?.[1];
512
+ const nodeMain = argv1 ? import.meta.url === pathToFileURL(argv1).href : false;
513
+ if (importMetaMain === true || nodeMain) {
514
+ // Node passes args via process.argv (slice off exec+script); Deno (run directly)
515
+ // exposes them on Deno.args. Prefer whichever actually carries args.
516
+ const fromNode = proc?.argv?.slice(2);
517
+ const fromDeno = deno?.args;
518
+ const argv = (fromNode && fromNode.length ? fromNode : fromDeno) ?? fromNode ?? [];
519
+ void runCli(argv);
520
+ }
@@ -1,9 +1,9 @@
1
1
  import type { HostContext } from "../host.ts";
2
2
  import type { AppManifest } from "../manifest.ts";
3
- import { type ShapeDecl } from "../../../toolkit/derivers/shapes.ts";
4
- import { type WorkerBindingDecl } from "../../../toolkit/derivers/worker-io.ts";
5
- import { type MessageBindingDecl } from "../../../toolkit/derivers/messages.ts";
6
- import { type MetaDecl } from "../../../toolkit/derivers/meta.ts";
3
+ import type { ShapeDecl } from "../../../toolkit/derivers/shapes.ts";
4
+ import type { WorkerBindingDecl } from "../../../toolkit/derivers/worker-io.ts";
5
+ import type { MessageBindingDecl } from "../../../toolkit/derivers/messages.ts";
6
+ import type { MetaDecl } from "../../../toolkit/derivers/meta.ts";
7
7
  /** The DB-manager op protocol's known op set — every op (including `domaintypes`) is dispatched by
8
8
  * `runDataOp`. Typing `op` as this union (instead of `string`) gives SDK/console callers type-safety
9
9
  * and catches typos at compile time. */
@@ -23,16 +23,6 @@ import { loadManifest } from "../manifest.js";
23
23
  import { validateManifest } from "../validate.js";
24
24
  import { makeGateway } from "./gateway.js";
25
25
  import { applyMigrations, MIGRATIONS_TABLE, openSqliteSource, resolveAppPath } from "./datasource.js";
26
- // The `domaintypes` op composes the domain reifier from the pure toolkit derivers (single source of
27
- // truth for the IDE's codegen, host dry-out nano-bpm#576). Imported directly from the deriver files
28
- // (not the `toolkit/` barrel, which pulls the Node fs IO) so core stays free of `node:*`/Deno; the
29
- // derivers' only core dependency is the gateway's `TableMeta`/`ColumnMeta` *types*, which erase.
30
- import { GENERATED_DIR } from "../../../toolkit/artifact.js";
31
- import { DOMAIN_BINDINGS, emitDomainBindings, emitDomainModel, registryFromManifest, } from "../../../toolkit/derivers/domain.js";
32
- import { DOMAIN_MODEL_JSON, emitDomainModelJson, resolveShapes, } from "../../../toolkit/derivers/shapes.js";
33
- import { DOMAIN_DTS, emitWorkerBindings, emitWorkerBindingsRuntime, overlayDerivedWorkerIo, WORKER_BINDINGS_DTS, WORKER_BINDINGS_TS, } from "../../../toolkit/derivers/worker-io.js";
34
- import { emitMessageBindings, emitMessageBindingsRuntime, MESSAGE_BINDINGS_DTS, MESSAGE_BINDINGS_TS, } from "../../../toolkit/derivers/messages.js";
35
- import { emitMeta, META_TS } from "../../../toolkit/derivers/meta.js";
36
26
  const DEFAULT_MIGRATIONS_DIR = "db/migrations";
37
27
  /** JSON-safe a value: BigInt → number (when lossless) or string, recursing objects/arrays. SQLite
38
28
  * reports `lastInsertRowid` (and INTEGER columns) as BigInt, which `JSON.stringify` cannot encode. */
@@ -242,6 +232,17 @@ export async function runDataOp(host, root, manifestPath, req) {
242
232
  // debounced keystroke has no DB side-effect and pays only for `text` + `shapeDiagnostics`.
243
233
  const { default: def, sources } = listSources(manifest);
244
234
  const persist = req.write !== false;
235
+ // The derivers are the only toolkit modules `urban data` ever needs, and only this op runs
236
+ // them — load them here, not at module scope, so every other op (the hot path of the data
237
+ // gateway, nano-ide#592) never pays for the `src/toolkit/` tree. Imported directly from the
238
+ // deriver files (not the `toolkit/` barrel, which pulls the Node fs IO) so core stays free
239
+ // of host-specific IO.
240
+ const { GENERATED_DIR } = await import("../../../toolkit/artifact.js");
241
+ const { DOMAIN_BINDINGS, emitDomainBindings, emitDomainModel, registryFromManifest, } = await import("../../../toolkit/derivers/domain.js");
242
+ const { DOMAIN_MODEL_JSON, emitDomainModelJson, resolveShapes } = await import("../../../toolkit/derivers/shapes.js");
243
+ const { DOMAIN_DTS, emitWorkerBindings, emitWorkerBindingsRuntime, overlayDerivedWorkerIo, WORKER_BINDINGS_DTS, WORKER_BINDINGS_TS, } = await import("../../../toolkit/derivers/worker-io.js");
244
+ const { emitMessageBindings, emitMessageBindingsRuntime, MESSAGE_BINDINGS_DTS, MESSAGE_BINDINGS_TS, } = await import("../../../toolkit/derivers/messages.js");
245
+ const { emitMeta, META_TS } = await import("../../../toolkit/derivers/meta.js");
245
246
  // Introspect each sqlite source's live schema. When persisting, migrate first (the domain
246
247
  // model is derived from `gw.schema()`, so a regen that raced ahead of `migrate` on a fresh DB
247
248
  // would emit an empty `Domain`); the migration runs on the shared `_urban_migrations` ledger.
@@ -2,7 +2,6 @@ import type { RuntimeContext } from "../context.ts";
2
2
  import type { HostContext, SqliteDb } from "../host.ts";
3
3
  import type { DomainType } from "../manifest.ts";
4
4
  import { Table, type DataSource as GatewayDataSource, type InsertObserver } from "./gateway.ts";
5
- import { isAbsolutePath } from "../../../toolkit/artifact.ts";
6
5
  export declare function sqlitePathFromUrl(url: string): string;
7
6
  /** True when `p` is already an absolute path on any host `urban data` targets. `urban data` runs
8
7
  * on a Node host that may be Windows, so absolute-path detection can't assume POSIX: this matches
@@ -10,10 +9,12 @@ export declare function sqlitePathFromUrl(url: string): string;
10
9
  * that starts with a single backslash ("\data\app.db") and a Windows UNC path
11
10
  * ("\\\\server\\share"). A single leading backslash covers both the drive-root and UNC cases,
12
11
  * matching Node's `path.win32.isAbsolute`. Used by `resolveAppPath` so a caller-supplied absolute
13
- * path is never incorrectly prefixed with the app root. Defined once in `toolkit/artifact.ts` (the
14
- * shared, dependency-free layer) and re-exported here so the toolkit's gen path-join and this
15
- * runtime resolver apply the identical rule (no gen/runtime drift). */
16
- export { isAbsolutePath };
12
+ * path is never incorrectly prefixed with the app root. Defined here in the runtime (not imported
13
+ * from `toolkit/artifact.ts`) so the `urban data` gateway's static module graph never touches the
14
+ * `src/toolkit/` tree (nano-ide#592); `toolkit/artifact.ts` re-exports this definition so the
15
+ * toolkit's gen path-join and this runtime resolver apply the identical rule (no gen/runtime
16
+ * drift). */
17
+ export declare function isAbsolutePath(p: string): boolean;
17
18
  /** Resolve `p` against the app `root`: an absolute `p` (see `isAbsolutePath`) is returned as-is;
18
19
  * a relative `p` is joined onto `root`. Trims a trailing separator of either kind off `root` so
19
20
  * we never emit a doubled separator. The join separator matches the root's own style so we never
@@ -4,7 +4,6 @@
4
4
  // runtime side of ADR 0024 (datasource) + ADR 0040 (domain model), scoped to what an app needs.
5
5
  import { makeGateway, } from "./gateway.js";
6
6
  import { currentJobContext } from "../execContext.js";
7
- import { isAbsolutePath } from "../../../toolkit/artifact.js";
8
7
  import { SQL_IDENT, isReservedObjectName } from "../read-model.js";
9
8
  export function sqlitePathFromUrl(url) {
10
9
  // Accept "file:./x.db", "file:x.db", "sqlite:./x.db" or a bare path.
@@ -16,10 +15,14 @@ export function sqlitePathFromUrl(url) {
16
15
  * that starts with a single backslash ("\data\app.db") and a Windows UNC path
17
16
  * ("\\\\server\\share"). A single leading backslash covers both the drive-root and UNC cases,
18
17
  * matching Node's `path.win32.isAbsolute`. Used by `resolveAppPath` so a caller-supplied absolute
19
- * path is never incorrectly prefixed with the app root. Defined once in `toolkit/artifact.ts` (the
20
- * shared, dependency-free layer) and re-exported here so the toolkit's gen path-join and this
21
- * runtime resolver apply the identical rule (no gen/runtime drift). */
22
- export { isAbsolutePath };
18
+ * path is never incorrectly prefixed with the app root. Defined here in the runtime (not imported
19
+ * from `toolkit/artifact.ts`) so the `urban data` gateway's static module graph never touches the
20
+ * `src/toolkit/` tree (nano-ide#592); `toolkit/artifact.ts` re-exports this definition so the
21
+ * toolkit's gen path-join and this runtime resolver apply the identical rule (no gen/runtime
22
+ * drift). */
23
+ export function isAbsolutePath(p) {
24
+ return /^(\/|\\|[A-Za-z]:[/\\])/.test(p);
25
+ }
23
26
  /** Resolve `p` against the app `root`: an absolute `p` (see `isAbsolutePath`) is returned as-is;
24
27
  * a relative `p` is joined onto `root`. Trims a trailing separator of either kind off `root` so
25
28
  * we never emit a doubled separator. The join separator matches the root's own style so we never
@@ -29,7 +29,9 @@ export declare const RUNTIME_MATERIALIZED_ARTIFACTS: readonly string[];
29
29
  /** Stable ordering so artifact lists compare deterministically. */
30
30
  export declare function sortArtifacts(a: DerivedArtifact[]): DerivedArtifact[];
31
31
  /** True when `p` is an absolute path — POSIX root (`/`), a drive-letter root (`C:\` or `C:/`), or a
32
- * Windows UNC/drive-root backslash (`\`). The single source of truth for the absolute-path rule,
33
- * shared by the toolkit's path join (gen.ts) and the runtime's resolveAppPath so both agree on
34
- * whether a manifest path is app-root-relative or absolute (no gen/runtime drift). */
35
- export declare function isAbsolutePath(p: string): boolean;
32
+ * Windows UNC/drive-root backslash (`\`). The single source of truth for the absolute-path rule is
33
+ * the runtime's `resolveAppPath` side (`runtime/core/modules/datasource.ts`); it is re-exported
34
+ * here so the toolkit's path join (gen.ts) and the runtime resolver agree on whether a manifest
35
+ * path is app-root-relative or absolute (no gen/runtime drift) without the runtime's data gateway
36
+ * statically importing the toolkit tree (nano-ide#592). */
37
+ export { isAbsolutePath } from "../runtime/core/modules/datasource.ts";
@@ -28,9 +28,9 @@ export function sortArtifacts(a) {
28
28
  return [...a].sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0));
29
29
  }
30
30
  /** True when `p` is an absolute path — POSIX root (`/`), a drive-letter root (`C:\` or `C:/`), or a
31
- * Windows UNC/drive-root backslash (`\`). The single source of truth for the absolute-path rule,
32
- * shared by the toolkit's path join (gen.ts) and the runtime's resolveAppPath so both agree on
33
- * whether a manifest path is app-root-relative or absolute (no gen/runtime drift). */
34
- export function isAbsolutePath(p) {
35
- return /^(\/|\\|[A-Za-z]:[/\\])/.test(p);
36
- }
31
+ * Windows UNC/drive-root backslash (`\`). The single source of truth for the absolute-path rule is
32
+ * the runtime's `resolveAppPath` side (`runtime/core/modules/datasource.ts`); it is re-exported
33
+ * here so the toolkit's path join (gen.ts) and the runtime resolver agree on whether a manifest
34
+ * path is app-root-relative or absolute (no gen/runtime drift) without the runtime's data gateway
35
+ * statically importing the toolkit tree (nano-ide#592). */
36
+ export { isAbsolutePath } from "../runtime/core/modules/datasource.js";
@@ -19,7 +19,8 @@ function join(root, rel) {
19
19
  // pass either style without gen/runtime drift over where a file resolves.
20
20
  const norm = (s) => s.replace(/\\/g, "/");
21
21
  // An absolute `rel` resolves to itself — never prefixed with `root` — mirroring the runtime's
22
- // resolveAppPath (isAbsolutePath is the shared SoT in artifact.ts). Without this an absolute
22
+ // resolveAppPath (isAbsolutePath is the shared SoT, defined in runtime's datasource.ts and
23
+ // re-exported from artifact.ts). Without this an absolute
23
24
  // manifest path (e.g. "/abs/openapi.json") would be stripped to root-relative and gen would
24
25
  // read/derive a different file than the runtime resolves. Trailing edge separators are still
25
26
  // trimmed for stable, comparable artifact keys.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.96.0",
3
+ "version": "0.96.2",
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/bin.mjs CHANGED
@@ -3,16 +3,23 @@
3
3
  // published as compiled JS + .d.ts because Node cannot strip types for files under
4
4
  // node_modules (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so no type-stripping flag
5
5
  // is needed here.
6
+ //
7
+ // The CLI is loaded IN-PROCESS (dynamic import + runCli) rather than spawned as a second
8
+ // Node: a spawn doubled the start-up cost of every command, and the gateway runs `urban
9
+ // data` once per data op (nano-ide#589, nano-bpm#1340). dist/cli.js only auto-runs when it
10
+ // is argv[1] itself, so importing it here does not start it twice.
6
11
 
7
- import { spawnSync } from "node:child_process";
8
- import { fileURLToPath } from "node:url";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
13
  import { dirname, join } from "node:path";
10
14
 
11
15
  const cli = join(dirname(fileURLToPath(import.meta.url)), "..", "dist", "cli.js");
12
16
 
13
- const r = spawnSync(process.execPath, [cli, ...process.argv.slice(2)], { stdio: "inherit" });
14
- if (r.error) {
15
- console.error(String(r.error.message ?? r.error));
17
+ let runCli;
18
+ try {
19
+ ({ runCli } = await import(pathToFileURL(cli).href));
20
+ } catch (err) {
21
+ console.error(String(err?.message ?? err));
16
22
  process.exit(1);
17
23
  }
18
- process.exit(r.status ?? 1);
24
+ // runCli owns the exit code, stdio flushing and long-running commands (run/dev).
25
+ await runCli(process.argv.slice(2));
@@ -0,0 +1,74 @@
1
+ // The `urban` bin (`bin.mjs`) must run the compiled CLI in the SAME Node process (nano-ide#589):
2
+ // the gateway spawns `urban data` per data op, and the old spawnSync bootstrap paid two Node
3
+ // start-ups for every one. Exercises the real bin against dist/ (CI builds before testing).
4
+
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { spawn } from "node:child_process";
8
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+
13
+ const bin = fileURLToPath(new URL("./bin.mjs", import.meta.url));
14
+ const dist = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
15
+
16
+ function runBin(
17
+ args: string[],
18
+ stdin: string,
19
+ env: NodeJS.ProcessEnv,
20
+ ): Promise<{ stdout: string; code: number | null }> {
21
+ return new Promise((resolve, reject) => {
22
+ const child = spawn(process.execPath, [bin, ...args], {
23
+ stdio: ["pipe", "pipe", "inherit"],
24
+ env: { ...process.env, ...env },
25
+ });
26
+ const chunks: Buffer[] = [];
27
+ child.stdout.on("data", (c: Buffer) => chunks.push(c));
28
+ child.on("error", reject);
29
+ child.on("close", (code) => resolve({ stdout: Buffer.concat(chunks).toString("utf8"), code }));
30
+ child.stdin.end(stdin);
31
+ });
32
+ }
33
+
34
+ // `bin.mjs` is the npm bin, so it always runs under Node: skip under Deno (`test:deno`), whose
35
+ // process.execPath is not Node (same as generated-lint.test.ts).
36
+ const runtimeIsNode = !("Deno" in globalThis);
37
+
38
+ test("bin.mjs runs the CLI in-process: one Node per command, reply + exit code intact", {
39
+ skip: runtimeIsNode ? false : "Node-only: spawns bin.mjs via process.execPath (Deno's execPath is not Node)",
40
+ }, async (t) => {
41
+ if (!existsSync(dist)) {
42
+ t.skip("dist/cli.js not built (run `npm run build`); CI builds before testing");
43
+ return;
44
+ }
45
+ const dir = mkdtempSync(join(tmpdir(), "urban-bin-"));
46
+ try {
47
+ // A preload every Node process runs (NODE_OPTIONS is inherited by child Nodes), recording
48
+ // its pid. The old spawnSync bootstrap would record two.
49
+ const pids = join(dir, "pids.txt");
50
+ const preload = join(dir, "preload.mjs");
51
+ writeFileSync(
52
+ preload,
53
+ `import { appendFileSync } from "node:fs";\nappendFileSync(${JSON.stringify(pids)}, process.pid + "\\n");\n`,
54
+ );
55
+ const env = { NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` };
56
+
57
+ // An unreadable request: `urban data` still answers with a parseable envelope and exits 0.
58
+ const { stdout, code } = await runBin(["data", "--root", dir], "not json", env);
59
+ assert.equal(code, 0, `bin exited ${code}, expected 0`);
60
+ const reply: unknown = JSON.parse(stdout);
61
+ assert.ok(reply !== null && typeof reply === "object", "reply is not an object");
62
+ assert.equal(Reflect.get(reply, "ok"), false);
63
+ assert.match(String(Reflect.get(reply, "error")), /^bad request:/);
64
+
65
+ const started = readFileSync(pids, "utf8").trim().split("\n").filter(Boolean);
66
+ assert.equal(started.length, 1, `expected 1 Node process, got ${started.length}`);
67
+
68
+ // A failing command's non-zero exit code still propagates through the bin.
69
+ const unknown = await runBin(["no-such-command"], "", {});
70
+ assert.equal(unknown.code, 1);
71
+ } finally {
72
+ rmSync(dir, { recursive: true, force: true });
73
+ }
74
+ });
@@ -0,0 +1,172 @@
1
+ // Module-load laziness guard for nanobpm/nano-ide#592: the CLI used to import the whole
2
+ // toolkit (`create-urban-app`, `./toolkit/index.ts`) and the runtime barrel at the top of
3
+ // cli.ts, so EVERY command — including the per-data-op `urban data` gateway — paid the full
4
+ // load before dispatching. On small hosts that load is ~90% of a command's cost (measured:
5
+ // ~2s CPU on a 4-core Atom vs ~0.1s bare Node start-up). The fix keeps only argument parsing
6
+ // and stdio/guard helpers at the top level and dynamically imports each command's modules
7
+ // inside its handler.
8
+ //
9
+ // These tests pin the laziness per command: they spawn the real CLI entrypoint with a
10
+ // `--import` preload that registers a module-resolution hook recording every resolved URL,
11
+ // then assert which module trees the command did NOT load.
12
+
13
+ import { test } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { spawn } from "node:child_process";
16
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { fileURLToPath, pathToFileURL } from "node:url";
20
+ import { isRecord } from "./runtime/core/guards.ts";
21
+
22
+ const cli = fileURLToPath(new URL("./cli.ts", import.meta.url));
23
+
24
+ // Spawning with --experimental-strip-types runs the TypeScript source directly, so the test
25
+ // exercises the authored cli.ts (no build step needed) — same as the >64 KB stdout test above.
26
+ // bin.mjs is Node-only, so skip under Deno (`test:deno`), whose process.execPath is not Node.
27
+ const runtimeIsNode = !("Deno" in globalThis);
28
+
29
+ interface RunResult {
30
+ stdout: string;
31
+ stderr: string;
32
+ code: number | null;
33
+ resolved: string[];
34
+ }
35
+
36
+ /**
37
+ * Spawn `cli.ts <args>` with a preload that records every module URL the process resolves.
38
+ * The preload registers a resolve hook (via `module.register`) on a loader thread that
39
+ * appends each resolved URL to a file; the parent then reads that file.
40
+ */
41
+ async function runCliRecordingModules(
42
+ dir: string,
43
+ args: string[],
44
+ stdin: string,
45
+ ): Promise<RunResult> {
46
+ const resolvedFile = join(dir, "resolved.txt");
47
+ await writeFile(resolvedFile, "");
48
+ const hooks = join(dir, "hooks.mjs");
49
+ await writeFile(
50
+ hooks,
51
+ [
52
+ 'import { appendFileSync } from "node:fs";',
53
+ `const file = ${JSON.stringify(resolvedFile)};`,
54
+ "export async function resolve(specifier, context, nextResolve) {",
55
+ " const result = await nextResolve(specifier, context);",
56
+ " try { appendFileSync(file, result.url + \"\\n\"); } catch {}",
57
+ " return result;",
58
+ "}",
59
+ "",
60
+ ].join("\n"),
61
+ );
62
+ const preload = join(dir, "preload.mjs");
63
+ await writeFile(
64
+ preload,
65
+ [
66
+ 'import { register } from "node:module";',
67
+ `register(${JSON.stringify(pathToFileURL(hooks).href)});`,
68
+ "",
69
+ ].join("\n"),
70
+ );
71
+
72
+ const { stdout, stderr, code } = await new Promise<{
73
+ stdout: Buffer;
74
+ stderr: Buffer;
75
+ code: number | null;
76
+ }>((resolve, reject) => {
77
+ const child = spawn(
78
+ process.execPath,
79
+ ["--experimental-strip-types", `--import=${pathToFileURL(preload).href}`, cli, ...args],
80
+ { stdio: ["pipe", "pipe", "pipe"] },
81
+ );
82
+ const out: Buffer[] = [];
83
+ const err: Buffer[] = [];
84
+ child.stdout.on("data", (c: Buffer) => out.push(c));
85
+ child.stderr.on("data", (c: Buffer) => err.push(c));
86
+ child.on("error", reject);
87
+ child.on("close", (code) =>
88
+ resolve({ stdout: Buffer.concat(out), stderr: Buffer.concat(err), code }),
89
+ );
90
+ child.stdin.end(stdin);
91
+ });
92
+
93
+ const { readFile } = await import("node:fs/promises");
94
+ const resolved = (await readFile(resolvedFile, "utf8")).split("\n").filter((l) => l.length > 0);
95
+ return { stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8"), code, resolved };
96
+ }
97
+
98
+ /** Resolved URLs under the given package path fragments (the heavy module trees of #592). */
99
+ function heavyLoads(resolved: string[]): string[] {
100
+ return resolved.filter(
101
+ (u) =>
102
+ // Node resolves a workspace package's node_modules symlink to its real target, so in a
103
+ // monorepo checkout `create-urban-app` resolves under `packages/create-urban-app/` — match
104
+ // both the installed and the workspace-real shape or a scaffold regression slips through.
105
+ u.includes("/node_modules/create-urban-app/") ||
106
+ u.includes("/packages/create-urban-app/") ||
107
+ u.includes("/dist/toolkit/") ||
108
+ u.includes("/src/toolkit/"),
109
+ );
110
+ }
111
+
112
+ test("urban data does not load the toolkit, scaffold or gen modules", {
113
+ skip: runtimeIsNode ? false : "Node-only: spawns cli.ts via process.execPath (Deno's execPath is not Node)",
114
+ }, async () => {
115
+ const dir = await mkdtemp(join(tmpdir(), "urban-cli-lazy-data-"));
116
+ try {
117
+ // A parseable request, so cmdData gets past JSON.parse and actually runs the command-specific
118
+ // dynamic imports (host detector + data-op runner) — malformed JSON would return from the
119
+ // parse-error branch first and prove nothing about the data path's module graph. The root has
120
+ // no manifest, so the op still fails; the CLI answers with its parseable envelope and exits 0.
121
+ const { stdout, stderr, code, resolved } = await runCliRecordingModules(
122
+ dir,
123
+ ["data", "--root", dir],
124
+ JSON.stringify({ op: "sources" }),
125
+ );
126
+ assert.equal(code, 0, `CLI exited ${code}, expected 0. stderr:\n${stderr}`);
127
+ const reply: unknown = JSON.parse(stdout);
128
+ assert.ok(isRecord(reply));
129
+ assert.equal(reply.ok, false);
130
+
131
+ const heavy = heavyLoads(resolved);
132
+ assert.deepEqual(
133
+ heavy,
134
+ [],
135
+ `urban data eagerly loaded toolkit/scaffold modules:\n${heavy.join("\n")}`,
136
+ );
137
+ } finally {
138
+ await rm(dir, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ test("urban --version loads neither the toolkit nor the runtime modules", {
143
+ skip: runtimeIsNode ? false : "Node-only: spawns cli.ts via process.execPath (Deno's execPath is not Node)",
144
+ }, async () => {
145
+ const dir = await mkdtemp(join(tmpdir(), "urban-cli-lazy-version-"));
146
+ try {
147
+ const { stdout, stderr, code, resolved } = await runCliRecordingModules(dir, ["--version"], "");
148
+ assert.equal(code, 0, `CLI exited ${code}, expected 0. stderr:\n${stderr}`);
149
+ assert.match(stdout, /^\d+\.\d+\.\d+/);
150
+
151
+ // The only runtime-tree modules the CLI itself may touch eagerly are its two tiny
152
+ // stdio/guard helpers — everything else under runtime/ (the barrel, run.ts, the data-op
153
+ // modules, the engine adapters, …) must stay unloaded until a command needs it.
154
+ const allowed = new Set(["/src/runtime/adapters/globals.ts", "/src/runtime/core/guards.ts"]);
155
+ const heavy = resolved.filter(
156
+ (u) =>
157
+ u.includes("/node_modules/create-urban-app/") ||
158
+ u.includes("/packages/create-urban-app/") ||
159
+ u.includes("/dist/toolkit/") ||
160
+ u.includes("/src/toolkit/") ||
161
+ ((u.includes("/dist/runtime/") || u.includes("/src/runtime/")) &&
162
+ ![...allowed].some((a) => u.endsWith(a))),
163
+ );
164
+ assert.deepEqual(
165
+ heavy,
166
+ [],
167
+ `urban --version eagerly loaded toolkit/runtime modules:\n${heavy.join("\n")}`,
168
+ );
169
+ } finally {
170
+ await rm(dir, { recursive: true, force: true });
171
+ }
172
+ });
package/src/cli.ts CHANGED
@@ -11,22 +11,18 @@
11
11
  //
12
12
  // Global flags: --root <dir> (default "."), --manifest <file> (default nano.app.json),
13
13
  // --port <n>, -h/--help, -v/--version.
14
+ //
15
+ // Module-load laziness (nano-ide#592): only argument parsing, the stdio/guard helpers and the
16
+ // dispatcher load eagerly. Every command's modules are dynamically imported inside its handler —
17
+ // a top-level import of the toolkit, the scaffold package or the runtime barrel made EVERY
18
+ // command (including the per-data-op `urban data` gateway) pay the full ~2s load on small
19
+ // hosts before dispatching. `urban data` loads only the host detector and the data-op runner.
20
+ // Guarded by cli-lazy.test.ts, which records the URLs each command resolves.
14
21
 
15
- import {
16
- collectManifestIssues,
17
- installSignalHandlers,
18
- loadManifest,
19
- runDataOp,
20
- runDev,
21
- runFromEnv,
22
- selectHost,
23
- } from "./runtime/index.ts";
24
22
  import { denoGlobal, processGlobal } from "./runtime/adapters/globals.ts";
25
23
  import type { WritableStdio } from "./runtime/adapters/globals.ts";
26
24
  import { errorMessage, isRecord } from "./runtime/core/guards.ts";
27
- import { scaffold, slugify } from "create-urban-app";
28
- import type { DataRequest } from "./runtime/index.ts";
29
- import { addConnector, createNodeGenIO, previewModels, generate, runGen } from "./toolkit/index.ts";
25
+ import type { DataRequest } from "./runtime/core/modules/dataops.ts";
30
26
  import { pathToFileURL } from "node:url";
31
27
  import { readFileSync } from "node:fs";
32
28
  import { spawnSync } from "node:child_process";
@@ -132,6 +128,9 @@ Engine address: $CAMUNDA_REST_ADDRESS (default http://localhost:8080/v2).
132
128
  `;
133
129
 
134
130
  async function cmdCheck(f: Flags): Promise<number> {
131
+ const { selectHost } = await import("./runtime/adapters/detect.ts");
132
+ const { loadManifest } = await import("./runtime/core/manifest.ts");
133
+ const { collectManifestIssues } = await import("./runtime/core/validate.ts");
135
134
  // The host is anchored at f.root, so manifest paths are already root-relative —
136
135
  // pass the bare manifest filename (prefixing root again would double it).
137
136
  const host = selectHost({ cwd: f.root });
@@ -147,6 +146,7 @@ async function cmdCheck(f: Flags): Promise<number> {
147
146
  }
148
147
 
149
148
  async function cmdGen(f: Flags): Promise<number> {
149
+ const { createNodeGenIO, generate } = await import("./toolkit/index.ts");
150
150
  const io = createNodeGenIO();
151
151
  const res = await generate({ root: f.root, io, manifestFile: f.manifest, check: f.check, emitModels: f.models });
152
152
  if (res.incomplete) {
@@ -209,6 +209,7 @@ async function cmdDerive(f: Flags): Promise<number> {
209
209
  );
210
210
  return 2;
211
211
  }
212
+ const { createNodeGenIO, previewModels, runGen } = await import("./toolkit/index.ts");
212
213
  const io = createNodeGenIO();
213
214
 
214
215
  // Non-writing preview: emit `{ models: [{ id, kind, xml }], incomplete }` to stdout for the
@@ -250,6 +251,7 @@ async function cmdDerive(f: Flags): Promise<number> {
250
251
  }
251
252
 
252
253
  async function cmdRun(f: Flags, mount?: Record<string, boolean>): Promise<number> {
254
+ const { runFromEnv } = await import("./runtime/run.ts");
253
255
  const app = await runFromEnv({
254
256
  root: f.root,
255
257
  manifestPath: f.manifest,
@@ -262,12 +264,15 @@ async function cmdRun(f: Flags, mount?: Record<string, boolean>): Promise<number
262
264
  }
263
265
 
264
266
  async function cmdDev(f: Flags): Promise<number> {
267
+ const { runDev } = await import("./runtime/devserver.ts");
268
+ const { installSignalHandlers } = await import("./runtime/run.ts");
265
269
  const dev = await runDev({ root: f.root, manifestPath: f.manifest, port: f.port });
266
270
  installSignalHandlers(() => dev.stop());
267
271
  return -1; // keep the process alive; signal handlers stop the dev server
268
272
  }
269
273
 
270
274
  async function cmdDeploy(f: Flags): Promise<number> {
275
+ const { runFromEnv } = await import("./runtime/run.ts");
271
276
  const app = await runFromEnv({
272
277
  root: f.root,
273
278
  manifestPath: f.manifest,
@@ -322,6 +327,12 @@ export async function cmdData(f: Flags, readInput: () => Promise<string> = readS
322
327
  );
323
328
  return 0;
324
329
  }
330
+ // Lazily the only modules `urban data` loads (nano-ide#592): the host detector (both
331
+ // adapters, no toolkit) and the data-op runner (gateway + SQLite adapter; the toolkit
332
+ // derivers behind the `domaintypes` op load only when that op is dispatched). Imported
333
+ // directly rather than via the runtime barrel, which would pull in the whole runtime.
334
+ const { selectHost } = await import("./runtime/adapters/detect.ts");
335
+ const { runDataOp } = await import("./runtime/core/modules/dataops.ts");
325
336
  const host = selectHost({ cwd: f.root });
326
337
  try {
327
338
  // Mirror runFromEnv: the host is anchored at f.root, so pass root "." to avoid
@@ -342,6 +353,7 @@ async function cmdNew(f: Flags): Promise<number> {
342
353
  console.error("usage: urban new <name> [--root <path>] [--deno] [--style model|code] (or --code-first)");
343
354
  return 1;
344
355
  }
356
+ const { scaffold, slugify } = await import("create-urban-app");
345
357
  const dir = f.root !== "." ? f.root : `./${slugify(name)}`;
346
358
  const res = await scaffold({ name, dir, deno: f.deno, style: f.style });
347
359
  console.log(`✔ scaffolded "${res.id}" in ${res.dir} (${res.files.length} files)`);
@@ -380,6 +392,7 @@ async function cmdAdd(f: Flags): Promise<number> {
380
392
  }
381
393
 
382
394
  try {
395
+ const { addConnector, createNodeGenIO } = await import("./toolkit/index.ts");
383
396
  const result = await addConnector({
384
397
  root: f.root,
385
398
  pkg,
@@ -472,22 +485,21 @@ async function flushStdio(): Promise<void> {
472
485
  await Promise.all([flushStream(p.stdout), flushStream(p.stderr)]);
473
486
  }
474
487
 
475
- const proc = processGlobal();
476
- const deno = denoGlobal();
477
- const importMetaMain: unknown = Reflect.get(import.meta, "main");
478
- const argv1 = proc?.argv?.[1];
479
- const nodeMain = argv1 ? import.meta.url === pathToFileURL(argv1).href : false;
480
- if (importMetaMain === true || nodeMain) {
481
- // Node passes args via process.argv (slice off exec+script); Deno (run directly)
482
- // exposes them on Deno.args. Prefer whichever actually carries args.
483
- const fromNode = proc?.argv?.slice(2);
484
- const fromDeno = deno?.args;
485
- const argv = (fromNode && fromNode.length ? fromNode : fromDeno) ?? fromNode ?? [];
488
+ /**
489
+ * Run the CLI as the process entrypoint: `main(argv)`, then drain stdio and exit with its code.
490
+ * A negative code means a long-running command (`run`, `dev`) owns the process, so it is left
491
+ * alive. Exported so the `urban` bin (`bin.mjs`) can run the CLI **in-process** via `import()`
492
+ * instead of spawning a second Node (nano-ide#589): every `urban` command, notably each
493
+ * gateway `urban data` op, used to pay two Node start-ups.
494
+ */
495
+ export function runCli(argv: string[]): Promise<void> {
496
+ const proc = processGlobal();
497
+ const deno = denoGlobal();
486
498
  const exit = (code: number) => {
487
499
  if (proc?.exit) proc.exit(code);
488
500
  else if (deno?.exit) deno.exit(code);
489
501
  };
490
- main(argv).then(
502
+ return main(argv).then(
491
503
  async (code) => {
492
504
  if (code >= 0) {
493
505
  await flushStdio();
@@ -501,3 +513,17 @@ if (importMetaMain === true || nodeMain) {
501
513
  },
502
514
  );
503
515
  }
516
+
517
+ const proc = processGlobal();
518
+ const deno = denoGlobal();
519
+ const importMetaMain: unknown = Reflect.get(import.meta, "main");
520
+ const argv1 = proc?.argv?.[1];
521
+ const nodeMain = argv1 ? import.meta.url === pathToFileURL(argv1).href : false;
522
+ if (importMetaMain === true || nodeMain) {
523
+ // Node passes args via process.argv (slice off exec+script); Deno (run directly)
524
+ // exposes them on Deno.args. Prefer whichever actually carries args.
525
+ const fromNode = proc?.argv?.slice(2);
526
+ const fromDeno = deno?.args;
527
+ const argv = (fromNode && fromNode.length ? fromNode : fromDeno) ?? fromNode ?? [];
528
+ void runCli(argv);
529
+ }
@@ -27,43 +27,17 @@ import { validateManifest } from "../validate.ts";
27
27
  import { makeGateway, type DataSource as GatewayDataSource } from "./gateway.ts";
28
28
  import { applyMigrations, MIGRATIONS_TABLE, openSqliteSource, resolveAppPath } from "./datasource.ts";
29
29
  // The `domaintypes` op composes the domain reifier from the pure toolkit derivers (single source of
30
- // truth for the IDE's codegen, host dry-out nano-bpm#576). Imported directly from the deriver files
31
- // (not the `toolkit/` barrel, which pulls the Node fs IO) so core stays free of `node:*`/Deno; the
32
- // derivers' only core dependency is the gateway's `TableMeta`/`ColumnMeta` *types*, which erase.
33
- import { GENERATED_DIR } from "../../../toolkit/artifact.ts";
34
- import {
35
- DOMAIN_BINDINGS,
36
- type DomainTypeDef,
37
- type DomainTypeRegistry,
38
- emitDomainBindings,
39
- emitDomainModel,
40
- registryFromManifest,
41
- type SourceSchema,
42
- } from "../../../toolkit/derivers/domain.ts";
43
- import {
44
- DOMAIN_MODEL_JSON,
45
- emitDomainModelJson,
46
- type FusedMetaDecl,
47
- resolveShapes,
48
- type ShapeDecl,
49
- } from "../../../toolkit/derivers/shapes.ts";
50
- import {
51
- DOMAIN_DTS,
52
- emitWorkerBindings,
53
- emitWorkerBindingsRuntime,
54
- overlayDerivedWorkerIo,
55
- WORKER_BINDINGS_DTS,
56
- WORKER_BINDINGS_TS,
57
- type WorkerBindingDecl,
58
- } from "../../../toolkit/derivers/worker-io.ts";
59
- import {
60
- emitMessageBindings,
61
- emitMessageBindingsRuntime,
62
- MESSAGE_BINDINGS_DTS,
63
- MESSAGE_BINDINGS_TS,
64
- type MessageBindingDecl,
65
- } from "../../../toolkit/derivers/messages.ts";
66
- import { emitMeta, META_TS, type MetaDecl } from "../../../toolkit/derivers/meta.ts";
30
+ // truth for the IDE's codegen, host dry-out nano-bpm#576). They are imported lazily inside the
31
+ // `domaintypes` case — a static import would make EVERY `urban data` op (the latency-sensitive
32
+ // gateway path, nano-ide#592) load the whole `src/toolkit/` tree before dispatching, when only
33
+ // `domaintypes` ever runs a deriver. The derivers are pure (no host-specific IO); their only core
34
+ // dependency is the gateway's `TableMeta`/`ColumnMeta` *types*, which erase. The deriver *types* the
35
+ // request carries are type-only imports (erased), so they cost nothing at module load.
36
+ import type { DomainTypeDef, DomainTypeRegistry, SourceSchema } from "../../../toolkit/derivers/domain.ts";
37
+ import type { FusedMetaDecl, ShapeDecl } from "../../../toolkit/derivers/shapes.ts";
38
+ import type { WorkerBindingDecl } from "../../../toolkit/derivers/worker-io.ts";
39
+ import type { MessageBindingDecl } from "../../../toolkit/derivers/messages.ts";
40
+ import type { MetaDecl } from "../../../toolkit/derivers/meta.ts";
67
41
 
68
42
  /** The DB-manager op protocol's known op set — every op (including `domaintypes`) is dispatched by
69
43
  * `runDataOp`. Typing `op` as this union (instead of `string`) gives SDK/console callers type-safety
@@ -349,6 +323,36 @@ export async function runDataOp(
349
323
  // debounced keystroke has no DB side-effect and pays only for `text` + `shapeDiagnostics`.
350
324
  const { default: def, sources } = listSources(manifest);
351
325
  const persist = req.write !== false;
326
+ // The derivers are the only toolkit modules `urban data` ever needs, and only this op runs
327
+ // them — load them here, not at module scope, so every other op (the hot path of the data
328
+ // gateway, nano-ide#592) never pays for the `src/toolkit/` tree. Imported directly from the
329
+ // deriver files (not the `toolkit/` barrel, which pulls the Node fs IO) so core stays free
330
+ // of host-specific IO.
331
+ const { GENERATED_DIR } = await import("../../../toolkit/artifact.ts");
332
+ const {
333
+ DOMAIN_BINDINGS,
334
+ emitDomainBindings,
335
+ emitDomainModel,
336
+ registryFromManifest,
337
+ } = await import("../../../toolkit/derivers/domain.ts");
338
+ const { DOMAIN_MODEL_JSON, emitDomainModelJson, resolveShapes } = await import(
339
+ "../../../toolkit/derivers/shapes.ts"
340
+ );
341
+ const {
342
+ DOMAIN_DTS,
343
+ emitWorkerBindings,
344
+ emitWorkerBindingsRuntime,
345
+ overlayDerivedWorkerIo,
346
+ WORKER_BINDINGS_DTS,
347
+ WORKER_BINDINGS_TS,
348
+ } = await import("../../../toolkit/derivers/worker-io.ts");
349
+ const {
350
+ emitMessageBindings,
351
+ emitMessageBindingsRuntime,
352
+ MESSAGE_BINDINGS_DTS,
353
+ MESSAGE_BINDINGS_TS,
354
+ } = await import("../../../toolkit/derivers/messages.ts");
355
+ const { emitMeta, META_TS } = await import("../../../toolkit/derivers/meta.ts");
352
356
  // Introspect each sqlite source's live schema. When persisting, migrate first (the domain
353
357
  // model is derived from `gw.schema()`, so a regen that raced ahead of `migrate` on a fresh DB
354
358
  // would emit an empty `Domain`); the migration runs on the shared `_urban_migrations` ledger.
@@ -13,7 +13,6 @@ import {
13
13
  type InsertObserver,
14
14
  } from "./gateway.ts";
15
15
  import { currentJobContext } from "../execContext.ts";
16
- import { isAbsolutePath } from "../../../toolkit/artifact.ts";
17
16
  import { SQL_IDENT, isReservedObjectName } from "../read-model.ts";
18
17
 
19
18
  export function sqlitePathFromUrl(url: string): string {
@@ -27,10 +26,14 @@ export function sqlitePathFromUrl(url: string): string {
27
26
  * that starts with a single backslash ("\data\app.db") and a Windows UNC path
28
27
  * ("\\\\server\\share"). A single leading backslash covers both the drive-root and UNC cases,
29
28
  * matching Node's `path.win32.isAbsolute`. Used by `resolveAppPath` so a caller-supplied absolute
30
- * path is never incorrectly prefixed with the app root. Defined once in `toolkit/artifact.ts` (the
31
- * shared, dependency-free layer) and re-exported here so the toolkit's gen path-join and this
32
- * runtime resolver apply the identical rule (no gen/runtime drift). */
33
- export { isAbsolutePath };
29
+ * path is never incorrectly prefixed with the app root. Defined here in the runtime (not imported
30
+ * from `toolkit/artifact.ts`) so the `urban data` gateway's static module graph never touches the
31
+ * `src/toolkit/` tree (nano-ide#592); `toolkit/artifact.ts` re-exports this definition so the
32
+ * toolkit's gen path-join and this runtime resolver apply the identical rule (no gen/runtime
33
+ * drift). */
34
+ export function isAbsolutePath(p: string): boolean {
35
+ return /^(\/|\\|[A-Za-z]:[/\\])/.test(p);
36
+ }
34
37
 
35
38
  /** Resolve `p` against the app `root`: an absolute `p` (see `isAbsolutePath`) is returned as-is;
36
39
  * a relative `p` is joined onto `root`. Trims a trailing separator of either kind off `root` so
@@ -48,9 +48,9 @@ export function sortArtifacts(a: DerivedArtifact[]): DerivedArtifact[] {
48
48
  }
49
49
 
50
50
  /** True when `p` is an absolute path — POSIX root (`/`), a drive-letter root (`C:\` or `C:/`), or a
51
- * Windows UNC/drive-root backslash (`\`). The single source of truth for the absolute-path rule,
52
- * shared by the toolkit's path join (gen.ts) and the runtime's resolveAppPath so both agree on
53
- * whether a manifest path is app-root-relative or absolute (no gen/runtime drift). */
54
- export function isAbsolutePath(p: string): boolean {
55
- return /^(\/|\\|[A-Za-z]:[/\\])/.test(p);
56
- }
51
+ * Windows UNC/drive-root backslash (`\`). The single source of truth for the absolute-path rule is
52
+ * the runtime's `resolveAppPath` side (`runtime/core/modules/datasource.ts`); it is re-exported
53
+ * here so the toolkit's path join (gen.ts) and the runtime resolver agree on whether a manifest
54
+ * path is app-root-relative or absolute (no gen/runtime drift) without the runtime's data gateway
55
+ * statically importing the toolkit tree (nano-ide#592). */
56
+ export { isAbsolutePath } from "../runtime/core/modules/datasource.ts";
@@ -76,7 +76,8 @@ function join(root: string, rel: string): string {
76
76
  // pass either style without gen/runtime drift over where a file resolves.
77
77
  const norm = (s: string): string => s.replace(/\\/g, "/");
78
78
  // An absolute `rel` resolves to itself — never prefixed with `root` — mirroring the runtime's
79
- // resolveAppPath (isAbsolutePath is the shared SoT in artifact.ts). Without this an absolute
79
+ // resolveAppPath (isAbsolutePath is the shared SoT, defined in runtime's datasource.ts and
80
+ // re-exported from artifact.ts). Without this an absolute
80
81
  // manifest path (e.g. "/abs/openapi.json") would be stripped to root-relative and gen would
81
82
  // read/derive a different file than the runtime resolves. Trailing edge separators are still
82
83
  // trimmed for stable, comparable artifact keys.