@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,108 @@
1
+ // Full-stack proof for lifecycle ↔ buildServer wiring.
2
+ // Drives drain() directly — SIGTERM plumbing has its own unit test.
3
+
4
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
5
+ import { defineFeature } from "../../engine";
6
+ import { setupTestStack, type TestStack } from "../../stack";
7
+ import { sharedWidgetEntity } from "../../testing";
8
+ import { createLifecycle, type Lifecycle } from "../lifecycle";
9
+
10
+ const widgetFeature = defineFeature("lifecycle-probe", (r) => {
11
+ r.entity("widget", sharedWidgetEntity);
12
+ // One MSP ensures buildServer actually constructs an eventDispatcher —
13
+ // without a consumer, dispatcher stays undefined and we wouldn't prove
14
+ // the shutdown-hook registration at all.
15
+ r.multiStreamProjection({
16
+ name: "observer",
17
+ apply: {
18
+ "widget.created": async () => {},
19
+ },
20
+ });
21
+ });
22
+
23
+ let stack: TestStack;
24
+ let lifecycle: Lifecycle;
25
+ let hookOrder: string[];
26
+
27
+ beforeAll(async () => {
28
+ lifecycle = createLifecycle({ startReady: true });
29
+ hookOrder = [];
30
+
31
+ // Register BEFORE setupTestStack so buildServer's hook lands in the middle
32
+ // of registration order — our assertion below keys on that layout.
33
+ lifecycle.registerShutdownHook("probe-before-boot", async () => {
34
+ hookOrder.push("probe-before-boot");
35
+ });
36
+
37
+ stack = await setupTestStack({ features: [widgetFeature], lifecycle });
38
+
39
+ if (!stack.lifecycle) throw new Error("lifecycle not wired through setupTestStack");
40
+ if (!stack.eventDispatcher) throw new Error("eventDispatcher not built — MSP missing?");
41
+ });
42
+
43
+ afterEach(() => {
44
+ hookOrder.length = 0;
45
+ });
46
+
47
+ afterAll(async () => {
48
+ await stack.cleanup();
49
+ });
50
+
51
+ describe("lifecycle — /health/ready live state", () => {
52
+ test("returns 200 with state=ready before drain", async () => {
53
+ const res = await stack.app.request("/health/ready");
54
+ expect(res.status).toBe(200);
55
+ const body = (await res.json()) as {
56
+ status: string;
57
+ state: string;
58
+ uptimeSec: number;
59
+ checks: { name: string; ok: boolean }[];
60
+ };
61
+ expect(body.status).toBe("ready");
62
+ expect(body.state).toBe("ready");
63
+ expect(body.uptimeSec).toBeGreaterThanOrEqual(0);
64
+ // Depth-check proof: db + redis were both probed, both green.
65
+ expect(body.checks.map((c) => c.name).sort()).toEqual(["db", "redis"]);
66
+ expect(body.checks.every((c) => c.ok)).toBe(true);
67
+ });
68
+
69
+ test("/health stays trivial regardless of lifecycle state", async () => {
70
+ const res = await stack.app.request("/health");
71
+ expect(res.status).toBe(200);
72
+ expect(await res.json()).toEqual({ status: "ok" });
73
+ });
74
+ });
75
+
76
+ describe("lifecycle — drain wiring", () => {
77
+ test("buildServer registers eventDispatcher between caller hooks", () => {
78
+ // Order matters, not just existence: probe-before-boot was registered
79
+ // before setupTestStack, so buildServer's eventDispatcher hook must land
80
+ // AFTER it in registration order. probe-after-boot is registered in the
81
+ // next test, so it isn't in the list yet.
82
+ expect(lifecycle.hookNames()).toEqual(["probe-before-boot", "eventDispatcher"]);
83
+ });
84
+
85
+ test("drain() flips /health/ready to 503 and runs hooks LIFO", async () => {
86
+ // Second probe registered AFTER setupTestStack — landing last in
87
+ // registration order means LIFO drain runs it first.
88
+ lifecycle.registerShutdownHook("probe-after-boot", async () => {
89
+ hookOrder.push("probe-after-boot");
90
+ });
91
+
92
+ await lifecycle.drain({ timeoutMs: 2_000 });
93
+
94
+ expect(lifecycle.state()).toBe("stopped");
95
+
96
+ const res = await stack.app.request("/health/ready");
97
+ expect(res.status).toBe(503);
98
+ const body = (await res.json()) as { status: string; state: string };
99
+ expect(body.status).toBe("not_ready");
100
+ expect(body.state).toBe("stopped");
101
+
102
+ // LIFO proof: probe-after-boot ran first (last registered), then the
103
+ // dispatcher hook (registered by buildServer in the middle), then
104
+ // probe-before-boot (first registered). The dispatcher hook doesn't
105
+ // push into hookOrder, so we only see our two probes in inverse order.
106
+ expect(hookOrder).toEqual(["probe-after-boot", "probe-before-boot"]);
107
+ });
108
+ });
@@ -0,0 +1,212 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { createLifecycle } from "../lifecycle";
3
+
4
+ describe("lifecycle — state machine", () => {
5
+ test("starts in 'starting' by default", () => {
6
+ const lc = createLifecycle();
7
+ expect(lc.state()).toBe("starting");
8
+ });
9
+
10
+ test("startReady option skips 'starting'", () => {
11
+ const lc = createLifecycle({ startReady: true });
12
+ expect(lc.state()).toBe("ready");
13
+ });
14
+
15
+ test("markReady transitions starting → ready", () => {
16
+ const lc = createLifecycle();
17
+ lc.markReady();
18
+ expect(lc.state()).toBe("ready");
19
+ });
20
+
21
+ test("markReady from 'ready' is a no-op", () => {
22
+ const lc = createLifecycle({ startReady: true });
23
+ const listener = vi.fn();
24
+ lc.onStateChange(listener);
25
+ lc.markReady();
26
+ expect(listener).not.toHaveBeenCalled();
27
+ expect(lc.state()).toBe("ready");
28
+ });
29
+
30
+ test("drain runs ready → draining → stopped once, second drain is a no-op", async () => {
31
+ const lc = createLifecycle({ startReady: true });
32
+ const transitions: string[] = [];
33
+ lc.onStateChange((_, to) => transitions.push(to));
34
+ await lc.drain({ timeoutMs: 100 });
35
+ expect(lc.state()).toBe("stopped");
36
+ expect(transitions).toEqual(["draining", "stopped"]);
37
+
38
+ // Second drain is a no-op — hooks must not run twice on double-SIGTERM.
39
+ await lc.drain({ timeoutMs: 100 });
40
+ expect(transitions).toEqual(["draining", "stopped"]);
41
+ });
42
+
43
+ test("markReady after drain is a no-op (state=stopped sticks)", async () => {
44
+ const lc = createLifecycle();
45
+ lc.markReady();
46
+ await lc.drain({ timeoutMs: 50 });
47
+ expect(lc.state()).toBe("stopped");
48
+
49
+ // Late markReady must not resurrect the lifecycle.
50
+ lc.markReady();
51
+ expect(lc.state()).toBe("stopped");
52
+ });
53
+ });
54
+
55
+ describe("lifecycle — shutdown hooks", () => {
56
+ test("hooks run in LIFO order", async () => {
57
+ const lc = createLifecycle({ startReady: true });
58
+ const calls: string[] = [];
59
+ lc.registerShutdownHook("first", async () => {
60
+ calls.push("first");
61
+ });
62
+ lc.registerShutdownHook("second", async () => {
63
+ calls.push("second");
64
+ });
65
+ lc.registerShutdownHook("third", async () => {
66
+ calls.push("third");
67
+ });
68
+ await lc.drain({ timeoutMs: 100 });
69
+ expect(calls).toEqual(["third", "second", "first"]);
70
+ });
71
+
72
+ test("one failing hook does not block the others, and the error is logged", async () => {
73
+ const logger = { error: vi.fn() };
74
+ const lc = createLifecycle({ startReady: true, logger });
75
+ const calls: string[] = [];
76
+ lc.registerShutdownHook("healthy-a", async () => {
77
+ calls.push("healthy-a");
78
+ });
79
+ lc.registerShutdownHook("broken", async () => {
80
+ throw new Error("boom");
81
+ });
82
+ lc.registerShutdownHook("healthy-b", async () => {
83
+ calls.push("healthy-b");
84
+ });
85
+ await expect(lc.drain({ timeoutMs: 100 })).resolves.toBeUndefined();
86
+ expect(calls).toEqual(["healthy-b", "healthy-a"]);
87
+ expect(lc.state()).toBe("stopped");
88
+
89
+ // Logging makes the failure visible to prod-ops. Silent swallow hid bugs.
90
+ expect(logger.error).toHaveBeenCalledTimes(1);
91
+ const [msg, ctx] = logger.error.mock.calls[0] as [string, { err: unknown }];
92
+ expect(msg).toMatch(/shutdown hook "broken" threw/);
93
+ expect((ctx.err as Error).message).toBe("boom");
94
+ });
95
+
96
+ test("registering after draining throws", async () => {
97
+ const lc = createLifecycle({ startReady: true });
98
+ await lc.drain({ timeoutMs: 50 });
99
+ expect(() => lc.registerShutdownHook("too-late", async () => {})).toThrow(/already stopped/);
100
+ });
101
+
102
+ test("hook receives the signal name that triggered drain", async () => {
103
+ const lc = createLifecycle({ startReady: true });
104
+ const seen: string[] = [];
105
+ lc.registerShutdownHook("spy", async (signal) => {
106
+ seen.push(signal);
107
+ });
108
+ await lc.drain({ signal: "SIGTERM", timeoutMs: 50 });
109
+ expect(seen).toEqual(["SIGTERM"]);
110
+ });
111
+
112
+ test("hookNames() reflects registration order", () => {
113
+ const lc = createLifecycle({ startReady: true });
114
+ expect(lc.hookNames()).toEqual([]);
115
+ lc.registerShutdownHook("a", async () => {});
116
+ lc.registerShutdownHook("b", async () => {});
117
+ expect(lc.hookNames()).toEqual(["a", "b"]);
118
+ });
119
+
120
+ test("hookNames() stays populated after drain (post-mortem ops use-case)", async () => {
121
+ // Contract: the name list is not cleared on drain. An operator inspecting
122
+ // a stopped process should still be able to see which hooks were wired.
123
+ const lc = createLifecycle({ startReady: true });
124
+ lc.registerShutdownHook("a", async () => {});
125
+ lc.registerShutdownHook("b", async () => {});
126
+ await lc.drain({ timeoutMs: 50 });
127
+ expect(lc.state()).toBe("stopped");
128
+ expect(lc.hookNames()).toEqual(["a", "b"]);
129
+ });
130
+ });
131
+
132
+ describe("lifecycle — drain timeout", () => {
133
+ test("timeout forces state to 'stopped' even if hook hangs", async () => {
134
+ const lc = createLifecycle({ startReady: true });
135
+ // Hook that never resolves — drain must force-stop via its timer.
136
+ lc.registerShutdownHook("hangs-forever", () => new Promise<void>(() => {}));
137
+
138
+ await lc.drain({ timeoutMs: 20 });
139
+ expect(lc.state()).toBe("stopped");
140
+ });
141
+
142
+ test("concurrent drain calls share the in-flight promise", async () => {
143
+ const lc = createLifecycle({ startReady: true });
144
+ let runs = 0;
145
+ lc.registerShutdownHook("count", async () => {
146
+ runs++;
147
+ });
148
+ const [a, b, c] = await Promise.all([
149
+ lc.drain({ timeoutMs: 100 }),
150
+ lc.drain({ timeoutMs: 100 }),
151
+ lc.drain({ timeoutMs: 100 }),
152
+ ]);
153
+ expect(runs).toBe(1);
154
+ expect([a, b, c]).toEqual([undefined, undefined, undefined]);
155
+ });
156
+ });
157
+
158
+ describe("lifecycle — onStateChange", () => {
159
+ test("subscribers receive from/to pairs", async () => {
160
+ const lc = createLifecycle();
161
+ const events: Array<[string, string]> = [];
162
+ lc.onStateChange((from, to) => events.push([from, to]));
163
+ lc.markReady();
164
+ await lc.drain({ timeoutMs: 50 });
165
+ expect(events).toEqual([
166
+ ["starting", "ready"],
167
+ ["ready", "draining"],
168
+ ["draining", "stopped"],
169
+ ]);
170
+ });
171
+
172
+ test("unsubscribe stops further callbacks", () => {
173
+ const lc = createLifecycle();
174
+ const cb = vi.fn();
175
+ const unsubscribe = lc.onStateChange(cb);
176
+ lc.markReady();
177
+ expect(cb).toHaveBeenCalledTimes(1);
178
+ unsubscribe();
179
+ // No more state changes expected to reach cb.
180
+ cb.mockClear();
181
+ // Trigger another transition — should not call cb.
182
+ void lc.drain({ timeoutMs: 50 });
183
+ expect(cb).not.toHaveBeenCalled();
184
+ });
185
+
186
+ test("broken listener does not break others, and the error is logged", () => {
187
+ const logger = { error: vi.fn() };
188
+ const lc = createLifecycle({ logger });
189
+ const healthy = vi.fn();
190
+ lc.onStateChange(() => {
191
+ throw new Error("subscriber exploded");
192
+ });
193
+ lc.onStateChange(healthy);
194
+ lc.markReady();
195
+ expect(lc.state()).toBe("ready");
196
+ expect(healthy).toHaveBeenCalledTimes(1);
197
+
198
+ expect(logger.error).toHaveBeenCalledTimes(1);
199
+ const [msg] = logger.error.mock.calls[0] as [string, unknown];
200
+ expect(msg).toMatch(/onStateChange listener threw during starting→ready/);
201
+ });
202
+ });
203
+
204
+ describe("lifecycle — uptimeSec", () => {
205
+ test("counts seconds since construction using injected clock", () => {
206
+ let t = 1_000_000;
207
+ const lc = createLifecycle({ now: () => t });
208
+ expect(lc.uptimeSec()).toBe(0);
209
+ t += 3_500;
210
+ expect(lc.uptimeSec()).toBe(3);
211
+ });
212
+ });
@@ -0,0 +1,106 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { createLifecycle } from "../lifecycle";
3
+ import { attachSignalHandlers } from "../signal-handlers";
4
+ import { createTestLifecycle } from "./create-test-lifecycle";
5
+
6
+ describe("attachSignalHandlers", () => {
7
+ test("SIGTERM triggers drain and calls exit(0)", async () => {
8
+ const lc = createLifecycle({ startReady: true });
9
+ const exit = vi.fn();
10
+ const hookCalls: string[] = [];
11
+ lc.registerShutdownHook("spy", async (signal) => {
12
+ hookCalls.push(signal);
13
+ });
14
+
15
+ const handle = attachSignalHandlers(lc, { exit, timeoutMs: 50 });
16
+ try {
17
+ process.emit("SIGTERM");
18
+ await waitFor(() => lc.state() === "stopped");
19
+ expect(hookCalls).toEqual(["SIGTERM"]);
20
+ expect(exit).toHaveBeenCalledWith(0);
21
+ } finally {
22
+ handle.detach();
23
+ }
24
+ });
25
+
26
+ test("SIGINT path drains with the right signal label", async () => {
27
+ const lc = createLifecycle({ startReady: true });
28
+ const exit = vi.fn();
29
+ const seen: string[] = [];
30
+ lc.registerShutdownHook("spy", async (signal) => {
31
+ seen.push(signal);
32
+ });
33
+
34
+ const handle = attachSignalHandlers(lc, { exit, timeoutMs: 50, signals: ["SIGINT"] });
35
+ try {
36
+ process.emit("SIGINT");
37
+ await waitFor(() => lc.state() === "stopped");
38
+ expect(seen).toEqual(["SIGINT"]);
39
+ expect(exit).toHaveBeenCalledWith(0);
40
+ } finally {
41
+ handle.detach();
42
+ }
43
+ });
44
+
45
+ test("multiple SIGTERMs still call exit exactly once", async () => {
46
+ const lc = createLifecycle({ startReady: true });
47
+ const exit = vi.fn();
48
+ // Slow hook so we can fire additional signals while drain is in-flight.
49
+ lc.registerShutdownHook("slow", async () => {
50
+ await new Promise((r) => setTimeout(r, 30));
51
+ });
52
+
53
+ const handle = attachSignalHandlers(lc, { exit, timeoutMs: 500 });
54
+ try {
55
+ process.emit("SIGTERM");
56
+ process.emit("SIGTERM");
57
+ process.emit("SIGTERM");
58
+ await waitFor(() => lc.state() === "stopped");
59
+ // Without the exitScheduled guard this would be 3 — the `.then` chains
60
+ // would each fire exit(0) independently.
61
+ expect(exit).toHaveBeenCalledTimes(1);
62
+ expect(exit).toHaveBeenCalledWith(0);
63
+ } finally {
64
+ handle.detach();
65
+ }
66
+ });
67
+
68
+ test("exit(1) is called when drain rejects", async () => {
69
+ // Our real lifecycle swallows hook errors internally so drain() always
70
+ // resolves. Mock a drain that rejects to cover the .catch branch —
71
+ // defensive code still needs a test or it rots.
72
+ const brokenLifecycle = createTestLifecycle({
73
+ drain: async () => {
74
+ throw new Error("drain itself exploded");
75
+ },
76
+ });
77
+ const exit = vi.fn();
78
+ const handle = attachSignalHandlers(brokenLifecycle, { exit, signals: ["SIGTERM"] });
79
+ try {
80
+ process.emit("SIGTERM");
81
+ await waitFor(() => exit.mock.calls.length > 0);
82
+ expect(exit).toHaveBeenCalledWith(1);
83
+ } finally {
84
+ handle.detach();
85
+ }
86
+ });
87
+
88
+ test("detach() removes the process listeners", () => {
89
+ const lc = createLifecycle({ startReady: true });
90
+ const exit = vi.fn();
91
+ const before = process.listenerCount("SIGTERM");
92
+ const handle = attachSignalHandlers(lc, { exit, signals: ["SIGTERM"] });
93
+ expect(process.listenerCount("SIGTERM")).toBe(before + 1);
94
+ handle.detach();
95
+ expect(process.listenerCount("SIGTERM")).toBe(before);
96
+ });
97
+ });
98
+
99
+ async function waitFor(predicate: () => boolean, timeoutMs = 500): Promise<void> {
100
+ const deadline = Date.now() + timeoutMs;
101
+ while (Date.now() < deadline) {
102
+ if (predicate()) return;
103
+ await new Promise((r) => setTimeout(r, 5));
104
+ }
105
+ throw new Error("waitFor timed out");
106
+ }
@@ -0,0 +1,13 @@
1
+ export {
2
+ createLifecycle,
3
+ type Lifecycle,
4
+ type LifecycleOptions,
5
+ type LifecycleState,
6
+ type ShutdownHookFn,
7
+ type StateChangeListener,
8
+ } from "./lifecycle";
9
+ export {
10
+ type AttachSignalHandlersOptions,
11
+ attachSignalHandlers,
12
+ type SignalHandlerHandle,
13
+ } from "./signal-handlers";
@@ -0,0 +1,160 @@
1
+ // Process lifecycle: 4-state machine + LIFO shutdown hooks.
2
+ // Signal wiring lives in signal-handlers.ts; v1 scope in architecture/lifecycle.md.
3
+
4
+ import type { Logger } from "../logging/types";
5
+
6
+ export type LifecycleState = "starting" | "ready" | "draining" | "stopped";
7
+
8
+ export type StateChangeListener = (from: LifecycleState, to: LifecycleState) => void;
9
+
10
+ export type ShutdownHookFn = (signal: string) => Promise<void>;
11
+
12
+ export interface Lifecycle {
13
+ state(): LifecycleState;
14
+ uptimeSec(): number;
15
+ markReady(): void;
16
+ onStateChange(cb: StateChangeListener): () => void;
17
+ registerShutdownHook(name: string, fn: ShutdownHookFn): void;
18
+ // Introspection: which hooks are registered, in registration order (drain
19
+ // runs them reversed). Used by ops + integration tests to verify that
20
+ // auto-wired hooks (e.g. eventDispatcher.stop) actually landed.
21
+ hookNames(): readonly string[];
22
+ drain(opts?: { signal?: string; timeoutMs?: number }): Promise<void>;
23
+ }
24
+
25
+ export type LifecycleOptions = {
26
+ // Start directly in "ready" state. Useful for tests that don't want to
27
+ // orchestrate a full startup sequence.
28
+ readonly startReady?: boolean;
29
+ /** @internal Test-only clock injection for deterministic uptimeSec assertions. */
30
+ readonly now?: () => number;
31
+ // Structured logger for hook / listener failures. Falls back to
32
+ // console.error when absent — matches the pattern in pipeline/lifecycle-pipeline.ts
33
+ // so one-file scripts and test setups don't need to wire a logger.
34
+ readonly logger?: Pick<Logger, "error">;
35
+ };
36
+
37
+ const DEFAULT_DRAIN_TIMEOUT_MS = 40_000;
38
+
39
+ export function createLifecycle(opts: LifecycleOptions = {}): Lifecycle {
40
+ const now = opts.now ?? (() => Date.now());
41
+ const startedAt = now();
42
+ const logError = makeErrorLogger(opts.logger);
43
+
44
+ let current: LifecycleState = opts.startReady ? "ready" : "starting";
45
+ const listeners = new Set<StateChangeListener>();
46
+ const hooks: Array<{ name: string; fn: ShutdownHookFn }> = [];
47
+ let drainPromise: Promise<void> | null = null;
48
+
49
+ function transition(to: LifecycleState): void {
50
+ const from = current;
51
+ // skip: no real state change (e.g. markReady() on an already-ready
52
+ // lifecycle). Listeners only fire on actual transitions.
53
+ if (from === to) return;
54
+ current = to;
55
+ for (const cb of listeners) {
56
+ try {
57
+ cb(from, to);
58
+ } catch (err) {
59
+ // A broken listener must not tear the state machine down, but swallowing
60
+ // silently hides bugs from ops. Log and move on.
61
+ logError(`onStateChange listener threw during ${from}→${to}`, err);
62
+ }
63
+ }
64
+ }
65
+
66
+ async function drainOnce(signal: string, timeoutMs: number): Promise<void> {
67
+ transition("draining");
68
+
69
+ const runAllHooks = async (): Promise<void> => {
70
+ // LIFO: the last thing registered is the first thing stopped. Matches
71
+ // the "things registered later depend on things registered earlier"
72
+ // convention — tear them down in reverse dependency order.
73
+ for (let i = hooks.length - 1; i >= 0; i--) {
74
+ const hook = hooks[i];
75
+ if (!hook) continue;
76
+ try {
77
+ await hook.fn(signal);
78
+ } catch (err) {
79
+ // Isolated failure: one broken hook must not block the others. Log
80
+ // so ops can see which hook failed during shutdown — silent swallow
81
+ // made prod incidents invisible.
82
+ logError(`shutdown hook "${hook.name}" threw`, err);
83
+ }
84
+ }
85
+ };
86
+
87
+ let forceTimer: ReturnType<typeof setTimeout> | undefined;
88
+ const forcePromise = new Promise<void>((resolve) => {
89
+ forceTimer = setTimeout(resolve, timeoutMs);
90
+ });
91
+
92
+ try {
93
+ await Promise.race([runAllHooks(), forcePromise]);
94
+ } finally {
95
+ if (forceTimer) clearTimeout(forceTimer);
96
+ transition("stopped");
97
+ }
98
+ }
99
+
100
+ return {
101
+ state: () => current,
102
+
103
+ uptimeSec: () => Math.floor((now() - startedAt) / 1000),
104
+
105
+ markReady: () => {
106
+ // skip: already past the "starting" phase. markReady is idempotent
107
+ // so boot orchestrators can call it defensively without branching.
108
+ if (current !== "starting") return;
109
+ transition("ready");
110
+ },
111
+
112
+ onStateChange: (cb) => {
113
+ listeners.add(cb);
114
+ return () => {
115
+ listeners.delete(cb);
116
+ };
117
+ },
118
+
119
+ registerShutdownHook: (name, fn) => {
120
+ // Accepting hooks after `draining` has started would silently drop them
121
+ // — better to reject so mis-wired late registrations surface.
122
+ if (current === "draining" || current === "stopped") {
123
+ throw new Error(
124
+ `Cannot register shutdown hook "${name}" — lifecycle is already ${current}`,
125
+ );
126
+ }
127
+ hooks.push({ name, fn });
128
+ },
129
+
130
+ hookNames: () => hooks.map((h) => h.name),
131
+
132
+ drain: async (drainOpts = {}) => {
133
+ const signal = drainOpts.signal ?? "manual";
134
+ const timeoutMs = drainOpts.timeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
135
+
136
+ // Re-entrant drain: concurrent calls share the in-flight promise.
137
+ // Post-stop calls are a no-op so double-SIGTERM doesn't double-run hooks.
138
+ if (drainPromise) return drainPromise;
139
+ // skip: already fully stopped (e.g. SIGTERM arrives after drain finished).
140
+ // Returning without action keeps the post-drain state pristine.
141
+ if (current === "stopped") return;
142
+
143
+ drainPromise = drainOnce(signal, timeoutMs);
144
+ return drainPromise;
145
+ },
146
+ };
147
+ }
148
+
149
+ // Builds a single error-log closure once per lifecycle instance. Structured
150
+ // logger wins when present; otherwise plain stderr via console.error so we
151
+ // never eat a failure silently.
152
+ function makeErrorLogger(
153
+ logger: Pick<Logger, "error"> | undefined,
154
+ ): (msg: string, err: unknown) => void {
155
+ if (logger) {
156
+ return (msg, err) => logger.error(`[lifecycle] ${msg}`, { err });
157
+ }
158
+ // biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
159
+ return (msg, err) => console.error(`[lifecycle] ${msg}:`, err);
160
+ }
@@ -0,0 +1,62 @@
1
+ // Opt-in signal handlers. Kept separate from createLifecycle() so test
2
+ // processes don't accidentally hijack SIGTERM/SIGINT — production `main.ts`
3
+ // calls this explicitly, tests drive drain() directly.
4
+
5
+ import type { Lifecycle } from "./lifecycle";
6
+
7
+ export type AttachSignalHandlersOptions = {
8
+ readonly timeoutMs?: number;
9
+ // Which signals to listen for. Default covers orchestrator (K8s, systemd)
10
+ // SIGTERM and interactive Ctrl-C (SIGINT).
11
+ readonly signals?: readonly NodeJS.Signals[];
12
+ // Called after drain completes, default: process.exit(0). Inject a stub in
13
+ // tests to assert exit was requested without terminating the test runner.
14
+ readonly exit?: (code: number) => void;
15
+ };
16
+
17
+ export type SignalHandlerHandle = {
18
+ // Remove the listeners added by attach(). Useful for tests and for hot-
19
+ // swapping the lifecycle in a long-running process.
20
+ detach(): void;
21
+ };
22
+
23
+ export function attachSignalHandlers(
24
+ lifecycle: Lifecycle,
25
+ opts: AttachSignalHandlersOptions = {},
26
+ ): SignalHandlerHandle {
27
+ const signals: readonly NodeJS.Signals[] = opts.signals ?? ["SIGTERM", "SIGINT"];
28
+ const exitFn = opts.exit ?? ((code) => process.exit(code));
29
+
30
+ const listeners = new Map<NodeJS.Signals, () => void>();
31
+ // Guard so double-SIGTERM doesn't call exitFn twice. drain() is already
32
+ // idempotent via its shared promise, but its .then/.catch chain would fire
33
+ // per signal otherwise.
34
+ let exitScheduled = false;
35
+
36
+ for (const sig of signals) {
37
+ const handler = () => {
38
+ // skip: exit already scheduled by a prior signal — drain() is in flight
39
+ // and will fire exitFn itself when it settles. Second signal is a no-op.
40
+ if (exitScheduled) return;
41
+ exitScheduled = true;
42
+ void lifecycle
43
+ .drain({
44
+ signal: sig,
45
+ ...(opts.timeoutMs !== undefined && { timeoutMs: opts.timeoutMs }),
46
+ })
47
+ .then(() => exitFn(0))
48
+ .catch(() => exitFn(1));
49
+ };
50
+ process.on(sig, handler);
51
+ listeners.set(sig, handler);
52
+ }
53
+
54
+ return {
55
+ detach: () => {
56
+ for (const [sig, handler] of listeners) {
57
+ process.off(sig, handler);
58
+ }
59
+ listeners.clear();
60
+ },
61
+ };
62
+ }