@things-factory/webhook 10.1.29 → 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,46 @@
|
|
|
1
|
+
import { type DeliveryOutcome } from './delivery.js';
|
|
2
|
+
import type { OutboxStore } from './store.js';
|
|
3
|
+
/**
|
|
4
|
+
* One delivery: take what is pending on a stream, offer it to the peer, and do to our rows what the
|
|
5
|
+
* peer's answer says.
|
|
6
|
+
*
|
|
7
|
+
* Moved from operato-plant's `mes-outbox-dispatcher.ts` (ADR-0078 decision 2 ②). The judgement was already
|
|
8
|
+
* outside this function — `interpretResponse`, `settlement`, `rowEffectOf` — so what is left is the order
|
|
9
|
+
* of things. Three changes on the way, and no others:
|
|
10
|
+
*
|
|
11
|
+
* - it reads and writes through an `OutboxStore` instead of an `EntityManager`;
|
|
12
|
+
* - the headers a receiver wants beside the contract's (`X-Mes-Site` and the like) come in as `headers`.
|
|
13
|
+
* They belong to the destination, not to the machine;
|
|
14
|
+
* - being asked to wait is written down (`store.noteHeld`), and a batch that goes in clears it.
|
|
15
|
+
*
|
|
16
|
+
* `fetch` and `say` are arguments so that this function can be run by a test. It could not be before: it
|
|
17
|
+
* needed an EntityManager and a receiver answering 503, and the branch that matters most — backpressure —
|
|
18
|
+
* carried a comment saying no test reached it.
|
|
19
|
+
*/
|
|
20
|
+
export interface DispatchConfig {
|
|
21
|
+
endpoint: string;
|
|
22
|
+
secret: string;
|
|
23
|
+
/** Extra request headers this receiver asks for. The contract's own headers are added here. */
|
|
24
|
+
headers?: Record<string, string>;
|
|
25
|
+
/** The name clock readings are kept under — one per peer, so two peers' clocks are not mixed. */
|
|
26
|
+
clockKey: string;
|
|
27
|
+
batchSize?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface DispatchReport {
|
|
30
|
+
outcome: DeliveryOutcome | {
|
|
31
|
+
kind: 'idle';
|
|
32
|
+
};
|
|
33
|
+
sent: number;
|
|
34
|
+
retryAfterMs?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface Say {
|
|
37
|
+
info(what: string): void;
|
|
38
|
+
warn(what: string): void;
|
|
39
|
+
error(what: string): void;
|
|
40
|
+
}
|
|
41
|
+
export interface DispatchDeps {
|
|
42
|
+
fetch: typeof fetch;
|
|
43
|
+
say: Say;
|
|
44
|
+
now?: () => Date;
|
|
45
|
+
}
|
|
46
|
+
export declare function dispatchPending(store: OutboxStore, config: DispatchConfig, deps: DispatchDeps): Promise<DispatchReport>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.dispatchPending = dispatchPending;
|
|
4
|
+
const webhook_1 = require("@operato/ops-contract/webhook");
|
|
5
|
+
const delivery_js_1 = require("./delivery.js");
|
|
6
|
+
const settlement_js_1 = require("./settlement.js");
|
|
7
|
+
const row_effect_js_1 = require("./row-effect.js");
|
|
8
|
+
const clock_offset_js_1 = require("../observability/clock-offset.js");
|
|
9
|
+
const trace_context_js_1 = require("../observability/trace-context.js");
|
|
10
|
+
const DEFAULT_BATCH_SIZE = 200;
|
|
11
|
+
async function dispatchPending(store, config, deps) {
|
|
12
|
+
const { say } = deps;
|
|
13
|
+
const now = deps.now ?? (() => new Date());
|
|
14
|
+
const take = config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
15
|
+
const pending = await store.pending(take);
|
|
16
|
+
if (!pending.length) {
|
|
17
|
+
return { outcome: { kind: 'idle' }, sent: 0 };
|
|
18
|
+
}
|
|
19
|
+
const ids = pending.map(row => row.id);
|
|
20
|
+
const body = JSON.stringify((0, delivery_js_1.buildBatch)(pending));
|
|
21
|
+
const timestamp = now().toISOString();
|
|
22
|
+
const trace = (0, trace_context_js_1.traceFor)(undefined);
|
|
23
|
+
let status;
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
const response = await deps.fetch(config.endpoint, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: {
|
|
29
|
+
...(config.headers ?? {}),
|
|
30
|
+
'Content-Type': 'application/json',
|
|
31
|
+
[webhook_1.WEBHOOK_HEADER.timestamp]: timestamp,
|
|
32
|
+
[webhook_1.WEBHOOK_HEADER.signature]: (0, delivery_js_1.sign)(config.secret, timestamp, body),
|
|
33
|
+
[trace_context_js_1.TRACEPARENT]: (0, trace_context_js_1.toTraceparent)(trace)
|
|
34
|
+
},
|
|
35
|
+
body
|
|
36
|
+
});
|
|
37
|
+
status = response.status;
|
|
38
|
+
parsed = await response.json().catch(() => ({}));
|
|
39
|
+
(0, clock_offset_js_1.noteClockReading)(config.clockKey, (0, clock_offset_js_1.offsetFrom)(response.headers.get('date') ?? undefined, Date.now()));
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
43
|
+
say.warn(`[webhook] outbox batch ${(0, trace_context_js_1.traceTag)(trace)} did not reach the receiver — ${reason}`);
|
|
44
|
+
await store.markAttempted(ids, reason);
|
|
45
|
+
return { outcome: { kind: 'retry', reason }, sent: 0 };
|
|
46
|
+
}
|
|
47
|
+
const outcome = (0, delivery_js_1.interpretResponse)(status, parsed);
|
|
48
|
+
switch (outcome.kind) {
|
|
49
|
+
case 'accepted':
|
|
50
|
+
case 'partial': {
|
|
51
|
+
const sent = await settle(store, pending, outcome.lastSeq, outcome.kind === 'partial' ? outcome.rejected : [], deps);
|
|
52
|
+
await store.noteTaken();
|
|
53
|
+
if (outcome.tally) {
|
|
54
|
+
const line = `[webhook] ${(0, delivery_js_1.describeTally)(outcome.tally)}`;
|
|
55
|
+
if (outcome.tally.balanced) {
|
|
56
|
+
say.info(line);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
say.warn(line);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
say.info('[webhook] the receiver did not send its counts — the offered total cannot be checked');
|
|
64
|
+
}
|
|
65
|
+
return { outcome, sent };
|
|
66
|
+
}
|
|
67
|
+
case 'gap': {
|
|
68
|
+
const sent = outcome.lastSeq === undefined ? 0 : await settle(store, pending, outcome.lastSeq, outcome.rejected, deps);
|
|
69
|
+
await store.rewindFrom(outcome.expectedSeq);
|
|
70
|
+
await store.noteTaken();
|
|
71
|
+
say.info(`[webhook] the receiver asked to resume from seq ${outcome.expectedSeq}`);
|
|
72
|
+
return { outcome, sent };
|
|
73
|
+
}
|
|
74
|
+
default: {
|
|
75
|
+
/* What an answer does to our rows is judged in one place. This switch does not judge again. */
|
|
76
|
+
const effect = (0, row_effect_js_1.rowEffectOf)(outcome, pending.length);
|
|
77
|
+
if (effect.rows === 'hold') {
|
|
78
|
+
await store.noteHeld(effect.retryAfterMs, now());
|
|
79
|
+
say[effect.say.level](effect.say.what);
|
|
80
|
+
return { outcome, sent: 0, retryAfterMs: effect.retryAfterMs };
|
|
81
|
+
}
|
|
82
|
+
if (effect.rows === 'fail') {
|
|
83
|
+
await store.markAttempted(ids, effect.reason);
|
|
84
|
+
await store.markFailed(ids);
|
|
85
|
+
say[effect.say.level](effect.say.what);
|
|
86
|
+
return { outcome, sent: 0 };
|
|
87
|
+
}
|
|
88
|
+
if (effect.rows === 'attempt') {
|
|
89
|
+
await store.markAttempted(ids, effect.reason);
|
|
90
|
+
if (effect.say) {
|
|
91
|
+
say[effect.say.level](effect.say.what);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { outcome, sent: 0 };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function settle(store, pending, lastSeq, rejectedIds, deps) {
|
|
99
|
+
const { sentIds, failedIds } = (0, settlement_js_1.settlement)(pending, lastSeq, rejectedIds);
|
|
100
|
+
if (sentIds.length) {
|
|
101
|
+
await store.markSent(sentIds, (deps.now ?? (() => new Date()))());
|
|
102
|
+
}
|
|
103
|
+
if (failedIds.length) {
|
|
104
|
+
await store.markRejected(failedIds, 'rejected by the receiver during validation');
|
|
105
|
+
deps.say.warn(`[webhook] the receiver rejected ${failedIds.length} record(s) — see the payload log on its side`);
|
|
106
|
+
}
|
|
107
|
+
return sentIds.length;
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=dispatcher.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dispatcher.js","sourceRoot":"","sources":["../../../server/service/outbox/dispatcher.ts"],"names":[],"mappings":"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","sourcesContent":["import { WEBHOOK_HEADER } from '@operato/ops-contract/webhook'\n\nimport { buildBatch, describeTally, interpretResponse, sign, type DeliveryOutcome } from './delivery.js'\nimport { settlement } from './settlement.js'\nimport { rowEffectOf } from './row-effect.js'\nimport type { OutboxStore, StoredOutboxRow } from './store.js'\nimport { noteClockReading, offsetFrom } from '../observability/clock-offset.js'\nimport { TRACEPARENT, toTraceparent, traceFor, traceTag } from '../observability/trace-context.js'\n\n/**\n * One delivery: take what is pending on a stream, offer it to the peer, and do to our rows what the\n * peer's answer says.\n *\n * Moved from operato-plant's `mes-outbox-dispatcher.ts` (ADR-0078 decision 2 ②). The judgement was already\n * outside this function — `interpretResponse`, `settlement`, `rowEffectOf` — so what is left is the order\n * of things. Three changes on the way, and no others:\n *\n * - it reads and writes through an `OutboxStore` instead of an `EntityManager`;\n * - the headers a receiver wants beside the contract's (`X-Mes-Site` and the like) come in as `headers`.\n * They belong to the destination, not to the machine;\n * - being asked to wait is written down (`store.noteHeld`), and a batch that goes in clears it.\n *\n * `fetch` and `say` are arguments so that this function can be run by a test. It could not be before: it\n * needed an EntityManager and a receiver answering 503, and the branch that matters most — backpressure —\n * carried a comment saying no test reached it.\n */\nexport interface DispatchConfig {\n endpoint: string\n secret: string\n /** Extra request headers this receiver asks for. The contract's own headers are added here. */\n headers?: Record<string, string>\n /** The name clock readings are kept under — one per peer, so two peers' clocks are not mixed. */\n clockKey: string\n batchSize?: number\n}\n\nexport interface DispatchReport {\n outcome: DeliveryOutcome | { kind: 'idle' }\n sent: number\n retryAfterMs?: number\n}\n\nexport interface Say {\n info(what: string): void\n warn(what: string): void\n error(what: string): void\n}\n\nexport interface DispatchDeps {\n fetch: typeof fetch\n say: Say\n now?: () => Date\n}\n\nconst DEFAULT_BATCH_SIZE = 200\n\nexport async function dispatchPending(store: OutboxStore, config: DispatchConfig, deps: DispatchDeps): Promise<DispatchReport> {\n const { say } = deps\n const now = deps.now ?? (() => new Date())\n const take = config.batchSize ?? DEFAULT_BATCH_SIZE\n\n const pending = await store.pending(take)\n\n if (!pending.length) {\n return { outcome: { kind: 'idle' }, sent: 0 }\n }\n\n const ids = pending.map(row => row.id)\n const body = JSON.stringify(buildBatch(pending))\n const timestamp = now().toISOString()\n const trace = traceFor(undefined)\n\n let status: number\n let parsed: unknown\n try {\n const response = await deps.fetch(config.endpoint, {\n method: 'POST',\n headers: {\n ...(config.headers ?? {}),\n 'Content-Type': 'application/json',\n [WEBHOOK_HEADER.timestamp]: timestamp,\n [WEBHOOK_HEADER.signature]: sign(config.secret, timestamp, body),\n [TRACEPARENT]: toTraceparent(trace)\n },\n body\n })\n status = response.status\n parsed = await response.json().catch(() => ({}))\n\n noteClockReading(config.clockKey, offsetFrom(response.headers.get('date') ?? undefined, Date.now()))\n } catch (error) {\n const reason = error instanceof Error ? error.message : String(error)\n say.warn(`[webhook] outbox batch ${traceTag(trace)} did not reach the receiver — ${reason}`)\n await store.markAttempted(ids, reason)\n return { outcome: { kind: 'retry', reason }, sent: 0 }\n }\n\n const outcome = interpretResponse(status, parsed)\n\n switch (outcome.kind) {\n case 'accepted':\n case 'partial': {\n const sent = await settle(store, pending, outcome.lastSeq, outcome.kind === 'partial' ? outcome.rejected : [], deps)\n await store.noteTaken()\n\n if (outcome.tally) {\n const line = `[webhook] ${describeTally(outcome.tally)}`\n\n if (outcome.tally.balanced) {\n say.info(line)\n } else {\n say.warn(line)\n }\n } else {\n say.info('[webhook] the receiver did not send its counts — the offered total cannot be checked')\n }\n\n return { outcome, sent }\n }\n\n case 'gap': {\n const sent = outcome.lastSeq === undefined ? 0 : await settle(store, pending, outcome.lastSeq, outcome.rejected, deps)\n\n await store.rewindFrom(outcome.expectedSeq)\n await store.noteTaken()\n say.info(`[webhook] the receiver asked to resume from seq ${outcome.expectedSeq}`)\n return { outcome, sent }\n }\n\n default: {\n /* What an answer does to our rows is judged in one place. This switch does not judge again. */\n const effect = rowEffectOf(outcome, pending.length)\n\n if (effect.rows === 'hold') {\n await store.noteHeld(effect.retryAfterMs, now())\n say[effect.say.level](effect.say.what)\n return { outcome, sent: 0, retryAfterMs: effect.retryAfterMs }\n }\n\n if (effect.rows === 'fail') {\n await store.markAttempted(ids, effect.reason)\n await store.markFailed(ids)\n say[effect.say.level](effect.say.what)\n return { outcome, sent: 0 }\n }\n\n if (effect.rows === 'attempt') {\n await store.markAttempted(ids, effect.reason)\n\n if (effect.say) {\n say[effect.say.level](effect.say.what)\n }\n }\n\n return { outcome, sent: 0 }\n }\n }\n}\n\nasync function settle(\n store: OutboxStore,\n pending: readonly StoredOutboxRow[],\n lastSeq: number,\n rejectedIds: readonly string[],\n deps: DispatchDeps\n): Promise<number> {\n const { sentIds, failedIds } = settlement(pending, lastSeq, rejectedIds)\n\n if (sentIds.length) {\n await store.markSent(sentIds, (deps.now ?? (() => new Date()))())\n }\n\n if (failedIds.length) {\n await store.markRejected(failedIds, 'rejected by the receiver during validation')\n deps.say.warn(`[webhook] the receiver rejected ${failedIds.length} record(s) — see the payload log on its side`)\n }\n\n return sentIds.length\n}\n"]}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 목록을 자를 때 **잘렸다는 사실을 함께 낸다.**
|
|
3
|
+
*
|
|
4
|
+
* 「500건이 왔다」와 「500건이 전부다」를 구별할 수 없으면 받는 쪽은 언제 멈춰야 할지 모른다. 정확히
|
|
5
|
+
* 상한만큼 남은 경우가 실제로 온다.
|
|
6
|
+
*
|
|
7
|
+
* 방법은 **한 건 더 읽는 것**이다. 상한보다 많이 읽히면 더 있는 것이고, 그 한 건은 버린다. 세는 질의를
|
|
8
|
+
* 따로 하지 않으므로 큰 표에서도 비용이 같다.
|
|
9
|
+
*/
|
|
10
|
+
export interface Page<T> {
|
|
11
|
+
items: T[];
|
|
12
|
+
hasMore: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* @param rows 상한보다 **한 건 더** 읽은 결과
|
|
16
|
+
* @param take 상한
|
|
17
|
+
*/
|
|
18
|
+
export declare function pageOf<T>(rows: T[], take: number): Page<T>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.pageOf = pageOf;
|
|
4
|
+
/**
|
|
5
|
+
* @param rows 상한보다 **한 건 더** 읽은 결과
|
|
6
|
+
* @param take 상한
|
|
7
|
+
*/
|
|
8
|
+
function pageOf(rows, take) {
|
|
9
|
+
if (take <= 0) {
|
|
10
|
+
throw new Error(`pageOf: take must be positive, got ${take}`);
|
|
11
|
+
}
|
|
12
|
+
return rows.length > take ? { items: rows.slice(0, take), hasMore: true } : { items: rows, hasMore: false };
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=page.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"page.js","sourceRoot":"","sources":["../../../server/service/outbox/page.ts"],"names":[],"mappings":";;AAkBA,wBAKC;AATD;;;GAGG;AACH,SAAgB,MAAM,CAAI,IAAS,EAAE,IAAY;IAC/C,IAAI,IAAI,IAAI,CAAC,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CAAC,sCAAsC,IAAI,EAAE,CAAC,CAAA;IAC/D,CAAC;IACD,OAAO,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,CAAA;AAC7G,CAAC","sourcesContent":["/**\n * 목록을 자를 때 **잘렸다는 사실을 함께 낸다.**\n *\n * 「500건이 왔다」와 「500건이 전부다」를 구별할 수 없으면 받는 쪽은 언제 멈춰야 할지 모른다. 정확히\n * 상한만큼 남은 경우가 실제로 온다.\n *\n * 방법은 **한 건 더 읽는 것**이다. 상한보다 많이 읽히면 더 있는 것이고, 그 한 건은 버린다. 세는 질의를\n * 따로 하지 않으므로 큰 표에서도 비용이 같다.\n */\nexport interface Page<T> {\n items: T[]\n hasMore: boolean\n}\n\n/**\n * @param rows 상한보다 **한 건 더** 읽은 결과\n * @param take 상한\n */\nexport function pageOf<T>(rows: T[], take: number): Page<T> {\n if (take <= 0) {\n throw new Error(`pageOf: take must be positive, got ${take}`)\n }\n return rows.length > take ? { items: rows.slice(0, take), hasMore: true } : { items: rows, hasMore: false }\n}\n"]}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **Can this installation send at all** — a pure judgement, no imports, no environment read here.
|
|
3
|
+
*
|
|
4
|
+
* ═════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* ## What used to guard this door, and why it moved
|
|
6
|
+
*
|
|
7
|
+
* The pump used to stand only if a destination was named in the configuration file. Destinations then
|
|
8
|
+
* moved to a table an administrator edits, and the door stayed where it was: a site could name its
|
|
9
|
+
* destination on the screen and **the pump still would not stand**. Rows piled up with nobody sending
|
|
10
|
+
* them, and nothing failed, so nobody knew.
|
|
11
|
+
*
|
|
12
|
+
* ## So the key is what declares a sender
|
|
13
|
+
*
|
|
14
|
+
* Destinations live in a table now, and the pump reads that table every round - a boot-time reading
|
|
15
|
+
* would not see a destination added later. What is left is the signing key. An installation with no
|
|
16
|
+
* key cannot deliver one record even with a destination: unsigned, the receiver refuses it. So the
|
|
17
|
+
* key is the declaration that this installation sends.
|
|
18
|
+
*
|
|
19
|
+
* ## The names belong to the caller, the shape belongs here
|
|
20
|
+
*
|
|
21
|
+
* This module never reads `process.env`, and it has **no default names**: a product that forgets to
|
|
22
|
+
* name its variables would otherwise sign with somebody else's key and nothing would say so. What the
|
|
23
|
+
* module does own is how a domain becomes a variable suffix - if each product wrote that rule itself,
|
|
24
|
+
* the same domain would look for different variables depending on which product asked.
|
|
25
|
+
*
|
|
26
|
+
* ```
|
|
27
|
+
* <common> one key for the installation - a single-site install
|
|
28
|
+
* <common>__MIRATEK_GUNSAN one key per domain - what a multi-tenant install should use
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* A domain's name is not known at boot, so this counts by prefix. Which domain gave the key is
|
|
32
|
+
* settled on that domain's turn, and named there.
|
|
33
|
+
*
|
|
34
|
+
* ⚠ This door answers "can we sign", not "is there anything to send". An installation with a key and
|
|
35
|
+
* no destination row still stands the pump - and says, once per domain with facts waiting, that it
|
|
36
|
+
* has nowhere to send them. That is better than not standing and saying nothing.
|
|
37
|
+
* ═════════════════════════════════════════════════════════════════════════
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* The two variable name stems a product declares. **Required** - see above on why there is no default.
|
|
41
|
+
*
|
|
42
|
+
* `domainPrefix` is the stem the per-domain names are built on; `domainSecretName` makes the whole
|
|
43
|
+
* name from it.
|
|
44
|
+
*/
|
|
45
|
+
export interface SecretNames {
|
|
46
|
+
common: string;
|
|
47
|
+
domainPrefix: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The variable a domain's own key lives in.
|
|
51
|
+
*
|
|
52
|
+
* Anything that is not a letter or a digit becomes `_`, because that is what an environment variable
|
|
53
|
+
* name may hold. Written once, here: a product that rounded this differently would look for
|
|
54
|
+
* `MIRATEK_GUNSAN` while another looked for `MIRATEK-GUNSAN`, for the same domain.
|
|
55
|
+
*/
|
|
56
|
+
export declare function domainSecretName(prefix: string, subdomain: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* Does this installation hold any key to sign with?
|
|
59
|
+
*
|
|
60
|
+
* The environment arrives as an argument. Reading the process here would put this judgement out of
|
|
61
|
+
* reach of a test, and this is the judgement that decides whether anything is delivered at all.
|
|
62
|
+
*/
|
|
63
|
+
export declare function outboxCanSign(env: Record<string, string | undefined>, names: SecretNames, declaredSecret?: string): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* This domain's key: its own first, then the installation's common one.
|
|
66
|
+
*
|
|
67
|
+
* **Absent is `undefined`, never an empty string.** An empty string signs, and a request signed with
|
|
68
|
+
* nothing cannot be told from a forgery. Falling back to the common key stops here; a caller with
|
|
69
|
+
* neither has to stop and name the domain it was working on.
|
|
70
|
+
*
|
|
71
|
+
* A multi-tenant installation should give per-domain keys and no common one. Then a domain nobody
|
|
72
|
+
* gave a key to is caught, rather than opening with somebody else's.
|
|
73
|
+
*/
|
|
74
|
+
export declare function outboxSecretFor(env: Record<string, string | undefined>, names: SecretNames, subdomain: string, declaredSecret?: string): string | undefined;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* **Can this installation send at all** — a pure judgement, no imports, no environment read here.
|
|
4
|
+
*
|
|
5
|
+
* ═════════════════════════════════════════════════════════════════════════
|
|
6
|
+
* ## What used to guard this door, and why it moved
|
|
7
|
+
*
|
|
8
|
+
* The pump used to stand only if a destination was named in the configuration file. Destinations then
|
|
9
|
+
* moved to a table an administrator edits, and the door stayed where it was: a site could name its
|
|
10
|
+
* destination on the screen and **the pump still would not stand**. Rows piled up with nobody sending
|
|
11
|
+
* them, and nothing failed, so nobody knew.
|
|
12
|
+
*
|
|
13
|
+
* ## So the key is what declares a sender
|
|
14
|
+
*
|
|
15
|
+
* Destinations live in a table now, and the pump reads that table every round - a boot-time reading
|
|
16
|
+
* would not see a destination added later. What is left is the signing key. An installation with no
|
|
17
|
+
* key cannot deliver one record even with a destination: unsigned, the receiver refuses it. So the
|
|
18
|
+
* key is the declaration that this installation sends.
|
|
19
|
+
*
|
|
20
|
+
* ## The names belong to the caller, the shape belongs here
|
|
21
|
+
*
|
|
22
|
+
* This module never reads `process.env`, and it has **no default names**: a product that forgets to
|
|
23
|
+
* name its variables would otherwise sign with somebody else's key and nothing would say so. What the
|
|
24
|
+
* module does own is how a domain becomes a variable suffix - if each product wrote that rule itself,
|
|
25
|
+
* the same domain would look for different variables depending on which product asked.
|
|
26
|
+
*
|
|
27
|
+
* ```
|
|
28
|
+
* <common> one key for the installation - a single-site install
|
|
29
|
+
* <common>__MIRATEK_GUNSAN one key per domain - what a multi-tenant install should use
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* A domain's name is not known at boot, so this counts by prefix. Which domain gave the key is
|
|
33
|
+
* settled on that domain's turn, and named there.
|
|
34
|
+
*
|
|
35
|
+
* ⚠ This door answers "can we sign", not "is there anything to send". An installation with a key and
|
|
36
|
+
* no destination row still stands the pump - and says, once per domain with facts waiting, that it
|
|
37
|
+
* has nowhere to send them. That is better than not standing and saying nothing.
|
|
38
|
+
* ═════════════════════════════════════════════════════════════════════════
|
|
39
|
+
*/
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.domainSecretName = domainSecretName;
|
|
42
|
+
exports.outboxCanSign = outboxCanSign;
|
|
43
|
+
exports.outboxSecretFor = outboxSecretFor;
|
|
44
|
+
/**
|
|
45
|
+
* The variable a domain's own key lives in.
|
|
46
|
+
*
|
|
47
|
+
* Anything that is not a letter or a digit becomes `_`, because that is what an environment variable
|
|
48
|
+
* name may hold. Written once, here: a product that rounded this differently would look for
|
|
49
|
+
* `MIRATEK_GUNSAN` while another looked for `MIRATEK-GUNSAN`, for the same domain.
|
|
50
|
+
*/
|
|
51
|
+
function domainSecretName(prefix, subdomain) {
|
|
52
|
+
return `${prefix}${subdomain.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
|
|
53
|
+
}
|
|
54
|
+
/** A value that is only whitespace is no value: signing with it produces a signature that stands. */
|
|
55
|
+
function filled(value) {
|
|
56
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Does this installation hold any key to sign with?
|
|
60
|
+
*
|
|
61
|
+
* The environment arrives as an argument. Reading the process here would put this judgement out of
|
|
62
|
+
* reach of a test, and this is the judgement that decides whether anything is delivered at all.
|
|
63
|
+
*/
|
|
64
|
+
function outboxCanSign(env, names, declaredSecret) {
|
|
65
|
+
if (filled(env[names.common]) || filled(declaredSecret)) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
return Object.keys(env).some(name => name.startsWith(names.domainPrefix) && filled(env[name]));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* This domain's key: its own first, then the installation's common one.
|
|
72
|
+
*
|
|
73
|
+
* **Absent is `undefined`, never an empty string.** An empty string signs, and a request signed with
|
|
74
|
+
* nothing cannot be told from a forgery. Falling back to the common key stops here; a caller with
|
|
75
|
+
* neither has to stop and name the domain it was working on.
|
|
76
|
+
*
|
|
77
|
+
* A multi-tenant installation should give per-domain keys and no common one. Then a domain nobody
|
|
78
|
+
* gave a key to is caught, rather than opening with somebody else's.
|
|
79
|
+
*/
|
|
80
|
+
function outboxSecretFor(env, names, subdomain, declaredSecret) {
|
|
81
|
+
const mine = env[domainSecretName(names.domainPrefix, subdomain)];
|
|
82
|
+
if (filled(mine)) {
|
|
83
|
+
return mine;
|
|
84
|
+
}
|
|
85
|
+
if (filled(env[names.common])) {
|
|
86
|
+
return env[names.common];
|
|
87
|
+
}
|
|
88
|
+
return filled(declaredSecret) ? declaredSecret : undefined;
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=pump-gate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pump-gate.js","sourceRoot":"","sources":["../../../server/service/outbox/pump-gate.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;;AAoBH,4CAEC;AAaD,sCAUC;AAYD,0CAiBC;AA7DD;;;;;;GAMG;AACH,SAAgB,gBAAgB,CAAC,MAAc,EAAE,SAAiB;IAChE,OAAO,GAAG,MAAM,GAAG,SAAS,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,aAAa,EAAE,GAAG,CAAC,EAAE,CAAA;AAC1E,CAAC;AAED,qGAAqG;AACrG,SAAS,MAAM,CAAC,KAAc;IAC5B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAA;AAC7D,CAAC;AAED;;;;;GAKG;AACH,SAAgB,aAAa,CAC3B,GAAuC,EACvC,KAAkB,EAClB,cAAuB;IAEvB,IAAI,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,MAAM,CAAC,cAAc,CAAC,EAAE,CAAC;QACxD,OAAO,IAAI,CAAA;IACb,CAAC;IAED,OAAO,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AAChG,CAAC;AAED;;;;;;;;;GASG;AACH,SAAgB,eAAe,CAC7B,GAAuC,EACvC,KAAkB,EAClB,SAAiB,EACjB,cAAuB;IAEvB,MAAM,IAAI,GAAG,GAAG,CAAC,gBAAgB,CAAC,KAAK,CAAC,YAAY,EAAE,SAAS,CAAC,CAAC,CAAA;IAEjE,IAAI,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC;QACjB,OAAO,IAAI,CAAA;IACb,CAAC;IAED,IAAI,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC;QAC9B,OAAO,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;IAC1B,CAAC;IAED,OAAO,MAAM,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,SAAS,CAAA;AAC5D,CAAC","sourcesContent":["/**\n * **Can this installation send at all** — a pure judgement, no imports, no environment read here.\n *\n * ═════════════════════════════════════════════════════════════════════════\n * ## What used to guard this door, and why it moved\n *\n * The pump used to stand only if a destination was named in the configuration file. Destinations then\n * moved to a table an administrator edits, and the door stayed where it was: a site could name its\n * destination on the screen and **the pump still would not stand**. Rows piled up with nobody sending\n * them, and nothing failed, so nobody knew.\n *\n * ## So the key is what declares a sender\n *\n * Destinations live in a table now, and the pump reads that table every round - a boot-time reading\n * would not see a destination added later. What is left is the signing key. An installation with no\n * key cannot deliver one record even with a destination: unsigned, the receiver refuses it. So the\n * key is the declaration that this installation sends.\n *\n * ## The names belong to the caller, the shape belongs here\n *\n * This module never reads `process.env`, and it has **no default names**: a product that forgets to\n * name its variables would otherwise sign with somebody else's key and nothing would say so. What the\n * module does own is how a domain becomes a variable suffix - if each product wrote that rule itself,\n * the same domain would look for different variables depending on which product asked.\n *\n * ```\n * <common> one key for the installation - a single-site install\n * <common>__MIRATEK_GUNSAN one key per domain - what a multi-tenant install should use\n * ```\n *\n * A domain's name is not known at boot, so this counts by prefix. Which domain gave the key is\n * settled on that domain's turn, and named there.\n *\n * ⚠ This door answers \"can we sign\", not \"is there anything to send\". An installation with a key and\n * no destination row still stands the pump - and says, once per domain with facts waiting, that it\n * has nowhere to send them. That is better than not standing and saying nothing.\n * ═════════════════════════════════════════════════════════════════════════\n */\n\n/**\n * The two variable name stems a product declares. **Required** - see above on why there is no default.\n *\n * `domainPrefix` is the stem the per-domain names are built on; `domainSecretName` makes the whole\n * name from it.\n */\nexport interface SecretNames {\n common: string\n domainPrefix: string\n}\n\n/**\n * The variable a domain's own key lives in.\n *\n * Anything that is not a letter or a digit becomes `_`, because that is what an environment variable\n * name may hold. Written once, here: a product that rounded this differently would look for\n * `MIRATEK_GUNSAN` while another looked for `MIRATEK-GUNSAN`, for the same domain.\n */\nexport function domainSecretName(prefix: string, subdomain: string): string {\n return `${prefix}${subdomain.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`\n}\n\n/** A value that is only whitespace is no value: signing with it produces a signature that stands. */\nfunction filled(value: unknown): boolean {\n return typeof value === 'string' && value.trim().length > 0\n}\n\n/**\n * Does this installation hold any key to sign with?\n *\n * The environment arrives as an argument. Reading the process here would put this judgement out of\n * reach of a test, and this is the judgement that decides whether anything is delivered at all.\n */\nexport function outboxCanSign(\n env: Record<string, string | undefined>,\n names: SecretNames,\n declaredSecret?: string\n): boolean {\n if (filled(env[names.common]) || filled(declaredSecret)) {\n return true\n }\n\n return Object.keys(env).some(name => name.startsWith(names.domainPrefix) && filled(env[name]))\n}\n\n/**\n * This domain's key: its own first, then the installation's common one.\n *\n * **Absent is `undefined`, never an empty string.** An empty string signs, and a request signed with\n * nothing cannot be told from a forgery. Falling back to the common key stops here; a caller with\n * neither has to stop and name the domain it was working on.\n *\n * A multi-tenant installation should give per-domain keys and no common one. Then a domain nobody\n * gave a key to is caught, rather than opening with somebody else's.\n */\nexport function outboxSecretFor(\n env: Record<string, string | undefined>,\n names: SecretNames,\n subdomain: string,\n declaredSecret?: string\n): string | undefined {\n const mine = env[domainSecretName(names.domainPrefix, subdomain)]\n\n if (filled(mine)) {\n return mine\n }\n\n if (filled(env[names.common])) {\n return env[names.common]\n }\n\n return filled(declaredSecret) ? declaredSecret : undefined\n}\n"]}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **보낸 수와 앉은 수를 맞춰 본다** — 순수 판단.
|
|
3
|
+
*
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* ── 오늘 이것이 없어서 무슨 일이 났나 ──────────────────────────────────────
|
|
6
|
+
* 2026-09-05 하루에 세 번, 사실이 안 갔는데 **양쪽 다 초록**이었다.
|
|
7
|
+
*
|
|
8
|
+
* ```
|
|
9
|
+
* 우리 쪽 SENT 84건 보냈다
|
|
10
|
+
* 받는 쪽 저널 17건 앉았다
|
|
11
|
+
* 그 차이 아무 데도 없다
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* 세 번 다 사람이 손으로 세서 찾았다. 담김이 보류로 남은 것, 재고 둘이 수량 0 으로 거절된 것, 어휘가
|
|
15
|
+
* 안 나간 것 — **다음엔 아무도 안 센다.**
|
|
16
|
+
*
|
|
17
|
+
* ── `SENT` 는 「보냈다」이지 「앉았다」가 아니다 ────────────────────────────
|
|
18
|
+
* 받는 쪽이 `accepted` 로 답하는 것은 **접수했다**는 뜻이다. 그 뒤에 되풀이로 합쳐지거나, 리듀서가
|
|
19
|
+
* 못 읽거나, 표준 밖 값이라 버려질 수 있다. 그 셋 다 우리에게는 `SENT` 로 보인다.
|
|
20
|
+
*
|
|
21
|
+
* ── **넷으로 가른다. 둘로는 안 닫힌다** ────────────────────────────────────
|
|
22
|
+
* 처음에는 「앉았다 · 거절됐다」 둘로 뺐다. 그러면 이렇게 된다.
|
|
23
|
+
*
|
|
24
|
+
* ```
|
|
25
|
+
* 둘로 157 − 46 − 2 = 109 「사라졌다」 ← 진실은 되풀이 108 + 어휘 1
|
|
26
|
+
* 넷으로 157 − 46 − 2 − 108 − 1 = 0
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* **109를 「사라졌다」로 내면 그날부터 아무도 그 수를 안 본다.** 늘 큰 수가 떠 있으면 진짜로 하나
|
|
30
|
+
* 사라진 날을 못 본다 — 경보를 울려 놓고 아무도 안 듣게 만드는 것이 안 울리는 것보다 나쁘다.
|
|
31
|
+
*
|
|
32
|
+
* 인티그레이션이 실제 수로 짚었다(2026-09-05). 108은 대부분 **되풀이**였고, 그것은 우리가 같은
|
|
33
|
+
* 실적을 다시 보낸 것을 받는 쪽이 옳게 합친 결과다 — 사라진 것이 아니다.
|
|
34
|
+
*
|
|
35
|
+
* ```
|
|
36
|
+
* landed 저널에 앉았다
|
|
37
|
+
* rejected 받는 쪽이 안 받는다고 말했다 우리가 안다
|
|
38
|
+
* duplicates 이미 있는 사실이라 합쳤다 우리가 다시 보낸 것이다
|
|
39
|
+
* vocabulary 저널에 안 가는 어휘다 설계대로다
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* ── 「모른다」를 「맞다」로 읽지 않는다 ─────────────────────────────────────
|
|
43
|
+
* **이 모듈의 요점이다.** 받는 쪽 수를 못 물어보면 답은 「맞다」가 아니라 **「모른다」**다. 0 으로
|
|
44
|
+
* 채우거나 우리 수를 그대로 두 번 쓰면, 맞춰 보는 자리를 만들어 놓고 **늘 초록인 자리**를 만든 것이다.
|
|
45
|
+
*
|
|
46
|
+
* 오늘 하루 종일 잡은 그 부류를, 그것을 잡으려고 만든 자리에서 다시 만들지 않는다.
|
|
47
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
48
|
+
*/
|
|
49
|
+
export type ReconcileVerdict =
|
|
50
|
+
/** 두 수가 같다. */
|
|
51
|
+
'matched'
|
|
52
|
+
/** 보낸 것보다 앉은 것이 적다 — **사실이 사라졌다.** */
|
|
53
|
+
| 'short'
|
|
54
|
+
/**
|
|
55
|
+
* 앉은 것이 더 많다 — **원인이 둘이고, 지금 이 셈은 둘을 못 가른다.**
|
|
56
|
+
*
|
|
57
|
+
* ```
|
|
58
|
+
* ① 같은 사실이 두 번 앉았다 받는 쪽 dedupe 가 놓친 재전송
|
|
59
|
+
* ② 받는 쪽이 스스로 넣은 행이다 우리 봉투가 없는 행. period fact backfill 이 그렇게 넣는다
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* ②는 셈이 정상인데 음수가 나는 경우다 — 그 구간을 맞춰 보면 그만큼 많이 나오는 것이 맞다.
|
|
63
|
+
* 그래서 이 판정을 「정상일 수도 있다」로 읽으면 ①도 ②도 안 찾게 된다.
|
|
64
|
+
*
|
|
65
|
+
* 가르려면 저널 행에 「어떻게 들어왔나」가 있어야 하는데 없다(인티그레이션 실측 2026-09-10 —
|
|
66
|
+
* ingest 함수 열이 record 하나에 event 하나만 내고, 저널을 쓰는 문 둘이 다 `.map()` 이라
|
|
67
|
+
* **봉투 하나가 사실 여럿을 내는 경로는 없다**).
|
|
68
|
+
*/
|
|
69
|
+
| 'excess'
|
|
70
|
+
/** 받는 쪽 수를 모른다. **맞다가 아니다.** */
|
|
71
|
+
| 'unknown';
|
|
72
|
+
export interface ReconcileInput {
|
|
73
|
+
/** 우리가 보냈다고 표시한 수. */
|
|
74
|
+
sent: number;
|
|
75
|
+
/**
|
|
76
|
+
* 받는 쪽에 앉은 수. **못 물어봤으면 `undefined`** — 0 이 아니다.
|
|
77
|
+
*
|
|
78
|
+
* 0 은 「하나도 안 앉았다」이고 그것은 심각한 사실이다. 모르는 것과 섞으면 진짜 0 이 묻힌다.
|
|
79
|
+
*/
|
|
80
|
+
landed?: number;
|
|
81
|
+
/** 받는 쪽이 거절한다고 답한 수 — 이건 우리가 안다. 사라진 것이 아니라 **거절된 것**이다. */
|
|
82
|
+
rejected?: number;
|
|
83
|
+
/**
|
|
84
|
+
* 이미 있는 사실이라 합쳐진 수. **우리가 다시 보낸 것**이지 사라진 것이 아니다.
|
|
85
|
+
*
|
|
86
|
+
* 이 값을 안 빼면 되풀이가 통째로 「사라졌다」로 세어진다 — 오늘 108건이 그랬다.
|
|
87
|
+
*/
|
|
88
|
+
duplicates?: number;
|
|
89
|
+
/**
|
|
90
|
+
* 저널에 안 가는 어휘(`MasterDataRecord`)의 수. **설계대로 안 가는 것**이다.
|
|
91
|
+
*
|
|
92
|
+
* 이건 우리가 안다 — 우리가 낸 봉투이므로 세어 둘 수 있다. 받는 쪽에 묻지 않아도 된다.
|
|
93
|
+
*/
|
|
94
|
+
vocabulary?: number;
|
|
95
|
+
}
|
|
96
|
+
export interface Reconciliation {
|
|
97
|
+
verdict: ReconcileVerdict;
|
|
98
|
+
/** 설명되지 않는 차이. 모르면 `undefined`. */
|
|
99
|
+
unexplained?: number;
|
|
100
|
+
sent: number;
|
|
101
|
+
landed?: number;
|
|
102
|
+
rejected: number;
|
|
103
|
+
duplicates: number;
|
|
104
|
+
vocabulary: number;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 두 수를 맞춘다.
|
|
108
|
+
*
|
|
109
|
+
* **거절된 것은 사라진 것이 아니다.** 받는 쪽이 「안 받는다」고 말했고 우리는 그것을 안다. 그것까지
|
|
110
|
+
* 「사라졌다」로 세면 진짜 조용히 없어진 것이 그 수에 묻힌다.
|
|
111
|
+
*/
|
|
112
|
+
export declare function reconcile(input: ReconcileInput): Reconciliation;
|
|
113
|
+
/** 사람이 읽는 한 줄 — 표에 그대로 걸 수 있게. */
|
|
114
|
+
export declare function describeReconciliation(found: Reconciliation, where: string): string;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* **보낸 수와 앉은 수를 맞춰 본다** — 순수 판단.
|
|
4
|
+
*
|
|
5
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
6
|
+
* ── 오늘 이것이 없어서 무슨 일이 났나 ──────────────────────────────────────
|
|
7
|
+
* 2026-09-05 하루에 세 번, 사실이 안 갔는데 **양쪽 다 초록**이었다.
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* 우리 쪽 SENT 84건 보냈다
|
|
11
|
+
* 받는 쪽 저널 17건 앉았다
|
|
12
|
+
* 그 차이 아무 데도 없다
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* 세 번 다 사람이 손으로 세서 찾았다. 담김이 보류로 남은 것, 재고 둘이 수량 0 으로 거절된 것, 어휘가
|
|
16
|
+
* 안 나간 것 — **다음엔 아무도 안 센다.**
|
|
17
|
+
*
|
|
18
|
+
* ── `SENT` 는 「보냈다」이지 「앉았다」가 아니다 ────────────────────────────
|
|
19
|
+
* 받는 쪽이 `accepted` 로 답하는 것은 **접수했다**는 뜻이다. 그 뒤에 되풀이로 합쳐지거나, 리듀서가
|
|
20
|
+
* 못 읽거나, 표준 밖 값이라 버려질 수 있다. 그 셋 다 우리에게는 `SENT` 로 보인다.
|
|
21
|
+
*
|
|
22
|
+
* ── **넷으로 가른다. 둘로는 안 닫힌다** ────────────────────────────────────
|
|
23
|
+
* 처음에는 「앉았다 · 거절됐다」 둘로 뺐다. 그러면 이렇게 된다.
|
|
24
|
+
*
|
|
25
|
+
* ```
|
|
26
|
+
* 둘로 157 − 46 − 2 = 109 「사라졌다」 ← 진실은 되풀이 108 + 어휘 1
|
|
27
|
+
* 넷으로 157 − 46 − 2 − 108 − 1 = 0
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* **109를 「사라졌다」로 내면 그날부터 아무도 그 수를 안 본다.** 늘 큰 수가 떠 있으면 진짜로 하나
|
|
31
|
+
* 사라진 날을 못 본다 — 경보를 울려 놓고 아무도 안 듣게 만드는 것이 안 울리는 것보다 나쁘다.
|
|
32
|
+
*
|
|
33
|
+
* 인티그레이션이 실제 수로 짚었다(2026-09-05). 108은 대부분 **되풀이**였고, 그것은 우리가 같은
|
|
34
|
+
* 실적을 다시 보낸 것을 받는 쪽이 옳게 합친 결과다 — 사라진 것이 아니다.
|
|
35
|
+
*
|
|
36
|
+
* ```
|
|
37
|
+
* landed 저널에 앉았다
|
|
38
|
+
* rejected 받는 쪽이 안 받는다고 말했다 우리가 안다
|
|
39
|
+
* duplicates 이미 있는 사실이라 합쳤다 우리가 다시 보낸 것이다
|
|
40
|
+
* vocabulary 저널에 안 가는 어휘다 설계대로다
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* ── 「모른다」를 「맞다」로 읽지 않는다 ─────────────────────────────────────
|
|
44
|
+
* **이 모듈의 요점이다.** 받는 쪽 수를 못 물어보면 답은 「맞다」가 아니라 **「모른다」**다. 0 으로
|
|
45
|
+
* 채우거나 우리 수를 그대로 두 번 쓰면, 맞춰 보는 자리를 만들어 놓고 **늘 초록인 자리**를 만든 것이다.
|
|
46
|
+
*
|
|
47
|
+
* 오늘 하루 종일 잡은 그 부류를, 그것을 잡으려고 만든 자리에서 다시 만들지 않는다.
|
|
48
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
49
|
+
*/
|
|
50
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
51
|
+
exports.reconcile = reconcile;
|
|
52
|
+
exports.describeReconciliation = describeReconciliation;
|
|
53
|
+
/**
|
|
54
|
+
* 두 수를 맞춘다.
|
|
55
|
+
*
|
|
56
|
+
* **거절된 것은 사라진 것이 아니다.** 받는 쪽이 「안 받는다」고 말했고 우리는 그것을 안다. 그것까지
|
|
57
|
+
* 「사라졌다」로 세면 진짜 조용히 없어진 것이 그 수에 묻힌다.
|
|
58
|
+
*/
|
|
59
|
+
function reconcile(input) {
|
|
60
|
+
const rejected = Math.max(0, input.rejected ?? 0);
|
|
61
|
+
const duplicates = Math.max(0, input.duplicates ?? 0);
|
|
62
|
+
const vocabulary = Math.max(0, input.vocabulary ?? 0);
|
|
63
|
+
const sent = Math.max(0, input.sent);
|
|
64
|
+
if (!Number.isFinite(input.landed)) {
|
|
65
|
+
/* 못 물어봤다. **맞다고 하지 않는다.** */
|
|
66
|
+
return { verdict: 'unknown', sent, rejected, duplicates, vocabulary };
|
|
67
|
+
}
|
|
68
|
+
const landed = Math.max(0, input.landed);
|
|
69
|
+
/*
|
|
70
|
+
* 앉아야 할 수 = 보낸 것 − (거절 + 되풀이 + 어휘).
|
|
71
|
+
*
|
|
72
|
+
* 셋 다 **우리가 아는 이유**다. 빼지 않으면 아는 것까지 「사라졌다」로 세어지고, 그러면 그 수를
|
|
73
|
+
* 아무도 안 보게 된다(머리말).
|
|
74
|
+
*/
|
|
75
|
+
const expected = Math.max(0, sent - rejected - duplicates - vocabulary);
|
|
76
|
+
const unexplained = expected - landed;
|
|
77
|
+
if (unexplained === 0) {
|
|
78
|
+
return { verdict: 'matched', unexplained: 0, sent, landed, rejected, duplicates, vocabulary };
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
verdict: unexplained > 0 ? 'short' : 'excess',
|
|
82
|
+
unexplained,
|
|
83
|
+
sent,
|
|
84
|
+
landed,
|
|
85
|
+
rejected,
|
|
86
|
+
duplicates,
|
|
87
|
+
vocabulary
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** 사람이 읽는 한 줄 — 표에 그대로 걸 수 있게. */
|
|
91
|
+
function describeReconciliation(found, where) {
|
|
92
|
+
switch (found.verdict) {
|
|
93
|
+
case 'unknown':
|
|
94
|
+
return (`${where}: 보낸 ${found.sent}건. 받는 쪽에 몇 건이 앉았는지 **묻지 못했습니다** — ` +
|
|
95
|
+
'맞는지 안 맞는지 알 수 없습니다');
|
|
96
|
+
case 'matched':
|
|
97
|
+
return `${where}: 보낸 ${found.sent}건이 모두 설명됩니다 — 앉음 ${found.landed}${aside(found)}`;
|
|
98
|
+
case 'short':
|
|
99
|
+
return (`${where}: **${found.unexplained}건이 사라졌습니다** — 보낸 ${found.sent}건 중 앉은 것이 ` +
|
|
100
|
+
`${found.landed}건입니다${aside(found)}. 거절도 되풀이도 어휘도 아닌데 안 앉았습니다`);
|
|
101
|
+
case 'excess':
|
|
102
|
+
return (`${where}: 앉은 것이 ${-found.unexplained}건 많습니다 — 보낸 ${found.sent}건, 앉은 ${found.landed}건. ` +
|
|
103
|
+
'같은 사실이 두 번 앉았거나, 받는 쪽이 우리 봉투 없이 스스로 넣은 행입니다');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** 아는 이유들 — 사라진 수와 섞이지 않게 괄호로 따로 적는다. */
|
|
107
|
+
function aside(found) {
|
|
108
|
+
const known = [
|
|
109
|
+
found.rejected ? `거절 ${found.rejected}` : '',
|
|
110
|
+
found.duplicates ? `되풀이 ${found.duplicates}` : '',
|
|
111
|
+
found.vocabulary ? `어휘 ${found.vocabulary}` : ''
|
|
112
|
+
].filter(Boolean);
|
|
113
|
+
return known.length ? ` (${known.join(' · ')} 은 뺀 수입니다)` : '';
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=reconciliation.js.map
|