@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,61 @@
1
+ import { v5 as uuidv5 } from "uuid";
2
+
3
+ // Fixed UUID-namespace für die cap-counter-aggregate-id-Ableitung.
4
+ // Generiert einmalig (2026-05-02), in Stein gemeißelt: ein Wechsel würde
5
+ // jeden existing aggregate-Stream re-keyen → kaputter event-replay,
6
+ // kaputte counter-history, verlorener Audit-Trail. Drift-Pin in
7
+ // __tests__/drift.test.ts pinnt den UUID-Wert.
8
+ const CAP_COUNTER_NAMESPACE = "9c1bf2a3-6e4d-4f5b-8a9c-2d3e4f5a6b7c";
9
+
10
+ // Separater Namespace für Rolling-Window-Counter (Sprint 4). Eigener
11
+ // Namespace damit das aggregate-id NIE mit einem Calendar-Counter
12
+ // kollidiert, selbst wenn jemand "1970-01-01..." als periodStart in
13
+ // den Calendar-Pfad reinpasst. Drift-Pin in __tests__/drift.test.ts.
14
+ const CAP_COUNTER_ROLLING_NAMESPACE = "8b2ad0c6-1f3e-4f7c-9b8a-3c4d5e6f7a8b";
15
+
16
+ /**
17
+ * Deterministic aggregate-id für ein cap-counter-Aggregate aus dem
18
+ * Tripel (tenantId, capName, periodStart-as-iso). Pro Tenant + Cap +
19
+ * Period existiert genau ein Aggregate.
20
+ *
21
+ * **Period-Semantik:**
22
+ * - Calendar-Month-Reset: neuer periodStart am 1. des Monats →
23
+ * neuer Aggregate-Stream. Vorherige Counter-Row bleibt für Audit.
24
+ * - Rolling-Window: periodStart wird NIE zurückgesetzt (z.B. fixed
25
+ * "1970-01-01" als Sentinel). Der Read filtert via Event-Store-
26
+ * Timestamp, nicht via Aggregate-Identity.
27
+ *
28
+ * **Aufruf-Pattern:** Caller (incrementCap-Helper) ruft das mit dem
29
+ * tenantId aus event.user.tenantId, dem capName und dem aktuellen
30
+ * Period-Start auf. Race-frei: zwei parallele Increments für denselben
31
+ * (tenant, cap, period) gehen auf denselben aggregate-Stream und werden
32
+ * vom event-store optimistic-lock serialisiert (version_conflict bei
33
+ * Race → Caller-side Retry).
34
+ */
35
+ export function capCounterAggregateId(
36
+ tenantId: string,
37
+ capName: string,
38
+ periodStartIso: string,
39
+ ): string {
40
+ return uuidv5(`${tenantId}|${capName}|${periodStartIso}`, CAP_COUNTER_NAMESPACE);
41
+ }
42
+
43
+ /**
44
+ * Deterministic aggregate-id für ein Rolling-Window-Counter-Aggregate
45
+ * aus dem Paar (tenantId, capName). Pro Tenant + Cap existiert genau
46
+ * EIN Rolling-Aggregate-Stream — die Window-Semantik kommt rein aus
47
+ * dem Read-Pfad (Filter via event-store-Timestamp).
48
+ *
49
+ * **Eigener Namespace:** kollidiert NICHT mit
50
+ * `capCounterAggregateId(tenantId, capName, "1970-01-01...")` — selbe
51
+ * inputs, andere uuidv5-namespace, anderer Output-UUID. Damit ist auch
52
+ * verhindert dass ein versehentlicher Calendar-Increment auf den
53
+ * Rolling-Stream trifft.
54
+ *
55
+ * **Aufruf-Pattern:** Caller (incrementRollingCap-Helper) ruft mit
56
+ * tenantId + capName auf, erzeugt Increment-Events am stream. Race-
57
+ * frei: der event-store hängt mit auto-incrementing version an.
58
+ */
59
+ export function rollingCapAggregateId(tenantId: string, capName: string): string {
60
+ return uuidv5(`${tenantId}|${capName}`, CAP_COUNTER_ROLLING_NAMESPACE);
61
+ }
@@ -0,0 +1,32 @@
1
+ // Feature name
2
+ export const CAP_COUNTER_FEATURE = "cap-counter" as const;
3
+
4
+ // Aggregate types — calendar-period-Counter benutzt CRUD-Events der
5
+ // projection-row. Rolling-Window-Counter benutzt einen eigenen
6
+ // aggregate-type mit custom increment-events (no projection — der
7
+ // Read summiert über die letzten N Tage Events). Sind getrennt damit
8
+ // die r.entity-projection nicht auch noch rolling-counter-rows tracken
9
+ // muss.
10
+ export const CAP_COUNTER_ROLLING_AGGREGATE_TYPE = "cap-counter-rolling" as const;
11
+
12
+ // Custom event-type für Rolling-Window-Counter. Symmetrisches Paar:
13
+ // _SHORT — passt zu `r.defineEvent(short, schema)` im Registrar
14
+ // (Framework prefixt automatisch zu QN)
15
+ // _QN — qualifizierte Form für `ctx.appendEventUnsafe({type})`
16
+ // + `events.type`-Spalte + `registry.getEvent(qn)`-Lookup
17
+ // Beide MÜSSEN konsistent sein (drift-pin im feature-test).
18
+ export const ROLLING_INCREMENTED_EVENT_SHORT = "rolling-incremented" as const;
19
+ export const ROLLING_INCREMENTED_EVENT_QN = "cap-counter:event:rolling-incremented" as const;
20
+
21
+ // Qualified write handler names (QN format: scope:type:name).
22
+ export const CapCounterHandlers = {
23
+ increment: "cap-counter:write:increment",
24
+ incrementRolling: "cap-counter:write:increment-rolling",
25
+ markSoftWarned: "cap-counter:write:mark-soft-warned",
26
+ } as const;
27
+
28
+ // Qualified query handler names.
29
+ export const CapCounterQueries = {
30
+ list: "cap-counter:query:cap-counter:list",
31
+ getCounter: "cap-counter:query:get-counter",
32
+ } as const;
@@ -0,0 +1,404 @@
1
+ import { createEntityExecutor, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
3
+ import { and, eq, gte } from "drizzle-orm";
4
+ import { rollingCapAggregateId } from "./aggregate-id";
5
+ import {
6
+ CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
7
+ CapCounterHandlers,
8
+ ROLLING_INCREMENTED_EVENT_QN,
9
+ } from "./constants";
10
+ import { capCounterEntity } from "./entity";
11
+
12
+ // Temporal globally provided by the framework's polyfill init
13
+ // (ensureTemporalPolyfill() in time/polyfill.ts, called from
14
+ // setupTestStack/boot). Importing from "temporal-polyfill" gives us
15
+ // the polyfill-package types which don't quite match drizzle's
16
+ // `instant()`-customType (temporal-spec narrowing of `until(...).sign`).
17
+ // Mirror the audit-handler pattern: rely on the global ambient
18
+ // declaration from temporal-spec.
19
+
20
+ const { table } = createEntityExecutor("cap-counter", capCounterEntity);
21
+
22
+ // =============================================================================
23
+ // Cap-Toleranz-Multipliers
24
+ // =============================================================================
25
+
26
+ /**
27
+ * Cap-Toleranz-Profile — pro Cap-Typ asymmetrisch. Quelle: Memory
28
+ * `project_pricing_byok_caps` §3 + `docs/plans/marketing/produkt/
29
+ * pricing.md` Cap-Verhalten-Block.
30
+ *
31
+ * **Soft** = Notification-Schwelle (Multiplier × Limit). Bei
32
+ * Erreichen wird einmalig gewarnt (lastSoftWarnedAt setzt das Flag).
33
+ * Caller-Code emittiert die echte Notification.
34
+ *
35
+ * **Hard** = Schreibe-Block (Multiplier × Limit). enforceCap throwt
36
+ * mit `cap_exceeded`-Error → Dispatcher mapped 429 + Upgrade-Hint.
37
+ */
38
+ export type CapToleranceProfile = {
39
+ readonly soft: number;
40
+ readonly hard: number;
41
+ };
42
+
43
+ export const CAP_TOLERANCES = {
44
+ /** Mails / Tokens — billig, BYOK ab Pro. Burst-Buffer großzügig. */
45
+ burstable: { soft: 1.1, hard: 1.2 },
46
+ /** DB-Storage / File-Storage — teuer + persistent. Strikter Cut. */
47
+ storage: { soft: 1.0, hard: 1.05 },
48
+ /** Apps-Count, Plattform-Slots — gebuchte Kapazität, kein Buffer. */
49
+ hardSlot: { soft: 1.0, hard: 1.0 },
50
+ /** Egress — Bursty-Traffic legitim, nur extreme Spikes blockieren. */
51
+ egress: { soft: 1.1, hard: 1.3 },
52
+ } as const satisfies Readonly<Record<string, CapToleranceProfile>>;
53
+
54
+ export type CapToleranceProfileName = keyof typeof CAP_TOLERANCES;
55
+
56
+ // =============================================================================
57
+ // Enforcement-Result
58
+ // =============================================================================
59
+
60
+ export type EnforceCapResult =
61
+ /** Counter < softLimit. No action. */
62
+ | { readonly state: "ok"; readonly value: number }
63
+ /** softLimit ≤ counter < hardLimit. Warning emitted iff first time. */
64
+ | {
65
+ readonly state: "soft-hit";
66
+ readonly value: number;
67
+ /** True if this call CROSSED the soft-threshold and notified. */
68
+ readonly crossed: boolean;
69
+ };
70
+
71
+ // =============================================================================
72
+ // Enforce-Cap helper
73
+ // =============================================================================
74
+
75
+ /**
76
+ * Synchronous read-and-check of the calling tenant's counter for
77
+ * (capName, period). Returns:
78
+ * - "ok" when value < soft-threshold
79
+ * - "soft-hit" when soft ≤ value < hard, with `crossed=true` on the
80
+ * first hit per period (caller emits notification, then calls
81
+ * mark-soft-warned to flip the flag)
82
+ *
83
+ * **Throws** `CapExceededError` when value ≥ hard-threshold. Pre-save
84
+ * hooks call this BEFORE the actual write — the throw rolls back the
85
+ * transaction, the dispatcher maps the error to HTTP 429 with the
86
+ * upgrade-hint shape (see CapExceededError below).
87
+ *
88
+ * **Sync read implication:** the counter reflects the state at this
89
+ * exact transaction. Two parallel writes can each see "value < hard"
90
+ * and both pass — that's a race. Cap-tolerance-buffers (soft 110% /
91
+ * hard 120% for burstable caps) cover this; truly hard slots
92
+ * (apps-count) need stricter serialization at the create-handler
93
+ * level (e.g. uniqueness-index on apps.tenantId+slot-number).
94
+ */
95
+ export async function enforceCap(
96
+ ctx: HandlerContext,
97
+ options: {
98
+ readonly capName: string;
99
+ readonly periodStartIso: string;
100
+ readonly limit: number;
101
+ readonly profile: CapToleranceProfileName;
102
+ },
103
+ ): Promise<EnforceCapResult> {
104
+ if (!ctx.db) {
105
+ throw new Error("cap-counter.enforceCap: ctx.db missing — run inside a handler context");
106
+ }
107
+
108
+ const tolerance = CAP_TOLERANCES[options.profile];
109
+ const softThreshold = options.limit * tolerance.soft;
110
+ const hardThreshold = options.limit * tolerance.hard;
111
+
112
+ const rows = await ctx.db
113
+ .select()
114
+ .from(table)
115
+ .where(
116
+ and(eq(table["capName"], options.capName), eq(table["periodStart"], options.periodStartIso)),
117
+ )
118
+ .limit(1);
119
+
120
+ const row = rows[0];
121
+ const value = row ? (row["value"] as number) : 0;
122
+
123
+ if (value >= hardThreshold) {
124
+ throw new CapExceededError(options.capName, options.limit, value, tolerance);
125
+ }
126
+
127
+ if (value >= softThreshold) {
128
+ const lastSoftWarnedAt = row ? row["lastSoftWarnedAt"] : null;
129
+ return { state: "soft-hit", value, crossed: lastSoftWarnedAt === null };
130
+ }
131
+
132
+ return { state: "ok", value };
133
+ }
134
+
135
+ // =============================================================================
136
+ // Enforce-Rolling-Cap helper
137
+ // =============================================================================
138
+
139
+ /**
140
+ * Synchronous read-and-check of the calling tenant's Rolling-Window-
141
+ * Counter for `capName`. Reads the increment-events of the last
142
+ * `windowDays` from the event-store and sums their `amount`. Returns:
143
+ * - "ok" when sum < soft-threshold
144
+ * - "soft-hit" when soft ≤ sum < hard.
145
+ *
146
+ * **Throws** `CapExceededError` when sum ≥ hard-threshold.
147
+ *
148
+ * **`crossed`-flag fehlt absichtlich:** Anders als der Calendar-
149
+ * Counter hat das Rolling-Aggregate keine projection-row mit
150
+ * `lastSoftWarnedAt`-Flag. Dedup gegen Notification-Storm passiert
151
+ * im Caller (eigener key in einer Cache-Tabelle, oder einfache
152
+ * memoization für die Lebensdauer des Request). Der Result-Shape
153
+ * matcht trotzdem `EnforceCapResult` damit der gleiche Caller-Code
154
+ * gegen beide Funktionen funktioniert; `crossed` ist hier immer
155
+ * `false` (= "wir tracken's nicht").
156
+ *
157
+ * **Performance-Note:** der Read summiert ALLE Events im Window für
158
+ * (tenant, capName). Bei 10k+ Events/Tenant in der Window könnte
159
+ * das langsam werden — dann Migration auf eine Multi-Stream-
160
+ * Projection mit pre-aggregierten daily-buckets. Heute nicht
161
+ * vorgezogen.
162
+ */
163
+ export async function enforceRollingCap(
164
+ ctx: HandlerContext,
165
+ options: {
166
+ readonly capName: string;
167
+ readonly windowDays: number;
168
+ readonly limit: number;
169
+ readonly profile: CapToleranceProfileName;
170
+ },
171
+ ): Promise<EnforceCapResult> {
172
+ if (!ctx.db) {
173
+ throw new Error("cap-counter.enforceRollingCap: ctx.db missing — run inside a handler context");
174
+ }
175
+ if (!ctx.user?.tenantId) {
176
+ throw new Error(
177
+ "cap-counter.enforceRollingCap: ctx.user.tenantId missing — required to compute aggregate-id",
178
+ );
179
+ }
180
+
181
+ const tolerance = CAP_TOLERANCES[options.profile];
182
+ const softThreshold = options.limit * tolerance.soft;
183
+ const hardThreshold = options.limit * tolerance.hard;
184
+
185
+ const aggregateId = rollingCapAggregateId(ctx.user.tenantId, options.capName);
186
+ const cutoff = Temporal.Now.instant().subtract({ hours: options.windowDays * 24 });
187
+
188
+ // events_tenant_type_idx (tenant_id, aggregate_type, created_at)
189
+ // covers the prefix; the additional aggregate_id eq narrows to the
190
+ // single rolling-stream. Postgres can use the index even with the
191
+ // aggregate_id filter applied as a residual.
192
+ const rows = await ctx.db
193
+ .select({ payload: eventsTable.payload })
194
+ .from(eventsTable)
195
+ .where(
196
+ and(
197
+ eq(eventsTable.tenantId, ctx.user.tenantId),
198
+ eq(eventsTable.aggregateType, CAP_COUNTER_ROLLING_AGGREGATE_TYPE),
199
+ eq(eventsTable.aggregateId, aggregateId),
200
+ eq(eventsTable.type, ROLLING_INCREMENTED_EVENT_QN),
201
+ gte(eventsTable.createdAt, cutoff),
202
+ ),
203
+ );
204
+
205
+ let value = 0;
206
+ for (const row of rows) {
207
+ // @cast-boundary engine-payload — events.payload is jsonb (typed as
208
+ // unknown by drizzle's $type<Record<string,unknown>>); narrowing
209
+ // the shape here is a deliberate read-side contract for the
210
+ // rolling-incremented-event we authored.
211
+ const payload = row["payload"] as { amount?: number };
212
+ if (typeof payload.amount === "number") {
213
+ value += payload.amount;
214
+ }
215
+ }
216
+
217
+ if (value >= hardThreshold) {
218
+ throw new CapExceededError(options.capName, options.limit, value, tolerance);
219
+ }
220
+
221
+ if (value >= softThreshold) {
222
+ return { state: "soft-hit", value, crossed: false };
223
+ }
224
+
225
+ return { state: "ok", value };
226
+ }
227
+
228
+ // =============================================================================
229
+ // CapExceededError
230
+ // =============================================================================
231
+
232
+ /**
233
+ * Thrown by enforceCap when value ≥ hard-threshold. Includes enough
234
+ * context for the HTTP layer to render an actionable 429 — `code`
235
+ * matches the framework's error-contract pattern (kebab + scope).
236
+ *
237
+ * Caller-side mapping example (in your dispatcher error-handler):
238
+ * if (err instanceof CapExceededError) {
239
+ * return c.json(
240
+ * { error: { code: err.code, message: err.message, capName: err.capName, ... } },
241
+ * 429,
242
+ * );
243
+ * }
244
+ */
245
+ export class CapExceededError extends Error {
246
+ readonly code = "cap_exceeded" as const;
247
+ constructor(
248
+ readonly capName: string,
249
+ readonly limit: number,
250
+ readonly currentValue: number,
251
+ readonly tolerance: CapToleranceProfile,
252
+ ) {
253
+ super(
254
+ `Cap "${capName}" exceeded: current=${currentValue}, limit=${limit}, hard-threshold=${limit * tolerance.hard}. Upgrade tier or wait for next period reset.`,
255
+ );
256
+ this.name = "CapExceededError";
257
+ }
258
+ }
259
+
260
+ // =============================================================================
261
+ // Period-Helpers
262
+ // =============================================================================
263
+
264
+ /**
265
+ * Calendar-month period start in UTC. Use this for monthly caps
266
+ * (mails, egress).
267
+ *
268
+ * Returns ISO string for the 1st of the current month at 00:00 UTC.
269
+ */
270
+ export function currentCalendarMonthStartIso(
271
+ now: Temporal.Instant = Temporal.Now.instant(),
272
+ ): string {
273
+ const zoned = now.toZonedDateTimeISO("UTC");
274
+ const start = zoned.with({
275
+ day: 1,
276
+ hour: 0,
277
+ minute: 0,
278
+ second: 0,
279
+ millisecond: 0,
280
+ microsecond: 0,
281
+ nanosecond: 0,
282
+ });
283
+ return start.toInstant().toString();
284
+ }
285
+
286
+ // =============================================================================
287
+ // Notification-Wiring helpers — convenience-wrapper für enforceCap +
288
+ // enforceRollingCap, die einen Caller-supplied delivery-emit beim
289
+ // soft-hit-crossing ausführen. Cap-counter kennt delivery-feature
290
+ // nicht direkt — der Caller injiziert den emitter.
291
+ // =============================================================================
292
+
293
+ /**
294
+ * Soft-hit-notifier callback. Caller liefert die Funktion die ein
295
+ * delivery-event emittet (z.B. `delivery.send({to, template, payload})`).
296
+ * Wird genau einmal pro Period beim Calendar-Counter aufgerufen
297
+ * (`crossed === true` deduplicated via `markSoftWarnedHandler`).
298
+ *
299
+ * Beim Rolling-Counter ist `crossed` immer `false` — der Caller muss
300
+ * dort selbst dedup'en (oder bewusst pro request feuern lassen).
301
+ */
302
+ export type SoftHitNotifier = (info: {
303
+ readonly capName: string;
304
+ readonly value: number;
305
+ readonly limit: number;
306
+ readonly tenantId: string;
307
+ }) => Promise<void> | void;
308
+
309
+ /**
310
+ * Calendar-Period-enforcement + automatische soft-hit-Notification.
311
+ * Ruft `enforceCap`, bei `crossed: true` den Notifier UND
312
+ * `mark-soft-warned`-Handler (flippt `lastSoftWarnedAt` damit der
313
+ * nächste Aufruf in derselben Period nicht erneut feuert).
314
+ *
315
+ * Returnt das `EnforceCapResult` weiter — Caller kann die Logik
316
+ * verzweigen (z.B. UI-Toast bei soft-hit zusätzlich zur
317
+ * Email-Notification).
318
+ */
319
+ export async function enforceCapAndMaybeNotify(
320
+ ctx: HandlerContext,
321
+ options: {
322
+ readonly capName: string;
323
+ readonly periodStartIso: string;
324
+ readonly limit: number;
325
+ readonly profile: CapToleranceProfileName;
326
+ readonly notify: SoftHitNotifier;
327
+ },
328
+ ): Promise<EnforceCapResult> {
329
+ const result = await enforceCap(ctx, {
330
+ capName: options.capName,
331
+ periodStartIso: options.periodStartIso,
332
+ limit: options.limit,
333
+ profile: options.profile,
334
+ });
335
+
336
+ if (result.state === "soft-hit" && result.crossed) {
337
+ if (!ctx.user?.tenantId) {
338
+ throw new Error(
339
+ "cap-counter.enforceCapAndMaybeNotify: ctx.user.tenantId missing — required for notification",
340
+ );
341
+ }
342
+ await options.notify({
343
+ capName: options.capName,
344
+ value: result.value,
345
+ limit: options.limit,
346
+ tenantId: ctx.user.tenantId,
347
+ });
348
+ // Flip the soft-warned flag so the same period doesn't re-notify.
349
+ // We're already inside a write-handler-context, so dispatching the
350
+ // mark-soft-warned-handler in-line works via ctx.write (re-uses
351
+ // the request user; the handler's own access-check enforces the
352
+ // SystemAdmin role on the caller).
353
+ await ctx.write(CapCounterHandlers.markSoftWarned, {
354
+ capName: options.capName,
355
+ periodStartIso: options.periodStartIso,
356
+ });
357
+ }
358
+
359
+ return result;
360
+ }
361
+
362
+ /**
363
+ * Rolling-Window-enforcement + immer-feuert-Notification beim soft-hit.
364
+ *
365
+ * **Achtung Storm-Risk:** Rolling-Counter trackt `lastSoftWarnedAt`
366
+ * NICHT (kein projection-row). Bei jedem Aufruf während der Counter
367
+ * im soft-Bereich ist, feuert der notifier. Der Caller muss
368
+ * dedup'en — z.B. via Cache-Eintrag `lastNotified[capName]` mit TTL,
369
+ * oder er ruft `enforceRollingCapAndMaybeNotify` nur einmal pro
370
+ * Tag/Stunde auf (Hourly-Cron statt pro Request).
371
+ */
372
+ export async function enforceRollingCapAndMaybeNotify(
373
+ ctx: HandlerContext,
374
+ options: {
375
+ readonly capName: string;
376
+ readonly windowDays: number;
377
+ readonly limit: number;
378
+ readonly profile: CapToleranceProfileName;
379
+ readonly notify: SoftHitNotifier;
380
+ },
381
+ ): Promise<EnforceCapResult> {
382
+ const result = await enforceRollingCap(ctx, {
383
+ capName: options.capName,
384
+ windowDays: options.windowDays,
385
+ limit: options.limit,
386
+ profile: options.profile,
387
+ });
388
+
389
+ if (result.state === "soft-hit") {
390
+ if (!ctx.user?.tenantId) {
391
+ throw new Error(
392
+ "cap-counter.enforceRollingCapAndMaybeNotify: ctx.user.tenantId missing — required for notification",
393
+ );
394
+ }
395
+ await options.notify({
396
+ capName: options.capName,
397
+ value: result.value,
398
+ limit: options.limit,
399
+ tenantId: ctx.user.tenantId,
400
+ });
401
+ }
402
+
403
+ return result;
404
+ }
@@ -0,0 +1,48 @@
1
+ import {
2
+ createEntity,
3
+ createNumberField,
4
+ createTextField,
5
+ createTimestampField,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ // cap-counter — eine Row pro (tenantId, capName, periodStart). tenantId
9
+ // kommt automatisch als Base-Column (Kumiko Multi-Tenant-Default).
10
+ //
11
+ // **Identity-Modell:**
12
+ // - aggregate-id: UUID (Kumiko-ES-Pflicht)
13
+ // - public natural-key: (tenantId, capName, periodStart) — die Counter-
14
+ // Engine nutzt deterministic uuidv5 daraus, damit Increments gegen
15
+ // denselben Stream gehen statt zwei Rows pro Tenant+Cap zu erzeugen
16
+ //
17
+ // **Felder:**
18
+ // - capName: das Domain-Konzept ("platform-mails", "ai-tokens-7day",
19
+ // "db-storage-bytes"). Frei-form String — die App definiert ihre
20
+ // Cap-Names, die Engine kennt sie nicht enumerated.
21
+ // - value: aktueller Counter-Wert. Tokens, MB, Anzahl etc — Einheit
22
+ // ist app-Sache, die Engine zählt nur Zahlen.
23
+ // - periodStart: Timestamp wann das aktuelle Counter-Period begann.
24
+ // Calendar-Month-Reset setzt einen neuen periodStart (= neue Aggregate-
25
+ // Identität). Rolling-Window (z.B. KI-Tokens 7-day) setzt periodStart
26
+ // einfach nicht zurück und filtert beim Read mit `WHERE timestamp >
27
+ // now() - 7d` über den Event-Store-Stream — das ist Caller-Sache.
28
+ // - lastSoftWarnedAt: Anti-Notification-Storm. Wenn Soft-Cap @ 110%
29
+ // einmal erreicht ist, soll nicht jeder Folge-Increment eine Mail an
30
+ // den Admin schicken. Pro Period maximal eine Soft-Warning, daher
31
+ // diese Spalte; nullable, wird beim Reset auf null zurückgesetzt.
32
+ //
33
+ // **Was bewusst NICHT in der Entity steht:**
34
+ // - softLimit / hardLimit / cap-toleranz-multipliers — die kommen aus
35
+ // der App-TierMap zur enforceCap-Aufruf-Zeit. Counter weiß nichts
36
+ // vom Tier, nur von "wie viele zähle ich".
37
+ // - userId / aggregate-Reference — Counter sind Plattform-Tenant-
38
+ // scoped, nicht User-scoped (auch wenn ein User den Increment
39
+ // auslöst).
40
+ export const capCounterEntity = createEntity({
41
+ table: "read_cap_counters",
42
+ fields: {
43
+ capName: createTextField({ required: true, maxLength: 100 }),
44
+ value: createNumberField({ required: true, default: 0 }),
45
+ periodStart: createTimestampField({ required: true }),
46
+ lastSoftWarnedAt: createTimestampField(),
47
+ },
48
+ });
@@ -0,0 +1,90 @@
1
+ // cap-counter — Counter-Storage + Increment-API + Soft-Warn-State.
2
+ //
3
+ // **Was diese Feature liefert:**
4
+ // 1. r.entity("cap-counter") — Counter-Rows pro (tenant, capName,
5
+ // period) für Calendar-Period-Caps (Mails/Monat, Egress/Monat).
6
+ // 2. increment-Handler — atomic counter increment via deterministic
7
+ // aggregate-id (try-create / executor-update). Race-frei via
8
+ // event-store optimistic-lock.
9
+ // 3. increment-rolling-Handler (Sprint 4) — append-only Custom-Event-
10
+ // Stream für Rolling-Window-Caps (KI-Tokens-7d, Egress-24h). Kein
11
+ // projection — der Wert kommt im Read aus dem Event-Stream.
12
+ // 4. mark-soft-warned-Handler — flippt das Anti-Notification-Storm-
13
+ // Flag (nur Calendar-Period-Counter).
14
+ // 5. get-counter-Query — sync read der aktuellen Counter-Value
15
+ // (Calendar).
16
+ // 6. enforceCap + enforceRollingCap-Helper (siehe enforce-cap.ts) —
17
+ // Pre-Save-Wrapper mit asymmetrischen Soft/Hard-Toleranzen pro
18
+ // Cap-Profile.
19
+ //
20
+ // **Calendar vs. Rolling — wann welches:**
21
+ // - **Calendar-Period** (incrementCap + enforceCap): Cap resettet
22
+ // sich am Period-Start (1. des Monats etc.). Counter ist 1 Row in
23
+ // der projection. Schneller Read.
24
+ // - **Rolling-Window** (incrementRollingCap + enforceRollingCap):
25
+ // Cap rollt kontinuierlich (z.B. "letzten 7 Tage"). Werte
26
+ // verfallen Event-für-Event ohne Reset. Kein projection — Read
27
+ // summiert über die letzten N Tage Events.
28
+ //
29
+ // **Was diese Feature NICHT macht:**
30
+ // - **Kein Foundation-Wiring.** mail-foundation / file-foundation /
31
+ // ai-foundation sind heute BYOK-default und haben keinen Plattform-
32
+ // Pool zum Zählen. Cap-Counter ist generic — wenn ein App-Owner
33
+ // den Counter nutzen will, ruft er `incrementCap(...)` /
34
+ // `incrementRollingCap(...)` aus seinem eigenen Handler auf.
35
+ // - **Kein Reset-Cron für Calendar-Period.** Funktioniert ohne —
36
+ // der periodStartIso-Bestandteil der aggregate-id rollt am
37
+ // Period-Tick natürlich auf einen frischen Counter. Alte Rows
38
+ // bleiben für Audit liegen.
39
+ // - **Kein Notification-Pfad als Hard-Wiring.** Cap-counter
40
+ // entkoppelt — `enforceCapAndMaybeNotify` (siehe enforce-cap.ts)
41
+ // ist ein Convenience-Helper, der einen Caller-supplied
42
+ // emit-Callback ausführt; cap-counter kennt delivery-feature
43
+ // nicht direkt.
44
+ //
45
+ // **Boot-Dependencies:** keine. cap-counter ist Plain-Vanilla — kein
46
+ // config, kein secrets, kein tenant-feature nötig. Tenant-Scoping kommt
47
+ // vom Framework-Default (Base-Column tenantId).
48
+
49
+ import {
50
+ defineEntityListHandler,
51
+ defineFeature,
52
+ type FeatureDefinition,
53
+ } from "@cosmicdrift/kumiko-framework/engine";
54
+ import { CAP_COUNTER_FEATURE, ROLLING_INCREMENTED_EVENT_SHORT } from "./constants";
55
+ import { capCounterEntity } from "./entity";
56
+ import { getCounterQuery } from "./handlers/get-counter.query";
57
+ import { incrementCapHandler } from "./handlers/increment.write";
58
+ import {
59
+ incrementRollingCapHandler,
60
+ rollingIncrementedSchema,
61
+ } from "./handlers/increment-rolling.write";
62
+ import { markSoftWarnedHandler } from "./handlers/mark-soft-warned.write";
63
+
64
+ const sysadminAccess = { access: { roles: ["SystemAdmin"] } } as const;
65
+
66
+ export const capCounterFeature: FeatureDefinition = defineFeature(CAP_COUNTER_FEATURE, (r) => {
67
+ r.entity("cap-counter", capCounterEntity);
68
+
69
+ // Custom Domain-Event für Rolling-Counter. r.defineEvent registriert
70
+ // das Schema beim Registry; ctx.appendEventUnsafe im Handler nutzt
71
+ // dasselbe Schema für Append-Time-Validation. QN nach Prefixing:
72
+ // "cap-counter:event:rolling-incremented" (siehe
73
+ // ROLLING_INCREMENTED_EVENT_QN).
74
+ r.defineEvent(ROLLING_INCREMENTED_EVENT_SHORT, rollingIncrementedSchema);
75
+
76
+ // Custom write-handlers.
77
+ // - increment: Calendar-Period (CRUD via projection-row).
78
+ // - increment-rolling: Rolling-Window (Custom-Event, no projection).
79
+ // - mark-soft-warned: Anti-Notification-Storm-Flag (nur Calendar).
80
+ r.writeHandler(incrementCapHandler);
81
+ r.writeHandler(incrementRollingCapHandler);
82
+ r.writeHandler(markSoftWarnedHandler);
83
+
84
+ // Custom + standard reads. Sysadmin-cross-tenant via list, per-tenant
85
+ // single-row via get-counter. Detail-by-id-handler bewusst weggelassen
86
+ // (kein Use-Case; der natürliche Lookup ist über capName + period, nicht
87
+ // über aggregate-id).
88
+ r.queryHandler(defineEntityListHandler("cap-counter", capCounterEntity, sysadminAccess));
89
+ r.queryHandler(getCounterQuery);
90
+ });
@@ -0,0 +1,43 @@
1
+ import { createEntityExecutor, type QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { and, eq } from "drizzle-orm";
3
+ import { z } from "zod";
4
+ import { capCounterEntity } from "../entity";
5
+
6
+ const { table } = createEntityExecutor("cap-counter", capCounterEntity);
7
+
8
+ // get-counter — return the current counter row for (calling tenant,
9
+ // capName, periodStartIso). Returns null if no increment has happened
10
+ // in this period yet — caller treats that as "value = 0, no warning
11
+ // flagged".
12
+ //
13
+ // **Composition:** enforceCap-helper consumes this. UIs that show
14
+ // remaining-quota call it directly.
15
+ const getCounterSchema = z.object({
16
+ capName: z.string().min(1).max(100),
17
+ periodStartIso: z.string().min(1),
18
+ });
19
+
20
+ export const getCounterQuery: QueryHandlerDef = {
21
+ name: "get-counter",
22
+ schema: getCounterSchema,
23
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
24
+ handler: async (query, ctx) => {
25
+ const { capName, periodStartIso } = query.payload as z.infer<typeof getCounterSchema>;
26
+
27
+ // ctx.db is tenant-scoped; filter by capName + periodStart explicitly.
28
+ const rows = await ctx.db
29
+ .select()
30
+ .from(table)
31
+ .where(
32
+ and(
33
+ eq(table["capName"], capName),
34
+ // periodStart is stored as Temporal.Instant; compare against
35
+ // the iso string directly (timestamptz-column round-trips).
36
+ eq(table["periodStart"], periodStartIso),
37
+ ),
38
+ )
39
+ .limit(1);
40
+
41
+ return rows[0] ?? null;
42
+ },
43
+ };