@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
@@ -2,7 +2,7 @@
2
2
  // belong to the infrastructure service; a recipe never operates postgres itself.
3
3
  import { spawn, type ChildProcess } from 'node:child_process';
4
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
+ import { accessSync, chmodSync, chownSync, copyFileSync, lchownSync, lstatSync, closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
6
6
  import { connect } from 'node:net';
7
7
  import { runtimeManifest } from './package-manifest.ts';
8
8
  import { delimiter, dirname, join, resolve } from 'node:path';
@@ -10,8 +10,81 @@ import { commandLifetime, COMMAND_GRACE_MS, CommandRetirementError } from './com
10
10
  import { survivingOwnedGroups } from './process-groups.ts';
11
11
  import { publishProcessReceipt, readProcessReceipt, readSpawnReceipt, spawnReceiptPath, verifySpawnIdentity, signalOwnedProcess, type SpawnIdentity } from './process-receipt.ts';
12
12
  import { signalChild, childIdentity } from './process-signal.mjs';
13
+ import { worldEvent } from './process-groups.mjs';
13
14
  import { errorMessage } from './error-message.ts';
14
- import { PREPARED_DIRECTORY, databasePrepareOf, expectedMigration, forgetStaleMigration, keepFailureEvidence, runDatabasePrepare, takePreparedCluster, type HostLaunch, type InfraService, type PreparedMigration } from './pglite-backing.ts';
15
+ import { PREPARED_DIRECTORY, PREPARE_STATE_DIRECTORY, packageScriptPath, databasePrepareOf, environmentFile, expectedMigration, forgetStaleMigration, keepFailureEvidence, migrationInputDigests, preparedStore, preparedStoreStamp, preparedWorldServices, runDatabasePrepare, takePreparedCluster, type HostLaunch, type InfraService, type PreparedMigration } from './pglite-backing.ts';
16
+ import type { WorldDatabasePrepare } from './schema.ts';
17
+
18
+
19
+ /** OS identity, distinct from PostgreSQL's configured SQL role. No in-process privilege mutation. */
20
+ function postgresRunAs(): { uid: number; gid: number } {
21
+ const uid = process.getuid?.(); const gid = process.getgid?.();
22
+ if (uid === undefined || gid === undefined) throw new Error('native PostgreSQL requires a numeric OS process identity');
23
+ if (uid !== 0) return { uid, gid };
24
+ const accounts = readFileSync('/etc/passwd', 'utf8').split('\n').map(line => line.split(':'));
25
+ for (const name of ['postgres', 'nobody']) {
26
+ const account = accounts.find(fields => fields[0] === name && /^\d+$/.test(fields[2] ?? '') && /^\d+$/.test(fields[3] ?? '')
27
+ && Number(fields[2]) > 0 && Number(fields[2]) < 0xffffffff && Number(fields[3]) > 0 && Number(fields[3]) < 0xffffffff);
28
+ if (account) return { uid: Number(account[2]), gid: Number(account[3]) };
29
+ }
30
+ throw new Error('native PostgreSQL started by root requires an installed non-root postgres or nobody account');
31
+ }
32
+
33
+ /**
34
+ * The prepared cluster copied as the server's own (S2 R15): each directory, file and link made, its mode taken from
35
+ * the source as cpSync takes it, and given to the server's uid and gid as it is made. A cpSync and then
36
+ * ownPostgresCluster's walk asked the tree for every one of the cluster's files twice more (Cal in a tab: the
37
+ * placing step 170 ms, each ask a kernel request from up's process).
38
+ */
39
+ function copyClusterOwned(runAs: { uid: number; gid: number }): (from: string, to: string) => void {
40
+ const copy = (from: string, to: string): void => {
41
+ const stat = lstatSync(from);
42
+ if (stat.isSymbolicLink()) { symlinkSync(readlinkSync(from), to); lchownSync(to, runAs.uid, runAs.gid); return; }
43
+ if (stat.isDirectory()) {
44
+ mkdirSync(to);
45
+ for (const name of readdirSync(from)) copy(join(from, name), join(to, name));
46
+ } else if (stat.isFile()) copyFileSync(from, to);
47
+ else throw new Error(`native PostgreSQL's prepared cluster holds a ${stat.isFIFO() ? 'FIFO' : 'special file'}, which is not copied: ${from}`);
48
+ chmodSync(to, stat.mode & 0o7777);
49
+ chownSync(to, runAs.uid, runAs.gid);
50
+ };
51
+ return copy;
52
+ }
53
+
54
+ /** Assign only the stopped World's cluster, including a cluster placed from preparation. Never follow links. */
55
+ function ownPostgresCluster(data: string, runAs: { uid: number; gid: number }): { walked: number; changed: number } {
56
+ // What the walk did, said by the start's step (S2 R71): every entry is looked at, and only another owner's changed.
57
+ const counts = { walked: 0, changed: 0 };
58
+ const visit = (path: string): void => {
59
+ const stat = lstatSync(path);
60
+ counts.walked += 1;
61
+ if (stat.uid !== runAs.uid || stat.gid !== runAs.gid) {
62
+ counts.changed += 1;
63
+ if (stat.isSymbolicLink()) lchownSync(path, runAs.uid, runAs.gid);
64
+ else chownSync(path, runAs.uid, runAs.gid);
65
+ }
66
+ if (stat.isDirectory()) for (const name of readdirSync(path)) visit(join(path, name));
67
+ };
68
+ visit(data);
69
+ return counts;
70
+ }
71
+
72
+ /** The service may traverse its own World bookkeeping directories, without reading their private receipts. */
73
+ function postgresDirectorySearch(root: string, env: NodeJS.ProcessEnv, runAs: { uid: number; gid: number }): void {
74
+ const directories = [root];
75
+ // The live lifecycle creates instance/data as 0700. Only its recorded instance is granted search;
76
+ // preparation has a different root and never changes the application's directory above it.
77
+ if (env.VOLTER_WORLD_INSTANCE && env.VOLTER_WORLD_DATA
78
+ && resolve(env.VOLTER_WORLD_DATA) === resolve(root)
79
+ && dirname(resolve(env.VOLTER_WORLD_INSTANCE)) === dirname(resolve(root))) directories.push(dirname(resolve(root)));
80
+ for (const directory of directories) {
81
+ const stat = lstatSync(directory);
82
+ if (!stat.isDirectory()) throw new Error(`native PostgreSQL directory is not a directory: ${directory}`);
83
+ if ((stat.uid === runAs.uid && (stat.mode & 0o100)) || (stat.gid === runAs.gid && (stat.mode & 0o010)) || (stat.mode & 0o001)) continue;
84
+ chownSync(directory, stat.uid, runAs.gid);
85
+ chmodSync(directory, (stat.mode & 0o7777) | 0o010);
86
+ }
87
+ }
15
88
 
16
89
  interface Ownership { pid: number; data: string; port: number; binary: string }
17
90
  interface Started { child: ChildProcess; owned: Ownership; closed: boolean; identity?: SpawnIdentity; birth?: Promise<SpawnIdentity | undefined>; error?: Error }
@@ -46,11 +119,58 @@ function ownership(root: string) {
46
119
  }
47
120
  return { present: records.some(row => row.present) || spawn.present, held: values.find(row => row.pid === spawn.value?.pid) ?? values[0], identity: spawn.value, values, errors };
48
121
  }
49
- function verifyOwner(held: Ownership): void {
50
- const lines = readFileSync(join(held.data, 'postmaster.pid'), 'utf8').split('\n');
122
+ /**
123
+ * Whether postmaster.pid names the postmaster this lifecycle holds. The postmaster CREATES that file and then writes
124
+ * it, and removes it as it exits: a reader meets it empty, part written, or gone. Those are not a cluster owned by
125
+ * another; they are a file not written yet (false: ask again). A mismatch is four COMPLETE lines of which the pid,
126
+ * the data directory or the port differs, and it is refused with what was read beside what was held.
127
+ */
128
+ function verifyOwner(held: Ownership): boolean {
129
+ let text: string;
130
+ try { text = readFileSync(join(held.data, 'postmaster.pid'), 'utf8'); } catch { return false; }
131
+ const lines = text.split('\n');
132
+ // Lines 1-4 are pid, data directory, start time, port; each is complete when a line follows it.
133
+ if (lines.length < 5 || lines.slice(0, 4).some((line) => line.trim() === '')) return false;
51
134
  if (Number(lines[0]) !== held.pid || resolve(lines[1]!) !== held.data || Number(lines[3]) !== held.port) {
52
- throw new Error('native postgres ownership does not match the cluster; teardown refused');
135
+ // A FILE LEFT BY A POSTMASTER THAT IS GONE IS STALE, NOT ANOTHER OWNER. After a kill or a crash the file stays,
136
+ // four complete lines naming a pid with no process; the postmaster just started reads it, finds that, and
137
+ // writes its own. Read in between, it names neither this postmaster nor a live one: not written yet. Only a
138
+ // file naming a LIVE process that is not this one, or this one with another directory or port, is a mismatch.
139
+ const named = Number(lines[0]);
140
+ if (named !== held.pid && Number.isInteger(named) && named > 0) {
141
+ let gone = false;
142
+ try { process.kill(named, 0); } catch (error) { gone = (error as NodeJS.ErrnoException).code === 'ESRCH'; }
143
+ if (gone) return false;
144
+ }
145
+ throw new Error(`native postgres ownership does not match the cluster; teardown refused (postmaster.pid says pid ${lines[0]}, data ${lines[1]}, port ${lines[3]}; this lifecycle holds pid ${held.pid}, data ${held.data}, port ${held.port})`);
146
+ }
147
+ return true;
148
+ }
149
+ /**
150
+ * THE POSTMASTER'S STOP. PostgreSQL's contract for a stop is one signal to the postmaster (SIGINT, its fast
151
+ * shutdown; what pg_ctl sends): it ends its backends itself, writes the shutdown checkpoint and removes
152
+ * postmaster.pid. A signal to its GROUP reaches the checkpointer and the writers too, which are not the ones to
153
+ * tell. Between the signal and its exit the postmaster is writing the cluster down, and how long that takes is the
154
+ * size of what is dirty, so it is awaited on the lifecycle's own budget (READY_TIMEOUT_MS), never a command's
155
+ * grace: a SIGKILL in the middle of the checkpoint leaves a cluster with its pid file, which nothing takes. Only
156
+ * past that budget is the group killed, and that is said as a kill. The stop, the exit, and the time between are
157
+ * events in the World's log.
158
+ */
159
+ async function stopPostmaster(pid: number, deliver: (signal: NodeJS.Signals, group: boolean) => Promise<boolean> | boolean, by: string, data?: string): Promise<void> {
160
+ if (!liveGroup(pid)) return;
161
+ const asked = Date.now();
162
+ worldEvent({ event: 'world.service.stop', service: 'postgres', by, signal: 'SIGINT', to: 'pid', pid });
163
+ await deliver('SIGINT', false);
164
+ const deadline = asked + READY_TIMEOUT_MS;
165
+ while (liveGroup(pid) && Date.now() < deadline) await wait(100);
166
+ if (!liveGroup(pid)) {
167
+ worldEvent({ event: 'world.service.exited', service: 'postgres', by, pid, shutdownMs: Date.now() - asked, ...(data ? { pidFileLeft: existsSync(join(data, 'postmaster.pid')) } : {}) });
168
+ return;
53
169
  }
170
+ worldEvent({ event: 'world.service.killed', service: 'postgres', by, pid, signal: 'SIGKILL', to: 'group', afterMs: Date.now() - asked, doing: 'its shutdown had not ended within the lifecycle budget; the cluster is left with its pid file and is not taken' });
171
+ await deliver('SIGKILL', true);
172
+ const confirm = Date.now() + COMMAND_GRACE_MS;
173
+ while (liveGroup(pid) && Date.now() < confirm) await wait(20);
54
174
  }
55
175
  const liveGroup = (pid: number) => pid > 0 && survivingOwnedGroups([pid]).length > 0;
56
176
  function removeReceipts(root: string): void {
@@ -65,6 +185,11 @@ function removeReceipts(root: string): void {
65
185
  }
66
186
  if (errors.length) throw new AggregateError(errors, 'native postgres retired but receipt cleanup failed');
67
187
  }
188
+ async function retirePostmaster(identity: SpawnIdentity, by: string, data?: string): Promise<void> {
189
+ await stopPostmaster(identity.pid, (signal, group) => signalOwnedProcess(identity, signal, group, by), by, data);
190
+ if (liveGroup(identity.pid)) throw new Error(`native postgres group ${identity.pid} retirement unconfirmed; ownership retained`);
191
+ }
192
+ /** A helper this backing started (psql, createdb, initdb): it holds no state, and keeps a command's grace. */
68
193
  async function retireIdentity(identity: SpawnIdentity): Promise<void> {
69
194
  const { pid } = identity;
70
195
  for (const signal of ['SIGINT', 'SIGKILL'] as const) {
@@ -76,19 +201,17 @@ async function retireIdentity(identity: SpawnIdentity): Promise<void> {
76
201
  if (liveGroup(pid)) throw new Error(`native postgres group ${pid} retirement unconfirmed; ownership retained`);
77
202
  }
78
203
  /** A retained child is ownership even before postmaster.pid or the ready receipt exists. */
79
- async function retireStarted(started: Started): Promise<void> {
204
+ async function retireStarted(started: Started, by = 'down (this process started it)'): Promise<void> {
80
205
  const { child, owned } = started;
81
206
  child.ref();
82
- for (const signal of ['SIGINT', 'SIGKILL'] as const) {
207
+ if (owned.pid > 0) {
208
+ await stopPostmaster(owned.pid, async (signal, group) => signalChild(child, signal, group, by)
209
+ || (liveGroup(owned.pid) ? signalOwnedProcess(started.identity ?? await started.birth, signal, group, by) : false), by, owned.data);
210
+ }
211
+ const deadline = Date.now() + COMMAND_GRACE_MS;
212
+ while (Date.now() < deadline) {
83
213
  if (started.closed && !liveGroup(owned.pid)) return;
84
- if (owned.pid > 0 && !signalChild(child, signal, true) && liveGroup(owned.pid)) {
85
- await signalOwnedProcess(started.identity ?? await started.birth, signal, true);
86
- }
87
- const deadline = Date.now() + COMMAND_GRACE_MS;
88
- while (Date.now() < deadline) {
89
- if (started.closed && !liveGroup(owned.pid)) return;
90
- await wait(20);
91
- }
214
+ await wait(20);
92
215
  }
93
216
  throw new Error(`native postgres child ${owned.pid} retirement unconfirmed; ownership retained`);
94
217
  }
@@ -120,11 +243,11 @@ function logTail(log: string): string {
120
243
  try { return streamTail(`log ${log}`, readFileSync(log, 'utf8')); } catch { return ''; }
121
244
  }
122
245
  /** Every helper joins exit and its private group, including spawn errors and abort. */
123
- async function tool(command: string, args: string[], env: NodeJS.ProcessEnv, deadline: number, root: string, signal?: AbortSignal): Promise<string> {
246
+ async function tool(command: string, args: string[], env: NodeJS.ProcessEnv, deadline: number, root: string, runAs: { uid: number; gid: number }, signal?: AbortSignal): Promise<string> {
124
247
  signal?.throwIfAborted();
125
248
  const remaining = deadline - Date.now();
126
249
  if (remaining <= 0) throw new Error(`native postgres readiness deadline elapsed before ${command}`);
127
- const child = spawn(command, args, { env, detached: true, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
250
+ const child = spawn(command, args, { env, ...runAs, detached: true, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
128
251
  const lifetime = commandLifetime(child, signal);
129
252
  let output = ''; let errorOutput = ''; let spawnError: Error | undefined; let timedOut = false; let failure: unknown;
130
253
  child.stdout?.on('data', chunk => { output += chunk; });
@@ -213,6 +336,18 @@ export function nativePostgresTools(env: NodeJS.ProcessEnv): string | undefined
213
336
  return undefined;
214
337
  }
215
338
 
339
+ /**
340
+ * How a cluster left running is made durable before its recovery: one syncfs of its file system, where PostgreSQL's
341
+ * default opens and fsyncs every file under the data directory. A World's postmaster is not stopped when its tab
342
+ * closes, so a returning visit recovers: the Umami f7b17202 pair's second visit spent 652 ms in the startup process
343
+ * (3,078 opens, 1,496 fsyncs, 244 directory reads), where the first's took 77 ms, and was ready at check 8, not 5
344
+ * (S2 L1). syncfs is Linux's (PostgreSQL builds it where the C library has it: glibc, and the musl the tab's postgres
345
+ * is built with); macOS has none, and an option its build lacks is refused at start, so only Linux asks for it.
346
+ */
347
+ function recoverySync(): string[] {
348
+ return process.platform === 'linux' ? ['-c', 'recovery_init_sync_method=syncfs'] : [];
349
+ }
350
+
216
351
  /** initdb's arguments after `-D`, the same at up and at prepare, and so in the stamp. */
217
352
  function initdbArguments(user: string): string[] {
218
353
  return [`--username=${user}`, '--auth=trust', '--no-locale', '--encoding=UTF8'];
@@ -294,6 +429,131 @@ function nativeStampMatches(text: string, expected: NativeStamp): boolean {
294
429
  * 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
295
430
  * runs, under the tab's kernel, so the cluster is the one a first boot there would write.
296
431
  */
432
+ /** Every log file under a state directory, as one text with each file's path before its lines; empty when none. */
433
+ function worldLogs(state: string): string {
434
+ const out: string[] = [];
435
+ const walk = (directory: string): void => {
436
+ let names: string[];
437
+ try { names = readdirSync(directory).sort(); } catch { return; }
438
+ for (const name of names) {
439
+ const path = join(directory, name);
440
+ let stats;
441
+ try { stats = statSync(path); } catch { continue; }
442
+ if (stats.isDirectory()) { if (name !== 'native-postgres-data' && name !== 'node_modules') walk(path); }
443
+ else if (stats.isFile() && /\.log$/.test(name) && stats.size <= 8 * 1024 * 1024) {
444
+ try { out.push(`==> ${path} <==\n${readFileSync(path, 'utf8')}`); } catch { /* a log that cannot be read is left out */ }
445
+ }
446
+ }
447
+ };
448
+ walk(state);
449
+ return out.join('\n');
450
+ }
451
+
452
+ /**
453
+ * A managed store, other than the cluster, that keeps its bytes under the World's data and is staged with it: the
454
+ * tree of a service a twin serves, which twin-backing.ts names `<kind>-twin` (redis-twin).
455
+ */
456
+ const stagedStore = (name: string): boolean => /^[a-z0-9]+-twin$/.test(name);
457
+
458
+ /** The state directory a preparation with the World up boots its disposable instance in, beside the World's own. */
459
+
460
+ /**
461
+ * A cluster migrated by a step that needs the World (`runtime.database.prepare.with: "world"`): a migration that
462
+ * talks to a service the World supplies (a cache it flushes, a queue it registers with, a vendor it reads) cannot
463
+ * run against a bare postmaster. The World is brought up whole, as `run` brings it up, in a state directory of its
464
+ * own beside the World's (`.volter-prepare`), with no prepared cluster to take, so its managed PostgreSQL is made
465
+ * new by initdb; the step is the World's foreground command, in the app's tree, with the image's environment under
466
+ * the World's own (the names its twins issued and its infrastructure discovered); the World then goes down, which
467
+ * stops the postmaster cleanly. The stopped cluster is moved to the World's prepared place and stamped with the
468
+ * step, its inputs' digests and the twins that were up. Each other managed store that keeps its bytes under the
469
+ * World's data (the tree of each service a twin serves, `<kind>-twin`: the Redis twin's root) is staged beside the cluster with the same migration in a
470
+ * stamp of its own, since what the step wrote there is the World's prepared state as much as its tables are.
471
+ * Everything else that instance wrote (a twin's own state, its logs) is named in the note and removed with the
472
+ * state directory. A step that fails leaves no cluster, keeps the step's whole output where a failed preparation
473
+ * keeps its evidence, and says so.
474
+ */
475
+ async function prepareWithWorld(step: WorldDatabasePrepare, definition: string, worldConfig: string, tools: string, env: NodeJS.ProcessEnv, launch: HostLaunch): Promise<string> {
476
+ const appRoot = env.VOLTER_WORLD_APP_ROOT;
477
+ if (!appRoot) throw new Error('the World declares runtime.database.prepare: VOLTER_WORLD_APP_ROOT must name the app image\'s root');
478
+ const root = dirname(worldConfig);
479
+ const { directory, stamp } = nativePreparedCluster(worldConfig);
480
+ const state = join(root, PREPARE_STATE_DIRECTORY);
481
+ rmSync(directory, { recursive: true, force: true });
482
+ rmSync(stamp, { force: true });
483
+ rmSync(state, { recursive: true, force: true });
484
+ // The other stores a step run with the World up may write, staged beside the cluster (architecture, "A step may
485
+ // need the World"): each by the name of its directory under the World's data.
486
+ const previouslyStaged = (): string[] => { try { return readdirSync(dirname(directory)).filter(stagedStore); } catch { return []; } };
487
+ const unstage = (): void => { for (const name of previouslyStaged()) { const store = preparedStore(worldConfig, name); rmSync(store.directory, { recursive: true, force: true }); rmSync(store.stamp, { force: true }); } };
488
+ unstage();
489
+ const cwd = join(appRoot, step.cwd);
490
+ const { user, database } = settings(definition);
491
+ // The World's run reads this process's environment: the preparation's own (its PostgreSQL programs, the app's
492
+ // root), the image's values over it as for a step run alone, and the state directory. This process is the
493
+ // preparation and nothing else, so its environment is the preparation's to set.
494
+ const { PATH: _imagePath, ...imageValues } = env.VOLTER_WORLD_APP_ENV ? environmentFile(env.VOLTER_WORLD_APP_ENV) : {};
495
+ const before = { ...process.env };
496
+ for (const [name, value] of Object.entries({ ...env, ...imageValues, VOLTER_STATE_DIR: PREPARE_STATE_DIRECTORY })) if (value !== undefined) process.env[name] = value;
497
+ // The step is a package script's command here too, and is found where the application's own start finds it
498
+ // (pglite-backing.ts runDatabasePrepare): each node_modules/.bin from its working directory upward, before PATH.
499
+ process.env.PATH = [...packageScriptPath(cwd, appRoot), process.env.PATH ?? ''].filter(Boolean).join(delimiter);
500
+ delete process.env.VOLTER_WORLD_CONFIG;
501
+ const names = join(state, 'prepare.env');
502
+ let moved = false;
503
+ try {
504
+ const { runWorldOwned } = await import('./runtime.ts');
505
+ let ran: Awaited<ReturnType<typeof runWorldOwned>>;
506
+ try { ran = await runWorldOwned(worldConfig, step.command, { root, cwd, owner: 'database-prepare', envFile: names, forceEnvOutput: true }, launch.signal); }
507
+ catch (error) { throw new Error(`the World's database prepare step (${step.command.join(' ')}), run with the World up, did not complete: ${errorMessage(error)}`, { cause: error }); }
508
+ const log = ran.outcome.log, output = log && existsSync(log) ? readFileSync(log, 'utf8') : '';
509
+ if (ran.exitCode !== 0) {
510
+ throw new Error(`the World's database prepare step (${step.command.join(' ')}), run with the World up, exited ${ran.exitCode}\n${output.trim().split('\n').slice(-40).join('\n')}${keepFailureEvidence('database-prepare-step-output', output, env)}`);
511
+ }
512
+ const data = join(ran.instance.dirs.data, 'native-postgres-data');
513
+ if (!existsSync(join(data, 'PG_VERSION'))) throw new Error(`the World came up without a native PostgreSQL cluster at ${data}: a step declared with: "world" is prepared on the native backing only`);
514
+ if (existsSync(join(data, 'postmaster.pid'))) throw new Error('the World went down and left its postmaster\'s pid file: the cluster was not stopped cleanly and is not taken');
515
+ const given = existsSync(names) ? readFileSync(names, 'utf8').split('\n').map((line) => /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=/.exec(line)?.[1]).filter((name): name is string => name !== undefined) : [];
516
+ const world = preparedWorldServices(worldConfig);
517
+ mkdirSync(dirname(directory), { recursive: true });
518
+ renameSync(data, directory);
519
+ moved = true;
520
+ const migration: PreparedMigration = { command: [...step.command], inputs: migrationInputDigests(cwd, step.inputs), world };
521
+ const staged: string[] = [];
522
+ for (const name of readdirSync(ran.instance.dirs.data).filter(stagedStore).sort()) {
523
+ const from = join(ran.instance.dirs.data, name), store = preparedStore(worldConfig, name);
524
+ if (!statSync(from).isDirectory() || !readdirSync(from).length) continue;
525
+ // The twin that served this root is gone when its pid file is: the World's down waits for it and removes the
526
+ // file (twin-backing.ts). One left behind is a twin that may still be writing, as a postmaster's is above.
527
+ const left = readdirSync(ran.instance.dirs.data).filter((entry) => entry.startsWith(`${name}-`) && entry.endsWith('.pid'));
528
+ if (left.length) throw new Error(`the World went down and left ${left.join(', ')}: the ${name} was not stopped cleanly and its root is not staged`);
529
+ renameSync(from, store.directory);
530
+ writeFileSync(store.stamp, preparedStoreStamp(migration));
531
+ staged.push(name);
532
+ }
533
+ // Everything else the instance left under its data, by name: not staged, and gone with the state directory.
534
+ const notStaged = readdirSync(ran.instance.dirs.data).filter((name) => !/\.(?:log|pid|json)$/.test(name) && !/^(?:logs?|run)$/.test(name)).sort();
535
+ // The cluster's stamp is written LAST: a preparation killed before here leaves a cluster nothing takes, never a
536
+ // stamped cluster whose stores did not arrive.
537
+ writeFileSync(stamp, `${JSON.stringify(nativeStamp(tools, user, database, true, migration))}\n`);
538
+ launch.note?.(`prepared stores beside the cluster: ${staged.join(', ') || 'none'}${notStaged.length ? `; left under the instance's data and not staged: ${notStaged.join(', ')}` : ''}`);
539
+ launch.note?.(`database prepare step, with the World up (${Object.entries(world).map(([id, version]) => `${id}@${version}`).join(', ') || 'no twins'}; ${given.length} names given: ${given.join(', ')}): ${step.command.join(' ')}\n${output.trim()}`);
540
+ return directory;
541
+ } catch (error) {
542
+ if (moved) { rmSync(directory, { recursive: true, force: true }); rmSync(stamp, { force: true }); }
543
+ unstage();
544
+ // The instance's own logs go with the state directory below, and they are where a World that did not come up
545
+ // says why (the co-located host's output, a managed service's log, the lifecycle log). They are kept first,
546
+ // whole, where a failed preparation keeps its evidence, and the error names the file.
547
+ const logs = worldLogs(state);
548
+ if (logs && error instanceof Error) error.message += keepFailureEvidence('database-prepare-world-logs', logs, env);
549
+ throw error;
550
+ } finally {
551
+ rmSync(state, { recursive: true, force: true });
552
+ for (const name of Object.keys(process.env)) if (!(name in before)) delete process.env[name];
553
+ Object.assign(process.env, before);
554
+ }
555
+ }
556
+
297
557
  export async function nativePrepare(service: InfraService, definition: string, worldConfig: string, launch: HostLaunch = {}): Promise<string> {
298
558
  if (service.kind !== 'postgres') throw new Error('only a declared PostgreSQL is prepared');
299
559
  const env: NodeJS.ProcessEnv = { ...(launch.env ?? process.env) };
@@ -301,6 +561,8 @@ export async function nativePrepare(service: InfraService, definition: string, w
301
561
  delete env.VOLTER_WORLD_CONFIG;
302
562
  const tools = nativePostgresTools(env);
303
563
  if (!tools) throw new Error('native prepare requires the PostgreSQL programs: a postgres server on PATH, or VOLTER_WORLD_POSTGRES_BIN');
564
+ const withWorld = databasePrepareOf(worldConfig);
565
+ if (withWorld?.with === 'world') return prepareWithWorld(withWorld, definition, worldConfig, tools, env, launch);
304
566
  const { directory, stamp } = nativePreparedCluster(worldConfig);
305
567
  const root = dirname(directory);
306
568
  mkdirSync(root, { recursive: true });
@@ -331,13 +593,7 @@ export async function nativePrepare(service: InfraService, definition: string, w
331
593
  }
332
594
  // The clean stop: SIGINT is the postmaster's fast shutdown, which writes its checkpoint and removes postmaster.pid.
333
595
  // Its wait is the lifecycle's own readiness budget, not a new limit; down then clears the receipts.
334
- const identity = readSpawnReceipt(recordPath(root)).value;
335
- if (identity) {
336
- await signalOwnedProcess(identity, 'SIGINT', true);
337
- const deadline = Date.now() + READY_TIMEOUT_MS;
338
- while (liveGroup(identity.pid) && Date.now() < deadline) await wait(100);
339
- }
340
- await nativePostgresDown(root);
596
+ await nativePostgresDown(root, 'prepare (the cluster is made; its clean stop)');
341
597
  if (stepFailure) {
342
598
  rmSync(directory, { recursive: true, force: true });
343
599
  throw failed(stepFailure.error);
@@ -351,9 +607,21 @@ export async function nativePrepare(service: InfraService, definition: string, w
351
607
  return directory;
352
608
  }
353
609
 
610
+ /**
611
+ * How often the start looks for the postmaster's readiness (postmaster.pid's status line, then its port). Each look
612
+ * is two file reads; at 100 ms a start waited up to 100 ms past a ready server (Cal in a tab: ready at check 8, 1,211
613
+ * ms). The postmaster says it no other way a parent can wait on: its log is a file this start does not hold open.
614
+ */
615
+ const READY_CHECK_MS = 20;
616
+
354
617
  export async function nativePostgresUp(services: InfraService[], root: string, definition: string, launch: HostLaunch): Promise<void> {
355
618
  if (services.length !== 1 || services[0]!.kind !== 'postgres') throw new Error('native postgres requires one declared postgres service');
356
619
  const deadline = Date.now() + READY_TIMEOUT_MS;
620
+ // EACH STEP OF THIS START SAYS ITS OWN TIME AND WHAT IT WAITED ON, as a note the World's up prints on its own
621
+ // clock. In a tab the whole start took ten seconds of a World's twelve (Twenty: backing chosen at +1.29 s, ready at
622
+ // +11.72 s) and one line, the cluster's placing, said where any of it went.
623
+ let stepBegan = performance.now();
624
+ const step = (what: string): void => { const now = performance.now(); launch.note?.(`native PostgreSQL: ${what}, ${Math.round(now - stepBegan)} ms`); stepBegan = now; };
357
625
  const env: NodeJS.ProcessEnv = { ...(launch.env ?? process.env), LC_ALL: launch.env?.LC_ALL || 'C' };
358
626
  const binaries = nativePostgresTools(env);
359
627
  if (!binaries) throw new Error('native postgres requires an installed PostgreSQL: a postgres server on PATH, or VOLTER_WORLD_POSTGRES_BIN naming its tool directory');
@@ -373,12 +641,19 @@ export async function nativePostgresUp(services: InfraService[], root: string, d
373
641
  await retireTools(root);
374
642
  const service = services[0]!;
375
643
  if (await listening(service.hostPort, deadline, launch.signal)) throw new Error(`native postgres port ${service.hostPort} is already occupied`);
644
+ step(`an earlier start's programs retired and port ${service.hostPort} found free (one connection refused)`);
376
645
  const data = resolve(root, 'native-postgres-data');
377
646
  const { user, database } = settings(definition);
378
647
  // An empty data directory takes the World's prepared cluster when its stamp matches (ADR 0018's placement; the
379
648
  // cluster was made by these same programs at image build). initdb then finds PG_VERSION, and the database exists.
380
649
  forgetStaleMigration(data);
381
650
  const prepared = env.VOLTER_WORLD_CONFIG ? nativePreparedCluster(env.VOLTER_WORLD_CONFIG) : undefined;
651
+ // Placed this start under a stamp that names its database: the cluster holds it (S2 R15).
652
+ let placedDatabase = false;
653
+ // Copied as the server's own, every file already its: the walk below would find nothing to change.
654
+ let ownedAsCopied = false;
655
+ // Moved in whole this start, as the image laid it (S2 B2): owned by the owner the image names, not to be walked.
656
+ let movedAsLaid = false;
382
657
  // The check reads two files' metadata and the stamp; it runs only when a prepared cluster is there to take.
383
658
  if (prepared && existsSync(join(prepared.directory, 'PG_VERSION')) && !existsSync(join(data, 'PG_VERSION'))) {
384
659
  // A migrated cluster is the app's only for the step inputs it was migrated from (pglite-backing.ts, expectedMigration).
@@ -387,13 +662,33 @@ export async function nativePostgresUp(services: InfraService[], root: string, d
387
662
  else {
388
663
  const expected = nativeStamp(binaries, user, database, false, wanted.migration);
389
664
  const placed = takePreparedCluster(prepared, text => nativeStampMatches(text, expected), data,
390
- 'other PostgreSQL programs, initdb arguments, identity or migration', wanted.migration);
665
+ 'other PostgreSQL programs, initdb arguments, identity or migration', wanted.migration, copyClusterOwned(postgresRunAs()));
666
+ ownedAsCopied = placed.copied === true;
667
+ movedAsLaid = placed.placed && placed.copied !== true;
391
668
  if (placed.note) launch.note?.(placed.note);
669
+ placedDatabase = placed.placed;
392
670
  }
393
671
  }
672
+ // The placement's own parts, each a step (S2 R71: Cal's 195-206 ms was one line): the stamp's check and the move,
673
+ // then the server's directories and ownership with what the walk looked at and changed.
674
+ step(placedDatabase ? 'the prepared cluster\'s stamp read and matched, and the cluster moved into place' : 'the prepared cluster looked for (none placed)');
394
675
  mkdirSync(data, { recursive: true, mode: 0o700 });
676
+ const runAs = postgresRunAs();
677
+ postgresDirectorySearch(root, env, runAs);
678
+ // A cluster this start moved in whole from the prepared tree is owned as the image laid it. Where the page says the
679
+ // image laid every entry of that cluster with one owner, and that owner is the server's (VOLTER_WORLD_PREPARED_CLUSTER_
680
+ // OWNER, given only where its open placed the World's tree with the prepared project), the walk could change nothing
681
+ // and is not run (S2 B2: 134-206 ms of lstats on Cal and Twenty). Any other cluster, or no such word, is walked.
682
+ const laid = /^(\d+):(\d+)$/u.exec(env.VOLTER_WORLD_PREPARED_CLUSTER_OWNER ?? '');
683
+ const ownedAsLaid = movedAsLaid && laid !== null && Number(laid[1]) === runAs.uid && Number(laid[2]) === runAs.gid;
684
+ const owned = ownedAsCopied || ownedAsLaid ? undefined : ownPostgresCluster(data, runAs);
685
+ launch.note?.(`native PostgreSQL: child uid ${runAs.uid}, gid ${runAs.gid}; cluster ${data}`);
686
+ step(ownedAsCopied ? 'the prepared cluster looked for and copied as the server\'s own'
687
+ : ownedAsLaid ? `the prepared cluster looked for and placed where one matches, laid as the server's own (${runAs.uid}:${runAs.gid}): not walked`
688
+ : `the prepared cluster looked for and placed where one matches, its files made the server's (${owned!.walked} entries walked, ${owned!.changed} changed)`);
395
689
  if (!existsSync(join(data, 'PG_VERSION'))) {
396
- await tool(join(binaries, 'initdb'), ['-D', data, ...initdbArguments(user)], env, deadline, root, launch.signal);
690
+ await tool(join(binaries, 'initdb'), ['-D', data, ...initdbArguments(user)], env, deadline, root, runAs, launch.signal);
691
+ step('initdb run to its end (no prepared cluster was placed)');
397
692
  }
398
693
  launch.signal?.throwIfAborted();
399
694
  const binary = join(binaries, 'postgres');
@@ -407,7 +702,7 @@ export async function nativePostgresUp(services: InfraService[], root: string, d
407
702
  const fd = openSync(log, 'a');
408
703
  let child: ChildProcess;
409
704
  try {
410
- child = spawn(binary, ['-D', data, '-h', '127.0.0.1', '-p', String(service.hostPort), '-k', ''], { env, detached: true, windowsHide: true, stdio: ['ignore', fd, fd] });
705
+ child = spawn(binary, ['-D', data, '-h', '127.0.0.1', '-p', String(service.hostPort), '-k', '', ...recoverySync()], { env, ...runAs, detached: true, windowsHide: true, stdio: ['ignore', fd, fd] });
411
706
  current.pid = child.pid ?? 0;
412
707
  started = { child, owned: current, closed: false };
413
708
  const observed = started;
@@ -424,22 +719,36 @@ export async function nativePostgresUp(services: InfraService[], root: string, d
424
719
  if (started.error) throw started.error;
425
720
  if (!child.pid) throw new Error('native postgres spawn failed');
426
721
  publishProcessReceipt(recordPath(root), JSON.stringify(current) + '\n');
722
+ step('the postmaster spawned, its identity captured and its receipts written');
723
+ // The cluster is chosen and placed (takePreparedCluster's move or copy, its migration record written; or initdb's)
724
+ // and its server spawned: what a twin's staged store is placed beside is there now (S2 R79), before readiness.
725
+ launch.onPlaced?.();
726
+ let checks = 0;
427
727
  for (;;) {
728
+ checks += 1;
428
729
  launch.signal?.throwIfAborted();
429
730
  if (started.error) throw started.error;
430
731
  if (child.exitCode !== null || child.signalCode !== null || started.closed) throw new Error(`native postgres exited before readiness; see ${log}${logTail(log)}`);
431
732
  if (Date.now() >= deadline) throw new Error(`native postgres did not become ready; see ${log}${logTail(log)}`);
432
- if (existsSync(join(data, 'postmaster.pid'))) {
433
- verifyOwner(current);
434
- const lines = readFileSync(join(data, 'postmaster.pid'), 'utf8').split('\n');
733
+ if (existsSync(join(data, 'postmaster.pid')) && verifyOwner(current)) {
734
+ // Read again for its status line; gone or shorter between the two reads is not ready yet.
735
+ let lines: string[] = [];
736
+ try { lines = readFileSync(join(data, 'postmaster.pid'), 'utf8').split('\n'); } catch { /* removed since the check above */ }
435
737
  if (lines[7]?.trim() === 'ready' && await listening(service.hostPort, deadline, launch.signal)) break;
436
738
  }
437
- await wait(100);
739
+ await wait(READY_CHECK_MS);
438
740
  }
439
- if (database !== 'postgres') {
741
+ step(`the postmaster ready: postmaster.pid says so and its port answers, at check ${checks} (a check every ${READY_CHECK_MS} ms)`);
742
+ // A cluster placed this start was matched on a stamp that names this database (nativeStampMatches), and made with
743
+ // it by these same programs at image build: asking the server again was a psql process of its own (Cal in a tab:
744
+ // 327 ms of a 2.3 s World up). A cluster from an earlier start, or initdb's, is still asked, and given the database
745
+ // where it lacks it.
746
+ if (placedDatabase && database !== 'postgres') step(`database ${database} is the placed cluster's, by its stamp: not asked`);
747
+ else if (database !== 'postgres') {
440
748
  const sql = `SELECT 1 FROM pg_database WHERE datname = '${database.replaceAll("'", "''")}'`;
441
- const output = await tool(join(binaries, 'psql'), ['-h', '127.0.0.1', '-p', String(service.hostPort), '-U', user, '-d', 'postgres', '-tAc', sql], env, deadline, root, launch.signal);
442
- if (!output.trim()) await tool(join(binaries, 'createdb'), ['-h', '127.0.0.1', '-p', String(service.hostPort), '-U', user, '--', database], env, deadline, root, launch.signal);
749
+ const output = await tool(join(binaries, 'psql'), ['-h', '127.0.0.1', '-p', String(service.hostPort), '-U', user, '-d', 'postgres', '-tAc', sql], env, deadline, root, runAs, launch.signal);
750
+ step(`psql run to its end, asking whether database ${database} is there`);
751
+ if (!output.trim()) { await tool(join(binaries, 'createdb'), ['-h', '127.0.0.1', '-p', String(service.hostPort), '-U', user, '--', database], env, deadline, root, runAs, launch.signal); step(`createdb run to its end`); }
443
752
  }
444
753
  launch.signal?.throwIfAborted();
445
754
  if (child.exitCode !== null || child.signalCode !== null) throw new Error(`native postgres exited during readiness; see ${log}${logTail(log)}`);
@@ -449,7 +758,7 @@ export async function nativePostgresUp(services: InfraService[], root: string, d
449
758
  // The original publication/readiness failure survives every cleanup failure.
450
759
  const failures: unknown[] = [error];
451
760
  try {
452
- if (started) await retireStarted(started);
761
+ if (started) await retireStarted(started, 'up (startup failed)');
453
762
  removeReceipts(root);
454
763
  starts.delete(root);
455
764
  } catch (cleanup) { failures.push(cleanup); }
@@ -457,13 +766,15 @@ export async function nativePostgresUp(services: InfraService[], root: string, d
457
766
  throw error;
458
767
  }
459
768
  }
460
- export async function nativePostgresStatus(root: string): Promise<boolean> {
769
+ /** The native postmaster's status proof: its ownership known, its held group live, its spawn identity and owner
770
+ * verified, and its port answering. `signal` ends the port's wait with the boot that asks (an up's hand-over). */
771
+ export async function nativePostgresStatus(root: string, signal?: AbortSignal): Promise<boolean> {
461
772
  const state = ownership(root);
462
773
  if (state.errors.length) throw new AggregateError(state.errors, 'native postgres ownership uncertain; retry down');
463
774
  if (!state.held || !liveGroup(state.held.pid)) return false;
464
775
  if (state.identity) await verifySpawnIdentity(state.identity);
465
- verifyOwner(state.held);
466
- return listening(state.held.port, Date.now() + READY_TIMEOUT_MS);
776
+ if (!verifyOwner(state.held)) return false;
777
+ return listening(state.held.port, Date.now() + READY_TIMEOUT_MS, signal);
467
778
  }
468
779
 
469
780
  async function retireTools(root: string): Promise<void> {
@@ -488,16 +799,17 @@ async function retireTools(root: string): Promise<void> {
488
799
  rmSync(receipt, { force: true }); rmSync(spawnReceiptPath(receipt), { force: true });
489
800
  }
490
801
  }
491
- export async function nativePostgresDown(root: string): Promise<void> {
802
+ export async function nativePostgresDown(root: string, by = 'down'): Promise<void> {
492
803
  const started = starts.get(root);
493
804
  const state = ownership(root);
805
+ const data = resolve(root, 'native-postgres-data');
494
806
  if (state.errors.length) process.stderr.write(`${errorMessage(new AggregateError(state.errors, 'native postgres ownership uncertain; retrying retirement through established identity'))}\n`);
495
807
  if (started) {
496
- await retireStarted(started);
808
+ await retireStarted(started, by);
497
809
  } else if (state.identity) {
498
810
  if (liveGroup(state.identity.pid)) {
499
811
  await verifySpawnIdentity(state.identity);
500
- await retireIdentity(state.identity);
812
+ await retirePostmaster(state.identity, by, data);
501
813
  }
502
814
  } else if (state.held && state.held.pid > 0) {
503
815
  if (liveGroup(state.held.pid)) {