Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 103 additions & 14 deletions sdk/ts/flags/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,25 @@ createFlags(kv, opts)
snapshot.start()
├─ loadAll(): list(flags:def:*) + batchGet() → in-memory Map
├─ loadAll(): list(flags:def:*) + batchGet() → in-memory Map; track maxRevision
└─ opts.watch !== false:
kv.watch("flags:def:*") → stream deltas → apply to Map
on error: exponential backoff (1s→60s) + poll fallback
kv.watch("flags:def:*", { since: lastRevision }) → stream deltas → apply to Map
on hard error: exponential backoff (1s→60s) + poll fallback,
then re-watch from { since: lastRevision } (no gap loss)
opts.watch === false (or fallback):
setInterval(loadAll, refresh * 1000) [timer unref'd]
flag eval: snapshot.get(name) → O(1) → FlagDef | undefined
```

`lastRevision` is the highest revision seen from any read or watch delta. The
first watch starts from the revision the initial `loadAll` saw, and every
reconnect resumes from the last delta applied — so writes/deletes that land while
the stream is down are replayed (the server treats `since` as exclusive) rather
than lost. Re-applying an already-seen revision is a no-op (byte-compare dedup),
so over-replay is harmless.

### User pref mutation (CAS loop)

```
Expand Down Expand Up @@ -178,6 +186,77 @@ Different frameworks expose different hooks, requiring two propagation strategie

**Important for Next.js edge middleware**: the scope established in `middleware.ts` does **not** propagate into App Router route handlers — Next dispatches them in a separate async context. Route handlers should use explicit `await flag(ctx)` instead.

## Vercel Flags SDK Adapter (`src/adapter.ts`)

`@beyond.dev/flags/adapter` lets the Vercel [Flags SDK](https://flags-sdk.dev) (`flags/next`) resolve flags against Beyond KV. The host owns request plumbing, toolbar overrides, precompute, and `reportValue`; this module implements the one seam the host calls — the `Adapter` interface — by reusing the same `evaluate()` engine and KV reads as the native API. It is purely additive and shares no state with the ALS/scope machinery above (the host manages the request lifecycle).

```
flag({ key, adapter, identify }) ← user declares with the Vercel SDK
│ flag(request) / evaluate([...]) ← host (flags/next) drives evaluation
host: identify({headers,cookies}) → entities ; reads override cookie
▼ adapter.decide({ key, entities, headers, cookies, defaultValue })
beyondAdapter: entities → FlagContext (entities.id = bucket key)
│ def = DefSource.get(key) (snapshot or per-request)
│ prefs = fetchUserPrefs(id) (per-request cached)
evaluate(key, defaultValue, ctx, def, prefs) ← same pure engine as native eval
```

**Mapping**: `EntitiesType = FlagContext`; the host's `entities.id` is the rollout bucket key. The declared `defaultValue` is threaded into `evaluate()` rather than baked into KV, so precedence is identical to native usage. Missing `entities`/`id` → the declared default (can't bucket).

**Read modes** (`mode` option):

| Mode | Source | Best for |
| -------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------- |
| `snapshot` (default) | reuses the `Snapshot` class — in-memory, watch/poll, zero per-eval I/O | long-lived Node servers |
| `request` | per-request fetch cached in `WeakMap<ReadonlyHeaders, …>` (one `list`+`batchGet`/request) | short-lived edge/serverless (no persistent watch) |

**Batching**: a stable per-instance `adapterId` (Symbol) + `bulkDecide` let the host's `evaluate()` resolve all flags sharing one adapter **and** one `identify` reference in a single call. Distinct `identify` closures form distinct groups (one `bulkDecide` each).

**Discovery**: `adapter.getProviderData()` enumerates `flags:def:*` for the toolbar/`createFlagsDiscoveryEndpoint`. Definitions are thin (KV stores only `on`/`rules`/`rollout`, marked `declaredInCode: false`); merge with the host's `getProviderData(flags)` via `mergeProviderData` for code-side `options`/`description`/`defaultValue`.

**Not bridged**: native `.set()`/`.reset()` (pref writes) and `.when()` overrides have no Vercel equivalent — the adapter still _reads_ prefs (end-user opt-in honored), but writes stay on the native API; toolbar overrides replace `.when()`.

## OpenFeature Providers (`src/openfeature/`)

`@beyond.dev/flags/openfeature/server` and `.../web` expose the same eval engine to [OpenFeature](https://openfeature.dev/), the CNCF vendor-neutral flag standard. OpenFeature's `Provider` is the same one-method seam as the Vercel `Adapter`; both providers are thin shells over the same `evaluate()` + `Snapshot` + `fetchUserPrefs` and share no state with the ALS machinery.

Two entry points because OpenFeature ships server and web as **distinct packages with different `Provider` interfaces** — server `resolve*` is `async`, web `resolve*` is **synchronous** against a static context. A small SDK-agnostic `shared.ts` (imports only `@openfeature/core`) holds the mapping both reuse, so neither entry pulls the other SDK.

```
EvaluationContext (targetingKey + attrs)
│ toFlagContext() ← shared.ts
FlagContext { id, ...attrs }
│ def = Snapshot.get(key) ← reuse snapshot.ts
│ prefs = fetchUserPrefs(id) ← reuse snapshot.ts
evaluate(key, default, ctx, def, prefs) ← same pure engine as native/adapter
│ toResolution() + type-check
ResolutionDetails<T> { value, reason, errorCode?, flagMetadata? }
```

**Context mapping**: `EvaluationContext.targetingKey` → `FlagContext.id` (the rollout/pref bucket key); other attributes carry through for rule matching. A missing `targetingKey` maps to `id: ""` — rules on attributes still match, rollout/prefs are skipped (no throw, unlike native `flag.ts`).

**Reason mapping** (`EvalReason` → OpenFeature `ResolutionReason`): `off`→`DISABLED`; `user-pref`/`override`/`rule`→`TARGETING_MATCH`; `rollout`→`SPLIT`; `no-snapshot`→`STALE` before init else `DEFAULT`; `default`→`DEFAULT`. `flagMetadata` carries `beyondReason` (+ `ruleIndex` on a rule match).

**Type contract**: each typed resolver (`resolveBoolean/String/Number/ObjectEvaluation`) checks the resolved value's type; on mismatch it returns the declared `defaultValue` with `errorCode: TYPE_MISMATCH` (never coerces). All four delegate to one private generic `resolve`.

**Read model**:

| Provider | Mode | Source |
| -------- | -------------------- | ------------------------------------------------------------------------------------------------------------------ |
| server | `snapshot` (default) | in-memory `Snapshot`, watch/poll, zero def I/O per eval |
| server | `fetch` | one `kv.get` per flag per eval (no persistent watch) |
| web | snapshot only | sync resolve needs in-memory state; prefs for the active context are pre-fetched on `initialize`/`onContextChange` |

**Events**: in snapshot mode both providers wire the new `Snapshot` `onChange` hook → `events.emit(PROVIDER_CONFIGURATION_CHANGED, { flagsChanged })`. `PROVIDER_READY`/`PROVIDER_ERROR` are emitted by the SDK from `initialize()` resolving/rejecting (no manual emit).

**Not bridged**: like the Vercel adapter, native `.set()`/`.reset()` (pref writes) and `.when()` overrides have no OpenFeature equivalent — the providers _read_ prefs but writes stay on the native API.

## KV Schema

| Key | Value | Written by |
Expand All @@ -197,8 +276,12 @@ Different frameworks expose different hooks, requiring two propagation strategie
| `src/flag.ts` | `Flag<T>` public interface and `FlagRuntime` internal interface; `makeFlag()` factory |
| `src/flags.ts` | `FlagsClient`, `createFlags()`, lazy `flags` singleton; `mutateUserPrefs()` CAS loop; `Runtime` impl |
| `src/eval.ts` | Pure synchronous `evaluate()` — the 7-step precedence chain |
| `src/adapter.ts` | Vercel Flags SDK adapter (`beyondAdapter()`): `decide`/`bulkDecide`/`getProviderData` over the same eval engine + KV |
| `src/hash.ts` | `fnv1a32()` and `bucket(id, flagName)` for deterministic rollouts |
| `src/snapshot.ts` | `Snapshot` class: in-memory `Map<name, FlagDef>`, watch+polling sync, `fetchUserPrefs()` |
| `src/snapshot.ts` | `Snapshot` class: in-memory `Map<name, FlagDef>`, watch+polling sync, `onChange` change hook, `fetchUserPrefs()` |
| `src/openfeature/shared.ts` | SDK-agnostic OpenFeature glue: `toFlagContext`, `mapReason`, `toResolution` (+ type-mismatch handling) |
| `src/openfeature/server.ts` | OpenFeature **server** provider (`BeyondProvider`): async resolvers, snapshot/fetch modes, `PROVIDER_CONFIGURATION_CHANGED` |
| `src/openfeature/web.ts` | OpenFeature **web** provider (`BeyondWebProvider`): sync resolvers, per-context pref prefetch on init/context-change |
| `src/middleware/hono.ts` | Hono `MiddlewareHandler` — wraps chain with `runWithScope` |
| `src/middleware/express.ts` | Express `RequestHandler` — wraps chain with `runWithScope`, errors via `next(err)` |
| `src/middleware/fastify.ts` | Fastify plugin — `onRequest` hook uses `enterScope` (can't wrap chain) |
Expand Down Expand Up @@ -239,16 +322,16 @@ CLOSED

## Failure Modes

| Failure | What Actually Happens | Recovery |
| ------------------------------------------ | ------------------------------------------------------------------------ | ----------------------------------------------------- |
| Zero-arg flag call with no active scope | Throws `FlagError("no_context")` | Ensure middleware is registered before route handlers |
| `ctx.id === ""` | Throws `FlagError("missing_id")` | Middleware must supply a non-empty id |
| Snapshot not yet loaded | Returns flag's `default` with reason `"no-snapshot"` | Await `client.ready()` at startup |
| Watch stream error | `onError` called; backoff + falls back to polling | Auto-recovers; no eval interruption |
| User prefs KV fetch fails | `onError` called; prefs treated as `null` | Evals continue, user-pref branch skipped |
| `flag.set/reset` CAS conflict (≤4 retries) | Retries; emits `onError` + throws `FlagError("kv_error")` on max retries | Caller must handle |
| `BEYOND_KV_URL` not set | Default `flags` singleton throws at first call | Set env var or use `createFlags(kv)` |
| KV entry has invalid JSON | `onError` called; flag treated as absent (`"no-snapshot"`) | Fix via CLI |
| Failure | What Actually Happens | Recovery |
| ------------------------------------------ | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| Zero-arg flag call with no active scope | Throws `FlagError("no_context")` | Ensure middleware is registered before route handlers |
| `ctx.id === ""` | Throws `FlagError("missing_id")` | Middleware must supply a non-empty id |
| Snapshot not yet loaded | Returns flag's `default` with reason `"no-snapshot"` | Await `client.ready()` at startup |
| Watch stream error | `onError` called; backoff + falls back to polling; reconnect resumes from `lastRevision` | Auto-recovers; deltas during the gap are replayed, not lost |
| User prefs KV fetch fails | `onError` called; prefs treated as `null` | Evals continue, user-pref branch skipped |
| `flag.set/reset` CAS conflict (≤4 retries) | Retries; emits `onError` + throws `FlagError("kv_error")` on max retries | Caller must handle |
| `BEYOND_KV_URL` not set | Default `flags` singleton throws at first call | Set env var or use `createFlags(kv)` |
| KV entry has invalid JSON | `onError` called; flag treated as absent (`"no-snapshot"`) | Fix via CLI |

## Why It Behaves This Way

Expand All @@ -271,3 +354,9 @@ Using only `id` would mean a user in the 20% cohort for flag A would also be in
### Why prefs are sparse (only non-defaults stored)

Most users never opt in or out of any flag. Storing only deviations means the `flags:user:{id}` key doesn't exist for most ids, keeping KV storage proportional to actual customization rather than user count × flag count.

### Why the watch resumes from `lastRevision` (not from "now")

A reconnect that re-subscribes from the current moment silently drops any write or delete that landed while the stream was down — leaving a flag permanently stale until the next change or restart. Resuming from the last revision applied makes the snapshot self-healing across disconnects: the server replays the gap (since is exclusive), and byte-compare dedup makes any over-replay a no-op. This is the snapshot equivalent of the idempotent/recoverable property required of state-mutating operations elsewhere — eventual consistency that actually converges, rather than a poll-timing race.

A periodic full reconcile on a _healthy_ stream is deliberately **not** done: its cost is `O(flags)` reads per interval × every replica, independent of change rate, and it would mask (rather than surface) a server-side watch-delivery bug. If silent same-stream drops are ever observed, that belongs fixed at the watch source, not papered over with fleet-wide polling.
45 changes: 43 additions & 2 deletions sdk/ts/flags/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,47 @@ export default async function Page() {
}
```

## Vercel Flags SDK Adapter

Already using the [Vercel Flags SDK](https://flags-sdk.dev) (`flags/next`)? Back its `flag()` declarations with Beyond KV via the adapter — you keep the SDK's `flag()`, toolbar overrides, precompute, and discovery endpoint, and Beyond KV supplies the values (same defs, rules, rollout, and user prefs as the native API).

```sh
npm install @beyond.dev/flags @beyond.dev/kv flags
```

```ts
import { beyondAdapter } from "@beyond.dev/flags/adapter";
import { createKvClient } from "@beyond.dev/kv";
import { flag } from "flags/next";

const kv = createKvClient({ url: process.env.BEYOND_KV_URL });
const adapter = beyondAdapter(kv);

export const newCheckout = flag<boolean>({
key: "new-checkout",
defaultValue: false,
adapter,
// identify supplies the entities passed to evaluation; `id` is the bucket key.
identify: ({ headers }) => ({
id: headers.get("x-user-id") ?? "anon",
plan: "free",
}),
});

const enabled = await newCheckout(); // resolved by Beyond KV through the host
```

**Read modes** — `beyondAdapter(kv, { mode })`:

- `"snapshot"` (default) keeps an in-memory snapshot synced via KV watch/poll — zero round-trips per evaluation. Best for long-lived Node servers.
- `"request"` fetches defs per request (cached by request headers). Best for short-lived edge/serverless functions that can't hold a persistent watch.

**Batching** — share one `adapter` instance and one `identify` reference across flags, and the host's `evaluate([...])` resolves them in a single `bulkDecide` call.

**Toolbar discovery** — wire `adapter.getProviderData()` into the SDK's `createFlagsDiscoveryEndpoint`, merging with the host's `getProviderData(flags)` via `mergeProviderData` for code-side metadata.

> Note: per-user writes (`.set()`/`.reset()`) and `.when()` overrides are native-only — the adapter still _reads_ user prefs, but the Vercel toolbar replaces code overrides. Use the native `createFlags` API (above) if you want those.

## Flag Definitions

Store flag definitions in KV under `flags:def:<name>`. Definitions control behavior without deploys.
Expand Down Expand Up @@ -180,7 +221,7 @@ const flags = createFlags(kv, {

| Option | Type | Default | Description |
| ------------ | ---------------------------------- | ------- | ------------------------------------------------------------- |
| `watch` | `boolean` | `false` | Stream flag definition updates via KV watch |
| `refresh` | `number` | | Poll interval in seconds (fallback when watch is unavailable) |
| `watch` | `boolean` | `true` | Stream flag definition updates via KV watch |
| `refresh` | `number` | `30` | Poll interval in seconds (fallback when watch is unavailable) |
| `onEvaluate` | `(event: FlagEvent) => void` | — | Called after each evaluation |
| `onError` | `(event: FlagsErrorEvent) => void` | — | Called on KV or snapshot errors |
Loading