Skip to content

feat: Add the override store, overlay, and data system wiring - #2051

Open
kinyoklion wants to merge 9 commits into
feat/overridesfrom
rlamb/overrides-node-override-layer
Open

kinyoklion wants to merge 9 commits into
feat/overridesfrom
rlamb/overrides-node-override-layer

Conversation

@kinyoklion

@kinyoklion kinyoklion commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Summary

This PR is based on the evaluator marking branch because it uses the marker and the marking that branch adds.

This change adds the override layer described by the OVERRIDE specification to the FDv2 data system of the Node.js server SDK.

An override source is configured with the new overrides property of the data system options, which accepts a source object or a factory function. A source is not a data source: it takes no part in the initializer and synchronizer pipeline and has no effect on initialization status, data availability, or data source status. The SDK starts it when the client is created, before the data source, passes it a sink, and closes it with the client. In offline mode the source is not started. The public LDOverrideSource and LDOverrideSink interfaces let an application supply a custom source. A configuration that is not a source fails client construction, the way an unsupported data source configuration does.

The source supplies complete snapshots to the override store. Each snapshot replaces the whole store in one assignment, so the store holds exactly one snapshot at any instant. The store holds prepared, marked copies of the definitions and never modifies the objects the source supplied. An overlay at the store read boundary returns the override entry for a key in preference to LaunchDarkly data, and an enumeration includes every flag the store holds. Evaluation, prerequisite and segment resolution, and the all-flags read all go through the overlay.

The client consults the override store before the not-initialized short circuit, and before it asks the data store anything: with a persistent store the initialization check is I/O, and an overridden flag must not wait on a store that is slow or unreachable. An overridden flag is served before the client has LaunchDarkly data, while a flag the store does not hold still gets the not-ready default. Evaluation never waits for the source: an evaluation reads the override store as it is at that moment. The source's initial load is part of starting the client, so the promise that waitForInitialization returns settles after the load as well, whether initialization succeeded or failed, bounded by the caller's timeout; for the file-based source that is a local read that completes within milliseconds of construction. A failed start of the source is logged and the client runs without overrides. The source is created before the data store, so an invalid override configuration fails construction before a persistent store has connected.

The override layer checks the shape of every definition before it stores it. The SDK keeps definitions in their JSON form, so a hand-written definition with a field of the wrong type (a flag without variations, a target or clause without values, rules that are not an array, an on switch that is a string) would otherwise make evaluation throw instead of returning a malformed-flag result. The fields the SDK itself sets when it prepares a definition, such as the sets it generates for large target lists, cannot be supplied. A definition that fails the check is rejected with an error that names the kind, the key, and the field, and no part of that snapshot is applied: the layer keeps its previous contents. Only the key, variations, and values are required; every other field may be absent, as in LaunchDarkly data, so LaunchDarkly definitions, value-only overrides, and TestData flags all pass. The store's maps have no prototype, so a key such as constructor or __proto__ is an ordinary key.

The all-flags state keeps override-affected flags with their values, versions, and marked reasons, and presents them with trackEvents and trackReason false and no debugEventsUntilDate. While the client is not initialized and the store holds entries, the state is valid and holds only those flags. A type mismatch keeps the marking of the evaluation it replaces. Adding, changing, or removing an override fires the normal flag change notifications, including for the flags that depend on a changed flag or segment.

The OVERRIDE specification's test vectors are vendored and run as a unit test through the full client stack, asserting the value, the variation index, and the reason.

SDK-3247


Note

Overview
Adds an experimental flag/segment override layer to the FDv2 server SDK via dataSystem.overrides, wired through public LDOverrideSource / LDOverrideSink APIs (source or factory).

Override snapshots replace an in-memory OverrideLayer and are read through a ReadStoreOverlay so per-key overrides win over LaunchDarkly data without changing init or data-source status. LDClientImpl serves overridden flags before the not-ready short-circuit (including when the persistent store is slow), marks evaluations with overrideAffected, adjusts allFlagsState (override-only when uninitialized, strips tracking for affected flags), fans change notifications through OverrideSink, and makes waitForInitialization wait for the source’s initial load while evaluations do not.

Definitions are validated and prepared atomically per snapshot; malformed snapshots are rejected and the previous layer is kept. OVERRIDE spec vectors and broad unit/integration tests cover precedence, dependencies, lifecycle, and configuration edge cases.

Reviewed by Cursor Bugbot for commit 27aa6a8. Bugbot is set up for automated code reviews on this repo. Configure here.

@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from 9e22472 to e6ad1d1 Compare September 28, 2026 20:28
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from 9bc7846 to 0c00688 Compare September 28, 2026 20:28
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from e6ad1d1 to 30eb906 Compare September 28, 2026 21:20
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from 0c00688 to e42e2cb Compare September 28, 2026 21:20
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from 30eb906 to 11aaea9 Compare September 29, 2026 15:20
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from e42e2cb to 8e9fd05 Compare September 29, 2026 15:20
@kinyoklion
kinyoklion marked this pull request as ready for review September 30, 2026 17:07
@kinyoklion
kinyoklion requested a review from a team as a code owner September 30, 2026 17:07
@kinyoklion
kinyoklion added this pull request to stack #2061 September 30, 2026 17:08
@github-actions

Copy link
Copy Markdown
Contributor

@launchdarkly/js-client-sdk-common size report
This is the brotli compressed size of the ESM build.
Compressed size: 25437 bytes
Compressed size limit: 44000
Uncompressed size: 165420 bytes

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

@launchdarkly/js-sdk-common size report
This is the brotli compressed size of the ESM build.
Compressed size: 29603 bytes
Compressed size limit: 30000
Uncompressed size: 142116 bytes

@kinyoklion
kinyoklion marked this pull request as draft September 30, 2026 17:10
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

@launchdarkly/js-client-sdk size report
This is the brotli compressed size of the ESM build.
Compressed size: 32662 bytes
Compressed size limit: 34000
Uncompressed size: 117055 bytes

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/shared/sdk-server/src/LDClientImpl.ts Outdated
Comment thread packages/shared/sdk-server/src/LDClientImpl.ts
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from 11aaea9 to 91d45a2 Compare September 30, 2026 18:17
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from 8e9fd05 to 71a1426 Compare September 30, 2026 18:17
@kinyoklion
kinyoklion removed this pull request from stack #2061 September 30, 2026 18:37
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from 91d45a2 to 7874143 Compare September 30, 2026 20:16
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from 71a1426 to a9e83b1 Compare September 30, 2026 20:16
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from 7874143 to 4ecf628 Compare September 30, 2026 20:23
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from a9e83b1 to a5614f5 Compare September 30, 2026 20:23
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from 4ecf628 to 2be4e36 Compare September 30, 2026 21:01
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from a5614f5 to d0ed461 Compare September 30, 2026 21:01
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from 2be4e36 to fa5493d Compare September 30, 2026 21:04
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from d0ed461 to 667c330 Compare September 30, 2026 21:04
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from fa5493d to f8012e2 Compare September 30, 2026 22:22
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from 667c330 to 431c72b Compare September 30, 2026 22:23
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from f8012e2 to 5605b28 Compare September 30, 2026 22:27
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from 431c72b to 980675a Compare September 30, 2026 22:27
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from 5605b28 to f5a88e9 Compare October 1, 2026 23:07
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from 14bcbd6 to cb45af0 Compare October 5, 2026 22:45
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from 62c63e6 to b21f974 Compare October 5, 2026 22:45
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from cb45af0 to 9b941d7 Compare October 8, 2026 16:38
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from b21f974 to 525a8ba Compare October 8, 2026 16:38
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from 9b941d7 to 50819bc Compare October 8, 2026 22:17
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from 525a8ba to 6e47ed9 Compare October 8, 2026 22:17
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from 50819bc to 4550837 Compare October 8, 2026 22:19
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from 6e47ed9 to e5a78d1 Compare October 8, 2026 22:19
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from 4550837 to 8650066 Compare October 8, 2026 22:35
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from e5a78d1 to f045d43 Compare October 8, 2026 22:35
@kinyoklion
kinyoklion marked this pull request as ready for review October 8, 2026 22:49

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/shared/sdk-server/src/LDClientImpl.ts
Comment thread packages/shared/sdk-server/src/LDClientImpl.ts
* prepares LaunchDarkly data, and marks it. The source's object is never modified.
*/
function prepareFlag(item: LDKeyedFeatureStoreItem): LayerEntry {
const json = JSON.stringify(item);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we should assess https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone for deep copy. I'm not too partial to it because there may be compatibility issues.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not convinced this copy is required. So I will check that.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked. The copy itself is needed only to keep the sink contract (the SDK never modifies the objects a source supplies; the other SDKs make the same promise and preparation here writes into nested data), but it was hand-rolled, and the serialized text it produced was also being used for change detection, which is not order-stable. Fixed in 7933b13: the layer now copies with the clone helper from js-sdk-common and stores only the prepared item, and the sink compares entries with fastDeepEqual, the same way the client flag manager computes changed keys. No serialized text is kept, so structuredClone is not needed. A test re-supplies a definition with every object's properties reversed and expects no notification.

Written by Claude Code on behalf of @kinyoklion.

@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from 8650066 to 405de3e Compare October 9, 2026 18:49
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from f045d43 to d29754c Compare October 9, 2026 18:49

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d29754c. Configure here.

Comment thread packages/shared/sdk-server/src/LDClientImpl.ts
Comment thread packages/shared/sdk-server/src/LDClientImpl.ts
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-evaluator-marking branch from 405de3e to 95c87a5 Compare October 9, 2026 19:58
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch 2 times, most recently from 2533bdc to 754c0f6 Compare October 9, 2026 20:30
Base automatically changed from rlamb/overrides-node-evaluator-marking to feat/overrides October 9, 2026 21:24
Adds the override layer described by the OVERRIDE specification to the FDv2
data system of the server SDK.

An override source is configured with the new overrides option of the data
system options. It is not a data source: it takes no part in the initializer
and synchronizer pipeline and has no effect on initialization status. The SDK
starts it when the client is created, passes it a sink, and closes it with the
client. The public LDOverrideSource and LDOverrideSink interfaces let an
application supply a custom source.

The source supplies complete snapshots to the override store. Each snapshot
replaces the whole store in one assignment. The store holds prepared, marked
copies of the definitions and never modifies the objects the source supplied.
An overlay at the store read boundary returns the override entry for a key in
preference to LaunchDarkly data. Evaluation, prerequisite and segment
resolution, and the all-flags read go through the overlay.

The client consults the override store before the not-initialized short
circuit, so an overridden flag is served before the client has LaunchDarkly
data while a non-overridden flag still gets the not-ready default. Evaluations
wait for the source's asynchronous initial load, so an override present at
startup takes effect from the first evaluation. The all-flags state keeps
override-affected flags with their values and marked reasons but turns their
event tracking fields off. A type mismatch keeps the marking of the evaluation
it replaces. Adding, changing, or removing an override fires the normal flag
change notifications, including for the flags that depend on a changed entry.

The OVERRIDE specification's test vectors run as a unit test through the full
client stack.
…the override layer

A throwing override source close is logged and the rest of the shutdown continues. Before initialization, the all flags state uses the override layer only when it holds at least one flag.
…load

Every evaluation path, and the all-flags state, was chained onto the promise
of the override source's start so that an override present at startup would
apply from the first evaluation. Evaluation must not wait on I/O: a source
whose start never settles, such as a read of a hung network file system or a
custom source waiting on an unreachable endpoint, held every flag evaluation
and every caller's promise forever, and close could not release them.

The wait is removed. The initial load is part of starting the client, so the
promise that waitForInitialization returns now resolves after the load as
well, bounded by the caller's timeout, which is the asynchronous start
operation the specification describes. An evaluation made before the load
completes reads the layer as it is at that moment.

A migration variation whose value is not a stage replaces the reason; the
replacement now keeps the override-affected marking, as the typed variation
methods do.

Negative controls: the migration test fails when the marking is dropped from
the replacement reason; the two waitForInitialization tests fail when the
load is left out of the start handle; the evaluation tests fail against the
previous gate.
…ation requires

The SDK stores flag and segment definitions in their JSON form and has no
typed deserialization, so a hand-written override definition could reach the
evaluator with a field of the wrong type: a flag without variations, a target
or clause without values, rules that are not an array. The evaluator then
threw instead of returning a malformed-flag result, and the all flags state
could never resolve. SDKs with typed deserialization reject such a definition
when they read it.

The override layer now checks each definition before it prepares and stores
it. The checks cover the fields that evaluation and the preparation read with
an assumed type: lists are arrays, list entries are objects, and attribute
names that become attribute references are strings. Only a flag's variations
and the values of a target or clause are required, because those are the
fields evaluation reads without a guard. Every other field may be absent or
null, as it may be in LaunchDarkly data. A definition that fails the check
throws an Error that names the kind, the key, and the field, and the layer
keeps its previous contents, so a snapshot with one bad entry applies
nothing.

Negative controls: each rejection test fails when its check is removed or
disabled; the acceptance tests fail when the optional lists are made
required, when null stops counting as absent, or when the string check is
inverted; the snapshot and client tests fail when the layer builds the new
snapshot in its live contents.
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-node-override-layer branch from fb16ec6 to dc6c2f8 Compare October 9, 2026 21:27
… shared clone helper

The override layer copied each supplied definition with an inline JSON round trip and kept the serialized text on the entry so the sink could tell which entries changed between snapshots. JSON.stringify is not canonical: the same definition supplied with its properties in another order, for example after a file was reformatted or by a source that builds objects in varying order, produced different text and a spurious flag change notification.

The sink now compares the prepared entries with fastDeepEqual, which ignores property order, the same way the client SDK's flag manager computes changed keys. The layer no longer stores text: an entry is the prepared, marked copy itself, made with the clone helper from js-sdk-common instead of a hand-rolled round trip. A new test supplies a definition again with every object's properties reversed and expects no notification.
…ent marking

The override indicator helper that the evaluator kept private now lives with the other marker functions, and the client uses it for the two wrong-type error paths instead of re-implementing the spread. The sink's merged view is the existing feature store storage type. The definition shape checks use the common validators for the plain-object and nullish tests and export the plain-object check for the document parser. The tests use the common sleep helper and one shared jest mock logger.
…ent, and harden the layer against odd keys and fields

Before initialization, an evaluation asked the data store whether it was initialized before it looked in the override layer. With a persistent store that check is I/O, so an override could wait on a store that was slow or unreachable, in the incident the overrides exist for. The layer is now consulted first, and the source is created before the store so an invalid override configuration fails construction before a store connects.

The start handle that waitForInitialization returns now settles after the override source's initial load whether initialization succeeded or failed, including when it had already failed, so an application that handles a failed start and evaluates sees the overrides present at startup.

The layer's maps and the merged views no longer have a prototype, so a key such as "constructor" or "__proto__" is an ordinary entry and a key the layer does not hold is not found through Object.prototype. The sink treats an entry whose comparison throws, for a definition with a property named like a method of Object, as changed instead of dropping the snapshot's notifications.

The shape check now requires a string key, requires the on switch to be a boolean when present, and rejects the fields the SDK sets when it prepares a definition (the generated target sets and the compiled bucket-by reference), which evaluation would otherwise read with the wrong type.

This branch has not been deployed

No deployments
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.

2 participants