@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
@@ -0,0 +1,642 @@
1
+ import { configureEventPiiCatalog } from "../crypto/event-pii";
2
+ import { resolveName } from "./handler-helpers";
3
+ import type { RegistryState } from "./registry-state";
4
+ import { buildImplicitProjection, hasFieldAccessRules, qualify } from "./registry-state";
5
+ import {
6
+ buildSoftDeleteCleanupJob,
7
+ buildSoftDeleteCleanupSystemJob,
8
+ SOFT_DELETE_CLEANUP_JOB,
9
+ SOFT_DELETE_CLEANUP_SYSTEM_JOB,
10
+ SOFT_DELETE_GRACE_DAYS_KEY,
11
+ softDeleteGraceDaysConfig,
12
+ } from "./soft-delete-cleanup";
13
+ import type { EventPiiFields, EventUpcastFn, FeatureDefinition } from "./types";
14
+ import { HookPhases } from "./types";
15
+
16
+ function allHandlerQns(state: RegistryState): ReadonlySet<string> {
17
+ return new Set([...state.writeHandlerMap.keys(), ...state.queryHandlerMap.keys()]);
18
+ }
19
+
20
+ // Pass 2 for workspaces: fold any nav-self-assigned QNs into their
21
+ // workspace's member list. Safe now that every feature (and therefore every
22
+ // workspace) is in state.workspaceMap.
23
+ export function finalizeWorkspaceNavMembership(state: RegistryState): void {
24
+ // workspace's member list. We can do this safely now that every feature
25
+ // (and therefore every workspace) is in state.workspaceMap. Cross-feature refs
26
+ // — a nav from feature A self-assigning to a workspace from feature B —
27
+ // resolve here because B's workspace was registered in pass 1 above.
28
+ // Dedup: a nav entry that's also in r.workspace({ nav: [...] }) shouldn't
29
+ // appear twice. Boot-validator catches dangling workspace ids.
30
+ for (const [navQn, navDef] of state.navMap) {
31
+ if (!navDef.workspaces || navDef.workspaces.length === 0) continue;
32
+ for (const wsQn of navDef.workspaces) {
33
+ const members = state.navsByWorkspace.get(wsQn);
34
+ if (members === undefined) continue; // dangling — boot-validator reports
35
+ if (!members.includes(navQn)) members.push(navQn);
36
+ }
37
+ }
38
+ }
39
+
40
+ // Build handler → entity mapping from feature declarations. Must happen before
41
+ // extension processing since extension preSave hooks need entity mappings.
42
+ export function populateHandlerEntityMappings(
43
+ state: RegistryState,
44
+ features: readonly FeatureDefinition[],
45
+ ): void {
46
+ // Build handler → entity mapping from feature declarations (filled by tryMapEntity
47
+ // in defineFeature via the "entityName:verb" colon convention).
48
+ // Must happen before extension processing since extension preSave hooks need entity mappings.
49
+ for (const feature of features) {
50
+ for (const [handlerName, entityName] of Object.entries(feature.handlerEntityMappings ?? {})) {
51
+ const writeQn = qualify(feature.name, "write", handlerName);
52
+ const queryQn = qualify(feature.name, "query", handlerName);
53
+ if (state.writeHandlerMap.has(writeQn)) {
54
+ state.handlerEntityMap.set(writeQn, entityName);
55
+ }
56
+ if (state.queryHandlerMap.has(queryQn)) {
57
+ state.handlerEntityMap.set(queryQn, entityName);
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ export function validateExtensionSelectors(state: RegistryState): void {
64
+ // Selector declarations point into the merged extension + config-key
65
+ // sets — a typo'd extension or dropped key must fail the boot, not
66
+ // silently un-gate readiness.
67
+ for (const [extensionName, qualifiedKey] of state.extensionSelectorMap) {
68
+ if (!state.extensionMap.has(extensionName)) {
69
+ throw new Error(
70
+ `extensionSelector("${extensionName}") declared but no feature ` +
71
+ `registers that extension via extendsRegistrar.`,
72
+ );
73
+ }
74
+ if (!state.configKeyMap.has(qualifiedKey)) {
75
+ throw new Error(
76
+ `extensionSelector("${extensionName}") points at unknown config key ` +
77
+ `"${qualifiedKey}" — no mounted feature declares it.`,
78
+ );
79
+ }
80
+ }
81
+ }
82
+
83
+ // Process extension usages: call onRegister, apply extendSchema, register hooks.
84
+ export function applyExtensionUsages(state: RegistryState): void {
85
+ // Process extension usages: call onRegister, apply extendSchema, register hooks
86
+ for (const usage of state.extensionUsages) {
87
+ const ext = state.extensionMap.get(usage.extensionName);
88
+ if (!ext) continue;
89
+
90
+ if (ext.onRegister) {
91
+ ext.onRegister(usage.entityName, usage.options);
92
+ }
93
+
94
+ // extendSchema: merge extra fields into entity definition
95
+ if (ext.extendSchema) {
96
+ const entity = state.entityMap.get(usage.entityName);
97
+ if (entity) {
98
+ const extraFields = ext.extendSchema(usage.entityName);
99
+ const merged = { ...entity, fields: { ...entity.fields, ...extraFields } };
100
+ state.entityMap.set(usage.entityName, merged);
101
+ }
102
+ }
103
+
104
+ // Extension hooks → entity hooks (fire for all writes on the entity).
105
+ // Extensions default to afterCommit phase (same default as r.hook).
106
+ //
107
+ // Owner "*" = always-enabled, not gated by feature-toggles. Extensions
108
+ // are plumbing (e.g. ownership) — the feature that declared them might
109
+ // itself be toggleable, but the extension-hook is conceptually part of
110
+ // the entity's invariants. If future requirements need extension hooks
111
+ // to also be gated, store the registering-feature on
112
+ // RegistrarExtensionRegistration and use that here.
113
+ const extOwner = "*";
114
+ if (ext.hooks) {
115
+ if (ext.hooks.postSave) {
116
+ const existing = state.entityPostSaveHooks.get(usage.entityName) ?? [];
117
+ existing.push({
118
+ fn: ext.hooks.postSave,
119
+ phase: HookPhases.afterCommit,
120
+ featureName: extOwner,
121
+ });
122
+ state.entityPostSaveHooks.set(usage.entityName, existing);
123
+ }
124
+ if (ext.hooks.preDelete) {
125
+ const existing = state.entityPreDeleteHooks.get(usage.entityName) ?? [];
126
+ existing.push({
127
+ fn: ext.hooks.preDelete,
128
+ phase: HookPhases.afterCommit,
129
+ featureName: extOwner,
130
+ });
131
+ state.entityPreDeleteHooks.set(usage.entityName, existing);
132
+ }
133
+ if (ext.hooks.postDelete) {
134
+ const existing = state.entityPostDeleteHooks.get(usage.entityName) ?? [];
135
+ existing.push({
136
+ fn: ext.hooks.postDelete,
137
+ phase: HookPhases.afterCommit,
138
+ featureName: extOwner,
139
+ });
140
+ state.entityPostDeleteHooks.set(usage.entityName, existing);
141
+ }
142
+ // preSave on extensions: store as handler hook for all CRUD handlers of this entity
143
+ if (ext.hooks.preSave) {
144
+ // Find all write handlers that belong to this entity via state.handlerEntityMap
145
+ for (const qualifiedHandler of state.writeHandlerMap.keys()) {
146
+ if (state.handlerEntityMap.get(qualifiedHandler) === usage.entityName) {
147
+ const existing = state.preSaveHooks.get(qualifiedHandler) ?? [];
148
+ existing.push({ fn: ext.hooks.preSave, featureName: extOwner });
149
+ state.preSaveHooks.set(qualifiedHandler, existing);
150
+ }
151
+ }
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ // Precompute: searchable/sortable fields.
158
+ export function buildSearchableSortableCaches(state: RegistryState): void {
159
+ // Precompute: searchable/sortable fields, search includes, incoming relations
160
+
161
+ for (const [name, entity] of state.entityMap) {
162
+ const searchable: string[] = [];
163
+ const sortable: string[] = [];
164
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
165
+ if (field.type === "text" && field.searchable === true) searchable.push(fieldName);
166
+ if (field.type === "text" && field.sortable === true) sortable.push(fieldName);
167
+ if (field.type === "embedded") {
168
+ for (const [subName, subField] of Object.entries(field.schema)) {
169
+ if (subField.searchable === true) searchable.push(`${fieldName}_${subName}`);
170
+ }
171
+ }
172
+ }
173
+ state.searchableFieldsCache.set(name, searchable);
174
+ state.sortableFieldsCache.set(name, sortable);
175
+ }
176
+ }
177
+
178
+ // Implicit-Projection pro r.entity — see buildImplicitProjection doc above.
179
+ export function buildImplicitProjections(
180
+ state: RegistryState,
181
+ features: readonly FeatureDefinition[],
182
+ ): void {
183
+ // Implicit-Projection pro r.entity. Macht die Entity-Tabelle rebaubar
184
+ // ohne dass Apps eine explizite r.projection schreiben müssen.
185
+ // Naming-Convention: `<feature>:projection:<entityName>-entity` — der
186
+ // "-entity"-Suffix unterscheidet implicit von explicit-Projections und
187
+ // vermeidet Kollisionen wenn jemand z.B. eine Cross-Aggregate-Projection
188
+ // mit Entity-Name registriert.
189
+ for (const feature of features) {
190
+ // extendEntityProjection targets must exist as r.entity in the SAME
191
+ // feature — a typo'd entity name would otherwise vanish silently and the
192
+ // extension's events would still be wiped on rebuild.
193
+ for (const extEntity of Object.keys(feature.entityProjectionExtensions ?? {})) {
194
+ if (!feature.entities?.[extEntity]) {
195
+ throw new Error(
196
+ `[Feature ${feature.name}] extendEntityProjection("${extEntity}"): no r.entity ` +
197
+ `with that name in this feature. Declare the entity first — the extension ` +
198
+ `merges into its implicit projection.`,
199
+ );
200
+ }
201
+ }
202
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
203
+ const def = buildImplicitProjection(
204
+ feature.name,
205
+ entityName,
206
+ entity,
207
+ qualify,
208
+ feature.entityTables?.[entityName],
209
+ feature.entityProjectionExtensions?.[entityName] ?? [],
210
+ );
211
+ if (state.projectionMap.has(def.name)) {
212
+ throw new Error(
213
+ `Implicit projection "${def.name}" kollidiert mit einer explizit registrierten r.projection. ` +
214
+ `Implicit-Projections werden für jede r.entity mit "-entity"-Suffix angelegt — ` +
215
+ `benenne deine explicit projection um (z.B. "<entity>-summary") um die Kollision aufzulösen.`,
216
+ );
217
+ }
218
+ state.projectionMap.set(def.name, def);
219
+ const existing = state.projectionsBySource.get(entityName) ?? [];
220
+ existing.push(def);
221
+ state.projectionsBySource.set(entityName, existing);
222
+ }
223
+ }
224
+ }
225
+
226
+ export function buildSearchIncludesAndIncomingRelations(state: RegistryState): void {
227
+ for (const [entityName, rels] of state.relationMap) {
228
+ const includes = new Map<string, readonly string[]>();
229
+ for (const [relName, rel] of Object.entries(rels)) {
230
+ if ((rel.type === "belongsTo" || rel.type === "manyToMany") && rel.searchInclude?.length) {
231
+ includes.set(relName, rel.searchInclude);
232
+ }
233
+ }
234
+ state.searchIncludesCache.set(entityName, includes);
235
+
236
+ // Build reverse index for incoming relations
237
+ for (const [relName, rel] of Object.entries(rels)) {
238
+ const existing = state.incomingRelationsCache.get(rel.target) ?? [];
239
+ existing.push({ sourceEntity: entityName, relationName: relName, relation: rel });
240
+ state.incomingRelationsCache.set(rel.target, existing);
241
+ }
242
+ }
243
+ }
244
+
245
+ export function validateFieldAccessHandlersAreEntityMapped(
246
+ state: RegistryState,
247
+ features: readonly FeatureDefinition[],
248
+ ): void {
249
+ // Validate: handlers in features with field-access rules must be entity-mapped.
250
+ // Without entity mapping, field-level access checks are silently skipped (security gap).
251
+ for (const feature of features) {
252
+ if (!hasFieldAccessRules(feature)) continue;
253
+
254
+ for (const handlerName of Object.keys(feature.writeHandlers ?? {})) {
255
+ const qualified = qualify(feature.name, "write", handlerName);
256
+ if (!state.handlerEntityMap.has(qualified)) {
257
+ throw new Error(
258
+ `Write handler "${qualified}" is not mapped to any entity, but feature "${feature.name}" has field-level access rules. ` +
259
+ `Name must follow "entity:verb" convention (e.g. "user:create") or use create/update/delete on a matching entity.`,
260
+ );
261
+ }
262
+ }
263
+
264
+ // Query handlers: only those with a colon must resolve (typo protection).
265
+ for (const handlerName of Object.keys(feature.queryHandlers ?? {})) {
266
+ if (!handlerName.includes(":")) continue;
267
+ const qualified = qualify(feature.name, "query", handlerName);
268
+ if (!state.handlerEntityMap.has(qualified)) {
269
+ throw new Error(
270
+ `Query handler "${qualified}" looks entity-bound but no matching entity exists. ` +
271
+ `Either fix the entity name, or use a name without colons for standalone queries.`,
272
+ );
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ export function validateRelationTargetsExist(state: RegistryState): void {
279
+ // Validate: all relation targets must reference existing entities
280
+ for (const [entityName, rels] of state.relationMap) {
281
+ for (const [relName, rel] of Object.entries(rels)) {
282
+ if (!state.entityMap.has(rel.target)) {
283
+ throw new Error(
284
+ `Relation "${entityName}.${relName}" targets entity "${rel.target}" which does not exist`,
285
+ );
286
+ }
287
+ }
288
+ }
289
+ }
290
+
291
+ export function validateEventMigrationVersions(
292
+ state: RegistryState,
293
+ features: readonly FeatureDefinition[],
294
+ ): void {
295
+ // Build + validate event upcaster chains. Run AFTER all features are
296
+ // ingested so defineEvent's `migrations` option can reference events from
297
+ // any feature (same feature in practice, but the check stays lax for
298
+ // future cross-feature event packs).
299
+ for (const feature of features) {
300
+ for (const [shortName, migrations] of Object.entries(feature.eventMigrations ?? {})) {
301
+ const qualified = qualify(feature.name, "event", shortName);
302
+ // registerEventMigration only ever runs alongside state.events[name]=def
303
+ // in the same defineEvent call (feature-config-events-jobs.ts:260-269),
304
+ // so a migrations entry here guarantees a matching eventMap entry.
305
+ // biome-ignore lint/style/noNonNullAssertion: invariant proven above
306
+ const eventDef = state.eventMap.get(qualified)!;
307
+ for (const m of migrations) {
308
+ if (m.toVersion > eventDef.version) {
309
+ throw new Error(
310
+ `Feature "${feature.name}" declares a migration for event "${shortName}" from v${m.fromVersion} to v${m.toVersion} ` +
311
+ `but r.defineEvent declares only version ${eventDef.version}. ` +
312
+ `Bump the version in defineEvent to at least ${m.toVersion}, or remove the migration.`,
313
+ );
314
+ }
315
+ }
316
+ }
317
+ }
318
+ }
319
+
320
+ export function buildEventUpcasterChains(
321
+ state: RegistryState,
322
+ features: readonly FeatureDefinition[],
323
+ ): void {
324
+ // Stitch the upcaster chain per qualified event. At this point, gaps in
325
+ // the chain (e.g. defineEvent version=3 but only a 1→2 migration exists)
326
+ // are hard errors — they would silently hand a v2-shape payload to a
327
+ // consumer expecting v3 at runtime, which is the class of bug upcasters
328
+ // are supposed to prevent.
329
+ for (const [qualified, eventDef] of state.eventMap) {
330
+ const chainMap = new Map<number, EventUpcastFn>();
331
+ // Locate the feature that owns this event (to pick up its migrations).
332
+ for (const feature of features) {
333
+ for (const [shortName, migs] of Object.entries(feature.eventMigrations ?? {})) {
334
+ const candidateQn = qualify(feature.name, "event", shortName);
335
+ if (candidateQn !== qualified) continue;
336
+ for (const m of migs) chainMap.set(m.fromVersion, m.transform);
337
+ }
338
+ }
339
+ if (eventDef.version > 1) {
340
+ for (let v = 1; v < eventDef.version; v++) {
341
+ if (!chainMap.has(v)) {
342
+ throw new Error(
343
+ `Event "${qualified}" declares version ${eventDef.version} but no migration ` +
344
+ `covers the step v${v} → v${v + 1}. Add { fromVersion: ${v}, toVersion: ${v + 1}, transform } to r.defineEvent("${qualified.split(":").pop() ?? qualified}", ...)'s migrations array ` +
345
+ `so stored v${v} payloads can be upcast on read.`,
346
+ );
347
+ }
348
+ }
349
+ }
350
+ state.eventUpcasterMap.set(qualified, {
351
+ currentVersion: eventDef.version,
352
+ chain: chainMap,
353
+ });
354
+ }
355
+ }
356
+
357
+ export function validateProjectionApplyKeys(state: RegistryState): void {
358
+ // Validate: every projection's source must reference a registered entity.
359
+ // A typo ("unti" instead of "unit") would otherwise be a silent no-op —
360
+ // the projection is stored but never fires because no aggregateType ever
361
+ // matches. Fail at boot so the feature author sees it immediately.
362
+ //
363
+ // Same guard extends to apply-keys: a handler for "unit.creatd" (missing
364
+ // 'e') would silently never fire. Valid apply-keys are the auto-generated
365
+ // CRUD types per source entity PLUS every domain event registered via
366
+ // r.defineEvent — an apply-handler for a domain event is how a projection
367
+ // reacts to ctx.appendEvent writes on the same aggregate stream.
368
+ const AUTO_EVENT_VERBS = ["created", "updated", "deleted", "restored", "forgotten"] as const;
369
+ const allDomainEventNames = new Set(state.eventMap.keys());
370
+ for (const [projName, projDef] of state.projectionMap) {
371
+ const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
372
+ // extraSources (r.extendEntityProjection) sit in the rebuild filter, so
373
+ // their auto-verbs are legitimately observable apply-keys too.
374
+ const rebuildSources = [...sources, ...(projDef.extraSources ?? [])];
375
+ const validEventTypes = new Set<string>();
376
+ // Two source-modes are legal:
377
+ //
378
+ // (a) Registered entity (r.entity(src, ...)) — the "normal" case:
379
+ // auto-lifecycle events `<src>.created/.updated/.deleted/.restored`
380
+ // fire when the event-store-executor writes, and any domain-event
381
+ // (r.defineEvent) appended onto an aggregate of that type is
382
+ // observable too.
383
+ //
384
+ // (b) Events-only source — no r.entity registered, but at least one
385
+ // apply-key must be a domain-event (not a CRUD-verb on the source
386
+ // name). Use-case: features that own an append-only event-stream
387
+ // without a CRUD lifecycle, e.g. `deliveryAttempt` (each call to
388
+ // the delivery-service produces one event on a fresh aggregate)
389
+ // or `jobRun` (BullMQ-callback-driven lifecycle, no executor).
390
+ // A "Shape-Anchor"-entity is no longer needed for this case.
391
+ const isEventsOnlySource = !sources.every((src) => state.entityMap.has(src));
392
+ for (const src of rebuildSources) {
393
+ if (state.entityMap.has(src)) {
394
+ for (const verb of AUTO_EVENT_VERBS) validEventTypes.add(`${src}.${verb}`);
395
+ }
396
+ }
397
+ // Domain events are valid apply-keys for any projection. They arrive via
398
+ // ctx.appendEvent on a specific aggregate — the runtime matches by event
399
+ // type, so a projection can observe domain events whose aggregate matches
400
+ // one of its declared sources.
401
+ for (const domainEvt of allDomainEventNames) validEventTypes.add(domainEvt);
402
+
403
+ // In events-only mode, at least one apply-key MUST be a domain-event —
404
+ // otherwise the source is simply a typo (no events will ever fire).
405
+ if (isEventsOnlySource) {
406
+ const hasAnyDomainEvent = Object.keys(projDef.apply).some((k) => allDomainEventNames.has(k));
407
+ if (!hasAnyDomainEvent) {
408
+ const unregistered = sources.filter((src) => !state.entityMap.has(src));
409
+ throw new Error(
410
+ `Projection "${projName}" declares source(s) [${unregistered.join(", ")}] that are not registered entities, ` +
411
+ `and has no domain-event apply-keys. This is either a typo or a missing r.defineEvent registration. ` +
412
+ `Events-only projections need at least one apply-key from r.defineEvent; ` +
413
+ `CRUD-style projections need r.entity("${unregistered[0]}", ...).`,
414
+ );
415
+ }
416
+ }
417
+
418
+ for (const applyKey of Object.keys(projDef.apply)) {
419
+ if (!validEventTypes.has(applyKey)) {
420
+ throw new Error(
421
+ `Projection "${projName}" has an apply handler for "${applyKey}" but no such event ` +
422
+ `type exists for its source(s) [${sources.join(", ")}]. ` +
423
+ `Valid types: ${[...validEventTypes].join(", ")}. ` +
424
+ `Check for a typo — auto-verbs follow "<entity>.<verb>"; ` +
425
+ `domain events follow "<feature>:event:<short-name>" (see r.defineEvent).`,
426
+ );
427
+ }
428
+ }
429
+ }
430
+ }
431
+
432
+ export function validateRequiredFeatures(
433
+ state: RegistryState,
434
+ features: readonly FeatureDefinition[],
435
+ ): void {
436
+ // Validate: all required features must be registered
437
+ for (const feature of features) {
438
+ for (const required of feature.requires ?? []) {
439
+ if (!state.featureMap.has(required)) {
440
+ throw new Error(
441
+ `Feature "${feature.name}" requires feature "${required}" which is not registered`,
442
+ );
443
+ }
444
+ }
445
+ }
446
+ }
447
+
448
+ export function resolveNotificationTriggersAndRegisterHooks(state: RegistryState): void {
449
+ // Resolve notification triggers and register postSave hooks
450
+ // Done after all features are registered so cross-feature triggers work
451
+ const allHandlerNames = allHandlerQns(state);
452
+ for (const [qualifiedName, notifDef] of state.notificationMap) {
453
+ // Both maps are populated in lockstep — same key-set by construction.
454
+ const featureName = state.notificationFeatureMap.get(qualifiedName) as string; // @cast-boundary engine-bridge
455
+ // I'll try the easy path first: if the trigger is already a fully qualified QN
456
+ // (cross-feature), I take it as-is. Otherwise I qualify with the own feature —
457
+ // as a write handler first (the common case), then as a query. If nothing
458
+ // matches by then, it was a typo and I'll say so.
459
+ let triggerOn: string;
460
+ if (allHandlerNames.has(notifDef.trigger.on)) {
461
+ triggerOn = notifDef.trigger.on;
462
+ } else {
463
+ // Try as write handler first (most common), then query
464
+ const writeQn = qualify(featureName, "write", notifDef.trigger.on);
465
+ const queryQn = qualify(featureName, "query", notifDef.trigger.on);
466
+ if (allHandlerNames.has(writeQn)) {
467
+ triggerOn = writeQn;
468
+ } else if (allHandlerNames.has(queryQn)) {
469
+ triggerOn = queryQn;
470
+ } else {
471
+ throw new Error(
472
+ `Notification "${qualifiedName}" triggers on "${notifDef.trigger.on}" ` +
473
+ `but no handler with that name exists. ` +
474
+ `Tried: "${notifDef.trigger.on}", "${writeQn}", and "${queryQn}"`,
475
+ );
476
+ }
477
+ }
478
+ // Update the stored definition with resolved trigger
479
+ state.notificationMap.set(qualifiedName, { ...notifDef, trigger: { on: triggerOn } });
480
+
481
+ if (!state.postSaveHooks.has(triggerOn)) state.postSaveHooks.set(triggerOn, []);
482
+ state.postSaveHooks.get(triggerOn)?.push({
483
+ phase: HookPhases.afterCommit,
484
+ featureName,
485
+ fn: async (result, context) => {
486
+ if (!context.notify) {
487
+ context.log?.debug(
488
+ `notification ${qualifiedName}: skipping — no notify function configured on context`,
489
+ );
490
+ return;
491
+ }
492
+ const to = notifDef.recipient(result);
493
+ if (to === null) {
494
+ context.log?.debug(
495
+ `notification ${qualifiedName}: skipping — recipient resolver returned null for result ${result.id}`,
496
+ );
497
+ return;
498
+ }
499
+ const data = notifDef.data(result);
500
+ await context.notify(qualifiedName, { to, data });
501
+ },
502
+ });
503
+ }
504
+ }
505
+
506
+ export function validateLifecycleHookTargets(state: RegistryState): void {
507
+ // Validate: lifecycle hook targets must reference existing handlers
508
+ const allHandlers = allHandlerQns(state);
509
+ const lifecycleHookMaps = [
510
+ { map: state.preSaveHooks, phase: "preSave" },
511
+ { map: state.postSaveHooks, phase: "postSave" },
512
+ { map: state.preDeleteHooks, phase: "preDelete" },
513
+ { map: state.postDeleteHooks, phase: "postDelete" },
514
+ { map: state.preQueryHooks, phase: "preQuery" },
515
+ { map: state.postQueryHooks, phase: "postQuery" },
516
+ ] as const;
517
+
518
+ // I'd rather warn you now at boot than have you open a ticket three weeks from now
519
+ // saying "my hook isn't firing". One typo in the target and the thing goes silent.
520
+ for (const { map, phase } of lifecycleHookMaps) {
521
+ for (const hookTarget of map.keys()) {
522
+ if (!allHandlers.has(hookTarget)) {
523
+ throw new Error(
524
+ `${phase} hook targets "${hookTarget}" but no handler with that name exists. ` +
525
+ `Check for typos — the hook will never fire.`,
526
+ );
527
+ }
528
+ }
529
+ }
530
+ }
531
+
532
+ export function validateEntityHookTargets(
533
+ state: RegistryState,
534
+ features: readonly FeatureDefinition[],
535
+ ): void {
536
+ // Same logic for entity-keyed hooks — targets must reference existing entities.
537
+ // Memory `feedback_dead_hook_needs_second_consumer`: a typo silently registers
538
+ // and never fires. Validates all four entity-hook types (postSave/preDelete/
539
+ // postDelete/postQuery) — net cleanup of an existing antipattern, not a
540
+ // postQuery-special.
541
+ const allEntities = new Set<string>();
542
+ for (const feature of features) {
543
+ for (const entityName of Object.keys(feature.entities ?? {})) {
544
+ allEntities.add(entityName);
545
+ }
546
+ }
547
+ const entityHookMaps = [
548
+ { map: state.entityPostSaveHooks, phase: "postSave (entityHook)", kind: "hook" },
549
+ { map: state.entityPreDeleteHooks, phase: "preDelete (entityHook)", kind: "hook" },
550
+ { map: state.entityPostDeleteHooks, phase: "postDelete (entityHook)", kind: "hook" },
551
+ { map: state.entityPostQueryHooks, phase: "postQuery (entityHook)", kind: "hook" },
552
+ { map: state.searchPayloadExtensions, phase: "searchPayloadExtension", kind: "extension" },
553
+ ] as const;
554
+ for (const { map, phase, kind } of entityHookMaps) {
555
+ for (const entityName of map.keys()) {
556
+ if (!allEntities.has(entityName)) {
557
+ throw new Error(
558
+ `${phase} ${kind} targets entity "${entityName}" but no entity with that name exists. ` +
559
+ `Check for typos — the ${kind} will never fire.`,
560
+ );
561
+ }
562
+ }
563
+ }
564
+ }
565
+
566
+ export function validateJobTriggers(state: RegistryState): void {
567
+ // Validate: job event triggers must reference an existing write/query
568
+ // handler OR an existing r.defineEvent registration. The latter is
569
+ // delivered async via the job-trigger event-consumer (server.ts), not
570
+ // the synchronous write-handler dispatch path — see
571
+ // createJobTriggerEventConsumer in pipeline/system-hooks.ts.
572
+ // Multi-Trigger-Form: jeden Eintrag im Array gegen allHandlers prüfen,
573
+ // auch wenn nur einer fehlt fail-fast.
574
+ const allHandlers = allHandlerQns(state);
575
+ for (const [jobName, jobDef] of state.jobMap) {
576
+ if (!("on" in jobDef.trigger)) continue;
577
+ const triggerOn = jobDef.trigger.on;
578
+ const triggers = Array.isArray(triggerOn) ? triggerOn : [triggerOn];
579
+ for (const t of triggers) {
580
+ const rawName = resolveName(t);
581
+ if (allHandlers.has(rawName)) continue;
582
+ if (state.eventMap.has(rawName)) continue;
583
+ throw new Error(
584
+ `Job "${jobName}" triggers on "${rawName}" but no handler or event with that name exists`,
585
+ );
586
+ }
587
+ }
588
+ }
589
+
590
+ export function validateExtensionUsageTargets(state: RegistryState): void {
591
+ // Validate: extension usages must reference existing extensions
592
+ for (const usage of state.extensionUsages) {
593
+ if (!state.extensionMap.has(usage.extensionName)) {
594
+ throw new Error(
595
+ `Extension usage "${usage.extensionName}" on entity "${usage.entityName}" references an extension that does not exist`,
596
+ );
597
+ }
598
+ }
599
+ }
600
+
601
+ // Pre-compute: any handler with a rateLimit option?
602
+ export function computeHasRateLimitedHandler(state: RegistryState): void {
603
+ // Pre-compute: any handler with a rateLimit option? Keeps the boot
604
+ // path able to short-circuit the RateLimitResolver wiring (and its
605
+ // Lua-script registration on Redis) when nobody opted in.
606
+ state.hasRateLimitedHandlerCached = (() => {
607
+ for (const h of state.writeHandlerMap.values()) if (h.rateLimit !== undefined) return true;
608
+ for (const h of state.queryHandlerMap.values()) if (h.rateLimit !== undefined) return true;
609
+ return false;
610
+ })();
611
+ }
612
+
613
+ export function publishEventPiiCatalog(state: RegistryState): void {
614
+ // Publish the event-PII catalog (#799): append() — the single write funnel
615
+ // into kumiko_events — encrypts catalogued payload fields regardless of
616
+ // which path produced the event (ctx.appendEvent, MSP-apply, low-level
617
+ // append in delivery/jobs loggers).
618
+ const eventPiiCatalog = new Map<string, EventPiiFields>();
619
+ for (const [qualified, def] of state.eventMap) {
620
+ if (def.piiFields) eventPiiCatalog.set(qualified, def.piiFields);
621
+ }
622
+ configureEventPiiCatalog(eventPiiCatalog);
623
+ }
624
+
625
+ export function autoWireSoftDeleteJobs(state: RegistryState): void {
626
+ // Auto-wire the soft-delete cleanup cron + its grace-days config key when ANY
627
+ // entity opts into softDelete — the framework owns this machinery, no feature
628
+ // declares it (mirrors the auto restore-handler). Job-runner reads getAllJobs
629
+ // ungated; config-resolver reads getConfigKey → default. Reserved owner
630
+ // segment, guarded against a real-feature collision.
631
+ if ([...state.entityMap.values()].some((e) => e.softDelete)) {
632
+ if (!state.jobMap.has(SOFT_DELETE_CLEANUP_JOB)) {
633
+ state.jobMap.set(SOFT_DELETE_CLEANUP_JOB, buildSoftDeleteCleanupJob());
634
+ }
635
+ if (!state.jobMap.has(SOFT_DELETE_CLEANUP_SYSTEM_JOB)) {
636
+ state.jobMap.set(SOFT_DELETE_CLEANUP_SYSTEM_JOB, buildSoftDeleteCleanupSystemJob());
637
+ }
638
+ if (!state.configKeyMap.has(SOFT_DELETE_GRACE_DAYS_KEY)) {
639
+ state.configKeyMap.set(SOFT_DELETE_GRACE_DAYS_KEY, softDeleteGraceDaysConfig);
640
+ }
641
+ }
642
+ }