@volter/world-core 3.0.153 → 3.0.155

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.
@@ -270,6 +270,9 @@
270
270
  {
271
271
  "host": "api.clerk.com"
272
272
  },
273
+ {
274
+ "host": "dashboard.clerk.com"
275
+ },
273
276
  {
274
277
  "suffix": ".clerk.accounts.dev"
275
278
  },
@@ -1309,8 +1312,7 @@
1309
1312
  "host": "api.linear.app"
1310
1313
  },
1311
1314
  {
1312
- "host": "linear.app",
1313
- "pathPattern": "^/oauth/"
1315
+ "host": "linear.app"
1314
1316
  }
1315
1317
  ],
1316
1318
  "archetype": "crud",
@@ -1330,7 +1332,7 @@
1330
1332
  "comment",
1331
1333
  "issue_label"
1332
1334
  ],
1333
- "endpointEnvNone": "Linear uses api.linear.app/graphql and linear.app/oauth/authorize; the World claims the documented hosts",
1335
+ "endpointEnvNone": "Linear uses api.linear.app/graphql and linear.app for its workspace and OAuth authorize page; the World claims those hosts",
1334
1336
  "credentialDoor": {
1335
1337
  "path": "/_twin/local-credentials",
1336
1338
  "body": {},
@@ -2023,6 +2025,10 @@
2023
2025
  },
2024
2026
  {
2025
2027
  "host": "inbound-cdn.resend.com"
2028
+ },
2029
+ {
2030
+ "host": "resend.com",
2031
+ "pathPattern": "^/emails(?:/|$)"
2026
2032
  }
2027
2033
  ],
2028
2034
  "archetype": "crud",
@@ -2179,6 +2185,9 @@
2179
2185
  ]
2180
2186
  },
2181
2187
  "hosts": [
2188
+ {
2189
+ "host": "app.slack.com"
2190
+ },
2182
2191
  {
2183
2192
  "host": "slack.com"
2184
2193
  },
@@ -1,7 +1,8 @@
1
1
  // A page's browser client from its `client/<name>.tsx` entry (the console's, the platform's), runtime-neutral (the
2
- // serve seam's helpers, docs/contributing/architecture.md#the-serve-seam): under Bun a `Bun.build` at serve time; under Node the PREBUILT `<name>.bundle.js` beside the entry, written at pack time
3
- // by scripts/publish/build.mjs into dist/client/. Built once per process and kept; a failed build
4
- // is retried on the next call. Nothing here runs at module scope.
2
+ // serve seam's helpers, docs/contributing/architecture.md#the-serve-seam): serve an adjacent `<name>.bundle.js`
3
+ // under either runtime, written at pack time by scripts/publish/build.mjs into dist/client/. Without one, Bun
4
+ // builds the source at serve time and Node refuses it. Kept once per process; a failed attempt is retried on
5
+ // the next call. Nothing here runs at module scope.
5
6
  import { existsSync, readFileSync } from 'node:fs';
6
7
  /** A file URL's path on this machine, without `node:url` (the modules that call it ride into browser
7
8
  * bundles): percent-escapes decoded, and without the slash `URL.pathname` puts before a Windows drive
@@ -18,19 +19,18 @@ const PROCESS_SHIM = 'globalThis.process=globalThis.process||{env:{NODE_ENV:"pro
18
19
  export function bundleClient(entry) {
19
20
  let pending = bundles.get(entry);
20
21
  if (!pending) {
21
- const bun = globalThis.Bun;
22
- pending = (bun && typeof bun.build === 'function'
23
- ? bun.build({ entrypoints: [entry], target: 'browser', minify: true }).then(async (result) => {
24
- if (!result.success)
25
- throw new Error(result.logs.map((l) => l.message).join('\n') || `client build failed: ${entry}`);
26
- return PROCESS_SHIM + await result.outputs[0].text();
27
- })
28
- : Promise.resolve().then(() => {
29
- const prebuilt = entry.replace(/\.tsx?$/, '.bundle.js');
30
- if (!existsSync(prebuilt))
31
- throw new Error(`client bundle missing at ${prebuilt} — the package's \`build\` writes it (scripts/publish/build.mjs); Node serves the prebuilt client`);
22
+ pending = Promise.resolve().then(async () => {
23
+ const prebuilt = entry.replace(/\.tsx?$/, '.bundle.js');
24
+ if (existsSync(prebuilt))
32
25
  return PROCESS_SHIM + readFileSync(prebuilt, 'utf8');
33
- }))
26
+ const bun = globalThis.Bun;
27
+ if (!bun || typeof bun.build !== 'function')
28
+ throw new Error(`client bundle missing at ${prebuilt} — the package's \`build\` writes it (scripts/publish/build.mjs); Node serves the prebuilt client`);
29
+ const result = await bun.build({ entrypoints: [entry], target: 'browser', minify: true });
30
+ if (!result.success)
31
+ throw new Error(result.logs.map((l) => l.message).join('\n') || `client build failed: ${entry}`);
32
+ return PROCESS_SHIM + await result.outputs[0].text();
33
+ })
34
34
  .catch((error) => { bundles.delete(entry); throw error; });
35
35
  bundles.set(entry, pending);
36
36
  }
@@ -0,0 +1,4 @@
1
+ import type { EventTransport } from './events.js';
2
+ /** Dispatch one newly recorded packet once. Legacy rows, existing attempts and
3
+ * interrupted sending claims never turn into an unrequested second send. */
4
+ export declare function sendRecordedEvent(service: string, deliveryType: string, deliveryId: string, root: string | undefined, transport: EventTransport, trace: Record<string, string>): Promise<void>;
@@ -0,0 +1,70 @@
1
+ // FIRST DECLARED-EVENT OUTCOMES (architecture, "What an author writes, and how").
2
+ // A queued packet is not evidence its receiver answered. Keep the first attempt
3
+ // under the shared action lock; this is not the originating-write outbox or a
4
+ // substitute for a vendor's declared retry/redelivery policy.
5
+ import { applyTwinWriteAtomic } from "./serve.js";
6
+ import { worldNow } from "./world-clock.js";
7
+ const ATTEMPT = '_world_event_delivery_attempt';
8
+ /** Dispatch one newly recorded packet once. Legacy rows, existing attempts and
9
+ * interrupted sending claims never turn into an unrequested second send. */
10
+ export async function sendRecordedEvent(service, deliveryType, deliveryId, root, transport, trace) {
11
+ const { value: claim } = await applyTwinWriteAtomic(service, (resources) => {
12
+ const row = resources.find((r) => r.type === deliveryType && r.id === deliveryId);
13
+ if (!row || row.deleted === true || row.delivery_protocol !== 1 || row.delivery_state !== 'queued'
14
+ || row.attempt_count !== 0 || row.current_attempt !== null)
15
+ return { kind: 'skip', value: undefined };
16
+ const attemptId = `${deliveryType}:${deliveryId}:1`;
17
+ if (resources.some((r) => r.type === ATTEMPT && r.id === attemptId))
18
+ return { kind: 'skip', value: undefined };
19
+ if (typeof row.url !== 'string' || typeof row.body !== 'string'
20
+ || !row.headers || typeof row.headers !== 'object' || Array.isArray(row.headers)
21
+ || Object.values(row.headers).some((v) => typeof v !== 'string')) {
22
+ throw new Error('A retained declared-event packet is incomplete; it cannot be dispatched.');
23
+ }
24
+ const occurredAt = worldNow();
25
+ return { kind: 'write', value: {
26
+ attemptId, url: row.url, body: row.body, headers: { ...row.headers },
27
+ }, write: {
28
+ operation: 'world_event_delivery.claim', subjectType: deliveryType, subjectId: deliveryId,
29
+ fields: { delivery_state: 'sending', attempt_count: 1, current_attempt: attemptId },
30
+ projection: { creates: [{ type: ATTEMPT, id: attemptId, fields: {
31
+ delivery_type: deliveryType, delivery_id: deliveryId, attempt: 1,
32
+ state: 'sending', started_at: Date.parse(occurredAt), finished_at: null, transport_status: null,
33
+ } }] },
34
+ occurredAt, actor: { kind: 'system' },
35
+ } };
36
+ }, root);
37
+ if (!claim)
38
+ return;
39
+ let status = null;
40
+ try {
41
+ const answer = await transport(claim.url, claim.body, { ...claim.headers, ...trace });
42
+ if (typeof answer === 'number' && Number.isInteger(answer) && answer >= 100 && answer <= 599)
43
+ status = answer;
44
+ }
45
+ catch {
46
+ // A thrown request can have reached the receiver. Preserve uncertainty and
47
+ // never copy exception strings, response bodies or credentials into history.
48
+ }
49
+ const state = status === null ? 'unknown' : status >= 200 && status < 300 ? 'succeeded' : 'failed';
50
+ const occurredAt = worldNow(), finishedAt = Date.parse(occurredAt);
51
+ await applyTwinWriteAtomic(service, (resources) => {
52
+ const attempt = resources.find((r) => r.type === ATTEMPT && r.id === claim.attemptId);
53
+ if (!attempt || attempt.deleted === true || attempt.state !== 'sending'
54
+ || attempt.delivery_type !== deliveryType || attempt.delivery_id !== deliveryId)
55
+ return { kind: 'skip', value: undefined };
56
+ const row = resources.find((r) => r.type === deliveryType && r.id === deliveryId);
57
+ const current = row && row.deleted !== true && row.delivery_protocol === 1
58
+ && row.delivery_state === 'sending' && row.current_attempt === claim.attemptId;
59
+ return { kind: 'write', value: undefined, write: {
60
+ operation: 'world_event_delivery.finish', subjectType: ATTEMPT, subjectId: claim.attemptId,
61
+ fields: { state, finished_at: finishedAt, transport_status: status },
62
+ ...(current && row ? { projection: { updates: [{ type: deliveryType, id: deliveryId, fields: {
63
+ delivery_state: state, finished_at: finishedAt, transport_status: status,
64
+ // A future manual attempt must not erase earlier positive acceptance.
65
+ accepted_at: state === 'succeeded' ? row.accepted_at ?? finishedAt : row.accepted_at ?? null,
66
+ } }] } } : {}),
67
+ occurredAt, actor: { kind: 'system' },
68
+ } };
69
+ }, root);
70
+ }
@@ -110,7 +110,8 @@ export type EventsDecl = {
110
110
  envelope: unknown;
111
111
  /** Where the World's endpoints are kept: one kind, or several (GitHub's repository and organization hooks). */
112
112
  endpoints: EndpointsDecl | ReadonlyArray<EndpointsDecl>;
113
- /** The bookkeeping type each delivery is kept as (`_webhook_message`): what a World reads to see what was sent. */
113
+ /** The bookkeeping type each delivery is kept as (`_webhook_message`), otherwise `_world_event_delivery`.
114
+ * Queuing and the first transport attempt/outcome are separate retained facts. */
114
115
  record?: string;
115
116
  };
116
117
  /** An event's further envelope values (`$`-named) the pack computes for a write, from the vendor's own state (the
@@ -131,7 +132,7 @@ export type EventWrite = {
131
132
  * it: the pack's `semantics/events.ts`, under the handler contract's read-only context, for each type the write sends
132
133
  * (a Slack message renders as `message` and as `app_mention`). */
133
134
  export type EventRender<C> = (ctx: C, write: EventWrite, type: string) => Record<string, unknown>;
134
- /** A delivery made: where it went and exactly what was sent. */
135
+ /** A rendered delivery packet. Its transport attempt and acceptance are separate retained facts. */
135
136
  export type EventDelivery = {
136
137
  url: string;
137
138
  body: string;
@@ -163,8 +164,8 @@ export declare function eventTypeOf(decl: Pick<EventsDecl, 'types' | 'known'>, o
163
164
  /** Whether an endpoint's list of event types takes a type: every type when the list is empty, `*` or `<family>.*`
164
165
  * its patterns, else the type itself. */
165
166
  export declare function takesEvent(list: unknown, type: string): boolean;
166
- /** Who watches what the World sends (a walk reading each delivery as its receiver would): told of every delivery as it
167
- * is made, before its transport. Set and restored by the walk, as its World store is; unset, nothing watches. */
167
+ /** Who watches signed packets before dispatch (a walk reading each as its receiver would). This is not evidence of
168
+ * transport or acceptance. Set and restored by the walk, as its World store is; unset, nothing watches. */
168
169
  export type DeliveryObserver = (delivery: EventDelivery & {
169
170
  service: string;
170
171
  }) => void;
@@ -10,6 +10,9 @@ import { createHash, createHmac } from 'node:crypto';
10
10
  import { applyTwinWrite, applyTwinWriteAtomic, twinResources } from "./serve.js";
11
11
  import { equalSecrets as same, ksuidFrom } from "./signing.js";
12
12
  import { deliveryTraceHeaders } from "./trace-context.js";
13
+ import { sendRecordedEvent } from "./event-delivery.js";
14
+ import { getActiveWorldStore, withWorldStore } from "./world-store.js";
15
+ import { worldPaths } from "./storage.js";
13
16
  /** Whether an endpoint takes an event by its `match`: every field of one match (any, when several) equal to the event's
14
17
  * value, or, a list, holding it. */
15
18
  function matches(row, match, filled) {
@@ -328,6 +331,7 @@ async function deliverEventType(service, decl, write, data, transport, values, t
328
331
  const shared = JSON.stringify(envelope);
329
332
  const each = perEndpoint(decl.envelope);
330
333
  const sent = [];
334
+ const recorded = [];
331
335
  for (const [i, { row, kind }] of endpoints.entries()) {
332
336
  const body = each ? JSON.stringify(fillEnvelope(decl.envelope, filled, row)) : shared;
333
337
  const url = String(at(row, kind.url));
@@ -338,22 +342,33 @@ async function deliverEventType(service, decl, write, data, transport, values, t
338
342
  const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq, identity) };
339
343
  sent.push({ url, body, headers });
340
344
  // kept with the write, by an id of the delivery itself (two made at once never take the same one)
341
- if (decl.record !== undefined) {
342
- await applyTwinWrite(service, {
343
- operation: `${decl.record.slice(1)}.record`, subjectType: decl.record, subjectId: `${decl.record.slice(1)}_v1_${seq}`,
344
- fields: { url, body, headers, event_type: type, sent_at: millis }, occurredAt: write.occurredAt, actor: { kind: 'system' },
345
- }, write.root);
346
- }
345
+ const record = decl.record ?? '_world_event_delivery';
346
+ const id = `${record.slice(1)}_v1_${seq}`;
347
+ await applyTwinWrite(service, {
348
+ operation: `${record.slice(1)}.record`, subjectType: record, subjectId: id,
349
+ fields: { url, body, headers, event_type: type, sent_at: millis,
350
+ delivery_protocol: 1, event_id: String(filled.$id),
351
+ endpoint_type: kind.storedAs, endpoint_id: String(row.id),
352
+ delivery_state: 'queued', attempt_count: 0, current_attempt: null,
353
+ transport_status: null, finished_at: null, accepted_at: null,
354
+ }, occurredAt: write.occurredAt, actor: { kind: 'system' },
355
+ }, write.root);
356
+ recorded.push({ type: record, id });
347
357
  }
348
358
  // sent after the write's answer, as a vendor sends it: the caller never waits on its receiver, and a receiver that is
349
359
  // slow or down fails no request
350
360
  // the write's trace is read now, inside its request, and carried on each delivery (W3C trace continuation)
351
361
  const trace = deliveryTraceHeaders();
362
+ // A timer must retain the request's store/root even if another World becomes
363
+ // active before it runs. Transport completion writes to the same retained state.
364
+ const store = getActiveWorldStore(), deliveryRoot = worldPaths(service, write.root).root;
352
365
  for (const d of sent)
353
366
  deliveryObserver?.({ ...d, service });
354
- for (const { url, body: payload, headers } of sent)
367
+ for (const packet of recorded)
355
368
  setTimeout(() => {
356
- void transport(url, payload, { ...headers, ...trace }).catch(() => { });
369
+ // Persistence failure leaves a queued/sending claim for explicit recovery;
370
+ // it must not fail the already-committed vendor write or cause a blind resend.
371
+ void withWorldStore(store, () => sendRecordedEvent(service, packet.type, packet.id, deliveryRoot, transport, trace)).catch(() => { });
357
372
  }, 0);
358
373
  return sent;
359
374
  }
@@ -20,12 +20,16 @@ export declare class GraphqlError extends Error {
20
20
  readonly type?: string | undefined;
21
21
  constructor(message: string, type?: string | undefined);
22
22
  }
23
+ type GraphqlLib = typeof import('graphql');
24
+ export type GraphqlLibraryLoader = () => Promise<GraphqlLib>;
23
25
  /** A GraphQL endpoint over a schema and a pack's part: answers a request's `{query, variables, operationName}`. The
24
26
  * context each resolver receives is made per request (the handler context). `unmodeled` answers a query selecting a
25
27
  * field nothing models; `mutationRefused`, when given, a mutation the World may not make (a read-only twin). */
26
28
  export declare function graphqlEndpoint<C>(options: {
27
29
  sdl: string;
28
30
  part: GraphqlPart<C>;
31
+ /** The generated pack fetch's lazy import, resolving the library from the pack that declares it. */
32
+ loadLibrary?: GraphqlLibraryLoader;
29
33
  context: (request: Request) => Promise<C>;
30
34
  unmodeled: (field: {
31
35
  type: string;
@@ -33,3 +37,4 @@ export declare function graphqlEndpoint<C>(options: {
33
37
  }) => Response;
34
38
  mutationRefused?: (request: Request) => Response | undefined;
35
39
  }): (request: Request) => Promise<Response>;
40
+ export {};
@@ -7,9 +7,9 @@
7
7
  // modeled; any other is answered by the manifest's `graphql.unmodeled`, naming the first field nothing models. An error
8
8
  // a resolver throws (`GraphqlError`) carries its `type`, as GitHub's GraphQL errors do.
9
9
  //
10
- // The `graphql` protocol library is loaded the first time a GraphQL request comes (installed by the pack's package.json,
11
- // as node-postgres is for the managed database): the kernel's own dependencies stay B1's, and importing the kernel
12
- // loads nothing.
10
+ // The generated pack fetch supplies a lazy pack-local loader for the `graphql` protocol library installed by the pack's
11
+ // package.json. Each endpoint caches its library on the first request; callers without a loader keep the kernel-relative
12
+ // lazy import fallback. The kernel's own dependencies stay B1's, and importing the kernel loads nothing.
13
13
  /** An error a resolver answers, with the vendor's `type` for it (GitHub's NOT_FOUND, UNPROCESSABLE). */
14
14
  export class GraphqlError extends Error {
15
15
  type;
@@ -19,8 +19,6 @@ export class GraphqlError extends Error {
19
19
  this.name = 'GraphqlError';
20
20
  }
21
21
  }
22
- let lib;
23
- const graphqlLib = () => (lib ??= import('graphql'));
24
22
  const snake = (s) => s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
25
23
  /** A list as a Relay connection, paged by `first`/`last` and `after`. */
26
24
  function connection(items, args) {
@@ -43,6 +41,8 @@ function connection(items, args) {
43
41
  * context each resolver receives is made per request (the handler context). `unmodeled` answers a query selecting a
44
42
  * field nothing models; `mutationRefused`, when given, a mutation the World may not make (a read-only twin). */
45
43
  export function graphqlEndpoint(options) {
44
+ let lib;
45
+ const loadLibrary = options.loadLibrary ?? (() => import('graphql'));
46
46
  let schema;
47
47
  const { resolvers, aliases, stored } = options.part;
48
48
  // introspection (`__typename`, `__type`, `__schema` and the `__Type`/`__Field`/… meta types) is the vendor's schema
@@ -50,7 +50,7 @@ export function graphqlEndpoint(options) {
50
50
  // before it merges)
51
51
  const modeled = (type, field) => field.startsWith('__') || type.startsWith('__') || `${type}.${field}` in resolvers || !!aliases?.[type]?.[field] || !!stored?.[type]?.includes(field) || !!stored?.[type]?.includes(snake(field));
52
52
  return async (request) => {
53
- const g = await graphqlLib();
53
+ const g = await (lib ??= loadLibrary());
54
54
  schema ??= g.buildSchema(options.sdl, { assumeValidSDL: true });
55
55
  const payload = (await request.clone().json().catch(() => ({})));
56
56
  let document;
@@ -5,7 +5,7 @@ import type { ApplicationStandIn } from './events.js';
5
5
  import { type SocketEngine } from './sockets.js';
6
6
  import type { LiveEngine } from './live/types.js';
7
7
  import type { WebSocketUpgrade } from './serve-http.js';
8
- import { type GraphqlPart } from './graphql-wire.js';
8
+ import { type GraphqlPart, type GraphqlLibraryLoader } from './graphql-wire.js';
9
9
  /** What a pack declares and writes, and nothing it assembles itself. */
10
10
  export type PackParts = {
11
11
  manifest: DerivedManifest;
@@ -39,6 +39,7 @@ export type PackParts = {
39
39
  * state, served at the manifest's `graphql.paths` (graphql-wire.ts). */
40
40
  graphql?: GraphqlPart<HandlerContext> & {
41
41
  sdl: string;
42
+ loadLibrary?: GraphqlLibraryLoader;
42
43
  };
43
44
  /** `semantics/sockets.ts`: each socket's engine, by the id the manifest's `sockets` gives it (sockets.ts). */
44
45
  sockets?: Record<string, SocketEngine<HandlerContext>>;
@@ -160,7 +160,7 @@ export function createPackFetch(parts, options = {}) {
160
160
  // the GraphQL wire over the same state, at the paths the manifest names (graphql-wire.ts)
161
161
  const gql = m.graphql && parts.graphql ? m.graphql : undefined;
162
162
  const graphql = gql && parts.graphql ? graphqlEndpoint({
163
- sdl: parts.graphql.sdl, part: parts.graphql,
163
+ sdl: parts.graphql.sdl, part: parts.graphql, loadLibrary: parts.graphql.loadLibrary,
164
164
  context: async (request) => contextFor(m, { request: request.clone(), operation: { id: 'graphql', method: 'POST', path: gql.paths[0], class: 'action' }, params: {} }, scope),
165
165
  unmodeled: ({ type, field }) => Response.json({ errors: [{ ...(gql.unmodeled.type ? { type: gql.unmodeled.type } : {}), message: gql.unmodeled.message.replace('{field}', field).replace('{type}', type) }] }, { status: gql.unmodeled.status ?? 200 }),
166
166
  mutationRefused: (request) => ((options.readOnly ?? false) || isReadOnlyRequest(request) ? vendorError(m, m.readOnly) : undefined),
@@ -270,6 +270,9 @@
270
270
  {
271
271
  "host": "api.clerk.com"
272
272
  },
273
+ {
274
+ "host": "dashboard.clerk.com"
275
+ },
273
276
  {
274
277
  "suffix": ".clerk.accounts.dev"
275
278
  },
@@ -1309,8 +1312,7 @@
1309
1312
  "host": "api.linear.app"
1310
1313
  },
1311
1314
  {
1312
- "host": "linear.app",
1313
- "pathPattern": "^/oauth/"
1315
+ "host": "linear.app"
1314
1316
  }
1315
1317
  ],
1316
1318
  "archetype": "crud",
@@ -1330,7 +1332,7 @@
1330
1332
  "comment",
1331
1333
  "issue_label"
1332
1334
  ],
1333
- "endpointEnvNone": "Linear uses api.linear.app/graphql and linear.app/oauth/authorize; the World claims the documented hosts",
1335
+ "endpointEnvNone": "Linear uses api.linear.app/graphql and linear.app for its workspace and OAuth authorize page; the World claims those hosts",
1334
1336
  "credentialDoor": {
1335
1337
  "path": "/_twin/local-credentials",
1336
1338
  "body": {},
@@ -2023,6 +2025,10 @@
2023
2025
  },
2024
2026
  {
2025
2027
  "host": "inbound-cdn.resend.com"
2028
+ },
2029
+ {
2030
+ "host": "resend.com",
2031
+ "pathPattern": "^/emails(?:/|$)"
2026
2032
  }
2027
2033
  ],
2028
2034
  "archetype": "crud",
@@ -2179,6 +2185,9 @@
2179
2185
  ]
2180
2186
  },
2181
2187
  "hosts": [
2188
+ {
2189
+ "host": "app.slack.com"
2190
+ },
2182
2191
  {
2183
2192
  "host": "slack.com"
2184
2193
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.153",
3
+ "version": "3.0.155",
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",
@@ -1,7 +1,8 @@
1
1
  // A page's browser client from its `client/<name>.tsx` entry (the console's, the platform's), runtime-neutral (the
2
- // serve seam's helpers, docs/contributing/architecture.md#the-serve-seam): under Bun a `Bun.build` at serve time; under Node the PREBUILT `<name>.bundle.js` beside the entry, written at pack time
3
- // by scripts/publish/build.mjs into dist/client/. Built once per process and kept; a failed build
4
- // is retried on the next call. Nothing here runs at module scope.
2
+ // serve seam's helpers, docs/contributing/architecture.md#the-serve-seam): serve an adjacent `<name>.bundle.js`
3
+ // under either runtime, written at pack time by scripts/publish/build.mjs into dist/client/. Without one, Bun
4
+ // builds the source at serve time and Node refuses it. Kept once per process; a failed attempt is retried on
5
+ // the next call. Nothing here runs at module scope.
5
6
  import { existsSync, readFileSync } from 'node:fs';
6
7
 
7
8
  /** A file URL's path on this machine, without `node:url` (the modules that call it ride into browser
@@ -23,17 +24,15 @@ const PROCESS_SHIM = 'globalThis.process=globalThis.process||{env:{NODE_ENV:"pro
23
24
  export function bundleClient(entry: string): Promise<string> {
24
25
  let pending = bundles.get(entry);
25
26
  if (!pending) {
26
- const bun = (globalThis as { Bun?: Partial<BunBuild> }).Bun;
27
- pending = (bun && typeof bun.build === 'function'
28
- ? (bun as BunBuild).build({ entrypoints: [entry], target: 'browser', minify: true }).then(async (result) => {
29
- if (!result.success) throw new Error(result.logs.map((l) => l.message).join('\n') || `client build failed: ${entry}`);
30
- return PROCESS_SHIM + await result.outputs[0]!.text();
31
- })
32
- : Promise.resolve().then(() => {
33
- const prebuilt = entry.replace(/\.tsx?$/, '.bundle.js');
34
- 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`);
35
- return PROCESS_SHIM + readFileSync(prebuilt, 'utf8');
36
- }))
27
+ pending = Promise.resolve().then(async () => {
28
+ const prebuilt = entry.replace(/\.tsx?$/, '.bundle.js');
29
+ if (existsSync(prebuilt)) return PROCESS_SHIM + readFileSync(prebuilt, 'utf8');
30
+ const bun = (globalThis as { Bun?: Partial<BunBuild> }).Bun;
31
+ if (!bun || typeof bun.build !== 'function') throw new Error(`client bundle missing at ${prebuilt} — the package's \`build\` writes it (scripts/publish/build.mjs); Node serves the prebuilt client`);
32
+ const result = await (bun as BunBuild).build({ entrypoints: [entry], target: 'browser', minify: true });
33
+ if (!result.success) throw new Error(result.logs.map((l) => l.message).join('\n') || `client build failed: ${entry}`);
34
+ return PROCESS_SHIM + await result.outputs[0]!.text();
35
+ })
37
36
  .catch((error) => { bundles.delete(entry); throw error; });
38
37
  bundles.set(entry, pending);
39
38
  }
@@ -0,0 +1,76 @@
1
+ // FIRST DECLARED-EVENT OUTCOMES (architecture, "What an author writes, and how").
2
+ // A queued packet is not evidence its receiver answered. Keep the first attempt
3
+ // under the shared action lock; this is not the originating-write outbox or a
4
+ // substitute for a vendor's declared retry/redelivery policy.
5
+ import { applyTwinWriteAtomic } from './serve.ts';
6
+ import { worldNow } from './world-clock.ts';
7
+ import type { EventDelivery, EventTransport } from './events.ts';
8
+
9
+ const ATTEMPT = '_world_event_delivery_attempt';
10
+ type Claimed = EventDelivery & { attemptId: string };
11
+
12
+ /** Dispatch one newly recorded packet once. Legacy rows, existing attempts and
13
+ * interrupted sending claims never turn into an unrequested second send. */
14
+ export async function sendRecordedEvent(
15
+ service: string,
16
+ deliveryType: string,
17
+ deliveryId: string,
18
+ root: string | undefined,
19
+ transport: EventTransport,
20
+ trace: Record<string, string>,
21
+ ): Promise<void> {
22
+ const { value: claim } = await applyTwinWriteAtomic<Claimed | undefined>(service, (resources) => {
23
+ const row = resources.find((r) => r.type === deliveryType && r.id === deliveryId);
24
+ if (!row || row.deleted === true || row.delivery_protocol !== 1 || row.delivery_state !== 'queued'
25
+ || row.attempt_count !== 0 || row.current_attempt !== null) return { kind: 'skip', value: undefined };
26
+ const attemptId = `${deliveryType}:${deliveryId}:1`;
27
+ if (resources.some((r) => r.type === ATTEMPT && r.id === attemptId)) return { kind: 'skip', value: undefined };
28
+ if (typeof row.url !== 'string' || typeof row.body !== 'string'
29
+ || !row.headers || typeof row.headers !== 'object' || Array.isArray(row.headers)
30
+ || Object.values(row.headers).some((v) => typeof v !== 'string')) {
31
+ throw new Error('A retained declared-event packet is incomplete; it cannot be dispatched.');
32
+ }
33
+ const occurredAt = worldNow();
34
+ return { kind: 'write', value: {
35
+ attemptId, url: row.url, body: row.body, headers: { ...row.headers } as Record<string, string>,
36
+ }, write: {
37
+ operation: 'world_event_delivery.claim', subjectType: deliveryType, subjectId: deliveryId,
38
+ fields: { delivery_state: 'sending', attempt_count: 1, current_attempt: attemptId },
39
+ projection: { creates: [{ type: ATTEMPT, id: attemptId, fields: {
40
+ delivery_type: deliveryType, delivery_id: deliveryId, attempt: 1,
41
+ state: 'sending', started_at: Date.parse(occurredAt), finished_at: null, transport_status: null,
42
+ } }] },
43
+ occurredAt, actor: { kind: 'system' as const },
44
+ } };
45
+ }, root);
46
+ if (!claim) return;
47
+
48
+ let status: number | null = null;
49
+ try {
50
+ const answer = await transport(claim.url, claim.body, { ...claim.headers, ...trace });
51
+ if (typeof answer === 'number' && Number.isInteger(answer) && answer >= 100 && answer <= 599) status = answer;
52
+ } catch {
53
+ // A thrown request can have reached the receiver. Preserve uncertainty and
54
+ // never copy exception strings, response bodies or credentials into history.
55
+ }
56
+ const state = status === null ? 'unknown' : status >= 200 && status < 300 ? 'succeeded' : 'failed';
57
+ const occurredAt = worldNow(), finishedAt = Date.parse(occurredAt);
58
+ await applyTwinWriteAtomic(service, (resources) => {
59
+ const attempt = resources.find((r) => r.type === ATTEMPT && r.id === claim.attemptId);
60
+ if (!attempt || attempt.deleted === true || attempt.state !== 'sending'
61
+ || attempt.delivery_type !== deliveryType || attempt.delivery_id !== deliveryId) return { kind: 'skip', value: undefined };
62
+ const row = resources.find((r) => r.type === deliveryType && r.id === deliveryId);
63
+ const current = row && row.deleted !== true && row.delivery_protocol === 1
64
+ && row.delivery_state === 'sending' && row.current_attempt === claim.attemptId;
65
+ return { kind: 'write', value: undefined, write: {
66
+ operation: 'world_event_delivery.finish', subjectType: ATTEMPT, subjectId: claim.attemptId,
67
+ fields: { state, finished_at: finishedAt, transport_status: status },
68
+ ...(current && row ? { projection: { updates: [{ type: deliveryType, id: deliveryId, fields: {
69
+ delivery_state: state, finished_at: finishedAt, transport_status: status,
70
+ // A future manual attempt must not erase earlier positive acceptance.
71
+ accepted_at: state === 'succeeded' ? row.accepted_at ?? finishedAt : row.accepted_at ?? null,
72
+ } }] } } : {}),
73
+ occurredAt, actor: { kind: 'system' as const },
74
+ } };
75
+ }, root);
76
+ }
package/src/events.ts CHANGED
@@ -10,6 +10,9 @@ import { createHash, createHmac } from 'node:crypto';
10
10
  import { applyTwinWrite, applyTwinWriteAtomic, twinResources } from './serve.ts';
11
11
  import { equalSecrets as same, ksuidFrom } from './signing.ts';
12
12
  import { deliveryTraceHeaders } from './trace-context.ts';
13
+ import { sendRecordedEvent } from './event-delivery.ts';
14
+ import { getActiveWorldStore, withWorldStore } from './world-store.ts';
15
+ import { worldPaths } from './storage.ts';
13
16
 
14
17
  /** How the vendor signs a delivery. */
15
18
  export type EventScheme =
@@ -99,7 +102,8 @@ export type EventsDecl = {
99
102
  envelope: unknown;
100
103
  /** Where the World's endpoints are kept: one kind, or several (GitHub's repository and organization hooks). */
101
104
  endpoints: EndpointsDecl | ReadonlyArray<EndpointsDecl>;
102
- /** The bookkeeping type each delivery is kept as (`_webhook_message`): what a World reads to see what was sent. */
105
+ /** The bookkeeping type each delivery is kept as (`_webhook_message`), otherwise `_world_event_delivery`.
106
+ * Queuing and the first transport attempt/outcome are separate retained facts. */
103
107
  record?: string;
104
108
  };
105
109
 
@@ -117,7 +121,7 @@ export type EventWrite = { operation: string; storedType: string; body: Record<s
117
121
  * (a Slack message renders as `message` and as `app_mention`). */
118
122
  export type EventRender<C> = (ctx: C, write: EventWrite, type: string) => Record<string, unknown>;
119
123
 
120
- /** A delivery made: where it went and exactly what was sent. */
124
+ /** A rendered delivery packet. Its transport attempt and acceptance are separate retained facts. */
121
125
  export type EventDelivery = { url: string; body: string; headers: Record<string, string> };
122
126
 
123
127
  /** The transport a delivery is made over; the World's by default, a test's own when given. */
@@ -302,8 +306,8 @@ const worldTransport: EventTransport = async (url, body, headers) => {
302
306
  try { return ((await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body })) as { status: number }).status; } catch { return 0; }
303
307
  };
304
308
 
305
- /** Who watches what the World sends (a walk reading each delivery as its receiver would): told of every delivery as it
306
- * is made, before its transport. Set and restored by the walk, as its World store is; unset, nothing watches. */
309
+ /** Who watches signed packets before dispatch (a walk reading each as its receiver would). This is not evidence of
310
+ * transport or acceptance. Set and restored by the walk, as its World store is; unset, nothing watches. */
307
311
  export type DeliveryObserver = (delivery: EventDelivery & { service: string }) => void;
308
312
  let deliveryObserver: DeliveryObserver | undefined;
309
313
  export function setDeliveryObserver(observer: DeliveryObserver | undefined): DeliveryObserver | undefined {
@@ -430,6 +434,7 @@ async function deliverEventType(
430
434
  const shared = JSON.stringify(envelope);
431
435
  const each = perEndpoint(decl.envelope);
432
436
  const sent: EventDelivery[] = [];
437
+ const recorded: Array<{ type: string; id: string }> = [];
433
438
  for (const [i, { row, kind }] of endpoints.entries()) {
434
439
  const body = each ? JSON.stringify(fillEnvelope(decl.envelope, filled, row)) : shared;
435
440
  const url = String(at(row, kind.url));
@@ -440,20 +445,31 @@ async function deliverEventType(
440
445
  const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq, identity) };
441
446
  sent.push({ url, body, headers });
442
447
  // kept with the write, by an id of the delivery itself (two made at once never take the same one)
443
- if (decl.record !== undefined) {
444
- await applyTwinWrite(service, {
445
- operation: `${decl.record.slice(1)}.record`, subjectType: decl.record, subjectId: `${decl.record.slice(1)}_v1_${seq}`,
446
- fields: { url, body, headers, event_type: type, sent_at: millis }, occurredAt: write.occurredAt, actor: { kind: 'system' },
447
- }, write.root);
448
- }
448
+ const record = decl.record ?? '_world_event_delivery';
449
+ const id = `${record.slice(1)}_v1_${seq}`;
450
+ await applyTwinWrite(service, {
451
+ operation: `${record.slice(1)}.record`, subjectType: record, subjectId: id,
452
+ fields: { url, body, headers, event_type: type, sent_at: millis,
453
+ delivery_protocol: 1, event_id: String(filled.$id),
454
+ endpoint_type: kind.storedAs, endpoint_id: String(row.id),
455
+ delivery_state: 'queued', attempt_count: 0, current_attempt: null,
456
+ transport_status: null, finished_at: null, accepted_at: null,
457
+ }, occurredAt: write.occurredAt, actor: { kind: 'system' },
458
+ }, write.root);
459
+ recorded.push({ type: record, id });
449
460
  }
450
461
  // sent after the write's answer, as a vendor sends it: the caller never waits on its receiver, and a receiver that is
451
462
  // slow or down fails no request
452
463
  // the write's trace is read now, inside its request, and carried on each delivery (W3C trace continuation)
453
464
  const trace = deliveryTraceHeaders();
465
+ // A timer must retain the request's store/root even if another World becomes
466
+ // active before it runs. Transport completion writes to the same retained state.
467
+ const store = getActiveWorldStore(), deliveryRoot = worldPaths(service, write.root).root;
454
468
  for (const d of sent) deliveryObserver?.({ ...d, service });
455
- for (const { url, body: payload, headers } of sent) setTimeout(() => {
456
- void transport(url, payload, { ...headers, ...trace }).catch(() => {});
469
+ for (const packet of recorded) setTimeout(() => {
470
+ // Persistence failure leaves a queued/sending claim for explicit recovery;
471
+ // it must not fail the already-committed vendor write or cause a blind resend.
472
+ void withWorldStore(store, () => sendRecordedEvent(service, packet.type, packet.id, deliveryRoot, transport, trace)).catch(() => {});
457
473
  }, 0);
458
474
  return sent;
459
475
  }
@@ -7,9 +7,9 @@
7
7
  // modeled; any other is answered by the manifest's `graphql.unmodeled`, naming the first field nothing models. An error
8
8
  // a resolver throws (`GraphqlError`) carries its `type`, as GitHub's GraphQL errors do.
9
9
  //
10
- // The `graphql` protocol library is loaded the first time a GraphQL request comes (installed by the pack's package.json,
11
- // as node-postgres is for the managed database): the kernel's own dependencies stay B1's, and importing the kernel
12
- // loads nothing.
10
+ // The generated pack fetch supplies a lazy pack-local loader for the `graphql` protocol library installed by the pack's
11
+ // package.json. Each endpoint caches its library on the first request; callers without a loader keep the kernel-relative
12
+ // lazy import fallback. The kernel's own dependencies stay B1's, and importing the kernel loads nothing.
13
13
 
14
14
  /** A resolver of one field, `Type.field`: its parent object, its arguments, and the request's handler context. */
15
15
  export type GraphqlResolver<C = unknown> = (source: Record<string, unknown>, args: Record<string, unknown>, ctx: C) => unknown;
@@ -32,8 +32,7 @@ export class GraphqlError extends Error {
32
32
  }
33
33
 
34
34
  type GraphqlLib = typeof import('graphql');
35
- let lib: Promise<GraphqlLib> | undefined;
36
- const graphqlLib = (): Promise<GraphqlLib> => (lib ??= import('graphql') as Promise<GraphqlLib>);
35
+ export type GraphqlLibraryLoader = () => Promise<GraphqlLib>;
37
36
 
38
37
  const snake = (s: string): string => s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
39
38
 
@@ -59,10 +58,14 @@ function connection(items: unknown[], args: Record<string, unknown>): Record<str
59
58
  export function graphqlEndpoint<C>(options: {
60
59
  sdl: string;
61
60
  part: GraphqlPart<C>;
61
+ /** The generated pack fetch's lazy import, resolving the library from the pack that declares it. */
62
+ loadLibrary?: GraphqlLibraryLoader;
62
63
  context: (request: Request) => Promise<C>;
63
64
  unmodeled: (field: { type: string; field: string }) => Response;
64
65
  mutationRefused?: (request: Request) => Response | undefined;
65
66
  }): (request: Request) => Promise<Response> {
67
+ let lib: Promise<GraphqlLib> | undefined;
68
+ const loadLibrary = options.loadLibrary ?? (() => import('graphql'));
66
69
  let schema: import('graphql').GraphQLSchema | undefined;
67
70
  const { resolvers, aliases, stored } = options.part;
68
71
  // introspection (`__typename`, `__type`, `__schema` and the `__Type`/`__Field`/… meta types) is the vendor's schema
@@ -71,7 +74,7 @@ export function graphqlEndpoint<C>(options: {
71
74
  const modeled = (type: string, field: string): boolean =>
72
75
  field.startsWith('__') || type.startsWith('__') || `${type}.${field}` in resolvers || !!aliases?.[type]?.[field] || !!stored?.[type]?.includes(field) || !!stored?.[type]?.includes(snake(field));
73
76
  return async (request) => {
74
- const g = await graphqlLib();
77
+ const g = await (lib ??= loadLibrary());
75
78
  schema ??= g.buildSchema(options.sdl, { assumeValidSDL: true });
76
79
  const payload = (await request.clone().json().catch(() => ({}))) as { query?: string; variables?: Record<string, unknown>; operationName?: string };
77
80
  let document: import('graphql').DocumentNode;
package/src/pack-fetch.ts CHANGED
@@ -35,7 +35,7 @@ import { worldNow } from './world-clock.ts';
35
35
  import type { WebSocketUpgrade, WebSocketPeer } from './serve-http.ts';
36
36
  import { MACHINE_POOL, POOL_KINDS, resetFakePool } from './machines.ts';
37
37
  import { applyTwinWrite } from './serve.ts';
38
- import { graphqlEndpoint, type GraphqlPart } from './graphql-wire.ts';
38
+ import { graphqlEndpoint, type GraphqlPart, type GraphqlLibraryLoader } from './graphql-wire.ts';
39
39
  import { gzipSync } from 'node:zlib';
40
40
  import { decompress } from './bytes.ts';
41
41
  import { decodedBodyLimit, oversizedBody, RequestBodyTooLargeError } from './request-body.ts';
@@ -71,7 +71,7 @@ export type PackParts = {
71
71
  lanes?: Record<string, LanePart>;
72
72
  /** `semantics/graphql.ts` with the vendor's schema (`generated/graphql-sdl.gen.json`): the GraphQL wire over the same
73
73
  * state, served at the manifest's `graphql.paths` (graphql-wire.ts). */
74
- graphql?: GraphqlPart<HandlerContext> & { sdl: string };
74
+ graphql?: GraphqlPart<HandlerContext> & { sdl: string; loadLibrary?: GraphqlLibraryLoader };
75
75
  /** `semantics/sockets.ts`: each socket's engine, by the id the manifest's `sockets` gives it (sockets.ts). */
76
76
  sockets?: Record<string, SocketEngine<HandlerContext>>;
77
77
  /** `semantics/board.ts`: the vendor's frames on the World's board, read from its state (`GET /twin/board`). */
@@ -229,7 +229,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
229
229
  // the GraphQL wire over the same state, at the paths the manifest names (graphql-wire.ts)
230
230
  const gql = m.graphql && parts.graphql ? m.graphql : undefined;
231
231
  const graphql = gql && parts.graphql ? graphqlEndpoint<HandlerContext>({
232
- sdl: parts.graphql.sdl, part: parts.graphql,
232
+ sdl: parts.graphql.sdl, part: parts.graphql, loadLibrary: parts.graphql.loadLibrary,
233
233
  context: async (request) => contextFor(m, { request: request.clone(), operation: { id: 'graphql', method: 'POST', path: gql.paths[0]!, class: 'action' } as DerivedOperation, params: {} }, scope) as Promise<HandlerContext>,
234
234
  unmodeled: ({ type, field }) => Response.json({ errors: [{ ...(gql.unmodeled.type ? { type: gql.unmodeled.type } : {}), message: gql.unmodeled.message.replace('{field}', field).replace('{type}', type) }] }, { status: gql.unmodeled.status ?? 200 }),
235
235
  mutationRefused: (request) => ((options.readOnly ?? false) || isReadOnlyRequest(request) ? vendorError(m, m.readOnly) : undefined),