@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.
- package/LICENSE +202 -0
- package/README.md +29 -0
- package/app-route.cjs +154 -0
- package/app-route.d.cts +7 -0
- package/attach.cjs +80 -0
- package/dist/app-route.cjs +154 -0
- package/dist/app-route.d.cts +7 -0
- package/dist/attach.cjs +80 -0
- package/dist/generated/pack-facts.json +4306 -0
- package/dist/inject.cjs +1097 -0
- package/dist/network-policy.cjs +92 -0
- package/dist/network-policy.d.cts +10 -0
- package/dist/src/actions.d.ts +276 -0
- package/dist/src/actions.js +436 -0
- package/dist/src/ancestry.d.ts +22 -0
- package/dist/src/ancestry.js +238 -0
- package/dist/src/args.d.ts +3 -0
- package/dist/src/args.js +12 -0
- package/dist/src/blob-store.d.ts +55 -0
- package/dist/src/blob-store.js +186 -0
- package/dist/src/brand-tokens.d.ts +2 -0
- package/dist/src/brand-tokens.js +17 -0
- package/dist/src/changeset.d.ts +431 -0
- package/dist/src/changeset.js +0 -0
- package/dist/src/client-bundle.d.ts +1 -0
- package/dist/src/client-bundle.js +28 -0
- package/dist/src/credential.d.ts +38 -0
- package/dist/src/credential.js +114 -0
- package/dist/src/derived-core.d.ts +452 -0
- package/dist/src/derived-core.js +782 -0
- package/dist/src/derived.d.ts +84 -0
- package/dist/src/derived.js +122 -0
- package/dist/src/emit.d.ts +106 -0
- package/dist/src/emit.js +157 -0
- package/dist/src/executor.d.ts +120 -0
- package/dist/src/executor.js +387 -0
- package/dist/src/file-response.d.ts +3 -0
- package/dist/src/file-response.js +22 -0
- package/dist/src/fork.d.ts +26 -0
- package/dist/src/fork.js +68 -0
- package/dist/src/git/history.d.ts +36 -0
- package/dist/src/git/history.js +298 -0
- package/dist/src/git/index.d.ts +6 -0
- package/dist/src/git/index.js +6 -0
- package/dist/src/git/inflate.d.ts +11 -0
- package/dist/src/git/inflate.js +194 -0
- package/dist/src/git/objects.d.ts +64 -0
- package/dist/src/git/objects.js +161 -0
- package/dist/src/git/pack.d.ts +14 -0
- package/dist/src/git/pack.js +199 -0
- package/dist/src/git/refs.d.ts +19 -0
- package/dist/src/git/refs.js +35 -0
- package/dist/src/git/smart-http.d.ts +45 -0
- package/dist/src/git/smart-http.js +223 -0
- package/dist/src/hash.d.ts +38 -0
- package/dist/src/hash.js +48 -0
- package/dist/src/head.d.ts +140 -0
- package/dist/src/head.js +313 -0
- package/dist/src/history.d.ts +76 -0
- package/dist/src/history.js +322 -0
- package/dist/src/index.d.ts +73 -0
- package/dist/src/index.js +98 -0
- package/dist/src/lifecycle.d.ts +1 -0
- package/dist/src/lifecycle.js +8 -0
- package/dist/src/log.d.ts +254 -0
- package/dist/src/log.js +801 -0
- package/dist/src/mirror-shell.d.ts +2 -0
- package/dist/src/mirror-shell.js +13 -0
- package/dist/src/observe.d.ts +49 -0
- package/dist/src/observe.js +148 -0
- package/dist/src/pack-assets.d.ts +30 -0
- package/dist/src/pack-assets.js +88 -0
- package/dist/src/packRegistry.d.ts +374 -0
- package/dist/src/packRegistry.js +142 -0
- package/dist/src/placeholder-remote.d.ts +22 -0
- package/dist/src/placeholder-remote.js +86 -0
- package/dist/src/proxy.d.ts +25 -0
- package/dist/src/proxy.js +155 -0
- package/dist/src/rateBudget.d.ts +367 -0
- package/dist/src/rateBudget.js +925 -0
- package/dist/src/references.d.ts +18 -0
- package/dist/src/references.js +27 -0
- package/dist/src/remote-execute.d.ts +22 -0
- package/dist/src/remote-execute.js +1 -0
- package/dist/src/resource-blob.d.ts +10 -0
- package/dist/src/resource-blob.js +56 -0
- package/dist/src/scenario.d.ts +197 -0
- package/dist/src/scenario.js +425 -0
- package/dist/src/schemas.d.ts +78 -0
- package/dist/src/schemas.js +50 -0
- package/dist/src/serve-http.d.ts +48 -0
- package/dist/src/serve-http.js +340 -0
- package/dist/src/serve.d.ts +147 -0
- package/dist/src/serve.js +507 -0
- package/dist/src/shared-blob-index.d.ts +4 -0
- package/dist/src/shared-blob-index.js +126 -0
- package/dist/src/state-system.d.ts +70 -0
- package/dist/src/state-system.js +90 -0
- package/dist/src/storage.d.ts +101 -0
- package/dist/src/storage.js +337 -0
- package/dist/src/twin-fetch.d.ts +64 -0
- package/dist/src/twin-fetch.js +91 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/src/v1-removed.d.ts +159 -0
- package/dist/src/v1-removed.js +124 -0
- package/dist/src/volter-home.d.ts +5 -0
- package/dist/src/volter-home.js +10 -0
- package/dist/src/world-clock.d.ts +4 -0
- package/dist/src/world-clock.js +32 -0
- package/dist/src/world-env.d.ts +3 -0
- package/dist/src/world-env.js +22 -0
- package/dist/src/world-store-sql.d.ts +27 -0
- package/dist/src/world-store-sql.js +86 -0
- package/dist/src/world-store.d.ts +168 -0
- package/dist/src/world-store.js +475 -0
- package/dist/src/worldConfig.d.ts +9 -0
- package/dist/src/worldConfig.js +17 -0
- package/dist/stream-bridge.cjs +80 -0
- package/dist/vendor-hosts.cjs +200 -0
- package/generated/pack-facts.json +4306 -0
- package/inject.cjs +1097 -0
- package/network-policy.cjs +92 -0
- package/network-policy.d.cts +10 -0
- package/package.json +103 -0
- package/src/actions.ts +564 -0
- package/src/ancestry.ts +213 -0
- package/src/args.ts +14 -0
- package/src/blob-store.ts +185 -0
- package/src/brand-tokens.ts +17 -0
- package/src/changeset.ts +1032 -0
- package/src/client-bundle.ts +29 -0
- package/src/credential.ts +140 -0
- package/src/derived-core.ts +1004 -0
- package/src/derived.ts +176 -0
- package/src/emit.ts +242 -0
- package/src/executor.ts +431 -0
- package/src/file-response.ts +22 -0
- package/src/fork.ts +89 -0
- package/src/git/history.ts +177 -0
- package/src/git/index.ts +6 -0
- package/src/git/inflate.ts +125 -0
- package/src/git/objects.ts +110 -0
- package/src/git/pack.ts +105 -0
- package/src/git/refs.ts +25 -0
- package/src/git/smart-http.ts +149 -0
- package/src/hash.ts +66 -0
- package/src/head.ts +318 -0
- package/src/history.ts +246 -0
- package/src/index.ts +323 -0
- package/src/lifecycle.ts +8 -0
- package/src/log.ts +793 -0
- package/src/mirror-shell.ts +15 -0
- package/src/observe.ts +130 -0
- package/src/pack-assets.ts +81 -0
- package/src/packRegistry.ts +408 -0
- package/src/placeholder-remote.ts +81 -0
- package/src/proxy.ts +183 -0
- package/src/rateBudget.ts +1115 -0
- package/src/references.ts +46 -0
- package/src/remote-execute.ts +26 -0
- package/src/resource-blob.ts +57 -0
- package/src/scenario.ts +479 -0
- package/src/schemas.ts +56 -0
- package/src/serve-http.ts +299 -0
- package/src/serve.ts +618 -0
- package/src/shared-blob-index.ts +108 -0
- package/src/state-system.ts +115 -0
- package/src/storage.ts +407 -0
- package/src/twin-fetch.ts +147 -0
- package/src/types.ts +50 -0
- package/src/v1-removed.ts +172 -0
- package/src/volter-home.ts +11 -0
- package/src/world-clock.ts +33 -0
- package/src/world-env.ts +18 -0
- package/src/world-store-sql.ts +118 -0
- package/src/world-store.ts +572 -0
- package/src/worldConfig.ts +27 -0
- package/stream-bridge.cjs +80 -0
- 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
|
+
}
|