@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.
@@ -1,14 +1,17 @@
1
1
  // World-managed multi-session PostgreSQL. The declaration, data, process and teardown
2
2
  // belong to the infrastructure service; a recipe never operates postgres itself.
3
3
  import { spawn } from 'node:child_process';
4
- import { accessSync, closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs';
4
+ import { createHash } from 'node:crypto';
5
+ import { accessSync, closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
5
6
  import { connect } from 'node:net';
7
+ import { runtimeManifest } from "./package-manifest.js";
6
8
  import { delimiter, dirname, join, resolve } from 'node:path';
7
9
  import { commandLifetime, COMMAND_GRACE_MS, CommandRetirementError } from "./command-lifetime.js";
8
10
  import { survivingOwnedGroups } from "./process-groups.js";
9
11
  import { publishProcessReceipt, readProcessReceipt, readSpawnReceipt, spawnReceiptPath, verifySpawnIdentity, signalOwnedProcess } from "./process-receipt.js";
10
12
  import { signalChild, childIdentity } from './process-signal.mjs';
11
13
  import { errorMessage } from "./error-message.js";
14
+ import { PREPARED_DIRECTORY, databasePrepareOf, expectedMigration, forgetStaleMigration, keepFailureEvidence, runDatabasePrepare, takePreparedCluster } from "./pglite-backing.js";
12
15
  const starts = new Map();
13
16
  const wait = (ms) => new Promise(done => setTimeout(done, ms));
14
17
  const recordPath = (root) => join(root, 'native-postgres.owner.json');
@@ -126,6 +129,20 @@ async function listening(port, deadline, signal) {
126
129
  abort();
127
130
  });
128
131
  }
132
+ /** The last lines of a child's stream for an error, labelled, or nothing when it said nothing. */
133
+ function streamTail(label, text, lines = 40) {
134
+ const tail = text.trim().split('\n').slice(-lines).join('\n');
135
+ return tail ? `\n${label}:\n${tail}` : '';
136
+ }
137
+ /** The postmaster's log as an error carries it: its last lines. A failed preparation also keeps the whole file (nativePrepare). */
138
+ function logTail(log) {
139
+ try {
140
+ return streamTail(`log ${log}`, readFileSync(log, 'utf8'));
141
+ }
142
+ catch {
143
+ return '';
144
+ }
145
+ }
129
146
  /** Every helper joins exit and its private group, including spawn errors and abort. */
130
147
  async function tool(command, args, env, deadline, root, signal) {
131
148
  signal?.throwIfAborted();
@@ -170,8 +187,10 @@ async function tool(command, args, env, deadline, root, signal) {
170
187
  throw new Error(`native postgres readiness deadline elapsed during ${command}`);
171
188
  if (spawnError)
172
189
  throw spawnError;
190
+ // Both streams, labelled: a tool's last words on stdout say how far it got (initdb's bootstrap steps) and are
191
+ // otherwise lost with the process.
173
192
  if (result.code !== 0 || lifetime.forwarded)
174
- throw new Error(`${command} exited ${result.code}/${result.signal}: ${errorOutput.trim()}`);
193
+ throw new Error(`${command} exited ${result.code}/${result.signal}${streamTail('stderr', errorOutput)}${streamTail('stdout', output)}`);
175
194
  return output;
176
195
  }
177
196
  catch (error) {
@@ -246,6 +265,141 @@ export function nativePostgresTools(env) {
246
265
  }
247
266
  return undefined;
248
267
  }
268
+ /** initdb's arguments after `-D`, the same at up and at prepare, and so in the stamp. */
269
+ function initdbArguments(user) {
270
+ return [`--username=${user}`, '--auth=trust', '--no-locale', '--encoding=UTF8'];
271
+ }
272
+ /** Where a World's prepared native cluster lives, beside its world.json, and its stamp (ADR 0018's layout). */
273
+ export function nativePreparedCluster(worldConfig) {
274
+ const directory = join(dirname(worldConfig), PREPARED_DIRECTORY, 'native-postgres-data');
275
+ return { directory, stamp: `${directory}.json` };
276
+ }
277
+ function nativeStamp(tools, user, database, digests, migration) {
278
+ const manifest = runtimeManifest();
279
+ const programs = {};
280
+ for (const name of ['postgres', 'initdb']) {
281
+ const file = programFile(join(tools, name));
282
+ programs[name] = { bytes: statSync(file).size,
283
+ ...(digests ? { sha256: createHash('sha256').update(readFileSync(file)).digest('hex') } : {}) };
284
+ }
285
+ return { runtime: manifest.version ?? 'unversioned', backing: 'native', programs, initdb: initdbArguments(user), user, database,
286
+ ...(migration ? { migration } : {}) };
287
+ }
288
+ /**
289
+ * The file a command runs: itself, or, for a host shim that runs another build of the program (an image's wasm32
290
+ * PostgreSQL at its image build: browser-substrate wali-host-program.ts), the program it declares on a
291
+ * `# program: <file>` line in its first 1 KiB. The stamp is about the program that makes the cluster, not its launcher.
292
+ */
293
+ function programFile(command) {
294
+ const file = realpathSync(command);
295
+ // Its first 1 KiB only: read whole, postgres and initdb crossed the tab's file system at every up for this line.
296
+ const bytes = Buffer.alloc(1024);
297
+ const fd = openSync(file, 'r');
298
+ let length;
299
+ try {
300
+ length = readSync(fd, bytes, 0, bytes.length, 0);
301
+ }
302
+ finally {
303
+ closeSync(fd);
304
+ }
305
+ const head = bytes.subarray(0, length).toString('latin1');
306
+ return head.startsWith('#!') ? /^# program: (.+)$/m.exec(head)?.[1]?.trim() || file : file;
307
+ }
308
+ /** Whether a stamp names this runtime, these programs' sizes, these initdb arguments, this identity and this migration
309
+ * (up's check): a migrated cluster is taken only for the step and input digests it was migrated from, an unmigrated one
310
+ * only by a World that declares no step. */
311
+ function nativeStampMatches(text, expected) {
312
+ let stamp;
313
+ try {
314
+ stamp = JSON.parse(text);
315
+ }
316
+ catch {
317
+ return false;
318
+ }
319
+ return stamp.runtime === expected.runtime && stamp.backing === 'native' && stamp.user === expected.user
320
+ && stamp.database === expected.database && JSON.stringify(stamp.initdb) === JSON.stringify(expected.initdb)
321
+ && Object.keys(expected.programs).every(name => stamp.programs?.[name]?.bytes === expected.programs[name].bytes)
322
+ && JSON.stringify(stamp.migration) === JSON.stringify(expected.migration);
323
+ }
324
+ /**
325
+ * `volter-world-infra prepare` for a World whose PostgreSQL is native (architecture, managed infrastructure; ADR
326
+ * 0018's phase): this lifecycle's own steps once, into `<World dir>/.volter-prepared/native-postgres-data`. initdb with
327
+ * up's arguments, the postmaster on the World's declared loopback port, the declared database, and a clean SIGINT stop (its
328
+ * shutdown checkpoint; no postmaster.pid left), then the stamp. Where the World declares runtime.database.prepare, the
329
+ * step runs against that postmaster before its stop, and a step that fails leaves no cluster. The image build runs it with the programs the tab
330
+ * runs, under the tab's kernel, so the cluster is the one a first boot there would write.
331
+ */
332
+ export async function nativePrepare(service, definition, worldConfig, launch = {}) {
333
+ if (service.kind !== 'postgres')
334
+ throw new Error('only a declared PostgreSQL is prepared');
335
+ const env = { ...(launch.env ?? process.env) };
336
+ // The preparation makes the cluster; it never takes one.
337
+ delete env.VOLTER_WORLD_CONFIG;
338
+ const tools = nativePostgresTools(env);
339
+ if (!tools)
340
+ throw new Error('native prepare requires the PostgreSQL programs: a postgres server on PATH, or VOLTER_WORLD_POSTGRES_BIN');
341
+ const { directory, stamp } = nativePreparedCluster(worldConfig);
342
+ const root = dirname(directory);
343
+ mkdirSync(root, { recursive: true });
344
+ if (ownership(root).present)
345
+ await nativePostgresDown(root);
346
+ rmSync(directory, { recursive: true, force: true });
347
+ rmSync(stamp, { force: true });
348
+ const { user, database } = settings(definition);
349
+ // On the World's declared port, the one up serves: the image build's sandbox admits exactly that loopback endpoint
350
+ // (browser-substrate world-cluster.ts), and a port already held is refused by up's own check.
351
+ // A preparation that fails keeps the postmaster's WHOLE log outside this directory, which its caller may remove,
352
+ // and says where (pglite-backing.ts keepFailureEvidence). The log is removed only once the cluster is prepared.
353
+ const log = join(root, 'native-postgres.log');
354
+ const failed = (error) => {
355
+ const kept = existsSync(log) ? keepFailureEvidence('native-postgres-log', { file: log }, env) : '';
356
+ if (error instanceof Error) {
357
+ error.message += kept;
358
+ return error;
359
+ }
360
+ return new Error(`${String(error)}${kept}`);
361
+ };
362
+ try {
363
+ await nativePostgresUp([service], root, definition, { ...launch, env });
364
+ }
365
+ catch (error) {
366
+ throw failed(error);
367
+ }
368
+ // The app's own migration, where the World declares one, against the postmaster this lifecycle has up: the same step
369
+ // the PGlite backing runs against its host. Its failure waits for the stop below, then removes the cluster.
370
+ const step = databasePrepareOf(worldConfig);
371
+ let migration;
372
+ let stepFailure;
373
+ if (step) {
374
+ try {
375
+ migration = await runDatabasePrepare(step, worldConfig, { ...launch, env });
376
+ }
377
+ catch (error) {
378
+ stepFailure = { error };
379
+ }
380
+ }
381
+ // The clean stop: SIGINT is the postmaster's fast shutdown, which writes its checkpoint and removes postmaster.pid.
382
+ // Its wait is the lifecycle's own readiness budget, not a new limit; down then clears the receipts.
383
+ const identity = readSpawnReceipt(recordPath(root)).value;
384
+ if (identity) {
385
+ await signalOwnedProcess(identity, 'SIGINT', true);
386
+ const deadline = Date.now() + READY_TIMEOUT_MS;
387
+ while (liveGroup(identity.pid) && Date.now() < deadline)
388
+ await wait(100);
389
+ }
390
+ await nativePostgresDown(root);
391
+ if (stepFailure) {
392
+ rmSync(directory, { recursive: true, force: true });
393
+ throw failed(stepFailure.error);
394
+ }
395
+ if (!existsSync(join(directory, 'PG_VERSION')) || existsSync(join(directory, 'postmaster.pid'))) {
396
+ rmSync(directory, { recursive: true, force: true });
397
+ throw failed(new Error('native prepare ended without a cleanly stopped cluster'));
398
+ }
399
+ rmSync(log, { force: true });
400
+ writeFileSync(stamp, `${JSON.stringify(nativeStamp(tools, user, database, true, migration))}\n`);
401
+ return directory;
402
+ }
249
403
  export async function nativePostgresUp(services, root, definition, launch) {
250
404
  if (services.length !== 1 || services[0].kind !== 'postgres')
251
405
  throw new Error('native postgres requires one declared postgres service');
@@ -275,10 +429,27 @@ export async function nativePostgresUp(services, root, definition, launch) {
275
429
  if (await listening(service.hostPort, deadline, launch.signal))
276
430
  throw new Error(`native postgres port ${service.hostPort} is already occupied`);
277
431
  const data = resolve(root, 'native-postgres-data');
278
- mkdirSync(data, { recursive: true, mode: 0o700 });
279
432
  const { user, database } = settings(definition);
433
+ // An empty data directory takes the World's prepared cluster when its stamp matches (ADR 0018's placement; the
434
+ // cluster was made by these same programs at image build). initdb then finds PG_VERSION, and the database exists.
435
+ forgetStaleMigration(data);
436
+ const prepared = env.VOLTER_WORLD_CONFIG ? nativePreparedCluster(env.VOLTER_WORLD_CONFIG) : undefined;
437
+ // The check reads two files' metadata and the stamp; it runs only when a prepared cluster is there to take.
438
+ if (prepared && existsSync(join(prepared.directory, 'PG_VERSION')) && !existsSync(join(data, 'PG_VERSION'))) {
439
+ // A migrated cluster is the app's only for the step inputs it was migrated from (pglite-backing.ts, expectedMigration).
440
+ const wanted = expectedMigration(env.VOLTER_WORLD_CONFIG, env, prepared.directory);
441
+ if ('note' in wanted)
442
+ launch.note?.(wanted.note);
443
+ else {
444
+ const expected = nativeStamp(binaries, user, database, false, wanted.migration);
445
+ const placed = takePreparedCluster(prepared, text => nativeStampMatches(text, expected), data, 'other PostgreSQL programs, initdb arguments, identity or migration', wanted.migration);
446
+ if (placed.note)
447
+ launch.note?.(placed.note);
448
+ }
449
+ }
450
+ mkdirSync(data, { recursive: true, mode: 0o700 });
280
451
  if (!existsSync(join(data, 'PG_VERSION'))) {
281
- await tool(join(binaries, 'initdb'), ['-D', data, `--username=${user}`, '--auth=trust', '--no-locale', '--encoding=UTF8'], env, deadline, root, launch.signal);
452
+ await tool(join(binaries, 'initdb'), ['-D', data, ...initdbArguments(user)], env, deadline, root, launch.signal);
282
453
  }
283
454
  launch.signal?.throwIfAborted();
284
455
  const binary = join(binaries, 'postgres');
@@ -321,9 +492,9 @@ export async function nativePostgresUp(services, root, definition, launch) {
321
492
  if (started.error)
322
493
  throw started.error;
323
494
  if (child.exitCode !== null || child.signalCode !== null || started.closed)
324
- throw new Error(`native postgres exited before readiness; see ${log}`);
495
+ throw new Error(`native postgres exited before readiness; see ${log}${logTail(log)}`);
325
496
  if (Date.now() >= deadline)
326
- throw new Error(`native postgres did not become ready; see ${log}`);
497
+ throw new Error(`native postgres did not become ready; see ${log}${logTail(log)}`);
327
498
  if (existsSync(join(data, 'postmaster.pid'))) {
328
499
  verifyOwner(current);
329
500
  const lines = readFileSync(join(data, 'postmaster.pid'), 'utf8').split('\n');
@@ -340,7 +511,7 @@ export async function nativePostgresUp(services, root, definition, launch) {
340
511
  }
341
512
  launch.signal?.throwIfAborted();
342
513
  if (child.exitCode !== null || child.signalCode !== null)
343
- throw new Error(`native postgres exited during readiness; see ${log}`);
514
+ throw new Error(`native postgres exited during readiness; see ${log}${logTail(log)}`);
344
515
  rmSync(startingPath(root));
345
516
  child.unref();
346
517
  }
@@ -0,0 +1,7 @@
1
+ export interface RuntimeManifest {
2
+ name?: string;
3
+ version?: string;
4
+ dependencies?: Record<string, string>;
5
+ }
6
+ /** The runtime's package.json, from wherever this module runs. */
7
+ export declare function runtimeManifest(): RuntimeManifest;
@@ -0,0 +1,29 @@
1
+ // THIS PACKAGE'S OWN MANIFEST, found by its name walking up from this module. One finder for every module of the
2
+ // runtime that needs it (a prepared cluster's stamp records the runtime's version and its PGlite's).
3
+ //
4
+ // A fixed hop is wrong in one of the two places the runtime runs from: src/ in the repository is one directory below
5
+ // the package root, dist/src/ in the published package is two, so `..` from a published module is dist/, which
6
+ // holds no package.json. That mistake was made twice and fixed once: the PGlite backing walked up, the native
7
+ // backing still hopped, and a native preparation failed after its migration had run (Cal run 28: "ENOENT ...
8
+ // node_modules/@volter/world-runtime/dist/package.json").
9
+ //
10
+ // Nothing runs at import: a module that builds a URL from import.meta.url when it loads fails to load in workerd,
11
+ // which bundles the runtime into the hosted World.
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ const NAME = '@volter/world-runtime';
16
+ /** The runtime's package.json, from wherever this module runs. */
17
+ export function runtimeManifest() {
18
+ const from = fileURLToPath(new URL('.', import.meta.url));
19
+ for (let dir = from;; dir = dirname(dir)) {
20
+ const candidate = join(dir, 'package.json');
21
+ if (existsSync(candidate)) {
22
+ const manifest = JSON.parse(readFileSync(candidate, 'utf8'));
23
+ if (manifest.name === NAME)
24
+ return manifest;
25
+ }
26
+ if (dirname(dir) === dir)
27
+ throw new Error(`no ${NAME} package.json above ${from}`);
28
+ }
29
+ }
@@ -1,3 +1,4 @@
1
+ import { type WorldDatabasePrepare } from './schema.js';
1
2
  export interface InfraService {
2
3
  kind: string;
3
4
  hostPort: number;
@@ -42,8 +43,92 @@ export declare function preparedCluster(worldConfig: string): {
42
43
  directory: string;
43
44
  stamp: string;
44
45
  };
46
+ /**
47
+ * What a prepared cluster must have been made with: this runtime (whose pglite-host.mjs is the open path that made
48
+ * it), its exact PGlite pin (initdb's encoding and locale are PGlite's defaults: the host passes no initdb
49
+ * parameters), and the declared user and database. The password is not in the cluster: the role is created without
50
+ * one (LOGIN SUPERUSER) and the host's gateway checks the declared password on each connection, so a World with
51
+ * another password takes the same cluster and authenticates against its own. Extensions are loaded into the module
52
+ * at each open, never created in the cluster by the open path.
53
+ */
54
+ /** A prepared cluster's migration, as its stamp records it: the step's command and the digest of each of its inputs. */
55
+ export interface PreparedMigration {
56
+ command: string[];
57
+ inputs: Record<string, string>;
58
+ }
59
+ /**
60
+ * 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
61
+ * sorted relative paths, each with its type, its mode and its bytes (a link, its target). What the image build records
62
+ * for an image, and what `up` hashes only for a World run outside one.
63
+ */
64
+ export declare function migrationInputDigests(cwd: string, inputs: readonly string[]): Record<string, string>;
65
+ /** The World's declared migration step for its prepared database, if any (runtime.database.prepare). */
66
+ export declare function databasePrepareOf(worldConfig: string): WorldDatabasePrepare | undefined;
67
+ /**
68
+ * The World's declared step (runtime.database.prepare) run once against a prepared cluster its backing is serving on
69
+ * the World's port: the declared command in the app's tree, with the image's environment and the World's values over
70
+ * it. The build host's own PATH finds the command's tools (the image's PATH names its own paths). Its failure throws,
71
+ * with its output; its answer is what the cluster's stamp records.
72
+ */
73
+ /**
74
+ * A FAILED PREPARATION KEEPS ITS WHOLE EVIDENCE. An error carries a tail, and the preparation's own directory is
75
+ * its caller's to remove (an image build prepares in a staging directory it retires when the preparation fails), so
76
+ * the tail was all that survived: a postmaster's first lines, its shared-memory setup among them, were gone with the
77
+ * directory (Cal runs 23 and 24). The whole text or file is written under the preparation's temporary directory
78
+ * (TMPDIR, which a failed build retains; the system's otherwise), outside the World's directory, and the sentence
79
+ * naming it is added to the error. Never on success, and a copy that fails says so instead of hiding the failure.
80
+ */
81
+ export declare function keepFailureEvidence(name: string, evidence: string | {
82
+ file: string;
83
+ }, env: NodeJS.ProcessEnv): string;
84
+ export declare function runDatabasePrepare(step: WorldDatabasePrepare, worldConfig: string, launch: HostLaunch): Promise<PreparedMigration>;
45
85
  /** Run the host's open path once into the World's prepared directory; replaces an earlier preparation. */
46
86
  export declare function pglitePrepare(service: InfraService, worldConfig: string, launch?: HostLaunch): Promise<string>;
87
+ /**
88
+ * The migration a prepared cluster must have been made with for `up` to take it, on either backing: none when the
89
+ * World declares no step; else the step's command and its inputs' digests, as the app's image recorded them
90
+ * (VOLTER_WORLD_APP_DIGESTS, no read of the tree) or, for a World run outside an image, as its tree has them. With
91
+ * neither given a migrated cluster is not taken, and the note says so.
92
+ */
93
+ export declare function expectedMigration(worldConfig: string, env: NodeJS.ProcessEnv, directory: string): {
94
+ migration?: PreparedMigration;
95
+ } | {
96
+ note: string;
97
+ };
98
+ /** A data directory with no cluster carries no migration: a record left by data since removed is dropped. */
99
+ export declare function forgetStaleMigration(serviceData: string): void;
100
+ /**
101
+ * The declared step's command when the LIVE database carries the migration the app's image expects, else undefined
102
+ * (architecture, managed infrastructure). A fact of the data, not of this boot's placement: kept data is still
103
+ * migrated on a later boot, and kept data under an image whose step inputs have changed is older than the image.
104
+ */
105
+ export declare function liveDatabaseMigration(worldConfig: string, env: NodeJS.ProcessEnv, serviceData: string): string[] | undefined;
106
+ /**
107
+ * The declared step's command when the World declares one and the live database does NOT carry the migration the
108
+ * image expects: data kept from an earlier image, or a cluster that was initialized here because the prepared one
109
+ * was not taken. Undefined with no step, no cluster yet, or a migrated one.
110
+ */
111
+ export declare function liveDatabaseBehind(worldConfig: string, env: NodeJS.ProcessEnv, serviceData: string): string[] | undefined;
112
+ /**
113
+ * Whether this is a development World whose app may migrate its database when it starts
114
+ * (runtime.development.migrateDatabaseAtRuntime; schema.ts WorldDevelopment says whose the setting is).
115
+ */
116
+ export declare function developmentMigratesAtRuntime(worldConfig: string): boolean;
117
+ /** What a development World says each time it is brought up or built into an image, so its use is never silent. */
118
+ export declare const DEVELOPMENT_MIGRATION_NOTICE = "development World: its database migrates at run time; it is not prepared";
119
+ /**
120
+ * The one placement of a prepared cluster, for every backing that prepares one (PGlite here, native PostgreSQL in
121
+ * native-postgres-backing.ts): an empty `serviceData` takes `prepared.directory` by rename when the stamp beside it
122
+ * is exactly `expected`; a mismatch is named, never taken. A migrated cluster's `migration` is recorded beside the data
123
+ * it becomes (liveDatabaseMigration).
124
+ */
125
+ export declare function takePreparedCluster(prepared: {
126
+ directory: string;
127
+ stamp: string;
128
+ }, expected: string | ((stamp: string) => boolean), serviceData: string, mismatch: string, migration?: PreparedMigration): {
129
+ placed: boolean;
130
+ note?: string;
131
+ };
47
132
  export declare function pgliteUp(services: InfraService[], dataDir: string, launch?: HostLaunch): Promise<void>;
48
133
  export declare function pgliteStatus(services: InfraService[], dataDir: string): Promise<{
49
134
  ok: boolean;