@cosmicdrift/kumiko-framework 0.1.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 (388) hide show
  1. package/README.md +159 -0
  2. package/package.json +91 -0
  3. package/src/__tests__/anonymous-access.integration.ts +325 -0
  4. package/src/__tests__/error-contract.integration.ts +435 -0
  5. package/src/__tests__/field-access.integration.ts +269 -0
  6. package/src/__tests__/full-stack.integration.ts +914 -0
  7. package/src/__tests__/ownership.integration.ts +449 -0
  8. package/src/__tests__/reference-data.integration.ts +198 -0
  9. package/src/__tests__/transition-guard.integration.ts +340 -0
  10. package/src/api/__tests__/api.test.ts +337 -0
  11. package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
  13. package/src/api/__tests__/batch.integration.ts +404 -0
  14. package/src/api/__tests__/body-limit.test.ts +88 -0
  15. package/src/api/__tests__/csrf-middleware.test.ts +97 -0
  16. package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
  17. package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
  18. package/src/api/__tests__/nested-write.integration.ts +213 -0
  19. package/src/api/__tests__/readiness.test.ts +76 -0
  20. package/src/api/__tests__/request-id-middleware.test.ts +72 -0
  21. package/src/api/__tests__/sse-broker.test.ts +58 -0
  22. package/src/api/__tests__/sse-route.test.ts +112 -0
  23. package/src/api/anonymous-cookie.ts +60 -0
  24. package/src/api/api-constants.ts +64 -0
  25. package/src/api/auth-middleware.ts +418 -0
  26. package/src/api/auth-routes.ts +982 -0
  27. package/src/api/csrf-middleware.ts +77 -0
  28. package/src/api/index.ts +31 -0
  29. package/src/api/jwt.ts +66 -0
  30. package/src/api/observability-middleware.ts +89 -0
  31. package/src/api/readiness.ts +132 -0
  32. package/src/api/request-context.ts +49 -0
  33. package/src/api/request-id-middleware.ts +50 -0
  34. package/src/api/route-registrars.ts +195 -0
  35. package/src/api/routes.ts +135 -0
  36. package/src/api/server.ts +640 -0
  37. package/src/api/sse-broker.ts +71 -0
  38. package/src/api/sse-route.ts +62 -0
  39. package/src/api/tokens.ts +16 -0
  40. package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
  41. package/src/db/__tests__/compound-types.test.ts +114 -0
  42. package/src/db/__tests__/connection-options.test.ts +68 -0
  43. package/src/db/__tests__/cursor.test.ts +41 -0
  44. package/src/db/__tests__/db-helpers.test.ts +369 -0
  45. package/src/db/__tests__/dialect-instant.test.ts +50 -0
  46. package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
  47. package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
  48. package/src/db/__tests__/encryption.test.ts +39 -0
  49. package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
  50. package/src/db/__tests__/event-store-executor.integration.ts +235 -0
  51. package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
  52. package/src/db/__tests__/located-timestamp.test.ts +184 -0
  53. package/src/db/__tests__/money.test.ts +199 -0
  54. package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
  55. package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
  56. package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
  57. package/src/db/__tests__/row-helpers.test.ts +59 -0
  58. package/src/db/__tests__/schema-migration.integration.ts +273 -0
  59. package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
  60. package/src/db/__tests__/table-builder-required.test.ts +216 -0
  61. package/src/db/__tests__/tenant-db.integration.ts +606 -0
  62. package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
  63. package/src/db/apply-entity-event.ts +188 -0
  64. package/src/db/assert-exists-in.ts +59 -0
  65. package/src/db/compound-types.ts +47 -0
  66. package/src/db/connection.ts +104 -0
  67. package/src/db/cursor.ts +83 -0
  68. package/src/db/dialect.ts +109 -0
  69. package/src/db/eagerload.ts +174 -0
  70. package/src/db/encryption.ts +39 -0
  71. package/src/db/event-store-executor.ts +906 -0
  72. package/src/db/index.ts +55 -0
  73. package/src/db/located-timestamp.ts +114 -0
  74. package/src/db/money.ts +120 -0
  75. package/src/db/pg-error.ts +46 -0
  76. package/src/db/reference-data.ts +77 -0
  77. package/src/db/row-helpers.ts +53 -0
  78. package/src/db/schema-inspection.ts +25 -0
  79. package/src/db/table-builder.ts +475 -0
  80. package/src/db/tenant-db.ts +434 -0
  81. package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
  82. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
  83. package/src/engine/__tests__/boot-validator.test.ts +1865 -0
  84. package/src/engine/__tests__/build-app-schema.test.ts +154 -0
  85. package/src/engine/__tests__/claim-keys.test.ts +274 -0
  86. package/src/engine/__tests__/config-helpers.test.ts +236 -0
  87. package/src/engine/__tests__/effective-features.test.ts +86 -0
  88. package/src/engine/__tests__/engine.test.ts +1461 -0
  89. package/src/engine/__tests__/entity-handlers.test.ts +274 -0
  90. package/src/engine/__tests__/event-helpers.test.ts +68 -0
  91. package/src/engine/__tests__/extends-registrar.test.ts +159 -0
  92. package/src/engine/__tests__/factories-long-text.test.ts +84 -0
  93. package/src/engine/__tests__/factories-time.test.ts +158 -0
  94. package/src/engine/__tests__/field-predicates.test.ts +48 -0
  95. package/src/engine/__tests__/hook-phases.test.ts +132 -0
  96. package/src/engine/__tests__/identifiers.test.ts +35 -0
  97. package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
  98. package/src/engine/__tests__/nav.test.ts +267 -0
  99. package/src/engine/__tests__/ownership.test.ts +421 -0
  100. package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
  101. package/src/engine/__tests__/projection-helpers.test.ts +62 -0
  102. package/src/engine/__tests__/projection.test.ts +191 -0
  103. package/src/engine/__tests__/qualified-name.test.ts +264 -0
  104. package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
  105. package/src/engine/__tests__/run-in.test.ts +38 -0
  106. package/src/engine/__tests__/schema-builder.test.ts +380 -0
  107. package/src/engine/__tests__/screen.test.ts +408 -0
  108. package/src/engine/__tests__/state-machine.test.ts +148 -0
  109. package/src/engine/__tests__/system-user.test.ts +57 -0
  110. package/src/engine/__tests__/validation-hooks.test.ts +71 -0
  111. package/src/engine/access.ts +23 -0
  112. package/src/engine/boot-validator.ts +1528 -0
  113. package/src/engine/build-app-schema.ts +125 -0
  114. package/src/engine/config-helpers.ts +115 -0
  115. package/src/engine/constants.ts +85 -0
  116. package/src/engine/create-app.ts +98 -0
  117. package/src/engine/define-feature.ts +702 -0
  118. package/src/engine/define-handler.ts +78 -0
  119. package/src/engine/define-roles.ts +19 -0
  120. package/src/engine/effective-features.ts +87 -0
  121. package/src/engine/entity-handlers.ts +364 -0
  122. package/src/engine/event-helpers.ts +73 -0
  123. package/src/engine/factories.ts +328 -0
  124. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
  125. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
  126. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
  127. package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
  128. package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
  129. package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
  130. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
  131. package/src/engine/feature-ast/extractors.ts +2562 -0
  132. package/src/engine/feature-ast/index.ts +105 -0
  133. package/src/engine/feature-ast/parse.ts +369 -0
  134. package/src/engine/feature-ast/patch.ts +525 -0
  135. package/src/engine/feature-ast/patcher.ts +518 -0
  136. package/src/engine/feature-ast/patterns.ts +434 -0
  137. package/src/engine/feature-ast/render.ts +602 -0
  138. package/src/engine/feature-ast/source-location.ts +45 -0
  139. package/src/engine/field-access.ts +120 -0
  140. package/src/engine/index.ts +254 -0
  141. package/src/engine/ownership.ts +337 -0
  142. package/src/engine/parse-ref-target.ts +22 -0
  143. package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
  144. package/src/engine/pattern-library/index.ts +24 -0
  145. package/src/engine/pattern-library/library.ts +1117 -0
  146. package/src/engine/pattern-library/types.ts +255 -0
  147. package/src/engine/projection-helpers.ts +85 -0
  148. package/src/engine/qualified-name.ts +122 -0
  149. package/src/engine/read-claim.ts +31 -0
  150. package/src/engine/registry.ts +1325 -0
  151. package/src/engine/resolve-config-or-param.ts +153 -0
  152. package/src/engine/run-in.ts +29 -0
  153. package/src/engine/schema-builder.ts +175 -0
  154. package/src/engine/screen-filter-ops.ts +51 -0
  155. package/src/engine/state-machine.ts +70 -0
  156. package/src/engine/system-user.ts +32 -0
  157. package/src/engine/types/config.ts +306 -0
  158. package/src/engine/types/event-type-map.ts +37 -0
  159. package/src/engine/types/feature.ts +574 -0
  160. package/src/engine/types/fields.ts +422 -0
  161. package/src/engine/types/handlers.ts +742 -0
  162. package/src/engine/types/hooks.ts +142 -0
  163. package/src/engine/types/http-route.ts +54 -0
  164. package/src/engine/types/identifiers.ts +47 -0
  165. package/src/engine/types/index.ts +208 -0
  166. package/src/engine/types/nav.ts +46 -0
  167. package/src/engine/types/projection.ts +132 -0
  168. package/src/engine/types/relations.ts +51 -0
  169. package/src/engine/types/screen.ts +452 -0
  170. package/src/engine/types/workspace.ts +42 -0
  171. package/src/engine/validation.ts +33 -0
  172. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
  173. package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
  174. package/src/entrypoint/index.ts +442 -0
  175. package/src/errors/__tests__/classes.test.ts +371 -0
  176. package/src/errors/__tests__/write-failures.test.ts +109 -0
  177. package/src/errors/classes.ts +249 -0
  178. package/src/errors/i18n/de.yaml +83 -0
  179. package/src/errors/i18n/en.yaml +80 -0
  180. package/src/errors/index.ts +41 -0
  181. package/src/errors/kumiko-error.ts +67 -0
  182. package/src/errors/reasons.ts +36 -0
  183. package/src/errors/serialize.ts +136 -0
  184. package/src/errors/transition-details.ts +30 -0
  185. package/src/errors/write-error-info.ts +123 -0
  186. package/src/errors/zod-bridge.ts +49 -0
  187. package/src/event-store/__tests__/admin-api.integration.ts +361 -0
  188. package/src/event-store/__tests__/event-store.integration.ts +584 -0
  189. package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
  190. package/src/event-store/__tests__/perf.integration.ts +255 -0
  191. package/src/event-store/__tests__/snapshot.integration.ts +267 -0
  192. package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
  193. package/src/event-store/__tests__/upcaster.integration.ts +460 -0
  194. package/src/event-store/admin-api.ts +257 -0
  195. package/src/event-store/archive.ts +106 -0
  196. package/src/event-store/errors.ts +35 -0
  197. package/src/event-store/event-store.ts +405 -0
  198. package/src/event-store/events-schema.ts +90 -0
  199. package/src/event-store/index.ts +50 -0
  200. package/src/event-store/snapshot.ts +210 -0
  201. package/src/event-store/upcaster-dead-letter.ts +119 -0
  202. package/src/event-store/upcaster.ts +147 -0
  203. package/src/files/__tests__/content-disposition.test.ts +123 -0
  204. package/src/files/__tests__/file-field-column.integration.ts +103 -0
  205. package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
  206. package/src/files/__tests__/file-handle.test.ts +122 -0
  207. package/src/files/__tests__/files.integration.ts +830 -0
  208. package/src/files/__tests__/storage-tracking.integration.ts +153 -0
  209. package/src/files/content-disposition.ts +55 -0
  210. package/src/files/file-handle.ts +63 -0
  211. package/src/files/file-ref-table.ts +22 -0
  212. package/src/files/file-routes.ts +353 -0
  213. package/src/files/in-memory-provider.ts +62 -0
  214. package/src/files/index.ts +29 -0
  215. package/src/files/local-provider.ts +35 -0
  216. package/src/files/storage-tracking.ts +60 -0
  217. package/src/files/types.ts +118 -0
  218. package/src/i18n/__tests__/i18n.test.ts +72 -0
  219. package/src/i18n/index.ts +29 -0
  220. package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
  221. package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
  222. package/src/jobs/__tests__/jobs.integration.ts +566 -0
  223. package/src/jobs/index.ts +2 -0
  224. package/src/jobs/job-runner.ts +574 -0
  225. package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
  226. package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
  227. package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
  228. package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
  229. package/src/lifecycle/index.ts +13 -0
  230. package/src/lifecycle/lifecycle.ts +160 -0
  231. package/src/lifecycle/signal-handlers.ts +62 -0
  232. package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
  233. package/src/logging/index.ts +3 -0
  234. package/src/logging/pino-logger.ts +64 -0
  235. package/src/logging/types.ts +7 -0
  236. package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
  237. package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
  238. package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
  239. package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
  240. package/src/migrations/index.ts +28 -0
  241. package/src/migrations/projection-detection.ts +149 -0
  242. package/src/migrations/rebuild-marker.ts +64 -0
  243. package/src/migrations/schema-drift.ts +395 -0
  244. package/src/observability/__tests__/console-provider.test.ts +67 -0
  245. package/src/observability/__tests__/metric-validator.test.ts +87 -0
  246. package/src/observability/__tests__/noop-provider.test.ts +82 -0
  247. package/src/observability/__tests__/observability.integration.ts +559 -0
  248. package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
  249. package/src/observability/__tests__/recording-meter.test.ts +101 -0
  250. package/src/observability/__tests__/recording-tracer.test.ts +110 -0
  251. package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
  252. package/src/observability/console-provider.ts +130 -0
  253. package/src/observability/context.ts +26 -0
  254. package/src/observability/fallback.ts +34 -0
  255. package/src/observability/ids.ts +25 -0
  256. package/src/observability/index.ts +79 -0
  257. package/src/observability/metric-validator.ts +86 -0
  258. package/src/observability/metrics-handle.ts +56 -0
  259. package/src/observability/noop-provider.ts +146 -0
  260. package/src/observability/prometheus-meter.ts +284 -0
  261. package/src/observability/recording-meter.ts +156 -0
  262. package/src/observability/recording-tracer.ts +198 -0
  263. package/src/observability/redis-wrapper.ts +132 -0
  264. package/src/observability/sensitive-filter.ts +108 -0
  265. package/src/observability/standard-metrics.ts +213 -0
  266. package/src/observability/types/index.ts +29 -0
  267. package/src/observability/types/metric.ts +56 -0
  268. package/src/observability/types/provider.ts +32 -0
  269. package/src/observability/types/span.ts +64 -0
  270. package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
  271. package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
  272. package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
  273. package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
  274. package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
  275. package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
  276. package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
  277. package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
  278. package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
  279. package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
  280. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
  281. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
  282. package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
  283. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
  284. package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
  285. package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
  286. package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
  287. package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
  288. package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
  289. package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
  290. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
  291. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
  292. package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
  293. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
  294. package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
  295. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
  296. package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
  297. package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
  298. package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
  299. package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
  300. package/src/pipeline/append-event-core.ts +117 -0
  301. package/src/pipeline/auth-claims-resolver.ts +103 -0
  302. package/src/pipeline/cascade-handler.ts +113 -0
  303. package/src/pipeline/dispatcher.ts +1585 -0
  304. package/src/pipeline/distributed-lock.ts +37 -0
  305. package/src/pipeline/entity-cache.ts +113 -0
  306. package/src/pipeline/event-consumer-state.ts +108 -0
  307. package/src/pipeline/event-dedup.ts +23 -0
  308. package/src/pipeline/event-dispatcher.ts +1016 -0
  309. package/src/pipeline/event-retention.ts +154 -0
  310. package/src/pipeline/idempotency.ts +76 -0
  311. package/src/pipeline/index.ts +66 -0
  312. package/src/pipeline/lifecycle-pipeline.ts +409 -0
  313. package/src/pipeline/msp-rebuild.ts +242 -0
  314. package/src/pipeline/multi-stream-apply-context.ts +115 -0
  315. package/src/pipeline/projection-rebuild.ts +334 -0
  316. package/src/pipeline/projection-state.ts +72 -0
  317. package/src/pipeline/projections-runner.ts +56 -0
  318. package/src/pipeline/redis-keys.ts +11 -0
  319. package/src/pipeline/system-hooks.ts +190 -0
  320. package/src/random/__tests__/generate.test.ts +149 -0
  321. package/src/random/generate.ts +141 -0
  322. package/src/random/index.ts +8 -0
  323. package/src/random/words.ts +392 -0
  324. package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
  325. package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
  326. package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
  327. package/src/rate-limit/bucket.ts +36 -0
  328. package/src/rate-limit/index.ts +14 -0
  329. package/src/rate-limit/middleware.ts +152 -0
  330. package/src/rate-limit/resolver.ts +267 -0
  331. package/src/redis/__tests__/redis-options.test.ts +54 -0
  332. package/src/redis/index.ts +74 -0
  333. package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
  334. package/src/search/__tests__/search-adapter.test.ts +256 -0
  335. package/src/search/in-memory-adapter.ts +123 -0
  336. package/src/search/index.ts +12 -0
  337. package/src/search/meilisearch-adapter.ts +106 -0
  338. package/src/search/types.ts +39 -0
  339. package/src/secrets/__tests__/dek-cache.test.ts +213 -0
  340. package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
  341. package/src/secrets/__tests__/envelope.test.ts +74 -0
  342. package/src/secrets/__tests__/leak-guard.test.ts +92 -0
  343. package/src/secrets/__tests__/rotation.test.ts +149 -0
  344. package/src/secrets/dek-cache.ts +116 -0
  345. package/src/secrets/env-master-key-provider.ts +162 -0
  346. package/src/secrets/envelope.ts +55 -0
  347. package/src/secrets/index.ts +19 -0
  348. package/src/secrets/leak-guard.ts +87 -0
  349. package/src/secrets/rotation.ts +34 -0
  350. package/src/secrets/types.ts +107 -0
  351. package/src/stack/db.ts +104 -0
  352. package/src/stack/event-collector.ts +23 -0
  353. package/src/stack/index.ts +32 -0
  354. package/src/stack/redis.ts +44 -0
  355. package/src/stack/request-helper.ts +168 -0
  356. package/src/stack/table-helpers.ts +104 -0
  357. package/src/stack/test-stack.ts +357 -0
  358. package/src/stack/test-users.ts +37 -0
  359. package/src/testing/__tests__/e2e-generator.test.ts +230 -0
  360. package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
  361. package/src/testing/access-assertions.ts +15 -0
  362. package/src/testing/assertions.ts +35 -0
  363. package/src/testing/e2e-generator.ts +465 -0
  364. package/src/testing/expect-error.ts +25 -0
  365. package/src/testing/handler-context.ts +125 -0
  366. package/src/testing/http-cookies.ts +52 -0
  367. package/src/testing/index.ts +41 -0
  368. package/src/testing/late-bound.ts +39 -0
  369. package/src/testing/mutable-master-key-provider.ts +31 -0
  370. package/src/testing/observability-recorder.ts +54 -0
  371. package/src/testing/shared-entities.ts +49 -0
  372. package/src/testing/utils.ts +1 -0
  373. package/src/testing/wait-for.ts +31 -0
  374. package/src/time/__tests__/polyfill.test.ts +73 -0
  375. package/src/time/__tests__/tz-context.test.ts +121 -0
  376. package/src/time/index.ts +21 -0
  377. package/src/time/polyfill.ts +70 -0
  378. package/src/time/tz-context.ts +107 -0
  379. package/src/ui-types/app-schema.ts +57 -0
  380. package/src/ui-types/index.ts +65 -0
  381. package/src/utils/__tests__/assert.test.ts +17 -0
  382. package/src/utils/__tests__/env-parse.test.ts +54 -0
  383. package/src/utils/assert.ts +18 -0
  384. package/src/utils/env-parse.ts +16 -0
  385. package/src/utils/ids.ts +16 -0
  386. package/src/utils/index.ts +5 -0
  387. package/src/utils/safe-json.ts +30 -0
  388. package/src/utils/serialization.ts +7 -0
@@ -0,0 +1,158 @@
1
+ // Unit-Tests für die neuen Time-Field-Factories
2
+ // (createTimestampField, createTzField, locatedTimestamp).
3
+ //
4
+ // Test-Fokus: korrektes Field-Shape + locatedBy-Marker-Verdrahtung. Die
5
+ // echte TZ-Konvertierung (Wall-Clock ↔ UTC) testen wir später beim
6
+ // DB-Wrapper-Schritt.
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import {
10
+ createLocatedTimestampField,
11
+ createTimestampField,
12
+ createTzField,
13
+ locatedTimestamp,
14
+ } from "../factories";
15
+
16
+ describe("createTimestampField", () => {
17
+ test("default-Form ist nicht-required UTC-Instant ohne locatedBy", () => {
18
+ expect(createTimestampField()).toEqual({
19
+ type: "timestamp",
20
+ required: false,
21
+ });
22
+ });
23
+
24
+ test("kann required gesetzt werden", () => {
25
+ expect(createTimestampField({ required: true })).toEqual({
26
+ type: "timestamp",
27
+ required: true,
28
+ });
29
+ });
30
+
31
+ test("kann mit locatedBy markiert werden (für ad-hoc Cases)", () => {
32
+ expect(createTimestampField({ locatedBy: "myTz" })).toEqual({
33
+ type: "timestamp",
34
+ required: false,
35
+ locatedBy: "myTz",
36
+ });
37
+ });
38
+
39
+ test("kann sensitive markiert sein (PII / Audit-Schutz)", () => {
40
+ const f = createTimestampField({ sensitive: true });
41
+ expect(f.sensitive).toBe(true);
42
+ });
43
+ });
44
+
45
+ describe("createTzField", () => {
46
+ test("default-Form ist nicht-required IANA-Zone-Slot", () => {
47
+ expect(createTzField()).toEqual({ type: "tz", required: false });
48
+ });
49
+
50
+ test("required + access-rules übernimmt Overrides", () => {
51
+ const f = createTzField({
52
+ required: true,
53
+ access: { read: ["Admin"] },
54
+ });
55
+ expect(f.required).toBe(true);
56
+ expect(f.access).toEqual({ read: ["Admin"] });
57
+ });
58
+ });
59
+
60
+ describe("locatedTimestamp(name) Helper", () => {
61
+ test("erzeugt korrektes Pair aus <name>At + <name>Tz mit locatedBy-Verdrahtung", () => {
62
+ const fields = locatedTimestamp("pickup");
63
+ expect(fields).toEqual({
64
+ pickupAt: { type: "timestamp", locatedBy: "pickupTz" },
65
+ pickupTz: { type: "tz" },
66
+ });
67
+ });
68
+
69
+ test("required-Override propagiert auf BEIDE Felder", () => {
70
+ const fields = locatedTimestamp("delivery", { required: true });
71
+ expect(fields).toEqual({
72
+ deliveryAt: { type: "timestamp", locatedBy: "deliveryTz", required: true },
73
+ deliveryTz: { type: "tz", required: true },
74
+ });
75
+ });
76
+
77
+ test("access-Override propagiert auf BEIDE Felder (Field-Level Read-Access)", () => {
78
+ const fields = locatedTimestamp("internal", {
79
+ access: { read: ["Dispatcher"] },
80
+ });
81
+ expect(fields).toEqual({
82
+ internalAt: {
83
+ type: "timestamp",
84
+ locatedBy: "internalTz",
85
+ access: { read: ["Dispatcher"] },
86
+ },
87
+ internalTz: { type: "tz", access: { read: ["Dispatcher"] } },
88
+ });
89
+ });
90
+
91
+ test("locatedBy-Marker zeigt immer auf das EIGENE Tz-Feld (nicht auf einen anderen Namen)", () => {
92
+ // Das ist der Kern des Patterns — wenn die zwei Felder nicht
93
+ // konsistent verdrahtet sind, fliegt der Boot-Validator (kommt in
94
+ // späterer Iteration). Hier prüfen wir die Helper-Garantie.
95
+ for (const name of ["a", "x_y", "long_field_name"]) {
96
+ const fields = locatedTimestamp(name);
97
+ const at = fields[`${name}At`];
98
+ if (!at || at.type !== "timestamp") throw new Error("at field missing");
99
+ expect(at.locatedBy).toBe(`${name}Tz`);
100
+ }
101
+ });
102
+
103
+ test("Spread in createEntity-fields Kompositions-tauglich", () => {
104
+ // Realer Use-Case: pickup + delivery in einer Entity, plus normale Felder.
105
+ const entityFields = {
106
+ ...locatedTimestamp("pickup"),
107
+ ...locatedTimestamp("delivery"),
108
+ // Kein Konflikt zwischen den beiden Pairs.
109
+ };
110
+ expect(Object.keys(entityFields).sort()).toEqual([
111
+ "deliveryAt",
112
+ "deliveryTz",
113
+ "pickupAt",
114
+ "pickupTz",
115
+ ]);
116
+ });
117
+ });
118
+
119
+ describe("createLocatedTimestampField (Phase A — atomarer Field-Type)", () => {
120
+ test("default-Form ist nicht-required mit type 'locatedTimestamp'", () => {
121
+ expect(createLocatedTimestampField()).toEqual({
122
+ type: "locatedTimestamp",
123
+ required: false,
124
+ });
125
+ });
126
+
127
+ test("required-Override propagiert", () => {
128
+ expect(createLocatedTimestampField({ required: true })).toEqual({
129
+ type: "locatedTimestamp",
130
+ required: true,
131
+ });
132
+ });
133
+
134
+ test("access-Override propagiert (Field-Level Read-Access)", () => {
135
+ const f = createLocatedTimestampField({ access: { read: ["Dispatcher"] } });
136
+ expect(f).toEqual({
137
+ type: "locatedTimestamp",
138
+ required: false,
139
+ access: { read: ["Dispatcher"] },
140
+ });
141
+ });
142
+
143
+ test("sensitive-Override propagiert (PII-Schutz)", () => {
144
+ const f = createLocatedTimestampField({ sensitive: true });
145
+ expect(f.sensitive).toBe(true);
146
+ });
147
+
148
+ test("ein einziges Field-Objekt — kein Pair wie der alte locatedTimestamp helper", () => {
149
+ // Die neue Form: r.entity({ pickup: createLocatedTimestampField() })
150
+ // erzeugt EIN Schema-Feld (nicht zwei). Die zwei DB-Spalten + drei
151
+ // API-Felder kommen aus dem Framework-Auto-Convert (Phase B–D).
152
+ const field = createLocatedTimestampField();
153
+ expect(field.type).toBe("locatedTimestamp");
154
+ // Keine `at`/`tz`/`utc` Sub-Felder im Schema-Object selbst.
155
+ expect("at" in field).toBe(false);
156
+ expect("tz" in field).toBe(false);
157
+ });
158
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { AnyFileFieldDef, FieldDefinition } from "../types";
3
+ import { isFileField } from "../types";
4
+
5
+ describe("isFileField()", () => {
6
+ test("accepts all four file variants", () => {
7
+ const variants: FieldDefinition[] = [
8
+ { type: "file" },
9
+ { type: "image" },
10
+ { type: "files" },
11
+ { type: "images" },
12
+ ];
13
+ for (const f of variants) expect(isFileField(f)).toBe(true);
14
+ });
15
+
16
+ test("rejects non-file variants", () => {
17
+ const variants: FieldDefinition[] = [
18
+ { type: "text" },
19
+ { type: "number" },
20
+ { type: "boolean" },
21
+ { type: "select", options: ["a", "b"] },
22
+ { type: "money" },
23
+ { type: "date" },
24
+ { type: "embedded", schema: {} },
25
+ ];
26
+ for (const f of variants) expect(isFileField(f)).toBe(false);
27
+ });
28
+
29
+ test("rejects undefined", () => {
30
+ expect(isFileField(undefined)).toBe(false);
31
+ });
32
+
33
+ test("narrows the type so readers can access file-specific props", () => {
34
+ const field: FieldDefinition | undefined = {
35
+ type: "file",
36
+ maxSize: "5mb",
37
+ accept: ["image/*"],
38
+ };
39
+ if (isFileField(field)) {
40
+ // If the TypeGuard didn't narrow, this would not compile.
41
+ const narrowed: AnyFileFieldDef = field;
42
+ expect(narrowed.maxSize).toBe("5mb");
43
+ expect(narrowed.accept).toEqual(["image/*"]);
44
+ } else {
45
+ throw new Error("isFileField should have narrowed to AnyFileFieldDef");
46
+ }
47
+ });
48
+ });
@@ -0,0 +1,132 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { z } from "zod";
3
+ import { createEntity, createRegistry, defineFeature, HookPhases } from "../index";
4
+ import type { PostSaveHookFn } from "../types";
5
+
6
+ // These tests lock in the phase defaults + filtering so the invariants survive
7
+ // refactors. Behavior under test:
8
+ // - r.hook("postSave", ...) defaults to afterCommit
9
+ // - r.hook("postSave", ..., { phase: inTransaction }) routes to the inTx bucket
10
+ // - r.hook("preDelete", ...) always lands in inTransaction (no option)
11
+ // - Registry getters filter by phase when asked, return all otherwise
12
+
13
+ const noopSave: PostSaveHookFn = async () => undefined;
14
+
15
+ describe("HookPhases defaults", () => {
16
+ test("postSave hook without options defaults to afterCommit phase", () => {
17
+ const feature = defineFeature("test", (r) => {
18
+ r.entity("thing", createEntity({ table: "things", fields: {} }));
19
+ r.writeHandler("thing:create", z.object({}), async () => ({ isSuccess: true, data: null }), {
20
+ access: { openToAll: true },
21
+ });
22
+ r.hook("postSave", "thing:create", noopSave);
23
+ });
24
+
25
+ const entry = feature.hooks.postSave["thing:create"];
26
+ expect(entry).toHaveLength(1);
27
+ expect(entry?.[0]?.phase).toBe(HookPhases.afterCommit);
28
+ });
29
+
30
+ test("postSave hook respects explicit phase option", () => {
31
+ const feature = defineFeature("test", (r) => {
32
+ r.entity("thing", createEntity({ table: "things", fields: {} }));
33
+ r.writeHandler("thing:create", z.object({}), async () => ({ isSuccess: true, data: null }), {
34
+ access: { openToAll: true },
35
+ });
36
+ r.hook("postSave", "thing:create", noopSave, { phase: HookPhases.inTransaction });
37
+ });
38
+
39
+ const entry = feature.hooks.postSave["thing:create"];
40
+ expect(entry?.[0]?.phase).toBe(HookPhases.inTransaction);
41
+ });
42
+
43
+ test("preDelete hook is always inTransaction (no option)", () => {
44
+ const feature = defineFeature("test", (r) => {
45
+ r.entity("thing", createEntity({ table: "things", fields: {} }));
46
+ r.writeHandler(
47
+ "thing:delete",
48
+ z.object({ id: z.uuid() }),
49
+ async () => ({
50
+ isSuccess: true,
51
+ data: null,
52
+ }),
53
+ { access: { openToAll: true } },
54
+ );
55
+ r.hook("preDelete", "thing:delete", async () => undefined);
56
+ });
57
+
58
+ const entry = feature.hooks.preDelete["thing:delete"];
59
+ expect(entry?.[0]?.phase).toBe(HookPhases.inTransaction);
60
+ });
61
+
62
+ test("entityHook postSave defaults to afterCommit, preDelete is forced inTransaction", () => {
63
+ const feature = defineFeature("test", (r) => {
64
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
65
+ r.entityHook("postSave", thing, noopSave);
66
+ r.entityHook("preDelete", thing, async () => undefined);
67
+ });
68
+
69
+ expect(feature.entityHooks.postSave["thing"]?.[0]?.phase).toBe(HookPhases.afterCommit);
70
+ expect(feature.entityHooks.preDelete["thing"]?.[0]?.phase).toBe(HookPhases.inTransaction);
71
+ });
72
+ });
73
+
74
+ describe("Registry phase filtering", () => {
75
+ test("getPostSaveHooks filters by phase when given", () => {
76
+ const inTxFn: PostSaveHookFn = async () => undefined;
77
+ const afterFn: PostSaveHookFn = async () => undefined;
78
+
79
+ const feature = defineFeature("test", (r) => {
80
+ r.entity("thing", createEntity({ table: "things", fields: {} }));
81
+ r.writeHandler("thing:create", z.object({}), async () => ({ isSuccess: true, data: null }), {
82
+ access: { openToAll: true },
83
+ });
84
+ r.hook("postSave", "thing:create", inTxFn, { phase: HookPhases.inTransaction });
85
+ r.hook("postSave", "thing:create", afterFn); // default afterCommit
86
+ });
87
+
88
+ const registry = createRegistry([feature]);
89
+ const handlerQn = "test:write:thing:create";
90
+
91
+ const inTxOnly = registry.getPostSaveHooks(handlerQn, HookPhases.inTransaction);
92
+ const afterOnly = registry.getPostSaveHooks(handlerQn, HookPhases.afterCommit);
93
+ const all = registry.getPostSaveHooks(handlerQn);
94
+
95
+ expect(inTxOnly).toHaveLength(1);
96
+ expect(inTxOnly[0]).toBe(inTxFn);
97
+ expect(afterOnly).toHaveLength(1);
98
+ expect(afterOnly[0]).toBe(afterFn);
99
+ expect(all).toHaveLength(2);
100
+ });
101
+
102
+ test("getPostSaveHooks returns empty array when no hooks for handler", () => {
103
+ const feature = defineFeature("test", (r) => {
104
+ r.entity("thing", createEntity({ table: "things", fields: {} }));
105
+ r.writeHandler("thing:create", z.object({}), async () => ({ isSuccess: true, data: null }), {
106
+ access: { openToAll: true },
107
+ });
108
+ });
109
+
110
+ const registry = createRegistry([feature]);
111
+ expect(registry.getPostSaveHooks("test:write:thing:create")).toEqual([]);
112
+ expect(registry.getPostSaveHooks("test:write:thing:create", HookPhases.inTransaction)).toEqual(
113
+ [],
114
+ );
115
+ });
116
+
117
+ test("getEntityPostSaveHooks filters by phase", () => {
118
+ const inTxFn: PostSaveHookFn = async () => undefined;
119
+ const afterFn: PostSaveHookFn = async () => undefined;
120
+
121
+ const feature = defineFeature("test", (r) => {
122
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
123
+ r.entityHook("postSave", thing, inTxFn, { phase: HookPhases.inTransaction });
124
+ r.entityHook("postSave", thing, afterFn);
125
+ });
126
+
127
+ const registry = createRegistry([feature]);
128
+ expect(registry.getEntityPostSaveHooks("thing", HookPhases.inTransaction)).toEqual([inTxFn]);
129
+ expect(registry.getEntityPostSaveHooks("thing", HookPhases.afterCommit)).toEqual([afterFn]);
130
+ expect(registry.getEntityPostSaveHooks("thing")).toHaveLength(2);
131
+ });
132
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { parseTenantId } from "../types/identifiers";
3
+
4
+ describe("parseTenantId", () => {
5
+ test("accepts canonical lowercase UUIDs", () => {
6
+ expect(parseTenantId("00000000-0000-4000-8000-000000000001")).toBe(
7
+ "00000000-0000-4000-8000-000000000001",
8
+ );
9
+ });
10
+
11
+ test("rejects non-UUID strings", () => {
12
+ expect(parseTenantId("not-a-uuid")).toBeNull();
13
+ expect(parseTenantId("acme")).toBeNull();
14
+ expect(parseTenantId("")).toBeNull();
15
+ });
16
+
17
+ test("rejects SQL-injection-shaped probes", () => {
18
+ expect(parseTenantId("'; DROP TABLE tenants; --")).toBeNull();
19
+ expect(parseTenantId("../../../etc/passwd")).toBeNull();
20
+ });
21
+
22
+ test("rejects uppercase UUIDs (canonical form is lowercase)", () => {
23
+ // The framework writes lowercase everywhere — accepting uppercase here
24
+ // would let two different strings produce the same logical tenant and
25
+ // diverge on string-equality checks downstream.
26
+ expect(parseTenantId("00000000-0000-4000-8000-00000000000A")).toBeNull();
27
+ });
28
+
29
+ test("rejects non-string inputs", () => {
30
+ expect(parseTenantId(undefined)).toBeNull();
31
+ expect(parseTenantId(null)).toBeNull();
32
+ expect(parseTenantId(123)).toBeNull();
33
+ expect(parseTenantId({})).toBeNull();
34
+ });
35
+ });
@@ -0,0 +1,237 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { z } from "zod";
3
+ import { createEntity, createRegistry, defineFeature } from "../index";
4
+ import type { PostSaveHookFn, PreDeleteHookFn, PreSaveHookFn, SaveContext } from "../types";
5
+
6
+ const stubHandler = async () => ({ isSuccess: true as const, data: null });
7
+
8
+ describe("lifecycle hook registration", () => {
9
+ test("preSave hooks are registered", () => {
10
+ const feature = defineFeature("test", (r) => {
11
+ r.entity("user", createEntity({ table: "Users", fields: {} }));
12
+ r.hook("preSave", "user", async (changes) => {
13
+ changes["email"] = (changes["email"] as string).toLowerCase();
14
+ return changes;
15
+ });
16
+ });
17
+
18
+ expect(Object.keys(feature.hooks.preSave)).toContain("user");
19
+ });
20
+
21
+ test("postSave hooks are registered", () => {
22
+ const feature = defineFeature("test", (r) => {
23
+ r.hook("postSave", "user", async () => {});
24
+ });
25
+
26
+ expect(Object.keys(feature.hooks.postSave)).toContain("user");
27
+ });
28
+
29
+ test("preDelete hooks are registered", () => {
30
+ const feature = defineFeature("test", (r) => {
31
+ r.hook("preDelete", "user", async () => {});
32
+ });
33
+
34
+ expect(Object.keys(feature.hooks.preDelete)).toContain("user");
35
+ });
36
+
37
+ test("postDelete hooks are registered", () => {
38
+ const feature = defineFeature("test", (r) => {
39
+ r.hook("postDelete", "user", async () => {});
40
+ });
41
+
42
+ expect(Object.keys(feature.hooks.postDelete)).toContain("user");
43
+ });
44
+
45
+ test("multiple hooks on same entity are collected in order", () => {
46
+ const feature = defineFeature("test", (r) => {
47
+ r.hook("preSave", "user", async (changes) => changes);
48
+ r.hook("preSave", "user", async (changes) => changes);
49
+ });
50
+
51
+ const hooks = feature.hooks.preSave["user"];
52
+ expect(hooks).toHaveLength(2);
53
+ });
54
+
55
+ test("validation hooks still work alongside lifecycle hooks", () => {
56
+ const feature = defineFeature("test", (r) => {
57
+ r.hook("validation", "userForm", () => null);
58
+ r.hook("preSave", "user", async (changes) => changes);
59
+ r.hook("postSave", "user", async () => {});
60
+ });
61
+
62
+ expect(feature.hooks.validation["userForm"]).toBeDefined();
63
+ expect(feature.hooks.preSave["user"]).toHaveLength(1);
64
+ expect(feature.hooks.postSave["user"]).toHaveLength(1);
65
+ });
66
+ });
67
+
68
+ describe("lifecycle hooks in registry", () => {
69
+ test("merges preSave hooks within same feature", () => {
70
+ const f1 = defineFeature("a", (r) => {
71
+ r.entity("user", createEntity({ table: "Users", fields: {} }));
72
+ r.writeHandler("user", z.object({}), stubHandler, { access: { openToAll: true } });
73
+ r.hook("preSave", "user", async (changes) => changes);
74
+ r.hook("preSave", "user", async (changes) => changes);
75
+ });
76
+
77
+ const registry = createRegistry([f1]);
78
+ expect(registry.getPreSaveHooks("a:write:user")).toHaveLength(2);
79
+ });
80
+
81
+ test("cross-feature hooks use full prefixed name", () => {
82
+ const f1 = defineFeature("a", (r) => {
83
+ r.entity("user", createEntity({ table: "Users", fields: {} }));
84
+ r.writeHandler("user", z.object({}), stubHandler, { access: { openToAll: true } });
85
+ r.hook("postSave", "user", async () => {});
86
+ });
87
+ // Feature b hooks into a.user by using the full prefixed name
88
+ const f2 = defineFeature("b", (r) => {
89
+ r.writeHandler("a.user", z.object({}), stubHandler, { access: { openToAll: true } });
90
+ r.hook("postSave", "a.user", async () => {});
91
+ r.hook("postSave", "a.user", async () => {});
92
+ });
93
+
94
+ const registry = createRegistry([f1, f2]);
95
+ // f1 registers as "a:write:user", f2 registers as "b:write:a-user" — different keys
96
+ expect(registry.getPostSaveHooks("a:write:user")).toHaveLength(1);
97
+ expect(registry.getPostSaveHooks("b:write:a-user")).toHaveLength(2);
98
+ });
99
+
100
+ test("returns empty array for entity without hooks", () => {
101
+ const feature = defineFeature("test", (r) => {
102
+ r.entity("user", createEntity({ table: "Users", fields: {} }));
103
+ });
104
+
105
+ const registry = createRegistry([feature]);
106
+ expect(registry.getPreSaveHooks("test:write:user")).toEqual([]);
107
+ expect(registry.getPostSaveHooks("test:write:user")).toEqual([]);
108
+ expect(registry.getPreDeleteHooks("test:write:user")).toEqual([]);
109
+ expect(registry.getPostDeleteHooks("test:write:user")).toEqual([]);
110
+ expect(registry.getPreQueryHooks("test:query:user")).toEqual([]);
111
+ });
112
+ });
113
+
114
+ describe("preSave hook behavior", () => {
115
+ test("preSave receives changes and can modify them", async () => {
116
+ const hook: PreSaveHookFn = async (changes, ctx) => {
117
+ expect(ctx.isNew).toBe(false);
118
+ expect(ctx.previous["email"]).toBe("old@test.de");
119
+ return { ...changes, email: (changes["email"] as string).toLowerCase() };
120
+ };
121
+
122
+ const result = await hook(
123
+ { email: "MARC@TEST.DE" },
124
+ { previous: { email: "old@test.de" }, isNew: false },
125
+ );
126
+ expect(result["email"]).toBe("marc@test.de");
127
+ });
128
+
129
+ test("preSave knows if it is a create (isNew=true)", async () => {
130
+ let wasNew: boolean | undefined;
131
+ const hook: PreSaveHookFn = async (changes, ctx) => {
132
+ wasNew = ctx.isNew;
133
+ return changes;
134
+ };
135
+
136
+ await hook({}, { previous: {}, isNew: true });
137
+ expect(wasNew).toBe(true);
138
+ });
139
+
140
+ test("preSave can abort by throwing", async () => {
141
+ const hook: PreSaveHookFn = async () => {
142
+ throw new Error("blocked_by_policy");
143
+ };
144
+
145
+ await expect(hook({}, { previous: {}, isNew: false })).rejects.toThrow("blocked_by_policy");
146
+ });
147
+ });
148
+
149
+ describe("postSave hook behavior", () => {
150
+ test("postSave receives full SaveContext with changes and previous", async () => {
151
+ let received: SaveContext | undefined;
152
+ const hook: PostSaveHookFn = async (result) => {
153
+ received = result;
154
+ };
155
+
156
+ await hook(
157
+ {
158
+ kind: "save",
159
+ id: 42,
160
+ data: { email: "new@test.de", status: "Started" },
161
+ changes: { status: "Started" },
162
+ previous: { email: "new@test.de", status: "Draft" },
163
+ isNew: false,
164
+ },
165
+ {},
166
+ );
167
+
168
+ expect(received?.id).toBe(42);
169
+ expect(received?.changes["status"]).toBe("Started");
170
+ expect(received?.previous["status"]).toBe("Draft");
171
+ expect(received?.isNew).toBe(false);
172
+ });
173
+
174
+ test("postSave: detect status transition for email trigger", async () => {
175
+ let shouldSendEmail = false;
176
+
177
+ const hook: PostSaveHookFn = async (result) => {
178
+ if (result.changes["status"] === "Started" && result.previous["status"] !== "Started") {
179
+ shouldSendEmail = true;
180
+ }
181
+ };
182
+
183
+ // First time: Draft → Started → send email
184
+ await hook(
185
+ {
186
+ kind: "save",
187
+ id: 1,
188
+ data: {},
189
+ changes: { status: "Started" },
190
+ previous: { status: "Draft" },
191
+ isNew: false,
192
+ },
193
+ {},
194
+ );
195
+ expect(shouldSendEmail).toBe(true);
196
+
197
+ // Second save: Started → Started (no change) → no email
198
+ shouldSendEmail = false;
199
+ await hook(
200
+ {
201
+ kind: "save",
202
+ id: 1,
203
+ data: {},
204
+ changes: { status: "Started" },
205
+ previous: { status: "Started" },
206
+ isNew: false,
207
+ },
208
+ {},
209
+ );
210
+ expect(shouldSendEmail).toBe(false);
211
+ });
212
+ });
213
+
214
+ describe("preDelete hook behavior", () => {
215
+ test("preDelete receives full entity data before deletion", async () => {
216
+ let receivedData: Record<string, unknown> | undefined;
217
+ const hook: PreDeleteHookFn = async (payload) => {
218
+ receivedData = payload.data;
219
+ };
220
+
221
+ await hook(
222
+ { kind: "delete", id: 1, data: { email: "delete-me@test.de", hasOrders: true } },
223
+ {},
224
+ );
225
+ expect(receivedData?.["email"]).toBe("delete-me@test.de");
226
+ });
227
+
228
+ test("preDelete can abort by throwing", async () => {
229
+ const hook: PreDeleteHookFn = async (payload) => {
230
+ if (payload.data["hasOrders"]) throw new Error("has_dependencies");
231
+ };
232
+
233
+ await expect(hook({ kind: "delete", id: 1, data: { hasOrders: true } }, {})).rejects.toThrow(
234
+ "has_dependencies",
235
+ );
236
+ });
237
+ });