@volter/world-runtime 3.0.163 → 3.0.164

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.
Files changed (137) hide show
  1. package/dist/src/ancestry.js +2 -2
  2. package/dist/src/app-url.d.ts +5 -1
  3. package/dist/src/app-url.js +14 -1
  4. package/dist/src/branch.js +28 -8
  5. package/dist/src/browser-files.d.ts +5 -0
  6. package/dist/src/browser-files.js +14 -0
  7. package/dist/src/browsers.d.ts +2 -3
  8. package/dist/src/browsers.js +4 -4
  9. package/dist/src/catalog.d.ts +2 -1
  10. package/dist/src/catalog.js +3 -2
  11. package/dist/src/changeset.js +1 -1
  12. package/dist/src/cli-commands.js +71 -18
  13. package/dist/src/cli-load-clock.d.ts +1 -0
  14. package/dist/src/cli-load-clock.js +2 -0
  15. package/dist/src/cli.js +5 -1
  16. package/dist/src/clickhouse-backing.d.ts +2 -0
  17. package/dist/src/clickhouse-backing.js +145 -0
  18. package/dist/src/command-lifetime.d.mts +1 -1
  19. package/dist/src/command-lifetime.mjs +5 -2
  20. package/dist/src/configs.js +1 -1
  21. package/dist/src/consumers.js +1 -1
  22. package/dist/src/covers.d.ts +4 -2
  23. package/dist/src/covers.js +8 -4
  24. package/dist/src/go-mysql-binding.d.ts +19 -0
  25. package/dist/src/go-mysql-binding.js +51 -0
  26. package/dist/src/guest-processes.d.mts +1 -1
  27. package/dist/src/guest-processes.mjs +22 -14
  28. package/dist/src/host-cli.js +8 -1
  29. package/dist/src/host-ready.d.ts +16 -2
  30. package/dist/src/host-ready.js +66 -39
  31. package/dist/src/host-worker.js +7 -1
  32. package/dist/src/host.d.ts +26 -0
  33. package/dist/src/host.js +93 -3
  34. package/dist/src/infra-definition.d.ts +35 -0
  35. package/dist/src/infra-definition.js +146 -0
  36. package/dist/src/infra.d.ts +3 -0
  37. package/dist/src/infra.js +375 -105
  38. package/dist/src/init.d.ts +46 -0
  39. package/dist/src/init.js +445 -18
  40. package/dist/src/lifecycle-record.d.ts +5 -1
  41. package/dist/src/lifecycle-record.js +27 -4
  42. package/dist/src/local-branches.js +1 -1
  43. package/dist/src/mariadb-bootstrap.d.ts +32 -0
  44. package/dist/src/mariadb-bootstrap.js +90 -0
  45. package/dist/src/mariadb-supplier.d.ts +23 -0
  46. package/dist/src/mariadb-supplier.js +215 -0
  47. package/dist/src/mongodb-backing.d.ts +4 -0
  48. package/dist/src/mongodb-backing.js +71 -1
  49. package/dist/src/native-mariadb-backing.d.ts +13 -0
  50. package/dist/src/native-mariadb-backing.js +512 -0
  51. package/dist/src/native-postgres-backing.d.ts +4 -10
  52. package/dist/src/native-postgres-backing.js +428 -43
  53. package/dist/src/native-server-inputs.d.ts +16 -0
  54. package/dist/src/native-server-inputs.js +204 -0
  55. package/dist/src/origin.js +3 -3
  56. package/dist/src/original-inputs.d.ts +14 -0
  57. package/dist/src/original-inputs.js +84 -0
  58. package/dist/src/pack-facts.d.ts +9 -1
  59. package/dist/src/pglite-backing.d.ts +41 -17
  60. package/dist/src/pglite-backing.js +213 -79
  61. package/dist/src/pglite-host.mjs +482 -10
  62. package/dist/src/process-groups.d.mts +6 -1
  63. package/dist/src/process-groups.d.ts +2 -1
  64. package/dist/src/process-groups.js +1 -1
  65. package/dist/src/process-groups.mjs +82 -15
  66. package/dist/src/process-signal.d.mts +2 -2
  67. package/dist/src/process-signal.mjs +29 -9
  68. package/dist/src/project-inspect.d.ts +12 -0
  69. package/dist/src/project-inspect.js +41 -4
  70. package/dist/src/proxy-env.d.ts +34 -0
  71. package/dist/src/proxy-env.js +114 -0
  72. package/dist/src/redirect-proxy.d.ts +1 -31
  73. package/dist/src/redirect-proxy.js +3 -112
  74. package/dist/src/redis-backing.d.ts +6 -4
  75. package/dist/src/redis-backing.js +20 -168
  76. package/dist/src/runtime.d.ts +1 -0
  77. package/dist/src/runtime.js +503 -218
  78. package/dist/src/schema.d.ts +1 -0
  79. package/dist/src/schema.js +5 -3
  80. package/dist/src/served-world.js +3 -3
  81. package/dist/src/twin-backing.d.ts +47 -0
  82. package/dist/src/twin-backing.js +343 -0
  83. package/dist/src/up-cli.js +56 -3
  84. package/package.json +3 -3
  85. package/src/ancestry.ts +2 -2
  86. package/src/app-url.ts +9 -2
  87. package/src/branch.ts +26 -8
  88. package/src/browser-files.ts +15 -0
  89. package/src/browsers.ts +4 -4
  90. package/src/catalog.ts +4 -3
  91. package/src/changeset.ts +1 -1
  92. package/src/cli-commands.ts +85 -18
  93. package/src/cli-load-clock.ts +2 -0
  94. package/src/cli.ts +5 -1
  95. package/src/clickhouse-backing.ts +70 -0
  96. package/src/command-lifetime.d.mts +1 -1
  97. package/src/command-lifetime.mjs +5 -2
  98. package/src/configs.ts +1 -1
  99. package/src/consumers.ts +1 -1
  100. package/src/covers.ts +9 -4
  101. package/src/go-mysql-binding.ts +49 -0
  102. package/src/guest-processes.d.mts +1 -1
  103. package/src/guest-processes.mjs +22 -14
  104. package/src/host-cli.ts +7 -1
  105. package/src/host-ready.ts +62 -31
  106. package/src/host-worker.ts +6 -2
  107. package/src/host.ts +88 -6
  108. package/src/infra-definition.ts +135 -0
  109. package/src/infra.ts +250 -45
  110. package/src/init.ts +416 -17
  111. package/src/lifecycle-record.ts +18 -4
  112. package/src/local-branches.ts +1 -1
  113. package/src/mariadb-bootstrap.ts +107 -0
  114. package/src/mariadb-supplier.ts +199 -0
  115. package/src/mongodb-backing.ts +59 -1
  116. package/src/native-mariadb-backing.ts +418 -0
  117. package/src/native-postgres-backing.ts +353 -41
  118. package/src/native-server-inputs.ts +169 -0
  119. package/src/origin.ts +3 -3
  120. package/src/original-inputs.ts +72 -0
  121. package/src/pack-facts.ts +1 -1
  122. package/src/pglite-backing.ts +192 -80
  123. package/src/pglite-host.mjs +482 -10
  124. package/src/process-groups.d.mts +6 -1
  125. package/src/process-groups.mjs +82 -15
  126. package/src/process-groups.ts +2 -1
  127. package/src/process-signal.d.mts +2 -2
  128. package/src/process-signal.mjs +29 -9
  129. package/src/project-inspect.ts +37 -4
  130. package/src/proxy-env.ts +128 -0
  131. package/src/redirect-proxy.ts +3 -125
  132. package/src/redis-backing.ts +19 -136
  133. package/src/runtime.ts +342 -107
  134. package/src/schema.ts +5 -4
  135. package/src/served-world.ts +3 -3
  136. package/src/twin-backing.ts +300 -0
  137. package/src/up-cli.ts +52 -3
@@ -16,73 +16,20 @@ import { appendFileSync, closeSync, copyFileSync, cpSync, existsSync, lstatSync,
16
16
  import { createHash, randomUUID } from 'node:crypto';
17
17
  import { assertWorldConfig, type WorldDatabasePrepare } from './schema.ts';
18
18
  import { connect } from 'node:net';
19
- import { dirname, join } from 'node:path';
19
+ import { delimiter, dirname, join } from 'node:path';
20
20
  import { tmpdir } from 'node:os';
21
21
  import { spawn, type ChildProcess } from 'node:child_process';
22
- import { commandLifetime, COMMAND_GRACE_MS, COMMAND_CONFIRM_MS } from './command-lifetime.ts';
23
- import { survivingOwnedGroups } from './process-groups.ts';
22
+ import { commandLifetime, COMMAND_CONFIRM_MS } from './command-lifetime.ts';
23
+ import { survivingOwnedGroups, worldEvent } from './process-groups.ts';
24
24
  import { publishProcessReceipt, readProcessReceipt, readSpawnReceipt, spawnReceiptPath, verifySpawnIdentity, signalOwnedProcess, type SpawnIdentity } from './process-receipt.ts';
25
25
  import { errorMessage } from './error-message.ts';
26
26
  import { runtimeManifest } from './package-manifest.ts';
27
27
  import { fileURLToPath } from 'node:url';
28
- import { parse } from 'yaml';
29
28
  import { mongodbArgv, mongodbReady, mongodbServes } from './mongodb-backing.ts';
30
29
 
31
- export interface InfraService { kind: string; hostPort: number; replicaSet?: string; postgres?: { user: string; database: string; password?: string } }
32
-
33
- /** Read the generated definition's loopback port mappings. MongoDB may explicitly request
34
- * one replica-set member through its Compose-compatible command; other mongod options are refused. */
35
- export function parseInfraDefinition(text: string): InfraService[] {
36
- const definition = parse(text);
37
- const services: InfraService[] = [];
38
- for (const [kind, value] of Object.entries(definition?.services ?? {})) {
39
- const declared = value as { ports?: unknown[]; command?: unknown; environment?: Record<string, unknown> };
40
- let replicaSet: string | undefined;
41
- if (kind === 'mongodb' && declared.command !== undefined) {
42
- const command = declared.command;
43
- if (!Array.isArray(command) || command.length !== 3 || command[0] !== 'mongod' || command[1] !== '--replSet'
44
- || typeof command[2] !== 'string' || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(command[2])) {
45
- throw new Error('World-managed MongoDB command must be ["mongod", "--replSet", "<name>"] or absent for standalone');
46
- }
47
- replicaSet = command[2];
48
- }
49
- const ports = (declared.ports ?? []).flatMap((port) => {
50
- const match = typeof port === 'string' ? port.match(/^127\.0\.0\.1:(\d+):\d+$/) : null;
51
- return match ? [Number(match[1])] : [];
52
- });
53
- if (ports.length !== 1) throw new Error(`managed infrastructure ${kind} needs exactly one declared loopback port`);
54
- let postgres: InfraService['postgres'];
55
- if (kind === 'postgres') {
56
- const environment = declared.environment ?? {};
57
- const user = environment.POSTGRES_USER ?? 'postgres';
58
- const database = environment.POSTGRES_DB ?? user;
59
- const password = environment.POSTGRES_PASSWORD;
60
- if ([user, database, password].some(value => value !== undefined && (typeof value !== 'string' || !value || /[\r\n\0]|\$\{/.test(value)))) {
61
- throw new Error('World-managed PGlite requires literal POSTGRES_USER, POSTGRES_DB and POSTGRES_PASSWORD');
62
- }
63
- if ([user, database].some(value => Buffer.byteLength(value as string, 'utf8') > 63)) throw new Error('World-managed PGlite identifiers must fit PostgreSQL\'s 63-byte name limit');
64
- postgres = { user: user as string, database: database as string, ...(password !== undefined ? { password: password as string } : {}) };
65
- }
66
- services.push({ kind, hostPort: ports[0]!, ...(replicaSet ? { replicaSet } : {}), ...(postgres ? { postgres } : {}) });
67
- }
68
- return services;
69
- }
70
-
71
- /** Endpoints of the declared infrastructure, published through external.discover. Only URLs
72
- * whose protocol and loopback port match a declared service belong to this lifecycle. */
73
- export function infraConnections(services: InfraService[], env: Record<string, string | undefined>): Record<string, string> {
74
- const connections: Record<string, string> = {};
75
- for (const [name, value] of Object.entries(env)) {
76
- if (value === undefined) continue;
77
- try {
78
- const url = new URL(value);
79
- if (url.hostname !== '127.0.0.1') continue;
80
- const kind = url.protocol === 'postgresql:' ? 'postgres' : url.protocol.slice(0, -1);
81
- if (services.some((service) => service.kind === kind && service.hostPort === Number(url.port))) connections[name] = value;
82
- } catch { /* not an infrastructure endpoint */ }
83
- }
84
- return connections;
85
- }
30
+ import type { InfraService } from './infra-definition.ts';
31
+ export { parseInfraDefinition, infraConnections } from './infra-definition.ts';
32
+ export type { InfraService } from './infra-definition.ts';
86
33
 
87
34
  // this module's directory under Node as under Bun: `volter-world-infra` is a node-shebang bin, where
88
35
  // `import.meta.dir` (Bun's) is undefined
@@ -127,7 +74,9 @@ function hostOwnership(pidFile: string) {
127
74
  * the control plane's NODE_OPTIONS and proxies), and the boot's abort. The `volter-world-infra` process passes
128
75
  * nothing and its hosts inherit its own environment, which is that same service environment. The World's `up`,
129
76
  * running the up phase in its own process, passes it: its own process.env is the operator's, not the service's. */
130
- export interface HostLaunch { env?: NodeJS.ProcessEnv; signal?: AbortSignal; note?: (what: string) => void }
77
+ export interface HostLaunch { env?: NodeJS.ProcessEnv; signal?: AbortSignal; note?: (what: string) => void;
78
+ /** Said once the database's cluster is chosen, placed and its server spawned, before its readiness is waited on (S2 R79). */
79
+ onPlaced?: () => void }
131
80
 
132
81
  /** The argv that serves one service on its declared loopback port, with its bytes in `serviceData`. */
133
82
  function hostArgv(service: InfraService, serviceData: string): string[] {
@@ -168,13 +117,14 @@ function listeningReceiptPath(pidFile: string): string {
168
117
  return pidFile.replace(/\.pid$/, '.listening.json');
169
118
  }
170
119
 
171
- function listeningReceipt(path: string): { pid: number; port: number; boot: string | null } | undefined {
120
+ function listeningReceipt(path: string): { pid: number; port: number; boot: string | null; openSplit?: string } | undefined {
172
121
  let text: string;
173
122
  try { text = readFileSync(path, 'utf8'); } catch { return undefined; }
174
123
  try {
175
- const value = JSON.parse(text) as { version?: unknown; pid?: unknown; port?: unknown; boot?: unknown };
124
+ const value = JSON.parse(text) as { version?: unknown; pid?: unknown; port?: unknown; boot?: unknown; openSplit?: unknown };
176
125
  if (value.version !== 1 || !Number.isSafeInteger(value.pid) || !Number.isSafeInteger(value.port)) return undefined;
177
- return { pid: value.pid as number, port: value.port as number, boot: typeof value.boot === 'string' ? value.boot : null };
126
+ return { pid: value.pid as number, port: value.port as number, boot: typeof value.boot === 'string' ? value.boot : null,
127
+ ...(typeof value.openSplit === 'string' ? { openSplit: value.openSplit } : {}) };
178
128
  } catch { return undefined; }
179
129
  }
180
130
 
@@ -223,8 +173,92 @@ export function preparedCluster(worldConfig: string): { directory: string; stamp
223
173
  * another password takes the same cluster and authenticates against its own. Extensions are loaded into the module
224
174
  * at each open, never created in the cluster by the open path.
225
175
  */
176
+ /**
177
+ * A managed store other than the cluster that a step run with the World up may have written (architecture, "A step
178
+ * may need the World"): where the preparation stages it, beside the cluster, and its stamp.
179
+ */
180
+ export function preparedStore(worldConfig: string, name: string): { directory: string; stamp: string } {
181
+ const directory = join(dirname(worldConfig), PREPARED_DIRECTORY, name);
182
+ return { directory, stamp: `${directory}.json` };
183
+ }
184
+
185
+ const canonical = (value: unknown): string => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
186
+ ? Object.fromEntries(Object.entries(item as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b))) : item);
187
+
188
+ /** The state directory a preparation with the World up runs in, beside the World's own (native-postgres-backing.ts). */
189
+ export const PREPARE_STATE_DIRECTORY = '.volter-prepare';
190
+
191
+ /** The stamp of a staged store: the migration it was written by. */
192
+ export function preparedStoreStamp(migration: PreparedMigration): string {
193
+ return `${canonical({ migration })}\n`;
194
+ }
195
+
196
+ /**
197
+ * Place a staged store where its service serves from, at `up`: only into a place that holds nothing, and only when
198
+ * its stamp is the migration this World expects (the rule a prepared cluster is taken by). Anything else leaves the
199
+ * service to start empty, and the note says why.
200
+ */
201
+ export function takePreparedStore(worldConfig: string | undefined, env: NodeJS.ProcessEnv, name: string, target: string): { placed: boolean; note?: string } {
202
+ if (!worldConfig) return { placed: false };
203
+ const { directory, stamp } = preparedStore(worldConfig, name);
204
+ if (!existsSync(directory) || !existsSync(stamp)) return { placed: false };
205
+ if (existsSync(target) && readdirSync(target).length) return { placed: false };
206
+ const wanted = expectedMigration(worldConfig, env, directory);
207
+ if ('note' in wanted) return { placed: false, note: wanted.note.replace('prepared cluster', `prepared ${name}`) };
208
+ if (!wanted.migration || readFileSync(stamp, 'utf8') !== preparedStoreStamp(wanted.migration)) return { placed: false, note: `prepared ${name} ${directory} not taken: its stamp names another migration` };
209
+ // A STORE GOES WITH ITS CLUSTER. Placed beside a database that does not carry the same migration, the World would
210
+ // have an unprepared database and a prepared store, and an application told to run the step itself would run it
211
+ // on a store that already holds what the step wrote. On `up` the twin-served kinds come up after the native
212
+ // backing's onPlaced (cluster placed) or after the database (infra.ts), so the live cluster is there to be read
213
+ // and must carry the migration. Where no cluster is live
214
+ // (a caller that starts a twin alone), a prepared cluster stamped with it must at least be waiting.
215
+ const data = dirname(target), prepared = join(dirname(worldConfig), PREPARED_DIRECTORY);
216
+ const clusters = [...new Set(['native-postgres-data', containerlessDataDirectory('postgres')!])];
217
+ const live = clusters.map((cluster) => join(data, cluster)).filter((cluster) => existsSync(join(cluster, 'PG_VERSION')));
218
+ const carried = live.length > 0
219
+ ? live.some((cluster) => liveDatabaseMigration(worldConfig, env, cluster) !== undefined)
220
+ : clusters.map((cluster) => join(prepared, cluster)).some((cluster) => {
221
+ if (!existsSync(join(cluster, 'PG_VERSION')) || !existsSync(`${cluster}.json`)) return false;
222
+ try { return canonical((JSON.parse(readFileSync(`${cluster}.json`, 'utf8')) as { migration?: unknown }).migration ?? null) === canonical(wanted.migration); }
223
+ catch { return false; }
224
+ });
225
+ if (!carried) return { placed: false, note: `prepared ${name} ${directory} not taken: ${live.length > 0 ? 'the live database does not carry its migration' : 'no prepared cluster of its migration is there to be taken with it'}` };
226
+ if (existsSync(target)) rmdirSync(target);
227
+ mkdirSync(dirname(target), { recursive: true });
228
+ try { renameSync(directory, target); }
229
+ catch (error) {
230
+ if ((error as NodeJS.ErrnoException).code !== 'EXDEV') throw error;
231
+ cpSync(directory, target, { recursive: true });
232
+ }
233
+ // THE STORE IS READ UNDER THE NAME THIS UP USES. A twin keeps its state inside its root under the state directory's
234
+ // name; the preparation ran with its own (PREPARE_STATE_DIRECTORY) and this `up` runs with another, so the root as
235
+ // it was written is read by nobody. The one the preparation wrote under is renamed to the one the twin is about to
236
+ // be given, and the note says both, or says that nothing was there to rename.
237
+ const wrote = join(target, PREPARE_STATE_DIRECTORY), reads = join(target, env.VOLTER_STATE_DIR || '.volter');
238
+ let under = `nothing in it is under ${PREPARE_STATE_DIRECTORY}`;
239
+ if (existsSync(wrote) && wrote !== reads) {
240
+ if (existsSync(reads)) return { placed: true, note: `prepared ${name} ${directory} placed at ${target}, and its ${PREPARE_STATE_DIRECTORY} NOT renamed: ${reads} is there already; the service reads that one` };
241
+ renameSync(wrote, reads);
242
+ under = `what the preparation wrote under ${PREPARE_STATE_DIRECTORY} is now under ${env.VOLTER_STATE_DIR || '.volter'}, where this up's service reads`;
243
+ }
244
+ return { placed: true, note: `prepared ${name} ${directory} placed at ${target}; ${under}` };
245
+ }
246
+
226
247
  /** A prepared cluster's migration, as its stamp records it: the step's command and the digest of each of its inputs. */
227
- export interface PreparedMigration { command: string[]; inputs: Record<string, string> }
248
+ /** What a prepared cluster was migrated by: the step's command, its inputs' digests and, for a step run with the
249
+ * World up (`with: "world"`), the twins that were up for it, each by its version. */
250
+ export interface PreparedMigration { command: string[]; inputs: Record<string, string>; world?: Record<string, string> }
251
+
252
+ /**
253
+ * The twins a step declared `with: "world"` runs beside, each by the version the World names: part of what the
254
+ * cluster was migrated with, so a World whose twins moved does not take a cluster another set of them wrote into.
255
+ * Read from the document itself, in id order.
256
+ */
257
+ export function preparedWorldServices(worldConfig: string): Record<string, string> {
258
+ const document = JSON.parse(readFileSync(worldConfig, 'utf8')) as { services?: Array<{ id?: unknown; type?: unknown; source?: { version?: unknown } }> };
259
+ const twins = (document.services ?? []).filter((service) => service.type === 'twin' && typeof service.id === 'string');
260
+ return Object.fromEntries(twins.map((service) => [service.id as string, typeof service.source?.version === 'string' ? service.source.version : 'unversioned'] as const).sort(([a], [b]) => a.localeCompare(b)));
261
+ }
228
262
 
229
263
  function preparedStamp(service: InfraService, migration?: PreparedMigration): string {
230
264
  const manifest = runtimeManifest();
@@ -234,6 +268,18 @@ function preparedStamp(service: InfraService, migration?: PreparedMigration): st
234
268
  ...(migration ? { migration } : {}) })}\n`;
235
269
  }
236
270
 
271
+ const declaredDatabaseReceipt = (serviceData: string): string => join(serviceData, '.volter-declared-database.json');
272
+
273
+ /** This cluster served the declared database and role; this is not a migration or process-ownership receipt. */
274
+ function hasDeclaredDatabase(serviceData: string, identity: string): boolean {
275
+ if (!existsSync(join(serviceData, 'PG_VERSION'))) return false;
276
+ try { return readFileSync(declaredDatabaseReceipt(serviceData), 'utf8') === identity; }
277
+ catch (error) {
278
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
279
+ throw error;
280
+ }
281
+ }
282
+
237
283
  /**
238
284
  * 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
239
285
  * sorted relative paths, each with its type, its mode and its bytes (a link, its target). What the image build records
@@ -266,7 +312,7 @@ export function databasePrepareOf(worldConfig: string): WorldDatabasePrepare | u
266
312
  }
267
313
 
268
314
  /** `NAME=value` lines (an image's environment, VOLTER_WORLD_APP_ENV), as an environment. */
269
- function environmentFile(path: string): Record<string, string> {
315
+ export function environmentFile(path: string): Record<string, string> {
270
316
  const values: Record<string, string> = {};
271
317
  for (const line of readFileSync(path, 'utf8').split('\n')) {
272
318
  const at = line.indexOf('=');
@@ -301,6 +347,17 @@ export function keepFailureEvidence(name: string, evidence: string | { file: str
301
347
  }
302
348
  }
303
349
 
350
+ /** A package script's own path: each `node_modules/.bin` from the working directory up to the application's root. */
351
+ export function packageScriptPath(cwd: string, appRoot: string): string[] {
352
+ const found: string[] = [];
353
+ for (let dir = cwd; ; dir = dirname(dir)) {
354
+ const bin = join(dir, 'node_modules', '.bin');
355
+ if (existsSync(bin)) found.push(bin);
356
+ if (dir === appRoot || dirname(dir) === dir || !dir.startsWith(appRoot)) break;
357
+ }
358
+ return found;
359
+ }
360
+
304
361
  export async function runDatabasePrepare(step: WorldDatabasePrepare, worldConfig: string, launch: HostLaunch): Promise<PreparedMigration> {
305
362
  const env = launch.env ?? process.env;
306
363
  const appRoot = env.VOLTER_WORLD_APP_ROOT;
@@ -310,12 +367,26 @@ export async function runDatabasePrepare(step: WorldDatabasePrepare, worldConfig
310
367
  const { PATH: _imagePath, ...imageValues } = imageEnv;
311
368
  const worldValues = assertWorldConfig(JSON.parse(readFileSync(worldConfig, 'utf8')), worldConfig).env ?? {};
312
369
  const [program, ...words] = step.command;
313
- const migration = spawn(program!, words, { windowsHide: true, cwd, env: { ...env, ...imageValues, ...worldValues }, stdio: ['ignore', 'pipe', 'pipe'] });
370
+ // THE STEP'S PROGRAM IS FOUND WHERE THE APPLICATION'S OWN START FINDS IT. A start written `yarn prisma:deploy`
371
+ // runs `prisma migrate deploy` with the package manager's path: each `node_modules/.bin` from the working
372
+ // directory up to the application's root, before PATH. The step is that script's command, so it is given that
373
+ // path; declared as a bare `prisma` and looked for on this process's PATH alone, it was not found, and a spawn
374
+ // that never started was reported as "exited null" with nothing else (linkwarden).
375
+ const scriptPath = packageScriptPath(cwd, appRoot);
376
+ const stepEnv: NodeJS.ProcessEnv = { ...env, ...imageValues, ...worldValues };
377
+ stepEnv.PATH = [...scriptPath, stepEnv.PATH ?? ''].filter(Boolean).join(delimiter);
378
+ const migration = spawn(program!, words, { windowsHide: true, cwd, env: stepEnv, stdio: ['ignore', 'pipe', 'pipe'] });
314
379
  let output = '';
380
+ // A spawn that fails has no exit: what it met is kept and said, with where the program was looked for.
381
+ let notStarted: NodeJS.ErrnoException | undefined;
382
+ migration.once('error', (error: NodeJS.ErrnoException) => { notStarted = error; });
315
383
  migration.stdout!.on('data', (chunk: Buffer) => { output += chunk.toString('utf8'); });
316
384
  migration.stderr!.on('data', (chunk: Buffer) => { output += chunk.toString('utf8'); });
317
- const { code } = await commandLifetime(migration, launch.signal).finish();
318
- if (code !== 0) 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)}`);
385
+ const { code, signal: endedBy } = await commandLifetime(migration, launch.signal).finish();
386
+ if (notStarted) {
387
+ throw new Error(`the World's database prepare step (${step.command.join(' ')}) was NOT STARTED: ${notStarted.code ?? ''} ${notStarted.message}. Its program is ${program}; its working directory ${cwd} ${existsSync(cwd) ? 'is there' : 'IS NOT THERE (a missing directory is reported by the system as the program not found)'}; it was looked for in ${scriptPath.length ? scriptPath.join(', ') : 'no node_modules/.bin between that directory and the application\'s root'}, then on PATH (${(env.PATH ?? '').split(delimiter).filter(Boolean).length} entries)`);
388
+ }
389
+ if (code !== 0) throw new Error(`the World's database prepare step (${step.command.join(' ')}) ${code === null ? `was ended by ${endedBy ?? 'no exit status and no signal'}` : `exited ${code}`}\n${output.trim().split('\n').slice(-40).join('\n')}${keepFailureEvidence('database-prepare-step-output', output, env)}`);
319
390
  launch.note?.(`database prepare step: ${step.command.join(' ')}\n${output.trim()}`);
320
391
  return { command: [...step.command], inputs: migrationInputDigests(cwd, step.inputs) };
321
392
  }
@@ -329,14 +400,16 @@ async function migratePreparedCluster(service: InfraService, directory: string,
329
400
  const pidFile = join(dirname(directory), 'pglite-prepare.pid');
330
401
  const boot = randomUUID();
331
402
  rmSync(listeningReceiptPath(pidFile), { force: true });
332
- const [command, ...args] = [...hostArgv(service, directory), '--prepared', '--listening-file', listeningReceiptPath(pidFile), '--boot', boot];
403
+ // --record-extensions: the step's host writes the extensions the migrations left beside the cluster, which a prepared
404
+ // open then loads alone (pglite-host.mjs DECLARED_EXTENSIONS, S2 R66b).
405
+ const [command, ...args] = [...hostArgv(service, directory), '--prepared', '--record-extensions', '--listening-file', listeningReceiptPath(pidFile), '--boot', boot];
333
406
  let hostOutput = '';
334
407
  const host = spawn(command!, args, { windowsHide: true,
335
408
  env: { ...env, VOLTER_WORLD_PGLITE_USER: service.postgres!.user, VOLTER_WORLD_PGLITE_DATABASE: service.postgres!.database, VOLTER_WORLD_PGLITE_PASSWORD: service.postgres!.password! },
336
409
  stdio: ['ignore', 'pipe', 'pipe'] });
337
410
  host.stdout!.on('data', (chunk: Buffer) => { hostOutput += chunk.toString('utf8'); });
338
411
  host.stderr!.on('data', (chunk: Buffer) => { hostOutput += chunk.toString('utf8'); });
339
- const hostLifetime = commandLifetime(host, launch.signal);
412
+ const hostLifetime = commandLifetime(host, launch.signal, false, READY_TIMEOUT_MS);
340
413
  try {
341
414
  const deadline = Date.now() + READY_TIMEOUT_MS;
342
415
  while (!(host.pid && alive(host.pid) && await hostServes(service, pidFile, host.pid, boot))) {
@@ -379,6 +452,10 @@ export async function pglitePrepare(service: InfraService, worldConfig: string,
379
452
  // The app's own migration, where the World declares one: a cluster it did not complete is never stamped.
380
453
  const step = databasePrepareOf(worldConfig);
381
454
  let migration: PreparedMigration | undefined;
455
+ if (step?.with === 'world') {
456
+ rmSync(directory, { recursive: true, force: true });
457
+ throw new Error('the World\'s database prepare step is declared with: "world", which is prepared on the native PostgreSQL backing only; this preparation is on PGlite (no PostgreSQL programs on PATH or in VOLTER_WORLD_POSTGRES_BIN, or VOLTER_WORLD_INFRA_BACKING=pglite)');
458
+ }
382
459
  if (step) {
383
460
  try { migration = await migratePreparedCluster(service, directory, step, worldConfig, launch); }
384
461
  catch (error) { rmSync(directory, { recursive: true, force: true }); throw error; }
@@ -418,7 +495,8 @@ export function expectedMigration(worldConfig: string, env: NodeJS.ProcessEnv, d
418
495
  inputs = recorded as Record<string, string>;
419
496
  } else if (env.VOLTER_WORLD_APP_ROOT) inputs = migrationInputDigests(join(env.VOLTER_WORLD_APP_ROOT, step.cwd), step.inputs);
420
497
  if (!inputs) 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)` };
421
- return { migration: { command: [...step.command], inputs: Object.fromEntries(step.inputs.map((input) => [input, inputs![input] ?? ''])) } };
498
+ return { migration: { command: [...step.command], inputs: Object.fromEntries(step.inputs.map((input) => [input, inputs![input] ?? ''])),
499
+ ...(step.with === 'world' ? { world: preparedWorldServices(worldConfig) } : {}) } };
422
500
  }
423
501
 
424
502
  /** Beside a service's data: the migration its live cluster carries, written when a migrated cluster is placed. */
@@ -470,7 +548,7 @@ export const DEVELOPMENT_MIGRATION_NOTICE = 'development World: its database mig
470
548
  * it becomes (liveDatabaseMigration).
471
549
  */
472
550
  export function takePreparedCluster(prepared: { directory: string; stamp: string }, expected: string | ((stamp: string) => boolean), serviceData: string,
473
- mismatch: string, migration?: PreparedMigration): { placed: boolean; note?: string } {
551
+ mismatch: string, migration?: PreparedMigration, copy: (from: string, to: string) => void = (from, to) => cpSync(from, to, { recursive: true })): { placed: boolean; copied?: boolean; note?: string } {
474
552
  const { directory, stamp } = prepared;
475
553
  if (existsSync(join(serviceData, 'PG_VERSION'))) return { placed: false };
476
554
  if (!existsSync(join(directory, 'PG_VERSION')) || !existsSync(stamp)) return { placed: false };
@@ -482,13 +560,22 @@ export function takePreparedCluster(prepared: { directory: string; stamp: string
482
560
  }
483
561
  mkdirSync(dirname(serviceData), { recursive: true });
484
562
  // A rename moves the cluster without writing its files; across file systems it is copied.
563
+ // A caller may copy its own way (the native backing copies as the server's owner, so its cluster is walked once).
564
+ let copied = false;
485
565
  try { renameSync(directory, serviceData); }
486
566
  catch (error) {
487
567
  if ((error as NodeJS.ErrnoException).code !== 'EXDEV') throw error;
488
- cpSync(directory, serviceData, { recursive: true });
568
+ // Copied beside its place and moved in whole: a copy cut short (ENOSPC, a crash) never leaves a part of a cluster
569
+ // where the next up would refuse it, or, holding PG_VERSION, take it as placed and boot it truncated. A sibling an
570
+ // earlier start left behind is removed first.
571
+ const placing = `${serviceData}.placing`;
572
+ rmSync(placing, { recursive: true, force: true });
573
+ try { copy(directory, placing); renameSync(placing, serviceData); }
574
+ catch (failure) { rmSync(placing, { recursive: true, force: true }); throw failure; }
575
+ copied = true;
489
576
  }
490
577
  if (migration) writeFileSync(migrationRecord(serviceData), `${JSON.stringify(migration)}\n`);
491
- return { placed: true, note: `prepared cluster ${directory} placed at ${serviceData}` };
578
+ return { placed: true, copied, note: `prepared cluster ${directory} placed at ${serviceData}${copied ? ' (copied: another file system)' : ''}` };
492
579
  }
493
580
 
494
581
  export async function pgliteUp(services: InfraService[], dataDir: string, launch: HostLaunch = {}): Promise<void> {
@@ -521,10 +608,24 @@ export async function pgliteUp(services: InfraService[], dataDir: string, launch
521
608
  mkdirSync(serviceData, { recursive: true });
522
609
  mkdirSync(dirname(log), { recursive: true });
523
610
  if (placed.note) { appendFileSync(log, `${placed.note}\n`); launch.note?.(placed.note); }
611
+ const identity = service.kind === 'postgres' ? preparedStamp(service) : undefined;
612
+ const keptIdentity = identity !== undefined && hasDeclaredDatabase(serviceData, identity);
613
+ const direct = service.kind === 'postgres' && (placed.placed || keptIdentity);
614
+ if (identity !== undefined) {
615
+ // Maintenance may rename a legacy database. Its old identity must not survive a partial initialization.
616
+ if (!direct) rmSync(declaredDatabaseReceipt(serviceData), { force: true });
617
+ const note = `${name} opening ${direct ? 'declared database directly' : 'template1 for database identity setup'} (${placed.placed ? 'placed prepared identity' : keptIdentity ? 'kept declared identity' : 'identity not established'})`;
618
+ appendFileSync(log, `${note}\n`); launch.note?.(note);
619
+ }
524
620
  // This boot's token, which the host writes into its listener receipt; an earlier receipt is never this one's.
525
621
  const boot = randomUUID();
526
622
  rmSync(listeningReceiptPath(pidFile), { force: true });
527
- const [command, ...args] = [...(placed.placed ? [...argv, '--prepared'] : argv), ...(service.kind === 'postgres' ? ['--listening-file', listeningReceiptPath(pidFile), '--boot', boot] : [])];
623
+ // The declared migrations' extensions alone, only for a cluster this boot placed whose live data carries the image's
624
+ // migration (S2 R66b, review): kept data, data a newer image migrates, or a cluster opened another way loads every
625
+ // extension, and the host drops the record.
626
+ const declaredOnly = service.kind === 'postgres' && placed.placed && env.VOLTER_WORLD_CONFIG !== undefined
627
+ && liveDatabaseMigration(env.VOLTER_WORLD_CONFIG, env, serviceData) !== undefined;
628
+ const [command, ...args] = [...(direct ? [...argv, '--prepared'] : argv), ...(declaredOnly ? ['--declared-extensions'] : []), ...(service.kind === 'postgres' ? ['--listening-file', listeningReceiptPath(pidFile), '--boot', boot] : [])];
528
629
  // a real fd, not a pipe: the host must outlive this process untethered
529
630
  const logFd = openSync(log, 'a');
530
631
  const child = spawn(command!, args, {
@@ -535,7 +636,7 @@ export async function pgliteUp(services: InfraService[], dataDir: string, launch
535
636
  stdio: ['ignore', logFd, logFd],
536
637
  });
537
638
  // Retain the actual child BEFORE descriptor close or either publication can fail.
538
- const lifetime = commandLifetime(child, launch.signal);
639
+ const lifetime = commandLifetime(child, launch.signal, false, READY_TIMEOUT_MS);
539
640
  const host: Host = { child, lifetime, closed: false };
540
641
  child.once('close', () => { host.closed = true; });
541
642
  hosts.set(pidFile, host); started.push(pidFile);
@@ -562,7 +663,13 @@ export async function pgliteUp(services: InfraService[], dataDir: string, launch
562
663
  launch.signal?.throwIfAborted();
563
664
  await sleep(25);
564
665
  }
565
- launch.note?.(`${name} serving on 127.0.0.1:${service.hostPort}`);
666
+ // Publish atomically only after this boot's own listener and protocol answer proved the database open.
667
+ // Kept opens read their identity once and need no extra write or fsync.
668
+ if (identity !== undefined && !keptIdentity) publishProcessReceipt(declaredDatabaseReceipt(serviceData), identity);
669
+ // The host's own open split (pglite-host.mjs, THE OPEN'S SPLIT), from its receipt: said where a run's console
670
+ // sees it, as the host's stdout goes to its log alone (S2 R66).
671
+ const openSplit = service.kind === 'postgres' ? listeningReceipt(listeningReceiptPath(pidFile))?.openSplit : undefined;
672
+ launch.note?.(`${name} serving on 127.0.0.1:${service.hostPort}${openSplit ? ` (open split: ${openSplit})` : ''}`);
566
673
  if (service.kind === 'mongodb') await mongodbReady(service, launch, deadline);
567
674
  child.unref();
568
675
  }
@@ -614,13 +721,18 @@ async function stopHost(pidFile: string): Promise<void> {
614
721
  if (live()) {
615
722
  if (ownership.identity) await verifySpawnIdentity(ownership.identity);
616
723
  else throw new AggregateError(ownership.errors, 'Managed host spawn identity missing or unreadable; ownership retained');
617
- // Reuse commandLifetime's existing grace and confirmation budgets; no new limit.
618
- for (const [signal, limit] of [['SIGTERM', COMMAND_GRACE_MS], ['SIGKILL', COMMAND_CONFIRM_MS]] as const) {
724
+ // A STORE IS AWAITED AT ITS STOP on the lifecycle's own budget, not a command's grace: between the signal and
725
+ // its exit it writes its state down, and a SIGKILL in the middle of that leaves a root nothing may take.
726
+ // Its stop, its exit and the time between are events in the World's log; a kill is said as a kill.
727
+ const stopAsked = Date.now();
728
+ for (const [signal, limit] of [['SIGTERM', READY_TIMEOUT_MS], ['SIGKILL', COMMAND_CONFIRM_MS]] as const) {
619
729
  if (!live()) break;
620
- await signalOwnedProcess(ownership.identity, signal, true);
730
+ worldEvent({ event: signal === 'SIGTERM' ? 'world.service.stop' : 'world.service.killed', service: 'managed host', by: 'down', signal, to: 'group', pid, ...(signal === 'SIGKILL' ? { afterMs: Date.now() - stopAsked, doing: 'its stop had not ended within the lifecycle budget' } : {}) });
731
+ await signalOwnedProcess(ownership.identity, signal, true, 'down');
621
732
  const deadline = Date.now() + limit;
622
733
  while (live() && Date.now() < deadline) await sleep(20);
623
734
  }
735
+ if (!live()) worldEvent({ event: 'world.service.exited', service: 'managed host', by: 'down', pid, shutdownMs: Date.now() - stopAsked });
624
736
  if (live()) throw new Error(`managed infrastructure group ${pid} did not stop; retained ${pidFile}`);
625
737
  }
626
738
  } else if (ownership.present) {