@volter/world-runtime 2.0.0 → 2.0.2
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/known-external-services.json +0 -8
- package/dist/src/app-url.js +1 -1
- package/dist/src/branch.js +79 -2
- package/dist/src/catalog.js +1 -1
- package/dist/src/cli.js +50 -22
- package/dist/src/console-apart.d.ts +1 -0
- package/dist/src/console-apart.js +7 -0
- package/dist/src/covers.js +4 -2
- package/dist/src/fixture-env.d.ts +3 -0
- package/dist/src/fixture-env.js +24 -0
- package/dist/src/host-worker.js +2 -9
- package/dist/src/host.js +2 -9
- package/dist/src/import-module.d.ts +1 -0
- package/dist/src/import-module.js +16 -0
- package/dist/src/index.d.ts +7 -2
- package/dist/src/index.js +4 -1
- package/dist/src/infra-cli.js +61 -12
- package/dist/src/init.d.ts +1 -1
- package/dist/src/init.js +34 -5
- package/dist/src/local-branches.d.ts +37 -0
- package/dist/src/local-branches.js +193 -0
- package/dist/src/pglite-backing.d.ts +8 -0
- package/dist/src/pglite-backing.js +121 -35
- package/dist/src/pglite-host.mjs +520 -14
- package/dist/src/prerequisites.js +1 -1
- package/dist/src/process-groups.js +1 -1
- package/dist/src/redirect-proxy.d.ts +1 -1
- package/dist/src/redirect-proxy.js +4 -4
- package/dist/src/redis-backing.d.ts +8 -0
- package/dist/src/redis-backing.js +120 -0
- package/dist/src/root.d.ts +23 -0
- package/dist/src/root.js +22 -10
- package/dist/src/run-task.js +1 -1
- package/dist/src/runtime.d.ts +1 -0
- package/dist/src/runtime.js +37 -20
- package/dist/src/schema.d.ts +5 -0
- package/dist/src/schema.js +10 -1
- package/dist/src/served-world.d.ts +196 -9
- package/dist/src/served-world.js +847 -103
- package/dist/src/service-recorder.js +1 -1
- package/dist/src/storage-capacity.js +2 -2
- package/dist/src/up-task.js +1 -1
- package/dist/src/world-origins.d.ts +13 -0
- package/dist/src/world-origins.js +37 -0
- package/dist/src/world-view.d.ts +25 -0
- package/dist/src/world-view.js +110 -0
- package/known-external-services.json +0 -8
- package/package.json +10 -4
- package/src/app-url.ts +1 -1
- package/src/branch.ts +66 -2
- package/src/catalog.ts +1 -1
- package/src/cli.ts +43 -21
- package/src/console-apart.ts +7 -1
- package/src/covers.ts +4 -2
- package/src/fixture-env.ts +25 -0
- package/src/host-worker.ts +2 -1
- package/src/host.ts +2 -1
- package/src/import-module.ts +9 -0
- package/src/index.ts +7 -2
- package/src/infra-cli.ts +56 -12
- package/src/init.ts +34 -5
- package/src/local-branches.ts +173 -0
- package/src/pglite-backing.ts +112 -36
- package/src/pglite-host.mjs +520 -14
- package/src/prerequisites.ts +1 -1
- package/src/process-groups.ts +1 -1
- package/src/redirect-proxy.ts +4 -4
- package/src/redis-backing.ts +107 -0
- package/src/root.ts +27 -2
- package/src/run-task.ts +1 -1
- package/src/runtime.ts +38 -20
- package/src/schema.ts +11 -1
- package/src/served-world.ts +762 -85
- package/src/service-recorder.ts +1 -1
- package/src/storage-capacity.ts +2 -2
- package/src/up-task.ts +1 -1
- package/src/world-origins.ts +38 -0
- package/src/world-view.ts +100 -0
package/dist/src/infra-cli.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { existsSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import { infraConnections, parseInfraDefinition } from "./pglite-backing.js";
|
|
8
9
|
const phase = process.argv[2];
|
|
9
10
|
if (phase !== 'up' && phase !== 'status' && phase !== 'down') {
|
|
@@ -22,7 +23,7 @@ if (!existsSync(definition)) {
|
|
|
22
23
|
process.exit(2);
|
|
23
24
|
}
|
|
24
25
|
const base = ['compose', '-f', definition];
|
|
25
|
-
const run = (args) => spawnSync('docker', [...base, ...args], {
|
|
26
|
+
const run = (args) => spawnSync('docker', [...base, ...args], { windowsHide: true,
|
|
26
27
|
encoding: 'utf8',
|
|
27
28
|
env: { ...process.env, VOLTER_WORLD_DATA: worldData },
|
|
28
29
|
timeout: 120_000,
|
|
@@ -44,8 +45,9 @@ const fail = (result) => {
|
|
|
44
45
|
// runtime answers it is private and chosen here, per machine, at each phase:
|
|
45
46
|
// 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite — explicit, for tests/operators;
|
|
46
47
|
// 2. a working container runtime — the compose path, byte-identical to before;
|
|
47
|
-
// 3. no container runtime + a postgres
|
|
48
|
-
// (pglite-
|
|
48
|
+
// 3. no container runtime + a definition of postgres and/or mongodb services —
|
|
49
|
+
// the containerless backing (pglite-backing.ts: PGlite for postgres, the
|
|
50
|
+
// MongoDB twin for mongodb), announced loudly;
|
|
49
51
|
// 4. otherwise the honest refusal naming what this machine cannot serve.
|
|
50
52
|
function selectBacking() {
|
|
51
53
|
const forced = process.env.VOLTER_WORLD_INFRA_BACKING;
|
|
@@ -55,7 +57,7 @@ function selectBacking() {
|
|
|
55
57
|
process.stderr.write(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker or pglite (got ${JSON.stringify(forced)})\n`);
|
|
56
58
|
process.exit(2);
|
|
57
59
|
}
|
|
58
|
-
const probe = spawnSync('docker', ['info', '--format', '{{.ServerVersion}}'], { encoding: 'utf8', timeout: 10_000 });
|
|
60
|
+
const probe = spawnSync('docker', ['info', '--format', '{{.ServerVersion}}'], { windowsHide: true, encoding: 'utf8', timeout: 10_000 });
|
|
59
61
|
if (probe.status === 0)
|
|
60
62
|
return 'docker';
|
|
61
63
|
// Absence of a container runtime (no binary, no daemon) is a capability
|
|
@@ -69,7 +71,7 @@ function selectBacking() {
|
|
|
69
71
|
}
|
|
70
72
|
async function runPglite() {
|
|
71
73
|
const { unsupportedKinds, pgliteUp, pgliteStatus, pgliteDown } = await import("./pglite-backing.js");
|
|
72
|
-
const services = parseInfraDefinition(readFileSync(definition, 'utf8'));
|
|
74
|
+
const services = parseInfraDefinition(readFileSync(definition, 'utf8')).filter((s) => composed === null || composed.includes(s.kind));
|
|
73
75
|
if (services.length === 0) {
|
|
74
76
|
process.stderr.write('managed infrastructure: the declared definition names no services\n');
|
|
75
77
|
process.exit(1);
|
|
@@ -80,7 +82,7 @@ async function runPglite() {
|
|
|
80
82
|
process.exit(1);
|
|
81
83
|
}
|
|
82
84
|
if (phase === 'up') {
|
|
83
|
-
process.stdout.write('managed infrastructure backing:
|
|
85
|
+
process.stdout.write('managed infrastructure backing: containerless (no container runtime)\n');
|
|
84
86
|
try {
|
|
85
87
|
await pgliteUp(services, worldData);
|
|
86
88
|
}
|
|
@@ -97,18 +99,65 @@ async function runPglite() {
|
|
|
97
99
|
process.stderr.write(`managed infrastructure status failed: ${status.ready}/${services.length} declared services are ready\n`);
|
|
98
100
|
process.exit(1);
|
|
99
101
|
}
|
|
100
|
-
process.stdout.write(`${JSON.stringify({ ok: true, services:
|
|
102
|
+
process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
|
|
101
103
|
process.exit(0);
|
|
102
104
|
}
|
|
103
105
|
await pgliteDown(services, worldData);
|
|
104
106
|
process.stdout.write('managed infrastructure stopped\n');
|
|
105
107
|
process.exit(0);
|
|
106
108
|
}
|
|
109
|
+
// ---- redis: the twin, containerless ----------------------------------------
|
|
110
|
+
// A redis service is served by the redis twin (redis-backing.ts) on every machine, container runtime or not,
|
|
111
|
+
// unless the operator forces the container backing; the other services take the backing chosen above. Where the
|
|
112
|
+
// twin is not installed (@volter/twin-redis is not this runtime's dependency), redis goes to that backing too:
|
|
113
|
+
// the container where there is one, else the backing's refusal naming redis.
|
|
114
|
+
const declared = parseInfraDefinition(readFileSync(definition, 'utf8'));
|
|
115
|
+
const { redisTwinCli } = await import("./redis-backing.js");
|
|
116
|
+
const twinCli = redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(worldConfig)]);
|
|
117
|
+
const twinned = process.env.VOLTER_WORLD_INFRA_BACKING === 'docker' || twinCli === undefined ? [] : declared.filter((s) => s.kind === 'redis');
|
|
118
|
+
/** The services the backing below answers for: every one, or those the twin does not serve (by compose service name). */
|
|
119
|
+
const composed = twinned.length === 0 ? null : declared.filter((s) => s.kind !== 'redis').map((s) => s.kind);
|
|
120
|
+
if (twinned.length > 0)
|
|
121
|
+
await runRedisTwin();
|
|
122
|
+
async function runRedisTwin() {
|
|
123
|
+
const { redisTwinDown, redisTwinStatus, redisTwinUp } = await import("./redis-backing.js");
|
|
124
|
+
const cli = twinCli;
|
|
125
|
+
if (phase === 'up') {
|
|
126
|
+
process.stdout.write('managed infrastructure backing: redis twin (containerless)\n');
|
|
127
|
+
try {
|
|
128
|
+
await redisTwinUp(twinned, worldData, cli);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
process.stderr.write(`managed infrastructure up failed: ${String(error.message ?? error)}\n`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else if (phase === 'status') {
|
|
136
|
+
const ready = await redisTwinStatus(twinned, worldData);
|
|
137
|
+
if (ready !== twinned.length) {
|
|
138
|
+
process.stderr.write(`managed infrastructure status failed: ${ready}/${declared.length} declared services are ready\n`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
await redisTwinDown(twinned, worldData);
|
|
144
|
+
}
|
|
145
|
+
if (composed.length === 0) {
|
|
146
|
+
if (phase === 'up')
|
|
147
|
+
process.stdout.write('managed infrastructure ready\n');
|
|
148
|
+
else if (phase === 'status')
|
|
149
|
+
process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
|
|
150
|
+
else
|
|
151
|
+
process.stdout.write('managed infrastructure stopped\n');
|
|
152
|
+
process.exit(0);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
107
155
|
if (selectBacking() === 'pglite') {
|
|
108
156
|
await runPglite();
|
|
109
157
|
}
|
|
158
|
+
const only = composed ?? [];
|
|
110
159
|
if (phase === 'up') {
|
|
111
|
-
const result = run(['up', '-d', '--wait']);
|
|
160
|
+
const result = run(['up', '-d', '--wait', ...only]);
|
|
112
161
|
if (result.status !== 0)
|
|
113
162
|
fail(result);
|
|
114
163
|
process.stdout.write('managed infrastructure ready\n');
|
|
@@ -120,13 +169,13 @@ else if (phase === 'status') {
|
|
|
120
169
|
const running = run(['ps', '--status', 'running', '--services']);
|
|
121
170
|
if (running.status !== 0)
|
|
122
171
|
fail(running);
|
|
123
|
-
const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).sort();
|
|
124
|
-
const runningNames = running.stdout.split(/\s+/u).filter(Boolean).sort();
|
|
172
|
+
const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
|
|
173
|
+
const runningNames = running.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
|
|
125
174
|
if (expectedNames.length === 0 || expectedNames.join('\0') !== runningNames.join('\0')) {
|
|
126
|
-
process.stderr.write(`managed infrastructure status failed: ${runningNames.length}/${expectedNames.length} declared services are ready\n`);
|
|
175
|
+
process.stderr.write(`managed infrastructure status failed: ${runningNames.length + twinned.length}/${expectedNames.length + twinned.length} declared services are ready\n`);
|
|
127
176
|
process.exit(1);
|
|
128
177
|
}
|
|
129
|
-
process.stdout.write(`${JSON.stringify({ ok: true, services: expectedNames.length, connections: infraConnections(
|
|
178
|
+
process.stdout.write(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length, connections: infraConnections(declared, process.env) })}\n`);
|
|
130
179
|
}
|
|
131
180
|
else {
|
|
132
181
|
const result = run(['down', '--remove-orphans']);
|
package/dist/src/init.d.ts
CHANGED
|
@@ -214,7 +214,7 @@ export declare function renderEnvFile(plan: InitPlan): string;
|
|
|
214
214
|
/** Write the plan's two artifacts. Byte-identical for the same repo + catalog: sorted keys, no
|
|
215
215
|
* timestamps, no absolute paths inside the files. */
|
|
216
216
|
/** What `.volter/.gitignore` says: the story is committed, the running state never is. */
|
|
217
|
-
export declare const STATE_GITIGNORE = "# Volter: the world's running state and live env \u2014 never committed. world.json, handlers/ and seeds/ are the story.\nworlds/\ncurrent\n*.env\ncredentials/\ntoken\n";
|
|
217
|
+
export declare const STATE_GITIGNORE = "# Volter: the world's running state and live env \u2014 never committed. world.json, handlers/ and seeds/ are the story.\nworlds/\ncurrent\n*.env\ncredentials/\ntoken\ntoken.read\nsessions.json\nkeys.json\n";
|
|
218
218
|
export declare function writeWorldInit(plan: InitPlan, options?: {
|
|
219
219
|
force?: boolean;
|
|
220
220
|
}): void;
|
package/dist/src/init.js
CHANGED
|
@@ -37,7 +37,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
37
37
|
import { PROTOCOL_MAJOR, stateDirName } from '@volter/world-core';
|
|
38
38
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
39
39
|
import { coverWorld, detectRepoVendors, envNameVendor, formatCoverageReport, injectorEnvNameForKey, injectorVendorKeysFor, isCredentialShapedEnvName, registryAcknowledgedReason, } from "./covers.js";
|
|
40
|
-
import { fakeEnvValue, isGoogleOAuthClientEnvName, isGoogleServiceAccountEnvName } from "./fixture-env.js";
|
|
40
|
+
import { fakeEnvValue, isAppKeyName, isGoogleOAuthClientEnvName, isGoogleServiceAccountEnvName } from "./fixture-env.js";
|
|
41
41
|
import { resolveCatalog, twinPackageName } from "./catalog.js";
|
|
42
42
|
import { projectEnvReads, projectManifestDirs } from "./project-inspect.js";
|
|
43
43
|
import { overlayEndpointEnv, packFacts } from "./pack-facts.js";
|
|
@@ -119,6 +119,20 @@ export const APP_READ_ENDPOINT_ENV = {
|
|
|
119
119
|
+ 'sends there. Read the mail back over the twin-only inspect sidecar: `world-smtp serve --inspect-port N`, then '
|
|
120
120
|
+ 'GET http://127.0.0.1:N/twin/messages/latest.',
|
|
121
121
|
},
|
|
122
|
+
// RAW PROTOCOL, the gRPC kind. The Temporal SDKs dial the frontend address they are given
|
|
123
|
+
// (`Connection.connect({ address })`, `NativeConnection.connect({ address })`), conventionally read
|
|
124
|
+
// from TEMPORAL_ADDRESS — Postiz's temporal.module.ts, the SDK samples and the Temporal CLI all read
|
|
125
|
+
// that name — and speak gRPC over HTTP/2, which the http/fetch injector never sees. The value is an
|
|
126
|
+
// address (host:port), not a URL, which is why this lives here as a template and not as the
|
|
127
|
+
// descriptor's `endpointEnv` (whose `name` receives the twin's URL).
|
|
128
|
+
temporal: {
|
|
129
|
+
injectEnvTemplates: {
|
|
130
|
+
TEMPORAL_ADDRESS: '${host}:${port}',
|
|
131
|
+
},
|
|
132
|
+
note: 'no injector entry and none possible: the Temporal SDKs speak gRPC to the address they are configured with. '
|
|
133
|
+
+ 'TEMPORAL_ADDRESS=<host>:<port> points them at the twin (namespace `default` exists); an app that sets '
|
|
134
|
+
+ 'TEMPORAL_API_KEY turns TLS on in the SDK, which this loopback frontend does not speak — leave it unset.',
|
|
135
|
+
},
|
|
122
136
|
};
|
|
123
137
|
// descriptor-first migration (adding-a-twin.md §3): packs now declare their endpoint-env wiring (with its grounding
|
|
124
138
|
// note) on the descriptor (`endpointEnv` on TwinPack); the table above shrinks toward empty as
|
|
@@ -257,7 +271,7 @@ const INFRA_PLACEHOLDER = {
|
|
|
257
271
|
//
|
|
258
272
|
// The subject pilots (dub, cal.com) put the number on it: world-side setup is seconds, and the
|
|
259
273
|
// minutes go to the app side — most avoidably, to hand-writing infrastructure for the Postgres/
|
|
260
|
-
// MySQL/Redis the repo signalled. For the kinds below, `init` upgrades the placeholder to a
|
|
274
|
+
// MySQL/Redis/MongoDB the repo signalled. For the kinds below, `init` upgrades the placeholder to a
|
|
261
275
|
// World-managed service and private definition with env URLs already pointing at it. The helper
|
|
262
276
|
// owns its implementation behind the declared service boundary. Kinds without a recipe keep the
|
|
263
277
|
// placeholder + `//infra` stub.
|
|
@@ -285,6 +299,15 @@ const COMPOSE_INFRA = {
|
|
|
285
299
|
volumePath: '/data',
|
|
286
300
|
healthcheck: () => ['CMD', 'redis-cli', 'ping'],
|
|
287
301
|
},
|
|
302
|
+
// Without a container runtime the containerless backing serves this kind with the MongoDB twin
|
|
303
|
+
// (@volter/twin-mongodb), at the same loopback port and URL.
|
|
304
|
+
mongodb: {
|
|
305
|
+
image: 'mongo:7',
|
|
306
|
+
containerPort: 27017,
|
|
307
|
+
memoryMiB: 1024,
|
|
308
|
+
volumePath: '/data/db',
|
|
309
|
+
healthcheck: () => ['CMD', 'mongosh', '--quiet', '--eval', "db.adminCommand('ping').ok"],
|
|
310
|
+
},
|
|
288
311
|
};
|
|
289
312
|
/** FNV-1a 32-bit — a tiny, dependency-free stable string hash for port derivation. */
|
|
290
313
|
function fnv1a(text) {
|
|
@@ -326,9 +349,12 @@ function composeService(name, kind, signals, taken) {
|
|
|
326
349
|
const recipe = COMPOSE_INFRA[kind];
|
|
327
350
|
const hostPort = composePort(name, kind, taken);
|
|
328
351
|
const ident = composeIdent(name);
|
|
352
|
+
// redis and mongodb run without authentication (no credentials in their images' env), so their URLs carry none
|
|
329
353
|
const url = kind === 'redis'
|
|
330
354
|
? `redis://127.0.0.1:${hostPort}`
|
|
331
|
-
:
|
|
355
|
+
: kind === 'mongodb'
|
|
356
|
+
? `mongodb://127.0.0.1:${hostPort}/${ident}`
|
|
357
|
+
: `${kind}://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`;
|
|
332
358
|
return {
|
|
333
359
|
kind,
|
|
334
360
|
image: recipe.image,
|
|
@@ -340,7 +366,7 @@ function composeService(name, kind, signals, taken) {
|
|
|
340
366
|
signals,
|
|
341
367
|
};
|
|
342
368
|
}
|
|
343
|
-
/** The declared env of one compose service (empty for redis). */
|
|
369
|
+
/** The declared env of one compose service (empty for redis and mongodb). */
|
|
344
370
|
function composeEnvironment(name, kind) {
|
|
345
371
|
const ident = composeIdent(name);
|
|
346
372
|
if (kind === 'postgres') {
|
|
@@ -661,7 +687,7 @@ export function planWorldInit(name, repoPath, options = {}) {
|
|
|
661
687
|
// RULE 3: credential-shaped means faked, even when no vendor claims the stem. That covers
|
|
662
688
|
// app-local secrets (JWT_SECRET, ENCRYPTION_KEY) and untwinned vendors alike — and it is the
|
|
663
689
|
// reason a live key committed to `.env.example` can never reach the emitted world.
|
|
664
|
-
if (isCredentialShapedEnvName(name)) {
|
|
690
|
+
if (isCredentialShapedEnvName(name) || isAppKeyName(name)) {
|
|
665
691
|
env[name] = fakeEnvValue(name);
|
|
666
692
|
envRows.push({ name, disposition: 'faked', source, reason: 'credential-shaped name — the example value is never copied' });
|
|
667
693
|
continue;
|
|
@@ -937,6 +963,9 @@ current
|
|
|
937
963
|
*.env
|
|
938
964
|
credentials/
|
|
939
965
|
token
|
|
966
|
+
token.read
|
|
967
|
+
sessions.json
|
|
968
|
+
keys.json
|
|
940
969
|
`;
|
|
941
970
|
export function writeWorldInit(plan, options = {}) {
|
|
942
971
|
// A world already here is refused unless forced; a `.volter/` holding only running state
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type BranchDoors, type BranchRow, type MountedWorld } from './served-world.js';
|
|
2
|
+
export type LocalBranchesOptions = {
|
|
3
|
+
/** where branches live: `<dir>/<org>/<world>/` */
|
|
4
|
+
dir: string;
|
|
5
|
+
/** the URL a branch reaches its parent at (the host's own listener, on loopback) */
|
|
6
|
+
origin: () => string;
|
|
7
|
+
/** the Worlds the host serves, by served name; branches join it while they live */
|
|
8
|
+
worlds: Map<string, MountedWorld>;
|
|
9
|
+
/** mount a World at a root, with its branches doors wired */
|
|
10
|
+
mount: (root: string) => Promise<MountedWorld>;
|
|
11
|
+
announce?: (line: string) => void;
|
|
12
|
+
};
|
|
13
|
+
export declare class LocalBranches {
|
|
14
|
+
private readonly o;
|
|
15
|
+
private sweeper;
|
|
16
|
+
constructor(o: LocalBranchesOptions);
|
|
17
|
+
private record;
|
|
18
|
+
/** The branches of `parent` this host holds. */
|
|
19
|
+
list(parent: string): BranchRow[];
|
|
20
|
+
/** Branches being made, by parent: a parent is not removed mid-clone. */
|
|
21
|
+
private readonly making;
|
|
22
|
+
hasBranches(parent: string): boolean;
|
|
23
|
+
/** A World's branches doors, answered by this host. */
|
|
24
|
+
doorsFor(parent: string): BranchDoors;
|
|
25
|
+
private create;
|
|
26
|
+
private make;
|
|
27
|
+
/** The parent's frozen clock, when it has one. */
|
|
28
|
+
private frozenClock;
|
|
29
|
+
/** Stop a branch and remove its World (a branch's own branches first). */
|
|
30
|
+
remove(name: string): Promise<void>;
|
|
31
|
+
/** Remove the branches whose time ran out, now and every minute while the host serves. */
|
|
32
|
+
resume(): Promise<void>;
|
|
33
|
+
/** One sweep at a time: a slow one never overlaps the next. */
|
|
34
|
+
private sweeping;
|
|
35
|
+
private sweep;
|
|
36
|
+
stop(): void;
|
|
37
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// LOCAL BRANCHES: the branches a local host makes of the Worlds it serves (docs/contributing/architecture.md,
|
|
2
|
+
// "Viewing a World"). A branch is another World: the parent's config under a new name, cloned from the
|
|
3
|
+
// parent through its own doors (its history cut at an instant, when one is asked), its clock frozen at
|
|
4
|
+
// that instant so no twin's catch-up walks it forward, and removed when its time runs out. world-host
|
|
5
|
+
// and `volter world view` both make them with this; a hosted World's supervisor makes its own.
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { stateDirName, withStateRemoval } from '@volter/world-core';
|
|
9
|
+
import { clockFile } from "./runtime.js";
|
|
10
|
+
import { loadWorldConfig } from "./configs.js";
|
|
11
|
+
import { TOKEN_HEADER } from "./served-world.js";
|
|
12
|
+
const RECORD = 'branch.json';
|
|
13
|
+
const SWEEP_MS = 60_000;
|
|
14
|
+
/** A World's manifest under another name, in the manifest's own format: format 2 names a World in
|
|
15
|
+
* `metadata.id` and serves it bare at `serving.name`; format 1 in `id` and `bare.name`. */
|
|
16
|
+
function renamed(config, id, name) {
|
|
17
|
+
if (config.schemaVersion === 2) {
|
|
18
|
+
return { ...config, metadata: { ...config.metadata, id }, serving: { ...config.serving, mode: 'bare', name } };
|
|
19
|
+
}
|
|
20
|
+
return { ...config, id, bare: { ...config.bare, name } };
|
|
21
|
+
}
|
|
22
|
+
export class LocalBranches {
|
|
23
|
+
o;
|
|
24
|
+
sweeper = null;
|
|
25
|
+
constructor(o) {
|
|
26
|
+
this.o = o;
|
|
27
|
+
}
|
|
28
|
+
record(world) {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(join(world.root, stateDirName(), RECORD), 'utf8'));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** The branches of `parent` this host holds. */
|
|
37
|
+
list(parent) {
|
|
38
|
+
const rows = [];
|
|
39
|
+
for (const [name, world] of this.o.worlds) {
|
|
40
|
+
const r = this.record(world);
|
|
41
|
+
if (r?.from === parent)
|
|
42
|
+
rows.push({ name, from: r.from, at: r.at, createdAt: r.createdAt, expiresAt: r.expiresAt });
|
|
43
|
+
}
|
|
44
|
+
return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
45
|
+
}
|
|
46
|
+
/** Branches being made, by parent: a parent is not removed mid-clone. */
|
|
47
|
+
making = new Map();
|
|
48
|
+
hasBranches(parent) { return (this.making.get(parent) ?? 0) > 0 || this.list(parent).length > 0; }
|
|
49
|
+
/** A World's branches doors, answered by this host. */
|
|
50
|
+
doorsFor(parent) {
|
|
51
|
+
return {
|
|
52
|
+
list: async () => this.list(parent),
|
|
53
|
+
create: (at, ttl, _origin, parentKey) => this.create(parent, at, ttl, parentKey),
|
|
54
|
+
remove: async (name) => { const w = this.o.worlds.get(name); if (!w || this.record(w)?.from !== parent)
|
|
55
|
+
return false; await this.remove(name); return true; },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
async create(parent, at, ttlSeconds, parentKey) {
|
|
59
|
+
this.making.set(parent, (this.making.get(parent) ?? 0) + 1);
|
|
60
|
+
try {
|
|
61
|
+
return await this.make(parent, at, ttlSeconds, parentKey);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
const left = (this.making.get(parent) ?? 1) - 1;
|
|
65
|
+
if (left > 0)
|
|
66
|
+
this.making.set(parent, left);
|
|
67
|
+
else
|
|
68
|
+
this.making.delete(parent);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async make(parent, at, ttlSeconds, parentKey) {
|
|
72
|
+
const from = this.o.worlds.get(parent);
|
|
73
|
+
if (!from)
|
|
74
|
+
throw new Error(`no world ${parent} here`);
|
|
75
|
+
const [org, world] = parent.split('/');
|
|
76
|
+
const stamp = at.instant ? at.instant.replace(/[-:]/g, '').replace(/\.\d+Z$|Z$/, '').toLowerCase().slice(0, 13) : 'now';
|
|
77
|
+
// a labelled branch (a pull request's preview) is named for its label; the random tail keeps a replacement distinct
|
|
78
|
+
// a name already taken (a 4-hex tail's rare clash) is passed over for a fresh tail, never made over
|
|
79
|
+
const free = () => {
|
|
80
|
+
for (let tries = 0; tries < 8; tries++) {
|
|
81
|
+
const suffix = `${at.label ? `-${at.label}` : `-at-${stamp}`}-${Buffer.from(crypto.getRandomValues(new Uint8Array(2))).toString('hex')}`;
|
|
82
|
+
const id = `${world.slice(0, 64 - suffix.length)}${suffix}`;
|
|
83
|
+
const name = `${org}/${id}`;
|
|
84
|
+
const root = join(this.o.dir, org, id);
|
|
85
|
+
if (!this.o.worlds.has(name) && !existsSync(root))
|
|
86
|
+
return { id, name, root };
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
};
|
|
90
|
+
const picked = free();
|
|
91
|
+
if (!picked)
|
|
92
|
+
throw new Error(`no free name for a branch of ${parent}`);
|
|
93
|
+
const { id, name, root } = picked;
|
|
94
|
+
// the parent's config under the branch's own name: the same twins, served at the branch's place
|
|
95
|
+
mkdirSync(join(root, stateDirName()), { recursive: true });
|
|
96
|
+
writeFileSync(join(root, stateDirName(), 'world.json'), `${JSON.stringify(renamed(JSON.parse(readFileSync(join(from.root, stateDirName(), 'world.json'), 'utf8')), id, name), null, 2)}\n`);
|
|
97
|
+
let mounted;
|
|
98
|
+
try {
|
|
99
|
+
mounted = await this.o.mount(root);
|
|
100
|
+
await mounted.boot(this.o.origin());
|
|
101
|
+
this.o.worlds.set(name, mounted);
|
|
102
|
+
const origin = this.o.origin();
|
|
103
|
+
// each twin's history cut at the instant, by the parent's own history door
|
|
104
|
+
let views;
|
|
105
|
+
if (at.instant) {
|
|
106
|
+
const cut = await from.handle(new Request(`${origin}/-/${parent}/history?at=${encodeURIComponent(at.instant)}`, { headers: { [TOKEN_HEADER]: from.token } }));
|
|
107
|
+
if (!cut.ok)
|
|
108
|
+
throw new Error(`the history of ${parent} at ${at.instant}: ${await cut.text()}`);
|
|
109
|
+
views = (await cut.json()).views;
|
|
110
|
+
}
|
|
111
|
+
const cloned = await mounted.handle(new Request(`${origin}/-/${name}/origin`, { method: 'PUT', headers: { [TOKEN_HEADER]: mounted.token, 'content-type': 'application/json' }, body: JSON.stringify({ url: `${origin}/${parent}`, token: parentKey ?? from.token, ...(views ? { views } : {}) }) })); // the branch's own key to its parent (the parent's doors make it), never the parent's token
|
|
112
|
+
if (!cloned.ok)
|
|
113
|
+
throw new Error(`cloning ${parent}: ${await cloned.text()}`);
|
|
114
|
+
// the branch's clock stands at its instant, else at the parent's: no twin's catch-up walks it on
|
|
115
|
+
// (a parent whose clock was never set: the branch's stands at now, frozen there, as http-api says);
|
|
116
|
+
// a live branch (a pull request's preview) keeps the parent's time: real time, or its simulated clock
|
|
117
|
+
const clock = at.instant ?? this.frozenClock(from) ?? (at.live ? null : new Date().toISOString());
|
|
118
|
+
if (clock) {
|
|
119
|
+
const file = clockFile(root, id);
|
|
120
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
121
|
+
writeFileSync(file, `${clock}\n`);
|
|
122
|
+
}
|
|
123
|
+
const createdAt = new Date().toISOString();
|
|
124
|
+
const expiresAt = ttlSeconds === null ? null : new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
125
|
+
writeFileSync(join(root, stateDirName(), RECORD), `${JSON.stringify({ from: parent, at: at.instant ? { instant: at.instant } : null, createdAt, expiresAt }, null, 2)}\n`);
|
|
126
|
+
this.o.announce?.(`branch ${name} of ${parent}${at.instant ? ` as of ${at.instant}` : ''}${expiresAt ? `, until ${expiresAt}` : ''}`);
|
|
127
|
+
return { name, token: mounted.token, readToken: mounted.readToken, expiresAt };
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
this.o.worlds.delete(name);
|
|
131
|
+
try {
|
|
132
|
+
await mounted?.stop();
|
|
133
|
+
}
|
|
134
|
+
catch { /* reported below */ }
|
|
135
|
+
withStateRemoval(root, () => rmSync(root, { recursive: true, force: true }));
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** The parent's frozen clock, when it has one. */
|
|
140
|
+
frozenClock(world) {
|
|
141
|
+
try {
|
|
142
|
+
const held = readFileSync(clockFile(world.root, loadWorldConfig(join(world.root, stateDirName(), 'world.json'), world.root).config.id), 'utf8').trim();
|
|
143
|
+
return Number.isNaN(Date.parse(held)) ? null : held;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Stop a branch and remove its World (a branch's own branches first). */
|
|
150
|
+
async remove(name) {
|
|
151
|
+
const world = this.o.worlds.get(name);
|
|
152
|
+
if (!world)
|
|
153
|
+
return;
|
|
154
|
+
for (const child of this.list(name))
|
|
155
|
+
await this.remove(child.name);
|
|
156
|
+
await world.stop();
|
|
157
|
+
withStateRemoval(world.root, () => rmSync(world.root, { recursive: true, force: true }));
|
|
158
|
+
this.o.worlds.delete(name);
|
|
159
|
+
this.o.announce?.(`removed ${name}`);
|
|
160
|
+
}
|
|
161
|
+
/** Remove the branches whose time ran out, now and every minute while the host serves. */
|
|
162
|
+
async resume() {
|
|
163
|
+
await this.sweep();
|
|
164
|
+
this.sweeper = setInterval(() => { void this.sweep(); }, SWEEP_MS);
|
|
165
|
+
this.sweeper.unref?.();
|
|
166
|
+
}
|
|
167
|
+
/** One sweep at a time: a slow one never overlaps the next. */
|
|
168
|
+
sweeping = false;
|
|
169
|
+
async sweep() {
|
|
170
|
+
if (this.sweeping)
|
|
171
|
+
return;
|
|
172
|
+
this.sweeping = true;
|
|
173
|
+
try {
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
for (const [name, world] of [...this.o.worlds]) {
|
|
176
|
+
const r = this.record(world);
|
|
177
|
+
if (r?.expiresAt && Date.parse(r.expiresAt) <= now && this.o.worlds.has(name)) {
|
|
178
|
+
try {
|
|
179
|
+
await this.remove(name);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
this.o.announce?.(`branch ${name}: removal failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
this.sweeping = false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
stop() { if (this.sweeper)
|
|
192
|
+
clearInterval(this.sweeper); this.sweeper = null; }
|
|
193
|
+
}
|
|
@@ -11,6 +11,14 @@ export declare function parseInfraDefinition(text: string): InfraService[];
|
|
|
11
11
|
/** Endpoints of the declared infrastructure, published through external.discover. Only URLs
|
|
12
12
|
* whose protocol and loopback port match a declared service belong to this lifecycle. */
|
|
13
13
|
export declare function infraConnections(services: InfraService[], env: Record<string, string | undefined>): Record<string, string>;
|
|
14
|
+
/** The MongoDB twin's cli: the package installed above the World's config (an app repo), else the one
|
|
15
|
+
* this runtime was installed with (its dependency, or the checkout's workspace link). */
|
|
16
|
+
export declare function mongodbTwinCli(worldConfig?: string, froms?: string[]): string;
|
|
17
|
+
/** The runtime a twin's cli runs under. A published package's cli is JavaScript and runs under this
|
|
18
|
+
* process's own runtime; a checkout's is TypeScript whose kernel needs more than Node's type
|
|
19
|
+
* stripping, so it runs under Bun (this process's, or the one on PATH) — the repository's toolchain —
|
|
20
|
+
* and without Bun it is refused by name rather than started to fail. */
|
|
21
|
+
export declare function twinRunner(cli: string, bunOnPath?: () => boolean, underBun?: boolean): string;
|
|
14
22
|
/** The kinds this backing cannot serve, or [] when it can serve the world. */
|
|
15
23
|
export declare function unsupportedKinds(services: InfraService[]): string[];
|
|
16
24
|
export declare function pgliteUp(services: InfraService[], dataDir: string): Promise<void>;
|