@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,20 @@
|
|
|
1
|
+
// Fully-qualified event name for feature-toggle changes. Kept as a constant
|
|
2
|
+
// so write-handler + tests reference one source.
|
|
3
|
+
export const FEATURE_TOGGLE_SET_EVENT_NAME = "feature-toggles:event:toggle-set";
|
|
4
|
+
|
|
5
|
+
// Aggregate type for toggle-set events. Shares the feature name to keep the
|
|
6
|
+
// events-table grep-friendly: every row belonging to this feature carries
|
|
7
|
+
// "feature-toggles" in its aggregate_type column.
|
|
8
|
+
export const FEATURE_TOGGLE_AGGREGATE_TYPE = "feature-toggles";
|
|
9
|
+
|
|
10
|
+
// Error reasons surfaced from feature-toggle handlers. Scoped to the
|
|
11
|
+
// feature's namespace per the framework's reason-convention.
|
|
12
|
+
export const FeatureToggleErrors = {
|
|
13
|
+
// set-handler attempted to toggle a feature that didn't declare
|
|
14
|
+
// r.toggleable(). The dispatcher's gate ignores such features anyway,
|
|
15
|
+
// but writing a row for them would create the illusion of configurability.
|
|
16
|
+
notToggleable: "feature_not_toggleable",
|
|
17
|
+
// set-handler attempted to toggle a feature name that isn't registered.
|
|
18
|
+
// Prevents typos from silently piling up orphan rows.
|
|
19
|
+
unknownFeature: "unknown_feature",
|
|
20
|
+
} as const;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// Toggle-change event payload.
|
|
4
|
+
//
|
|
5
|
+
// Contract: every set-operation produces exactly one event, even when
|
|
6
|
+
// enabled === previousEnabled. Redundant writes are legal (confirms the
|
|
7
|
+
// current state, useful for ops "make sure feature X is on"), so consumers
|
|
8
|
+
// that filter for actual transitions must compare enabled !== previousEnabled
|
|
9
|
+
// themselves. `previousEnabled` is null when this is the first time the
|
|
10
|
+
// feature is being toggled (no row existed).
|
|
11
|
+
export const featureToggleSetSchema = z.object({
|
|
12
|
+
featureName: z.string().min(1),
|
|
13
|
+
enabled: z.boolean(),
|
|
14
|
+
previousEnabled: z.boolean().nullable(),
|
|
15
|
+
updatedBy: z.string(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export type FeatureToggleSetPayload = z.infer<typeof featureToggleSetSchema>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { FEATURE_TOGGLE_SET_EVENT_NAME } from "./constants";
|
|
3
|
+
import { featureToggleSetSchema } from "./events";
|
|
4
|
+
import { listQuery } from "./handlers/list.query";
|
|
5
|
+
import { registeredQuery } from "./handlers/registered.query";
|
|
6
|
+
import { createSetWriteHandler } from "./handlers/set.write";
|
|
7
|
+
import type { GlobalFeatureToggleRuntime } from "./toggle-runtime";
|
|
8
|
+
|
|
9
|
+
// IMPORTANT: feature-toggles itself is NOT r.toggleable. Making it
|
|
10
|
+
// toggleable would brick the system — once disabled, no handler of this
|
|
11
|
+
// feature is reachable to turn it back on. The boot-validator won't catch
|
|
12
|
+
// this (it only warns about dependency shapes), so the guarantee lives in
|
|
13
|
+
// this file: do not add r.toggleable() here.
|
|
14
|
+
|
|
15
|
+
export type FeatureTogglesOptions = {
|
|
16
|
+
// Accessor for the in-memory snapshot the dispatcher gate reads. Must
|
|
17
|
+
// return a resolved runtime by the time the feature's set-handler is
|
|
18
|
+
// called — NOT by the time the feature is registered. This matters
|
|
19
|
+
// because createFeatureToggleRuntime needs the registry that
|
|
20
|
+
// setupTestStack / buildServer builds from the feature list, so the
|
|
21
|
+
// runtime and the feature are chicken-and-egg at wire-up time. Passing
|
|
22
|
+
// an accessor (vs the runtime directly) lets the caller close over a
|
|
23
|
+
// mutable holder.
|
|
24
|
+
//
|
|
25
|
+
// Production setup: resolve the runtime after buildServer returns, then
|
|
26
|
+
// pass `() => runtime`. For tests, use createLateBoundHolder + .get().
|
|
27
|
+
readonly getRuntime: () => GlobalFeatureToggleRuntime;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function createFeatureTogglesFeature(options: FeatureTogglesOptions): FeatureDefinition {
|
|
31
|
+
return defineFeature("feature-toggles", (r) => {
|
|
32
|
+
r.systemScope();
|
|
33
|
+
|
|
34
|
+
// Toggle-change domain event. The event ends up in the events-table
|
|
35
|
+
// alongside every other write — audit.list picks it up automatically,
|
|
36
|
+
// no dedicated projection needed. Qualified name after prefixing:
|
|
37
|
+
// "feature-toggles:event:toggle-set" (see constants.FEATURE_TOGGLE_SET_EVENT_NAME).
|
|
38
|
+
r.defineEvent("toggle-set", featureToggleSetSchema);
|
|
39
|
+
|
|
40
|
+
const handlers = {
|
|
41
|
+
set: r.writeHandler(createSetWriteHandler(options.getRuntime)),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const queries = {
|
|
45
|
+
list: r.queryHandler(listQuery),
|
|
46
|
+
registered: r.queryHandler(registeredQuery),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// toggle-cache-sync — multi-instance snapshot propagation. Every
|
|
50
|
+
// API/worker instance runs its own dispatcher cursor on this MSP
|
|
51
|
+
// (delivery: "per-instance") and converges its in-memory snapshot on
|
|
52
|
+
// every toggle-set event it observes. Named "cache-sync" (not
|
|
53
|
+
// "projection" or "audit") because it's side-effect-only
|
|
54
|
+
// infrastructure — the framework's boot-validator also rejects
|
|
55
|
+
// per-instance MSPs that carry a `table`.
|
|
56
|
+
//
|
|
57
|
+
// Why this is correct alongside the set-handler's own `runtime.apply`:
|
|
58
|
+
// - local apply = immediate response-latency optimization so the
|
|
59
|
+
// next request on the same instance sees the flip without a
|
|
60
|
+
// dispatcher-tick round-trip
|
|
61
|
+
// - MSP = multi-instance propagation + crash-recovery. If a process
|
|
62
|
+
// crashes between appendEvent (persisted) and the local apply
|
|
63
|
+
// (volatile), the MSP rebuilds the snapshot on restart; if
|
|
64
|
+
// instance B never ran the write, the MSP is how it learns. Both
|
|
65
|
+
// paths are idempotent — apply is Map.set, replay on boot just
|
|
66
|
+
// converges to the DB state that initialize() already loaded.
|
|
67
|
+
//
|
|
68
|
+
// Requires: options.getRuntime() must resolve by the time the
|
|
69
|
+
// dispatcher processes its first toggle-set event. The holder-based
|
|
70
|
+
// wire-up (see FeatureTogglesOptions.getRuntime docstring) guarantees
|
|
71
|
+
// this in setupTestStack and production boot.
|
|
72
|
+
r.multiStreamProjection({
|
|
73
|
+
name: "toggle-cache-sync",
|
|
74
|
+
delivery: "per-instance",
|
|
75
|
+
apply: {
|
|
76
|
+
[FEATURE_TOGGLE_SET_EVENT_NAME]: async (event) => {
|
|
77
|
+
// The event payload shape is guaranteed by featureToggleSetSchema
|
|
78
|
+
// (validated on append). Shallow-cast to a typed shape rather
|
|
79
|
+
// than re-parsing — the payload round-trips through JSON and is
|
|
80
|
+
// fixed at the source.
|
|
81
|
+
const payload = event.payload as { featureName: string; enabled: boolean };
|
|
82
|
+
options.getRuntime().apply(payload.featureName, payload.enabled);
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return { handlers, queries };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { FEATURE_TOGGLE_SET_EVENT_NAME, FeatureToggleErrors } from "./constants";
|
|
92
|
+
export { globalFeatureStateTable } from "./global-feature-state-table";
|
|
93
|
+
// Re-export the runtime factory + class so app-boot code has a single
|
|
94
|
+
// import path: "@cosmicdrift/kumiko-bundled-features/feature-toggles".
|
|
95
|
+
export {
|
|
96
|
+
createFeatureToggleRuntime,
|
|
97
|
+
GlobalFeatureToggleRuntime,
|
|
98
|
+
} from "./toggle-runtime";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {
|
|
2
|
+
boolean,
|
|
3
|
+
instant,
|
|
4
|
+
integer,
|
|
5
|
+
table as pgTable,
|
|
6
|
+
text,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
+
import { sql } from "drizzle-orm";
|
|
9
|
+
|
|
10
|
+
// Global feature-toggle override state. One row per feature that has ever
|
|
11
|
+
// been explicitly flipped by an operator. Missing row = "no override,
|
|
12
|
+
// fall back to the feature's r.toggleable({ default }) value".
|
|
13
|
+
//
|
|
14
|
+
// PK is featureName (text) — not a surrogate UUID — because the feature
|
|
15
|
+
// name IS the identity here. No tenantId: this is a global override that
|
|
16
|
+
// applies across every tenant (per-tenant toggles are intentionally out of
|
|
17
|
+
// scope, see core-feature-toggles.md).
|
|
18
|
+
export const globalFeatureStateTable = pgTable("read_global_feature_state", {
|
|
19
|
+
featureName: text("feature_name").primaryKey(),
|
|
20
|
+
enabled: boolean("enabled").notNull(),
|
|
21
|
+
// Optimistic-lock column. The set-handler reads the existing row, then
|
|
22
|
+
// updates with `WHERE feature_name = ? AND version = ?`; a 0-row update
|
|
23
|
+
// means someone else wrote concurrently — the handler retries the fetch.
|
|
24
|
+
version: integer("version").notNull().default(1),
|
|
25
|
+
updatedAt: instant("updated_at").default(sql`now()`).notNull(),
|
|
26
|
+
// UserId (text — SessionUser.id is a uuid string post-ES).
|
|
27
|
+
updatedBy: text("updated_by"),
|
|
28
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { globalFeatureStateTable } from "../global-feature-state-table";
|
|
4
|
+
|
|
5
|
+
// List every row in the global_feature_state table — i.e. every feature
|
|
6
|
+
// that has ever been explicitly flipped. Features without a row aren't
|
|
7
|
+
// returned; callers must combine this with `registered` to see the full
|
|
8
|
+
// effective state (registered features + their current override, if any).
|
|
9
|
+
export const listQuery = defineQueryHandler({
|
|
10
|
+
name: "list",
|
|
11
|
+
schema: z.object({}),
|
|
12
|
+
access: { roles: ["SystemAdmin", "Admin"] },
|
|
13
|
+
handler: async (_event, ctx) => {
|
|
14
|
+
type Row = typeof globalFeatureStateTable.$inferSelect;
|
|
15
|
+
const rows = (await ctx.db.select().from(globalFeatureStateTable)) as Row[];
|
|
16
|
+
return {
|
|
17
|
+
items: rows.map((r) => ({
|
|
18
|
+
featureName: r.featureName,
|
|
19
|
+
enabled: r.enabled,
|
|
20
|
+
version: r.version,
|
|
21
|
+
updatedAt: r.updatedAt.toString(),
|
|
22
|
+
updatedBy: r.updatedBy,
|
|
23
|
+
})),
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { globalFeatureStateTable } from "../global-feature-state-table";
|
|
4
|
+
|
|
5
|
+
// Inventory of every registered feature, annotated with toggle metadata
|
|
6
|
+
// and the current effective state. This is the canonical "what's here,
|
|
7
|
+
// what's on, what depends on what" snapshot — the UI for the operator
|
|
8
|
+
// toggle screen binds to it.
|
|
9
|
+
//
|
|
10
|
+
// Design: registry introspection (toggleable/default/requires) + a single
|
|
11
|
+
// DB read of overrides. No per-feature DB calls. Scales to however many
|
|
12
|
+
// features an app registers — currently tens, never thousands.
|
|
13
|
+
export const registeredQuery = defineQueryHandler({
|
|
14
|
+
name: "registered",
|
|
15
|
+
schema: z.object({}),
|
|
16
|
+
access: { roles: ["SystemAdmin", "Admin"] },
|
|
17
|
+
handler: async (_event, ctx) => {
|
|
18
|
+
type OverrideRow = Pick<typeof globalFeatureStateTable.$inferSelect, "featureName" | "enabled">;
|
|
19
|
+
const overrideRows = (await ctx.db
|
|
20
|
+
.select({
|
|
21
|
+
featureName: globalFeatureStateTable.featureName,
|
|
22
|
+
enabled: globalFeatureStateTable.enabled,
|
|
23
|
+
})
|
|
24
|
+
.from(globalFeatureStateTable)) as OverrideRow[];
|
|
25
|
+
const overrides = new Map(overrideRows.map((r) => [r.featureName, r.enabled]));
|
|
26
|
+
|
|
27
|
+
const effective = ctx.effectiveFeatures?.();
|
|
28
|
+
|
|
29
|
+
const items = [];
|
|
30
|
+
for (const feature of ctx.registry.features.values()) {
|
|
31
|
+
const toggleable = feature.toggleableDefault !== undefined;
|
|
32
|
+
const override = overrides.get(feature.name);
|
|
33
|
+
items.push({
|
|
34
|
+
name: feature.name,
|
|
35
|
+
toggleable,
|
|
36
|
+
// `default` is null when non-toggleable; the UI must render
|
|
37
|
+
// non-toggleable features as "always on" without an enable/disable
|
|
38
|
+
// control (flipping them would be rejected by the set-handler).
|
|
39
|
+
default: feature.toggleableDefault ?? null,
|
|
40
|
+
// `override` is null when no explicit row exists. That's distinct
|
|
41
|
+
// from "override says on" or "override says off" so the UI can show
|
|
42
|
+
// an "inherits default" indicator.
|
|
43
|
+
override: override ?? null,
|
|
44
|
+
requires: feature.requires,
|
|
45
|
+
// Effective = what the dispatcher-gate actually uses right now,
|
|
46
|
+
// after cascade. When the feature-toggles runtime isn't wired
|
|
47
|
+
// (dev setup without the feature loaded), we surface null so the
|
|
48
|
+
// UI knows the runtime isn't available rather than defaulting
|
|
49
|
+
// to "everything on".
|
|
50
|
+
effective: effective ? effective.has(feature.name) : null,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { items };
|
|
55
|
+
},
|
|
56
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { defineWriteHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
UnprocessableError,
|
|
5
|
+
VersionConflictError,
|
|
6
|
+
writeFailure,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
8
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
9
|
+
import { Temporal } from "temporal-polyfill";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import {
|
|
12
|
+
FEATURE_TOGGLE_AGGREGATE_TYPE,
|
|
13
|
+
FEATURE_TOGGLE_SET_EVENT_NAME,
|
|
14
|
+
FeatureToggleErrors,
|
|
15
|
+
} from "../constants";
|
|
16
|
+
import { globalFeatureStateTable } from "../global-feature-state-table";
|
|
17
|
+
import type { GlobalFeatureToggleRuntime } from "../toggle-runtime";
|
|
18
|
+
|
|
19
|
+
// Factory: binds a runtime accessor to the handler at registration time.
|
|
20
|
+
// The runtime holds the in-memory snapshot that the dispatcher's gate
|
|
21
|
+
// reads; every successful set() call must update it, otherwise the flip
|
|
22
|
+
// won't take effect until the next boot.
|
|
23
|
+
//
|
|
24
|
+
// Accessor form (instead of direct runtime ref) supports the bootstrapping
|
|
25
|
+
// flow: tests + setupTestStack construct the feature definition BEFORE the
|
|
26
|
+
// runtime exists (the runtime needs the registry, which setupTestStack
|
|
27
|
+
// builds from the features). The accessor is resolved lazily, at call time.
|
|
28
|
+
export function createSetWriteHandler(getRuntime: () => GlobalFeatureToggleRuntime) {
|
|
29
|
+
return defineWriteHandler({
|
|
30
|
+
name: "set",
|
|
31
|
+
schema: z.object({
|
|
32
|
+
featureName: z.string().min(1),
|
|
33
|
+
enabled: z.boolean(),
|
|
34
|
+
}),
|
|
35
|
+
// Platform-operator action — SystemAdmin only.
|
|
36
|
+
access: { roles: ["SystemAdmin"] },
|
|
37
|
+
handler: async (event, ctx) => {
|
|
38
|
+
const { featureName, enabled } = event.payload;
|
|
39
|
+
|
|
40
|
+
// Guard 1: featureName must be a registered feature. Otherwise we'd
|
|
41
|
+
// pile up orphan rows from typos that the gate would silently apply
|
|
42
|
+
// (if someone ever added a feature with that name later).
|
|
43
|
+
const feature = ctx.registry.getFeature(featureName);
|
|
44
|
+
if (!feature) {
|
|
45
|
+
return writeFailure(
|
|
46
|
+
new UnprocessableError(FeatureToggleErrors.unknownFeature, {
|
|
47
|
+
i18nKey: "feature-toggles.errors.unknownFeature",
|
|
48
|
+
details: { featureName },
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Guard 2: feature must be toggleable. Non-toggleable features (auth,
|
|
54
|
+
// tenant, user, feature-toggles itself) must stay on — the gate
|
|
55
|
+
// would ignore any row, but writing one sends the wrong signal to
|
|
56
|
+
// anyone reading the table.
|
|
57
|
+
if (feature.toggleableDefault === undefined) {
|
|
58
|
+
return writeFailure(
|
|
59
|
+
new UnprocessableError(FeatureToggleErrors.notToggleable, {
|
|
60
|
+
i18nKey: "feature-toggles.errors.notToggleable",
|
|
61
|
+
details: { featureName },
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Read current state for event payload + optimistic-lock version.
|
|
67
|
+
// `$inferSelect` narrows the result shape to the real table schema —
|
|
68
|
+
// no hand-rolled cast, no drift if a column is added later.
|
|
69
|
+
type StateRow = typeof globalFeatureStateTable.$inferSelect;
|
|
70
|
+
const [existing] = (await ctx.db
|
|
71
|
+
.select()
|
|
72
|
+
.from(globalFeatureStateTable)
|
|
73
|
+
.where(eq(globalFeatureStateTable.featureName, featureName))
|
|
74
|
+
.limit(1)) as StateRow[];
|
|
75
|
+
|
|
76
|
+
const previousEnabled = existing?.enabled ?? null;
|
|
77
|
+
|
|
78
|
+
if (!existing) {
|
|
79
|
+
// First-time override: insert.
|
|
80
|
+
await ctx.db.insert(globalFeatureStateTable).values({
|
|
81
|
+
featureName,
|
|
82
|
+
enabled,
|
|
83
|
+
version: 1,
|
|
84
|
+
updatedBy: event.user.id,
|
|
85
|
+
updatedAt: Temporal.Now.instant(),
|
|
86
|
+
});
|
|
87
|
+
} else {
|
|
88
|
+
// Upsert with optimistic lock. Two operators flipping the same
|
|
89
|
+
// toggle simultaneously is rare but possible — the version-WHERE
|
|
90
|
+
// ensures only one wins; the loser sees VersionConflictError.
|
|
91
|
+
const updated = await ctx.db
|
|
92
|
+
.update(globalFeatureStateTable)
|
|
93
|
+
.set({
|
|
94
|
+
enabled,
|
|
95
|
+
version: sql<number>`${globalFeatureStateTable.version} + 1`,
|
|
96
|
+
updatedBy: event.user.id,
|
|
97
|
+
updatedAt: Temporal.Now.instant(),
|
|
98
|
+
})
|
|
99
|
+
.where(
|
|
100
|
+
and(
|
|
101
|
+
eq(globalFeatureStateTable.featureName, featureName),
|
|
102
|
+
eq(globalFeatureStateTable.version, existing.version),
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
.returning();
|
|
106
|
+
|
|
107
|
+
if (updated.length === 0) {
|
|
108
|
+
return writeFailure(
|
|
109
|
+
new VersionConflictError({
|
|
110
|
+
entityId: featureName,
|
|
111
|
+
expectedVersion: existing.version,
|
|
112
|
+
currentVersion: existing.version + 1,
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Domain event — the event-store IS the toggle-change audit trail.
|
|
119
|
+
// aggregateId = SYSTEM_TENANT_ID (uuid) because the events table
|
|
120
|
+
// types aggregate_id as uuid. Per-feature stream isolation would
|
|
121
|
+
// need synthetic UUIDs from the feature-name, which add nothing
|
|
122
|
+
// audit-wise; one shared toggle-changes stream per system is fine,
|
|
123
|
+
// and filtering by payload.featureName is trivial at query time.
|
|
124
|
+
// This mirrors how `config` handles the same constraint for
|
|
125
|
+
// its config-changed events.
|
|
126
|
+
// appendEventUnsafe — bundled-features ohne lokalen Wrapper. Apps
|
|
127
|
+
// mit `yarn kumiko codegen` kriegen `.kumiko/define.ts` als strict-
|
|
128
|
+
// path; bundled-features bleibt bei der unsafe-Variante. Schema-
|
|
129
|
+
// Validation läuft trotzdem via r.defineEvent("toggle-set", ...).
|
|
130
|
+
await ctx.appendEventUnsafe({
|
|
131
|
+
aggregateId: SYSTEM_TENANT_ID,
|
|
132
|
+
aggregateType: FEATURE_TOGGLE_AGGREGATE_TYPE,
|
|
133
|
+
type: FEATURE_TOGGLE_SET_EVENT_NAME,
|
|
134
|
+
payload: {
|
|
135
|
+
featureName,
|
|
136
|
+
enabled,
|
|
137
|
+
previousEnabled,
|
|
138
|
+
updatedBy: event.user.id,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Update the local in-memory snapshot. Done AFTER the DB write +
|
|
143
|
+
// event append so a crash in either leaves the snapshot consistent
|
|
144
|
+
// with what's persisted. This is the response-latency optimization:
|
|
145
|
+
// the next request on THIS instance sees the flip without waiting
|
|
146
|
+
// for a dispatcher tick. Other instances learn the change through
|
|
147
|
+
// the `toggle-cache-sync` MSP (see feature-toggles-feature.ts). Both
|
|
148
|
+
// paths are idempotent — Map.set is last-write-wins and the DB is
|
|
149
|
+
// the source of truth after boot-time initialize().
|
|
150
|
+
getRuntime().apply(featureName, enabled);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
isSuccess: true,
|
|
154
|
+
data: { featureName, enabled, previousEnabled },
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
computeEffectiveFeatures,
|
|
4
|
+
type Registry,
|
|
5
|
+
type ToggleReader,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { globalFeatureStateTable } from "./global-feature-state-table";
|
|
8
|
+
|
|
9
|
+
// Holds the current global-override snapshot in memory and exposes a
|
|
10
|
+
// synchronous reader — the dispatcher's feature-gate calls it on every
|
|
11
|
+
// handler invocation, so this must not do I/O on the hot path. The
|
|
12
|
+
// snapshot is loaded once at boot via `.initialize()`, refreshed by the
|
|
13
|
+
// set-handler on the local instance, and kept in sync across
|
|
14
|
+
// instances by the `toggle-cache-sync` MSP (declared on the
|
|
15
|
+
// feature-toggles feature, delivery: "per-instance"). Every API/worker
|
|
16
|
+
// process observes every toggle-set event and applies it to its local
|
|
17
|
+
// snapshot — no Redis / SSE / polling needed; the existing events-table
|
|
18
|
+
// + event-dispatcher pipeline handles propagation.
|
|
19
|
+
export class GlobalFeatureToggleRuntime {
|
|
20
|
+
private snapshot = new Map<string, boolean>();
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly db: DbConnection,
|
|
24
|
+
private readonly registry: Registry,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
async initialize(): Promise<void> {
|
|
28
|
+
const rows = await this.db
|
|
29
|
+
.select({
|
|
30
|
+
featureName: globalFeatureStateTable.featureName,
|
|
31
|
+
enabled: globalFeatureStateTable.enabled,
|
|
32
|
+
})
|
|
33
|
+
.from(globalFeatureStateTable);
|
|
34
|
+
this.snapshot = new Map(rows.map((r) => [r.featureName, r.enabled]));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Re-read the full snapshot. Called from the set-handler after a
|
|
38
|
+
// successful write — cheap point-update would be an optimisation but
|
|
39
|
+
// the table is small (O(features)) and this keeps the cache honest in
|
|
40
|
+
// the presence of concurrent external writes (seed scripts, ops SQL).
|
|
41
|
+
async refresh(): Promise<void> {
|
|
42
|
+
await this.initialize();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// In-memory cache update. Used by the set-handler when a single
|
|
46
|
+
// featureName transitions — saves a round-trip compared to refresh()
|
|
47
|
+
// while staying correct because set-handlers serialise via optimistic
|
|
48
|
+
// lock. Kept alongside refresh() so both options are explicit.
|
|
49
|
+
apply(featureName: string, enabled: boolean): void {
|
|
50
|
+
this.snapshot.set(featureName, enabled);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The callback shape the dispatcher expects. Computes the effective
|
|
54
|
+
// feature set from the current snapshot + the registry's requires()
|
|
55
|
+
// cascade every call. Cheap: the cascade is a DFS over O(features);
|
|
56
|
+
// for the expected sizes (tens of features per app) this is ~µs.
|
|
57
|
+
effectiveFeatures = (): ReadonlySet<string> => {
|
|
58
|
+
const reader: ToggleReader = (name) => this.snapshot.get(name);
|
|
59
|
+
return computeEffectiveFeatures(this.registry, reader);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Factory for app-boot wiring: instantiate, initialize, return both the
|
|
64
|
+
// runtime (for the set-handler to refresh) and the callback (for
|
|
65
|
+
// createDispatcher's effectiveFeatures option).
|
|
66
|
+
export async function createFeatureToggleRuntime(
|
|
67
|
+
db: DbConnection,
|
|
68
|
+
registry: Registry,
|
|
69
|
+
): Promise<GlobalFeatureToggleRuntime> {
|
|
70
|
+
const runtime = new GlobalFeatureToggleRuntime(db, registry);
|
|
71
|
+
await runtime.initialize();
|
|
72
|
+
return runtime;
|
|
73
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// feature.ts contract tests — pin the public surface of the
|
|
2
|
+
// Plugin-API-shaped file-foundation. Provider-specific configs/secrets
|
|
3
|
+
// are tested in their own provider-feature (file-provider-s3/__tests__).
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "vitest";
|
|
6
|
+
import { fileFoundationFeature } from "../feature";
|
|
7
|
+
|
|
8
|
+
describe("fileFoundationFeature — shape", () => {
|
|
9
|
+
test("has the expected name", () => {
|
|
10
|
+
expect(fileFoundationFeature.name).toBe("file-foundation");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("declares config as a hard requirement (provider-selector lives there)", () => {
|
|
14
|
+
expect(fileFoundationFeature.requires).toContain("config");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("does NOT require secrets — provider-plugins own their own secrets", () => {
|
|
18
|
+
expect(fileFoundationFeature.requires).not.toContain("secrets");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("fileFoundationFeature.exports — typed handles", () => {
|
|
23
|
+
test("exposes only the provider-selector config-key", () => {
|
|
24
|
+
const keys = fileFoundationFeature.exports.configKeys;
|
|
25
|
+
expect(keys.provider).toBeDefined();
|
|
26
|
+
expect((keys as Record<string, unknown>)["bucket"]).toBeUndefined();
|
|
27
|
+
expect((keys as Record<string, unknown>)["region"]).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("fileFoundationFeature — registers extension-point", () => {
|
|
32
|
+
test("declares the 'fileProvider' extension-point", () => {
|
|
33
|
+
expect(fileFoundationFeature.registrarExtensions["fileProvider"]).toBeDefined();
|
|
34
|
+
});
|
|
35
|
+
});
|