@volter/world-core 3.0.153 → 3.0.154

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.
@@ -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
  }
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.154",
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",
@@ -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
  }