Examples
Feature flags
Typed flags, a cache that does not stampede, and updates that reach a running browser without a refresh.
Declare the flag set once
A stringly-typed flags.get("new-checkout") returns string | undefined and no compiler help. Declaring the set gives you autocompletion and a type error when a flag is renamed.
import { col, createDatabase } from "yatta/db";import { createCache } from "yatta/cache"; export const FLAG_KEYS = [ "new-checkout", "ai-summaries", "dark-mode", "usage-billing",] as const; export type FlagKey = (typeof FLAG_KEYS)[number];export type FlagValue = boolean | number | string; export const schema = { flags: { key: col.text().primaryKey(), value: col.text(), description: col.text().nullable(), updatedAt: col.createdAt(), }, // Per-user overrides, so you can enable a flag for yourself before rolling // it out to everyone. flagOverrides: { id: col.uuid(), flagKey: col.text(), userId: col.text(), value: col.text(), expiresAt: col.date().nullable(), },}; export const db = createDatabase({ url: process.env.DATABASE_URL, schema });export const cache = createCache({ maxSize: 500 });Read with singleflight
cache.remember collapses a burst of concurrent misses into one database read. Without it, a cold flag read on a busy route runs the query once per in-flight request.
import { z } from "zod"; const CACHE_TTL = "30s"; function parse(raw: string): FlagValue { if (raw === "true") return true; if (raw === "false") return false; const n = Number(raw); return Number.isNaN(n) ? raw : n;} /** * Resolve one flag: override, then cache, then database. */export async function getFlag(key: FlagKey, userId?: string): Promise<FlagValue> { if (userId) { const override = await db.flagOverrides .where((f) => and( f.flagKey.isEqualTo(key), f.userId.isEqualTo(userId), )) .first(); if (override && (!override.expiresAt || override.expiresAt > new Date())) { return parse(override.value); } } return cache.remember( `flag:${key}`, async () => { const row = await db.flags.where((f) => f.key.isEqualTo(key)).first(); return row ? parse(row.value) : false; }, { ttl: CACHE_TTL, tags: ["flags"] }, );} /** Evaluate many flags in one round trip. */export async function evaluate(userId?: string): Promise<Record<FlagKey, FlagValue>> { const entries = await Promise.all( FLAG_KEYS.map(async (k) => [k, await getFlag(k, userId)] as const), ); return Object.fromEntries(entries) as Record<FlagKey, FlagValue>;}Set and invalidate
import { realtime } from "./realtime"; export async function setFlag( key: FlagKey, value: FlagValue,): Promise<void> { await db.flags .where((f) => f.key.isEqualTo(key)) .update({ value: String(value), updatedAt: new Date() }); // One tag invalidates every read for this key, in L1 and L2. await cache.invalidateTags("flags"); // Open browsers apply it without a refresh. realtime.to("flags").send("flag.updated", { key, value });} /** Give one user a temporary override, e.g. to debug a rollout. */export async function overrideFor( key: FlagKey, userId: string, value: FlagValue, hours = 24,): Promise<void> { await db.flagOverrides.insert({ flagKey: key, userId, value: String(value), expiresAt: new Date(Date.now() + hours * 3_600_000), });}The browser
const store = {}; // One socket, not one per flag.const ws = new WebSocket(`ws://${location.host}/ws`); ws.addEventListener("message", (e) => { const { event, data } = JSON.parse(e.data); if (event !== "flag.updated") return; store[data.key] = data.value; // Re-render whatever subscribed to this flag. window.dispatchEvent( new CustomEvent("yatta:flag", { detail: data }), );}); window.flags = { get: (key) => store[key] ?? false, on: (key, fn) => { const handler = (e) => { if (e.detail.key === key) fn(e.detail.value); }; window.addEventListener("yatta:flag", handler); return () => window.removeEventListener("yatta:flag", handler); },}; // Hydrate from the server on first paint so there is no flash of default.const res = await fetch("/api/flags");const initial = await res.json();Object.assign(store, initial);Tip
Always hydrate from the server on first paint. A flag that is false until the socket opens will flash the wrong UI, which is exactly the kind of bug that gets blamed on the rollout.