Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/featureFlags/db/featureFlags.db.server.ts
T

222 lines
6.5 KiB
TypeScript

import { db } from "~/utils/db.server";
import { FeatureFlagsFilterType } from "../dtos/FeatureFlagsFilterTypes";
import { FeatureFlag, FeatureFlagFilter, Prisma } from "@hivegrid/db/prisma-browser";
import { runWithIsolatedPlatformAnalyticsRlsContext, runWithRlsContext } from "@hivegrid/db/rls-context.server";
import { clearCacheKeyPrefix } from "~/utils/cache.server";
export type FeatureFlagWithDetails = FeatureFlag & {
filters: FeatureFlagFilter[];
};
export type FeatureFlagWithEventCount = FeatureFlagWithDetails & {
_count: { events: number };
};
export type FeatureFlagScopeLookup = {
enabled?: boolean | undefined;
forcedFlags?: string[];
tenantId?: string | null;
userId?: string | null;
scope?: string | null;
};
export async function getFeatureFlag(where: { name?: string; id?: string; description?: string; enabled?: boolean | undefined }) {
return db.featureFlag.findFirst({
where: { ...where, archivedAt: null },
include: {
filters: true,
},
});
}
export async function getAllFeatureFlags(): Promise<FeatureFlagWithEventCount[]> {
return loadFeatureFlagsWithEventCounts();
}
/**
* Return the distinct flag identifiers needed by request-time evaluation.
* This deliberately avoids the cross-tenant analytics aggregation used by
* the admin list projection, because ordinary app requests already run inside
* a tenant-bound RLS context.
*/
export async function getFeatureFlagEvaluationNames(): Promise<string[]> {
const flags = await db.featureFlag.findMany({
where: { archivedAt: null },
select: { name: true, key: true },
orderBy: { createdAt: "desc" },
});
return [...new Set(flags.map((flag) => flag.name ?? flag.key ?? "").filter((name) => name.length > 0))];
}
export async function getFeatureFlags(where: FeatureFlagScopeLookup): Promise<FeatureFlagWithDetails[]> {
const forcedFlags = where.forcedFlags ?? [];
const scopedOverrides: Prisma.FeatureFlagWhereInput[] = [];
scopedOverrides.push({ scope: "global", tenantId: null, userId: null });
if (where.tenantId) scopedOverrides.push({ scope: "tenant", tenantId: where.tenantId, userId: null });
if (where.userId) scopedOverrides.push({ scope: "user", tenantId: null, userId: where.userId });
if (where.tenantId && where.userId) scopedOverrides.push({ scope: "user", tenantId: where.tenantId, userId: where.userId });
return db.featureFlag.findMany({
where: {
AND: [
{ archivedAt: null },
{
OR: [{ enabled: where.enabled }, { name: { in: forcedFlags } }, ...scopedOverrides],
},
{ OR: scopedOverrides },
],
},
include: {
filters: true,
},
orderBy: {
createdAt: "desc",
},
});
}
export async function getFeatureFlagsWithEventCounts(where: { enabled: boolean | undefined }): Promise<FeatureFlagWithEventCount[]> {
return loadFeatureFlagsWithEventCounts({ enabled: where.enabled });
}
/**
* The legacy editor predates the append-only v2 audit contract. Reserved
* operational flags must only be visible and mutable in the audited engine.
*/
export async function getLegacyFeatureFlagsWithEventCounts(where: { enabled: boolean | undefined }): Promise<FeatureFlagWithEventCount[]> {
return loadFeatureFlagsWithEventCounts({ enabled: where.enabled });
}
async function loadFeatureFlagsWithEventCounts(where?: Prisma.FeatureFlagWhereInput): Promise<FeatureFlagWithEventCount[]> {
const flags = await db.featureFlag.findMany({
where: { AND: [{ archivedAt: null }, ...(where ? [where] : [])] },
include: {
filters: true,
},
orderBy: {
createdAt: "desc",
},
});
if (flags.length === 0) return [];
const eventCounts = await aggregateFeatureFlagEventCounts(flags.map((flag) => flag.id));
return flags.map((flag) => ({
...flag,
_count: { events: eventCounts.get(flag.id) ?? 0 },
}));
}
async function aggregateFeatureFlagEventCounts(featureFlagIds: string[]): Promise<Map<string, number>> {
const tenants = await db.tenant.findMany({
select: { id: true },
orderBy: { id: "asc" },
});
const counts = new Map<string, number>();
async function addCountsVisibleInCurrentContext() {
const groups = await db.analyticsEvent.groupBy({
by: ["featureFlagId"],
where: { featureFlagId: { in: featureFlagIds } },
_count: true,
});
for (const group of groups) {
if (!group.featureFlagId) continue;
counts.set(group.featureFlagId, (counts.get(group.featureFlagId) ?? 0) + group._count);
}
}
for (const tenant of tenants) {
await runWithRlsContext({ tenantId: tenant.id }, addCountsVisibleInCurrentContext);
}
await runWithIsolatedPlatformAnalyticsRlsContext(addCountsVisibleInCurrentContext);
return counts;
}
export async function createFeatureFlag(data: {
name: string;
description: string;
enabled: boolean;
filters: {
type: FeatureFlagsFilterType;
value: string | null;
action?: string | null;
}[];
}) {
return await db.featureFlag
.create({
data: {
name: data.name,
description: data.description,
enabled: data.enabled,
filters: {
create: data.filters.map((f) => ({
type: f.type.toString(),
value: f.value ?? null,
action: f.action ?? null,
})),
},
},
})
.then((item) => {
clearCacheKeyPrefix(`featureFlags:enabled`);
return item;
});
}
export async function updateFeatureFlag(
id: string,
{
name,
description,
filters,
enabled,
}: {
name?: string;
description?: string;
filters?: { type: FeatureFlagsFilterType; value: string | null; action?: string | null }[];
enabled?: boolean;
}
) {
return await db.featureFlag
.update({
where: { id },
data: {
name,
description,
enabled,
filters: !filters
? undefined
: {
deleteMany: {},
create: filters.map((f) => ({
type: f.type.toString(),
value: f.value ?? null,
action: f.action ?? null,
})),
},
},
})
.then((flag) => {
clearCacheKeyPrefix(`featureFlags:enabled`);
return flag;
});
}
export async function deleteFeatureFlag(id: string) {
return await db.featureFlag
.delete({
where: { id },
})
.then((item) => {
clearCacheKeyPrefix(`featureFlags:enabled`);
return item;
});
}
export async function deleteAllFeatureFlags() {
const result = await db.featureFlag.deleteMany();
clearCacheKeyPrefix(`featureFlags:enabled`);
return result;
}