@cosmicdrift/kumiko-framework 0.387.0 → 0.388.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 (240) hide show
  1. package/AGENTS.md +6 -1
  2. package/agents/examples/anonymous-access.md +1 -1
  3. package/agents/examples/apex-landing.md +204 -0
  4. package/agents/examples/api-worker-split.md +64 -0
  5. package/agents/examples/app-config.md +14 -0
  6. package/agents/examples/auth-mfa.md +115 -0
  7. package/agents/examples/env-composition.md +28 -1
  8. package/agents/examples/legal-pages.md +1 -1
  9. package/agents/examples/prod-boot-options.md +16 -0
  10. package/agents/examples/rate-limiting.md +48 -0
  11. package/agents/examples/record-detail-layout.md +692 -0
  12. package/agents/examples/seed-migration.md +212 -0
  13. package/agents/examples/wizard-form.md +941 -0
  14. package/agents/examples/writeform-section.md +548 -0
  15. package/agents/features.md +19 -2
  16. package/agents/widgets.md +1 -0
  17. package/dist/api/client-error-route.js +8 -2
  18. package/dist/api/index.d.ts +3 -1
  19. package/dist/api/index.js +3 -1
  20. package/dist/api/request-context.d.ts +1 -1
  21. package/dist/api/request-context.js +1 -1
  22. package/dist/api/server-boot-checks.d.ts +10 -0
  23. package/dist/api/server-boot-checks.js +157 -0
  24. package/dist/api/server-event-dispatcher.d.ts +26 -0
  25. package/dist/api/server-event-dispatcher.js +208 -0
  26. package/dist/api/server-extra-routes.d.ts +11 -0
  27. package/dist/api/server-extra-routes.js +203 -0
  28. package/dist/api/server-middleware.d.ts +20 -0
  29. package/dist/api/server-middleware.js +211 -0
  30. package/dist/api/server-routes.d.ts +19 -0
  31. package/dist/api/server-routes.js +293 -0
  32. package/dist/api/server-websocket-routes.d.ts +4 -0
  33. package/dist/api/server-websocket-routes.js +108 -0
  34. package/dist/api/server-wiring.d.ts +32 -0
  35. package/dist/api/server-wiring.js +236 -0
  36. package/dist/api/server.d.ts +10 -15
  37. package/dist/api/server.js +48 -1251
  38. package/dist/api/session-cookies.js +4 -4
  39. package/dist/api/sse-route.js +16 -12
  40. package/dist/crypto/owner-transition-rekey.d.ts +5 -1
  41. package/dist/crypto/owner-transition-rekey.js +1 -1
  42. package/dist/crypto/pii-field-encryption.d.ts +5 -1
  43. package/dist/crypto/pii-field-encryption.js +3 -1
  44. package/dist/db/aggregate-sql.d.ts +21 -0
  45. package/dist/db/aggregate-sql.js +155 -0
  46. package/dist/db/closed-connection.d.ts +9 -0
  47. package/dist/db/closed-connection.js +95 -0
  48. package/dist/db/dialect.js +1 -1
  49. package/dist/db/distinct-on.d.ts +1 -1
  50. package/dist/db/driver-row-coercion.d.ts +2 -0
  51. package/dist/db/driver-row-coercion.js +186 -0
  52. package/dist/db/driver-value-preparation.d.ts +16 -0
  53. package/dist/db/driver-value-preparation.js +99 -0
  54. package/dist/db/entity-field-encryption.d.ts +1 -0
  55. package/dist/db/entity-field-encryption.js +1 -1
  56. package/dist/db/entity-table-meta.js +103 -110
  57. package/dist/db/event-store-executor-context.d.ts +2 -1
  58. package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
  59. package/dist/db/event-store-executor-read-aggregate.js +112 -0
  60. package/dist/db/event-store-executor-read-filter.d.ts +31 -0
  61. package/dist/db/event-store-executor-read-filter.js +203 -0
  62. package/dist/db/event-store-executor-read-paging.d.ts +14 -0
  63. package/dist/db/event-store-executor-read-paging.js +99 -0
  64. package/dist/db/event-store-executor-read-rows.d.ts +6 -0
  65. package/dist/db/event-store-executor-read-rows.js +32 -0
  66. package/dist/db/event-store-executor-read-search.d.ts +28 -0
  67. package/dist/db/event-store-executor-read-search.js +221 -0
  68. package/dist/db/event-store-executor-read-sort.d.ts +41 -0
  69. package/dist/db/event-store-executor-read-sort.js +130 -0
  70. package/dist/db/event-store-executor-read-where.d.ts +24 -0
  71. package/dist/db/event-store-executor-read-where.js +81 -0
  72. package/dist/db/event-store-executor-read.d.ts +2 -9
  73. package/dist/db/event-store-executor-read.js +48 -701
  74. package/dist/db/event-store-executor-write-steps.d.ts +20 -0
  75. package/dist/db/event-store-executor-write-steps.js +100 -0
  76. package/dist/db/event-store-executor-write.js +395 -499
  77. package/dist/db/index.d.ts +6 -2
  78. package/dist/db/index.js +5 -1
  79. package/dist/db/list-aggregate-labels.d.ts +14 -0
  80. package/dist/db/list-aggregate-labels.js +26 -0
  81. package/dist/db/migrate-generator.js +71 -86
  82. package/dist/db/migrate-runner.js +4 -1
  83. package/dist/db/query.d.ts +2 -15
  84. package/dist/db/query.js +8 -761
  85. package/dist/db/reference-data.js +6 -1
  86. package/dist/db/table-builder.js +113 -166
  87. package/dist/db/table-info.d.ts +10 -0
  88. package/dist/db/table-info.js +45 -0
  89. package/dist/db/temporal-value-guards.d.ts +6 -0
  90. package/dist/db/temporal-value-guards.js +15 -0
  91. package/dist/db/tenant-db-runner.d.ts +1 -0
  92. package/dist/db/tenant-db-runner.js +3 -0
  93. package/dist/db/tenant-db.js +39 -9
  94. package/dist/db/where-clause.d.ts +6 -0
  95. package/dist/db/where-clause.js +236 -0
  96. package/dist/derivatives/delete-stored-file.js +2 -1
  97. package/dist/engine/boot-validator/boot-check.js +1 -1
  98. package/dist/engine/boot-validator/detail-screens.js +2 -2
  99. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  100. package/dist/engine/boot-validator/entity-handler.js +17 -0
  101. package/dist/engine/boot-validator/form-derivations.js +35 -1
  102. package/dist/engine/boot-validator/i18n-keys.js +5 -3
  103. package/dist/engine/boot-validator/index.js +2 -0
  104. package/dist/engine/boot-validator/pii-retention.js +233 -155
  105. package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
  106. package/dist/engine/boot-validator/screens-actions.js +8 -1
  107. package/dist/engine/boot-validator/screens-dashboard.js +2 -5
  108. package/dist/engine/boot-validator/screens-forms.js +2 -6
  109. package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
  110. package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
  111. package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
  112. package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
  113. package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
  114. package/dist/engine/boot-validator/screens-kind-entity-list.js +228 -0
  115. package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
  116. package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
  117. package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
  118. package/dist/engine/boot-validator/screens-kind-projection-list.js +52 -0
  119. package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
  120. package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
  121. package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
  122. package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
  123. package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
  124. package/dist/engine/boot-validator/screens-lists.js +12 -9
  125. package/dist/engine/boot-validator/screens.d.ts +0 -2
  126. package/dist/engine/boot-validator/screens.js +99 -860
  127. package/dist/engine/build-app-schema.js +56 -96
  128. package/dist/engine/define-feature.js +9 -2
  129. package/dist/engine/define-handler.js +1 -0
  130. package/dist/engine/entity-handlers.d.ts +13 -0
  131. package/dist/engine/entity-handlers.js +10 -21
  132. package/dist/engine/entity-list-runtime-options.d.ts +8 -0
  133. package/dist/engine/entity-list-runtime-options.js +18 -0
  134. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  135. package/dist/engine/feature-ast/extractors/round2.js +25 -6
  136. package/dist/engine/feature-ast/patch.js +4 -182
  137. package/dist/engine/feature-ast/pattern-change-schema.d.ts +3 -0
  138. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  139. package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
  140. package/dist/engine/feature-ast/pattern-id-matchers.js +210 -0
  141. package/dist/engine/feature-ast/patterns.d.ts +9 -0
  142. package/dist/engine/feature-ast/patterns.js +43 -51
  143. package/dist/engine/feature-ast/render.js +48 -87
  144. package/dist/engine/feature-entity-handlers.js +3 -0
  145. package/dist/engine/form-derivations.js +18 -7
  146. package/dist/engine/handler-field-references.js +1 -1
  147. package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
  148. package/dist/engine/project-app-schema-for-roles.js +6 -4
  149. package/dist/engine/registry-ingest.js +1 -1
  150. package/dist/engine/schema-builder.js +228 -212
  151. package/dist/engine/screen-helpers.js +1 -1
  152. package/dist/engine/screen-kind.d.ts +7 -0
  153. package/dist/engine/screen-kind.js +1 -0
  154. package/dist/engine/strip-disabled-features-from-app-schema.js +51 -14
  155. package/dist/engine/validate-projection-allowlist.js +2 -1
  156. package/dist/entrypoint/index.d.ts +4 -1
  157. package/dist/entrypoint/index.js +7 -1
  158. package/dist/errors/docs-url.d.ts +1 -1
  159. package/dist/errors/docs-url.js +22 -2
  160. package/dist/es-ops/context.js +1 -1
  161. package/dist/event-store/admin-api.js +1 -1
  162. package/dist/event-store/event-store.js +10 -4
  163. package/dist/files/bind-pending-file-refs.js +3 -3
  164. package/dist/files/create-file-ref-from-bytes.js +4 -1
  165. package/dist/files/file-handle.js +2 -3
  166. package/dist/files/file-routes.js +11 -8
  167. package/dist/files/resolve-content-type.js +43 -0
  168. package/dist/files/upload-ledger.js +7 -0
  169. package/dist/i18n/required-surface-keys.js +182 -206
  170. package/dist/internal/stack-support.d.ts +2 -1
  171. package/dist/internal/stack-support.js +1 -1
  172. package/dist/jobs/job-runner-context.d.ts +32 -0
  173. package/dist/jobs/job-runner-context.js +305 -0
  174. package/dist/jobs/job-runner-execution.d.ts +7 -0
  175. package/dist/jobs/job-runner-execution.js +160 -0
  176. package/dist/jobs/job-runner-gates.d.ts +19 -0
  177. package/dist/jobs/job-runner-gates.js +198 -0
  178. package/dist/jobs/job-runner-helpers.d.ts +17 -3
  179. package/dist/jobs/job-runner-helpers.js +61 -2
  180. package/dist/jobs/job-runner-scope.d.ts +44 -0
  181. package/dist/jobs/job-runner-scope.js +102 -0
  182. package/dist/jobs/job-runner-types.d.ts +12 -0
  183. package/dist/jobs/job-runner.d.ts +0 -1
  184. package/dist/jobs/job-runner.js +47 -657
  185. package/dist/observability/escape-hatch-report.d.ts +3 -0
  186. package/dist/observability/escape-hatch-report.js +12 -1
  187. package/dist/pipeline/cascade-handler.js +165 -138
  188. package/dist/pipeline/dispatch-query.js +10 -3
  189. package/dist/pipeline/dispatch-shared.d.ts +13 -3
  190. package/dist/pipeline/dispatch-shared.js +58 -710
  191. package/dist/pipeline/dispatch-write.js +296 -189
  192. package/dist/pipeline/event-dispatcher-admin.js +2 -1
  193. package/dist/pipeline/event-dispatcher-delivery.js +3 -5
  194. package/dist/pipeline/handler-context-auth.d.ts +9 -0
  195. package/dist/pipeline/handler-context-auth.js +54 -0
  196. package/dist/pipeline/handler-context-config.d.ts +14 -0
  197. package/dist/pipeline/handler-context-config.js +113 -0
  198. package/dist/pipeline/handler-context-db.d.ts +24 -0
  199. package/dist/pipeline/handler-context-db.js +149 -0
  200. package/dist/pipeline/handler-context-dispatch.d.ts +21 -0
  201. package/dist/pipeline/handler-context-dispatch.js +89 -0
  202. package/dist/pipeline/handler-context-events.d.ts +43 -0
  203. package/dist/pipeline/handler-context-events.js +237 -0
  204. package/dist/pipeline/handler-context-notify.d.ts +12 -0
  205. package/dist/pipeline/handler-context-notify.js +55 -0
  206. package/dist/pipeline/handler-context-observability.d.ts +7 -0
  207. package/dist/pipeline/handler-context-observability.js +21 -0
  208. package/dist/pipeline/handler-context-scope.d.ts +48 -0
  209. package/dist/pipeline/handler-context-scope.js +88 -0
  210. package/dist/pipeline/notify-scope.js +12 -1
  211. package/dist/pipeline/pre-handler-gates.js +20 -4
  212. package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
  213. package/dist/pipeline/pre-transaction-reservations.js +9 -3
  214. package/dist/pipeline/projection-rebuild.js +2 -1
  215. package/dist/schema-cli/apply-command.d.ts +2 -0
  216. package/dist/schema-cli/apply-command.js +97 -0
  217. package/dist/schema-cli/baseline-command.d.ts +2 -0
  218. package/dist/schema-cli/baseline-command.js +33 -0
  219. package/dist/schema-cli/generate-command.d.ts +2 -0
  220. package/dist/schema-cli/generate-command.js +90 -0
  221. package/dist/schema-cli/shared.d.ts +39 -0
  222. package/dist/schema-cli/shared.js +95 -0
  223. package/dist/schema-cli/status-command.d.ts +2 -0
  224. package/dist/schema-cli/status-command.js +73 -0
  225. package/dist/schema-cli/validate-boot-input.d.ts +5 -0
  226. package/dist/schema-cli/validate-boot-input.js +130 -0
  227. package/dist/schema-cli/validate-command.d.ts +2 -0
  228. package/dist/schema-cli/validate-command.js +130 -0
  229. package/dist/schema-cli.d.ts +2 -18
  230. package/dist/schema-cli.js +47 -604
  231. package/dist/search/meilisearch-adapter.js +2 -1
  232. package/dist/upgrade-cli.js +149 -121
  233. package/package.json +4 -4
  234. package/src/changes.json +310 -0
  235. package/src/scripts/codemod/feature-layout.ts +10 -2
  236. package/src/scripts/codemod/object-form.ts +41 -4
  237. package/src/scripts/codemod/package-merge.ts +58 -8
  238. package/src/scripts/codemod/pii-personal-migration.ts +175 -145
  239. package/src/scripts/codemod/testing-injection.ts +122 -42
  240. package/src/scripts/codemod/testing-move.ts +29 -7
@@ -92,6 +92,13 @@ function publishConfigWrite(ctx, type, user, result) {
92
92
  const message = scope === ConfigScopes.system ? { scope: "all", key } : { tenantId: user.tenantId, key };
93
93
  ctx.cacheSync?.publish(CACHE_SYNC_TOPICS.tenantConfig, message);
94
94
  }
95
+ async function settleSaveFileRefs(handlerContext, result, user) {
96
+ const fileRefArgs = { registry: handlerContext.registry, result, user, db: handlerContext.db };
97
+ const fileRefRows = await loadSaveFileRefs(fileRefArgs);
98
+ assertFileRefsBelongToTenant({ ...fileRefArgs, fileRefRows });
99
+ await bindPendingFileRefs({ ...fileRefArgs, fileRefRows });
100
+ await disposeRemovedFileRefs(fileRefArgs);
101
+ }
95
102
  // Runs lifecycle hooks for a handler result. inTransaction hooks fire NOW
96
103
  // (they see the tx via ctx.db when batch/write opens a transaction).
97
104
  // afterCommit hooks are queued into `afterCommitHooks` for the caller to
@@ -115,14 +122,12 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
115
122
  // keeps projection state consistent with what the hooks observe.
116
123
  await runProjections(result, handlerContext.registry, runner);
117
124
  if (result.kind === "save") {
118
- const fileRefArgs = { registry: handlerContext.registry, result, user, db: handlerContext.db };
119
- const fileRefRows = await loadSaveFileRefs(fileRefArgs);
120
- assertFileRefsBelongToTenant({ ...fileRefArgs, fileRefRows });
121
- await bindPendingFileRefs({ ...fileRefArgs, fileRefRows });
122
- await disposeRemovedFileRefs(fileRefArgs);
125
+ await settleSaveFileRefs(handlerContext, result, user);
123
126
  await lifecycle.runPostSave(type, result, handlerContext, HookPhases.inTransaction);
124
127
  afterCommitHooks.push(async () => {
125
- const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, "postCommit");
128
+ const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, {
129
+ transactionScope: "postCommit",
130
+ });
126
131
  await lifecycle.runPostSave(type, result, afterCommitContext, HookPhases.afterCommit);
127
132
  });
128
133
  }
@@ -130,7 +135,9 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
130
135
  await lifecycle.runPreDelete(type, result, handlerContext);
131
136
  await lifecycle.runPostDelete(type, result, handlerContext, HookPhases.inTransaction);
132
137
  afterCommitHooks.push(async () => {
133
- const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, "postCommit");
138
+ const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, {
139
+ transactionScope: "postCommit",
140
+ });
134
141
  await lifecycle.runPostDelete(type, result, afterCommitContext, HookPhases.afterCommit);
135
142
  });
136
143
  }
@@ -190,25 +197,7 @@ async function completeParentRow(ctx, parentEntityName, parentEntity, parentRow,
190
197
  });
191
198
  return stored ? { ...stored, ...parentRow } : parentRow;
192
199
  }
193
- // Nested-write orchestration (v1: depth=1, create-only, hasMany-only).
194
- //
195
- // When a parent `:create` handler's payload carries values under keys
196
- // declared as `hasMany` relations with `nestedWrite: true`, those values
197
- // are expanded into child writes: parent first (so its new id exists),
198
- // then each nested entry as a separate `<target>:create` write with the
199
- // foreign key set by the framework — never taken from the client. All of
200
- // this runs inside the caller's transaction, so a child failure rolls the
201
- // parent (and any earlier children) back together.
202
- //
203
- // This wrapper is what runBatch calls, not executeWrite. Single writes
204
- // (`dispatcher.write`) flow through runBatch as batch-of-one, so they get
205
- // nested-expansion too for free. A batch with N heterogeneous commands
206
- // can each independently carry nested-children — all still one TX.
207
- export async function executeNestedWrite(ctx, type, payload, user, origin, tx, afterCommitHooks) {
208
- const { registry } = ctx;
209
- const nested = extractNestedSpecs(type, payload, registry);
210
- if (!nested)
211
- return executeWrite(ctx, type, payload, user, origin, tx, afterCommitHooks);
200
+ function collectNestedForeignKeyIssues(nested) {
212
201
  // Pre-flight client-shape checks. Merge non-array issues (collected up
213
202
  // front by extractNestedSpecs) with fk-injection issues into one error
214
203
  // so the client sees every problem in a single round-trip.
@@ -230,39 +219,33 @@ export async function executeNestedWrite(ctx, type, payload, user, origin, tx, a
230
219
  }
231
220
  }
232
221
  }
233
- if (issues.length > 0) {
234
- return writeFailure(new ValidationError({ fields: issues }));
235
- }
236
- const parentResult = await executeWrite(ctx, type, nested.cleanPayload, user, origin, tx, afterCommitHooks);
237
- if (!parentResult.isSuccess)
238
- return parentResult;
239
- // Handlers built on the CRUD executor return a SaveContext wrapper —
240
- // `{ kind: "save", id, data: <row>, changes, previous, event, ... }`.
241
- // The wrapper is load-bearing for batch-level hooks downstream (see
242
- // flushBatchHooks), so we mutate in place: nested children land on the
243
- // inner `data` (which mirrors the entity shape the client expects) while
244
- // the wrapper keeps its SaveContext semantics intact for the lifecycle
245
- // pipeline. For handlers that return a bare row (no wrapper), children
246
- // land directly on that object.
247
- //
248
- // Hook-ordering note: per-entity postSave hooks already ran inside the
249
- // parent's executeWrite call above — they never saw `tasks`, which is
250
- // the right semantic (postSave gets the entity's own columns, not
251
- // synthetic relation keys). A future postSaveBatch subscriber that
252
- // enumerates columns generically WOULD see `tasks`; no such subscriber
253
- // exists today. If you add one that iterates `Object.keys(save.data)`,
254
- // filter by `entity.fields` membership to stay correct.
255
- // handler-Result.data is generic across all entity handlers; nested-
256
- // write inspects the shape structurally.
257
- const parentWrapper = parentResult.data; // @cast-boundary engine-payload
258
- const parentRow = (parentWrapper["data"] ?? parentWrapper); // @cast-boundary engine-payload
259
- const parentId = parentRow["id"];
260
- if (typeof parentId !== "string") {
261
- return writeFailure(new InternalError({
262
- message: `nested-write: parent handler "${type}" returned no string "id" — cannot attach children`,
263
- }));
264
- }
265
- // A custom create handler may return an existing foreign row; verify it before attaching children.
222
+ return issues;
223
+ }
224
+ // Handlers built on the CRUD executor return a SaveContext wrapper —
225
+ // `{ kind: "save", id, data: <row>, changes, previous, event, ... }`.
226
+ // The wrapper is load-bearing for batch-level hooks downstream (see
227
+ // flushBatchHooks), so we mutate in place: nested children land on the
228
+ // inner `data` (which mirrors the entity shape the client expects) while
229
+ // the wrapper keeps its SaveContext semantics intact for the lifecycle
230
+ // pipeline. For handlers that return a bare row (no wrapper), children
231
+ // land directly on that object.
232
+ //
233
+ // Hook-ordering note: per-entity postSave hooks already ran inside the
234
+ // parent's executeWrite call — they never saw `tasks`, which is
235
+ // the right semantic (postSave gets the entity's own columns, not
236
+ // synthetic relation keys). A future postSaveBatch subscriber that
237
+ // enumerates columns generically WOULD see `tasks`; no such subscriber
238
+ // exists today. If you add one that iterates `Object.keys(save.data)`,
239
+ // filter by `entity.fields` membership to stay correct.
240
+ // handler-Result.data is generic across all entity handlers; nested-
241
+ // write inspects the shape structurally.
242
+ function unwrapSaveRow(data) {
243
+ const wrapper = data; // @cast-boundary engine-payload
244
+ return (wrapper["data"] ?? wrapper); // @cast-boundary engine-payload
245
+ }
246
+ // A custom create handler may return an existing foreign row; verify it before attaching children.
247
+ async function rejectUnverifiedParent(ctx, type, parentRow, parentId, user, tx) {
248
+ const { registry } = ctx;
266
249
  const parentEntityName = registry.getHandlerEntity(type);
267
250
  const isCallerScoped = !registry.isHandlerSystemScoped(type);
268
251
  const isParentForeign = isCallerScoped &&
@@ -275,45 +258,90 @@ export async function executeNestedWrite(ctx, type, payload, user, origin, tx, a
275
258
  details: { reason: FrameworkReasons.fieldAccessDenied, handler: type },
276
259
  }));
277
260
  }
278
- if (parentEntityName) {
279
- const parentEntity = registry.getEntity(parentEntityName);
280
- if (parentEntity) {
281
- const ownershipRow = await completeParentRow(ctx, parentEntityName, parentEntity, parentRow, user, tx);
282
- const deniedField = checkWriteFieldOwnership(parentEntity, ownershipRow, user);
283
- if (deniedField) {
284
- return writeFailure(new AccessDeniedError({
285
- 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`,
286
- i18nKey: "errors.access.fieldDenied",
287
- details: {
288
- reason: FrameworkReasons.fieldAccessDenied,
289
- field: deniedField,
290
- handler: type,
291
- },
292
- }));
293
- }
294
- }
295
- }
296
- for (const spec of nested.specs) {
297
- const subRows = [];
298
- for (let i = 0; i < spec.items.length; i++) {
299
- const rawItem = spec.items[i];
300
- const itemObj = (rawItem ?? {}); // @cast-boundary engine-payload
301
- const subPayload = { ...itemObj, [spec.foreignKey]: parentId };
302
- const subResult = await executeWrite(ctx, spec.subType, subPayload, user, origin, tx, afterCommitHooks);
303
- if (!subResult.isSuccess) {
304
- return {
261
+ if (!parentEntityName)
262
+ return undefined;
263
+ const parentEntity = registry.getEntity(parentEntityName);
264
+ if (!parentEntity)
265
+ return undefined;
266
+ const ownershipRow = await completeParentRow(ctx, parentEntityName, parentEntity, parentRow, user, tx);
267
+ const deniedField = checkWriteFieldOwnership(parentEntity, ownershipRow, user);
268
+ if (!deniedField)
269
+ return undefined;
270
+ return writeFailure(new AccessDeniedError({
271
+ 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`,
272
+ i18nKey: "errors.access.fieldDenied",
273
+ details: {
274
+ reason: FrameworkReasons.fieldAccessDenied,
275
+ field: deniedField,
276
+ handler: type,
277
+ },
278
+ }));
279
+ }
280
+ async function writeNestedChildren(ctx, spec, parentId, user, origin, tx, afterCommitHooks) {
281
+ const { registry } = ctx;
282
+ const subRows = [];
283
+ for (let i = 0; i < spec.items.length; i++) {
284
+ const rawItem = spec.items[i];
285
+ const itemObj = (rawItem ?? {}); // @cast-boundary engine-payload
286
+ const subPayload = { ...itemObj, [spec.foreignKey]: parentId };
287
+ const subResult = await executeWrite(ctx, spec.subType, subPayload, user, origin, tx, afterCommitHooks);
288
+ if (!subResult.isSuccess) {
289
+ return {
290
+ kind: "failed",
291
+ failure: {
305
292
  isSuccess: false,
306
293
  error: prefixValidationPath(subResult.error, `${spec.key}.${i}`),
307
- };
308
- }
309
- const subWrapper = subResult.data; // @cast-boundary engine-payload
310
- const subRow = (subWrapper["data"] ?? subWrapper); // @cast-boundary engine-payload
311
- const subEntityName = registry.getHandlerEntity(spec.subType);
312
- const subEntity = subEntityName ? registry.getEntity(subEntityName) : undefined;
313
- // parentRow is returned to the client; the child's own SaveContext keeps the plaintext.
314
- subRows.push(subEntity ? maskWriteOnlyFields(subEntity, subRow) : subRow);
294
+ },
295
+ };
315
296
  }
316
- parentRow[spec.key] = subRows;
297
+ const subRow = unwrapSaveRow(subResult.data);
298
+ const subEntityName = registry.getHandlerEntity(spec.subType);
299
+ const subEntity = subEntityName ? registry.getEntity(subEntityName) : undefined;
300
+ // parentRow is returned to the client; the child's own SaveContext keeps the plaintext.
301
+ subRows.push(subEntity ? maskWriteOnlyFields(subEntity, subRow) : subRow);
302
+ }
303
+ return { kind: "written", rows: subRows };
304
+ }
305
+ // Nested-write orchestration (v1: depth=1, create-only, hasMany-only).
306
+ //
307
+ // When a parent `:create` handler's payload carries values under keys
308
+ // declared as `hasMany` relations with `nestedWrite: true`, those values
309
+ // are expanded into child writes: parent first (so its new id exists),
310
+ // then each nested entry as a separate `<target>:create` write with the
311
+ // foreign key set by the framework — never taken from the client. All of
312
+ // this runs inside the caller's transaction, so a child failure rolls the
313
+ // parent (and any earlier children) back together.
314
+ //
315
+ // This wrapper is what runBatch calls, not executeWrite. Single writes
316
+ // (`dispatcher.write`) flow through runBatch as batch-of-one, so they get
317
+ // nested-expansion too for free. A batch with N heterogeneous commands
318
+ // can each independently carry nested-children — all still one TX.
319
+ export async function executeNestedWrite(ctx, type, payload, user, origin, tx, afterCommitHooks) {
320
+ const nested = extractNestedSpecs(type, payload, ctx.registry);
321
+ if (!nested)
322
+ return executeWrite(ctx, type, payload, user, origin, tx, afterCommitHooks);
323
+ const issues = collectNestedForeignKeyIssues(nested);
324
+ if (issues.length > 0) {
325
+ return writeFailure(new ValidationError({ fields: issues }));
326
+ }
327
+ const parentResult = await executeWrite(ctx, type, nested.cleanPayload, user, origin, tx, afterCommitHooks);
328
+ if (!parentResult.isSuccess)
329
+ return parentResult;
330
+ const parentRow = unwrapSaveRow(parentResult.data);
331
+ const parentId = parentRow["id"];
332
+ if (typeof parentId !== "string") {
333
+ return writeFailure(new InternalError({
334
+ message: `nested-write: parent handler "${type}" returned no string "id" — cannot attach children`,
335
+ }));
336
+ }
337
+ const parentRejection = await rejectUnverifiedParent(ctx, type, parentRow, parentId, user, tx);
338
+ if (parentRejection)
339
+ return parentRejection;
340
+ for (const spec of nested.specs) {
341
+ const children = await writeNestedChildren(ctx, spec, parentId, user, origin, tx, afterCommitHooks);
342
+ if (children.kind === "failed")
343
+ return children.failure;
344
+ parentRow[spec.key] = children.rows;
317
345
  }
318
346
  return parentResult;
319
347
  }
@@ -333,96 +361,130 @@ async function confirmReservation(reservation, tx, user) {
333
361
  }
334
362
  return undefined;
335
363
  }
336
- async function executeWriteInner(ctx, type, payload, user, origin, tx, afterCommitHooks) {
337
- const { registry, jobRunner } = ctx;
338
- const handler = registry.getWriteHandler(type);
364
+ function failedStep(failure) {
365
+ return { kind: "failed", failure };
366
+ }
367
+ function resolveWriteEntry(ctx, type, user, tx) {
368
+ const handler = ctx.registry.getWriteHandler(type);
339
369
  if (!handler)
340
- return writeFailure(new NotFoundError("handler", type));
370
+ return failedStep(writeFailure(new NotFoundError("handler", type)));
341
371
  // Defense in depth — covers runBatch and a spread-copied user that kept
342
372
  // `origin`, beyond buildHandlerContext's own ctx.write replacement.
343
373
  if (isMemberResolutionPrincipal(user)) {
344
- return writeFailure(memberResolutionReadOnlyDenied());
374
+ return failedStep(writeFailure(memberResolutionReadOnlyDenied()));
345
375
  }
346
376
  const isPerCommand = handler.transaction === "perCommand";
347
377
  if (isPerCommand && tx !== undefined) {
348
- return writeFailure(perCommandNestedDenied(type));
378
+ return failedStep(writeFailure(perCommandNestedDenied(type)));
349
379
  }
380
+ return { kind: "run", handler, isPerCommand };
381
+ }
382
+ async function runWriteGates(ctx, handler, type, payload, user) {
350
383
  // A command the dispatcher ran pre-transaction phases for was already billed against the rate
351
384
  // limits; the gates only run again against the in-transaction state.
352
385
  const needsPreTransactionPhase = hasPreTransactionPhase(handler);
353
386
  const reservation = needsPreTransactionPhase ? consumeReservation(type) : undefined;
354
387
  const gates = await runPreHandlerGates(ctx, handler, type, payload, user, reservation ? "recheck" : "charge");
355
388
  if (!gates.isSuccess)
356
- return gates;
357
- const parsedPayload = gates.payload;
389
+ return failedStep(gates);
390
+ const parsedPayload = reservation ? reservation.parsedPayload : gates.payload;
358
391
  // A handler with a pre-transaction phase cannot run that phase from inside an open transaction;
359
392
  // running it without would skip the reservation or the prepared data, so a nested ctx.write of
360
393
  // such a handler is rejected instead.
361
394
  if (needsPreTransactionPhase && !reservation) {
362
- return writeFailure(new InternalError({
395
+ return failedStep(writeFailure(new InternalError({
363
396
  message: `handler "${type}" has a pre-transaction phase (reserveBeforeTransaction/beforeTransaction) and cannot run as a nested write unless the top-level command ran it`,
364
- }));
397
+ })));
365
398
  }
366
- const entityName = registry.getHandlerEntity(type);
367
- // Without a database there is no transaction either, but ordinary handlers keep the in-transaction nested-write semantics.
368
- const handlerContext = await buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, undefined, isPerCommand ? "withoutTransaction" : "inTransaction");
369
- // Auto transition guard: if entity has transitions and handler doesn't skip it.
370
- // Reads via the guard's own db handle — for r.systemScope() handlers
371
- // handlerContext.db is fail-closed, so this reaches for
372
- // systemDb.acknowledgeCrossTenant like any other framework-internal
373
- // mechanism that touches a system-scoped handler's data.
374
- const transitionGuardDb = handlerContext.systemDb
399
+ return { kind: "run", parsedPayload, reservation };
400
+ }
401
+ // Reads via the guard's own db handle — for r.systemScope() handlers
402
+ // handlerContext.db is fail-closed, so this reaches for
403
+ // systemDb.acknowledgeCrossTenant like any other framework-internal
404
+ // mechanism that touches a system-scoped handler's data.
405
+ function resolveTransitionGuardDb(handlerContext, type) {
406
+ return handlerContext.systemDb
375
407
  ? handlerContext.systemDb.acknowledgeCrossTenant(`auto transition guard for r.systemScope() write handler (${type})`)
376
408
  : handlerContext.db;
377
- if (entityName && !handler.unsafeSkipTransitionGuard) {
378
- const entity = registry.getEntity(entityName);
379
- if (entity?.transitions && transitionGuardDb) {
380
- const parsedData = parsedPayload; // @cast-boundary engine-payload
381
- const changes = parsedData["changes"] ?? parsedData; // @cast-boundary engine-payload
382
- const id = parsedData["id"] ?? undefined; // @cast-boundary engine-payload
383
- for (const [fieldName, transitionMap] of Object.entries(entity.transitions)) {
384
- const newValue = changes[fieldName]; // @cast-boundary engine-bridge
385
- if (!newValue || !id)
386
- continue;
387
- const table = getTable(ctx, entityName);
388
- if (!table)
389
- continue;
390
- // SELECT FOR UPDATE inside the surrounding transaction — locks the
391
- // row so a concurrent handler can't mutate `status` between our
392
- // guard check and the handler's UPDATE. Without this lock the guard
393
- // can false-pass; optimistic locking would catch it later, but with
394
- // a less specific error. Falls back to a plain SELECT if no tx is
395
- // active (tests without a DB connection).
396
- const tableName = asEntityTableMeta(table)?.tableName ?? "";
397
- const rows = tx
398
- ? await selectRowForUpdateById(tenantDbRunner(transitionGuardDb), tableName, id)
399
- : await selectMany(transitionGuardDb, table, { id });
400
- const row = rows[0];
401
- if (!row)
402
- continue;
403
- // Skip guard for soft-deleted rows — they shouldn't be transitioning
404
- // at all; a handler that wants to move a deleted row should use
405
- // unsafeSkipTransitionGuard or restore first.
406
- const rowAsRow = row; // @cast-boundary engine-payload
407
- const isDeleted = rowAsRow["isDeleted"] ?? rowAsRow["is_deleted"];
408
- if (entity.softDelete && isDeleted === true) {
409
- continue;
410
- }
411
- const currentValue = row[fieldName] ??
412
- row[toSnakeCase(fieldName)]; // @cast-boundary engine-bridge
413
- guardTransition(getTransitions(ctx, { entityName, fieldName, map: transitionMap }), currentValue, newValue);
414
- }
415
- }
409
+ }
410
+ // Auto transition guard: if entity has transitions and handler doesn't skip it.
411
+ async function guardAutoTransitions(ctx, handler, type, parsedPayload, transitionGuardDb, tx) {
412
+ const entityName = ctx.registry.getHandlerEntity(type);
413
+ // skip: handler has no entity or opted out of the transition guard
414
+ if (!entityName || handler.unsafeSkipTransitionGuard)
415
+ return;
416
+ const entity = ctx.registry.getEntity(entityName);
417
+ // skip: entity declares no transitions, or there is no db to read the current value from
418
+ if (!entity?.transitions || !transitionGuardDb)
419
+ return;
420
+ const parsedData = parsedPayload; // @cast-boundary engine-payload
421
+ const changes = parsedData["changes"] ?? parsedData; // @cast-boundary engine-payload
422
+ const id = parsedData["id"] ?? undefined; // @cast-boundary engine-payload
423
+ for (const [fieldName, transitionMap] of Object.entries(entity.transitions)) {
424
+ await guardFieldTransition(ctx, {
425
+ entityName,
426
+ entity,
427
+ fieldName,
428
+ transitionMap,
429
+ changes,
430
+ id,
431
+ transitionGuardDb,
432
+ tx,
433
+ });
416
434
  }
417
- // The handler itself plus the lifecycle pipeline run under the same
418
- // try-wrapper: any KumikoError bubbles up as a typed WriteErrorInfo, any
419
- // other throw gets wrapped in InternalError so the Prod contract holds
420
- // ("unexpected throw → 500 with sanitized body"). We intentionally do NOT
421
- // catch further out (runBatch still sees these as exceptions via
422
- // writeFailure, not via a rethrow) so batches roll back naturally.
435
+ }
436
+ async function guardFieldTransition(ctx, args) {
437
+ const { entityName, entity, fieldName, transitionMap, changes, id, transitionGuardDb, tx } = args;
438
+ const newValue = changes[fieldName]; // @cast-boundary engine-bridge
439
+ // skip: field is not part of this write
440
+ if (!newValue || !id)
441
+ return;
442
+ const table = getTable(ctx, entityName);
443
+ // skip: entity has no table to read the current value from
444
+ if (!table)
445
+ return;
446
+ // SELECT FOR UPDATE inside the surrounding transaction — locks the
447
+ // row so a concurrent handler can't mutate `status` between our
448
+ // guard check and the handler's UPDATE. Without this lock the guard
449
+ // can false-pass; optimistic locking would catch it later, but with
450
+ // a less specific error. Falls back to a plain SELECT if no tx is
451
+ // active (tests without a DB connection).
452
+ const tableName = asEntityTableMeta(table)?.tableName ?? "";
453
+ const rows = tx
454
+ ? await selectRowForUpdateById(tenantDbRunner(transitionGuardDb), tableName, id)
455
+ : await selectMany(transitionGuardDb, table, { id });
456
+ const row = rows[0];
457
+ // skip: no stored row to transition from
458
+ if (!row)
459
+ return;
460
+ // Skip guard for soft-deleted rows — they shouldn't be transitioning
461
+ // at all; a handler that wants to move a deleted row should use
462
+ // unsafeSkipTransitionGuard or restore first.
463
+ const rowAsRow = row; // @cast-boundary engine-payload
464
+ const isDeleted = rowAsRow["isDeleted"] ?? rowAsRow["is_deleted"];
465
+ // skip: soft-deleted row
466
+ if (entity.softDelete && isDeleted === true) {
467
+ return;
468
+ }
469
+ const currentValue = row[fieldName] ??
470
+ row[toSnakeCase(fieldName)]; // @cast-boundary engine-bridge
471
+ guardTransition(getTransitions(ctx, { entityName, fieldName, map: transitionMap }), currentValue, newValue);
472
+ }
473
+ // The handler itself plus the lifecycle pipeline run under the same
474
+ // try-wrapper: any KumikoError bubbles up as a typed WriteErrorInfo, any
475
+ // other throw gets wrapped in InternalError so the Prod contract holds
476
+ // ("unexpected throw → 500 with sanitized body"). We intentionally do NOT
477
+ // catch further out (runBatch still sees these as exceptions via
478
+ // writeFailure, not via a rethrow) so batches roll back naturally.
479
+ async function invokeWriteHandler(handler, type, gated, user, handlerContext) {
423
480
  let result;
424
481
  try {
425
- const event = { type, payload: parsedPayload, user, prepared: reservation?.prepared };
482
+ const event = {
483
+ type,
484
+ payload: gated.parsedPayload,
485
+ user,
486
+ prepared: gated.reservation?.prepared,
487
+ };
426
488
  result = await handler.handler(event, handlerContext);
427
489
  }
428
490
  catch (e) {
@@ -441,41 +503,86 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
441
503
  `Use defineWriteHandler() or wrap the return as { isSuccess: true as const, data: ... }.`,
442
504
  }));
443
505
  }
444
- if (result.isSuccess && reservation) {
445
- const confirmFailure = await confirmReservation(reservation, tx, user);
506
+ return result;
507
+ }
508
+ async function runWriteLifecycle(ctx, args) {
509
+ try {
510
+ const runner = resolveDbSource(ctx, args.tx);
511
+ await runLifecycle(ctx, args.type, args.result.data, args.handlerContext, args.user, args.origin, args.afterCommitHooks, runner);
512
+ }
513
+ catch (e) {
514
+ return writeFailure(wrapToKumiko(e));
515
+ }
516
+ return undefined;
517
+ }
518
+ function scheduleWriteAfterCommit(ctx, type, user, parsedPayload, result, afterCommitHooks) {
519
+ const { jobRunner } = ctx;
520
+ // jobRunner has external side-effects (BullMQ enqueue) — must NOT
521
+ // fire for rolled-back writes. Defer to afterCommit.
522
+ if (jobRunner) {
523
+ const eventData = (parsedPayload ?? {}); // @cast-boundary engine-payload
524
+ afterCommitHooks.push(() => jobRunner.handleEvent(type, eventData, user));
525
+ }
526
+ invalidateTenantTimezoneCache(ctx, type, user, result);
527
+ // Again after commit: a query landing between the drop above and the
528
+ // commit would read the pre-write row (tx not yet visible) and
529
+ // repopulate the cache with the stale value for a full TTL.
530
+ afterCommitHooks.push(async () => {
531
+ invalidateTenantTimezoneCache(ctx, type, user, result);
532
+ publishConfigWrite(ctx, type, user, result);
533
+ });
534
+ }
535
+ async function settleSuccessfulWrite(ctx, args) {
536
+ if (args.reservation) {
537
+ const confirmFailure = await confirmReservation(args.reservation, args.tx, args.user);
446
538
  if (confirmFailure)
447
539
  return confirmFailure;
448
540
  }
449
- if (result.isSuccess) {
450
- // A perCommand handler's writes ran as their own commands; its result carries no SaveContext of its own to project.
451
- if (!isPerCommand) {
452
- try {
453
- const runner = resolveDbSource(ctx, tx);
454
- await runLifecycle(ctx, type, result.data, handlerContext, user, origin, afterCommitHooks, runner);
455
- }
456
- catch (e) {
457
- return writeFailure(wrapToKumiko(e));
458
- }
459
- }
460
- // jobRunner has external side-effects (BullMQ enqueue) — must NOT
461
- // fire for rolled-back writes. Defer to afterCommit.
462
- if (jobRunner) {
463
- const eventData = (parsedPayload ?? {}); // @cast-boundary engine-payload
464
- afterCommitHooks.push(() => jobRunner.handleEvent(type, eventData, user));
465
- }
466
- invalidateTenantTimezoneCache(ctx, type, user, result);
467
- // Again after commit: a query landing between the drop above and the
468
- // commit would read the pre-write row (tx not yet visible) and
469
- // repopulate the cache with the stale value for a full TTL.
470
- afterCommitHooks.push(async () => {
471
- invalidateTenantTimezoneCache(ctx, type, user, result);
472
- publishConfigWrite(ctx, type, user, result);
473
- });
541
+ // A perCommand handler's writes ran as their own commands; its result carries no SaveContext of its own to project.
542
+ if (!args.isPerCommand) {
543
+ const lifecycleFailure = await runWriteLifecycle(ctx, args);
544
+ if (lifecycleFailure)
545
+ return lifecycleFailure;
474
546
  }
547
+ scheduleWriteAfterCommit(ctx, args.type, args.user, args.parsedPayload, args.result, args.afterCommitHooks);
548
+ return undefined;
549
+ }
550
+ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterCommitHooks) {
551
+ const entry = resolveWriteEntry(ctx, type, user, tx);
552
+ if (entry.kind === "failed")
553
+ return entry.failure;
554
+ const { handler, isPerCommand } = entry;
555
+ const gated = await runWriteGates(ctx, handler, type, payload, user);
556
+ if (gated.kind === "failed")
557
+ return gated.failure;
558
+ // Without a database there is no transaction either, but ordinary handlers keep the in-transaction nested-write semantics.
559
+ const handlerContext = await buildHandlerContext(ctx, type, user, origin, {
560
+ tx,
561
+ afterCommitHooks,
562
+ transactionScope: isPerCommand ? "withoutTransaction" : "inTransaction",
563
+ });
564
+ const transitionGuardDb = resolveTransitionGuardDb(handlerContext, type);
565
+ await guardAutoTransitions(ctx, handler, type, gated.parsedPayload, transitionGuardDb, tx);
566
+ const result = await invokeWriteHandler(handler, type, gated, user, handlerContext);
567
+ if (!result.isSuccess)
568
+ return result;
569
+ const settleFailure = await settleSuccessfulWrite(ctx, {
570
+ type,
571
+ user,
572
+ origin,
573
+ tx,
574
+ afterCommitHooks,
575
+ parsedPayload: gated.parsedPayload,
576
+ reservation: gated.reservation,
577
+ isPerCommand,
578
+ handlerContext,
579
+ result,
580
+ });
581
+ if (settleFailure)
582
+ return settleFailure;
475
583
  // Response-guard: block Secret<> leaks in write responses (SaveContext
476
584
  // data / previous / changes). Feature code that fed a plaintext through
477
585
  // to the return payload fails here instead of hitting the client.
478
- if (result.isSuccess)
479
- assertNoSecretLeak(result.data);
586
+ assertNoSecretLeak(result.data);
480
587
  return result;
481
588
  }
@@ -1,6 +1,7 @@
1
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
- import { coerceRow, extractTableInfo, runInNewTransaction, runInSavepointIfSupported, runnerCanBeginTransaction, selectMany, } from "../db/query.js";
3
+ import { coerceRow, runInNewTransaction, runInSavepointIfSupported, runnerCanBeginTransaction, selectMany, } from "../db/query.js";
4
+ import { extractTableInfo } from "../db/table-info.js";
4
5
  import { getEventsHighWaterMark } from "../event-store/index.js";
5
6
  import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.js";
6
7
  import { mergeContiguousGapRanges, rangeContainsId, splitRangeExcludingIds, toIdRanges, } from "./pending-gap-ranges.js";
@@ -3,7 +3,8 @@ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
3
  import { requestContext } from "../api/request-context.js";
4
4
  import { insertConsumerIfAbsent, markConsumerProcessing, rearmDeadConsumer, selectConsumerForUpdate, selectConsumerForUpdateSkipLocked, selectProvablyIdleConsumerPairs, updateConsumerDeliveryOutcome, } from "../db/queries/event-consumer.js";
5
5
  import { selectEventsHeadId, selectPendingAndNewEventRows, } from "../db/queries/event-store.js";
6
- import { coerceRow, extractTableInfo } from "../db/query.js";
6
+ import { coerceRow } from "../db/query.js";
7
+ import { extractTableInfo } from "../db/table-info.js";
7
8
  import { qnScope } from "../engine/qualified-name.js";
8
9
  import { eventsTable, toStoredEvent as rowToStoredEvent, } from "../event-store/index.js";
9
10
  import { emitDispatcherError, emitEventConsumerLag, emitEventConsumerPendingGaps, getFallbackMeter, } from "../observability/index.js";
@@ -30,10 +31,6 @@ const UNPARSEABLE_STORED_WRITE_ORIGIN = {
30
31
  export async function acquireConsumerState(tx, name, instanceId, rearmCooldownMs, maxRearmCount) {
31
32
  const rawState = await selectConsumerForUpdateSkipLocked(tx, name, instanceId);
32
33
  if (!rawState) {
33
- return { state: null, skip: "not_registered" };
34
- }
35
- const state = coerceRow(rawState, extractTableInfo(eventConsumerStateTable));
36
- if (!state) {
37
34
  // Either the row never existed (no pre-reg, no ensureRegistered) or
38
35
  // another instance currently holds the lock with SKIP LOCKED filtering
39
36
  // us out. We can't distinguish here in a single query, so return
@@ -42,6 +39,7 @@ export async function acquireConsumerState(tx, name, instanceId, rearmCooldownMs
42
39
  // this path is never taken.
43
40
  return { state: null, skip: "not_registered" };
44
41
  }
42
+ const state = coerceRow(rawState, extractTableInfo(eventConsumerStateTable));
45
43
  if (state.status === ConsumerStatuses.disabled)
46
44
  return { state: null, skip: "disabled" };
47
45
  if (state.status === ConsumerStatuses.dead) {
@@ -0,0 +1,9 @@
1
+ import type { TenantId } from "../engine/types/identifiers.ts";
2
+ import type { MemberReader, SessionUser } from "../engine/types/index.ts";
3
+ import type { HandlerContextScope } from "./handler-context-scope.ts";
4
+ export declare function buildAuthFacet(scope: HandlerContextScope): {
5
+ resolveAuthClaims: (claimsUser: SessionUser) => Promise<Record<string, unknown>>;
6
+ resolveActiveMembership: (userId: string, tenantId: TenantId) => Promise<import("@cosmicdrift/kumiko-types/handlers").ActiveMembershipResult>;
7
+ queryAsMember: MemberReader;
8
+ hasFeature: (featureName: string) => Promise<boolean>;
9
+ };