@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,644 @@
1
+ import { checkWriteFieldOwnership } from "../engine/field-access";
2
+ import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership";
3
+ import type { EntityId } from "../engine/types";
4
+ import {
5
+ VersionConflictError as FrameworkVersionConflict,
6
+ IdempotentReplayError,
7
+ InternalError,
8
+ NotFoundError,
9
+ UnprocessableError,
10
+ writeFailure,
11
+ } from "../errors";
12
+ import {
13
+ append,
14
+ IdempotentAppendConflictError as EventStoreIdempotentAppendConflict,
15
+ VersionConflictError as EventStoreVersionConflict,
16
+ getStreamVersion,
17
+ } from "../event-store";
18
+ import { generateId } from "../utils";
19
+ import { applyEntityEvent } from "./apply-entity-event";
20
+ import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
21
+ import type { DbRow } from "./connection";
22
+ import type { EventStoreExecutor } from "./event-store-executor";
23
+ import {
24
+ buildEventMetadata,
25
+ type ExecutorContext,
26
+ entityEventName,
27
+ tryMapUniqueViolation,
28
+ } from "./event-store-executor-context";
29
+ import { selectMany } from "./query";
30
+
31
+ // The five write verbs (create/update/delete/forget/restore) of the event-
32
+ // store-executor. Split out of event-store-executor.ts (#1005, Welle 2) —
33
+ // behavior-preserving relocation, not a redesign: every closure below is
34
+ // unchanged from the original, just relocated behind an explicit
35
+ // ExecutorContext instead of capturing the factory's local scope directly.
36
+
37
+ // updateOptions.skipUnchanged (#464) — opt-in: a resubmitted-but-identical
38
+ // key is dropped before encryption so pii/encrypted fields don't get a fresh
39
+ // AEAD ciphertext (new nonce) for no real change, and the event's `changes`
40
+ // don't carry a phantom diff. Opt-in, not the executor's default, because
41
+ // direct executor.update() callers rely on the current behavior to FORCE a
42
+ // re-encrypt of an unchanged plaintext — KEK-rotation (auth-mfa reencrypt.job)
43
+ // and the user-data-rights #494 backfill both resubmit the current value on
44
+ // purpose to land a fresh event/ciphertext. Only the generic entity update
45
+ // handler (entity-handlers.ts) sets this flag; those two call their own
46
+ // executor directly and never see it.
47
+ function isUnchangedValue(a: unknown, b: unknown): boolean {
48
+ return JSON.stringify(a) === JSON.stringify(b);
49
+ }
50
+
51
+ export function createWriteVerbs(
52
+ ctx: ExecutorContext,
53
+ ): Pick<EventStoreExecutor, "create" | "update" | "delete" | "forget" | "restore"> {
54
+ const {
55
+ table,
56
+ entity,
57
+ entityName,
58
+ entityCache,
59
+ softDelete,
60
+ streamTenantFor,
61
+ encryptForStorage,
62
+ decryptForRead,
63
+ applyDefaults,
64
+ stripSensitive,
65
+ loadById,
66
+ assertStreamWritable,
67
+ } = ctx;
68
+
69
+ return {
70
+ async create(payload, user, db) {
71
+ // Respect an explicit id in the payload (seed pattern, SCIM import). Without
72
+ // one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
73
+ // event payload so defaults + downstream consumers don't see a redundant id field.
74
+ const explicitId = typeof payload["id"] === "string" ? (payload["id"] as string) : undefined; // @cast-boundary engine-payload
75
+ const aggregateId = explicitId ?? generateId();
76
+ const { id: _id, ...payloadWithoutId } = payload;
77
+ const data = applyDefaults(payloadWithoutId);
78
+
79
+ // H.2 — entity-level write-ownership on create. No oldRow exists, so
80
+ // only the new row is checked. No Straddle concern for creates.
81
+ if (!userCanCreateFieldRow(user, entity.access?.write, data)) {
82
+ return writeFailure(
83
+ new UnprocessableError("ownership_denied", {
84
+ i18nKey: "errors.ownershipDenied",
85
+ details: { scope: "entity", entityName, action: "create", userId: user.id },
86
+ }),
87
+ );
88
+ }
89
+
90
+ // Field-level write-ownership on create — mirror of entity-level but
91
+ // per declared field. Role-level was already checked by the
92
+ // dispatcher; here we enforce ownership-rules against the new row.
93
+ const fieldDeniedCreate = checkWriteFieldOwnership(entity, data, user);
94
+ if (fieldDeniedCreate) {
95
+ return writeFailure(
96
+ new UnprocessableError("ownership_denied", {
97
+ i18nKey: "errors.ownershipDenied",
98
+ details: {
99
+ scope: "field",
100
+ entityName,
101
+ action: "create",
102
+ field: fieldDeniedCreate,
103
+ userId: user.id,
104
+ },
105
+ }),
106
+ );
107
+ }
108
+
109
+ // Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
110
+ // dieselbe Pipeline. Caller schickt combined API-Form, Framework
111
+ // speichert flat DB-Form. Siehe db/compound-types.ts.
112
+ // subjectSource carries the freshly minted aggregateId: the create
113
+ // payload has no id column, but a pii:true self-subject resolves from it.
114
+ const flatCreateData = flattenCompoundTypes(data, entity);
115
+ const flatData = await encryptForStorage(flatCreateData, user, {
116
+ subjectSource: { ...flatCreateData, id: aggregateId },
117
+ });
118
+
119
+ // 1. Append event (same TX as the projection write — both must succeed
120
+ // or both roll back; the dispatcher wraps both in one transaction).
121
+ // flatData is already table ciphertext for pii/encrypted fields, so
122
+ // the immutable log never sees plaintext and replay reproduces the
123
+ // row byte-identically (#967).
124
+ //
125
+ // `expectedVersion: 0` heißt: stream existiert noch nicht. Bei
126
+ // deterministic-aggregate-id-Patterns (z.B. uuidv5(tenantId|naturalKey))
127
+ // ist es legitim dass create kollidiert — selbe id, schon vorhandener
128
+ // stream → version_conflict statt internal_error. Update hat den
129
+ // selben catch (siehe line 493+).
130
+ let event: Awaited<ReturnType<typeof append>>;
131
+ try {
132
+ event = await append(db.raw, {
133
+ aggregateId,
134
+ aggregateType: entityName,
135
+ tenantId: streamTenantFor(user),
136
+ expectedVersion: 0,
137
+ type: entityEventName(entityName, "created"),
138
+ payload: flatData,
139
+ metadata: buildEventMetadata(user),
140
+ });
141
+ } catch (e) {
142
+ if (e instanceof EventStoreVersionConflict) {
143
+ // Try to look up the real stream-version for the diagnostic — but
144
+ // wrap defensively: when `append` raised the unique-violation, the
145
+ // current TX is already aborted, and a second query on the same
146
+ // runner would re-throw "current transaction is aborted". Update-
147
+ // path doesn't have this problem (it queries getStreamVersion
148
+ // BEFORE the try-block). Falling back to a sentinel keeps the
149
+ // version_conflict mapping reliable; the actual current version
150
+ // is recoverable client-side via a fresh detail-query if needed.
151
+ let currentVersion = -1;
152
+ try {
153
+ currentVersion = await getStreamVersion(db.raw, aggregateId, streamTenantFor(user));
154
+ } catch {
155
+ // Aborted TX or any lookup failure — keep the sentinel.
156
+ }
157
+ return writeFailure(
158
+ new FrameworkVersionConflict({
159
+ entityId: aggregateId,
160
+ expectedVersion: 0,
161
+ currentVersion,
162
+ }),
163
+ );
164
+ }
165
+ if (e instanceof EventStoreIdempotentAppendConflict) {
166
+ return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
167
+ }
168
+ throw e;
169
+ }
170
+
171
+ // 2. Update projection via applyEntityEvent — derselbe Code-Pfad den
172
+ // rebuildProjection für Replay nutzt, mit demselben StoredEvent →
173
+ // Live==Rebuild by-construction (#967).
174
+ //
175
+ // F8-Patch: app-level unique-violations (z.B. (tenantId, email)
176
+ // auf User-Entity, (tenantId, slug) auf Article) werfen pg-23505
177
+ // aus der projection-INSERT. Ohne den catch propagiert das als
178
+ // unhandled exception → 500 internal_error. Map auf
179
+ // UniqueViolationError 409 damit Designer/Frontend einen sauberen
180
+ // "duplicate" zeigen können statt cryptic "internal server error".
181
+ let result: Awaited<ReturnType<typeof applyEntityEvent>>;
182
+ try {
183
+ result = await applyEntityEvent(event, table, entity, db.raw);
184
+ } catch (e) {
185
+ const mapped = tryMapUniqueViolation(e, entityName);
186
+ if (mapped) return mapped;
187
+ throw e;
188
+ }
189
+ if (result.kind !== "applied" || result.row === null) {
190
+ return writeFailure(new InternalError({ message: "projection insert returned no row" }));
191
+ }
192
+ const row = result.row;
193
+ // Read-Side Auto-Convert: DB-Form → API-combined-Form für alle
194
+ // Compound-Types in einem Pass.
195
+ const projection = await decryptForRead(
196
+ rehydrateCompoundTypes(row as DbRow, entity) as DbRow,
197
+ );
198
+
199
+ if (entityCache && entityName) {
200
+ await entityCache.del(user.tenantId, entityName, aggregateId);
201
+ }
202
+
203
+ return {
204
+ isSuccess: true,
205
+ data: {
206
+ kind: "save",
207
+ id: aggregateId,
208
+ data: projection,
209
+ changes: data,
210
+ previous: {},
211
+ isNew: true,
212
+ entityName,
213
+ // Persisted event carries ciphertext by design — the caller-facing
214
+ // echo must be plaintext like every other response field (#820).
215
+ event: { ...event, payload: stripSensitive(flatCreateData) },
216
+ },
217
+ };
218
+ },
219
+
220
+ async update(payload, user, db, updateOptions) {
221
+ const previous = await loadById(payload.id, db);
222
+ if (!previous) return writeFailure(new NotFoundError(entityName, payload.id));
223
+
224
+ // H.2 — entity-level write-ownership on update. Load old row (already
225
+ // done above), build post-change row via shallow merge. Straddle-safe
226
+ // multi-role check: at least one role must accept BOTH old and new —
227
+ // prevents the attack where role A passes old, role B passes new and
228
+ // aggregation would wrongly allow a row-grab.
229
+ const mergedNew: Record<string, unknown> = { ...previous, ...payload.changes };
230
+ if (!userCanWriteFieldRow(user, entity.access?.write, previous, mergedNew)) {
231
+ return writeFailure(
232
+ new UnprocessableError("ownership_denied", {
233
+ i18nKey: "errors.ownershipDenied",
234
+ details: {
235
+ scope: "entity",
236
+ entityName,
237
+ action: "update",
238
+ userId: user.id,
239
+ entityId: payload.id,
240
+ },
241
+ }),
242
+ );
243
+ }
244
+
245
+ // Field-level write-ownership on update — this is the path the
246
+ // dispatcher could not evaluate (no oldRow). Now that we have
247
+ // `previous`, we can run the ownership rules per field against both
248
+ // sides and reject individual fields the user isn't entitled to
249
+ // touch on this specific row.
250
+ const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, user, previous);
251
+ if (fieldDeniedUpdate) {
252
+ return writeFailure(
253
+ new UnprocessableError("ownership_denied", {
254
+ i18nKey: "errors.ownershipDenied",
255
+ details: {
256
+ scope: "field",
257
+ entityName,
258
+ action: "update",
259
+ field: fieldDeniedUpdate,
260
+ userId: user.id,
261
+ entityId: payload.id,
262
+ },
263
+ }),
264
+ );
265
+ }
266
+
267
+ await assertStreamWritable(db, payload.id, streamTenantFor(user));
268
+
269
+ // Stream-version is authoritative, not row.version. `ctx.appendEvent`
270
+ // can bump the stream between CRUD writes (domain event on the same
271
+ // aggregate); a stale row.version here would make the next CRUD write
272
+ // trip `events_aggregate_version_uq` (tenant_id, aggregate_id, version)
273
+ // with version_conflict.
274
+ const currentVersion = await getStreamVersion(
275
+ db.raw,
276
+ String(payload.id),
277
+ streamTenantFor(user),
278
+ );
279
+ if (!updateOptions?.skipOptimisticLock) {
280
+ if (payload.version === undefined) {
281
+ return writeFailure(
282
+ new FrameworkVersionConflict({
283
+ entityId: payload.id,
284
+ expectedVersion: 0,
285
+ currentVersion,
286
+ }),
287
+ );
288
+ }
289
+ if (currentVersion !== payload.version) {
290
+ return writeFailure(
291
+ new FrameworkVersionConflict({
292
+ entityId: payload.id,
293
+ expectedVersion: payload.version,
294
+ currentVersion,
295
+ }),
296
+ );
297
+ }
298
+ }
299
+
300
+ try {
301
+ // Compound-Types Auto-Convert (alle in einem Pass).
302
+ // subjectSource: partial changes may carry a pii field without its
303
+ // ownerField — the merged row still names the subject.
304
+ const submittedChanges = updateOptions?.skipUnchanged
305
+ ? Object.fromEntries(
306
+ Object.entries(payload.changes).filter(
307
+ ([key, value]) => !isUnchangedValue(value, previous[key]),
308
+ ),
309
+ )
310
+ : payload.changes;
311
+ const flatChangesPlain = flattenCompoundTypes(submittedChanges, entity);
312
+ const flatChanges = await encryptForStorage(flatChangesPlain, user, {
313
+ onlyKeys: Object.keys(submittedChanges),
314
+ subjectSource: mergedNew,
315
+ });
316
+
317
+ // The event payload carries BOTH `changes` (what the user asked for) AND
318
+ // `previous` (the pre-update row). Cross-aggregate projections need the
319
+ // previous value to decrement/undo when a parent-FK moves — without it
320
+ // you'd have to snapshot-and-diff on every apply, and replays would
321
+ // break. Storage cost is acceptable (rows are bounded), correctness is
322
+ // not negotiable. `previous` came from loadById(), which decrypts —
323
+ // re-encrypt it before it's persisted so plaintext of pii/encrypted
324
+ // fields doesn't land in the immutable log (flatChanges is already
325
+ // ciphertext from encryptForStorage above).
326
+ const event = await append(db.raw, {
327
+ aggregateId: String(payload.id),
328
+ aggregateType: entityName,
329
+ tenantId: streamTenantFor(user),
330
+ expectedVersion: currentVersion,
331
+ type: entityEventName(entityName, "updated"),
332
+ payload: {
333
+ changes: flatChanges,
334
+ previous: await encryptForStorage(previous, user),
335
+ },
336
+ metadata: buildEventMetadata(user),
337
+ });
338
+
339
+ // Live==Rebuild via applyEntityEvent mit demselben StoredEvent —
340
+ // apply liest nur `changes`, und die sind live wie im Replay
341
+ // identischer Ciphertext (#967).
342
+ //
343
+ // F8-Patch: dasselbe unique-violation-handling wie im create-Pfad
344
+ // — ein update das einen unique-Index verletzt (z.B. email-update
345
+ // auf einen schon-existierenden Wert) wird mit 409 unique_violation
346
+ // statt 500 internal_error rückgemeldet.
347
+ let result: Awaited<ReturnType<typeof applyEntityEvent>>;
348
+ try {
349
+ result = await applyEntityEvent(event, table, entity, db.raw);
350
+ } catch (e) {
351
+ const mapped = tryMapUniqueViolation(e, entityName);
352
+ if (mapped) return mapped;
353
+ throw e;
354
+ }
355
+ if (result.kind !== "applied" || result.row === null) {
356
+ return writeFailure(new InternalError({ message: "projection update returned no row" }));
357
+ }
358
+ const row = result.row;
359
+ const data = await decryptForRead(rehydrateCompoundTypes(row as DbRow, entity) as DbRow);
360
+
361
+ if (entityCache && entityName) {
362
+ await entityCache.del(user.tenantId, entityName, payload.id);
363
+ }
364
+
365
+ return {
366
+ isSuccess: true,
367
+ data: {
368
+ kind: "save",
369
+ id: data["id"] as EntityId, // @cast-boundary engine-payload
370
+ data,
371
+ changes: payload.changes,
372
+ previous,
373
+ isNew: false,
374
+ entityName,
375
+ event: {
376
+ ...event,
377
+ payload: {
378
+ changes: stripSensitive(flatChangesPlain),
379
+ previous: stripSensitive(previous),
380
+ },
381
+ },
382
+ },
383
+ };
384
+ } catch (e) {
385
+ // The pre-check above eliminates the common stale-version case; this
386
+ // branch catches the narrow race where two writers both read version=N
387
+ // and both pass the local check — the unique index on (aggregate_id,
388
+ // version) serializes them, one wins, the other lands here.
389
+ if (e instanceof EventStoreVersionConflict) {
390
+ return writeFailure(
391
+ new FrameworkVersionConflict({
392
+ entityId: payload.id,
393
+ expectedVersion: payload.version ?? 0,
394
+ currentVersion,
395
+ }),
396
+ );
397
+ }
398
+ if (e instanceof EventStoreIdempotentAppendConflict) {
399
+ return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
400
+ }
401
+ throw e;
402
+ }
403
+ },
404
+
405
+ async delete(payload, user, db) {
406
+ const existing = await loadById(payload.id, db);
407
+ if (!existing) return writeFailure(new NotFoundError(entityName, payload.id));
408
+
409
+ // H.2 — entity-level write-ownership on delete. Only the pre-delete
410
+ // row matters (there's no "new" row for a delete); passing existing
411
+ // twice to userCanWriteFieldRow makes the Straddle check trivial
412
+ // (same row on both sides) while keeping the multi-role-atomic shape.
413
+ if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
414
+ return writeFailure(
415
+ new UnprocessableError("ownership_denied", {
416
+ i18nKey: "errors.ownershipDenied",
417
+ details: {
418
+ scope: "entity",
419
+ entityName,
420
+ action: "delete",
421
+ userId: user.id,
422
+ entityId: payload.id,
423
+ },
424
+ }),
425
+ );
426
+ }
427
+
428
+ await assertStreamWritable(db, payload.id, streamTenantFor(user));
429
+
430
+ // Stream-version authoritative (see update() for rationale).
431
+ const currentVersion = await getStreamVersion(
432
+ db.raw,
433
+ String(payload.id),
434
+ streamTenantFor(user),
435
+ );
436
+
437
+ // Deletes carry the full pre-delete row as `previous`. That's what
438
+ // projections and downstream consumers need to reverse any aggregates —
439
+ // a `{}`-payload delete would make cross-aggregate projections impossible
440
+ // to rebuild from the event log alone. `existing` came from loadById(),
441
+ // which decrypts — re-encrypt before persisting so plaintext doesn't
442
+ // land in the immutable log.
443
+ const event = await append(db.raw, {
444
+ aggregateId: String(payload.id),
445
+ aggregateType: entityName,
446
+ tenantId: streamTenantFor(user),
447
+ expectedVersion: currentVersion,
448
+ type: entityEventName(entityName, "deleted"),
449
+ payload: { previous: await encryptForStorage(existing, user) },
450
+ metadata: buildEventMetadata(user),
451
+ });
452
+
453
+ // Live==Rebuild via applyEntityEvent. Delete-Operation hat keine
454
+ // sensitive-Drift weil das Event-Payload nur `previous` ist und das
455
+ // wird vom soft/hard-delete-Code gar nicht in die Tabelle geschrieben
456
+ // (nur isDeleted/deletedAt/version-Bump). Live + Replay schreiben
457
+ // dasselbe — kein payload-override nötig.
458
+ const deleteResult = await applyEntityEvent(event, table, entity, db.raw);
459
+ if (deleteResult.kind !== "applied") {
460
+ return writeFailure(
461
+ new InternalError({ message: "projection delete: applyEntityEvent skipped" }),
462
+ );
463
+ }
464
+
465
+ if (entityCache && entityName) {
466
+ await entityCache.del(user.tenantId, entityName, payload.id);
467
+ }
468
+
469
+ return {
470
+ isSuccess: true,
471
+ data: {
472
+ kind: "delete",
473
+ id: payload.id,
474
+ data: existing,
475
+ entityName,
476
+ event: { ...event, payload: { previous: stripSensitive(existing) } },
477
+ },
478
+ };
479
+ },
480
+
481
+ // Hard-purge (Art. 17). Same shape as delete(), but emits `forgotten` which
482
+ // hard-deletes the row regardless of softDelete — and, being an auto-verb,
483
+ // the erasure replays on rebuild (created → forgotten → row gone). Loads
484
+ // without the isDeleted filter so trashed (soft-deleted) rows are erased too.
485
+ async forget(payload, user, db) {
486
+ const raw = await db.fetchOne<Record<string, unknown>>(table, { id: payload.id });
487
+ if (!raw) return writeFailure(new NotFoundError(entityName, payload.id));
488
+ const existing = await decryptForRead(rehydrateCompoundTypes(raw as DbRow, entity) as DbRow);
489
+
490
+ if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
491
+ return writeFailure(
492
+ new UnprocessableError("ownership_denied", {
493
+ i18nKey: "errors.ownershipDenied",
494
+ details: {
495
+ scope: "entity",
496
+ entityName,
497
+ action: "delete",
498
+ userId: user.id,
499
+ entityId: payload.id,
500
+ },
501
+ }),
502
+ );
503
+ }
504
+
505
+ await assertStreamWritable(db, payload.id, streamTenantFor(user));
506
+ const currentVersion = await getStreamVersion(
507
+ db.raw,
508
+ String(payload.id),
509
+ streamTenantFor(user),
510
+ );
511
+
512
+ const event = await append(db.raw, {
513
+ aggregateId: String(payload.id),
514
+ aggregateType: entityName,
515
+ tenantId: streamTenantFor(user),
516
+ expectedVersion: currentVersion,
517
+ type: entityEventName(entityName, "forgotten"),
518
+ // Re-encrypt like delete(): `existing` came decrypted from loadById —
519
+ // plaintext must not land in the immutable log, least of all on forget.
520
+ payload: { previous: await encryptForStorage(existing, user) },
521
+ metadata: buildEventMetadata(user),
522
+ });
523
+
524
+ const forgetResult = await applyEntityEvent(event, table, entity, db.raw);
525
+ if (forgetResult.kind !== "applied") {
526
+ return writeFailure(
527
+ new InternalError({ message: "projection forget: applyEntityEvent skipped" }),
528
+ );
529
+ }
530
+
531
+ if (entityCache && entityName) {
532
+ await entityCache.del(user.tenantId, entityName, payload.id);
533
+ }
534
+
535
+ return {
536
+ isSuccess: true,
537
+ data: {
538
+ kind: "delete",
539
+ id: payload.id,
540
+ data: existing,
541
+ entityName,
542
+ event: { ...event, payload: { previous: stripSensitive(existing) } },
543
+ },
544
+ };
545
+ },
546
+
547
+ async restore(payload, user, db) {
548
+ if (!softDelete) {
549
+ return writeFailure(
550
+ new UnprocessableError("soft_delete_not_enabled", {
551
+ i18nKey: "errors.softDeleteNotEnabled",
552
+ }),
553
+ );
554
+ }
555
+
556
+ const [row] = await selectMany(db.raw, table, { id: payload.id });
557
+ if (!row) return writeFailure(new NotFoundError(entityName, payload.id));
558
+ const data = row as DbRow;
559
+ if (!data["isDeleted"]) {
560
+ return writeFailure(
561
+ new UnprocessableError("not_deleted", { i18nKey: "errors.notDeleted" }),
562
+ );
563
+ }
564
+
565
+ // H.2 — entity-level write-ownership on restore. Same shape as delete:
566
+ // only the stored row matters. Stored row carries pre-soft-delete
567
+ // teamId/... fields, so the ownership predicate still applies cleanly.
568
+ if (!userCanWriteFieldRow(user, entity.access?.write, data, data)) {
569
+ return writeFailure(
570
+ new UnprocessableError("ownership_denied", {
571
+ i18nKey: "errors.ownershipDenied",
572
+ details: {
573
+ scope: "entity",
574
+ entityName,
575
+ action: "restore",
576
+ userId: user.id,
577
+ entityId: payload.id,
578
+ },
579
+ }),
580
+ );
581
+ }
582
+
583
+ await assertStreamWritable(db, payload.id, streamTenantFor(user));
584
+
585
+ // Stream-version authoritative (see update() for rationale).
586
+ const currentVersion = await getStreamVersion(
587
+ db.raw,
588
+ String(payload.id),
589
+ streamTenantFor(user),
590
+ );
591
+ // Restore carries the soft-deleted snapshot as `previous` — mirror of
592
+ // delete for symmetry. Projections that decremented on delete use
593
+ // `previous` to re-increment on restore without re-querying the entity
594
+ // table. `data` is the raw stored row — pii/encrypted fields are
595
+ // already ciphertext, no re-encrypt needed.
596
+ const event = await append(db.raw, {
597
+ aggregateId: String(payload.id),
598
+ aggregateType: entityName,
599
+ tenantId: streamTenantFor(user),
600
+ expectedVersion: currentVersion,
601
+ type: entityEventName(entityName, "restored"),
602
+ payload: { previous: data },
603
+ metadata: buildEventMetadata(user),
604
+ });
605
+
606
+ // Live==Rebuild via applyEntityEvent. Restore schreibt nur isDeleted=
607
+ // false + version-Bump in die Tabelle — keine sensitive-Drift, daher
608
+ // kein payload-override nötig.
609
+ const restoreResult = await applyEntityEvent(event, table, entity, db.raw);
610
+ if (restoreResult.kind !== "applied" || restoreResult.row === null) {
611
+ return writeFailure(new InternalError({ message: "projection restore returned no row" }));
612
+ }
613
+ const restored = restoreResult.row;
614
+
615
+ if (entityCache && entityName) {
616
+ await entityCache.del(user.tenantId, entityName, payload.id);
617
+ }
618
+
619
+ // Read-Side Auto-Convert für Compound-Types (parallel zu update/list).
620
+ // decryptForRead matches create/update/list/detail: the caller-facing
621
+ // row and `previous` snapshot must be plaintext for `encrypted` fields,
622
+ // same as every other executor method — `data`/`restored` are raw rows
623
+ // (selectMany / applyEntityEvent), never decrypted before this point.
624
+ const restoredHydrated = await decryptForRead(
625
+ rehydrateCompoundTypes(restored as DbRow, entity) as DbRow,
626
+ );
627
+
628
+ const previousPlain = await decryptForRead(data);
629
+ return {
630
+ isSuccess: true,
631
+ data: {
632
+ kind: "save",
633
+ id: payload.id,
634
+ data: restoredHydrated,
635
+ changes: { isDeleted: false },
636
+ previous: previousPlain,
637
+ isNew: false,
638
+ entityName,
639
+ event: { ...event, payload: { previous: stripSensitive(previousPlain) } },
640
+ },
641
+ };
642
+ },
643
+ };
644
+ }