@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.
@@ -0,0 +1,120 @@
1
+ // The containerless backing for a World's managed REDIS: the redis twin (@volter/twin-redis) serving Redis's
2
+ // own protocol on the loopback port the definition declares, its keys held in the World's tree under
3
+ // VOLTER_WORLD_DATA. Chosen by `volter-world-infra` for every redis service unless the operator forces the
4
+ // container backing (VOLTER_WORLD_INFRA_BACKING=docker); the definition, the injected REDIS_URL and the declared
5
+ // service contract are the same either way, as the boundary comment in infra-cli.ts promises.
6
+ //
7
+ // Why the twin and not a container: a World's state branches, checkpoints and rolls back as one tree, and a Redis
8
+ // in a container is state outside it (a branch of the World would share the container's queue and cache). The
9
+ // twin keeps each key a subject of the World (packages/twin/redis/src/redis-storage.ts). It needs nothing
10
+ // installed: no container runtime, no redis-server binary.
11
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { connect } from 'node:net';
13
+ import { dirname, join } from 'node:path';
14
+ import { spawn } from 'node:child_process';
15
+ import { findInstalledPackage, packCli } from "./catalog.js";
16
+ const PACKAGE = '@volter/twin-redis';
17
+ const READY_TIMEOUT_MS = 60_000;
18
+ /** The redis twin's cli: installed beside this runtime (its node_modules walk), else beside the World's config. */
19
+ export function redisTwinCli(from) {
20
+ for (const dir of from) {
21
+ const found = findInstalledPackage(dir, PACKAGE);
22
+ const cli = found === undefined ? undefined : packCli(found);
23
+ if (cli !== undefined && existsSync(cli))
24
+ return cli;
25
+ }
26
+ return undefined;
27
+ }
28
+ const pidPath = (dataDir, service) => join(dataDir, `redis-twin-${service.hostPort}.pid`);
29
+ /** Where the service's keys live: the World's tree for the `redis` service, under the World's data. */
30
+ export const redisTwinRoot = (dataDir) => join(dataDir, 'redis-twin');
31
+ function alive(pid) {
32
+ try {
33
+ process.kill(pid, 0);
34
+ return true;
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ function listening(port) {
41
+ return new Promise((resolve) => {
42
+ const socket = connect({ host: '127.0.0.1', port, timeout: 1000 });
43
+ // a port that answers PING with PONG is this service; anything else is not ready (or not ours)
44
+ socket.once('connect', () => socket.write('PING\r\n'));
45
+ socket.once('data', (chunk) => { socket.destroy(); resolve(chunk.toString('latin1').startsWith('+PONG')); });
46
+ socket.on('error', () => resolve(false));
47
+ socket.on('timeout', () => { socket.destroy(); resolve(false); });
48
+ });
49
+ }
50
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
51
+ export async function redisTwinUp(services, dataDir, cli) {
52
+ for (const service of services) {
53
+ const pidFile = pidPath(dataDir, service);
54
+ if (existsSync(pidFile) && alive(Number(readFileSync(pidFile, 'utf8').trim())) && await listening(service.hostPort))
55
+ continue;
56
+ // a port that already answers is another process's (another World's twin, a redis-server): serving there would
57
+ // hand this World's app someone else's keys, so it is refused, never taken as ready
58
+ if (await listening(service.hostPort))
59
+ throw new Error(`port ${service.hostPort} already serves Redis for another process`);
60
+ const root = redisTwinRoot(dataDir);
61
+ mkdirSync(root, { recursive: true });
62
+ const log = join(dataDir, `redis-twin-${service.hostPort}.log`);
63
+ mkdirSync(dirname(log), { recursive: true });
64
+ // a real fd, not a pipe: the server outlives this process untethered
65
+ const logFd = openSync(log, 'a');
66
+ // the runtime this runs on: Bun runs the pack's TypeScript as it is; Node (volter-world-infra's shebang) needs
67
+ // type transformation, not only stripping (the kernel declares constructor parameter properties)
68
+ const runtime = typeof globalThis.Bun !== 'undefined' || !cli.endsWith('.ts') ? [] : ['--experimental-transform-types', '--no-warnings'];
69
+ const child = spawn(process.execPath, [...runtime, cli, 'serve', '--port', String(service.hostPort), '--root', root], {
70
+ detached: true,
71
+ stdio: ['ignore', logFd, logFd],
72
+ });
73
+ child.unref();
74
+ closeSync(logFd);
75
+ writeFileSync(pidFile, `${child.pid}\n`);
76
+ const deadline = Date.now() + READY_TIMEOUT_MS;
77
+ while (!(await listening(service.hostPort)) || !alive(child.pid)) {
78
+ if (!alive(child.pid))
79
+ throw new Error(`redis exited before serving — see ${log}`);
80
+ if (Date.now() > deadline)
81
+ throw new Error(`redis did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
82
+ await sleep(200);
83
+ }
84
+ }
85
+ }
86
+ export async function redisTwinStatus(services, dataDir) {
87
+ let ready = 0;
88
+ for (const service of services) {
89
+ const pidFile = pidPath(dataDir, service);
90
+ if (!existsSync(pidFile))
91
+ continue;
92
+ if (alive(Number(readFileSync(pidFile, 'utf8').trim())) && await listening(service.hostPort))
93
+ ready += 1;
94
+ }
95
+ return ready;
96
+ }
97
+ export async function redisTwinDown(services, dataDir) {
98
+ for (const service of services) {
99
+ const pidFile = pidPath(dataDir, service);
100
+ if (!existsSync(pidFile))
101
+ continue;
102
+ const pid = Number(readFileSync(pidFile, 'utf8').trim());
103
+ if (alive(pid)) {
104
+ try {
105
+ process.kill(pid, 'SIGTERM');
106
+ }
107
+ catch { /* already gone */ }
108
+ const deadline = Date.now() + 5000;
109
+ while (alive(pid) && Date.now() < deadline)
110
+ await sleep(100);
111
+ if (alive(pid)) {
112
+ try {
113
+ process.kill(pid, 'SIGKILL');
114
+ }
115
+ catch { /* raced */ }
116
+ }
117
+ }
118
+ rmSync(pidFile, { force: true });
119
+ }
120
+ }
@@ -122,6 +122,7 @@ export declare function pruneWorlds(options?: {
122
122
  entries: WorldCleanupEntry[];
123
123
  };
124
124
  export declare function parseCloudflareQuickPublicUrl(candidate: string): string | undefined;
125
+ export declare function ownCommand(command: string[]): string[];
125
126
  export declare function shareWorld(name: string, options?: ShareWorldOptions): Promise<WorldInstance>;
126
127
  export declare function shareWorldServices(name: string, options?: ShareWorldServicesOptions): Promise<WorldInstance>;
127
128
  /** TWIN-62: `unshare` must give tunnels the same contract plain `downWorld` already gives owned
@@ -17,7 +17,7 @@ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
17
17
  import { packFacts } from "./pack-facts.js";
18
18
  import { describeServiceEnd, readServiceEnd, serviceOutput } from "./service-exit.js";
19
19
  import { Socket } from 'node:net';
20
- import { getActiveWorldStore, withFileLock, stateDirName, PROTOCOL_VERSION, PROTOCOL_MAJOR, WORLD_BOOT_PATH, WORLD_ENV_NAMES_ENV } from '@volter/world-core';
20
+ import { getActiveWorldStore, withFileLock, stateDirName, PROTOCOL_VERSION, PROTOCOL_MAJOR, WORLD_BOOT_PATH, WORLD_DATA_ENV, WORLD_ENV_NAMES_ENV } from '@volter/world-core';
21
21
  import { loadWorldConfig } from "./configs.js";
22
22
  import { createWorldNetworkPolicy, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
23
23
  import { resolveServicePackage } from "./catalog.js";
@@ -762,7 +762,7 @@ function getByJsonPath(value, jsonPath) {
762
762
  return current;
763
763
  }
764
764
  async function runExternalCommand(command, cwd, serviceId, phase, env = process.env, abort, timeoutMs = 60_000) {
765
- const [bin, ...args] = command;
765
+ const [bin, ...args] = ownCommand(command);
766
766
  if (!bin)
767
767
  throw new Error(`External service "${serviceId}": ${phase} command is empty`);
768
768
  if (!commandExists(bin))
@@ -803,7 +803,7 @@ async function runExternalCommand(command, cwd, serviceId, phase, env = process.
803
803
  * (instead of buffering through spawnSync's 1MB cap, which a large DB restore would overflow). Returns
804
804
  * the combined output so `discover` can read it. Throws with a bounded tail on nonzero exit. */
805
805
  async function runExternalUp(command, cwd, serviceId, env, logPath, abort) {
806
- const [bin, ...args] = command;
806
+ const [bin, ...args] = ownCommand(command);
807
807
  if (!bin)
808
808
  throw new Error(`External service "${serviceId}": up command is empty`);
809
809
  if (!commandExists(bin)) {
@@ -1453,7 +1453,8 @@ export async function upWorld(configId, options = {}, abort) {
1453
1453
  VOLTER_WORLD_MODE: mode,
1454
1454
  VOLTER_WORLD_CONFIG: loaded.path,
1455
1455
  VOLTER_WORLD_INSTANCE: instanceFile,
1456
- VOLTER_WORLD_DATA: data,
1456
+ // every service's root is `<data>/<service id>`; the kernel finds another pack's store across them (A3, ownerStoreRoots)
1457
+ [WORLD_DATA_ENV]: data,
1457
1458
  // the live env file this `up` writes: what a service that runs apps elsewhere (world-machine's guest) carries there
1458
1459
  VOLTER_WORLD_ENV_FILE: envFile,
1459
1460
  // THE WORLD CLOCK: every service stamps from this file via the kernel's worldNow();
@@ -2220,8 +2221,23 @@ function sleep(ms) {
2220
2221
  }
2221
2222
  function commandExists(command) {
2222
2223
  // Pass the live env so PATH reflects the current process (some runtimes snapshot env otherwise).
2223
- const result = spawnSync('which', [command], { stdio: 'ignore', env: process.env });
2224
- return result.status === 0;
2224
+ return OWN_COMMANDS[command] !== undefined || onPath(command);
2225
+ }
2226
+ /** A lifecycle command naming this runtime's own bin (`volter-world-infra`, which `init` names in every managed
2227
+ * infrastructure's lifecycle) runs this runtime's own script with the executable running it, when nothing on PATH
2228
+ * answers the name: a bare `volter-world down` (no runner that put node_modules/.bin on PATH) failed "not found on
2229
+ * PATH" and left the infrastructure up. Only the name is mapped — no directory joins PATH — and a same-named
2230
+ * command on PATH still wins, as before. */
2231
+ const OWN_COMMANDS = { 'volter-world-infra': 'infra-cli' };
2232
+ export function ownCommand(command) {
2233
+ const [bin, ...args] = command;
2234
+ const stem = bin === undefined ? undefined : OWN_COMMANDS[bin];
2235
+ if (stem === undefined || onPath(bin))
2236
+ return command;
2237
+ return [process.execPath, siblingScript(import.meta.url, stem), ...args];
2238
+ }
2239
+ function onPath(command) {
2240
+ return spawnSync('which', [command], { stdio: 'ignore', env: process.env }).status === 0;
2225
2241
  }
2226
2242
  function resolveWithPublicDns(hostname) {
2227
2243
  const result = spawnSync('dig', ['+short', '@1.1.1.1', hostname, 'A'], {
@@ -2523,7 +2539,8 @@ async function probeExternalOnce(ready, cwd, env, logPath) {
2523
2539
  if (!commandExists(ready.command)) {
2524
2540
  return { ok: false, message: `readiness command \`${ready.command}\` not found on PATH` };
2525
2541
  }
2526
- const result = spawnSync(ready.command, ready.args ?? [], {
2542
+ const [probeBin, ...probeArgs] = ownCommand([ready.command, ...(ready.args ?? [])]);
2543
+ const result = spawnSync(probeBin, probeArgs, {
2527
2544
  cwd, encoding: 'utf8', env, timeout: DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
2528
2545
  });
2529
2546
  if (result.status === 0)
@@ -373,10 +373,6 @@
373
373
  "disposition": "demanded",
374
374
  "reason": "vendor google-play (Play Developer API), ×1 — ladder classification 2026-09-02"
375
375
  },
376
- "@googleapis/calendar": {
377
- "disposition": "demanded",
378
- "reason": "vendor google-calendar, ×1 (plus the GOOGLE_CALENDAR_API_KEY stem, ×1) — ladder classification 2026-09-02"
379
- },
380
376
  "@googleapis/sheets": {
381
377
  "disposition": "demanded",
382
378
  "reason": "vendor google-sheets, ×1 — ladder classification 2026-09-02"
@@ -863,10 +859,6 @@
863
859
  "disposition": "demanded",
864
860
  "reason": "vendor ghost (VITE_PUBLIC_GHOST_CONTENT_API_KEY — the browser-side Content API key), ×1 — ladder classification 2026-09-02"
865
861
  },
866
- "googlecalendar": {
867
- "disposition": "demanded",
868
- "reason": "vendor google-calendar (GOOGLE_CALENDAR_API_KEY; the @googleapis/calendar client is the same demand), ×1 — ladder classification 2026-09-02"
869
- },
870
862
  "googlecloud": {
871
863
  "disposition": "demanded",
872
864
  "reason": "vendor googlecloud (GOOGLE_CLOUD_API_KEY; the roster's one consumer spends it on the Web Fonts Developer API at www.googleapis.com/webfonts/v1, so the stem is a Google Cloud API key rather than a googleoauth credential), ×1 — ladder classification 2026-09-02"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
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",
@@ -59,6 +59,7 @@
59
59
  "devDependencies": {
60
60
  "@types/bun": "^1.2.20",
61
61
  "@types/node": "^24.0.0",
62
+ "mongodb": "~6.20.0",
62
63
  "pg": "8.16.3",
63
64
  "typescript": "^5.9.0"
64
65
  },
@@ -66,12 +67,13 @@
66
67
  "node": ">=22.3"
67
68
  },
68
69
  "peerDependencies": {
69
- "@volter/world-core": "2.0.0",
70
- "@volter/world-console": "2.0.0"
70
+ "@volter/world-core": "2.0.1",
71
+ "@volter/world-console": "2.0.1"
71
72
  },
72
73
  "dependencies": {
73
74
  "@electric-sql/pglite": "0.5.8",
74
- "@volter/world-core": "2.0.0",
75
+ "@electric-sql/pglite-pgvector": "0.0.9",
76
+ "@volter/world-core": "2.0.1",
75
77
  "pg-gateway": "0.3.0-beta.4",
76
78
  "smol-toml": "^1.8.0"
77
79
  },
@@ -79,5 +81,8 @@
79
81
  "@volter/world-console": {
80
82
  "optional": true
81
83
  }
84
+ },
85
+ "optionalDependencies": {
86
+ "@volter/twin-mongodb": "0.1.1"
82
87
  }
83
88
  }
package/src/branch.ts CHANGED
@@ -2,13 +2,18 @@
2
2
  // log of changes, and a world is a branch with compute attached (docs/concepts/the-model.md).
3
3
  // `branch <world> <name>` boots a new world from the base's config and forks every twin's mirror
4
4
  // into it (core's `forkTwin`: a POINTER to the base at its current position — the base's whole
5
- // history, its own entries included, is the branch's parent; nothing is copied). `checkout <name>`
5
+ // history, its own entries included, is the branch's parent; nothing is copied). A managed database (containerless
6
+ // Postgres) keeps no history, so it is the one thing copied: the stopped base's data, as of now. Two branches of one
7
+ // base running at once share the database port its definition names; run one at a time (the SDK does). `checkout <name>`
6
8
  // brings a stopped world back up from its own instance record, state intact.
7
- import { existsSync, readdirSync } from 'node:fs';
9
+ import { cpSync, existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
8
10
  import { dirname, join, resolve } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
9
12
  import { captureHistory, historyAtInstant, forkTwin, stateGeneration, withAncestryLock, worldPaths, worldStateRoot } from '@volter/world-core';
10
13
  import { downWorld, saveWorldInstance, statusWorld, upWorld } from './runtime.ts';
11
14
  import type { WorldInstance } from './schema.ts';
15
+ import { parseInfraDefinition, pgliteDown, pgliteUp } from './pglite-backing.ts';
16
+ import { redisTwinCli, redisTwinDown, redisTwinUp } from './redis-backing.ts';
12
17
 
13
18
  export type BranchOptions = { root?: string; envFile?: string; now?: Date; /** branch from a point in the base's history: an instant, or a position per twin (contract "Just like Neon", 3) */ at?: { instant?: string; positions?: Record<string, number>; views?: Record<string, string> } };
14
19
 
@@ -37,6 +42,12 @@ export async function branchWorld(base: string, name: string, options: BranchOpt
37
42
  return { state, view, position, generation: stateGeneration(worldPaths(state, fromRoot).dir) };
38
43
  }) };
39
44
  }));
45
+ // the base's managed databases (checked before the branch boots: its database would share the base's port)
46
+ const databases = managedDatabases(from);
47
+ if (databases.length && (options.at?.instant || options.at?.positions || options.at?.views)) {
48
+ throw new Error(`volter-world branch: ${base} has a managed database, which keeps no history to branch from at an instant or position; branch from its current state`);
49
+ }
50
+ refuseLiveDatabases(from, databases);
40
51
  const instance = await upWorld(existsSync(from.configPath) ? from.configPath : from.config, { name, root, mode: from.mode, envFile });
41
52
  const forked: Record<string, string[]> = {};
42
53
  try {
@@ -48,6 +59,7 @@ export async function branchWorld(base: string, name: string, options: BranchOpt
48
59
  }
49
60
  if (states.length) forked[service] = states.map(s => s.state);
50
61
  }
62
+ await branchManagedDatabases(from, instance, databases);
51
63
  } catch (error) {
52
64
  await downWorld(name, root, { purge: true, expectedCreatedAt: instance.createdAt });
53
65
  throw error;
@@ -55,6 +67,58 @@ export async function branchWorld(base: string, name: string, options: BranchOpt
55
67
  return { instance, forked };
56
68
  }
57
69
 
70
+ /** The base's containerless managed databases: each PGlite `pglite-<kind>-data` and the MongoDB twin's
71
+ * `twin-mongodb-data` (pglite-backing.ts), and the redis twin's `redis-twin` tree (redis-backing.ts). A stopped World's
72
+ * hosts leave no pid file; a docker-backed World keeps its data under the container's volume, which a branch does not
73
+ * carry. */
74
+ function managedDatabases(from: WorldInstance): string[] {
75
+ const data = from.dirs.data;
76
+ if (!existsSync(data)) return [];
77
+ return readdirSync(data).filter((n) => /^pglite-[a-z0-9_-]+-data$/.test(n) || n === 'twin-mongodb-data' || n === 'redis-twin');
78
+ }
79
+
80
+ /** The pid files of the hosts serving one managed database directory. */
81
+ function hostPidFiles(data: string, db: string): string[] {
82
+ if (db === 'redis-twin') return readdirSync(data).filter((n) => /^redis-twin-\d+\.pid$/.test(n)).map((n) => join(data, n));
83
+ return [join(data, `${db.slice(0, -'-data'.length)}.pid`)];
84
+ }
85
+
86
+ /** A copy of a live database directory is not a consistent one, and the branch's database would answer on the base's
87
+ * port: the base must be stopped first (`volter world branch` stops it, as a branch replaces its base). */
88
+ function refuseLiveDatabases(from: WorldInstance, dbs: string[]): void {
89
+ for (const db of dbs) {
90
+ for (const pidFile of hostPidFiles(from.dirs.data, db)) {
91
+ const pid = existsSync(pidFile) ? Number(readFileSync(pidFile, 'utf8').trim()) : NaN;
92
+ let live = false;
93
+ if (Number.isInteger(pid) && pid > 0) { try { process.kill(pid, 0); live = true; } catch { live = false; } }
94
+ if (live) throw new Error(`volter-world branch: ${from.name}'s managed ${db} is running (pid ${pid}); stop ${from.name} first so its database can be copied into the branch consistently`);
95
+ }
96
+ }
97
+ }
98
+
99
+ /** The branch's managed databases as the base holds them: the branch's own, just booted empty by `up`, are stopped,
100
+ * replaced by a copy of the base's data, and started again, so a branch of a migrated and seeded World serves that
101
+ * data, as its twins serve the base's history. */
102
+ async function branchManagedDatabases(from: WorldInstance, instance: WorldInstance, dbs: string[]): Promise<void> {
103
+ if (!dbs.length) return;
104
+ const definition = join(dirname(instance.configPath), 'world.infrastructure.yml');
105
+ if (!existsSync(definition)) return;
106
+ const services = parseInfraDefinition(readFileSync(definition, 'utf8'));
107
+ const redis = services.filter((s) => s.kind === 'redis');
108
+ const hosted = services.filter((s) => s.kind !== 'redis');
109
+ const redisCli = dbs.includes('redis-twin') && redis.length ? redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(instance.configPath)]) : undefined;
110
+ await pgliteDown(hosted, instance.dirs.data);
111
+ if (redisCli) await redisTwinDown(redis, instance.dirs.data);
112
+ for (const db of dbs) {
113
+ if (db === 'redis-twin' && !redisCli) continue;
114
+ const to = join(instance.dirs.data, db);
115
+ rmSync(to, { recursive: true, force: true });
116
+ cpSync(join(from.dirs.data, db), to, { recursive: true });
117
+ }
118
+ if (dbs.some((db) => db !== 'redis-twin')) await pgliteUp(hosted, instance.dirs.data);
119
+ if (redisCli) await redisTwinUp(redis, instance.dirs.data, redisCli);
120
+ }
121
+
58
122
  /** A stopped world back up, from its own record. */
59
123
  export async function checkoutWorld(name: string, options: { root?: string } = {}): Promise<WorldInstance> {
60
124
  const root = resolve(options.root ?? process.cwd());
package/src/cli.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { sessionTrustEnv } from './ca-trust.ts';
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
4
  import { createRequire } from 'node:module';
5
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
5
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { dirname, join, resolve, resolve as resolvePath } from 'node:path';
8
8
  import { activateScript, branchWorld, checkoutWorld, fetchFromOrigin, pushWorldChangeset, rebaseWorldChangeset, resetWorld, seedWorld, worldOrigin, approveWorldChangeset, checkPrerequisites, coverWorld, createWorldChangeset, diffWorld, doctorWorld, downWorld, findWorldChangeset, formatCoverageReport, formatInitReport, formatPrerequisiteChecks, formatProjectInspection, initWorld, inspectProject, isRemoteWorldRef, listWorldChangesets, listWorlds, markWorld, migrateWorldConfig, readReflectRoutes, reflectRoutesPath, replayWorldChangeset, resolveWorldRef, attachWorld, retireWorldConsumers, runWorld, shareWorldServices, shellWorld, startReflectFront, startReflectResolver, statusWorld, statusWorldChangeset, tailWorldActions, unshareWorld, upWorld, urlsWorld, urlWorld, verifyWorldChangeset, worldManifest, writeReflectRoutes, readReflectManifest, writeReflectManifest, clearReflectManifest, composeOverrideForReflect, splitDockerComposeArgs, dockerComposeWithOverride, ATTACHED_CA_PATH } from './index.ts';
@@ -518,39 +518,61 @@ async function main(): Promise<void> {
518
518
  }
519
519
 
520
520
  if (cmd === 'clock') {
521
- // THE operator door for world time (physics): show / set <iso> / advance <duration>.
522
- // The clock is a frozen instant every twin reads per request (kernel worldNow()); setting
523
- // or advancing takes effect live, no restarts. `advance` requires a set clock (advancing
524
- // wall-clock would silently freeze time as a side effect).
521
+ // THE operator door for world time (physics): show / set <iso> / advance <duration> / shift <duration> / clear.
522
+ // The clock every twin reads per request (kernel worldNow()), and every application process through the injector
523
+ // (inject.cjs, WORLD TIME); a change takes effect live, no restarts. Its two forms (world-clock.cjs): `set` freezes
524
+ // time at an instant (scripted time: a life, a seed); `shift` moves a World forward while its time keeps running (a
525
+ // World serving an application, whose time a frozen instant would stop); `advance` moves either form; `clear`
526
+ // returns the World to the machine's time. `advance` refuses a World on the machine's time, which it would freeze.
525
527
  // argv shape here: cmd='clock', subject=<world>, rest=[action, value?, flags...]
528
+ const clockForm = createRequire(import.meta.url)('@volter/world-core/world-clock') as typeof import('@volter/world-core/world-clock');
526
529
  const name = subject;
527
530
  const [action, value] = rest;
528
- if (!name || !action || (action !== 'show' && action !== 'set' && action !== 'advance')) {
529
- console.error('usage: volter-world clock <world> show | set <iso-8601> | advance <N s|m|h|d> [--root <repo>]');
531
+ if (!name || !action || !['show', 'set', 'advance', 'shift', 'clear'].includes(action)) {
532
+ console.error('usage: volter-world clock <world> show | set <iso-8601> | advance <N s|m|h|d> | shift <N s|m|h|d> | clear [--root <repo>]');
530
533
  process.exit(2);
531
534
  }
532
535
  const rootDir = resolve(optionValue(rest, '--root') ?? process.cwd());
533
536
  const file = clockFile(rootDir, name);
537
+ const current = existsSync(file) ? clockForm.parseClock(readFileSync(file, 'utf8')) : null;
538
+ const write = (clock: Parameters<typeof clockForm.formatClock>[0]) => {
539
+ mkdirSync(dirname(file), { recursive: true });
540
+ writeFileSync(file, `${clockForm.formatClock(clock)}\n`);
541
+ // the World's time now, bare, as scripts read it (`show` says which form)
542
+ console.log(new Date(clockForm.clockNowMs(clock, Date.now())).toISOString());
543
+ };
534
544
  if (action === 'show') {
535
- console.log(existsSync(file) ? `${readFileSync(file, 'utf8').trim()} (frozen)` : `${new Date().toISOString()} (wall clock — no world clock set)`);
545
+ if (!current) console.log(`${new Date().toISOString()} (wall clock — no world clock set)`);
546
+ else if (current.kind === 'frozen') console.log(`${new Date(current.at).toISOString()} (frozen)`);
547
+ else {
548
+ const ahead = current.at - current.since;
549
+ console.log(`${new Date(clockForm.clockNowMs(current, Date.now())).toISOString()} (running, ${ahead >= 0 ? '+' : '-'}${Math.abs(ahead) / 1000}s from the machine's time)`);
550
+ }
551
+ return;
552
+ }
553
+ if (action === 'clear') {
554
+ if (existsSync(file)) rmSync(file);
555
+ console.log(`${new Date().toISOString()} (wall clock — no world clock set)`);
536
556
  return;
537
557
  }
538
558
  if (action === 'set') {
539
559
  const parsed = Date.parse(value ?? '');
540
560
  if (Number.isNaN(parsed)) { console.error(`clock set: ${JSON.stringify(value)} is not an ISO-8601 instant`); process.exit(2); }
541
- mkdirSync(dirname(file), { recursive: true });
542
- writeFileSync(file, `${new Date(parsed).toISOString()}\n`);
543
- console.log(new Date(parsed).toISOString());
561
+ write({ kind: 'frozen', at: parsed });
544
562
  return;
545
563
  }
546
564
  const m = /^(\d+(?:\.\d+)?)(s|m|h|d)$/.exec((value ?? '').trim());
547
- if (!m) { console.error(`clock advance: ${JSON.stringify(value)} is not <N>(s|m|h|d)`); process.exit(2); }
548
- if (!existsSync(file)) { console.error('clock advance: no world clock is set (advance from wall-clock would freeze time as a side effect) — `clock set <iso>` first'); process.exit(2); }
549
- const base = Date.parse(readFileSync(file, 'utf8').trim());
550
- const unit = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as 's' | 'm' | 'h' | 'd'];
551
- const next = new Date(base + Number(m[1]) * unit).toISOString();
552
- writeFileSync(file, `${next}\n`);
553
- console.log(next);
565
+ if (!m) { console.error(`clock ${action}: ${JSON.stringify(value)} is not <N>(s|m|h|d)`); process.exit(2); }
566
+ const by = Number(m[1]) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as 's' | 'm' | 'h' | 'd'];
567
+ if (action === 'advance') {
568
+ if (!current) { console.error('clock advance: no world clock is set (advance from wall-clock would freeze time as a side effect) — `clock set <iso>` first, or `clock shift` to move a running World'); process.exit(2); }
569
+ write({ ...current, at: current.at + by });
570
+ return;
571
+ }
572
+ // shift: a World on the machine's time, or already running, moves forward and keeps running
573
+ if (current?.kind === 'frozen') { console.error('clock shift: this World\'s clock is frozen — `clock advance` moves it, or `clock clear` first'); process.exit(2); }
574
+ const wall = Date.now();
575
+ write(current ? { ...current, at: current.at + by } : { kind: 'running', at: wall + by, since: wall });
554
576
  return;
555
577
  }
556
578
 
@@ -152,6 +152,11 @@ export function isGoogleServiceAccountEnvName(name: string): boolean {
152
152
  * world-runtime must not import a pack, and the tests pin every value against the injector's actual
153
153
  * VENDOR_HOSTS predicate so either side changing makes the contract fail loudly. */
154
154
  const ENDPOINT_SHAPES: Array<{ match: RegExp; value: string; source: string }> = [
155
+ // the vendor's own API URL, which the World routes to its twin: an app that reads a base-URL override (empty in its
156
+ // example, the vendor's URL as its default) gets the vendor's URL, never an unreachable fake
157
+ { match: /^DYNADOT_BASE_URL$/, value: 'https://api.dynadot.com/api3.json', source: 'packages/twin/dynadot/src/index.ts hosts (api.dynadot.com); dubinc/dub apps/web/lib/dynadot/client.ts default' },
158
+ // Vercel KV's REST URL (the Upstash Redis REST API under Vercel's names): an upstash host the twin serves
159
+ { match: /(^|_)KV_REST_API_URL$/, value: 'https://twin-fake.upstash.io', source: 'packages/world-core inject VENDOR_HOSTS.upstash; lukevella/rallly KV_REST_API_URL' },
155
160
  {
156
161
  match: /(^|_)UPSTASH_REDIS_REST_URL$/,
157
162
  value: 'https://twin-fake.upstash.io',
@@ -200,6 +205,14 @@ const CREDENTIAL_SHAPES: Array<{ match: RegExp; value: (name: string) => string;
200
205
  // (`.clerk.accounts.dev`, packages/twin/clerk/src/index.ts hosts), so the boundary — the injector
201
206
  // server-side, the browser proxy browser-side — resolves it to the twin, and an app CSP that
202
207
  // allowlists that host stays legal (peak drive 2026-08-26).
208
+ // Stripe's keys carry their kind in their prefix, and applications check it before calling (Cal.com's Stripe app
209
+ // refuses a key that is not `sk_…`/`pk_…`); a webhook signing secret is `whsec_…`. Test mode, as a World is.
210
+ { match: /(^|_)STRIPE_[A-Z0-9_]*WEBHOOK_SECRET$/, value: () => 'whsec_twinfake0000000000000000000000000000', source: 'https://docs.stripe.com/webhooks#verify-events' },
211
+ { match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
212
+ { match: /(^|_)STRIPE_[A-Z0-9_]*(PUBLISHABLE|PUBLIC)(_API)?_KEY$/, value: () => 'pk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (pk_test_)' },
213
+ // tavily-auth.ts and firecrawl-auth.ts refuse a key without the vendor's prefix with the vendor's 401
214
+ { match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: 'packages/twin/tavily/src/tavily-auth.ts' },
215
+ { match: /(^|_)FIRECRAWL_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'fc-twinfake00000000000000000000000', source: 'packages/twin/firecrawl/src/firecrawl-auth.ts FIRECRAWL_KEY_PREFIX' },
203
216
  { match: /(^|_)CLERK_[A-Z0-9_]*SECRET[A-Z0-9_]*$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'packages/twin/clerk/src/clerk-twin.ts' },
204
217
  { match: /(^|_)CLERK_[A-Z0-9_]*PUBLISHABLE[A-Z0-9_]*$/, value: () => `pk_test_${Buffer.from('twin.clerk.accounts.dev$').toString('base64')}`, source: 'packages/twin/clerk/src/index.ts' },
205
218
  ];
@@ -211,8 +224,20 @@ const APP_KEY_SHAPES: Array<{ match: RegExp; value: () => string; source: string
211
224
  // AES-256-GCM: the key base64-decodes to 32 bytes ("ENCRYPTION_KEY must be 32 bytes (base64-encoded)"); the 32 bytes
212
225
  // spell out that they are fake
213
226
  { match: /^ENCRYPTION_KEY$/, value: () => Buffer.from('twin-fake-encryption-key-32bytes').toString('base64'), source: "dubinc/dub apps/web/lib/encryption.ts" },
227
+ // AES-256-CBC as hex: the key is 32 bytes (64 hex characters) and the IV 16 (32); LibreChat refuses to boot on any
228
+ // other shape; the bytes spell out that they are fake
229
+ // AES-256 over the key's latin1 bytes: exactly 32 characters (Cal.com's symmetricEncrypt)
230
+ { match: /^CALENDSO_ENCRYPTION_KEY$/, value: () => 'twin-fake-calendso-key-32-chars!', source: 'calcom/cal.diy packages/lib/crypto.ts' },
231
+ { match: /^CREDS_KEY$/, value: () => Buffer.from('twin-fake-creds-key-of-32-bytes!').toString('hex'), source: 'LibreChat-AI/LibreChat api/server/utils/crypto.js' },
232
+ { match: /^CREDS_IV$/, value: () => Buffer.from('twin-fake-iv-16b').toString('hex'), source: 'LibreChat-AI/LibreChat api/server/utils/crypto.js' },
214
233
  ];
215
234
 
235
+ /** Whether `name` is a key an application parses (APP_KEY_SHAPES): a secret init fakes in its shape, whatever the
236
+ * example leaves it (LibreChat's CREDS_IV is empty in its example and not credential-shaped by name). */
237
+ export function isAppKeyName(name: string): boolean {
238
+ return APP_KEY_SHAPES.some((shape) => shape.match.test(name));
239
+ }
240
+
216
241
  export function fakeEnvValue(name: string): string {
217
242
  if (isGoogleOAuthClientEnvName(name)) return fakeGoogleOAuthClientJson();
218
243
  for (const shape of APP_KEY_SHAPES) if (shape.match.test(name)) return shape.value();
package/src/infra-cli.ts 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.ts';
8
9
 
9
10
  const phase = process.argv[2];
@@ -48,8 +49,9 @@ const fail = (result: ReturnType<typeof run>): never => {
48
49
  // runtime answers it is private and chosen here, per machine, at each phase:
49
50
  // 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite — explicit, for tests/operators;
50
51
  // 2. a working container runtime — the compose path, byte-identical to before;
51
- // 3. no container runtime + a postgres-only definition — the PGlite backing
52
- // (pglite-host.ts), announced loudly;
52
+ // 3. no container runtime + a definition of postgres and/or mongodb services —
53
+ // the containerless backing (pglite-backing.ts: PGlite for postgres, the
54
+ // MongoDB twin for mongodb), announced loudly;
53
55
  // 4. otherwise the honest refusal naming what this machine cannot serve.
54
56
  function selectBacking(): 'docker' | 'pglite' {
55
57
  const forced = process.env.VOLTER_WORLD_INFRA_BACKING;
@@ -71,7 +73,7 @@ function selectBacking(): 'docker' | 'pglite' {
71
73
 
72
74
  async function runPglite(): Promise<never> {
73
75
  const { unsupportedKinds, pgliteUp, pgliteStatus, pgliteDown } = await import('./pglite-backing.ts');
74
- const services = parseInfraDefinition(readFileSync(definition, 'utf8'));
76
+ const services = parseInfraDefinition(readFileSync(definition, 'utf8')).filter((s) => composed === null || composed.includes(s.kind));
75
77
  if (services.length === 0) {
76
78
  process.stderr.write('managed infrastructure: the declared definition names no services\n');
77
79
  process.exit(1);
@@ -82,7 +84,7 @@ async function runPglite(): Promise<never> {
82
84
  process.exit(1);
83
85
  }
84
86
  if (phase === 'up') {
85
- process.stdout.write('managed infrastructure backing: pglite (no container runtime)\n');
87
+ process.stdout.write('managed infrastructure backing: containerless (no container runtime)\n');
86
88
  try {
87
89
  await pgliteUp(services, worldData!);
88
90
  } catch (error) {
@@ -98,7 +100,7 @@ async function runPglite(): Promise<never> {
98
100
  process.stderr.write(`managed infrastructure status failed: ${status.ready}/${services.length} declared services are ready\n`);
99
101
  process.exit(1);
100
102
  }
101
- process.stdout.write(`${JSON.stringify({ ok: true, services: services.length, connections: infraConnections(services, process.env) })}\n`);
103
+ process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
102
104
  process.exit(0);
103
105
  }
104
106
  await pgliteDown(services, worldData!);
@@ -106,12 +108,54 @@ async function runPglite(): Promise<never> {
106
108
  process.exit(0);
107
109
  }
108
110
 
111
+ // ---- redis: the twin, containerless ----------------------------------------
112
+ // A redis service is served by the redis twin (redis-backing.ts) on every machine, container runtime or not,
113
+ // unless the operator forces the container backing; the other services take the backing chosen above. Where the
114
+ // twin is not installed (@volter/twin-redis is not this runtime's dependency), redis goes to that backing too:
115
+ // the container where there is one, else the backing's refusal naming redis.
116
+ const declared = parseInfraDefinition(readFileSync(definition, 'utf8'));
117
+ const { redisTwinCli } = await import('./redis-backing.ts');
118
+ const twinCli = redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(worldConfig)]);
119
+ const twinned = process.env.VOLTER_WORLD_INFRA_BACKING === 'docker' || twinCli === undefined ? [] : declared.filter((s) => s.kind === 'redis');
120
+ /** The services the backing below answers for: every one, or those the twin does not serve (by compose service name). */
121
+ const composed = twinned.length === 0 ? null : declared.filter((s) => s.kind !== 'redis').map((s) => s.kind);
122
+ if (twinned.length > 0) await runRedisTwin();
123
+
124
+ async function runRedisTwin(): Promise<void> {
125
+ const { redisTwinDown, redisTwinStatus, redisTwinUp } = await import('./redis-backing.ts');
126
+ const cli = twinCli!;
127
+ if (phase === 'up') {
128
+ process.stdout.write('managed infrastructure backing: redis twin (containerless)\n');
129
+ try {
130
+ await redisTwinUp(twinned, worldData!, cli);
131
+ } catch (error) {
132
+ process.stderr.write(`managed infrastructure up failed: ${String((error as Error).message ?? error)}\n`);
133
+ process.exit(1);
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
+ } else {
142
+ await redisTwinDown(twinned, worldData!);
143
+ }
144
+ if (composed!.length === 0) {
145
+ if (phase === 'up') process.stdout.write('managed infrastructure ready\n');
146
+ else if (phase === 'status') process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
147
+ else process.stdout.write('managed infrastructure stopped\n');
148
+ process.exit(0);
149
+ }
150
+ }
151
+
109
152
  if (selectBacking() === 'pglite') {
110
153
  await runPglite();
111
154
  }
112
155
 
156
+ const only = composed ?? [];
113
157
  if (phase === 'up') {
114
- const result = run(['up', '-d', '--wait']);
158
+ const result = run(['up', '-d', '--wait', ...only]);
115
159
  if (result.status !== 0) fail(result);
116
160
  process.stdout.write('managed infrastructure ready\n');
117
161
  } else if (phase === 'status') {
@@ -119,13 +163,13 @@ if (phase === 'up') {
119
163
  if (expected.status !== 0) fail(expected);
120
164
  const running = run(['ps', '--status', 'running', '--services']);
121
165
  if (running.status !== 0) fail(running);
122
- const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).sort();
123
- const runningNames = running.stdout.split(/\s+/u).filter(Boolean).sort();
166
+ const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
167
+ const runningNames = running.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
124
168
  if (expectedNames.length === 0 || expectedNames.join('\0') !== runningNames.join('\0')) {
125
- process.stderr.write(`managed infrastructure status failed: ${runningNames.length}/${expectedNames.length} declared services are ready\n`);
169
+ process.stderr.write(`managed infrastructure status failed: ${runningNames.length + twinned.length}/${expectedNames.length + twinned.length} declared services are ready\n`);
126
170
  process.exit(1);
127
171
  }
128
- process.stdout.write(`${JSON.stringify({ ok: true, services: expectedNames.length, connections: infraConnections(parseInfraDefinition(readFileSync(definition, 'utf8')), process.env) })}\n`);
172
+ process.stdout.write(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length, connections: infraConnections(declared, process.env) })}\n`);
129
173
  } else {
130
174
  const result = run(['down', '--remove-orphans']);
131
175
  if (result.status !== 0) fail(result);