@cosmicdrift/kumiko-framework 1.0.0 → 2.0.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 (424) hide show
  1. package/README.md +9 -38
  2. package/package.json +21 -2
  3. package/src/__tests__/anonymous-access.integration.test.ts +185 -0
  4. package/src/__tests__/consumer-cli.integration.test.ts +172 -0
  5. package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
  6. package/src/__tests__/full-stack.integration.test.ts +1 -1
  7. package/src/__tests__/schema-cli-temporal-polyfill.test.ts +48 -0
  8. package/src/__tests__/schema-cli.integration.test.ts +50 -0
  9. package/src/__tests__/store-table.integration.test.ts +93 -0
  10. package/src/api/__tests__/api.test.ts +373 -0
  11. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  13. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +253 -0
  14. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +222 -0
  15. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +249 -0
  16. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +202 -0
  17. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  18. package/src/api/__tests__/batch.integration.test.ts +101 -11
  19. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  20. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  21. package/src/api/__tests__/jwt.test.ts +200 -1
  22. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +50 -0
  23. package/src/api/__tests__/pat-scope.test.ts +36 -0
  24. package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
  25. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +138 -0
  26. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  27. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  28. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  29. package/src/api/__tests__/sse-broker.test.ts +57 -0
  30. package/src/api/__tests__/sse-route.test.ts +4 -0
  31. package/src/api/api-constants.ts +11 -0
  32. package/src/api/auth-middleware.ts +248 -40
  33. package/src/api/auth-routes.ts +576 -95
  34. package/src/api/index.ts +13 -4
  35. package/src/api/jwt.ts +170 -11
  36. package/src/api/pat-scope.ts +14 -0
  37. package/src/api/pii-leak-guard.ts +47 -0
  38. package/src/api/request-context.ts +3 -0
  39. package/src/api/request-id-middleware.ts +2 -0
  40. package/src/api/routes.ts +169 -2
  41. package/src/api/server.ts +112 -16
  42. package/src/api/sse-broker.ts +39 -0
  43. package/src/bun-db/__tests__/PATTERN.md +0 -1
  44. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
  45. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  46. package/src/bun-db/__tests__/write-brand.test.ts +21 -0
  47. package/src/bun-db/connection.ts +3 -3
  48. package/src/bun-db/index.ts +2 -0
  49. package/src/bun-db/query.ts +105 -32
  50. package/src/consumer-cli.ts +134 -0
  51. package/src/crypto/__tests__/blind-index.test.ts +130 -0
  52. package/src/crypto/__tests__/event-pii.test.ts +161 -0
  53. package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
  54. package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
  55. package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
  56. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
  57. package/src/crypto/__tests__/pii-field-encryption.test.ts +376 -0
  58. package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
  59. package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
  60. package/src/crypto/blind-index.ts +118 -0
  61. package/src/crypto/event-pii.ts +70 -0
  62. package/src/crypto/in-memory-kms-adapter.ts +50 -0
  63. package/src/crypto/index.ts +67 -0
  64. package/src/crypto/kms-adapter.ts +2 -0
  65. package/src/crypto/pg-kms-adapter.ts +295 -0
  66. package/src/crypto/pii-field-encryption.ts +248 -0
  67. package/src/crypto/request-kms-cache.ts +38 -0
  68. package/src/crypto/subject-resolver.ts +91 -0
  69. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +191 -0
  70. package/src/db/__tests__/blind-index.integration.test.ts +248 -0
  71. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  72. package/src/db/__tests__/collect-table-metas.test.ts +10 -10
  73. package/src/db/__tests__/config-seed.integration.test.ts +13 -5
  74. package/src/db/__tests__/dialect-instant.test.ts +1 -4
  75. package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
  76. package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
  77. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  78. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +402 -0
  79. package/src/db/__tests__/event-store-executor.integration.test.ts +299 -29
  80. package/src/db/__tests__/feature-table-sources.test.ts +34 -0
  81. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
  82. package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
  83. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  84. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  85. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  86. package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
  87. package/src/db/__tests__/rebuild-marker.test.ts +13 -3
  88. package/src/db/__tests__/replay-migration-sql.test.ts +384 -0
  89. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  90. package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
  91. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
  92. package/src/db/__tests__/tenant-db-where-merge.test.ts +49 -3
  93. package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
  94. package/src/db/api.ts +2 -2
  95. package/src/db/apply-entity-event.ts +18 -14
  96. package/src/db/blind-index-cleanup.ts +55 -0
  97. package/src/db/bun-provider.ts +2 -2
  98. package/src/db/collect-table-metas.ts +6 -7
  99. package/src/db/config-seed.ts +9 -9
  100. package/src/db/connection.ts +7 -12
  101. package/src/db/cursor.ts +1 -18
  102. package/src/db/dialect.ts +20 -26
  103. package/src/db/entity-field-encryption.ts +81 -24
  104. package/src/db/entity-table-meta-types.ts +2 -0
  105. package/src/db/entity-table-meta.ts +63 -90
  106. package/src/db/event-store-executor-context.ts +404 -0
  107. package/src/db/event-store-executor-read.ts +275 -0
  108. package/src/db/event-store-executor-write.ts +644 -0
  109. package/src/db/event-store-executor.ts +28 -1155
  110. package/src/db/feature-table-sources.ts +6 -5
  111. package/src/db/index.ts +20 -3
  112. package/src/db/located-timestamp.ts +4 -0
  113. package/src/db/migrate-generator.ts +39 -6
  114. package/src/db/migrate-runner.ts +107 -11
  115. package/src/db/pg-error.ts +9 -1
  116. package/src/db/postgres-provider.ts +2 -2
  117. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  118. package/src/db/queries/backfill-pii.ts +277 -0
  119. package/src/db/queries/ddl.ts +45 -0
  120. package/src/db/queries/event-consumer.ts +35 -2
  121. package/src/db/queries/event-store.ts +126 -19
  122. package/src/db/queries/projection-rebuild.ts +22 -8
  123. package/src/db/queries/shadow-swap.ts +183 -0
  124. package/src/db/queries/test-stack.ts +4 -30
  125. package/src/db/query-api.ts +1 -0
  126. package/src/db/query.ts +1 -0
  127. package/src/db/rebuild-marker.ts +18 -2
  128. package/src/db/reference-data.ts +2 -3
  129. package/src/db/replay-migration-sql.ts +257 -0
  130. package/src/db/schema-inspection.ts +1 -1
  131. package/src/db/table-builder.ts +102 -71
  132. package/src/db/tenant-db.ts +15 -53
  133. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
  134. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  135. package/src/engine/__tests__/boot-validator-dashboard.test.ts +237 -0
  136. package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
  137. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -69
  138. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
  139. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
  140. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +246 -4
  141. package/src/engine/__tests__/boot-validator.test.ts +219 -15
  142. package/src/engine/__tests__/build-app-schema.test.ts +82 -0
  143. package/src/engine/__tests__/build-config-feature-schema.test.ts +125 -0
  144. package/src/engine/__tests__/build-target.test.ts +3 -11
  145. package/src/engine/__tests__/codemod-pipeline.test.ts +152 -21
  146. package/src/engine/__tests__/config-helpers.test.ts +16 -0
  147. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  148. package/src/engine/__tests__/define-roles.test.ts +21 -0
  149. package/src/engine/__tests__/engine.test.ts +163 -1
  150. package/src/engine/__tests__/entity-handlers.test.ts +80 -0
  151. package/src/engine/__tests__/event-migration-declarative.test.ts +65 -0
  152. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  153. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  154. package/src/engine/__tests__/factories-time.test.ts +2 -66
  155. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  156. package/src/engine/__tests__/feature-manifest.test.ts +31 -1
  157. package/src/engine/__tests__/field-access.test.ts +23 -1
  158. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  159. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  160. package/src/engine/__tests__/nav.test.ts +86 -1
  161. package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
  162. package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
  163. package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
  164. package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
  165. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
  166. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  167. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  168. package/src/engine/__tests__/registry.test.ts +144 -0
  169. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  170. package/src/engine/__tests__/screen.test.ts +147 -1
  171. package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
  172. package/src/engine/__tests__/store-table.test.ts +229 -0
  173. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  174. package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +7 -95
  175. package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
  176. package/src/engine/boot-validator/__tests__/config-deps.test.ts +92 -0
  177. package/src/engine/boot-validator/action-wiring.ts +149 -0
  178. package/src/engine/boot-validator/boot-check.ts +21 -0
  179. package/src/engine/boot-validator/config-deps.ts +40 -4
  180. package/src/engine/boot-validator/entity-handler.ts +20 -21
  181. package/src/engine/boot-validator/entity-list-screens.ts +88 -0
  182. package/src/engine/boot-validator/gdpr-storage.ts +0 -20
  183. package/src/engine/boot-validator/i18n-keys.ts +58 -4
  184. package/src/engine/boot-validator/index.ts +33 -12
  185. package/src/engine/boot-validator/nav.ts +125 -0
  186. package/src/engine/boot-validator/pii-retention.ts +95 -10
  187. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +214 -191
  188. package/src/engine/boot-validator/workspaces.ts +68 -0
  189. package/src/engine/build-app-schema.ts +16 -0
  190. package/src/engine/build-config-feature-schema.ts +44 -7
  191. package/src/engine/codemod/pipeline-codemod.ts +5 -5
  192. package/src/engine/config-helpers.ts +15 -0
  193. package/src/engine/constants.ts +32 -6
  194. package/src/engine/create-app.ts +11 -0
  195. package/src/engine/define-feature.ts +95 -947
  196. package/src/engine/define-handler.ts +28 -94
  197. package/src/engine/define-workflow.ts +1 -1
  198. package/src/engine/effective-features.ts +12 -2
  199. package/src/engine/entity-handlers.ts +76 -11
  200. package/src/engine/extensions/tenant-data.ts +19 -0
  201. package/src/engine/extensions/user-data.ts +29 -2
  202. package/src/engine/factories.ts +8 -49
  203. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +26 -29
  204. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  205. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  206. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  207. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  208. package/src/engine/feature-ast/__tests__/parse.test.ts +1640 -160
  209. package/src/engine/feature-ast/__tests__/patch.test.ts +206 -12
  210. package/src/engine/feature-ast/__tests__/patcher.test.ts +20 -23
  211. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +380 -5
  212. package/src/engine/feature-ast/extractors/events.ts +322 -0
  213. package/src/engine/feature-ast/extractors/handlers.ts +234 -0
  214. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  215. package/src/engine/feature-ast/extractors/index.ts +34 -31
  216. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  217. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  218. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  219. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  220. package/src/engine/feature-ast/extractors/round6.ts +2 -36
  221. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  222. package/src/engine/feature-ast/index.ts +2 -4
  223. package/src/engine/feature-ast/parse.ts +130 -23
  224. package/src/engine/feature-ast/patch.ts +39 -50
  225. package/src/engine/feature-ast/patcher.ts +37 -38
  226. package/src/engine/feature-ast/patterns.ts +50 -62
  227. package/src/engine/feature-ast/render.ts +67 -44
  228. package/src/engine/feature-builder-state.ts +168 -0
  229. package/src/engine/feature-config-events-jobs.ts +403 -0
  230. package/src/engine/feature-entity-handlers.ts +208 -0
  231. package/src/engine/feature-manifest.ts +2 -1
  232. package/src/engine/feature-ui-extensions.ts +500 -0
  233. package/src/engine/field-access.ts +13 -2
  234. package/src/engine/field-helpers.ts +31 -0
  235. package/src/engine/handler-helpers.ts +26 -0
  236. package/src/engine/hook-helpers.ts +16 -0
  237. package/src/engine/index.ts +23 -7
  238. package/src/engine/membership-roles.ts +13 -0
  239. package/src/engine/object-form.ts +27 -0
  240. package/src/engine/ownership.ts +26 -79
  241. package/src/engine/pattern-library/__tests__/library.test.ts +7 -20
  242. package/src/engine/pattern-library/library.ts +44 -1152
  243. package/src/engine/pattern-library/mixed-schemas.ts +450 -0
  244. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  245. package/src/engine/pattern-library/shared-fields.ts +75 -0
  246. package/src/engine/pattern-library/static-schemas.ts +456 -0
  247. package/src/engine/pipeline.ts +6 -11
  248. package/src/engine/registry-facade.ts +362 -0
  249. package/src/engine/registry-ingest.ts +478 -0
  250. package/src/engine/registry-state.ts +388 -0
  251. package/src/engine/registry-validate.ts +642 -0
  252. package/src/engine/registry.ts +78 -1658
  253. package/src/engine/run-pipeline.ts +1 -1
  254. package/src/engine/schema-builder.ts +1 -0
  255. package/src/engine/screen-helpers.ts +54 -0
  256. package/src/engine/soft-delete-cleanup.ts +6 -2
  257. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  258. package/src/engine/steps/_duration-utils.ts +2 -0
  259. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  260. package/src/engine/tier-resolver-extension.ts +3 -2
  261. package/src/engine/types/config.ts +2 -482
  262. package/src/engine/types/define-handler.ts +2 -0
  263. package/src/engine/types/entity-handlers.ts +2 -0
  264. package/src/engine/types/event-type-map.ts +1 -37
  265. package/src/engine/types/feature.ts +2 -972
  266. package/src/engine/types/fields.ts +2 -675
  267. package/src/engine/types/handlers.ts +2 -774
  268. package/src/engine/types/hooks.ts +2 -184
  269. package/src/engine/types/http-route.ts +1 -54
  270. package/src/engine/types/identifiers.ts +1 -47
  271. package/src/engine/types/index.ts +86 -40
  272. package/src/engine/types/nav.ts +2 -63
  273. package/src/engine/types/ownership.ts +2 -0
  274. package/src/engine/types/projection.ts +2 -138
  275. package/src/engine/types/relations.ts +1 -51
  276. package/src/engine/types/screen.ts +2 -574
  277. package/src/engine/types/step.ts +2 -334
  278. package/src/engine/types/target-ref.ts +1 -21
  279. package/src/engine/types/tree-node.ts +1 -132
  280. package/src/engine/types/workspace.ts +2 -49
  281. package/src/engine/validate-projection-allowlist.ts +6 -6
  282. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +120 -1
  283. package/src/entrypoint/index.ts +49 -6
  284. package/src/errors/classes.ts +22 -1
  285. package/src/errors/field-issue.ts +0 -3
  286. package/src/errors/index.ts +1 -1
  287. package/src/errors/write-error-info.ts +10 -22
  288. package/src/es-ops/README.md +1 -1
  289. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  290. package/src/es-ops/context.ts +4 -2
  291. package/src/es-ops/types.ts +8 -1
  292. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  293. package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
  294. package/src/event-store/__tests__/event-store.integration.test.ts +201 -0
  295. package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
  296. package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
  297. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +58 -0
  298. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  299. package/src/event-store/admin-api.ts +11 -4
  300. package/src/event-store/errors.ts +2 -35
  301. package/src/event-store/event-store.ts +64 -78
  302. package/src/event-store/events-schema.ts +11 -13
  303. package/src/event-store/index.ts +18 -3
  304. package/src/event-store/rebuild-dead-letter.ts +111 -0
  305. package/src/event-store/snapshot.ts +63 -40
  306. package/src/event-store/types.ts +2 -0
  307. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  308. package/src/files/__tests__/file-ref-entity.test.ts +8 -0
  309. package/src/files/__tests__/files.integration.test.ts +24 -0
  310. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  311. package/src/files/__tests__/local-provider.test.ts +31 -0
  312. package/src/files/__tests__/provider-resolver.test.ts +70 -0
  313. package/src/files/__tests__/write-stream.test.ts +13 -0
  314. package/src/files/file-handle.ts +2 -19
  315. package/src/files/file-ref-entity.ts +2 -2
  316. package/src/files/file-routes.ts +13 -0
  317. package/src/files/index.ts +1 -1
  318. package/src/files/local-provider.ts +27 -8
  319. package/src/files/provider-resolver.ts +31 -20
  320. package/src/files/types.ts +13 -55
  321. package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
  322. package/src/i18n/required-surface-keys.ts +120 -1
  323. package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
  324. package/src/jobs/__tests__/jobs.integration.test.ts +329 -3
  325. package/src/jobs/job-runner.ts +82 -12
  326. package/src/logging/types.ts +1 -7
  327. package/src/migrations/pending-rebuilds.ts +1 -1
  328. package/src/observability/__tests__/observability.integration.test.ts +4 -1
  329. package/src/observability/__tests__/recording-tracer.test.ts +6 -4
  330. package/src/observability/index.ts +2 -0
  331. package/src/observability/noop-provider.ts +0 -9
  332. package/src/observability/recording-tracer.ts +0 -12
  333. package/src/observability/standard-metrics.ts +64 -2
  334. package/src/observability/types/index.ts +1 -29
  335. package/src/observability/types/metric.ts +1 -56
  336. package/src/observability/types/provider.ts +1 -32
  337. package/src/observability/types/span.ts +1 -64
  338. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  339. package/src/pipeline/__tests__/dispatcher.test.ts +366 -1
  340. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  341. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  342. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +263 -0
  343. package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
  344. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  345. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +308 -77
  346. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  347. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  348. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +80 -2
  349. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  350. package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
  351. package/src/pipeline/dispatch-batch.ts +187 -0
  352. package/src/pipeline/dispatch-query.ts +165 -0
  353. package/src/pipeline/dispatch-shared.ts +826 -0
  354. package/src/pipeline/dispatch-stream.ts +90 -0
  355. package/src/pipeline/dispatch-write.ts +451 -0
  356. package/src/pipeline/dispatcher-utils.ts +1 -1
  357. package/src/pipeline/dispatcher.ts +44 -1372
  358. package/src/pipeline/entity-cache.ts +2 -33
  359. package/src/pipeline/event-consumer-state.ts +30 -2
  360. package/src/pipeline/event-dispatcher-admin.ts +293 -0
  361. package/src/pipeline/event-dispatcher-delivery.ts +305 -0
  362. package/src/pipeline/event-dispatcher.ts +85 -542
  363. package/src/pipeline/index.ts +2 -0
  364. package/src/pipeline/msp-rebuild.ts +42 -3
  365. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  366. package/src/pipeline/projection-rebuild.ts +105 -3
  367. package/src/pipeline/system-hooks.ts +144 -1
  368. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  369. package/src/rate-limit/resolver.ts +16 -32
  370. package/src/schema-cli.ts +76 -16
  371. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +207 -185
  372. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  373. package/src/search/__tests__/reindex-entity.integration.test.ts +144 -0
  374. package/src/search/index.ts +6 -0
  375. package/src/search/meilisearch-adapter.ts +13 -12
  376. package/src/search/reindex-entity.ts +177 -0
  377. package/src/search/types.ts +1 -39
  378. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  379. package/src/secrets/__tests__/envelope-cipher.test.ts +59 -0
  380. package/src/secrets/__tests__/envelope.test.ts +1 -1
  381. package/src/secrets/dek-cache.ts +26 -5
  382. package/src/secrets/envelope-cipher.ts +53 -0
  383. package/src/secrets/envelope.ts +5 -3
  384. package/src/secrets/index.ts +13 -1
  385. package/src/secrets/stored-envelope.ts +46 -0
  386. package/src/secrets/types.ts +2 -162
  387. package/src/stack/__tests__/event-collector.test.ts +42 -0
  388. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +125 -0
  389. package/src/stack/db.ts +2 -1
  390. package/src/stack/push-entity-projection-tables.ts +4 -3
  391. package/src/stack/redis.ts +8 -0
  392. package/src/stack/request-helper.ts +38 -2
  393. package/src/stack/table-helpers.ts +6 -4
  394. package/src/stack/test-stack.ts +249 -131
  395. package/src/testing/__tests__/late-bound.test.ts +32 -0
  396. package/src/testing/__tests__/wait-for.test.ts +59 -0
  397. package/src/testing/boot-validator-fixture.ts +121 -0
  398. package/src/testing/e2e-generator.ts +8 -0
  399. package/src/testing/file-provider-contract.ts +104 -0
  400. package/src/testing/handler-context.ts +3 -1
  401. package/src/testing/index.ts +5 -0
  402. package/src/testing/late-bound.ts +5 -3
  403. package/src/testing/mutable-master-key-provider.ts +29 -3
  404. package/src/testing/wait-for.ts +3 -0
  405. package/src/testing/without-ambient-temporal.ts +14 -0
  406. package/src/time/__tests__/tz-dateline.test.ts +8 -10
  407. package/src/time/geo-tz.ts +1 -32
  408. package/src/time/index.ts +1 -0
  409. package/src/time/legacy-date.ts +18 -0
  410. package/src/time/polyfill.ts +21 -38
  411. package/src/time/tz-context.ts +44 -85
  412. package/src/ui-types/app-schema.ts +12 -0
  413. package/src/ui-types/index.ts +20 -7
  414. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  415. package/src/utils/safe-json.ts +13 -1
  416. package/src/__tests__/raw-table.integration.test.ts +0 -116
  417. package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
  418. package/src/db/__tests__/encryption.test.ts +0 -39
  419. package/src/db/encryption.ts +0 -39
  420. package/src/db/row-helpers.ts +0 -4
  421. package/src/engine/__tests__/raw-table.test.ts +0 -150
  422. package/src/engine/__tests__/unmanaged-table.test.ts +0 -127
  423. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
  424. package/src/engine/feature-ast/extractors/round4.ts +0 -1366
@@ -1,1668 +1,88 @@
1
- import { applyEntityEvent } from "../db/apply-entity-event";
1
+ import { validateExtensionPreSaveWiring } from "./boot-validator/entity-handler";
2
+ import { buildRegistryFacade } from "./registry-facade";
2
3
  import {
3
- assertBackingTableSuperset,
4
- buildEntityTableMeta,
5
- resolveTableName,
6
- } from "../db/entity-table-meta";
7
- import { asEntityTableMeta } from "../db/query";
8
- import { buildEntityTable } from "../db/table-builder";
9
- import { buildMetricName, validateMetricName } from "../observability";
10
- import { type QnType, qualifyEntityName } from "./qualified-name";
4
+ populateClaimsAndAuth,
5
+ populateConfigKeys,
6
+ populateEvents,
7
+ populateExtensionsAndSeeds,
8
+ populateFeatureCore,
9
+ populateHandlers,
10
+ populateHooks,
11
+ populateJobsAndNotifications,
12
+ populateMetricsAndSecrets,
13
+ populateProjectionsAndTables,
14
+ populateScreensNavWorkspaces,
15
+ populateTranslations,
16
+ } from "./registry-ingest";
17
+ import { createInitialState } from "./registry-state";
11
18
  import {
12
- buildSoftDeleteCleanupJob,
13
- buildSoftDeleteCleanupSystemJob,
14
- SOFT_DELETE_CLEANUP_JOB,
15
- SOFT_DELETE_CLEANUP_SYSTEM_JOB,
16
- SOFT_DELETE_GRACE_DAYS_KEY,
17
- softDeleteGraceDaysConfig,
18
- } from "./soft-delete-cleanup";
19
- import type {
20
- AuthClaimsHookDef,
21
- ClaimKeyDefinition,
22
- ConfigKeyDefinition,
23
- ConfigSeedDef,
24
- EntityDefinition,
25
- EntityRelations,
26
- EventDef,
27
- EventUpcastFn,
28
- FeatureDefinition,
29
- FeatureMetricDef,
30
- HookPhase,
31
- JobDefinition,
32
- MultiStreamProjectionDefinition,
33
- NavDefinition,
34
- NotificationDefinition,
35
- OwnedFn,
36
- PhasedHook,
37
- PostDeleteHookFn,
38
- PostQueryHookFn,
39
- PostSaveHookFn,
40
- PreDeleteHookFn,
41
- PreQueryHookFn,
42
- PreSaveHookFn,
43
- ProjectionDefinition,
44
- QueryHandlerDef,
45
- RawTableDef,
46
- ReferenceDataDef,
47
- RegistrarExtensionDef,
48
- RegistrarExtensionRegistration,
49
- Registry,
50
- RelationDefinition,
51
- ScreenDefinition,
52
- SearchPayloadContributorFn,
53
- SecretKeyDefinition,
54
- TranslationKeys,
55
- TreeActionDef,
56
- TreeChildrenSubscribe,
57
- UnmanagedTableDef,
58
- WorkspaceDefinition,
59
- WriteHandlerDef,
60
- } from "./types";
61
- import { HookPhases } from "./types";
62
- import { resolveName } from "./types/handlers";
63
-
64
- type IncomingRelation = {
65
- sourceEntity: string;
66
- relationName: string;
67
- relation: RelationDefinition;
68
- };
69
-
70
- const IMPLICIT_PROJECTION_SUFFIX = "-entity" as const;
71
-
72
- // Pro r.entity-Registration eine ImplicitProjection mit auto-generierten
73
- // apply-Handlern für die 4 Auto-Verben. Live-Pfad geht durch
74
- // EventStoreExecutor und schreibt direkt in die Tabelle; rebuildProjection
75
- // nutzt diese Definition um aus Events zu replayen. Beide rufen dieselbe
76
- // applyEntityEvent-Funktion → Live==Rebuild by-construction (verstärkt
77
- // durch implicit-projection-equivalence.integration.ts).
78
- function buildImplicitProjection(
79
- featureName: string,
80
- entityName: string,
81
- entity: EntityDefinition,
82
- qualify: typeof qualifyEntityName,
83
- backingTable?: unknown,
84
- ): ProjectionDefinition {
85
- const name = qualify(featureName, "projection", `${entityName}${IMPLICIT_PROJECTION_SUFFIX}`);
86
- // Backing table (r.entity(name, def, { table })) is the one physical table
87
- // object shared by executor-writes, rebuild-replay, test-push and
88
- // collectTableMetas — restoring the #255 invariant (test-push == generate).
89
- // Validated as a superset of the field-derived columns so a field/table
90
- // disagreement fails at boot, not as a silent thin-vs-rich row.
91
- const drizzleTable =
92
- backingTable !== undefined
93
- ? resolveBackingTable(entityName, entity, backingTable)
94
- : buildEntityTable(entityName, entity);
95
- // applyEntityEvent gibt ApplyResult zurück; SingleStreamApplyFn erwartet
96
- // Promise<void>. Im rebuild-Pfad ist die Row irrelevant — wir discarden.
97
- const handler = async (
98
- event: Parameters<ProjectionDefinition["apply"][string]>[0],
99
- tx: Parameters<ProjectionDefinition["apply"][string]>[1],
100
- ): Promise<void> => {
101
- await applyEntityEvent(event, drizzleTable, entity, tx);
102
- };
103
- const apply: Record<string, ProjectionDefinition["apply"][string]> = {
104
- [`${entityName}.created`]: handler,
105
- [`${entityName}.updated`]: handler,
106
- [`${entityName}.deleted`]: handler,
107
- // forget/purge (Art. 17): hard-deletes the row even for softDelete entities.
108
- // Registered for every entity so the erasure replays on rebuild.
109
- [`${entityName}.forgotten`]: handler,
110
- };
111
- // Restore-Verb existiert nur für softDelete-Entities. Hard-Delete-
112
- // Entities sollten keine restored-Events produzieren — würden sie es
113
- // doch, würde applyEntityEvent intern als no-op laufen, aber wir
114
- // registrieren den Handler gar nicht erst.
115
- if (entity.softDelete) {
116
- apply[`${entityName}.restored`] = handler;
117
- }
118
- return {
119
- name,
120
- source: entityName,
121
- table: drizzleTable,
122
- apply,
123
- isImplicit: true,
124
- };
125
- }
126
-
127
- // Validates a r.entity backing table is a superset of the entity's field-
128
- // derived columns, then hands it back as the projection table. The cast is a
129
- // system-boundary reconstitution: the table is stored as `unknown` on
130
- // FeatureDefinition only to keep drizzle out of the plain-data shape, and
131
- // asEntityTableMeta confirms the kumiko-table shape at runtime.
132
- function resolveBackingTable(
133
- entityName: string,
134
- entity: EntityDefinition,
135
- backingTable: unknown,
136
- ): ProjectionDefinition["table"] {
137
- const tableMeta = asEntityTableMeta(backingTable);
138
- if (!tableMeta) {
139
- throw new Error(
140
- `r.entity("${entityName}", …, { table }): the backing table carries no ` +
141
- "EntityTableMeta — build it via table() / buildEntityTable.",
142
- );
143
- }
144
- assertBackingTableSuperset(entityName, buildEntityTableMeta(entityName, entity), tableMeta);
145
- return backingTable as ProjectionDefinition["table"];
146
- }
19
+ applyExtensionUsages,
20
+ autoWireSoftDeleteJobs,
21
+ buildEventUpcasterChains,
22
+ buildImplicitProjections,
23
+ buildSearchableSortableCaches,
24
+ buildSearchIncludesAndIncomingRelations,
25
+ computeHasRateLimitedHandler,
26
+ finalizeWorkspaceNavMembership,
27
+ populateHandlerEntityMappings,
28
+ publishEventPiiCatalog,
29
+ resolveNotificationTriggersAndRegisterHooks,
30
+ validateEntityHookTargets,
31
+ validateEventMigrationVersions,
32
+ validateExtensionSelectors,
33
+ validateExtensionUsageTargets,
34
+ validateFieldAccessHandlersAreEntityMapped,
35
+ validateJobTriggers,
36
+ validateLifecycleHookTargets,
37
+ validateProjectionApplyKeys,
38
+ validateRelationTargetsExist,
39
+ validateRequiredFeatures,
40
+ } from "./registry-validate";
41
+ import type { FeatureDefinition, Registry } from "./types";
147
42
 
148
43
  // This is where the magic happens. By "magic" I mean: precomputed maps.
149
44
  // I build everything once at boot (hooks, relations, searchable fields, ...)
150
45
  // so nothing has to iterate over objects at runtime. O(1) instead of O(n*m).
151
46
  export function createRegistry(features: readonly FeatureDefinition[]): Registry {
152
- const featureMap = new Map<string, FeatureDefinition>();
153
- const entityMap = new Map<string, EntityDefinition>();
154
- const relationMap = new Map<string, Record<string, RelationDefinition>>();
155
- const writeHandlerMap = new Map<string, WriteHandlerDef>();
156
- const queryHandlerMap = new Map<string, QueryHandlerDef>();
157
- // Hook storage. Every entry carries its owning feature (on the OwnedFn /
158
- // PhasedHook shape), so the lifecycle pipeline can skip hooks whose
159
- // feature is globally disabled without a parallel bookkeeping map.
160
- // featureName === "*" = always fire (extension-provided invariants).
161
- const preSaveHooks = new Map<string, OwnedFn<PreSaveHookFn>[]>();
162
- const postSaveHooks = new Map<string, PhasedHook<PostSaveHookFn>[]>();
163
- const preDeleteHooks = new Map<string, PhasedHook<PreDeleteHookFn>[]>();
164
- const postDeleteHooks = new Map<string, PhasedHook<PostDeleteHookFn>[]>();
165
- const preQueryHooks = new Map<string, OwnedFn<PreQueryHookFn>[]>();
166
- const postQueryHooks = new Map<string, OwnedFn<PostQueryHookFn>[]>();
167
- // Entity hooks — keyed by entity name, NOT prefixed
168
- const entityPostSaveHooks = new Map<string, PhasedHook<PostSaveHookFn>[]>();
169
- const entityPreDeleteHooks = new Map<string, PhasedHook<PreDeleteHookFn>[]>();
170
- const entityPostDeleteHooks = new Map<string, PhasedHook<PostDeleteHookFn>[]>();
171
- const entityPostQueryHooks = new Map<string, OwnedFn<PostQueryHookFn>[]>();
172
- const searchPayloadExtensions = new Map<string, OwnedFn<SearchPayloadContributorFn>[]>();
173
- const configKeyMap = new Map<string, ConfigKeyDefinition>();
174
- const jobMap = new Map<string, JobDefinition>();
175
- const notificationMap = new Map<string, NotificationDefinition>();
176
- const notificationFeatureMap = new Map<string, string>(); // qualifiedName → featureName
177
- const eventMap = new Map<string, EventDef>();
178
- // Schema-migration chain per qualified event name. Built at boot after all
179
- // features are ingested, then exposed via getEventUpcasters(). Readers of
180
- // the events-table (projection rebuild, future aggregate loaders) walk the
181
- // chain to upcast stored payloads to the current shape at read time.
182
- const eventUpcasterMap = new Map<
183
- string,
184
- { readonly currentVersion: number; readonly chain: ReadonlyMap<number, EventUpcastFn> }
185
- >();
186
- // Handler → entity mapping (populated from entities + handler name convention)
187
- const handlerEntityMap = new Map<string, string>();
188
- // Handler → feature mapping (for systemScope check)
189
- const handlerFeatureMap = new Map<string, string>();
190
- const extensionMap = new Map<string, RegistrarExtensionDef>();
191
- const extensionUsages: RegistrarExtensionRegistration[] = [];
192
- const extensionSelectorMap = new Map<string, string>();
193
- const allReferenceData: ReferenceDataDef[] = [];
194
- const allConfigSeeds: ConfigSeedDef[] = [];
195
- const mergedTranslations: Record<string, Record<string, string>> = {};
196
- // Metric registry — keyed by fully qualified name (kumiko_<feature>_<short>).
197
- // Boot-time validation rejects bad names; dashboards then safely rely on shape.
198
- const metricMap = new Map<string, FeatureMetricDef & { readonly featureName: string }>();
199
- // Feature-declared secrets. Keyed by qualified name ("<feature>:<short>").
200
- // The map is the source of truth for ops-UIs, the rotation job, and any
201
- // boot validation that wants to reject a secrets.get for an unknown key.
202
- const secretKeyMap = new Map<string, SecretKeyDefinition>();
203
- // Projections — full list keyed by qualified name AND a source-entity index
204
- // the executor consults on every write. Index is precomputed so the hot path
205
- // does a single Map.get, never a scan.
206
- const projectionMap = new Map<string, ProjectionDefinition>();
207
- const projectionsBySource = new Map<string, ProjectionDefinition[]>();
208
- // Multi-stream projections — cross-aggregate, async via event-dispatcher.
209
- // One qualified name per MSP; each becomes its own EventConsumer with a
210
- // dedicated cursor in kumiko_event_consumers.
211
- const multiStreamProjectionMap = new Map<string, MultiStreamProjectionDefinition>();
212
- // qualified-MSP-name → owning-feature name. Used by the event-dispatcher
213
- // to pause consumers whose feature is globally disabled.
214
- const multiStreamProjectionFeatureMap = new Map<string, string>();
215
- // Raw tables — declared via r.rawTable(). Bypass the projection registry,
216
- // so they have no qualified-name namespace and no source-entity index.
217
- // Keyed by the feature-local short name; cross-feature uniqueness is
218
- // enforced at ingest below (collisions would race two CREATE TABLE
219
- // statements at the same physical name and break boot).
220
- const rawTableMap = new Map<string, RawTableDef>();
221
- // Unmanaged tables — declared via r.unmanagedTable() (EntityTableMeta).
222
- // Cousin of rawTables: same uniqueness-by-tableName invariant, different
223
- // storage shape (post-drizzle migrate-runner consumes EntityTableMeta).
224
- const unmanagedTableMap = new Map<string, UnmanagedTableDef>();
225
- // Final physical table names (entity-derived + unmanaged) → owner. Catches
226
- // a collision between an r.unmanagedTable() tableName and an r.entity()
227
- // physical name at boot instead of as a duplicate CREATE TABLE in migrate.
228
- const physicalTableOwners = new Map<
229
- string,
230
- { kind: "entity" | "unmanaged"; owner: string; featureName: string }
231
- >();
232
- // Auth-claims hooks — tagged with featureName so the login resolver can
233
- // auto-prefix each hook's returned keys with "<feature>:".
234
- const authClaimsHooks: AuthClaimsHookDef[] = [];
235
- // Feature-declared claim keys. Keyed by qualified name ("<feature>:<short>").
236
- // Used by readClaim callers to introspect; the resolver reads it via the
237
- // declaredKeys set on each AuthClaimsHookDef (pre-built per feature below).
238
- const claimKeyMap = new Map<string, ClaimKeyDefinition>();
239
- // Screens — keyed by qualified name ("<feature>:screen:<id>"). One map for
240
- // lookup + a parallel featureMap so the nav-resolver can gate screens by
241
- // effective-features without scanning. `screensByEntity` pre-groups the
242
- // entity-bound screens (entityList / entityEdit) by their entity name so
243
- // ui-core's Schema-driven view-model builders don't need to scan
244
- // getAllScreens() for every render.
245
- const screenMap = new Map<string, ScreenDefinition>();
246
- const screenFeatureMap = new Map<string, string>();
247
- const screensByEntity = new Map<string, ScreenDefinition[]>();
248
- // Nav entries — same shape as screenMap. Tree assembly happens in ui-core
249
- // at render time; the engine just stores the flat list and its owners.
250
- // `navsByParent` pre-groups children by their parent's QN so
251
- // resolveNavigation does O(n) passes, not O(n²) parent-filters. Top-level
252
- // entries (no parent) sit in the separate `topLevelNavs` list.
253
- const navMap = new Map<string, NavDefinition>();
254
- const navFeatureMap = new Map<string, string>();
255
- const navsByParent = new Map<string, NavDefinition[]>();
256
- const topLevelNavs: NavDefinition[] = [];
257
-
258
- // Workspaces — stored verbatim, plus a parallel feature-owner map and a
259
- // pre-computed nav-membership map. Membership merges two sources at boot:
260
- // 1. r.workspace({ nav: [...] }) — explicit list on the workspace
261
- // 2. r.nav({ workspaces: [...] }) — self-assignment on the nav entry
262
- // Order matters for the switcher: workspace-declared QNs come first (in
263
- // declaration order), then nav-self-assigned ones (in registration order).
264
- // Duplicates are deduped — a nav entry listed in both shows up once.
265
- const workspaceMap = new Map<string, WorkspaceDefinition>();
266
- const workspaceFeatureMap = new Map<string, string>();
267
- const navsByWorkspace = new Map<string, string[]>();
268
- let defaultWorkspace: WorkspaceDefinition | undefined;
269
-
270
- // Visual-Tree-Provider — keyed by declaring feature name (NOT qualified;
271
- // ein Feature liefert genau einen Provider). Visual-Tree-Component
272
- // iteriert die Map zur Mount-Zeit. Tree-Actions parallel — featureName
273
- // → erased Action-Map (compile-time-typed Variante geht über
274
- // setup-export-handle, siehe FeatureRegistrar.treeActions docs).
275
- const treeProvidersMap = new Map<string, TreeChildrenSubscribe>();
276
- const treeActionsMap = new Map<string, Readonly<Record<string, TreeActionDef>>>();
277
-
278
- // Local alias for readability — `qualifyEntityName` is the shared helper
279
- // from qualified-name.ts, also used by validateBoot to keep ingest and
280
- // validation in lockstep on the qualification rule.
281
- const qualify = qualifyEntityName;
282
-
283
- // Filter hooks by phase and/or owning feature.
284
- //
285
- // - `phase === undefined` → any phase passes.
286
- // - `effectiveFeatures === undefined` → ownership filter disabled.
287
- // - hook.featureName === "*" or undefined → always passes ownership filter.
288
- // "*" is reserved for extension-provided hooks that are invariant
289
- // plumbing, not opt-in feature logic.
290
- function filterByPhase<TFn>(
291
- list: readonly PhasedHook<TFn>[] | undefined,
292
- phase: HookPhase | undefined,
293
- effectiveFeatures?: ReadonlySet<string>,
294
- ): readonly TFn[] {
295
- if (!list || list.length === 0) return [];
296
- const result: TFn[] = [];
297
- for (const entry of list) {
298
- if (phase !== undefined && entry.phase !== phase) continue;
299
- if (!ownerEnabled(entry.featureName, effectiveFeatures)) continue;
300
- result.push(entry.fn);
301
- }
302
- return result;
303
- }
304
-
305
- // Same ownership rule as filterByPhase, but for unphased hook lists
306
- // (preSave, preQuery). Returns the raw fns ready for the lifecycle runner.
307
- function filterOwned<TFn>(
308
- list: readonly OwnedFn<TFn>[] | undefined,
309
- effectiveFeatures?: ReadonlySet<string>,
310
- ): readonly TFn[] {
311
- if (!list || list.length === 0) return [];
312
- const result: TFn[] = [];
313
- for (const entry of list) {
314
- if (!ownerEnabled(entry.featureName, effectiveFeatures)) continue;
315
- result.push(entry.fn);
316
- }
317
- return result;
318
- }
319
-
320
- function ownerEnabled(
321
- owner: string | undefined,
322
- effectiveFeatures: ReadonlySet<string> | undefined,
323
- ): boolean {
324
- if (!effectiveFeatures) return true;
325
- if (owner === undefined || owner === "*") return true;
326
- return effectiveFeatures.has(owner);
327
- }
328
-
329
- // Merge hooks without prefix (entity hooks). featureName is already on
330
- // every hook entry (set by defineFeature), so there's no parallel
331
- // bookkeeping — just append.
332
- function mergeHookList<T>(
333
- map: Map<string, T[]>,
334
- source: Readonly<Record<string, readonly T[]>> | undefined,
335
- ): void {
336
- // skip: optionaler entityHook-slot — features ohne postSave/preDelete/
337
- // postDelete/postQuery lassen das slot undefined.
338
- if (!source) return;
339
- for (const [name, fns] of Object.entries(source)) {
340
- const existing = map.get(name) ?? [];
341
- existing.push(...fns);
342
- map.set(name, existing);
343
- }
344
- }
345
-
346
- // Merge hooks with feature prefix (handler hooks).
347
- // Hook keys are handler QNs — hooks don't get their own QN, they're keyed by the handler they target.
348
- // The hookQnType indicates whether the targeted handler is a write or query handler.
349
- function mergeHookListQualified<T>(
350
- map: Map<string, T[]>,
351
- source: Readonly<Record<string, readonly T[]>> | undefined,
352
- featureName: string,
353
- hookQnType: QnType,
354
- ): void {
355
- // skip: optionaler hook-slot — defineFeature materialisiert zwar alle
356
- // Slots, aber hand-gebaute Definitionen an System-Grenzen (Fixtures,
357
- // Partial-Boots, s. registry.test.ts) lassen sie weg. Leeres Record
358
- // statt Object.entries(undefined)-Crash.
359
- if (!source) return;
360
- for (const [name, fns] of Object.entries(source)) {
361
- const qualified = qualify(featureName, hookQnType, name);
362
- const existing = map.get(qualified) ?? [];
363
- existing.push(...fns);
364
- map.set(qualified, existing);
365
- }
366
- }
367
-
368
- for (const feature of features) {
369
- if (featureMap.has(feature.name)) {
370
- throw new Error(`Duplicate feature: "${feature.name}"`);
371
- }
372
- featureMap.set(feature.name, feature);
373
-
374
- // Entities: NOT prefixed — entity names must be globally unique
375
- for (const [name, entity] of Object.entries(feature.entities ?? {})) {
376
- if (entityMap.has(name)) {
377
- throw new Error(`Duplicate entity: "${name}" (registered by multiple features)`);
378
- }
379
- entityMap.set(name, entity);
380
- const physical = resolveTableName(name, entity, feature.name);
381
- const clash = physicalTableOwners.get(physical);
382
- if (clash?.kind === "unmanaged") {
383
- throw new Error(
384
- `Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
385
- `collides with r.unmanagedTable("${physical}") (feature "${clash.featureName}"). ` +
386
- `Pick a different tableName — both would emit CREATE TABLE "${physical}".`,
387
- );
388
- }
389
- // Entity-vs-entity ist genauso fatal: zwei Entities mit explizitem,
390
- // identischem tableName überschrieben sich hier vorher still —
391
- // doppeltes CREATE TABLE bzw. eine Projektion frisst die andere.
392
- if (clash?.kind === "entity") {
393
- throw new Error(
394
- `Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
395
- `collides with entity "${clash.owner}" (feature "${clash.featureName}"). ` +
396
- `Pick a different tableName — both would project into "${physical}".`,
397
- );
398
- }
399
- physicalTableOwners.set(physical, { kind: "entity", owner: name, featureName: feature.name });
400
- }
401
-
402
- // Relations: entityName (not prefixed)
403
- for (const [entityName, rels] of Object.entries(feature.relations ?? {})) {
404
- const existing = relationMap.get(entityName) ?? {};
405
- for (const [relName, relDef] of Object.entries(rels)) {
406
- if (existing[relName]) {
407
- throw new Error(
408
- `Duplicate relation: "${entityName}.${relName}" (registered by multiple features)`,
409
- );
410
- }
411
- existing[relName] = relDef;
412
- }
413
- relationMap.set(entityName, existing);
414
- }
415
-
416
- // Write handlers: scope:write:name
417
- for (const [name, handler] of Object.entries(feature.writeHandlers ?? {})) {
418
- const qualified = qualify(feature.name, "write", name);
419
- if (writeHandlerMap.has(qualified)) {
420
- throw new Error(
421
- `Duplicate write handler: "${qualified}" (registered by multiple features)`,
422
- );
423
- }
424
- writeHandlerMap.set(qualified, { ...handler, name: qualified });
425
- handlerFeatureMap.set(qualified, feature.name);
426
- }
427
-
428
- // Query handlers: scope:query:name
429
- for (const [name, handler] of Object.entries(feature.queryHandlers ?? {})) {
430
- const qualified = qualify(feature.name, "query", name);
431
- if (queryHandlerMap.has(qualified)) {
432
- throw new Error(
433
- `Duplicate query handler: "${qualified}" (registered by multiple features)`,
434
- );
435
- }
436
- queryHandlerMap.set(qualified, { ...handler, name: qualified });
437
- handlerFeatureMap.set(qualified, feature.name);
438
- }
439
-
440
- // Config keys: scope:config:name
441
- for (const [key, keyDef] of Object.entries(feature.configKeys ?? {})) {
442
- const qualifiedKey = qualify(feature.name, "config", key);
443
- if (configKeyMap.has(qualifiedKey)) {
444
- throw new Error(
445
- `Duplicate config key: "${qualifiedKey}" (registered by multiple features)`,
446
- );
447
- }
448
- configKeyMap.set(qualifiedKey, keyDef);
449
- }
450
-
451
- // Jobs: scope:job:name
452
- for (const [name, jobDef] of Object.entries(feature.jobs ?? {})) {
453
- const qualifiedName = qualify(feature.name, "job", name);
454
- if (jobMap.has(qualifiedName)) {
455
- throw new Error(`Duplicate job: "${qualifiedName}" (registered by multiple features)`);
456
- }
457
- // runIn runtime-check. TS's JobRunIn = Exclude<RunIn, "both"> already
458
- // rejects "both" at compile time, but dynamically-constructed jobs
459
- // (serialized config, plugin authors using `as any`) could slip it
460
- // past the type system. Fail loud — "both" for jobs would mean "fan
461
- // out to both lane-queues", which over-delivers; the routing assumes
462
- // exactly one target queue per dispatch.
463
- // @cast-boundary schema-walk — defensive runtime-check against bypassed type-system
464
- const runIn = (jobDef as { runIn?: unknown }).runIn;
465
- if (runIn !== undefined && runIn !== "api" && runIn !== "worker") {
466
- throw new Error(
467
- `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.`,
468
- );
469
- }
470
- jobMap.set(qualifiedName, { ...jobDef, name: qualifiedName });
471
- }
472
-
473
- // Notifications: scope:notify:name
474
- for (const [name, notifDef] of Object.entries(feature.notifications ?? {})) {
475
- const qualifiedName = qualify(feature.name, "notify", name);
476
- notificationMap.set(qualifiedName, {
477
- ...notifDef,
478
- name: qualifiedName,
479
- trigger: { on: notifDef.trigger.on },
480
- });
481
- notificationFeatureMap.set(qualifiedName, feature.name);
482
- }
483
-
484
- // Events: scope:event:name. Migrations stay keyed by feature+short-name
485
- // in the FeatureDefinition and get stitched into the eventUpcasterMap
486
- // below (after ALL features are ingested) so cross-feature validation has
487
- // the complete picture.
488
- for (const [eventName, eventDef] of Object.entries(feature.events ?? {})) {
489
- const qualified = qualify(feature.name, "event", eventName);
490
- eventMap.set(qualified, { ...eventDef, name: qualified });
491
- }
492
-
493
- // Translations prefixed with featureName: (i18next namespace convention)
494
- for (const [key, value] of Object.entries(feature.translations ?? {})) {
495
- mergedTranslations[`${feature.name}:${key}`] = value;
496
- }
497
-
498
- // Lifecycle hooks: keyed by handler QN. featureName rides along on each
499
- // hook entry — defineFeature sets it, the registry just appends.
500
- // Save/delete hooks target write handlers, query hooks target query handlers.
501
- mergeHookListQualified(preSaveHooks, feature.hooks?.preSave, feature.name, "write");
502
- mergeHookListQualified(postSaveHooks, feature.hooks?.postSave, feature.name, "write");
503
- mergeHookListQualified(preDeleteHooks, feature.hooks?.preDelete, feature.name, "write");
504
- mergeHookListQualified(postDeleteHooks, feature.hooks?.postDelete, feature.name, "write");
505
- mergeHookListQualified(preQueryHooks, feature.hooks?.preQuery, feature.name, "query");
506
- mergeHookListQualified(postQueryHooks, feature.hooks?.postQuery, feature.name, "query");
507
-
508
- // Entity hooks: NOT prefixed, keyed by entity name
509
- mergeHookList(entityPostSaveHooks, feature.entityHooks?.postSave);
510
- mergeHookList(entityPreDeleteHooks, feature.entityHooks?.preDelete);
511
- mergeHookList(entityPostDeleteHooks, feature.entityHooks?.postDelete);
512
- mergeHookList(entityPostQueryHooks, feature.entityHooks?.postQuery);
513
-
514
- // F3 search-payload-extensions: per-entity contributors merged additively
515
- for (const [entityName, contributors] of Object.entries(
516
- feature.searchPayloadExtensions ?? {},
517
- )) {
518
- const existing = searchPayloadExtensions.get(entityName) ?? [];
519
- for (const c of contributors) existing.push(c);
520
- searchPayloadExtensions.set(entityName, existing);
521
- }
522
-
523
- // Registrar extensions: collect definitions and usages
524
- for (const [extName, extDef] of Object.entries(feature.registrarExtensions ?? {})) {
525
- if (extensionMap.has(extName)) {
526
- throw new Error(
527
- `Duplicate registrar extension: "${extName}" (registered by multiple features)`,
528
- );
529
- }
530
- extensionMap.set(extName, extDef);
531
- }
532
- // Annotate the owner so consumers (readiness gating) can map a
533
- // registration back to the feature's config keys + secrets.
534
- extensionUsages.push(
535
- ...(feature.extensionUsages ?? []).map((u) => ({ ...u, featureName: feature.name })),
536
- );
537
- for (const sel of feature.extensionSelectors ?? []) {
538
- if (extensionSelectorMap.has(sel.extensionName)) {
539
- throw new Error(
540
- `Duplicate extension selector for "${sel.extensionName}" ` +
541
- `(feature "${feature.name}") — one owning feature declares the selector.`,
542
- );
543
- }
544
- extensionSelectorMap.set(sel.extensionName, sel.qualifiedKey);
545
- }
546
- allReferenceData.push(...(feature.referenceData ?? []));
547
- allConfigSeeds.push(...(feature.configSeeds ?? []));
548
-
549
- // Metrics: validate + qualify per feature. Collisions across features are
550
- // rejected here — two features can't both register "created_total" under
551
- // different shapes (labels/type) because the resulting fully qualified
552
- // names differ, but same short+feature combo would already fail in
553
- // defineFeature. This loop catches cross-feature/extension edge cases.
554
- for (const [shortName, def] of Object.entries(feature.metrics ?? {})) {
555
- const fullName = buildMetricName(feature.name, shortName);
556
- validateMetricName(fullName, def.type);
557
- if (metricMap.has(fullName)) {
558
- throw new Error(
559
- `[Kumiko Observability] Metric "${fullName}" registered multiple times ` +
560
- `(Feature: ${feature.name}). Metric names must be globally unique.`,
561
- );
562
- }
563
- metricMap.set(fullName, { ...def, featureName: feature.name });
564
- }
565
-
566
- // Secret keys: already qualified during defineFeature (same "<feature>:<short>"
567
- // convention used elsewhere). Reject cross-feature duplicates — extensions
568
- // could theoretically register on another feature's namespace.
569
- for (const def of Object.values(feature.secretKeys ?? {})) {
570
- if (secretKeyMap.has(def.qualifiedName)) {
571
- throw new Error(
572
- `[Kumiko Secrets] Secret key "${def.qualifiedName}" registered multiple times. ` +
573
- "Secret names must be globally unique across features.",
574
- );
575
- }
576
- secretKeyMap.set(def.qualifiedName, def);
577
- }
578
-
579
- // Projections: qualified by feature name. Build the source-entity index so
580
- // the event-store-executor can fetch matching projections in O(1) per write.
581
- for (const [projName, projDef] of Object.entries(feature.projections ?? {})) {
582
- const qualified = qualify(feature.name, "projection", projName);
583
- if (projectionMap.has(qualified)) {
584
- throw new Error(`Duplicate projection: "${qualified}" (registered by multiple features)`);
585
- }
586
- const stored = { ...projDef, name: qualified };
587
- projectionMap.set(qualified, stored);
588
- const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
589
- for (const src of sources) {
590
- const existing = projectionsBySource.get(src) ?? [];
591
- existing.push(stored);
592
- projectionsBySource.set(src, existing);
593
- }
594
- }
595
-
596
- // Multi-stream projections: qualified + stored for later wiring into
597
- // event-dispatcher. Namespace is shared with single-stream projections —
598
- // defineFeature already catches name collisions inside one feature, but
599
- // we also guard the cross-feature case here.
600
- for (const [mspName, mspDef] of Object.entries(feature.multiStreamProjections ?? {})) {
601
- const qualified = qualify(feature.name, "projection", mspName);
602
- if (projectionMap.has(qualified) || multiStreamProjectionMap.has(qualified)) {
603
- throw new Error(`Duplicate projection: "${qualified}" (registered by multiple features)`);
604
- }
605
- // runIn runtime-check. TS's RunIn union already enforces the three
606
- // values at compile time; this guards dynamically-constructed MSPs
607
- // (config-driven, plugin authors) that could slip a typo through.
608
- // @cast-boundary schema-walk — defensive runtime-check against bypassed type-system
609
- const mspRunIn = (mspDef as { runIn?: unknown }).runIn;
610
- if (
611
- mspRunIn !== undefined &&
612
- mspRunIn !== "api" &&
613
- mspRunIn !== "worker" &&
614
- mspRunIn !== "both"
615
- ) {
616
- throw new Error(
617
- `Invalid runIn "${String(mspRunIn)}" on MSP "${qualified}" — must be "api", "worker", or "both".`,
618
- );
619
- }
620
- multiStreamProjectionMap.set(qualified, { ...mspDef, name: qualified });
621
- multiStreamProjectionFeatureMap.set(qualified, feature.name);
622
- }
623
-
624
- // Raw tables: aggregated by feature-local short name (unprefixed —
625
- // these bypass the qualified-name namespace because they have no
626
- // event-stream binding to disambiguate). Reject cross-feature
627
- // duplicates at boot so the dev-server doesn't race two CREATE TABLE
628
- // statements that target the same physical table name.
629
- for (const [rawName, rawDef] of Object.entries(feature.rawTables ?? {})) {
630
- const existing = rawTableMap.get(rawName);
631
- if (existing) {
632
- throw new Error(
633
- `Raw-table "${rawName}" registered by both feature "${existing.featureName}" and ` +
634
- `"${feature.name}". Pick a feature-prefixed name to disambiguate.`,
635
- );
636
- }
637
- rawTableMap.set(rawName, { ...rawDef, featureName: feature.name });
638
- }
639
-
640
- // Unmanaged tables — same cross-feature uniqueness invariant as rawTables.
641
- // Two features registering the same physical tableName would race two
642
- // CREATE TABLE statements via migrate-runner.
643
- for (const [umName, umDef] of Object.entries(feature.unmanagedTables ?? {})) {
644
- const existing = unmanagedTableMap.get(umName);
645
- if (existing) {
646
- throw new Error(
647
- `Unmanaged-table "${umName}" registered by both feature "${existing.featureName}" and ` +
648
- `"${feature.name}". Pick a feature-prefixed tableName to disambiguate.`,
649
- );
650
- }
651
- const physicalClash = physicalTableOwners.get(umName);
652
- if (physicalClash?.kind === "entity") {
653
- throw new Error(
654
- `Unmanaged-table "${umName}" (feature "${feature.name}") collides with the physical ` +
655
- `table of entity "${physicalClash.owner}" (feature "${physicalClash.featureName}"). ` +
656
- `Pick a different tableName — both would emit CREATE TABLE "${umName}".`,
657
- );
658
- }
659
- physicalTableOwners.set(umName, {
660
- kind: "unmanaged",
661
- owner: umName,
662
- featureName: feature.name,
663
- });
664
- unmanagedTableMap.set(umName, { ...umDef, featureName: feature.name });
665
- }
666
-
667
- // Claim keys: aggregated by qualified name. Two features cannot collide
668
- // here (qualified by feature name), but we still guard for explicit
669
- // correctness — the only way to hit this is a hand-built FeatureDefinition
670
- // bypassing defineFeature's per-feature duplicate check.
671
- const declaredShortNames = new Set<string>();
672
- for (const def of Object.values(feature.claimKeys ?? {})) {
673
- if (claimKeyMap.has(def.qualifiedName)) {
674
- throw new Error(
675
- `[Kumiko ClaimKeys] Claim key "${def.qualifiedName}" registered multiple times. ` +
676
- "Claim short-names must be globally unique across features.",
677
- );
678
- }
679
- claimKeyMap.set(def.qualifiedName, def);
680
- declaredShortNames.add(def.shortName);
681
- }
682
-
683
- // Screens: qualified + stored. Uniqueness per-feature is enforced in
684
- // defineFeature; cross-feature collisions are impossible because the
685
- // qualified name includes the feature-prefix. The separate featureMap
686
- // entry lets the nav resolver pause screens owned by disabled features
687
- // in O(1) without walking every screen.
688
- for (const [screenId, screenDef] of Object.entries(feature.screens ?? {})) {
689
- const qualified = qualify(feature.name, "screen", screenId);
690
- // Stored version overwrites `id` with the qualified name so callers
691
- // never need a reverse index (NavDef → qn) during tree-walking.
692
- // Same pattern as writeHandlerMap/projectionMap/multiStreamProjectionMap
693
- // (see `{ ...def, name: qualified }` above). Feature-side
694
- // `feature.screens[shortId]` keeps the short id — only the registry
695
- // surface flips.
696
- const stored = { ...screenDef, id: qualified };
697
- screenMap.set(qualified, stored);
698
- screenFeatureMap.set(qualified, feature.name);
699
- // entity-Index nur für Screens die direkt an einer Entity hängen.
700
- // entityList/entityEdit haben `entity`; custom + actionForm haben
701
- // keinen entity-Bezug (custom ist opaque, actionForm hat inline
702
- // fields ohne Entity-Reference).
703
- if (stored.type === "entityList" || stored.type === "entityEdit") {
704
- const existing = screensByEntity.get(stored.entity) ?? [];
705
- existing.push(stored);
706
- screensByEntity.set(stored.entity, existing);
707
- }
708
- }
709
-
710
- // Nav entries: same qualification pattern as screens. The parent/screen
711
- // refs are boot-validated below (after all features are ingested, so
712
- // cross-feature parents can resolve). parent-index is built in the same
713
- // loop because `parent` refers to a qualified name that doesn't need
714
- // resolution — just string equality with whatever's in the target
715
- // entry's QN.
716
- for (const [navId, navDef] of Object.entries(feature.navs ?? {})) {
717
- const qualified = qualify(feature.name, "nav", navId);
718
- // See screens above — stored version carries the qualified id so
719
- // resolveNavigation can recurse via getNavsByParent(child.id) without
720
- // hand-building a reverse index.
721
- const stored = { ...navDef, id: qualified };
722
- navMap.set(qualified, stored);
723
- navFeatureMap.set(qualified, feature.name);
724
- if (stored.parent === undefined) {
725
- topLevelNavs.push(stored);
726
- } else {
727
- const existing = navsByParent.get(stored.parent) ?? [];
728
- existing.push(stored);
729
- navsByParent.set(stored.parent, existing);
730
- }
731
- }
732
-
733
- // Workspaces: same qualification pattern as nav/screen. Step one stores
734
- // the workspace itself + its explicit nav list; step two (after every
735
- // feature has been ingested) folds nav-self-assigned QNs into the same
736
- // member list. Doing it in two passes keeps cross-feature workspace
737
- // refs valid — a nav entry can self-assign to a workspace whose feature
738
- // hasn't been ingested yet.
739
- for (const [wsId, wsDef] of Object.entries(feature.workspaces ?? {})) {
740
- const qualified = qualify(feature.name, "workspace", wsId);
741
- const stored = { ...wsDef, id: qualified };
742
- workspaceMap.set(qualified, stored);
743
- workspaceFeatureMap.set(qualified, feature.name);
744
- // Seed the membership list with the workspace's explicit nav refs in
745
- // declaration order. Boot-validator checks the QNs resolve.
746
- navsByWorkspace.set(qualified, [...(stored.nav ?? [])]);
747
- if (stored.default === true) {
748
- // Boot-validator enforces uniqueness; here we just remember the
749
- // first one and let validateBoot complain if there's a second.
750
- if (defaultWorkspace === undefined) {
751
- defaultWorkspace = stored;
752
- }
753
- }
754
- }
755
-
756
- // Visual-Tree slots — at-most-one per feature (only-once-guard im
757
- // registrar). Erased Maps für Runtime-Lookup; compile-time-typed
758
- // Surface läuft über FeatureDefinition.exports (TreeActionsHandle).
759
- if (feature.treeProvider !== undefined) {
760
- treeProvidersMap.set(feature.name, feature.treeProvider);
761
- }
762
- if (feature.treeActions !== undefined) {
763
- treeActionsMap.set(feature.name, feature.treeActions);
764
- }
765
-
766
- // Auth-claims hooks: order of registration is preserved. Feature name is
767
- // captured alongside so the resolver can apply the auto-prefix at merge
768
- // time — the feature author never ships pre-prefixed keys.
769
- //
770
- // If the feature declared ANY claim keys, every hook from that feature
771
- // gets the declaredShortNames set attached. The resolver uses it to warn
772
- // on undeclared inner-keys (typo / rename drift). Features that don't
773
- // declare claimKeys skip the check entirely — it's opt-in.
774
- const declaredKeys = declaredShortNames.size > 0 ? declaredShortNames : undefined;
775
- for (const fn of feature.authClaimsHooks ?? []) {
776
- authClaimsHooks.push({
777
- featureName: feature.name,
778
- fn,
779
- ...(declaredKeys && { declaredKeys }),
780
- });
781
- }
782
- }
783
-
784
- // Pass 2 for workspaces: fold any nav-self-assigned QNs into their
785
- // workspace's member list. We can do this safely now that every feature
786
- // (and therefore every workspace) is in workspaceMap. Cross-feature refs
787
- // — a nav from feature A self-assigning to a workspace from feature B —
788
- // resolve here because B's workspace was registered in pass 1 above.
789
- // Dedup: a nav entry that's also in r.workspace({ nav: [...] }) shouldn't
790
- // appear twice. Boot-validator catches dangling workspace ids.
791
- for (const [navQn, navDef] of navMap) {
792
- if (!navDef.workspaces || navDef.workspaces.length === 0) continue;
793
- for (const wsQn of navDef.workspaces) {
794
- const members = navsByWorkspace.get(wsQn);
795
- if (members === undefined) continue; // dangling — boot-validator reports
796
- if (!members.includes(navQn)) members.push(navQn);
797
- }
798
- }
799
-
800
- // Build handler → entity mapping from feature declarations (filled by tryMapEntity
801
- // in defineFeature via the "entityName:verb" colon convention).
802
- // Must happen before extension processing since extension preSave hooks need entity mappings.
803
- for (const feature of features) {
804
- for (const [handlerName, entityName] of Object.entries(feature.handlerEntityMappings ?? {})) {
805
- const writeQn = qualify(feature.name, "write", handlerName);
806
- const queryQn = qualify(feature.name, "query", handlerName);
807
- if (writeHandlerMap.has(writeQn)) {
808
- handlerEntityMap.set(writeQn, entityName);
809
- }
810
- if (queryHandlerMap.has(queryQn)) {
811
- handlerEntityMap.set(queryQn, entityName);
812
- }
813
- }
814
- }
815
-
816
- // Selector declarations point into the merged extension + config-key
817
- // sets — a typo'd extension or dropped key must fail the boot, not
818
- // silently un-gate readiness.
819
- for (const [extensionName, qualifiedKey] of extensionSelectorMap) {
820
- if (!extensionMap.has(extensionName)) {
821
- throw new Error(
822
- `extensionSelector("${extensionName}") declared but no feature ` +
823
- `registers that extension via extendsRegistrar.`,
824
- );
825
- }
826
- if (!configKeyMap.has(qualifiedKey)) {
827
- throw new Error(
828
- `extensionSelector("${extensionName}") points at unknown config key ` +
829
- `"${qualifiedKey}" — no mounted feature declares it.`,
830
- );
831
- }
832
- }
833
-
834
- // Process extension usages: call onRegister, apply extendSchema, register hooks
835
- for (const usage of extensionUsages) {
836
- const ext = extensionMap.get(usage.extensionName);
837
- if (!ext) continue;
838
-
839
- if (ext.onRegister) {
840
- ext.onRegister(usage.entityName, usage.options);
841
- }
842
-
843
- // extendSchema: merge extra fields into entity definition
844
- if (ext.extendSchema) {
845
- const entity = entityMap.get(usage.entityName);
846
- if (entity) {
847
- const extraFields = ext.extendSchema(usage.entityName);
848
- const merged = { ...entity, fields: { ...entity.fields, ...extraFields } };
849
- entityMap.set(usage.entityName, merged);
850
- }
851
- }
852
-
853
- // Extension hooks → entity hooks (fire for all writes on the entity).
854
- // Extensions default to afterCommit phase (same default as r.hook).
855
- //
856
- // Owner "*" = always-enabled, not gated by feature-toggles. Extensions
857
- // are plumbing (e.g. ownership) — the feature that declared them might
858
- // itself be toggleable, but the extension-hook is conceptually part of
859
- // the entity's invariants. If future requirements need extension hooks
860
- // to also be gated, store the registering-feature on
861
- // RegistrarExtensionRegistration and use that here.
862
- const extOwner = "*";
863
- if (ext.hooks) {
864
- if (ext.hooks.postSave) {
865
- const existing = entityPostSaveHooks.get(usage.entityName) ?? [];
866
- existing.push({
867
- fn: ext.hooks.postSave,
868
- phase: HookPhases.afterCommit,
869
- featureName: extOwner,
870
- });
871
- entityPostSaveHooks.set(usage.entityName, existing);
872
- }
873
- if (ext.hooks.preDelete) {
874
- const existing = entityPreDeleteHooks.get(usage.entityName) ?? [];
875
- existing.push({
876
- fn: ext.hooks.preDelete,
877
- phase: HookPhases.afterCommit,
878
- featureName: extOwner,
879
- });
880
- entityPreDeleteHooks.set(usage.entityName, existing);
881
- }
882
- if (ext.hooks.postDelete) {
883
- const existing = entityPostDeleteHooks.get(usage.entityName) ?? [];
884
- existing.push({
885
- fn: ext.hooks.postDelete,
886
- phase: HookPhases.afterCommit,
887
- featureName: extOwner,
888
- });
889
- entityPostDeleteHooks.set(usage.entityName, existing);
890
- }
891
- // preSave on extensions: store as handler hook for all CRUD handlers of this entity
892
- if (ext.hooks.preSave) {
893
- // Find all write handlers that belong to this entity via handlerEntityMap
894
- for (const qualifiedHandler of writeHandlerMap.keys()) {
895
- if (handlerEntityMap.get(qualifiedHandler) === usage.entityName) {
896
- const existing = preSaveHooks.get(qualifiedHandler) ?? [];
897
- existing.push({ fn: ext.hooks.preSave, featureName: extOwner });
898
- preSaveHooks.set(qualifiedHandler, existing);
899
- }
900
- }
901
- }
902
- }
903
- }
904
-
905
- // Precompute: searchable/sortable fields, search includes, incoming relations
906
- const searchableFieldsCache = new Map<string, readonly string[]>();
907
- const sortableFieldsCache = new Map<string, readonly string[]>();
908
- const searchIncludesCache = new Map<string, ReadonlyMap<string, readonly string[]>>();
909
- const incomingRelationsCache = new Map<string, IncomingRelation[]>();
910
-
911
- for (const [name, entity] of entityMap) {
912
- const searchable: string[] = [];
913
- const sortable: string[] = [];
914
- for (const [fieldName, field] of Object.entries(entity.fields)) {
915
- if (field.type === "text" && field.searchable === true) searchable.push(fieldName);
916
- if (field.type === "text" && field.sortable === true) sortable.push(fieldName);
917
- if (field.type === "embedded") {
918
- for (const [subName, subField] of Object.entries(field.schema)) {
919
- if (subField.searchable === true) searchable.push(`${fieldName}_${subName}`);
920
- }
921
- }
922
- }
923
- searchableFieldsCache.set(name, searchable);
924
- sortableFieldsCache.set(name, sortable);
925
- }
926
-
927
- // Implicit-Projection pro r.entity. Macht die Entity-Tabelle rebaubar
928
- // ohne dass Apps eine explizite r.projection schreiben müssen.
929
- // Naming-Convention: `<feature>:projection:<entityName>-entity` — der
930
- // "-entity"-Suffix unterscheidet implicit von explicit-Projections und
931
- // vermeidet Kollisionen wenn jemand z.B. eine Cross-Aggregate-Projection
932
- // mit Entity-Name registriert.
933
- for (const feature of features) {
934
- for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
935
- const def = buildImplicitProjection(
936
- feature.name,
937
- entityName,
938
- entity,
939
- qualify,
940
- feature.entityTables?.[entityName],
941
- );
942
- if (projectionMap.has(def.name)) {
943
- throw new Error(
944
- `Implicit projection "${def.name}" kollidiert mit einer explizit registrierten r.projection. ` +
945
- `Implicit-Projections werden für jede r.entity mit "-entity"-Suffix angelegt — ` +
946
- `benenne deine explicit projection um (z.B. "<entity>-summary") um die Kollision aufzulösen.`,
947
- );
948
- }
949
- projectionMap.set(def.name, def);
950
- const existing = projectionsBySource.get(entityName) ?? [];
951
- existing.push(def);
952
- projectionsBySource.set(entityName, existing);
953
- }
954
- }
955
-
956
- // Cross-cut: a r.rawTable() PgTable must not coincide with any
957
- // registered projection's table. Silent dedupe via Set would mask a
958
- // real authoring bug (two owners writing to the same physical table).
959
- // Run after both passes so implicit projections are visible too.
960
- const projectionTables = new Set<unknown>();
961
- for (const proj of projectionMap.values()) projectionTables.add(proj.table);
962
- for (const msp of multiStreamProjectionMap.values()) {
963
- if (msp.table) projectionTables.add(msp.table);
964
- }
965
- for (const raw of rawTableMap.values()) {
966
- if (projectionTables.has(raw.table)) {
967
- throw new Error(
968
- `r.rawTable "${raw.name}" (feature "${raw.featureName}") shares a Drizzle ` +
969
- `PgTable with a registered projection. Pick one owner: r.entity() / ` +
970
- `r.projection() for event-sourced reads, r.rawTable() for the bypass.`,
971
- );
972
- }
973
- }
974
-
975
- for (const [entityName, rels] of relationMap) {
976
- const includes = new Map<string, readonly string[]>();
977
- for (const [relName, rel] of Object.entries(rels)) {
978
- if ((rel.type === "belongsTo" || rel.type === "manyToMany") && rel.searchInclude?.length) {
979
- includes.set(relName, rel.searchInclude);
980
- }
981
- }
982
- searchIncludesCache.set(entityName, includes);
983
-
984
- // Build reverse index for incoming relations
985
- for (const [relName, rel] of Object.entries(rels)) {
986
- const existing = incomingRelationsCache.get(rel.target) ?? [];
987
- existing.push({ sourceEntity: entityName, relationName: relName, relation: rel });
988
- incomingRelationsCache.set(rel.target, existing);
989
- }
990
- }
991
-
992
- // Validate: handlers in features with field-access rules must be entity-mapped.
993
- // Without entity mapping, field-level access checks are silently skipped (security gap).
994
- for (const feature of features) {
995
- if (!hasFieldAccessRules(feature)) continue;
996
-
997
- for (const handlerName of Object.keys(feature.writeHandlers ?? {})) {
998
- const qualified = qualify(feature.name, "write", handlerName);
999
- if (!handlerEntityMap.has(qualified)) {
1000
- throw new Error(
1001
- `Write handler "${qualified}" is not mapped to any entity, but feature "${feature.name}" has field-level access rules. ` +
1002
- `Name must follow "entity:verb" convention (e.g. "user:create") or use create/update/delete on a matching entity.`,
1003
- );
1004
- }
1005
- }
1006
-
1007
- // Query handlers: only those with a colon must resolve (typo protection).
1008
- for (const handlerName of Object.keys(feature.queryHandlers ?? {})) {
1009
- if (!handlerName.includes(":")) continue;
1010
- const qualified = qualify(feature.name, "query", handlerName);
1011
- if (!handlerEntityMap.has(qualified)) {
1012
- throw new Error(
1013
- `Query handler "${qualified}" looks entity-bound but no matching entity exists. ` +
1014
- `Either fix the entity name, or use a name without colons for standalone queries.`,
1015
- );
1016
- }
1017
- }
1018
- }
1019
-
1020
- // Extension preSave: useExtension on an entity must have at least one mapped write handler.
1021
- const extensionsWithPreSave = new Set<string>();
1022
- for (const f of features) {
1023
- for (const [extName, def] of Object.entries(f.registrarExtensions ?? {})) {
1024
- if (def.hooks?.preSave) extensionsWithPreSave.add(extName);
1025
- }
1026
- }
1027
- for (const usage of extensionUsages) {
1028
- if (!extensionsWithPreSave.has(usage.extensionName)) continue;
1029
- const hasMapped = [...writeHandlerMap.keys()].some(
1030
- (qn) => handlerEntityMap.get(qn) === usage.entityName,
1031
- );
1032
- if (!hasMapped) {
1033
- throw new Error(
1034
- `Feature "${usage.featureName}" uses extension "${usage.extensionName}" with preSave on entity "${usage.entityName}" ` +
1035
- `but no write handler is entity-mapped to "${usage.entityName}". ` +
1036
- `Use create/update/delete on a matching entity or name the handler "entity:verb".`,
1037
- );
1038
- }
1039
- }
1040
-
1041
- // Validate: all relation targets must reference existing entities
1042
- for (const [entityName, rels] of relationMap) {
1043
- for (const [relName, rel] of Object.entries(rels)) {
1044
- if (!entityMap.has(rel.target)) {
1045
- throw new Error(
1046
- `Relation "${entityName}.${relName}" targets entity "${rel.target}" which does not exist`,
1047
- );
1048
- }
1049
- }
1050
- }
1051
-
1052
- // Build + validate event upcaster chains. Run AFTER all features are
1053
- // ingested so r.eventMigration calls can reference events from any
1054
- // feature (same feature in practice, but the check stays lax for future
1055
- // cross-feature event packs).
1056
- for (const feature of features) {
1057
- for (const [shortName, migrations] of Object.entries(feature.eventMigrations ?? {})) {
1058
- const qualified = qualify(feature.name, "event", shortName);
1059
- const eventDef = eventMap.get(qualified);
1060
- if (!eventDef) {
1061
- throw new Error(
1062
- `Feature "${feature.name}" registered r.eventMigration for "${shortName}" ` +
1063
- `but no r.defineEvent exists for that name. Register the event first.`,
1064
- );
1065
- }
1066
- for (const m of migrations) {
1067
- if (m.toVersion > eventDef.version) {
1068
- throw new Error(
1069
- `Feature "${feature.name}" has r.eventMigration("${shortName}", ${m.fromVersion}, ${m.toVersion}) ` +
1070
- `but r.defineEvent declares only version ${eventDef.version}. ` +
1071
- `Bump the version in defineEvent to at least ${m.toVersion}, or remove the migration.`,
1072
- );
1073
- }
1074
- }
1075
- }
1076
- }
1077
-
1078
- // Stitch the upcaster chain per qualified event. At this point, gaps in
1079
- // the chain (e.g. defineEvent version=3 but only a 1→2 migration exists)
1080
- // are hard errors — they would silently hand a v2-shape payload to a
1081
- // consumer expecting v3 at runtime, which is the class of bug upcasters
1082
- // are supposed to prevent.
1083
- for (const [qualified, eventDef] of eventMap) {
1084
- const chainMap = new Map<number, EventUpcastFn>();
1085
- // Locate the feature that owns this event (to pick up its migrations).
1086
- for (const feature of features) {
1087
- for (const [shortName, migs] of Object.entries(feature.eventMigrations ?? {})) {
1088
- const candidateQn = qualify(feature.name, "event", shortName);
1089
- if (candidateQn !== qualified) continue;
1090
- for (const m of migs) chainMap.set(m.fromVersion, m.transform);
1091
- }
1092
- }
1093
- if (eventDef.version > 1) {
1094
- for (let v = 1; v < eventDef.version; v++) {
1095
- if (!chainMap.has(v)) {
1096
- throw new Error(
1097
- `Event "${qualified}" declares version ${eventDef.version} but no migration ` +
1098
- `covers the step v${v} → v${v + 1}. Register r.eventMigration("${qualified.split(":").pop() ?? qualified}", ${v}, ${v + 1}, transform) ` +
1099
- `so stored v${v} payloads can be upcast on read.`,
1100
- );
1101
- }
1102
- }
1103
- }
1104
- eventUpcasterMap.set(qualified, {
1105
- currentVersion: eventDef.version,
1106
- chain: chainMap,
1107
- });
1108
- }
1109
-
1110
- // Validate: every projection's source must reference a registered entity.
1111
- // A typo ("unti" instead of "unit") would otherwise be a silent no-op —
1112
- // the projection is stored but never fires because no aggregateType ever
1113
- // matches. Fail at boot so the feature author sees it immediately.
1114
- //
1115
- // Same guard extends to apply-keys: a handler for "unit.creatd" (missing
1116
- // 'e') would silently never fire. Valid apply-keys are the auto-generated
1117
- // CRUD types per source entity PLUS every domain event registered via
1118
- // r.defineEvent — an apply-handler for a domain event is how a projection
1119
- // reacts to ctx.appendEvent writes on the same aggregate stream.
1120
- const AUTO_EVENT_VERBS = ["created", "updated", "deleted", "restored", "forgotten"] as const;
1121
- const allDomainEventNames = new Set(eventMap.keys());
1122
- for (const [projName, projDef] of projectionMap) {
1123
- const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
1124
- const validEventTypes = new Set<string>();
1125
- // Two source-modes are legal:
1126
- //
1127
- // (a) Registered entity (r.entity(src, ...)) — the "normal" case:
1128
- // auto-lifecycle events `<src>.created/.updated/.deleted/.restored`
1129
- // fire when the event-store-executor writes, and any domain-event
1130
- // (r.defineEvent) appended onto an aggregate of that type is
1131
- // observable too.
1132
- //
1133
- // (b) Events-only source — no r.entity registered, but at least one
1134
- // apply-key must be a domain-event (not a CRUD-verb on the source
1135
- // name). Use-case: features that own an append-only event-stream
1136
- // without a CRUD lifecycle, e.g. `deliveryAttempt` (each call to
1137
- // the delivery-service produces one event on a fresh aggregate)
1138
- // or `jobRun` (BullMQ-callback-driven lifecycle, no executor).
1139
- // A "Shape-Anchor"-entity is no longer needed for this case.
1140
- const isEventsOnlySource = !sources.every((src) => entityMap.has(src));
1141
- for (const src of sources) {
1142
- if (entityMap.has(src)) {
1143
- for (const verb of AUTO_EVENT_VERBS) validEventTypes.add(`${src}.${verb}`);
1144
- }
1145
- }
1146
- // Domain events are valid apply-keys for any projection. They arrive via
1147
- // ctx.appendEvent on a specific aggregate — the runtime matches by event
1148
- // type, so a projection can observe domain events whose aggregate matches
1149
- // one of its declared sources.
1150
- for (const domainEvt of allDomainEventNames) validEventTypes.add(domainEvt);
47
+ const state = createInitialState();
1151
48
 
1152
- // In events-only mode, at least one apply-key MUST be a domain-event —
1153
- // otherwise the source is simply a typo (no events will ever fire).
1154
- if (isEventsOnlySource) {
1155
- const hasAnyDomainEvent = Object.keys(projDef.apply).some((k) => allDomainEventNames.has(k));
1156
- if (!hasAnyDomainEvent) {
1157
- const unregistered = sources.filter((src) => !entityMap.has(src));
1158
- throw new Error(
1159
- `Projection "${projName}" declares source(s) [${unregistered.join(", ")}] that are not registered entities, ` +
1160
- `and has no domain-event apply-keys. This is either a typo or a missing r.defineEvent registration. ` +
1161
- `Events-only projections need at least one apply-key from r.defineEvent; ` +
1162
- `CRUD-style projections need r.entity("${unregistered[0]}", ...).`,
1163
- );
1164
- }
1165
- }
1166
-
1167
- for (const applyKey of Object.keys(projDef.apply)) {
1168
- if (!validEventTypes.has(applyKey)) {
1169
- throw new Error(
1170
- `Projection "${projName}" has an apply handler for "${applyKey}" but no such event ` +
1171
- `type exists for its source(s) [${sources.join(", ")}]. ` +
1172
- `Valid types: ${[...validEventTypes].join(", ")}. ` +
1173
- `Check for a typo — auto-verbs follow "<entity>.<verb>"; ` +
1174
- `domain events follow "<feature>:event:<short-name>" (see r.defineEvent).`,
1175
- );
1176
- }
1177
- }
1178
- }
1179
-
1180
- // Validate: all required features must be registered
1181
- for (const feature of features) {
1182
- for (const required of feature.requires ?? []) {
1183
- if (!featureMap.has(required)) {
1184
- throw new Error(
1185
- `Feature "${feature.name}" requires feature "${required}" which is not registered`,
1186
- );
1187
- }
1188
- }
1189
- }
1190
-
1191
- // Resolve notification triggers and register postSave hooks
1192
- // Done after all features are registered so cross-feature triggers work
1193
- const allHandlerNames = new Set([...writeHandlerMap.keys(), ...queryHandlerMap.keys()]);
1194
- for (const [qualifiedName, notifDef] of notificationMap) {
1195
- // Both maps are populated in lockstep — same key-set by construction.
1196
- const featureName = notificationFeatureMap.get(qualifiedName) as string; // @cast-boundary engine-bridge
1197
- // I'll try the easy path first: if the trigger is already a fully qualified QN
1198
- // (cross-feature), I take it as-is. Otherwise I qualify with the own feature —
1199
- // as a write handler first (the common case), then as a query. If nothing
1200
- // matches by then, it was a typo and I'll say so.
1201
- let triggerOn: string;
1202
- if (allHandlerNames.has(notifDef.trigger.on)) {
1203
- triggerOn = notifDef.trigger.on;
1204
- } else {
1205
- // Try as write handler first (most common), then query
1206
- const writeQn = qualify(featureName, "write", notifDef.trigger.on);
1207
- const queryQn = qualify(featureName, "query", notifDef.trigger.on);
1208
- if (allHandlerNames.has(writeQn)) {
1209
- triggerOn = writeQn;
1210
- } else if (allHandlerNames.has(queryQn)) {
1211
- triggerOn = queryQn;
1212
- } else {
1213
- throw new Error(
1214
- `Notification "${qualifiedName}" triggers on "${notifDef.trigger.on}" ` +
1215
- `but no handler with that name exists. ` +
1216
- `Tried: "${notifDef.trigger.on}", "${writeQn}", and "${queryQn}"`,
1217
- );
1218
- }
1219
- }
1220
- // Update the stored definition with resolved trigger
1221
- notificationMap.set(qualifiedName, { ...notifDef, trigger: { on: triggerOn } });
1222
-
1223
- if (!postSaveHooks.has(triggerOn)) postSaveHooks.set(triggerOn, []);
1224
- postSaveHooks.get(triggerOn)?.push({
1225
- phase: HookPhases.afterCommit,
1226
- featureName,
1227
- fn: async (result, context) => {
1228
- if (!context.notify) {
1229
- context.log?.debug(
1230
- `notification ${qualifiedName}: skipping — no notify function configured on context`,
1231
- );
1232
- return;
1233
- }
1234
- const to = notifDef.recipient(result);
1235
- if (to === null) {
1236
- context.log?.debug(
1237
- `notification ${qualifiedName}: skipping — recipient resolver returned null for result ${result.id}`,
1238
- );
1239
- return;
1240
- }
1241
- const data = notifDef.data(result);
1242
- await context.notify(qualifiedName, { to, data });
1243
- },
1244
- });
1245
- }
1246
-
1247
- // Validate: lifecycle hook targets must reference existing handlers
1248
- const allHandlers = allHandlerNames;
1249
- const lifecycleHookMaps = [
1250
- { map: preSaveHooks, phase: "preSave" },
1251
- { map: postSaveHooks, phase: "postSave" },
1252
- { map: preDeleteHooks, phase: "preDelete" },
1253
- { map: postDeleteHooks, phase: "postDelete" },
1254
- { map: preQueryHooks, phase: "preQuery" },
1255
- { map: postQueryHooks, phase: "postQuery" },
1256
- ] as const;
1257
-
1258
- // I'd rather warn you now at boot than have you open a ticket three weeks from now
1259
- // saying "my hook isn't firing". One typo in the target and the thing goes silent.
1260
- for (const { map, phase } of lifecycleHookMaps) {
1261
- for (const hookTarget of map.keys()) {
1262
- if (!allHandlers.has(hookTarget)) {
1263
- throw new Error(
1264
- `${phase} hook targets "${hookTarget}" but no handler with that name exists. ` +
1265
- `Check for typos — the hook will never fire.`,
1266
- );
1267
- }
1268
- }
1269
- }
1270
-
1271
- // Same logic for entity-keyed hooks — targets must reference existing entities.
1272
- // Memory `feedback_dead_hook_needs_second_consumer`: a typo silently registers
1273
- // and never fires. Validates all four entity-hook types (postSave/preDelete/
1274
- // postDelete/postQuery) — net cleanup of an existing antipattern, not a
1275
- // postQuery-special.
1276
- const allEntities = new Set<string>();
1277
49
  for (const feature of features) {
1278
- for (const entityName of Object.keys(feature.entities ?? {})) {
1279
- allEntities.add(entityName);
1280
- }
1281
- }
1282
- const entityHookMaps = [
1283
- { map: entityPostSaveHooks, phase: "postSave (entityHook)", kind: "hook" },
1284
- { map: entityPreDeleteHooks, phase: "preDelete (entityHook)", kind: "hook" },
1285
- { map: entityPostDeleteHooks, phase: "postDelete (entityHook)", kind: "hook" },
1286
- { map: entityPostQueryHooks, phase: "postQuery (entityHook)", kind: "hook" },
1287
- { map: searchPayloadExtensions, phase: "searchPayloadExtension", kind: "extension" },
1288
- ] as const;
1289
- for (const { map, phase, kind } of entityHookMaps) {
1290
- for (const entityName of map.keys()) {
1291
- if (!allEntities.has(entityName)) {
1292
- throw new Error(
1293
- `${phase} ${kind} targets entity "${entityName}" but no entity with that name exists. ` +
1294
- `Check for typos — the ${kind} will never fire.`,
1295
- );
1296
- }
1297
- }
1298
- }
1299
-
1300
- // Validate: job event triggers must reference existing handlers.
1301
- // Multi-Trigger-Form: jeden Eintrag im Array gegen allHandlers prüfen,
1302
- // auch wenn nur einer fehlt fail-fast.
1303
- for (const [jobName, jobDef] of jobMap) {
1304
- if (!("on" in jobDef.trigger)) continue;
1305
- const triggerOn = jobDef.trigger.on;
1306
- const triggers = Array.isArray(triggerOn) ? triggerOn : [triggerOn];
1307
- for (const t of triggers) {
1308
- const rawName = resolveName(t);
1309
- if (allHandlers.has(rawName)) continue;
1310
- throw new Error(
1311
- `Job "${jobName}" triggers on "${rawName}" but no handler with that name exists`,
1312
- );
1313
- }
1314
- }
1315
-
1316
- // Validate: extension usages must reference existing extensions
1317
- for (const usage of extensionUsages) {
1318
- if (!extensionMap.has(usage.extensionName)) {
1319
- throw new Error(
1320
- `Extension usage "${usage.extensionName}" on entity "${usage.entityName}" references an extension that does not exist`,
1321
- );
1322
- }
1323
- }
1324
-
1325
- // Pre-compute: any handler with a rateLimit option? Keeps the boot
1326
- // path able to short-circuit the RateLimitResolver wiring (and its
1327
- // Lua-script registration on Redis) when nobody opted in.
1328
- const hasRateLimitedHandlerCached = (() => {
1329
- for (const h of writeHandlerMap.values()) if (h.rateLimit !== undefined) return true;
1330
- for (const h of queryHandlerMap.values()) if (h.rateLimit !== undefined) return true;
1331
- return false;
1332
- })();
1333
-
1334
- // Auto-wire the soft-delete cleanup cron + its grace-days config key when ANY
1335
- // entity opts into softDelete — the framework owns this machinery, no feature
1336
- // declares it (mirrors the auto restore-handler). Job-runner reads getAllJobs
1337
- // ungated; config-resolver reads getConfigKey → default. Reserved owner
1338
- // segment, guarded against a real-feature collision.
1339
- if ([...entityMap.values()].some((e) => e.softDelete)) {
1340
- if (!jobMap.has(SOFT_DELETE_CLEANUP_JOB)) {
1341
- jobMap.set(SOFT_DELETE_CLEANUP_JOB, buildSoftDeleteCleanupJob());
1342
- }
1343
- if (!jobMap.has(SOFT_DELETE_CLEANUP_SYSTEM_JOB)) {
1344
- jobMap.set(SOFT_DELETE_CLEANUP_SYSTEM_JOB, buildSoftDeleteCleanupSystemJob());
1345
- }
1346
- if (!configKeyMap.has(SOFT_DELETE_GRACE_DAYS_KEY)) {
1347
- configKeyMap.set(SOFT_DELETE_GRACE_DAYS_KEY, softDeleteGraceDaysConfig);
1348
- }
1349
- }
1350
-
1351
- return {
1352
- features: featureMap,
1353
-
1354
- getFeature(name: string): FeatureDefinition | undefined {
1355
- return featureMap.get(name);
1356
- },
1357
-
1358
- hasRateLimitedHandler(): boolean {
1359
- return hasRateLimitedHandlerCached;
1360
- },
1361
-
1362
- getEntity(name: string): EntityDefinition | undefined {
1363
- return entityMap.get(name);
1364
- },
1365
-
1366
- getWriteHandler(name: string): WriteHandlerDef | undefined {
1367
- return writeHandlerMap.get(name);
1368
- },
1369
-
1370
- getQueryHandler(name: string): QueryHandlerDef | undefined {
1371
- return queryHandlerMap.get(name);
1372
- },
1373
-
1374
- getSearchableFields(entityName: string): readonly string[] {
1375
- return searchableFieldsCache.get(entityName) ?? [];
1376
- },
1377
-
1378
- getSortableFields(entityName: string): readonly string[] {
1379
- return sortableFieldsCache.get(entityName) ?? [];
1380
- },
1381
-
1382
- getRelations(entityName: string): EntityRelations {
1383
- return (relationMap.get(entityName) ?? {}) as EntityRelations; // @cast-boundary schema-walk
1384
- },
1385
-
1386
- getSearchIncludes(entityName: string): ReadonlyMap<string, readonly string[]> {
1387
- return searchIncludesCache.get(entityName) ?? new Map();
1388
- },
1389
-
1390
- getIncomingRelations(entityName: string): readonly IncomingRelation[] {
1391
- return incomingRelationsCache.get(entityName) ?? [];
1392
- },
1393
-
1394
- getPreSaveHooks(
1395
- name: string,
1396
- effectiveFeatures?: ReadonlySet<string>,
1397
- ): readonly PreSaveHookFn[] {
1398
- return filterOwned(preSaveHooks.get(name), effectiveFeatures);
1399
- },
1400
-
1401
- getPostSaveHooks(
1402
- name: string,
1403
- phase?: HookPhase,
1404
- effectiveFeatures?: ReadonlySet<string>,
1405
- ): readonly PostSaveHookFn[] {
1406
- return filterByPhase(postSaveHooks.get(name), phase, effectiveFeatures);
1407
- },
1408
-
1409
- getPreDeleteHooks(
1410
- name: string,
1411
- phase?: HookPhase,
1412
- effectiveFeatures?: ReadonlySet<string>,
1413
- ): readonly PreDeleteHookFn[] {
1414
- return filterByPhase(preDeleteHooks.get(name), phase, effectiveFeatures);
1415
- },
1416
-
1417
- getPostDeleteHooks(
1418
- name: string,
1419
- phase?: HookPhase,
1420
- effectiveFeatures?: ReadonlySet<string>,
1421
- ): readonly PostDeleteHookFn[] {
1422
- return filterByPhase(postDeleteHooks.get(name), phase, effectiveFeatures);
1423
- },
1424
-
1425
- getPreQueryHooks(
1426
- name: string,
1427
- effectiveFeatures?: ReadonlySet<string>,
1428
- ): readonly PreQueryHookFn[] {
1429
- return filterOwned(preQueryHooks.get(name), effectiveFeatures);
1430
- },
1431
-
1432
- getPostQueryHooks(
1433
- name: string,
1434
- effectiveFeatures?: ReadonlySet<string>,
1435
- ): readonly PostQueryHookFn[] {
1436
- return filterOwned(postQueryHooks.get(name), effectiveFeatures);
1437
- },
1438
-
1439
- // Entity hooks — fire for all writes on an entity
1440
- getEntityPostSaveHooks(
1441
- entityName: string,
1442
- phase?: HookPhase,
1443
- effectiveFeatures?: ReadonlySet<string>,
1444
- ): readonly PostSaveHookFn[] {
1445
- return filterByPhase(entityPostSaveHooks.get(entityName), phase, effectiveFeatures);
1446
- },
1447
-
1448
- getEntityPreDeleteHooks(
1449
- entityName: string,
1450
- phase?: HookPhase,
1451
- effectiveFeatures?: ReadonlySet<string>,
1452
- ): readonly PreDeleteHookFn[] {
1453
- return filterByPhase(entityPreDeleteHooks.get(entityName), phase, effectiveFeatures);
1454
- },
1455
-
1456
- getEntityPostDeleteHooks(
1457
- entityName: string,
1458
- phase?: HookPhase,
1459
- effectiveFeatures?: ReadonlySet<string>,
1460
- ): readonly PostDeleteHookFn[] {
1461
- return filterByPhase(entityPostDeleteHooks.get(entityName), phase, effectiveFeatures);
1462
- },
1463
-
1464
- getEntityPostQueryHooks(
1465
- entityName: string,
1466
- effectiveFeatures?: ReadonlySet<string>,
1467
- ): readonly PostQueryHookFn[] {
1468
- return filterOwned(entityPostQueryHooks.get(entityName), effectiveFeatures);
1469
- },
1470
-
1471
- // F3 — Search-Payload-Extension contributors for an entity. Used by
1472
- // `buildSearchDocument` in system-hooks.ts to enrich the indexed payload.
1473
- // `effectiveFeatures` filters out contributors owned by feature-toggle-
1474
- // disabled features (parallel to getEntityPostQueryHooks etc.).
1475
- getSearchPayloadExtensions(
1476
- entityName: string,
1477
- effectiveFeatures?: ReadonlySet<string>,
1478
- ): readonly SearchPayloadContributorFn[] {
1479
- return filterOwned(searchPayloadExtensions.get(entityName), effectiveFeatures);
1480
- },
1481
-
1482
- getAllTranslations(): TranslationKeys {
1483
- return mergedTranslations;
1484
- },
1485
-
1486
- getHandlerEntity(qualifiedHandler: string): string | undefined {
1487
- return handlerEntityMap.get(qualifiedHandler);
1488
- },
1489
-
1490
- isHandlerSystemScoped(qualifiedHandler: string): boolean {
1491
- const featureName = handlerFeatureMap.get(qualifiedHandler);
1492
- if (!featureName) return false;
1493
- return featureMap.get(featureName)?.systemScope ?? false;
1494
- },
1495
-
1496
- getHandlerFeature(qualifiedHandler: string): string | undefined {
1497
- return handlerFeatureMap.get(qualifiedHandler);
1498
- },
1499
-
1500
- getAllMetrics() {
1501
- return metricMap;
1502
- },
1503
-
1504
- getAllSecretKeys(): ReadonlyMap<string, SecretKeyDefinition> {
1505
- return secretKeyMap;
1506
- },
1507
-
1508
- getSecretKey(qualifiedName: string): SecretKeyDefinition | undefined {
1509
- return secretKeyMap.get(qualifiedName);
1510
- },
1511
-
1512
- getConfigKey(qualifiedKey: string): ConfigKeyDefinition | undefined {
1513
- return configKeyMap.get(qualifiedKey);
1514
- },
1515
-
1516
- getAllConfigKeys(): ReadonlyMap<string, ConfigKeyDefinition> {
1517
- return configKeyMap;
1518
- },
1519
-
1520
- getJob(qualifiedName: string): JobDefinition | undefined {
1521
- return jobMap.get(qualifiedName);
1522
- },
1523
-
1524
- getAllJobs(): ReadonlyMap<string, JobDefinition> {
1525
- return jobMap;
1526
- },
1527
-
1528
- getEvent(qualifiedName: string): EventDef | undefined {
1529
- return eventMap.get(qualifiedName);
1530
- },
1531
-
1532
- getEventUpcasters() {
1533
- return eventUpcasterMap;
1534
- },
1535
-
1536
- getExtension(name: string): RegistrarExtensionDef | undefined {
1537
- return extensionMap.get(name);
1538
- },
1539
-
1540
- getExtensionUsages(extensionName: string): readonly RegistrarExtensionRegistration[] {
1541
- return extensionUsages.filter((u) => u.extensionName === extensionName);
1542
- },
1543
-
1544
- getAllExtensionSelectors(): ReadonlyMap<string, string> {
1545
- return extensionSelectorMap;
1546
- },
1547
-
1548
- getAllNotifications(): ReadonlyMap<string, NotificationDefinition> {
1549
- return notificationMap;
1550
- },
1551
-
1552
- getAllReferenceData(): readonly ReferenceDataDef[] {
1553
- return allReferenceData;
1554
- },
1555
-
1556
- getAllConfigSeeds(): readonly ConfigSeedDef[] {
1557
- return allConfigSeeds;
1558
- },
1559
-
1560
- getProjectionsForSource(entityName: string): readonly ProjectionDefinition[] {
1561
- return projectionsBySource.get(entityName) ?? [];
1562
- },
1563
-
1564
- getAllProjections(): ReadonlyMap<string, ProjectionDefinition> {
1565
- return projectionMap;
1566
- },
1567
-
1568
- getAllRawTables(): ReadonlyMap<string, RawTableDef> {
1569
- return rawTableMap;
1570
- },
1571
-
1572
- getAllMultiStreamProjections(): ReadonlyMap<string, MultiStreamProjectionDefinition> {
1573
- return multiStreamProjectionMap;
1574
- },
1575
-
1576
- getMultiStreamProjectionFeature(qualifiedName: string): string | undefined {
1577
- return multiStreamProjectionFeatureMap.get(qualifiedName);
1578
- },
1579
-
1580
- getAuthClaimsHooks(): readonly AuthClaimsHookDef[] {
1581
- return authClaimsHooks;
1582
- },
1583
-
1584
- getAllClaimKeys(): ReadonlyMap<string, ClaimKeyDefinition> {
1585
- return claimKeyMap;
1586
- },
1587
-
1588
- getClaimKey(qualifiedName: string): ClaimKeyDefinition | undefined {
1589
- return claimKeyMap.get(qualifiedName);
1590
- },
1591
-
1592
- getAllScreens(): ReadonlyMap<string, ScreenDefinition> {
1593
- return screenMap;
1594
- },
1595
-
1596
- getScreen(qualifiedName: string): ScreenDefinition | undefined {
1597
- return screenMap.get(qualifiedName);
1598
- },
1599
-
1600
- getScreenFeature(qualifiedName: string): string | undefined {
1601
- return screenFeatureMap.get(qualifiedName);
1602
- },
1603
-
1604
- getScreensByEntity(entityName: string): readonly ScreenDefinition[] {
1605
- return screensByEntity.get(entityName) ?? [];
1606
- },
1607
-
1608
- getAllNavs(): ReadonlyMap<string, NavDefinition> {
1609
- return navMap;
1610
- },
1611
-
1612
- getNav(qualifiedName: string): NavDefinition | undefined {
1613
- return navMap.get(qualifiedName);
1614
- },
1615
-
1616
- getNavFeature(qualifiedName: string): string | undefined {
1617
- return navFeatureMap.get(qualifiedName);
1618
- },
1619
-
1620
- getNavsByParent(parentQualifiedName: string): readonly NavDefinition[] {
1621
- return navsByParent.get(parentQualifiedName) ?? [];
1622
- },
1623
-
1624
- getTopLevelNavs(): readonly NavDefinition[] {
1625
- return topLevelNavs;
1626
- },
1627
-
1628
- getAllWorkspaces(): ReadonlyMap<string, WorkspaceDefinition> {
1629
- return workspaceMap;
1630
- },
1631
-
1632
- getWorkspace(qualifiedName: string): WorkspaceDefinition | undefined {
1633
- return workspaceMap.get(qualifiedName);
1634
- },
1635
-
1636
- getWorkspaceFeature(qualifiedName: string): string | undefined {
1637
- return workspaceFeatureMap.get(qualifiedName);
1638
- },
1639
-
1640
- getWorkspaceNavs(workspaceQualifiedName: string): readonly string[] {
1641
- return navsByWorkspace.get(workspaceQualifiedName) ?? [];
1642
- },
1643
-
1644
- getDefaultWorkspace(): WorkspaceDefinition | undefined {
1645
- return defaultWorkspace;
1646
- },
1647
-
1648
- getTreeProviders(): ReadonlyMap<string, TreeChildrenSubscribe> {
1649
- return treeProvidersMap;
1650
- },
1651
-
1652
- getTreeActions(featureName: string): Readonly<Record<string, TreeActionDef>> | undefined {
1653
- return treeActionsMap.get(featureName);
1654
- },
1655
- };
1656
- }
1657
-
1658
- /** Returns true if any entity in the feature has field-level access rules (read or write). */
1659
- function hasFieldAccessRules(feature: FeatureDefinition): boolean {
1660
- for (const entity of Object.values(feature.entities ?? {})) {
1661
- for (const field of Object.values(entity.fields)) {
1662
- if (field.access?.read?.length || field.access?.write?.length) {
1663
- return true;
1664
- }
1665
- }
1666
- }
1667
- return false;
50
+ populateFeatureCore(state, feature);
51
+ populateHandlers(state, feature);
52
+ populateConfigKeys(state, feature);
53
+ populateJobsAndNotifications(state, feature);
54
+ populateEvents(state, feature);
55
+ populateTranslations(state, feature);
56
+ populateHooks(state, feature);
57
+ populateExtensionsAndSeeds(state, feature);
58
+ populateMetricsAndSecrets(state, feature);
59
+ populateProjectionsAndTables(state, feature);
60
+ populateClaimsAndAuth(state, feature);
61
+ populateScreensNavWorkspaces(state, feature);
62
+ }
63
+
64
+ finalizeWorkspaceNavMembership(state);
65
+ populateHandlerEntityMappings(state, features);
66
+ validateExtensionSelectors(state);
67
+ applyExtensionUsages(state);
68
+ buildSearchableSortableCaches(state);
69
+ buildImplicitProjections(state, features);
70
+ buildSearchIncludesAndIncomingRelations(state);
71
+ validateFieldAccessHandlersAreEntityMapped(state, features);
72
+ validateExtensionPreSaveWiring(features);
73
+ validateRelationTargetsExist(state);
74
+ validateEventMigrationVersions(state, features);
75
+ buildEventUpcasterChains(state, features);
76
+ validateProjectionApplyKeys(state);
77
+ validateRequiredFeatures(state, features);
78
+ resolveNotificationTriggersAndRegisterHooks(state);
79
+ validateLifecycleHookTargets(state);
80
+ validateEntityHookTargets(state, features);
81
+ validateJobTriggers(state);
82
+ validateExtensionUsageTargets(state);
83
+ computeHasRateLimitedHandler(state);
84
+ publishEventPiiCatalog(state);
85
+ autoWireSoftDeleteJobs(state);
86
+
87
+ return buildRegistryFacade(state);
1668
88
  }