feat(flags): Vercel Flags SDK adapter + OpenFeature providers#9
Merged
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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=<last rev> 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
jaredLunde
added a commit
that referenced
this pull request
Jun 23, 2026
….2.0 (#10) The Release SDK workflow only published @beyond.dev/kv, so the flags adapter and OpenFeature providers added in #9 had no path to npm. Wire the workflow to publish both packages from the same sdk/v* tag (lockstep): - release-sdk.yml: set the tag version on both kv and flags (--allow-same-version), pin flags' @beyond.dev/kv dependency to ^<version>, build all workspaces, then publish kv first and flags second so the pinned dependency is resolvable the moment flags lands. - Bump @beyond.dev/kv and @beyond.dev/flags to 0.2.0 and replace the flags "@beyond.dev/kv": "*" dependency with "^0.2.0"; sync package-lock. Verified: both packages' export maps fully resolve to built files, npm pack --dry-run includes every dist target (kv: index/next/cache; flags: index/adapter/4 middleware/2 openfeature), full flags suite 153/153 green, all workspaces typecheck. Note: publishing @beyond.dev/flags via OIDC requires it to be registered as a trusted publisher on npm (same as @beyond.dev/kv). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Exposes the KV-backed flag engine (
evaluate()+Snapshot+fetchUserPrefs) to two external flag ecosystems, plus a correctness fix to the snapshot's watch recovery found along the way. All consumers are thin shells over the same pure engine — no evaluation logic is duplicated.What's here
Vercel Flags SDK adapter —
@beyond.dev/flags/adapter(src/adapter.ts)beyondAdapter()implements the VercelAdapterseam (decide/bulkDecide/getProviderData) over the same eval engine + KV. Snapshot and per-request read modes.OpenFeature providers —
@beyond.dev/flags/openfeature/{server,web}(src/openfeature/)BeyondProvider(server SDK): async resolvers;snapshot(default) or per-evalfetchmode.BeyondWebProvider(web SDK): synchronous resolvers against the in-memory snapshot, with per-context prefs pre-fetched oninitialize/onContextChange.Providerinterfaces);shared.tsholds the SDK-agnostic context/reason mapping and spec-correctTYPE_MISMATCHhandling.PROVIDER_CONFIGURATION_CHANGED.Snapshot improvements (
src/snapshot.ts)onChangehook; change-detection compares raw KV bytes (notJSON.stringifyof parsed objects) — exact, length-short-circuited, no re-serialization.lastRevisionviasince, instead of re-subscribing from "now". Previously a write/delete that landed while the stream was down (after a hardKvError) was silently dropped, leaving a flag stale until the next change or restart. Now the gap is replayed (server treatssinceas exclusive; byte-dedup makes over-replay a no-op).Tests
153 tests pass. Highlights:
userPrefs:false, type mismatch, malformed-data + KV-unreachable degradation, lifecycle (STALEpre-init).since=<last rev>and replay the missed delta.ARCHITECTURE.md updated to match in the same change (providers section, byte-compare,
since-resume rationale).🤖 Generated with Claude Code