@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.
- package/dist/src/event-delivery.d.ts +4 -0
- package/dist/src/event-delivery.js +70 -0
- package/dist/src/events.d.ts +5 -4
- package/dist/src/events.js +23 -8
- package/package.json +1 -1
- package/src/event-delivery.ts +76 -0
- package/src/events.ts +28 -12
|
@@ -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;
|
|
@@ -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
|
@@ -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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
367
|
+
for (const packet of recorded)
|
|
355
368
|
setTimeout(() => {
|
|
356
|
-
|
|
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.
|
|
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`)
|
|
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
|
|
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
|
|
306
|
-
*
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
|
456
|
-
|
|
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
|
}
|