@ultimat3/admin 29.4.0 → 30.1.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/src/dev/data.ts CHANGED
@@ -22,6 +22,7 @@ import type {
22
22
  LiveQueryFact,
23
23
  LiveSubscriberFact,
24
24
  MailFact,
25
+ MailTemplateFact,
25
26
  ManifestFact,
26
27
  PolicyFact,
27
28
  QueueFact,
@@ -54,6 +55,7 @@ export function staticDevSources(facts: Partial<DevSources> = {}): DevSources {
54
55
  runSql:
55
56
  facts.runSql ?? ((): Promise<SqlResult> => empty({ columns: [], rows: [], elapsedMs: 0 })),
56
57
  mail: facts.mail ?? ((): Promise<readonly MailFact[]> => empty([])),
58
+ mailTemplates: facts.mailTemplates ?? ((): Promise<readonly MailTemplateFact[]> => empty([])),
57
59
  cacheGraph: facts.cacheGraph ?? ((): Promise<readonly CacheEdgeFact[]> => empty([])),
58
60
  invalidations: facts.invalidations ?? ((): Promise<readonly InvalidationFact[]> => empty([])),
59
61
  policyMatrix: facts.policyMatrix ?? ((): Promise<readonly PolicyFact[]> => empty([])),
@@ -122,6 +124,10 @@ const STEP_STATUS: Readonly<Record<StepStatus, JobStepFact['status']>> = {
122
124
  sleeping: 'sleeping',
123
125
  waiting: 'pending',
124
126
  failed: 'failed',
127
+ // A `once` step whose `fn` began and recorded no outcome: in flight, or unknown until an operator
128
+ // says so with `x jobs resolve-step`. `cleared` is that operator's "it did not run": it runs again.
129
+ started: 'running',
130
+ cleared: 'pending',
125
131
  };
126
132
 
127
133
  /**
@@ -335,6 +341,9 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
335
341
 
336
342
  runSql: unwired<SqlResult>('runSql', 'db'),
337
343
  mail: unwired<readonly MailFact[]>('mail', 'mail'),
344
+ // The mail registry and the app's locales are the host's: `x dev` renders each declared
345
+ // `preview` through `@ultimat3/mail`, which this package does not depend on.
346
+ mailTemplates: unwired<readonly MailTemplateFact[]>('mailTemplates', 'mail'),
338
347
 
339
348
  /**
340
349
  * The tag graph is read through `dependentsOf`, one entity tag at a time: the cache owns
package/src/dev/facts.ts CHANGED
@@ -225,6 +225,30 @@ export interface MailFact {
225
225
  readonly sentAt: string;
226
226
  }
227
227
 
228
+ /** One locale's render of a declared mail's `preview`, or the instruction its schema refused with. */
229
+ export type MailTemplatePreviewFact =
230
+ | {
231
+ readonly locale: string;
232
+ readonly ok: true;
233
+ readonly subject: string;
234
+ readonly html: string;
235
+ readonly text: string;
236
+ /** Catalog keys this locale could not resolve: `send` would refuse these. */
237
+ readonly missing: readonly string[];
238
+ }
239
+ | {
240
+ readonly locale: string;
241
+ readonly ok: false;
242
+ readonly error: { readonly code: string; readonly cause: string; readonly fix: string };
243
+ };
244
+
245
+ /** A declared mail (`defineMail`) and its preview in every locale; none when it declares none. */
246
+ export interface MailTemplateFact {
247
+ readonly id: string;
248
+ readonly hasPreview: boolean;
249
+ readonly previews: readonly MailTemplatePreviewFact[];
250
+ }
251
+
228
252
  export interface CacheEdgeFact {
229
253
  readonly tag: string;
230
254
  readonly dependents: readonly { readonly kind: string; readonly id: string }[];
@@ -270,6 +294,7 @@ export interface DevSources {
270
294
  drift(): Promise<readonly DriftFact[]>;
271
295
  runSql(sql: string): Promise<SqlResult>;
272
296
  mail(): Promise<readonly MailFact[]>;
297
+ mailTemplates(): Promise<readonly MailTemplateFact[]>;
273
298
  cacheGraph(): Promise<readonly CacheEdgeFact[]>;
274
299
  invalidations(): Promise<readonly InvalidationFact[]>;
275
300
  policyMatrix(): Promise<readonly PolicyFact[]>;
package/src/dev/index.ts CHANGED
@@ -17,6 +17,8 @@ export type {
17
17
  LiveQueryFact,
18
18
  LiveSubscriberFact,
19
19
  MailFact,
20
+ MailTemplateFact,
21
+ MailTemplatePreviewFact,
20
22
  ManifestFact,
21
23
  PolicyFact,
22
24
  QueueFact,
@@ -1,8 +1,8 @@
1
1
  // Panel: Mail.
2
- // Kills: "what did that email actually look like, in that locale?" — every caught message,
3
- // rendered, grouped per locale so a missing translation is visible instead of inferred.
2
+ // Kills: "what did that email actually look like, in that locale?" — every caught message, and
3
+ // every declared template rendered from its `preview` in every locale with no flow triggered.
4
4
 
5
- import type { MailFact } from './facts';
5
+ import type { MailFact, MailTemplateFact } from './facts';
6
6
  import type { DevPanel } from './panel';
7
7
 
8
8
  export interface MailPanelData {
@@ -14,14 +14,30 @@ export interface MailPanelData {
14
14
  readonly subject: string;
15
15
  readonly missing: readonly string[];
16
16
  }[];
17
+ /** Every declared mail, each with its preview rendered per locale (`defineMail({ preview })`). */
18
+ readonly templates: readonly MailTemplateFact[];
19
+ readonly selectedTemplate: MailTemplateFact | null;
20
+ /** A template's catalog keys a locale could not resolve — the gap before anything is sent. */
21
+ readonly templateGaps: readonly {
22
+ readonly id: string;
23
+ readonly locale: string;
24
+ readonly missing: readonly string[];
25
+ }[];
17
26
  }
18
27
 
28
+ /** `?template=` names one; none named is the first; an unknown name is nothing, never the first. */
29
+ const selectedOf = (
30
+ templates: readonly MailTemplateFact[],
31
+ wanted: string | null,
32
+ ): MailTemplateFact | null =>
33
+ (wanted === null ? templates[0] : templates.find((one) => one.id === wanted)) ?? null;
34
+
19
35
  export const mailPanel: DevPanel<MailPanelData> = {
20
36
  key: 'mail',
21
37
  titleKey: 'dev.panel.mail.title',
22
38
  questionKey: 'dev.panel.mail.question',
23
39
  async data(sources, params): Promise<MailPanelData> {
24
- const all = await sources.mail();
40
+ const [all, templates] = await Promise.all([sources.mail(), sources.mailTemplates()]);
25
41
  const locale = params.get('locale');
26
42
  const messages = locale === null ? all : all.filter((mail) => mail.locale === locale);
27
43
  const wanted = params.get('id');
@@ -46,6 +62,15 @@ export const mailPanel: DevPanel<MailPanelData> = {
46
62
  missing: locales.filter((candidate) => !seen.has(candidate)),
47
63
  }))
48
64
  .filter((entry) => entry.missing.length > 0),
65
+ templates,
66
+ selectedTemplate: selectedOf(templates, params.get('template')),
67
+ templateGaps: templates.flatMap((template) =>
68
+ template.previews.flatMap((preview) =>
69
+ preview.ok && preview.missing.length > 0
70
+ ? [{ id: template.id, locale: preview.locale, missing: preview.missing }]
71
+ : [],
72
+ ),
73
+ ),
49
74
  };
50
75
  },
51
76
  };
@@ -0,0 +1,50 @@
1
+ // Single responsibility: `/admin/errors` as `pages:` entries — the list and one group's detail,
2
+ // guarded like every custom page (`admin:read` plus `errors:read`). Data, not a new screen kind:
3
+ // the route table, the nav and the audit of a refusal are the ones `pages.ts` already builds.
4
+
5
+ import type { ErrorGroupsStore } from './error-groups-store';
6
+ import { ErrorsDetail } from './errors-detail';
7
+ import { ErrorsList } from './errors-list';
8
+ import type { AdminCustomPage } from './pages';
9
+
10
+ /** The permission the two pages compose after `admin:read`. */
11
+ export const ERRORS_READ = 'errors:read';
12
+
13
+ export interface ErrorGroupsPagesOptions {
14
+ readonly store: ErrorGroupsStore;
15
+ /** The nav group the list is linked under; omitted, reachable by URL only. */
16
+ readonly navGroup?: string | undefined;
17
+ /** Rooted at the admin's `basePath`. Default `/errors`. */
18
+ readonly path?: string | undefined;
19
+ }
20
+
21
+ export function errorGroupsPages(options: ErrorGroupsPagesOptions): readonly AdminCustomPage[] {
22
+ const path = options.path ?? '/errors';
23
+ return [
24
+ {
25
+ path,
26
+ titleKey: 'admin.errors.title',
27
+ permissions: [ERRORS_READ],
28
+ ...(options.navGroup === undefined ? {} : { navGroup: options.navGroup }),
29
+ component: (props) =>
30
+ ErrorsList({
31
+ store: options.store,
32
+ basePath: new URL(props.url).pathname,
33
+ url: props.url,
34
+ }),
35
+ },
36
+ {
37
+ path: `${path}/:fingerprint`,
38
+ titleKey: 'admin.errors.detail.title',
39
+ permissions: [ERRORS_READ],
40
+ component: (props) => {
41
+ const pathname = new URL(props.url).pathname;
42
+ return ErrorsDetail({
43
+ store: options.store,
44
+ listPath: pathname.slice(0, pathname.lastIndexOf('/')),
45
+ fingerprint: props.params['fingerprint'] ?? '',
46
+ });
47
+ },
48
+ },
49
+ ];
50
+ }
@@ -0,0 +1,163 @@
1
+ // The durable error-group store: one upsert per occurrence on `x_error_groups`, read back newest
2
+ // first. What `errorGroupsSink({ store: postgresErrorGroupsStore() })` names in a deployment with
3
+ // more than one process — the memory twin forgets at every restart and counts per replica.
4
+
5
+ import { isJsonObject } from '@ultimat3/core';
6
+ import { type DbClient, db } from '@ultimat3/db';
7
+ import { ERROR_GROUPS_TABLE } from './error-groups-schema';
8
+ import {
9
+ ERROR_GROUP_STATUSES,
10
+ type ErrorGroup,
11
+ type ErrorGroupSample,
12
+ type ErrorGroupStatus,
13
+ type ErrorGroupsStore,
14
+ errorGroupsLimit,
15
+ } from './error-groups-store';
16
+
17
+ const COLUMNS =
18
+ 'fingerprint, code, source, operation, message, status, first_seen, last_seen, count, sample';
19
+
20
+ /**
21
+ * `xmax = 0` is true only on the row version an INSERT wrote: under a concurrent conflict one
22
+ * session inserts and every other one updates, so exactly one caller reads `inserted`. A resolved
23
+ * group re-opens here, in the same statement, so a regression can never be counted and stay shut.
24
+ */
25
+ export const SQL_ERROR_GROUP_UPSERT = `
26
+ insert into x_error_groups as g (
27
+ fingerprint, code, source, operation, message, status, first_seen, last_seen, count, sample
28
+ ) values ($1, $2, $3, $4, $5, 'open', $6::timestamptz, $6::timestamptz, $7, $8::jsonb)
29
+ on conflict (fingerprint) do update set
30
+ message = excluded.message,
31
+ status = case when g.status = 'resolved' then 'open' else g.status end,
32
+ last_seen = greatest(g.last_seen, excluded.last_seen),
33
+ count = g.count + excluded.count,
34
+ sample = excluded.sample
35
+ returning (xmax = 0) as inserted
36
+ `;
37
+
38
+ interface GroupRow {
39
+ readonly fingerprint: string;
40
+ readonly code: string;
41
+ readonly source: string;
42
+ readonly operation: string | null;
43
+ readonly message: string;
44
+ readonly status: string;
45
+ readonly first_seen: Date | string;
46
+ readonly last_seen: Date | string;
47
+ readonly count: number | string | bigint;
48
+ readonly sample: unknown;
49
+ }
50
+
51
+ const iso = (value: Date | string): string =>
52
+ (value instanceof Date ? value : new Date(value)).toISOString();
53
+
54
+ /** A `jsonb` column, whichever way the driver hands it back: parsed, or as its text. */
55
+ const parsed = (value: unknown): unknown => {
56
+ if (typeof value !== 'string') return value;
57
+ try {
58
+ return JSON.parse(value);
59
+ } catch {
60
+ return null;
61
+ }
62
+ };
63
+
64
+ /** An own string field, or `null`: a stored row is data, never a prototype to read through. */
65
+ const text = (record: Readonly<Record<string, unknown>>, key: string): string | null => {
66
+ const value = Object.hasOwn(record, key) ? record[key] : undefined;
67
+ return typeof value === 'string' ? value : null;
68
+ };
69
+
70
+ /** Written by this file from `ErrorGroupSample`; a hand-edited row reads as what it holds. */
71
+ function sampleOfRow(value: unknown): ErrorGroupSample {
72
+ const record = isJsonObject(value) ? value : {};
73
+ const meta = record['meta'];
74
+ // Spelled apart: the stored instruction is data read back, not a `fix:` this file writes.
75
+ const fix = text(record, 'fix') ?? '';
76
+ return {
77
+ at: text(record, 'at') ?? '',
78
+ severity: text(record, 'severity') ?? 'error',
79
+ title: text(record, 'title') ?? '',
80
+ cause: text(record, 'cause') ?? '',
81
+ fix,
82
+ release: text(record, 'release'),
83
+ environment: text(record, 'environment') ?? '',
84
+ requestId: text(record, 'requestId'),
85
+ traceId: text(record, 'traceId'),
86
+ meta: isJsonObject(meta) ? meta : null,
87
+ stack: text(record, 'stack'),
88
+ };
89
+ }
90
+
91
+ const groupOf = (row: GroupRow): ErrorGroup => ({
92
+ fingerprint: row.fingerprint,
93
+ code: row.code,
94
+ source: row.source,
95
+ operation: row.operation,
96
+ message: row.message,
97
+ status: ERROR_GROUP_STATUSES.find((known) => known === row.status) ?? 'open',
98
+ firstSeen: iso(row.first_seen),
99
+ lastSeen: iso(row.last_seen),
100
+ count: Number(row.count),
101
+ sample: sampleOfRow(parsed(row.sample)),
102
+ });
103
+
104
+ export interface PostgresErrorGroupsStoreOptions {
105
+ /** Omitted, the process's own `db()`. The sink already left any open transaction. */
106
+ readonly client?: DbClient | undefined;
107
+ }
108
+
109
+ export function postgresErrorGroupsStore(
110
+ options: PostgresErrorGroupsStoreOptions = {},
111
+ ): ErrorGroupsStore {
112
+ const client = (): DbClient => options.client ?? db();
113
+ return {
114
+ kind: 'postgres',
115
+ async record(occurrence) {
116
+ const rows = await client().query<{ inserted: boolean }>({
117
+ text: SQL_ERROR_GROUP_UPSERT,
118
+ values: [
119
+ occurrence.fingerprint,
120
+ occurrence.code,
121
+ occurrence.source,
122
+ occurrence.operation,
123
+ occurrence.message,
124
+ occurrence.at.toISOString(),
125
+ occurrence.count,
126
+ JSON.stringify(occurrence.sample),
127
+ ],
128
+ });
129
+ return { isNew: rows[0]?.inserted === true };
130
+ },
131
+ async list(query = {}) {
132
+ const limit = errorGroupsLimit('postgresErrorGroupsStore', query.limit);
133
+ const filtered = query.status !== undefined;
134
+ return (
135
+ await client().query<GroupRow>({
136
+ text: `select ${COLUMNS} from ${ERROR_GROUPS_TABLE}${filtered ? ' where status = $2' : ''} order by last_seen desc, fingerprint limit $1`,
137
+ values: filtered ? [limit, query.status] : [limit],
138
+ })
139
+ ).map(groupOf);
140
+ },
141
+ async get(fingerprint) {
142
+ const rows = await client().query<GroupRow>({
143
+ text: `select ${COLUMNS} from ${ERROR_GROUPS_TABLE} where fingerprint = $1`,
144
+ values: [fingerprint],
145
+ });
146
+ return rows[0] === undefined ? undefined : groupOf(rows[0]);
147
+ },
148
+ async setStatus(fingerprint, status: ErrorGroupStatus) {
149
+ const rows = await client().query<GroupRow>({
150
+ text: `update ${ERROR_GROUPS_TABLE} set status = $2 where fingerprint = $1 returning ${COLUMNS}`,
151
+ values: [fingerprint, status],
152
+ });
153
+ return rows[0] === undefined ? undefined : groupOf(rows[0]);
154
+ },
155
+ async purge(beforeMs) {
156
+ const rows = await client().query<{ removed: number | string }>({
157
+ text: `with gone as (delete from ${ERROR_GROUPS_TABLE} where last_seen < $1::timestamptz returning 1) select count(*)::int as removed from gone`,
158
+ values: [new Date(beforeMs).toISOString()],
159
+ });
160
+ return Number(rows[0]?.removed ?? 0);
161
+ },
162
+ };
163
+ }
@@ -0,0 +1,36 @@
1
+ // The DDL of the error-groups table, and nothing else: a LEAF module, so the boot's
2
+ // `FRAMEWORK_SCHEMA` (`@ultimat3/cli`) can apply it in every role without pulling one screen into
3
+ // a worker's module graph — `audit-schema.ts`'s rule, for the same reason.
4
+
5
+ /** The table. One name, read by this DDL and by `error-groups-pg.ts`'s statements. */
6
+ export const ERROR_GROUPS_TABLE = 'x_error_groups';
7
+
8
+ /**
9
+ * Applied by the boot, never by an app migration — the rule every framework table follows.
10
+ *
11
+ * **One row per fingerprint** (`errorFingerprint`, core): the primary key IS the group, so the
12
+ * upsert's `xmax = 0` answers "this insert made it" for exactly one of N racing replicas.
13
+ *
14
+ * **`sample` is what the sink already redacted** — credentials by key, the stack cut — and never
15
+ * the thrown value. A replayable bundle is `x_failures`', not this table's.
16
+ *
17
+ * **One index**, `(last_seen desc, fingerprint)`: the list screen's order and the purge's range.
18
+ * Retention is the app's: `errorGroupsPurgeTarget(store, { olderThanMs })` has no default.
19
+ */
20
+ export const SQL_ERROR_GROUPS_TABLE = `
21
+ create table if not exists x_error_groups (
22
+ fingerprint text primary key,
23
+ code text not null,
24
+ source text not null,
25
+ operation text,
26
+ message text not null,
27
+ status text not null default 'open',
28
+ first_seen timestamptz not null,
29
+ last_seen timestamptz not null,
30
+ count bigint not null,
31
+ sample jsonb not null
32
+ );
33
+
34
+ create index if not exists x_error_groups_seen_idx
35
+ on x_error_groups (last_seen desc, fingerprint);
36
+ `;
@@ -0,0 +1,181 @@
1
+ // Single responsibility: `errorGroupsSink` — an `ErrorReporter` that folds every report into its
2
+ // `errorFingerprint` group in a store, outside the request's transaction, bounded per minute, and
3
+ // enqueues the app's `onNewGroup` job exactly once per new group. It never re-enters the reporter.
4
+
5
+ import type { Clock, ErrorReport, ErrorReporter } from '@ultimat3/core';
6
+ import {
7
+ errorFingerprint,
8
+ finiteCount,
9
+ isRedactedKey,
10
+ logger,
11
+ REDACTED,
12
+ renderThrowable,
13
+ runToCompletion,
14
+ systemClock,
15
+ } from '@ultimat3/core';
16
+ import { outsideTransaction } from '@ultimat3/db';
17
+ import type { JobHandle } from '@ultimat3/jobs';
18
+ import type {
19
+ ErrorGroupOccurrence,
20
+ ErrorGroupSample,
21
+ ErrorGroupsStore,
22
+ } from './error-groups-store';
23
+
24
+ /** What `onNewGroup` is enqueued with: enough to title an issue, nothing a report did not group on. */
25
+ export interface ErrorGroupNotice {
26
+ readonly fingerprint: string;
27
+ readonly code: string;
28
+ readonly source: string;
29
+ readonly operation: string | null;
30
+ readonly message: string;
31
+ readonly firstSeen: string;
32
+ }
33
+
34
+ export interface ErrorGroupsSinkOptions {
35
+ readonly store: ErrorGroupsStore;
36
+ /** The app's job: which tracker, which title, which assignee are its own (axiom 8). */
37
+ readonly onNewGroup?: Pick<JobHandle<ErrorGroupNotice>, 'name' | 'enqueue'> | undefined;
38
+ /** A report this answers `true` for is not grouped. */
39
+ readonly ignore?: ((report: ErrorReport) => boolean) | undefined;
40
+ /** Store writes per process per minute. Past it, reports are counted and written as one later. */
41
+ readonly perMinute?: number | undefined;
42
+ readonly clock?: Clock | undefined;
43
+ }
44
+
45
+ export interface ErrorGroupsSink extends ErrorReporter {
46
+ /** Resolves when every write started so far has settled. For tests and a drain hook. */
47
+ settled(): Promise<void>;
48
+ }
49
+
50
+ export const ERROR_GROUPS_PER_MINUTE = 120;
51
+ /** The cause's head: a list row, never a page of text. */
52
+ const MESSAGE_HEAD = 200;
53
+ const STACK_MAX = 8_000;
54
+ /** Groups held back while over the cap. Past this many, a report is dropped and that is logged. */
55
+ const DEFERRED_MAX = 1_000;
56
+
57
+ /** Credentials by key, as the log line and the monitor envelope redact them: one rule. */
58
+ function redactedMeta(meta: ErrorReport['meta']): Readonly<Record<string, unknown>> | null {
59
+ if (meta === undefined) return null;
60
+ const out = new Map<string, unknown>();
61
+ for (const [key, value] of Object.entries(meta))
62
+ out.set(key, isRedactedKey(key) ? REDACTED : value);
63
+ return Object.fromEntries(out);
64
+ }
65
+
66
+ export function sampleOf(report: ErrorReport): ErrorGroupSample {
67
+ return {
68
+ at: new Date(report.at).toISOString(),
69
+ severity: report.severity,
70
+ title: report.title,
71
+ cause: report.cause,
72
+ fix: report.fix,
73
+ release: report.release,
74
+ environment: report.environment,
75
+ requestId: report.scope.requestId ?? null,
76
+ traceId: report.scope.traceId ?? null,
77
+ meta: redactedMeta(report.meta),
78
+ stack: report.stack === undefined ? null : report.stack.slice(0, STACK_MAX),
79
+ };
80
+ }
81
+
82
+ function occurrenceOf(report: ErrorReport, count: number): ErrorGroupOccurrence {
83
+ return {
84
+ fingerprint: errorFingerprint(report),
85
+ code: report.code,
86
+ source: report.source,
87
+ operation: report.scope.operation ?? null,
88
+ message: report.cause.split('\n')[0]?.slice(0, MESSAGE_HEAD) ?? '',
89
+ at: new Date(report.at),
90
+ count,
91
+ sample: sampleOf(report),
92
+ };
93
+ }
94
+
95
+ export function errorGroupsSink(options: ErrorGroupsSinkOptions): ErrorGroupsSink {
96
+ const clock = options.clock ?? systemClock;
97
+ const cap = finiteCount(
98
+ 'errorGroupsSink',
99
+ 'perMinute',
100
+ options.perMinute ?? ERROR_GROUPS_PER_MINUTE,
101
+ 1,
102
+ );
103
+ const deferred = new Map<string, ErrorGroupOccurrence>();
104
+ const pending = new Set<Promise<void>>();
105
+ let window = -1;
106
+ let written = 0;
107
+
108
+ /** Its own failure is logged, never reported: a sink that reports itself loops on an outage. */
109
+ const write = (occurrence: ErrorGroupOccurrence): void => {
110
+ const run = outsideTransaction(() =>
111
+ runToCompletion(async () => {
112
+ const { isNew } = await options.store.record(occurrence);
113
+ if (!isNew || options.onNewGroup === undefined) return;
114
+ await options.onNewGroup.enqueue({
115
+ fingerprint: occurrence.fingerprint,
116
+ code: occurrence.code,
117
+ source: occurrence.source,
118
+ operation: occurrence.operation,
119
+ message: occurrence.message,
120
+ firstSeen: occurrence.at.toISOString(),
121
+ });
122
+ }),
123
+ ).catch((failure: unknown) => {
124
+ logger.warn('error group write failed', {
125
+ fingerprint: occurrence.fingerprint,
126
+ code: occurrence.code,
127
+ error: renderThrowable(failure),
128
+ });
129
+ });
130
+ pending.add(run);
131
+ void run.finally(() => pending.delete(run));
132
+ };
133
+
134
+ /** A new minute writes what the last one held back, each group once, before the new report. */
135
+ const turn = (minute: number): void => {
136
+ if (minute === window) return;
137
+ window = minute;
138
+ written = 0;
139
+ const held = [...deferred.values()];
140
+ deferred.clear();
141
+ for (const occurrence of held) {
142
+ written += 1;
143
+ write(occurrence);
144
+ }
145
+ };
146
+
147
+ const hold = (occurrence: ErrorGroupOccurrence): void => {
148
+ const seen = deferred.get(occurrence.fingerprint);
149
+ if (seen !== undefined) {
150
+ deferred.set(occurrence.fingerprint, { ...occurrence, count: seen.count + occurrence.count });
151
+ } else if (deferred.size < DEFERRED_MAX) {
152
+ deferred.set(occurrence.fingerprint, occurrence);
153
+ } else {
154
+ logger.warn('error group dropped over the per-minute cap', {
155
+ fingerprint: occurrence.fingerprint,
156
+ perMinute: cap,
157
+ });
158
+ }
159
+ };
160
+
161
+ return {
162
+ report(report: ErrorReport): void {
163
+ try {
164
+ if (options.ignore?.(report) === true) return;
165
+ turn(Math.floor(clock.now().getTime() / 60_000));
166
+ const occurrence = occurrenceOf(report, 1);
167
+ if (written >= cap) {
168
+ hold(occurrence);
169
+ return;
170
+ }
171
+ written += 1;
172
+ write(occurrence);
173
+ } catch (failure) {
174
+ logger.warn('error group sink failed', { error: renderThrowable(failure) });
175
+ }
176
+ },
177
+ async settled(): Promise<void> {
178
+ while (pending.size > 0) await Promise.all([...pending]);
179
+ },
180
+ };
181
+ }