@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,277 @@
1
+ // One-time backfill for pre-KMS plaintext PII in kumiko_events (#799).
2
+ //
3
+ // Crypto-shredding (#724/#818) only covers NEW writes — events appended
4
+ // before a KMS was configured still carry plaintext (user.created email,
5
+ // delivery attempt recipientAddress, job payloads). This tool re-encrypts
6
+ // them in place, per field, under the owning subject's DEK:
7
+ //
8
+ // - entity lifecycle events (<entity>.created/updated/deleted/forgotten/
9
+ // restored) for every entity with PII subject annotations
10
+ // - custom events from the event-PII catalog (r.defineEvent piiFields)
11
+ //
12
+ // Already-forgotten subjects must NOT get a fresh key minted for their old
13
+ // plaintext — three erased-detection layers write [[erased]] instead:
14
+ // 1. KeyErasedError from the KMS (subject forgotten in the KMS era)
15
+ // 2. the event's own aggregate has a *.forgotten event (pre-KMS forget)
16
+ // 3. the resolved user subject's id has a *.forgotten event (custom
17
+ // events referencing a pre-KMS-forgotten user)
18
+ //
19
+ // Idempotent: ciphertext and sentinel values pass through untouched — a
20
+ // second run reports 0 updates. One failing event does not abort the run;
21
+ // failures are collected and reported (fail-loud at the caller).
22
+ //
23
+ // Snapshots of touched aggregates are dropped (they may cache plaintext);
24
+ // the next snapshotting load recreates them. AFTER a run, rebuild the
25
+ // affected projections — applyEntityEvent materializes ciphertext AND the
26
+ // blind-index columns, which keeps equality lookups (login by email) alive.
27
+
28
+ import { asRawClient } from "../../bun-db";
29
+ import { configuredEventPiiCatalog } from "../../crypto/event-pii";
30
+ import type { KmsContext, LocalKeyKmsAdapter, SubjectId } from "../../crypto/kms-adapter";
31
+ import { KeyErasedError } from "../../crypto/kms-adapter";
32
+ import {
33
+ configuredPiiSubjectKms,
34
+ encryptPiiValueForSubject,
35
+ isPiiCiphertext,
36
+ PII_ERASED_SENTINEL,
37
+ } from "../../crypto/pii-field-encryption";
38
+ import { collectPiiSubjectFields, resolveSubjectForField } from "../../crypto/subject-resolver";
39
+ import type { EntityDefinition, Registry, TenantId } from "../../engine/types";
40
+ import type { DbRunner } from "../connection";
41
+
42
+ const LIFECYCLE_VERBS = ["created", "updated", "deleted", "restored", "forgotten"] as const;
43
+
44
+ export type PiiBackfillFailure = {
45
+ readonly eventId: string;
46
+ readonly reason: string;
47
+ };
48
+
49
+ export type PiiBackfillResult = {
50
+ readonly scannedEvents: number;
51
+ readonly updatedEvents: number;
52
+ readonly encryptedFields: number;
53
+ readonly erasedFields: number;
54
+ readonly deletedSnapshots: number;
55
+ readonly failures: readonly PiiBackfillFailure[];
56
+ };
57
+
58
+ export type PiiBackfillOptions = {
59
+ readonly batchSize?: number;
60
+ // Scan + count only, write nothing.
61
+ readonly dryRun?: boolean;
62
+ };
63
+
64
+ type EventRow = {
65
+ readonly id: bigint | string;
66
+ readonly aggregate_id: string;
67
+ readonly aggregate_type: string;
68
+ readonly tenant_id: string;
69
+ readonly type: string;
70
+ readonly payload: Record<string, unknown>;
71
+ };
72
+
73
+ type FieldOutcome = "unchanged" | "encrypted" | "erased";
74
+
75
+ export async function backfillEventPiiEncryption(
76
+ db: DbRunner,
77
+ registry: Registry,
78
+ options: PiiBackfillOptions = {},
79
+ ): Promise<PiiBackfillResult> {
80
+ const kms = configuredPiiSubjectKms();
81
+ if (!kms) {
82
+ throw new Error(
83
+ "backfillEventPiiEncryption requires a configured subject KMS — boot with " +
84
+ "runProdApp({ kms }) / configurePiiSubjectKms(adapter) before running the backfill.",
85
+ );
86
+ }
87
+ const batchSize = options.batchSize ?? 500;
88
+ const raw = asRawClient(db);
89
+ const kmsCtx: KmsContext = { requestId: "pii-backfill" };
90
+
91
+ const entityTargets = new Map<
92
+ string,
93
+ { readonly entity: EntityDefinition; readonly piiFields: readonly string[] }
94
+ >();
95
+ for (const [name, entity] of registry.getAllEntities()) {
96
+ const piiFields = collectPiiSubjectFields(entity);
97
+ if (piiFields.length > 0) entityTargets.set(name, { entity, piiFields });
98
+ }
99
+ const eventCatalog = configuredEventPiiCatalog();
100
+
101
+ const aggregateTypes = [...entityTargets.keys()];
102
+ const catalogTypes = [...eventCatalog.keys()];
103
+ const result = {
104
+ scannedEvents: 0,
105
+ updatedEvents: 0,
106
+ encryptedFields: 0,
107
+ erasedFields: 0,
108
+ deletedSnapshots: 0,
109
+ failures: [] as PiiBackfillFailure[],
110
+ };
111
+ if (aggregateTypes.length === 0 && catalogTypes.length === 0) return result;
112
+
113
+ // Pre-KMS forgets left no key tombstone — the *.forgotten event on the
114
+ // stream is the only durable marker. Collect once; aggregate_id doubles
115
+ // as the user id for user-subject lookups.
116
+ const forgottenRows = (await raw.unsafe(
117
+ `SELECT DISTINCT "aggregate_id" FROM "kumiko_events" WHERE "type" LIKE '%.forgotten'`,
118
+ )) as ReadonlyArray<{ aggregate_id: string }>;
119
+ const forgottenAggregates = new Set(forgottenRows.map((r) => r.aggregate_id));
120
+
121
+ const touchedAggregates = new Set<string>();
122
+ let cursor = "0";
123
+
124
+ for (;;) {
125
+ const rows = (await raw.unsafe(
126
+ `SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
127
+ FROM "kumiko_events"
128
+ WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" > $3::bigint
129
+ ORDER BY "id" ASC
130
+ LIMIT $4`,
131
+ [aggregateTypes, catalogTypes, cursor, batchSize],
132
+ )) as ReadonlyArray<EventRow>;
133
+ if (rows.length === 0) break;
134
+
135
+ for (const row of rows) {
136
+ result.scannedEvents++;
137
+ try {
138
+ const outcome = await transformEvent(row);
139
+ if (outcome === null) continue;
140
+ result.encryptedFields += outcome.encrypted;
141
+ result.erasedFields += outcome.erased;
142
+ if (!options.dryRun) {
143
+ await raw.unsafe(`UPDATE "kumiko_events" SET "payload" = $1::jsonb WHERE "id" = $2`, [
144
+ JSON.stringify(outcome.payload),
145
+ row.id,
146
+ ]);
147
+ }
148
+ result.updatedEvents++;
149
+ touchedAggregates.add(row.aggregate_id);
150
+ } catch (e) {
151
+ result.failures.push({
152
+ eventId: String(row.id),
153
+ reason: e instanceof Error ? e.message : String(e),
154
+ });
155
+ }
156
+ }
157
+ const last = rows[rows.length - 1];
158
+ if (last === undefined) break;
159
+ cursor = String(last.id);
160
+ }
161
+
162
+ // Snapshots may cache the plaintext state of touched aggregates.
163
+ if (!options.dryRun && touchedAggregates.size > 0) {
164
+ const deleted = (await raw.unsafe(
165
+ `DELETE FROM "kumiko_snapshots" WHERE "aggregate_id" = ANY($1::uuid[]) RETURNING "aggregate_id"`,
166
+ [[...touchedAggregates]],
167
+ )) as ReadonlyArray<unknown>;
168
+ result.deletedSnapshots = deleted.length;
169
+ }
170
+
171
+ return result;
172
+
173
+ async function transformEvent(
174
+ row: EventRow,
175
+ ): Promise<{ payload: Record<string, unknown>; encrypted: number; erased: number } | null> {
176
+ const counters = { encrypted: 0, erased: 0 };
177
+ const payload = structuredClone(row.payload);
178
+
179
+ const catalogFields = eventCatalog.get(row.type);
180
+ if (catalogFields) {
181
+ for (const [field, spec] of Object.entries(catalogFields)) {
182
+ const subjectId = payload[spec.subjectField];
183
+ if (typeof subjectId !== "string" || subjectId.length === 0) continue;
184
+ const outcome = await encryptField(payload, field, { kind: "user", userId: subjectId });
185
+ bump(outcome);
186
+ }
187
+ } else {
188
+ const target = entityTargets.get(row.aggregate_type);
189
+ if (!target || !isLifecycleEventOf(row.type, row.aggregate_type)) return null;
190
+ const sections = lifecycleSections(payload);
191
+ for (const section of sections) {
192
+ // Update-changes may carry a pii field without its owner field —
193
+ // resolve subjects from the merged view; aggregate_id backs the
194
+ // self-subject when a section lacks the id column.
195
+ const subjectSource: Record<string, unknown> = {
196
+ id: row.aggregate_id,
197
+ ...Object.assign({}, ...sections),
198
+ ...section,
199
+ };
200
+ for (const field of target.piiFields) {
201
+ const subject = resolveSubjectForField(target.entity, field, subjectSource, {
202
+ // @cast-boundary db-read — tenant_id column is the branded TenantId
203
+ tenantId: row.tenant_id as TenantId,
204
+ });
205
+ if (subject === null) continue;
206
+ const outcome = await encryptField(section, field, subject);
207
+ bump(outcome);
208
+ }
209
+ }
210
+ }
211
+
212
+ if (counters.encrypted === 0 && counters.erased === 0) return null;
213
+ return { payload, ...counters };
214
+
215
+ function bump(outcome: FieldOutcome): void {
216
+ if (outcome === "encrypted") counters.encrypted++;
217
+ if (outcome === "erased") counters.erased++;
218
+ }
219
+
220
+ async function encryptField(
221
+ section: Record<string, unknown>,
222
+ field: string,
223
+ subject: SubjectId,
224
+ ): Promise<FieldOutcome> {
225
+ const value = section[field];
226
+ if (value === null || value === undefined) return "unchanged";
227
+ if (typeof value !== "string") return "unchanged";
228
+ if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) return "unchanged";
229
+ if (isForgottenSubject(subject, row.aggregate_id)) {
230
+ section[field] = PII_ERASED_SENTINEL;
231
+ return "erased";
232
+ }
233
+ try {
234
+ section[field] = await encryptPiiValueForSubject(
235
+ kms as LocalKeyKmsAdapter,
236
+ subject,
237
+ value,
238
+ kmsCtx,
239
+ field,
240
+ );
241
+ return "encrypted";
242
+ } catch (e) {
243
+ if (e instanceof KeyErasedError) {
244
+ section[field] = PII_ERASED_SENTINEL;
245
+ return "erased";
246
+ }
247
+ throw e;
248
+ }
249
+ }
250
+ }
251
+
252
+ function isForgottenSubject(subject: SubjectId, aggregateId: string): boolean {
253
+ if (forgottenAggregates.has(aggregateId)) return true;
254
+ return subject.kind === "user" && forgottenAggregates.has(subject.userId);
255
+ }
256
+ }
257
+
258
+ function isLifecycleEventOf(eventType: string, aggregateType: string): boolean {
259
+ if (!eventType.startsWith(`${aggregateType}.`)) return false;
260
+ const verb = eventType.slice(aggregateType.length + 1);
261
+ return (LIFECYCLE_VERBS as readonly string[]).includes(verb);
262
+ }
263
+
264
+ // created events carry the fields flat; updated carries { changes, previous };
265
+ // deleted/forgotten/restored carry { previous }. Returned sections are the
266
+ // mutable objects INSIDE the payload clone — encryptField writes in place.
267
+ function lifecycleSections(payload: Record<string, unknown>): Record<string, unknown>[] {
268
+ const sections: Record<string, unknown>[] = [];
269
+ if (isRecord(payload["changes"])) sections.push(payload["changes"]);
270
+ if (isRecord(payload["previous"])) sections.push(payload["previous"]);
271
+ if (sections.length === 0) sections.push(payload);
272
+ return sections;
273
+ }
274
+
275
+ function isRecord(value: unknown): value is Record<string, unknown> {
276
+ return typeof value === "object" && value !== null && !Array.isArray(value);
277
+ }
@@ -0,0 +1,45 @@
1
+ import type { AnyDb } from "../query";
2
+ import { asRawClient } from "../query";
3
+ import { quoteTableIdent } from "./table-ops";
4
+
5
+ // Generic DDL helpers used on the prod-boot path (stack/table-helpers.ts's
6
+ // unsafePushTables, event-consumer-state.ts's multi-instance backfill) —
7
+ // split out of queries/test-stack.ts so a prod-boot import doesn't point at
8
+ // a module named for test-only concerns (truncate/create/drop-database).
9
+
10
+ export async function executeDdlStatement(db: AnyDb, sqlText: string): Promise<void> {
11
+ await asRawClient(db).unsafe(sqlText);
12
+ }
13
+
14
+ export async function alterTableAddColumn(
15
+ db: AnyDb,
16
+ tableName: string,
17
+ columnName: string,
18
+ columnType: string,
19
+ defaultClause: string,
20
+ notNull: string,
21
+ // table-helpers.ts's unmanaged-table sync relies on the plain form
22
+ // throwing when a column already exists with a different shape than
23
+ // EntityTableMeta expects — that's how it surfaces drift. Callers that
24
+ // just need an idempotent, race-safe backfill (e.g. event-consumer-state's
25
+ // multi-instance boot path, #1362) opt in explicitly.
26
+ ifNotExists = false,
27
+ ): Promise<void> {
28
+ await asRawClient(db).unsafe(
29
+ `ALTER TABLE ${quoteTableIdent(tableName)} ADD COLUMN ${ifNotExists ? "IF NOT EXISTS " : ""}${quoteTableIdent(columnName)} ${columnType}${defaultClause}${notNull}`,
30
+ );
31
+ }
32
+
33
+ export async function createIndexIfNotExists(
34
+ db: AnyDb,
35
+ indexKind: "UNIQUE INDEX" | "INDEX",
36
+ indexName: string,
37
+ tableName: string,
38
+ columnList: string,
39
+ whereSql?: string,
40
+ ): Promise<void> {
41
+ const where = whereSql !== undefined ? ` WHERE ${whereSql}` : "";
42
+ await asRawClient(db).unsafe(
43
+ `CREATE ${indexKind} IF NOT EXISTS ${quoteTableIdent(indexName)} ON ${quoteTableIdent(tableName)} (${columnList})${where}`,
44
+ );
45
+ }
@@ -47,6 +47,7 @@ export type ConsumerDeliveryOutcome = {
47
47
  readonly attempts: number;
48
48
  readonly lastError: string | null;
49
49
  readonly deadLettered: boolean;
50
+ readonly processed: number;
50
51
  };
51
52
 
52
53
  export async function updateConsumerDeliveryOutcome(
@@ -55,19 +56,26 @@ export async function updateConsumerDeliveryOutcome(
55
56
  instanceId: string,
56
57
  outcome: ConsumerDeliveryOutcome,
57
58
  ): Promise<void> {
59
+ // Advancing the cursor proves this pass wasn't poison — reset the re-arm
60
+ // budget so an unrelated failure down the line gets its own fresh 3
61
+ // chances instead of inheriting a partially-spent counter from a past,
62
+ // already-resolved outage.
63
+ const resetRearmCount = outcome.processed > 0;
58
64
  await asRawClient(db).unsafe(
59
65
  `UPDATE "kumiko_event_consumers" SET
60
66
  "last_processed_event_id" = $1,
61
67
  "attempts" = $2,
62
68
  "status" = $3,
63
69
  "last_error" = $4,
70
+ "rearm_count" = CASE WHEN $5 THEN 0 ELSE "rearm_count" END,
64
71
  "updated_at" = now()
65
- WHERE "name" = $5 AND "instance_id" = $6`,
72
+ WHERE "name" = $6 AND "instance_id" = $7`,
66
73
  [
67
74
  outcome.cursor,
68
75
  outcome.attempts,
69
76
  outcome.deadLettered ? "dead" : "idle",
70
77
  outcome.lastError,
78
+ resetRearmCount,
71
79
  name,
72
80
  instanceId,
73
81
  ],
@@ -81,7 +89,7 @@ export async function updateConsumerStatusReturning(
81
89
  status: "idle" | "disabled",
82
90
  ): Promise<Record<string, unknown> | undefined> {
83
91
  const rows = (await asRawClient(db).unsafe(
84
- `UPDATE "kumiko_event_consumers" SET "status" = $1, "attempts" = 0, "last_error" = NULL, "updated_at" = now()
92
+ `UPDATE "kumiko_event_consumers" SET "status" = $1, "attempts" = 0, "last_error" = NULL, "rearm_count" = 0, "updated_at" = now()
85
93
  WHERE "name" = $2 AND "instance_id" = $3
86
94
  RETURNING *`,
87
95
  [status, name, instanceId],
@@ -101,6 +109,7 @@ export async function advanceConsumerPastEventReturning(
101
109
  "status" = 'idle',
102
110
  "attempts" = 0,
103
111
  "last_error" = NULL,
112
+ "rearm_count" = 0,
104
113
  "updated_at" = now()
105
114
  WHERE "name" = $2 AND "instance_id" = $3
106
115
  RETURNING *`,
@@ -168,3 +177,27 @@ export async function markConsumerRebuildFailed(
168
177
  [errorMessage, name, instanceId],
169
178
  );
170
179
  }
180
+
181
+ // Auto-revive a dead consumer once its cooldown has elapsed (called from
182
+ // acquireConsumerState — not an ops action, so no "requireConsumerRow"
183
+ // precondition like restartConsumer/enableConsumer). Increments rearm_count
184
+ // so the caller can enforce a lifetime cap on automatic revivals; manual
185
+ // restartConsumer()/enableConsumer() reset it back to 0.
186
+ export async function rearmDeadConsumer(
187
+ db: AnyDb,
188
+ name: string,
189
+ instanceId: string,
190
+ ): Promise<Record<string, unknown> | undefined> {
191
+ const rows = (await asRawClient(db).unsafe(
192
+ `UPDATE "kumiko_event_consumers" SET
193
+ "status" = 'idle',
194
+ "attempts" = 0,
195
+ "last_error" = NULL,
196
+ "rearm_count" = "rearm_count" + 1,
197
+ "updated_at" = now()
198
+ WHERE "name" = $1 AND "instance_id" = $2
199
+ RETURNING *`,
200
+ [name, instanceId],
201
+ )) as ReadonlyArray<Record<string, unknown>>;
202
+ return rows[0];
203
+ }
@@ -1,3 +1,9 @@
1
+ import {
2
+ constraintOf,
3
+ isLockNotAvailable,
4
+ isTableAlreadyExists,
5
+ isUniqueViolation,
6
+ } from "../pg-error";
1
7
  import type { AnyDb } from "../query";
2
8
  import { asRawClient } from "../query";
3
9
 
@@ -6,6 +12,106 @@ export async function notifyPgChannel(db: AnyDb, channel: string): Promise<void>
6
12
  await asRawClient(db).unsafe(`SELECT pg_notify($1, '')`, [channel]);
7
13
  }
8
14
 
15
+ // Tenant-scoped partial unique index over metadata.idempotencyKey.
16
+ // Expression index straight on the jsonb column — no dedicated key column,
17
+ // so it needs no INSERT-path change and covers admin-api's raw appends too
18
+ // (same metadata jsonb). CREATE ... IF NOT EXISTS makes this safe to call
19
+ // on every boot, same "ensure" pattern as ensureSnapshotVersionColumn: heals
20
+ // installs that predate the index without a table rebuild.
21
+ //
22
+ // CONCURRENTLY (not a plain CREATE): a non-concurrent build takes a SHARE
23
+ // lock for the full table scan on kumiko_events — the hottest table in the
24
+ // framework — blocking every append() for however long that scan takes on
25
+ // an existing installation's event history. CONCURRENTLY avoids that at the
26
+ // cost of needing to tolerate two failure modes a plain build doesn't have.
27
+ // Neither CREATE nor DROP ... CONCURRENTLY may run inside a transaction —
28
+ // no caller of this function (dev-server, schema-cli.ts, stack/db.ts) may
29
+ // wrap it in one, or Postgres raises 25001.
30
+ // undefined = index doesn't exist at all (nothing to drop, CREATE below
31
+ // handles it); false = exists but INVALID (crashed mid-build, needs DROP +
32
+ // rebuild); true = exists and valid.
33
+ async function indexValidity(client: ReturnType<typeof asRawClient>): Promise<boolean | undefined> {
34
+ const rows = await client.unsafe(
35
+ `SELECT i.indisvalid FROM pg_class c JOIN pg_index i ON i.indexrelid = c.oid ` +
36
+ `WHERE c.relname = 'events_idempotency_uq' AND i.indrelid = '"kumiko_events"'::regclass`,
37
+ );
38
+ return (rows[0] as { indisvalid?: boolean } | undefined)?.indisvalid;
39
+ }
40
+
41
+ export async function ensureIdempotencyKeyIndex(db: AnyDb): Promise<void> {
42
+ const client = asRawClient(db);
43
+
44
+ try {
45
+ // 1) A prior CONCURRENTLY build that got killed mid-flight (crash, deploy
46
+ // restart) leaves an INVALID index: the catalog entry exists, so
47
+ // IF NOT EXISTS below would silently skip forever, but the index is
48
+ // incomplete and not plannable for queries — it does NOT mean the
49
+ // constraint enforces nothing; Postgres keeps maintaining an INVALID
50
+ // index on every insert, it just refuses to use it for planning.
51
+ // Detect + rebuild it. Scoped to kumiko_events specifically (indrelid),
52
+ // not just the relname, so a same-named index in another schema can't
53
+ // false-positive this DROP.
54
+ if ((await indexValidity(client)) === false) {
55
+ await client.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS "events_idempotency_uq"`);
56
+ }
57
+
58
+ await client.unsafe(
59
+ `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "events_idempotency_uq" ON "kumiko_events" ` +
60
+ `("tenant_id", (("metadata"->>'idempotencyKey'))) ` +
61
+ `WHERE "metadata"->>'idempotencyKey' IS NOT NULL`,
62
+ );
63
+ } catch (e) {
64
+ if (isBenignConcurrentIndexBuildRace(e)) {
65
+ // "Benign" only means the losing side of a race, not that the index
66
+ // actually landed valid — a lock_timeout on the CREATE (55P03) backs
67
+ // off the same way a genuine duplicate-build race does, but leaves no
68
+ // valid index at all. Re-check before declaring victory instead of
69
+ // trusting the error class alone.
70
+ // skip: sibling pod already built a valid index — this race loser is done.
71
+ if ((await indexValidity(client)) === true) return;
72
+ console.warn(
73
+ `ensureIdempotencyKeyIndex: backed off on a "benign" race (${String(e)}) but ` +
74
+ `"events_idempotency_uq" is still missing/invalid afterward — likely a ` +
75
+ `lock_timeout during CREATE INDEX CONCURRENTLY, not an actual winner/loser race.`,
76
+ );
77
+ throw e;
78
+ }
79
+ throw duplicateIdempotencyKeyErrorOr(e);
80
+ }
81
+ }
82
+
83
+ // Two pods booting concurrently against the same DB (rolling deploy): both
84
+ // see the index missing/invalid and both start a DROP/CREATE CONCURRENTLY
85
+ // build. The loser typically does NOT get the plain duplicate-relation
86
+ // no-op IF NOT EXISTS normally gives — it can instead see a unique-violation
87
+ // on Postgres' own pg_class catalog insert (23505, constraint
88
+ // pg_class_relname_nsp_index) or a lock-not-available (55P03) from the
89
+ // racing DDL. Both are benign: the other pod's build wins and this one just
90
+ // backs off. A 23505 against "events_idempotency_uq" itself is NOT this
91
+ // race — see duplicateIdempotencyKeyErrorOr.
92
+ function isBenignConcurrentIndexBuildRace(e: unknown): boolean {
93
+ if (isTableAlreadyExists(e) || isLockNotAvailable(e)) return true;
94
+ return isUniqueViolation(e) && constraintOf(e) === "pg_class_relname_nsp_index";
95
+ }
96
+
97
+ // A 23505 against "events_idempotency_uq" means real duplicate
98
+ // metadata->>'idempotencyKey' values for the same tenant already exist —
99
+ // CONCURRENTLY still enforces uniqueness on live inserts against the
100
+ // not-yet-valid index. That needs an operator to find + resolve the
101
+ // duplicates, not a crash-loop on every subsequent boot, so re-throw a
102
+ // distinguishable error instead of the raw driver error.
103
+ function duplicateIdempotencyKeyErrorOr(e: unknown): unknown {
104
+ if (isUniqueViolation(e) && constraintOf(e) === "events_idempotency_uq") {
105
+ return new Error(
106
+ "ensureIdempotencyKeyIndex: duplicate metadata->>'idempotencyKey' values exist for at least " +
107
+ "one tenant in kumiko_events — CREATE UNIQUE INDEX CONCURRENTLY cannot complete. Find and " +
108
+ "resolve the duplicate idempotencyKey rows, then restart to retry.",
109
+ { cause: e },
110
+ );
111
+ }
112
+ return e;
113
+ }
114
+
9
115
  export type SubsequentEventInsertParams = {
10
116
  readonly aggregateId: string;
11
117
  readonly aggregateType: string;
@@ -85,22 +191,6 @@ export async function selectAggregateMaxVersion(db: AnyDb, aggregateId: string):
85
191
  return rows[0]?.v ?? 0;
86
192
  }
87
193
 
88
- /** tenant_id the aggregate's events were written under — no membership/tenant
89
- * filter. A r.systemScope() aggregate (e.g. user) lives in whichever tenant
90
- * its creating executor used, which need not be a tenant the subject holds a
91
- * membership in. Returns null for unknown streams. */
92
- export async function selectAggregateStreamTenant(
93
- db: AnyDb,
94
- aggregateId: string,
95
- aggregateType: string,
96
- ): Promise<string | null> {
97
- const rows = (await asRawClient(db).unsafe(
98
- `SELECT "tenant_id" AS t FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = $2 ORDER BY "version" LIMIT 1`,
99
- [aggregateId, aggregateType],
100
- )) as ReadonlyArray<{ t: string | null }>;
101
- return rows[0]?.t ?? null;
102
- }
103
-
104
194
  export async function selectEventsHighWaterMark(db: AnyDb): Promise<bigint> {
105
195
  const rows = (await asRawClient(db).unsafe(
106
196
  `SELECT COALESCE(MAX("id"), 0)::bigint AS max FROM "kumiko_events"`,
@@ -135,18 +225,35 @@ export type SaveSnapshotParams = {
135
225
  // Plain object — see SubsequentEventInsertParams on why pre-stringified
136
226
  // JSON double-encodes under Bun.SQL's ::jsonb binding.
137
227
  readonly state: Record<string, unknown>;
228
+ readonly snapshotVersion: number;
138
229
  };
139
230
 
231
+ // kumiko_snapshots predates snapshot_version — idempotent heal for existing
232
+ // installs, run from the same ensure path as table creation.
233
+ export async function ensureSnapshotVersionColumn(db: AnyDb): Promise<void> {
234
+ await asRawClient(db).unsafe(
235
+ `ALTER TABLE "kumiko_snapshots" ADD COLUMN IF NOT EXISTS "snapshot_version" integer NOT NULL DEFAULT 1`,
236
+ );
237
+ }
238
+
140
239
  export async function upsertSnapshot(db: AnyDb, params: SaveSnapshotParams): Promise<void> {
141
240
  await asRawClient(db).unsafe(
142
241
  `INSERT INTO "kumiko_snapshots"
143
- ("aggregate_id", "tenant_id", "aggregate_type", "version", "state")
144
- VALUES ($1, $2, $3, $4, $5::jsonb)
242
+ ("aggregate_id", "tenant_id", "aggregate_type", "version", "state", "snapshot_version")
243
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6)
145
244
  ON CONFLICT ("aggregate_id", "version") DO UPDATE SET
146
245
  "state" = $5::jsonb,
147
246
  "aggregate_type" = $3,
247
+ "snapshot_version" = $6,
148
248
  "created_at" = now()`,
149
- [params.aggregateId, params.tenantId, params.aggregateType, params.version, params.state],
249
+ [
250
+ params.aggregateId,
251
+ params.tenantId,
252
+ params.aggregateType,
253
+ params.version,
254
+ params.state,
255
+ params.snapshotVersion,
256
+ ],
150
257
  );
151
258
  }
152
259
 
@@ -23,12 +23,19 @@ export async function selectEventsForProjectionRebuildBatch(
23
23
  afterId: bigint,
24
24
  limit: number,
25
25
  ): Promise<ReadonlyArray<Record<string, unknown>>> {
26
+ // Archived streams don't replay (Marten-aligned): their aggregates are
27
+ // frozen ops-tombstones — replaying them would resurrect rows (or, for
28
+ // stranded duplicate-aggregates like fw#832, collide on unique indexes).
26
29
  return (await asRawClient(db).unsafe(
27
- `SELECT * FROM "kumiko_events"
28
- WHERE "aggregate_type" = ANY($1::text[])
29
- AND "type" = ANY($2::text[])
30
- AND "id" > $3
31
- ORDER BY "id" ASC
30
+ `SELECT * FROM "kumiko_events" e
31
+ WHERE e."aggregate_type" = ANY($1::text[])
32
+ AND e."type" = ANY($2::text[])
33
+ AND e."id" > $3
34
+ AND NOT EXISTS (
35
+ SELECT 1 FROM "kumiko_archived_streams" a
36
+ WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id"
37
+ )
38
+ ORDER BY e."id" ASC
32
39
  LIMIT $4`,
33
40
  [aggregateTypes, eventTypes, afterId, limit],
34
41
  )) as ReadonlyArray<Record<string, unknown>>;
@@ -43,10 +50,17 @@ export async function countSubscribedEvents(
43
50
  aggregateTypes: readonly string[],
44
51
  eventTypes: readonly string[],
45
52
  ): Promise<bigint> {
53
+ // Same archived-streams exclusion as the batch query — the #443 recompute
54
+ // compares this count against applied events; a filter mismatch would make
55
+ // every rebuild with an archived stream loop the full-re-replay forever.
46
56
  const rows = (await asRawClient(db).unsafe(
47
- `SELECT count(*)::bigint AS n FROM "kumiko_events"
48
- WHERE "aggregate_type" = ANY($1::text[])
49
- AND "type" = ANY($2::text[])`,
57
+ `SELECT count(*)::bigint AS n FROM "kumiko_events" e
58
+ WHERE e."aggregate_type" = ANY($1::text[])
59
+ AND e."type" = ANY($2::text[])
60
+ AND NOT EXISTS (
61
+ SELECT 1 FROM "kumiko_archived_streams" a
62
+ WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id"
63
+ )`,
50
64
  [aggregateTypes, eventTypes],
51
65
  )) as ReadonlyArray<{ n: bigint | string | number | null }>;
52
66
  const raw = rows[0]?.n;