@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.
@@ -26,6 +26,38 @@ export type WorldSelection = {
26
26
  include: WorldSelectionSelector[];
27
27
  exclude: WorldSelectionSelector[];
28
28
  };
29
+ /** One row of the coverage proof as it is kept: `evidence` is the proof table's detected-via column, `connection`
30
+ * its transport column and `twinInWorld` its twin-in-world column (a display label, not a service id). */
31
+ export type WorldCoverageEntry = {
32
+ vendor: string;
33
+ evidence: string[];
34
+ connection?: string;
35
+ twinInWorld?: string;
36
+ };
37
+ /** The last coverage proof `init` or `covers` printed, kept beside the selection it applied (covers.ts
38
+ * `coverageRecord`): each list is the proof's rows of one status. A record; nothing reads it to route, select or boot. */
39
+ export type WorldCoverage = {
40
+ /** The package that made the proof. */
41
+ producer: {
42
+ package: string;
43
+ version: string;
44
+ };
45
+ /** The proof's verdict, which was the command's exit code. */
46
+ ok: boolean;
47
+ covered: WorldCoverageEntry[];
48
+ notCovered: WorldCoverageEntry[];
49
+ uninterceptable: WorldCoverageEntry[];
50
+ unknownSdk: {
51
+ signal: string;
52
+ evidence: string[];
53
+ connection?: string;
54
+ twinInWorld?: string;
55
+ }[];
56
+ acknowledged: (WorldCoverageEntry & {
57
+ reason: string;
58
+ })[];
59
+ excludedBySelection: WorldCoverageEntry[];
60
+ };
29
61
  /** The format-2 document is normalized to WorldConfig before runtime code sees it. */
30
62
  export type WorldManifestV2 = {
31
63
  schemaVersion: 2;
@@ -35,6 +67,7 @@ export type WorldManifestV2 = {
35
67
  };
36
68
  discovery?: {
37
69
  selection?: WorldSelection;
70
+ coverage?: WorldCoverage;
38
71
  };
39
72
  services: unknown[];
40
73
  runtime?: {
@@ -44,6 +77,10 @@ export type WorldManifestV2 = {
44
77
  strip?: string[];
45
78
  };
46
79
  network?: WorldNetwork;
80
+ database?: {
81
+ prepare?: WorldDatabasePrepare;
82
+ };
83
+ development?: WorldDevelopment;
47
84
  };
48
85
  serving?: {
49
86
  mode?: 'app' | 'bare';
@@ -290,8 +327,36 @@ export type WorldChangesetRules = {
290
327
  approvals?: number;
291
328
  summary?: 'required' | 'generated';
292
329
  };
330
+ /**
331
+ * The app's own migration of its database, run by `volter-world-infra prepare` against the prepared cluster
332
+ * (architecture, managed infrastructure): its command as the app's image runs it (an argv), the image path it runs in,
333
+ * and the paths under it whose bytes decide what it writes (the prepared cluster's stamp names their digests).
334
+ */
335
+ export type WorldDatabasePrepare = {
336
+ command: string[];
337
+ cwd: string;
338
+ inputs: string[];
339
+ };
340
+ /**
341
+ * SETTINGS FOR THE PERSON DEVELOPING THE APP AGAINST THIS WORLD (runtime.development). A World that sets any of
342
+ * them is a development World: `up` and an image's build say so each time, and an image built from it is not a
343
+ * prepared one. None of them is a way to make an application boot or to get past a failed preparation; an
344
+ * application's World does not set them.
345
+ *
346
+ * migrateDatabaseAtRuntime: the app may run its own migration when it starts, for a schema its developer is
347
+ * changing right now. Omitted, no: a migration is the World's `runtime.database.prepare`, run when the image is
348
+ * built.
349
+ */
350
+ export type WorldDevelopment = {
351
+ migrateDatabaseAtRuntime?: boolean;
352
+ };
293
353
  export type WorldConfig = {
294
354
  id: string;
355
+ /** The app's own migration step for the World's prepared database cluster (runtime.database.prepare). */
356
+ databasePrepare?: WorldDatabasePrepare;
357
+ /** The World's development settings (runtime.development; WorldDevelopment says whose they are). Its
358
+ * migrateDatabaseAtRuntime is said to the app's commands as VOLTER_WORLD_DEVELOPMENT_MIGRATE_AT_RUNTIME. */
359
+ development?: WorldDevelopment;
295
360
  /** How changesets are made and readied here; omitted: one approval, a summary required. */
296
361
  changesets?: WorldChangesetRules;
297
362
  /** HTTPS origin/exact-path/subtree grants with declared phase and explicit build passthrough; [] closes real egress. */
@@ -299,6 +364,8 @@ export type WorldConfig = {
299
364
  /** Internal normalized source format and saved discovery selection. */
300
365
  manifestVersion?: 1 | 2;
301
366
  selection?: WorldSelection;
367
+ /** The last coverage proof made for this config (`discovery.coverage`); carried so a rewrite keeps it. */
368
+ coverage?: WorldCoverage;
302
369
  description?: string;
303
370
  /** A world with no app in front of it (`init --bare <org>/<world>`): a shared world for a team, or a
304
371
  * world standing in for a vendor. `name` is how it is served: `/<org>/<world>/…`. */
@@ -497,6 +564,7 @@ export declare function isEnvironmentName(value: unknown): value is string;
497
564
  export declare const DEFAULT_WORLD_SELECTION: WorldSelection;
498
565
  export declare const LEGACY_WORLD_SELECTION: WorldSelection;
499
566
  export declare function assertWorldSelection(value: unknown, path: string): asserts value is WorldSelection;
567
+ export declare function assertWorldCoverage(value: unknown, path: string): asserts value is WorldCoverage;
500
568
  export declare function assertWorldConfig(value: unknown, path: string): WorldConfig;
501
569
  export declare function worldSelectionIncludes(selection: WorldSelection, vendor: string, usage: WorldUsage): boolean;
502
570
  /** Serialize normalized intent as the committed format-2 manifest. */
@@ -489,8 +489,32 @@ function normalizeV2(value, path) {
489
489
  const serving = (value.serving ?? {});
490
490
  const scenario = (value.scenario ?? {});
491
491
  const provenance = (value.provenance ?? {});
492
- assertKeys(discovery, ['selection'], 'discovery', path);
493
- assertKeys(runtime, ['isolation', 'environment', 'network'], 'runtime', path);
492
+ assertKeys(discovery, ['selection', 'coverage'], 'discovery', path);
493
+ assertKeys(runtime, ['isolation', 'environment', 'network', 'database', 'development'], 'runtime', path);
494
+ const database = runtime.database ?? {};
495
+ if (!record(database))
496
+ throw new Error(`runtime.database must be an object in ${path}`);
497
+ assertKeys(database, ['prepare'], 'runtime.database', path);
498
+ const development = runtime.development ?? {};
499
+ if (!record(development))
500
+ throw new Error(`runtime.development must be an object in ${path}`);
501
+ assertKeys(development, ['migrateDatabaseAtRuntime'], 'runtime.development', path);
502
+ if (development.migrateDatabaseAtRuntime !== undefined && typeof development.migrateDatabaseAtRuntime !== 'boolean')
503
+ throw new Error(`runtime.development.migrateDatabaseAtRuntime must be a boolean in ${path}`);
504
+ const databasePrepare = database.prepare;
505
+ if (databasePrepare !== undefined) {
506
+ if (!record(databasePrepare))
507
+ throw new Error(`runtime.database.prepare must be an object in ${path}`);
508
+ assertKeys(databasePrepare, ['command', 'cwd', 'inputs'], 'runtime.database.prepare', path);
509
+ const strings = (value) => Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string' && item !== '');
510
+ if (!strings(databasePrepare.command))
511
+ throw new Error(`runtime.database.prepare.command must be a non-empty argv in ${path}`);
512
+ if (typeof databasePrepare.cwd !== 'string' || !databasePrepare.cwd.startsWith('/'))
513
+ throw new Error(`runtime.database.prepare.cwd must be an absolute image path in ${path}`);
514
+ if (!strings(databasePrepare.inputs) || databasePrepare.inputs.some((input) => input.startsWith('/') || input.split('/').includes('..'))) {
515
+ throw new Error(`runtime.database.prepare.inputs must name paths under its cwd in ${path}`);
516
+ }
517
+ }
494
518
  assertKeys(serving, ['mode', 'name', 'share'], 'serving', path);
495
519
  assertKeys(scenario, ['actors', 'fixtures'], 'scenario', path);
496
520
  assertKeys(provenance, ['catalog'], 'provenance', path);
@@ -513,6 +537,8 @@ function normalizeV2(value, path) {
513
537
  throw new Error(`App serving must omit serving.name in ${path}`);
514
538
  const selection = discovery.selection ?? DEFAULT_WORLD_SELECTION;
515
539
  assertWorldSelection(selection, path);
540
+ if (discovery.coverage !== undefined)
541
+ assertWorldCoverage(discovery.coverage, path);
516
542
  if (!Array.isArray(value.services))
517
543
  throw new Error(`World manifest must define services[] in ${path}`);
518
544
  const normalized = {
@@ -521,9 +547,12 @@ function normalizeV2(value, path) {
521
547
  ...(metadata.description === undefined ? {} : { description: metadata.description }),
522
548
  manifestVersion: 2,
523
549
  selection,
550
+ ...(discovery.coverage === undefined ? {} : { coverage: discovery.coverage }),
524
551
  services: value.services.map((service) => serviceV2ToLegacy(service, path)),
525
552
  ...(runtime.isolation === undefined ? {} : { isolation: runtime.isolation }),
526
553
  ...(runtime.network === undefined ? {} : { network: normalizeWorldNetwork(runtime.network) }),
554
+ ...(databasePrepare === undefined ? {} : { databasePrepare: databasePrepare }),
555
+ ...(development.migrateDatabaseAtRuntime === true ? { development: { migrateDatabaseAtRuntime: true } } : {}),
527
556
  ...(environment.values === undefined ? {} : { env: environment.values }),
528
557
  ...(environment.strip === undefined ? {} : { stripEnv: environment.strip }),
529
558
  ...(mode === 'bare' ? { bare: { name: serving.name } } : {}),
@@ -536,6 +565,41 @@ function normalizeV2(value, path) {
536
565
  };
537
566
  return { ...assertLegacyWorldConfig(normalized, path), manifestVersion: 2, selection };
538
567
  }
568
+ const WORLD_COVERAGE_LISTS = ['covered', 'notCovered', 'uninterceptable', 'unknownSdk', 'acknowledged', 'excludedBySelection'];
569
+ export function assertWorldCoverage(value, path) {
570
+ if (!record(value))
571
+ throw new Error(`discovery.coverage must be an object in ${path}`);
572
+ assertKeys(value, ['producer', 'ok', ...WORLD_COVERAGE_LISTS], 'discovery.coverage', path);
573
+ const producer = value.producer;
574
+ if (!record(producer))
575
+ throw new Error(`discovery.coverage.producer must be an object in ${path}`);
576
+ assertKeys(producer, ['package', 'version'], 'discovery.coverage.producer', path);
577
+ for (const key of ['package', 'version'])
578
+ if (typeof producer[key] !== 'string' || producer[key] === '')
579
+ throw new Error(`discovery.coverage.producer.${key} must be a non-empty string in ${path}`);
580
+ if (typeof value.ok !== 'boolean')
581
+ throw new Error(`discovery.coverage.ok must be a boolean in ${path}`);
582
+ for (const list of WORLD_COVERAGE_LISTS) {
583
+ if (!Array.isArray(value[list]))
584
+ throw new Error(`discovery.coverage.${list} must be an array in ${path}`);
585
+ const name = list === 'unknownSdk' ? 'signal' : 'vendor';
586
+ const strings = [name, 'connection', 'twinInWorld', ...(list === 'acknowledged' ? ['reason'] : [])];
587
+ const required = [name, ...(list === 'acknowledged' ? ['reason'] : [])];
588
+ for (const entry of value[list]) {
589
+ if (!record(entry))
590
+ throw new Error(`discovery.coverage.${list} entries must be objects in ${path}`);
591
+ assertKeys(entry, [...strings, 'evidence'], `discovery.coverage.${list} entry`, path);
592
+ for (const key of strings) {
593
+ if (entry[key] === undefined && !required.includes(key))
594
+ continue;
595
+ if (typeof entry[key] !== 'string' || entry[key] === '')
596
+ throw new Error(`discovery.coverage.${list} ${key} must be a non-empty string in ${path}`);
597
+ }
598
+ if (!Array.isArray(entry.evidence) || entry.evidence.some((item) => typeof item !== 'string'))
599
+ throw new Error(`discovery.coverage.${list} evidence must be an array of strings in ${path}`);
600
+ }
601
+ }
602
+ }
539
603
  export function assertWorldConfig(value, path) {
540
604
  if (record(value) && value.schemaVersion !== undefined) {
541
605
  if (value.schemaVersion !== WORLD_SCHEMA_VERSION)
@@ -604,9 +668,11 @@ export function worldConfigDocument(config, selection = config.selection ?? DEFA
604
668
  return {
605
669
  schemaVersion: WORLD_SCHEMA_VERSION,
606
670
  metadata: { id: config.id, ...(config.description === undefined ? {} : { description: config.description }) },
607
- discovery: { selection },
608
- ...(config.isolation === undefined && config.env === undefined && config.stripEnv === undefined && config.network === undefined ? {} : { runtime: {
671
+ discovery: { selection, ...(config.coverage === undefined ? {} : { coverage: config.coverage }) },
672
+ ...(config.isolation === undefined && config.env === undefined && config.stripEnv === undefined && config.network === undefined && config.databasePrepare === undefined && !config.development?.migrateDatabaseAtRuntime ? {} : { runtime: {
609
673
  ...(config.isolation === undefined ? {} : { isolation: config.isolation }),
674
+ ...(config.databasePrepare === undefined ? {} : { database: { prepare: config.databasePrepare } }),
675
+ ...(config.development?.migrateDatabaseAtRuntime ? { development: { migrateDatabaseAtRuntime: true } } : {}),
610
676
  ...(config.network === undefined ? {} : { network: normalizeWorldNetwork(config.network) }),
611
677
  ...(config.env === undefined && config.stripEnv === undefined ? {} : { environment: {
612
678
  ...(config.env === undefined ? {} : { values: config.env }),
@@ -29,7 +29,13 @@ export async function upCommand(subject, rest, root) {
29
29
  const appUrl = optionValue(rest, '--app-url') || undefined;
30
30
  if (appUrl !== undefined && !(URL.canParse(appUrl) && /^https?:$/.test(new URL(appUrl).protocol)))
31
31
  throw new Error(`volter-world up: --app-url "${appUrl}" must be an http(s) URL`);
32
- const instance = await superviseWorldUp(subject, { name, root, mode, envFile, forceEnvOutput: rest.includes('--force'), owner: ownerOption(rest), ...(isolation ? { isolation } : {}), ...(keepState ? { keepState: true } : {}) });
32
+ const options = { name, root, mode, envFile, forceEnvOutput: rest.includes('--force'), owner: ownerOption(rest), ...(isolation ? { isolation } : {}), ...(keepState ? { keepState: true } : {}) };
33
+ // --in-process: the boot runs in this process, for a caller with no terminal to lose (a browser tab's page, whose
34
+ // loss ends every process it runs). The owner process exists so a closed terminal does not leave a boot half-done;
35
+ // where there is none, it costs a whole Node start before the boot and its own end after it (Rallly run 35: 611 ms
36
+ // from the owner's "returning" to its reply reaching this process). The caller's SIGINT or SIGTERM still cancels the
37
+ // boot, which rolls back.
38
+ const instance = rest.includes('--in-process') ? await upInProcess(subject, options) : await superviseWorldUp(subject, options);
33
39
  if (appUrl !== undefined) {
34
40
  const { setAppUrl } = await import("./app-url.js");
35
41
  setAppUrl(instance.name, appUrl, { root });
@@ -40,6 +46,20 @@ export async function upCommand(subject, rest, root) {
40
46
  else
41
47
  printReady(instance);
42
48
  }
49
+ async function upInProcess(configId, options) {
50
+ const { upWorld } = await import("./runtime.js");
51
+ const abort = new AbortController();
52
+ const cancel = (signal) => abort.abort(signal);
53
+ process.on('SIGINT', cancel);
54
+ process.on('SIGTERM', cancel);
55
+ try {
56
+ return await upWorld(configId, options, abort.signal);
57
+ }
58
+ finally {
59
+ process.off('SIGINT', cancel);
60
+ process.off('SIGTERM', cancel);
61
+ }
62
+ }
43
63
  export function printReady(instance) {
44
64
  process.stdout.write(`World ${instance.name} is ready (${instance.config})
45
65
  Instance: ${instance.instance}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.144",
3
+ "version": "3.0.146",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
@@ -70,14 +70,14 @@
70
70
  "node": ">=22.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@volter/world-core": "3.0.144",
74
- "@volter/world-console": "3.0.139"
73
+ "@volter/world-core": "3.0.146",
74
+ "@volter/world-console": "3.0.141"
75
75
  },
76
76
  "dependencies": {
77
77
  "@electric-sql/pglite": "0.5.8",
78
78
  "@electric-sql/pglite-pgvector": "0.0.9",
79
79
  "@volter/world-access": "3.0.1",
80
- "@volter/world-core": "3.0.144",
80
+ "@volter/world-core": "3.0.146",
81
81
  "mongodb": "~6.20.0",
82
82
  "pg-gateway": "0.3.0-beta.4",
83
83
  "smol-toml": "^1.8.0",
@@ -7,7 +7,7 @@ import { createRequire } from 'node:module';
7
7
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { dirname, join, resolve, resolve as resolvePath } from 'node:path';
10
- import { activateScript, branchWorld, checkoutWorld, fetchFromOrigin, pushWorldChangeset, rebaseWorldChangeset, resetWorld, seedWorld, worldOrigin, approveWorldChangeset, writeWorldClock, checkPrerequisites, coverWorld, createWorldChangeset, diffWorld, doctorWorld, downWorld, findWorldChangeset, formatCoverageReport, formatInitReport, formatPrerequisiteChecks, formatProjectInspection, initWorld, inspectProject, isRemoteWorldRef, listWorldChangesets, listWorlds, markWorld, migrateWorldConfig, readReflectRoutes, reflectRoutesPath, replayWorldChangeset, resolveWorldRef, attachWorld, retireWorldConsumers, runWorld, shareWorldServices, shellWorld, startReflectFront, startReflectResolver, statusWorld, statusWorldChangeset, tailWorldActions, unshareWorld, upWorld, urlsWorld, urlWorld, verifyWorldChangeset, worldManifest, writeReflectRoutes, readReflectManifest, writeReflectManifest, clearReflectManifest, composeOverrideForReflect, splitDockerComposeArgs, dockerComposeWithOverride, ATTACHED_CA_PATH } from './index.ts';
10
+ import { activateScript, branchWorld, checkoutWorld, fetchFromOrigin, pushWorldChangeset, rebaseWorldChangeset, resetWorld, seedWorld, worldOrigin, approveWorldChangeset, writeWorldClock, checkPrerequisites, coverWorld, createWorldChangeset, diffWorld, doctorWorld, downWorld, findWorldChangeset, formatCoverageReport, recordCoverage, formatInitReport, formatPrerequisiteChecks, formatProjectInspection, initWorld, inspectProject, isRemoteWorldRef, listWorldChangesets, listWorlds, markWorld, migrateWorldConfig, readReflectRoutes, reflectRoutesPath, replayWorldChangeset, resolveWorldRef, attachWorld, retireWorldConsumers, runWorld, shareWorldServices, shellWorld, startReflectFront, startReflectResolver, statusWorld, statusWorldChangeset, tailWorldActions, unshareWorld, upWorld, urlsWorld, urlWorld, verifyWorldChangeset, worldManifest, writeReflectRoutes, readReflectManifest, writeReflectManifest, clearReflectManifest, composeOverrideForReflect, splitDockerComposeArgs, dockerComposeWithOverride, ATTACHED_CA_PATH } from './index.ts';
11
11
  import { formatApplication, formatChangeset, formatRebaseReport, formatChangesetStatus, formatLedgerDelta, formatReplayReport, formatVerification, parseVerifierExpression } from '@volter/world-core/runtime';
12
12
  import { advertiseWorldManifest, fetchRemoteManifest, MANIFEST_PATH, remoteAttachEnv, startManifestServer } from './serve.ts';
13
13
  import type { WorldManifest } from './attach.ts';
@@ -61,7 +61,7 @@ function printHelp(): void {
61
61
  # emission. Exit 0 = ready to \`up\`; exit 1 = the honest worklist.
62
62
  # Deterministic: same repo + same pack catalog => identical bytes.
63
63
  # init DIAGNOSES; it never boots — it prints the exact next commands
64
- volter-world up <config> --env-out=<path> [--owner <label>] [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--keep-state] [--app-url <url>] [--json] [--root <repo>]
64
+ volter-world up <config> --env-out=<path> [--owner <label>] [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--keep-state] [--app-url <url>] [--in-process] [--json] [--root <repo>]
65
65
  volter-world outdated <world> [--json] — each mounted twin: pinned / mounted / catalog-current version and protocol standing (R17)
66
66
  volter-world audit <world> [--json] — each mounted twin's grade, as the catalog records it (R17)
67
67
  volter-world down <name> [--grace-ms <ms>] [--purge] [--if-instance <token>] [--root <repo>] # SIGTERM, then SIGKILL survivors after the grace (default 5000ms)
@@ -468,6 +468,14 @@ export async function main(): Promise<void> {
468
468
  if (!repo) throw new Error('volter-world covers: --repo <path> is required (the application repo to prove coverage for)');
469
469
  const report = coverWorld(subject, repo, { root, allowUnknown: rest.includes('--allow-unknown'), acknowledge: acknowledgeOptions(rest, 'covers') });
470
470
  process.stdout.write(rest.includes('--json') ? `${JSON.stringify(report, null, 2)}\n` : formatCoverageReport(report));
471
+ // `covers` is a reading. It keeps the proof it printed only as the plain verb on a World's own config: with --json
472
+ // it prints and writes nothing, and it never rewrites the config a running instance booted from. Whether it kept
473
+ // the proof is said on stderr, beside the report and outside its JSON.
474
+ if (!rest.includes('--json')) {
475
+ if (report.worldSource !== 'config') process.stderr.write(`coverage not recorded: ${report.world} was read from its instance (${report.worldPath}); the config it booted from is not rewritten\n`);
476
+ else if (recordCoverage(report, root)) process.stderr.write(`coverage recorded in ${report.configPath}\n`);
477
+ else process.stderr.write(`coverage not recorded: ${report.configPath} is a format-1 document, which has no discovery section, or is no longer on disk\n`);
478
+ }
471
479
  if (!report.ok) process.exit(1);
472
480
  return;
473
481
  }
package/src/covers.ts CHANGED
@@ -26,14 +26,15 @@ import { errorMessage } from './error-message.ts';
26
26
  import { existsSync, readFileSync } from 'node:fs';
27
27
  import { basename, dirname, join, relative, resolve } from 'node:path';
28
28
  import { fileURLToPath } from 'node:url';
29
- import { installedPackageDir, packVendor, vendorOfPackModule } from '@volter/world-core/runtime';
30
- import { loadWorldConfig } from './configs.ts';
29
+ import { getActiveWorldStore, installedPackageDir, packVendor, vendorOfPackModule } from '@volter/world-core/runtime';
30
+ import { loadWorldConfig, writeWorldConfig } from './configs.ts';
31
+ import { runtimeManifest } from './package-manifest.ts';
31
32
  import { VENDOR_SERVER_BACKINGS } from './backings.ts';
32
33
  import { injectableVendorKeys, loadInject, twinUrlVendorFor } from './inject-map.ts';
33
34
  import { connectionProtocol, projectConnections, projectDependencies, projectManifestDirs, projectEnvNames, projectFetchUrls, projectNpmRegistries, PYPI_TWINS, SDK_TWINS } from './project-inspect.ts';
34
35
  import { overlayCoversMaps, packFacts } from './pack-facts.ts';
35
36
  import { statusWorld } from './runtime.ts';
36
- import { ISSUE_ENV_PREFIX, worldSelectionIncludes, type WorldSelection, type WorldServiceConfig, type WorldServiceInstance, type WorldUsage } from './schema.ts';
37
+ import { ISSUE_ENV_PREFIX, worldSelectionIncludes, type WorldCoverage, type WorldCoverageEntry, type WorldSelection, type WorldServiceConfig, type WorldServiceInstance, type WorldUsage } from './schema.ts';
37
38
 
38
39
  export type CoverageStatus = 'covered' | 'missing' | 'uninterceptable' | 'unknown-sdk' | 'acknowledged' | 'excluded';
39
40
 
@@ -54,6 +55,9 @@ export type CoverageReport = {
54
55
  /** 'instance' when a booted/recorded instance was read; 'config' for the stable config */
55
56
  worldSource: 'instance' | 'config';
56
57
  worldPath: string;
58
+ /** The World's config, where the proof is kept (`recordCoverage`): `worldPath` itself for a config, the config
59
+ * an instance booted from otherwise. */
60
+ configPath: string;
57
61
  repo: string;
58
62
  twinsInWorld: string[];
59
63
  rows: CoverageRow[];
@@ -348,6 +352,8 @@ type WorldTwinInventory = {
348
352
  services: Array<WorldServiceConfig | WorldServiceInstance>;
349
353
  source: 'instance' | 'config';
350
354
  path: string;
355
+ /** The config the World is declared in: the one read, or the one its instance booted from. */
356
+ configPath: string;
351
357
  /** normalized identifier → id record (e.g. 'stripe' → { label: 'stripe (service)', … }) */
352
358
  ids: Map<string, WorldTwinId>;
353
359
  connections: Map<string, { protocol: string | null; label: string }>;
@@ -593,7 +599,7 @@ export function worldTwinInventory(world: string, root: string): WorldTwinInvent
593
599
  // a bare env `*_TWIN_URL` var is only a REACHABLE twin when the injector reads it
594
600
  if (stem) addId(ids, normalizeId(stem[1]!), `env ${key}`, twinUrlVendorFor(key) !== null);
595
601
  }
596
- return { services: Object.values(status.services), source: 'instance', path: `${status.dirs.instance}/instance.json`, ids, connections: serviceConnections(Object.values(status.services), status.env, root), selection: status.selection, declared: declaredTwin(Object.values(status.services), status.env, root) };
602
+ return { services: Object.values(status.services), source: 'instance', path: `${status.dirs.instance}/instance.json`, configPath: status.configPath, ids, connections: serviceConnections(Object.values(status.services), status.env, root), selection: status.selection, declared: declaredTwin(Object.values(status.services), status.env, root) };
597
603
  } catch (error) {
598
604
  instanceError = errorMessage(error);
599
605
  }
@@ -608,7 +614,7 @@ export function worldTwinInventory(world: string, root: string): WorldTwinInvent
608
614
  for (const [name, value] of Object.entries(config.env ?? {})) {
609
615
  if (value.startsWith(ISSUE_ENV_PREFIX)) connections.set(name, { protocol: 'issued', label: `${value.slice(ISSUE_ENV_PREFIX.length)} (${name}; issued)` });
610
616
  }
611
- return { services: config.services, source: 'config', path, ids, connections, selection: config.selection, declared: declaredTwin(config.services, undefined, root) };
617
+ return { services: config.services, source: 'config', path, configPath: path, ids, connections, selection: config.selection, declared: declaredTwin(config.services, undefined, root) };
612
618
  } catch (error) {
613
619
  const configError = errorMessage(error);
614
620
  throw new Error(
@@ -1023,6 +1029,7 @@ export function coverWorld(world: string, repoPath: string, options: CoverageOpt
1023
1029
  world,
1024
1030
  worldSource: inventory.source,
1025
1031
  worldPath: inventory.path,
1032
+ configPath: inventory.configPath,
1026
1033
  repo,
1027
1034
  twinsInWorld: [...inventory.ids.values()].map((id) => id.label).sort(),
1028
1035
  rows,
@@ -1037,6 +1044,47 @@ export function coverWorld(world: string, repoPath: string, options: CoverageOpt
1037
1044
  };
1038
1045
  }
1039
1046
 
1047
+ /**
1048
+ * The report as the data `init` keeps in the config (`discovery.coverage`): the same rows `formatCoverageReport`
1049
+ * prints, grouped by the status each verdict line counts. Every push to `covered`, `missing`, `uninterceptable`,
1050
+ * `unknown`, `acknowledged` and `excluded` in `coverWorld` adds the row of that status, so the lists here name what
1051
+ * those lines name. Paths (`worldPath`, `configPath`, `repo`) stay out: the config is portable and init's bytes are deterministic.
1052
+ */
1053
+ export function coverageRecord(report: CoverageReport): WorldCoverage {
1054
+ const entry = (row: CoverageRow): Omit<WorldCoverageEntry, 'vendor'> => ({
1055
+ evidence: row.detectedVia,
1056
+ ...(row.connection === undefined ? {} : { connection: row.connection }),
1057
+ ...(row.twinInWorld === null ? {} : { twinInWorld: row.twinInWorld }),
1058
+ });
1059
+ const of = (status: CoverageStatus): WorldCoverageEntry[] =>
1060
+ report.rows.filter((row) => row.status === status).map((row) => ({ vendor: row.vendor, ...entry(row) }));
1061
+ return {
1062
+ producer: { package: '@volter/world-runtime', version: runtimeManifest().version ?? 'unversioned' },
1063
+ ok: report.ok,
1064
+ covered: of('covered'),
1065
+ notCovered: of('missing'),
1066
+ uninterceptable: of('uninterceptable'),
1067
+ unknownSdk: report.rows.filter((row) => row.status === 'unknown-sdk').map((row) => ({ signal: row.vendor, ...entry(row) })),
1068
+ acknowledged: of('acknowledged').map((row) => ({ ...row, reason: report.acknowledged.find((held) => held.vendor === row.vendor)!.reason })),
1069
+ excludedBySelection: of('excluded'),
1070
+ };
1071
+ }
1072
+
1073
+ /**
1074
+ * Keep a proof just made in the World's config, replacing the record of an earlier one. It answers true when it
1075
+ * wrote the record and false when it wrote nothing: a format-1 document has no `discovery` section and a World whose
1076
+ * config is no longer on disk has nowhere to keep it. The caller decides whether to record at all and says the
1077
+ * answer (`init` writes its own record into the config it makes): `covers` records only as the plain verb on a
1078
+ * World's own config, never with --json and never into the config a running instance booted from (cli-commands.ts).
1079
+ */
1080
+ export function recordCoverage(report: CoverageReport, root = process.cwd()): boolean {
1081
+ if (!getActiveWorldStore().exists(report.configPath)) return false;
1082
+ const { path, config } = loadWorldConfig(report.configPath, root);
1083
+ if (config.manifestVersion !== 2) return false;
1084
+ writeWorldConfig(path, { ...config, coverage: coverageRecord(report) });
1085
+ return true;
1086
+ }
1087
+
1040
1088
  export function formatCoverageReport(report: CoverageReport): string {
1041
1089
  const lines: string[] = [];
1042
1090
  lines.push(`World ${report.world} (${report.worldSource}: ${report.worldPath})`);
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@ export type {
13
13
  WorldRunRecord,
14
14
  WorldResourceRequirements,
15
15
  WorldManifestV2,
16
+ WorldCoverage,
17
+ WorldCoverageEntry,
16
18
  WorldSelection,
17
19
  WorldSelectionSelector,
18
20
  WorldUsage,
@@ -87,11 +89,13 @@ export { fakeEnvValue, fakeGoogleOAuthClientJson, fakeGoogleServiceAccountJson,
87
89
  export type { FakeServiceAccountOptions } from './fixture-env.ts';
88
90
  export {
89
91
  coverWorld,
92
+ coverageRecord,
90
93
  detectRepoVendors,
91
94
  envNameVendor,
92
95
  formatCoverageReport,
93
96
  injectorVendorKeysFor,
94
97
  isCredentialShapedEnvName,
98
+ recordCoverage,
95
99
  worldTwinInventory,
96
100
  } from './covers.ts';
97
101
  export type { CoverageReport, CoverageRow, CoverageStatus, CoverageOptions, RepoVendorSignals } from './covers.ts';
package/src/infra.ts CHANGED
@@ -12,7 +12,7 @@ import { delimiter, dirname, join } from 'node:path';
12
12
  import { signalChild } from './process-signal.mjs';
13
13
  import { spawn } from 'node:child_process';
14
14
  import { fileURLToPath } from 'node:url';
15
- import { infraConnections, parseInfraDefinition } from './pglite-backing.ts';
15
+ import { containerlessDataDirectory, infraConnections, liveDatabaseBehind, liveDatabaseMigration, developmentMigratesAtRuntime, DEVELOPMENT_MIGRATION_NOTICE, parseInfraDefinition } from './pglite-backing.ts';
16
16
  import { errorMessage } from './error-message.ts';
17
17
 
18
18
  /** Whether a program is on PATH, read from the file system without starting a process: where a missing program
@@ -77,6 +77,11 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
77
77
  // prepare: the image build's step that makes the World's PGlite cluster once, on its host, so the World's first up
78
78
  // opens it instead of running initdb (pglite-backing.ts, pglitePrepare; ADR 0018). It needs the World's
79
79
  // definition, not an instance.
80
+ // A backing the World's environment forces is said where the backing is said, and in the status fact: forcing one
81
+ // is a choice someone made for this World, and it is never silent.
82
+ const forcedBy = (backing: 'docker' | 'pglite' | 'native'): string => env.VOLTER_WORLD_INFRA_BACKING === backing ? ' (forced by VOLTER_WORLD_INFRA_BACKING)' : '';
83
+ const backingFact = (backing: 'docker' | 'pglite' | 'native'): { backing: { is: string; by: 'forced' | 'chosen' } } =>
84
+ ({ backing: { is: backing, by: env.VOLTER_WORLD_INFRA_BACKING === backing ? 'forced' : 'chosen' } });
80
85
  if (phase === 'prepare') {
81
86
  const config = env.VOLTER_WORLD_CONFIG;
82
87
  const declaredDefinition = config ? join(dirname(config), 'world.infrastructure.yml') : undefined;
@@ -90,11 +95,23 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
90
95
  return 0;
91
96
  }
92
97
  const { pglitePrepare } = await import('./pglite-backing.ts');
98
+ const { nativePrepare, nativePostgresTools } = await import('./native-postgres-backing.ts');
99
+ // The backing up would choose for this PostgreSQL: native where the PostgreSQL programs are (containerless rule
100
+ // 2; in a tab image, the tab's own build), PGlite otherwise or when forced.
101
+ const native = env.VOLTER_WORLD_INFRA_BACKING !== 'pglite' && nativePostgresTools(env) !== undefined;
102
+ // The database prepare step's own output (prisma's "N migrations found … No pending migrations") is the
103
+ // preparation's record, said where its caller reads it.
104
+ const launch = { env, ...(signal ? { signal } : {}), note: (what: string) => io.out(`${what.trimEnd()}\n`) };
93
105
  try {
94
- io.out(`managed infrastructure prepared ${await pglitePrepare(postgres[0]!, config, { env, ...(signal ? { signal } : {}) })}\n`);
106
+ const prepared = native
107
+ ? await nativePrepare(postgres[0]!, readFileSync(declaredDefinition, 'utf8'), config, launch)
108
+ : await pglitePrepare(postgres[0]!, config, launch);
109
+ io.out(`managed infrastructure prepared ${prepared} (backing: ${native ? 'native PostgreSQL' : 'PGlite'}${forcedBy(native ? 'native' : 'pglite')})\n`);
95
110
  return 0;
96
111
  } catch (error) {
97
112
  io.err(`managed infrastructure prepare failed: ${errorMessage(error)}\n`);
113
+ // Said where the person or agent reading the failure stands: the fix is here, in the preparation.
114
+ io.err('The fix is in this build-time preparation (the step above, or what it runs against). Migrating at run time is not the remedy for a failed preparation.\n');
98
115
  return 1;
99
116
  }
100
117
  }
@@ -110,7 +127,19 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
110
127
  io.err('managed infrastructure: declared definition is missing\n');
111
128
  return 2;
112
129
  }
130
+ // A development World says so every time it comes up: its use is never silent.
131
+ if (phase === 'up' && developmentMigratesAtRuntime(worldConfig)) io.out(`${DEVELOPMENT_MIGRATION_NOTICE}\n`);
113
132
 
133
+ // What the app's commands are told of the World's database (architecture, managed infrastructure): the declared
134
+ // step's command while the live data carries the migration the image expects, the same command when the World
135
+ // declares the step and the live data does not carry it, and whether this is a development World whose app migrates when it runs. `postgresData` is the serving backing's data directory, set where a status knows it.
136
+ let postgresData: string | undefined;
137
+ const databaseFacts = (): { database?: { migrated?: string[]; behind?: string[] }; development?: { migrateDatabaseAtRuntime: true } } => {
138
+ const migrated = postgresData ? liveDatabaseMigration(worldConfig, env, postgresData) : undefined;
139
+ const behind = postgresData ? liveDatabaseBehind(worldConfig, env, postgresData) : undefined;
140
+ return { ...(migrated || behind ? { database: { ...(migrated ? { migrated } : {}), ...(behind ? { behind } : {}) } } : {}),
141
+ ...(developmentMigratesAtRuntime(worldConfig) ? { development: { migrateDatabaseAtRuntime: true as const } } : {}) };
142
+ };
114
143
  const base = ['compose', '-f', definition];
115
144
  const run = async (args: string[]): Promise<Ran> => {
116
145
  const ran = await runCommand('docker', [...base, ...args], { env: { ...env, VOLTER_WORLD_DATA: worldData }, timeout: 120_000, ...(signal ? { signal } : {}) });
@@ -216,7 +245,7 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
216
245
  return 1;
217
246
  }
218
247
  if (phase === 'up') {
219
- io.out('managed infrastructure backing: containerless (no container runtime)\n');
248
+ io.out(`managed infrastructure backing: containerless${forcedBy('pglite') || ' (no container runtime)'}\n`);
220
249
  if (services.some((s) => s.kind === 'postgres')) {
221
250
  io.out('managed infrastructure: PostgreSQL is PGlite, one session every connection takes in turn; a read through '
222
251
  + 'the pool inside a transaction waits on itself until that transaction ends. A postgres server on PATH, or '
@@ -233,11 +262,12 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
233
262
  }
234
263
  if (phase === 'status') {
235
264
  const status = await pgliteStatus(services, worldData!);
265
+ if (services.some((s) => s.kind === 'postgres')) postgresData = join(worldData!, containerlessDataDirectory('postgres')!);
236
266
  if (!status.ok) {
237
267
  io.err(`managed infrastructure status failed: ${status.ready}/${services.length} declared services are ready\n`);
238
268
  return 1;
239
269
  }
240
- io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env) })}\n`);
270
+ io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env), ...backingFact('pglite'), ...databaseFacts() })}\n`);
241
271
  return 0;
242
272
  }
243
273
  await pgliteDown(services, worldData!);
@@ -298,7 +328,7 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
298
328
  }
299
329
  if (composed?.length === 0) {
300
330
  if (phase === 'up') io.out('managed infrastructure ready\n');
301
- else if (phase === 'status') io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env) })}\n`);
331
+ else if (phase === 'status') io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env), ...databaseFacts() })}\n`);
302
332
  else io.out('managed infrastructure stopped\n');
303
333
  return 0;
304
334
  }
@@ -317,7 +347,7 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
317
347
  }
318
348
  try {
319
349
  if (phase === 'up') {
320
- io.out('managed infrastructure backing: native PostgreSQL (multi-session)\n');
350
+ io.out(`managed infrastructure backing: native PostgreSQL (multi-session)${forcedBy('native')}\n`);
321
351
  await nativePostgresUp(postgres, worldData, readFileSync(definition, 'utf8'), launch);
322
352
  try { await pgliteUp(others, worldData, launch); }
323
353
  catch (error) {
@@ -329,7 +359,8 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
329
359
  } else if (phase === 'status') {
330
360
  const ready = others.length === 0 || (await pgliteStatus(others, worldData)).ok;
331
361
  if (!ready || !await nativePostgresStatus(worldData)) return 1;
332
- io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env) })}\n`);
362
+ postgresData = join(worldData, 'native-postgres-data');
363
+ io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env), ...backingFact('native'), ...databaseFacts() })}\n`);
333
364
  } else {
334
365
  await pgliteDown(others, worldData);
335
366
  await nativePostgresDown(worldData);
@@ -345,6 +376,7 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
345
376
 
346
377
  const only = composed ?? [];
347
378
  if (phase === 'up') {
379
+ if (forcedBy('docker')) io.out(`managed infrastructure backing: container runtime${forcedBy('docker')}\n`);
348
380
  const result = await run(['up', '-d', '--wait', ...only]);
349
381
  if (result.status !== 0) return fail(result);
350
382
  io.out('managed infrastructure ready\n');
@@ -359,7 +391,7 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
359
391
  io.err(`managed infrastructure status failed: ${runningNames.length + twinned.length + native.length}/${expectedNames.length + twinned.length + native.length} declared services are ready\n`);
360
392
  return 1;
361
393
  }
362
- io.out(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length + native.length, connections: infraConnections(declared, env) })}\n`);
394
+ io.out(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length + native.length, connections: infraConnections(declared, env), ...backingFact('docker'), ...databaseFacts() })}\n`);
363
395
  } else {
364
396
  const result = await run(['down', '--remove-orphans']);
365
397
  if (result.status !== 0) return fail(result);