@volter/world-core 3.0.37 → 3.0.39
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/app-route.cjs +26 -25
- package/app-route.d.cts +1 -0
- package/dist/app-route.cjs +26 -25
- package/dist/app-route.d.cts +1 -0
- package/dist/generated/pack-facts.json +409 -352
- package/dist/inject.cjs +474 -95
- package/dist/network-policy.cjs +108 -13
- package/dist/network-policy.d.cts +8 -3
- package/dist/src/actions.js +2 -1
- package/dist/src/anthropic-wire.d.ts +2 -0
- package/dist/src/anthropic-wire.js +5 -0
- package/dist/src/attestation.d.ts +6 -0
- package/dist/src/attestation.js +27 -0
- package/dist/src/bytes.d.ts +3 -0
- package/dist/src/bytes.js +17 -0
- package/dist/src/changeset.js +2 -1
- package/dist/src/clickhouse/sql.js +73 -14
- package/dist/src/derived-core.d.ts +210 -32
- package/dist/src/derived-core.js +510 -244
- package/dist/src/derived-real.d.ts +1 -1
- package/dist/src/derived-real.js +704 -93
- package/dist/src/derived.d.ts +18 -2
- package/dist/src/derived.js +21 -2
- package/dist/src/events.d.ts +28 -45
- package/dist/src/events.js +40 -58
- package/dist/src/exact-json.d.ts +10 -0
- package/dist/src/exact-json.js +98 -0
- package/dist/src/executor.d.ts +17 -0
- package/dist/src/executor.js +91 -30
- package/dist/src/file-response.js +1 -1
- package/dist/src/form.d.ts +8 -0
- package/dist/src/form.js +76 -0
- package/dist/src/git/format.d.ts +16 -0
- package/dist/src/git/format.js +110 -0
- package/dist/src/git/index.d.ts +1 -0
- package/dist/src/git/index.js +1 -0
- package/dist/src/git/lfs.d.ts +1 -1
- package/dist/src/git/lfs.js +4 -3
- package/dist/src/graphql-wire.d.ts +6 -0
- package/dist/src/graphql-wire.js +6 -3
- package/dist/src/grpc-wire.js +2 -1
- package/dist/src/head.d.ts +1 -0
- package/dist/src/head.js +15 -4
- package/dist/src/held-reads.d.ts +6 -0
- package/dist/src/held-reads.js +60 -0
- package/dist/src/history.js +5 -4
- package/dist/src/host-port.d.ts +5 -0
- package/dist/src/host-port.js +14 -0
- package/dist/src/index.d.ts +11 -3
- package/dist/src/index.js +9 -4
- package/dist/src/log.js +12 -7
- package/dist/src/machines.d.ts +81 -5
- package/dist/src/machines.js +125 -9
- package/dist/src/managed-database.d.ts +15 -11
- package/dist/src/managed-database.js +38 -37
- package/dist/src/multipart.d.ts +21 -3
- package/dist/src/multipart.js +112 -38
- package/dist/src/observe.d.ts +3 -0
- package/dist/src/observe.js +6 -2
- package/dist/src/openai-wire.d.ts +16 -0
- package/dist/src/openai-wire.js +43 -12
- package/dist/src/pack-fetch.d.ts +4 -6
- package/dist/src/pack-fetch.js +189 -57
- package/dist/src/packRegistry.d.ts +7 -1
- package/dist/src/packRegistry.js +20 -3
- package/dist/src/private-transport.d.ts +1 -0
- package/dist/src/private-transport.js +11 -0
- package/dist/src/protobuf.js +10 -4
- package/dist/src/rateBudget.d.ts +8 -1
- package/dist/src/rateBudget.js +1 -0
- package/dist/src/redis/engine.d.ts +14 -5
- package/dist/src/redis/engine.js +271 -72
- package/dist/src/redis/frames.d.ts +17 -0
- package/dist/src/redis/frames.js +123 -0
- package/dist/src/redis/index.d.ts +6 -0
- package/dist/src/redis/index.js +6 -0
- package/dist/src/redis/json.d.ts +2 -0
- package/dist/src/redis/json.js +14 -0
- package/dist/src/redis/lua-libs.d.ts +4 -0
- package/dist/src/redis/lua-libs.js +173 -0
- package/dist/src/redis/lua.d.ts +1 -0
- package/dist/src/redis/lua.js +36 -0
- package/dist/src/redis/member-storage.d.ts +5 -0
- package/dist/src/redis/member-storage.js +75 -0
- package/dist/src/redis/resp2.d.ts +4 -0
- package/dist/src/redis/resp2.js +29 -0
- package/dist/src/redis/stream.d.ts +14 -0
- package/dist/src/redis/stream.js +335 -0
- package/dist/src/remote-execute.d.ts +2 -2
- package/dist/src/request-body.d.ts +11 -0
- package/dist/src/request-body.js +11 -0
- package/dist/src/runtime.d.ts +6 -5
- package/dist/src/runtime.js +5 -5
- package/dist/src/s3/wire.d.ts +4 -4
- package/dist/src/s3/wire.js +5 -3
- package/dist/src/scenario.d.ts +22 -0
- package/dist/src/scenario.js +35 -31
- package/dist/src/serve-http.d.ts +2 -1
- package/dist/src/serve-http.js +10 -1
- package/dist/src/serve.js +7 -4
- package/dist/src/signing.d.ts +23 -5
- package/dist/src/signing.js +74 -11
- package/dist/src/sigv4.d.ts +3 -1
- package/dist/src/sigv4.js +4 -2
- package/dist/src/smtp.js +2 -1
- package/dist/src/sockets.js +8 -3
- package/dist/src/state-system.d.ts +3 -0
- package/dist/src/storage.js +5 -2
- package/dist/src/twin-fetch.d.ts +3 -1
- package/dist/src/twin-fetch.js +3 -2
- package/dist/src/vendor-call.d.ts +10 -3
- package/dist/src/vendor-call.js +58 -14
- package/dist/test-fixtures/attestation.SOURCE.md +1 -0
- package/dist/test-fixtures/attestation.json +50 -0
- package/generated/pack-facts.json +409 -352
- package/inject.cjs +474 -95
- package/network-policy.cjs +108 -13
- package/network-policy.d.cts +8 -3
- package/package.json +11 -4
- package/src/actions.ts +2 -1
- package/src/anthropic-wire.ts +6 -0
- package/src/attestation.ts +23 -0
- package/src/bytes.ts +21 -0
- package/src/changeset.ts +2 -1
- package/src/clickhouse/sql.ts +50 -16
- package/src/derived-core.ts +573 -249
- package/src/derived-real.ts +552 -42
- package/src/derived.ts +23 -4
- package/src/events.ts +54 -85
- package/src/exact-json.ts +71 -0
- package/src/executor.ts +86 -27
- package/src/file-response.ts +1 -1
- package/src/form.ts +66 -0
- package/src/git/format.ts +89 -0
- package/src/git/index.ts +1 -0
- package/src/git/lfs.ts +5 -4
- package/src/graphql-wire.ts +8 -3
- package/src/grpc-wire.ts +2 -1
- package/src/head.ts +15 -5
- package/src/held-reads.ts +54 -0
- package/src/history.ts +5 -4
- package/src/host-port.ts +9 -0
- package/src/index.ts +10 -4
- package/src/log.ts +11 -7
- package/src/machines.ts +135 -13
- package/src/managed-database.ts +48 -40
- package/src/multipart.ts +110 -35
- package/src/observe.ts +6 -3
- package/src/openai-wire.ts +45 -14
- package/src/pack-fetch.ts +173 -50
- package/src/packRegistry.ts +27 -4
- package/src/private-transport.ts +8 -0
- package/src/protobuf.ts +8 -4
- package/src/rateBudget.ts +6 -1
- package/src/redis/engine.ts +168 -63
- package/src/redis/frames.ts +53 -0
- package/src/redis/index.ts +6 -0
- package/src/redis/json.ts +13 -0
- package/src/redis/lua-libs.ts +62 -0
- package/src/redis/lua.ts +20 -0
- package/src/redis/member-storage.ts +58 -0
- package/src/redis/resp2.ts +25 -0
- package/src/redis/stream.ts +102 -0
- package/src/remote-execute.ts +2 -2
- package/src/request-body.ts +12 -0
- package/src/runtime.ts +6 -5
- package/src/s3/wire.ts +8 -5
- package/src/scenario.ts +34 -13
- package/src/serve-http.ts +11 -2
- package/src/serve.ts +4 -3
- package/src/signing.ts +71 -17
- package/src/sigv4.ts +5 -3
- package/src/smtp.ts +3 -2
- package/src/sockets.ts +8 -3
- package/src/state-system.ts +3 -0
- package/src/storage.ts +5 -2
- package/src/twin-fetch.ts +4 -3
- package/src/vendor-call.ts +59 -12
- package/test-fixtures/attestation.json +50 -0
package/src/machines.ts
CHANGED
|
@@ -3,27 +3,80 @@
|
|
|
3
3
|
// `POST /_twin/machine-pool`, recorded in the pack's own tree; a handler reaches it as `ctx.machines`. `docker` runs
|
|
4
4
|
// each machine as a local container with its internal ports published on loopback; `none` runs nothing; `fake`
|
|
5
5
|
// records what it was asked (capability verification). Nothing here runs at module scope, and `node:child_process`
|
|
6
|
-
// is reached only when a
|
|
6
|
+
// is reached only when a compute pool is asked to act. The light pool delegates to the host's Substrate adapter.
|
|
7
|
+
import { refuseReadOnlyWrite } from './request-scope.ts';
|
|
7
8
|
import { nodeBuiltin } from './serve-http.ts';
|
|
8
9
|
|
|
9
|
-
export type MachineSpec = { name: string; image: string; env?: Record<string, string>; ports?: number[]; command?: string[] };
|
|
10
|
-
export type
|
|
10
|
+
export type MachineSpec = { name: string; image: string; env?: Record<string, string>; ports?: number[]; command?: string[]; checks?: Array<{ port: number; type: 'http' | 'tcp'; path?: string }>; resetOnStop?: boolean; mounts?: Array<{ name: string; path: string }>; signal?: AbortSignal; startTimeoutMs?: number };
|
|
11
|
+
export type GuestImage = { context: string; dockerfile?: string; runtime?: 'node' | 'bun' | 'python' | 'deno'; command?: string[]; cwd?: string; env?: Record<string, string>; prepared?: boolean };
|
|
12
|
+
export type LightPoolOptions = { images?: Record<string, GuestImage> };
|
|
13
|
+
export type MachineRun = { ports: Array<{ internal: number; host: number; url?: string }> };
|
|
14
|
+
export type MachineStatus = { state: 'started' | 'paused' | 'stopped' | 'exited' | 'failed'; exitCode?: number; reason?: string };
|
|
15
|
+
export type MachineFileStat = { type: 'file' | 'directory'; size: number; mode: number; mtime: string };
|
|
16
|
+
export type MachineFiles = {
|
|
17
|
+
read(path: string): Promise<Uint8Array>;
|
|
18
|
+
write(path: string, bytes: Uint8Array): Promise<unknown>;
|
|
19
|
+
list(path: string): Promise<Array<{ name: string; type: 'file' | 'directory'; size: number }>>;
|
|
20
|
+
mkdir(path: string): Promise<unknown>;
|
|
21
|
+
remove(path: string): Promise<unknown>;
|
|
22
|
+
stat(path: string): Promise<MachineFileStat>;
|
|
23
|
+
};
|
|
11
24
|
/** A process started in a machine: its pid, and what it writes and how it ends, as they happen. */
|
|
12
25
|
export type MachineProcess = { pid: number; events: AsyncIterable<{ stdout?: string } | { stderr?: string } | { exit: number }> };
|
|
13
26
|
export type ExecSpec = { args: string[]; env?: Record<string, string>; cwd?: string };
|
|
27
|
+
export type MachineHttpReply = { port: number; method: string; path: string; requestText?: string; status: number; headers?: Record<string, string>; body?: string };
|
|
14
28
|
export type MachinePool = {
|
|
15
|
-
readonly kind: 'docker' | 'none' | 'fake';
|
|
29
|
+
readonly kind: 'docker' | 'none' | 'fake' | 'light';
|
|
30
|
+
/** Optional capability admission, before vendor resource creation; starts no process. */
|
|
31
|
+
admitRun?(spec: MachineSpec): Promise<void>;
|
|
16
32
|
/** runs the image; a failure (no container runtime, an image that will not start) rejects with the runtime's message */
|
|
17
33
|
run(spec: MachineSpec): Promise<MachineRun>;
|
|
18
34
|
/** starts a process in a running machine; a machine the pool does not run rejects */
|
|
19
35
|
exec(name: string, spec: ExecSpec): Promise<MachineProcess>;
|
|
36
|
+
request(name: string, port: number, request: Request): Promise<Response>;
|
|
37
|
+
/** Undefined means the metadata-only/fake backing has no process lifecycle to observe. */
|
|
38
|
+
status(name: string): Promise<MachineStatus | undefined>;
|
|
39
|
+
ports(name: string): Promise<number[]>;
|
|
40
|
+
files(name: string): Promise<MachineFiles>;
|
|
41
|
+
pause(name: string): Promise<void>;
|
|
42
|
+
resume(name: string): Promise<void>;
|
|
20
43
|
stop(name: string): Promise<void>;
|
|
21
44
|
remove(name: string): Promise<void>;
|
|
22
45
|
/** the fake pool's record of what it was asked */
|
|
23
|
-
readonly asked?: Array<{ act: 'run' | 'exec' | 'stop' | 'remove'; name: string; spec?: MachineSpec; exec?: ExecSpec }>;
|
|
46
|
+
readonly asked?: Array<{ act: 'run' | 'exec' | 'request' | 'pause' | 'resume' | 'stop' | 'remove'; name: string; spec?: MachineSpec; exec?: ExecSpec; port?: number; path?: string; requestText?: string }>;
|
|
24
47
|
};
|
|
48
|
+
/** A declared compute capability gap, distinct from a failed admitted guest. */
|
|
49
|
+
export function isMachineCapabilityGap(error: unknown): boolean {
|
|
50
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'WORLD_MACHINE_CAPABILITY_GAP';
|
|
51
|
+
}
|
|
25
52
|
export const MACHINE_POOL = '_machine_pool';
|
|
26
|
-
export const POOL_KINDS = ['docker', 'none', 'fake'] as const;
|
|
53
|
+
export const POOL_KINDS = ['docker', 'none', 'fake', 'light'] as const;
|
|
54
|
+
|
|
55
|
+
/** The host owns provider bootstrapping. Browser/kernel imports stay inert, and a missing
|
|
56
|
+
* host provider refuses rather than falling back to metadata-only success. */
|
|
57
|
+
function lightPool(world: string, root: string | undefined, options: LightPoolOptions): MachinePool {
|
|
58
|
+
let ready: Promise<MachinePool> | undefined;
|
|
59
|
+
const load = (): Promise<MachinePool> => ready ??= (async () => {
|
|
60
|
+
const entry = typeof process === 'undefined' ? undefined : process.env.VOLTER_WORLD_GUEST_RUNNER;
|
|
61
|
+
if (!entry) throw new Error('light guest runner is unavailable: run this twin inside a World');
|
|
62
|
+
const provider = await import(entry);
|
|
63
|
+
return provider.createLightGuestPool({ world, root, ...options }) as MachinePool;
|
|
64
|
+
})();
|
|
65
|
+
return {
|
|
66
|
+
kind: 'light',
|
|
67
|
+
admitRun: async (spec) => { await (await load()).admitRun?.(spec); },
|
|
68
|
+
run: async (spec) => (await load()).run(spec),
|
|
69
|
+
exec: async (name, spec) => (await load()).exec(name, spec),
|
|
70
|
+
request: async (name, port, request) => (await load()).request(name, port, request),
|
|
71
|
+
status: async (name) => (await load()).status(name),
|
|
72
|
+
ports: async (name) => (await load()).ports(name),
|
|
73
|
+
files: async (name) => (await load()).files(name),
|
|
74
|
+
pause: async (name) => (await load()).pause(name),
|
|
75
|
+
resume: async (name) => (await load()).resume(name),
|
|
76
|
+
stop: async (name) => (await load()).stop(name),
|
|
77
|
+
remove: async (name) => (await load()).remove(name),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
27
80
|
|
|
28
81
|
/** A container's name for a machine: the World's prefix, the World's own mark (a machine id repeats across Worlds and
|
|
29
82
|
* branches, which mint alike), then the vendor's machine name made safe for docker. */
|
|
@@ -41,6 +94,12 @@ function dockerPool(world: string): MachinePool {
|
|
|
41
94
|
});
|
|
42
95
|
return {
|
|
43
96
|
kind: 'docker',
|
|
97
|
+
async status(name) {
|
|
98
|
+
const state = JSON.parse(await docker(['inspect', '--format', '{{json .State}}', containerName(world, name)]));
|
|
99
|
+
return state.Running ? { state: 'started' } : { state: 'exited', exitCode: state.ExitCode, ...(state.Error ? { reason: state.Error } : {}) };
|
|
100
|
+
},
|
|
101
|
+
async ports() { throw new Error('port discovery requires the light guest backing'); },
|
|
102
|
+
async files() { throw new Error('file access requires the light guest backing'); },
|
|
44
103
|
async run(spec) {
|
|
45
104
|
const name = containerName(world, spec.name);
|
|
46
105
|
await docker(['rm', '-f', name]).catch(() => undefined);
|
|
@@ -105,47 +164,110 @@ function dockerPool(world: string): MachinePool {
|
|
|
105
164
|
const timeout = new Promise<number>((_, reject) => { timer = setTimeout(() => reject(new Error('the process did not start')), STEP_TIMEOUT_MS); });
|
|
106
165
|
try { return { pid: await Promise.race([pidKnown, timeout]), events }; } finally { clearTimeout(timer); }
|
|
107
166
|
},
|
|
167
|
+
async request(name, port, request) {
|
|
168
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('invalid machine port');
|
|
169
|
+
const line = (await docker(['port', containerName(world, name), String(port)])).split('\n')[0] ?? '';
|
|
170
|
+
const host = Number(line.split(':').at(-1));
|
|
171
|
+
if (!Number.isInteger(host) || host < 1 || host > 65535 || !line.startsWith('127.0.0.1:')) throw new Error('the machine port is not published on loopback');
|
|
172
|
+
const source = new URL(request.url);
|
|
173
|
+
const headers = new Headers(request.headers);
|
|
174
|
+
for (const key of ['host', 'connection', 'content-length', 'transfer-encoding']) headers.delete(key);
|
|
175
|
+
const body = ['GET', 'HEAD'].includes(request.method) ? undefined : await request.clone().arrayBuffer();
|
|
176
|
+
return fetch(`http://127.0.0.1:${host}${source.pathname}${source.search}`, { method: request.method, headers, body, redirect: 'manual', signal: request.signal });
|
|
177
|
+
},
|
|
178
|
+
async pause(name) { await docker(['pause', containerName(world, name)]); },
|
|
179
|
+
async resume(name) { await docker(['unpause', containerName(world, name)]); },
|
|
108
180
|
async stop(name) { await docker(['stop', containerName(world, name)]).catch(() => undefined); },
|
|
109
181
|
async remove(name) { await docker(['rm', '-f', containerName(world, name)]).catch(() => undefined); },
|
|
110
182
|
};
|
|
111
183
|
}
|
|
112
184
|
|
|
113
185
|
/** The pool of a kind, for a World (`world`: the mark its containers carry). */
|
|
114
|
-
export function machinePool(kind: MachinePool['kind'], world = 'world'): MachinePool {
|
|
186
|
+
export function machinePool(kind: MachinePool['kind'], world = 'world', httpReplies: readonly MachineHttpReply[] = [], light: LightPoolOptions = {}, root?: string): MachinePool {
|
|
187
|
+
if (kind === 'light') return lightPool(world, root, light);
|
|
115
188
|
if (kind === 'docker') return dockerPool(world);
|
|
116
189
|
if (kind === 'fake') {
|
|
117
190
|
const asked: NonNullable<MachinePool['asked']> = [];
|
|
191
|
+
const running = new Map<string, { ports: number[]; paused: boolean }>();
|
|
118
192
|
return {
|
|
119
193
|
kind, asked,
|
|
120
|
-
async
|
|
194
|
+
async status() { return undefined; },
|
|
195
|
+
async ports(name) { return running.get(name)?.ports ?? []; },
|
|
196
|
+
async files() { throw new Error('the fake pool has no filesystem'); },
|
|
197
|
+
async run(spec) { running.set(spec.name, { ports: spec.ports ?? [], paused: false }); asked.push({ act: 'run', name: spec.name, spec }); return { ports: (spec.ports ?? []).map((internal) => ({ internal, host: 40_000 + internal })) }; },
|
|
121
198
|
async exec(name, spec) {
|
|
199
|
+
if (!running.has(name) || running.get(name)!.paused) throw new Error('the fake machine is not running');
|
|
122
200
|
asked.push({ act: 'exec', name, exec: spec });
|
|
123
201
|
// a fake process: its pid the count of processes asked, and it ends at once
|
|
124
202
|
const pid = asked.filter((a) => a.act === 'exec').length + 100;
|
|
125
203
|
return { pid, events: (async function* () { yield { exit: 0 }; })() };
|
|
126
204
|
},
|
|
127
|
-
async
|
|
128
|
-
|
|
205
|
+
async request(name, port, request) {
|
|
206
|
+
const machine = running.get(name);
|
|
207
|
+
if (!machine || machine.paused || !machine.ports.includes(port)) throw new Error('the fake machine port is not running');
|
|
208
|
+
const url = new URL(request.url), path = url.pathname + url.search;
|
|
209
|
+
const requestText = ['GET', 'HEAD'].includes(request.method) ? '' : await request.clone().text();
|
|
210
|
+
asked.push({ act: 'request', name, port, path, requestText });
|
|
211
|
+
const reply = httpReplies.find((r) => r.port === port && r.method === request.method && r.path === path && (r.requestText === undefined || r.requestText === requestText));
|
|
212
|
+
if (!reply) throw new Error('no fake HTTP reply matches');
|
|
213
|
+
return new Response(reply.body ?? null, { status: reply.status, headers: reply.headers });
|
|
214
|
+
},
|
|
215
|
+
async pause(name) { if (!running.has(name)) throw new Error('no fake machine'); running.get(name)!.paused = true; asked.push({ act: 'pause', name }); },
|
|
216
|
+
async resume(name) { if (!running.has(name)) throw new Error('no fake machine'); running.get(name)!.paused = false; asked.push({ act: 'resume', name }); },
|
|
217
|
+
async stop(name) { running.delete(name); asked.push({ act: 'stop', name }); },
|
|
218
|
+
async remove(name) { running.delete(name); asked.push({ act: 'remove', name }); },
|
|
129
219
|
};
|
|
130
220
|
}
|
|
131
221
|
return {
|
|
132
222
|
kind: 'none', async run() { return { ports: [] }; },
|
|
223
|
+
async status() { return undefined; },
|
|
224
|
+
async ports() { throw new Error('no guest ports'); },
|
|
225
|
+
async files() { throw new Error('no guest files'); },
|
|
133
226
|
async exec() { throw new Error('no machine runs in this World: its machine pool is none'); },
|
|
227
|
+
async request() { throw new Error('no machine runs in this World: its machine pool is none'); },
|
|
228
|
+
async pause() {},
|
|
229
|
+
async resume() {},
|
|
134
230
|
async stop() { /* nothing runs */ }, async remove() { /* nothing runs */ },
|
|
135
231
|
};
|
|
136
232
|
}
|
|
137
233
|
|
|
138
234
|
/** A vendor's pool in a World, one per World and kind for the life of the process (a fake pool keeps what it was asked). */
|
|
139
235
|
const pools = new Map<string, MachinePool>();
|
|
140
|
-
export function poolFor(service: string, root: string | undefined, kind: MachinePool['kind']): MachinePool {
|
|
141
|
-
const key = `${service}\0${root ?? ''}\0${kind}`;
|
|
236
|
+
export function poolFor(service: string, root: string | undefined, kind: MachinePool['kind'], httpReplies: readonly MachineHttpReply[] = [], light: LightPoolOptions = {}): MachinePool {
|
|
237
|
+
const key = `${service}\0${root ?? ''}\0${kind}${kind === 'light' ? JSON.stringify(light) : ''}`;
|
|
142
238
|
let pool = pools.get(key);
|
|
143
239
|
if (!pool) {
|
|
144
240
|
// the World's mark: a short digest of the vendor and the World's tree, so two Worlds' machines never share a name
|
|
145
241
|
let h = 0;
|
|
146
242
|
for (const ch of `${service}:${root ?? ''}`) h = (Math.imul(h, 31) + ch.charCodeAt(0)) >>> 0;
|
|
147
|
-
pool = machinePool(kind, h.toString(16).padStart(8, '0'));
|
|
243
|
+
pool = machinePool(kind, h.toString(16).padStart(8, '0'), httpReplies, light, root);
|
|
148
244
|
pools.set(key, pool);
|
|
149
245
|
}
|
|
150
246
|
return pool;
|
|
151
247
|
}
|
|
248
|
+
|
|
249
|
+
/** Re-enrolling a verification pool starts a fresh fake execution record. */
|
|
250
|
+
export function resetFakePool(service: string, root: string | undefined): void { pools.delete(`${service}\0${root ?? ''}\0fake`); }
|
|
251
|
+
|
|
252
|
+
/** Every machine mutation shares the tree's refusal seam. HTTP/RPC reads must be explicitly
|
|
253
|
+
* classified by the pack; a computed operation may execute arbitrary guest code. */
|
|
254
|
+
export function guardedMachinePool(pool: MachinePool, service: string, safeRead: boolean): MachinePool {
|
|
255
|
+
const guard = (): void => refuseReadOnlyWrite(service);
|
|
256
|
+
return {
|
|
257
|
+
kind: pool.kind, asked: pool.asked,
|
|
258
|
+
...(pool.admitRun ? { admitRun: (spec: MachineSpec) => { guard(); return pool.admitRun!(spec); } } : {}),
|
|
259
|
+
run: (spec) => { guard(); return pool.run(spec); },
|
|
260
|
+
exec: (name, spec) => { guard(); return pool.exec(name, spec); },
|
|
261
|
+
request: (name, port, request) => { if (!safeRead) guard(); return pool.request(name, port, request); },
|
|
262
|
+
status: (name) => pool.status(name),
|
|
263
|
+
ports: (name) => pool.ports(name),
|
|
264
|
+
files: async (name) => {
|
|
265
|
+
const files = await pool.files(name);
|
|
266
|
+
return { ...files, write: (path, bytes) => { guard(); return files.write(path, bytes); }, mkdir: (path) => { guard(); return files.mkdir(path); }, remove: (path) => { guard(); return files.remove(path); } };
|
|
267
|
+
},
|
|
268
|
+
pause: (name) => { guard(); return pool.pause(name); },
|
|
269
|
+
resume: (name) => { guard(); return pool.resume(name); },
|
|
270
|
+
stop: (name) => { guard(); return pool.stop(name); },
|
|
271
|
+
remove: (name) => { guard(); return pool.remove(name); },
|
|
272
|
+
};
|
|
273
|
+
}
|
package/src/managed-database.ts
CHANGED
|
@@ -52,47 +52,15 @@ export type Batch = { role?: string; settings?: Record<string, string>; readOnly
|
|
|
52
52
|
export interface ManagedDatabase {
|
|
53
53
|
/** Whether the World binds a database at all (a World with none refuses every batch). */
|
|
54
54
|
readonly bound: boolean;
|
|
55
|
+
/** The URL the World binds it at, what an application connects to (a credential door's database URL); none when
|
|
56
|
+
* unbound. */
|
|
57
|
+
readonly url?: string;
|
|
55
58
|
/** One transaction as one pipeline and one Sync; the answer is the batch's own statements', in order. */
|
|
56
59
|
batch(batch: Batch): Promise<StatementResult[]>;
|
|
57
60
|
/** A whole SQL script as one simple Query (a vendor's migrations, several statements to a file). */
|
|
58
61
|
script(text: string): Promise<void>;
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
/** The transaction-local setting every batch a handler makes carries: the World clock at the call (ctx.occurredAt). */
|
|
62
|
-
export const WORLD_CLOCK_SETTING = 'volter.world_clock';
|
|
63
|
-
|
|
64
|
-
/** The World clock inside the database, for the schemas a vendor lays (its migrations stamp rows with Postgres's
|
|
65
|
-
* `now()`, which reads the wall clock): `volter.now()`, the World clock the batch set or the wall clock where none is
|
|
66
|
-
* set, and every column default in `schemas` that reads `now()` or `CURRENT_TIMESTAMP` (alone or inside an expression,
|
|
67
|
-
* GoTrue's `timezone('utc', now())`) rewritten to read it. Run after the schemas are laid; running it again changes
|
|
68
|
-
* nothing. A trigger or function that calls `now()` itself is the pack's to redefine. */
|
|
69
|
-
export function worldClockSql(schemas: string[]): string {
|
|
70
|
-
const list = schemas.map((s) => `'${s.replace(/'/g, "''")}'`).join(', ');
|
|
71
|
-
return `CREATE SCHEMA IF NOT EXISTS volter;
|
|
72
|
-
CREATE OR REPLACE FUNCTION volter.now() RETURNS timestamptz LANGUAGE sql STABLE AS $fn$
|
|
73
|
-
SELECT coalesce(nullif(current_setting('${WORLD_CLOCK_SETTING}', true), '')::timestamptz, pg_catalog.now())
|
|
74
|
-
$fn$;
|
|
75
|
-
GRANT USAGE ON SCHEMA volter TO PUBLIC;
|
|
76
|
-
GRANT EXECUTE ON FUNCTION volter.now() TO PUBLIC;
|
|
77
|
-
DO $do$
|
|
78
|
-
DECLARE d record; rewritten text;
|
|
79
|
-
BEGIN
|
|
80
|
-
FOR d IN
|
|
81
|
-
SELECT n.nspname AS schema, c.relname AS tbl, a.attname AS col, pg_get_expr(ad.adbin, ad.adrelid) AS expr
|
|
82
|
-
FROM pg_attrdef ad
|
|
83
|
-
JOIN pg_class c ON c.oid = ad.adrelid
|
|
84
|
-
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
85
|
-
JOIN pg_attribute a ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
|
|
86
|
-
WHERE n.nspname IN (${list}) AND c.relkind IN ('r', 'p')
|
|
87
|
-
AND pg_get_expr(ad.adbin, ad.adrelid) ~* '(^|[^.[:alnum:]_])(now\\(\\)|current_timestamp)'
|
|
88
|
-
LOOP
|
|
89
|
-
rewritten := regexp_replace(d.expr, '(^|[^.[:alnum:]_])(now\\(\\)|current_timestamp)', '\\1volter.now()', 'gi');
|
|
90
|
-
EXECUTE format('ALTER TABLE %I.%I ALTER COLUMN %I SET DEFAULT %s', d.schema, d.tbl, d.col, rewritten);
|
|
91
|
-
END LOOP;
|
|
92
|
-
END
|
|
93
|
-
$do$;`;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
64
|
/** Quote an identifier the way Postgres's quote_ident does for a name that needs it. */
|
|
97
65
|
export function quoteIdent(name: string): string {
|
|
98
66
|
return `"${name.replace(/"/g, '""')}"`;
|
|
@@ -252,14 +220,13 @@ function settingsStatement(settings: Record<string, string>): Statement | null {
|
|
|
252
220
|
}
|
|
253
221
|
|
|
254
222
|
/** The World's Postgres at `url` (the runtime's binding, never the environment); with `readOnly`, every transaction is
|
|
255
|
-
* READ ONLY whatever the batch asks (a read-only twin or request: Postgres itself refuses a write, 25006)
|
|
256
|
-
|
|
257
|
-
export function managedDatabase(url: string, options: { readOnly?: boolean; clock?: string } = {}): ManagedDatabase {
|
|
223
|
+
* READ ONLY whatever the batch asks (a read-only twin or request: Postgres itself refuses a write, 25006). */
|
|
224
|
+
export function managedDatabase(url: string, options: { readOnly?: boolean } = {}): ManagedDatabase {
|
|
258
225
|
return {
|
|
259
226
|
bound: true,
|
|
227
|
+
url,
|
|
260
228
|
async batch(batch) {
|
|
261
|
-
|
|
262
|
-
const set = settingsStatement({ ...(options.clock !== undefined ? { [WORLD_CLOCK_SETTING]: options.clock } : {}), ...(batch.settings ?? {}) });
|
|
229
|
+
const set = settingsStatement(batch.settings ?? {});
|
|
263
230
|
const head: Statement[] = [
|
|
264
231
|
{ text: batch.readOnly || options.readOnly ? 'BEGIN READ ONLY' : 'BEGIN' },
|
|
265
232
|
...(batch.role === undefined ? [] : [{ text: `SET LOCAL ROLE ${quoteIdent(batch.role)}` }]),
|
|
@@ -283,6 +250,47 @@ export function managedDatabase(url: string, options: { readOnly?: boolean; cloc
|
|
|
283
250
|
};
|
|
284
251
|
}
|
|
285
252
|
|
|
253
|
+
/** One connection of an application's own to the World's Postgres (a journey's SQL steps: twin-standard
|
|
254
|
+
* walk/journey-kit.ts), its statements sent as its client sends them: a text alone as one simple Query (a migration
|
|
255
|
+
* file, a `BEGIN`), a text with values as one extended-protocol statement, the values bind parameters. A transaction it
|
|
256
|
+
* opens stays open across its statements until it ends it, as on the application's own connection. Each statement's
|
|
257
|
+
* answer is its results in Postgres's text format, untyped. */
|
|
258
|
+
export type DatabaseSession = { query(text: string, values?: ReadonlyArray<string | null>): Promise<StatementResult[]>; end(): Promise<void> };
|
|
259
|
+
|
|
260
|
+
type PgResult = { command?: string | null; rowCount?: number | null; fields?: Array<{ name: string }>; rows?: Array<Array<string | null>> };
|
|
261
|
+
|
|
262
|
+
/** The World's Postgres at `url` as one connection held until `end()`. */
|
|
263
|
+
export async function databaseSession(url: string): Promise<DatabaseSession> {
|
|
264
|
+
const { Pool } = await loadDriver();
|
|
265
|
+
const pool = new Pool({ connectionString: url, max: 1, idleTimeoutMillis: 0, connectionTimeoutMillis: 10_000 });
|
|
266
|
+
pool.on('error', () => {});
|
|
267
|
+
let client: PgClient;
|
|
268
|
+
try { client = await pool.connect(); } catch (error) {
|
|
269
|
+
await pool.end().catch(() => {});
|
|
270
|
+
throw new EngineUnavailableError(`the World's Postgres is not answering: ${(error as Error).message}`);
|
|
271
|
+
}
|
|
272
|
+
// every value as the wire carries it: a row an array of texts, no type parsed
|
|
273
|
+
const raw = { getTypeParser: () => (value: string) => value };
|
|
274
|
+
return {
|
|
275
|
+
async query(text, values) {
|
|
276
|
+
try {
|
|
277
|
+
const answer = (await (client.query({ text, ...(values === undefined ? {} : { values }), rowMode: 'array', types: raw }) as Promise<PgResult | PgResult[]>));
|
|
278
|
+
return (Array.isArray(answer) ? answer : [answer]).map((r) => ({
|
|
279
|
+
fields: (r.fields ?? []).map((f) => f.name),
|
|
280
|
+
rows: r.rows ?? [],
|
|
281
|
+
command: [r.command ?? '', r.command === 'INSERT' ? '0' : '', r.rowCount ?? ''].filter((part) => part !== '').join(' '),
|
|
282
|
+
}));
|
|
283
|
+
} catch (error) {
|
|
284
|
+
throw asDatabaseError(error as Error);
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
async end() {
|
|
288
|
+
client.release();
|
|
289
|
+
await pool.end().catch(() => {});
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
286
294
|
/** A handler's database when the World binds none: every use is the refusal naming what to add. */
|
|
287
295
|
export function unboundDatabase(service: string): ManagedDatabase {
|
|
288
296
|
const refuse = (): never => {
|
package/src/multipart.ts
CHANGED
|
@@ -1,51 +1,126 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// supabase-js's Blob upload sends. Dependency-free and side-effect free (a browser bundle imports it).
|
|
1
|
+
// The one MIME multipart reader for requests and published examples. Dependency-free, side-effect-free and
|
|
2
|
+
// byte-preserving: a browser bundle imports it, and empty field names and binary uploads must survive reading.
|
|
4
3
|
|
|
5
|
-
/** One part: its field name (possibly empty), its filename when it is a file, its media type as sent, its bytes. */
|
|
6
4
|
export type MultipartPart = { name: string; filename: string | null; type: string | null; body: Uint8Array };
|
|
5
|
+
export type MultipartFramedPart = MultipartPart & {
|
|
6
|
+
headers: Uint8Array; leading: Uint8Array; separator: Uint8Array; trailing: Uint8Array;
|
|
7
|
+
};
|
|
8
|
+
export type MultipartEnvelope = {
|
|
9
|
+
complete: boolean; preamble: Uint8Array; epilogue: Uint8Array;
|
|
10
|
+
delimiters: Uint8Array[]; parts: MultipartFramedPart[];
|
|
11
|
+
};
|
|
7
12
|
|
|
8
|
-
/** The boundary
|
|
13
|
+
/** The boundary named by any multipart media type. */
|
|
9
14
|
export function multipartBoundary(contentType: string | null | undefined): string | null {
|
|
10
|
-
if (!contentType || !/^multipart
|
|
11
|
-
const hit =
|
|
15
|
+
if (!contentType || !/^multipart\//i.test(contentType)) return null;
|
|
16
|
+
const hit = /\bboundary\s*=\s*(?:"([^"]+)"|([^;\s]+))/i.exec(contentType);
|
|
12
17
|
return hit ? (hit[1] ?? hit[2]!) : null;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
type Line = { at: number; end: number; after: number };
|
|
21
|
+
function byteLines(bytes: Uint8Array): Line[] {
|
|
22
|
+
const lines: Line[] = [];
|
|
23
|
+
for (let at = 0; at < bytes.length;) {
|
|
24
|
+
let end = at;
|
|
25
|
+
while (end < bytes.length && bytes[end] !== 13 && bytes[end] !== 10) end++;
|
|
26
|
+
const after = end === bytes.length ? end : end + (bytes[end] === 13 && bytes[end + 1] === 10 ? 2 : 1);
|
|
27
|
+
lines.push({ at, end, after });
|
|
28
|
+
at = after;
|
|
19
29
|
}
|
|
20
|
-
return
|
|
30
|
+
return lines;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
|
-
/**
|
|
24
|
-
export function
|
|
25
|
-
const
|
|
33
|
+
/** Used both by the byte reader and by consumers guarding placeholder spans within a part. */
|
|
34
|
+
export function multipartDelimiterPattern(boundary: string): string {
|
|
35
|
+
const literal = boundary.replace(/[.*+?^$(){}|[\]\\]/g, '\\$&');
|
|
36
|
+
// Include malformed whitespace-before-close lines in the guard: they cannot be hidden in a value either.
|
|
37
|
+
return '(?:^|(?<=[\\r\\n]))--' + literal + '(?:--|[ \\t]+--)?[ \\t]*(?=\\r\\n|\\n|\\r|$)';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Infer only a boundary with a corresponding closing line; Content-Type remains authoritative when supplied. */
|
|
41
|
+
export function inferMultipartBoundary(bytes: Uint8Array): string | null {
|
|
42
|
+
const decoder = new TextDecoder();
|
|
43
|
+
const lines = byteLines(bytes).map(({ at, end }) => decoder.decode(bytes.subarray(at, end)));
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
if (!line.startsWith('--')) continue;
|
|
46
|
+
const boundary = line.slice(2).replace(/[ \t]+$/, '');
|
|
47
|
+
if (boundary && lines.some((closing) => closing.replace(/[ \t]+$/, '') === '--' + boundary + '--')) return boundary;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Read MIME fields once, unfolding continuations for interpretation while retaining raw bytes in the envelope. */
|
|
53
|
+
function mimeHeaders(raw: Uint8Array): Map<string, string> | undefined {
|
|
54
|
+
const fields = new Map<string, string>();
|
|
55
|
+
let previous: string | undefined;
|
|
56
|
+
for (const line of new TextDecoder().decode(raw).split(/\r\n|\n|\r/)) {
|
|
57
|
+
if (!line) continue;
|
|
58
|
+
if (/^[ \t]/.test(line)) {
|
|
59
|
+
if (!previous) return undefined;
|
|
60
|
+
fields.set(previous, fields.get(previous)! + ' ' + line.trim());
|
|
61
|
+
} else {
|
|
62
|
+
const field = /^([^\s:]+):[ \t]*(.*)$/.exec(line);
|
|
63
|
+
if (!field) return undefined;
|
|
64
|
+
previous = field[1]!.toLowerCase();
|
|
65
|
+
fields.set(previous, field[2]!.trim());
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return fields;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Raw MIME framing and interpreted parts. A malformed tail ends reading and leaves `complete` false. */
|
|
72
|
+
export function multipartEnvelope(bytes: Uint8Array, boundary: string): MultipartEnvelope {
|
|
73
|
+
const empty = new Uint8Array();
|
|
74
|
+
const result: MultipartEnvelope = { complete: false, preamble: bytes.slice(), epilogue: empty, delimiters: [], parts: [] };
|
|
75
|
+
const lines = byteLines(bytes);
|
|
26
76
|
const decoder = new TextDecoder();
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
77
|
+
const prefix = '--' + boundary;
|
|
78
|
+
const hits: Array<Line & { closing: boolean; valid: boolean }> = [];
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
const text = decoder.decode(bytes.subarray(line.at, line.end));
|
|
81
|
+
if (!text.startsWith(prefix)) continue;
|
|
82
|
+
const suffix = text.slice(prefix.length);
|
|
83
|
+
const closing = /^--[ \t]*$/.test(suffix);
|
|
84
|
+
const valid = closing || /^[ \t]*$/.test(suffix);
|
|
85
|
+
if (!valid && !/^[ \t]+--[ \t]*$/.test(suffix)) continue;
|
|
86
|
+
hits.push({ ...line, closing, valid });
|
|
87
|
+
if (closing || !valid) break;
|
|
88
|
+
}
|
|
89
|
+
if (!hits.length) return result;
|
|
90
|
+
result.preamble = bytes.slice(0, hits[0]!.at);
|
|
91
|
+
result.delimiters = hits.map(({ at, end }) => bytes.slice(at, end));
|
|
92
|
+
for (let i = 0; i < hits.length - 1; i++) {
|
|
93
|
+
const current = hits[i]!;
|
|
94
|
+
const next = hits[i + 1]!;
|
|
95
|
+
if (!current.valid || current.closing || current.after === current.end || !next.valid) return result;
|
|
96
|
+
const headerLines = lines.filter((line) => line.at >= current.after && line.at < next.at);
|
|
97
|
+
const blank = headerLines.find((line) => line.at === line.end);
|
|
98
|
+
if (!blank) return result;
|
|
99
|
+
const previous = headerLines.find((line) => line.after === blank.at);
|
|
100
|
+
const headersEnd = previous?.end ?? blank.at;
|
|
101
|
+
const headers = bytes.slice(current.after, headersEnd);
|
|
102
|
+
const fields = mimeHeaders(headers);
|
|
103
|
+
if (!fields) return result;
|
|
104
|
+
const beforeNext = lines.find((line) => line.after === next.at);
|
|
105
|
+
// The line break immediately before a delimiter belongs to framing, never to the uploaded bytes.
|
|
106
|
+
const bodyEnd = Math.max(blank.after, beforeNext?.end ?? next.at);
|
|
107
|
+
const disposition = fields.get('content-disposition') ?? '';
|
|
108
|
+
result.parts.push({
|
|
42
109
|
name: /\bname="([^"]*)"/i.exec(disposition)?.[1] ?? '',
|
|
43
110
|
filename: /\bfilename="([^"]*)"/i.exec(disposition)?.[1] ?? null,
|
|
44
|
-
type:
|
|
45
|
-
|
|
46
|
-
|
|
111
|
+
type: fields.get('content-type')?.trim() ?? null,
|
|
112
|
+
body: bytes.slice(blank.after, bodyEnd), headers,
|
|
113
|
+
leading: bytes.slice(current.end, current.after), separator: bytes.slice(headersEnd, blank.after),
|
|
114
|
+
trailing: bytes.slice(bodyEnd, next.at),
|
|
47
115
|
});
|
|
48
|
-
at = next;
|
|
49
116
|
}
|
|
50
|
-
|
|
117
|
+
const last = hits.at(-1)!;
|
|
118
|
+
result.complete = last.valid && last.closing;
|
|
119
|
+
if (result.complete) result.epilogue = bytes.slice(last.end);
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The request's parts, in order, using the same framing reader as wire comparison. */
|
|
124
|
+
export function multipartParts(bytes: Uint8Array, boundary: string): MultipartPart[] {
|
|
125
|
+
return multipartEnvelope(bytes, boundary).parts.map(({ name, filename, type, body }) => ({ name, filename, type, body }));
|
|
51
126
|
}
|
package/src/observe.ts
CHANGED
|
@@ -42,7 +42,7 @@ export async function collectObservations<T>(fn: () => Promise<T>): Promise<{ va
|
|
|
42
42
|
|
|
43
43
|
/** A type a batch listed in full: all of it, or (a type under a parent) the subjects whose `field` names one of the
|
|
44
44
|
* parents listed `within`. */
|
|
45
|
-
export type Completed = string | { type: string; field: string; within: string[] };
|
|
45
|
+
export type Completed = string | { type: string; field: string; within: string[] } | { type: string; ids: string[] };
|
|
46
46
|
|
|
47
47
|
/** Fold a collected batch: one `observeResources` per service, `complete` applied to each. */
|
|
48
48
|
export function foldObservations(observations: Observation[], opts: { root?: string; at?: string; complete?: Completed[]; batch?: string } = {}): ObserveReport {
|
|
@@ -134,10 +134,13 @@ function foldResources(service: string, resources: ObservedResource[], opts: { r
|
|
|
134
134
|
}
|
|
135
135
|
for (const c of opts.complete ?? []) {
|
|
136
136
|
const type = typeof c === 'string' ? c : c.type;
|
|
137
|
-
const within = typeof c === 'string' ? undefined : new Set(c.within);
|
|
137
|
+
const within = typeof c === 'string' || !('within' in c) ? undefined : new Set(c.within);
|
|
138
|
+
// subjects read one by one (a refresh of the known): only those read are whole, an unseen one is gone
|
|
139
|
+
const ids = typeof c !== 'string' && 'ids' in c ? new Set(c.ids) : undefined;
|
|
138
140
|
for (const s of tree.values()) {
|
|
139
141
|
if (s.type !== type || seen.has(`${s.type}:${s.id}`) || s.fields.deleted === true) continue;
|
|
140
|
-
if (within && typeof c !== 'string' && !within.has(String(s.fields[c.field]))) continue;
|
|
142
|
+
if (within && typeof c !== 'string' && 'field' in c && !within.has(String(s.fields[c.field]))) continue;
|
|
143
|
+
if (ids && !ids.has(s.id)) continue;
|
|
141
144
|
if (append({ type: s.type, id: s.id }, { deleted: true })) report.removed += 1;
|
|
142
145
|
}
|
|
143
146
|
}
|