@cosmicdrift/kumiko-framework 0.353.0 → 0.354.1

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 (165) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/redis-sse-broker.d.ts +1 -0
  4. package/dist/api/redis-sse-broker.js +16 -10
  5. package/dist/api/request-context.d.ts +1 -0
  6. package/dist/api/request-context.js +5 -0
  7. package/dist/api/routes.js +4 -2
  8. package/dist/api/server.js +28 -3
  9. package/dist/api/sse-broker.d.ts +7 -3
  10. package/dist/api/sse-route.js +2 -6
  11. package/dist/api/token-expiry-timer.d.ts +1 -0
  12. package/dist/api/token-expiry-timer.js +10 -0
  13. package/dist/bun-db/query.js +29 -2
  14. package/dist/crypto/event-pii.js +4 -2
  15. package/dist/crypto/kms-wiring.js +31 -5
  16. package/dist/db/eagerload.d.ts +8 -3
  17. package/dist/db/eagerload.js +85 -81
  18. package/dist/db/event-store-executor-context.d.ts +12 -0
  19. package/dist/db/event-store-executor-context.js +59 -18
  20. package/dist/db/event-store-executor-read.js +81 -31
  21. package/dist/db/event-store-executor-write.js +54 -3
  22. package/dist/db/index.d.ts +1 -1
  23. package/dist/db/migrate-generator.js +1 -1
  24. package/dist/db/queries/backfill-pii.d.ts +1 -0
  25. package/dist/db/queries/backfill-pii.js +11 -6
  26. package/dist/db/queries/event-consumer.d.ts +2 -3
  27. package/dist/db/queries/event-consumer.js +54 -43
  28. package/dist/db/queries/event-store.d.ts +5 -0
  29. package/dist/db/queries/event-store.js +51 -3
  30. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  31. package/dist/db/queries/pii-backfill-state.js +16 -5
  32. package/dist/db/queries/shadow-swap.js +32 -3
  33. package/dist/db/render-ddl.js +6 -6
  34. package/dist/db/table-builder.js +6 -7
  35. package/dist/db/tenant-db.d.ts +5 -2
  36. package/dist/db/tenant-db.js +47 -7
  37. package/dist/derivatives/variant-key.d.ts +1 -0
  38. package/dist/derivatives/variant-key.js +3 -0
  39. package/dist/engine/boot-validator/access-declarations.js +5 -0
  40. package/dist/engine/boot-validator/config-deps.js +3 -0
  41. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  42. package/dist/engine/boot-validator/entity-handler.js +40 -0
  43. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  44. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  45. package/dist/engine/boot-validator/index.js +6 -1
  46. package/dist/engine/boot-validator/nav.js +3 -1
  47. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  48. package/dist/engine/boot-validator/ownership.js +32 -1
  49. package/dist/engine/boot-validator/pii-retention.js +8 -1
  50. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  51. package/dist/engine/boot-validator/screens.js +98 -7
  52. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  53. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  54. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  55. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  56. package/dist/engine/build-app-schema.js +3 -0
  57. package/dist/engine/changeset-changes.d.ts +1 -0
  58. package/dist/engine/changeset-changes.js +49 -33
  59. package/dist/engine/define-feature.js +11 -0
  60. package/dist/engine/entity-handlers.d.ts +3 -3
  61. package/dist/engine/entity-handlers.js +55 -34
  62. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  63. package/dist/engine/escape-hatch-reason.js +8 -0
  64. package/dist/engine/extension-names.d.ts +8 -9
  65. package/dist/engine/extension-names.js +8 -9
  66. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  67. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  68. package/dist/engine/factories.d.ts +1 -1
  69. package/dist/engine/feature-changelog.d.ts +2 -0
  70. package/dist/engine/feature-changelog.js +6 -0
  71. package/dist/engine/feature-config-events-jobs.js +41 -4
  72. package/dist/engine/handler-field-references.d.ts +3 -0
  73. package/dist/engine/handler-field-references.js +31 -0
  74. package/dist/engine/i18n-key.js +2 -3
  75. package/dist/engine/index.d.ts +8 -2
  76. package/dist/engine/index.js +6 -1
  77. package/dist/engine/ownership.js +3 -4
  78. package/dist/engine/project-app-schema-for-roles.js +3 -1
  79. package/dist/engine/registry-ingest.d.ts +1 -0
  80. package/dist/engine/registry-ingest.js +8 -3
  81. package/dist/engine/run-pipeline.js +12 -1
  82. package/dist/engine/schema-builder.js +35 -2
  83. package/dist/engine/screen-helpers.d.ts +13 -0
  84. package/dist/engine/screen-helpers.js +31 -0
  85. package/dist/engine/soft-delete-cleanup.js +3 -2
  86. package/dist/engine/steps/_read-source.d.ts +4 -0
  87. package/dist/engine/steps/_read-source.js +22 -0
  88. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  89. package/dist/engine/steps/read-find-many.js +2 -1
  90. package/dist/engine/steps/read-find-one.js +2 -1
  91. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  92. package/dist/engine/steps/workflow-step-error.js +13 -0
  93. package/dist/engine/transfer-adjacency.d.ts +24 -0
  94. package/dist/engine/transfer-adjacency.js +107 -0
  95. package/dist/entrypoint/index.d.ts +2 -0
  96. package/dist/entrypoint/index.js +2 -0
  97. package/dist/env/index.js +45 -23
  98. package/dist/errors/classes.d.ts +8 -0
  99. package/dist/errors/classes.js +13 -0
  100. package/dist/errors/index.d.ts +2 -1
  101. package/dist/errors/index.js +2 -1
  102. package/dist/errors/reasons.d.ts +1 -0
  103. package/dist/errors/reasons.js +3 -0
  104. package/dist/errors/subject-erased.d.ts +2 -0
  105. package/dist/errors/subject-erased.js +9 -0
  106. package/dist/event-store/admin-api.js +14 -10
  107. package/dist/event-store/event-store.d.ts +1 -0
  108. package/dist/event-store/event-store.js +14 -9
  109. package/dist/event-store/events-schema.js +2 -1
  110. package/dist/event-store/index.d.ts +1 -1
  111. package/dist/event-store/index.js +1 -1
  112. package/dist/files/file-handle.d.ts +1 -1
  113. package/dist/files/file-handle.js +30 -6
  114. package/dist/files/file-routes.js +10 -4
  115. package/dist/files/resolve-content-type.d.ts +2 -0
  116. package/dist/files/resolve-content-type.js +8 -0
  117. package/dist/i18n/required-surface-keys.d.ts +2 -0
  118. package/dist/i18n/required-surface-keys.js +8 -0
  119. package/dist/jobs/index.d.ts +1 -1
  120. package/dist/jobs/job-runner.d.ts +13 -0
  121. package/dist/jobs/job-runner.js +128 -22
  122. package/dist/migrations/pii-event-backfill.js +46 -25
  123. package/dist/observability/index.d.ts +1 -1
  124. package/dist/observability/index.js +1 -1
  125. package/dist/observability/standard-metrics.d.ts +4 -0
  126. package/dist/observability/standard-metrics.js +12 -0
  127. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  128. package/dist/pipeline/dispatch-shared.js +74 -9
  129. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  130. package/dist/pipeline/dispatch-stream.js +19 -12
  131. package/dist/pipeline/dispatcher.d.ts +3 -1
  132. package/dist/pipeline/dispatcher.js +10 -15
  133. package/dist/pipeline/event-consumer-state.js +0 -26
  134. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  135. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  136. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  137. package/dist/pipeline/event-dispatcher.js +48 -7
  138. package/dist/pipeline/index.d.ts +2 -0
  139. package/dist/pipeline/index.js +1 -0
  140. package/dist/pipeline/member-read-only-transaction.js +25 -5
  141. package/dist/pipeline/msp-rebuild.js +3 -3
  142. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  143. package/dist/pipeline/pending-gap-ranges.js +22 -0
  144. package/dist/pipeline/system-hooks.js +4 -4
  145. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  146. package/dist/pipeline/system-identity-switch.js +5 -4
  147. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  148. package/dist/redis/cache-sync-topics.d.ts +1 -0
  149. package/dist/redis/cache-sync-topics.js +1 -0
  150. package/dist/redis/client.d.ts +1 -0
  151. package/dist/redis/client.js +7 -0
  152. package/dist/redis/index.d.ts +1 -1
  153. package/dist/redis/index.js +1 -1
  154. package/dist/redis/pubsub-signal.d.ts +1 -0
  155. package/dist/redis/pubsub-signal.js +3 -0
  156. package/dist/search/meilisearch-adapter.js +34 -4
  157. package/dist/stack/test-stack.d.ts +4 -1
  158. package/dist/stack/test-stack.js +9 -2
  159. package/dist/testing/e2e-generator.js +7 -2
  160. package/dist/ui-types/index.d.ts +3 -3
  161. package/dist/ui-types/index.js +2 -2
  162. package/dist/upgrade-cli.js +7 -2
  163. package/package.json +12 -4
  164. package/src/changes.json +209 -0
  165. package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
@@ -1,7 +1,7 @@
1
1
  import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
2
2
  import { requestContext, runWithOrigin } from "../api/request-context.js";
3
3
  import { runInSavepoint, selectMany } from "../db/query.js";
4
- import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, } from "../db/tenant-db.js";
4
+ import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, runInOwnTransaction, } from "../db/tenant-db.js";
5
5
  import { createDerivativesContext } from "../derivatives/derivatives-context.js";
6
6
  import { createSystemUser } from "../engine/system-user.js";
7
7
  import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
@@ -40,6 +40,7 @@ export const TENANT_TIMEZONE_CONFIG_KEY = "tenant:config:timezone";
40
40
  // exercises both against the real config feature.
41
41
  export const CONFIG_WRITE_SET_TYPE = "config:write:set";
42
42
  export const CONFIG_WRITE_RESET_TYPE = "config:write:reset";
43
+ export const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10_000;
43
44
  // Narrowing-helper: AppContext.db ist DbConnection|TenantDb|undefined. Die
44
45
  // dispatch-Pfade brauchen DbConnection (oder DbTx aus Caller-Scope) für
45
46
  // appendEvent/projection-writes; TenantDb-Branch wird hier ausgeschlossen.
@@ -113,14 +114,57 @@ function denyingJobRunnerProxy() {
113
114
  },
114
115
  });
115
116
  }
117
+ // Allowlist (not denylist): a method added to the target later stays denied
118
+ // until someone decides it is read-only.
119
+ function allowlistedReadOnlyProxy(target, allowedMethods) {
120
+ return new Proxy(target, {
121
+ get(inner, prop) {
122
+ if (typeof prop === "string" && allowedMethods.has(prop)) {
123
+ const method = Reflect.get(inner, prop, inner);
124
+ return typeof method === "function" ? method.bind(inner) : method;
125
+ }
126
+ // Symbol reads and "then" come from await/Promise.resolve/inspect, not from handler code.
127
+ if (typeof prop === "symbol" || prop === "then")
128
+ return undefined;
129
+ throw memberResolutionReadOnlyDenied();
130
+ },
131
+ });
132
+ }
133
+ const MEMBER_READ_SEARCH_METHODS = new Set(["search"]);
134
+ const MEMBER_READ_ENTITY_CACHE_METHODS = new Set(["get", "mget"]);
135
+ const MEMBER_READ_REDIS_METHODS = new Set([
136
+ "get",
137
+ "mget",
138
+ "exists",
139
+ "ttl",
140
+ "pttl",
141
+ "strlen",
142
+ "type",
143
+ "hget",
144
+ "hmget",
145
+ "hgetall",
146
+ "hexists",
147
+ "hlen",
148
+ "sismember",
149
+ "smembers",
150
+ "scard",
151
+ "llen",
152
+ "lrange",
153
+ "zscore",
154
+ "zcard",
155
+ "zrange",
156
+ ]);
116
157
  // Every write/side-effect surface throws, so "no writeAsMember" holds even
117
158
  // if a handler tries ctx.write directly on a resolved member principal.
159
+ // Deliberately open: `db` (guarded by the READ ONLY transaction + memberReadOnly grant),
160
+ // and read-only methods of searchAdapter / redis / entityCache (allowlisted).
118
161
  export function applyMemberResolutionReadOnly(handlerContext) {
119
162
  return {
120
163
  ...handlerContext,
121
164
  // `db` stays open — executeQuery runs the whole handler in a Postgres READ ONLY
122
165
  // transaction, and its `memberReadOnly` grant denies every raw-runner handout.
123
166
  dbOutsideTransaction: undefined,
167
+ outsideTransaction: undefined,
124
168
  write: denyMemberResolutionWrite,
125
169
  writeAs: denyMemberResolutionWrite,
126
170
  // A resolved member cannot switch identity either — the target would get a normal, writable context.
@@ -144,6 +188,15 @@ export function applyMemberResolutionReadOnly(handlerContext) {
144
188
  derivatives: undefined,
145
189
  ...(handlerContext.jobRunner && { jobRunner: denyingJobRunnerProxy() }),
146
190
  ...(handlerContext.notify && { notify: denyMemberResolutionWrite }),
191
+ ...(handlerContext.searchAdapter && {
192
+ searchAdapter: allowlistedReadOnlyProxy(handlerContext.searchAdapter, MEMBER_READ_SEARCH_METHODS),
193
+ }),
194
+ ...(handlerContext.redis && {
195
+ redis: allowlistedReadOnlyProxy(handlerContext.redis, MEMBER_READ_REDIS_METHODS),
196
+ }),
197
+ ...(handlerContext.entityCache && {
198
+ entityCache: allowlistedReadOnlyProxy(handlerContext.entityCache, MEMBER_READ_ENTITY_CACHE_METHODS),
199
+ }),
147
200
  };
148
201
  }
149
202
  export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, isPostCommit) {
@@ -210,6 +263,11 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
210
263
  ? createSystemScopedDbGuard(type, "dbOutsideTransaction", "ctx.systemDb.outsideTransaction.assertTenantMatch(...) or " +
211
264
  "ctx.systemDb.outsideTransaction.acknowledgeCrossTenant(...)")
212
265
  : rawDbOutsideTransaction;
266
+ // Not offered to r.systemScope() handlers: the raw outside-tx db is unfiltered in
267
+ // system mode, so fn would hand them the same cross-tenant door the guards above close.
268
+ const outsideTransaction = !isSystem && rawDbOutsideTransaction
269
+ ? (fn) => runInOwnTransaction(rawDbOutsideTransaction, fn)
270
+ : undefined;
213
271
  const log = context.log?.child({
214
272
  handler: type,
215
273
  tenantId: user.tenantId,
@@ -255,7 +313,9 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
255
313
  // _fileProviderResolver when a file-provider plugin is mounted; falls back
256
314
  // to a statically-injected context.files (tests).
257
315
  const fileResolver = context._fileProviderResolver;
258
- const files = fileResolver ? createFileContext(() => fileResolver(user.tenantId)) : context.files;
316
+ const files = fileResolver
317
+ ? createFileContext(() => fileResolver(user.tenantId), user.tenantId)
318
+ : context.files;
259
319
  // ctx.derivatives builds on ctx.files (needs a FileContext to read the
260
320
  // original + write the variant) plus db (to look up the FileRef row) — so
261
321
  // it can only be constructed exactly when files+db both resolved; falls
@@ -302,13 +362,13 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
302
362
  ungatedMemberReader ??= createMemberReaderFn(ctx, user.tenantId, tx);
303
363
  return ungatedMemberReader(userId, qn, payload);
304
364
  }, { ...identitySwitchAudit, tenantId: user.tenantId });
365
+ // queryProjection works against both single-stream and multi-stream
366
+ // projections. MSPs without a table cannot be queried — those are
367
+ // side-effect-only consumers (no state to read back).
368
+ const findProjectionTable = (qualifiedName) => registry.getAllProjections().get(qualifiedName)?.table ??
369
+ registry.getAllMultiStreamProjections().get(qualifiedName)?.table;
305
370
  const ungatedQueryProjection = async (qualifiedName, queryOptions) => {
306
- // queryProjection works against both single-stream and multi-stream
307
- // projections. MSPs without a table cannot be queried — those are
308
- // side-effect-only consumers (no state to read back).
309
- const singleProj = registry.getAllProjections().get(qualifiedName);
310
- const mspProj = registry.getAllMultiStreamProjections().get(qualifiedName);
311
- const projTable = singleProj?.table ?? mspProj?.table;
371
+ const projTable = findProjectionTable(qualifiedName);
312
372
  if (!projTable) {
313
373
  const singleNames = [...registry.getAllProjections().keys()];
314
374
  const mspNames = [...registry.getAllMultiStreamProjections().keys()].filter((n) => registry.getAllMultiStreamProjections().get(n)?.table);
@@ -330,7 +390,11 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
330
390
  const rows = await selectMany(dbSource, projTable, where);
331
391
  return rows; // @cast-boundary engine-payload
332
392
  };
333
- const queryProjection = createGatedProjectionReader(`handler "${type}"`, hasIdentitySwitchGrant, ungatedQueryProjection, identitySwitchAudit);
393
+ const queryProjection = createGatedProjectionReader(`handler "${type}"`, hasIdentitySwitchGrant, ungatedQueryProjection, identitySwitchAudit, (qualifiedName) => {
394
+ const projTable = findProjectionTable(qualifiedName);
395
+ // Unknown projection: keep the grant check first, the ungated reader reports the real error.
396
+ return projTable === undefined || hasTenantColumn(projTable);
397
+ });
334
398
  const bridge = {
335
399
  query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx), // @wrapper-known semantic-alias
336
400
  queryAs: identitySwitch.queryAs,
@@ -624,6 +688,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
624
688
  registry,
625
689
  db: exposedDb,
626
690
  dbOutsideTransaction,
691
+ ...(outsideTransaction && { outsideTransaction }),
627
692
  ...(systemDb && { systemDb }),
628
693
  log,
629
694
  notify,
@@ -1,3 +1,7 @@
1
1
  import type { SessionUser } from "../engine/types/index.js";
2
2
  import { type DispatchContext, type WriteOrigin } from "./dispatch-shared.js";
3
- export declare function executeStream(ctx: DispatchContext, type: string, payload: unknown, user: SessionUser, origin: WriteOrigin): AsyncGenerator<unknown>;
3
+ export type StreamOptions = {
4
+ /** JWT exp (epoch seconds) the stream was opened with; the stream ends when it passes. */
5
+ readonly tokenExpiresAtSec?: number | undefined;
6
+ };
7
+ export declare function executeStream(ctx: DispatchContext, type: string, payload: unknown, user: SessionUser, origin: WriteOrigin, options?: StreamOptions): AsyncGenerator<unknown>;
@@ -1,4 +1,5 @@
1
1
  import { accessInvalidationCredentialFor } from "../api/sse-broker.js";
2
+ import { scheduleTokenExpiry } from "../api/token-expiry-timer.js";
2
3
  import { hasAccess } from "../engine/access.js";
3
4
  import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, validationErrorFromZod, } from "../errors/index.js";
4
5
  import { assertNoSecretLeak } from "../secrets/index.js";
@@ -10,10 +11,10 @@ import { handlerAccessError } from "./handler-access-error.js";
10
11
  // of returning a single response. streamHandler never entity-maps (unlike
11
12
  // write/queryHandler — see feature-entity-handlers.ts), so there's no
12
13
  // field-access filter or postQuery-hook stage to run here.
13
- export async function* executeStream(ctx, type, payload, user, origin) {
14
- yield* runStreamInstrumented(ctx, type, user, () => executeStreamInner(ctx, type, payload, user, origin));
14
+ export async function* executeStream(ctx, type, payload, user, origin, options) {
15
+ yield* runStreamInstrumented(ctx, type, user, () => executeStreamInner(ctx, type, payload, user, origin, options));
15
16
  }
16
- async function* executeStreamInner(ctx, type, payload, user, origin) {
17
+ async function* executeStreamInner(ctx, type, payload, user, origin, options) {
17
18
  const { registry } = ctx;
18
19
  const handler = registry.getStreamHandler(type);
19
20
  if (!handler)
@@ -32,15 +33,18 @@ async function* executeStreamInner(ctx, type, payload, user, origin) {
32
33
  if (!parsed.success) {
33
34
  throw validationErrorFromZod(parsed.error);
34
35
  }
35
- // Idle (heartbeat-only) streams must also cut on access revoke — race each
36
- // pull against an invalidated Deferred instead of a post-chunk boolean.
37
- let resolveInvalidated;
38
- const invalidated = new Promise((resolve) => {
39
- resolveInvalidated = resolve;
36
+ // Idle (heartbeat-only) streams must also cut on access revoke or token expiry — race each
37
+ // pull against an ended Deferred instead of a post-chunk boolean.
38
+ let resolveEnded;
39
+ const ended = new Promise((resolve) => {
40
+ resolveEnded = resolve;
40
41
  });
41
42
  const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
42
- resolveInvalidated?.();
43
+ resolveEnded?.("revoked");
43
44
  }, accessInvalidationCredentialFor(user));
45
+ const expiryTimer = scheduleTokenExpiry(options?.tokenExpiresAtSec, () => {
46
+ resolveEnded?.("expired");
47
+ });
44
48
  let iterator;
45
49
  // When access is revoked mid-pull, `iterator.next()` is still in flight.
46
50
  // Awaiting `iterator.return()` in that state deadlocks async generators in
@@ -55,14 +59,16 @@ async function* executeStreamInner(ctx, type, payload, user, origin) {
55
59
  const nextPull = iterator.next();
56
60
  const outcome = await Promise.race([
57
61
  nextPull.then((result) => ({ kind: "chunk", result })),
58
- invalidated.then(() => ({ kind: "invalidated" })),
62
+ ended.then((reason) => ({ kind: "ended", reason })),
59
63
  ]);
60
- if (outcome.kind === "invalidated") {
64
+ if (outcome.kind === "ended") {
61
65
  abandonedForInvalidation = true;
62
66
  void nextPull.catch(() => { });
63
67
  void iterator.return?.(undefined)?.then(undefined, () => { });
64
68
  throw new AccessDeniedError({
65
- message: `access revoked mid-stream for ${type}`,
69
+ message: outcome.reason === "expired"
70
+ ? `token expired mid-stream for ${type}`
71
+ : `access revoked mid-stream for ${type}`,
66
72
  details: { handler: type },
67
73
  });
68
74
  }
@@ -75,6 +81,7 @@ async function* executeStreamInner(ctx, type, payload, user, origin) {
75
81
  }
76
82
  finally {
77
83
  unsubscribeAccessInvalidation?.();
84
+ clearTimeout(expiryTimer);
78
85
  // Close the generator so cleanup runs; skip awaiting return() after
79
86
  // access-revoke abandonment — overlapping next()+return() can deadlock.
80
87
  if (iterator !== undefined && !abandonedForInvalidation) {
@@ -4,6 +4,7 @@ import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extensio
4
4
  import type { TenantId } from "../engine/types/identifiers.js";
5
5
  import type { ActiveMembershipResult, AppContext, DispatchWriteRef, JobRunnerRef, MemberReader, Registry, SessionUser, WriteResult } from "../engine/types/index.js";
6
6
  import type { BatchCommand, BatchResult } from "./dispatch-shared.js";
7
+ import { type StreamOptions } from "./dispatch-stream.js";
7
8
  import { type HandlerType } from "./dispatcher-utils.js";
8
9
  import type { IdempotencyGuard } from "./idempotency.js";
9
10
  import type { LifecycleHooks } from "./lifecycle-pipeline.js";
@@ -14,6 +15,7 @@ export type DispatcherOptions = {
14
15
  lifecycle?: LifecycleHooks;
15
16
  jobRunner?: JobRunnerRef;
16
17
  membershipQuery?: string;
18
+ memberReadTimeoutMs?: number;
17
19
  effectiveFeatures?: EffectiveFeaturesResolver;
18
20
  sseBroker?: SseBroker;
19
21
  cacheSync?: CacheSyncBus | null;
@@ -21,7 +23,7 @@ export type DispatcherOptions = {
21
23
  export type Dispatcher = {
22
24
  write(type: HandlerType, payload: unknown, user: SessionUser, requestId?: string): Promise<WriteResult>;
23
25
  query(type: HandlerType, payload: unknown, user: SessionUser): Promise<unknown>;
24
- stream(type: HandlerType, payload: unknown, user: SessionUser): AsyncGenerator<unknown>;
26
+ stream(type: HandlerType, payload: unknown, user: SessionUser, options?: StreamOptions): AsyncGenerator<unknown>;
25
27
  command(type: HandlerType, payload: unknown, user: SessionUser): Promise<void>;
26
28
  batch(commands: readonly BatchCommand[], user: SessionUser, requestId?: string): Promise<BatchResult>;
27
29
  resolveAuthClaims(user: SessionUser): Promise<Record<string, unknown>>;
@@ -1,5 +1,4 @@
1
1
  import { runWithWriteOrigin } from "../api/request-context.js";
2
- import { DEPRECATED_CROSS_TENANT_SIGNAL, isDeprecatedCrossTenantHandler, } from "../engine/entity-handlers.js";
3
2
  import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names.js";
4
3
  import { InternalError, reraiseAsKumikoError } from "../errors/index.js";
5
4
  import { createEscapeHatchReportWindow, getFallbackMeter, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
@@ -7,7 +6,7 @@ import { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage } from "../redis/cache-syn
7
6
  import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
8
7
  import { runBatch, unwrapSingle } from "./dispatch-batch.js";
9
8
  import { executeQuery } from "./dispatch-query.js";
10
- import { resolveAuthClaimsFn, TENANT_TIMEZONE_CONFIG_KEY } from "./dispatch-shared.js";
9
+ import { DEFAULT_MEMBER_READ_TIMEOUT_MS, resolveAuthClaimsFn, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
11
10
  import { executeStream } from "./dispatch-stream.js";
12
11
  import { resolveType } from "./dispatcher-utils.js";
13
12
  import { createMemberReaderFn } from "./member-reader.js";
@@ -49,6 +48,12 @@ export function dispatcherToWriteRef(dispatcher) {
49
48
  export function createDispatcher(registry, context, options = {}) {
50
49
  const { idempotency, lifecycle, jobRunner, effectiveFeatures, sseBroker } = options;
51
50
  const membershipQuery = options.membershipQuery ?? TENANT_MEMBERSHIPS_QUERY;
51
+ const memberReadTimeoutMs = options.memberReadTimeoutMs ?? DEFAULT_MEMBER_READ_TIMEOUT_MS;
52
+ if (!Number.isSafeInteger(memberReadTimeoutMs) || memberReadTimeoutMs <= 0) {
53
+ throw new InternalError({
54
+ message: `DispatcherOptions.memberReadTimeoutMs must be a positive integer, got ${memberReadTimeoutMs}`,
55
+ });
56
+ }
52
57
  // Pre-build tables and transition maps for auto-guard (avoid per-request allocation)
53
58
  const tableCache = new Map();
54
59
  const transitionCache = new Map();
@@ -75,17 +80,6 @@ export function createDispatcher(registry, context, options = {}) {
75
80
  // Ensure standard metrics exist on whatever meter we ended up with.
76
81
  // Idempotent: buildServer may have registered them already.
77
82
  registerStandardMetrics(dispatcherMeter);
78
- for (const def of [
79
- ...registry.getAllWriteHandlers().values(),
80
- ...registry.getAllQueryHandlers().values(),
81
- ]) {
82
- if (isDeprecatedCrossTenantHandler(def.handler)) {
83
- context.log?.warn(DEPRECATED_CROSS_TENANT_SIGNAL, {
84
- handler: def.name,
85
- migration: 'replace crossTenant: true with escapeHatch: { reason: "<why this operator handler reads/writes every tenant>" } (bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/migrate-cross-tenant.ts)',
86
- });
87
- }
88
- }
89
83
  const ctx = {
90
84
  registry,
91
85
  appContext: context,
@@ -102,6 +96,7 @@ export function createDispatcher(registry, context, options = {}) {
102
96
  tracer: dispatcherTracer,
103
97
  meter: dispatcherMeter,
104
98
  membershipQuery,
99
+ memberReadTimeoutMs,
105
100
  };
106
101
  const dispatcher = {
107
102
  async write(typeOrRef, payload, user, requestId) {
@@ -116,9 +111,9 @@ export function createDispatcher(registry, context, options = {}) {
116
111
  const origin = rootWriteOrigin(registry, type, user);
117
112
  return runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin));
118
113
  },
119
- stream: (typeOrRef, payload, user) => {
114
+ stream: (typeOrRef, payload, user, options) => {
120
115
  const type = resolveType(typeOrRef);
121
- return executeStream(ctx, type, payload, user, rootWriteOrigin(registry, type, user));
116
+ return executeStream(ctx, type, payload, user, rootWriteOrigin(registry, type, user), options);
122
117
  },
123
118
  async command(typeOrRef, payload, user) {
124
119
  const type = resolveType(typeOrRef);
@@ -1,6 +1,5 @@
1
1
  import { bigint, index, instant, integer, jsonb, table as pgTable, primaryKey, sql, text, } from "../db/dialect.js";
2
2
  import { alterTableAddColumn } from "../db/queries/ddl.js";
3
- import { deleteOrphanedPerInstanceConsumerRows } from "../db/queries/event-consumer.js";
4
3
  import { tableExists } from "../db/schema-inspection.js";
5
4
  import { unsafePushTables } from "../stack/index.js";
6
5
  // Reserved sentinel used in the instance_id column for consumers whose
@@ -39,20 +38,6 @@ export const eventConsumerStateTable = pgTable("kumiko_event_consumers", {
39
38
  pk: primaryKey({ columns: [t.name, t.instanceId] }),
40
39
  statusIdx: index("kumiko_event_consumers_status_idx").on(t.status),
41
40
  }));
42
- // fw#2625: these three consumers moved from delivery: "per-instance" to
43
- // "shared" (see system-hooks.ts and bundled-features/feature-toggles/
44
- // feature.ts). Their old per-instance rows are now orphaned — no dispatcher
45
- // will ever advance them again — and pruneEvents stays pinned to whichever
46
- // one has the lowest cursor forever. Named here (not imported from
47
- // system-hooks.ts / feature.ts) to avoid a pipeline → system-hooks /
48
- // bundled-features dependency for three string literals; those modules'
49
- // own exported constants (or, for feature-toggles, the qualified consumer
50
- // name qualify() produces) are the source of truth these must match.
51
- const ORPHANED_PER_INSTANCE_CONSUMER_NAMES = [
52
- "system:consumer:sse-broadcast",
53
- "system:consumer:access-invalidation",
54
- "feature-toggles:projection:toggle-cache-sync",
55
- ];
56
41
  // Object-const form lets call sites write `ConsumerStatuses.disabled` instead
57
42
  // of the raw string, which keeps status checks refactor-safe. The runtime
58
43
  // value on each field is the same string the DB stores — no mapping needed.
@@ -84,19 +69,8 @@ export async function createEventConsumerStateTable(db) {
84
69
  /* ifNotExists */ true);
85
70
  await alterTableAddColumn(db, "kumiko_event_consumers", "last_failed_event_id", "bigint", "", "",
86
71
  /* ifNotExists */ true);
87
- // Runs on every boot, including mid-rolling-deploy: a new pod can delete
88
- // an old pod's still-in-use per-instance row out from under it. That old
89
- // pod's next acquireConsumer then finds no row, delivers no more SSE/
90
- // access-invalidation events for the rest of its lifetime — a one-time,
91
- // few-second gap that resolves itself as clients reconnect onto new pods
92
- // during the same rollout. Not a replay risk: preRegisterConsumers only
93
- // (re-)inserts a row at dispatcher startup, so the deleted pod won't
94
- // silently get a fresh cursor-0 row and replay history mid-flight.
95
- await deleteOrphanedPerInstanceConsumerRows(db, ORPHANED_PER_INSTANCE_CONSUMER_NAMES, SHARED_INSTANCE_SENTINEL);
96
72
  // skip: table (+ any missing column) is already up to date
97
73
  return;
98
74
  }
99
- // A freshly created table has no rows to clean up — the DELETE only
100
- // matters for a DB that already ran the old per-instance consumers.
101
75
  await unsafePushTables(db, { kumikoEventConsumers: eventConsumerStateTable });
102
76
  }
@@ -3,7 +3,7 @@ import { selectNextEventIdAfter, selectSmallestVisibleIdInRanges, } from "../db/
3
3
  import { coerceRow, extractTableInfo, selectMany } from "../db/query.js";
4
4
  import { getEventsHighWaterMark } from "../event-store/index.js";
5
5
  import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.js";
6
- import { rangeContainsId, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges.js";
6
+ import { mergeContiguousGapRanges, rangeContainsId, splitRangeExcludingIds, toIdRanges, } from "./pending-gap-ranges.js";
7
7
  // --- Ops recovery surface ---
8
8
  //
9
9
  // These are intentionally verb-distinct; each maps to a CLI sub-command.
@@ -84,6 +84,19 @@ export async function enableConsumer(db, name, instanceId = SHARED_INSTANCE_SENT
84
84
  }
85
85
  return applyConsumerStatusTransition(db, name, instanceId, "idle");
86
86
  }
87
+ // Delivery stops at the poison event, so ids between the cursor and it were
88
+ // invisible during that pass (in-flight commits); keeping them as a pending gap
89
+ // avoids losing them when the cursor jumps over.
90
+ async function invisibleWindowGap(tx, cursor, poisonId) {
91
+ const windowStart = cursor + 1n;
92
+ if (poisonId <= windowStart)
93
+ return null;
94
+ return {
95
+ from: windowStart.toString(),
96
+ to: (poisonId - 1n).toString(),
97
+ xmax: await selectSnapshotXmax(tx),
98
+ };
99
+ }
87
100
  // skipPoisonEvent skips the event whose handler failed last
88
101
  // (last_failed_event_id, set by the delivery's halt-on-poison branch): inside
89
102
  // pending_gaps it is split out of its range, otherwise the cursor moves onto
@@ -109,7 +122,7 @@ export async function skipPoisonEvent(db, name, instanceId = SHARED_INSTANCE_SEN
109
122
  const newPendingGaps = pendingGaps.flatMap((gap) => rangeContainsId(gap, pendingPoisonId)
110
123
  ? splitRangeExcludingIds(gap, [pendingPoisonId])
111
124
  : [gap]);
112
- const raw = await removePendingGapReturning(tx, name, instanceId, newPendingGaps);
125
+ const raw = await removePendingGapReturning(tx, name, instanceId, mergeContiguousGapRanges(newPendingGaps));
113
126
  const updated = raw && coerceRow(raw, extractTableInfo(eventConsumerStateTable));
114
127
  if (!updated)
115
128
  throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`);
@@ -130,15 +143,8 @@ export async function skipPoisonEvent(db, name, instanceId = SHARED_INSTANCE_SEN
130
143
  // Delivery stops at the poison event, so ids between the cursor and it were
131
144
  // invisible during that pass (in-flight commits). Jumping the cursor over them
132
145
  // would lose them; keep them as a pending gap like the dispatcher's own gaps.
133
- const skippedWindowStart = before.lastProcessedEventId + 1n;
134
- const gapToAdd = poisonId > skippedWindowStart
135
- ? {
136
- from: skippedWindowStart.toString(),
137
- to: (poisonId - 1n).toString(),
138
- xmax: await selectSnapshotXmax(tx),
139
- }
140
- : null;
141
- const raw = await advanceConsumerPastEventReturning(tx, name, instanceId, poisonId, gapToAdd);
146
+ const gapToAdd = await invisibleWindowGap(tx, before.lastProcessedEventId, poisonId);
147
+ const raw = await advanceConsumerPastEventReturning(tx, name, instanceId, poisonId, mergeContiguousGapRanges(gapToAdd ? [...pendingGaps, gapToAdd] : pendingGaps));
142
148
  const updated = raw && coerceRow(raw, extractTableInfo(eventConsumerStateTable));
143
149
  if (!updated)
144
150
  throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`);
@@ -68,4 +68,4 @@ export type PersistedConsumerOutcome = DeliveryOutcome & {
68
68
  readonly pendingGaps: readonly PendingGapEntry[];
69
69
  };
70
70
  export declare function persistConsumerOutcome(tx: DbTx, name: string, instanceId: string, outcome: PersistedConsumerOutcome): Promise<void>;
71
- export declare function emitLagFromTx(tx: DbTx, consumerName: string, instanceId: string, cursor: bigint, meter: Meter): Promise<void>;
71
+ export declare function emitLagFromTx(tx: DbTx, consumerName: string, instanceId: string, cursor: bigint, meter: Meter, pendingGapRanges: number): Promise<void>;
@@ -6,7 +6,7 @@ import { selectEventsHeadId, selectPendingAndNewEventRows, } from "../db/queries
6
6
  import { coerceRow, extractTableInfo } from "../db/query.js";
7
7
  import { qnScope } from "../engine/qualified-name.js";
8
8
  import { eventsTable, toStoredEvent as rowToStoredEvent, } from "../event-store/index.js";
9
- import { emitDispatcherError, emitEventConsumerLag, getFallbackMeter, } from "../observability/index.js";
9
+ import { emitDispatcherError, emitEventConsumerLag, emitEventConsumerPendingGaps, getFallbackMeter, } from "../observability/index.js";
10
10
  import { ConsumerStatuses, eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.js";
11
11
  import { parseWriteOrigin } from "./write-origin.js";
12
12
  // Fails closed without throwing: a throw would poison the event for every consumer.
@@ -315,8 +315,9 @@ export async function persistConsumerOutcome(tx, name, instanceId, outcome) {
315
315
  // Emit the lag gauge inside the consumer pass's tx so ops sees a snapshot
316
316
  // consistent with the cursor we just advanced to. `MAX(id)` on the events
317
317
  // table is an O(1) reverse-index scan — cheap even under load.
318
- export async function emitLagFromTx(tx, consumerName, instanceId, cursor, meter) {
318
+ export async function emitLagFromTx(tx, consumerName, instanceId, cursor, meter, pendingGapRanges) {
319
319
  const head = await selectEventsHeadId(tx);
320
320
  const lag = head > cursor ? Number(head - cursor) : 0;
321
321
  emitEventConsumerLag(meter, { consumer: consumerName, instanceId }, lag);
322
+ emitEventConsumerPendingGaps(meter, { consumer: consumerName, instanceId }, pendingGapRanges);
322
323
  }
@@ -1,15 +1,33 @@
1
+ import { extractPgError } from "../db/pg-error.js";
1
2
  import { recordConsumerPassFailure, selectSnapshotXmax, selectSnapshotXmin, } from "../db/queries/event-consumer.js";
2
3
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
3
4
  import { EVENTS_PUBSUB_CHANNEL } from "../event-store/index.js";
4
5
  import { emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, getFallbackMeter, getFallbackTracer, } from "../observability/index.js";
5
6
  import { SHARED_INSTANCE_SENTINEL } from "./event-consumer-state.js";
6
7
  import { acquireConsumerState, consumerInstanceId, deliverEvents, emitLagFromTx, fetchPendingEvents, markProcessing, persistConsumerOutcome, preRegisterConsumers, selectIdleConsumerKeys, } from "./event-dispatcher-delivery.js";
7
- import { partitionBurntGaps, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges.js";
8
+ import { mergeContiguousGapRanges, partitionBurntGaps, splitRangeExcludingIds, toIdRanges, } from "./pending-gap-ranges.js";
8
9
  const DEFAULT_BATCH_SIZE = 200;
9
10
  const DEFAULT_POLL_MS = 100;
10
11
  const DEFAULT_MAX_ATTEMPTS = 10;
11
12
  const DEFAULT_REARM_COOLDOWN_MS = 5 * 60_000;
12
13
  const DEFAULT_MAX_REARM_COUNT = 3;
14
+ const PENDING_GAPS_WARN_THRESHOLD = 1000;
15
+ // Only these say the database socket itself is gone; timeouts, schema or pool errors do not
16
+ // imply the dedicated LISTEN connection dropped.
17
+ const CONNECTION_LOSS_CODES = new Set([
18
+ "CONNECTION_CLOSED",
19
+ "CONNECTION_DESTROYED",
20
+ "CONNECTION_ENDED",
21
+ "CONNECT_TIMEOUT",
22
+ "ECONNREFUSED",
23
+ "ECONNRESET",
24
+ "ERR_POSTGRES_CONNECTION_CLOSED",
25
+ "57P01",
26
+ ]);
27
+ function isConnectionLossError(e) {
28
+ const code = extractPgError(e)?.code;
29
+ return code !== undefined && CONNECTION_LOSS_CODES.has(code);
30
+ }
13
31
  // Backoff for a consumer whose pass THREW (the processConsumer catch below)
14
32
  // — an infra-level failure (db.begin's callback rejected outside deliverEvents'
15
33
  // own per-event try/catch), not a poisoned event. Doubles per consecutive
@@ -52,6 +70,19 @@ export function createEventDispatcher(options) {
52
70
  // which is fine: a fresh process re-observing a still-dead consumer is
53
71
  // exactly the "still needs a human" signal ops wants.
54
72
  const reportedDeadConsumers = new Set();
73
+ // Same once-per-process dedup for the pending_gaps size warning.
74
+ const reportedLargeGapSets = new Set();
75
+ // A long-running writer transaction pins the cluster-wide xmin, so rolled-back appends
76
+ // (each burns a sequence id) accumulate as gaps and every turn ships them all to the fetch.
77
+ const warnOnceOnLargeGapSet = (consumerName, instance, count) => {
78
+ const key = `${consumerName}:${instance}`;
79
+ // skip: below the warn threshold, or already reported once for this consumer instance
80
+ if (count <= PENDING_GAPS_WARN_THRESHOLD || reportedLargeGapSets.has(key))
81
+ return;
82
+ reportedLargeGapSets.add(key);
83
+ logDispatcherError(`[event-dispatcher] ${consumerName} tracks ${count} pending gap ranges (> ${PENDING_GAPS_WARN_THRESHOLD}); ` +
84
+ `a long-running write transaction is probably holding back the snapshot xmin`);
85
+ };
55
86
  // Per-(consumer, instanceId) backoff after a thrown pass (see the catch in
56
87
  // processConsumer). Keyed the same way as reportedDeadConsumers.
57
88
  // consecutiveFailures drives the exponential delay; retryAtMs is the
@@ -67,6 +98,9 @@ export function createEventDispatcher(options) {
67
98
  // unrelated outage gets its own fresh log line instead of staying
68
99
  // permanently suppressed by this process's flag.
69
100
  let idlePreCheckErrorLogged = false;
101
+ // Mirrors the last gauge value derived from the LISTEN connection (set by onlisten), so the
102
+ // pre-check recovery can restore it after a connection-loss error flipped it to 0.
103
+ let listenSubscribed = false;
70
104
  let running = false;
71
105
  // Separate from `running` on purpose: pre-registration of consumer state
72
106
  // rows is a one-time boot action, while running/timer/LISTEN is a
@@ -241,6 +275,7 @@ export function createEventDispatcher(options) {
241
275
  })));
242
276
  if (idlePreCheckErrorLogged) {
243
277
  idlePreCheckErrorLogged = false;
278
+ emitEventDispatcherListenConnected(meter, listenSubscribed);
244
279
  const recoveredMsg = "[event-dispatcher] idle pre-check recovered, database reachable again";
245
280
  if (context.log) {
246
281
  context.log.info(recoveredMsg);
@@ -258,9 +293,10 @@ export function createEventDispatcher(options) {
258
293
  // signal for ops, not a delivery failure.
259
294
  if (!idlePreCheckErrorLogged) {
260
295
  idlePreCheckErrorLogged = true;
261
- // The LISTEN peer talks to the same DB, so it is gone too; only a
262
- // later onlisten may flip the gauge back to 1.
263
- emitEventDispatcherListenConnected(meter, false);
296
+ // A lost DB socket takes the LISTEN peer down with it; other pre-check failures
297
+ // (statement_timeout, pool exhaustion, schema errors) say nothing about LISTEN.
298
+ if (isConnectionLossError(e))
299
+ emitEventDispatcherListenConnected(meter, false);
264
300
  const msg = e instanceof Error ? e.message : String(e);
265
301
  const logMsg = `[event-dispatcher] idle pre-check failed, falling back to per-consumer locking every tick until it recovers: ${msg}`;
266
302
  logDispatcherError(logMsg);
@@ -290,6 +326,7 @@ export function createEventDispatcher(options) {
290
326
  "consumer.instance_id": instanceId,
291
327
  },
292
328
  });
329
+ let cursorBeforePass;
293
330
  try {
294
331
  await db.begin(async (tx) => {
295
332
  const acquired = await acquireConsumerState(tx, consumer.name, instanceId, rearmCooldownMs, maxRearmCount);
@@ -313,6 +350,7 @@ export function createEventDispatcher(options) {
313
350
  // staying permanently suppressed by this process's Set.
314
351
  reportedDeadConsumers.delete(`${consumer.name}:${instanceId}`);
315
352
  const oldCursor = acquired.state.lastProcessedEventId;
353
+ cursorBeforePass = oldCursor;
316
354
  const pendingGaps = acquired.state.pendingGaps;
317
355
  // xmin BEFORE the fetch: a range is provably burnt only once this has
318
356
  // passed the xmax recorded with it. Only needed when ranges exist.
@@ -377,10 +415,11 @@ export function createEventDispatcher(options) {
377
415
  }));
378
416
  const persistedOutcome = {
379
417
  ...outcome,
380
- pendingGaps: [...keptGaps, ...newGaps],
418
+ pendingGaps: mergeContiguousGapRanges([...keptGaps, ...newGaps]),
381
419
  };
420
+ warnOnceOnLargeGapSet(consumer.name, instanceId, persistedOutcome.pendingGaps.length);
382
421
  await persistConsumerOutcome(tx, consumer.name, instanceId, persistedOutcome);
383
- await emitLagFromTx(tx, consumer.name, instanceId, outcome.cursor, meter);
422
+ await emitLagFromTx(tx, consumer.name, instanceId, outcome.cursor, meter, persistedOutcome.pendingGaps.length);
384
423
  });
385
424
  // Pass completed without throwing — clear any backoff from a prior
386
425
  // failure so the next unrelated outage gets its own fresh delay
@@ -409,7 +448,7 @@ export function createEventDispatcher(options) {
409
448
  // so any failure here is swallowed after logging.
410
449
  try {
411
450
  await db.begin(async (tx) => {
412
- await recordConsumerPassFailure(tx, consumer.name, instanceId, msg);
451
+ await recordConsumerPassFailure(tx, consumer.name, instanceId, msg, cursorBeforePass);
413
452
  });
414
453
  }
415
454
  catch (persistErr) {
@@ -472,6 +511,7 @@ export function createEventDispatcher(options) {
472
511
  // ponytail: no own LISTEN reconnect, the backoff gap is polled;
473
512
  // add a dedicated LISTEN connection if prod wires a pgClient
474
513
  // and that latency matters.
514
+ listenSubscribed = true;
475
515
  emitEventDispatcherListenConnected(meter, true);
476
516
  });
477
517
  pgUnlisten = sub.unlisten;
@@ -498,6 +538,7 @@ export function createEventDispatcher(options) {
498
538
  // subscription is being torn down anyway.
499
539
  });
500
540
  pgUnlisten = null;
541
+ listenSubscribed = false;
501
542
  emitEventDispatcherListenConnected(meter, false);
502
543
  }
503
544
  // Drain any in-flight pass so shutdown observes consistent state —
@@ -1,5 +1,7 @@
1
1
  export type { EscapeHatchReportWindow } from "../observability/escape-hatch-report.js";
2
2
  export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "../observability/escape-hatch-report.js";
3
+ export type { AppendDomainEventCoreDeps } from "./append-event-core.js";
4
+ export { appendDomainEventCore } from "./append-event-core.js";
3
5
  export type { ResolveAuthClaimsArgs } from "./auth-claims-resolver.js";
4
6
  export { resolveAuthClaims } from "./auth-claims-resolver.js";
5
7
  export { createCascadeDeleteHook } from "./cascade-handler.js";
@@ -1,4 +1,5 @@
1
1
  export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "../observability/escape-hatch-report.js";
2
+ export { appendDomainEventCore } from "./append-event-core.js";
2
3
  export { resolveAuthClaims } from "./auth-claims-resolver.js";
3
4
  export { createCascadeDeleteHook } from "./cascade-handler.js";
4
5
  export { createDispatcher, dispatcherToWriteRef } from "./dispatcher.js";