@strav/flag 0.4.31 → 1.0.0-alpha.42

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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `PostgresFlagStore` — cross-process feature flag store backed by a
3
+ * single `strav_flags` table (key: `(feature, scope)`). Values live in
4
+ * a `jsonb` column so booleans, numbers, strings, and rich variant
5
+ * payloads all round-trip.
6
+ *
7
+ * Bun's SQL driver hydrates jsonb scalars natively but returns
8
+ * jsonb objects/arrays as their JSON-encoded text — the read path
9
+ * `JSON.parse`s strings opportunistically and falls back to the raw
10
+ * string when parsing fails (so a string-scalar stored as `"hello"`
11
+ * round-trips correctly without us claiming `hello` is an object).
12
+ * Same pattern as `@strav/cache`'s Postgres driver.
13
+ *
14
+ * The runtime peer-dep on `@strav/database` is *optional* — apps
15
+ * using `MemoryFlagStore` shouldn't pull `@strav/database` into their
16
+ * bundle. The minimum surface is declared inline as
17
+ * `PostgresFlagDatabase`.
18
+ */
19
+
20
+ import type { FeatureStore } from '../../feature_store.ts'
21
+ import type { ScopeKey, StoredFeature } from '../../types.ts'
22
+
23
+ export interface PostgresFlagDatabase {
24
+ query<T = Record<string, unknown>>(sql: string, params?: readonly unknown[]): Promise<T[]>
25
+ execute(sql: string, params?: readonly unknown[]): Promise<number>
26
+ }
27
+
28
+ export interface PostgresFlagStoreOptions {
29
+ db: PostgresFlagDatabase
30
+ }
31
+
32
+ const TABLE = '"strav_flags"'
33
+
34
+ export class PostgresFlagStore implements FeatureStore {
35
+ readonly name = 'postgres'
36
+ private readonly db: PostgresFlagDatabase
37
+
38
+ constructor(options: PostgresFlagStoreOptions) {
39
+ this.db = options.db
40
+ }
41
+
42
+ async get(feature: string, scope: ScopeKey): Promise<unknown | undefined> {
43
+ const rows = await this.db.query<{ value: unknown }>(
44
+ `SELECT "value" FROM ${TABLE} WHERE "feature" = $1 AND "scope" = $2 LIMIT 1`,
45
+ [feature, scope],
46
+ )
47
+ if (rows.length === 0) return undefined
48
+ return hydrate(rows[0]?.value)
49
+ }
50
+
51
+ async getMany(features: string[], scope: ScopeKey): Promise<Map<string, unknown>> {
52
+ const out = new Map<string, unknown>()
53
+ if (features.length === 0) return out
54
+ const placeholders = features.map((_, i) => `$${i + 2}`).join(', ')
55
+ const rows = await this.db.query<{ feature: string; value: unknown }>(
56
+ `SELECT "feature", "value" FROM ${TABLE}
57
+ WHERE "scope" = $1 AND "feature" IN (${placeholders})`,
58
+ [scope, ...features],
59
+ )
60
+ for (const r of rows) out.set(r.feature, hydrate(r.value))
61
+ return out
62
+ }
63
+
64
+ async set(feature: string, scope: ScopeKey, value: unknown): Promise<void> {
65
+ const json = JSON.stringify(value)
66
+ await this.db.execute(
67
+ `INSERT INTO ${TABLE} ("feature", "scope", "value", "created_at", "updated_at")
68
+ VALUES ($1, $2, $3::jsonb, now(), now())
69
+ ON CONFLICT ("feature", "scope")
70
+ DO UPDATE SET "value" = $3::jsonb, "updated_at" = now()`,
71
+ [feature, scope, json],
72
+ )
73
+ }
74
+
75
+ async setMany(
76
+ entries: Array<{ feature: string; scope: ScopeKey; value: unknown }>,
77
+ ): Promise<void> {
78
+ for (const e of entries) await this.set(e.feature, e.scope, e.value)
79
+ }
80
+
81
+ async forget(feature: string, scope: ScopeKey): Promise<void> {
82
+ await this.db.execute(`DELETE FROM ${TABLE} WHERE "feature" = $1 AND "scope" = $2`, [
83
+ feature,
84
+ scope,
85
+ ])
86
+ }
87
+
88
+ async purge(feature: string): Promise<void> {
89
+ await this.db.execute(`DELETE FROM ${TABLE} WHERE "feature" = $1`, [feature])
90
+ }
91
+
92
+ async purgeAll(): Promise<void> {
93
+ await this.db.execute(`DELETE FROM ${TABLE}`)
94
+ }
95
+
96
+ async featureNames(): Promise<string[]> {
97
+ const rows = await this.db.query<{ feature: string }>(
98
+ `SELECT DISTINCT "feature" FROM ${TABLE} ORDER BY "feature"`,
99
+ )
100
+ return rows.map((r) => r.feature)
101
+ }
102
+
103
+ async allFor(feature: string): Promise<StoredFeature[]> {
104
+ const rows = await this.db.query<{
105
+ feature: string
106
+ scope: string
107
+ value: unknown
108
+ created_at: Date
109
+ updated_at: Date
110
+ }>(
111
+ `SELECT "feature", "scope", "value", "created_at", "updated_at"
112
+ FROM ${TABLE} WHERE "feature" = $1 ORDER BY "scope"`,
113
+ [feature],
114
+ )
115
+ return rows.map((r) => ({
116
+ feature: r.feature,
117
+ scope: r.scope,
118
+ value: hydrate(r.value),
119
+ createdAt: r.created_at,
120
+ updatedAt: r.updated_at,
121
+ }))
122
+ }
123
+ }
124
+
125
+ function hydrate(raw: unknown): unknown {
126
+ if (typeof raw !== 'string') return raw
127
+ try {
128
+ return JSON.parse(raw)
129
+ } catch {
130
+ return raw
131
+ }
132
+ }
@@ -1,33 +1,40 @@
1
+ /**
2
+ * `FeatureStore` — contract every flag storage driver implements.
3
+ *
4
+ * The store is purely a persistence layer: it doesn't know about
5
+ * resolvers, the in-process cache, or scope semantics. The
6
+ * `FlagManager` composes the higher-level behavior on top.
7
+ */
8
+
1
9
  import type { ScopeKey, StoredFeature } from './types.ts'
2
10
 
3
- /** Contract that every feature flag storage driver must implement. */
4
11
  export interface FeatureStore {
5
12
  readonly name: string
6
13
 
7
- /** Retrieve the stored value. Returns `undefined` if not yet resolved. */
14
+ /** Retrieve a stored value. `undefined` if the pair has never been resolved. */
8
15
  get(feature: string, scope: ScopeKey): Promise<unknown | undefined>
9
16
 
10
- /** Retrieve stored values for multiple features for a single scope. */
17
+ /** Batch-read multiple features for a single scope. */
11
18
  getMany(features: string[], scope: ScopeKey): Promise<Map<string, unknown>>
12
19
 
13
- /** Store a resolved value (upsert). */
20
+ /** Upsert a value for `(feature, scope)`. */
14
21
  set(feature: string, scope: ScopeKey, value: unknown): Promise<void>
15
22
 
16
- /** Store multiple resolved values at once. */
23
+ /** Batch upsert. Drivers SHOULD do this in a single transaction. */
17
24
  setMany(entries: Array<{ feature: string; scope: ScopeKey; value: unknown }>): Promise<void>
18
25
 
19
- /** Remove the stored value for a feature+scope pair. */
26
+ /** Drop a single `(feature, scope)` entry. */
20
27
  forget(feature: string, scope: ScopeKey): Promise<void>
21
28
 
22
- /** Remove ALL stored values for a feature (all scopes). */
29
+ /** Drop every entry for `feature` across all scopes. */
23
30
  purge(feature: string): Promise<void>
24
31
 
25
- /** Remove all stored values for all features. */
32
+ /** Drop every entry for every feature. */
26
33
  purgeAll(): Promise<void>
27
34
 
28
- /** List all distinct feature names that have stored values. */
35
+ /** Distinct names of features that have at least one stored entry. */
29
36
  featureNames(): Promise<string[]>
30
37
 
31
- /** List all stored records for a feature. */
38
+ /** All stored records for `feature` (every scope it's been resolved against). */
32
39
  allFor(feature: string): Promise<StoredFeature[]>
33
40
  }
@@ -0,0 +1,29 @@
1
+ import { StravError } from '@strav/kernel'
2
+
3
+ export class FlagError extends StravError {
4
+ constructor(message: string) {
5
+ super(message, { code: 'flag-error', status: 500 })
6
+ }
7
+ }
8
+
9
+ export class FeatureNotDefinedError extends FlagError {
10
+ constructor(feature: string) {
11
+ super(`Feature "${feature}" is not defined. Register it with flagManager.define().`)
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Thrown when `flag.strictScopes` is enabled and a flag is evaluated
17
+ * without a scope. Catches the common bug where a per-user flag
18
+ * silently resolves the global value because the caller forgot to
19
+ * pass `ctx.auth.user`.
20
+ */
21
+ export class MissingScopeError extends FlagError {
22
+ constructor(feature: string) {
23
+ super(
24
+ `Feature "${feature}" was evaluated without a scope, but flag.strictScopes is enabled. ` +
25
+ `Pass an explicit scope (e.g. flags.for(user).value('${feature}')) or call ` +
26
+ `flags.activateForEveryone('${feature}') for genuinely global flags.`,
27
+ )
28
+ }
29
+ }