@things-factory/webhook 10.1.29 → 10.1.33

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
@@ -2,3 +2,19 @@ export * from './webhook-destination/webhook-destination';
2
2
  export * from './webhook-destination/webhook-destination-resolver';
3
3
  export * from './webhook-outbox-event/webhook-outbox-event';
4
4
  export * from './webhook-sequence/webhook-sequence';
5
+ export * from './outbox/delivery';
6
+ export * from './outbox/settlement';
7
+ export * from './outbox/row-effect';
8
+ export * from './outbox/seq-report';
9
+ export * from './outbox/reconciliation';
10
+ export * from './outbox/standing';
11
+ export * from './outbox/requeue-note';
12
+ export * from './outbox/page';
13
+ export * from './outbox/writing-transaction';
14
+ export * from './outbox/append';
15
+ export * from './outbox/pump-gate';
16
+ export * from './observability/clock-offset';
17
+ export * from './observability/trace-context';
18
+ export * from './outbox/store';
19
+ export * from './outbox/dispatcher';
20
+ export * from './outbox/webhook-store';
@@ -14,4 +14,32 @@ tslib_1.__exportStar(require("./webhook-destination/webhook-destination"), expor
14
14
  tslib_1.__exportStar(require("./webhook-destination/webhook-destination-resolver"), exports);
15
15
  tslib_1.__exportStar(require("./webhook-outbox-event/webhook-outbox-event"), exports);
16
16
  tslib_1.__exportStar(require("./webhook-sequence/webhook-sequence"), exports);
17
+ /*
18
+ * The outbox machine, moved from operato-plant (ADR-0078 decision 2). Step ①: the pure judgement —
19
+ * envelope, signature, reading the receiver's answer, settlement, what an answer does to our rows,
20
+ * the sequence report, reconciliation, standing. None of these touch a store.
21
+ */
22
+ tslib_1.__exportStar(require("./outbox/delivery"), exports);
23
+ tslib_1.__exportStar(require("./outbox/settlement"), exports);
24
+ tslib_1.__exportStar(require("./outbox/row-effect"), exports);
25
+ tslib_1.__exportStar(require("./outbox/seq-report"), exports);
26
+ tslib_1.__exportStar(require("./outbox/reconciliation"), exports);
27
+ tslib_1.__exportStar(require("./outbox/standing"), exports);
28
+ tslib_1.__exportStar(require("./outbox/requeue-note"), exports);
29
+ tslib_1.__exportStar(require("./outbox/page"), exports);
30
+ tslib_1.__exportStar(require("./outbox/writing-transaction"), exports);
31
+ /*
32
+ * Step ②: the back row. Writing a fact down takes the caller's open transaction - in the type, not only
33
+ * in a check - because the number and the row have to commit together or the number burns.
34
+ */
35
+ tslib_1.__exportStar(require("./outbox/append"), exports);
36
+ tslib_1.__exportStar(require("./outbox/pump-gate"), exports);
37
+ /* What a delivery needs to say "when" and "which request" — moved from operato-plant with the dispatcher's
38
+ * needs in mind (ADR-0078 decision 1). The inbound side reads the same two. */
39
+ tslib_1.__exportStar(require("./observability/clock-offset"), exports);
40
+ tslib_1.__exportStar(require("./observability/trace-context"), exports);
41
+ /* Step ②: the back row. One dispatcher over a store interface; `webhookStore` is this module's own store. */
42
+ tslib_1.__exportStar(require("./outbox/store"), exports);
43
+ tslib_1.__exportStar(require("./outbox/dispatcher"), exports);
44
+ tslib_1.__exportStar(require("./outbox/webhook-store"), exports);
17
45
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/service/index.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;GAQG;AACH,oFAAyD;AACzD,6FAAkE;AAClE,sFAA2D;AAC3D,8EAAmD","sourcesContent":["/*\n * Each source file is named rather than re-exporting folder barrels.\n *\n * A name added to a folder barrel but not listed here stops at the package boundary, and the\n * consumer's error is `TS2305: has no exported member` — which reads like the name does not\n * exist rather than like it is unreachable. That happened on 2026-09-07 with\n * `work-shift/days-of-week`: the parser was in the folder barrel, not here, and the consuming\n * lane wrote its own until the miss was found.\n */\nexport * from './webhook-destination/webhook-destination'\nexport * from './webhook-destination/webhook-destination-resolver'\nexport * from './webhook-outbox-event/webhook-outbox-event'\nexport * from './webhook-sequence/webhook-sequence'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/service/index.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;GAQG;AACH,oFAAyD;AACzD,6FAAkE;AAClE,sFAA2D;AAC3D,8EAAmD;AAEnD;;;;GAIG;AACH,4DAAiC;AACjC,8DAAmC;AACnC,8DAAmC;AACnC,8DAAmC;AACnC,kEAAuC;AACvC,4DAAiC;AACjC,gEAAqC;AACrC,wDAA6B;AAC7B,uEAA4C;AAE5C;;;GAGG;AACH,0DAA+B;AAC/B,6DAAkC;AAElC;+EAC+E;AAC/E,uEAA4C;AAC5C,wEAA6C;AAE7C,6GAA6G;AAC7G,yDAA8B;AAC9B,8DAAmC;AACnC,iEAAsC","sourcesContent":["/*\n * Each source file is named rather than re-exporting folder barrels.\n *\n * A name added to a folder barrel but not listed here stops at the package boundary, and the\n * consumer's error is `TS2305: has no exported member` — which reads like the name does not\n * exist rather than like it is unreachable. That happened on 2026-09-07 with\n * `work-shift/days-of-week`: the parser was in the folder barrel, not here, and the consuming\n * lane wrote its own until the miss was found.\n */\nexport * from './webhook-destination/webhook-destination'\nexport * from './webhook-destination/webhook-destination-resolver'\nexport * from './webhook-outbox-event/webhook-outbox-event'\nexport * from './webhook-sequence/webhook-sequence'\n\n/*\n * The outbox machine, moved from operato-plant (ADR-0078 decision 2). Step ①: the pure judgement —\n * envelope, signature, reading the receiver's answer, settlement, what an answer does to our rows,\n * the sequence report, reconciliation, standing. None of these touch a store.\n */\nexport * from './outbox/delivery'\nexport * from './outbox/settlement'\nexport * from './outbox/row-effect'\nexport * from './outbox/seq-report'\nexport * from './outbox/reconciliation'\nexport * from './outbox/standing'\nexport * from './outbox/requeue-note'\nexport * from './outbox/page'\nexport * from './outbox/writing-transaction'\n\n/*\n * Step ②: the back row. Writing a fact down takes the caller's open transaction - in the type, not only\n * in a check - because the number and the row have to commit together or the number burns.\n */\nexport * from './outbox/append'\nexport * from './outbox/pump-gate'\n\n/* What a delivery needs to say \"when\" and \"which request\" — moved from operato-plant with the dispatcher's\n * needs in mind (ADR-0078 decision 1). The inbound side reads the same two. */\nexport * from './observability/clock-offset'\nexport * from './observability/trace-context'\n\n/* Step ②: the back row. One dispatcher over a store interface; `webhookStore` is this module's own store. */\nexport * from './outbox/store'\nexport * from './outbox/dispatcher'\nexport * from './outbox/webhook-store'\n\n"]}
@@ -0,0 +1,88 @@
1
+ /**
2
+ * How far our clock has drifted from the systems we exchange records with.
3
+ *
4
+ * ── Why this is worth measuring ────────────────────────────────────────────
5
+ * Every record we keep is stamped by this server's clock, and the audit trail
6
+ * is only worth anything if that clock is right. Nothing here can check that
7
+ * against a reference clock -- keeping the host in step with one is the host's
8
+ * job, not the application's.
9
+ *
10
+ * But we are not without evidence. Every signed exchange carries the other
11
+ * side's timestamp, so each one is a reading of the difference between two
12
+ * clocks. We were throwing that away.
13
+ *
14
+ * ── What goes wrong when it drifts ─────────────────────────────────────────
15
+ * The signature check refuses a timestamp more than five minutes from now, in
16
+ * either direction. So a clock that slips past that does not degrade: every
17
+ * exchange in both directions turns into 401 at once, and the message says the
18
+ * webhook was rejected, which reads like a secret problem. Somebody then
19
+ * checks the secret, finds it right, and looks somewhere else.
20
+ *
21
+ * That is why the middle band exists. Half the tolerance is still working, and
22
+ * it is the only chance to say something before everything stops.
23
+ *
24
+ * ── What this is not ───────────────────────────────────────────────────────
25
+ * It is not a time source and it does not correct anything. A reading says the
26
+ * two clocks disagree; it cannot say which one is wrong. Both are reported so
27
+ * a person can go and look.
28
+ */
29
+ /** Where a reading came from. Kept so a report can name the peer rather than an average. */
30
+ export interface ClockReading {
31
+ /** The system on the other side, as we know it -- a site id or a destination name. */
32
+ peer: string;
33
+ /**
34
+ * Our clock minus theirs, in milliseconds.
35
+ *
36
+ * Positive means we are ahead of them. The sign is kept because it is the
37
+ * first thing anyone asks, and an absolute value cannot be added back.
38
+ */
39
+ offsetMs: number;
40
+ /** When we took the reading, by our clock. */
41
+ atMs: number;
42
+ }
43
+ export type ClockStanding =
44
+ /** Comfortably within what the signature check allows. */
45
+ 'in-step'
46
+ /** Still working, but past half the tolerance -- this is the warning band. */
47
+ | 'drifting'
48
+ /** Beyond what the signature check allows: exchanges are failing right now. */
49
+ | 'out-of-step';
50
+ /**
51
+ * How bad a reading is, against the tolerance the signature check actually uses.
52
+ *
53
+ * The threshold is not a number picked here. It is the same constant the
54
+ * contract refuses timestamps by, so "out-of-step" means precisely "this is
55
+ * already being rejected" rather than an opinion about how much drift is too
56
+ * much.
57
+ */
58
+ export declare function clockStanding(offsetMs: number, toleranceMs?: number): ClockStanding;
59
+ /**
60
+ * The reading to report for a peer, given everything seen from it.
61
+ *
62
+ * The most recent one, not the worst. A clock that was wrong and has been put
63
+ * right should stop being reported as wrong -- otherwise the fix cannot be
64
+ * seen to have worked, and the report has to be cleared by hand, which means
65
+ * it will not be.
66
+ */
67
+ export declare function latestOf(readings: readonly ClockReading[], peer: string): ClockReading | undefined;
68
+ /**
69
+ * The single reading that best answers "is this server's clock trustworthy".
70
+ *
71
+ * The furthest-off peer among the current readings. One peer disagreeing is
72
+ * that peer's problem; every peer disagreeing by about the same amount is
73
+ * ours, and either way the largest one is what a person needs to see first.
74
+ */
75
+ export declare function worstOf(readings: readonly ClockReading[]): ClockReading | undefined;
76
+ /**
77
+ * Read a peer's timestamp header into an offset.
78
+ *
79
+ * Anything unreadable gives back nothing rather than zero. Zero is the value
80
+ * that means "the clocks agree perfectly", which is the one thing a header we
81
+ * could not parse never tells us.
82
+ */
83
+ export declare function offsetFrom(theirTimestamp: string | undefined, ourMs: number): number | undefined;
84
+ /** Record one reading. Peers we have not heard from in this process are simply absent. */
85
+ export declare function noteClockReading(peer: string, offsetMs: number | undefined, atMs?: number): void;
86
+ export declare function clockReadings(): ClockReading[];
87
+ /** For tests and restarts -- a reading from a previous run says nothing about this clock. */
88
+ export declare function forgetClockReadings(): void;
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.clockStanding = clockStanding;
4
+ exports.latestOf = latestOf;
5
+ exports.worstOf = worstOf;
6
+ exports.offsetFrom = offsetFrom;
7
+ exports.noteClockReading = noteClockReading;
8
+ exports.clockReadings = clockReadings;
9
+ exports.forgetClockReadings = forgetClockReadings;
10
+ const webhook_1 = require("@operato/ops-contract/webhook");
11
+ /**
12
+ * How bad a reading is, against the tolerance the signature check actually uses.
13
+ *
14
+ * The threshold is not a number picked here. It is the same constant the
15
+ * contract refuses timestamps by, so "out-of-step" means precisely "this is
16
+ * already being rejected" rather than an opinion about how much drift is too
17
+ * much.
18
+ */
19
+ function clockStanding(offsetMs, toleranceMs = webhook_1.WEBHOOK_TOLERANCE_MS) {
20
+ const off = Math.abs(offsetMs);
21
+ if (off >= toleranceMs) {
22
+ return 'out-of-step';
23
+ }
24
+ return off >= toleranceMs / 2 ? 'drifting' : 'in-step';
25
+ }
26
+ /**
27
+ * The reading to report for a peer, given everything seen from it.
28
+ *
29
+ * The most recent one, not the worst. A clock that was wrong and has been put
30
+ * right should stop being reported as wrong -- otherwise the fix cannot be
31
+ * seen to have worked, and the report has to be cleared by hand, which means
32
+ * it will not be.
33
+ */
34
+ function latestOf(readings, peer) {
35
+ let latest;
36
+ for (const reading of readings) {
37
+ if (reading.peer === peer && (!latest || reading.atMs > latest.atMs)) {
38
+ latest = reading;
39
+ }
40
+ }
41
+ return latest;
42
+ }
43
+ /**
44
+ * The single reading that best answers "is this server's clock trustworthy".
45
+ *
46
+ * The furthest-off peer among the current readings. One peer disagreeing is
47
+ * that peer's problem; every peer disagreeing by about the same amount is
48
+ * ours, and either way the largest one is what a person needs to see first.
49
+ */
50
+ function worstOf(readings) {
51
+ let worst;
52
+ for (const reading of readings) {
53
+ if (!worst || Math.abs(reading.offsetMs) > Math.abs(worst.offsetMs)) {
54
+ worst = reading;
55
+ }
56
+ }
57
+ return worst;
58
+ }
59
+ /**
60
+ * Read a peer's timestamp header into an offset.
61
+ *
62
+ * Anything unreadable gives back nothing rather than zero. Zero is the value
63
+ * that means "the clocks agree perfectly", which is the one thing a header we
64
+ * could not parse never tells us.
65
+ */
66
+ function offsetFrom(theirTimestamp, ourMs) {
67
+ if (!theirTimestamp) {
68
+ return undefined;
69
+ }
70
+ const theirs = Date.parse(theirTimestamp);
71
+ return Number.isFinite(theirs) ? ourMs - theirs : undefined;
72
+ }
73
+ /*
74
+ * ── The readings themselves ────────────────────────────────────────────────
75
+ * Held per process and not written down. A clock offset is only meaningful for
76
+ * about as long as it takes to read it, so persisting it would mostly serve to
77
+ * report a drift that has already been fixed. Each instance answers for the
78
+ * clock it is actually stamping records with, which is the honest thing for a
79
+ * process to report about itself.
80
+ */
81
+ const readings = new Map();
82
+ /** Record one reading. Peers we have not heard from in this process are simply absent. */
83
+ function noteClockReading(peer, offsetMs, atMs = Date.now()) {
84
+ if (offsetMs === undefined) {
85
+ return;
86
+ }
87
+ readings.set(peer, { peer, offsetMs, atMs });
88
+ }
89
+ function clockReadings() {
90
+ return [...readings.values()];
91
+ }
92
+ /** For tests and restarts -- a reading from a previous run says nothing about this clock. */
93
+ function forgetClockReadings() {
94
+ readings.clear();
95
+ }
96
+ //# sourceMappingURL=clock-offset.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clock-offset.js","sourceRoot":"","sources":["../../../server/service/observability/clock-offset.ts"],"names":[],"mappings":";;AA8DA,sCAQC;AAUD,4BAUC;AASD,0BAUC;AASD,gCAQC;AAaD,4CAMC;AAED,sCAEC;AAGD,kDAEC;AA1JD,2DAAoE;AAsDpE;;;;;;;GAOG;AACH,SAAgB,aAAa,CAAC,QAAgB,EAAE,cAAsB,8BAAoB;IACxF,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IAE9B,IAAI,GAAG,IAAI,WAAW,EAAE,CAAC;QACvB,OAAO,aAAa,CAAA;IACtB,CAAC;IAED,OAAO,GAAG,IAAI,WAAW,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,SAAS,CAAA;AACxD,CAAC;AAED;;;;;;;GAOG;AACH,SAAgB,QAAQ,CAAC,QAAiC,EAAE,IAAY;IACtE,IAAI,MAAgC,CAAA;IAEpC,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,IAAI,OAAO,CAAC,IAAI,KAAK,IAAI,IAAI,CAAC,CAAC,MAAM,IAAI,OAAO,CAAC,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC;YACrE,MAAM,GAAG,OAAO,CAAA;QAClB,CAAC;IACH,CAAC;IAED,OAAO,MAAM,CAAA;AACf,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,OAAO,CAAC,QAAiC;IACvD,IAAI,KAA+B,CAAA;IAEnC,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAC;YACpE,KAAK,GAAG,OAAO,CAAA;QACjB,CAAC;IACH,CAAC;IAED,OAAO,KAAK,CAAA;AACd,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,UAAU,CAAC,cAAkC,EAAE,KAAa;IAC1E,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,OAAO,SAAS,CAAA;IAClB,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,CAAA;IAEzC,OAAO,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,SAAS,CAAA;AAC7D,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAwB,CAAA;AAEhD,0FAA0F;AAC1F,SAAgB,gBAAgB,CAAC,IAAY,EAAE,QAA4B,EAAE,OAAe,IAAI,CAAC,GAAG,EAAE;IACpG,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B,OAAM;IACR,CAAC;IAED,QAAQ,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;AAC9C,CAAC;AAED,SAAgB,aAAa;IAC3B,OAAO,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAA;AAC/B,CAAC;AAED,6FAA6F;AAC7F,SAAgB,mBAAmB;IACjC,QAAQ,CAAC,KAAK,EAAE,CAAA;AAClB,CAAC","sourcesContent":["import { WEBHOOK_TOLERANCE_MS } from '@operato/ops-contract/webhook'\n\n/**\n * How far our clock has drifted from the systems we exchange records with.\n *\n * ── Why this is worth measuring ────────────────────────────────────────────\n * Every record we keep is stamped by this server's clock, and the audit trail\n * is only worth anything if that clock is right. Nothing here can check that\n * against a reference clock -- keeping the host in step with one is the host's\n * job, not the application's.\n *\n * But we are not without evidence. Every signed exchange carries the other\n * side's timestamp, so each one is a reading of the difference between two\n * clocks. We were throwing that away.\n *\n * ── What goes wrong when it drifts ─────────────────────────────────────────\n * The signature check refuses a timestamp more than five minutes from now, in\n * either direction. So a clock that slips past that does not degrade: every\n * exchange in both directions turns into 401 at once, and the message says the\n * webhook was rejected, which reads like a secret problem. Somebody then\n * checks the secret, finds it right, and looks somewhere else.\n *\n * That is why the middle band exists. Half the tolerance is still working, and\n * it is the only chance to say something before everything stops.\n *\n * ── What this is not ───────────────────────────────────────────────────────\n * It is not a time source and it does not correct anything. A reading says the\n * two clocks disagree; it cannot say which one is wrong. Both are reported so\n * a person can go and look.\n */\n\n/** Where a reading came from. Kept so a report can name the peer rather than an average. */\nexport interface ClockReading {\n /** The system on the other side, as we know it -- a site id or a destination name. */\n peer: string\n /**\n * Our clock minus theirs, in milliseconds.\n *\n * Positive means we are ahead of them. The sign is kept because it is the\n * first thing anyone asks, and an absolute value cannot be added back.\n */\n offsetMs: number\n /** When we took the reading, by our clock. */\n atMs: number\n}\n\nexport type ClockStanding =\n /** Comfortably within what the signature check allows. */\n | 'in-step'\n /** Still working, but past half the tolerance -- this is the warning band. */\n | 'drifting'\n /** Beyond what the signature check allows: exchanges are failing right now. */\n | 'out-of-step'\n\n/**\n * How bad a reading is, against the tolerance the signature check actually uses.\n *\n * The threshold is not a number picked here. It is the same constant the\n * contract refuses timestamps by, so \"out-of-step\" means precisely \"this is\n * already being rejected\" rather than an opinion about how much drift is too\n * much.\n */\nexport function clockStanding(offsetMs: number, toleranceMs: number = WEBHOOK_TOLERANCE_MS): ClockStanding {\n const off = Math.abs(offsetMs)\n\n if (off >= toleranceMs) {\n return 'out-of-step'\n }\n\n return off >= toleranceMs / 2 ? 'drifting' : 'in-step'\n}\n\n/**\n * The reading to report for a peer, given everything seen from it.\n *\n * The most recent one, not the worst. A clock that was wrong and has been put\n * right should stop being reported as wrong -- otherwise the fix cannot be\n * seen to have worked, and the report has to be cleared by hand, which means\n * it will not be.\n */\nexport function latestOf(readings: readonly ClockReading[], peer: string): ClockReading | undefined {\n let latest: ClockReading | undefined\n\n for (const reading of readings) {\n if (reading.peer === peer && (!latest || reading.atMs > latest.atMs)) {\n latest = reading\n }\n }\n\n return latest\n}\n\n/**\n * The single reading that best answers \"is this server's clock trustworthy\".\n *\n * The furthest-off peer among the current readings. One peer disagreeing is\n * that peer's problem; every peer disagreeing by about the same amount is\n * ours, and either way the largest one is what a person needs to see first.\n */\nexport function worstOf(readings: readonly ClockReading[]): ClockReading | undefined {\n let worst: ClockReading | undefined\n\n for (const reading of readings) {\n if (!worst || Math.abs(reading.offsetMs) > Math.abs(worst.offsetMs)) {\n worst = reading\n }\n }\n\n return worst\n}\n\n/**\n * Read a peer's timestamp header into an offset.\n *\n * Anything unreadable gives back nothing rather than zero. Zero is the value\n * that means \"the clocks agree perfectly\", which is the one thing a header we\n * could not parse never tells us.\n */\nexport function offsetFrom(theirTimestamp: string | undefined, ourMs: number): number | undefined {\n if (!theirTimestamp) {\n return undefined\n }\n\n const theirs = Date.parse(theirTimestamp)\n\n return Number.isFinite(theirs) ? ourMs - theirs : undefined\n}\n\n/*\n * ── The readings themselves ────────────────────────────────────────────────\n * Held per process and not written down. A clock offset is only meaningful for\n * about as long as it takes to read it, so persisting it would mostly serve to\n * report a drift that has already been fixed. Each instance answers for the\n * clock it is actually stamping records with, which is the honest thing for a\n * process to report about itself.\n */\nconst readings = new Map<string, ClockReading>()\n\n/** Record one reading. Peers we have not heard from in this process are simply absent. */\nexport function noteClockReading(peer: string, offsetMs: number | undefined, atMs: number = Date.now()): void {\n if (offsetMs === undefined) {\n return\n }\n\n readings.set(peer, { peer, offsetMs, atMs })\n}\n\nexport function clockReadings(): ClockReading[] {\n return [...readings.values()]\n}\n\n/** For tests and restarts -- a reading from a previous run says nothing about this clock. */\nexport function forgetClockReadings(): void {\n readings.clear()\n}\n"]}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * **한 사실이 어디를 지났는지 잇는 값** — W3C Trace Context 의 `traceparent`.
3
+ *
4
+ * ═══════════════════════════════════════════════════════════════════════════
5
+ * ── 무엇이 없어서 무엇이 아팠나 ────────────────────────────────────────────
6
+ * 실적 하나가 MES 에서 트윈까지 가는 데 다섯 봉투가 나간다. 그중 하나가 안 앉으면 **양쪽 로그를
7
+ * 사람이 눈으로 맞춰야 한다.**
8
+ *
9
+ * 실제로 그랬다(2026-09-05). 봉투가 저널에 앉았는지 확인하려고 두 사람이 `seq` 와 밀리초 시각으로
10
+ * 대조했고, 한 건이 조용히 합쳐진 것을 찾는 데 그 대조가 전부였다. 잇는 값이 있었으면 한 번에 찾았다.
11
+ *
12
+ * ── 왜 우리 것을 짓지 않나 ─────────────────────────────────────────────────
13
+ * W3C 가 이미 정했고(Trace Context Level 1), 모든 수집기가 그 이름으로 읽는다. 우리 이름으로 지으면
14
+ * 우리 로그에서만 이어지고 그 밖에서는 아무 뜻이 없다.
15
+ *
16
+ * ```
17
+ * traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
18
+ * ^^ ^──────── trace-id ────────────^ ^─ parent-id ─^ ^^ flags
19
+ * 버전 한 흐름 전체를 가리킨다 이 구간 샘플 여부
20
+ * ```
21
+ *
22
+ * ── 받은 것을 이어 간다. 없으면 시작한다 ───────────────────────────────────
23
+ * 부르는 쪽이 값을 주면 **그 흐름을 잇는다**(`trace-id` 를 유지하고 구간만 새로 딴다). 안 주면 우리가
24
+ * 시작한 흐름이다.
25
+ *
26
+ * ── 모양이 이상하면 **잇지 않고 새로 시작한다** ────────────────────────────
27
+ * 남이 준 값을 검사 없이 실어 보내면 우리 로그에 남의 형식이 섞이고, 수집기가 그 줄을 통째로 버린다.
28
+ * 그러면 **추적을 붙였는데 아무것도 안 이어지는** 상태가 된다 — 안 붙인 것보다 나쁘다.
29
+ *
30
+ * 전부 0인 값도 규격이 금지한다(§3.2.2.3). 그것을 이으면 서로 다른 흐름이 한 덩어리가 된다.
31
+ * ═══════════════════════════════════════════════════════════════════════════
32
+ */
33
+ /** W3C 가 정한 헤더 이름. 소문자다. */
34
+ export declare const TRACEPARENT = "traceparent";
35
+ export interface TraceContext {
36
+ /** 한 흐름 전체 — 32자리 16진수. 실적 하나가 트윈까지 가는 동안 같은 값이다. */
37
+ traceId: string;
38
+ /** 이 구간 — 16자리 16진수. 봉투마다 새로 딴다. */
39
+ spanId: string;
40
+ /** 부르는 쪽이 준 값을 이었나. 거짓이면 우리가 시작한 흐름이다. */
41
+ continued: boolean;
42
+ }
43
+ /**
44
+ * 받은 헤더에서 **이을 수 있는 흐름**만 꺼낸다.
45
+ *
46
+ * 못 이으면 `undefined` — 부르는 쪽이 그때 새로 시작한다. **고쳐서 쓰지 않는다**(머리말).
47
+ */
48
+ export declare function parseTraceparent(header: unknown): {
49
+ traceId: string;
50
+ spanId: string;
51
+ } | undefined;
52
+ /**
53
+ * 이 구간의 추적 값을 만든다.
54
+ *
55
+ * `incoming` 이 이을 수 있으면 그 흐름을 잇고, 아니면 새로 시작한다.
56
+ */
57
+ export declare function traceFor(incoming: unknown, random?: () => string): TraceContext;
58
+ /** 내보낼 헤더 값. 받는 쪽이 이것을 이어 간다. */
59
+ export declare function toTraceparent(trace: TraceContext): string;
60
+ /**
61
+ * 로그에 붙일 짧은 표시.
62
+ *
63
+ * 32자리를 매 줄에 적으면 사람이 안 읽는다. 앞 8자리면 한 서버의 하루 안에서 충분히 갈린다 —
64
+ * **대조용이지 식별자가 아니다.** 정확히 이을 때는 전체 값을 쓴다.
65
+ */
66
+ export declare function traceTag(trace: TraceContext): string;
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ /**
3
+ * **한 사실이 어디를 지났는지 잇는 값** — W3C Trace Context 의 `traceparent`.
4
+ *
5
+ * ═══════════════════════════════════════════════════════════════════════════
6
+ * ── 무엇이 없어서 무엇이 아팠나 ────────────────────────────────────────────
7
+ * 실적 하나가 MES 에서 트윈까지 가는 데 다섯 봉투가 나간다. 그중 하나가 안 앉으면 **양쪽 로그를
8
+ * 사람이 눈으로 맞춰야 한다.**
9
+ *
10
+ * 실제로 그랬다(2026-09-05). 봉투가 저널에 앉았는지 확인하려고 두 사람이 `seq` 와 밀리초 시각으로
11
+ * 대조했고, 한 건이 조용히 합쳐진 것을 찾는 데 그 대조가 전부였다. 잇는 값이 있었으면 한 번에 찾았다.
12
+ *
13
+ * ── 왜 우리 것을 짓지 않나 ─────────────────────────────────────────────────
14
+ * W3C 가 이미 정했고(Trace Context Level 1), 모든 수집기가 그 이름으로 읽는다. 우리 이름으로 지으면
15
+ * 우리 로그에서만 이어지고 그 밖에서는 아무 뜻이 없다.
16
+ *
17
+ * ```
18
+ * traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
19
+ * ^^ ^──────── trace-id ────────────^ ^─ parent-id ─^ ^^ flags
20
+ * 버전 한 흐름 전체를 가리킨다 이 구간 샘플 여부
21
+ * ```
22
+ *
23
+ * ── 받은 것을 이어 간다. 없으면 시작한다 ───────────────────────────────────
24
+ * 부르는 쪽이 값을 주면 **그 흐름을 잇는다**(`trace-id` 를 유지하고 구간만 새로 딴다). 안 주면 우리가
25
+ * 시작한 흐름이다.
26
+ *
27
+ * ── 모양이 이상하면 **잇지 않고 새로 시작한다** ────────────────────────────
28
+ * 남이 준 값을 검사 없이 실어 보내면 우리 로그에 남의 형식이 섞이고, 수집기가 그 줄을 통째로 버린다.
29
+ * 그러면 **추적을 붙였는데 아무것도 안 이어지는** 상태가 된다 — 안 붙인 것보다 나쁘다.
30
+ *
31
+ * 전부 0인 값도 규격이 금지한다(§3.2.2.3). 그것을 이으면 서로 다른 흐름이 한 덩어리가 된다.
32
+ * ═══════════════════════════════════════════════════════════════════════════
33
+ */
34
+ Object.defineProperty(exports, "__esModule", { value: true });
35
+ exports.TRACEPARENT = void 0;
36
+ exports.parseTraceparent = parseTraceparent;
37
+ exports.traceFor = traceFor;
38
+ exports.toTraceparent = toTraceparent;
39
+ exports.traceTag = traceTag;
40
+ /** W3C 가 정한 헤더 이름. 소문자다. */
41
+ exports.TRACEPARENT = 'traceparent';
42
+ /** 우리가 내는 버전. Level 1 은 `00` 하나뿐이다. */
43
+ const VERSION = '00';
44
+ /** 표본으로 남긴다 — 우리는 모두 남기므로 늘 켜 둔다. */
45
+ const SAMPLED = '01';
46
+ const TRACEPARENT_SHAPE = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
47
+ /**
48
+ * 받은 헤더에서 **이을 수 있는 흐름**만 꺼낸다.
49
+ *
50
+ * 못 이으면 `undefined` — 부르는 쪽이 그때 새로 시작한다. **고쳐서 쓰지 않는다**(머리말).
51
+ */
52
+ function parseTraceparent(header) {
53
+ if (typeof header !== 'string') {
54
+ return undefined;
55
+ }
56
+ const matched = TRACEPARENT_SHAPE.exec(header.trim().toLowerCase());
57
+ if (!matched) {
58
+ return undefined;
59
+ }
60
+ const [, version, traceId, spanId] = matched;
61
+ /* `ff` 는 규격이 잘못된 값으로 정한다(§3.2.2.1). 뒤 버전은 앞부분이 같으므로 읽어도 된다. */
62
+ if (version === 'ff') {
63
+ return undefined;
64
+ }
65
+ /* 전부 0인 식별자는 규격이 금지한다 — 이으면 서로 다른 흐름이 한 덩어리가 된다. */
66
+ if (/^0+$/.test(traceId) || /^0+$/.test(spanId)) {
67
+ return undefined;
68
+ }
69
+ return { traceId, spanId };
70
+ }
71
+ /**
72
+ * 이 구간의 추적 값을 만든다.
73
+ *
74
+ * `incoming` 이 이을 수 있으면 그 흐름을 잇고, 아니면 새로 시작한다.
75
+ */
76
+ function traceFor(incoming, random = hex) {
77
+ const found = parseTraceparent(incoming);
78
+ return {
79
+ /* **자리 수를 늘 맞춘다.** 짧으면 규격에 안 맞아 받는 쪽이 헤더를 통째로 버린다 — 붙였는데
80
+ 아무것도 안 이어지는 상태가 되고, 그건 안 붙인 것보다 나쁘다. */
81
+ traceId: found ? found.traceId : fit(random(), 32),
82
+ spanId: fit(random(), 16),
83
+ continued: !!found
84
+ };
85
+ }
86
+ /** 16진수를 정확히 그 자리 수로. 모자라면 채우고 남으면 자른다. */
87
+ function fit(value, size) {
88
+ return value.padEnd(size, '0').slice(0, size);
89
+ }
90
+ /** 내보낼 헤더 값. 받는 쪽이 이것을 이어 간다. */
91
+ function toTraceparent(trace) {
92
+ return `${VERSION}-${trace.traceId}-${trace.spanId}-${SAMPLED}`;
93
+ }
94
+ /**
95
+ * 로그에 붙일 짧은 표시.
96
+ *
97
+ * 32자리를 매 줄에 적으면 사람이 안 읽는다. 앞 8자리면 한 서버의 하루 안에서 충분히 갈린다 —
98
+ * **대조용이지 식별자가 아니다.** 정확히 이을 때는 전체 값을 쓴다.
99
+ */
100
+ function traceTag(trace) {
101
+ return trace.traceId.slice(0, 8);
102
+ }
103
+ /** 16진수 32자리. 시각을 앞에 두어 같은 밀리초가 아니면 앞자리부터 갈린다. */
104
+ function hex() {
105
+ const now = Date.now().toString(16).padStart(12, '0');
106
+ const tail = Array.from({ length: 5 }, () => Math.floor(Math.random() * 0xffff).toString(16).padStart(4, '0')).join('');
107
+ return (now + tail).slice(0, 32);
108
+ }
109
+ //# sourceMappingURL=trace-context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trace-context.js","sourceRoot":"","sources":["../../../server/service/observability/trace-context.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;;;AA2BH,4CAuBC;AAOD,4BAUC;AAQD,sCAEC;AAQD,4BAEC;AArFD,4BAA4B;AACf,QAAA,WAAW,GAAG,aAAa,CAAA;AAExC,uCAAuC;AACvC,MAAM,OAAO,GAAG,IAAI,CAAA;AAEpB,qCAAqC;AACrC,MAAM,OAAO,GAAG,IAAI,CAAA;AAWpB,MAAM,iBAAiB,GAAG,6DAA6D,CAAA;AAEvF;;;;GAIG;AACH,SAAgB,gBAAgB,CAAC,MAAe;IAC9C,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;QAC/B,OAAO,SAAS,CAAA;IAClB,CAAC;IAED,MAAM,OAAO,GAAG,iBAAiB,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,CAAA;IACnE,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,SAAS,CAAA;IAClB,CAAC;IAED,MAAM,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,GAAG,OAAO,CAAA;IAE5C,+DAA+D;IAC/D,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;QACrB,OAAO,SAAS,CAAA;IAClB,CAAC;IAED,oDAAoD;IACpD,IAAI,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;QAChD,OAAO,SAAS,CAAA;IAClB,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,CAAA;AAC5B,CAAC;AAED;;;;GAIG;AACH,SAAgB,QAAQ,CAAC,QAAiB,EAAE,SAAuB,GAAG;IACpE,MAAM,KAAK,GAAG,gBAAgB,CAAC,QAAQ,CAAC,CAAA;IAExC,OAAO;QACL;kDAC0C;QAC1C,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,EAAE,EAAE,EAAE,CAAC;QAClD,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,EAAE,EAAE,CAAC;QACzB,SAAS,EAAE,CAAC,CAAC,KAAK;KACnB,CAAA;AACH,CAAC;AAED,2CAA2C;AAC3C,SAAS,GAAG,CAAC,KAAa,EAAE,IAAY;IACtC,OAAO,KAAK,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,CAAA;AAC/C,CAAC;AAED,iCAAiC;AACjC,SAAgB,aAAa,CAAC,KAAmB;IAC/C,OAAO,GAAG,OAAO,IAAI,KAAK,CAAC,OAAO,IAAI,KAAK,CAAC,MAAM,IAAI,OAAO,EAAE,CAAA;AACjE,CAAC;AAED;;;;;GAKG;AACH,SAAgB,QAAQ,CAAC,KAAmB;IAC1C,OAAO,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;AAClC,CAAC;AAED,kDAAkD;AAClD,SAAS,GAAG;IACV,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,EAAE,EAAE,GAAG,CAAC,CAAA;IACrD,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,MAAM,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAEvH,OAAO,CAAC,GAAG,GAAG,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;AAClC,CAAC","sourcesContent":["/**\n * **한 사실이 어디를 지났는지 잇는 값** — W3C Trace Context 의 `traceparent`.\n *\n * ═══════════════════════════════════════════════════════════════════════════\n * ── 무엇이 없어서 무엇이 아팠나 ────────────────────────────────────────────\n * 실적 하나가 MES 에서 트윈까지 가는 데 다섯 봉투가 나간다. 그중 하나가 안 앉으면 **양쪽 로그를\n * 사람이 눈으로 맞춰야 한다.**\n *\n * 실제로 그랬다(2026-09-05). 봉투가 저널에 앉았는지 확인하려고 두 사람이 `seq` 와 밀리초 시각으로\n * 대조했고, 한 건이 조용히 합쳐진 것을 찾는 데 그 대조가 전부였다. 잇는 값이 있었으면 한 번에 찾았다.\n *\n * ── 왜 우리 것을 짓지 않나 ─────────────────────────────────────────────────\n * W3C 가 이미 정했고(Trace Context Level 1), 모든 수집기가 그 이름으로 읽는다. 우리 이름으로 지으면\n * 우리 로그에서만 이어지고 그 밖에서는 아무 뜻이 없다.\n *\n * ```\n * traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\n * ^^ ^──────── trace-id ────────────^ ^─ parent-id ─^ ^^ flags\n * 버전 한 흐름 전체를 가리킨다 이 구간 샘플 여부\n * ```\n *\n * ── 받은 것을 이어 간다. 없으면 시작한다 ───────────────────────────────────\n * 부르는 쪽이 값을 주면 **그 흐름을 잇는다**(`trace-id` 를 유지하고 구간만 새로 딴다). 안 주면 우리가\n * 시작한 흐름이다.\n *\n * ── 모양이 이상하면 **잇지 않고 새로 시작한다** ────────────────────────────\n * 남이 준 값을 검사 없이 실어 보내면 우리 로그에 남의 형식이 섞이고, 수집기가 그 줄을 통째로 버린다.\n * 그러면 **추적을 붙였는데 아무것도 안 이어지는** 상태가 된다 — 안 붙인 것보다 나쁘다.\n *\n * 전부 0인 값도 규격이 금지한다(§3.2.2.3). 그것을 이으면 서로 다른 흐름이 한 덩어리가 된다.\n * ═══════════════════════════════════════════════════════════════════════════\n */\n\n/** W3C 가 정한 헤더 이름. 소문자다. */\nexport const TRACEPARENT = 'traceparent'\n\n/** 우리가 내는 버전. Level 1 은 `00` 하나뿐이다. */\nconst VERSION = '00'\n\n/** 표본으로 남긴다 — 우리는 모두 남기므로 늘 켜 둔다. */\nconst SAMPLED = '01'\n\nexport interface TraceContext {\n /** 한 흐름 전체 — 32자리 16진수. 실적 하나가 트윈까지 가는 동안 같은 값이다. */\n traceId: string\n /** 이 구간 — 16자리 16진수. 봉투마다 새로 딴다. */\n spanId: string\n /** 부르는 쪽이 준 값을 이었나. 거짓이면 우리가 시작한 흐름이다. */\n continued: boolean\n}\n\nconst TRACEPARENT_SHAPE = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/\n\n/**\n * 받은 헤더에서 **이을 수 있는 흐름**만 꺼낸다.\n *\n * 못 이으면 `undefined` — 부르는 쪽이 그때 새로 시작한다. **고쳐서 쓰지 않는다**(머리말).\n */\nexport function parseTraceparent(header: unknown): { traceId: string; spanId: string } | undefined {\n if (typeof header !== 'string') {\n return undefined\n }\n\n const matched = TRACEPARENT_SHAPE.exec(header.trim().toLowerCase())\n if (!matched) {\n return undefined\n }\n\n const [, version, traceId, spanId] = matched\n\n /* `ff` 는 규격이 잘못된 값으로 정한다(§3.2.2.1). 뒤 버전은 앞부분이 같으므로 읽어도 된다. */\n if (version === 'ff') {\n return undefined\n }\n\n /* 전부 0인 식별자는 규격이 금지한다 — 이으면 서로 다른 흐름이 한 덩어리가 된다. */\n if (/^0+$/.test(traceId) || /^0+$/.test(spanId)) {\n return undefined\n }\n\n return { traceId, spanId }\n}\n\n/**\n * 이 구간의 추적 값을 만든다.\n *\n * `incoming` 이 이을 수 있으면 그 흐름을 잇고, 아니면 새로 시작한다.\n */\nexport function traceFor(incoming: unknown, random: () => string = hex): TraceContext {\n const found = parseTraceparent(incoming)\n\n return {\n /* **자리 수를 늘 맞춘다.** 짧으면 규격에 안 맞아 받는 쪽이 헤더를 통째로 버린다 — 붙였는데\n 아무것도 안 이어지는 상태가 되고, 그건 안 붙인 것보다 나쁘다. */\n traceId: found ? found.traceId : fit(random(), 32),\n spanId: fit(random(), 16),\n continued: !!found\n }\n}\n\n/** 16진수를 정확히 그 자리 수로. 모자라면 채우고 남으면 자른다. */\nfunction fit(value: string, size: number): string {\n return value.padEnd(size, '0').slice(0, size)\n}\n\n/** 내보낼 헤더 값. 받는 쪽이 이것을 이어 간다. */\nexport function toTraceparent(trace: TraceContext): string {\n return `${VERSION}-${trace.traceId}-${trace.spanId}-${SAMPLED}`\n}\n\n/**\n * 로그에 붙일 짧은 표시.\n *\n * 32자리를 매 줄에 적으면 사람이 안 읽는다. 앞 8자리면 한 서버의 하루 안에서 충분히 갈린다 —\n * **대조용이지 식별자가 아니다.** 정확히 이을 때는 전체 값을 쓴다.\n */\nexport function traceTag(trace: TraceContext): string {\n return trace.traceId.slice(0, 8)\n}\n\n/** 16진수 32자리. 시각을 앞에 두어 같은 밀리초가 아니면 앞자리부터 갈린다. */\nfunction hex(): string {\n const now = Date.now().toString(16).padStart(12, '0')\n const tail = Array.from({ length: 5 }, () => Math.floor(Math.random() * 0xffff).toString(16).padStart(4, '0')).join('')\n\n return (now + tail).slice(0, 32)\n}\n"]}
@@ -0,0 +1,85 @@
1
+ import { type MaybeTransactional } from './writing-transaction.js';
2
+ /**
3
+ * **The back row of the outbox** — writing a fact down so it can be delivered later
4
+ * (ADR-0078 decision 2 ②).
5
+ *
6
+ * ═════════════════════════════════════════════════════════════════════════
7
+ * ## The number can burn, and nothing says so
8
+ *
9
+ * A number is taken by raising a counter, and the row is written with it. If those two are not in one
10
+ * transaction, the raise commits on its own and a later failure leaves a number that names no row.
11
+ * The receiver stops at that hole and waits for a record that will never come.
12
+ *
13
+ * It happened: 200 numbers in one stretch (968..1167) at one site, found months later by counting rows
14
+ * against numbers. There is no delete anywhere in this machine, so the rows were not removed - the
15
+ * numbers were burned. A caller had passed the default manager, where every statement commits where it
16
+ * stands.
17
+ *
18
+ * ## So the transaction is in the type, not only in a check
19
+ *
20
+ * `OpenTransaction` cannot be written down by hand: the only way to get one is through
21
+ * `openTransaction(manager)`, which throws when the manager is not in a transaction. A plain manager
22
+ * does not compile here. The runtime check stays for callers who reach this through JavaScript.
23
+ *
24
+ * ## The store does not know what a row is made of
25
+ *
26
+ * Allocating and inserting belong to whoever owns the table (this module over its own, operato-plant
27
+ * over its own until those rows move). What belongs here is the order of the two, the grouping by
28
+ * stream, and the refusals below - which is what makes the machine one machine rather than two copies.
29
+ * ═════════════════════════════════════════════════════════════════════════
30
+ */
31
+ declare const OPEN_TRANSACTION: unique symbol;
32
+ /**
33
+ * A manager that is **in an open transaction**. Obtained only from `openTransaction`, so a function
34
+ * asking for one cannot be handed the default manager by mistake.
35
+ */
36
+ export type OpenTransaction<T = unknown> = T & {
37
+ readonly [OPEN_TRANSACTION]: true;
38
+ };
39
+ /** Throws when this manager is not in a transaction; otherwise hands it back as one. */
40
+ export declare function openTransaction<T extends MaybeTransactional>(manager: T): OpenTransaction<T>;
41
+ /** What is written down about one fact. The record itself is opaque here. */
42
+ export interface OutboxAppendInput<Fact = unknown> {
43
+ /** The fact, as the contract shapes it. */
44
+ record: Fact;
45
+ /** The receiver's own id for this envelope. One is made when the caller does not name it. */
46
+ eventId?: string;
47
+ /** When it happened. Defaults to when it was recorded. */
48
+ eventTime?: Date;
49
+ /** Anything else this product keeps per row - lane, provenance, and the like. */
50
+ extra?: Record<string, unknown>;
51
+ }
52
+ /** A row on its way into the table, before the store gives it an id. */
53
+ export interface NewOutboxRow<Fact = unknown> {
54
+ seq: number;
55
+ eventId: string;
56
+ eventTime: Date;
57
+ recordTime: Date;
58
+ record: Fact;
59
+ extra?: Record<string, unknown>;
60
+ }
61
+ /**
62
+ * The half of a store that writes. Bound to one stream, like the reading half.
63
+ *
64
+ * `allocate` raises this stream's counter by `count` and answers the **first** number of the run it
65
+ * just took. Raising and answering are one step on purpose: two callers that read, add and write would
66
+ * hand out the same number.
67
+ */
68
+ export interface OutboxAppendStore<Fact = unknown, Row = unknown> {
69
+ allocate(count: number): Promise<number>;
70
+ insert(rows: readonly NewOutboxRow<Fact>[]): Promise<Row[]>;
71
+ }
72
+ /** What a record has to satisfy before it is written down. The caller declares this per product. */
73
+ export type RecordCheck<Fact = unknown> = (record: Fact) => string[];
74
+ /**
75
+ * Write these facts down, in one run of numbers, inside the caller's transaction.
76
+ *
77
+ * Refusals are here rather than at the receiver because this runs **inside the business transaction**:
78
+ * refusing rolls the work back, so a fact with a broken identity is never recorded in the first place.
79
+ * Let it through and it lands in the receiver's dead letters, by which time the fact is late and the
80
+ * row that caused it is already committed.
81
+ */
82
+ export declare function appendOutbox<Fact, Row>(tx: OpenTransaction, store: OutboxAppendStore<Fact, Row>, inputs: readonly OutboxAppendInput<Fact>[], options?: {
83
+ check?: RecordCheck<Fact>;
84
+ }): Promise<Row[]>;
85
+ export {};
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.openTransaction = openTransaction;
4
+ exports.appendOutbox = appendOutbox;
5
+ const crypto_1 = require("crypto");
6
+ const writing_transaction_js_1 = require("./writing-transaction.js");
7
+ /** Throws when this manager is not in a transaction; otherwise hands it back as one. */
8
+ function openTransaction(manager) {
9
+ (0, writing_transaction_js_1.assertWritingTransaction)(manager);
10
+ return manager;
11
+ }
12
+ /**
13
+ * Write these facts down, in one run of numbers, inside the caller's transaction.
14
+ *
15
+ * Refusals are here rather than at the receiver because this runs **inside the business transaction**:
16
+ * refusing rolls the work back, so a fact with a broken identity is never recorded in the first place.
17
+ * Let it through and it lands in the receiver's dead letters, by which time the fact is late and the
18
+ * row that caused it is already committed.
19
+ */
20
+ async function appendOutbox(tx, store, inputs, options = {}) {
21
+ if (!inputs?.length) {
22
+ return [];
23
+ }
24
+ for (const input of inputs) {
25
+ if (!input.record || typeof input.record !== 'object') {
26
+ throw new Error('appendOutbox: record is required - an empty payload would be delivered as a fact');
27
+ }
28
+ const violations = options.check?.(input.record) ?? [];
29
+ if (violations.length) {
30
+ throw new Error(`appendOutbox: identity is not valid - ${violations.join(' · ')}`);
31
+ }
32
+ }
33
+ /* The type says this already; the check stays for callers arriving from JavaScript. */
34
+ (0, writing_transaction_js_1.assertWritingTransaction)(tx);
35
+ const recordTime = new Date();
36
+ const firstSeq = await store.allocate(inputs.length);
37
+ return await store.insert(inputs.map((input, index) => ({
38
+ seq: firstSeq + index,
39
+ eventId: input.eventId ?? `urn:uuid:${(0, crypto_1.randomUUID)()}`,
40
+ eventTime: input.eventTime ?? recordTime,
41
+ recordTime,
42
+ record: input.record,
43
+ ...(input.extra ? { extra: input.extra } : {})
44
+ })));
45
+ }
46
+ //# sourceMappingURL=append.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"append.js","sourceRoot":"","sources":["../../../server/service/outbox/append.ts"],"names":[],"mappings":";;AA2CA,0CAIC;AA+CD,oCAsCC;AApID,mCAAmC;AAEnC,qEAA4F;AAwC5F,wFAAwF;AACxF,SAAgB,eAAe,CAA+B,OAAU;IACtE,IAAA,iDAAwB,EAAC,OAAO,CAAC,CAAA;IAEjC,OAAO,OAA6B,CAAA;AACtC,CAAC;AAuCD;;;;;;;GAOG;AACI,KAAK,UAAU,YAAY,CAChC,EAAmB,EACnB,KAAmC,EACnC,MAA0C,EAC1C,UAAyC,EAAE;IAE3C,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC;QACpB,OAAO,EAAE,CAAA;IACX,CAAC;IAED,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,IAAI,CAAC,KAAK,CAAC,MAAM,IAAI,OAAO,KAAK,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;YACtD,MAAM,IAAI,KAAK,CAAC,kFAAkF,CAAC,CAAA;QACrG,CAAC;QAED,MAAM,UAAU,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,EAAE,CAAA;QAEtD,IAAI,UAAU,CAAC,MAAM,EAAE,CAAC;YACtB,MAAM,IAAI,KAAK,CAAC,yCAAyC,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;QACpF,CAAC;IACH,CAAC;IAED,uFAAuF;IACvF,IAAA,iDAAwB,EAAC,EAAwB,CAAC,CAAA;IAElD,MAAM,UAAU,GAAG,IAAI,IAAI,EAAE,CAAA;IAC7B,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IAEpD,OAAO,MAAM,KAAK,CAAC,MAAM,CACvB,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;QAC5B,GAAG,EAAE,QAAQ,GAAG,KAAK;QACrB,OAAO,EAAE,KAAK,CAAC,OAAO,IAAI,YAAY,IAAA,mBAAU,GAAE,EAAE;QACpD,SAAS,EAAE,KAAK,CAAC,SAAS,IAAI,UAAU;QACxC,UAAU;QACV,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC/C,CAAC,CAAC,CACJ,CAAA;AACH,CAAC","sourcesContent":["import { randomUUID } from 'crypto'\n\nimport { assertWritingTransaction, type MaybeTransactional } from './writing-transaction.js'\n\n/**\n * **The back row of the outbox** — writing a fact down so it can be delivered later\n * (ADR-0078 decision 2 ②).\n *\n * ═════════════════════════════════════════════════════════════════════════\n * ## The number can burn, and nothing says so\n *\n * A number is taken by raising a counter, and the row is written with it. If those two are not in one\n * transaction, the raise commits on its own and a later failure leaves a number that names no row.\n * The receiver stops at that hole and waits for a record that will never come.\n *\n * It happened: 200 numbers in one stretch (968..1167) at one site, found months later by counting rows\n * against numbers. There is no delete anywhere in this machine, so the rows were not removed - the\n * numbers were burned. A caller had passed the default manager, where every statement commits where it\n * stands.\n *\n * ## So the transaction is in the type, not only in a check\n *\n * `OpenTransaction` cannot be written down by hand: the only way to get one is through\n * `openTransaction(manager)`, which throws when the manager is not in a transaction. A plain manager\n * does not compile here. The runtime check stays for callers who reach this through JavaScript.\n *\n * ## The store does not know what a row is made of\n *\n * Allocating and inserting belong to whoever owns the table (this module over its own, operato-plant\n * over its own until those rows move). What belongs here is the order of the two, the grouping by\n * stream, and the refusals below - which is what makes the machine one machine rather than two copies.\n * ═════════════════════════════════════════════════════════════════════════\n */\n\ndeclare const OPEN_TRANSACTION: unique symbol\n\n/**\n * A manager that is **in an open transaction**. Obtained only from `openTransaction`, so a function\n * asking for one cannot be handed the default manager by mistake.\n */\nexport type OpenTransaction<T = unknown> = T & { readonly [OPEN_TRANSACTION]: true }\n\n/** Throws when this manager is not in a transaction; otherwise hands it back as one. */\nexport function openTransaction<T extends MaybeTransactional>(manager: T): OpenTransaction<T> {\n assertWritingTransaction(manager)\n\n return manager as OpenTransaction<T>\n}\n\n/** What is written down about one fact. The record itself is opaque here. */\nexport interface OutboxAppendInput<Fact = unknown> {\n /** The fact, as the contract shapes it. */\n record: Fact\n /** The receiver's own id for this envelope. One is made when the caller does not name it. */\n eventId?: string\n /** When it happened. Defaults to when it was recorded. */\n eventTime?: Date\n /** Anything else this product keeps per row - lane, provenance, and the like. */\n extra?: Record<string, unknown>\n}\n\n/** A row on its way into the table, before the store gives it an id. */\nexport interface NewOutboxRow<Fact = unknown> {\n seq: number\n eventId: string\n eventTime: Date\n recordTime: Date\n record: Fact\n extra?: Record<string, unknown>\n}\n\n/**\n * The half of a store that writes. Bound to one stream, like the reading half.\n *\n * `allocate` raises this stream's counter by `count` and answers the **first** number of the run it\n * just took. Raising and answering are one step on purpose: two callers that read, add and write would\n * hand out the same number.\n */\nexport interface OutboxAppendStore<Fact = unknown, Row = unknown> {\n allocate(count: number): Promise<number>\n insert(rows: readonly NewOutboxRow<Fact>[]): Promise<Row[]>\n}\n\n/** What a record has to satisfy before it is written down. The caller declares this per product. */\nexport type RecordCheck<Fact = unknown> = (record: Fact) => string[]\n\n/**\n * Write these facts down, in one run of numbers, inside the caller's transaction.\n *\n * Refusals are here rather than at the receiver because this runs **inside the business transaction**:\n * refusing rolls the work back, so a fact with a broken identity is never recorded in the first place.\n * Let it through and it lands in the receiver's dead letters, by which time the fact is late and the\n * row that caused it is already committed.\n */\nexport async function appendOutbox<Fact, Row>(\n tx: OpenTransaction,\n store: OutboxAppendStore<Fact, Row>,\n inputs: readonly OutboxAppendInput<Fact>[],\n options: { check?: RecordCheck<Fact> } = {}\n): Promise<Row[]> {\n if (!inputs?.length) {\n return []\n }\n\n for (const input of inputs) {\n if (!input.record || typeof input.record !== 'object') {\n throw new Error('appendOutbox: record is required - an empty payload would be delivered as a fact')\n }\n\n const violations = options.check?.(input.record) ?? []\n\n if (violations.length) {\n throw new Error(`appendOutbox: identity is not valid - ${violations.join(' · ')}`)\n }\n }\n\n /* The type says this already; the check stays for callers arriving from JavaScript. */\n assertWritingTransaction(tx as MaybeTransactional)\n\n const recordTime = new Date()\n const firstSeq = await store.allocate(inputs.length)\n\n return await store.insert(\n inputs.map((input, index) => ({\n seq: firstSeq + index,\n eventId: input.eventId ?? `urn:uuid:${randomUUID()}`,\n eventTime: input.eventTime ?? recordTime,\n recordTime,\n record: input.record,\n ...(input.extra ? { extra: input.extra } : {})\n }))\n )\n}\n"]}