Skip to content

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.

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:

lib/flags.ts
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.

if (await flags.enabled(c, "forecast")) { ... } // a route or loader
await flags.require(c, "forecast") // 404 while it's off
const 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)).

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 } }]
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's

set(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.

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 feature action.
  • 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/flags returns the member’s values.

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-d1 and Hono’s HTTPException; nothing from the rest of @recranet/flare.