@cosmicdrift/kumiko-framework 0.353.0 → 0.354.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 (161) 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 +67 -25
  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 +4 -0
  29. package/dist/db/queries/event-store.js +40 -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/screen-helpers.d.ts +13 -0
  83. package/dist/engine/screen-helpers.js +31 -0
  84. package/dist/engine/soft-delete-cleanup.js +3 -2
  85. package/dist/engine/steps/_read-source.d.ts +4 -0
  86. package/dist/engine/steps/_read-source.js +22 -0
  87. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  88. package/dist/engine/steps/read-find-many.js +2 -1
  89. package/dist/engine/steps/read-find-one.js +2 -1
  90. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  91. package/dist/engine/steps/workflow-step-error.js +13 -0
  92. package/dist/engine/transfer-adjacency.d.ts +24 -0
  93. package/dist/engine/transfer-adjacency.js +107 -0
  94. package/dist/entrypoint/index.d.ts +2 -0
  95. package/dist/entrypoint/index.js +2 -0
  96. package/dist/env/index.js +45 -23
  97. package/dist/errors/classes.d.ts +8 -0
  98. package/dist/errors/classes.js +13 -0
  99. package/dist/errors/index.d.ts +2 -1
  100. package/dist/errors/index.js +2 -1
  101. package/dist/errors/reasons.d.ts +1 -0
  102. package/dist/errors/reasons.js +3 -0
  103. package/dist/errors/subject-erased.d.ts +2 -0
  104. package/dist/errors/subject-erased.js +9 -0
  105. package/dist/event-store/admin-api.js +14 -10
  106. package/dist/event-store/event-store.js +11 -9
  107. package/dist/event-store/events-schema.js +2 -1
  108. package/dist/files/file-handle.d.ts +1 -1
  109. package/dist/files/file-handle.js +30 -6
  110. package/dist/files/file-routes.js +10 -4
  111. package/dist/files/resolve-content-type.d.ts +2 -0
  112. package/dist/files/resolve-content-type.js +8 -0
  113. package/dist/i18n/required-surface-keys.d.ts +2 -0
  114. package/dist/i18n/required-surface-keys.js +8 -0
  115. package/dist/jobs/index.d.ts +1 -1
  116. package/dist/jobs/job-runner.d.ts +13 -0
  117. package/dist/jobs/job-runner.js +128 -22
  118. package/dist/migrations/pii-event-backfill.js +46 -25
  119. package/dist/observability/index.d.ts +1 -1
  120. package/dist/observability/index.js +1 -1
  121. package/dist/observability/standard-metrics.d.ts +4 -0
  122. package/dist/observability/standard-metrics.js +12 -0
  123. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  124. package/dist/pipeline/dispatch-shared.js +74 -9
  125. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  126. package/dist/pipeline/dispatch-stream.js +19 -12
  127. package/dist/pipeline/dispatcher.d.ts +3 -1
  128. package/dist/pipeline/dispatcher.js +10 -15
  129. package/dist/pipeline/event-consumer-state.js +0 -26
  130. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  131. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  132. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  133. package/dist/pipeline/event-dispatcher.js +48 -7
  134. package/dist/pipeline/index.d.ts +2 -0
  135. package/dist/pipeline/index.js +1 -0
  136. package/dist/pipeline/member-read-only-transaction.js +25 -5
  137. package/dist/pipeline/msp-rebuild.js +3 -3
  138. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  139. package/dist/pipeline/pending-gap-ranges.js +22 -0
  140. package/dist/pipeline/system-hooks.js +4 -4
  141. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  142. package/dist/pipeline/system-identity-switch.js +5 -4
  143. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  144. package/dist/redis/cache-sync-topics.d.ts +1 -0
  145. package/dist/redis/cache-sync-topics.js +1 -0
  146. package/dist/redis/client.d.ts +1 -0
  147. package/dist/redis/client.js +7 -0
  148. package/dist/redis/index.d.ts +1 -1
  149. package/dist/redis/index.js +1 -1
  150. package/dist/redis/pubsub-signal.d.ts +1 -0
  151. package/dist/redis/pubsub-signal.js +3 -0
  152. package/dist/search/meilisearch-adapter.js +34 -4
  153. package/dist/stack/test-stack.d.ts +4 -1
  154. package/dist/stack/test-stack.js +9 -2
  155. package/dist/testing/e2e-generator.js +7 -2
  156. package/dist/ui-types/index.d.ts +3 -3
  157. package/dist/ui-types/index.js +2 -2
  158. package/dist/upgrade-cli.js +7 -2
  159. package/package.json +12 -4
  160. package/src/changes.json +198 -0
  161. package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
@@ -14,6 +14,7 @@ import { bigint, instant, table as pgTable, sql, text } from "../db/dialect.js";
14
14
  import { extractTableName } from "../db/index.js";
15
15
  import { acquireNamespacedAdvisoryLock } from "../db/queries/advisory-lock.js";
16
16
  import { backfillEventPiiEncryptionBatch, } from "../db/queries/backfill-pii.js";
17
+ import { alterTableAddColumn } from "../db/queries/ddl.js";
17
18
  import { persistPiiBackfillState, readPiiBackfillState, } from "../db/queries/pii-backfill-state.js";
18
19
  import { upsertOnConflict } from "../db/query.js";
19
20
  import { tableExists } from "../db/schema-inspection.js";
@@ -37,10 +38,12 @@ export const piiBackfillStateTable = pgTable("kumiko_pii_backfill_state", {
37
38
  updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql `now()`),
38
39
  });
39
40
  export async function createPiiBackfillStateTable(db) {
40
- // skip: table already exists — boot runs on every replica
41
- if (await tableExists(db, "public.kumiko_pii_backfill_state"))
42
- return;
43
- await unsafePushTables(db, { kumikoPiiBackfillState: piiBackfillStateTable });
41
+ if (!(await tableExists(db, "public.kumiko_pii_backfill_state"))) {
42
+ await unsafePushTables(db, { kumikoPiiBackfillState: piiBackfillStateTable });
43
+ }
44
+ // Added after the table's first deploy; IF NOT EXISTS keeps concurrent replica boots safe.
45
+ await alterTableAddColumn(db, "kumiko_pii_backfill_state", "failed_event_ids", "bigint[]", " DEFAULT '{}'::bigint[]", " NOT NULL",
46
+ /* ifNotExists */ true);
44
47
  }
45
48
  // Replicas boot concurrently: two of them can both pass the tableExists gate and
46
49
  // one CREATE then fails. The table existing afterwards means the other won.
@@ -107,17 +110,19 @@ function initialScanCursor(state) {
107
110
  // One transaction per batch: the advisory lock serializes pods, and the payload
108
111
  // is re-read inside it so a value is never encrypted twice and two backfills
109
112
  // never mint the same DEK. Returns null when another pod already finished a full run.
110
- async function runOneBatch(ctx, progress) {
113
+ async function runOneBatch(ctx, progress, retryEventIds) {
111
114
  return ctx.db.begin(async (tx) => {
112
115
  await acquireNamespacedAdvisoryLock(tx, PII_EVENT_BACKFILL_LOCK_NAMESPACE, PII_EVENT_BACKFILL_LOCK_KEY);
113
116
  const state = await readPiiBackfillState(tx, ctx.fingerprint);
114
- if (ctx.mode === "full" && state?.completed === true)
117
+ if (retryEventIds === undefined && ctx.mode === "full" && state?.completed === true) {
115
118
  return null;
119
+ }
116
120
  const startCursor = ctx.mode === "full" && state
117
121
  ? maxBigInt(progress.scanCursor, BigInt(state.cursor))
118
122
  : progress.scanCursor;
119
123
  const batch = await backfillEventPiiEncryptionBatch(tx, ctx.registry, {
120
124
  afterEventId: startCursor,
125
+ ...(retryEventIds && { eventIds: retryEventIds }),
121
126
  batchSize: ctx.batchSize,
122
127
  resolveOwnerFromProjection: true,
123
128
  scanCache: progress.scanCache,
@@ -128,10 +133,15 @@ async function runOneBatch(ctx, progress) {
128
133
  : [];
129
134
  if (tables.length > 0)
130
135
  await queueRebuildTables(tx, tables, ctx.migrationId);
131
- if (!progress.failureSeen) {
132
- const completesRun = batch.scannedAll && batch.failures.length === 0;
133
- await persistPiiBackfillState(tx, ctx.fingerprint, persistableCursor(batch, startCursor), completesRun);
134
- }
136
+ // The cursor moves past failed events: they are recorded here and retried on their own,
137
+ // so one permanently failing event no longer forces a rescan from it on every boot.
138
+ const failedIds = batch.failures.map((failure) => failure.eventId);
139
+ await persistPiiBackfillState(tx, ctx.fingerprint, {
140
+ cursor: retryEventIds ? 0n : advancedCursor(batch, startCursor),
141
+ markCompleted: retryEventIds === undefined && batch.scannedAll,
142
+ addFailedEventIds: failedIds,
143
+ removeFailedEventIds: retryEventIds?.filter((id) => !failedIds.includes(id)) ?? [],
144
+ });
135
145
  return { batch, tables };
136
146
  });
137
147
  }
@@ -141,14 +151,25 @@ function recordBatch(progress, step) {
141
151
  progress.totals.updatedEvents += batch.updatedEvents;
142
152
  progress.totals.encryptedFields += batch.encryptedFields;
143
153
  progress.totals.erasedFields += batch.erasedFields;
144
- progress.failures.push(...batch.failures);
154
+ for (const failure of batch.failures) {
155
+ // The catch-up overlap and the per-event retry can hit the same failed event twice in one run.
156
+ if (progress.failedIdsSeen.has(failure.eventId))
157
+ continue;
158
+ progress.failedIdsSeen.add(failure.eventId);
159
+ progress.failures.push(failure);
160
+ }
145
161
  for (const table of step.tables)
146
162
  progress.queuedTables.add(table);
147
- if (batch.failures.length > 0)
148
- progress.failureSeen = true;
149
163
  }
150
164
  // Returns true when the run was aborted between batches.
151
- async function scanAllBatches(ctx, progress, signal) {
165
+ async function scanAllBatches(ctx, progress, signal, retryEventIds) {
166
+ for (let from = 0; from < retryEventIds.length; from += ctx.batchSize) {
167
+ if (signal?.aborted)
168
+ return true;
169
+ const step = await runOneBatch(ctx, progress, retryEventIds.slice(from, from + ctx.batchSize));
170
+ if (step !== null)
171
+ recordBatch(progress, step);
172
+ }
152
173
  for (;;) {
153
174
  if (signal?.aborted)
154
175
  return true;
@@ -163,7 +184,9 @@ async function scanAllBatches(ctx, progress, signal) {
163
184
  }
164
185
  function reportRunOutcome(log, progress) {
165
186
  if (progress.failures.length > 0) {
166
- log.error(`${progress.failures.length} event(s) could not be PII-encrypted and will be retried on the next boot`, {
187
+ log.error(`${progress.failures.length} event(s) could not be PII-encrypted; the scan moved past them and ` +
188
+ "only these events are retried on each boot. Fix the cause, or erase subjects that stay unresolvable via " +
189
+ "backfillEventPiiEncryption(..., { eraseUnresolvableSubjects: true })", {
167
190
  failureCount: progress.failures.length,
168
191
  eventIds: progress.failures.slice(0, MAX_LOGGED_FAILURE_IDS).map((f) => f.eventId),
169
192
  });
@@ -180,24 +203,27 @@ export async function runPiiEventBackfill(db, registry, options = {}) {
180
203
  const fingerprint = piiAnnotationFingerprint(registry);
181
204
  await ensureInfraTable(db, "kumiko_pii_backfill_state", createPiiBackfillStateTable);
182
205
  await ensureInfraTable(db, "kumiko_pending_rebuilds", createPendingRebuildsTable);
183
- const { mode, scanCursor } = initialScanCursor(await readPiiBackfillState(db, fingerprint));
206
+ const initialState = await readPiiBackfillState(db, fingerprint);
207
+ const { mode, scanCursor } = initialScanCursor(initialState);
184
208
  const ctx = {
185
209
  db,
186
210
  registry,
187
211
  fingerprint,
188
- migrationId: `${PII_BACKFILL_MIGRATION_PREFIX}${fingerprint.slice(0, 12)}`,
212
+ // The per-run suffix keeps a peer replica's re-queue of the same table (new
213
+ // migration_id via the upsert) from being deleted by this run's clear.
214
+ migrationId: `${PII_BACKFILL_MIGRATION_PREFIX}${fingerprint.slice(0, 12)}:${crypto.randomUUID()}`,
189
215
  mode,
190
216
  batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE,
191
217
  };
192
218
  const progress = {
193
219
  scanCursor,
194
- failureSeen: false,
195
220
  scanCache: {},
196
221
  totals: { scannedEvents: 0, updatedEvents: 0, encryptedFields: 0, erasedFields: 0 },
197
222
  failures: [],
198
223
  queuedTables: new Set(),
224
+ failedIdsSeen: new Set(),
199
225
  };
200
- const aborted = await scanAllBatches(ctx, progress, options.signal);
226
+ const aborted = await scanAllBatches(ctx, progress, options.signal, initialState?.failedEventIds ?? []);
201
227
  reportRunOutcome(log, progress);
202
228
  const rebuild = aborted ? null : await rebuildQueuedTables(db, registry, options.signal, log);
203
229
  const queuedTables = [...progress.queuedTables];
@@ -218,12 +244,7 @@ export async function runPiiEventBackfill(db, registry, options = {}) {
218
244
  rebuild,
219
245
  };
220
246
  }
221
- // Never advance past a failed event so the next boot retries it.
222
- function persistableCursor(batch, startCursor) {
223
- if (batch.firstFailedEventId !== null) {
224
- const beforeFailure = BigInt(batch.firstFailedEventId) - 1n;
225
- return beforeFailure > startCursor ? beforeFailure : startCursor;
226
- }
247
+ function advancedCursor(batch, startCursor) {
227
248
  return batch.lastEventId === null ? startCursor : BigInt(batch.lastEventId);
228
249
  }
229
250
  function maxBigInt(a, b) {
@@ -12,5 +12,5 @@ export { type MetricEvent, type MetricEventHandler, RecordingMeter, } from "./re
12
12
  export { type RecordedSpan, RecordingTracer, type RecordingTracerOptions, serializeSpanContext, } from "./recording-tracer.js";
13
13
  export { wrapRedisClient } from "./redis-wrapper.js";
14
14
  export { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, REDACTED, redactAttributes, redactHeaders, redactQueryString, redactValue, shouldRedactAttribute, } from "./sensitive-filter.js";
15
- export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
15
+ export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerPendingGaps, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
16
16
  export type { Counter, Gauge, Histogram, Meter, MetricDefinition, MetricLabels, MetricsHandle, MetricType, ObservabilityOptions, ObservabilityProvider, SamplingConfig, SensitiveFilterConfig, SerializedTraceContext, Span, SpanAttributes, SpanAttributeValue, SpanKind, SpanStatus, StartSpanOptions, Tracer, } from "./types/index.js";
@@ -13,4 +13,4 @@ export { RecordingMeter, } from "./recording-meter.js";
13
13
  export { RecordingTracer, serializeSpanContext, } from "./recording-tracer.js";
14
14
  export { wrapRedisClient } from "./redis-wrapper.js";
15
15
  export { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, REDACTED, redactAttributes, redactHeaders, redactQueryString, redactValue, shouldRedactAttribute, } from "./sensitive-filter.js";
16
- export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
16
+ export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerPendingGaps, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
@@ -28,6 +28,10 @@ export declare function emitEventConsumerLag(meter: Meter, labels: {
28
28
  readonly consumer: string;
29
29
  readonly instanceId: string;
30
30
  }, lagEvents: number): void;
31
+ export declare function emitEventConsumerPendingGaps(meter: Meter, labels: {
32
+ readonly consumer: string;
33
+ readonly instanceId: string;
34
+ }, gapRanges: number): void;
31
35
  export declare function emitEventConsumerRearmExhausted(meter: Meter, labels: {
32
36
  readonly consumer: string;
33
37
  readonly instanceId: string;
@@ -61,6 +61,12 @@ export const STANDARD_METRIC_DEFS = [
61
61
  // per-instance consumers (one cursor per dispatcher). Without this,
62
62
  // Prometheus collapses per-instance shards into last-writer-wins and
63
63
  // per-instance lag is invisible to alerting.
64
+ {
65
+ name: "kumiko_event_consumer_pending_gaps",
66
+ type: "gauge",
67
+ description: "Number of unresolved id ranges below the consumer's cursor (ids not yet visible or proven rolled back).",
68
+ labels: ["consumer", "instance_id"],
69
+ },
64
70
  {
65
71
  name: "kumiko_event_consumer_lag_events",
66
72
  type: "gauge",
@@ -212,6 +218,12 @@ export function emitEventConsumerLag(meter, labels, lagEvents) {
212
218
  instance_id: labels.instanceId,
213
219
  });
214
220
  }
221
+ export function emitEventConsumerPendingGaps(meter, labels, gapRanges) {
222
+ meter.gauge("kumiko_event_consumer_pending_gaps").set(gapRanges, {
223
+ consumer: labels.consumer,
224
+ instance_id: labels.instanceId,
225
+ });
226
+ }
215
227
  // Caller (event-dispatcher.ts) is responsible for calling this exactly
216
228
  // once per (consumer, instanceId) transition into exhausted-dead — not
217
229
  // once per poll pass while it stays dead.
@@ -46,7 +46,9 @@ export type DispatchContext = {
46
46
  tracer: ReturnType<typeof getFallbackTracer>;
47
47
  meter: ReturnType<typeof getFallbackMeter>;
48
48
  membershipQuery: string;
49
+ memberReadTimeoutMs: number;
49
50
  };
51
+ export declare const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10000;
50
52
  export declare function resolveDbSource(ctx: DispatchContext, tx: DbTx | undefined): DbConnection | DbTx | undefined;
51
53
  export declare function isMemberResolutionPrincipal(user: SessionUser): boolean;
52
54
  export declare function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext;
@@ -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
  }