@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,238 @@
|
|
|
1
|
+
// Local branches pin parent storage. This is lifecycle authority, not a disposable cache.
|
|
2
|
+
import { canonicalJson } from "./hash.js";
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { volterHome } from "./volter-home.js";
|
|
5
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { stateDirName } from "./storage.js";
|
|
7
|
+
import { getActiveWorldStore } from "./world-store.js";
|
|
8
|
+
let entered;
|
|
9
|
+
export const canonicalStatePath = (path) => getActiveWorldStore().canonicalPath?.(path) ?? resolve(path);
|
|
10
|
+
const metaPath = (dir) => join(dir, 'ancestry.json');
|
|
11
|
+
const inside = (base, path) => { const rel = relative(canonicalStatePath(base), canonicalStatePath(path)); return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !rel.startsWith(sep)); };
|
|
12
|
+
/** Synchronous only; use exclusive acquisition without age-stealing a live owner.
|
|
13
|
+
* The coordinator lives outside service, instance and hosted World deletion trees. */
|
|
14
|
+
export function withAncestryLock(fn) {
|
|
15
|
+
const store = getActiveWorldStore();
|
|
16
|
+
const active = entered ??= new WeakSet();
|
|
17
|
+
if (active.has(store))
|
|
18
|
+
return fn();
|
|
19
|
+
const path = join(volterHome(), 'branch-ancestry.lock');
|
|
20
|
+
return store.withLock(path, () => {
|
|
21
|
+
active.add(store);
|
|
22
|
+
try {
|
|
23
|
+
recoverRemovals();
|
|
24
|
+
return fn();
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
active.delete(store);
|
|
28
|
+
}
|
|
29
|
+
}, { retainLiveOwner: true });
|
|
30
|
+
}
|
|
31
|
+
function metadata(dir) {
|
|
32
|
+
const raw = getActiveWorldStore().read(metaPath(dir));
|
|
33
|
+
if (raw === null)
|
|
34
|
+
return null;
|
|
35
|
+
const value = JSON.parse(raw);
|
|
36
|
+
if (!value || typeof value.generation !== 'string' || !Array.isArray(value.children)
|
|
37
|
+
|| value.children.some(p => typeof p.child !== 'string' || !Number.isInteger(p.ownerPid) || p.ownerPid <= 0)) {
|
|
38
|
+
throw new Error(`Unreadable branch ancestry at ${dir}; retained`);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
/** Create an identity only for an existing parent. A recreated path gets a new identity. */
|
|
43
|
+
export function stateGeneration(dir) {
|
|
44
|
+
return withAncestryLock(() => {
|
|
45
|
+
const store = getActiveWorldStore();
|
|
46
|
+
if (!store.stat(dir)?.isDirectory)
|
|
47
|
+
throw new Error(`Branch parent is missing: ${dir}`);
|
|
48
|
+
assertNotBeingRemoved(dir);
|
|
49
|
+
let meta = metadata(dir);
|
|
50
|
+
if (!meta) {
|
|
51
|
+
meta = { generation: randomUUID(), children: [] };
|
|
52
|
+
store.writeAtomic(metaPath(dir), JSON.stringify(meta));
|
|
53
|
+
}
|
|
54
|
+
return meta.generation;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Must be called in the same critical section as child pointer publication. A crash before
|
|
58
|
+
* publication leaves a pending pin; it can be ignored only once its writer is provably dead. */
|
|
59
|
+
export function pinParent(parentDir, child, expected) {
|
|
60
|
+
parentDir = canonicalStatePath(parentDir);
|
|
61
|
+
child = canonicalStatePath(child);
|
|
62
|
+
const generation = stateGeneration(parentDir);
|
|
63
|
+
if (expected !== undefined && generation !== expected)
|
|
64
|
+
throw new Error(`Branch parent generation changed: ${parentDir}`);
|
|
65
|
+
const meta = metadata(parentDir);
|
|
66
|
+
if (!meta.children.some(p => p.child === child)) {
|
|
67
|
+
meta.children.push({ child, ownerPid: process.pid });
|
|
68
|
+
getActiveWorldStore().writeAtomic(metaPath(parentDir), JSON.stringify(meta));
|
|
69
|
+
}
|
|
70
|
+
return generation;
|
|
71
|
+
}
|
|
72
|
+
/** Mark publication complete only after branch.json exists. */
|
|
73
|
+
export function commitParentPin(parentDir, child) {
|
|
74
|
+
parentDir = canonicalStatePath(parentDir);
|
|
75
|
+
child = canonicalStatePath(child);
|
|
76
|
+
const meta = metadata(parentDir);
|
|
77
|
+
const pin = meta.children.find(p => p.child === child);
|
|
78
|
+
pin.committed = true;
|
|
79
|
+
getActiveWorldStore().writeAtomic(metaPath(parentDir), JSON.stringify(meta));
|
|
80
|
+
}
|
|
81
|
+
/** Read-only identity validation. */
|
|
82
|
+
export function checkParent(parentDir, expected) {
|
|
83
|
+
if (!getActiveWorldStore().stat(parentDir)?.isDirectory)
|
|
84
|
+
throw new Error(`Branch parent is missing: ${parentDir}`);
|
|
85
|
+
if (expected !== undefined && metadata(parentDir)?.generation !== expected)
|
|
86
|
+
throw new Error(`Branch parent generation changed: ${parentDir}`);
|
|
87
|
+
}
|
|
88
|
+
function filesUnder(path) {
|
|
89
|
+
const store = getActiveWorldStore();
|
|
90
|
+
const info = store.stat(path);
|
|
91
|
+
if (info?.isSymbolicLink)
|
|
92
|
+
throw new Error(`Symbolic-link branch state retained: ${path}`);
|
|
93
|
+
if (!info?.isDirectory)
|
|
94
|
+
return [];
|
|
95
|
+
const result = [];
|
|
96
|
+
const stateSuffix = join(stateDirName(), 'world');
|
|
97
|
+
const stateService = dirname(path).endsWith(`${sep}${stateSuffix}`);
|
|
98
|
+
if (stateService && store.stat(join(path, 'views'))?.isSymbolicLink)
|
|
99
|
+
throw new Error(`Symbolic-link history views retained: ${path}`);
|
|
100
|
+
if (stateService)
|
|
101
|
+
return [...['ancestry.json', 'branch.json'].map(name => join(path, name)), ...store.list(join(path, 'views')).filter(name => /^[a-f0-9]{64}\.json$/.test(name)).map(name => join(path, 'views', name))].filter(file => {
|
|
102
|
+
const stat = store.stat(file);
|
|
103
|
+
if (stat?.isSymbolicLink)
|
|
104
|
+
throw new Error(`Symbolic-link ancestry metadata retained: ${file}`);
|
|
105
|
+
return stat !== null;
|
|
106
|
+
});
|
|
107
|
+
for (const name of store.list(path)) {
|
|
108
|
+
if (name === 'node_modules' || name === '.git')
|
|
109
|
+
continue;
|
|
110
|
+
const child = join(path, name);
|
|
111
|
+
if (store.stat(child)?.isSymbolicLink)
|
|
112
|
+
continue; // recursive removal unlinks this entry; its target survives
|
|
113
|
+
if (store.stat(child)?.isDirectory)
|
|
114
|
+
result.push(...filesUnder(child));
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
/** Caller holds the coordinator through the actual deletion. A committed pin with missing
|
|
119
|
+
* child metadata is uncertainty, not permission: only explicit child removal releases it. */
|
|
120
|
+
export function assertStateRemovable(path) {
|
|
121
|
+
const store = getActiveWorldStore();
|
|
122
|
+
for (const file of filesUnder(path).filter(f => f.endsWith(`${sep}ancestry.json`))) {
|
|
123
|
+
const meta = metadata(dirname(file));
|
|
124
|
+
for (const pin of meta.children) {
|
|
125
|
+
if (inside(path, pin.child))
|
|
126
|
+
continue; // deleting the entire dependent subtree together
|
|
127
|
+
const raw = store.read(pin.child);
|
|
128
|
+
if (raw === null) {
|
|
129
|
+
if (!pin.committed) {
|
|
130
|
+
try {
|
|
131
|
+
process.kill(pin.ownerPid, 0);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error.code === 'ESRCH')
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`Retained branch parent: dependent branch metadata unavailable at ${pin.child}`);
|
|
139
|
+
}
|
|
140
|
+
const retained = JSON.parse(raw);
|
|
141
|
+
if (retained.dependencies !== undefined) {
|
|
142
|
+
if (createHash('sha256').update(canonicalJson(retained)).digest('hex') + '.json' !== basename(pin.child))
|
|
143
|
+
throw new Error(`Invalid retained history ownership ${pin.child}`);
|
|
144
|
+
if (!Array.isArray(retained.dependencies) || retained.dependencies.some(d => typeof d.directory !== 'string' || typeof d.generation !== 'string'))
|
|
145
|
+
throw new Error(`Unreadable retained history ${pin.child}`);
|
|
146
|
+
if (retained.dependencies.some(d => canonicalStatePath(d.directory) === canonicalStatePath(dirname(file))))
|
|
147
|
+
throw new Error(`Retained branch parent: dependent history view ${pin.child}`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const child = JSON.parse(raw);
|
|
151
|
+
if (!child || !child.parent || typeof child.parent.at !== 'string' || !Number.isInteger(child.parent.position) || Number(child.parent.position) < 0
|
|
152
|
+
|| (child.parent.generation !== undefined && typeof child.parent.generation !== 'string'))
|
|
153
|
+
throw new Error(`Unreadable dependent branch ${pin.child}; retained`);
|
|
154
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(child.parent.at))
|
|
155
|
+
continue;
|
|
156
|
+
// The pin's child state layout is <control>/<stateDir>/world/<service>/branch.json.
|
|
157
|
+
// Derive the real parent from at, never trust an independently edited directory field.
|
|
158
|
+
const layout = join(stateDirName(), 'world', basename(dirname(pin.child)));
|
|
159
|
+
const actualParent = canonicalStatePath(join(child.parent.at, layout));
|
|
160
|
+
if (actualParent === canonicalStatePath(dirname(file)) || !child.parent.generation
|
|
161
|
+
|| child.parent.directory !== actualParent || metadata(actualParent)?.generation !== child.parent.generation) {
|
|
162
|
+
throw new Error(`Retained branch parent: dependent branch ${pin.child}. Remove dependent branches first; down without purge can still stop compute.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const removalDir = () => join(volterHome(), 'branch-removals');
|
|
168
|
+
function releaseOutgoing(removal) {
|
|
169
|
+
const store = getActiveWorldStore();
|
|
170
|
+
for (const { child, parent } of removal.outgoing) {
|
|
171
|
+
if (!inside(removal.path, child))
|
|
172
|
+
throw new Error('Invalid branch removal receipt; retained');
|
|
173
|
+
if (!parent?.directory || inside(removal.path, parent.directory))
|
|
174
|
+
continue;
|
|
175
|
+
const meta = metadata(parent.directory);
|
|
176
|
+
if (!meta || meta.generation !== parent.generation)
|
|
177
|
+
continue;
|
|
178
|
+
meta.children = meta.children.filter(p => p.child !== child);
|
|
179
|
+
store.writeAtomic(metaPath(parent.directory), JSON.stringify(meta));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** A crash after deleting files cannot strand committed child pins. Receipts are replayed
|
|
183
|
+
* before any subsequent ancestry publication, so path reuse cannot hide completed removal. */
|
|
184
|
+
function recoverRemovals() {
|
|
185
|
+
const store = getActiveWorldStore();
|
|
186
|
+
const pending = [];
|
|
187
|
+
for (const name of store.list(removalDir()).filter(n => n.endsWith('.json'))) {
|
|
188
|
+
const file = join(removalDir(), name);
|
|
189
|
+
const removal = JSON.parse(store.read(file));
|
|
190
|
+
if (typeof removal.path !== 'string' || !Array.isArray(removal.outgoing))
|
|
191
|
+
throw new Error('Invalid branch removal receipt; retained');
|
|
192
|
+
if (!store.exists(removal.path)) {
|
|
193
|
+
releaseOutgoing(removal);
|
|
194
|
+
store.remove(file);
|
|
195
|
+
}
|
|
196
|
+
else
|
|
197
|
+
pending.push(removal.path);
|
|
198
|
+
}
|
|
199
|
+
beingRemoved = pending;
|
|
200
|
+
}
|
|
201
|
+
/** State whose removal is recorded and not yet complete: its deletion runs outside the coordinator, so nothing may pin
|
|
202
|
+
* it as a parent or write into it meanwhile. Read under the coordinator, where recoverRemovals has just refreshed it. */
|
|
203
|
+
let beingRemoved = [];
|
|
204
|
+
export function assertNotBeingRemoved(path) {
|
|
205
|
+
const at = beingRemoved.find((p) => inside(p, path));
|
|
206
|
+
if (at)
|
|
207
|
+
throw new Error(`World state is being removed: ${at}`);
|
|
208
|
+
}
|
|
209
|
+
/** force on scrub bypasses shape checking only, never another branch's ownership. The coordinator is held to decide
|
|
210
|
+
* and record the removal (`check`, the caller's own precondition, runs there too) and to release it, not through the
|
|
211
|
+
* deletion: every World on the machine shares the coordinator, and a large tree's deletion outlasts their writes'
|
|
212
|
+
* patience. The receipt, written first, keeps the path from being pinned or written while it is deleted. */
|
|
213
|
+
export function withStateRemoval(path, remove, check) {
|
|
214
|
+
const store = getActiveWorldStore();
|
|
215
|
+
const { removal, receipt } = withAncestryLock(() => {
|
|
216
|
+
check?.();
|
|
217
|
+
assertStateRemovable(path);
|
|
218
|
+
const outgoing = filesUnder(path).filter(f => !f.endsWith(`${sep}ancestry.json`)).flatMap(child => {
|
|
219
|
+
const branch = JSON.parse(store.read(child));
|
|
220
|
+
return (branch.dependencies ?? [...(branch.previousParents ?? []), branch.parent]).map(parent => ({ child: canonicalStatePath(child), parent }));
|
|
221
|
+
});
|
|
222
|
+
const removal = { path: canonicalStatePath(path), outgoing };
|
|
223
|
+
const receipt = join(removalDir(), `${randomUUID()}.json`);
|
|
224
|
+
store.writeAtomic(receipt, JSON.stringify(removal));
|
|
225
|
+
beingRemoved = [...beingRemoved, removal.path];
|
|
226
|
+
return { removal, receipt };
|
|
227
|
+
});
|
|
228
|
+
const result = remove();
|
|
229
|
+
withAncestryLock(() => {
|
|
230
|
+
if (store.exists(path))
|
|
231
|
+
throw new Error(`Branch state removal incomplete: ${path}; receipt retained`);
|
|
232
|
+
// Another process's recovery may have released it already; both steps are idempotent.
|
|
233
|
+
releaseOutgoing(removal);
|
|
234
|
+
if (store.exists(receipt))
|
|
235
|
+
store.remove(receipt);
|
|
236
|
+
});
|
|
237
|
+
return result;
|
|
238
|
+
}
|
package/dist/src/args.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Shared CLI argument helpers for world scripts and tools. Scripts kept
|
|
2
|
+
// growing private copies of these (5 in the sync subsystem alone before this
|
|
3
|
+
// file); a parsing fix that lands in one copy and not the others corrupts
|
|
4
|
+
// flag handling silently.
|
|
5
|
+
/** Value of `--name <value>`; fallback when the flag is absent or has no value. */
|
|
6
|
+
export function optionValue(args, name, fallback = '') {
|
|
7
|
+
const index = args.indexOf(name);
|
|
8
|
+
return index >= 0 && index + 1 < args.length ? args[index + 1] : fallback;
|
|
9
|
+
}
|
|
10
|
+
export function hasFlag(args, name) {
|
|
11
|
+
return args.includes(name);
|
|
12
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface BlobStore {
|
|
2
|
+
/** Bytes at `key`, or null when absent. Never throws for a missing key. */
|
|
3
|
+
get(key: string): Promise<Uint8Array | null>;
|
|
4
|
+
/** Store bytes at `key`, atomically (a reader never observes a torn blob). Creating
|
|
5
|
+
* parent "directories" is the store's job; idempotent overwrite is allowed (content-
|
|
6
|
+
* addressed callers never rewrite different bytes under one key). */
|
|
7
|
+
put(key: string, bytes: Uint8Array): Promise<void>;
|
|
8
|
+
exists(key: string): Promise<boolean>;
|
|
9
|
+
/** Remove the blob (or, for a prefix ending in '/', everything under it — namespace
|
|
10
|
+
* reset uses this). A missing key is not an error. */
|
|
11
|
+
remove(key: string): Promise<void>;
|
|
12
|
+
/** Keys currently stored under `prefix` (full keys, sorted). Lifecycle/reset tooling
|
|
13
|
+
* only — the serve path addresses blobs by exact key. */
|
|
14
|
+
list(prefix: string): Promise<string[]>;
|
|
15
|
+
/** Byte size at `key`, or null when absent. */
|
|
16
|
+
size(key: string): Promise<number | null>;
|
|
17
|
+
/** Bytes `start`..`endInclusive` at `key` (clamped to the blob), or null when absent — a native
|
|
18
|
+
* ranged read, so serving a range of a large blob (a video a player seeks in) does not load it
|
|
19
|
+
* whole. Optional: `readBlobRange` falls back to `get` (a whole read) for a backend without it —
|
|
20
|
+
* the hosted World's R2 store today. */
|
|
21
|
+
getRange?(key: string, start: number, endInclusive: number): Promise<Uint8Array | null>;
|
|
22
|
+
}
|
|
23
|
+
/** A range of the blob at `key` through the active store: native when the store has it, else sliced. */
|
|
24
|
+
export declare function readBlobRange(key: string, start: number, endInclusive: number): Promise<Uint8Array | null>;
|
|
25
|
+
/** Today's layout, verbatim: keys are filesystem paths. */
|
|
26
|
+
export declare class FsBlobStore implements BlobStore {
|
|
27
|
+
private options;
|
|
28
|
+
constructor(options?: {
|
|
29
|
+
sharedIndexRoot?: string | false;
|
|
30
|
+
});
|
|
31
|
+
get(key: string): Promise<Uint8Array | null>;
|
|
32
|
+
put(key: string, bytes: Uint8Array): Promise<void>;
|
|
33
|
+
exists(key: string): Promise<boolean>;
|
|
34
|
+
remove(key: string): Promise<void>;
|
|
35
|
+
list(prefix: string): Promise<string[]>;
|
|
36
|
+
size(key: string): Promise<number | null>;
|
|
37
|
+
getRange(key: string, start: number, endInclusive: number): Promise<Uint8Array | null>;
|
|
38
|
+
}
|
|
39
|
+
/** In-memory backend — tests and hydration rehearsals; also the shape an R2/S3 adapter
|
|
40
|
+
* implements (same interface, object keys instead of paths). */
|
|
41
|
+
export declare class MemoryBlobStore implements BlobStore {
|
|
42
|
+
private blobs;
|
|
43
|
+
get(key: string): Promise<Uint8Array | null>;
|
|
44
|
+
put(key: string, bytes: Uint8Array): Promise<void>;
|
|
45
|
+
exists(key: string): Promise<boolean>;
|
|
46
|
+
remove(key: string): Promise<void>;
|
|
47
|
+
list(prefix: string): Promise<string[]>;
|
|
48
|
+
size(key: string): Promise<number | null>;
|
|
49
|
+
getRange(key: string, start: number, endInclusive: number): Promise<Uint8Array | null>;
|
|
50
|
+
}
|
|
51
|
+
/** Content-address helper shared by byte-carrying packs: sha256 hex of the bytes. */
|
|
52
|
+
export declare function blobDigest(bytes: Uint8Array): string;
|
|
53
|
+
export declare function getActiveBlobStore(): BlobStore;
|
|
54
|
+
export declare function setActiveBlobStore(store: BlobStore): BlobStore;
|
|
55
|
+
export declare function withBlobStore<T>(store: BlobStore, fn: () => Promise<T> | T): Promise<T>;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// THE BLOB SEAM (runtime contract R11) — byte storage behind every handler that touches
|
|
2
|
+
// raw bytes (slack file uploads, jira attachments), so a serverless namespace can put its
|
|
3
|
+
// bytes in object storage while local worlds keep today's on-disk layout unchanged.
|
|
4
|
+
//
|
|
5
|
+
// Why bytes get their OWN seam instead of riding WorldStore: WorldStore is string-typed by
|
|
6
|
+
// design (JSONL rows, JSON sidecars — the hydrate/flush snapshot is a path→string map), and
|
|
7
|
+
// blob bytes must never enter that snapshot: they can be large, they never participate in
|
|
8
|
+
// preconditions or projection, and they are served/accepted at the edge. Blob KEYS live in
|
|
9
|
+
// string state; bytes live here. That separation is what keeps namespace snapshots small
|
|
10
|
+
// enough for a Durable Object's memory (runtime contract R11–R14).
|
|
11
|
+
//
|
|
12
|
+
// ASYNC-NATIVE, unlike WorldStore: the sync kernel never reads blobs mid-projection — bytes
|
|
13
|
+
// are read/written at the request edge (upload accept, download serve), which is already
|
|
14
|
+
// async in every server. So there is no hydrate-whole-namespace step for blobs and no sync
|
|
15
|
+
// constraint to honor: an R2/S3 adapter implements this interface directly, per request.
|
|
16
|
+
//
|
|
17
|
+
// Keys are PATHS (the FsWorldStore convention): the fs backend uses them verbatim on disk,
|
|
18
|
+
// so packs keep computing today's exact layout (…/resources/blobs/sha256/<ab>/<hex>) and
|
|
19
|
+
// local worlds change NOTHING — scrub, backup, and inspection all still see the same tree.
|
|
20
|
+
// A remote adapter maps the path to an object key (namespace prefix + path).
|
|
21
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
22
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { volterHome } from "./volter-home.js";
|
|
25
|
+
import { sharedBlobIndex } from "./shared-blob-index.js";
|
|
26
|
+
/** A range of the blob at `key` through the active store: native when the store has it, else sliced. */
|
|
27
|
+
export async function readBlobRange(key, start, endInclusive) {
|
|
28
|
+
// one clamping for every backend: a negative or non-finite start reads from 0, a range that ends
|
|
29
|
+
// before it begins is empty (subarray alone would count negatives from the end)
|
|
30
|
+
const from = Number.isFinite(start) ? Math.max(0, Math.floor(start)) : 0;
|
|
31
|
+
const to = Number.isFinite(endInclusive) ? Math.floor(endInclusive) : -1;
|
|
32
|
+
const store = getActiveBlobStore();
|
|
33
|
+
if (to < from)
|
|
34
|
+
return (await store.exists(key)) ? new Uint8Array(0) : null;
|
|
35
|
+
if (store.getRange)
|
|
36
|
+
return store.getRange(key, from, to);
|
|
37
|
+
const whole = await store.get(key);
|
|
38
|
+
return whole === null ? null : whole.subarray(from, to + 1);
|
|
39
|
+
}
|
|
40
|
+
/** Today's layout, verbatim: keys are filesystem paths. */
|
|
41
|
+
export class FsBlobStore {
|
|
42
|
+
options;
|
|
43
|
+
constructor(options = {}) {
|
|
44
|
+
this.options = options;
|
|
45
|
+
}
|
|
46
|
+
async get(key) {
|
|
47
|
+
if (!existsSync(key))
|
|
48
|
+
return null;
|
|
49
|
+
return new Uint8Array(readFileSync(key));
|
|
50
|
+
}
|
|
51
|
+
async put(key, bytes) {
|
|
52
|
+
mkdirSync(dirname(key), { recursive: true });
|
|
53
|
+
// Write-then-rename so a crash mid-write never leaves a truncated blob that a
|
|
54
|
+
// content-addressed caller would forever report as present (the slack-blobs rule,
|
|
55
|
+
// now the seam's rule).
|
|
56
|
+
const tmp = `${key}.tmp.${randomUUID()}`;
|
|
57
|
+
let index;
|
|
58
|
+
try {
|
|
59
|
+
if (this.options.sharedIndexRoot !== false) {
|
|
60
|
+
try {
|
|
61
|
+
index = sharedBlobIndex(this.options.sharedIndexRoot ?? join(volterHome(), 'blob-index-v1'), statSync(dirname(key)).dev, blobDigest(bytes));
|
|
62
|
+
}
|
|
63
|
+
catch { /* The index is an optimization, never a prerequisite for a blob write. */ }
|
|
64
|
+
}
|
|
65
|
+
if (!index?.reuse(tmp, bytes.byteLength))
|
|
66
|
+
writeFileSync(tmp, bytes, { flag: 'wx' });
|
|
67
|
+
renameSync(tmp, key);
|
|
68
|
+
index?.record(key);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
rmSync(tmp, { force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async exists(key) {
|
|
75
|
+
return existsSync(key);
|
|
76
|
+
}
|
|
77
|
+
async remove(key) {
|
|
78
|
+
rmSync(key, { force: true, recursive: key.endsWith('/') });
|
|
79
|
+
}
|
|
80
|
+
async list(prefix) {
|
|
81
|
+
const out = [];
|
|
82
|
+
const walk = (dir) => {
|
|
83
|
+
if (!existsSync(dir))
|
|
84
|
+
return;
|
|
85
|
+
for (const name of readdirSync(dir, { withFileTypes: true })) {
|
|
86
|
+
const child = join(dir, name.name);
|
|
87
|
+
if (name.isDirectory())
|
|
88
|
+
walk(child);
|
|
89
|
+
else if (!name.name.includes('.tmp.'))
|
|
90
|
+
out.push(child);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
walk(prefix.replace(/\/$/, ''));
|
|
94
|
+
return out.sort();
|
|
95
|
+
}
|
|
96
|
+
async size(key) {
|
|
97
|
+
if (!existsSync(key))
|
|
98
|
+
return null;
|
|
99
|
+
return statSync(key).size;
|
|
100
|
+
}
|
|
101
|
+
async getRange(key, start, endInclusive) {
|
|
102
|
+
if (!existsSync(key))
|
|
103
|
+
return null;
|
|
104
|
+
const total = statSync(key).size;
|
|
105
|
+
const from = Math.max(0, start), to = Math.min(endInclusive, total - 1);
|
|
106
|
+
if (to < from)
|
|
107
|
+
return new Uint8Array(0);
|
|
108
|
+
const out = new Uint8Array(to - from + 1);
|
|
109
|
+
const fd = openSync(key, 'r');
|
|
110
|
+
let read = 0;
|
|
111
|
+
try {
|
|
112
|
+
while (read < out.length) {
|
|
113
|
+
const n = readSync(fd, out, read, out.length - read, from + read);
|
|
114
|
+
if (n === 0)
|
|
115
|
+
break;
|
|
116
|
+
read += n;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
closeSync(fd);
|
|
121
|
+
}
|
|
122
|
+
return read === out.length ? out : out.subarray(0, read);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** In-memory backend — tests and hydration rehearsals; also the shape an R2/S3 adapter
|
|
126
|
+
* implements (same interface, object keys instead of paths). */
|
|
127
|
+
export class MemoryBlobStore {
|
|
128
|
+
blobs = new Map();
|
|
129
|
+
async get(key) {
|
|
130
|
+
const bytes = this.blobs.get(key);
|
|
131
|
+
return bytes === undefined ? null : new Uint8Array(bytes);
|
|
132
|
+
}
|
|
133
|
+
async put(key, bytes) {
|
|
134
|
+
this.blobs.set(key, new Uint8Array(bytes));
|
|
135
|
+
}
|
|
136
|
+
async exists(key) {
|
|
137
|
+
return this.blobs.has(key);
|
|
138
|
+
}
|
|
139
|
+
async remove(key) {
|
|
140
|
+
if (key.endsWith('/')) {
|
|
141
|
+
for (const k of [...this.blobs.keys()])
|
|
142
|
+
if (k.startsWith(key))
|
|
143
|
+
this.blobs.delete(k);
|
|
144
|
+
}
|
|
145
|
+
else
|
|
146
|
+
this.blobs.delete(key);
|
|
147
|
+
}
|
|
148
|
+
async list(prefix) {
|
|
149
|
+
return [...this.blobs.keys()].filter((k) => k.startsWith(prefix.replace(/\/$/, ''))).sort();
|
|
150
|
+
}
|
|
151
|
+
async size(key) {
|
|
152
|
+
return this.blobs.get(key)?.length ?? null;
|
|
153
|
+
}
|
|
154
|
+
async getRange(key, start, endInclusive) {
|
|
155
|
+
const bytes = this.blobs.get(key);
|
|
156
|
+
if (bytes === undefined)
|
|
157
|
+
return null;
|
|
158
|
+
const from = Number.isFinite(start) ? Math.max(0, Math.floor(start)) : 0;
|
|
159
|
+
const to = Number.isFinite(endInclusive) ? Math.floor(endInclusive) : -1;
|
|
160
|
+
return to < from ? new Uint8Array(0) : new Uint8Array(bytes.subarray(from, to + 1));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Content-address helper shared by byte-carrying packs: sha256 hex of the bytes. */
|
|
164
|
+
export function blobDigest(bytes) {
|
|
165
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
166
|
+
}
|
|
167
|
+
// Active-store scoping, the world-store.ts pattern exactly: default fs; a serverless entry
|
|
168
|
+
// (or a test) swaps in its adapter for the scope of a request.
|
|
169
|
+
let activeBlobStore = new FsBlobStore();
|
|
170
|
+
export function getActiveBlobStore() {
|
|
171
|
+
return activeBlobStore;
|
|
172
|
+
}
|
|
173
|
+
export function setActiveBlobStore(store) {
|
|
174
|
+
const previous = activeBlobStore;
|
|
175
|
+
activeBlobStore = store;
|
|
176
|
+
return previous;
|
|
177
|
+
}
|
|
178
|
+
export async function withBlobStore(store, fn) {
|
|
179
|
+
const previous = setActiveBlobStore(store);
|
|
180
|
+
try {
|
|
181
|
+
return await fn();
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
setActiveBlobStore(previous);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Serving the Volter brand's tokens and faces (company decision 0018) from a page's own base, like
|
|
2
|
+
// fileResponse (the serve seam's helpers, docs/contributing/architecture.md#the-serve-seam). The files
|
|
3
|
+
// are written at build by scripts/brand-tokens.ts into the page's gitignored `brand/` directory; this
|
|
4
|
+
// only reads them and never fetches, so a running world does not reach the brand.
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { fileResponse } from "./file-response.js";
|
|
8
|
+
const FONT_TYPES = { woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf' };
|
|
9
|
+
/** Serve `<base>/brand/<rest>` from `dir`: `tokens.css` or `fonts/<face>`; 404 for any other name or a file the build did not write. */
|
|
10
|
+
export function brandTokensResponse(dir, rest) {
|
|
11
|
+
const font = /^fonts\/[A-Za-z0-9_.-]+\.(woff2|woff|ttf|otf)$/.exec(rest);
|
|
12
|
+
if (rest !== 'tokens.css' && !font)
|
|
13
|
+
return new Response('not found', { status: 404 });
|
|
14
|
+
if (!existsSync(join(dir, 'tokens.css')))
|
|
15
|
+
return new Response('the brand tokens are not built here; run `bun scripts/brand-tokens.ts`', { status: 404 });
|
|
16
|
+
return fileResponse(join(dir, rest), { headers: { 'cache-control': 'no-cache', 'content-type': font ? FONT_TYPES[font[1]] : 'text/css; charset=utf-8' } });
|
|
17
|
+
}
|