@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.
Files changed (78) hide show
  1. package/dist/known-external-services.json +0 -8
  2. package/dist/src/app-url.js +1 -1
  3. package/dist/src/branch.js +79 -2
  4. package/dist/src/catalog.js +1 -1
  5. package/dist/src/cli.js +50 -22
  6. package/dist/src/console-apart.d.ts +1 -0
  7. package/dist/src/console-apart.js +7 -0
  8. package/dist/src/covers.js +4 -2
  9. package/dist/src/fixture-env.d.ts +3 -0
  10. package/dist/src/fixture-env.js +24 -0
  11. package/dist/src/host-worker.js +2 -9
  12. package/dist/src/host.js +2 -9
  13. package/dist/src/import-module.d.ts +1 -0
  14. package/dist/src/import-module.js +16 -0
  15. package/dist/src/index.d.ts +7 -2
  16. package/dist/src/index.js +4 -1
  17. package/dist/src/infra-cli.js +61 -12
  18. package/dist/src/init.d.ts +1 -1
  19. package/dist/src/init.js +34 -5
  20. package/dist/src/local-branches.d.ts +37 -0
  21. package/dist/src/local-branches.js +193 -0
  22. package/dist/src/pglite-backing.d.ts +8 -0
  23. package/dist/src/pglite-backing.js +121 -35
  24. package/dist/src/pglite-host.mjs +520 -14
  25. package/dist/src/prerequisites.js +1 -1
  26. package/dist/src/process-groups.js +1 -1
  27. package/dist/src/redirect-proxy.d.ts +1 -1
  28. package/dist/src/redirect-proxy.js +4 -4
  29. package/dist/src/redis-backing.d.ts +8 -0
  30. package/dist/src/redis-backing.js +120 -0
  31. package/dist/src/root.d.ts +23 -0
  32. package/dist/src/root.js +22 -10
  33. package/dist/src/run-task.js +1 -1
  34. package/dist/src/runtime.d.ts +1 -0
  35. package/dist/src/runtime.js +37 -20
  36. package/dist/src/schema.d.ts +5 -0
  37. package/dist/src/schema.js +10 -1
  38. package/dist/src/served-world.d.ts +196 -9
  39. package/dist/src/served-world.js +847 -103
  40. package/dist/src/service-recorder.js +1 -1
  41. package/dist/src/storage-capacity.js +2 -2
  42. package/dist/src/up-task.js +1 -1
  43. package/dist/src/world-origins.d.ts +13 -0
  44. package/dist/src/world-origins.js +37 -0
  45. package/dist/src/world-view.d.ts +25 -0
  46. package/dist/src/world-view.js +110 -0
  47. package/known-external-services.json +0 -8
  48. package/package.json +10 -4
  49. package/src/app-url.ts +1 -1
  50. package/src/branch.ts +66 -2
  51. package/src/catalog.ts +1 -1
  52. package/src/cli.ts +43 -21
  53. package/src/console-apart.ts +7 -1
  54. package/src/covers.ts +4 -2
  55. package/src/fixture-env.ts +25 -0
  56. package/src/host-worker.ts +2 -1
  57. package/src/host.ts +2 -1
  58. package/src/import-module.ts +9 -0
  59. package/src/index.ts +7 -2
  60. package/src/infra-cli.ts +56 -12
  61. package/src/init.ts +34 -5
  62. package/src/local-branches.ts +173 -0
  63. package/src/pglite-backing.ts +112 -36
  64. package/src/pglite-host.mjs +520 -14
  65. package/src/prerequisites.ts +1 -1
  66. package/src/process-groups.ts +1 -1
  67. package/src/redirect-proxy.ts +4 -4
  68. package/src/redis-backing.ts +107 -0
  69. package/src/root.ts +27 -2
  70. package/src/run-task.ts +1 -1
  71. package/src/runtime.ts +38 -20
  72. package/src/schema.ts +11 -1
  73. package/src/served-world.ts +762 -85
  74. package/src/service-recorder.ts +1 -1
  75. package/src/storage-capacity.ts +2 -2
  76. package/src/up-task.ts +1 -1
  77. package/src/world-origins.ts +38 -0
  78. package/src/world-view.ts +100 -0
@@ -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-only definition — the PGlite backing
48
- // (pglite-host.ts), announced loudly;
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: pglite (no container runtime)\n');
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: services.length, connections: infraConnections(services, process.env) })}\n`);
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(parseInfraDefinition(readFileSync(definition, 'utf8')), process.env) })}\n`);
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']);
@@ -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
- : `${kind}://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`;
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>;