@cosmicdrift/kumiko-framework 0.387.0 → 0.389.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 (375) hide show
  1. package/AGENTS.md +9 -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/golden-path.md +13 -1
  9. package/agents/examples/legal-pages.md +1 -1
  10. package/agents/examples/lifecycle-hooks.md +55 -2
  11. package/agents/examples/list-table-options.md +466 -0
  12. package/agents/examples/pdf-audit-export.md +4 -2
  13. package/agents/examples/prod-boot-options.md +16 -0
  14. package/agents/examples/rate-limiting.md +48 -0
  15. package/agents/examples/record-detail-layout.md +692 -0
  16. package/agents/examples/seed-migration.md +212 -0
  17. package/agents/examples/state-machine.md +3 -2
  18. package/agents/examples/tenant-isolation.md +73 -1
  19. package/agents/examples/wizard-form.md +941 -0
  20. package/agents/examples/writeform-section.md +548 -0
  21. package/agents/examples.md +1 -0
  22. package/agents/features.md +19 -2
  23. package/agents/widgets.md +2 -0
  24. package/dist/api/api-constants.d.ts +1 -0
  25. package/dist/api/api-constants.js +2 -0
  26. package/dist/api/auth-routes-shared.d.ts +2 -0
  27. package/dist/api/auth-routes-shared.js +5 -0
  28. package/dist/api/client-error-route.js +10 -5
  29. package/dist/api/entity-read-access.d.ts +2 -1
  30. package/dist/api/entity-read-access.js +14 -0
  31. package/dist/api/export/export-cells-reference.d.ts +9 -0
  32. package/dist/api/export/export-cells-reference.js +21 -0
  33. package/dist/api/export/export-cells.d.ts +11 -0
  34. package/dist/api/export/export-cells.js +136 -0
  35. package/dist/api/export/export-ciphertext-guard.d.ts +2 -0
  36. package/dist/api/export/export-ciphertext-guard.js +27 -0
  37. package/dist/api/export/export-columns.d.ts +29 -0
  38. package/dist/api/export/export-columns.js +85 -0
  39. package/dist/api/export/export-errors.d.ts +4 -0
  40. package/dist/api/export/export-errors.js +14 -0
  41. package/dist/api/export/export-route.d.ts +4 -0
  42. package/dist/api/export/export-route.js +74 -0
  43. package/dist/api/export/export-rows.d.ts +13 -0
  44. package/dist/api/export/export-rows.js +62 -0
  45. package/dist/api/export/export-screen.d.ts +12 -0
  46. package/dist/api/export/export-screen.js +47 -0
  47. package/dist/api/export/export-xlsx-writer.d.ts +4 -0
  48. package/dist/api/export/export-xlsx-writer.js +21 -0
  49. package/dist/api/extra-route.d.ts +4 -0
  50. package/dist/api/index.d.ts +3 -1
  51. package/dist/api/index.js +3 -1
  52. package/dist/api/pii-leak-guard.d.ts +3 -0
  53. package/dist/api/pii-leak-guard.js +3 -2
  54. package/dist/api/request-context.d.ts +1 -1
  55. package/dist/api/request-context.js +1 -1
  56. package/dist/api/routes.d.ts +16 -0
  57. package/dist/api/routes.js +2 -2
  58. package/dist/api/server-boot-checks.d.ts +10 -0
  59. package/dist/api/server-boot-checks.js +157 -0
  60. package/dist/api/server-event-dispatcher.d.ts +26 -0
  61. package/dist/api/server-event-dispatcher.js +208 -0
  62. package/dist/api/server-extra-routes.d.ts +11 -0
  63. package/dist/api/server-extra-routes.js +206 -0
  64. package/dist/api/server-middleware.d.ts +20 -0
  65. package/dist/api/server-middleware.js +211 -0
  66. package/dist/api/server-routes.d.ts +19 -0
  67. package/dist/api/server-routes.js +299 -0
  68. package/dist/api/server-websocket-routes.d.ts +4 -0
  69. package/dist/api/server-websocket-routes.js +108 -0
  70. package/dist/api/server-wiring.d.ts +32 -0
  71. package/dist/api/server-wiring.js +238 -0
  72. package/dist/api/server.d.ts +10 -15
  73. package/dist/api/server.js +48 -1251
  74. package/dist/api/session-cookies.js +4 -4
  75. package/dist/api/sse-route.d.ts +2 -1
  76. package/dist/api/sse-route.js +27 -13
  77. package/dist/config/index.d.ts +3 -1
  78. package/dist/crypto/owner-transition-rekey.d.ts +5 -1
  79. package/dist/crypto/owner-transition-rekey.js +1 -1
  80. package/dist/crypto/pg-kms-adapter.d.ts +1 -0
  81. package/dist/crypto/pg-kms-adapter.js +12 -18
  82. package/dist/crypto/pii-field-encryption.d.ts +5 -1
  83. package/dist/crypto/pii-field-encryption.js +3 -1
  84. package/dist/db/aggregate-sql.d.ts +23 -0
  85. package/dist/db/aggregate-sql.js +165 -0
  86. package/dist/db/closed-connection.d.ts +9 -0
  87. package/dist/db/closed-connection.js +88 -0
  88. package/dist/db/connection.d.ts +4 -5
  89. package/dist/db/connection.js +29 -20
  90. package/dist/db/dialect.js +1 -1
  91. package/dist/db/distinct-on.d.ts +1 -1
  92. package/dist/db/driver-row-coercion.d.ts +2 -0
  93. package/dist/db/driver-row-coercion.js +186 -0
  94. package/dist/db/driver-value-preparation.d.ts +16 -0
  95. package/dist/db/driver-value-preparation.js +101 -0
  96. package/dist/db/entity-field-encryption.d.ts +1 -0
  97. package/dist/db/entity-field-encryption.js +1 -1
  98. package/dist/db/entity-table-meta.js +103 -110
  99. package/dist/db/event-store-executor-context.d.ts +2 -1
  100. package/dist/db/event-store-executor-read-aggregate-currencies.d.ts +17 -0
  101. package/dist/db/event-store-executor-read-aggregate-currencies.js +40 -0
  102. package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
  103. package/dist/db/event-store-executor-read-aggregate.js +143 -0
  104. package/dist/db/event-store-executor-read-filter.d.ts +31 -0
  105. package/dist/db/event-store-executor-read-filter.js +206 -0
  106. package/dist/db/event-store-executor-read-paging.d.ts +14 -0
  107. package/dist/db/event-store-executor-read-paging.js +99 -0
  108. package/dist/db/event-store-executor-read-rows.d.ts +6 -0
  109. package/dist/db/event-store-executor-read-rows.js +32 -0
  110. package/dist/db/event-store-executor-read-search.d.ts +28 -0
  111. package/dist/db/event-store-executor-read-search.js +221 -0
  112. package/dist/db/event-store-executor-read-sort.d.ts +41 -0
  113. package/dist/db/event-store-executor-read-sort.js +130 -0
  114. package/dist/db/event-store-executor-read-where.d.ts +24 -0
  115. package/dist/db/event-store-executor-read-where.js +81 -0
  116. package/dist/db/event-store-executor-read.d.ts +2 -9
  117. package/dist/db/event-store-executor-read.js +48 -701
  118. package/dist/db/event-store-executor-write-atomic.d.ts +4 -0
  119. package/dist/db/event-store-executor-write-atomic.js +41 -0
  120. package/dist/db/event-store-executor-write-steps.d.ts +17 -0
  121. package/dist/db/event-store-executor-write-steps.js +95 -0
  122. package/dist/db/event-store-executor-write.js +405 -509
  123. package/dist/db/event-store-executor.js +2 -1
  124. package/dist/db/executor-managed-tables.js +1 -1
  125. package/dist/db/index.d.ts +7 -3
  126. package/dist/db/index.js +6 -2
  127. package/dist/db/list-aggregate-labels.d.ts +14 -0
  128. package/dist/db/list-aggregate-labels.js +26 -0
  129. package/dist/db/migrate-generator.js +71 -86
  130. package/dist/db/migrate-runner.js +7 -4
  131. package/dist/db/pg-error.js +26 -21
  132. package/dist/db/queries/backfill-pii.js +3 -6
  133. package/dist/db/queries/database-now.js +2 -1
  134. package/dist/db/queries/shadow-swap.js +2 -3
  135. package/dist/db/query.d.ts +16 -15
  136. package/dist/db/query.js +76 -804
  137. package/dist/db/reference-data.js +19 -2
  138. package/dist/db/statement-timeout.js +2 -1
  139. package/dist/db/table-builder.js +113 -166
  140. package/dist/db/table-info.d.ts +10 -0
  141. package/dist/db/table-info.js +45 -0
  142. package/dist/db/temporal-value-guards.d.ts +6 -0
  143. package/dist/db/temporal-value-guards.js +15 -0
  144. package/dist/db/tenant-db-runner.d.ts +1 -0
  145. package/dist/db/tenant-db-runner.js +3 -0
  146. package/dist/db/tenant-db-upsert.d.ts +5 -0
  147. package/dist/db/tenant-db-upsert.js +91 -0
  148. package/dist/db/tenant-db.js +50 -10
  149. package/dist/db/where-clause.d.ts +6 -0
  150. package/dist/db/where-clause.js +236 -0
  151. package/dist/derivatives/delete-stored-file.js +2 -1
  152. package/dist/engine/boot-validator/boot-check.js +1 -1
  153. package/dist/engine/boot-validator/bundled-lists-table.d.ts +2 -0
  154. package/dist/engine/boot-validator/bundled-lists-table.js +36 -0
  155. package/dist/engine/boot-validator/detail-screens.js +2 -2
  156. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  157. package/dist/engine/boot-validator/entity-handler.js +17 -0
  158. package/dist/engine/boot-validator/entity-list-aggregate-options.d.ts +3 -0
  159. package/dist/engine/boot-validator/entity-list-aggregate-options.js +23 -0
  160. package/dist/engine/boot-validator/form-derivations.js +35 -1
  161. package/dist/engine/boot-validator/i18n-keys.js +5 -3
  162. package/dist/engine/boot-validator/index.js +2 -0
  163. package/dist/engine/boot-validator/list-bulk-actions.d.ts +3 -0
  164. package/dist/engine/boot-validator/list-bulk-actions.js +49 -0
  165. package/dist/engine/boot-validator/list-column-layout.d.ts +8 -0
  166. package/dist/engine/boot-validator/list-column-layout.js +41 -0
  167. package/dist/engine/boot-validator/list-table-aggregate-options.d.ts +24 -0
  168. package/dist/engine/boot-validator/list-table-aggregate-options.js +145 -0
  169. package/dist/engine/boot-validator/list-table-aggregate-query.d.ts +10 -0
  170. package/dist/engine/boot-validator/list-table-aggregate-query.js +31 -0
  171. package/dist/engine/boot-validator/list-table-export-options.d.ts +1 -0
  172. package/dist/engine/boot-validator/list-table-export-options.js +27 -0
  173. package/dist/engine/boot-validator/list-table-options.d.ts +3 -0
  174. package/dist/engine/boot-validator/list-table-options.js +77 -0
  175. package/dist/engine/boot-validator/list-table-reject-unknown-keys.d.ts +1 -0
  176. package/dist/engine/boot-validator/list-table-reject-unknown-keys.js +7 -0
  177. package/dist/engine/boot-validator/pii-retention.js +233 -155
  178. package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
  179. package/dist/engine/boot-validator/projection-list-screens.js +3 -13
  180. package/dist/engine/boot-validator/related-list-table-rules.d.ts +4 -0
  181. package/dist/engine/boot-validator/related-list-table-rules.js +42 -0
  182. package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
  183. package/dist/engine/boot-validator/screens-actions.js +8 -1
  184. package/dist/engine/boot-validator/screens-dashboard.js +2 -5
  185. package/dist/engine/boot-validator/screens-forms.js +2 -6
  186. package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
  187. package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
  188. package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
  189. package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
  190. package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
  191. package/dist/engine/boot-validator/screens-kind-entity-list.js +235 -0
  192. package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
  193. package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
  194. package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
  195. package/dist/engine/boot-validator/screens-kind-projection-list.js +56 -0
  196. package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
  197. package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
  198. package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
  199. package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
  200. package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
  201. package/dist/engine/boot-validator/screens-lists.js +24 -11
  202. package/dist/engine/boot-validator/screens.d.ts +0 -2
  203. package/dist/engine/boot-validator/screens.js +99 -860
  204. package/dist/engine/build-app-schema.d.ts +2 -1
  205. package/dist/engine/build-app-schema.js +65 -97
  206. package/dist/engine/build-config-feature-schema.js +6 -1
  207. package/dist/engine/define-feature.js +13 -2
  208. package/dist/engine/define-handler.js +1 -0
  209. package/dist/engine/entity-handlers.d.ts +13 -0
  210. package/dist/engine/entity-handlers.js +10 -21
  211. package/dist/engine/entity-list-runtime-options.d.ts +8 -0
  212. package/dist/engine/entity-list-runtime-options.js +18 -0
  213. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  214. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  215. package/dist/engine/feature-ast/extractors/index.js +1 -1
  216. package/dist/engine/feature-ast/extractors/round1.d.ts +2 -1
  217. package/dist/engine/feature-ast/extractors/round1.js +6 -0
  218. package/dist/engine/feature-ast/extractors/round2.js +25 -6
  219. package/dist/engine/feature-ast/index.d.ts +1 -1
  220. package/dist/engine/feature-ast/parse.js +3 -1
  221. package/dist/engine/feature-ast/patch.d.ts +2 -0
  222. package/dist/engine/feature-ast/patch.js +5 -182
  223. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -245
  224. package/dist/engine/feature-ast/pattern-change-schema.js +10 -163
  225. package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
  226. package/dist/engine/feature-ast/pattern-id-matchers.js +211 -0
  227. package/dist/engine/feature-ast/pattern-id-schema.d.ts +251 -0
  228. package/dist/engine/feature-ast/pattern-id-schema.js +166 -0
  229. package/dist/engine/feature-ast/patterns.d.ts +14 -1
  230. package/dist/engine/feature-ast/patterns.js +44 -51
  231. package/dist/engine/feature-ast/render.js +52 -87
  232. package/dist/engine/feature-builder-state.d.ts +1 -0
  233. package/dist/engine/feature-builder-state.js +1 -0
  234. package/dist/engine/feature-entity-handlers.js +3 -0
  235. package/dist/engine/feature-manifest.d.ts +1 -0
  236. package/dist/engine/feature-manifest.js +1 -0
  237. package/dist/engine/field-access.d.ts +1 -0
  238. package/dist/engine/field-access.js +12 -0
  239. package/dist/engine/form-derivations.js +18 -7
  240. package/dist/engine/handler-field-references.js +1 -1
  241. package/dist/engine/index.d.ts +2 -2
  242. package/dist/engine/index.js +1 -1
  243. package/dist/engine/pattern-library/library.js +2 -1
  244. package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
  245. package/dist/engine/pattern-library/static-schemas.js +9 -0
  246. package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
  247. package/dist/engine/project-app-schema-for-roles.js +6 -4
  248. package/dist/engine/registry-ingest.js +1 -1
  249. package/dist/engine/schema-builder.js +228 -212
  250. package/dist/engine/screen-filter-ops.js +2 -0
  251. package/dist/engine/screen-helpers.js +1 -1
  252. package/dist/engine/screen-kind.d.ts +7 -0
  253. package/dist/engine/screen-kind.js +1 -0
  254. package/dist/engine/strip-disabled-features-from-app-schema.js +58 -17
  255. package/dist/engine/types/feature.d.ts +1 -0
  256. package/dist/engine/types/index.d.ts +4 -3
  257. package/dist/engine/types/screen.d.ts +1 -1
  258. package/dist/engine/types/screen.js +1 -1
  259. package/dist/engine/validate-projection-allowlist.js +2 -1
  260. package/dist/entrypoint/index.d.ts +6 -1
  261. package/dist/entrypoint/index.js +10 -1
  262. package/dist/errors/docs-url.d.ts +1 -1
  263. package/dist/errors/docs-url.js +22 -2
  264. package/dist/es-ops/context.js +1 -1
  265. package/dist/event-store/admin-api.js +1 -1
  266. package/dist/event-store/event-store.d.ts +1 -0
  267. package/dist/event-store/event-store.js +17 -8
  268. package/dist/event-store/provenance-append.js +1 -1
  269. package/dist/files/bind-pending-file-refs.js +3 -3
  270. package/dist/files/create-file-ref-from-bytes.js +7 -1
  271. package/dist/files/create-file-ref.d.ts +3 -0
  272. package/dist/files/create-file-ref.js +11 -2
  273. package/dist/files/file-handle.js +2 -3
  274. package/dist/files/file-routes.d.ts +2 -0
  275. package/dist/files/file-routes.js +40 -10
  276. package/dist/files/provider-resolver.d.ts +1 -0
  277. package/dist/files/provider-resolver.js +1 -1
  278. package/dist/files/record-write-check.d.ts +6 -0
  279. package/dist/files/record-write-check.js +13 -0
  280. package/dist/files/resolve-content-type.js +43 -0
  281. package/dist/files/upload-ledger.d.ts +2 -2
  282. package/dist/files/upload-ledger.js +11 -4
  283. package/dist/i18n/required-surface-keys.js +189 -208
  284. package/dist/internal/stack-support.d.ts +2 -1
  285. package/dist/internal/stack-support.js +1 -1
  286. package/dist/internal/testing.js +1 -1
  287. package/dist/jobs/job-runner-context.d.ts +32 -0
  288. package/dist/jobs/job-runner-context.js +305 -0
  289. package/dist/jobs/job-runner-execution.d.ts +7 -0
  290. package/dist/jobs/job-runner-execution.js +160 -0
  291. package/dist/jobs/job-runner-gates.d.ts +19 -0
  292. package/dist/jobs/job-runner-gates.js +198 -0
  293. package/dist/jobs/job-runner-helpers.d.ts +17 -3
  294. package/dist/jobs/job-runner-helpers.js +61 -2
  295. package/dist/jobs/job-runner-scope.d.ts +44 -0
  296. package/dist/jobs/job-runner-scope.js +102 -0
  297. package/dist/jobs/job-runner-types.d.ts +12 -0
  298. package/dist/jobs/job-runner.d.ts +0 -1
  299. package/dist/jobs/job-runner.js +50 -657
  300. package/dist/observability/escape-hatch-report.d.ts +3 -0
  301. package/dist/observability/escape-hatch-report.js +21 -3
  302. package/dist/pipeline/after-rollback-sink.d.ts +6 -0
  303. package/dist/pipeline/after-rollback-sink.js +32 -0
  304. package/dist/pipeline/cascade-handler.js +165 -138
  305. package/dist/pipeline/dispatch-batch.js +8 -1
  306. package/dist/pipeline/dispatch-query.js +10 -3
  307. package/dist/pipeline/dispatch-shared.d.ts +13 -3
  308. package/dist/pipeline/dispatch-shared.js +62 -710
  309. package/dist/pipeline/dispatch-write.js +296 -189
  310. package/dist/pipeline/event-dispatcher-admin.js +2 -1
  311. package/dist/pipeline/event-dispatcher-delivery.js +6 -6
  312. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  313. package/dist/pipeline/event-dispatcher.js +6 -9
  314. package/dist/pipeline/handler-context-auth.d.ts +9 -0
  315. package/dist/pipeline/handler-context-auth.js +54 -0
  316. package/dist/pipeline/handler-context-config.d.ts +14 -0
  317. package/dist/pipeline/handler-context-config.js +113 -0
  318. package/dist/pipeline/handler-context-db.d.ts +24 -0
  319. package/dist/pipeline/handler-context-db.js +149 -0
  320. package/dist/pipeline/handler-context-dispatch.d.ts +23 -0
  321. package/dist/pipeline/handler-context-dispatch.js +101 -0
  322. package/dist/pipeline/handler-context-events.d.ts +41 -0
  323. package/dist/pipeline/handler-context-events.js +239 -0
  324. package/dist/pipeline/handler-context-notify.d.ts +12 -0
  325. package/dist/pipeline/handler-context-notify.js +55 -0
  326. package/dist/pipeline/handler-context-observability.d.ts +7 -0
  327. package/dist/pipeline/handler-context-observability.js +21 -0
  328. package/dist/pipeline/handler-context-scope.d.ts +48 -0
  329. package/dist/pipeline/handler-context-scope.js +88 -0
  330. package/dist/pipeline/notify-scope.js +12 -1
  331. package/dist/pipeline/pre-handler-gates.js +20 -4
  332. package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
  333. package/dist/pipeline/pre-transaction-reservations.js +9 -3
  334. package/dist/pipeline/projection-rebuild.js +2 -1
  335. package/dist/pipeline/system-hooks.d.ts +3 -1
  336. package/dist/pipeline/system-hooks.js +55 -6
  337. package/dist/pipeline/write-result-masking.js +13 -10
  338. package/dist/schema-cli/apply-command.d.ts +2 -0
  339. package/dist/schema-cli/apply-command.js +97 -0
  340. package/dist/schema-cli/baseline-command.d.ts +2 -0
  341. package/dist/schema-cli/baseline-command.js +33 -0
  342. package/dist/schema-cli/generate-command.d.ts +2 -0
  343. package/dist/schema-cli/generate-command.js +90 -0
  344. package/dist/schema-cli/shared.d.ts +39 -0
  345. package/dist/schema-cli/shared.js +95 -0
  346. package/dist/schema-cli/status-command.d.ts +2 -0
  347. package/dist/schema-cli/status-command.js +73 -0
  348. package/dist/schema-cli/validate-boot-input.d.ts +5 -0
  349. package/dist/schema-cli/validate-boot-input.js +130 -0
  350. package/dist/schema-cli/validate-command.d.ts +2 -0
  351. package/dist/schema-cli/validate-command.js +130 -0
  352. package/dist/schema-cli.d.ts +2 -18
  353. package/dist/schema-cli.js +47 -604
  354. package/dist/search/meilisearch-adapter.js +2 -1
  355. package/dist/search/reindex-entity.js +15 -5
  356. package/dist/ui-types/index.d.ts +2 -2
  357. package/dist/ui-types/index.js +1 -1
  358. package/dist/upgrade-changelog.d.ts +2 -0
  359. package/dist/upgrade-changelog.js +14 -0
  360. package/dist/upgrade-cli.d.ts +3 -12
  361. package/dist/upgrade-cli.js +171 -197
  362. package/dist/upgrade-marker-parse.d.ts +32 -0
  363. package/dist/upgrade-marker-parse.js +73 -0
  364. package/package.json +9 -6
  365. package/src/changes.json +555 -0
  366. package/src/scripts/codemod/codemod-support.ts +1 -1
  367. package/src/scripts/codemod/db-factory.ts +1 -1
  368. package/src/scripts/codemod/feature-layout.ts +10 -2
  369. package/src/scripts/codemod/object-form.ts +41 -4
  370. package/src/scripts/codemod/package-merge.ts +58 -8
  371. package/src/scripts/codemod/pii-personal-migration.ts +175 -145
  372. package/src/scripts/codemod/testing-injection.ts +122 -42
  373. package/src/scripts/codemod/testing-move.ts +29 -7
  374. package/dist/engine/provider-plugin-context.d.ts +0 -3
  375. package/dist/engine/provider-plugin-context.js +0 -28
@@ -35,6 +35,10 @@ back.
35
35
  - **`r.hook({ type: "postDelete", target: { allOf: entityRef }, handler })`** — fires after commit.
36
36
  External side effects (logs, notifications) belong here because
37
37
  failures cannot roll the delete back.
38
+ - **`ctx.scheduleAfterRollback(fn)`** — the counterpart of `scheduleAfterCommit`: runs `fn` when
39
+ the handler transaction (or the enclosing savepoint of a nested `ctx.write`) does not commit.
40
+ Use it to give back something reserved outside the transaction, like the slot in
41
+ `article:reserve-slot`. A failed COMMIT triggers it too, so write the callback idempotent.
38
42
 
39
43
  ## When to reach for it
40
44
 
@@ -71,7 +75,7 @@ import {
71
75
  type TestStack,
72
76
  unsafeCreateEntityTable,
73
77
  } from "@cosmicdrift/kumiko-testing/stack";
74
- import { articleEntity, articlesFeature, hookLog } from "../feature";
78
+ import { articleEntity, articlesFeature, hookLog, reservedSlots } from "../feature";
75
79
 
76
80
  let stack: TestStack;
77
81
 
@@ -90,6 +94,7 @@ afterAll(async () => {
90
94
  beforeEach(() => {
91
95
  stack.events.reset();
92
96
  hookLog.length = 0;
97
+ reservedSlots.clear();
93
98
  });
94
99
 
95
100
  describe("validation hook: single handler", () => {
@@ -256,6 +261,29 @@ describe("preDelete + postDelete entity hooks", () => {
256
261
  expect(hookLog.map((e) => e.type)).toEqual(["deleted"]);
257
262
  });
258
263
  });
264
+
265
+ describe("scheduleAfterRollback", () => {
266
+ test("keeps the outside-transaction reservation when the write commits", async () => {
267
+ await stack.http.writeOk(
268
+ "blog:write:article:reserve-slot",
269
+ { slot: "kept", reject: false },
270
+ editor,
271
+ );
272
+
273
+ expect(reservedSlots.has("kept")).toBe(true);
274
+ });
275
+
276
+ test("gives the reservation back when the write rolls back", async () => {
277
+ const error = await stack.http.writeErr(
278
+ "blog:write:article:reserve-slot",
279
+ { slot: "released", reject: true },
280
+ editor,
281
+ );
282
+
283
+ expectErrorIncludes(error, "slot_rejected");
284
+ expect(reservedSlots.has("released")).toBe(false);
285
+ });
286
+ });
259
287
  ```
260
288
 
261
289
  ### src/feature.ts
@@ -276,7 +304,12 @@ import {
276
304
  defineFeature,
277
305
  type SaveContext,
278
306
  } from "@cosmicdrift/kumiko-framework/engine";
279
- import { ConflictError } from "@cosmicdrift/kumiko-framework/errors";
307
+ import {
308
+ ConflictError,
309
+ UnprocessableError,
310
+ writeFailure,
311
+ } from "@cosmicdrift/kumiko-framework/errors";
312
+ import * as z from "zod";
280
313
 
281
314
  export const articleEntity = createEntity({
282
315
  table: "read_sample_articles",
@@ -298,6 +331,9 @@ export const articleEntity = createEntity({
298
331
  // Collect hook events for testing
299
332
  export const hookLog: { type: string; data: Record<string, unknown> }[] = [];
300
333
 
334
+ // Stands in for a quota held in an external system, outside the handler transaction.
335
+ export const reservedSlots = new Set<string>();
336
+
301
337
  const editorWrite = { access: { roles: ["Admin", "Editor"] } } as const;
302
338
 
303
339
  export const articlesFeature = defineFeature("blog", (r) => {
@@ -311,6 +347,23 @@ export const articlesFeature = defineFeature("blog", (r) => {
311
347
  );
312
348
  r.writeHandler(defineEntityDeleteHandler("article", articleEntity, editorWrite));
313
349
 
350
+ // --- scheduleAfterRollback: give back a reservation made outside the transaction when it does
351
+ // not commit. The callback must be idempotent: a failed COMMIT also triggers it.
352
+ r.writeHandler({
353
+ name: "article:reserve-slot",
354
+ schema: z.object({ slot: z.string(), reject: z.boolean() }),
355
+ access: editorWrite.access,
356
+ handler: async (event, ctx) => {
357
+ const { slot, reject } = event.payload;
358
+ reservedSlots.add(slot);
359
+ ctx.scheduleAfterRollback?.(async () => {
360
+ reservedSlots.delete(slot);
361
+ });
362
+ if (reject) return writeFailure(new UnprocessableError("slot_rejected"));
363
+ return { isSuccess: true as const, data: {} };
364
+ },
365
+ });
366
+
314
367
  // --- Validation hook on create: reject banned words + length ---
315
368
  r.hook({
316
369
  type: "validation",
@@ -0,0 +1,466 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/list-table-options. Do not edit. -->
2
+
3
+ # list-table-options
4
+
5
+ Opt-in table behaviour of a list: remembered search/sort/filters, one reset button, column picker with hidden columns, row density, server-side groups with sums, a totals row and a number range facet.
6
+
7
+ ## README
8
+
9
+ # List table options
10
+
11
+ Opt-in table behaviour for entity, projection and related lists. A list without
12
+ the `table` block renders and behaves as before.
13
+
14
+ ## What it shows
15
+
16
+ - `table.rememberState`: search, sort, page size, facets and date range survive
17
+ leaving and re-entering the list within the browser session.
18
+ - `table.resetAll`: one button resets everything that differs from the defaults.
19
+ - `table.columnPicker`: the view menu lets the user hide and reorder columns;
20
+ `hidden: true` on a column starts it hidden.
21
+ - `table.density`: row density default and an optional user choice.
22
+ - `table.pinning`: the user pins columns left or right in the view menu;
23
+ `pinned: "left"` on a column starts it pinned.
24
+ - `table.resizing`: columns can be resized (table-layout fixed); `width` and
25
+ `minWidth` on a column are start values in px.
26
+ - `table.keyboardNavigation`: rows are one tab stop, arrow keys move between rows,
27
+ Space selects the focused row.
28
+ - `table.export`: an Export button downloads the whole filtered list as an XLSX
29
+ file; `maxRows` lowers the default limit of 10000 rows.
30
+ Reference columns export their label and timestamps use the browser time zone.
31
+ - `bulkActions`: row checkboxes and a selection bar. "Delete" runs the delete
32
+ row action once per selected row; done tasks are skipped.
33
+ - `table.grouping`: the server groups the list by status; each header shows the
34
+ group's count and estimate sum, "done" starts collapsed and the view menu
35
+ offers grouping by priority or none.
36
+ - `table.totals`: a footer row with the estimate sum over the filtered list.
37
+ - `facets.estimateHours: { display: "range" }`: from/to inputs for a number field.
38
+ - The same block on a `projectionList`, and `task:workload`, a hand-written list
39
+ query that keeps grouping and totals by accepting `aggregate`
40
+ (`entityListSchema`) and answering it with `answerListAggregate`.
41
+
42
+ ## Source
43
+
44
+ Feature entry point: `src/feature.ts`.
45
+
46
+ ## Tests
47
+
48
+ ```bash
49
+ cd samples/recipes/list-table-options
50
+ bun test
51
+ ```
52
+
53
+ The test boots the feature through the boot validator and checks that a
54
+ misspelled key, a hidden column without the picker, an unknown density, a pinned column
55
+ without `pinning`, a width without `resizing` and a bulk action on a navigate
56
+ row action are rejected.
57
+ `workload.integration.test.ts` runs the hand-written query over HTTP and checks
58
+ the grouped sums and the filtered total.
59
+
60
+ ## Source files
61
+
62
+ ### src/__tests__/feature.test.ts
63
+
64
+ ```ts
65
+ import { describe, expect, test } from "bun:test";
66
+ import {
67
+ createRegistry,
68
+ defineFeature,
69
+ validateBoot as validateBootRaw,
70
+ } from "@cosmicdrift/kumiko-framework/engine";
71
+ import type { ListTableOptions } from "@cosmicdrift/kumiko-framework/ui-types";
72
+ import { withBootValidatorFixture } from "@cosmicdrift/kumiko-testing/helpers";
73
+ import { createTaskBoardFeature, openAccess, taskEntity } from "../feature";
74
+
75
+ // The boot validator is the net for blocks the type system never sees (JS, generated features).
76
+ function featureWithBlock(block: object) {
77
+ return createTaskBoardFeature(block as ListTableOptions);
78
+ }
79
+
80
+ function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
81
+ validateBootRaw(withBootValidatorFixture(features));
82
+ }
83
+
84
+ describe("list-table-options recipe", () => {
85
+ test("the full table block boots and reaches the registered screen", () => {
86
+ const feature = createTaskBoardFeature();
87
+ expect(() => validateBoot([feature])).not.toThrow();
88
+ const screen = createRegistry([feature]).getScreen("task-board:screen:task-list");
89
+ expect(screen?.type === "entityList" ? screen.table : undefined).toEqual({
90
+ rememberState: true,
91
+ resetAll: true,
92
+ columnPicker: true,
93
+ density: { default: "comfortable", userChoice: true },
94
+ pinning: true,
95
+ resizing: true,
96
+ keyboardNavigation: true,
97
+ export: { xlsx: true, maxRows: 5000 },
98
+ grouping: { field: "status", collapsedWhen: "done", userChoice: true, fields: ["priority"] },
99
+ totals: [{ field: "estimateHours", kind: "sum" }],
100
+ });
101
+ });
102
+
103
+ test("the bulk action resolves to the delete row action", () => {
104
+ const screen = createRegistry([createTaskBoardFeature()]).getScreen(
105
+ "task-board:screen:task-list",
106
+ );
107
+ expect(screen?.type === "entityList" ? screen.bulkActions : undefined).toEqual([
108
+ { id: "delete", rowAction: "delete" },
109
+ ]);
110
+ });
111
+
112
+ test("a bulk action that points at a navigate row action is rejected", () => {
113
+ const feature = defineFeature("bulk-on-navigate", (r) => {
114
+ r.crud("task", taskEntity, { write: openAccess, read: openAccess });
115
+ r.screen({
116
+ id: "task-list",
117
+ type: "entityList",
118
+ entity: "task",
119
+ columns: ["title"],
120
+ rowActions: [{ kind: "navigate", id: "open", label: "Open", screen: "task-list" }],
121
+ bulkActions: [{ id: "open", rowAction: "open" }],
122
+ access: openAccess.access,
123
+ });
124
+ });
125
+ expect(() => validateBoot([feature])).toThrow(
126
+ /bulk action "open" points at rowAction "open" of kind "navigate"/,
127
+ );
128
+ });
129
+
130
+ test("a pinned column needs table.pinning", () => {
131
+ expect(() => validateBoot([featureWithBlock({ columnPicker: true, resizing: true })])).toThrow(
132
+ /column "title" is pinned but table\.pinning is not enabled/,
133
+ );
134
+ });
135
+
136
+ test("width and minWidth need table.resizing", () => {
137
+ expect(() => validateBoot([featureWithBlock({ columnPicker: true, pinning: true })])).toThrow(
138
+ /column "title" sets width but table\.resizing is not enabled/,
139
+ );
140
+ });
141
+
142
+ test("a misspelled table key is rejected with the allowed keys", () => {
143
+ expect(() => validateBoot([featureWithBlock({ rememberStatee: true })])).toThrow(
144
+ /unknown key\(s\) "rememberStatee"\. Allowed keys: rememberState, resetAll, columnPicker, density, pinning, resizing, keyboardNavigation, export/,
145
+ );
146
+ });
147
+
148
+ test("a hidden column needs the column picker", () => {
149
+ expect(() => validateBoot([featureWithBlock({ rememberState: true, resetAll: true })])).toThrow(
150
+ /column "estimateHours" is hidden but table\.columnPicker is not enabled/,
151
+ );
152
+ });
153
+
154
+ test("a density outside the union is rejected", () => {
155
+ expect(() =>
156
+ validateBoot([featureWithBlock({ columnPicker: true, density: { default: "tight" } })]),
157
+ ).toThrow(/table\.density\.default is "tight"/);
158
+ });
159
+
160
+ test("an export row limit above the hard cap is rejected", () => {
161
+ expect(() =>
162
+ validateBoot([
163
+ featureWithBlock({ columnPicker: true, export: { xlsx: true, maxRows: 100001 } }),
164
+ ]),
165
+ ).toThrow(/table\.export\.maxRows is 100001 .* at most 100000/);
166
+ });
167
+ });
168
+ ```
169
+
170
+ ### src/__tests__/workload.integration.test.ts
171
+
172
+ ```ts
173
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
174
+ import { createTestUser, testTenantId } from "@cosmicdrift/kumiko-framework/seeding";
175
+ import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-testing/stack";
176
+ import { createTaskBoardFeature } from "../feature";
177
+
178
+ type ListAggregateResult = {
179
+ readonly groups: readonly { key: unknown; count: number; measures: readonly unknown[] }[];
180
+ };
181
+
182
+ let stack: TestStack;
183
+ const user = createTestUser({ roles: ["User"], tenantId: testTenantId(1) });
184
+
185
+ const TASKS = [
186
+ { title: "Quarterly VAT filing", assignee: "Ines", estimateHours: 6 },
187
+ { title: "Prepare audit", assignee: "Ines", estimateHours: 24 },
188
+ { title: "Plan offsite", assignee: "Mara", estimateHours: 7 },
189
+ ] as const;
190
+
191
+ beforeAll(async () => {
192
+ stack = await setupTestStack({ features: [createTaskBoardFeature()], systemHooks: [] });
193
+ for (const task of TASKS) {
194
+ await stack.http.writeOk("task-board:write:task:create", task, user);
195
+ }
196
+ });
197
+
198
+ afterAll(async () => {
199
+ await stack.cleanup();
200
+ });
201
+
202
+ describe("hand-written list query with aggregate", () => {
203
+ test("groups by assignee with the server count and estimate sum", async () => {
204
+ const result = await stack.http.queryOk<ListAggregateResult>(
205
+ "task-board:query:task:workload",
206
+ { aggregate: { groupBy: "assignee", measures: [{ field: "estimateHours", kind: "sum" }] } },
207
+ user,
208
+ );
209
+ expect(result.groups.map(({ key, count, measures }) => ({ key, count, measures }))).toEqual([
210
+ { key: "Ines", count: 2, measures: [30] },
211
+ { key: "Mara", count: 1, measures: [7] },
212
+ ]);
213
+ });
214
+
215
+ test("the totals answer follows the list filter", async () => {
216
+ const result = await stack.http.queryOk<ListAggregateResult>(
217
+ "task-board:query:task:workload",
218
+ {
219
+ filters: [{ field: "estimateHours", op: "lte", value: 7 }],
220
+ aggregate: { measures: [{ field: "estimateHours", kind: "sum" }] },
221
+ },
222
+ user,
223
+ );
224
+ expect(result.groups.map((group) => group.measures)).toEqual([[13]]);
225
+ });
226
+
227
+ test("without aggregate the same query lists rows", async () => {
228
+ const list = await stack.http.queryOk<{ rows: { title: string }[] }>(
229
+ "task-board:query:task:workload",
230
+ { sort: "title" },
231
+ user,
232
+ );
233
+ expect(list.rows.map((row) => row.title)).toEqual([
234
+ "Plan offsite",
235
+ "Prepare audit",
236
+ "Quarterly VAT filing",
237
+ ]);
238
+ });
239
+ });
240
+ ```
241
+
242
+ ### src/feature.ts
243
+
244
+ ```ts
245
+ import {
246
+ answerListAggregate,
247
+ buildEntityTable,
248
+ createEventStoreExecutor,
249
+ } from "@cosmicdrift/kumiko-framework/db";
250
+ import {
251
+ createDateField,
252
+ createEntity,
253
+ createNumberField,
254
+ createSelectField,
255
+ createTextField,
256
+ defineFeature,
257
+ entityListSchema,
258
+ type FeatureDefinition,
259
+ } from "@cosmicdrift/kumiko-framework/engine";
260
+ import type { ListTableOptions } from "@cosmicdrift/kumiko-framework/ui-types";
261
+ import { z } from "zod";
262
+
263
+ export const taskEntity = createEntity({
264
+ table: "read_list_table_tasks",
265
+ fields: {
266
+ title: createTextField({
267
+ personal: false,
268
+ reason: "is_business_data",
269
+ required: true,
270
+ searchable: true,
271
+ sortable: true,
272
+ }),
273
+ status: createSelectField({
274
+ options: ["open", "inProgress", "blocked", "done"] as const,
275
+ default: "open",
276
+ sortable: true,
277
+ filterable: true,
278
+ }),
279
+ priority: createSelectField({
280
+ options: ["low", "medium", "high"] as const,
281
+ default: "medium",
282
+ sortable: true,
283
+ filterable: true,
284
+ }),
285
+ dueDate: createDateField({ sortable: true }),
286
+ assignee: createTextField({ personal: false, reason: "is_business_data", sortable: true }),
287
+ estimateHours: createNumberField({ sortable: true, filterable: true }),
288
+ },
289
+ });
290
+
291
+ export const openAccess = {
292
+ access: {
293
+ openToAll: { reason: "recipe: any signed-in user manages the tasks; no per-user ownership" },
294
+ },
295
+ } as const;
296
+
297
+ // Overridable so a test can feed the boot validator a block that must be rejected.
298
+ export function createTaskBoardFeature(
299
+ taskTable: ListTableOptions = {
300
+ rememberState: true,
301
+ resetAll: true,
302
+ columnPicker: true,
303
+ density: { default: "comfortable", userChoice: true },
304
+ pinning: true,
305
+ resizing: true,
306
+ keyboardNavigation: true,
307
+ export: { xlsx: true, maxRows: 5000 },
308
+ grouping: { field: "status", collapsedWhen: "done", userChoice: true, fields: ["priority"] },
309
+ totals: [{ field: "estimateHours", kind: "sum" }],
310
+ },
311
+ ): FeatureDefinition {
312
+ return defineFeature("task-board", (r) => {
313
+ r.crud("task", taskEntity, { write: openAccess, read: openAccess });
314
+
315
+ r.screen({
316
+ id: "task-list",
317
+ type: "entityList",
318
+ entity: "task",
319
+ columns: [
320
+ // Starts pinned left; width and minWidth are start values for resizing.
321
+ { field: "title", pinned: "left", width: 280, minWidth: 160 },
322
+ "status",
323
+ "priority",
324
+ { field: "dueDate", hideOnNarrow: true },
325
+ { field: "assignee", hideOnNarrow: true },
326
+ // Starts hidden; the user can add it back in the view menu.
327
+ { field: "estimateHours", hidden: true, hideOnNarrow: true },
328
+ ],
329
+ facets: { status: { display: "chips" }, priority: {}, estimateHours: { display: "range" } },
330
+ defaultSort: { field: "dueDate", dir: "asc" },
331
+ searchable: true,
332
+ table: taskTable,
333
+ rowActions: [
334
+ { kind: "navigate", id: "open", label: "Open", screen: "task-edit", rowClick: true },
335
+ {
336
+ kind: "writeHandler",
337
+ id: "delete",
338
+ label: "Delete",
339
+ handler: "task-board:write:task:delete",
340
+ style: "danger",
341
+ // Done tasks are skipped by the bulk run and counted as skipped.
342
+ visible: { field: "status", ne: "done" },
343
+ },
344
+ ],
345
+ // Row checkboxes and a selection bar; each selected row gets its own delete write.
346
+ bulkActions: [{ id: "delete", rowAction: "delete" }],
347
+ access: openAccess.access,
348
+ });
349
+
350
+ r.screen({
351
+ id: "task-edit",
352
+ type: "entityEdit",
353
+ entity: "task",
354
+ dormant: true,
355
+ listScreenId: "task-list",
356
+ layout: {
357
+ sections: [
358
+ {
359
+ columns: 2,
360
+ fields: ["title", "assignee", "status", "priority", "dueDate", "estimateHours"],
361
+ },
362
+ ],
363
+ },
364
+ });
365
+
366
+ // The same block works on a list over any query.
367
+ r.queryHandler({
368
+ name: "task:overdue",
369
+ schema: z.object({}),
370
+ handler: async () => ({
371
+ rows: [{ id: "t1", title: "Quarterly VAT filing", assignee: "Ines", daysLate: 3 }],
372
+ nextCursor: null,
373
+ }),
374
+ access: openAccess.access,
375
+ outputSchema: z.object({
376
+ rows: z.array(
377
+ z.object({
378
+ id: z.string(),
379
+ title: z.string(),
380
+ assignee: z.string(),
381
+ daysLate: z.number(),
382
+ }),
383
+ ),
384
+ nextCursor: z.string().nullable(),
385
+ }),
386
+ });
387
+
388
+ r.screen({
389
+ id: "task-overdue",
390
+ type: "projectionList",
391
+ query: "task-board:query:task:overdue",
392
+ columns: ["title", "assignee", "daysLate"],
393
+ table: { rememberState: true, density: { userChoice: true } },
394
+ });
395
+
396
+ // A hand-written list query keeps grouping and totals when it accepts
397
+ // `aggregate` (entityListSchema carries it) and answers it on the server.
398
+ const taskExecutor = createEventStoreExecutor(
399
+ buildEntityTable("task", taskEntity),
400
+ taskEntity,
401
+ {
402
+ entityName: "task",
403
+ },
404
+ );
405
+ r.queryHandler({
406
+ name: "task:workload",
407
+ schema: entityListSchema,
408
+ handler: async (query, ctx) => {
409
+ const parentVisibility = { entities: ctx.registry.getAllEntities() };
410
+ const listPayload = query.payload;
411
+ if (listPayload.aggregate !== undefined) {
412
+ return answerListAggregate(
413
+ taskExecutor,
414
+ { ...listPayload, aggregate: listPayload.aggregate },
415
+ query.user,
416
+ ctx.db,
417
+ { parentVisibility },
418
+ {
419
+ entity: taskEntity,
420
+ resolveEntity: (name) => ctx.registry.getEntity(name),
421
+ viewer: { user: query.user, parentVisibility },
422
+ },
423
+ );
424
+ }
425
+ return taskExecutor.list(listPayload, query.user, ctx.db, { parentVisibility });
426
+ },
427
+ access: openAccess.access,
428
+ });
429
+
430
+ r.screen({
431
+ id: "task-workload",
432
+ type: "projectionList",
433
+ query: "task-board:query:task:workload",
434
+ columns: ["title", "assignee", "status", "estimateHours"],
435
+ defaultSort: { field: "title", dir: "asc" },
436
+ table: {
437
+ grouping: { field: "assignee" },
438
+ totals: [{ field: "estimateHours", kind: "sum" }],
439
+ },
440
+ });
441
+
442
+ r.nav({ id: "tasks", label: "task-board:nav.tasks", icon: "list", order: 10 });
443
+ r.nav({
444
+ id: "task-list",
445
+ label: "task-board:nav.list",
446
+ parent: "task-board:nav:tasks",
447
+ screen: "task-board:screen:task-list",
448
+ order: 10,
449
+ });
450
+ r.nav({
451
+ id: "task-overdue",
452
+ label: "task-board:nav.overdue",
453
+ parent: "task-board:nav:tasks",
454
+ screen: "task-board:screen:task-overdue",
455
+ order: 20,
456
+ });
457
+ r.nav({
458
+ id: "task-workload",
459
+ label: "task-board:nav.workload",
460
+ parent: "task-board:nav:tasks",
461
+ screen: "task-board:screen:task-workload",
462
+ order: 30,
463
+ });
464
+ });
465
+ }
466
+ ```
@@ -42,6 +42,7 @@ bun test
42
42
  // and its text contains exactly the inspections of the requested period.
43
43
 
44
44
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
45
+ import { MAX_LIST_LIMIT } from "@cosmicdrift/kumiko-framework/engine";
45
46
  import {
46
47
  createFilesFeature,
47
48
  createInMemoryFileProvider,
@@ -150,7 +151,7 @@ describe("export-audit", () => {
150
151
 
151
152
  test("a period with more inspections than the export holds is rejected instead of cut off", async () => {
152
153
  const assetName = "Großanlage";
153
- for (let day = 0; day < 101; day += 1) {
154
+ for (let day = 0; day < MAX_LIST_LIMIT; day += 1) {
154
155
  const inspectedOn = new Date(Date.UTC(2025, 0, 1 + day)).toISOString().slice(0, 10);
155
156
  await addInspection(inspectedOn, "ok", `Prüfer-${day}`, assetName);
156
157
  }
@@ -185,6 +186,7 @@ import {
185
186
  createTextField,
186
187
  defineFeature,
187
188
  defineWriteHandler,
189
+ MAX_LIST_LIMIT,
188
190
  } from "@cosmicdrift/kumiko-framework/engine";
189
191
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
190
192
  import {
@@ -221,7 +223,7 @@ function toFileNameSegment(text: string): string {
221
223
  }
222
224
 
223
225
  // One row above the limit tells "exactly full" from "cut off"; entity lists cap at 200 rows.
224
- const AUDIT_ROW_LIMIT = 100;
226
+ const AUDIT_ROW_LIMIT = MAX_LIST_LIMIT - 1;
225
227
 
226
228
  const access = { access: { roles: ["Admin", "User"] } } as const;
227
229
 
@@ -57,6 +57,22 @@ bun test samples/recipes/prod-boot-options
57
57
 
58
58
  ## Source files
59
59
 
60
+ ### bin/server.ts
61
+
62
+ ```ts
63
+ import { composeEnvSchema } from "@cosmicdrift/kumiko-framework/env";
64
+ import { runProdApp } from "@cosmicdrift/kumiko-server-runtime";
65
+ import { appFeatures, bootOptions } from "../src/app";
66
+
67
+ const features = appFeatures();
68
+
69
+ await runProdApp({
70
+ features,
71
+ envSchema: composeEnvSchema({ features }),
72
+ ...bootOptions(process.env),
73
+ });
74
+ ```
75
+
60
76
  ### src/__tests__/boot-options.test.ts
61
77
 
62
78
  ```ts
@@ -36,6 +36,24 @@ exhausted) and no bucket is charged. Each `per` may appear once; an empty list f
36
36
  An anonymous handler needs every bucket to be ip-keyed, and `{ disabled: true, reason }`
37
37
  stays a single declaration.
38
38
 
39
+ ## Cost per payload element
40
+
41
+ A bulk handler books one token per item instead of one per request. Declare `rateLimitCost`
42
+ next to `rateLimit`:
43
+
44
+ ```ts illustration
45
+ r.writeHandler({
46
+ // ...
47
+ rateLimit: { per: "tenant+handler", limit: 20, windowSeconds: 600 },
48
+ rateLimitCost: (payload) => payload.files.length,
49
+ });
50
+ ```
51
+
52
+ The cost is computed from the validated payload and booked on every declared bucket, so an
53
+ invalid payload costs nothing and a rejected request consumes nothing. Results below 1 count
54
+ as 1. Because it books after the parse, boot rejects `rateLimitCost` on anonymous or ip-limited
55
+ handlers.
56
+
39
57
  ## Per-payload limits
40
58
 
41
59
  A public write handler that mails an address typed by the caller is also limited per
@@ -229,6 +247,24 @@ describe("L2 — auth-endpoints middleware (tighter cap on /api/auth/*)", () =>
229
247
  expect(body.error.details.bucket).toBe("l2");
230
248
  });
231
249
  });
250
+
251
+ describe("L3 — cost per payload element (bulk-upload)", () => {
252
+ const bulkUpload = "rl-showcase:write:bulk-upload";
253
+ const files = (count: number): { files: string[] } => ({
254
+ files: Array.from({ length: count }, (_, index) => `file-${index}.pdf`),
255
+ });
256
+
257
+ test("a request books one token per file; a request over the remaining budget is rejected", async () => {
258
+ expect((await stack.http.write(bulkUpload, files(10), admin)).status).toBe(200);
259
+ expect((await stack.http.write(bulkUpload, files(15), admin)).status).toBe(429);
260
+ // The rejected request consumed nothing: 10 more still fit.
261
+ expect((await stack.http.write(bulkUpload, files(10), admin)).status).toBe(200);
262
+ const blocked = await stack.http.write(bulkUpload, files(1), admin);
263
+ expect(blocked.status).toBe(429);
264
+ const body = (await blocked.json()) as { error: { code: string } };
265
+ expect(body.error.code).toBe("rate_limited");
266
+ });
267
+ });
232
268
  ```
233
269
 
234
270
  ### src/feature.ts
@@ -269,6 +305,18 @@ export function createRateLimitShowcaseFeature(): FeatureDefinition {
269
305
  { per: "tenant+handler", limit: 3, windowSeconds: 3600 },
270
306
  ],
271
307
  });
308
+
309
+ // Bulk upload: a request books one token per item instead of one per request. The cost
310
+ // comes from the validated payload, so a schema-invalid request costs nothing; a request
311
+ // that would exceed the remaining budget is rejected and consumes nothing.
312
+ r.writeHandler({
313
+ name: "bulk-upload",
314
+ schema: z.object({ files: z.array(z.string()).min(1).max(20) }),
315
+ handler: async () => ({ isSuccess: true as const, data: {} }),
316
+ access: { roles: ["Admin", "User"] },
317
+ rateLimit: { per: "tenant+handler", limit: 20, windowSeconds: 600 },
318
+ rateLimitCost: (payload) => payload.files.length,
319
+ });
272
320
  });
273
321
  }
274
322
  ```