@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
@@ -1,455 +1,23 @@
1
- import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
- import { escapeLikePattern } from "../crypto/ciphertext-pattern.js";
3
- import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/index.js";
4
1
  import { executeRawQueryRead } from "../db/queries/raw-sql.js";
5
- import { coerceRow, extractTableInfo } from "../db/query.js";
6
- import { buildOwnershipClause, combineClauses, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
7
- import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
8
- import { UnprocessableError, ValidationError } from "../errors/index.js";
2
+ import { extractTableInfo } from "../db/table-info.js";
9
3
  import { getStreamVersion } from "../event-store/index.js";
10
- import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta.js";
11
- import { rehydrateCompoundTypes } from "./compound-types.js";
12
- import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
13
- import { hasSearchablePlaintext, isListFilterAllowed, isListSortAllowed, isSensitiveLabelField, } from "./entity-field-encryption.js";
14
- import { buildFilterWhere, } from "./event-store-executor-context.js";
15
- import { buildParentRefClause } from "./parent-ref-clause.js";
16
- import { buildEntityTable, physicalColumnName } from "./table-builder.js";
4
+ import { aggregateList } from "./event-store-executor-read-aggregate.js";
5
+ import { appendCursorBoundary, countListRows, encodeNextCursor, resolveListPagination, } from "./event-store-executor-read-paging.js";
6
+ import { appendStreamVersionProjection, coerceAndRehydrateRow, takeStreamVersions, withStreamVersion, } from "./event-store-executor-read-rows.js";
7
+ import { renderListOrderBy, resolveListSort, takeLastSortLabel, } from "./event-store-executor-read-sort.js";
8
+ import { buildListWhere, resolveReadOwnership } from "./event-store-executor-read-where.js";
17
9
  import { schemaTableName } from "./table-introspection.js";
18
10
  import { tenantDbRunner } from "./tenant-db-runner.js";
19
- // The two read verbs (list/detail) of the event-store-executor. Split out
11
+ // The read verbs (list/aggregate/detail) of the event-store-executor. Split out
20
12
  // of event-store-executor.ts — behavior-preserving relocation, not a redesign:
21
13
  // unchanged from the original, now behind an explicit ExecutorContext instead
22
- // of the factory's local scope.
23
- // Defense-in-depth pagination guard. The handler boundary (entityListSchema)
24
- // already validates limit/offset, but the executor is public API for custom
25
- // handlers that pass their payload straight through — a non-integer limit
26
- // would otherwise be interpolated raw into `LIMIT ${limit}` SQL text.
27
- const MAX_LIST_LIMIT = 200; // keep in sync with engine/entity-handlers.ts MAX_LIST_LIMIT
28
- export function resolveListPagination(payload) {
29
- const limit = payload.limit ?? 50;
30
- const offset = payload.offset ?? 0;
31
- if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 0) {
32
- throw new UnprocessableError("invalid_list_limit", {
33
- details: { hint: "limit must be a non-negative integer" },
34
- });
35
- }
36
- if (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0) {
37
- throw new UnprocessableError("invalid_list_offset", {
38
- details: { hint: "offset must be a non-negative integer" },
39
- });
40
- }
41
- return { limit: Math.min(limit, MAX_LIST_LIMIT), offset };
42
- }
43
- // Cursor sort values travel as text and bind as plain string params — Postgres
44
- // infers the parameter type from the column they are compared against, the same
45
- // way prepareValue binds a timestamptz as an ISO string. undefined means the
46
- // driver value has no faithful text form, which downgrades the page to the
47
- // legacy id-only boundary instead of emitting a wrong one.
48
- function toCursorSortText(value) {
49
- if (value === undefined)
50
- return undefined;
51
- if (value === null)
52
- return null;
53
- if (typeof value === "string")
54
- return value;
55
- if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
56
- return String(value);
57
- }
58
- if (value instanceof Date)
59
- return value.toISOString();
60
- if (typeof value === "object") {
61
- const tag = Reflect.get(value, Symbol.toStringTag);
62
- if (typeof tag === "string" && tag.startsWith("Temporal."))
63
- return String(value);
64
- return JSON.stringify(value);
65
- }
66
- return undefined;
67
- }
68
- function withStreamVersion(row, streamVersion) {
69
- return streamVersion > 0 ? { ...row, version: streamVersion } : row;
70
- }
71
- // Keyset boundary for `ORDER BY <sort> <dir>, id ASC` under Postgres' DEFAULT
72
- // null ordering (ASC → NULLS LAST, DESC → NULLS FIRST). An explicit NULLS clause
73
- // would change the visible order and cost the sort its index.
74
- function keysetBoundarySql(sortCol, idCol, cursor, descending, params) {
75
- params.push(cursor.id);
76
- const afterId = `${idCol} > $${params.length}`;
77
- const nullsComeFirst = descending;
78
- if (cursor.sortValue === null) {
79
- return nullsComeFirst
80
- ? `(${sortCol} IS NOT NULL OR ${afterId})`
81
- : `(${sortCol} IS NULL AND ${afterId})`;
82
- }
83
- params.push(cursor.sortValue);
84
- const sortParam = `$${params.length}`;
85
- const beyond = `${sortCol} ${descending ? "<" : ">"} ${sortParam}`;
86
- const tie = `(${sortCol} = ${sortParam} AND ${afterId})`;
87
- return nullsComeFirst
88
- ? `(${sortCol} IS NOT NULL AND (${beyond} OR ${tie}))`
89
- : `(${sortCol} IS NULL OR ${beyond} OR ${tie})`;
90
- }
91
- // multiSelect stores its options as a jsonb array — a filter value is one
92
- // option, not the whole array, so eq/ne/in must check array containment
93
- // (`@>`) instead of scalar `=`/`<>`/`IN` against the jsonb column.
94
- // lt/gt/lte/gte have no containment analogue; the boot-validator already
95
- // blocks them for screen-declared filters (screen-filter-ops.ts
96
- // EQUALITY_ONLY), but a client-supplied facet filter reaches here
97
- // unvalidated, so treat it as unsatisfiable rather than emitting SQL
98
- // Postgres would reject.
99
- function applyMultiSelectFilter(colSql, whereSql, params, f) {
100
- if (f.op === "lt" || f.op === "gt" || f.op === "lte" || f.op === "gte") {
101
- whereSql.push("FALSE");
102
- // skip: lt/gt/lte/gte is unsatisfiable on a multiSelect column
103
- return;
104
- }
105
- if (f.op === "in") {
106
- if (!Array.isArray(f.value) || f.value.length === 0) {
107
- whereSql.push("FALSE");
108
- // skip: empty/non-array `in` is unsatisfiable, mirrors buildFilterWhere's "in" short-circuit
109
- return;
110
- }
111
- const parts = f.value.map((v) => {
112
- params.push(JSON.stringify([v]));
113
- return `${colSql(f.field)} @> $${params.length}::text::jsonb`;
114
- });
115
- whereSql.push(`(${parts.join(" OR ")})`);
116
- // skip: containment condition already pushed — don't fall through to the eq/ne branch below
117
- return;
118
- }
119
- const candidates = Array.isArray(f.value) ? f.value : [f.value];
120
- // `@> '[]'` is true for every non-null row, so an empty array would turn
121
- // the filter into a silent match-all.
122
- if (candidates.length === 0) {
123
- whereSql.push("FALSE");
124
- // skip: empty candidate list is unsatisfiable
125
- return;
126
- }
127
- // Bound as text then cast: a JS array makes postgres.js infer boolean[]/int4[]
128
- // for client-supplied non-string values, and `boolean[]::jsonb` is rejected
129
- // (500 on client input). An array value means "contains all of these".
130
- params.push(JSON.stringify(candidates));
131
- const containment = `${colSql(f.field)} @> $${params.length}::text::jsonb`;
132
- // A NULL column must count as "does not contain" — plain NOT(...) yields
133
- // NULL and would drop those rows.
134
- whereSql.push(f.op === "ne" ? `(${colSql(f.field)} IS NULL OR NOT (${containment}))` : containment);
135
- }
136
- // Falls through to the screen-filter WHERE builder shared with
137
- // screen-declared filters — buildFilterWhere flattens the op into a
138
- // WhereObject, which this then lowers to raw SQL fragments.
139
- function applyScreenFilter(table, colSql, whereSql, params, f) {
140
- const screen = buildFilterWhere(f.field, f.op, f.value);
141
- if (screen === null) {
142
- whereSql.push("FALSE");
143
- // skip: filter is unsatisfiable → emit FALSE, no params to bind
144
- return;
145
- }
146
- for (const [field, value] of Object.entries(screen)) {
147
- // `x <> NULL` is never true — mirror buildWhereClause's IS [NOT] NULL handling in db/query.ts.
148
- if (value === null) {
149
- whereSql.push(`${colSql(field)} IS NULL`);
150
- }
151
- else if (Array.isArray(value)) {
152
- const placeholders = value.map((v) => {
153
- params.push(v);
154
- return `$${params.length}`;
155
- });
156
- whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
157
- }
158
- else if (isPlainObject(value)) {
159
- if (value["ne"] === null && Object.keys(value).length === 1) {
160
- whereSql.push(`${colSql(field)} IS NOT NULL`);
161
- continue;
162
- }
163
- const opMap = {
164
- gt: ">",
165
- gte: ">=",
166
- lt: "<",
167
- lte: "<=",
168
- ne: "<>",
169
- };
170
- for (const [opKey, opSym] of Object.entries(opMap)) {
171
- if (!(opKey in value))
172
- continue;
173
- params.push(value[opKey]);
174
- whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
175
- }
176
- }
177
- else {
178
- // Blind-index OR rewrite, lock-step with buildWhereClause
179
- // in db/query.ts — equality on lookupable fields matches the
180
- // plaintext arm OR the HMAC arm.
181
- const bidxKey = configuredBlindIndexKey();
182
- if (bidxKey !== undefined && typeof value === "string" && table[`${field}Bidx`]) {
183
- params.push(value, computeBlindIndex(bidxKey, value));
184
- whereSql.push(`(${colSql(field)} = $${params.length - 1} OR ${colSql(`${field}Bidx`)} = $${params.length})`);
185
- }
186
- else {
187
- params.push(value);
188
- whereSql.push(`${colSql(field)} = $${params.length}`);
189
- }
190
- }
191
- }
192
- }
193
- // A `filter`/`filters` entry that already pins the parentRef's entityType
194
- // field is a real subset of the parent-ref clause's own OR-branches — the
195
- // client-supplied filter can't widen the result, only narrow which branches
196
- // the SQL even needs to consider. Skipped for multiSelect fields, where `eq`
197
- // means containment (see applyMultiSelectFilter) rather than equality.
198
- function deriveParentNarrowTypes(entity, payload) {
199
- const parentRef = entity.parentRef;
200
- if (parentRef === undefined)
201
- return undefined;
202
- if (entity.fields[parentRef.entityTypeField]?.type === "multiSelect")
203
- return undefined;
204
- const typeFilters = [
205
- ...(payload.filter ? [payload.filter] : []),
206
- ...(payload.filters ?? []),
207
- ].filter((f) => f.field === parentRef.entityTypeField);
208
- let narrowed;
209
- for (const f of typeFilters) {
210
- let values;
211
- if (f.op === "eq" && typeof f.value === "string") {
212
- values = [f.value];
213
- }
214
- else if (f.op === "in" &&
215
- Array.isArray(f.value) &&
216
- f.value.every((v) => typeof v === "string")) {
217
- values = f.value;
218
- }
219
- if (values === undefined)
220
- continue;
221
- narrowed =
222
- narrowed === undefined
223
- ? new Set(values)
224
- : new Set([...narrowed].filter((v) => values.includes(v)));
225
- }
226
- return narrowed;
227
- }
228
- // Above this many target-row matches, drop the reference clause
229
- // entirely instead of truncating: an arbitrary 200-row slice of "which
230
- // targets match" would silently hide rows a full-cardinality match would
231
- // have found. Native text search on the entity's own fields still applies.
232
- const MAX_REFERENCE_SEARCH_IDS = 200;
233
- // The hit ids become an `id IN (...)` filter beneath the SQL cursor/filters/sort, so the
234
- // adapter must return the whole candidate set, not a page; matches Meilisearch's default
235
- // maxTotalHits, which caps a response at exactly this many hits however high a larger limit is
236
- // asked. A response that reaches the cap is therefore flagged `searchTruncated` rather than
237
- // treated as complete (a cap of N+1 could never be observed).
238
- const MAX_SEARCH_CANDIDATE_IDS = 1000;
239
- // Registry-declared `searchable` reference fields, plus the implicit
240
- // tenantId→tenant.name row-meta reference — both resolve a text
241
- // search term against a target row's label instead of the raw FK column.
242
- function collectReferenceSearchDescriptors(table, referenceSearch, dbMode) {
243
- if (!referenceSearch)
244
- return [];
245
- const descriptors = referenceSearch.fields.map((f) => ({
246
- ownColumn: f.fieldName,
247
- targetEntityName: f.targetEntityName,
248
- labelField: f.labelField,
249
- }));
250
- for (const [ownColumn, ref] of Object.entries(LIST_ROW_META_REFERENCES)) {
251
- if (table[ownColumn] === undefined)
252
- continue;
253
- // In "tenant" mode the outer tenant filter already pins tenantId to one
254
- // of exactly two values, so a by-name lookup here can never narrow that further.
255
- if (ownColumn === "tenantId" && dbMode === "tenant")
256
- continue;
257
- descriptors.push({ ownColumn, targetEntityName: ref.refEntity, labelField: ref.refLabelField });
258
- }
259
- return descriptors;
260
- }
261
- // Matches the main list's own default: soft-deleted rows are invisible, so a
262
- // deleted target must neither match a search nor influence an ordering.
263
- function isSoftDeletedTargetHidden(targetEntity, targetTable) {
264
- return targetEntity.softDelete === true && targetTable["isDeleted"] !== undefined;
265
- }
266
- // Tenant-scoped ILIKE lookup against one reference target — never raw/
267
- // unscoped SQL, a match in a foreign tenant must never surface a row here.
268
- // Mirrors the main table's own tenant filter (see the `table["tenantId"]`
269
- // check further down in list()).
270
- async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db) {
271
- // The label must not leak through match counts: a viewer who cannot read the
272
- // target row or its label field would otherwise probe names by searching.
273
- const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
274
- if (labelAccess.kind !== "pass")
275
- return undefined;
276
- const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
277
- if (ownership.kind === "empty")
278
- return undefined;
279
- const labelCol = physicalColumnName(targetTable, descriptor.labelField);
280
- const subParams = [`%${escapeLikePattern(searchTerm)}%`];
281
- let tenantClause = "";
282
- if (targetTable["tenantId"] !== undefined && db.mode === "tenant") {
283
- subParams.push(db.tenantId, SYSTEM_TENANT_ID);
284
- tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($2, $3)`;
285
- }
286
- let ownershipClause = "";
287
- if (ownership.kind === "sql") {
288
- const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, subParams.length);
289
- ownershipClause = ` AND ${shifted.sqlText}`;
290
- for (const p of shifted.params)
291
- subParams.push(p);
292
- }
293
- const deletedClause = isSoftDeletedTargetHidden(targetEntity, targetTable)
294
- ? ` AND "${physicalColumnName(targetTable, "isDeleted")}" = FALSE`
295
- : "";
296
- // ::text cast covers a non-text labelField (e.g. a number/select column
297
- // used as label) — Postgres has no ILIKE for those types otherwise.
298
- const sql = `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause}${ownershipClause}${deletedClause} ` +
299
- `LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
300
- const rows = await executeRawQueryRead(tenantDbRunner(db), sql, subParams);
301
- if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
302
- // skip: no match, or over the cap — drop the clause rather than
303
- // truncate to an arbitrary slice of matching targets
304
- return undefined;
305
- }
306
- return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
307
- }
308
- // Same lookup, but the target's index is tenant-wide, not per-viewer, so
309
- // candidate ids get re-checked against the target entity's own read access.
310
- async function resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, searchAdapter) {
311
- const searchTenantId = targetEntity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
312
- const results = await searchAdapter.search(searchTenantId, searchTerm, {
313
- filterType: descriptor.targetEntityName,
314
- limit: MAX_REFERENCE_SEARCH_IDS + 1,
315
- });
316
- if (results.length === 0 || results.length > MAX_REFERENCE_SEARCH_IDS) {
317
- // skip: no match, or over the cap — same drop-the-clause policy as the ILIKE path
318
- return undefined;
319
- }
320
- const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
321
- if (ownership.kind === "empty")
322
- return undefined;
323
- const candidateIds = results.map((r) => String(r.entityId));
324
- // The index is only trusted to name candidates: a stale or foreign-tenant document must
325
- // never become a filter value, so tenant-scoped targets are always re-read from the DB.
326
- const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
327
- if (ownership.kind === "pass" && !tenantScoped) {
328
- return { ownColumn: descriptor.ownColumn, ids: candidateIds };
329
- }
330
- const params = [...candidateIds];
331
- const idPlaceholders = candidateIds.map((_, i) => `$${i + 1}`);
332
- let tenantClause = "";
333
- if (tenantScoped) {
334
- params.push(db.tenantId, SYSTEM_TENANT_ID);
335
- tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($${params.length - 1}, $${params.length})`;
336
- }
337
- let ownershipClause = "";
338
- if (ownership.kind === "sql") {
339
- const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, params.length);
340
- for (const p of shifted.params)
341
- params.push(p);
342
- ownershipClause = ` AND ${shifted.sqlText}`;
343
- }
344
- const sql = `SELECT "id" FROM "${targetTableName}" WHERE "id" IN (${idPlaceholders.join(", ")})` +
345
- `${tenantClause}${ownershipClause}`;
346
- const rows = await executeRawQueryRead(tenantDbRunner(db), sql, params);
347
- if (rows.length === 0)
348
- return undefined;
349
- return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
350
- }
351
- async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db, user, searchAdapter) {
352
- const targetEntity = resolveEntity(descriptor.targetEntityName);
353
- if (targetEntity === undefined) {
354
- // skip: unknown target entity — registry inconsistency the boot-validator
355
- // should have caught; fail open (no clause) rather than 500 the request
356
- return undefined;
357
- }
358
- if (targetEntity.fields[descriptor.labelField] === undefined) {
359
- // skip: labelField isn't a real (declared) column on the target entity —
360
- // row-meta references (e.g. tenant.name) always resolve here since
361
- // `name` is a declared field on the tenant entity, not an id/row-meta column
362
- return undefined;
363
- }
364
- const targetTable = referenceTargetTable(descriptor.targetEntityName, targetEntity);
365
- const targetTableName = String(schemaTableName(targetTable));
366
- if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
367
- return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db);
368
- }
369
- if (!hasSearchablePlaintext(targetEntity, descriptor.labelField)) {
370
- // skip: no plaintext anywhere to match — boot rejects this normally,
371
- // this is defense-in-depth for a target entity resolved only at runtime.
372
- return undefined;
373
- }
374
- return resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, searchAdapter);
375
- }
376
- // Building the table is pure but not free, and runs on every search request;
377
- // keyed by the definition object so a re-registered entity never sees a stale table.
378
- const referenceTargetTables = new WeakMap();
379
- function referenceTargetTable(name, entity) {
380
- const cached = referenceTargetTables.get(entity);
381
- if (cached?.name === name)
382
- return cached.table;
383
- const table = buildEntityTable(name, entity);
384
- referenceTargetTables.set(entity, { name, table });
385
- return table;
386
- }
387
- // Projected alongside the row when a reference sort is active, so the keyset
388
- // cursor carries the label that was ordered by instead of the FK's UUID.
389
- const SORT_LABEL_ALIAS = "__kumiko_sort_label";
390
- const STREAM_VERSION_ALIAS = "__streamVersion";
391
- // Resolve a `sortable: true` reference field's label inline for
392
- // ORDER BY. A correlated scalar subquery, not a join: the result set keeps one
393
- // row per entity row, the read model stays untouched, and the lookup rides the
394
- // target's primary key. Tenant-scoped exactly like the search path — a foreign
395
- // tenant's label must never influence this tenant's ordering.
396
- // undefined means "cannot order by this label", which drops the sort to plain
397
- // id order; falling back to the UUID column would look deliberate to the user
398
- // while being arbitrary, which is worse than no sorting.
399
- function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolveEntity, db, user) {
400
- const targetEntity = resolveEntity(descriptor.targetEntityName);
401
- if (targetEntity === undefined) {
402
- // skip: unknown target entity — registry inconsistency the boot-validator
403
- // should have caught; drop the sort rather than 500 the request
404
- return undefined;
405
- }
406
- if (targetEntity.fields[descriptor.labelField] === undefined) {
407
- // skip: labelField isn't a real (declared) column on the target entity
408
- return undefined;
409
- }
410
- if (isSensitiveLabelField(targetEntity, descriptor.labelField)) {
411
- // skip: encrypted/PII labelField — ciphertext orders by its bytes, which is
412
- // arbitrary to the reader and would also rank rows by their stored secret
413
- return undefined;
414
- }
415
- const targetTable = buildEntityTable(descriptor.targetEntityName, targetEntity);
416
- // The order of labels the viewer may not read would leak them bit by bit, same as the search path.
417
- const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
418
- if (labelAccess.kind !== "pass")
419
- return undefined;
420
- const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
421
- if (ownership.kind === "empty")
422
- return undefined;
423
- const targetTableName = String(schemaTableName(targetTable));
424
- const labelCol = physicalColumnName(targetTable, descriptor.labelField);
425
- const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
426
- const tenantCol = tenantScoped ? physicalColumnName(targetTable, "tenantId") : "";
427
- const tenantId = db.mode === "tenant" ? db.tenantId : undefined;
428
- const deletedClause = isSoftDeletedTargetHidden(targetEntity, targetTable)
429
- ? ` AND t."${physicalColumnName(targetTable, "isDeleted")}" = FALSE`
430
- : "";
431
- // No ::text cast (unlike the ILIKE search path): a non-text label must keep
432
- // its native collation/numeric order, and the keyset param infers its type
433
- // from the comparison anyway.
434
- return (params) => {
435
- let tenantClause = "";
436
- if (tenantScoped) {
437
- params.push(tenantId, SYSTEM_TENANT_ID);
438
- tenantClause = ` AND t."${tenantCol}" IN ($${params.length - 1}, $${params.length})`;
439
- }
440
- let ownershipClause = "";
441
- if (ownership.kind === "sql") {
442
- const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, params.length);
443
- for (const p of shifted.params)
444
- params.push(p);
445
- ownershipClause = ` AND ${shifted.sqlText}`;
446
- }
447
- return (`(SELECT t."${labelCol}" FROM "${targetTableName}" t ` +
448
- `WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause}${ownershipClause}${deletedClause})`);
449
- };
14
+ // of the factory's local scope. The list steps (filter, search, sort, paging,
15
+ // row post-processing) live in the event-store-executor-read-*.ts neighbours.
16
+ function emptyListPage(totalCount) {
17
+ return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
450
18
  }
451
19
  export function createReadVerbs(ctx) {
452
- const { table, entity, entityName, entityCache, searchAdapter, softDelete, streamTenantFor, idFilter, probeOwnershipWithStreamVersion, loadDetailSnapshot, decryptForRead, encryptForStorage, } = ctx;
20
+ const { table, entity, entityName, entityCache, streamTenantFor, idFilter, probeOwnershipWithStreamVersion, loadDetailSnapshot, decryptForRead, encryptForStorage, } = ctx;
453
21
  return {
454
22
  // list + detail are unchanged from crud-executor — projections are the
455
23
  // read-model and serve these queries directly.
@@ -458,246 +26,38 @@ export function createReadVerbs(ctx) {
458
26
  const { limit, offset } = resolveListPagination(payload);
459
27
  const totalCount = payload.totalCount === true;
460
28
  const tableName = String(schemaTableName(table));
461
- // H.2 — entity-level read ownership, AND-ed with the parent-ref
462
- // read-gate (join-row entities derive their visibility from a host
463
- // row's own read path — see db/parent-ref-clause.ts). Decide before
464
- // touching search or the DB: `empty` means there's no row the user
465
- // could ever see, so skip both paths and return an empty page.
466
- const narrowTypes = deriveParentNarrowTypes(entity, payload);
467
- const parentClause = buildParentRefClause(entity, table, tableName, user, db, runtimeOptions?.parentVisibility, { includeDeleted: runtimeOptions?.includeDeleted === true, narrowTypes });
468
- const ownership = combineClauses(buildOwnershipClause(user, entity.access?.read, table), parentClause);
469
- if (ownership.kind === "empty") {
470
- return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
471
- }
472
- let filterIds;
473
- // Build the WHERE clause as raw SQL — ownership produces a
474
- // parameterised fragment that we splice in alongside simple WhereObject
475
- // conditions (cursor, search-filter-IDs, screen-filter, tenant-scope).
476
- const whereSql = [];
477
- const params = [];
478
- const physicalCol = (field) => physicalColumnName(table, field);
479
- const colSql = (field) => `"${physicalCol(field)}"`;
480
- // Field-level read access gates filtering and sorting the same way it
481
- // gates the projected row: a caller who cannot read a field must not be
482
- // able to probe its values through result counts or ordering.
483
- // Hoisted above the search block — a searchable-reference clause is the
484
- // same probe surface as a filter/sort and must be gated the same way,
485
- // before the reference-match query even runs.
486
- const fieldReadClause = (field) => buildOwnershipClause(user, normalizeAccessEntry(entity.fields[field]?.access?.read), table);
487
- // The build-time options.searchAdapter takes precedence; use the runtime override
488
- // as a fallback because defaultEntityQueryHandler receives the search adapter only
489
- // at runtime, when createEventStoreExecutor has no server context yet.
490
- const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
491
- let searchTruncated = false;
492
- if (payload.search) {
493
- const searchTerm = payload.search;
494
- // A search term with no adapter wired must fail loud, not
495
- // silently return the unfiltered list dressed up as a search result.
496
- if (!effectiveSearchAdapter) {
497
- throw new UnprocessableError("search_adapter_not_wired", {
498
- details: {
499
- entity: entityName,
500
- hint: "Wire a SearchAdapter for this entity, or remove `searchable` from the field/screen.",
501
- },
502
- });
503
- }
504
- // Same choke-point as writes/index: systemStream docs live under
505
- // SYSTEM_TENANT_ID; everything else under the session tenant — db.mode
506
- // must not invent a second predicate.
507
- const searchTenantId = streamTenantFor(user);
508
- const results = await effectiveSearchAdapter.search(searchTenantId, searchTerm, {
509
- filterType: entityName,
510
- limit: MAX_SEARCH_CANDIDATE_IDS,
511
- });
512
- searchTruncated = results.length >= MAX_SEARCH_CANDIDATE_IDS;
513
- filterIds = results.map((r) => r.entityId);
514
- // Union in reference-column label matches: a searchable
515
- // reference field (or the implicit tenantId→tenant.name row-meta
516
- // reference) whose target row's labelField matches the search term
517
- // also counts as a hit on this entity, not just a raw text-field hit.
518
- const referenceSearch = runtimeOptions?.referenceSearch;
519
- // A caller who cannot read the reference column itself must not be able
520
- // to infer its value by matching the target's label — same probe
521
- // surface as filtering/sorting on it directly.
522
- const descriptors = collectReferenceSearchDescriptors(table, referenceSearch, db.mode).filter((d) => fieldReadClause(d.ownColumn).kind === "pass");
523
- const referenceMatches = referenceSearch
524
- ? (await Promise.all(descriptors.map((d) => resolveReferenceMatches(d, searchTerm, referenceSearch.resolveEntity, db, user, effectiveSearchAdapter)))).filter((m) => m !== undefined)
525
- : [];
526
- if (filterIds.length === 0 && referenceMatches.length === 0) {
527
- return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
528
- }
529
- const orParts = [];
530
- if (filterIds.length > 0) {
531
- const placeholders = filterIds.map((id) => {
532
- params.push(id);
533
- return `$${params.length}`;
534
- });
535
- orParts.push(`${colSql("id")} IN (${placeholders.join(", ")})`);
536
- }
537
- for (const match of referenceMatches) {
538
- const placeholders = match.ids.map((id) => {
539
- params.push(id);
540
- return `$${params.length}`;
541
- });
542
- orParts.push(`${colSql(match.ownColumn)} IN (${placeholders.join(", ")})`);
543
- }
544
- // Parenthesized as one whereSql entry — whereSql is joined with
545
- // " AND " below, an unparenthesized OR here would leak past the
546
- // tenant filter that follows.
547
- whereSql.push(`(${orParts.join(" OR ")})`);
548
- }
549
- // A column sort must not 400 (the renderer sends one per header click), and the order of
550
- // ciphertext carries no meaning for the user while leaking the order of the plaintext.
551
- const sortCandidate = payload.sort &&
552
- table[payload.sort] &&
553
- fieldReadClause(payload.sort).kind === "pass" &&
554
- isListSortAllowed(entity, payload.sort)
555
- ? payload.sort
556
- : undefined;
557
- const sortDescending = payload.sortDirection === "desc";
558
- // A reference column stores a UUID, so it is never sorted by
559
- // directly: either the field opted into `sortable` and we order by the
560
- // target row's label, or the sort is dropped to plain id order.
561
- let sortField;
562
- let referenceSortExpr;
563
- if (sortCandidate !== undefined && entity.fields[sortCandidate]?.type === "reference") {
564
- const referenceSort = runtimeOptions?.referenceSort;
565
- const descriptor = referenceSort?.fields.find((f) => f.fieldName === sortCandidate);
566
- referenceSortExpr =
567
- referenceSort && descriptor
568
- ? buildReferenceSortExpr(tableName, colSql(sortCandidate), descriptor, referenceSort.resolveEntity, db, user)
569
- : undefined;
570
- if (referenceSortExpr !== undefined)
571
- sortField = sortCandidate;
572
- }
573
- else {
574
- sortField = sortCandidate;
575
- }
576
- // Tenant-Filter (replicates TenantDb's readWhere semantics).
577
- if (table["tenantId"] !== undefined && db.mode === "tenant") {
578
- params.push(db.tenantId, SYSTEM_TENANT_ID);
579
- whereSql.push(`${colSql("tenantId")} IN ($${params.length - 1}, $${params.length})`);
580
- }
581
- if (softDelete && table["isDeleted"] && runtimeOptions?.includeDeleted !== true) {
582
- whereSql.push(`${colSql("isDeleted")} = FALSE`);
583
- }
584
- if (payload.cursor) {
585
- const cursor = decodeKeysetCursor(payload.cursor);
586
- if (sortField === undefined || cursor.sortValue === undefined) {
587
- params.push(cursor.id);
588
- whereSql.push(`${colSql("id")} > $${params.length}`);
589
- }
590
- else {
591
- whereSql.push(keysetBoundarySql(referenceSortExpr !== undefined ? referenceSortExpr(params) : colSql(sortField), colSql("id"), { id: cursor.id, sortValue: cursor.sortValue }, sortDescending, params));
592
- }
593
- }
594
- if (ownership.kind === "sql") {
595
- const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, params.length);
596
- whereSql.push(shifted.sqlText);
597
- for (const p of shifted.params)
598
- params.push(p);
599
- }
600
- const applyFilter = (f) => {
601
- if (table[f.field] === undefined) {
602
- // skip: unknown field — not a real column, drop the filter (injection guard)
603
- return;
604
- }
605
- const readClause = fieldReadClause(f.field);
606
- if (readClause.kind === "empty") {
607
- whereSql.push("FALSE");
608
- // skip: no role grants read on this field, so no row may be probed
609
- // through it — an unsatisfiable filter beats an error, which would
610
- // itself confirm the field exists (field-access.ts strips silently)
611
- return;
612
- }
613
- if (!isListFilterAllowed(entity, f.field, f)) {
614
- throw new ValidationError({
615
- fields: [
616
- {
617
- path: `filter.${f.field}`,
618
- code: "field_not_filterable",
619
- i18nKey: "errors.validation.invalid_value",
620
- params: {
621
- field: f.field,
622
- op: f.op,
623
- hint: "Encrypted, personal and secret fields cannot be filtered; only equality on a field with find: exact or fuzzy.",
624
- },
625
- },
626
- ],
627
- });
628
- }
629
- if (readClause.kind === "sql") {
630
- const shifted = shiftParams({ sqlText: readClause.sqlText, params: readClause.params }, params.length);
631
- whereSql.push(shifted.sqlText);
632
- for (const p of shifted.params)
633
- params.push(p);
634
- }
635
- if (entity.fields[f.field]?.type === "multiSelect") {
636
- applyMultiSelectFilter(colSql, whereSql, params, f);
637
- // skip: multiSelect already handled — don't fall through to applyScreenFilter
638
- return;
639
- }
640
- applyScreenFilter(table, colSql, whereSql, params, f);
641
- };
642
- if (payload.filter !== undefined)
643
- applyFilter(payload.filter);
644
- if (payload.filters !== undefined)
645
- for (const f of payload.filters)
646
- applyFilter(f);
29
+ const built = await buildListWhere(ctx, payload, user, db, runtimeOptions);
30
+ if (built.kind === "empty")
31
+ return emptyListPage(totalCount);
32
+ const { where, fieldReadClause, searchTruncated } = built;
33
+ const sort = resolveListSort({
34
+ payload,
35
+ table,
36
+ entity,
37
+ tableName,
38
+ colSql: where.colSql,
39
+ fieldReadClause,
40
+ referenceSort: runtimeOptions?.referenceSort,
41
+ db,
42
+ user,
43
+ });
44
+ if (payload.cursor)
45
+ appendCursorBoundary(where, payload.cursor, sort);
647
46
  // The COUNT below reuses the WHERE text only, so it must bind exactly the
648
47
  // params that text references — Postgres rejects a statement supplied
649
48
  // with more parameters than it uses, and ORDER BY may add its own.
650
- const whereParams = params.slice();
651
- const sortSql = sortField === undefined
652
- ? undefined
653
- : referenceSortExpr !== undefined
654
- ? referenceSortExpr(params)
655
- : colSql(sortField);
656
- // The projected label alias is ordered by directly, so Postgres evaluates
657
- // the correlated subquery once per row instead of once for ORDER BY and
658
- // once for the SELECT list.
659
- const orderBySortSql = referenceSortExpr !== undefined ? `"${SORT_LABEL_ALIAS}"` : sortSql;
660
- const orderByClause = orderBySortSql !== undefined
661
- ? ` ORDER BY ${orderBySortSql} ${sortDescending ? "DESC" : "ASC"}, ${colSql("id")} ASC`
662
- : ` ORDER BY ${colSql("id")} ASC`;
663
- const useOffset = !payload.cursor && offset > 0;
664
- const offsetClause = useOffset ? ` OFFSET ${offset}` : "";
665
- const whereClauseSqlText = whereSql.length > 0 ? ` WHERE ${whereSql.join(" AND ")}` : "";
666
- // Project the resolved label too, so the keyset cursor below carries the
667
- // value that was ordered by rather than the FK column's UUID.
668
- const sortLabelProjection = referenceSortExpr !== undefined && sortSql !== undefined
669
- ? `, ${sortSql} AS "${SORT_LABEL_ALIAS}"`
670
- : "";
671
- // Stream version in the same statement as the rows: a separate read could pair a
672
- // post-write version with a pre-write row, and update({version}) would then pass
673
- // the optimistic lock against a state the client never saw (see loadDetailSnapshot).
674
- params.push(streamTenantFor(user));
675
- const streamVersionProjection = `, (SELECT MAX("version") FROM "kumiko_events" ` +
676
- `WHERE "aggregate_id" = "${tableName}"."id" AND "tenant_id" = $${params.length}) ` +
677
- `AS "${STREAM_VERSION_ALIAS}"`;
49
+ const whereParams = where.params.slice();
50
+ const { orderByClause, sortLabelProjection } = renderListOrderBy(sort, where);
51
+ const offsetClause = !payload.cursor && offset > 0 ? ` OFFSET ${offset}` : "";
52
+ const whereClauseSqlText = where.whereSql.length > 0 ? ` WHERE ${where.whereSql.join(" AND ")}` : "";
53
+ const streamVersionProjection = appendStreamVersionProjection(where.params, tableName, streamTenantFor(user));
678
54
  const selectList = `*${sortLabelProjection}${streamVersionProjection}`;
679
55
  const listSql = `SELECT ${selectList} FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
680
- const rawRows = await executeRawQueryRead(runner, listSql, params);
681
- const lastSortLabel = rawRows[rawRows.length - 1]?.[SORT_LABEL_ALIAS];
682
- // Aliases are query artifacts, not columns of this entity — strip them before
683
- // coercion so they never reach the caller or the entity cache.
684
- const streamVersions = rawRows.map((r) => {
685
- const v = r[STREAM_VERSION_ALIAS];
686
- delete r[STREAM_VERSION_ALIAS];
687
- return typeof v === "number" ? v : 0;
688
- });
689
- if (referenceSortExpr !== undefined) {
690
- for (const row of rawRows)
691
- delete row[SORT_LABEL_ALIAS];
692
- }
693
- // Per-row read-side rehydrate + snake→camel coercion for driver-agnostic field names.
694
- // Coerce BEFORE rehydrate/decrypt: the raw SELECT * rows carry snake_case
695
- // column names, while compound-type lookups (rehydrateMoney et al.) and the
696
- // encrypted/pii field lists are all camelCase — running either first on a
697
- // still-snake_case row silently no-ops (money) or skips every multi-word
698
- // field (ciphertext leaked to the caller).
56
+ const rawRows = await executeRawQueryRead(runner, listSql, where.params);
57
+ const lastSortLabel = takeLastSortLabel(rawRows, sort);
58
+ const streamVersions = takeStreamVersions(rawRows);
699
59
  const tableInfo = extractTableInfo(table);
700
- const encryptedRows = rawRows.map((r) => rehydrateCompoundTypes(coerceRow(r, tableInfo), entity));
60
+ const encryptedRows = rawRows.map((r) => coerceAndRehydrateRow(r, tableInfo, entity));
701
61
  const decryptedRows = await Promise.all(encryptedRows.map((r) => decryptForRead(r, { degradeUndecryptable: () => { } })));
702
62
  // Stream version is authoritative (see detail()): ctx.appendEvent bumps the
703
63
  // stream without touching row.version, so the raw row version would hand
@@ -714,24 +74,11 @@ export function createReadVerbs(ctx) {
714
74
  let nextCursor = null;
715
75
  if (rows.length === limit && lastRow && lastRaw) {
716
76
  const cursorId = lastRow["id"]; // @cast-boundary engine-payload
717
- const sortText = sortField === undefined
718
- ? undefined
719
- : toCursorSortText(referenceSortExpr !== undefined ? lastSortLabel : lastRaw[physicalCol(sortField)]);
720
- nextCursor =
721
- sortText === undefined ? encodeCursor(cursorId) : encodeKeysetCursor(sortText, cursorId);
722
- }
723
- // total: extra COUNT(*) — only when explicitly requested (pager UI).
724
- // Postgres cost is O(table-scan) without a filter, with a filter as
725
- // expensive as the corresponding WHERE — cheap enough on indexed columns.
726
- // Always counted over the full WHERE: search ids, reference matches, tenant,
727
- // soft-delete, cursor, ownership and filters all narrow the result, so the
728
- // search hit count alone is never the total.
729
- let total;
730
- if (totalCount) {
731
- const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
732
- const countRows = await executeRawQueryRead(runner, countSql, whereParams);
733
- total = countRows[0]?.count ?? 0;
77
+ nextCursor = encodeNextCursor(cursorId, sort, lastRaw, lastSortLabel, table);
734
78
  }
79
+ const total = totalCount
80
+ ? await countListRows(runner, tableName, whereClauseSqlText, whereParams)
81
+ : undefined;
735
82
  return {
736
83
  rows,
737
84
  nextCursor,
@@ -739,14 +86,17 @@ export function createReadVerbs(ctx) {
739
86
  ...(searchTruncated && { searchTruncated: true }),
740
87
  };
741
88
  },
89
+ aggregate: (payload, user, db, runtimeOptions) => aggregateList(ctx, payload, user, db, runtimeOptions),
742
90
  async detail(payload, user, db, options) {
743
91
  const tableName = String(schemaTableName(table));
744
92
  // H.2 — ownership check, AND-ed with the parent-ref read-gate (see
745
93
  // db/parent-ref-clause.ts). `empty` → the user can never see this row
746
94
  // regardless of its id. Return null (same shape as "not found", so a
747
95
  // probing attacker can't distinguish "no access" from "doesn't exist").
748
- const parentClause = buildParentRefClause(entity, table, tableName, user, db, options?.parentVisibility, { includeDeleted: false, narrowTypes: undefined });
749
- const ownership = combineClauses(buildOwnershipClause(user, entity.access?.read, table), parentClause);
96
+ const ownership = resolveReadOwnership(ctx, tableName, user, db, options?.parentVisibility, {
97
+ includeDeleted: false,
98
+ narrowTypes: undefined,
99
+ });
750
100
  if (ownership.kind === "empty")
751
101
  return null;
752
102
  const idWhere = idFilter(payload.id);
@@ -787,11 +137,8 @@ export function createReadVerbs(ctx) {
787
137
  const raw = snapshot.row;
788
138
  if (!raw)
789
139
  return null;
790
- // Same coerce-before-rehydrate/decrypt ordering as list() above — raw
791
- // is snake_case (raw SQL); coerceRow maps it to camelCase.
792
- const rowInfo = extractTableInfo(table);
793
140
  const undecryptableFields = [];
794
- const coerced = await decryptForRead(rehydrateCompoundTypes(coerceRow(raw, rowInfo), entity), { degradeUndecryptable: (field) => undecryptableFields.push(field) });
141
+ const coerced = await decryptForRead(coerceAndRehydrateRow(raw, extractTableInfo(table), entity), { degradeUndecryptable: (field) => undecryptableFields.push(field) });
795
142
  const result = withStreamVersion(coerced, snapshot.streamVersion);
796
143
  // A degraded row must not be cached: re-encrypting its null would
797
144
  // replay the missing value on later cache hits without any error log.