@ultimat3/admin 30.8.0 → 31.0.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/CLAUDE.md CHANGED
@@ -46,7 +46,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
46
46
  - **An edit is optimistic** (`row-version.ts`): the form posts `_version` = `rowVersion(row)` (keyed), `adminUpdate(…, { version })` answers `stale` — 409, `admin.error.row-changed`, the form re-drawn over the row as it is now — when the row moved on; a post with no version is stale. An instant posted back as it was drawn (`datetimeInputValue`) is LEFT OUT of the patch (`decodeForm(…, before)`): `datetime-local` holds minutes. A create or update is decided again on the validated values as `subject.input` (update: beside `row`).
47
47
  - **The audit trail is the actor's tenant's**: `/admin/audit` and the history card pass `orgId` to `entries()` when the actor has one (`audit-tenancy.test.ts`).
48
48
  - **A table read by a runtime key is `Object.hasOwn` / a `Map`, never a bare index** — `impliedBy`, the `/_x` counters, `list-filters.ts`' operator table, `renderers.tsx` (`bun run proto-index`).
49
- - **The `/_x` SQL runs in `readOnlyQuery`** (`dev/sql-runner.ts`, `readOnlySql(client)` — what a host wires as `runSql`): read-only transaction, timeout, `DEV_SQL_MAX_ROWS`.
49
+ - **The `/_x` SQL runs in `readOnlyQuery`** (`dev/sql-runner.ts`, `readOnlySql(client)`, a host's `runSql`): read-only txn, timeout, `DEV_SQL_MAX_ROWS`, SELECT-only role.
50
50
  - **One read-only SQL guard, and it is `@ultimat3/mcp`'s** — no local keyword scan; the panel keeps only the emptiness test and the way out (`psql "$DATABASE_URL"`, the developer's own client — `x db` has no `psql`).
51
51
  - **Every admin operation is audited, reads included**, not `/admin`'s counts (a count reads no row). `ListResult` carries its `AuditEntry` on both branches (`entityId: null`); `AdminSearchResult.audit` one entry per resource decided, and a skipped resource (no text field, no repo, a repo that threw) is in `skipped`, never a 500.
52
52
  - **A write's RESULT satisfies the actor's row scope** (`row-scope-write.ts`): a create, and an update over the fields it touches, outside `rows(actor)` is refused before the repo — `admin.error.row-out-of-scope`, audited as a denial. The one predicate evaluator in this package; an ordering over text is refused, never guessed. `row-scope-write.test.ts`.
@@ -95,7 +95,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
95
95
  | `batch.ts` / `batch-queue.ts` / `batch-bar.tsx` / `batch-job.ts` | a batch through the gate / its queued half / the bar and row checkboxes / the `admin.batch` job |
96
96
  | `resource-fields.ts` / `resource-layout.ts` / `related.ts` / `list-columns.tsx` | one column → one field / sections and form groups / related lists / a resource's table columns |
97
97
  | `audit.ts` / `audit-pg.ts` / `json-text.ts` / `audit-request.ts` | the log interface + memory log / the Postgres log / readable JSON for a cell or a diff / the trail's URL ⇄ `AuditRequest`, the history cursor |
98
- | `mcp-tools.ts` / `mcp-scopes.ts` / `mcp.ts` | tool derivation, each tool's scope (pure) / scope → tools for `defineAppMcp`, a token's expanded scopes / transport wiring |
98
+ | `mcp-tools.ts` / `mcp-scopes.ts` / `mcp.ts` / `mcp-dispatch.ts` | tool derivation, each tool's scope (pure) / scope → tools for `defineAppMcp`, a token's scopes / transport / one call |
99
99
  | `test-ctx.ts` | `adminTestCtx({ actor?, granted? })` — the `CrudCtx` a test hands a page or a CRUD call; `staticAuthz` over a grant list, its own audit log. What `x g admin:page`'s emitted render test calls |
100
100
  | `jobs/` | the jobs dashboard: entities, repos, the where translation, actions, labels, resources, the overview and its `/_x` tab |
101
101
  | `batch-matching.ts` / `audit-schema.ts` | a set-based "all matching" / the audit table's DDL, a leaf |
@@ -109,6 +109,6 @@ From the repo root — the seal-key preload is the root's, so `bun test --filter
109
109
  directory fails on the first sealed column:
110
110
 
111
111
  ```bash
112
- bun test packages/admin
112
+ bun test ./packages/admin/
113
113
  bunx tsc --noEmit -p packages/admin/tsconfig.json
114
114
  ```
package/README.md CHANGED
@@ -32,7 +32,7 @@ One panel per file. Each kills one question, and each is available as `--json`
32
32
  | `timeline` | where did the time go? — a server-rendered waterfall of SQL, cache, action, policy spans (one row per span, bar placed by the payload's own `offset`/`width`), time per kind, and the N+1 detector's verdicts with their fix lines; the payload folded under it |
33
33
  | `live` | what does each subscriber receive, and **why** — the matcher's decision trace |
34
34
  | `jobs` | a ring of the recent runs by state (`DonutChart`), then the admin's own jobs overview (`/admin/jobs`'s component) over this process's queue; the payload — queue depth, step traces, retry-from-step target, dead letter — folded under it |
35
- | `db` | psql in a tab (read-only twice: `assertReadOnly` refuses DML, and `readOnlySql(client)` — what a host wires as `runSql` — runs it in a `BEGIN READ ONLY` transaction with a timeout and `DEV_SQL_MAX_ROWS`), schema + drift (`null` unless a host wires the check) |
35
+ | `db` | psql in a tab (read-only twice: `assertReadOnly` refuses DML, and `readOnlySql(client)` — what a host wires as `runSql` — runs it in a `BEGIN READ ONLY` transaction with a timeout, `DEV_SQL_MAX_ROWS` and the SELECT-only role `ensureReadOnlyRole` grants), schema + drift (`null` unless a host wires the check) |
36
36
  | `mail` | caught mail, rendered, per locale, with the locale gaps listed; beside it every declared mail's `preview` rendered per locale (`mailTemplates`, the host's source: `x dev` wires it) |
37
37
  | `cache` | the tag graph — what invalidated what, and which tags are orphans |
38
38
  | `policy` | the permission matrix per actor, every cell carrying its trace |
@@ -330,9 +330,9 @@ import { AdminList, listHref, Widget } from '@ultimat3/admin';
330
330
  <Widget field={field} value={row.total} ctx={ctx} mode="read" />;
331
331
  ```
332
332
 
333
- `Widget` takes the field and the raw row value, not pre-derived props: `widgetProps()` is the
334
- guard (money is minor units, a timestamp has an IANA zone) and the component calls it, so there is
335
- no way to render a cell that skipped it.
333
+ `Widget` takes the field and the raw row value, not pre-derived props: the internal
334
+ `widgetProps()` is the guard (money is minor units, a timestamp has an IANA zone) and the component
335
+ calls it, so there is no way to render a cell that skipped it.
336
336
 
337
337
  ### Mounting it yourself
338
338
 
@@ -522,6 +522,8 @@ Opt-in AI panes, each declaring the scope it needs (`aiPanes({ enable: ['anomaly
522
522
  | `backlog-forecast` | `jobs:read`, `metrics:read` |
523
523
 
524
524
  Panes are off until enabled, and `runAiPane` refuses (never no-ops) without a runner.
525
+ `GatewayAdapter` — the type a host narrows `@ultimat3/ai`'s `Gateway` to an `AiRunner` with — is
526
+ `@ultimat3/admin/adapter`'s, off the barrel.
525
527
 
526
528
  ## Errors
527
529
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/admin",
3
- "version": "30.8.0",
3
+ "version": "31.0.0",
4
4
  "description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "exports": {
27
27
  ".": "./src/index.ts",
28
+ "./adapter": "./src/adapter.ts",
28
29
  "./dev": "./src/dev/index.ts",
29
30
  "./schema": "./src/audit-schema.ts",
30
31
  "./error-groups": "./src/error-groups.ts"
@@ -45,21 +46,21 @@
45
46
  "test": "bun test"
46
47
  },
47
48
  "dependencies": {
48
- "@ultimat3/action": "30.8.0",
49
- "@ultimat3/ai": "30.8.0",
50
- "@ultimat3/cache": "30.8.0",
51
- "@ultimat3/core": "30.8.0",
52
- "@ultimat3/db": "30.8.0",
53
- "@ultimat3/entity": "30.8.0",
54
- "@ultimat3/http": "30.8.0",
55
- "@ultimat3/i18n": "30.8.0",
56
- "@ultimat3/jobs": "30.8.0",
57
- "@ultimat3/mcp": "30.8.0",
58
- "@ultimat3/money": "30.8.0",
59
- "@ultimat3/policy": "30.8.0",
60
- "@ultimat3/query": "30.8.0",
61
- "@ultimat3/render": "30.8.0",
62
- "@ultimat3/schema": "30.8.0",
63
- "@ultimat3/ui": "30.8.0"
49
+ "@ultimat3/action": "31.0.0",
50
+ "@ultimat3/ai": "31.0.0",
51
+ "@ultimat3/cache": "31.0.0",
52
+ "@ultimat3/core": "31.0.0",
53
+ "@ultimat3/db": "31.0.0",
54
+ "@ultimat3/entity": "31.0.0",
55
+ "@ultimat3/http": "31.0.0",
56
+ "@ultimat3/i18n": "31.0.0",
57
+ "@ultimat3/jobs": "31.0.0",
58
+ "@ultimat3/mcp": "31.0.0",
59
+ "@ultimat3/money": "31.0.0",
60
+ "@ultimat3/policy": "31.0.0",
61
+ "@ultimat3/query": "31.0.0",
62
+ "@ultimat3/render": "31.0.0",
63
+ "@ultimat3/schema": "31.0.0",
64
+ "@ultimat3/ui": "31.0.0"
64
65
  }
65
66
  }
package/src/adapter.ts ADDED
@@ -0,0 +1,5 @@
1
+ // `@ultimat3/admin/adapter` — what a host wiring an AI gateway into the admin reads: the shape of
2
+ // the function that narrows `@ultimat3/ai`'s `Gateway` to the runner a pane may call. Never the
3
+ // `.` barrel: an app declares an admin; only the code that hands it a gateway needs this.
4
+
5
+ export type { GatewayAdapter } from './ai-panes';
package/src/admin.ts CHANGED
@@ -105,6 +105,11 @@ export interface DefineAdminInput {
105
105
 
106
106
  export interface AdminApp {
107
107
  readonly basePath: string;
108
+ /**
109
+ * Where "back to the app" goes: `/` as declared; the root a request was served under behind an
110
+ * HTTP base path or a routed locale (`/myapp/es/`) — `adminAppFor`, `localized-app.ts`.
111
+ */
112
+ readonly home?: string;
108
113
  readonly branding: AdminBranding;
109
114
  readonly theme: ThemeAttributes;
110
115
  readonly resources: readonly AdminResource[];
@@ -414,6 +419,7 @@ export function defineAdmin(input: DefineAdminInput): AdminApp {
414
419
 
415
420
  const app: AdminApp = {
416
421
  basePath,
422
+ home: '/',
417
423
  branding,
418
424
  theme: themeAttributes(branding),
419
425
  resources,
package/src/audit-pg.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // every restart, and a row's history that ends at the last deploy is not a history.
4
4
 
5
5
  import { isUltimateError, uuidV7 } from '@ultimat3/core';
6
+ import type { AuditSink } from '@ultimat3/core/adapter';
6
7
  import { type DbClient, db, withTransaction } from '@ultimat3/db';
7
8
  import {
8
9
  AUDIT_READ_OPERATIONS,
@@ -12,7 +13,6 @@ import {
12
13
  type AuditLog,
13
14
  type AuditOutcome,
14
15
  type AuditQuery,
15
- type AuditSink,
16
16
  auditEntry,
17
17
  auditLimit,
18
18
  auditWindow,
@@ -138,7 +138,7 @@ export interface PostgresAuditLogOptions {
138
138
  * read is an event and is always written.
139
139
  */
140
140
  readonly reads?: boolean;
141
- readonly sinks?: readonly AuditSink[];
141
+ readonly sinks?: readonly AuditSink<AuditEntry>[];
142
142
  readonly now?: () => Date;
143
143
  /** Must answer a uuid: the column is one. Omitted, a time-ordered v7. */
144
144
  readonly nextId?: () => string;
package/src/audit.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // there is deliberately no update or delete on this interface.
4
4
 
5
5
  import { canonicalJson, finiteCount, REDACTED } from '@ultimat3/core';
6
+ import type { AuditSink } from '@ultimat3/core/adapter';
6
7
  import type { AdminActor, AdminDecision } from './authz';
7
8
  import { AdminFilterInvalidError } from './errors';
8
9
  import { instantOf } from './list-filters';
@@ -55,11 +56,6 @@ export interface AuditDraft {
55
56
  readonly diff?: readonly AuditFieldDiff[];
56
57
  }
57
58
 
58
- /** Where a copy of every entry goes besides the log's own store: stdout as JSON lines, OTel. */
59
- export interface AuditSink {
60
- write(entry: AuditEntry): Promise<void> | void;
61
- }
62
-
63
59
  /** A position in the trail: the entry a page ended on. Entries are ordered `at` then `id`. */
64
60
  export interface AuditCursor {
65
61
  readonly at: string;
@@ -113,7 +109,11 @@ export interface AuditLog {
113
109
  }
114
110
 
115
111
  export interface AuditLogOptions {
116
- readonly sinks?: readonly AuditSink[];
112
+ /**
113
+ * Where a copy of every entry goes besides the log's own store: stdout as JSON lines, OTel.
114
+ * `@ultimat3/core`'s one `AuditSink`, over this package's entry — there is no second interface.
115
+ */
116
+ readonly sinks?: readonly AuditSink<AuditEntry>[];
117
117
  /** Injected so tests get deterministic timestamps and ids. */
118
118
  readonly now?: () => Date;
119
119
  readonly nextId?: () => string;
@@ -39,23 +39,39 @@ export interface TimelinePanelData {
39
39
  readonly nPlusOne: readonly StatementLoopFact[] | null;
40
40
  }
41
41
 
42
+ /**
43
+ * Depth-first by start time. A span whose parent is not in this recording — work handed off after
44
+ * its trace's early spans were evicted, or a host's own parenting — is drawn at depth 0, as is one
45
+ * caught in a parent cycle: `totalsByKind` counts every span, so the flame must draw every span,
46
+ * and a walk from `null` alone left those out of it without a word.
47
+ */
42
48
  function flatten(trace: RequestTrace): readonly FlameRow[] {
49
+ const ids = new Set(trace.spans.map((span) => span.id));
43
50
  const byParent = new Map<string | null, TimelineSpan[]>();
44
51
  for (const span of trace.spans) {
45
- const bucket = byParent.get(span.parentId) ?? [];
52
+ const parent =
53
+ span.parentId !== null && span.parentId !== span.id && ids.has(span.parentId)
54
+ ? span.parentId
55
+ : null;
56
+ const bucket = byParent.get(parent) ?? [];
46
57
  bucket.push(span);
47
- byParent.set(span.parentId, bucket);
58
+ byParent.set(parent, bucket);
48
59
  }
49
60
  const total = trace.totalMs === 0 ? 1 : trace.totalMs;
50
61
  const rows: FlameRow[] = [];
62
+ const drawn = new Set<TimelineSpan>();
63
+ const byStart = (spans: readonly TimelineSpan[]) =>
64
+ [...spans].sort((a, b) => a.startMs - b.startMs);
51
65
 
52
- const walk = (parentId: string | null, depth: number): void => {
53
- for (const span of (byParent.get(parentId) ?? []).sort((a, b) => a.startMs - b.startMs)) {
54
- rows.push({ span, depth, offset: span.startMs / total, width: span.durationMs / total });
55
- walk(span.id, depth + 1);
56
- }
66
+ const draw = (span: TimelineSpan, depth: number): void => {
67
+ if (drawn.has(span)) return;
68
+ drawn.add(span);
69
+ rows.push({ span, depth, offset: span.startMs / total, width: span.durationMs / total });
70
+ for (const child of byStart(byParent.get(span.id) ?? [])) draw(child, depth + 1);
57
71
  };
58
- walk(null, 0);
72
+ for (const span of byStart(byParent.get(null) ?? [])) draw(span, 0);
73
+ // Only a cycle of two or more spans is left: none of them is anyone's root.
74
+ for (const span of byStart(trace.spans)) draw(span, 0);
59
75
  return rows;
60
76
  }
61
77
 
@@ -1,8 +1,9 @@
1
1
  // The `/_x` DB panel's statement, RUN: inside `readOnlyQuery` — its own `BEGIN READ ONLY`
2
- // transaction, a statement timeout and a row ceiling — never through the app's plain `db.query`.
3
- // A host wires `runSql: readOnlySql(client)`; the guard upstream decides what reaches it.
2
+ // transaction, a statement timeout, a row ceiling and the SELECT-only role — never through the
3
+ // app's plain `db.query`. A host wires `runSql: readOnlySql(client)`; the guard upstream decides
4
+ // what reaches it.
4
5
 
5
- import { type DbClient, readOnlyQuery } from '@ultimat3/db';
6
+ import { type DbClient, ensureReadOnlyRole, readOnlyQuery } from '@ultimat3/db';
6
7
  import type { SqlResult } from './facts';
7
8
 
8
9
  /**
@@ -14,15 +15,30 @@ export const DEV_SQL_MAX_ROWS = 500;
14
15
  /**
15
16
  * The runner a `/_x` host hands the DB panel. `assertReadOnlyQuery` (`panel-db.ts`) is the parse
16
17
  * guard; this is the SERVER saying no: a statement the guard let through that writes, locks or
17
- * runs away still meets a read-only transaction that always rolls back, with the app's own
18
- * credentials and no way to commit. Columns are the first row's keys — the driver hands objects.
18
+ * runs away still meets a read-only transaction that always rolls back, run as the role
19
+ * `ensureReadOnlyRole` grants SELECT and nothing else — the layer MCP's `db.query` already had.
20
+ * The app's own credentials are often a dev superuser, so without it a parse-guard miss met one
21
+ * layer, not two. Columns are the first row's keys — the driver hands objects.
22
+ *
23
+ * The role's DDL runs until it succeeds, then never again for this runner. `ensureReadOnlyRole`
24
+ * never throws: a database that refuses `CREATE ROLE` answers `null`, the read runs without that
25
+ * layer, and the next statement asks again.
19
26
  */
20
27
  export function readOnlySql(client: DbClient): (sql: string) => Promise<SqlResult> {
28
+ let role: Promise<string | null> | undefined;
21
29
  return async (sql) => {
30
+ role ??= ensureReadOnlyRole(client);
31
+ const assumed = await role;
32
+ // `null` is not an answer to keep: a lost race (`x mcp serve` creating the role at the same
33
+ // moment) reads exactly like a refusal, and caching it ran every later statement without the
34
+ // layer. Asked again on the next statement; a role that exists is kept.
35
+ if (assumed === null) role = undefined;
36
+ // After the role: the panel times the statement, not the first call's one-off DDL.
22
37
  const started = performance.now();
23
38
  const { rows } = await readOnlyQuery<Readonly<Record<string, unknown>>>(sql, {
24
39
  client,
25
40
  maxRows: DEV_SQL_MAX_ROWS,
41
+ role: assumed,
26
42
  });
27
43
  const elapsedMs = Math.round(performance.now() - started);
28
44
  const columns = Object.keys(rows[0] ?? {});
@@ -5,6 +5,8 @@
5
5
  // physical columns. And a foreign key is read back from `$describe()`, because the binding
6
6
  // that turns `() => orgs.id` into `"orgs.id"` is private to @ultimat3/entity.
7
7
 
8
+ import { isUltimateError } from '@ultimat3/core';
9
+ import { SchemaUnsupportedError, toWireSchema, type WireJsonSchema } from '@ultimat3/schema';
8
10
  import { AdminFieldUnsupportedError } from './errors';
9
11
  import type { AdminColumnMeta, AdminEntity } from './registry';
10
12
 
@@ -31,6 +33,12 @@ export interface AdminColumnFacts {
31
33
  readonly length?: number;
32
34
  readonly values?: readonly string[];
33
35
  readonly references?: AdminColumnReference;
36
+ /**
37
+ * What a `json()` or `arrayOf()` value is, as JSON Schema: the column's own schema, so an MCP
38
+ * argument is typed by the one judge of the value. `{}` (any) where none can be derived. Absent
39
+ * on every other kind, whose scalar type the field type already says. SQL NULL is `nullable`.
40
+ */
41
+ readonly valueSchema?: WireJsonSchema;
34
42
  }
35
43
 
36
44
  const parseReference = (entity: string, property: string, target: string): AdminColumnReference => {
@@ -61,25 +69,49 @@ const referencesOf = (entity: AdminEntity): ReadonlyMap<string, AdminColumnRefer
61
69
  return out;
62
70
  };
63
71
 
72
+ /**
73
+ * The declared schema as a tool argument, or `{}` when it cannot be said (`X_SCHEMA_UNSUPPORTED`)
74
+ * — permissive, so the entity's own parse still judges the value. Any other throw is a defect.
75
+ */
76
+ const declaredSchema = (schema: unknown): WireJsonSchema => {
77
+ if (schema === undefined) return {};
78
+ try {
79
+ return toWireSchema(schema);
80
+ } catch (error) {
81
+ if (isUltimateError(error) && error.code === SchemaUnsupportedError.code) return {};
82
+ throw error;
83
+ }
84
+ };
85
+
86
+ /** The VALUE's shape; whether the column also takes SQL NULL is `nullable`, beside it. */
87
+ const valueSchemaOf = (meta: AdminColumnMeta): WireJsonSchema | undefined => {
88
+ if (meta.kind !== 'jsonb' && meta.kind !== 'array') return undefined;
89
+ return meta.kind === 'array' ? { type: 'array' } : declaredSchema(meta.schema);
90
+ };
91
+
64
92
  const factsFor = (
65
93
  name: string,
66
94
  meta: AdminColumnMeta,
67
95
  primaryKey: boolean,
68
96
  references: AdminColumnReference | undefined,
69
- ): AdminColumnFacts => ({
70
- name,
71
- kind: meta.kind,
72
- nullable: !meta.notNull,
73
- primaryKey,
74
- unique: meta.unique,
75
- index: meta.index,
76
- // A value the writer never supplies. `.default('free')` is a starting value the operator
77
- // may still change, so it stays writable — only a generated one is read-only.
78
- generated: meta.default?.kind === 'generated' || meta.onUpdate !== undefined,
79
- ...(meta.length === undefined ? {} : { length: meta.length }),
80
- ...(meta.values === undefined ? {} : { values: meta.values }),
81
- ...(references === undefined ? {} : { references }),
82
- });
97
+ ): AdminColumnFacts => {
98
+ const valueSchema = valueSchemaOf(meta);
99
+ return {
100
+ name,
101
+ kind: meta.kind,
102
+ nullable: !meta.notNull,
103
+ primaryKey,
104
+ unique: meta.unique,
105
+ index: meta.index,
106
+ // A value the writer never supplies. `.default('free')` is a starting value the operator
107
+ // may still change, so it stays writable — only a generated one is read-only.
108
+ generated: meta.default?.kind === 'generated' || meta.onUpdate !== undefined,
109
+ ...(meta.length === undefined ? {} : { length: meta.length }),
110
+ ...(meta.values === undefined ? {} : { values: meta.values }),
111
+ ...(references === undefined ? {} : { references }),
112
+ ...(valueSchema === undefined ? {} : { valueSchema }),
113
+ };
114
+ };
83
115
 
84
116
  const factsOf = (entity: AdminEntity, sealed: boolean): readonly AdminColumnFacts[] => {
85
117
  const references = referencesOf(entity);
@@ -7,6 +7,7 @@ import {
7
7
  errorFingerprint,
8
8
  finiteCount,
9
9
  isRedactedKey,
10
+ localErrorSink,
10
11
  logger,
11
12
  REDACTED,
12
13
  renderThrowable,
@@ -158,7 +159,9 @@ export function errorGroupsSink(options: ErrorGroupsSinkOptions): ErrorGroupsSin
158
159
  }
159
160
  };
160
161
 
161
- return {
162
+ // Local: the groups live in this app's own table, so a boot with `SENTRY_DSN` still adds its
163
+ // transport beside this sink rather than reading it as the app's monitor.
164
+ return localErrorSink({
162
165
  report(report: ErrorReport): void {
163
166
  try {
164
167
  if (options.ignore?.(report) === true) return;
@@ -177,5 +180,5 @@ export function errorGroupsSink(options: ErrorGroupsSinkOptions): ErrorGroupsSin
177
180
  async settled(): Promise<void> {
178
181
  while (pending.size > 0) await Promise.all([...pending]);
179
182
  },
180
- };
183
+ });
181
184
  }
@@ -113,6 +113,7 @@ export function memoryErrorGroupsStore(): ErrorGroupsStore {
113
113
  ...seen,
114
114
  message: occurrence.message,
115
115
  status: statusAfterOccurrence(seen.status),
116
+ // biome-ignore lint/complexity/useMathMinMax: ISO-8601 STRINGS, compared as text — Math.max would return NaN.
116
117
  lastSeen: at > seen.lastSeen ? at : seen.lastSeen,
117
118
  count: seen.count + occurrence.count,
118
119
  sample: occurrence.sample,