flags
Feature flags for a Void app: declared in code with a default, switched on
or off per organization, per user, or per user in one organization, in a
D1 table. Readable in routes, loaders and pages, and switched with one call
that fits in a db.batch() next to its audit row. examples/crm uses it.
Set up
Section titled “Set up”The table, in db/schema.ts, then void db generate writes the migration:
export { featureFlag } from "@recranet/flare/flags/schema"The flags, in a file of your own:
import { defineFlags } from "@recranet/flare/flags"
export const flags = defineFlags({ forecast: { label: "Forecast", description: "A weighted forecast on the overview.", default: false },})FlagSpec |
|
|---|---|
default |
The value without an override. Default false |
label, description |
For a settings page; label defaults to the name |
defineFlags(flags, { db }) takes another Drizzle database; the default is
void/db’s.
Reading
Section titled “Reading”if (await flags.enabled(c, "forecast")) { ... } // a route or loaderawait flags.require(c, "forecast") // 404 while it's offconst values = await flags.get(c) // { forecast: true }c is any Hono or Void context: the subject is the signed-in user
(c.var.user) in the session’s active organization. Or pass a subject, such
as the member access.requireMember returned: flags.enabled(m, "forecast").
Signed out, only the overrides for everyone apply.
get(c) reads once per request: one indexed query for every flag, kept on
the context. A write later in the same request shows from the next request.
A subject (not a context) is read each time.
require throws Hono’s HTTPException with a 404,
{ "error": "Not found", "code": "NOT_FOUND" }: a route behind a flag isn’t
there until the flag is on, the same answer as a record of another
organization.
A page reads them in its loader and returns what it needs; nothing goes to
the browser that the loader doesn’t return. A JSON route can hand them to a
client: export const GET = defineHandler(async (c) => flags.get(c)).
Levels
Section titled “Levels”An override applies at one of four levels. The most specific one wins:
| Level | set() target |
For |
|---|---|---|
member |
{ organizationId, userId } |
One user in one organization: “try it for me” |
user |
{ userId } |
One user in every organization: staff, beta testers |
organization |
{ organizationId } |
Everyone in one organization |
everyone |
{} |
The whole app: a kill switch, or a rollout’s last step |
Without any, the flag’s default. So a member’s own “off” beats their team’s “on”, and a team’s “off” beats an app-wide “on”.
explain(c) says where each value came from, for a settings page:
await flags.explain(m)// [{ name: "forecast", label: "Forecast", description: "…", default: false,// value: true, source: "organization", overrides: { organization: true } }]Switching
Section titled “Switching”await flags.set({ organizationId: m.organizationId }, "forecast", true, { userId: m.userId })await flags.set({ organizationId: m.organizationId, userId: m.userId }, "forecast", null) // back to the team'sset(target, name, value, { userId }) returns the query: await it, or put
it in a db.batch() with the change’s audit row so both commit together.
null removes the override at that level. userId is recorded as who set
it (updated_by). An unknown name throws.
Who may switch what is the app’s call. Take the target’s ids from the session or a checked member, never from the request body (decision 4).
list(organizationId) is an organization’s own overrides and its members’,
each with its level, for an admin view.
Flags no longer in defineFlags are ignored when read and left out of
list, so deleting one from code retires it; its rows can be deleted at
leisure.
In the CRM
Section titled “In the CRM”lib/flags.ts declares one flag, forecast: a card on the overview with
each open stage’s amount weighted by how often it closes.
- Settings › Features lists the flags. Owners and admins switch one for
the team (a toggle); everyone picks “Same as the team”, “On” or “Off” for
themselves. Both go through the page’s
featureaction. - A team change is in one batch with an audit row (entity
feature,{ team: ["Default", "On"] }), so it shows in the audit log. A member’s own choice isn’t logged. - The overview’s loader asks
flags.enabled(m, "forecast")and returns the forecast only when it’s on. GET /api/flagsreturns the member’s values.
Flags or access’s features
Section titled “Flags or access’s features”access has features too: requireFeature(c, key) on a JSON
column of Better Auth’s organization table, ported from Studio. Those are
entitlements: what an organization’s plan includes, set with the
organization. Flags are for rolling things out: per user as well as per
organization, switched often, each change its own row with who made it.
An app can use both.
- D1, not KV: see decision 9.
- Booleans only. Percentage rollouts, variants and targeting rules are left out until an app needs them; a user-level override covers a staged rollout by hand.
- The table has no foreign keys: a deleted organization or user leaves its rows, which no longer match anyone.
- The module imports
void/db,void/schema-d1and Hono’sHTTPException; nothing from the rest of@recranet/flare.