@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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 (281) hide show
  1. package/AGENTS.md +338 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +446 -0
  4. package/agents/examples/anonymous-access.md +288 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +319 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +315 -0
  20. package/agents/examples/custom-handlers.md +444 -0
  21. package/agents/examples/dashboard-stats.md +229 -0
  22. package/agents/examples/delivery-notifications.md +432 -0
  23. package/agents/examples/designer-demo.md +86 -0
  24. package/agents/examples/e2e-generator.md +211 -0
  25. package/agents/examples/embedded-entity-form.md +598 -0
  26. package/agents/examples/embedded.md +649 -0
  27. package/agents/examples/encrypted-tenant-config.md +414 -0
  28. package/agents/examples/error-contract.md +535 -0
  29. package/agents/examples/event-sourcing.md +1287 -0
  30. package/agents/examples/feature-toggles.md +274 -0
  31. package/agents/examples/field-access.md +271 -0
  32. package/agents/examples/files-post-processing.md +433 -0
  33. package/agents/examples/i18n.md +166 -0
  34. package/agents/examples/idempotency.md +179 -0
  35. package/agents/examples/inbound-mail-triage.md +315 -0
  36. package/agents/examples/lane-routing.md +246 -0
  37. package/agents/examples/ledger-banking.md +215 -0
  38. package/agents/examples/ledger-invoicing.md +236 -0
  39. package/agents/examples/legal-pages.md +393 -0
  40. package/agents/examples/lifecycle-hooks.md +368 -0
  41. package/agents/examples/managed-config.md +428 -0
  42. package/agents/examples/managed-pages.md +353 -0
  43. package/agents/examples/notes-history-basic.md +346 -0
  44. package/agents/examples/ownership.md +404 -0
  45. package/agents/examples/pdf-audit-export.md +306 -0
  46. package/agents/examples/pipeline-basics.md +792 -0
  47. package/agents/examples/public-share-token.md +351 -0
  48. package/agents/examples/rate-limiting.md +211 -0
  49. package/agents/examples/realtime-sse.md +187 -0
  50. package/agents/examples/record-detail-layout.md +297 -0
  51. package/agents/examples/reference-data.md +151 -0
  52. package/agents/examples/relations.md +280 -0
  53. package/agents/examples/screens-nav.md +353 -0
  54. package/agents/examples/search.md +233 -0
  55. package/agents/examples/secrets-demo.md +417 -0
  56. package/agents/examples/security-baseline.md +187 -0
  57. package/agents/examples/seed-migration.md +99 -0
  58. package/agents/examples/session-revocation.md +250 -0
  59. package/agents/examples/state-machine.md +920 -0
  60. package/agents/examples/tags-basic.md +401 -0
  61. package/agents/examples/tenant-isolation.md +173 -0
  62. package/agents/examples/tenant-settings.md +275 -0
  63. package/agents/examples/tier-admin.md +347 -0
  64. package/agents/examples/timezones.md +366 -0
  65. package/agents/examples/user-data-rights.md +418 -0
  66. package/agents/examples/user-profile.md +226 -0
  67. package/agents/examples/webhook-step.md +597 -0
  68. package/agents/examples/wizard-form.md +379 -0
  69. package/agents/examples/workflow-engine.md +1731 -0
  70. package/agents/examples/writeform-section.md +300 -0
  71. package/agents/examples.md +75 -0
  72. package/agents/features.md +768 -0
  73. package/agents/widgets.md +181 -0
  74. package/dist/api/auth-middleware.d.ts +2 -0
  75. package/dist/api/auth-middleware.js +7 -0
  76. package/dist/api/auth-routes.d.ts +0 -1
  77. package/dist/api/auth-routes.js +49 -46
  78. package/dist/api/extra-route.d.ts +3 -0
  79. package/dist/api/index.d.ts +2 -2
  80. package/dist/api/index.js +1 -1
  81. package/dist/api/routes.js +46 -17
  82. package/dist/api/server.d.ts +4 -3
  83. package/dist/api/server.js +39 -10
  84. package/dist/api/session-cookies.d.ts +1 -0
  85. package/dist/api/session-cookies.js +20 -5
  86. package/dist/api/sse-route.js +16 -1
  87. package/dist/bun-db/connection.js +3 -16
  88. package/dist/bun-db/index.d.ts +1 -1
  89. package/dist/bun-db/index.js +1 -1
  90. package/dist/bun-db/query.d.ts +15 -3
  91. package/dist/bun-db/query.js +76 -24
  92. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  93. package/dist/crypto/owner-transition-rekey.js +7 -3
  94. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  95. package/dist/crypto/pii-field-encryption.js +9 -2
  96. package/dist/crypto/subject-resolver.d.ts +1 -0
  97. package/dist/crypto/subject-resolver.js +7 -0
  98. package/dist/db/api.d.ts +4 -3
  99. package/dist/db/apply-entity-event.js +26 -2
  100. package/dist/db/blind-index-cleanup.js +2 -1
  101. package/dist/db/bun-provider.js +9 -18
  102. package/dist/db/collect-table-metas.js +11 -7
  103. package/dist/db/dialect.d.ts +1 -1
  104. package/dist/db/dialect.js +2 -0
  105. package/dist/db/eagerload.js +4 -3
  106. package/dist/db/event-store-executor-context.d.ts +6 -6
  107. package/dist/db/event-store-executor-context.js +25 -22
  108. package/dist/db/event-store-executor-read.js +31 -16
  109. package/dist/db/event-store-executor-write.d.ts +1 -1
  110. package/dist/db/event-store-executor-write.js +84 -63
  111. package/dist/db/index.d.ts +2 -1
  112. package/dist/db/index.js +2 -1
  113. package/dist/db/lock-guard.d.ts +4 -0
  114. package/dist/db/lock-guard.js +14 -0
  115. package/dist/db/migrate-generator.js +1 -1
  116. package/dist/db/migrate-runner.d.ts +1 -0
  117. package/dist/db/migrate-runner.js +64 -38
  118. package/dist/db/queries/backfill-pii.js +63 -25
  119. package/dist/db/queries/event-consumer.d.ts +6 -6
  120. package/dist/db/queries/event-consumer.js +2 -1
  121. package/dist/db/queries/event-store-transfer.js +15 -4
  122. package/dist/db/queries/event-store.d.ts +1 -1
  123. package/dist/db/queries/event-store.js +11 -10
  124. package/dist/db/queries/pii-backfill-state.js +3 -2
  125. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  126. package/dist/db/queries/postgres-array-literal.js +6 -0
  127. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  128. package/dist/db/queries/projection-rebuild.js +3 -2
  129. package/dist/db/queries/shadow-swap.d.ts +1 -1
  130. package/dist/db/queries/shadow-swap.js +12 -8
  131. package/dist/db/query.d.ts +1 -1
  132. package/dist/db/query.js +1 -1
  133. package/dist/db/retired-framework-tables.js +22 -4
  134. package/dist/db/table-builder.js +6 -2
  135. package/dist/db/tenant-db-runner.d.ts +4 -0
  136. package/dist/db/tenant-db-runner.js +17 -0
  137. package/dist/db/tenant-db.d.ts +4 -0
  138. package/dist/db/tenant-db.js +50 -5
  139. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  140. package/dist/derivatives/delete-stored-file.js +9 -0
  141. package/dist/derivatives/derivatives-context.js +1 -1
  142. package/dist/derivatives/index.d.ts +1 -1
  143. package/dist/derivatives/index.js +1 -1
  144. package/dist/engine/boot-validator/access-declarations.js +2 -2
  145. package/dist/engine/boot-validator/api-ext.js +3 -3
  146. package/dist/engine/boot-validator/boot-check.js +1 -1
  147. package/dist/engine/boot-validator/config-deps.js +13 -13
  148. package/dist/engine/boot-validator/detail-screens.js +1 -1
  149. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  150. package/dist/engine/boot-validator/entity-handler.js +87 -35
  151. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  152. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  153. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  154. package/dist/engine/boot-validator/nav.js +1 -1
  155. package/dist/engine/boot-validator/pii-retention.js +8 -8
  156. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  157. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  158. package/dist/engine/boot-validator/query-refs.js +14 -2
  159. package/dist/engine/boot-validator/screens.d.ts +2 -1
  160. package/dist/engine/boot-validator/screens.js +153 -95
  161. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  162. package/dist/engine/dedupe-features.js +31 -4
  163. package/dist/engine/define-handler.d.ts +2 -2
  164. package/dist/engine/define-handler.js +8 -1
  165. package/dist/engine/entity-handlers.js +14 -7
  166. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  167. package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
  168. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  169. package/dist/engine/feature-ast/render.js +10 -2
  170. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  171. package/dist/engine/feature-entity-handlers.js +12 -3
  172. package/dist/engine/handler-field-references.d.ts +21 -2
  173. package/dist/engine/handler-field-references.js +55 -17
  174. package/dist/engine/index.d.ts +3 -3
  175. package/dist/engine/index.js +1 -1
  176. package/dist/engine/schema-builder.js +2 -1
  177. package/dist/engine/screen-helpers.d.ts +1 -0
  178. package/dist/engine/screen-helpers.js +2 -2
  179. package/dist/engine/soft-delete-cleanup.js +10 -3
  180. package/dist/engine/steps/_read-source.js +5 -2
  181. package/dist/engine/steps/retry.js +2 -1
  182. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  183. package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
  184. package/dist/engine/template-variables.d.ts +3 -0
  185. package/dist/engine/template-variables.js +3 -0
  186. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  187. package/dist/engine/tier-resolver-extension.js +6 -0
  188. package/dist/engine/types/index.d.ts +1 -1
  189. package/dist/entrypoint/index.d.ts +2 -2
  190. package/dist/env/_zod-introspect.d.ts +1 -0
  191. package/dist/errors/classes.js +1 -0
  192. package/dist/errors/docs-url.js +6 -6
  193. package/dist/errors/index.d.ts +1 -1
  194. package/dist/errors/index.js +1 -1
  195. package/dist/errors/kumiko-error.d.ts +2 -0
  196. package/dist/errors/kumiko-error.js +2 -0
  197. package/dist/errors/subject-erased.d.ts +1 -0
  198. package/dist/errors/subject-erased.js +6 -0
  199. package/dist/errors/write-error-info.js +2 -0
  200. package/dist/event-store/admin-api.js +3 -2
  201. package/dist/event-store/errors.d.ts +9 -0
  202. package/dist/event-store/errors.js +20 -0
  203. package/dist/event-store/event-store.d.ts +6 -0
  204. package/dist/event-store/event-store.js +28 -6
  205. package/dist/event-store/index.d.ts +1 -1
  206. package/dist/event-store/index.js +1 -1
  207. package/dist/event-store/tenant-db-append.js +2 -2
  208. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  209. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  210. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  211. package/dist/files/bind-pending-file-refs.js +3 -3
  212. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  213. package/dist/files/bind-referenced-files.write.js +22 -13
  214. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  215. package/dist/files/create-file-ref-from-bytes.js +67 -4
  216. package/dist/files/create-file-ref.d.ts +24 -1
  217. package/dist/files/create-file-ref.js +67 -25
  218. package/dist/files/dispose-removed-file-refs.js +21 -6
  219. package/dist/files/feature.d.ts +1 -0
  220. package/dist/files/feature.js +2 -1
  221. package/dist/files/file-handle.d.ts +1 -1
  222. package/dist/files/file-handle.js +12 -0
  223. package/dist/files/file-routes.js +10 -6
  224. package/dist/files/index.d.ts +3 -3
  225. package/dist/files/index.js +2 -2
  226. package/dist/files/load-save-file-refs.d.ts +10 -0
  227. package/dist/files/load-save-file-refs.js +47 -0
  228. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  229. package/dist/files/purge-expired-file-ref-bytes.js +46 -21
  230. package/dist/files/resolve-content-type.js +40 -4
  231. package/dist/files/storage-tracking.js +5 -4
  232. package/dist/files/types.js +5 -8
  233. package/dist/files/zip-entry-names.d.ts +1 -0
  234. package/dist/files/zip-entry-names.js +46 -0
  235. package/dist/jobs/index.d.ts +1 -1
  236. package/dist/jobs/job-runner.d.ts +5 -1
  237. package/dist/jobs/job-runner.js +124 -37
  238. package/dist/observability/escape-hatch-report.d.ts +2 -1
  239. package/dist/observability/escape-hatch-report.js +48 -7
  240. package/dist/observability/index.d.ts +1 -1
  241. package/dist/observability/index.js +1 -1
  242. package/dist/observability/metrics-handle.d.ts +1 -0
  243. package/dist/observability/metrics-handle.js +7 -0
  244. package/dist/observability/standard-metrics.js +4 -4
  245. package/dist/pipeline/cascade-handler.js +5 -2
  246. package/dist/pipeline/dispatch-batch.js +79 -31
  247. package/dist/pipeline/dispatch-query.d.ts +2 -1
  248. package/dist/pipeline/dispatch-query.js +6 -6
  249. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  250. package/dist/pipeline/dispatch-shared.js +136 -48
  251. package/dist/pipeline/dispatch-stream.js +10 -4
  252. package/dist/pipeline/dispatch-write.js +34 -22
  253. package/dist/pipeline/dispatcher.js +3 -1
  254. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  255. package/dist/pipeline/event-dispatcher.js +11 -9
  256. package/dist/pipeline/event-retention.d.ts +1 -0
  257. package/dist/pipeline/event-retention.js +7 -1
  258. package/dist/pipeline/index.d.ts +1 -0
  259. package/dist/pipeline/index.js +1 -0
  260. package/dist/pipeline/notify-scope.js +3 -1
  261. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  262. package/dist/pipeline/pre-transaction-reservations.js +29 -9
  263. package/dist/rate-limit/bucket.d.ts +3 -3
  264. package/dist/rate-limit/bucket.js +14 -4
  265. package/dist/redis/cache-sync-bus.js +9 -3
  266. package/dist/schema-cli.js +12 -1
  267. package/dist/search/meilisearch-adapter.js +36 -9
  268. package/dist/stack/db.d.ts +2 -2
  269. package/dist/stack/test-stack.js +2 -1
  270. package/dist/testing/index.d.ts +1 -0
  271. package/dist/testing/index.js +1 -0
  272. package/dist/testing/multipart-helper.d.ts +6 -0
  273. package/dist/testing/multipart-helper.js +1 -1
  274. package/dist/testing/zip-fixture.d.ts +1 -0
  275. package/dist/testing/zip-fixture.js +33 -0
  276. package/dist/ui-types/card-column-roles.js +4 -2
  277. package/dist/ui-types/index.d.ts +1 -1
  278. package/dist/ui-types/index.js +1 -1
  279. package/dist/upgrade-cli.js +46 -13
  280. package/package.json +7 -5
  281. package/src/changes.json +512 -8
@@ -6,16 +6,16 @@ import { createDerivativesContext } from "../derivatives/derivatives-context.js"
6
6
  import { mayOverrideTenant } from "../engine/cross-tenant.js";
7
7
  import { createSystemUser } from "../engine/system-user.js";
8
8
  import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
9
- import { AccessDeniedError, FeatureDisabledError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, } from "../errors/index.js";
9
+ import { AccessDeniedError, FeatureDisabledError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, writeFailure, } from "../errors/index.js";
10
10
  import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
11
11
  import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
12
- import { getStreamVersion, loadAggregate, loadAggregateAsOf, } from "../event-store/event-store.js";
12
+ import { loadAggregate, loadAggregateAsOf, loadAggregateForWriting, } from "../event-store/event-store.js";
13
13
  import { effectiveSnapshotGeneration, loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapshot.js";
14
14
  import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster.js";
15
15
  import { createFileContext } from "../files/file-handle.js";
16
16
  import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale.js";
17
17
  import { createFallbackLogger } from "../logging/utils.js";
18
- import { createEscapeHatchReporter, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
18
+ import { createEscapeHatchReporter, createMetricsForFactory, createMetricsHandle, createNoopMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
19
19
  import { buildBucketKey, buildPayloadBucketKey, normalizePayloadBucketValue, } from "../rate-limit/index.js";
20
20
  import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
21
21
  import { isPlainObject } from "../utils/is-plain-object.js";
@@ -112,10 +112,10 @@ async function denyMemberResolutionWrite() {
112
112
  }
113
113
  // JobRunnerRef's declared type only carries handleEvent, but callers
114
114
  // routinely bracket-access a fuller runner — a Proxy denies ANY property read.
115
- function denyingJobRunnerProxy() {
115
+ function denyingJobRunnerProxy(denied = memberResolutionReadOnlyDenied) {
116
116
  return new Proxy({}, {
117
117
  get() {
118
- throw memberResolutionReadOnlyDenied();
118
+ throw denied();
119
119
  },
120
120
  });
121
121
  }
@@ -205,7 +205,66 @@ export function applyMemberResolutionReadOnly(handlerContext) {
205
205
  }),
206
206
  };
207
207
  }
208
- export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, isPostCommit) {
208
+ function beforeTransactionDenied(surface) {
209
+ return new InternalError({
210
+ message: `ctx.${surface} is not available before the transaction; move it into the handler`,
211
+ });
212
+ }
213
+ function withoutTransactionDenied(surface) {
214
+ return new InternalError({
215
+ message: `ctx.${surface} is not available without an open transaction; run the write through ctx.write so it gets its own transaction`,
216
+ });
217
+ }
218
+ export function perCommandNestedDenied(targetType) {
219
+ return new InternalError({
220
+ message: `handler "${targetType}" runs without a transaction (transaction: "perCommand") and cannot run as a nested write inside an open transaction`,
221
+ });
222
+ }
223
+ function denySurface(buildError, surface) {
224
+ return () => {
225
+ throw buildError(surface);
226
+ };
227
+ }
228
+ function denyEventStoreSurfaces(handlerContext, buildError) {
229
+ return {
230
+ ...handlerContext,
231
+ appendEvent: denySurface(buildError, "appendEvent"),
232
+ unsafeAppendEvent: denySurface(buildError, "unsafeAppendEvent"),
233
+ tryAppendEvent: denySurface(buildError, "tryAppendEvent"),
234
+ fetchForWriting: denySurface(buildError, "fetchForWriting"),
235
+ archiveStream: denySurface(buildError, "archiveStream"),
236
+ restoreStream: denySurface(buildError, "restoreStream"),
237
+ snapshotAggregate: denySurface(buildError, "snapshotAggregate"),
238
+ };
239
+ }
240
+ // Reservations write on purpose through the outside-transaction handles, so they keep those but
241
+ // lose every surface bound to the not-yet-open transaction.
242
+ export function denyTransactionBoundSurfaces(handlerContext) {
243
+ return {
244
+ ...denyEventStoreSurfaces(handlerContext, beforeTransactionDenied),
245
+ write: denySurface(beforeTransactionDenied, "write"),
246
+ writeAs: denySurface(beforeTransactionDenied, "writeAs"),
247
+ scheduleAfterCommit: denySurface(beforeTransactionDenied, "scheduleAfterCommit"),
248
+ ...(handlerContext.notify && { notify: denySurface(beforeTransactionDenied, "notify") }),
249
+ ...(handlerContext.jobRunner && {
250
+ jobRunner: denyingJobRunnerProxy(() => beforeTransactionDenied("jobRunner")),
251
+ }),
252
+ };
253
+ }
254
+ // The beforeTransaction phase has no write need at all, so it also loses the outside-transaction handles.
255
+ export function applyBeforeTransactionReadOnly(handlerContext) {
256
+ return {
257
+ ...denyTransactionBoundSurfaces(handlerContext),
258
+ dbOutsideTransaction: undefined,
259
+ outsideTransaction: undefined,
260
+ };
261
+ }
262
+ export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, transactionScope = tx ? "inTransaction" : "withoutTransaction",
263
+ // A query bridge has no commit sink of its own, but ctx.notify inside it must still wait for
264
+ // the outer write's commit: the outer sink is lent to notify only, not to scheduleAfterCommit.
265
+ outerNotifySink,
266
+ // beforeTransaction phase: ctx.forTenant stays available but every target db denies writes.
267
+ readOnlyForTenant = false) {
209
268
  const { registry, appContext: context, effectiveFeatures, jobRunner, lifecycle } = ctx;
210
269
  const isSystem = registry.isHandlerSystemScoped(type);
211
270
  // The outer dispatcher receives a DbConnection from the server/stack;
@@ -214,7 +273,10 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
214
273
  const dbSource = resolveDbSource(ctx, tx);
215
274
  const reqCtx = requestContext.get();
216
275
  // global() writes are write-handler-only; SYSTEM identity switch and unsafeRaw accept write, query or stream escapeHatch.
217
- const writeEscapeHatch = registry.getWriteHandler(type)?.escapeHatch;
276
+ const writeHandler = registry.getWriteHandler(type);
277
+ const writeEscapeHatch = writeHandler?.escapeHatch;
278
+ const isPerCommand = writeHandler?.transaction === "perCommand";
279
+ const denyDbWrites = transactionScope === "withoutTransaction";
218
280
  const handlerEscapeHatch = writeEscapeHatch ??
219
281
  registry.getQueryHandler(type)?.escapeHatch ??
220
282
  registry.getStreamHandler(type)?.escapeHatch;
@@ -231,7 +293,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
231
293
  });
232
294
  const identitySwitchGrantReason = identitySwitchEscapeHatch?.reason ??
233
295
  (isSystem ? `r.systemScope() feature "${featureName ?? "unknown"}"` : undefined);
234
- const buildTenantScopedDb = (source, signal, foreignTenantId) => createTenantDb(source, foreignTenantId ?? user.tenantId, isSystem && foreignTenantId === undefined ? "system" : "tenant", context.tracer, context.meter, signal, {
296
+ const buildTenantScopedDb = (source, signal, foreignTenantId, denyWrites = false) => createTenantDb(source, foreignTenantId ?? user.tenantId, isSystem && foreignTenantId === undefined ? "system" : "tenant", context.tracer, context.meter, signal, {
235
297
  ...(foreignTenantId === undefined && {
236
298
  globalWrites: escapeHatchFor(writeEscapeHatch, "globalWrites"),
237
299
  unsafeRaw: escapeHatchFor(handlerEscapeHatch, "unsafeRaw"),
@@ -240,11 +302,14 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
240
302
  memberReadOnly: isMemberResolutionPrincipal(user),
241
303
  personalDataGate: buildPersonalDataGate(registry, origin),
242
304
  projectionRegistry: registry,
305
+ ...(denyWrites && { writesDeniedReason: "without an open transaction" }),
243
306
  });
244
307
  // Propagate the request's AbortSignal so every TenantDb query throws when
245
308
  // the client has disconnected — handlers with many sequential queries skip
246
309
  // the rest of the chain instead of burning DB-CPU for results no one reads.
247
- const db = dbSource ? buildTenantScopedDb(dbSource, reqCtx?.signal) : undefined;
310
+ const db = dbSource
311
+ ? buildTenantScopedDb(dbSource, reqCtx?.signal, undefined, denyDbWrites)
312
+ : undefined;
248
313
  // Unbound pool, tenant-scoped like `db` but never tx-bound — writes
249
314
  // through it survive a rollback of the handler's own transaction. No
250
315
  // AbortSignal here: a client disconnect must not abort a durability write
@@ -286,7 +351,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
286
351
  // queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
287
352
  const bridgeSink = afterCommitHooks ?? [];
288
353
  // Query bridges and post-commit contexts get a throwaway sink: hooks pushed there never run.
289
- const hasLiveCommitSink = afterCommitHooks !== undefined && isPostCommit !== true;
354
+ const hasLiveCommitSink = afterCommitHooks !== undefined && transactionScope !== "postCommit";
290
355
  const scheduleAfterCommit = (hook) => {
291
356
  bridgeSink.push(hook);
292
357
  };
@@ -297,23 +362,30 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
297
362
  const notifyInTransactionFactory = notifyFactory && tx !== undefined
298
363
  ? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, outsideTxSource, context._fileProviderResolver)
299
364
  : undefined;
365
+ const notifyAfterCommitFactory = notifyFactory
366
+ ? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, undefined, context._fileProviderResolver)
367
+ : undefined;
300
368
  const notify = buildNotify();
301
369
  function buildNotify() {
302
- if (!notifyFactory)
370
+ if (!notifyAfterCommitFactory)
303
371
  return undefined;
304
- const notifyAfterCommit = notifyFactory(user, user.tenantId, notifyJobDispatcher, undefined, context._fileProviderResolver);
372
+ const notifyAfterCommit = notifyAfterCommitFactory(user, user.tenantId);
305
373
  if (!notifyInTransactionFactory)
306
374
  return notifyAfterCommit;
307
375
  const notifyInTransaction = notifyInTransactionFactory(user, user.tenantId);
308
- // A rolled-back write must leave no notification behind.
309
- if (!hasLiveCommitSink)
376
+ // A rolled-back write must leave no notification behind. Without any commit to wait for (a
377
+ // post-commit context) delivery stays immediate.
378
+ const notifyCommitSink = hasLiveCommitSink ? bridgeSink : outerNotifySink;
379
+ if (notifyCommitSink === undefined)
310
380
  return notifyInTransaction;
311
381
  return createDeferringNotify({
312
382
  registry,
313
383
  tenantId: user.tenantId,
314
384
  notifyAfterCommit,
315
385
  notifyNow: notifyInTransaction,
316
- scheduleAfterCommit,
386
+ scheduleAfterCommit: (hook) => {
387
+ notifyCommitSink.push(hook);
388
+ },
317
389
  });
318
390
  }
319
391
  // Mirror notify: only built when the config feature wired its factory.
@@ -354,7 +426,11 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
354
426
  const forTenant = !isSystem && dbSource && registry.getWriteHandler(type) !== undefined
355
427
  ? (targetTenantId) => {
356
428
  if (targetTenantId === user.tenantId && exposedDb) {
357
- return { db: exposedDb, streamTenantId: undefined };
429
+ // Only perCommand handlers lose forTenant writes; reservations keep their pre-transaction contract.
430
+ const ownTenantDb = denyDbWrites && !isPerCommand
431
+ ? buildTenantScopedDb(dbSource, reqCtx?.signal, undefined, readOnlyForTenant)
432
+ : exposedDb;
433
+ return { db: ownTenantDb, streamTenantId: undefined };
358
434
  }
359
435
  if (!mayOverrideTenant(user)) {
360
436
  throw new AccessDeniedError({
@@ -362,7 +438,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
362
438
  });
363
439
  }
364
440
  return {
365
- db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId),
441
+ db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId, denyDbWrites && (isPerCommand || readOnlyForTenant)),
366
442
  streamTenantId: targetTenantId,
367
443
  };
368
444
  }
@@ -390,16 +466,16 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
390
466
  const metrics = meter && featureName ? createMetricsHandle(meter, featureName) : createNoopMetricsHandle();
391
467
  // ctx.metricsFor(featureName) — shared/library code binds to a feature
392
468
  // name of its own choosing instead of the dispatching handler's
393
- // (framework#1844). Unregistered names no-op rather than throw, see
394
- // createSafeMetricsHandle.
395
- const metricsFor = meter
396
- ? (targetFeatureName) => createSafeMetricsHandle(meter, targetFeatureName)
397
- : () => createNoopMetricsHandle();
469
+ // (framework#1844). Unregistered names no-op rather than throw.
470
+ const metricsFor = createMetricsForFactory(meter);
398
471
  // A failed inner write has already appended its event and projections when a later
399
472
  // step (e.g. an inTransaction hook) fails, and the outer handler may swallow the
400
473
  // failure result. The savepoint makes the failure undo exactly the inner write; the
401
474
  // inner afterCommit hooks only reach the outer sink if it succeeded.
402
- const executeNestedWriteInSavepoint = async (asUser, targetType, payload) => {
475
+ const runNestedWriteInSavepoint = async (asUser, targetType, payload) => {
476
+ if (registry.getWriteHandler(targetType)?.transaction === "perCommand") {
477
+ return writeFailure(perCommandNestedDenied(targetType));
478
+ }
403
479
  if (!tx)
404
480
  return executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink);
405
481
  const innerAfterCommitHooks = [];
@@ -420,17 +496,26 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
420
496
  throw e;
421
497
  }
422
498
  };
423
- // Post-commit hooks have no transaction left, so a bare executeWrite would
424
- // auto-commit event append, projections and inTransaction hooks separately
425
- // and its afterCommit hooks would land in an already-flushed sink. A
426
- // batch-of-one gives the write its own transaction and its own flush.
427
- const writeAsUser = async (asUser, targetType, payload) => isPostCommit
428
- ? unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin))
429
- : executeNestedWriteInSavepoint(asUser, targetType, payload);
499
+ // Savepoints on one connection are not serialized by the driver: two parallel nested writes would
500
+ // interleave their statements and a ROLLBACK TO of the first would also discard the second's rows.
501
+ // Writes nested one level deeper run on their own savepoint handle and therefore their own chain.
502
+ let nestedWriteChain = Promise.resolve();
503
+ const executeNestedWriteInSavepoint = (asUser, targetType, payload) => {
504
+ const run = nestedWriteChain.then(() => runNestedWriteInSavepoint(asUser, targetType, payload));
505
+ nestedWriteChain = run.catch(() => undefined);
506
+ return run;
507
+ };
508
+ // Without an open transaction (post-commit hooks, stream/query handlers, perCommand handlers) a bare
509
+ // executeWrite would auto-commit event append, projections and inTransaction hooks separately and its
510
+ // afterCommit hooks would land in a sink nobody flushes. A batch-of-one gives the write its own
511
+ // transaction and its own flush.
512
+ const writeAsUser = async (asUser, targetType, payload) => transactionScope === "inTransaction"
513
+ ? executeNestedWriteInSavepoint(asUser, targetType, payload)
514
+ : unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin));
430
515
  const identitySwitchAudit = { reason: identitySwitchGrantReason, report: reportEscapeHatch };
431
516
  const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, user, hasIdentitySwitchGrant, {
432
517
  // Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
433
- queryAs: (asUser, targetType, payload) => executeQuery(ctx, targetType, payload, asUser, origin, tx), // @wrapper-known semantic-alias
518
+ queryAs: (asUser, targetType, payload) => executeQuery(ctx, targetType, payload, asUser, origin, tx, hasLiveCommitSink ? bridgeSink : outerNotifySink), // @wrapper-known semantic-alias
434
519
  writeAs: writeAsUser,
435
520
  }, identitySwitchAudit);
436
521
  // Lazy — creates the reader (which fails closed on SYSTEM_TENANT_ID) only
@@ -474,7 +559,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
474
559
  return projTable === undefined || hasTenantColumn(projTable);
475
560
  });
476
561
  const bridge = {
477
- query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx), // @wrapper-known semantic-alias
562
+ query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx, hasLiveCommitSink ? bridgeSink : outerNotifySink), // @wrapper-known semantic-alias
478
563
  queryAs: identitySwitch.queryAs,
479
564
  write: (targetType, payload) => writeAsUser(user, targetType, payload),
480
565
  writeAs: identitySwitch.writeAs,
@@ -529,16 +614,9 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
529
614
  message: `ctx.fetchForWriting("${args.aggregateId}") requires a database connection — none is configured.`,
530
615
  });
531
616
  }
532
- // Stream-version authoritative (same policy as CRUD executor + Block 0).
533
- // A single SELECT MAX(version) is cheaper than loading the full stream
534
- // when the caller just wants to append — but most callers also want
535
- // the events (business-rule checks), so fetch both in parallel.
536
- const [storedEvents, fetchedVersion] = await Promise.all([
537
- loadAggregate(dbSource, args.aggregateId, user.tenantId, {
538
- aggregateType: args.aggregateType,
539
- }),
540
- getStreamVersion(dbSource, args.aggregateId, user.tenantId),
541
- ]);
617
+ // Stream-version authoritative (same policy as CRUD executor + Block 0), read together
618
+ // with the events so the pinned version covers exactly what the handler's rules saw.
619
+ const { events: storedEvents, streamVersion: fetchedVersion } = await loadAggregateForWriting(dbSource, args.aggregateId, user.tenantId, { aggregateType: args.aggregateType });
542
620
  const events = await upcastStoredEvents(storedEvents, registry.getEventUpcasters(), {
543
621
  db: dbSource,
544
622
  tenantId: user.tenantId,
@@ -761,12 +839,14 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
761
839
  const locale = reqCtx?.locale ?? safeUserLocale ?? context.defaultLocale ?? DEFAULT_LOCALE;
762
840
  // The payload-bucket digest is dispatcher-internal; handlers must not be
763
841
  // able to compute bucket keys.
764
- const { _rateLimitPayloadDigest: _omitPayloadDigest,
842
+ const { _rateLimitPayloadDigest: payloadDigest,
765
843
  // Bound to this call's tx: an outer context's factory must not leak into a tx-less one.
766
- _notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, ...contextForHandler } = context;
844
+ _notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, _notifyAfterCommitFactory: _omitOuterNotifyAfterCommitFactory, ...contextForHandler } = context;
767
845
  const handlerContext = {
768
846
  ...contextForHandler,
847
+ ...(payloadDigest && { rateLimitDigest: createHandlerBoundDigest(payloadDigest, type) }),
769
848
  ...(notifyInTransactionFactory && { _notifyInTransactionFactory: notifyInTransactionFactory }),
849
+ ...(notifyAfterCommitFactory && { _notifyAfterCommitFactory: notifyAfterCommitFactory }),
770
850
  registry,
771
851
  db: exposedDb,
772
852
  dbOutsideTransaction,
@@ -816,10 +896,18 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
816
896
  ...(includeDeleted && { includeDeleted: true }),
817
897
  ...bridge,
818
898
  }; // @cast-boundary engine-bridge
899
+ const scopedContext = transactionScope === "withoutTransaction"
900
+ ? denyEventStoreSurfaces(handlerContext, withoutTransactionDenied)
901
+ : handlerContext;
819
902
  // A resolved member principal is read-only by construction.
820
903
  return isMemberResolutionPrincipal(user)
821
- ? applyMemberResolutionReadOnly(handlerContext)
822
- : handlerContext;
904
+ ? applyMemberResolutionReadOnly(scopedContext)
905
+ : scopedContext;
906
+ }
907
+ // The handler-name prefix keeps a handler from reproducing the digests the
908
+ // dispatcher uses for payload buckets (those hash the bare normalized value).
909
+ export function createHandlerBoundDigest(digest, handlerName) {
910
+ return (value) => digest(`handler:${handlerName}:${value}`);
823
911
  }
824
912
  // Resolves the agent risk the irreversible-operation gate should attribute to
825
913
  // `type` when it becomes the request's entry handler — same defaulting rule
@@ -1107,7 +1195,7 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
1107
1195
  message: `Handler "${handlerName}" declares additionalRateLimits but its payload is not an object.`,
1108
1196
  });
1109
1197
  }
1110
- for (const { per, limit, windowSeconds, cost } of limits) {
1198
+ for (const { per, limit, windowSeconds, cost, normalize } of limits) {
1111
1199
  const value = data[per.payloadField];
1112
1200
  // skip: optional field not sent — nothing to bucket on
1113
1201
  if (value === undefined)
@@ -1117,7 +1205,7 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
1117
1205
  message: `Handler "${handlerName}" additionalRateLimits payloadField "${per.payloadField}" is not a string.`,
1118
1206
  });
1119
1207
  }
1120
- const key = buildPayloadBucketKey(handlerName, per.payloadField, digest(normalizePayloadBucketValue(value)));
1208
+ const key = buildPayloadBucketKey(user.tenantId, handlerName, per.payloadField, digest(normalizePayloadBucketValue(value, normalize)));
1121
1209
  await context.rateLimit.enforce(key, { limit, windowSeconds, cost });
1122
1210
  }
1123
1211
  }
@@ -2,7 +2,7 @@ import { accessInvalidationCredentialFor } from "../api/sse-broker.js";
2
2
  import { scheduleTokenExpiry } from "../api/token-expiry-timer.js";
3
3
  import { hasAccess } from "../engine/access.js";
4
4
  import { tenantOverrideDenied } from "../engine/cross-tenant.js";
5
- import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, validationErrorFromZod, } from "../errors/index.js";
5
+ import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, UnauthenticatedError, validationErrorFromZod, } from "../errors/index.js";
6
6
  import { assertNoSecretLeak } from "../secrets/index.js";
7
7
  import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, isMemberResolutionPrincipal, runStreamInstrumented, } from "./dispatch-shared.js";
8
8
  import { handlerAccessError } from "./handler-access-error.js";
@@ -69,10 +69,16 @@ async function* executeStreamInner(ctx, type, payload, user, origin, options) {
69
69
  abandonedForInvalidation = true;
70
70
  void nextPull.catch(() => { });
71
71
  void iterator.return?.(undefined)?.then(undefined, () => { });
72
+ // An expired token is a lapsed session (401, so clients show the
73
+ // session-end hint); revocation is a permission change (403).
74
+ if (outcome.reason === "expired") {
75
+ throw new UnauthenticatedError({
76
+ message: `token expired mid-stream for ${type}`,
77
+ details: { handler: type, reason: outcome.reason },
78
+ });
79
+ }
72
80
  throw new AccessDeniedError({
73
- message: outcome.reason === "expired"
74
- ? `token expired mid-stream for ${type}`
75
- : `access revoked mid-stream for ${type}`,
81
+ message: `access revoked mid-stream for ${type}`,
76
82
  details: { handler: type },
77
83
  });
78
84
  }
@@ -11,12 +11,13 @@ import { AccessDeniedError, FrameworkReasons, InternalError, memberResolutionRea
11
11
  import { assertFileRefsBelongToTenant } from "../files/assert-file-refs-belong-to-tenant.js";
12
12
  import { bindPendingFileRefs } from "../files/bind-pending-file-refs.js";
13
13
  import { disposeRemovedFileRefs } from "../files/dispose-removed-file-refs.js";
14
+ import { loadSaveFileRefs } from "../files/load-save-file-refs.js";
14
15
  import { CACHE_SYNC_TOPICS } from "../redis/cache-sync-topics.js";
15
16
  import { assertNoSecretLeak } from "../secrets/index.js";
16
- import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, isMemberResolutionPrincipal, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
17
+ import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, isMemberResolutionPrincipal, perCommandNestedDenied, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
17
18
  import { describeShape, extractNestedSpecs, isLifecycleResult, isWriteResultShape, prefixValidationPath, wrapToKumiko, } from "./dispatcher-utils.js";
18
19
  import { runPreHandlerGates } from "./pre-handler-gates.js";
19
- import { consumeReservation } from "./pre-transaction-reservations.js";
20
+ import { consumeReservation, hasPreTransactionPhase, } from "./pre-transaction-reservations.js";
20
21
  import { runProjections } from "./projections-runner.js";
21
22
  function getTable(ctx, entityName) {
22
23
  const { registry, tableCache } = ctx;
@@ -115,12 +116,13 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
115
116
  await runProjections(result, handlerContext.registry, runner);
116
117
  if (result.kind === "save") {
117
118
  const fileRefArgs = { registry: handlerContext.registry, result, user, db: handlerContext.db };
118
- await assertFileRefsBelongToTenant(fileRefArgs);
119
- await bindPendingFileRefs(fileRefArgs);
119
+ const fileRefRows = await loadSaveFileRefs(fileRefArgs);
120
+ assertFileRefsBelongToTenant({ ...fileRefArgs, fileRefRows });
121
+ await bindPendingFileRefs({ ...fileRefArgs, fileRefRows });
120
122
  await disposeRemovedFileRefs(fileRefArgs);
121
123
  await lifecycle.runPostSave(type, result, handlerContext, HookPhases.inTransaction);
122
124
  afterCommitHooks.push(async () => {
123
- const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, true);
125
+ const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, "postCommit");
124
126
  await lifecycle.runPostSave(type, result, afterCommitContext, HookPhases.afterCommit);
125
127
  });
126
128
  }
@@ -128,7 +130,7 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
128
130
  await lifecycle.runPreDelete(type, result, handlerContext);
129
131
  await lifecycle.runPostDelete(type, result, handlerContext, HookPhases.inTransaction);
130
132
  afterCommitHooks.push(async () => {
131
- const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, true);
133
+ const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, "postCommit");
132
134
  await lifecycle.runPostDelete(type, result, afterCommitContext, HookPhases.afterCommit);
133
135
  });
134
136
  }
@@ -337,23 +339,29 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
337
339
  if (isMemberResolutionPrincipal(user)) {
338
340
  return writeFailure(memberResolutionReadOnlyDenied());
339
341
  }
340
- // A command the dispatcher reserved for was already billed against the rate limits; the
341
- // gates only run again against the in-transaction state.
342
- const reservation = handler.reserveBeforeTransaction ? consumeReservation(type) : undefined;
342
+ const isPerCommand = handler.transaction === "perCommand";
343
+ if (isPerCommand && tx !== undefined) {
344
+ return writeFailure(perCommandNestedDenied(type));
345
+ }
346
+ // A command the dispatcher ran pre-transaction phases for was already billed against the rate
347
+ // limits; the gates only run again against the in-transaction state.
348
+ const needsPreTransactionPhase = hasPreTransactionPhase(handler);
349
+ const reservation = needsPreTransactionPhase ? consumeReservation(type) : undefined;
343
350
  const gates = await runPreHandlerGates(ctx, handler, type, payload, user, reservation ? "recheck" : "charge");
344
351
  if (!gates.isSuccess)
345
352
  return gates;
346
353
  const parsedPayload = gates.payload;
347
- // A handler that reserves before the transaction cannot do so from inside an open one; running
348
- // it unreserved would skip the check entirely, so a nested ctx.write of a different capped
349
- // handler is rejected instead.
350
- if (handler.reserveBeforeTransaction && !reservation) {
354
+ // A handler with a pre-transaction phase cannot run that phase from inside an open transaction;
355
+ // running it without would skip the reservation or the prepared data, so a nested ctx.write of
356
+ // such a handler is rejected instead.
357
+ if (needsPreTransactionPhase && !reservation) {
351
358
  return writeFailure(new InternalError({
352
- message: `handler "${type}" reserves before the transaction and cannot run as a nested write unless the top-level command reserved it`,
359
+ message: `handler "${type}" has a pre-transaction phase (reserveBeforeTransaction/beforeTransaction) and cannot run as a nested write unless the top-level command ran it`,
353
360
  }));
354
361
  }
355
362
  const entityName = registry.getHandlerEntity(type);
356
- const handlerContext = await buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks);
363
+ // Without a database there is no transaction either, but ordinary handlers keep the in-transaction nested-write semantics.
364
+ const handlerContext = await buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, undefined, isPerCommand ? "withoutTransaction" : "inTransaction");
357
365
  // Auto transition guard: if entity has transitions and handler doesn't skip it.
358
366
  // Reads via the guard's own db handle — for r.systemScope() handlers
359
367
  // handlerContext.db is fail-closed, so this reaches for
@@ -410,7 +418,8 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
410
418
  // writeFailure, not via a rethrow) so batches roll back naturally.
411
419
  let result;
412
420
  try {
413
- result = await handler.handler({ type, payload: parsedPayload, user }, handlerContext);
421
+ const event = { type, payload: parsedPayload, user, prepared: reservation?.prepared };
422
+ result = await handler.handler(event, handlerContext);
414
423
  }
415
424
  catch (e) {
416
425
  return writeFailure(wrapToKumiko(e));
@@ -434,12 +443,15 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
434
443
  return confirmFailure;
435
444
  }
436
445
  if (result.isSuccess) {
437
- try {
438
- const runner = resolveDbSource(ctx, tx);
439
- await runLifecycle(ctx, type, result.data, handlerContext, user, origin, afterCommitHooks, runner);
440
- }
441
- catch (e) {
442
- return writeFailure(wrapToKumiko(e));
446
+ // A perCommand handler's writes ran as their own commands; its result carries no SaveContext of its own to project.
447
+ if (!isPerCommand) {
448
+ try {
449
+ const runner = resolveDbSource(ctx, tx);
450
+ await runLifecycle(ctx, type, result.data, handlerContext, user, origin, afterCommitHooks, runner);
451
+ }
452
+ catch (e) {
453
+ return writeFailure(wrapToKumiko(e));
454
+ }
443
455
  }
444
456
  // jobRunner has external side-effects (BullMQ enqueue) — must NOT
445
457
  // fire for rolled-back writes. Defer to afterCommit.
@@ -1,6 +1,7 @@
1
1
  import { runWithWriteOrigin } from "../api/request-context.js";
2
2
  import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names.js";
3
3
  import { InternalError, reraiseAsKumikoError } from "../errors/index.js";
4
+ import { withEscapeHatchAuditScopeStream } from "../observability/escape-hatch-report.js";
4
5
  import { createEscapeHatchReportWindow, getFallbackMeter, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
5
6
  import { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage } from "../redis/cache-sync-topics.js";
6
7
  import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
@@ -114,7 +115,8 @@ export function createDispatcher(registry, context, options = {}) {
114
115
  },
115
116
  stream: (typeOrRef, payload, user, options) => {
116
117
  const type = resolveType(typeOrRef);
117
- return executeStream(ctx, type, payload, user, rootWriteOrigin(registry, type, user), options);
118
+ const origin = rootWriteOrigin(registry, type, user);
119
+ return withEscapeHatchAuditScopeStream(() => executeStream(ctx, type, payload, user, origin, options));
118
120
  },
119
121
  async command(typeOrRef, payload, user) {
120
122
  const type = resolveType(typeOrRef);
@@ -1,4 +1,4 @@
1
- import type { PgClient } from "../db/connection.js";
1
+ import type { PgListenClient } from "../db/connection.js";
2
2
  import type { AppContext } from "../engine/types/index.js";
3
3
  import { type StoredEvent } from "../event-store/index.js";
4
4
  import { type Meter, type Tracer } from "../observability/index.js";
@@ -46,7 +46,7 @@ export type EventDispatcherOptions = {
46
46
  readonly tracer?: Tracer;
47
47
  readonly meter?: Meter;
48
48
  readonly instanceId?: string;
49
- readonly pgClient?: PgClient;
49
+ readonly pgClient?: PgListenClient;
50
50
  };
51
51
  export declare function createEventDispatcher(options: EventDispatcherOptions): EventDispatcher;
52
52
  export type { ConsumerProgress, ConsumerRecoveryState } from "./event-dispatcher-admin.js";
@@ -114,7 +114,7 @@ export function createEventDispatcher(options) {
114
114
  // to EVENTS_PUBSUB_CHANNEL; cleared by .stop(). The timer remains active
115
115
  // even with LISTEN attached — it's a cheap safety net against missed
116
116
  // NOTIFYs (subscription drop, crash mid-commit).
117
- let pgUnlisten = null;
117
+ let pgSubscription = null;
118
118
  // Bounds how many consumer turns hold a DB transaction at once. Each turn's
119
119
  // db.begin() checks out one pool connection, plus any writes the handler
120
120
  // itself makes via context.db — the app db pool defaults to postgres-js's
@@ -498,15 +498,16 @@ export function createEventDispatcher(options) {
498
498
  // succeeds (onlisten callback). It drops to 0 on the initial connect
499
499
  // failure, on stop() and when the idle pre-check detects a DB outage.
500
500
  // An outage shorter than one poll tick is not detected; the gauge then
501
- // stays at 1 until postgres.js's delayed re-LISTEN lands.
501
+ // stays at 1 until the driver's delayed re-LISTEN lands.
502
502
  emitEventDispatcherListenConnected(meter, false);
503
503
  if (options.pgClient) {
504
504
  try {
505
505
  const sub = await options.pgClient.listen(EVENTS_PUBSUB_CHANNEL, runBackgroundPass, () => {
506
- // Fires on initial connect and again when postgres.js's own
506
+ // Fires on initial connect and again when the driver's own
507
507
  // re-LISTEN lands after a drop; during an outage that re-LISTEN
508
- // waits in its connection backoff (measured 10-30s after the DB
509
- // returns), polling covers delivery meanwhile. Never unlisten()
508
+ // waits in its connection backoff (postgres-js: measured 10-30s
509
+ // after the DB returns, Bun.SQL: up to 32s), polling covers
510
+ // delivery meanwhile. Never unlisten()
510
511
  // + listen() by hand: that leaves duplicate listeners.
511
512
  // ponytail: no own LISTEN reconnect, the backoff gap is polled;
512
513
  // add a dedicated LISTEN connection if prod wires a pgClient
@@ -514,7 +515,7 @@ export function createEventDispatcher(options) {
514
515
  listenSubscribed = true;
515
516
  emitEventDispatcherListenConnected(meter, true);
516
517
  });
517
- pgUnlisten = sub.unlisten;
518
+ pgSubscription = sub;
518
519
  }
519
520
  catch (e) {
520
521
  emitEventDispatcherListenConnected(meter, false);
@@ -532,12 +533,13 @@ export function createEventDispatcher(options) {
532
533
  clearInterval(timer);
533
534
  timer = null;
534
535
  }
535
- if (pgUnlisten) {
536
- await pgUnlisten().catch(() => {
536
+ if (pgSubscription) {
537
+ // Called on the subscription: Bun.SQL's unlisten is a prototype method.
538
+ await pgSubscription.unlisten().catch(() => {
537
539
  // skip: unlisten failure only matters during shutdown — the
538
540
  // subscription is being torn down anyway.
539
541
  });
540
- pgUnlisten = null;
542
+ pgSubscription = null;
541
543
  listenSubscribed = false;
542
544
  emitEventDispatcherListenConnected(meter, false);
543
545
  }
@@ -6,6 +6,7 @@ export type PruneEventsOptions = {
6
6
  readonly aggregateTypes: readonly string[];
7
7
  readonly aggregateIds?: readonly string[];
8
8
  readonly dryRun?: boolean;
9
+ readonly batchSize?: number;
9
10
  };
10
11
  export type PruneEventsResult = {
11
12
  readonly deletedCount: number;
@@ -34,6 +34,10 @@ export async function pruneEvents(db, options) {
34
34
  if (options.aggregateIds !== undefined && options.aggregateIds.length === 0) {
35
35
  throw new Error("pruneEvents: aggregateIds must be non-empty when set.");
36
36
  }
37
+ if (options.batchSize !== undefined &&
38
+ (!Number.isInteger(options.batchSize) || options.batchSize < 1)) {
39
+ throw new Error("pruneEvents: batchSize must be a positive integer.");
40
+ }
37
41
  const aggregateTypes = options.aggregateTypes;
38
42
  const aggregateIds = options.aggregateIds;
39
43
  const dryRun = options.dryRun === true;
@@ -56,7 +60,9 @@ export async function pruneEvents(db, options) {
56
60
  aggregateType: [...aggregateTypes],
57
61
  ...(aggregateIds ? { aggregateId: [...aggregateIds] } : {}),
58
62
  createdAt: { lt: cutoff },
59
- });
63
+ }, options.batchSize === undefined
64
+ ? undefined
65
+ : { orderBy: { col: "id", direction: "asc" }, limit: options.batchSize });
60
66
  if (candidates.length === 0) {
61
67
  return { deletedCount: 0, cutoff, aggregateTypes, dryRun };
62
68
  }
@@ -1,5 +1,6 @@
1
1
  export type { EscapeHatchReportWindow } from "../observability/escape-hatch-report.js";
2
2
  export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "../observability/escape-hatch-report.js";
3
+ export { normalizePayloadBucketValue } from "../rate-limit/bucket.js";
3
4
  export type { AppendDomainEventCoreDeps } from "./append-event-core.js";
4
5
  export { appendDomainEventCore } from "./append-event-core.js";
5
6
  export type { ResolveAuthClaimsArgs } from "./auth-claims-resolver.js";
@@ -1,4 +1,5 @@
1
1
  export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "../observability/escape-hatch-report.js";
2
+ export { normalizePayloadBucketValue } from "../rate-limit/bucket.js";
2
3
  export { appendDomainEventCore } from "./append-event-core.js";
3
4
  export { resolveAuthClaims } from "./auth-claims-resolver.js";
4
5
  export { createCascadeDeleteHook } from "./cascade-handler.js";