@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,248 @@
1
+ // PII field encryption with per-subject DEKs (crypto-shredding, #724 phase C).
2
+ // Same executor hook points as `encrypted: true`, but the key belongs to the
3
+ // erase subject — kms.eraseKey(subject) makes every value unreadable at once.
4
+ // Storage format is a sniffable string that fits existing text columns and
5
+ // names its subject inline, so decrypt needs no schema change and no resolver:
6
+ // kumiko-pii:v2:<subjectKey>:<base64(iv|tag|ciphertext)>
7
+ // v2 GCM-binds the ciphertext to `subjectKey|field` as AAD (#1263) — subject
8
+ // selects the DEK, field stops a cut-and-paste between two fields of the
9
+ // SAME subject (same DEK, so key selection alone can't catch that) from
10
+ // decrypting silently. v1 (no AAD) stays decrypt-only for pre-#1263 rows;
11
+ // every new write emits v2.
12
+
13
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
14
+ import type { EntityDefinition } from "../engine/types/fields";
15
+ import type { TenantId } from "../engine/types/identifiers";
16
+ import {
17
+ isLocalKeyKmsAdapter,
18
+ KeyAlreadyExistsError,
19
+ KeyErasedError,
20
+ KeyNotFoundError,
21
+ type KmsAdapter,
22
+ type KmsContext,
23
+ type LocalKeyKmsAdapter,
24
+ type SubjectDek,
25
+ type SubjectId,
26
+ subjectIdFromKey,
27
+ subjectIdToKey,
28
+ } from "./kms-adapter";
29
+ import { resolveSubjectForField } from "./subject-resolver";
30
+
31
+ // Spec value (crypto-shredding.md) — renderers show it verbatim.
32
+ export const PII_ERASED_SENTINEL = "[[erased]]";
33
+
34
+ const PII_CIPHERTEXT_PREFIX_V1 = "kumiko-pii:v1:";
35
+ export const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
36
+ const IV_LENGTH = 12;
37
+ const AUTH_TAG_LENGTH = 16;
38
+
39
+ export function isPiiCiphertext(value: unknown): value is string {
40
+ return (
41
+ typeof value === "string" &&
42
+ (value.startsWith(PII_CIPHERTEXT_PREFIX) || value.startsWith(PII_CIPHERTEXT_PREFIX_V1))
43
+ );
44
+ }
45
+
46
+ function buildAad(subject: SubjectId, field: string): Buffer {
47
+ return Buffer.from(`${subjectIdToKey(subject)}|${field}`, "utf8");
48
+ }
49
+
50
+ function encryptValue(
51
+ subject: SubjectId,
52
+ dek: SubjectDek,
53
+ plaintext: string,
54
+ field: string,
55
+ ): string {
56
+ const iv = randomBytes(IV_LENGTH);
57
+ const cipher = createCipheriv("aes-256-gcm", dek, iv);
58
+ cipher.setAAD(buildAad(subject, field));
59
+ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
60
+ const blob = Buffer.concat([iv, cipher.getAuthTag(), ciphertext]);
61
+ return `${PII_CIPHERTEXT_PREFIX}${subjectIdToKey(subject)}:${blob.toString("base64")}`;
62
+ }
63
+
64
+ function parseCiphertext(value: string): { subject: SubjectId; blob: Buffer; hasAad: boolean } {
65
+ const hasAad = value.startsWith(PII_CIPHERTEXT_PREFIX);
66
+ const prefix = hasAad ? PII_CIPHERTEXT_PREFIX : PII_CIPHERTEXT_PREFIX_V1;
67
+ const rest = value.slice(prefix.length);
68
+ // subjectKey itself contains ":" ("user:<id>") — base64 never does, so the
69
+ // last ":" is always the key/blob separator.
70
+ const sep = rest.lastIndexOf(":");
71
+ if (sep === -1) throw new Error(`Malformed PII ciphertext (no subject/blob separator)`);
72
+ return {
73
+ subject: subjectIdFromKey(rest.slice(0, sep)),
74
+ blob: Buffer.from(rest.slice(sep + 1), "base64"),
75
+ hasAad,
76
+ };
77
+ }
78
+
79
+ function decryptValue(
80
+ dek: SubjectDek,
81
+ blob: Buffer,
82
+ subject: SubjectId,
83
+ field: string,
84
+ hasAad: boolean,
85
+ ): string {
86
+ const iv = blob.subarray(0, IV_LENGTH);
87
+ const tag = blob.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
88
+ const ciphertext = blob.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
89
+ const decipher = createDecipheriv("aes-256-gcm", dek, iv);
90
+ if (hasAad) decipher.setAAD(buildAad(subject, field));
91
+ decipher.setAuthTag(tag);
92
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
93
+ }
94
+
95
+ // First write to a PII field of a new subject creates the key implicitly
96
+ // (user-signup / tenant-create need no extra hook). The AlreadyExists catch
97
+ // covers the concurrent-first-write race; a tombstoned subject re-throws
98
+ // KeyErasedError from the final getKey — writes to a forgotten subject fail.
99
+ async function getOrCreateDek(
100
+ kms: LocalKeyKmsAdapter,
101
+ subject: SubjectId,
102
+ ctx: KmsContext,
103
+ ): Promise<SubjectDek> {
104
+ try {
105
+ return await kms.getKey(subject, ctx);
106
+ } catch (e) {
107
+ if (!(e instanceof KeyNotFoundError)) throw e;
108
+ }
109
+ try {
110
+ await kms.createKey(subject, ctx);
111
+ } catch (e) {
112
+ if (!(e instanceof KeyAlreadyExistsError)) throw e;
113
+ }
114
+ return kms.getKey(subject, ctx);
115
+ }
116
+
117
+ // Single-value encrypt for callers that resolve the subject themselves
118
+ // (event-pii catalog, backfill). Ciphertext/sentinel inputs pass through —
119
+ // idempotent like the field-map variant. `field` names the value's storage
120
+ // slot (payload field / config key) — part of the AAD, so it must match the
121
+ // `field` passed to decryptPiiValueForSubject for the same value.
122
+ export async function encryptPiiValueForSubject(
123
+ kms: LocalKeyKmsAdapter,
124
+ subject: SubjectId,
125
+ value: string,
126
+ kmsCtx: KmsContext,
127
+ field: string,
128
+ ): Promise<string> {
129
+ if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) return value;
130
+ const dek = await getOrCreateDek(kms, subject, kmsCtx);
131
+ return encryptValue(subject, dek, value, field);
132
+ }
133
+
134
+ // Single-value decrypt for callers that don't have an entity/field-map to
135
+ // pass through decryptPiiFieldValues (config values). The subject lives
136
+ // inside the ciphertext itself, so no subject/scope resolution is needed
137
+ // on this side — only the encrypt direction has to pick one. `field` must
138
+ // match the one used at encrypt time (AAD) or decrypt fails loud.
139
+ export async function decryptPiiValueForSubject(
140
+ kms: LocalKeyKmsAdapter,
141
+ value: string,
142
+ kmsCtx: KmsContext,
143
+ field: string,
144
+ ): Promise<string> {
145
+ if (!isPiiCiphertext(value)) return value;
146
+ const { subject, blob, hasAad } = parseCiphertext(value);
147
+ try {
148
+ return decryptValue(await kms.getKey(subject, kmsCtx), blob, subject, field, hasAad);
149
+ } catch (e) {
150
+ if (!(e instanceof KeyErasedError)) throw e;
151
+ return PII_ERASED_SENTINEL;
152
+ }
153
+ }
154
+
155
+ export interface EncryptPiiOptions {
156
+ readonly onlyKeys?: Iterable<string>;
157
+ // Write-time tenant for tenantOwned fields on rows without a tenantId column.
158
+ readonly tenantId?: TenantId;
159
+ // Row to resolve subjects from when `row` is a partial (update changes may
160
+ // carry a pii field without its ownerField — the merged row still has it).
161
+ readonly subjectSource?: Record<string, unknown>;
162
+ }
163
+
164
+ export async function encryptPiiFieldValues(
165
+ row: Record<string, unknown>,
166
+ entity: EntityDefinition,
167
+ piiFields: readonly string[],
168
+ kms: LocalKeyKmsAdapter,
169
+ kmsCtx: KmsContext,
170
+ opts: EncryptPiiOptions = {},
171
+ ): Promise<Record<string, unknown>> {
172
+ if (piiFields.length === 0) return row;
173
+ const only = opts.onlyKeys ? new Set(opts.onlyKeys) : null;
174
+ const subjectSource = opts.subjectSource ?? row;
175
+ const out = { ...row };
176
+ for (const name of piiFields) {
177
+ if (only && !only.has(name)) continue;
178
+ if (!(name in out)) continue;
179
+ const value = out[name];
180
+ if (value === null || value === undefined) continue;
181
+ // Re-encrypt paths (update previous, detail cache) may see values that
182
+ // are already ciphertext or the erased sentinel — both stay as-is.
183
+ if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) continue;
184
+ if (typeof value !== "string") {
185
+ throw new Error(`PII field "${name}" must be a string, got ${typeof value}`);
186
+ }
187
+ const subject = resolveSubjectForField(entity, name, subjectSource, {
188
+ ...(opts.tenantId !== undefined && { tenantId: opts.tenantId }),
189
+ });
190
+ // skip: collectPiiSubjectFields only yields annotated fields — null is unreachable, kept as a type guard
191
+ if (subject === null) continue;
192
+ const dek = await getOrCreateDek(kms, subject, kmsCtx);
193
+ out[name] = encryptValue(subject, dek, value, name);
194
+ }
195
+ return out;
196
+ }
197
+
198
+ export async function decryptPiiFieldValues(
199
+ row: Record<string, unknown>,
200
+ piiFields: readonly string[],
201
+ kms: LocalKeyKmsAdapter,
202
+ kmsCtx: KmsContext,
203
+ ): Promise<Record<string, unknown>> {
204
+ if (piiFields.length === 0) return row;
205
+ const out = { ...row };
206
+ for (const name of piiFields) {
207
+ const value = out[name];
208
+ // Pre-engine plaintext rows pass through unchanged (mixed-state reads
209
+ // work during rollout; backfill is tracked in kumiko-framework#799).
210
+ if (!isPiiCiphertext(value)) continue;
211
+ const { subject, blob, hasAad } = parseCiphertext(value);
212
+ try {
213
+ out[name] = decryptValue(await kms.getKey(subject, kmsCtx), blob, subject, name, hasAad);
214
+ } catch (e) {
215
+ // KeyNotFound deliberately propagates: ciphertext without a key row
216
+ // means the key store is wrong (not shredded) — fail loud.
217
+ if (!(e instanceof KeyErasedError)) throw e;
218
+ out[name] = PII_ERASED_SENTINEL;
219
+ }
220
+ }
221
+ return out;
222
+ }
223
+
224
+ // Boot-injected app-wide subject KMS, mirroring configureEntityFieldEncryption:
225
+ // run{Prod,Dev}App call configurePiiSubjectKms once; executors resolve lazily.
226
+ // Absent adapter = engine off (pii fields stay plaintext, pre-phase-C
227
+ // behavior) — the hard boot gate arrives with the prod-grade PgKmsAdapter
228
+ // (phase E); until then no production deployment can satisfy it.
229
+ let injectedKms: LocalKeyKmsAdapter | undefined;
230
+
231
+ export function configurePiiSubjectKms(adapter: KmsAdapter | undefined): void {
232
+ if (adapter !== undefined && !isLocalKeyKmsAdapter(adapter)) {
233
+ throw new Error(
234
+ "PII field encryption requires a local-key KMS adapter — remote-crypto " +
235
+ "(Vault transit) support lands with the BYOK adapter.",
236
+ );
237
+ }
238
+ injectedKms = adapter;
239
+ }
240
+
241
+ export function configuredPiiSubjectKms(): LocalKeyKmsAdapter | undefined {
242
+ return injectedKms;
243
+ }
244
+
245
+ /** @internal test-only */
246
+ export function resetPiiSubjectKmsForTests(): void {
247
+ injectedKms = undefined;
248
+ }
@@ -0,0 +1,38 @@
1
+ import type {
2
+ KmsContext,
3
+ LocalKeyKmsAdapter,
4
+ SubjectDek,
5
+ SubjectId,
6
+ SubjectKey,
7
+ } from "./kms-adapter";
8
+ import { subjectIdToKey } from "./kms-adapter";
9
+
10
+ // Per-request DEK cache: a list rendering 50 comments of one author does one
11
+ // adapter round-trip, not 50. Only meaningful for local-key adapters —
12
+ // remote-crypto backends never release keys, every call is a round-trip.
13
+ export interface RequestKmsCache {
14
+ getKey(subject: SubjectId, ctx: KmsContext): Promise<SubjectDek>;
15
+ /** Drops one subject's cached DEK — called when its key is shredded mid-request. */
16
+ invalidate(subject: SubjectId): void;
17
+ clear(): void;
18
+ }
19
+
20
+ export function createRequestKmsCache(adapter: LocalKeyKmsAdapter): RequestKmsCache {
21
+ const cache = new Map<SubjectKey, SubjectDek>();
22
+ return {
23
+ async getKey(subject, ctx) {
24
+ const subjectKey = subjectIdToKey(subject);
25
+ const hit = cache.get(subjectKey);
26
+ if (hit !== undefined) return hit;
27
+ const dek = await adapter.getKey(subject, ctx);
28
+ cache.set(subjectKey, dek);
29
+ return dek;
30
+ },
31
+ invalidate(subject) {
32
+ cache.delete(subjectIdToKey(subject));
33
+ },
34
+ clear() {
35
+ cache.clear();
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,91 @@
1
+ import type { EntityDefinition } from "../engine/types/fields";
2
+ import type { TenantId } from "../engine/types/identifiers";
3
+ import type { SubjectId } from "./kms-adapter";
4
+
5
+ // Thrown when a field IS pii-annotated but the row can't name its subject —
6
+ // that must surface as an error, not fall back to plaintext.
7
+ export class SubjectResolutionError extends Error {
8
+ constructor(
9
+ public readonly fieldName: string,
10
+ reason: string,
11
+ ) {
12
+ super(`Cannot resolve PII subject for field "${fieldName}": ${reason}`);
13
+ this.name = "SubjectResolutionError";
14
+ }
15
+ }
16
+
17
+ export interface ResolveSubjectOptions {
18
+ // Write-time tenant scope — consulted for tenantOwned fields when the row
19
+ // itself carries no tenantId column.
20
+ readonly tenantId?: TenantId;
21
+ }
22
+
23
+ function nonEmptyString(value: unknown): string | null {
24
+ return typeof value === "string" && value.length > 0 ? value : null;
25
+ }
26
+
27
+ /**
28
+ * Maps a pii-annotated field to the subject whose key encrypts it.
29
+ * Returns null for fields without any PII annotation (stored plaintext).
30
+ *
31
+ * Precedence for multi-annotated fields mirrors the erase triggers:
32
+ * userOwned (user-forget) > tenantOwned (tenant-destroy) > pii (self).
33
+ */
34
+ export function resolveSubjectForField(
35
+ entity: EntityDefinition,
36
+ fieldName: string,
37
+ row: Record<string, unknown>,
38
+ opts: ResolveSubjectOptions = {},
39
+ ): SubjectId | null {
40
+ const field = entity.fields[fieldName];
41
+ if (!field) throw new SubjectResolutionError(fieldName, "field is not defined on the entity");
42
+
43
+ if ("userOwned" in field && field.userOwned !== undefined) {
44
+ const ownerField = field.userOwned.ownerField;
45
+ const userId = nonEmptyString(row[ownerField]);
46
+ if (userId === null) {
47
+ throw new SubjectResolutionError(
48
+ fieldName,
49
+ `owner field "${ownerField}" is empty on the row`,
50
+ );
51
+ }
52
+ return { kind: "user", userId };
53
+ }
54
+
55
+ if ("tenantOwned" in field && field.tenantOwned === true) {
56
+ const tenantId = nonEmptyString(row["tenantId"]) ?? opts.tenantId;
57
+ if (tenantId === undefined) {
58
+ throw new SubjectResolutionError(
59
+ fieldName,
60
+ "row has no tenantId column and no write-time tenantId was provided",
61
+ );
62
+ }
63
+ return { kind: "tenant", tenantId };
64
+ }
65
+
66
+ if ("pii" in field && field.pii === true) {
67
+ // pii: true = the entity itself is the subject (user.email belongs to
68
+ // that user row). Serial ids are stringified — subject keys are text.
69
+ const id = row["id"];
70
+ const userId = nonEmptyString(id) ?? (typeof id === "number" ? String(id) : null);
71
+ if (userId === null) {
72
+ throw new SubjectResolutionError(fieldName, "row has no id to use as the pii self-subject");
73
+ }
74
+ return { kind: "user", userId };
75
+ }
76
+
77
+ return null;
78
+ }
79
+
80
+ // The field names an encrypt engine must process for an entity — precomputed
81
+ // once at executor build time, like the sensitiveFields set.
82
+ export function collectPiiSubjectFields(entity: EntityDefinition): readonly string[] {
83
+ return Object.entries(entity.fields)
84
+ .filter(
85
+ ([, field]) =>
86
+ ("userOwned" in field && field.userOwned !== undefined) ||
87
+ ("tenantOwned" in field && field.tenantOwned === true) ||
88
+ ("pii" in field && field.pii === true),
89
+ )
90
+ .map(([name]) => name);
91
+ }
@@ -0,0 +1,191 @@
1
+ // Runtime guard #722: before the rebuild swaps the shadow over the live table,
2
+ // abort if a live row has NO event in the projection's source streams — a row
3
+ // no replay can reconstruct (#498 ghost, direct-inserted without a .created
4
+ // event), which the swap would silently drop.
5
+ //
6
+ // The guard is deliberately narrow (event EXISTENCE only, not a column diff):
7
+ // the framework legitimately makes live diverge from a fresh replay in shipped
8
+ // ways (blind-index erase→NULL, sensitive-strip, archived-stream wipe, the #494
9
+ // backfill flow). Those rows all HAVE an event, so they are not ghosts and the
10
+ // guard leaves them alone — proven by the "direct column-write" test below.
11
+
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
14
+ import { asRawClient, insertOne, selectMany } from "../../db/query";
15
+ import { createBooleanField, createEntity, createTextField, defineFeature } from "../../engine";
16
+ import { createRegistry } from "../../engine/registry";
17
+ import { archiveStream, createArchivedStreamsTable, createEventsTable } from "../../event-store";
18
+ import { rebuildProjection } from "../../pipeline";
19
+ import { createProjectionStateTable } from "../../pipeline/projection-state";
20
+ import { TestUsers, unsafeCreateEntityTable } from "../../stack";
21
+ import { ensureTemporalPolyfill } from "../../time/polyfill";
22
+ import { createEventStoreExecutor } from "../event-store-executor";
23
+ import { buildEntityTable } from "../table-builder";
24
+ import { createTenantDb, type TenantDb } from "../tenant-db";
25
+
26
+ const userEntity = createEntity({
27
+ table: "read_unreachable_users",
28
+ fields: {
29
+ email: createTextField({ required: true }),
30
+ firstName: createTextField(),
31
+ isEnabled: createBooleanField({ default: true }),
32
+ },
33
+ softDelete: true,
34
+ });
35
+
36
+ const userFeature = defineFeature("unreachabletest", (r) => {
37
+ r.entity("user", userEntity);
38
+ });
39
+
40
+ const userTable = buildEntityTable("user", userEntity);
41
+ const projectionName = "unreachabletest:projection:user-entity";
42
+ const registry = createRegistry([userFeature]);
43
+
44
+ let testDb: BunTestDb;
45
+ let tdb: TenantDb;
46
+ const adminUser = TestUsers.admin;
47
+
48
+ beforeAll(async () => {
49
+ await ensureTemporalPolyfill();
50
+ testDb = await createTestDb();
51
+ await unsafeCreateEntityTable(testDb.db, userEntity, "user");
52
+ await createEventsTable(testDb.db);
53
+ await createArchivedStreamsTable(testDb.db);
54
+ await createProjectionStateTable(testDb.db);
55
+ tdb = createTenantDb(testDb.db, adminUser.tenantId);
56
+ });
57
+
58
+ afterAll(async () => {
59
+ await testDb.cleanup();
60
+ });
61
+
62
+ beforeEach(async () => {
63
+ await asRawClient(testDb.db).unsafe(
64
+ `TRUNCATE kumiko_events, kumiko_archived_streams, read_unreachable_users, kumiko_projections RESTART IDENTITY CASCADE`,
65
+ );
66
+ });
67
+
68
+ async function snapshotTable(): Promise<readonly Record<string, unknown>[]> {
69
+ const rows = await selectMany(
70
+ testDb.db,
71
+ userTable,
72
+ {},
73
+ { orderBy: { col: "id", direction: "asc" } },
74
+ );
75
+ return rows as readonly Record<string, unknown>[];
76
+ }
77
+
78
+ describe("assert-no-unreachable-live-rows / #722 ghost-row guard", () => {
79
+ test("clean executor-only projection rebuilds without firing the guard", async () => {
80
+ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
81
+ await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
82
+ await crud.create({ email: "b@test.de", firstName: "Bob" }, adminUser, tdb);
83
+ const before = await snapshotTable();
84
+
85
+ const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
86
+
87
+ expect(result.eventsProcessed).toBe(2);
88
+ expect(result.columnDriftCount).toBe(0);
89
+ expect(await snapshotTable()).toEqual(before);
90
+ });
91
+
92
+ test("ghost row (no backing event) → swap aborts, live untouched", async () => {
93
+ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
94
+ const a = await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
95
+ if (!a.isSuccess) throw new Error("setup failed");
96
+
97
+ // Drift: clone the event-backed row into a second id WITHOUT appending an
98
+ // event — the #498 ghost. No replay can ever reconstruct it.
99
+ const [liveRow] = await selectMany(testDb.db, userTable, { id: a.data.id as string });
100
+ if (!liveRow) throw new Error("live row missing");
101
+ const ghostId = crypto.randomUUID();
102
+ // Deliberately bypass the EXECUTOR_ONLY brand: the whole point is to write
103
+ // the entity table WITHOUT going through the executor — the production bug
104
+ // the guard defends against.
105
+ const writable = userTable as unknown as Parameters<typeof insertOne>[1];
106
+ await insertOne(tdb, writable, { ...liveRow, id: ghostId, email: "ghost@test.de" });
107
+ expect(await snapshotTable()).toHaveLength(2);
108
+
109
+ await expect(rebuildProjection(projectionName, { db: testDb.db, registry })).rejects.toThrow(
110
+ // #915: exact count below the truncation limit, no misleading "+" suffix.
111
+ /^projection-rebuild ".*": 1 live rows in ".*" have no\s+event in the projection's source streams/,
112
+ );
113
+
114
+ // Swap never ran: both rows — including the ghost — survive.
115
+ const after = await snapshotTable();
116
+ expect(after).toHaveLength(2);
117
+ expect(after.map((r) => r["id"])).toContain(ghostId);
118
+ });
119
+
120
+ test("direct column-write on an event-backed row does NOT trip the guard", async () => {
121
+ // The row has a .created event, so it is not a ghost. Its column state was
122
+ // direct-written without an event (the #494 / blind-index-erase class) — the
123
+ // guard is event-existence-only and leaves it alone; the replay overwrites
124
+ // the column from the event, which is the intended, shipped behavior.
125
+ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
126
+ const a = await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
127
+ if (!a.isSuccess) throw new Error("setup failed");
128
+
129
+ await asRawClient(testDb.db).unsafe(
130
+ `UPDATE read_unreachable_users SET first_name = 'DirectWrite' WHERE id = $1`,
131
+ [a.data.id],
132
+ );
133
+
134
+ // No throw — the row is event-backed. Rebuild replays the create event.
135
+ // #916: the direct write IS reported as column drift (non-blocking) —
136
+ // this is the exact #494 healing case the guard leaves to replay.
137
+ const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
138
+ expect(result.columnDriftCount).toBe(1);
139
+
140
+ const [rebuilt] = await selectMany(testDb.db, userTable, { id: a.data.id as string });
141
+ expect(rebuilt?.["email"]).toBe("a@test.de");
142
+ // The direct write is overwritten by the replay — deliberately allowed.
143
+ expect(rebuilt?.["firstName"]).toBe("Alice");
144
+ });
145
+
146
+ test("columnDriftCount reports the TRUE total, not capped at the 20-row sample limit", async () => {
147
+ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
148
+ const created = await Promise.all(
149
+ Array.from({ length: 21 }, (_, i) =>
150
+ crud.create({ email: `u${i}@test.de`, firstName: `Name${i}` }, adminUser, tdb),
151
+ ),
152
+ );
153
+ for (const c of created) {
154
+ if (!c.isSuccess) throw new Error("setup failed");
155
+ await asRawClient(testDb.db).unsafe(
156
+ `UPDATE read_unreachable_users SET first_name = 'DirectWrite' WHERE id = $1`,
157
+ [c.data.id],
158
+ );
159
+ }
160
+
161
+ const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
162
+
163
+ // #916: the sample query is LIMITed to 20 rows, but the count must reflect
164
+ // all 21 — a min(actual, 20) would silently understate severity to ops.
165
+ expect(result.columnDriftCount).toBe(21);
166
+ });
167
+
168
+ test("archived stream (fw#832 tombstone) does NOT trip the guard", async () => {
169
+ // archiveStream never deletes the .created event — projection rebuild
170
+ // deliberately excludes archived streams from replay (fw#832, see
171
+ // projection-rebuild.ts), so the row is wiped from live on swap. The
172
+ // guard only checks event EXISTENCE, not replay-inclusion, so it must
173
+ // not fire — this is the shipped tombstone behavior, not drift.
174
+ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
175
+ const a = await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
176
+ if (!a.isSuccess) throw new Error("setup failed");
177
+
178
+ await archiveStream(testDb.db, {
179
+ tenantId: adminUser.tenantId,
180
+ aggregateId: a.data.id as string,
181
+ aggregateType: "user",
182
+ archivedBy: adminUser.id,
183
+ });
184
+
185
+ // No throw — the row is event-backed despite being archived.
186
+ const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
187
+
188
+ expect(result.eventsProcessed).toBe(0);
189
+ expect(await snapshotTable()).toHaveLength(0);
190
+ });
191
+ });