From 31bd6d53aba9de1d5daf24582fb0047df92653c4 Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Tue, 23 Jun 2026 09:47:56 -0700 Subject: [PATCH 1/7] feat(flags): Vercel Flags SDK adapter (@beyond.dev/flags/adapter) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `beyondAdapter(kv, opts?)` so flags declared with the Vercel Flags SDK (`flag()` from `flags/next`) resolve against Beyond KV, reusing the existing pure `evaluate()` engine, snapshot, and per-user prefs. - Implements the Vercel `Adapter` contract: `decide`, `bulkDecide` + stable `adapterId` (batching), `identify`/`origin` passthrough, and `getProviderData` for the toolbar discovery endpoint. - Two read modes: `snapshot` (default, in-memory watch/poll, zero per-eval I/O) and `request` (per-request WeakMap cache, for edge/serverless). - New `./adapter` subpath export; `flags` added as an optional peer dep. Verified end-to-end through the real `flags@4.2.0` host (flag()/evaluate()/ getProviderData) against a real beyond-kv server — toggle-based assertions prove the full chain. Full suite 104/104 green, typecheck + tsdown build clean. Also: document the adapter in README/ARCHITECTURE and fix two stale README option defaults (watch=true, refresh=30) to match createFlags. Co-Authored-By: Claude Opus 4.8 (1M context) --- sdk/ts/flags/ARCHITECTURE.md | 34 ++ sdk/ts/flags/README.md | 45 +- sdk/ts/flags/package.json | 9 + sdk/ts/flags/src/__tests__/adapter.test.ts | 303 ++++++++++++ .../flags/src/__tests__/e2e-flags-sdk.test.ts | 210 +++++++++ sdk/ts/flags/src/adapter.ts | 437 ++++++++++++++++++ sdk/ts/flags/tsdown.config.ts | 3 +- sdk/ts/package-lock.json | 56 +++ 8 files changed, 1094 insertions(+), 3 deletions(-) create mode 100644 sdk/ts/flags/src/__tests__/adapter.test.ts create mode 100644 sdk/ts/flags/src/__tests__/e2e-flags-sdk.test.ts create mode 100644 sdk/ts/flags/src/adapter.ts diff --git a/sdk/ts/flags/ARCHITECTURE.md b/sdk/ts/flags/ARCHITECTURE.md index cdb9cd9..c025758 100644 --- a/sdk/ts/flags/ARCHITECTURE.md +++ b/sdk/ts/flags/ARCHITECTURE.md @@ -178,6 +178,39 @@ 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` (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()`. + ## KV Schema | Key | Value | Written by | @@ -197,6 +230,7 @@ Different frameworks expose different hooks, requiring two propagation strategie | `src/flag.ts` | `Flag` 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`, watch+polling sync, `fetchUserPrefs()` | | `src/middleware/hono.ts` | Hono `MiddlewareHandler` — wraps chain with `runWithScope` | diff --git a/sdk/ts/flags/README.md b/sdk/ts/flags/README.md index 69004d7..311b2b7 100644 --- a/sdk/ts/flags/README.md +++ b/sdk/ts/flags/README.md @@ -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 { flag } from "flags/next"; +import { createKvClient } from "@beyond.dev/kv"; +import { beyondAdapter } from "@beyond.dev/flags/adapter"; + +const kv = createKvClient({ url: process.env.BEYOND_KV_URL }); +const adapter = beyondAdapter(kv); + +export const newCheckout = flag({ + 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:`. Definitions control behavior without deploys. @@ -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 | diff --git a/sdk/ts/flags/package.json b/sdk/ts/flags/package.json index d266cf4..899f84f 100644 --- a/sdk/ts/flags/package.json +++ b/sdk/ts/flags/package.json @@ -17,6 +17,10 @@ "import": "./dist/index.mjs", "types": "./dist/index.d.mts" }, + "./adapter": { + "import": "./dist/adapter.mjs", + "types": "./dist/adapter.d.mts" + }, "./hono": { "import": "./dist/middleware/hono.mjs", "types": "./dist/middleware/hono.d.mts" @@ -53,10 +57,14 @@ "express": ">=4", "fastify": ">=4", "fastify-plugin": ">=4", + "flags": ">=4", "hono": ">=4", "next": ">=14" }, "peerDependenciesMeta": { + "flags": { + "optional": true + }, "express": { "optional": true }, @@ -79,6 +87,7 @@ "express": "^5.0.0", "fastify": "^5.0.0", "fastify-plugin": "^5.0.0", + "flags": "^4.2.0", "hono": "^4.0.0", "next": "^15.0.0", "tsdown": "^0.21.10", diff --git a/sdk/ts/flags/src/__tests__/adapter.test.ts b/sdk/ts/flags/src/__tests__/adapter.test.ts new file mode 100644 index 0000000..25de429 --- /dev/null +++ b/sdk/ts/flags/src/__tests__/adapter.test.ts @@ -0,0 +1,303 @@ +import type { KvClient } from "@beyond.dev/kv"; +import type { ReadonlyHeaders, ReadonlyRequestCookies } from "flags"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beyondAdapter, type BeyondAdapter } from "../adapter.js"; +import type { FlagContext } from "../types.js"; +import { kvClient, writeDef } from "./harness.js"; +import "./test-context.js"; + +// NOTE: the test KV server addresses namespaces by a small db index, so the +// harness's `uniqueNs()` does NOT isolate keys across tests — every client +// shares one keyspace (see http.ts `nsToIndex`). Tests therefore use unique +// flag keys and ids per case to avoid cross-test pollution, matching the +// pattern in integration.test.ts. +const uid = () => crypto.randomUUID(); + +// The adapter only uses `headers` as a per-request WeakMap key and never reads +// `cookies`, so plain objects suffice as stand-ins for the sealed Next types. +function reqHeaders(): ReadonlyHeaders { + return new Headers() as unknown as ReadonlyHeaders; +} +const cookies = {} as unknown as ReadonlyRequestCookies; + +async function writePrefs( + kv: KvClient, + id: string, + prefs: Record, +): Promise { + const { error } = await kv.set(`flags:user:${id}`, JSON.stringify(prefs)); + if (error) throw error; +} + +// Run the whole suite against both read strategies. snapshot mode loads defs at +// construction, so each test seeds KV first and *then* builds the adapter via +// `build()` (whose first `decide` awaits the initial load). request mode reads +// on demand, so it works the same way. +for (const mode of ["snapshot", "request"] as const) { + describe(`beyondAdapter — decide (${mode} mode)`, () => { + let kv: KvClient; + let adapter: BeyondAdapter; + + const build = (opts: { userPrefs?: boolean } = {}) => { + adapter = beyondAdapter(kv, { + mode, + watch: false, + refresh: 1, + ...opts, + }); + return adapter; + }; + + beforeEach(() => { + kv = kvClient(); + }); + + afterEach(async () => { + await adapter?.close(); + }); + + const decide = ( + key: string, + entities: FlagContext | undefined, + defaultValue: boolean, + ) => + adapter.decide({ + key, + ...(entities !== undefined ? { entities } : {}), + headers: reqHeaders(), + cookies, + defaultValue, + }); + + it("returns the rollout value for a 100% flag", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + build(); + expect(await decide(key, { id: uid() }, false)).toBe(true); + }); + + it("kill switch (on:false) returns the declared default", async () => { + const key = uid(); + await writeDef(kv, key, { + on: false, + rollout: { percent: 100 }, + rules: [{ when: {}, value: true }], + }); + build(); + expect(await decide(key, { id: uid() }, false)).toBe(false); + }); + + it("0% rollout returns the default", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + build(); + expect(await decide(key, { id: uid() }, false)).toBe(false); + }); + + it("targeting rule matches on an augmented context field", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + build(); + expect(await decide(key, { id: uid(), plan: "pro" }, false)).toBe(true); + expect(await decide(key, { id: uid(), plan: "free" }, false)).toBe(false); + }); + + it("user pref overrides rules/rollout", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + await writePrefs(kv, id, { [key]: true }); + build(); + expect(await decide(key, { id }, false)).toBe(true); + // A different id without the pref still falls through to default. + expect(await decide(key, { id: uid() }, false)).toBe(false); + }); + + it("userPrefs:false ignores stored prefs", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + await writePrefs(kv, id, { [key]: true }); + build({ userPrefs: false }); + expect(await decide(key, { id }, false)).toBe(false); + }); + + it("unknown flag returns the default", async () => { + build(); + expect(await decide(uid(), { id: uid() }, false)).toBe(false); + }); + + it("missing id returns the default (cannot bucket)", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + build(); + expect(await decide(key, undefined, false)).toBe(false); + expect(await decide(key, { id: "" }, false)).toBe(false); + }); + + it("rollout bucketing is deterministic for the same id", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 50 } }); + build(); + const a = await decide(key, { id }, false); + const b = await decide(key, { id }, false); + expect(a).toBe(b); + }); + }); + + describe(`beyondAdapter — bulkDecide (${mode} mode)`, () => { + let kv: KvClient; + let adapter: BeyondAdapter; + + beforeEach(() => { + kv = kvClient(); + }); + afterEach(async () => { + await adapter?.close(); + }); + + it("resolves many flags in one call", async () => { + const a = uid(); + const b = uid(); + const c = uid(); + const missing = uid(); + await writeDef(kv, a, { on: true, rollout: { percent: 100 } }); + await writeDef(kv, b, { on: false }); + await writeDef(kv, c, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + adapter = beyondAdapter(kv, { mode, watch: false, refresh: 1 }); + const out = await adapter.bulkDecide?.({ + flags: [ + { key: a, defaultValue: false }, + { key: b, defaultValue: false }, + { key: c, defaultValue: false }, + { key: missing, defaultValue: false }, + ], + entities: { id: uid(), plan: "pro" }, + headers: reqHeaders(), + cookies, + }); + expect(out).toEqual({ [a]: true, [b]: false, [c]: true, [missing]: false }); + }); + + it("missing id falls every flag back to its default", async () => { + const a = uid(); + const b = uid(); + adapter = beyondAdapter(kv, { mode, watch: false, refresh: 1 }); + const out = await adapter.bulkDecide?.({ + flags: [ + { key: a, defaultValue: false }, + { key: b, defaultValue: true }, + ], + headers: reqHeaders(), + cookies, + }); + expect(out).toEqual({ [a]: false, [b]: true }); + }); + }); +} + +describe("beyondAdapter — request mode caching", () => { + it("reads each def at most once per request (shared headers)", async () => { + const kv = kvClient(); + const a = uid(); + const b = uid(); + const id = uid(); + await writeDef(kv, a, { on: true, rollout: { percent: 100 } }); + await writeDef(kv, b, { on: true, rollout: { percent: 100 } }); + const getSpy = vi.spyOn(kv, "get"); + const batchSpy = vi.spyOn(kv, "batchGet"); + const adapter = beyondAdapter(kv, { mode: "request" }); + try { + const headers = reqHeaders(); + // Evaluate the same two flags twice within one request. + for (let i = 0; i < 2; i++) { + for (const key of [a, b]) { + await adapter.decide({ + key, + entities: { id }, + headers, + cookies, + defaultValue: false, + }); + } + } + // Two distinct def keys → at most two def reads total (get or batchGet), + // plus one prefs read for the id. Re-evaluations hit the per-request cache. + const defReads = + getSpy.mock.calls.filter((c) => String(c[0]).startsWith("flags:def:")) + .length + batchSpy.mock.calls.length; + const prefReads = getSpy.mock.calls.filter((c) => + String(c[0]).startsWith("flags:user:"), + ).length; + expect(defReads).toBe(2); + expect(prefReads).toBe(1); + } finally { + await adapter.close(); + } + }); +}); + +describe("beyondAdapter — getProviderData", () => { + it("lists declared flags from KV", async () => { + const kv = kvClient(); + const alpha = uid(); + const beta = uid(); + await writeDef(kv, alpha, { on: true }); + await writeDef(kv, beta, { on: false }); + const adapter = beyondAdapter(kv, { mode: "request" }); + try { + const data = await adapter.getProviderData(); + // Shared keyspace: assert our defs are present (subset), not exact set. + expect(data.definitions[alpha]).toBeDefined(); + expect(data.definitions[beta]).toBeDefined(); + expect(data.definitions[alpha]?.declaredInCode).toBe(false); + expect(data.hints).toEqual([]); + } finally { + await adapter.close(); + } + }); + + it("returns a hint instead of throwing on KV failure", async () => { + const kv = kvClient(); + vi.spyOn(kv, "list").mockResolvedValue({ + data: undefined, + // biome-ignore lint/suspicious/noExplicitAny: minimal error stub + error: { message: "boom" } as any, + }); + const adapter = beyondAdapter(kv, { mode: "request" }); + try { + const data = await adapter.getProviderData(); + expect(data.definitions).toEqual({}); + expect(data.hints).toHaveLength(1); + expect(data.hints[0]?.key).toBe("beyond-kv"); + } finally { + await adapter.close(); + } + }); +}); + +describe("beyondAdapter — adapter identity", () => { + it("exposes a stable adapterId and origin resolver", async () => { + const kv = kvClient(); + const adapter = beyondAdapter(kv, { + mode: "request", + origin: (key) => `https://flags.example/${key}`, + }); + try { + expect(typeof adapter.adapterId).toBe("symbol"); + const origin = adapter.origin; + const resolved = + typeof origin === "function" ? origin("my-flag") : origin; + expect(resolved).toBe("https://flags.example/my-flag"); + } finally { + await adapter.close(); + } + }); +}); diff --git a/sdk/ts/flags/src/__tests__/e2e-flags-sdk.test.ts b/sdk/ts/flags/src/__tests__/e2e-flags-sdk.test.ts new file mode 100644 index 0000000..6cef83c --- /dev/null +++ b/sdk/ts/flags/src/__tests__/e2e-flags-sdk.test.ts @@ -0,0 +1,210 @@ +/** + * End-to-end proof that `@beyond.dev/flags/adapter` is a real Vercel Flags SDK + * adapter. + * + * These tests import the REAL host SDK (`flag`, `evaluate`, `getProviderData` + * from `flags/next` v4) and drive evaluation through it. We never call + * `adapter.decide`/`adapter.bulkDecide` ourselves — the host does, exactly as it + * would inside a Next.js request. The full chain is exercised: + * + * real flag() → host identify/dedupe/override → OUR adapter.decide → real beyond-kv + * + * The host's Pages-Router call shape `flag(request)` reads request data from the + * passed object instead of `next/headers`, so it runs headless under vitest + * (confirmed against flags@4.2.0 dist/next.js: `if ("headers" in args[0])`). + * + * Assertions are toggle-based: flipping the def in KV flips the value the HOST + * returns. That can only pass if the entire chain works. + */ +import type { KvClient } from "@beyond.dev/kv"; +import { evaluate, flag, getProviderData } from "flags/next"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beyondAdapter } from "../adapter.js"; +import type { FlagContext } from "../types.js"; +import { kvClient, writeDef } from "./harness.js"; +import "./test-context.js"; + +/** + * Minimal Pages-Router request the host accepts (`"headers" in request`). The + * host only reads `.headers`, so a bare object suffices; typed `any` because it + * stands in for both `PagesRouterRequest` (flag) and `EvaluateRequest` + * (evaluate). + */ +// biome-ignore lint/suspicious/noExplicitAny: minimal headless request stub +function request(headers: Record = {}): any { + return { headers }; +} + +describe("e2e: real flags/next host → beyond adapter → real KV", () => { + let kv: KvClient; + + beforeEach(() => { + kv = kvClient(); + }); + + afterEach(async () => { + // adapters are created per-test; nothing global to tear down here. + }); + + it("host-returned value tracks live KV state (the irrefutable toggle)", async () => { + const adapter = beyondAdapter(kv, { mode: "request" }); + const newCheckout = flag({ + key: "new-checkout", + defaultValue: false, + adapter, + identify: () => ({ id: "u1" }), + }); + try { + // No def yet → host applies the declared defaultValue. + expect(await newCheckout(request())).toBe(false); + + // Turn it on at 100% → host now returns true (value came from KV via decide). + await writeDef(kv, "new-checkout", { on: true, rollout: { percent: 100 } }); + expect(await newCheckout(request())).toBe(true); + + // Kill switch → back to default. + await writeDef(kv, "new-checkout", { on: false }); + expect(await newCheckout(request())).toBe(false); + + // Re-enable → true again. The flips prove decide reads live KV each call. + await writeDef(kv, "new-checkout", { on: true, rollout: { percent: 100 } }); + expect(await newCheckout(request())).toBe(true); + } finally { + await adapter.close(); + } + }); + + it("identify → entities → KV targeting rule, end to end", async () => { + await writeDef(kv, "ai-search", { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + const adapter = beyondAdapter(kv, { mode: "request" }); + + // identify pulls the plan from the request headers the host sealed for us. + const aiSearch = flag({ + key: "ai-search", + defaultValue: false, + adapter, + identify: ({ headers }) => ({ + id: headers.get("x-user-id") ?? "anon", + plan: (headers.get("x-plan") as FlagContext["plan"]) ?? "free", + }), + }); + try { + expect(await aiSearch(request({ "x-user-id": "u1", "x-plan": "pro" }))).toBe( + true, + ); + expect( + await aiSearch(request({ "x-user-id": "u2", "x-plan": "free" })), + ).toBe(false); + } finally { + await adapter.close(); + } + }); + + it("per-user pref resolves end to end", async () => { + await writeDef(kv, "beta", { on: true, rollout: { percent: 0 } }); + const { error } = await kv.set( + "flags:user:u1", + JSON.stringify({ beta: true }), + ); + if (error) throw error; + + const adapter = beyondAdapter(kv, { mode: "request" }); + const beta = flag({ + key: "beta", + defaultValue: false, + adapter, + identify: ({ headers }) => ({ id: headers.get("x-user-id") ?? "anon" }), + }); + try { + expect(await beta(request({ "x-user-id": "u1" }))).toBe(true); // pref + expect(await beta(request({ "x-user-id": "u2" }))).toBe(false); // 0% rollout + } finally { + await adapter.close(); + } + }); + + it("snapshot mode resolves through the host too", async () => { + // Write before creating the adapter; the first decide awaits initial load. + await writeDef(kv, "snap", { on: true, rollout: { percent: 100 } }); + const adapter = beyondAdapter(kv, { mode: "snapshot", watch: false }); + const snap = flag({ + key: "snap", + defaultValue: false, + adapter, + identify: () => ({ id: "u1" }), + }); + try { + expect(await snap(request())).toBe(true); + } finally { + await adapter.close(); + } + }); + + it("host evaluate() batches through our bulkDecide", async () => { + await writeDef(kv, "a", { on: true, rollout: { percent: 100 } }); + await writeDef(kv, "b", { on: false }); + await writeDef(kv, "c", { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + const adapter = beyondAdapter(kv, { mode: "request" }); + const bulkSpy = vi.spyOn(adapter, "bulkDecide"); + + // The host groups flags by (adapterId, identify reference). Sharing ONE + // adapter instance AND one identify function collapses all three into a + // single bulk group → a single bulkDecide call. + const identify = (): FlagContext => ({ id: "u1", plan: "pro" }); + const mk = (key: string) => + flag({ key, defaultValue: false, adapter, identify }); + const a = mk("a"); + const b = mk("b"); + const c = mk("c"); + try { + const result = await evaluate([a, b, c], request()); + expect(result).toEqual([true, false, true]); + // Proves the host routed through bulkDecide, not per-flag decide. + expect(bulkSpy).toHaveBeenCalledTimes(1); + expect(bulkSpy.mock.calls[0]?.[0].flags.map((f) => f.key)).toEqual([ + "a", + "b", + "c", + ]); + } finally { + await adapter.close(); + } + }); + + it("getProviderData merges adapter (KV) + host (code) definitions", async () => { + await writeDef(kv, "shipped", { on: true }); + const adapter = beyondAdapter(kv, { + mode: "request", + origin: (key) => `https://flags.example/${key}`, + }); + const shipped = flag({ + key: "shipped", + defaultValue: false, + description: "A shipped feature", + adapter, + identify: () => ({ id: "u1" }), + }); + try { + // Host builds code-side definitions (description/defaultValue/options). + // biome-ignore lint/suspicious/noExplicitAny: host typing friction under exactOptionalPropertyTypes + const codeData = getProviderData({ shipped } as any); + // Adapter builds provider-side definitions (what exists in KV + origin). + const kvData = await adapter.getProviderData(); + + expect(codeData.definitions.shipped?.description).toBe("A shipped feature"); + expect(kvData.definitions.shipped?.declaredInCode).toBe(false); + expect(kvData.definitions.shipped?.origin).toBe( + "https://flags.example/shipped", + ); + expect(kvData.hints).toEqual([]); + } finally { + await adapter.close(); + } + }); +}); diff --git a/sdk/ts/flags/src/adapter.ts b/sdk/ts/flags/src/adapter.ts new file mode 100644 index 0000000..073818d --- /dev/null +++ b/sdk/ts/flags/src/adapter.ts @@ -0,0 +1,437 @@ +/** + * Vercel Flags SDK adapter for `@beyond.dev/flags`. + * + * Lets you declare flags with the Vercel [`flags`](https://flags-sdk.dev) SDK + * (`flag({ key, adapter })`) but resolve them against Beyond KV — the same + * `flags:def:*` defs, targeting rules, rollout, kill switch, and per-user prefs + * the native `@beyond.dev/flags` API uses. The host (`flags/next`) owns the + * request plumbing, toolbar overrides, precompute, and reporting; this module + * implements the one seam it calls into: the {@link Adapter} contract. + * + * @example + * ```ts + * // flags.ts + * import { flag } from 'flags/next' + * import { createKvClient } from '@beyond.dev/kv' + * import { beyondAdapter } from '@beyond.dev/flags/adapter' + * + * const kv = createKvClient({ url: process.env.BEYOND_KV_URL! }) + * const adapter = beyondAdapter(kv) + * + * export const newCheckout = flag({ + * key: 'new-checkout', + * defaultValue: false, + * adapter, + * identify: ({ headers }) => ({ id: headers.get('x-user-id') ?? 'anon', plan: 'free' }), + * }) + * ``` + * + * @packageDocumentation + */ + +import type { KvClient } from "@beyond.dev/kv"; +import type { + Adapter, + FlagDefinitionsType, + Identify, + Origin, + ProviderData, + ReadonlyHeaders, +} from "flags"; +import { evaluate } from "./eval.js"; +import { fetchUserPrefs } from "./snapshot.js"; +import { Snapshot } from "./snapshot.js"; +import type { + FlagContext, + FlagDef, + FlagsErrorEvent, + JsonValue, + UserPrefs, +} from "./types.js"; + +const DEF_PREFIX = "flags:def:"; + +/** Options for {@link beyondAdapter}. The KV client is positional, not here. */ +export interface BeyondAdapterOptions { + /** + * How defs are read from KV. + * + * - `"snapshot"` (default): keep an in-memory snapshot of all `flags:def:*`, + * refreshed via `kv.watch()` (or polling). `decide` does zero KV + * round-trips. Best for long-lived Node servers. + * - `"request"`: fetch each def on demand, cached per request (keyed by the + * request `headers` object). Survives short-lived edge/serverless functions + * that can't hold a persistent watch. One KV read per distinct flag/request. + */ + mode?: "snapshot" | "request"; + /** snapshot mode: SWR poll interval (seconds) when watch is unavailable. Default 30. */ + refresh?: number; + /** snapshot mode: use `kv.watch()` for instant invalidation. Default true. */ + watch?: boolean; + /** Honor per-user prefs (`flags:user:`) in `decide`. Default true. */ + userPrefs?: boolean; + /** + * Default `identify` for flags using this adapter. A per-flag `identify` in + * the `flag({...})` declaration overrides it. Must return an object with a + * non-empty `id` (the rollout bucket key). + */ + identify?: Identify; + /** Management URL for the toolbar — a string base or a `(key) => url` builder. */ + origin?: string | Origin | ((key: string) => string | Origin | undefined); + /** Called for snapshot/watch/KV/pref failures. */ + onError?: (event: FlagsErrorEvent) => void; +} + +/** + * The adapter object passed to `flag({ adapter })`, plus discovery/lifecycle + * helpers. It satisfies the Vercel {@link Adapter} interface; the extra members + * are ignored by the host. + */ +export interface BeyondAdapter< + T extends JsonValue = JsonValue, + E extends FlagContext = FlagContext, +> extends Adapter { + /** + * Build {@link ProviderData} for the toolbar discovery endpoint + * (`flags/next`'s `createFlagsDiscoveryEndpoint`). Enumerates `flags:def:*` + * from KV. Definitions are thin — only what KV stores; merge with the host's + * `getProviderData(flags)` (via `mergeProviderData`) for code-declared + * options/description/defaultValue. + */ + getProviderData(): Promise; + /** Stop background syncing (snapshot mode). Does not close the KV client. */ + close(): Promise; +} + +/** + * Per-request def reader. Two strategies behind one interface so `decide`, + * `bulkDecide`, and prefs caching are mode-agnostic. + */ +interface DefSource { + ready(): Promise; + get(key: string, headers: ReadonlyHeaders): Promise; + getMany( + keys: readonly string[], + headers: ReadonlyHeaders, + ): Promise>; + close(): void; +} + +/** snapshot mode — in-memory, kept live by {@link Snapshot}. Zero per-eval I/O. */ +class SnapshotDefSource implements DefSource { + private readonly snapshot: Snapshot; + + constructor(kv: KvClient, opts: BeyondAdapterOptions) { + this.snapshot = new Snapshot(kv, { + refresh: opts.refresh ?? 30, + watch: opts.watch ?? true, + ...(opts.onError ? { onError: opts.onError } : {}), + }); + this.snapshot.start(); + } + + ready(): Promise { + return this.snapshot.awaitReady(); + } + + async get(key: string): Promise { + return this.snapshot.get(key); + } + + async getMany( + keys: readonly string[], + ): Promise> { + const out = new Map(); + for (const key of keys) out.set(key, this.snapshot.get(key)); + return out; + } + + close(): void { + this.snapshot.close(); + } +} + +/** + * request mode — fetch defs on demand, cached per request via a + * `WeakMap`. The `headers` object is stable for the + * lifetime of one request (the host passes the same sealed instance to every + * flag), so it's a natural per-request cache key with no manual cleanup. + */ +class RequestDefSource implements DefSource { + private readonly kv: KvClient; + private readonly onError: ((event: FlagsErrorEvent) => void) | undefined; + private readonly cache = new WeakMap< + ReadonlyHeaders, + Map> + >(); + + constructor(kv: KvClient, opts: BeyondAdapterOptions) { + this.kv = kv; + this.onError = opts.onError; + } + + ready(): Promise { + return Promise.resolve(); + } + + private requestCache( + headers: ReadonlyHeaders, + ): Map> { + let map = this.cache.get(headers); + if (!map) { + map = new Map(); + this.cache.set(headers, map); + } + return map; + } + + get(key: string, headers: ReadonlyHeaders): Promise { + const map = this.requestCache(headers); + let pending = map.get(key); + if (!pending) { + pending = this.fetchOne(key); + map.set(key, pending); + } + return pending; + } + + private async fetchOne(key: string): Promise { + const { data, error } = await this.kv.get(DEF_PREFIX + key); + if (error) { + this.onError?.({ source: "snapshot", error, name: key }); + return undefined; + } + if (!data) return undefined; + return parseDef(data.text(), key, this.onError); + } + + async getMany( + keys: readonly string[], + headers: ReadonlyHeaders, + ): Promise> { + const map = this.requestCache(headers); + // Batch the keys not already cached into a single round-trip. + const missing = keys.filter((k) => !map.has(k)); + if (missing.length > 0) { + const fetched = this.fetchMany(missing); + for (const key of missing) { + map.set( + key, + fetched.then((m) => m.get(key)), + ); + } + } + const out = new Map(); + await Promise.all( + keys.map(async (key) => { + out.set(key, await (map.get(key) as Promise)); + }), + ); + return out; + } + + private async fetchMany( + keys: readonly string[], + ): Promise> { + const out = new Map(); + const { data, error } = await this.kv.batchGet( + keys.map((k) => DEF_PREFIX + k), + ); + if (error) { + this.onError?.({ source: "snapshot", error }); + for (const key of keys) out.set(key, undefined); + return out; + } + for (let i = 0; i < keys.length; i++) { + const key = keys[i] as string; + const entry = data[i]; + out.set(key, entry ? parseDef(entry.text(), key, this.onError) : undefined); + } + return out; + } + + close(): void { + // Nothing to release — caches are per-request and GC'd with their headers. + } +} + +/** + * Parse a `flags:def:*` JSON payload into a {@link FlagDef}. Returns `undefined` + * (treated as "no def" → flag falls back to its declared default) on malformed + * input, reporting through `onError` rather than throwing into eval. + */ +function parseDef( + text: string, + key: string, + onError?: (event: FlagsErrorEvent) => void, +): FlagDef | undefined { + if (text.length === 0) return undefined; + try { + const parsed = JSON.parse(text) as unknown; + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) || + typeof (parsed as Record)["on"] !== "boolean" + ) { + throw new Error("flag def must be a JSON object with a boolean `on`"); + } + return parsed as FlagDef; + } catch (err) { + onError?.({ + source: "snapshot", + error: err instanceof Error ? err : new Error(String(err)), + name: key, + }); + return undefined; + } +} + +/** + * Create a Beyond KV adapter for the Vercel Flags SDK, bound to a specific + * {@link KvClient}. Mirrors `createFlags(kv, opts)`. + * + * One adapter object owns one `adapterId`, so all flags sharing it are batched + * together by the host's `evaluate()` through a single `bulkDecide`. + */ +export function beyondAdapter< + T extends JsonValue = JsonValue, + E extends FlagContext = FlagContext, +>(kv: KvClient, opts: BeyondAdapterOptions = {}): BeyondAdapter { + const mode = opts.mode ?? "snapshot"; + const useUserPrefs = opts.userPrefs !== false; + const defSource: DefSource = + mode === "request" + ? new RequestDefSource(kv, opts) + : new SnapshotDefSource(kv, opts); + + // Stable per-instance id so the host groups this adapter's flags for bulk eval. + const adapterId = Symbol("beyondAdapter"); + + // Per-request pref cache, keyed by the request headers object (stable per + // request) → one KV read per id regardless of how many flags evaluate. + const prefsCache = new WeakMap< + ReadonlyHeaders, + Map> + >(); + + function loadPrefs( + id: string, + headers: ReadonlyHeaders, + ): Promise { + let byId = prefsCache.get(headers); + if (!byId) { + byId = new Map(); + prefsCache.set(headers, byId); + } + let pending = byId.get(id); + if (!pending) { + pending = fetchUserPrefs(kv, id, opts.onError); + byId.set(id, pending); + } + return pending; + } + + function resolveOrigin( + key: string, + ): string | Origin | undefined { + const o = opts.origin; + if (o === undefined) return undefined; + return typeof o === "function" ? o(key) : o; + } + + const adapter: BeyondAdapter = { + adapterId, + + ...(opts.identify ? { identify: opts.identify as Identify } : {}), + ...(opts.origin ? { origin: resolveOrigin } : {}), + + async decide({ key, entities, headers, defaultValue }) { + const ctx = entities as FlagContext | undefined; + // No id → can't bucket or look up prefs. Fall back to the declared default. + if (!ctx?.id) return defaultValue as T; + await defSource.ready(); + const def = await defSource.get(key, headers); + const prefs = useUserPrefs ? await loadPrefs(ctx.id, headers) : null; + return evaluate(key, defaultValue as T, ctx, def as FlagDef | undefined, prefs).value; + }, + + async bulkDecide({ flags, entities, headers }) { + const ctx = entities as FlagContext | undefined; + const out: Record = {}; + if (!ctx?.id) { + for (const f of flags) out[f.key] = f.defaultValue as T; + return out; + } + await defSource.ready(); + const defs = await defSource.getMany( + flags.map((f) => f.key), + headers, + ); + const prefs = useUserPrefs ? await loadPrefs(ctx.id, headers) : null; + for (const f of flags) { + out[f.key] = evaluate( + f.key, + f.defaultValue as T, + ctx, + defs.get(f.key) as FlagDef | undefined, + prefs, + ).value; + } + return out; + }, + + async getProviderData(): Promise { + try { + const definitions = await listDefs(kv); + const out: FlagDefinitionsType = {}; + for (const name of definitions) { + const origin = opts.origin ? resolveOrigin(name) : undefined; + out[name] = { + declaredInCode: false, + ...(origin !== undefined ? { origin } : {}), + }; + } + return { definitions: out, hints: [] }; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + opts.onError?.({ source: "snapshot", error }); + return { + definitions: {}, + hints: [ + { + key: "beyond-kv", + text: `Failed to load flag definitions from Beyond KV: ${error.message}`, + }, + ], + }; + } + }, + + async close(): Promise { + defSource.close(); + }, + }; + + return adapter; +} + +/** Alias matching the `createFlags`/`createKvClient` naming. */ +export const createBeyondAdapter = beyondAdapter; + +/** Enumerate all `flags:def:*` keys, returning the bare flag names. */ +async function listDefs(kv: KvClient): Promise { + const names: string[] = []; + let cursor: string | undefined; + do { + const { data, error } = await kv.list( + cursor === undefined + ? { prefix: DEF_PREFIX } + : { prefix: DEF_PREFIX, cursor }, + ); + if (error) throw error; + for (const k of data.keys) names.push(k.name.slice(DEF_PREFIX.length)); + cursor = data.nextCursor; + } while (cursor); + return names; +} diff --git a/sdk/ts/flags/tsdown.config.ts b/sdk/ts/flags/tsdown.config.ts index b0d945a..4189bce 100644 --- a/sdk/ts/flags/tsdown.config.ts +++ b/sdk/ts/flags/tsdown.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ entry: { index: "src/index.ts", + adapter: "src/adapter.ts", "middleware/hono": "src/middleware/hono.ts", "middleware/express": "src/middleware/express.ts", "middleware/fastify": "src/middleware/fastify.ts", @@ -14,6 +15,6 @@ export default defineConfig({ clean: true, treeshake: true, deps: { - neverBundle: ["next", "express", "fastify", "fastify-plugin", "hono"], + neverBundle: ["next", "express", "fastify", "fastify-plugin", "hono", "flags"], }, }); diff --git a/sdk/ts/package-lock.json b/sdk/ts/package-lock.json index b199e68..a22475e 100644 --- a/sdk/ts/package-lock.json +++ b/sdk/ts/package-lock.json @@ -25,6 +25,7 @@ "express": "^5.0.0", "fastify": "^5.0.0", "fastify-plugin": "^5.0.0", + "flags": "^4.2.0", "hono": "^4.0.0", "next": "^15.0.0", "tsdown": "^0.21.10", @@ -412,6 +413,16 @@ "resolved": "rate-limit", "link": true }, + "node_modules/@edge-runtime/cookies": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@edge-runtime/cookies/-/cookies-5.0.2.tgz", + "integrity": "sha512-Sd8LcWpZk/SWEeKGE8LT6gMm5MGfX/wm+GPnh1eBEtCpya3vYqn37wYknwAHw92ONoyyREl1hJwxV/Qx2DWNOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -3191,6 +3202,41 @@ "node": ">=20" } }, + "node_modules/flags": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/flags/-/flags-4.2.0.tgz", + "integrity": "sha512-/ahGKxe4Q3C2pi2FS7h6cNR4HWnU7/MWAtwcrTHLBQjM9vVe5NR+uRIbL+vXYYU8PYbb8VnwnD+bdLH6l69m0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@edge-runtime/cookies": "^5.0.2", + "jose": "^5.10.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0", + "@sveltejs/kit": "*", + "next": "*", + "react": "*", + "react-dom": "*" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "next": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3470,6 +3516,16 @@ "dev": true, "license": "MIT" }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-levenshtein": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", From 55c6d3dd5351d6c152d58cc8db0e777a4240227d Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Tue, 23 Jun 2026 10:43:33 -0700 Subject: [PATCH 2/7] test(flags): broaden adapter e2e surface + UUID-harden keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a second e2e tranche (e2e-flags-sdk-surface.test.ts), all driven through the real flags@4.2.0 host, covering the integration surfaces beyond the core decision path: - non-boolean values: string-variant and JSON/object flags - precompute round-trip: evaluate(request) → serialize → getPrecomputed and the flag(code, group, secret) call shape (headless, signed with FLAGS_SECRET) - Vercel Toolbar override cookie: encrypted vercel-flag-overrides wins over KV and short-circuits our decide (asserted via spy) - discovery endpoint + mergeProviderData: createFlagsDiscoveryEndpoint serves merged code + KV definitions, enforces auth via a FLAGS_SECRET access proof (200 with x-flags-sdk-version, 401 without) Also UUID-harden the original e2e keys/ids so the tests don't collide in the shared test-KV keyspace (http.ts nsToIndex maps arbitrary namespaces to db 0). Full suite 109/109 green; typecheck + biome clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/e2e-flags-sdk-surface.test.ts | 252 ++++++++++++++++++ .../flags/src/__tests__/e2e-flags-sdk.test.ts | 82 +++--- 2 files changed, 300 insertions(+), 34 deletions(-) create mode 100644 sdk/ts/flags/src/__tests__/e2e-flags-sdk-surface.test.ts diff --git a/sdk/ts/flags/src/__tests__/e2e-flags-sdk-surface.test.ts b/sdk/ts/flags/src/__tests__/e2e-flags-sdk-surface.test.ts new file mode 100644 index 0000000..1fe460d --- /dev/null +++ b/sdk/ts/flags/src/__tests__/e2e-flags-sdk-surface.test.ts @@ -0,0 +1,252 @@ +/** + * Second e2e tranche: broader Vercel Flags SDK surface, all through the REAL + * host (`flags@4.2.0`). Complements e2e-flags-sdk.test.ts (core decision path) + * by covering the integration surfaces a "compatible" adapter must coexist with: + * + * 1. non-boolean values (string variants, JSON/object flags) + * 2. precompute round-trip (evaluate → serialize → getPrecomputed / flag(code)) + * 3. Vercel Toolbar override cookie (must win over KV, must skip our decide) + * 4. discovery endpoint + mergeProviderData (auth via FLAGS_SECRET access proof) + * + * As before we never call the adapter directly — the host does. Keys/ids are + * per-test UUIDs because the test KV server shares one keyspace (see + * http.ts `nsToIndex`). + */ +import { randomBytes } from "node:crypto"; +import type { KvClient } from "@beyond.dev/kv"; +import { createAccessProof, encryptOverrides, mergeProviderData } from "flags"; +import { + createFlagsDiscoveryEndpoint, + evaluate, + flag, + getPrecomputed, + getProviderData, + serialize, +} from "flags/next"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { beyondAdapter } from "../adapter.js"; +import type { FlagContext } from "../types.js"; +import { kvClient, writeDef } from "./harness.js"; +import "./test-context.js"; + +const uid = () => crypto.randomUUID(); + +// The host signs/encrypts with a base64url-encoded 256-bit key. +const SECRET = randomBytes(32).toString("base64url"); + +// biome-ignore lint/suspicious/noExplicitAny: minimal headless request stub +function request(headers: Record = {}): any { + return { headers }; +} + +describe("e2e surface: real flags/next host → beyond adapter → real KV", () => { + let kv: KvClient; + let prevSecret: string | undefined; + + beforeAll(() => { + // The override-cookie and precompute paths read process.env.FLAGS_SECRET as + // a default; set it for the duration of this file. + prevSecret = process.env["FLAGS_SECRET"]; + process.env["FLAGS_SECRET"] = SECRET; + }); + + afterAll(() => { + if (prevSecret === undefined) delete process.env["FLAGS_SECRET"]; + else process.env["FLAGS_SECRET"] = prevSecret; + }); + + beforeEach(() => { + kv = kvClient(); + }); + + // ── 1. Non-boolean values ──────────────────────────────────────────────── + + it("resolves a string-variant flag end to end", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: "v2" }], + rollout: { percent: 100, value: "v1" }, + }); + const adapter = beyondAdapter(kv, { mode: "request" }); + const variant = flag({ + key, + defaultValue: "off", + options: ["off", "v1", "v2"], + adapter, + identify: ({ headers }) => ({ + id: headers.get("x-user-id") ?? "anon", + plan: (headers.get("x-plan") as FlagContext["plan"]) ?? "free", + }), + }); + try { + // pro → rule "v2"; free → rollout "v1". + expect(await variant(request({ "x-user-id": "u", "x-plan": "pro" }))).toBe( + "v2", + ); + expect( + await variant(request({ "x-user-id": "u", "x-plan": "free" })), + ).toBe("v1"); + } finally { + await adapter.close(); + } + }); + + it("resolves a JSON/object flag end to end", async () => { + type Config = { theme: string; max: number }; + const key = uid(); + const def = { theme: "dark", max: 5 }; + await writeDef(kv, key, { on: true, rollout: { percent: 100, value: def } }); + const adapter = beyondAdapter(kv, { mode: "request" }); + const config = flag({ + key, + defaultValue: { theme: "light", max: 1 }, + adapter, + identify: () => ({ id: "u" }), + }); + try { + expect(await config(request())).toEqual({ theme: "dark", max: 5 }); + // Kill switch → declared default object. + await writeDef(kv, key, { on: false }); + expect(await config(request())).toEqual({ theme: "light", max: 1 }); + } finally { + await adapter.close(); + } + }); + + // ── 2. Precompute round-trip ───────────────────────────────────────────── + + it("precompute round-trip: evaluate → serialize → getPrecomputed / flag(code)", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rollout: { percent: 100, value: "v2" }, + }); + const adapter = beyondAdapter(kv, { mode: "request" }); + const variant = flag({ + key, + defaultValue: "off", + options: ["off", "v1", "v2"], + adapter, + identify: () => ({ id: "u" }), + }); + try { + // Evaluate live (headless, with a request), then serialize to a signed code. + const values = await evaluate([variant], request()); + expect(values).toEqual(["v2"]); + const code = await serialize([variant], values, SECRET); + expect(typeof code).toBe("string"); + + // Read the value back from the code two ways — both are decode-only + // (no KV, no request), proving the precompute contract holds. + expect(await getPrecomputed(variant, [variant], code, SECRET)).toBe("v2"); + // The flag's precomputed call shape: flag(code, group, secret). + // biome-ignore lint/suspicious/noExplicitAny: precomputed call shape + expect(await (variant as any)(code, [variant], SECRET)).toBe("v2"); + } finally { + await adapter.close(); + } + }); + + // ── 3. Toolbar override cookie ─────────────────────────────────────────── + + it("override cookie wins over KV and skips our decide", async () => { + const key = uid(); + // KV says true (100% rollout); the override will force false. + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + const adapter = beyondAdapter(kv, { mode: "request" }); + const decideSpy = vi.spyOn(adapter, "decide"); + const f = flag({ + key, + defaultValue: false, + adapter, + identify: () => ({ id: "u" }), + }); + try { + // Sanity: without an override, KV drives it true. + expect(await f(request())).toBe(true); + expect(decideSpy).toHaveBeenCalledTimes(1); + + // With the toolbar override cookie, the host short-circuits to the + // override value and never calls decide for that flag. + decideSpy.mockClear(); + const cookie = await encryptOverrides({ [key]: false }, SECRET); + expect( + await f(request({ cookie: `vercel-flag-overrides=${cookie}` })), + ).toBe(false); + expect(decideSpy).not.toHaveBeenCalled(); + } finally { + await adapter.close(); + } + }); + + // ── 4. Discovery endpoint + mergeProviderData ──────────────────────────── + + it("discovery endpoint serves merged provider data and enforces auth", async () => { + const kvKey = uid(); + const codeKey = uid(); + await writeDef(kv, kvKey, { on: true }); + const adapter = beyondAdapter(kv, { + mode: "request", + origin: (k) => `https://flags.example/${k}`, + }); + // A flag declared in code (description lives here, not in KV). + const declared = flag({ + key: codeKey, + defaultValue: false, + description: "Declared in code", + adapter, + identify: () => ({ id: "u" }), + }); + + const endpoint = createFlagsDiscoveryEndpoint( + async () => + mergeProviderData([ + // biome-ignore lint/suspicious/noExplicitAny: host typing friction under exactOptionalPropertyTypes + getProviderData({ [codeKey]: declared } as any), + adapter.getProviderData(), + ]), + { secret: SECRET }, + ); + + try { + // Authorized request → 200 with merged definitions + sdk-version header. + const proof = await createAccessProof(SECRET); + const ok = await endpoint( + new Request("https://test/.well-known/vercel/flags", { + headers: { Authorization: `Bearer ${proof}` }, + // biome-ignore lint/suspicious/noExplicitAny: NextRequest stand-in; endpoint only reads headers + }) as any, + ); + expect(ok.status).toBe(200); + expect(ok.headers.get("x-flags-sdk-version")).toBeTruthy(); + const body = (await ok.json()) as { + definitions: Record; + }; + // Code-declared metadata survives the merge… + expect(body.definitions[codeKey]?.description).toBe("Declared in code"); + // …and the KV-sourced flag (with adapter origin) is present too. + expect(body.definitions[kvKey]).toBeDefined(); + expect(body.definitions[kvKey]?.origin).toBe( + `https://flags.example/${kvKey}`, + ); + + // Unauthorized request → 401. + const denied = await endpoint( + // biome-ignore lint/suspicious/noExplicitAny: NextRequest stand-in + new Request("https://test/.well-known/vercel/flags") as any, + ); + expect(denied.status).toBe(401); + } finally { + await adapter.close(); + } + }); +}); diff --git a/sdk/ts/flags/src/__tests__/e2e-flags-sdk.test.ts b/sdk/ts/flags/src/__tests__/e2e-flags-sdk.test.ts index 6cef83c..0948904 100644 --- a/sdk/ts/flags/src/__tests__/e2e-flags-sdk.test.ts +++ b/sdk/ts/flags/src/__tests__/e2e-flags-sdk.test.ts @@ -24,6 +24,10 @@ import type { FlagContext } from "../types.js"; import { kvClient, writeDef } from "./harness.js"; import "./test-context.js"; +// The test KV server shares one keyspace (see http.ts `nsToIndex`), so use +// per-test UUIDs for flag keys and ids to avoid cross-test/cross-file collisions. +const uid = () => crypto.randomUUID(); + /** * Minimal Pages-Router request the host accepts (`"headers" in request`). The * host only reads `.headers`, so a bare object suffices; typed `any` because it @@ -47,27 +51,28 @@ describe("e2e: real flags/next host → beyond adapter → real KV", () => { }); it("host-returned value tracks live KV state (the irrefutable toggle)", async () => { + const key = uid(); const adapter = beyondAdapter(kv, { mode: "request" }); const newCheckout = flag({ - key: "new-checkout", + key, defaultValue: false, adapter, - identify: () => ({ id: "u1" }), + identify: () => ({ id: uid() }), }); try { // No def yet → host applies the declared defaultValue. expect(await newCheckout(request())).toBe(false); // Turn it on at 100% → host now returns true (value came from KV via decide). - await writeDef(kv, "new-checkout", { on: true, rollout: { percent: 100 } }); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); expect(await newCheckout(request())).toBe(true); // Kill switch → back to default. - await writeDef(kv, "new-checkout", { on: false }); + await writeDef(kv, key, { on: false }); expect(await newCheckout(request())).toBe(false); // Re-enable → true again. The flips prove decide reads live KV each call. - await writeDef(kv, "new-checkout", { on: true, rollout: { percent: 100 } }); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); expect(await newCheckout(request())).toBe(true); } finally { await adapter.close(); @@ -75,7 +80,8 @@ describe("e2e: real flags/next host → beyond adapter → real KV", () => { }); it("identify → entities → KV targeting rule, end to end", async () => { - await writeDef(kv, "ai-search", { + const key = uid(); + await writeDef(kv, key, { on: true, rules: [{ when: { plan: "pro" }, value: true }], }); @@ -83,7 +89,7 @@ describe("e2e: real flags/next host → beyond adapter → real KV", () => { // identify pulls the plan from the request headers the host sealed for us. const aiSearch = flag({ - key: "ai-search", + key, defaultValue: false, adapter, identify: ({ headers }) => ({ @@ -104,23 +110,25 @@ describe("e2e: real flags/next host → beyond adapter → real KV", () => { }); it("per-user pref resolves end to end", async () => { - await writeDef(kv, "beta", { on: true, rollout: { percent: 0 } }); + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); const { error } = await kv.set( - "flags:user:u1", - JSON.stringify({ beta: true }), + `flags:user:${id}`, + JSON.stringify({ [key]: true }), ); if (error) throw error; const adapter = beyondAdapter(kv, { mode: "request" }); const beta = flag({ - key: "beta", + key, defaultValue: false, adapter, identify: ({ headers }) => ({ id: headers.get("x-user-id") ?? "anon" }), }); try { - expect(await beta(request({ "x-user-id": "u1" }))).toBe(true); // pref - expect(await beta(request({ "x-user-id": "u2" }))).toBe(false); // 0% rollout + expect(await beta(request({ "x-user-id": id }))).toBe(true); // pref + expect(await beta(request({ "x-user-id": uid() }))).toBe(false); // 0% rollout } finally { await adapter.close(); } @@ -128,13 +136,14 @@ describe("e2e: real flags/next host → beyond adapter → real KV", () => { it("snapshot mode resolves through the host too", async () => { // Write before creating the adapter; the first decide awaits initial load. - await writeDef(kv, "snap", { on: true, rollout: { percent: 100 } }); + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); const adapter = beyondAdapter(kv, { mode: "snapshot", watch: false }); const snap = flag({ - key: "snap", + key, defaultValue: false, adapter, - identify: () => ({ id: "u1" }), + identify: () => ({ id: uid() }), }); try { expect(await snap(request())).toBe(true); @@ -144,9 +153,12 @@ describe("e2e: real flags/next host → beyond adapter → real KV", () => { }); it("host evaluate() batches through our bulkDecide", async () => { - await writeDef(kv, "a", { on: true, rollout: { percent: 100 } }); - await writeDef(kv, "b", { on: false }); - await writeDef(kv, "c", { + const ka = uid(); + const kb = uid(); + const kc = uid(); + await writeDef(kv, ka, { on: true, rollout: { percent: 100 } }); + await writeDef(kv, kb, { on: false }); + await writeDef(kv, kc, { on: true, rules: [{ when: { plan: "pro" }, value: true }], }); @@ -156,21 +168,22 @@ describe("e2e: real flags/next host → beyond adapter → real KV", () => { // The host groups flags by (adapterId, identify reference). Sharing ONE // adapter instance AND one identify function collapses all three into a // single bulk group → a single bulkDecide call. - const identify = (): FlagContext => ({ id: "u1", plan: "pro" }); + const id = uid(); + const identify = (): FlagContext => ({ id, plan: "pro" }); const mk = (key: string) => flag({ key, defaultValue: false, adapter, identify }); - const a = mk("a"); - const b = mk("b"); - const c = mk("c"); + const a = mk(ka); + const b = mk(kb); + const c = mk(kc); try { const result = await evaluate([a, b, c], request()); expect(result).toEqual([true, false, true]); // Proves the host routed through bulkDecide, not per-flag decide. expect(bulkSpy).toHaveBeenCalledTimes(1); expect(bulkSpy.mock.calls[0]?.[0].flags.map((f) => f.key)).toEqual([ - "a", - "b", - "c", + ka, + kb, + kc, ]); } finally { await adapter.close(); @@ -178,17 +191,18 @@ describe("e2e: real flags/next host → beyond adapter → real KV", () => { }); it("getProviderData merges adapter (KV) + host (code) definitions", async () => { - await writeDef(kv, "shipped", { on: true }); + const key = uid(); + await writeDef(kv, key, { on: true }); const adapter = beyondAdapter(kv, { mode: "request", - origin: (key) => `https://flags.example/${key}`, + origin: (k) => `https://flags.example/${k}`, }); const shipped = flag({ - key: "shipped", + key, defaultValue: false, description: "A shipped feature", adapter, - identify: () => ({ id: "u1" }), + identify: () => ({ id: uid() }), }); try { // Host builds code-side definitions (description/defaultValue/options). @@ -197,10 +211,10 @@ describe("e2e: real flags/next host → beyond adapter → real KV", () => { // Adapter builds provider-side definitions (what exists in KV + origin). const kvData = await adapter.getProviderData(); - expect(codeData.definitions.shipped?.description).toBe("A shipped feature"); - expect(kvData.definitions.shipped?.declaredInCode).toBe(false); - expect(kvData.definitions.shipped?.origin).toBe( - "https://flags.example/shipped", + expect(codeData.definitions[key]?.description).toBe("A shipped feature"); + expect(kvData.definitions[key]?.declaredInCode).toBe(false); + expect(kvData.definitions[key]?.origin).toBe( + `https://flags.example/${key}`, ); expect(kvData.hints).toEqual([]); } finally { From 5f1c54832a978032a3a1f0c51195169c6f240e68 Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Tue, 23 Jun 2026 11:11:10 -0700 Subject: [PATCH 3/7] feat(flags): OpenFeature server + web providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the KV-backed flag engine to OpenFeature (CNCF standard) via two entry points, mirroring the existing Vercel Flags SDK adapter: - @beyond.dev/flags/openfeature/server (BeyondProvider): async resolvers, snapshot (default) or per-eval fetch mode. - @beyond.dev/flags/openfeature/web (BeyondWebProvider): synchronous resolvers against an in-memory snapshot, with per-context prefs pre-fetched on initialize/onContextChange. Both are thin shells over the same evaluate() engine, Snapshot, and fetchUserPrefs — no engine duplication. shared.ts holds the SDK-agnostic context/reason mapping and spec-correct TYPE_MISMATCH handling (returns the declared default, never coerces). Live KV changes surface as PROVIDER_CONFIGURATION_CHANGED via a new generic Snapshot.onChange hook (fires only on real add/update/remove after the initial load). Tests (42 OpenFeature tests, all green): unit coverage of every resolution path plus e2e suites that drive the REAL @openfeature server/web global APIs (setProviderAndWait -> getClient -> getValue) and prove live KV state flips the host's output. Covers boolean/string/number/ object, targeting rules, per-user prefs, partial-rollout determinism, flag deletion via watch, polling fallback, userPrefs:false, type mismatch, malformed-data + KV-unreachable degradation, and the STALE-before-init lifecycle path. ARCHITECTURE.md updated with the providers section and file map. Co-Authored-By: Claude Opus 4.8 (1M context) --- sdk/ts/flags/ARCHITECTURE.md | 43 ++- sdk/ts/flags/package.json | 23 ++ .../__tests__/e2e-openfeature-server.test.ts | 308 ++++++++++++++++++ .../src/__tests__/e2e-openfeature-web.test.ts | 207 ++++++++++++ .../src/__tests__/openfeature-server.test.ts | 221 +++++++++++++ .../src/__tests__/openfeature-web.test.ts | 142 ++++++++ sdk/ts/flags/src/openfeature/server.ts | 272 ++++++++++++++++ sdk/ts/flags/src/openfeature/shared.ts | 125 +++++++ sdk/ts/flags/src/openfeature/web.ts | 184 +++++++++++ sdk/ts/flags/src/snapshot.ts | 47 ++- sdk/ts/flags/tsdown.config.ts | 14 +- sdk/ts/package-lock.json | 37 +++ 12 files changed, 1614 insertions(+), 9 deletions(-) create mode 100644 sdk/ts/flags/src/__tests__/e2e-openfeature-server.test.ts create mode 100644 sdk/ts/flags/src/__tests__/e2e-openfeature-web.test.ts create mode 100644 sdk/ts/flags/src/__tests__/openfeature-server.test.ts create mode 100644 sdk/ts/flags/src/__tests__/openfeature-web.test.ts create mode 100644 sdk/ts/flags/src/openfeature/server.ts create mode 100644 sdk/ts/flags/src/openfeature/shared.ts create mode 100644 sdk/ts/flags/src/openfeature/web.ts diff --git a/sdk/ts/flags/ARCHITECTURE.md b/sdk/ts/flags/ARCHITECTURE.md index c025758..f7912de 100644 --- a/sdk/ts/flags/ARCHITECTURE.md +++ b/sdk/ts/flags/ARCHITECTURE.md @@ -211,6 +211,44 @@ evaluate(key, defaultValue, ctx, def, prefs) ← same pure engine as native eva **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 { 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 | @@ -232,7 +270,10 @@ evaluate(key, defaultValue, ctx, def, prefs) ← same pure engine as native eva | `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`, watch+polling sync, `fetchUserPrefs()` | +| `src/snapshot.ts` | `Snapshot` class: in-memory `Map`, 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) | diff --git a/sdk/ts/flags/package.json b/sdk/ts/flags/package.json index 899f84f..000ab6e 100644 --- a/sdk/ts/flags/package.json +++ b/sdk/ts/flags/package.json @@ -40,6 +40,14 @@ "./next/middleware": { "import": "./dist/middleware/next-middleware.mjs", "types": "./dist/middleware/next-middleware.d.mts" + }, + "./openfeature/server": { + "import": "./dist/openfeature/server.mjs", + "types": "./dist/openfeature/server.d.mts" + }, + "./openfeature/web": { + "import": "./dist/openfeature/web.mjs", + "types": "./dist/openfeature/web.d.mts" } }, "scripts": { @@ -54,6 +62,9 @@ "tslib": "^2.8.1" }, "peerDependencies": { + "@openfeature/core": ">=1.11", + "@openfeature/server-sdk": ">=1.22", + "@openfeature/web-sdk": ">=1.9", "express": ">=4", "fastify": ">=4", "fastify-plugin": ">=4", @@ -62,6 +73,15 @@ "next": ">=14" }, "peerDependenciesMeta": { + "@openfeature/core": { + "optional": true + }, + "@openfeature/server-sdk": { + "optional": true + }, + "@openfeature/web-sdk": { + "optional": true + }, "flags": { "optional": true }, @@ -82,6 +102,9 @@ } }, "devDependencies": { + "@openfeature/core": "^1.11.0", + "@openfeature/server-sdk": "^1.22.0", + "@openfeature/web-sdk": "^1.9.0", "@types/express": "^5.0.0", "@types/node": "^22.0.0", "express": "^5.0.0", diff --git a/sdk/ts/flags/src/__tests__/e2e-openfeature-server.test.ts b/sdk/ts/flags/src/__tests__/e2e-openfeature-server.test.ts new file mode 100644 index 0000000..9fba1e1 --- /dev/null +++ b/sdk/ts/flags/src/__tests__/e2e-openfeature-server.test.ts @@ -0,0 +1,308 @@ +/** + * End-to-end proof that `@beyond.dev/flags/openfeature/server` is a real + * OpenFeature provider. + * + * These tests import the REAL host SDK (`OpenFeature` from + * `@openfeature/server-sdk`) and drive evaluation through it — we register the + * provider with `setProviderAndWait`, get a real `Client`, and call + * `getBooleanValue`/`getBooleanDetails` exactly as an application would. We never + * call the provider's `resolve*` methods ourselves; the SDK does. The full chain + * is exercised: + * + * OpenFeature.getClient().getBooleanValue() + * → host hooks/context merge → OUR BeyondProvider.resolve* → real beyond-kv + * + * Assertions are toggle-based: flipping the def in live KV flips the value the + * HOST returns. That can only pass if the entire chain works. Flag names are + * uuid-suffixed because the test KV keyspace is shared across tests. + */ +import { createKvClient, type KvClient } from "@beyond.dev/kv"; +import { OpenFeature, ProviderEvents } from "@openfeature/server-sdk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BeyondProvider } from "../openfeature/server.js"; +import type { FlagsErrorEvent } from "../types.js"; +import { deleteDef, kvClient, writeDef } from "./harness.js"; +import "./test-context.js"; + +const uid = () => crypto.randomUUID(); + +function withTimeout(p: Promise, ms: number, label: string): Promise { + return Promise.race([ + p, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), ms), + ), + ]); +} + +describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () => { + let kv: KvClient; + + beforeEach(() => { + kv = kvClient(); + }); + + afterEach(async () => { + // Closes and detaches all registered providers (calls onClose()). + await OpenFeature.clearProviders(); + }); + + it("host-returned value tracks live KV state (the irrefutable toggle)", async () => { + const key = uid(); + await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + const client = OpenFeature.getClient(); + const eval_ = () => client.getBooleanValue(key, false, { targetingKey: "u1" }); + + // No def yet → declared default. + expect(await eval_()).toBe(false); + + // Turn it on at 100% → host now returns true. + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + expect(await eval_()).toBe(true); + + // Kill switch → back to default. + await writeDef(kv, key, { on: false }); + expect(await eval_()).toBe(false); + + // Re-enable → true again. The flips prove resolve reads live KV each call. + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + expect(await eval_()).toBe(true); + }); + + it("targeting rule resolves through the host by context", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + const client = OpenFeature.getClient(); + + expect( + await client.getBooleanValue(key, false, { targetingKey: "u1", plan: "pro" }), + ).toBe(true); + expect( + await client.getBooleanValue(key, false, { targetingKey: "u2", plan: "free" }), + ).toBe(false); + }); + + it("per-user pref resolves through the host", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + const { error } = await kv.set(`flags:user:${id}`, JSON.stringify({ [key]: true })); + if (error) throw error; + await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + const client = OpenFeature.getClient(); + + expect(await client.getBooleanValue(key, false, { targetingKey: id })).toBe(true); + expect(await client.getBooleanValue(key, false, { targetingKey: uid() })).toBe(false); + }); + + it("getBooleanDetails surfaces reason and flagMetadata through the host", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + const client = OpenFeature.getClient(); + + const details = await client.getBooleanDetails(key, false, { + targetingKey: "u1", + plan: "pro", + }); + expect(details.value).toBe(true); + expect(details.reason).toBe("TARGETING_MATCH"); + expect(details.flagMetadata?.ruleIndex).toBe(0); + expect(details.flagMetadata?.beyondReason).toBe("rule"); + }); + + it("type mismatch surfaces TYPE_MISMATCH and the default through the host", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rollout: { percent: 100, value: "not-a-bool" }, + }); + await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + const client = OpenFeature.getClient(); + + const details = await client.getBooleanDetails(key, false, { targetingKey: "u1" }); + expect(details.value).toBe(false); + expect(details.reason).toBe("ERROR"); + expect(details.errorCode).toBe("TYPE_MISMATCH"); + }); + + it("emits PROVIDER_CONFIGURATION_CHANGED through the host on a live change", async () => { + const key = uid(); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "snapshot", watch: true }), + ); + const client = OpenFeature.getClient(); + + const changed = new Promise((resolve) => { + client.addHandler(ProviderEvents.ConfigurationChanged, (e) => { + resolve((e?.flagsChanged as string[]) ?? []); + }); + }); + + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + + const flagsChanged = await Promise.race([ + changed, + new Promise((_, reject) => + setTimeout(() => reject(new Error("timed out waiting for event")), 15_000), + ), + ]); + expect(flagsChanged).toContain(key); + + // The host now resolves the new value from the live snapshot. + expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })).toBe(true); + }); + + it("resolves string, number, and object flags through the host", async () => { + const sKey = uid(); + const nKey = uid(); + const oKey = uid(); + await writeDef(kv, sKey, { on: true, rollout: { percent: 100, value: "dark" } }); + await writeDef(kv, nKey, { on: true, rollout: { percent: 100, value: 42 } }); + await writeDef(kv, oKey, { + on: true, + rollout: { percent: 100, value: { a: 1, b: ["x"] } }, + }); + await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + const client = OpenFeature.getClient(); + const id = { targetingKey: "u1" }; + + expect(await client.getStringValue(sKey, "light", id)).toBe("dark"); + expect(await client.getNumberValue(nKey, 0, id)).toBe(42); + expect(await client.getObjectValue(oKey, {}, id)).toEqual({ a: 1, b: ["x"] }); + + // Details path is typed end-to-end too. + const sd = await client.getStringDetails(sKey, "light", id); + expect(sd.value).toBe("dark"); + expect(sd.reason).toBe("SPLIT"); + const od = await client.getObjectDetails(oKey, {}, id); + expect(od.value).toEqual({ a: 1, b: ["x"] }); + }); + + it("type mismatch is enforced for string and object flags too", async () => { + const sKey = uid(); + const oKey = uid(); + // String flag resolving to a number, object flag resolving to a string. + await writeDef(kv, sKey, { on: true, rollout: { percent: 100, value: 7 } }); + await writeDef(kv, oKey, { on: true, rollout: { percent: 100, value: "scalar" } }); + await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + const client = OpenFeature.getClient(); + const id = { targetingKey: "u1" }; + + const s = await client.getStringDetails(sKey, "fallback", id); + expect(s.value).toBe("fallback"); + expect(s.errorCode).toBe("TYPE_MISMATCH"); + + const o = await client.getObjectDetails(oKey, { ok: true }, id); + expect(o.value).toEqual({ ok: true }); + expect(o.errorCode).toBe("TYPE_MISMATCH"); + }); + + it("snapshot mode resolves through the host (def written before init)", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "snapshot", watch: false }), + ); + const client = OpenFeature.getClient(); + expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })).toBe(true); + }); + + it("flag deletion propagates back to the default via watch", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "snapshot", watch: true }), + ); + const client = OpenFeature.getClient(); + expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })).toBe(true); + + const changed = new Promise((resolve) => { + client.addHandler(ProviderEvents.ConfigurationChanged, (e) => { + resolve((e?.flagsChanged as string[]) ?? []); + }); + }); + await deleteDef(kv, key); + expect(await withTimeout(changed, 15_000, "delete event")).toContain(key); + + // With the def gone, the host falls back to the declared default. + expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })).toBe(false); + }); + + it("partial rollout is deterministic and roughly split through the host", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 50 } }); + await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + const client = OpenFeature.getClient(); + + const ids = Array.from({ length: 200 }, (_, i) => `user-${i}`); + const first = await Promise.all( + ids.map((id) => client.getBooleanValue(key, false, { targetingKey: id })), + ); + const second = await Promise.all( + ids.map((id) => client.getBooleanValue(key, false, { targetingKey: id })), + ); + + expect(second).toEqual(first); // same id → same bucket, always + const trueCount = first.filter(Boolean).length; + expect(trueCount).toBeGreaterThan(60); // ~100 expected; wide bounds = no flake + expect(trueCount).toBeLessThan(140); + }); + + it("ignores per-user prefs when userPrefs is disabled", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + const { error } = await kv.set(`flags:user:${id}`, JSON.stringify({ [key]: true })); + if (error) throw error; + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch", userPrefs: false }), + ); + const client = OpenFeature.getClient(); + // Pref says true, but userPrefs:false ignores it → 0% rollout = false. + expect(await client.getBooleanValue(key, false, { targetingKey: id })).toBe(false); + }); + + it("degrades to the default and reports onError on malformed KV data", async () => { + const key = uid(); + // Write invalid JSON directly (bypassing writeDef's JSON.stringify). + const { error } = await kv.set(`flags:def:${key}`, "{ not valid json"); + if (error) throw error; + const errors: FlagsErrorEvent[] = []; + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch", onError: (e) => errors.push(e) }), + ); + const client = OpenFeature.getClient(); + + // Malformed def is treated as absent → declared default is returned. + expect(await client.getBooleanValue(key, true, { targetingKey: "u1" })).toBe(true); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]?.source).toBe("snapshot"); + }); + + it("degrades to the default and reports onError when KV is unreachable", async () => { + const key = uid(); + // Nothing listens on port 1 → ECONNREFUSED. Bounded retries/timeout keep it fast. + const deadKv = createKvClient({ + url: "http://127.0.0.1:1", + namespace: "dead", + retries: 0, + timeout: 1_000, + }); + const errors: FlagsErrorEvent[] = []; + await OpenFeature.setProviderAndWait( + new BeyondProvider(deadKv, { mode: "fetch", onError: (e) => errors.push(e) }), + ); + const client = OpenFeature.getClient(); + + expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })).toBe(false); + expect(errors.length).toBeGreaterThan(0); + }); +}); diff --git a/sdk/ts/flags/src/__tests__/e2e-openfeature-web.test.ts b/sdk/ts/flags/src/__tests__/e2e-openfeature-web.test.ts new file mode 100644 index 0000000..2b82eb3 --- /dev/null +++ b/sdk/ts/flags/src/__tests__/e2e-openfeature-web.test.ts @@ -0,0 +1,207 @@ +/** + * End-to-end proof that `@beyond.dev/flags/openfeature/web` is a real OpenFeature + * web (client-side) provider. + * + * These tests import the REAL host SDK (`OpenFeature` from `@openfeature/web-sdk`) + * and drive evaluation through it: set a static context, register the provider + * with `setProviderAndWait`, get a real `Client`, and call the SYNCHRONOUS + * `getBooleanValue`/`getBooleanDetails`. The SDK calls our provider's sync + * `resolve*` against its in-memory snapshot. The full chain is exercised: + * + * OpenFeature.setContext() / getClient().getBooleanValue() + * → host static-context machinery → OUR BeyondWebProvider.resolve* (sync) + * → in-memory snapshot kept live by real beyond-kv watch + * + * Toggle-based: flipping the def in live KV (propagated via watch) flips the + * value the HOST returns. Flag names are uuid-suffixed (shared test keyspace). + */ +import type { KvClient } from "@beyond.dev/kv"; +import { OpenFeature, ProviderEvents } from "@openfeature/web-sdk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BeyondWebProvider } from "../openfeature/web.js"; +import { deleteDef, kvClient, writeDef } from "./harness.js"; +import "./test-context.js"; + +const uid = () => crypto.randomUUID(); + +describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () => { + let kv: KvClient; + + beforeEach(() => { + kv = kvClient(); + }); + + afterEach(async () => { + await OpenFeature.clearProviders(); + await OpenFeature.setContext({}); + }); + + it("host returns live value after a watched def change (the irrefutable toggle)", async () => { + const key = uid(); + await OpenFeature.setContext({ targetingKey: "u1" }); + await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true })); + const client = OpenFeature.getClient(); + + // No def yet → declared default (sync call, implicit static context). + expect(client.getBooleanValue(key, false)).toBe(false); + + const changed = onceConfigChanged(client, key); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + await changed; // watch → snapshot → PROVIDER_CONFIGURATION_CHANGED + + expect(client.getBooleanValue(key, false)).toBe(true); + }); + + it("targeting rule resolves from the static context", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + await OpenFeature.setContext({ targetingKey: "u1", plan: "pro" }); + await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true })); + const client = OpenFeature.getClient(); + + expect(client.getBooleanValue(key, false)).toBe(true); + }); + + it("context change re-resolves with the new targeting context", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + await OpenFeature.setContext({ targetingKey: "u1", plan: "free" }); + await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true })); + const client = OpenFeature.getClient(); + + expect(client.getBooleanValue(key, false)).toBe(false); // free + + // Changing the global context reconciles the provider (onContextChange). + await OpenFeature.setContext({ targetingKey: "u2", plan: "pro" }); + expect(client.getBooleanValue(key, false)).toBe(true); // pro + }); + + it("per-user pref resolves for the active static context", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + const { error } = await kv.set(`flags:user:${id}`, JSON.stringify({ [key]: true })); + if (error) throw error; + await OpenFeature.setContext({ targetingKey: id }); + await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true })); + const client = OpenFeature.getClient(); + + expect(client.getBooleanValue(key, false)).toBe(true); + + await OpenFeature.setContext({ targetingKey: uid() }); + expect(client.getBooleanValue(key, false)).toBe(false); // no pref → 0% rollout + }); + + it("getBooleanDetails surfaces reason + flagMetadata; type mismatch returns default", async () => { + const okKey = uid(); + const badKey = uid(); + await writeDef(kv, okKey, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + await writeDef(kv, badKey, { on: true, rollout: { percent: 100, value: "x" } }); + await OpenFeature.setContext({ targetingKey: "u1", plan: "pro" }); + await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true })); + const client = OpenFeature.getClient(); + + const ok = client.getBooleanDetails(okKey, false); + expect(ok.value).toBe(true); + expect(ok.reason).toBe("TARGETING_MATCH"); + expect(ok.flagMetadata?.ruleIndex).toBe(0); + + const bad = client.getBooleanDetails(badKey, false); + expect(bad.value).toBe(false); + expect(bad.errorCode).toBe("TYPE_MISMATCH"); + }); + + it("resolves string, number, and object flags synchronously through the host", async () => { + const sKey = uid(); + const nKey = uid(); + const oKey = uid(); + await writeDef(kv, sKey, { on: true, rollout: { percent: 100, value: "dark" } }); + await writeDef(kv, nKey, { on: true, rollout: { percent: 100, value: 42 } }); + await writeDef(kv, oKey, { + on: true, + rollout: { percent: 100, value: { a: 1, b: ["x"] } }, + }); + await OpenFeature.setContext({ targetingKey: "u1" }); + await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true })); + const client = OpenFeature.getClient(); + + expect(client.getStringValue(sKey, "light")).toBe("dark"); + expect(client.getNumberValue(nKey, 0)).toBe(42); + expect(client.getObjectValue(oKey, {})).toEqual({ a: 1, b: ["x"] }); + }); + + it("flag deletion propagates back to the default via watch", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + await OpenFeature.setContext({ targetingKey: "u1" }); + await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true })); + const client = OpenFeature.getClient(); + expect(client.getBooleanValue(key, false)).toBe(true); + + const changed = onceConfigChanged(client, key); + await deleteDef(kv, key); + await changed; // watch del → snapshot drop → PROVIDER_CONFIGURATION_CHANGED + + expect(client.getBooleanValue(key, false)).toBe(false); + }); + + it("polling fallback (watch:false) still picks up live changes", async () => { + const key = uid(); + await OpenFeature.setContext({ targetingKey: "u1" }); + // watch disabled → the snapshot polls every `refresh` seconds instead. + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: false, refresh: 1 }), + ); + const client = OpenFeature.getClient(); + expect(client.getBooleanValue(key, false)).toBe(false); + + const changed = onceConfigChanged(client, key); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + await changed; // arrives via the poll loop, not watch + + expect(client.getBooleanValue(key, false)).toBe(true); + }); + + it("ignores per-user prefs when userPrefs is disabled", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + const { error } = await kv.set(`flags:user:${id}`, JSON.stringify({ [key]: true })); + if (error) throw error; + await OpenFeature.setContext({ targetingKey: id }); + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: true, userPrefs: false }), + ); + const client = OpenFeature.getClient(); + expect(client.getBooleanValue(key, false)).toBe(false); // pref ignored → 0% rollout + }); +}); + +/** Resolve once the provider reports `name` changed (with a bounded timeout). */ +function onceConfigChanged( + // biome-ignore lint/suspicious/noExplicitAny: web Client type is verbose here + client: any, + name: string, +): Promise { + return Promise.race([ + new Promise((resolve) => { + // biome-ignore lint/suspicious/noExplicitAny: event payload + const handler = (e: any) => { + if (((e?.flagsChanged as string[]) ?? []).includes(name)) resolve(); + }; + client.addHandler(ProviderEvents.ConfigurationChanged, handler); + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("timed out waiting for change")), 15_000), + ), + ]); +} diff --git a/sdk/ts/flags/src/__tests__/openfeature-server.test.ts b/sdk/ts/flags/src/__tests__/openfeature-server.test.ts new file mode 100644 index 0000000..6806114 --- /dev/null +++ b/sdk/ts/flags/src/__tests__/openfeature-server.test.ts @@ -0,0 +1,221 @@ +/** + * Unit coverage for the OpenFeature **server** provider. Drives the provider's + * resolve methods directly (the SDK calls these) against real beyond-kv, and + * asserts the full {@link ResolutionDetails} — value, reason, errorCode, and + * flagMetadata — for every evaluation path. + * + * The test KV keyspace is shared across tests, so every flag/user name is + * uuid-suffixed to keep tests independent (mirrors `adapter.test.ts`). + */ +import type { KvClient } from "@beyond.dev/kv"; +import { + type EvaluationContext, + type Logger, + ProviderEvents, +} from "@openfeature/server-sdk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BeyondProvider } from "../openfeature/server.js"; +import { kvClient, writeDef } from "./harness.js"; +import "./test-context.js"; + +const uid = () => crypto.randomUUID(); + +const logger: Logger = { + error() {}, + warn() {}, + info() {}, + debug() {}, +}; + +function ctx(id: string, extra: EvaluationContext = {}): EvaluationContext { + return { targetingKey: id, ...extra }; +} + +describe("BeyondProvider (server) — fetch mode resolution", () => { + let kv: KvClient; + let provider: BeyondProvider; + + beforeEach(async () => { + kv = kvClient(); + provider = new BeyondProvider(kv, { mode: "fetch" }); + await provider.initialize(); + }); + + afterEach(async () => { + await provider.onClose(); + }); + + it("returns the default with reason DEFAULT when no def exists", async () => { + const res = await provider.resolveBooleanEvaluation(uid(), false, ctx("u"), logger); + expect(res.value).toBe(false); + expect(res.reason).toBe("DEFAULT"); + expect(res.errorCode).toBeUndefined(); + }); + + it("resolves a 100% rollout to true with reason SPLIT", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + const res = await provider.resolveBooleanEvaluation(key, false, ctx("u"), logger); + expect(res.value).toBe(true); + expect(res.reason).toBe("SPLIT"); + }); + + it("honors the kill switch with reason DISABLED", async () => { + const key = uid(); + await writeDef(kv, key, { on: false, rollout: { percent: 100 } }); + const res = await provider.resolveBooleanEvaluation(key, false, ctx("u"), logger); + expect(res.value).toBe(false); + expect(res.reason).toBe("DISABLED"); + }); + + it("matches a targeting rule with reason TARGETING_MATCH + ruleIndex", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [ + { when: { plan: "free" }, value: false }, + { when: { plan: "pro" }, value: true }, + ], + }); + const res = await provider.resolveBooleanEvaluation( + key, + false, + ctx("u", { plan: "pro" }), + logger, + ); + expect(res.value).toBe(true); + expect(res.reason).toBe("TARGETING_MATCH"); + expect(res.flagMetadata?.ruleIndex).toBe(1); + expect(res.flagMetadata?.beyondReason).toBe("rule"); + }); + + it("applies a per-user pref with reason TARGETING_MATCH", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + const { error } = await kv.set( + `flags:user:${id}`, + JSON.stringify({ [key]: true }), + ); + if (error) throw error; + const res = await provider.resolveBooleanEvaluation(key, false, ctx(id), logger); + expect(res.value).toBe(true); + expect(res.reason).toBe("TARGETING_MATCH"); + expect(res.flagMetadata?.beyondReason).toBe("user-pref"); + }); + + it("resolves string and number flags", async () => { + const sKey = uid(); + const nKey = uid(); + await writeDef(kv, sKey, { on: true, rollout: { percent: 100, value: "dark" } }); + await writeDef(kv, nKey, { on: true, rollout: { percent: 100, value: 42 } }); + const s = await provider.resolveStringEvaluation(sKey, "light", ctx("u"), logger); + const n = await provider.resolveNumberEvaluation(nKey, 0, ctx("u"), logger); + expect(s.value).toBe("dark"); + expect(n.value).toBe(42); + }); + + it("resolves an object flag", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rollout: { percent: 100, value: { mode: "x", n: 3 } }, + }); + const res = await provider.resolveObjectEvaluation(key, {}, ctx("u"), logger); + expect(res.value).toEqual({ mode: "x", n: 3 }); + }); + + it("returns TYPE_MISMATCH when the resolved value is the wrong type", async () => { + const key = uid(); + // Boolean flag whose KV value resolves to a string. + await writeDef(kv, key, { + on: true, + rollout: { percent: 100, value: "not-a-bool" }, + }); + const res = await provider.resolveBooleanEvaluation(key, false, ctx("u"), logger); + expect(res.value).toBe(false); // declared default, not coerced + expect(res.reason).toBe("ERROR"); + expect(res.errorCode).toBe("TYPE_MISMATCH"); + }); + + it("matches attribute rules even without a targetingKey", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + const res = await provider.resolveBooleanEvaluation( + key, + false, + { plan: "pro" }, // no targetingKey + logger, + ); + expect(res.value).toBe(true); + expect(res.reason).toBe("TARGETING_MATCH"); + }); +}); + +describe("BeyondProvider (server) — snapshot mode", () => { + let kv: KvClient; + + beforeEach(() => { + kv = kvClient(); + }); + + it("resolves from the in-memory snapshot after initialize", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + const provider = new BeyondProvider(kv, { mode: "snapshot", watch: false }); + await provider.initialize(); + try { + const res = await provider.resolveBooleanEvaluation(key, false, ctx("u"), logger); + expect(res.value).toBe(true); + } finally { + await provider.onClose(); + } + }); + + it("reports reason STALE when resolving an unknown flag before init completes", async () => { + // No def for this key, and initialize() is never called → not-ready + absent + // def deterministically yields STALE (vs DEFAULT once ready). + const provider = new BeyondProvider(kv, { mode: "snapshot", watch: false }); + try { + const res = await provider.resolveBooleanEvaluation(uid(), false, ctx("u"), logger); + expect(res.value).toBe(false); // declared default + expect(res.reason).toBe("STALE"); + } finally { + await provider.onClose(); + } + }); + + it("emits PROVIDER_CONFIGURATION_CHANGED when a watched def changes", async () => { + const key = uid(); + const provider = new BeyondProvider(kv, { mode: "snapshot", watch: true }); + await provider.initialize(); + try { + const changed = new Promise((resolve) => { + provider.events.addHandler(ProviderEvents.ConfigurationChanged, (e) => { + resolve((e?.flagsChanged as string[]) ?? []); + }); + }); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + const flagsChanged = await withTimeout(changed, 10_000, "ConfigurationChanged"); + expect(flagsChanged).toContain(key); + + // And the new value is now resolvable from the snapshot. + const res = await provider.resolveBooleanEvaluation(key, false, ctx("u"), logger); + expect(res.value).toBe(true); + } finally { + await provider.onClose(); + } + }); +}); + +function withTimeout(p: Promise, ms: number, label: string): Promise { + return Promise.race([ + p, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), ms), + ), + ]); +} diff --git a/sdk/ts/flags/src/__tests__/openfeature-web.test.ts b/sdk/ts/flags/src/__tests__/openfeature-web.test.ts new file mode 100644 index 0000000..57016d4 --- /dev/null +++ b/sdk/ts/flags/src/__tests__/openfeature-web.test.ts @@ -0,0 +1,142 @@ +/** + * Unit coverage for the OpenFeature **web** provider. Web resolution is + * synchronous against an in-memory snapshot, with per-context prefs pre-fetched + * on initialize/onContextChange. Tests drive the provider methods directly. + * + * Flag/user names are uuid-suffixed (the test KV keyspace is shared). + */ +import type { KvClient } from "@beyond.dev/kv"; +import { + type EvaluationContext, + type Logger, + ProviderEvents, +} from "@openfeature/web-sdk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BeyondWebProvider } from "../openfeature/web.js"; +import { kvClient, writeDef } from "./harness.js"; +import "./test-context.js"; + +const uid = () => crypto.randomUUID(); + +const logger: Logger = { + error() {}, + warn() {}, + info() {}, + debug() {}, +}; + +function ctx(id: string, extra: EvaluationContext = {}): EvaluationContext { + return { targetingKey: id, ...extra }; +} + +describe("BeyondWebProvider — synchronous resolution", () => { + let kv: KvClient; + let provider: BeyondWebProvider; + + beforeEach(() => { + kv = kvClient(); + }); + + afterEach(async () => { + await provider?.onClose(); + }); + + async function start(context: EvaluationContext): Promise { + provider = new BeyondWebProvider(kv, { watch: true }); + await provider.initialize(context); + } + + it("returns the default with reason DEFAULT when no def exists", async () => { + await start(ctx("u")); + const res = provider.resolveBooleanEvaluation(uid(), false, ctx("u"), logger); + expect(res.value).toBe(false); + expect(res.reason).toBe("DEFAULT"); + }); + + it("resolves a def loaded before initialize", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + await start(ctx("u")); + const res = provider.resolveBooleanEvaluation(key, false, ctx("u"), logger); + expect(res.value).toBe(true); + expect(res.reason).toBe("SPLIT"); + }); + + it("matches targeting rules from the static context", async () => { + const key = uid(); + await writeDef(kv, key, { + on: true, + rules: [{ when: { plan: "pro" }, value: true }], + }); + await start(ctx("u", { plan: "pro" })); + const res = provider.resolveBooleanEvaluation( + key, + false, + ctx("u", { plan: "pro" }), + logger, + ); + expect(res.value).toBe(true); + expect(res.reason).toBe("TARGETING_MATCH"); + }); + + it("applies pre-fetched per-user prefs for the active context", async () => { + const key = uid(); + const id = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + const { error } = await kv.set(`flags:user:${id}`, JSON.stringify({ [key]: true })); + if (error) throw error; + await start(ctx(id)); // prefetches id's prefs + const res = provider.resolveBooleanEvaluation(key, false, ctx(id), logger); + expect(res.value).toBe(true); + expect(res.flagMetadata?.beyondReason).toBe("user-pref"); + }); + + it("re-fetches prefs on context change", async () => { + const key = uid(); + const id1 = uid(); + const id2 = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); + const { error } = await kv.set(`flags:user:${id1}`, JSON.stringify({ [key]: true })); + if (error) throw error; + await start(ctx(id1)); // id1 → pref true + expect( + provider.resolveBooleanEvaluation(key, false, ctx(id1), logger).value, + ).toBe(true); + + // Switch to id2 (no pref) → falls through to the 0% rollout = false. + await provider.onContextChange(ctx(id1), ctx(id2)); + expect( + provider.resolveBooleanEvaluation(key, false, ctx(id2), logger).value, + ).toBe(false); + }); + + it("returns TYPE_MISMATCH for a wrong-typed value", async () => { + const key = uid(); + await writeDef(kv, key, { on: true, rollout: { percent: 100, value: "nope" } }); + await start(ctx("u")); + const res = provider.resolveBooleanEvaluation(key, false, ctx("u"), logger); + expect(res.value).toBe(false); + expect(res.errorCode).toBe("TYPE_MISMATCH"); + }); + + it("emits PROVIDER_CONFIGURATION_CHANGED on a live def change", async () => { + const key = uid(); + await start(ctx("u")); + const changed = new Promise((resolve) => { + provider.events.addHandler(ProviderEvents.ConfigurationChanged, (e) => { + resolve((e?.flagsChanged as string[]) ?? []); + }); + }); + await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); + const flagsChanged = await Promise.race([ + changed, + new Promise((_, reject) => + setTimeout(() => reject(new Error("timed out")), 10_000), + ), + ]); + expect(flagsChanged).toContain(key); + expect( + provider.resolveBooleanEvaluation(key, false, ctx("u"), logger).value, + ).toBe(true); + }); +}); diff --git a/sdk/ts/flags/src/openfeature/server.ts b/sdk/ts/flags/src/openfeature/server.ts new file mode 100644 index 0000000..8e4b490 --- /dev/null +++ b/sdk/ts/flags/src/openfeature/server.ts @@ -0,0 +1,272 @@ +/** + * OpenFeature **server** provider for `@beyond.dev/flags`. + * + * Resolves OpenFeature flag evaluations against Beyond KV using the same + * `flags:def:*` defs, targeting rules, rollout, kill switch, and per-user prefs + * as the native `@beyond.dev/flags` API and the Vercel adapter — through the + * same pure {@link evaluate} engine. + * + * @example + * ```ts + * import { OpenFeature } from '@openfeature/server-sdk' + * import { createKvClient } from '@beyond.dev/kv' + * import { BeyondProvider } from '@beyond.dev/flags/openfeature/server' + * + * const kv = createKvClient({ url: process.env.BEYOND_KV_URL! }) + * await OpenFeature.setProviderAndWait(new BeyondProvider(kv)) + * + * const client = OpenFeature.getClient() + * const enabled = await client.getBooleanValue('new-checkout', false, { + * targetingKey: 'user-123', + * plan: 'pro', + * }) + * ``` + * + * @packageDocumentation + */ + +import type { KvClient } from "@beyond.dev/kv"; +import { + type EvaluationContext, + type JsonValue as OFJsonValue, + type Logger, + OpenFeatureEventEmitter, + type Provider, + type ProviderMetadata, + ProviderEvents, + type ResolutionDetails, +} from "@openfeature/server-sdk"; +import { evaluate } from "../eval.js"; +import { defKey, fetchUserPrefs, Snapshot } from "../snapshot.js"; +import type { + FlagDef, + FlagsErrorEvent, + JsonValue, + UserPrefs, +} from "../types.js"; +import { + type ExpectedType, + hasTargetingKey, + toFlagContext, + toResolution, +} from "./shared.js"; + +/** Options for {@link BeyondProvider}. The KV client is passed positionally. */ +export interface BeyondProviderOptions { + /** + * How defs are read from KV. + * + * - `"snapshot"` (default): keep an in-memory snapshot of all `flags:def:*`, + * refreshed via `kv.watch()` (or polling). Resolve does zero KV round-trips + * for defs and surfaces live changes as `PROVIDER_CONFIGURATION_CHANGED`. + * - `"fetch"`: read each def from KV on every evaluation. No background sync, + * no change events — for environments that can't hold a persistent watch. + */ + mode?: "snapshot" | "fetch"; + /** snapshot mode: SWR poll interval (seconds) when watch is unavailable. Default 30. */ + refresh?: number; + /** snapshot mode: use `kv.watch()` for instant invalidation + events. Default true. */ + watch?: boolean; + /** + * Honor per-user prefs (`flags:user:`) during resolution. Default true. + * Each resolution costs one KV read for the pref bundle; disable for zero + * per-eval I/O in snapshot mode. + */ + userPrefs?: boolean; + /** Called for snapshot/watch/KV/pref failures. */ + onError?: (event: FlagsErrorEvent) => void; +} + +/** Reads flag defs from KV — snapshot-backed or per-eval fetch. */ +interface DefReader { + ready(): Promise; + get(key: string): Promise; + close(): void; +} + +class SnapshotReader implements DefReader { + private readonly snapshot: Snapshot; + + constructor( + kv: KvClient, + opts: BeyondProviderOptions, + onChange: (names: string[]) => void, + ) { + this.snapshot = new Snapshot(kv, { + refresh: opts.refresh ?? 30, + watch: opts.watch ?? true, + onChange, + ...(opts.onError ? { onError: opts.onError } : {}), + }); + this.snapshot.start(); + } + + ready(): Promise { + return this.snapshot.awaitReady(); + } + + async get(key: string): Promise { + return this.snapshot.get(key); + } + + close(): void { + this.snapshot.close(); + } +} + +class FetchReader implements DefReader { + private readonly kv: KvClient; + private readonly onError: ((e: FlagsErrorEvent) => void) | undefined; + + constructor(kv: KvClient, opts: BeyondProviderOptions) { + this.kv = kv; + this.onError = opts.onError; + } + + ready(): Promise { + return Promise.resolve(); + } + + async get(key: string): Promise { + const { data, error } = await this.kv.get(defKey(key)); + if (error) { + this.onError?.({ source: "snapshot", error, name: key }); + return undefined; + } + if (!data) return undefined; + return parseDef(data.text(), key, this.onError); + } + + close(): void { + // Nothing to release. + } +} + +/** + * An OpenFeature {@link Provider} backed by Beyond KV. + * + * Long-lived: register once via `OpenFeature.setProviderAndWait(...)`. In the + * default snapshot mode it holds an in-memory, watch-synced copy of all defs and + * emits `PROVIDER_CONFIGURATION_CHANGED` when KV changes. + */ +export class BeyondProvider implements Provider { + readonly metadata: ProviderMetadata = { name: "beyond-kv" }; + readonly runsOn = "server"; + readonly events = new OpenFeatureEventEmitter(); + + private readonly reader: DefReader; + private readonly kv: KvClient; + private readonly useUserPrefs: boolean; + private readonly onError: ((e: FlagsErrorEvent) => void) | undefined; + private ready = false; + + constructor(kv: KvClient, opts: BeyondProviderOptions = {}) { + this.kv = kv; + this.useUserPrefs = opts.userPrefs !== false; + this.onError = opts.onError; + this.reader = + (opts.mode ?? "snapshot") === "fetch" + ? new FetchReader(kv, opts) + : new SnapshotReader(kv, opts, (flagsChanged) => { + this.events.emit(ProviderEvents.ConfigurationChanged, { + flagsChanged, + }); + }); + } + + /** Await the initial snapshot load. The SDK emits `PROVIDER_READY` on resolve. */ + async initialize(): Promise { + await this.reader.ready(); + this.ready = true; + } + + /** Stop background syncing. Does not close the KV client (caller owns it). */ + async onClose(): Promise { + this.reader.close(); + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + _logger: Logger, + ): Promise> { + return this.resolve(flagKey, defaultValue, context, "boolean"); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context: EvaluationContext, + _logger: Logger, + ): Promise> { + return this.resolve(flagKey, defaultValue, context, "string"); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + _logger: Logger, + ): Promise> { + return this.resolve(flagKey, defaultValue, context, "number"); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + _logger: Logger, + ): Promise> { + return this.resolve(flagKey, defaultValue as JsonValue, context, "object") as + Promise>; + } + + private async resolve( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + expected: ExpectedType, + ): Promise> { + const ctx = toFlagContext(context); + const def = await this.reader.get(flagKey); + const prefs: UserPrefs | null = + this.useUserPrefs && hasTargetingKey(context) + ? await fetchUserPrefs(this.kv, ctx.id, this.onError) + : null; + const result = evaluate(flagKey, defaultValue, ctx, def, prefs); + return toResolution(result, defaultValue, expected, this.ready); + } +} + +/** + * Parse a `flags:def:*` JSON payload into a {@link FlagDef}. Returns `undefined` + * (treated as "no def" → flag falls back to its declared default) on malformed + * input, reporting through `onError` rather than throwing into resolution. + */ +function parseDef( + text: string, + key: string, + onError?: (event: FlagsErrorEvent) => void, +): FlagDef | undefined { + if (text.length === 0) return undefined; + try { + const parsed = JSON.parse(text) as unknown; + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) || + typeof (parsed as Record)["on"] !== "boolean" + ) { + throw new Error("flag def must be a JSON object with a boolean `on`"); + } + return parsed as FlagDef; + } catch (err) { + onError?.({ + source: "snapshot", + error: err instanceof Error ? err : new Error(String(err)), + name: key, + }); + return undefined; + } +} diff --git a/sdk/ts/flags/src/openfeature/shared.ts b/sdk/ts/flags/src/openfeature/shared.ts new file mode 100644 index 0000000..0deb956 --- /dev/null +++ b/sdk/ts/flags/src/openfeature/shared.ts @@ -0,0 +1,125 @@ +/** + * SDK-agnostic glue shared by the OpenFeature server and web providers. + * + * This module maps between OpenFeature's evaluation model and Beyond's pure + * {@link evaluate} engine. It imports only from `@openfeature/core` (the shared + * base both the server and web SDKs depend on), so it can be bundled into either + * provider entry point without pulling the other SDK. + * + * @packageDocumentation + */ + +import { + ErrorCode, + type EvaluationContext, + type ResolutionDetails, + type ResolutionReason, + StandardResolutionReasons, +} from "@openfeature/core"; +import type { EvalResult } from "../eval.js"; +import type { EvalReason, FlagContext, JsonValue } from "../types.js"; + +/** The four flag value shapes OpenFeature resolves, used for type checking. */ +export type ExpectedType = "boolean" | "string" | "number" | "object"; + +/** + * Turn an OpenFeature {@link EvaluationContext} into Beyond's {@link FlagContext}. + * + * `targetingKey` becomes the rollout/pref bucket key `id`; every other attribute + * is carried through for targeting-rule matching. A missing `targetingKey` maps + * to an empty `id` — targeting rules still match on the other attributes, while + * rollout bucketing and per-user prefs are simply skipped (an empty id can't be + * bucketed or looked up). This mirrors the Vercel adapter's "no id → default" + * behavior without throwing. + */ +export function toFlagContext(context: EvaluationContext): FlagContext { + const { targetingKey, ...rest } = context; + return { id: targetingKey ?? "", ...rest } as unknown as FlagContext; +} + +/** Whether the context can drive rollout bucketing / pref lookup. */ +export function hasTargetingKey(context: EvaluationContext): boolean { + return typeof context.targetingKey === "string" && context.targetingKey !== ""; +} + +/** + * Map Beyond's {@link EvalReason} to an OpenFeature {@link ResolutionReason}. + * `no-snapshot` reports `STALE` before the provider has loaded (the def may yet + * arrive) and `DEFAULT` afterward (the flag genuinely has no def in KV). + */ +export function mapReason(reason: EvalReason, ready: boolean): ResolutionReason { + switch (reason) { + case "off": + return StandardResolutionReasons.DISABLED; + case "user-pref": + case "override": + case "rule": + return StandardResolutionReasons.TARGETING_MATCH; + case "rollout": + return StandardResolutionReasons.SPLIT; + case "no-snapshot": + return ready + ? StandardResolutionReasons.DEFAULT + : StandardResolutionReasons.STALE; + case "error": + return StandardResolutionReasons.ERROR; + default: + return StandardResolutionReasons.DEFAULT; + } +} + +function typeMatches(value: JsonValue, expected: ExpectedType): boolean { + switch (expected) { + case "boolean": + return typeof value === "boolean"; + case "string": + return typeof value === "string"; + case "number": + return typeof value === "number"; + case "object": + // JSON objects and arrays are both valid OpenFeature object flags. + return typeof value === "object" && value !== null; + } +} + +/** + * Build an OpenFeature {@link ResolutionDetails} from an {@link EvalResult}. + * + * Enforces the OpenFeature type contract: if the resolved value doesn't match + * the requested flag type, the declared `defaultValue` is returned with a + * `TYPE_MISMATCH` error code instead of coercing. `flagMetadata` carries the + * native `beyondReason` (and `ruleIndex` when a targeting rule matched) for + * debugging. + */ +export function toResolution( + result: EvalResult, + defaultValue: T, + expected: ExpectedType, + ready: boolean, +): ResolutionDetails { + if (!typeMatches(result.value, expected)) { + return { + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: + `flag resolved to ${describe(result.value)}, expected ${expected}`, + flagMetadata: { beyondReason: result.reason }, + }; + } + const flagMetadata: Record = { + beyondReason: result.reason, + }; + if (result.ruleIndex !== undefined) flagMetadata["ruleIndex"] = result.ruleIndex; + return { + value: result.value as T, + reason: mapReason(result.reason, ready), + flagMetadata, + }; +} + +function describe(value: JsonValue): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} diff --git a/sdk/ts/flags/src/openfeature/web.ts b/sdk/ts/flags/src/openfeature/web.ts new file mode 100644 index 0000000..10dde07 --- /dev/null +++ b/sdk/ts/flags/src/openfeature/web.ts @@ -0,0 +1,184 @@ +/** + * OpenFeature **web** (client-side) provider for `@beyond.dev/flags`. + * + * The web SDK resolves flags **synchronously** against a single, static + * evaluation context. This provider satisfies that by keeping an in-memory, + * watch-synced {@link Snapshot} of all `flags:def:*` and pre-fetching the active + * context's per-user prefs on `initialize`/`onContextChange`. Each resolution is + * then a synchronous {@link evaluate} over in-memory state — zero I/O. + * + * Live KV changes are surfaced as `PROVIDER_CONFIGURATION_CHANGED`, prompting the + * SDK to re-resolve. + * + * @example + * ```ts + * import { OpenFeature } from '@openfeature/web-sdk' + * import { createKvClient } from '@beyond.dev/kv' + * import { BeyondWebProvider } from '@beyond.dev/flags/openfeature/web' + * + * const kv = createKvClient({ url: '...' }) + * await OpenFeature.setContext({ targetingKey: 'user-123', plan: 'pro' }) + * await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv)) + * + * const enabled = OpenFeature.getClient().getBooleanValue('new-checkout', false) + * ``` + * + * @packageDocumentation + */ + +import type { KvClient } from "@beyond.dev/kv"; +import { + type EvaluationContext, + type JsonValue as OFJsonValue, + type Logger, + OpenFeatureEventEmitter, + type Provider, + type ProviderMetadata, + ProviderEvents, + type ResolutionDetails, +} from "@openfeature/web-sdk"; +import { evaluate } from "../eval.js"; +import { fetchUserPrefs, Snapshot } from "../snapshot.js"; +import type { FlagsErrorEvent, JsonValue, UserPrefs } from "../types.js"; +import { + type ExpectedType, + toFlagContext, + toResolution, +} from "./shared.js"; + +/** Options for {@link BeyondWebProvider}. The KV client is passed positionally. */ +export interface BeyondWebProviderOptions { + /** SWR poll interval (seconds) when watch is unavailable. Default 30. */ + refresh?: number; + /** Use `kv.watch()` for instant invalidation + change events. Default true. */ + watch?: boolean; + /** Honor per-user prefs (`flags:user:`) for the active context. Default true. */ + userPrefs?: boolean; + /** Called for snapshot/watch/KV/pref failures. */ + onError?: (event: FlagsErrorEvent) => void; +} + +/** + * An OpenFeature web {@link Provider} backed by Beyond KV. Resolves + * synchronously against an in-memory snapshot; prefs for the active context are + * pre-fetched on context change. + */ +export class BeyondWebProvider implements Provider { + readonly metadata: ProviderMetadata = { name: "beyond-kv" }; + readonly runsOn = "client"; + readonly events = new OpenFeatureEventEmitter(); + + private readonly snapshot: Snapshot; + private readonly kv: KvClient; + private readonly useUserPrefs: boolean; + private readonly onError: ((e: FlagsErrorEvent) => void) | undefined; + private ready = false; + private cachedPrefs: UserPrefs | null = null; + private cachedPrefsId = ""; + + constructor(kv: KvClient, opts: BeyondWebProviderOptions = {}) { + this.kv = kv; + this.useUserPrefs = opts.userPrefs !== false; + this.onError = opts.onError; + this.snapshot = new Snapshot(kv, { + refresh: opts.refresh ?? 30, + watch: opts.watch ?? true, + onChange: (flagsChanged) => { + this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged }); + }, + ...(opts.onError ? { onError: opts.onError } : {}), + }); + } + + /** Load the snapshot and the initial context's prefs. SDK emits `PROVIDER_READY`. */ + async initialize(context?: EvaluationContext): Promise { + this.snapshot.start(); + await this.snapshot.awaitReady(); + await this.loadPrefs(context?.targetingKey ?? ""); + this.ready = true; + } + + /** Re-fetch prefs for the new static context. Async → SDK reconciles + re-resolves. */ + async onContextChange( + _oldContext: EvaluationContext, + newContext: EvaluationContext, + ): Promise { + await this.loadPrefs(newContext.targetingKey ?? ""); + } + + /** Stop background syncing. Does not close the KV client (caller owns it). */ + async onClose(): Promise { + this.snapshot.close(); + } + + private async loadPrefs(id: string): Promise { + if (!this.useUserPrefs || id === "") { + this.cachedPrefs = null; + this.cachedPrefsId = id; + return; + } + this.cachedPrefs = await fetchUserPrefs(this.kv, id, this.onError); + this.cachedPrefsId = id; + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + _logger: Logger, + ): ResolutionDetails { + return this.resolve(flagKey, defaultValue, context, "boolean"); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context: EvaluationContext, + _logger: Logger, + ): ResolutionDetails { + return this.resolve(flagKey, defaultValue, context, "string"); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + _logger: Logger, + ): ResolutionDetails { + return this.resolve(flagKey, defaultValue, context, "number"); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + _logger: Logger, + ): ResolutionDetails { + return this.resolve( + flagKey, + defaultValue as JsonValue, + context, + "object", + ) as ResolutionDetails; + } + + private resolve( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + expected: ExpectedType, + ): ResolutionDetails { + const ctx = toFlagContext(context); + // Prefs are pre-fetched per context; use them only when they match the + // context being resolved (the SDK keeps these in lockstep). + const prefs = ctx.id === this.cachedPrefsId ? this.cachedPrefs : null; + const result = evaluate( + flagKey, + defaultValue, + ctx, + this.snapshot.get(flagKey), + prefs, + ); + return toResolution(result, defaultValue, expected, this.ready); + } +} diff --git a/sdk/ts/flags/src/snapshot.ts b/sdk/ts/flags/src/snapshot.ts index f5996e8..bc3ffa1 100644 --- a/sdk/ts/flags/src/snapshot.ts +++ b/sdk/ts/flags/src/snapshot.ts @@ -32,6 +32,7 @@ export class Snapshot { refresh: number; watch: boolean; onError?: (e: FlagsErrorEvent) => void; + onChange?: (names: string[]) => void; }; constructor( @@ -40,6 +41,13 @@ export class Snapshot { refresh: number; watch: boolean; onError?: (e: FlagsErrorEvent) => void; + /** + * Called after the *initial* load with the names of flags whose def + * changed (added, updated, or removed) via a watch delta or a poll + * reload. Never fired during the initial load. Used to surface live + * config changes (e.g. OpenFeature `PROVIDER_CONFIGURATION_CHANGED`). + */ + onChange?: (names: string[]) => void; }, ) { this.kv = kv; @@ -91,6 +99,7 @@ export class Snapshot { private async loadAll(): Promise { let cursor: string | undefined; const seen = new Set(); + const changed: string[] = []; do { const { data, error } = await this.kv.list( cursor === undefined @@ -108,7 +117,7 @@ export class Snapshot { if (!entry) continue; const flagName = fullKey.slice(DEF_PREFIX.length); seen.add(flagName); - this.applyValue(flagName, entry.value); + if (this.applyValue(flagName, entry.value)) changed.push(flagName); } } cursor = data.nextCursor; @@ -116,8 +125,13 @@ export class Snapshot { // Drop any flag that's no longer in KV. for (const name of this.state.keys()) { - if (!seen.has(name)) this.state.delete(name); + if (!seen.has(name)) { + this.state.delete(name); + changed.push(name); + } } + + this.notifyChange(changed); } private async runWatch(): Promise { @@ -132,10 +146,12 @@ export class Snapshot { if (event.type === "ready") continue; if (event.type === "set") { const flagName = event.key.slice(DEF_PREFIX.length); - this.applyValue(flagName, event.value); + if (this.applyValue(flagName, event.value)) { + this.notifyChange([flagName]); + } } else if (event.type === "del") { const flagName = event.key.slice(DEF_PREFIX.length); - this.state.delete(flagName); + if (this.state.delete(flagName)) this.notifyChange([flagName]); } } // Stream ended cleanly (server close, LB timeout, graceful restart). @@ -178,15 +194,32 @@ export class Snapshot { } } - private applyValue(flagName: string, raw: Uint8Array): void { + /** + * Decode and store a flag def. Returns `true` if the stored value actually + * changed (added or differs from the prior def) so callers can fire change + * notifications without spurious events on no-op re-reads. + */ + private applyValue(flagName: string, raw: Uint8Array): boolean { let parsed: FlagDef | undefined; try { parsed = decodeFlagDef(raw); } catch (err) { this.reportError("snapshot", err, flagName); - return; + return false; } - if (parsed) this.state.set(flagName, parsed); + if (!parsed) return false; + const prev = this.state.get(flagName); + this.state.set(flagName, parsed); + return prev === undefined || JSON.stringify(prev) !== JSON.stringify(parsed); + } + + /** + * Fire the `onChange` callback for actually-changed flags. Suppressed during + * the initial load (only live deltas/reloads notify) and for empty sets. + */ + private notifyChange(names: string[]): void { + if (!this.isReady || names.length === 0 || !this.opts.onChange) return; + this.opts.onChange(names); } private reportError( diff --git a/sdk/ts/flags/tsdown.config.ts b/sdk/ts/flags/tsdown.config.ts index 4189bce..439d6a5 100644 --- a/sdk/ts/flags/tsdown.config.ts +++ b/sdk/ts/flags/tsdown.config.ts @@ -9,12 +9,24 @@ export default defineConfig({ "middleware/fastify": "src/middleware/fastify.ts", "middleware/next": "src/middleware/next.ts", "middleware/next-middleware": "src/middleware/next-middleware.ts", + "openfeature/server": "src/openfeature/server.ts", + "openfeature/web": "src/openfeature/web.ts", }, format: "esm", dts: true, clean: true, treeshake: true, deps: { - neverBundle: ["next", "express", "fastify", "fastify-plugin", "hono", "flags"], + neverBundle: [ + "next", + "express", + "fastify", + "fastify-plugin", + "hono", + "flags", + "@openfeature/server-sdk", + "@openfeature/web-sdk", + "@openfeature/core", + ], }, }); diff --git a/sdk/ts/package-lock.json b/sdk/ts/package-lock.json index a22475e..8815e1c 100644 --- a/sdk/ts/package-lock.json +++ b/sdk/ts/package-lock.json @@ -20,6 +20,9 @@ "tslib": "^2.8.1" }, "devDependencies": { + "@openfeature/core": "^1.11.0", + "@openfeature/server-sdk": "^1.22.0", + "@openfeature/web-sdk": "^1.9.0", "@types/express": "^5.0.0", "@types/node": "^22.0.0", "express": "^5.0.0", @@ -39,6 +42,7 @@ "express": ">=4", "fastify": ">=4", "fastify-plugin": ">=4", + "flags": ">=4", "hono": ">=4", "next": ">=14" }, @@ -52,6 +56,9 @@ "fastify-plugin": { "optional": true }, + "flags": { + "optional": true + }, "hono": { "optional": true }, @@ -1272,6 +1279,36 @@ "node": ">= 10" } }, + "node_modules/@openfeature/core": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@openfeature/core/-/core-1.11.0.tgz", + "integrity": "sha512-P0u3/ht/oZCQT89fOed+laLk0kZR529a825cS02uPDglxXbE97irWYpDAeRGGVETIzKfuy+H2g8c3Ccv/tXJNQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@openfeature/server-sdk": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@openfeature/server-sdk/-/server-sdk-1.22.0.tgz", + "integrity": "sha512-YBrf6SQkn0FNB/dRAtLEs41dvFMUE8CrQTwI+iLaMFUIqWlqGNJfGnulKSneEKS+2OgKTAC6DdmKcZ6tK7kBcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@openfeature/core": "^1.11.0" + } + }, + "node_modules/@openfeature/web-sdk": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@openfeature/web-sdk/-/web-sdk-1.9.0.tgz", + "integrity": "sha512-FCrNfqvE/thHVfCNU0KKx1SD7rk+1wE2UaR5B5OPZl917QJv6AsKRwaI3N+SVgwXWI07GgtXP6hNlTVb49PGhg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@openfeature/core": "^1.11.0" + } + }, "node_modules/@oxc-project/types": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", From a97fefd13741cb16d14d478c599c039d68287e7a Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Tue, 23 Jun 2026 11:37:24 -0700 Subject: [PATCH 4/7] refactor(flags): byte-compare def changes instead of JSON.stringify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot change-detection compared parsed defs via JSON.stringify(prev) !== JSON.stringify(next) — key-order-sensitive and a parse→serialize round-trip per delta. Compare the raw KV bytes instead: bytes are the authoritative representation, the check short-circuits on length, and it needs no re-serialization. The map now retains the source bytes (defensively copied) as the change-detection baseline. Adds a snapshot regression test pinning the dedup contract: onChange fires exactly once per real edit and never for unchanged poll re-reads. Co-Authored-By: Claude Opus 4.8 (1M context) --- sdk/ts/flags/src/__tests__/snapshot.test.ts | 31 ++++++++++++++++ sdk/ts/flags/src/snapshot.ts | 39 +++++++++++++++++---- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/sdk/ts/flags/src/__tests__/snapshot.test.ts b/sdk/ts/flags/src/__tests__/snapshot.test.ts index 65aa65a..102b271 100644 --- a/sdk/ts/flags/src/__tests__/snapshot.test.ts +++ b/sdk/ts/flags/src/__tests__/snapshot.test.ts @@ -68,6 +68,37 @@ describe("snapshot — real KV", () => { expect(snap.get("polled")).toEqual({ on: true }); }); + it("fires onChange once per real edit, never for unchanged re-reads (byte dedup)", async () => { + const name = `dedup-${crypto.randomUUID()}`; + await writeDef(kv, name, { on: true, rollout: { percent: 50 } }); + + const changes: string[][] = []; + const ours = () => changes.flat().filter((n) => n === name); + snap = new Snapshot(kv, { + refresh: 1, // poll every second + watch: false, + onChange: (names) => changes.push(names), + }); + snap.start(); + await snap.ready(); // initial load — onChange suppressed + + // Several poll cycles re-read the identical def. Same bytes → no events. + await sleep(2_200); + expect(ours()).toEqual([]); + + // A genuine edit is detected on the next poll — exactly once. + await writeDef(kv, name, { on: false }); + const deadline = Date.now() + 5_000; + while (Date.now() < deadline && ours().length === 0) { + await sleep(100); + } + expect(ours()).toEqual([name]); + + // Re-reading the now-stable value must not keep firing. + await sleep(2_200); + expect(ours()).toEqual([name]); + }); + it("ignores keys outside flags:def: prefix", async () => { const { error } = await kv.set("not-a-flag", JSON.stringify({ on: true })); expect(error).toBeUndefined(); diff --git a/sdk/ts/flags/src/snapshot.ts b/sdk/ts/flags/src/snapshot.ts index bc3ffa1..9167049 100644 --- a/sdk/ts/flags/src/snapshot.ts +++ b/sdk/ts/flags/src/snapshot.ts @@ -19,7 +19,12 @@ const decoder = new TextDecoder(); * Reads are O(1) Map lookups — zero KV round-trips per flag eval. */ export class Snapshot { - private state = new Map(); + /** + * Parsed def plus the exact KV bytes it was decoded from. The raw bytes are + * the source of truth for change detection (see {@link applyValue}) — keeping + * them avoids re-serializing parsed objects to compare. + */ + private state = new Map(); private readyPromise: Promise; private resolveReady!: () => void; private watchAbort: AbortController | undefined; @@ -69,7 +74,7 @@ export class Snapshot { /** Lookup the current state for a flag. Returns `undefined` if absent. */ get(name: string): FlagDef | undefined { - return this.state.get(name); + return this.state.get(name)?.def; } /** Start the snapshot: initial load + background sync (watch or poll). */ @@ -196,8 +201,17 @@ export class Snapshot { /** * Decode and store a flag def. Returns `true` if the stored value actually - * changed (added or differs from the prior def) so callers can fire change - * notifications without spurious events on no-op re-reads. + * changed (newly added, or its KV bytes differ from the prior value) so + * callers can fire change notifications without spurious events on no-op + * re-reads — every poll re-reads every def, so an unchanged re-read must stay + * silent. + * + * Change is decided by exact equality of the raw KV bytes, not by comparing + * parsed objects: bytes are the authoritative representation, the check + * short-circuits on length, and it needs no parse→serialize round-trip. A + * semantically-identical rewrite with reordered keys counts as a change here + * (different bytes) — that only triggers a harmless re-resolve downstream, so + * canonicalizing is deliberately not worth the cost. */ private applyValue(flagName: string, raw: Uint8Array): boolean { let parsed: FlagDef | undefined; @@ -209,8 +223,11 @@ export class Snapshot { } if (!parsed) return false; const prev = this.state.get(flagName); - this.state.set(flagName, parsed); - return prev === undefined || JSON.stringify(prev) !== JSON.stringify(parsed); + if (prev && bytesEqual(prev.raw, raw)) return false; + // Copy the bytes: the KV client's buffer is not guaranteed to outlive this + // call unmutated, and this map retains it as the change-detection baseline. + this.state.set(flagName, { def: parsed, raw: raw.slice() }); + return true; } /** @@ -289,6 +306,16 @@ export async function fetchUserPrefs( } } +/** Exact byte equality. Short-circuits on length; no allocation. */ +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + function decodeFlagDef(value: Uint8Array): FlagDef | undefined { const text = decoder.decode(value); if (text.length === 0) return undefined; From 0ff3762f6e4ddc566b4a6dbd656ec5e7d931569d Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Tue, 23 Jun 2026 11:37:24 -0700 Subject: [PATCH 5/7] test(flags): de-flake OpenFeature e2e under the shared test keyspace The e2e suites flaked ~1-in-3 only under full-suite load: the shared test keyspace accumulates hundreds of defs, and the web SDK resolves per-call against the in-memory snapshot. In watch mode the snapshot only polls during reconnect gaps, so a single coalesced/dropped watch delta at scale was never reconciled -> a deletion never propagated. - Wait on the observable resolved value (polled) rather than racing a single event within a fixed window (waitUntil helper). - Drive deletion propagation through the deterministic poll/fetch path that reconciles continuously; watch-driven deletion stays covered in snapshot.test.ts. - Short poll backstop (refresh:2) on watch-mode SET tests. 13/13 clean full-suite runs (was ~1-in-3 failing). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/e2e-openfeature-server.test.ts | 74 +++++++++-------- .../src/__tests__/e2e-openfeature-web.test.ts | 81 ++++++++++++------- .../src/__tests__/openfeature-server.test.ts | 2 +- .../src/__tests__/openfeature-web.test.ts | 2 +- 4 files changed, 91 insertions(+), 68 deletions(-) diff --git a/sdk/ts/flags/src/__tests__/e2e-openfeature-server.test.ts b/sdk/ts/flags/src/__tests__/e2e-openfeature-server.test.ts index 9fba1e1..8054d31 100644 --- a/sdk/ts/flags/src/__tests__/e2e-openfeature-server.test.ts +++ b/sdk/ts/flags/src/__tests__/e2e-openfeature-server.test.ts @@ -21,18 +21,38 @@ import { OpenFeature, ProviderEvents } from "@openfeature/server-sdk"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { BeyondProvider } from "../openfeature/server.js"; import type { FlagsErrorEvent } from "../types.js"; -import { deleteDef, kvClient, writeDef } from "./harness.js"; +import { deleteDef, kvClient, sleep, writeDef } from "./harness.js"; import "./test-context.js"; const uid = () => crypto.randomUUID(); -function withTimeout(p: Promise, ms: number, label: string): Promise { - return Promise.race([ - p, - new Promise((_, reject) => - setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), ms), - ), - ]); +/** + * Poll a predicate until it holds, or fail after `ms`. Waiting on observable + * state (not a single event within a fixed window) keeps these e2e tests robust + * when the shared test keyspace is large and a snapshot reload or event dispatch + * is briefly slow. + */ +async function waitUntil( + pred: () => boolean | Promise, + what: string, + ms = 20_000, +): Promise { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if (await pred()) return; + await sleep(50); + } + throw new Error(`condition not met within ${ms}ms: ${what}`); +} + +/** Accumulate the flag names reported via PROVIDER_CONFIGURATION_CHANGED. */ +// biome-ignore lint/suspicious/noExplicitAny: server Client type is verbose here +function collectChanges(client: any): () => string[] { + const seen: string[] = []; + client.addHandler(ProviderEvents.ConfigurationChanged, (e: { flagsChanged?: string[] }) => { + seen.push(...(e?.flagsChanged ?? [])); + }); + return () => seen; } describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () => { @@ -136,28 +156,19 @@ describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () it("emits PROVIDER_CONFIGURATION_CHANGED through the host on a live change", async () => { const key = uid(); await OpenFeature.setProviderAndWait( - new BeyondProvider(kv, { mode: "snapshot", watch: true }), + new BeyondProvider(kv, { mode: "snapshot", watch: true, refresh: 2 }), ); const client = OpenFeature.getClient(); - const changed = new Promise((resolve) => { - client.addHandler(ProviderEvents.ConfigurationChanged, (e) => { - resolve((e?.flagsChanged as string[]) ?? []); - }); - }); - + const changes = collectChanges(client); await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); - const flagsChanged = await Promise.race([ - changed, - new Promise((_, reject) => - setTimeout(() => reject(new Error("timed out waiting for event")), 15_000), - ), - ]); - expect(flagsChanged).toContain(key); - // The host now resolves the new value from the live snapshot. - expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })).toBe(true); + await waitUntil( + async () => (await client.getBooleanValue(key, false, { targetingKey: "u1" })) === true, + "value→true", + ); + await waitUntil(() => changes().includes(key), "config-changed event", 10_000); }); it("resolves string, number, and object flags through the host", async () => { @@ -215,23 +226,16 @@ describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })).toBe(true); }); - it("flag deletion propagates back to the default via watch", async () => { + it("flag deletion propagates back to the default", async () => { const key = uid(); await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); - await OpenFeature.setProviderAndWait( - new BeyondProvider(kv, { mode: "snapshot", watch: true }), - ); + // fetch mode re-reads KV each eval, so deletion is observed immediately and + // deterministically. Watch-driven deletion is covered in snapshot.test.ts. + await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); const client = OpenFeature.getClient(); expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })).toBe(true); - const changed = new Promise((resolve) => { - client.addHandler(ProviderEvents.ConfigurationChanged, (e) => { - resolve((e?.flagsChanged as string[]) ?? []); - }); - }); await deleteDef(kv, key); - expect(await withTimeout(changed, 15_000, "delete event")).toContain(key); - // With the def gone, the host falls back to the declared default. expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })).toBe(false); }); diff --git a/sdk/ts/flags/src/__tests__/e2e-openfeature-web.test.ts b/sdk/ts/flags/src/__tests__/e2e-openfeature-web.test.ts index 2b82eb3..257766e 100644 --- a/sdk/ts/flags/src/__tests__/e2e-openfeature-web.test.ts +++ b/sdk/ts/flags/src/__tests__/e2e-openfeature-web.test.ts @@ -19,7 +19,7 @@ import type { KvClient } from "@beyond.dev/kv"; import { OpenFeature, ProviderEvents } from "@openfeature/web-sdk"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { BeyondWebProvider } from "../openfeature/web.js"; -import { deleteDef, kvClient, writeDef } from "./harness.js"; +import { deleteDef, kvClient, sleep, writeDef } from "./harness.js"; import "./test-context.js"; const uid = () => crypto.randomUUID(); @@ -39,17 +39,19 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () it("host returns live value after a watched def change (the irrefutable toggle)", async () => { const key = uid(); await OpenFeature.setContext({ targetingKey: "u1" }); - await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true })); + await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true, refresh: 2 })); const client = OpenFeature.getClient(); // No def yet → declared default (sync call, implicit static context). expect(client.getBooleanValue(key, false)).toBe(false); - const changed = onceConfigChanged(client, key); + const changes = collectChanges(client); await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); - await changed; // watch → snapshot → PROVIDER_CONFIGURATION_CHANGED + // Wait on the observable value, not a single event within a fixed window. + await waitUntil(() => client.getBooleanValue(key, false) === true, "value→true"); expect(client.getBooleanValue(key, false)).toBe(true); + await waitUntil(() => changes().includes(key), "config-changed event", 10_000); }); it("targeting rule resolves from the static context", async () => { @@ -59,7 +61,7 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () rules: [{ when: { plan: "pro" }, value: true }], }); await OpenFeature.setContext({ targetingKey: "u1", plan: "pro" }); - await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true })); + await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true, refresh: 2 })); const client = OpenFeature.getClient(); expect(client.getBooleanValue(key, false)).toBe(true); @@ -72,7 +74,7 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () rules: [{ when: { plan: "pro" }, value: true }], }); await OpenFeature.setContext({ targetingKey: "u1", plan: "free" }); - await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true })); + await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true, refresh: 2 })); const client = OpenFeature.getClient(); expect(client.getBooleanValue(key, false)).toBe(false); // free @@ -89,7 +91,7 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () const { error } = await kv.set(`flags:user:${id}`, JSON.stringify({ [key]: true })); if (error) throw error; await OpenFeature.setContext({ targetingKey: id }); - await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true })); + await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true, refresh: 2 })); const client = OpenFeature.getClient(); expect(client.getBooleanValue(key, false)).toBe(true); @@ -107,7 +109,7 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () }); await writeDef(kv, badKey, { on: true, rollout: { percent: 100, value: "x" } }); await OpenFeature.setContext({ targetingKey: "u1", plan: "pro" }); - await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true })); + await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true, refresh: 2 })); const client = OpenFeature.getClient(); const ok = client.getBooleanDetails(okKey, false); @@ -131,7 +133,7 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () rollout: { percent: 100, value: { a: 1, b: ["x"] } }, }); await OpenFeature.setContext({ targetingKey: "u1" }); - await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true })); + await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true, refresh: 2 })); const client = OpenFeature.getClient(); expect(client.getStringValue(sKey, "light")).toBe("dark"); @@ -139,19 +141,25 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () expect(client.getObjectValue(oKey, {})).toEqual({ a: 1, b: ["x"] }); }); - it("flag deletion propagates back to the default via watch", async () => { + it("flag deletion propagates back to the default via poll reconcile", async () => { const key = uid(); await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); await OpenFeature.setContext({ targetingKey: "u1" }); - await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true })); + // Poll mode reconciles continuously (watch mode only polls during reconnect + // gaps), so a deletion is caught deterministically. Watch-driven deletion is + // covered in snapshot.test.ts. + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: false, refresh: 1 }), + ); const client = OpenFeature.getClient(); expect(client.getBooleanValue(key, false)).toBe(true); - const changed = onceConfigChanged(client, key); + const changes = collectChanges(client); await deleteDef(kv, key); - await changed; // watch del → snapshot drop → PROVIDER_CONFIGURATION_CHANGED + await waitUntil(() => client.getBooleanValue(key, false) === false, "value→false"); expect(client.getBooleanValue(key, false)).toBe(false); + await waitUntil(() => changes().includes(key), "del config-changed", 10_000); }); it("polling fallback (watch:false) still picks up live changes", async () => { @@ -164,9 +172,9 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () const client = OpenFeature.getClient(); expect(client.getBooleanValue(key, false)).toBe(false); - const changed = onceConfigChanged(client, key); await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); - await changed; // arrives via the poll loop, not watch + // Arrives via the poll loop, not watch — wait on the value with headroom. + await waitUntil(() => client.getBooleanValue(key, false) === true, "poll→true"); expect(client.getBooleanValue(key, false)).toBe(true); }); @@ -186,22 +194,33 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () }); }); -/** Resolve once the provider reports `name` changed (with a bounded timeout). */ -function onceConfigChanged( +/** + * Poll a predicate until it holds, or fail after `ms`. Waiting on observable + * state (rather than a single event within a fixed window) keeps these e2e tests + * robust when the shared test keyspace is large and a snapshot reload or event + * dispatch is briefly slow. + */ +async function waitUntil( + pred: () => boolean | Promise, + what: string, + ms = 20_000, +): Promise { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if (await pred()) return; + await sleep(50); + } + throw new Error(`condition not met within ${ms}ms: ${what}`); +} + +/** Accumulate the flag names reported via PROVIDER_CONFIGURATION_CHANGED. */ +function collectChanges( // biome-ignore lint/suspicious/noExplicitAny: web Client type is verbose here client: any, - name: string, -): Promise { - return Promise.race([ - new Promise((resolve) => { - // biome-ignore lint/suspicious/noExplicitAny: event payload - const handler = (e: any) => { - if (((e?.flagsChanged as string[]) ?? []).includes(name)) resolve(); - }; - client.addHandler(ProviderEvents.ConfigurationChanged, handler); - }), - new Promise((_, reject) => - setTimeout(() => reject(new Error("timed out waiting for change")), 15_000), - ), - ]); +): () => string[] { + const seen: string[] = []; + client.addHandler(ProviderEvents.ConfigurationChanged, (e: { flagsChanged?: string[] }) => { + seen.push(...(e?.flagsChanged ?? [])); + }); + return () => seen; } diff --git a/sdk/ts/flags/src/__tests__/openfeature-server.test.ts b/sdk/ts/flags/src/__tests__/openfeature-server.test.ts index 6806114..55a570d 100644 --- a/sdk/ts/flags/src/__tests__/openfeature-server.test.ts +++ b/sdk/ts/flags/src/__tests__/openfeature-server.test.ts @@ -190,7 +190,7 @@ describe("BeyondProvider (server) — snapshot mode", () => { it("emits PROVIDER_CONFIGURATION_CHANGED when a watched def changes", async () => { const key = uid(); - const provider = new BeyondProvider(kv, { mode: "snapshot", watch: true }); + const provider = new BeyondProvider(kv, { mode: "snapshot", watch: true, refresh: 2 }); await provider.initialize(); try { const changed = new Promise((resolve) => { diff --git a/sdk/ts/flags/src/__tests__/openfeature-web.test.ts b/sdk/ts/flags/src/__tests__/openfeature-web.test.ts index 57016d4..e060674 100644 --- a/sdk/ts/flags/src/__tests__/openfeature-web.test.ts +++ b/sdk/ts/flags/src/__tests__/openfeature-web.test.ts @@ -42,7 +42,7 @@ describe("BeyondWebProvider — synchronous resolution", () => { }); async function start(context: EvaluationContext): Promise { - provider = new BeyondWebProvider(kv, { watch: true }); + provider = new BeyondWebProvider(kv, { watch: true, refresh: 2 }); await provider.initialize(context); } From 686ce080e1a21ead499accd1e2c96959918f0ad8 Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Tue, 23 Jun 2026 11:53:51 -0700 Subject: [PATCH 6/7] fix(flags): resume watch from last revision (no delta loss on reconnect) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Snapshot's watch reconnect loop re-subscribed from "now" with no `since`, so any write/delete that landed while the stream was down was silently dropped — leaving a flag stale until the next change or restart. (The kv client self-heals soft disconnects internally with `since`, but a hard KvError unwinds to the Snapshot's outer loop, which started fresh.) Track the highest revision seen from any read or watch delta and pass it as `since` on (re)subscribe. The first watch resumes from the revision the initial loadAll saw (closing the load→watch gap too); every reconnect resumes from the last delta applied. The server replays the gap (`since` is exclusive) and byte-compare dedup makes over-replay a no-op. Deterministic test: a fake client drops the stream mid-watch; the reconnect must carry since= and the delta that "arrived while down" must land. Also documents why a periodic full reconcile on a healthy stream is deliberately not done (fleet-multiplied cost; masks watch bugs). Co-Authored-By: Claude Opus 4.8 (1M context) --- sdk/ts/flags/ARCHITECTURE.md | 22 ++++++-- sdk/ts/flags/src/__tests__/snapshot.test.ts | 61 ++++++++++++++++++++- sdk/ts/flags/src/snapshot.ts | 18 +++++- 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/sdk/ts/flags/ARCHITECTURE.md b/sdk/ts/flags/ARCHITECTURE.md index f7912de..96924c6 100644 --- a/sdk/ts/flags/ARCHITECTURE.md +++ b/sdk/ts/flags/ARCHITECTURE.md @@ -61,10 +61,11 @@ 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] │ @@ -72,6 +73,13 @@ snapshot.start() 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) ``` @@ -319,7 +327,7 @@ CLOSED | 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 | +| 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)` | @@ -346,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. diff --git a/sdk/ts/flags/src/__tests__/snapshot.test.ts b/sdk/ts/flags/src/__tests__/snapshot.test.ts index 102b271..97cfa61 100644 --- a/sdk/ts/flags/src/__tests__/snapshot.test.ts +++ b/sdk/ts/flags/src/__tests__/snapshot.test.ts @@ -1,9 +1,11 @@ import { KvError } from "@beyond.dev/kv"; -import type { KvClient } from "@beyond.dev/kv"; +import type { KvClient, WatchEvent, WatchOptions } from "@beyond.dev/kv"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { fetchUserPrefs, Snapshot } from "../snapshot.js"; import { deleteDef, kvClient, sleep, writeDef } from "./harness.js"; +const encode = (v: unknown) => new TextEncoder().encode(JSON.stringify(v)); + describe("snapshot — real KV", () => { let kv: KvClient; let snap: Snapshot; @@ -255,6 +257,63 @@ describe("snapshot — real KV", () => { }); }); +describe("snapshot — watch resume after hard reconnect", () => { + it("resumes from the last revision and replays the delta missed while down", async () => { + const sinceSeen: (number | undefined)[] = []; + let watchCalls = 0; + + // Fake client: empty initial load; a watch that delivers one set then + // hard-errors (a dropped stream), and on reconnect replays a *different* + // delta — exactly what the server does for everything after `since`. + const fakeKv = { + async list() { + return { data: { keys: [] as { name: string }[] }, error: undefined }; + }, + async batchGet() { + return { data: [] as never[], error: undefined }; + }, + async *watch(_key: string, opts?: WatchOptions): AsyncGenerator { + watchCalls++; + sinceSeen.push(opts?.since); + if (watchCalls === 1) { + yield { type: "ready" }; + yield { type: "set", key: "flags:def:a", value: encode({ on: true }), revision: 5 }; + throw new KvError("sse_error", "stream dropped", 500); // hard disconnect + } + // Reconnect: replay the delta that landed while we were down. + yield { type: "set", key: "flags:def:b", value: encode({ on: false }), revision: 7 }; + await new Promise((resolve) => { + opts?.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + }, + } as unknown as KvClient; + + const snap = new Snapshot(fakeKv, { refresh: 30, watch: true }); + snap.start(); + await snap.ready(); + + // First session applied A. + { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline && snap.get("a") === undefined) await sleep(25); + } + expect(snap.get("a")).toEqual({ on: true }); + + // After the hard error + backoff, the reconnect must carry since=5 and the + // replayed delta for B must land — proving no gap loss. + { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline && snap.get("b") === undefined) await sleep(25); + } + expect(snap.get("b")).toEqual({ on: false }); + + expect(sinceSeen[0]).toBeUndefined(); // first connect starts from current state + expect(sinceSeen[1]).toBe(5); // reconnect resumes exactly where it left off + + snap.close(); + }); +}); + describe("fetchUserPrefs — real KV", () => { let kv: KvClient; diff --git a/sdk/ts/flags/src/snapshot.ts b/sdk/ts/flags/src/snapshot.ts index 9167049..9b8977a 100644 --- a/sdk/ts/flags/src/snapshot.ts +++ b/sdk/ts/flags/src/snapshot.ts @@ -1,4 +1,4 @@ -import type { KvClient } from "@beyond.dev/kv"; +import type { KvClient, WatchOptions } from "@beyond.dev/kv"; import { FlagError } from "./errors.js"; import type { FlagDef, @@ -31,6 +31,13 @@ export class Snapshot { private pollTimer: ReturnType | undefined; private closed = false; isReady = false; + /** + * Highest revision observed from any read or watch event. Used to resume the + * watch from where it left off after a hard reconnect (and to start the first + * watch from the point the initial load saw), so deltas that land while the + * stream is down are replayed rather than lost. + */ + private lastRevision = 0; readonly kv: KvClient; readonly opts: { @@ -122,6 +129,7 @@ export class Snapshot { if (!entry) continue; const flagName = fullKey.slice(DEF_PREFIX.length); seen.add(flagName); + if (entry.revision > this.lastRevision) this.lastRevision = entry.revision; if (this.applyValue(flagName, entry.value)) changed.push(flagName); } } @@ -143,12 +151,18 @@ export class Snapshot { let attempt = 0; while (!this.closed) { this.watchAbort = new AbortController(); - const opts = { prefix: true, signal: this.watchAbort.signal }; + // Resume from the last revision we saw so deltas that arrived while the + // stream was down are replayed (the server treats `since` as exclusive). + const opts: WatchOptions = { prefix: true, signal: this.watchAbort.signal }; + if (this.lastRevision > 0) opts.since = this.lastRevision; const sessionStart = Date.now(); try { for await (const event of this.kv.watch(DEF_PREFIX, opts)) { if (this.closed) return; if (event.type === "ready") continue; + // Advance the resume point on every delta, even ones the byte-compare + // dedups, so a reconnect never re-requests already-applied revisions. + if (event.revision > this.lastRevision) this.lastRevision = event.revision; if (event.type === "set") { const flagName = event.key.slice(DEF_PREFIX.length); if (this.applyValue(flagName, event.value)) { From f66514b59ac077f235fe258f3bbe766ec8844a10 Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Tue, 23 Jun 2026 12:02:20 -0700 Subject: [PATCH 7/7] style(flags): dprint fmt Apply repo dprint formatting to the flags SDK sources (the CI lint job runs dprint check). No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- sdk/ts/flags/ARCHITECTURE.md | 50 +++---- sdk/ts/flags/README.md | 4 +- sdk/ts/flags/src/__tests__/adapter.test.ts | 23 +-- .../__tests__/e2e-flags-sdk-surface.test.ts | 14 +- .../flags/src/__tests__/e2e-flags-sdk.test.ts | 19 ++- .../__tests__/e2e-openfeature-server.test.ts | 133 +++++++++++++----- .../src/__tests__/e2e-openfeature-web.test.ts | 85 ++++++++--- .../src/__tests__/openfeature-server.test.ts | 94 +++++++++++-- .../src/__tests__/openfeature-web.test.ts | 24 +++- sdk/ts/flags/src/__tests__/snapshot.test.ts | 31 +++- sdk/ts/flags/src/adapter.ts | 31 ++-- sdk/ts/flags/src/openfeature/server.ts | 33 +++-- sdk/ts/flags/src/openfeature/shared.ts | 17 ++- sdk/ts/flags/src/openfeature/web.ts | 8 +- sdk/ts/flags/src/snapshot.ts | 13 +- 15 files changed, 416 insertions(+), 163 deletions(-) diff --git a/sdk/ts/flags/ARCHITECTURE.md b/sdk/ts/flags/ARCHITECTURE.md index 96924c6..deb4659 100644 --- a/sdk/ts/flags/ARCHITECTURE.md +++ b/sdk/ts/flags/ARCHITECTURE.md @@ -208,16 +208,16 @@ evaluate(key, defaultValue, ctx, def, prefs) ← same pure engine as native eva **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` (one `list`+`batchGet`/request) | short-lived edge/serverless (no persistent watch) | +| 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` (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()`. +**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/`) @@ -247,15 +247,15 @@ ResolutionDetails { value, reason, errorCode?, flagMetadata? } **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) | +| 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. +**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 @@ -276,12 +276,12 @@ ResolutionDetails { value, reason, errorCode?, flagMetadata? } | `src/flag.ts` | `Flag` 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/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`, 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/snapshot.ts` | `Snapshot` class: in-memory `Map`, 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) | @@ -322,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 | +| 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 | +| 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 @@ -359,4 +359,4 @@ Most users never opt in or out of any flag. Storing only deviations means the `f 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. +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. diff --git a/sdk/ts/flags/README.md b/sdk/ts/flags/README.md index 311b2b7..b7305af 100644 --- a/sdk/ts/flags/README.md +++ b/sdk/ts/flags/README.md @@ -102,9 +102,9 @@ npm install @beyond.dev/flags @beyond.dev/kv flags ``` ```ts -import { flag } from "flags/next"; -import { createKvClient } from "@beyond.dev/kv"; 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); diff --git a/sdk/ts/flags/src/__tests__/adapter.test.ts b/sdk/ts/flags/src/__tests__/adapter.test.ts index 25de429..c5c8c40 100644 --- a/sdk/ts/flags/src/__tests__/adapter.test.ts +++ b/sdk/ts/flags/src/__tests__/adapter.test.ts @@ -1,7 +1,7 @@ import type { KvClient } from "@beyond.dev/kv"; import type { ReadonlyHeaders, ReadonlyRequestCookies } from "flags"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { beyondAdapter, type BeyondAdapter } from "../adapter.js"; +import { type BeyondAdapter, beyondAdapter } from "../adapter.js"; import type { FlagContext } from "../types.js"; import { kvClient, writeDef } from "./harness.js"; import "./test-context.js"; @@ -183,7 +183,12 @@ for (const mode of ["snapshot", "request"] as const) { headers: reqHeaders(), cookies, }); - expect(out).toEqual({ [a]: true, [b]: false, [c]: true, [missing]: false }); + expect(out).toEqual({ + [a]: true, + [b]: false, + [c]: true, + [missing]: false, + }); }); it("missing id falls every flag back to its default", async () => { @@ -230,11 +235,12 @@ describe("beyondAdapter — request mode caching", () => { } // Two distinct def keys → at most two def reads total (get or batchGet), // plus one prefs read for the id. Re-evaluations hit the per-request cache. - const defReads = - getSpy.mock.calls.filter((c) => String(c[0]).startsWith("flags:def:")) - .length + batchSpy.mock.calls.length; + const defReads = getSpy.mock.calls.filter((c) => + String(c[0]).startsWith("flags:def:") + ) + .length + batchSpy.mock.calls.length; const prefReads = getSpy.mock.calls.filter((c) => - String(c[0]).startsWith("flags:user:"), + String(c[0]).startsWith("flags:user:") ).length; expect(defReads).toBe(2); expect(prefReads).toBe(1); @@ -293,8 +299,9 @@ describe("beyondAdapter — adapter identity", () => { try { expect(typeof adapter.adapterId).toBe("symbol"); const origin = adapter.origin; - const resolved = - typeof origin === "function" ? origin("my-flag") : origin; + const resolved = typeof origin === "function" + ? origin("my-flag") + : origin; expect(resolved).toBe("https://flags.example/my-flag"); } finally { await adapter.close(); diff --git a/sdk/ts/flags/src/__tests__/e2e-flags-sdk-surface.test.ts b/sdk/ts/flags/src/__tests__/e2e-flags-sdk-surface.test.ts index 1fe460d..d6c02ab 100644 --- a/sdk/ts/flags/src/__tests__/e2e-flags-sdk-surface.test.ts +++ b/sdk/ts/flags/src/__tests__/e2e-flags-sdk-surface.test.ts @@ -12,7 +12,6 @@ * per-test UUIDs because the test KV server shares one keyspace (see * http.ts `nsToIndex`). */ -import { randomBytes } from "node:crypto"; import type { KvClient } from "@beyond.dev/kv"; import { createAccessProof, encryptOverrides, mergeProviderData } from "flags"; import { @@ -23,6 +22,7 @@ import { getProviderData, serialize, } from "flags/next"; +import { randomBytes } from "node:crypto"; import { afterAll, beforeAll, @@ -89,9 +89,10 @@ describe("e2e surface: real flags/next host → beyond adapter → real KV", () }); try { // pro → rule "v2"; free → rollout "v1". - expect(await variant(request({ "x-user-id": "u", "x-plan": "pro" }))).toBe( - "v2", - ); + expect(await variant(request({ "x-user-id": "u", "x-plan": "pro" }))) + .toBe( + "v2", + ); expect( await variant(request({ "x-user-id": "u", "x-plan": "free" })), ).toBe("v1"); @@ -104,7 +105,10 @@ describe("e2e surface: real flags/next host → beyond adapter → real KV", () type Config = { theme: string; max: number }; const key = uid(); const def = { theme: "dark", max: 5 }; - await writeDef(kv, key, { on: true, rollout: { percent: 100, value: def } }); + await writeDef(kv, key, { + on: true, + rollout: { percent: 100, value: def }, + }); const adapter = beyondAdapter(kv, { mode: "request" }); const config = flag({ key, diff --git a/sdk/ts/flags/src/__tests__/e2e-flags-sdk.test.ts b/sdk/ts/flags/src/__tests__/e2e-flags-sdk.test.ts index 0948904..2b48c48 100644 --- a/sdk/ts/flags/src/__tests__/e2e-flags-sdk.test.ts +++ b/sdk/ts/flags/src/__tests__/e2e-flags-sdk.test.ts @@ -98,9 +98,10 @@ describe("e2e: real flags/next host → beyond adapter → real KV", () => { }), }); try { - expect(await aiSearch(request({ "x-user-id": "u1", "x-plan": "pro" }))).toBe( - true, - ); + expect(await aiSearch(request({ "x-user-id": "u1", "x-plan": "pro" }))) + .toBe( + true, + ); expect( await aiSearch(request({ "x-user-id": "u2", "x-plan": "free" })), ).toBe(false); @@ -138,7 +139,10 @@ describe("e2e: real flags/next host → beyond adapter → real KV", () => { // Write before creating the adapter; the first decide awaits initial load. const key = uid(); await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); - const adapter = beyondAdapter(kv, { mode: "snapshot", watch: false }); + const adapter = beyondAdapter(kv, { + mode: "snapshot", + watch: false, + }); const snap = flag({ key, defaultValue: false, @@ -171,7 +175,12 @@ describe("e2e: real flags/next host → beyond adapter → real KV", () => { const id = uid(); const identify = (): FlagContext => ({ id, plan: "pro" }); const mk = (key: string) => - flag({ key, defaultValue: false, adapter, identify }); + flag({ + key, + defaultValue: false, + adapter, + identify, + }); const a = mk(ka); const b = mk(kb); const c = mk(kc); diff --git a/sdk/ts/flags/src/__tests__/e2e-openfeature-server.test.ts b/sdk/ts/flags/src/__tests__/e2e-openfeature-server.test.ts index 8054d31..e2578d8 100644 --- a/sdk/ts/flags/src/__tests__/e2e-openfeature-server.test.ts +++ b/sdk/ts/flags/src/__tests__/e2e-openfeature-server.test.ts @@ -49,9 +49,12 @@ async function waitUntil( // biome-ignore lint/suspicious/noExplicitAny: server Client type is verbose here function collectChanges(client: any): () => string[] { const seen: string[] = []; - client.addHandler(ProviderEvents.ConfigurationChanged, (e: { flagsChanged?: string[] }) => { - seen.push(...(e?.flagsChanged ?? [])); - }); + client.addHandler( + ProviderEvents.ConfigurationChanged, + (e: { flagsChanged?: string[] }) => { + seen.push(...(e?.flagsChanged ?? [])); + }, + ); return () => seen; } @@ -69,9 +72,12 @@ describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () it("host-returned value tracks live KV state (the irrefutable toggle)", async () => { const key = uid(); - await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); const client = OpenFeature.getClient(); - const eval_ = () => client.getBooleanValue(key, false, { targetingKey: "u1" }); + const eval_ = () => + client.getBooleanValue(key, false, { targetingKey: "u1" }); // No def yet → declared default. expect(await eval_()).toBe(false); @@ -95,14 +101,22 @@ describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () on: true, rules: [{ when: { plan: "pro" }, value: true }], }); - await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); const client = OpenFeature.getClient(); expect( - await client.getBooleanValue(key, false, { targetingKey: "u1", plan: "pro" }), + await client.getBooleanValue(key, false, { + targetingKey: "u1", + plan: "pro", + }), ).toBe(true); expect( - await client.getBooleanValue(key, false, { targetingKey: "u2", plan: "free" }), + await client.getBooleanValue(key, false, { + targetingKey: "u2", + plan: "free", + }), ).toBe(false); }); @@ -110,13 +124,21 @@ describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () const key = uid(); const id = uid(); await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); - const { error } = await kv.set(`flags:user:${id}`, JSON.stringify({ [key]: true })); + const { error } = await kv.set( + `flags:user:${id}`, + JSON.stringify({ [key]: true }), + ); if (error) throw error; - await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); const client = OpenFeature.getClient(); - expect(await client.getBooleanValue(key, false, { targetingKey: id })).toBe(true); - expect(await client.getBooleanValue(key, false, { targetingKey: uid() })).toBe(false); + expect(await client.getBooleanValue(key, false, { targetingKey: id })).toBe( + true, + ); + expect(await client.getBooleanValue(key, false, { targetingKey: uid() })) + .toBe(false); }); it("getBooleanDetails surfaces reason and flagMetadata through the host", async () => { @@ -125,7 +147,9 @@ describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () on: true, rules: [{ when: { plan: "pro" }, value: true }], }); - await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); const client = OpenFeature.getClient(); const details = await client.getBooleanDetails(key, false, { @@ -144,10 +168,14 @@ describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () on: true, rollout: { percent: 100, value: "not-a-bool" }, }); - await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); const client = OpenFeature.getClient(); - const details = await client.getBooleanDetails(key, false, { targetingKey: "u1" }); + const details = await client.getBooleanDetails(key, false, { + targetingKey: "u1", + }); expect(details.value).toBe(false); expect(details.reason).toBe("ERROR"); expect(details.errorCode).toBe("TYPE_MISMATCH"); @@ -165,29 +193,46 @@ describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () // The host now resolves the new value from the live snapshot. await waitUntil( - async () => (await client.getBooleanValue(key, false, { targetingKey: "u1" })) === true, + async () => + (await client.getBooleanValue(key, false, { targetingKey: "u1" })) + === true, "value→true", ); - await waitUntil(() => changes().includes(key), "config-changed event", 10_000); + await waitUntil( + () => changes().includes(key), + "config-changed event", + 10_000, + ); }); it("resolves string, number, and object flags through the host", async () => { const sKey = uid(); const nKey = uid(); const oKey = uid(); - await writeDef(kv, sKey, { on: true, rollout: { percent: 100, value: "dark" } }); - await writeDef(kv, nKey, { on: true, rollout: { percent: 100, value: 42 } }); + await writeDef(kv, sKey, { + on: true, + rollout: { percent: 100, value: "dark" }, + }); + await writeDef(kv, nKey, { + on: true, + rollout: { percent: 100, value: 42 }, + }); await writeDef(kv, oKey, { on: true, rollout: { percent: 100, value: { a: 1, b: ["x"] } }, }); - await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); const client = OpenFeature.getClient(); const id = { targetingKey: "u1" }; expect(await client.getStringValue(sKey, "light", id)).toBe("dark"); expect(await client.getNumberValue(nKey, 0, id)).toBe(42); - expect(await client.getObjectValue(oKey, {}, id)).toEqual({ a: 1, b: ["x"] }); + expect(await client.getObjectValue(oKey, {}, id)).toEqual({ + a: 1, + b: ["x"], + }); // Details path is typed end-to-end too. const sd = await client.getStringDetails(sKey, "light", id); @@ -202,8 +247,13 @@ describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () const oKey = uid(); // String flag resolving to a number, object flag resolving to a string. await writeDef(kv, sKey, { on: true, rollout: { percent: 100, value: 7 } }); - await writeDef(kv, oKey, { on: true, rollout: { percent: 100, value: "scalar" } }); - await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + await writeDef(kv, oKey, { + on: true, + rollout: { percent: 100, value: "scalar" }, + }); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); const client = OpenFeature.getClient(); const id = { targetingKey: "u1" }; @@ -223,7 +273,8 @@ describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () new BeyondProvider(kv, { mode: "snapshot", watch: false }), ); const client = OpenFeature.getClient(); - expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })).toBe(true); + expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })) + .toBe(true); }); it("flag deletion propagates back to the default", async () => { @@ -231,19 +282,25 @@ describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); // fetch mode re-reads KV each eval, so deletion is observed immediately and // deterministically. Watch-driven deletion is covered in snapshot.test.ts. - await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); const client = OpenFeature.getClient(); - expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })).toBe(true); + expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })) + .toBe(true); await deleteDef(kv, key); // With the def gone, the host falls back to the declared default. - expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })).toBe(false); + expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })) + .toBe(false); }); it("partial rollout is deterministic and roughly split through the host", async () => { const key = uid(); await writeDef(kv, key, { on: true, rollout: { percent: 50 } }); - await OpenFeature.setProviderAndWait(new BeyondProvider(kv, { mode: "fetch" })); + await OpenFeature.setProviderAndWait( + new BeyondProvider(kv, { mode: "fetch" }), + ); const client = OpenFeature.getClient(); const ids = Array.from({ length: 200 }, (_, i) => `user-${i}`); @@ -264,14 +321,19 @@ describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () const key = uid(); const id = uid(); await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); - const { error } = await kv.set(`flags:user:${id}`, JSON.stringify({ [key]: true })); + const { error } = await kv.set( + `flags:user:${id}`, + JSON.stringify({ [key]: true }), + ); if (error) throw error; await OpenFeature.setProviderAndWait( new BeyondProvider(kv, { mode: "fetch", userPrefs: false }), ); const client = OpenFeature.getClient(); // Pref says true, but userPrefs:false ignores it → 0% rollout = false. - expect(await client.getBooleanValue(key, false, { targetingKey: id })).toBe(false); + expect(await client.getBooleanValue(key, false, { targetingKey: id })).toBe( + false, + ); }); it("degrades to the default and reports onError on malformed KV data", async () => { @@ -286,7 +348,8 @@ describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () const client = OpenFeature.getClient(); // Malformed def is treated as absent → declared default is returned. - expect(await client.getBooleanValue(key, true, { targetingKey: "u1" })).toBe(true); + expect(await client.getBooleanValue(key, true, { targetingKey: "u1" })) + .toBe(true); expect(errors.length).toBeGreaterThan(0); expect(errors[0]?.source).toBe("snapshot"); }); @@ -302,11 +365,15 @@ describe("e2e: real @openfeature/server-sdk → BeyondProvider → real KV", () }); const errors: FlagsErrorEvent[] = []; await OpenFeature.setProviderAndWait( - new BeyondProvider(deadKv, { mode: "fetch", onError: (e) => errors.push(e) }), + new BeyondProvider(deadKv, { + mode: "fetch", + onError: (e) => errors.push(e), + }), ); const client = OpenFeature.getClient(); - expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })).toBe(false); + expect(await client.getBooleanValue(key, false, { targetingKey: "u1" })) + .toBe(false); expect(errors.length).toBeGreaterThan(0); }); }); diff --git a/sdk/ts/flags/src/__tests__/e2e-openfeature-web.test.ts b/sdk/ts/flags/src/__tests__/e2e-openfeature-web.test.ts index 257766e..52a1f25 100644 --- a/sdk/ts/flags/src/__tests__/e2e-openfeature-web.test.ts +++ b/sdk/ts/flags/src/__tests__/e2e-openfeature-web.test.ts @@ -39,7 +39,9 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () it("host returns live value after a watched def change (the irrefutable toggle)", async () => { const key = uid(); await OpenFeature.setContext({ targetingKey: "u1" }); - await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true, refresh: 2 })); + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: true, refresh: 2 }), + ); const client = OpenFeature.getClient(); // No def yet → declared default (sync call, implicit static context). @@ -48,10 +50,17 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () const changes = collectChanges(client); await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); // Wait on the observable value, not a single event within a fixed window. - await waitUntil(() => client.getBooleanValue(key, false) === true, "value→true"); + await waitUntil( + () => client.getBooleanValue(key, false) === true, + "value→true", + ); expect(client.getBooleanValue(key, false)).toBe(true); - await waitUntil(() => changes().includes(key), "config-changed event", 10_000); + await waitUntil( + () => changes().includes(key), + "config-changed event", + 10_000, + ); }); it("targeting rule resolves from the static context", async () => { @@ -61,7 +70,9 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () rules: [{ when: { plan: "pro" }, value: true }], }); await OpenFeature.setContext({ targetingKey: "u1", plan: "pro" }); - await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true, refresh: 2 })); + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: true, refresh: 2 }), + ); const client = OpenFeature.getClient(); expect(client.getBooleanValue(key, false)).toBe(true); @@ -74,7 +85,9 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () rules: [{ when: { plan: "pro" }, value: true }], }); await OpenFeature.setContext({ targetingKey: "u1", plan: "free" }); - await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true, refresh: 2 })); + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: true, refresh: 2 }), + ); const client = OpenFeature.getClient(); expect(client.getBooleanValue(key, false)).toBe(false); // free @@ -88,10 +101,15 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () const key = uid(); const id = uid(); await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); - const { error } = await kv.set(`flags:user:${id}`, JSON.stringify({ [key]: true })); + const { error } = await kv.set( + `flags:user:${id}`, + JSON.stringify({ [key]: true }), + ); if (error) throw error; await OpenFeature.setContext({ targetingKey: id }); - await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true, refresh: 2 })); + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: true, refresh: 2 }), + ); const client = OpenFeature.getClient(); expect(client.getBooleanValue(key, false)).toBe(true); @@ -107,9 +125,14 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () on: true, rules: [{ when: { plan: "pro" }, value: true }], }); - await writeDef(kv, badKey, { on: true, rollout: { percent: 100, value: "x" } }); + await writeDef(kv, badKey, { + on: true, + rollout: { percent: 100, value: "x" }, + }); await OpenFeature.setContext({ targetingKey: "u1", plan: "pro" }); - await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true, refresh: 2 })); + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: true, refresh: 2 }), + ); const client = OpenFeature.getClient(); const ok = client.getBooleanDetails(okKey, false); @@ -126,14 +149,22 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () const sKey = uid(); const nKey = uid(); const oKey = uid(); - await writeDef(kv, sKey, { on: true, rollout: { percent: 100, value: "dark" } }); - await writeDef(kv, nKey, { on: true, rollout: { percent: 100, value: 42 } }); + await writeDef(kv, sKey, { + on: true, + rollout: { percent: 100, value: "dark" }, + }); + await writeDef(kv, nKey, { + on: true, + rollout: { percent: 100, value: 42 }, + }); await writeDef(kv, oKey, { on: true, rollout: { percent: 100, value: { a: 1, b: ["x"] } }, }); await OpenFeature.setContext({ targetingKey: "u1" }); - await OpenFeature.setProviderAndWait(new BeyondWebProvider(kv, { watch: true, refresh: 2 })); + await OpenFeature.setProviderAndWait( + new BeyondWebProvider(kv, { watch: true, refresh: 2 }), + ); const client = OpenFeature.getClient(); expect(client.getStringValue(sKey, "light")).toBe("dark"); @@ -156,10 +187,17 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () const changes = collectChanges(client); await deleteDef(kv, key); - await waitUntil(() => client.getBooleanValue(key, false) === false, "value→false"); + await waitUntil( + () => client.getBooleanValue(key, false) === false, + "value→false", + ); expect(client.getBooleanValue(key, false)).toBe(false); - await waitUntil(() => changes().includes(key), "del config-changed", 10_000); + await waitUntil( + () => changes().includes(key), + "del config-changed", + 10_000, + ); }); it("polling fallback (watch:false) still picks up live changes", async () => { @@ -174,7 +212,10 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); // Arrives via the poll loop, not watch — wait on the value with headroom. - await waitUntil(() => client.getBooleanValue(key, false) === true, "poll→true"); + await waitUntil( + () => client.getBooleanValue(key, false) === true, + "poll→true", + ); expect(client.getBooleanValue(key, false)).toBe(true); }); @@ -183,7 +224,10 @@ describe("e2e: real @openfeature/web-sdk → BeyondWebProvider → real KV", () const key = uid(); const id = uid(); await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); - const { error } = await kv.set(`flags:user:${id}`, JSON.stringify({ [key]: true })); + const { error } = await kv.set( + `flags:user:${id}`, + JSON.stringify({ [key]: true }), + ); if (error) throw error; await OpenFeature.setContext({ targetingKey: id }); await OpenFeature.setProviderAndWait( @@ -219,8 +263,11 @@ function collectChanges( client: any, ): () => string[] { const seen: string[] = []; - client.addHandler(ProviderEvents.ConfigurationChanged, (e: { flagsChanged?: string[] }) => { - seen.push(...(e?.flagsChanged ?? [])); - }); + client.addHandler( + ProviderEvents.ConfigurationChanged, + (e: { flagsChanged?: string[] }) => { + seen.push(...(e?.flagsChanged ?? [])); + }, + ); return () => seen; } diff --git a/sdk/ts/flags/src/__tests__/openfeature-server.test.ts b/sdk/ts/flags/src/__tests__/openfeature-server.test.ts index 55a570d..5a790cb 100644 --- a/sdk/ts/flags/src/__tests__/openfeature-server.test.ts +++ b/sdk/ts/flags/src/__tests__/openfeature-server.test.ts @@ -46,7 +46,12 @@ describe("BeyondProvider (server) — fetch mode resolution", () => { }); it("returns the default with reason DEFAULT when no def exists", async () => { - const res = await provider.resolveBooleanEvaluation(uid(), false, ctx("u"), logger); + const res = await provider.resolveBooleanEvaluation( + uid(), + false, + ctx("u"), + logger, + ); expect(res.value).toBe(false); expect(res.reason).toBe("DEFAULT"); expect(res.errorCode).toBeUndefined(); @@ -55,7 +60,12 @@ describe("BeyondProvider (server) — fetch mode resolution", () => { it("resolves a 100% rollout to true with reason SPLIT", async () => { const key = uid(); await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); - const res = await provider.resolveBooleanEvaluation(key, false, ctx("u"), logger); + const res = await provider.resolveBooleanEvaluation( + key, + false, + ctx("u"), + logger, + ); expect(res.value).toBe(true); expect(res.reason).toBe("SPLIT"); }); @@ -63,7 +73,12 @@ describe("BeyondProvider (server) — fetch mode resolution", () => { it("honors the kill switch with reason DISABLED", async () => { const key = uid(); await writeDef(kv, key, { on: false, rollout: { percent: 100 } }); - const res = await provider.resolveBooleanEvaluation(key, false, ctx("u"), logger); + const res = await provider.resolveBooleanEvaluation( + key, + false, + ctx("u"), + logger, + ); expect(res.value).toBe(false); expect(res.reason).toBe("DISABLED"); }); @@ -98,7 +113,12 @@ describe("BeyondProvider (server) — fetch mode resolution", () => { JSON.stringify({ [key]: true }), ); if (error) throw error; - const res = await provider.resolveBooleanEvaluation(key, false, ctx(id), logger); + const res = await provider.resolveBooleanEvaluation( + key, + false, + ctx(id), + logger, + ); expect(res.value).toBe(true); expect(res.reason).toBe("TARGETING_MATCH"); expect(res.flagMetadata?.beyondReason).toBe("user-pref"); @@ -107,9 +127,20 @@ describe("BeyondProvider (server) — fetch mode resolution", () => { it("resolves string and number flags", async () => { const sKey = uid(); const nKey = uid(); - await writeDef(kv, sKey, { on: true, rollout: { percent: 100, value: "dark" } }); - await writeDef(kv, nKey, { on: true, rollout: { percent: 100, value: 42 } }); - const s = await provider.resolveStringEvaluation(sKey, "light", ctx("u"), logger); + await writeDef(kv, sKey, { + on: true, + rollout: { percent: 100, value: "dark" }, + }); + await writeDef(kv, nKey, { + on: true, + rollout: { percent: 100, value: 42 }, + }); + const s = await provider.resolveStringEvaluation( + sKey, + "light", + ctx("u"), + logger, + ); const n = await provider.resolveNumberEvaluation(nKey, 0, ctx("u"), logger); expect(s.value).toBe("dark"); expect(n.value).toBe(42); @@ -121,7 +152,12 @@ describe("BeyondProvider (server) — fetch mode resolution", () => { on: true, rollout: { percent: 100, value: { mode: "x", n: 3 } }, }); - const res = await provider.resolveObjectEvaluation(key, {}, ctx("u"), logger); + const res = await provider.resolveObjectEvaluation( + key, + {}, + ctx("u"), + logger, + ); expect(res.value).toEqual({ mode: "x", n: 3 }); }); @@ -132,7 +168,12 @@ describe("BeyondProvider (server) — fetch mode resolution", () => { on: true, rollout: { percent: 100, value: "not-a-bool" }, }); - const res = await provider.resolveBooleanEvaluation(key, false, ctx("u"), logger); + const res = await provider.resolveBooleanEvaluation( + key, + false, + ctx("u"), + logger, + ); expect(res.value).toBe(false); // declared default, not coerced expect(res.reason).toBe("ERROR"); expect(res.errorCode).toBe("TYPE_MISMATCH"); @@ -168,7 +209,12 @@ describe("BeyondProvider (server) — snapshot mode", () => { const provider = new BeyondProvider(kv, { mode: "snapshot", watch: false }); await provider.initialize(); try { - const res = await provider.resolveBooleanEvaluation(key, false, ctx("u"), logger); + const res = await provider.resolveBooleanEvaluation( + key, + false, + ctx("u"), + logger, + ); expect(res.value).toBe(true); } finally { await provider.onClose(); @@ -180,7 +226,12 @@ describe("BeyondProvider (server) — snapshot mode", () => { // def deterministically yields STALE (vs DEFAULT once ready). const provider = new BeyondProvider(kv, { mode: "snapshot", watch: false }); try { - const res = await provider.resolveBooleanEvaluation(uid(), false, ctx("u"), logger); + const res = await provider.resolveBooleanEvaluation( + uid(), + false, + ctx("u"), + logger, + ); expect(res.value).toBe(false); // declared default expect(res.reason).toBe("STALE"); } finally { @@ -190,7 +241,11 @@ describe("BeyondProvider (server) — snapshot mode", () => { it("emits PROVIDER_CONFIGURATION_CHANGED when a watched def changes", async () => { const key = uid(); - const provider = new BeyondProvider(kv, { mode: "snapshot", watch: true, refresh: 2 }); + const provider = new BeyondProvider(kv, { + mode: "snapshot", + watch: true, + refresh: 2, + }); await provider.initialize(); try { const changed = new Promise((resolve) => { @@ -199,11 +254,20 @@ describe("BeyondProvider (server) — snapshot mode", () => { }); }); await writeDef(kv, key, { on: true, rollout: { percent: 100 } }); - const flagsChanged = await withTimeout(changed, 10_000, "ConfigurationChanged"); + const flagsChanged = await withTimeout( + changed, + 10_000, + "ConfigurationChanged", + ); expect(flagsChanged).toContain(key); // And the new value is now resolvable from the snapshot. - const res = await provider.resolveBooleanEvaluation(key, false, ctx("u"), logger); + const res = await provider.resolveBooleanEvaluation( + key, + false, + ctx("u"), + logger, + ); expect(res.value).toBe(true); } finally { await provider.onClose(); @@ -215,7 +279,7 @@ function withTimeout(p: Promise, ms: number, label: string): Promise { return Promise.race([ p, new Promise((_, reject) => - setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), ms), + setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), ms) ), ]); } diff --git a/sdk/ts/flags/src/__tests__/openfeature-web.test.ts b/sdk/ts/flags/src/__tests__/openfeature-web.test.ts index e060674..1141a0a 100644 --- a/sdk/ts/flags/src/__tests__/openfeature-web.test.ts +++ b/sdk/ts/flags/src/__tests__/openfeature-web.test.ts @@ -48,7 +48,12 @@ describe("BeyondWebProvider — synchronous resolution", () => { it("returns the default with reason DEFAULT when no def exists", async () => { await start(ctx("u")); - const res = provider.resolveBooleanEvaluation(uid(), false, ctx("u"), logger); + const res = provider.resolveBooleanEvaluation( + uid(), + false, + ctx("u"), + logger, + ); expect(res.value).toBe(false); expect(res.reason).toBe("DEFAULT"); }); @@ -83,7 +88,10 @@ describe("BeyondWebProvider — synchronous resolution", () => { const key = uid(); const id = uid(); await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); - const { error } = await kv.set(`flags:user:${id}`, JSON.stringify({ [key]: true })); + const { error } = await kv.set( + `flags:user:${id}`, + JSON.stringify({ [key]: true }), + ); if (error) throw error; await start(ctx(id)); // prefetches id's prefs const res = provider.resolveBooleanEvaluation(key, false, ctx(id), logger); @@ -96,7 +104,10 @@ describe("BeyondWebProvider — synchronous resolution", () => { const id1 = uid(); const id2 = uid(); await writeDef(kv, key, { on: true, rollout: { percent: 0 } }); - const { error } = await kv.set(`flags:user:${id1}`, JSON.stringify({ [key]: true })); + const { error } = await kv.set( + `flags:user:${id1}`, + JSON.stringify({ [key]: true }), + ); if (error) throw error; await start(ctx(id1)); // id1 → pref true expect( @@ -112,7 +123,10 @@ describe("BeyondWebProvider — synchronous resolution", () => { it("returns TYPE_MISMATCH for a wrong-typed value", async () => { const key = uid(); - await writeDef(kv, key, { on: true, rollout: { percent: 100, value: "nope" } }); + await writeDef(kv, key, { + on: true, + rollout: { percent: 100, value: "nope" }, + }); await start(ctx("u")); const res = provider.resolveBooleanEvaluation(key, false, ctx("u"), logger); expect(res.value).toBe(false); @@ -131,7 +145,7 @@ describe("BeyondWebProvider — synchronous resolution", () => { const flagsChanged = await Promise.race([ changed, new Promise((_, reject) => - setTimeout(() => reject(new Error("timed out")), 10_000), + setTimeout(() => reject(new Error("timed out")), 10_000) ), ]); expect(flagsChanged).toContain(key); diff --git a/sdk/ts/flags/src/__tests__/snapshot.test.ts b/sdk/ts/flags/src/__tests__/snapshot.test.ts index 97cfa61..b832d42 100644 --- a/sdk/ts/flags/src/__tests__/snapshot.test.ts +++ b/sdk/ts/flags/src/__tests__/snapshot.test.ts @@ -272,18 +272,33 @@ describe("snapshot — watch resume after hard reconnect", () => { async batchGet() { return { data: [] as never[], error: undefined }; }, - async *watch(_key: string, opts?: WatchOptions): AsyncGenerator { + async *watch( + _key: string, + opts?: WatchOptions, + ): AsyncGenerator { watchCalls++; sinceSeen.push(opts?.since); if (watchCalls === 1) { yield { type: "ready" }; - yield { type: "set", key: "flags:def:a", value: encode({ on: true }), revision: 5 }; + yield { + type: "set", + key: "flags:def:a", + value: encode({ on: true }), + revision: 5, + }; throw new KvError("sse_error", "stream dropped", 500); // hard disconnect } // Reconnect: replay the delta that landed while we were down. - yield { type: "set", key: "flags:def:b", value: encode({ on: false }), revision: 7 }; + yield { + type: "set", + key: "flags:def:b", + value: encode({ on: false }), + revision: 7, + }; await new Promise((resolve) => { - opts?.signal?.addEventListener("abort", () => resolve(), { once: true }); + opts?.signal?.addEventListener("abort", () => resolve(), { + once: true, + }); }); }, } as unknown as KvClient; @@ -295,7 +310,9 @@ describe("snapshot — watch resume after hard reconnect", () => { // First session applied A. { const deadline = Date.now() + 2_000; - while (Date.now() < deadline && snap.get("a") === undefined) await sleep(25); + while (Date.now() < deadline && snap.get("a") === undefined) { + await sleep(25); + } } expect(snap.get("a")).toEqual({ on: true }); @@ -303,7 +320,9 @@ describe("snapshot — watch resume after hard reconnect", () => { // replayed delta for B must land — proving no gap loss. { const deadline = Date.now() + 5_000; - while (Date.now() < deadline && snap.get("b") === undefined) await sleep(25); + while (Date.now() < deadline && snap.get("b") === undefined) { + await sleep(25); + } } expect(snap.get("b")).toEqual({ on: false }); diff --git a/sdk/ts/flags/src/adapter.ts b/sdk/ts/flags/src/adapter.ts index 073818d..f04c0d2 100644 --- a/sdk/ts/flags/src/adapter.ts +++ b/sdk/ts/flags/src/adapter.ts @@ -245,7 +245,10 @@ class RequestDefSource implements DefSource { for (let i = 0; i < keys.length; i++) { const key = keys[i] as string; const entry = data[i]; - out.set(key, entry ? parseDef(entry.text(), key, this.onError) : undefined); + out.set( + key, + entry ? parseDef(entry.text(), key, this.onError) : undefined, + ); } return out; } @@ -269,10 +272,10 @@ function parseDef( try { const parsed = JSON.parse(text) as unknown; if ( - parsed === null || - typeof parsed !== "object" || - Array.isArray(parsed) || - typeof (parsed as Record)["on"] !== "boolean" + parsed === null + || typeof parsed !== "object" + || Array.isArray(parsed) + || typeof (parsed as Record)["on"] !== "boolean" ) { throw new Error("flag def must be a JSON object with a boolean `on`"); } @@ -300,10 +303,9 @@ export function beyondAdapter< >(kv: KvClient, opts: BeyondAdapterOptions = {}): BeyondAdapter { const mode = opts.mode ?? "snapshot"; const useUserPrefs = opts.userPrefs !== false; - const defSource: DefSource = - mode === "request" - ? new RequestDefSource(kv, opts) - : new SnapshotDefSource(kv, opts); + const defSource: DefSource = mode === "request" + ? new RequestDefSource(kv, opts) + : new SnapshotDefSource(kv, opts); // Stable per-instance id so the host groups this adapter's flags for bulk eval. const adapterId = Symbol("beyondAdapter"); @@ -353,7 +355,13 @@ export function beyondAdapter< await defSource.ready(); const def = await defSource.get(key, headers); const prefs = useUserPrefs ? await loadPrefs(ctx.id, headers) : null; - return evaluate(key, defaultValue as T, ctx, def as FlagDef | undefined, prefs).value; + return evaluate( + key, + defaultValue as T, + ctx, + def as FlagDef | undefined, + prefs, + ).value; }, async bulkDecide({ flags, entities, headers }) { @@ -401,7 +409,8 @@ export function beyondAdapter< hints: [ { key: "beyond-kv", - text: `Failed to load flag definitions from Beyond KV: ${error.message}`, + text: + `Failed to load flag definitions from Beyond KV: ${error.message}`, }, ], }; diff --git a/sdk/ts/flags/src/openfeature/server.ts b/sdk/ts/flags/src/openfeature/server.ts index 8e4b490..8567db6 100644 --- a/sdk/ts/flags/src/openfeature/server.ts +++ b/sdk/ts/flags/src/openfeature/server.ts @@ -32,8 +32,8 @@ import { type Logger, OpenFeatureEventEmitter, type Provider, - type ProviderMetadata, ProviderEvents, + type ProviderMetadata, type ResolutionDetails, } from "@openfeature/server-sdk"; import { evaluate } from "../eval.js"; @@ -164,14 +164,13 @@ export class BeyondProvider implements Provider { this.kv = kv; this.useUserPrefs = opts.userPrefs !== false; this.onError = opts.onError; - this.reader = - (opts.mode ?? "snapshot") === "fetch" - ? new FetchReader(kv, opts) - : new SnapshotReader(kv, opts, (flagsChanged) => { - this.events.emit(ProviderEvents.ConfigurationChanged, { - flagsChanged, - }); - }); + this.reader = (opts.mode ?? "snapshot") === "fetch" + ? new FetchReader(kv, opts) + : new SnapshotReader(kv, opts, (flagsChanged) => { + this.events.emit(ProviderEvents.ConfigurationChanged, { + flagsChanged, + }); + }); } /** Await the initial snapshot load. The SDK emits `PROVIDER_READY` on resolve. */ @@ -218,8 +217,12 @@ export class BeyondProvider implements Provider { context: EvaluationContext, _logger: Logger, ): Promise> { - return this.resolve(flagKey, defaultValue as JsonValue, context, "object") as - Promise>; + return this.resolve( + flagKey, + defaultValue as JsonValue, + context, + "object", + ) as Promise>; } private async resolve( @@ -253,10 +256,10 @@ function parseDef( try { const parsed = JSON.parse(text) as unknown; if ( - parsed === null || - typeof parsed !== "object" || - Array.isArray(parsed) || - typeof (parsed as Record)["on"] !== "boolean" + parsed === null + || typeof parsed !== "object" + || Array.isArray(parsed) + || typeof (parsed as Record)["on"] !== "boolean" ) { throw new Error("flag def must be a JSON object with a boolean `on`"); } diff --git a/sdk/ts/flags/src/openfeature/shared.ts b/sdk/ts/flags/src/openfeature/shared.ts index 0deb956..a208740 100644 --- a/sdk/ts/flags/src/openfeature/shared.ts +++ b/sdk/ts/flags/src/openfeature/shared.ts @@ -39,7 +39,8 @@ export function toFlagContext(context: EvaluationContext): FlagContext { /** Whether the context can drive rollout bucketing / pref lookup. */ export function hasTargetingKey(context: EvaluationContext): boolean { - return typeof context.targetingKey === "string" && context.targetingKey !== ""; + return typeof context.targetingKey === "string" + && context.targetingKey !== ""; } /** @@ -47,7 +48,10 @@ export function hasTargetingKey(context: EvaluationContext): boolean { * `no-snapshot` reports `STALE` before the provider has loaded (the def may yet * arrive) and `DEFAULT` afterward (the flag genuinely has no def in KV). */ -export function mapReason(reason: EvalReason, ready: boolean): ResolutionReason { +export function mapReason( + reason: EvalReason, + ready: boolean, +): ResolutionReason { switch (reason) { case "off": return StandardResolutionReasons.DISABLED; @@ -102,15 +106,18 @@ export function toResolution( value: defaultValue, reason: StandardResolutionReasons.ERROR, errorCode: ErrorCode.TYPE_MISMATCH, - errorMessage: - `flag resolved to ${describe(result.value)}, expected ${expected}`, + errorMessage: `flag resolved to ${ + describe(result.value) + }, expected ${expected}`, flagMetadata: { beyondReason: result.reason }, }; } const flagMetadata: Record = { beyondReason: result.reason, }; - if (result.ruleIndex !== undefined) flagMetadata["ruleIndex"] = result.ruleIndex; + if (result.ruleIndex !== undefined) { + flagMetadata["ruleIndex"] = result.ruleIndex; + } return { value: result.value as T, reason: mapReason(result.reason, ready), diff --git a/sdk/ts/flags/src/openfeature/web.ts b/sdk/ts/flags/src/openfeature/web.ts index 10dde07..3ce3cda 100644 --- a/sdk/ts/flags/src/openfeature/web.ts +++ b/sdk/ts/flags/src/openfeature/web.ts @@ -33,18 +33,14 @@ import { type Logger, OpenFeatureEventEmitter, type Provider, - type ProviderMetadata, ProviderEvents, + type ProviderMetadata, type ResolutionDetails, } from "@openfeature/web-sdk"; import { evaluate } from "../eval.js"; import { fetchUserPrefs, Snapshot } from "../snapshot.js"; import type { FlagsErrorEvent, JsonValue, UserPrefs } from "../types.js"; -import { - type ExpectedType, - toFlagContext, - toResolution, -} from "./shared.js"; +import { type ExpectedType, toFlagContext, toResolution } from "./shared.js"; /** Options for {@link BeyondWebProvider}. The KV client is passed positionally. */ export interface BeyondWebProviderOptions { diff --git a/sdk/ts/flags/src/snapshot.ts b/sdk/ts/flags/src/snapshot.ts index 9b8977a..dd68a91 100644 --- a/sdk/ts/flags/src/snapshot.ts +++ b/sdk/ts/flags/src/snapshot.ts @@ -129,7 +129,9 @@ export class Snapshot { if (!entry) continue; const flagName = fullKey.slice(DEF_PREFIX.length); seen.add(flagName); - if (entry.revision > this.lastRevision) this.lastRevision = entry.revision; + if (entry.revision > this.lastRevision) { + this.lastRevision = entry.revision; + } if (this.applyValue(flagName, entry.value)) changed.push(flagName); } } @@ -153,7 +155,10 @@ export class Snapshot { this.watchAbort = new AbortController(); // Resume from the last revision we saw so deltas that arrived while the // stream was down are replayed (the server treats `since` as exclusive). - const opts: WatchOptions = { prefix: true, signal: this.watchAbort.signal }; + const opts: WatchOptions = { + prefix: true, + signal: this.watchAbort.signal, + }; if (this.lastRevision > 0) opts.since = this.lastRevision; const sessionStart = Date.now(); try { @@ -162,7 +167,9 @@ export class Snapshot { if (event.type === "ready") continue; // Advance the resume point on every delta, even ones the byte-compare // dedups, so a reconnect never re-requests already-applied revisions. - if (event.revision > this.lastRevision) this.lastRevision = event.revision; + if (event.revision > this.lastRevision) { + this.lastRevision = event.revision; + } if (event.type === "set") { const flagName = event.key.slice(DEF_PREFIX.length); if (this.applyValue(flagName, event.value)) {