@cosmicdrift/kumiko-framework 0.334.0 → 0.336.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 (178) hide show
  1. package/dist/api/anonymous-live-entities.d.ts +2 -0
  2. package/dist/api/anonymous-live-entities.js +17 -0
  3. package/dist/api/auth-routes.js +19 -4
  4. package/dist/api/client-ip.js +3 -6
  5. package/dist/api/extra-route.js +3 -2
  6. package/dist/api/index.d.ts +2 -1
  7. package/dist/api/index.js +1 -0
  8. package/dist/api/pii-leak-guard.js +7 -4
  9. package/dist/api/redis-sse-broker.d.ts +2 -0
  10. package/dist/api/redis-sse-broker.js +6 -7
  11. package/dist/api/routes.js +16 -17
  12. package/dist/api/server.js +20 -10
  13. package/dist/api/sse-broker.d.ts +3 -0
  14. package/dist/api/sse-broker.js +14 -0
  15. package/dist/api/sse-route.d.ts +5 -2
  16. package/dist/api/sse-route.js +71 -11
  17. package/dist/bun-db/connection.js +0 -2
  18. package/dist/bun-db/index.d.ts +1 -1
  19. package/dist/bun-db/index.js +1 -1
  20. package/dist/bun-db/query.d.ts +1 -0
  21. package/dist/bun-db/query.js +15 -4
  22. package/dist/crypto/kek-source.js +40 -36
  23. package/dist/crypto/kms-adapter.js +2 -2
  24. package/dist/db/apply-entity-event.js +8 -2
  25. package/dist/db/blind-index-cleanup.js +32 -11
  26. package/dist/db/entity-table-meta.js +5 -3
  27. package/dist/db/event-store-executor-context.js +13 -19
  28. package/dist/db/event-store-executor-read.js +45 -38
  29. package/dist/db/event-store-executor-write.js +15 -17
  30. package/dist/db/migrate-generator.js +27 -28
  31. package/dist/db/pg-error.js +9 -5
  32. package/dist/db/queries/backfill-pii.js +15 -5
  33. package/dist/db/queries/shadow-swap.js +5 -4
  34. package/dist/db/table-builder.d.ts +1 -0
  35. package/dist/db/table-builder.js +7 -15
  36. package/dist/db/tenant-db.js +18 -2
  37. package/dist/derivatives/variant-key.js +3 -1
  38. package/dist/engine/boot-validator/access-declarations.js +12 -3
  39. package/dist/engine/boot-validator/entity-handler.js +13 -1
  40. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  41. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  42. package/dist/engine/boot-validator/index.js +6 -6
  43. package/dist/engine/boot-validator/nav.d.ts +2 -1
  44. package/dist/engine/boot-validator/nav.js +10 -20
  45. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  46. package/dist/engine/boot-validator/ownership.js +2 -1
  47. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  48. package/dist/engine/boot-validator/pii-retention.js +5 -2
  49. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  50. package/dist/engine/boot-validator/query-output-columns.js +53 -5
  51. package/dist/engine/boot-validator/screens.d.ts +2 -2
  52. package/dist/engine/boot-validator/screens.js +125 -46
  53. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  54. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  55. package/dist/engine/boot-validator/zod-shape.js +17 -4
  56. package/dist/engine/build-app-schema.js +65 -20
  57. package/dist/engine/build-config-feature-schema.js +8 -1
  58. package/dist/engine/changeset-changes.js +24 -10
  59. package/dist/engine/create-app.js +10 -1
  60. package/dist/engine/extension-names.d.ts +4 -0
  61. package/dist/engine/extension-names.js +4 -0
  62. package/dist/engine/feature-ast/extractors/events.js +11 -8
  63. package/dist/engine/feature-ast/extractors/handlers.js +39 -16
  64. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  65. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  66. package/dist/engine/feature-ast/parse.d.ts +1 -0
  67. package/dist/engine/feature-ast/parse.js +18 -3
  68. package/dist/engine/feature-ast/patch.js +80 -61
  69. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  70. package/dist/engine/feature-ast/patcher.js +2 -1
  71. package/dist/engine/feature-ast/render.js +1 -1
  72. package/dist/engine/feature-entity-handlers.d.ts +1 -0
  73. package/dist/engine/feature-entity-handlers.js +2 -0
  74. package/dist/engine/index.d.ts +4 -3
  75. package/dist/engine/index.js +3 -2
  76. package/dist/engine/ownership.d.ts +1 -0
  77. package/dist/engine/ownership.js +15 -2
  78. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  79. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  80. package/dist/engine/pattern-library/shared-fields.js +17 -0
  81. package/dist/engine/registry-facade.js +3 -0
  82. package/dist/engine/registry-validate.d.ts +1 -0
  83. package/dist/engine/registry-validate.js +14 -0
  84. package/dist/engine/registry.js +2 -1
  85. package/dist/engine/role-assignment.d.ts +7 -1
  86. package/dist/engine/role-assignment.js +27 -7
  87. package/dist/engine/schema-builder.d.ts +1 -0
  88. package/dist/engine/schema-builder.js +9 -2
  89. package/dist/engine/screen-helpers.js +15 -4
  90. package/dist/engine/steps/_event-match.js +34 -7
  91. package/dist/engine/steps/_read-source.js +8 -0
  92. package/dist/engine/system-user.d.ts +1 -0
  93. package/dist/engine/system-user.js +3 -0
  94. package/dist/engine/where-rule-lint.js +22 -4
  95. package/dist/env/dry-run.js +10 -3
  96. package/dist/env/index.js +21 -1
  97. package/dist/errors/reasons.d.ts +0 -1
  98. package/dist/errors/reasons.js +2 -5
  99. package/dist/event-store/event-store.js +10 -2
  100. package/dist/event-store/provenance-append.js +4 -1
  101. package/dist/files/file-routes.d.ts +0 -1
  102. package/dist/files/file-routes.js +22 -7
  103. package/dist/files/local-provider.js +13 -10
  104. package/dist/files/provider-resolver.js +20 -1
  105. package/dist/files/types.js +16 -1
  106. package/dist/i18n/request-locale.d.ts +3 -2
  107. package/dist/i18n/request-locale.js +16 -6
  108. package/dist/i18n/required-surface-keys.js +23 -35
  109. package/dist/i18n/settings-hub-keys.js +3 -0
  110. package/dist/jobs/index.d.ts +2 -2
  111. package/dist/jobs/index.js +1 -1
  112. package/dist/jobs/job-runner.d.ts +2 -0
  113. package/dist/jobs/job-runner.js +172 -59
  114. package/dist/migrations/index.d.ts +1 -1
  115. package/dist/migrations/index.js +1 -1
  116. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  117. package/dist/migrations/pending-rebuilds.js +23 -1
  118. package/dist/observability/escape-hatch-report.d.ts +1 -0
  119. package/dist/observability/escape-hatch-report.js +3 -0
  120. package/dist/observability/index.d.ts +2 -1
  121. package/dist/observability/index.js +2 -1
  122. package/dist/observability/standard-metrics.d.ts +4 -0
  123. package/dist/observability/standard-metrics.js +16 -0
  124. package/dist/pipeline/dispatch-query.d.ts +1 -1
  125. package/dist/pipeline/dispatch-query.js +2 -2
  126. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  127. package/dist/pipeline/dispatch-shared.js +14 -9
  128. package/dist/pipeline/dispatch-stream.js +2 -5
  129. package/dist/pipeline/dispatch-write.js +22 -5
  130. package/dist/pipeline/dispatcher.js +1 -2
  131. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  132. package/dist/pipeline/event-dispatcher-delivery.js +5 -0
  133. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  134. package/dist/pipeline/event-dispatcher.js +30 -12
  135. package/dist/pipeline/index.d.ts +1 -1
  136. package/dist/pipeline/index.js +1 -1
  137. package/dist/pipeline/member-read-only-transaction.js +1 -14
  138. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  139. package/dist/pipeline/msp-rebuild.js +1 -0
  140. package/dist/pipeline/system-hooks.d.ts +1 -1
  141. package/dist/pipeline/system-hooks.js +19 -20
  142. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  143. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  144. package/dist/pipeline/write-origin.js +13 -6
  145. package/dist/redis/pubsub-signal.d.ts +2 -0
  146. package/dist/redis/pubsub-signal.js +2 -2
  147. package/dist/schema-cli.js +4 -14
  148. package/dist/search/meilisearch-adapter.js +17 -6
  149. package/dist/search/purge-subject.js +8 -3
  150. package/dist/stack/drain-event-consumers.js +3 -0
  151. package/dist/stack/drain-jobs.d.ts +3 -1
  152. package/dist/stack/drain-jobs.js +55 -13
  153. package/dist/stack/table-helpers.js +10 -8
  154. package/dist/testing/closed-connection-error.js +2 -3
  155. package/dist/testing/wait-for.js +1 -0
  156. package/dist/ui-types/index.d.ts +1 -1
  157. package/dist/ui-types/index.js +1 -1
  158. package/dist/upgrade-cli.d.ts +1 -0
  159. package/dist/upgrade-cli.js +36 -15
  160. package/package.json +4 -4
  161. package/src/changes.json +264 -0
  162. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  163. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  164. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  165. package/src/scripts/codemod/pii-personal-migration.ts +111 -44
  166. package/src/scripts/codemod/unprocessable-error-details-reason.ts +136 -42
  167. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  168. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  169. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  170. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  171. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  172. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  173. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  174. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  175. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  176. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  177. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  178. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -11,13 +11,13 @@ import { loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapsho
11
11
  import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster.js";
12
12
  import { createFileContext } from "../files/file-handle.js";
13
13
  import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale.js";
14
- import { createEscapeHatchReporter, } from "../observability/escape-hatch-report.js";
15
- import { createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
14
+ import { createEscapeHatchReporter, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
16
15
  import { buildBucketKey } from "../rate-limit/index.js";
17
16
  import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
18
17
  import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
19
18
  import { appendDomainEventCore } from "./append-event-core.js";
20
19
  import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolver.js";
20
+ import { runBatch, unwrapSingle } from "./dispatch-batch.js";
21
21
  import { executeQuery } from "./dispatch-query.js";
22
22
  import { executeWrite } from "./dispatch-write.js";
23
23
  import { dispatcherSpanAttributes, isFailedWriteResult, } from "./dispatcher-utils.js";
@@ -135,7 +135,7 @@ export function applyMemberResolutionReadOnly(handlerContext) {
135
135
  ...(handlerContext.notify && { notify: denyMemberResolutionWrite }),
136
136
  };
137
137
  }
138
- export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted) {
138
+ export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, isPostCommit) {
139
139
  const { registry, appContext: context, effectiveFeatures, jobRunner, lifecycle } = ctx;
140
140
  const isSystem = registry.isHandlerSystemScoped(type);
141
141
  // The outer dispatcher receives a DbConnection from the server/stack;
@@ -241,6 +241,13 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
241
241
  // Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
242
242
  // queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
243
243
  const bridgeSink = afterCommitHooks ?? [];
244
+ // Post-commit hooks have no transaction left, so a bare executeWrite would
245
+ // auto-commit event append, projections and inTransaction hooks separately
246
+ // and its afterCommit hooks would land in an already-flushed sink. A
247
+ // batch-of-one gives the write its own transaction and its own flush.
248
+ const writeAsUser = async (asUser, targetType, payload) => isPostCommit
249
+ ? unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin))
250
+ : executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink);
244
251
  const scheduleAfterCommit = (hook) => {
245
252
  bridgeSink.push(hook);
246
253
  };
@@ -248,7 +255,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
248
255
  const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, user, hasIdentitySwitchGrant, {
249
256
  // Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
250
257
  queryAs: (asUser, targetType, payload) => executeQuery(ctx, targetType, payload, asUser, origin, tx), // @wrapper-known semantic-alias
251
- writeAs: (asUser, targetType, payload) => executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink),
258
+ writeAs: writeAsUser,
252
259
  }, identitySwitchAudit);
253
260
  // Lazy — creates the reader (which fails closed on SYSTEM_TENANT_ID) only
254
261
  // on first actual use, not on every HandlerContext build.
@@ -289,10 +296,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
289
296
  const bridge = {
290
297
  query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx), // @wrapper-known semantic-alias
291
298
  queryAs: identitySwitch.queryAs,
292
- write: async (targetType, payload) => {
293
- const res = await executeWrite(ctx, targetType, payload, user, origin, tx, bridgeSink);
294
- return res;
295
- },
299
+ write: (targetType, payload) => writeAsUser(user, targetType, payload),
296
300
  writeAs: identitySwitch.writeAs,
297
301
  // Strict + unsafe share the same runtime — only the type-surface
298
302
  // differs. The strict signature is what's exposed to typed callers;
@@ -545,9 +549,10 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
545
549
  tenantTz = cachedTz.value;
546
550
  }
547
551
  else {
552
+ const generationAtRead = ctx.tenantTimezoneCache.generation();
548
553
  tenantTz = await config(TENANT_TIMEZONE_CONFIG_KEY);
549
554
  if (tx === undefined) {
550
- ctx.tenantTimezoneCache.set(user.tenantId, typeof tenantTz === "string" ? tenantTz : undefined);
555
+ ctx.tenantTimezoneCache.setIfFresh(user.tenantId, typeof tenantTz === "string" ? tenantTz : undefined, generationAtRead);
551
556
  }
552
557
  }
553
558
  }
@@ -1,3 +1,4 @@
1
+ import { accessInvalidationCredentialFor } from "../api/sse-broker.js";
1
2
  import { hasAccess } from "../engine/access.js";
2
3
  import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, validationErrorFromZod, } from "../errors/index.js";
3
4
  import { assertNoSecretLeak } from "../secrets/index.js";
@@ -39,13 +40,9 @@ async function* executeStreamInner(ctx, type, payload, user, origin) {
39
40
  const invalidated = new Promise((resolve) => {
40
41
  resolveInvalidated = resolve;
41
42
  });
42
- const streamCredential = {
43
- ...(user.sid !== undefined && { sid: user.sid }),
44
- ...(user.pat?.tokenId !== undefined && { patTokenId: user.pat.tokenId }),
45
- };
46
43
  const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
47
44
  resolveInvalidated?.();
48
- }, streamCredential);
45
+ }, accessInvalidationCredentialFor(user));
49
46
  let iterator;
50
47
  // When access is revoked mid-pull, `iterator.next()` is still in flight.
51
48
  // Awaiting `iterator.return()` in that state deadlocks async generators in
@@ -97,7 +97,7 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
97
97
  if (result.kind === "save") {
98
98
  await lifecycle.runPostSave(type, result, handlerContext, HookPhases.inTransaction);
99
99
  afterCommitHooks.push(async () => {
100
- const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, afterCommitHooks);
100
+ const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, true);
101
101
  await lifecycle.runPostSave(type, result, afterCommitContext, HookPhases.afterCommit);
102
102
  });
103
103
  }
@@ -105,7 +105,7 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
105
105
  await lifecycle.runPreDelete(type, result, handlerContext);
106
106
  await lifecycle.runPostDelete(type, result, handlerContext, HookPhases.inTransaction);
107
107
  afterCommitHooks.push(async () => {
108
- const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, afterCommitHooks);
108
+ const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, true);
109
109
  await lifecycle.runPostDelete(type, result, afterCommitContext, HookPhases.afterCommit);
110
110
  });
111
111
  }
@@ -115,8 +115,7 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
115
115
  //
116
116
  // Contract:
117
117
  // - `tx` is the active Drizzle transaction handle, or undefined for "no
118
- // surrounding transaction" — either no Postgres is configured, or the
119
- // transaction already committed (afterCommit phase).
118
+ // surrounding transaction" — no Postgres is configured.
120
119
  // - `afterCommitHooks` collects deferred side-effects that must only fire
121
120
  // after the transaction commits. The caller flushes them on commit, drops
122
121
  // them on rollback. executeWrite never fires them directly.
@@ -145,6 +144,23 @@ async function isParentRowHiddenFromCaller(ctx, parentEntityName, parentId, user
145
144
  return true;
146
145
  return hasTenantColumn(table) && visibleRow["tenantId"] !== user.tenantId;
147
146
  }
147
+ // A custom create handler may return a partial row. The ownership check only
148
+ // inspects the keys it is given, so a row missing the ownership-bound field
149
+ // would be denied (rule evaluated against undefined) or skipped entirely.
150
+ // Complete it from the stored row, read through the caller's tenant filter.
151
+ async function completeParentRow(ctx, parentEntityName, parentEntity, parentRow, user, tx) {
152
+ if (Object.keys(parentEntity.fields).every((fieldName) => fieldName in parentRow)) {
153
+ return parentRow;
154
+ }
155
+ const table = getTable(ctx, parentEntityName);
156
+ const source = resolveDbSource(ctx, tx);
157
+ if (!table || !source || typeof parentRow["id"] !== "string")
158
+ return parentRow;
159
+ const stored = await createTenantDb(source, user.tenantId, "tenant").fetchOne(table, {
160
+ id: parentRow["id"],
161
+ });
162
+ return stored ? { ...stored, ...parentRow } : parentRow;
163
+ }
148
164
  // Nested-write orchestration (v1: depth=1, create-only, hasMany-only).
149
165
  //
150
166
  // When a parent `:create` handler's payload carries values under keys
@@ -233,7 +249,8 @@ export async function executeNestedWrite(ctx, type, payload, user, origin, tx, a
233
249
  if (parentEntityName) {
234
250
  const parentEntity = registry.getEntity(parentEntityName);
235
251
  if (parentEntity) {
236
- const deniedField = checkWriteFieldOwnership(parentEntity, parentRow, user);
252
+ const ownershipRow = await completeParentRow(ctx, parentEntityName, parentEntity, parentRow, user, tx);
253
+ const deniedField = checkWriteFieldOwnership(parentEntity, ownershipRow, user);
237
254
  if (deniedField) {
238
255
  return writeFailure(new AccessDeniedError({
239
256
  message: `nested-write: parent row ownership check failed on field "${deniedField}" of "${type}" — refusing to attach children to a parent row the caller does not own`,
@@ -2,8 +2,7 @@ import { runWithWriteOrigin } from "../api/request-context.js";
2
2
  import { DEPRECATED_CROSS_TENANT_SIGNAL, isDeprecatedCrossTenantHandler, } from "../engine/entity-handlers.js";
3
3
  import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names.js";
4
4
  import { InternalError, reraiseAsKumikoError } from "../errors/index.js";
5
- import { createEscapeHatchReportWindow } from "../observability/escape-hatch-report.js";
6
- import { getFallbackMeter, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
5
+ import { createEscapeHatchReportWindow, getFallbackMeter, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
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";
@@ -1,4 +1,4 @@
1
- import { advanceConsumerPastEventReturning, removePendingGapReturning, selectSnapshotXmax, updateConsumerStatusReturning, } from "../db/queries/event-consumer.js";
1
+ import { advanceConsumerPastEventReturning, removePendingGapReturning, selectConsumerForUpdate, selectSnapshotXmax, updateConsumerStatusReturning, } from "../db/queries/event-consumer.js";
2
2
  import { selectNextEventIdAfter, selectSmallestVisibleIdInRanges, } from "../db/queries/event-store.js";
3
3
  import { coerceRow, extractTableInfo, selectMany } from "../db/query.js";
4
4
  import { getEventsHighWaterMark } from "../event-store/index.js";
@@ -50,6 +50,14 @@ async function requireConsumerRow(db, name, instanceId) {
50
50
  }
51
51
  return row;
52
52
  }
53
+ async function requireConsumerRowForUpdate(tx, name, instanceId) {
54
+ const raw = await selectConsumerForUpdate(tx, name, instanceId);
55
+ if (!raw) {
56
+ throw new Error(`Consumer "${name}" (instance_id="${instanceId}") has no state row — it hasn't run yet, the name is misspelled, or the instance is misspelled. ` +
57
+ `For per-instance consumers pass the instance_id explicitly; shared consumers use the default.`);
58
+ }
59
+ return coerceRow(raw, extractTableInfo(eventConsumerStateTable));
60
+ }
53
61
  async function applyConsumerStatusTransition(db, name, instanceId, targetStatus) {
54
62
  const raw = await updateConsumerStatusReturning(db, name, instanceId, targetStatus);
55
63
  const updated = raw && coerceRow(raw, extractTableInfo(eventConsumerStateTable));
@@ -84,8 +92,10 @@ export async function enableConsumer(db, name, instanceId = SHARED_INSTANCE_SENT
84
92
  // event after the cursor. Single TX so concurrent dispatcher passes can't
85
93
  // double-advance. Nothing to skip → idempotent no-op.
86
94
  export async function skipPoisonEvent(db, name, instanceId = SHARED_INSTANCE_SENTINEL) {
87
- const before = await requireConsumerRow(db, name, instanceId);
88
95
  return db.begin(async (tx) => {
96
+ // Locked read: a dispatcher turn committing new gaps between an unlocked
97
+ // read and the pending_gaps overwrite below would have them dropped.
98
+ const before = await requireConsumerRowForUpdate(tx, name, instanceId);
89
99
  const pendingGaps = before.pendingGaps;
90
100
  const failedEventId = before.lastFailedEventId;
91
101
  const failedIsPending = failedEventId !== null && pendingGaps.some((gap) => rangeContainsId(gap, failedEventId));
@@ -105,6 +105,11 @@ export async function selectConsumerCursorForUpdate(db, name, instanceId) {
105
105
  // pre-check reads pending_gaps live on every pass, so it observes that gap
106
106
  // too. In short: this can under-report idleness for one tick, never
107
107
  // over-report it.
108
+ //
109
+ // Observability: an idle turn opens no events.consumer.pass span (so no
110
+ // consumer.skip_reason either, including for a disabled consumer with nothing
111
+ // pending); the dispatcher counts it in
112
+ // kumiko_event_consumer_pass_skipped_total{reason="idle"} instead.
108
113
  export async function selectIdleConsumerKeys(db, pairs) {
109
114
  if (pairs.length === 0)
110
115
  return new Set();
@@ -31,6 +31,7 @@ export type EventDispatcher = {
31
31
  runOnce(): Promise<DispatcherPassResult>;
32
32
  ensureRegistered(): Promise<void>;
33
33
  drain(): Promise<void>;
34
+ withBackgroundPassesPaused<T>(fn: () => Promise<T>): Promise<T>;
34
35
  readonly consumers: readonly EventConsumer[];
35
36
  };
36
37
  export type EventDispatcherOptions = {
@@ -1,7 +1,7 @@
1
1
  import { selectSnapshotXmax, selectSnapshotXmin } from "../db/queries/event-consumer.js";
2
2
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
3
3
  import { EVENTS_PUBSUB_CHANNEL } from "../event-store/index.js";
4
- import { emitEventConsumerPassOutcome, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, getFallbackMeter, getFallbackTracer, } from "../observability/index.js";
4
+ import { emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, getFallbackMeter, getFallbackTracer, } from "../observability/index.js";
5
5
  import { SHARED_INSTANCE_SENTINEL } from "./event-consumer-state.js";
6
6
  import { acquireConsumerState, consumerInstanceId, deliverEvents, emitLagFromTx, fetchPendingEvents, markProcessing, persistConsumerOutcome, persistConsumerPassFailure, preRegisterConsumers, selectIdleConsumerKeys, } from "./event-dispatcher-delivery.js";
7
7
  import { partitionBurntGaps, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges.js";
@@ -132,8 +132,11 @@ export function createEventDispatcher(options) {
132
132
  // backoff-gate above already confirmed retryAtMs has elapsed, and
133
133
  // "provably nothing to deliver" is itself proof the consumer isn't
134
134
  // presently failing.
135
+ // No events.consumer.pass span is opened here (so no consumer.skip_reason either),
136
+ // which is why the skip is counted instead.
135
137
  if (idleKeys.has(key)) {
136
138
  consumerBackoff.delete(key);
139
+ emitEventConsumerPassSkipped(meter, { consumer: consumer.name, instanceId: consumerInstanceId(consumer, options.instanceId) }, "idle");
137
140
  return { processed: 0, failed: 0 };
138
141
  }
139
142
  // Registered before awaiting the slot — a turn queued behind the
@@ -170,6 +173,15 @@ export function createEventDispatcher(options) {
170
173
  // skip: errors already recorded per-consumer inside the pass
171
174
  })));
172
175
  }
176
+ let backgroundPassPauseDepth = 0;
177
+ function runBackgroundPass() {
178
+ // skip: background passes are paused
179
+ if (backgroundPassPauseDepth > 0)
180
+ return;
181
+ void runOnce().catch(() => {
182
+ // skip: per-consumer errors already recorded in the state row
183
+ });
184
+ }
173
185
  async function runOnce() {
174
186
  if (!preRegistered) {
175
187
  throw new Error("EventDispatcher.runOnce() called before start() — consumer state rows are not registered. Call start() first (production) or ensureRegistered() (tests after truncating kumiko_event_consumers).");
@@ -445,11 +457,7 @@ export function createEventDispatcher(options) {
445
457
  // any consumer that exists, including freshly-deployed ones.
446
458
  await preRegisterConsumers(db, consumers, options.instanceId);
447
459
  preRegistered = true;
448
- timer = setInterval(() => {
449
- void runOnce().catch(() => {
450
- // skip: per-consumer errors already recorded in the state row
451
- });
452
- }, pollIntervalMs);
460
+ timer = setInterval(runBackgroundPass, pollIntervalMs);
453
461
  // NOTIFY-based wake-up: subscribe on the same channel that
454
462
  // event-store.append fires on commit. Fires runOnce directly, no
455
463
  // polling round-trip. The timer stays on as a belt-and-braces
@@ -464,11 +472,7 @@ export function createEventDispatcher(options) {
464
472
  emitEventDispatcherListenConnected(meter, false);
465
473
  if (options.pgClient) {
466
474
  try {
467
- const sub = await options.pgClient.listen(EVENTS_PUBSUB_CHANNEL, () => {
468
- void runOnce().catch(() => {
469
- // skip: per-consumer errors already recorded in the state row
470
- });
471
- }, () => {
475
+ const sub = await options.pgClient.listen(EVENTS_PUBSUB_CHANNEL, runBackgroundPass, () => {
472
476
  // Fires on initial connect and again when postgres.js's own
473
477
  // re-LISTEN lands after a drop; during an outage that re-LISTEN
474
478
  // waits in its connection backoff (measured 10-30s after the DB
@@ -517,7 +521,21 @@ export function createEventDispatcher(options) {
517
521
  await preRegisterConsumers(db, consumers, options.instanceId);
518
522
  preRegistered = true;
519
523
  },
520
- drain: drainInFlightTurns,
524
+ async drain() {
525
+ await drainInFlightPasses();
526
+ await drainInFlightTurns();
527
+ },
528
+ async withBackgroundPassesPaused(fn) {
529
+ backgroundPassPauseDepth++;
530
+ try {
531
+ await drainInFlightPasses();
532
+ await drainInFlightTurns();
533
+ return await fn();
534
+ }
535
+ finally {
536
+ backgroundPassPauseDepth--;
537
+ }
538
+ },
521
539
  runOnce,
522
540
  };
523
541
  }
@@ -1,5 +1,5 @@
1
1
  export type { EscapeHatchReportWindow } from "../observability/escape-hatch-report.js";
2
- export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, } from "../observability/escape-hatch-report.js";
2
+ export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "../observability/escape-hatch-report.js";
3
3
  export type { ResolveAuthClaimsArgs } from "./auth-claims-resolver.js";
4
4
  export { resolveAuthClaims } from "./auth-claims-resolver.js";
5
5
  export { createCascadeDeleteHook } from "./cascade-handler.js";
@@ -1,4 +1,4 @@
1
- export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, } from "../observability/escape-hatch-report.js";
1
+ export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "../observability/escape-hatch-report.js";
2
2
  export { resolveAuthClaims } from "./auth-claims-resolver.js";
3
3
  export { createCascadeDeleteHook } from "./cascade-handler.js";
4
4
  export { createDispatcher, dispatcherToWriteRef } from "./dispatcher.js";
@@ -3,21 +3,8 @@ import { asRawClient, runInSavepoint, transaction } from "../db/query.js";
3
3
  import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
4
4
  import { resolveDbSource } from "./dispatch-shared.js";
5
5
  const PG_READ_ONLY_SQLSTATE = "25006";
6
- // Bun.SQL surfaces the PG SQLSTATE as `errno` (code stays "ERR_POSTGRES_SERVER_ERROR");
7
- // postgres.js surfaces it as `code`, possibly one `.cause` layer down (DrizzleQueryError-style wrapping).
8
6
  function isReadOnlyTransactionViolation(e) {
9
- if (extractPgError(e)?.code === PG_READ_ONLY_SQLSTATE)
10
- return true;
11
- if (typeof e !== "object" || e === null)
12
- return false;
13
- // @cast-boundary error-details — Bun.SQL error shape (errno)
14
- if (e.errno === PG_READ_ONLY_SQLSTATE)
15
- return true;
16
- // @cast-boundary error-details — Bun.SQL error shape (errno) under .cause
17
- const cause = e.cause;
18
- if (typeof cause !== "object" || cause === null)
19
- return false;
20
- return cause.errno === PG_READ_ONLY_SQLSTATE;
7
+ return extractPgError(e)?.code === PG_READ_ONLY_SQLSTATE;
21
8
  }
22
9
  async function runInDiscardedReadOnlySavepoint(tx, fn) {
23
10
  const discardMarker = {};
@@ -8,5 +8,6 @@ export type MspRebuildDeps = {
8
8
  readonly meter?: Meter;
9
9
  readonly onMetrics?: (result: RebuildResult) => void;
10
10
  readonly markDeadOnFailure?: boolean;
11
+ readonly signal?: AbortSignal;
11
12
  };
12
13
  export declare function rebuildMultiStreamProjection(mspName: string, deps: MspRebuildDeps): Promise<RebuildResult>;
@@ -71,6 +71,7 @@ export async function rebuildMultiStreamProjection(mspName, deps) {
71
71
  const events = await selectMany(tx, eventsTable, { type: [...subscribedTypes], id: { lte: horizon } }, { orderBy: { col: "id", direction: "asc" } });
72
72
  const upcasters = registry.getEventUpcasters();
73
73
  for (const row of events) {
74
+ deps.signal?.throwIfAborted();
74
75
  const raw = {
75
76
  id: String(row.id),
76
77
  aggregateId: row.aggregateId,
@@ -1,4 +1,4 @@
1
- import type { SseBroker } from "../api/sse-broker.js";
1
+ import { type SseBroker } from "../api/sse-broker.js";
2
2
  import type { EntityId, JobRunnerRef, Registry, TenantId } from "../engine/types/index.js";
3
3
  import type { SearchAdapter, SearchDocument } from "../search/types.js";
4
4
  import type { EventConsumer } from "./event-dispatcher.js";
@@ -1,6 +1,8 @@
1
+ import { readNonEmptyStringArray, } from "../api/sse-broker.js";
1
2
  import { collectSearchableSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "../crypto/index.js";
2
3
  import { createTenantDb, entityTableFromRegistry, rehydrateCompoundTypes } from "../db/index.js";
3
4
  import { tenantChannel } from "../engine/constants.js";
5
+ import { InternalError } from "../errors/index.js";
4
6
  // --- Search Index Consumer (async, via event-dispatcher) ---
5
7
  //
6
8
  // Search-Indexierung läuft seit D.4 als async EventConsumer über den event-
@@ -187,8 +189,13 @@ async function readProjectionRowForSearch(ctx, registry, entityName, entity, ten
187
189
  // DbConnection: event-dispatcher creation is gated on it being set
188
190
  // (api/server.ts), never a HandlerContext's already-tenant-scoped TenantDb.
189
191
  const baseDb = ctx.db;
190
- if (!baseDb)
191
- return undefined;
192
+ // undefined must keep meaning "no live row" (the caller removes the index
193
+ // entry), so a missing db fails the event for retry instead.
194
+ if (!baseDb) {
195
+ throw new InternalError({
196
+ message: `search consumer: no db on the consumer context — cannot read the live row of "${entityName}" ${entityId}`,
197
+ });
198
+ }
192
199
  const table = entityTableFromRegistry(registry, entityName, entity);
193
200
  const where = { id: entityId };
194
201
  if (entity.softDelete && table["isDeleted"]) {
@@ -359,19 +366,17 @@ export async function buildSearchDocument(entityName, entityId, state, registry)
359
366
  }
360
367
  // --- SSE Broadcast (async, via event-dispatcher) ---
361
368
  //
362
- // SSE-Broadcast läuft seit D.3 als async EventConsumer über den event-
363
- // dispatcher, nicht mehr als synchroner postSave/postDelete-hook. Das hat
364
- // zwei Konsequenzen:
369
+ // The SSE broadcast runs as an async EventConsumer on the event dispatcher,
370
+ // not as a synchronous postSave/postDelete hook. Two consequences:
365
371
  //
366
- // 1. **Event-native Payload-Shape.** Der SSE-event spiegelt den StoredEvent:
367
- // `type` ist event.type ("user.created", "unit.updated"), `data` enthält
368
- // id, aggregateType, version und die event-payload — keine künstliche
369
- // "system:event:<entity>:<verb>" Hülle mehr. Clients haben direkten
370
- // Zugriff auf `payload.changes` + `payload.previous` (wie im event-log).
371
- // 2. **Eventual consistency statt Read-after-Write.** Ein SSE-Event kommt
372
- // ~10–100ms nach dem HTTP-200 (abhängig von pollIntervalMs). UI-Clients
373
- // die auf optimistic-update setzen merken das nicht; strictly-waiting
374
- // Clients müssten poll-after-write.
372
+ // 1. **Signal only.** The frame mirrors the StoredEvent's identity: `type` is
373
+ // event.type ("user.created"), `data` holds id, aggregateType, version and
374
+ // createdAt, never the payload. The tenant channel fans out to every member
375
+ // and to anonymous connections, so field values must not enter the broker
376
+ // or Redis; the client refetches through a query with its own access check.
377
+ // 2. **Eventual consistency, not read-after-write.** A frame arrives roughly
378
+ // 10 to 100 ms after the HTTP 200 (depends on pollIntervalMs). Clients that
379
+ // must observe their own write poll after writing.
375
380
  //
376
381
  // Tests drain deterministically via `await stack.eventDispatcher.runOnce()`,
377
382
  // or via `drainEventConsumers` for this consumer name specifically.
@@ -395,7 +400,6 @@ export function createSseBroadcastEventConsumer(sseBroker) {
395
400
  id: event.aggregateId,
396
401
  aggregateType: event.aggregateType,
397
402
  version: event.version,
398
- payload: event.payload,
399
403
  createdAt: event.createdAt,
400
404
  },
401
405
  });
@@ -511,11 +515,6 @@ function readUserIdFromPreviousSnapshot(payload) {
511
515
  const userId = previous["userId"];
512
516
  return typeof userId === "string" && userId.length > 0 ? userId : undefined;
513
517
  }
514
- function readNonEmptyStringArray(value) {
515
- if (!Array.isArray(value) || value.length === 0)
516
- return undefined;
517
- return value.every((v) => typeof v === "string") ? value : undefined;
518
- }
519
518
  // session-revoked's scope resolution, in order:
520
519
  // 1. streamScope === "revoked-sessions" with a non-empty sessionIds array
521
520
  // (only the raw sessionRevoker callback sets this) → narrow "sessions"
@@ -10,6 +10,8 @@ export type TenantTimezoneCacheEntry = {
10
10
  export type TenantTimezoneCache = {
11
11
  get(tenantId: TenantId): TenantTimezoneCacheEntry | undefined;
12
12
  set(tenantId: TenantId, value: string | undefined): void;
13
+ generation(): number;
14
+ setIfFresh(tenantId: TenantId, value: string | undefined, generationAtRead: number): void;
13
15
  invalidate(tenantId: TenantId): void;
14
16
  clear(): void;
15
17
  size(): number;
@@ -10,20 +10,27 @@ const DEFAULT_TTL_MS = 5 * 60 * 1000;
10
10
  const DEFAULT_MAX_ENTRIES = 1000;
11
11
  export function createTenantTimezoneCache(opts = {}) {
12
12
  const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
13
- const maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
13
+ // 0 or negative would never evict (size < max is never true) and grow unbounded.
14
+ const maxEntries = Math.max(1, opts.maxEntries ?? DEFAULT_MAX_ENTRIES);
14
15
  const now = opts.now ?? (() => Date.now());
15
16
  // Map insertion order doubles as LRU order: touch = delete+re-insert.
16
17
  const entries = new Map();
18
+ let generation = 0;
17
19
  function evictOldestIfFull() {
18
20
  // skip: cache has room, nothing to evict
19
21
  if (entries.size < maxEntries)
20
22
  return;
21
23
  const oldestKey = entries.keys().next().value;
22
- // skip: defensive — only reachable if maxEntries is 0 (cache disabled) and entries is empty
24
+ // skip: defensive — maxEntries >= 1 guarantees a non-empty map here
23
25
  if (oldestKey === undefined)
24
26
  return;
25
27
  entries.delete(oldestKey);
26
28
  }
29
+ function set(tenantId, value) {
30
+ entries.delete(tenantId);
31
+ evictOldestIfFull();
32
+ entries.set(tenantId, { value, expiresAt: now() + ttlMs });
33
+ }
27
34
  return {
28
35
  get(tenantId) {
29
36
  const hit = entries.get(tenantId);
@@ -37,15 +44,22 @@ export function createTenantTimezoneCache(opts = {}) {
37
44
  entries.set(tenantId, hit);
38
45
  return { value: hit.value };
39
46
  },
40
- set(tenantId, value) {
41
- entries.delete(tenantId);
42
- evictOldestIfFull();
43
- entries.set(tenantId, { value, expiresAt: now() + ttlMs });
47
+ set,
48
+ generation() {
49
+ return generation;
50
+ },
51
+ setIfFresh(tenantId, value, generationAtRead) {
52
+ // skip: an invalidation ran since the read started, the value may be pre-write
53
+ if (generationAtRead !== generation)
54
+ return;
55
+ set(tenantId, value);
44
56
  },
45
57
  invalidate(tenantId) {
58
+ generation++;
46
59
  entries.delete(tenantId);
47
60
  },
48
61
  clear() {
62
+ generation++;
49
63
  entries.clear();
50
64
  },
51
65
  size() {
@@ -49,8 +49,14 @@ export function rootWriteOrigin(registry, type, user) {
49
49
  // Owner binding is ignored (honorOwnerBinding=false): all anonymous callers share one
50
50
  // user id, so from("user:id", ...) vouches for nobody.
51
51
  const personalDataTableMaps = new WeakMap();
52
+ const personalColumnsByEntity = new WeakMap();
52
53
  function personalColumnNames(entity) {
53
- return new Set([...personalFieldNames(entity, false)].map(toSnakeCase));
54
+ const cached = personalColumnsByEntity.get(entity);
55
+ if (cached)
56
+ return cached;
57
+ const columns = new Set([...personalFieldNames(entity, false)].map(toSnakeCase));
58
+ personalColumnsByEntity.set(entity, columns);
59
+ return columns;
54
60
  }
55
61
  function buildPersonalDataTableMap(registry) {
56
62
  const map = new Map();
@@ -73,21 +79,22 @@ function personalDataTableMap(registry) {
73
79
  personalDataTableMaps.set(registry, built);
74
80
  return built;
75
81
  }
76
- // Names fields only, never values: the error reaches the anonymous HTTP caller.
82
+ // The error reaches the anonymous HTTP caller, so table and column names stay out of
83
+ // message/details (they would map the DB schema); they travel in `cause`, which the
84
+ // serializer never sends to clients. The 4xx log line carries only status and code, so
85
+ // `cause` is not logged today; it is only available to in-process error handling.
77
86
  export function publicIntakeRequiredError(origin, target, fields) {
78
87
  return new AccessDeniedError({
79
- message: `Anonymous root handler "${origin.rootHandler}" wrote personal-data field(s) ` +
80
- `${fields.map((f) => `"${f}"`).join(", ")} on "${target}"` +
88
+ message: `Anonymous root handler "${origin.rootHandler}" wrote personal data` +
81
89
  (origin.viaJob ? ` via job "${origin.viaJob}"` : "") +
82
90
  '. Declare access: { roles: [..., "anonymous"], personalData: "public-intake" } on ' +
83
91
  `"${origin.rootHandler}" to allow anonymous callers to write personal data.`,
84
92
  details: {
85
93
  reason: FrameworkReasons.publicIntakeRequired,
86
94
  rootHandler: origin.rootHandler,
87
- target,
88
- fields,
89
95
  ...(origin.viaJob !== undefined && { job: origin.viaJob }),
90
96
  },
97
+ cause: new Error(`personal-data field(s) ${fields.map((f) => `"${f}"`).join(", ")} written on "${target}"`),
91
98
  });
92
99
  }
93
100
  // Without an entity the table name is looked up in the registry map; tables outside it
@@ -1,3 +1,4 @@
1
+ import { type RedisClientOptions } from "./client.js";
1
2
  export type PubSubSignal = {
2
3
  publish(channel: string, payload: unknown): void;
3
4
  onMessage(listener: (channel: string, payload: unknown) => void): void;
@@ -7,5 +8,6 @@ export type PubSubSignalOptions = {
7
8
  readonly redisUrl: string;
8
9
  readonly channelPattern: string;
9
10
  readonly label: string;
11
+ readonly clientOptions?: RedisClientOptions;
10
12
  };
11
13
  export declare function createRedisPubSubSignal(opts: PubSubSignalOptions): PubSubSignal;
@@ -9,8 +9,8 @@ function logConnectionError(label, role) {
9
9
  };
10
10
  }
11
11
  export function createRedisPubSubSignal(opts) {
12
- const publisher = createRedisClient(opts.redisUrl);
13
- const subscriber = createRedisClient(opts.redisUrl);
12
+ const publisher = createRedisClient(opts.redisUrl, opts.clientOptions);
13
+ const subscriber = createRedisClient(opts.redisUrl, opts.clientOptions);
14
14
  publisher.on("error", logConnectionError(opts.label, "publisher"));
15
15
  subscriber.on("error", logConnectionError(opts.label, "subscriber"));
16
16
  const listeners = new Set();
@@ -14,7 +14,7 @@ import { assertValidMigrationName, baselineMigrations, createDbConnection, diffR
14
14
  import { validateBoot } from "./engine/boot-validator.js";
15
15
  import { createRegistry } from "./engine/registry.js";
16
16
  import { createEventsTable } from "./event-store/index.js";
17
- import { queueRebuildsFromMarkers, runPendingRebuilds } from "./migrations/index.js";
17
+ import { queueRebuildsFromMarkers, reportPendingRebuildRun, runPendingRebuilds, } from "./migrations/index.js";
18
18
  import { createEventConsumerStateTable, createProjectionStateTable } from "./pipeline/index.js";
19
19
  import { ensureTemporalPolyfill } from "./time/index.js";
20
20
  const SNAPSHOT_FILENAME = ".snapshot.json";
@@ -49,6 +49,7 @@ function nextSequenceNumber(migrationsDir) {
49
49
  }
50
50
  return max + 1;
51
51
  }
52
+ const SCHEMA_SUBCOMMANDS = ["generate", "validate", "apply", "baseline", "status"];
52
53
  /**
53
54
  * Runs a schema-CLI subcommand. `appCwd` is the app workspace root (where
54
55
  * `kumiko/schema.ts` + `kumiko/migrations/` live). Returns a process exit code.
@@ -319,18 +320,7 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
319
320
  });
320
321
  const registry = createRegistry(options.features);
321
322
  const rebuildRun = await runPendingRebuilds(db, registry, { thisRunTables });
322
- if (rebuildRun.rebuilt.length > 0) {
323
- out.log(` Rebuild ${rebuildRun.rebuilt.length} Projection(s)…`);
324
- for (const r of rebuildRun.rebuilt) {
325
- out.log(` ↻ ${r.projection} (${r.eventsProcessed} events)`);
326
- }
327
- out.log("");
328
- }
329
- if (rebuildRun.failed.length > 0) {
330
- throw new Error(`Projection rebuild failed for: ${rebuildRun.failed
331
- .map((f) => `${f.projection} (${f.error})`)
332
- .join("; ")}. Table(s) stay queued in kumiko_pending_rebuilds — retried on the next apply.`);
333
- }
323
+ reportPendingRebuildRun(rebuildRun, out.log);
334
324
  }
335
325
  return 0;
336
326
  }
@@ -421,7 +411,7 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
421
411
  // move on without ever running a migration.
422
412
  if (sub !== undefined) {
423
413
  out.err(` Unbekanntes Subcommand: "${sub}"`);
424
- out.err(" Erlaubt: generate | validate | apply | baseline | status");
414
+ out.err(` Erlaubt: ${SCHEMA_SUBCOMMANDS.join(" | ")}`);
425
415
  return 1;
426
416
  }
427
417
  out.log("");