better-auth-feature-flags
Version:
Ship features safely with feature flags, A/B testing, and progressive rollouts - Better Auth plugin for modern release management
437 lines (429 loc) • 10.2 kB
text/typescript
// SPDX-FileCopyrightText: 2025-present Kriasoft
// SPDX-License-Identifier: MIT
import type { AuthPluginSchema } from "better-auth";
import * as z from "zod";
import { parseJSON } from "../utils";
/**
* Database schema for <100ms P99 evaluation at 100K+ RPS.
*
* REQUIRED CONSTRAINTS (add manually):
* ```sql
* ALTER TABLE flag_overrides ADD CONSTRAINT uk_flag_user UNIQUE (flag_id, user_id);
* ALTER TABLE feature_flags ADD CONSTRAINT uk_org_key UNIQUE (organization_id, key);
* ```
*
* CRITICAL INDEXES:
* - (organizationId, key) on featureFlag - O(1) tenant lookups
* - (flagId, priority) on flagRule - O(log n) ordered evaluation
* - (flagId, userId) on flagOverride - O(1) override checks
*
* @invariant Percentages: 0 ≤ n ≤ 100
* @invariant Variant weights sum to 100 ± 0.01
* @invariant Priority: -1000 ≤ n ≤ 1000, lower first
* @see ../utils/index.ts for parseJSON utility
*/
export const featureFlagsSchema = {
featureFlag: {
modelName: "featureFlag",
fields: {
key: {
type: "string",
required: true,
unique: true,
},
name: {
type: "string",
required: true,
},
description: {
type: "string",
required: false,
},
type: {
type: "string",
defaultValue: "boolean",
validator: {
input: z.enum(["boolean", "string", "number", "json"]),
output: z.enum(["boolean", "string", "number", "json"]),
},
},
enabled: {
type: "boolean",
defaultValue: false,
},
defaultValue: {
type: "string",
required: false,
transform: {
input(value: any) {
return value !== undefined ? JSON.stringify(value) : null;
},
output(value: any) {
if (!value) return null;
return parseJSON<any>(value as string);
},
},
},
rolloutPercentage: {
type: "number",
defaultValue: 0,
validator: {
input: z.number().min(0).max(100),
output: z.number().min(0).max(100),
},
},
organizationId: {
type: "string",
references: {
model: "organization",
field: "id",
onDelete: "cascade",
},
required: false,
},
createdAt: {
type: "date",
required: true,
defaultValue: () => new Date(),
},
updatedAt: {
type: "date",
required: true,
defaultValue: () => new Date(),
},
variants: {
type: "string",
required: false,
transform: {
input(value: any) {
return value ? JSON.stringify(value) : null;
},
output(value: any) {
if (!value) return null;
return parseJSON<any>(value as string);
},
},
},
metadata: {
type: "string",
required: false,
transform: {
input(value: any) {
return value ? JSON.stringify(value) : null;
},
output(value: any) {
if (!value) return null;
return parseJSON<any>(value as string);
},
},
},
},
},
flagRule: {
modelName: "flagRule",
fields: {
flagId: {
type: "string",
references: {
model: "featureFlag",
field: "id",
onDelete: "cascade",
},
required: true,
},
priority: {
type: "number",
defaultValue: 0,
validator: {
input: z.number().int().min(-1000).max(1000),
output: z.number().int(),
},
},
name: {
type: "string",
required: false,
},
conditions: {
type: "string",
required: true,
transform: {
input(value: any) {
return JSON.stringify(value);
},
output(value: any) {
return parseJSON<any>(value as string);
},
},
},
value: {
type: "string",
required: true,
transform: {
input(value: any) {
return JSON.stringify(value);
},
output(value: any) {
return parseJSON<any>(value as string);
},
},
},
percentage: {
type: "number",
required: false,
validator: {
input: z.number().min(0).max(100).optional(),
output: z.number().min(0).max(100).optional(),
},
},
enabled: {
type: "boolean",
defaultValue: true,
},
createdAt: {
type: "date",
required: true,
defaultValue: () => new Date(),
},
},
},
flagOverride: {
modelName: "flagOverride",
fields: {
flagId: {
type: "string",
references: {
model: "featureFlag",
field: "id",
onDelete: "cascade",
},
required: true,
},
userId: {
type: "string",
references: {
model: "user",
field: "id",
onDelete: "cascade",
},
required: true,
},
value: {
type: "string",
required: true,
transform: {
input(value: any) {
return JSON.stringify(value);
},
output(value: any) {
return parseJSON<any>(value as string);
},
},
},
enabled: {
type: "boolean",
defaultValue: true,
},
variant: {
type: "string",
required: false,
},
reason: {
type: "string",
required: false,
},
expiresAt: {
type: "date",
required: false,
},
createdAt: {
type: "date",
required: true,
defaultValue: () => new Date(),
},
},
},
flagEvaluation: {
modelName: "flagEvaluation",
fields: {
flagId: {
type: "string",
references: {
model: "featureFlag",
field: "id",
onDelete: "cascade",
},
required: true,
},
userId: {
type: "string",
references: {
model: "user",
field: "id",
onDelete: "set null",
},
required: false,
},
sessionId: {
type: "string",
required: false,
},
value: {
type: "string",
required: true,
transform: {
input(value: any) {
return JSON.stringify(value);
},
output(value: any) {
return parseJSON<any>(value as string);
},
},
},
variant: {
type: "string",
required: false,
},
reason: {
type: "string",
required: false,
validator: {
input: z
.enum([
"rule_match",
"override",
"percentage_rollout",
"default",
"disabled",
"not_found",
])
.optional(),
output: z
.enum([
"rule_match",
"override",
"percentage_rollout",
"default",
"disabled",
"not_found",
])
.optional(),
},
},
context: {
type: "string",
required: false,
transform: {
input(value: any) {
return value ? JSON.stringify(value) : null;
},
output(value: any) {
if (!value) return null;
return parseJSON<any>(value as string);
},
},
},
evaluatedAt: {
type: "date",
required: true,
defaultValue: () => new Date(),
},
},
},
flagAudit: {
modelName: "flagAudit",
fields: {
flagId: {
type: "string",
references: {
model: "featureFlag",
field: "id",
onDelete: "cascade",
},
required: true,
},
userId: {
type: "string",
references: {
model: "user",
field: "id",
onDelete: "set null",
},
required: false,
},
action: {
type: "string",
required: true,
validator: {
input: z.enum([
"created",
"updated",
"deleted",
"enabled",
"disabled",
"rule_added",
"rule_updated",
"rule_deleted",
"override_added",
"override_removed",
]),
output: z.enum([
"created",
"updated",
"deleted",
"enabled",
"disabled",
"rule_added",
"rule_updated",
"rule_deleted",
"override_added",
"override_removed",
]),
},
},
previousValue: {
type: "string",
required: false,
transform: {
input(value: any) {
return value ? JSON.stringify(value) : null;
},
output(value: any) {
if (!value) return null;
return parseJSON<any>(value as string);
},
},
},
newValue: {
type: "string",
required: false,
transform: {
input(value: any) {
return value ? JSON.stringify(value) : null;
},
output(value: any) {
if (!value) return null;
return parseJSON<any>(value as string);
},
},
},
metadata: {
type: "string",
required: false,
transform: {
input(value: any) {
return value ? JSON.stringify(value) : null;
},
output(value: any) {
if (!value) return null;
return parseJSON<any>(value as string);
},
},
},
createdAt: {
type: "date",
required: true,
defaultValue: () => new Date(),
},
},
},
} satisfies AuthPluginSchema;