@cosmicdrift/kumiko-framework 0.1.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 (388) hide show
  1. package/README.md +159 -0
  2. package/package.json +91 -0
  3. package/src/__tests__/anonymous-access.integration.ts +325 -0
  4. package/src/__tests__/error-contract.integration.ts +435 -0
  5. package/src/__tests__/field-access.integration.ts +269 -0
  6. package/src/__tests__/full-stack.integration.ts +914 -0
  7. package/src/__tests__/ownership.integration.ts +449 -0
  8. package/src/__tests__/reference-data.integration.ts +198 -0
  9. package/src/__tests__/transition-guard.integration.ts +340 -0
  10. package/src/api/__tests__/api.test.ts +337 -0
  11. package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
  13. package/src/api/__tests__/batch.integration.ts +404 -0
  14. package/src/api/__tests__/body-limit.test.ts +88 -0
  15. package/src/api/__tests__/csrf-middleware.test.ts +97 -0
  16. package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
  17. package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
  18. package/src/api/__tests__/nested-write.integration.ts +213 -0
  19. package/src/api/__tests__/readiness.test.ts +76 -0
  20. package/src/api/__tests__/request-id-middleware.test.ts +72 -0
  21. package/src/api/__tests__/sse-broker.test.ts +58 -0
  22. package/src/api/__tests__/sse-route.test.ts +112 -0
  23. package/src/api/anonymous-cookie.ts +60 -0
  24. package/src/api/api-constants.ts +64 -0
  25. package/src/api/auth-middleware.ts +418 -0
  26. package/src/api/auth-routes.ts +982 -0
  27. package/src/api/csrf-middleware.ts +77 -0
  28. package/src/api/index.ts +31 -0
  29. package/src/api/jwt.ts +66 -0
  30. package/src/api/observability-middleware.ts +89 -0
  31. package/src/api/readiness.ts +132 -0
  32. package/src/api/request-context.ts +49 -0
  33. package/src/api/request-id-middleware.ts +50 -0
  34. package/src/api/route-registrars.ts +195 -0
  35. package/src/api/routes.ts +135 -0
  36. package/src/api/server.ts +640 -0
  37. package/src/api/sse-broker.ts +71 -0
  38. package/src/api/sse-route.ts +62 -0
  39. package/src/api/tokens.ts +16 -0
  40. package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
  41. package/src/db/__tests__/compound-types.test.ts +114 -0
  42. package/src/db/__tests__/connection-options.test.ts +68 -0
  43. package/src/db/__tests__/cursor.test.ts +41 -0
  44. package/src/db/__tests__/db-helpers.test.ts +369 -0
  45. package/src/db/__tests__/dialect-instant.test.ts +50 -0
  46. package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
  47. package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
  48. package/src/db/__tests__/encryption.test.ts +39 -0
  49. package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
  50. package/src/db/__tests__/event-store-executor.integration.ts +235 -0
  51. package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
  52. package/src/db/__tests__/located-timestamp.test.ts +184 -0
  53. package/src/db/__tests__/money.test.ts +199 -0
  54. package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
  55. package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
  56. package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
  57. package/src/db/__tests__/row-helpers.test.ts +59 -0
  58. package/src/db/__tests__/schema-migration.integration.ts +273 -0
  59. package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
  60. package/src/db/__tests__/table-builder-required.test.ts +216 -0
  61. package/src/db/__tests__/tenant-db.integration.ts +606 -0
  62. package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
  63. package/src/db/apply-entity-event.ts +188 -0
  64. package/src/db/assert-exists-in.ts +59 -0
  65. package/src/db/compound-types.ts +47 -0
  66. package/src/db/connection.ts +104 -0
  67. package/src/db/cursor.ts +83 -0
  68. package/src/db/dialect.ts +109 -0
  69. package/src/db/eagerload.ts +174 -0
  70. package/src/db/encryption.ts +39 -0
  71. package/src/db/event-store-executor.ts +906 -0
  72. package/src/db/index.ts +55 -0
  73. package/src/db/located-timestamp.ts +114 -0
  74. package/src/db/money.ts +120 -0
  75. package/src/db/pg-error.ts +46 -0
  76. package/src/db/reference-data.ts +77 -0
  77. package/src/db/row-helpers.ts +53 -0
  78. package/src/db/schema-inspection.ts +25 -0
  79. package/src/db/table-builder.ts +475 -0
  80. package/src/db/tenant-db.ts +434 -0
  81. package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
  82. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
  83. package/src/engine/__tests__/boot-validator.test.ts +1865 -0
  84. package/src/engine/__tests__/build-app-schema.test.ts +154 -0
  85. package/src/engine/__tests__/claim-keys.test.ts +274 -0
  86. package/src/engine/__tests__/config-helpers.test.ts +236 -0
  87. package/src/engine/__tests__/effective-features.test.ts +86 -0
  88. package/src/engine/__tests__/engine.test.ts +1461 -0
  89. package/src/engine/__tests__/entity-handlers.test.ts +274 -0
  90. package/src/engine/__tests__/event-helpers.test.ts +68 -0
  91. package/src/engine/__tests__/extends-registrar.test.ts +159 -0
  92. package/src/engine/__tests__/factories-long-text.test.ts +84 -0
  93. package/src/engine/__tests__/factories-time.test.ts +158 -0
  94. package/src/engine/__tests__/field-predicates.test.ts +48 -0
  95. package/src/engine/__tests__/hook-phases.test.ts +132 -0
  96. package/src/engine/__tests__/identifiers.test.ts +35 -0
  97. package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
  98. package/src/engine/__tests__/nav.test.ts +267 -0
  99. package/src/engine/__tests__/ownership.test.ts +421 -0
  100. package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
  101. package/src/engine/__tests__/projection-helpers.test.ts +62 -0
  102. package/src/engine/__tests__/projection.test.ts +191 -0
  103. package/src/engine/__tests__/qualified-name.test.ts +264 -0
  104. package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
  105. package/src/engine/__tests__/run-in.test.ts +38 -0
  106. package/src/engine/__tests__/schema-builder.test.ts +380 -0
  107. package/src/engine/__tests__/screen.test.ts +408 -0
  108. package/src/engine/__tests__/state-machine.test.ts +148 -0
  109. package/src/engine/__tests__/system-user.test.ts +57 -0
  110. package/src/engine/__tests__/validation-hooks.test.ts +71 -0
  111. package/src/engine/access.ts +23 -0
  112. package/src/engine/boot-validator.ts +1528 -0
  113. package/src/engine/build-app-schema.ts +125 -0
  114. package/src/engine/config-helpers.ts +115 -0
  115. package/src/engine/constants.ts +85 -0
  116. package/src/engine/create-app.ts +98 -0
  117. package/src/engine/define-feature.ts +702 -0
  118. package/src/engine/define-handler.ts +78 -0
  119. package/src/engine/define-roles.ts +19 -0
  120. package/src/engine/effective-features.ts +87 -0
  121. package/src/engine/entity-handlers.ts +364 -0
  122. package/src/engine/event-helpers.ts +73 -0
  123. package/src/engine/factories.ts +328 -0
  124. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
  125. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
  126. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
  127. package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
  128. package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
  129. package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
  130. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
  131. package/src/engine/feature-ast/extractors.ts +2562 -0
  132. package/src/engine/feature-ast/index.ts +105 -0
  133. package/src/engine/feature-ast/parse.ts +369 -0
  134. package/src/engine/feature-ast/patch.ts +525 -0
  135. package/src/engine/feature-ast/patcher.ts +518 -0
  136. package/src/engine/feature-ast/patterns.ts +434 -0
  137. package/src/engine/feature-ast/render.ts +602 -0
  138. package/src/engine/feature-ast/source-location.ts +45 -0
  139. package/src/engine/field-access.ts +120 -0
  140. package/src/engine/index.ts +254 -0
  141. package/src/engine/ownership.ts +337 -0
  142. package/src/engine/parse-ref-target.ts +22 -0
  143. package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
  144. package/src/engine/pattern-library/index.ts +24 -0
  145. package/src/engine/pattern-library/library.ts +1117 -0
  146. package/src/engine/pattern-library/types.ts +255 -0
  147. package/src/engine/projection-helpers.ts +85 -0
  148. package/src/engine/qualified-name.ts +122 -0
  149. package/src/engine/read-claim.ts +31 -0
  150. package/src/engine/registry.ts +1325 -0
  151. package/src/engine/resolve-config-or-param.ts +153 -0
  152. package/src/engine/run-in.ts +29 -0
  153. package/src/engine/schema-builder.ts +175 -0
  154. package/src/engine/screen-filter-ops.ts +51 -0
  155. package/src/engine/state-machine.ts +70 -0
  156. package/src/engine/system-user.ts +32 -0
  157. package/src/engine/types/config.ts +306 -0
  158. package/src/engine/types/event-type-map.ts +37 -0
  159. package/src/engine/types/feature.ts +574 -0
  160. package/src/engine/types/fields.ts +422 -0
  161. package/src/engine/types/handlers.ts +742 -0
  162. package/src/engine/types/hooks.ts +142 -0
  163. package/src/engine/types/http-route.ts +54 -0
  164. package/src/engine/types/identifiers.ts +47 -0
  165. package/src/engine/types/index.ts +208 -0
  166. package/src/engine/types/nav.ts +46 -0
  167. package/src/engine/types/projection.ts +132 -0
  168. package/src/engine/types/relations.ts +51 -0
  169. package/src/engine/types/screen.ts +452 -0
  170. package/src/engine/types/workspace.ts +42 -0
  171. package/src/engine/validation.ts +33 -0
  172. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
  173. package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
  174. package/src/entrypoint/index.ts +442 -0
  175. package/src/errors/__tests__/classes.test.ts +371 -0
  176. package/src/errors/__tests__/write-failures.test.ts +109 -0
  177. package/src/errors/classes.ts +249 -0
  178. package/src/errors/i18n/de.yaml +83 -0
  179. package/src/errors/i18n/en.yaml +80 -0
  180. package/src/errors/index.ts +41 -0
  181. package/src/errors/kumiko-error.ts +67 -0
  182. package/src/errors/reasons.ts +36 -0
  183. package/src/errors/serialize.ts +136 -0
  184. package/src/errors/transition-details.ts +30 -0
  185. package/src/errors/write-error-info.ts +123 -0
  186. package/src/errors/zod-bridge.ts +49 -0
  187. package/src/event-store/__tests__/admin-api.integration.ts +361 -0
  188. package/src/event-store/__tests__/event-store.integration.ts +584 -0
  189. package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
  190. package/src/event-store/__tests__/perf.integration.ts +255 -0
  191. package/src/event-store/__tests__/snapshot.integration.ts +267 -0
  192. package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
  193. package/src/event-store/__tests__/upcaster.integration.ts +460 -0
  194. package/src/event-store/admin-api.ts +257 -0
  195. package/src/event-store/archive.ts +106 -0
  196. package/src/event-store/errors.ts +35 -0
  197. package/src/event-store/event-store.ts +405 -0
  198. package/src/event-store/events-schema.ts +90 -0
  199. package/src/event-store/index.ts +50 -0
  200. package/src/event-store/snapshot.ts +210 -0
  201. package/src/event-store/upcaster-dead-letter.ts +119 -0
  202. package/src/event-store/upcaster.ts +147 -0
  203. package/src/files/__tests__/content-disposition.test.ts +123 -0
  204. package/src/files/__tests__/file-field-column.integration.ts +103 -0
  205. package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
  206. package/src/files/__tests__/file-handle.test.ts +122 -0
  207. package/src/files/__tests__/files.integration.ts +830 -0
  208. package/src/files/__tests__/storage-tracking.integration.ts +153 -0
  209. package/src/files/content-disposition.ts +55 -0
  210. package/src/files/file-handle.ts +63 -0
  211. package/src/files/file-ref-table.ts +22 -0
  212. package/src/files/file-routes.ts +353 -0
  213. package/src/files/in-memory-provider.ts +62 -0
  214. package/src/files/index.ts +29 -0
  215. package/src/files/local-provider.ts +35 -0
  216. package/src/files/storage-tracking.ts +60 -0
  217. package/src/files/types.ts +118 -0
  218. package/src/i18n/__tests__/i18n.test.ts +72 -0
  219. package/src/i18n/index.ts +29 -0
  220. package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
  221. package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
  222. package/src/jobs/__tests__/jobs.integration.ts +566 -0
  223. package/src/jobs/index.ts +2 -0
  224. package/src/jobs/job-runner.ts +574 -0
  225. package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
  226. package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
  227. package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
  228. package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
  229. package/src/lifecycle/index.ts +13 -0
  230. package/src/lifecycle/lifecycle.ts +160 -0
  231. package/src/lifecycle/signal-handlers.ts +62 -0
  232. package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
  233. package/src/logging/index.ts +3 -0
  234. package/src/logging/pino-logger.ts +64 -0
  235. package/src/logging/types.ts +7 -0
  236. package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
  237. package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
  238. package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
  239. package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
  240. package/src/migrations/index.ts +28 -0
  241. package/src/migrations/projection-detection.ts +149 -0
  242. package/src/migrations/rebuild-marker.ts +64 -0
  243. package/src/migrations/schema-drift.ts +395 -0
  244. package/src/observability/__tests__/console-provider.test.ts +67 -0
  245. package/src/observability/__tests__/metric-validator.test.ts +87 -0
  246. package/src/observability/__tests__/noop-provider.test.ts +82 -0
  247. package/src/observability/__tests__/observability.integration.ts +559 -0
  248. package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
  249. package/src/observability/__tests__/recording-meter.test.ts +101 -0
  250. package/src/observability/__tests__/recording-tracer.test.ts +110 -0
  251. package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
  252. package/src/observability/console-provider.ts +130 -0
  253. package/src/observability/context.ts +26 -0
  254. package/src/observability/fallback.ts +34 -0
  255. package/src/observability/ids.ts +25 -0
  256. package/src/observability/index.ts +79 -0
  257. package/src/observability/metric-validator.ts +86 -0
  258. package/src/observability/metrics-handle.ts +56 -0
  259. package/src/observability/noop-provider.ts +146 -0
  260. package/src/observability/prometheus-meter.ts +284 -0
  261. package/src/observability/recording-meter.ts +156 -0
  262. package/src/observability/recording-tracer.ts +198 -0
  263. package/src/observability/redis-wrapper.ts +132 -0
  264. package/src/observability/sensitive-filter.ts +108 -0
  265. package/src/observability/standard-metrics.ts +213 -0
  266. package/src/observability/types/index.ts +29 -0
  267. package/src/observability/types/metric.ts +56 -0
  268. package/src/observability/types/provider.ts +32 -0
  269. package/src/observability/types/span.ts +64 -0
  270. package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
  271. package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
  272. package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
  273. package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
  274. package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
  275. package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
  276. package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
  277. package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
  278. package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
  279. package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
  280. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
  281. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
  282. package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
  283. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
  284. package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
  285. package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
  286. package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
  287. package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
  288. package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
  289. package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
  290. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
  291. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
  292. package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
  293. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
  294. package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
  295. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
  296. package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
  297. package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
  298. package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
  299. package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
  300. package/src/pipeline/append-event-core.ts +117 -0
  301. package/src/pipeline/auth-claims-resolver.ts +103 -0
  302. package/src/pipeline/cascade-handler.ts +113 -0
  303. package/src/pipeline/dispatcher.ts +1585 -0
  304. package/src/pipeline/distributed-lock.ts +37 -0
  305. package/src/pipeline/entity-cache.ts +113 -0
  306. package/src/pipeline/event-consumer-state.ts +108 -0
  307. package/src/pipeline/event-dedup.ts +23 -0
  308. package/src/pipeline/event-dispatcher.ts +1016 -0
  309. package/src/pipeline/event-retention.ts +154 -0
  310. package/src/pipeline/idempotency.ts +76 -0
  311. package/src/pipeline/index.ts +66 -0
  312. package/src/pipeline/lifecycle-pipeline.ts +409 -0
  313. package/src/pipeline/msp-rebuild.ts +242 -0
  314. package/src/pipeline/multi-stream-apply-context.ts +115 -0
  315. package/src/pipeline/projection-rebuild.ts +334 -0
  316. package/src/pipeline/projection-state.ts +72 -0
  317. package/src/pipeline/projections-runner.ts +56 -0
  318. package/src/pipeline/redis-keys.ts +11 -0
  319. package/src/pipeline/system-hooks.ts +190 -0
  320. package/src/random/__tests__/generate.test.ts +149 -0
  321. package/src/random/generate.ts +141 -0
  322. package/src/random/index.ts +8 -0
  323. package/src/random/words.ts +392 -0
  324. package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
  325. package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
  326. package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
  327. package/src/rate-limit/bucket.ts +36 -0
  328. package/src/rate-limit/index.ts +14 -0
  329. package/src/rate-limit/middleware.ts +152 -0
  330. package/src/rate-limit/resolver.ts +267 -0
  331. package/src/redis/__tests__/redis-options.test.ts +54 -0
  332. package/src/redis/index.ts +74 -0
  333. package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
  334. package/src/search/__tests__/search-adapter.test.ts +256 -0
  335. package/src/search/in-memory-adapter.ts +123 -0
  336. package/src/search/index.ts +12 -0
  337. package/src/search/meilisearch-adapter.ts +106 -0
  338. package/src/search/types.ts +39 -0
  339. package/src/secrets/__tests__/dek-cache.test.ts +213 -0
  340. package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
  341. package/src/secrets/__tests__/envelope.test.ts +74 -0
  342. package/src/secrets/__tests__/leak-guard.test.ts +92 -0
  343. package/src/secrets/__tests__/rotation.test.ts +149 -0
  344. package/src/secrets/dek-cache.ts +116 -0
  345. package/src/secrets/env-master-key-provider.ts +162 -0
  346. package/src/secrets/envelope.ts +55 -0
  347. package/src/secrets/index.ts +19 -0
  348. package/src/secrets/leak-guard.ts +87 -0
  349. package/src/secrets/rotation.ts +34 -0
  350. package/src/secrets/types.ts +107 -0
  351. package/src/stack/db.ts +104 -0
  352. package/src/stack/event-collector.ts +23 -0
  353. package/src/stack/index.ts +32 -0
  354. package/src/stack/redis.ts +44 -0
  355. package/src/stack/request-helper.ts +168 -0
  356. package/src/stack/table-helpers.ts +104 -0
  357. package/src/stack/test-stack.ts +357 -0
  358. package/src/stack/test-users.ts +37 -0
  359. package/src/testing/__tests__/e2e-generator.test.ts +230 -0
  360. package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
  361. package/src/testing/access-assertions.ts +15 -0
  362. package/src/testing/assertions.ts +35 -0
  363. package/src/testing/e2e-generator.ts +465 -0
  364. package/src/testing/expect-error.ts +25 -0
  365. package/src/testing/handler-context.ts +125 -0
  366. package/src/testing/http-cookies.ts +52 -0
  367. package/src/testing/index.ts +41 -0
  368. package/src/testing/late-bound.ts +39 -0
  369. package/src/testing/mutable-master-key-provider.ts +31 -0
  370. package/src/testing/observability-recorder.ts +54 -0
  371. package/src/testing/shared-entities.ts +49 -0
  372. package/src/testing/utils.ts +1 -0
  373. package/src/testing/wait-for.ts +31 -0
  374. package/src/time/__tests__/polyfill.test.ts +73 -0
  375. package/src/time/__tests__/tz-context.test.ts +121 -0
  376. package/src/time/index.ts +21 -0
  377. package/src/time/polyfill.ts +70 -0
  378. package/src/time/tz-context.ts +107 -0
  379. package/src/ui-types/app-schema.ts +57 -0
  380. package/src/ui-types/index.ts +65 -0
  381. package/src/utils/__tests__/assert.test.ts +17 -0
  382. package/src/utils/__tests__/env-parse.test.ts +54 -0
  383. package/src/utils/assert.ts +18 -0
  384. package/src/utils/env-parse.ts +16 -0
  385. package/src/utils/ids.ts +16 -0
  386. package/src/utils/index.ts +5 -0
  387. package/src/utils/safe-json.ts +30 -0
  388. package/src/utils/serialization.ts +7 -0
@@ -0,0 +1,475 @@
1
+ import { sql } from "drizzle-orm";
2
+ import type { AnyPgColumn } from "drizzle-orm/pg-core";
3
+ import type {
4
+ EntityDefinition,
5
+ EntityRelations,
6
+ FieldDefinition,
7
+ FieldsMap,
8
+ } from "../engine/types";
9
+ import { assertUnreachable } from "../utils";
10
+ import {
11
+ boolean,
12
+ index,
13
+ instant,
14
+ integer,
15
+ jsonb,
16
+ moneyAmount,
17
+ table as pgTable,
18
+ serial,
19
+ type TableColumns,
20
+ text,
21
+ uniqueIndex,
22
+ uuid,
23
+ } from "./dialect";
24
+
25
+ type ColumnBuilder =
26
+ | ReturnType<typeof text>
27
+ | ReturnType<typeof integer>
28
+ | ReturnType<typeof boolean>
29
+ | ReturnType<typeof moneyAmount>
30
+ | ReturnType<typeof jsonb>
31
+ | ReturnType<typeof instant>
32
+ | ReturnType<typeof serial>
33
+ | ReturnType<typeof uuid>;
34
+
35
+ // Returns column(s) for a field. Most fields return a single entry,
36
+ // money returns two (amount + currency), files/images return none.
37
+ //
38
+ // `required: true` auf einem FieldDefinition mappt **immer** auf .notNull()
39
+ // in der DB-Spalte. Die alte Implementation hat das nur für reference-
40
+ // fields gemacht — text/select/number/etc. waren stillschweigend nullable
41
+ // in der DB, auch wenn der API-Validator required erzwungen hat. Folge:
42
+ // hand-written PgTable-Definitionen mussten daneben mit .notNull() pflegen,
43
+ // was zu Doppel-Definitionen + Schema-Drift führte. Jetzt ist r.entity
44
+ // die einzige Wahrheit.
45
+ function fieldToColumns(
46
+ name: string,
47
+ field: FieldDefinition,
48
+ entity: EntityDefinition,
49
+ ): Record<string, ColumnBuilder> {
50
+ const snakeName = toSnakeCase(name);
51
+
52
+ switch (field.type) {
53
+ case "text":
54
+ case "longText": {
55
+ // Beide mappen auf PG `text` (unbounded). Unterschied lebt nur
56
+ // im Type-Layer: longText hat kein sortable/searchable/filterable
57
+ // (Sprint 5b vorab). Reihenfolge default() VOR notNull(): drizzle's
58
+ // column-builder chained beides; ohne default() hat die generierte
59
+ // SQL keinen DEFAULT-clause (bricht ALTER TABLE ADD COLUMN auf
60
+ // existing rows). longText hat heute kein default-feld im type,
61
+ // aber der check `field.default !== undefined` ist defensive.
62
+ const base = text(snakeName);
63
+ const withDefault =
64
+ "default" in field && field.default !== undefined ? base.default(field.default) : base;
65
+ return { [name]: field.required ? withDefault.notNull() : withDefault };
66
+ }
67
+ case "boolean":
68
+ return {
69
+ [name]:
70
+ field.default !== undefined
71
+ ? boolean(snakeName).default(field.default).notNull()
72
+ : field.required
73
+ ? boolean(snakeName).notNull()
74
+ : boolean(snakeName),
75
+ };
76
+ case "select": {
77
+ const col = text(snakeName);
78
+ return { [name]: field.required ? col.notNull() : col };
79
+ }
80
+ case "multiSelect":
81
+ // jsonb-Array<string> mit Default `[]` und immer NOT NULL.
82
+ //
83
+ // Der `required`-Flag auf MultiSelectFieldDef wird hier bewusst
84
+ // ignoriert: Mit Default `[]` ist das Feld strukturell never-null
85
+ // (Insert ohne Wert → leeres Array, nicht NULL). Read-Side-Code
86
+ // braucht keinen null-check, das ist API-Garantie. Wer "wirklich
87
+ // null" will (= "Feld noch nie gesetzt") nutzt einen separaten
88
+ // Status-Field oder ein optional-typed reference statt eines
89
+ // multi-select.
90
+ return { [name]: jsonb(snakeName).default([]).notNull() };
91
+ case "number": {
92
+ const col = integer(snakeName);
93
+ return { [name]: field.required ? col.notNull() : col };
94
+ }
95
+ case "reference":
96
+ // Tier 2.7e-3: FK-Style UUID-Spalte. Multi-Mode (Tier 2.7e-Multi)
97
+ // speichert UUIDs als jsonb-Array<string>. Single-Mode bleibt
98
+ // klassische UUID-Spalte (NOT NULL nur bei required).
99
+ if (field.multiple === true) {
100
+ return { [name]: jsonb(snakeName).default([]).notNull() };
101
+ }
102
+ return {
103
+ [name]: field.required ? uuid(snakeName).notNull() : uuid(snakeName),
104
+ };
105
+ case "money":
106
+ // BIGINT storing the integer minor unit (cents for EUR, yen for JPY —
107
+ // the currency column tells you which). INTEGER would cap at ~21 M EUR
108
+ // which is too tight for B2B invoices, property values or balance
109
+ // aggregates. BIGINT handles up to ~90 trillion EUR safely in JS.
110
+ // Currency hat immer einen Default, ist also strukturell .notNull().
111
+ return {
112
+ [name]: field.required ? moneyAmount(snakeName).notNull() : moneyAmount(snakeName),
113
+ [`${name}Currency`]: text(`${snakeName}_currency`)
114
+ .default(entity.defaultCurrency ?? "EUR")
115
+ .notNull(),
116
+ };
117
+ case "embedded":
118
+ // jsonb mit default `{}` und immer NOT NULL — analog zu multiSelect.
119
+ // `required` wird bewusst ignoriert weil der Default das Feld
120
+ // strukturell never-null macht. Wer optional-embedded möchte (=
121
+ // "Feld komplett weglassen können") modelliert das über ein
122
+ // wrapper-feld mit boolean-flag oder discriminierte-union.
123
+ return { [name]: jsonb(snakeName).default({}).notNull() };
124
+ case "date": {
125
+ // TODO(Sprint G): semantisch falsch — `type:"date"` sollte
126
+ // Temporal.PlainDate sein (PG `date` Spalte, kein TZ). Heute aliased auf
127
+ // instant() = TIMESTAMPTZ damit Caller die gleiche API nutzen wie für
128
+ // type:"timestamp". Echte PlainDate-Migration kommt nach Sprint F.
129
+ const col = instant(snakeName);
130
+ return { [name]: field.required ? col.notNull() : col };
131
+ }
132
+ case "timestamp": {
133
+ // UTC-Instant — gespeichert als TIMESTAMPTZ in PG, gelesen/geschrieben
134
+ // als Temporal.Instant via instant() customType (siehe dialect.ts).
135
+ // Sprint F: Single-Mode-Welt — Caller-Code kennt nur Temporal.Instant,
136
+ // nie JS-Date. Auch Vergleiche (lte/gt/orderBy) akzeptieren Instants
137
+ // direkt, kein .toString()-Cast nötig.
138
+ const col = instant(snakeName);
139
+ return { [name]: field.required ? col.notNull() : col };
140
+ }
141
+ case "tz": {
142
+ // IANA-Zonenname als TEXT — Validierung über Zod-Schema (kommt im
143
+ // Validator-Schritt). Snake-Convention: `pickup_tz`.
144
+ const col = text(snakeName);
145
+ return { [name]: field.required ? col.notNull() : col };
146
+ }
147
+ case "locatedTimestamp": {
148
+ // ZWEI Spalten als atomares Pair: <name>_utc TIMESTAMPTZ + <name>_tz TEXT.
149
+ // _utc ist instant() (Temporal.Instant), _tz ist text (IANA-Name).
150
+ // Auto-Convert (at+tz → utc beim Insert; utc+tz → at beim Read) wird
151
+ // im Executor verdrahtet (Phase C). required propagiert auf beide.
152
+ const utc = instant(`${snakeName}_utc`);
153
+ const tz = text(`${snakeName}_tz`);
154
+ return {
155
+ [`${name}Utc`]: field.required ? utc.notNull() : utc,
156
+ [`${name}Tz`]: field.required ? tz.notNull() : tz,
157
+ };
158
+ }
159
+ case "file":
160
+ case "image": {
161
+ // Single file: stores fileRefId as UUID — must match fileRefsTable.id
162
+ // (uuid column). Anything narrower (integer, text length-limited) would
163
+ // silently truncate or type-coerce at INSERT time and the FK reference
164
+ // would be unusable.
165
+ const col = uuid(snakeName);
166
+ return { [name]: field.required ? col.notNull() : col };
167
+ }
168
+ case "files":
169
+ case "images":
170
+ // Multi file: no column in entity table, resolved via FileRef table
171
+ // over (entityType, entityId, fieldName). A bridge-table with
172
+ // CASCADE + sort-order is a later improvement; today plural files
173
+ // live entirely in fileRefsTable.
174
+ return {};
175
+ default:
176
+ assertUnreachable(field, "field type");
177
+ }
178
+ }
179
+
180
+ // Accepts both camelCase (`tenantMembership`) and kebab-case (`tenant-membership`)
181
+ // entity / field names. Kebab is the canonical form for new multi-word entity
182
+ // types (consistent across r.entity, event-types, table names) — camelCase is
183
+ // kept working for already-shipped code.
184
+ export function toSnakeCase(str: string): string {
185
+ return str.replace(/-/g, "_").replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
186
+ }
187
+
188
+ /**
189
+ * Derives a table name from an entity name:
190
+ * 1. camelCase → snake_case (e.g. "memberTask" → "member_task")
191
+ * 2. Simple English pluralization (category→categories, status→statuses, task→tasks)
192
+ * 3. `read_` prefix — markiert die Tabelle als Event-Sourced-Read-Model,
193
+ * damit im DB-Tool sofort erkennbar ist dass App-Code nicht direkt
194
+ * reinschreibt. Event-Store (kumiko_events) + Framework-State
195
+ * (kumiko_*) haben ihren eigenen Prefix, normale App-Side-Tables
196
+ * (ohne ES-Anbindung) haben keinen — die drei Kategorien sind damit
197
+ * im Tabellenbrowser unterscheidbar.
198
+ */
199
+ const ES_PLURAL_SUFFIXES = ["s", "sh", "ch", "x"] as const;
200
+
201
+ export const READ_MODEL_PREFIX = "read_";
202
+
203
+ export function toTableName(entityName: string): string {
204
+ const snake = toSnakeCase(entityName);
205
+ let plural: string;
206
+ if (snake.endsWith("y") && !/[aeiou]y$/.test(snake)) {
207
+ plural = `${snake.slice(0, -1)}ies`;
208
+ } else if (ES_PLURAL_SUFFIXES.some((suffix) => snake.endsWith(suffix))) {
209
+ plural = `${snake}es`;
210
+ } else {
211
+ plural = `${snake}s`;
212
+ }
213
+ return `${READ_MODEL_PREFIX}${plural}`;
214
+ }
215
+
216
+ // Drizzle's PgTableWithColumns<any> erbt eine `[k: string]: any` Index-
217
+ // Signature die in strict-mode (noUncheckedIndexedAccess + TS4111) jeden
218
+ // konsumierenden Code zwingt auf Bracket-Notation auch für bekannte
219
+ // Spalten. Da wir die Tabelle dynamisch aus EntityDefinition bauen,
220
+ // kann TS Drizzle's volle Spalten-Inferenz nicht liefern — wir spiegeln
221
+ // stattdessen die `fieldToColumns`-Logik im Type-System: jeder Field-Type
222
+ // mappt auf einen konkreten data-Type, ColumnsForEntity baut daraus den
223
+ // vollständigen Property-Bag.
224
+ //
225
+ // Mit konkret-inferiertem `F` (via createEntity({ fields: { ... } }))
226
+ // kommt das volle data-typing durch Drizzle's `eq()`/`select()`/`row.x`
227
+ // am Call-Site an. Der alte `EntityDefinition` ohne Generic-Param fällt
228
+ // auf `FieldsMap` zurück (= breaking-change-frei) — dort kennt TS die
229
+ // Field-Types nicht, das Mapping kollabiert auf `AnyPgColumn`.
230
+ //
231
+ // Lock-step-Vertrag: jeder Branch hier muss zur Runtime-Entscheidung in
232
+ // fieldToColumns passen. Type-Tests gegen repräsentative Entities (siehe
233
+ // db/__tests__/drizzle-table-types.test.ts) catchen Drift.
234
+
235
+ // Single drizzle column with concrete data + nullability — preserves
236
+ // Drizzle's `.select`/`eq`/`lt`-Inferenz für T.
237
+ type Col<T> = AnyPgColumn<{ data: T; notNull: true }>;
238
+ type NullCol<T> = AnyPgColumn<{ data: T; notNull: false }>;
239
+
240
+ // Per-field column shape — matches `fieldToColumns`. Money +
241
+ // locatedTimestamp produce two-column pairs; files/images contribute no
242
+ // columns (resolved via FileRef table). `notNull` propagiert von
243
+ // `field.required` (literal preserved by createXField generics).
244
+ type ColumnsForField<K extends string, F extends FieldDefinition> = F extends {
245
+ type: "text" | "select" | "tz";
246
+ }
247
+ ? F extends { required: true }
248
+ ? { readonly [P in K]: Col<string> }
249
+ : { readonly [P in K]: NullCol<string> }
250
+ : F extends { type: "boolean" }
251
+ ? // boolean default OR required → notNull (DB has DEFAULT, structurally never-null)
252
+ F extends { default: boolean } | { required: true }
253
+ ? { readonly [P in K]: Col<boolean> }
254
+ : { readonly [P in K]: NullCol<boolean> }
255
+ : F extends { type: "multiSelect" }
256
+ ? // jsonb default `[]`, immer notNull
257
+ { readonly [P in K]: Col<readonly string[]> }
258
+ : F extends { type: "number" }
259
+ ? F extends { required: true }
260
+ ? { readonly [P in K]: Col<number> }
261
+ : { readonly [P in K]: NullCol<number> }
262
+ : F extends { type: "money" }
263
+ ? F extends { required: true }
264
+ ? { readonly [P in K]: Col<number> } & {
265
+ readonly [P in `${K}Currency`]: Col<string>;
266
+ }
267
+ : { readonly [P in K]: NullCol<number> } & {
268
+ readonly [P in `${K}Currency`]: Col<string>;
269
+ }
270
+ : F extends { type: "reference"; multiple: true }
271
+ ? { readonly [P in K]: Col<readonly string[]> }
272
+ : F extends { type: "reference" }
273
+ ? F extends { required: true }
274
+ ? { readonly [P in K]: Col<string> }
275
+ : { readonly [P in K]: NullCol<string> }
276
+ : F extends { type: "embedded" }
277
+ ? // jsonb default `{}`, immer notNull
278
+ { readonly [P in K]: Col<Readonly<Record<string, unknown>>> }
279
+ : F extends { type: "date" | "timestamp" }
280
+ ? F extends { required: true }
281
+ ? { readonly [P in K]: Col<Temporal.Instant> }
282
+ : { readonly [P in K]: NullCol<Temporal.Instant> }
283
+ : F extends { type: "locatedTimestamp" }
284
+ ? F extends { required: true }
285
+ ? { readonly [P in `${K}Utc`]: Col<Temporal.Instant> } & {
286
+ readonly [P in `${K}Tz`]: Col<string>;
287
+ }
288
+ : { readonly [P in `${K}Utc`]: NullCol<Temporal.Instant> } & {
289
+ readonly [P in `${K}Tz`]: NullCol<string>;
290
+ }
291
+ : F extends { type: "file" | "image" }
292
+ ? F extends { required: true }
293
+ ? { readonly [P in K]: Col<string> }
294
+ : { readonly [P in K]: NullCol<string> }
295
+ : F extends { type: "files" | "images" }
296
+ ? Record<never, never>
297
+ : never;
298
+
299
+ type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
300
+ k: infer I,
301
+ ) => void
302
+ ? I
303
+ : never;
304
+
305
+ type ColumnsForEntity<F extends FieldsMap> = UnionToIntersection<
306
+ {
307
+ [K in keyof F & string]: ColumnsForField<K, F[K]>;
308
+ }[keyof F & string]
309
+ >;
310
+
311
+ // Base-Spalten von buildBaseColumns — `idType: "serial"` returnt number,
312
+ // sonst uuid-as-string. `insertedAt` hat `default(now())`, ist also
313
+ // strukturell non-null (Drizzle's `notNull` flag matcht das).
314
+ type BaseColumnsType<E extends EntityDefinition> = {
315
+ readonly id: E extends { idType: "serial" } ? Col<number> : Col<string>;
316
+ readonly tenantId: Col<string>;
317
+ readonly version: Col<number>;
318
+ readonly insertedAt: Col<Temporal.Instant>;
319
+ readonly modifiedAt: NullCol<Temporal.Instant>;
320
+ readonly insertedById: NullCol<string>;
321
+ readonly modifiedById: NullCol<string>;
322
+ };
323
+
324
+ // SoftDelete-Spalten existieren nur wenn entity.softDelete === true. Das
325
+ // Type-Level kann das nicht klein narrowen ohne Generic auf softDelete,
326
+ // also unionen wir beide Sets — Lean-Entities sehen die never-präsenten
327
+ // Spalten als typed-existierend, was dem alten `<any>`-Verhalten matcht.
328
+ type SoftDeleteColumnsType = {
329
+ readonly isDeleted: Col<boolean>;
330
+ readonly deletedAt: NullCol<Temporal.Instant>;
331
+ readonly deletedById: NullCol<string>;
332
+ };
333
+
334
+ export type DrizzleTable<E extends EntityDefinition = EntityDefinition> =
335
+ TableColumns<// biome-ignore lint/suspicious/noExplicitAny: drizzle's internal table-config stays generic; we layer typed columns on top via the intersection below.
336
+ any> &
337
+ BaseColumnsType<E> &
338
+ SoftDeleteColumnsType &
339
+ ColumnsForEntity<E["fields"]>;
340
+
341
+ export function buildBaseColumns(softDelete: boolean, idType: "serial" | "uuid" = "uuid") {
342
+ const idColumn =
343
+ idType === "uuid"
344
+ ? uuid("id").primaryKey().default(sql`gen_random_uuid()`)
345
+ : serial("id").primaryKey();
346
+
347
+ const base = {
348
+ id: idColumn,
349
+ tenantId: uuid("tenant_id").notNull(),
350
+ version: integer("version").default(1).notNull(),
351
+ // Sprint F: Temporal.Instant durchgängig (siehe instant() in dialect.ts).
352
+ // Vorher mode default "date" → Inkonsistenz mit user-defined timestamp
353
+ // Felder (mode "string"). Jetzt: ein Mode für alle Timestamps.
354
+ // customType doesn't expose Drizzle's `defaultNow()` shortcut — use raw
355
+ // SQL so PG sets the value on insert and we don't need to pass an
356
+ // Instant from JS for every row create.
357
+ insertedAt: instant("inserted_at").default(sql`now()`).notNull(),
358
+ modifiedAt: instant("modified_at"),
359
+ // User-IDs are stringified UUIDs post-ES migration. Text (not uuid) so the
360
+ // columns accept system actors ("SYSTEM", "SEED", etc.) and legacy-shaped
361
+ // integer ids during transitional tests.
362
+ insertedById: text("inserted_by_id"),
363
+ modifiedById: text("modified_by_id"),
364
+ };
365
+
366
+ if (softDelete) {
367
+ return {
368
+ ...base,
369
+ isDeleted: boolean("is_deleted").default(false).notNull(),
370
+ deletedAt: instant("deleted_at"),
371
+ deletedById: text("deleted_by_id"),
372
+ };
373
+ }
374
+
375
+ return base;
376
+ }
377
+
378
+ export type BuildDrizzleTableOptions = {
379
+ readonly featureName?: string;
380
+ // Relations declared for this entity. When present, every belongsTo
381
+ // foreignKey gets an index — otherwise joins and `WHERE fk = ?` filters
382
+ // sequential-scan the child table. Pass the output of
383
+ // `registry.getRelations(entityName)` or the raw relations block.
384
+ readonly relations?: EntityRelations;
385
+ };
386
+
387
+ export function buildDrizzleTable<E extends EntityDefinition>(
388
+ entityName: string,
389
+ entity: E,
390
+ options?: BuildDrizzleTableOptions,
391
+ ): DrizzleTable<E> {
392
+ const baseColumns = buildBaseColumns(entity.softDelete ?? false, entity.idType ?? "uuid");
393
+ const fieldColumns: Record<string, ColumnBuilder> = {};
394
+
395
+ for (const [name, field] of Object.entries(entity.fields)) {
396
+ const cols = fieldToColumns(name, field, entity);
397
+ Object.assign(fieldColumns, cols);
398
+ }
399
+
400
+ // Default table name derived from entityName (e.g. "memberTask" → "read_member_tasks")
401
+ const baseTableName = entity.table ?? toTableName(entityName);
402
+ // featureName-prefix wird zwischen read_ und den base-Namen geschoben,
403
+ // damit alle read-models einheitlich mit `read_` starten — egal ob
404
+ // featureName gesetzt ist oder nicht. Beispiel:
405
+ // featureName="shop", base="read_orders" → "read_shop_orders"
406
+ // featureName=undef, base="read_orders" → "read_orders"
407
+ // featureName="shop", base="orders" (no read_) → "shop_orders"
408
+ const tableName = options?.featureName
409
+ ? baseTableName.startsWith(READ_MODEL_PREFIX)
410
+ ? `${READ_MODEL_PREFIX}${options.featureName}_${baseTableName.slice(READ_MODEL_PREFIX.length)}`
411
+ : `${options.featureName}_${baseTableName}`
412
+ : baseTableName;
413
+
414
+ // Build the list of foreign-key columns to index. Sources:
415
+ // (a) single-file / single-image fields store a fileRef id and are queried
416
+ // by that id whenever a detail view resolves attachments.
417
+ // (b) belongsTo relations declared via r.relation() — the FK column is the
418
+ // parent-side lookup key; without an index every child join scans the
419
+ // full table.
420
+ // `Set` keeps the list deduplicated when (a) and (b) name the same column.
421
+ const foreignKeyFields = new Set<string>();
422
+ for (const [name, field] of Object.entries(entity.fields)) {
423
+ if (field.type === "file" || field.type === "image") {
424
+ foreignKeyFields.add(name);
425
+ }
426
+ }
427
+ if (options?.relations) {
428
+ for (const rel of Object.values(options.relations)) {
429
+ if (rel.type === "belongsTo") foreignKeyFields.add(rel.foreignKey);
430
+ }
431
+ }
432
+
433
+ // Cast back to DrizzleTable<E>: drizzle-kit's pgTable returns a fully
434
+ // inferred PgTableWithColumns over the *exact* column-builder map we
435
+ // hand in. Our typed signature narrows that to the static names from
436
+ // EntityDefinition (kept in sync with fieldToColumns + buildBaseColumns).
437
+ // Drizzle's runtime instance carries every needed method on top.
438
+ return pgTable(
439
+ tableName,
440
+ {
441
+ ...baseColumns,
442
+ ...fieldColumns,
443
+ },
444
+ // Every multi-tenant query filters by tenant_id. Without this index, list
445
+ // queries scan the whole table across all tenants. Applies to every table
446
+ // built via buildDrizzleTable since every entity inherits tenantId.
447
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle's table callback is generic; we access columns by their JS property name.
448
+ (table: any) => {
449
+ const indexes = [index(`${tableName}_tenant_id_idx`).on(table.tenantId)];
450
+ for (const fieldName of foreignKeyFields) {
451
+ const column = table[fieldName];
452
+ if (column) {
453
+ indexes.push(index(`${tableName}_${toSnakeCase(fieldName)}_idx`).on(column));
454
+ }
455
+ }
456
+ // entity.indexes = composite/unique-Indices die der Author explizit
457
+ // deklariert hat. Spalten werden via field-name (camelCase) angesprochen,
458
+ // der Index-Name folgt der Convention <table>_<col1>_<col2>_<unique|idx>
459
+ // — Override via index.name möglich.
460
+ for (const def of entity.indexes ?? []) {
461
+ const cols = def.columns
462
+ .map((fieldName) => table[fieldName])
463
+ .filter((col): col is unknown => col !== undefined);
464
+ if (cols.length !== def.columns.length) continue; // Boot-Validator catched das
465
+ const suffix = def.unique === true ? "unique" : "idx";
466
+ const indexName =
467
+ def.name ?? `${tableName}_${def.columns.map((c) => toSnakeCase(c)).join("_")}_${suffix}`;
468
+ const builder = def.unique === true ? uniqueIndex(indexName) : index(indexName);
469
+ // biome-ignore lint/suspicious/noExplicitAny: drizzle's .on(...cols) is variadic generic
470
+ indexes.push((builder.on as any)(...cols));
471
+ }
472
+ return indexes;
473
+ },
474
+ ) as unknown as DrizzleTable<E>;
475
+ }