@volter/world-core 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 (180) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +29 -0
  3. package/app-route.cjs +154 -0
  4. package/app-route.d.cts +7 -0
  5. package/attach.cjs +80 -0
  6. package/dist/app-route.cjs +154 -0
  7. package/dist/app-route.d.cts +7 -0
  8. package/dist/attach.cjs +80 -0
  9. package/dist/generated/pack-facts.json +4306 -0
  10. package/dist/inject.cjs +1097 -0
  11. package/dist/network-policy.cjs +92 -0
  12. package/dist/network-policy.d.cts +10 -0
  13. package/dist/src/actions.d.ts +276 -0
  14. package/dist/src/actions.js +436 -0
  15. package/dist/src/ancestry.d.ts +22 -0
  16. package/dist/src/ancestry.js +238 -0
  17. package/dist/src/args.d.ts +3 -0
  18. package/dist/src/args.js +12 -0
  19. package/dist/src/blob-store.d.ts +55 -0
  20. package/dist/src/blob-store.js +186 -0
  21. package/dist/src/brand-tokens.d.ts +2 -0
  22. package/dist/src/brand-tokens.js +17 -0
  23. package/dist/src/changeset.d.ts +431 -0
  24. package/dist/src/changeset.js +0 -0
  25. package/dist/src/client-bundle.d.ts +1 -0
  26. package/dist/src/client-bundle.js +28 -0
  27. package/dist/src/credential.d.ts +38 -0
  28. package/dist/src/credential.js +114 -0
  29. package/dist/src/derived-core.d.ts +452 -0
  30. package/dist/src/derived-core.js +782 -0
  31. package/dist/src/derived.d.ts +84 -0
  32. package/dist/src/derived.js +122 -0
  33. package/dist/src/emit.d.ts +106 -0
  34. package/dist/src/emit.js +157 -0
  35. package/dist/src/executor.d.ts +120 -0
  36. package/dist/src/executor.js +387 -0
  37. package/dist/src/file-response.d.ts +3 -0
  38. package/dist/src/file-response.js +22 -0
  39. package/dist/src/fork.d.ts +26 -0
  40. package/dist/src/fork.js +68 -0
  41. package/dist/src/git/history.d.ts +36 -0
  42. package/dist/src/git/history.js +298 -0
  43. package/dist/src/git/index.d.ts +6 -0
  44. package/dist/src/git/index.js +6 -0
  45. package/dist/src/git/inflate.d.ts +11 -0
  46. package/dist/src/git/inflate.js +194 -0
  47. package/dist/src/git/objects.d.ts +64 -0
  48. package/dist/src/git/objects.js +161 -0
  49. package/dist/src/git/pack.d.ts +14 -0
  50. package/dist/src/git/pack.js +199 -0
  51. package/dist/src/git/refs.d.ts +19 -0
  52. package/dist/src/git/refs.js +35 -0
  53. package/dist/src/git/smart-http.d.ts +45 -0
  54. package/dist/src/git/smart-http.js +223 -0
  55. package/dist/src/hash.d.ts +38 -0
  56. package/dist/src/hash.js +48 -0
  57. package/dist/src/head.d.ts +140 -0
  58. package/dist/src/head.js +313 -0
  59. package/dist/src/history.d.ts +76 -0
  60. package/dist/src/history.js +322 -0
  61. package/dist/src/index.d.ts +73 -0
  62. package/dist/src/index.js +98 -0
  63. package/dist/src/lifecycle.d.ts +1 -0
  64. package/dist/src/lifecycle.js +8 -0
  65. package/dist/src/log.d.ts +254 -0
  66. package/dist/src/log.js +801 -0
  67. package/dist/src/mirror-shell.d.ts +2 -0
  68. package/dist/src/mirror-shell.js +13 -0
  69. package/dist/src/observe.d.ts +49 -0
  70. package/dist/src/observe.js +148 -0
  71. package/dist/src/pack-assets.d.ts +30 -0
  72. package/dist/src/pack-assets.js +88 -0
  73. package/dist/src/packRegistry.d.ts +374 -0
  74. package/dist/src/packRegistry.js +142 -0
  75. package/dist/src/placeholder-remote.d.ts +22 -0
  76. package/dist/src/placeholder-remote.js +86 -0
  77. package/dist/src/proxy.d.ts +25 -0
  78. package/dist/src/proxy.js +155 -0
  79. package/dist/src/rateBudget.d.ts +367 -0
  80. package/dist/src/rateBudget.js +925 -0
  81. package/dist/src/references.d.ts +18 -0
  82. package/dist/src/references.js +27 -0
  83. package/dist/src/remote-execute.d.ts +22 -0
  84. package/dist/src/remote-execute.js +1 -0
  85. package/dist/src/resource-blob.d.ts +10 -0
  86. package/dist/src/resource-blob.js +56 -0
  87. package/dist/src/scenario.d.ts +197 -0
  88. package/dist/src/scenario.js +425 -0
  89. package/dist/src/schemas.d.ts +78 -0
  90. package/dist/src/schemas.js +50 -0
  91. package/dist/src/serve-http.d.ts +48 -0
  92. package/dist/src/serve-http.js +340 -0
  93. package/dist/src/serve.d.ts +147 -0
  94. package/dist/src/serve.js +507 -0
  95. package/dist/src/shared-blob-index.d.ts +4 -0
  96. package/dist/src/shared-blob-index.js +126 -0
  97. package/dist/src/state-system.d.ts +70 -0
  98. package/dist/src/state-system.js +90 -0
  99. package/dist/src/storage.d.ts +101 -0
  100. package/dist/src/storage.js +337 -0
  101. package/dist/src/twin-fetch.d.ts +64 -0
  102. package/dist/src/twin-fetch.js +91 -0
  103. package/dist/src/types.d.ts +40 -0
  104. package/dist/src/types.js +1 -0
  105. package/dist/src/v1-removed.d.ts +159 -0
  106. package/dist/src/v1-removed.js +124 -0
  107. package/dist/src/volter-home.d.ts +5 -0
  108. package/dist/src/volter-home.js +10 -0
  109. package/dist/src/world-clock.d.ts +4 -0
  110. package/dist/src/world-clock.js +32 -0
  111. package/dist/src/world-env.d.ts +3 -0
  112. package/dist/src/world-env.js +22 -0
  113. package/dist/src/world-store-sql.d.ts +27 -0
  114. package/dist/src/world-store-sql.js +86 -0
  115. package/dist/src/world-store.d.ts +168 -0
  116. package/dist/src/world-store.js +475 -0
  117. package/dist/src/worldConfig.d.ts +9 -0
  118. package/dist/src/worldConfig.js +17 -0
  119. package/dist/stream-bridge.cjs +80 -0
  120. package/dist/vendor-hosts.cjs +200 -0
  121. package/generated/pack-facts.json +4306 -0
  122. package/inject.cjs +1097 -0
  123. package/network-policy.cjs +92 -0
  124. package/network-policy.d.cts +10 -0
  125. package/package.json +103 -0
  126. package/src/actions.ts +564 -0
  127. package/src/ancestry.ts +213 -0
  128. package/src/args.ts +14 -0
  129. package/src/blob-store.ts +185 -0
  130. package/src/brand-tokens.ts +17 -0
  131. package/src/changeset.ts +1032 -0
  132. package/src/client-bundle.ts +29 -0
  133. package/src/credential.ts +140 -0
  134. package/src/derived-core.ts +1004 -0
  135. package/src/derived.ts +176 -0
  136. package/src/emit.ts +242 -0
  137. package/src/executor.ts +431 -0
  138. package/src/file-response.ts +22 -0
  139. package/src/fork.ts +89 -0
  140. package/src/git/history.ts +177 -0
  141. package/src/git/index.ts +6 -0
  142. package/src/git/inflate.ts +125 -0
  143. package/src/git/objects.ts +110 -0
  144. package/src/git/pack.ts +105 -0
  145. package/src/git/refs.ts +25 -0
  146. package/src/git/smart-http.ts +149 -0
  147. package/src/hash.ts +66 -0
  148. package/src/head.ts +318 -0
  149. package/src/history.ts +246 -0
  150. package/src/index.ts +323 -0
  151. package/src/lifecycle.ts +8 -0
  152. package/src/log.ts +793 -0
  153. package/src/mirror-shell.ts +15 -0
  154. package/src/observe.ts +130 -0
  155. package/src/pack-assets.ts +81 -0
  156. package/src/packRegistry.ts +408 -0
  157. package/src/placeholder-remote.ts +81 -0
  158. package/src/proxy.ts +183 -0
  159. package/src/rateBudget.ts +1115 -0
  160. package/src/references.ts +46 -0
  161. package/src/remote-execute.ts +26 -0
  162. package/src/resource-blob.ts +57 -0
  163. package/src/scenario.ts +479 -0
  164. package/src/schemas.ts +56 -0
  165. package/src/serve-http.ts +299 -0
  166. package/src/serve.ts +618 -0
  167. package/src/shared-blob-index.ts +108 -0
  168. package/src/state-system.ts +115 -0
  169. package/src/storage.ts +407 -0
  170. package/src/twin-fetch.ts +147 -0
  171. package/src/types.ts +50 -0
  172. package/src/v1-removed.ts +172 -0
  173. package/src/volter-home.ts +11 -0
  174. package/src/world-clock.ts +33 -0
  175. package/src/world-env.ts +18 -0
  176. package/src/world-store-sql.ts +118 -0
  177. package/src/world-store.ts +572 -0
  178. package/src/worldConfig.ts +27 -0
  179. package/stream-bridge.cjs +80 -0
  180. package/vendor-hosts.cjs +200 -0
@@ -0,0 +1,108 @@
1
+ // A disposable lookup cache, not a byte store. Only owning World files retain payloads.
2
+ import { createHash } from 'node:crypto';
3
+ import { closeSync, constants, fstatSync, ftruncateSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, rmSync, writeFileSync, type Stats } from 'node:fs';
4
+ import { isAbsolute, join, resolve } from 'node:path';
5
+
6
+ const MAX_BUCKET_BYTES = 128 * 1024;
7
+ const MAX_ENTRIES = 32;
8
+ const MAX_PATHS = 4;
9
+ type Index = Record<string, string[]>;
10
+
11
+ function privateFile(stat: Stats): boolean {
12
+ return stat.isFile() && stat.nlink === 1 && (!process.getuid || stat.uid === process.getuid()) && (stat.mode & 0o077) === 0;
13
+ }
14
+
15
+ function privateDirectory(root: string): boolean {
16
+ try {
17
+ const stat = lstatSync(root);
18
+ return stat.isDirectory() && (stat.mode & 0o077) === 0 && (!process.getuid || stat.uid === process.getuid());
19
+ } catch { return false; }
20
+ }
21
+
22
+ function readIndex(file: string): Index {
23
+ let fd: number | undefined;
24
+ try {
25
+ fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
26
+ const stat = fstatSync(fd);
27
+ if (!privateFile(stat) || stat.size > MAX_BUCKET_BYTES) return {};
28
+ const parsed: unknown = JSON.parse(readFileSync(fd, 'utf8'));
29
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
30
+ const index: Index = {};
31
+ for (const [key, paths] of Object.entries(parsed).slice(-MAX_ENTRIES)) {
32
+ if (!/^\d+:[a-f0-9]{64}$/.test(key) || !Array.isArray(paths)) continue;
33
+ index[key] = paths.filter((path): path is string => typeof path === 'string' && path.length <= 4096 && isAbsolute(path)).slice(0, MAX_PATHS);
34
+ }
35
+ return index;
36
+ } catch { return {}; }
37
+ finally { if (fd !== undefined) closeSync(fd); }
38
+ }
39
+
40
+ function digestFile(file: string, size: number): string | null {
41
+ const fd = openSync(file, 'r');
42
+ try {
43
+ const hash = createHash('sha256');
44
+ const buffer = Buffer.allocUnsafe(64 * 1024);
45
+ let remaining = size;
46
+ while (remaining > 0) {
47
+ const length = readSync(fd, buffer, 0, Math.min(buffer.length, remaining), null);
48
+ if (!length) return null;
49
+ hash.update(buffer.subarray(0, length));
50
+ remaining -= length;
51
+ }
52
+ if (readSync(fd, buffer, 0, 1, null)) return null;
53
+ return hash.digest('hex');
54
+ } finally { closeSync(fd); }
55
+ }
56
+
57
+ export function sharedBlobIndex(root: string, destinationDevice: number, digest: string): {
58
+ reuse(temporary: string, size: number): boolean;
59
+ record(key: string): void;
60
+ } {
61
+ // 256 buckets with bounded entries/bytes keep metadata bounded across roots and devices.
62
+ const file = join(root, `${digest.slice(0, 2)}.json`);
63
+ const entry = `${destinationDevice}:${digest}`;
64
+ return {
65
+ reuse(temporary, size) {
66
+ if (!privateDirectory(root)) return false;
67
+ for (const candidate of readIndex(file)[entry] ?? []) {
68
+ try {
69
+ const source = lstatSync(candidate);
70
+ if (!source.isFile() || source.dev !== destinationDevice || source.size !== size
71
+ || (process.getuid && source.uid !== process.getuid())) continue;
72
+ linkSync(candidate, temporary);
73
+ // Check the actual linked file, not a source path that could have been replaced.
74
+ const linked = lstatSync(temporary);
75
+ if (linked.isFile() && linked.dev === destinationDevice && linked.size === size
76
+ && (!process.getuid || linked.uid === process.getuid()) && digestFile(temporary, size) === digest) return true;
77
+ } catch { /* A cache miss, stale path, unsupported link, or corrupt candidate. */ }
78
+ rmSync(temporary, { force: true });
79
+ }
80
+ return false;
81
+ },
82
+ record(key) {
83
+ try {
84
+ const path = resolve(key);
85
+ if (path.length > 4096) return;
86
+ const index = readIndex(file);
87
+ const paths = [path, ...(index[entry] ?? []).filter((old) => old !== path)].slice(0, MAX_PATHS);
88
+ delete index[entry];
89
+ index[entry] = paths;
90
+ let text = JSON.stringify(index);
91
+ while (Object.keys(index).length > MAX_ENTRIES || Buffer.byteLength(text) > MAX_BUCKET_BYTES) {
92
+ delete index[Object.keys(index)[0]!];
93
+ text = JSON.stringify(index);
94
+ }
95
+ mkdirSync(root, { recursive: true, mode: 0o700 });
96
+ if (!privateDirectory(root)) return;
97
+ // A torn/concurrent index update is just a cache miss. Writing the bounded
98
+ // bucket directly avoids retaining metadata temp files after SIGKILL.
99
+ const fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0), 0o600);
100
+ try {
101
+ if (!privateFile(fstatSync(fd))) return;
102
+ ftruncateSync(fd, 0);
103
+ writeFileSync(fd, text);
104
+ } finally { closeSync(fd); }
105
+ } catch { /* Index storage is optional: the World already owns its complete bytes. */ }
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,115 @@
1
+ // THE STATE SYSTEM (docs/concepts/the-model.md): what a write does at the
2
+ // head of a twin's log, and how the tree is kept current. Two implementations of one interface,
3
+ // bound per twin per world:
4
+ // simulated — apply appends the entry and the tree is the projection; refresh is nothing. That
5
+ // is every twin with no root, and every line of the write path as it stands.
6
+ // real — the twin has a ROOT: the vendor's real account. Apply performs the entry against
7
+ // the vendor with the sealed credential (the pack's perform adapter over the kernel
8
+ // executor) and lands the receipt on the parent log; refresh fills the parent log
9
+ // from the vendor (the pack's refresh adapter). Reads never egress.
10
+ // The binding is a file in the twin's state dir (`root.json`), written by `volter twin <vendor>
11
+ // root`, read here — no env var, no pack code. The adapters come from the pack's descriptor
12
+ // (protocol 2 `stateSystem`) or its anchored exports, registered by whoever mounts the twin.
13
+ // CHECKS run before any entry is performed: a check that fails lands the entry as `refused` with
14
+ // its reason and the deploy stops there. Checks are the world's files; the runtime loads them.
15
+ import { dirname, join } from 'node:path';
16
+ import type { TwinAuthStrategy } from './executor.ts';
17
+ import { pushablePendingActions, type TwinAction } from './actions.ts';
18
+ import type { PerformAction } from './head.ts';
19
+ import type { RemoteExecute } from './remote-execute.ts';
20
+ import { readTree } from './log.ts';
21
+ import type { TwinResource } from './serve.ts';
22
+ import { worldPaths } from './storage.ts';
23
+ import { getActiveWorldStore } from './world-store.ts';
24
+
25
+ export type DeployPolicy = 'auto' | 'gated' | 'hold';
26
+
27
+ /** A twin's root: the vendor's real account. Key-free; the credential is sealed elsewhere. */
28
+ export type RootConfig = {
29
+ /** the vendor's API, or the one resource the account is (`https://api.github.com/repos/o/r`) */
30
+ url: string;
31
+ deploy: DeployPolicy;
32
+ /** the world's override of the pack's refresh posture: an interval, and the least time between on-demand refreshes */
33
+ refresh?: { every?: string; webhook?: boolean; atMost?: string };
34
+ /** the one resource the account is, under `url` — `repos/acme/web` for GitHub — when the vendor's
35
+ * adapters need it named; the adapters see `url/scope` as their origin */
36
+ scope?: string;
37
+ };
38
+
39
+ export function rootPath(service: string, root?: string): string {
40
+ return join(dirname(worldPaths(service, root).events), 'root.json');
41
+ }
42
+ export function readRoot(service: string, root?: string): RootConfig | null {
43
+ const raw = getActiveWorldStore().read(rootPath(service, root));
44
+ return raw === null ? null : (JSON.parse(raw) as RootConfig);
45
+ }
46
+ export function writeRoot(service: string, config: RootConfig, root?: string): void {
47
+ const path = rootPath(service, root);
48
+ getActiveWorldStore().mkdir(dirname(path));
49
+ getActiveWorldStore().writeAtomic(path, `${JSON.stringify(config, null, 2)}\n`);
50
+ }
51
+ export function clearRoot(service: string, root?: string): void {
52
+ const path = rootPath(service, root);
53
+ if (getActiveWorldStore().exists(path)) getActiveWorldStore().remove(path);
54
+ }
55
+
56
+ /** The pack's half of the real state system. `perform` applies one entry against the vendor;
57
+ * `refresh` observes the vendor into the parent log (it folds through the kernel — a pack never
58
+ * writes a root log by hand); `ingest` folds one signed webhook. All over the kernel executor. */
59
+ export type StateSystemAdapters = {
60
+ perform?: PerformAction;
61
+ refresh?: (execute: RemoteExecute, opts: { root?: string; origin?: string; credential?: string }) => Promise<unknown>;
62
+ ingest?: (request: Request, ctx: { root?: string; secret?: string }) => Response | Promise<Response>;
63
+ };
64
+
65
+ const adapters = new Map<string, StateSystemAdapters>();
66
+ /** Register a twin's adapters (the mounting host does this from the pack's descriptor or its
67
+ * anchored exports). Replaces any earlier registration for the service. */
68
+ export function registerStateSystem(service: string, a: StateSystemAdapters): void { adapters.set(service, a); }
69
+ export function stateSystemFor(service: string): StateSystemAdapters | undefined { return adapters.get(service); }
70
+ export function clearStateSystems(): void { adapters.clear(); authStrategies.clear(); }
71
+
72
+ /** HOW EACH VENDOR AUTHENTICATES, registered from the pack's descriptor by `registerPack` — the
73
+ * same shape as the adapters above, and it lives here rather than in the pack registry so the
74
+ * head can read it without importing one (there is no cycle to argue about). Absent means header
75
+ * replacement, which is what the executor has always done. */
76
+ const authStrategies = new Map<string, TwinAuthStrategy>();
77
+ export function registerAuthStrategy(service: string, a: TwinAuthStrategy): void { authStrategies.set(service, a); }
78
+ export function authStrategyFor(service: string): TwinAuthStrategy | undefined { return authStrategies.get(service); }
79
+
80
+ /** A check: deterministic, sees the entry and the tree, answers pass or fail with a reason. */
81
+ /** A check may answer at once or later: a hosted World runs its checks in an isolate of their own,
82
+ * reached over RPC. */
83
+ export type CheckResult = { ok: true } | { ok: false; reason: string };
84
+ export type Check = { name: string; run: (entry: TwinAction, tree: TwinResource[]) => CheckResult | Promise<CheckResult> };
85
+ export type CheckVerdict = { name: string; reason: string } | null;
86
+
87
+ /** Run every check over one entry; the first failure is the verdict. */
88
+ export async function runChecks(checks: Check[], entry: TwinAction, tree: TwinResource[]): Promise<CheckVerdict> {
89
+ for (const check of checks) {
90
+ const result = await check.run(entry, tree);
91
+ if (!result.ok) return { name: check.name, reason: result.reason };
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /** The one check every world ships: no credential-shaped string leaves for a vendor. Looks at
97
+ * every string field of the entry (and its projection), so a token in a message body, a title or
98
+ * a description is caught wherever the vendor would have put it. */
99
+ export const NO_SECRETS_CHECK: Check = {
100
+ name: 'no-secrets',
101
+ run(entry) {
102
+ const CREDENTIAL = /\b(?:sk|pk|rk)[_-](?:live|test|prod)[_-][A-Za-z0-9]{8,}|\bxox[abprs]-[A-Za-z0-9-]{10,}|\bgh[pousr]_[A-Za-z0-9]{20,}|\bAKIA[0-9A-Z]{16}\b|\bsk-[A-Za-z0-9]{20,}|\bsk-(?:proj|ant)-[A-Za-z0-9_-]{16,}|-----BEGIN [A-Z ]*PRIVATE KEY-----/;
103
+ const hit = (value: unknown, path: string): string | null => {
104
+ if (typeof value === 'string') return CREDENTIAL.test(value) ? path : null;
105
+ if (Array.isArray(value)) { for (const [i, v] of value.entries()) { const h = hit(v, `${path}[${i}]`); if (h) return h; } return null; }
106
+ if (value && typeof value === 'object') { for (const [k, v] of Object.entries(value)) { const h = hit(v, path ? `${path}.${k}` : k); if (h) return h; } return null; }
107
+ return null;
108
+ };
109
+ const where = hit(entry.fields, '') ?? hit(entry.projection, 'projection') ?? hit(entry.input, 'input');
110
+ return where === null ? { ok: true } : { ok: false, reason: `a credential-shaped string in ${where}` };
111
+ },
112
+ };
113
+
114
+ // DEPLOY and the deployable set live in head.ts (`performEntries`, `deployableEntries`): the same
115
+ // code performs a wire write at an `auto` head, a pushed changeset and `volter world deploy`.
package/src/storage.ts ADDED
@@ -0,0 +1,407 @@
1
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
2
+ import { assertNotBeingRemoved, withAncestryLock, withStateRemoval } from './ancestry.ts';
3
+ import { appendParentEntry, toEntry } from './log.ts';
4
+ import {
5
+ GenericWorldStateSchema,
6
+ WorldServiceEventSchema,
7
+ } from './schemas.ts';
8
+ import { getActiveWorldStore } from './world-store.ts';
9
+ import { parentEntries, toEvent } from './log.ts';
10
+ import type {
11
+ AppendEventResult,
12
+ CommitQueuedEventsResult,
13
+ EnqueueEventResult,
14
+ GenericWorldState,
15
+ QueuedWorldServiceEvent,
16
+ WorldPaths,
17
+ WorldReducer,
18
+ WorldServiceEvent,
19
+ } from './types.ts';
20
+
21
+ function nowIso(): string {
22
+ return new Date().toISOString();
23
+ }
24
+
25
+ function projectRoot(root?: string): string {
26
+ return resolve(root || process.env.PROJECT_ROOT || process.cwd());
27
+ }
28
+
29
+ /**
30
+ * Name of the per-project state directory holding world data
31
+ * (`<root>/<stateDir>/world/...`). Defaults to `.volter`; hosts that need a
32
+ * different directory set VOLTER_STATE_DIR. Every path in this package must
33
+ * go through worldStateRoot — never the literal.
34
+ */
35
+ export function stateDirName(): string {
36
+ const name = process.env.VOLTER_STATE_DIR || '.volter';
37
+ if (isAbsolute(name) || name.split(/[\\/]/).includes('..')) throw new Error('VOLTER_STATE_DIR must be a relative directory without .. segments');
38
+ return name;
39
+ }
40
+
41
+ export function worldStateRoot(root?: string): string {
42
+ return join(projectRoot(root), stateDirName(), 'world');
43
+ }
44
+
45
+ function assertServiceName(service: string): string {
46
+ if (!/^[A-Za-z0-9_-]+$/.test(service)) {
47
+ throw new Error(`Invalid world service name: ${service}`);
48
+ }
49
+ return service;
50
+ }
51
+
52
+ /**
53
+ * IDENTITY SEAM for the request journal (serve.ts). `worldPaths` is the single funnel every
54
+ * state read/write in every pack goes through, so it is also the one place a twin says, in its
55
+ * own words, WHO it is (`service`) and WHERE its state lives (`root`) — the two things an HTTP
56
+ * layer that only sees `Bun.serve({fetch})` cannot know. serve.ts installs an observer here so
57
+ * the journal it writes lands beside that twin's `actions.jsonl` instead of guessing a root.
58
+ * Nothing else may use this: it is a one-slot observability hook, never a control seam.
59
+ */
60
+ let worldPathsObserver: ((service: string, root: string | undefined) => void) | undefined;
61
+
62
+ export function observeWorldPaths(observer: (service: string, root: string | undefined) => void): void {
63
+ worldPathsObserver = observer;
64
+ }
65
+
66
+ export function worldPaths(service: string, root?: string): WorldPaths {
67
+ const safeService = assertServiceName(service);
68
+ worldPathsObserver?.(safeService, root);
69
+ const resolvedRoot = projectRoot(root);
70
+ const dir = join(worldStateRoot(root), safeService);
71
+ return {
72
+ root: resolvedRoot,
73
+ service: safeService,
74
+ dir,
75
+ events: join(dir, 'events.jsonl'),
76
+ state: join(dir, 'state.json'),
77
+ resources: join(dir, 'resources'),
78
+ cursors: join(dir, 'cursors'),
79
+ ingests: join(dir, 'ingests'),
80
+ eventQueue: join(dir, 'event-queue.jsonl'),
81
+ };
82
+ }
83
+
84
+ function readJsonl<T>(path: string): T[] {
85
+ const rows: T[] = [];
86
+ for (const [index, line] of getActiveWorldStore().readLines(path).entries()) {
87
+ if (!line.trim()) continue;
88
+ try {
89
+ rows.push(JSON.parse(line) as T);
90
+ } catch (error) {
91
+ throw new Error(`${path}:${index + 1}: invalid JSONL row: ${(error as Error).message}`);
92
+ }
93
+ }
94
+ return rows;
95
+ }
96
+
97
+ /** Read + parse a whole-file JSON sidecar, citing the path on a parse failure (mirrors
98
+ * readJsonl's `${path}:...` error shape). Callers own existence semantics — this parses a
99
+ * file that is expected to exist; guard with existsSync first when absence is allowed. */
100
+ export function readJsonFile<T>(path: string): T {
101
+ try {
102
+ const raw = getActiveWorldStore().read(path);
103
+ if (raw === null) throw new Error('no such file');
104
+ return JSON.parse(raw) as T;
105
+ } catch (error) {
106
+ throw new Error(`${path}: invalid JSON: ${(error as Error).message}`);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Append `data` to `path`. When VOLTER_DURABLE=1, `fsyncSync` the file after the write so
112
+ * the appended record survives an OS crash / power loss — not just a process crash.
113
+ *
114
+ * Default (VOLTER_DURABLE unset) is OFF: `appendFileSync` is a completed write syscall, so a
115
+ * *process* crash after it returns still leaves the record on the log (the OS owns the page
116
+ * cache). The window this leaves open is a **kernel-panic / power loss** between the write
117
+ * landing in the page cache and the fs flushing it to stable storage — a torn or lost tail
118
+ * record. That crash window matters more once real pulled staging data lives in these files
119
+ * (purpose 2), so hosts that need durability opt in with VOLTER_DURABLE=1 at the cost of an
120
+ * fsync per append. See docs/contributing/architecture.md D1.
121
+ */
122
+ export function appendDurable(path: string, data: string): void {
123
+ getActiveWorldStore().append(path, data);
124
+ }
125
+
126
+ /**
127
+ * Opt-in structured stderr logging for the audit trail (the July 2026 architecture review, row D3):
128
+ * one line per action-log append and per push-ledger row, so "who reviewed the change
129
+ * that caused this real write" is mechanically greppable from a single log stream via
130
+ * `correlationId`. Off by default — mirrors the VOLTER_DURABLE opt-in policy above: no
131
+ * always-on I/O, never on the default path. Set VOLTER_TWIN_LOG=1 to enable.
132
+ */
133
+ export function twinLog(kind: string, details: Record<string, unknown>): void {
134
+ if (process.env.VOLTER_TWIN_LOG !== '1') return;
135
+ console.error(`[twin:${kind}]`, JSON.stringify(details));
136
+ }
137
+
138
+ function appendJsonl(path: string, value: unknown): void {
139
+ const store = getActiveWorldStore();
140
+ store.mkdir(dirname(path));
141
+ store.append(path, `${JSON.stringify(value)}\n`);
142
+ }
143
+
144
+ /** Run `fn` holding an exclusive cross-process lock, via the active store. On the fs
145
+ * store this is the reclaimable file lock the event log uses; on an in-memory,
146
+ * single-instance store it is a synchronous pass-through. Used to make read-then-append
147
+ * critical sections atomic. Public so world-runtime can guard concurrent `upWorld`
148
+ * claims of one instance dir with the SAME lock semantics the event log uses (TWIN-36). */
149
+ export function withFileLock<T>(lockPath: string, fn: () => T): T {
150
+ return withAncestryLock(() => { assertNotBeingRemoved(dirname(lockPath)); return getActiveWorldStore().withLock(lockPath, fn); });
151
+ }
152
+
153
+ function writeJsonAtomic(path: string, value: unknown): void {
154
+ getActiveWorldStore().writeAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
155
+ }
156
+
157
+ /** The parent log as a protocol 1 pack reads it: every entry the branch inherited or landed, as
158
+ * observed rows (log.ts `toEvent`). A branched world sees its base world's logs to the branch
159
+ * position first. */
160
+ export function listEvents(service: string, root?: string): WorldServiceEvent[] {
161
+ return parentEntries(service, root).map(toEvent);
162
+ }
163
+
164
+ function ensureEventDirs(paths: WorldPaths): void {
165
+ const store = getActiveWorldStore();
166
+ store.mkdir(paths.dir);
167
+ store.mkdir(paths.resources);
168
+ store.mkdir(paths.cursors);
169
+ store.mkdir(paths.ingests);
170
+ }
171
+
172
+ /** Exported so other modules that share a service's events log (e.g. egress.ts,
173
+ * TWIN-58) can serialize their own critical sections on the SAME lock appendEvent
174
+ * itself uses, instead of inventing a second, uncoordinated lockfile. */
175
+ export function eventsLockPath(paths: WorldPaths): string {
176
+ return join(paths.dir, 'events.jsonl.lock');
177
+ }
178
+
179
+ /**
180
+ * Serializes mutations that can change a service's projected state, regardless of whether the
181
+ * durable row lands in events.jsonl or actions.jsonl. The per-log locks still protect their own
182
+ * append/dedupe mechanics; this outer lock makes a projection-based decision linearizable against
183
+ * connector observations as well as local actions.
184
+ */
185
+ export function projectionLockPath(paths: WorldPaths): string {
186
+ return join(paths.dir, 'projection.lock');
187
+ }
188
+
189
+ /**
190
+ * The append body, assuming the caller already holds `eventsLockPath(paths)`. Split
191
+ * out so `commitQueuedEvents` can run a whole batch of appends (and the rebuild that
192
+ * follows them) under ONE lock acquisition instead of nesting a fresh `withFileLock`
193
+ * per row — `withFileLock` is not reentrant, so re-acquiring it from inside an
194
+ * already-held lock in the same process would just spin to its own timeout. Also
195
+ * exported for egress.ts (TWIN-58): performExternalWrite's ledger-check +
196
+ * intent-append span holds `eventsLockPath` itself, so it must append through this
197
+ * already-locked path rather than the public `appendEvent` (which would try to
198
+ * re-acquire the same lock and spin to its own timeout).
199
+ */
200
+ export function appendEvent(event: WorldServiceEvent, root?: string): AppendEventResult {
201
+ const parsed = WorldServiceEventSchema.parse(event);
202
+ const paths = worldPaths(parsed.service, root);
203
+ ensureEventDirs(paths);
204
+ const { appended } = appendParentEntry(toEntry(parsed as unknown as Record<string, unknown>), root);
205
+ return { event: parsed, appended };
206
+ }
207
+
208
+ export function genericWorldReducer(state: GenericWorldState, event: WorldServiceEvent): GenericWorldState {
209
+ const key = `${event.subject.type}:${event.subject.id}`;
210
+ return GenericWorldStateSchema.parse({
211
+ ...state,
212
+ eventCount: state.eventCount + 1,
213
+ latestEventId: event.id,
214
+ subjects: {
215
+ ...state.subjects,
216
+ [key]: {
217
+ type: event.subject.type,
218
+ id: event.subject.id,
219
+ latestEventId: event.id,
220
+ latestType: event.type,
221
+ updatedAt: event.occurredAt || event.observedAt,
222
+ },
223
+ },
224
+ });
225
+ }
226
+
227
+ export function emptyGenericState(service: string): GenericWorldState {
228
+ return {
229
+ version: 1,
230
+ service: assertServiceName(service),
231
+ rebuiltAt: nowIso(),
232
+ eventCount: 0,
233
+ subjects: {},
234
+ };
235
+ }
236
+
237
+ export function rebuildState<State>(
238
+ service: string,
239
+ initialState: State,
240
+ reducer: WorldReducer<State>,
241
+ root?: string,
242
+ ): State {
243
+ const paths = worldPaths(service, root);
244
+ const state = listEvents(service, root).reduce(reducer, initialState);
245
+ writeJsonAtomic(paths.state, state);
246
+ return state;
247
+ }
248
+
249
+ export function rebuildGenericState(service: string, root?: string): GenericWorldState {
250
+ return rebuildState(service, emptyGenericState(service), genericWorldReducer, root);
251
+ }
252
+
253
+ export function loadState<T = unknown>(service: string, root?: string): T | null {
254
+ const paths = worldPaths(service, root);
255
+ if (!getActiveWorldStore().exists(paths.state)) return null;
256
+ return readJsonFile<T>(paths.state);
257
+ }
258
+
259
+ export function createEvent(input: Omit<WorldServiceEvent, 'schemaVersion' | 'observedAt'> & {
260
+ schemaVersion?: number;
261
+ observedAt?: string;
262
+ }): WorldServiceEvent {
263
+ return WorldServiceEventSchema.parse({
264
+ schemaVersion: 1,
265
+ observedAt: nowIso(),
266
+ ...input,
267
+ });
268
+ }
269
+
270
+ // ── Scrub: delete pulled data at rest (TWIN-45 dev/02a) ─────────────────────
271
+ // `sync pull` (sync.ts) folds real, customer-shaped resources into a service's
272
+ // event log + rebuilt state.json (see worldPaths); `scrub` is the honest
273
+ // counterpart — plain `rm` of exactly that on-disk data, nothing cleverer (no
274
+ // crypto-shredding; see docs/concepts/data-and-keys.md). Two grains, matching what's
275
+ // actually there: one service's dir, or the whole world dir. Both refuse to
276
+ // touch a directory whose contents don't look like our own event-log shape,
277
+ // unless the caller passes `force: true` — the directories scrub ever removes
278
+ // are ALWAYS ones derived from worldPaths()/worldStateRoot() (never a raw path
279
+ // from the caller), so this check is defense in depth against a wrong `root`
280
+ // resolving somewhere unexpected, not a general path-safety mechanism.
281
+
282
+ export type ScrubResult = {
283
+ /** The directory scrub targeted (may not have existed). */
284
+ target: string;
285
+ /** Every file actually removed, path relative to `target`, in no particular order. */
286
+ removed: string[];
287
+ /** One-line human summary, safe to print as-is. */
288
+ message: string;
289
+ };
290
+
291
+ /** Filenames/dirnames ANY control-plane src module ever writes inside a single
292
+ * service's state dir (see worldPaths) — plus the lockfiles withFileLock creates
293
+ * next to a log and the `.tmp`/`.stale.*` sidecars its atomic-write/reclaim paths
294
+ * use. storage.ts itself only accounts for events.jsonl/event-queue.jsonl/
295
+ * state.json/resources/cursors/ingests; the rest are written by other modules
296
+ * that share the same service dir (actions.ts, pushLedger.ts, plan.ts, lease.ts,
297
+ * refs.ts, fork.ts, queueLifecycle.ts, and connector-owned coordination) — this set MUST stay in sync with every
298
+ * one of them so scrubService never demands --force for an ordinary twin. */
299
+ const KNOWN_SERVICE_ENTRIES = new Set([
300
+ 'ancestry.json', 'history-format.json', 'views', 'origin.jsonl', 'origin-head.json',
301
+ 'events.jsonl',
302
+ 'events.jsonl.lock',
303
+ 'event-queue.jsonl',
304
+ 'event-queue.jsonl.lock',
305
+ 'state.json',
306
+ 'resources',
307
+ 'cursors',
308
+ 'ingests',
309
+ // actions.ts: the local transaction-commit log + its lock.
310
+ 'actions.jsonl',
311
+ 'actions.jsonl.lock',
312
+ // pushLedger.ts: the push-phase ledger for real-vendor replication.
313
+ 'push-ledger.jsonl',
314
+ // queueLifecycle.ts: the append-only status ledger over event-queue.jsonl rows.
315
+ 'event-queue-status.jsonl',
316
+ // fork.ts: fork metadata (base snapshot + divergence baseline) for a fork root.
317
+ 'fork-meta.json',
318
+ // plan.ts: one JSON file per apply plan, under plans/<planId>.json.
319
+ 'plans',
320
+ // lease.ts: one JSON file per apply lease, under leases/<leaseId>.json.
321
+ 'leases',
322
+ // refs.ts: remote/local checkpoint refs, under refs/remote/<provider>/<name>.json
323
+ // and refs/local/<forkId>.json.
324
+ 'refs',
325
+ // Vendor connectors may keep private, non-secret workflow leases and monotone observation clocks
326
+ // here when an async provider span must be serialized across processes.
327
+ 'connector-workflows',
328
+ ]);
329
+
330
+ function isKnownServiceEntry(name: string): boolean {
331
+ // log.ts: the branch pointer and the checkpoint
332
+ if (name === 'branch.json' || name === 'checkpoints') return true;
333
+ return KNOWN_SERVICE_ENTRIES.has(name) || name.endsWith('.tmp') || /\.stale\.\d+\.\d+$/.test(name);
334
+ }
335
+
336
+ /** A service dir "looks like" one of ours when every entry in it is something
337
+ * storage.ts is known to create. A directory that doesn't exist yet trivially
338
+ * looks fine (scrub will just no-op on it). */
339
+ function looksLikeServiceStateDir(dir: string): boolean {
340
+ const store = getActiveWorldStore();
341
+ if (!store.exists(dir)) return true;
342
+ return store.list(dir).every((entry) => isKnownServiceEntry(entry));
343
+ }
344
+
345
+ /** The whole world dir "looks like" ours when every entry in it is itself a
346
+ * directory that looks like a service state dir (each service gets one subdir
347
+ * under worldStateRoot — see worldPaths). */
348
+ function looksLikeWorldStateDir(dir: string): boolean {
349
+ const store = getActiveWorldStore();
350
+ if (!store.exists(dir)) return true;
351
+ return store.list(dir).every((entry) => {
352
+ const full = join(dir, entry);
353
+ return (store.stat(full)?.isDirectory ?? false) && looksLikeServiceStateDir(full);
354
+ });
355
+ }
356
+
357
+ /** Every file under `dir`, path relative to `dir`, depth-first. Used to report
358
+ * exactly what scrub is about to remove before it removes it. */
359
+ function listFilesRecursive(dir: string, base: string = dir): string[] {
360
+ const store = getActiveWorldStore();
361
+ if (!store.exists(dir)) return [];
362
+ const out: string[] = [];
363
+ for (const name of store.list(dir)) {
364
+ const full = join(dir, name);
365
+ if (store.stat(full)?.isDirectory) out.push(...listFilesRecursive(full, base));
366
+ else out.push(full.slice(base.length + 1));
367
+ }
368
+ return out;
369
+ }
370
+
371
+ function scrubDir(
372
+ dir: string,
373
+ label: string,
374
+ looksLike: (d: string) => boolean,
375
+ options: { force?: boolean },
376
+ ): ScrubResult {
377
+ const store = getActiveWorldStore();
378
+ if (!store.exists(dir)) {
379
+ return { target: dir, removed: [], message: `nothing to scrub: ${label} (${dir}) does not exist` };
380
+ }
381
+ if (!options.force && !looksLike(dir)) {
382
+ throw new Error(
383
+ `world scrub: refusing to remove "${dir}" — it does not look like a world state directory ` +
384
+ `(unexpected contents). Pass --force (or { force: true }) to scrub it anyway.`,
385
+ );
386
+ }
387
+ const removed = listFilesRecursive(dir);
388
+ withStateRemoval(dir, () => store.remove(dir));
389
+ return { target: dir, removed, message: `scrubbed ${label}: removed ${removed.length} file(s) under ${dir}` };
390
+ }
391
+
392
+ /** Delete one service's pulled data at rest: its event log, queued events,
393
+ * rebuilt state.json, and any resources/cursors/ingests sidecars — everything
394
+ * `worldPaths(service)` points at. Refuses (unless `force`) when the dir holds
395
+ * anything storage.ts didn't put there. */
396
+ export function scrubService(service: string, options: { root?: string; force?: boolean } = {}): ScrubResult {
397
+ const paths = worldPaths(service, options.root);
398
+ return scrubDir(paths.dir, `service "${service}"`, looksLikeServiceStateDir, options);
399
+ }
400
+
401
+ /** Delete the ENTIRE world state dir (`<root>/<stateDir>/world`) — every
402
+ * service's pulled data at once. Refuses (unless `force`) when any entry in it
403
+ * isn't itself a recognizable per-service state dir. */
404
+ export function scrubWorld(options: { root?: string; force?: boolean } = {}): ScrubResult {
405
+ const dir = worldStateRoot(options.root);
406
+ return scrubDir(dir, 'entire world state dir', looksLikeWorldStateDir, options);
407
+ }