@things-factory/webhook 10.1.28 → 10.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist-server/service/index.d.ts +16 -0
  2. package/dist-server/service/index.js +28 -0
  3. package/dist-server/service/index.js.map +1 -1
  4. package/dist-server/service/observability/clock-offset.d.ts +88 -0
  5. package/dist-server/service/observability/clock-offset.js +96 -0
  6. package/dist-server/service/observability/clock-offset.js.map +1 -0
  7. package/dist-server/service/observability/trace-context.d.ts +66 -0
  8. package/dist-server/service/observability/trace-context.js +109 -0
  9. package/dist-server/service/observability/trace-context.js.map +1 -0
  10. package/dist-server/service/outbox/append.d.ts +85 -0
  11. package/dist-server/service/outbox/append.js +46 -0
  12. package/dist-server/service/outbox/append.js.map +1 -0
  13. package/dist-server/service/outbox/delivery.d.ts +224 -0
  14. package/dist-server/service/outbox/delivery.js +337 -0
  15. package/dist-server/service/outbox/delivery.js.map +1 -0
  16. package/dist-server/service/outbox/dispatcher.d.ts +46 -0
  17. package/dist-server/service/outbox/dispatcher.js +109 -0
  18. package/dist-server/service/outbox/dispatcher.js.map +1 -0
  19. package/dist-server/service/outbox/page.d.ts +18 -0
  20. package/dist-server/service/outbox/page.js +14 -0
  21. package/dist-server/service/outbox/page.js.map +1 -0
  22. package/dist-server/service/outbox/pump-gate.d.ts +74 -0
  23. package/dist-server/service/outbox/pump-gate.js +90 -0
  24. package/dist-server/service/outbox/pump-gate.js.map +1 -0
  25. package/dist-server/service/outbox/reconciliation.d.ts +114 -0
  26. package/dist-server/service/outbox/reconciliation.js +115 -0
  27. package/dist-server/service/outbox/reconciliation.js.map +1 -0
  28. package/dist-server/service/outbox/requeue-note.d.ts +13 -0
  29. package/dist-server/service/outbox/requeue-note.js +21 -0
  30. package/dist-server/service/outbox/requeue-note.js.map +1 -0
  31. package/dist-server/service/outbox/row-effect.d.ts +68 -0
  32. package/dist-server/service/outbox/row-effect.js +54 -0
  33. package/dist-server/service/outbox/row-effect.js.map +1 -0
  34. package/dist-server/service/outbox/seq-report.d.ts +88 -0
  35. package/dist-server/service/outbox/seq-report.js +112 -0
  36. package/dist-server/service/outbox/seq-report.js.map +1 -0
  37. package/dist-server/service/outbox/settlement.d.ts +63 -0
  38. package/dist-server/service/outbox/settlement.js +68 -0
  39. package/dist-server/service/outbox/settlement.js.map +1 -0
  40. package/dist-server/service/outbox/standing.d.ts +63 -0
  41. package/dist-server/service/outbox/standing.js +65 -0
  42. package/dist-server/service/outbox/standing.js.map +1 -0
  43. package/dist-server/service/outbox/store.d.ts +52 -0
  44. package/dist-server/service/outbox/store.js +3 -0
  45. package/dist-server/service/outbox/store.js.map +1 -0
  46. package/dist-server/service/outbox/webhook-store.d.ts +17 -0
  47. package/dist-server/service/outbox/webhook-store.js +62 -0
  48. package/dist-server/service/outbox/webhook-store.js.map +1 -0
  49. package/dist-server/service/outbox/writing-transaction.d.ts +33 -0
  50. package/dist-server/service/outbox/writing-transaction.js +40 -0
  51. package/dist-server/service/outbox/writing-transaction.js.map +1 -0
  52. package/dist-server/service/webhook-destination/webhook-destination.d.ts +3 -0
  53. package/dist-server/service/webhook-destination/webhook-destination.js +15 -0
  54. package/dist-server/service/webhook-destination/webhook-destination.js.map +1 -1
  55. package/dist-server/service/webhook-outbox-event/webhook-outbox-event.d.ts +3 -0
  56. package/dist-server/service/webhook-outbox-event/webhook-outbox-event.js +22 -0
  57. package/dist-server/service/webhook-outbox-event/webhook-outbox-event.js.map +1 -1
  58. package/dist-server/tsconfig.tsbuildinfo +1 -1
  59. package/package.json +6 -6
  60. package/tests/outbox-append.test.ts +106 -0
  61. package/tests/outbox-pump-gate.test.ts +65 -0
@@ -0,0 +1,224 @@
1
+ import { type WebhookLane } from '@operato/ops-contract';
2
+ /**
3
+ * A queued row, as far as building an envelope is concerned.
4
+ *
5
+ * The pure half of delivery does not know the entity. It reads these columns off whatever the store
6
+ * hands it — this module's `WebhookOutboxEvent`, or a product's own table while that product is still
7
+ * moving over (ADR-0078 decision 2). `payload` is the only product-specific value (ADR-0041), so it
8
+ * stays `unknown` here: what a record means is the sender's business and the receiver's.
9
+ */
10
+ export interface OutboxRow {
11
+ seq: number | string;
12
+ /** The unit the numbers are counted in — the contract's `WebhookEnvelope.scope`. */
13
+ scope: string;
14
+ lane?: WebhookLane | null;
15
+ eventId: string;
16
+ eventTime: Date | string;
17
+ recordTime: Date | string;
18
+ /** Declared only when this did not actually happen — the contract's `CanonicalEnvelope.provenance`. */
19
+ provenance?: 'actual' | 'simulated' | null;
20
+ payload: unknown;
21
+ }
22
+ /**
23
+ * 전송의 순수 부분 — 서명 · 봉투 · 응답 해석.
24
+ *
25
+ * 데이터베이스도 네트워크도 만지지 않는다. 여기서 갈라 두면 「받는 쪽이 429 를 주면 어떻게 하나」 같은
26
+ * 판단을 서버를 띄우지 않고 테스트할 수 있다.
27
+ *
28
+ * 계약: `@operato/ops-contract/webhook`. 이 파일은 operato-plant 의 `mes-outbox-delivery.ts` 를 옮긴 것이다(ADR-0078 결정 2 ①) —
29
+ * 주석의 날짜와 사건은 그 기계가 plant 에서 겪은 것이고, 그래서 지우지 않았다.
30
+ */
31
+ /**
32
+ * 트윈으로 보내는 봉투 하나.
33
+ *
34
+ * ── `scope` 가 왜 필수인가 ─────────────────────────────────────────────────
35
+ * 번호를 매기는 단위가 **(도메인, 채널)** 이다(`ix_mes_outbox_event_0`). 도메인은 주소가 가른다(목적지가
36
+ * 도메인마다 다르다). 그래서 봉투가 말해야 하는 것은 **채널**이고, 그것이 계약의 `scope` 다.
37
+ *
38
+ * 봉투에 그것이 없으면 받는 쪽은 그 번호가 어느 줄의 번호인지 알 수 없다. 지금은 한 채널만 흐르고 있어
39
+ * 드러나지 않는다 — **master 채널이 흐르기 시작하는 날** 두 줄의 번호가 한 커서에 섞여 끊임없이
40
+ * 「빠졌다」가 된다.
41
+ *
42
+ * 비어 있을 수 없다. 받는 쪽이 기본값을 정하면 그 기본값이 두 번째 규칙이 된다. 주소에서 유추하게
43
+ * 두지도 않는다 — 주소는 어디로 갈지를 정하고 `scope` 는 어느 줄인지를 정한다. 다른 일이다.
44
+ *
45
+ * **이름은 계약이 정한다**(`WebhookEnvelope.scope`). 처음에 `channel` 로 보냈는데 받는 쪽은 계약대로
46
+ * `scope` 를 읽는다 — 같은 뜻에 이름이 둘이면 한쪽은 그 값을 못 본다.
47
+ *
48
+ * ── `streamId` 를 지웠다 ───────────────────────────────────────────────────
49
+ * 설계 문서가 「순서는 스트림 안에서만 보장한다」고 적어 두었는데 **코드가 하지 않는 보장이었다.**
50
+ * 아무도 채우지 않았고 번호에 참여하지도 않았다. 쓰지 않는 두 번째 이름이 문서에서 보장으로 적혀
51
+ * 있는 것이 사고의 원인이라, 이름을 하나로 줄인다.
52
+ */
53
+ export interface DeliveryEnvelope {
54
+ seq: number;
55
+ /** 이 번호가 어느 줄의 번호인가. 커서의 키다. 계약의 `WebhookEnvelope.scope`. */
56
+ scope: string;
57
+ lane?: WebhookLane;
58
+ eventId: string;
59
+ eventTime: string;
60
+ recordTime: string;
61
+ /** 정말 일어났나 — **예외만 실린다.** 계약의 `CanonicalEnvelope.provenance`. */
62
+ provenance?: 'actual' | 'simulated';
63
+ record: unknown;
64
+ }
65
+ export interface DeliveryBatch {
66
+ batch: DeliveryEnvelope[];
67
+ }
68
+ /** 받는 쪽 응답을 보내는 쪽이 할 일로 옮긴 것. */
69
+ export type DeliveryOutcome = {
70
+ kind: 'accepted';
71
+ lastSeq: number;
72
+ tally?: Tally;
73
+ } | {
74
+ kind: 'partial';
75
+ lastSeq: number;
76
+ rejected: string[];
77
+ tally?: Tally;
78
+ }
79
+ /**
80
+ * 번호가 비었다. `expectedSeq` 부터 다시 보낸다.
81
+ *
82
+ * `lastSeq` 는 **구멍 앞까지 실제로 들어간 것**이다. 그것을 보냄으로 표시하지 않으면 매 주기마다 같은
83
+ * 봉투를 다시 보내고, 받는 쪽이 중복으로 거르면서 처리량만 쓴다.
84
+ *
85
+ * `rejected` 가 함께 올 수 있다 — 구멍과 떨어진 것이 한 응답에 실린다.
86
+ */
87
+ | {
88
+ kind: 'gap';
89
+ expectedSeq: number;
90
+ lastSeq?: number;
91
+ rejected: string[];
92
+ } | {
93
+ kind: 'backpressure';
94
+ retryAfterMs: number;
95
+ } | {
96
+ kind: 'unauthorized';
97
+ }
98
+ /** 다시 보내도 같다. 사람이 고쳐야 풀린다 — 되풀이하면 받는 쪽을 두드리며 로그만 채운다. */
99
+ | {
100
+ kind: 'stop';
101
+ reason: string;
102
+ } | {
103
+ kind: 'retry';
104
+ reason: string;
105
+ };
106
+ /** 기본 대기 시간 — 받는 쪽이 `retryAfterMs` 를 주지 않았을 때만 쓴다. */
107
+ export declare const DEFAULT_RETRY_AFTER_MS = 1000;
108
+ export declare function buildBatch(events: readonly OutboxRow[]): DeliveryBatch;
109
+ /**
110
+ * 서명 대상은 `타임스탬프 + '.' + 본문` 이다. 타임스탬프를 빼면 재생을 막을 수 없다.
111
+ *
112
+ * ── 식도 이름도 계약이 갖는다 ───────────────────────────────────────────────
113
+ * 이 식은 원래 여기서 났고 계약이 그것을 그대로 가져갔다. 그런데 **두 벌이 되자 헤더 이름이 갈라졌다** —
114
+ * 우리는 `x-mes-*` 로 보내고 받는 쪽은 `x-ops-*` 를 찾아, 서명이 맞아도 401 이 났다. 값이 같아도 두
115
+ * 군데 적혀 있으면 언젠가 갈라진다.
116
+ *
117
+ * 그래서 여기 남은 것은 **계약을 부르는 껍데기**다. 식이 바뀌는 날 양쪽이 함께 움직인다.
118
+ */
119
+ export declare function signingInput(timestamp: string, body: string): string;
120
+ export declare function sign(secret: string, timestamp: string, body: string): string;
121
+ /** 시각까지 함께 본다 — 계약이 오래된 서명을 만료로 가른다. 재생을 막는 것이 서명의 절반이다. */
122
+ export declare function verifySignature(secret: string, timestamp: string, body: string, signature: string, nowMs?: number): boolean;
123
+ /**
124
+ * 응답을 보고 다음에 무엇을 할지 정한다 — **표는 계약이 갖는다.**
125
+ *
126
+ * ── 왜 우리 표를 두지 않나 ─────────────────────────────────────────────────
127
+ * 코드마다 무엇을 할지는 받는 쪽과 보내는 쪽이 **같은 표**를 봐야 정해진다. 여기 따로 적어 두면 계약이
128
+ * 한 줄 바뀔 때 이 파일이 남고, 그때부터 두 쪽이 다른 규칙으로 움직인다. 실제로 그렇게 벌어졌다 —
129
+ * 422 가 생겼는데 여기는 「모르는 코드」로 두어 **떨어진 것을 되풀이해 보낼 뻔했다.**
130
+ *
131
+ * ── 400 과 500 을 가른다 ───────────────────────────────────────────────────
132
+ * 앞서는 200·409·429·401 말고 전부 「다시 보낸다」였다. 그러면 본문 모양이 틀려 400 을 받는 날 5초마다
133
+ * 영원히 두드린다. 계약의 `webhookSenderAction` 이 그 둘을 가른다.
134
+ */
135
+ /**
136
+ * 받는 쪽이 본문에 담은 이유를 꺼낸다.
137
+ *
138
+ * 어느 칸에 담을지는 받는 쪽이 정하므로 흔한 이름들을 본다. 아무 데도 없으면 **본문을 그대로 잘라
139
+ * 붙인다** — 모양을 모른다고 버리면 고칠 실마리가 사라진다.
140
+ */
141
+ export declare function detailOf(payload: Record<string, unknown>): string;
142
+ export declare function refusalReason(status: number, payload: Record<string, unknown>): string;
143
+ /**
144
+ * Whether the receiver told us something about rejections we cannot read.
145
+ *
146
+ * `WebhookRejection` is `{ eventId, errors }` or `{ record, errors }` — the connector's own id
147
+ * when it carried one, the offending record when it did not. This reader still expects a list of
148
+ * envelope ids, which is a guess made when the contract named `rejected` in prose and declared no
149
+ * shape for it; three sibling modules pinned `{ record, errors }` and `webhook` was the one that
150
+ * did not. It says so now, so this reader is the side that is wrong.
151
+ *
152
+ * Reading a shape it does not know as "none rejected" marks every envelope in the batch as
153
+ * delivered, and a fact that was refused is recorded here as stored. That is the worst shape a
154
+ * mismatch can take: both sides think they agree. Until this reads `WebhookRejection` properly,
155
+ * an unknown shape goes to retry instead — losing the reason, but not the fact.
156
+ *
157
+ * `offered` against `applied + duplicates` closes on its own and needs no rejection list at all.
158
+ * On seq 178 it was three against two while the list was being misread. Worth standing up next to
159
+ * this, not instead of it: the sum says something was dropped, `errors` says why.
160
+ *
161
+ * Absent is not unreadable. A receiver that rejected nothing says nothing, and that is the
162
+ * ordinary case.
163
+ */
164
+ /**
165
+ * What the receiver said it did with the batch, and whether those numbers add up.
166
+ *
167
+ * ── Why the sender keeps its own copy of this ──────────────────────────────
168
+ * The receiver reports `offered`, `applied`, `duplicates`, `vocabulary` and a rejection list, and
169
+ * `offered` has to equal the rest. Until now this sender read none of it: 870 envelopes went out
170
+ * and there was no record on this side of what the far end said it stored (2026-09-08). The far
171
+ * end can check its own arithmetic, but a disagreement between the two sides is exactly what one
172
+ * side checking alone cannot find.
173
+ *
174
+ * ── Vocabulary is in the sum ───────────────────────────────────────────────
175
+ * Vocabulary rides the same channel as events but has no time axis, so it never reaches the
176
+ * journal. It is still offered, so leaving it out of the sum makes a healthy batch look short.
177
+ *
178
+ * ── Absent is not zero ────────────────────────────────────────────────────
179
+ * A receiver that reports nothing gets `undefined`, not a tally of zeroes. A zero tally would
180
+ * read as "it offered nothing", and that is a different fact from "it did not say".
181
+ */
182
+ export interface Tally {
183
+ offered: number;
184
+ applied: number;
185
+ duplicates: number;
186
+ vocabulary: number;
187
+ rejected: number;
188
+ /** `offered === applied + duplicates + vocabulary + rejected`. */
189
+ balanced: boolean;
190
+ }
191
+ export declare function tallyOf(payload: Record<string, unknown>, rejectedCount: number): Tally | undefined;
192
+ /** One line for the log — short enough to print on every batch. */
193
+ export declare function describeTally(tally: Tally): string;
194
+ /**
195
+ * Pull the stated reasons out of a rejection list, whatever shape it came in.
196
+ *
197
+ * The contract declares `{ eventId, errors }` and `{ record, errors }`; older senders were told
198
+ * to expect plain ids. This reads `errors` wherever it finds it and ignores the rest — it is for
199
+ * telling a person what happened, not for deciding what was stored.
200
+ */
201
+ export interface ReadRejections {
202
+ /** Envelopes the receiver named, so they can be settled as refused. */
203
+ eventIds: string[];
204
+ /** What it said was wrong, once each. */
205
+ reasons: string[];
206
+ /** Refusals that named no envelope — these cannot be settled, only retried. */
207
+ unidentified: number;
208
+ }
209
+ /**
210
+ * Read the contract's `WebhookRejection` — `{ eventId, errors }` or `{ record, errors }`.
211
+ *
212
+ * ── Why this matters more than it looked ──────────────────────────────────
213
+ * Until this existed, a refusal this reader could not parse went to retry with the reason thrown
214
+ * away. That is safe for one envelope and fatal for a queue: on 2026-09-08 one permanently invalid
215
+ * envelope sat at the head of the batch, the whole batch was refused every five seconds, and 61
216
+ * envelopes behind it — including valid ones — never moved. 113 attempts, no progress, and the
217
+ * response had been naming the culprit the whole time.
218
+ *
219
+ * Naming which envelope was refused is what lets the queue move past it. A refusal that names no
220
+ * envelope still cannot be settled, and that is counted rather than assumed away.
221
+ */
222
+ export declare function readRejections(said: unknown): ReadRejections;
223
+ export declare function reasonsIn(said: unknown): string[];
224
+ export declare function interpretResponse(status: number, body: unknown): DeliveryOutcome;
@@ -0,0 +1,337 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_RETRY_AFTER_MS = void 0;
4
+ exports.buildBatch = buildBatch;
5
+ exports.signingInput = signingInput;
6
+ exports.sign = sign;
7
+ exports.verifySignature = verifySignature;
8
+ exports.detailOf = detailOf;
9
+ exports.refusalReason = refusalReason;
10
+ exports.tallyOf = tallyOf;
11
+ exports.describeTally = describeTally;
12
+ exports.readRejections = readRejections;
13
+ exports.reasonsIn = reasonsIn;
14
+ exports.interpretResponse = interpretResponse;
15
+ const ops_contract_1 = require("@operato/ops-contract");
16
+ const webhook_1 = require("@operato/ops-contract/webhook");
17
+ /** 기본 대기 시간 — 받는 쪽이 `retryAfterMs` 를 주지 않았을 때만 쓴다. */
18
+ exports.DEFAULT_RETRY_AFTER_MS = 1_000;
19
+ function buildBatch(events) {
20
+ return {
21
+ batch: events.map(event => ({
22
+ seq: Number(event.seq),
23
+ /* 번호의 단위를 봉투가 이름으로 말한다 — 받는 쪽이 추측하지 않게. */
24
+ scope: event.scope,
25
+ ...(event.lane === 'ATTENTIONS' ? { lane: 'ATTENTIONS' } : {}),
26
+ eventId: event.eventId,
27
+ eventTime: new Date(event.eventTime).toISOString(),
28
+ recordTime: new Date(event.recordTime).toISOString(),
29
+ /*
30
+ * **예외만 싣는다.** 안 적힌 봉투는 「일어났다」이고, `'actual'` 을 전부 붙이면 안 붙은 것이
31
+ * 지어낸 것처럼 보인다 — 계약이 셋을 가르라고 한 자리다(§`CanonicalEnvelope.provenance`).
32
+ */
33
+ ...(event.provenance ? { provenance: event.provenance } : {}),
34
+ record: event.payload
35
+ }))
36
+ };
37
+ }
38
+ /**
39
+ * 서명 대상은 `타임스탬프 + '.' + 본문` 이다. 타임스탬프를 빼면 재생을 막을 수 없다.
40
+ *
41
+ * ── 식도 이름도 계약이 갖는다 ───────────────────────────────────────────────
42
+ * 이 식은 원래 여기서 났고 계약이 그것을 그대로 가져갔다. 그런데 **두 벌이 되자 헤더 이름이 갈라졌다** —
43
+ * 우리는 `x-mes-*` 로 보내고 받는 쪽은 `x-ops-*` 를 찾아, 서명이 맞아도 401 이 났다. 값이 같아도 두
44
+ * 군데 적혀 있으면 언젠가 갈라진다.
45
+ *
46
+ * 그래서 여기 남은 것은 **계약을 부르는 껍데기**다. 식이 바뀌는 날 양쪽이 함께 움직인다.
47
+ */
48
+ function signingInput(timestamp, body) {
49
+ return (0, webhook_1.webhookSigningInput)(timestamp, body);
50
+ }
51
+ function sign(secret, timestamp, body) {
52
+ if (!secret) {
53
+ throw new Error('sign: secret is required — an unsigned webhook cannot be told apart from a forged one');
54
+ }
55
+ return (0, webhook_1.signWebhook)(secret, timestamp, body);
56
+ }
57
+ /** 시각까지 함께 본다 — 계약이 오래된 서명을 만료로 가른다. 재생을 막는 것이 서명의 절반이다. */
58
+ function verifySignature(secret, timestamp, body, signature, nowMs = Date.now()) {
59
+ return (0, webhook_1.verifyWebhookSignature)({ secret, timestamp, signature, body, nowMs }).ok;
60
+ }
61
+ /**
62
+ * 응답을 보고 다음에 무엇을 할지 정한다 — **표는 계약이 갖는다.**
63
+ *
64
+ * ── 왜 우리 표를 두지 않나 ─────────────────────────────────────────────────
65
+ * 코드마다 무엇을 할지는 받는 쪽과 보내는 쪽이 **같은 표**를 봐야 정해진다. 여기 따로 적어 두면 계약이
66
+ * 한 줄 바뀔 때 이 파일이 남고, 그때부터 두 쪽이 다른 규칙으로 움직인다. 실제로 그렇게 벌어졌다 —
67
+ * 422 가 생겼는데 여기는 「모르는 코드」로 두어 **떨어진 것을 되풀이해 보낼 뻔했다.**
68
+ *
69
+ * ── 400 과 500 을 가른다 ───────────────────────────────────────────────────
70
+ * 앞서는 200·409·429·401 말고 전부 「다시 보낸다」였다. 그러면 본문 모양이 틀려 400 을 받는 날 5초마다
71
+ * 영원히 두드린다. 계약의 `webhookSenderAction` 이 그 둘을 가른다.
72
+ */
73
+ /**
74
+ * 받는 쪽이 본문에 담은 이유를 꺼낸다.
75
+ *
76
+ * 어느 칸에 담을지는 받는 쪽이 정하므로 흔한 이름들을 본다. 아무 데도 없으면 **본문을 그대로 잘라
77
+ * 붙인다** — 모양을 모른다고 버리면 고칠 실마리가 사라진다.
78
+ */
79
+ function detailOf(payload) {
80
+ for (const key of ['error', 'message', 'reason', 'detail']) {
81
+ const value = payload[key];
82
+ if (typeof value === 'string' && value.trim()) {
83
+ return ` — ${value.trim()}`;
84
+ }
85
+ }
86
+ const reasons = payload.reasons;
87
+ if (Array.isArray(reasons) && reasons.length) {
88
+ return ` — ${reasons.map(String).join(' · ')}`;
89
+ }
90
+ const text = JSON.stringify(payload);
91
+ return text && text !== '{}' ? ` — ${text.slice(0, 300)}` : '';
92
+ }
93
+ function refusalReason(status, payload) {
94
+ return `the receiver refused with ${status} — resending will not change it${detailOf(payload)}`;
95
+ }
96
+ function tallyOf(payload, rejectedCount) {
97
+ const offered = Number(payload.offered);
98
+ if (!Number.isFinite(offered)) {
99
+ /* The receiver did not say. Nothing to check, and nothing to complain about. */
100
+ return undefined;
101
+ }
102
+ /*
103
+ * The three terms default to 0 because a receiver that stored none of a kind may leave that key
104
+ * out. `offered` is the one that may not be guessed — it is the left side of the equation.
105
+ */
106
+ const applied = Number(payload.applied) || 0;
107
+ const duplicates = Number(payload.duplicates) || 0;
108
+ const vocabulary = Number(payload.vocabulary) || 0;
109
+ return {
110
+ offered,
111
+ applied,
112
+ duplicates,
113
+ vocabulary,
114
+ rejected: rejectedCount,
115
+ balanced: offered === applied + duplicates + vocabulary + rejectedCount
116
+ };
117
+ }
118
+ /** One line for the log — short enough to print on every batch. */
119
+ function describeTally(tally) {
120
+ const sum = `${tally.applied} + ${tally.duplicates} + ${tally.vocabulary} + ${tally.rejected}`;
121
+ return tally.balanced
122
+ ? `offered ${tally.offered} = ${sum}`
123
+ : `⚠ offered ${tally.offered} ≠ ${sum} — 받는 쪽이 센 것과 합이 안 맞습니다`;
124
+ }
125
+ function unreadableRejections(said) {
126
+ if (said === undefined || said === null) {
127
+ return false;
128
+ }
129
+ if (!Array.isArray(said)) {
130
+ return true;
131
+ }
132
+ /*
133
+ * An array is not enough. The receiver sends `{ record, errors }` objects while this expects
134
+ * envelope ids, and `Array.isArray` passes both — so the ids match nothing, every envelope in
135
+ * the batch is marked delivered, and the reasons in `errors` are dropped with them. That is
136
+ * what happened to seq 178: HTTP 422 with ok:false, recorded here as SENT with no attempts.
137
+ */
138
+ return said.some(one => typeof one !== 'string');
139
+ }
140
+ /**
141
+ * Read the contract's `WebhookRejection` — `{ eventId, errors }` or `{ record, errors }`.
142
+ *
143
+ * ── Why this matters more than it looked ──────────────────────────────────
144
+ * Until this existed, a refusal this reader could not parse went to retry with the reason thrown
145
+ * away. That is safe for one envelope and fatal for a queue: on 2026-09-08 one permanently invalid
146
+ * envelope sat at the head of the batch, the whole batch was refused every five seconds, and 61
147
+ * envelopes behind it — including valid ones — never moved. 113 attempts, no progress, and the
148
+ * response had been naming the culprit the whole time.
149
+ *
150
+ * Naming which envelope was refused is what lets the queue move past it. A refusal that names no
151
+ * envelope still cannot be settled, and that is counted rather than assumed away.
152
+ */
153
+ function readRejections(said) {
154
+ const found = { eventIds: [], reasons: [], unidentified: 0 };
155
+ if (!Array.isArray(said)) {
156
+ return found;
157
+ }
158
+ const reasons = [];
159
+ for (const one of said) {
160
+ if (typeof one === 'string') {
161
+ /* The shape older senders were told to expect: a bare envelope id. */
162
+ found.eventIds.push(one);
163
+ continue;
164
+ }
165
+ if (!one || typeof one !== 'object') {
166
+ found.unidentified++;
167
+ continue;
168
+ }
169
+ const row = one;
170
+ const errors = row.errors;
171
+ if (Array.isArray(errors)) {
172
+ reasons.push(...errors.map(each => String(each)));
173
+ }
174
+ else if (typeof errors === 'string') {
175
+ reasons.push(errors);
176
+ }
177
+ /*
178
+ * `eventId` may be named directly, or sit inside the echoed record. Both are the contract's
179
+ * shapes; neither is guessed at beyond reading a field of that name.
180
+ */
181
+ const record = (row.record ?? {});
182
+ const eventId = row.eventId ?? record.eventId;
183
+ if (typeof eventId === 'string' && eventId) {
184
+ found.eventIds.push(eventId);
185
+ }
186
+ else {
187
+ found.unidentified++;
188
+ }
189
+ }
190
+ /* Same reason repeated for every envelope in a batch is one reason, not forty. */
191
+ found.reasons = [...new Set(reasons)];
192
+ return found;
193
+ }
194
+ function reasonsIn(said) {
195
+ if (!Array.isArray(said)) {
196
+ return [];
197
+ }
198
+ const reasons = [];
199
+ for (const one of said) {
200
+ if (!one || typeof one !== 'object') {
201
+ continue;
202
+ }
203
+ const errors = one.errors;
204
+ if (Array.isArray(errors)) {
205
+ reasons.push(...errors.map(each => String(each)));
206
+ }
207
+ else if (typeof errors === 'string') {
208
+ reasons.push(errors);
209
+ }
210
+ }
211
+ /* Same reason repeated for every envelope in a batch is one reason, not forty. */
212
+ return [...new Set(reasons)];
213
+ }
214
+ function interpretResponse(status, body) {
215
+ const payload = (body ?? {});
216
+ const rejected = Array.isArray(payload.rejected) ? payload.rejected : [];
217
+ /* 서명이 문제인 것은 사람에게 할 말이 달라 따로 든다. 행동은 `stop` 과 같다. */
218
+ if (status === 401 || status === 403) {
219
+ return { kind: 'unauthorized' };
220
+ }
221
+ /*
222
+ * The receiver said something about rejections in a shape this does not know. Retrying is the
223
+ * honest answer: we cannot say which of these were stored, and marking them delivered would
224
+ * lose whichever were refused with nobody able to find out (2026-09-07 — an envelope read as
225
+ * SENT with no attempts had in fact been refused, and it took another lane reading the twin's
226
+ * journal to notice).
227
+ */
228
+ if (unreadableRejections(payload.rejected)) {
229
+ /*
230
+ * ── Say why it was refused, even when we cannot say which ─────────────
231
+ *
232
+ * The contract's `WebhookRejection` is `{ eventId, errors }` or `{ record, errors }`. This
233
+ * reader still expects envelope ids, so it cannot settle these — retrying is right, the fact
234
+ * is not lost. But throwing the `errors` away with them was not right: on 2026-09-08 the twin
235
+ * refused every genealogy envelope this plant had just started sending, and the queue climbed
236
+ * to 59 attempts saying only "cannot read". The reason was in the response the whole time.
237
+ *
238
+ * So the reasons are read out of whichever shape arrived and carried in the retry message.
239
+ * Reading them does not mean this understands the shape — settling still waits for that.
240
+ */
241
+ if (!Array.isArray(payload.rejected)) {
242
+ /*
243
+ * A count, or an object with a count in it. There is no list to read and nothing names an
244
+ * envelope, so there is nothing to settle — only the fact that the two sides disagree about
245
+ * the shape of this field.
246
+ */
247
+ return {
248
+ kind: 'retry',
249
+ reason: `response carried a "rejected" that is not a list (${typeof payload.rejected}) — cannot tell what was stored`
250
+ };
251
+ }
252
+ const read = readRejections(payload.rejected);
253
+ const why = read.reasons.length ? ` 받는 쪽이 말한 까닭: ${read.reasons.slice(0, 3).join(' · ')}` : '';
254
+ if (read.unidentified) {
255
+ /*
256
+ * Some refusal named no envelope. Settling the ones that were named would leave the
257
+ * unnamed ones marked delivered, and one of them is what the receiver refused.
258
+ */
259
+ return {
260
+ kind: 'retry',
261
+ reason: `${read.unidentified} of the refusals named no envelope — cannot tell what was stored.${why}`
262
+ };
263
+ }
264
+ /*
265
+ * Every refusal named an envelope. Those settle as refused and everything else in the batch
266
+ * settles as delivered — which is what lets the queue move past a permanently invalid
267
+ * envelope instead of retrying the whole batch behind it forever.
268
+ */
269
+ const lastSeq = Number(payload.lastSeq);
270
+ if (!Number.isFinite(lastSeq)) {
271
+ return { kind: 'retry', reason: `refusals were readable but no lastSeq came with them.${why}` };
272
+ }
273
+ return { kind: 'partial', lastSeq, rejected: read.eventIds, ...(tallyOf(payload, read.eventIds.length) ? { tally: tallyOf(payload, read.eventIds.length) } : {}) };
274
+ }
275
+ switch ((0, ops_contract_1.webhookSenderAction)(status)) {
276
+ case 'accepted': {
277
+ const lastSeq = Number(payload.lastSeq);
278
+ if (!Number.isFinite(lastSeq)) {
279
+ /* 무엇까지 들어갔는지 모르면 보냄으로 표시할 수 없다. 표시하면 그 사실이 사라진다. */
280
+ return { kind: 'retry', reason: 'accepted response carried no lastSeq — cannot tell what was stored' };
281
+ }
282
+ /*
283
+ * When the receiver said nothing about counts, the key is left off entirely rather than set
284
+ * to `undefined`. An outcome carrying `tally: undefined` is not the same object as one
285
+ * without the key — `deepStrictEqual` tells them apart, and so does anything that walks
286
+ * the keys. Absent means absent.
287
+ */
288
+ const counted = tallyOf(payload, rejected.length);
289
+ const tally = counted ? { tally: counted } : {};
290
+ return rejected.length
291
+ ? { kind: 'partial', lastSeq, rejected, ...tally }
292
+ : { kind: 'accepted', lastSeq, ...tally };
293
+ }
294
+ case 'partial': {
295
+ const lastSeq = Number(payload.lastSeq);
296
+ if (!Number.isFinite(lastSeq)) {
297
+ return { kind: 'retry', reason: 'partial response carried no lastSeq — cannot tell what was stored' };
298
+ }
299
+ const counted = tallyOf(payload, rejected.length);
300
+ return { kind: 'partial', lastSeq, rejected, ...(counted ? { tally: counted } : {}) };
301
+ }
302
+ case 'resend-from': {
303
+ const expectedSeq = Number(payload.expectedSeq);
304
+ if (!Number.isFinite(expectedSeq)) {
305
+ return { kind: 'retry', reason: 'gap response carried no expectedSeq — cannot tell where to resume' };
306
+ }
307
+ const lastSeq = Number(payload.lastSeq);
308
+ /* 구멍 앞까지 들어간 것은 보냄으로 표시한다 — 아니면 매 주기마다 그것을 다시 보낸다. */
309
+ return { kind: 'gap', expectedSeq, lastSeq: Number.isFinite(lastSeq) ? lastSeq : undefined, rejected };
310
+ }
311
+ case 'wait': {
312
+ /*
313
+ * 503(아직 안 돈다)과 429(따라오지 못한다)가 함께 여기로 온다.
314
+ *
315
+ * 429 는 앞에서 따로 받아 두고 있었다 — 계약이 `stop` 을 답하던 때는 그래야 했다. 0.9.13 이
316
+ * 429 를 `wait` 로 답하게 되어 그 분기가 두 벌이 됐으므로 지웠다. **다시 앞에 두지 말 것** —
317
+ * 두 벌이 되면 한쪽만 고치는 날이 온다.
318
+ */
319
+ const retryAfterMs = Number(payload.retryAfterMs);
320
+ return {
321
+ kind: 'backpressure',
322
+ retryAfterMs: Number.isFinite(retryAfterMs) ? retryAfterMs : exports.DEFAULT_RETRY_AFTER_MS
323
+ };
324
+ }
325
+ case 'stop':
326
+ /*
327
+ * **받는 쪽이 말해 준 이유를 버리지 않는다.**
328
+ *
329
+ * 앞서는 상태 코드만 적었다. 그러면 「트윈이 400 으로 거절했다」만 남고 왜인지는 아무 데도
330
+ * 없어서, 고치려면 같은 요청을 손으로 다시 보내 봐야 했다. 실제로 그렇게 했다(2026-09-02).
331
+ */
332
+ return { kind: 'stop', reason: refusalReason(status, payload) };
333
+ default:
334
+ return { kind: 'retry', reason: `the receiver failed with ${status}${detailOf(payload)}` };
335
+ }
336
+ }
337
+ //# sourceMappingURL=delivery.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delivery.js","sourceRoot":"","sources":["../../../server/service/outbox/delivery.ts"],"names":[],"mappings":"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","sourcesContent":["\nimport { webhookSenderAction, type WebhookLane } from '@operato/ops-contract'\n\nimport { WEBHOOK_HEADER, signWebhook, verifyWebhookSignature, webhookSigningInput } from '@operato/ops-contract/webhook'\n\n/**\n * A queued row, as far as building an envelope is concerned.\n *\n * The pure half of delivery does not know the entity. It reads these columns off whatever the store\n * hands it — this module's `WebhookOutboxEvent`, or a product's own table while that product is still\n * moving over (ADR-0078 decision 2). `payload` is the only product-specific value (ADR-0041), so it\n * stays `unknown` here: what a record means is the sender's business and the receiver's.\n */\nexport interface OutboxRow {\n seq: number | string\n /** The unit the numbers are counted in — the contract's `WebhookEnvelope.scope`. */\n scope: string\n lane?: WebhookLane | null\n eventId: string\n eventTime: Date | string\n recordTime: Date | string\n /** Declared only when this did not actually happen — the contract's `CanonicalEnvelope.provenance`. */\n provenance?: 'actual' | 'simulated' | null\n payload: unknown\n}\n\n/**\n * 전송의 순수 부분 — 서명 · 봉투 · 응답 해석.\n *\n * 데이터베이스도 네트워크도 만지지 않는다. 여기서 갈라 두면 「받는 쪽이 429 를 주면 어떻게 하나」 같은\n * 판단을 서버를 띄우지 않고 테스트할 수 있다.\n *\n * 계약: `@operato/ops-contract/webhook`. 이 파일은 operato-plant 의 `mes-outbox-delivery.ts` 를 옮긴 것이다(ADR-0078 결정 2 ①) —\n * 주석의 날짜와 사건은 그 기계가 plant 에서 겪은 것이고, 그래서 지우지 않았다.\n */\n\n/**\n * 트윈으로 보내는 봉투 하나.\n *\n * ── `scope` 가 왜 필수인가 ─────────────────────────────────────────────────\n * 번호를 매기는 단위가 **(도메인, 채널)** 이다(`ix_mes_outbox_event_0`). 도메인은 주소가 가른다(목적지가\n * 도메인마다 다르다). 그래서 봉투가 말해야 하는 것은 **채널**이고, 그것이 계약의 `scope` 다.\n *\n * 봉투에 그것이 없으면 받는 쪽은 그 번호가 어느 줄의 번호인지 알 수 없다. 지금은 한 채널만 흐르고 있어\n * 드러나지 않는다 — **master 채널이 흐르기 시작하는 날** 두 줄의 번호가 한 커서에 섞여 끊임없이\n * 「빠졌다」가 된다.\n *\n * 비어 있을 수 없다. 받는 쪽이 기본값을 정하면 그 기본값이 두 번째 규칙이 된다. 주소에서 유추하게\n * 두지도 않는다 — 주소는 어디로 갈지를 정하고 `scope` 는 어느 줄인지를 정한다. 다른 일이다.\n *\n * **이름은 계약이 정한다**(`WebhookEnvelope.scope`). 처음에 `channel` 로 보냈는데 받는 쪽은 계약대로\n * `scope` 를 읽는다 — 같은 뜻에 이름이 둘이면 한쪽은 그 값을 못 본다.\n *\n * ── `streamId` 를 지웠다 ───────────────────────────────────────────────────\n * 설계 문서가 「순서는 스트림 안에서만 보장한다」고 적어 두었는데 **코드가 하지 않는 보장이었다.**\n * 아무도 채우지 않았고 번호에 참여하지도 않았다. 쓰지 않는 두 번째 이름이 문서에서 보장으로 적혀\n * 있는 것이 사고의 원인이라, 이름을 하나로 줄인다.\n */\nexport interface DeliveryEnvelope {\n seq: number\n /** 이 번호가 어느 줄의 번호인가. 커서의 키다. 계약의 `WebhookEnvelope.scope`. */\n scope: string\n lane?: WebhookLane\n eventId: string\n eventTime: string\n recordTime: string\n /** 정말 일어났나 — **예외만 실린다.** 계약의 `CanonicalEnvelope.provenance`. */\n provenance?: 'actual' | 'simulated'\n record: unknown\n}\n\nexport interface DeliveryBatch {\n batch: DeliveryEnvelope[]\n}\n\n/** 받는 쪽 응답을 보내는 쪽이 할 일로 옮긴 것. */\nexport type DeliveryOutcome =\n | { kind: 'accepted'; lastSeq: number; tally?: Tally }\n | { kind: 'partial'; lastSeq: number; rejected: string[]; tally?: Tally }\n /**\n * 번호가 비었다. `expectedSeq` 부터 다시 보낸다.\n *\n * `lastSeq` 는 **구멍 앞까지 실제로 들어간 것**이다. 그것을 보냄으로 표시하지 않으면 매 주기마다 같은\n * 봉투를 다시 보내고, 받는 쪽이 중복으로 거르면서 처리량만 쓴다.\n *\n * `rejected` 가 함께 올 수 있다 — 구멍과 떨어진 것이 한 응답에 실린다.\n */\n | { kind: 'gap'; expectedSeq: number; lastSeq?: number; rejected: string[] }\n | { kind: 'backpressure'; retryAfterMs: number }\n | { kind: 'unauthorized' }\n /** 다시 보내도 같다. 사람이 고쳐야 풀린다 — 되풀이하면 받는 쪽을 두드리며 로그만 채운다. */\n | { kind: 'stop'; reason: string }\n | { kind: 'retry'; reason: string }\n\n/** 기본 대기 시간 — 받는 쪽이 `retryAfterMs` 를 주지 않았을 때만 쓴다. */\nexport const DEFAULT_RETRY_AFTER_MS = 1_000\n\nexport function buildBatch(events: readonly OutboxRow[]): DeliveryBatch {\n return {\n batch: events.map(event => ({\n seq: Number(event.seq),\n /* 번호의 단위를 봉투가 이름으로 말한다 — 받는 쪽이 추측하지 않게. */\n scope: event.scope,\n ...(event.lane === 'ATTENTIONS' ? { lane: 'ATTENTIONS' as const } : {}),\n eventId: event.eventId,\n eventTime: new Date(event.eventTime).toISOString(),\n recordTime: new Date(event.recordTime).toISOString(),\n /*\n * **예외만 싣는다.** 안 적힌 봉투는 「일어났다」이고, `'actual'` 을 전부 붙이면 안 붙은 것이\n * 지어낸 것처럼 보인다 — 계약이 셋을 가르라고 한 자리다(§`CanonicalEnvelope.provenance`).\n */\n ...(event.provenance ? { provenance: event.provenance } : {}),\n record: event.payload\n }))\n }\n}\n\n/**\n * 서명 대상은 `타임스탬프 + '.' + 본문` 이다. 타임스탬프를 빼면 재생을 막을 수 없다.\n *\n * ── 식도 이름도 계약이 갖는다 ───────────────────────────────────────────────\n * 이 식은 원래 여기서 났고 계약이 그것을 그대로 가져갔다. 그런데 **두 벌이 되자 헤더 이름이 갈라졌다** —\n * 우리는 `x-mes-*` 로 보내고 받는 쪽은 `x-ops-*` 를 찾아, 서명이 맞아도 401 이 났다. 값이 같아도 두\n * 군데 적혀 있으면 언젠가 갈라진다.\n *\n * 그래서 여기 남은 것은 **계약을 부르는 껍데기**다. 식이 바뀌는 날 양쪽이 함께 움직인다.\n */\nexport function signingInput(timestamp: string, body: string): string {\n return webhookSigningInput(timestamp, body)\n}\n\nexport function sign(secret: string, timestamp: string, body: string): string {\n if (!secret) {\n throw new Error('sign: secret is required — an unsigned webhook cannot be told apart from a forged one')\n }\n\n return signWebhook(secret, timestamp, body)\n}\n\n/** 시각까지 함께 본다 — 계약이 오래된 서명을 만료로 가른다. 재생을 막는 것이 서명의 절반이다. */\nexport function verifySignature(\n secret: string,\n timestamp: string,\n body: string,\n signature: string,\n nowMs: number = Date.now()\n): boolean {\n return verifyWebhookSignature({ secret, timestamp, signature, body, nowMs }).ok\n}\n\n/**\n * 응답을 보고 다음에 무엇을 할지 정한다 — **표는 계약이 갖는다.**\n *\n * ── 왜 우리 표를 두지 않나 ─────────────────────────────────────────────────\n * 코드마다 무엇을 할지는 받는 쪽과 보내는 쪽이 **같은 표**를 봐야 정해진다. 여기 따로 적어 두면 계약이\n * 한 줄 바뀔 때 이 파일이 남고, 그때부터 두 쪽이 다른 규칙으로 움직인다. 실제로 그렇게 벌어졌다 —\n * 422 가 생겼는데 여기는 「모르는 코드」로 두어 **떨어진 것을 되풀이해 보낼 뻔했다.**\n *\n * ── 400 과 500 을 가른다 ───────────────────────────────────────────────────\n * 앞서는 200·409·429·401 말고 전부 「다시 보낸다」였다. 그러면 본문 모양이 틀려 400 을 받는 날 5초마다\n * 영원히 두드린다. 계약의 `webhookSenderAction` 이 그 둘을 가른다.\n */\n/**\n * 받는 쪽이 본문에 담은 이유를 꺼낸다.\n *\n * 어느 칸에 담을지는 받는 쪽이 정하므로 흔한 이름들을 본다. 아무 데도 없으면 **본문을 그대로 잘라\n * 붙인다** — 모양을 모른다고 버리면 고칠 실마리가 사라진다.\n */\nexport function detailOf(payload: Record<string, unknown>): string {\n for (const key of ['error', 'message', 'reason', 'detail'] as const) {\n const value = payload[key]\n\n if (typeof value === 'string' && value.trim()) {\n return ` — ${value.trim()}`\n }\n }\n\n const reasons = payload.reasons\n\n if (Array.isArray(reasons) && reasons.length) {\n return ` — ${reasons.map(String).join(' · ')}`\n }\n\n const text = JSON.stringify(payload)\n\n return text && text !== '{}' ? ` — ${text.slice(0, 300)}` : ''\n}\n\nexport function refusalReason(status: number, payload: Record<string, unknown>): string {\n return `the receiver refused with ${status} — resending will not change it${detailOf(payload)}`\n}\n\n/**\n * Whether the receiver told us something about rejections we cannot read.\n *\n * `WebhookRejection` is `{ eventId, errors }` or `{ record, errors }` — the connector's own id\n * when it carried one, the offending record when it did not. This reader still expects a list of\n * envelope ids, which is a guess made when the contract named `rejected` in prose and declared no\n * shape for it; three sibling modules pinned `{ record, errors }` and `webhook` was the one that\n * did not. It says so now, so this reader is the side that is wrong.\n *\n * Reading a shape it does not know as \"none rejected\" marks every envelope in the batch as\n * delivered, and a fact that was refused is recorded here as stored. That is the worst shape a\n * mismatch can take: both sides think they agree. Until this reads `WebhookRejection` properly,\n * an unknown shape goes to retry instead — losing the reason, but not the fact.\n *\n * `offered` against `applied + duplicates` closes on its own and needs no rejection list at all.\n * On seq 178 it was three against two while the list was being misread. Worth standing up next to\n * this, not instead of it: the sum says something was dropped, `errors` says why.\n *\n * Absent is not unreadable. A receiver that rejected nothing says nothing, and that is the\n * ordinary case.\n */\n/**\n * What the receiver said it did with the batch, and whether those numbers add up.\n *\n * ── Why the sender keeps its own copy of this ──────────────────────────────\n * The receiver reports `offered`, `applied`, `duplicates`, `vocabulary` and a rejection list, and\n * `offered` has to equal the rest. Until now this sender read none of it: 870 envelopes went out\n * and there was no record on this side of what the far end said it stored (2026-09-08). The far\n * end can check its own arithmetic, but a disagreement between the two sides is exactly what one\n * side checking alone cannot find.\n *\n * ── Vocabulary is in the sum ───────────────────────────────────────────────\n * Vocabulary rides the same channel as events but has no time axis, so it never reaches the\n * journal. It is still offered, so leaving it out of the sum makes a healthy batch look short.\n *\n * ── Absent is not zero ────────────────────────────────────────────────────\n * A receiver that reports nothing gets `undefined`, not a tally of zeroes. A zero tally would\n * read as \"it offered nothing\", and that is a different fact from \"it did not say\".\n */\nexport interface Tally {\n offered: number\n applied: number\n duplicates: number\n vocabulary: number\n rejected: number\n /** `offered === applied + duplicates + vocabulary + rejected`. */\n balanced: boolean\n}\n\nexport function tallyOf(payload: Record<string, unknown>, rejectedCount: number): Tally | undefined {\n const offered = Number(payload.offered)\n\n if (!Number.isFinite(offered)) {\n /* The receiver did not say. Nothing to check, and nothing to complain about. */\n return undefined\n }\n\n /*\n * The three terms default to 0 because a receiver that stored none of a kind may leave that key\n * out. `offered` is the one that may not be guessed — it is the left side of the equation.\n */\n const applied = Number(payload.applied) || 0\n const duplicates = Number(payload.duplicates) || 0\n const vocabulary = Number(payload.vocabulary) || 0\n\n return {\n offered,\n applied,\n duplicates,\n vocabulary,\n rejected: rejectedCount,\n balanced: offered === applied + duplicates + vocabulary + rejectedCount\n }\n}\n\n/** One line for the log — short enough to print on every batch. */\nexport function describeTally(tally: Tally): string {\n const sum = `${tally.applied} + ${tally.duplicates} + ${tally.vocabulary} + ${tally.rejected}`\n\n return tally.balanced\n ? `offered ${tally.offered} = ${sum}`\n : `⚠ offered ${tally.offered} ≠ ${sum} — 받는 쪽이 센 것과 합이 안 맞습니다`\n}\n\nfunction unreadableRejections(said: unknown): boolean {\n if (said === undefined || said === null) {\n return false\n }\n\n if (!Array.isArray(said)) {\n return true\n }\n\n /*\n * An array is not enough. The receiver sends `{ record, errors }` objects while this expects\n * envelope ids, and `Array.isArray` passes both — so the ids match nothing, every envelope in\n * the batch is marked delivered, and the reasons in `errors` are dropped with them. That is\n * what happened to seq 178: HTTP 422 with ok:false, recorded here as SENT with no attempts.\n */\n return said.some(one => typeof one !== 'string')\n}\n\n/**\n * Pull the stated reasons out of a rejection list, whatever shape it came in.\n *\n * The contract declares `{ eventId, errors }` and `{ record, errors }`; older senders were told\n * to expect plain ids. This reads `errors` wherever it finds it and ignores the rest — it is for\n * telling a person what happened, not for deciding what was stored.\n */\nexport interface ReadRejections {\n /** Envelopes the receiver named, so they can be settled as refused. */\n eventIds: string[]\n /** What it said was wrong, once each. */\n reasons: string[]\n /** Refusals that named no envelope — these cannot be settled, only retried. */\n unidentified: number\n}\n\n/**\n * Read the contract's `WebhookRejection` — `{ eventId, errors }` or `{ record, errors }`.\n *\n * ── Why this matters more than it looked ──────────────────────────────────\n * Until this existed, a refusal this reader could not parse went to retry with the reason thrown\n * away. That is safe for one envelope and fatal for a queue: on 2026-09-08 one permanently invalid\n * envelope sat at the head of the batch, the whole batch was refused every five seconds, and 61\n * envelopes behind it — including valid ones — never moved. 113 attempts, no progress, and the\n * response had been naming the culprit the whole time.\n *\n * Naming which envelope was refused is what lets the queue move past it. A refusal that names no\n * envelope still cannot be settled, and that is counted rather than assumed away.\n */\nexport function readRejections(said: unknown): ReadRejections {\n const found: ReadRejections = { eventIds: [], reasons: [], unidentified: 0 }\n\n if (!Array.isArray(said)) {\n return found\n }\n\n const reasons: string[] = []\n\n for (const one of said) {\n if (typeof one === 'string') {\n /* The shape older senders were told to expect: a bare envelope id. */\n found.eventIds.push(one)\n continue\n }\n\n if (!one || typeof one !== 'object') {\n found.unidentified++\n continue\n }\n\n const row = one as Record<string, unknown>\n const errors = row.errors\n\n if (Array.isArray(errors)) {\n reasons.push(...errors.map(each => String(each)))\n } else if (typeof errors === 'string') {\n reasons.push(errors)\n }\n\n /*\n * `eventId` may be named directly, or sit inside the echoed record. Both are the contract's\n * shapes; neither is guessed at beyond reading a field of that name.\n */\n const record = (row.record ?? {}) as Record<string, unknown>\n const eventId = row.eventId ?? record.eventId\n\n if (typeof eventId === 'string' && eventId) {\n found.eventIds.push(eventId)\n } else {\n found.unidentified++\n }\n }\n\n /* Same reason repeated for every envelope in a batch is one reason, not forty. */\n found.reasons = [...new Set(reasons)]\n\n return found\n}\n\nexport function reasonsIn(said: unknown): string[] {\n if (!Array.isArray(said)) {\n return []\n }\n\n const reasons: string[] = []\n\n for (const one of said) {\n if (!one || typeof one !== 'object') {\n continue\n }\n\n const errors = (one as Record<string, unknown>).errors\n\n if (Array.isArray(errors)) {\n reasons.push(...errors.map(each => String(each)))\n } else if (typeof errors === 'string') {\n reasons.push(errors)\n }\n }\n\n /* Same reason repeated for every envelope in a batch is one reason, not forty. */\n return [...new Set(reasons)]\n}\n\nexport function interpretResponse(status: number, body: unknown): DeliveryOutcome {\n const payload = (body ?? {}) as Record<string, unknown>\n const rejected = Array.isArray(payload.rejected) ? (payload.rejected as string[]) : []\n\n /* 서명이 문제인 것은 사람에게 할 말이 달라 따로 든다. 행동은 `stop` 과 같다. */\n if (status === 401 || status === 403) {\n return { kind: 'unauthorized' }\n }\n\n /*\n * The receiver said something about rejections in a shape this does not know. Retrying is the\n * honest answer: we cannot say which of these were stored, and marking them delivered would\n * lose whichever were refused with nobody able to find out (2026-09-07 — an envelope read as\n * SENT with no attempts had in fact been refused, and it took another lane reading the twin's\n * journal to notice).\n */\n if (unreadableRejections(payload.rejected)) {\n /*\n * ── Say why it was refused, even when we cannot say which ─────────────\n *\n * The contract's `WebhookRejection` is `{ eventId, errors }` or `{ record, errors }`. This\n * reader still expects envelope ids, so it cannot settle these — retrying is right, the fact\n * is not lost. But throwing the `errors` away with them was not right: on 2026-09-08 the twin\n * refused every genealogy envelope this plant had just started sending, and the queue climbed\n * to 59 attempts saying only \"cannot read\". The reason was in the response the whole time.\n *\n * So the reasons are read out of whichever shape arrived and carried in the retry message.\n * Reading them does not mean this understands the shape — settling still waits for that.\n */\n if (!Array.isArray(payload.rejected)) {\n /*\n * A count, or an object with a count in it. There is no list to read and nothing names an\n * envelope, so there is nothing to settle — only the fact that the two sides disagree about\n * the shape of this field.\n */\n return {\n kind: 'retry',\n reason: `response carried a \"rejected\" that is not a list (${typeof payload.rejected}) — cannot tell what was stored`\n }\n }\n\n const read = readRejections(payload.rejected)\n const why = read.reasons.length ? ` 받는 쪽이 말한 까닭: ${read.reasons.slice(0, 3).join(' · ')}` : ''\n\n if (read.unidentified) {\n /*\n * Some refusal named no envelope. Settling the ones that were named would leave the\n * unnamed ones marked delivered, and one of them is what the receiver refused.\n */\n return {\n kind: 'retry',\n reason: `${read.unidentified} of the refusals named no envelope — cannot tell what was stored.${why}`\n }\n }\n\n /*\n * Every refusal named an envelope. Those settle as refused and everything else in the batch\n * settles as delivered — which is what lets the queue move past a permanently invalid\n * envelope instead of retrying the whole batch behind it forever.\n */\n const lastSeq = Number(payload.lastSeq)\n\n if (!Number.isFinite(lastSeq)) {\n return { kind: 'retry', reason: `refusals were readable but no lastSeq came with them.${why}` }\n }\n\n return { kind: 'partial', lastSeq, rejected: read.eventIds, ...(tallyOf(payload, read.eventIds.length) ? { tally: tallyOf(payload, read.eventIds.length)! } : {}) }\n }\n\n switch (webhookSenderAction(status)) {\n case 'accepted': {\n const lastSeq = Number(payload.lastSeq)\n if (!Number.isFinite(lastSeq)) {\n /* 무엇까지 들어갔는지 모르면 보냄으로 표시할 수 없다. 표시하면 그 사실이 사라진다. */\n return { kind: 'retry', reason: 'accepted response carried no lastSeq — cannot tell what was stored' }\n }\n /*\n * When the receiver said nothing about counts, the key is left off entirely rather than set\n * to `undefined`. An outcome carrying `tally: undefined` is not the same object as one\n * without the key — `deepStrictEqual` tells them apart, and so does anything that walks\n * the keys. Absent means absent.\n */\n const counted = tallyOf(payload, rejected.length)\n const tally = counted ? { tally: counted } : {}\n\n return rejected.length\n ? { kind: 'partial', lastSeq, rejected, ...tally }\n : { kind: 'accepted', lastSeq, ...tally }\n }\n\n case 'partial': {\n const lastSeq = Number(payload.lastSeq)\n if (!Number.isFinite(lastSeq)) {\n return { kind: 'retry', reason: 'partial response carried no lastSeq — cannot tell what was stored' }\n }\n const counted = tallyOf(payload, rejected.length)\n\n return { kind: 'partial', lastSeq, rejected, ...(counted ? { tally: counted } : {}) }\n }\n\n case 'resend-from': {\n const expectedSeq = Number(payload.expectedSeq)\n if (!Number.isFinite(expectedSeq)) {\n return { kind: 'retry', reason: 'gap response carried no expectedSeq — cannot tell where to resume' }\n }\n const lastSeq = Number(payload.lastSeq)\n /* 구멍 앞까지 들어간 것은 보냄으로 표시한다 — 아니면 매 주기마다 그것을 다시 보낸다. */\n return { kind: 'gap', expectedSeq, lastSeq: Number.isFinite(lastSeq) ? lastSeq : undefined, rejected }\n }\n\n case 'wait': {\n /*\n * 503(아직 안 돈다)과 429(따라오지 못한다)가 함께 여기로 온다.\n *\n * 429 는 앞에서 따로 받아 두고 있었다 — 계약이 `stop` 을 답하던 때는 그래야 했다. 0.9.13 이\n * 429 를 `wait` 로 답하게 되어 그 분기가 두 벌이 됐으므로 지웠다. **다시 앞에 두지 말 것** —\n * 두 벌이 되면 한쪽만 고치는 날이 온다.\n */\n const retryAfterMs = Number(payload.retryAfterMs)\n return {\n kind: 'backpressure',\n retryAfterMs: Number.isFinite(retryAfterMs) ? retryAfterMs : DEFAULT_RETRY_AFTER_MS\n }\n }\n\n case 'stop':\n /*\n * **받는 쪽이 말해 준 이유를 버리지 않는다.**\n *\n * 앞서는 상태 코드만 적었다. 그러면 「트윈이 400 으로 거절했다」만 남고 왜인지는 아무 데도\n * 없어서, 고치려면 같은 요청을 손으로 다시 보내 봐야 했다. 실제로 그렇게 했다(2026-09-02).\n */\n return { kind: 'stop', reason: refusalReason(status, payload) }\n\n default:\n return { kind: 'retry', reason: `the receiver failed with ${status}${detailOf(payload)}` }\n }\n}\n"]}