All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[0.9.0] - 2026-08-25
Added
Targeting rules: force a flag's value for a specific key. Pass a context of attributes and let ordered rules override the value when it matches — onboard a beta customer, unblock one account, raise a limit for one tenant, without a deploy.
# once per request, in a plug or on_mount hook PhoenixFlags.put_context(user_id: user.id, company_id: user.company_id) # unchanged call sites are now targeted MyApp.SystemConfig.get("enable_benefits", false) #=> true for company 123 MyApp.SystemConfig.variant("checkout_flow", user.id) #=> pinned arm for user 7 # rules, from the dashboard or in code MyApp.SystemConfig.put_target("enable_benefits", conditions: [[attribute: :company_id, operator: :in, values: [123, 456]]], value: "true" )Requires migration V4 —
PhoenixFlags.Migration.up(version: 4), ormix igniter.upgrade phoenix_flagsto generate it. Existing flags are unaffected until a rule is added.This is the attribute-targeting half of the AWS AppConfig model deferred when
:variantshipped in 0.7.0. Rules are structured rows rather than a string expression DSL, so they can be validated properly and edited in a UI. Conditions live in their own table rather than ajsonbcolumn:jasonis declared optional and unused inlib/, and a JSON column would make a JSON library effectively required.Details:
- Applies to any flag type. A rule forces a value, validated against that
flag's type through the same changeset a dashboard save uses. A
:variantrule names a declared arm rather than a weights string. - Precedence: a test stub, then a matching rule, then the stored value or the weighted split. A rule therefore overrides an A/B split, which is the point of pinning a customer to one arm.
- Operators
:in,:not_in,:eq,:starts_with. Conditions within a rule are ANDed; rules are checked in order and the first match wins. - Everything compares as strings, so
%{company_id: 123}matches"123"and:company_idmatches"company_id"— consistent with every flag value being stored as a string. - A missing attribute never matches,
:not_inincluded: "everyone except these" must not sweep in callers we know nothing about. :secretflags cannot be targeted — the rule value would sit in the targets table as plaintext, defeating the encryptor.- Cost when unused is one
:persistent_termread plus a map lookup, and a flag with no rules never reads the context. That ordering came from measurement: checking the context first measured slower than the read it was avoiding. Seedocs/benchmarks.md. - The context is per-process and not inherited by
Task.async/1and friends; documented, withcontext: PhoenixFlags.context()as the answer. - Cluster replication is free: a rule write triggers a full cache reload and notifies peers, which reload the same way they already do.
- Applies to any flag type. A rule forces a value, validated against that
flag's type through the same changeset a dashboard save uses. A
PhoenixFlags.Context—put/1,merge/1,get/0,clear/0, withPhoenixFlags.put_context/1and friends as shorthands.PhoenixFlags.TargetandPhoenixFlags.Target.Condition, withresolve/2andmatches?/2as pure, fuzz-tested functions.Generated
targets/1,put_target/2anddelete_target/1on every config module;get/2becomesget/3with a:contextoption, andvariant/3accepts:contexttoo.The dashboard's edit dialog lists a flag's rules and can add or delete them. The forced-value input is constrained to what the flag's type allows — a dropdown of declared variants for
:variant, true/false for:boolean.
Fixed
The
:variantweight inputs did not line up, and the digits sat under the spinner. Each row was its own flex container, so the columns only aligned when the labels happened to be the same width — a longer label squeezed its input narrower, since.pf-inputsetswidth: 100%and a flex item may shrink. The editor is now a single three-column grid, so every input shares one column by construction, and the inputs are wider with more right padding so the number clears the native spinner.Elixir 1.16 and 1.18 could not compile
PhoenixFlags.Target. The nestedConditionmodule sat at the bottom of the file, below the first%Condition{}pattern match; those versions expand structs eagerly and failed with "Condition.__struct__/1is undefined", while 1.20 tolerates it.Conditionnow lives in its own file. Caught by the CI version matrix, which is exactly what it is there for.Save and Cancel sat in the middle of the edit dialog. Adding the targeting section below the value form left the footer stranded between the two, because HTML forbids nesting forms so the footer had to live inside the value one. The footer now sits outside both forms at the bottom of the dialog, with the submit button associated by HTML5's
formattribute. The dialog is also a flex column now: the header and footer are pinned and only the body scrolls, so Save stays visible on a tall dialog.A single bad
:variantweights string produced two identical changeset errors, which the dashboard rendered twice.Type.validate_value/2and the declared-name check both parsed the value and both reported the same failure; the name check now skips when the value has already been rejected.
Changed
- Benchmarks use their own
phoenix_flags_benchdatabase, created and migrated on first run.bench/bench_helper.exspointed at the test database and runs outside the Ecto sandbox, so benchmark writes committed and broke the nextmix test. The README documented that as a trap; it is now simply gone.
[0.8.0] - 2026-08-25
Changed
The dashboard editor is now a modal dialog instead of an inline form that replaced the row. Opens from the row's Edit button; closes on Save, Cancel, the ×, Escape, or a backdrop click. Keyboard focus moves into the dialog on open, and a validation error keeps it open with the message in place.
The dialog is rendered once for whichever flag is being edited, derived from the current entries rather than held in its own assign, so a cluster update cannot leave it showing a stale value.
:secretflags now go through the same dialog as everything else rather than a bespoke inline form.Dead CSS for the old inline editor (
.pf-row-editing,.pf-edit-actions,.pf-input-wrap) has been removed.
Fixed
Editing
priv/static/app.csshad no effect. The stylesheet is read into a module attribute at compile time, but nothing declared it as an@external_resource— so Elixir did not know the module depended on it and never recompiled. The old CSS stayed baked into the beam and kept being served. Found while adding the modal styles, which silently did not appear.The edit dialog focused the close button on open.
JS.focus_first/0walks DOM order and reached the header's × before any input, which put keyboard focus in the wrong place and rendered the × with a focus ring. Focus is now scoped to the dialog body.Modal styling: the weight rows had a 120px label gutter that left a dead gap for short labels, and a 92px right-aligned input put the value underneath the native spinner. Labels and inputs now sit at opposite ends of the row with the value padded clear of the spinner, and the footer has enough contrast to read as a footer in dark mode. The close button's focus ring is
:focus-visibleonly, so it no longer shows for pointer users.docs/benchmarks.mdwas linked from the README but missing from:extras, so the link resolved on GitHub but not on hexdocs, andmix docswarned.The
mix run dev.exsdashboard rendered but was completely inert. Clicking Edit or a toggle did nothing, withwindow.LiveView is undefinedin the console. The inline dev bundle wrapsphoenix.min.jsandphoenix_live_view.min.jsin an arrow function, and those are esbuild IIFEs of the formvar LiveView = (() => {...})()— so inside that wrapper they are ordinary function-scoped locals, never globals. Reading them aswindow.LiveView/window.Phoenixtherefore yieldedundefinedand no LiveSocket was ever constructed. Now references the local bindings, verified by evaluating the served bundle under Node with a DOM shim: it constructs a LiveSocket, where the previous bundle threw the exact reported error.Dev tooling only — the packaged library is unaffected, and the dashboard's server-side behaviour was always covered by the LiveView tests. But it means manual browser checks of the dashboard were never actually exercising interactivity.
dev.exsnow asserts at load time that each bundled asset still begins with the expectedvar Phoenix=/var LiveView=binding, so an upstream rename or format change fails loudly instead of silently producing an inert page again..formatter.exsnow includesdev.exsandbench/. They sat outside the input globs, somix format --check-formattedin CI never checked them.
Added
- The README's Development section now covers running the dashboard locally,
running the suite, reproducing CI exactly, driving the API from a script (and
why
iex -S mix run dev.exsnever reaches a prompt), trying the library in your own app via a path dependency, and the test-database pollution trap inbench/bench_helper.exs. usage-rules.mdgains an Admin dashboard section, leading with the fact that the dashboard ships no authentication and must be guarded at both the pipeline and:on_mountlayers.
[0.7.1] - 2026-08-25
Bug fixes from a review of the 0.7.0 A/B feature, plus two crash paths that predate it. Every fix below is covered by a test that was verified to fail without it.
Fixed
Hash parts could be re-partitioned, aliasing two experiments together. The seed, flag key and identity were joined with
":", sovariant("exp", "org:123")andvariant("exp:org", "123")hashed identically — a 100% collision rate, not a rare one. Composite identities like"org:123"are common enough for this to bite. Parts are now length-prefixed. Distribution is unchanged (50.02/49.98 over 20k identities); collisions dropped from 5000/5000 to chance.This changes every assignment relative to 0.7.0. Since 0.7.0 was tagged but never published to Hex, nobody should be affected in practice — but if you installed it from git, expect your population to reshuffle once.
A changed variant set kept assigning removed variants. Seeding deliberately leaves the stored weights alone so a runtime rollout survives a deploy. But when the declared set of variants changed, the stored split still named variants the code no longer had, and
variant/3went on assigning them — crashing callers that pattern match on the declared names. Seeding now resets the split (with a warning) when the set differs, mirroring the existing reset on a type change. Comparison is by set, so a rollout survives both a reordering and any weight change.variant/3raised on an unusable identity. Anilidentity is a data condition — an anonymous visitor, a record without an id — not necessarily a coding mistake, and it took down the caller's process. It now warns and returns:default, consistent with howget/3handles a failed read.PhoenixFlags.Variant.assign/4stays strict for direct callers.A forged dashboard weight crashed the LiveView. A non-string value in
entry[variants][...]reached string interpolation and raisedProtocol.UndefinedError. It is now coerced to0, which surfaces as an ordinary "must total 100" field error.A forged
pf-savefor an unknown key crashed the dashboard.update_entry/4returns{:error, :not_found}for a key that is not in the table; that bound aschangesetandto_form/2raised on it. The dashboard also gained a catch-allhandle_event/3, so an unknown event name or a missing field no longer takes the view down for want of a matching clause.Log statements inside rescue handlers could themselves raise — predates 0.7.0. Several interpolated the flag key directly, and
String.Charsis undefined for a map, tuple or pid. Because these sat insiderescueblocks, the failure escaped to the caller:get("some_key")with a map key crashed withProtocol.UndefinedErrorinstead of returning the default. All such log statements now useinspect/1, which is also unambiguous for keys containing spaces. Log message format changed accordingly.A non-string key reached the database — predates 0.7.0.
get/3,update_entry/4andvariant/3now screen the key before querying, rather than relying on a rescue to mop up anEcto.Query.CastError.
Changed
A stored split must name every declared variant. Previously a subset was accepted, so
"a=100"could be saved for a two-variant flag — and then be reset by the next restart, because seeding reconciles the stored set against the declaration. Writes are now durable or rejected; use a weight of0to switch a variant off.Declaration options that would be ignored are now rejected, the same reasoning as
PhoenixFlags.Config.new!/1refusing unknown keys::defaulton a:variantflag (its value comes from:variants), and:ttlor:seedon a non-variant flag.
Added
test/phoenix_flags/variant_robustness_test.exs— regression tests for each fix above, plus a fuzz suite that throws ~40 malformed inputs (separator abuse, unicode, huge and negative weights, wrong types) atVariant.parse/2,parse_weights/1,Entry.cast_value/2andEntry.changeset/3, asserting they only ever return and never raise. The log-statement bug above was found this way.
[0.7.0] - 2026-08-25
Added
A/B testing via a new
:variantflag type. A:variantflag resolves to a different value per caller, chosen by a consistent hash of an identity you supply, so a given user sees a stable experience and results stay analysable.flag "checkout_flow", type: :variant, category: "experiments", label: "Checkout flow experiment", ttl: nil, # nil (default) = assignment never expires variants: [{"Control", "control", 90}, {"New flow", "new_flow", 10}]MyApp.SystemConfig.variant("checkout_flow", user.id) #=> "control"Weights are whole numbers totalling 100, stored as the flag's value (
"control=90,new_flow=10"), so a rollout can go 5% → 15% → 40% → 100% from the dashboard with no deploy. No migration is required —system_flags.typeis an unconstrained string column andvalueis alreadytext.Modelled on AWS AppConfig traffic splitting, with one deliberate difference. AppConfig evaluates each variant as an independent
(split pct::N by::$id)rule, first match wins over a shared hash, which means two variants atpct::20produceA: 20%, B: 0%— a footgun their own docs document. A single ordered weight table makes buckets disjoint by construction and lets the total be validated.Details:
- Sticky rollouts. Buckets are cumulative in declaration order, so growing
a variant at the expense of the next one moves only the boundary between
them. Verified over 20k identities: going
90/10→80/20moves nobody out ofnew_flow. Reordering:variants, changing:seed, or a:ttlrollover all reshuffle the population, and the docs say so. ttl:(milliseconds,nilby default and meaning permanent) folds a time window into the hash so each caller is re-rolled once per window. Windows are offset per identity, so the population does not all flip at once. Stateless — no rows stored, no database call.- Independence. The flag key is part of the hash input, so concurrent
experiments do not correlate.
seed:re-randomises everyone, for restarting an experiment on the same flag. - SHA-256, not
:erlang.phash2/2.phash2is not guaranteed stable across OTP major versions, and an OTP upgrade must not silently reshuffle a live experiment. Costs ~0.5 us per assignment; seedocs/benchmarks.md. get/2raises for a:variantflag, namingvariant/3, rather than leaking a%PhoenixFlags.Variant{}into application code. Benchmarked at no measurable cost to ordinary reads.- A missing identity raises.
nil(or anything not a non-empty string or integer) would put every caller in the same bucket, which is an invisible bug, so it fails loudly. - Dashboard editor with one input per variant and a live running total that must reach 100.
- Opt-in exposure events.
variant/3emits nothing by default; passtelemetry: truefor[:phoenix_flags, :variant, :assigned].:telemetryis now a declared dependency — it was previously only transitive.
- Sticky rollouts. Buckets are cumulative in declaration order, so growing
a variant at the expense of the next one moves only the boundary between
them. Verified over 20k identities: going
PhoenixFlags.Variant—parse/2,parse_weights/1,serialize/1,assign/4.Generated
variant/3andvariants/1on every config module.
Fixed
usage-rules.mddocumented a function that does not exist. It describedTest.put_override/2; the generated helper isTest.stub/2. Sinceusage-rules.mdships in the package and is consumed by coding agents, this was actively misleading.- Dashboard edit forms now carry an
id, which LiveView needs for form recovery after a reconnect. Previously it warned about this in tests.
Changed
- Unexpected stored boolean values now warn instead of failing silently.
cast_value/2wasvalue == "true", so a value that was neither"true"nor"false"— only reachable via a hand-edited row or a data migration, sincechangeset/3rejects anything else — read asfalsewith no signal. It still returnsfalse(for a boolean flag, failing closed beats returningniland changing whatget(key) == falsemeans), but now logs a warning like the integer and decimal casts already did.
Internal
No behaviour change; all of these are covered by the existing suite.
- Both write paths now build their changeset through one
value_changeset/3. The encrypt-then-validate sequence was duplicated across the cached and uncached paths, which is why:selectmembership went unchecked on both at once. - Removed a dead
rescue ArgumentErrorinread_persistent_term/2;:persistent_term.get/2returns the default for a missing key rather than raising. Documented why theget/1call sites do still need theirs. - Documented the deliberate asymmetry in
update_in_caller/4: unlike the cached path it has norescue, because with no GenServer to keep alive a database error should reach the caller rather than be flattened into an error changeset. PhoenixFlags.Configderives bothdefstructand its accepted-option list from a single@fieldsattribute; the two were maintained side by side and could drift.- Named the
999_999sort sentinel inall_grouped/1. - Added
PhoenixFlags.EntryTestcoveringcast_value/2andchangeset/3, which had no direct unit tests.
[0.6.2] - 2026-08-25
Fixed
:selectvalues are validated against their declared options on write.PhoenixFlags.Type.validate_value/2fell through to its catch-all:okclause forselect, soupdate_entry/3accepted, stored and cached any string at all:update_entry("feature_tier", %{"value" => "not-an-option"}) #=> {:ok, %Entry{value: "not-an-option"}} # before #=> {:error, #Ecto.Changeset<errors: [value: {"must be one of: basic, pro", []}]>}The constraint was already enforced on the declared default at compile time (
PhoenixFlags.Flag), so this closes an inconsistency rather than adding a new rule. It matters because the dashboard's rendered<select>is not a validation boundary — LiveView event params are client-controlled — and an out-of-range value reachedget/2, crashing consumers that pattern match on the known options.PhoenixFlags.Entry.changeset/2gained an optional third argument carrying:select_options; the membership check only runs when they are supplied, so callers that build a bare form (or a:namemodule exporting onlyflags/0) are unaffected.
Upgrading
No migration or code change required. If you were relying on storing arbitrary
values in a :select flag, those writes now return {:error, changeset} —
either add the value to the flag's :options or change the flag to :string.
Values already in the database are left alone; only new writes are checked.
[0.6.1] - 2026-08-25
Changed
Dependencies updated. No library code changes;
mix.exsconstraints are unchanged (every bump is within the existing requirements).Package From To phoenix1.8.9 1.8.12 phoenix_live_view1.2.8 1.2.10 postgrex0.22.3 0.22.4 ecto3.14.1 3.14.2 req(dev, viaigniter)0.7.1 0.7.3 spitfire(dev, viaigniter)0.3.13 0.4.0
Security
- Picks up postgrex 0.22.4, which escapes comments in
Postgrex.stream/4(CVE-2026-66838). PhoenixFlags does not callPostgrex.stream/4itself, so this is a transitive hardening for host applications that do. - Picks up phoenix_live_view 1.2.9, which fixes an open redirect in
redirect/2via ASCII tab, LF and CR (CVE-2026-64941). The embedded dashboard performs no redirects or navigation of its own, so it was not exposed, but host applications on 1.2.8 should upgrade. mix hex.auditreports no retired or advisory packages.
[0.6.0] - 2026-07-30
Upgrading
Generate the V3 migration with mix igniter.upgrade phoenix_flags, or manually:
defmodule MyApp.Repo.Migrations.UpgradeSystemFlagsV3 do
use Ecto.Migration
def up, do: PhoenixFlags.Migration.up(version: 3)
def down, do: PhoenixFlags.Migration.down(version: 3)
endThe upgrade is seamless:
- Existing databases (V1 or V2): the migration widens the value columns and moves the schema version from the
system_flagstable comment into the newsystem_flags_metatable. The version is read from the comment during the transition, so no manual steps are needed. - Fresh databases (new dev machines, CI): your existing migration folder keeps working — the original install migration now builds V3 directly, and older pinned migrations (
up(version: 2)) become no-ops. - No downtime:
varchar(255)→textis binary-coercible in PostgreSQL, so the column change is a catalog-only update with no table rewrite, even on large audit tables. - Rollback:
mix ecto.rollbackof the V3 migration restores the previous scheme exactly (columns narrowed back — this fails if any stored value now exceeds 255 characters — and the version written back to the table comment). - Caveat: if you ever need to downgrade the package below 0.6.0, roll back the V3 migration first (while 0.6.0 is still installed). Older releases only know the comment-based version store, which V3 clears.
Fixed
- Full rollback works again.
PhoenixFlags.Migration.down(version: 1)executed invalid SQL (COMMENT ON TABLE IF EXISTSdoes not exist in PostgreSQL) after dropping the table, somix ecto.rollbackalways failed. The redundant comment reset was removed. :secretdefaults are encrypted at seed time. Previously a:secretflag with a non-empty default was written to the database in plaintext, and cached reads then failed to decrypt it. Seeding (and the value reset on type changes) now runs the default through the configured encryptor.- Uncached reads decrypt secrets. With
cache_enabled: false,get/2returned the stored ciphertext for:secretflags instead of the plaintext the cached path returns. Both paths now share the same decrypt-and-cast pipeline. - Server survives database errors during writes. A
Postgrex.Errorraised insideupdate_entry/3(e.g. connection loss) crashed the GenServer; it is now rescued and returned as an error changeset. - Restarts no longer serve call-site defaults.
terminate/2erased the persistent_term config key thatget/3needs, so every flag read as its default during a Server restart. The cache, config, and order keys are now all left intact and overwritten byinit/1. - Decrypt failures signalled by return value are handled. An encryptor returning a non-binary (e.g.
:errorfrom:crypto.crypto_one_time_aead/7) was cached as if it were the plaintext; it now logs a warning and yieldsnil. - README example encryptor could not decrypt its own output (12-byte IV written, 16-byte IV read). The install snippet also referenced version
0.1.0and omitted theorganizationoption. - Dashboard toggle only accepts boolean flags. A forged
pf-toggleevent could overwrite any flag (including strings/selects) with"true"/"false". - Mounting
flags_dashboardtwice works. The router macro defined a shared pipeline and plug function per mount, so a second dashboard silently reused the first mount's:app_jsand emitted duplicate-clause warnings. - Deterministic audit ordering. Audit queries now tiebreak on
id—inserted_athas second precision, so same-second changes had nondeterministic order.
Added
- Migration V03 widens
system_flags.valueand the auditold_value/new_valuecolumns fromvarchar(255)totext(encrypted secrets routinely exceed 255 characters and previously crashed the Server on write), and moves the schema version out of thesystem_flagstable comment into a queryablesystem_flags_metatable. Older databases are still detected via the comment fallback, and rolling back V03 restores the comment.mix igniter.upgrade phoenix_flagsgenerates the migration. - Periodic cache refresh. Each instance reloads its cache from the database on a jittered
refresh_interval(default 60s,falseto disable), bounding staleness for nodes that miss a:reloadnotification during partitions or restarts. - One-config-module-per-repo guard. Two config modules with different flag declarations sharing one repo would delete each other's rows at seed time; the Server now detects this at boot and raises with a clear message.
Changed
PhoenixFlags.Config.new!/1raises on unknown options instead of silently ignoring them (a typo likeaudit_enabled:left auditing off).audit_log/0andaudit_log/1raise a clearPhoenixFlags.Errorwhenaudit: trueis not set, instead of failing with a database error about the missing table.:jasonis now an optional dependency — nothing inlib/uses it, so consumers are no longer forced to install it.usage-rules.mdis now included in the published package.
[0.5.0] - 2026-04-23
Added
- Audit log. Opt-in per-flag change history via
audit: trueonuse PhoenixFlags. Writes to a newsystem_flags_audittable.actor_fnresolves the actor from the LiveView socket / Plug conn.audit_log/0andaudit_log/1query the history. - Migration V02 adds the
system_flags_audittable.PhoenixFlags.Migration.up(version: 2)runs the whole chain for new installs; existing installs add a second migration calling the same helper (themix igniter.upgrade phoenix_flagstask generates this automatically). :secretflag type for credentials and other sensitive values. Secrets are encrypted at rest via a host-supplied encryptor module (encrypt/1+decrypt/1), displayed in the dashboard as "Set" / "Not set" with a password-style edit input, and redacted as"[redacted]"in the audit log. PhoenixFlags ships no cryptography itself — you pick the cipher and manage the key.:encryptoroption onuse PhoenixFlags. Required whenever any:secretflag is declared — omitting it raises aPhoenixFlags.Errorat compile time with the list of offending keys. A boot-time check also verifies the encryptor module exportsencrypt/1anddecrypt/1.mix igniter.upgrade phoenix_flagsnow works — newMix.Tasks.PhoenixFlags.Upgrademodule. Generates the v2 migration for users upgrading from 0.4.x.
Changed
update_entry/3accepts an:actoroption that's recorded in the audit log.Entry.changeset/2accepts""as a valid value for:secretflags (so operators can clear a secret); non-secret flags continue to reject empty strings as before.- Renamed
PhoenixFlags.Testing.put_override/3→stub/3andget_override/2→get_stub/2. The generatedMyApp.SystemConfig.Test.stub/2helper exposes the new name. This is a breaking API change for tests written against 0.4.x — rename.Test.put_override("key", value)to.Test.stub("key", value).
[0.4.2] - 2026-03-20
Changed
update_entry/3now patches the persistent_term cache in-memory instead of doing a fullrepo.all(Entry)reload — reduces DB calls per write from 3 to 2- Flag declaration order index is now pre-computed once at startup and stored in persistent_term, instead of being rebuilt on every
all_grouped/0call
Added
- Benchee benchmarks (
bench/phoenix_flags_bench.exs) covering all public functions - Benchmark documentation (
docs/benchmarks.md) with I/O profiles and results
[0.4.1] - 2026-03-20
Fixed
- SQL injection vector in migration
prefix— now validated against^[a-z_][a-z0-9_]*$ - Unsafe
String.to_integer/1inmigrated_version/1— replaced withInteger.parse/1with fallback - Cast failures (
cast_value/2) now log at:warninginstead of:debugfor better observability - Cache reload failures now log at
:errorinstead of:warning insert_allseed log now reports the actual inserted count, not the declared countterminate/2now logs cleanup failures at:debuginstead of silently swallowing themto_seed_map/1catch-all clause tightened to require:key,:type, and:valuekeys- Moved
require Loggerto top ofEntrymodule
Added
- Tests for malformed DB values (corrupted integer, decimal, percentage, partial integer)
docs/TODO.mdwith planned improvements
[0.4.0] - 2026-03-20
Added
- Self-contained CSS and HTML layout — dashboard no longer depends on host app's stylesheets or layout system
- Asset plug serves CSS with content-hashed URLs and immutable cache headers
- Own root layout with
<script>tag to load the host app's LiveView JS :app_jsoption onflags_dashboardto customise the JS bundle path (defaults to/assets/js/app.js)- 14 LiveView tests covering renders, toggle, edit, save, validation errors, cancel, and select
select_options/1callback on the generated module for:selecttype flag options- Declaration-order sorting — categories and entries always render in
flag/2declaration order
Changed
- Dashboard is fully self-contained (like Oban Web / LiveDashboard) — no
@sourcedirective or:layoutoption needed - Removed
:layoutoption fromflags_dashboardmacro - Removed flash messages from dashboard — updates are instant and don't need confirmation
- Extracted
entry_infocomponent to reduce template duplication - Consolidated integer/decimal/percentage inputs into a single component clause
- Extracted
input_classandreloadhelpers to reduce repetition
Removed
:layoutoption fromflags_dashboard— the package owns its own layout now- Flash message rendering from the live layout
[0.3.0] - 2026-03-19
Added
:optionsfield onFlagstruct for:selecttype — a list of{label, value}tuples- Compile-time validation that
:selectflags must provide:options - Compile-time validation that
:selectdefault value must be in the:optionslist
[0.2.0] - 2026-03-19
Added
PhoenixFlags.Errorcustom exception for all library-raised errorsPhoenixFlags.Typeshared validation module, eliminating duplicated logic betweenFlagandEntryupdate_entry/3now accepts:timeoutoption for GenServer call timeoutTesting.insert_entry/4infers type from value (boolean, integer, Decimal, string)- Helpful error message when numeric flag types omit
:default(e.g.flag "x", type: :integer) - Catch-all
handle_infoclause to prevent GenServer crashes from unexpected messages terminate/2erases config key on shutdown, signalling "server down" to readers- Peer notification after flag seeding, so other nodes pick up changes from deploys
- Dashboard
:layoutoption to render inside the host app's layout PhoenixFlags.UI.OnMounthook for mounting dashboard in customlive_session- Conditional compilation for UI modules (
phoenix_live_viewis truly optional)
Changed
get/3never crashes — returns default when server is not running or restartingall_grouped/0returns[]when server is not running- Cache stores values and entries in a single atomic
{values, entries}tuple (one persistent_term key instead of two) - Metadata updates during seeding are wrapped in
Repo.transactionfor atomicity insert_alluseson_conflict: :nothingto handle concurrent node startups gracefully- Dashboard uses host app's layout and CSS framework instead of self-contained styles
load_cachecrashes on first load ininit(supervisor retries), rescues on subsequent reloads- Removed dead
:tableconfig option fromConfigstruct
Fixed
- SQL injection in
Migration.migrated_version/1— now uses parameterized query - Migration V01 now respects the
:prefixoption for table and index creation - Narrowed
rescue ArgumentErroringet/3to only catch persistent_term lookup failures cast_valuelogs at debug level for unparseable values instead of silently returning nil
[0.1.0] - 2026-03-19
Added
- Initial release
use PhoenixFlagsmacro for defining configuration modulesflag/2macro for compile-time validated flag declarationsPhoenixFlags.Flagstruct with validation for key, type, and default valuesPhoenixFlags.EntryEcto schema with typed value casting and validationPhoenixFlags.ServerGenServer with:persistent_termcaching- Declarative flag seeding on startup: insert new, update metadata, remove stale, reset value on type change
- Cluster-aware cache replication via direct node messaging
- Versioned migration system (
PhoenixFlags.Migration) - V01 migration:
system_flagstable with key, value, type, category, label, description PhoenixFlags.Testingmodule with process-scoped overrides and DB insert helpers- Auto-generated
Testsubmodule in:testenvironment withput_override/2andinsert_entry/3 PhoenixFlags.Configstruct for instance configuration- Support for boolean, integer, decimal, percentage, select, and string value types
- Validation per type on
update_entry/2 all_grouped/0for admin UI data- Test sandbox compatibility via
cache_enabled: false - Embedded admin dashboard (
PhoenixFlags.Router.flags_dashboard/2) - Self-contained LiveView UI with own layout, components, and CSS (no app dependencies)
- Dashboard features: boolean toggle switches, inline edit forms, validation errors, flash messages, dark mode