@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.
- package/package.json +90 -0
- package/src/audit/__tests__/audit.integration.ts +328 -0
- package/src/audit/constants.ts +7 -0
- package/src/audit/feature.ts +23 -0
- package/src/audit/handlers/list.query.ts +98 -0
- package/src/audit/index.ts +2 -0
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
- package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
- package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
- package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
- package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
- package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
- package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
- package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
- package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
- package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
- package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
- package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
- package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
- package/src/auth-email-password/auth-user-row.ts +41 -0
- package/src/auth-email-password/constants.ts +101 -0
- package/src/auth-email-password/email-templates.ts +283 -0
- package/src/auth-email-password/feature.ts +140 -0
- package/src/auth-email-password/handlers/change-password.write.ts +58 -0
- package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
- package/src/auth-email-password/handlers/login.write.ts +208 -0
- package/src/auth-email-password/handlers/logout.write.ts +12 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
- package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
- package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
- package/src/auth-email-password/i18n.ts +211 -0
- package/src/auth-email-password/identity-v3-hash.ts +97 -0
- package/src/auth-email-password/index.ts +35 -0
- package/src/auth-email-password/invite-token-store.ts +92 -0
- package/src/auth-email-password/lockout-store.ts +118 -0
- package/src/auth-email-password/password-hashing.ts +43 -0
- package/src/auth-email-password/reset-token.ts +28 -0
- package/src/auth-email-password/seeding.ts +183 -0
- package/src/auth-email-password/signed-token.ts +85 -0
- package/src/auth-email-password/signup-token-store.ts +104 -0
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/testing.ts +5 -0
- package/src/auth-email-password/token-burn-store.ts +57 -0
- package/src/auth-email-password/verification-token.ts +27 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
- package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
- package/src/auth-email-password/web/auth-client.ts +350 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
- package/src/auth-email-password/web/auth-gate.tsx +33 -0
- package/src/auth-email-password/web/client-plugin.ts +48 -0
- package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
- package/src/auth-email-password/web/index.ts +56 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
- package/src/auth-email-password/web/login-screen.tsx +150 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
- package/src/auth-email-password/web/session.tsx +171 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
- package/src/auth-email-password/web/signup-screen.tsx +130 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
- package/src/auth-email-password/web/use-shell-user.ts +34 -0
- package/src/auth-email-password/web/user-menu.tsx +89 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
- package/src/billing-foundation/__tests__/feature.test.ts +110 -0
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
- package/src/billing-foundation/aggregate-id.ts +21 -0
- package/src/billing-foundation/constants.ts +70 -0
- package/src/billing-foundation/entities.ts +50 -0
- package/src/billing-foundation/events.ts +71 -0
- package/src/billing-foundation/feature.ts +122 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
- package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
- package/src/billing-foundation/handlers/process-event.write.ts +160 -0
- package/src/billing-foundation/index.ts +42 -0
- package/src/billing-foundation/projection.ts +135 -0
- package/src/billing-foundation/types.ts +157 -0
- package/src/billing-foundation/webhook-handler.ts +184 -0
- package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
- package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
- package/src/cap-counter/aggregate-id.ts +61 -0
- package/src/cap-counter/constants.ts +32 -0
- package/src/cap-counter/enforce-cap.ts +404 -0
- package/src/cap-counter/entity.ts +48 -0
- package/src/cap-counter/feature.ts +90 -0
- package/src/cap-counter/handlers/get-counter.query.ts +43 -0
- package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
- package/src/cap-counter/handlers/increment.write.ts +92 -0
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
- package/src/cap-counter/index.ts +34 -0
- package/src/cap-counter/with-cap-enforcement.ts +179 -0
- package/src/channel-email/email-channel.ts +48 -0
- package/src/channel-email/feature.ts +15 -0
- package/src/channel-email/index.ts +4 -0
- package/src/channel-email/smtp-transport.ts +65 -0
- package/src/channel-email/types.ts +34 -0
- package/src/channel-in-app/constants.ts +11 -0
- package/src/channel-in-app/feature.ts +30 -0
- package/src/channel-in-app/handlers/inbox.query.ts +28 -0
- package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
- package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
- package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
- package/src/channel-in-app/in-app-channel.ts +44 -0
- package/src/channel-in-app/index.ts +4 -0
- package/src/channel-in-app/tables.ts +22 -0
- package/src/channel-push/feature.ts +15 -0
- package/src/channel-push/index.ts +3 -0
- package/src/channel-push/push-channel.ts +33 -0
- package/src/channel-push/types.ts +22 -0
- package/src/config/__tests__/app-overrides.test.ts +118 -0
- package/src/config/__tests__/config.integration.ts +1246 -0
- package/src/config/constants.ts +23 -0
- package/src/config/feature.ts +117 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
- package/src/config/handlers/reset.write.ts +45 -0
- package/src/config/handlers/schema.query.ts +22 -0
- package/src/config/handlers/set.write.ts +93 -0
- package/src/config/handlers/values.query.ts +43 -0
- package/src/config/index.ts +15 -0
- package/src/config/resolver.ts +283 -0
- package/src/config/table.ts +35 -0
- package/src/config/write-helpers.ts +268 -0
- package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
- package/src/delivery/__tests__/delivery.integration.ts +1405 -0
- package/src/delivery/constants.ts +33 -0
- package/src/delivery/delivery-service.ts +489 -0
- package/src/delivery/events.ts +18 -0
- package/src/delivery/feature.ts +70 -0
- package/src/delivery/handlers/log.query.ts +21 -0
- package/src/delivery/handlers/preferences.query.ts +18 -0
- package/src/delivery/handlers/set-preference.write.ts +28 -0
- package/src/delivery/index.ts +35 -0
- package/src/delivery/tables.ts +74 -0
- package/src/delivery/testing.ts +47 -0
- package/src/delivery/types.ts +71 -0
- package/src/delivery/unsubscribe.ts +99 -0
- package/src/delivery/upsert-preference.ts +145 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
- package/src/feature-toggles/constants.ts +20 -0
- package/src/feature-toggles/events.ts +18 -0
- package/src/feature-toggles/feature.ts +98 -0
- package/src/feature-toggles/global-feature-state-table.ts +28 -0
- package/src/feature-toggles/handlers/list.query.ts +26 -0
- package/src/feature-toggles/handlers/registered.query.ts +56 -0
- package/src/feature-toggles/handlers/set.write.ts +158 -0
- package/src/feature-toggles/index.ts +9 -0
- package/src/feature-toggles/toggle-runtime.ts +73 -0
- package/src/file-foundation/__tests__/feature.test.ts +35 -0
- package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
- package/src/file-foundation/feature.ts +123 -0
- package/src/file-foundation/index.ts +7 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
- package/src/file-provider-inmemory/feature.ts +73 -0
- package/src/file-provider-inmemory/index.ts +3 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
- package/src/file-provider-s3/feature.ts +169 -0
- package/src/file-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
- package/src/files-provider-s3/env-helper.ts +49 -0
- package/src/files-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/s3-provider.ts +114 -0
- package/src/foundation-shared/config-helpers.ts +67 -0
- package/src/foundation-shared/index.ts +4 -0
- package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
- package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
- package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
- package/src/jobs/constants.ts +21 -0
- package/src/jobs/events.ts +39 -0
- package/src/jobs/feature.ts +150 -0
- package/src/jobs/handlers/detail.query.ts +30 -0
- package/src/jobs/handlers/list.query.ts +36 -0
- package/src/jobs/handlers/retry.write.ts +69 -0
- package/src/jobs/handlers/trigger.write.ts +39 -0
- package/src/jobs/index.ts +5 -0
- package/src/jobs/job-run-logger.ts +213 -0
- package/src/jobs/job-run-table.ts +55 -0
- package/src/legal-pages/README.md +195 -0
- package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
- package/src/legal-pages/constants.ts +36 -0
- package/src/legal-pages/feature.ts +187 -0
- package/src/legal-pages/index.ts +13 -0
- package/src/legal-pages/markdown.ts +69 -0
- package/src/mail-foundation/__tests__/feature.test.ts +46 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
- package/src/mail-foundation/feature.ts +160 -0
- package/src/mail-foundation/index.ts +14 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
- package/src/mail-transport-inmemory/feature.ts +90 -0
- package/src/mail-transport-inmemory/index.ts +3 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
- package/src/mail-transport-smtp/feature.ts +182 -0
- package/src/mail-transport-smtp/index.ts +3 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
- package/src/rate-limiting/constants.ts +9 -0
- package/src/rate-limiting/feature.ts +16 -0
- package/src/rate-limiting/handlers/status.query.ts +52 -0
- package/src/rate-limiting/index.ts +2 -0
- package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
- package/src/renderer-simple/feature.ts +12 -0
- package/src/renderer-simple/index.ts +2 -0
- package/src/renderer-simple/simple-renderer.ts +72 -0
- package/src/secrets/__tests__/rotate.integration.ts +176 -0
- package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
- package/src/secrets/__tests__/secrets.integration.ts +118 -0
- package/src/secrets/feature.ts +84 -0
- package/src/secrets/handlers/delete.write.ts +20 -0
- package/src/secrets/handlers/list.query.ts +38 -0
- package/src/secrets/handlers/rotate.job.ts +193 -0
- package/src/secrets/handlers/set.write.ts +50 -0
- package/src/secrets/index.ts +16 -0
- package/src/secrets/secrets-context.ts +296 -0
- package/src/secrets/table.ts +68 -0
- package/src/sessions/__tests__/cleanup.integration.ts +175 -0
- package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
- package/src/sessions/__tests__/sessions.integration.ts +472 -0
- package/src/sessions/__tests__/test-helpers.ts +66 -0
- package/src/sessions/constants.ts +43 -0
- package/src/sessions/feature.ts +84 -0
- package/src/sessions/handlers/cleanup.job.ts +109 -0
- package/src/sessions/handlers/list.query.ts +35 -0
- package/src/sessions/handlers/mine.query.ts +37 -0
- package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
- package/src/sessions/handlers/revoke.write.ts +76 -0
- package/src/sessions/index.ts +17 -0
- package/src/sessions/schema/index.ts +5 -0
- package/src/sessions/schema/user-session.ts +67 -0
- package/src/sessions/session-callbacks.ts +110 -0
- package/src/sessions/testing.ts +42 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
- package/src/subscription-mollie/constants.ts +33 -0
- package/src/subscription-mollie/feature.ts +144 -0
- package/src/subscription-mollie/index.ts +13 -0
- package/src/subscription-mollie/plugin-methods.ts +79 -0
- package/src/subscription-mollie/verify-webhook.ts +244 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
- package/src/subscription-stripe/constants.ts +20 -0
- package/src/subscription-stripe/feature.ts +120 -0
- package/src/subscription-stripe/index.ts +14 -0
- package/src/subscription-stripe/plugin-methods.ts +91 -0
- package/src/subscription-stripe/verify-webhook.ts +235 -0
- package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
- package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
- package/src/tenant/__tests__/tenant.integration.ts +347 -0
- package/src/tenant/command-schemas.ts +37 -0
- package/src/tenant/constants.ts +37 -0
- package/src/tenant/feature.ts +109 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
- package/src/tenant/handlers/add-member.write.ts +53 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
- package/src/tenant/handlers/create.write.ts +21 -0
- package/src/tenant/handlers/disable.write.ts +18 -0
- package/src/tenant/handlers/invitations.query.ts +31 -0
- package/src/tenant/handlers/list.query.ts +17 -0
- package/src/tenant/handlers/me.query.ts +17 -0
- package/src/tenant/handlers/members.query.ts +22 -0
- package/src/tenant/handlers/memberships.query.ts +24 -0
- package/src/tenant/handlers/remove-member.write.ts +40 -0
- package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
- package/src/tenant/handlers/update-member-roles.write.ts +54 -0
- package/src/tenant/handlers/update.write.ts +20 -0
- package/src/tenant/index.ts +12 -0
- package/src/tenant/invitation-table.ts +93 -0
- package/src/tenant/membership-table.ts +35 -0
- package/src/tenant/schema/index.ts +5 -0
- package/src/tenant/schema/tenant.ts +27 -0
- package/src/tenant/seeding.ts +155 -0
- package/src/tenant/testing.ts +8 -0
- package/src/text-content/README.md +190 -0
- package/src/text-content/__tests__/text-content.integration.ts +415 -0
- package/src/text-content/api.ts +92 -0
- package/src/text-content/constants.ts +19 -0
- package/src/text-content/feature.ts +29 -0
- package/src/text-content/handlers/by-slug.query.ts +55 -0
- package/src/text-content/handlers/set.write.ts +118 -0
- package/src/text-content/index.ts +14 -0
- package/src/text-content/seeding.ts +91 -0
- package/src/text-content/table.ts +45 -0
- package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
- package/src/tier-engine/__tests__/drift.test.ts +42 -0
- package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
- package/src/tier-engine/aggregate-id.ts +27 -0
- package/src/tier-engine/compose-app.ts +150 -0
- package/src/tier-engine/constants.ts +15 -0
- package/src/tier-engine/entity.ts +30 -0
- package/src/tier-engine/feature.ts +72 -0
- package/src/tier-engine/handlers/active-tier.query.ts +23 -0
- package/src/tier-engine/index.ts +22 -0
- package/src/user/__tests__/seed-testing.integration.ts +127 -0
- package/src/user/__tests__/user.integration.ts +198 -0
- package/src/user/command-schemas.ts +15 -0
- package/src/user/constants.ts +23 -0
- package/src/user/feature.ts +32 -0
- package/src/user/handlers/create.write.ts +54 -0
- package/src/user/handlers/detail.query.ts +9 -0
- package/src/user/handlers/find-for-auth.query.ts +38 -0
- package/src/user/handlers/list.query.ts +8 -0
- package/src/user/handlers/me.query.ts +15 -0
- package/src/user/handlers/update.write.ts +54 -0
- package/src/user/index.ts +4 -0
- package/src/user/schema/index.ts +5 -0
- package/src/user/schema/user.ts +69 -0
- package/src/user/seeding.ts +93 -0
- 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
|
+
};
|