@volter/world-core 2.0.0 → 2.0.2
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 +95 -6
- package/app-route.d.cts +2 -1
- package/dist/app-route.cjs +95 -6
- package/dist/app-route.d.cts +2 -1
- package/dist/generated/pack-facts.json +127 -8
- package/dist/inject.cjs +46 -1
- package/dist/src/actions.d.ts +27 -0
- package/dist/src/actions.js +62 -4
- package/dist/src/blob-store.d.ts +3 -0
- package/dist/src/blob-store.js +15 -1
- package/dist/src/client-bundle.d.ts +4 -0
- package/dist/src/client-bundle.js +13 -2
- package/dist/src/derived-core.d.ts +6 -0
- package/dist/src/derived-core.js +17 -3
- package/dist/src/derived.js +5 -2
- package/dist/src/git/refs.js +5 -3
- package/dist/src/head.js +12 -2
- package/dist/src/index.d.ts +8 -5
- package/dist/src/index.js +9 -5
- package/dist/src/log.d.ts +2 -0
- package/dist/src/packRegistry.d.ts +8 -6
- package/dist/src/redis/engine.d.ts +308 -0
- package/dist/src/redis/engine.js +1663 -0
- package/dist/src/redis/index.d.ts +2 -0
- package/dist/src/redis/index.js +6 -0
- package/dist/src/redis/lua.d.ts +153 -0
- package/dist/src/redis/lua.js +1373 -0
- package/dist/src/request-scope.d.ts +28 -0
- package/dist/src/request-scope.js +70 -0
- package/dist/src/storage.d.ts +22 -1
- package/dist/src/storage.js +57 -1
- package/dist/src/trace-context.d.ts +31 -0
- package/dist/src/trace-context.js +78 -0
- package/dist/src/twin-fetch.d.ts +16 -0
- package/dist/src/twin-fetch.js +66 -3
- package/dist/src/world-clock.d.ts +2 -2
- package/dist/src/world-clock.js +18 -17
- package/dist/src/world-store.js +9 -0
- package/dist/vendor-hosts.cjs +2 -2
- package/dist/world-clock.cjs +40 -0
- package/dist/world-clock.d.cts +4 -0
- package/generated/pack-facts.json +127 -8
- package/inject.cjs +46 -1
- package/package.json +11 -1
- package/src/actions.ts +69 -4
- package/src/blob-store.ts +15 -1
- package/src/client-bundle.ts +15 -2
- package/src/derived-core.ts +17 -3
- package/src/derived.ts +5 -2
- package/src/git/refs.ts +5 -3
- package/src/head.ts +11 -2
- package/src/index.ts +11 -3
- package/src/log.ts +2 -0
- package/src/packRegistry.ts +8 -6
- package/src/redis/engine.ts +1468 -0
- package/src/redis/index.ts +6 -0
- package/src/redis/lua.ts +1250 -0
- package/src/request-scope.ts +74 -0
- package/src/storage.ts +54 -2
- package/src/trace-context.ts +87 -0
- package/src/twin-fetch.ts +69 -3
- package/src/world-clock.ts +19 -16
- package/src/world-store.ts +9 -0
- package/vendor-hosts.cjs +2 -2
- package/world-clock.cjs +40 -0
- package/world-clock.d.cts +4 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// THE WORLD CLOCK'S FORM — the one home of how a clock file reads (dependency-free, side-effect free), shared by the
|
|
3
|
+
// twins' worldNow() (src/world-clock.ts, reading through the active WorldStore) and the injector, which gives an
|
|
4
|
+
// application process the same time (inject.cjs, WORLD TIME).
|
|
5
|
+
//
|
|
6
|
+
// A clock file holds one line in one of two forms:
|
|
7
|
+
// · FROZEN: `<ISO-8601 instant>` — every read answers exactly that instant (scripted time: lives, seeds, a story);
|
|
8
|
+
// · RUNNING: `<ISO-8601 instant> from <ISO-8601 wall instant>` — the World's time was <instant> when the machine's was
|
|
9
|
+
// <wall instant>, and runs on at the machine's pace from there: a World serving an application moves time this way
|
|
10
|
+
// (`volter-world clock <world> shift <N s|m|h|d>`), since a frozen instant would stop the application's time.
|
|
11
|
+
// No file: the machine's time.
|
|
12
|
+
|
|
13
|
+
/** The clock a file's text states, or throws on text that states neither form. */
|
|
14
|
+
function parseClock(raw) {
|
|
15
|
+
const text = String(raw ?? '').trim();
|
|
16
|
+
const m = /^(\S+)\s+from\s+(\S+)$/.exec(text);
|
|
17
|
+
if (m) {
|
|
18
|
+
const at = Date.parse(m[1]);
|
|
19
|
+
const since = Date.parse(m[2]);
|
|
20
|
+
if (!Number.isNaN(at) && !Number.isNaN(since)) return { kind: 'running', at, since };
|
|
21
|
+
} else {
|
|
22
|
+
const at = Date.parse(text);
|
|
23
|
+
if (!Number.isNaN(at)) return { kind: 'frozen', at };
|
|
24
|
+
}
|
|
25
|
+
throw new Error(`world clock: expected an ISO-8601 instant, or "<instant> from <instant>", got ${JSON.stringify(text.slice(0, 60))}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The World's time in ms for a parsed clock, at the machine's time `wallMs`. */
|
|
29
|
+
function clockNowMs(clock, wallMs) {
|
|
30
|
+
return clock.kind === 'running' ? clock.at + (wallMs - clock.since) : clock.at;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A clock's text: frozen at `atMs`, or running from `atMs` as of the machine's `sinceMs`. */
|
|
34
|
+
function formatClock(clock) {
|
|
35
|
+
return clock.kind === 'running'
|
|
36
|
+
? `${new Date(clock.at).toISOString()} from ${new Date(clock.since).toISOString()}`
|
|
37
|
+
: new Date(clock.at).toISOString();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { parseClock, clockNowMs, formatClock };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type WorldClock = { kind: 'frozen'; at: number } | { kind: 'running'; at: number; since: number };
|
|
2
|
+
export function parseClock(raw: string | null | undefined): WorldClock;
|
|
3
|
+
export function clockNowMs(clock: WorldClock, wallMs: number): number;
|
|
4
|
+
export function formatClock(clock: WorldClock): string;
|
|
@@ -714,6 +714,9 @@
|
|
|
714
714
|
"hosts": [
|
|
715
715
|
{
|
|
716
716
|
"host": "api.cohere.com"
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
"host": "api.cohere.ai"
|
|
717
720
|
}
|
|
718
721
|
],
|
|
719
722
|
"archetype": "generative",
|
|
@@ -728,7 +731,7 @@
|
|
|
728
731
|
"embed_job",
|
|
729
732
|
"token"
|
|
730
733
|
],
|
|
731
|
-
"endpointEnvNone": "neither official SDK reads a base-URL env var: cohere-ai@8.1.0 takes the override as the `baseUrl`/`environment` CONSTRUCTOR option and reads only CO_API_KEY from the environment (auth/BearerAuthProvider.js), and @ai-sdk/cohere@4.0.35 takes `options.baseURL` and reads only COHERE_API_KEY. Interception is host-based on api.cohere.com; inventing a COHERE_BASE_URL nothing reads would make `covers` claim a world it does not cover.",
|
|
734
|
+
"endpointEnvNone": "neither official SDK reads a base-URL env var: cohere-ai@8.1.0 takes the override as the `baseUrl`/`environment` CONSTRUCTOR option and reads only CO_API_KEY from the environment (auth/BearerAuthProvider.js), and @ai-sdk/cohere@4.0.35 takes `options.baseURL` and reads only COHERE_API_KEY. Interception is host-based on api.cohere.com and api.cohere.ai; inventing a COHERE_BASE_URL nothing reads would make `covers` claim a world it does not cover.",
|
|
732
735
|
"serveExport": "createCohereTwinServer"
|
|
733
736
|
},
|
|
734
737
|
"currencyapi": {
|
|
@@ -963,6 +966,10 @@
|
|
|
963
966
|
"host": "discord.com",
|
|
964
967
|
"pathPattern": "^/api/"
|
|
965
968
|
},
|
|
969
|
+
{
|
|
970
|
+
"host": "discord.com",
|
|
971
|
+
"pathPattern": "^/(oauth2/authorize|login)/?$"
|
|
972
|
+
},
|
|
966
973
|
{
|
|
967
974
|
"host": "gateway.discord.gg"
|
|
968
975
|
},
|
|
@@ -1399,6 +1406,10 @@
|
|
|
1399
1406
|
"host": "github.com",
|
|
1400
1407
|
"pathPattern": "^/login/oauth/(authorize|access_token)$"
|
|
1401
1408
|
},
|
|
1409
|
+
{
|
|
1410
|
+
"host": "github.com",
|
|
1411
|
+
"pathPattern": "^/(login|session|sessions/two-factor(/app)?)$"
|
|
1412
|
+
},
|
|
1402
1413
|
{
|
|
1403
1414
|
"host": "github.com",
|
|
1404
1415
|
"pathPattern": "^/apps/[^/]+/installations/new$"
|
|
@@ -1470,6 +1481,37 @@
|
|
|
1470
1481
|
"endpointEnvNone": "no Google Ads client exposes an HTTP base-URL override: google-ads-api builds the host into the request literally, google-ads-python's `endpoint` is a gRPC host:port for its transport channel, and Dub hardcodes the host in a raw fetch. Interception is host-based.",
|
|
1471
1482
|
"serveExport": "createGoogleAdsTwinServer"
|
|
1472
1483
|
},
|
|
1484
|
+
"googlecalendar": {
|
|
1485
|
+
"adoption": {
|
|
1486
|
+
"sdks": [
|
|
1487
|
+
"@googleapis/calendar"
|
|
1488
|
+
],
|
|
1489
|
+
"pypi": [],
|
|
1490
|
+
"envStems": [
|
|
1491
|
+
"GOOGLECALENDAR"
|
|
1492
|
+
]
|
|
1493
|
+
},
|
|
1494
|
+
"hosts": [
|
|
1495
|
+
{
|
|
1496
|
+
"host": "www.googleapis.com",
|
|
1497
|
+
"pathPattern": "^/(batch/)?calendar/v3(/|$)"
|
|
1498
|
+
}
|
|
1499
|
+
],
|
|
1500
|
+
"archetype": "crud",
|
|
1501
|
+
"protocol": {
|
|
1502
|
+
"declared": "2",
|
|
1503
|
+
"major": 2,
|
|
1504
|
+
"standing": "current"
|
|
1505
|
+
},
|
|
1506
|
+
"transport": "rest",
|
|
1507
|
+
"resources": [
|
|
1508
|
+
"Calendar",
|
|
1509
|
+
"CalendarListEntry",
|
|
1510
|
+
"Event"
|
|
1511
|
+
],
|
|
1512
|
+
"endpointEnvNone": "@googleapis/calendar and googleapis read no base-URL variable: the root URL is the Discovery document's `https://www.googleapis.com/` unless the caller passes `rootUrl` (Inbox Zero reads its own GOOGLE_BASE_URL for that); interception is the host rule above.",
|
|
1513
|
+
"serveExport": "createGooglecalendarTwinServer"
|
|
1514
|
+
},
|
|
1473
1515
|
"googlemaps": {
|
|
1474
1516
|
"adoption": {
|
|
1475
1517
|
"pypi": [
|
|
@@ -2148,6 +2190,27 @@
|
|
|
2148
2190
|
],
|
|
2149
2191
|
"serveExport": "createMixpanelTwinServer"
|
|
2150
2192
|
},
|
|
2193
|
+
"mongodb": {
|
|
2194
|
+
"adoption": {
|
|
2195
|
+
"pypi": [],
|
|
2196
|
+
"sdks": []
|
|
2197
|
+
},
|
|
2198
|
+
"hostsNone": "RAW-PROTOCOL pack — there is no host to map. MongoDB clients speak the wire protocol over a raw TCP socket to whatever host the connection string names, which the injector (http/fetch patching) cannot see; a World reaches this twin through the connection string itself (MONGO_URI and its kin), which World-managed infrastructure injects.",
|
|
2199
|
+
"archetype": "crud",
|
|
2200
|
+
"protocol": {
|
|
2201
|
+
"declared": "2",
|
|
2202
|
+
"major": 2,
|
|
2203
|
+
"standing": "current"
|
|
2204
|
+
},
|
|
2205
|
+
"transport": "raw-tcp",
|
|
2206
|
+
"resources": [
|
|
2207
|
+
"collection",
|
|
2208
|
+
"index",
|
|
2209
|
+
"document"
|
|
2210
|
+
],
|
|
2211
|
+
"endpointEnvNone": "no vendor base-URL variable: the application reads its own connection-string variable (MONGO_URI, MONGODB_URL, DATABASE_URL, …), and World-managed infrastructure (`volter-world init` → `volter-world-infra`) injects that variable pointing at this twin's listener.",
|
|
2212
|
+
"serveExport": "createMongodbTwinServer"
|
|
2213
|
+
},
|
|
2151
2214
|
"moonshot": {
|
|
2152
2215
|
"adoption": {
|
|
2153
2216
|
"pypi": [],
|
|
@@ -2816,6 +2879,26 @@
|
|
|
2816
2879
|
"endpointEnvNone": "reddit clients take no base-URL environment variable (snoowrap and PRAW build https://oauth.reddit.com and https://www.reddit.com URLs from constants or explicit constructor options), so a World reaches them through the hosts above; inventing a REDDIT_BASE_URL the app never reads would report coverage the app does not have.",
|
|
2817
2880
|
"serveExport": "createRedditTwinServer"
|
|
2818
2881
|
},
|
|
2882
|
+
"redis": {
|
|
2883
|
+
"adoption": {},
|
|
2884
|
+
"hostsNone": "RAW-PROTOCOL pack — there is no host to map. Redis's protocol runs over a TCP socket the injector (which patches http/fetch) never sees, and a Redis has no vendor hostname: it is wherever the app's REDIS_URL points. The World points it here: init classifies REDIS_URL as managed infrastructure and the managed redis service is this twin.",
|
|
2885
|
+
"archetype": "crud",
|
|
2886
|
+
"protocol": {
|
|
2887
|
+
"major": 1,
|
|
2888
|
+
"standing": "deprecated"
|
|
2889
|
+
},
|
|
2890
|
+
"transport": "raw-tcp",
|
|
2891
|
+
"resources": [
|
|
2892
|
+
"key",
|
|
2893
|
+
"field",
|
|
2894
|
+
"member",
|
|
2895
|
+
"scored",
|
|
2896
|
+
"entry",
|
|
2897
|
+
"script"
|
|
2898
|
+
],
|
|
2899
|
+
"endpointEnvNone": "Redis clients read no base-URL env of their own: the app passes its REDIS_URL (or host/port) to ioredis or node-redis itself, and init emits that REDIS_URL for the managed redis service (world-runtime/src/init.ts), not as a twin endpoint env.",
|
|
2900
|
+
"serveExport": "createRedisTwinServer"
|
|
2901
|
+
},
|
|
2819
2902
|
"replicate": {
|
|
2820
2903
|
"adoption": {
|
|
2821
2904
|
"pypi": [
|
|
@@ -3358,11 +3441,11 @@
|
|
|
3358
3441
|
},
|
|
3359
3442
|
{
|
|
3360
3443
|
"host": "connect.stripe.com",
|
|
3361
|
-
"pathPattern": "^/setup/"
|
|
3444
|
+
"pathPattern": "^/setup/|^/oauth/(authorize|token|deauthorize)/?$"
|
|
3362
3445
|
},
|
|
3363
3446
|
{
|
|
3364
3447
|
"host": "dashboard.stripe.com",
|
|
3365
|
-
"pathPattern": "^/settings/public
|
|
3448
|
+
"pathPattern": "^/settings/public/?$|^/(test/)?settings/connect(/onboarding-options/oauth)?/?$"
|
|
3366
3449
|
},
|
|
3367
3450
|
{
|
|
3368
3451
|
"host": "verify.stripe.com",
|
|
@@ -3561,6 +3644,38 @@
|
|
|
3561
3644
|
"endpointEnvNone": "the @tavily/core SDK exposes no base-URL env var — it reads only TAVILY_API_KEY/TAVILY_HTTP(S)_PROXY/TAVILY_ORG_ID/TAVILY_PROJECT from process.env; base-URL override is the constructor option apiBaseURL (dist/index.mjs post()/get(): `apiBaseURL || BASE_URL`), so interception is the hosts entry above.",
|
|
3562
3645
|
"serveExport": "createTavilyTwinServer"
|
|
3563
3646
|
},
|
|
3647
|
+
"temporal": {
|
|
3648
|
+
"adoption": {
|
|
3649
|
+
"sdks": [
|
|
3650
|
+
"@temporalio/client",
|
|
3651
|
+
"@temporalio/worker",
|
|
3652
|
+
"@temporalio/workflow",
|
|
3653
|
+
"@temporalio/activity"
|
|
3654
|
+
],
|
|
3655
|
+
"scopes": [
|
|
3656
|
+
"@temporalio/"
|
|
3657
|
+
],
|
|
3658
|
+
"pypi": [
|
|
3659
|
+
"temporalio"
|
|
3660
|
+
],
|
|
3661
|
+
"envStems": [
|
|
3662
|
+
"TEMPORAL"
|
|
3663
|
+
]
|
|
3664
|
+
},
|
|
3665
|
+
"hostsNone": "RAW-PROTOCOL pack — there is no host to map. The Temporal SDKs dial the frontend address they are configured with (Connection.connect / NativeConnection.connect `address`, conventionally TEMPORAL_ADDRESS) over gRPC, which the http/fetch injector never sees. Interception is the app-read address: the World injects TEMPORAL_ADDRESS=<host>:<port> at this twin (APP_READ_ENDPOINT_ENV in world-runtime/src/init.ts).",
|
|
3666
|
+
"archetype": "engine-control",
|
|
3667
|
+
"protocol": {
|
|
3668
|
+
"declared": "2",
|
|
3669
|
+
"major": 2,
|
|
3670
|
+
"standing": "current"
|
|
3671
|
+
},
|
|
3672
|
+
"transport": "raw-tcp",
|
|
3673
|
+
"resources": [
|
|
3674
|
+
"workflow-execution",
|
|
3675
|
+
"namespace"
|
|
3676
|
+
],
|
|
3677
|
+
"serveExport": "createTemporalTwinServer"
|
|
3678
|
+
},
|
|
3564
3679
|
"tiktok": {
|
|
3565
3680
|
"adoption": {
|
|
3566
3681
|
"sdks": [],
|
|
@@ -4154,11 +4269,11 @@
|
|
|
4154
4269
|
"hosts": [
|
|
4155
4270
|
{
|
|
4156
4271
|
"host": "api.x.com",
|
|
4157
|
-
"pathPattern": "^(?:/2/media/upload/?|/2/media/upload/initialize/?|/2/media/upload/[0-9]+/(?:append|finalize)/?|/2/tweets/?|/2/tweets/[^/]+/?|/2/tweets/search/recent/?|/2/users/(?!me/?$)[^/]+/?|/2/users/by/username/[^/]+/?|/2/users/[^/]+/mentions/?|/2/users/[^/]+/tweets/?|/2/users/[^/]+/timelines/reverse_chronological/?)$|^/_twin/"
|
|
4272
|
+
"pathPattern": "^(?:/2/media/upload/?|/2/media/upload/initialize/?|/2/media/upload/[0-9]+/(?:append|finalize)/?|/2/tweets/?|/2/tweets/[^/]+/?|/2/tweets/search/recent/?|/2/users/(?!me/?$)[^/]+/?|/2/users/by/username/[^/]+/?|/2/users/[^/]+/mentions/?|/2/users/[^/]+/tweets/?|/2/users/[^/]+/timelines/reverse_chronological/?)$|^/_twin/(?!assets/consent\\.(?:js|css)$)"
|
|
4158
4273
|
},
|
|
4159
4274
|
{
|
|
4160
4275
|
"host": "api.twitter.com",
|
|
4161
|
-
"pathPattern": "^(?:/2/media/upload/?|/2/media/upload/initialize/?|/2/media/upload/[0-9]+/(?:append|finalize)/?|/2/tweets/?|/2/tweets/[^/]+/?|/2/tweets/search/recent/?|/2/users/(?!me/?$)[^/]+/?|/2/users/by/username/[^/]+/?|/2/users/[^/]+/mentions/?|/2/users/[^/]+/tweets/?|/2/users/[^/]+/timelines/reverse_chronological/?)$|^/_twin/"
|
|
4276
|
+
"pathPattern": "^(?:/2/media/upload/?|/2/media/upload/initialize/?|/2/media/upload/[0-9]+/(?:append|finalize)/?|/2/tweets/?|/2/tweets/[^/]+/?|/2/tweets/search/recent/?|/2/users/(?!me/?$)[^/]+/?|/2/users/by/username/[^/]+/?|/2/users/[^/]+/mentions/?|/2/users/[^/]+/tweets/?|/2/users/[^/]+/timelines/reverse_chronological/?)$|^/_twin/(?!assets/consent\\.(?:js|css)$)"
|
|
4162
4277
|
}
|
|
4163
4278
|
],
|
|
4164
4279
|
"archetype": "crud",
|
|
@@ -4241,11 +4356,11 @@
|
|
|
4241
4356
|
},
|
|
4242
4357
|
{
|
|
4243
4358
|
"host": "api.x.com",
|
|
4244
|
-
"pathPattern": "^/2/(?:oauth2/(?:token|revoke)|users/me)
|
|
4359
|
+
"pathPattern": "^/2/(?:oauth2/(?:token|revoke)|users/me)/?$|^/oauth/(?:request_token|authorize|authenticate|access_token)/?$|^/1\\.1/oauth/invalidate_token(?:\\.json)?/?$|^/_twin/assets/consent\\.(?:js|css)$"
|
|
4245
4360
|
},
|
|
4246
4361
|
{
|
|
4247
4362
|
"host": "api.twitter.com",
|
|
4248
|
-
"pathPattern": "^/2/(?:oauth2/(?:token|revoke)|users/me)
|
|
4363
|
+
"pathPattern": "^/2/(?:oauth2/(?:token|revoke)|users/me)/?$|^/oauth/(?:request_token|authorize|authenticate|access_token)/?$|^/1\\.1/oauth/invalidate_token(?:\\.json)?/?$|^/_twin/assets/consent\\.(?:js|css)$"
|
|
4249
4364
|
}
|
|
4250
4365
|
],
|
|
4251
4366
|
"archetype": "crud",
|
|
@@ -4263,7 +4378,11 @@
|
|
|
4263
4378
|
"access_token",
|
|
4264
4379
|
"refresh_token",
|
|
4265
4380
|
"grant",
|
|
4266
|
-
"rate_window"
|
|
4381
|
+
"rate_window",
|
|
4382
|
+
"oauth1_app",
|
|
4383
|
+
"oauth1_request_token",
|
|
4384
|
+
"oauth1_token",
|
|
4385
|
+
"oauth1_grant"
|
|
4267
4386
|
],
|
|
4268
4387
|
"serveExport": "createXIdentityTwinServer"
|
|
4269
4388
|
},
|
package/inject.cjs
CHANGED
|
@@ -678,7 +678,7 @@ function fakeClientRequest(origin, method, fullPath, headers, callback) {
|
|
|
678
678
|
|
|
679
679
|
// --- unclaimed path on a twinned shared host: refuse LOUDLY, never mis-route -----------------
|
|
680
680
|
// The Cal.com incident class: a host two vendors share (www.googleapis.com) is twinned, the
|
|
681
|
-
// request's PATH belongs to neither twin (`/
|
|
681
|
+
// request's PATH belongs to neither twin (`/drive/v3/*` — Google Drive has no pack; `/calendar/v3/*` was the case until the googlecalendar pack), and
|
|
682
682
|
// the old catch-all matcher answered it from the WRONG pack with a vendor-shaped 404: fails open
|
|
683
683
|
// AND plausible. The operator declared this host virtualized, so the traffic must not leak to the
|
|
684
684
|
// real vendor either. The only honest outcome is a refusal that NAMES the situation.
|
|
@@ -1092,6 +1092,51 @@ function restore() {
|
|
|
1092
1092
|
|
|
1093
1093
|
module.exports = { install, restore, readMap, resolveTwin, twinUrlVendor, twinnedHostVendors, unclaimedTwinnedHostPathMessage, appForwardHeaders, VENDOR_HOSTS };
|
|
1094
1094
|
|
|
1095
|
+
// WORLD TIME: an application process keeps the World's time, as its twins do (worldNow(), world-clock.ts). A World's
|
|
1096
|
+
// clock (TWIN_WORLD_CLOCK_FILE, one of world-clock.cjs's two forms) is read at most once a second, so a clock moved
|
|
1097
|
+
// while the application runs applies within a second; with no clock set, the process keeps the machine's time.
|
|
1098
|
+
// `Date` is the machine's own, wrapped so its `now()` and argument-less construction read the World's time; every other use
|
|
1099
|
+
// (a parsed date, `Date.UTC`, arithmetic, timers, `performance.now()`) is unchanged. The machine's own `Date.now` is
|
|
1100
|
+
// left under a global symbol, so the kernel's worldNow() applies a running clock's offset once in a process like this.
|
|
1101
|
+
// Without this, time moved in a World reaches its twins and not its application: a token the twins sign by the
|
|
1102
|
+
// World's time is refused by the application's (dub-stress findings row 107).
|
|
1103
|
+
function installWorldTime() {
|
|
1104
|
+
const file = process.env.TWIN_WORLD_CLOCK_FILE;
|
|
1105
|
+
if (!file || globalThis[Symbol.for('volter.machineDateNow')]) return;
|
|
1106
|
+
const fs = require('fs');
|
|
1107
|
+
const { parseClock, clockNowMs } = require('./world-clock.cjs');
|
|
1108
|
+
const MachineDate = Date;
|
|
1109
|
+
const machineNow = MachineDate.now.bind(MachineDate);
|
|
1110
|
+
let held = { checkedAt: -Infinity, mtimeMs: -1, clock: null };
|
|
1111
|
+
const clock = () => {
|
|
1112
|
+
const wall = machineNow();
|
|
1113
|
+
if (wall - held.checkedAt < 1000) return held.clock;
|
|
1114
|
+
held.checkedAt = wall;
|
|
1115
|
+
try {
|
|
1116
|
+
const mtimeMs = fs.statSync(file).mtimeMs;
|
|
1117
|
+
if (mtimeMs !== held.mtimeMs) held = { checkedAt: wall, mtimeMs, clock: parseClock(fs.readFileSync(file, 'utf8')) };
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
// no clock: the machine's time; a clock caught mid-write keeps the last one read until the next look
|
|
1120
|
+
if (error && error.code === 'ENOENT') held = { checkedAt: wall, mtimeMs: -1, clock: null };
|
|
1121
|
+
}
|
|
1122
|
+
return held.clock;
|
|
1123
|
+
};
|
|
1124
|
+
const worldMs = () => { const c = clock(); return c ? clockNowMs(c, machineNow()) : machineNow(); };
|
|
1125
|
+
// the machine's own Date, wrapped rather than subclassed, so identity holds everywhere: dates Node makes itself
|
|
1126
|
+
// (a file's mtime, structuredClone, a worker's message) are `instanceof Date`, `d.constructor === Date` stays true,
|
|
1127
|
+
// and Date.prototype is the machine's; only now() and argument-less construction read the World's time
|
|
1128
|
+
const WorldDate = new Proxy(MachineDate, {
|
|
1129
|
+
construct: (target, args, newTarget) => Reflect.construct(target, args.length ? args : [worldMs()], newTarget === WorldDate ? target : newTarget),
|
|
1130
|
+
// called as a function, Date() answers the current time's string, as the machine's does
|
|
1131
|
+
apply: () => new MachineDate(worldMs()).toString(),
|
|
1132
|
+
});
|
|
1133
|
+
Object.defineProperty(globalThis, Symbol.for('volter.machineDateNow'), { value: machineNow, enumerable: false });
|
|
1134
|
+
MachineDate.now = worldMs;
|
|
1135
|
+
Object.defineProperty(MachineDate.prototype, 'constructor', { value: WorldDate, writable: true, configurable: true, enumerable: false });
|
|
1136
|
+
globalThis.Date = WorldDate;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1095
1139
|
// Auto-install as a preload (`--require`); strict refusal also applies without twin URLs.
|
|
1096
1140
|
install();
|
|
1141
|
+
installWorldTime();
|
|
1097
1142
|
installPrismaAdapterShim();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
"network-policy.d.cts",
|
|
30
30
|
"app-route.cjs",
|
|
31
31
|
"vendor-hosts.cjs",
|
|
32
|
+
"world-clock.cjs",
|
|
33
|
+
"world-clock.d.cts",
|
|
32
34
|
"app-route.d.cts",
|
|
33
35
|
"generated",
|
|
34
36
|
"src",
|
|
@@ -73,10 +75,18 @@
|
|
|
73
75
|
"default": "./app-route.cjs"
|
|
74
76
|
},
|
|
75
77
|
"./vendor-hosts": "./vendor-hosts.cjs",
|
|
78
|
+
"./world-clock": {
|
|
79
|
+
"types": "./world-clock.d.cts",
|
|
80
|
+
"default": "./world-clock.cjs"
|
|
81
|
+
},
|
|
76
82
|
"./generated/pack-facts.json": "./generated/pack-facts.json",
|
|
77
83
|
"./lifecycle": {
|
|
78
84
|
"types": "./dist/src/lifecycle.d.ts",
|
|
79
85
|
"default": "./dist/src/lifecycle.js"
|
|
86
|
+
},
|
|
87
|
+
"./redis": {
|
|
88
|
+
"types": "./dist/src/redis/index.d.ts",
|
|
89
|
+
"default": "./dist/src/redis/index.js"
|
|
80
90
|
}
|
|
81
91
|
},
|
|
82
92
|
"scripts": {
|
package/src/actions.ts
CHANGED
|
@@ -17,11 +17,13 @@ import { randomUUID } from 'node:crypto';
|
|
|
17
17
|
import { dirname, join } from 'node:path';
|
|
18
18
|
import { canonicalJson, hashFieldValue, subjectKey } from './hash.ts';
|
|
19
19
|
import type { SubjectFields } from './hash.ts';
|
|
20
|
-
import { appendDurable, eventsLockPath, projectionLockPath, twinLog, withFileLock, worldPaths } from './storage.ts';
|
|
20
|
+
import { appendDurable, eventsLockPath, ownerStoreRoots, projectionLockPath, twinLog, withFileLock, withoutIdentityClaim, worldPaths } from './storage.ts';
|
|
21
21
|
import { landAsPlaceholder, placeholderPullActive } from './placeholder-remote.ts';
|
|
22
|
-
import { aliasesFrom, dropCheckpoint, landedCopy, landedIds, parentEntries, readTree, type Receipt } from './log.ts';
|
|
22
|
+
import { aliasesFrom, dropCheckpoint, landedCopy, landedIds, parentEntries, readTree, treeStamp, type Receipt } from './log.ts';
|
|
23
23
|
import { WorldServiceEventSchema } from './schemas.ts';
|
|
24
|
+
import { refuseReadOnlyWrite } from './request-scope.ts';
|
|
24
25
|
import { getActiveWorldStore } from './world-store.ts';
|
|
26
|
+
import { currentTraceparent } from './trace-context.ts';
|
|
25
27
|
import type { WorldServiceEvent } from './types.ts';
|
|
26
28
|
import type { TwinResource } from './serve.ts';
|
|
27
29
|
|
|
@@ -94,6 +96,9 @@ export type TwinAction = {
|
|
|
94
96
|
* alone, with no dependence on actionId/pushId naming conventions.
|
|
95
97
|
*/
|
|
96
98
|
correlationId?: string;
|
|
99
|
+
/** The W3C `traceparent` of the request that caused this action, when it carried a valid one
|
|
100
|
+
* (trace-context.ts). Authoring metadata like correlationId: excluded from replay identity. */
|
|
101
|
+
traceparent?: string;
|
|
97
102
|
/**
|
|
98
103
|
* The action's MERGE BASE against the remote (runtime contract R14, non-fast-forward
|
|
99
104
|
* rule): per touched subject, the observed mirror's remote ref (shadow.ts remoteRefs)
|
|
@@ -129,7 +134,10 @@ function withCorrelationId(action: TwinAction): TwinAction {
|
|
|
129
134
|
// The wire's request id (the addressed service stamps every response with one and threads it
|
|
130
135
|
// into the handler's async context) is the correlation when the caller supplies none — the
|
|
131
136
|
// join from a request on the wire to the action rows it caused, with no pack involved.
|
|
132
|
-
|
|
137
|
+
const correlated = action.correlationId ? action : { ...action, correlationId: currentCorrelationId() ?? randomUUID() };
|
|
138
|
+
// the request's W3C trace context (trace-context.ts) is stamped the same way, from the handler's async context
|
|
139
|
+
const traceparent = correlated.traceparent === undefined ? currentTraceparent() : undefined;
|
|
140
|
+
return traceparent ? { ...correlated, traceparent } : correlated;
|
|
133
141
|
}
|
|
134
142
|
|
|
135
143
|
/** Subjects a `set` action touches: its own subject, every projection resource, and every
|
|
@@ -200,7 +208,7 @@ function landIfPlaceholderPull(action: TwinAction, root?: string): TwinAction |
|
|
|
200
208
|
}
|
|
201
209
|
|
|
202
210
|
function replayBody(action: TwinAction): string {
|
|
203
|
-
const { correlationId: _correlationId, ...stampedBody } = action;
|
|
211
|
+
const { correlationId: _correlationId, traceparent: _traceparent, ...stampedBody } = action;
|
|
204
212
|
// A confirmation is content-addressed by the observed event ids. Its timestamp is observation
|
|
205
213
|
// metadata, just like the occurredAt/observedAt values that appendEvent ignores when replaying
|
|
206
214
|
// one content-addressed event. Two reconcilers confirming the same bytes at different wall-clock
|
|
@@ -233,6 +241,7 @@ export function runWithCorrelationId<T>(id: string, fn: () => T): T { return cor
|
|
|
233
241
|
export function currentCorrelationId(): string | undefined { return correlationScope().getStore(); }
|
|
234
242
|
|
|
235
243
|
export function appendAction(action: TwinAction, root?: string): TwinAction {
|
|
244
|
+
refuseReadOnlyWrite(action.service); // a read-only request writes nothing (request-scope.ts)
|
|
236
245
|
// Evaluate and append under the service projection and actions locks. A precondition is a
|
|
237
246
|
// compare-and-set, not an advisory validation: checking outside either lock would let another
|
|
238
247
|
// writer invalidate it before this action lands. The shadow basis is stamped under the same
|
|
@@ -252,6 +261,7 @@ export function appendAction(action: TwinAction, root?: string): TwinAction {
|
|
|
252
261
|
* atomic across processes (two concurrent identical writes converge to ONE action; distinct
|
|
253
262
|
* writes both land). A reused id with different content fails loudly. */
|
|
254
263
|
export function appendActionIfAbsent(action: TwinAction, root?: string): { action: TwinAction; appended: boolean; placeholder?: true } {
|
|
264
|
+
refuseReadOnlyWrite(action.service);
|
|
255
265
|
return withFileLock(projectionLock(action.service, root), () => withFileLock(actionsLock(action.service, root), () => {
|
|
256
266
|
const identified = withCorrelationId(action);
|
|
257
267
|
// An exact retry is already committed. Resolve it before re-evaluating author-time
|
|
@@ -285,6 +295,7 @@ export function appendActionIfAbsent(action: TwinAction, root?: string): { actio
|
|
|
285
295
|
* the same ordinals, which is what serve-path determinism requires.
|
|
286
296
|
*/
|
|
287
297
|
export function appendActionOccurrence(base: TwinAction, root?: string): { action: TwinAction; appended: boolean; placeholder?: true } {
|
|
298
|
+
refuseReadOnlyWrite(base.service);
|
|
288
299
|
return withFileLock(projectionLock(base.service, root), () => withFileLock(actionsLock(base.service, root), () => {
|
|
289
300
|
const stamped = occurrenceOf(base, actionIds(base.service, root), root);
|
|
290
301
|
assertPreconditions(stamped, root);
|
|
@@ -345,6 +356,7 @@ export function decideAndAppendAction<T>(
|
|
|
345
356
|
const resources = projectResources(service, root);
|
|
346
357
|
const decision = decide(resources);
|
|
347
358
|
if (decision.kind === 'skip') return { value: decision.value, appended: false };
|
|
359
|
+
refuseReadOnlyWrite(service); // a decision to write is the write a read-only request may not make
|
|
348
360
|
if (decision.action.service !== service) {
|
|
349
361
|
throw new Error(`Atomic action service mismatch: expected ${service}, got ${decision.action.service}`);
|
|
350
362
|
}
|
|
@@ -439,6 +451,57 @@ export function projectResources(service: string, root?: string, opts: { until?:
|
|
|
439
451
|
return readTree(service, root, opts);
|
|
440
452
|
}
|
|
441
453
|
|
|
454
|
+
/** A read of another pack's store that cannot choose: the subject it looks for (or, with none named, the store itself)
|
|
455
|
+
* is held by more than one service of the World. A reader answers it as the vendor answers a credential it cannot
|
|
456
|
+
* resolve; it is never a server error. */
|
|
457
|
+
export class OwnerStoreAmbiguousError extends Error {
|
|
458
|
+
constructor(owner: string, roots: string[], subject?: { type: string; id: string }) {
|
|
459
|
+
super(`${subject ? `${subject.type} ${subject.id} of ` : ''}${owner}'s store is held by more than one service of this World (${roots.join(', ')}); a cross-pack read cannot choose between them`);
|
|
460
|
+
this.name = 'OwnerStoreAmbiguousError';
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** One owner store as a cross-pack reader sees it: its rows (frozen, shared by every read until the store changes) and
|
|
465
|
+
* an index by `type:id`. Memoized per (owner, root) on the store's `treeStamp`, so a reader resolving a token on every
|
|
466
|
+
* request folds and copies the owner's tree only when the owner has written since. */
|
|
467
|
+
type OwnerIndex = { stamp: string; rows: readonly TwinResource[]; byKey: Map<string, TwinResource> };
|
|
468
|
+
const ownerIndexes = new Map<string, OwnerIndex>();
|
|
469
|
+
function ownerIndex(owner: string, root: string): OwnerIndex {
|
|
470
|
+
const key = `${owner}\0${root}`;
|
|
471
|
+
const stamp = treeStamp(owner, root);
|
|
472
|
+
const held = ownerIndexes.get(key);
|
|
473
|
+
if (held && held.stamp === stamp) return held;
|
|
474
|
+
const rows = readTree(owner, root).map((r) => Object.freeze(r));
|
|
475
|
+
const index: OwnerIndex = { stamp, rows: Object.freeze(rows), byKey: new Map(rows.map((r) => [`${r.type}:${r.id}`, r])) };
|
|
476
|
+
ownerIndexes.set(key, index);
|
|
477
|
+
return index;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* ANOTHER pack's rows, read by contract (architecture A3: one vendor's store split across two packs, xidentity's tokens
|
|
482
|
+
* read by x, googleoauth's by googlecalendar; scripts/architecture.test.ts holds the declared reader-owner pairs): the
|
|
483
|
+
* owner's tree, from wherever the World keeps the owner's store (`ownerStoreRoots`). Read only: the rows are frozen and
|
|
484
|
+
* shared. It claims no journal identity (the twin answering is the reader).
|
|
485
|
+
*
|
|
486
|
+
* With `subject`, the store that holds that subject (the token a request presents): `[]` when none does, and
|
|
487
|
+
* `OwnerStoreAmbiguousError` only when more than one does, so a second service running the owning pack never breaks a
|
|
488
|
+
* lookup of what only one of them issued. Without it, the one store there is, and `OwnerStoreAmbiguousError` when the
|
|
489
|
+
* World holds two.
|
|
490
|
+
*/
|
|
491
|
+
export function projectOwnerResources(owner: string, root?: string, subject?: { type: string; id: string }): readonly TwinResource[] {
|
|
492
|
+
return withoutIdentityClaim(() => {
|
|
493
|
+
const roots = ownerStoreRoots(owner, root);
|
|
494
|
+
if (!subject) {
|
|
495
|
+
if (roots.length > 1) throw new OwnerStoreAmbiguousError(owner, roots);
|
|
496
|
+
return roots[0] ? ownerIndex(owner, roots[0]).rows : [];
|
|
497
|
+
}
|
|
498
|
+
const key = `${subject.type}:${subject.id}`;
|
|
499
|
+
const holding = roots.filter((r) => ownerIndex(owner, r).byKey.has(key));
|
|
500
|
+
if (holding.length > 1) throw new OwnerStoreAmbiguousError(owner, holding, subject);
|
|
501
|
+
return holding[0] ? ownerIndex(owner, holding[0]).rows : [];
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
442
505
|
/** The local → vendor id aliases the landed copies carry: a read by the id a caller was handed before
|
|
443
506
|
* its write was performed resolves to the row the vendor now owns. */
|
|
444
507
|
export function subjectAliases(service: string, root?: string): Map<string, string> {
|
|
@@ -474,6 +537,7 @@ export function confirmAction(opts: {
|
|
|
474
537
|
occurredAt: string;
|
|
475
538
|
root?: string;
|
|
476
539
|
}): { observedEventId: string; observedEventIds: string[] } {
|
|
540
|
+
refuseReadOnlyWrite(opts.service);
|
|
477
541
|
// LANDING (log.ts): the entry is copied to the parent log with its receipt — no confirm row, no
|
|
478
542
|
// suppression; the fold skips a branch entry the parent holds.
|
|
479
543
|
const paths = worldPaths(opts.service, opts.root);
|
|
@@ -510,6 +574,7 @@ export type RevertOutcome =
|
|
|
510
574
|
* Only `set` rows are revertable — reverting a revert or a confirm is a category error.
|
|
511
575
|
*/
|
|
512
576
|
export function revertAction(opts: { service: string; actionId: string; occurredAt: string; root?: string }): RevertOutcome {
|
|
577
|
+
refuseReadOnlyWrite(opts.service);
|
|
513
578
|
return withFileLock(projectionLock(opts.service, opts.root), () => withFileLock(actionsLock(opts.service, opts.root), (): RevertOutcome => {
|
|
514
579
|
const all = listActions(opts.service, opts.root);
|
|
515
580
|
const target = all.find((a) => a.id === opts.actionId);
|
package/src/blob-store.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync,
|
|
|
23
23
|
import { dirname, join } from 'node:path';
|
|
24
24
|
import { volterHome } from './volter-home.ts';
|
|
25
25
|
import { sharedBlobIndex } from './shared-blob-index.ts';
|
|
26
|
+
import { refuseReadOnlyWrite, writesRefused } from './request-scope.ts';
|
|
26
27
|
|
|
27
28
|
export interface BlobStore {
|
|
28
29
|
/** Bytes at `key`, or null when absent. Never throws for a missing key. */
|
|
@@ -165,8 +166,21 @@ export function blobDigest(bytes: Uint8Array): string {
|
|
|
165
166
|
// (or a test) swaps in its adapter for the scope of a request.
|
|
166
167
|
let activeBlobStore: BlobStore = new FsBlobStore();
|
|
167
168
|
|
|
169
|
+
/** The active store; under a read-only request's refusing scope (request-scope.ts), one that refuses
|
|
170
|
+
* to store or remove bytes before any reach it, as the log's appenders refuse (a git push's objects,
|
|
171
|
+
* an upload's body), while reads pass. */
|
|
168
172
|
export function getActiveBlobStore(): BlobStore {
|
|
169
|
-
return activeBlobStore;
|
|
173
|
+
return writesRefused() ? readOnlyBlobs(activeBlobStore) : activeBlobStore;
|
|
174
|
+
}
|
|
175
|
+
function readOnlyBlobs(store: BlobStore): BlobStore {
|
|
176
|
+
return {
|
|
177
|
+
get: (key) => store.get(key),
|
|
178
|
+
exists: (key) => store.exists(key),
|
|
179
|
+
list: (prefix) => store.list(prefix),
|
|
180
|
+
size: (key) => store.size(key),
|
|
181
|
+
put: async () => { refuseReadOnlyWrite('blobs'); },
|
|
182
|
+
remove: async () => { refuseReadOnlyWrite('blobs'); },
|
|
183
|
+
};
|
|
170
184
|
}
|
|
171
185
|
|
|
172
186
|
export function setActiveBlobStore(store: BlobStore): BlobStore {
|
package/src/client-bundle.ts
CHANGED
|
@@ -5,9 +5,22 @@
|
|
|
5
5
|
// is retried on the next call. Nothing here runs at module scope.
|
|
6
6
|
import { existsSync, readFileSync } from 'node:fs';
|
|
7
7
|
|
|
8
|
+
/** A file URL's path on this machine, without `node:url` (the modules that call it ride into browser
|
|
9
|
+
* bundles): percent-escapes decoded, and without the slash `URL.pathname` puts before a Windows drive
|
|
10
|
+
* (`/C:/…`), which no file API opens. `new URL('../client/x.css', import.meta.url).pathname` is this. */
|
|
11
|
+
export function filePathOf(url: URL): string {
|
|
12
|
+
const path = decodeURIComponent(url.pathname);
|
|
13
|
+
return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path;
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
const bundles = new Map<string, Promise<string>>();
|
|
9
17
|
type BunBuild = { build: (o: { entrypoints: string[]; target: 'browser'; minify: boolean }) => Promise<{ success: boolean; logs: Array<{ message: string }>; outputs: Array<{ text: () => Promise<string> }> }> };
|
|
10
18
|
|
|
19
|
+
/** A browser has no `process`. A client that imports shared helpers from its pack's server-side module can pull Node
|
|
20
|
+
* code into its bundle (util.deprecate, crypto shims) that reads it; without a shim the page throws before it renders
|
|
21
|
+
* (newer Bun no longer adds one to a browser build). A minimal one, the page's own, before the bundle. */
|
|
22
|
+
const PROCESS_SHIM = 'globalThis.process=globalThis.process||{env:{NODE_ENV:"production"},browser:true,argv:[],versions:{},platform:"browser",emit:function(){return false},on:function(){},nextTick:function(f){var a=[].slice.call(arguments,1);queueMicrotask(function(){f.apply(null,a)})}};\n';
|
|
23
|
+
|
|
11
24
|
export function bundleClient(entry: string): Promise<string> {
|
|
12
25
|
let pending = bundles.get(entry);
|
|
13
26
|
if (!pending) {
|
|
@@ -15,12 +28,12 @@ export function bundleClient(entry: string): Promise<string> {
|
|
|
15
28
|
pending = (bun && typeof bun.build === 'function'
|
|
16
29
|
? (bun as BunBuild).build({ entrypoints: [entry], target: 'browser', minify: true }).then(async (result) => {
|
|
17
30
|
if (!result.success) throw new Error(result.logs.map((l) => l.message).join('\n') || `client build failed: ${entry}`);
|
|
18
|
-
return result.outputs[0]!.text();
|
|
31
|
+
return PROCESS_SHIM + await result.outputs[0]!.text();
|
|
19
32
|
})
|
|
20
33
|
: Promise.resolve().then(() => {
|
|
21
34
|
const prebuilt = entry.replace(/\.tsx?$/, '.bundle.js');
|
|
22
35
|
if (!existsSync(prebuilt)) throw new Error(`client bundle missing at ${prebuilt} — the package's \`build\` writes it (scripts/publish/build.mjs); Node serves the prebuilt client`);
|
|
23
|
-
return readFileSync(prebuilt, 'utf8');
|
|
36
|
+
return PROCESS_SHIM + readFileSync(prebuilt, 'utf8');
|
|
24
37
|
}))
|
|
25
38
|
.catch((error) => { bundles.delete(entry); throw error; });
|
|
26
39
|
bundles.set(entry, pending);
|
package/src/derived-core.ts
CHANGED
|
@@ -14,7 +14,8 @@ import { hashFieldValue } from './hash.ts';
|
|
|
14
14
|
import { resolveSubjectId, subjectAliases } from './actions.ts';
|
|
15
15
|
import { packReferences } from './references.ts';
|
|
16
16
|
import type { SubjectFields } from './hash.ts';
|
|
17
|
-
import { twinPublicBase } from './twin-fetch.ts';
|
|
17
|
+
import { twinPublicBase, withRequestScopes } from './twin-fetch.ts';
|
|
18
|
+
import { isReadOnlyRequest } from './request-scope.ts';
|
|
18
19
|
import type { DerivedCall, DerivedCoreOutcome, DerivedHandler, DerivedOperation } from './derived.ts';
|
|
19
20
|
|
|
20
21
|
// ── the manifest ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -965,8 +966,10 @@ export function crossCutting(m: DerivedManifest, opts: CoreScope & { readOnly?:
|
|
|
965
966
|
}
|
|
966
967
|
}
|
|
967
968
|
// a read-only twin refuses writes: what the operation does, not the HTTP verb it came by (an RPC
|
|
968
|
-
// wire POSTs its reads)
|
|
969
|
-
|
|
969
|
+
// wire POSTs its reads). A read-only REQUEST (x-volter-read-only, request-scope.ts) is refused the
|
|
970
|
+
// same way, up front, on a writable twin.
|
|
971
|
+
const readOnly = opts.readOnly || isReadOnlyRequest(request);
|
|
972
|
+
if (readOnly && !['retrieve', 'list', 'computed'].includes(call.operation.class) && !m.reads?.includes(call.operation.id)) return vendorError(m, m.readOnly);
|
|
970
973
|
// a body labelled JSON that does not parse is the vendor's refusal, never a crash of the twin
|
|
971
974
|
if ((request.headers.get('content-type') ?? '').includes('json') || m.body.json === 'always') {
|
|
972
975
|
const text = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.clone().text().catch(() => '');
|
|
@@ -988,6 +991,8 @@ export function crossCutting(m: DerivedManifest, opts: CoreScope & { readOnly?:
|
|
|
988
991
|
// a streamed answer replays as the stream it was; a record kept before text was kept replays its JSON
|
|
989
992
|
return answer.text !== undefined ? new Response(answer.text, { status: answer.status, headers: { 'content-type': answer.contentType ?? 'application/json' } }) : Response.json(answer.body, { status: answer.status });
|
|
990
993
|
}
|
|
994
|
+
// a read-only request replays a stored answer but records none: recording is a write
|
|
995
|
+
if (readOnly) return next();
|
|
991
996
|
const response = await next();
|
|
992
997
|
if (m.idempotency.onlySuccess && (response.status < 200 || response.status >= 300)) return response;
|
|
993
998
|
const contentType = response.headers.get('content-type') ?? 'application/json';
|
|
@@ -997,6 +1002,15 @@ export function crossCutting(m: DerivedManifest, opts: CoreScope & { readOnly?:
|
|
|
997
1002
|
}
|
|
998
1003
|
}
|
|
999
1004
|
|
|
1005
|
+
/** A derived pack's whole fetch behind the kernel's read scope (twin-fetch.ts withRequestScopes): a
|
|
1006
|
+
* read-only request's writes are refused at the write seam — the ones `crossCutting` cannot see
|
|
1007
|
+
* coming (another wire's mutation, a door, a screen) — and answered with the vendor's own read-only
|
|
1008
|
+
* error; `GET /twin` advertises `requestScopes`. The pack's catch-up runs under `runAsVendorMove`
|
|
1009
|
+
* (request-scope.ts), so time's moves still land when a read-only request is what arrives. */
|
|
1010
|
+
export function derivedRequestScopes<F extends (request: Request) => Promise<Response>>(m: DerivedManifest, fetch: F): F {
|
|
1011
|
+
return withRequestScopes(fetch, { refuse: () => vendorError(m, m.readOnly) });
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1000
1014
|
/** A semantics context for a request no surface operation names: another wire's (GraphQL) resolvers
|
|
1001
1015
|
* get the same interface as a handler, named by the operation id the wire gives. */
|
|
1002
1016
|
export function semanticsContext(m: DerivedManifest, request: Request, operation: DerivedOperation, scope: CoreScope = {}): Promise<SemanticsContext> {
|