@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
@@ -18,9 +18,11 @@
18
18
  // expressed in meta (hand-added in a migration) is not reconstructed, and a
19
19
  // partial index whose WHERE the renderer can't express is rejected up-front.
20
20
 
21
+ import type { DbConnection, DbTx } from "../connection";
21
22
  import type { EntityTableMeta } from "../entity-table-meta";
22
23
  import { type AnyDb, asEntityTableMeta, asRawClient } from "../query";
23
24
  import { renderTableDdl } from "../render-ddl";
25
+ import { columnNamesOf, tableExists } from "../schema-inspection";
24
26
  import { quoteTableIdent } from "./table-ops";
25
27
 
26
28
  export const PROJECTION_REBUILD_SCHEMA = "kumiko_rebuild";
@@ -68,6 +70,41 @@ export function rebuildMetaOrThrow(table: unknown, projectionName: string): Enti
68
70
  return meta;
69
71
  }
70
72
 
73
+ // Fence against a rebuild running with a registry that does not match the
74
+ // migrated live schema (#835): during a rolling deploy, a pod still running
75
+ // the previous build can pick up an async rebuild job; its shadow — built from
76
+ // the stale EntityTableMeta — would swap away a freshly-migrated column
77
+ // (recurrence class of #494). Compares COLUMN NAMES only; a type-/nullability-
78
+ // only drift passes (schema regression, not data loss — the boot gate of the
79
+ // next deploy catches it). A missing live table is fine: nothing to wipe.
80
+ // Must run BEFORE buildShadowTable; columnNamesOf pins table_schema='public',
81
+ // so the shadow search_path could not redirect it anyway.
82
+ export async function assertLiveColumnsMatchMeta(
83
+ db: DbConnection | DbTx,
84
+ meta: EntityTableMeta,
85
+ projectionName: string,
86
+ ): Promise<void> {
87
+ // skip: no live table yet — nothing a stale-meta shadow could wipe
88
+ if (!(await tableExists(db, `public.${meta.tableName}`))) return;
89
+ const live = await columnNamesOf(db, meta.tableName);
90
+ const metaNames = new Set(meta.columns.map((c) => c.name));
91
+ const onlyLive = [...live].filter((c) => !metaNames.has(c));
92
+ const onlyMeta = [...metaNames].filter((c) => !live.has(c));
93
+ // skip: column sets match — this process's registry is in sync with the migrated table
94
+ if (onlyLive.length === 0 && onlyMeta.length === 0) return;
95
+ const detail = [
96
+ onlyLive.length > 0 ? `live-only: ${onlyLive.join(", ")}` : "",
97
+ onlyMeta.length > 0 ? `meta-only: ${onlyMeta.join(", ")}` : "",
98
+ ]
99
+ .filter(Boolean)
100
+ .join("; ");
101
+ throw new Error(
102
+ `projection-rebuild "${projectionName}": columns of live table "${meta.tableName}" do not match this process's EntityTableMeta (${detail}). ` +
103
+ "Rebuilding would swap away the difference. Likely cause: this pod runs a build whose registry is behind (or ahead of) the applied migrations — rolling deploy in progress? — or DDL was applied by hand. " +
104
+ "Rebuild aborted; retry from a pod whose code matches the migrated schema.",
105
+ );
106
+ }
107
+
71
108
  // Runs INSIDE the rebuild tx, AFTER the state/consumer row lock is taken.
72
109
  // Points search_path at the shadow schema (SET LOCAL → auto-reset on commit or
73
110
  // rollback), drops any leftover shadow from a crashed run, then builds the
@@ -112,6 +149,152 @@ export async function fenceLiveTable(
112
149
  await raw.unsafe(`LOCK TABLE public.${quoteTableIdent(tableName)} IN ACCESS EXCLUSIVE MODE`);
113
150
  }
114
151
 
152
+ // Ids reported when the swap is aborted — enough to locate the ghost rows
153
+ // without dumping an unbounded set into the log.
154
+ const UNREACHABLE_SAMPLE_LIMIT = 20;
155
+
156
+ // Runs INSIDE the rebuild tx, under the fence, before swapShadowIntoLive. A live
157
+ // row whose aggregate id has NO event in the projection's source streams is
158
+ // UNREACHABLE: no replay can ever reconstruct it, so the swap would silently
159
+ // drop it. That is the #498 ghost — a row direct-inserted without ever emitting
160
+ // a .created event. The static CI guard cannot see it in data that already
161
+ // exists in production, or on table identifiers it couldn't resolve; this
162
+ // catches it at cutover and aborts (tx rolls back, live untouched).
163
+ //
164
+ // Deliberately NARROW — event EXISTENCE only, not a column or row-vs-shadow
165
+ // diff. The framework legitimately makes live diverge from a fresh replay in
166
+ // several SHIPPED ways, none of which is drift:
167
+ // - a blind-index column recomputed to NULL after the subject's key is
168
+ // shredded (GDPR erase) — the NULL is the intended end state;
169
+ // - a `sensitive` column stripped from the event log by design;
170
+ // - an archived stream that stops replaying (fw#832) — the row's wipe is the
171
+ // intended tombstone behavior, reported via backfill's `failed` list;
172
+ // - a legacy column direct-written before its handler emitted events, healed
173
+ // by the #494 backfill-then-rebuild flow.
174
+ // Checking event existence INCLUDING archived streams leaves every one of them
175
+ // alone: those rows all have a real event, so they are not ghosts. Column-level
176
+ // drift is a SEPARATE, non-blocking check — see countColumnDrift below (#916,
177
+ // resolves the #722 open question: observe, don't block).
178
+ //
179
+ // Implicit projections only (caller-gated). aggregate_id and the entity id are
180
+ // both uuid, so the anti-join probes the events index without a cast.
181
+ export async function assertNoUnreachableLiveRows(
182
+ tx: AnyDb,
183
+ projectionName: string,
184
+ tableName: string,
185
+ aggregateTypes: readonly string[],
186
+ ): Promise<void> {
187
+ // skip: no source streams → no events could back any row anyway; a rebuild
188
+ // of a subscription-less projection swaps an empty shadow (handled upstream).
189
+ if (aggregateTypes.length === 0) return;
190
+ const raw = asRawClient(tx);
191
+ const t = quoteTableIdent(tableName);
192
+ const ghosts = await raw.unsafe<{ id: unknown }>(
193
+ `SELECT l."id" FROM public.${t} l
194
+ WHERE NOT EXISTS (
195
+ SELECT 1 FROM "kumiko_events" e
196
+ WHERE e."aggregate_id" = l."id" AND e."aggregate_type" = ANY($1::text[])
197
+ )
198
+ LIMIT ${UNREACHABLE_SAMPLE_LIMIT}`,
199
+ [aggregateTypes],
200
+ );
201
+ // skip: every live row has a backing event — nothing unreachable, swap is safe
202
+ if (ghosts.length === 0) return;
203
+ const ids = ghosts.map((r) => String(r.id));
204
+ const countLabel =
205
+ ids.length === UNREACHABLE_SAMPLE_LIMIT ? `${ids.length}+` : String(ids.length);
206
+ throw new Error(
207
+ `projection-rebuild "${projectionName}": ${countLabel} live rows in "${tableName}" have no ` +
208
+ `event in the projection's source streams and cannot be reconstructed by replay — the swap ` +
209
+ `would silently drop them (ids: ${ids.join(", ")}). A handler direct-inserted these rows ` +
210
+ `without emitting a .created event. Fix: register the table with r.storeTable(meta, ` +
211
+ `{ reason }) to opt out of rebuild, or emit the missing events. See ` +
212
+ `docs/reference/entity-write-patterns.md. Rebuild aborted; live table untouched.`,
213
+ );
214
+ }
215
+
216
+ // Columns ignored by countColumnDrift — the one PROVABLY legitimate class of
217
+ // live-vs-shadow divergence. A blind-index column (`<field>_bidx`) is
218
+ // recomputed to NULL on GDPR key-shredding; the NULL is the intended end
219
+ // state, not drift. Everything else that legitimately diverges (archived
220
+ // streams, #494 backfill) either never reaches this comparison (archived rows
221
+ // are absent from the shadow entirely, see swapShadowIntoLive) or IS real
222
+ // column drift that the #494 backfill-then-rebuild flow relies on replay to
223
+ // heal — reporting it (without blocking) is correct, not a false positive.
224
+ const COLUMN_DRIFT_SAMPLE_LIMIT = 20;
225
+
226
+ export type ColumnDriftResult = {
227
+ readonly rowCount: number;
228
+ // Capped sample of "<id>.<column>" pairs for log/ops triage.
229
+ readonly sample: readonly string[];
230
+ };
231
+
232
+ // Runs INSIDE the rebuild tx, in the same slot as assertNoUnreachableLiveRows
233
+ // (after replay settles, before swapShadowIntoLive). Non-blocking counterpart
234
+ // to the ghost-row guard: reports live rows whose column values differ from
235
+ // the freshly-replayed shadow, WITHOUT aborting the swap (#916, resolves the
236
+ // #722 open question in favor of observe-not-block).
237
+ //
238
+ // Why non-blocking: a legacy column direct-written before its handler emitted
239
+ // events (#494) diverges from replay by design — that divergence is exactly
240
+ // what the backfill-then-rebuild flow relies on replay to heal. Failing hard
241
+ // here would make rebuild mutually exclusive with that shipped healing path.
242
+ // There is no reliable metadata to distinguish "#494 healing in progress" from
243
+ // "someone else corrupted this row" short of an open-ended per-column policy
244
+ // blocklist — wrong-by-default whenever a class is missed. So: surface it,
245
+ // don't police it. The caller logs the result; ops decides.
246
+ //
247
+ // Caveat: sensitive CUSTOM fields still diverge until #972 (Subject-DEK
248
+ // design) — regular sensitive fields carry event-payload ciphertext parity
249
+ // post-#973 and don't drift. Both are reported the same as any other column
250
+ // drift; this is deliberate (see module comment above), not an oversight.
251
+ //
252
+ // Relies on assertLiveColumnsMatchMeta having already run: live/shadow/meta
253
+ // column sets are known to match, so the diff can walk meta.columns directly.
254
+ export async function countColumnDrift(
255
+ tx: AnyDb,
256
+ tableName: string,
257
+ meta: EntityTableMeta,
258
+ ): Promise<ColumnDriftResult> {
259
+ const comparable = meta.columns.filter((c) => c.primaryKey !== true && !c.name.endsWith("_bidx"));
260
+ // skip: nothing to compare (id-only or all-bidx table) — no drift is possible
261
+ if (comparable.length === 0) return { rowCount: 0, sample: [] };
262
+ const t = quoteTableIdent(tableName);
263
+ const raw = asRawClient(tx);
264
+ const driftCte = `WITH drifted AS (
265
+ SELECT l."id" AS id, string_agg(diff.col, ',') AS drifted_columns
266
+ FROM public.${t} l
267
+ JOIN ${SCHEMA_IDENT}.${t} s ON s."id" = l."id"
268
+ CROSS JOIN LATERAL (
269
+ VALUES ${comparable
270
+ .map(
271
+ (c) =>
272
+ `('${c.name}', l.${quoteTableIdent(c.name)} IS DISTINCT FROM s.${quoteTableIdent(c.name)})`,
273
+ )
274
+ .join(", ")}
275
+ ) AS diff(col, differs)
276
+ WHERE diff.differs
277
+ GROUP BY l."id"
278
+ )`;
279
+ // Two passes over `drifted`: an unbounded COUNT for the true total (replay
280
+ // already scanned every row this run, so a second scan here is cheap by
281
+ // comparison) plus a capped sample for the log. rowCount must NEVER be
282
+ // min(actual, LIMIT) — that would silently understate severity to ops.
283
+ const totalRows = await raw.unsafe<{ total: string }>(
284
+ `${driftCte} SELECT count(*)::text AS total FROM drifted`,
285
+ );
286
+ const rowCount = Number(totalRows[0]?.total ?? "0");
287
+ // skip: no drift — nothing to sample
288
+ if (rowCount === 0) return { rowCount: 0, sample: [] };
289
+ const rows = await raw.unsafe<{ id: unknown; drifted_columns: string }>(
290
+ `${driftCte} SELECT id, drifted_columns FROM drifted LIMIT ${COLUMN_DRIFT_SAMPLE_LIMIT}`,
291
+ );
292
+ const sample = rows.flatMap((r) =>
293
+ r.drifted_columns.split(",").map((col) => `${String(r.id)}.${col}`),
294
+ );
295
+ return { rowCount, sample };
296
+ }
297
+
115
298
  // Atomic swap, INSIDE the rebuild tx, AFTER replay. Schema-qualified so the
116
299
  // active shadow search_path can't redirect them. DROP without CASCADE: if any
117
300
  // object depends on the live table the swap fails loud and the whole rebuild
@@ -2,36 +2,10 @@ import type { AnyDb } from "../query";
2
2
  import { asRawClient } from "../query";
3
3
  import { quoteTableIdent } from "./table-ops";
4
4
 
5
- export async function executeDdlStatement(db: AnyDb, sqlText: string): Promise<void> {
6
- await asRawClient(db).unsafe(sqlText);
7
- }
8
-
9
- export async function alterTableAddColumn(
10
- db: AnyDb,
11
- tableName: string,
12
- columnName: string,
13
- columnType: string,
14
- defaultClause: string,
15
- notNull: string,
16
- ): Promise<void> {
17
- await asRawClient(db).unsafe(
18
- `ALTER TABLE ${quoteTableIdent(tableName)} ADD COLUMN ${quoteTableIdent(columnName)} ${columnType}${defaultClause}${notNull}`,
19
- );
20
- }
21
-
22
- export async function createIndexIfNotExists(
23
- db: AnyDb,
24
- indexKind: "UNIQUE INDEX" | "INDEX",
25
- indexName: string,
26
- tableName: string,
27
- columnList: string,
28
- whereSql?: string,
29
- ): Promise<void> {
30
- const where = whereSql !== undefined ? ` WHERE ${whereSql}` : "";
31
- await asRawClient(db).unsafe(
32
- `CREATE ${indexKind} IF NOT EXISTS ${quoteTableIdent(indexName)} ON ${quoteTableIdent(tableName)} (${columnList})${where}`,
33
- );
34
- }
5
+ // Re-exported for back-compat the generic DDL helpers moved to ./ddl so
6
+ // the prod-boot path (stack/table-helpers.ts, pipeline/event-consumer-state.ts)
7
+ // doesn't import from a module named for test-only concerns.
8
+ export { alterTableAddColumn, createIndexIfNotExists, executeDdlStatement } from "./ddl";
35
9
 
36
10
  export async function truncateTablesRestartIdentity(
37
11
  db: AnyDb,
@@ -17,6 +17,7 @@ export {
17
17
  fetchOne,
18
18
  insertMany,
19
19
  insertOne,
20
+ runInSavepoint,
20
21
  selectMany,
21
22
  transaction,
22
23
  updateMany,
package/src/db/query.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  insertMany,
18
18
  insertOne,
19
19
  type OrderByClause,
20
+ runInSavepoint,
20
21
  type SelectOptions,
21
22
  selectMany,
22
23
  type TableInfo,
@@ -32,12 +32,28 @@ function markerPathFor(migrationsDir: string, migrationId: string): string {
32
32
  return join(migrationsDir, `${migrationId}.rebuild.json`);
33
33
  }
34
34
 
35
- // Only managed tables (event-stream derivatives) get rebuild markers — unmanaged carry real data, never rebuilt from events; sorted+deduped for stable PR diff.
35
+ // Only managed tables (event-stream derivatives) get rebuild markers — unmanaged
36
+ // carry real data, never rebuilt from events; sorted+deduped for stable PR diff.
37
+ //
38
+ // A changed table needs a rebuild ONLY when the generated SQL RECREATES it
39
+ // (managedChangeRequiresRecreate: drop/NOT-NULL-w/o-default/unique-index/type-
40
+ // or nullability-change) — then the table is emptied and must be re-derived from
41
+ // events. A pure additive nullable column is an in-place `ADD COLUMN` ALTER that
42
+ // already brings the table to the target state (same logic #181 applied to
43
+ // index-/default-only changes). Rebuilding it anyway is wasted replay AND — the
44
+ // bug this closes — a co-triggered rebuild can drop the fresh column (rebuild
45
+ // runs from the rebuilding process's registry meta; on a rolling deploy an older
46
+ // pod's meta lacks the column → shadow-swap wipes it → phantom migration + boot-
47
+ // drift crash; see 0008_add_pending_deletion_request_id / #494 / #835).
48
+ //
49
+ // If a NEW additive column genuinely needs value-backfill from historical events
50
+ // (a column DERIVED from existing event fields), opt in explicitly by hand-adding
51
+ // a `NNNN_<name>.rebuild.json` next to the migration (readRebuildMarker reads it).
36
52
  export function rebuildTablesFromDiff(diff: SchemaDiff): readonly string[] {
37
53
  const names = new Set<string>();
38
54
  for (const t of diff.changedTables) {
39
55
  if (t.nextMeta.source !== "managed") continue;
40
- if (t.newColumns.length > 0 || managedChangeRequiresRecreate(t)) names.add(t.tableName);
56
+ if (managedChangeRequiresRecreate(t)) names.add(t.tableName);
41
57
  }
42
58
  for (const t of diff.newTables) {
43
59
  if (t.source === "managed") names.add(t.tableName);
@@ -1,3 +1,4 @@
1
+ import { KUMIKO_COLUMNS_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
1
2
  import { fetchOne, insertOne, updateMany } from "../db/query";
2
3
  import type { ReferenceDataDef } from "../engine/types";
3
4
  import { SYSTEM_TENANT_ID } from "../engine/types";
@@ -7,10 +8,8 @@ import type { TableColumns } from "./dialect";
7
8
  // biome-ignore lint/suspicious/noExplicitAny: Drizzle dynamic tables
8
9
  type Table = TableColumns<any>;
9
10
 
10
- const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
11
-
12
11
  function hasColumn(table: Table, field: string): boolean {
13
- const cols = (table as Record<symbol, unknown>)[KUMIKO_COLUMNS_SYMBOL];
12
+ const cols = table[KUMIKO_COLUMNS_SYMBOL];
14
13
  if (typeof cols !== "object" || cols === null) return false;
15
14
  return field in (cols as Record<string, unknown>);
16
15
  }
@@ -0,0 +1,257 @@
1
+ // Build-time, DB-free replay: reads the checked-in `kumiko/migrations/*.sql`
2
+ // files in sequence order and reconstructs the table/column shape they
3
+ // actually produce — then that gets diffed against `.snapshot.json`.
4
+ //
5
+ // Catches the class of bug where a migration file's *content* silently
6
+ // drifts from what its filename/snapshot-entry claims (e.g. a copy-paste
7
+ // from an earlier migration): `kumiko schema validate`'s other checks only
8
+ // compare ENTITY_METAS ↔ snapshot, never the committed SQL bytes against
9
+ // either. Reuses `loadMigrationsFromDir`'s statement-splitting so the replay
10
+ // sees exactly what the real runner would execute.
11
+
12
+ import type { Snapshot } from "./migrate-generator";
13
+ import { loadMigrationsFromDir } from "./migrate-runner";
14
+
15
+ // Migration files comment out destructive ops (DROP TABLE/COLUMN) as
16
+ // `-- DESTRUCTIVE: <stmt>; -- uncomment + ensure backup` so the real
17
+ // migrate-runner never executes them unattended. For replay purposes the
18
+ // snapshot represents the INTENDED end state, so a commented-out drop must
19
+ // still count as applied here — otherwise a table/column the snapshot
20
+ // already omits shows up as "unexpected" forever. splitSqlStatements (the
21
+ // real runner's splitter) strips `--`-comments outright, which would erase
22
+ // these markers before they ever reach applyStatement.
23
+ const DESTRUCTIVE_MARKER = /^--\s*DESTRUCTIVE:\s*(.+?;)/i;
24
+
25
+ function expandDestructiveMarkers(sqlText: string): string {
26
+ return sqlText
27
+ .split("\n")
28
+ .map((line) => DESTRUCTIVE_MARKER.exec(line.trim())?.[1] ?? line)
29
+ .join("\n");
30
+ }
31
+
32
+ export type ReplayedTable = {
33
+ readonly columns: ReadonlySet<string>;
34
+ };
35
+
36
+ export type ReplayedSchema = ReadonlyMap<string, ReplayedTable>;
37
+
38
+ // Splits a parenthesized column-list body on top-level commas — depth-aware
39
+ // so commas inside `numeric(10,2)` or `DEFAULT gen_random_uuid()` don't
40
+ // fracture a column definition.
41
+ function splitTopLevel(body: string): readonly string[] {
42
+ const parts: string[] = [];
43
+ let depth = 0;
44
+ let current = "";
45
+ for (const ch of body) {
46
+ if (ch === "(") depth++;
47
+ if (ch === ")") depth--;
48
+ if (ch === "," && depth === 0) {
49
+ parts.push(current);
50
+ current = "";
51
+ } else {
52
+ current += ch;
53
+ }
54
+ }
55
+ if (current.trim().length > 0) parts.push(current);
56
+ return parts;
57
+ }
58
+
59
+ // Identifiers in hand-written migrations aren't always quoted (the generator
60
+ // always quotes, but the header explicitly invites hand-editing) — optional
61
+ // quotes so `CREATE TABLE foo (...)` parses the same as `CREATE TABLE "foo" (...)`.
62
+ const IDENT = `"?([^"\\s(;,.]+)"?`;
63
+
64
+ function parseColumnNames(body: string): Set<string> {
65
+ const columns = new Set<string>();
66
+ for (const part of splitTopLevel(body)) {
67
+ const trimmed = part.trim();
68
+ if (/^(CONSTRAINT|PRIMARY|UNIQUE|CHECK|FOREIGN|EXCLUDE|LIKE)\b/i.test(trimmed)) continue; // table-constraint or LIKE clause, not a column
69
+ const match = trimmed.match(new RegExp(`^${IDENT}`));
70
+ if (match?.[1] !== undefined) columns.add(match[1]);
71
+ }
72
+ return columns;
73
+ }
74
+
75
+ // Clauses that can appear inside an ALTER TABLE body which do NOT change the
76
+ // table/column shape this replay tracks (presence only, not types/
77
+ // constraints/RLS/ownership) — recognized explicitly so they don't fall
78
+ // through to the fail-loud check as "unparsed". Deliberately does NOT
79
+ // include RENAME TO / RENAME COLUMN: those DO change identity in a way this
80
+ // replay can't track, so they must keep failing loud.
81
+ const SHAPE_NEUTRAL_ALTER_CLAUSE_RE = new RegExp(
82
+ [
83
+ `ALTER COLUMN\\s+${IDENT}\\s+TYPE\\b`, // #1085 int/bigint-catchup fixes
84
+ `ALTER COLUMN\\s+${IDENT}\\s+(SET|DROP)\\s+NOT NULL\\b`,
85
+ `ALTER COLUMN\\s+${IDENT}\\s+(SET DEFAULT\\b|DROP DEFAULT\\b)`,
86
+ `(ADD|DROP)\\s+CONSTRAINT\\s+${IDENT}`,
87
+ `\\b(ENABLE|DISABLE)\\s+ROW LEVEL SECURITY\\b`,
88
+ `^OWNER TO\\b`,
89
+ ].join("|"),
90
+ "gi",
91
+ );
92
+
93
+ function applyStatement(
94
+ schema: Map<string, { columns: Set<string> }>,
95
+ statement: string,
96
+ context: { readonly file: string },
97
+ ): void {
98
+ const create = statement.match(
99
+ new RegExp(`^CREATE TABLE\\s+(IF NOT EXISTS\\s+)?${IDENT}\\s*\\(([\\s\\S]*)\\);?\\s*$`, "i"),
100
+ );
101
+ if (create?.[2] !== undefined && create[3] !== undefined) {
102
+ const hasIfNotExists = create[1] !== undefined;
103
+ // A real Postgres CREATE TABLE IF NOT EXISTS is a no-op when the table
104
+ // already exists — treating it as an overwrite here loses any columns
105
+ // an earlier ALTER TABLE added in between (0001 CREATE, 0002 ALTER ADD
106
+ // COLUMN, 0003 an accidental copy-paste of 0001) and reports a false
107
+ // column-drift for exactly the copy-paste bug this replay exists to
108
+ // catch. A bare CREATE TABLE (no IF NOT EXISTS) still overwrites — the
109
+ // explicit recreate path (migrate-generator.ts) always DROPs first, so
110
+ // reaching a second CREATE for the same name there is itself already
111
+ // the bug the replay should surface via the resulting drift.
112
+ if (!(hasIfNotExists && schema.has(create[2]))) {
113
+ schema.set(create[2], { columns: parseColumnNames(create[3]) });
114
+ }
115
+ // skip: CREATE TABLE fully handled above, no other clause can also match
116
+ return;
117
+ }
118
+
119
+ const dropTable = statement.match(new RegExp(`^DROP TABLE\\s+(?:IF EXISTS\\s+)?${IDENT}`, "i"));
120
+ if (dropTable?.[1] !== undefined) {
121
+ schema.delete(dropTable[1]);
122
+ // skip: DROP TABLE fully handled above, no other clause can also match
123
+ return;
124
+ }
125
+
126
+ // A single ALTER TABLE statement can carry multiple comma-separated
127
+ // ADD/DROP COLUMN clauses (e.g. migration 0007_fix-secrets-table-columns'
128
+ // three-column fix in one statement) — matchAll over the whole body
129
+ // instead of matching only the first clause, in statement order so an
130
+ // add-then-drop of the same column (unusual, but not impossible) resolves
131
+ // correctly.
132
+ const alterTable = statement.match(
133
+ new RegExp(`^ALTER TABLE\\s+${IDENT}\\s+([\\s\\S]*?);?\\s*$`, "i"),
134
+ );
135
+ const alterTableName = alterTable?.[1];
136
+ const alterBody = alterTable?.[2];
137
+ if (alterTableName !== undefined && alterBody !== undefined) {
138
+ const table = schema.get(alterTableName) ?? { columns: new Set<string>() };
139
+ schema.set(alterTableName, table);
140
+ const clauseRe = new RegExp(
141
+ `(ADD|DROP)\\s+COLUMN\\s+(?:IF (?:NOT )?EXISTS\\s+)?${IDENT}`,
142
+ "gi",
143
+ );
144
+ let matchedAClause = false;
145
+ for (const [, verb, name] of alterBody.matchAll(clauseRe)) {
146
+ if (verb === undefined || name === undefined) continue;
147
+ matchedAClause = true;
148
+ if (verb.toUpperCase() === "ADD") table.columns.add(name);
149
+ else table.columns.delete(name);
150
+ }
151
+ // Shape-neutral clauses (ALTER COLUMN TYPE, SET/DROP NOT NULL, SET/DROP
152
+ // DEFAULT, ADD/DROP CONSTRAINT, ENABLE/DISABLE ROW LEVEL SECURITY, OWNER
153
+ // TO) — none add/remove/rename a column, so this replay (which only
154
+ // tracks column presence) correctly has nothing to do for them.
155
+ if (alterBody.match(SHAPE_NEUTRAL_ALTER_CLAUSE_RE)) matchedAClause = true;
156
+ // An ALTER TABLE that matched the outer "ALTER TABLE <name> <body>" shape
157
+ // but whose body contains no recognized clause (e.g. RENAME TO/RENAME
158
+ // COLUMN, which DO change identity) would otherwise silently no-op here
159
+ // — fall through to the fail-loud check below instead of returning, so
160
+ // it's reported rather than vanishing.
161
+ // skip: at least one recognized clause matched — this ALTER TABLE is
162
+ // fully handled, nothing left to do.
163
+ if (matchedAClause) return;
164
+ }
165
+ // else: CREATE INDEX and everything else don't change the table/column
166
+ // shape this replay tracks — but a statement that clearly INTENDED to
167
+ // touch a table's shape (starts with CREATE/ALTER/DROP TABLE) and matched
168
+ // none of the recognized patterns above must fail loud, not vanish
169
+ // silently. Concretely this is RENAME TO / RENAME COLUMN (identity change
170
+ // this replay can't track) or genuinely unparsed hand-written DDL — either
171
+ // way a misleading missing-table/column-drift report is worse than
172
+ // pointing at the actual unparsed statement.
173
+ if (/^(CREATE|ALTER|DROP)\s+TABLE\b/i.test(statement)) {
174
+ const prefix = statement.slice(0, 200).replace(/\s+/g, " ").trim();
175
+ throw new Error(
176
+ `replayMigrationsDir: unparsed table-DDL statement in ${context.file} — ` +
177
+ `starts with CREATE/ALTER/DROP TABLE but matched none of the replay's ` +
178
+ `recognized patterns (CREATE TABLE, DROP TABLE, ALTER TABLE ADD/DROP ` +
179
+ `COLUMN, ALTER COLUMN ... TYPE, SET/DROP NOT NULL, SET/DROP DEFAULT, ` +
180
+ `ADD/DROP CONSTRAINT, ENABLE/DISABLE ROW LEVEL SECURITY, OWNER TO — ` +
181
+ `optionally-quoted identifiers). Likely RENAME TO/RENAME COLUMN (real ` +
182
+ `identity change, not trackable here) or genuinely unparsed hand-written ` +
183
+ `DDL. Statement: ${prefix}${statement.length > 200 ? "…" : ""}`,
184
+ );
185
+ }
186
+ }
187
+
188
+ // Reads `<migrationsDir>/*.sql` in sequence order and replays every
189
+ // CREATE/ALTER/DROP TABLE statement to reconstruct the resulting schema.
190
+ // Reuses the real runner's file-discovery + statement-splitting
191
+ // (loadMigrationsFromDir) instead of a second copy, so any future change to
192
+ // sub-directory handling, numeric sort order, or the .sql filter can't
193
+ // silently drift between the runner and this replay (#1522/9).
194
+ export function replayMigrationsDir(migrationsDir: string): ReplayedSchema {
195
+ const schema = new Map<string, { columns: Set<string> }>();
196
+ const migrations = loadMigrationsFromDir(migrationsDir, expandDestructiveMarkers);
197
+ for (const migration of migrations) {
198
+ for (const statement of migration.statements) {
199
+ applyStatement(schema, statement, { file: migration.id });
200
+ }
201
+ }
202
+ return schema;
203
+ }
204
+
205
+ export type ReplayMismatch = {
206
+ readonly tableName: string;
207
+ readonly kind: "missing-table" | "unexpected-table" | "column-drift";
208
+ readonly detail: string;
209
+ };
210
+
211
+ // Compares what the migration files actually produce (`replayed`) against
212
+ // what `.snapshot.json` claims (`snapshot`) — the check that would have
213
+ // caught kumiko-studio's 0016 misgeneration (snapshot correct, SQL wrong).
214
+ export function diffReplayAgainstSnapshot(
215
+ replayed: ReplayedSchema,
216
+ snapshot: Snapshot,
217
+ ): readonly ReplayMismatch[] {
218
+ const mismatches: ReplayMismatch[] = [];
219
+ const snapshotTableNames = new Set(snapshot.tables.map((t) => t.tableName));
220
+
221
+ for (const meta of snapshot.tables) {
222
+ const table = replayed.get(meta.tableName);
223
+ if (!table) {
224
+ mismatches.push({
225
+ tableName: meta.tableName,
226
+ kind: "missing-table",
227
+ detail: `snapshot expects "${meta.tableName}" but no migration file creates it`,
228
+ });
229
+ continue;
230
+ }
231
+ const expected = new Set(meta.columns.map((c) => c.name));
232
+ const missing = [...expected].filter((c) => !table.columns.has(c));
233
+ const extra = [...table.columns].filter((c) => !expected.has(c));
234
+ if (missing.length > 0 || extra.length > 0) {
235
+ const parts: string[] = [];
236
+ if (missing.length > 0) parts.push(`missing columns: ${missing.join(", ")}`);
237
+ if (extra.length > 0) parts.push(`unexpected columns: ${extra.join(", ")}`);
238
+ mismatches.push({
239
+ tableName: meta.tableName,
240
+ kind: "column-drift",
241
+ detail: parts.join("; "),
242
+ });
243
+ }
244
+ }
245
+
246
+ for (const tableName of replayed.keys()) {
247
+ if (!snapshotTableNames.has(tableName)) {
248
+ mismatches.push({
249
+ tableName,
250
+ kind: "unexpected-table",
251
+ detail: `migrations create "${tableName}" but .snapshot.json has no entry for it`,
252
+ });
253
+ }
254
+ }
255
+
256
+ return mismatches;
257
+ }
@@ -13,7 +13,7 @@ function resolveUnsafeClient(db: DbConnection | DbTx): UnsafeFn {
13
13
  };
14
14
  const client = dbAny.$client ?? dbAny.session?.client ?? dbAny;
15
15
  const fn = (client as { unsafe?: UnsafeFn }).unsafe;
16
- if (fn === undefined) {
16
+ if (typeof fn !== "function") {
17
17
  throw new Error(
18
18
  "resolveUnsafeClient: no `.unsafe(sql, params)` fn on the db connection " +
19
19
  "(checked $client, session.client, db itself) — schema-inspection needs the raw postgres escape hatch.",