@substrat-run/control-plane-api 0.139.0 → 0.141.0
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/api.d.ts +17 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +701 -160
- package/dist/api.js.map +1 -1
- package/dist/audited-change.d.ts +139 -0
- package/dist/audited-change.d.ts.map +1 -0
- package/dist/audited-change.js +214 -0
- package/dist/audited-change.js.map +1 -0
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +38 -1
- package/dist/cf-observability.js.map +1 -1
- package/dist/connect-links.d.ts +29 -0
- package/dist/connect-links.d.ts.map +1 -0
- package/dist/connect-links.js +139 -0
- package/dist/connect-links.js.map +1 -0
- package/dist/connect-url.d.ts +20 -1
- package/dist/connect-url.d.ts.map +1 -1
- package/dist/connect-url.js +1 -1
- package/dist/connect-url.js.map +1 -1
- package/dist/connection-relay.d.ts +24 -0
- package/dist/connection-relay.d.ts.map +1 -1
- package/dist/connection-relay.js +31 -12
- package/dist/connection-relay.js.map +1 -1
- package/dist/email-intent.d.ts +71 -0
- package/dist/email-intent.d.ts.map +1 -0
- package/dist/email-intent.js +186 -0
- package/dist/email-intent.js.map +1 -0
- package/dist/field-coverage-read.d.ts +44 -0
- package/dist/field-coverage-read.d.ts.map +1 -0
- package/dist/field-coverage-read.js +45 -0
- package/dist/field-coverage-read.js.map +1 -0
- package/dist/field-coverage-tally.d.ts +12 -0
- package/dist/field-coverage-tally.d.ts.map +1 -1
- package/dist/field-coverage-tally.js +55 -14
- package/dist/field-coverage-tally.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +13 -0
- package/dist/observability.d.ts.map +1 -1
- package/dist/platform-drain.d.ts +12 -2
- package/dist/platform-drain.d.ts.map +1 -1
- package/dist/platform-drain.js +15 -3
- package/dist/platform-drain.js.map +1 -1
- package/dist/reconcile.d.ts +17 -2
- package/dist/reconcile.d.ts.map +1 -1
- package/dist/reconcile.js +35 -1
- package/dist/reconcile.js.map +1 -1
- package/dist/scope-copy-cleanup.d.ts +76 -0
- package/dist/scope-copy-cleanup.d.ts.map +1 -0
- package/dist/scope-copy-cleanup.js +228 -0
- package/dist/scope-copy-cleanup.js.map +1 -0
- package/dist/staff-client.d.ts +3 -1
- package/dist/staff-client.d.ts.map +1 -1
- package/dist/staff-client.js +2 -0
- package/dist/staff-client.js.map +1 -1
- package/dist/vertical-client.d.ts +30 -2
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +100 -22
- package/dist/vertical-client.js.map +1 -1
- package/package.json +10 -10
package/dist/connection-relay.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isPrimaryScope, ulid } from '@substrat-run/kernel';
|
|
2
|
-
import { connectionId, connectionRelayRequest, } from '@substrat-run/contracts';
|
|
2
|
+
import { instant, connectionId, connectionRelayRequest, } from '@substrat-run/contracts';
|
|
3
3
|
/**
|
|
4
4
|
* The connection relay (connections.md §3.5.2) — the credential half of #574's routed
|
|
5
5
|
* connector dispatch. A hosted CP-less vertical's tenant admin holds a provider credential
|
|
@@ -76,21 +76,39 @@ export async function relayConnectionUpsert(host, actor, body, options = {}) {
|
|
|
76
76
|
throw new ConnectionRelayError(PREVIEW_CONNECTIONS_REFUSAL, 403);
|
|
77
77
|
}
|
|
78
78
|
const vertical = rec.vertical;
|
|
79
|
-
// Ask the provider before touching the store (#605). Deliberately here, ahead of every
|
|
80
|
-
// write below: a refused rotation must leave the live credential exactly as it was.
|
|
81
|
-
const probe = await options.probeCandidate?.(input.provider, input.secret);
|
|
82
|
-
if (probe && !probe.ok && probe.refused) {
|
|
83
|
-
throw new ConnectionRelayError(probe.error ?? `${input.provider} refused these credentials`, 422, probe);
|
|
84
|
-
}
|
|
85
79
|
// `listConnections` already excludes revoked rows; expired/errored rows are still the
|
|
86
80
|
// live row for this key — rotation is what revives them. The account leg matches
|
|
87
81
|
// exactly (absent matches absent): a multi-account provider's second account is a new
|
|
88
|
-
// connection, not a rotation of the first.
|
|
82
|
+
// connection, not a rotation of the first. Read before the probe because a provider that
|
|
83
|
+
// finishes its own credential (#2100) needs the one this upsert would replace.
|
|
89
84
|
const live = (await host.admin.listConnections(actor, {
|
|
90
85
|
tenantId: input.tenantId,
|
|
91
86
|
vertical,
|
|
92
87
|
provider: input.provider,
|
|
93
88
|
})).filter((c) => c.externalAccountRef === (input.externalAccountRef ?? null));
|
|
89
|
+
// #2100: the platform's half of the credential, made here — before the probe, so what is
|
|
90
|
+
// probed is exactly what is stored.
|
|
91
|
+
let secret = input.secret;
|
|
92
|
+
let expiresAt = input.expiresAt;
|
|
93
|
+
const prepare = options.prepareCandidate?.(input.provider);
|
|
94
|
+
if (prepare) {
|
|
95
|
+
const previous = live.length === 1
|
|
96
|
+
? (await host.admin.openConnection(input.tenantId, vertical, input.provider, input.externalAccountRef))?.secret
|
|
97
|
+
: undefined;
|
|
98
|
+
const prepared = await prepare(input.secret, previous);
|
|
99
|
+
secret = prepared.secret;
|
|
100
|
+
// Parsed: a provider's date becomes the same branded instant a caller's would be. `null` is
|
|
101
|
+
// the provider clearing an expiry whose credential it dropped (a certificate → a client secret).
|
|
102
|
+
expiresAt =
|
|
103
|
+
input.expiresAt ??
|
|
104
|
+
(prepared.expiresAt === undefined || prepared.expiresAt === null ? prepared.expiresAt : instant.parse(prepared.expiresAt));
|
|
105
|
+
}
|
|
106
|
+
// Ask the provider before touching the store (#605). Deliberately here, ahead of every
|
|
107
|
+
// write below: a refused rotation must leave the live credential exactly as it was.
|
|
108
|
+
const probe = await options.probeCandidate?.(input.provider, secret);
|
|
109
|
+
if (probe && !probe.ok && probe.refused) {
|
|
110
|
+
throw new ConnectionRelayError(probe.error ?? `${input.provider} refused these credentials`, 422, probe);
|
|
111
|
+
}
|
|
94
112
|
let id;
|
|
95
113
|
let created;
|
|
96
114
|
if (live.length === 0) {
|
|
@@ -103,9 +121,10 @@ export async function relayConnectionUpsert(host, actor, body, options = {}) {
|
|
|
103
121
|
label: input.label ?? input.provider,
|
|
104
122
|
externalAccountRef: input.externalAccountRef,
|
|
105
123
|
scopes: input.scopes,
|
|
106
|
-
// Whatever the caller supplies
|
|
107
|
-
|
|
108
|
-
|
|
124
|
+
// Whatever the caller supplies, else what the provider's own preparation knows (#2100: a
|
|
125
|
+
// generated certificate's end). Undefined reads "not reported" (connections.md §3.6).
|
|
126
|
+
expiresAt: expiresAt ?? undefined,
|
|
127
|
+
secret,
|
|
109
128
|
// §3.5.1 — the authorizing tenant principal, proven by the vertical's own
|
|
110
129
|
// `ctx.check` before the secret ever left the operation.
|
|
111
130
|
createdBy: input.createdBy,
|
|
@@ -114,7 +133,7 @@ export async function relayConnectionUpsert(host, actor, body, options = {}) {
|
|
|
114
133
|
}
|
|
115
134
|
else if (live.length === 1) {
|
|
116
135
|
id = live[0].id;
|
|
117
|
-
await host.admin.updateConnectionSecret(actor, id,
|
|
136
|
+
await host.admin.updateConnectionSecret(actor, id, secret, expiresAt, {
|
|
118
137
|
rotatedBy: input.createdBy,
|
|
119
138
|
});
|
|
120
139
|
created = false;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connection-relay.js","sourceRoot":"","sources":["../src/connection-relay.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,IAAI,EAAkB,MAAM,sBAAsB,CAAC;AAC5E,OAAO,EACL,YAAY,EACZ,sBAAsB,GAIvB,MAAM,yBAAyB,CAAC;AAEjC;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,OAAO,oBAAqB,SAAQ,KAAK;IASlC,MAAM;IAEN,KAAK;IAVhB,YACE,OAAe;IACf;;;;;OAKG;IACM,MAAyC;IAClD,uFAAuF;IAC9E,KAAuB;QAEhC,KAAK,CAAC,OAAO,CAAC,CAAC;sBAJN,MAAM;qBAEN,KAAK;QAGd,IAAI,CAAC,IAAI,GAAG,sBAAsB,CAAC;IACrC,CAAC;CACF;
|
|
1
|
+
{"version":3,"file":"connection-relay.js","sourceRoot":"","sources":["../src/connection-relay.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,IAAI,EAAkB,MAAM,sBAAsB,CAAC;AAC5E,OAAO,EACL,OAAO,EACP,YAAY,EACZ,sBAAsB,GAIvB,MAAM,yBAAyB,CAAC;AAEjC;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,OAAO,oBAAqB,SAAQ,KAAK;IASlC,MAAM;IAEN,KAAK;IAVhB,YACE,OAAe;IACf;;;;;OAKG;IACM,MAAyC;IAClD,uFAAuF;IAC9E,KAAuB;QAEhC,KAAK,CAAC,OAAO,CAAC,CAAC;sBAJN,MAAM;qBAEN,KAAK;QAGd,IAAI,CAAC,IAAI,GAAG,sBAAsB,CAAC;IACrC,CAAC;CACF;AA6CD,oFAAoF;AACpF,MAAM,CAAC,MAAM,2BAA2B,GACtC,4FAA4F,CAAC;AAE/F,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,IAAe,EACf,KAAsB,EACtB,IAAa,EACb,OAAO,GAAkG,EAAE;IAE3G,MAAM,MAAM,GAAG,sBAAsB,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;IACtD,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QACrC,MAAM,IAAI,oBAAoB,CAC5B,qCAAqC,KAAK,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,QAAQ,MAAM,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,gBAAgB,EAAE,EAC1H,GAAG,CACJ,CAAC;IACJ,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC;IAE1B,kFAAkF;IAClF,kFAAkF;IAClF,oFAAoF;IACpF,kFAAkF;IAClF,qFAAqF;IACrF,0EAA0E;IAC1E,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,eAAe,EAAE,CAAC;QAChC,MAAM,IAAI,oBAAoB,CAC5B,wEAAwE;YACtE,4EAA4E;YAC5E,0CAA0C,EAC5C,GAAG,CACJ,CAAC;IACJ,CAAC;IAED,0EAA0E;IAC1E,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,KAAK,EAAE,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;IAClF,IAAI,CAAC,GAAG,EAAE,QAAQ,EAAE,CAAC;QACnB,MAAM,IAAI,oBAAoB,CAAC,6BAA6B,EAAE,GAAG,CAAC,CAAC;IACrE,CAAC;IACD,2FAA2F;IAC3F,yFAAyF;IACzF,0FAA0F;IAC1F,yFAAyF;IACzF,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,EAAE,CAAC;QACzB,MAAM,IAAI,oBAAoB,CAAC,2BAA2B,EAAE,GAAG,CAAC,CAAC;IACnE,CAAC;IACD,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAQ,CAAC;IAE9B,sFAAsF;IACtF,iFAAiF;IACjF,sFAAsF;IACtF,yFAAyF;IACzF,+EAA+E;IAC/E,MAAM,IAAI,GAAG,CACX,MAAM,IAAI,CAAC,KAAK,CAAC,eAAe,CAAC,KAAK,EAAE;QACtC,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,QAAQ;QACR,QAAQ,EAAE,KAAK,CAAC,QAAQ;KACzB,CAAC,CACH,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,kBAAkB,KAAK,CAAC,KAAK,CAAC,kBAAkB,IAAI,IAAI,CAAC,CAAC,CAAC;IAE7E,yFAAyF;IACzF,oCAAoC;IACpC,IAAI,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC;IAC1B,IAAI,SAAS,GAA8B,KAAK,CAAC,SAAS,CAAC;IAC3D,MAAM,OAAO,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IAC3D,IAAI,OAAO,EAAE,CAAC;QACZ,MAAM,QAAQ,GACZ,IAAI,CAAC,MAAM,KAAK,CAAC;YACf,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,KAAK,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,kBAAkB,CAAC,CAAC,EAAE,MAAM;YAC/G,CAAC,CAAC,SAAS,CAAC;QAChB,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,KAAK,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;QACvD,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC;QACzB,4FAA4F;QAC5F,iGAAiG;QACjG,SAAS;YACP,KAAK,CAAC,SAAS;gBACf,CAAC,QAAQ,CAAC,SAAS,KAAK,SAAS,IAAI,QAAQ,CAAC,SAAS,KAAK,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC;IAC/H,CAAC;IAED,uFAAuF;IACvF,oFAAoF;IACpF,MAAM,KAAK,GAAG,MAAM,OAAO,CAAC,cAAc,EAAE,CAAC,KAAK,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACrE,IAAI,KAAK,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC;QACxC,MAAM,IAAI,oBAAoB,CAC5B,KAAK,CAAC,KAAK,IAAI,GAAG,KAAK,CAAC,QAAQ,4BAA4B,EAC5D,GAAG,EACH,KAAK,CACN,CAAC;IACJ,CAAC;IAED,IAAI,EAAE,CAAC;IACP,IAAI,OAAO,CAAC;IACZ,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtB,EAAE,GAAG,YAAY,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;QAChC,MAAM,IAAI,CAAC,KAAK,CAAC,gBAAgB,CAAC,KAAK,EAAE;YACvC,EAAE;YACF,QAAQ,EAAE,KAAK,CAAC,QAAQ;YACxB,QAAQ;YACR,QAAQ,EAAE,KAAK,CAAC,QAAQ;YACxB,KAAK,EAAE,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,QAAQ;YACpC,kBAAkB,EAAE,KAAK,CAAC,kBAAkB;YAC5C,MAAM,EAAE,KAAK,CAAC,MAAM;YACpB,yFAAyF;YACzF,sFAAsF;YACtF,SAAS,EAAE,SAAS,IAAI,SAAS;YACjC,MAAM;YACN,0EAA0E;YAC1E,yDAAyD;YACzD,SAAS,EAAE,KAAK,CAAC,SAAS;SAC3B,CAAC,CAAC;QACH,OAAO,GAAG,IAAI,CAAC;IACjB,CAAC;SAAM,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC7B,EAAE,GAAG,IAAI,CAAC,CAAC,CAAE,CAAC,EAAE,CAAC;QACjB,MAAM,IAAI,CAAC,KAAK,CAAC,sBAAsB,CAAC,KAAK,EAAE,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE;YACpE,SAAS,EAAE,KAAK,CAAC,SAAS;SAC3B,CAAC,CAAC;QACH,OAAO,GAAG,KAAK,CAAC;IAClB,CAAC;SAAM,CAAC;QACN,kFAAkF;QAClF,+EAA+E;QAC/E,wEAAwE;QACxE,MAAM,IAAI,oBAAoB,CAC5B,gBAAgB,IAAI,CAAC,MAAM,UAAU,KAAK,CAAC,QAAQ,+BAA+B,QAAQ,yCAAyC,EACnI,GAAG,CACJ,CAAC;IACJ,CAAC;IAED,KAAK,MAAM,UAAU,IAAI,KAAK,CAAC,MAAM,EAAE,CAAC;QACtC,MAAM,IAAI,CAAC,KAAK,CAAC,iBAAiB,CAAC,KAAK,EAAE;YACxC,YAAY,EAAE,EAAE;YAChB,UAAU;YACV,IAAI,EAAE,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE;YAC1D,SAAS,EAAE,KAAK;SACjB,CAAC,CAAC;IACL,CAAC;IAED,yFAAyF;IACzF,wFAAwF;IACxF,0FAA0F;IAC1F,sFAAsF;IACtF,kEAAkE;IAClE,IAAI,KAAK,EAAE,EAAE;QAAE,MAAM,IAAI,CAAC,KAAK,CAAC,mBAAmB,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;IAEtE,OAAO,EAAE,YAAY,EAAE,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;AAC3F,CAAC"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type SendEmailRequest, type TenantId, type ScopeId } from '@substrat-run/contracts';
|
|
2
|
+
import type { PlatformRequestHandler } from './platform-drain.js';
|
|
3
|
+
/**
|
|
4
|
+
* The drain's handler for `send-email` (#2102): one mail a vertical requested inside an
|
|
5
|
+
* operation, delivered after that operation committed.
|
|
6
|
+
*
|
|
7
|
+
* It routes as the synchronous relay does — no `from` sends as the platform, a `from` sends
|
|
8
|
+
* through the tenant connection whose `MailSender` covers that address — and it retries what a
|
|
9
|
+
* provider deferred: a `429`, a `5xx`, or a failure with no status at all. Any other 4xx is a
|
|
10
|
+
* refusal and is final. Each outcome that ends the request is written into the scope as an
|
|
11
|
+
* event, with the provider's message id when it gives one.
|
|
12
|
+
*
|
|
13
|
+
* **Retries wait.** The drain runs on every kick and every sweep, and a kick follows any
|
|
14
|
+
* operation that requested something — so a throttled mailbox would otherwise be retried as
|
|
15
|
+
* fast as the vertical writes. A failed attempt records the earliest time to try again in the
|
|
16
|
+
* intent's `result` (`Retry-After` when the provider gave one, else a doubling backoff), and a
|
|
17
|
+
* pass before then defers it without trying, which counts no attempt. Within one pass, a
|
|
18
|
+
* sender that answered `429` is not tried again: the rest of its mail defers to the next pass.
|
|
19
|
+
*
|
|
20
|
+
* **At least once.** A send the provider accepted whose settle is then lost is sent again on
|
|
21
|
+
* the next pass. Mail has no idempotency key a provider honours across all of them, so this is
|
|
22
|
+
* the honest guarantee; the alternative — settle before sending — loses mail instead.
|
|
23
|
+
*/
|
|
24
|
+
/** Attempts before a send that keeps failing transiently dead-letters. Well under the drain's ceiling. */
|
|
25
|
+
export declare const MAX_EMAIL_SEND_ATTEMPTS = 10;
|
|
26
|
+
/** The first retry's wait when the provider named none; doubled per attempt. */
|
|
27
|
+
export declare const EMAIL_RETRY_BASE_MS = 30000;
|
|
28
|
+
/** The longest wait between attempts the backoff alone will choose. */
|
|
29
|
+
export declare const EMAIL_RETRY_MAX_MS: number;
|
|
30
|
+
/** What a sender returns. `messageId` when the provider gives one. */
|
|
31
|
+
export interface EmailSent {
|
|
32
|
+
messageId?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface SendEmailDeps {
|
|
35
|
+
/** Does this vertical hold the `emailSender` grant? Read per pass, so a revoke takes effect. */
|
|
36
|
+
mayEmail(vertical: string): Promise<boolean>;
|
|
37
|
+
/**
|
|
38
|
+
* Send as the platform's own address. A refusal throws an error carrying `status` (and
|
|
39
|
+
* `retryAfter` seconds for a throttle), read structurally.
|
|
40
|
+
*/
|
|
41
|
+
sendAsPlatform(mail: Omit<SendEmailRequest, 'from' | 'attachments' | 'about'>): Promise<EmailSent>;
|
|
42
|
+
/**
|
|
43
|
+
* Send as a tenant address, through the connection that covers it. Same error contract; a
|
|
44
|
+
* wrapper that rewords a provider's failure keeps the original as `cause`.
|
|
45
|
+
*/
|
|
46
|
+
sendAsTenant(input: {
|
|
47
|
+
tenantId: TenantId;
|
|
48
|
+
scopeId: ScopeId;
|
|
49
|
+
vertical: string;
|
|
50
|
+
from: string;
|
|
51
|
+
fromName?: string;
|
|
52
|
+
to: string;
|
|
53
|
+
subject: string;
|
|
54
|
+
html: string;
|
|
55
|
+
text: string;
|
|
56
|
+
attachmentIds: string[];
|
|
57
|
+
}): Promise<EmailSent>;
|
|
58
|
+
now?(): Date;
|
|
59
|
+
maxAttempts?: number;
|
|
60
|
+
}
|
|
61
|
+
/** One handler per drain pass: the throttles it learns last only as long as the pass. */
|
|
62
|
+
export declare function sendEmailHandler(deps: SendEmailDeps): PlatformRequestHandler;
|
|
63
|
+
/**
|
|
64
|
+
* The status that decides a retry: the provider's, when a wrapper kept it as `cause`, else the
|
|
65
|
+
* wrapper's own. A tenant send that failed in the provider surfaces as a 502 around the
|
|
66
|
+
* provider's answer — the 502 says "the provider failed", the cause says whether it will again.
|
|
67
|
+
*/
|
|
68
|
+
export declare function sendFailureStatus(e: unknown): number | undefined;
|
|
69
|
+
/** `retryAfter` (seconds) wherever along the cause chain a sender put it; 0 when nobody did. */
|
|
70
|
+
export declare function retryAfterSecondsOf(e: unknown): number;
|
|
71
|
+
//# sourceMappingURL=email-intent.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"email-intent.d.ts","sourceRoot":"","sources":["../src/email-intent.ts"],"names":[],"mappings":"AAAA,OAAO,EAUL,KAAK,gBAAgB,EACrB,KAAK,QAAQ,EACb,KAAK,OAAO,EACb,MAAM,yBAAyB,CAAC;AAEjC,OAAO,KAAK,EAAE,sBAAsB,EAA0B,MAAM,qBAAqB,CAAC;AAG1F;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,0GAA0G;AAC1G,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAC1C,gFAAgF;AAChF,eAAO,MAAM,mBAAmB,QAAS,CAAC;AAC1C,uEAAuE;AACvE,eAAO,MAAM,kBAAkB,QAAc,CAAC;AAE9C,sEAAsE;AACtE,MAAM,WAAW,SAAS;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,aAAa;IAC5B,gGAAgG;IAChG,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC7C;;;OAGG;IACH,cAAc,CAAC,IAAI,EAAE,IAAI,CAAC,gBAAgB,EAAE,MAAM,GAAG,aAAa,GAAG,OAAO,CAAC,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;IACnG;;;OAGG;IACH,YAAY,CAAC,KAAK,EAAE;QAClB,QAAQ,EAAE,QAAQ,CAAC;QACnB,OAAO,EAAE,OAAO,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;QACjB,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,EAAE,EAAE,MAAM,CAAC;QACX,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,aAAa,EAAE,MAAM,EAAE,CAAC;KACzB,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;IACvB,GAAG,CAAC,IAAI,IAAI,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAOD,yFAAyF;AACzF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,aAAa,GAAG,sBAAsB,CAgG5E;AAoDD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAEhE;AAED,gGAAgG;AAChG,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,CAQtD"}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { EMAIL_DEAD_LETTERED, EMAIL_REFUSED, EMAIL_SENT, PLATFORM_REQUEST_ENTITY_TYPE, sendEmailRequest, } from '@substrat-run/contracts';
|
|
2
|
+
import { attributeFailure } from './failure-attribution.js';
|
|
3
|
+
import { isTerminalDispatchFailure, providerErrorStatus } from './provider-error.js';
|
|
4
|
+
/**
|
|
5
|
+
* The drain's handler for `send-email` (#2102): one mail a vertical requested inside an
|
|
6
|
+
* operation, delivered after that operation committed.
|
|
7
|
+
*
|
|
8
|
+
* It routes as the synchronous relay does — no `from` sends as the platform, a `from` sends
|
|
9
|
+
* through the tenant connection whose `MailSender` covers that address — and it retries what a
|
|
10
|
+
* provider deferred: a `429`, a `5xx`, or a failure with no status at all. Any other 4xx is a
|
|
11
|
+
* refusal and is final. Each outcome that ends the request is written into the scope as an
|
|
12
|
+
* event, with the provider's message id when it gives one.
|
|
13
|
+
*
|
|
14
|
+
* **Retries wait.** The drain runs on every kick and every sweep, and a kick follows any
|
|
15
|
+
* operation that requested something — so a throttled mailbox would otherwise be retried as
|
|
16
|
+
* fast as the vertical writes. A failed attempt records the earliest time to try again in the
|
|
17
|
+
* intent's `result` (`Retry-After` when the provider gave one, else a doubling backoff), and a
|
|
18
|
+
* pass before then defers it without trying, which counts no attempt. Within one pass, a
|
|
19
|
+
* sender that answered `429` is not tried again: the rest of its mail defers to the next pass.
|
|
20
|
+
*
|
|
21
|
+
* **At least once.** A send the provider accepted whose settle is then lost is sent again on
|
|
22
|
+
* the next pass. Mail has no idempotency key a provider honours across all of them, so this is
|
|
23
|
+
* the honest guarantee; the alternative — settle before sending — loses mail instead.
|
|
24
|
+
*/
|
|
25
|
+
/** Attempts before a send that keeps failing transiently dead-letters. Well under the drain's ceiling. */
|
|
26
|
+
export const MAX_EMAIL_SEND_ATTEMPTS = 10;
|
|
27
|
+
/** The first retry's wait when the provider named none; doubled per attempt. */
|
|
28
|
+
export const EMAIL_RETRY_BASE_MS = 30_000;
|
|
29
|
+
/** The longest wait between attempts the backoff alone will choose. */
|
|
30
|
+
export const EMAIL_RETRY_MAX_MS = 30 * 60_000;
|
|
31
|
+
/** One handler per drain pass: the throttles it learns last only as long as the pass. */
|
|
32
|
+
export function sendEmailHandler(deps) {
|
|
33
|
+
const throttledUntil = new Map();
|
|
34
|
+
const now = () => deps.now?.() ?? new Date();
|
|
35
|
+
const maxAttempts = deps.maxAttempts ?? MAX_EMAIL_SEND_ATTEMPTS;
|
|
36
|
+
return async (ctx, request) => {
|
|
37
|
+
const attempts = request.attempts + 1;
|
|
38
|
+
const parsed = sendEmailRequest.safeParse(request.payload);
|
|
39
|
+
if (!parsed.success) {
|
|
40
|
+
// Module code goes through `requestEmail`, which parses first; this is a row written some
|
|
41
|
+
// other way. Final: the same bytes fail the same parse on every pass.
|
|
42
|
+
return over(request, EMAIL_REFUSED, null, 'platform', attempts, {
|
|
43
|
+
error: `not sent: the send-email payload is invalid (${parsed.error.issues[0]?.message ?? 'unknown'})`,
|
|
44
|
+
failure: { origin: 'platform', code: 'validation_failed', permission: null },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const mail = parsed.data;
|
|
48
|
+
const about = mail.about ?? null;
|
|
49
|
+
const sender = mail.from === undefined ? 'platform' : 'tenant';
|
|
50
|
+
const waitUntil = retryAtOf(request);
|
|
51
|
+
if (waitUntil !== null && waitUntil > now().getTime())
|
|
52
|
+
return { status: 'deferred' };
|
|
53
|
+
const key = mail.from === undefined ? 'platform' : `tenant:${mail.from.toLowerCase()}`;
|
|
54
|
+
if ((throttledUntil.get(key) ?? 0) > now().getTime())
|
|
55
|
+
return { status: 'deferred' };
|
|
56
|
+
if (!(await deps.mayEmail(ctx.vertical))) {
|
|
57
|
+
return over(request, EMAIL_REFUSED, about, sender, attempts, {
|
|
58
|
+
error: `not sent: vertical '${ctx.vertical}' does not hold the email-sender capability — staff ` +
|
|
59
|
+
'grants it in the console (setVerticalEmailSender)',
|
|
60
|
+
failure: { origin: 'platform', code: 'permission_denied', permission: null },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
let sent;
|
|
64
|
+
try {
|
|
65
|
+
sent =
|
|
66
|
+
mail.from === undefined
|
|
67
|
+
? await deps.sendAsPlatform({
|
|
68
|
+
to: mail.to,
|
|
69
|
+
subject: mail.subject,
|
|
70
|
+
html: mail.html,
|
|
71
|
+
text: mail.text,
|
|
72
|
+
...(mail.fromName ? { fromName: mail.fromName } : {}),
|
|
73
|
+
})
|
|
74
|
+
: await deps.sendAsTenant({
|
|
75
|
+
tenantId: ctx.tenantId,
|
|
76
|
+
scopeId: ctx.scopeId,
|
|
77
|
+
vertical: ctx.vertical,
|
|
78
|
+
from: mail.from,
|
|
79
|
+
...(mail.fromName ? { fromName: mail.fromName } : {}),
|
|
80
|
+
to: mail.to,
|
|
81
|
+
subject: mail.subject,
|
|
82
|
+
html: mail.html,
|
|
83
|
+
text: mail.text,
|
|
84
|
+
attachmentIds: (mail.attachments ?? []).map((a) => a.attachmentId),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
const status = sendFailureStatus(e);
|
|
89
|
+
const failure = attributeFailure(innermost(e));
|
|
90
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
91
|
+
if (status !== undefined && isTerminalDispatchFailure({ status })) {
|
|
92
|
+
return over(request, EMAIL_REFUSED, about, sender, attempts, { error, failure, status });
|
|
93
|
+
}
|
|
94
|
+
if (attempts >= maxAttempts) {
|
|
95
|
+
return over(request, EMAIL_DEAD_LETTERED, about, sender, attempts, {
|
|
96
|
+
error: `gave up after ${attempts} attempts — last error: ${error}`,
|
|
97
|
+
failure,
|
|
98
|
+
status,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const retryAfterMs = retryAfterSecondsOf(e) * 1000;
|
|
102
|
+
if (status === 429)
|
|
103
|
+
throttledUntil.set(key, now().getTime() + Math.max(retryAfterMs, 1));
|
|
104
|
+
const backoff = Math.min(EMAIL_RETRY_BASE_MS * 2 ** (attempts - 1), EMAIL_RETRY_MAX_MS);
|
|
105
|
+
const retry = { retryAt: new Date(now().getTime() + Math.max(retryAfterMs, backoff)).toISOString() };
|
|
106
|
+
return { status: 'pending', result: retry, error, failure };
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
status: 'done',
|
|
110
|
+
result: { sent: true, sender, messageId: sent.messageId ?? null },
|
|
111
|
+
event: {
|
|
112
|
+
type: EMAIL_SENT,
|
|
113
|
+
entity: entityOf(request, about),
|
|
114
|
+
payload: {
|
|
115
|
+
request: request.id,
|
|
116
|
+
about,
|
|
117
|
+
sender,
|
|
118
|
+
messageId: sent.messageId ?? null,
|
|
119
|
+
attempts,
|
|
120
|
+
code: null,
|
|
121
|
+
status: null,
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** A terminal outcome and the event that says so. */
|
|
128
|
+
function over(request, type, about, sender, attempts, why) {
|
|
129
|
+
return {
|
|
130
|
+
status: 'failed',
|
|
131
|
+
error: why.error,
|
|
132
|
+
failure: why.failure,
|
|
133
|
+
event: {
|
|
134
|
+
type,
|
|
135
|
+
entity: entityOf(request, about),
|
|
136
|
+
payload: {
|
|
137
|
+
request: request.id,
|
|
138
|
+
about,
|
|
139
|
+
sender,
|
|
140
|
+
messageId: null,
|
|
141
|
+
attempts,
|
|
142
|
+
code: why.failure.code,
|
|
143
|
+
status: why.status ?? null,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const entityOf = (request, about) => about ?? { entityType: PLATFORM_REQUEST_ENTITY_TYPE, entityId: request.id };
|
|
149
|
+
function retryAtOf(request) {
|
|
150
|
+
const at = request.result?.retryAt;
|
|
151
|
+
if (typeof at !== 'string')
|
|
152
|
+
return null;
|
|
153
|
+
const ms = Date.parse(at);
|
|
154
|
+
return Number.isFinite(ms) ? ms : null;
|
|
155
|
+
}
|
|
156
|
+
/** The error a wrapper kept as `cause`, followed to the bottom: the provider's own, if any. */
|
|
157
|
+
function innermost(e) {
|
|
158
|
+
let current = e;
|
|
159
|
+
for (let depth = 0; depth < 5; depth++) {
|
|
160
|
+
const cause = current?.cause;
|
|
161
|
+
if (cause === undefined || cause === null)
|
|
162
|
+
return current;
|
|
163
|
+
current = cause;
|
|
164
|
+
}
|
|
165
|
+
return current;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The status that decides a retry: the provider's, when a wrapper kept it as `cause`, else the
|
|
169
|
+
* wrapper's own. A tenant send that failed in the provider surfaces as a 502 around the
|
|
170
|
+
* provider's answer — the 502 says "the provider failed", the cause says whether it will again.
|
|
171
|
+
*/
|
|
172
|
+
export function sendFailureStatus(e) {
|
|
173
|
+
return providerErrorStatus(innermost(e)) ?? providerErrorStatus(e);
|
|
174
|
+
}
|
|
175
|
+
/** `retryAfter` (seconds) wherever along the cause chain a sender put it; 0 when nobody did. */
|
|
176
|
+
export function retryAfterSecondsOf(e) {
|
|
177
|
+
let current = e;
|
|
178
|
+
for (let depth = 0; depth < 6 && current !== null && typeof current === 'object'; depth++) {
|
|
179
|
+
const value = current.retryAfter;
|
|
180
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0)
|
|
181
|
+
return value;
|
|
182
|
+
current = current.cause;
|
|
183
|
+
}
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=email-intent.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"email-intent.js","sourceRoot":"","sources":["../src/email-intent.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,aAAa,EACb,UAAU,EACV,4BAA4B,EAC5B,gBAAgB,GAQjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAE5D,OAAO,EAAE,yBAAyB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAErF;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,0GAA0G;AAC1G,MAAM,CAAC,MAAM,uBAAuB,GAAG,EAAE,CAAC;AAC1C,gFAAgF;AAChF,MAAM,CAAC,MAAM,mBAAmB,GAAG,MAAM,CAAC;AAC1C,uEAAuE;AACvE,MAAM,CAAC,MAAM,kBAAkB,GAAG,EAAE,GAAG,MAAM,CAAC;AAwC9C,yFAAyF;AACzF,MAAM,UAAU,gBAAgB,CAAC,IAAmB;IAClD,MAAM,cAAc,GAAG,IAAI,GAAG,EAAkB,CAAC;IACjD,MAAM,GAAG,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,IAAI,IAAI,IAAI,EAAE,CAAC;IAC7C,MAAM,WAAW,GAAG,IAAI,CAAC,WAAW,IAAI,uBAAuB,CAAC;IAEhE,OAAO,KAAK,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE;QAC5B,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;QACtC,MAAM,MAAM,GAAG,gBAAgB,CAAC,SAAS,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAC3D,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;YACpB,0FAA0F;YAC1F,sEAAsE;YACtE,OAAO,IAAI,CAAC,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE;gBAC9D,KAAK,EAAE,gDAAgD,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,OAAO,IAAI,SAAS,GAAG;gBACtG,OAAO,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,mBAAmB,EAAE,UAAU,EAAE,IAAI,EAAE;aAC7E,CAAC,CAAC;QACL,CAAC;QACD,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC;QACzB,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC;QACjC,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC;QAE/D,MAAM,SAAS,GAAG,SAAS,CAAC,OAAO,CAAC,CAAC;QACrC,IAAI,SAAS,KAAK,IAAI,IAAI,SAAS,GAAG,GAAG,EAAE,CAAC,OAAO,EAAE;YAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;QACrF,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,CAAC;QACvF,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,GAAG,GAAG,EAAE,CAAC,OAAO,EAAE;YAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;QAEpF,IAAI,CAAC,CAAC,MAAM,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC;YACzC,OAAO,IAAI,CAAC,OAAO,EAAE,aAAa,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE;gBAC3D,KAAK,EACH,uBAAuB,GAAG,CAAC,QAAQ,sDAAsD;oBACzF,mDAAmD;gBACrD,OAAO,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,mBAAmB,EAAE,UAAU,EAAE,IAAI,EAAE;aAC7E,CAAC,CAAC;QACL,CAAC;QAED,IAAI,IAAe,CAAC;QACpB,IAAI,CAAC;YACH,IAAI;gBACF,IAAI,CAAC,IAAI,KAAK,SAAS;oBACrB,CAAC,CAAC,MAAM,IAAI,CAAC,cAAc,CAAC;wBACxB,EAAE,EAAE,IAAI,CAAC,EAAE;wBACX,OAAO,EAAE,IAAI,CAAC,OAAO;wBACrB,IAAI,EAAE,IAAI,CAAC,IAAI;wBACf,IAAI,EAAE,IAAI,CAAC,IAAI;wBACf,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;qBACtD,CAAC;oBACJ,CAAC,CAAC,MAAM,IAAI,CAAC,YAAY,CAAC;wBACtB,QAAQ,EAAE,GAAG,CAAC,QAAQ;wBACtB,OAAO,EAAE,GAAG,CAAC,OAAO;wBACpB,QAAQ,EAAE,GAAG,CAAC,QAAQ;wBACtB,IAAI,EAAE,IAAI,CAAC,IAAI;wBACf,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;wBACrD,EAAE,EAAE,IAAI,CAAC,EAAE;wBACX,OAAO,EAAE,IAAI,CAAC,OAAO;wBACrB,IAAI,EAAE,IAAI,CAAC,IAAI;wBACf,IAAI,EAAE,IAAI,CAAC,IAAI;wBACf,aAAa,EAAE,CAAC,IAAI,CAAC,WAAW,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,YAAY,CAAC;qBACnE,CAAC,CAAC;QACX,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,MAAM,MAAM,GAAG,iBAAiB,CAAC,CAAC,CAAC,CAAC;YACpC,MAAM,OAAO,GAAG,gBAAgB,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;YAC/C,MAAM,KAAK,GAAG,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;YACzD,IAAI,MAAM,KAAK,SAAS,IAAI,yBAAyB,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;gBAClE,OAAO,IAAI,CAAC,OAAO,EAAE,aAAa,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC;YAC3F,CAAC;YACD,IAAI,QAAQ,IAAI,WAAW,EAAE,CAAC;gBAC5B,OAAO,IAAI,CAAC,OAAO,EAAE,mBAAmB,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE;oBACjE,KAAK,EAAE,iBAAiB,QAAQ,2BAA2B,KAAK,EAAE;oBAClE,OAAO;oBACP,MAAM;iBACP,CAAC,CAAC;YACL,CAAC;YACD,MAAM,YAAY,GAAG,mBAAmB,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;YACnD,IAAI,MAAM,KAAK,GAAG;gBAAE,cAAc,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,CAAC;YACzF,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,mBAAmB,GAAG,CAAC,IAAI,CAAC,QAAQ,GAAG,CAAC,CAAC,EAAE,kBAAkB,CAAC,CAAC;YACxF,MAAM,KAAK,GAAe,EAAE,OAAO,EAAE,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC;YACjH,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC;QAC9D,CAAC;QAED,OAAO;YACL,MAAM,EAAE,MAAM;YACd,MAAM,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,IAAI,IAAI,EAAE;YACjE,KAAK,EAAE;gBACL,IAAI,EAAE,UAAU;gBAChB,MAAM,EAAE,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC;gBAChC,OAAO,EAAE;oBACP,OAAO,EAAE,OAAO,CAAC,EAAE;oBACnB,KAAK;oBACL,MAAM;oBACN,SAAS,EAAE,IAAI,CAAC,SAAS,IAAI,IAAI;oBACjC,QAAQ;oBACR,IAAI,EAAE,IAAI;oBACV,MAAM,EAAE,IAAI;iBACb;aACF;SACF,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC;AAED,qDAAqD;AACrD,SAAS,IAAI,CACX,OAAwB,EACxB,IAAkD,EAClD,KAAuB,EACvB,MAA6B,EAC7B,QAAgB,EAChB,GAAoF;IAEpF,OAAO;QACL,MAAM,EAAE,QAAQ;QAChB,KAAK,EAAE,GAAG,CAAC,KAAK;QAChB,OAAO,EAAE,GAAG,CAAC,OAAO;QACpB,KAAK,EAAE;YACL,IAAI;YACJ,MAAM,EAAE,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC;YAChC,OAAO,EAAE;gBACP,OAAO,EAAE,OAAO,CAAC,EAAE;gBACnB,KAAK;gBACL,MAAM;gBACN,SAAS,EAAE,IAAI;gBACf,QAAQ;gBACR,IAAI,EAAE,GAAG,CAAC,OAAO,CAAC,IAAI;gBACtB,MAAM,EAAE,GAAG,CAAC,MAAM,IAAI,IAAI;aAC3B;SACF;KACF,CAAC;AACJ,CAAC;AAED,MAAM,QAAQ,GAAG,CAAC,OAAwB,EAAE,KAAuB,EAAa,EAAE,CAChF,KAAK,IAAI,EAAE,UAAU,EAAE,4BAA4B,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC;AAE9E,SAAS,SAAS,CAAC,OAAwB;IACzC,MAAM,EAAE,GAAI,OAAO,CAAC,MAAiD,EAAE,OAAO,CAAC;IAC/E,IAAI,OAAO,EAAE,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IACxC,MAAM,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAC1B,OAAO,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AACzC,CAAC;AAED,+FAA+F;AAC/F,SAAS,SAAS,CAAC,CAAU;IAC3B,IAAI,OAAO,GAAG,CAAC,CAAC;IAChB,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC;QACvC,MAAM,KAAK,GAAI,OAAsC,EAAE,KAAK,CAAC;QAC7D,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;YAAE,OAAO,OAAO,CAAC;QAC1D,OAAO,GAAG,KAAK,CAAC;IAClB,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,CAAU;IAC1C,OAAO,mBAAmB,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,mBAAmB,CAAC,CAAC,CAAC,CAAC;AACrE,CAAC;AAED,gGAAgG;AAChG,MAAM,UAAU,mBAAmB,CAAC,CAAU;IAC5C,IAAI,OAAO,GAAG,CAAC,CAAC;IAChB,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,OAAO,KAAK,QAAQ,EAAE,KAAK,EAAE,EAAE,CAAC;QAC1F,MAAM,KAAK,GAAI,OAAoC,CAAC,UAAU,CAAC;QAC/D,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC;YAAE,OAAO,KAAK,CAAC;QACnF,OAAO,GAAI,OAA+B,CAAC,KAAK,CAAC;IACnD,CAAC;IACD,OAAO,CAAC,CAAC;AACX,CAAC"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type FieldCoverageScope } from './field-coverage-tally.js';
|
|
2
|
+
export interface FieldCoverageWindow {
|
|
3
|
+
since: string;
|
|
4
|
+
until: string;
|
|
5
|
+
}
|
|
6
|
+
/** One row is a count of sampled SERIALISATIONS, with its provenance beside the numbers. */
|
|
7
|
+
export interface ObservedFieldRow {
|
|
8
|
+
field: string;
|
|
9
|
+
present: number;
|
|
10
|
+
empty: number;
|
|
11
|
+
absent: number;
|
|
12
|
+
/** Present or null; absent optional fields do not enter this denominator. */
|
|
13
|
+
eligible: number;
|
|
14
|
+
sampleRate: number | null;
|
|
15
|
+
window: FieldCoverageWindow;
|
|
16
|
+
}
|
|
17
|
+
export interface FieldCoverageRead {
|
|
18
|
+
tenantId: string;
|
|
19
|
+
vertical: string;
|
|
20
|
+
versionId: string | null;
|
|
21
|
+
source: 'vertical-asserted';
|
|
22
|
+
window: FieldCoverageWindow;
|
|
23
|
+
/** Empty means the router armed no request in this window. */
|
|
24
|
+
groups: Array<{
|
|
25
|
+
sampleRate: number | null;
|
|
26
|
+
armedRequests: number;
|
|
27
|
+
operations: Array<{
|
|
28
|
+
operation: string;
|
|
29
|
+
responses: number;
|
|
30
|
+
fields: ObservedFieldRow[];
|
|
31
|
+
}>;
|
|
32
|
+
/** Zero present and zero null in an armed sample: a candidate, never a proof of no use. */
|
|
33
|
+
notObservedReturned: Array<{
|
|
34
|
+
operation: string;
|
|
35
|
+
field: string;
|
|
36
|
+
}>;
|
|
37
|
+
refused: number;
|
|
38
|
+
}>;
|
|
39
|
+
}
|
|
40
|
+
/** The only public read shape over the raw report: declared names, counts, and no request detail. */
|
|
41
|
+
export declare function readFieldCoverage(reports: Iterable<unknown>, routerLines: Iterable<unknown>, scope: FieldCoverageScope & {
|
|
42
|
+
versionId?: string;
|
|
43
|
+
}, window: FieldCoverageWindow): FieldCoverageRead;
|
|
44
|
+
//# sourceMappingURL=field-coverage-read.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"field-coverage-read.d.ts","sourceRoot":"","sources":["../src/field-coverage-read.ts"],"names":[],"mappings":"AAAA,OAAO,EAA4B,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE9F,MAAM,WAAW,mBAAmB;IAAG,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AAErE,4FAA4F;AAC5F,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,MAAM,EAAE,mBAAmB,CAAC;CAC7B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,EAAE,mBAAmB,CAAC;IAC5B,MAAM,EAAE,mBAAmB,CAAC;IAC5B,8DAA8D;IAC9D,MAAM,EAAE,KAAK,CAAC;QACZ,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;QAC1B,aAAa,EAAE,MAAM,CAAC;QACtB,UAAU,EAAE,KAAK,CAAC;YAAE,SAAS,EAAE,MAAM,CAAC;YAAC,SAAS,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,gBAAgB,EAAE,CAAA;SAAE,CAAC,CAAC;QACxF,2FAA2F;QAC3F,mBAAmB,EAAE,KAAK,CAAC;YAAE,SAAS,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QACjE,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC,CAAC;CACJ;AAED,qGAAqG;AACrG,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,QAAQ,CAAC,OAAO,CAAC,EAC1B,WAAW,EAAE,QAAQ,CAAC,OAAO,CAAC,EAC9B,KAAK,EAAE,kBAAkB,GAAG;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,EAClD,MAAM,EAAE,mBAAmB,GAC1B,iBAAiB,CA0CnB"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { tallyFieldCoverageByRate } from './field-coverage-tally.js';
|
|
2
|
+
/** The only public read shape over the raw report: declared names, counts, and no request detail. */
|
|
3
|
+
export function readFieldCoverage(reports, routerLines, scope, window) {
|
|
4
|
+
const groups = tallyFieldCoverageByRate(reports, routerLines, scope).map((group) => {
|
|
5
|
+
const byOperation = new Map(group.operations.map((op) => [op.operation, op]));
|
|
6
|
+
const declared = scope.declared ?? {};
|
|
7
|
+
const operations = Object.entries(declared).map(([operation, fields]) => {
|
|
8
|
+
const observed = byOperation.get(operation);
|
|
9
|
+
const byField = new Map(observed?.fields.map((f) => [f.field, f]) ?? []);
|
|
10
|
+
return {
|
|
11
|
+
operation,
|
|
12
|
+
responses: observed?.responses ?? 0,
|
|
13
|
+
fields: fields.map((field) => {
|
|
14
|
+
const count = byField.get(field);
|
|
15
|
+
const present = count?.present ?? 0;
|
|
16
|
+
const empty = count?.empty ?? 0;
|
|
17
|
+
return {
|
|
18
|
+
field, present, empty, absent: count?.absent ?? 0,
|
|
19
|
+
eligible: present + empty,
|
|
20
|
+
sampleRate: group.sampleRate,
|
|
21
|
+
window,
|
|
22
|
+
};
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
sampleRate: group.sampleRate,
|
|
28
|
+
armedRequests: group.armedRequests,
|
|
29
|
+
operations,
|
|
30
|
+
notObservedReturned: operations.flatMap((op) => op.responses > 0
|
|
31
|
+
? op.fields.filter((f) => f.eligible === 0).map((f) => ({ operation: op.operation, field: f.field }))
|
|
32
|
+
: []),
|
|
33
|
+
refused: group.refused,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
tenantId: scope.tenantId,
|
|
38
|
+
vertical: scope.vertical,
|
|
39
|
+
versionId: scope.versionId ?? null,
|
|
40
|
+
source: 'vertical-asserted',
|
|
41
|
+
window,
|
|
42
|
+
groups,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=field-coverage-read.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"field-coverage-read.js","sourceRoot":"","sources":["../src/field-coverage-read.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAA2B,MAAM,2BAA2B,CAAC;AAiC9F,qGAAqG;AACrG,MAAM,UAAU,iBAAiB,CAC/B,OAA0B,EAC1B,WAA8B,EAC9B,KAAkD,EAClD,MAA2B;IAE3B,MAAM,MAAM,GAAG,wBAAwB,CAAC,OAAO,EAAE,WAAW,EAAE,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE;QACjF,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;QAC9E,MAAM,QAAQ,GAAG,KAAK,CAAC,QAAQ,IAAI,EAAE,CAAC;QACtC,MAAM,UAAU,GAAG,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,EAAE,MAAM,CAAC,EAAE,EAAE;YACtE,MAAM,QAAQ,GAAG,WAAW,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;YAC5C,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;YACzE,OAAO;gBACL,SAAS;gBACT,SAAS,EAAE,QAAQ,EAAE,SAAS,IAAI,CAAC;gBACnC,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAoB,EAAE;oBAC7C,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;oBACjC,MAAM,OAAO,GAAG,KAAK,EAAE,OAAO,IAAI,CAAC,CAAC;oBACpC,MAAM,KAAK,GAAG,KAAK,EAAE,KAAK,IAAI,CAAC,CAAC;oBAChC,OAAO;wBACL,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,IAAI,CAAC;wBACjD,QAAQ,EAAE,OAAO,GAAG,KAAK;wBACzB,UAAU,EAAE,KAAK,CAAC,UAAU;wBAC5B,MAAM;qBACP,CAAC;gBACJ,CAAC,CAAC;aACH,CAAC;QACJ,CAAC,CAAC,CAAC;QACH,OAAO;YACL,UAAU,EAAE,KAAK,CAAC,UAAU;YAC5B,aAAa,EAAE,KAAK,CAAC,aAAa;YAClC,UAAU;YACV,mBAAmB,EAAE,UAAU,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,CAC7C,EAAE,CAAC,SAAS,GAAG,CAAC;gBACd,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,SAAS,EAAE,EAAE,CAAC,SAAS,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;gBACrG,CAAC,CAAC,EAAE,CAAC;YACT,OAAO,EAAE,KAAK,CAAC,OAAO;SACvB,CAAC;IACJ,CAAC,CAAC,CAAC;IACH,OAAO;QACL,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,SAAS,EAAE,KAAK,CAAC,SAAS,IAAI,IAAI;QAClC,MAAM,EAAE,mBAAmB;QAC3B,MAAM;QACN,MAAM;KACP,CAAC;AACJ,CAAC"}
|
|
@@ -27,12 +27,22 @@ export interface FieldCoverageTally {
|
|
|
27
27
|
*/
|
|
28
28
|
refused: number;
|
|
29
29
|
}
|
|
30
|
+
/** Counts for router-armed requests at one effective rate. Null means an older router did not record it. */
|
|
31
|
+
export interface RatedFieldCoverageTally extends FieldCoverageTally {
|
|
32
|
+
sampleRate: number | null;
|
|
33
|
+
armedRequests: number;
|
|
34
|
+
}
|
|
30
35
|
/** What the tally is for. Every field is the CALLER's resolution, never a line's. */
|
|
31
36
|
export interface FieldCoverageScope {
|
|
32
37
|
/** The tenant, forced from the principal the way every tenant read forces it. */
|
|
33
38
|
tenantId: string;
|
|
34
39
|
/** The app's vertical slug, as the router resolves it onto its line (`vertical`). */
|
|
35
40
|
vertical: string;
|
|
41
|
+
/**
|
|
42
|
+
* The one installed app (`scopeId` on the router's line), when the read is for a single
|
|
43
|
+
* scope. Omitted, every scope of this vertical in the tenant counts.
|
|
44
|
+
*/
|
|
45
|
+
scopeId?: string;
|
|
36
46
|
/** The app's script family stems. Empty names no script, so nothing counts. */
|
|
37
47
|
services: readonly string[];
|
|
38
48
|
/**
|
|
@@ -55,4 +65,6 @@ export declare const FIELD_COVERAGE_OPERATIONS_MAX = 1000;
|
|
|
55
65
|
* the tally is the thing a view trusts, and a query is one forgotten parameter from fleet-wide.
|
|
56
66
|
*/
|
|
57
67
|
export declare function tallyFieldCoverage(events: Iterable<unknown>, routerEvents: Iterable<unknown>, scope: FieldCoverageScope): FieldCoverageTally;
|
|
68
|
+
/** Partition by the router's own rate before tallying, so a changed rate is never averaged away. */
|
|
69
|
+
export declare function tallyFieldCoverageByRate(events: Iterable<unknown>, routerEvents: Iterable<unknown>, scope: FieldCoverageScope): RatedFieldCoverageTally[];
|
|
58
70
|
//# sourceMappingURL=field-coverage-tally.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"field-coverage-tally.d.ts","sourceRoot":"","sources":["../src/field-coverage-tally.ts"],"names":[],"mappings":"AAuDA,8EAA8E;AAC9E,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,4BAA4B;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,yBAAyB;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,mBAAmB;IACnB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,mFAAmF;AACnF,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,mFAAmF;IACnF,MAAM,EAAE,kBAAkB,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,kBAAkB;IACjC,8FAA8F;IAC9F,QAAQ,EAAE,MAAM,CAAC;IACjB,yBAAyB;IACzB,UAAU,EAAE,sBAAsB,EAAE,CAAC;IACrC;;;;OAIG;IACH,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,qFAAqF;AACrF,MAAM,WAAW,kBAAkB;IACjC,iFAAiF;IACjF,QAAQ,EAAE,MAAM,CAAC;IACjB,qFAAqF;IACrF,QAAQ,EAAE,MAAM,CAAC;IACjB,+EAA+E;IAC/E,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;CACxD;
|
|
1
|
+
{"version":3,"file":"field-coverage-tally.d.ts","sourceRoot":"","sources":["../src/field-coverage-tally.ts"],"names":[],"mappings":"AAuDA,8EAA8E;AAC9E,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,4BAA4B;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,yBAAyB;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,mBAAmB;IACnB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,mFAAmF;AACnF,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,mFAAmF;IACnF,MAAM,EAAE,kBAAkB,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,kBAAkB;IACjC,8FAA8F;IAC9F,QAAQ,EAAE,MAAM,CAAC;IACjB,yBAAyB;IACzB,UAAU,EAAE,sBAAsB,EAAE,CAAC;IACrC;;;;OAIG;IACH,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,4GAA4G;AAC5G,MAAM,WAAW,uBAAwB,SAAQ,kBAAkB;IACjE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,qFAAqF;AACrF,MAAM,WAAW,kBAAkB;IACjC,iFAAiF;IACjF,QAAQ,EAAE,MAAM,CAAC;IACjB,qFAAqF;IACrF,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+EAA+E;IAC/E,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;CACxD;AA0CD;;;GAGG;AACH,eAAO,MAAM,6BAA6B,OAAO,CAAC;AAyBlD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,QAAQ,CAAC,OAAO,CAAC,EACzB,YAAY,EAAE,QAAQ,CAAC,OAAO,CAAC,EAC/B,KAAK,EAAE,kBAAkB,GACxB,kBAAkB,CAqGpB;AAED,oGAAoG;AACpG,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,QAAQ,CAAC,OAAO,CAAC,EACzB,YAAY,EAAE,QAAQ,CAAC,OAAO,CAAC,EAC/B,KAAK,EAAE,kBAAkB,GACxB,uBAAuB,EAAE,CA2B3B"}
|
|
@@ -66,23 +66,33 @@ function serviceOf(event) {
|
|
|
66
66
|
function dispatchIdsFor(routerEvents, scope) {
|
|
67
67
|
const ids = new Set();
|
|
68
68
|
for (const event of routerEvents) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (typeof service !== 'string' || !ROUTER_SCRIPT_NAMES.includes(service))
|
|
73
|
-
continue;
|
|
74
|
-
const source = event['source'];
|
|
75
|
-
if (source === null || typeof source !== 'object')
|
|
76
|
-
continue;
|
|
77
|
-
const line = source;
|
|
78
|
-
if (line['router'] !== 'request' || line['tenantId'] !== scope.tenantId || line['vertical'] !== scope.vertical)
|
|
79
|
-
continue;
|
|
80
|
-
const id = line[FIELD_COVERAGE_ID_FIELD];
|
|
81
|
-
if (typeof id === 'string' && DISPATCH_ID.test(id))
|
|
82
|
-
ids.add(id);
|
|
69
|
+
const vouched = routerDispatch(event, scope);
|
|
70
|
+
if (vouched)
|
|
71
|
+
ids.add(vouched.id);
|
|
83
72
|
}
|
|
84
73
|
return ids;
|
|
85
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* One router request line that vouches for a dispatch in `scope`, or `undefined`. Tenant, app
|
|
77
|
+
* and scope are read off the ROUTER's line only, never off the vertical's report.
|
|
78
|
+
*/
|
|
79
|
+
function routerDispatch(event, scope) {
|
|
80
|
+
if (event === null || typeof event !== 'object')
|
|
81
|
+
return undefined;
|
|
82
|
+
const service = serviceOf(event);
|
|
83
|
+
if (typeof service !== 'string' || !ROUTER_SCRIPT_NAMES.includes(service))
|
|
84
|
+
return undefined;
|
|
85
|
+
const source = event['source'];
|
|
86
|
+
if (source === null || typeof source !== 'object')
|
|
87
|
+
return undefined;
|
|
88
|
+
const line = source;
|
|
89
|
+
if (line['router'] !== 'request' || line['tenantId'] !== scope.tenantId || line['vertical'] !== scope.vertical)
|
|
90
|
+
return undefined;
|
|
91
|
+
if (scope.scopeId !== undefined && line['scopeId'] !== scope.scopeId)
|
|
92
|
+
return undefined;
|
|
93
|
+
const id = line[FIELD_COVERAGE_ID_FIELD];
|
|
94
|
+
return typeof id === 'string' && DISPATCH_ID.test(id) ? { id, line } : undefined;
|
|
95
|
+
}
|
|
86
96
|
/**
|
|
87
97
|
* How many distinct operations one tally keeps. Past it, a report on a new operation is
|
|
88
98
|
* refused: a vertical cannot grow the answer without bound by inventing names.
|
|
@@ -231,4 +241,35 @@ export function tallyFieldCoverage(events, routerEvents, scope) {
|
|
|
231
241
|
refused,
|
|
232
242
|
};
|
|
233
243
|
}
|
|
244
|
+
/** Partition by the router's own rate before tallying, so a changed rate is never averaged away. */
|
|
245
|
+
export function tallyFieldCoverageByRate(events, routerEvents, scope) {
|
|
246
|
+
const reports = [...events];
|
|
247
|
+
const groups = new Map();
|
|
248
|
+
const ids = new Map();
|
|
249
|
+
const assigned = new Set();
|
|
250
|
+
for (const event of routerEvents) {
|
|
251
|
+
const vouched = routerDispatch(event, scope);
|
|
252
|
+
if (!vouched)
|
|
253
|
+
continue;
|
|
254
|
+
const { id, line } = vouched;
|
|
255
|
+
// A duplicate router line cannot assign one response to several rates.
|
|
256
|
+
if (assigned.has(id))
|
|
257
|
+
continue;
|
|
258
|
+
assigned.add(id);
|
|
259
|
+
// The rate comes only from this router line, never from the vertical's report.
|
|
260
|
+
const candidate = line['fieldCoverageRate'];
|
|
261
|
+
const rate = typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0 && candidate <= 1
|
|
262
|
+
? candidate : null;
|
|
263
|
+
const group = groups.get(rate) ?? [];
|
|
264
|
+
group.push(event);
|
|
265
|
+
groups.set(rate, group);
|
|
266
|
+
const known = ids.get(rate) ?? new Set();
|
|
267
|
+
known.add(id);
|
|
268
|
+
ids.set(rate, known);
|
|
269
|
+
}
|
|
270
|
+
return [...groups].map(([sampleRate, vouched]) => {
|
|
271
|
+
const counted = tallyFieldCoverage(reports, vouched, scope);
|
|
272
|
+
return { ...counted, sampleRate, armedRequests: ids.get(sampleRate).size };
|
|
273
|
+
});
|
|
274
|
+
}
|
|
234
275
|
//# sourceMappingURL=field-coverage-tally.js.map
|