@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
@@ -2,14 +2,15 @@ import type { Hono } from "hono";
2
2
  import type { AuthRoutesConfig } from "../api/auth-routes";
3
3
  import type { JwtHelper } from "../api/jwt";
4
4
  import { buildServer } from "../api/server";
5
- import { createSseBroker } from "../api/sse-broker";
5
+ import { createSseBroker, type SseBroker } from "../api/sse-broker";
6
6
  import type { PgClient } from "../db/connection";
7
7
  import { extractTableInfo } from "../db/query";
8
8
  import { createRegistry } from "../engine/registry";
9
- import type { FeatureDefinition, Registry, TenantId } from "../engine/types";
9
+ import type { FeatureDefinition, JobRunIn, Registry, TenantId } from "../engine/types";
10
10
  import { createArchivedStreamsTable, createEventsTable } from "../event-store";
11
+ import { createJobRunner, type JobRunner } from "../jobs";
11
12
  import type { Lifecycle } from "../lifecycle";
12
- import type { ObservabilityProvider } from "../observability";
13
+ import { createNoopProvider, type ObservabilityProvider } from "../observability";
13
14
  import type { Dispatcher, EventDispatcher } from "../pipeline";
14
15
  import { createEntityCache, createEventDedup, createIdempotencyGuard } from "../pipeline";
15
16
  import { createInMemorySearchAdapter } from "../search";
@@ -24,13 +25,17 @@ export type TestStack = {
24
25
  app: Hono;
25
26
  jwt: JwtHelper;
26
27
  registry: Registry;
27
- // biome-ignore lint/suspicious/noExplicitAny: cross-provider connection
28
- db: any;
28
+ db: import("../db").DbConnection;
29
29
  redis: TestRedis;
30
30
  search: SearchAdapter;
31
31
  events: EventCollector;
32
32
  http: RequestHelper;
33
33
  observability: ObservabilityProvider;
34
+ // In-memory broker backing the server's SSE routes + access-invalidation
35
+ // channel. Tests subscribe directly (e.g. sseBroker.subscribeAccessInvalidation)
36
+ // to assert a consumer pushed an invalidation without opening a real SSE
37
+ // connection.
38
+ sseBroker: SseBroker;
34
39
  // Command-dispatcher behind the HTTP routes — for direct system-writes
35
40
  // in tests and dev-server extraRoutes (provider-webhook wiring).
36
41
  dispatcher: Dispatcher;
@@ -41,6 +46,10 @@ export type TestStack = {
41
46
  // Only set when the caller passed `lifecycle` via options. Tests that
42
47
  // exercise drain() / /health/ready wire one in; ordinary suites ignore it.
43
48
  lifecycle?: Lifecycle;
49
+ // Only set when `options.jobs` is truthy AND ≥1 `r.job(...)` is
50
+ // registered in the mounted features. Lets integration tests call
51
+ // `stack.jobRunner.dispatch(...)` directly, mirroring `ctx.jobRunner`.
52
+ jobRunner?: JobRunner;
44
53
  cleanup: () => Promise<void>;
45
54
  };
46
55
 
@@ -61,8 +70,7 @@ export type TestStackOptions = {
61
70
  | Record<string, unknown>
62
71
  | ((deps: {
63
72
  registry: Registry;
64
- // biome-ignore lint/suspicious/noExplicitAny: cross-provider connection
65
- db: any;
73
+ db: import("../db").DbConnection;
66
74
  sseBroker: import("../api/sse-broker").SseBroker;
67
75
  redis: import("ioredis").default;
68
76
  }) => Record<string, unknown>);
@@ -114,11 +122,31 @@ export type TestStackOptions = {
114
122
  | import("../api/server").ServerOptions["anonymousAccess"]
115
123
  | ((deps: {
116
124
  registry: Registry;
117
- // biome-ignore lint/suspicious/noExplicitAny: cross-provider connection
118
- db: any;
125
+ db: import("../db").DbConnection;
119
126
  sseBroker: import("../api/sse-broker").SseBroker;
120
127
  redis: import("ioredis").default;
121
128
  }) => import("../api/server").ServerOptions["anonymousAccess"]);
129
+ /** Optional post-factory enricher (e.g. merge auth-foundation tenant
130
+ * providers). Keeps framework free of a bundled-features dependency. */
131
+ enrichAnonymousAccess?: (
132
+ base: import("../api/server").ServerOptions["anonymousAccess"] | undefined,
133
+ deps: {
134
+ registry: Registry;
135
+ db: import("../db").DbConnection;
136
+ },
137
+ ) => Promise<import("../api/server").ServerOptions["anonymousAccess"] | undefined>;
138
+ /** Opt-in JobRunner wired into ctx.jobRunner and merged into
139
+ * dispatcherOptions so event-triggered jobs enqueue on commit — mirrors
140
+ * the prod entrypoint's `buildJobRunnerWithHook`. Unlike prod (which
141
+ * always builds one), this only builds a runner when `registry.getAllJobs()`
142
+ * is non-empty — a deliberate test-stack-only shortcut, not parity.
143
+ * Default `undefined`. Pass `consumerLane` only when this test IS the
144
+ * sole consumer (otherwise enqueuer-only, avoiding double-running
145
+ * `runOnBoot`/cron jobs against a caller-owned consumer). */
146
+ jobs?: {
147
+ consumerLane?: JobRunIn;
148
+ queueNamePrefix?: string;
149
+ };
122
150
  };
123
151
 
124
152
  const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
@@ -213,40 +241,8 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
213
241
  const events = createEventCollector();
214
242
  const registry = createRegistry([...options.features]);
215
243
 
216
- // Auto-configure search for tenant 1 based on registry
217
- if (enabledHooks.includes("search")) {
218
- const searchableFields: string[] = [];
219
- for (const feature of options.features) {
220
- for (const [, entity] of Object.entries(feature.entities ?? {})) {
221
- for (const [fieldName, field] of Object.entries(entity.fields)) {
222
- if (field.type === "text" && field.searchable) {
223
- searchableFields.push(fieldName);
224
- }
225
- if (field.type === "embedded") {
226
- for (const [subName, subField] of Object.entries(field.schema)) {
227
- if (subField.searchable) {
228
- searchableFields.push(`${fieldName}_${subName}`);
229
- }
230
- }
231
- }
232
- }
233
- }
234
- }
235
-
236
- if (options.searchConfig) {
237
- await searchAdapter.configure(options.searchConfig.tenantId, {
238
- searchableFields: options.searchConfig.searchableFields,
239
- rankingFields: options.searchConfig.rankingFields,
240
- });
241
- } else if (searchableFields.length > 0) {
242
- await searchAdapter.configure("00000000-0000-4000-8000-000000000001", {
243
- searchableFields,
244
- rankingFields: searchableFields,
245
- });
246
- }
247
- }
248
-
249
- // Wire SSE broker with event collector
244
+ // Wire SSE broker with event collector built early (not just before
245
+ // buildServer) because extraContext + the job context below both need it.
250
246
  const sseBroker = createSseBroker();
251
247
  sseBroker.addClient(
252
248
  "tenant:00000000-0000-4000-8000-000000000001",
@@ -254,8 +250,6 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
254
250
  () => {},
255
251
  );
256
252
 
257
- const idempotency = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 60 });
258
- const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
259
253
  const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
260
254
 
261
255
  // A static `files.storageProvider` is wired as the per-tenant resolver — the
@@ -267,97 +261,221 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
267
261
  fileProviderResolver = () => Promise.resolve(provider);
268
262
  }
269
263
 
270
- const server = buildServer({
264
+ const observability = options.observability ?? createNoopProvider();
265
+
266
+ // Same AppContext buildServer() gets below (db/redis/searchAdapter/
267
+ // entityCache/masterKeyProvider/fileProviderResolver/extraContext) —
268
+ // shared so the job context can't drift from the request-path context
269
+ // (kumiko-framework#1232: a reduced `{ db, registry }` literal let jobs
270
+ // pass in tests while reaching for fields only prod's context has).
271
+ //
272
+ // effectiveFeatures deliberately stays OUT of appContext — prod never puts
273
+ // it there either (only dispatcherOptions.effectiveFeatures, consumed by
274
+ // the command-dispatcher — see buildJobRunnerWithHook in entrypoint/
275
+ // index.ts). Putting it here would reintroduce the exact test-vs-prod
276
+ // drift #1232 fixed: a job/handler reading `ctx.effectiveFeatures` would
277
+ // pass in tests and break in prod (kumiko-framework#1255).
278
+ const appContext = {
279
+ db: testDb.db,
280
+ redis: testRedis.redis,
281
+ searchAdapter,
282
+ entityCache,
271
283
  registry,
272
- context: {
273
- db: testDb.db,
274
- redis: testRedis.redis,
275
- searchAdapter,
276
- entityCache,
284
+ ...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
285
+ ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
286
+ ...(typeof options.extraContext === "function"
287
+ ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
288
+ : options.extraContext),
289
+ };
290
+
291
+ // Built before buildServer() so it can be merged into dispatcherOptions
292
+ // (write handlers' `ctx.jobRunner.dispatch(...)` and the afterCommit
293
+ // event-trigger hook both need it present at dispatcher-construction
294
+ // time, same as the prod entrypoint's buildJobRunnerWithHook). Uses the
295
+ // test-stack's own ephemeral redis — no separate `redisUrl` seam needed.
296
+ //
297
+ // context = appContext + tracer/meter, mirroring the prod entrypoint's
298
+ // `contextWithObservability(options.context, observability)`.
299
+ let jobRunner: JobRunner | undefined;
300
+ if (options.jobs && registry.getAllJobs().size > 0) {
301
+ jobRunner = createJobRunner({
277
302
  registry,
278
- ...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
279
- ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
280
- ...(typeof options.extraContext === "function"
281
- ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
282
- : options.extraContext),
283
- },
284
- jwtSecret,
285
- dispatcherOptions: {
286
- idempotency,
287
- ...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
288
- },
289
- eventDedup,
290
- sseBroker,
291
- // Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
292
- // deterministic drains no timer-induced flakiness. pollIntervalMs
293
- // stays short anyway in case a test opts into `.start()`. pgClient
294
- // plumbs through the LISTEN wake-up for tests that want to measure
295
- // post-commit latency (Sprint E.4).
296
- eventDispatcher: {
297
- pollIntervalMs: 50,
298
- pgClient: testDb.client as PgClient | undefined,
299
- systemConsumers: {
300
- sse: enabledHooks.includes("sse"),
301
- search: enabledHooks.includes("search"),
302
- },
303
- },
304
- // Default tests to no login rate-limiter so existing suites that loop
305
- // over logins don't hit a 429 after 10 attempts. Suites specifically
306
- // testing the limiter can override via authConfig.loginRateLimit.
307
- ...(options.authConfig
308
- ? {
309
- auth: {
310
- ...options.authConfig,
311
- ...(options.authConfig.loginRateLimit === undefined ? { loginRateLimit: null } : {}),
312
- },
313
- }
314
- : {}),
315
- ...(options.observability ? { observability: options.observability } : {}),
316
- ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
317
- ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
318
- ...(options.anonymousAccess
319
- ? {
320
- anonymousAccess:
321
- typeof options.anonymousAccess === "function"
322
- ? options.anonymousAccess({
323
- registry,
324
- db: testDb.db,
325
- sseBroker,
326
- redis: testRedis.redis,
327
- })
328
- : options.anonymousAccess,
303
+ context: { ...appContext, tracer: observability.tracer, meter: observability.meter },
304
+ // The real REDIS_URL, not one rebuilt from `.options` — that lost
305
+ // password/username/tls/path (pr-review kumiko-framework #1036/2).
306
+ redisUrl: testRedis.redisUrl,
307
+ ...(options.jobs.consumerLane !== undefined && { consumerLane: options.jobs.consumerLane }),
308
+ ...(options.jobs.queueNamePrefix !== undefined && {
309
+ queueNamePrefix: options.jobs.queueNamePrefix,
310
+ }),
311
+ });
312
+ }
313
+
314
+ // From here on, any throw must stop a jobRunner that was already created
315
+ // above (createJobRunner() itself opens live BullMQ Queue/lock Redis
316
+ // connections, .start() adds a live worker) — otherwise a failing
317
+ // buildServer()/search-config/etc. leaks that connection and the test
318
+ // process hangs on exit (pr-review kumiko-framework #1036/1).
319
+ try {
320
+ if (jobRunner) await jobRunner.start();
321
+
322
+ // Auto-configure search for tenant 1 based on registry
323
+ if (enabledHooks.includes("search")) {
324
+ const searchableFields: string[] = [];
325
+ for (const feature of options.features) {
326
+ for (const [, entity] of Object.entries(feature.entities ?? {})) {
327
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
328
+ if (field.type === "text" && field.searchable) {
329
+ searchableFields.push(fieldName);
330
+ }
331
+ if (field.type === "embedded") {
332
+ for (const [subName, subField] of Object.entries(field.schema)) {
333
+ if (subField.searchable) {
334
+ searchableFields.push(`${fieldName}_${subName}`);
335
+ }
336
+ }
337
+ }
338
+ }
329
339
  }
330
- : {}),
331
- });
340
+ }
332
341
 
333
- const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
342
+ if (options.searchConfig) {
343
+ await searchAdapter.configure(options.searchConfig.tenantId, {
344
+ searchableFields: options.searchConfig.searchableFields,
345
+ rankingFields: options.searchConfig.rankingFields,
346
+ });
347
+ } else if (searchableFields.length > 0) {
348
+ await searchAdapter.configure("00000000-0000-4000-8000-000000000001", {
349
+ searchableFields,
350
+ rankingFields: searchableFields,
351
+ });
352
+ }
353
+ }
334
354
 
335
- // Pre-register consumer state rows so tests can call runOnce() directly
336
- // without a preceding explicit start(). Timer fires at pollIntervalMs=50
337
- // but passInFlight serialises concurrent passes — tests that drain via
338
- // runOnce() remain deterministic. Tests that specifically exercise the
339
- // timer loop call start() again (idempotent) after setup.
340
- if (eventDispatcher) await eventDispatcher.ensureRegistered();
355
+ const idempotency = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 60 });
356
+ const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
341
357
 
342
- const http = createRequestHelper(server.app, server.jwt);
358
+ const server = buildServer({
359
+ registry,
360
+ context: appContext,
361
+ jwtSecret,
362
+ ...(options.observability ? { observability: options.observability } : {}),
363
+ dispatcherOptions: {
364
+ idempotency,
365
+ ...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
366
+ ...(jobRunner && { jobRunner }),
367
+ },
368
+ eventDedup,
369
+ sseBroker,
370
+ // Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
371
+ // deterministic drains — no timer-induced flakiness. pollIntervalMs
372
+ // stays short anyway in case a test opts into `.start()`. pgClient
373
+ // plumbs through the LISTEN wake-up for tests that want to measure
374
+ // post-commit latency (Sprint E.4).
375
+ eventDispatcher: {
376
+ pollIntervalMs: 50,
377
+ pgClient: testDb.client as PgClient | undefined,
378
+ systemConsumers: {
379
+ sse: enabledHooks.includes("sse"),
380
+ search: enabledHooks.includes("search"),
381
+ accessInvalidation: enabledHooks.includes("sse"),
382
+ },
383
+ },
384
+ // Default tests to no login rate-limiter so existing suites that loop
385
+ // over logins don't hit a 429 after 10 attempts. Suites specifically
386
+ // testing the limiter can override via authConfig.loginRateLimit.
387
+ ...(options.authConfig
388
+ ? {
389
+ auth: {
390
+ ...options.authConfig,
391
+ ...(options.authConfig.loginRateLimit === undefined ? { loginRateLimit: null } : {}),
392
+ },
393
+ }
394
+ : {}),
395
+ ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
396
+ ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
397
+ ...(await (async () => {
398
+ const baseAnon =
399
+ typeof options.anonymousAccess === "function"
400
+ ? options.anonymousAccess({
401
+ registry,
402
+ db: testDb.db,
403
+ sseBroker,
404
+ redis: testRedis.redis,
405
+ })
406
+ : options.anonymousAccess;
407
+ const resolvedAnon = options.enrichAnonymousAccess
408
+ ? await options.enrichAnonymousAccess(baseAnon, {
409
+ registry,
410
+ db: testDb.db,
411
+ })
412
+ : baseAnon;
413
+ return resolvedAnon ? { anonymousAccess: resolvedAnon } : {};
414
+ })()),
415
+ });
343
416
 
344
- return {
345
- app: server.app,
346
- jwt: server.jwt,
347
- registry,
348
- db: testDb.db,
349
- redis: testRedis,
350
- search: searchAdapter,
351
- events,
352
- http,
353
- observability: server.observability,
354
- dispatcher: server.dispatcher,
355
- ...(eventDispatcher ? { eventDispatcher } : {}),
356
- ...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
357
- cleanup: async () => {
358
- if (eventDispatcher) await eventDispatcher.stop();
359
- await server.observability.shutdown();
360
- await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
361
- },
362
- };
417
+ const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
418
+
419
+ // Pre-register consumer state rows so tests can call runOnce() directly
420
+ // without a preceding explicit start(). Timer fires at pollIntervalMs=50
421
+ // but passInFlight serialises concurrent passes — tests that drain via
422
+ // runOnce() remain deterministic. Tests that specifically exercise the
423
+ // timer loop call start() again (idempotent) after setup.
424
+ if (eventDispatcher) await eventDispatcher.ensureRegistered();
425
+
426
+ const http = createRequestHelper(server.app, server.jwt, {
427
+ ...(options.authConfig?.sessionCreator !== undefined && {
428
+ sessionCreator: options.authConfig.sessionCreator,
429
+ }),
430
+ });
431
+
432
+ return {
433
+ app: server.app,
434
+ jwt: server.jwt,
435
+ registry,
436
+ db: testDb.db,
437
+ redis: testRedis,
438
+ search: searchAdapter,
439
+ events,
440
+ http,
441
+ observability: server.observability,
442
+ sseBroker,
443
+ dispatcher: server.dispatcher,
444
+ ...(eventDispatcher ? { eventDispatcher } : {}),
445
+ ...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
446
+ ...(jobRunner ? { jobRunner } : {}),
447
+ cleanup: async () => {
448
+ if (jobRunner) await jobRunner.stop();
449
+ if (eventDispatcher) await eventDispatcher.stop();
450
+ await server.observability.shutdown();
451
+ await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
452
+ },
453
+ };
454
+ } catch (error) {
455
+ // Best-effort — a broken jobRunner.stop()/cleanup() must never mask the
456
+ // real setup failure below. testDb/testRedis are created before this
457
+ // try even starts, so a throw anywhere in setup (buildServer,
458
+ // search-config, ...) would otherwise leak their open pg/ioredis
459
+ // sockets — the caller never gets the stack object back to call
460
+ // cleanup() itself, which is the same "hangs on exit" failure class
461
+ // this function exists to prevent.
462
+ if (jobRunner) {
463
+ try {
464
+ await jobRunner.stop();
465
+ } catch {
466
+ // ignore — `error` is the one that matters
467
+ }
468
+ }
469
+ try {
470
+ await testDb.cleanup();
471
+ } catch {
472
+ // ignore — `error` is the one that matters
473
+ }
474
+ try {
475
+ await testRedis.cleanup();
476
+ } catch {
477
+ // ignore — `error` is the one that matters
478
+ }
479
+ throw error;
480
+ }
363
481
  }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createLateBoundHolder } from "../late-bound";
3
+
4
+ describe("createLateBoundHolder", () => {
5
+ test("isReady is false and get() throws before set()", () => {
6
+ const holder = createLateBoundHolder<number>("thing");
7
+ expect(holder.isReady()).toBe(false);
8
+ expect(() => holder.get()).toThrow(/thing accessed before set\(\) was called/);
9
+ });
10
+
11
+ test("get() returns the stored value after set()", () => {
12
+ const holder = createLateBoundHolder<{ n: number }>();
13
+ const value = { n: 42 };
14
+ holder.set(value);
15
+ expect(holder.isReady()).toBe(true);
16
+ expect(holder.get()).toBe(value);
17
+ });
18
+
19
+ test("set() can be called again to replace the value", () => {
20
+ const holder = createLateBoundHolder<string>();
21
+ holder.set("first");
22
+ holder.set("second");
23
+ expect(holder.get()).toBe("second");
24
+ });
25
+
26
+ test("set(undefined) still marks the holder ready", () => {
27
+ const holder = createLateBoundHolder<number | undefined>("opt");
28
+ holder.set(undefined);
29
+ expect(holder.isReady()).toBe(true);
30
+ expect(holder.get()).toBeUndefined();
31
+ });
32
+ });
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { waitFor } from "../wait-for";
3
+
4
+ describe("waitFor", () => {
5
+ test("returns immediately once fn succeeds on the first attempt", async () => {
6
+ let calls = 0;
7
+ await waitFor(
8
+ () => {
9
+ calls++;
10
+ },
11
+ { delays: [1, 1, 1] },
12
+ );
13
+ expect(calls).toBe(1);
14
+ });
15
+
16
+ test("retries on failure and succeeds once fn passes", async () => {
17
+ let calls = 0;
18
+ await waitFor(
19
+ () => {
20
+ calls++;
21
+ if (calls < 3) throw new Error(`not yet (${calls})`);
22
+ },
23
+ { delays: [1, 1, 1] },
24
+ );
25
+ expect(calls).toBe(3);
26
+ });
27
+
28
+ test("throws the last error once every attempt in the schedule fails", async () => {
29
+ let calls = 0;
30
+ await expect(
31
+ waitFor(
32
+ () => {
33
+ calls++;
34
+ throw new Error(`fail-${calls}`);
35
+ },
36
+ { delays: [1, 1] },
37
+ ),
38
+ ).rejects.toThrow("fail-2");
39
+ expect(calls).toBe(2);
40
+ });
41
+
42
+ test("throws a descriptive error for an empty delay schedule", async () => {
43
+ await expect(waitFor(() => {}, { delays: [] })).rejects.toThrow(
44
+ "waitFor: empty delay schedule",
45
+ );
46
+ });
47
+
48
+ test("supports an async fn", async () => {
49
+ let calls = 0;
50
+ await waitFor(
51
+ async () => {
52
+ calls++;
53
+ if (calls < 2) throw new Error("not yet");
54
+ },
55
+ { delays: [1, 1] },
56
+ );
57
+ expect(calls).toBe(2);
58
+ });
59
+ });
@@ -0,0 +1,121 @@
1
+ import { SEARCHABLE_FALSE_WHITELIST } from "../engine/boot-validator/entity-list-screens";
2
+ import { normalizeListColumn } from "../engine/screen-helpers";
3
+ import type {
4
+ EntityDefinition,
5
+ EntityListScreenDefinition,
6
+ FeatureDefinition,
7
+ TranslationEntry,
8
+ } from "../engine/types";
9
+ import { featureHasI18nSurface, requiredKeysFromFeature } from "../i18n/required-surface-keys";
10
+
11
+ function ensureEntityListSortable(feature: FeatureDefinition): FeatureDefinition {
12
+ if (!feature.entities) return feature;
13
+ const entities: Record<string, EntityDefinition> = { ...feature.entities };
14
+
15
+ for (const screen of Object.values(feature.screens)) {
16
+ if (screen.type !== "entityList") continue;
17
+ const entity = entities[screen.entity];
18
+ if (!entity) continue;
19
+
20
+ const hasSortable = Object.values(entity.fields).some(
21
+ (field) => "sortable" in field && field.sortable === true,
22
+ );
23
+ if (hasSortable) continue;
24
+
25
+ const firstCol = screen.columns[0];
26
+ if (firstCol === undefined) continue;
27
+ const fieldName = normalizeListColumn(firstCol).field;
28
+ const fieldDef = entity.fields[fieldName];
29
+ if (fieldDef === undefined) continue;
30
+
31
+ if (fieldDef.type !== "text" && fieldDef.type !== "number") continue;
32
+
33
+ entities[screen.entity] = {
34
+ ...entity,
35
+ fields: {
36
+ ...entity.fields,
37
+ [fieldName]: { ...fieldDef, sortable: true },
38
+ },
39
+ };
40
+ }
41
+
42
+ return { ...feature, entities };
43
+ }
44
+
45
+ function ensureEntityListRowNavigation(
46
+ feature: FeatureDefinition,
47
+ screen: EntityListScreenDefinition,
48
+ ): EntityListScreenDefinition {
49
+ const hasEdit = Object.values(feature.screens).some(
50
+ (s) => s.type === "entityEdit" && s.entity === screen.entity,
51
+ );
52
+ if (!hasEdit) return screen;
53
+ const hasNavigate = (screen.rowActions ?? []).some(
54
+ (a) => a.kind === "navigate" && (a.id === "view" || a.id === "edit"),
55
+ );
56
+ if (hasNavigate) return screen;
57
+ const editScreen = Object.values(feature.screens).find(
58
+ (s) => s.type === "entityEdit" && s.entity === screen.entity,
59
+ );
60
+ if (editScreen === undefined) return screen;
61
+ return {
62
+ ...screen,
63
+ rowActions: [
64
+ ...(screen.rowActions ?? []),
65
+ {
66
+ kind: "navigate",
67
+ id: "edit",
68
+ label: "stub:edit",
69
+ screen: editScreen.id,
70
+ params: { pick: ["id"] },
71
+ },
72
+ ],
73
+ };
74
+ }
75
+
76
+ function defaultSortForEntityList(
77
+ feature: FeatureDefinition,
78
+ screen: EntityListScreenDefinition,
79
+ ): EntityListScreenDefinition {
80
+ if (screen.defaultSort !== undefined || screen.searchable === false) return screen;
81
+ if (SEARCHABLE_FALSE_WHITELIST.has(screen.id)) return screen;
82
+
83
+ const entity = feature.entities?.[screen.entity];
84
+ const sortableColumn = screen.columns.map(normalizeListColumn).find((col) => {
85
+ const fieldDef = entity?.fields[col.field];
86
+ return fieldDef !== undefined && "sortable" in fieldDef && fieldDef.sortable === true;
87
+ });
88
+ if (sortableColumn === undefined) return screen;
89
+
90
+ return { ...screen, defaultSort: { field: sortableColumn.field, dir: "asc" as const } };
91
+ }
92
+
93
+ function stubTranslations(feature: FeatureDefinition): FeatureDefinition {
94
+ if (!featureHasI18nSurface(feature)) return feature;
95
+ const keys: Record<string, TranslationEntry> = {
96
+ ...Object.fromEntries(
97
+ Object.entries(feature.translations ?? {}).map(([key, value]) => [key, { ...value }]),
98
+ ),
99
+ };
100
+ for (const key of requiredKeysFromFeature(feature)) {
101
+ if (keys[key] === undefined) keys[key] = { de: "stub", en: "stub" };
102
+ }
103
+ return { ...feature, translations: keys };
104
+ }
105
+
106
+ /** Test-fixture helper: satisfy entityList + i18n boot rules without bloating every makeFeature. */
107
+ export function withBootValidatorFixture(
108
+ features: readonly FeatureDefinition[],
109
+ ): FeatureDefinition[] {
110
+ return features.map((feature) => {
111
+ const sortable = ensureEntityListSortable(feature);
112
+ const screens = Object.fromEntries(
113
+ Object.entries(sortable.screens).map(([id, screen]) => {
114
+ if (screen.type !== "entityList") return [id, screen];
115
+ const withDefaults = defaultSortForEntityList(sortable, screen);
116
+ return [id, ensureEntityListRowNavigation(sortable, withDefaults)];
117
+ }),
118
+ );
119
+ return stubTranslations({ ...sortable, screens });
120
+ });
121
+ }