@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,1325 @@
1
+ import { applyEntityEvent } from "../db/apply-entity-event";
2
+ import { buildDrizzleTable } from "../db/table-builder";
3
+ import { buildMetricName, validateMetricName } from "../observability";
4
+ import { type QnType, qualifyEntityName } from "./qualified-name";
5
+ import type {
6
+ AuthClaimsHookDef,
7
+ ClaimKeyDefinition,
8
+ ConfigKeyDefinition,
9
+ EntityDefinition,
10
+ EntityRelations,
11
+ EventDef,
12
+ EventUpcastFn,
13
+ FeatureDefinition,
14
+ FeatureMetricDef,
15
+ HookPhase,
16
+ JobDefinition,
17
+ MultiStreamProjectionDefinition,
18
+ NavDefinition,
19
+ NotificationDefinition,
20
+ OwnedFn,
21
+ PhasedHook,
22
+ PostDeleteHookFn,
23
+ PostSaveHookFn,
24
+ PreDeleteHookFn,
25
+ PreQueryHookFn,
26
+ PreSaveHookFn,
27
+ ProjectionDefinition,
28
+ QueryHandlerDef,
29
+ ReferenceDataDef,
30
+ RegistrarExtensionDef,
31
+ RegistrarExtensionRegistration,
32
+ Registry,
33
+ RelationDefinition,
34
+ ScreenDefinition,
35
+ SecretKeyDefinition,
36
+ TranslationKeys,
37
+ WorkspaceDefinition,
38
+ WriteHandlerDef,
39
+ } from "./types";
40
+ import { HookPhases } from "./types";
41
+ import { resolveName } from "./types/handlers";
42
+
43
+ type IncomingRelation = {
44
+ sourceEntity: string;
45
+ relationName: string;
46
+ relation: RelationDefinition;
47
+ };
48
+
49
+ const IMPLICIT_PROJECTION_SUFFIX = "-entity" as const;
50
+
51
+ // Pro r.entity-Registration eine ImplicitProjection mit auto-generierten
52
+ // apply-Handlern für die 4 Auto-Verben. Live-Pfad geht durch
53
+ // EventStoreExecutor und schreibt direkt in die Tabelle; rebuildProjection
54
+ // nutzt diese Definition um aus Events zu replayen. Beide rufen dieselbe
55
+ // applyEntityEvent-Funktion → Live==Rebuild by-construction (verstärkt
56
+ // durch implicit-projection-equivalence.integration.ts).
57
+ function buildImplicitProjection(
58
+ featureName: string,
59
+ entityName: string,
60
+ entity: EntityDefinition,
61
+ qualify: typeof qualifyEntityName,
62
+ ): ProjectionDefinition {
63
+ const name = qualify(featureName, "projection", `${entityName}${IMPLICIT_PROJECTION_SUFFIX}`);
64
+ const drizzleTable = buildDrizzleTable(entityName, entity);
65
+ // applyEntityEvent gibt ApplyResult zurück; SingleStreamApplyFn erwartet
66
+ // Promise<void>. Im rebuild-Pfad ist die Row irrelevant — wir discarden.
67
+ const handler = async (
68
+ event: Parameters<ProjectionDefinition["apply"][string]>[0],
69
+ tx: Parameters<ProjectionDefinition["apply"][string]>[1],
70
+ ): Promise<void> => {
71
+ await applyEntityEvent(event, drizzleTable, entity, tx);
72
+ };
73
+ const apply: Record<string, ProjectionDefinition["apply"][string]> = {
74
+ [`${entityName}.created`]: handler,
75
+ [`${entityName}.updated`]: handler,
76
+ [`${entityName}.deleted`]: handler,
77
+ };
78
+ // Restore-Verb existiert nur für softDelete-Entities. Hard-Delete-
79
+ // Entities sollten keine restored-Events produzieren — würden sie es
80
+ // doch, würde applyEntityEvent intern als no-op laufen, aber wir
81
+ // registrieren den Handler gar nicht erst.
82
+ if (entity.softDelete) {
83
+ apply[`${entityName}.restored`] = handler;
84
+ }
85
+ return {
86
+ name,
87
+ source: entityName,
88
+ table: drizzleTable,
89
+ apply,
90
+ isImplicit: true,
91
+ };
92
+ }
93
+
94
+ // This is where the magic happens. By "magic" I mean: precomputed maps.
95
+ // I build everything once at boot (hooks, relations, searchable fields, ...)
96
+ // so nothing has to iterate over objects at runtime. O(1) instead of O(n*m).
97
+ export function createRegistry(features: readonly FeatureDefinition[]): Registry {
98
+ const featureMap = new Map<string, FeatureDefinition>();
99
+ const entityMap = new Map<string, EntityDefinition>();
100
+ const relationMap = new Map<string, Record<string, RelationDefinition>>();
101
+ const writeHandlerMap = new Map<string, WriteHandlerDef>();
102
+ const queryHandlerMap = new Map<string, QueryHandlerDef>();
103
+ // Hook storage. Every entry carries its owning feature (on the OwnedFn /
104
+ // PhasedHook shape), so the lifecycle pipeline can skip hooks whose
105
+ // feature is globally disabled without a parallel bookkeeping map.
106
+ // featureName === "*" = always fire (extension-provided invariants).
107
+ const preSaveHooks = new Map<string, OwnedFn<PreSaveHookFn>[]>();
108
+ const postSaveHooks = new Map<string, PhasedHook<PostSaveHookFn>[]>();
109
+ const preDeleteHooks = new Map<string, PhasedHook<PreDeleteHookFn>[]>();
110
+ const postDeleteHooks = new Map<string, PhasedHook<PostDeleteHookFn>[]>();
111
+ const preQueryHooks = new Map<string, OwnedFn<PreQueryHookFn>[]>();
112
+ // Entity hooks — keyed by entity name, NOT prefixed
113
+ const entityPostSaveHooks = new Map<string, PhasedHook<PostSaveHookFn>[]>();
114
+ const entityPreDeleteHooks = new Map<string, PhasedHook<PreDeleteHookFn>[]>();
115
+ const entityPostDeleteHooks = new Map<string, PhasedHook<PostDeleteHookFn>[]>();
116
+ const configKeyMap = new Map<string, ConfigKeyDefinition>();
117
+ const jobMap = new Map<string, JobDefinition>();
118
+ const notificationMap = new Map<string, NotificationDefinition>();
119
+ const notificationFeatureMap = new Map<string, string>(); // qualifiedName → featureName
120
+ const eventMap = new Map<string, EventDef>();
121
+ // Schema-migration chain per qualified event name. Built at boot after all
122
+ // features are ingested, then exposed via getEventUpcasters(). Readers of
123
+ // the events-table (projection rebuild, future aggregate loaders) walk the
124
+ // chain to upcast stored payloads to the current shape at read time.
125
+ const eventUpcasterMap = new Map<
126
+ string,
127
+ { readonly currentVersion: number; readonly chain: ReadonlyMap<number, EventUpcastFn> }
128
+ >();
129
+ // Handler → entity mapping (populated from entities + handler name convention)
130
+ const handlerEntityMap = new Map<string, string>();
131
+ // Handler → feature mapping (for systemScope check)
132
+ const handlerFeatureMap = new Map<string, string>();
133
+ const extensionMap = new Map<string, RegistrarExtensionDef>();
134
+ const extensionUsages: RegistrarExtensionRegistration[] = [];
135
+ const allReferenceData: ReferenceDataDef[] = [];
136
+ const mergedTranslations: Record<string, Record<string, string>> = {};
137
+ // Metric registry — keyed by fully qualified name (kumiko_<feature>_<short>).
138
+ // Boot-time validation rejects bad names; dashboards then safely rely on shape.
139
+ const metricMap = new Map<string, FeatureMetricDef & { readonly featureName: string }>();
140
+ // Feature-declared secrets. Keyed by qualified name ("<feature>:<short>").
141
+ // The map is the source of truth for ops-UIs, the rotation job, and any
142
+ // boot validation that wants to reject a secrets.get for an unknown key.
143
+ const secretKeyMap = new Map<string, SecretKeyDefinition>();
144
+ // Projections — full list keyed by qualified name AND a source-entity index
145
+ // the executor consults on every write. Index is precomputed so the hot path
146
+ // does a single Map.get, never a scan.
147
+ const projectionMap = new Map<string, ProjectionDefinition>();
148
+ const projectionsBySource = new Map<string, ProjectionDefinition[]>();
149
+ // Multi-stream projections — cross-aggregate, async via event-dispatcher.
150
+ // One qualified name per MSP; each becomes its own EventConsumer with a
151
+ // dedicated cursor in kumiko_event_consumers.
152
+ const multiStreamProjectionMap = new Map<string, MultiStreamProjectionDefinition>();
153
+ // qualified-MSP-name → owning-feature name. Used by the event-dispatcher
154
+ // to pause consumers whose feature is globally disabled.
155
+ const multiStreamProjectionFeatureMap = new Map<string, string>();
156
+ // Auth-claims hooks — tagged with featureName so the login resolver can
157
+ // auto-prefix each hook's returned keys with "<feature>:".
158
+ const authClaimsHooks: AuthClaimsHookDef[] = [];
159
+ // Feature-declared claim keys. Keyed by qualified name ("<feature>:<short>").
160
+ // Used by readClaim callers to introspect; the resolver reads it via the
161
+ // declaredKeys set on each AuthClaimsHookDef (pre-built per feature below).
162
+ const claimKeyMap = new Map<string, ClaimKeyDefinition>();
163
+ // Screens — keyed by qualified name ("<feature>:screen:<id>"). One map for
164
+ // lookup + a parallel featureMap so the nav-resolver can gate screens by
165
+ // effective-features without scanning. `screensByEntity` pre-groups the
166
+ // entity-bound screens (entityList / entityEdit) by their entity name so
167
+ // ui-core's Schema-driven view-model builders don't need to scan
168
+ // getAllScreens() for every render.
169
+ const screenMap = new Map<string, ScreenDefinition>();
170
+ const screenFeatureMap = new Map<string, string>();
171
+ const screensByEntity = new Map<string, ScreenDefinition[]>();
172
+ // Nav entries — same shape as screenMap. Tree assembly happens in ui-core
173
+ // at render time; the engine just stores the flat list and its owners.
174
+ // `navsByParent` pre-groups children by their parent's QN so
175
+ // resolveNavigation does O(n) passes, not O(n²) parent-filters. Top-level
176
+ // entries (no parent) sit in the separate `topLevelNavs` list.
177
+ const navMap = new Map<string, NavDefinition>();
178
+ const navFeatureMap = new Map<string, string>();
179
+ const navsByParent = new Map<string, NavDefinition[]>();
180
+ const topLevelNavs: NavDefinition[] = [];
181
+
182
+ // Workspaces — stored verbatim, plus a parallel feature-owner map and a
183
+ // pre-computed nav-membership map. Membership merges two sources at boot:
184
+ // 1. r.workspace({ nav: [...] }) — explicit list on the workspace
185
+ // 2. r.nav({ workspaces: [...] }) — self-assignment on the nav entry
186
+ // Order matters for the switcher: workspace-declared QNs come first (in
187
+ // declaration order), then nav-self-assigned ones (in registration order).
188
+ // Duplicates are deduped — a nav entry listed in both shows up once.
189
+ const workspaceMap = new Map<string, WorkspaceDefinition>();
190
+ const workspaceFeatureMap = new Map<string, string>();
191
+ const navsByWorkspace = new Map<string, string[]>();
192
+ let defaultWorkspace: WorkspaceDefinition | undefined;
193
+
194
+ // Local alias for readability — `qualifyEntityName` is the shared helper
195
+ // from qualified-name.ts, also used by validateBoot to keep ingest and
196
+ // validation in lockstep on the qualification rule.
197
+ const qualify = qualifyEntityName;
198
+
199
+ // Filter hooks by phase and/or owning feature.
200
+ //
201
+ // - `phase === undefined` → any phase passes.
202
+ // - `effectiveFeatures === undefined` → ownership filter disabled.
203
+ // - hook.featureName === "*" or undefined → always passes ownership filter.
204
+ // "*" is reserved for extension-provided hooks that are invariant
205
+ // plumbing, not opt-in feature logic.
206
+ function filterByPhase<TFn>(
207
+ list: readonly PhasedHook<TFn>[] | undefined,
208
+ phase: HookPhase | undefined,
209
+ effectiveFeatures?: ReadonlySet<string>,
210
+ ): readonly TFn[] {
211
+ if (!list || list.length === 0) return [];
212
+ const result: TFn[] = [];
213
+ for (const entry of list) {
214
+ if (phase !== undefined && entry.phase !== phase) continue;
215
+ if (!ownerEnabled(entry.featureName, effectiveFeatures)) continue;
216
+ result.push(entry.fn);
217
+ }
218
+ return result;
219
+ }
220
+
221
+ // Same ownership rule as filterByPhase, but for unphased hook lists
222
+ // (preSave, preQuery). Returns the raw fns ready for the lifecycle runner.
223
+ function filterOwned<TFn>(
224
+ list: readonly OwnedFn<TFn>[] | undefined,
225
+ effectiveFeatures?: ReadonlySet<string>,
226
+ ): readonly TFn[] {
227
+ if (!list || list.length === 0) return [];
228
+ const result: TFn[] = [];
229
+ for (const entry of list) {
230
+ if (!ownerEnabled(entry.featureName, effectiveFeatures)) continue;
231
+ result.push(entry.fn);
232
+ }
233
+ return result;
234
+ }
235
+
236
+ function ownerEnabled(
237
+ owner: string | undefined,
238
+ effectiveFeatures: ReadonlySet<string> | undefined,
239
+ ): boolean {
240
+ if (!effectiveFeatures) return true;
241
+ if (owner === undefined || owner === "*") return true;
242
+ return effectiveFeatures.has(owner);
243
+ }
244
+
245
+ // Merge hooks without prefix (entity hooks). featureName is already on
246
+ // every hook entry (set by defineFeature), so there's no parallel
247
+ // bookkeeping — just append.
248
+ function mergeHookList<T>(
249
+ map: Map<string, T[]>,
250
+ source: Readonly<Record<string, readonly T[]>>,
251
+ ): void {
252
+ for (const [name, fns] of Object.entries(source)) {
253
+ const existing = map.get(name) ?? [];
254
+ existing.push(...fns);
255
+ map.set(name, existing);
256
+ }
257
+ }
258
+
259
+ // Merge hooks with feature prefix (handler hooks).
260
+ // Hook keys are handler QNs — hooks don't get their own QN, they're keyed by the handler they target.
261
+ // The hookQnType indicates whether the targeted handler is a write or query handler.
262
+ function mergeHookListQualified<T>(
263
+ map: Map<string, T[]>,
264
+ source: Readonly<Record<string, readonly T[]>>,
265
+ featureName: string,
266
+ hookQnType: QnType,
267
+ ): void {
268
+ for (const [name, fns] of Object.entries(source)) {
269
+ const qualified = qualify(featureName, hookQnType, name);
270
+ const existing = map.get(qualified) ?? [];
271
+ existing.push(...fns);
272
+ map.set(qualified, existing);
273
+ }
274
+ }
275
+
276
+ for (const feature of features) {
277
+ if (featureMap.has(feature.name)) {
278
+ throw new Error(`Duplicate feature: "${feature.name}"`);
279
+ }
280
+ featureMap.set(feature.name, feature);
281
+
282
+ // Entities: NOT prefixed — entity names must be globally unique
283
+ for (const [name, entity] of Object.entries(feature.entities)) {
284
+ if (entityMap.has(name)) {
285
+ throw new Error(`Duplicate entity: "${name}" (registered by multiple features)`);
286
+ }
287
+ entityMap.set(name, entity);
288
+ }
289
+
290
+ // Relations: entityName (not prefixed)
291
+ for (const [entityName, rels] of Object.entries(feature.relations)) {
292
+ const existing = relationMap.get(entityName) ?? {};
293
+ for (const [relName, relDef] of Object.entries(rels)) {
294
+ if (existing[relName]) {
295
+ throw new Error(
296
+ `Duplicate relation: "${entityName}.${relName}" (registered by multiple features)`,
297
+ );
298
+ }
299
+ existing[relName] = relDef;
300
+ }
301
+ relationMap.set(entityName, existing);
302
+ }
303
+
304
+ // Write handlers: scope:write:name
305
+ for (const [name, handler] of Object.entries(feature.writeHandlers)) {
306
+ const qualified = qualify(feature.name, "write", name);
307
+ if (writeHandlerMap.has(qualified)) {
308
+ throw new Error(
309
+ `Duplicate write handler: "${qualified}" (registered by multiple features)`,
310
+ );
311
+ }
312
+ writeHandlerMap.set(qualified, { ...handler, name: qualified });
313
+ handlerFeatureMap.set(qualified, feature.name);
314
+ }
315
+
316
+ // Query handlers: scope:query:name
317
+ for (const [name, handler] of Object.entries(feature.queryHandlers)) {
318
+ const qualified = qualify(feature.name, "query", name);
319
+ if (queryHandlerMap.has(qualified)) {
320
+ throw new Error(
321
+ `Duplicate query handler: "${qualified}" (registered by multiple features)`,
322
+ );
323
+ }
324
+ queryHandlerMap.set(qualified, { ...handler, name: qualified });
325
+ handlerFeatureMap.set(qualified, feature.name);
326
+ }
327
+
328
+ // Config keys: scope:config:name
329
+ for (const [key, keyDef] of Object.entries(feature.configKeys)) {
330
+ const qualifiedKey = qualify(feature.name, "config", key);
331
+ if (configKeyMap.has(qualifiedKey)) {
332
+ throw new Error(
333
+ `Duplicate config key: "${qualifiedKey}" (registered by multiple features)`,
334
+ );
335
+ }
336
+ configKeyMap.set(qualifiedKey, keyDef);
337
+ }
338
+
339
+ // Jobs: scope:job:name
340
+ for (const [name, jobDef] of Object.entries(feature.jobs)) {
341
+ const qualifiedName = qualify(feature.name, "job", name);
342
+ if (jobMap.has(qualifiedName)) {
343
+ throw new Error(`Duplicate job: "${qualifiedName}" (registered by multiple features)`);
344
+ }
345
+ // runIn runtime-check. TS's JobRunIn = Exclude<RunIn, "both"> already
346
+ // rejects "both" at compile time, but dynamically-constructed jobs
347
+ // (serialized config, plugin authors using `as any`) could slip it
348
+ // past the type system. Fail loud — "both" for jobs would mean "fan
349
+ // out to both lane-queues", which over-delivers; the routing assumes
350
+ // exactly one target queue per dispatch.
351
+ // @cast-boundary schema-walk — defensive runtime-check against bypassed type-system
352
+ const runIn = (jobDef as { runIn?: unknown }).runIn;
353
+ if (runIn !== undefined && runIn !== "api" && runIn !== "worker") {
354
+ throw new Error(
355
+ `Invalid runIn "${String(runIn)}" on job "${qualifiedName}" — jobs must be pinned to a single lane ("api" or "worker"). "both" is not allowed because BullMQ queues are lane-scoped.`,
356
+ );
357
+ }
358
+ jobMap.set(qualifiedName, { ...jobDef, name: qualifiedName });
359
+ }
360
+
361
+ // Notifications: scope:notify:name
362
+ for (const [name, notifDef] of Object.entries(feature.notifications)) {
363
+ const qualifiedName = qualify(feature.name, "notify", name);
364
+ notificationMap.set(qualifiedName, {
365
+ ...notifDef,
366
+ name: qualifiedName,
367
+ trigger: { on: notifDef.trigger.on },
368
+ });
369
+ notificationFeatureMap.set(qualifiedName, feature.name);
370
+ }
371
+
372
+ // Events: scope:event:name. Migrations stay keyed by feature+short-name
373
+ // in the FeatureDefinition and get stitched into the eventUpcasterMap
374
+ // below (after ALL features are ingested) so cross-feature validation has
375
+ // the complete picture.
376
+ for (const [eventName, eventDef] of Object.entries(feature.events)) {
377
+ const qualified = qualify(feature.name, "event", eventName);
378
+ eventMap.set(qualified, { ...eventDef, name: qualified });
379
+ }
380
+
381
+ // Translations prefixed with featureName: (i18next namespace convention)
382
+ for (const [key, value] of Object.entries(feature.translations)) {
383
+ mergedTranslations[`${feature.name}:${key}`] = value;
384
+ }
385
+
386
+ // Lifecycle hooks: keyed by handler QN. featureName rides along on each
387
+ // hook entry — defineFeature sets it, the registry just appends.
388
+ // Save/delete hooks target write handlers, query hooks target query handlers.
389
+ mergeHookListQualified(preSaveHooks, feature.hooks.preSave, feature.name, "write");
390
+ mergeHookListQualified(postSaveHooks, feature.hooks.postSave, feature.name, "write");
391
+ mergeHookListQualified(preDeleteHooks, feature.hooks.preDelete, feature.name, "write");
392
+ mergeHookListQualified(postDeleteHooks, feature.hooks.postDelete, feature.name, "write");
393
+ mergeHookListQualified(preQueryHooks, feature.hooks.preQuery, feature.name, "query");
394
+
395
+ // Entity hooks: NOT prefixed, keyed by entity name
396
+ mergeHookList(entityPostSaveHooks, feature.entityHooks.postSave);
397
+ mergeHookList(entityPreDeleteHooks, feature.entityHooks.preDelete);
398
+ mergeHookList(entityPostDeleteHooks, feature.entityHooks.postDelete);
399
+
400
+ // Registrar extensions: collect definitions and usages
401
+ for (const [extName, extDef] of Object.entries(feature.registrarExtensions)) {
402
+ if (extensionMap.has(extName)) {
403
+ throw new Error(
404
+ `Duplicate registrar extension: "${extName}" (registered by multiple features)`,
405
+ );
406
+ }
407
+ extensionMap.set(extName, extDef);
408
+ }
409
+ extensionUsages.push(...feature.extensionUsages);
410
+ allReferenceData.push(...feature.referenceData);
411
+
412
+ // Metrics: validate + qualify per feature. Collisions across features are
413
+ // rejected here — two features can't both register "created_total" under
414
+ // different shapes (labels/type) because the resulting fully qualified
415
+ // names differ, but same short+feature combo would already fail in
416
+ // defineFeature. This loop catches cross-feature/extension edge cases.
417
+ for (const [shortName, def] of Object.entries(feature.metrics)) {
418
+ const fullName = buildMetricName(feature.name, shortName);
419
+ validateMetricName(fullName, def.type);
420
+ if (metricMap.has(fullName)) {
421
+ throw new Error(
422
+ `[Kumiko Observability] Metric "${fullName}" registered multiple times ` +
423
+ `(Feature: ${feature.name}). Metric names must be globally unique.`,
424
+ );
425
+ }
426
+ metricMap.set(fullName, { ...def, featureName: feature.name });
427
+ }
428
+
429
+ // Secret keys: already qualified during defineFeature (same "<feature>:<short>"
430
+ // convention used elsewhere). Reject cross-feature duplicates — extensions
431
+ // could theoretically register on another feature's namespace.
432
+ for (const def of Object.values(feature.secretKeys)) {
433
+ if (secretKeyMap.has(def.qualifiedName)) {
434
+ throw new Error(
435
+ `[Kumiko Secrets] Secret key "${def.qualifiedName}" registered multiple times. ` +
436
+ "Secret names must be globally unique across features.",
437
+ );
438
+ }
439
+ secretKeyMap.set(def.qualifiedName, def);
440
+ }
441
+
442
+ // Projections: qualified by feature name. Build the source-entity index so
443
+ // the event-store-executor can fetch matching projections in O(1) per write.
444
+ for (const [projName, projDef] of Object.entries(feature.projections)) {
445
+ const qualified = qualify(feature.name, "projection", projName);
446
+ if (projectionMap.has(qualified)) {
447
+ throw new Error(`Duplicate projection: "${qualified}" (registered by multiple features)`);
448
+ }
449
+ const stored = { ...projDef, name: qualified };
450
+ projectionMap.set(qualified, stored);
451
+ const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
452
+ for (const src of sources) {
453
+ const existing = projectionsBySource.get(src) ?? [];
454
+ existing.push(stored);
455
+ projectionsBySource.set(src, existing);
456
+ }
457
+ }
458
+
459
+ // Multi-stream projections: qualified + stored for later wiring into
460
+ // event-dispatcher. Namespace is shared with single-stream projections —
461
+ // defineFeature already catches name collisions inside one feature, but
462
+ // we also guard the cross-feature case here.
463
+ for (const [mspName, mspDef] of Object.entries(feature.multiStreamProjections)) {
464
+ const qualified = qualify(feature.name, "projection", mspName);
465
+ if (projectionMap.has(qualified) || multiStreamProjectionMap.has(qualified)) {
466
+ throw new Error(`Duplicate projection: "${qualified}" (registered by multiple features)`);
467
+ }
468
+ // runIn runtime-check. TS's RunIn union already enforces the three
469
+ // values at compile time; this guards dynamically-constructed MSPs
470
+ // (config-driven, plugin authors) that could slip a typo through.
471
+ // @cast-boundary schema-walk — defensive runtime-check against bypassed type-system
472
+ const mspRunIn = (mspDef as { runIn?: unknown }).runIn;
473
+ if (
474
+ mspRunIn !== undefined &&
475
+ mspRunIn !== "api" &&
476
+ mspRunIn !== "worker" &&
477
+ mspRunIn !== "both"
478
+ ) {
479
+ throw new Error(
480
+ `Invalid runIn "${String(mspRunIn)}" on MSP "${qualified}" — must be "api", "worker", or "both".`,
481
+ );
482
+ }
483
+ multiStreamProjectionMap.set(qualified, { ...mspDef, name: qualified });
484
+ multiStreamProjectionFeatureMap.set(qualified, feature.name);
485
+ }
486
+
487
+ // Claim keys: aggregated by qualified name. Two features cannot collide
488
+ // here (qualified by feature name), but we still guard for explicit
489
+ // correctness — the only way to hit this is a hand-built FeatureDefinition
490
+ // bypassing defineFeature's per-feature duplicate check.
491
+ const declaredShortNames = new Set<string>();
492
+ for (const def of Object.values(feature.claimKeys)) {
493
+ if (claimKeyMap.has(def.qualifiedName)) {
494
+ throw new Error(
495
+ `[Kumiko ClaimKeys] Claim key "${def.qualifiedName}" registered multiple times. ` +
496
+ "Claim short-names must be globally unique across features.",
497
+ );
498
+ }
499
+ claimKeyMap.set(def.qualifiedName, def);
500
+ declaredShortNames.add(def.shortName);
501
+ }
502
+
503
+ // Screens: qualified + stored. Uniqueness per-feature is enforced in
504
+ // defineFeature; cross-feature collisions are impossible because the
505
+ // qualified name includes the feature-prefix. The separate featureMap
506
+ // entry lets the nav resolver pause screens owned by disabled features
507
+ // in O(1) without walking every screen.
508
+ for (const [screenId, screenDef] of Object.entries(feature.screens)) {
509
+ const qualified = qualify(feature.name, "screen", screenId);
510
+ // Stored version overwrites `id` with the qualified name so callers
511
+ // never need a reverse index (NavDef → qn) during tree-walking.
512
+ // Same pattern as writeHandlerMap/projectionMap/multiStreamProjectionMap
513
+ // (see `{ ...def, name: qualified }` above). Feature-side
514
+ // `feature.screens[shortId]` keeps the short id — only the registry
515
+ // surface flips.
516
+ const stored = { ...screenDef, id: qualified };
517
+ screenMap.set(qualified, stored);
518
+ screenFeatureMap.set(qualified, feature.name);
519
+ // entity-Index nur für Screens die direkt an einer Entity hängen.
520
+ // entityList/entityEdit haben `entity`; custom + actionForm haben
521
+ // keinen entity-Bezug (custom ist opaque, actionForm hat inline
522
+ // fields ohne Entity-Reference).
523
+ if (stored.type === "entityList" || stored.type === "entityEdit") {
524
+ const existing = screensByEntity.get(stored.entity) ?? [];
525
+ existing.push(stored);
526
+ screensByEntity.set(stored.entity, existing);
527
+ }
528
+ }
529
+
530
+ // Nav entries: same qualification pattern as screens. The parent/screen
531
+ // refs are boot-validated below (after all features are ingested, so
532
+ // cross-feature parents can resolve). parent-index is built in the same
533
+ // loop because `parent` refers to a qualified name that doesn't need
534
+ // resolution — just string equality with whatever's in the target
535
+ // entry's QN.
536
+ for (const [navId, navDef] of Object.entries(feature.navs)) {
537
+ const qualified = qualify(feature.name, "nav", navId);
538
+ // See screens above — stored version carries the qualified id so
539
+ // resolveNavigation can recurse via getNavsByParent(child.id) without
540
+ // hand-building a reverse index.
541
+ const stored = { ...navDef, id: qualified };
542
+ navMap.set(qualified, stored);
543
+ navFeatureMap.set(qualified, feature.name);
544
+ if (stored.parent === undefined) {
545
+ topLevelNavs.push(stored);
546
+ } else {
547
+ const existing = navsByParent.get(stored.parent) ?? [];
548
+ existing.push(stored);
549
+ navsByParent.set(stored.parent, existing);
550
+ }
551
+ }
552
+
553
+ // Workspaces: same qualification pattern as nav/screen. Step one stores
554
+ // the workspace itself + its explicit nav list; step two (after every
555
+ // feature has been ingested) folds nav-self-assigned QNs into the same
556
+ // member list. Doing it in two passes keeps cross-feature workspace
557
+ // refs valid — a nav entry can self-assign to a workspace whose feature
558
+ // hasn't been ingested yet.
559
+ for (const [wsId, wsDef] of Object.entries(feature.workspaces)) {
560
+ const qualified = qualify(feature.name, "workspace", wsId);
561
+ const stored = { ...wsDef, id: qualified };
562
+ workspaceMap.set(qualified, stored);
563
+ workspaceFeatureMap.set(qualified, feature.name);
564
+ // Seed the membership list with the workspace's explicit nav refs in
565
+ // declaration order. Boot-validator checks the QNs resolve.
566
+ navsByWorkspace.set(qualified, [...(stored.nav ?? [])]);
567
+ if (stored.default === true) {
568
+ // Boot-validator enforces uniqueness; here we just remember the
569
+ // first one and let validateBoot complain if there's a second.
570
+ if (defaultWorkspace === undefined) {
571
+ defaultWorkspace = stored;
572
+ }
573
+ }
574
+ }
575
+
576
+ // Auth-claims hooks: order of registration is preserved. Feature name is
577
+ // captured alongside so the resolver can apply the auto-prefix at merge
578
+ // time — the feature author never ships pre-prefixed keys.
579
+ //
580
+ // If the feature declared ANY claim keys, every hook from that feature
581
+ // gets the declaredShortNames set attached. The resolver uses it to warn
582
+ // on undeclared inner-keys (typo / rename drift). Features that don't
583
+ // declare claimKeys skip the check entirely — it's opt-in.
584
+ const declaredKeys = declaredShortNames.size > 0 ? declaredShortNames : undefined;
585
+ for (const fn of feature.authClaimsHooks) {
586
+ authClaimsHooks.push({
587
+ featureName: feature.name,
588
+ fn,
589
+ ...(declaredKeys && { declaredKeys }),
590
+ });
591
+ }
592
+ }
593
+
594
+ // Pass 2 for workspaces: fold any nav-self-assigned QNs into their
595
+ // workspace's member list. We can do this safely now that every feature
596
+ // (and therefore every workspace) is in workspaceMap. Cross-feature refs
597
+ // — a nav from feature A self-assigning to a workspace from feature B —
598
+ // resolve here because B's workspace was registered in pass 1 above.
599
+ // Dedup: a nav entry that's also in r.workspace({ nav: [...] }) shouldn't
600
+ // appear twice. Boot-validator catches dangling workspace ids.
601
+ for (const [navQn, navDef] of navMap) {
602
+ if (!navDef.workspaces || navDef.workspaces.length === 0) continue;
603
+ for (const wsQn of navDef.workspaces) {
604
+ const members = navsByWorkspace.get(wsQn);
605
+ if (members === undefined) continue; // dangling — boot-validator reports
606
+ if (!members.includes(navQn)) members.push(navQn);
607
+ }
608
+ }
609
+
610
+ // Build handler → entity mapping from feature declarations (filled by tryMapEntity
611
+ // in defineFeature via the "entityName:verb" colon convention).
612
+ // Must happen before extension processing since extension preSave hooks need entity mappings.
613
+ for (const feature of features) {
614
+ for (const [handlerName, entityName] of Object.entries(feature.handlerEntityMappings)) {
615
+ const writeQn = qualify(feature.name, "write", handlerName);
616
+ const queryQn = qualify(feature.name, "query", handlerName);
617
+ if (writeHandlerMap.has(writeQn)) {
618
+ handlerEntityMap.set(writeQn, entityName);
619
+ }
620
+ if (queryHandlerMap.has(queryQn)) {
621
+ handlerEntityMap.set(queryQn, entityName);
622
+ }
623
+ }
624
+ }
625
+
626
+ // Process extension usages: call onRegister, apply extendSchema, register hooks
627
+ for (const usage of extensionUsages) {
628
+ const ext = extensionMap.get(usage.extensionName);
629
+ if (!ext) continue;
630
+
631
+ if (ext.onRegister) {
632
+ ext.onRegister(usage.entityName, usage.options);
633
+ }
634
+
635
+ // extendSchema: merge extra fields into entity definition
636
+ if (ext.extendSchema) {
637
+ const entity = entityMap.get(usage.entityName);
638
+ if (entity) {
639
+ const extraFields = ext.extendSchema(usage.entityName);
640
+ const merged = { ...entity, fields: { ...entity.fields, ...extraFields } };
641
+ entityMap.set(usage.entityName, merged);
642
+ }
643
+ }
644
+
645
+ // Extension hooks → entity hooks (fire for all writes on the entity).
646
+ // Extensions default to afterCommit phase (same default as r.hook).
647
+ //
648
+ // Owner "*" = always-enabled, not gated by feature-toggles. Extensions
649
+ // are plumbing (e.g. ownership) — the feature that declared them might
650
+ // itself be toggleable, but the extension-hook is conceptually part of
651
+ // the entity's invariants. If future requirements need extension hooks
652
+ // to also be gated, store the registering-feature on
653
+ // RegistrarExtensionRegistration and use that here.
654
+ const extOwner = "*";
655
+ if (ext.hooks) {
656
+ if (ext.hooks.postSave) {
657
+ const existing = entityPostSaveHooks.get(usage.entityName) ?? [];
658
+ existing.push({
659
+ fn: ext.hooks.postSave,
660
+ phase: HookPhases.afterCommit,
661
+ featureName: extOwner,
662
+ });
663
+ entityPostSaveHooks.set(usage.entityName, existing);
664
+ }
665
+ if (ext.hooks.preDelete) {
666
+ const existing = entityPreDeleteHooks.get(usage.entityName) ?? [];
667
+ existing.push({
668
+ fn: ext.hooks.preDelete,
669
+ phase: HookPhases.afterCommit,
670
+ featureName: extOwner,
671
+ });
672
+ entityPreDeleteHooks.set(usage.entityName, existing);
673
+ }
674
+ if (ext.hooks.postDelete) {
675
+ const existing = entityPostDeleteHooks.get(usage.entityName) ?? [];
676
+ existing.push({
677
+ fn: ext.hooks.postDelete,
678
+ phase: HookPhases.afterCommit,
679
+ featureName: extOwner,
680
+ });
681
+ entityPostDeleteHooks.set(usage.entityName, existing);
682
+ }
683
+ // preSave on extensions: store as handler hook for all CRUD handlers of this entity
684
+ if (ext.hooks.preSave) {
685
+ // Find all write handlers that belong to this entity via handlerEntityMap
686
+ for (const qualifiedHandler of writeHandlerMap.keys()) {
687
+ if (handlerEntityMap.get(qualifiedHandler) === usage.entityName) {
688
+ const existing = preSaveHooks.get(qualifiedHandler) ?? [];
689
+ existing.push({ fn: ext.hooks.preSave, featureName: extOwner });
690
+ preSaveHooks.set(qualifiedHandler, existing);
691
+ }
692
+ }
693
+ }
694
+ }
695
+ }
696
+
697
+ // Precompute: searchable/sortable fields, search includes, incoming relations
698
+ const searchableFieldsCache = new Map<string, readonly string[]>();
699
+ const sortableFieldsCache = new Map<string, readonly string[]>();
700
+ const searchIncludesCache = new Map<string, ReadonlyMap<string, readonly string[]>>();
701
+ const incomingRelationsCache = new Map<string, IncomingRelation[]>();
702
+
703
+ for (const [name, entity] of entityMap) {
704
+ const searchable: string[] = [];
705
+ const sortable: string[] = [];
706
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
707
+ if (field.type === "text" && field.searchable === true) searchable.push(fieldName);
708
+ if (field.type === "text" && field.sortable === true) sortable.push(fieldName);
709
+ if (field.type === "embedded") {
710
+ for (const [subName, subField] of Object.entries(field.schema)) {
711
+ if (subField.searchable === true) searchable.push(`${fieldName}_${subName}`);
712
+ }
713
+ }
714
+ }
715
+ searchableFieldsCache.set(name, searchable);
716
+ sortableFieldsCache.set(name, sortable);
717
+ }
718
+
719
+ // Implicit-Projection pro r.entity. Macht die Entity-Tabelle rebaubar
720
+ // ohne dass Apps eine explizite r.projection schreiben müssen.
721
+ // Naming-Convention: `<feature>:projection:<entityName>-entity` — der
722
+ // "-entity"-Suffix unterscheidet implicit von explicit-Projections und
723
+ // vermeidet Kollisionen wenn jemand z.B. eine Cross-Aggregate-Projection
724
+ // mit Entity-Name registriert.
725
+ for (const feature of features) {
726
+ for (const [entityName, entity] of Object.entries(feature.entities)) {
727
+ const def = buildImplicitProjection(feature.name, entityName, entity, qualify);
728
+ if (projectionMap.has(def.name)) {
729
+ throw new Error(
730
+ `Implicit projection "${def.name}" kollidiert mit einer explizit registrierten r.projection. ` +
731
+ `Implicit-Projections werden für jede r.entity mit "-entity"-Suffix angelegt — ` +
732
+ `benenne deine explicit projection um (z.B. "<entity>-summary") um die Kollision aufzulösen.`,
733
+ );
734
+ }
735
+ projectionMap.set(def.name, def);
736
+ const existing = projectionsBySource.get(entityName) ?? [];
737
+ existing.push(def);
738
+ projectionsBySource.set(entityName, existing);
739
+ }
740
+ }
741
+
742
+ for (const [entityName, rels] of relationMap) {
743
+ const includes = new Map<string, readonly string[]>();
744
+ for (const [relName, rel] of Object.entries(rels)) {
745
+ if ((rel.type === "belongsTo" || rel.type === "manyToMany") && rel.searchInclude?.length) {
746
+ includes.set(relName, rel.searchInclude);
747
+ }
748
+ }
749
+ searchIncludesCache.set(entityName, includes);
750
+
751
+ // Build reverse index for incoming relations
752
+ for (const [relName, rel] of Object.entries(rels)) {
753
+ const existing = incomingRelationsCache.get(rel.target) ?? [];
754
+ existing.push({ sourceEntity: entityName, relationName: relName, relation: rel });
755
+ incomingRelationsCache.set(rel.target, existing);
756
+ }
757
+ }
758
+
759
+ // Validate: handlers in features with field-access rules must be entity-mapped.
760
+ // Without entity mapping, field-level access checks are silently skipped (security gap).
761
+ // Convention: "entityName.action" = entity-bound (must resolve), "action" = standalone (no filter).
762
+ for (const feature of features) {
763
+ if (!hasFieldAccessRules(feature)) continue;
764
+
765
+ // Write handlers: ALL must be entity-mapped (security-critical, writes need field-access checks)
766
+ for (const handlerName of Object.keys(feature.writeHandlers)) {
767
+ const qualified = qualify(feature.name, "write", handlerName);
768
+ if (!handlerEntityMap.has(qualified)) {
769
+ throw new Error(
770
+ `Write handler "${qualified}" is not mapped to any entity, but feature "${feature.name}" has field-level access rules. ` +
771
+ `Name must follow "entity:action" convention (e.g. "user:create") so field-access checks apply.`,
772
+ );
773
+ }
774
+ }
775
+
776
+ // Query handlers: only those with a dash must resolve (typo protection).
777
+ // No dash = standalone query (dashboard, stats) — intentionally not entity-bound.
778
+ for (const handlerName of Object.keys(feature.queryHandlers)) {
779
+ if (!handlerName.includes(":")) continue;
780
+ const qualified = qualify(feature.name, "query", handlerName);
781
+ if (!handlerEntityMap.has(qualified)) {
782
+ throw new Error(
783
+ `Query handler "${qualified}" looks entity-bound but no matching entity exists. ` +
784
+ `Either fix the entity name, or use a name without colons for standalone queries.`,
785
+ );
786
+ }
787
+ }
788
+ }
789
+
790
+ // Validate: all relation targets must reference existing entities
791
+ for (const [entityName, rels] of relationMap) {
792
+ for (const [relName, rel] of Object.entries(rels)) {
793
+ if (!entityMap.has(rel.target)) {
794
+ throw new Error(
795
+ `Relation "${entityName}.${relName}" targets entity "${rel.target}" which does not exist`,
796
+ );
797
+ }
798
+ }
799
+ }
800
+
801
+ // Build + validate event upcaster chains. Run AFTER all features are
802
+ // ingested so r.eventMigration calls can reference events from any
803
+ // feature (same feature in practice, but the check stays lax for future
804
+ // cross-feature event packs).
805
+ for (const feature of features) {
806
+ for (const [shortName, migrations] of Object.entries(feature.eventMigrations)) {
807
+ const qualified = qualify(feature.name, "event", shortName);
808
+ const eventDef = eventMap.get(qualified);
809
+ if (!eventDef) {
810
+ throw new Error(
811
+ `Feature "${feature.name}" registered r.eventMigration for "${shortName}" ` +
812
+ `but no r.defineEvent exists for that name. Register the event first.`,
813
+ );
814
+ }
815
+ for (const m of migrations) {
816
+ if (m.toVersion > eventDef.version) {
817
+ throw new Error(
818
+ `Feature "${feature.name}" has r.eventMigration("${shortName}", ${m.fromVersion}, ${m.toVersion}) ` +
819
+ `but r.defineEvent declares only version ${eventDef.version}. ` +
820
+ `Bump the version in defineEvent to at least ${m.toVersion}, or remove the migration.`,
821
+ );
822
+ }
823
+ }
824
+ }
825
+ }
826
+
827
+ // Stitch the upcaster chain per qualified event. At this point, gaps in
828
+ // the chain (e.g. defineEvent version=3 but only a 1→2 migration exists)
829
+ // are hard errors — they would silently hand a v2-shape payload to a
830
+ // consumer expecting v3 at runtime, which is the class of bug upcasters
831
+ // are supposed to prevent.
832
+ for (const [qualified, eventDef] of eventMap) {
833
+ const chainMap = new Map<number, EventUpcastFn>();
834
+ // Locate the feature that owns this event (to pick up its migrations).
835
+ for (const feature of features) {
836
+ for (const [shortName, migs] of Object.entries(feature.eventMigrations)) {
837
+ const candidateQn = qualify(feature.name, "event", shortName);
838
+ if (candidateQn !== qualified) continue;
839
+ for (const m of migs) chainMap.set(m.fromVersion, m.transform);
840
+ }
841
+ }
842
+ if (eventDef.version > 1) {
843
+ for (let v = 1; v < eventDef.version; v++) {
844
+ if (!chainMap.has(v)) {
845
+ throw new Error(
846
+ `Event "${qualified}" declares version ${eventDef.version} but no migration ` +
847
+ `covers the step v${v} → v${v + 1}. Register r.eventMigration("${qualified.split(":").pop() ?? qualified}", ${v}, ${v + 1}, transform) ` +
848
+ `so stored v${v} payloads can be upcast on read.`,
849
+ );
850
+ }
851
+ }
852
+ }
853
+ eventUpcasterMap.set(qualified, {
854
+ currentVersion: eventDef.version,
855
+ chain: chainMap,
856
+ });
857
+ }
858
+
859
+ // Validate: every projection's source must reference a registered entity.
860
+ // A typo ("unti" instead of "unit") would otherwise be a silent no-op —
861
+ // the projection is stored but never fires because no aggregateType ever
862
+ // matches. Fail at boot so the feature author sees it immediately.
863
+ //
864
+ // Same guard extends to apply-keys: a handler for "unit.creatd" (missing
865
+ // 'e') would silently never fire. Valid apply-keys are the auto-generated
866
+ // CRUD types per source entity PLUS every domain event registered via
867
+ // r.defineEvent — an apply-handler for a domain event is how a projection
868
+ // reacts to ctx.appendEvent writes on the same aggregate stream.
869
+ const AUTO_EVENT_VERBS = ["created", "updated", "deleted", "restored"] as const;
870
+ const allDomainEventNames = new Set(eventMap.keys());
871
+ for (const [projName, projDef] of projectionMap) {
872
+ const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
873
+ const validEventTypes = new Set<string>();
874
+ // Two source-modes are legal:
875
+ //
876
+ // (a) Registered entity (r.entity(src, ...)) — the "normal" case:
877
+ // auto-lifecycle events `<src>.created/.updated/.deleted/.restored`
878
+ // fire when the event-store-executor writes, and any domain-event
879
+ // (r.defineEvent) appended onto an aggregate of that type is
880
+ // observable too.
881
+ //
882
+ // (b) Events-only source — no r.entity registered, but at least one
883
+ // apply-key must be a domain-event (not a CRUD-verb on the source
884
+ // name). Use-case: features that own an append-only event-stream
885
+ // without a CRUD lifecycle, e.g. `deliveryAttempt` (each call to
886
+ // the delivery-service produces one event on a fresh aggregate)
887
+ // or `jobRun` (BullMQ-callback-driven lifecycle, no executor).
888
+ // A "Shape-Anchor"-entity is no longer needed for this case.
889
+ const isEventsOnlySource = !sources.every((src) => entityMap.has(src));
890
+ for (const src of sources) {
891
+ if (entityMap.has(src)) {
892
+ for (const verb of AUTO_EVENT_VERBS) validEventTypes.add(`${src}.${verb}`);
893
+ }
894
+ }
895
+ // Domain events are valid apply-keys for any projection. They arrive via
896
+ // ctx.appendEvent on a specific aggregate — the runtime matches by event
897
+ // type, so a projection can observe domain events whose aggregate matches
898
+ // one of its declared sources.
899
+ for (const domainEvt of allDomainEventNames) validEventTypes.add(domainEvt);
900
+
901
+ // In events-only mode, at least one apply-key MUST be a domain-event —
902
+ // otherwise the source is simply a typo (no events will ever fire).
903
+ if (isEventsOnlySource) {
904
+ const hasAnyDomainEvent = Object.keys(projDef.apply).some((k) => allDomainEventNames.has(k));
905
+ if (!hasAnyDomainEvent) {
906
+ const unregistered = sources.filter((src) => !entityMap.has(src));
907
+ throw new Error(
908
+ `Projection "${projName}" declares source(s) [${unregistered.join(", ")}] that are not registered entities, ` +
909
+ `and has no domain-event apply-keys. This is either a typo or a missing r.defineEvent registration. ` +
910
+ `Events-only projections need at least one apply-key from r.defineEvent; ` +
911
+ `CRUD-style projections need r.entity("${unregistered[0]}", ...).`,
912
+ );
913
+ }
914
+ }
915
+
916
+ for (const applyKey of Object.keys(projDef.apply)) {
917
+ if (!validEventTypes.has(applyKey)) {
918
+ throw new Error(
919
+ `Projection "${projName}" has an apply handler for "${applyKey}" but no such event ` +
920
+ `type exists for its source(s) [${sources.join(", ")}]. ` +
921
+ `Valid types: ${[...validEventTypes].join(", ")}. ` +
922
+ `Check for a typo — auto-verbs follow "<entity>.<verb>"; ` +
923
+ `domain events follow "<feature>:event:<short-name>" (see r.defineEvent).`,
924
+ );
925
+ }
926
+ }
927
+ }
928
+
929
+ // Validate: all required features must be registered
930
+ for (const feature of features) {
931
+ for (const required of feature.requires) {
932
+ if (!featureMap.has(required)) {
933
+ throw new Error(
934
+ `Feature "${feature.name}" requires feature "${required}" which is not registered`,
935
+ );
936
+ }
937
+ }
938
+ }
939
+
940
+ // Resolve notification triggers and register postSave hooks
941
+ // Done after all features are registered so cross-feature triggers work
942
+ const allHandlerNames = new Set([...writeHandlerMap.keys(), ...queryHandlerMap.keys()]);
943
+ for (const [qualifiedName, notifDef] of notificationMap) {
944
+ // Both maps are populated in lockstep — same key-set by construction.
945
+ const featureName = notificationFeatureMap.get(qualifiedName) as string;
946
+ // I'll try the easy path first: if the trigger is already a fully qualified QN
947
+ // (cross-feature), I take it as-is. Otherwise I qualify with the own feature —
948
+ // as a write handler first (the common case), then as a query. If nothing
949
+ // matches by then, it was a typo and I'll say so.
950
+ let triggerOn: string;
951
+ if (allHandlerNames.has(notifDef.trigger.on)) {
952
+ triggerOn = notifDef.trigger.on;
953
+ } else {
954
+ // Try as write handler first (most common), then query
955
+ const writeQn = qualify(featureName, "write", notifDef.trigger.on);
956
+ const queryQn = qualify(featureName, "query", notifDef.trigger.on);
957
+ if (allHandlerNames.has(writeQn)) {
958
+ triggerOn = writeQn;
959
+ } else if (allHandlerNames.has(queryQn)) {
960
+ triggerOn = queryQn;
961
+ } else {
962
+ throw new Error(
963
+ `Notification "${qualifiedName}" triggers on "${notifDef.trigger.on}" ` +
964
+ `but no handler with that name exists. ` +
965
+ `Tried: "${notifDef.trigger.on}", "${writeQn}", and "${queryQn}"`,
966
+ );
967
+ }
968
+ }
969
+ // Update the stored definition with resolved trigger
970
+ notificationMap.set(qualifiedName, { ...notifDef, trigger: { on: triggerOn } });
971
+
972
+ if (!postSaveHooks.has(triggerOn)) postSaveHooks.set(triggerOn, []);
973
+ postSaveHooks.get(triggerOn)?.push({
974
+ phase: HookPhases.afterCommit,
975
+ featureName,
976
+ fn: async (result, context) => {
977
+ if (!context.notify) {
978
+ context.log?.debug(
979
+ `notification ${qualifiedName}: skipping — no notify function configured on context`,
980
+ );
981
+ return;
982
+ }
983
+ const to = notifDef.recipient(result);
984
+ if (to === null) {
985
+ context.log?.debug(
986
+ `notification ${qualifiedName}: skipping — recipient resolver returned null for result ${result.id}`,
987
+ );
988
+ return;
989
+ }
990
+ const data = notifDef.data(result);
991
+ await context.notify(qualifiedName, { to, data });
992
+ },
993
+ });
994
+ }
995
+
996
+ // Validate: lifecycle hook targets must reference existing handlers
997
+ const allHandlers = allHandlerNames;
998
+ const lifecycleHookMaps = [
999
+ { map: preSaveHooks, phase: "preSave" },
1000
+ { map: postSaveHooks, phase: "postSave" },
1001
+ { map: preDeleteHooks, phase: "preDelete" },
1002
+ { map: postDeleteHooks, phase: "postDelete" },
1003
+ { map: preQueryHooks, phase: "preQuery" },
1004
+ ] as const;
1005
+
1006
+ // I'd rather warn you now at boot than have you open a ticket three weeks from now
1007
+ // saying "my hook isn't firing". One typo in the target and the thing goes silent.
1008
+ for (const { map, phase } of lifecycleHookMaps) {
1009
+ for (const hookTarget of map.keys()) {
1010
+ if (!allHandlers.has(hookTarget)) {
1011
+ throw new Error(
1012
+ `${phase} hook targets "${hookTarget}" but no handler with that name exists. ` +
1013
+ `Check for typos — the hook will never fire.`,
1014
+ );
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ // Validate: job event triggers must reference existing handlers.
1020
+ // Multi-Trigger-Form: jeden Eintrag im Array gegen allHandlers prüfen,
1021
+ // auch wenn nur einer fehlt fail-fast.
1022
+ for (const [jobName, jobDef] of jobMap) {
1023
+ if (!("on" in jobDef.trigger)) continue;
1024
+ const triggerOn = jobDef.trigger.on;
1025
+ const triggers = Array.isArray(triggerOn) ? triggerOn : [triggerOn];
1026
+ for (const t of triggers) {
1027
+ const rawName = resolveName(t);
1028
+ if (allHandlers.has(rawName)) continue;
1029
+ throw new Error(
1030
+ `Job "${jobName}" triggers on "${rawName}" but no handler with that name exists`,
1031
+ );
1032
+ }
1033
+ }
1034
+
1035
+ // Validate: extension usages must reference existing extensions
1036
+ for (const usage of extensionUsages) {
1037
+ if (!extensionMap.has(usage.extensionName)) {
1038
+ throw new Error(
1039
+ `Extension usage "${usage.extensionName}" on entity "${usage.entityName}" references an extension that does not exist`,
1040
+ );
1041
+ }
1042
+ }
1043
+
1044
+ // Pre-compute: any handler with a rateLimit option? Keeps the boot
1045
+ // path able to short-circuit the RateLimitResolver wiring (and its
1046
+ // Lua-script registration on Redis) when nobody opted in.
1047
+ const hasRateLimitedHandlerCached = (() => {
1048
+ for (const h of writeHandlerMap.values()) if (h.rateLimit !== undefined) return true;
1049
+ for (const h of queryHandlerMap.values()) if (h.rateLimit !== undefined) return true;
1050
+ return false;
1051
+ })();
1052
+
1053
+ return {
1054
+ features: featureMap,
1055
+
1056
+ getFeature(name: string): FeatureDefinition | undefined {
1057
+ return featureMap.get(name);
1058
+ },
1059
+
1060
+ hasRateLimitedHandler(): boolean {
1061
+ return hasRateLimitedHandlerCached;
1062
+ },
1063
+
1064
+ getEntity(name: string): EntityDefinition | undefined {
1065
+ return entityMap.get(name);
1066
+ },
1067
+
1068
+ getWriteHandler(name: string): WriteHandlerDef | undefined {
1069
+ return writeHandlerMap.get(name);
1070
+ },
1071
+
1072
+ getQueryHandler(name: string): QueryHandlerDef | undefined {
1073
+ return queryHandlerMap.get(name);
1074
+ },
1075
+
1076
+ getSearchableFields(entityName: string): readonly string[] {
1077
+ return searchableFieldsCache.get(entityName) ?? [];
1078
+ },
1079
+
1080
+ getSortableFields(entityName: string): readonly string[] {
1081
+ return sortableFieldsCache.get(entityName) ?? [];
1082
+ },
1083
+
1084
+ getRelations(entityName: string): EntityRelations {
1085
+ return (relationMap.get(entityName) ?? {}) as EntityRelations;
1086
+ },
1087
+
1088
+ getSearchIncludes(entityName: string): ReadonlyMap<string, readonly string[]> {
1089
+ return searchIncludesCache.get(entityName) ?? new Map();
1090
+ },
1091
+
1092
+ getIncomingRelations(entityName: string): readonly IncomingRelation[] {
1093
+ return incomingRelationsCache.get(entityName) ?? [];
1094
+ },
1095
+
1096
+ getPreSaveHooks(
1097
+ name: string,
1098
+ effectiveFeatures?: ReadonlySet<string>,
1099
+ ): readonly PreSaveHookFn[] {
1100
+ return filterOwned(preSaveHooks.get(name), effectiveFeatures);
1101
+ },
1102
+
1103
+ getPostSaveHooks(
1104
+ name: string,
1105
+ phase?: HookPhase,
1106
+ effectiveFeatures?: ReadonlySet<string>,
1107
+ ): readonly PostSaveHookFn[] {
1108
+ return filterByPhase(postSaveHooks.get(name), phase, effectiveFeatures);
1109
+ },
1110
+
1111
+ getPreDeleteHooks(
1112
+ name: string,
1113
+ phase?: HookPhase,
1114
+ effectiveFeatures?: ReadonlySet<string>,
1115
+ ): readonly PreDeleteHookFn[] {
1116
+ return filterByPhase(preDeleteHooks.get(name), phase, effectiveFeatures);
1117
+ },
1118
+
1119
+ getPostDeleteHooks(
1120
+ name: string,
1121
+ phase?: HookPhase,
1122
+ effectiveFeatures?: ReadonlySet<string>,
1123
+ ): readonly PostDeleteHookFn[] {
1124
+ return filterByPhase(postDeleteHooks.get(name), phase, effectiveFeatures);
1125
+ },
1126
+
1127
+ getPreQueryHooks(
1128
+ name: string,
1129
+ effectiveFeatures?: ReadonlySet<string>,
1130
+ ): readonly PreQueryHookFn[] {
1131
+ return filterOwned(preQueryHooks.get(name), effectiveFeatures);
1132
+ },
1133
+
1134
+ // Entity hooks — fire for all writes on an entity
1135
+ getEntityPostSaveHooks(
1136
+ entityName: string,
1137
+ phase?: HookPhase,
1138
+ effectiveFeatures?: ReadonlySet<string>,
1139
+ ): readonly PostSaveHookFn[] {
1140
+ return filterByPhase(entityPostSaveHooks.get(entityName), phase, effectiveFeatures);
1141
+ },
1142
+
1143
+ getEntityPreDeleteHooks(
1144
+ entityName: string,
1145
+ phase?: HookPhase,
1146
+ effectiveFeatures?: ReadonlySet<string>,
1147
+ ): readonly PreDeleteHookFn[] {
1148
+ return filterByPhase(entityPreDeleteHooks.get(entityName), phase, effectiveFeatures);
1149
+ },
1150
+
1151
+ getEntityPostDeleteHooks(
1152
+ entityName: string,
1153
+ phase?: HookPhase,
1154
+ effectiveFeatures?: ReadonlySet<string>,
1155
+ ): readonly PostDeleteHookFn[] {
1156
+ return filterByPhase(entityPostDeleteHooks.get(entityName), phase, effectiveFeatures);
1157
+ },
1158
+
1159
+ getAllTranslations(): TranslationKeys {
1160
+ return mergedTranslations;
1161
+ },
1162
+
1163
+ getHandlerEntity(qualifiedHandler: string): string | undefined {
1164
+ return handlerEntityMap.get(qualifiedHandler);
1165
+ },
1166
+
1167
+ isHandlerSystemScoped(qualifiedHandler: string): boolean {
1168
+ const featureName = handlerFeatureMap.get(qualifiedHandler);
1169
+ if (!featureName) return false;
1170
+ return featureMap.get(featureName)?.systemScope ?? false;
1171
+ },
1172
+
1173
+ getHandlerFeature(qualifiedHandler: string): string | undefined {
1174
+ return handlerFeatureMap.get(qualifiedHandler);
1175
+ },
1176
+
1177
+ getAllMetrics() {
1178
+ return metricMap;
1179
+ },
1180
+
1181
+ getAllSecretKeys(): ReadonlyMap<string, SecretKeyDefinition> {
1182
+ return secretKeyMap;
1183
+ },
1184
+
1185
+ getSecretKey(qualifiedName: string): SecretKeyDefinition | undefined {
1186
+ return secretKeyMap.get(qualifiedName);
1187
+ },
1188
+
1189
+ getConfigKey(qualifiedKey: string): ConfigKeyDefinition | undefined {
1190
+ return configKeyMap.get(qualifiedKey);
1191
+ },
1192
+
1193
+ getAllConfigKeys(): ReadonlyMap<string, ConfigKeyDefinition> {
1194
+ return configKeyMap;
1195
+ },
1196
+
1197
+ getJob(qualifiedName: string): JobDefinition | undefined {
1198
+ return jobMap.get(qualifiedName);
1199
+ },
1200
+
1201
+ getAllJobs(): ReadonlyMap<string, JobDefinition> {
1202
+ return jobMap;
1203
+ },
1204
+
1205
+ getEvent(qualifiedName: string): EventDef | undefined {
1206
+ return eventMap.get(qualifiedName);
1207
+ },
1208
+
1209
+ getEventUpcasters() {
1210
+ return eventUpcasterMap;
1211
+ },
1212
+
1213
+ getExtension(name: string): RegistrarExtensionDef | undefined {
1214
+ return extensionMap.get(name);
1215
+ },
1216
+
1217
+ getExtensionUsages(extensionName: string): readonly RegistrarExtensionRegistration[] {
1218
+ return extensionUsages.filter((u) => u.extensionName === extensionName);
1219
+ },
1220
+
1221
+ getAllNotifications(): ReadonlyMap<string, NotificationDefinition> {
1222
+ return notificationMap;
1223
+ },
1224
+
1225
+ getAllReferenceData(): readonly ReferenceDataDef[] {
1226
+ return allReferenceData;
1227
+ },
1228
+
1229
+ getProjectionsForSource(entityName: string): readonly ProjectionDefinition[] {
1230
+ return projectionsBySource.get(entityName) ?? [];
1231
+ },
1232
+
1233
+ getAllProjections(): ReadonlyMap<string, ProjectionDefinition> {
1234
+ return projectionMap;
1235
+ },
1236
+
1237
+ getAllMultiStreamProjections(): ReadonlyMap<string, MultiStreamProjectionDefinition> {
1238
+ return multiStreamProjectionMap;
1239
+ },
1240
+
1241
+ getMultiStreamProjectionFeature(qualifiedName: string): string | undefined {
1242
+ return multiStreamProjectionFeatureMap.get(qualifiedName);
1243
+ },
1244
+
1245
+ getAuthClaimsHooks(): readonly AuthClaimsHookDef[] {
1246
+ return authClaimsHooks;
1247
+ },
1248
+
1249
+ getAllClaimKeys(): ReadonlyMap<string, ClaimKeyDefinition> {
1250
+ return claimKeyMap;
1251
+ },
1252
+
1253
+ getClaimKey(qualifiedName: string): ClaimKeyDefinition | undefined {
1254
+ return claimKeyMap.get(qualifiedName);
1255
+ },
1256
+
1257
+ getAllScreens(): ReadonlyMap<string, ScreenDefinition> {
1258
+ return screenMap;
1259
+ },
1260
+
1261
+ getScreen(qualifiedName: string): ScreenDefinition | undefined {
1262
+ return screenMap.get(qualifiedName);
1263
+ },
1264
+
1265
+ getScreenFeature(qualifiedName: string): string | undefined {
1266
+ return screenFeatureMap.get(qualifiedName);
1267
+ },
1268
+
1269
+ getScreensByEntity(entityName: string): readonly ScreenDefinition[] {
1270
+ return screensByEntity.get(entityName) ?? [];
1271
+ },
1272
+
1273
+ getAllNavs(): ReadonlyMap<string, NavDefinition> {
1274
+ return navMap;
1275
+ },
1276
+
1277
+ getNav(qualifiedName: string): NavDefinition | undefined {
1278
+ return navMap.get(qualifiedName);
1279
+ },
1280
+
1281
+ getNavFeature(qualifiedName: string): string | undefined {
1282
+ return navFeatureMap.get(qualifiedName);
1283
+ },
1284
+
1285
+ getNavsByParent(parentQualifiedName: string): readonly NavDefinition[] {
1286
+ return navsByParent.get(parentQualifiedName) ?? [];
1287
+ },
1288
+
1289
+ getTopLevelNavs(): readonly NavDefinition[] {
1290
+ return topLevelNavs;
1291
+ },
1292
+
1293
+ getAllWorkspaces(): ReadonlyMap<string, WorkspaceDefinition> {
1294
+ return workspaceMap;
1295
+ },
1296
+
1297
+ getWorkspace(qualifiedName: string): WorkspaceDefinition | undefined {
1298
+ return workspaceMap.get(qualifiedName);
1299
+ },
1300
+
1301
+ getWorkspaceFeature(qualifiedName: string): string | undefined {
1302
+ return workspaceFeatureMap.get(qualifiedName);
1303
+ },
1304
+
1305
+ getWorkspaceNavs(workspaceQualifiedName: string): readonly string[] {
1306
+ return navsByWorkspace.get(workspaceQualifiedName) ?? [];
1307
+ },
1308
+
1309
+ getDefaultWorkspace(): WorkspaceDefinition | undefined {
1310
+ return defaultWorkspace;
1311
+ },
1312
+ };
1313
+ }
1314
+
1315
+ /** Returns true if any entity in the feature has field-level access rules (read or write). */
1316
+ function hasFieldAccessRules(feature: FeatureDefinition): boolean {
1317
+ for (const entity of Object.values(feature.entities)) {
1318
+ for (const field of Object.values(entity.fields)) {
1319
+ if (field.access?.read?.length || field.access?.write?.length) {
1320
+ return true;
1321
+ }
1322
+ }
1323
+ }
1324
+ return false;
1325
+ }