Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,209 @@
|
||||
# Feature-Flags Engine v2
|
||||
|
||||
The v2 engine ships in `packages/feature-flags/` and adds targeting
|
||||
rules, variants, dependencies, audit-trail, two-tier cache and an
|
||||
SSR-hydrated React hook on top of the existing `FeatureFlag` Prisma
|
||||
table. Legacy boolean-only callers (`FeatureFlagsService.hasFeatureFlag`)
|
||||
keep working unchanged — the migration is additive.
|
||||
|
||||
## Storage
|
||||
|
||||
`prisma/schema/featureFlags.prisma` extends `FeatureFlag` with:
|
||||
|
||||
| Column | Type | Purpose |
|
||||
|----------------|-------------|------------------------------------------------------|
|
||||
| `rules` | `Json?` | Targeting-rule tree (see below). |
|
||||
| `variants` | `Json[]` | Variant array `[{ key, weight?, value? }, ...]`. |
|
||||
| `enabledFrom` | `DateTime?` | Lower bound for time-based killswitches. |
|
||||
| `enabledUntil` | `DateTime?` | Upper bound for time-based killswitches. |
|
||||
| `parentFlagId` | `String?` | Self-reference. Parent must be enabled for child. |
|
||||
|
||||
The `FeatureFlagAudit` table is append-only — every CRUD / toggle
|
||||
writes a row with `actorUserId`, `action`, `oldValue`, `newValue`.
|
||||
The app never UPDATEs / DELETEs audit rows; admins can verify
|
||||
tamper-resistance by reading the audit log alone.
|
||||
|
||||
Migration: `prisma/migrations/20260517100000_05_feature_flags_v2/`.
|
||||
All new columns nullable / defaulted so existing rows keep evaluating
|
||||
through the legacy boolean `enabled` flag.
|
||||
|
||||
## Rule-tree schema
|
||||
|
||||
Every node has a `type` discriminator. Composite trees combine leaves
|
||||
via AND / OR / NOT. Strict union — adding a new leaf type forces an
|
||||
exhaustive evaluator update.
|
||||
|
||||
### Leaves
|
||||
|
||||
```jsonc
|
||||
// boolean
|
||||
{ "type": "boolean", "value": true }
|
||||
|
||||
// allowlist — OR over tenantIds / userIds / emails
|
||||
{ "type": "allowlist",
|
||||
"tenantIds": ["tenant_a", "tenant_b"],
|
||||
"userIds": ["user_42"],
|
||||
"emails": ["alice@example.com"] }
|
||||
|
||||
// percentage — sticky SHA1(<bucketKey>:<flagName>) % 100 < N
|
||||
{ "type": "percentage", "rolloutPercent": 30, "bucketBy": "userId" }
|
||||
|
||||
// plan-tier — match if ctx.planTier in plans
|
||||
{ "type": "plan-tier", "plans": ["pro", "enterprise"] }
|
||||
|
||||
// role-based — match if ctx.roles intersects roles
|
||||
{ "type": "role-based", "roles": ["admin", "beta-tester"] }
|
||||
|
||||
// time-based — match if now ∈ [enabledFrom, enabledUntil]
|
||||
{ "type": "time-based",
|
||||
"enabledFrom": "2026-06-01T00:00:00Z",
|
||||
"enabledUntil": "2026-12-31T23:59:59Z" }
|
||||
```
|
||||
|
||||
### Composite
|
||||
|
||||
```jsonc
|
||||
// enterprise-tier OR (10% of pro users) — gradual roll-out gated by plan
|
||||
{ "type": "composite", "op": "OR",
|
||||
"children": [
|
||||
{ "type": "plan-tier", "plans": ["enterprise"] },
|
||||
{ "type": "composite", "op": "AND",
|
||||
"children": [
|
||||
{ "type": "plan-tier", "plans": ["pro"] },
|
||||
{ "type": "percentage", "rolloutPercent": 10 }
|
||||
] } ] }
|
||||
```
|
||||
|
||||
### Sticky percentage rollouts
|
||||
|
||||
The bucket is `SHA1(<userId>:<flagName>) mod 100`, big-endian over the
|
||||
first 4 bytes of the digest. A user that sees a feature at 30%
|
||||
rollout will keep seeing it until the percentage drops below their
|
||||
bucket — no per-request flipping. Switch `bucketBy: "tenantId"` to
|
||||
make whole organisations sticky as one unit.
|
||||
|
||||
Distribution: tested over 10 000 random user-ids at 30% rollout
|
||||
the observed share stays within ±5%.
|
||||
|
||||
## evaluateFlag
|
||||
|
||||
```ts
|
||||
import { evaluateFlagForRequest } from "~/utils/services/.server/featureFlagsRequest.server";
|
||||
|
||||
const result = await evaluateFlagForRequest("checkout-v2", { request, params });
|
||||
// result: { enabled: boolean, variant?: FlagVariant, reason: string }
|
||||
if (result.enabled) {
|
||||
render(result.variant?.key ?? "default");
|
||||
}
|
||||
```
|
||||
|
||||
Reasons (typed union): `flag-not-found`, `disabled`,
|
||||
`time-window-future`, `time-window-expired`, `parent-disabled`,
|
||||
`parent-not-found`, `no-match`, `matched`. Safe to log; never contains
|
||||
PII pulled from the evaluation context.
|
||||
|
||||
### Dependency resolution
|
||||
|
||||
If `parentFlagId` is set the parent must be enabled, in-window, and
|
||||
match its own targeting rules. Cycles are bounded by `maxDepth = 8`
|
||||
and resolve fail-closed to `parent-disabled`.
|
||||
|
||||
### Variant selection
|
||||
|
||||
If multiple variants are configured the engine picks one
|
||||
deterministically based on `SHA1(<userId>:<flagName>:variant) mod 100`
|
||||
scaled to the sum of weights (missing `weight` defaults to 1). Single-
|
||||
variant flags return that variant unconditionally.
|
||||
|
||||
## Eval cache + pub/sub invalidation
|
||||
|
||||
Cache lives in the shared-store two-tier helper (#39). Key:
|
||||
`ff:<flagName>:<hash(ctx)>` where `hash(ctx)` covers
|
||||
`(tenantId, userId, email, planTier, roles)`. Default TTL 60 s
|
||||
(`FEATURE_FLAGS_CACHE_TTL_SECONDS` overrides).
|
||||
|
||||
Channel: `feature-flags:invalidate` (under the shared-store namespace).
|
||||
Web boot calls `subscribeFeatureFlagCache()` once from
|
||||
`cache-bootstrap.server.ts`. On any flag mutation (admin save, CLI
|
||||
toggle, Stripe-driven activation) the publisher calls
|
||||
`invalidateFlagCache(flagName)`; every replica drops its L1 entirely on
|
||||
receipt (cheap — mutations are rare and L1 entries are tiny).
|
||||
|
||||
To opt out for a single replica (debugging) set
|
||||
`CACHE_INVALIDATION_ENABLED=false` — the subscriber is suppressed and
|
||||
the L1 entries expire via TTL.
|
||||
|
||||
## Admin UI
|
||||
|
||||
Route: `/admin/feature-flags/engine`. Lists every flag with toggle +
|
||||
rule-JSON editor + last 50 audit entries. Visual rule-builder
|
||||
(tree-render with drag-drop, percentage slider, allowlist autocomplete,
|
||||
live affected-count) is a documented follow-up — the JSON editor
|
||||
catches structurally-broken trees by running them through `evaluateRule`
|
||||
against a probe context before persisting.
|
||||
|
||||
## Client SDK + SSR hydration
|
||||
|
||||
```tsx
|
||||
import { FeatureFlagProvider, useFeatureFlag } from "@toolctrl/feature-flags/client";
|
||||
|
||||
// root.tsx loader — pre-evaluate every flag the tree consumes
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const flags = await evaluateFlagsForContext(["chat-v2", "ab-onboarding"], { ... });
|
||||
return { flags };
|
||||
}
|
||||
|
||||
// root.tsx component — lift the map into context
|
||||
export default function Root({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<FeatureFlagProvider flags={loaderData.flags}>
|
||||
<Outlet />
|
||||
</FeatureFlagProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// any descendant — read synchronously, no fetch in render
|
||||
function Page() {
|
||||
const { enabled, variant } = useFeatureFlag("chat-v2");
|
||||
if (!enabled) return null;
|
||||
return <Chat variant={variant?.key} />;
|
||||
}
|
||||
```
|
||||
|
||||
Convenience hooks: `useFeatureFlagEnabled(name): boolean` and
|
||||
`useFeatureFlagVariant(name): string | undefined`.
|
||||
|
||||
Hydration shape:
|
||||
|
||||
```ts
|
||||
type FeatureFlagsHydration = Record<string, { enabled: boolean; variant?; reason: string }>;
|
||||
```
|
||||
|
||||
Missing entries fall back to `{ enabled: false, reason: "flag-not-found" }`
|
||||
so a stale client bundle (deployment skew) does not crash the page when
|
||||
a new flag is referenced.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
toolctrl-cli feature-flags list # every flag with state
|
||||
toolctrl-cli feature-flags list --enabled=true # filter
|
||||
toolctrl-cli feature-flags enable <flag-name>
|
||||
toolctrl-cli feature-flags disable <flag-name>
|
||||
toolctrl-cli feature-flags set-rules <flag-name> --file=rules.json # or '-' for stdin
|
||||
```
|
||||
|
||||
Every mutation writes an audit row and triggers a pub/sub
|
||||
invalidation; no separate cache-clear step.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- **Visual rule-builder UI.** Drag-drop AND/OR/NOT composer, percentage
|
||||
slider, allowlist search with debounced Tenant/User lookup, variant-
|
||||
JSON Monaco editor. Tracked in the detail-md.
|
||||
- **`POST /api/feature-flags/preview-affected`** — backend that
|
||||
estimates how many tenants/users a rule change would affect, used by
|
||||
the visual builder for live feedback.
|
||||
- **Engine kill-switch `FEATURE_FLAGS_USE_LEGACY=true`** — meta-flag to
|
||||
bypass v2 entirely during a roll-back window. Skeleton in place
|
||||
(the v1 service is still wired); flip-switch wiring is a follow-up.
|
||||
Reference in new issue
Block a user