@volter/world-runtime 3.0.144 → 3.0.145

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.
@@ -14,10 +14,31 @@ export function survivingOwnedGroups(pids) {
14
14
  }
15
15
  });
16
16
  if (!candidates.length || process.platform === 'win32') return candidates;
17
- const result = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], { windowsHide: true,
18
- encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024,
19
- });
20
- if (result.status !== 0 || result.error) return candidates;
17
+ const options = { windowsHide: true, encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024 };
18
+ const result = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], options);
19
+ // When the whole-table listing does not answer (a non-zero status or an error; which of the two it was in the
20
+ // run this was written for, Cal build 43, was not read, and no measurement says why), every candidate would read
21
+ // as live, a group of zombies among them. Where the system can list one group by its leader (Darwin's `ps -g`: "processes with the
22
+ // specified process group leaders"), each candidate is asked for by itself, which does not walk the table; a
23
+ // group that cannot be listed either stays live.
24
+ if (result.status !== 0 || result.error) {
25
+ if (process.platform !== 'darwin') return candidates;
26
+ return candidates.filter(pid => {
27
+ const own = spawnSync('ps', ['-o', 'pid=,pgid=,stat=', '-g', String(pid)], options);
28
+ // Status 1 with no row is `ps` saying the group has no process; any other failure says nothing.
29
+ if (own.error || own.signal || (own.status !== 0 && !(own.status === 1 && !own.stdout.trim()))) return true;
30
+ const members = own.stdout.trim() ? own.stdout.trim().split('\n').map(line => line.trim().split(/\s+/)) : [];
31
+ // Visibility restrictions can omit a live group. Only ESRCH proves absence.
32
+ if (!members.length) {
33
+ for (const target of [-pid, pid]) {
34
+ try { process.kill(target, 0); return true; }
35
+ catch (error) { if (error.code !== 'ESRCH') return true; }
36
+ }
37
+ return false;
38
+ }
39
+ return members.some(([, , state]) => !state || !state.startsWith('Z'));
40
+ });
41
+ }
21
42
  const rows = result.stdout.trim().split('\n').map(line => line.trim().split(/\s+/));
22
43
  return candidates.filter(pid => {
23
44
  const members = rows.filter(([id, group]) => Number(group) === pid || Number(id) === pid);
@@ -1,6 +1,7 @@
1
- import { spawn } from 'node:child_process';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ import { survivingOwnedGroups } from './process-groups.mjs';
4
5
 
5
6
  // Retain the existing 1000 ms inspection ceiling, not a new timeout chosen from one sample.
6
7
  // Measured 2026-10-03 on shared Darwin 25.4.0 ARM64, Bun 1.3.11 (process.version
@@ -33,7 +34,24 @@ function birthProbe(pid) {
33
34
  },
34
35
  };
35
36
  }
36
- /** No subprocess on Linux; elsewhere the lookup never blocks the lifecycle event loop. */
37
+ /** What a probe that could not answer says, once for each distinct outcome in this process: its command, error code,
38
+ * exit, signal, timeout and branch. It is said, never thrown (see processBirth). */
39
+ const saidProbeFailures = new Set();
40
+ function sayProbeFailure(probe, error, code, signal, timedOut, reason) {
41
+ const line = `Process birth probe ${probe.command} failed (${reason}): errorCode=${error?.code ?? 'none'}, exit=${code ?? 'null'}, signal=${signal ?? 'null'}, timedOut=${timedOut}`;
42
+ if (saidProbeFailures.has(line)) return;
43
+ saidProbeFailures.add(line);
44
+ try { process.stderr.write(`[birth] ${line}; the birth is unknown (first asked of pid ${probe.args.at(-1)})\n`); } catch { /* an instrument */ }
45
+ }
46
+ /** A pid's birth, or undefined: no process has that pid, or the probe could not answer. A probe that cannot run is
47
+ * a birth unknown, never a thrown lifecycle error: spawn throws synchronously where the process may not exec `ps` at
48
+ * all (setuid root on macOS, which a sandbox-exec profile refuses with EPERM: an image build's prepare exited 1 for
49
+ * it), and reports other failures as 'error', a timeout, another exit or output that is not a birth. Every command
50
+ * the runtime runs captures its child's identity through this, and `down` asks it of every recorded pid, so a
51
+ * rejection here would fail a command that succeeded and refuse a whole `down`. Ownership is decided elsewhere and
52
+ * never from an unknown: verifySpawnIdentity refuses to retire a live pid whose birth it cannot read, and each
53
+ * spawner that needs a birth refuses a child it captured none for. What the probe met is said once on stderr.
54
+ * No subprocess on Linux; elsewhere the lookup never blocks the lifecycle event loop. */
37
55
  export async function processBirth(pid) {
38
56
  if (!Number.isInteger(pid) || pid <= 0) return undefined; // a pid enters a probe's command only as an integer
39
57
  if (process.platform === 'linux') {
@@ -45,29 +63,43 @@ export async function processBirth(pid) {
45
63
  }
46
64
  const probe = birthProbe(pid);
47
65
  return new Promise(resolve => {
48
- // A probe that cannot run is a birth unknown, never a lifecycle failure: spawn throws synchronously where the
49
- // process may not exec `ps` at all (setuid root on macOS, which a sandbox-exec profile refuses: EPERM), and reports
50
- // other failures as 'error' (below). Either way the identity is the pid without a start time.
51
66
  let child;
52
67
  try {
53
68
  child = spawn(probe.command, probe.args, {
54
69
  windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'],
55
70
  env: { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' },
56
71
  });
57
- } catch {
72
+ } catch (error) {
73
+ sayProbeFailure(probe, error, null, null, false, 'spawn threw');
58
74
  resolve(undefined);
59
75
  return;
60
76
  }
61
77
  let output = '', settled = false;
62
- const finish = value => { if (!settled) { settled = true; clearTimeout(timer); child.stdout.destroy(); resolve(value); } };
78
+ const finish = (value, error, code, signal, timedOut, reason) => {
79
+ if (settled) return;
80
+ settled = true;
81
+ clearTimeout(timer);
82
+ child.stdout.destroy();
83
+ if (value === undefined) sayProbeFailure(probe, error, code, signal, timedOut, reason);
84
+ resolve(value);
85
+ };
63
86
  // This is our still-held inspector, not a PID from a receipt.
64
87
  const timer = setTimeout(() => {
65
88
  try { signalChild(child, 'SIGKILL'); } catch { /* failed inspection remains unavailable */ }
66
- finish(undefined); // an exited wrapper's descendant may keep stdout open indefinitely
89
+ finish(undefined, undefined, child.exitCode, child.signalCode, true, 'timeout'); // a descendant may keep stdout open
67
90
  }, BIRTH_LOOKUP_MS);
68
91
  child.stdout.on('data', chunk => { output += chunk; });
69
- child.once('error', () => finish(undefined));
70
- child.once('close', code => finish(code === 0 ? probe.read(output.trim()) : undefined));
92
+ child.once('error', error => finish(undefined, error, child.exitCode, child.signalCode, false, 'spawn error'));
93
+ child.once('close', (code, signal) => {
94
+ // No process by that pid is an answer, not a failed inspection, and is not said. `ps -p <pid>` (and the Windows
95
+ // probe) says it by ending on its own with status 1 and no row: read on Darwin 25.4, `ps -o pid=,lstart= -p <an
96
+ // exited pid>` prints nothing and exits 1; its manual gives `-p` no other way to say it. Everything else is
97
+ // the probe failing: it could not be spawned, was killed, ran out of time, exited otherwise, or printed
98
+ // something that is not a birth. That is said once and is also undefined.
99
+ if (code === 1 && signal === null && output.trim() === '') { if (!settled) { settled = true; clearTimeout(timer); child.stdout.destroy(); resolve(undefined); } return; }
100
+ finish(code === 0 ? probe.read(output.trim()) : undefined, undefined, code, signal, false,
101
+ code === 0 ? 'unrecognized birth output' : 'nonzero exit');
102
+ });
71
103
  });
72
104
  }
73
105
  /** Capture at spawn for durable ownership, never when retrying a retained PID. */
@@ -96,11 +128,28 @@ export async function verifySpawnIdentity(identity) {
96
128
  throw new Error(`Process ${identity.pid} identity cannot be established; ownership retained`);
97
129
  }
98
130
  }
131
+ /** A signal the system refused has nothing left to reach when the process and its group are gone or are zombies
132
+ * only: a zombie holds nothing and takes no signal, and a group of them is retired (Cal build 43: "kill EPERM"
133
+ * after the postmaster had shut down). That is read from the process table (`survivingOwnedGroups`), never from
134
+ * the error's code; where the table cannot be read the group counts as live and the refusal is thrown. */
135
+ function nothingLeftToSignal(pid) {
136
+ try { return survivingOwnedGroups([pid]).length === 0; } catch { return false; }
137
+ }
138
+ /** An instrument: a signal that failed with anything but ESRCH says what it targeted and who is in that group, then
139
+ * is thrown as before (Cal build 43: "kill EPERM" after the postmaster's clean shutdown). It changes no outcome. */
140
+ function saySignalFailure(error, target, signal, child) {
141
+ try {
142
+ const rows = process.platform === 'win32' ? '' : spawnSync('ps', ['-o', 'pid=,pgid=,stat=,lstart=,command=', '-g', String(Math.abs(target))],
143
+ { encoding: 'utf8', timeout: BIRTH_LOOKUP_MS, killSignal: 'SIGKILL' }).stdout ?? '';
144
+ process.stderr.write(`[signal] ${error.code} kill(${target}, ${signal}) exitCode=${child?.exitCode} signalCode=${child?.signalCode} group: ${rows.trim() || 'no rows'}\n`);
145
+ } catch { /* the failure itself is still thrown by the caller */ }
146
+ }
99
147
  /** Stored or retained numbers use birth verification before every delivery. */
100
148
  export async function signalOwnedProcess(identity, signal, group = false) {
101
149
  await verifySpawnIdentity(identity);
102
- try { return process.kill(group && process.platform !== 'win32' ? -identity.pid : identity.pid, signal); }
103
- catch (error) { if (error.code === 'ESRCH') return false; throw error; }
150
+ const target = group && process.platform !== 'win32' ? -identity.pid : identity.pid;
151
+ try { return process.kill(target, signal); }
152
+ catch (error) { if (error.code === 'ESRCH' || nothingLeftToSignal(identity.pid)) return false; saySignalFailure(error, target, signal); throw error; }
104
153
  }
105
154
  /** A live ChildProcess is ownership until exit. A group additionally needs its leader alive.
106
155
  * False means no held delivery was possible; a caller with surviving descendants needs a birth. */
@@ -108,9 +157,9 @@ export function signalChild(child, signal, group = false) {
108
157
  if (!child.pid || child.exitCode !== null || child.signalCode !== null) return false;
109
158
  if (!group || process.platform === 'win32') return child.kill(signal);
110
159
  try { process.kill(child.pid, 0); }
111
- catch (error) { if (error.code === 'ESRCH') return false; throw error; }
160
+ catch (error) { if (error.code === 'ESRCH' || nothingLeftToSignal(child.pid)) return false; saySignalFailure(error, child.pid, 0, child); throw error; }
112
161
  try { return process.kill(-child.pid, signal); }
113
- catch (error) { if (error.code === 'ESRCH') return false; throw error; }
162
+ catch (error) { if (error.code === 'ESRCH' || nothingLeftToSignal(child.pid)) return false; saySignalFailure(error, -child.pid, signal, child); throw error; }
114
163
  }
115
164
 
116
165
  /** Held first; an orphaned group then becomes retained numeric ownership. */
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
2
  import { parse as parseToml } from 'smol-toml';
3
- import { basename, relative, resolve } from 'node:path';
3
+ import { basename, dirname, relative, resolve } from 'node:path';
4
4
  import { normalizePypiName, overlayPypiTwins, overlaySdkTwins } from './pack-facts.ts';
5
5
 
6
6
  export type ProjectInspection = {
@@ -628,6 +628,43 @@ export function projectConnections(input = process.cwd(), attributedEnv = new Se
628
628
  * env heuristic. Only direct `fetch("https://...")`, `window.fetch`, and `globalThis.fetch` calls
629
629
  * count. Dynamic destinations remain intentionally unclaimed; guessing from arbitrary URL strings
630
630
  * would turn docs, links, and webhook examples into false external-service dependencies. */
631
+ /** A migration tool an application carries, by its evidence in the repository (architecture, World wiring). */
632
+ export interface ProjectMigrationTool {
633
+ tool: string;
634
+ /** the tool's schema, and its migrations directory, relative to the repository */
635
+ schema: string;
636
+ migrations: string;
637
+ }
638
+
639
+ /**
640
+ * The migration tools `init` can notice from a repository: the tool as a dependency, and its own migrations beside
641
+ * its schema. One row per tool; the next tool is a row. What a World's `runtime.database.prepare` needs (the command
642
+ * as the image runs it, the image path it runs in, the inputs' paths there) is the IMAGE's, which a repository does
643
+ * not state: a Dockerfile's prune and copies decide it. So this names the tool and where its migrations are, never
644
+ * the step.
645
+ */
646
+ const MIGRATION_TOOLS: Array<{ tool: string; dependency: string; schemaExtension: string; migrations: string }> = [
647
+ { tool: 'prisma', dependency: 'prisma', schemaExtension: '.prisma', migrations: 'migrations' },
648
+ ];
649
+
650
+ /** Each migration tool the repository depends on that has migrations beside its schema (or beside its schema's folder). */
651
+ export function projectMigrationTools(input = process.cwd()): ProjectMigrationTool[] {
652
+ const root = resolve(input);
653
+ const dependencies = new Set(projectDependencies(root));
654
+ const found = new Map<string, ProjectMigrationTool>();
655
+ for (const row of MIGRATION_TOOLS) {
656
+ if (!dependencies.has(row.dependency)) continue;
657
+ for (const schema of projectSourceFiles(root, new Set([row.schemaExtension]))) {
658
+ const beside = [dirname(schema), dirname(dirname(schema))].map((dir) => resolve(dir, row.migrations))
659
+ .find((dir) => existsSync(dir) && statSync(dir).isDirectory());
660
+ if (!beside) continue;
661
+ const migrations = relative(root, beside);
662
+ if (!found.has(`${row.tool}\0${migrations}`)) found.set(`${row.tool}\0${migrations}`, { tool: row.tool, schema: relative(root, schema), migrations });
663
+ }
664
+ }
665
+ return [...found.values()].sort((a, b) => a.migrations.localeCompare(b.migrations));
666
+ }
667
+
631
668
  export function projectFetchUrls(input = process.cwd()): ProjectFetchUrl[] {
632
669
  const root = resolve(input);
633
670
  const found: ProjectFetchUrl[] = [];
package/src/runtime.ts CHANGED
@@ -1316,6 +1316,18 @@ async function startExternalService(
1316
1316
  }
1317
1317
  injected[mapping.as] = value;
1318
1318
  }
1319
+ // The World's own infrastructure also says what the app's commands are told of its database (architecture,
1320
+ // managed infrastructure): that the live data carries the image's migration, and that this is a development
1321
+ // World whose app migrates when it runs.
1322
+ if (ownInfrastructureUp(external.up) && statusOutput) {
1323
+ let facts: { database?: { migrated?: unknown; behind?: unknown }; development?: { migrateDatabaseAtRuntime?: unknown } } | undefined;
1324
+ try { facts = JSON.parse(statusOutput) as typeof facts; } catch { /* a status that is not JSON says neither */ }
1325
+ const migrated = facts?.database?.migrated;
1326
+ if (Array.isArray(migrated) && migrated.every((word) => typeof word === 'string')) injected.VOLTER_WORLD_DATABASE_MIGRATED = JSON.stringify(migrated);
1327
+ const behind = facts?.database?.behind;
1328
+ if (Array.isArray(behind) && behind.every((word) => typeof word === 'string')) injected.VOLTER_WORLD_DATABASE_BEHIND = JSON.stringify(behind);
1329
+ if (facts?.development?.migrateDatabaseAtRuntime === true) injected.VOLTER_WORLD_DEVELOPMENT_MIGRATE_AT_RUNTIME = '1';
1330
+ }
1319
1331
 
1320
1332
  return {
1321
1333
  id: service.id,
@@ -3746,7 +3758,9 @@ export async function attachWorld(name: string, command: string[], root = proces
3746
3758
  throw new Error(`World "${name}" is changing lifecycle; retry after it finishes`);
3747
3759
  }
3748
3760
  const current = statusWorld(name, resolvedRoot);
3749
- if (!current.running || current.createdAt !== attached.instance.createdAt) throw new Error(`World "${name}" is stopped or changed while attaching`);
3761
+ // Which of the two it was, with what was read: "stopped" and "another instance" are different repairs.
3762
+ if (!current.running) throw new Error(`World "${name}" is not running while attaching: none of its recorded processes is live and its own (recorded pids ${readPids(current.pidsFile).join(', ') || 'none'}; live and its own: none)`);
3763
+ if (current.createdAt !== attached.instance.createdAt) throw new Error(`World "${name}" changed while attaching: its instance was created ${attached.instance.createdAt} when the command's environment was read and ${current.createdAt} now`);
3750
3764
  // Attaching to a World whose services died is allowed (a command may not need them), but never silent.
3751
3765
  for (const [id, state] of Object.entries(current.serviceStates)) {
3752
3766
  if (state === 'running' || state === 'external') continue;
package/src/schema.ts CHANGED
@@ -23,14 +23,31 @@ export const WORLD_USAGES = ['application', 'dependencies', 'build', 'deployment
23
23
  export type WorldUsage = typeof WORLD_USAGES[number];
24
24
  export type WorldSelectionSelector = { usage?: WorldUsage; vendor?: string };
25
25
  export type WorldSelection = { include: WorldSelectionSelector[]; exclude: WorldSelectionSelector[] };
26
+ /** One row of the coverage proof as it is kept: `evidence` is the proof table's detected-via column, `connection`
27
+ * its transport column and `twinInWorld` its twin-in-world column (a display label, not a service id). */
28
+ export type WorldCoverageEntry = { vendor: string; evidence: string[]; connection?: string; twinInWorld?: string };
29
+ /** The last coverage proof `init` or `covers` printed, kept beside the selection it applied (covers.ts
30
+ * `coverageRecord`): each list is the proof's rows of one status. A record; nothing reads it to route, select or boot. */
31
+ export type WorldCoverage = {
32
+ /** The package that made the proof. */
33
+ producer: { package: string; version: string };
34
+ /** The proof's verdict, which was the command's exit code. */
35
+ ok: boolean;
36
+ covered: WorldCoverageEntry[];
37
+ notCovered: WorldCoverageEntry[];
38
+ uninterceptable: WorldCoverageEntry[];
39
+ unknownSdk: { signal: string; evidence: string[]; connection?: string; twinInWorld?: string }[];
40
+ acknowledged: (WorldCoverageEntry & { reason: string })[];
41
+ excludedBySelection: WorldCoverageEntry[];
42
+ };
26
43
 
27
44
  /** The format-2 document is normalized to WorldConfig before runtime code sees it. */
28
45
  export type WorldManifestV2 = {
29
46
  schemaVersion: 2;
30
47
  metadata: { id: string; description?: string };
31
- discovery?: { selection?: WorldSelection };
48
+ discovery?: { selection?: WorldSelection; coverage?: WorldCoverage };
32
49
  services: unknown[];
33
- runtime?: { isolation?: WorldIsolation; environment?: { values?: Record<string, string>; strip?: string[] }; network?: WorldNetwork };
50
+ runtime?: { isolation?: WorldIsolation; environment?: { values?: Record<string, string>; strip?: string[] }; network?: WorldNetwork; database?: { prepare?: WorldDatabasePrepare }; development?: WorldDevelopment };
34
51
  serving?: { mode?: 'app' | 'bare'; name?: string; share?: WorldShareConfig };
35
52
  remotes?: Record<string, string>;
36
53
  changesets?: WorldChangesetRules;
@@ -256,8 +273,32 @@ export type WorldServiceConfig = {
256
273
  * generated from its changes. A team's rules, committed with the World, as a repository's are. */
257
274
  export type WorldChangesetRules = { approvals?: number; summary?: 'required' | 'generated' };
258
275
 
276
+ /**
277
+ * The app's own migration of its database, run by `volter-world-infra prepare` against the prepared cluster
278
+ * (architecture, managed infrastructure): its command as the app's image runs it (an argv), the image path it runs in,
279
+ * and the paths under it whose bytes decide what it writes (the prepared cluster's stamp names their digests).
280
+ */
281
+ export type WorldDatabasePrepare = { command: string[]; cwd: string; inputs: string[] };
282
+
283
+ /**
284
+ * SETTINGS FOR THE PERSON DEVELOPING THE APP AGAINST THIS WORLD (runtime.development). A World that sets any of
285
+ * them is a development World: `up` and an image's build say so each time, and an image built from it is not a
286
+ * prepared one. None of them is a way to make an application boot or to get past a failed preparation; an
287
+ * application's World does not set them.
288
+ *
289
+ * migrateDatabaseAtRuntime: the app may run its own migration when it starts, for a schema its developer is
290
+ * changing right now. Omitted, no: a migration is the World's `runtime.database.prepare`, run when the image is
291
+ * built.
292
+ */
293
+ export type WorldDevelopment = { migrateDatabaseAtRuntime?: boolean };
294
+
259
295
  export type WorldConfig = {
260
296
  id: string;
297
+ /** The app's own migration step for the World's prepared database cluster (runtime.database.prepare). */
298
+ databasePrepare?: WorldDatabasePrepare;
299
+ /** The World's development settings (runtime.development; WorldDevelopment says whose they are). Its
300
+ * migrateDatabaseAtRuntime is said to the app's commands as VOLTER_WORLD_DEVELOPMENT_MIGRATE_AT_RUNTIME. */
301
+ development?: WorldDevelopment;
261
302
  /** How changesets are made and readied here; omitted: one approval, a summary required. */
262
303
  changesets?: WorldChangesetRules;
263
304
  /** HTTPS origin/exact-path/subtree grants with declared phase and explicit build passthrough; [] closes real egress. */
@@ -265,6 +306,8 @@ export type WorldConfig = {
265
306
  /** Internal normalized source format and saved discovery selection. */
266
307
  manifestVersion?: 1 | 2;
267
308
  selection?: WorldSelection;
309
+ /** The last coverage proof made for this config (`discovery.coverage`); carried so a rewrite keeps it. */
310
+ coverage?: WorldCoverage;
268
311
  description?: string;
269
312
  /** A world with no app in front of it (`init --bare <org>/<world>`): a shared world for a team, or a
270
313
  * world standing in for a vendor. `name` is how it is served: `/<org>/<world>/…`. */
@@ -845,7 +888,25 @@ function normalizeV2(value: Record<string, unknown>, path: string): WorldConfig
845
888
  const serving = (value.serving ?? {}) as Record<string, unknown>;
846
889
  const scenario = (value.scenario ?? {}) as Record<string, unknown>;
847
890
  const provenance = (value.provenance ?? {}) as Record<string, unknown>;
848
- assertKeys(discovery, ['selection'], 'discovery', path); assertKeys(runtime, ['isolation', 'environment', 'network'], 'runtime', path);
891
+ assertKeys(discovery, ['selection', 'coverage'], 'discovery', path); assertKeys(runtime, ['isolation', 'environment', 'network', 'database', 'development'], 'runtime', path);
892
+ const database = runtime.database ?? {};
893
+ if (!record(database)) throw new Error(`runtime.database must be an object in ${path}`);
894
+ assertKeys(database, ['prepare'], 'runtime.database', path);
895
+ const development = runtime.development ?? {};
896
+ if (!record(development)) throw new Error(`runtime.development must be an object in ${path}`);
897
+ assertKeys(development, ['migrateDatabaseAtRuntime'], 'runtime.development', path);
898
+ if (development.migrateDatabaseAtRuntime !== undefined && typeof development.migrateDatabaseAtRuntime !== 'boolean') throw new Error(`runtime.development.migrateDatabaseAtRuntime must be a boolean in ${path}`);
899
+ const databasePrepare = database.prepare;
900
+ if (databasePrepare !== undefined) {
901
+ if (!record(databasePrepare)) throw new Error(`runtime.database.prepare must be an object in ${path}`);
902
+ assertKeys(databasePrepare, ['command', 'cwd', 'inputs'], 'runtime.database.prepare', path);
903
+ const strings = (value: unknown): value is string[] => Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string' && item !== '');
904
+ if (!strings(databasePrepare.command)) throw new Error(`runtime.database.prepare.command must be a non-empty argv in ${path}`);
905
+ if (typeof databasePrepare.cwd !== 'string' || !databasePrepare.cwd.startsWith('/')) throw new Error(`runtime.database.prepare.cwd must be an absolute image path in ${path}`);
906
+ if (!strings(databasePrepare.inputs) || databasePrepare.inputs.some((input) => input.startsWith('/') || input.split('/').includes('..'))) {
907
+ throw new Error(`runtime.database.prepare.inputs must name paths under its cwd in ${path}`);
908
+ }
909
+ }
849
910
  assertKeys(serving, ['mode', 'name', 'share'], 'serving', path); assertKeys(scenario, ['actors', 'fixtures'], 'scenario', path); assertKeys(provenance, ['catalog'], 'provenance', path);
850
911
  const environment = runtime.environment ?? {};
851
912
  if (!record(environment)) throw new Error(`runtime.environment must be an object in ${path}`);
@@ -859,6 +920,7 @@ function normalizeV2(value: Record<string, unknown>, path: string): WorldConfig
859
920
  if (mode === 'app' && serving.name !== undefined) throw new Error(`App serving must omit serving.name in ${path}`);
860
921
  const selection = discovery.selection ?? DEFAULT_WORLD_SELECTION;
861
922
  assertWorldSelection(selection, path);
923
+ if (discovery.coverage !== undefined) assertWorldCoverage(discovery.coverage, path);
862
924
  if (!Array.isArray(value.services)) throw new Error(`World manifest must define services[] in ${path}`);
863
925
  const normalized: WorldConfig = {
864
926
  ...Object.fromEntries(Object.entries(value).filter(([key]) => key.startsWith('//'))),
@@ -866,9 +928,12 @@ function normalizeV2(value: Record<string, unknown>, path: string): WorldConfig
866
928
  ...(metadata.description === undefined ? {} : { description: metadata.description as string }),
867
929
  manifestVersion: 2,
868
930
  selection,
931
+ ...(discovery.coverage === undefined ? {} : { coverage: discovery.coverage as WorldCoverage }),
869
932
  services: value.services.map((service) => serviceV2ToLegacy(service, path)),
870
933
  ...(runtime.isolation === undefined ? {} : { isolation: runtime.isolation as WorldIsolation }),
871
934
  ...(runtime.network === undefined ? {} : { network: normalizeWorldNetwork(runtime.network) }),
935
+ ...(databasePrepare === undefined ? {} : { databasePrepare: databasePrepare as WorldDatabasePrepare }),
936
+ ...(development.migrateDatabaseAtRuntime === true ? { development: { migrateDatabaseAtRuntime: true } } : {}),
872
937
  ...(environment.values === undefined ? {} : { env: environment.values as Record<string, string> }),
873
938
  ...(environment.strip === undefined ? {} : { stripEnv: environment.strip as string[] }),
874
939
  ...(mode === 'bare' ? { bare: { name: serving.name as string } } : {}),
@@ -882,6 +947,33 @@ function normalizeV2(value: Record<string, unknown>, path: string): WorldConfig
882
947
  return { ...assertLegacyWorldConfig(normalized, path), manifestVersion: 2, selection };
883
948
  }
884
949
 
950
+ const WORLD_COVERAGE_LISTS = ['covered', 'notCovered', 'uninterceptable', 'unknownSdk', 'acknowledged', 'excludedBySelection'] as const;
951
+
952
+ export function assertWorldCoverage(value: unknown, path: string): asserts value is WorldCoverage {
953
+ if (!record(value)) throw new Error(`discovery.coverage must be an object in ${path}`);
954
+ assertKeys(value, ['producer', 'ok', ...WORLD_COVERAGE_LISTS], 'discovery.coverage', path);
955
+ const producer = value.producer;
956
+ if (!record(producer)) throw new Error(`discovery.coverage.producer must be an object in ${path}`);
957
+ assertKeys(producer, ['package', 'version'], 'discovery.coverage.producer', path);
958
+ for (const key of ['package', 'version'] as const) if (typeof producer[key] !== 'string' || producer[key] === '') throw new Error(`discovery.coverage.producer.${key} must be a non-empty string in ${path}`);
959
+ if (typeof value.ok !== 'boolean') throw new Error(`discovery.coverage.ok must be a boolean in ${path}`);
960
+ for (const list of WORLD_COVERAGE_LISTS) {
961
+ if (!Array.isArray(value[list])) throw new Error(`discovery.coverage.${list} must be an array in ${path}`);
962
+ const name = list === 'unknownSdk' ? 'signal' : 'vendor';
963
+ const strings = [name, 'connection', 'twinInWorld', ...(list === 'acknowledged' ? ['reason'] : [])];
964
+ const required = [name, ...(list === 'acknowledged' ? ['reason'] : [])];
965
+ for (const entry of value[list]) {
966
+ if (!record(entry)) throw new Error(`discovery.coverage.${list} entries must be objects in ${path}`);
967
+ assertKeys(entry, [...strings, 'evidence'], `discovery.coverage.${list} entry`, path);
968
+ for (const key of strings) {
969
+ if (entry[key] === undefined && !required.includes(key)) continue;
970
+ if (typeof entry[key] !== 'string' || entry[key] === '') throw new Error(`discovery.coverage.${list} ${key} must be a non-empty string in ${path}`);
971
+ }
972
+ if (!Array.isArray(entry.evidence) || entry.evidence.some((item) => typeof item !== 'string')) throw new Error(`discovery.coverage.${list} evidence must be an array of strings in ${path}`);
973
+ }
974
+ }
975
+ }
976
+
885
977
  export function assertWorldConfig(value: unknown, path: string): WorldConfig {
886
978
  if (record(value) && value.schemaVersion !== undefined) {
887
979
  if (value.schemaVersion !== WORLD_SCHEMA_VERSION) throw new Error(`Unsupported World schemaVersion ${String(value.schemaVersion)} in ${path}`);
@@ -952,9 +1044,11 @@ export function worldConfigDocument(config: WorldConfig, selection: WorldSelecti
952
1044
  return {
953
1045
  schemaVersion: WORLD_SCHEMA_VERSION,
954
1046
  metadata: { id: config.id, ...(config.description === undefined ? {} : { description: config.description }) },
955
- discovery: { selection },
956
- ...(config.isolation === undefined && config.env === undefined && config.stripEnv === undefined && config.network === undefined ? {} : { runtime: {
1047
+ discovery: { selection, ...(config.coverage === undefined ? {} : { coverage: config.coverage }) },
1048
+ ...(config.isolation === undefined && config.env === undefined && config.stripEnv === undefined && config.network === undefined && config.databasePrepare === undefined && !config.development?.migrateDatabaseAtRuntime ? {} : { runtime: {
957
1049
  ...(config.isolation === undefined ? {} : { isolation: config.isolation }),
1050
+ ...(config.databasePrepare === undefined ? {} : { database: { prepare: config.databasePrepare } }),
1051
+ ...(config.development?.migrateDatabaseAtRuntime ? { development: { migrateDatabaseAtRuntime: true } } : {}),
958
1052
  ...(config.network === undefined ? {} : { network: normalizeWorldNetwork(config.network) }),
959
1053
  ...(config.env === undefined && config.stripEnv === undefined ? {} : { environment: {
960
1054
  ...(config.env === undefined ? {} : { values: config.env }),
package/src/up-cli.ts CHANGED
@@ -27,7 +27,13 @@ export async function upCommand(subject: string | undefined, rest: string[], roo
27
27
  // saves a whole command between up and the app's start.
28
28
  const appUrl = optionValue(rest, '--app-url') || undefined;
29
29
  if (appUrl !== undefined && !(URL.canParse(appUrl) && /^https?:$/.test(new URL(appUrl).protocol))) throw new Error(`volter-world up: --app-url "${appUrl}" must be an http(s) URL`);
30
- const instance = await superviseWorldUp(subject, { name, root, mode, envFile, forceEnvOutput: rest.includes('--force'), owner: ownerOption(rest), ...(isolation ? { isolation } : {}), ...(keepState ? { keepState: true } : {}) });
30
+ const options = { name, root, mode, envFile, forceEnvOutput: rest.includes('--force'), owner: ownerOption(rest), ...(isolation ? { isolation } : {}), ...(keepState ? { keepState: true } : {}) };
31
+ // --in-process: the boot runs in this process, for a caller with no terminal to lose (a browser tab's page, whose
32
+ // loss ends every process it runs). The owner process exists so a closed terminal does not leave a boot half-done;
33
+ // where there is none, it costs a whole Node start before the boot and its own end after it (Rallly run 35: 611 ms
34
+ // from the owner's "returning" to its reply reaching this process). The caller's SIGINT or SIGTERM still cancels the
35
+ // boot, which rolls back.
36
+ const instance = rest.includes('--in-process') ? await upInProcess(subject, options) : await superviseWorldUp(subject, options);
31
37
  if (appUrl !== undefined) {
32
38
  const { setAppUrl } = await import('./app-url.ts');
33
39
  setAppUrl(instance.name, appUrl, { root });
@@ -37,6 +43,15 @@ export async function upCommand(subject: string | undefined, rest: string[], roo
37
43
  else printReady(instance);
38
44
  }
39
45
 
46
+ async function upInProcess(configId: string, options: Parameters<typeof upWorld>[1]): Promise<Awaited<ReturnType<typeof upWorld>>> {
47
+ const { upWorld } = await import('./runtime.ts');
48
+ const abort = new AbortController();
49
+ const cancel = (signal: NodeJS.Signals) => abort.abort(signal);
50
+ process.on('SIGINT', cancel); process.on('SIGTERM', cancel);
51
+ try { return await upWorld(configId, options, abort.signal); }
52
+ finally { process.off('SIGINT', cancel); process.off('SIGTERM', cancel); }
53
+ }
54
+
40
55
  export function printReady(instance: Awaited<ReturnType<typeof upWorld>>): void {
41
56
  process.stdout.write(`World ${instance.name} is ready (${instance.config})
42
57
  Instance: ${instance.instance}