@volter/world-runtime 3.0.163 → 3.0.165
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 +9 -3
- 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 +446 -19
- 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 +9 -4
- 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 +417 -18
- 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
package/dist/src/schema.d.ts
CHANGED
package/dist/src/schema.js
CHANGED
|
@@ -86,8 +86,8 @@ function assertExternalServiceConfig(service, path) {
|
|
|
86
86
|
if (!mapping.as || typeof mapping.as !== 'string') {
|
|
87
87
|
throw new Error(`External service "${id}" discover entry must define string "as" in ${path}`);
|
|
88
88
|
}
|
|
89
|
-
if (mapping.protocol !== undefined && !['postgres', 'mysql', 'mongodb', 'redis', 'http'].includes(mapping.protocol)) {
|
|
90
|
-
throw new Error(`External service "${id}" discover "${mapping.as}" protocol must be postgres, mysql, mongodb, redis or
|
|
89
|
+
if (mapping.protocol !== undefined && !['postgres', 'mysql', 'mongodb', 'redis', 'http', 'clickhouse'].includes(mapping.protocol)) {
|
|
90
|
+
throw new Error(`External service "${id}" discover "${mapping.as}" protocol must be postgres, mysql, mongodb, redis, http or clickhouse in ${path}`);
|
|
91
91
|
}
|
|
92
92
|
const hasJson = typeof mapping.jsonPath === 'string';
|
|
93
93
|
const hasPattern = typeof mapping.pattern === 'string';
|
|
@@ -505,7 +505,9 @@ function normalizeV2(value, path) {
|
|
|
505
505
|
if (databasePrepare !== undefined) {
|
|
506
506
|
if (!record(databasePrepare))
|
|
507
507
|
throw new Error(`runtime.database.prepare must be an object in ${path}`);
|
|
508
|
-
assertKeys(databasePrepare, ['command', 'cwd', 'inputs'], 'runtime.database.prepare', path);
|
|
508
|
+
assertKeys(databasePrepare, ['command', 'cwd', 'inputs', 'with'], 'runtime.database.prepare', path);
|
|
509
|
+
if (databasePrepare.with !== undefined && databasePrepare.with !== 'world')
|
|
510
|
+
throw new Error(`runtime.database.prepare.with must be "world" where it is given in ${path}`);
|
|
509
511
|
const strings = (value) => Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string' && item !== '');
|
|
510
512
|
if (!strings(databasePrepare.command))
|
|
511
513
|
throw new Error(`runtime.database.prepare.command must be a non-empty argv in ${path}`);
|
package/dist/src/served-world.js
CHANGED
|
@@ -2075,7 +2075,7 @@ export class WorldDoors {
|
|
|
2075
2075
|
const views = {};
|
|
2076
2076
|
const vendors = Object.keys(this.up().services);
|
|
2077
2077
|
withHistoryLocks(vendors.map((vendor) => worldPaths(this.stateOf(vendor), this.controlRoot(vendor)).dir), () => { for (const vendor of vendors)
|
|
2078
|
-
views[vendor] = historyAtInstant(this.stateOf(vendor), instant, this.controlRoot(vendor)); });
|
|
2078
|
+
views[vendor] = historyAtInstant(this.stateOf(vendor), instant, this.controlRoot(vendor)); }, { operation: 'capture' });
|
|
2079
2079
|
return Response.json({ world: this.served, at: new Date(Date.parse(instant)).toISOString(), views });
|
|
2080
2080
|
}
|
|
2081
2081
|
// THE WORLD'S MARKS (`GET marks`, `POST marks { note? }`): where its changes count from. A World seeded through its
|
|
@@ -2099,7 +2099,7 @@ export class WorldDoors {
|
|
|
2099
2099
|
const passed = delta.actions.filter((a) => !received.has(a.action.id)).length;
|
|
2100
2100
|
const mark = markWorld(this.name, { root: this.worldRoot, ...(typeof body.note === 'string' && body.note.trim() ? { note: body.note } : {}) });
|
|
2101
2101
|
return { mark, passed };
|
|
2102
|
-
});
|
|
2102
|
+
}, { operation: 'capture' });
|
|
2103
2103
|
return Response.json({ world: this.served, mark, passed }, { status: 201 });
|
|
2104
2104
|
}
|
|
2105
2105
|
if (kind === 'map' && !name && request.method === 'GET')
|
|
@@ -3188,7 +3188,7 @@ export async function landChangeset(name, worldRoot, instance, changeset, expect
|
|
|
3188
3188
|
}
|
|
3189
3189
|
landedByTwin.set(twin, ids);
|
|
3190
3190
|
}
|
|
3191
|
-
});
|
|
3191
|
+
}, { operation: 'action append' });
|
|
3192
3192
|
for (const [twin, landedIds] of landedByTwin) {
|
|
3193
3193
|
const controlRoot = join(instance.dirs.data, twin);
|
|
3194
3194
|
const state = stateOf(twin);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type PackFacts } from './pack-facts.js';
|
|
2
|
+
import { type HostLaunch, type InfraService } from './pglite-backing.js';
|
|
3
|
+
/** A managed-infrastructure kind a twin may serve (the kinds pack facts admit in `managedService`). */
|
|
4
|
+
export type ManagedKind = NonNullable<PackFacts['managedService']>['kind'];
|
|
5
|
+
/** What only a kind's protocol can answer, and the kind's name as a person reads it in a message. */
|
|
6
|
+
export interface ManagedProtocol {
|
|
7
|
+
name: string;
|
|
8
|
+
/** Ownership may use the kernel boot answer, bound to this child by a retained nonce. */
|
|
9
|
+
bootProof?: true;
|
|
10
|
+
/** Whether a server of this protocol answers on the port at all: one that does before this World's starts is another process's. */
|
|
11
|
+
answers(port: number): Promise<boolean>;
|
|
12
|
+
/** Whether the server answering on the port is the process `pid` (architecture D9). */
|
|
13
|
+
serves(port: number, pid: number, service?: InfraService, boot?: string): Promise<boolean>;
|
|
14
|
+
}
|
|
15
|
+
export interface TwinServedBacking extends ManagedProtocol {
|
|
16
|
+
/** The vendor id of the pack that declares the kind, and the word its messages use in lower case. */
|
|
17
|
+
id: string;
|
|
18
|
+
/** The pack's package. */
|
|
19
|
+
package: string;
|
|
20
|
+
/** The directory under the World's data that holds the service's state, and the stem of its pid file and log. */
|
|
21
|
+
directory: string;
|
|
22
|
+
facts: PackFacts;
|
|
23
|
+
}
|
|
24
|
+
/** The directory of a kind's state under the World's data. */
|
|
25
|
+
export declare const managedDirectory: (kind: ManagedKind) => string;
|
|
26
|
+
/** The twin that serves a kind: the one pack whose facts declare it (pack-facts refuses two for a kind), with the
|
|
27
|
+
* kind's protocol. Undefined where no pack declares the kind; the kind then keeps its own backing.
|
|
28
|
+
*
|
|
29
|
+
* Facts are read in two places. The kernel's own table holds the packs it was built with. A pack it was not built
|
|
30
|
+
* with carries its own facts (`generated/pack-facts.json`, architecture, "The catalog: where twins come from"), and
|
|
31
|
+
* the catalog overlays those for a World's SELECTED twins only; a pack that serves a managed service is selected by
|
|
32
|
+
* nobody (it is no service of world.json), so its declaration is read here from the package itself, installed
|
|
33
|
+
* above one of `from`. Only the declaration is read: nothing of that file is overlaid on the kernel's tables. */
|
|
34
|
+
export declare function managedTwin(kind: ManagedKind, protocol: ManagedProtocol, from?: readonly string[], originalInputs?: boolean): TwinServedBacking | undefined;
|
|
35
|
+
/** The twin's cli: installed beside this runtime (its node_modules walk), else beside the World's config. */
|
|
36
|
+
export declare function twinBackingCli(kind: TwinServedBacking, from: string[]): string | undefined;
|
|
37
|
+
/** Where the service's state lives: the World's tree for the service, under the World's data. */
|
|
38
|
+
export declare const twinBackingRoot: (kind: TwinServedBacking, dataDir: string) => string;
|
|
39
|
+
export declare function originalInputArguments(kind: TwinServedBacking, service: InfraService, config: string): string[];
|
|
40
|
+
export declare function twinBackingUp(kind: TwinServedBacking, services: InfraService[], dataDir: string, cli: string, launch?: HostLaunch): Promise<void>;
|
|
41
|
+
/** Whether these services stand now as their status would prove them, asked now: for each port, the pid recorded is
|
|
42
|
+
* the child this process started, that child has not closed and is alive, and the port is served by it (`serves`,
|
|
43
|
+
* asked again here, as status asks it). A service it did not start here, or any doubt (an error asking), is not held,
|
|
44
|
+
* and the caller's own status runs instead. */
|
|
45
|
+
export declare function twinBackingHeld(kind: TwinServedBacking, services: InfraService[], dataDir: string): Promise<boolean>;
|
|
46
|
+
export declare function twinBackingStatus(kind: TwinServedBacking, services: InfraService[], dataDir: string): Promise<number>;
|
|
47
|
+
export declare function twinBackingDown(kind: TwinServedBacking, services: InfraService[], dataDir: string): Promise<void>;
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// A World's managed service SERVED BY A TWIN: the lifecycle every such service shares, whatever protocol the twin
|
|
2
|
+
// speaks. The twin's own cli is started detached on the loopback port the definition declares, its state under the
|
|
3
|
+
// World's data (so it branches, checkpoints and rolls back with the World's tree); the child is recorded by pid and
|
|
4
|
+
// birth, proven to be the one answering on the port, and retired by TERM then KILL with its receipts kept until
|
|
5
|
+
// retirement is confirmed.
|
|
6
|
+
//
|
|
7
|
+
// Which pack serves a kind is the pack's own declaration (`managedService` in its facts; architecture, "The
|
|
8
|
+
// descriptor"), found here by managedTwin: no pack and no vendor is named in the runtime. Every such pack is started
|
|
9
|
+
// the same way, by its bin: `serve --port <the definition's port> --root <the service's tree>`.
|
|
10
|
+
//
|
|
11
|
+
// What the runtime does hold per kind is its ManagedProtocol: the two questions only the kind's protocol can answer
|
|
12
|
+
// (does anything of this protocol answer on the port; is the one answering this child, architecture D9).
|
|
13
|
+
// redis-backing.ts holds Redis's.
|
|
14
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync } from 'node:fs';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { connect } from 'node:net';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { commandLifetime, COMMAND_CONFIRM_MS } from "./command-lifetime.js";
|
|
19
|
+
import { publishProcessReceipt, readProcessReceipt, readSpawnReceipt, spawnReceiptPath, signalOwnedProcess } from "./process-receipt.js";
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { survivingOwnedGroups, worldEvent } from "./process-groups.js";
|
|
22
|
+
import { errorMessage } from "./error-message.js";
|
|
23
|
+
import { findInstalledPackage, packCli, packFactsFile, twinPackageName } from "./catalog.js";
|
|
24
|
+
import { installedVendorPacks, vendorPackageDir } from '@volter/world-core/runtime';
|
|
25
|
+
import { packFacts } from "./pack-facts.js";
|
|
26
|
+
import { takePreparedStore } from "./pglite-backing.js";
|
|
27
|
+
import { bindOriginalInputState, preflightOriginalInputState, readOriginalInputSnapshot, verifyOriginalInputState } from "./original-inputs.js";
|
|
28
|
+
/** The directory of a kind's state under the World's data. */
|
|
29
|
+
export const managedDirectory = (kind) => `${kind}-twin`;
|
|
30
|
+
/** The twin that serves a kind: the one pack whose facts declare it (pack-facts refuses two for a kind), with the
|
|
31
|
+
* kind's protocol. Undefined where no pack declares the kind; the kind then keeps its own backing.
|
|
32
|
+
*
|
|
33
|
+
* Facts are read in two places. The kernel's own table holds the packs it was built with. A pack it was not built
|
|
34
|
+
* with carries its own facts (`generated/pack-facts.json`, architecture, "The catalog: where twins come from"), and
|
|
35
|
+
* the catalog overlays those for a World's SELECTED twins only; a pack that serves a managed service is selected by
|
|
36
|
+
* nobody (it is no service of world.json), so its declaration is read here from the package itself, installed
|
|
37
|
+
* above one of `from`. Only the declaration is read: nothing of that file is overlaid on the kernel's tables. */
|
|
38
|
+
export function managedTwin(kind, protocol, from = [], originalInputs = false) {
|
|
39
|
+
const built = Object.entries(packFacts()).find(([, facts]) => facts.managedService?.kind === kind)?.[0];
|
|
40
|
+
const installed = built && !originalInputs ? undefined : installedDeclaring(kind, from);
|
|
41
|
+
if (originalInputs && !installed)
|
|
42
|
+
return undefined;
|
|
43
|
+
if (built && installed && built !== installed.vendor)
|
|
44
|
+
throw new Error(`Installed managed ${kind} pack conflicts with the built-in declaration`);
|
|
45
|
+
const vendor = originalInputs ? installed?.vendor : built ?? installed?.vendor;
|
|
46
|
+
return vendor === undefined ? undefined : { ...protocol, id: vendor, package: twinPackageName(vendor), directory: managedDirectory(kind), facts: installed?.facts ?? packFacts()[built] };
|
|
47
|
+
}
|
|
48
|
+
/** The installed pack whose own facts declare the kind, by vendor id; two that do are refused by name, as the
|
|
49
|
+
* kernel's table refuses two. */
|
|
50
|
+
function installedDeclaring(kind, from) {
|
|
51
|
+
const found = new Map();
|
|
52
|
+
for (const dir of from) {
|
|
53
|
+
for (const [vendor, pack] of installedVendorPacks(dir)) {
|
|
54
|
+
const file = packFactsFile(pack);
|
|
55
|
+
if (file === undefined)
|
|
56
|
+
continue;
|
|
57
|
+
let own;
|
|
58
|
+
try {
|
|
59
|
+
own = JSON.parse(readFileSync(file, 'utf8')).packs?.[vendor];
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (own?.managedService?.kind === kind)
|
|
65
|
+
found.set(vendor, own);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (found.size > 1)
|
|
69
|
+
throw new Error(`two installed packs declare the ${kind} kind (managedService): ${[...found.keys()].sort().join(', ')}`);
|
|
70
|
+
const entry = [...found.entries()][0];
|
|
71
|
+
return entry ? { vendor: entry[0], facts: entry[1] } : undefined;
|
|
72
|
+
}
|
|
73
|
+
const hosts = new Map();
|
|
74
|
+
const READY_TIMEOUT_MS = 60_000;
|
|
75
|
+
/** The twin's cli: installed beside this runtime (its node_modules walk), else beside the World's config. */
|
|
76
|
+
export function twinBackingCli(kind, from) {
|
|
77
|
+
for (const dir of from) {
|
|
78
|
+
const found = vendorPackageDir(dir, kind.id) ?? findInstalledPackage(dir, kind.package);
|
|
79
|
+
const cli = found === undefined ? undefined : packCli(found);
|
|
80
|
+
if (cli !== undefined && existsSync(cli))
|
|
81
|
+
return cli;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const pidPath = (kind, dataDir, service) => join(dataDir, `${kind.directory}-${service.hostPort}.pid`);
|
|
86
|
+
/** Where the service's state lives: the World's tree for the service, under the World's data. */
|
|
87
|
+
export const twinBackingRoot = (kind, dataDir) => join(dataDir, kind.directory);
|
|
88
|
+
function alive(pid) {
|
|
89
|
+
try {
|
|
90
|
+
process.kill(pid, 0);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error.code === 'ESRCH')
|
|
95
|
+
return false;
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
100
|
+
function portOccupied(port) {
|
|
101
|
+
return new Promise(resolve => {
|
|
102
|
+
const socket = connect({ host: '127.0.0.1', port, timeout: 1000 });
|
|
103
|
+
const end = (occupied) => { socket.destroy(); resolve(occupied); };
|
|
104
|
+
socket.once('connect', () => end(true));
|
|
105
|
+
socket.once('error', () => end(false));
|
|
106
|
+
socket.once('timeout', () => end(false));
|
|
107
|
+
socket.once('close', () => end(false));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function sideArguments(kind, service) {
|
|
111
|
+
const args = [];
|
|
112
|
+
for (const [name, side] of Object.entries(service.sides ?? {})) {
|
|
113
|
+
const declared = kind.facts.sidePorts?.find(port => port.name === name);
|
|
114
|
+
if (!declared || !/^--[a-z][a-z-]*$/.test(declared.flag))
|
|
115
|
+
throw new Error(`${kind.name} installed pack does not declare side listener ${name}`);
|
|
116
|
+
args.push(declared.flag, String(side.hostPort));
|
|
117
|
+
}
|
|
118
|
+
return args;
|
|
119
|
+
}
|
|
120
|
+
export function originalInputArguments(kind, service, config) {
|
|
121
|
+
if (!service.originalInputs)
|
|
122
|
+
return [];
|
|
123
|
+
const input = kind.facts.managedService?.originalInputs;
|
|
124
|
+
if (!input || input.version !== 1 || input.manifestArg !== '--original-inputs' || input.rootArg !== '--original-root' || input.digestArg !== '--original-inputs-sha256'
|
|
125
|
+
|| !Array.isArray(input.paths) || !input.paths.length)
|
|
126
|
+
throw new Error(`${kind.name} installed pack has no original-input consumer`);
|
|
127
|
+
const read = readOriginalInputSnapshot(dirname(config), service.originalInputs);
|
|
128
|
+
if (read.snapshot.manifest.family !== service.kind || input.paths.some(path => !read.snapshot.manifest.roots.includes(path)))
|
|
129
|
+
throw new Error(`${kind.name} original-input source differs from its consumer`);
|
|
130
|
+
return [input.manifestArg, join(read.root, read.reference.manifest), input.rootArg, read.root, input.digestArg, read.reference.sha256];
|
|
131
|
+
}
|
|
132
|
+
export async function twinBackingUp(kind, services, dataDir, cli, launch = {}) {
|
|
133
|
+
const config = (launch.env ?? process.env).VOLTER_WORLD_CONFIG;
|
|
134
|
+
if (services.some(service => service.originalInputs) && !config)
|
|
135
|
+
throw new Error('Original-input services require their World configuration path');
|
|
136
|
+
// Authenticate every source before changing any existing host or persistent store.
|
|
137
|
+
const originals = new Map(services.map(service => [service, service.originalInputs ? originalInputArguments(kind, service, config) : []]));
|
|
138
|
+
for (const service of services)
|
|
139
|
+
if (service.originalInputs)
|
|
140
|
+
preflightOriginalInputState(twinBackingRoot(kind, dataDir), service.originalInputs);
|
|
141
|
+
for (const service of services) {
|
|
142
|
+
const pidFile = pidPath(kind, dataDir, service);
|
|
143
|
+
if (hosts.has(pidFile) || existsSync(pidFile) || existsSync(spawnReceiptPath(pidFile))) {
|
|
144
|
+
await twinBackingDown(kind, [service], dataDir);
|
|
145
|
+
}
|
|
146
|
+
// a port that already answers is another process's (another World's twin, the vendor's own server): serving there would
|
|
147
|
+
// hand this World's app someone else's state, so it is refused, never taken as ready
|
|
148
|
+
if (await kind.answers(service.hostPort))
|
|
149
|
+
throw new Error(`port ${service.hostPort} already serves ${kind.name} for another process`);
|
|
150
|
+
const sides = sideArguments(kind, service);
|
|
151
|
+
for (const side of Object.values(service.sides ?? {}))
|
|
152
|
+
if (await portOccupied(side.hostPort))
|
|
153
|
+
throw new Error(`${kind.name} side port ${side.hostPort} already belongs to another listener`);
|
|
154
|
+
if (service.configuration && kind.facts.managedService?.configurationArg !== '--configuration')
|
|
155
|
+
throw new Error(`${kind.name} installed pack does not declare operator configuration`);
|
|
156
|
+
const root = twinBackingRoot(kind, dataDir);
|
|
157
|
+
// What a prepare step run with the World up wrote to this service (a queue's registered jobs in Redis) is the
|
|
158
|
+
// World's prepared state as its cluster is (architecture, "A step may need the World"): the staged root is
|
|
159
|
+
// placed here before the twin first serves an empty one, whichever pack's twin it is.
|
|
160
|
+
const env = launch.env ?? process.env;
|
|
161
|
+
const taken = takePreparedStore(env.VOLTER_WORLD_CONFIG, env, kind.directory, root);
|
|
162
|
+
if (taken.note)
|
|
163
|
+
launch.note?.(taken.note);
|
|
164
|
+
mkdirSync(root, { recursive: true });
|
|
165
|
+
if (service.originalInputs)
|
|
166
|
+
bindOriginalInputState(root, service.originalInputs);
|
|
167
|
+
const configurationFile = service.configuration ? join(root, `operator-${service.hostPort}.json`) : undefined;
|
|
168
|
+
if (configurationFile)
|
|
169
|
+
publishProcessReceipt(configurationFile, JSON.stringify(service.configuration) + '\n');
|
|
170
|
+
const boot = kind.bootProof ? randomUUID() : undefined;
|
|
171
|
+
const log = join(dataDir, `${kind.directory}-${service.hostPort}.log`);
|
|
172
|
+
mkdirSync(dirname(log), { recursive: true });
|
|
173
|
+
// a real fd, not a pipe: the server outlives this process untethered
|
|
174
|
+
const logFd = openSync(log, 'a');
|
|
175
|
+
// the runtime this runs on: Bun runs the pack's TypeScript as it is; Node (volter-world-infra's shebang) needs
|
|
176
|
+
// type transformation, not only stripping (the kernel declares constructor parameter properties)
|
|
177
|
+
const runtime = typeof globalThis.Bun !== 'undefined' || !cli.endsWith('.ts') ? [] : ['--experimental-transform-types', '--no-warnings'];
|
|
178
|
+
let held;
|
|
179
|
+
try {
|
|
180
|
+
try {
|
|
181
|
+
const child = spawn(process.execPath, [...runtime, cli, 'serve', '--port', String(service.hostPort), '--root', root,
|
|
182
|
+
...(service.replicaSet ? ['--replica-set', service.replicaSet] : []), ...sides, ...(configurationFile ? ['--configuration', configurationFile] : []), ...originals.get(service)], {
|
|
183
|
+
env: { ...(launch.env ?? process.env), ...(boot ? { VOLTER_WORLD_BOOT_ID: boot } : {}) }, detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd],
|
|
184
|
+
});
|
|
185
|
+
// Retain BEFORE descriptor close, birth capture or either publication can fail.
|
|
186
|
+
held = { child, lifetime: commandLifetime(child, launch.signal, false, READY_TIMEOUT_MS), closed: false, ...(boot ? { boot } : {}) };
|
|
187
|
+
const observed = held;
|
|
188
|
+
child.once('close', () => { observed.closed = true; });
|
|
189
|
+
child.on('error', () => { }); // lifetime joins spawn failure and close
|
|
190
|
+
hosts.set(pidFile, held);
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
closeSync(logFd);
|
|
194
|
+
}
|
|
195
|
+
if (!held.child.pid)
|
|
196
|
+
throw new Error(`${kind.name} child did not start`);
|
|
197
|
+
const identity = await held.lifetime.identity;
|
|
198
|
+
if (!identity?.start)
|
|
199
|
+
throw new Error(`${kind.name} birth capture failed; retiring held child`);
|
|
200
|
+
publishProcessReceipt(spawnReceiptPath(pidFile), JSON.stringify({ ...identity, ...(boot ? { boot } : {}) }) + '\n');
|
|
201
|
+
publishProcessReceipt(pidFile, `${held.child.pid}\n`);
|
|
202
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
203
|
+
while (!alive(held.child.pid) || !(await kind.serves(service.hostPort, held.child.pid, service, boot))) {
|
|
204
|
+
if (!alive(held.child.pid))
|
|
205
|
+
throw new Error(`${kind.id} exited before serving — see ${log}`);
|
|
206
|
+
launch.signal?.throwIfAborted();
|
|
207
|
+
if (Date.now() > deadline)
|
|
208
|
+
throw new Error(`${kind.id} did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
|
|
209
|
+
await sleep(25);
|
|
210
|
+
}
|
|
211
|
+
launch.signal?.throwIfAborted();
|
|
212
|
+
held.child.unref();
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
if (held) {
|
|
216
|
+
try {
|
|
217
|
+
await twinBackingDown(kind, [service], dataDir);
|
|
218
|
+
}
|
|
219
|
+
catch (cleanup) {
|
|
220
|
+
throw new AggregateError([error, cleanup], `${kind.name} startup and retirement failed; ownership retained`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Whether these services stand now as their status would prove them, asked now: for each port, the pid recorded is
|
|
228
|
+
* the child this process started, that child has not closed and is alive, and the port is served by it (`serves`,
|
|
229
|
+
* asked again here, as status asks it). A service it did not start here, or any doubt (an error asking), is not held,
|
|
230
|
+
* and the caller's own status runs instead. */
|
|
231
|
+
export async function twinBackingHeld(kind, services, dataDir) {
|
|
232
|
+
for (const service of services) {
|
|
233
|
+
try {
|
|
234
|
+
const pidFile = pidPath(kind, dataDir, service);
|
|
235
|
+
const held = hosts.get(pidFile);
|
|
236
|
+
if (!held?.child.pid || held.closed || !existsSync(pidFile))
|
|
237
|
+
return false;
|
|
238
|
+
const pid = Number(readFileSync(pidFile, 'utf8').trim());
|
|
239
|
+
if (pid !== held.child.pid || !alive(pid) || !(await kind.serves(service.hostPort, pid, service)))
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
export async function twinBackingStatus(kind, services, dataDir) {
|
|
249
|
+
let ready = 0;
|
|
250
|
+
for (const service of services) {
|
|
251
|
+
if (service.originalInputs) {
|
|
252
|
+
try {
|
|
253
|
+
verifyOriginalInputState(twinBackingRoot(kind, dataDir), service.originalInputs);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
process.stderr.write(`${errorMessage(error)}\n`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const pidFile = pidPath(kind, dataDir, service);
|
|
261
|
+
if (!existsSync(pidFile))
|
|
262
|
+
continue;
|
|
263
|
+
const recorded = readProcessReceipt(pidFile, text => {
|
|
264
|
+
const pid = Number(text.trim());
|
|
265
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
266
|
+
throw new Error(`Invalid ${kind.name} ownership PID`);
|
|
267
|
+
return pid;
|
|
268
|
+
});
|
|
269
|
+
const identity = kind.bootProof ? readSpawnReceipt(pidFile) : undefined;
|
|
270
|
+
if (recorded.error || identity?.error) {
|
|
271
|
+
process.stderr.write(`${errorMessage(recorded.error ?? identity.error)}\n`);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const pid = recorded.value;
|
|
275
|
+
if (!pid)
|
|
276
|
+
continue;
|
|
277
|
+
const boot = identity?.value?.boot;
|
|
278
|
+
if (kind.bootProof && (identity?.value?.pid !== pid || typeof boot !== 'string' || !boot))
|
|
279
|
+
continue;
|
|
280
|
+
if (alive(pid) && await kind.serves(service.hostPort, pid, service, typeof boot === 'string' ? boot : undefined))
|
|
281
|
+
ready += 1;
|
|
282
|
+
}
|
|
283
|
+
return ready;
|
|
284
|
+
}
|
|
285
|
+
export async function twinBackingDown(kind, services, dataDir) {
|
|
286
|
+
for (const service of services) {
|
|
287
|
+
const pidFile = pidPath(kind, dataDir, service);
|
|
288
|
+
const recorded = readProcessReceipt(pidFile, text => {
|
|
289
|
+
const pid = Number(text.trim());
|
|
290
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
291
|
+
throw new Error(`Invalid ${kind.name} ownership PID`);
|
|
292
|
+
return pid;
|
|
293
|
+
});
|
|
294
|
+
const identity = readSpawnReceipt(pidFile);
|
|
295
|
+
const held = hosts.get(pidFile);
|
|
296
|
+
if (recorded.error || identity.error)
|
|
297
|
+
process.stderr.write(`${errorMessage(new AggregateError([recorded.error, identity.error].filter(Boolean), `${kind.name} ownership uncertain; retrying established ownership`))}\n`);
|
|
298
|
+
if (held) {
|
|
299
|
+
const retired = () => held.closed && (!held.child.pid || !survivingOwnedGroups([held.child.pid]).length);
|
|
300
|
+
if (!retired()) {
|
|
301
|
+
held.child.ref();
|
|
302
|
+
held.lifetime.cancel('SIGTERM');
|
|
303
|
+
try {
|
|
304
|
+
await held.lifetime.finish();
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (!retired())
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
if (!recorded.present && !identity.present)
|
|
314
|
+
continue;
|
|
315
|
+
const pid = identity.value?.pid ?? recorded.value;
|
|
316
|
+
if (!pid)
|
|
317
|
+
throw new AggregateError([recorded.error, identity.error].filter(Boolean), `${kind.name} ownership uncertain; receipts retained`);
|
|
318
|
+
if (recorded.value && identity.value && recorded.value !== identity.value.pid)
|
|
319
|
+
throw new Error(`${kind.name} ownership receipts disagree; retirement refused; receipts retained`);
|
|
320
|
+
const live = () => survivingOwnedGroups([pid]).length > 0;
|
|
321
|
+
// A STORE IS AWAITED AT ITS STOP on the lifecycle's own budget, not a command's grace: between the signal and
|
|
322
|
+
// its exit it writes its state down, and a SIGKILL in the middle of that leaves a root nothing may take.
|
|
323
|
+
// Its stop, its exit and the time between are events in the World's log; a kill is said as a kill.
|
|
324
|
+
const stopAsked = Date.now();
|
|
325
|
+
for (const [signal, limit] of [['SIGTERM', READY_TIMEOUT_MS], ['SIGKILL', COMMAND_CONFIRM_MS]]) {
|
|
326
|
+
if (!live())
|
|
327
|
+
break;
|
|
328
|
+
worldEvent({ event: signal === 'SIGTERM' ? 'world.service.stop' : 'world.service.killed', service: kind.name, by: 'down', signal, to: 'group', pid, ...(signal === 'SIGKILL' ? { afterMs: Date.now() - stopAsked, doing: 'its stop had not ended within the lifecycle budget' } : {}) });
|
|
329
|
+
await signalOwnedProcess(identity.value, signal, true, 'down');
|
|
330
|
+
const deadline = Date.now() + limit;
|
|
331
|
+
while (live() && Date.now() < deadline)
|
|
332
|
+
await sleep(20);
|
|
333
|
+
}
|
|
334
|
+
if (!live())
|
|
335
|
+
worldEvent({ event: 'world.service.exited', service: kind.name, by: 'down', pid, shutdownMs: Date.now() - stopAsked });
|
|
336
|
+
if (live())
|
|
337
|
+
throw new Error(`${kind.name} retirement unconfirmed; receipts retained`);
|
|
338
|
+
}
|
|
339
|
+
rmSync(pidFile, { force: true });
|
|
340
|
+
rmSync(spawnReceiptPath(pidFile), { force: true });
|
|
341
|
+
hosts.delete(pidFile);
|
|
342
|
+
}
|
|
343
|
+
}
|
package/dist/src/up-cli.js
CHANGED
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
// the process starts (a browser tab's engine compiles every module it loads), those modules were most of the
|
|
4
4
|
// CLI's time before the owner process began.
|
|
5
5
|
import { optionValue, ownerOption } from "./cli-args.js";
|
|
6
|
-
import { superviseWorldUp } from "./up-task.js";
|
|
7
6
|
export async function upCommand(subject, rest, root) {
|
|
7
|
+
const split = rest.indexOf('--');
|
|
8
|
+
const command = split < 0 ? [] : rest.slice(split + 1);
|
|
9
|
+
if (split >= 0)
|
|
10
|
+
rest = rest.slice(0, split);
|
|
11
|
+
const attachAfterLine = rest.includes('--attach-after-line');
|
|
12
|
+
if (attachAfterLine && !rest.includes('--in-process'))
|
|
13
|
+
throw new Error('volter-world up: --attach-after-line requires --in-process');
|
|
14
|
+
if (attachAfterLine && command.length === 0)
|
|
15
|
+
throw new Error('volter-world up: --attach-after-line requires a command after --');
|
|
8
16
|
if (rest.some(arg => arg === '--env-file' || arg.startsWith('--env-file=')))
|
|
9
17
|
throw new Error('--env-file is not an input for this command. Use --env-out=<new-output-path>; never pass credentials or custody as an output.');
|
|
10
18
|
if (!subject)
|
|
@@ -35,19 +43,64 @@ export async function upCommand(subject, rest, root) {
|
|
|
35
43
|
// where there is none, it costs a whole Node start before the boot and its own end after it (Rallly run 35: 611 ms
|
|
36
44
|
// from the owner's "returning" to its reply reaching this process). The caller's SIGINT or SIGTERM still cancels the
|
|
37
45
|
// boot, which rolls back.
|
|
38
|
-
const instance = rest.includes('--in-process') ? await upInProcess(subject, options) : await superviseWorldUp(subject, options);
|
|
46
|
+
const instance = rest.includes('--in-process') ? await upInProcess(subject, options) : await (await import("./up-task.js")).superviseWorldUp(subject, options);
|
|
47
|
+
// An instrument: what this process does between the boot's return and its last line, each part timed. A second
|
|
48
|
+
// stood between "returning: the World is up" and the ready text with nothing naming it (LibreChat and Dub, in a
|
|
49
|
+
// browser tab); the parts are the app-url module's load, the World's status read, the record's locked write, and
|
|
50
|
+
// the ready text.
|
|
51
|
+
const returned = Date.now();
|
|
52
|
+
let tail = '';
|
|
39
53
|
if (appUrl !== undefined) {
|
|
40
54
|
const { setAppUrl } = await import("./app-url.js");
|
|
41
|
-
|
|
55
|
+
const loaded = Date.now();
|
|
56
|
+
const timings = {};
|
|
57
|
+
setAppUrl(instance.name, appUrl, { root, timings });
|
|
42
58
|
process.stderr.write(`volter-world up ${instance.name}: app url recorded: ${appUrl}\n`);
|
|
59
|
+
tail = `app-url module loaded in ${loaded - returned} ms, status read in ${timings.statusMs ?? '?'} ms, record written in ${timings.writeMs ?? '?'} ms, `;
|
|
43
60
|
}
|
|
61
|
+
const printing = Date.now();
|
|
44
62
|
if (rest.includes('--json'))
|
|
45
63
|
process.stdout.write(JSON.stringify(instance, null, 2) + '\n');
|
|
46
64
|
else
|
|
47
65
|
printReady(instance, !keepState);
|
|
66
|
+
process.stderr.write(`volter-world up ${instance.name}: after the boot returned: ${tail}ready text in ${Date.now() - printing} ms, ${Date.now() - returned} ms in all\n`);
|
|
67
|
+
if (attachAfterLine) {
|
|
68
|
+
await waitForAttachLine();
|
|
69
|
+
const { attachWorld } = await import("./runtime.js");
|
|
70
|
+
process.exit(await attachWorld(instance.name, command, root, { cwd: process.cwd(), owner: ownerOption(rest), verbose: rest.includes('--verbose'), instance }));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Read only the admission line; the command inherits the same stdin afterward. */
|
|
74
|
+
function waitForAttachLine() {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
let line = '';
|
|
77
|
+
const cleanup = () => { process.stdin.off('readable', readable); process.stdin.off('end', ended); process.stdin.off('error', failed); };
|
|
78
|
+
const failed = (error) => { cleanup(); reject(error); };
|
|
79
|
+
const ended = () => failed(new Error('volter-world up: stdin ended before the attach line'));
|
|
80
|
+
const readable = () => {
|
|
81
|
+
let byte;
|
|
82
|
+
while ((byte = process.stdin.read(1)) !== null) {
|
|
83
|
+
if (byte[0] === 10) {
|
|
84
|
+
cleanup();
|
|
85
|
+
if (line.replace(/\r$/, '') !== 'attach')
|
|
86
|
+
reject(new Error('volter-world up: expected stdin line "attach"'));
|
|
87
|
+
else
|
|
88
|
+
resolve();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
line += byte.toString();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
process.stdin.on('readable', readable);
|
|
95
|
+
process.stdin.once('end', ended);
|
|
96
|
+
process.stdin.once('error', failed);
|
|
97
|
+
readable();
|
|
98
|
+
});
|
|
48
99
|
}
|
|
49
100
|
async function upInProcess(configId, options) {
|
|
101
|
+
const loading = performance.now();
|
|
50
102
|
const { upWorld } = await import("./runtime.js");
|
|
103
|
+
process.stderr.write(`volter-world up: runtime modules loaded in ${(performance.now() - loading).toFixed(1)} ms\n`);
|
|
51
104
|
const abort = new AbortController();
|
|
52
105
|
const cancel = (signal) => abort.abort(signal);
|
|
53
106
|
process.on('SIGINT', cancel);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.165",
|
|
4
4
|
"description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"node": ">=22.3"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
|
-
"@volter/world-core": "3.0.
|
|
73
|
+
"@volter/world-core": "3.0.156",
|
|
74
74
|
"@volter/world-console": "3.0.151"
|
|
75
75
|
},
|
|
76
76
|
"dependencies": {
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"@volter/browser-runtime": "0.1.104",
|
|
81
81
|
"@volter/browser-sandboxes": "0.1.84",
|
|
82
82
|
"@volter/world-access": "3.0.1",
|
|
83
|
-
"@volter/world-core": "3.0.
|
|
83
|
+
"@volter/world-core": "3.0.156",
|
|
84
84
|
"modern-screenshot": "4.7.0",
|
|
85
85
|
"mongodb": "~6.20.0",
|
|
86
86
|
"pg-gateway": "0.3.0-beta.4",
|
package/src/ancestry.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// carry parent-side pins, including clones across checkouts, without a global World registry.
|
|
3
3
|
import { existsSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { join, relative, resolve, sep } from 'node:path';
|
|
5
|
-
import { assertStateRemovable, readBranchMeta, stateDirName, withAncestryLock, withStateRemoval, getActiveWorldStore, worldPaths, worldStateRoot } from '@volter/world-core/runtime';
|
|
5
|
+
import { assertStateRemovable, readBranchMeta, stateDirName, withAncestryLock, withStateRemoval, getActiveWorldStore, worldPaths, worldStateRoot } from '@volter/world-core/runtime-state';
|
|
6
6
|
|
|
7
7
|
function checkWorldAncestry(root: string, path: string): void {
|
|
8
8
|
const base = join(root, stateDirName(), 'worlds');
|
|
@@ -28,7 +28,7 @@ function checkWorldAncestry(root: string, path: string): void {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export function assertWorldStateRemovable(root: string, path: string): void {
|
|
31
|
-
withAncestryLock(() => { checkWorldAncestry(root, path); assertStateRemovable(path); });
|
|
31
|
+
withAncestryLock(() => { checkWorldAncestry(root, path); assertStateRemovable(path); }, { operation: 'removal preflight' });
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export function removeWorldState<T>(root: string, path: string, remove: () => T): T {
|
package/src/app-url.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// instance it described.
|
|
22
22
|
import { errorMessage } from './error-message.ts';
|
|
23
23
|
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
24
|
-
import { withFileLock } from '@volter/world-core/runtime';
|
|
24
|
+
import { withFileLock } from '@volter/world-core/runtime-state';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
26
|
import { spawnSync } from 'node:child_process';
|
|
27
27
|
import net from 'node:net';
|
|
@@ -113,11 +113,18 @@ function assertHttpUrl(url: string): URL {
|
|
|
113
113
|
/** Record `url` as the app endpoint of the RUNNING/booted instance `name`. The instance must
|
|
114
114
|
* exist (a record for a world that was never upped would describe nothing); the app itself is
|
|
115
115
|
* deliberately NOT probed — the booter declaring "this is where I put the app" is the truth. */
|
|
116
|
-
export function setAppUrl(name: string, url: string, options: { root?: string; via?: Exclude<AppUrlSource, 'service'>; detail?: string; app?: string } = {}): AppUrlRecord {
|
|
116
|
+
export function setAppUrl(name: string, url: string, options: { root?: string; via?: Exclude<AppUrlSource, 'service'>; detail?: string; app?: string; /** An instrument: filled with how long the World's status and the record's write each took. */ timings?: { statusMs?: number; writeMs?: number } } = {}): AppUrlRecord {
|
|
117
117
|
const root = options.root ?? process.cwd();
|
|
118
118
|
const app = appName(options.app);
|
|
119
|
+
const began = Date.now();
|
|
119
120
|
const status = statusWorld(name, root); // throws "World instance not found" — the loud path
|
|
121
|
+
const statusRead = Date.now();
|
|
122
|
+
if (options.timings) options.timings.statusMs = statusRead - began;
|
|
120
123
|
assertHttpUrl(url);
|
|
124
|
+
try { return setAppUrlEntry(root, name, app, url, status, options); }
|
|
125
|
+
finally { if (options.timings) options.timings.writeMs = Date.now() - statusRead; }
|
|
126
|
+
}
|
|
127
|
+
function setAppUrlEntry(root: string, name: string, app: string, url: string, status: ReturnType<typeof statusWorld>, options: { via?: Exclude<AppUrlSource, 'service'>; detail?: string }): AppUrlRecord {
|
|
121
128
|
return writeEntry(root, name, app, (file) => {
|
|
122
129
|
// a new URL keeps the hosts recorded for the app (they name the app, not where it listens)
|
|
123
130
|
const hosts = entryOf(file, app)?.hosts;
|