@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,62 @@
1
+ // In-memory file provider for unit tests. Not for production: nothing
2
+ // persists across process restarts, and memory grows with every write.
3
+ //
4
+ // Factored out of test-files so any package can opt in (samples, downstream
5
+ // feature tests) without re-inventing a Map-backed mock.
6
+
7
+ import type { FileStorageProvider } from "./types";
8
+
9
+ export type InMemoryFileProvider = FileStorageProvider & {
10
+ // Test-only introspection: keys currently stored. Useful for assertions
11
+ // like `expect(provider.keys()).toContain("tenant/foo.jpg")`.
12
+ keys(): readonly string[];
13
+ // Test-only reset between cases. beforeEach-friendly.
14
+ clear(): void;
15
+ };
16
+
17
+ type StoredEntry = {
18
+ readonly data: Uint8Array;
19
+ readonly mimeType?: string | undefined;
20
+ };
21
+
22
+ export function createInMemoryFileProvider(): InMemoryFileProvider {
23
+ const store = new Map<string, StoredEntry>();
24
+
25
+ return {
26
+ async write(key, data, mimeType) {
27
+ // Copy the buffer so the caller can reuse/mutate theirs without
28
+ // aliasing the stored bytes. Cheap for tests, predictable semantics.
29
+ store.set(key, { data: new Uint8Array(data), mimeType });
30
+ },
31
+
32
+ async read(key) {
33
+ const entry = store.get(key);
34
+ if (!entry) throw new Error(`in-memory file not found: ${key}`);
35
+ return new Uint8Array(entry.data);
36
+ },
37
+
38
+ async delete(key) {
39
+ store.delete(key);
40
+ },
41
+
42
+ async exists(key) {
43
+ return store.has(key);
44
+ },
45
+
46
+ // Deterministic fake URL — encodes the key + expiry so tests can assert
47
+ // the route wired through without running a real presigner. Shape
48
+ // (memory://<key>?expires=<seconds>) intentionally differs from any real
49
+ // provider so leakage into production would be obvious at a glance.
50
+ async getSignedUrl(key, expiresInSeconds) {
51
+ return `memory://${key}?expires=${expiresInSeconds}`;
52
+ },
53
+
54
+ keys() {
55
+ return Array.from(store.keys());
56
+ },
57
+
58
+ clear() {
59
+ store.clear();
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,29 @@
1
+ export type { FileContext, FileHandle } from "./file-handle";
2
+ // `createFileHandle` is an implementation detail — construct handles via
3
+ // `createFileContext(provider).ref(key)`, which is the AppContext surface.
4
+ export { createFileContext, deriveKey } from "./file-handle";
5
+ export { fileRefsTable } from "./file-ref-table";
6
+ export type {
7
+ FileAccessDecision,
8
+ FileAccessGuard,
9
+ FileRef,
10
+ FileRoutesOptions,
11
+ FileUploadedPayload,
12
+ } from "./file-routes";
13
+ export {
14
+ createFileRoutes,
15
+ FILE_UPLOADED_EVENT_TYPE,
16
+ fileUploadedEvent,
17
+ fileUploadedPayloadSchema,
18
+ } from "./file-routes";
19
+ export type { InMemoryFileProvider } from "./in-memory-provider";
20
+ export { createInMemoryFileProvider } from "./in-memory-provider";
21
+ export { createLocalProvider } from "./local-provider";
22
+ export { filesStorageTrackingFeature, tenantStorageUsageTable } from "./storage-tracking";
23
+ export type {
24
+ FileMetadata,
25
+ FileStorageProvider,
26
+ FileValidationOptions,
27
+ SignedUrlOptions,
28
+ } from "./types";
29
+ export { buildStorageKey, parseMaxSize, validateFile } from "./types";
@@ -0,0 +1,35 @@
1
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import type { FileStorageProvider } from "./types";
4
+
5
+ // Local-filesystem backend — intended for dev + tests. Production deploys
6
+ // pick an object-store provider (S3/R2/…). mimeType is ignored here; the
7
+ // filesystem tracks no metadata beyond what the caller stores on FileRef.
8
+ export function createLocalProvider(basePath: string): FileStorageProvider {
9
+ return {
10
+ async write(key: string, data: Uint8Array, _mimeType?: string): Promise<void> {
11
+ const filePath = join(basePath, key);
12
+ await mkdir(dirname(filePath), { recursive: true });
13
+ await writeFile(filePath, data);
14
+ },
15
+
16
+ async read(key: string): Promise<Uint8Array> {
17
+ const filePath = join(basePath, key);
18
+ return readFile(filePath);
19
+ },
20
+
21
+ async delete(key: string): Promise<void> {
22
+ const filePath = join(basePath, key);
23
+ await rm(filePath, { force: true });
24
+ },
25
+
26
+ async exists(key: string): Promise<boolean> {
27
+ try {
28
+ await stat(join(basePath, key));
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,60 @@
1
+ // Tenant storage usage — counts bytes + files per tenant from the event log.
2
+ //
3
+ // Tracking-only for Phase 1: no hard limit, no upload gatekeeping. Apps read
4
+ // the row to decide what to do (show a warning, soft-throttle, bill, …).
5
+ // Enforcement is a conscious deferred call — we want production numbers
6
+ // before picking thresholds (see core-files.md, Architektur-Entscheidung 3).
7
+ //
8
+ // The MSP is packaged as its own opt-in feature so tests that don't care
9
+ // about storage metrics don't pay for the projection-table push or the
10
+ // consumer-cursor row. Apps that want it pass filesStorageTrackingFeature
11
+ // into createApp / setupTestStack alongside their domain features.
12
+
13
+ import { sql } from "drizzle-orm";
14
+ import { bigint, instant, integer, table as pgTable, uuid } from "../db/dialect";
15
+ import { defineFeature, typedPayload } from "../engine";
16
+ import { fileUploadedEvent } from "./file-routes";
17
+
18
+ // bigint in `mode: "number"` returns a JS number (safe up to 2^53 ≈ 9e15
19
+ // bytes ≈ 8 petabytes per tenant — large enough for any practical storage
20
+ // quota). Default "bigint" mode would hand back a bigint value, which
21
+ // arithmetic on Drizzle's sql`` template would still accept but forces
22
+ // callers to remember the type.
23
+ export const tenantStorageUsageTable = pgTable("read_tenant_storage_usage", {
24
+ tenantId: uuid("tenant_id").primaryKey(),
25
+ totalBytes: bigint("total_bytes", { mode: "number" }).notNull().default(0),
26
+ fileCount: integer("file_count").notNull().default(0),
27
+ lastUpdatedAt: instant("last_updated_at").default(sql`now()`).notNull(),
28
+ });
29
+
30
+ export const filesStorageTrackingFeature = defineFeature("files-storage-tracking", (r) => {
31
+ r.multiStreamProjection({
32
+ name: "tenant-storage-usage",
33
+ table: tenantStorageUsageTable,
34
+ apply: {
35
+ [fileUploadedEvent.name]: async (event, tx) => {
36
+ const payload = typedPayload(event, fileUploadedEvent);
37
+
38
+ // UPSERT: INSERT on first upload per tenant, otherwise atomic increment.
39
+ // The SQL increment guarantees correctness under concurrent dispatcher
40
+ // runs (shouldn't happen with a single consumer, but the invariant is
41
+ // free and cheap — no reason to rely on serial delivery).
42
+ await tx
43
+ .insert(tenantStorageUsageTable)
44
+ .values({
45
+ tenantId: event.tenantId,
46
+ totalBytes: payload.size,
47
+ fileCount: 1,
48
+ })
49
+ .onConflictDoUpdate({
50
+ target: tenantStorageUsageTable.tenantId,
51
+ set: {
52
+ totalBytes: sql`${tenantStorageUsageTable.totalBytes} + ${payload.size}`,
53
+ fileCount: sql`${tenantStorageUsageTable.fileCount} + 1`,
54
+ lastUpdatedAt: sql`NOW()`,
55
+ },
56
+ });
57
+ },
58
+ },
59
+ });
60
+ });
@@ -0,0 +1,118 @@
1
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ export type FileMetadata = {
4
+ readonly fileName: string;
5
+ readonly mimeType: string;
6
+ readonly size: number;
7
+ };
8
+
9
+ // Options for `getSignedUrl`. `contentDisposition` lets the caller hint the
10
+ // browser to download-with-name vs inline-display (maps to ResponseContent-
11
+ // Disposition on S3). Keep the option-bag small and additive; provider impls
12
+ // that don't support a given hint should ignore it rather than error.
13
+ export type SignedUrlOptions = {
14
+ readonly contentDisposition?: string;
15
+ };
16
+
17
+ // Primitive storage contract: key+bytes in, bytes out. Metadata (fileName,
18
+ // mimeType, size) lives on the FileRef row — the provider only needs to
19
+ // shuttle bytes. `mimeType` on write() is a hint for providers that need a
20
+ // Content-Type header (S3/R2/…); local filesystems can ignore it.
21
+ //
22
+ // `getSignedUrl` is optional: object-store backends (S3/R2/GCS) implement it
23
+ // so clients can download directly from the provider after the server has
24
+ // checked access — offloads bandwidth and enables browser-native caching.
25
+ // Filesystem providers leave it undefined; the route then returns 501 and
26
+ // the client falls back to streaming via GET /files/:id. Callers must
27
+ // feature-detect via `typeof provider.getSignedUrl === "function"`.
28
+ export type FileStorageProvider = {
29
+ write(key: string, data: Uint8Array, mimeType?: string): Promise<void>;
30
+ read(key: string): Promise<Uint8Array>;
31
+ delete(key: string): Promise<void>;
32
+ exists(key: string): Promise<boolean>;
33
+ getSignedUrl?(key: string, expiresInSeconds: number, options?: SignedUrlOptions): Promise<string>;
34
+ };
35
+
36
+ export type FileValidationOptions = {
37
+ readonly maxSize?: string | undefined;
38
+ readonly accept?: readonly string[] | undefined;
39
+ };
40
+
41
+ export function parseMaxSize(maxSize: string): number {
42
+ const match = maxSize.match(/^(\d+)(kb|mb|gb)$/i);
43
+ if (!match) throw new Error(`Invalid maxSize format: "${maxSize}". Use e.g. "10mb", "500kb".`);
44
+ const value = Number(match[1]);
45
+ const unit = (match[2] ?? "").toLowerCase();
46
+ switch (unit) {
47
+ case "kb":
48
+ return value * 1024;
49
+ case "mb":
50
+ return value * 1024 * 1024;
51
+ case "gb":
52
+ return value * 1024 * 1024 * 1024;
53
+ default:
54
+ throw new Error(`Unknown unit: ${unit}`);
55
+ }
56
+ }
57
+
58
+ // Extension → acceptable MIME-type whitelist. Guards against a client
59
+ // uploading e.g. name="x.jpg" with mimeType="application/pdf" to slip an
60
+ // executable past the extension-only check. Kept small & conservative — add
61
+ // entries on demand rather than importing a heavyweight mime DB.
62
+ const EXTENSION_MIME_WHITELIST: Record<string, readonly string[]> = {
63
+ jpg: ["image/jpeg", "image/jpg"],
64
+ jpeg: ["image/jpeg", "image/jpg"],
65
+ png: ["image/png"],
66
+ gif: ["image/gif"],
67
+ webp: ["image/webp"],
68
+ svg: ["image/svg+xml"],
69
+ pdf: ["application/pdf"],
70
+ txt: ["text/plain"],
71
+ csv: ["text/csv", "application/csv", "text/plain"],
72
+ json: ["application/json", "text/json"],
73
+ md: ["text/markdown", "text/plain"],
74
+ };
75
+
76
+ export function validateFile(
77
+ metadata: FileMetadata,
78
+ options: FileValidationOptions,
79
+ ): string | null {
80
+ if (options.maxSize) {
81
+ const maxBytes = parseMaxSize(options.maxSize);
82
+ if (metadata.size > maxBytes) {
83
+ return `file_too_large: ${metadata.size} bytes exceeds ${options.maxSize}`;
84
+ }
85
+ }
86
+
87
+ if (options.accept && options.accept.length > 0) {
88
+ const ext = metadata.fileName.split(".").pop()?.toLowerCase();
89
+ if (!ext || !options.accept.includes(ext)) {
90
+ return `invalid_file_type: ".${ext}" is not in [${options.accept.join(", ")}]`;
91
+ }
92
+ // Extension passed the whitelist — now make sure the client-reported
93
+ // mimeType is consistent with that extension. Guards against MIME-spoofing:
94
+ // an attacker can't claim extension=jpg while actually uploading PDF bytes
95
+ // and having the mimeType reflect that.
96
+ const allowedMimes = EXTENSION_MIME_WHITELIST[ext];
97
+ if (allowedMimes && metadata.mimeType) {
98
+ const normalized = metadata.mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
99
+ if (!allowedMimes.includes(normalized)) {
100
+ return `mime_mismatch: extension ".${ext}" does not match mimeType "${metadata.mimeType}"`;
101
+ }
102
+ }
103
+ }
104
+
105
+ return null;
106
+ }
107
+
108
+ export function buildStorageKey(
109
+ tenantId: TenantId,
110
+ entityType: string,
111
+ entityId: number | string,
112
+ fieldName: string,
113
+ fileName: string,
114
+ uniqueId: string,
115
+ ): string {
116
+ const ext = fileName.split(".").pop() ?? "bin";
117
+ return `${tenantId}/${entityType}/${entityId}/${fieldName}/${uniqueId}.${ext}`;
118
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
3
+ import { createI18n } from "../index";
4
+
5
+ describe("createI18n", () => {
6
+ const adminFeature = defineFeature("adminUsers", (r) => {
7
+ r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
8
+ r.translations({
9
+ keys: {
10
+ "nav.title": { de: "Benutzer", en: "Users" },
11
+ "field.email": { de: "E-Mail", en: "Email" },
12
+ },
13
+ });
14
+ });
15
+
16
+ const profileFeature = defineFeature("userProfile", (r) => {
17
+ r.translations({
18
+ keys: {
19
+ "nav.title": { de: "Profil", en: "Profile" },
20
+ },
21
+ });
22
+ });
23
+
24
+ test("looks up translation by prefixed key and locale", () => {
25
+ const registry = createRegistry([adminFeature]);
26
+ const i18n = createI18n(registry, { defaultLocale: "de" });
27
+
28
+ // Keys are prefixed: featureName:key
29
+ expect(i18n.t("adminUsers:nav.title", "de")).toBe("Benutzer");
30
+ expect(i18n.t("adminUsers:nav.title", "en")).toBe("Users");
31
+ });
32
+
33
+ test("falls back to default locale", () => {
34
+ const registry = createRegistry([adminFeature]);
35
+ const i18n = createI18n(registry, { defaultLocale: "de" });
36
+
37
+ expect(i18n.t("adminUsers:nav.title", "fr")).toBe("Benutzer");
38
+ });
39
+
40
+ test("returns key if translation not found", () => {
41
+ const registry = createRegistry([adminFeature]);
42
+ const i18n = createI18n(registry, { defaultLocale: "de" });
43
+
44
+ expect(i18n.t("nonexistent.key", "de")).toBe("nonexistent.key");
45
+ });
46
+
47
+ test("different features have separate namespaces (no collision)", () => {
48
+ const registry = createRegistry([adminFeature, profileFeature]);
49
+ const i18n = createI18n(registry, { defaultLocale: "de" });
50
+
51
+ // Same short key, different prefix — no collision
52
+ expect(i18n.t("adminUsers:nav.title", "de")).toBe("Benutzer");
53
+ expect(i18n.t("userProfile:nav.title", "de")).toBe("Profil");
54
+ expect(i18n.t("adminUsers:field.email", "de")).toBe("E-Mail");
55
+ });
56
+
57
+ test("uses default locale when none specified", () => {
58
+ const registry = createRegistry([adminFeature]);
59
+ const i18n = createI18n(registry, { defaultLocale: "de" });
60
+
61
+ expect(i18n.t("adminUsers:nav.title")).toBe("Benutzer");
62
+ });
63
+
64
+ test("getAllKeys returns prefixed translation keys", () => {
65
+ const registry = createRegistry([adminFeature]);
66
+ const i18n = createI18n(registry, { defaultLocale: "de" });
67
+
68
+ const keys = i18n.getAllKeys();
69
+ expect(keys).toContain("adminUsers:nav.title");
70
+ expect(keys).toContain("adminUsers:field.email");
71
+ });
72
+ });
@@ -0,0 +1,29 @@
1
+ import type { Registry, TranslationKeys } from "../engine/types";
2
+
3
+ export type I18nOptions = {
4
+ defaultLocale: string;
5
+ };
6
+
7
+ export type I18n = {
8
+ t(key: string, locale?: string): string;
9
+ getAllKeys(): string[];
10
+ };
11
+
12
+ export function createI18n(registry: Registry, options: I18nOptions): I18n {
13
+ const translations: TranslationKeys = registry.getAllTranslations();
14
+ const { defaultLocale } = options;
15
+
16
+ return {
17
+ t(key: string, locale?: string): string {
18
+ const entry = translations[key];
19
+ if (!entry) return key;
20
+
21
+ const resolvedLocale = locale ?? defaultLocale;
22
+ return entry[resolvedLocale] ?? entry[defaultLocale] ?? key;
23
+ },
24
+
25
+ getAllKeys(): string[] {
26
+ return Object.keys(translations);
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,172 @@
1
+ import type { Hono } from "hono";
2
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
3
+ import { z } from "zod";
4
+ import { buildServer, type JwtHelper } from "../../api";
5
+ import { createRegistry, defineFeature, type SessionUser } from "../../engine";
6
+ import { createTestDb, createTestRedis, type TestDb, type TestRedis, TestUsers } from "../../stack";
7
+ import { waitFor } from "../../testing";
8
+ import { createJobRunner, type JobRunner } from "../job-runner";
9
+
10
+ // --- Track job executions ---
11
+
12
+ const jobExecutions: Array<{ name: string; payload: Record<string, unknown> }> = [];
13
+
14
+ // --- Features ---
15
+
16
+ // Feature A: has a write handler "orders:create"
17
+ const ordersFeature = defineFeature("orders", (r) => {
18
+ r.writeHandler(
19
+ "orders:create",
20
+ z.object({ product: z.string(), amount: z.number() }),
21
+ async (event) => {
22
+ return {
23
+ isSuccess: true,
24
+ data: { id: 1, product: event.payload.product, amount: event.payload.amount },
25
+ };
26
+ },
27
+ { access: { openToAll: true } },
28
+ );
29
+ });
30
+
31
+ // Feature B: has a job that triggers on "orders:write:orders:create" (prefixed)
32
+ const notificationsFeature = defineFeature("notifications", (r) => {
33
+ r.job(
34
+ "sendOrderConfirmation",
35
+ { trigger: { on: "orders:write:orders:create" } },
36
+ async (payload) => {
37
+ jobExecutions.push({ name: "notifications:job:send-order-confirmation", payload });
38
+ },
39
+ );
40
+ });
41
+
42
+ // Feature C: has ANOTHER job on the same event — both should fire
43
+ const analyticsFeature = defineFeature("analytics", (r) => {
44
+ // Dummy handler so the trackUser job trigger has a valid target
45
+ r.writeHandler(
46
+ "users:create",
47
+ z.object({}),
48
+ async () => ({
49
+ isSuccess: true as const,
50
+ data: null,
51
+ }),
52
+ { access: { openToAll: true } },
53
+ );
54
+
55
+ r.job("trackOrder", { trigger: { on: "orders:write:orders:create" } }, async (payload) => {
56
+ jobExecutions.push({ name: "analytics:job:track-order", payload });
57
+ });
58
+
59
+ // Job on a different event — should NOT fire on orders.create
60
+ r.job("trackUser", { trigger: { on: "analytics:write:users:create" } }, async (payload) => {
61
+ jobExecutions.push({ name: "analytics:job:track-user", payload });
62
+ });
63
+ });
64
+
65
+ // --- Setup ---
66
+
67
+ let testDb: TestDb;
68
+ let testRedis: TestRedis;
69
+ let app: Hono;
70
+ let jwt: JwtHelper;
71
+ let jobRunner: JobRunner;
72
+
73
+ const adminUser = TestUsers.admin;
74
+ const JWT_SECRET = "event-trigger-test-secret-minimum-32-chars!!";
75
+
76
+ beforeAll(async () => {
77
+ testDb = await createTestDb();
78
+ testRedis = await createTestRedis();
79
+
80
+ const registry = createRegistry([ordersFeature, notificationsFeature, analyticsFeature]);
81
+ const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
82
+
83
+ jobRunner = createJobRunner({
84
+ registry,
85
+ context: {},
86
+ redisUrl,
87
+ consumerLane: "worker",
88
+ queueNamePrefix: `kumiko-event-trigger-test-${Date.now()}`,
89
+ });
90
+
91
+ const server = buildServer({
92
+ registry,
93
+ context: {},
94
+ jwtSecret: JWT_SECRET,
95
+ dispatcherOptions: { jobRunner },
96
+ });
97
+ app = server.app;
98
+ jwt = server.jwt;
99
+
100
+ await jobRunner.start();
101
+ });
102
+
103
+ afterAll(async () => {
104
+ await jobRunner.stop();
105
+ await testDb.cleanup();
106
+ await testRedis.cleanup();
107
+ });
108
+
109
+ // --- Helpers ---
110
+
111
+ async function writeApi(user: SessionUser, type: string, payload: unknown) {
112
+ const token = await jwt.sign(user);
113
+ const res = await app.request("/api/write", {
114
+ method: "POST",
115
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
116
+ body: JSON.stringify({ type, payload }),
117
+ });
118
+ return res.json();
119
+ }
120
+
121
+ // --- Tests ---
122
+
123
+ describe("event trigger: write handler fires matching jobs", () => {
124
+ test("orders.create triggers both notification and analytics jobs", async () => {
125
+ jobExecutions.length = 0;
126
+
127
+ const result = await writeApi(adminUser, "orders:write:orders:create", {
128
+ product: "Widget",
129
+ amount: 3,
130
+ });
131
+ expect(result.isSuccess).toBe(true);
132
+
133
+ // Wait for BullMQ to process
134
+ await waitFor(() => {
135
+ const notification = jobExecutions.find(
136
+ (e) => e.name === "notifications:job:send-order-confirmation",
137
+ );
138
+ const analytics = jobExecutions.find((e) => e.name === "analytics:job:track-order");
139
+
140
+ expect(notification).toBeDefined();
141
+ expect(notification?.payload["product"]).toBe("Widget");
142
+ expect(notification?.payload["amount"]).toBe(3);
143
+
144
+ expect(analytics).toBeDefined();
145
+ expect(analytics?.payload["product"]).toBe("Widget");
146
+ });
147
+ });
148
+
149
+ test("unrelated jobs do NOT fire", async () => {
150
+ // analytics.trackUser listens on "users:create", not "orders:create"
151
+ const trackUser = jobExecutions.find((e) => e.name === "analytics:job:track-user");
152
+ expect(trackUser).toBeUndefined();
153
+ });
154
+
155
+ test("multiple orders each trigger jobs independently", async () => {
156
+ jobExecutions.length = 0;
157
+
158
+ await writeApi(adminUser, "orders:write:orders:create", { product: "A", amount: 1 });
159
+ await writeApi(adminUser, "orders:write:orders:create", { product: "B", amount: 2 });
160
+
161
+ await waitFor(() => {
162
+ const notifications = jobExecutions.filter(
163
+ (e) => e.name === "notifications:job:send-order-confirmation",
164
+ );
165
+ expect(notifications.length).toBe(2);
166
+
167
+ const products = notifications.map((e) => e.payload["product"]);
168
+ expect(products).toContain("A");
169
+ expect(products).toContain("B");
170
+ });
171
+ });
172
+ });