@volter/world-core 3.0.155 → 3.0.157
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/attach.cjs +2 -0
- package/capability-decision.cjs +6 -4
- package/dist/attach.cjs +2 -0
- package/dist/capability-decision.cjs +6 -4
- package/dist/generated/pack-facts.json +204 -4
- package/dist/inject.cjs +150 -86
- package/dist/network-policy.cjs +11 -1
- package/dist/network-policy.d.cts +1 -0
- package/dist/pack-facts.d.cts +3 -0
- package/dist/required-credential-transport.cjs +29 -0
- package/dist/required-credential-transport.d.cts +3 -0
- package/dist/src/actions.d.ts +15 -0
- package/dist/src/actions.js +82 -21
- package/dist/src/ancestry.d.ts +5 -4
- package/dist/src/ancestry.js +53 -28
- package/dist/src/blob-store.d.ts +16 -0
- package/dist/src/blob-store.js +76 -1
- package/dist/src/changeset.js +1 -1
- package/dist/src/clickhouse/compression.d.ts +28 -0
- package/dist/src/clickhouse/compression.js +281 -0
- package/dist/src/clickhouse/index.d.ts +1 -0
- package/dist/src/clickhouse/index.js +1 -0
- package/dist/src/derived-core.d.ts +84 -7
- package/dist/src/derived-core.js +336 -61
- package/dist/src/derived-real.js +97 -15
- package/dist/src/derived.d.ts +5 -0
- package/dist/src/derived.js +3 -0
- package/dist/src/executor.d.ts +7 -0
- package/dist/src/executor.js +15 -1
- package/dist/src/fork.js +1 -1
- package/dist/src/git/lfs.d.ts +13 -3
- package/dist/src/git/lfs.js +41 -9
- package/dist/src/grpc-wire.js +2 -1
- package/dist/src/h2.js +63 -10
- package/dist/src/head.d.ts +5 -0
- package/dist/src/head.js +15 -0
- package/dist/src/history.d.ts +1 -1
- package/dist/src/history.js +7 -7
- package/dist/src/index.d.ts +5 -1
- package/dist/src/index.js +6 -1
- package/dist/src/live/ingest.js +9 -13
- package/dist/src/live/rtmp.js +7 -5
- package/dist/src/live/segmenter.js +7 -1
- package/dist/src/log.d.ts +23 -3
- package/dist/src/log.js +160 -37
- package/dist/src/machines.js +5 -1
- package/dist/src/managed-go-programs.d.ts +55 -0
- package/dist/src/managed-go-programs.js +133 -0
- package/dist/src/managed-original-inputs.d.ts +101 -0
- package/dist/src/managed-original-inputs.js +125 -0
- package/dist/src/mongodb/client.d.ts +8 -0
- package/dist/src/mongodb/client.js +27 -0
- package/dist/src/mongodb/index.d.ts +2 -0
- package/dist/src/mongodb/index.js +5 -0
- package/dist/src/mongodb/stream.d.ts +97 -0
- package/dist/src/mongodb/stream.js +206 -0
- package/dist/src/observe.js +1 -1
- package/dist/src/pack-fetch.d.ts +6 -1
- package/dist/src/pack-fetch.js +69 -14
- package/dist/src/pack-paths.d.ts +2 -1
- package/dist/src/pack-paths.js +6 -1
- package/dist/src/packRegistry.d.ts +10 -14
- package/dist/src/packRegistry.js +2 -13
- package/dist/src/protocol.d.ts +13 -0
- package/dist/src/protocol.js +13 -0
- package/dist/src/redis/engine.d.ts +10 -4
- package/dist/src/redis/engine.js +103 -32
- package/dist/src/redis/member-storage.js +20 -8
- package/dist/src/redis/service-times.d.ts +25 -0
- package/dist/src/redis/service-times.js +87 -0
- package/dist/src/redis/stream.d.ts +2 -1
- package/dist/src/redis/stream.js +109 -20
- package/dist/src/resource-blob.d.ts +4 -0
- package/dist/src/resource-blob.js +8 -1
- package/dist/src/runtime-state.d.ts +11 -0
- package/dist/src/runtime-state.js +13 -0
- package/dist/src/runtime.d.ts +7 -5
- package/dist/src/runtime.js +7 -4
- package/dist/src/serve-http.d.ts +2 -0
- package/dist/src/serve-http.js +73 -6
- package/dist/src/serve.d.ts +10 -0
- package/dist/src/serve.js +43 -3
- package/dist/src/sockets.d.ts +4 -1
- package/dist/src/sockets.js +59 -6
- package/dist/src/storage.d.ts +2 -1
- package/dist/src/storage.js +6 -3
- package/dist/src/vendor-call.js +58 -0
- package/dist/src/world-boot.d.ts +1 -0
- package/dist/src/world-boot.js +6 -0
- package/dist/src/world-clock.js +6 -2
- package/dist/src/world-store.d.ts +122 -7
- package/dist/src/world-store.js +959 -103
- package/dist/src/xml.d.ts +30 -0
- package/dist/src/xml.js +74 -0
- package/generated/pack-facts.json +204 -4
- package/inject.cjs +150 -86
- package/network-policy.cjs +11 -1
- package/network-policy.d.cts +1 -0
- package/pack-facts.d.cts +3 -0
- package/package.json +27 -1
- package/required-credential-transport.cjs +29 -0
- package/required-credential-transport.d.cts +3 -0
- package/src/actions.ts +78 -21
- package/src/ancestry.ts +46 -24
- package/src/blob-store.ts +59 -1
- package/src/changeset.ts +1 -1
- package/src/clickhouse/compression.ts +172 -0
- package/src/clickhouse/index.ts +2 -0
- package/src/derived-core.ts +356 -61
- package/src/derived-real.ts +80 -14
- package/src/derived.ts +9 -1
- package/src/executor.ts +17 -3
- package/src/fork.ts +1 -1
- package/src/git/lfs.ts +40 -9
- package/src/grpc-wire.ts +2 -1
- package/src/h2.ts +46 -5
- package/src/head.ts +15 -0
- package/src/history.ts +7 -7
- package/src/index.ts +7 -1
- package/src/live/ingest.ts +7 -3
- package/src/live/rtmp.ts +7 -5
- package/src/live/segmenter.ts +7 -1
- package/src/log.ts +147 -32
- package/src/machines.ts +5 -1
- package/src/managed-go-programs.ts +146 -0
- package/src/managed-original-inputs.ts +163 -0
- package/src/mongodb/client.ts +27 -0
- package/src/mongodb/index.ts +5 -0
- package/src/mongodb/stream.ts +223 -0
- package/src/observe.ts +1 -1
- package/src/pack-fetch.ts +66 -16
- package/src/pack-paths.ts +5 -1
- package/src/packRegistry.ts +3 -15
- package/src/protocol.ts +13 -0
- package/src/redis/engine.ts +100 -57
- package/src/redis/member-storage.ts +17 -9
- package/src/redis/service-times.ts +81 -0
- package/src/redis/stream.ts +66 -14
- package/src/resource-blob.ts +9 -1
- package/src/runtime-state.ts +14 -0
- package/src/runtime.ts +10 -3
- package/src/serve-http.ts +65 -6
- package/src/serve.ts +45 -3
- package/src/sockets.ts +39 -8
- package/src/storage.ts +5 -3
- package/src/vendor-call.ts +54 -0
- package/src/world-boot.ts +6 -0
- package/src/world-clock.ts +6 -2
- package/src/world-store.ts +768 -91
- package/src/xml.ts +71 -0
package/src/runtime.ts
CHANGED
|
@@ -157,10 +157,13 @@ export {
|
|
|
157
157
|
setActiveBlobStore,
|
|
158
158
|
withBlobStore,
|
|
159
159
|
readBlobRange,
|
|
160
|
+
streamChunks,
|
|
161
|
+
writeBlobStream,
|
|
160
162
|
} from './blob-store.ts';
|
|
161
|
-
export type { BlobStore } from './blob-store.ts';
|
|
163
|
+
export type { BlobStore, BlobWritten } from './blob-store.ts';
|
|
162
164
|
export {
|
|
163
165
|
applyTwinWrite,
|
|
166
|
+
applyTwinWrites,
|
|
164
167
|
applyTwinWriteAtomic,
|
|
165
168
|
createTwinServer,
|
|
166
169
|
CALLER_HEADER,
|
|
@@ -297,6 +300,8 @@ export { installedPackageDir, installedVendorPacks, packVendor, packRepository,
|
|
|
297
300
|
export * as git from './git/index.ts';
|
|
298
301
|
// S3's wire (contract "S3's wire is a kernel library too"): its XML, CORS, lifecycle, delete batches, ETags, body bytes.
|
|
299
302
|
export * as s3 from './s3/wire.ts';
|
|
303
|
+
// Generic strict XML grammar; vendor interpretation remains in its consumer.
|
|
304
|
+
export * as xml from './xml.ts';
|
|
300
305
|
|
|
301
306
|
// The push arm: the kernel's transaction over a pack's one `perform<Name>Action` (contract section of that name).
|
|
302
307
|
|
|
@@ -323,7 +328,7 @@ export { readTreeMap, readParentTreeMap, treeStamp, treeChangesSince, originEntr
|
|
|
323
328
|
|
|
324
329
|
export { referenceField, registerReferences, packReferences, resolveReferences, type ReferenceDeclaration } from './references.ts';
|
|
325
330
|
// the HTTP server seam: the one place the serve path meets the runtime (Bun or Node)
|
|
326
|
-
export { serveHttp, serveStream, connectWebSocket, nodeBuiltin, WORLD_BOOT_PATH, type HttpServer, type ServeHttpOptions, type HttpHandler } from './serve-http.ts';
|
|
331
|
+
export { serveHttp, serveStream, connectWebSocket, nodeBuiltin, servedHandlerOf, WORLD_BOOT_PATH, type HttpServer, type ServeHttpOptions, type HttpHandler } from './serve-http.ts';
|
|
327
332
|
// two runtime-neutral helpers for a serve path: a file as a Response, a page's client bundle
|
|
328
333
|
export { fileResponse, contentTypeOf } from './file-response.ts';
|
|
329
334
|
export { bundleClient, filePathOf } from './client-bundle.ts';
|
|
@@ -335,7 +340,7 @@ export { corsHeaders, withCors, type CorsDecl } from './cors.ts';
|
|
|
335
340
|
export { setInProcessTwins, vendorFetch, VendorUnreachableError, type InProcessTwin } from './vendor-call.ts';
|
|
336
341
|
export { closeManagedDatabases, databaseSession, EngineDatabaseError, EngineUnavailableError, managedDatabase, quoteIdent, useDatabaseDriver, type DatabaseDriver, type DatabaseSession } from './managed-database.ts';
|
|
337
342
|
export type { Batch as EngineBatch, ManagedDatabase, Statement as EngineStatement, StatementResult as EngineStatementResult } from './managed-database.ts';
|
|
338
|
-
export { readResourceBlob, readResourceBlobRange, removeResourceBlob, resourceBlobFile, resourceBlobKey, resourceBlobSize, resourceChain, writeResourceBlob } from './resource-blob.ts';
|
|
343
|
+
export { readResourceBlob, readResourceBlobRange, removeResourceBlob, resourceBlobFile, resourceBlobKey, resourceBlobSize, resourceChain, writeResourceBlob, writeResourceBlobStream } from './resource-blob.ts';
|
|
339
344
|
|
|
340
345
|
export { assertStateRemovable, checkParent, stateGeneration, withAncestryLock, withHistoryLock, withHistoryLocks, withStateLock, withStateRemoval } from './ancestry.ts';
|
|
341
346
|
|
|
@@ -347,3 +352,5 @@ export type { MachinePool, MachineSpec, MachineProcess, MachineStatus, MachineFi
|
|
|
347
352
|
|
|
348
353
|
|
|
349
354
|
export { boardFrameKey } from './board-keys.ts';
|
|
355
|
+
|
|
356
|
+
export * as managedOriginalInputs from './managed-original-inputs.ts';
|
package/src/serve-http.ts
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
// as a standard Request and the handler's Response written back. Async, because binding a port is
|
|
5
5
|
// asynchronous on Node — Bun's synchronous bind is the special case.
|
|
6
6
|
//
|
|
7
|
-
// Nothing here runs at module scope, and `node:http` is reached through `process.getBuiltinModule`
|
|
7
|
+
// Nothing here runs at module scope (the served-handler registry is made on first use), and `node:http` is reached through `process.getBuiltinModule`
|
|
8
8
|
// rather than a static import: this module rides into browser client bundles through the kernel's
|
|
9
9
|
// entrypoint, where a static `node:` import is the 2026-09-06 class of break.
|
|
10
10
|
|
|
11
11
|
import type { TwinStream } from './twin-fetch.ts';
|
|
12
|
+
import { worldBootAnswer } from './world-boot.ts';
|
|
12
13
|
|
|
13
14
|
/** One socket library at the host seam, shared by inbound upgrades and authenticated outbound relays. */
|
|
14
15
|
const socketLibrary = () => import('ws');
|
|
@@ -64,6 +65,24 @@ const bunRuntime = (): BunLike | undefined => { const b = (globalThis as { Bun?:
|
|
|
64
65
|
/** The path a World asks a service it booted, when that service's port answers, which boot it belongs to. */
|
|
65
66
|
export const WORLD_BOOT_PATH = '/__volter/world-boot';
|
|
66
67
|
|
|
68
|
+
/** Each listening server's own handler, by its port, as the seam serves it (the boot door answered, nothing journaled):
|
|
69
|
+
* the World's co-located host warms a twin in-process through it (world-runtime host.ts warmInProcess), so its warm-up is
|
|
70
|
+
* never a request on the wire nor in any journal. A server leaves it when it stops. Kept on globalThis under a registered
|
|
71
|
+
* symbol, as the reference registry is (references.ts), so a pack that resolved its own copy of this module shares it. */
|
|
72
|
+
const servedHandlers = (): Map<number, HttpHandler> =>
|
|
73
|
+
((globalThis as Record<symbol, unknown>)[Symbol.for('volter.servedHandlers')] ??= new Map<number, HttpHandler>()) as Map<number, HttpHandler>;
|
|
74
|
+
|
|
75
|
+
/** The handler serving `port` in this process, when a server made through the seam listens there. */
|
|
76
|
+
export function servedHandlerOf(port: number): HttpHandler | undefined { return servedHandlers().get(port); }
|
|
77
|
+
|
|
78
|
+
/** A server's handler kept by its port until the server stops. */
|
|
79
|
+
function keepHandler(server: HttpServer, handler: HttpHandler): HttpServer {
|
|
80
|
+
const port = server.port;
|
|
81
|
+
if (!port) return server;
|
|
82
|
+
servedHandlers().set(port, handler);
|
|
83
|
+
return { ...server, stop: async (closeActive?: boolean) => { if (servedHandlers().get(port) === handler) servedHandlers().delete(port); await server.stop(closeActive); } };
|
|
84
|
+
}
|
|
85
|
+
|
|
67
86
|
export async function serveHttp(options: ServeHttpOptions): Promise<HttpServer> {
|
|
68
87
|
// Inside a World every server made through the seam answers which boot it belongs to, so the World can tell its own
|
|
69
88
|
// twin from a leftover World's that holds the same port. Outside a World nothing changes.
|
|
@@ -76,7 +95,7 @@ export async function serveHttp(options: ServeHttpOptions): Promise<HttpServer>
|
|
|
76
95
|
const worldBoot = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.VOLTER_WORLD_BOOT_ID;
|
|
77
96
|
if (worldBoot) {
|
|
78
97
|
const served = options.fetch;
|
|
79
|
-
options = { ...options, fetch: (request) => (new URL(request.url).pathname === WORLD_BOOT_PATH ?
|
|
98
|
+
options = { ...options, fetch: (request) => (new URL(request.url).pathname === WORLD_BOOT_PATH ? worldBootAnswer(worldBoot) : served(request)) };
|
|
80
99
|
}
|
|
81
100
|
// loopback by default: an unnamed interface is never every interface
|
|
82
101
|
const hostname = options.hostname ?? '127.0.0.1';
|
|
@@ -86,6 +105,28 @@ export async function serveHttp(options: ServeHttpOptions): Promise<HttpServer>
|
|
|
86
105
|
// the global `Bun.serve` — the request journal's wrap of it (serve.ts) applies to packs and to
|
|
87
106
|
// this seam alike, so the seam never decorates twice on Bun
|
|
88
107
|
const upgrade = options.upgrade;
|
|
108
|
+
// the sockets open now, by the seam's own count: Bun keeps a socket its server closed (a refused key's 1008, from
|
|
109
|
+
// `open` or later) in `pendingWebSockets` after its close handler ran, and `stop()`'s promise then never settles
|
|
110
|
+
// (measured on Bun 1.3.11, macOS arm64, alone: a Bun.serve whose open() calls ws.close(1008) leaves
|
|
111
|
+
// pendingWebSockets at 1 with its close handler called once, and an awaited stop(true) had not settled after 3 s;
|
|
112
|
+
// unawaited, the listener closed and the process used 4 ms of CPU over the next 2 s; a client-initiated close
|
|
113
|
+
// stops in 0 ms). Conform's life hung on it after OpenAI's keyless Realtime step.
|
|
114
|
+
// and the requests whose handlers have not answered: a stop that drops connections (closeActive) aborts each one's
|
|
115
|
+
// signal, and Bun's stop still waits for the handler to settle, which a handler that never answers never does
|
|
116
|
+
// (measured on Bun 1.3.11, macOS arm64, alone: a Bun.serve whose fetch never resolves, its request's signal aborted
|
|
117
|
+
// by stop(true), pendingRequests 1, the stop unsettled after 3 s). Conform's client check hung on a pack served with
|
|
118
|
+
// a scenario that answered nothing. Node's path below reports such a handler as cleanup and resolves; so does this one.
|
|
119
|
+
const live = new Set<unknown>();
|
|
120
|
+
const inflight = new Set<Request>();
|
|
121
|
+
const served = options.fetch;
|
|
122
|
+
const tracked = (request: Request, ...rest: unknown[]): Response | Promise<Response> => {
|
|
123
|
+
inflight.add(request);
|
|
124
|
+
let answer: Response | Promise<Response>;
|
|
125
|
+
try { answer = (served as (r: Request, ...x: unknown[]) => Response | Promise<Response>)(request, ...rest); } catch (error) { inflight.delete(request); throw error; }
|
|
126
|
+
if (!(answer instanceof Promise)) { inflight.delete(request); return answer; }
|
|
127
|
+
return answer.finally(() => { inflight.delete(request); });
|
|
128
|
+
};
|
|
129
|
+
options = { ...options, fetch: tracked };
|
|
89
130
|
const server = bun.serve({ ...options, hostname, port: options.port ?? 0,
|
|
90
131
|
...(upgrade ? {
|
|
91
132
|
fetch: (request: Request, server: { upgrade: (request: Request, options: { data: { request: Request } }) => boolean }) => {
|
|
@@ -95,16 +136,34 @@ export async function serveHttp(options: ServeHttpOptions): Promise<HttpServer>
|
|
|
95
136
|
return options.fetch(request);
|
|
96
137
|
},
|
|
97
138
|
websocket: {
|
|
98
|
-
open: (peer: WebSocketPeer & { data: { request: Request } }) => upgrade.open(peer, peer.data.request),
|
|
139
|
+
open: (peer: WebSocketPeer & { data: { request: Request } }) => { live.add(peer); return upgrade.open(peer, peer.data.request); },
|
|
99
140
|
message: (peer: WebSocketPeer, data: string | Uint8Array) => upgrade.message(peer, data),
|
|
100
|
-
close: upgrade.close,
|
|
141
|
+
close: (peer: WebSocketPeer, ...rest: unknown[]) => { live.delete(peer); return (upgrade.close as (p: WebSocketPeer, ...r: unknown[]) => unknown)(peer, ...rest); },
|
|
101
142
|
},
|
|
102
143
|
} : {}),
|
|
103
144
|
});
|
|
104
145
|
const port = server.port ?? 0;
|
|
105
|
-
return { hostname, port, url: server.url instanceof URL ? server.url : new URL(`http://${urlHost}:${port}/`), stop: async (closeActive = true) => {
|
|
146
|
+
return keepHandler({ hostname, port, url: server.url instanceof URL ? server.url : new URL(`http://${urlHost}:${port}/`), stop: async (closeActive = true) => {
|
|
147
|
+
const stopping = Promise.resolve(server.stop(closeActive));
|
|
148
|
+
// stop closes the sockets still open (closeActive), each a close Bun may then keep counting, and drops the
|
|
149
|
+
// connections of handlers still running: once every socket the seam opened has closed and every handler left is
|
|
150
|
+
// one whose request was dropped, Bun's stop would never settle on them (above), and the seam has stopped
|
|
151
|
+
let settled = false;
|
|
152
|
+
stopping.then(() => { settled = true; }, () => { settled = true; });
|
|
153
|
+
while (!settled) {
|
|
154
|
+
const orphaned = [...inflight].every((request) => request.signal.aborted);
|
|
155
|
+
const counted = ((server as { pendingWebSockets?: number }).pendingWebSockets ?? 0) > 0;
|
|
156
|
+
if (live.size === 0 && orphaned && (counted || (closeActive && inflight.size > 0))) {
|
|
157
|
+
stopping.catch(() => {});
|
|
158
|
+
if (inflight.size) console.error(`serveHttp: forced stop left pending handler cleanup: ${inflight.size} request(s)`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
162
|
+
}
|
|
163
|
+
await stopping;
|
|
164
|
+
} }, options.fetch);
|
|
106
165
|
}
|
|
107
|
-
return serveOnNode({ ...options, hostname, urlHost, fetch: decorate ? decorate(options) : options.fetch });
|
|
166
|
+
return keepHandler(await serveOnNode({ ...options, hostname, urlHost, fetch: decorate ? decorate(options) : options.fetch }), options.fetch);
|
|
108
167
|
}
|
|
109
168
|
|
|
110
169
|
type NodeHttp = typeof import('node:http');
|
package/src/serve.ts
CHANGED
|
@@ -12,13 +12,14 @@ import { parseExactJson } from './exact-json.ts';
|
|
|
12
12
|
// reality). Forking is a separate data operation (fork.ts), never a serve mode.
|
|
13
13
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
14
14
|
import { serveHttp, setServeDecorator, WORLD_BOOT_PATH, type HttpHandler } from './serve-http.ts';
|
|
15
|
-
import { performAtHead } from './head.ts';
|
|
15
|
+
import { performAtHead, performsAtHeadNow } from './head.ts';
|
|
16
16
|
import { withHeaders } from './with-headers.ts';
|
|
17
17
|
import { createHash } from 'node:crypto';
|
|
18
18
|
import { dirname, join } from 'node:path';
|
|
19
19
|
import type { SubjectFields } from './hash.ts';
|
|
20
20
|
import { hashFieldValue } from './hash.ts';
|
|
21
|
-
import { appendActionIfAbsent, appendActionOccurrence, decideAndAppendAction, projectResources } from './actions.ts';
|
|
21
|
+
import { appendActionIfAbsent, appendActionOccurrence, appendActionOccurrences, decideAndAppendAction, projectResources } from './actions.ts';
|
|
22
|
+
import { readSubject } from './log.ts';
|
|
22
23
|
import type { ActionProjection, TwinAction, TwinActionPrecondition } from './actions.ts';
|
|
23
24
|
import { observeWorldPaths, worldPaths } from './storage.ts';
|
|
24
25
|
import { getActiveWorldStore } from './world-store.ts';
|
|
@@ -699,12 +700,53 @@ export async function applyTwinWrite(
|
|
|
699
700
|
return {
|
|
700
701
|
result,
|
|
701
702
|
get resource(): TwinResource {
|
|
702
|
-
return resolved ??=
|
|
703
|
+
return resolved ??= readSubject(service, write.subjectType, answerId, root)
|
|
703
704
|
?? ({ id: answerId, type: write.subjectType, updatedAt: landed.occurredAt, ...write.fields } as TwinResource);
|
|
704
705
|
},
|
|
705
706
|
};
|
|
706
707
|
}
|
|
707
708
|
|
|
709
|
+
/**
|
|
710
|
+
* Several local writes of one service, landed as `applyTwinWrite` lands each called in turn — the same actions, ids and
|
|
711
|
+
* order — under one lock and one append (actions.ts `appendActionOccurrences`). Writes that need the one-at-a-time
|
|
712
|
+
* path take it: a caller's identity (`idempotencyKey`/`actionId`), preconditions, or a head that performs each entry as
|
|
713
|
+
* it lands. An input is offloaded once per distinct value, as each write's own offload would name it.
|
|
714
|
+
*/
|
|
715
|
+
export async function applyTwinWrites(
|
|
716
|
+
service: string,
|
|
717
|
+
writes: readonly TwinWriteInput[],
|
|
718
|
+
root?: string,
|
|
719
|
+
): Promise<Array<{ result: TwinWriteResult; resource: TwinResource }>> {
|
|
720
|
+
if (writes.length <= 1 || performsAtHeadNow(service, root) || writes.some((write) => write.idempotencyKey || write.actionId || write.preconditions?.length)) {
|
|
721
|
+
const out: Array<{ result: TwinWriteResult; resource: TwinResource }> = [];
|
|
722
|
+
for (const write of writes) out.push(await applyTwinWrite(service, write, root));
|
|
723
|
+
return out;
|
|
724
|
+
}
|
|
725
|
+
// Each distinct input offloaded once (S2 R17): `offloadInput` is content-addressed (a string's blob is named by its
|
|
726
|
+
// SHA-256), so the result is the one each write's own offload would give, and a command's writes, which share one
|
|
727
|
+
// recorded request, hash and look up its blob once instead of once per write.
|
|
728
|
+
const offloaded = new Map<unknown, unknown>();
|
|
729
|
+
for (const write of writes) if (write.input !== undefined && !offloaded.has(write.input)) offloaded.set(write.input, await offloadInput(service, write.input, root));
|
|
730
|
+
const committed = appendActionOccurrences(writes.map((write) => actionForTwinWrite(service,
|
|
731
|
+
write.input === undefined ? write : { ...write, input: offloaded.get(write.input) as TwinWriteInput['input'] })), root);
|
|
732
|
+
const out: Array<{ result: TwinWriteResult; resource: TwinResource }> = [];
|
|
733
|
+
for (const [index, { appended, action: landed, placeholder }] of committed.entries()) {
|
|
734
|
+
const write = writes[index]!;
|
|
735
|
+
// the head does not perform here (`performsAtHeadNow` was false): this marks the request's deferred group as written
|
|
736
|
+
if (appended && !placeholder) await performAtHead(service, landed, root);
|
|
737
|
+
const answerId = write.subjectId;
|
|
738
|
+
let resolved: TwinResource | undefined;
|
|
739
|
+
out.push({
|
|
740
|
+
result: { status: appended ? 'performed' : 'replayed', actionId: landed.id },
|
|
741
|
+
get resource(): TwinResource {
|
|
742
|
+
return resolved ??= readSubject(service, write.subjectType, answerId, root)
|
|
743
|
+
?? ({ id: answerId, type: write.subjectType, updatedAt: landed.occurredAt, ...write.fields } as TwinResource);
|
|
744
|
+
},
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
return out;
|
|
748
|
+
}
|
|
749
|
+
|
|
708
750
|
export async function createTwinServer(options: { service: string; root?: string; port?: number; readOnly?: boolean }): Promise<{ port: number; stop: () => void }> {
|
|
709
751
|
const readOnly = options.readOnly ?? false;
|
|
710
752
|
const serveOptions = {
|
package/src/sockets.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// upgrade through the serve seam, and offers every write to every open session of the vendor, after the write's
|
|
7
7
|
// webhooks. A session's own counters (a sequence number, a heartbeat) are the connection's, never the World's state.
|
|
8
8
|
// Nothing here runs at module scope beyond the empty registry.
|
|
9
|
+
import { worldNow } from './world-clock.ts';
|
|
9
10
|
import type { EventWrite } from './events.ts';
|
|
10
11
|
import type { ScenarioEngine } from './scenario.ts';
|
|
11
12
|
import type { WebSocketPeer, WebSocketUpgrade } from './serve-http.ts';
|
|
@@ -48,7 +49,7 @@ export type SocketEngine<C> = {
|
|
|
48
49
|
close?(session: SocketSession, ctx: C): void | Promise<void>;
|
|
49
50
|
};
|
|
50
51
|
|
|
51
|
-
type Open = { session: SocketSession; engine: SocketEngine<unknown>; context: () => Promise<unknown>; queue: Promise<void>; pending: Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout
|
|
52
|
+
type Open = { session: SocketSession; engine: SocketEngine<unknown>; context: () => Promise<unknown>; queue: Promise<void>; pending: Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout>; due: number }> };
|
|
52
53
|
const sessions = new Map<string, Set<Open>>();
|
|
53
54
|
/** A World's next session number, per World (its service and root): a session's id is the World's, never the
|
|
54
55
|
* process's, so a life replayed on a fresh World numbers its sessions as it did the first time. */
|
|
@@ -60,6 +61,15 @@ export function openSessions(service: string, root: string | undefined, socket:
|
|
|
60
61
|
return [...(sessions.get(keyOf(service, root)) ?? [])].filter((o) => o.session.socket === socket).map((o) => o.session);
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
/** What a session awaits that the World's clock has run out (a life's `wait` past a relay's timeout), rejected as the
|
|
65
|
+
* machine's clock would reject it; called as the vendor serves a request ("A request answered by a session"). */
|
|
66
|
+
export function expireSocketWaits(service: string, root: string | undefined): void {
|
|
67
|
+
const open = sessions.get(keyOf(service, root));
|
|
68
|
+
if (!open?.size) return;
|
|
69
|
+
const now = Date.parse(worldNow());
|
|
70
|
+
for (const o of open) for (const [k, p] of o.pending) if (p.due <= now) { clearTimeout(p.timer); o.pending.delete(k); p.reject(new Error('no reply in time')); }
|
|
71
|
+
}
|
|
72
|
+
|
|
63
73
|
/** Offer a write to every open session of the vendor, each in its turn after what it is already doing. Queued, never
|
|
64
74
|
* awaited: the write may be one a session's own frame made, which that session finishes before it is told. */
|
|
65
75
|
export function socketWrite(service: string, root: string | undefined, write: EventWrite): void {
|
|
@@ -72,21 +82,42 @@ export function socketWrite(service: string, root: string | undefined, write: Ev
|
|
|
72
82
|
}
|
|
73
83
|
}
|
|
74
84
|
|
|
85
|
+
/** A declared socket path's parameters for a request's path, or undefined when it does not match: a `{name}` segment
|
|
86
|
+
* matches one segment, as an operation's path parameter does (architecture, sockets). */
|
|
87
|
+
function socketParams(declared: string, path: string): Record<string, string> | undefined {
|
|
88
|
+
const names: string[] = [];
|
|
89
|
+
const pattern = (declared.replace(/\/+$/, '') || '/').split(/(\{[^}/]+\})/).map((part) => {
|
|
90
|
+
const name = /^\{([^}/]+)\}$/.exec(part)?.[1];
|
|
91
|
+
if (name === undefined) return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
92
|
+
names.push(name);
|
|
93
|
+
return '([^/]+)';
|
|
94
|
+
}).join('');
|
|
95
|
+
const hit = new RegExp(`^${pattern}$`).exec(path);
|
|
96
|
+
if (!hit) return undefined;
|
|
97
|
+
try { return Object.fromEntries(names.map((name, i) => [name, decodeURIComponent(hit[i + 1]!)])); } catch { return undefined; }
|
|
98
|
+
}
|
|
99
|
+
|
|
75
100
|
/** The upgrade the serve seam takes: a request to a declared socket's path (and host) opens a session of its engine. */
|
|
76
101
|
export function socketUpgrade<C>(
|
|
77
102
|
service: string,
|
|
78
103
|
root: string | undefined,
|
|
79
104
|
decls: ReadonlyArray<SocketDecl>,
|
|
80
105
|
engines: Record<string, SocketEngine<C>>,
|
|
81
|
-
context: (request: Request, decl: SocketDecl) => Promise<C>,
|
|
106
|
+
context: (request: Request, decl: SocketDecl, params: Record<string, string>) => Promise<C>,
|
|
82
107
|
hostOf: (request: Request) => string,
|
|
83
108
|
pathOf: (request: Request) => string = (request) => new URL(request.url).pathname,
|
|
84
109
|
decide: (asked: unknown) => Promise<ScenarioServed | undefined> = async () => undefined,
|
|
85
110
|
): WebSocketUpgrade {
|
|
86
|
-
const
|
|
111
|
+
const matchFor = (request: Request): { decl: SocketDecl; params: Record<string, string> } | undefined => {
|
|
87
112
|
const path = pathOf(request).replace(/\/+$/, '') || '/';
|
|
88
|
-
|
|
89
|
-
|
|
113
|
+
for (const d of decls) {
|
|
114
|
+
if ((d.host && d.host.toLowerCase() !== hostOf(request)) || engines[d.id] === undefined) continue;
|
|
115
|
+
const params = socketParams(d.path, path);
|
|
116
|
+
if (params) return { decl: d, params };
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
};
|
|
120
|
+
const declFor = (request: Request): SocketDecl | undefined => matchFor(request)?.decl;
|
|
90
121
|
const byPeer = new WeakMap<WebSocketPeer, Open>();
|
|
91
122
|
const key = keyOf(service, root);
|
|
92
123
|
return {
|
|
@@ -99,7 +130,7 @@ export function socketUpgrade<C>(
|
|
|
99
130
|
return declared ? offered.find((p) => declared.includes(p)) : offered[0];
|
|
100
131
|
},
|
|
101
132
|
open: (peer, request) => {
|
|
102
|
-
const decl =
|
|
133
|
+
const { decl, params } = matchFor(request)!;
|
|
103
134
|
const engine = engines[decl.id]! as SocketEngine<unknown>;
|
|
104
135
|
const pending: Open['pending'] = new Map();
|
|
105
136
|
const session: SocketSession = {
|
|
@@ -108,7 +139,7 @@ export function socketUpgrade<C>(
|
|
|
108
139
|
close: (code, reason) => { peer.close(code, reason); },
|
|
109
140
|
expect: (key, ms) => new Promise((resolve, reject) => {
|
|
110
141
|
const timer = setTimeout(() => { pending.delete(key); reject(new Error('no reply in time')); }, ms);
|
|
111
|
-
pending.set(key, { resolve, reject, timer });
|
|
142
|
+
pending.set(key, { resolve, reject, timer, due: Date.parse(worldNow()) + ms });
|
|
112
143
|
}),
|
|
113
144
|
fulfil: (key, value) => {
|
|
114
145
|
const p = pending.get(key);
|
|
@@ -118,7 +149,7 @@ export function socketUpgrade<C>(
|
|
|
118
149
|
},
|
|
119
150
|
decide,
|
|
120
151
|
};
|
|
121
|
-
const o: Open = { session, engine, context: () => context(request.clone(), decl), queue: Promise.resolve(), pending };
|
|
152
|
+
const o: Open = { session, engine, context: () => context(request.clone(), decl, params), queue: Promise.resolve(), pending };
|
|
122
153
|
byPeer.set(peer, o);
|
|
123
154
|
(sessions.get(key) ?? sessions.set(key, new Set()).get(key)!).add(o);
|
|
124
155
|
if (engine.open) o.queue = o.queue.then(async () => engine.open!(session, await o.context())).catch((error) => session.close(1011, String((error as Error).message ?? error).slice(0, 120)));
|
package/src/storage.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
GenericWorldStateSchema,
|
|
7
7
|
WorldServiceEventSchema,
|
|
8
8
|
} from './schemas.ts';
|
|
9
|
-
import { getActiveWorldStore } from './world-store.ts';
|
|
9
|
+
import { getActiveWorldStore, storageLockScope, TREE_GENERATION, type WorldLockOptions } from './world-store.ts';
|
|
10
10
|
import { refuseReadOnlyWrite } from './request-scope.ts';
|
|
11
11
|
import { parentEntries, toEvent } from './log.ts';
|
|
12
12
|
import type {
|
|
@@ -198,8 +198,8 @@ function appendJsonl(path: string, value: unknown): void {
|
|
|
198
198
|
* single-instance store it is a synchronous pass-through. Used to make read-then-append
|
|
199
199
|
* critical sections atomic. Public so world-runtime can guard concurrent `upWorld`
|
|
200
200
|
* claims of one instance dir with the SAME lock semantics the event log uses (TWIN-36). */
|
|
201
|
-
export function withFileLock<T>(lockPath: string, fn: () => T): T {
|
|
202
|
-
return withStateLock(lockPath, fn);
|
|
201
|
+
export function withFileLock<T>(lockPath: string, fn: () => T, options: WorldLockOptions = {}): T {
|
|
202
|
+
return withStateLock(lockPath, fn, storageLockScope(options));
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
function writeJsonAtomic(path: string, value: unknown): void {
|
|
@@ -385,6 +385,8 @@ const KNOWN_SERVICE_ENTRIES = new Set([
|
|
|
385
385
|
function isKnownServiceEntry(name: string): boolean {
|
|
386
386
|
// log.ts: the branch pointer and the checkpoint
|
|
387
387
|
if (name === 'branch.json' || name === 'checkpoints') return true;
|
|
388
|
+
// world-store.ts: the generation a file store keeps beside a tree's files
|
|
389
|
+
if (name === TREE_GENERATION) return true;
|
|
388
390
|
return KNOWN_SERVICE_ENTRIES.has(name) || name.endsWith('.tmp') || /\.stale\.\d+\.\d+$/.test(name);
|
|
389
391
|
}
|
|
390
392
|
|
package/src/vendor-call.ts
CHANGED
|
@@ -41,6 +41,50 @@ async function worldTwins(): Promise<Record<string, string>> {
|
|
|
41
41
|
return { ...twinOrigins(env), ...twinOrigins(instanceEnv) };
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** The hosts each claiming twin last listed at its `hostsClaimed` door, by the door's address, and when; read again
|
|
45
|
+
* after CLAIMS_TTL_MS or for a host it lacks, as the injector polls the doors every five seconds (inject.cjs, "HOSTS A TWIN CLAIMS WHILE IT
|
|
46
|
+
* RUNS"). */
|
|
47
|
+
const claimsRead = new Map<string, { hosts: Set<string>; at: number }>();
|
|
48
|
+
const CLAIMS_TTL_MS = 5_000;
|
|
49
|
+
|
|
50
|
+
/** The hosts a twin claims at its door: in process (a journey walk's companion) or at its address, within the injector's
|
|
51
|
+
* first-read deadline (inject.cjs CLAIMS_WAIT_MS, 1500 ms, measured beside it); a door that does not answer claims
|
|
52
|
+
* nothing. */
|
|
53
|
+
async function claimedHosts(at: string, ask: () => Promise<Response>, host: string): Promise<Set<string>> {
|
|
54
|
+
const held = claimsRead.get(at);
|
|
55
|
+
// a host the held list lacks is asked for again once, as the injector refreshes before it refuses: a domain claimed a
|
|
56
|
+
// moment ago is found
|
|
57
|
+
if (held && Date.now() - held.at < CLAIMS_TTL_MS && held.hosts.has(host)) return held.hosts;
|
|
58
|
+
let hosts = new Set<string>();
|
|
59
|
+
try {
|
|
60
|
+
const r = await ask();
|
|
61
|
+
const body = r.ok ? await r.json() as { hosts?: unknown[] } : null;
|
|
62
|
+
hosts = new Set((body?.hosts ?? []).map((h) => String(h).toLowerCase()));
|
|
63
|
+
} catch { /* an unanswered door claims nothing */ }
|
|
64
|
+
claimsRead.set(at, { hosts, at: Date.now() });
|
|
65
|
+
return hosts;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The twin that claims a host at its `hostsClaimed` door (an R2 bucket's custom domain, a Worker's), as the injector
|
|
69
|
+
* routes it: a twin this process answers first, the World's twins after. */
|
|
70
|
+
async function claimingTwin(target: URL, twins: Record<string, string>): Promise<{ vendor: string; local: boolean } | undefined> {
|
|
71
|
+
const { packFacts } = await import('../pack-facts.cjs') as { packFacts: () => { packs: Record<string, { hostsClaimed?: { door?: string } }> } };
|
|
72
|
+
const host = target.hostname.toLowerCase();
|
|
73
|
+
const key = process.env.VOLTER_TWINS_KEY;
|
|
74
|
+
const doors = Object.entries(packFacts().packs).flatMap(([vendor, facts]) => (facts.hostsClaimed?.door ? [[vendor, facts.hostsClaimed.door] as const] : []));
|
|
75
|
+
for (const [vendor, door] of doors) {
|
|
76
|
+
const twin = inProcessTwins[vendor];
|
|
77
|
+
if (twin && (await claimedHosts(`in-process:${vendor}${door}`, async () => twin(new Request(`http://${vendor}.twin${door}`)), host)).has(host)) return { vendor, local: true };
|
|
78
|
+
}
|
|
79
|
+
for (const [vendor, door] of doors) {
|
|
80
|
+
const origin = twins[vendor];
|
|
81
|
+
if (origin === undefined) continue;
|
|
82
|
+
const at = `${origin.replace(/\/$/, '')}${door}`;
|
|
83
|
+
if ((await claimedHosts(at, () => fetch(at, { headers: key ? { 'x-twins-key': key } : {}, signal: AbortSignal.timeout(1500) }), host)).has(host)) return { vendor, local: false };
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
44
88
|
/** One URL answered only by a World-held application or vendor twin: the fetch's application stand-in first (its answer
|
|
45
89
|
* is the application's; `'unreachable'` leaves no application to reach, the World's twins still reachable). */
|
|
46
90
|
async function worldResponse(target: URL, init: RequestInit, standIn?: ApplicationStandIn): Promise<Response> {
|
|
@@ -67,8 +111,18 @@ async function worldResponse(target: URL, init: RequestInit, standIn?: Applicati
|
|
|
67
111
|
if (key && !headers.has('x-twins-key')) headers.set('x-twins-key', key);
|
|
68
112
|
return fetch(`${twins[vendor]!.replace(/\/$/, '')}${target.pathname}${target.search}`, { ...init, headers, redirect: 'manual' });
|
|
69
113
|
}
|
|
114
|
+
// the application's own hosts before any twin's claimed ones: a call to the application asks no door
|
|
70
115
|
const { appDestination, appFetch } = await import('../app-route.cjs') as { appDestination: (url: URL) => unknown; appFetch: (url: URL, init: RequestInit) => Promise<Response> };
|
|
71
116
|
if (scripted !== 'unreachable' && appDestination(target)) return appFetch(target, { ...init, redirect: 'manual' });
|
|
117
|
+
const claimer = await claimingTwin(target, twins);
|
|
118
|
+
if (claimer !== undefined) {
|
|
119
|
+
const headers = new Headers(init.headers);
|
|
120
|
+
headers.set('x-volter-twin-original-host', target.host);
|
|
121
|
+
if (claimer.local) return inProcessTwins[claimer.vendor]!(new Request(target, { ...init, headers, redirect: 'manual' }));
|
|
122
|
+
const key = process.env.VOLTER_TWINS_KEY;
|
|
123
|
+
if (key && !headers.has('x-twins-key')) headers.set('x-twins-key', key);
|
|
124
|
+
return fetch(`${twins[claimer.vendor]!.replace(/\/$/, '')}${target.pathname}${target.search}`, { ...init, headers, redirect: 'manual' });
|
|
125
|
+
}
|
|
72
126
|
throw new VendorUnreachableError(`${target.host}${target.pathname}: this World holds no application or vendor twin at this URL`);
|
|
73
127
|
}
|
|
74
128
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// One private boot answer for the kernel's HTTP and gRPC serve seams.
|
|
2
|
+
// Reading process identity happens on request; importing this helper has no side effects.
|
|
3
|
+
export function worldBootAnswer(boot: string): Response {
|
|
4
|
+
const pid = (globalThis as { process?: { pid?: number } }).process?.pid;
|
|
5
|
+
return Response.json({ boot, pid });
|
|
6
|
+
}
|
package/src/world-clock.ts
CHANGED
|
@@ -26,10 +26,14 @@ const machineNow = (): number => {
|
|
|
26
26
|
* now); real wall-clock time otherwise. */
|
|
27
27
|
export function worldNow(): string {
|
|
28
28
|
const file = process.env[WORLD_CLOCK_ENV];
|
|
29
|
-
|
|
29
|
+
// One read answers both whether the clock file is there and what it holds.
|
|
30
|
+
// Kept while the clock file's own facts stand (world-store.ts readKept): every operation asks the time.
|
|
31
|
+
const store = getActiveWorldStore();
|
|
32
|
+
const held = file ? (store.readKept ? store.readKept(file) : store.read(file)) : null;
|
|
33
|
+
if (held !== null) {
|
|
30
34
|
// A configured-but-corrupt clock is a WORLD defect — parseClock fails loudly, never silently drifting back to
|
|
31
35
|
// wall-clock (two services disagreeing about now breaks the world).
|
|
32
|
-
const clock = clockForm.parseClock(
|
|
36
|
+
const clock = clockForm.parseClock(held);
|
|
33
37
|
return new Date(clockForm.clockNowMs(clock, machineNow())).toISOString();
|
|
34
38
|
}
|
|
35
39
|
return new Date(machineNow()).toISOString();
|