@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.
Files changed (66) hide show
  1. package/app-route.cjs +95 -6
  2. package/app-route.d.cts +2 -1
  3. package/dist/app-route.cjs +95 -6
  4. package/dist/app-route.d.cts +2 -1
  5. package/dist/generated/pack-facts.json +127 -8
  6. package/dist/inject.cjs +46 -1
  7. package/dist/src/actions.d.ts +27 -0
  8. package/dist/src/actions.js +62 -4
  9. package/dist/src/blob-store.d.ts +3 -0
  10. package/dist/src/blob-store.js +15 -1
  11. package/dist/src/client-bundle.d.ts +4 -0
  12. package/dist/src/client-bundle.js +13 -2
  13. package/dist/src/derived-core.d.ts +6 -0
  14. package/dist/src/derived-core.js +17 -3
  15. package/dist/src/derived.js +5 -2
  16. package/dist/src/git/refs.js +5 -3
  17. package/dist/src/head.js +12 -2
  18. package/dist/src/index.d.ts +8 -5
  19. package/dist/src/index.js +9 -5
  20. package/dist/src/log.d.ts +2 -0
  21. package/dist/src/packRegistry.d.ts +8 -6
  22. package/dist/src/redis/engine.d.ts +308 -0
  23. package/dist/src/redis/engine.js +1663 -0
  24. package/dist/src/redis/index.d.ts +2 -0
  25. package/dist/src/redis/index.js +6 -0
  26. package/dist/src/redis/lua.d.ts +153 -0
  27. package/dist/src/redis/lua.js +1373 -0
  28. package/dist/src/request-scope.d.ts +28 -0
  29. package/dist/src/request-scope.js +70 -0
  30. package/dist/src/storage.d.ts +22 -1
  31. package/dist/src/storage.js +57 -1
  32. package/dist/src/trace-context.d.ts +31 -0
  33. package/dist/src/trace-context.js +78 -0
  34. package/dist/src/twin-fetch.d.ts +16 -0
  35. package/dist/src/twin-fetch.js +66 -3
  36. package/dist/src/world-clock.d.ts +2 -2
  37. package/dist/src/world-clock.js +18 -17
  38. package/dist/src/world-store.js +9 -0
  39. package/dist/vendor-hosts.cjs +2 -2
  40. package/dist/world-clock.cjs +40 -0
  41. package/dist/world-clock.d.cts +4 -0
  42. package/generated/pack-facts.json +127 -8
  43. package/inject.cjs +46 -1
  44. package/package.json +11 -1
  45. package/src/actions.ts +69 -4
  46. package/src/blob-store.ts +15 -1
  47. package/src/client-bundle.ts +15 -2
  48. package/src/derived-core.ts +17 -3
  49. package/src/derived.ts +5 -2
  50. package/src/git/refs.ts +5 -3
  51. package/src/head.ts +11 -2
  52. package/src/index.ts +11 -3
  53. package/src/log.ts +2 -0
  54. package/src/packRegistry.ts +8 -6
  55. package/src/redis/engine.ts +1468 -0
  56. package/src/redis/index.ts +6 -0
  57. package/src/redis/lua.ts +1250 -0
  58. package/src/request-scope.ts +74 -0
  59. package/src/storage.ts +54 -2
  60. package/src/trace-context.ts +87 -0
  61. package/src/twin-fetch.ts +69 -3
  62. package/src/world-clock.ts +19 -16
  63. package/src/world-store.ts +9 -0
  64. package/vendor-hosts.cjs +2 -2
  65. package/world-clock.cjs +40 -0
  66. 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 (`/calendar/v3/*` — Google Calendar has no pack), and
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.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
- return action.correlationId ? action : { ...action, correlationId: currentCorrelationId() ?? randomUUID() };
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 {
@@ -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);
@@ -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
- if (opts.readOnly && !['retrieve', 'list', 'computed'].includes(call.operation.class) && !m.reads?.includes(call.operation.id)) return vendorError(m, m.readOnly);
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> {