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