@cosmicdrift/kumiko-bundled-features 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 (333) hide show
  1. package/package.json +90 -0
  2. package/src/audit/__tests__/audit.integration.ts +328 -0
  3. package/src/audit/constants.ts +7 -0
  4. package/src/audit/feature.ts +23 -0
  5. package/src/audit/handlers/list.query.ts +98 -0
  6. package/src/audit/index.ts +2 -0
  7. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
  8. package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
  9. package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
  10. package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
  11. package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
  12. package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
  13. package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
  14. package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
  15. package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
  16. package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
  17. package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
  18. package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
  19. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
  20. package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
  21. package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
  22. package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
  23. package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
  24. package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
  25. package/src/auth-email-password/auth-user-row.ts +41 -0
  26. package/src/auth-email-password/constants.ts +101 -0
  27. package/src/auth-email-password/email-templates.ts +283 -0
  28. package/src/auth-email-password/feature.ts +140 -0
  29. package/src/auth-email-password/handlers/change-password.write.ts +58 -0
  30. package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
  31. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
  32. package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
  33. package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
  34. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
  35. package/src/auth-email-password/handlers/login.write.ts +208 -0
  36. package/src/auth-email-password/handlers/logout.write.ts +12 -0
  37. package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
  38. package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
  39. package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
  40. package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
  41. package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
  42. package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
  43. package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
  44. package/src/auth-email-password/i18n.ts +211 -0
  45. package/src/auth-email-password/identity-v3-hash.ts +97 -0
  46. package/src/auth-email-password/index.ts +35 -0
  47. package/src/auth-email-password/invite-token-store.ts +92 -0
  48. package/src/auth-email-password/lockout-store.ts +118 -0
  49. package/src/auth-email-password/password-hashing.ts +43 -0
  50. package/src/auth-email-password/reset-token.ts +28 -0
  51. package/src/auth-email-password/seeding.ts +183 -0
  52. package/src/auth-email-password/signed-token.ts +85 -0
  53. package/src/auth-email-password/signup-token-store.ts +104 -0
  54. package/src/auth-email-password/stream-tenant.ts +31 -0
  55. package/src/auth-email-password/testing.ts +5 -0
  56. package/src/auth-email-password/token-burn-store.ts +57 -0
  57. package/src/auth-email-password/verification-token.ts +27 -0
  58. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
  59. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
  60. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
  61. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
  62. package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
  63. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
  64. package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
  65. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
  66. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
  67. package/src/auth-email-password/web/auth-client.ts +350 -0
  68. package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
  69. package/src/auth-email-password/web/auth-gate.tsx +33 -0
  70. package/src/auth-email-password/web/client-plugin.ts +48 -0
  71. package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
  72. package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
  73. package/src/auth-email-password/web/index.ts +56 -0
  74. package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
  75. package/src/auth-email-password/web/login-screen.tsx +150 -0
  76. package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
  77. package/src/auth-email-password/web/session.tsx +171 -0
  78. package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
  79. package/src/auth-email-password/web/signup-screen.tsx +130 -0
  80. package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
  81. package/src/auth-email-password/web/use-shell-user.ts +34 -0
  82. package/src/auth-email-password/web/user-menu.tsx +89 -0
  83. package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
  84. package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
  85. package/src/billing-foundation/__tests__/feature.test.ts +110 -0
  86. package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
  87. package/src/billing-foundation/aggregate-id.ts +21 -0
  88. package/src/billing-foundation/constants.ts +70 -0
  89. package/src/billing-foundation/entities.ts +50 -0
  90. package/src/billing-foundation/events.ts +71 -0
  91. package/src/billing-foundation/feature.ts +122 -0
  92. package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
  93. package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
  94. package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
  95. package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
  96. package/src/billing-foundation/handlers/process-event.write.ts +160 -0
  97. package/src/billing-foundation/index.ts +42 -0
  98. package/src/billing-foundation/projection.ts +135 -0
  99. package/src/billing-foundation/types.ts +157 -0
  100. package/src/billing-foundation/webhook-handler.ts +184 -0
  101. package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
  102. package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
  103. package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
  104. package/src/cap-counter/aggregate-id.ts +61 -0
  105. package/src/cap-counter/constants.ts +32 -0
  106. package/src/cap-counter/enforce-cap.ts +404 -0
  107. package/src/cap-counter/entity.ts +48 -0
  108. package/src/cap-counter/feature.ts +90 -0
  109. package/src/cap-counter/handlers/get-counter.query.ts +43 -0
  110. package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
  111. package/src/cap-counter/handlers/increment.write.ts +92 -0
  112. package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
  113. package/src/cap-counter/index.ts +34 -0
  114. package/src/cap-counter/with-cap-enforcement.ts +179 -0
  115. package/src/channel-email/email-channel.ts +48 -0
  116. package/src/channel-email/feature.ts +15 -0
  117. package/src/channel-email/index.ts +4 -0
  118. package/src/channel-email/smtp-transport.ts +65 -0
  119. package/src/channel-email/types.ts +34 -0
  120. package/src/channel-in-app/constants.ts +11 -0
  121. package/src/channel-in-app/feature.ts +30 -0
  122. package/src/channel-in-app/handlers/inbox.query.ts +28 -0
  123. package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
  124. package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
  125. package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
  126. package/src/channel-in-app/in-app-channel.ts +44 -0
  127. package/src/channel-in-app/index.ts +4 -0
  128. package/src/channel-in-app/tables.ts +22 -0
  129. package/src/channel-push/feature.ts +15 -0
  130. package/src/channel-push/index.ts +3 -0
  131. package/src/channel-push/push-channel.ts +33 -0
  132. package/src/channel-push/types.ts +22 -0
  133. package/src/config/__tests__/app-overrides.test.ts +118 -0
  134. package/src/config/__tests__/config.integration.ts +1246 -0
  135. package/src/config/constants.ts +23 -0
  136. package/src/config/feature.ts +117 -0
  137. package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
  138. package/src/config/handlers/reset.write.ts +45 -0
  139. package/src/config/handlers/schema.query.ts +22 -0
  140. package/src/config/handlers/set.write.ts +93 -0
  141. package/src/config/handlers/values.query.ts +43 -0
  142. package/src/config/index.ts +15 -0
  143. package/src/config/resolver.ts +283 -0
  144. package/src/config/table.ts +35 -0
  145. package/src/config/write-helpers.ts +268 -0
  146. package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
  147. package/src/delivery/__tests__/delivery.integration.ts +1405 -0
  148. package/src/delivery/constants.ts +33 -0
  149. package/src/delivery/delivery-service.ts +489 -0
  150. package/src/delivery/events.ts +18 -0
  151. package/src/delivery/feature.ts +70 -0
  152. package/src/delivery/handlers/log.query.ts +21 -0
  153. package/src/delivery/handlers/preferences.query.ts +18 -0
  154. package/src/delivery/handlers/set-preference.write.ts +28 -0
  155. package/src/delivery/index.ts +35 -0
  156. package/src/delivery/tables.ts +74 -0
  157. package/src/delivery/testing.ts +47 -0
  158. package/src/delivery/types.ts +71 -0
  159. package/src/delivery/unsubscribe.ts +99 -0
  160. package/src/delivery/upsert-preference.ts +145 -0
  161. package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
  162. package/src/feature-toggles/constants.ts +20 -0
  163. package/src/feature-toggles/events.ts +18 -0
  164. package/src/feature-toggles/feature.ts +98 -0
  165. package/src/feature-toggles/global-feature-state-table.ts +28 -0
  166. package/src/feature-toggles/handlers/list.query.ts +26 -0
  167. package/src/feature-toggles/handlers/registered.query.ts +56 -0
  168. package/src/feature-toggles/handlers/set.write.ts +158 -0
  169. package/src/feature-toggles/index.ts +9 -0
  170. package/src/feature-toggles/toggle-runtime.ts +73 -0
  171. package/src/file-foundation/__tests__/feature.test.ts +35 -0
  172. package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
  173. package/src/file-foundation/feature.ts +123 -0
  174. package/src/file-foundation/index.ts +7 -0
  175. package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
  176. package/src/file-provider-inmemory/feature.ts +73 -0
  177. package/src/file-provider-inmemory/index.ts +3 -0
  178. package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
  179. package/src/file-provider-s3/feature.ts +169 -0
  180. package/src/file-provider-s3/index.ts +3 -0
  181. package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
  182. package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
  183. package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
  184. package/src/files-provider-s3/env-helper.ts +49 -0
  185. package/src/files-provider-s3/index.ts +3 -0
  186. package/src/files-provider-s3/s3-provider.ts +114 -0
  187. package/src/foundation-shared/config-helpers.ts +67 -0
  188. package/src/foundation-shared/index.ts +4 -0
  189. package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
  190. package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
  191. package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
  192. package/src/jobs/constants.ts +21 -0
  193. package/src/jobs/events.ts +39 -0
  194. package/src/jobs/feature.ts +150 -0
  195. package/src/jobs/handlers/detail.query.ts +30 -0
  196. package/src/jobs/handlers/list.query.ts +36 -0
  197. package/src/jobs/handlers/retry.write.ts +69 -0
  198. package/src/jobs/handlers/trigger.write.ts +39 -0
  199. package/src/jobs/index.ts +5 -0
  200. package/src/jobs/job-run-logger.ts +213 -0
  201. package/src/jobs/job-run-table.ts +55 -0
  202. package/src/legal-pages/README.md +195 -0
  203. package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
  204. package/src/legal-pages/constants.ts +36 -0
  205. package/src/legal-pages/feature.ts +187 -0
  206. package/src/legal-pages/index.ts +13 -0
  207. package/src/legal-pages/markdown.ts +69 -0
  208. package/src/mail-foundation/__tests__/feature.test.ts +46 -0
  209. package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
  210. package/src/mail-foundation/feature.ts +160 -0
  211. package/src/mail-foundation/index.ts +14 -0
  212. package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
  213. package/src/mail-transport-inmemory/feature.ts +90 -0
  214. package/src/mail-transport-inmemory/index.ts +3 -0
  215. package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
  216. package/src/mail-transport-smtp/feature.ts +182 -0
  217. package/src/mail-transport-smtp/index.ts +3 -0
  218. package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
  219. package/src/rate-limiting/constants.ts +9 -0
  220. package/src/rate-limiting/feature.ts +16 -0
  221. package/src/rate-limiting/handlers/status.query.ts +52 -0
  222. package/src/rate-limiting/index.ts +2 -0
  223. package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
  224. package/src/renderer-simple/feature.ts +12 -0
  225. package/src/renderer-simple/index.ts +2 -0
  226. package/src/renderer-simple/simple-renderer.ts +72 -0
  227. package/src/secrets/__tests__/rotate.integration.ts +176 -0
  228. package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
  229. package/src/secrets/__tests__/secrets.integration.ts +118 -0
  230. package/src/secrets/feature.ts +84 -0
  231. package/src/secrets/handlers/delete.write.ts +20 -0
  232. package/src/secrets/handlers/list.query.ts +38 -0
  233. package/src/secrets/handlers/rotate.job.ts +193 -0
  234. package/src/secrets/handlers/set.write.ts +50 -0
  235. package/src/secrets/index.ts +16 -0
  236. package/src/secrets/secrets-context.ts +296 -0
  237. package/src/secrets/table.ts +68 -0
  238. package/src/sessions/__tests__/cleanup.integration.ts +175 -0
  239. package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
  240. package/src/sessions/__tests__/sessions.integration.ts +472 -0
  241. package/src/sessions/__tests__/test-helpers.ts +66 -0
  242. package/src/sessions/constants.ts +43 -0
  243. package/src/sessions/feature.ts +84 -0
  244. package/src/sessions/handlers/cleanup.job.ts +109 -0
  245. package/src/sessions/handlers/list.query.ts +35 -0
  246. package/src/sessions/handlers/mine.query.ts +37 -0
  247. package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
  248. package/src/sessions/handlers/revoke.write.ts +76 -0
  249. package/src/sessions/index.ts +17 -0
  250. package/src/sessions/schema/index.ts +5 -0
  251. package/src/sessions/schema/user-session.ts +67 -0
  252. package/src/sessions/session-callbacks.ts +110 -0
  253. package/src/sessions/testing.ts +42 -0
  254. package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
  255. package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
  256. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
  257. package/src/subscription-mollie/constants.ts +33 -0
  258. package/src/subscription-mollie/feature.ts +144 -0
  259. package/src/subscription-mollie/index.ts +13 -0
  260. package/src/subscription-mollie/plugin-methods.ts +79 -0
  261. package/src/subscription-mollie/verify-webhook.ts +244 -0
  262. package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
  263. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
  264. package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
  265. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
  266. package/src/subscription-stripe/constants.ts +20 -0
  267. package/src/subscription-stripe/feature.ts +120 -0
  268. package/src/subscription-stripe/index.ts +14 -0
  269. package/src/subscription-stripe/plugin-methods.ts +91 -0
  270. package/src/subscription-stripe/verify-webhook.ts +235 -0
  271. package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
  272. package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
  273. package/src/tenant/__tests__/tenant.integration.ts +347 -0
  274. package/src/tenant/command-schemas.ts +37 -0
  275. package/src/tenant/constants.ts +37 -0
  276. package/src/tenant/feature.ts +109 -0
  277. package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
  278. package/src/tenant/handlers/add-member.write.ts +53 -0
  279. package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
  280. package/src/tenant/handlers/create.write.ts +21 -0
  281. package/src/tenant/handlers/disable.write.ts +18 -0
  282. package/src/tenant/handlers/invitations.query.ts +31 -0
  283. package/src/tenant/handlers/list.query.ts +17 -0
  284. package/src/tenant/handlers/me.query.ts +17 -0
  285. package/src/tenant/handlers/members.query.ts +22 -0
  286. package/src/tenant/handlers/memberships.query.ts +24 -0
  287. package/src/tenant/handlers/remove-member.write.ts +40 -0
  288. package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
  289. package/src/tenant/handlers/update-member-roles.write.ts +54 -0
  290. package/src/tenant/handlers/update.write.ts +20 -0
  291. package/src/tenant/index.ts +12 -0
  292. package/src/tenant/invitation-table.ts +93 -0
  293. package/src/tenant/membership-table.ts +35 -0
  294. package/src/tenant/schema/index.ts +5 -0
  295. package/src/tenant/schema/tenant.ts +27 -0
  296. package/src/tenant/seeding.ts +155 -0
  297. package/src/tenant/testing.ts +8 -0
  298. package/src/text-content/README.md +190 -0
  299. package/src/text-content/__tests__/text-content.integration.ts +415 -0
  300. package/src/text-content/api.ts +92 -0
  301. package/src/text-content/constants.ts +19 -0
  302. package/src/text-content/feature.ts +29 -0
  303. package/src/text-content/handlers/by-slug.query.ts +55 -0
  304. package/src/text-content/handlers/set.write.ts +118 -0
  305. package/src/text-content/index.ts +14 -0
  306. package/src/text-content/seeding.ts +91 -0
  307. package/src/text-content/table.ts +45 -0
  308. package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
  309. package/src/tier-engine/__tests__/drift.test.ts +42 -0
  310. package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
  311. package/src/tier-engine/aggregate-id.ts +27 -0
  312. package/src/tier-engine/compose-app.ts +150 -0
  313. package/src/tier-engine/constants.ts +15 -0
  314. package/src/tier-engine/entity.ts +30 -0
  315. package/src/tier-engine/feature.ts +72 -0
  316. package/src/tier-engine/handlers/active-tier.query.ts +23 -0
  317. package/src/tier-engine/index.ts +22 -0
  318. package/src/user/__tests__/seed-testing.integration.ts +127 -0
  319. package/src/user/__tests__/user.integration.ts +198 -0
  320. package/src/user/command-schemas.ts +15 -0
  321. package/src/user/constants.ts +23 -0
  322. package/src/user/feature.ts +32 -0
  323. package/src/user/handlers/create.write.ts +54 -0
  324. package/src/user/handlers/detail.query.ts +9 -0
  325. package/src/user/handlers/find-for-auth.query.ts +38 -0
  326. package/src/user/handlers/list.query.ts +8 -0
  327. package/src/user/handlers/me.query.ts +15 -0
  328. package/src/user/handlers/update.write.ts +54 -0
  329. package/src/user/index.ts +4 -0
  330. package/src/user/schema/index.ts +5 -0
  331. package/src/user/schema/user.ts +69 -0
  332. package/src/user/seeding.ts +93 -0
  333. package/src/user/testing.ts +5 -0
@@ -0,0 +1,687 @@
1
+ import {
2
+ buildDrizzleTable,
3
+ createEventStoreExecutor,
4
+ entityEventName,
5
+ integer,
6
+ table as pgTable,
7
+ uuid,
8
+ } from "@cosmicdrift/kumiko-framework/db";
9
+ import {
10
+ createBooleanField,
11
+ createEntity,
12
+ createTextField,
13
+ defineFeature,
14
+ type FeatureDefinition,
15
+ SYSTEM_TENANT_ID,
16
+ } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { createEventDispatcher, type EventConsumer } from "@cosmicdrift/kumiko-framework/pipeline";
18
+ import {
19
+ createEntityTable,
20
+ createTestUser,
21
+ pushTables,
22
+ setupTestStack,
23
+ type TestStack,
24
+ } from "@cosmicdrift/kumiko-framework/stack";
25
+ import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
26
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
27
+ import { sql } from "drizzle-orm";
28
+ import { Temporal } from "temporal-polyfill";
29
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
30
+ import { z } from "zod";
31
+ import { FEATURE_TOGGLE_SET_EVENT_NAME } from "../constants";
32
+ import { createFeatureTogglesFeature } from "../feature";
33
+ import { globalFeatureStateTable } from "../global-feature-state-table";
34
+ import { GlobalFeatureToggleRuntime } from "../toggle-runtime";
35
+
36
+ // Widget — the "tenant" under test. toggleable(default=true), owns a
37
+ // simple entity and a create-handler that writes via the event-store
38
+ // executor, so the full lifecycle pipeline (postSave hooks + event-log
39
+ // append that the tracker-MSP consumes) actually fires downstream.
40
+ const widgetEntity = createEntity({
41
+ table: "read_widgets",
42
+ fields: {
43
+ name: createTextField({ required: true, maxLength: 100 }),
44
+ active: createBooleanField({ default: true }),
45
+ },
46
+ });
47
+ const widgetTable = buildDrizzleTable("widget", widgetEntity);
48
+
49
+ const widgetCrud = createEventStoreExecutor(widgetTable, widgetEntity, {
50
+ entityName: "widget",
51
+ });
52
+
53
+ function widgetFeature(): FeatureDefinition {
54
+ return defineFeature("widget", (r) => {
55
+ r.systemScope();
56
+ r.toggleable({ default: true });
57
+ r.entity("widget", widgetEntity);
58
+
59
+ // Use the event-store executor so the framework's lifecycle pipeline
60
+ // (postSave hooks, incl. cross-feature ones) actually fires after the
61
+ // write. Direct-DB writes bypass the pipeline — we'd never be able to
62
+ // prove the hook-filter then.
63
+ r.writeHandler(
64
+ "widget:create",
65
+ z.object({ name: z.string().min(1).max(100), active: z.boolean().optional() }),
66
+ async (event, ctx) => widgetCrud.create(event.payload, event.user, ctx.db),
67
+ { access: { roles: ["SystemAdmin"] } },
68
+ );
69
+ });
70
+ }
71
+
72
+ // Widget-Audit — registers a cross-feature entityHook on widget's postSave.
73
+ // When widget-audit is disabled, that hook MUST NOT fire, but widget's own
74
+ // create-handler MUST keep working. That's the "B hooks on A" test the
75
+ // user explicitly asked for.
76
+ const widgetAuditEntity = createEntity({
77
+ table: "widget_audits",
78
+ fields: {
79
+ widgetName: createTextField({ required: true, maxLength: 100 }),
80
+ },
81
+ });
82
+ const widgetAuditTable = buildDrizzleTable("widget-audit", widgetAuditEntity);
83
+
84
+ function widgetAuditFeature(): FeatureDefinition {
85
+ return defineFeature("widget-audit", (r) => {
86
+ r.systemScope();
87
+ r.toggleable({ default: true });
88
+ r.entity("widget-audit", widgetAuditEntity);
89
+
90
+ r.entityHook("postSave", "widget", async (result, ctx) => {
91
+ if (result.kind !== "save" || !result.isNew) return;
92
+ if (!ctx.db) return;
93
+ const name = result.changes!["name"] as string | undefined;
94
+ if (!name) return;
95
+ await ctx.db.insert(widgetAuditTable).values({
96
+ id: generateId(),
97
+ widgetName: name,
98
+ version: 1,
99
+ tenantId: SYSTEM_TENANT_ID,
100
+ createdAt: Temporal.Now.instant(),
101
+ modifiedAt: Temporal.Now.instant(),
102
+ });
103
+ });
104
+ });
105
+ }
106
+
107
+ // Widget-Tracker — owns a multi-stream projection that reacts to
108
+ // widget.created events and upserts a per-tenant counter. Drives the
109
+ // MSP-filter tests below: disable widget-tracker and the consumer
110
+ // must pause (cursor unchanged); re-enable and it catches up on the
111
+ // queued events without replaying them through a disabled pipeline.
112
+ const widgetTrackerTable = pgTable("widget_tracker", {
113
+ tenantId: uuid("tenant_id").primaryKey(),
114
+ count: integer("count").notNull().default(0),
115
+ });
116
+
117
+ function widgetTrackerFeature(): FeatureDefinition {
118
+ return defineFeature("widget-tracker", (r) => {
119
+ r.systemScope();
120
+ r.toggleable({ default: true });
121
+ // Declared dependency on widget: when widget is globally off, the
122
+ // resolver's cascade drops widget-tracker as well (no matter its own
123
+ // override). That's the shape the cascade test below asserts on.
124
+ r.requires("widget");
125
+
126
+ r.multiStreamProjection({
127
+ name: "tracker",
128
+ table: widgetTrackerTable,
129
+ apply: {
130
+ [entityEventName("widget", "created")]: async (event, tx) => {
131
+ await tx
132
+ .insert(widgetTrackerTable)
133
+ .values({ tenantId: event.tenantId, count: 1 })
134
+ .onConflictDoUpdate({
135
+ target: widgetTrackerTable.tenantId,
136
+ set: { count: sql`${widgetTrackerTable.count} + 1` },
137
+ });
138
+ },
139
+ },
140
+ });
141
+ });
142
+ }
143
+
144
+ let stack: TestStack;
145
+ let runtime: GlobalFeatureToggleRuntime;
146
+
147
+ beforeAll(async () => {
148
+ // Bootstrapping dance: setupTestStack wires the dispatcher's
149
+ // effectiveFeatures callback AND the feature-toggles feature's
150
+ // set-handler both need the runtime, but the runtime needs the
151
+ // registry that setupTestStack builds. Two late-bound holders
152
+ // break the cycle: one for the callback (held by the dispatcher),
153
+ // one for the runtime accessor (held by the set-handler closure).
154
+ let effective: () => ReadonlySet<string> = () => new Set();
155
+ const runtimeHolder = createLateBoundHolder<GlobalFeatureToggleRuntime>("runtime");
156
+
157
+ stack = await setupTestStack({
158
+ features: [
159
+ widgetFeature(),
160
+ widgetAuditFeature(),
161
+ widgetTrackerFeature(),
162
+ createFeatureTogglesFeature({ getRuntime: () => runtimeHolder.get() }),
163
+ ],
164
+ effectiveFeatures: () => effective(),
165
+ systemHooks: [],
166
+ });
167
+
168
+ await pushTables(stack.db, { globalFeatureStateTable });
169
+ // widgetTrackerTable is auto-pushed by setupTestStack because it's the
170
+ // projection-table of a registered r.multiStreamProjection — manually
171
+ // pushing again would re-run the CREATE TABLE and fail duplicate.
172
+ await createEntityTable(stack.db, widgetEntity);
173
+ await createEntityTable(stack.db, widgetAuditEntity, "widget-audit");
174
+
175
+ runtime = new GlobalFeatureToggleRuntime(stack.db, stack.registry);
176
+ await runtime.initialize();
177
+ effective = runtime.effectiveFeatures;
178
+ runtimeHolder.set(runtime);
179
+ });
180
+
181
+ afterAll(async () => {
182
+ await stack?.cleanup();
183
+ });
184
+
185
+ beforeEach(async () => {
186
+ await stack.db.delete(widgetAuditTable);
187
+ await stack.db.delete(widgetTable);
188
+ await stack.db.delete(widgetTrackerTable);
189
+ await stack.db.delete(globalFeatureStateTable);
190
+ // Wipe the event log + reset every consumer cursor so each test starts
191
+ // from event-id 0. Tests that drain via eventDispatcher.runOnce() need
192
+ // this or they drain a shared backlog and see false-positive counters.
193
+ await stack.db.execute(sql`DELETE FROM kumiko_events`);
194
+ await stack.db.execute(sql`UPDATE kumiko_event_consumers SET last_processed_event_id = 0`);
195
+ await runtime.refresh();
196
+ });
197
+
198
+ const admin = createTestUser({
199
+ id: "11111111-1111-1111-1111-111111111111",
200
+ tenantId: SYSTEM_TENANT_ID,
201
+ roles: ["SystemAdmin"],
202
+ });
203
+
204
+ async function createWidget(name: string) {
205
+ const res = await stack.http.write("widget:write:widget:create", { name }, admin);
206
+ const body = (await res.json()) as {
207
+ isSuccess: boolean;
208
+ error?: { code: string; details?: Record<string, unknown> };
209
+ data?: Record<string, unknown>;
210
+ };
211
+ return { status: res.status, body };
212
+ }
213
+
214
+ async function countWidgets(): Promise<number> {
215
+ const rows = await stack.db.select().from(widgetTable);
216
+ return rows.length;
217
+ }
218
+
219
+ async function countAuditRows(): Promise<number> {
220
+ const rows = await stack.db.select().from(widgetAuditTable);
221
+ return rows.length;
222
+ }
223
+
224
+ async function trackerCount(): Promise<number> {
225
+ const rows = await stack.db.select().from(widgetTrackerTable);
226
+ return rows[0]?.count ?? 0;
227
+ }
228
+
229
+ // Raw SQL because kumiko_event_consumers isn't exported as a drizzle table
230
+ // from @cosmicdrift/kumiko-framework/db. Single cast at the system boundary with
231
+ // explicit shape — typed access everywhere else.
232
+ type ConsumerCursorRow = { last_processed_event_id: number | string };
233
+ async function trackerCursor(): Promise<number> {
234
+ const rows = (await stack.db.execute(
235
+ sql`SELECT last_processed_event_id FROM kumiko_event_consumers WHERE name LIKE '%tracker%' LIMIT 1`,
236
+ )) as unknown as readonly ConsumerCursorRow[];
237
+ return Number(rows[0]?.last_processed_event_id ?? 0);
238
+ }
239
+
240
+ async function setTrackerCursor(value: number): Promise<void> {
241
+ await stack.db.execute(
242
+ sql`UPDATE kumiko_event_consumers SET last_processed_event_id = ${value} WHERE name LIKE '%tracker%'`,
243
+ );
244
+ }
245
+
246
+ describe("feature-toggles runtime cache", () => {
247
+ test("apply() flips the in-memory snapshot instantly", () => {
248
+ runtime.apply("widget", false);
249
+ expect(runtime.effectiveFeatures().has("widget")).toBe(false);
250
+ runtime.apply("widget", true);
251
+ expect(runtime.effectiveFeatures().has("widget")).toBe(true);
252
+ });
253
+
254
+ test("refresh() re-reads the DB snapshot", async () => {
255
+ await stack.db.insert(globalFeatureStateTable).values({
256
+ featureName: "widget",
257
+ enabled: false,
258
+ version: 1,
259
+ updatedBy: "test",
260
+ });
261
+ await runtime.refresh();
262
+ expect(runtime.effectiveFeatures().has("widget")).toBe(false);
263
+ });
264
+
265
+ test("cascade: widget-tracker requires widget → disabling widget drops widget-tracker too", () => {
266
+ // widget-tracker has r.requires("widget") declared. Disabling widget
267
+ // should cascade through the resolver so widget-tracker is effectively
268
+ // off even though nobody touched its own override row.
269
+ runtime.apply("widget", false);
270
+ expect(runtime.effectiveFeatures().has("widget")).toBe(false);
271
+ expect(runtime.effectiveFeatures().has("widget-tracker")).toBe(false);
272
+
273
+ // widget back on → tracker back on (override row never existed, so
274
+ // the cascade flips back automatically).
275
+ runtime.apply("widget", true);
276
+ expect(runtime.effectiveFeatures().has("widget")).toBe(true);
277
+ expect(runtime.effectiveFeatures().has("widget-tracker")).toBe(true);
278
+ });
279
+ });
280
+
281
+ describe("runtime on/off/on — the user's scenario", () => {
282
+ test("full cycle: ON → create works + hook fires, OFF → 403 + no-op, ON → works again", async () => {
283
+ // PHASE 1: both features on (the default).
284
+ const first = await createWidget("alpha");
285
+ expect(first.body.isSuccess).toBe(true);
286
+ expect(await countWidgets()).toBe(1);
287
+ expect(await countAuditRows()).toBe(1); // widget-audit hook fired
288
+
289
+ // PHASE 2: disable widget at runtime.
290
+ runtime.apply("widget", false);
291
+
292
+ const denied = await createWidget("beta");
293
+ expect(denied.body.isSuccess).toBe(false);
294
+ expect(denied.body.error?.code).toBe("feature_disabled");
295
+ expect(denied.body.error?.details).toMatchObject({
296
+ reason: "feature_disabled",
297
+ feature: "widget",
298
+ });
299
+ // DB unchanged — neither widget nor audit got a new row.
300
+ expect(await countWidgets()).toBe(1);
301
+ expect(await countAuditRows()).toBe(1);
302
+
303
+ // PHASE 3: re-enable widget. Handler works again, hook fires again.
304
+ runtime.apply("widget", true);
305
+
306
+ const again = await createWidget("gamma");
307
+ expect(again.body.isSuccess).toBe(true);
308
+ expect(await countWidgets()).toBe(2);
309
+ expect(await countAuditRows()).toBe(2);
310
+ });
311
+
312
+ test("HTTP set-handler persists + updates snapshot + emits toggle-set event", async () => {
313
+ // End-to-end through the dispatcher: API call → DB row → in-memory
314
+ // snapshot flip → next widget:create gated accordingly.
315
+ const toggleRes = await stack.http.write(
316
+ "feature-toggles:write:set",
317
+ { featureName: "widget", enabled: false },
318
+ admin,
319
+ );
320
+ const body = (await toggleRes.json()) as {
321
+ isSuccess: boolean;
322
+ error?: { code: string; message?: string; details?: Record<string, unknown> };
323
+ data?: { featureName: string; enabled: boolean; previousEnabled: boolean | null };
324
+ };
325
+ if (!body.isSuccess) {
326
+ throw new Error(`set-handler failed: ${JSON.stringify(body.error)}`);
327
+ }
328
+ expect(body.isSuccess).toBe(true);
329
+ expect(body.data?.enabled).toBe(false);
330
+ expect(body.data?.previousEnabled).toBeNull();
331
+
332
+ // Row persisted.
333
+ const rows = await stack.db.select().from(globalFeatureStateTable);
334
+ expect(rows).toHaveLength(1);
335
+
336
+ // Snapshot updated — widget:create now 403s.
337
+ const denied = await createWidget("iota");
338
+ expect(denied.body.isSuccess).toBe(false);
339
+ expect(denied.body.error?.code).toBe("feature_disabled");
340
+
341
+ // Flip back on via the handler.
342
+ await stack.http.write(
343
+ "feature-toggles:write:set",
344
+ { featureName: "widget", enabled: true },
345
+ admin,
346
+ );
347
+ const ok = await createWidget("kappa");
348
+ expect(ok.body.isSuccess).toBe(true);
349
+ });
350
+
351
+ test("set-handler rejects non-toggleable features", async () => {
352
+ const res = await stack.http.write(
353
+ "feature-toggles:write:set",
354
+ { featureName: "feature-toggles", enabled: false },
355
+ admin,
356
+ );
357
+ const body = (await res.json()) as {
358
+ isSuccess: boolean;
359
+ error?: { code: string; details?: Record<string, unknown> };
360
+ };
361
+ expect(body.isSuccess).toBe(false);
362
+ expect(body.error?.details).toMatchObject({ reason: "feature_not_toggleable" });
363
+ });
364
+
365
+ test("set-handler rejects unknown features", async () => {
366
+ const res = await stack.http.write(
367
+ "feature-toggles:write:set",
368
+ { featureName: "does-not-exist", enabled: true },
369
+ admin,
370
+ );
371
+ const body = (await res.json()) as {
372
+ isSuccess: boolean;
373
+ error?: { details?: Record<string, unknown> };
374
+ };
375
+ expect(body.isSuccess).toBe(false);
376
+ expect(body.error?.details).toMatchObject({ reason: "unknown_feature" });
377
+ });
378
+
379
+ test("cross-feature hook: disabling widget-audit skips the hook but widget keeps working", async () => {
380
+ // Baseline — both features on.
381
+ await createWidget("delta");
382
+ expect(await countWidgets()).toBe(1);
383
+ expect(await countAuditRows()).toBe(1);
384
+
385
+ // Disable widget-audit (the hook-owner). Widget is still on, so
386
+ // widget:create must continue succeeding — but the audit-hook owned
387
+ // by widget-audit must be skipped.
388
+ runtime.apply("widget-audit", false);
389
+
390
+ const res = await createWidget("epsilon");
391
+ expect(res.body.isSuccess).toBe(true);
392
+ expect(await countWidgets()).toBe(2); // widget wrote a row
393
+ expect(await countAuditRows()).toBe(1); // audit-hook did NOT fire
394
+
395
+ // Re-enable widget-audit. Hook resumes firing.
396
+ runtime.apply("widget-audit", true);
397
+
398
+ await createWidget("zeta");
399
+ expect(await countWidgets()).toBe(3);
400
+ expect(await countAuditRows()).toBe(2);
401
+ });
402
+ });
403
+
404
+ // --- MSP-filter: disabled features pause their consumers ---
405
+
406
+ describe("MSP consumer pauses for disabled features", () => {
407
+ test("on → event advances cursor and increments counter", async () => {
408
+ await createWidget("msp-alpha");
409
+ await stack.eventDispatcher?.runOnce();
410
+
411
+ expect(await trackerCount()).toBe(1);
412
+ });
413
+
414
+ test("off → new event does NOT advance cursor, no projection write", async () => {
415
+ // baseline: run one event through to set the cursor.
416
+ await createWidget("msp-beta");
417
+ await stack.eventDispatcher?.runOnce();
418
+ const cursorBefore = await trackerCursor();
419
+ expect(await trackerCount()).toBe(1);
420
+
421
+ // Disable the MSP's owning feature. Next event generates but the
422
+ // consumer pauses — cursor frozen, projection unchanged.
423
+ runtime.apply("widget-tracker", false);
424
+ await createWidget("msp-gamma");
425
+ await stack.eventDispatcher?.runOnce();
426
+
427
+ expect(await trackerCount()).toBe(1); // no increment
428
+ expect(await trackerCursor()).toBe(cursorBefore); // cursor frozen
429
+ });
430
+
431
+ test("on → off → on: events accumulate, resume replays from same cursor", async () => {
432
+ await createWidget("msp-delta");
433
+ await stack.eventDispatcher?.runOnce();
434
+ expect(await trackerCount()).toBe(1);
435
+
436
+ // Off. Widgets keep being created (widget feature is still on);
437
+ // their events land in the store but the tracker consumer sits idle.
438
+ runtime.apply("widget-tracker", false);
439
+ await createWidget("msp-epsilon");
440
+ await createWidget("msp-zeta");
441
+ await stack.eventDispatcher?.runOnce();
442
+ expect(await trackerCount()).toBe(1); // still 1 — paused
443
+
444
+ // On again. The dispatcher picks up the queued events from the
445
+ // frozen cursor — no data loss, no replay of already-processed ones.
446
+ runtime.apply("widget-tracker", true);
447
+ await stack.eventDispatcher?.runOnce();
448
+ expect(await trackerCount()).toBe(3); // caught up (1 + 2)
449
+ });
450
+
451
+ test("cascade via HTTP: disabling widget freezes widget-tracker cursor too", async () => {
452
+ // End-to-end cascade proof. Both downstream surfaces must respect the
453
+ // cascade when *only* widget's override row is flipped:
454
+ // 1. widget handler-gate blocks creates (covered by inline assert)
455
+ // 2. widget-tracker MSP consumer pauses — cursor frozen, no projection
456
+ // write, even with a pending widget.created event in the log
457
+ //
458
+ // How the MSP-side is proven: process one widget event normally, then
459
+ // rewind the tracker-consumer's cursor by one so the same event sits
460
+ // pending again. Flip widget off via HTTP (which cascades tracker off
461
+ // via r.requires), drain the dispatcher, and assert the cursor stayed
462
+ // frozen. Re-enable widget and the cursor advances past the rewind.
463
+ await createWidget("cascade-alpha");
464
+ await stack.eventDispatcher?.runOnce();
465
+ const cursorAfterFirstRun = await trackerCursor();
466
+ expect(await trackerCount()).toBe(1);
467
+ // cursor is some positive value — absolute id depends on the global
468
+ // events-sequence (bigserial; DELETE doesn't rewind it). All further
469
+ // assertions use this as the anchor so they stay deterministic.
470
+ expect(cursorAfterFirstRun).toBeGreaterThan(0);
471
+
472
+ // Rewind one event. The widget.created event is now "pending" from the
473
+ // consumer's POV — a clean setup for the cascade-pause assertion.
474
+ await setTrackerCursor(cursorAfterFirstRun - 1);
475
+
476
+ // Persist "widget off" via the real set-handler (not apply() — this
477
+ // proves the through-the-DB path works, including the cascade-refresh
478
+ // that the set-handler triggers). This also emits event 2 (toggle-set).
479
+ await stack.http.write(
480
+ "feature-toggles:write:set",
481
+ { featureName: "widget", enabled: false },
482
+ admin,
483
+ );
484
+
485
+ // widget's create-handler: gate blocks.
486
+ const denied = await createWidget("cascade-beta");
487
+ expect(denied.body.error?.code).toBe("feature_disabled");
488
+
489
+ // MSP-side cascade: run the dispatcher. widget-tracker is cascade-off
490
+ // so its consumer must NOT advance the cursor even though a pending
491
+ // event is sitting right there waiting to be drained.
492
+ await stack.eventDispatcher?.runOnce();
493
+ expect(await trackerCursor()).toBe(cursorAfterFirstRun - 1);
494
+
495
+ // Re-enable widget via HTTP — emits event 3 (toggle-set). Cascade flips
496
+ // tracker back on; consumer drains events 1..3. Only event 1 matches
497
+ // the tracker's apply map, so count increments from 1 → 2 (replay of
498
+ // the rewound event), and the cursor lands at 3.
499
+ await stack.http.write(
500
+ "feature-toggles:write:set",
501
+ { featureName: "widget", enabled: true },
502
+ admin,
503
+ );
504
+ await stack.eventDispatcher?.runOnce();
505
+ expect(await trackerCursor()).toBe(cursorAfterFirstRun + 2);
506
+ expect(await trackerCount()).toBe(2);
507
+ });
508
+ });
509
+
510
+ // --- Event-audit automation + read-side queries ---
511
+
512
+ describe("feature-toggles queries + audit automation", () => {
513
+ test("set-handler appends toggle-set event to the event store", async () => {
514
+ await stack.http.write(
515
+ "feature-toggles:write:set",
516
+ { featureName: "widget", enabled: false },
517
+ admin,
518
+ );
519
+
520
+ const events = (await stack.db.execute(
521
+ sql`SELECT type, payload FROM kumiko_events WHERE type = 'feature-toggles:event:toggle-set'`,
522
+ )) as unknown as readonly {
523
+ type: string;
524
+ payload: Record<string, unknown>;
525
+ }[];
526
+
527
+ expect(events).toHaveLength(1);
528
+ expect(events[0]?.payload).toMatchObject({
529
+ featureName: "widget",
530
+ enabled: false,
531
+ previousEnabled: null,
532
+ });
533
+ });
534
+
535
+ test("list query returns only features with explicit override rows", async () => {
536
+ await stack.http.write(
537
+ "feature-toggles:write:set",
538
+ { featureName: "widget", enabled: false },
539
+ admin,
540
+ );
541
+
542
+ const data = await stack.http.queryOk<{
543
+ items: Array<{ featureName: string; enabled: boolean; version: number }>;
544
+ }>("feature-toggles:query:list", {}, admin);
545
+ expect(data.items).toHaveLength(1);
546
+ expect(data.items[0]).toMatchObject({
547
+ featureName: "widget",
548
+ enabled: false,
549
+ version: 1,
550
+ });
551
+ });
552
+
553
+ test("registered query reports metadata + override + effective for every feature", async () => {
554
+ runtime.apply("widget", false);
555
+ const data = await stack.http.queryOk<{
556
+ items: Array<{
557
+ name: string;
558
+ toggleable: boolean;
559
+ default: boolean | null;
560
+ override: boolean | null;
561
+ requires: readonly string[];
562
+ effective: boolean | null;
563
+ }>;
564
+ }>("feature-toggles:query:registered", {}, admin);
565
+ const byName = new Map(data.items.map((i) => [i.name, i]));
566
+
567
+ expect(byName.get("widget")).toMatchObject({
568
+ toggleable: true,
569
+ default: true,
570
+ effective: false,
571
+ });
572
+
573
+ expect(byName.get("feature-toggles")).toMatchObject({
574
+ toggleable: false,
575
+ default: null,
576
+ effective: true,
577
+ });
578
+ });
579
+ });
580
+
581
+ // --- Multi-instance cache-sync (toggle-cache-sync MSP) ---
582
+ //
583
+ // Production scenario: two API instances share a DB. Instance A runs the
584
+ // set-handler (flips widget off), Instance B didn't. Before this MSP,
585
+ // B's runtime stayed stuck on the pre-flip snapshot until it was
586
+ // restarted. Now B's dispatcher picks up the toggle-set event from the
587
+ // events table (via the per-instance consumer cursor) and converges its
588
+ // own snapshot.
589
+ //
590
+ // We simulate B with a hand-rolled second dispatcher against the same DB
591
+ // — same pattern as the Welle-2.7 multi-instance tests in
592
+ // event-dispatcher-multi-instance.integration.ts. Building a second
593
+ // setupTestStack would need a shared DB pool across stacks, which adds
594
+ // more test-infra than the scenario is worth; the hand-rolled consumer
595
+ // mirrors exactly what the feature-toggles feature registers on the
596
+ // primary stack.
597
+ describe("multi-instance cache-sync via toggle-cache-sync MSP", () => {
598
+ test("flip on instance A propagates to instance B after its dispatcher ticks", async () => {
599
+ // Instance B's runtime — same DB as instance A's `runtime`, but its
600
+ // own in-memory snapshot. `initialize()` loads the pre-existing rows;
601
+ // at this point there are no override rows (beforeEach wiped the
602
+ // table), so `widget` is on via its toggleable default.
603
+ const runtimeB = new GlobalFeatureToggleRuntime(stack.db, stack.registry);
604
+ await runtimeB.initialize();
605
+ expect(runtimeB.effectiveFeatures().has("widget")).toBe(true);
606
+
607
+ // Instance B's dispatcher — same consumer name as the feature's MSP
608
+ // so per-instance cursor rows stay aligned with production reality
609
+ // (each instance owns one cursor row keyed by (name, instance_id)).
610
+ // The handler mirrors the MSP's apply: narrow the payload, call
611
+ // runtime.apply. In production this code lives in the
612
+ // r.multiStreamProjection declaration; here we hand-roll it to keep
613
+ // the second runtime a local object.
614
+ const consumer: EventConsumer = {
615
+ name: "feature-toggles:projection:toggle-cache-sync",
616
+ delivery: "per-instance",
617
+ handler: async (event) => {
618
+ if (event.type !== FEATURE_TOGGLE_SET_EVENT_NAME) return;
619
+ const payload = event.payload as { featureName: string; enabled: boolean };
620
+ runtimeB.apply(payload.featureName, payload.enabled);
621
+ },
622
+ };
623
+ const dispatcherB = createEventDispatcher({
624
+ db: stack.db,
625
+ consumers: [consumer],
626
+ context: { db: stack.db, registry: stack.registry },
627
+ instanceId: "test-instance-B",
628
+ batchSize: 200,
629
+ pollIntervalMs: 5000,
630
+ });
631
+ await dispatcherB.ensureRegistered();
632
+
633
+ // Flip widget off on "instance A" via the HTTP path. This triggers:
634
+ // 1. DB row write (globalFeatureStateTable)
635
+ // 2. ctx.appendEvent (toggle-set into events-table)
636
+ // 3. runtime.apply on A's in-memory snapshot (fast-path)
637
+ await stack.http.write(
638
+ "feature-toggles:write:set",
639
+ { featureName: "widget", enabled: false },
640
+ admin,
641
+ );
642
+
643
+ // A sees the flip immediately (local apply in set-handler).
644
+ expect(runtime.effectiveFeatures().has("widget")).toBe(false);
645
+
646
+ // B hasn't ticked yet — its snapshot still says widget is on.
647
+ // This is the exact stale-cache bug this MSP is solving.
648
+ expect(runtimeB.effectiveFeatures().has("widget")).toBe(true);
649
+
650
+ // B's dispatcher ticks: consumes the toggle-set event, apply fires,
651
+ // runtimeB converges.
652
+ await dispatcherB.runOnce();
653
+ expect(runtimeB.effectiveFeatures().has("widget")).toBe(false);
654
+
655
+ // Flip back on — regression-check: propagation works both ways, not
656
+ // just a one-shot on the first event.
657
+ await stack.http.write(
658
+ "feature-toggles:write:set",
659
+ { featureName: "widget", enabled: true },
660
+ admin,
661
+ );
662
+ expect(runtime.effectiveFeatures().has("widget")).toBe(true);
663
+ expect(runtimeB.effectiveFeatures().has("widget")).toBe(false);
664
+ await dispatcherB.runOnce();
665
+ expect(runtimeB.effectiveFeatures().has("widget")).toBe(true);
666
+ });
667
+
668
+ test("per-instance delivery: the primary stack's dispatcher also fires the MSP (double-apply is idempotent)", async () => {
669
+ // The feature registers the MSP with delivery="per-instance", so the
670
+ // primary stack's dispatcher also owns a cursor row and fires the
671
+ // handler on every toggle-set event — even on the instance that just
672
+ // wrote locally via the set-handler. `runtime.apply` is Map.set, so
673
+ // the second write is a no-op. We verify the dispatcher-tick path
674
+ // doesn't corrupt state.
675
+ await stack.http.write(
676
+ "feature-toggles:write:set",
677
+ { featureName: "widget", enabled: false },
678
+ admin,
679
+ );
680
+ expect(runtime.effectiveFeatures().has("widget")).toBe(false);
681
+
682
+ // Run the primary dispatcher — MSP fires, applies the same value
683
+ // again. Snapshot must NOT flip back on.
684
+ await stack.eventDispatcher?.runOnce();
685
+ expect(runtime.effectiveFeatures().has("widget")).toBe(false);
686
+ });
687
+ });