@volter/world-runtime 3.0.143 → 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.
@@ -12,15 +12,18 @@
12
12
  // services only. A definition declaring mysql without a container runtime is
13
13
  // refused with the kinds named, never half-booted. Redis never reaches it: every
14
14
  // redis service is the redis twin's (redis-backing.ts).
15
- import { appendFileSync, closeSync, cpSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
16
- import { randomUUID } from 'node:crypto';
15
+ import { appendFileSync, closeSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, renameSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { createHash, randomUUID } from 'node:crypto';
17
+ import { assertWorldConfig } from "./schema.js";
17
18
  import { connect } from 'node:net';
18
19
  import { dirname, join } from 'node:path';
20
+ import { tmpdir } from 'node:os';
19
21
  import { spawn } from 'node:child_process';
20
22
  import { commandLifetime, COMMAND_GRACE_MS, COMMAND_CONFIRM_MS } from "./command-lifetime.js";
21
23
  import { survivingOwnedGroups } from "./process-groups.js";
22
24
  import { publishProcessReceipt, readProcessReceipt, readSpawnReceipt, spawnReceiptPath, verifySpawnIdentity, signalOwnedProcess } from "./process-receipt.js";
23
25
  import { errorMessage } from "./error-message.js";
26
+ import { runtimeManifest } from "./package-manifest.js";
24
27
  import { fileURLToPath } from 'node:url';
25
28
  import { parse } from 'yaml';
26
29
  import { mongodbArgv, mongodbReady, mongodbServes } from "./mongodb-backing.js";
@@ -212,34 +215,142 @@ export function preparedCluster(worldConfig) {
212
215
  const directory = join(dirname(worldConfig), PREPARED_DIRECTORY, containerlessDataDirectory('postgres'));
213
216
  return { directory, stamp: `${directory}.json` };
214
217
  }
215
- /**
216
- * What a prepared cluster must have been made with: this runtime (whose pglite-host.mjs is the open path that made
217
- * it), its exact PGlite pin (initdb's encoding and locale are PGlite's defaults: the host passes no initdb
218
- * parameters), and the declared user and database. The password is not in the cluster: the role is created without
219
- * one (LOGIN SUPERUSER) and the host's gateway checks the declared password on each connection, so a World with
220
- * another password takes the same cluster and authenticates against its own. Extensions are loaded into the module
221
- * at each open, never created in the cluster by the open path.
222
- */
223
- /** This package's own manifest, found by its name walking up from this module: the package root from src/ in the
224
- * repository and from dist/src/ in the published package alike, where a fixed `..` reached dist/package.json. */
225
- function runtimeManifest() {
226
- for (let dir = here();; dir = dirname(dir)) {
227
- const candidate = join(dir, 'package.json');
228
- if (existsSync(candidate)) {
229
- const manifest = JSON.parse(readFileSync(candidate, 'utf8'));
230
- if (manifest.name === '@volter/world-runtime')
231
- return manifest;
232
- }
233
- if (dirname(dir) === dir)
234
- throw new Error(`no @volter/world-runtime package.json above ${here()}`);
235
- }
236
- }
237
- function preparedStamp(service) {
218
+ function preparedStamp(service, migration) {
238
219
  const manifest = runtimeManifest();
239
220
  const pglite = manifest.dependencies?.['@electric-sql/pglite'];
240
221
  if (!pglite || !/^\d+\.\d+\.\d+$/.test(pglite))
241
222
  throw new Error('world-runtime pins no exact @electric-sql/pglite version');
242
- return `${JSON.stringify({ runtime: manifest.version ?? 'unversioned', pglite, user: service.postgres.user, database: service.postgres.database })}\n`;
223
+ return `${JSON.stringify({ runtime: manifest.version ?? 'unversioned', pglite, user: service.postgres.user, database: service.postgres.database,
224
+ ...(migration ? { migration } : {}) })}\n`;
225
+ }
226
+ /**
227
+ * The SHA-256 of each of a migration step's inputs, under its cwd in the app's tree: a file's bytes; a directory's
228
+ * sorted relative paths, each with its type, its mode and its bytes (a link, its target). What the image build records
229
+ * for an image, and what `up` hashes only for a World run outside one.
230
+ */
231
+ export function migrationInputDigests(cwd, inputs) {
232
+ const digests = {};
233
+ for (const input of inputs) {
234
+ const hash = createHash('sha256');
235
+ const walk = (absolute, relative) => {
236
+ const stat = lstatSync(absolute);
237
+ if (stat.isSymbolicLink()) {
238
+ hash.update(`l ${relative} ${readlinkSync(absolute)}\n`);
239
+ return;
240
+ }
241
+ if (stat.isDirectory()) {
242
+ hash.update(`d ${relative} ${(stat.mode & 0o7777).toString(8)}\n`);
243
+ for (const name of readdirSync(absolute).sort())
244
+ walk(join(absolute, name), relative === '' ? name : `${relative}/${name}`);
245
+ return;
246
+ }
247
+ hash.update(`f ${relative} ${(stat.mode & 0o7777).toString(8)} ${stat.size}\n`);
248
+ hash.update(readFileSync(absolute));
249
+ };
250
+ walk(join(cwd, input), '');
251
+ digests[input] = `sha256:${hash.digest('hex')}`;
252
+ }
253
+ return digests;
254
+ }
255
+ /** The World's declared migration step for its prepared database, if any (runtime.database.prepare). */
256
+ export function databasePrepareOf(worldConfig) {
257
+ return assertWorldConfig(JSON.parse(readFileSync(worldConfig, 'utf8')), worldConfig).databasePrepare;
258
+ }
259
+ /** `NAME=value` lines (an image's environment, VOLTER_WORLD_APP_ENV), as an environment. */
260
+ function environmentFile(path) {
261
+ const values = {};
262
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
263
+ const at = line.indexOf('=');
264
+ if (at > 0)
265
+ values[line.slice(0, at)] = line.slice(at + 1);
266
+ }
267
+ return values;
268
+ }
269
+ /**
270
+ * The World's declared step (runtime.database.prepare) run once against a prepared cluster its backing is serving on
271
+ * the World's port: the declared command in the app's tree, with the image's environment and the World's values over
272
+ * it. The build host's own PATH finds the command's tools (the image's PATH names its own paths). Its failure throws,
273
+ * with its output; its answer is what the cluster's stamp records.
274
+ */
275
+ /**
276
+ * A FAILED PREPARATION KEEPS ITS WHOLE EVIDENCE. An error carries a tail, and the preparation's own directory is
277
+ * its caller's to remove (an image build prepares in a staging directory it retires when the preparation fails), so
278
+ * the tail was all that survived: a postmaster's first lines, its shared-memory setup among them, were gone with the
279
+ * directory (Cal runs 23 and 24). The whole text or file is written under the preparation's temporary directory
280
+ * (TMPDIR, which a failed build retains; the system's otherwise), outside the World's directory, and the sentence
281
+ * naming it is added to the error. Never on success, and a copy that fails says so instead of hiding the failure.
282
+ */
283
+ export function keepFailureEvidence(name, evidence, env) {
284
+ try {
285
+ const directory = env.TMPDIR ?? tmpdir();
286
+ mkdirSync(directory, { recursive: true });
287
+ const path = join(directory, `volter-world-prepare-${name}-${Date.now()}-${process.pid}.log`);
288
+ if (typeof evidence === 'string')
289
+ writeFileSync(path, evidence);
290
+ else
291
+ copyFileSync(evidence.file, path);
292
+ return `\nThe whole ${name} is kept at ${path}`;
293
+ }
294
+ catch (error) {
295
+ return `\n(the whole ${name} could not be kept: ${errorMessage(error)})`;
296
+ }
297
+ }
298
+ export async function runDatabasePrepare(step, worldConfig, launch) {
299
+ const env = launch.env ?? process.env;
300
+ const appRoot = env.VOLTER_WORLD_APP_ROOT;
301
+ if (!appRoot)
302
+ throw new Error('the World declares runtime.database.prepare: VOLTER_WORLD_APP_ROOT must name the app image\'s root');
303
+ const cwd = join(appRoot, step.cwd);
304
+ const imageEnv = env.VOLTER_WORLD_APP_ENV ? environmentFile(env.VOLTER_WORLD_APP_ENV) : {};
305
+ const { PATH: _imagePath, ...imageValues } = imageEnv;
306
+ const worldValues = assertWorldConfig(JSON.parse(readFileSync(worldConfig, 'utf8')), worldConfig).env ?? {};
307
+ const [program, ...words] = step.command;
308
+ const migration = spawn(program, words, { windowsHide: true, cwd, env: { ...env, ...imageValues, ...worldValues }, stdio: ['ignore', 'pipe', 'pipe'] });
309
+ let output = '';
310
+ migration.stdout.on('data', (chunk) => { output += chunk.toString('utf8'); });
311
+ migration.stderr.on('data', (chunk) => { output += chunk.toString('utf8'); });
312
+ const { code } = await commandLifetime(migration, launch.signal).finish();
313
+ if (code !== 0)
314
+ throw new Error(`the World's database prepare step (${step.command.join(' ')}) exited ${code ?? 'null'}\n${output.trim().split('\n').slice(-40).join('\n')}${keepFailureEvidence('database-prepare-step-output', output, env)}`);
315
+ launch.note?.(`database prepare step: ${step.command.join(' ')}\n${output.trim()}`);
316
+ return { command: [...step.command], inputs: migrationInputDigests(cwd, step.inputs) };
317
+ }
318
+ /**
319
+ * The app's own migration against a prepared PGlite cluster: the cluster served by the same host at the World's port
320
+ * for the step (runDatabasePrepare), then the host closed cleanly.
321
+ */
322
+ async function migratePreparedCluster(service, directory, step, worldConfig, launch) {
323
+ const env = launch.env ?? process.env;
324
+ const pidFile = join(dirname(directory), 'pglite-prepare.pid');
325
+ const boot = randomUUID();
326
+ rmSync(listeningReceiptPath(pidFile), { force: true });
327
+ const [command, ...args] = [...hostArgv(service, directory), '--prepared', '--listening-file', listeningReceiptPath(pidFile), '--boot', boot];
328
+ let hostOutput = '';
329
+ const host = spawn(command, args, { windowsHide: true,
330
+ env: { ...env, VOLTER_WORLD_PGLITE_USER: service.postgres.user, VOLTER_WORLD_PGLITE_DATABASE: service.postgres.database, VOLTER_WORLD_PGLITE_PASSWORD: service.postgres.password },
331
+ stdio: ['ignore', 'pipe', 'pipe'] });
332
+ host.stdout.on('data', (chunk) => { hostOutput += chunk.toString('utf8'); });
333
+ host.stderr.on('data', (chunk) => { hostOutput += chunk.toString('utf8'); });
334
+ const hostLifetime = commandLifetime(host, launch.signal);
335
+ try {
336
+ const deadline = Date.now() + READY_TIMEOUT_MS;
337
+ while (!(host.pid && alive(host.pid) && await hostServes(service, pidFile, host.pid, boot))) {
338
+ if (!host.pid || !alive(host.pid))
339
+ throw new Error(`the prepared cluster's host exited before serving\n${hostOutput.trim().split('\n').slice(-20).join('\n')}`);
340
+ if (Date.now() > deadline)
341
+ throw new Error(`the prepared cluster's host did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s`);
342
+ launch.signal?.throwIfAborted();
343
+ await sleep(25);
344
+ }
345
+ return await runDatabasePrepare(step, worldConfig, launch);
346
+ }
347
+ finally {
348
+ // A clean close: PGlite writes its cluster back before the host exits.
349
+ if (host.pid && alive(host.pid))
350
+ host.kill('SIGTERM');
351
+ await hostLifetime.finish();
352
+ rmSync(listeningReceiptPath(pidFile), { force: true });
353
+ }
243
354
  }
244
355
  /** Run the host's open path once into the World's prepared directory; replaces an earlier preparation. */
245
356
  export async function pglitePrepare(service, worldConfig, launch = {}) {
@@ -262,20 +373,123 @@ export async function pglitePrepare(service, worldConfig, launch = {}) {
262
373
  const { code } = await lifetime.finish();
263
374
  if (code !== 0 || !existsSync(join(directory, 'PG_VERSION'))) {
264
375
  rmSync(directory, { recursive: true, force: true });
265
- throw new Error(`pglite prepare exited ${code ?? 'null'} without a cluster\n${output.trim().split('\n').slice(-40).join('\n')}`);
376
+ throw new Error(`pglite prepare exited ${code ?? 'null'} without a cluster\n${output.trim().split('\n').slice(-40).join('\n')}${keepFailureEvidence('pglite-prepare-output', output, launch.env ?? process.env)}`);
266
377
  }
267
- writeFileSync(stamp, preparedStamp(service));
378
+ // The app's own migration, where the World declares one: a cluster it did not complete is never stamped.
379
+ const step = databasePrepareOf(worldConfig);
380
+ let migration;
381
+ if (step) {
382
+ try {
383
+ migration = await migratePreparedCluster(service, directory, step, worldConfig, launch);
384
+ }
385
+ catch (error) {
386
+ rmSync(directory, { recursive: true, force: true });
387
+ throw error;
388
+ }
389
+ }
390
+ writeFileSync(stamp, preparedStamp(service, migration));
268
391
  return directory;
269
392
  }
270
393
  /** Give an empty service data directory the World's prepared cluster when its stamp matches; says what it did. */
271
- function placePreparedCluster(service, serviceData, worldConfig) {
394
+ function placePreparedCluster(service, serviceData, worldConfig, env) {
395
+ if (service.kind === 'postgres')
396
+ forgetStaleMigration(serviceData);
272
397
  if (service.kind !== 'postgres' || !worldConfig || existsSync(join(serviceData, 'PG_VERSION')))
273
398
  return { placed: false };
274
- const { directory, stamp } = preparedCluster(worldConfig);
399
+ const prepared = preparedCluster(worldConfig);
400
+ if (!existsSync(join(prepared.directory, 'PG_VERSION')) || !existsSync(prepared.stamp))
401
+ return { placed: false };
402
+ const wanted = expectedMigration(worldConfig, env, prepared.directory);
403
+ if ('note' in wanted)
404
+ return { placed: false, note: wanted.note };
405
+ return takePreparedCluster(prepared, preparedStamp(service, wanted.migration), serviceData, 'another PGlite pin, identity or migration', wanted.migration);
406
+ }
407
+ /**
408
+ * The migration a prepared cluster must have been made with for `up` to take it, on either backing: none when the
409
+ * World declares no step; else the step's command and its inputs' digests, as the app's image recorded them
410
+ * (VOLTER_WORLD_APP_DIGESTS, no read of the tree) or, for a World run outside an image, as its tree has them. With
411
+ * neither given a migrated cluster is not taken, and the note says so.
412
+ */
413
+ export function expectedMigration(worldConfig, env, directory) {
414
+ const step = databasePrepareOf(worldConfig);
415
+ if (!step)
416
+ return {};
417
+ let inputs;
418
+ if (env.VOLTER_WORLD_APP_DIGESTS) {
419
+ // The image wrote this; a value that is not a JSON object of digests is said, and the cluster is not taken.
420
+ let recorded;
421
+ try {
422
+ recorded = JSON.parse(env.VOLTER_WORLD_APP_DIGESTS);
423
+ }
424
+ catch {
425
+ recorded = undefined;
426
+ }
427
+ if (!recorded || typeof recorded !== 'object' || Array.isArray(recorded) || Object.values(recorded).some((value) => typeof value !== 'string')) {
428
+ return { note: `prepared cluster ${directory} not taken: it is migrated, and VOLTER_WORLD_APP_DIGESTS is not a JSON object of its inputs' digests` };
429
+ }
430
+ inputs = recorded;
431
+ }
432
+ else if (env.VOLTER_WORLD_APP_ROOT)
433
+ inputs = migrationInputDigests(join(env.VOLTER_WORLD_APP_ROOT, step.cwd), step.inputs);
434
+ if (!inputs)
435
+ return { note: `prepared cluster ${directory} not taken: it is migrated, and neither the app's recorded digests nor its tree were given to compare (VOLTER_WORLD_APP_DIGESTS, VOLTER_WORLD_APP_ROOT)` };
436
+ return { migration: { command: [...step.command], inputs: Object.fromEntries(step.inputs.map((input) => [input, inputs[input] ?? ''])) } };
437
+ }
438
+ /** Beside a service's data: the migration its live cluster carries, written when a migrated cluster is placed. */
439
+ const migrationRecord = (serviceData) => `${serviceData}.migration.json`;
440
+ /** A data directory with no cluster carries no migration: a record left by data since removed is dropped. */
441
+ export function forgetStaleMigration(serviceData) {
442
+ if (!existsSync(join(serviceData, 'PG_VERSION')))
443
+ rmSync(migrationRecord(serviceData), { force: true });
444
+ }
445
+ /**
446
+ * The declared step's command when the LIVE database carries the migration the app's image expects, else undefined
447
+ * (architecture, managed infrastructure). A fact of the data, not of this boot's placement: kept data is still
448
+ * migrated on a later boot, and kept data under an image whose step inputs have changed is older than the image.
449
+ */
450
+ export function liveDatabaseMigration(worldConfig, env, serviceData) {
451
+ if (!existsSync(join(serviceData, 'PG_VERSION')) || !existsSync(migrationRecord(serviceData)))
452
+ return undefined;
453
+ const wanted = expectedMigration(worldConfig, env, serviceData);
454
+ if ('note' in wanted || !wanted.migration)
455
+ return undefined;
456
+ return readFileSync(migrationRecord(serviceData), 'utf8') === `${JSON.stringify(wanted.migration)}\n` ? [...wanted.migration.command] : undefined;
457
+ }
458
+ /**
459
+ * The declared step's command when the World declares one and the live database does NOT carry the migration the
460
+ * image expects: data kept from an earlier image, or a cluster that was initialized here because the prepared one
461
+ * was not taken. Undefined with no step, no cluster yet, or a migrated one.
462
+ */
463
+ export function liveDatabaseBehind(worldConfig, env, serviceData) {
464
+ const step = databasePrepareOf(worldConfig);
465
+ if (!step || !existsSync(join(serviceData, 'PG_VERSION')) || liveDatabaseMigration(worldConfig, env, serviceData))
466
+ return undefined;
467
+ return [...step.command];
468
+ }
469
+ /**
470
+ * Whether this is a development World whose app may migrate its database when it starts
471
+ * (runtime.development.migrateDatabaseAtRuntime; schema.ts WorldDevelopment says whose the setting is).
472
+ */
473
+ export function developmentMigratesAtRuntime(worldConfig) {
474
+ return assertWorldConfig(JSON.parse(readFileSync(worldConfig, 'utf8')), worldConfig).development?.migrateDatabaseAtRuntime === true;
475
+ }
476
+ /** What a development World says each time it is brought up or built into an image, so its use is never silent. */
477
+ export const DEVELOPMENT_MIGRATION_NOTICE = 'development World: its database migrates at run time; it is not prepared';
478
+ /**
479
+ * The one placement of a prepared cluster, for every backing that prepares one (PGlite here, native PostgreSQL in
480
+ * native-postgres-backing.ts): an empty `serviceData` takes `prepared.directory` by rename when the stamp beside it
481
+ * is exactly `expected`; a mismatch is named, never taken. A migrated cluster's `migration` is recorded beside the data
482
+ * it becomes (liveDatabaseMigration).
483
+ */
484
+ export function takePreparedCluster(prepared, expected, serviceData, mismatch, migration) {
485
+ const { directory, stamp } = prepared;
486
+ if (existsSync(join(serviceData, 'PG_VERSION')))
487
+ return { placed: false };
275
488
  if (!existsSync(join(directory, 'PG_VERSION')) || !existsSync(stamp))
276
489
  return { placed: false };
277
- if (readFileSync(stamp, 'utf8') !== preparedStamp(service))
278
- return { placed: false, note: `prepared cluster ${directory} not taken: its stamp names another PGlite pin or identity` };
490
+ const text = readFileSync(stamp, 'utf8');
491
+ if (typeof expected === 'string' ? text !== expected : !expected(text))
492
+ return { placed: false, note: `prepared cluster ${directory} not taken: its stamp names ${mismatch}` };
279
493
  if (existsSync(serviceData)) {
280
494
  if (readdirSync(serviceData).length)
281
495
  return { placed: false };
@@ -291,6 +505,8 @@ function placePreparedCluster(service, serviceData, worldConfig) {
291
505
  throw error;
292
506
  cpSync(directory, serviceData, { recursive: true });
293
507
  }
508
+ if (migration)
509
+ writeFileSync(migrationRecord(serviceData), `${JSON.stringify(migration)}\n`);
294
510
  return { placed: true, note: `prepared cluster ${directory} placed at ${serviceData}` };
295
511
  }
296
512
  export async function pgliteUp(services, dataDir, launch = {}) {
@@ -321,7 +537,7 @@ export async function pgliteUp(services, dataDir, launch = {}) {
321
537
  const started = [];
322
538
  try {
323
539
  for (const { service, name, serviceData, log, pidFile, argv } of plan) {
324
- const placed = placePreparedCluster(service, serviceData, env.VOLTER_WORLD_CONFIG);
540
+ const placed = placePreparedCluster(service, serviceData, env.VOLTER_WORLD_CONFIG, env);
325
541
  mkdirSync(serviceData, { recursive: true });
326
542
  mkdirSync(dirname(log), { recursive: true });
327
543
  if (placed.note) {
@@ -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. */
@@ -99,6 +99,15 @@ export declare function projectConnections(input?: string, attributedEnv?: Set<s
99
99
  * env heuristic. Only direct `fetch("https://...")`, `window.fetch`, and `globalThis.fetch` calls
100
100
  * count. Dynamic destinations remain intentionally unclaimed; guessing from arbitrary URL strings
101
101
  * would turn docs, links, and webhook examples into false external-service dependencies. */
102
+ /** A migration tool an application carries, by its evidence in the repository (architecture, World wiring). */
103
+ export interface ProjectMigrationTool {
104
+ tool: string;
105
+ /** the tool's schema, and its migrations directory, relative to the repository */
106
+ schema: string;
107
+ migrations: string;
108
+ }
109
+ /** Each migration tool the repository depends on that has migrations beside its schema (or beside its schema's folder). */
110
+ export declare function projectMigrationTools(input?: string): ProjectMigrationTool[];
102
111
  export declare function projectFetchUrls(input?: string): ProjectFetchUrl[];
103
112
  /** Env names read directly by production JS/TS source, with repo-relative `path:line` evidence.
104
113
  * This is intentionally evidence, not validator inference: seeing an empty example value read by
@@ -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.js";
5
5
  /** npm package → vendor/twin mapping — the shared vendor↔SDK knowledge used by both
6
6
  * `inspect-project` (adoption discovery) and `covers` (room-setup coverage proof).
@@ -663,13 +663,36 @@ export function projectConnections(input = process.cwd(), attributedEnv = new Se
663
663
  }
664
664
  return [...merged.values()];
665
665
  }
666
- /** Literal HTTP(S) destinations passed directly to the platform fetch API in production source.
667
- *
668
- * This is the third vendor signal beside dependencies and env names: hand-written REST clients
669
- * often declare no SDK, and an unconventional credential name can hide the same vendor from the
670
- * env heuristic. Only direct `fetch("https://...")`, `window.fetch`, and `globalThis.fetch` calls
671
- * count. Dynamic destinations remain intentionally unclaimed; guessing from arbitrary URL strings
672
- * would turn docs, links, and webhook examples into false external-service dependencies. */
666
+ /**
667
+ * The migration tools `init` can notice from a repository: the tool as a dependency, and its own migrations beside
668
+ * its schema. One row per tool; the next tool is a row. What a World's `runtime.database.prepare` needs (the command
669
+ * as the image runs it, the image path it runs in, the inputs' paths there) is the IMAGE's, which a repository does
670
+ * not state: a Dockerfile's prune and copies decide it. So this names the tool and where its migrations are, never
671
+ * the step.
672
+ */
673
+ const MIGRATION_TOOLS = [
674
+ { tool: 'prisma', dependency: 'prisma', schemaExtension: '.prisma', migrations: 'migrations' },
675
+ ];
676
+ /** Each migration tool the repository depends on that has migrations beside its schema (or beside its schema's folder). */
677
+ export function projectMigrationTools(input = process.cwd()) {
678
+ const root = resolve(input);
679
+ const dependencies = new Set(projectDependencies(root));
680
+ const found = new Map();
681
+ for (const row of MIGRATION_TOOLS) {
682
+ if (!dependencies.has(row.dependency))
683
+ continue;
684
+ for (const schema of projectSourceFiles(root, new Set([row.schemaExtension]))) {
685
+ const beside = [dirname(schema), dirname(dirname(schema))].map((dir) => resolve(dir, row.migrations))
686
+ .find((dir) => existsSync(dir) && statSync(dir).isDirectory());
687
+ if (!beside)
688
+ continue;
689
+ const migrations = relative(root, beside);
690
+ if (!found.has(`${row.tool}\0${migrations}`))
691
+ found.set(`${row.tool}\0${migrations}`, { tool: row.tool, schema: relative(root, schema), migrations });
692
+ }
693
+ }
694
+ return [...found.values()].sort((a, b) => a.migrations.localeCompare(b.migrations));
695
+ }
673
696
  export function projectFetchUrls(input = process.cwd()) {
674
697
  const root = resolve(input);
675
698
  const found = [];
@@ -1334,6 +1334,24 @@ async function startExternalService(service, paths, worldEnv, stripEnv, boot) {
1334
1334
  }
1335
1335
  injected[mapping.as] = value;
1336
1336
  }
1337
+ // The World's own infrastructure also says what the app's commands are told of its database (architecture,
1338
+ // managed infrastructure): that the live data carries the image's migration, and that this is a development
1339
+ // World whose app migrates when it runs.
1340
+ if (ownInfrastructureUp(external.up) && statusOutput) {
1341
+ let facts;
1342
+ try {
1343
+ facts = JSON.parse(statusOutput);
1344
+ }
1345
+ catch { /* a status that is not JSON says neither */ }
1346
+ const migrated = facts?.database?.migrated;
1347
+ if (Array.isArray(migrated) && migrated.every((word) => typeof word === 'string'))
1348
+ injected.VOLTER_WORLD_DATABASE_MIGRATED = JSON.stringify(migrated);
1349
+ const behind = facts?.database?.behind;
1350
+ if (Array.isArray(behind) && behind.every((word) => typeof word === 'string'))
1351
+ injected.VOLTER_WORLD_DATABASE_BEHIND = JSON.stringify(behind);
1352
+ if (facts?.development?.migrateDatabaseAtRuntime === true)
1353
+ injected.VOLTER_WORLD_DEVELOPMENT_MIGRATE_AT_RUNTIME = '1';
1354
+ }
1337
1355
  return {
1338
1356
  id: service.id,
1339
1357
  type: 'external',
@@ -3972,8 +3990,11 @@ export async function attachWorld(name, command, root = process.cwd(), options =
3972
3990
  throw new Error(`World "${name}" is changing lifecycle; retry after it finishes`);
3973
3991
  }
3974
3992
  const current = statusWorld(name, resolvedRoot);
3975
- if (!current.running || current.createdAt !== attached.instance.createdAt)
3976
- throw new Error(`World "${name}" is stopped or changed while attaching`);
3993
+ // Which of the two it was, with what was read: "stopped" and "another instance" are different repairs.
3994
+ if (!current.running)
3995
+ 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)`);
3996
+ if (current.createdAt !== attached.instance.createdAt)
3997
+ 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`);
3977
3998
  // Attaching to a World whose services died is allowed (a command may not need them), but never silent.
3978
3999
  for (const [id, state] of Object.entries(current.serviceStates)) {
3979
4000
  if (state === 'running' || state === 'external')