Skip to content

feat(flags): Vercel Flags SDK adapter + OpenFeature providers#9

Merged
jaredLunde merged 7 commits into
mainfrom
flags-sdk-adapter
Jun 23, 2026
Merged

feat(flags): Vercel Flags SDK adapter + OpenFeature providers#9
jaredLunde merged 7 commits into
mainfrom
flags-sdk-adapter

Conversation

@jaredLunde

Copy link
Copy Markdown
Contributor

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 Vercel Adapter seam (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-eval fetch mode.
  • BeyondWebProvider (web SDK): synchronous resolvers against the in-memory snapshot, with per-context prefs pre-fetched on initialize/onContextChange.
  • Separate entry points (server/web are distinct OpenFeature packages with different Provider interfaces); shared.ts holds the SDK-agnostic context/reason mapping and spec-correct TYPE_MISMATCH handling.
  • Live KV changes surface as PROVIDER_CONFIGURATION_CHANGED.

Snapshot improvements (src/snapshot.ts)

  • onChange hook; change-detection compares raw KV bytes (not JSON.stringify of parsed objects) — exact, length-short-circuited, no re-serialization.
  • Correctness fix: the watch reconnect loop now resumes from lastRevision via since, instead of re-subscribing from "now". Previously a write/delete that landed while the stream was down (after a hard KvError) was silently dropped, leaving a flag stale until the next change or restart. Now the gap is replayed (server treats since as exclusive; byte-dedup makes over-replay a no-op).

Tests

153 tests pass. Highlights:

  • e2e suites drive the real Vercel and OpenFeature host SDKs (not provider methods directly) and assert that flipping a def in live KV flips the value the host returns — boolean/string/number/object, targeting rules, per-user prefs, partial-rollout determinism, deletion, polling fallback, userPrefs:false, type mismatch, malformed-data + KV-unreachable degradation, lifecycle (STALE pre-init).
  • Deterministic watch-resume test: a fake client drops the stream mid-watch; the reconnect must carry since=<last rev> and replay the missed delta.
  • Validated against ~13 clean full-suite runs (the suites were de-flaked under the shared test keyspace).

ARCHITECTURE.md updated to match in the same change (providers section, byte-compare, since-resume rationale).

🤖 Generated with Claude Code

jaredLunde and others added 7 commits June 23, 2026 09:47
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 jaredLunde merged commit 274df56 into main Jun 23, 2026
5 checks passed
@jaredLunde jaredLunde deleted the flags-sdk-adapter branch June 23, 2026 19:05
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant