@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,234 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
2
+ import { z } from "zod";
3
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
4
+ import { buildDrizzleTable } from "../../db/table-builder";
5
+ import {
6
+ access,
7
+ createEntity,
8
+ createNumberField,
9
+ createSystemUser,
10
+ createTextField,
11
+ defineFeature,
12
+ } from "../../engine";
13
+ import { UnprocessableError, writeFailure } from "../../errors";
14
+ import { createEntityTable, setupTestStack, type TestStack, TestUsers } from "../../stack";
15
+
16
+ // Two entities: `bag` (outer) + `secret` (inner). The outer handler calls
17
+ // the inner via ctx.queryAs / ctx.writeAs. We verify:
18
+ // - ctx.query / ctx.write run under the CURRENT user (field-access filters)
19
+ // - ctx.queryAs(systemUser) bypasses field-access read filters
20
+ // - A writeAs inside a failing outer write rolls back with the outer tx
21
+ // - afterCommit hooks from writeAs fire exactly once on outer commit
22
+
23
+ const bagEntity = createEntity({
24
+ table: "ctx_bags",
25
+ fields: {
26
+ label: createTextField({ required: true }),
27
+ counter: createNumberField({ default: 0 }),
28
+ },
29
+ });
30
+ const bagTable = buildDrizzleTable("bag", bagEntity);
31
+
32
+ // secret has a system-only read field — proves queryAs(system) reads it,
33
+ // plain query doesn't.
34
+ const secretEntity = createEntity({
35
+ table: "ctx_secrets",
36
+ fields: {
37
+ owner: createTextField({ required: true }),
38
+ token: createTextField({
39
+ required: true,
40
+ access: { read: access.privileged, write: access.privileged },
41
+ }),
42
+ },
43
+ });
44
+ const secretTable = buildDrizzleTable("secret", secretEntity);
45
+
46
+ let stack: TestStack;
47
+ const admin = TestUsers.admin;
48
+
49
+ const afterCommitLog: string[] = [];
50
+
51
+ const bridgeFeature = defineFeature("ctxbridge", (r) => {
52
+ const bag = r.entity("bag", bagEntity);
53
+ const secret = r.entity("secret", secretEntity);
54
+
55
+ r.writeHandler(
56
+ "bag:create",
57
+ z.object({ label: z.string() }),
58
+ async (event, ctx) => {
59
+ const crud = createEventStoreExecutor(bagTable, bagEntity, { entityName: "bag" });
60
+ return crud.create(event.payload, event.user, ctx.db);
61
+ },
62
+ { access: { roles: ["Admin"] } },
63
+ );
64
+
65
+ r.writeHandler(
66
+ "secret:create",
67
+ z.object({ owner: z.string(), token: z.string() }),
68
+ async (event, ctx) => {
69
+ const crud = createEventStoreExecutor(secretTable, secretEntity, { entityName: "secret" });
70
+ return crud.create(event.payload, event.user, ctx.db);
71
+ },
72
+ { access: { roles: access.privileged } },
73
+ );
74
+
75
+ r.queryHandler(
76
+ "secret:by-owner",
77
+ z.object({ owner: z.string() }),
78
+ async (query, ctx) => {
79
+ const rows = await ctx.db.select().from(secretTable);
80
+ return (
81
+ (rows as Array<Record<string, unknown>>).find((r) => r["owner"] === query.payload.owner) ??
82
+ null
83
+ );
84
+ },
85
+ { access: { roles: access.privileged } },
86
+ );
87
+
88
+ // Outer handler: creates a bag AND (as system) creates a secret for the user.
89
+ // writeAs(system) must share the outer tx. An intentional failure in a later
90
+ // step rolls the secret back too.
91
+ r.writeHandler(
92
+ "bag:create-with-secret",
93
+ z.object({ label: z.string(), token: z.string(), fail: z.boolean().optional() }),
94
+ async (event, ctx) => {
95
+ const crud = createEventStoreExecutor(bagTable, bagEntity, { entityName: "bag" });
96
+ const created = await crud.create({ label: event.payload.label }, event.user, ctx.db);
97
+ if (!created.isSuccess) return created;
98
+
99
+ const secretRes = await ctx.writeAs(
100
+ createSystemUser(event.user.tenantId),
101
+ "ctxbridge:write:secret:create",
102
+ {
103
+ owner: event.user.id,
104
+ token: event.payload.token,
105
+ },
106
+ );
107
+ if (!secretRes.isSuccess) return secretRes;
108
+
109
+ if (event.payload.fail) {
110
+ return writeFailure(new UnprocessableError("intentional_failure"));
111
+ }
112
+
113
+ return created;
114
+ },
115
+ { access: { roles: ["Admin"] } },
116
+ );
117
+
118
+ // Handler that fetches the secret via ctx.queryAs(system) — proves the
119
+ // privileged call returns the token field even though the caller (Admin)
120
+ // couldn't read it themselves.
121
+ r.queryHandler(
122
+ "bag:peek-secret",
123
+ z.object({ owner: z.string() }),
124
+ async (query, ctx) => {
125
+ return ctx.queryAs(createSystemUser(query.user.tenantId), "ctxbridge:query:secret:by-owner", {
126
+ owner: query.payload.owner,
127
+ });
128
+ },
129
+ { access: { roles: ["Admin"] } },
130
+ );
131
+
132
+ // afterCommit hook on bag — fires once per outer commit.
133
+ r.entityHook("postSave", bag, async (result) => {
134
+ afterCommitLog.push(`bag:${result.data["label"]}`);
135
+ });
136
+
137
+ // afterCommit hook on secret — the entity targeted by the nested writeAs.
138
+ // Proves: (a) hook fires exactly once per successful writeAs, (b) hook
139
+ // does NOT fire when the outer transaction rolls back.
140
+ r.entityHook("postSave", secret, async (result) => {
141
+ afterCommitLog.push(`secret:${result.data["owner"]}`);
142
+ });
143
+ });
144
+
145
+ beforeAll(async () => {
146
+ stack = await setupTestStack({ features: [bridgeFeature] });
147
+ await createEntityTable(stack.db, bagEntity);
148
+ await createEntityTable(stack.db, secretEntity);
149
+ });
150
+
151
+ afterAll(async () => {
152
+ await stack.cleanup();
153
+ });
154
+
155
+ beforeEach(async () => {
156
+ afterCommitLog.length = 0;
157
+ await stack.db.delete(bagTable);
158
+ await stack.db.delete(secretTable);
159
+ // Clear the event-dedup cache — tests re-use entity ids (Postgres sequences
160
+ // reset, each test sees id=1). Without flushing Redis the second test hits
161
+ // a dedup hit on the same handler:id:version:phase key and the hook is
162
+ // silently skipped.
163
+ await stack.redis.flushNamespace();
164
+ });
165
+
166
+ describe("ctx.query / ctx.queryAs", () => {
167
+ test("queryAs(system) returns fields that the caller's role cannot read", async () => {
168
+ // Seed via writeAs(system) — the caller Admin can't write the token directly
169
+ const res = await stack.http.write(
170
+ "ctxbridge:write:bag:create-with-secret",
171
+ { label: "outer", token: "top-secret" },
172
+ admin,
173
+ );
174
+ expect((await res.json()).isSuccess).toBe(true);
175
+
176
+ // Fetch via ctx.queryAs(system) — token comes through because system
177
+ // satisfies the field-access read rule on `token`.
178
+ const peeked = await stack.http.queryOk<Record<string, unknown>>(
179
+ "ctxbridge:query:bag:peek-secret",
180
+ { owner: admin.id },
181
+ admin,
182
+ );
183
+ expect(peeked).toMatchObject({ owner: admin.id, token: "top-secret" });
184
+ });
185
+ });
186
+
187
+ describe("ctx.writeAs shares the outer transaction", () => {
188
+ test("failure in outer write rolls back the writeAs insert too", async () => {
189
+ const res = await stack.http.write(
190
+ "ctxbridge:write:bag:create-with-secret",
191
+ { label: "rolled-back", token: "discarded", fail: true },
192
+ admin,
193
+ );
194
+ const body = await res.json();
195
+ expect(body.isSuccess).toBe(false);
196
+
197
+ // Both tables empty — outer bag + inner secret rolled back together
198
+ const bags = await stack.db.select().from(bagTable);
199
+ const secrets = await stack.db.select().from(secretTable);
200
+ expect(bags).toHaveLength(0);
201
+ expect(secrets).toHaveLength(0);
202
+ });
203
+
204
+ test("success: both writes persist, both afterCommit hooks fire exactly once", async () => {
205
+ const res = await stack.http.write(
206
+ "ctxbridge:write:bag:create-with-secret",
207
+ { label: "committed", token: "kept" },
208
+ admin,
209
+ );
210
+ expect((await res.json()).isSuccess).toBe(true);
211
+
212
+ const bags = await stack.db.select().from(bagTable);
213
+ const secrets = await stack.db.select().from(secretTable);
214
+ expect(bags).toHaveLength(1);
215
+ expect(secrets).toHaveLength(1);
216
+
217
+ // Both entities' afterCommit hooks fire once each: bag (outer write) and
218
+ // secret (inner writeAs). Neither fires twice, even though secret was
219
+ // created through the nested bridge call.
220
+ expect(afterCommitLog.sort()).toEqual([`bag:committed`, `secret:${admin.id}`]);
221
+ });
222
+
223
+ test("rollback: inner secret hook does NOT fire when outer write fails", async () => {
224
+ const res = await stack.http.write(
225
+ "ctxbridge:write:bag:create-with-secret",
226
+ { label: "ignored", token: "discarded", fail: true },
227
+ admin,
228
+ );
229
+ expect((await res.json()).isSuccess).toBe(false);
230
+
231
+ // Both hooks must stay silent — tx rolled back, afterCommit queue dropped.
232
+ expect(afterCommitLog).toEqual([]);
233
+ });
234
+ });
@@ -0,0 +1,379 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { z } from "zod";
3
+ import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
4
+ import { createTestUser } from "../../stack";
5
+ import { createDispatcher } from "../dispatcher";
6
+
7
+ const echoFeature = defineFeature("echo", (r) => {
8
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
9
+
10
+ r.writeHandler(
11
+ "item:create",
12
+ z.object({ name: z.string().min(1) }),
13
+ async (event) => ({ isSuccess: true, data: { name: event.payload.name } }),
14
+ { access: { roles: ["Admin"] } },
15
+ );
16
+
17
+ r.queryHandler(
18
+ "item:list",
19
+ z.object({ search: z.string().optional() }),
20
+ async (query) => ({
21
+ items: [],
22
+ search: query.payload.search,
23
+ }),
24
+ { access: { openToAll: true } },
25
+ );
26
+
27
+ r.hook("validation", "item:create", (data) => {
28
+ if (data["name"] === "forbidden") return [{ field: "name", error: "forbidden_name" }];
29
+ return null;
30
+ });
31
+ });
32
+
33
+ function createTestDispatcher() {
34
+ const registry = createRegistry([echoFeature]);
35
+ return createDispatcher(registry, {});
36
+ }
37
+
38
+ // --- Dispatch: Write ---
39
+
40
+ describe("dispatcher.write", () => {
41
+ test("validates payload and calls handler", async () => {
42
+ const dispatcher = createTestDispatcher();
43
+ const result = await dispatcher.write(
44
+ "echo:write:item:create",
45
+ { name: "Test" },
46
+ createTestUser(),
47
+ );
48
+
49
+ expect(result.isSuccess).toBe(true);
50
+ if (result.isSuccess) {
51
+ expect(result.data).toEqual({ name: "Test" });
52
+ }
53
+ });
54
+
55
+ test("rejects invalid payload", async () => {
56
+ const dispatcher = createTestDispatcher();
57
+ const result = await dispatcher.write("echo:write:item:create", { name: "" }, createTestUser());
58
+
59
+ expect(result.isSuccess).toBe(false);
60
+ if (!result.isSuccess) {
61
+ expect(result.error.code).toBe("validation_error");
62
+ }
63
+ });
64
+
65
+ test("rejects unauthorized user", async () => {
66
+ const dispatcher = createTestDispatcher();
67
+ const guest = createTestUser({ roles: ["Guest"] });
68
+ const result = await dispatcher.write("echo:write:item:create", { name: "Test" }, guest);
69
+
70
+ expect(result.isSuccess).toBe(false);
71
+ if (!result.isSuccess) {
72
+ expect(result.error.code).toBe("access_denied");
73
+ }
74
+ });
75
+
76
+ test("ctx.user ist Convenience-Alias auf event.user (gleicher Wert)", async () => {
77
+ // Pinst dass der Handler auf ctx.user zugreifen kann ohne den
78
+ // typo-resistenten event.user-Pfad zu nutzen. Identitätsprüfung
79
+ // gegen denselben SessionUser — sonst ist's nicht der gleiche.
80
+ const captured: { fromEvent?: unknown; fromCtx?: unknown } = {};
81
+ const aliasFeature = defineFeature("alias", (r) => {
82
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
83
+ r.writeHandler(
84
+ "item:create",
85
+ z.object({ name: z.string() }),
86
+ async (event, ctx) => {
87
+ captured.fromEvent = event.user;
88
+ captured.fromCtx = ctx.user;
89
+ return { isSuccess: true, data: {} };
90
+ },
91
+ { access: { roles: ["Admin"] } },
92
+ );
93
+ });
94
+ const dispatcher = createDispatcher(createRegistry([aliasFeature]), {});
95
+ const user = createTestUser();
96
+ const res = await dispatcher.write("alias:write:item:create", { name: "x" }, user);
97
+ expect(res.isSuccess).toBe(true);
98
+ expect(captured.fromCtx).toBe(captured.fromEvent);
99
+ expect((captured.fromCtx as { id: number }).id).toBe(user.id);
100
+ });
101
+
102
+ test("runs validation hooks", async () => {
103
+ const dispatcher = createTestDispatcher();
104
+ const result = await dispatcher.write(
105
+ "echo:write:item:create",
106
+ { name: "forbidden" },
107
+ createTestUser(),
108
+ );
109
+
110
+ expect(result.isSuccess).toBe(false);
111
+ if (!result.isSuccess) {
112
+ expect(result.error.code).toBe("validation_error");
113
+ const fields = (result.error.details as { fields: Array<{ code: string }> }).fields;
114
+ expect(fields.some((f) => f.code === "forbidden_name")).toBe(true);
115
+ }
116
+ });
117
+
118
+ test("returns error for unknown handler", async () => {
119
+ const dispatcher = createTestDispatcher();
120
+ const result = await dispatcher.write("nonexistent", {}, createTestUser());
121
+
122
+ expect(result.isSuccess).toBe(false);
123
+ if (!result.isSuccess) {
124
+ expect(result.error.code).toBe("not_found");
125
+ }
126
+ });
127
+ });
128
+
129
+ // --- Dispatch: Query ---
130
+
131
+ describe("dispatcher.query", () => {
132
+ test("validates and calls query handler", async () => {
133
+ const dispatcher = createTestDispatcher();
134
+ const result = await dispatcher.query(
135
+ "echo:query:item:list",
136
+ { search: "hello" },
137
+ createTestUser(),
138
+ );
139
+
140
+ expect(result).toEqual({ items: [], search: "hello" });
141
+ });
142
+
143
+ test("rejects invalid query payload", async () => {
144
+ const dispatcher = createTestDispatcher();
145
+
146
+ await expect(
147
+ dispatcher.query("echo:query:item:list", { search: 123 }, createTestUser()),
148
+ ).rejects.toMatchObject({ code: "validation_error", httpStatus: 400 });
149
+ });
150
+
151
+ test("throws for unknown query handler", async () => {
152
+ const dispatcher = createTestDispatcher();
153
+
154
+ await expect(dispatcher.query("nonexistent", {}, createTestUser())).rejects.toMatchObject({
155
+ code: "not_found",
156
+ httpStatus: 404,
157
+ });
158
+ });
159
+ });
160
+
161
+ // --- Dispatch: Command (fire-and-forget) ---
162
+
163
+ describe("dispatcher.command", () => {
164
+ test("executes write handler without returning data", async () => {
165
+ const dispatcher = createTestDispatcher();
166
+ // Command uses write handlers but discards the result
167
+ await expect(
168
+ dispatcher.command("echo:write:item:create", { name: "Fire" }, createTestUser()),
169
+ ).resolves.toBeUndefined();
170
+ });
171
+
172
+ test("still validates and checks access", async () => {
173
+ const dispatcher = createTestDispatcher();
174
+ const guest = createTestUser({ roles: ["Guest"] });
175
+
176
+ await expect(
177
+ dispatcher.command("echo:write:item:create", { name: "Test" }, guest),
178
+ ).rejects.toThrow(/access/i);
179
+ });
180
+ });
181
+
182
+ // --- Dispatch: Idempotency ---
183
+
184
+ describe("dispatcher.write idempotency", () => {
185
+ test("duplicate requestId returns cached result", async () => {
186
+ const registry = createRegistry([echoFeature]);
187
+ const dispatcher = createDispatcher(
188
+ registry,
189
+ {},
190
+ {
191
+ idempotency: createMockIdempotencyGuard(),
192
+ },
193
+ );
194
+
195
+ const user = createTestUser();
196
+ const result1 = await dispatcher.write(
197
+ "echo:write:item:create",
198
+ { name: "Once" },
199
+ user,
200
+ "req-001",
201
+ );
202
+ const result2 = await dispatcher.write(
203
+ "echo:write:item:create",
204
+ { name: "Once" },
205
+ user,
206
+ "req-001",
207
+ );
208
+
209
+ expect(result1.isSuccess).toBe(true);
210
+ expect(result2.isSuccess).toBe(true);
211
+ // Same cached result — handler should only have been called once
212
+ if (result1.isSuccess && result2.isSuccess) {
213
+ expect(result2.data).toEqual(result1.data);
214
+ }
215
+ });
216
+
217
+ test("different requestIds execute separately", async () => {
218
+ const registry = createRegistry([echoFeature]);
219
+ const dispatcher = createDispatcher(
220
+ registry,
221
+ {},
222
+ {
223
+ idempotency: createMockIdempotencyGuard(),
224
+ },
225
+ );
226
+
227
+ const user = createTestUser();
228
+ const result1 = await dispatcher.write("echo:write:item:create", { name: "A" }, user, "req-a");
229
+ const result2 = await dispatcher.write("echo:write:item:create", { name: "B" }, user, "req-b");
230
+
231
+ expect(result1.isSuccess).toBe(true);
232
+ expect(result2.isSuccess).toBe(true);
233
+ if (result1.isSuccess && result2.isSuccess) {
234
+ expect(result1.data).toEqual({ name: "A" });
235
+ expect(result2.data).toEqual({ name: "B" });
236
+ }
237
+ });
238
+ });
239
+
240
+ // --- Feature-toggle gate ---
241
+
242
+ describe("dispatcher feature-gate", () => {
243
+ function toggled() {
244
+ return defineFeature("toggled", (r) => {
245
+ r.toggleable({ default: true });
246
+ r.entity("widget", createEntity({ table: "Widgets", fields: { name: createTextField() } }));
247
+ r.queryHandler("widget:list", z.object({}).passthrough(), async () => ({ items: [] }), {
248
+ access: { openToAll: true },
249
+ });
250
+ r.writeHandler(
251
+ "widget:create",
252
+ z.object({ name: z.string() }),
253
+ async (event) => ({ isSuccess: true, data: { name: event.payload.name } }),
254
+ { access: { roles: ["Admin"] } },
255
+ );
256
+ });
257
+ }
258
+
259
+ const user = createTestUser({ id: "u1", roles: ["Admin"] });
260
+
261
+ test("query of disabled feature throws FeatureDisabledError", async () => {
262
+ const registry = createRegistry([toggled()]);
263
+ const disabled = new Set<string>();
264
+ const dispatcher = createDispatcher(
265
+ registry,
266
+ {},
267
+ {
268
+ effectiveFeatures: () => {
269
+ const all = new Set(registry.features.keys());
270
+ for (const d of disabled) all.delete(d);
271
+ return all;
272
+ },
273
+ },
274
+ );
275
+
276
+ await expect(dispatcher.query("toggled:query:widget:list", {}, user)).resolves.toEqual({
277
+ items: [],
278
+ });
279
+
280
+ disabled.add("toggled");
281
+ await expect(dispatcher.query("toggled:query:widget:list", {}, user)).rejects.toThrow(
282
+ /feature toggled is disabled/,
283
+ );
284
+
285
+ disabled.delete("toggled");
286
+ await expect(dispatcher.query("toggled:query:widget:list", {}, user)).resolves.toEqual({
287
+ items: [],
288
+ });
289
+ });
290
+
291
+ test("write of disabled feature returns WriteFailure with feature_disabled reason", async () => {
292
+ const registry = createRegistry([toggled()]);
293
+ const disabled = new Set<string>(["toggled"]);
294
+ const dispatcher = createDispatcher(
295
+ registry,
296
+ {},
297
+ {
298
+ effectiveFeatures: () => {
299
+ const all = new Set(registry.features.keys());
300
+ for (const d of disabled) all.delete(d);
301
+ return all;
302
+ },
303
+ },
304
+ );
305
+
306
+ const result = await dispatcher.write("toggled:write:widget:create", { name: "x" }, user);
307
+ expect(result.isSuccess).toBe(false);
308
+ if (!result.isSuccess) {
309
+ expect(result.error.code).toBe("feature_disabled");
310
+ expect(result.error.details).toMatchObject({
311
+ reason: "feature_disabled",
312
+ feature: "toggled",
313
+ });
314
+ }
315
+ });
316
+
317
+ test("no effectiveFeatures callback → gate is pass-through", async () => {
318
+ const registry = createRegistry([toggled()]);
319
+ const dispatcher = createDispatcher(registry, {});
320
+ await expect(dispatcher.query("toggled:query:widget:list", {}, user)).resolves.toEqual({
321
+ items: [],
322
+ });
323
+ });
324
+ });
325
+
326
+ describe("write-handler shape guard", () => {
327
+ // Real footgun caught while building the publicstatus showcase: a custom
328
+ // handler that returns `{ id }` instead of `{ isSuccess: true, data: { id } }`
329
+ // used to crash the dispatcher with an obscure "internal error". The
330
+ // shape-guard turns this into a clear actionable message at the
331
+ // dispatcher boundary.
332
+
333
+ function brokenFeature() {
334
+ return defineFeature("broken", (r) => {
335
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
336
+ r.writeHandler(
337
+ "item:create",
338
+ z.object({ name: z.string() }),
339
+ // biome-ignore lint/suspicious/noExplicitAny: deliberate wrong-shape return for the test
340
+ async (event) => ({ id: "x", name: event.payload.name }) as any,
341
+ { access: { roles: ["Admin"] } },
342
+ );
343
+ });
344
+ }
345
+
346
+ test("handler returning a non-WriteResult shape → InternalError with actionable hint", async () => {
347
+ const registry = createRegistry([brokenFeature()]);
348
+ const dispatcher = createDispatcher(registry, {});
349
+ const result = await dispatcher.write(
350
+ "broken:write:item:create",
351
+ { name: "test" },
352
+ createTestUser(),
353
+ );
354
+
355
+ expect(result.isSuccess).toBe(false);
356
+ if (!result.isSuccess) {
357
+ expect(result.error.code).toBe("internal_error");
358
+ // Message points at defineWriteHandler / WriteResult-shape — that's
359
+ // the developer's actionable next step. Also surfaces in the
360
+ // dev-mode response body via error.message.
361
+ expect(result.error.message).toContain("invalid shape");
362
+ expect(result.error.message).toContain("defineWriteHandler");
363
+ }
364
+ });
365
+ });
366
+
367
+ // --- Mock helpers ---
368
+
369
+ function createMockIdempotencyGuard() {
370
+ const cache = new Map<string, string>();
371
+ return {
372
+ async check(requestId: string) {
373
+ return cache.get(requestId) ?? null;
374
+ },
375
+ async store(requestId: string, result: unknown) {
376
+ cache.set(requestId, JSON.stringify(result));
377
+ },
378
+ };
379
+ }
@@ -0,0 +1,67 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
2
+ import { createTestRedis, type TestRedis } from "../../stack";
3
+ import { createDistributedLock } from "../distributed-lock";
4
+
5
+ let testRedis: TestRedis;
6
+
7
+ beforeAll(async () => {
8
+ testRedis = await createTestRedis();
9
+ });
10
+
11
+ afterAll(async () => {
12
+ await testRedis.cleanup();
13
+ });
14
+
15
+ describe("distributed lock", () => {
16
+ test("acquire returns token on success", async () => {
17
+ const lock = createDistributedLock(testRedis.redis);
18
+ const token = await lock.acquire("test-lock-1");
19
+ expect(token).not.toBeNull();
20
+ expect(typeof token).toBe("string");
21
+ });
22
+
23
+ test("second acquire on same key fails", async () => {
24
+ const lock = createDistributedLock(testRedis.redis);
25
+ const token1 = await lock.acquire("test-lock-2");
26
+ const token2 = await lock.acquire("test-lock-2");
27
+
28
+ expect(token1).not.toBeNull();
29
+ expect(token2).toBeNull();
30
+ });
31
+
32
+ test("release allows re-acquire", async () => {
33
+ const lock = createDistributedLock(testRedis.redis);
34
+ const token = await lock.acquire("test-lock-3");
35
+ expect(token).not.toBeNull();
36
+
37
+ if (!token) throw new Error("expected token");
38
+ const released = await lock.release("test-lock-3", token);
39
+ expect(released).toBe(true);
40
+
41
+ const token2 = await lock.acquire("test-lock-3");
42
+ expect(token2).not.toBeNull();
43
+ });
44
+
45
+ test("release with wrong token fails", async () => {
46
+ const lock = createDistributedLock(testRedis.redis);
47
+ await lock.acquire("test-lock-4");
48
+
49
+ const released = await lock.release("test-lock-4", "wrong-token");
50
+ expect(released).toBe(false);
51
+ });
52
+
53
+ test("lock expires after TTL", async () => {
54
+ const lock = createDistributedLock(testRedis.redis);
55
+ await lock.acquire("test-lock-5", { ttlSeconds: 1 });
56
+
57
+ // Can't acquire immediately
58
+ expect(await lock.acquire("test-lock-5")).toBeNull();
59
+
60
+ // Wait for expiry
61
+ await new Promise((r) => setTimeout(r, 1100));
62
+
63
+ // Now we can
64
+ const token = await lock.acquire("test-lock-5");
65
+ expect(token).not.toBeNull();
66
+ });
67
+ });