@cosmicdrift/kumiko-framework 0.336.1 → 0.338.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.
Files changed (116) hide show
  1. package/dist/api/api-constants.d.ts +1 -0
  2. package/dist/api/api-constants.js +9 -0
  3. package/dist/api/auth-middleware.js +45 -21
  4. package/dist/api/auth-routes.d.ts +1 -0
  5. package/dist/api/auth-routes.js +20 -0
  6. package/dist/api/redis-sse-broker.d.ts +1 -0
  7. package/dist/api/redis-sse-broker.js +4 -5
  8. package/dist/api/server.js +14 -4
  9. package/dist/api/sse-route.js +14 -1
  10. package/dist/bun-db/query.js +6 -4
  11. package/dist/compliance/sub-processors.js +10 -1
  12. package/dist/crypto/kek-source.js +1 -0
  13. package/dist/db/blind-index-cleanup.d.ts +1 -0
  14. package/dist/db/blind-index-cleanup.js +12 -1
  15. package/dist/db/event-store-executor-read.js +30 -4
  16. package/dist/db/index.d.ts +1 -1
  17. package/dist/db/index.js +1 -1
  18. package/dist/db/queries/event-consumer.d.ts +2 -2
  19. package/dist/db/queries/event-consumer.js +5 -4
  20. package/dist/db/queries/event-store.js +6 -6
  21. package/dist/db/queries/projection-rebuild.js +4 -4
  22. package/dist/db/queries/seed-context.js +6 -6
  23. package/dist/db/tenant-db.js +16 -8
  24. package/dist/derivatives/derivatives-context.js +5 -2
  25. package/dist/engine/boot-validator/access-declarations.js +13 -7
  26. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  27. package/dist/engine/boot-validator/entity-handler.js +26 -0
  28. package/dist/engine/boot-validator/index.js +2 -1
  29. package/dist/engine/boot-validator/pii-retention.js +11 -1
  30. package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
  31. package/dist/engine/boot-validator/projection-list-screens.js +13 -0
  32. package/dist/engine/boot-validator/screens.js +12 -0
  33. package/dist/engine/build-config-feature-schema.js +4 -0
  34. package/dist/engine/config-helpers.d.ts +2 -1
  35. package/dist/engine/config-helpers.js +1 -0
  36. package/dist/engine/define-feature.js +1 -0
  37. package/dist/engine/extensions/escape-hatch-usage.js +3 -1
  38. package/dist/engine/feature-ast/extractors/hooks.js +7 -1
  39. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  40. package/dist/engine/feature-ast/extractors/index.js +1 -1
  41. package/dist/engine/feature-ast/extractors/jobs-routes.js +6 -2
  42. package/dist/engine/feature-ast/extractors/round3.d.ts +2 -1
  43. package/dist/engine/feature-ast/extractors/round3.js +11 -0
  44. package/dist/engine/feature-ast/extractors/shared.js +1 -3
  45. package/dist/engine/feature-ast/index.d.ts +1 -1
  46. package/dist/engine/feature-ast/parse.js +3 -1
  47. package/dist/engine/feature-ast/patch.d.ts +3 -0
  48. package/dist/engine/feature-ast/patch.js +8 -11
  49. package/dist/engine/feature-ast/patcher.d.ts +6 -1
  50. package/dist/engine/feature-ast/patcher.js +3 -0
  51. package/dist/engine/feature-ast/pattern-change-schema.d.ts +630 -1
  52. package/dist/engine/feature-ast/pattern-change-schema.js +19 -2
  53. package/dist/engine/feature-ast/patterns.d.ts +8 -2
  54. package/dist/engine/feature-ast/patterns.js +1 -0
  55. package/dist/engine/feature-ast/render.js +9 -0
  56. package/dist/engine/feature-builder-state.d.ts +2 -1
  57. package/dist/engine/feature-builder-state.js +1 -0
  58. package/dist/engine/feature-config-events-jobs.d.ts +4 -1
  59. package/dist/engine/feature-config-events-jobs.js +25 -1
  60. package/dist/engine/feature-manifest.d.ts +1 -0
  61. package/dist/engine/feature-manifest.js +1 -0
  62. package/dist/engine/feature-ui-extensions.js +24 -21
  63. package/dist/engine/index.d.ts +1 -1
  64. package/dist/engine/pattern-library/library.js +2 -1
  65. package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
  66. package/dist/engine/pattern-library/static-schemas.js +47 -4
  67. package/dist/engine/registry-facade.js +7 -0
  68. package/dist/engine/registry-ingest.js +7 -0
  69. package/dist/engine/registry-state.d.ts +2 -1
  70. package/dist/engine/registry-state.js +1 -0
  71. package/dist/engine/registry-validate.d.ts +1 -1
  72. package/dist/engine/registry-validate.js +39 -17
  73. package/dist/engine/registry.js +2 -2
  74. package/dist/engine/types/index.d.ts +3 -3
  75. package/dist/engine/types/index.js +1 -1
  76. package/dist/engine/types/screen.d.ts +1 -1
  77. package/dist/engine/types/screen.js +1 -1
  78. package/dist/errors/classes.d.ts +5 -0
  79. package/dist/errors/classes.js +15 -0
  80. package/dist/errors/index.d.ts +1 -1
  81. package/dist/errors/index.js +1 -1
  82. package/dist/i18n/required-surface-keys.js +3 -5
  83. package/dist/jobs/job-runner.js +2 -1
  84. package/dist/migrations/pending-rebuilds.d.ts +2 -0
  85. package/dist/migrations/pending-rebuilds.js +3 -0
  86. package/dist/pipeline/dispatch-query.js +3 -5
  87. package/dist/pipeline/dispatch-shared.js +32 -5
  88. package/dist/pipeline/dispatch-stream.js +2 -4
  89. package/dist/pipeline/dispatch-write.js +2 -4
  90. package/dist/pipeline/event-dispatcher-delivery.d.ts +0 -1
  91. package/dist/pipeline/event-dispatcher-delivery.js +1 -8
  92. package/dist/pipeline/event-dispatcher.js +16 -25
  93. package/dist/pipeline/handler-access-error.d.ts +3 -0
  94. package/dist/pipeline/handler-access-error.js +17 -0
  95. package/dist/pipeline/irreversible-operation-gate.js +9 -3
  96. package/dist/pipeline/member-read-only-transaction.js +8 -3
  97. package/dist/pipeline/system-hooks.js +7 -1
  98. package/dist/pipeline/system-identity-switch.js +11 -8
  99. package/dist/search/meilisearch-adapter.js +0 -2
  100. package/dist/stack/push-entity-projection-tables.js +5 -3
  101. package/dist/stack/redis.d.ts +1 -0
  102. package/dist/stack/redis.js +11 -2
  103. package/dist/stack/table-helpers.d.ts +3 -1
  104. package/dist/stack/table-helpers.js +9 -7
  105. package/dist/stack/test-stack.js +1 -7
  106. package/dist/testing/handler-context.js +3 -6
  107. package/dist/ui-types/index.d.ts +1 -1
  108. package/dist/ui-types/index.js +1 -1
  109. package/dist/upgrade-cli.js +29 -2
  110. package/package.json +4 -4
  111. package/src/changes.json +153 -0
  112. package/src/scripts/codemod/collect-source-files.ts +48 -0
  113. package/src/scripts/codemod/migrate-cross-tenant.ts +3 -23
  114. package/src/scripts/codemod/migrate-db-raw.ts +6 -23
  115. package/src/scripts/codemod/migrate-open-to-all.ts +4 -24
  116. package/src/scripts/codemod/unprocessable-error-details-reason.ts +47 -3
@@ -33,6 +33,7 @@ export declare const Routes: {
33
33
  };
34
34
  export declare const PUBLIC_API_PATHS: ReadonlySet<string>;
35
35
  export declare const NON_PUBLIC_API_PATHS: ReadonlySet<string>;
36
+ export declare function isSessionRequiredApiPath(path: string): boolean;
36
37
  export declare const BODY_LIMIT_OPT_OUT_PATHS: ReadonlySet<string>;
37
38
  export declare const AUTH_RATE_LIMIT_EXEMPT_ROUTES: ReadonlyArray<{
38
39
  readonly method: string;
@@ -87,6 +87,15 @@ export const NON_PUBLIC_API_PATHS = new Set([
87
87
  `/api${Routes.files}`,
88
88
  `/api${Routes.schema}`,
89
89
  ]);
90
+ // Paths that need a session but no tenant: without a token they answer 401
91
+ // before any tenant resolution, so an unresolvable tenant can never turn
92
+ // "not logged in" into a 400 tenant_required.
93
+ const SESSION_REQUIRED_API_PREFIXES = [`/api${Routes.auth}/`];
94
+ const SESSION_REQUIRED_API_EXACT_PATHS = new Set([`/api${Routes.schema}`]);
95
+ export function isSessionRequiredApiPath(path) {
96
+ return (SESSION_REQUIRED_API_EXACT_PATHS.has(path) ||
97
+ SESSION_REQUIRED_API_PREFIXES.some((prefix) => path.startsWith(prefix)));
98
+ }
90
99
  // Opt-out from the default request-body-size cap (registerBodyLimit applies
91
100
  // it to all of /api/* by construction — a new route needs no entry here to
92
101
  // be covered). Only routes with their own, deliberately different size
@@ -2,7 +2,7 @@ import { getCookie } from "hono/cookie";
2
2
  import { TENANT_TEARDOWN_STATUSES } from "../engine/active-membership.js";
3
3
  import { createAnonymousUser } from "../engine/system-user.js";
4
4
  import { parseTenantId } from "../engine/types/identifiers.js";
5
- import { TENANT_COOKIE_NAME, TENANT_HEADER_NAME } from "./api-constants.js";
5
+ import { isSessionRequiredApiPath, TENANT_COOKIE_NAME, TENANT_HEADER_NAME, } from "./api-constants.js";
6
6
  import { isForeignCookieOrigin } from "./origin-middleware.js";
7
7
  const USER_KEY = "pipelineUser";
8
8
  const AUTH_TRANSPORT_KEY = "authTransport";
@@ -33,15 +33,29 @@ function middlewareReject(c, opts) {
33
33
  },
34
34
  }, opts.status);
35
35
  }
36
- // @wrapper-known error-helper
37
- function sessionInvalid(c, reason) {
38
- return middlewareReject(c, {
36
+ function sessionInvalidRejection(reason) {
37
+ return {
39
38
  code: "session_invalid",
40
39
  status: 401,
41
40
  message: `session ${reason}`,
42
41
  i18nKey: "auth.errors.sessionInvalid",
43
42
  details: { reason },
44
- });
43
+ };
44
+ }
45
+ const INVALID_TOKEN_REJECTION = {
46
+ code: "invalid_token",
47
+ status: 401,
48
+ message: "token verification failed",
49
+ i18nKey: "auth.errors.invalidToken",
50
+ };
51
+ // A stale auth cookie (revoked session, rotated signing key) must not lock the
52
+ // browser out of public handlers until the cookie expires, so with
53
+ // anonymousAccess on the request continues anonymously and protected handlers
54
+ // answer 401 unauthenticated in the dispatcher. Bearer clients own their header
55
+ // and keep the explicit reject; /api/auth/* keeps it for the same reason as the
56
+ // missing-token branch.
57
+ function isStaleCookieDowngradable(c, transport, anonymousAccess) {
58
+ return (transport === "cookie" && anonymousAccess !== undefined && !c.req.path.startsWith("/api/auth/"));
45
59
  }
46
60
  // Extract the JWT from either the kumiko_auth cookie (web) or the
47
61
  // Authorization Bearer header (native / server-to-server). The two paths
@@ -95,7 +109,7 @@ export function authMiddleware(jwt, options = {}) {
95
109
  // missing authentication, nicht missing tenant. Daher hier direkt
96
110
  // 401, ohne den anonymous-Tenant-Flow zu durchlaufen.
97
111
  if (anonymousAccess) {
98
- if (c.req.path.startsWith("/api/auth/")) {
112
+ if (isSessionRequiredApiPath(c.req.path)) {
99
113
  return middlewareReject(c, {
100
114
  code: "missing_token",
101
115
  status: 401,
@@ -136,17 +150,20 @@ export function authMiddleware(jwt, options = {}) {
136
150
  return await handleVerifiedBearerUser(c, verifiedUser, next, resolveTenantLifecycleStatus);
137
151
  }
138
152
  }
153
+ const rejectInvalidCredential = async (rejection) => {
154
+ if (isStaleCookieDowngradable(c, transport, anonymousAccess)) {
155
+ return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus, {
156
+ rejectionIfTenantUnresolved: rejection,
157
+ });
158
+ }
159
+ return middlewareReject(c, rejection);
160
+ };
139
161
  let payload;
140
162
  try {
141
163
  payload = await jwt.verify(token);
142
164
  }
143
165
  catch {
144
- return middlewareReject(c, {
145
- code: "invalid_token",
146
- status: 401,
147
- message: "token verification failed",
148
- i18nKey: "auth.errors.invalidToken",
149
- });
166
+ return await rejectInvalidCredential(INVALID_TOKEN_REJECTION);
150
167
  }
151
168
  // Session liveness check — only when both a checker is wired AND the
152
169
  // token carries a sid.
@@ -158,14 +175,14 @@ export function authMiddleware(jwt, options = {}) {
158
175
  const result = await sessionChecker(payload.jti, payload.sub);
159
176
  const status = sessionCheckStatus(result);
160
177
  if (status !== "live") {
161
- return sessionInvalid(c, status);
178
+ return await rejectInvalidCredential(sessionInvalidRejection(status));
162
179
  }
163
180
  if (typeof result === "object") {
164
181
  derivedRoles = result.roles;
165
182
  }
166
183
  }
167
184
  else {
168
- return sessionInvalid(c, "no_sid");
185
+ return await rejectInvalidCredential(sessionInvalidRejection("no_sid"));
169
186
  }
170
187
  }
171
188
  // Tenant-mismatch guard: if the caller sends BOTH a JWT (carrying a
@@ -254,17 +271,21 @@ async function handleVerifiedBearerUser(c, user, next, resolveTenantLifecycleSta
254
271
  // 5. Synthesise the anonymous SessionUser. The transport flag stays unset
255
272
  // so csrf-middleware skips the double-submit check (no auth-cookie ⇒
256
273
  // no CSRF vector to defend against).
257
- async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus) {
274
+ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, staleCredential) {
275
+ // A downgraded stale credential whose anonymous tenant can't be resolved or
276
+ // is torn down answers with the credential's own 401: a tenant error would
277
+ // hide the session end from the client.
278
+ const rejectTenant = (tenantError) => middlewareReject(c, staleCredential?.rejectionIfTenantUnresolved ?? tenantError);
258
279
  // Step 1+2: parse client-supplied tenant. Reject malformed values before
259
280
  // they touch any downstream consumer (DB, cache, audit row).
260
281
  const headerRaw = c.req.header(TENANT_HEADER_NAME);
261
282
  const cookieRaw = getCookie(c, TENANT_COOKIE_NAME);
262
283
  const headerCheck = parseClientTenant(headerRaw, "X-Tenant header");
263
284
  if (headerCheck.error)
264
- return middlewareReject(c, headerCheck.error);
285
+ return rejectTenant(headerCheck.error);
265
286
  const cookieCheck = parseClientTenant(cookieRaw, "kumiko_tenant cookie");
266
287
  if (cookieCheck.error)
267
- return middlewareReject(c, cookieCheck.error);
288
+ return rejectTenant(cookieCheck.error);
268
289
  const clientTenant = headerCheck.tenantId !== null
269
290
  ? { id: headerCheck.tenantId, source: "header" }
270
291
  : cookieCheck.tenantId !== null
@@ -273,12 +294,12 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus) {
273
294
  // Step 3: pick the authoritative tenant.
274
295
  const resolved = await resolveTenant(c, config, clientTenant);
275
296
  if ("error" in resolved)
276
- return middlewareReject(c, resolved.error);
297
+ return rejectTenant(resolved.error);
277
298
  // Step 4: existence check for untrusted sources.
278
299
  if (resolved.source !== "default" && config.tenantExists) {
279
300
  const exists = await config.tenantExists(resolved.tenantId);
280
301
  if (!exists) {
281
- return middlewareReject(c, {
302
+ return rejectTenant({
282
303
  code: "tenant_not_found",
283
304
  status: 404,
284
305
  message: `tenant "${resolved.tenantId}" does not exist`,
@@ -289,8 +310,11 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus) {
289
310
  }
290
311
  // Step 5: synthesise + continue.
291
312
  const lifecycleReject = await rejectIfTenantTeardown(c, resolved.tenantId, resolveTenantLifecycleStatus);
292
- if (lifecycleReject)
293
- return lifecycleReject;
313
+ if (lifecycleReject) {
314
+ return staleCredential
315
+ ? middlewareReject(c, staleCredential.rejectionIfTenantUnresolved)
316
+ : lifecycleReject;
317
+ }
294
318
  c.set(USER_KEY, createAnonymousUser(resolved.tenantId));
295
319
  await next();
296
320
  // skip: anonymous path completed — Hono middleware contract returns void
@@ -19,6 +19,7 @@ export type SessionRevoker = (sid: string) => Promise<void>;
19
19
  export type SessionMassRevoker = (userId: string) => Promise<number>;
20
20
  export type SessionChecker = AuthSessionChecker;
21
21
  export type { AuthSessionStatus };
22
+ export declare function assertSessionCallbacksPaired(auth: Pick<AuthRoutesConfig, "sessionCreator" | "sessionRevoker" | "sessionChecker"> | undefined): void;
22
23
  export type AuthRoutesConfig = {
23
24
  membershipQuery: string;
24
25
  userQuery?: string;
@@ -159,6 +159,26 @@ function switchTenantRejectionResponse(c, reason) {
159
159
  return assertUnreachable(reason, "ActiveMembershipRejection");
160
160
  }
161
161
  }
162
+ // A sid minted by sessionCreator is only revocable when logout can revoke it
163
+ // (sessionRevoker) AND every request re-checks it (sessionChecker). Wiring the
164
+ // first without the other two would make logout look effective while the JWT
165
+ // stays valid until expiry. A checker alone stays allowed: it fails closed.
166
+ export function assertSessionCallbacksPaired(auth) {
167
+ const issuesSessions = auth?.sessionCreator !== undefined || auth?.sessionRevoker !== undefined;
168
+ const missing = issuesSessions
169
+ ? [
170
+ ...(auth?.sessionCreator === undefined ? ["sessionCreator"] : []),
171
+ ...(auth?.sessionRevoker === undefined ? ["sessionRevoker"] : []),
172
+ ...(auth?.sessionChecker === undefined ? ["sessionChecker"] : []),
173
+ ]
174
+ : [];
175
+ if (missing.length > 0) {
176
+ throw new Error(`[kumiko:boot] auth session callbacks are wired partially — missing ${missing.join(", ")}. ` +
177
+ "Server sessions need sessionCreator, sessionRevoker and sessionChecker together, " +
178
+ "otherwise logout leaves the JWT valid until it expires. Wire all three (the sessions " +
179
+ "feature's sessionStore provides them) or none for stateless JWTs.");
180
+ }
181
+ }
162
182
  // Second rate-limit axis for preauth-enable-start: unlike the IP, a
163
183
  // preauthSetupToken isn't freely choosable by the attacker (it's minted by
164
184
  // login.write.ts), so hashing it into a bucket key still caps the replay
@@ -5,6 +5,7 @@ export type RedisSseBrokerOptions = {
5
5
  readonly clientOptions?: RedisClientOptions;
6
6
  };
7
7
  export type RedisSseBroker = SseBroker & {
8
+ readonly kind: "redis";
8
9
  close(): Promise<void>;
9
10
  };
10
11
  export declare function isRedisSseBroker(broker: SseBroker): broker is RedisSseBroker;
@@ -9,12 +9,10 @@ import { createSseBroker, readNonEmptyStringArray, } from "./sse-broker.js";
9
9
  const CHANNEL_PREFIX = "kumiko:sse:ch:";
10
10
  const INVALIDATION_PREFIX = "kumiko:sse:inval:";
11
11
  const PSUBSCRIBE_PATTERN = "kumiko:sse:*";
12
- // The one property RedisSseBroker adds over SseBroker — cheap and reliable
13
- // to narrow on, so callers that only hold an SseBroker (e.g. a test that
14
- // pulled it back out of a generic ServerOptions.sseBroker slot) can tell
15
- // which lifecycle they own without an unsound `as` cast.
12
+ // Narrows on the `kind` discriminant, not on `close`: an app-owned SseBroker
13
+ // may have its own close() and must not be mistaken for the Redis one.
16
14
  export function isRedisSseBroker(broker) {
17
- return "close" in broker;
15
+ return "kind" in broker && broker.kind === "redis";
18
16
  }
19
17
  // Single decision point for "which SseBroker should a caller default to
20
18
  // when it doesn't inject its own": REDIS_URL present → cross-replica
@@ -117,6 +115,7 @@ export function createRedisSseBroker(opts) {
117
115
  }
118
116
  });
119
117
  return {
118
+ kind: "redis",
120
119
  addClient: inner.addClient,
121
120
  removeClient: inner.removeClient,
122
121
  getClientCount: inner.getClientCount,
@@ -23,9 +23,9 @@ import { authEndpointRateLimit, createRateLimitResolver, globalIpRateLimit, } fr
23
23
  import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-config.js";
24
24
  import { assertUnreachable, generateId } from "../utils/index.js";
25
25
  import { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
26
- import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS, Routes } from "./api-constants.js";
26
+ import { NO_ROUTE_MATCH_HEADER_NAME, NON_PUBLIC_API_PATHS, PUBLIC_API_PATHS, Routes, } from "./api-constants.js";
27
27
  import { authMiddleware, getAuthTokenExpiry, getUser, } from "./auth-middleware.js";
28
- import { createAuthRoutes } from "./auth-routes.js";
28
+ import { assertSessionCallbacksPaired, createAuthRoutes, } from "./auth-routes.js";
29
29
  import { assertValidTrustedProxyHops, clientIpSourceFromHonoContext, createClientIpResolver, } from "./client-ip.js";
30
30
  import { csrfMiddleware } from "./csrf-middleware.js";
31
31
  import { ExtraRouteEntries, ExtraRouteRejection, } from "./extra-route.js";
@@ -505,6 +505,7 @@ export function buildServer(options) {
505
505
  // without an allowlist and without an explicit opt-out — that config is the
506
506
  // unguarded-subdomain-XSS footgun, not a warn-and-continue case.
507
507
  assertOriginGuardConfig(options.auth);
508
+ assertSessionCallbacksPaired(options.auth);
508
509
  const allowedOrigins = options.auth?.allowedOrigins;
509
510
  const originGuard = allowedOrigins && allowedOrigins.length > 0 ? originMiddleware(allowedOrigins) : undefined;
510
511
  if (originGuard) {
@@ -527,11 +528,10 @@ export function buildServer(options) {
527
528
  return next();
528
529
  return csrfGuard(c, next);
529
530
  });
530
- // Same order as /api/* above: auth → PAT route scope → PAT rate-limit → origin → CSRF.
531
+ // No PAT rate-limit here: patRouteGuardMiddleware rejects every PAT on httpRoutes before it could count.
531
532
  const sessionOnlyHttpRouteGuards = [
532
533
  sessionOnlyGuard,
533
534
  patRouteGuardMiddleware,
534
- ...(patRateLimitGuard ? [patRateLimitGuard] : []),
535
535
  ...(originGuard ? [originGuard] : []),
536
536
  csrfGuard,
537
537
  ];
@@ -708,6 +708,16 @@ export function buildServer(options) {
708
708
  throw new Error(`[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
709
709
  'use a wildcard under "/api/" — it would bypass the auth chain for every matching path.');
710
710
  }
711
+ // A `:param` segment is as broad as a wildcard, and the public matchers test
712
+ // the bare request path: "/:seg/write" and "/api/:hook" both match /api/write.
713
+ if (route.entry === ExtraRouteEntries.signature) {
714
+ const routePattern = honoPathToRegex(route.path);
715
+ const shadowedFrameworkPath = [...PUBLIC_API_PATHS, ...NON_PUBLIC_API_PATHS].find((path) => routePattern.test(path));
716
+ if (shadowedFrameworkPath !== undefined) {
717
+ throw new Error(`[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
718
+ `match the framework path "${shadowedFrameworkPath}" — it would bypass the auth chain for it.`);
719
+ }
720
+ }
711
721
  }
712
722
  const dispatchSystemWrite = makeDispatchSystemWrite(dispatcher);
713
723
  const dispatchSystemQuery = makeDispatchSystemQuery(dispatcher);
@@ -45,6 +45,16 @@ function toEntitySignalWire(event, data) {
45
45
  createdAt: data["createdAt"],
46
46
  };
47
47
  }
48
+ // The signal covers every row of the entity, including rows the anonymous
49
+ // query hides, so id and version would leak which hidden rows exist and
50
+ // change. Anonymous clients refetch the whole query anyway.
51
+ function toAnonymousEntitySignalWire(event, data) {
52
+ return {
53
+ aggregateType: data.aggregateType,
54
+ eventType: event.type,
55
+ createdAt: data["createdAt"],
56
+ };
57
+ }
48
58
  // The tenant channel fans out to every member, so a frame without an entity
49
59
  // (e.g. channel-in-app:event:delivered) reaches only its addressee; a frame
50
60
  // lacking userId is dropped (fail-closed).
@@ -61,7 +71,10 @@ function decideWireFrame(event, user, options) {
61
71
  // skip: anonymous connections only get signals for entities an anonymous query declares
62
72
  if (!mayReceiveEntitySignal(user, data.aggregateType, options))
63
73
  return undefined;
64
- return { name: data.aggregateType, data: toEntitySignalWire(event, data) };
74
+ const signal = isAnonymousSessionUser(user)
75
+ ? toAnonymousEntitySignalWire(event, data)
76
+ : toEntitySignalWire(event, data);
77
+ return { name: data.aggregateType, data: signal };
65
78
  }
66
79
  // skip: frames without an entity belong to their addressee only
67
80
  if (!isAddressedTo(event, user.id))
@@ -735,10 +735,11 @@ async function unsafeLoggingClosedConnection(db, sqlText, params, context) {
735
735
  throw err;
736
736
  }
737
737
  }
738
+ const DEFAULT_POOL_MAX = 10;
738
739
  function poolMaxOf(raw) {
739
740
  // @cast-boundary driver pool options — both postgres-js and Bun.SQL expose options.max
740
741
  const max = raw.options?.max;
741
- return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : 10;
742
+ return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : DEFAULT_POOL_MAX;
742
743
  }
743
744
  // Exported so raw-SQL query modules outside bun-db (e.g. bundled-features'
744
745
  // db/queries/*.ts) can opt into the same closed-connection retry instead of
@@ -754,9 +755,10 @@ export async function unsafeReadRetrying(db, sqlText, params) {
754
755
  logClosedConnection(raw, err, 1);
755
756
  throw err;
756
757
  }
757
- const maxAttempts = poolMaxOf(raw) + 1;
758
+ // Enough retries to flush every dead pooled connection plus one fresh one; total calls = maxRetries + 1.
759
+ const maxRetries = poolMaxOf(raw) + 1;
758
760
  let lastErr = err;
759
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
761
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
760
762
  try {
761
763
  return (await raw.unsafe(sqlText, params));
762
764
  }
@@ -768,7 +770,7 @@ export async function unsafeReadRetrying(db, sqlText, params) {
768
770
  }
769
771
  }
770
772
  }
771
- logClosedConnection(raw, lastErr, maxAttempts + 1);
773
+ logClosedConnection(raw, lastErr, maxRetries + 1);
772
774
  throw lastErr;
773
775
  }
774
776
  }
@@ -84,13 +84,22 @@ export const KUMIKO_SUB_PROCESSORS = [
84
84
  },
85
85
  {
86
86
  name: "Scaleway SAS",
87
- purpose: "Offsite Backup Mirror (Object Storage); planned: platform key custody (Key Manager) and Transactional Email",
87
+ purpose: "Offsite Backup Mirror (Object Storage)",
88
88
  region: "EU (France)",
89
89
  dpa: "https://www.scaleway.com/en/contracts/",
90
90
  addedAt: "2026-09-18",
91
91
  appliesTo: ["all-tiers"],
92
92
  status: "active",
93
93
  },
94
+ {
95
+ name: "Scaleway SAS",
96
+ purpose: "Platform key custody (Key Manager) and Transactional Email Delivery",
97
+ region: "EU (France)",
98
+ dpa: "https://www.scaleway.com/en/contracts/",
99
+ addedAt: "2026-09-18",
100
+ appliesTo: ["all-tiers"],
101
+ status: "planned",
102
+ },
94
103
  ];
95
104
  /**
96
105
  * Filter helper — nur aktive Sub-Processors (kein status="planned").
@@ -43,6 +43,7 @@ async function decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, lo
43
43
  }
44
44
  if (!response.ok) {
45
45
  if (isRetryableStatus(response.status) && attempt < maxAttempts) {
46
+ await response.body?.cancel();
46
47
  await sleep(RETRY_DELAYS_MS[attempt - 1] ?? 0);
47
48
  continue;
48
49
  }
@@ -3,4 +3,5 @@ import type { DbRunner } from "./connection.js";
3
3
  export declare function nullBlindIndexesForSubject(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, subjectKey: string): Promise<void>;
4
4
  export declare function subjectRowExistsInTenant(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, subjectId: string, tenantId: string): Promise<boolean>;
5
5
  export declare function recordRowExistsInTenant(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, entityName: string, recordId: string, tenantId: string): Promise<boolean>;
6
+ export declare function recordEventsOwnedExclusivelyByTenant(db: DbRunner, aggregateType: string, aggregateId: string, tenantId: string): Promise<boolean>;
6
7
  export declare function recordRowOwningTenantId(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, entityName: string, recordId: string): Promise<string | undefined>;
@@ -55,7 +55,7 @@ export async function nullBlindIndexesForSubject(db, features, subjectKey) {
55
55
  async function existingTableNames(db, tableNames) {
56
56
  if (tableNames.length === 0)
57
57
  return new Set();
58
- const rows = await executeRawQueryRead(db, `SELECT t.name FROM unnest($1::text[]) AS t(name) WHERE to_regclass(quote_ident(t.name)) IS NOT NULL`, [tableNames]);
58
+ const rows = await executeRawQueryRead(db, `SELECT t.name FROM unnest($1::text[]) AS t(name) WHERE to_regclass('public.' || quote_ident(t.name)) IS NOT NULL`, [tableNames]);
59
59
  return new Set(rows.map((r) => r.name));
60
60
  }
61
61
  // Tenant-scope oracle for crypto-shredding's forget-subject (mh#349): a
@@ -131,6 +131,17 @@ export async function recordRowExistsInTenant(db, features, entityName, recordId
131
131
  const rows = await executeRawQueryRead(db, `SELECT 1 FROM ${quoteIdent(tableName)} WHERE id = $1 AND tenant_id = $2 LIMIT 1`, [recordId, tenantId]);
132
132
  return rows.length > 0;
133
133
  }
134
+ // Provenance fallback for record subjects whose projection row is gone (hard
135
+ // delete) or that have no registered entity at all (custom aggregates): the
136
+ // event stream still names the owning tenant. The unique index is per tenant
137
+ // and the subject key is global, so another tenant could open a stream under
138
+ // the same aggregate id; any foreign event voids the proof.
139
+ export async function recordEventsOwnedExclusivelyByTenant(db, aggregateType, aggregateId, tenantId) {
140
+ const rows = await executeRawQueryRead(db, `SELECT 1 FROM "kumiko_events" WHERE aggregate_type = $1 AND aggregate_id = $2::uuid AND tenant_id = $3::uuid
141
+ AND NOT EXISTS (SELECT 1 FROM "kumiko_events" WHERE aggregate_type = $1 AND aggregate_id = $2::uuid AND tenant_id <> $3::uuid)
142
+ LIMIT 1`, [aggregateType, aggregateId, tenantId]);
143
+ return rows.length > 0;
144
+ }
134
145
  // Owning tenant of a record subject's row, for callers that must evaluate
135
146
  // tenant-scoped policy for the row's real owner (not the acting user's tenant).
136
147
  export async function recordRowOwningTenantId(db, features, entityName, recordId) {
@@ -249,11 +249,24 @@ function collectReferenceSearchDescriptors(table, referenceSearch, dbMode) {
249
249
  }
250
250
  return descriptors;
251
251
  }
252
+ // Matches the main list's own default: soft-deleted rows are invisible, so a
253
+ // deleted target must neither match a search nor influence an ordering.
254
+ function isSoftDeletedTargetHidden(targetEntity, targetTable) {
255
+ return targetEntity.softDelete === true && targetTable["isDeleted"] !== undefined;
256
+ }
252
257
  // Tenant-scoped ILIKE lookup against one reference target — never raw/
253
258
  // unscoped SQL, a match in a foreign tenant must never surface a row here
254
259
  // (fw#2660's hard constraint). Mirrors the main table's own tenant filter
255
260
  // (see the `table["tenantId"]` check further down in list()).
256
- async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetTable, targetTableName, db) {
261
+ async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db) {
262
+ // The label must not leak through match counts: a viewer who cannot read the
263
+ // target row or its label field would otherwise probe names by searching.
264
+ const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
265
+ if (labelAccess.kind !== "pass")
266
+ return undefined;
267
+ const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
268
+ if (ownership.kind === "empty")
269
+ return undefined;
257
270
  const labelCol = physicalColumnName(targetTable, descriptor.labelField);
258
271
  const subParams = [`%${escapeLikePattern(searchTerm)}%`];
259
272
  let tenantClause = "";
@@ -261,9 +274,19 @@ async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetTab
261
274
  subParams.push(db.tenantId, SYSTEM_TENANT_ID);
262
275
  tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($2, $3)`;
263
276
  }
277
+ let ownershipClause = "";
278
+ if (ownership.kind === "sql") {
279
+ const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, subParams.length);
280
+ ownershipClause = ` AND ${shifted.sqlText}`;
281
+ for (const p of shifted.params)
282
+ subParams.push(p);
283
+ }
284
+ const deletedClause = isSoftDeletedTargetHidden(targetEntity, targetTable)
285
+ ? ` AND "${physicalColumnName(targetTable, "isDeleted")}" = FALSE`
286
+ : "";
264
287
  // ::text cast covers a non-text labelField (e.g. a number/select column
265
288
  // used as label) — Postgres has no ILIKE for those types otherwise.
266
- const sql = `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause} ` +
289
+ const sql = `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause}${ownershipClause}${deletedClause} ` +
267
290
  `LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
268
291
  const rows = await executeRawQueryRead(tenantDbRunner(db), sql, subParams);
269
292
  if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
@@ -320,7 +343,7 @@ async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db
320
343
  const targetTable = referenceTargetTable(descriptor.targetEntityName, targetEntity);
321
344
  const targetTableName = String(targetTable[KUMIKO_NAME_SYMBOL]);
322
345
  if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
323
- return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetTable, targetTableName, db);
346
+ return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db);
324
347
  }
325
348
  if (!hasSearchablePlaintext(targetEntity, descriptor.labelField)) {
326
349
  // skip: no plaintext anywhere to match — boot rejects this normally,
@@ -373,6 +396,9 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
373
396
  const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
374
397
  const tenantCol = tenantScoped ? physicalColumnName(targetTable, "tenantId") : "";
375
398
  const tenantId = db.mode === "tenant" ? db.tenantId : undefined;
399
+ const deletedClause = isSoftDeletedTargetHidden(targetEntity, targetTable)
400
+ ? ` AND t."${physicalColumnName(targetTable, "isDeleted")}" = FALSE`
401
+ : "";
376
402
  // No ::text cast (unlike the ILIKE search path): a non-text label must keep
377
403
  // its native collation/numeric order, and the keyset param infers its type
378
404
  // from the comparison anyway.
@@ -383,7 +409,7 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
383
409
  tenantClause = ` AND t."${tenantCol}" IN ($${params.length - 1}, $${params.length})`;
384
410
  }
385
411
  return (`(SELECT t."${labelCol}" FROM "${targetTableName}" t ` +
386
- `WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause})`);
412
+ `WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause}${deletedClause})`);
387
413
  };
388
414
  }
389
415
  export function createReadVerbs(ctx) {
@@ -1,5 +1,5 @@
1
1
  export { assertExistsIn } from "./assert-exists-in.js";
2
- export { nullBlindIndexesForSubject, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
2
+ export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
3
3
  export { collectTableMetas } from "./collect-table-metas.js";
4
4
  export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
5
5
  export { seedConfigValues } from "./config-seed.js";
package/dist/db/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { assertExistsIn } from "./assert-exists-in.js";
2
- export { nullBlindIndexesForSubject, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
2
+ export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
3
3
  export { collectTableMetas } from "./collect-table-metas.js";
4
4
  export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
5
5
  export { seedConfigValues } from "./config-seed.js";
@@ -1,4 +1,4 @@
1
- import type { PendingGapEntry } from "../../pipeline/event-consumer-state.js";
1
+ import type { ConsumerStatuses, PendingGapEntry } from "../../pipeline/event-consumer-state.js";
2
2
  import type { AnyDb } from "../query.js";
3
3
  export declare function selectSnapshotXmin(db: AnyDb): Promise<string>;
4
4
  export declare function selectSnapshotXmax(db: AnyDb): Promise<string>;
@@ -10,7 +10,7 @@ export declare function selectEventIdHorizonWithMissingRanges(db: AnyDb): Promis
10
10
  }>;
11
11
  export declare function insertConsumerIfAbsent(db: AnyDb, name: string, instanceId: string, startFrom?: "beginning" | "now"): Promise<void>;
12
12
  export declare function deleteOrphanedPerInstanceConsumerRows(db: AnyDb, consumerNames: readonly string[], sharedInstanceSentinel: string): Promise<void>;
13
- export declare function selectProvablyIdleConsumerPairs(db: AnyDb, names: readonly string[], instanceIds: readonly string[], deadStatus: string): Promise<ReadonlyArray<{
13
+ export declare function selectProvablyIdleConsumerPairs(db: AnyDb, names: readonly string[], instanceIds: readonly string[], deadStatus: typeof ConsumerStatuses.dead): Promise<ReadonlyArray<{
14
14
  readonly name: string;
15
15
  readonly instanceId: string;
16
16
  }>>;
@@ -112,10 +112,11 @@ sharedInstanceSentinel) {
112
112
  // exists past its cursor. Paired via unnest so the two positional arrays
113
113
  // zip element-wise into rows instead of a cartesian product.
114
114
  //
115
- // `deadStatus` is passed in rather than imported from event-consumer-state.ts
116
- // (which defines ConsumerStatuses) — that module already imports from this
117
- // one, so importing back would be a require cycle (same pattern as
118
- // deleteOrphanedPerInstanceConsumerRows above).
115
+ // `deadStatus` is passed in rather than imported as a value from
116
+ // event-consumer-state.ts (which defines ConsumerStatuses) — that module
117
+ // already imports from this one, so a value import back would be a require
118
+ // cycle (same pattern as deleteOrphanedPerInstanceConsumerRows above). The
119
+ // type-only import keeps the parameter pinned to the "dead" literal.
119
120
  export async function selectProvablyIdleConsumerPairs(db, names, instanceIds, deadStatus) {
120
121
  const rows = (await asRawClient(db).unsafe(`SELECT c."name" AS name, c."instance_id" AS instance_id
121
122
  FROM "kumiko_event_consumers" c
@@ -130,16 +130,16 @@ export async function insertSubsequentEventRow(db, params) {
130
130
  return rows[0];
131
131
  }
132
132
  export async function selectStreamMaxVersion(db, aggregateId, tenantId) {
133
- const rows = (await unsafeReadRetrying(db, `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "tenant_id" = $2`, [aggregateId, tenantId]));
133
+ const rows = await unsafeReadRetrying(db, `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "tenant_id" = $2`, [aggregateId, tenantId]);
134
134
  return rows[0]?.v ?? 0;
135
135
  }
136
136
  /** MAX(version) for one aggregate stream — no tenant filter (seed idempotency). */
137
137
  export async function selectAggregateMaxVersion(db, aggregateId) {
138
- const rows = (await unsafeReadRetrying(db, `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1`, [aggregateId]));
138
+ const rows = await unsafeReadRetrying(db, `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1`, [aggregateId]);
139
139
  return rows[0]?.v ?? 0;
140
140
  }
141
141
  export async function selectEventsHighWaterMark(db) {
142
- const rows = (await unsafeReadRetrying(db, `SELECT COALESCE(MAX("id"), 0)::bigint AS max FROM "kumiko_events"`, []));
142
+ const rows = await unsafeReadRetrying(db, `SELECT COALESCE(MAX("id"), 0)::bigint AS max FROM "kumiko_events"`, []);
143
143
  const raw = rows[0]?.max;
144
144
  if (typeof raw === "bigint")
145
145
  return raw;
@@ -153,7 +153,7 @@ export async function selectEventsHeadId(db) {
153
153
  return selectEventsHighWaterMark(db);
154
154
  }
155
155
  export async function selectNextEventIdAfter(db, afterId) {
156
- const rows = (await unsafeReadRetrying(db, `SELECT "id" FROM "kumiko_events" WHERE "id" > $1 ORDER BY "id" ASC LIMIT 1`, [afterId]));
156
+ const rows = await unsafeReadRetrying(db, `SELECT "id" FROM "kumiko_events" WHERE "id" > $1 ORDER BY "id" ASC LIMIT 1`, [afterId]);
157
157
  const row = rows[0];
158
158
  if (!row)
159
159
  return null;
@@ -222,10 +222,10 @@ export async function selectPendingAndNewEventRows(db, cursor, pendingRanges, ba
222
222
  export async function selectSmallestVisibleIdInRanges(db, pendingRanges) {
223
223
  if (pendingRanges.length === 0)
224
224
  return null;
225
- const rows = (await unsafeReadRetrying(db, `SELECT MIN("id")::text AS id FROM "kumiko_events"
225
+ const rows = await unsafeReadRetrying(db, `SELECT MIN("id")::text AS id FROM "kumiko_events"
226
226
  WHERE EXISTS (
227
227
  SELECT 1 FROM unnest($1::bigint[], $2::bigint[]) AS g(f, t) WHERE "id" BETWEEN g.f AND g.t
228
- )`, [pendingRanges.map((r) => r.from.toString()), pendingRanges.map((r) => r.to.toString())]));
228
+ )`, [pendingRanges.map((r) => r.from.toString()), pendingRanges.map((r) => r.to.toString())]);
229
229
  const id = rows[0]?.id;
230
230
  return id === null || id === undefined ? null : BigInt(id);
231
231
  }
@@ -14,7 +14,7 @@ export async function selectEventsForProjectionRebuildBatch(db, aggregateTypes,
14
14
  // Archived streams don't replay (Marten-aligned): their aggregates are
15
15
  // frozen ops-tombstones — replaying them would resurrect rows (or, for
16
16
  // stranded duplicate-aggregates like fw#832, collide on unique indexes).
17
- return (await unsafeReadRetrying(db, `SELECT * FROM "kumiko_events" e
17
+ return unsafeReadRetrying(db, `SELECT * FROM "kumiko_events" e
18
18
  WHERE e."aggregate_type" = ANY($1::text[])
19
19
  AND e."type" = ANY($2::text[])
20
20
  AND e."id" > $3
@@ -23,7 +23,7 @@ export async function selectEventsForProjectionRebuildBatch(db, aggregateTypes,
23
23
  WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id"
24
24
  )
25
25
  ORDER BY e."id" ASC
26
- LIMIT $4`, [aggregateTypes, eventTypes, afterId, limit]));
26
+ LIMIT $4`, [aggregateTypes, eventTypes, afterId, limit]);
27
27
  }
28
28
  // Total subscribed events in the log — same source/type filter as
29
29
  // selectEventsForProjectionRebuildBatch, no id cursor. Under the cutover fence
@@ -33,13 +33,13 @@ export async function countSubscribedEvents(db, aggregateTypes, eventTypes) {
33
33
  // Same archived-streams exclusion as the batch query — the #443 recompute
34
34
  // compares this count against applied events; a filter mismatch would make
35
35
  // every rebuild with an archived stream loop the full-re-replay forever.
36
- const rows = (await unsafeReadRetrying(db, `SELECT count(*)::bigint AS n FROM "kumiko_events" e
36
+ const rows = await unsafeReadRetrying(db, `SELECT count(*)::bigint AS n FROM "kumiko_events" e
37
37
  WHERE e."aggregate_type" = ANY($1::text[])
38
38
  AND e."type" = ANY($2::text[])
39
39
  AND NOT EXISTS (
40
40
  SELECT 1 FROM "kumiko_archived_streams" a
41
41
  WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id"
42
- )`, [aggregateTypes, eventTypes]));
42
+ )`, [aggregateTypes, eventTypes]);
43
43
  const raw = rows[0]?.n;
44
44
  if (typeof raw === "bigint")
45
45
  return raw;