@things-factory/webhook 10.1.38 → 10.1.39

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.
@@ -6,7 +6,7 @@ export * from './webhook-destination/webhook-destination';
6
6
  export * from './webhook-destination/webhook-destination-resolver';
7
7
  export * from './webhook-outbox-event/webhook-outbox-event';
8
8
  export * from './webhook-sequence/webhook-sequence';
9
- export declare const entities: (typeof WebhookDestination | typeof WebhookOutboxEvent | typeof WebhookSequence)[];
9
+ export declare const entities: (typeof WebhookOutboxEvent | typeof WebhookDestination | typeof WebhookSequence)[];
10
10
  export declare const schema: {
11
11
  resolverClasses: (typeof WebhookDestinationResolver)[];
12
12
  };
@@ -20,10 +20,12 @@ export * from './outbox/requeue-note';
20
20
  export * from './outbox/page';
21
21
  export * from './outbox/writing-transaction';
22
22
  export * from './outbox/append';
23
- export * from './outbox/pump-gate';
24
23
  export * from './observability/clock-offset';
25
24
  export * from './observability/trace-context';
26
25
  export * from './outbox/store';
27
26
  export * from './outbox/dispatcher';
28
27
  export * from './outbox/webhook-store';
29
28
  export * from './outbox/webhook-append-store';
29
+ export * from './outbox/pump-round';
30
+ export * from './outbox/pump';
31
+ export * from './outbox/pump-loop';
@@ -46,7 +46,6 @@ tslib_1.__exportStar(require("./outbox/writing-transaction"), exports);
46
46
  * in a check - because the number and the row have to commit together or the number burns.
47
47
  */
48
48
  tslib_1.__exportStar(require("./outbox/append"), exports);
49
- tslib_1.__exportStar(require("./outbox/pump-gate"), exports);
50
49
  /* What a delivery needs to say "when" and "which request" — moved from operato-plant with the dispatcher's
51
50
  * needs in mind (ADR-0078 decision 1). The inbound side reads the same two. */
52
51
  tslib_1.__exportStar(require("./observability/clock-offset"), exports);
@@ -56,4 +55,12 @@ tslib_1.__exportStar(require("./outbox/store"), exports);
56
55
  tslib_1.__exportStar(require("./outbox/dispatcher"), exports);
57
56
  tslib_1.__exportStar(require("./outbox/webhook-store"), exports);
58
57
  tslib_1.__exportStar(require("./outbox/webhook-append-store"), exports);
58
+ /*
59
+ * The pump: one round's judgement (`pump-round`), one round (`pump`), and the loop an application starts from its
60
+ * boot hook (`pump-loop`). Keys come from the contract's ladder - the product-named `pump-gate` is gone
61
+ * (ADR-0078 decision 5, re-ruled 2026-09-24).
62
+ */
63
+ tslib_1.__exportStar(require("./outbox/pump-round"), exports);
64
+ tslib_1.__exportStar(require("./outbox/pump"), exports);
65
+ tslib_1.__exportStar(require("./outbox/pump-loop"), exports);
59
66
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/service/index.ts"],"names":[],"mappings":";;;;AAAA;;;;;;;;GAQG;AACH,mFAA8E;AAC9E,qGAA+F;AAC/F,sFAAgF;AAChF,0EAAqE;AAErE,oFAAyD;AACzD,6FAAkE;AAClE,sFAA2D;AAC3D,8EAAmD;AAEnD;;;;;GAKG;AACU,QAAA,QAAQ,GAAG,CAAC,wCAAkB,EAAE,yCAAkB,EAAE,kCAAe,CAAC,CAAA;AAEpE,QAAA,MAAM,GAAG,EAAE,eAAe,EAAE,CAAC,yDAA0B,CAAC,EAAE,CAAA;AAEvE;;;;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;AACtC,wEAA6C","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 */\nimport { WebhookDestination } from './webhook-destination/webhook-destination'\nimport { WebhookDestinationResolver } from './webhook-destination/webhook-destination-resolver'\nimport { WebhookOutboxEvent } from './webhook-outbox-event/webhook-outbox-event'\nimport { WebhookSequence } from './webhook-sequence/webhook-sequence'\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 shell builds the database and the schema from these two names.** Without them the module's\n * tables are never created in any application and the destination door is in no schema - the files stand\n * and nothing reaches them. That was the state through 10.1.37 (found 2026-09-24 by the first application to\n * append: its database had no webhook table at all). `test/append-store-db.test.ts` holds this line.\n */\nexport const entities = [WebhookDestination, WebhookOutboxEvent, WebhookSequence]\n\nexport const schema = { resolverClasses: [WebhookDestinationResolver] }\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'\nexport * from './outbox/webhook-append-store'\n\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/service/index.ts"],"names":[],"mappings":";;;;AAAA;;;;;;;;GAQG;AACH,mFAA8E;AAC9E,qGAA+F;AAC/F,sFAAgF;AAChF,0EAAqE;AAErE,oFAAyD;AACzD,6FAAkE;AAClE,sFAA2D;AAC3D,8EAAmD;AAEnD;;;;;GAKG;AACU,QAAA,QAAQ,GAAG,CAAC,wCAAkB,EAAE,yCAAkB,EAAE,kCAAe,CAAC,CAAA;AAEpE,QAAA,MAAM,GAAG,EAAE,eAAe,EAAE,CAAC,yDAA0B,CAAC,EAAE,CAAA;AAEvE;;;;GAIG;AACH,4DAAiC;AACjC,8DAAmC;AACnC,8DAAmC;AACnC,8DAAmC;AACnC,kEAAuC;AACvC,4DAAiC;AACjC,gEAAqC;AACrC,wDAA6B;AAC7B,uEAA4C;AAE5C;;;GAGG;AACH,0DAA+B;AAE/B;+EAC+E;AAC/E,uEAA4C;AAC5C,wEAA6C;AAE7C,6GAA6G;AAC7G,yDAA8B;AAC9B,8DAAmC;AACnC,iEAAsC;AACtC,wEAA6C;AAE7C;;;;GAIG;AACH,8DAAmC;AACnC,wDAA6B;AAC7B,6DAAkC","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 */\nimport { WebhookDestination } from './webhook-destination/webhook-destination'\nimport { WebhookDestinationResolver } from './webhook-destination/webhook-destination-resolver'\nimport { WebhookOutboxEvent } from './webhook-outbox-event/webhook-outbox-event'\nimport { WebhookSequence } from './webhook-sequence/webhook-sequence'\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 shell builds the database and the schema from these two names.** Without them the module's\n * tables are never created in any application and the destination door is in no schema - the files stand\n * and nothing reaches them. That was the state through 10.1.37 (found 2026-09-24 by the first application to\n * append: its database had no webhook table at all). `test/append-store-db.test.ts` holds this line.\n */\nexport const entities = [WebhookDestination, WebhookOutboxEvent, WebhookSequence]\n\nexport const schema = { resolverClasses: [WebhookDestinationResolver] }\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'\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'\nexport * from './outbox/webhook-append-store'\n\n/*\n * The pump: one round's judgement (`pump-round`), one round (`pump`), and the loop an application starts from its\n * boot hook (`pump-loop`). Keys come from the contract's ladder - the product-named `pump-gate` is gone\n * (ADR-0078 decision 5, re-ruled 2026-09-24).\n */\nexport * from './outbox/pump-round'\nexport * from './outbox/pump'\nexport * from './outbox/pump-loop'\n\n"]}
@@ -0,0 +1,38 @@
1
+ import { EntityManager } from 'typeorm';
2
+ import { type PumpSource } from './pump.js';
3
+ /**
4
+ * **The pump's loop** — every few seconds, one round, on one instance of the cluster (ADR-0078 decision 6).
5
+ *
6
+ * ═════════════════════════════════════════════════════════════════════════
7
+ * ## The application starts it
8
+ *
9
+ * The module does not start anything on its own. An application that appends facts calls `startWebhookPump`
10
+ * from its boot hook, and passes the names its older installations used for keys, if it has any.
11
+ *
12
+ * ## One instance at a time, and one round at a time
13
+ *
14
+ * `running` stops a round starting while the last is still sending - it would send the same facts twice. It
15
+ * only guards this process; behind a load balancer two instances would both send, and the later one arriving
16
+ * first reads at the receiver as a gap. So each round runs under a lease.
17
+ *
18
+ * **A paused pump keeps the lease.** The pause lives in this process. Let go, another instance - which knows
19
+ * nothing of it - takes over at once and knocks on the receiver that asked us to wait.
20
+ *
21
+ * ## It stands without a key
22
+ *
23
+ * Keys are looked up per stream, per round, on the contract's ladder. A missing key is said once, naming the
24
+ * stream, and the facts wait. Refusing to start instead would say nothing at all about which domain lacks one.
25
+ * ═════════════════════════════════════════════════════════════════════════
26
+ */
27
+ export interface WebhookPumpOptions {
28
+ intervalSeconds?: number;
29
+ batchSize?: number;
30
+ /** Names this product's installations used for keys before the contract's ladder. Most products have none. */
31
+ legacyBases?: readonly string[];
32
+ }
33
+ /** The source over this module's tables. */
34
+ export declare function webhookPumpSource(manager: EntityManager): PumpSource;
35
+ /** Start the loop. Starting twice does nothing. */
36
+ export declare function startWebhookPump(options?: WebhookPumpOptions): void;
37
+ /** Stop the loop. For tests and shutdown - left running, the process does not end. */
38
+ export declare function stopWebhookPump(): void;
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.webhookPumpSource = webhookPumpSource;
4
+ exports.startWebhookPump = startWebhookPump;
5
+ exports.stopWebhookPump = stopWebhookPump;
6
+ const env_1 = require("@things-factory/env");
7
+ const shell_1 = require("@things-factory/shell");
8
+ const task_lease_1 = require("@things-factory/task-lease");
9
+ const webhook_destination_js_1 = require("../webhook-destination/webhook-destination.js");
10
+ const webhook_outbox_event_js_1 = require("../webhook-outbox-event/webhook-outbox-event.js");
11
+ const pump_js_1 = require("./pump.js");
12
+ const webhook_store_js_1 = require("./webhook-store.js");
13
+ const DEFAULTS = { intervalSeconds: 5, batchSize: 200 };
14
+ const LEASE = 'webhook-outbox-pump';
15
+ /** Longer than a round, so it does not lapse mid-batch; short enough that a dead instance is replaced soon. */
16
+ function leaseTtlMs(intervalSeconds) {
17
+ return Math.max(60_000, intervalSeconds * 1000 * 6);
18
+ }
19
+ /** The source over this module's tables. */
20
+ function webhookPumpSource(manager) {
21
+ return {
22
+ async pendingStreams() {
23
+ const rows = await manager
24
+ .createQueryBuilder(webhook_outbox_event_js_1.WebhookOutboxEvent, 'event')
25
+ .innerJoin('event.domain', 'domain')
26
+ .select('domain.id', 'domainId')
27
+ .addSelect('domain.subdomain', 'subdomain')
28
+ .addSelect('event.peer', 'peer')
29
+ .addSelect('event.scope', 'scope')
30
+ .where('event.status = :pending', { pending: webhook_outbox_event_js_1.WebhookOutboxStatus.Pending })
31
+ .groupBy('domain.id')
32
+ .addGroupBy('domain.subdomain')
33
+ .addGroupBy('event.peer')
34
+ .addGroupBy('event.scope')
35
+ .orderBy('domain.subdomain')
36
+ .addOrderBy('event.peer')
37
+ .addOrderBy('event.scope')
38
+ .getRawMany();
39
+ return rows.map(row => ({ domainId: row.domainId, subdomain: row.subdomain, peer: row.peer, scope: row.scope }));
40
+ },
41
+ async destinationOf(domainId, peer) {
42
+ const row = await manager.findOne(webhook_destination_js_1.WebhookDestination, { where: { domain: { id: domainId }, peer } });
43
+ return row ? { endpoint: row.endpoint, active: row.active, params: row.params ?? undefined } : undefined;
44
+ },
45
+ storeFor(stream) {
46
+ return (0, webhook_store_js_1.webhookStore)(manager, { domainId: stream.domainId, peer: stream.peer, scope: stream.scope });
47
+ }
48
+ };
49
+ }
50
+ let timer;
51
+ let running = false;
52
+ let state = (0, pump_js_1.newPumpState)();
53
+ /** Start the loop. Starting twice does nothing. */
54
+ function startWebhookPump(options = {}) {
55
+ if (timer) {
56
+ return;
57
+ }
58
+ const intervalSeconds = options.intervalSeconds ?? DEFAULTS.intervalSeconds;
59
+ const batchSize = options.batchSize ?? DEFAULTS.batchSize;
60
+ const say = { info: (what) => env_1.logger.info(what), warn: (what) => env_1.logger.warn(what), error: (what) => env_1.logger.error(what) };
61
+ env_1.logger.info(`[webhook] sending queued facts every ${intervalSeconds}s`);
62
+ timer = setInterval(async () => {
63
+ if (running) {
64
+ return;
65
+ }
66
+ running = true;
67
+ try {
68
+ await (0, task_lease_1.withLease)(LEASE, leaseTtlMs(intervalSeconds), async () => {
69
+ await (0, pump_js_1.pumpOnce)({ source: webhookPumpSource((0, shell_1.getRepository)(webhook_outbox_event_js_1.WebhookOutboxEvent).manager), env: process.env, fetch, say }, state, { batchSize, ...(options.legacyBases?.length ? { legacyBases: options.legacyBases } : {}) });
70
+ /* How much longer to rest after this round. Zero or less when not resting. */
71
+ return (0, pump_js_1.restingFor)(state, Date.now());
72
+ },
73
+ /* A paused pump keeps the lease a little past the pause (see the head of this file). */
74
+ { hold: restingFor => (restingFor > 0 ? restingFor + leaseTtlMs(intervalSeconds) : undefined) });
75
+ }
76
+ catch (error) {
77
+ /* A failed round must not stop the next one - stopped, facts pile up for good. */
78
+ env_1.logger.error(`[webhook] sending queued facts failed this round - ${String(error)}`);
79
+ }
80
+ finally {
81
+ running = false;
82
+ }
83
+ }, intervalSeconds * 1000);
84
+ }
85
+ /** Stop the loop. For tests and shutdown - left running, the process does not end. */
86
+ function stopWebhookPump() {
87
+ if (timer) {
88
+ clearInterval(timer);
89
+ timer = undefined;
90
+ }
91
+ state = (0, pump_js_1.newPumpState)();
92
+ }
93
+ //# sourceMappingURL=pump-loop.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pump-loop.js","sourceRoot":"","sources":["../../../server/service/outbox/pump-loop.ts"],"names":[],"mappings":"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","sourcesContent":["import { EntityManager } from 'typeorm'\n\nimport { logger } from '@things-factory/env'\nimport { getRepository } from '@things-factory/shell'\nimport { withLease } from '@things-factory/task-lease'\n\nimport { WebhookDestination } from '../webhook-destination/webhook-destination.js'\nimport { WebhookOutboxEvent, WebhookOutboxStatus } from '../webhook-outbox-event/webhook-outbox-event.js'\nimport { newPumpState, pumpOnce, restingFor, type PumpSource, type PumpState } from './pump.js'\nimport type { PendingStream } from './pump-round.js'\nimport { webhookStore } from './webhook-store.js'\n\n/**\n * **The pump's loop** — every few seconds, one round, on one instance of the cluster (ADR-0078 decision 6).\n *\n * ═════════════════════════════════════════════════════════════════════════\n * ## The application starts it\n *\n * The module does not start anything on its own. An application that appends facts calls `startWebhookPump`\n * from its boot hook, and passes the names its older installations used for keys, if it has any.\n *\n * ## One instance at a time, and one round at a time\n *\n * `running` stops a round starting while the last is still sending - it would send the same facts twice. It\n * only guards this process; behind a load balancer two instances would both send, and the later one arriving\n * first reads at the receiver as a gap. So each round runs under a lease.\n *\n * **A paused pump keeps the lease.** The pause lives in this process. Let go, another instance - which knows\n * nothing of it - takes over at once and knocks on the receiver that asked us to wait.\n *\n * ## It stands without a key\n *\n * Keys are looked up per stream, per round, on the contract's ladder. A missing key is said once, naming the\n * stream, and the facts wait. Refusing to start instead would say nothing at all about which domain lacks one.\n * ═════════════════════════════════════════════════════════════════════════\n */\n\nexport interface WebhookPumpOptions {\n intervalSeconds?: number\n batchSize?: number\n /** Names this product's installations used for keys before the contract's ladder. Most products have none. */\n legacyBases?: readonly string[]\n}\n\nconst DEFAULTS = { intervalSeconds: 5, batchSize: 200 }\nconst LEASE = 'webhook-outbox-pump'\n\n/** Longer than a round, so it does not lapse mid-batch; short enough that a dead instance is replaced soon. */\nfunction leaseTtlMs(intervalSeconds: number): number {\n return Math.max(60_000, intervalSeconds * 1000 * 6)\n}\n\n/** The source over this module's tables. */\nexport function webhookPumpSource(manager: EntityManager): PumpSource {\n return {\n async pendingStreams(): Promise<PendingStream[]> {\n const rows = await manager\n .createQueryBuilder(WebhookOutboxEvent, 'event')\n .innerJoin('event.domain', 'domain')\n .select('domain.id', 'domainId')\n .addSelect('domain.subdomain', 'subdomain')\n .addSelect('event.peer', 'peer')\n .addSelect('event.scope', 'scope')\n .where('event.status = :pending', { pending: WebhookOutboxStatus.Pending })\n .groupBy('domain.id')\n .addGroupBy('domain.subdomain')\n .addGroupBy('event.peer')\n .addGroupBy('event.scope')\n .orderBy('domain.subdomain')\n .addOrderBy('event.peer')\n .addOrderBy('event.scope')\n .getRawMany()\n\n return rows.map(row => ({ domainId: row.domainId, subdomain: row.subdomain, peer: row.peer, scope: row.scope }))\n },\n\n async destinationOf(domainId: string, peer: string) {\n const row = await manager.findOne(WebhookDestination, { where: { domain: { id: domainId }, peer } })\n return row ? { endpoint: row.endpoint, active: row.active, params: row.params ?? undefined } : undefined\n },\n\n storeFor(stream: PendingStream) {\n return webhookStore(manager, { domainId: stream.domainId, peer: stream.peer, scope: stream.scope })\n }\n }\n}\n\nlet timer: ReturnType<typeof setInterval> | undefined\nlet running = false\nlet state: PumpState = newPumpState()\n\n/** Start the loop. Starting twice does nothing. */\nexport function startWebhookPump(options: WebhookPumpOptions = {}): void {\n if (timer) {\n return\n }\n\n const intervalSeconds = options.intervalSeconds ?? DEFAULTS.intervalSeconds\n const batchSize = options.batchSize ?? DEFAULTS.batchSize\n const say = { info: (what: string) => logger.info(what), warn: (what: string) => logger.warn(what), error: (what: string) => logger.error(what) }\n\n logger.info(`[webhook] sending queued facts every ${intervalSeconds}s`)\n\n timer = setInterval(async () => {\n if (running) {\n return\n }\n\n running = true\n try {\n await withLease(\n LEASE,\n leaseTtlMs(intervalSeconds),\n async () => {\n await pumpOnce(\n { source: webhookPumpSource(getRepository(WebhookOutboxEvent).manager), env: process.env, fetch, say },\n state,\n { batchSize, ...(options.legacyBases?.length ? { legacyBases: options.legacyBases } : {}) }\n )\n\n /* How much longer to rest after this round. Zero or less when not resting. */\n return restingFor(state, Date.now())\n },\n /* A paused pump keeps the lease a little past the pause (see the head of this file). */\n { hold: restingFor => (restingFor > 0 ? restingFor + leaseTtlMs(intervalSeconds) : undefined) }\n )\n } catch (error) {\n /* A failed round must not stop the next one - stopped, facts pile up for good. */\n logger.error(`[webhook] sending queued facts failed this round - ${String(error)}`)\n } finally {\n running = false\n }\n }, intervalSeconds * 1000)\n}\n\n/** Stop the loop. For tests and shutdown - left running, the process does not end. */\nexport function stopWebhookPump(): void {\n if (timer) {\n clearInterval(timer)\n timer = undefined\n }\n state = newPumpState()\n}\n"]}
@@ -0,0 +1,75 @@
1
+ import { type Say } from './dispatcher.js';
2
+ import { type DestinationReading, type PendingStream, type RoundOptions } from './pump-round.js';
3
+ import type { OutboxStore } from './store.js';
4
+ /**
5
+ * **One round of the pump** — every stream with facts waiting, one delivery each (ADR-0078 decision 6).
6
+ *
7
+ * ═════════════════════════════════════════════════════════════════════════
8
+ * Built against operato-plant's `mes-outbox-pump.ts`, which learned these by running:
9
+ *
10
+ * - **Asked to wait, nothing more goes to that receiver until the wait is over.** Ignoring a 429 does not crash
11
+ * anything - the receiver drops facts instead.
12
+ * - **An answer only a person can fix pauses that stream.** A wrong key gets the same 401 every five seconds,
13
+ * knocks on the receiver and buries the log. It pauses only what it is about - see `PumpState.pauses`.
14
+ * - **Each cause of facts piling up is said once.** Said every round, the line covers everything else.
15
+ *
16
+ * Nothing here touches TypeORM, the process or a timer - the source, the environment, `fetch` and the clock are
17
+ * handed in, so every branch runs in a test. The loop, the lease and the tables are in `pump-loop.ts`.
18
+ * ═════════════════════════════════════════════════════════════════════════
19
+ */
20
+ /** Where a round reads from. `pump-loop.ts` supplies one over this module's tables. */
21
+ export interface PumpSource {
22
+ pendingStreams(): Promise<PendingStream[]>;
23
+ destinationOf(domainId: string, peer: string): Promise<DestinationReading | undefined>;
24
+ storeFor(stream: PendingStream): OutboxStore;
25
+ }
26
+ export interface PumpDeps {
27
+ source: PumpSource;
28
+ env: Record<string, string | undefined>;
29
+ fetch: typeof fetch;
30
+ say: Say;
31
+ nowMs?: () => number;
32
+ }
33
+ /**
34
+ * One pause: until when, and why. The same reason is not said twice in a row.
35
+ */
36
+ interface Pause {
37
+ until: number;
38
+ why: string;
39
+ }
40
+ /** What a pump remembers between rounds. One per running pump. */
41
+ export interface PumpState {
42
+ /**
43
+ * Pauses **by what they are about** (Plant, 2026-09-24). The first cut held one pause for the whole pump, as
44
+ * operato-plant's pump does: a tenant with a *wrong* key paused everything, and since the round goes in a fixed
45
+ * order the tenants behind it never sent again - measured, five minutes, zero sent. So each cause pauses its
46
+ * own unit:
47
+ *
48
+ * ```
49
+ * receiver:<endpoint> asked to wait it is that receiver's load, whoever the stream belongs to
50
+ * stream:<domain>/<peer> signature refused a key is per domain and peer
51
+ * stream:<domain>/<peer>/<scope> stop the answer was about what this stream sent
52
+ * ```
53
+ *
54
+ * Waiting is keyed by the endpoint rather than the peer's name: two domains may both call their twin `twin`
55
+ * and mean two different servers.
56
+ */
57
+ pauses: Map<string, Pause>;
58
+ /** Notes said, by key. A key whose cause is gone this round is forgotten, so a cause that returns is said again. */
59
+ said: Set<string>;
60
+ }
61
+ export declare function newPumpState(): PumpState;
62
+ /**
63
+ * How much longer the longest pause stands - what the lease should be held past. Zero or less when nothing rests.
64
+ *
65
+ * Holding past the longest keeps the rule the single pause had: while anything rests, no other instance takes over
66
+ * and knocks on it. Streams that are not paused still go out - they go out from this instance.
67
+ */
68
+ export declare function restingFor(state: PumpState, nowMs: number): number;
69
+ /** How long to rest after an answer that resending will not change. */
70
+ export declare const PAUSE_AFTER_UNRECOVERABLE_MS = 60000;
71
+ export interface PumpOptions extends RoundOptions {
72
+ batchSize: number;
73
+ }
74
+ export declare function pumpOnce(deps: PumpDeps, state: PumpState, options: PumpOptions): Promise<void>;
75
+ export {};
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PAUSE_AFTER_UNRECOVERABLE_MS = void 0;
4
+ exports.newPumpState = newPumpState;
5
+ exports.restingFor = restingFor;
6
+ exports.pumpOnce = pumpOnce;
7
+ const dispatcher_js_1 = require("./dispatcher.js");
8
+ const pump_round_js_1 = require("./pump-round.js");
9
+ function newPumpState() {
10
+ return { pauses: new Map(), said: new Set() };
11
+ }
12
+ /**
13
+ * How much longer the longest pause stands - what the lease should be held past. Zero or less when nothing rests.
14
+ *
15
+ * Holding past the longest keeps the rule the single pause had: while anything rests, no other instance takes over
16
+ * and knocks on it. Streams that are not paused still go out - they go out from this instance.
17
+ */
18
+ function restingFor(state, nowMs) {
19
+ let latest = 0;
20
+ for (const pause of state.pauses.values()) {
21
+ latest = Math.max(latest, pause.until);
22
+ }
23
+ return latest - nowMs;
24
+ }
25
+ /** How long to rest after an answer that resending will not change. */
26
+ exports.PAUSE_AFTER_UNRECOVERABLE_MS = 60_000;
27
+ async function pumpOnce(deps, state, options) {
28
+ const now = deps.nowMs ?? (() => Date.now());
29
+ /* A pause that is over is forgotten - the next time the same answer comes, it has to be said again. */
30
+ for (const [key, pause] of state.pauses) {
31
+ if (pause.until <= now())
32
+ state.pauses.delete(key);
33
+ }
34
+ const resting = (key) => (state.pauses.get(key)?.until ?? 0) > now();
35
+ const pause = (key, forMs, why) => {
36
+ const standing = state.pauses.get(key);
37
+ if (standing?.why !== why) {
38
+ deps.say.warn(`[webhook] ${why} - waiting ${Math.round(forMs / 1000)}s`);
39
+ }
40
+ state.pauses.set(key, { until: Math.max(standing?.until ?? 0, now() + forMs), why });
41
+ };
42
+ const streams = await deps.source.pendingStreams();
43
+ const destinations = new Map();
44
+ for (const stream of streams) {
45
+ const key = `${stream.domainId}\u0000${stream.peer}`;
46
+ if (!destinations.has(key)) {
47
+ destinations.set(key, await deps.source.destinationOf(stream.domainId, stream.peer));
48
+ }
49
+ }
50
+ const steps = (0, pump_round_js_1.planRound)(streams, (domainId, peer) => destinations.get(`${domainId}\u0000${peer}`), deps.env, options);
51
+ const saidThisRound = new Set();
52
+ for (const step of steps) {
53
+ if (step.note) {
54
+ saidThisRound.add(step.note.key);
55
+ if (!state.said.has(step.note.key)) {
56
+ state.said.add(step.note.key);
57
+ deps.say.warn(step.note.line);
58
+ }
59
+ }
60
+ if (step.kind === 'wait') {
61
+ continue;
62
+ }
63
+ const { domainId, peer, scope } = step.stream;
64
+ const receiver = `receiver:${step.endpoint}`;
65
+ const signer = `stream:${domainId}/${peer}`;
66
+ const stream = `stream:${domainId}/${peer}/${scope}`;
67
+ if (resting(receiver) || resting(signer) || resting(stream)) {
68
+ continue;
69
+ }
70
+ const report = await (0, dispatcher_js_1.dispatchPending)(deps.source.storeFor(step.stream), { endpoint: step.endpoint, secret: step.secret, headers: step.headers, clockKey: step.clockKey, batchSize: options.batchSize }, { fetch: deps.fetch, say: deps.say, now: () => new Date(now()) });
71
+ if (report.retryAfterMs) {
72
+ pause(receiver, report.retryAfterMs, `${step.endpoint} asked us to slow down`);
73
+ }
74
+ else if (report.outcome.kind === 'stop') {
75
+ pause(stream, exports.PAUSE_AFTER_UNRECOVERABLE_MS, report.outcome.reason);
76
+ }
77
+ else if (report.outcome.kind === 'unauthorized') {
78
+ pause(signer, exports.PAUSE_AFTER_UNRECOVERABLE_MS, `${step.endpoint} did not accept our signature for ${step.stream.subdomain} - check the key on both sides, then it will try again`);
79
+ }
80
+ }
81
+ /* A note whose cause did not come up this round is forgotten. If the cause comes back, it is said again. */
82
+ for (const key of [...state.said]) {
83
+ if (!saidThisRound.has(key))
84
+ state.said.delete(key);
85
+ }
86
+ }
87
+ //# sourceMappingURL=pump.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pump.js","sourceRoot":"","sources":["../../../server/service/outbox/pump.ts"],"names":[],"mappings":";;;AAkEA,oCAEC;AAQD,gCAMC;AASD,4BAwEC;AAnKD,mDAA2D;AAC3D,mDAA2G;AAiE3G,SAAgB,YAAY;IAC1B,OAAO,EAAE,MAAM,EAAE,IAAI,GAAG,EAAE,EAAE,IAAI,EAAE,IAAI,GAAG,EAAE,EAAE,CAAA;AAC/C,CAAC;AAED;;;;;GAKG;AACH,SAAgB,UAAU,CAAC,KAAgB,EAAE,KAAa;IACxD,IAAI,MAAM,GAAG,CAAC,CAAA;IACd,KAAK,MAAM,KAAK,IAAI,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,EAAE,CAAC;QAC1C,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,KAAK,CAAC,CAAA;IACxC,CAAC;IACD,OAAO,MAAM,GAAG,KAAK,CAAA;AACvB,CAAC;AAED,uEAAuE;AAC1D,QAAA,4BAA4B,GAAG,MAAM,CAAA;AAM3C,KAAK,UAAU,QAAQ,CAAC,IAAc,EAAE,KAAgB,EAAE,OAAoB;IACnF,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAA;IAE5C,uGAAuG;IACvG,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,KAAK,CAAC,MAAM,EAAE,CAAC;QACxC,IAAI,KAAK,CAAC,KAAK,IAAI,GAAG,EAAE;YAAE,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;IACpD,CAAC;IAED,MAAM,OAAO,GAAG,CAAC,GAAW,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,KAAK,IAAI,CAAC,CAAC,GAAG,GAAG,EAAE,CAAA;IAE5E,MAAM,KAAK,GAAG,CAAC,GAAW,EAAE,KAAa,EAAE,GAAW,EAAE,EAAE;QACxD,MAAM,QAAQ,GAAG,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QACtC,IAAI,QAAQ,EAAE,GAAG,KAAK,GAAG,EAAE,CAAC;YAC1B,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,aAAa,GAAG,cAAc,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAA;QAC1E,CAAC;QACD,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,KAAK,IAAI,CAAC,EAAE,GAAG,EAAE,GAAG,KAAK,CAAC,EAAE,GAAG,EAAE,CAAC,CAAA;IACtF,CAAC,CAAA;IAED,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,cAAc,EAAE,CAAA;IAElD,MAAM,YAAY,GAAG,IAAI,GAAG,EAA0C,CAAA;IACtE,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,MAAM,GAAG,GAAG,GAAG,MAAM,CAAC,QAAQ,SAAS,MAAM,CAAC,IAAI,EAAE,CAAA;QACpD,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;YAC3B,YAAY,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAA;QACtF,CAAC;IACH,CAAC;IAED,MAAM,KAAK,GAAG,IAAA,yBAAS,EAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,IAAI,EAAE,EAAE,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,QAAQ,SAAS,IAAI,EAAE,CAAC,EAAE,IAAI,CAAC,GAAG,EAAE,OAAO,CAAC,CAAA;IACrH,MAAM,aAAa,GAAG,IAAI,GAAG,EAAU,CAAA;IAEvC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC;YACd,aAAa,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;YAChC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;gBACnC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;gBAC7B,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;YAC/B,CAAC;QACH,CAAC;QAED,IAAI,IAAI,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;YACzB,SAAQ;QACV,CAAC;QAED,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,IAAI,CAAC,MAAM,CAAA;QAC7C,MAAM,QAAQ,GAAG,YAAY,IAAI,CAAC,QAAQ,EAAE,CAAA;QAC5C,MAAM,MAAM,GAAG,UAAU,QAAQ,IAAI,IAAI,EAAE,CAAA;QAC3C,MAAM,MAAM,GAAG,UAAU,QAAQ,IAAI,IAAI,IAAI,KAAK,EAAE,CAAA;QAEpD,IAAI,OAAO,CAAC,QAAQ,CAAC,IAAI,OAAO,CAAC,MAAM,CAAC,IAAI,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;YAC5D,SAAQ;QACV,CAAC;QAED,MAAM,MAAM,GAAG,MAAM,IAAA,+BAAe,EAClC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,EACjC,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,EAC9H,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC,EAAE,CACjE,CAAA;QAED,IAAI,MAAM,CAAC,YAAY,EAAE,CAAC;YACxB,KAAK,CAAC,QAAQ,EAAE,MAAM,CAAC,YAAY,EAAE,GAAG,IAAI,CAAC,QAAQ,wBAAwB,CAAC,CAAA;QAChF,CAAC;aAAM,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;YAC1C,KAAK,CAAC,MAAM,EAAE,oCAA4B,EAAE,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAA;QACpE,CAAC;aAAM,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,KAAK,cAAc,EAAE,CAAC;YAClD,KAAK,CAAC,MAAM,EAAE,oCAA4B,EAAE,GAAG,IAAI,CAAC,QAAQ,qCAAqC,IAAI,CAAC,MAAM,CAAC,SAAS,wDAAwD,CAAC,CAAA;QACjL,CAAC;IACH,CAAC;IAED,4GAA4G;IAC5G,KAAK,MAAM,GAAG,IAAI,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QAClC,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;IACrD,CAAC;AACH,CAAC","sourcesContent":["import { dispatchPending, type Say } from './dispatcher.js'\nimport { planRound, type DestinationReading, type PendingStream, type RoundOptions } from './pump-round.js'\nimport type { OutboxStore } from './store.js'\n\n/**\n * **One round of the pump** — every stream with facts waiting, one delivery each (ADR-0078 decision 6).\n *\n * ═════════════════════════════════════════════════════════════════════════\n * Built against operato-plant's `mes-outbox-pump.ts`, which learned these by running:\n *\n * - **Asked to wait, nothing more goes to that receiver until the wait is over.** Ignoring a 429 does not crash\n * anything - the receiver drops facts instead.\n * - **An answer only a person can fix pauses that stream.** A wrong key gets the same 401 every five seconds,\n * knocks on the receiver and buries the log. It pauses only what it is about - see `PumpState.pauses`.\n * - **Each cause of facts piling up is said once.** Said every round, the line covers everything else.\n *\n * Nothing here touches TypeORM, the process or a timer - the source, the environment, `fetch` and the clock are\n * handed in, so every branch runs in a test. The loop, the lease and the tables are in `pump-loop.ts`.\n * ═════════════════════════════════════════════════════════════════════════\n */\n\n/** Where a round reads from. `pump-loop.ts` supplies one over this module's tables. */\nexport interface PumpSource {\n pendingStreams(): Promise<PendingStream[]>\n destinationOf(domainId: string, peer: string): Promise<DestinationReading | undefined>\n storeFor(stream: PendingStream): OutboxStore\n}\n\nexport interface PumpDeps {\n source: PumpSource\n env: Record<string, string | undefined>\n fetch: typeof fetch\n say: Say\n nowMs?: () => number\n}\n\n/**\n * One pause: until when, and why. The same reason is not said twice in a row.\n */\ninterface Pause {\n until: number\n why: string\n}\n\n/** What a pump remembers between rounds. One per running pump. */\nexport interface PumpState {\n /**\n * Pauses **by what they are about** (Plant, 2026-09-24). The first cut held one pause for the whole pump, as\n * operato-plant's pump does: a tenant with a *wrong* key paused everything, and since the round goes in a fixed\n * order the tenants behind it never sent again - measured, five minutes, zero sent. So each cause pauses its\n * own unit:\n *\n * ```\n * receiver:<endpoint> asked to wait it is that receiver's load, whoever the stream belongs to\n * stream:<domain>/<peer> signature refused a key is per domain and peer\n * stream:<domain>/<peer>/<scope> stop the answer was about what this stream sent\n * ```\n *\n * Waiting is keyed by the endpoint rather than the peer's name: two domains may both call their twin `twin`\n * and mean two different servers.\n */\n pauses: Map<string, Pause>\n /** Notes said, by key. A key whose cause is gone this round is forgotten, so a cause that returns is said again. */\n said: Set<string>\n}\n\nexport function newPumpState(): PumpState {\n return { pauses: new Map(), said: new Set() }\n}\n\n/**\n * How much longer the longest pause stands - what the lease should be held past. Zero or less when nothing rests.\n *\n * Holding past the longest keeps the rule the single pause had: while anything rests, no other instance takes over\n * and knocks on it. Streams that are not paused still go out - they go out from this instance.\n */\nexport function restingFor(state: PumpState, nowMs: number): number {\n let latest = 0\n for (const pause of state.pauses.values()) {\n latest = Math.max(latest, pause.until)\n }\n return latest - nowMs\n}\n\n/** How long to rest after an answer that resending will not change. */\nexport const PAUSE_AFTER_UNRECOVERABLE_MS = 60_000\n\nexport interface PumpOptions extends RoundOptions {\n batchSize: number\n}\n\nexport async function pumpOnce(deps: PumpDeps, state: PumpState, options: PumpOptions): Promise<void> {\n const now = deps.nowMs ?? (() => Date.now())\n\n /* A pause that is over is forgotten - the next time the same answer comes, it has to be said again. */\n for (const [key, pause] of state.pauses) {\n if (pause.until <= now()) state.pauses.delete(key)\n }\n\n const resting = (key: string) => (state.pauses.get(key)?.until ?? 0) > now()\n\n const pause = (key: string, forMs: number, why: string) => {\n const standing = state.pauses.get(key)\n if (standing?.why !== why) {\n deps.say.warn(`[webhook] ${why} - waiting ${Math.round(forMs / 1000)}s`)\n }\n state.pauses.set(key, { until: Math.max(standing?.until ?? 0, now() + forMs), why })\n }\n\n const streams = await deps.source.pendingStreams()\n\n const destinations = new Map<string, DestinationReading | undefined>()\n for (const stream of streams) {\n const key = `${stream.domainId}\\u0000${stream.peer}`\n if (!destinations.has(key)) {\n destinations.set(key, await deps.source.destinationOf(stream.domainId, stream.peer))\n }\n }\n\n const steps = planRound(streams, (domainId, peer) => destinations.get(`${domainId}\\u0000${peer}`), deps.env, options)\n const saidThisRound = new Set<string>()\n\n for (const step of steps) {\n if (step.note) {\n saidThisRound.add(step.note.key)\n if (!state.said.has(step.note.key)) {\n state.said.add(step.note.key)\n deps.say.warn(step.note.line)\n }\n }\n\n if (step.kind === 'wait') {\n continue\n }\n\n const { domainId, peer, scope } = step.stream\n const receiver = `receiver:${step.endpoint}`\n const signer = `stream:${domainId}/${peer}`\n const stream = `stream:${domainId}/${peer}/${scope}`\n\n if (resting(receiver) || resting(signer) || resting(stream)) {\n continue\n }\n\n const report = await dispatchPending(\n deps.source.storeFor(step.stream),\n { endpoint: step.endpoint, secret: step.secret, headers: step.headers, clockKey: step.clockKey, batchSize: options.batchSize },\n { fetch: deps.fetch, say: deps.say, now: () => new Date(now()) }\n )\n\n if (report.retryAfterMs) {\n pause(receiver, report.retryAfterMs, `${step.endpoint} asked us to slow down`)\n } else if (report.outcome.kind === 'stop') {\n pause(stream, PAUSE_AFTER_UNRECOVERABLE_MS, report.outcome.reason)\n } else if (report.outcome.kind === 'unauthorized') {\n pause(signer, PAUSE_AFTER_UNRECOVERABLE_MS, `${step.endpoint} did not accept our signature for ${step.stream.subdomain} - check the key on both sides, then it will try again`)\n }\n }\n\n /* A note whose cause did not come up this round is forgotten. If the cause comes back, it is said again. */\n for (const key of [...state.said]) {\n if (!saidThisRound.has(key)) state.said.delete(key)\n }\n}\n"]}