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