Skip to content

Commit 67da537

Browse files
committed
Merge branch 'flag' into staging
2 parents d8f977a + de36cb4 commit 67da537

File tree

95 files changed

+8512
-27261
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

95 files changed

+8512
-27261
lines changed

.cursor/rules/01-MUST-DO.mdc

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,8 @@ Decouple state management, data transformations, and API interactions from the R
3030
Simplify data flow to eliminate prop drilling and callback hell.
3131
Prioritize modularity and testability in all components.
3232

33-
Never throw errors in server actions, use try catch and return the error to the client
34-
35-
For all blogs and content pages, add TL;DRs near the title on the top for better conversion
36-
3733
ALWAYS use error boundaries properly
3834

39-
use console properly, like console.error, console.time, console.json, console.table, etc
40-
4135
Use Dayjs NEVER date-fns, and Tanstack query for hooks, NEVER SWR
4236

4337
use Icon at the end of phosphor react icons, like CaretIcon not Caret AND THATS THE DEFAULT IMPORT, NOT AS
@@ -46,4 +40,8 @@ use json.stringify() when adding debugging
4640

4741
Almost NEVER use useEffect unless it's critical
4842

49-
do NOT use types any, unknown or never, use proper explicit types
43+
do NOT use types any, unknown or never, use proper explicit types
44+
45+
Suffix functions with Action in types, like type Test = { testAction }
46+
47+
Never use barrel exports or create index files

.gitattributes

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +0,0 @@
1-
apps/dashboard/public/countries.json filter=lfs diff=lfs merge=lfs -text
2-
apps/dashboard/public/subdivisions.json filter=lfs diff=lfs merge=lfs -text

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"elysia": "^1.4.19",
4343
"jszip": "^3.10.1",
4444
"keypal": "^0.1.11",
45+
"bullmq": "^5.34.0",
4546
"zod": "catalog:"
4647
}
4748
}

apps/api/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,14 @@ import { agent } from "./routes/agent";
2424
import { health } from "./routes/health";
2525
import { publicApi } from "./routes/public";
2626
import { query } from "./routes/query";
27+
import { createFlagSchedulerWorker } from "./workers/flag-scheduler-worker";
2728

2829
initTracing();
2930
setupUncaughtErrorHandlers();
3031

32+
// Start BullMQ worker for flag schedules
33+
const flagSchedulerWorker = createFlagSchedulerWorker();
34+
3135
const rpcHandler = new RPCHandler(appRouter, {
3236
interceptors: [
3337
createAbortSignalInterceptor(),
@@ -171,6 +175,7 @@ export default {
171175

172176
process.on("SIGINT", async () => {
173177
logger.info("SIGINT received, shutting down gracefully...");
178+
await flagSchedulerWorker.close();
174179
await shutdownTracing().catch((error) =>
175180
logger.error({ error }, "Shutdown error")
176181
);
@@ -179,6 +184,7 @@ process.on("SIGINT", async () => {
179184

180185
process.on("SIGTERM", async () => {
181186
logger.info("SIGTERM received, shutting down gracefully...");
187+
await flagSchedulerWorker.close();
182188
await shutdownTracing().catch((error) =>
183189
logger.error({ error }, "Shutdown error")
184190
);

apps/api/src/routes/public/flags.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
evaluateValueRule,
77
hashString,
88
parseProperties,
9+
selectVariant,
910
} from "./flags";
1011

1112
const ROLLOUT_REASON_REGEX = /ROLLOUT_(ENABLED|DISABLED)/;
@@ -453,6 +454,131 @@ describe("evaluateRule - non-batch mode", () => {
453454
});
454455
});
455456

457+
describe("selectVariant", () => {
458+
it("selects variant based on user hash", () => {
459+
const flag = {
460+
key: "button-color",
461+
variants: [
462+
{ key: "control", value: "gray", weight: 50 },
463+
{ key: "variant-a", value: "blue", weight: 25 },
464+
{ key: "variant-b", value: "green", weight: 25 },
465+
],
466+
};
467+
468+
const result = selectVariant(flag, { userId: "user-123" });
469+
expect(["gray", "blue", "green"]).toContain(result.value);
470+
expect(["control", "variant-a", "variant-b"]).toContain(result.variant);
471+
});
472+
473+
it("provides sticky assignment for same user", () => {
474+
const flag = {
475+
key: "button-color",
476+
variants: [
477+
{ key: "control", value: "gray", weight: 50 },
478+
{ key: "variant-a", value: "blue", weight: 50 },
479+
],
480+
};
481+
482+
const result1 = selectVariant(flag, { userId: "user-123" });
483+
const result2 = selectVariant(flag, { userId: "user-123" });
484+
485+
expect(result1.variant).toBe(result2.variant);
486+
expect(result1.value).toBe(result2.value);
487+
});
488+
489+
it("supports string variants", () => {
490+
const flag = {
491+
key: "theme",
492+
variants: [
493+
{ key: "light", value: "light-theme", weight: 50 },
494+
{ key: "dark", value: "dark-theme", weight: 50 },
495+
],
496+
};
497+
498+
const result = selectVariant(flag, { userId: "user-456" });
499+
expect(typeof result.value).toBe("string");
500+
expect(["light-theme", "dark-theme"]).toContain(result.value);
501+
});
502+
503+
it("supports number variants", () => {
504+
const flag = {
505+
key: "price-point",
506+
variants: [
507+
{ key: "low", value: 9.99, weight: 33 },
508+
{ key: "medium", value: 14.99, weight: 33 },
509+
{ key: "high", value: 19.99, weight: 34 },
510+
],
511+
};
512+
513+
const result = selectVariant(flag, { userId: "user-789" });
514+
expect(typeof result.value).toBe("number");
515+
expect([9.99, 14.99, 19.99]).toContain(result.value);
516+
});
517+
518+
it("supports object variants", () => {
519+
const flag = {
520+
key: "feature-config",
521+
variants: [
522+
{ key: "basic", value: { features: ["a", "b"] }, weight: 50 },
523+
{
524+
key: "premium",
525+
value: { features: ["a", "b", "c", "d"] },
526+
weight: 50,
527+
},
528+
],
529+
};
530+
531+
const result = selectVariant(flag, { userId: "user-abc" });
532+
expect(typeof result.value).toBe("object");
533+
expect(result.value).toHaveProperty("features");
534+
});
535+
536+
it("returns default when no variants", () => {
537+
const flag = {
538+
key: "test",
539+
defaultValue: true,
540+
variants: [],
541+
};
542+
543+
const result = selectVariant(flag, { userId: "user-123" });
544+
expect(result.variant).toBe("default");
545+
expect(result.value).toBe(true);
546+
});
547+
});
548+
549+
describe("evaluateFlag - multi-variant", () => {
550+
it("evaluates multivariant flags correctly", () => {
551+
const flag = {
552+
key: "button-color",
553+
type: "multivariant",
554+
variants: [
555+
{ key: "control", value: "gray", weight: 50 },
556+
{ key: "variant-a", value: "blue", weight: 50 },
557+
],
558+
payload: { experiment: "button-test" },
559+
};
560+
561+
const result = evaluateFlag(flag, { userId: "user-123" });
562+
expect(result.enabled).toBe(true);
563+
expect(["gray", "blue"]).toContain(result.value);
564+
expect(["control", "variant-a"]).toContain(result.variant as string);
565+
expect(result.reason).toBe("MULTIVARIANT_EVALUATED");
566+
expect(result.payload).toEqual({ experiment: "button-test" });
567+
});
568+
569+
it("falls back to boolean for non-multivariant flags", () => {
570+
const flag = {
571+
key: "simple-flag",
572+
type: "boolean",
573+
defaultValue: true,
574+
};
575+
576+
const result = evaluateFlag(flag, { userId: "user-123" });
577+
expect(result.value).toBe(true);
578+
expect(result.variant).toBeUndefined();
579+
});
580+
});
581+
456582
describe("evaluateFlag - rules present", () => {
457583
it("returns first matching rule", () => {
458584
const flag = {

0 commit comments

Comments
 (0)