@voltro/plugin-flags 0.54.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -39,6 +39,144 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.55.0] — 2026-08-27
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/protocol, @voltro/client, @voltro/runtime, @voltro/cli** — **A multi-reference field now flips as instantly as a scalar one.** A mutation target's declared `relations:` reconciled the junction inside the server's transaction and nothing else: the client learned about the link change only when the delta came back. On the same submit, the renamed title flipped immediately and the assigned stores did not — the half of the promise that was never stated.
47
+
48
+ The declaration now drives BOTH. `useMutation`'s auto-optimistic stages a patch on every subscription sourced on the junction table, reconciling that anchor's links against `input[field]` — surplus links removed, new ones staged, surviving links left untouched with their real ids (a diff, mirroring `store.relationLinks(...).set`, not a drop-and-restage that would blink every unchanged row).
49
+
50
+ The patches ride the ordinary optimistic lane, staged under the mutation id, so the rollback rule holds by construction: reverted on failure, kept on success until the base actually moves. Nothing here is on a timer.
51
+
52
+ **Migration — `relations:` values are objects now:**
53
+
54
+ ```ts
55
+ // before
56
+ target: { table: 'employees', op: 'update',
57
+ relations: { assignedStores: 'employee_assigned_stores' } }
58
+
59
+ // after
60
+ target: { table: 'employees', op: 'update',
61
+ relations: { assignedStores: {
62
+ junction: 'employee_assigned_stores',
63
+ anchorColumn: 'employeeId', // the junction reference() pointing at `employees`
64
+ targetColumn: 'storeId', // the junction's other reference()
65
+ } } }
66
+ ```
67
+
68
+ The columns are declaration data because the optimistic patch runs in the BROWSER, which has no table registry to derive them from — `@voltro/database` is server-only by construction, and guessing a column from a table name is exactly what `store.relationLinks` refuses to do. They are not taken on trust: before it writes, the server compares the declaration against the junction's real reference columns and refuses, naming the correct pair, if they disagree. A wrong declaration is a loud error carrying its own fix, never a client that patches one column while the server writes another.
69
+
70
+ Semantics unchanged and now shared by both sides: an absent input field touches nothing (absent ≠ empty), an empty array is the explicit clear. The client uses `input.id` for an update and, for an insert, the same optimistic id it stamped on the new row — the server's `output.id` is not knowable before the response.
71
+
72
+ **`voltro update` carries you across this** — codemod `0.55.0/01_target-relations-declare-columns`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.55.0).
73
+ - **@voltro/client, @voltro/ui, @voltro/ui-shadcn, @voltro/web, @voltro/cli** — **A rich-text field, and the sanitizing contract is the point of it.** `RichTextDocument` (`@voltro/client`) is the value; `widget: 'rich-text'` renders it; `<RichTextView>` (`@voltro/ui`, re-exported by `@voltro/web`) displays it.
74
+
75
+ The contract, stated plainly because the alternatives all look reasonable until you name who the attacker is:
76
+
77
+ - **The value is a closed document tree, not an HTML string.** There is no `html` node, no raw-markup escape hatch, no attribute bag. Anything that is not one of the declared node types fails to decode. - **The boundary is the `Schema` decode**, which is the server's existing, non-bypassable input boundary — the same one every mutation input already passes through. So the guarantee is not "somebody remembered to sanitize this"; it is that a document which reached the database is one of these shapes. - **A link's `href` is the one field that points outward, and it is allowlisted** — `http(s)`, `mailto:`, a `#fragment`, a `/path`; nothing else. Control characters and whitespace are stripped before the check, because `java\tscript:` navigates exactly like `javascript:` and a check on the raw string passes it. - **Client-side is not a boundary and is not treated as one.** The widget's parser runs in the browser for the editing experience; every property it maintains is re-established by the decode on the server. - **Rendering never uses `dangerouslySetInnerHTML`.** Nodes become React elements, text becomes React children — so markup typed into the box is markup the reader SEES. `<RichTextView>` also drops an href that would not survive a decode, for the value that never went through one.
78
+
79
+ Rejected, for the record: sanitizing an HTML string on write (ships an HTML parser and the mXSS surface that comes with it), escaping at render (makes safety a property of every read site), and declaring an unenforced boundary (a convention, not a guarantee).
80
+
81
+ The built-in widget is a `<textarea>` over a small, CLOSED markdown subset — headings, `**bold**`, `*italic*`, `` `code` ``, `[text](href)`, lists, blockquote, fenced code — with everything unrecognised left as literal text. That also closes the no-JS loop: the textarea posts source, `/form/*` parses it, and the same decode validates it. A WYSIWYG belongs at rung 2 (register a `rich-text` widget); the stored value shape does not change.
82
+
83
+ **Not** the collaborative case: `crdtDoc()` + `useCrdtEditor` remains the multi-writer path (a CRDT bytes column, a sync lane, Tiptap). This is one column, one writer, ordinary JSON the server can validate and diff, and no new dependency in the default kit.
84
+
85
+ **Migration:** `WidgetKind` gained `'rich-text'`. Only a registry typed as a TOTAL map (`Record<WidgetKind, Widget>`) notices — add one entry pointing at the exported `RichTextWidget`. Partial registries need nothing.
86
+
87
+ Also fixed alongside: the capability manifest's `MANIFEST_WIDGET_KINDS` is a hand copy of `WidgetKind` (a CLI module cannot import `@voltro/client`) and had silently drifted by two kinds since 0.53.0, telling coding agents a smaller set than the renderer accepts. It is complete again and pinned by a test that reads the union out of the client's source.
88
+
89
+ **`voltro update` carries you across this** — codemod `0.55.0/02_widget-kind-gained-rich-text`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.55.0).
90
+
91
+ ### Added
92
+
93
+ - **@voltro/database, @voltro/data-transfer, @voltro/cli, @voltro/voltro** — A staged `--mode replace` now RECORDS the scratch tables it creates, and a boot collects the ones nobody is coming back for.
94
+
95
+ Staging tables are `_voltro_staging_`-prefixed, so the differ correctly ignores them — and nothing else mentioned them either. A run that died between the load and the swap left a full copy of a bundle that only a hand-written introspection could find, on a database whose boot said nothing. The marker row now names the set, carries a heartbeat the run refreshes while rows land, and records whether the run was started `--no-atomic`. That is what lets a boot tell the three cases apart: silent past the threshold and not resumable → the tables are dropped; still beating → an import is loading into them, here or on another replica; resumable → its staging IS the resume point and is left alone however stale. `VOLTRO_STAGING_STALE_MINUTES` moves the threshold (default 30). A staging record is reported, never a refusal — a staged replace destroys nothing until one short server-side swap, so refusing a boot over it would be an alarm on a healthy database. `voltro data clear-staging` reads the same records and labels each table with what its own run says, instead of listing them flat under a warning that it could not tell a leftover from an import in flight.
96
+
97
+ Two smaller things came with it. The raw-SQL seam the staged swap runs on (`DataStore.run`) is a DECLARED optional capability now, in the shape `emptyTables` established, with a parity assertion across the four dialect stores — it was duck-typed against an interface that never mentioned it, so a store that dropped it would have fallen out of the feature detection and taken the slower path forever, on that one dialect, in silence. And a registered staging clone can no longer reach the differ's DECLARED side: `--mode replace` registers each staging table as a clone of its target for the length of the load (a typed write resolves its columns by name), and a plan computed while an import was in flight proposed `create-table _voltro_staging_notes`.
98
+
99
+ Also measured rather than assumed: the staging table `createStagingSql` builds on **sqlite** (`CREATE TABLE … AS SELECT * FROM t WHERE 0`) and on **SQL Server** (`SELECT * INTO … WHERE 1 = 0`) carries the target's columns and ZERO foreign keys, which is what the load needs. Those were the two dialects the postgres and mysql-family measurements had not covered.
100
+ - **@voltro/client, @voltro/ui, @voltro/web** — **`useFormField(path)` finds its binding.** `<FormBindingProvider binding={form}>` (mounted for you by `<AutoForm>`) makes the narrow per-field subscription reachable without threading the binding down to every field component as a prop. That thread was blocking incremental adoption: a codebase moving a hundred-plus forms one at a time keeps its own field context and swaps engines per form, and being asked to prop-drill to ~30 field components at once meant taking the binding and declining the optimisation they had the most to gain from.
101
+
102
+ **Message ids a real catalogue needed**, each because the generic answer is worse at the point of use:
103
+
104
+ - `betweenLength {min,max}` when a field carries BOTH bounds — "at least 2" is a half-truth for a rule that is "between 2 and 50" — and `exactLength {amount}` when they are equal. Read from the schema, not the failing issue: piping nests the later refinement outermost, so the sibling bound is not reachable from the issue that failed. - `invalidEmail` / `invalidUrl` / `invalidUuid` when the refinement declares a JSON-Schema `format`. A bare regex cannot name its own rule, and "Invalid format" beside an email box tells nobody anything. - `minDate` / `maxDate`, because a date bound rendered as a number bound reads "must be at least 2026-01-01". - `invalidFileType` / `fileTooLarge` — not produced by any refinement, carried so an app's own `ctx.validation.fail('doc', 'validation.fileTooLarge')` renders a sentence rather than an id.
105
+
106
+ `apiSurface: compatible` — `useFormField` goes from a const arrow to an overloaded function so it can take `(path)` as well as `(binding, path)`. The golden line for the old signature is replaced rather than removed: every existing `useFormField(form, path)` call compiles unchanged, because that overload is still declared first-class. Only code capturing the function's exact TYPE (rather than calling it) sees a difference.
107
+
108
+ **Counting rules pass `count`.** `minItems` / `maxItems` carry `{ count }` beside `{min}`/`{max}`: i18next selects a plural form on a parameter named exactly `count`, so ids passing only `{min}` could not be pluralised at all.
109
+ - **@voltro/runtime, @voltro/cli** — **The resume census — `/_voltro/inspect/subscriptions` now carries `resume`.** Per query label: how many subscriptions recorded a delta-resume ring, and how many were excluded, counted per reason (`computed`, `row-filter`, `eager-load`, `uncanonical-input`, `not-offered`). `voltro dev` also logs each verdict once per label under the `voltro:resume` scope — debug is the default level outside production, so it is already on where the tuning happens and off where it is served.
110
+
111
+ It exists because the two failure shapes are indistinguishable from outside. A subscription excluded by a row filter and one whose executor returns a **value** rather than a descriptor both reconnect with a fresh snapshot and rows on the screen, so an app measuring its own reconnects cannot tell which of its queries a `tables:` declaration is even capable of helping. The answer is which of the two bind paths the executor took, and nothing on the wire carries it.
112
+
113
+ The reasons are the load-bearing part, not the counts: `computed` means no declaration can ever change this query, `row-filter` means the filter narrows its source and the exclusion is the point, and `eager-load` is reported ONLY when the base table is not itself narrowed — so that verdict always means "drop the `.with(...)` and this one resumes". Counts rather than one verdict per label, because resumability is not purely a property of the label: an input that does not canonicalise is a property of the value, so one query can be resumable for one subscriber and excluded for the next. A label nobody has subscribed to is ABSENT rather than reported as zero — "nothing has subscribed yet" and "every query is excluded" must not read the same.
114
+
115
+ **And a correction to what `tables:` was documented to buy.** The 0.54.0 notes, the `tables:` doc comment and the row-level-security page all said one registration cost delta-resume on every query descriptor in an app, with a count beside it. The count was real; the sentence around it claimed those descriptors would have HAD the feature, and that was never measured. A query only has a delta chain when its executor returns a descriptor — one that maps its rows or wraps them in a page envelope re-runs an opaque handler and emits snapshots, filter or no filter. So the number a declaration gives back is the number of descriptor-returning subscriptions, not the number of queries. The docs now say that where the decision is made, and the census is how you find out which shape each of yours took.
116
+
117
+ ### Changed
118
+
119
+ - `dataTransfer.stagingStaleMinutes` in `app.config.ts` — how long a staged import's silence has to last before a boot treats its scratch tables as abandoned. Previously `VOLTRO_STAGING_STALE_MINUTES` only; the env var still overrides the declaration, on the rule every other tunable here follows.
120
+
121
+ The reason this was not already a field was recorded as "the boot check runs off the store alone, before the app config is threaded to it". That described the function's signature, not the boot: both paths already held the config three lines above the call. Resolution lives inside `stagingLeftoversAtBoot` rather than at either call site, so the two cannot disagree about what a declared value means, and a source-reading assertion fails if either path stops handing the config over.
122
+
123
+ ### Fixed
124
+
125
+ - **@voltro/protocol, @voltro/cli, @voltro/client, @voltro/plugin-notifications, @voltro/plugin-comments, @voltro/plugin-presence, @voltro/plugin-search, @voltro/plugin-flags** — **Installing a plugin under an `alias` now moves its client too.** `alias` exists for one problem — your app already publishes `notifications.*` and cannot install a plugin that wants the same namespace — and it has to move four surfaces or it is worse than not existing. It moved three.
126
+
127
+ The two that did not:
128
+
129
+ - **The generated client sent the tag the PLUGIN authored.** Every lifter is `Rpc.make(descriptor.name, …)`, so the wire tag comes from the descriptor, not from the tag the codegen computed. Under `alias: 'inbox'` the exported identifier became `inboxInboxRpc`, the `appDescriptors` key became `inbox.inbox`, the type key became `inbox.inbox` — and the browser still asked a server that had stopped serving it for `notifications.inbox`. The codegen now lifts every plugin descriptor through `withRpcTag(…)`, unconditionally, so the aliased and un-aliased cases are one code path rather than a branch nothing exercises. - **The plugin's own hooks spelled their namespace as a literal.** `useInbox()`, `useUpload()`, `useComments()`, `usePresence()`, `useFlag()`, `useSearch()` all carried strings like `'notifications.inbox'`, which no alias could reach. `voltro dev` now writes a `registerPluginAliases({ … })` declaration into `rpcGroup.generated.ts` — the module the web client already loads value-level — and every plugin hook resolves its tag through `pluginTag(baseName, route)` from `@voltro/protocol` at call time, not at module load. Aliasing a plugin needs no change at any call site.
130
+
131
+ Two installs of one plugin (`name: 'ops'`) with no un-suffixed primary make `pluginTag` **refuse** rather than pick: a hook has no way to name an install, and guessing would address the wrong one silently. The error names both candidates and points at the full-tag call that says which you mean.
132
+
133
+ `pluginAlias` / `pluginSlug` moved from the CLI into `@voltro/protocol` (and are re-exported from their old path) because the browser has to derive the same namespace the server registered, and a second copy on the client would be a second definition of the rule with nothing comparing them.
134
+
135
+ Also corrected, in the same seam: the list of plugins that deliberately do NOT accept `tables: false` read as exhaustive and omitted `_voltro_storage_grants`, which decides who may read an object. It is named now — along with the reason the option would not have reached it anyway (storage's tables are framework tables, not `extendSchema` contributions).
136
+ - `voltro build` could not produce an api serve bundle on 0.53.0 or 0.54.0.
137
+
138
+ `@voltro/content`'s `get.ts` reaches its render pipeline through a dynamic `import('./serverLoad')`. That is deliberate — an unresolvable-at-build-time specifier is what keeps marked and the shiki grammars out of a consumer's client chunk graph. But `serverLoad` was not in the package's entry map, so nothing emitted `dist/serverLoad.js`, and `dist/index.js` shipped an import of a file beside it that was not there.
139
+
140
+ It resolved in this repo every time, because the workspace `exports` point at `src/` and `serverLoad.ts` sits next to `get.ts`. A consumer resolves `publishConfig.exports` to `dist/index.js`, and the same line cannot resolve. esbuild does not honour `@vite-ignore` — that is a vite directive — so the serve bundle refused to ship. The refusal was right; nothing had ever triggered it.
141
+
142
+ Two things worth knowing, both measured rather than reasoned:
143
+
144
+ - **The build was stopped by a dependency that contributes nothing to it.** An api serve bundle reaches `@voltro/content` through `serveCommand → dev → webDev → contentWiring`, and esbuild resolves before it tree-shakes. After shaking, the content pipeline is **0 bytes** of a 14.42 MB bundle. So an api app with no markdown anywhere was blocked by a markdown loader whose code it would never have carried. - **Shipping the file does not bloat anything.** Same measurement with the fixed package resolved as a consumer resolves it: 14.42 MB, content still 0 bytes. The dynamic import stays shaken away.
145
+
146
+ `scripts/check-dist-internal-specifiers.mjs` now bundles every emitted file of every publishable package — from `.publish/`, the tree users receive — with bare specifiers external, and fails if any relative specifier does not resolve. GATE-2 (`publint`) answers "does a declared subpath resolve"; this is one level below it, where `./serverLoad` lives.
147
+ - **@voltro/client** — Three places still taught the pre-fix contract for a cold-start failure.
148
+
149
+ `SubscriptionFailed` gives it its own state — `loading: false`, `failed: true`, `error` non-optional — precisely so a component branching on `loading` alone cannot render a skeleton forever. But `SubscriptionMeta.error`'s doc comment and two docs pages still said the opposite ("leaves `loading` TRUE … check `error` to break out of it"), which is the sentence a deployment quoted back at us as evidence for the defect that had already been fixed.
150
+
151
+ A comment that predicts a trap the code no longer has is worse than no comment: it teaches the defensive shape as if it were still required, and it invites the reading that `loading` is unreliable. All four now describe the state that exists, with the old behaviour kept only as the history that explains why the field is there.
152
+ - **@voltro/client, @voltro/web, @voltro/cli, @voltro/ui** — Four defects a real migration found, all of which passed `tsc` and a full test suite and only showed up against a running system.
153
+
154
+ **A server render derived a different form than the browser.** Nothing mounts a runtimes provider during SSR, so `useFormBinding` resolved its input schema from an EMPTY descriptor map: no fields, `required: false`. The browser then rendered the real ones and React discarded the subtree — "Hydration failed" on every server-rendered page carrying a bound form, with the diff pointing at a `Mui-required` class. `@voltro/client` keeps a process-global SSR descriptor registry now, and both boot paths fill it before rendering (`voltro dev` and `voltro start`, pinned as a parity test — a CLI module cannot import `@voltro/client`, so the call goes through `@voltro/web/ssr`, which both already load).
155
+
156
+ **A rejected `submit()` had nowhere to go.** A form calls it from an `onSubmit` handler that cannot await it, so the rejection surfaced as `Uncaught (in promise)` while the form sat there looking saved. `submit()` resolves `undefined` now and the failure is state: `state.submitError`, plus an optional `onError`. That covers a composed `onSubmit` whose follow-up write fails on its OWN mutation handle — a failure the binding never saw, and the common shape (create the row, then its first child).
157
+
158
+ **Undeclared fields went on the wire.** The binding validated the mapped input and then sent the object unchanged; the client decode ignores excess properties while the server has refused them since 0.37, so a form carrying anything beyond the mutation's input passed validation and was rejected on the wire with a message pointing at no field. The payload is restricted to the declared keys now — the rule the no-JS path already followed, so the two submits agree — with a dev warning naming what was dropped.
159
+
160
+ **`setValue` with an unchanged value produced a new `values`.** Every React state source is expected to no-op on that; this one did not, so an effect depending on `values` that re-set a field to the value it already held never settled ("Maximum update depth exceeded" on a form mirroring toggles out of a multi-select).
161
+ - A plugin's dashboard panel no longer disappears when the app aliases the plugin.
162
+
163
+ `alias` moves `plugin.name`, and the inspect mount is derived from it, so `alias: 'inbox'` on notifications moved its panel to `/_voltro/inspect/plugins/inbox/...` while both dashboards ask for `/plugins/notifications/...` with the path compiled in. They live in other repositories and cannot follow. The field's own doc comment stated this as a cost you accept — in nine plugins, the protocol helper and the docs.
164
+
165
+ `makePluginInspectRegistry` now mounts each plugin's `inspectEndpoints` under its CANONICAL slug as well, in a second pass so an effective mount always wins the path. Added only where unambiguous: a base name carried by more than one installed plugin gets no shared mount, because showing either install under it would hand a dashboard the other one's rows under a name that looks right — the hazard `pluginTag` refuses rather than guesses. `/_voltro/inspect/plugins` now reports `baseName` and `inspectSlug` per plugin, which is how a caller reaches a specific install.
166
+ - **@voltro/cli, @voltro/data-transfer** — **`voltro data restore --drill` failed every healthy backup of a real app.** It compared the restored schema's fingerprint against the backup stamp's `schemaFingerprint`, which records the SOURCE database's whole live schema — and the artifact never carries that schema. `pg_dump` / `mariadb-dump` exclude `_voltro_replace_in_progress` and `_voltro_data_transfers` on purpose, and the backup command opens a run row in the second one before it dumps, so on any database the framework has run against, the artifact is two tables short of the value it was being measured against. The drill answered:
167
+
168
+ FAIL — restored N table(s), but the schema fingerprint (…) does NOT match the backup's stamp (…). The restore did not reproduce the schema that was backed up — the artifact is inconsistent.
169
+
170
+ about an artifact that was exactly right. A drill exists to be wired to a CI cron, and one that is red on every healthy input gets switched off — taking its two real failures with it.
171
+
172
+ The stamp now carries a second value, `dumpFingerprint`: the same snapshot minus `dumpExcludedTables(dialect)` — what a faithful restore must reproduce. The drill compares against that. A stamp written before this field degrades to a PARTIAL pass that says so, rather than falling back to the value that produces the false failure. `dumpExcludedTables` is per-dialect because only two of the five backup paths carry an exclusion flag at all: the sqlite/turso copy and the mssql export carry everything, and subtracting a set from those would invent the same bug in the other direction.
173
+
174
+ **The drill also checks the one boot-fatal condition a schema comparison cannot see.** `voltro serve`'s boot gate reads the newest `_voltro_migration_plans` row and refuses with `prod-mismatch` when there is none — so a ledger table that restores with exactly the right columns and zero rows is a database no source tree can boot, and its fingerprint is identical to a healthy one's. That is now a FAIL with the reason named. A restored database with no ledger table at all is not a voltro-managed schema and is reported as such, not failed.
175
+
176
+ There is deliberately no app boot in the drill. The boot gate is a comparison, not a startup sequence, so the part that generalises is reachable with a SELECT; booting a fixture app instead would prove something about our fixture rather than about your backup.
177
+
178
+ ---
179
+
42
180
  ## [0.54.0] — 2026-08-27
43
181
 
44
182
  ### ⚠ BREAKING
@@ -114,9 +252,11 @@ _Changes staged for the next release accumulate here (rolled up from
114
252
  Each message is processed inside a `queue.consume` span whose parent is the incoming `traceparent`, parsed by the same `externalSpanFromTraceparent` the inbound-webhook path uses — so a malformed or all-zero header means "no parent" (a fresh root span), never a failed message. The span covers the whole message including retries and the dead-letter publish, and carries the OTel `messaging.*` attributes plus `voltro.queue.outcome`. The topic is an attribute, not part of the span name.
115
253
 
116
254
  Known limit, measured rather than assumed: a span opened from DETACHED work does not reach an OTLP exporter, because the framework's tracer is a `Layer` provided only inside the rpc server's scope. This is framework-wide (`cdcOut.deliver` and `plugin.<name>.schedule-fire` are the same shape) and is stated in the queue docs rather than implied away. The metrics are unaffected — the metric registry is a process global.
117
- - **@voltro/runtime, @voltro/voltro** — `setRowFilter({ …, tables: ['bookmarks', 'recentSearches'] })` — declare which tables your filter may narrow, and delta-resume survives everywhere else.
255
+ - **@voltro/runtime, @voltro/voltro** — `setRowFilter({ …, tables: ['documents', 'comments'] })` — declare which tables your filter may narrow, and delta-resume survives everywhere else.
256
+
257
+ Delta-resume is excluded for a subscription whose row set is re-resolved per delivery: replaying deltas could serve rows the subject has since lost. But the question the runtime could ask was only "is a filter registered in this process?", so ONE registration disabled cheap reconnects for every subscription in the app, including every one whose source the filter could never narrow.
118
258
 
119
- Delta-resume is excluded for a subscription whose row set is re-resolved per delivery: replaying deltas could serve rows the subject has since lost. But the question the runtime could ask was only "is a filter registered in this process?", so ONE registration disabled cheap reconnects for every subscription in the app a deployment measured a filter narrowing 4 tables costing the feature on all 173 of their query descriptors, 55 of whose source tables the filter never touches.
259
+ It does not reach every query. A subscription only has a delta chain when its executor returns a DESCRIPTOR; one that returns a mapped value or a page envelope re-runs an opaque handler and emits snapshots, with or without a filter. `tables:` costs nothing and applies the moment such a query returns a builder but the count it gives back is the count of descriptor-returning subscriptions, not of queries. The resume census on `/_voltro/inspect/subscriptions` reports which shape each of your queries took.
120
260
 
121
261
  The exclusion is per table now. A declared filter keeps resume for every subscription whose source is not in its set; an undeclared filter keeps today's conservative behaviour (the runtime cannot know which tables the predicate may reach, and "unknown" must read as "yes").
122
262
 
package/dist/index.d.ts CHANGED
@@ -420,10 +420,12 @@ export declare interface FlagsPluginOptions {
420
420
  * `name` below: `alias` REPLACES the namespace, `name` distinguishes two
421
421
  * installations within it.
422
422
  *
423
- * The cost, stated because nothing else states it: the local and cloud
424
- * dashboards fetch this plugin's panel at the DEFAULT slug, so an aliased
425
- * install keeps working while its dashboard panel 404s. Alias to escape a
426
- * collision, not for taste.
423
+ * The dashboard panel follows: the plugin's inspect endpoints keep a mount
424
+ * under its CANONICAL name alongside the aliased one, so aliasing does not
425
+ * take the panel away. The one case it cannot cover is two installs of this
426
+ * plugin — one canonical name, two panels — which get no shared mount at
427
+ * all, on purpose. Read `inspectSlug` from `/_voltro/inspect/plugins` to
428
+ * reach a specific install.
427
429
  */
428
430
  readonly alias?: string;
429
431
  /**
package/dist/index.js CHANGED
@@ -1,25 +1,25 @@
1
- import { evaluateDescriptor as e, evaluateVariantsDescriptor as t, flagsRpcClientImports as n } from "./rpc.js";
2
- import { i as r, n as i, r as a, t as o } from "./define-D9Eet7wA.js";
3
- import { FlagDisabled as s } from "./errors.js";
4
- import { Effect as c } from "effect";
5
- import { definePlugin as l, pluginInstanceName as u } from "@voltro/protocol";
6
- import { boolean as d, derivedRowId as f, id as p, integer as m, json as h, queryFor as g, registerRetention as _, retentionTtlMsFromEnv as v, table as y, text as b, timestamp as x } from "@voltro/database";
1
+ import { BASE_NAME as e, evaluateDescriptor as t, evaluateVariantsDescriptor as n, flagsRpcClientImports as r } from "./rpc.js";
2
+ import { i, n as a, r as o, t as s } from "./define-D9Eet7wA.js";
3
+ import { FlagDisabled as c } from "./errors.js";
4
+ import { Effect as l } from "effect";
5
+ import { definePlugin as u, pluginInstanceName as d } from "@voltro/protocol";
6
+ import { boolean as f, derivedRowId as p, id as m, integer as h, json as g, queryFor as _, registerRetention as v, retentionTtlMsFromEnv as y, table as b, text as x, timestamp as S } from "@voltro/database";
7
7
  //#region src/evaluate.ts
8
- var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
8
+ var C = (e) => typeof e == "boolean" ? { enabled: e } : e, w = (e, t) => {
9
9
  let n = 2166136261, r = `${e}:${t}`;
10
10
  for (let e = 0; e < r.length; e++) n ^= r.charCodeAt(e), n = Math.imul(n, 16777619);
11
11
  return n >>> 0;
12
- }, ee = (e, t) => C(e, t) % 100, w = (e, t) => C(`${e}:variant`, t) % 1e4, T = (e, t) => {
12
+ }, T = (e, t) => w(e, t) % 100, ee = (e, t) => w(`${e}:variant`, t) % 1e4, E = (e, t) => {
13
13
  if (e.subjectIds && !(t.id != null && e.subjectIds.includes(t.id)) || e.tenantIds && !(t.tenantId != null && e.tenantIds.includes(t.tenantId)) || e.subjectTypes && !(t.type != null && e.subjectTypes.includes(t.type))) return !1;
14
14
  if (e.metadata) {
15
15
  let n = t.metadata ?? {};
16
16
  for (let [t, r] of Object.entries(e.metadata)) if (String(n[t]) !== r) return !1;
17
17
  }
18
18
  return !0;
19
- }, E = (e) => typeof e == "number" ? e : Date.parse(e), D = (e, t) => (e.rolloutBy === "tenant" ? t.tenantId : t.id) ?? "anon", O = (e, t) => {
20
- if (e.activateAt != null && t < E(e.activateAt) || e.deactivateAt != null && t >= E(e.deactivateAt)) return { active: !1 };
19
+ }, D = (e) => typeof e == "number" ? e : Date.parse(e), O = (e, t) => (e.rolloutBy === "tenant" ? t.tenantId : t.id) ?? "anon", k = (e, t) => {
20
+ if (e.activateAt != null && t < D(e.activateAt) || e.deactivateAt != null && t >= D(e.deactivateAt)) return { active: !1 };
21
21
  if (e.ramp) {
22
- let { from: n, to: r, startAt: i, endAt: a } = e.ramp, o = E(i), s = E(a);
22
+ let { from: n, to: r, startAt: i, endAt: a } = e.ramp, o = D(i), s = D(a);
23
23
  if (t <= o) return {
24
24
  active: !0,
25
25
  rollout: n
@@ -35,71 +35,71 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
35
35
  };
36
36
  }
37
37
  return { active: !0 };
38
- }, k = (e, t, n, r = Date.now()) => {
39
- let i = S(t);
38
+ }, A = (e, t, n, r = Date.now()) => {
39
+ let i = C(t);
40
40
  if (i.enabled === !1) return !1;
41
41
  let a;
42
42
  if (i.schedule) {
43
- let e = O(i.schedule, r);
43
+ let e = k(i.schedule, r);
44
44
  if (!e.active) return !1;
45
45
  a = e.rollout;
46
46
  }
47
- if (i.targeting && i.targeting.length > 0 && !i.targeting.some((e) => T(e, n))) return !1;
47
+ if (i.targeting && i.targeting.length > 0 && !i.targeting.some((e) => E(e, n))) return !1;
48
48
  let o = a ?? i.rollout ?? 100;
49
49
  if (o < 100 && o > 0) {
50
- if (!(ee(e, D(i, n)) < o)) return !1;
50
+ if (!(T(e, O(i, n)) < o)) return !1;
51
51
  } else if (o <= 0) return !1;
52
- return i.variants && i.variants.length > 0 && i.offVariant != null ? A(e, i, n).name !== i.offVariant : !0;
53
- }, te = (e) => {
52
+ return i.variants && i.variants.length > 0 && i.offVariant != null ? M(e, i, n).name !== i.offVariant : !0;
53
+ }, j = (e) => {
54
54
  let t = e.reduce((e, t) => e + (t.weight ?? 1), 0) || e.length, n = 0;
55
55
  return e.map((r, i) => (n += r.weight ?? 1, i === e.length - 1 ? 1e4 : Math.round(n / t * 1e4)));
56
- }, A = (e, t, n) => {
56
+ }, M = (e, t, n) => {
57
57
  let r = t.variants ?? [];
58
58
  if (r.length === 0) throw Error(`flag "${e}" has no variants`);
59
- let i = w(e, D(t, n)), a = te(r);
59
+ let i = ee(e, O(t, n)), a = j(r);
60
60
  for (let e = 0; e < r.length; e++) if (i < a[e]) return r[e];
61
61
  return r[r.length - 1];
62
- }, j = (e, t, n, r = Date.now()) => {
63
- let i = S(t), a = k(e, t, n, r);
62
+ }, N = (e, t, n, r = Date.now()) => {
63
+ let i = C(t), a = A(e, t, n, r);
64
64
  if (!i.variants || i.variants.length === 0) return {
65
65
  name: a ? "on" : "off",
66
66
  value: a,
67
67
  enabled: a
68
68
  };
69
- let o = A(e, i, n);
69
+ let o = M(e, i, n);
70
70
  return {
71
71
  name: o.name,
72
72
  value: o.value,
73
73
  enabled: a
74
74
  };
75
- }, ne = (e, t, n = Date.now()) => {
75
+ }, te = (e, t, n = Date.now()) => {
76
76
  let r = {};
77
- for (let [i, a] of Object.entries(e)) r[i] = k(i, a, t, n);
77
+ for (let [i, a] of Object.entries(e)) r[i] = A(i, a, t, n);
78
78
  return r;
79
- }, re = (e, t, n = Date.now()) => {
79
+ }, ne = (e, t, n = Date.now()) => {
80
80
  let r = {};
81
- for (let [i, a] of Object.entries(e)) r[i] = j(i, a, t, n);
81
+ for (let [i, a] of Object.entries(e)) r[i] = N(i, a, t, n);
82
82
  return r;
83
- }, M = "_voltro_feature_flag_audit", N = "_voltro_feature_flags", P = y(N, {
84
- id: p({ prefix: "flag" }),
85
- key: b().unique(),
86
- enabled: d().default(!0),
87
- rollout: m().nullable(),
88
- rolloutBy: b().nullable(),
89
- targeting: h().nullable(),
90
- variants: h().nullable(),
91
- offVariant: b().nullable(),
92
- schedule: h().nullable(),
93
- description: b().nullable(),
94
- updatedAt: x().default("now").onUpdate("now")
95
- }), F = y(M, {
96
- id: p({ prefix: "flagaud" }),
97
- flag: b(),
98
- enabled: d(),
99
- previousEnabled: d().nullable(),
100
- actorId: b().nullable(),
101
- at: x().default("now")
102
- }).index("byFlagAuditFlag", ["flag"]).index("byFlagAuditAt", ["at"]), I = (e, t) => {
83
+ }, P = "_voltro_feature_flag_audit", F = "_voltro_feature_flags", I = b(F, {
84
+ id: m({ prefix: "flag" }),
85
+ key: x().unique(),
86
+ enabled: f().default(!0),
87
+ rollout: h().nullable(),
88
+ rolloutBy: x().nullable(),
89
+ targeting: g().nullable(),
90
+ variants: g().nullable(),
91
+ offVariant: x().nullable(),
92
+ schedule: g().nullable(),
93
+ description: x().nullable(),
94
+ updatedAt: S().default("now").onUpdate("now")
95
+ }), L = b(P, {
96
+ id: m({ prefix: "flagaud" }),
97
+ flag: x(),
98
+ enabled: f(),
99
+ previousEnabled: f().nullable(),
100
+ actorId: x().nullable(),
101
+ at: S().default("now")
102
+ }).index("byFlagAuditFlag", ["flag"]).index("byFlagAuditAt", ["at"]), re = (e, t) => {
103
103
  let n = { ...e };
104
104
  for (let e of t) {
105
105
  let t = {
@@ -115,7 +115,7 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
115
115
  n[e.key] = t;
116
116
  }
117
117
  return n;
118
- }, L = new class {
118
+ }, R = new class {
119
119
  flags = {};
120
120
  set(e) {
121
121
  this.flags = e;
@@ -126,19 +126,19 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
126
126
  get(e) {
127
127
  return this.flags[e];
128
128
  }
129
- }(), R = (e, t) => z(e, t) !== null, z = (e, t) => {
130
- let n = L.get(e);
129
+ }(), z = (e, t) => B(e, t) !== null, B = (e, t) => {
130
+ let n = R.get(e);
131
131
  if (n === void 0) return null;
132
- let r = S(n), i = r.enabled !== !1;
133
- return L.set({
134
- ...L.all(),
132
+ let r = C(n), i = r.enabled !== !1;
133
+ return R.set({
134
+ ...R.all(),
135
135
  [e]: {
136
136
  ...r,
137
137
  enabled: t
138
138
  }
139
139
  }), i;
140
- }, B = (e) => Object.entries(e).map(([e, t]) => {
141
- let n = S(t);
140
+ }, V = (e) => Object.entries(e).map(([e, t]) => {
141
+ let n = C(t);
142
142
  return {
143
143
  key: e,
144
144
  enabled: n.enabled !== !1,
@@ -148,7 +148,7 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
148
148
  description: n.description
149
149
  };
150
150
  }), ie = (e, t) => {
151
- let n = S(t);
151
+ let n = C(t);
152
152
  return {
153
153
  key: e,
154
154
  enabled: n.enabled !== !1,
@@ -161,7 +161,7 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
161
161
  description: n.description ?? null
162
162
  };
163
163
  }, ae = (e) => ({
164
- loadOverrides: async () => (await e.query(g(P).descriptor)).map((e) => ({
164
+ loadOverrides: async () => (await e.query(_(I).descriptor)).map((e) => ({
165
165
  key: String(e.key),
166
166
  enabled: e.enabled === !0 || e.enabled === 1,
167
167
  rollout: e.rollout == null ? null : Number(e.rollout),
@@ -174,7 +174,7 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
174
174
  })),
175
175
  upsertOverride: async (t) => {
176
176
  let n = {
177
- id: f("flag", t.key),
177
+ id: p("flag", t.key),
178
178
  key: t.key,
179
179
  enabled: t.enabled,
180
180
  rollout: t.rollout,
@@ -186,7 +186,7 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
186
186
  description: t.description,
187
187
  updatedAt: /* @__PURE__ */ new Date()
188
188
  };
189
- await e.upsert(N, n, { conflictColumns: ["key"] });
189
+ await e.upsert(F, n, { conflictColumns: ["key"] });
190
190
  }
191
191
  }), oe = (e) => ({
192
192
  flag: String(e.flag),
@@ -196,7 +196,7 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
196
196
  at: e.at instanceof Date ? e.at : new Date(String(e.at))
197
197
  }), se = (e) => ({
198
198
  record: async (t) => {
199
- await e.insert(M, {
199
+ await e.insert(P, {
200
200
  id: `flagaud_${t.at.getTime()}_${Math.random().toString(36).slice(2, 10)}`,
201
201
  flag: t.flag,
202
202
  enabled: t.enabled,
@@ -206,12 +206,12 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
206
206
  });
207
207
  },
208
208
  list: async (t) => {
209
- let n = g(F).orderBy("at", "desc");
209
+ let n = _(L).orderBy("at", "desc");
210
210
  t?.limit != null && (n = n.limit(t.limit));
211
211
  let r = (await e.query(n.descriptor)).map(oe);
212
212
  return t?.flag == null ? r : r.filter((e) => e.flag === t.flag);
213
213
  }
214
- }), V = (e, t) => {
214
+ }), H = (e, t) => {
215
215
  let n = new Map(t.map((e) => [e.name, e])), r = [];
216
216
  for (let t of e) {
217
217
  let e = t.experiment;
@@ -250,24 +250,24 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
250
250
  }
251
251
  }
252
252
  return r;
253
- }, H = (e, t) => {
254
- let n = V(e, t);
253
+ }, U = (e, t) => {
254
+ let n = H(e, t);
255
255
  if (n.length === 0) return;
256
256
  let r = n.map((e) => ` · flag "${e.flag}" → experiment "${e.experiment}": ${e.problem}`);
257
257
  throw Error(`@voltro/plugin-flags: ${n.length} flag→experiment link problem(s):\n${r.join("\n")}`);
258
- }, U = 864e5, W = (e) => typeof e == "number" ? e : Date.parse(e), G = (e, t) => {
259
- let n = S(e);
258
+ }, W = 864e5, G = (e) => typeof e == "number" ? e : Date.parse(e), K = (e, t) => {
259
+ let n = C(e);
260
260
  if (n.enabled === !1) return {
261
261
  shape: "constantOff",
262
262
  reason: "enabled: false — resolves false for every caller"
263
263
  };
264
264
  if (n.schedule) {
265
265
  let { activateAt: e, deactivateAt: r } = n.schedule;
266
- if (r != null && t >= W(r)) return {
266
+ if (r != null && t >= G(r)) return {
267
267
  shape: "expired",
268
268
  reason: `schedule.deactivateAt (${String(r)}) has passed — the flag can never be on again`
269
269
  };
270
- if (e != null && t < W(e)) return {
270
+ if (e != null && t < G(e)) return {
271
271
  shape: "notYetActive",
272
272
  reason: `schedule.activateAt (${String(e)}) is in the future — pending, not dead`
273
273
  };
@@ -277,7 +277,7 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
277
277
  shape: "constantOff",
278
278
  reason: `rollout: ${r} — nobody is in the rollout`
279
279
  };
280
- let i = (n.targeting?.length ?? 0) > 0, a = (n.variants?.length ?? 0) > 1, o = n.schedule !== void 0 && (n.schedule.ramp !== void 0 || n.schedule.deactivateAt != null && t < W(n.schedule.deactivateAt));
280
+ let i = (n.targeting?.length ?? 0) > 0, a = (n.variants?.length ?? 0) > 1, o = n.schedule !== void 0 && (n.schedule.ramp !== void 0 || n.schedule.deactivateAt != null && t < G(n.schedule.deactivateAt));
281
281
  return r >= 100 && !i && !a && !o ? {
282
282
  shape: "constantOn",
283
283
  reason: "rollout 100 with no targeting, no variants and no live schedule — resolves true for every caller"
@@ -290,14 +290,14 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
290
290
  o ? "a live schedule" : void 0
291
291
  ].filter((e) => e !== void 0).join(", ")})`
292
292
  };
293
- }, K = [
293
+ }, ce = [
294
294
  "Reachability is not decided. \"Not evaluated since <date>\" is a measurement; \"this code path is dead\" is not decidable in general. A seasonal flag, a flag behind a route nobody visited this month, and a flag whose last call site was deleted are indistinguishable from here.",
295
295
  "Only SERVER-side reads are counted as use: isFlagEnabled / requireFlag / flagValue and a gatedBy interception. A flag consulted only in the browser via useFlag() / useFlagValue() reads from the bulk set the server already sent, so it never reaches this report as a named read.",
296
296
  "A \"bulk\" observation means the flag was INCLUDED in a flags.evaluate / flags.variants response. It does not mean any client read that key — one useFlags() poll evaluates the whole registry.",
297
297
  "The window is bounded by retention (see coverage.retentionDays). Nothing older is evidence: a flag last read BEFORE the window has no observation at all and reads \"neverObserved\", which is exactly what a flag declared this morning reads.",
298
298
  "\"neverObserved\" is not a removal candidate. A flag declared yesterday and a flag abandoned last year produce the identical row; only a flag that WAS observed and then stopped (\"stale\") is proven unused."
299
- ], ce = (e) => {
300
- let t = e.now ?? Date.now(), n = e.staleAfterDays, r = t - n * U, i = /* @__PURE__ */ new Map(), a = /* @__PURE__ */ new Map(), o;
299
+ ], le = (e) => {
300
+ let t = e.now ?? Date.now(), n = e.staleAfterDays, r = t - n * W, i = /* @__PURE__ */ new Map(), a = /* @__PURE__ */ new Map(), o;
301
301
  for (let t of e.usage) {
302
302
  let e = t.lastAt.getTime();
303
303
  if (!Number.isFinite(e)) continue;
@@ -305,7 +305,7 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
305
305
  let n = t.source === "bulk" ? a : i, r = n.get(t.flag);
306
306
  (r === void 0 || e > r) && n.set(t.flag, e);
307
307
  }
308
- let s = o === void 0 ? 0 : Math.floor((t - o) / U);
308
+ let s = o === void 0 ? 0 : Math.floor((t - o) / W);
309
309
  return {
310
310
  coverage: {
311
311
  tracking: e.tracking,
@@ -315,7 +315,7 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
315
315
  staleAfterDays: n
316
316
  },
317
317
  findings: Object.entries(e.flags).map(([o, c]) => {
318
- let { shape: l, reason: u } = G(c, t), d = i.get(o), f = a.get(o), p = [u], m;
318
+ let { shape: l, reason: u } = K(c, t), d = i.get(o), f = a.get(o), p = [u], m;
319
319
  e.tracking ? d !== void 0 && d >= r ? (m = "evaluated", p.push(`last server-side read ${new Date(d).toISOString()}`)) : d === void 0 ? (m = "neverObserved", p.push(s > 0 ? `no server-side read in the ${s}-day observation window` : "no server-side read observed yet (the observation window is empty)"), f !== void 0 && p.push("the flag WAS delivered to a client in a bulk flags.evaluate response — which does not prove it was read")) : (m = "stale", p.push(`last server-side read ${new Date(d).toISOString()}, more than ${n} day(s) ago, over an observation window of ${s} day(s)`)) : (m = "untracked", p.push("evaluation tracking is off — no usage evidence exists for this deployment"));
320
320
  let h = l === "constantOn" || l === "constantOff" || l === "expired";
321
321
  return h && p.push("the definition alone makes this flag a constant — it selects nothing"), {
@@ -330,23 +330,23 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
330
330
  why: p
331
331
  };
332
332
  }),
333
- limits: K
333
+ limits: ce
334
334
  };
335
- }, q = "_voltro_feature_flag_usage", J = y(q, {
336
- id: p({ prefix: "flagusg" }),
337
- flag: b(),
338
- day: b(),
339
- source: b(),
340
- lastAt: x()
335
+ }, q = "_voltro_feature_flag_usage", J = b(q, {
336
+ id: m({ prefix: "flagusg" }),
337
+ flag: x(),
338
+ day: x(),
339
+ source: x(),
340
+ lastAt: S()
341
341
  }).unique([
342
342
  "flag",
343
343
  "day",
344
344
  "source"
345
- ]).index("byFlagUsageLastAt", ["lastAt"]).index("byFlagUsageFlag", ["flag"]), le = (e) => e.toISOString().slice(0, 10), ue = (e) => {
345
+ ]).index("byFlagUsageLastAt", ["lastAt"]).index("byFlagUsageFlag", ["flag"]), ue = (e) => e.toISOString().slice(0, 10), de = (e) => {
346
346
  let t = 2166136261;
347
347
  for (let n = 0; n < e.length; n++) t ^= e.charCodeAt(n), t = Math.imul(t, 16777619);
348
348
  return (t >>> 0).toString(16).padStart(8, "0");
349
- }, de = (e, t, n) => `flagusg_${t.replace(/-/g, "")}_${n === "targeted" ? "t" : "b"}_${ue(e)}`, Y = new class {
349
+ }, fe = (e, t, n) => `flagusg_${t.replace(/-/g, "")}_${n === "targeted" ? "t" : "b"}_${de(e)}`, Y = new class {
350
350
  on = !1;
351
351
  pending = /* @__PURE__ */ new Map();
352
352
  liveCounts = /* @__PURE__ */ new Map();
@@ -384,12 +384,12 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
384
384
  reset() {
385
385
  this.on = !1, this.pending.clear(), this.liveCounts.clear();
386
386
  }
387
- }(), X = (e, t, n) => Y.record(e, t, n), fe = (e) => e === "bulk" ? "bulk" : "targeted", pe = (e) => ({
387
+ }(), X = (e, t, n) => Y.record(e, t, n), pe = (e) => e === "bulk" ? "bulk" : "targeted", me = (e) => ({
388
388
  upsertMany: async (t) => {
389
389
  for (let n of t) {
390
- let t = le(n.lastAt);
390
+ let t = ue(n.lastAt);
391
391
  await e.upsert(q, {
392
- id: de(n.flag, t, n.source),
392
+ id: fe(n.flag, t, n.source),
393
393
  flag: n.flag,
394
394
  day: t,
395
395
  source: n.source,
@@ -401,13 +401,13 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
401
401
  ] });
402
402
  }
403
403
  },
404
- load: async () => (await e.query(g(J).descriptor)).map((e) => ({
404
+ load: async () => (await e.query(_(J).descriptor)).map((e) => ({
405
405
  flag: String(e.flag),
406
406
  day: String(e.day),
407
- source: fe(e.source),
407
+ source: pe(e.source),
408
408
  lastAt: e.lastAt instanceof Date ? e.lastAt : new Date(String(e.lastAt))
409
409
  }))
410
- }), me = 3e5, he = (e) => {
410
+ }), he = 3e5, ge = (e) => {
411
411
  let t = e.intervalMs ?? 3e5, n = !1, r = async () => {
412
412
  let t = Y.drain();
413
413
  if (t.length !== 0) try {
@@ -431,9 +431,9 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
431
431
  n || (n = !0, clearInterval(i), await r());
432
432
  }
433
433
  };
434
- }, ge = (e = process.env, t = 90) => {
435
- let n = v(e.VOLTRO_FLAG_USAGE_TTL_HOURS, 24 * t);
436
- return _({
434
+ }, _e = (e = process.env, t = 90) => {
435
+ let n = y(e.VOLTRO_FLAG_USAGE_TTL_HOURS, 24 * t);
436
+ return v({
437
437
  table: q,
438
438
  timeColumn: "lastAt",
439
439
  source: "plugin",
@@ -441,88 +441,88 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
441
441
  envVar: "VOLTRO_FLAG_USAGE_TTL_HOURS"
442
442
  }), n;
443
443
  }, Z = (e) => "request" in e && e.request?.subject || "subject" in e && e.subject || {}, Q = (e, t) => {
444
- let n = L.get(t);
445
- return X(t, "targeted"), n !== void 0 && k(t, n, Z(e));
446
- }, _e = (e, t) => Q(e, t) ? c.void : c.fail(new s({ flag: t })), ve = (e, t) => {
447
- let n = L.get(t.key);
448
- return X(t.key, "targeted"), n === void 0 ? t.defaultValue : r(t, j(t.key, n, Z(e)));
449
- }, ye = (e, t) => {
450
- let n = L.get(t.key);
451
- return X(t.key, "targeted"), n === void 0 ? null : j(t.key, n, Z(e)).name;
452
- }, be = (e, t) => e === t || e.startsWith("/") && e.endsWith("/") && new RegExp(e.slice(1, -1)).test(t), $ = "@voltro/plugin-flags", xe = (r = {}) => {
453
- let a = r.gatedBy ?? {}, o = u({
444
+ let n = R.get(t);
445
+ return X(t, "targeted"), n !== void 0 && A(t, n, Z(e));
446
+ }, ve = (e, t) => Q(e, t) ? l.void : l.fail(new c({ flag: t })), ye = (e, t) => {
447
+ let n = R.get(t.key);
448
+ return X(t.key, "targeted"), n === void 0 ? t.defaultValue : i(t, N(t.key, n, Z(e)));
449
+ }, be = (e, t) => {
450
+ let n = R.get(t.key);
451
+ return X(t.key, "targeted"), n === void 0 ? null : N(t.key, n, Z(e)).name;
452
+ }, xe = (e, t) => e === t || e.startsWith("/") && e.endsWith("/") && new RegExp(e.slice(1, -1)).test(t), $ = e, Se = (e = {}) => {
453
+ let i = e.gatedBy ?? {}, o = d({
454
454
  base: $,
455
- alias: r.alias,
456
- instance: r.name
457
- }), d = r.typedFlags ?? [], f = /* @__PURE__ */ new Map();
458
- for (let e of d) {
459
- if (f.has(e.key)) throw Error(`${$}: two typedFlags declare the key ${JSON.stringify(e.key)}`);
460
- if (r.flags?.[e.key] !== void 0) throw Error(`${$}: ${JSON.stringify(e.key)} is declared BOTH in \`flags\` and in \`typedFlags\`. One key, one declaration — the typed one would otherwise be silently overwritten by the untyped baseline.`);
461
- f.set(e.key, e);
455
+ alias: e.alias,
456
+ instance: e.name
457
+ }), s = e.typedFlags ?? [], f = /* @__PURE__ */ new Map();
458
+ for (let t of s) {
459
+ if (f.has(t.key)) throw Error(`${$}: two typedFlags declare the key ${JSON.stringify(t.key)}`);
460
+ if (e.flags?.[t.key] !== void 0) throw Error(`${$}: ${JSON.stringify(t.key)} is declared BOTH in \`flags\` and in \`typedFlags\`. One key, one declaration — the typed one would otherwise be silently overwritten by the untyped baseline.`);
461
+ f.set(t.key, t);
462
462
  }
463
- d.some((e) => e.experiment !== void 0) && H(d, r.experiments ?? []);
464
- let p = { ...r.flags ?? {} };
465
- for (let e of d) p[e.key] = e.definition;
466
- let m = r.store === "postgres", h, g, _, v, y = [], b = r.usage ?? {}, x = b.track ?? m;
463
+ s.some((e) => e.experiment !== void 0) && U(s, e.experiments ?? []);
464
+ let p = { ...e.flags ?? {} };
465
+ for (let e of s) p[e.key] = e.definition;
466
+ let m = e.store === "postgres", h, g, _, v, y = [], b = e.usage ?? {}, x = b.track ?? m;
467
467
  if (x && !m) throw Error(`${$}: usage.track requires store: 'postgres' — there is nowhere to record an evaluation on the memory tier, and a dead-flag report that silently observed nothing is exactly the defect it exists to find.`);
468
468
  let S = b.staleAfterDays ?? 30, C = b.retentionDays ?? 90;
469
469
  if (S > C) throw Error(`${$}: usage.staleAfterDays (${S}) exceeds usage.retentionDays (${C}), so the observation window can never be long enough to prove a flag stale and every verdict would be withheld.`);
470
- let ee = (e) => {
471
- let t = I(p, e), n = [];
470
+ let w = (e) => {
471
+ let t = re(p, e), n = [];
472
472
  for (let [e, r] of f) {
473
- let a = t[e];
474
- if (a === void 0) continue;
475
- let o = i(r, typeof a == "boolean" ? { enabled: a } : a);
473
+ let i = t[e];
474
+ if (i === void 0) continue;
475
+ let o = a(r, typeof i == "boolean" ? { enabled: i } : i);
476
476
  o.ok || (n.push(o.refusal), t[e] = r.definition);
477
477
  }
478
- y = n, L.set(t);
479
- }, w = async () => {
480
- h && ee(await h.loadOverrides());
481
- }, T = (e) => {
482
- if (a[e] !== void 0) return a[e];
483
- for (let [t, n] of Object.entries(a)) if (t !== e && be(t, e)) return n;
478
+ y = n, R.set(t);
479
+ }, T = async () => {
480
+ h && w(await h.loadOverrides());
481
+ }, ee = (e) => {
482
+ if (i[e] !== void 0) return i[e];
483
+ for (let [t, n] of Object.entries(i)) if (t !== e && xe(t, e)) return n;
484
484
  }, E = (e, t) => {
485
- let n = T(t.tag);
486
- return n === void 0 || Q({ subject: t.subject }, n) ? e : c.fail(new s({ flag: n }));
487
- }, D = Object.keys(a).length > 0, O = (e) => {
485
+ let n = ee(t.tag);
486
+ return n === void 0 || Q({ subject: t.subject }, n) ? e : l.fail(new c({ flag: n }));
487
+ }, D = Object.keys(i).length > 0, O = (e) => {
488
488
  for (let t of e) X(t, "bulk");
489
489
  }, k = {
490
- ...e,
490
+ ...t,
491
491
  description: "Resolve every feature flag for the calling subject.",
492
- execute: (e, t) => c.sync(() => {
493
- let e = L.all();
494
- return O(Object.keys(e)), ne(e, t.request.subject ?? {});
492
+ execute: (e, t) => l.sync(() => {
493
+ let e = R.all();
494
+ return O(Object.keys(e)), te(e, t.request.subject ?? {});
495
495
  })
496
- }, te = {
497
- ...t,
496
+ }, A = {
497
+ ...n,
498
498
  description: "Resolve every feature flag to its served variant for the calling subject.",
499
- execute: (e, t) => c.sync(() => {
500
- let e = L.all();
501
- return O(Object.keys(e)), re(e, t.request.subject ?? {});
499
+ execute: (e, t) => l.sync(() => {
500
+ let e = R.all();
501
+ return O(Object.keys(e)), ne(e, t.request.subject ?? {});
502
502
  })
503
- }, A = (e) => ({
503
+ }, j = (e) => ({
504
504
  kind: "json",
505
505
  data: e
506
- }), j = (e, t) => ({
506
+ }), M = (e, t) => ({
507
507
  kind: "json",
508
508
  status: e,
509
509
  data: { error: t }
510
- }), M = async () => {
510
+ }), N = async () => {
511
511
  let e = [];
512
512
  if (x && _) try {
513
513
  e = await _.load();
514
514
  } catch {
515
515
  e = [];
516
516
  }
517
- return ce({
518
- flags: L.all(),
517
+ return le({
518
+ flags: R.all(),
519
519
  usage: e,
520
520
  tracking: x,
521
521
  liveCounts: Y.liveTargetedCounts(),
522
522
  staleAfterDays: S,
523
523
  retentionDays: C
524
524
  });
525
- }, N = () => {
525
+ }, P = () => {
526
526
  let e = {};
527
527
  for (let [t, n] of f) e[t] = {
528
528
  valueType: n.valueType,
@@ -530,17 +530,17 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
530
530
  experiment: n.experiment ?? null
531
531
  };
532
532
  return e;
533
- }, R = [
533
+ }, F = [
534
534
  {
535
535
  method: "GET",
536
536
  path: "/list",
537
537
  description: "Every resolved flag (key, enabled, rollout, description) plus the dead-flag lifecycle report: per-flag shape + usage verdict, the observation coverage window, and the limits of what the report can prove.",
538
- handler: () => c.promise(async () => {
539
- let e = await M();
540
- return A({
541
- flags: B(L.all()),
538
+ handler: () => l.promise(async () => {
539
+ let e = await N();
540
+ return j({
541
+ flags: V(R.all()),
542
542
  lifecycle: e,
543
- typed: N(),
543
+ typed: P(),
544
544
  overrideRefusals: y
545
545
  });
546
546
  })
@@ -549,26 +549,26 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
549
549
  method: "POST",
550
550
  path: "/toggle",
551
551
  description: "Flip a flag's kill-switch on/off (live registry; durable on the postgres tier). Logs an audit row.",
552
- handler: (e) => c.gen(function* () {
552
+ handler: (e) => l.gen(function* () {
553
553
  let t;
554
554
  try {
555
555
  t = JSON.parse(e.body || "{}");
556
556
  } catch {
557
- return j(400, "invalid JSON body");
557
+ return M(400, "invalid JSON body");
558
558
  }
559
- if (!t.key || typeof t.enabled != "boolean") return j(400, "key (string) + enabled (boolean) required");
560
- let n = z(t.key, t.enabled);
561
- if (n === null) return j(404, `unknown flag "${t.key}"`);
562
- let r = h ? L.get(t.key) : void 0;
563
- return h && r !== void 0 && (yield* c.promise(() => h.upsertOverride(ie(t.key, r)))), g && (yield* c.promise(() => g.record({
559
+ if (!t.key || typeof t.enabled != "boolean") return M(400, "key (string) + enabled (boolean) required");
560
+ let n = B(t.key, t.enabled);
561
+ if (n === null) return M(404, `unknown flag "${t.key}"`);
562
+ let r = h ? R.get(t.key) : void 0;
563
+ return h && r !== void 0 && (yield* l.promise(() => h.upsertOverride(ie(t.key, r)))), g && (yield* l.promise(() => g.record({
564
564
  flag: t.key,
565
565
  enabled: t.enabled,
566
566
  previousEnabled: n,
567
567
  actorId: typeof t.actor == "string" ? t.actor : null,
568
568
  at: /* @__PURE__ */ new Date()
569
- }))), A({
569
+ }))), j({
570
570
  ok: !0,
571
- flags: B(L.all())
571
+ flags: V(R.all())
572
572
  });
573
573
  })
574
574
  },
@@ -576,16 +576,16 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
576
576
  method: "GET",
577
577
  path: "/audit",
578
578
  description: "The kill-switch flip audit trail, newest-first. `?flag=<key>` filters; `?limit=<n>` caps (postgres tier only).",
579
- handler: (e) => c.gen(function* () {
580
- if (!g) return A({
579
+ handler: (e) => l.gen(function* () {
580
+ if (!g) return j({
581
581
  audit: [],
582
582
  note: "audit trail requires store: \"postgres\""
583
583
  });
584
- let t = new URL(e.url, "http://local"), n = t.searchParams.get("flag"), r = t.searchParams.get("limit"), i = r != null && Number.isFinite(Number(r)) ? Number(r) : 100, a = yield* c.promise(() => g.list({
584
+ let t = new URL(e.url, "http://local"), n = t.searchParams.get("flag"), r = t.searchParams.get("limit"), i = r != null && Number.isFinite(Number(r)) ? Number(r) : 100, a = yield* l.promise(() => g.list({
585
585
  ...n == null ? {} : { flag: n },
586
586
  limit: i
587
587
  }));
588
- return A({ audit: a.map((e) => ({
588
+ return j({ audit: a.map((e) => ({
589
589
  flag: e.flag,
590
590
  enabled: e.enabled,
591
591
  previousEnabled: e.previousEnabled,
@@ -594,7 +594,7 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
594
594
  })) });
595
595
  })
596
596
  }
597
- ], oe = [
597
+ ], z = [
598
598
  ...D ? [
599
599
  "rpc:intercept:mutation",
600
600
  "rpc:intercept:query",
@@ -604,16 +604,16 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
604
604
  "inspect:read",
605
605
  "inspect:write"
606
606
  ];
607
- return l({
607
+ return u({
608
608
  name: o,
609
609
  baseName: $,
610
610
  description: "Feature flags — targeting, % rollout, kill-switch, declarative gating + client evaluation.",
611
- permissions: oe,
612
- routes: [k, te],
613
- rpcClientDescriptors: n,
614
- inspectEndpoints: R,
611
+ permissions: z,
612
+ routes: [k, A],
613
+ rpcClientDescriptors: r,
614
+ inspectEndpoints: F,
615
615
  errorSchemas: [{
616
- schema: s,
616
+ schema: c,
617
617
  import: {
618
618
  module: "@voltro/plugin-flags/errors",
619
619
  name: "FlagDisabled"
@@ -625,38 +625,38 @@ var S = (e) => typeof e == "boolean" ? { enabled: e } : e, C = (e, t) => {
625
625
  interceptAction: E
626
626
  } : {},
627
627
  ...m ? { extendSchema: { tables: x ? [
628
- P,
629
- F,
628
+ I,
629
+ L,
630
630
  J
631
- ] : [P, F] } } : {},
631
+ ] : [I, L] } } : {},
632
632
  ...m ? { bindDataStore: (e) => {
633
- h = ae(e), g = se(e), x && (_ = pe(e));
633
+ h = ae(e), g = se(e), x && (_ = me(e));
634
634
  } } : {},
635
- ...m ? { onChangeEvent: (e) => e.table === "_voltro_feature_flags" ? c.tryPromise(() => w()) : c.void } : {},
636
- onActivate: (e) => c.gen(function* () {
637
- L.set(p), m && h && (yield* c.promise(w));
638
- let t;
639
- x && _ && (t = ge(process.env, C), Y.enable(), v = he({
635
+ ...m ? { onChangeEvent: (e) => e.table === "_voltro_feature_flags" ? l.tryPromise(() => T()) : l.void } : {},
636
+ onActivate: (t) => l.gen(function* () {
637
+ R.set(p), m && h && (yield* l.promise(T));
638
+ let n;
639
+ x && _ && (n = _e(process.env, C), Y.enable(), v = ge({
640
640
  store: _,
641
641
  intervalMs: b.flushIntervalMs ?? 3e5,
642
- log: { warn: (t, n) => e.logger.warn(t, n) }
642
+ log: { warn: (e, n) => t.logger.warn(e, n) }
643
643
  }));
644
- for (let t of y) e.logger.warn("feature flag override REFUSED — the code-declared definition stands", {
645
- flag: t.key,
646
- reason: t.reason
644
+ for (let e of y) t.logger.warn("feature flag override REFUSED — the code-declared definition stands", {
645
+ flag: e.key,
646
+ reason: e.reason
647
647
  });
648
- e.logger.info("feature flags active", {
648
+ t.logger.info("feature flags active", {
649
649
  count: Object.keys(p).length,
650
650
  typed: f.size,
651
- gated: Object.keys(a).length,
652
- store: r.store ?? "memory",
653
- usage: x ? `tracking (stale after ${S}d, observations kept ${Math.round((t ?? 0) / 864e5)}d)` : "off — no dead-flag evidence is collected"
651
+ gated: Object.keys(i).length,
652
+ store: e.store ?? "memory",
653
+ usage: x ? `tracking (stale after ${S}d, observations kept ${Math.round((n ?? 0) / 864e5)}d)` : "off — no dead-flag evidence is collected"
654
654
  });
655
655
  }),
656
- onDeactivate: () => c.promise(async () => {
657
- await v?.stop(), v = void 0, Y.disable(), L.set({});
656
+ onDeactivate: () => l.promise(async () => {
657
+ await v?.stop(), v = void 0, Y.disable(), R.set({});
658
658
  })
659
659
  });
660
660
  };
661
661
  //#endregion
662
- export { me as DEFAULT_FLAG_USAGE_FLUSH_MS, K as FLAG_LIFECYCLE_LIMITS, q as FLAG_USAGE_TABLE, s as FlagDisabled, H as assertFlagExperimentLinks, V as checkFlagExperimentLinks, G as classifyShape, pe as dataStoreFlagUsageStore, o as defineFlag, B as describeFlags, ne as evaluateAll, re as evaluateAllVariants, k as evaluateFlag, O as evaluateSchedule, j as evaluateVariant, P as featureFlagsTable, F as flagAuditTable, ce as flagLifecycleReport, L as flagRegistry, Y as flagUsageBuffer, de as flagUsageRowId, J as flagUsageTable, ve as flagValue, ye as flagVariant, xe as flagsPlugin, z as flipFlagEnabled, i as gateOverride, Q as isFlagEnabled, a as isTypedFlag, I as mergeOverrides, X as recordFlagEvaluation, ge as registerFlagUsageRetention, _e as requireFlag, A as resolveVariant, R as setFlagEnabled, he as startFlagUsageFlush, S as toDefinition, r as typedValueOf, le as utcDay };
662
+ export { he as DEFAULT_FLAG_USAGE_FLUSH_MS, ce as FLAG_LIFECYCLE_LIMITS, q as FLAG_USAGE_TABLE, c as FlagDisabled, U as assertFlagExperimentLinks, H as checkFlagExperimentLinks, K as classifyShape, me as dataStoreFlagUsageStore, s as defineFlag, V as describeFlags, te as evaluateAll, ne as evaluateAllVariants, A as evaluateFlag, k as evaluateSchedule, N as evaluateVariant, I as featureFlagsTable, L as flagAuditTable, le as flagLifecycleReport, R as flagRegistry, Y as flagUsageBuffer, fe as flagUsageRowId, J as flagUsageTable, ye as flagValue, be as flagVariant, Se as flagsPlugin, B as flipFlagEnabled, a as gateOverride, Q as isFlagEnabled, o as isTypedFlag, re as mergeOverrides, X as recordFlagEvaluation, _e as registerFlagUsageRetention, ve as requireFlag, M as resolveVariant, z as setFlagEnabled, ge as startFlagUsageFlush, C as toDefinition, i as typedValueOf, ue as utcDay };
package/dist/rpc.d.ts CHANGED
@@ -2,6 +2,18 @@ import { PluginRpcClientDescriptor } from '@voltro/protocol';
2
2
  import { QueryProcedureDescriptor } from '@voltro/protocol';
3
3
  import { Schema } from 'effect';
4
4
 
5
+ /**
6
+ * The plugin's canonical package name — the key an app's `alias` is recorded
7
+ * under, and what `./web`'s hooks resolve their tags through.
8
+ *
9
+ * It lives in this BROWSER-SAFE module rather than in `index.ts` because both
10
+ * halves need it and only one of them may reach the server graph: `index.ts`
11
+ * passes it to `pluginInstanceName` / `baseName`, and `web.ts` passes it to
12
+ * `pluginTag`. A second copy in `web.ts` would be a second definition of the
13
+ * plugin's identity with nothing comparing them.
14
+ */
15
+ export declare const BASE_NAME = "@voltro/plugin-flags";
16
+
5
17
  export declare const evaluateDescriptor: QueryProcedureDescriptor<"flags.evaluate", Schema.Struct<{}>, Schema.Record$<typeof Schema.String, typeof Schema.Boolean>, typeof Schema.Never>;
6
18
 
7
19
  export declare const evaluateVariantsDescriptor: QueryProcedureDescriptor<"flags.variants", Schema.Struct<{}>, Schema.Record$<typeof Schema.String, Schema.Struct<{
package/dist/rpc.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Schema as e } from "effect";
2
2
  import { defineQuery as t } from "@voltro/protocol";
3
3
  //#region src/rpc.ts
4
- var n = t({
4
+ var n = "@voltro/plugin-flags", r = t({
5
5
  name: "flags.evaluate",
6
6
  input: e.Struct({}),
7
7
  output: e.Record({
@@ -9,19 +9,19 @@ var n = t({
9
9
  value: e.Boolean
10
10
  }),
11
11
  openAccess: "per-caller flag evaluation: returns only the values computed for the calling subject; every session (incl. anonymous) evaluates its own flags"
12
- }), r = e.Struct({
12
+ }), i = e.Struct({
13
13
  name: e.String,
14
14
  value: e.Unknown,
15
15
  enabled: e.Boolean
16
- }), i = t({
16
+ }), a = t({
17
17
  name: "flags.variants",
18
18
  input: e.Struct({}),
19
19
  output: e.Record({
20
20
  key: e.String,
21
- value: r
21
+ value: i
22
22
  }),
23
23
  openAccess: "per-caller variant resolution: returns only the variants served to the calling subject; every session (incl. anonymous) evaluates its own flags"
24
- }), a = [{
24
+ }), o = [{
25
25
  tag: "flags.evaluate",
26
26
  kind: "query",
27
27
  import: {
@@ -37,4 +37,4 @@ var n = t({
37
37
  }
38
38
  }];
39
39
  //#endregion
40
- export { n as evaluateDescriptor, i as evaluateVariantsDescriptor, a as flagsRpcClientImports };
40
+ export { n as BASE_NAME, r as evaluateDescriptor, a as evaluateVariantsDescriptor, o as flagsRpcClientImports };
package/dist/web.js CHANGED
@@ -1,12 +1,14 @@
1
- import { i as e } from "./define-D9Eet7wA.js";
2
- import { useSubscription as t } from "@voltro/client";
1
+ import { BASE_NAME as e } from "./rpc.js";
2
+ import { i as t } from "./define-D9Eet7wA.js";
3
+ import { pluginTag as n } from "@voltro/protocol";
4
+ import { useSubscription as r } from "@voltro/client";
3
5
  //#region src/web.ts
4
- var n = (e = "app") => {
5
- let { data: n } = t(e, "flags.evaluate", {});
6
- return n ?? {};
7
- }, r = (e, t = "app") => n(t)[e] === !0, i = (e = "app") => {
8
- let { data: n } = t(e, "flags.variants", {});
9
- return n ?? {};
10
- }, a = (e, t = "app") => i(t)[e], o = (t, n = "app") => e(t, i(n)[t.key]);
6
+ var i = (t) => n(e, t), a = (e = "app") => {
7
+ let { data: t } = r(e, i("evaluate"), {});
8
+ return t ?? {};
9
+ }, o = (e, t = "app") => a(t)[e] === !0, s = (e = "app") => {
10
+ let { data: t } = r(e, i("variants"), {});
11
+ return t ?? {};
12
+ }, c = (e, t = "app") => s(t)[e], l = (e, n = "app") => t(e, s(n)[e.key]);
11
13
  //#endregion
12
- export { r as useFlag, o as useFlagValue, n as useFlags, a as useVariant, i as useVariants };
14
+ export { o as useFlag, l as useFlagValue, a as useFlags, c as useVariant, s as useVariants };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-flags",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "description": "Feature flags — per-subject / per-tenant targeting, deterministic % rollouts, kill-switch. Gate mutations/queries/actions declaratively or guard in-handler; evaluate flags client-side for UI gating. Config-as-code (memory) or runtime-toggleable (postgres).",
5
5
  "keywords": [
6
6
  "voltro",
@@ -48,10 +48,10 @@
48
48
  "node": ">=24.0.0"
49
49
  },
50
50
  "dependencies": {
51
- "@voltro/client": "0.54.0",
52
- "@voltro/database": "0.54.0",
53
- "@voltro/logger": "0.54.0",
54
- "@voltro/protocol": "0.54.0"
51
+ "@voltro/client": "0.55.0",
52
+ "@voltro/database": "0.55.0",
53
+ "@voltro/logger": "0.55.0",
54
+ "@voltro/protocol": "0.55.0"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "effect": "^3.22.0"