@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
@@ -29,7 +29,11 @@
29
29
  // "extension … is not available" error. Loading the set costs boot time:
30
30
  // PGlite.create() in-memory, node 22 on darwin arm64 (this repo's dev box,
31
31
  // 2026-09-27, nothing else running in the process) took 1.66s bare and
32
- // 2.47s with the set; RSS was within 50 MB either way.
32
+ // 2.47s with the set; RSS was within 50 MB either way. One open loads less:
33
+ // a migrated prepared cluster this boot placed (`--declared-extensions`)
34
+ // loads its installed extensions and startup preloads, including the retained
35
+ // configuration's preloads (DECLARED_EXTENSIONS below); uncertain evidence
36
+ // and every other open load the whole set.
33
37
  //
34
38
  // Limits, stated plainly (measured on 0.5.8 through this gateway with `pg`
35
39
  // clients; pglite-backing.test.ts holds the extension and database checks):
@@ -63,7 +67,12 @@
63
67
  // Randomness remains the host's own; no database schema objects are installed.
64
68
  import { errorMessage } from './error-message.mjs';
65
69
  import net from 'node:net';
66
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
70
+ import { closeSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
71
+ import { createHash, randomUUID } from 'node:crypto';
72
+ import { fileURLToPath } from 'node:url';
73
+ import { gunzipSync } from 'node:zlib';
74
+ import * as nodeModule from 'node:module';
75
+ import { dirname, join } from 'node:path';
67
76
  import clockForm from '@volter/world-core/world-clock';
68
77
  import { fromNodeSocket } from 'pg-gateway/node';
69
78
  import { BackendError } from 'pg-gateway';
@@ -97,6 +106,109 @@ const CONTRIB = [
97
106
  'pg_trgm', 'pg_visibility', 'pg_walinspect', 'pgcrypto', 'seg', 'tablefunc',
98
107
  'tcn', 'tsm_system_rows', 'tsm_system_time', 'unaccent', 'uuid_ossp',
99
108
  ];
109
+ // THE OPEN'S SPLIT (S2, Rallly's World): a tab's World waited 0.50 s from this host's spawn to its serving, against
110
+ // ~0.12 s for the same reopen of a prepared cluster natively, and nothing said where. Said once, on stdout beside the
111
+ // serving line: ms from this process's start to the imports done, to create() called and done, the wasm compile and
112
+ // instantiate inside it (WebAssembly's own calls, timed where create makes them), and to serving.
113
+ //
114
+ // THE EXTENSION PHASE inside create() (S2 R66): a prepared open loads every extension bundle it is given (34 here) before
115
+ // the backend starts. For each, PGlite 0.5.8 reads the .tar.gz through zlib's createGunzip, untars it into the
116
+ // Emscripten file system, and preloads each .so, a side module: its preload plugin calls loadWebAssemblyModule with
117
+ // loadAsync, which for bytes is WebAssembly.instantiate(bytes) (dist/chunk-*.js; the synchronous `new
118
+ // WebAssembly.Module` and `new WebAssembly.Instance` are its path for a dlopen of a library not preloaded, and for the
119
+ // few-byte modules it makes to wrap a JS function in a table). Said beside the rest: the bundles' gunzips (count, ms
120
+ // summed and the wall they spanned), the side modules (count, bytes, compile+instantiate ms summed and their wall), the
121
+ // main module's own wasm, the wrapper stubs, and the time between, inside the extension window, that none of them
122
+ // covers (the untar and the file writes, and anything else there). All four WebAssembly entries are timed, the
123
+ // constructors too, so a side module made either way is a side module, never "between". Not seen: work that is
124
+ // none of these (an extension's own JS), which lands in "between". The whole line also rides the listener receipt, so
125
+ // the World's serving line (pglite-backing.ts) carries it where a run's console sees it.
126
+ const openSplit = { importsDoneMs: 0, createStartMs: 0, createDoneMs: 0, wasm: { compileMs: 0, instantiateMs: 0, calls: 0, bytes: 0 }, sessionDoneMs: 0 };
127
+ const split = (value) => Math.round(value * 10) / 10;
128
+ /** [start, end, kind, bytes] of every timed piece; kind 'gunzip', 'stub' (under 1 KiB), 'side' (under 1 MiB) or 'main'. */
129
+ const pieces = [];
130
+ // Keep only the opening observation: process-wide wrappers may still see later serving work.
131
+ let collectingOpenPieces = true;
132
+ const STUB_MODULE_BYTES = 1024, SIDE_MODULE_BYTES = 1024 * 1024;
133
+ {
134
+ const wasm = openSplit.wasm, compile = WebAssembly.compile, instantiate = WebAssembly.instantiate;
135
+ // A compiled module's bytes, so its instantiation is counted as the module it is (a side module compiled, then
136
+ // instantiated from its Module, is a side module twice, not main wasm).
137
+ const moduleBytes = new WeakMap();
138
+ const sizeOf = (source) => (source instanceof WebAssembly.Module ? moduleBytes.get(source) ?? 0 : source?.byteLength ?? 0);
139
+ const kindOf = (bytes) => (bytes === 0 || bytes >= SIDE_MODULE_BYTES ? 'main' : bytes < STUB_MODULE_BYTES ? 'stub' : 'side');
140
+ const counted = (began, bytes, field) => {
141
+ if (!collectingOpenPieces) return;
142
+ const ended = performance.now(); wasm[field] += ended - began; pieces.push([began, ended, kindOf(bytes), bytes]);
143
+ };
144
+ WebAssembly.compile = async function (...args) {
145
+ const began = performance.now(), bytes = sizeOf(args[0]); wasm.calls += 1; wasm.bytes += bytes;
146
+ try { const module = await compile.apply(this, args); moduleBytes.set(module, bytes); return module; } finally { counted(began, bytes, 'compileMs'); }
147
+ };
148
+ WebAssembly.instantiate = async function (...args) {
149
+ const began = performance.now(), bytes = sizeOf(args[0]); wasm.calls += 1; if (!(args[0] instanceof WebAssembly.Module)) wasm.bytes += bytes;
150
+ try { return await instantiate.apply(this, args); } finally { counted(began, bytes, 'instantiateMs'); }
151
+ };
152
+ // The synchronous constructors, as proxies whose construct trap times them: `instanceof` still answers through the
153
+ // target's prototype, and what they make is the target's own.
154
+ WebAssembly.Module = new Proxy(WebAssembly.Module, {
155
+ construct(target, args) {
156
+ const began = performance.now(), bytes = sizeOf(args[0]); wasm.calls += 1; wasm.bytes += bytes;
157
+ try { const module = Reflect.construct(target, args); moduleBytes.set(module, bytes); return module; } finally { counted(began, bytes, 'compileMs'); }
158
+ },
159
+ });
160
+ WebAssembly.Instance = new Proxy(WebAssembly.Instance, {
161
+ construct(target, args) {
162
+ const began = performance.now(), bytes = sizeOf(args[0]); wasm.calls += 1;
163
+ try { return Reflect.construct(target, args); } finally { counted(began, bytes, 'instantiateMs'); }
164
+ },
165
+ });
166
+ // Node's CommonJS createGunzip is configurable but not writable (lib/zlib.js createProperty): assignment throws.
167
+ // Redefine only that configurable data property, preserving its flags, then sync its ESM binding for PGlite.
168
+ // Timing is optional: a runtime that cannot redefine or sync the builtin leaves gunzip timing unobserved.
169
+ const { createRequire, syncBuiltinESMExports } = nodeModule;
170
+ let zlib, property, wrapped;
171
+ try {
172
+ zlib = typeof createRequire === 'function' ? createRequire(import.meta.url)('node:zlib') : undefined;
173
+ property = zlib ? Object.getOwnPropertyDescriptor(zlib, 'createGunzip') : undefined;
174
+ if (property?.configurable && typeof property.value === 'function' && typeof syncBuiltinESMExports === 'function') {
175
+ const createGunzip = property.value;
176
+ wrapped = function (...args) {
177
+ const began = performance.now(), stream = createGunzip.apply(this, args);
178
+ stream.once('end', () => { if (collectingOpenPieces) pieces.push([began, performance.now(), 'gunzip', 0]); });
179
+ return stream;
180
+ };
181
+ Object.defineProperty(zlib, 'createGunzip', { ...property, value: wrapped });
182
+ syncBuiltinESMExports();
183
+ }
184
+ } catch {
185
+ // A missing observation cannot prevent the database opening. Restore an installed wrapper if sync refused it.
186
+ if (zlib && property && Object.getOwnPropertyDescriptor(zlib, 'createGunzip')?.value === wrapped) {
187
+ try { Object.defineProperty(zlib, 'createGunzip', property); syncBuiltinESMExports?.(); } catch { /* timing only */ }
188
+ }
189
+ }
190
+ }
191
+ /** The extension phase, from the timed pieces: see THE EXTENSION PHASE above. */
192
+ function extensionPhase() {
193
+ const of = (kind) => pieces.filter(piece => piece[2] === kind);
194
+ const sum = (list) => list.reduce((total, [start, end]) => total + (end - start), 0);
195
+ const wall = (list) => (list.length ? Math.max(...list.map(piece => piece[1])) - Math.min(...list.map(piece => piece[0])) : 0);
196
+ const gunzips = of('gunzip'), sides = of('side'), main = of('main'), stubs = of('stub'), phase = [...gunzips, ...sides, ...stubs];
197
+ let between = 0;
198
+ if (phase.length) {
199
+ // The window's time no gunzip or side module covers: the union of their intervals, taken from the window.
200
+ const sorted = [...phase].sort((left, right) => left[0] - right[0]);
201
+ let covered = 0, [from, to] = sorted[0];
202
+ for (const [start, end] of sorted.slice(1)) {
203
+ if (start > to) { covered += to - from; [from, to] = [start, end]; } else to = Math.max(to, end);
204
+ }
205
+ covered += to - from;
206
+ between = wall(phase) - covered;
207
+ }
208
+ return `extensions ${split(wall(phase))} ms wall: ${gunzips.length} gunzips ${split(sum(gunzips))} ms (wall ${split(wall(gunzips))}), `
209
+ + `${sides.length} side modules ${sides.reduce((total, piece) => total + piece[3], 0)} bytes ${split(sum(sides))} ms (wall ${split(wall(sides))}), `
210
+ + `${stubs.length} stubs ${split(sum(stubs))} ms, between ${split(between)} ms; main wasm ${main.length} calls ${split(sum(main))} ms`;
211
+ }
100
212
  const { PGlite } = await import('@electric-sql/pglite');
101
213
  const { vector } = await import('@electric-sql/pglite-pgvector');
102
214
  const extensions = { vector };
@@ -107,6 +219,7 @@ for (const [name, module] of await Promise.all(CONTRIB.map(async name => [name,
107
219
  if (!extension) throw new Error(`pglite-host: @electric-sql/pglite/contrib/${name} exports no ${name}`);
108
220
  extensions[name] = extension;
109
221
  }
222
+ openSplit.importsDoneMs = performance.now();
110
223
 
111
224
  function arg(name) {
112
225
  const index = process.argv.indexOf(name);
@@ -128,9 +241,18 @@ const preparing = process.argv.includes('--prepare');
128
241
  const optional = (name) => { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined; };
129
242
  const listeningFile = optional('--listening-file');
130
243
  const bootToken = optional('--boot');
131
- // --prepared: the data directory is a cluster `--prepare` made for this declared identity (pglite-backing.ts checks
132
- // its stamp before passing this), so the declared database and role exist and the maintenance open is skipped.
244
+ // --prepared: pglite-backing.ts proved this declared identity from a placed preparation stamp or a kept cluster's
245
+ // serving receipt, so the declared database and role exist and the maintenance open is skipped.
133
246
  const prepared = process.argv.includes('--prepared');
247
+ // --record-extensions: this host serves the World's declared database step (pglite-backing.ts migratePreparedCluster),
248
+ // so the extensions the cluster has when it closes are the declared migrations' own: they are written beside the
249
+ // cluster (DECLARED_EXTENSIONS) for a prepared open to load those alone (S2 R66b).
250
+ const recordingExtensions = process.argv.includes('--record-extensions');
251
+ // --declared-extensions: pglite-backing.ts placed this cluster from the World's preparation this boot and the live
252
+ // database carries the image's migration (liveDatabaseMigration), so its record is the declared migrations' set and
253
+ // nothing has created an extension in it since. Every other open loads the whole set and removes the record, which a
254
+ // later extension (kept data migrated by a newer image, or created through a whole-set open) would make stale.
255
+ const declaredOnly = process.argv.includes('--declared-extensions');
134
256
  const port = preparing ? 0 : Number(arg('--port'));
135
257
  const dataDir = arg('--data');
136
258
  const databaseUser = process.env.VOLTER_WORLD_PGLITE_USER;
@@ -153,15 +275,361 @@ process.stderr.on('error', () => {});
153
275
  // listening starts AFTER readiness so "port answers" means "serves queries"
154
276
  const initKeepAlive = setInterval(() => {}, 500);
155
277
  const quoteIdentifier = value => `"${value.replaceAll('"', '""')}"`;
278
+ /** PGlite's default printErr suppresses backend startup errors when debug is off. Its public extension setup seam
279
+ * keeps backend stderr in the child log without enabling debug. PostgreSQL may include a failed statement's SQL. */
280
+ function withBackendStderr(bundles) {
281
+ return { ...bundles, world_backend_stderr: {
282
+ name: 'world_backend_stderr',
283
+ setup: async (_db, emscriptenOpts) => ({ emscriptenOpts: { ...emscriptenOpts, printErr: line => {
284
+ try { process.stderr.write(`pglite-host backend: ${String(line)}\n`); } catch { /* a closed log cannot fail startup */ }
285
+ } } }),
286
+ } };
287
+ }
288
+ /**
289
+ * EXTENSIONS AS POSTGRES LOADS THEM: THEIR FILES ONCE, THEIR LIBRARIES WHEN USED (S2 T12). PGlite 0.5.8 loads each
290
+ * extension it is given at every open: it gunzips the bundle (.tar.gz), untars it into the Emscripten file system and
291
+ * preloads every .so, a side module compiled and instantiated into the instance (THE EXTENSION PHASE above: Rallly's
292
+ * prepared open, 34 bundles, ~166-183 ms of extension wall, of which gunzips ~76-90 ms, side modules ~57-62 ms and the
293
+ * untar and file writes ~76 ms). Native Postgres has the files on disk once, loads its shared_preload_libraries once at
294
+ * the postmaster's start, and loads any other extension's library at its first use (dfmgr's load_external_function:
295
+ * dlopen). This host does the same:
296
+ * - a bundle is gunzipped and untarred once per cache, its files kept unpacked beside the cluster's data directory
297
+ * (`.volter-pglite-extensions/<name>-<sha256 of the bundle, 16 hex>/`, with `.files.json` naming the digest and the
298
+ * files); every later open whose bundle has that digest reads the kept files and writes them into the instance's
299
+ * file system before it starts (a preRun), with no gunzip and no untar. The cache is the data directory's parent's:
300
+ * the image build's --prepare host keeps its own, so a World's first `up` unpacks once more;
301
+ * - no .so is preloaded: Postgres's own dlopen loads a library when it is first used, and the shared preload
302
+ * libraries (pg_stat_statements, which PGlite's preload list and the retained configuration name) at the backend's
303
+ * start, through Emscripten's dlopen, which reads the file and compiles and instantiates it synchronously (the path
304
+ * PGlite itself leaves for a library not preloaded).
305
+ * A bundle that cannot be read or unpacked is said and given no files, as PGlite logs a failed bundle and goes on; a
306
+ * cache that cannot be kept (no space, no permission, a read-only directory) is counted as keepFailed with its error,
307
+ * and that open's files are the ones it unpacked.
308
+ * THE CEILING, from source: an instantiated side module cannot be kept across opens. Each open is its own Emscripten
309
+ * instance with its own memory and table, and a side module is linked into those: libdylink's loadWebAssemblyModule
310
+ * places its data at a memoryBase it allocates in that instance's heap and its functions in that instance's table, then
311
+ * instantiates with that instance's memory and table as imports. So the library's instantiation is per open, as a
312
+ * process's dlopen is per process natively; only the files are kept.
313
+ * The gain is unmeasured: the reading is the next Rallly lap's serving line, its extension phase (no gunzips, no side
314
+ * module preloads) and its `extension files` account (kept, unpacked, keepFailed, ms).
315
+ */
316
+ const EXTENSION_FILES = join(dirname(dataDir), '.volter-pglite-extensions');
317
+ /** A tar member's name is a relative path inside the bundle: no absolute path, no `..` or empty segment. */
318
+ function safeMemberName(name) {
319
+ return typeof name === 'string' && name.length > 0 && !name.startsWith('/') && !name.includes('\0')
320
+ && name.split('/').every((segment, at, all) => segment !== '..' && segment !== '.' && (segment !== '' || at === all.length - 1));
321
+ }
322
+ /** A bundle's tar members: ustar (its magic and checksum checked), a pax header's path or a GNU long name where one is given. */
323
+ function untarMembers(tar) {
324
+ const members = [];
325
+ let longName;
326
+ const text = (at, length) => { const end = tar.indexOf(0, at); return tar.toString('utf8', at, end >= 0 && end < at + length ? end : at + length); };
327
+ const octal = (at, length, what) => {
328
+ const field = text(at, length).trim();
329
+ if (!/^[0-7]*$/.test(field)) throw new Error(`tar: ${what} is not octal`);
330
+ return field ? parseInt(field, 8) : 0;
331
+ };
332
+ for (let at = 0; at + 512 <= tar.length;) {
333
+ if (tar.subarray(at, at + 512).every(byte => byte === 0)) break;
334
+ if (!text(at + 257, 6).startsWith('ustar')) throw new Error('tar: a header without the ustar magic');
335
+ let sum = 0;
336
+ for (let index = 0; index < 512; index += 1) sum += index >= 148 && index < 156 ? 0x20 : tar[at + index];
337
+ if (sum !== octal(at + 148, 8, 'a header checksum')) throw new Error('tar: a header checksum does not match');
338
+ const size = octal(at + 124, 12, 'a member size');
339
+ const type = String.fromCharCode(tar[at + 156] || 0x30);
340
+ const prefix = text(at + 345, 155);
341
+ const name = longName ?? (prefix ? `${prefix}/${text(at, 100)}` : text(at, 100));
342
+ if (at + 512 + size > tar.length) throw new Error('tar: a member runs past the archive');
343
+ const data = tar.subarray(at + 512, at + 512 + size);
344
+ at += 512 + Math.ceil(size / 512) * 512;
345
+ if (type === 'x') {
346
+ const record = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(data.toString('utf8'));
347
+ longName = record ? record[1] : undefined;
348
+ continue;
349
+ }
350
+ if (type === 'L') { longName = data.toString('utf8').replace(/\0+$/, ''); continue; }
351
+ longName = undefined;
352
+ if (type === 'g') continue;
353
+ if (type !== '0' && type !== '\0') continue;
354
+ if (!safeMemberName(name)) throw new Error(`tar: a member named outside the bundle (${JSON.stringify(name).slice(0, 80)})`);
355
+ members.push({ name, data });
356
+ }
357
+ return members;
358
+ }
359
+ /** An open's account of its bundles: kept and unpacked bundles, their files, failures to keep, kept lists unreadable. */
360
+ const accountFor = (create) => ({ create, bundles: 0, kept: 0, unpacked: 0, tarRefused: 0, keepFailed: 0, keepErrors: new Set(), keptUnread: 0, files: 0, ms: 0 });
361
+ // Every create's account, in order. A host that is not prepared opens twice (template1, then the declared database),
362
+ // and the extension phase's timed pieces span both creates, so the account is said for both (review of T12b, D): the
363
+ // timed gunzips are every create's unpacked plus its tarRefused (a bundle gunzipped whose tar the reader refused).
364
+ const creates = [];
365
+ const KEPT_LIST = '.files.json';
366
+ // The kept list's form: changed with the tar reader, its member filter, this list's fields or how a keep is made
367
+ // durable, so a cache another form wrote is unpacked again rather than read. 2: each file with its size, checked on
368
+ // read. 3: each file synced before the keep's rename, so a v2 keep (unsynced, possibly a zero-filled file at its full
369
+ // size after a crash) is never served (review of T12c, B1).
370
+ const KEPT_VERSION = 3;
371
+ /** A kept list that cannot be read is said and counted once per bundle, however many times the bundle looks at it. */
372
+ function keptUnreadable(bundle, what, error) {
373
+ if (bundle.unread) return;
374
+ bundle.unread = true;
375
+ bundle.account.keptUnread += 1;
376
+ process.stderr.write(`pglite-host: extension ${bundle.name}: ${what} cannot be read (${errorMessage(error)}); unpacked again\n`);
377
+ }
378
+ /** The files `kept` lists, where its list has this form and this digest; else undefined. */
379
+ function keptList(bundle, kept, digest) {
380
+ let listed;
381
+ try { listed = JSON.parse(readFileSync(join(kept, KEPT_LIST), 'utf8')); }
382
+ catch (error) {
383
+ if (error?.code !== 'ENOENT') keptUnreadable(bundle, 'its kept files', error);
384
+ return undefined;
385
+ }
386
+ if (listed?.version !== KEPT_VERSION || listed.digest !== digest || !Array.isArray(listed.files)
387
+ || !listed.files.every(file => safeMemberName(file?.name) && Number.isSafeInteger(file?.size) && file.size >= 0)) return undefined;
388
+ return listed.files;
389
+ }
390
+ /** The kept files of `kept`, read, where its list stands and every file is present at its size; else undefined. */
391
+ function keptFiles(bundle, kept, digest) {
392
+ const listed = keptList(bundle, kept, digest);
393
+ if (!listed) return undefined;
394
+ try {
395
+ const files = listed.map(file => [file.name, readFileSync(join(kept, file.name))]);
396
+ // A file cut short after a power loss (a zero-length .so) is not served: every size must be the one kept.
397
+ if (files.some(([_file, data], at) => data.length !== listed[at].size)) return undefined;
398
+ return files;
399
+ } catch (error) {
400
+ if (error?.code !== 'ENOENT') keptUnreadable(bundle, 'a kept file', error);
401
+ return undefined;
402
+ }
403
+ }
404
+ /** Whether `kept` stands whole, by its list and each file's size as stat says it: no file read back (review of T12b, B). */
405
+ function keptStands(bundle, kept, digest) {
406
+ const listed = keptList(bundle, kept, digest);
407
+ if (!listed) return false;
408
+ try { return listed.every(file => { const stat = statSync(join(kept, file.name)); return stat.isFile() && stat.size === file.size; }); }
409
+ catch (error) {
410
+ if (error?.code !== 'ENOENT') keptUnreadable(bundle, 'a kept file', error);
411
+ return false;
412
+ }
413
+ }
414
+ /**
415
+ * `data` written to `path` and synced before the keep's rename, so a crash cannot leave a kept file at its listed size
416
+ * with blocks never written (a zero-filled .so the size check would pass; review of T12b, C). The directories are not
417
+ * synced: a rename a crash loses leaves the cache cold, never wrong, and the next open unpacks again. Where fsync fails
418
+ * though writes work (some FUSE, 9p or network file systems), nothing is kept: every open there unpacks, as before T12.
419
+ * A failure to close is thrown only where the write and the sync succeeded, so it never hides theirs.
420
+ */
421
+ function writeSynced(path, data) {
422
+ const fd = openSync(path, 'w');
423
+ let failure;
424
+ try { writeFileSync(fd, data); fsyncSync(fd); } catch (error) { failure = error; }
425
+ try { closeSync(fd); } catch (error) { failure ??= error; }
426
+ if (failure) throw failure;
427
+ }
428
+ /** A bundle's files (relative path, bytes) as PGlite would write them, unpacked once and kept beside the cluster. */
429
+ function bundleFiles(name, bundlePath, account) {
430
+ let packed;
431
+ try { packed = readFileSync(bundlePath instanceof URL ? fileURLToPath(bundlePath) : String(bundlePath)); }
432
+ catch (error) {
433
+ process.stderr.write(`pglite-host: extension ${name}: its bundle cannot be read (${errorMessage(error)}); it is not loaded\n`);
434
+ return [];
435
+ }
436
+ const bundle = { name, account, unread: false };
437
+ const digest = createHash('sha256').update(packed).digest('hex');
438
+ const keptName = `${name}-${digest.slice(0, 16)}`;
439
+ const kept = join(EXTENSION_FILES, keptName);
440
+ const standing = keptFiles(bundle, kept, digest);
441
+ if (standing) { account.kept += 1; return standing; }
442
+ let files;
443
+ let gunzipped = false;
444
+ try {
445
+ // As PGlite's own untar (dist/index.js Et): members whose names start with "." are not written. A bundle the
446
+ // reader refuses (a bad checksum or magic, a member outside the bundle) is logged and given no files, where
447
+ // PGlite's own untar would throw out of the open.
448
+ const began = performance.now();
449
+ const tar = gunzipSync(packed);
450
+ if (collectingOpenPieces) pieces.push([began, performance.now(), 'gunzip', 0]);
451
+ gunzipped = true;
452
+ files = untarMembers(tar).filter(member => !member.name.startsWith('.') && !member.name.endsWith('/'))
453
+ .map(member => [member.name, member.data]);
454
+ } catch (error) {
455
+ if (gunzipped) account.tarRefused += 1;
456
+ process.stderr.write(`pglite-host: extension ${name}: its bundle cannot be unpacked (${errorMessage(error)}); it is not loaded\n`);
457
+ return [];
458
+ }
459
+ account.unpacked += 1;
460
+ // Kept by a rename of a whole directory, so a reader never sees half of one; a host that lost the race keeps nothing.
461
+ const writing = `${kept}.${process.pid}.${randomUUID()}`;
462
+ let keepError;
463
+ try {
464
+ mkdirSync(EXTENSION_FILES, { recursive: true });
465
+ for (const [file, data] of files) { mkdirSync(dirname(join(writing, file)), { recursive: true }); writeSynced(join(writing, file), data); }
466
+ writeSynced(join(writing, KEPT_LIST), JSON.stringify({ version: KEPT_VERSION, digest, files: files.map(([file, data]) => ({ name: file, size: data.length })) }));
467
+ // A directory left by an earlier keep that lacks a file is replaced; one another host kept meanwhile stands.
468
+ try { renameSync(writing, kept); }
469
+ catch (error) {
470
+ if (error?.code !== 'ENOTEMPTY' && error?.code !== 'EEXIST') throw error;
471
+ if (!keptStands(bundle, kept, digest)) { rmSync(kept, { recursive: true, force: true }); renameSync(writing, kept); }
472
+ }
473
+ } catch (error) { keepError = error; }
474
+ // Whatever is left of the staging, and an earlier digest's directory or an ended host's staging of this name, goes.
475
+ // Cleaning is best effort and neither said nor counted: a staging that cannot be removed, or whose pid answers as live
476
+ // (its host, or another process now holding a reused pid), stays until a later keep of this name finds it removable.
477
+ try { rmSync(writing, { recursive: true, force: true }); } catch { /* left for a later keep */ }
478
+ try {
479
+ for (const entry of readdirSync(EXTENSION_FILES)) {
480
+ if (!entry.startsWith(`${name}-`) || entry === keptName) continue;
481
+ const staging = /\.(\d+)\.[0-9a-f-]{36}$/.exec(entry);
482
+ if (staging) { try { process.kill(Number(staging[1]), 0); continue; } catch (error) { if (error?.code === 'EPERM') continue; } }
483
+ rmSync(join(EXTENSION_FILES, entry), { recursive: true, force: true });
484
+ }
485
+ } catch { /* left for a later keep */ }
486
+ // Kept or not is what stands at the end: another host's directory, or ours, holding every file at its size.
487
+ if (!keptStands(bundle, kept, digest)) {
488
+ account.keepFailed += 1;
489
+ account.keepErrors.add(keepError?.code ?? (keepError ? errorMessage(keepError).slice(0, 60) : 'not kept'));
490
+ }
491
+ return files;
492
+ }
493
+ /** One create's extension-files account. */
494
+ function accountSaid(account) {
495
+ return `${account.bundles} bundles (${account.kept} kept, ${account.unpacked} unpacked${account.tarRefused ? `, ${account.tarRefused} tars refused` : ''}, ${account.keepFailed} not kept`
496
+ + `${account.keepErrors.size ? `: ${[...account.keepErrors].join(', ')}` : ''}${account.keptUnread ? `, ${account.keptUnread} kept unreadable` : ''}), `
497
+ + `${account.files} files in ${split(account.ms)} ms`;
498
+ }
499
+ /** The open's extension-files account, every create's, as the serving line (and a --prepare host's own line) says it. */
500
+ function extensionFilesSaid() {
501
+ const said = creates.length === 1 ? accountSaid(creates[0]) : creates.map(account => `${account.create}: ${accountSaid(account)}`).join('; ');
502
+ return `extension files: ${said || 'no create'}, no library preloaded (S2 T12)`;
503
+ }
504
+ /** Each bundle extension as one whose files are written into the instance before it starts, its libraries not preloaded. */
505
+ function unpackedOnce(bundles, create) {
506
+ const account = accountFor(create);
507
+ creates.push(account);
508
+ const wrapped = {};
509
+ for (const [name, extension] of Object.entries(bundles)) {
510
+ wrapped[name] = { name: extension.name ?? name, setup: async (db, emscriptenOpts, clientOnly) => {
511
+ const given = await extension.setup(db, emscriptenOpts, clientOnly);
512
+ if (!given?.bundlePath) return given;
513
+ const { bundlePath, ...rest } = given;
514
+ const began = performance.now();
515
+ const files = bundleFiles(name, bundlePath, account);
516
+ account.bundles += 1; account.files += files.length; account.ms += performance.now() - began;
517
+ const base = rest.emscriptenOpts ?? emscriptenOpts;
518
+ return { ...rest, emscriptenOpts: { ...base, preRun: [...(base.preRun ?? []), (module) => {
519
+ for (const [file, data] of files) {
520
+ const target = `${module.WASM_PREFIX}/${file}`;
521
+ const parent = target.slice(0, target.lastIndexOf('/'));
522
+ if (!module.FS.analyzePath(parent).exists) module.FS.mkdirTree(parent);
523
+ module.FS.writeFile(target, data);
524
+ }
525
+ }] } };
526
+ } };
527
+ }
528
+ return wrapped;
529
+ }
530
+
531
+ /**
532
+ * THE DECLARED MIGRATIONS' EXTENSIONS (S2 R66b). A prepared open loaded every extension bundle the host offers (34) at
533
+ * create(), each a gunzip, an untar into the Emscripten file system and a side module's instantiate, whatever the
534
+ * cluster uses: Rallly's World waited 0.50 s from spawn to serving against ~0.12 s natively. Where the cluster was
535
+ * prepared with the World's declared database step, its installed extensions and preload libraries are recorded
536
+ * beside it (`.volter-declared-extensions.json`, under the data directory, which Postgres leaves alone). Only an open
537
+ * given `--declared-extensions` (the boot that placed the cluster, see `declaredOnly` above) loads those and the retained
538
+ * configuration's preload libraries: pg_extension alone omits a required preload without CREATE EXTENSION. An
539
+ * extension the migrations created is loaded; one they did not is absent, as it is from the freshly placed cluster, and
540
+ * a `CREATE EXTENSION` of it refuses "is not available". Such an open of a cluster with no record (prepared with no
541
+ * declared step, or before the record existed), or of a record naming an extension this host does not offer, loads every
542
+ * bundle, as every other open does.
543
+ */
544
+ const DECLARED_EXTENSIONS = join(dataDir, '.volter-declared-extensions.json');
545
+ /** The bundles a prepared open loads: the record's, where every name in it is one this host offers; else all. */
546
+ function declaredExtensions() {
547
+ let named;
548
+ try { named = JSON.parse(readFileSync(DECLARED_EXTENSIONS, 'utf8')); } catch { return extensions; }
549
+ // A v1 record says nothing about startup preloads: keep the full set, including for existing prepared images.
550
+ if (named?.version !== 2 || !Array.isArray(named.extensions) || !Array.isArray(named.preloadLibraries)) return extensions;
551
+ const configured = configuredPreloads();
552
+ if (configured === undefined) return extensions;
553
+ const chosen = {};
554
+ for (const name of [...named.extensions, ...named.preloadLibraries, ...configured]) {
555
+ if (typeof name !== 'string') return extensions;
556
+ // plpgsql is the server's own; uuid-ossp's module is uuid_ossp (the module name; see CONTRIB).
557
+ if (name === 'plpgsql') continue;
558
+ const module = name === 'uuid-ossp' ? 'uuid_ossp' : name;
559
+ if (!Object.hasOwn(extensions, module)) return extensions;
560
+ chosen[module] = extensions[module];
561
+ }
562
+ return chosen;
563
+ }
564
+ /** Only simple library names this host supplies can narrow the bundle set; uncertain syntax keeps every bundle. */
565
+ function preloadNames(value) {
566
+ if (typeof value !== 'string') return undefined;
567
+ if (!value.trim()) return [];
568
+ const names = value.split(',').map(name => name.trim());
569
+ return names.every(name => /^[a-z][a-z0-9_-]*$/.test(name) && Object.hasOwn(extensions, name)) ? names : undefined;
570
+ }
571
+ /** The files PostgreSQL will read are the truth at boot. Union both files' preloads, even a superseded assignment;
572
+ * includes and unsupported value syntax fall back to all bundles rather than guessing their dependency closure. */
573
+ function configuredPreloads() {
574
+ const names = new Set();
575
+ for (const file of ['postgresql.conf', 'postgresql.auto.conf']) {
576
+ let text;
577
+ try { text = readFileSync(join(dataDir, file), 'utf8'); }
578
+ catch (error) { if (file === 'postgresql.auto.conf' && error?.code === 'ENOENT') continue; return undefined; }
579
+ for (const source of text.split(/\r?\n/)) {
580
+ const line = source.trim();
581
+ if (!line || line.startsWith('#')) continue;
582
+ if (/^include(?:_dir|_if_exists)?\b/i.test(line)) return undefined;
583
+ if (!/^shared_preload_libraries\b/i.test(line)) continue;
584
+ const setting = /^shared_preload_libraries\s*=\s*(?:'([^'\\]*)'|([a-z0-9_, \t-]*?))\s*(?:#.*)?$/i.exec(line);
585
+ if (!setting) return undefined;
586
+ const libraries = preloadNames(setting[1] ?? setting[2]);
587
+ if (libraries === undefined) return undefined;
588
+ for (const name of libraries) names.add(name);
589
+ }
590
+ }
591
+ return [...names];
592
+ }
593
+ /** The cluster's extensions now, written beside it for the next prepared open (only where this host records them). */
594
+ async function recordExtensions(db) {
595
+ if (!recordingExtensions) return;
596
+ try {
597
+ const rows = (await db.query('SELECT extname FROM pg_extension ORDER BY extname')).rows;
598
+ const startup = (await db.query("SELECT current_setting('session_preload_libraries') AS session, current_setting('local_preload_libraries') AS local")).rows[0];
599
+ // Database/role defaults can supply these outside the conf files; no narrowed record is safe while either is set.
600
+ if (typeof startup?.session !== 'string' || typeof startup?.local !== 'string' || startup.session.trim() || startup.local.trim()) {
601
+ throw new Error('Session or local preload libraries prevent a narrowed extension record.');
602
+ }
603
+ const setting = (await db.query("SELECT current_setting('shared_preload_libraries') AS libraries")).rows[0]?.libraries;
604
+ const preloadLibraries = preloadNames(setting);
605
+ if (preloadLibraries === undefined) throw new Error('The cluster preload libraries cannot be recorded as known bundles.');
606
+ const temporary = `${DECLARED_EXTENSIONS}.${process.pid}.tmp`;
607
+ writeFileSync(temporary, `${JSON.stringify({ version: 2, extensions: rows.map(row => row.extname), preloadLibraries })}\n`);
608
+ renameSync(temporary, DECLARED_EXTENSIONS);
609
+ } catch (error) {
610
+ // No record is the full set at the next open: never a narrower one than the cluster needs.
611
+ rmSync(DECLARED_EXTENSIONS, { force: true });
612
+ process.stderr.write(`pglite-host: the cluster's extensions were not recorded: ${errorMessage(error)}\n`);
613
+ }
614
+ }
156
615
  async function openDeclaredDatabase() {
616
+ // A record a narrowed open did not ask for is gone before anything can create an extension it lacks (review of R66b).
617
+ if (!declaredOnly && !recordingExtensions) rmSync(DECLARED_EXTENSIONS, { force: true });
157
618
  if (prepared) {
158
- const db = await PGlite.create({ dataDir, extensions, database: databaseName });
619
+ process.stdout.write('pglite-host opening declared database directly\n');
620
+ openSplit.createStartMs = performance.now();
621
+ const loading = declaredOnly && !recordingExtensions ? declaredExtensions() : extensions;
622
+ if (loading !== extensions) process.stdout.write(`pglite-host loading the declared migrations' extensions: ${Object.keys(loading).join(', ') || 'none'}\n`);
623
+ const db = await PGlite.create({ dataDir, extensions: withBackendStderr(unpackedOnce(loading, databaseName)), database: databaseName });
624
+ openSplit.createDoneMs = performance.now();
159
625
  await db.exec(`SET SESSION AUTHORIZATION ${quoteIdentifier(databaseUser)}`);
626
+ openSplit.sessionDoneMs = performance.now();
160
627
  return db;
161
628
  }
162
629
  // PostgreSQL cannot rename its current database. Use its maintenance database
163
630
  // before opening the declared one; old Worlds' rows in postgres stay intact.
164
- const maintenance = await PGlite.create({ dataDir, extensions, database: 'template1' });
631
+ process.stdout.write('pglite-host opening template1 for database identity setup\n');
632
+ const maintenance = await PGlite.create({ dataDir, extensions: withBackendStderr(unpackedOnce(extensions, 'template1')), database: 'template1' });
165
633
  try {
166
634
  const existing = await maintenance.query('SELECT datname FROM pg_database WHERE datname = $1', [databaseName]);
167
635
  if (!existing.rows.length) {
@@ -173,7 +641,7 @@ async function openDeclaredDatabase() {
173
641
  const role = await maintenance.query('SELECT rolname FROM pg_roles WHERE rolname = $1', [databaseUser]);
174
642
  if (!role.rows.length) await maintenance.exec(`CREATE ROLE ${quoteIdentifier(databaseUser)} LOGIN SUPERUSER`);
175
643
  } finally { await maintenance.close(); }
176
- const db = await PGlite.create({ dataDir, extensions, database: databaseName });
644
+ const db = await PGlite.create({ dataDir, extensions: withBackendStderr(unpackedOnce(extensions, databaseName)), database: databaseName });
177
645
  await db.exec(`SET SESSION AUTHORIZATION ${quoteIdentifier(databaseUser)}`);
178
646
  return db;
179
647
  }
@@ -649,17 +1117,21 @@ const server = net.createServer((socket) => {
649
1117
  if (preparing) void dbReady.then(async (db) => {
650
1118
  await db.close();
651
1119
  clearInterval(initKeepAlive);
652
- process.stdout.write(`pglite-host prepared ${dataDir}\n`);
1120
+ process.stdout.write(`pglite-host prepared ${dataDir} (${extensionFilesSaid()})\n`);
653
1121
  process.exit(0);
654
1122
  });
655
1123
  else void dbReady.then(() => {
656
1124
  clearInterval(initKeepAlive);
657
1125
  server.listen(port, '127.0.0.1', () => {
1126
+ const wasm = openSplit.wasm;
1127
+ const said = `imports ${split(openSplit.importsDoneMs)} ms, create ${split(openSplit.createStartMs)} -> ${split(openSplit.createDoneMs)} ms (wasm ${wasm.calls} calls, ${wasm.bytes} bytes: compile ${split(wasm.compileMs)} ms, instantiate ${split(wasm.instantiateMs)} ms; ${extensionPhase()}; ${extensionFilesSaid()}), session ${split(openSplit.sessionDoneMs)} ms, serving ${split(performance.now())} ms from this process's start`;
1128
+ collectingOpenPieces = false;
658
1129
  if (listeningFile) {
659
1130
  const temporary = `${listeningFile}.${process.pid}.tmp`;
660
- writeFileSync(temporary, `${JSON.stringify({ version: 1, pid: process.pid, port, boot: bootToken ?? null })}\n`, { mode: 0o600 });
1131
+ writeFileSync(temporary, `${JSON.stringify({ version: 1, pid: process.pid, port, boot: bootToken ?? null, openSplit: said })}\n`, { mode: 0o600 });
661
1132
  renameSync(temporary, listeningFile);
662
1133
  }
1134
+ process.stdout.write(`pglite-host open split: ${said}\n`);
663
1135
  process.stdout.write(`pglite-host serving postgres wire protocol on 127.0.0.1:${port}\n`);
664
1136
  });
665
1137
  });
@@ -667,7 +1139,7 @@ else void dbReady.then(() => {
667
1139
  for (const signal of ['SIGTERM', 'SIGINT']) {
668
1140
  process.on(signal, () => {
669
1141
  server.close(() => {
670
- void dbReady.then((db) => db.close()).finally(() => process.exit(0));
1142
+ void dbReady.then(async (db) => { await recordExtensions(db); await db.close(); }).finally(() => process.exit(0));
671
1143
  });
672
1144
  // a hung close must not make `down` hang the world
673
1145
  setTimeout(() => process.exit(0), 3000).unref();
@@ -1,2 +1,7 @@
1
- export function survivingOwnedGroups(pids: number[]): number[];
1
+ /** One reading of the process table, shared by the questions of one decision: made empty by the caller, filled by the first question that needs it. */
2
+ export type ProcessListing = { table?: unknown; proc?: unknown };
3
+ export function survivingOwnedGroups(pids: number[], listing?: ProcessListing): number[];
4
+ export function setWorldEventsFile(path: string | undefined): void;
5
+ export function worldEvent(record: Record<string, unknown>): void;
6
+ export function sayProbe(command: string, startedAt: number, outcome: string): void;
2
7
  export function processStarts(pids: number[]): Map<number, string>;