@volter/world-runtime 3.0.37 → 3.0.39

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 (168) hide show
  1. package/dist/known-external-services.json +3 -10
  2. package/dist/src/actions-runner-cli.js +3 -3
  3. package/dist/src/actions-runner.js +2 -1
  4. package/dist/src/app-url.js +2 -1
  5. package/dist/src/backings.d.ts +10 -1
  6. package/dist/src/backings.js +66 -7
  7. package/dist/src/branch.js +85 -52
  8. package/dist/src/browser-routes.cjs +59 -0
  9. package/dist/src/ca-trust.js +4 -3
  10. package/dist/src/changeset.js +4 -3
  11. package/dist/src/cli-commands.js +25 -9
  12. package/dist/src/cli.js +2 -1
  13. package/dist/src/command-lifetime.d.mts +12 -0
  14. package/dist/src/command-lifetime.d.ts +1 -15
  15. package/dist/src/command-lifetime.js +1 -98
  16. package/dist/src/command-lifetime.mjs +90 -0
  17. package/dist/src/configs.js +1 -1
  18. package/dist/src/console-apart.js +3 -2
  19. package/dist/src/covers.d.ts +3 -2
  20. package/dist/src/covers.js +95 -18
  21. package/dist/src/error-message.d.mts +1 -0
  22. package/dist/src/error-message.d.ts +1 -0
  23. package/dist/src/error-message.js +2 -0
  24. package/dist/src/error-message.mjs +10 -0
  25. package/dist/src/guest-command.d.mts +4 -0
  26. package/dist/src/guest-command.mjs +26 -0
  27. package/dist/src/guest-control.d.mts +1 -0
  28. package/dist/src/guest-control.mjs +17 -0
  29. package/dist/src/guest-entry.mjs +146 -0
  30. package/dist/src/guest-processes.d.mts +9 -0
  31. package/dist/src/guest-processes.mjs +47 -0
  32. package/dist/src/guest-runner.d.ts +6 -0
  33. package/dist/src/guest-runner.js +567 -0
  34. package/dist/src/host.js +5 -4
  35. package/dist/src/index.d.ts +1 -0
  36. package/dist/src/index.js +1 -0
  37. package/dist/src/infra-cli.js +4 -0
  38. package/dist/src/infra.js +133 -23
  39. package/dist/src/init.js +60 -23
  40. package/dist/src/lifecycle-record.d.ts +3 -1
  41. package/dist/src/lifecycle-record.js +29 -6
  42. package/dist/src/livekit-backing.mjs +37 -0
  43. package/dist/src/local-branches.js +11 -3
  44. package/dist/src/mongodb-backing.d.ts +7 -0
  45. package/dist/src/mongodb-backing.js +58 -0
  46. package/dist/src/native-postgres-backing.d.ts +4 -0
  47. package/dist/src/native-postgres-backing.js +404 -0
  48. package/dist/src/origin.js +2 -1
  49. package/dist/src/owned-path.d.ts +4 -0
  50. package/dist/src/owned-path.js +37 -0
  51. package/dist/src/pack-facts.d.ts +1 -0
  52. package/dist/src/pglite-backing.d.ts +4 -13
  53. package/dist/src/pglite-backing.js +198 -106
  54. package/dist/src/pglite-host.mjs +47 -17
  55. package/dist/src/placeholder.d.ts +3 -2
  56. package/dist/src/placeholder.js +9 -4
  57. package/dist/src/process-groups.d.mts +2 -0
  58. package/dist/src/process-groups.d.ts +1 -4
  59. package/dist/src/process-groups.js +1 -49
  60. package/dist/src/process-groups.mjs +59 -0
  61. package/dist/src/process-receipt.d.mts +6 -0
  62. package/dist/src/process-receipt.d.ts +11 -0
  63. package/dist/src/process-receipt.js +3 -0
  64. package/dist/src/process-receipt.mjs +45 -0
  65. package/dist/src/process-signal.d.mts +9 -0
  66. package/dist/src/process-signal.mjs +92 -0
  67. package/dist/src/project-inspect.d.ts +1 -1
  68. package/dist/src/project-inspect.js +4 -2
  69. package/dist/src/proxy-daemon.js +2 -1
  70. package/dist/src/redirect-proxy.js +186 -19
  71. package/dist/src/redis-backing.js +99 -35
  72. package/dist/src/reflect.js +2 -1
  73. package/dist/src/root.js +7 -6
  74. package/dist/src/run-task-worker.js +2 -1
  75. package/dist/src/runtime.d.ts +24 -7
  76. package/dist/src/runtime.js +703 -286
  77. package/dist/src/schema.d.ts +12 -1
  78. package/dist/src/schema.js +20 -5
  79. package/dist/src/served-world.d.ts +1 -0
  80. package/dist/src/served-world.js +26 -14
  81. package/dist/src/service-recorder.js +8 -3
  82. package/dist/src/signals.d.mts +1 -0
  83. package/dist/src/signals.d.ts +1 -1
  84. package/dist/src/signals.js +1 -11
  85. package/dist/src/signals.mjs +10 -0
  86. package/dist/src/tail.d.ts +2 -0
  87. package/dist/src/tail.js +9 -2
  88. package/dist/src/tcp-port.js +27 -13
  89. package/dist/src/temporal-backing.mjs +28 -0
  90. package/dist/src/up-cli.js +8 -3
  91. package/dist/src/up-task-worker.js +2 -1
  92. package/dist/src/websocket-relay.d.ts +1 -1
  93. package/dist/src/websocket-relay.js +23 -10
  94. package/dist/src/world-view.js +2 -2
  95. package/known-external-services.json +3 -10
  96. package/package.json +12 -7
  97. package/src/actions-runner-cli.ts +3 -3
  98. package/src/actions-runner.ts +2 -1
  99. package/src/app-url.ts +2 -1
  100. package/src/backings.ts +65 -7
  101. package/src/branch.ts +73 -34
  102. package/src/browser-routes.cjs +59 -0
  103. package/src/ca-trust.ts +4 -3
  104. package/src/changeset.ts +4 -3
  105. package/src/cli-commands.ts +21 -9
  106. package/src/cli.ts +2 -1
  107. package/src/command-lifetime.d.mts +12 -0
  108. package/src/command-lifetime.mjs +90 -0
  109. package/src/command-lifetime.ts +1 -79
  110. package/src/configs.ts +1 -1
  111. package/src/console-apart.ts +2 -1
  112. package/src/covers.ts +97 -19
  113. package/src/error-message.d.mts +1 -0
  114. package/src/error-message.mjs +10 -0
  115. package/src/error-message.ts +2 -0
  116. package/src/guest-command.d.mts +4 -0
  117. package/src/guest-command.mjs +26 -0
  118. package/src/guest-control.d.mts +1 -0
  119. package/src/guest-control.mjs +17 -0
  120. package/src/guest-entry.mjs +146 -0
  121. package/src/guest-processes.d.mts +9 -0
  122. package/src/guest-processes.mjs +47 -0
  123. package/src/guest-runner.ts +414 -0
  124. package/src/host.ts +5 -4
  125. package/src/index.ts +1 -0
  126. package/src/infra-cli.ts +4 -0
  127. package/src/infra.ts +103 -22
  128. package/src/init.ts +55 -22
  129. package/src/lifecycle-record.ts +21 -6
  130. package/src/livekit-backing.mjs +37 -0
  131. package/src/local-branches.ts +6 -3
  132. package/src/mongodb-backing.ts +55 -0
  133. package/src/native-postgres-backing.ts +329 -0
  134. package/src/origin.ts +2 -1
  135. package/src/owned-path.ts +32 -0
  136. package/src/pack-facts.ts +1 -0
  137. package/src/pglite-backing.ts +155 -79
  138. package/src/pglite-host.mjs +47 -17
  139. package/src/placeholder.ts +10 -5
  140. package/src/process-groups.d.mts +2 -0
  141. package/src/process-groups.mjs +59 -0
  142. package/src/process-groups.ts +1 -33
  143. package/src/process-receipt.d.mts +6 -0
  144. package/src/process-receipt.mjs +45 -0
  145. package/src/process-receipt.ts +5 -0
  146. package/src/process-signal.d.mts +9 -0
  147. package/src/process-signal.mjs +92 -0
  148. package/src/project-inspect.ts +4 -2
  149. package/src/proxy-daemon.ts +2 -1
  150. package/src/redirect-proxy.ts +153 -18
  151. package/src/redis-backing.ts +74 -25
  152. package/src/reflect.ts +2 -1
  153. package/src/root.ts +7 -6
  154. package/src/run-task-worker.ts +2 -1
  155. package/src/runtime.ts +596 -265
  156. package/src/schema.ts +30 -6
  157. package/src/served-world.ts +21 -15
  158. package/src/service-recorder.ts +4 -2
  159. package/src/signals.d.mts +1 -0
  160. package/src/signals.mjs +10 -0
  161. package/src/signals.ts +1 -10
  162. package/src/tail.ts +10 -3
  163. package/src/tcp-port.ts +24 -13
  164. package/src/temporal-backing.mjs +28 -0
  165. package/src/up-cli.ts +6 -3
  166. package/src/up-task-worker.ts +2 -1
  167. package/src/websocket-relay.ts +18 -11
  168. package/src/world-view.ts +2 -2
@@ -1,8 +1,7 @@
1
1
  // The containerless backing for World-managed infrastructure, instead of docker
2
2
  // compose: a POSTGRES service is a PGlite host (see pglite-host.mjs), a MONGODB
3
- // service is the MongoDB twin (@volter/twin-mongodb: the MongoDB wire protocol over
4
- // the World state kernel). Selected by `volter-world-infra` when no container
5
- // runtime is available (or by explicit VOLTER_WORLD_INFRA_BACKING=pglite); the
3
+ // service is MongoDB's own mongod (mongodb-backing.ts). MongoDB is always native; Postgres is selected
4
+ // when no container runtime is available (or by VOLTER_WORLD_INFRA_BACKING=pglite). The
6
5
  // world config, definition file, injected env, and declared-service contract are
7
6
  // byte-identical either way — backing is private, exactly as the boundary comment
8
7
  // in infra-cli.ts promises. Each service's bytes live under VOLTER_WORLD_DATA, so
@@ -12,39 +11,40 @@
12
11
  // services only. A definition declaring mysql without a container runtime is
13
12
  // refused with the kinds named, never half-booted. Redis never reaches it: every
14
13
  // redis service is the redis twin's (redis-backing.ts).
15
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
14
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync } from 'node:fs';
16
15
  import { connect } from 'node:net';
17
16
  import { dirname, join } from 'node:path';
18
- import { spawn, spawnSync } from 'node:child_process';
17
+ import { spawn } from 'node:child_process';
18
+ import { commandLifetime, COMMAND_GRACE_MS, COMMAND_CONFIRM_MS } from "./command-lifetime.js";
19
+ import { survivingOwnedGroups } from "./process-groups.js";
20
+ import { publishProcessReceipt, readProcessReceipt, readSpawnReceipt, spawnReceiptPath, verifySpawnIdentity, signalOwnedProcess } from "./process-receipt.js";
21
+ import { errorMessage } from "./error-message.js";
19
22
  import { fileURLToPath } from 'node:url';
20
- import { findInstalledPackage, packCli } from "./catalog.js";
21
- import { vendorPackageDir } from '@volter/world-core/runtime';
22
- /** Parse the services out of world.infrastructure.yml. This is NOT a YAML
23
- * parser: init's renderComposeFile emits a deterministic shape, and this
24
- * reads exactly that shape back (service key at 2-space indent = the kind;
25
- * the single loopback port mapping beneath it). Anything else is a malformed
26
- * definition and fails loudly. */
23
+ import { parse } from 'yaml';
24
+ import { mongodbArgv, mongodbListening, mongodbReady } from "./mongodb-backing.js";
25
+ /** Read the generated definition's loopback port mappings. MongoDB may explicitly request
26
+ * one replica-set member through its Compose-compatible command; other mongod options are refused. */
27
27
  export function parseInfraDefinition(text) {
28
+ const definition = parse(text);
28
29
  const services = [];
29
- let current = null;
30
- let inServices = false;
31
- for (const line of text.split(/\r?\n/)) {
32
- if (/^services:\s*$/.test(line)) {
33
- inServices = true;
34
- continue;
35
- }
36
- if (!inServices)
37
- continue;
38
- const service = line.match(/^ {2}([a-z][a-z0-9_-]*):\s*$/);
39
- if (service) {
40
- current = service[1];
41
- continue;
42
- }
43
- const port = line.match(/^ {6}- "127\.0\.0\.1:(\d+):\d+"\s*$/);
44
- if (port && current) {
45
- services.push({ kind: current, hostPort: Number(port[1]) });
46
- current = null;
30
+ for (const [kind, value] of Object.entries(definition?.services ?? {})) {
31
+ const declared = value;
32
+ let replicaSet;
33
+ if (kind === 'mongodb' && declared.command !== undefined) {
34
+ const command = declared.command;
35
+ if (!Array.isArray(command) || command.length !== 3 || command[0] !== 'mongod' || command[1] !== '--replSet'
36
+ || typeof command[2] !== 'string' || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(command[2])) {
37
+ throw new Error('World-managed MongoDB command must be ["mongod", "--replSet", "<name>"] or absent for standalone');
38
+ }
39
+ replicaSet = command[2];
47
40
  }
41
+ const ports = (declared.ports ?? []).flatMap((port) => {
42
+ const match = typeof port === 'string' ? port.match(/^127\.0\.0\.1:(\d+):\d+$/) : null;
43
+ return match ? [Number(match[1])] : [];
44
+ });
45
+ if (ports.length !== 1)
46
+ throw new Error(`managed infrastructure ${kind} needs exactly one declared loopback port`);
47
+ services.push({ kind, hostPort: ports[0], ...(replicaSet ? { replicaSet } : {}) });
48
48
  }
49
49
  return services;
50
50
  }
@@ -74,50 +74,50 @@ export function infraConnections(services, env) {
74
74
  const here = () => fileURLToPath(new URL('.', import.meta.url));
75
75
  const hostScript = () => join(here(), 'pglite-host.mjs');
76
76
  const READY_TIMEOUT_MS = 60_000;
77
- const MONGODB_TWIN = '@volter/twin-mongodb';
78
77
  /** The kinds this backing serves, and the name its files carry under the data dir. */
79
- const CONTAINERLESS = { postgres: 'pglite-postgres', mongodb: 'twin-mongodb' };
78
+ const CONTAINERLESS = { postgres: 'pglite-postgres', mongodb: 'mongod-mongodb' };
79
+ // Shared with stopped-state branching: only these backings own cold-copied database bytes.
80
+ export function containerlessDataDirectory(kind) {
81
+ const name = CONTAINERLESS[kind];
82
+ return name === undefined ? undefined : `${name}-data`;
83
+ }
80
84
  function pidPath(dataDir, kind) {
81
85
  return join(dataDir, `${CONTAINERLESS[kind] ?? `pglite-${kind}`}.pid`);
82
86
  }
83
- /** The MongoDB twin's cli: the package installed above the World's config (an app repo), else the one
84
- * this runtime was installed with (its dependency, or the checkout's workspace link). */
85
- export function mongodbTwinCli(worldConfig, froms = [...(worldConfig ? [dirname(worldConfig)] : []), here()]) {
86
- for (const from of froms) {
87
- const dir = vendorPackageDir(from, 'mongodb') ?? findInstalledPackage(from, MONGODB_TWIN);
88
- const cli = dir === undefined ? undefined : packCli(dir);
89
- if (cli !== undefined)
90
- return cli;
91
- }
92
- throw new Error(`the containerless mongodb service is served by ${MONGODB_TWIN} (an optional dependency of @volter/world-runtime), which is not installed above ${froms.join(' or ')} — install it, or run the World with a container runtime`);
93
- }
94
- /** The runtime a twin's cli runs under. A published package's cli is JavaScript and runs under this
95
- * process's own runtime; a checkout's is TypeScript whose kernel needs more than Node's type
96
- * stripping, so it runs under Bun (this process's, or the one on PATH) — the repository's toolchain —
97
- * and without Bun it is refused by name rather than started to fail. */
98
- export function twinRunner(cli, bunOnPath = () => spawnSync('bun', ['--version'], { stdio: 'ignore', timeout: 10_000, windowsHide: true }).status === 0, underBun = typeof globalThis.Bun !== 'undefined') {
99
- if (!cli.endsWith('.ts') || underBun)
100
- return process.execPath;
101
- if (bunOnPath())
102
- return 'bun';
103
- throw new Error(`a checkout's MongoDB twin runs under bun, which is not on PATH (${cli} is TypeScript that Node cannot run)`);
87
+ const hosts = new Map();
88
+ function hostOwnership(pidFile) {
89
+ const receipt = readProcessReceipt(pidFile, text => {
90
+ if (!/^[1-9][0-9]*\s*$/.test(text))
91
+ throw new Error('Invalid managed host PID');
92
+ const pid = Number(text.trim());
93
+ if (!Number.isSafeInteger(pid) || pid <= 0)
94
+ throw new Error('Invalid managed host PID');
95
+ return pid;
96
+ });
97
+ const spawn = readSpawnReceipt(pidFile);
98
+ const errors = [receipt.error, spawn.error].filter((error) => !!error);
99
+ if (receipt.value && spawn.value && receipt.value !== spawn.value.pid) {
100
+ errors.push(new Error('Managed host receipts disagree; ownership uncertain'));
101
+ }
102
+ return { present: receipt.present || spawn.present, pid: spawn.value?.pid ?? receipt.value, identity: spawn.value, errors, recordedPid: receipt.value, readyReceipt: receipt.value !== undefined, spawnPresent: spawn.present };
104
103
  }
105
104
  /** The argv that serves one service on its declared loopback port, with its bytes in `serviceData`. */
106
- function hostArgv(service, serviceData, env) {
107
- if (service.kind === 'mongodb') {
108
- const cli = mongodbTwinCli(env.VOLTER_WORLD_CONFIG);
109
- // blocking (spawnSync), as it was in the tool: reached only for a checkout's TypeScript cli under Node
110
- return [twinRunner(cli, () => spawnSync('bun', ['--version'], { stdio: 'ignore', timeout: 10_000, windowsHide: true, env }).status === 0), cli, 'serve', '--port', String(service.hostPort), '--root', serviceData];
111
- }
105
+ function hostArgv(service, serviceData) {
106
+ if (service.kind === 'mongodb')
107
+ return mongodbArgv(service, serviceData);
112
108
  return ['node', hostScript(), '--port', String(service.hostPort), '--data', serviceData];
113
109
  }
114
110
  function alive(pid) {
111
+ if (!Number.isInteger(pid) || pid <= 0)
112
+ return false;
115
113
  try {
116
114
  process.kill(pid, 0);
117
115
  return true;
118
116
  }
119
- catch {
120
- return false;
117
+ catch (error) {
118
+ if (error.code === 'ESRCH')
119
+ return false;
120
+ throw error;
121
121
  }
122
122
  }
123
123
  function listening(port) {
@@ -137,16 +137,11 @@ export function unsupportedKinds(services) {
137
137
  }
138
138
  /** The line each host prints once ITS OWN listener is bound: readiness is taken from the child, never
139
139
  * from a connect to the port, which any other process holding the port would answer. */
140
- function readyLine(kind, port) {
141
- return kind === 'mongodb' ? `listening on mongodb://127.0.0.1:${port}` : `serving postgres wire protocol on 127.0.0.1:${port}`;
140
+ function readyLine(port) {
141
+ return `serving postgres wire protocol on 127.0.0.1:${port}`;
142
142
  }
143
143
  function logSince(log, offset) {
144
- try {
145
- return readFileSync(log).subarray(offset).toString('utf8');
146
- }
147
- catch {
148
- return '';
149
- }
144
+ return readFileSync(log).subarray(offset).toString('utf8');
150
145
  }
151
146
  export async function pgliteUp(services, dataDir, launch = {}) {
152
147
  const env = launch.env ?? process.env;
@@ -154,15 +149,22 @@ export async function pgliteUp(services, dataDir, launch = {}) {
154
149
  const plan = [];
155
150
  for (const service of services) {
156
151
  const pidFile = pidPath(dataDir, service.kind);
157
- if (existsSync(pidFile) && alive(Number(readFileSync(pidFile, 'utf8').trim())))
158
- continue;
152
+ const ownership = hostOwnership(pidFile);
153
+ if (ownership.present || hosts.has(pidFile)) {
154
+ if (!ownership.errors.length && ownership.readyReceipt && ownership.pid && alive(ownership.pid)) {
155
+ if (ownership.identity)
156
+ await verifySpawnIdentity(ownership.identity);
157
+ continue;
158
+ }
159
+ await stopHost(pidFile);
160
+ }
159
161
  const name = CONTAINERLESS[service.kind] ?? `pglite-${service.kind}`;
160
- const serviceData = join(dataDir, `${name}-data`);
162
+ const serviceData = join(dataDir, containerlessDataDirectory(service.kind) ?? `${name}-data`);
161
163
  const log = join(dataDir, `${name}.log`);
162
164
  if (await listening(service.hostPort)) {
163
165
  throw new Error(`${name} cannot serve port ${service.hostPort}: another process is already listening on it`);
164
166
  }
165
- plan.push({ service, name, serviceData, log, pidFile, argv: hostArgv(service, serviceData, env) });
167
+ plan.push({ service, name, serviceData, log, pidFile, argv: hostArgv(service, serviceData) });
166
168
  }
167
169
  const started = [];
168
170
  try {
@@ -179,15 +181,31 @@ export async function pgliteUp(services, dataDir, launch = {}) {
179
181
  detached: true,
180
182
  stdio: ['ignore', logFd, logFd],
181
183
  });
182
- child.unref();
184
+ // Retain the actual child BEFORE descriptor close or either publication can fail.
185
+ const lifetime = commandLifetime(child, launch.signal);
186
+ const host = { child, lifetime, closed: false };
187
+ child.once('close', () => { host.closed = true; });
188
+ hosts.set(pidFile, host);
189
+ started.push(pidFile);
190
+ let spawnError;
191
+ child.on('error', (error) => { spawnError = error; });
183
192
  closeSync(logFd);
184
- writeFileSync(pidFile, `${child.pid}\n`);
185
- started.push({ pid: child.pid, pidFile });
193
+ if (!child.pid)
194
+ throw new Error(service.kind === 'mongodb'
195
+ ? "World-managed MongoDB needs MongoDB's own mongod on PATH; see cookbook/mongodb-backing/README.md"
196
+ : `${name} could not start ${command}`);
197
+ host.identity = await lifetime.identity;
198
+ if (!host.identity?.start)
199
+ throw new Error('Managed host birth capture failed; retiring held child');
200
+ publishProcessReceipt(spawnReceiptPath(pidFile), JSON.stringify(host.identity) + '\n');
201
+ publishProcessReceipt(pidFile, `${child.pid}\n`);
186
202
  const deadline = Date.now() + READY_TIMEOUT_MS;
187
- const line = readyLine(service.kind, service.hostPort);
203
+ const line = readyLine(service.hostPort);
188
204
  // ready when OUR child says its listener is bound (a squatter that took the port after the check
189
205
  // above makes the child fail its bind and exit, never print this) and the port answers
190
- while (!(alive(child.pid) && logSince(log, offset).includes(line) && await listening(service.hostPort))) {
206
+ while (!(alive(child.pid) && (service.kind === 'mongodb' ? mongodbListening(logSince(log, offset), service.hostPort) : logSince(log, offset).includes(line)) && await listening(service.hostPort))) {
207
+ if (spawnError)
208
+ throw spawnError;
191
209
  if (!alive(child.pid))
192
210
  throw new Error(`${name} exited before serving — see ${log}`);
193
211
  if (Date.now() > deadline)
@@ -196,19 +214,24 @@ export async function pgliteUp(services, dataDir, launch = {}) {
196
214
  launch.signal?.throwIfAborted();
197
215
  await sleep(200);
198
216
  }
217
+ if (service.kind === 'mongodb')
218
+ await mongodbReady(service, launch, deadline);
219
+ child.unref();
199
220
  }
200
221
  }
201
222
  catch (error) {
202
223
  // tear down what this up started, so a failed up leaves nothing running
203
- for (const { pid, pidFile } of started) {
204
- if (alive(pid)) {
205
- try {
206
- process.kill(pid, 'SIGKILL');
207
- }
208
- catch { /* already gone */ }
224
+ const errors = [error];
225
+ for (const pidFile of started) {
226
+ try {
227
+ await stopHost(pidFile);
228
+ }
229
+ catch (cleanupError) {
230
+ errors.push(cleanupError);
209
231
  }
210
- rmSync(pidFile, { force: true });
211
232
  }
233
+ if (errors.length > 1)
234
+ throw new AggregateError(errors, 'Managed infrastructure startup and cleanup failed');
212
235
  throw error;
213
236
  }
214
237
  }
@@ -216,35 +239,104 @@ export async function pgliteStatus(services, dataDir) {
216
239
  let ready = 0;
217
240
  for (const service of services) {
218
241
  const pidFile = pidPath(dataDir, service.kind);
219
- if (!existsSync(pidFile))
242
+ const ownership = hostOwnership(pidFile);
243
+ if (ownership.errors.length)
244
+ throw new AggregateError(ownership.errors, 'Managed infrastructure ownership uncertain; retry down');
245
+ if (!ownership.readyReceipt || !ownership.pid || !alive(ownership.pid) || !(await listening(service.hostPort)))
220
246
  continue;
221
- const pid = Number(readFileSync(pidFile, 'utf8').trim());
222
- if (alive(pid) && await listening(service.hostPort))
223
- ready += 1;
247
+ if (ownership.identity)
248
+ await verifySpawnIdentity(ownership.identity);
249
+ if (service.kind === 'mongodb') {
250
+ await mongodbReady(service, {}, Date.now() + READY_TIMEOUT_MS, false);
251
+ }
252
+ ready += 1;
224
253
  }
225
254
  return { ok: ready === services.length && services.length > 0, ready };
226
255
  }
227
- export async function pgliteDown(services, dataDir) {
228
- for (const service of services) {
229
- const pidFile = pidPath(dataDir, service.kind);
230
- if (!existsSync(pidFile))
231
- continue;
232
- const pid = Number(readFileSync(pidFile, 'utf8').trim());
233
- if (alive(pid)) {
256
+ /** Retain evidence until both the child and its private group are confirmed stopped. */
257
+ async function stopHost(pidFile) {
258
+ const held = hosts.get(pidFile);
259
+ const ownership = hostOwnership(pidFile);
260
+ if (ownership.errors.length)
261
+ process.stderr.write(`${errorMessage(new AggregateError(ownership.errors, 'Managed host ownership uncertain; retrying established identity'))}\n`);
262
+ if (held) {
263
+ const pid = held.child.pid;
264
+ const retired = () => held.closed && (!pid || survivingOwnedGroups([pid]).length === 0);
265
+ // A failed lifetime promise is a past diagnosis. A later close and vanished
266
+ // owned group are current retirement evidence, even when finish stays rejected.
267
+ if (!retired()) {
268
+ held.child.ref();
269
+ held.lifetime.cancel('SIGTERM');
234
270
  try {
235
- process.kill(pid, 'SIGTERM');
271
+ await held.lifetime.finish();
236
272
  }
237
- catch { /* already gone */ }
238
- const deadline = Date.now() + 5000;
239
- while (alive(pid) && Date.now() < deadline)
240
- await sleep(100);
241
- if (alive(pid)) {
242
- try {
243
- process.kill(pid, 'SIGKILL');
244
- }
245
- catch { /* raced */ }
273
+ catch (error) {
274
+ if (!retired())
275
+ throw error;
246
276
  }
247
277
  }
278
+ }
279
+ else if (ownership.pid) {
280
+ const pid = ownership.pid;
281
+ const live = () => survivingOwnedGroups([pid]).length > 0;
282
+ if (live()) {
283
+ if (ownership.identity)
284
+ await verifySpawnIdentity(ownership.identity);
285
+ else
286
+ throw new AggregateError(ownership.errors, 'Managed host spawn identity missing or unreadable; ownership retained');
287
+ // Reuse commandLifetime's existing grace and confirmation budgets; no new limit.
288
+ for (const [signal, limit] of [['SIGTERM', COMMAND_GRACE_MS], ['SIGKILL', COMMAND_CONFIRM_MS]]) {
289
+ if (!live())
290
+ break;
291
+ await signalOwnedProcess(ownership.identity, signal, true);
292
+ const deadline = Date.now() + limit;
293
+ while (live() && Date.now() < deadline)
294
+ await sleep(20);
295
+ }
296
+ if (live())
297
+ throw new Error(`managed infrastructure group ${pid} did not stop; retained ${pidFile}`);
298
+ }
299
+ }
300
+ else if (ownership.present) {
301
+ throw new AggregateError(ownership.errors, 'Managed host ownership uncertain; no established identity; receipt retained');
302
+ }
303
+ if (ownership.recordedPid && survivingOwnedGroups([ownership.recordedPid]).length) {
304
+ throw new AggregateError(ownership.errors, 'Managed host receipt still names a live or uncertain group; ownership retained');
305
+ }
306
+ const failures = [];
307
+ try {
248
308
  rmSync(pidFile, { force: true });
249
309
  }
310
+ catch (error) {
311
+ failures.push(error);
312
+ }
313
+ // A later CLI must still have this identity if clearing the damaged PID receipt failed.
314
+ if (!failures.length) {
315
+ try {
316
+ rmSync(spawnReceiptPath(pidFile), { force: true });
317
+ }
318
+ catch (error) {
319
+ failures.push(error);
320
+ }
321
+ }
322
+ if (failures.length)
323
+ throw new AggregateError(failures, 'Managed host stopped but receipt cleanup failed');
324
+ hosts.delete(pidFile);
325
+ }
326
+ export async function pgliteDown(services, dataDir) {
327
+ const failures = [];
328
+ for (const service of services) {
329
+ try {
330
+ const pidFile = pidPath(dataDir, service.kind);
331
+ if (unsupportedKinds([service]).length && (hostOwnership(pidFile).present || hosts.has(pidFile))) {
332
+ throw new Error(`cannot stop recorded ${service.kind} infrastructure with this backing; use its owning runtime`);
333
+ }
334
+ await stopHost(pidFile);
335
+ }
336
+ catch (error) {
337
+ failures.push(error);
338
+ }
339
+ }
340
+ if (failures.length)
341
+ throw new AggregateError(failures, 'Managed infrastructure retirement failed');
250
342
  }
@@ -84,12 +84,37 @@
84
84
  // pg_try_advisory_lock on a held key returns true); a NOTIFY is delivered
85
85
  // to the connection that sent it, not to the one that ran LISTEN.
86
86
  // CREATE ROLE succeeds but connections never run as that role.
87
+ //
88
+ // Database timestamps use the World clock: PGlite reads Date.now() for now(),
89
+ // CURRENT_TIMESTAMP, clock_timestamp() and transaction starts. Refresh the file
90
+ // at each connection message; without a file the host uses machine time.
91
+ // Gateway timers retain machineNow. A frozen database clock also freezes
92
+ // pg_sleep; elapsed-time SQL and timeouts are not promised in that mode.
93
+ // Randomness remains the host's own; no database schema objects are installed.
94
+ import { errorMessage } from './error-message.mjs';
87
95
  import net from 'node:net';
88
- import { mkdirSync } from 'node:fs';
89
- import { PGlite } from '@electric-sql/pglite';
90
- import { vector } from '@electric-sql/pglite-pgvector';
96
+ import { mkdirSync, readFileSync } from 'node:fs';
97
+ import clockForm from '@volter/world-core/world-clock';
91
98
  import { fromNodeSocket } from 'pg-gateway/node';
92
99
 
100
+ // ---- the World's time (above) --------------------------------
101
+ const machineNow = Date.now.bind(Date);
102
+ const clockFile = process.env.TWIN_WORLD_CLOCK_FILE;
103
+ let worldClock = null;
104
+ /** Read the World clock again: at each message a connection sends. */
105
+ function readWorldClock() {
106
+ if (!clockFile) return;
107
+ try {
108
+ worldClock = clockForm.parseClock(readFileSync(clockFile, 'utf8'));
109
+ } catch (error) {
110
+ // no clock: the machine's time; a clock caught mid-write keeps the last one read until the next message
111
+ if (error?.code === 'ENOENT') worldClock = null;
112
+ else process.stderr.write(`pglite-host: clock read failed: ${errorMessage(error)}\n`);
113
+ }
114
+ }
115
+ readWorldClock();
116
+ Date.now = () => (worldClock ? clockForm.clockNowMs(worldClock, machineNow()) : machineNow());
117
+
93
118
  // The contrib extensions shipped in @electric-sql/pglite/dist/contrib (the
94
119
  // module name; `uuid_ossp` installs as "uuid-ossp"). auto_explain is omitted:
95
120
  // it is a preload library with no CREATE EXTENSION control file.
@@ -101,6 +126,8 @@ const CONTRIB = [
101
126
  'pg_trgm', 'pg_visibility', 'pg_walinspect', 'pgcrypto', 'seg', 'tablefunc',
102
127
  'tcn', 'tsm_system_rows', 'tsm_system_time', 'unaccent', 'uuid_ossp',
103
128
  ];
129
+ const { PGlite } = await import('@electric-sql/pglite');
130
+ const { vector } = await import('@electric-sql/pglite-pgvector');
104
131
  const extensions = { vector };
105
132
  for (const name of CONTRIB) {
106
133
  const extension = (await import(`@electric-sql/pglite/contrib/${name}`))[name];
@@ -134,7 +161,7 @@ process.stderr.on('error', () => {});
134
161
  const initKeepAlive = setInterval(() => {}, 500);
135
162
  const dbReady = PGlite.create({ dataDir, extensions });
136
163
  dbReady.catch((error) => {
137
- process.stderr.write(`pglite-host: backend failed to start: ${error?.message ?? error}\n`);
164
+ process.stderr.write(`pglite-host: backend failed to start: ${errorMessage(error)}\n`);
138
165
  process.exit(1);
139
166
  });
140
167
 
@@ -184,7 +211,7 @@ async function enter(token, session, data) {
184
211
  if (host && !host.busy && !host.wanting && !host.midBatch && host.status === 'T'
185
212
  && session.status !== 'T' && session.status !== 'E' && guestMessageAllowed(session, data)) {
186
213
  guestBatch = token;
187
- guestStartedAt = Date.now();
214
+ guestStartedAt = machineNow();
188
215
  return 'guest';
189
216
  }
190
217
  }
@@ -451,7 +478,7 @@ async function flushClosed(db) {
451
478
  try {
452
479
  await db.execProtocolRaw(new Uint8Array(close));
453
480
  await db.execProtocolRaw(new Uint8Array([0x53, 0, 0, 0, 4])); // Sync
454
- } catch { /* the backend's own failure surfaces on the next real message */ }
481
+ } catch (error) { process.stderr.write(`pglite-host: statement cleanup failed: ${errorMessage(error)}\n`); }
455
482
  if (heldStatements.get(nameKey(backend))?.closed) heldStatements.delete(nameKey(backend));
456
483
  }
457
484
  }
@@ -500,7 +527,9 @@ const READY_IDLE = Buffer.from([0x5a, 0, 0, 0, 5, 0x49]);
500
527
 
501
528
  /** A statement of the host's own around a guest; its failure (the holder's transaction already gone) is not fatal. */
502
529
  async function quiet(db, sql) {
503
- try { await db.query(sql); } catch { /* the savepoint went with the transaction */ }
530
+ try { await db.query(sql); } catch (error) {
531
+ if (error?.code !== '3B001' && error?.code !== '25P01') throw error;
532
+ }
504
533
  }
505
534
 
506
535
  /** `response` with its last ReadyForQuery reporting `status`. */
@@ -532,7 +561,7 @@ function idleInTransactionFatal() {
532
561
  // the holder idle in its transaction past the limit while others wait: end it
533
562
  const idleWatch = setInterval(() => {
534
563
  // a guest holding its turn past GUEST_TURN_MS while others wait: its batch is refused, its savepoint undone
535
- if (guestBatch !== null && waiters.length > 0 && Date.now() - guestStartedAt > GUEST_TURN_MS) {
564
+ if (guestBatch !== null && waiters.length > 0 && machineNow() - guestStartedAt > GUEST_TURN_MS) {
536
565
  const guest = sessions.get(guestBatch);
537
566
  const expired = guestBatch;
538
567
  if (guest && guest.guestRefused && !guest.guestRunning) {
@@ -541,7 +570,7 @@ const idleWatch = setInterval(() => {
541
570
  guest.guestExpired = true;
542
571
  guest.guestErrorSent = true;
543
572
  const host = sessions.get(holder);
544
- if (host) host.idleSince += Date.now() - guestStartedAt;
573
+ if (host) host.idleSince += machineNow() - guestStartedAt;
545
574
  guestBatch = null;
546
575
  wakeAll();
547
576
  } else if (guest && guest.guestOpen && !guest.guestRunning) {
@@ -554,7 +583,7 @@ const idleWatch = setInterval(() => {
554
583
  }).then(() => {
555
584
  if (guestBatch !== expired) return;
556
585
  const host = sessions.get(holder);
557
- if (host) host.idleSince += Date.now() - guestStartedAt;
586
+ if (host) host.idleSince += machineNow() - guestStartedAt;
558
587
  guestBatch = null;
559
588
  wakeAll();
560
589
  });
@@ -565,7 +594,7 @@ const idleWatch = setInterval(() => {
565
594
  // armed at ReadyForQuery inside a transaction, as Postgres arms it — never mid-pipeline
566
595
  // a holder waiting for its turn (behind a guest) is sending, not idle; a guest's turn is not the holder's idleness
567
596
  if (!session || session.busy || session.wanting || guestBatch !== null || session.terminated || (session.status !== 'T' && session.status !== 'E')) return;
568
- if (Date.now() - session.idleSince < IDLE_IN_TRANSACTION_MS) return;
597
+ if (machineNow() - session.idleSince < IDLE_IN_TRANSACTION_MS) return;
569
598
  session.terminated = true;
570
599
  process.stderr.write('pglite-host: terminated a connection idle in its transaction while others waited\n');
571
600
  // the socket's close handler rolls the transaction back and releases the lock
@@ -727,7 +756,7 @@ function withoutEarlyReady(message, response) {
727
756
  const server = net.createServer(async (socket) => {
728
757
  const token = {};
729
758
  let holding = false;
730
- const session = { socket, busy: false, idleSince: Date.now(), terminated: false, closed: false, status: null, midBatch: false,
759
+ const session = { socket, busy: false, idleSince: machineNow(), terminated: false, closed: false, status: null, midBatch: false,
731
760
  wanting: false, guestOpen: false, guestRefused: false, guestUnnamed: false, guestExpired: false, guestErrorSent: false, guestRunning: false, statements: new Map(),
732
761
  names: new Map() };
733
762
  sessions.set(token, session);
@@ -747,7 +776,7 @@ const server = net.createServer(async (socket) => {
747
776
  // next connection in — never leak a half-open transaction into a stranger
748
777
  void dbReady
749
778
  .then((db) => db.query('ROLLBACK'))
750
- .catch(() => {})
779
+ .catch(error => { process.stderr.write(`pglite-host: connection cleanup failed: ${errorMessage(error)}\n`); })
751
780
  .then(() => {
752
781
  holding = false;
753
782
  release(token);
@@ -765,6 +794,7 @@ const server = net.createServer(async (socket) => {
765
794
  // after the closed connections' names are released (a reconnect's `s0` is its own again)
766
795
  data = scopeNames(session, client, false);
767
796
  const db = await dbReady;
797
+ readWorldClock();
768
798
  // a guest whose turn ran out: told once, the rest of its batch dropped until its Sync
769
799
  if (session.guestExpired) {
770
800
  if (data[0] === 0x53) { session.guestExpired = false; session.guestErrorSent = false; session.status = 'I'; return READY_IDLE; }
@@ -785,7 +815,7 @@ const server = net.createServer(async (socket) => {
785
815
  session.guestUnnamed = false;
786
816
  // the holder's idle clock stops while a guest runs, and goes on from where it was
787
817
  const host = sessions.get(holder);
788
- if (host) host.idleSince += Date.now() - guestStartedAt;
818
+ if (host) host.idleSince += machineNow() - guestStartedAt;
789
819
  wakeAll();
790
820
  };
791
821
  // a batch refused mid-way: its remaining messages are dropped until its Sync, as Postgres skips after an error
@@ -811,7 +841,7 @@ const server = net.createServer(async (socket) => {
811
841
  try {
812
842
  raw = await db.execProtocolRaw(refuseStatements(data, own));
813
843
  } catch (error) {
814
- process.stderr.write(`pglite-host: backend aborted: ${error?.message ?? error}\n`);
844
+ process.stderr.write(`pglite-host: backend aborted: ${errorMessage(error)}\n`);
815
845
  process.exit(1);
816
846
  }
817
847
  const response = withoutEarlyReady(data, raw);
@@ -847,11 +877,11 @@ const server = net.createServer(async (socket) => {
847
877
  // SQL errors arrive as ErrorResponse bytes; a throw means the WASM
848
878
  // backend itself aborted and every later call would hang. Exit so the
849
879
  // World sees the service down (status fails) instead of a wedged port.
850
- process.stderr.write(`pglite-host: backend aborted: ${error?.message ?? error}\n`);
880
+ process.stderr.write(`pglite-host: backend aborted: ${errorMessage(error)}\n`);
851
881
  process.exit(1);
852
882
  }
853
883
  session.busy = false;
854
- session.idleSince = Date.now();
884
+ session.idleSince = machineNow();
855
885
  const response = withoutEarlyReady(data, raw);
856
886
  settleClose(session, closes, response);
857
887
  const status = lastReadyStatus(response);
@@ -2,7 +2,8 @@ import type { WorldInstance } from './schema.js';
2
2
  export type SeedWorldOptions = {
3
3
  root?: string;
4
4
  entry?: string;
5
- cwd?: string;
5
+ cwd?: string; /** Reset writes a new live environment output; never an input file. */
6
+ envFile?: string;
6
7
  };
7
8
  export type SeedWorldOutcome = {
8
9
  world: string;
@@ -14,7 +15,7 @@ export type SeedWorldOutcome = {
14
15
  export declare function seedEntryFor(instance: WorldInstance, entry?: string): string;
15
16
  /** Pull from the placeholder: every twin's window open around the seed entry (a twin with a root excepted). */
16
17
  export declare function seedWorld(name: string, options?: SeedWorldOptions): Promise<SeedWorldOutcome>;
17
- /** Back to the defaults: down with purge, up from the same config and env file, seed. */
18
+ /** Back to the defaults: down with purge, up from the same config and a fresh env output, seed. */
18
19
  export declare function resetWorld(name: string, options?: SeedWorldOptions): Promise<SeedWorldOutcome & {
19
20
  instance: WorldInstance;
20
21
  }>;
@@ -5,13 +5,14 @@
5
5
  // `diff` reads empty, nothing is pending. `reset` is a pull from the placeholder again — down
6
6
  // with purge, up, seed — so a world returns to its defaults at any time; local work you want to
7
7
  // keep is a fork, taken before.
8
+ import { randomUUID } from 'node:crypto';
9
+ import { claimEnvOutput } from "./env-output.js";
8
10
  import { materializeRoots } from "./root.js";
9
11
  import { loadWorldConfig } from "./configs.js";
10
12
  import { WorldDoors, servedName } from "./served-world.js";
11
- import { randomUUID } from 'node:crypto';
12
13
  import { existsSync, readdirSync } from 'node:fs';
13
14
  import { dirname, join, resolve } from 'node:path';
14
- import { serveHttp, beginPlaceholderPull, endPlaceholderPull, listEvents, PLACEHOLDER_REMOTE, worldStateRoot } from '@volter/world-core/runtime';
15
+ import { serveHttp, beginPlaceholderPull, endPlaceholderPull, listEvents, PLACEHOLDER_REMOTE, stateDirName, worldStateRoot } from '@volter/world-core/runtime';
15
16
  import { downWorld, runWithWorldEnv, saveWorldInstance, statusWorld, upWorld } from "./runtime.js";
16
17
  /** The seed entry `init` emits beside the world config, unless the caller names one. */
17
18
  export function seedEntryFor(instance, entry) {
@@ -89,18 +90,22 @@ export async function seedWorld(name, options = {}) {
89
90
  observed[service] = placeholderCount(join(instance.dirs.data, service));
90
91
  return { world: name, entry, exitCode, observed };
91
92
  }
92
- /** Back to the defaults: down with purge, up from the same config and env file, seed. */
93
+ /** Back to the defaults: down with purge, up from the same config and a fresh env output, seed. */
93
94
  export async function resetWorld(name, options = {}) {
94
95
  const root = resolve(options.root ?? process.cwd());
95
96
  const before = statusWorld(name, root);
96
97
  // A world whose twins ship no default data has no seed entry: back to the defaults is back to
97
98
  // empty, and that is a reset too — not an error.
98
99
  const entry = existsSync(resolve(options.entry ?? join(dirname(before.configPath), 'seed.ts'))) ? seedEntryFor(before, options.entry) : null;
100
+ // Purge removes the previous output's ownership receipt. Select and validate a fresh output before
101
+ // destroying state; never force an overwrite of a file that may hold credentials.
102
+ const envFile = resolve(options.envFile ?? join(dirname(before.envFile), `${name}.reset-${randomUUID()}.world.env`));
103
+ claimEnvOutput(envFile, join(root, stateDirName(), 'env-output-backups'));
99
104
  const stopped = await downWorld(name, root, { purge: true });
100
105
  if (!stopped.purged || stopped.externalErrors.length)
101
106
  throw new Error(`World reset refused: teardown did not purge the old instance (${stopped.externalErrors.join('; ')})`);
102
107
  // a world booted from a config PATH re-ups from that path; one booted by id, by id
103
- const booted = await upWorld(existsSync(before.configPath) ? before.configPath : before.config, { name, root, mode: before.mode, envFile: before.envFile });
108
+ const booted = await upWorld(existsSync(before.configPath) ? before.configPath : before.config, { name, root, mode: before.mode, owner: before.lifecycle?.owner ?? before.resources?.owner, envFile });
104
109
  // the origin belongs to the branch, not to its running state (git: a reset keeps the remote);
105
110
  // the cursors do not — the mirror is gone, so the next fetch starts from the beginning
106
111
  const instance = before.origin === undefined ? booted : { ...booted, origin: { url: before.origin.url, namespace: before.origin.namespace, ...(before.origin.clonedAt ? { clonedAt: before.origin.clonedAt } : {}) } };
@@ -0,0 +1,2 @@
1
+ export function survivingOwnedGroups(pids: number[]): number[];
2
+ export function processStarts(pids: number[]): Map<number, string>;