@volter/world-runtime 2.0.0

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 (164) hide show
  1. package/LICENSE +202 -0
  2. package/dist/known-external-services.json +1108 -0
  3. package/dist/src/ancestry.d.ts +2 -0
  4. package/dist/src/ancestry.js +42 -0
  5. package/dist/src/app-url.d.ts +47 -0
  6. package/dist/src/app-url.js +239 -0
  7. package/dist/src/attach.d.ts +48 -0
  8. package/dist/src/attach.js +87 -0
  9. package/dist/src/branch.d.ts +20 -0
  10. package/dist/src/branch.js +65 -0
  11. package/dist/src/browser-proxy-cli.d.ts +2 -0
  12. package/dist/src/browser-proxy-cli.js +41 -0
  13. package/dist/src/ca-trust.d.ts +5 -0
  14. package/dist/src/ca-trust.js +64 -0
  15. package/dist/src/catalog.d.ts +31 -0
  16. package/dist/src/catalog.js +148 -0
  17. package/dist/src/changeset.d.ts +142 -0
  18. package/dist/src/changeset.js +570 -0
  19. package/dist/src/cli.d.ts +2 -0
  20. package/dist/src/cli.js +1262 -0
  21. package/dist/src/command-lifetime.d.ts +15 -0
  22. package/dist/src/command-lifetime.js +98 -0
  23. package/dist/src/configs.d.ts +18 -0
  24. package/dist/src/configs.js +119 -0
  25. package/dist/src/console-apart.d.ts +38 -0
  26. package/dist/src/console-apart.js +107 -0
  27. package/dist/src/consumers.d.ts +46 -0
  28. package/dist/src/consumers.js +200 -0
  29. package/dist/src/covers.d.ts +183 -0
  30. package/dist/src/covers.js +800 -0
  31. package/dist/src/fixture-env.d.ts +42 -0
  32. package/dist/src/fixture-env.js +221 -0
  33. package/dist/src/host-cli.d.ts +2 -0
  34. package/dist/src/host-cli.js +92 -0
  35. package/dist/src/host-fault-fixture.d.ts +32 -0
  36. package/dist/src/host-fault-fixture.js +100 -0
  37. package/dist/src/host-worker.d.ts +1 -0
  38. package/dist/src/host-worker.js +23 -0
  39. package/dist/src/host.d.ts +38 -0
  40. package/dist/src/host.js +135 -0
  41. package/dist/src/index.d.ts +48 -0
  42. package/dist/src/index.js +35 -0
  43. package/dist/src/infra-cli.d.ts +2 -0
  44. package/dist/src/infra-cli.js +136 -0
  45. package/dist/src/init.d.ts +227 -0
  46. package/dist/src/init.js +1117 -0
  47. package/dist/src/inject-map.d.ts +34 -0
  48. package/dist/src/inject-map.js +56 -0
  49. package/dist/src/lifecycle-record.d.ts +47 -0
  50. package/dist/src/lifecycle-record.js +196 -0
  51. package/dist/src/origin.d.ts +31 -0
  52. package/dist/src/origin.js +139 -0
  53. package/dist/src/pack-facts.d.ts +75 -0
  54. package/dist/src/pack-facts.js +98 -0
  55. package/dist/src/pglite-backing.d.ts +21 -0
  56. package/dist/src/pglite-backing.js +158 -0
  57. package/dist/src/pglite-host.mjs +147 -0
  58. package/dist/src/placeholder.d.ts +20 -0
  59. package/dist/src/placeholder.js +100 -0
  60. package/dist/src/prerequisites.d.ts +21 -0
  61. package/dist/src/prerequisites.js +49 -0
  62. package/dist/src/process-groups.d.ts +4 -0
  63. package/dist/src/process-groups.js +49 -0
  64. package/dist/src/project-inspect.d.ts +109 -0
  65. package/dist/src/project-inspect.js +827 -0
  66. package/dist/src/proxy-daemon.d.ts +2 -0
  67. package/dist/src/proxy-daemon.js +18 -0
  68. package/dist/src/redirect-proxy.d.ts +105 -0
  69. package/dist/src/redirect-proxy.js +665 -0
  70. package/dist/src/reflect.d.ts +74 -0
  71. package/dist/src/reflect.js +392 -0
  72. package/dist/src/resources.d.ts +26 -0
  73. package/dist/src/resources.js +22 -0
  74. package/dist/src/root.d.ts +114 -0
  75. package/dist/src/root.js +312 -0
  76. package/dist/src/run-task-worker.d.ts +1 -0
  77. package/dist/src/run-task-worker.js +38 -0
  78. package/dist/src/run-task.d.ts +18 -0
  79. package/dist/src/run-task.js +48 -0
  80. package/dist/src/runtime-test-support.d.ts +59 -0
  81. package/dist/src/runtime-test-support.js +205 -0
  82. package/dist/src/runtime.d.ts +256 -0
  83. package/dist/src/runtime.js +3502 -0
  84. package/dist/src/schema.d.ts +449 -0
  85. package/dist/src/schema.js +605 -0
  86. package/dist/src/serve.d.ts +30 -0
  87. package/dist/src/serve.js +82 -0
  88. package/dist/src/served-world.d.ts +194 -0
  89. package/dist/src/served-world.js +986 -0
  90. package/dist/src/service-exit.d.ts +46 -0
  91. package/dist/src/service-exit.js +195 -0
  92. package/dist/src/service-recorder.d.ts +1 -0
  93. package/dist/src/service-recorder.js +121 -0
  94. package/dist/src/sibling.d.ts +1 -0
  95. package/dist/src/sibling.js +9 -0
  96. package/dist/src/signals.d.ts +1 -0
  97. package/dist/src/signals.js +11 -0
  98. package/dist/src/storage-capacity.d.ts +8 -0
  99. package/dist/src/storage-capacity.js +61 -0
  100. package/dist/src/tail.d.ts +30 -0
  101. package/dist/src/tail.js +160 -0
  102. package/dist/src/tcp-port.d.ts +2 -0
  103. package/dist/src/tcp-port.js +36 -0
  104. package/dist/src/up-task-worker.d.ts +1 -0
  105. package/dist/src/up-task-worker.js +61 -0
  106. package/dist/src/up-task.d.ts +17 -0
  107. package/dist/src/up-task.js +49 -0
  108. package/dist/src/websocket-relay.d.ts +3 -0
  109. package/dist/src/websocket-relay.js +40 -0
  110. package/known-external-services.json +1108 -0
  111. package/package.json +83 -0
  112. package/src/ancestry.ts +36 -0
  113. package/src/app-url.ts +253 -0
  114. package/src/attach.ts +117 -0
  115. package/src/branch.ts +63 -0
  116. package/src/browser-proxy-cli.ts +44 -0
  117. package/src/ca-trust.ts +57 -0
  118. package/src/catalog.ts +156 -0
  119. package/src/changeset.ts +627 -0
  120. package/src/cli.ts +1111 -0
  121. package/src/command-lifetime.ts +79 -0
  122. package/src/configs.ts +110 -0
  123. package/src/console-apart.ts +90 -0
  124. package/src/consumers.ts +185 -0
  125. package/src/covers.ts +934 -0
  126. package/src/fixture-env.ts +230 -0
  127. package/src/host-cli.ts +90 -0
  128. package/src/host-worker.ts +23 -0
  129. package/src/host.ts +169 -0
  130. package/src/index.ts +171 -0
  131. package/src/infra-cli.ts +133 -0
  132. package/src/init.ts +1316 -0
  133. package/src/inject-map.ts +72 -0
  134. package/src/lifecycle-record.ts +168 -0
  135. package/src/origin.ts +134 -0
  136. package/src/pack-facts.ts +128 -0
  137. package/src/pglite-backing.ts +141 -0
  138. package/src/pglite-host.mjs +147 -0
  139. package/src/placeholder.ts +89 -0
  140. package/src/prerequisites.ts +66 -0
  141. package/src/process-groups.ts +33 -0
  142. package/src/project-inspect.ts +770 -0
  143. package/src/proxy-daemon.ts +21 -0
  144. package/src/redirect-proxy.ts +684 -0
  145. package/src/reflect.ts +440 -0
  146. package/src/resources.ts +22 -0
  147. package/src/root.ts +290 -0
  148. package/src/run-task-worker.ts +27 -0
  149. package/src/run-task.ts +44 -0
  150. package/src/runtime-test-support.ts +208 -0
  151. package/src/runtime.ts +3357 -0
  152. package/src/schema.ts +922 -0
  153. package/src/serve.ts +102 -0
  154. package/src/served-world.ts +812 -0
  155. package/src/service-exit.ts +175 -0
  156. package/src/service-recorder.ts +89 -0
  157. package/src/sibling.ts +10 -0
  158. package/src/signals.ts +10 -0
  159. package/src/storage-capacity.ts +60 -0
  160. package/src/tail.ts +205 -0
  161. package/src/tcp-port.ts +35 -0
  162. package/src/up-task-worker.ts +40 -0
  163. package/src/up-task.ts +45 -0
  164. package/src/websocket-relay.ts +32 -0
@@ -0,0 +1,72 @@
1
+ // The ONE world-runtime home for the injector's host→twin knowledge (`@volter/world-core/inject`:
2
+ // `VENDOR_HOSTS` + `readMap` + `resolveTwin` + `twinUrlVendor`). The redirect proxy, `covers`,
3
+ // and `up`'s inert-injectEnv warning all read the injector THROUGH this module, so none of them
4
+ // can re-encode (and silently drift from) what the injector actually redirects — the exact
5
+ // failure mode of the LibreChat blind-adoption run, where a world injected AWS_TWIN_URL that no
6
+ // injector vendor reads and nothing said so.
7
+ import { createRequire } from 'node:module';
8
+
9
+ /** The slice of `@volter/world-core/inject` reused here (data + pure functions only). */
10
+ export type InjectModule = {
11
+ readMap(env: Record<string, string | undefined>): Record<string, string>;
12
+ /** `pathname` is optional; it only disambiguates hosts two vendors share (see inject.cjs). */
13
+ resolveTwin(host: string, map: Record<string, string>, pathname?: string): { vendor: string; origin: string } | null;
14
+ /** `S3_TWIN_URL` → 's3'; a `*_TWIN_URL` name readMap would never read → null. */
15
+ twinUrlVendor(name: string): string | null;
16
+ /** the headers a request routed to the application carries: the host and scheme it was made with */
17
+ appForwardHeaders(url: URL): Record<string, string>;
18
+ VENDOR_HOSTS: Record<string, (host: string, pathname?: string) => boolean>;
19
+ restore(): void;
20
+ };
21
+
22
+ let injectCache: InjectModule | null = null;
23
+
24
+ /** Lazily load the injector's host→twin table. Loading the CJS auto-installs its http/fetch
25
+ * patches as a side effect; we immediately `restore()` so requiring it here is inert (we only
26
+ * want the data + pure functions, not to patch THIS process). */
27
+ export function loadInject(): InjectModule {
28
+ if (injectCache) return injectCache;
29
+ const require = createRequire(import.meta.url);
30
+ const mod = require('@volter/world-core/inject') as InjectModule;
31
+ try { mod.restore(); } catch { /* nothing was installed */ }
32
+ injectCache = mod;
33
+ return mod;
34
+ }
35
+
36
+ /** The vendor keys the injector can redirect for (the keys of VENDOR_HOSTS). */
37
+ export function injectableVendorKeys(): Set<string> {
38
+ return new Set(Object.keys(loadInject().VENDOR_HOSTS));
39
+ }
40
+
41
+ /** Does the injector actually read this env var name? (`S3_TWIN_URL` → 's3'; `AWS_TWIN_URL`
42
+ * → null — the consolidated aws twin answers under the s3/dynamodb/timestream keys.) */
43
+ export function twinUrlVendorFor(name: string): string | null {
44
+ return loadInject().twinUrlVendor(name);
45
+ }
46
+
47
+ /** WARN lines for twin-shaped injectEnv vars the injector will never read — an injectEnv like
48
+ * `AWS_TWIN_URL` is written into the world env, looks wired, and does NOTHING (readMap iterates
49
+ * VENDOR_HOSTS keys looking for `<VENDOR>_TWIN_URL`; there is no `aws` vendor key). `up` prints
50
+ * these loudly so the disagreement is visible at boot instead of surfacing as silently-real
51
+ * vendor traffic later. Vars that do not end in `_TWIN_URL` are app-read config (SUPABASE_URL,
52
+ * LIVEKIT_URL, …), not injector input — never warned about. */
53
+ export function inertInjectEnvWarnings(
54
+ services: Array<{ id: string; injectEnv?: string }>,
55
+ ): string[] {
56
+ const warnings: string[] = [];
57
+ for (const service of services) {
58
+ const name = service.injectEnv;
59
+ if (!name || !/_TWIN_URL$/.test(name)) continue;
60
+ if (twinUrlVendorFor(name) !== null) continue;
61
+ const stem = name.replace(/_TWIN_URL$/, '');
62
+ const hint = stem.toUpperCase() === 'AWS'
63
+ ? ' The consolidated aws twin is read under its per-AREA keys — S3_TWIN_URL / DYNAMODB_TWIN_URL / TIMESTREAM_TWIN_URL / SESV2_TWIN_URL / SECRETSMANAGER_TWIN_URL / BEDROCK_TWIN_URL — point those at the aws twin instead.'
64
+ : '';
65
+ warnings.push(
66
+ `[volter-world] WARN: service "${service.id}" injects ${name}, but the injector reads no such vendor` +
67
+ ` ("${stem.toLowerCase()}" is not a VENDOR_HOSTS key in @volter/world-core/inject) — the var is INERT and this` +
68
+ ` vendor's SDK traffic will NOT be redirected to the twin.${hint}`,
69
+ );
70
+ }
71
+ return warnings;
72
+ }
@@ -0,0 +1,168 @@
1
+ // Durable ownership of one World. This inventory does not allocate or reserve capacity.
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { volterHome } from '@volter/world-core';
5
+ import { hostname } from 'node:os';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { stateDirName, withFileLock } from '@volter/world-core';
8
+ import { checkedOwner } from './consumers.ts';
9
+
10
+ export type WorldLifecycleRecord = {
11
+ version: 1; world: string; root: string; identity: string; instanceCreatedAt: string;
12
+ owner?: string; ownerUid?: number; ownerPid: number; hostname: string; startedAt: string;
13
+ phase: 'starting' | 'running' | 'cleanup-pending'; pids: number[]; log: string;
14
+ };
15
+
16
+ const directory = () => join(volterHome(), 'world-lifecycles');
17
+ const identity = (root: string, world: string) => createHash('sha256').update(`${resolve(root)}\0${world}`).digest('hex');
18
+ const recordPath = (root: string, world: string) => join(directory(), `${identity(root, world)}.json`);
19
+ const legacyPath = (root: string, world: string) => join(volterHome(), 'world-resource-claims', `${identity(root, world)}.json`);
20
+ export const lifecycleLogPath = (root: string, world: string) => join(resolve(root), stateDirName(), 'worlds', '.lifecycle', `${world}.log`);
21
+
22
+ function read(path: string): WorldLifecycleRecord {
23
+ const record = JSON.parse(readFileSync(path, 'utf8')) as WorldLifecycleRecord;
24
+ if (record?.version !== 1 || typeof record.world !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(record.world)
25
+ || typeof record.root !== 'string' || !record.root || record.identity !== identity(record.root, record.world)
26
+ || !Number.isFinite(Date.parse(record.instanceCreatedAt)) || !Number.isFinite(Date.parse(record.startedAt))
27
+ || !Number.isInteger(record.ownerPid) || record.ownerPid <= 0 || typeof record.hostname !== 'string'
28
+ || !['starting', 'running', 'cleanup-pending'].includes(record.phase) || !Array.isArray(record.pids)
29
+ || record.pids.some(pid => !Number.isInteger(pid) || pid <= 0)
30
+ || record.log !== lifecycleLogPath(record.root, record.world)) throw new Error('invalid lifecycle record');
31
+ checkedOwner(record.owner);
32
+ return record;
33
+ }
34
+
35
+ function write(path: string, record: WorldLifecycleRecord): void {
36
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
37
+ const temporary = `${path}.${randomUUID()}.tmp`;
38
+ try {
39
+ writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
40
+ renameSync(temporary, path);
41
+ } finally { rmSync(temporary, { force: true }); }
42
+ }
43
+
44
+ /** Called under the instance lock, before replacing data. Only this identity is read. */
45
+ export function assertLifecycleStartable(root: string, world: string): void {
46
+ if (existsSync(legacyPath(root, world))) throw new Error(`World "${world}" has a legacy ownership record; finish teardown using its pinned old runtime before migration. Record retained: ${legacyPath(root, world)}`);
47
+ if (existsSync(recordPath(root, world))) throw new Error(`World "${world}" still owns a lifecycle record; complete volter-world down before replacing it. Record retained: ${recordPath(root, world)}`);
48
+ }
49
+
50
+ /** A missing process is not successful teardown; records persist until explicit release. */
51
+ export function beginWorldLifecycle(root: string, world: string, instanceCreatedAt: string, owner?: string): void {
52
+ checkedOwner(owner);
53
+ assertLifecycleStartable(root, world);
54
+ const record: WorldLifecycleRecord = {
55
+ version: 1, world, root: resolve(root), identity: identity(root, world), instanceCreatedAt,
56
+ owner, ownerUid: process.getuid?.(), ownerPid: process.pid, hostname: hostname(),
57
+ startedAt: new Date().toISOString(), phase: 'starting', pids: [], log: lifecycleLogPath(root, world),
58
+ };
59
+ recordWorldLifecycleEvent(root, world, 'recording startup ownership; no capacity reserved');
60
+ write(recordPath(root, world), record);
61
+ }
62
+
63
+ function update(root: string, world: string, change: (record: WorldLifecycleRecord) => WorldLifecycleRecord): void {
64
+ const path = recordPath(root, world);
65
+ withFileLock(`${path}.lock`, () => {
66
+ if (!existsSync(path)) throw new Error(`World "${world}" lifecycle record is missing; ownership update refused`);
67
+ const record = read(path);
68
+ if (record.identity !== identity(root, world)) throw new Error('World lifecycle identity mismatch');
69
+ write(path, change(record));
70
+ });
71
+ }
72
+
73
+ export function handoffWorldLifecycle(root: string, world: string, pids: number[]): void {
74
+ update(root, world, record => ({ ...record, phase: 'running', pids: [...new Set(pids.filter(pid => pid > 0))] }));
75
+ }
76
+
77
+ export function retainWorldLifecycleForCleanup(root: string, world: string): void {
78
+ if (existsSync(legacyPath(root, world))) throw new Error(`World "${world}" has legacy ownership; use its pinned runtime for teardown`);
79
+ if (!existsSync(recordPath(root, world))) return;
80
+ update(root, world, record => ({ ...record, phase: 'cleanup-pending' }));
81
+ }
82
+
83
+ export function recordWorldLifecycleEvent(root: string, world: string, message: string): void {
84
+ const path = lifecycleLogPath(root, world);
85
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
86
+ writeFileSync(path, `${new Date().toISOString()} ${message}\n`, { flag: 'a', mode: 0o600 });
87
+ }
88
+
89
+ /** Called only after verified cleanup. Never touches legacy records. */
90
+ export function releaseWorldLifecycle(root: string, world: string): void {
91
+ const path = recordPath(root, world);
92
+ if (!existsSync(path)) return;
93
+ withFileLock(`${path}.lock`, () => {
94
+ if (!existsSync(path)) return;
95
+ const record = read(path);
96
+ if (record.identity !== identity(root, world)) throw new Error('World lifecycle identity mismatch');
97
+ // The receipt line is evidence, not a precondition: failing to append it must not keep ownership (and so the next
98
+ // `up`) blocked after a verified teardown.
99
+ try { recordWorldLifecycleEvent(root, world, 'teardown verified; ownership released'); } catch { /* released regardless */ }
100
+ rmSync(path);
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Releases the record of a World whose instance metadata is gone when nothing it
106
+ * recorded can still run: the record is this host's and this user's, and its owner
107
+ * and every process it handed off have exited. There is nothing left to tear down,
108
+ * so the record is the only thing left to release. Anything alive, another host's,
109
+ * another user's, or unreadable keeps the record. Returns whether it released.
110
+ */
111
+ export function releaseAbandonedWorldLifecycle(root: string, world: string): boolean {
112
+ const path = recordPath(root, world);
113
+ if (!existsSync(path)) return false;
114
+ return withFileLock(`${path}.lock`, () => {
115
+ if (!existsSync(path)) return false;
116
+ let record: WorldLifecycleRecord;
117
+ try { record = read(path); } catch { return false; }
118
+ if (record.identity !== identity(root, world) || record.hostname !== hostname()) return false;
119
+ if (record.ownerUid !== undefined && record.ownerUid !== process.getuid?.()) return false;
120
+ if ([record.ownerPid, ...record.pids].some((pid) => processExists(pid))) return false;
121
+ try { recordWorldLifecycleEvent(root, world, 'instance metadata gone and every recorded process exited; ownership released'); } catch { /* the root may be gone with it */ }
122
+ rmSync(path);
123
+ return true;
124
+ });
125
+ }
126
+
127
+ function processExists(pid: number): boolean {
128
+ try { process.kill(pid, 0); return true; } catch (cause) { return (cause as NodeJS.ErrnoException).code === 'EPERM'; }
129
+ }
130
+
131
+ export function hasWorldLifecycle(root: string, world: string): boolean {
132
+ return existsSync(recordPath(root, world)) || existsSync(legacyPath(root, world));
133
+ }
134
+
135
+ export function inspectWorldLifecycles() {
136
+ const worlds: WorldLifecycleRecord[] = [];
137
+ const warnings: string[] = [];
138
+ for (const file of existsSync(directory()) ? readdirSync(directory()).sort() : []) {
139
+ if (!file.endsWith('.json')) continue;
140
+ try {
141
+ const record = read(join(directory(), file));
142
+ if (file !== `${record.identity}.json`) throw new Error('filename identity mismatch');
143
+ worlds.push(record);
144
+ } catch { warnings.push(`Unreadable lifecycle record ${file}; retained, ownership unknown.`); }
145
+ }
146
+ const legacyDirectory = join(volterHome(), 'world-resource-claims');
147
+ const legacyRecords: Array<{ path: string; world?: string; root?: string; cleanupPending?: boolean; readable: boolean }> = [];
148
+ for (const file of existsSync(legacyDirectory) ? readdirSync(legacyDirectory).sort() : []) {
149
+ if (!file.endsWith('.json')) continue;
150
+ const path = join(legacyDirectory, file);
151
+ try {
152
+ const old = JSON.parse(readFileSync(path, 'utf8'));
153
+ const root = old.root ?? (typeof old.log === 'string' ? resolve(dirname(old.log), '../../..') : undefined);
154
+ if (typeof root !== 'string' || typeof old.world !== 'string' || identity(root, old.world) !== old.identity
155
+ || file !== `${old.identity}.json`) throw new Error('unknown identity');
156
+ legacyRecords.push({ path, world: old.world, root, cleanupPending: old.cleanupPending === true, readable: true });
157
+ } catch { legacyRecords.push({ path, readable: false }); warnings.push(`Unreadable legacy record ${file}; retained, not charged as capacity.`); }
158
+ }
159
+ return { worlds, legacyRecords, warnings };
160
+ }
161
+
162
+ /** Runtime phase is meaningful only for the same instance generation; it is not a health probe. */
163
+ export function isWorldLifecycleRunning(root: string, world: string, instanceCreatedAt: string): boolean {
164
+ try {
165
+ const record = read(recordPath(root, world));
166
+ return record.identity === identity(root, world) && record.instanceCreatedAt === instanceCreatedAt && record.phase === 'running';
167
+ } catch { return false; }
168
+ }
package/src/origin.ts ADDED
@@ -0,0 +1,134 @@
1
+ import { captureHistory, historyDigest, originHead, publishOriginHistory, type HistoryLayout, type HistoryReference, readHistoryView, historyLength, worldPaths } from '@volter/world-core';
2
+ // `volter world clone` and `fetch` — a world's ORIGIN is another world, served (docs/concepts/the-model.md
3
+ // #a-branch-is-a-position-not-a-copy: a clone is a branch whose parent is at a URL): its whole log per twin is this world's PARENT. `clone` records the origin
4
+ // and brings every twin's log in from position zero; `fetch` appends what the origin has past the
5
+ // recorded position. Nothing here touches a vendor: an origin is always a world, the token is the
6
+ // served world's, and what arrives are entries — the parent log grows, the branch log is untouched.
7
+ import { join, resolve } from 'node:path';
8
+ import { appendOriginEntry, appendParentEntry, branchEntries, getActiveWorldStore, landedIds, originEntries, parentEntries, readBranchMeta, type Entry, wholeLog, withAncestryLock, writeBranchMeta } from '@volter/world-core';
9
+ import { saveWorldInstance, statusWorld } from './runtime.ts';
10
+ import type { WorldInstance } from './schema.ts';
11
+ import { fetchReady, isPathRemote, localWorld, TOKEN_HEADER } from './served-world.ts';
12
+
13
+ export type FetchOptions = { root?: string; url?: string; namespace?: string; key: string; full?: boolean; services?: string[]; views?: Record<string, HistoryReference>; now?: Date };
14
+ export type FetchOutcome = {
15
+ /** per twin: entries in the cache past the branch's position — what a rebase folds */
16
+ snapshots: Record<string, HistoryReference & { remoteView?: string }>; behind: Record<string, number>; world: string; origin: { url: string; namespace: string }; full: boolean; appended: Record<string, number>; files?: number };
17
+
18
+ function originOf(instance: WorldInstance, options: FetchOptions): { url: string; namespace: string } {
19
+ const url = options.url ?? instance.origin?.url; const namespace = options.namespace ?? instance.origin?.namespace;
20
+ if (!url || !namespace) throw new Error(`World "${instance.name}" has no origin recorded — \`volter remote add origin <url>\` or \`volter world clone <url>\` first`);
21
+ if (!isPathRemote(url) && !/^[a-z0-9_-]+\/[a-z0-9_-]+$/i.test(namespace)) throw new Error(`a served world is <org>/<world>, got ${JSON.stringify(namespace)}`);
22
+ return { url: url.replace(/\/+$/, ''), namespace };
23
+ }
24
+
25
+ /** A twin's whole log from the origin, from a position: over http from a served world's door, or
26
+ * straight off the disk of a world at a path. */
27
+ /** The state service a twin records under: its own name, else the one state dir under its control root (slack → chat). */
28
+ function stateServiceOf(controlRoot: string, service: string): string {
29
+ const stateRoot = join(controlRoot, '.volter', 'world');
30
+ const store = getActiveWorldStore();
31
+ const states = store.list(stateRoot).filter((name) => store.stat(join(stateRoot, name))?.isDirectory);
32
+ return states.includes(service) ? service : states.length === 1 ? states[0]! : service;
33
+ }
34
+
35
+ type HistoryPage = { state: string; view: string; position: number; after: number; layout: HistoryLayout; digest: string; entries: Entry[] };
36
+ async function pageOf(origin: { url: string; namespace: string }, service: string, after: number, key: string, view?: string): Promise<HistoryPage> {
37
+ return door<HistoryPage>(origin, `log/${service}?after=${after}&limit=500${view ? `&view=${encodeURIComponent(view)}` : ''}`, key);
38
+ }
39
+
40
+ async function door<T>(origin: { url: string; namespace: string }, path: string, key: string): Promise<T> {
41
+ const res = await fetchReady(`${origin.url}/-/${origin.namespace}/${path}`, { headers: { [TOKEN_HEADER]: key } });
42
+ if (!res.ok) throw new Error(`origin ${origin.url}/-/${origin.namespace}/${path} answered ${res.status}: ${(await res.text()).slice(0, 200)}`);
43
+ return (await res.json()) as T;
44
+ }
45
+
46
+ /** Bring in what the origin has past this world's recorded position, per twin; `full` starts from
47
+ * zero and records the origin as this world's. */
48
+ export async function fetchFromOrigin(name: string, options: FetchOptions): Promise<FetchOutcome> {
49
+ const root = resolve(options.root ?? process.cwd());
50
+ const instance = statusWorld(name, root);
51
+ const origin = originOf(instance, options);
52
+ const at_ = (options.now ?? new Date()).toISOString();
53
+ const services = (options.services ?? Object.keys(instance.services)).sort();
54
+ const appended: Record<string, number> = {};
55
+ const behind: Record<string, number> = {};
56
+ const snapshots: FetchOutcome['snapshots'] = {};
57
+ withAncestryLock(() => {
58
+ for (const service of services) {
59
+ const controlRoot = join(instance.dirs.data, service);
60
+ const meta = readBranchMeta(stateServiceOf(controlRoot, service), controlRoot);
61
+ if (meta?.parent && !meta.origin) throw new Error('Cannot attach a first origin to an existing local fork; clone the origin before branching');
62
+ }
63
+ });
64
+ for (const service of services) {
65
+ const controlRoot = join(instance.dirs.data, service);
66
+ const state = stateServiceOf(controlRoot, service);
67
+ if (isPathRemote(origin.url)) {
68
+ // A PARENT ON THIS MACHINE is read live through the pointer, like a local branch (contract
69
+ // "Just like Neon", 1): nothing is copied; a clone points at the parent's head, a fetch only
70
+ // says how far the parent has moved past this branch's position
71
+ withAncestryLock(() => {
72
+ const target = localWorld(origin.url);
73
+ const at = join(target.instance.dirs.data, service);
74
+ const parentState = stateServiceOf(at, service);
75
+ const head = options.views?.[service] ?? captureHistory(parentState, at);
76
+ const parentLength = historyLength(readHistoryView(worldPaths(parentState, at).dir, head.view).layout);
77
+ if (head.position !== parentLength) throw new Error('Origin view and position disagree');
78
+ snapshots[service] = { view: head.view, position: parentLength };
79
+ const meta = readBranchMeta(state, controlRoot);
80
+ if (meta?.parent && !meta.origin) throw new Error('Cannot attach a first origin to an existing local fork; clone the origin before branching');
81
+ const before = meta?.origin?.position ?? meta?.parent?.position ?? 0;
82
+ const descriptor = readHistoryView(worldPaths(parentState, at).dir, head.view);
83
+ const tracking = { at, directory: descriptor.owner.directory, generation: descriptor.owner.generation, view: head.view, position: parentLength, depth: 0 };
84
+ // a world with no pointer adopts this origin as its parent: a clone starts at the origin's head,
85
+ // a world that only added the remote starts at zero and rebases onto it
86
+ if (!meta?.parent) writeBranchMeta(state, { parent: { at, position: options.full ? parentLength : 0, view: head.view }, origin: { ...tracking, ...(!options.full ? { position: 0, incomplete: true } : {}) }, fetchedOrigin: tracking, branchedAt: at_ }, controlRoot);
87
+ else writeBranchMeta(state, { ...meta, fetchedOrigin: tracking }, controlRoot);
88
+ const selected = readBranchMeta(state, controlRoot);
89
+ const pointer = selected?.origin ?? selected?.parent;
90
+ appended[service] = Math.max(0, parentLength - before);
91
+ behind[service] = pointer ? Math.max(0, parentLength - pointer.position) || (pointer.view === head.view ? 0 : 1) : 0;
92
+ });
93
+ continue;
94
+ }
95
+ // Download one immutable view, then publish its complete cache head. No mixed pages.
96
+ const requested = options.views?.[service];
97
+ const first = await pageOf(origin, service, 0, options.key, requested?.view);
98
+ if (requested && (first.view !== requested.view || first.position !== requested.position)) throw new Error('Origin did not return the requested history snapshot');
99
+ if (!first.view || !Number.isInteger(first.position) || first.position < 0 || first.after !== 0) throw new Error('Origin did not return an immutable history view');
100
+ const entries: Entry[] = [];
101
+ let page = first;
102
+ for (;;) {
103
+ if (page.view !== first.view || page.position !== first.position || page.after !== entries.length || page.state !== first.state || page.digest !== first.digest || historyDigest(page.layout) !== historyDigest(first.layout)) throw new Error('Origin changed history view during pagination');
104
+ entries.push(...page.entries);
105
+ if (entries.length > first.position) throw new Error('Origin history exceeds its declared position');
106
+ if (entries.length === first.position) break;
107
+ if (page.entries.length === 0) throw new Error('Origin history page ended before its declared position');
108
+ page = await pageOf(origin, service, entries.length, options.key, first.view);
109
+ }
110
+ if (historyDigest({ layout: first.layout, entries }) !== first.digest) throw new Error('Origin history digest mismatch');
111
+ withAncestryLock(() => {
112
+ const meta = readBranchMeta(state, controlRoot);
113
+ if (meta?.parent && !meta.origin) throw new Error('Cannot attach a first origin to an existing local fork; clone the origin before branching');
114
+ const cached = publishOriginHistory(state, controlRoot, first.view, first.layout, entries);
115
+ appended[service] = cached.appended;
116
+ snapshots[service] = { view: cached.view, position: entries.length, remoteView: first.view };
117
+ const descriptor = readHistoryView(worldPaths(state, controlRoot).dir, cached.view);
118
+ const tracking = { at: `${origin.url}/${origin.namespace}`, directory: descriptor.owner.directory, generation: descriptor.owner.generation, view: cached.view, position: entries.length, remoteView: first.view, depth: 0 };
119
+ if (!meta?.parent) writeBranchMeta(state, { parent: { at: tracking.at, position: options.full ? entries.length : 0, view: cached.view, remoteView: first.view }, origin: { ...tracking, ...(!options.full ? { position: 0, incomplete: true } : {}) }, fetchedOrigin: tracking, branchedAt: at_ }, controlRoot);
120
+ else writeBranchMeta(state, { ...meta, fetchedOrigin: tracking }, controlRoot);
121
+ const selected = readBranchMeta(state, controlRoot);
122
+ const pointer = selected?.origin ?? selected?.parent;
123
+ // Changed same-length snapshots are still drift; offsets are scoped to a view.
124
+ behind[service] = pointer ? Math.max(0, entries.length - pointer.position) || (pointer.remoteView === first.view ? 0 : 1) : 0;
125
+ });
126
+
127
+ }
128
+ saveWorldInstance({ ...instance, origin: { ...origin, ...(options.full ? { clonedAt: at_ } : instance.origin?.clonedAt ? { clonedAt: instance.origin.clonedAt } : {}), fetchedAt: at_ } });
129
+ return { world: name, origin, full: options.full ?? false, appended, behind, snapshots };
130
+ }
131
+
132
+ export function worldOrigin(name: string, root?: string): WorldInstance['origin'] | null {
133
+ return statusWorld(name, resolve(root ?? process.cwd())).origin ?? null;
134
+ }
@@ -0,0 +1,128 @@
1
+ // PACK FACTS OVERLAY — the world-runtime consumer of the generated pack-facts artifact
2
+ // (packages/world-core/generated/pack-facts.json, compiled by scripts/pack-facts.ts
3
+ // from each pack's `pack: TwinPack` descriptor).
4
+ //
5
+ // During the descriptor-first migration (adding-a-twin.md §3) (adding-a-twin.md §3 (the descriptor is the one home)), the
6
+ // adoption tables here are the UNION of the legacy central maps and the pack declarations.
7
+ // The overlay MUTATES the central tables at module init — every use-site keeps reading the
8
+ // object it always read — and a fact declared in BOTH places throws: dual declaration is the
9
+ // drift this migration exists to end, so it is loud everywhere, not just in a test.
10
+
11
+ import factsDocument from '@volter/world-core/generated/pack-facts.json' with { type: 'json' };
12
+
13
+ export type PackAdoption = { sdks?: string[]; pypi?: string[]; scopes?: string[]; envStems?: string[]; worldIds?: string[]; tools?: Array<{ package: string; usage: 'build' | 'deployment' }> };
14
+ export type PackFacts = {
15
+ /** Existing descriptor transport, compiled into the same artifact as endpoint/adoption facts. */
16
+ transport?: string;
17
+ /** Optional native frontend of the same state owner, selected explicitly on the pack CLI. */
18
+ nativeTransport?: { protocol: string; flag: string; upstreamEnv: string };
19
+ adoption?: PackAdoption;
20
+ hosts?: Array<{ host?: string; suffix?: string; hostPattern?: string; pathPattern?: string; key?: string; exclude?: true }>;
21
+ hostsNone?: string;
22
+ endpointEnv?: { name: string; templates?: Record<string, string>; note: string };
23
+ endpointEnvNone?: string;
24
+ /** The pack's colocatable `create<Name>TwinServer` factory export, derived (or descriptor-
25
+ * declared under ambiguity) by scripts/pack-facts.ts. Consumed by `init` to emit
26
+ * `colocate:` service entries so a world's twins boot inside ONE host process (R2a). */
27
+ serveExport?: string;
28
+ /** R16: the protocol major the package targets and its standing against the kernel that compiled the artifact. */
29
+ protocol?: { declared?: string; major: number; standing: 'current' | 'deprecated' | 'refused' | 'assumed' };
30
+ };
31
+
32
+ // world-runtime and core are sibling workspace packages; the artifact is committed,
33
+ // so a missing file is a broken checkout (or an unbuilt generator change), never a soft state.
34
+ // core's artifact, imported as a JSON module: the published runtime resolves it beside an installed
35
+ // core, a bundle carries it, and no host needs a filesystem to read it
36
+ export function packFacts(): Record<string, PackFacts> {
37
+ return (factsDocument as unknown as { packs: Record<string, PackFacts> }).packs;
38
+ }
39
+
40
+ const dual = (vendor: string, kind: string, key: string, holder: string): Error =>
41
+ new Error(
42
+ `pack-facts: ${kind} "${key}" is declared BOTH on the ${vendor} pack descriptor AND in the central ${holder} table — ` +
43
+ `the migration rule is one home per fact. Delete the central entry (the descriptor wins).`,
44
+ );
45
+
46
+ /** Overlay descriptor-declared npm SDK names onto the central SDK_TWINS map (project-inspect.ts). */
47
+ /** The PyPI half of the SDK map, built the same way from each descriptor's `adoption.pypi`
48
+ * (PEP 503 names). One name belongs to one pack; a second claim refuses at load. */
49
+ export function overlayPypiTwins(pypiTwins: Record<string, { vendor: string; twin: string }>): void {
50
+ for (const [vendor, facts] of Object.entries(packFacts())) {
51
+ for (const raw of facts.adoption?.pypi ?? []) {
52
+ const name = normalizePypiName(raw);
53
+ if (Object.hasOwn(pypiTwins, name)) {
54
+ const holder = pypiTwins[name]!.vendor;
55
+ if (holder !== vendor) throw new Error(`pack-facts overlay: pypi package "${name}" is claimed by BOTH the "${holder}" and "${vendor}" packs' descriptors — one name, one pack`);
56
+ continue;
57
+ }
58
+ pypiTwins[name] = { vendor, twin: `@volter/twin-${vendor}` };
59
+ }
60
+ }
61
+ }
62
+ /** PEP 503: case-insensitive, runs of `-_.` collapse to one `-`. */
63
+ export function normalizePypiName(name: string): string {
64
+ return name.trim().toLowerCase().replace(/[-_.]+/g, '-');
65
+ }
66
+
67
+ export function overlaySdkTwins(sdkTwins: Record<string, { vendor: string; twin: string }>): void {
68
+ for (const [vendor, facts] of Object.entries(packFacts())) {
69
+ for (const sdk of facts.adoption?.sdks ?? []) {
70
+ // Object.hasOwn, not `in`: `in` walks the prototype chain, so a pathological name like
71
+ // 'constructor' would throw a bogus dual error. And when the holder is another PACK's
72
+ // overlay (two descriptors claiming one name), the right fix is different from a hand-
73
+ // table collision — say which it is (§9 round two L2, 2026-08-31).
74
+ if (Object.hasOwn(sdkTwins, sdk)) {
75
+ const holder = sdkTwins[sdk]!.vendor;
76
+ if (holder !== vendor) {
77
+ throw new Error(
78
+ `pack-facts overlay: npm sdk "${sdk}" is claimed by BOTH the "${holder}" and "${vendor}" packs' descriptors (or by ${holder} in the central SDK_TWINS table) — one home per fact: decide which pack owns the client and remove the other claim.`,
79
+ );
80
+ }
81
+ throw dual(vendor, 'sdk', sdk, 'SDK_TWINS');
82
+ }
83
+ sdkTwins[sdk] = { vendor, twin: `@volter/twin-${vendor}` };
84
+ }
85
+ }
86
+ }
87
+
88
+ /** Overlay descriptor-declared scopes/env stems/world ids onto covers.ts's central maps. */
89
+ export function overlayCoversMaps(maps: {
90
+ scopeVendors: Record<string, string>;
91
+ envStemVendors: Record<string, string>;
92
+ vendorWorldIds: Record<string, string[]>;
93
+ }): void {
94
+ for (const [vendor, facts] of Object.entries(packFacts())) {
95
+ for (const scope of facts.adoption?.scopes ?? []) {
96
+ if (scope in maps.scopeVendors) throw dual(vendor, 'scope', scope, 'SDK_SCOPE_VENDORS');
97
+ maps.scopeVendors[scope] = vendor;
98
+ }
99
+ for (const stemRaw of facts.adoption?.envStems ?? []) {
100
+ const stem = stemRaw.toLowerCase();
101
+ if (stem in maps.envStemVendors) throw dual(vendor, 'env stem', stem, 'ENV_STEM_VENDORS');
102
+ maps.envStemVendors[stem] = vendor;
103
+ }
104
+ const worldIds = facts.adoption?.worldIds ?? [];
105
+ if (worldIds.length > 0) {
106
+ if (vendor in maps.vendorWorldIds) throw dual(vendor, 'world ids', vendor, 'VENDOR_WORLD_IDS');
107
+ maps.vendorWorldIds[vendor] = [vendor, ...worldIds];
108
+ }
109
+ }
110
+ }
111
+
112
+ /** Overlay descriptor-declared endpoint-env wiring for packs with no injectable host.
113
+ * Packs that also declare hosts are wired through the injector; init reads their optional
114
+ * endpoint templates directly from pack facts without misclassifying them as app-read-only. */
115
+ export function overlayEndpointEnv(
116
+ table: Record<string, { injectEnv?: string; injectEnvTemplates?: Record<string, string>; note: string }>,
117
+ ): void {
118
+ for (const [vendor, facts] of Object.entries(packFacts())) {
119
+ if (facts.endpointEnv === undefined) continue;
120
+ if ((facts.hosts?.length ?? 0) > 0) continue;
121
+ if (vendor in table) throw dual(vendor, 'endpoint env', vendor, 'APP_READ_ENDPOINT_ENV');
122
+ table[vendor] = {
123
+ injectEnv: facts.endpointEnv.name,
124
+ ...(facts.endpointEnv.templates ? { injectEnvTemplates: facts.endpointEnv.templates } : {}),
125
+ note: facts.endpointEnv.note,
126
+ };
127
+ }
128
+ }
@@ -0,0 +1,141 @@
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.
8
+ //
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';
13
+ import { connect } from 'node:net';
14
+ import { dirname, join } from 'node:path';
15
+ import { spawn } from 'node:child_process';
16
+
17
+ export interface InfraService { kind: string; hostPort: number }
18
+
19
+ /** Parse the services out of world.infrastructure.yml. This is NOT a YAML
20
+ * parser: init's renderComposeFile emits a deterministic shape, and this
21
+ * reads exactly that shape back (service key at 2-space indent = the kind;
22
+ * the single loopback port mapping beneath it). Anything else is a malformed
23
+ * definition and fails loudly. */
24
+ export function parseInfraDefinition(text: string): InfraService[] {
25
+ const services: InfraService[] = [];
26
+ let current: string | null = null;
27
+ let inServices = false;
28
+ for (const line of text.split(/\r?\n/)) {
29
+ if (/^services:\s*$/.test(line)) { inServices = true; continue; }
30
+ if (!inServices) continue;
31
+ const service = line.match(/^ {2}([a-z][a-z0-9_-]*):\s*$/);
32
+ if (service) { current = service[1]!; continue; }
33
+ const port = line.match(/^ {6}- "127\.0\.0\.1:(\d+):\d+"\s*$/);
34
+ if (port && current) {
35
+ services.push({ kind: current, hostPort: Number(port[1]) });
36
+ current = null;
37
+ }
38
+ }
39
+ return services;
40
+ }
41
+
42
+ /** Endpoints of the declared infrastructure, published through external.discover. Only URLs
43
+ * whose protocol and loopback port match a declared service belong to this lifecycle. */
44
+ export function infraConnections(services: InfraService[], env: Record<string, string | undefined>): Record<string, string> {
45
+ const connections: Record<string, string> = {};
46
+ for (const [name, value] of Object.entries(env)) {
47
+ if (value === undefined) continue;
48
+ try {
49
+ const url = new URL(value);
50
+ if (url.hostname !== '127.0.0.1') continue;
51
+ const kind = url.protocol === 'postgresql:' ? 'postgres' : url.protocol.slice(0, -1);
52
+ if (services.some((service) => service.kind === kind && service.hostPort === Number(url.port))) connections[name] = value;
53
+ } catch { /* not an infrastructure endpoint */ }
54
+ }
55
+ return connections;
56
+ }
57
+
58
+ const HOST = join(import.meta.dir, 'pglite-host.mjs');
59
+ const READY_TIMEOUT_MS = 60_000;
60
+
61
+ function pidPath(dataDir: string, kind: string): string {
62
+ return join(dataDir, `pglite-${kind}.pid`);
63
+ }
64
+
65
+ function alive(pid: number): boolean {
66
+ try { process.kill(pid, 0); return true; } catch { return false; }
67
+ }
68
+
69
+ function listening(port: number): Promise<boolean> {
70
+ return new Promise((resolve) => {
71
+ const socket = connect({ host: '127.0.0.1', port, timeout: 1000 }, () => {
72
+ socket.destroy();
73
+ resolve(true);
74
+ });
75
+ socket.on('error', () => resolve(false));
76
+ socket.on('timeout', () => { socket.destroy(); resolve(false); });
77
+ });
78
+ }
79
+
80
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
81
+
82
+ /** The kinds this backing cannot serve, or [] when it can serve the world. */
83
+ export function unsupportedKinds(services: InfraService[]): string[] {
84
+ return [...new Set(services.filter((s) => s.kind !== 'postgres').map((s) => s.kind))];
85
+ }
86
+
87
+ export async function pgliteUp(services: InfraService[], dataDir: string): Promise<void> {
88
+ for (const service of services) {
89
+ const pidFile = pidPath(dataDir, service.kind);
90
+ 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}`);
111
+ }
112
+ await sleep(200);
113
+ }
114
+ }
115
+ }
116
+
117
+ export async function pgliteStatus(services: InfraService[], dataDir: string): Promise<{ ok: boolean; ready: number }> {
118
+ let ready = 0;
119
+ for (const service of services) {
120
+ const pidFile = pidPath(dataDir, service.kind);
121
+ if (!existsSync(pidFile)) continue;
122
+ const pid = Number(readFileSync(pidFile, 'utf8').trim());
123
+ if (alive(pid) && await listening(service.hostPort)) ready += 1;
124
+ }
125
+ return { ok: ready === services.length && services.length > 0, ready };
126
+ }
127
+
128
+ export async function pgliteDown(services: InfraService[], dataDir: string): Promise<void> {
129
+ for (const service of services) {
130
+ const pidFile = pidPath(dataDir, service.kind);
131
+ if (!existsSync(pidFile)) continue;
132
+ const pid = Number(readFileSync(pidFile, 'utf8').trim());
133
+ if (alive(pid)) {
134
+ try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
135
+ const deadline = Date.now() + 5000;
136
+ while (alive(pid) && Date.now() < deadline) await sleep(100);
137
+ if (alive(pid)) { try { process.kill(pid, 'SIGKILL'); } catch { /* raced */ } }
138
+ }
139
+ rmSync(pidFile, { force: true });
140
+ }
141
+ }