@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.
- package/README.md +97 -31
- package/package.json +34 -21
- package/src/console/flag_activate.ts +60 -0
- package/src/console/flag_console_provider.ts +25 -0
- package/src/console/flag_deactivate.ts +37 -0
- package/src/console/flag_list.ts +32 -0
- package/src/console/flag_purge.ts +38 -0
- package/src/console/index.ts +5 -0
- package/src/drivers/memory/index.ts +1 -0
- package/src/drivers/memory/memory_flag_store.ts +94 -0
- package/src/drivers/postgres/apply_flag_migration.ts +32 -0
- package/src/drivers/postgres/index.ts +10 -0
- package/src/drivers/postgres/postgres_flag_provider.ts +49 -0
- package/src/drivers/postgres/postgres_flag_store.ts +132 -0
- package/src/feature_store.ts +17 -10
- package/src/flag_error.ts +29 -0
- package/src/flag_manager.ts +205 -272
- package/src/flag_provider.ts +43 -19
- package/src/http/ensure_feature.ts +56 -0
- package/src/http/index.ts +1 -0
- package/src/index.ts +28 -38
- package/src/pending_scope.ts +26 -14
- package/src/types.ts +60 -36
- package/src/commands/flag_commands.ts +0 -99
- package/src/drivers/array_driver.ts +0 -79
- package/src/drivers/database_driver.ts +0 -139
- package/src/errors.ts +0 -25
- package/src/helpers.ts +0 -109
- package/src/middleware/ensure_feature.ts +0 -36
- package/stubs/config/flag.ts +0 -16
- package/tsconfig.json +0 -5
package/README.md
CHANGED
|
@@ -1,65 +1,131 @@
|
|
|
1
1
|
# @strav/flag
|
|
2
2
|
|
|
3
|
-
Feature flags for
|
|
3
|
+
Feature flags for [Strav 1.x](https://github.com/strav-dev/strav-1.x). Define
|
|
4
|
+
flags, scope them per user / team / tenant, persist resolutions in-memory or in
|
|
5
|
+
Postgres, and gate routes with an HTTP middleware. Mirrors the shape of
|
|
6
|
+
`@strav/cache` — kernel-free core in the root, drivers and integrations under
|
|
7
|
+
subpaths.
|
|
4
8
|
|
|
5
9
|
## Install
|
|
6
10
|
|
|
7
11
|
```bash
|
|
8
12
|
bun add @strav/flag
|
|
9
|
-
bun strav install flag
|
|
10
13
|
```
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
## Setup
|
|
15
|
+
## Quick start
|
|
15
16
|
|
|
16
17
|
```ts
|
|
17
|
-
import { FlagProvider } from '@strav/flag'
|
|
18
|
+
import { FlagManager, FlagProvider } from '@strav/flag'
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
// Wire the provider (in-memory store by default).
|
|
21
|
+
new FlagProvider({
|
|
22
|
+
define(flags) {
|
|
23
|
+
flags.define('beta-ui', (scope) => scope.startsWith('User:1'))
|
|
24
|
+
flags.define('upload-limit', 25)
|
|
25
|
+
},
|
|
26
|
+
})
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
// Resolve and read.
|
|
29
|
+
const flags = app.resolve(FlagManager)
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
if (await flags.active('beta-ui', user)) { ... }
|
|
32
|
+
if (await flags.for(team).active('analytics')) { ... }
|
|
33
|
+
|
|
34
|
+
const limit = await flags.value('upload-limit') // 25
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Drivers
|
|
26
38
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
39
|
+
| Subpath | Driver | Use case |
|
|
40
|
+
| ----------------------- | --------------------- | -------------------------------------- |
|
|
41
|
+
| (root) | `MemoryFlagStore` | dev / tests / single-process apps |
|
|
42
|
+
| `@strav/flag/postgres` | `PostgresFlagStore` | multi-node deployments (cross-process) |
|
|
31
43
|
|
|
32
|
-
|
|
33
|
-
if (await flag.for(user).active('beta-dashboard')) {
|
|
34
|
-
// enabled for this user
|
|
35
|
-
}
|
|
44
|
+
Swap providers — both register under the same `FlagManager` token:
|
|
36
45
|
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
```ts
|
|
47
|
+
import { PostgresFlagProvider, applyFlagMigration } from '@strav/flag/postgres'
|
|
48
|
+
|
|
49
|
+
// Migration:
|
|
50
|
+
await applyFlagMigration(db)
|
|
51
|
+
|
|
52
|
+
// Provider:
|
|
53
|
+
providers: [
|
|
54
|
+
new PostgresFlagProvider({
|
|
55
|
+
define(flags) {
|
|
56
|
+
flags.define('beta-ui', (scope) => scope.startsWith('User:'))
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
]
|
|
39
60
|
```
|
|
40
61
|
|
|
41
|
-
##
|
|
62
|
+
## HTTP guard
|
|
42
63
|
|
|
43
64
|
```ts
|
|
44
|
-
import { ensureFeature } from '@strav/flag'
|
|
65
|
+
import { ensureFeature } from '@strav/flag/http'
|
|
45
66
|
|
|
46
|
-
router.get('/beta', ensureFeature('beta-
|
|
67
|
+
router.get('/beta', ensureFeature('beta-ui'), betaHandler)
|
|
68
|
+
router.group(
|
|
69
|
+
{ middleware: [authMiddleware(), ensureFeature('analytics')] },
|
|
70
|
+
(r) => r.get('/insights', insights),
|
|
71
|
+
)
|
|
47
72
|
```
|
|
48
73
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
- **Database** — persistent feature flags in `_strav_features`
|
|
52
|
-
- **Array** — in-memory driver for testing
|
|
74
|
+
Default scope is `ctx.auth?.user`. Pass `scopeFrom` for team / tenant / custom
|
|
75
|
+
subjects.
|
|
53
76
|
|
|
54
77
|
## CLI
|
|
55
78
|
|
|
56
79
|
```bash
|
|
57
|
-
bun strav flag:
|
|
80
|
+
bun strav flag:list # list stored flags + values
|
|
81
|
+
bun strav flag:activate beta-ui # global on
|
|
82
|
+
bun strav flag:activate beta-ui '"control"' --scope User:42
|
|
83
|
+
bun strav flag:deactivate beta-ui --scope User:42
|
|
84
|
+
bun strav flag:purge beta-ui # drop stored values → re-resolve
|
|
58
85
|
```
|
|
59
86
|
|
|
60
|
-
|
|
87
|
+
Register the commands provider alongside the flag provider:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { FlagConsoleProvider } from '@strav/flag/console'
|
|
61
91
|
|
|
62
|
-
|
|
92
|
+
providers: [
|
|
93
|
+
new FlagProvider({ /* … */ }),
|
|
94
|
+
new FlagConsoleProvider(),
|
|
95
|
+
]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Strict scopes
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
// config/flag.ts
|
|
102
|
+
export default { strictScopes: true }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
With `strictScopes: true`, reading a flag without a scope throws
|
|
106
|
+
`MissingScopeError` instead of silently evaluating the global value. Catches the
|
|
107
|
+
common bug where middleware forgets to pass `ctx.auth.user`. Writes keep the
|
|
108
|
+
loose semantics — `activateForEveryone()` makes a global write explicit.
|
|
109
|
+
|
|
110
|
+
## Audit hook
|
|
111
|
+
|
|
112
|
+
`activate` / `deactivate` accept an optional `actor: { type, id }` that flows
|
|
113
|
+
into the `flag:updated` event payload alongside `previous` (the prior stored
|
|
114
|
+
value). Subscribe via `EventBus` to wire an audit log without coupling
|
|
115
|
+
`@strav/flag` to `@strav/audit`:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
app.resolve(EventBus).on('flag:updated', (e) => {
|
|
119
|
+
if (!e.actor) return
|
|
120
|
+
auditLog.record({
|
|
121
|
+
actor: e.actor,
|
|
122
|
+
on: 'feature_flag',
|
|
123
|
+
feature: e.feature,
|
|
124
|
+
scope: e.scope,
|
|
125
|
+
diff: { from: e.previous, to: e.value },
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
```
|
|
63
129
|
|
|
64
130
|
## License
|
|
65
131
|
|
package/package.json
CHANGED
|
@@ -1,33 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/flag",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-alpha.42",
|
|
4
|
+
"description": "Strav feature-flag primitive — FlagManager + scope-aware resolution + in-memory store, with Postgres store, HTTP middleware, and CLI commands shipped under subpaths. Mirrors @strav/cache: kernel-free core in the root, every backend/integration under its own subpath.",
|
|
4
5
|
"type": "module",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
7
8
|
"exports": {
|
|
8
9
|
".": "./src/index.ts",
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
10
|
+
"./memory": "./src/drivers/memory/index.ts",
|
|
11
|
+
"./postgres": "./src/drivers/postgres/index.ts",
|
|
12
|
+
"./http": "./src/http/index.ts",
|
|
13
|
+
"./console": "./src/console/index.ts"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
|
-
"src
|
|
16
|
-
"
|
|
17
|
-
"package.json",
|
|
18
|
-
"tsconfig.json"
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
19
18
|
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"bun": ">=1.3.14"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@strav/kernel": "1.0.0-alpha.42"
|
|
27
|
+
},
|
|
20
28
|
"peerDependencies": {
|
|
21
|
-
"@strav/
|
|
22
|
-
"@strav/database": "0.
|
|
23
|
-
"@strav/http": "0.
|
|
24
|
-
"@
|
|
29
|
+
"@strav/cli": "1.0.0-alpha.42",
|
|
30
|
+
"@strav/database": "1.0.0-alpha.42",
|
|
31
|
+
"@strav/http": "1.0.0-alpha.42",
|
|
32
|
+
"@types/bun": ">=1.3.14"
|
|
25
33
|
},
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
|
|
34
|
+
"peerDependenciesMeta": {
|
|
35
|
+
"@strav/cli": {
|
|
36
|
+
"optional": true
|
|
37
|
+
},
|
|
38
|
+
"@strav/database": {
|
|
39
|
+
"optional": true
|
|
40
|
+
},
|
|
41
|
+
"@strav/http": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
29
44
|
},
|
|
30
|
-
"devDependencies":
|
|
31
|
-
"commander": "^14.0.3"
|
|
32
|
-
}
|
|
45
|
+
"devDependencies": null
|
|
33
46
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav flag:activate <feature> [value] [--scope <Type:id>]` —
|
|
3
|
+
* turn a flag on, optionally with a rich JSON value, for a specific
|
|
4
|
+
* scope or globally (default).
|
|
5
|
+
*
|
|
6
|
+
* The scope format mirrors the on-disk serialization (`User:42`,
|
|
7
|
+
* `Team:7`) — passing one is sufficient because the operator pins the
|
|
8
|
+
* scope manually; the runtime resolver pulls scopes off live request
|
|
9
|
+
* state, where the type prefix is derived automatically.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Command, type ExecuteArgs, ExitCode } from '@strav/cli'
|
|
13
|
+
import { FlagError } from '../flag_error.ts'
|
|
14
|
+
import { FlagManager } from '../flag_manager.ts'
|
|
15
|
+
|
|
16
|
+
export class FlagActivate extends Command {
|
|
17
|
+
static signature = 'flag:activate {feature} {value?} {--scope=}'
|
|
18
|
+
static description = 'Turn a flag on, optionally with a JSON value, for a scope or globally.'
|
|
19
|
+
static providers = ['config', 'logger', 'flag']
|
|
20
|
+
|
|
21
|
+
override async execute({ args, flags: cli }: ExecuteArgs): Promise<number> {
|
|
22
|
+
const flags = this.app.resolve(FlagManager)
|
|
23
|
+
const feature = args.feature as string
|
|
24
|
+
const raw = args.value as string | undefined
|
|
25
|
+
const scope = cli.scope as string | undefined
|
|
26
|
+
|
|
27
|
+
const value = raw !== undefined ? parseValue(raw) : true
|
|
28
|
+
|
|
29
|
+
if (scope === undefined) {
|
|
30
|
+
await flags.activateForEveryone(feature, value)
|
|
31
|
+
this.success(`Activated "${feature}" globally${raw ? ` = ${raw}` : ''}.`)
|
|
32
|
+
} else {
|
|
33
|
+
await flags.activate(feature, value, scopeFromKey(scope))
|
|
34
|
+
this.success(`Activated "${feature}" for ${scope}${raw ? ` = ${raw}` : ''}.`)
|
|
35
|
+
}
|
|
36
|
+
return ExitCode.Success
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseValue(raw: string): unknown {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(raw)
|
|
43
|
+
} catch {
|
|
44
|
+
return raw
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// CLI helper: turn `User:42` into a fake Scopeable so the manager
|
|
49
|
+
// serializes it back to the same `User:42` key. The runtime
|
|
50
|
+
// resolution path always works on live objects with `id`; this is the
|
|
51
|
+
// inverse used only at the CLI boundary.
|
|
52
|
+
function scopeFromKey(key: string): { id: string | number; featureScope: () => string } {
|
|
53
|
+
const colon = key.indexOf(':')
|
|
54
|
+
if (colon <= 0) {
|
|
55
|
+
throw new FlagError(`--scope must look like "Type:id" (e.g. "User:42"); got "${key}".`)
|
|
56
|
+
}
|
|
57
|
+
const type = key.slice(0, colon)
|
|
58
|
+
const id: string | number = key.slice(colon + 1)
|
|
59
|
+
return { id, featureScope: () => type }
|
|
60
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FlagConsoleProvider` — declares the `flag:*` console commands.
|
|
3
|
+
*
|
|
4
|
+
* Apps register it alongside the flag provider:
|
|
5
|
+
*
|
|
6
|
+
* providers: [
|
|
7
|
+
* new FlagProvider(), // or PostgresFlagProvider from @strav/flag/postgres
|
|
8
|
+
* new FlagConsoleProvider(),
|
|
9
|
+
* ]
|
|
10
|
+
*
|
|
11
|
+
* Separate provider so apps without a CLI don't pay the cost of
|
|
12
|
+
* resolving commands at boot — same pattern as `CacheConsoleProvider`
|
|
13
|
+
* and `QueueConsoleProvider`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ConsoleProvider } from '@strav/cli'
|
|
17
|
+
import { FlagActivate } from './flag_activate.ts'
|
|
18
|
+
import { FlagDeactivate } from './flag_deactivate.ts'
|
|
19
|
+
import { FlagList } from './flag_list.ts'
|
|
20
|
+
import { FlagPurge } from './flag_purge.ts'
|
|
21
|
+
|
|
22
|
+
export class FlagConsoleProvider extends ConsoleProvider {
|
|
23
|
+
override readonly name = 'console.flag'
|
|
24
|
+
override readonly commands = [FlagList, FlagPurge, FlagActivate, FlagDeactivate] as const
|
|
25
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav flag:deactivate <feature> [--scope <Type:id>]` — turn a
|
|
3
|
+
* flag off for a specific scope or globally (default).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Command, type ExecuteArgs, ExitCode } from '@strav/cli'
|
|
7
|
+
import { FlagError } from '../flag_error.ts'
|
|
8
|
+
import { FlagManager } from '../flag_manager.ts'
|
|
9
|
+
|
|
10
|
+
export class FlagDeactivate extends Command {
|
|
11
|
+
static signature = 'flag:deactivate {feature} {--scope=}'
|
|
12
|
+
static description = 'Turn a flag off for a scope or globally.'
|
|
13
|
+
static providers = ['config', 'logger', 'flag']
|
|
14
|
+
|
|
15
|
+
override async execute({ args, flags: cli }: ExecuteArgs): Promise<number> {
|
|
16
|
+
const flags = this.app.resolve(FlagManager)
|
|
17
|
+
const feature = args.feature as string
|
|
18
|
+
const scope = cli.scope as string | undefined
|
|
19
|
+
|
|
20
|
+
if (scope === undefined) {
|
|
21
|
+
await flags.deactivateForEveryone(feature)
|
|
22
|
+
this.success(`Deactivated "${feature}" globally.`)
|
|
23
|
+
} else {
|
|
24
|
+
await flags.deactivate(feature, scopeFromKey(scope))
|
|
25
|
+
this.success(`Deactivated "${feature}" for ${scope}.`)
|
|
26
|
+
}
|
|
27
|
+
return ExitCode.Success
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function scopeFromKey(key: string): { id: string | number; featureScope: () => string } {
|
|
32
|
+
const colon = key.indexOf(':')
|
|
33
|
+
if (colon <= 0) {
|
|
34
|
+
throw new FlagError(`--scope must look like "Type:id" (e.g. "User:42"); got "${key}".`)
|
|
35
|
+
}
|
|
36
|
+
return { id: key.slice(colon + 1), featureScope: () => key.slice(0, colon) }
|
|
37
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav flag:list` — list every feature with at least one stored
|
|
3
|
+
* value and its per-scope evaluations. Read-only.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Command, type ExecuteArgs, ExitCode } from '@strav/cli'
|
|
7
|
+
import { FlagManager } from '../flag_manager.ts'
|
|
8
|
+
|
|
9
|
+
export class FlagList extends Command {
|
|
10
|
+
static signature = 'flag:list'
|
|
11
|
+
static description = 'List stored feature flags and their per-scope values.'
|
|
12
|
+
static providers = ['config', 'logger', 'flag']
|
|
13
|
+
|
|
14
|
+
override async execute(_args: ExecuteArgs): Promise<number> {
|
|
15
|
+
const flags = this.app.resolve(FlagManager)
|
|
16
|
+
const names = await flags.stored()
|
|
17
|
+
if (names.length === 0) {
|
|
18
|
+
this.info('No stored feature flags.')
|
|
19
|
+
return ExitCode.Success
|
|
20
|
+
}
|
|
21
|
+
this.info(`Stored feature flags (${names.length}):`)
|
|
22
|
+
for (const name of names) {
|
|
23
|
+
const records = await flags.driver().allFor(name)
|
|
24
|
+
this.info(` ${name} (${records.length} scope${records.length === 1 ? '' : 's'})`)
|
|
25
|
+
for (const r of records) {
|
|
26
|
+
const printed = typeof r.value === 'boolean' ? String(r.value) : JSON.stringify(r.value)
|
|
27
|
+
this.info(` ${r.scope} → ${printed}`)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return ExitCode.Success
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav flag:purge [feature] [--all] [--force]` — drop stored
|
|
3
|
+
* flag values so resolvers re-evaluate. Without `--force`, prompts
|
|
4
|
+
* before running.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Command, type ExecuteArgs, ExitCode } from '@strav/cli'
|
|
8
|
+
import { FlagManager } from '../flag_manager.ts'
|
|
9
|
+
|
|
10
|
+
export class FlagPurge extends Command {
|
|
11
|
+
static signature = 'flag:purge {feature?} {--all} {--force}'
|
|
12
|
+
static description = 'Drop stored flag values (forces re-resolution on next read).'
|
|
13
|
+
static providers = ['config', 'logger', 'flag']
|
|
14
|
+
|
|
15
|
+
override async execute({ args, flags: cli }: ExecuteArgs): Promise<number> {
|
|
16
|
+
const flags = this.app.resolve(FlagManager)
|
|
17
|
+
const feature = args.feature as string | undefined
|
|
18
|
+
const all = cli.all === true || !feature
|
|
19
|
+
|
|
20
|
+
if (cli.force !== true) {
|
|
21
|
+
const target = all ? 'EVERY feature flag' : `feature "${feature}"`
|
|
22
|
+
const ok = await this.confirm(`Purge ${target}? This drops stored values for all scopes.`)
|
|
23
|
+
if (!ok) {
|
|
24
|
+
this.info('Aborted.')
|
|
25
|
+
return ExitCode.Success
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (all) {
|
|
30
|
+
await flags.purgeAll()
|
|
31
|
+
this.success('All feature flags purged.')
|
|
32
|
+
} else {
|
|
33
|
+
await flags.purge(feature as string)
|
|
34
|
+
this.success(`Feature "${feature}" purged.`)
|
|
35
|
+
}
|
|
36
|
+
return ExitCode.Success
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MemoryFlagStore } from './memory_flag_store.ts'
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MemoryFlagStore` — in-process feature flag store backed by a single
|
|
3
|
+
* `Map`. Right driver for dev / tests / per-process resolutions that
|
|
4
|
+
* don't need cross-process visibility. Wrong driver for multi-node
|
|
5
|
+
* deployments — each node sees its own copy of every flag.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FeatureStore } from '../../feature_store.ts'
|
|
9
|
+
import type { ScopeKey, StoredFeature } from '../../types.ts'
|
|
10
|
+
|
|
11
|
+
interface Entry {
|
|
12
|
+
value: unknown
|
|
13
|
+
createdAt: Date
|
|
14
|
+
updatedAt: Date
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class MemoryFlagStore implements FeatureStore {
|
|
18
|
+
readonly name = 'memory'
|
|
19
|
+
private readonly data = new Map<string, Entry>()
|
|
20
|
+
|
|
21
|
+
private key(feature: string, scope: ScopeKey): string {
|
|
22
|
+
return `${feature}\0${scope}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async get(feature: string, scope: ScopeKey): Promise<unknown | undefined> {
|
|
26
|
+
return this.data.get(this.key(feature, scope))?.value
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async getMany(features: string[], scope: ScopeKey): Promise<Map<string, unknown>> {
|
|
30
|
+
const result = new Map<string, unknown>()
|
|
31
|
+
for (const f of features) {
|
|
32
|
+
const e = this.data.get(this.key(f, scope))
|
|
33
|
+
if (e !== undefined) result.set(f, e.value)
|
|
34
|
+
}
|
|
35
|
+
return result
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async set(feature: string, scope: ScopeKey, value: unknown): Promise<void> {
|
|
39
|
+
const k = this.key(feature, scope)
|
|
40
|
+
const existing = this.data.get(k)
|
|
41
|
+
const now = new Date()
|
|
42
|
+
this.data.set(k, {
|
|
43
|
+
value,
|
|
44
|
+
createdAt: existing?.createdAt ?? now,
|
|
45
|
+
updatedAt: now,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async setMany(
|
|
50
|
+
entries: Array<{ feature: string; scope: ScopeKey; value: unknown }>,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
for (const e of entries) await this.set(e.feature, e.scope, e.value)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async forget(feature: string, scope: ScopeKey): Promise<void> {
|
|
56
|
+
this.data.delete(this.key(feature, scope))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async purge(feature: string): Promise<void> {
|
|
60
|
+
const prefix = `${feature}\0`
|
|
61
|
+
for (const k of this.data.keys()) {
|
|
62
|
+
if (k.startsWith(prefix)) this.data.delete(k)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async purgeAll(): Promise<void> {
|
|
67
|
+
this.data.clear()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async featureNames(): Promise<string[]> {
|
|
71
|
+
const names = new Set<string>()
|
|
72
|
+
for (const k of this.data.keys()) {
|
|
73
|
+
const sep = k.indexOf('\0')
|
|
74
|
+
if (sep > 0) names.add(k.slice(0, sep))
|
|
75
|
+
}
|
|
76
|
+
return [...names].sort()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async allFor(feature: string): Promise<StoredFeature[]> {
|
|
80
|
+
const prefix = `${feature}\0`
|
|
81
|
+
const out: StoredFeature[] = []
|
|
82
|
+
for (const [k, e] of this.data) {
|
|
83
|
+
if (!k.startsWith(prefix)) continue
|
|
84
|
+
out.push({
|
|
85
|
+
feature,
|
|
86
|
+
scope: k.slice(prefix.length),
|
|
87
|
+
value: e.value,
|
|
88
|
+
createdAt: e.createdAt,
|
|
89
|
+
updatedAt: e.updatedAt,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `applyFlagMigration` — emit the DDL `PostgresFlagStore` requires.
|
|
3
|
+
*
|
|
4
|
+
* strav_flags (
|
|
5
|
+
* feature text NOT NULL,
|
|
6
|
+
* scope text NOT NULL,
|
|
7
|
+
* value jsonb NOT NULL,
|
|
8
|
+
* created_at timestamptz NOT NULL DEFAULT now(),
|
|
9
|
+
* updated_at timestamptz NOT NULL DEFAULT now(),
|
|
10
|
+
* PRIMARY KEY (feature, scope)
|
|
11
|
+
* )
|
|
12
|
+
*
|
|
13
|
+
* Not registered with the SchemaRegistry — the table shape (composite
|
|
14
|
+
* PK, jsonb value column) doesn't fit the schema DSL cleanly, and
|
|
15
|
+
* the table is package-internal infrastructure rather than an app
|
|
16
|
+
* model. Call from a migration `up()`; `DROP TABLE` in `down()`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { DatabaseExecutor } from '@strav/database'
|
|
20
|
+
|
|
21
|
+
export async function applyFlagMigration(db: DatabaseExecutor): Promise<void> {
|
|
22
|
+
await db.execute(
|
|
23
|
+
`CREATE TABLE IF NOT EXISTS "strav_flags" (
|
|
24
|
+
"feature" text NOT NULL,
|
|
25
|
+
"scope" text NOT NULL,
|
|
26
|
+
"value" jsonb NOT NULL,
|
|
27
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
28
|
+
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
|
29
|
+
PRIMARY KEY ("feature", "scope")
|
|
30
|
+
)`,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { applyFlagMigration } from './apply_flag_migration.ts'
|
|
2
|
+
export {
|
|
3
|
+
PostgresFlagProvider,
|
|
4
|
+
type PostgresFlagProviderOptions,
|
|
5
|
+
} from './postgres_flag_provider.ts'
|
|
6
|
+
export {
|
|
7
|
+
type PostgresFlagDatabase,
|
|
8
|
+
PostgresFlagStore,
|
|
9
|
+
type PostgresFlagStoreOptions,
|
|
10
|
+
} from './postgres_flag_store.ts'
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PostgresFlagProvider` — wires `FlagManager` with `PostgresFlagStore`.
|
|
3
|
+
* Apps register this INSTEAD OF `FlagProvider` to swap the in-memory
|
|
4
|
+
* store for the cross-process Postgres backplane. Both providers
|
|
5
|
+
* register under the same `FlagManager` token, so app code injecting
|
|
6
|
+
* the manager doesn't change between dev and prod.
|
|
7
|
+
*
|
|
8
|
+
* Schema creation is migration-driven (`applyFlagMigration`); the
|
|
9
|
+
* provider does NOT auto-create on boot.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { type Application, ConfigRepository, EventBus, ServiceProvider } from '@strav/kernel'
|
|
13
|
+
import { FlagManager } from '../../flag_manager.ts'
|
|
14
|
+
import type { FlagConfig } from '../../types.ts'
|
|
15
|
+
import { type PostgresFlagDatabase, PostgresFlagStore } from './postgres_flag_store.ts'
|
|
16
|
+
|
|
17
|
+
export interface PostgresFlagProviderOptions {
|
|
18
|
+
/** Run after the manager is created, before `boot` returns. Use for `define`. */
|
|
19
|
+
define?: (flags: FlagManager) => void | Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class PostgresFlagProvider extends ServiceProvider {
|
|
23
|
+
override readonly name = 'flag'
|
|
24
|
+
override readonly dependencies = ['config', 'database']
|
|
25
|
+
|
|
26
|
+
constructor(private readonly options: PostgresFlagProviderOptions = {}) {
|
|
27
|
+
super()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override register(app: Application): void {
|
|
31
|
+
app.singleton(FlagManager, (c) => {
|
|
32
|
+
// Resolve `Database` by string token to keep `@strav/database` an
|
|
33
|
+
// optional peer-dep — apps using `MemoryFlagStore` shouldn't
|
|
34
|
+
// pay for `@strav/database` in their bundle.
|
|
35
|
+
const db = c.resolve<PostgresFlagDatabase>('database' as never)
|
|
36
|
+
const cfg = (c.resolve(ConfigRepository).get('flag') as FlagConfig | undefined) ?? {}
|
|
37
|
+
return new FlagManager({
|
|
38
|
+
store: new PostgresFlagStore({ db }),
|
|
39
|
+
events: c.resolve(EventBus),
|
|
40
|
+
config: cfg,
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
override async boot(app: Application): Promise<void> {
|
|
46
|
+
const flags = app.resolve(FlagManager)
|
|
47
|
+
if (this.options.define) await this.options.define(flags)
|
|
48
|
+
}
|
|
49
|
+
}
|