@wtfalch/flags
v0.1.0
Published
The estate's feature flags: a flag declared in code, its rules stored in a table, one resolver per org then per estate then the default.
Readme
@wtfalch/flags
The estate's feature flags: a flag declared in code, its rules stored in a
table, and one resolver that answers per organisation, then per estate, then
the declared default. Extracted from app-template/src/lib/flags/ (ADR
0017 there), generalised into a library every generated app can depend on
instead of copying.
Status
v1. Not published yet: that needs the user's npm 2FA. Both dependencies —
@wtfalch/authz and @wtfalch/audit — are on npm already.
Install
pnpm add @wtfalch/flags
pnpm exec flags-migrations # copies migrations/*.sql into drizzle/ as the next numberThe copy is recorded in drizzle/.flags-migrations.json; running it again
copies nothing. Apply the copied file with the host's own migrate script.
Use
Apply the migration — either the file
flags-migrationscopied intodrizzle/, or, for a quick local setup with no host migrate script,migrate()against any drizzleDbOrTx:import { migrate } from '@wtfalch/flags'; await migrate(db); // idempotentDeclare the app's own flags. Nothing is declared here — the way a template has no feature of its own to gate — a host calls
defineFlagsonce:import { defineFlags } from '@wtfalch/flags'; export const FLAGS = defineFlags({ 'billing:new-invoices': { description: 'The new invoice flow.', kind: 'release', // 'release' | 'kill' | 'ops' owner: 'platform', on: false, expires: '2027-01-31', // required for 'release', forbidden otherwise }, 'search:fallback': { description: 'Fall back to the old search index.', kind: 'kill', // must default on: it exists to be turned OFF owner: 'platform', on: true, }, }); export type Flag = keyof typeof FLAGS & string;defineFlagsvalidates at import time — a typo, a missing expiry on areleaseflag, or akillflag defaulting off all throw immediately, every problem at once. It hands the object back with its literal type, soflag(db, FLAGS, 'billing:new-invoices')is a compile error on a misspelled key once the host narrows to its ownFlagunion.Add
overdueReleaseFlags(FLAGS)to the host's own test suite to get ADR 0017's mechanical expiry check — areleaseflag that outlives its date fails the test rather than quietly becoming permanent:import { overdueReleaseFlags } from '@wtfalch/flags'; it('has no release flag past the day it was meant to go', () => { expect(overdueReleaseFlags(FLAGS)).toEqual([]); });Read a flag:
import { flag } from '@wtfalch/flags'; const on = await flag(db, FLAGS, 'billing:new-invoices', { tenantId });tenantIdis the organisation the request is about, when there is one. Precedence: that organisation's rule, then the estate-wide rule, then the declared default. An undeclared flag is off, whatever its rules say — fail closed.No built-in per-request cache.
app-template's original wrapped this in React'scache()so one request loads every rule once; this package makes no assumption about Next.js/RSC. A host on RSC wraps its ownloadRules-equivalent call (or justflag's first call per request) incache()if it wants that; a host with no framework request scope calls it as many times as it likes — there are tens of rows, not thousands.A flag read never throws. If the database is unreachable, every flag answers with its declared default and the failure goes to
console.error— a flag is not an authorisation decision.Set or clear a rule, gated by
flags:update(see step 6):import { setFlagRule, clearFlagRule } from '@wtfalch/flags'; const change = await setFlagRule(db, { key: 'billing:new-invoices', tenantId, // null for the estate-wide rule enabled: true, note: 'pilot for org-42', updatedBy: personId, }); // { key, tenantId, before, after } | null — null when nothing changed // (setting a rule to what it already says is not an event) await clearFlagRule(db, { key: 'billing:new-invoices', tenantId });Pass an
AuditOptions(a bound@wtfalch/auditAuditWriterplus the actingActor) as the third argument to either function to write theflag.changedaudit event — carrying the rule before and after — in the same transaction as the change (ADR 0017: "Changing a rule is an audit event"):await setFlagRule(db, input, { writer: auditWriter, actor });Everything for a dashboard, in one call:
import { flagStates } from '@wtfalch/flags'; const states = await flagStates(db, FLAGS); // [{ key, declaration, effective, expiry, global, perTenant }]declarationisundefinedfor a rule whose flag has been deleted from the code —flagStatesis where a host's page finds rows to clean up.expiryis{ state: 'none' | 'fine' | 'soon' | 'overdue', days? }for areleaseflag;soonis the last fortnight.Authorization, checked before every write above (this package's store functions do not check it themselves — the same separation
package-template's widget toy and@wtfalch/peoplekeep):import { catalogue, checkFlagsRead, checkFlagsUpdate } from '@wtfalch/flags'; import { resourceAccess } from '@wtfalch/authz'; const access = resourceAccess({ catalogue, principal, /* ...organisations, grants */ }); const result = checkFlagsUpdate(access, { id: 'billing:new-invoices', type: 'flag', applicationId, platformId, organisationId, teamId: null, }); if (!result.allowed) throw new Error(result.reason);Two permissions:
flags:readandflags:update— notplatform.flags:read/platform.flags:update(ADR 0017's own app-level namespace). A host composes this module's vocabulary into its own the same way it does@wtfalch/people's.
Tests
pnpm test # PGlite, in memory
TEST_DATABASE_URL=postgres://... pnpm test # a real Postgres; a scratch schema per runNot in v1
Experiments, percentage rollouts and analytics (the root README's own
scope). No dashboard/UI component — app-template's src/app/flags/ (the
page) sits outside src/lib/flags/, the extraction source, and outside this
package's scope.
