@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,384 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { EntityTableMeta } from "../entity-table-meta";
6
+ import { snapshotFromMetas } from "../migrate-generator";
7
+ import { diffReplayAgainstSnapshot, replayMigrationsDir } from "../replay-migration-sql";
8
+
9
+ function tmpMigrationsDir(): string {
10
+ return mkdtempSync(join(tmpdir(), "replay-migration-sql-"));
11
+ }
12
+
13
+ function write(dir: string, filename: string, sql: string): void {
14
+ writeFileSync(join(dir, filename), sql);
15
+ }
16
+
17
+ function meta(tableName: string, columns: EntityTableMeta["columns"]): EntityTableMeta {
18
+ return { tableName, source: "managed", indexes: [], columns };
19
+ }
20
+
21
+ describe("replayMigrationsDir", () => {
22
+ test("reconstructs table+column shape from CREATE TABLE", () => {
23
+ const dir = tmpMigrationsDir();
24
+ try {
25
+ write(
26
+ dir,
27
+ "0001_init.sql",
28
+ `CREATE TABLE IF NOT EXISTS "read_accounts" (
29
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
30
+ "tenant_id" uuid NOT NULL,
31
+ "stripe_customer_id" text
32
+ );
33
+ CREATE INDEX IF NOT EXISTS "read_accounts_tenant_id_idx" ON "read_accounts" ("tenant_id");`,
34
+ );
35
+ const replayed = replayMigrationsDir(dir);
36
+ expect([...replayed.keys()]).toEqual(["read_accounts"]);
37
+ expect([...(replayed.get("read_accounts")?.columns ?? [])].sort()).toEqual([
38
+ "id",
39
+ "stripe_customer_id",
40
+ "tenant_id",
41
+ ]);
42
+ } finally {
43
+ rmSync(dir, { recursive: true, force: true });
44
+ }
45
+ });
46
+
47
+ test("ADD COLUMN extends an already-created table across files", () => {
48
+ const dir = tmpMigrationsDir();
49
+ try {
50
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
51
+ write(dir, "0002_add-col.sql", `ALTER TABLE "read_a" ADD COLUMN "title" text;`);
52
+ const replayed = replayMigrationsDir(dir);
53
+ expect([...(replayed.get("read_a")?.columns ?? [])].sort()).toEqual(["id", "title"]);
54
+ } finally {
55
+ rmSync(dir, { recursive: true, force: true });
56
+ }
57
+ });
58
+
59
+ // Hand-edited migrations legitimately add "IF NOT EXISTS"/"IF EXISTS" to
60
+ // ADD/DROP COLUMN (the generator itself never emits it, but app authors
61
+ // are explicitly allowed to hand-edit before committing — see the header
62
+ // comment every generated migration carries).
63
+ test("ADD COLUMN IF NOT EXISTS (hand-edited) still extends the table", () => {
64
+ const dir = tmpMigrationsDir();
65
+ try {
66
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
67
+ write(dir, "0002_add-col.sql", `ALTER TABLE "read_a" ADD COLUMN IF NOT EXISTS "title" text;`);
68
+ const replayed = replayMigrationsDir(dir);
69
+ expect([...(replayed.get("read_a")?.columns ?? [])].sort()).toEqual(["id", "title"]);
70
+ } finally {
71
+ rmSync(dir, { recursive: true, force: true });
72
+ }
73
+ });
74
+
75
+ // Regression: publicstatus#0007_fix-secrets-table-columns adds three
76
+ // columns in ONE statement (comma-separated ADD COLUMN clauses) — the
77
+ // replay used to only pick up the first, reporting "metadata" and
78
+ // "last_rotated_at" as missing even though the migration creates them.
79
+ test("multiple ADD COLUMN clauses in a single ALTER TABLE statement all extend the table", () => {
80
+ const dir = tmpMigrationsDir();
81
+ try {
82
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
83
+ write(
84
+ dir,
85
+ "0002_add-cols.sql",
86
+ `ALTER TABLE "read_a"
87
+ ADD COLUMN IF NOT EXISTS "envelope" jsonb NOT NULL,
88
+ ADD COLUMN IF NOT EXISTS "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
89
+ ADD COLUMN IF NOT EXISTS "last_rotated_at" timestamp with time zone DEFAULT now() NOT NULL;`,
90
+ );
91
+ const replayed = replayMigrationsDir(dir);
92
+ expect([...(replayed.get("read_a")?.columns ?? [])].sort()).toEqual([
93
+ "envelope",
94
+ "id",
95
+ "last_rotated_at",
96
+ "metadata",
97
+ ]);
98
+ } finally {
99
+ rmSync(dir, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test("mixed ADD + DROP COLUMN clauses in one statement apply in order", () => {
104
+ const dir = tmpMigrationsDir();
105
+ try {
106
+ write(
107
+ dir,
108
+ "0001_init.sql",
109
+ `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY, "legacy" text);`,
110
+ );
111
+ write(
112
+ dir,
113
+ "0002_migrate-cols.sql",
114
+ `ALTER TABLE "read_a" DROP COLUMN IF EXISTS "legacy", ADD COLUMN IF NOT EXISTS "title" text;`,
115
+ );
116
+ const replayed = replayMigrationsDir(dir);
117
+ expect([...(replayed.get("read_a")?.columns ?? [])].sort()).toEqual(["id", "title"]);
118
+ } finally {
119
+ rmSync(dir, { recursive: true, force: true });
120
+ }
121
+ });
122
+
123
+ test("DROP COLUMN IF EXISTS (hand-edited) still removes the column", () => {
124
+ const dir = tmpMigrationsDir();
125
+ try {
126
+ write(
127
+ dir,
128
+ "0001_init.sql",
129
+ `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY, "title" text);`,
130
+ );
131
+ write(dir, "0002_drop-col.sql", `ALTER TABLE "read_a" DROP COLUMN IF EXISTS "title";`);
132
+ const replayed = replayMigrationsDir(dir);
133
+ expect([...(replayed.get("read_a")?.columns ?? [])]).toEqual(["id"]);
134
+ } finally {
135
+ rmSync(dir, { recursive: true, force: true });
136
+ }
137
+ });
138
+
139
+ // The real migrate-runner never executes a DESTRUCTIVE marker (it's a
140
+ // commented-out suggestion an operator uncomments deliberately) — but the
141
+ // snapshot already reflects the post-drop state as the intended end state,
142
+ // so replay must count the marker as applied or "unexpected-table"/
143
+ // "unexpected columns" fires forever against a table the snapshot
144
+ // correctly omits.
145
+ // #1473/2: CREATE TABLE IF NOT EXISTS is a no-op in real Postgres when
146
+ // the table already exists — treating it as an overwrite here silently
147
+ // discards whatever an in-between ALTER TABLE added, exactly the
148
+ // copy-paste-migration class this replay exists to catch.
149
+ test("CREATE TABLE IF NOT EXISTS for an already-created table is a no-op, not an overwrite", () => {
150
+ const dir = tmpMigrationsDir();
151
+ try {
152
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
153
+ write(dir, "0002_add-col.sql", `ALTER TABLE "read_a" ADD COLUMN "title" text;`);
154
+ // Accidental copy-paste of 0001 — same CREATE TABLE IF NOT EXISTS text.
155
+ write(
156
+ dir,
157
+ "0003_copy-paste.sql",
158
+ `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`,
159
+ );
160
+ const replayed = replayMigrationsDir(dir);
161
+ expect([...(replayed.get("read_a")?.columns ?? [])].sort()).toEqual(["id", "title"]);
162
+ } finally {
163
+ rmSync(dir, { recursive: true, force: true });
164
+ }
165
+ });
166
+
167
+ test("commented-out destructive DROP TABLE is replayed as applied", () => {
168
+ const dir = tmpMigrationsDir();
169
+ try {
170
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
171
+ write(
172
+ dir,
173
+ "0002_drop.sql",
174
+ `-- DESTRUCTIVE: DROP TABLE IF EXISTS "read_a"; -- uncomment + ensure backup`,
175
+ );
176
+ const replayed = replayMigrationsDir(dir);
177
+ expect([...replayed.keys()]).toEqual([]);
178
+ } finally {
179
+ rmSync(dir, { recursive: true, force: true });
180
+ }
181
+ });
182
+
183
+ test("commented-out destructive DROP COLUMN is replayed as applied", () => {
184
+ const dir = tmpMigrationsDir();
185
+ try {
186
+ write(
187
+ dir,
188
+ "0001_init.sql",
189
+ `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY, "legacy" text);`,
190
+ );
191
+ write(
192
+ dir,
193
+ "0002_drop-col.sql",
194
+ `-- DESTRUCTIVE: ALTER TABLE "read_a" DROP COLUMN "legacy"; -- uncomment + ensure backup`,
195
+ );
196
+ const replayed = replayMigrationsDir(dir);
197
+ expect([...(replayed.get("read_a")?.columns ?? [])]).toEqual(["id"]);
198
+ } finally {
199
+ rmSync(dir, { recursive: true, force: true });
200
+ }
201
+ });
202
+ });
203
+
204
+ describe("diffReplayAgainstSnapshot", () => {
205
+ test("clean: replayed schema matches snapshot exactly", () => {
206
+ const dir = tmpMigrationsDir();
207
+ try {
208
+ write(
209
+ dir,
210
+ "0001_init.sql",
211
+ `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY, "title" text);`,
212
+ );
213
+ const snapshot = snapshotFromMetas([
214
+ meta("read_a", [
215
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
216
+ { name: "title", pgType: "text", notNull: false },
217
+ ]),
218
+ ]);
219
+ const mismatches = diffReplayAgainstSnapshot(replayMigrationsDir(dir), snapshot);
220
+ expect(mismatches).toEqual([]);
221
+ } finally {
222
+ rmSync(dir, { recursive: true, force: true });
223
+ }
224
+ });
225
+
226
+ // Reproduces the kumiko-studio 0016 incident: the snapshot correctly
227
+ // records store_api_tokens, but the migration file that's supposed to
228
+ // create it is an accidental copy of an earlier file (creates read_accounts
229
+ // again instead) — CI must fail loud instead of shipping a table that only
230
+ // ever exists on paper.
231
+ test("misgenerated migration: snapshot expects a table no file actually creates", () => {
232
+ const dir = tmpMigrationsDir();
233
+ try {
234
+ write(
235
+ dir,
236
+ "0001_account-entity.sql",
237
+ `CREATE TABLE IF NOT EXISTS "read_accounts" ("id" uuid PRIMARY KEY, "stripe_customer_id" text);`,
238
+ );
239
+ write(
240
+ dir,
241
+ "0002_add-personal-access-tokens.sql",
242
+ // bug: this should create store_api_tokens, but it's a copy of 0001
243
+ `CREATE TABLE IF NOT EXISTS "read_accounts" ("id" uuid PRIMARY KEY, "stripe_customer_id" text);`,
244
+ );
245
+ const snapshot = snapshotFromMetas([
246
+ meta("read_accounts", [
247
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
248
+ { name: "stripe_customer_id", pgType: "text", notNull: false },
249
+ ]),
250
+ meta("store_api_tokens", [
251
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
252
+ { name: "token_hash", pgType: "text", notNull: true },
253
+ ]),
254
+ ]);
255
+ const mismatches = diffReplayAgainstSnapshot(replayMigrationsDir(dir), snapshot);
256
+ expect(mismatches).toEqual([
257
+ {
258
+ tableName: "store_api_tokens",
259
+ kind: "missing-table",
260
+ detail: 'snapshot expects "store_api_tokens" but no migration file creates it',
261
+ },
262
+ ]);
263
+ } finally {
264
+ rmSync(dir, { recursive: true, force: true });
265
+ }
266
+ });
267
+
268
+ test("column drift: migration creates a table with the wrong columns", () => {
269
+ const dir = tmpMigrationsDir();
270
+ try {
271
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
272
+ const snapshot = snapshotFromMetas([
273
+ meta("read_a", [
274
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
275
+ { name: "title", pgType: "text", notNull: false },
276
+ ]),
277
+ ]);
278
+ const mismatches = diffReplayAgainstSnapshot(replayMigrationsDir(dir), snapshot);
279
+ expect(mismatches).toEqual([
280
+ { tableName: "read_a", kind: "column-drift", detail: "missing columns: title" },
281
+ ]);
282
+ } finally {
283
+ rmSync(dir, { recursive: true, force: true });
284
+ }
285
+ });
286
+
287
+ test("kumiko-framework#1473: an unparsed ALTER TABLE RENAME statement fails loud instead of vanishing", () => {
288
+ const dir = tmpMigrationsDir();
289
+ try {
290
+ write(
291
+ dir,
292
+ "0001_init.sql",
293
+ `CREATE TABLE IF NOT EXISTS "widgets" ("id" uuid PRIMARY KEY);
294
+ ALTER TABLE "widgets" RENAME TO "gadgets";`,
295
+ );
296
+ expect(() => replayMigrationsDir(dir)).toThrow(/unparsed table-DDL statement/);
297
+ } finally {
298
+ rmSync(dir, { recursive: true, force: true });
299
+ }
300
+ });
301
+
302
+ test("kumiko-framework#1535: an unquoted CREATE TABLE identifier parses instead of failing loud", () => {
303
+ // Was "fails loud" pre-#1535 — but real committed migrations do this
304
+ // (publicstatus's 0014_marketing-waitlist.sql), and the old behavior
305
+ // turned an already-graceful missing-table report into a hard abort.
306
+ // Unquoted identifiers must parse like quoted ones.
307
+ const dir = tmpMigrationsDir();
308
+ try {
309
+ write(dir, "0001_init.sql", `CREATE TABLE widgets ("id" uuid PRIMARY KEY);`);
310
+ const replayed = replayMigrationsDir(dir);
311
+ expect([...replayed.keys()]).toEqual(["widgets"]);
312
+ expect([...(replayed.get("widgets")?.columns ?? [])]).toEqual(["id"]);
313
+ } finally {
314
+ rmSync(dir, { recursive: true, force: true });
315
+ }
316
+ });
317
+
318
+ test("schema-qualified CREATE TABLE (public.widgets) fails loud — IDENT rejects '.'", () => {
319
+ const dir = tmpMigrationsDir();
320
+ try {
321
+ write(dir, "0001_init.sql", "CREATE TABLE public.widgets (id uuid PRIMARY KEY);\n");
322
+ expect(() => replayMigrationsDir(dir)).toThrow(/unparsed table-DDL statement/);
323
+ } finally {
324
+ rmSync(dir, { recursive: true, force: true });
325
+ }
326
+ });
327
+
328
+ test("kumiko-framework#1535: a bare table-level PRIMARY KEY/UNIQUE clause isn't parsed as a column", () => {
329
+ const dir = tmpMigrationsDir();
330
+ try {
331
+ write(
332
+ dir,
333
+ "0001_init.sql",
334
+ `CREATE TABLE widgets (id uuid, sku text, name text, UNIQUE (sku), PRIMARY KEY (id));`,
335
+ );
336
+ const replayed = replayMigrationsDir(dir);
337
+ expect([...(replayed.get("widgets")?.columns ?? [])].sort()).toEqual(["id", "name", "sku"]);
338
+ } finally {
339
+ rmSync(dir, { recursive: true, force: true });
340
+ }
341
+ });
342
+
343
+ test("kumiko-framework#1535: shape-neutral ALTER COLUMN/CONSTRAINT clauses don't fail loud", () => {
344
+ // Regression for the false-positive class: these are all real,
345
+ // committed, shape-neutral DDL shapes (kumiko-studio's
346
+ // 0002_file_refs_soft_delete.sql, publicstatus's
347
+ // 0020_add-monitor-checks.sql) that the #1535 fix must recognize
348
+ // instead of throwing "unparsed table-DDL statement".
349
+ const dir = tmpMigrationsDir();
350
+ try {
351
+ write(
352
+ dir,
353
+ "0001_init.sql",
354
+ `CREATE TABLE IF NOT EXISTS "widgets" ("id" uuid PRIMARY KEY, "title" text);
355
+ ALTER TABLE "widgets" ALTER COLUMN "title" SET NOT NULL;
356
+ ALTER TABLE "widgets" ALTER COLUMN "title" SET DEFAULT 'untitled';
357
+ ALTER TABLE "widgets" ADD CONSTRAINT "widgets_title_check" CHECK (length("title") > 0);
358
+ ALTER TABLE "widgets" ENABLE ROW LEVEL SECURITY;
359
+ ALTER TABLE "widgets" OWNER TO app_user;`,
360
+ );
361
+ const replayed = replayMigrationsDir(dir);
362
+ expect([...(replayed.get("widgets")?.columns ?? [])].sort()).toEqual(["id", "title"]);
363
+ } finally {
364
+ rmSync(dir, { recursive: true, force: true });
365
+ }
366
+ });
367
+
368
+ test("unexpected table: migrations create a table the snapshot doesn't know about", () => {
369
+ const dir = tmpMigrationsDir();
370
+ try {
371
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "orphan" ("id" uuid PRIMARY KEY);`);
372
+ const mismatches = diffReplayAgainstSnapshot(replayMigrationsDir(dir), snapshotFromMetas([]));
373
+ expect(mismatches).toEqual([
374
+ {
375
+ tableName: "orphan",
376
+ kind: "unexpected-table",
377
+ detail: 'migrations create "orphan" but .snapshot.json has no entry for it',
378
+ },
379
+ ]);
380
+ } finally {
381
+ rmSync(dir, { recursive: true, force: true });
382
+ }
383
+ });
384
+ });
@@ -17,4 +17,11 @@ describe("schema-inspection — resolveUnsafeClient guard", () => {
17
17
  test("columnNamesOf throws the same named error", async () => {
18
18
  await expect(columnNamesOf(noUnsafe, "x")).rejects.toThrow(/resolveUnsafeClient: no `\.unsafe/);
19
19
  });
20
+
21
+ test("tableExists throws the same named error when .unsafe is a non-function value", async () => {
22
+ const notAFunction = { unsafe: "notafunction" } as unknown as DbConnection;
23
+ await expect(tableExists(notAFunction, "public.x")).rejects.toThrow(
24
+ /resolveUnsafeClient: no `\.unsafe/,
25
+ );
26
+ });
20
27
  });
@@ -105,7 +105,7 @@ describe("schema migration workflows", () => {
105
105
  // Entity fields
106
106
  expect(columns.get("title")?.dataType).toBe("text");
107
107
  expect(columns.get("body")?.dataType).toBe("text");
108
- expect(columns.get("view_count")?.dataType).toBe("integer");
108
+ expect(columns.get("view_count")?.dataType).toBe("double precision");
109
109
  expect(columns.get("published_at")?.dataType).toContain("timestamp");
110
110
  expect(columns.get("is_draft")?.dataType).toBe("boolean");
111
111
  expect(columns.get("is_draft")?.isNullable).toBe(false); // has default → NOT NULL
@@ -109,3 +109,42 @@ describe("lock-step — softDelete + explizite Indexes", () => {
109
109
  expect((fromBuilder?.indexes ?? []).length).toBeGreaterThanOrEqual(3);
110
110
  });
111
111
  });
112
+
113
+ // Dritte Probe: lookupable-Feld (#818) — bidx-Spalte, bidx-Index und das
114
+ // partielle Unique-Pendant müssen auf beiden Pfaden identisch entstehen.
115
+ const entityWithLookupable = createEntity({
116
+ table: "read_lockstep_probe_bidx",
117
+ fields: {
118
+ email: { type: "text", required: true, pii: true, lookupable: true },
119
+ tenantSlug: { type: "text", required: true },
120
+ },
121
+ indexes: [{ columns: ["tenantSlug", "email"], unique: true }],
122
+ });
123
+
124
+ describe("lock-step — lookupable / blind-index (#818)", () => {
125
+ const fromBuilder = asEntityTableMeta(
126
+ buildEntityTable("lockstepProbeBidx", entityWithLookupable),
127
+ );
128
+ const fromMeta = buildEntityTableMeta("lockstepProbeBidx", entityWithLookupable);
129
+
130
+ test("identical columns inkl. nullable bidx-Spalte", () => {
131
+ expect(byName<ColumnMeta>(fromBuilder?.columns ?? [])).toEqual(
132
+ byName<ColumnMeta>(fromMeta.columns),
133
+ );
134
+ const bidx = (fromMeta.columns ?? []).find((c) => c.name === "email_bidx");
135
+ expect(bidx).toEqual({ name: "email_bidx", pgType: "text", notNull: false });
136
+ });
137
+
138
+ test("identical indexes inkl. bidx-Index + partiellem Unique-Pendant", () => {
139
+ expect(byName<IndexMeta>(fromBuilder?.indexes ?? [])).toEqual(
140
+ byName<IndexMeta>(fromMeta.indexes),
141
+ );
142
+ const names = fromMeta.indexes.map((i) => i.name);
143
+ expect(names).toContain("read_lockstep_probe_bidx_email_bidx_idx");
144
+ const partial = fromMeta.indexes.find((i) => i.name.endsWith("_tenant_slug_email_unique_bidx"));
145
+ expect(partial).toBeDefined();
146
+ expect(partial?.unique).toBe(true);
147
+ expect(partial?.columns).toEqual(["tenant_slug", "email_bidx"]);
148
+ expect(partial?.whereSql).toBe('"email_bidx" IS NOT NULL');
149
+ });
150
+ });
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createEntity, createTextField } from "../../engine";
3
3
  import { testTenantId } from "../../stack";
4
+ import type { DbRunner } from "../connection";
5
+ import type { TableColumns } from "../dialect";
6
+ import { buildEntityTableMeta } from "../entity-table-meta";
4
7
  import { buildEntityTable } from "../table-builder";
5
8
  import { createTenantDb } from "../tenant-db";
6
9
 
@@ -13,20 +16,25 @@ const entity = createEntity({
13
16
  table: "merge_items",
14
17
  fields: { name: createTextField({ required: true }) },
15
18
  });
16
- const table = buildEntityTable("mergeItem", entity);
19
+ // Brand (#742) is compile-time-only; hold the handle at the unbranded TableColumns
20
+ // view so the method-form scoping test still compiles (runtime shape is identical).
21
+ const table: TableColumns = buildEntityTable("mergeItem", entity);
17
22
 
18
23
  const own = testTenantId(1);
19
24
  const foreign = testTenantId(2);
20
25
 
21
26
  type Captured = { sql: string; values: readonly unknown[] };
22
27
 
23
- function recordingDb(captured: Captured[]) {
28
+ function recordingDb(captured: Captured[]): DbRunner {
24
29
  return {
25
30
  unsafe: async (sql: string, values: readonly unknown[]) => {
26
31
  captured.push({ sql, values });
27
32
  return [] as unknown[];
28
33
  },
29
- };
34
+ begin: async () => {
35
+ throw new Error("begin not used in these tests");
36
+ },
37
+ } as DbRunner;
30
38
  }
31
39
 
32
40
  describe("tenant-db WHERE merge — caller cannot override tenant scope", () => {
@@ -113,3 +121,41 @@ describe("tenant-db WHERE merge — narrowing within the enforced scope", () =>
113
121
  expect(captured[0]?.values).not.toContain(foreign);
114
122
  });
115
123
  });
124
+
125
+ // Root-cause regression for the cross-tenant leak fixed in hasTenantColumn:
126
+ // unmanaged direct-write stores (buildEntityTableMeta, e.g. userSessionTable,
127
+ // apiTokenTable) store tenantId as a snake_case column-meta entry, not a
128
+ // direct `table.tenantId` property — a naive property check silently treated
129
+ // them as tenant-less and skipped the WHERE-scope entirely.
130
+ describe("tenant-db WHERE merge — unmanaged EntityTableMeta tables (buildEntityTableMeta)", () => {
131
+ const unmanagedEntity = createEntity({
132
+ table: "merge_meta_items",
133
+ fields: { tenantId: createTextField({ required: true }), name: createTextField() },
134
+ });
135
+ const unmanagedTable = buildEntityTableMeta("merge-meta-item", unmanagedEntity);
136
+
137
+ test("selectMany still applies the tenant scope (pre-fix: no WHERE at all)", async () => {
138
+ const captured: Captured[] = [];
139
+ const tdb = createTenantDb(recordingDb(captured), own);
140
+
141
+ // selectMany's Table param types the branded-EntityTable shape only;
142
+ // EntityTableMeta reads work identically at runtime (both normalize via
143
+ // asEntityTableMeta) — same unbranded-view cast as `table` above.
144
+ await tdb.selectMany(unmanagedTable as unknown as TableColumns, { name: "needle" });
145
+
146
+ expect(captured[0]?.sql).toMatch(/tenant_id" IN /i);
147
+ expect(captured[0]?.values).toContain(own);
148
+ expect(captured[0]?.values).not.toContain(foreign);
149
+ });
150
+
151
+ test("updateMany still forces own tenantId (pre-fix: no scope on writes either)", async () => {
152
+ const captured: Captured[] = [];
153
+ const tdb = createTenantDb(recordingDb(captured), own);
154
+
155
+ await tdb.updateMany(unmanagedTable, { name: "x" }, { tenantId: foreign });
156
+
157
+ const update = captured.find((c) => /UPDATE/i.test(c.sql));
158
+ expect(update?.values).toContain(own);
159
+ expect(update?.values).not.toContain(foreign);
160
+ });
161
+ });
@@ -10,7 +10,7 @@ import {
10
10
  unsafeCreateEntityTable,
11
11
  unsafePushTables,
12
12
  } from "../../stack";
13
- import { table as pgTable, serial, text, timestamp } from "../dialect";
13
+ import { table as pgTable, serial, type TableColumns, text, timestamp } from "../dialect";
14
14
  import { buildEntityTable } from "../table-builder";
15
15
  import { createTenantDb } from "../tenant-db";
16
16
 
@@ -26,7 +26,11 @@ const entity = createEntity({
26
26
  softDelete: true,
27
27
  });
28
28
 
29
- const table = buildEntityTable("tenantDbItem", entity);
29
+ // The ES-write brand (#742) is a compile-time phantom — no runtime property. This
30
+ // suite exercises TenantDb method-form scoping on a real entity table, so the handle
31
+ // is held at the unbranded TableColumns view: identical runtime shape, and both reads
32
+ // and method-form writes accept it (a branded EntityTable is now rejected).
33
+ const table: TableColumns = buildEntityTable("tenantDbItem", entity);
30
34
 
31
35
  // --- System table (no tenantId — like job_runs) ---
32
36
 
package/src/db/api.ts CHANGED
@@ -15,7 +15,7 @@ export type DbConnectionOptions = {
15
15
 
16
16
  // Connection-Handle: db für Queries, client für Legacy-Zugriff (LISTEN/NOTIFY-Peer
17
17
  // bei Bun.SQL), close für Pool-Shutdown.
18
- export type DbConnection = {
18
+ export type DbPoolHandle = {
19
19
  /** Provider Connection — Calls gehen über asRawClient() oder direkt. */
20
20
  // biome-ignore lint/suspicious/noExplicitAny: cross-provider connection — postgres-js | Bun.SQL
21
21
  readonly db: any;
@@ -34,7 +34,7 @@ let _provider: undefined | (() => Promise<typeof import("./postgres-provider")>)
34
34
  export async function createConnection(
35
35
  url: string,
36
36
  options: DbConnectionOptions = {},
37
- ): Promise<DbConnection> {
37
+ ): Promise<DbPoolHandle> {
38
38
  const p = process.env["DB_PROVIDER"];
39
39
  if (p === "bun" || p === "bun-sql") {
40
40
  const { createBunConnection } = await import("./bun-provider");
@@ -1,22 +1,18 @@
1
1
  // applyEntityEvent — die EINZIGE Schreib-Logik für r.entity-Tabellen aus
2
2
  // Stored-Events. Beide Aufrufer benutzen sie:
3
3
  //
4
- // - createEventStoreExecutor (live, im Write-TX) — übergibt ein
5
- // "live event" mit unstripped flatData/flatChanges als payload damit
6
- // sensitive Felder in der Read-Tabelle landen, das Event-Log selbst
7
- // bleibt aber stripped (siehe append-Site im Executor).
4
+ // - createEventStoreExecutor (live, im Write-TX) — übergibt das gerade
5
+ // appendete StoredEvent direkt; sensitive Felder liegen als Tabellen-
6
+ // Ciphertext im Payload (boot-validiert pii/encrypted, #967).
8
7
  // - rebuildProjection via ImplicitProjection (replay, im Rebuild-TX) —
9
- // übergibt das StoredEvent direkt; payload ist dort ohne sensitive,
10
- // was bei Rebuild akzeptiert wird (sensitive-Drift durch GDPR-Strip
11
- // ist als load-bearing Backlog-Item gepinnt — siehe
12
- // docs/plans/architecture/migrations.md Sektion "Backlog (Welle 3+)"
13
- // → "Sensitive-Field-Persistenz im Rebuild" für Optionen a/b/c).
8
+ // übergibt dasselbe StoredEvent; apply kopiert Ciphertext byte-gleich
9
+ // zurück, bidx wird unten neu berechnet.
14
10
  //
15
- // Live==Rebuild-Equivalence ist damit by-construction für alle Felder
16
- // die NICHT als sensitive markiert sind eine geänderte Schreib-Logik
17
- // muss nur an EINER Stelle gepflegt werden, kein Sync-Contract mehr.
18
- // Der load-bearing Test bleibt für non-sensitive-Drift in
19
- // db/__tests__/implicit-projection-equivalence.integration.ts.
11
+ // Live==Rebuild-Equivalence ist damit by-construction für ALLE Felder
12
+ // eine geänderte Schreib-Logik muss nur an EINER Stelle gepflegt werden,
13
+ // kein Sync-Contract mehr. Einzige legitime Divergenz: Crypto-Shredding
14
+ // (DEK erased Wert unlesbar, bidx NULL). Load-bearing Test:
15
+ // db/__tests__/implicit-projection-equivalence.integration.test.ts.
20
16
  //
21
17
  // Tenant-Isolation: applyEntityEvent erwartet einen rohen DbRunner (TX
22
18
  // oder pool), KEINEN TenantDb-Wrapper. Schutz kommt aus zwei Quellen:
@@ -49,6 +45,7 @@
49
45
  // - "skipped" → Event ist kein Auto-Verb (Domain-Event auf demselben
50
46
  // Aggregate). Caller no-op.
51
47
 
48
+ import { collectLookupableFields, computeBlindIndexValues } from "../crypto/blind-index";
52
49
  import { deleteMany, insertOne, updateMany } from "../db/query";
53
50
  import type { EntityDefinition } from "../engine/types";
54
51
  import { InternalError } from "../errors";
@@ -116,6 +113,11 @@ export async function applyEntityEvent(
116
113
  }
117
114
  const row = await insertOne<DbRow>(tx, table, {
118
115
  ...event.payload,
116
+ // Blind-Index-Spalten aus dem Payload-Wert (ciphertext → decrypt →
117
+ // HMAC, plaintext → HMAC, erased → NULL). Hier statt im Executor,
118
+ // damit Live-Write und Rebuild denselben Wert produzieren und der
119
+ // deterministische HMAC nie im Event-Log landet.
120
+ ...(await computeBlindIndexValues(event.payload, collectLookupableFields(entity))),
119
121
  tenantId,
120
122
  id: event.aggregateId,
121
123
  version: event.version,
@@ -142,6 +144,8 @@ export async function applyEntityEvent(
142
144
  table,
143
145
  {
144
146
  ...changes,
147
+ // bidx nur für die geänderten lookupable-Felder (siehe created).
148
+ ...(await computeBlindIndexValues(changes, collectLookupableFields(entity))),
145
149
  version: event.version,
146
150
  modifiedAt: event.createdAt,
147
151
  modifiedById: event.createdBy,