@volter/world-runtime 3.0.144 → 3.0.146
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/src/cli-commands.js +13 -2
- package/dist/src/covers.d.ts +21 -1
- package/dist/src/covers.js +46 -4
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/infra.js +42 -8
- package/dist/src/init.d.ts +4 -0
- package/dist/src/init.js +20 -2
- package/dist/src/native-postgres-backing.d.ts +15 -1
- package/dist/src/native-postgres-backing.js +178 -7
- package/dist/src/package-manifest.d.ts +7 -0
- package/dist/src/package-manifest.js +29 -0
- package/dist/src/pglite-backing.d.ts +85 -0
- package/dist/src/pglite-backing.js +249 -33
- package/dist/src/process-groups.mjs +25 -4
- package/dist/src/process-signal.mjs +63 -14
- package/dist/src/project-inspect.d.ts +9 -0
- package/dist/src/project-inspect.js +31 -8
- package/dist/src/runtime.js +23 -2
- package/dist/src/schema.d.ts +68 -0
- package/dist/src/schema.js +70 -4
- package/dist/src/up-cli.js +21 -1
- package/package.json +4 -4
- package/src/cli-commands.ts +10 -2
- package/src/covers.ts +53 -5
- package/src/index.ts +4 -0
- package/src/infra.ts +40 -8
- package/src/init.ts +22 -1
- package/src/native-postgres-backing.ts +175 -8
- package/src/package-manifest.ts +31 -0
- package/src/pglite-backing.ts +227 -22
- package/src/process-groups.mjs +25 -4
- package/src/process-signal.mjs +63 -14
- package/src/project-inspect.ts +38 -1
- package/src/runtime.ts +15 -1
- package/src/schema.ts +99 -5
- package/src/up-cli.ts +16 -1
package/src/process-groups.mjs
CHANGED
|
@@ -14,10 +14,31 @@ export function survivingOwnedGroups(pids) {
|
|
|
14
14
|
}
|
|
15
15
|
});
|
|
16
16
|
if (!candidates.length || process.platform === 'win32') return candidates;
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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);
|
package/src/process-signal.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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); //
|
|
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',
|
|
70
|
-
child.once('close',
|
|
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
|
-
|
|
103
|
-
|
|
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. */
|
package/src/project-inspect.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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}
|