@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,210 @@
1
+ import { and, desc, eq, sql } from "drizzle-orm";
2
+ import type { DbConnection, DbRunner } from "../db/connection";
3
+ import {
4
+ index,
5
+ instant,
6
+ integer,
7
+ jsonb,
8
+ table as pgTable,
9
+ primaryKey,
10
+ text,
11
+ uuid,
12
+ } from "../db/dialect";
13
+ import { tableExists } from "../db/schema-inspection";
14
+ import type { TenantId } from "../engine/types";
15
+ import { pushTables } from "../stack";
16
+ import { isStreamArchived } from "./archive";
17
+ import { loadEventsAfterVersion, type StoredEvent } from "./event-store";
18
+
19
+ // Marten-aligned snapshot store. A snapshot is a point-in-time materialised
20
+ // state of an aggregate at a specific version, cached so rehydrating the
21
+ // aggregate doesn't require replaying every historical event.
22
+ //
23
+ // Read path (loadAggregateWithSnapshot):
24
+ // 1. isStreamArchived? → honour same semantics as loadAggregate
25
+ // 2. loadLatestSnapshot → state + version N (or null)
26
+ // 3. loadEventsAfterVersion(aggregate, N) → only the delta
27
+ // 4. reducer(snapshot, delta) → current state
28
+ //
29
+ // Write path: feature authors opt in via ctx.snapshotAggregate. Policy
30
+ // (every N events, every M minutes, on-demand) is a feature-level decision
31
+ // — the framework only offers the storage primitive.
32
+ //
33
+ // Schema-migration policy: NO built-in snapshot versioning. A snapshot stores
34
+ // the aggregate state in the reducer's current shape. When the reducer's
35
+ // shape changes (added field, renamed property, moved compound), invalidate
36
+ // the cache — DELETE from kumiko_snapshots WHERE aggregate_type = '...'.
37
+ // The read path then falls back to full replay (which runs the upcaster
38
+ // chain on events) until the next snapshotAggregate call. Cheaper than a
39
+ // second migration mechanism; snapshots are a perf optimisation, not a
40
+ // source of truth.
41
+ //
42
+ // Upcaster interaction: the raw API (loadAggregateWithSnapshot below) does
43
+ // NOT apply the upcaster chain on delta events — same layering as raw
44
+ // loadAggregate. The Dispatcher wraps this into ctx.loadAggregateWithSnapshot
45
+ // and runs upcastStoredEvents on the delta before calling the reducer, so
46
+ // feature authors always see current-version payloads.
47
+
48
+ export const snapshotsTable = pgTable(
49
+ "kumiko_snapshots",
50
+ {
51
+ aggregateId: uuid("aggregate_id").notNull(),
52
+ tenantId: uuid("tenant_id").notNull(),
53
+ // Kept even though (aggregate_id, version) is globally unique: the
54
+ // schema-migration invalidation mechanism (see file header) filters by
55
+ // aggregate_type, so storing it avoids a join on events just to
56
+ // invalidate snapshots.
57
+ aggregateType: text("aggregate_type").notNull(),
58
+ // The version covered by this snapshot. `loadEventsAfterVersion`
59
+ // returns events with version > this value.
60
+ version: integer("version").notNull(),
61
+ state: jsonb("state").$type<Record<string, unknown>>().notNull(),
62
+ createdAt: instant("created_at", { precision: 3 }).notNull().default(sql`now()`),
63
+ },
64
+ (t) => ({
65
+ pk: primaryKey({ columns: [t.aggregateId, t.version] }),
66
+ // Latest-snapshot lookup: WHERE aggregate_id = ? ORDER BY version DESC
67
+ // LIMIT 1. With this index the planner does one seek + backward scan
68
+ // instead of sort-and-limit.
69
+ latestIdx: index("kumiko_snapshots_latest_idx").on(t.aggregateId, t.tenantId, t.version),
70
+ }),
71
+ );
72
+
73
+ export async function createSnapshotsTable(db: DbConnection): Promise<void> {
74
+ // skip: table already exists — idempotent boot + test-setup call
75
+ if (await tableExists(db, "public.kumiko_snapshots")) return;
76
+ await pushTables(db, { kumikoSnapshots: snapshotsTable });
77
+ }
78
+
79
+ export type Snapshot<TState extends Record<string, unknown> = Record<string, unknown>> = {
80
+ readonly aggregateId: string;
81
+ readonly tenantId: TenantId;
82
+ readonly aggregateType: string;
83
+ readonly version: number;
84
+ readonly state: TState;
85
+ readonly createdAt: Temporal.Instant;
86
+ };
87
+
88
+ export type SaveSnapshotArgs = {
89
+ readonly aggregateId: string;
90
+ readonly tenantId: TenantId;
91
+ readonly aggregateType: string;
92
+ readonly version: number;
93
+ readonly state: Record<string, unknown>;
94
+ };
95
+
96
+ // Upsert-style save so re-snapshotting the same (aggregateId, version) is
97
+ // idempotent. Caller can retake a snapshot at the same version without
98
+ // bespoke error handling — useful when a feature's snapshot policy runs
99
+ // during a concurrent retake.
100
+ export async function saveSnapshot(db: DbRunner, args: SaveSnapshotArgs): Promise<void> {
101
+ await db
102
+ .insert(snapshotsTable)
103
+ .values({
104
+ aggregateId: args.aggregateId,
105
+ tenantId: args.tenantId,
106
+ aggregateType: args.aggregateType,
107
+ version: args.version,
108
+ state: args.state,
109
+ })
110
+ .onConflictDoUpdate({
111
+ target: [snapshotsTable.aggregateId, snapshotsTable.version],
112
+ set: {
113
+ state: args.state,
114
+ aggregateType: args.aggregateType,
115
+ createdAt: sql`now()`,
116
+ },
117
+ });
118
+ }
119
+
120
+ // Latest snapshot lookup. Tenant filter is belt-and-suspenders — the
121
+ // aggregate_id should already scope uniquely, but an accidentally-reused
122
+ // UUID across tenants would otherwise silently leak.
123
+ export async function loadLatestSnapshot<
124
+ TState extends Record<string, unknown> = Record<string, unknown>,
125
+ >(db: DbRunner, aggregateId: string, tenantId: TenantId): Promise<Snapshot<TState> | null> {
126
+ const rows = await db
127
+ .select()
128
+ .from(snapshotsTable)
129
+ .where(and(eq(snapshotsTable.aggregateId, aggregateId), eq(snapshotsTable.tenantId, tenantId)))
130
+ .orderBy(desc(snapshotsTable.version))
131
+ .limit(1);
132
+ const row = rows[0];
133
+ if (!row) return null;
134
+ return {
135
+ aggregateId: row.aggregateId,
136
+ tenantId: row.tenantId,
137
+ aggregateType: row.aggregateType,
138
+ version: row.version,
139
+ state: row.state as TState,
140
+ createdAt: row.createdAt,
141
+ };
142
+ }
143
+
144
+ // Reducer used to fold events onto a state. Kept narrow and pure — the
145
+ // caller supplies the shape and update rules. Mirrors the reducer shape
146
+ // feature authors already write for r.projection.apply.
147
+ export type SnapshotReducer<TState extends Record<string, unknown>> = (
148
+ state: TState,
149
+ event: StoredEvent,
150
+ ) => TState;
151
+
152
+ export type LoadAggregateWithSnapshotResult<TState extends Record<string, unknown>> = {
153
+ readonly state: TState;
154
+ readonly version: number;
155
+ readonly snapshotHit: boolean;
156
+ };
157
+
158
+ export type LoadAggregateWithSnapshotOptions = {
159
+ // Opt-in: include archived streams in the rehydrate. Default false — same
160
+ // semantics as loadAggregate / loadAggregateAsOf. Archive check is a
161
+ // single indexed lookup, so the cost stays negligible on the hot path.
162
+ readonly includeArchived?: boolean;
163
+ // Optional upcaster step: every delta event goes through this transform
164
+ // BEFORE the reducer sees it. The dispatcher wires this up with
165
+ // r.eventMigration so feature code always sees current-version payloads.
166
+ // Async to support Marten-style AsyncOnlyEventUpcaster (DB lookups).
167
+ readonly upcastEvent?: (event: StoredEvent) => Promise<StoredEvent>;
168
+ };
169
+
170
+ // Snapshot-aware rehydrate. Loads the latest snapshot (if any), applies
171
+ // events strictly newer than snapshot.version, and returns the fold.
172
+ // Callers that want strictly-event-sourced loading should stick with
173
+ // loadAggregate + reduce — this path exists for perf-critical aggregates.
174
+ //
175
+ // Archive behaviour mirrors loadAggregate: an archived stream returns
176
+ // `initial` with version=0, snapshotHit=false, unless
177
+ // { includeArchived: true } is passed. This keeps snapshot and raw
178
+ // loadAggregate interchangeable from the caller's point of view.
179
+ export async function loadAggregateWithSnapshot<TState extends Record<string, unknown>>(
180
+ db: DbRunner,
181
+ aggregateId: string,
182
+ tenantId: TenantId,
183
+ reducer: SnapshotReducer<TState>,
184
+ initial: TState,
185
+ options?: LoadAggregateWithSnapshotOptions,
186
+ ): Promise<LoadAggregateWithSnapshotResult<TState>> {
187
+ if (!options?.includeArchived) {
188
+ const archived = await isStreamArchived(db, tenantId, aggregateId);
189
+ if (archived) {
190
+ return { state: initial, version: 0, snapshotHit: false };
191
+ }
192
+ }
193
+ const snapshot = await loadLatestSnapshot<TState>(db, aggregateId, tenantId);
194
+ const baseState = snapshot ? snapshot.state : initial;
195
+ const afterVersion = snapshot ? snapshot.version : 0;
196
+ const delta = await loadEventsAfterVersion(db, aggregateId, tenantId, afterVersion);
197
+
198
+ let state = baseState;
199
+ for (const event of delta) {
200
+ const effective = options?.upcastEvent ? await options.upcastEvent(event) : event;
201
+ state = reducer(state, effective);
202
+ }
203
+ const lastDelta = delta[delta.length - 1];
204
+ const latestVersion = lastDelta ? lastDelta.version : afterVersion;
205
+ return {
206
+ state,
207
+ version: latestVersion,
208
+ snapshotHit: snapshot !== null,
209
+ };
210
+ }
@@ -0,0 +1,119 @@
1
+ // Dead-letter storage for failed event upcasters.
2
+ //
3
+ // Background: upcastStoredEvent walks a stored event's payload through
4
+ // r.eventMigration transforms until it matches the current schema. A
5
+ // migration that throws (malformed legacy payload, DB-dependent
6
+ // enrichment that fails) propagates to the dispatcher and kills the
7
+ // pass — one bad event in a million can stall every projection behind
8
+ // it. The same applies to MSP rebuild.
9
+ //
10
+ // Quarantine mode captures the failure into `kumiko_upcaster_dead_letters`,
11
+ // lets the dispatcher skip the event, and surfaces the row count via
12
+ // ops tooling. Replay (re-apply the migration after a code fix) is a
13
+ // separate CLI step — not implemented here, tracked as follow-up.
14
+
15
+ import { bigint, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
16
+ import type { DbConnection, DbRunner } from "../db/connection";
17
+ import { tableExists } from "../db/schema-inspection";
18
+ import { pushTables } from "../stack";
19
+ import type { StoredEvent } from "./event-store";
20
+
21
+ export const upcasterDeadLetterTable = pgTable(
22
+ "kumiko_upcaster_dead_letters",
23
+ {
24
+ // Surrogate PK. We don't reuse eventId so a single event can land
25
+ // here multiple times (retry attempts across deploys before the fix
26
+ // lands) without unique-violation noise.
27
+ id: bigint("id", { mode: "bigint" }).primaryKey().generatedAlwaysAsIdentity(),
28
+ // StoredEvent.id is surfaced as `string` (bigint serialised for JSON
29
+ // safety). Storing as text keeps the round-trip identity without a
30
+ // coerce step at every write site.
31
+ eventId: text("event_id").notNull(),
32
+ tenantId: uuid("tenant_id").notNull(),
33
+ aggregateId: text("aggregate_id").notNull(),
34
+ aggregateType: text("aggregate_type").notNull(),
35
+ eventType: text("event_type").notNull(),
36
+ fromVersion: integer("from_version").notNull(),
37
+ targetVersion: integer("target_version").notNull(),
38
+ errorMessage: text("error_message").notNull(),
39
+ originalPayload: jsonb("original_payload").notNull(),
40
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
41
+ },
42
+ (t) => ({
43
+ eventTypeIdx: index("upcaster_dead_letters_event_type_idx").on(t.eventType),
44
+ createdAtIdx: index("upcaster_dead_letters_created_at_idx").on(t.createdAt),
45
+ }),
46
+ );
47
+
48
+ // Idempotent table-create. Called from setupTestStack for suites that
49
+ // exercise the quarantine path; production boot uses drizzle-kit push.
50
+ export async function createUpcasterDeadLetterTable(db: DbConnection): Promise<void> {
51
+ // skip: table already exists — bootstrap called from multiple paths
52
+ if (await tableExists(db, "public.kumiko_upcaster_dead_letters")) return;
53
+ await pushTables(db, { kumikoUpcasterDeadLetters: upcasterDeadLetterTable });
54
+ }
55
+
56
+ // Writes a dead-letter row. Called by upcastStoredEvent when errorPolicy
57
+ // is "quarantine" and a transform threw. Returns the inserted row id —
58
+ // ops tooling uses it for correlate-and-replay flows.
59
+ export async function recordUpcasterDeadLetter(
60
+ db: DbRunner,
61
+ args: {
62
+ event: StoredEvent;
63
+ fromVersion: number;
64
+ targetVersion: number;
65
+ error: unknown;
66
+ },
67
+ ): Promise<void> {
68
+ const message = args.error instanceof Error ? args.error.message : String(args.error);
69
+ await db.insert(upcasterDeadLetterTable).values({
70
+ eventId: args.event.id,
71
+ tenantId: args.event.tenantId,
72
+ aggregateId: args.event.aggregateId,
73
+ aggregateType: args.event.aggregateType,
74
+ eventType: args.event.type,
75
+ fromVersion: args.fromVersion,
76
+ targetVersion: args.targetVersion,
77
+ errorMessage: message,
78
+ originalPayload: args.event.payload,
79
+ });
80
+ }
81
+
82
+ export type DeadLetterRow = {
83
+ readonly id: bigint;
84
+ readonly eventId: string;
85
+ readonly tenantId: string;
86
+ readonly aggregateId: string;
87
+ readonly aggregateType: string;
88
+ readonly eventType: string;
89
+ readonly fromVersion: number;
90
+ readonly targetVersion: number;
91
+ readonly errorMessage: string;
92
+ readonly originalPayload: Record<string, unknown>;
93
+ readonly createdAt: Date;
94
+ };
95
+
96
+ // Ops-side query. Loads recent failures, optionally scoped by event-type
97
+ // to triage a single broken migration without pulling the full table.
98
+ export async function listDeadLetters(
99
+ db: DbConnection,
100
+ options: { eventType?: string; limit?: number } = {},
101
+ ): Promise<readonly DeadLetterRow[]> {
102
+ const { desc, eq } = await import("drizzle-orm");
103
+ const limit = options.limit ?? 100;
104
+ const eventType = options.eventType;
105
+ const rows =
106
+ eventType !== undefined
107
+ ? await db
108
+ .select()
109
+ .from(upcasterDeadLetterTable)
110
+ .where(eq(upcasterDeadLetterTable.eventType, eventType))
111
+ .orderBy(desc(upcasterDeadLetterTable.createdAt))
112
+ .limit(limit)
113
+ : await db
114
+ .select()
115
+ .from(upcasterDeadLetterTable)
116
+ .orderBy(desc(upcasterDeadLetterTable.createdAt))
117
+ .limit(limit);
118
+ return rows as readonly DeadLetterRow[];
119
+ }
@@ -0,0 +1,147 @@
1
+ import type { DbRunner } from "../db";
2
+ import type { EventUpcastCtx, EventUpcastFn, TenantId } from "../engine/types";
3
+ import type { StoredEvent } from "./event-store";
4
+ import { recordUpcasterDeadLetter } from "./upcaster-dead-letter";
5
+
6
+ // Error-handling contract for the upcast pass.
7
+ //
8
+ // throw — legacy behaviour: the pass aborts, the dispatcher retries,
9
+ // a permanently broken payload eventually dead-letters at
10
+ // the consumer level after maxAttempts retries. Pick this
11
+ // when every event must land exactly once and "skip" is
12
+ // never acceptable.
13
+ //
14
+ // quarantine — the failing event is written to
15
+ // `kumiko_upcaster_dead_letters` with the error + original
16
+ // payload and REMOVED from the returned list. The
17
+ // dispatcher skips it cleanly; ops tooling replays after
18
+ // the code fix. Pick this for projections where a single
19
+ // unrenderable historic event shouldn't block the rest
20
+ // of the stream.
21
+ export type UpcasterErrorPolicy = "throw" | "quarantine";
22
+
23
+ export type UpcastOptions = {
24
+ readonly errorPolicy?: UpcasterErrorPolicy;
25
+ };
26
+
27
+ // Event schema evolution (Marten-style upcaster). An event's stored payload
28
+ // stays immutable on disk; when a feature bumps the event version and
29
+ // registers step-wise r.eventMigration transforms, reads walk older events
30
+ // through the chain until the payload matches the current shape.
31
+ //
32
+ // Sync transforms cost O(version_gap) plain JSON rewrites — hot path on
33
+ // projection rebuild stays cheap. Async transforms (Marten's
34
+ // AsyncOnlyEventUpcaster) for DB-enrichment are supported via the same
35
+ // signature: return Promise<unknown>, the framework awaits unconditionally.
36
+ // Sync transforms still pay only the await-microtask overhead.
37
+
38
+ export type EventUpcasters = ReadonlyMap<
39
+ string,
40
+ { readonly currentVersion: number; readonly chain: ReadonlyMap<number, EventUpcastFn> }
41
+ >;
42
+
43
+ // Upcast a single stored event through however many registered migrations
44
+ // separate its stored eventVersion from the current schema version.
45
+ //
46
+ // Contract:
47
+ // - Event types with no registered upcaster pass through unchanged.
48
+ // - Event types whose stored version equals currentVersion pass through
49
+ // unchanged (fast path — hot on projection rebuild).
50
+ // - Gaps in the chain are a hard error. The registry validates chain
51
+ // completeness at boot, so this throw is a belt-and-suspenders signal
52
+ // that something wrote a version number the registry doesn't expect.
53
+ //
54
+ // `ctx` carries db + tenantId for async upcasters that need DB enrichment.
55
+ // Sync transforms ignore ctx entirely.
56
+ // Legacy throw-on-error API — preserved so existing callers (projection-
57
+ // rebuild, msp-rebuild, feature tests) stay unchanged. Returns a
58
+ // StoredEvent (never null); quarantine mode lives on the bulk helper.
59
+ export async function upcastStoredEvent(
60
+ event: StoredEvent,
61
+ upcasters: EventUpcasters,
62
+ ctx: EventUpcastCtx,
63
+ ): Promise<StoredEvent> {
64
+ const result = await upcastStoredEventWithPolicy(event, upcasters, ctx, {
65
+ errorPolicy: "throw",
66
+ });
67
+ // `throw` mode can never return null — the catch-block rethrows. Narrow
68
+ // the type for callers without an `if (result === null)` check at every
69
+ // callsite.
70
+ if (result === null) {
71
+ throw new Error(
72
+ `unreachable: upcastStoredEvent with errorPolicy="throw" returned null for "${event.type}"`,
73
+ );
74
+ }
75
+ return result;
76
+ }
77
+
78
+ // Underlying policy-aware worker. Returns null when the transform threw
79
+ // AND errorPolicy="quarantine" — the event gets recorded in dead-letters
80
+ // and the bulk helper filters it out.
81
+ async function upcastStoredEventWithPolicy(
82
+ event: StoredEvent,
83
+ upcasters: EventUpcasters,
84
+ ctx: EventUpcastCtx,
85
+ options: UpcastOptions,
86
+ ): Promise<StoredEvent | null> {
87
+ const info = upcasters.get(event.type);
88
+ if (!info) return event;
89
+ if (event.eventVersion >= info.currentVersion) return event;
90
+
91
+ let payload = event.payload as unknown;
92
+ let v = event.eventVersion;
93
+ const startVersion = event.eventVersion;
94
+ while (v < info.currentVersion) {
95
+ const transform = info.chain.get(v);
96
+ if (!transform) {
97
+ // Missing chain is a boot-validator bug, not a data problem —
98
+ // always throw regardless of policy so the gap gets fixed rather
99
+ // than silently rotting every affected event into dead-letters.
100
+ throw new Error(
101
+ `Missing upcaster for event "${event.type}" v${v} → v${v + 1}. ` +
102
+ `The registry should have caught this at boot — check the eventUpcasterMap wiring.`,
103
+ );
104
+ }
105
+ try {
106
+ payload = await transform(payload, ctx);
107
+ v++;
108
+ } catch (err) {
109
+ if (options.errorPolicy === "quarantine") {
110
+ await recordUpcasterDeadLetter(ctx.db, {
111
+ event,
112
+ fromVersion: startVersion,
113
+ targetVersion: info.currentVersion,
114
+ error: err,
115
+ });
116
+ return null;
117
+ }
118
+ throw err;
119
+ }
120
+ }
121
+ return {
122
+ ...event,
123
+ payload: payload as Record<string, unknown>, // @cast-boundary engine-payload
124
+ eventVersion: v,
125
+ };
126
+ }
127
+
128
+ export async function upcastStoredEvents(
129
+ events: readonly StoredEvent[],
130
+ upcasters: EventUpcasters,
131
+ ctx: EventUpcastCtx,
132
+ options: UpcastOptions = {},
133
+ ): Promise<readonly StoredEvent[]> {
134
+ // skip: no upcasters registered anywhere — common case when a project
135
+ // hasn't bumped any event version yet. Short-circuit keeps replay fast.
136
+ if (upcasters.size === 0) return events;
137
+ const results = await Promise.all(
138
+ events.map((e) => upcastStoredEventWithPolicy(e, upcasters, ctx, options)),
139
+ );
140
+ return results.filter((e): e is StoredEvent => e !== null);
141
+ }
142
+
143
+ // Convenience builder for callers that have db + tenantId at hand and want
144
+ // to construct the ctx-arg without restating the field names everywhere.
145
+ export function makeUpcastCtx(db: DbRunner, tenantId: TenantId): EventUpcastCtx {
146
+ return { db, tenantId };
147
+ }
@@ -0,0 +1,123 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ buildContentDispositionHeader,
4
+ encodeRFC5987,
5
+ toAsciiFallback,
6
+ } from "../content-disposition";
7
+
8
+ describe("toAsciiFallback", () => {
9
+ test("keeps ASCII letters, digits, dot, dash, underscore, parens", () => {
10
+ expect(toAsciiFallback("photo_2024-01.png")).toBe("photo_2024-01.png");
11
+ expect(toAsciiFallback("report(v2).pdf")).toBe("report(v2).pdf");
12
+ });
13
+
14
+ test("collapses spaces, commas, and other non-safe chars to underscore", () => {
15
+ expect(toAsciiFallback("my photo, v2.png")).toBe("my_photo__v2.png");
16
+ });
17
+
18
+ test("strips quote characters — the core injection protection", () => {
19
+ const evil = `safe.png"; filename*=utf-8''evil.exe`;
20
+ const out = toAsciiFallback(evil);
21
+ expect(out).not.toContain('"');
22
+ expect(out).not.toContain(";");
23
+ });
24
+
25
+ test("strips backslash and path separators (directory-traversal guard)", () => {
26
+ // Dots survive (whitelisted); `/` and `\` collapse to underscore.
27
+ expect(toAsciiFallback("../../../etc/passwd")).toBe(".._.._.._etc_passwd");
28
+ expect(toAsciiFallback("C:\\Windows\\evil.exe")).toBe("C__Windows_evil.exe");
29
+ });
30
+
31
+ test("collapses non-ASCII (unicode) to underscore — one char per code unit", () => {
32
+ // 測 + 試 = 2 BMP code units → 2 underscores, then `.png` passes through.
33
+ expect(toAsciiFallback("測試.png")).toBe("__.png");
34
+ expect(toAsciiFallback("café.pdf")).toBe("caf_.pdf");
35
+ });
36
+
37
+ test("truncates at 100 chars to bound header size", () => {
38
+ const longName = `${"a".repeat(200)}.png`;
39
+ const out = toAsciiFallback(longName);
40
+ expect(out.length).toBe(100);
41
+ expect(out.startsWith("aaaa")).toBe(true);
42
+ });
43
+
44
+ test("returns 'download' for empty input or when no alphanumerics survive", () => {
45
+ // Empty stripped.
46
+ expect(toAsciiFallback("")).toBe("download");
47
+ // All non-safe chars collapsed → 12 underscores → readable default.
48
+ expect(toAsciiFallback("@@@###$$$%%%")).toBe("download");
49
+ // Mix of symbols + dots (dots whitelisted) → still no alphanumerics.
50
+ expect(toAsciiFallback("@.#.$")).toBe("download");
51
+ });
52
+ });
53
+
54
+ describe("encodeRFC5987", () => {
55
+ test("passes pure ASCII through (letters, digits)", () => {
56
+ expect(encodeRFC5987("photo.png")).toBe("photo.png");
57
+ });
58
+
59
+ test("percent-encodes UTF-8 bytes for non-ASCII", () => {
60
+ // 測 = UTF-8 E6 B8 AC → %E6%B8%AC
61
+ const out = encodeRFC5987("測");
62
+ expect(out).toBe("%E6%B8%AC");
63
+ });
64
+
65
+ test("escapes the RFC-5987 extras that encodeURIComponent leaves alone", () => {
66
+ // encodeURIComponent doesn't escape ' ( ) * — RFC 5987 requires we do.
67
+ // Each char maps to its uppercase hex code.
68
+ expect(encodeRFC5987("a'b")).toBe("a%27b");
69
+ expect(encodeRFC5987("a(b)")).toBe("a%28b%29");
70
+ expect(encodeRFC5987("a*b")).toBe("a%2Ab");
71
+ });
72
+
73
+ test("uses uppercase hex for consistency (matches RFC sample output)", () => {
74
+ expect(encodeRFC5987(" ")).toBe("%20");
75
+ expect(encodeRFC5987(";")).toBe("%3B");
76
+ });
77
+ });
78
+
79
+ describe("buildContentDispositionHeader", () => {
80
+ test("pure ASCII input produces both parameters", () => {
81
+ const header = buildContentDispositionHeader("photo.png");
82
+ expect(header).toBe(`attachment; filename="photo.png"; filename*=UTF-8''photo.png`);
83
+ });
84
+
85
+ test("unicode input survives losslessly in filename*, stripped in fallback", () => {
86
+ const header = buildContentDispositionHeader("測試.png");
87
+ // 2 BMP code units → 2 underscores in fallback, then `.png`.
88
+ expect(header).toContain(`filename="__.png"`);
89
+ // filename* carries the full UTF-8 bytes percent-encoded.
90
+ expect(header).toContain("filename*=UTF-8''%E6%B8%AC%E8%A9%A6.png");
91
+ });
92
+
93
+ test("injection attempt — header has exactly 3 semicolon-separated parts", () => {
94
+ // `"; filename*=utf-8''evil.exe` injection — sanitised header must
95
+ // still parse as a single attachment with exactly two parameters.
96
+ const header = buildContentDispositionHeader(`normal.png"; filename*=utf-8''evil.exe`);
97
+ const parts = header.split(";");
98
+ expect(parts).toHaveLength(3);
99
+ expect(parts[0]).toBe("attachment");
100
+ expect(parts[1]?.trim().startsWith("filename=")).toBe(true);
101
+ expect(parts[2]?.trim().startsWith("filename*=")).toBe(true);
102
+ });
103
+
104
+ test("fallback never leaks unquoted double-quote", () => {
105
+ // Any quote inside filename="..." would close the string early and
106
+ // let the tail parse as new parameters. Proof: the fallback value
107
+ // (the chars between the first two quotes after "filename=") has
108
+ // no further quotes.
109
+ const header = buildContentDispositionHeader(`a"b"c.png`);
110
+ const match = header.match(/filename="([^"]*)"/);
111
+ expect(match).not.toBeNull();
112
+ expect(match?.[1]).not.toContain('"');
113
+ // All 3 quotes collapsed to underscore in the fallback.
114
+ expect(match?.[1]).toBe("a_b_c.png");
115
+ });
116
+
117
+ test("empty filename falls back to 'download'", () => {
118
+ const header = buildContentDispositionHeader("");
119
+ expect(header).toContain(`filename="download"`);
120
+ // Empty filename*: encodeRFC5987("") → "", so filename*=UTF-8''
121
+ expect(header).toContain(`filename*=UTF-8''`);
122
+ });
123
+ });