@volter/world-core 3.0.152 → 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.
- package/dist/src/event-delivery.d.ts +4 -0
- package/dist/src/event-delivery.js +70 -0
- package/dist/src/events.d.ts +9 -8
- package/dist/src/events.js +61 -22
- package/package.json +1 -1
- package/src/event-delivery.ts +76 -0
- package/src/events.ts +67 -27
|
@@ -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
|
+
}
|
package/dist/src/events.d.ts
CHANGED
|
@@ -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`)
|
|
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
|
|
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;
|
|
@@ -140,11 +141,11 @@ export type EventDelivery = {
|
|
|
140
141
|
/** The transport a delivery is made over; the World's by default, a test's own when given. */
|
|
141
142
|
export type EventTransport = (url: string, body: string, headers: Record<string, string>) => Promise<number | void>;
|
|
142
143
|
/** A signature scheme's headers for one delivery: deterministic (the id is derived from the delivery itself). */
|
|
143
|
-
/** `seq` tells apart
|
|
144
|
-
*
|
|
145
|
-
export declare function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq?: number): Record<string, string>;
|
|
144
|
+
/** `seq` tells apart deliveries at one instant; the declared-event path reserves it durably. `identity` supplies that
|
|
145
|
+
* occurrence's namespace. Omitted, existing direct callers retain their deterministic signing contract. */
|
|
146
|
+
export declare function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq?: number, identity?: string): Record<string, string>;
|
|
146
147
|
/** Every scheme's headers for one delivery, merged. */
|
|
147
|
-
export declare function signEventAll(schemes: EventScheme | ReadonlyArray<EventScheme>, secret: string, body: string, seconds: number, url: string, seq?: number): Record<string, string>;
|
|
148
|
+
export declare function signEventAll(schemes: EventScheme | ReadonlyArray<EventScheme>, secret: string, body: string, seconds: number, url: string, seq?: number, identity?: string): Record<string, string>;
|
|
148
149
|
/** A delivery's signature checked as its receiver checks it (a Standard Webhooks verifier, a timestamp-v1 verifier, a plain
|
|
149
150
|
* HMAC header): true when one of its signatures is the one the secret makes (constant time), and its timestamp,
|
|
150
151
|
* when the scheme sends one, is within `tolerance` seconds of `now`. */
|
|
@@ -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
|
|
167
|
-
*
|
|
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;
|
package/dist/src/events.js
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
// Imports stay free of side effects: the delivery's transport (Node's http, the World's egress policy) is loaded when a
|
|
8
8
|
// delivery is made, never when a browser client imports the kernel.
|
|
9
9
|
import { createHash, createHmac } from 'node:crypto';
|
|
10
|
-
import { applyTwinWrite, twinResources } from "./serve.js";
|
|
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) {
|
|
@@ -25,16 +28,15 @@ function matches(row, match, filled) {
|
|
|
25
28
|
});
|
|
26
29
|
return Array.isArray(match) ? match.some(one) : one(match);
|
|
27
30
|
}
|
|
28
|
-
const digest = (s) => createHash('sha256').update(s).digest('hex');
|
|
29
31
|
/** A `webhook-id` scheme's key: the base64 part of the secret, after its prefix when it carries one. */
|
|
30
32
|
const webhookKey = (scheme, secret) => scheme.secretKey === 'utf8' ? Buffer.from(secret, 'utf8') : Buffer.from(scheme.secretPrefix && secret.startsWith(scheme.secretPrefix) ? secret.slice(scheme.secretPrefix.length) : secret, 'base64');
|
|
31
33
|
/** A signature scheme's headers for one delivery: deterministic (the id is derived from the delivery itself). */
|
|
32
|
-
/** `seq` tells apart
|
|
33
|
-
*
|
|
34
|
-
export function signEvent(scheme, secret, body, seconds, url, seq = 0) {
|
|
34
|
+
/** `seq` tells apart deliveries at one instant; the declared-event path reserves it durably. `identity` supplies that
|
|
35
|
+
* occurrence's namespace. Omitted, existing direct callers retain their deterministic signing contract. */
|
|
36
|
+
export function signEvent(scheme, secret, body, seconds, url, seq = 0, identity) {
|
|
35
37
|
if (scheme.kind === 'webhook-id') {
|
|
36
38
|
// Svix's `msg_` and a KSUID (its ids' form), derived from the delivery itself
|
|
37
|
-
const id = `msg_${ksuidFrom(seconds, seq, `${url}|${seconds}|${seq}|${body}`)}`;
|
|
39
|
+
const id = `msg_${ksuidFrom(seconds, seq, identity ?? `${url}|${seconds}|${seq}|${body}`)}`;
|
|
38
40
|
const p = scheme.prefix.toLowerCase();
|
|
39
41
|
const signature = createHmac('sha256', webhookKey(scheme, secret)).update(`${id}.${seconds}.${body}`, 'utf8').digest('base64');
|
|
40
42
|
return { [`${p}-id`]: id, [`${p}-timestamp`]: String(seconds), [`${p}-signature`]: `v1,${signature}` };
|
|
@@ -58,8 +60,24 @@ const hashOf = (scheme, secret, body) => {
|
|
|
58
60
|
/** What an `hmac` scheme signs: its `signed` template, else `${t}.${body}` with a timestamp header, else the body. */
|
|
59
61
|
const hmacBase = (scheme, seconds, body) => scheme.signed !== undefined ? scheme.signed.replaceAll('{t}', String(seconds)).replaceAll('{body}', body) : scheme.timestampHeader ? `${seconds}.${body}` : body;
|
|
60
62
|
/** Every scheme's headers for one delivery, merged. */
|
|
61
|
-
export function signEventAll(schemes, secret, body, seconds, url, seq = 0) {
|
|
62
|
-
return Object.assign({}, ...(Array.isArray(schemes) ? schemes : [schemes]).map((scheme) => signEvent(scheme, secret, body, seconds, url, seq)));
|
|
63
|
+
export function signEventAll(schemes, secret, body, seconds, url, seq = 0, identity) {
|
|
64
|
+
return Object.assign({}, ...(Array.isArray(schemes) ? schemes : [schemes]).map((scheme) => signEvent(scheme, secret, body, seconds, url, seq, identity)));
|
|
65
|
+
}
|
|
66
|
+
/** Reserve occurrences under the same cross-process lock that appends actions. This private retained row is never a
|
|
67
|
+
* vendor resource. Deleted event/delivery rows, concurrent renders and restarts cannot give its ordinals back. */
|
|
68
|
+
async function reserveEventOrdinals(service, root, occurredAt, count) {
|
|
69
|
+
const { value } = await applyTwinWriteAtomic(service, (resources) => {
|
|
70
|
+
const counter = resources.find((r) => r.type === '_world_event_sequence' && r.id === 'sequence');
|
|
71
|
+
const last = counter === undefined ? 0 : counter.last_ordinal;
|
|
72
|
+
if (typeof last !== 'number' || !Number.isSafeInteger(last) || last < 0 || !Number.isSafeInteger(count) || count < 1 || !Number.isSafeInteger(last + count)) {
|
|
73
|
+
throw new Error('Invalid retained declared-event sequence; occurrence ids cannot be reused.');
|
|
74
|
+
}
|
|
75
|
+
return { kind: 'write', value: last + 1, write: {
|
|
76
|
+
operation: 'world_event_sequence.reserve', subjectType: '_world_event_sequence', subjectId: 'sequence',
|
|
77
|
+
fields: { last_ordinal: last + count }, input: { clock: true }, occurredAt, actor: { kind: 'system' },
|
|
78
|
+
} };
|
|
79
|
+
}, root);
|
|
80
|
+
return value;
|
|
63
81
|
}
|
|
64
82
|
/** A delivery's signature checked as its receiver checks it (a Standard Webhooks verifier, a timestamp-v1 verifier, a plain
|
|
65
83
|
* HMAC header): true when one of its signatures is the one the secret makes (constant time), and its timestamp,
|
|
@@ -282,11 +300,19 @@ async function deliverEventType(service, decl, write, data, transport, values, t
|
|
|
282
300
|
const seconds = Math.floor(millis / 1000);
|
|
283
301
|
const eventData = await data(type);
|
|
284
302
|
const extra = values ? await values(type) : {};
|
|
285
|
-
//
|
|
286
|
-
|
|
303
|
+
// Reserve one event and at most one delivery per live endpoint before assigning identities. Unused ordinals are
|
|
304
|
+
// harmless gaps; a failed render/store never gives them back. This is not an atomic originating-write outbox.
|
|
305
|
+
const ordinal = await reserveEventOrdinals(service, write.root, write.occurredAt, 1 + live.length);
|
|
306
|
+
// Fresh/forked Worlds can reserve the same ordinal at a pinned clock. Keep
|
|
307
|
+
// content binding as well as the lineage's occurrence ordinal, so different
|
|
308
|
+
// event payloads cannot reuse a receiver's event/webhook id across those Worlds.
|
|
309
|
+
const eventContent = createHash('sha256').update(JSON.stringify({
|
|
310
|
+
operation: write.operation, type, data: eventData, envelope: decl.envelope,
|
|
311
|
+
values: extra, request: requestValues(write.request),
|
|
312
|
+
}), 'utf8').digest('hex');
|
|
287
313
|
const filled = {
|
|
288
314
|
$type: type, $data: eventData,
|
|
289
|
-
$id: `evt_${ksuidFrom(seconds,
|
|
315
|
+
$id: `evt_${ksuidFrom(seconds, ordinal, `world-events:v1:${service}:${type}:${ordinal}:${eventContent}`)}`,
|
|
290
316
|
'$time.ms': millis, '$time.s': seconds, '$time.iso': write.occurredAt,
|
|
291
317
|
...requestValues(write.request),
|
|
292
318
|
...extra,
|
|
@@ -305,31 +331,44 @@ async function deliverEventType(service, decl, write, data, transport, values, t
|
|
|
305
331
|
const shared = JSON.stringify(envelope);
|
|
306
332
|
const each = perEndpoint(decl.envelope);
|
|
307
333
|
const sent = [];
|
|
308
|
-
const
|
|
334
|
+
const recorded = [];
|
|
309
335
|
for (const [i, { row, kind }] of endpoints.entries()) {
|
|
310
336
|
const body = each ? JSON.stringify(fillEnvelope(decl.envelope, filled, row)) : shared;
|
|
311
337
|
const url = String(at(row, kind.url));
|
|
312
|
-
const seq =
|
|
338
|
+
const seq = ordinal + 1 + i;
|
|
339
|
+
const deliveryContent = createHash('sha256').update(url, 'utf8').update('\0').update(body, 'utf8').digest('hex');
|
|
340
|
+
const identity = `world-events:v1:${service}:${seq}:${deliveryContent}`;
|
|
313
341
|
const extraHeaders = decl.headers ? headersFor(decl.headers, filled, row) : {};
|
|
314
|
-
const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq) };
|
|
342
|
+
const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq, identity) };
|
|
315
343
|
sent.push({ url, body, headers });
|
|
316
344
|
// kept with the write, by an id of the delivery itself (two made at once never take the same one)
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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 });
|
|
323
357
|
}
|
|
324
358
|
// sent after the write's answer, as a vendor sends it: the caller never waits on its receiver, and a receiver that is
|
|
325
359
|
// slow or down fails no request
|
|
326
360
|
// the write's trace is read now, inside its request, and carried on each delivery (W3C trace continuation)
|
|
327
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;
|
|
328
365
|
for (const d of sent)
|
|
329
366
|
deliveryObserver?.({ ...d, service });
|
|
330
|
-
for (const
|
|
367
|
+
for (const packet of recorded)
|
|
331
368
|
setTimeout(() => {
|
|
332
|
-
|
|
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(() => { });
|
|
333
372
|
}, 0);
|
|
334
373
|
return sent;
|
|
335
374
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "3.0.
|
|
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
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
// Imports stay free of side effects: the delivery's transport (Node's http, the World's egress policy) is loaded when a
|
|
8
8
|
// delivery is made, never when a browser client imports the kernel.
|
|
9
9
|
import { createHash, createHmac } from 'node:crypto';
|
|
10
|
-
import { applyTwinWrite, twinResources } from './serve.ts';
|
|
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`)
|
|
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,25 +121,23 @@ 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
|
|
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. */
|
|
124
128
|
export type EventTransport = (url: string, body: string, headers: Record<string, string>) => Promise<number | void>;
|
|
125
129
|
|
|
126
|
-
const digest = (s: string): string => createHash('sha256').update(s).digest('hex');
|
|
127
|
-
|
|
128
130
|
/** A `webhook-id` scheme's key: the base64 part of the secret, after its prefix when it carries one. */
|
|
129
131
|
const webhookKey = (scheme: { secretPrefix?: string; secretKey?: 'base64' | 'utf8' }, secret: string): Buffer =>
|
|
130
132
|
scheme.secretKey === 'utf8' ? Buffer.from(secret, 'utf8') : Buffer.from(scheme.secretPrefix && secret.startsWith(scheme.secretPrefix) ? secret.slice(scheme.secretPrefix.length) : secret, 'base64');
|
|
131
133
|
|
|
132
134
|
/** A signature scheme's headers for one delivery: deterministic (the id is derived from the delivery itself). */
|
|
133
|
-
/** `seq` tells apart
|
|
134
|
-
*
|
|
135
|
-
export function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq = 0): Record<string, string> {
|
|
135
|
+
/** `seq` tells apart deliveries at one instant; the declared-event path reserves it durably. `identity` supplies that
|
|
136
|
+
* occurrence's namespace. Omitted, existing direct callers retain their deterministic signing contract. */
|
|
137
|
+
export function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq = 0, identity?: string): Record<string, string> {
|
|
136
138
|
if (scheme.kind === 'webhook-id') {
|
|
137
139
|
// Svix's `msg_` and a KSUID (its ids' form), derived from the delivery itself
|
|
138
|
-
const id = `msg_${ksuidFrom(seconds, seq, `${url}|${seconds}|${seq}|${body}`)}`;
|
|
140
|
+
const id = `msg_${ksuidFrom(seconds, seq, identity ?? `${url}|${seconds}|${seq}|${body}`)}`;
|
|
139
141
|
const p = scheme.prefix.toLowerCase();
|
|
140
142
|
const signature = createHmac('sha256', webhookKey(scheme, secret)).update(`${id}.${seconds}.${body}`, 'utf8').digest('base64');
|
|
141
143
|
return { [`${p}-id`]: id, [`${p}-timestamp`]: String(seconds), [`${p}-signature`]: `v1,${signature}` };
|
|
@@ -161,8 +163,25 @@ const hmacBase = (scheme: { timestampHeader?: string; signed?: string }, seconds
|
|
|
161
163
|
scheme.signed !== undefined ? scheme.signed.replaceAll('{t}', String(seconds)).replaceAll('{body}', body) : scheme.timestampHeader ? `${seconds}.${body}` : body;
|
|
162
164
|
|
|
163
165
|
/** Every scheme's headers for one delivery, merged. */
|
|
164
|
-
export function signEventAll(schemes: EventScheme | ReadonlyArray<EventScheme>, secret: string, body: string, seconds: number, url: string, seq = 0): Record<string, string> {
|
|
165
|
-
return Object.assign({}, ...(Array.isArray(schemes) ? schemes : [schemes]).map((scheme: EventScheme) => signEvent(scheme, secret, body, seconds, url, seq)));
|
|
166
|
+
export function signEventAll(schemes: EventScheme | ReadonlyArray<EventScheme>, secret: string, body: string, seconds: number, url: string, seq = 0, identity?: string): Record<string, string> {
|
|
167
|
+
return Object.assign({}, ...(Array.isArray(schemes) ? schemes : [schemes]).map((scheme: EventScheme) => signEvent(scheme, secret, body, seconds, url, seq, identity)));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Reserve occurrences under the same cross-process lock that appends actions. This private retained row is never a
|
|
171
|
+
* vendor resource. Deleted event/delivery rows, concurrent renders and restarts cannot give its ordinals back. */
|
|
172
|
+
async function reserveEventOrdinals(service: string, root: string | undefined, occurredAt: string, count: number): Promise<number> {
|
|
173
|
+
const { value } = await applyTwinWriteAtomic(service, (resources) => {
|
|
174
|
+
const counter = resources.find((r) => r.type === '_world_event_sequence' && r.id === 'sequence');
|
|
175
|
+
const last = counter === undefined ? 0 : (counter as Record<string, unknown>).last_ordinal;
|
|
176
|
+
if (typeof last !== 'number' || !Number.isSafeInteger(last) || last < 0 || !Number.isSafeInteger(count) || count < 1 || !Number.isSafeInteger(last + count)) {
|
|
177
|
+
throw new Error('Invalid retained declared-event sequence; occurrence ids cannot be reused.');
|
|
178
|
+
}
|
|
179
|
+
return { kind: 'write', value: last + 1, write: {
|
|
180
|
+
operation: 'world_event_sequence.reserve', subjectType: '_world_event_sequence', subjectId: 'sequence',
|
|
181
|
+
fields: { last_ordinal: last + count }, input: { clock: true }, occurredAt, actor: { kind: 'system' },
|
|
182
|
+
} };
|
|
183
|
+
}, root);
|
|
184
|
+
return value;
|
|
166
185
|
}
|
|
167
186
|
|
|
168
187
|
/** A delivery's signature checked as its receiver checks it (a Standard Webhooks verifier, a timestamp-v1 verifier, a plain
|
|
@@ -287,8 +306,8 @@ const worldTransport: EventTransport = async (url, body, headers) => {
|
|
|
287
306
|
try { return ((await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body })) as { status: number }).status; } catch { return 0; }
|
|
288
307
|
};
|
|
289
308
|
|
|
290
|
-
/** Who watches
|
|
291
|
-
*
|
|
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. */
|
|
292
311
|
export type DeliveryObserver = (delivery: EventDelivery & { service: string }) => void;
|
|
293
312
|
let deliveryObserver: DeliveryObserver | undefined;
|
|
294
313
|
export function setDeliveryObserver(observer: DeliveryObserver | undefined): DeliveryObserver | undefined {
|
|
@@ -386,11 +405,19 @@ async function deliverEventType(
|
|
|
386
405
|
const seconds = Math.floor(millis / 1000);
|
|
387
406
|
const eventData = await data(type);
|
|
388
407
|
const extra = values ? await values(type) : {};
|
|
389
|
-
//
|
|
390
|
-
|
|
408
|
+
// Reserve one event and at most one delivery per live endpoint before assigning identities. Unused ordinals are
|
|
409
|
+
// harmless gaps; a failed render/store never gives them back. This is not an atomic originating-write outbox.
|
|
410
|
+
const ordinal = await reserveEventOrdinals(service, write.root, write.occurredAt, 1 + live.length);
|
|
411
|
+
// Fresh/forked Worlds can reserve the same ordinal at a pinned clock. Keep
|
|
412
|
+
// content binding as well as the lineage's occurrence ordinal, so different
|
|
413
|
+
// event payloads cannot reuse a receiver's event/webhook id across those Worlds.
|
|
414
|
+
const eventContent = createHash('sha256').update(JSON.stringify({
|
|
415
|
+
operation: write.operation, type, data: eventData, envelope: decl.envelope,
|
|
416
|
+
values: extra, request: requestValues(write.request),
|
|
417
|
+
}), 'utf8').digest('hex');
|
|
391
418
|
const filled: Record<string, unknown> = {
|
|
392
419
|
$type: type, $data: eventData,
|
|
393
|
-
$id: `evt_${ksuidFrom(seconds,
|
|
420
|
+
$id: `evt_${ksuidFrom(seconds, ordinal, `world-events:v1:${service}:${type}:${ordinal}:${eventContent}`)}`,
|
|
394
421
|
'$time.ms': millis, '$time.s': seconds, '$time.iso': write.occurredAt,
|
|
395
422
|
...requestValues(write.request),
|
|
396
423
|
...extra,
|
|
@@ -407,29 +434,42 @@ async function deliverEventType(
|
|
|
407
434
|
const shared = JSON.stringify(envelope);
|
|
408
435
|
const each = perEndpoint(decl.envelope);
|
|
409
436
|
const sent: EventDelivery[] = [];
|
|
410
|
-
const
|
|
437
|
+
const recorded: Array<{ type: string; id: string }> = [];
|
|
411
438
|
for (const [i, { row, kind }] of endpoints.entries()) {
|
|
412
439
|
const body = each ? JSON.stringify(fillEnvelope(decl.envelope, filled, row)) : shared;
|
|
413
440
|
const url = String(at(row, kind.url));
|
|
414
|
-
const seq =
|
|
441
|
+
const seq = ordinal + 1 + i;
|
|
442
|
+
const deliveryContent = createHash('sha256').update(url, 'utf8').update('\0').update(body, 'utf8').digest('hex');
|
|
443
|
+
const identity = `world-events:v1:${service}:${seq}:${deliveryContent}`;
|
|
415
444
|
const extraHeaders = decl.headers ? headersFor(decl.headers, filled, row) : {};
|
|
416
|
-
const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq) };
|
|
445
|
+
const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq, identity) };
|
|
417
446
|
sent.push({ url, body, headers });
|
|
418
447
|
// kept with the write, by an id of the delivery itself (two made at once never take the same one)
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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 });
|
|
425
460
|
}
|
|
426
461
|
// sent after the write's answer, as a vendor sends it: the caller never waits on its receiver, and a receiver that is
|
|
427
462
|
// slow or down fails no request
|
|
428
463
|
// the write's trace is read now, inside its request, and carried on each delivery (W3C trace continuation)
|
|
429
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;
|
|
430
468
|
for (const d of sent) deliveryObserver?.({ ...d, service });
|
|
431
|
-
for (const
|
|
432
|
-
|
|
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(() => {});
|
|
433
473
|
}, 0);
|
|
434
474
|
return sent;
|
|
435
475
|
}
|