@volter/world-runtime 2.0.0 → 2.0.1

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/src/init.ts CHANGED
@@ -47,7 +47,7 @@ import {
47
47
  registryAcknowledgedReason,
48
48
  type CoverageReport,
49
49
  } from './covers.ts';
50
- import { fakeEnvValue, isGoogleOAuthClientEnvName, isGoogleServiceAccountEnvName } from './fixture-env.ts';
50
+ import { fakeEnvValue, isAppKeyName, isGoogleOAuthClientEnvName, isGoogleServiceAccountEnvName } from './fixture-env.ts';
51
51
  import { resolveCatalog, twinPackageName, type Catalog } from './catalog.ts';
52
52
  import { projectEnvReads, projectManifestDirs } from './project-inspect.ts';
53
53
  import { overlayEndpointEnv, packFacts } from './pack-facts.ts';
@@ -300,6 +300,20 @@ export const APP_READ_ENDPOINT_ENV: Record<string, { injectEnv?: string; injectE
300
300
  + 'sends there. Read the mail back over the twin-only inspect sidecar: `world-smtp serve --inspect-port N`, then '
301
301
  + 'GET http://127.0.0.1:N/twin/messages/latest.',
302
302
  },
303
+ // RAW PROTOCOL, the gRPC kind. The Temporal SDKs dial the frontend address they are given
304
+ // (`Connection.connect({ address })`, `NativeConnection.connect({ address })`), conventionally read
305
+ // from TEMPORAL_ADDRESS — Postiz's temporal.module.ts, the SDK samples and the Temporal CLI all read
306
+ // that name — and speak gRPC over HTTP/2, which the http/fetch injector never sees. The value is an
307
+ // address (host:port), not a URL, which is why this lives here as a template and not as the
308
+ // descriptor's `endpointEnv` (whose `name` receives the twin's URL).
309
+ temporal: {
310
+ injectEnvTemplates: {
311
+ TEMPORAL_ADDRESS: '${host}:${port}',
312
+ },
313
+ note: 'no injector entry and none possible: the Temporal SDKs speak gRPC to the address they are configured with. '
314
+ + 'TEMPORAL_ADDRESS=<host>:<port> points them at the twin (namespace `default` exists); an app that sets '
315
+ + 'TEMPORAL_API_KEY turns TLS on in the SDK, which this loopback frontend does not speak — leave it unset.',
316
+ },
303
317
  };
304
318
  // descriptor-first migration (adding-a-twin.md §3): packs now declare their endpoint-env wiring (with its grounding
305
319
  // note) on the descriptor (`endpointEnv` on TwinPack); the table above shrinks toward empty as
@@ -442,7 +456,7 @@ const INFRA_PLACEHOLDER: Record<string, string> = {
442
456
  //
443
457
  // The subject pilots (dub, cal.com) put the number on it: world-side setup is seconds, and the
444
458
  // minutes go to the app side — most avoidably, to hand-writing infrastructure for the Postgres/
445
- // MySQL/Redis the repo signalled. For the kinds below, `init` upgrades the placeholder to a
459
+ // MySQL/Redis/MongoDB the repo signalled. For the kinds below, `init` upgrades the placeholder to a
446
460
  // World-managed service and private definition with env URLs already pointing at it. The helper
447
461
  // owns its implementation behind the declared service boundary. Kinds without a recipe keep the
448
462
  // placeholder + `//infra` stub.
@@ -479,6 +493,15 @@ const COMPOSE_INFRA: Record<string, {
479
493
  volumePath: '/data',
480
494
  healthcheck: () => ['CMD', 'redis-cli', 'ping'],
481
495
  },
496
+ // Without a container runtime the containerless backing serves this kind with the MongoDB twin
497
+ // (@volter/twin-mongodb), at the same loopback port and URL.
498
+ mongodb: {
499
+ image: 'mongo:7',
500
+ containerPort: 27017,
501
+ memoryMiB: 1024,
502
+ volumePath: '/data/db',
503
+ healthcheck: () => ['CMD', 'mongosh', '--quiet', '--eval', "db.adminCommand('ping').ok"],
504
+ },
482
505
  };
483
506
 
484
507
  /** FNV-1a 32-bit — a tiny, dependency-free stable string hash for port derivation. */
@@ -525,9 +548,12 @@ function composeService(name: string, kind: string, signals: string[], taken: Se
525
548
  const recipe = COMPOSE_INFRA[kind]!;
526
549
  const hostPort = composePort(name, kind, taken);
527
550
  const ident = composeIdent(name);
551
+ // redis and mongodb run without authentication (no credentials in their images' env), so their URLs carry none
528
552
  const url = kind === 'redis'
529
553
  ? `redis://127.0.0.1:${hostPort}`
530
- : `${kind}://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`;
554
+ : kind === 'mongodb'
555
+ ? `mongodb://127.0.0.1:${hostPort}/${ident}`
556
+ : `${kind}://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`;
531
557
  return {
532
558
  kind,
533
559
  image: recipe.image,
@@ -540,7 +566,7 @@ function composeService(name: string, kind: string, signals: string[], taken: Se
540
566
  };
541
567
  }
542
568
 
543
- /** The declared env of one compose service (empty for redis). */
569
+ /** The declared env of one compose service (empty for redis and mongodb). */
544
570
  function composeEnvironment(name: string, kind: string): Array<[string, string]> {
545
571
  const ident = composeIdent(name);
546
572
  if (kind === 'postgres') {
@@ -858,7 +884,7 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
858
884
  // RULE 3: credential-shaped means faked, even when no vendor claims the stem. That covers
859
885
  // app-local secrets (JWT_SECRET, ENCRYPTION_KEY) and untwinned vendors alike — and it is the
860
886
  // reason a live key committed to `.env.example` can never reach the emitted world.
861
- if (isCredentialShapedEnvName(name)) {
887
+ if (isCredentialShapedEnvName(name) || isAppKeyName(name)) {
862
888
  env[name] = fakeEnvValue(name);
863
889
  envRows.push({ name, disposition: 'faked', source, reason: 'credential-shaped name — the example value is never copied' });
864
890
  continue;
@@ -1,18 +1,23 @@
1
- // The containerless backing for World-managed infrastructure: PGlite hosts
2
- // (see pglite-host.ts) instead of docker compose. Selected by
3
- // `volter-world-infra` when no container runtime is available (or by
4
- // explicit VOLTER_WORLD_INFRA_BACKING=pglite); the world config, definition
5
- // file, injected env, and declared-service contract are byte-identical either
6
- // way — backing is private, exactly as the boundary comment in
7
- // infra-cli.ts promises.
1
+ // The containerless backing for World-managed infrastructure, instead of docker
2
+ // compose: a POSTGRES service is a PGlite host (see pglite-host.mjs), a MONGODB
3
+ // service is the MongoDB twin (@volter/twin-mongodb: the MongoDB wire protocol over
4
+ // the World state kernel). Selected by `volter-world-infra` when no container
5
+ // runtime is available (or by explicit VOLTER_WORLD_INFRA_BACKING=pglite); the
6
+ // world config, definition file, injected env, and declared-service contract are
7
+ // byte-identical either way — backing is private, exactly as the boundary comment
8
+ // in infra-cli.ts promises. Each service's bytes live under VOLTER_WORLD_DATA, so
9
+ // they survive down/up as the compose volumes do.
8
10
  //
9
- // Capability is stated, not stretched: this backing serves POSTGRES services
10
- // only. A definition declaring mysql/redis without a container runtime is
11
- // refused with the kinds named, never half-booted.
12
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
+ // Capability is stated, not stretched: this backing serves POSTGRES and MONGODB
12
+ // services only. A definition declaring mysql without a container runtime is
13
+ // refused with the kinds named, never half-booted. Redis never reaches it: every
14
+ // redis service is the redis twin's (redis-backing.ts).
15
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
13
16
  import { connect } from 'node:net';
14
17
  import { dirname, join } from 'node:path';
15
- import { spawn } from 'node:child_process';
18
+ import { spawn, spawnSync } from 'node:child_process';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { findInstalledPackage, packCli } from './catalog.ts';
16
21
 
17
22
  export interface InfraService { kind: string; hostPort: number }
18
23
 
@@ -55,11 +60,48 @@ export function infraConnections(services: InfraService[], env: Record<string, s
55
60
  return connections;
56
61
  }
57
62
 
58
- const HOST = join(import.meta.dir, 'pglite-host.mjs');
63
+ // this module's directory under Node as under Bun: `volter-world-infra` is a node-shebang bin, where
64
+ // `import.meta.dir` (Bun's) is undefined
65
+ const HERE = fileURLToPath(new URL('.', import.meta.url));
66
+ const HOST = join(HERE, 'pglite-host.mjs');
59
67
  const READY_TIMEOUT_MS = 60_000;
68
+ const MONGODB_TWIN = '@volter/twin-mongodb';
69
+
70
+ /** The kinds this backing serves, and the name its files carry under the data dir. */
71
+ const CONTAINERLESS: Record<string, string> = { postgres: 'pglite-postgres', mongodb: 'twin-mongodb' };
60
72
 
61
73
  function pidPath(dataDir: string, kind: string): string {
62
- return join(dataDir, `pglite-${kind}.pid`);
74
+ return join(dataDir, `${CONTAINERLESS[kind] ?? `pglite-${kind}`}.pid`);
75
+ }
76
+
77
+ /** The MongoDB twin's cli: the package installed above the World's config (an app repo), else the one
78
+ * this runtime was installed with (its dependency, or the checkout's workspace link). */
79
+ export function mongodbTwinCli(worldConfig?: string, froms: string[] = [...(worldConfig ? [dirname(worldConfig)] : []), HERE]): string {
80
+ for (const from of froms) {
81
+ const dir = findInstalledPackage(from, MONGODB_TWIN);
82
+ const cli = dir === undefined ? undefined : packCli(dir);
83
+ if (cli !== undefined) return cli;
84
+ }
85
+ throw new Error(`the containerless mongodb service is served by ${MONGODB_TWIN} (an optional dependency of @volter/world-runtime), which is not installed above ${froms.join(' or ')} — install it, or run the World with a container runtime`);
86
+ }
87
+
88
+ /** The runtime a twin's cli runs under. A published package's cli is JavaScript and runs under this
89
+ * process's own runtime; a checkout's is TypeScript whose kernel needs more than Node's type
90
+ * stripping, so it runs under Bun (this process's, or the one on PATH) — the repository's toolchain —
91
+ * and without Bun it is refused by name rather than started to fail. */
92
+ export function twinRunner(cli: string, bunOnPath: () => boolean = () => spawnSync('bun', ['--version'], { stdio: 'ignore', timeout: 10_000 }).status === 0, underBun = typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined'): string {
93
+ if (!cli.endsWith('.ts') || underBun) return process.execPath;
94
+ if (bunOnPath()) return 'bun';
95
+ throw new Error(`a checkout's MongoDB twin runs under bun, which is not on PATH (${cli} is TypeScript that Node cannot run)`);
96
+ }
97
+
98
+ /** The argv that serves one service on its declared loopback port, with its bytes in `serviceData`. */
99
+ function hostArgv(service: InfraService, serviceData: string): string[] {
100
+ if (service.kind === 'mongodb') {
101
+ const cli = mongodbTwinCli(process.env.VOLTER_WORLD_CONFIG);
102
+ return [twinRunner(cli), cli, 'serve', '--port', String(service.hostPort), '--root', serviceData];
103
+ }
104
+ return ['node', HOST, '--port', String(service.hostPort), '--data', serviceData];
63
105
  }
64
106
 
65
107
  function alive(pid: number): boolean {
@@ -81,36 +123,67 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
81
123
 
82
124
  /** The kinds this backing cannot serve, or [] when it can serve the world. */
83
125
  export function unsupportedKinds(services: InfraService[]): string[] {
84
- return [...new Set(services.filter((s) => s.kind !== 'postgres').map((s) => s.kind))];
126
+ return [...new Set(services.filter((s) => CONTAINERLESS[s.kind] === undefined).map((s) => s.kind))];
127
+ }
128
+
129
+ /** The line each host prints once ITS OWN listener is bound: readiness is taken from the child, never
130
+ * from a connect to the port, which any other process holding the port would answer. */
131
+ function readyLine(kind: string, port: number): string {
132
+ return kind === 'mongodb' ? `listening on mongodb://127.0.0.1:${port}` : `serving postgres wire protocol on 127.0.0.1:${port}`;
133
+ }
134
+
135
+ function logSince(log: string, offset: number): string {
136
+ try { return readFileSync(log).subarray(offset).toString('utf8'); } catch { return ''; }
85
137
  }
86
138
 
87
139
  export async function pgliteUp(services: InfraService[], dataDir: string): Promise<void> {
140
+ // Every check before any start: a refused up starts nothing, so it never leaves the World half-booted.
141
+ const plan: Array<{ service: InfraService; name: string; serviceData: string; log: string; pidFile: string; argv: string[] }> = [];
88
142
  for (const service of services) {
89
143
  const pidFile = pidPath(dataDir, service.kind);
90
144
  if (existsSync(pidFile) && alive(Number(readFileSync(pidFile, 'utf8').trim()))) continue;
91
- const serviceData = join(dataDir, `pglite-${service.kind}-data`);
92
- mkdirSync(serviceData, { recursive: true });
93
- const log = join(dataDir, `pglite-${service.kind}.log`);
94
- mkdirSync(dirname(log), { recursive: true });
95
- // a real fd, not a pipe: the host must outlive this process untethered
96
- const logFd = openSync(log, 'a');
97
- const child = spawn('node', [HOST, '--port', String(service.hostPort), '--data', serviceData], {
98
- detached: true,
99
- stdio: ['ignore', logFd, logFd],
100
- });
101
- child.unref();
102
- closeSync(logFd);
103
- writeFileSync(pidFile, `${child.pid}\n`);
104
- const deadline = Date.now() + READY_TIMEOUT_MS;
105
- while (!(await listening(service.hostPort))) {
106
- if (!alive(child.pid!)) {
107
- throw new Error(`pglite ${service.kind} exited before serving — see ${log}`);
108
- }
109
- if (Date.now() > deadline) {
110
- throw new Error(`pglite ${service.kind} did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
145
+ const name = CONTAINERLESS[service.kind] ?? `pglite-${service.kind}`;
146
+ const serviceData = join(dataDir, `${name}-data`);
147
+ const log = join(dataDir, `${name}.log`);
148
+ if (await listening(service.hostPort)) {
149
+ throw new Error(`${name} cannot serve port ${service.hostPort}: another process is already listening on it`);
150
+ }
151
+ plan.push({ service, name, serviceData, log, pidFile, argv: hostArgv(service, serviceData) });
152
+ }
153
+ const started: Array<{ pid: number; pidFile: string }> = [];
154
+ try {
155
+ for (const { service, name, serviceData, log, pidFile, argv } of plan) {
156
+ mkdirSync(serviceData, { recursive: true });
157
+ mkdirSync(dirname(log), { recursive: true });
158
+ const offset = existsSync(log) ? statSync(log).size : 0;
159
+ const [command, ...args] = argv;
160
+ // a real fd, not a pipe: the host must outlive this process untethered
161
+ const logFd = openSync(log, 'a');
162
+ const child = spawn(command!, args, {
163
+ detached: true,
164
+ stdio: ['ignore', logFd, logFd],
165
+ });
166
+ child.unref();
167
+ closeSync(logFd);
168
+ writeFileSync(pidFile, `${child.pid}\n`);
169
+ started.push({ pid: child.pid!, pidFile });
170
+ const deadline = Date.now() + READY_TIMEOUT_MS;
171
+ const line = readyLine(service.kind, service.hostPort);
172
+ // ready when OUR child says its listener is bound (a squatter that took the port after the check
173
+ // above makes the child fail its bind and exit, never print this) and the port answers
174
+ while (!(alive(child.pid!) && logSince(log, offset).includes(line) && await listening(service.hostPort))) {
175
+ if (!alive(child.pid!)) throw new Error(`${name} exited before serving — see ${log}`);
176
+ if (Date.now() > deadline) throw new Error(`${name} did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
177
+ await sleep(200);
111
178
  }
112
- await sleep(200);
113
179
  }
180
+ } catch (error) {
181
+ // tear down what this up started, so a failed up leaves nothing running
182
+ for (const { pid, pidFile } of started) {
183
+ if (alive(pid)) { try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } }
184
+ rmSync(pidFile, { force: true });
185
+ }
186
+ throw error;
114
187
  }
115
188
  }
116
189