@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.
- package/dist-server/service/index.d.ts +16 -0
- package/dist-server/service/index.js +28 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/observability/clock-offset.d.ts +88 -0
- package/dist-server/service/observability/clock-offset.js +96 -0
- package/dist-server/service/observability/clock-offset.js.map +1 -0
- package/dist-server/service/observability/trace-context.d.ts +66 -0
- package/dist-server/service/observability/trace-context.js +109 -0
- package/dist-server/service/observability/trace-context.js.map +1 -0
- package/dist-server/service/outbox/append.d.ts +85 -0
- package/dist-server/service/outbox/append.js +46 -0
- package/dist-server/service/outbox/append.js.map +1 -0
- package/dist-server/service/outbox/delivery.d.ts +224 -0
- package/dist-server/service/outbox/delivery.js +337 -0
- package/dist-server/service/outbox/delivery.js.map +1 -0
- package/dist-server/service/outbox/dispatcher.d.ts +46 -0
- package/dist-server/service/outbox/dispatcher.js +109 -0
- package/dist-server/service/outbox/dispatcher.js.map +1 -0
- package/dist-server/service/outbox/page.d.ts +18 -0
- package/dist-server/service/outbox/page.js +14 -0
- package/dist-server/service/outbox/page.js.map +1 -0
- package/dist-server/service/outbox/pump-gate.d.ts +74 -0
- package/dist-server/service/outbox/pump-gate.js +90 -0
- package/dist-server/service/outbox/pump-gate.js.map +1 -0
- package/dist-server/service/outbox/reconciliation.d.ts +114 -0
- package/dist-server/service/outbox/reconciliation.js +115 -0
- package/dist-server/service/outbox/reconciliation.js.map +1 -0
- package/dist-server/service/outbox/requeue-note.d.ts +13 -0
- package/dist-server/service/outbox/requeue-note.js +21 -0
- package/dist-server/service/outbox/requeue-note.js.map +1 -0
- package/dist-server/service/outbox/row-effect.d.ts +68 -0
- package/dist-server/service/outbox/row-effect.js +54 -0
- package/dist-server/service/outbox/row-effect.js.map +1 -0
- package/dist-server/service/outbox/seq-report.d.ts +88 -0
- package/dist-server/service/outbox/seq-report.js +112 -0
- package/dist-server/service/outbox/seq-report.js.map +1 -0
- package/dist-server/service/outbox/settlement.d.ts +63 -0
- package/dist-server/service/outbox/settlement.js +68 -0
- package/dist-server/service/outbox/settlement.js.map +1 -0
- package/dist-server/service/outbox/standing.d.ts +63 -0
- package/dist-server/service/outbox/standing.js +65 -0
- package/dist-server/service/outbox/standing.js.map +1 -0
- package/dist-server/service/outbox/store.d.ts +52 -0
- package/dist-server/service/outbox/store.js +3 -0
- package/dist-server/service/outbox/store.js.map +1 -0
- package/dist-server/service/outbox/webhook-store.d.ts +17 -0
- package/dist-server/service/outbox/webhook-store.js +62 -0
- package/dist-server/service/outbox/webhook-store.js.map +1 -0
- package/dist-server/service/outbox/writing-transaction.d.ts +33 -0
- package/dist-server/service/outbox/writing-transaction.js +40 -0
- package/dist-server/service/outbox/writing-transaction.js.map +1 -0
- package/dist-server/service/webhook-destination/webhook-destination.d.ts +3 -0
- package/dist-server/service/webhook-destination/webhook-destination.js +15 -0
- package/dist-server/service/webhook-destination/webhook-destination.js.map +1 -1
- package/dist-server/service/webhook-outbox-event/webhook-outbox-event.d.ts +3 -0
- package/dist-server/service/webhook-outbox-event/webhook-outbox-event.js +22 -0
- package/dist-server/service/webhook-outbox-event/webhook-outbox-event.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/tests/outbox-append.test.ts +106 -0
- package/tests/outbox-pump-gate.test.ts +65 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **우리 대기 큐가 어떻게 서 있나** — 저장소를 모르는 판단. 수는 부르는 쪽이 세어 온다.
|
|
3
|
+
*
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* ## 왜 나이가 첫 항목인가 (아키텍트 판정 2026-09-07)
|
|
6
|
+
*
|
|
7
|
+
* 배압(503)은 **행에 아무 흔적을 안 남긴다.** 시도로 세지 않기로 한 대가다(§`outbox-row-effect`).
|
|
8
|
+
* 그래서 백 번 붙들린 배치도 `attempts: 0` · `lastError` 없음이고, **시도 횟수만 세는 눈은 그 고리를
|
|
9
|
+
* 영영 못 본다.** 남는 증거는 하나뿐이다 — 맨 앞 봉투가 얼마나 오래 기다렸나.
|
|
10
|
+
*
|
|
11
|
+
* ```
|
|
12
|
+
* 1 가장 오래된 대기 봉투의 나이 503 고리를 잡는 유일한 항목
|
|
13
|
+
* 2 상태별 건수 쌓이는 중인가 흐르는 중인가
|
|
14
|
+
* 3 시도가 임계를 넘은 봉투 수 이것만 세면 위의 고리를 놓친다
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* ## 나이는 `createdAt` 으로 잰다. 재시도 시각으로 재지 않는다
|
|
18
|
+
*
|
|
19
|
+
* 배압은 `sent: 0` 으로 돌아가 **상태를 안 바꾸고 같은 배치를 다시 집는다.** 재시도 시각으로 재면
|
|
20
|
+
* 매 주기가 새 측정이 되어 그 봉투는 **영영 늙지 않는다** — 고리가 돌수록 나이가 0 에 가까워진다.
|
|
21
|
+
* 만들어진 시각은 아무도 다시 쓰지 않으므로 그것만 늙는다.
|
|
22
|
+
*
|
|
23
|
+
* ## 기다리는 것이 없는 것과 못 물어본 것은 다르다
|
|
24
|
+
*
|
|
25
|
+
* 대기가 0이면 `oldestPendingAt` 이 없고 그것은 **사실**이다(「아무것도 안 기다린다」). 그때 나이를
|
|
26
|
+
* 0으로 적으면 「막 만든 봉투가 하나 있다」로 읽힌다. 없는 것은 없는 채로 둔다.
|
|
27
|
+
*
|
|
28
|
+
* ## 이 판단은 트윈 쪽을 안 본다
|
|
29
|
+
*
|
|
30
|
+
* 분담이 그렇다 — **plant 는 자기 대기 큐 깊이**, 트윈은 소식이 오지 않는 시간을 본다. 보내는 쪽이
|
|
31
|
+
* 무한 재시도 중이면 보내는 쪽만 알고, 보내는 쪽이 죽어 있으면 보내는 쪽은 아무 말도 못 한다. 양쪽이
|
|
32
|
+
* 있어야 둘 다 덮인다. 서로의 안을 들여다볼 필요는 없다.
|
|
33
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
34
|
+
*/
|
|
35
|
+
/** 시도가 이 수를 넘으면 세어 보여 준다. 임계를 넘었다는 것이지 실패했다는 것이 아니다. */
|
|
36
|
+
export declare const ATTEMPTS_WORTH_SAYING = 3;
|
|
37
|
+
export interface OutboxCounts {
|
|
38
|
+
pending: number;
|
|
39
|
+
sent: number;
|
|
40
|
+
failed: number;
|
|
41
|
+
}
|
|
42
|
+
export interface OutboxAsked {
|
|
43
|
+
channel: string;
|
|
44
|
+
counts: OutboxCounts;
|
|
45
|
+
/** 가장 오래된 **대기** 봉투가 만들어진 시각. 대기가 없으면 없다. */
|
|
46
|
+
oldestPendingAt?: Date | string | null;
|
|
47
|
+
/** 시도가 `ATTEMPTS_WORTH_SAYING` 을 넘은 대기 봉투 수. */
|
|
48
|
+
triedOften: number;
|
|
49
|
+
/** 지금 시각 — 부르는 쪽이 준다. 시험이 시계를 고정할 수 있어야 한다. */
|
|
50
|
+
now: number;
|
|
51
|
+
}
|
|
52
|
+
export interface OutboxStanding {
|
|
53
|
+
channel: string;
|
|
54
|
+
counts: OutboxCounts;
|
|
55
|
+
/** 가장 오래된 대기 봉투가 만들어진 시각. 기다리는 것이 없으면 `null`. */
|
|
56
|
+
oldestPendingAt: string | null;
|
|
57
|
+
/** 그 봉투가 기다린 시간(ms). 기다리는 것이 없으면 `null` — 0 이 아니다. */
|
|
58
|
+
waitingMs: number | null;
|
|
59
|
+
triedOften: number;
|
|
60
|
+
/** 시도가 몇 번을 넘은 것을 셌나 — 화면이 이 수를 다시 정하지 않는다. */
|
|
61
|
+
triedOftenOver: number;
|
|
62
|
+
}
|
|
63
|
+
export declare function outboxStandingOf(asked: OutboxAsked): OutboxStanding;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* **우리 대기 큐가 어떻게 서 있나** — 저장소를 모르는 판단. 수는 부르는 쪽이 세어 온다.
|
|
4
|
+
*
|
|
5
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
6
|
+
* ## 왜 나이가 첫 항목인가 (아키텍트 판정 2026-09-07)
|
|
7
|
+
*
|
|
8
|
+
* 배압(503)은 **행에 아무 흔적을 안 남긴다.** 시도로 세지 않기로 한 대가다(§`outbox-row-effect`).
|
|
9
|
+
* 그래서 백 번 붙들린 배치도 `attempts: 0` · `lastError` 없음이고, **시도 횟수만 세는 눈은 그 고리를
|
|
10
|
+
* 영영 못 본다.** 남는 증거는 하나뿐이다 — 맨 앞 봉투가 얼마나 오래 기다렸나.
|
|
11
|
+
*
|
|
12
|
+
* ```
|
|
13
|
+
* 1 가장 오래된 대기 봉투의 나이 503 고리를 잡는 유일한 항목
|
|
14
|
+
* 2 상태별 건수 쌓이는 중인가 흐르는 중인가
|
|
15
|
+
* 3 시도가 임계를 넘은 봉투 수 이것만 세면 위의 고리를 놓친다
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* ## 나이는 `createdAt` 으로 잰다. 재시도 시각으로 재지 않는다
|
|
19
|
+
*
|
|
20
|
+
* 배압은 `sent: 0` 으로 돌아가 **상태를 안 바꾸고 같은 배치를 다시 집는다.** 재시도 시각으로 재면
|
|
21
|
+
* 매 주기가 새 측정이 되어 그 봉투는 **영영 늙지 않는다** — 고리가 돌수록 나이가 0 에 가까워진다.
|
|
22
|
+
* 만들어진 시각은 아무도 다시 쓰지 않으므로 그것만 늙는다.
|
|
23
|
+
*
|
|
24
|
+
* ## 기다리는 것이 없는 것과 못 물어본 것은 다르다
|
|
25
|
+
*
|
|
26
|
+
* 대기가 0이면 `oldestPendingAt` 이 없고 그것은 **사실**이다(「아무것도 안 기다린다」). 그때 나이를
|
|
27
|
+
* 0으로 적으면 「막 만든 봉투가 하나 있다」로 읽힌다. 없는 것은 없는 채로 둔다.
|
|
28
|
+
*
|
|
29
|
+
* ## 이 판단은 트윈 쪽을 안 본다
|
|
30
|
+
*
|
|
31
|
+
* 분담이 그렇다 — **plant 는 자기 대기 큐 깊이**, 트윈은 소식이 오지 않는 시간을 본다. 보내는 쪽이
|
|
32
|
+
* 무한 재시도 중이면 보내는 쪽만 알고, 보내는 쪽이 죽어 있으면 보내는 쪽은 아무 말도 못 한다. 양쪽이
|
|
33
|
+
* 있어야 둘 다 덮인다. 서로의 안을 들여다볼 필요는 없다.
|
|
34
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
35
|
+
*/
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.ATTEMPTS_WORTH_SAYING = void 0;
|
|
38
|
+
exports.outboxStandingOf = outboxStandingOf;
|
|
39
|
+
/** 시도가 이 수를 넘으면 세어 보여 준다. 임계를 넘었다는 것이지 실패했다는 것이 아니다. */
|
|
40
|
+
exports.ATTEMPTS_WORTH_SAYING = 3;
|
|
41
|
+
/** 시각을 글자로. 없는 것은 없는 채로. */
|
|
42
|
+
function at(value) {
|
|
43
|
+
if (!value) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return value instanceof Date ? value.toISOString() : String(value);
|
|
47
|
+
}
|
|
48
|
+
function outboxStandingOf(asked) {
|
|
49
|
+
const oldest = at(asked.oldestPendingAt);
|
|
50
|
+
const since = oldest ? Date.parse(oldest) : Number.NaN;
|
|
51
|
+
return {
|
|
52
|
+
channel: asked.channel,
|
|
53
|
+
counts: asked.counts,
|
|
54
|
+
oldestPendingAt: oldest,
|
|
55
|
+
/*
|
|
56
|
+
* 미래 시각이 오면 음수가 된다 — 그것을 0 으로 깎지 않는다. 음수는 **두 시계가 어긋났다**는
|
|
57
|
+
* 사실이고, 0 으로 깎으면 「막 만들어졌다」로 읽혀 그 사실이 사라진다(§`clock-offset` 이 그
|
|
58
|
+
* 어긋남을 따로 잰다).
|
|
59
|
+
*/
|
|
60
|
+
waitingMs: Number.isFinite(since) ? asked.now - since : null,
|
|
61
|
+
triedOften: asked.triedOften,
|
|
62
|
+
triedOftenOver: exports.ATTEMPTS_WORTH_SAYING
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=standing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"standing.js","sourceRoot":"","sources":["../../../server/service/outbox/standing.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;;;AA2CH,4CAiBC;AA1DD,wDAAwD;AAC3C,QAAA,qBAAqB,GAAG,CAAC,CAAA;AA+BtC,4BAA4B;AAC5B,SAAS,EAAE,CAAC,KAAuC;IACjD,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO,IAAI,CAAA;IACb,CAAC;IAED,OAAO,KAAK,YAAY,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AACpE,CAAC;AAED,SAAgB,gBAAgB,CAAC,KAAkB;IACjD,MAAM,MAAM,GAAG,EAAE,CAAC,KAAK,CAAC,eAAe,CAAC,CAAA;IACxC,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAA;IAEtD,OAAO;QACL,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,eAAe,EAAE,MAAM;QACvB;;;;WAIG;QACH,SAAS,EAAE,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI;QAC5D,UAAU,EAAE,KAAK,CAAC,UAAU;QAC5B,cAAc,EAAE,6BAAqB;KACtC,CAAA;AACH,CAAC","sourcesContent":["/**\n * **우리 대기 큐가 어떻게 서 있나** — 저장소를 모르는 판단. 수는 부르는 쪽이 세어 온다.\n *\n * ═══════════════════════════════════════════════════════════════════════════\n * ## 왜 나이가 첫 항목인가 (아키텍트 판정 2026-09-07)\n *\n * 배압(503)은 **행에 아무 흔적을 안 남긴다.** 시도로 세지 않기로 한 대가다(§`outbox-row-effect`).\n * 그래서 백 번 붙들린 배치도 `attempts: 0` · `lastError` 없음이고, **시도 횟수만 세는 눈은 그 고리를\n * 영영 못 본다.** 남는 증거는 하나뿐이다 — 맨 앞 봉투가 얼마나 오래 기다렸나.\n *\n * ```\n * 1 가장 오래된 대기 봉투의 나이 503 고리를 잡는 유일한 항목\n * 2 상태별 건수 쌓이는 중인가 흐르는 중인가\n * 3 시도가 임계를 넘은 봉투 수 이것만 세면 위의 고리를 놓친다\n * ```\n *\n * ## 나이는 `createdAt` 으로 잰다. 재시도 시각으로 재지 않는다\n *\n * 배압은 `sent: 0` 으로 돌아가 **상태를 안 바꾸고 같은 배치를 다시 집는다.** 재시도 시각으로 재면\n * 매 주기가 새 측정이 되어 그 봉투는 **영영 늙지 않는다** — 고리가 돌수록 나이가 0 에 가까워진다.\n * 만들어진 시각은 아무도 다시 쓰지 않으므로 그것만 늙는다.\n *\n * ## 기다리는 것이 없는 것과 못 물어본 것은 다르다\n *\n * 대기가 0이면 `oldestPendingAt` 이 없고 그것은 **사실**이다(「아무것도 안 기다린다」). 그때 나이를\n * 0으로 적으면 「막 만든 봉투가 하나 있다」로 읽힌다. 없는 것은 없는 채로 둔다.\n *\n * ## 이 판단은 트윈 쪽을 안 본다\n *\n * 분담이 그렇다 — **plant 는 자기 대기 큐 깊이**, 트윈은 소식이 오지 않는 시간을 본다. 보내는 쪽이\n * 무한 재시도 중이면 보내는 쪽만 알고, 보내는 쪽이 죽어 있으면 보내는 쪽은 아무 말도 못 한다. 양쪽이\n * 있어야 둘 다 덮인다. 서로의 안을 들여다볼 필요는 없다.\n * ═══════════════════════════════════════════════════════════════════════════\n */\n\n/** 시도가 이 수를 넘으면 세어 보여 준다. 임계를 넘었다는 것이지 실패했다는 것이 아니다. */\nexport const ATTEMPTS_WORTH_SAYING = 3\n\nexport interface OutboxCounts {\n pending: number\n sent: number\n failed: number\n}\n\nexport interface OutboxAsked {\n channel: string\n counts: OutboxCounts\n /** 가장 오래된 **대기** 봉투가 만들어진 시각. 대기가 없으면 없다. */\n oldestPendingAt?: Date | string | null\n /** 시도가 `ATTEMPTS_WORTH_SAYING` 을 넘은 대기 봉투 수. */\n triedOften: number\n /** 지금 시각 — 부르는 쪽이 준다. 시험이 시계를 고정할 수 있어야 한다. */\n now: number\n}\n\nexport interface OutboxStanding {\n channel: string\n counts: OutboxCounts\n /** 가장 오래된 대기 봉투가 만들어진 시각. 기다리는 것이 없으면 `null`. */\n oldestPendingAt: string | null\n /** 그 봉투가 기다린 시간(ms). 기다리는 것이 없으면 `null` — 0 이 아니다. */\n waitingMs: number | null\n triedOften: number\n /** 시도가 몇 번을 넘은 것을 셌나 — 화면이 이 수를 다시 정하지 않는다. */\n triedOftenOver: number\n}\n\n/** 시각을 글자로. 없는 것은 없는 채로. */\nfunction at(value: Date | string | null | undefined): string | null {\n if (!value) {\n return null\n }\n\n return value instanceof Date ? value.toISOString() : String(value)\n}\n\nexport function outboxStandingOf(asked: OutboxAsked): OutboxStanding {\n const oldest = at(asked.oldestPendingAt)\n const since = oldest ? Date.parse(oldest) : Number.NaN\n\n return {\n channel: asked.channel,\n counts: asked.counts,\n oldestPendingAt: oldest,\n /*\n * 미래 시각이 오면 음수가 된다 — 그것을 0 으로 깎지 않는다. 음수는 **두 시계가 어긋났다**는\n * 사실이고, 0 으로 깎으면 「막 만들어졌다」로 읽혀 그 사실이 사라진다(§`clock-offset` 이 그\n * 어긋남을 따로 잰다).\n */\n waitingMs: Number.isFinite(since) ? asked.now - since : null,\n triedOften: asked.triedOften,\n triedOftenOver: ATTEMPTS_WORTH_SAYING\n }\n}\n"]}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { OutboxRow } from './delivery.js';
|
|
2
|
+
/**
|
|
3
|
+
* What the back row of the outbox reads and writes — and nothing about how it is stored.
|
|
4
|
+
*
|
|
5
|
+
* ═════════════════════════════════════════════════════════════════════════
|
|
6
|
+
* ## Why an interface (ADR-0078 decision 2 ②)
|
|
7
|
+
*
|
|
8
|
+
* The machine was written once, in operato-plant, against plant's own table. A second product needs the
|
|
9
|
+
* same machine, and plant's table moves to this module's table only later (its numbering has to be carried
|
|
10
|
+
* over when it does). An interface lets one dispatcher and one pump run over **both** tables in the
|
|
11
|
+
* meantime, so the machine is never two copies: this module supplies a store over `WebhookOutboxEvent`,
|
|
12
|
+
* and plant supplies one over `MesOutboxEvent` until its rows move.
|
|
13
|
+
*
|
|
14
|
+
* ## One store is one stream
|
|
15
|
+
*
|
|
16
|
+
* A store is already bound to `(domain, peer, scope)`. The dispatcher never names a domain, a peer or a
|
|
17
|
+
* scope — that is how a batch cannot mix two streams, which is how two numberings would end up on one
|
|
18
|
+
* cursor at the receiver.
|
|
19
|
+
*
|
|
20
|
+
* ## The five things a reply can do to rows — and the one it does to the peer
|
|
21
|
+
*
|
|
22
|
+
* ```
|
|
23
|
+
* markSent the peer took them
|
|
24
|
+
* markRejected the peer refused these few during validation — failed, the rest of the batch went in
|
|
25
|
+
* markAttempted the peer refused the batch for now — count the try, keep them pending
|
|
26
|
+
* markFailed resending will not change the answer — take them out of the flow
|
|
27
|
+
* rewindFrom the peer has a hole: what we called sent from there on is pending again
|
|
28
|
+
*
|
|
29
|
+
* noteHeld the peer asked us to wait. **No row is touched** — it is not an attempt
|
|
30
|
+
* noteTaken the peer took a batch — it is not holding us any more
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* `noteHeld` exists because being held left no trace anywhere but a log line: a hundred holds is still
|
|
34
|
+
* `attempts 0` and no `lastError` on every row. It is a fact about the peer, so it is kept with the
|
|
35
|
+
* destination (decision 3).
|
|
36
|
+
* ═════════════════════════════════════════════════════════════════════════
|
|
37
|
+
*/
|
|
38
|
+
export interface StoredOutboxRow extends OutboxRow {
|
|
39
|
+
id: string;
|
|
40
|
+
}
|
|
41
|
+
export interface OutboxStore {
|
|
42
|
+
/** Pending rows of this stream, lowest number first. */
|
|
43
|
+
pending(take: number): Promise<StoredOutboxRow[]>;
|
|
44
|
+
markSent(ids: readonly string[], at: Date): Promise<void>;
|
|
45
|
+
markRejected(ids: readonly string[], reason: string): Promise<void>;
|
|
46
|
+
markAttempted(ids: readonly string[], reason: string): Promise<void>;
|
|
47
|
+
markFailed(ids: readonly string[]): Promise<void>;
|
|
48
|
+
/** Rows of this stream marked sent with `seq >= fromSeq` go back to pending. */
|
|
49
|
+
rewindFrom(fromSeq: number): Promise<void>;
|
|
50
|
+
noteHeld(retryAfterMs: number, at: Date): Promise<void>;
|
|
51
|
+
noteTaken(): Promise<void>;
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../../../server/service/outbox/store.ts"],"names":[],"mappings":"","sourcesContent":["import type { OutboxRow } from './delivery.js'\n\n/**\n * What the back row of the outbox reads and writes — and nothing about how it is stored.\n *\n * ═════════════════════════════════════════════════════════════════════════\n * ## Why an interface (ADR-0078 decision 2 ②)\n *\n * The machine was written once, in operato-plant, against plant's own table. A second product needs the\n * same machine, and plant's table moves to this module's table only later (its numbering has to be carried\n * over when it does). An interface lets one dispatcher and one pump run over **both** tables in the\n * meantime, so the machine is never two copies: this module supplies a store over `WebhookOutboxEvent`,\n * and plant supplies one over `MesOutboxEvent` until its rows move.\n *\n * ## One store is one stream\n *\n * A store is already bound to `(domain, peer, scope)`. The dispatcher never names a domain, a peer or a\n * scope — that is how a batch cannot mix two streams, which is how two numberings would end up on one\n * cursor at the receiver.\n *\n * ## The five things a reply can do to rows — and the one it does to the peer\n *\n * ```\n * markSent the peer took them\n * markRejected the peer refused these few during validation — failed, the rest of the batch went in\n * markAttempted the peer refused the batch for now — count the try, keep them pending\n * markFailed resending will not change the answer — take them out of the flow\n * rewindFrom the peer has a hole: what we called sent from there on is pending again\n *\n * noteHeld the peer asked us to wait. **No row is touched** — it is not an attempt\n * noteTaken the peer took a batch — it is not holding us any more\n * ```\n *\n * `noteHeld` exists because being held left no trace anywhere but a log line: a hundred holds is still\n * `attempts 0` and no `lastError` on every row. It is a fact about the peer, so it is kept with the\n * destination (decision 3).\n * ═════════════════════════════════════════════════════════════════════════\n */\nexport interface StoredOutboxRow extends OutboxRow {\n id: string\n}\n\nexport interface OutboxStore {\n /** Pending rows of this stream, lowest number first. */\n pending(take: number): Promise<StoredOutboxRow[]>\n\n markSent(ids: readonly string[], at: Date): Promise<void>\n markRejected(ids: readonly string[], reason: string): Promise<void>\n markAttempted(ids: readonly string[], reason: string): Promise<void>\n markFailed(ids: readonly string[]): Promise<void>\n\n /** Rows of this stream marked sent with `seq >= fromSeq` go back to pending. */\n rewindFrom(fromSeq: number): Promise<void>\n\n noteHeld(retryAfterMs: number, at: Date): Promise<void>\n noteTaken(): Promise<void>\n}\n"]}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { EntityManager } from 'typeorm';
|
|
2
|
+
import type { OutboxStore } from './store.js';
|
|
3
|
+
/**
|
|
4
|
+
* The store over this module's own tables — one stream, `(domain, peer, scope)`.
|
|
5
|
+
*
|
|
6
|
+
* It is the only file of the back row that knows TypeORM. The dispatcher and the pump see `OutboxStore`.
|
|
7
|
+
*
|
|
8
|
+
* Every write names its rows by id or by the stream's three keys. None of them is wider than the stream:
|
|
9
|
+
* `rewindFrom` on a wider `where` would turn another peer's delivered rows back to pending, and the other
|
|
10
|
+
* peer would receive them again as duplicates it has to filter.
|
|
11
|
+
*/
|
|
12
|
+
export interface WebhookStream {
|
|
13
|
+
domainId: string;
|
|
14
|
+
peer: string;
|
|
15
|
+
scope: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function webhookStore(manager: EntityManager, stream: WebhookStream): OutboxStore;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.webhookStore = webhookStore;
|
|
4
|
+
const typeorm_1 = require("typeorm");
|
|
5
|
+
const webhook_destination_js_1 = require("../webhook-destination/webhook-destination.js");
|
|
6
|
+
const webhook_outbox_event_js_1 = require("../webhook-outbox-event/webhook-outbox-event.js");
|
|
7
|
+
function webhookStore(manager, stream) {
|
|
8
|
+
const { domainId, peer, scope } = stream;
|
|
9
|
+
const ofStream = { domain: { id: domainId }, peer, scope };
|
|
10
|
+
const ofPeer = { domain: { id: domainId }, peer };
|
|
11
|
+
return {
|
|
12
|
+
async pending(take) {
|
|
13
|
+
return await manager.find(webhook_outbox_event_js_1.WebhookOutboxEvent, {
|
|
14
|
+
where: { ...ofStream, status: webhook_outbox_event_js_1.WebhookOutboxStatus.Pending },
|
|
15
|
+
order: { seq: 'ASC' },
|
|
16
|
+
take
|
|
17
|
+
});
|
|
18
|
+
},
|
|
19
|
+
async markSent(ids, at) {
|
|
20
|
+
if (!ids.length)
|
|
21
|
+
return;
|
|
22
|
+
await manager.update(webhook_outbox_event_js_1.WebhookOutboxEvent, { id: (0, typeorm_1.In)([...ids]) }, { status: webhook_outbox_event_js_1.WebhookOutboxStatus.Sent, sentAt: at });
|
|
23
|
+
},
|
|
24
|
+
async markRejected(ids, reason) {
|
|
25
|
+
if (!ids.length)
|
|
26
|
+
return;
|
|
27
|
+
await manager.update(webhook_outbox_event_js_1.WebhookOutboxEvent, { id: (0, typeorm_1.In)([...ids]) }, { status: webhook_outbox_event_js_1.WebhookOutboxStatus.Failed, lastError: reason });
|
|
28
|
+
},
|
|
29
|
+
async markAttempted(ids, reason) {
|
|
30
|
+
if (!ids.length)
|
|
31
|
+
return;
|
|
32
|
+
await manager.increment(webhook_outbox_event_js_1.WebhookOutboxEvent, { id: (0, typeorm_1.In)([...ids]) }, 'attempts', 1);
|
|
33
|
+
await manager.update(webhook_outbox_event_js_1.WebhookOutboxEvent, { id: (0, typeorm_1.In)([...ids]) }, { lastError: reason });
|
|
34
|
+
},
|
|
35
|
+
async markFailed(ids) {
|
|
36
|
+
if (!ids.length)
|
|
37
|
+
return;
|
|
38
|
+
await manager.update(webhook_outbox_event_js_1.WebhookOutboxEvent, { id: (0, typeorm_1.In)([...ids]) }, { status: webhook_outbox_event_js_1.WebhookOutboxStatus.Failed });
|
|
39
|
+
},
|
|
40
|
+
async rewindFrom(fromSeq) {
|
|
41
|
+
await manager.update(webhook_outbox_event_js_1.WebhookOutboxEvent, { ...ofStream, seq: (0, typeorm_1.MoreThanOrEqual)(fromSeq), status: webhook_outbox_event_js_1.WebhookOutboxStatus.Sent }, { status: webhook_outbox_event_js_1.WebhookOutboxStatus.Pending, sentAt: null });
|
|
42
|
+
},
|
|
43
|
+
/*
|
|
44
|
+
* Held since the **first** hold of this run, not the latest — "how long has the peer been pushing back"
|
|
45
|
+
* is the question an administrator asks. The count says how many times it was said.
|
|
46
|
+
*/
|
|
47
|
+
async noteHeld(retryAfterMs, at) {
|
|
48
|
+
const destination = await manager.findOne(webhook_destination_js_1.WebhookDestination, { where: ofPeer });
|
|
49
|
+
if (!destination)
|
|
50
|
+
return;
|
|
51
|
+
await manager.update(webhook_destination_js_1.WebhookDestination, { id: destination.id }, {
|
|
52
|
+
heldSince: destination.heldSince ?? at,
|
|
53
|
+
heldCount: Number(destination.heldCount ?? 0) + 1,
|
|
54
|
+
heldRetryAfterMs: retryAfterMs
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
async noteTaken() {
|
|
58
|
+
await manager.update(webhook_destination_js_1.WebhookDestination, { ...ofPeer }, { heldSince: null, heldCount: 0, heldRetryAfterMs: null });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=webhook-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-store.js","sourceRoot":"","sources":["../../../server/service/outbox/webhook-store.ts"],"names":[],"mappings":"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","sourcesContent":["import { EntityManager, In, MoreThanOrEqual } from 'typeorm'\n\nimport { WebhookDestination } from '../webhook-destination/webhook-destination.js'\nimport { WebhookOutboxEvent, WebhookOutboxStatus } from '../webhook-outbox-event/webhook-outbox-event.js'\nimport type { OutboxStore, StoredOutboxRow } from './store.js'\n\n/**\n * The store over this module's own tables — one stream, `(domain, peer, scope)`.\n *\n * It is the only file of the back row that knows TypeORM. The dispatcher and the pump see `OutboxStore`.\n *\n * Every write names its rows by id or by the stream's three keys. None of them is wider than the stream:\n * `rewindFrom` on a wider `where` would turn another peer's delivered rows back to pending, and the other\n * peer would receive them again as duplicates it has to filter.\n */\nexport interface WebhookStream {\n domainId: string\n peer: string\n scope: string\n}\n\nexport function webhookStore(manager: EntityManager, stream: WebhookStream): OutboxStore {\n const { domainId, peer, scope } = stream\n const ofStream = { domain: { id: domainId }, peer, scope }\n const ofPeer = { domain: { id: domainId }, peer }\n\n return {\n async pending(take: number): Promise<StoredOutboxRow[]> {\n return await manager.find(WebhookOutboxEvent, {\n where: { ...ofStream, status: WebhookOutboxStatus.Pending },\n order: { seq: 'ASC' },\n take\n })\n },\n\n async markSent(ids, at) {\n if (!ids.length) return\n await manager.update(WebhookOutboxEvent, { id: In([...ids]) }, { status: WebhookOutboxStatus.Sent, sentAt: at })\n },\n\n async markRejected(ids, reason) {\n if (!ids.length) return\n await manager.update(WebhookOutboxEvent, { id: In([...ids]) }, { status: WebhookOutboxStatus.Failed, lastError: reason })\n },\n\n async markAttempted(ids, reason) {\n if (!ids.length) return\n await manager.increment(WebhookOutboxEvent, { id: In([...ids]) }, 'attempts', 1)\n await manager.update(WebhookOutboxEvent, { id: In([...ids]) }, { lastError: reason })\n },\n\n async markFailed(ids) {\n if (!ids.length) return\n await manager.update(WebhookOutboxEvent, { id: In([...ids]) }, { status: WebhookOutboxStatus.Failed })\n },\n\n async rewindFrom(fromSeq) {\n await manager.update(\n WebhookOutboxEvent,\n { ...ofStream, seq: MoreThanOrEqual(fromSeq), status: WebhookOutboxStatus.Sent },\n { status: WebhookOutboxStatus.Pending, sentAt: null }\n )\n },\n\n /*\n * Held since the **first** hold of this run, not the latest — \"how long has the peer been pushing back\"\n * is the question an administrator asks. The count says how many times it was said.\n */\n async noteHeld(retryAfterMs, at) {\n const destination = await manager.findOne(WebhookDestination, { where: ofPeer })\n if (!destination) return\n\n await manager.update(\n WebhookDestination,\n { id: destination.id },\n {\n heldSince: destination.heldSince ?? at,\n heldCount: Number(destination.heldCount ?? 0) + 1,\n heldRetryAfterMs: retryAfterMs\n }\n )\n },\n\n async noteTaken() {\n await manager.update(WebhookDestination, { ...ofPeer }, { heldSince: null, heldCount: 0, heldRetryAfterMs: null })\n }\n }\n}\n"]}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 아웃박스에 쓰는 관리자가 **트랜잭션인가** — 판단 하나, import 없음.
|
|
3
|
+
*
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* ── 왜 따로 있나 ───────────────────────────────────────────────────────────
|
|
6
|
+
* `mes-outbox-append.ts` 는 엔티티를 들여오고, 그 엔티티는 데코레이터를 들고 있다. 그래서 그 파일을
|
|
7
|
+
* 시험에서 부르면 저장소 계층이 통째로 딸려 온다. 판단만 여기 두면 **띄우지 않고 부를 수 있다.**
|
|
8
|
+
*
|
|
9
|
+
* ── 무엇을 보나 ───────────────────────────────────────────────────────────
|
|
10
|
+
* TypeORM 의 기본 관리자에는 `queryRunner` 가 없고, `transaction()` 이나 `@transaction` 이 만들어 준
|
|
11
|
+
* 관리자에는 있다. 그것이 트랜잭션을 열고 있는지까지 본다 — 러너가 있어도 트랜잭션이 안 열렸으면
|
|
12
|
+
* 문장은 그 자리에서 커밋된다.
|
|
13
|
+
*
|
|
14
|
+
* ── 왜 이것이 중요한가 ─────────────────────────────────────────────────────
|
|
15
|
+
* 아웃박스 번호는 채번 행을 한 칸 올려서 얻는다. 올리는 것과 행을 쓰는 것이 한 트랜잭션이 아니면,
|
|
16
|
+
* 올리기만 커밋되고 행이 안 써지는 순간이 생긴다. 그러면 그 번호는 **영영 없는 번호**가 된다 —
|
|
17
|
+
* 받는 쪽은 거기서 멈추고, 이 모듈에는 지우는 코드가 없으므로 되짚을 자국도 없다.
|
|
18
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
19
|
+
*/
|
|
20
|
+
/** 이 판단에 필요한 만큼만. 진짜 `EntityManager` 도 이 모양을 만족한다. */
|
|
21
|
+
export interface MaybeTransactional {
|
|
22
|
+
queryRunner?: {
|
|
23
|
+
isTransactionActive?: boolean;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export declare function isWritingTransaction(manager: MaybeTransactional | undefined | null): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* 트랜잭션이 아니면 던진다.
|
|
29
|
+
*
|
|
30
|
+
* 문장은 **무엇을 잃는지**를 말한다. 「트랜잭션이 필요합니다」로만 적으면 부르는 사람이 형식 문제로
|
|
31
|
+
* 읽고 관리자를 아무거나 바꿔 넣는다.
|
|
32
|
+
*/
|
|
33
|
+
export declare function assertWritingTransaction(manager: MaybeTransactional | undefined | null): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 아웃박스에 쓰는 관리자가 **트랜잭션인가** — 판단 하나, import 없음.
|
|
4
|
+
*
|
|
5
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
6
|
+
* ── 왜 따로 있나 ───────────────────────────────────────────────────────────
|
|
7
|
+
* `mes-outbox-append.ts` 는 엔티티를 들여오고, 그 엔티티는 데코레이터를 들고 있다. 그래서 그 파일을
|
|
8
|
+
* 시험에서 부르면 저장소 계층이 통째로 딸려 온다. 판단만 여기 두면 **띄우지 않고 부를 수 있다.**
|
|
9
|
+
*
|
|
10
|
+
* ── 무엇을 보나 ───────────────────────────────────────────────────────────
|
|
11
|
+
* TypeORM 의 기본 관리자에는 `queryRunner` 가 없고, `transaction()` 이나 `@transaction` 이 만들어 준
|
|
12
|
+
* 관리자에는 있다. 그것이 트랜잭션을 열고 있는지까지 본다 — 러너가 있어도 트랜잭션이 안 열렸으면
|
|
13
|
+
* 문장은 그 자리에서 커밋된다.
|
|
14
|
+
*
|
|
15
|
+
* ── 왜 이것이 중요한가 ─────────────────────────────────────────────────────
|
|
16
|
+
* 아웃박스 번호는 채번 행을 한 칸 올려서 얻는다. 올리는 것과 행을 쓰는 것이 한 트랜잭션이 아니면,
|
|
17
|
+
* 올리기만 커밋되고 행이 안 써지는 순간이 생긴다. 그러면 그 번호는 **영영 없는 번호**가 된다 —
|
|
18
|
+
* 받는 쪽은 거기서 멈추고, 이 모듈에는 지우는 코드가 없으므로 되짚을 자국도 없다.
|
|
19
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.isWritingTransaction = isWritingTransaction;
|
|
23
|
+
exports.assertWritingTransaction = assertWritingTransaction;
|
|
24
|
+
function isWritingTransaction(manager) {
|
|
25
|
+
return Boolean(manager?.queryRunner?.isTransactionActive);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 트랜잭션이 아니면 던진다.
|
|
29
|
+
*
|
|
30
|
+
* 문장은 **무엇을 잃는지**를 말한다. 「트랜잭션이 필요합니다」로만 적으면 부르는 사람이 형식 문제로
|
|
31
|
+
* 읽고 관리자를 아무거나 바꿔 넣는다.
|
|
32
|
+
*/
|
|
33
|
+
function assertWritingTransaction(manager) {
|
|
34
|
+
if (isWritingTransaction(manager)) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
throw new Error('appendOutbox: must run inside the transaction that writes the fact — ' +
|
|
38
|
+
'a plain manager commits the sequence bump on its own, and a later failure burns that number for good');
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=writing-transaction.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"writing-transaction.js","sourceRoot":"","sources":["../../../server/service/outbox/writing-transaction.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;GAkBG;;AAOH,oDAEC;AAQD,4DASC;AAnBD,SAAgB,oBAAoB,CAAC,OAA8C;IACjF,OAAO,OAAO,CAAC,OAAO,EAAE,WAAW,EAAE,mBAAmB,CAAC,CAAA;AAC3D,CAAC;AAED;;;;;GAKG;AACH,SAAgB,wBAAwB,CAAC,OAA8C;IACrF,IAAI,oBAAoB,CAAC,OAAO,CAAC,EAAE,CAAC;QAClC,OAAM;IACR,CAAC;IAED,MAAM,IAAI,KAAK,CACb,uEAAuE;QACrE,sGAAsG,CACzG,CAAA;AACH,CAAC","sourcesContent":["/**\n * 아웃박스에 쓰는 관리자가 **트랜잭션인가** — 판단 하나, import 없음.\n *\n * ═══════════════════════════════════════════════════════════════════════════\n * ── 왜 따로 있나 ───────────────────────────────────────────────────────────\n * `mes-outbox-append.ts` 는 엔티티를 들여오고, 그 엔티티는 데코레이터를 들고 있다. 그래서 그 파일을\n * 시험에서 부르면 저장소 계층이 통째로 딸려 온다. 판단만 여기 두면 **띄우지 않고 부를 수 있다.**\n *\n * ── 무엇을 보나 ───────────────────────────────────────────────────────────\n * TypeORM 의 기본 관리자에는 `queryRunner` 가 없고, `transaction()` 이나 `@transaction` 이 만들어 준\n * 관리자에는 있다. 그것이 트랜잭션을 열고 있는지까지 본다 — 러너가 있어도 트랜잭션이 안 열렸으면\n * 문장은 그 자리에서 커밋된다.\n *\n * ── 왜 이것이 중요한가 ─────────────────────────────────────────────────────\n * 아웃박스 번호는 채번 행을 한 칸 올려서 얻는다. 올리는 것과 행을 쓰는 것이 한 트랜잭션이 아니면,\n * 올리기만 커밋되고 행이 안 써지는 순간이 생긴다. 그러면 그 번호는 **영영 없는 번호**가 된다 —\n * 받는 쪽은 거기서 멈추고, 이 모듈에는 지우는 코드가 없으므로 되짚을 자국도 없다.\n * ═══════════════════════════════════════════════════════════════════════════\n */\n\n/** 이 판단에 필요한 만큼만. 진짜 `EntityManager` 도 이 모양을 만족한다. */\nexport interface MaybeTransactional {\n queryRunner?: { isTransactionActive?: boolean }\n}\n\nexport function isWritingTransaction(manager: MaybeTransactional | undefined | null): boolean {\n return Boolean(manager?.queryRunner?.isTransactionActive)\n}\n\n/**\n * 트랜잭션이 아니면 던진다.\n *\n * 문장은 **무엇을 잃는지**를 말한다. 「트랜잭션이 필요합니다」로만 적으면 부르는 사람이 형식 문제로\n * 읽고 관리자를 아무거나 바꿔 넣는다.\n */\nexport function assertWritingTransaction(manager: MaybeTransactional | undefined | null): void {\n if (isWritingTransaction(manager)) {\n return\n }\n\n throw new Error(\n 'appendOutbox: must run inside the transaction that writes the fact — ' +\n 'a plain manager commits the sequence bump on its own, and a later failure burns that number for good'\n )\n}\n"]}
|
|
@@ -82,6 +82,21 @@ tslib_1.__decorate([
|
|
|
82
82
|
(0, type_graphql_1.Field)({ nullable: true, description: 'What the last delivery attempt learned about reachability. Not the same as `active`, which is a decision.' }),
|
|
83
83
|
tslib_1.__metadata("design:type", String)
|
|
84
84
|
], WebhookDestination.prototype, "state", void 0);
|
|
85
|
+
tslib_1.__decorate([
|
|
86
|
+
(0, typeorm_1.Column)({ nullable: true }),
|
|
87
|
+
(0, type_graphql_1.Field)({ nullable: true, description: 'Since when the peer has been asking us to wait. Null when it is taking what we send.' }),
|
|
88
|
+
tslib_1.__metadata("design:type", Date)
|
|
89
|
+
], WebhookDestination.prototype, "heldSince", void 0);
|
|
90
|
+
tslib_1.__decorate([
|
|
91
|
+
(0, typeorm_1.Column)({ type: 'int', default: 0 }),
|
|
92
|
+
(0, type_graphql_1.Field)(type => type_graphql_1.Int, { description: 'How many times in a row the peer asked us to wait. Reset when it takes a batch.' }),
|
|
93
|
+
tslib_1.__metadata("design:type", Number)
|
|
94
|
+
], WebhookDestination.prototype, "heldCount", void 0);
|
|
95
|
+
tslib_1.__decorate([
|
|
96
|
+
(0, typeorm_1.Column)({ type: 'int', nullable: true }),
|
|
97
|
+
(0, type_graphql_1.Field)(type => type_graphql_1.Int, { nullable: true, description: 'The wait the peer last asked for, in milliseconds.' }),
|
|
98
|
+
tslib_1.__metadata("design:type", Number)
|
|
99
|
+
], WebhookDestination.prototype, "heldRetryAfterMs", void 0);
|
|
85
100
|
tslib_1.__decorate([
|
|
86
101
|
(0, typeorm_1.CreateDateColumn)(),
|
|
87
102
|
(0, type_graphql_1.Field)({ nullable: true }),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webhook-destination.js","sourceRoot":"","sources":["../../../server/service/webhook-destination/webhook-destination.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n Column,\n CreateDateColumn,\n Entity,\n Index,\n ManyToOne,\n PrimaryGeneratedColumn,\n RelationId,\n UpdateDateColumn\n} from 'typeorm'\nimport { Field, ID, ObjectType } from 'type-graphql'\n\nimport { Domain, ScalarObject, encryptedJsonColumn } from '@things-factory/shell'\nimport { User } from '@things-factory/auth-base'\n\n/*\n * ═══════════════════════════════════════════════════════════════════════════\n * Where this tenant pushes facts, and who it is pushing them to.\n *\n * ── The unit is (domain, peer) — three places used three units ─────────────\n * This concept already existed twice in the platform, keyed three different ways\n * (ADR-0041, measured 2026-09-08):\n *\n * operato-plant the destination table UNIQUE(domain_id) per tenant\n * operato-plant the inbound signing key keyed by siteId per site\n * operato-twin the same pair of keys twin_references row per connection\n *\n * None of those survives one tenant talking to two peers. A tenant that mirrors into two\n * twins, or pushes to a twin *and* an ERP, needs a row for each — and each of those rows\n * needs its own keys, its own numbering and its own delivery state.\n *\n * So `peer` is the unit: `mes-miratek`, `erp-sap`, a second twin. It names **who we are\n * talking to**, not what kind of system we are. That distinction is why `erp_postings` and\n * `mes_outbox_events` were two machines in one package — they were two peers, and nothing\n * in the model said so.\n *\n * ── The address is not here ────────────────────────────────────────────────\n * `endpoint` is where the POST goes. The *route* on the receiving side is owned by whichever\n * module opens that door, and it uses the contract's system vocabulary\n * (`DomainSystem = 'wms' | 'yms' | 'mes' | 'ems'`), not a product name — `/webhook/mes/…`\n * stays as it is. A sender writes an address into its own config, so the word in it has to\n * outlive product renames.\n * ═══════════════════════════════════════════════════════════════════════════\n */\n@ObjectType({ description: 'A peer this tenant pushes facts to, and the address and options to reach it.' })\n@Entity()\n@Index('ix_webhook_destination_0', (dest: WebhookDestination) => [dest.domain, dest.peer], { unique: true })\nexport class WebhookDestination {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { description: 'Unique identifier for this destination.' })\n readonly id: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { description: 'The tenant whose facts go to this peer.' })\n domain: Domain\n\n @RelationId((dest: WebhookDestination) => dest.domain)\n domainId: string\n\n /**\n * Who we are talking to. **The unit of everything on this seam** — keys, numbering,\n * delivery state and accounting are all per peer.\n *\n * A name, not a kind: `mes-miratek`, `erp-sap`, `twin-b`. Two peers of the same kind are\n * two rows, and that is the point.\n */\n @Column()\n @Field({ description: 'Name of the peer being pushed to (e.g. mes-miratek, erp-sap). The unit of keys, numbering and delivery state.' })\n peer: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'What this destination is for, in a person\\'s words.' })\n description: string\n\n /** Where the POST goes. */\n @Column()\n @Field({ description: 'The URL facts are posted to.' })\n endpoint: string\n\n /**\n * What the receiving side needs in order to place the envelopes — its own tenant, the\n * instance they belong to, and anything else that peer asks for.\n *\n * Deliberately open: the protocol is fixed by the contract, but what a given peer wants in\n * the path or the headers is that peer's business. `receivingDomain` and `instanceId` live\n * here rather than as columns, because the next peer may want neither.\n *\n * **No secrets here.** Signing and verifying keys come from the environment, per peer\n * (`webhookSecretCandidates`), because that kind of key is one per installation and can be\n * provisioned at deploy time.\n *\n * The column is nonetheless encrypted at rest. The rule above is a declaration, and a\n * declaration does not stop a value arriving — on 2026-09-09 a connection config in the twin\n * was found holding a signing secret and an access token in the clear, in a column whose own\n * comment said credentials belong there. Encrypting this one costs a line and means a mistake\n * is not readable from a database file or a backup. It does not make this the place for keys.\n */\n @Column({ nullable: true, ...encryptedJsonColumn() })\n @Field(type => ScalarObject, { nullable: true, description: 'Options this peer needs (receiving domain, instance, path parts). Not the place for secrets; encrypted at rest so a mistake is not readable.' })\n params: any\n\n /**\n * Whether to push at all. **Off is not the same as removed** — a destination that is\n * turned off keeps its numbering and its queue, so turning it back on resumes rather than\n * starts over.\n */\n @Column({ default: true })\n @Field({ description: 'Whether facts are being pushed to this peer right now. Turning it off keeps the queue and the numbering.' })\n active: boolean\n\n /**\n * What the last attempt learned — reachable or not.\n *\n * Separate from `active` because they answer different questions: `active` is what a person\n * decided, this is what the network said. Folding them together loses the difference\n * between \"we stopped pushing\" and \"we cannot get through\".\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'What the last delivery attempt learned about reachability. Not the same as `active`, which is a decision.' })\n state: string\n\n @CreateDateColumn()\n @Field({ nullable: true })\n createdAt: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true })\n updatedAt: Date\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true })\n creator: User\n\n @RelationId((dest: WebhookDestination) => dest.creator)\n creatorId: string\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true })\n updater: User\n\n @RelationId((dest: WebhookDestination) => dest.updater)\n updaterId: string\n}\n"]}
|
|
1
|
+
{"version":3,"file":"webhook-destination.js","sourceRoot":"","sources":["../../../server/service/webhook-destination/webhook-destination.ts"],"names":[],"mappings":";;;;AAAA,qCASgB;AAChB,+CAAyD;AAEzD,iDAAiF;AACjF,yDAAgD;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAII,IAAM,kBAAkB,GAAxB,MAAM,kBAAkB;CAsH9B,CAAA;AAtHY,gDAAkB;AAGpB;IAFR,IAAA,gCAAsB,EAAC,MAAM,CAAC;IAC9B,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,iBAAE,EAAE,EAAE,WAAW,EAAE,yCAAyC,EAAE,CAAC;;8CAC3D;AAInB;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,CAAC;IACzB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,EAAE,EAAE,WAAW,EAAE,yCAAyC,EAAE,CAAC;sCAC1E,cAAM;kDAAA;AAGd;IADC,IAAA,oBAAU,EAAC,CAAC,IAAwB,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC;;oDACtC;AAWhB;IAFC,IAAA,gBAAM,GAAE;IACR,IAAA,oBAAK,EAAC,EAAE,WAAW,EAAE,+GAA+G,EAAE,CAAC;;gDAC5H;AAIZ;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,qDAAqD,EAAE,CAAC;;uDAC3E;AAKnB;IAFC,IAAA,gBAAM,GAAE;IACR,IAAA,oBAAK,EAAC,EAAE,WAAW,EAAE,8BAA8B,EAAE,CAAC;;oDACvC;AAsBhB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,IAAA,2BAAmB,GAAE,EAAE,CAAC;IACpD,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,oBAAY,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,8IAA8I,EAAE,CAAC;;kDAClM;AASX;IAFC,IAAA,gBAAM,EAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;IACzB,IAAA,oBAAK,EAAC,EAAE,WAAW,EAAE,0GAA0G,EAAE,CAAC;;kDACpH;AAWf;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,2GAA2G,EAAE,CAAC;;iDACvI;AAeb;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,sFAAsF,EAAE,CAAC;sCACpH,IAAI;qDAAA;AAIf;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC;IACnC,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,kBAAG,EAAE,EAAE,WAAW,EAAE,iFAAiF,EAAE,CAAC;;qDACtG;AAIjB;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IACvC,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,kBAAG,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,oDAAoD,EAAE,CAAC;;4DAClF;AAIxB;IAFC,IAAA,0BAAgB,GAAE;IAClB,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;sCACf,IAAI;qDAAA;AAIf;IAFC,IAAA,0BAAgB,GAAE;IAClB,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;sCACf,IAAI;qDAAA;AAIf;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC3C,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;sCAC/B,gBAAI;mDAAA;AAGb;IADC,IAAA,oBAAU,EAAC,CAAC,IAAwB,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC;;qDACtC;AAIjB;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC3C,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;sCAC/B,gBAAI;mDAAA;AAGb;IADC,IAAA,oBAAU,EAAC,CAAC,IAAwB,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC;;qDACtC;6BArHN,kBAAkB;IAH9B,IAAA,yBAAU,EAAC,EAAE,WAAW,EAAE,8EAA8E,EAAE,CAAC;IAC3G,IAAA,gBAAM,GAAE;IACR,IAAA,eAAK,EAAC,0BAA0B,EAAE,CAAC,IAAwB,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;GAC/F,kBAAkB,CAsH9B","sourcesContent":["import {\n Column,\n CreateDateColumn,\n Entity,\n Index,\n ManyToOne,\n PrimaryGeneratedColumn,\n RelationId,\n UpdateDateColumn\n} from 'typeorm'\nimport { Field, ID, Int, ObjectType } from 'type-graphql'\n\nimport { Domain, ScalarObject, encryptedJsonColumn } from '@things-factory/shell'\nimport { User } from '@things-factory/auth-base'\n\n/*\n * ═══════════════════════════════════════════════════════════════════════════\n * Where this tenant pushes facts, and who it is pushing them to.\n *\n * ── The unit is (domain, peer) — three places used three units ─────────────\n * This concept already existed twice in the platform, keyed three different ways\n * (ADR-0041, measured 2026-09-08):\n *\n * operato-plant the destination table UNIQUE(domain_id) per tenant\n * operato-plant the inbound signing key keyed by siteId per site\n * operato-twin the same pair of keys twin_references row per connection\n *\n * None of those survives one tenant talking to two peers. A tenant that mirrors into two\n * twins, or pushes to a twin *and* an ERP, needs a row for each — and each of those rows\n * needs its own keys, its own numbering and its own delivery state.\n *\n * So `peer` is the unit: `mes-miratek`, `erp-sap`, a second twin. It names **who we are\n * talking to**, not what kind of system we are. That distinction is why `erp_postings` and\n * `mes_outbox_events` were two machines in one package — they were two peers, and nothing\n * in the model said so.\n *\n * ── The address is not here ────────────────────────────────────────────────\n * `endpoint` is where the POST goes. The *route* on the receiving side is owned by whichever\n * module opens that door, and it uses the contract's system vocabulary\n * (`DomainSystem = 'wms' | 'yms' | 'mes' | 'ems'`), not a product name — `/webhook/mes/…`\n * stays as it is. A sender writes an address into its own config, so the word in it has to\n * outlive product renames.\n * ═══════════════════════════════════════════════════════════════════════════\n */\n@ObjectType({ description: 'A peer this tenant pushes facts to, and the address and options to reach it.' })\n@Entity()\n@Index('ix_webhook_destination_0', (dest: WebhookDestination) => [dest.domain, dest.peer], { unique: true })\nexport class WebhookDestination {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { description: 'Unique identifier for this destination.' })\n readonly id: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { description: 'The tenant whose facts go to this peer.' })\n domain: Domain\n\n @RelationId((dest: WebhookDestination) => dest.domain)\n domainId: string\n\n /**\n * Who we are talking to. **The unit of everything on this seam** — keys, numbering,\n * delivery state and accounting are all per peer.\n *\n * A name, not a kind: `mes-miratek`, `erp-sap`, `twin-b`. Two peers of the same kind are\n * two rows, and that is the point.\n */\n @Column()\n @Field({ description: 'Name of the peer being pushed to (e.g. mes-miratek, erp-sap). The unit of keys, numbering and delivery state.' })\n peer: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'What this destination is for, in a person\\'s words.' })\n description: string\n\n /** Where the POST goes. */\n @Column()\n @Field({ description: 'The URL facts are posted to.' })\n endpoint: string\n\n /**\n * What the receiving side needs in order to place the envelopes — its own tenant, the\n * instance they belong to, and anything else that peer asks for.\n *\n * Deliberately open: the protocol is fixed by the contract, but what a given peer wants in\n * the path or the headers is that peer's business. `receivingDomain` and `instanceId` live\n * here rather than as columns, because the next peer may want neither.\n *\n * **No secrets here.** Signing and verifying keys come from the environment, per peer\n * (`webhookSecretCandidates`), because that kind of key is one per installation and can be\n * provisioned at deploy time.\n *\n * The column is nonetheless encrypted at rest. The rule above is a declaration, and a\n * declaration does not stop a value arriving — on 2026-09-09 a connection config in the twin\n * was found holding a signing secret and an access token in the clear, in a column whose own\n * comment said credentials belong there. Encrypting this one costs a line and means a mistake\n * is not readable from a database file or a backup. It does not make this the place for keys.\n */\n @Column({ nullable: true, ...encryptedJsonColumn() })\n @Field(type => ScalarObject, { nullable: true, description: 'Options this peer needs (receiving domain, instance, path parts). Not the place for secrets; encrypted at rest so a mistake is not readable.' })\n params: any\n\n /**\n * Whether to push at all. **Off is not the same as removed** — a destination that is\n * turned off keeps its numbering and its queue, so turning it back on resumes rather than\n * starts over.\n */\n @Column({ default: true })\n @Field({ description: 'Whether facts are being pushed to this peer right now. Turning it off keeps the queue and the numbering.' })\n active: boolean\n\n /**\n * What the last attempt learned — reachable or not.\n *\n * Separate from `active` because they answer different questions: `active` is what a person\n * decided, this is what the network said. Folding them together loses the difference\n * between \"we stopped pushing\" and \"we cannot get through\".\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'What the last delivery attempt learned about reachability. Not the same as `active`, which is a decision.' })\n state: string\n\n /*\n * ── The trace of being asked to wait (ADR-0078 decision 3) ────────────────\n * A 429 or a 503 touches no event row — it is not an attempt, and counting it would spend the retry\n * budget on a receiver that only asked us to slow down. The price was that a sender held a hundred\n * times left `attempts 0` and no `lastError`, and the only place that knew was a log line.\n *\n * Being held is a fact about the **peer**, not about a fact in the queue, so it is written here. It is\n * cleared the moment the peer takes a batch. `active` is a person's decision and these three are what\n * the peer said: a screen that shows both as \"nothing is going out\" sends the administrator to the\n * wrong place — turn it on, or go and look at the receiver.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Since when the peer has been asking us to wait. Null when it is taking what we send.' })\n heldSince: Date\n\n @Column({ type: 'int', default: 0 })\n @Field(type => Int, { description: 'How many times in a row the peer asked us to wait. Reset when it takes a batch.' })\n heldCount: number\n\n @Column({ type: 'int', nullable: true })\n @Field(type => Int, { nullable: true, description: 'The wait the peer last asked for, in milliseconds.' })\n heldRetryAfterMs: number\n\n @CreateDateColumn()\n @Field({ nullable: true })\n createdAt: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true })\n updatedAt: Date\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true })\n creator: User\n\n @RelationId((dest: WebhookDestination) => dest.creator)\n creatorId: string\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true })\n updater: User\n\n @RelationId((dest: WebhookDestination) => dest.updater)\n updaterId: string\n}\n"]}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WebhookLane } from '@operato/ops-contract';
|
|
1
2
|
import { Domain } from '@things-factory/shell';
|
|
2
3
|
export declare enum WebhookOutboxStatus {
|
|
3
4
|
/** Waiting to go. The pump picks these up in `seq` order. */
|
|
@@ -41,6 +42,8 @@ export declare class WebhookOutboxEvent {
|
|
|
41
42
|
*/
|
|
42
43
|
recordTime: Date;
|
|
43
44
|
/** The fact itself, in whatever shape this product speaks. The only product-specific column. */
|
|
45
|
+
lane: WebhookLane;
|
|
46
|
+
provenance: 'actual' | 'simulated';
|
|
44
47
|
payload: any;
|
|
45
48
|
status: WebhookOutboxStatus;
|
|
46
49
|
sentAt: Date;
|
|
@@ -103,8 +103,23 @@ tslib_1.__decorate([
|
|
|
103
103
|
(0, type_graphql_1.Field)({ nullable: true, description: 'When this side recorded the fact. Not the same as when it happened.' }),
|
|
104
104
|
tslib_1.__metadata("design:type", Date
|
|
105
105
|
/** The fact itself, in whatever shape this product speaks. The only product-specific column. */
|
|
106
|
+
/*
|
|
107
|
+
* What the envelope is for — the contract's `WebhookLane`. `scope` stays the numbering stream; `lane`
|
|
108
|
+
* selects the receiver's path. Null means FACTS, which is also what the contract reads when the envelope
|
|
109
|
+
* carries no lane, so only the exception is stored and only the exception is sent.
|
|
110
|
+
*/
|
|
106
111
|
)
|
|
107
112
|
], WebhookOutboxEvent.prototype, "recordTime", void 0);
|
|
113
|
+
tslib_1.__decorate([
|
|
114
|
+
(0, typeorm_1.Column)({ type: 'varchar', nullable: true }),
|
|
115
|
+
(0, type_graphql_1.Field)({ nullable: true, description: 'Receiver lane for this envelope. Absent means FACTS.' }),
|
|
116
|
+
tslib_1.__metadata("design:type", String)
|
|
117
|
+
], WebhookOutboxEvent.prototype, "lane", void 0);
|
|
118
|
+
tslib_1.__decorate([
|
|
119
|
+
(0, typeorm_1.Column)({ type: 'varchar', nullable: true }),
|
|
120
|
+
(0, type_graphql_1.Field)({ nullable: true, description: 'Declared only when this did not actually happen.' }),
|
|
121
|
+
tslib_1.__metadata("design:type", String)
|
|
122
|
+
], WebhookOutboxEvent.prototype, "provenance", void 0);
|
|
108
123
|
tslib_1.__decorate([
|
|
109
124
|
(0, typeorm_1.Column)({ type: 'simple-json' }),
|
|
110
125
|
(0, type_graphql_1.Field)(type => shell_1.ScalarObject, { description: 'The fact being pushed. The only column whose shape belongs to the product.' }),
|
|
@@ -162,5 +177,12 @@ exports.WebhookOutboxEvent = WebhookOutboxEvent = tslib_1.__decorate([
|
|
|
162
177
|
/* The pump's own query: what is still waiting for this peer, oldest first. */
|
|
163
178
|
,
|
|
164
179
|
(0, typeorm_1.Index)('ix_webhook_outbox_event_1', (event) => [event.domain, event.peer, event.status, event.seq])
|
|
180
|
+
/*
|
|
181
|
+
* The same fact is not queued twice for the same peer (ADR-0078 decision 3). operato-plant's table had this
|
|
182
|
+
* and this one did not. It is per peer, not per domain: one fact may be queued for two peers — a lot status
|
|
183
|
+
* goes to the twin and to the warehouse — and each of those rows is its own delivery.
|
|
184
|
+
*/
|
|
185
|
+
,
|
|
186
|
+
(0, typeorm_1.Index)('ix_webhook_outbox_event_2', (event) => [event.domain, event.peer, event.eventId], { unique: true })
|
|
165
187
|
], WebhookOutboxEvent);
|
|
166
188
|
//# sourceMappingURL=webhook-outbox-event.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webhook-outbox-event.js","sourceRoot":"","sources":["../../../server/service/webhook-outbox-event/webhook-outbox-event.ts"],"names":[],"mappings":";;;;AAAA,qCASgB;AAChB,+CAA2E;AAE3E,iDAA4D;AAE5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,IAAY,mBAOX;AAPD,WAAY,mBAAmB;IAC7B,6DAA6D;IAC7D,0CAAmB,CAAA;IACnB,wBAAwB;IACxB,oCAAa,CAAA;IACb,qFAAqF;IACrF,wCAAiB,CAAA;AACnB,CAAC,EAPW,mBAAmB,mCAAnB,mBAAmB,QAO9B;AAED,IAAA,+BAAgB,EAAC,mBAAmB,EAAE;IACpC,IAAI,EAAE,qBAAqB;IAC3B,WAAW,EAAE,mEAAmE;CACjF,CAAC,CAAA;AAcK,IAAM,kBAAkB,GAAxB,MAAM,kBAAkB;CA6F9B,CAAA;AA7FY,gDAAkB;AAGpB;IAFR,IAAA,gCAAsB,EAAC,MAAM,CAAC;IAC9B,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,iBAAE,EAAE,EAAE,WAAW,EAAE,yCAAyC,EAAE,CAAC;;8CAC3D;AAInB;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,CAAC;IACzB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,EAAE,EAAE,WAAW,EAAE,kCAAkC,EAAE,CAAC;sCACnE,cAAM;kDAAA;AAGd;IADC,IAAA,oBAAU,EAAC,CAAC,KAAyB,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC;;oDACxC;AAKhB;IAFC,IAAA,gBAAM,GAAE;IACR,IAAA,oBAAK,EAAC,EAAE,WAAW,EAAE,mCAAmC,EAAE,CAAC;;gDAChD;AAUZ;IAFC,IAAA,gBAAM,GAAE;IACR,IAAA,oBAAK,EAAC,EAAE,WAAW,EAAE,qGAAqG,EAAE,CAAC;;iDACjH;AAKb;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;IACvB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,kBAAG,EAAE,EAAE,WAAW,EAAE,6EAA6E,EAAE,CAAC;;+CACxG;AAaX;IAFC,IAAA,gBAAM,GAAE;IACR,IAAA,oBAAK,EAAC,EAAE,WAAW,EAAE,sFAAsF,EAAE,CAAC;;mDAChG;AAKf;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,iDAAiD,EAAE,CAAC;sCAC/E,IAAI;IAEf;;;;OAIG;;qDANY;AASf;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,qEAAqE,EAAE,CAAC;sCAClG,IAAI;IAEhB,gGAAgG;;sDAFhF;AAKhB;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,aAAa,EAAE,CAAC;IAC/B,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,oBAAY,EAAE,EAAE,WAAW,EAAE,4EAA4E,EAAE,CAAC;;mDAC/G;AAIZ;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,mBAAmB,CAAC,OAAO,EAAE,CAAC;IACjE,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,mBAAmB,EAAE,EAAE,WAAW,EAAE,0CAA0C,EAAE,CAAC;;kDACrE;AAI3B;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,wBAAwB,EAAE,CAAC;sCACzD,IAAI;IAEZ;;;;;OAKG;;kDAPS;AAUZ;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC;IACnC,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,kBAAG,EAAE,EAAE,WAAW,EAAE,6EAA6E,EAAE,CAAC;;oDACnG;AAIhB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,8CAA8C,EAAE,CAAC;;qDACtE;AAIjB;IAFC,IAAA,0BAAgB,GAAE;IAClB,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;sCACf,IAAI;qDAAA;AAIf;IAFC,IAAA,0BAAgB,GAAE;IAClB,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;sCACf,IAAI;qDAAA;6BA5FJ,kBAAkB;IAZ9B,IAAA,yBAAU,EAAC,EAAE,WAAW,EAAE,4FAA4F,EAAE,CAAC;IACzH,IAAA,gBAAM,GAAE;IACT;;;;OAIG;;IACF,IAAA,eAAK,EAAC,2BAA2B,EAAE,CAAC,KAAyB,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,EAAE;QACrH,MAAM,EAAE,IAAI;KACb,CAAC;IACF,8EAA8E;;IAC7E,IAAA,eAAK,EAAC,2BAA2B,EAAE,CAAC,KAAyB,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;GAC1G,kBAAkB,CA6F9B","sourcesContent":["import {\n Column,\n CreateDateColumn,\n Entity,\n Index,\n ManyToOne,\n PrimaryGeneratedColumn,\n RelationId,\n UpdateDateColumn\n} from 'typeorm'\nimport { Field, ID, Int, ObjectType, registerEnumType } from 'type-graphql'\n\nimport { Domain, ScalarObject } from '@things-factory/shell'\n\n/*\n * ═══════════════════════════════════════════════════════════════════════════\n * One fact waiting to reach a peer.\n *\n * ── This row is the contract's envelope plus delivery state ────────────────\n * The shape is not invented here. `@operato/ops-contract` already declares what crosses\n * this seam, and the row is that declaration with the delivery columns added:\n *\n * WebhookEnvelope seq · scope · eventId · eventTime · recordTime · record\n * this row seq · scope · eventId · eventTime · recordTime · payload\n * + status · attempts · lastError · sentAt\n *\n * `payload` is the only product-specific column — a production report, a putaway task, a\n * setpoint. Everything else is the same whether the sender is an MES, a WMS, a YMS or an EMS,\n * which is why this table does not carry a product's name (ADR-0041).\n *\n * ── Why `scope` and not `channel` ─────────────────────────────────────────\n * operato-plant called this `channel` and the contract calls it `scope`. Same idea: the unit\n * the numbers are counted in. The contract's word wins, because the receiving side reads\n * `scope` off the envelope to decide which cursor to compare against — a sender using a\n * different word has to translate, and a translation is a place to be wrong.\n *\n * ── Failure that keeps the fact, and failure that gives up ─────────────────\n * A 429 or a 503 is not a failed delivery — it is \"not now\". Those must not consume the\n * retry budget, and they must not be recorded as attempts, or a receiver asking us to wait\n * looks identical to a receiver refusing us. Measured on 2026-09-08: the sender's\n * backpressure path touched neither `attempts` nor `lastError`, so a loop that re-offered the\n * same batch every five seconds left no trace on either side. The row cannot fix that alone —\n * the pump has to say something out loud — but it is why `attempts` means \"tries that were\n * really refused\".\n * ═══════════════════════════════════════════════════════════════════════════\n */\n\nexport enum WebhookOutboxStatus {\n /** Waiting to go. The pump picks these up in `seq` order. */\n Pending = 'PENDING',\n /** The peer took it. */\n Sent = 'SENT',\n /** The peer refused it and resending will not change that — a person has to look. */\n Failed = 'FAILED'\n}\n\nregisterEnumType(WebhookOutboxStatus, {\n name: 'WebhookOutboxStatus',\n description: 'Whether a queued fact is waiting, delivered, or refused for good.'\n})\n\n@ObjectType({ description: 'A fact queued for one peer, with its number and what happened when we tried to deliver it.' })\n@Entity()\n/*\n * Numbering is per (domain, peer, scope) and must be unique there — the receiving side\n * detects gaps by comparing consecutive numbers, so two rows sharing a number would make a\n * gap invisible.\n */\n@Index('ix_webhook_outbox_event_0', (event: WebhookOutboxEvent) => [event.domain, event.peer, event.scope, event.seq], {\n unique: true\n})\n/* The pump's own query: what is still waiting for this peer, oldest first. */\n@Index('ix_webhook_outbox_event_1', (event: WebhookOutboxEvent) => [event.domain, event.peer, event.status, event.seq])\nexport class WebhookOutboxEvent {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { description: 'Unique identifier for this queued fact.' })\n readonly id: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { description: 'The tenant this fact belongs to.' })\n domain: Domain\n\n @RelationId((event: WebhookOutboxEvent) => event.domain)\n domainId: string\n\n /** Who this is going to. Same unit as the destination row. */\n @Column()\n @Field({ description: 'The peer this fact is queued for.' })\n peer: string\n\n /**\n * The unit the numbers are counted in — the contract's `WebhookEnvelope.scope`.\n *\n * Without it the receiving side cannot tell which run of numbers a given envelope belongs\n * to, and two streams sharing one cursor answer \"a number is missing\" forever.\n */\n @Column()\n @Field({ description: 'The unit the sequence is counted in (the contract\\'s scope). Two streams must not share one cursor.' })\n scope: string\n\n /** Increases by one within (domain, peer, scope). A hole here is a lost fact. */\n @Column({ type: 'int' })\n @Field(type => Int, { description: 'Position in this scope. Increases by one; a hole means a fact went missing.' })\n seq: number\n\n /**\n * Idempotency key. The receiving side uses it to recognise a fact it has already taken, so\n * a resend after a lost response does not seat the same fact twice.\n *\n * It also lets a refusal name **which** envelope was refused: the receiver echoes this back\n * in `WebhookResponse.rejected`, and without it the sender can only be told \"one of these\n * was refused\" (measured 2026-09-07 — a whole batch was marked delivered because the\n * refusal could not be matched to a row).\n */\n @Column()\n @Field({ description: 'Idempotency key. Echoed back in a refusal so the sender knows which row was refused.' })\n eventId: string\n\n /** When the fact happened. */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'When the fact happened, as the source knows it.' })\n eventTime: Date\n\n /**\n * When we wrote it down. **Kept apart from `eventTime`** — a fact that arrives late is not\n * a fact that happened late, and folding them together makes a backfill look like a\n * present-tense event.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'When this side recorded the fact. Not the same as when it happened.' })\n recordTime: Date\n\n /** The fact itself, in whatever shape this product speaks. The only product-specific column. */\n @Column({ type: 'simple-json' })\n @Field(type => ScalarObject, { description: 'The fact being pushed. The only column whose shape belongs to the product.' })\n payload: any\n\n @Column({ type: 'varchar', default: WebhookOutboxStatus.Pending })\n @Field(type => WebhookOutboxStatus, { description: 'Waiting, delivered, or refused for good.' })\n status: WebhookOutboxStatus\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'When the peer took it.' })\n sentAt: Date\n\n /**\n * How many times a delivery was **really refused** — not how many times we tried.\n *\n * A peer answering 429 or 503 is asking us to wait; that does not count here, or a\n * receiver under load becomes indistinguishable from a receiver that rejects us.\n */\n @Column({ type: 'int', default: 0 })\n @Field(type => Int, { description: 'Deliveries that were refused. Being asked to wait (429/503) does not count.' })\n attempts: number\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'What the peer said the last time it refused.' })\n lastError: string\n\n @CreateDateColumn()\n @Field({ nullable: true })\n createdAt: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true })\n updatedAt: Date\n}\n"]}
|
|
1
|
+
{"version":3,"file":"webhook-outbox-event.js","sourceRoot":"","sources":["../../../server/service/webhook-outbox-event/webhook-outbox-event.ts"],"names":[],"mappings":";;;;AAAA,qCASgB;AAChB,+CAA2E;AAI3E,iDAA4D;AAE5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,IAAY,mBAOX;AAPD,WAAY,mBAAmB;IAC7B,6DAA6D;IAC7D,0CAAmB,CAAA;IACnB,wBAAwB;IACxB,oCAAa,CAAA;IACb,qFAAqF;IACrF,wCAAiB,CAAA;AACnB,CAAC,EAPW,mBAAmB,mCAAnB,mBAAmB,QAO9B;AAED,IAAA,+BAAgB,EAAC,mBAAmB,EAAE;IACpC,IAAI,EAAE,qBAAqB;IAC3B,WAAW,EAAE,mEAAmE;CACjF,CAAC,CAAA;AAoBK,IAAM,kBAAkB,GAAxB,MAAM,kBAAkB;CA+G9B,CAAA;AA/GY,gDAAkB;AAGpB;IAFR,IAAA,gCAAsB,EAAC,MAAM,CAAC;IAC9B,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,iBAAE,EAAE,EAAE,WAAW,EAAE,yCAAyC,EAAE,CAAC;;8CAC3D;AAInB;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,CAAC;IACzB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,EAAE,EAAE,WAAW,EAAE,kCAAkC,EAAE,CAAC;sCACnE,cAAM;kDAAA;AAGd;IADC,IAAA,oBAAU,EAAC,CAAC,KAAyB,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC;;oDACxC;AAKhB;IAFC,IAAA,gBAAM,GAAE;IACR,IAAA,oBAAK,EAAC,EAAE,WAAW,EAAE,mCAAmC,EAAE,CAAC;;gDAChD;AAUZ;IAFC,IAAA,gBAAM,GAAE;IACR,IAAA,oBAAK,EAAC,EAAE,WAAW,EAAE,qGAAqG,EAAE,CAAC;;iDACjH;AAKb;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;IACvB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,kBAAG,EAAE,EAAE,WAAW,EAAE,6EAA6E,EAAE,CAAC;;+CACxG;AAaX;IAFC,IAAA,gBAAM,GAAE;IACR,IAAA,oBAAK,EAAC,EAAE,WAAW,EAAE,sFAAsF,EAAE,CAAC;;mDAChG;AAKf;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,iDAAiD,EAAE,CAAC;sCAC/E,IAAI;IAEf;;;;OAIG;;qDANY;AASf;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,qEAAqE,EAAE,CAAC;sCAClG,IAAI;IAEhB,gGAAgG;IAChG;;;;OAIG;;sDAPa;AAUhB;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC3C,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,sDAAsD,EAAE,CAAC;;gDAC9E;AASjB;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC3C,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,kDAAkD,EAAE,CAAC;;sDACzD;AAIlC;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,aAAa,EAAE,CAAC;IAC/B,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,oBAAY,EAAE,EAAE,WAAW,EAAE,4EAA4E,EAAE,CAAC;;mDAC/G;AAIZ;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,mBAAmB,CAAC,OAAO,EAAE,CAAC;IACjE,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,mBAAmB,EAAE,EAAE,WAAW,EAAE,0CAA0C,EAAE,CAAC;;kDACrE;AAI3B;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,wBAAwB,EAAE,CAAC;sCACzD,IAAI;IAEZ;;;;;OAKG;;kDAPS;AAUZ;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC;IACnC,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,kBAAG,EAAE,EAAE,WAAW,EAAE,6EAA6E,EAAE,CAAC;;oDACnG;AAIhB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,8CAA8C,EAAE,CAAC;;qDACtE;AAIjB;IAFC,IAAA,0BAAgB,GAAE;IAClB,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;sCACf,IAAI;qDAAA;AAIf;IAFC,IAAA,0BAAgB,GAAE;IAClB,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;sCACf,IAAI;qDAAA;6BA9GJ,kBAAkB;IAlB9B,IAAA,yBAAU,EAAC,EAAE,WAAW,EAAE,4FAA4F,EAAE,CAAC;IACzH,IAAA,gBAAM,GAAE;IACT;;;;OAIG;;IACF,IAAA,eAAK,EAAC,2BAA2B,EAAE,CAAC,KAAyB,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,EAAE;QACrH,MAAM,EAAE,IAAI;KACb,CAAC;IACF,8EAA8E;;IAC7E,IAAA,eAAK,EAAC,2BAA2B,EAAE,CAAC,KAAyB,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;IACvH;;;;OAIG;;IACF,IAAA,eAAK,EAAC,2BAA2B,EAAE,CAAC,KAAyB,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;GAClH,kBAAkB,CA+G9B","sourcesContent":["import {\n Column,\n CreateDateColumn,\n Entity,\n Index,\n ManyToOne,\n PrimaryGeneratedColumn,\n RelationId,\n UpdateDateColumn\n} from 'typeorm'\nimport { Field, ID, Int, ObjectType, registerEnumType } from 'type-graphql'\n\nimport type { WebhookLane } from '@operato/ops-contract'\n\nimport { Domain, ScalarObject } from '@things-factory/shell'\n\n/*\n * ═══════════════════════════════════════════════════════════════════════════\n * One fact waiting to reach a peer.\n *\n * ── This row is the contract's envelope plus delivery state ────────────────\n * The shape is not invented here. `@operato/ops-contract` already declares what crosses\n * this seam, and the row is that declaration with the delivery columns added:\n *\n * WebhookEnvelope seq · scope · eventId · eventTime · recordTime · record\n * this row seq · scope · eventId · eventTime · recordTime · payload\n * + status · attempts · lastError · sentAt\n *\n * `payload` is the only product-specific column — a production report, a putaway task, a\n * setpoint. Everything else is the same whether the sender is an MES, a WMS, a YMS or an EMS,\n * which is why this table does not carry a product's name (ADR-0041).\n *\n * ── Why `scope` and not `channel` ─────────────────────────────────────────\n * operato-plant called this `channel` and the contract calls it `scope`. Same idea: the unit\n * the numbers are counted in. The contract's word wins, because the receiving side reads\n * `scope` off the envelope to decide which cursor to compare against — a sender using a\n * different word has to translate, and a translation is a place to be wrong.\n *\n * ── Failure that keeps the fact, and failure that gives up ─────────────────\n * A 429 or a 503 is not a failed delivery — it is \"not now\". Those must not consume the\n * retry budget, and they must not be recorded as attempts, or a receiver asking us to wait\n * looks identical to a receiver refusing us. Measured on 2026-09-08: the sender's\n * backpressure path touched neither `attempts` nor `lastError`, so a loop that re-offered the\n * same batch every five seconds left no trace on either side. The row cannot fix that alone —\n * the pump has to say something out loud — but it is why `attempts` means \"tries that were\n * really refused\".\n * ═══════════════════════════════════════════════════════════════════════════\n */\n\nexport enum WebhookOutboxStatus {\n /** Waiting to go. The pump picks these up in `seq` order. */\n Pending = 'PENDING',\n /** The peer took it. */\n Sent = 'SENT',\n /** The peer refused it and resending will not change that — a person has to look. */\n Failed = 'FAILED'\n}\n\nregisterEnumType(WebhookOutboxStatus, {\n name: 'WebhookOutboxStatus',\n description: 'Whether a queued fact is waiting, delivered, or refused for good.'\n})\n\n@ObjectType({ description: 'A fact queued for one peer, with its number and what happened when we tried to deliver it.' })\n@Entity()\n/*\n * Numbering is per (domain, peer, scope) and must be unique there — the receiving side\n * detects gaps by comparing consecutive numbers, so two rows sharing a number would make a\n * gap invisible.\n */\n@Index('ix_webhook_outbox_event_0', (event: WebhookOutboxEvent) => [event.domain, event.peer, event.scope, event.seq], {\n unique: true\n})\n/* The pump's own query: what is still waiting for this peer, oldest first. */\n@Index('ix_webhook_outbox_event_1', (event: WebhookOutboxEvent) => [event.domain, event.peer, event.status, event.seq])\n/*\n * The same fact is not queued twice for the same peer (ADR-0078 decision 3). operato-plant's table had this\n * and this one did not. It is per peer, not per domain: one fact may be queued for two peers — a lot status\n * goes to the twin and to the warehouse — and each of those rows is its own delivery.\n */\n@Index('ix_webhook_outbox_event_2', (event: WebhookOutboxEvent) => [event.domain, event.peer, event.eventId], { unique: true })\nexport class WebhookOutboxEvent {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { description: 'Unique identifier for this queued fact.' })\n readonly id: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { description: 'The tenant this fact belongs to.' })\n domain: Domain\n\n @RelationId((event: WebhookOutboxEvent) => event.domain)\n domainId: string\n\n /** Who this is going to. Same unit as the destination row. */\n @Column()\n @Field({ description: 'The peer this fact is queued for.' })\n peer: string\n\n /**\n * The unit the numbers are counted in — the contract's `WebhookEnvelope.scope`.\n *\n * Without it the receiving side cannot tell which run of numbers a given envelope belongs\n * to, and two streams sharing one cursor answer \"a number is missing\" forever.\n */\n @Column()\n @Field({ description: 'The unit the sequence is counted in (the contract\\'s scope). Two streams must not share one cursor.' })\n scope: string\n\n /** Increases by one within (domain, peer, scope). A hole here is a lost fact. */\n @Column({ type: 'int' })\n @Field(type => Int, { description: 'Position in this scope. Increases by one; a hole means a fact went missing.' })\n seq: number\n\n /**\n * Idempotency key. The receiving side uses it to recognise a fact it has already taken, so\n * a resend after a lost response does not seat the same fact twice.\n *\n * It also lets a refusal name **which** envelope was refused: the receiver echoes this back\n * in `WebhookResponse.rejected`, and without it the sender can only be told \"one of these\n * was refused\" (measured 2026-09-07 — a whole batch was marked delivered because the\n * refusal could not be matched to a row).\n */\n @Column()\n @Field({ description: 'Idempotency key. Echoed back in a refusal so the sender knows which row was refused.' })\n eventId: string\n\n /** When the fact happened. */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'When the fact happened, as the source knows it.' })\n eventTime: Date\n\n /**\n * When we wrote it down. **Kept apart from `eventTime`** — a fact that arrives late is not\n * a fact that happened late, and folding them together makes a backfill look like a\n * present-tense event.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'When this side recorded the fact. Not the same as when it happened.' })\n recordTime: Date\n\n /** The fact itself, in whatever shape this product speaks. The only product-specific column. */\n /*\n * What the envelope is for — the contract's `WebhookLane`. `scope` stays the numbering stream; `lane`\n * selects the receiver's path. Null means FACTS, which is also what the contract reads when the envelope\n * carries no lane, so only the exception is stored and only the exception is sent.\n */\n @Column({ type: 'varchar', nullable: true })\n @Field({ nullable: true, description: 'Receiver lane for this envelope. Absent means FACTS.' })\n lane: WebhookLane\n\n /*\n * Did this happen? — the contract's `CanonicalEnvelope.provenance`. **Only the exception is written.**\n * Writing 'actual' on every row makes an unwritten one look invented, and the contract asks for three\n * readings: written simulated, written actual, not written.\n */\n @Column({ type: 'varchar', nullable: true })\n @Field({ nullable: true, description: 'Declared only when this did not actually happen.' })\n provenance: 'actual' | 'simulated'\n\n @Column({ type: 'simple-json' })\n @Field(type => ScalarObject, { description: 'The fact being pushed. The only column whose shape belongs to the product.' })\n payload: any\n\n @Column({ type: 'varchar', default: WebhookOutboxStatus.Pending })\n @Field(type => WebhookOutboxStatus, { description: 'Waiting, delivered, or refused for good.' })\n status: WebhookOutboxStatus\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'When the peer took it.' })\n sentAt: Date\n\n /**\n * How many times a delivery was **really refused** — not how many times we tried.\n *\n * A peer answering 429 or 503 is asking us to wait; that does not count here, or a\n * receiver under load becomes indistinguishable from a receiver that rejects us.\n */\n @Column({ type: 'int', default: 0 })\n @Field(type => Int, { description: 'Deliveries that were refused. Being asked to wait (429/503) does not count.' })\n attempts: number\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'What the peer said the last time it refused.' })\n lastError: string\n\n @CreateDateColumn()\n @Field({ nullable: true })\n createdAt: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true })\n updatedAt: Date\n}\n"]}
|