@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.
- 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 +3 -3
- 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 +3 -3
- 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 +3 -3
- package/src/twin-backing.ts +300 -0
- package/src/up-cli.ts +52 -3
|
@@ -16,75 +16,17 @@ import { appendFileSync, closeSync, copyFileSync, cpSync, existsSync, lstatSync,
|
|
|
16
16
|
import { createHash, randomUUID } from 'node:crypto';
|
|
17
17
|
import { assertWorldConfig } from "./schema.js";
|
|
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 } from 'node:child_process';
|
|
22
|
-
import { commandLifetime,
|
|
23
|
-
import { survivingOwnedGroups } from "./process-groups.js";
|
|
22
|
+
import { commandLifetime, COMMAND_CONFIRM_MS } from "./command-lifetime.js";
|
|
23
|
+
import { survivingOwnedGroups, worldEvent } from "./process-groups.js";
|
|
24
24
|
import { publishProcessReceipt, readProcessReceipt, readSpawnReceipt, spawnReceiptPath, verifySpawnIdentity, signalOwnedProcess } from "./process-receipt.js";
|
|
25
25
|
import { errorMessage } from "./error-message.js";
|
|
26
26
|
import { runtimeManifest } from "./package-manifest.js";
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
|
-
import { parse } from 'yaml';
|
|
29
28
|
import { mongodbArgv, mongodbReady, mongodbServes } from "./mongodb-backing.js";
|
|
30
|
-
|
|
31
|
-
* one replica-set member through its Compose-compatible command; other mongod options are refused. */
|
|
32
|
-
export function parseInfraDefinition(text) {
|
|
33
|
-
const definition = parse(text);
|
|
34
|
-
const services = [];
|
|
35
|
-
for (const [kind, value] of Object.entries(definition?.services ?? {})) {
|
|
36
|
-
const declared = value;
|
|
37
|
-
let replicaSet;
|
|
38
|
-
if (kind === 'mongodb' && declared.command !== undefined) {
|
|
39
|
-
const command = declared.command;
|
|
40
|
-
if (!Array.isArray(command) || command.length !== 3 || command[0] !== 'mongod' || command[1] !== '--replSet'
|
|
41
|
-
|| typeof command[2] !== 'string' || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(command[2])) {
|
|
42
|
-
throw new Error('World-managed MongoDB command must be ["mongod", "--replSet", "<name>"] or absent for standalone');
|
|
43
|
-
}
|
|
44
|
-
replicaSet = command[2];
|
|
45
|
-
}
|
|
46
|
-
const ports = (declared.ports ?? []).flatMap((port) => {
|
|
47
|
-
const match = typeof port === 'string' ? port.match(/^127\.0\.0\.1:(\d+):\d+$/) : null;
|
|
48
|
-
return match ? [Number(match[1])] : [];
|
|
49
|
-
});
|
|
50
|
-
if (ports.length !== 1)
|
|
51
|
-
throw new Error(`managed infrastructure ${kind} needs exactly one declared loopback port`);
|
|
52
|
-
let postgres;
|
|
53
|
-
if (kind === 'postgres') {
|
|
54
|
-
const environment = declared.environment ?? {};
|
|
55
|
-
const user = environment.POSTGRES_USER ?? 'postgres';
|
|
56
|
-
const database = environment.POSTGRES_DB ?? user;
|
|
57
|
-
const password = environment.POSTGRES_PASSWORD;
|
|
58
|
-
if ([user, database, password].some(value => value !== undefined && (typeof value !== 'string' || !value || /[\r\n\0]|\$\{/.test(value)))) {
|
|
59
|
-
throw new Error('World-managed PGlite requires literal POSTGRES_USER, POSTGRES_DB and POSTGRES_PASSWORD');
|
|
60
|
-
}
|
|
61
|
-
if ([user, database].some(value => Buffer.byteLength(value, 'utf8') > 63))
|
|
62
|
-
throw new Error('World-managed PGlite identifiers must fit PostgreSQL\'s 63-byte name limit');
|
|
63
|
-
postgres = { user: user, database: database, ...(password !== undefined ? { password: password } : {}) };
|
|
64
|
-
}
|
|
65
|
-
services.push({ kind, hostPort: ports[0], ...(replicaSet ? { replicaSet } : {}), ...(postgres ? { postgres } : {}) });
|
|
66
|
-
}
|
|
67
|
-
return services;
|
|
68
|
-
}
|
|
69
|
-
/** Endpoints of the declared infrastructure, published through external.discover. Only URLs
|
|
70
|
-
* whose protocol and loopback port match a declared service belong to this lifecycle. */
|
|
71
|
-
export function infraConnections(services, env) {
|
|
72
|
-
const connections = {};
|
|
73
|
-
for (const [name, value] of Object.entries(env)) {
|
|
74
|
-
if (value === undefined)
|
|
75
|
-
continue;
|
|
76
|
-
try {
|
|
77
|
-
const url = new URL(value);
|
|
78
|
-
if (url.hostname !== '127.0.0.1')
|
|
79
|
-
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)))
|
|
82
|
-
connections[name] = value;
|
|
83
|
-
}
|
|
84
|
-
catch { /* not an infrastructure endpoint */ }
|
|
85
|
-
}
|
|
86
|
-
return connections;
|
|
87
|
-
}
|
|
29
|
+
export { parseInfraDefinition, infraConnections } from "./infra-definition.js";
|
|
88
30
|
// this module's directory under Node as under Bun: `volter-world-infra` is a node-shebang bin, where
|
|
89
31
|
// `import.meta.dir` (Bun's) is undefined
|
|
90
32
|
// lazy: a module that builds a URL from import.meta.url when it loads fails to load in workerd, which bundles this
|
|
@@ -174,7 +116,8 @@ function listeningReceipt(path) {
|
|
|
174
116
|
const value = JSON.parse(text);
|
|
175
117
|
if (value.version !== 1 || !Number.isSafeInteger(value.pid) || !Number.isSafeInteger(value.port))
|
|
176
118
|
return undefined;
|
|
177
|
-
return { pid: value.pid, port: value.port, boot: typeof value.boot === 'string' ? value.boot : null
|
|
119
|
+
return { pid: value.pid, port: value.port, boot: typeof value.boot === 'string' ? value.boot : null,
|
|
120
|
+
...(typeof value.openSplit === 'string' ? { openSplit: value.openSplit } : {}) };
|
|
178
121
|
}
|
|
179
122
|
catch {
|
|
180
123
|
return undefined;
|
|
@@ -215,6 +158,106 @@ export function preparedCluster(worldConfig) {
|
|
|
215
158
|
const directory = join(dirname(worldConfig), PREPARED_DIRECTORY, containerlessDataDirectory('postgres'));
|
|
216
159
|
return { directory, stamp: `${directory}.json` };
|
|
217
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* What a prepared cluster must have been made with: this runtime (whose pglite-host.mjs is the open path that made
|
|
163
|
+
* it), its exact PGlite pin (initdb's encoding and locale are PGlite's defaults: the host passes no initdb
|
|
164
|
+
* parameters), and the declared user and database. The password is not in the cluster: the role is created without
|
|
165
|
+
* one (LOGIN SUPERUSER) and the host's gateway checks the declared password on each connection, so a World with
|
|
166
|
+
* another password takes the same cluster and authenticates against its own. Extensions are loaded into the module
|
|
167
|
+
* at each open, never created in the cluster by the open path.
|
|
168
|
+
*/
|
|
169
|
+
/**
|
|
170
|
+
* A managed store other than the cluster that a step run with the World up may have written (architecture, "A step
|
|
171
|
+
* may need the World"): where the preparation stages it, beside the cluster, and its stamp.
|
|
172
|
+
*/
|
|
173
|
+
export function preparedStore(worldConfig, name) {
|
|
174
|
+
const directory = join(dirname(worldConfig), PREPARED_DIRECTORY, name);
|
|
175
|
+
return { directory, stamp: `${directory}.json` };
|
|
176
|
+
}
|
|
177
|
+
const canonical = (value) => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
|
|
178
|
+
? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) : item);
|
|
179
|
+
/** The state directory a preparation with the World up runs in, beside the World's own (native-postgres-backing.ts). */
|
|
180
|
+
export const PREPARE_STATE_DIRECTORY = '.volter-prepare';
|
|
181
|
+
/** The stamp of a staged store: the migration it was written by. */
|
|
182
|
+
export function preparedStoreStamp(migration) {
|
|
183
|
+
return `${canonical({ migration })}\n`;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Place a staged store where its service serves from, at `up`: only into a place that holds nothing, and only when
|
|
187
|
+
* its stamp is the migration this World expects (the rule a prepared cluster is taken by). Anything else leaves the
|
|
188
|
+
* service to start empty, and the note says why.
|
|
189
|
+
*/
|
|
190
|
+
export function takePreparedStore(worldConfig, env, name, target) {
|
|
191
|
+
if (!worldConfig)
|
|
192
|
+
return { placed: false };
|
|
193
|
+
const { directory, stamp } = preparedStore(worldConfig, name);
|
|
194
|
+
if (!existsSync(directory) || !existsSync(stamp))
|
|
195
|
+
return { placed: false };
|
|
196
|
+
if (existsSync(target) && readdirSync(target).length)
|
|
197
|
+
return { placed: false };
|
|
198
|
+
const wanted = expectedMigration(worldConfig, env, directory);
|
|
199
|
+
if ('note' in wanted)
|
|
200
|
+
return { placed: false, note: wanted.note.replace('prepared cluster', `prepared ${name}`) };
|
|
201
|
+
if (!wanted.migration || readFileSync(stamp, 'utf8') !== preparedStoreStamp(wanted.migration))
|
|
202
|
+
return { placed: false, note: `prepared ${name} ${directory} not taken: its stamp names another migration` };
|
|
203
|
+
// A STORE GOES WITH ITS CLUSTER. Placed beside a database that does not carry the same migration, the World would
|
|
204
|
+
// have an unprepared database and a prepared store, and an application told to run the step itself would run it
|
|
205
|
+
// on a store that already holds what the step wrote. On `up` the twin-served kinds come up after the native
|
|
206
|
+
// backing's onPlaced (cluster placed) or after the database (infra.ts), so the live cluster is there to be read
|
|
207
|
+
// and must carry the migration. Where no cluster is live
|
|
208
|
+
// (a caller that starts a twin alone), a prepared cluster stamped with it must at least be waiting.
|
|
209
|
+
const data = dirname(target), prepared = join(dirname(worldConfig), PREPARED_DIRECTORY);
|
|
210
|
+
const clusters = [...new Set(['native-postgres-data', containerlessDataDirectory('postgres')])];
|
|
211
|
+
const live = clusters.map((cluster) => join(data, cluster)).filter((cluster) => existsSync(join(cluster, 'PG_VERSION')));
|
|
212
|
+
const carried = live.length > 0
|
|
213
|
+
? live.some((cluster) => liveDatabaseMigration(worldConfig, env, cluster) !== undefined)
|
|
214
|
+
: clusters.map((cluster) => join(prepared, cluster)).some((cluster) => {
|
|
215
|
+
if (!existsSync(join(cluster, 'PG_VERSION')) || !existsSync(`${cluster}.json`))
|
|
216
|
+
return false;
|
|
217
|
+
try {
|
|
218
|
+
return canonical(JSON.parse(readFileSync(`${cluster}.json`, 'utf8')).migration ?? null) === canonical(wanted.migration);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
if (!carried)
|
|
225
|
+
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))
|
|
227
|
+
rmdirSync(target);
|
|
228
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
229
|
+
try {
|
|
230
|
+
renameSync(directory, target);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (error.code !== 'EXDEV')
|
|
234
|
+
throw error;
|
|
235
|
+
cpSync(directory, target, { recursive: true });
|
|
236
|
+
}
|
|
237
|
+
// THE STORE IS READ UNDER THE NAME THIS UP USES. A twin keeps its state inside its root under the state directory's
|
|
238
|
+
// name; the preparation ran with its own (PREPARE_STATE_DIRECTORY) and this `up` runs with another, so the root as
|
|
239
|
+
// it was written is read by nobody. The one the preparation wrote under is renamed to the one the twin is about to
|
|
240
|
+
// be given, and the note says both, or says that nothing was there to rename.
|
|
241
|
+
const wrote = join(target, PREPARE_STATE_DIRECTORY), reads = join(target, env.VOLTER_STATE_DIR || '.volter');
|
|
242
|
+
let under = `nothing in it is under ${PREPARE_STATE_DIRECTORY}`;
|
|
243
|
+
if (existsSync(wrote) && wrote !== reads) {
|
|
244
|
+
if (existsSync(reads))
|
|
245
|
+
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` };
|
|
246
|
+
renameSync(wrote, reads);
|
|
247
|
+
under = `what the preparation wrote under ${PREPARE_STATE_DIRECTORY} is now under ${env.VOLTER_STATE_DIR || '.volter'}, where this up's service reads`;
|
|
248
|
+
}
|
|
249
|
+
return { placed: true, note: `prepared ${name} ${directory} placed at ${target}; ${under}` };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The twins a step declared `with: "world"` runs beside, each by the version the World names: part of what the
|
|
253
|
+
* cluster was migrated with, so a World whose twins moved does not take a cluster another set of them wrote into.
|
|
254
|
+
* Read from the document itself, in id order.
|
|
255
|
+
*/
|
|
256
|
+
export function preparedWorldServices(worldConfig) {
|
|
257
|
+
const document = JSON.parse(readFileSync(worldConfig, 'utf8'));
|
|
258
|
+
const twins = (document.services ?? []).filter((service) => service.type === 'twin' && typeof service.id === 'string');
|
|
259
|
+
return Object.fromEntries(twins.map((service) => [service.id, typeof service.source?.version === 'string' ? service.source.version : 'unversioned']).sort(([a], [b]) => a.localeCompare(b)));
|
|
260
|
+
}
|
|
218
261
|
function preparedStamp(service, migration) {
|
|
219
262
|
const manifest = runtimeManifest();
|
|
220
263
|
const pglite = manifest.dependencies?.['@electric-sql/pglite'];
|
|
@@ -223,6 +266,20 @@ function preparedStamp(service, migration) {
|
|
|
223
266
|
return `${JSON.stringify({ runtime: manifest.version ?? 'unversioned', pglite, user: service.postgres.user, database: service.postgres.database,
|
|
224
267
|
...(migration ? { migration } : {}) })}\n`;
|
|
225
268
|
}
|
|
269
|
+
const declaredDatabaseReceipt = (serviceData) => join(serviceData, '.volter-declared-database.json');
|
|
270
|
+
/** This cluster served the declared database and role; this is not a migration or process-ownership receipt. */
|
|
271
|
+
function hasDeclaredDatabase(serviceData, identity) {
|
|
272
|
+
if (!existsSync(join(serviceData, 'PG_VERSION')))
|
|
273
|
+
return false;
|
|
274
|
+
try {
|
|
275
|
+
return readFileSync(declaredDatabaseReceipt(serviceData), 'utf8') === identity;
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (error.code === 'ENOENT')
|
|
279
|
+
return false;
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
226
283
|
/**
|
|
227
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
|
|
228
285
|
* sorted relative paths, each with its type, its mode and its bytes (a link, its target). What the image build records
|
|
@@ -257,7 +314,7 @@ export function databasePrepareOf(worldConfig) {
|
|
|
257
314
|
return assertWorldConfig(JSON.parse(readFileSync(worldConfig, 'utf8')), worldConfig).databasePrepare;
|
|
258
315
|
}
|
|
259
316
|
/** `NAME=value` lines (an image's environment, VOLTER_WORLD_APP_ENV), as an environment. */
|
|
260
|
-
function environmentFile(path) {
|
|
317
|
+
export function environmentFile(path) {
|
|
261
318
|
const values = {};
|
|
262
319
|
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
263
320
|
const at = line.indexOf('=');
|
|
@@ -295,6 +352,18 @@ export function keepFailureEvidence(name, evidence, env) {
|
|
|
295
352
|
return `\n(the whole ${name} could not be kept: ${errorMessage(error)})`;
|
|
296
353
|
}
|
|
297
354
|
}
|
|
355
|
+
/** A package script's own path: each `node_modules/.bin` from the working directory up to the application's root. */
|
|
356
|
+
export function packageScriptPath(cwd, appRoot) {
|
|
357
|
+
const found = [];
|
|
358
|
+
for (let dir = cwd;; dir = dirname(dir)) {
|
|
359
|
+
const bin = join(dir, 'node_modules', '.bin');
|
|
360
|
+
if (existsSync(bin))
|
|
361
|
+
found.push(bin);
|
|
362
|
+
if (dir === appRoot || dirname(dir) === dir || !dir.startsWith(appRoot))
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
return found;
|
|
366
|
+
}
|
|
298
367
|
export async function runDatabasePrepare(step, worldConfig, launch) {
|
|
299
368
|
const env = launch.env ?? process.env;
|
|
300
369
|
const appRoot = env.VOLTER_WORLD_APP_ROOT;
|
|
@@ -305,13 +374,27 @@ export async function runDatabasePrepare(step, worldConfig, launch) {
|
|
|
305
374
|
const { PATH: _imagePath, ...imageValues } = imageEnv;
|
|
306
375
|
const worldValues = assertWorldConfig(JSON.parse(readFileSync(worldConfig, 'utf8')), worldConfig).env ?? {};
|
|
307
376
|
const [program, ...words] = step.command;
|
|
308
|
-
|
|
377
|
+
// THE STEP'S PROGRAM IS FOUND WHERE THE APPLICATION'S OWN START FINDS IT. A start written `yarn prisma:deploy`
|
|
378
|
+
// runs `prisma migrate deploy` with the package manager's path: each `node_modules/.bin` from the working
|
|
379
|
+
// directory up to the application's root, before PATH. The step is that script's command, so it is given that
|
|
380
|
+
// path; declared as a bare `prisma` and looked for on this process's PATH alone, it was not found, and a spawn
|
|
381
|
+
// that never started was reported as "exited null" with nothing else (linkwarden).
|
|
382
|
+
const scriptPath = packageScriptPath(cwd, appRoot);
|
|
383
|
+
const stepEnv = { ...env, ...imageValues, ...worldValues };
|
|
384
|
+
stepEnv.PATH = [...scriptPath, stepEnv.PATH ?? ''].filter(Boolean).join(delimiter);
|
|
385
|
+
const migration = spawn(program, words, { windowsHide: true, cwd, env: stepEnv, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
309
386
|
let output = '';
|
|
387
|
+
// A spawn that fails has no exit: what it met is kept and said, with where the program was looked for.
|
|
388
|
+
let notStarted;
|
|
389
|
+
migration.once('error', (error) => { notStarted = error; });
|
|
310
390
|
migration.stdout.on('data', (chunk) => { output += chunk.toString('utf8'); });
|
|
311
391
|
migration.stderr.on('data', (chunk) => { output += chunk.toString('utf8'); });
|
|
312
|
-
const { code } = await commandLifetime(migration, launch.signal).finish();
|
|
392
|
+
const { code, signal: endedBy } = await commandLifetime(migration, launch.signal).finish();
|
|
393
|
+
if (notStarted) {
|
|
394
|
+
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)`);
|
|
395
|
+
}
|
|
313
396
|
if (code !== 0)
|
|
314
|
-
throw new Error(`the World's database prepare step (${step.command.join(' ')})
|
|
397
|
+
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)}`);
|
|
315
398
|
launch.note?.(`database prepare step: ${step.command.join(' ')}\n${output.trim()}`);
|
|
316
399
|
return { command: [...step.command], inputs: migrationInputDigests(cwd, step.inputs) };
|
|
317
400
|
}
|
|
@@ -324,14 +407,16 @@ async function migratePreparedCluster(service, directory, step, worldConfig, lau
|
|
|
324
407
|
const pidFile = join(dirname(directory), 'pglite-prepare.pid');
|
|
325
408
|
const boot = randomUUID();
|
|
326
409
|
rmSync(listeningReceiptPath(pidFile), { force: true });
|
|
327
|
-
|
|
410
|
+
// --record-extensions: the step's host writes the extensions the migrations left beside the cluster, which a prepared
|
|
411
|
+
// open then loads alone (pglite-host.mjs DECLARED_EXTENSIONS, S2 R66b).
|
|
412
|
+
const [command, ...args] = [...hostArgv(service, directory), '--prepared', '--record-extensions', '--listening-file', listeningReceiptPath(pidFile), '--boot', boot];
|
|
328
413
|
let hostOutput = '';
|
|
329
414
|
const host = spawn(command, args, { windowsHide: true,
|
|
330
415
|
env: { ...env, VOLTER_WORLD_PGLITE_USER: service.postgres.user, VOLTER_WORLD_PGLITE_DATABASE: service.postgres.database, VOLTER_WORLD_PGLITE_PASSWORD: service.postgres.password },
|
|
331
416
|
stdio: ['ignore', 'pipe', 'pipe'] });
|
|
332
417
|
host.stdout.on('data', (chunk) => { hostOutput += chunk.toString('utf8'); });
|
|
333
418
|
host.stderr.on('data', (chunk) => { hostOutput += chunk.toString('utf8'); });
|
|
334
|
-
const hostLifetime = commandLifetime(host, launch.signal);
|
|
419
|
+
const hostLifetime = commandLifetime(host, launch.signal, false, READY_TIMEOUT_MS);
|
|
335
420
|
try {
|
|
336
421
|
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
337
422
|
while (!(host.pid && alive(host.pid) && await hostServes(service, pidFile, host.pid, boot))) {
|
|
@@ -378,6 +463,10 @@ export async function pglitePrepare(service, worldConfig, launch = {}) {
|
|
|
378
463
|
// The app's own migration, where the World declares one: a cluster it did not complete is never stamped.
|
|
379
464
|
const step = databasePrepareOf(worldConfig);
|
|
380
465
|
let migration;
|
|
466
|
+
if (step?.with === 'world') {
|
|
467
|
+
rmSync(directory, { recursive: true, force: true });
|
|
468
|
+
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)');
|
|
469
|
+
}
|
|
381
470
|
if (step) {
|
|
382
471
|
try {
|
|
383
472
|
migration = await migratePreparedCluster(service, directory, step, worldConfig, launch);
|
|
@@ -433,7 +522,8 @@ export function expectedMigration(worldConfig, env, directory) {
|
|
|
433
522
|
inputs = migrationInputDigests(join(env.VOLTER_WORLD_APP_ROOT, step.cwd), step.inputs);
|
|
434
523
|
if (!inputs)
|
|
435
524
|
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)` };
|
|
436
|
-
return { migration: { command: [...step.command], inputs: Object.fromEntries(step.inputs.map((input) => [input, inputs[input] ?? '']))
|
|
525
|
+
return { migration: { command: [...step.command], inputs: Object.fromEntries(step.inputs.map((input) => [input, inputs[input] ?? ''])),
|
|
526
|
+
...(step.with === 'world' ? { world: preparedWorldServices(worldConfig) } : {}) } };
|
|
437
527
|
}
|
|
438
528
|
/** Beside a service's data: the migration its live cluster carries, written when a migrated cluster is placed. */
|
|
439
529
|
const migrationRecord = (serviceData) => `${serviceData}.migration.json`;
|
|
@@ -481,7 +571,7 @@ export const DEVELOPMENT_MIGRATION_NOTICE = 'development World: its database mig
|
|
|
481
571
|
* is exactly `expected`; a mismatch is named, never taken. A migrated cluster's `migration` is recorded beside the data
|
|
482
572
|
* it becomes (liveDatabaseMigration).
|
|
483
573
|
*/
|
|
484
|
-
export function takePreparedCluster(prepared, expected, serviceData, mismatch, migration) {
|
|
574
|
+
export function takePreparedCluster(prepared, expected, serviceData, mismatch, migration, copy = (from, to) => cpSync(from, to, { recursive: true })) {
|
|
485
575
|
const { directory, stamp } = prepared;
|
|
486
576
|
if (existsSync(join(serviceData, 'PG_VERSION')))
|
|
487
577
|
return { placed: false };
|
|
@@ -497,17 +587,32 @@ export function takePreparedCluster(prepared, expected, serviceData, mismatch, m
|
|
|
497
587
|
}
|
|
498
588
|
mkdirSync(dirname(serviceData), { recursive: true });
|
|
499
589
|
// A rename moves the cluster without writing its files; across file systems it is copied.
|
|
590
|
+
// A caller may copy its own way (the native backing copies as the server's owner, so its cluster is walked once).
|
|
591
|
+
let copied = false;
|
|
500
592
|
try {
|
|
501
593
|
renameSync(directory, serviceData);
|
|
502
594
|
}
|
|
503
595
|
catch (error) {
|
|
504
596
|
if (error.code !== 'EXDEV')
|
|
505
597
|
throw error;
|
|
506
|
-
|
|
598
|
+
// Copied beside its place and moved in whole: a copy cut short (ENOSPC, a crash) never leaves a part of a cluster
|
|
599
|
+
// where the next up would refuse it, or, holding PG_VERSION, take it as placed and boot it truncated. A sibling an
|
|
600
|
+
// earlier start left behind is removed first.
|
|
601
|
+
const placing = `${serviceData}.placing`;
|
|
602
|
+
rmSync(placing, { recursive: true, force: true });
|
|
603
|
+
try {
|
|
604
|
+
copy(directory, placing);
|
|
605
|
+
renameSync(placing, serviceData);
|
|
606
|
+
}
|
|
607
|
+
catch (failure) {
|
|
608
|
+
rmSync(placing, { recursive: true, force: true });
|
|
609
|
+
throw failure;
|
|
610
|
+
}
|
|
611
|
+
copied = true;
|
|
507
612
|
}
|
|
508
613
|
if (migration)
|
|
509
614
|
writeFileSync(migrationRecord(serviceData), `${JSON.stringify(migration)}\n`);
|
|
510
|
-
return { placed: true, note: `prepared cluster ${directory} placed at ${serviceData}` };
|
|
615
|
+
return { placed: true, copied, note: `prepared cluster ${directory} placed at ${serviceData}${copied ? ' (copied: another file system)' : ''}` };
|
|
511
616
|
}
|
|
512
617
|
export async function pgliteUp(services, dataDir, launch = {}) {
|
|
513
618
|
const env = launch.env ?? process.env;
|
|
@@ -544,10 +649,26 @@ export async function pgliteUp(services, dataDir, launch = {}) {
|
|
|
544
649
|
appendFileSync(log, `${placed.note}\n`);
|
|
545
650
|
launch.note?.(placed.note);
|
|
546
651
|
}
|
|
652
|
+
const identity = service.kind === 'postgres' ? preparedStamp(service) : undefined;
|
|
653
|
+
const keptIdentity = identity !== undefined && hasDeclaredDatabase(serviceData, identity);
|
|
654
|
+
const direct = service.kind === 'postgres' && (placed.placed || keptIdentity);
|
|
655
|
+
if (identity !== undefined) {
|
|
656
|
+
// Maintenance may rename a legacy database. Its old identity must not survive a partial initialization.
|
|
657
|
+
if (!direct)
|
|
658
|
+
rmSync(declaredDatabaseReceipt(serviceData), { force: true });
|
|
659
|
+
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'})`;
|
|
660
|
+
appendFileSync(log, `${note}\n`);
|
|
661
|
+
launch.note?.(note);
|
|
662
|
+
}
|
|
547
663
|
// This boot's token, which the host writes into its listener receipt; an earlier receipt is never this one's.
|
|
548
664
|
const boot = randomUUID();
|
|
549
665
|
rmSync(listeningReceiptPath(pidFile), { force: true });
|
|
550
|
-
|
|
666
|
+
// The declared migrations' extensions alone, only for a cluster this boot placed whose live data carries the image's
|
|
667
|
+
// migration (S2 R66b, review): kept data, data a newer image migrates, or a cluster opened another way loads every
|
|
668
|
+
// extension, and the host drops the record.
|
|
669
|
+
const declaredOnly = service.kind === 'postgres' && placed.placed && env.VOLTER_WORLD_CONFIG !== undefined
|
|
670
|
+
&& liveDatabaseMigration(env.VOLTER_WORLD_CONFIG, env, serviceData) !== undefined;
|
|
671
|
+
const [command, ...args] = [...(direct ? [...argv, '--prepared'] : argv), ...(declaredOnly ? ['--declared-extensions'] : []), ...(service.kind === 'postgres' ? ['--listening-file', listeningReceiptPath(pidFile), '--boot', boot] : [])];
|
|
551
672
|
// a real fd, not a pipe: the host must outlive this process untethered
|
|
552
673
|
const logFd = openSync(log, 'a');
|
|
553
674
|
const child = spawn(command, args, {
|
|
@@ -558,7 +679,7 @@ export async function pgliteUp(services, dataDir, launch = {}) {
|
|
|
558
679
|
stdio: ['ignore', logFd, logFd],
|
|
559
680
|
});
|
|
560
681
|
// Retain the actual child BEFORE descriptor close or either publication can fail.
|
|
561
|
-
const lifetime = commandLifetime(child, launch.signal);
|
|
682
|
+
const lifetime = commandLifetime(child, launch.signal, false, READY_TIMEOUT_MS);
|
|
562
683
|
const host = { child, lifetime, closed: false };
|
|
563
684
|
child.once('close', () => { host.closed = true; });
|
|
564
685
|
hosts.set(pidFile, host);
|
|
@@ -591,7 +712,14 @@ export async function pgliteUp(services, dataDir, launch = {}) {
|
|
|
591
712
|
launch.signal?.throwIfAborted();
|
|
592
713
|
await sleep(25);
|
|
593
714
|
}
|
|
594
|
-
|
|
715
|
+
// Publish atomically only after this boot's own listener and protocol answer proved the database open.
|
|
716
|
+
// Kept opens read their identity once and need no extra write or fsync.
|
|
717
|
+
if (identity !== undefined && !keptIdentity)
|
|
718
|
+
publishProcessReceipt(declaredDatabaseReceipt(serviceData), identity);
|
|
719
|
+
// The host's own open split (pglite-host.mjs, THE OPEN'S SPLIT), from its receipt: said where a run's console
|
|
720
|
+
// sees it, as the host's stdout goes to its log alone (S2 R66).
|
|
721
|
+
const openSplit = service.kind === 'postgres' ? listeningReceipt(listeningReceiptPath(pidFile))?.openSplit : undefined;
|
|
722
|
+
launch.note?.(`${name} serving on 127.0.0.1:${service.hostPort}${openSplit ? ` (open split: ${openSplit})` : ''}`);
|
|
595
723
|
if (service.kind === 'mongodb')
|
|
596
724
|
await mongodbReady(service, launch, deadline);
|
|
597
725
|
child.unref();
|
|
@@ -662,15 +790,21 @@ async function stopHost(pidFile) {
|
|
|
662
790
|
await verifySpawnIdentity(ownership.identity);
|
|
663
791
|
else
|
|
664
792
|
throw new AggregateError(ownership.errors, 'Managed host spawn identity missing or unreadable; ownership retained');
|
|
665
|
-
//
|
|
666
|
-
|
|
793
|
+
// A STORE IS AWAITED AT ITS STOP on the lifecycle's own budget, not a command's grace: between the signal and
|
|
794
|
+
// its exit it writes its state down, and a SIGKILL in the middle of that leaves a root nothing may take.
|
|
795
|
+
// Its stop, its exit and the time between are events in the World's log; a kill is said as a kill.
|
|
796
|
+
const stopAsked = Date.now();
|
|
797
|
+
for (const [signal, limit] of [['SIGTERM', READY_TIMEOUT_MS], ['SIGKILL', COMMAND_CONFIRM_MS]]) {
|
|
667
798
|
if (!live())
|
|
668
799
|
break;
|
|
669
|
-
|
|
800
|
+
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' } : {}) });
|
|
801
|
+
await signalOwnedProcess(ownership.identity, signal, true, 'down');
|
|
670
802
|
const deadline = Date.now() + limit;
|
|
671
803
|
while (live() && Date.now() < deadline)
|
|
672
804
|
await sleep(20);
|
|
673
805
|
}
|
|
806
|
+
if (!live())
|
|
807
|
+
worldEvent({ event: 'world.service.exited', service: 'managed host', by: 'down', pid, shutdownMs: Date.now() - stopAsked });
|
|
674
808
|
if (live())
|
|
675
809
|
throw new Error(`managed infrastructure group ${pid} did not stop; retained ${pidFile}`);
|
|
676
810
|
}
|