@voltro/plugin-flags 0.33.0 → 0.34.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 +1801 -0
- package/dist/define-D9Eet7wA.js +79 -0
- package/dist/index.d.ts +552 -1
- package/dist/index.js +441 -120
- package/dist/web.d.ts +160 -0
- package/dist/web.js +8 -7
- package/package.json +5 -5
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,118 @@
|
|
|
1
1
|
import { ColumnBuilder } from '@voltro/database';
|
|
2
|
+
import { DataStore } from '@voltro/database';
|
|
2
3
|
import { Effect } from 'effect';
|
|
3
4
|
import { FieldDefinitions } from '@voltro/database';
|
|
4
5
|
import { Schema } from 'effect';
|
|
5
6
|
import { Table } from '@voltro/database';
|
|
6
7
|
import { VoltroPlugin } from '@voltro/protocol';
|
|
7
8
|
|
|
9
|
+
/** `TypedFlag` with the value type erased — what the plugin and the registry
|
|
10
|
+
* hold. Sound because `TypedFlag` is covariant in `A` (see the annotation). */
|
|
11
|
+
export declare type AnyTypedFlag = TypedFlag<unknown>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The boot-time form: throw with every problem at once.
|
|
15
|
+
*
|
|
16
|
+
* Loud and at CONSTRUCTION rather than a warning at first evaluation, because
|
|
17
|
+
* the failure this prevents produces a number an operator will act on. An
|
|
18
|
+
* experiment reporting the wrong split is worse than one reporting nothing.
|
|
19
|
+
*/
|
|
20
|
+
export declare const assertFlagExperimentLinks: (flags: ReadonlyArray<AnyTypedFlag>, experiments: ReadonlyArray<LinkedExperimentLike>) => void;
|
|
21
|
+
|
|
22
|
+
declare interface BufferEntry {
|
|
23
|
+
readonly flag: string;
|
|
24
|
+
readonly source: FlagUsageSource;
|
|
25
|
+
lastAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check every typed flag that names an experiment against the experiments the
|
|
30
|
+
* app declared. Pure — returns the problems rather than throwing, so the same
|
|
31
|
+
* rules are unit-testable and the caller decides the cost.
|
|
32
|
+
*/
|
|
33
|
+
export declare const checkFlagExperimentLinks: (flags: ReadonlyArray<AnyTypedFlag>, experiments: ReadonlyArray<LinkedExperimentLike>) => ReadonlyArray<FlagExperimentLinkProblem>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Classify a flag from its definition alone. Pure, total, and independent of
|
|
37
|
+
* every observation — this is the half that is a proof.
|
|
38
|
+
*/
|
|
39
|
+
export declare const classifyShape: (value: FlagValue, now: number) => {
|
|
40
|
+
readonly shape: FlagShape;
|
|
41
|
+
readonly reason: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Adapt the framework `DataStore` to a {@link FlagUsageStore}. */
|
|
45
|
+
export declare const dataStoreFlagUsageStore: (store: DataStore) => FlagUsageStore;
|
|
46
|
+
|
|
47
|
+
/** Default flush interval. Five minutes rather than one: the report's
|
|
48
|
+
* resolution is a DAY, so a tighter interval buys nothing and costs one write
|
|
49
|
+
* per active flag per replica. */
|
|
50
|
+
export declare const DEFAULT_FLAG_USAGE_FLUSH_MS = 300000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Declare a flag with a value type.
|
|
54
|
+
*
|
|
55
|
+
* ```ts
|
|
56
|
+
* // apps/api/lib/flags.ts — browser-safe, imported by app.config.ts AND by the UI
|
|
57
|
+
* import { Schema } from 'effect'
|
|
58
|
+
* import { defineFlag } from '@voltro/plugin-flags'
|
|
59
|
+
*
|
|
60
|
+
* export const checkoutButton = defineFlag({
|
|
61
|
+
* key: 'checkout.button',
|
|
62
|
+
* value: Schema.Literal('blue', 'green'),
|
|
63
|
+
* default: 'blue',
|
|
64
|
+
* variants: [{ name: 'control', value: 'blue' }, { name: 'green', value: 'green' }],
|
|
65
|
+
* experiment: 'checkout-button-color',
|
|
66
|
+
* })
|
|
67
|
+
*
|
|
68
|
+
* export const pageSize = defineFlag({
|
|
69
|
+
* key: 'search.pageSize',
|
|
70
|
+
* value: Schema.Number,
|
|
71
|
+
* default: 20,
|
|
72
|
+
* // default: 'twenty', ← Type 'string' is not assignable to type 'number'
|
|
73
|
+
* })
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* Validation that CANNOT wait for a type error runs here, at declaration, so a
|
|
77
|
+
* malformed flag fails at import rather than at the first evaluation:
|
|
78
|
+
* a duplicate variant name, an `offVariant` naming no variant, a non-positive
|
|
79
|
+
* weight, a rollout outside 0..100.
|
|
80
|
+
*/
|
|
81
|
+
export declare const defineFlag: <S extends Schema.Schema.AnyNoContext>(spec: DefineFlagSpec<S>) => TypedFlag<Schema.Schema.Type<S>>;
|
|
82
|
+
|
|
83
|
+
export declare interface DefineFlagSpec<S extends Schema.Schema.AnyNoContext> {
|
|
84
|
+
/** The flag key — `dotted.camelCase`, matching the `flags: { … }` record. */
|
|
85
|
+
readonly key: string;
|
|
86
|
+
/** The value Schema. Everything else in this spec is checked against it. */
|
|
87
|
+
readonly value: S;
|
|
88
|
+
/**
|
|
89
|
+
* The value served when the flag does not resolve to a variant.
|
|
90
|
+
*
|
|
91
|
+
* A wrong value here is a COMPILE ERROR. `NoInfer` is one of three reasons
|
|
92
|
+
* why and, measured, the redundant one — see the header for the other two and
|
|
93
|
+
* for why it is kept anyway.
|
|
94
|
+
*/
|
|
95
|
+
readonly default: NoInfer<Schema.Schema.Type<S>>;
|
|
96
|
+
/** The value arms, each checked against the Schema. */
|
|
97
|
+
readonly variants?: ReadonlyArray<TypedFlagVariant<NoInfer<Schema.Schema.Type<S>>>>;
|
|
98
|
+
/** Variant name treated as the "off" state for boolean resolution. */
|
|
99
|
+
readonly offVariant?: string;
|
|
100
|
+
/** Master switch. `false` ⇒ off for everyone. Default true. */
|
|
101
|
+
readonly enabled?: boolean;
|
|
102
|
+
/** Percentage rollout 0..100. Omit ⇒ 100. */
|
|
103
|
+
readonly rollout?: number;
|
|
104
|
+
/** OR-of-rules targeting. */
|
|
105
|
+
readonly targeting?: ReadonlyArray<FlagTargetingRule>;
|
|
106
|
+
/** Bucket on the tenant id instead of the subject id. */
|
|
107
|
+
readonly rolloutBy?: 'subject' | 'tenant';
|
|
108
|
+
/** Time-boxed / ramping schedule. */
|
|
109
|
+
readonly schedule?: FlagSchedule;
|
|
110
|
+
/** Surfaced in the dashboard. */
|
|
111
|
+
readonly description?: string;
|
|
112
|
+
/** IN-11 — the `defineExperiment` name this flag's arms report uplift for. */
|
|
113
|
+
readonly experiment?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
8
116
|
/** Convert a resolved flag to a dashboard/inspect row. Surfaces the variant
|
|
9
117
|
* names + a `scheduled` flag so the panel can badge multivariate / time-boxed
|
|
10
118
|
* flags without re-deriving the definition. */
|
|
@@ -73,6 +181,12 @@ export declare const featureFlagsTable: Table<"_voltro_feature_flags", FieldDefi
|
|
|
73
181
|
readonly updatedAt: ColumnBuilder<Date, "timestamp", true>;
|
|
74
182
|
}>, true, never>;
|
|
75
183
|
|
|
184
|
+
/** What the report structurally cannot see. Shipped in the payload. */
|
|
185
|
+
export declare const FLAG_LIFECYCLE_LIMITS: ReadonlyArray<string>;
|
|
186
|
+
|
|
187
|
+
/** The observation table name. */
|
|
188
|
+
export declare const FLAG_USAGE_TABLE = "_voltro_feature_flag_usage";
|
|
189
|
+
|
|
76
190
|
/** One kill-switch audit row (who flipped what, when, old→new). */
|
|
77
191
|
export declare interface FlagAuditRow {
|
|
78
192
|
readonly flag: string;
|
|
@@ -97,6 +211,17 @@ export declare const flagAuditTable: Table<"_voltro_feature_flag_audit", FieldDe
|
|
|
97
211
|
readonly at: ColumnBuilder<Date, "timestamp", true>;
|
|
98
212
|
}>, true, "byFlagAuditFlag" | "byFlagAuditAt">;
|
|
99
213
|
|
|
214
|
+
/** The result of decoding an UNTRUSTED value (a DB override, a dashboard edit)
|
|
215
|
+
* against a flag's Schema. Deliberately not an exception: the caller's correct
|
|
216
|
+
* response is to keep the code-declared value and say so, not to unwind. */
|
|
217
|
+
export declare type FlagDecodeResult<A> = {
|
|
218
|
+
readonly ok: true;
|
|
219
|
+
readonly value: A;
|
|
220
|
+
} | {
|
|
221
|
+
readonly ok: false;
|
|
222
|
+
readonly reason: string;
|
|
223
|
+
};
|
|
224
|
+
|
|
100
225
|
export declare interface FlagDefinition {
|
|
101
226
|
/** Master switch. `false` → off for everyone regardless of rollout/targeting. Default true. */
|
|
102
227
|
readonly enabled?: boolean;
|
|
@@ -135,6 +260,70 @@ declare const FlagDisabled_base: Schema.TaggedErrorClass<FlagDisabled, "FlagDisa
|
|
|
135
260
|
flag: typeof Schema.String;
|
|
136
261
|
}>;
|
|
137
262
|
|
|
263
|
+
export declare interface FlagExperimentLinkProblem {
|
|
264
|
+
readonly flag: string;
|
|
265
|
+
readonly experiment: string;
|
|
266
|
+
readonly problem: string;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export declare interface FlagLifecycleFinding {
|
|
270
|
+
readonly key: string;
|
|
271
|
+
readonly shape: FlagShape;
|
|
272
|
+
/** Plain-language reason for `shape`, quoting the fields that decided it. */
|
|
273
|
+
readonly shapeReason: string;
|
|
274
|
+
readonly usage: FlagUsageVerdict;
|
|
275
|
+
/** ISO instant of the newest TARGETED evaluation in the window, or null. */
|
|
276
|
+
readonly lastTargetedAt: string | null;
|
|
277
|
+
/** ISO instant of the newest BULK delivery in the window, or null. A flag
|
|
278
|
+
* that only ever appears here is one a client was HANDED, not one a client
|
|
279
|
+
* was measured reading. */
|
|
280
|
+
readonly lastBulkAt: string | null;
|
|
281
|
+
/** Targeted evaluations seen by THIS process since it started. Not persisted
|
|
282
|
+
* and not summed across replicas — a live pulse, not a total. */
|
|
283
|
+
readonly liveTargetedEvaluations: number;
|
|
284
|
+
/**
|
|
285
|
+
* True only when something here is PROVEN: a constant/expired shape, or a
|
|
286
|
+
* `stale` usage verdict over a sufficient window. `neverObserved` never sets
|
|
287
|
+
* it, because a flag declared yesterday looks exactly the same.
|
|
288
|
+
*/
|
|
289
|
+
readonly removalCandidate: boolean;
|
|
290
|
+
/** The evidence, in the order it was applied. */
|
|
291
|
+
readonly why: ReadonlyArray<string>;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export declare interface FlagLifecycleInput {
|
|
295
|
+
/** The live flag set — the same record the registry holds. */
|
|
296
|
+
readonly flags: Record<string, FlagValue>;
|
|
297
|
+
/** Persisted observations. Empty is meaningful (nothing observed yet), which
|
|
298
|
+
* is why `tracking` is a separate input rather than inferred from length. */
|
|
299
|
+
readonly usage: ReadonlyArray<FlagUsageRow>;
|
|
300
|
+
/** Is recording on? `false` ⇒ every verdict is `untracked`. */
|
|
301
|
+
readonly tracking: boolean;
|
|
302
|
+
/** Targeted evaluations seen by this process, per flag. */
|
|
303
|
+
readonly liveCounts?: ReadonlyMap<string, number>;
|
|
304
|
+
/** A flag with no targeted evaluation in this many days is `stale`. */
|
|
305
|
+
readonly staleAfterDays: number;
|
|
306
|
+
/** The observation retention TTL, in days. The window ceiling. */
|
|
307
|
+
readonly retentionDays: number;
|
|
308
|
+
readonly now?: number;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export declare interface FlagLifecycleReport {
|
|
312
|
+
readonly coverage: FlagUsageCoverage;
|
|
313
|
+
readonly findings: ReadonlyArray<FlagLifecycleFinding>;
|
|
314
|
+
/** What this report structurally cannot see. Shipped WITH the data, not in a
|
|
315
|
+
* docs page nobody reads next to the number they are about to act on. */
|
|
316
|
+
readonly limits: ReadonlyArray<string>;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Build the lifecycle report.
|
|
321
|
+
*
|
|
322
|
+
* Pure: definitions + rows + a clock in, a report out. Nothing here reads a
|
|
323
|
+
* store, so every rule above is unit-testable at a chosen instant.
|
|
324
|
+
*/
|
|
325
|
+
export declare const flagLifecycleReport: (input: FlagLifecycleInput) => FlagLifecycleReport;
|
|
326
|
+
|
|
138
327
|
/** A persisted override row (`_voltro_feature_flags`). `targeting` is JSON. */
|
|
139
328
|
declare interface FlagOverrideRow {
|
|
140
329
|
readonly key: string;
|
|
@@ -181,18 +370,67 @@ export declare interface FlagSchedule {
|
|
|
181
370
|
};
|
|
182
371
|
}
|
|
183
372
|
|
|
373
|
+
/** What the DEFINITION alone proves about a flag. */
|
|
374
|
+
export declare type FlagShape =
|
|
375
|
+
/** Serves the same answer to everyone, forever: `rollout` ≥ 100, no
|
|
376
|
+
* targeting, no variants, no live schedule. The flag is a constant `true`. */
|
|
377
|
+
'constantOn'
|
|
378
|
+
/** `enabled: false`, or a rollout of 0 — a constant `false`. The guarded path
|
|
379
|
+
* is unreachable THROUGH THIS FLAG (which is not the same as unreachable). */
|
|
380
|
+
| 'constantOff'
|
|
381
|
+
/** A schedule whose `deactivateAt` has passed. It can never be on again. */
|
|
382
|
+
| 'expired'
|
|
383
|
+
/** A schedule whose `activateAt` is still in the future. Not dead — pending. */
|
|
384
|
+
| 'notYetActive'
|
|
385
|
+
/** Genuinely does work: a partial rollout, targeting, variants, or a ramp. */
|
|
386
|
+
| 'conditional';
|
|
387
|
+
|
|
184
388
|
export declare const flagsPlugin: (options?: FlagsPluginOptions) => VoltroPlugin;
|
|
185
389
|
|
|
186
390
|
export declare interface FlagsPluginOptions {
|
|
187
391
|
/** Flags as code. The baseline; a postgres store overlays runtime toggles. */
|
|
188
392
|
readonly flags?: Record<string, FlagValue>;
|
|
393
|
+
/**
|
|
394
|
+
* Flags declared with `defineFlag()` — a per-flag VALUE Schema, so a wrong
|
|
395
|
+
* default is a compile error and a runtime override that does not match is
|
|
396
|
+
* refused instead of served. Their keys join the same namespace as `flags`;
|
|
397
|
+
* a key in both is refused at construction.
|
|
398
|
+
*/
|
|
399
|
+
readonly typedFlags?: ReadonlyArray<AnyTypedFlag>;
|
|
400
|
+
/**
|
|
401
|
+
* The `defineExperiment()` definitions any typed flag links to via
|
|
402
|
+
* `experiment:`. Required when at least one does — the linkage is validated
|
|
403
|
+
* at construction (see `experimentLink.ts` for why a name alone is not
|
|
404
|
+
* enough), and an unvalidated link reports uplift for a split nobody served.
|
|
405
|
+
*/
|
|
406
|
+
readonly experiments?: ReadonlyArray<LinkedExperimentLike>;
|
|
407
|
+
/** Evaluation observation + dead-flag reporting. */
|
|
408
|
+
readonly usage?: FlagUsageOptions;
|
|
189
409
|
/** `'memory'` (config-as-code, default) | `'postgres'` (runtime-toggleable
|
|
190
410
|
* via `_voltro_feature_flags`, overlaid on the config baseline). */
|
|
191
411
|
readonly store?: 'memory' | 'postgres';
|
|
192
412
|
/** Declarative gate: map an rpc tag (or regex) → flag key. A call to a gated
|
|
193
413
|
* tag fails `FlagDisabled` BEFORE the handler runs when the flag is off. */
|
|
194
414
|
readonly gatedBy?: Record<string, string>;
|
|
195
|
-
/**
|
|
415
|
+
/**
|
|
416
|
+
* Namespace for this plugin's rpc tags + inspect endpoints. Default `flags`.
|
|
417
|
+
*
|
|
418
|
+
* Set it when your app already publishes under that name — an exact tag
|
|
419
|
+
* collision is fatal at codegen, and this is the way out. Orthogonal to
|
|
420
|
+
* `name` below: `alias` REPLACES the namespace, `name` distinguishes two
|
|
421
|
+
* installations within it.
|
|
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.
|
|
427
|
+
*/
|
|
428
|
+
readonly alias?: string;
|
|
429
|
+
/**
|
|
430
|
+
* Discriminator for a SECOND installation of this plugin, when one app runs
|
|
431
|
+
* two (`@voltro/plugin-flags#analytics`). Not a rename — for that use
|
|
432
|
+
* `alias`.
|
|
433
|
+
*/
|
|
196
434
|
readonly name?: string;
|
|
197
435
|
}
|
|
198
436
|
|
|
@@ -239,9 +477,154 @@ export declare interface FlagTargetingRule {
|
|
|
239
477
|
readonly metadata?: Record<string, string>;
|
|
240
478
|
}
|
|
241
479
|
|
|
480
|
+
/**
|
|
481
|
+
* Process-wide evaluation buffer.
|
|
482
|
+
*
|
|
483
|
+
* A module-level singleton for the same reason `registry` is one: the guards
|
|
484
|
+
* (`isFlagEnabled`, `requireFlag`) are free functions a handler calls without
|
|
485
|
+
* reaching the plugin instance, so the recorder has to be reachable the same
|
|
486
|
+
* way. Recording is OFF until the plugin turns it on — an app on the memory
|
|
487
|
+
* tier, or one that opted out, pays nothing but a boolean check.
|
|
488
|
+
*/
|
|
489
|
+
declare class FlagUsageBuffer {
|
|
490
|
+
private on;
|
|
491
|
+
private readonly pending;
|
|
492
|
+
/** Since-process-start counters, surfaced live in the inspect payload. Never
|
|
493
|
+
* persisted — see the header on why a persisted count would be a lie. */
|
|
494
|
+
private readonly liveCounts;
|
|
495
|
+
enable(): void;
|
|
496
|
+
disable(): void;
|
|
497
|
+
get enabled(): boolean;
|
|
498
|
+
record(flag: string, source: FlagUsageSource, at?: number): void;
|
|
499
|
+
/** Take + clear the pending set. The caller owns writing it. */
|
|
500
|
+
drain(): ReadonlyArray<BufferEntry>;
|
|
501
|
+
/** Put drained entries back after a failed flush — a store hiccup must not
|
|
502
|
+
* silently erase the observation that keeps a flag classified `active`. */
|
|
503
|
+
restore(entries: ReadonlyArray<BufferEntry>): void;
|
|
504
|
+
/** Targeted evaluations this PROCESS has seen, per flag. */
|
|
505
|
+
liveTargetedCounts(): ReadonlyMap<string, number>;
|
|
506
|
+
/** Test seam — resets everything, including the live counters. */
|
|
507
|
+
reset(): void;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export declare const flagUsageBuffer: FlagUsageBuffer;
|
|
511
|
+
|
|
512
|
+
export declare interface FlagUsageCoverage {
|
|
513
|
+
/** Is evaluation being recorded at all? `false` ⇒ every `usage` is `untracked`. */
|
|
514
|
+
readonly tracking: boolean;
|
|
515
|
+
/** ISO day of the earliest retained observation, or null when there is none. */
|
|
516
|
+
readonly observedSince: string | null;
|
|
517
|
+
/** Whole days between `observedSince` and now. 0 when nothing is observed. */
|
|
518
|
+
readonly observedDays: number;
|
|
519
|
+
/** The retention TTL in days — the hard ceiling on `observedDays`. */
|
|
520
|
+
readonly retentionDays: number;
|
|
521
|
+
/** The threshold a `stale` verdict is measured against. */
|
|
522
|
+
readonly staleAfterDays: number;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export declare interface FlagUsageFlushHandle {
|
|
526
|
+
/** Write whatever is buffered right now. */
|
|
527
|
+
readonly flushNow: () => Promise<void>;
|
|
528
|
+
/** Stop the timer AND write the last window — a shutdown that drops it is how
|
|
529
|
+
* a flag evaluated only in the final minutes reads as stale. */
|
|
530
|
+
readonly stop: () => Promise<void>;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export declare interface FlagUsageFlushOptions {
|
|
534
|
+
readonly store: FlagUsageStore;
|
|
535
|
+
/** How often the buffer is written. Default 5 min. */
|
|
536
|
+
readonly intervalMs?: number;
|
|
537
|
+
readonly log?: {
|
|
538
|
+
readonly warn?: (message: string, fields?: Record<string, unknown>) => void;
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Tunables for evaluation observation + the dead-flag report. Every number the
|
|
543
|
+
* framework would otherwise choose on your behalf is a field with a default. */
|
|
544
|
+
export declare interface FlagUsageOptions {
|
|
545
|
+
/**
|
|
546
|
+
* Record which flags are evaluated, so the lifecycle report can answer "not
|
|
547
|
+
* consulted since when".
|
|
548
|
+
*
|
|
549
|
+
* Default: ON when `store: 'postgres'`, OFF otherwise — the memory tier has
|
|
550
|
+
* no store to write to. Setting `true` on the memory tier is refused at
|
|
551
|
+
* construction rather than silently ignored: a dead-flag report that
|
|
552
|
+
* quietly observed nothing is the failure mode this whole feature is about.
|
|
553
|
+
*/
|
|
554
|
+
readonly track?: boolean;
|
|
555
|
+
/** How often buffered observations are written. Default 5 min — the report's
|
|
556
|
+
* resolution is a DAY, so a tighter interval buys nothing. */
|
|
557
|
+
readonly flushIntervalMs?: number;
|
|
558
|
+
/** A flag with no server-side read in this many days is reported `stale`.
|
|
559
|
+
* Default 30. Must be ≤ `retentionDays`, or staleness is never provable. */
|
|
560
|
+
readonly staleAfterDays?: number;
|
|
561
|
+
/** How long observations are kept. Default 90 days; also settable with
|
|
562
|
+
* `VOLTRO_FLAG_USAGE_TTL_HOURS`, which wins. This is the report's hard
|
|
563
|
+
* observation ceiling, not just a disk bound. */
|
|
564
|
+
readonly retentionDays?: number;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** One persisted observation, as the report reads it. */
|
|
568
|
+
export declare interface FlagUsageRow {
|
|
569
|
+
readonly flag: string;
|
|
570
|
+
readonly day: string;
|
|
571
|
+
readonly source: FlagUsageSource;
|
|
572
|
+
readonly lastAt: Date;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export declare const flagUsageRowId: (flag: string, day: string, source: FlagUsageSource) => string;
|
|
576
|
+
|
|
577
|
+
/** Which kind of read produced the observation. See the header for why the
|
|
578
|
+
* distinction is load-bearing rather than bookkeeping. */
|
|
579
|
+
export declare type FlagUsageSource = 'targeted' | 'bulk';
|
|
580
|
+
|
|
581
|
+
export declare interface FlagUsageStore {
|
|
582
|
+
readonly upsertMany: (entries: ReadonlyArray<{
|
|
583
|
+
readonly flag: string;
|
|
584
|
+
readonly source: FlagUsageSource;
|
|
585
|
+
readonly lastAt: Date;
|
|
586
|
+
}>) => Promise<void>;
|
|
587
|
+
readonly load: () => Promise<ReadonlyArray<FlagUsageRow>>;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* One (flag, UTC day, source) observation.
|
|
592
|
+
*
|
|
593
|
+
* `lastAt` is last-writer-wins across replicas, which is exactly right at this
|
|
594
|
+
* grain — every writer's value falls inside the same day, and the day is the
|
|
595
|
+
* resolution the report uses.
|
|
596
|
+
*/
|
|
597
|
+
export declare const flagUsageTable: Table<"_voltro_feature_flag_usage", FieldDefinitions<{
|
|
598
|
+
readonly id: ColumnBuilder<string, "id", boolean>;
|
|
599
|
+
readonly flag: ColumnBuilder<string, "text", boolean>;
|
|
600
|
+
/** UTC calendar day, `YYYY-MM-DD`. */
|
|
601
|
+
readonly day: ColumnBuilder<string, "text", boolean>;
|
|
602
|
+
/** `targeted` | `bulk`. */
|
|
603
|
+
readonly source: ColumnBuilder<string, "text", boolean>;
|
|
604
|
+
/** The most recent evaluation seen in this bucket. Also the retention clock. */
|
|
605
|
+
readonly lastAt: ColumnBuilder<Date, "timestamp", boolean>;
|
|
606
|
+
}>, true, "byFlagUsageLastAt" | "byFlagUsageFlag">;
|
|
607
|
+
|
|
608
|
+
/** What the OBSERVATIONS prove about a flag. Only `stale` is a proof. */
|
|
609
|
+
export declare type FlagUsageVerdict = 'evaluated' | 'stale' | 'neverObserved' | 'untracked';
|
|
610
|
+
|
|
242
611
|
/** A flag value as authored: bare boolean (kill-switch) OR a rich definition. */
|
|
243
612
|
export declare type FlagValue = boolean | FlagDefinition;
|
|
244
613
|
|
|
614
|
+
/**
|
|
615
|
+
* The typed value of a `defineFlag()` flag for the caller.
|
|
616
|
+
*
|
|
617
|
+
* Returns `A`, not `unknown` and not a union — the payoff of declaring a value
|
|
618
|
+
* Schema. The value comes from the LIVE registry (so a postgres-tier override
|
|
619
|
+
* is reflected), decoded against the flag's own Schema, falling back to the
|
|
620
|
+
* declared `default`.
|
|
621
|
+
*
|
|
622
|
+
* ```ts
|
|
623
|
+
* const size: number = flagValue(ctx, pageSize)
|
|
624
|
+
* ```
|
|
625
|
+
*/
|
|
626
|
+
export declare const flagValue: <A>(ctx: GuardCtx, flag: TypedFlag<A>) => A;
|
|
627
|
+
|
|
245
628
|
/** One multivariate variant: a named value with an optional rollout weight. */
|
|
246
629
|
export declare interface FlagVariant {
|
|
247
630
|
/** Stable variant name (the resolution returns this). */
|
|
@@ -253,6 +636,19 @@ export declare interface FlagVariant {
|
|
|
253
636
|
readonly weight?: number;
|
|
254
637
|
}
|
|
255
638
|
|
|
639
|
+
/**
|
|
640
|
+
* The NAME of the variant served to the caller — the arm to persist on a row so
|
|
641
|
+
* a `defineExperiment({ variantFrom })` can measure it (IN-11).
|
|
642
|
+
*
|
|
643
|
+
* ```ts
|
|
644
|
+
* await ctx.store.insert('orders', { …, checkoutArm: flagVariant(ctx, checkoutButton) })
|
|
645
|
+
* ```
|
|
646
|
+
*
|
|
647
|
+
* Returns `null` when the flag is not in the registry — writing a guessed arm
|
|
648
|
+
* would put rows in a bucket the flag never served.
|
|
649
|
+
*/
|
|
650
|
+
export declare const flagVariant: (ctx: GuardCtx, flag: AnyTypedFlag) => string | null;
|
|
651
|
+
|
|
256
652
|
/** A concrete variant value — string / number / boolean / JSON. Kept browser-safe. */
|
|
257
653
|
export declare type FlagVariantValue = string | number | boolean | null | ReadonlyArray<unknown> | Record<string, unknown>;
|
|
258
654
|
|
|
@@ -261,6 +657,27 @@ export declare type FlagVariantValue = string | number | boolean | null | Readon
|
|
|
261
657
|
* companion for callers that don't need the old value. */
|
|
262
658
|
export declare const flipFlagEnabled: (key: string, enabled: boolean) => boolean | null;
|
|
263
659
|
|
|
660
|
+
/**
|
|
661
|
+
* Gate a merged flag definition for a TYPED flag against its Schema.
|
|
662
|
+
*
|
|
663
|
+
* Returns the definition when every variant value decodes, or a refusal.
|
|
664
|
+
* `mergeOverrides` has already produced one definition from (config baseline ⊕
|
|
665
|
+
* DB row) by the time this runs, so the question is not "is the row valid" but
|
|
666
|
+
* "can this flag serve what the merged definition says it serves".
|
|
667
|
+
*
|
|
668
|
+
* A partial acceptance is not on offer, and that is the decision worth
|
|
669
|
+
* defending: dropping the ONE bad variant re-normalises the weights of the rest,
|
|
670
|
+
* so a typo in a 5% arm silently reallocates the other 95% — a change nobody
|
|
671
|
+
* asked for, applied to every subject, reported nowhere.
|
|
672
|
+
*/
|
|
673
|
+
export declare const gateOverride: (flag: AnyTypedFlag, merged: FlagDefinition) => {
|
|
674
|
+
readonly ok: true;
|
|
675
|
+
readonly definition: FlagDefinition;
|
|
676
|
+
} | {
|
|
677
|
+
readonly ok: false;
|
|
678
|
+
readonly refusal: OverrideRefusal;
|
|
679
|
+
};
|
|
680
|
+
|
|
264
681
|
/** Loose ctx shape: the framework `AppContext` (`ctx.request.subject`) OR a
|
|
265
682
|
* bare `{ subject }`. */
|
|
266
683
|
declare type GuardCtx = {
|
|
@@ -274,15 +691,66 @@ declare type GuardCtx = {
|
|
|
274
691
|
/** `true` if the flag is on for the caller. */
|
|
275
692
|
export declare const isFlagEnabled: (ctx: GuardCtx, key: string) => boolean;
|
|
276
693
|
|
|
694
|
+
export declare const isTypedFlag: (value: unknown) => value is AnyTypedFlag;
|
|
695
|
+
|
|
696
|
+
/** The part of a `defineExperiment` result this check needs. An
|
|
697
|
+
* `ExperimentDefinition` from `@voltro/runtime` satisfies it structurally. */
|
|
698
|
+
export declare interface LinkedExperimentLike {
|
|
699
|
+
readonly name: string;
|
|
700
|
+
readonly variants: ReadonlyArray<{
|
|
701
|
+
readonly name: string;
|
|
702
|
+
}>;
|
|
703
|
+
/** Non-null ⇒ the experiment READS its arm off the row (the only mode that
|
|
704
|
+
* can honestly measure a flag). Null/absent ⇒ it assigns its own. */
|
|
705
|
+
readonly variantFrom?: unknown;
|
|
706
|
+
/** Fraction held out of the experiment's OWN assignment. Must be 0 for a
|
|
707
|
+
* flag-driven experiment — see the check below. */
|
|
708
|
+
readonly holdout?: number;
|
|
709
|
+
}
|
|
710
|
+
|
|
277
711
|
/** Overlay persisted override rows on top of the config baseline. An override
|
|
278
712
|
* fully replaces the config flag's definition (last-write-wins), so toggling a
|
|
279
713
|
* flag off in the DB beats the code default. Pure. */
|
|
280
714
|
export declare const mergeOverrides: (config: Record<string, FlagValue>, rows: ReadonlyArray<FlagOverrideRow>) => Record<string, FlagValue>;
|
|
281
715
|
|
|
716
|
+
/** Why a runtime override was refused — surfaced in the boot/inspect log so a
|
|
717
|
+
* dashboard edit that "did nothing" says what it did instead. */
|
|
718
|
+
export declare interface OverrideRefusal {
|
|
719
|
+
readonly key: string;
|
|
720
|
+
readonly reason: string;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Record one flag evaluation. A no-op (one boolean read) when tracking is off. */
|
|
724
|
+
export declare const recordFlagEvaluation: (flag: string, source: FlagUsageSource, at?: number) => void;
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Retention for the observation table.
|
|
728
|
+
*
|
|
729
|
+
* Registered together with the table, in the same change, because the two are
|
|
730
|
+
* one decision: the TTL is also the report's OBSERVATION CEILING. A flag cannot
|
|
731
|
+
* be shown as "not evaluated in 120 days" when the evidence only reaches back
|
|
732
|
+
* 90 — `flagLifecycleReport` reads this number and downgrades the finding to
|
|
733
|
+
* `insufficientHistory` rather than guessing.
|
|
734
|
+
*/
|
|
735
|
+
export declare const registerFlagUsageRetention: (env?: NodeJS.ProcessEnv, defaultDays?: number) => number;
|
|
736
|
+
|
|
282
737
|
/** Effect-native guard — fails typed `FlagDisabled` when the flag is off for
|
|
283
738
|
* the caller. Declare `error: FlagDisabled` on the procedure for a typed client. */
|
|
284
739
|
export declare const requireFlag: (ctx: GuardCtx, key: string) => Effect.Effect<void, FlagDisabled>;
|
|
285
740
|
|
|
741
|
+
/**
|
|
742
|
+
* A resolved variant as either side sees it: the server gets it from
|
|
743
|
+
* `evaluateVariant`, the browser from the `flags.variants` wire payload. Both
|
|
744
|
+
* shapes are `{ name, value, enabled }`, which is why one function can serve
|
|
745
|
+
* both — and why it must, since a server guard and the component it gates
|
|
746
|
+
* disagreeing about a flag's value is the exact bug typing this was for.
|
|
747
|
+
*/
|
|
748
|
+
export declare interface ResolvedFlagVariant {
|
|
749
|
+
readonly name: string;
|
|
750
|
+
readonly value: unknown;
|
|
751
|
+
readonly enabled: boolean;
|
|
752
|
+
}
|
|
753
|
+
|
|
286
754
|
/**
|
|
287
755
|
* Resolve WHICH variant a subject gets — deterministic + stable, weighted by
|
|
288
756
|
* each variant's `weight` (equal split when omitted). Buckets on a `variant`-
|
|
@@ -297,9 +765,92 @@ export declare const resolveVariant: (key: string, def: FlagDefinition, subject:
|
|
|
297
765
|
* persists only when a postgres overlay store is wired. */
|
|
298
766
|
export declare const setFlagEnabled: (key: string, enabled: boolean) => boolean;
|
|
299
767
|
|
|
768
|
+
export declare const startFlagUsageFlush: (options: FlagUsageFlushOptions) => FlagUsageFlushHandle;
|
|
769
|
+
|
|
300
770
|
/** Normalise a bare boolean to a definition. */
|
|
301
771
|
export declare const toDefinition: (value: FlagValue) => FlagDefinition;
|
|
302
772
|
|
|
773
|
+
/**
|
|
774
|
+
* A flag with a value type.
|
|
775
|
+
*
|
|
776
|
+
* `out A` is load-bearing rather than decorative: the plugin holds these in one
|
|
777
|
+
* `ReadonlyArray<TypedFlag<unknown>>`, which is only sound while every member
|
|
778
|
+
* that mentions `A` is a read position. If you add a field that CONSUMES an `A`
|
|
779
|
+
* (a `(value: A) => …` callback, say), that annotation is what will stop you —
|
|
780
|
+
* and the fix is to erase it behind a function returning a result, the way
|
|
781
|
+
* `decode` already does, not to drop the annotation.
|
|
782
|
+
*/
|
|
783
|
+
export declare interface TypedFlag<out A> {
|
|
784
|
+
/** Brand — lets the plugin discriminate a typed flag from a bare `FlagValue`. */
|
|
785
|
+
readonly _voltroTypedFlag: true;
|
|
786
|
+
/** The flag key. Shares the flag namespace with `flags: { … }` entries; a
|
|
787
|
+
* duplicate is refused at plugin construction. */
|
|
788
|
+
readonly key: string;
|
|
789
|
+
/** Human label of the value Schema (`number`, `"a" | "b"`, …) — for the
|
|
790
|
+
* dashboard panel and for the refusal message when an override fails to
|
|
791
|
+
* decode. Derived from the Schema's AST, so it cannot drift from it. */
|
|
792
|
+
readonly valueType: string;
|
|
793
|
+
/** The value served whenever the flag does not resolve to a variant: it is
|
|
794
|
+
* off for this caller, or it declares no variants at all. Compile-checked
|
|
795
|
+
* against the Schema. */
|
|
796
|
+
readonly defaultValue: A;
|
|
797
|
+
/** The value arms. Empty ⇒ the flag always serves `defaultValue` (which the
|
|
798
|
+
* lifecycle report classifies as `constantOn`/`constantOff` — a flag that
|
|
799
|
+
* cannot serve anything else is one to delete). */
|
|
800
|
+
readonly variants: ReadonlyArray<TypedFlagVariant<A>>;
|
|
801
|
+
/** Decode an untrusted value against the flag's Schema — the runtime half of
|
|
802
|
+
* the contract `default` gets at compile time. */
|
|
803
|
+
readonly decode: (input: unknown) => FlagDecodeResult<A>;
|
|
804
|
+
/** The evaluator-facing definition. This is what goes into the registry the
|
|
805
|
+
* existing `evaluateFlag` / `resolveVariant` already read; nothing about
|
|
806
|
+
* evaluation changes because a flag is typed. */
|
|
807
|
+
readonly definition: FlagDefinition;
|
|
808
|
+
/**
|
|
809
|
+
* IN-11 — the `defineExperiment` this flag's variants report uplift for.
|
|
810
|
+
*
|
|
811
|
+
* A NAME rather than the definition object, on purpose: `defineExperiment`
|
|
812
|
+
* lives in `@voltro/runtime`, which is server-only, and this module has to
|
|
813
|
+
* stay loadable in a browser bundle. The link is validated at BOOT, where
|
|
814
|
+
* both sides are known (`assertFlagExperimentLinkage`) — a flag naming an
|
|
815
|
+
* experiment that does not exist, or whose arms disagree with the flag's,
|
|
816
|
+
* refuses to start rather than reporting uplift for arms nobody is served.
|
|
817
|
+
*/
|
|
818
|
+
readonly experiment?: string;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** One named arm of a typed flag. `value` is checked against the flag's Schema
|
|
822
|
+
* at COMPILE time. Weights behave exactly as `FlagVariant.weight`. */
|
|
823
|
+
export declare interface TypedFlagVariant<out A> {
|
|
824
|
+
readonly name: string;
|
|
825
|
+
readonly value: A;
|
|
826
|
+
/** Relative allocation weight. Omit ⇒ equal split. */
|
|
827
|
+
readonly weight?: number;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* The typed value for a resolution.
|
|
832
|
+
*
|
|
833
|
+
* DECODE FIRST, fall back to `defaultValue` — never branch on `enabled`. That
|
|
834
|
+
* ordering is what makes every case come out right with one rule:
|
|
835
|
+
*
|
|
836
|
+
* - off, boolean-typed flag → the resolution's value IS `false`, and `false`
|
|
837
|
+
* decodes against `Schema.Boolean`. The caller gets `false`, not whatever
|
|
838
|
+
* the author happened to write as `default`.
|
|
839
|
+
* - off, value-typed flag → the resolution's value is the boolean `false`,
|
|
840
|
+
* which does NOT decode against `Schema.Number` / a literal union, so the
|
|
841
|
+
* caller gets `defaultValue`. Correct, and reached without a special case.
|
|
842
|
+
* - on, with variants → the served variant's value decodes.
|
|
843
|
+
* - unknown / not yet loaded → no resolution at all → `defaultValue`.
|
|
844
|
+
*
|
|
845
|
+
* A decode failure is silent HERE on purpose: this runs per read, on the hot
|
|
846
|
+
* path, and the values that can reach it are gated at the point they ENTER the
|
|
847
|
+
* registry (`gateOverride`), where there is a logger and a boot to fail.
|
|
848
|
+
*/
|
|
849
|
+
export declare const typedValueOf: <A>(flag: TypedFlag<A>, resolution: ResolvedFlagVariant | undefined) => A;
|
|
850
|
+
|
|
851
|
+
/** UTC calendar day of an instant, `YYYY-MM-DD`. */
|
|
852
|
+
export declare const utcDay: (at: Date) => string;
|
|
853
|
+
|
|
303
854
|
/** The result of resolving a multivariate flag for a subject. */
|
|
304
855
|
export declare interface VariantResolution {
|
|
305
856
|
/** The resolved variant name. */
|