@wardx/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,289 @@
1
+ # @wardx/core
2
+
3
+ `@wardx/core` is the runtime-agnostic engine of Wardx.
4
+
5
+ The engine records metrics, events, and logs in memory. The engine also stores Remote Config and assigns experiment variants.
6
+
7
+ The engine does not send HTTP. A runtime package, for example `wardx`, sends the frames.
8
+
9
+ Node.js 20 or later is required.
10
+
11
+ Install this package from npm when you write a custom runtime. If you use Node.js, install `wardx`. The `wardx` package depends on `@wardx/core`.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @wardx/core
17
+ ```
18
+
19
+ ```js
20
+ import { WardxCore, assignVariant, loadSdkDefaults } from '@wardx/core';
21
+ ```
22
+
23
+ ## Design rules
24
+
25
+ - A measure call changes local memory only.
26
+ - A measure call does not send network data.
27
+ - A measure call does not wait for a Promise.
28
+ - If a buffer is full, the engine discards data. The engine does not block the application.
29
+ - Counters in a frame are window deltas. Counters are not lifetime totals.
30
+
31
+ ## Settings
32
+
33
+ `WardxCore` needs a settings object. Use `loadSdkDefaults()` and add the identity fields.
34
+
35
+ | Key | Description |
36
+ | --- | --- |
37
+ | `endpoint` | Sync URL. The core does not use this key. Runtimes use this key. |
38
+ | `projectKey` | Project credential. Runtimes send this key. If `privacySalt` is empty, the core uses this key as the salt. |
39
+ | `project` | Project name. |
40
+ | `role` | Runtime identity inside the project. Runtimes send this name. The core does not use this key. |
41
+ | `appVersion` | Application version. |
42
+ | `environment` | Environment name, for example `production`. |
43
+ | `privacySalt` | Salt for the hashed subject. If you omit this key, the core uses `projectKey`. |
44
+ | `aggregateIntervalMs` | Default `1000`. Interval to snapshot dirty data. |
45
+ | `syncIntervalMs` | Default `15000`. Interval for the runtime sync. |
46
+ | `maxBufferedEvents` | Default `5000`. |
47
+ | `maxBufferedLogs` | Default `2000`. |
48
+ | `maxFrameBytes` | Default `524288`. |
49
+ | `maxSeriesPerMetric` | Default `1000`. |
50
+ | `maxDimensionKeys` | Default `8`. |
51
+ | `maxDimensionValueLength` | Default `64`. |
52
+ | `histogramBuckets` | Default `[10, 25, 50, 100, 250, 500, 1000]`. |
53
+ | `tracer` | Optional. Duck-typed local hook with any of `measure`, `event`, `log`, `frame`. The core does not print. Runtimes may also call `sync`. |
54
+
55
+ The defaults live in `defaults.json`. Do not omit a required key. The loader does not add a fallback for a missing key. `tracer` is not a default key. Omit it to keep the measure path unchanged.
56
+
57
+ ## Use case 1: Record metrics in a custom runtime
58
+
59
+ **When:** You write a runtime that is not Node.js, or you test the engine without HTTP.
60
+
61
+ **Objective:** Record counters, gauges, histograms, and timers. Then make a frame.
62
+
63
+ ```js
64
+ import { WardxCore, loadSdkDefaults } from '@wardx/core';
65
+
66
+ const settings = {
67
+ ...loadSdkDefaults(),
68
+ endpoint: 'http://127.0.0.1:8787',
69
+ projectKey: 'dev_project_key',
70
+ project: 'demo',
71
+ role: 'client',
72
+ appVersion: '0.1.0',
73
+ environment: 'development',
74
+ privacySalt: 'dev_project_key'
75
+ };
76
+
77
+ const core = new WardxCore(settings);
78
+
79
+ core.counter('match.completed', { mode: 'ranked' }).inc();
80
+ core.counter('coins.awarded').add(25);
81
+ core.gauge('players.online').set(12921);
82
+ core.histogram('request.duration').observe(42);
83
+
84
+ const endTimer = core.timer('matchmaking.duration');
85
+ endTimer({ result: 'success' });
86
+
87
+ const fitted = core.snapshotFrame();
88
+ const frames = core.takePendingFrames();
89
+ ```
90
+
91
+ ### Procedure
92
+
93
+ 1. Load the SDK defaults.
94
+ 2. Add the identity fields.
95
+ 3. Construct `WardxCore`.
96
+ 4. Call `counter`, `gauge`, `histogram`, or `timer`.
97
+ 5. Call `snapshotFrame` when you need a frame.
98
+ 6. Call `takePendingFrames` to get the pending frames.
99
+
100
+ `counter(name, dims).inc()` adds `1`. `add(n)` adds a finite number `n`.
101
+
102
+ `gauge(name, dims).set(value)` stores the last finite value and a timestamp.
103
+
104
+ `histogram(name).observe(value)` records a finite value into buckets. You can set buckets:
105
+
106
+ ```js
107
+ core.histogram('request.duration', { buckets: [10, 25, 50, 100] }).observe(42);
108
+ core.histogram('coins.award_size').observe(80, { grantId: 'g-80' });
109
+ ```
110
+
111
+ `observe(value, attrs)` keeps `attrs` only when `value` is the window max. The frame stores that pair as `exemplar`. Attrs use the same key and value limits as dimensions. Do not change the buckets of an existing series. The engine throws an error.
112
+
113
+ `timer(name, dims)` starts a timer. The returned function records the duration in milliseconds into a histogram. You can add dimensions when you stop the timer.
114
+
115
+ If a series is above `maxSeriesPerMetric`, or a dimension is not valid, the engine returns a no-op object. The engine increments `wardx.internal.cardinality_dropped`.
116
+
117
+ A dimension value must be a string, a number, or a boolean.
118
+
119
+ ## Use case 2: Record product events and logs
120
+
121
+ **When:** You need discrete product events or structured logs in the same frame as metrics.
122
+
123
+ **Objective:** Buffer events and logs until the next snapshot.
124
+
125
+ ```js
126
+ core.event('purchase', { product: 'premium' });
127
+ core.log.info('match_started', { mode: 'ranked', players: 4 });
128
+ core.log.error('payment_failed', { code: 'timeout' });
129
+ ```
130
+
131
+ Log levels: `debug`, `info`, `warn`, `error`.
132
+
133
+ ### Buffer limits
134
+
135
+ - If the event buffer is full, the engine discards the new event.
136
+ - If the log buffer is full, the engine replaces a log with a lower severity when possible.
137
+ - If the engine cannot replace a log, the engine discards the new log.
138
+
139
+ Dropped items increment `wardx.internal.events_dropped` or `wardx.internal.logs_dropped`.
140
+
141
+ Use counters and histograms for rates and latency. Use events for rare product facts: a purchase, an experiment exposure or goal, a named screen. Use logs for failures. Do not put user ids on metric dimensions.
142
+
143
+ ## Use case 3: Get Remote Config and assign an experiment
144
+
145
+ **When:** The server sends a config snapshot. You need a value for a subject.
146
+
147
+ **Objective:** Get a config value. If an experiment applies, get the variant value.
148
+
149
+ ```js
150
+ core.applyConfig(13, {
151
+ values: {
152
+ 'message.delayMs': 1000,
153
+ 'chat.enabled': true
154
+ },
155
+ experiments: [
156
+ {
157
+ id: 'message-delay-v1',
158
+ enabled: true,
159
+ allocation: 1,
160
+ salt: '3ad8f9',
161
+ primaryMetric: 'message.sent',
162
+ variants: [
163
+ { key: 'control', weight: 50, values: { 'message.delayMs': 1000 } },
164
+ { key: 'fast', weight: 50, values: { 'message.delayMs': 400 } }
165
+ ]
166
+ }
167
+ ]
168
+ });
169
+
170
+ const fallback = 1000;
171
+ const delay = core.configGet('message.delayMs', fallback, { subjectId: 'user-1' });
172
+ core.experimentGoal('message.sent', { subjectId: 'user-1', value: 1 });
173
+ ```
174
+
175
+ ### Resolution order
176
+
177
+ 1. If the key is not in the snapshot, return `fallback`.
178
+ 2. If `subjectId` is missing, return the Remote Config value.
179
+ 3. If no enabled experiment contains the key, return the Remote Config value.
180
+ 4. If the subject is not in the allocation, return the Remote Config value.
181
+ 5. If the subject is in the allocation, return the variant value.
182
+
183
+ The assignment is deterministic. The same `experimentId`, `subjectId`, and `salt` always give the same variant.
184
+
185
+ The first resolve for a subject in a session emits event `experiment.exposure`. The payload contains a hashed subject. The payload does not contain the raw `subjectId`.
186
+
187
+ `experimentGoal` emits event `experiment.goal`. You must supply `subjectId`. You can supply `value`.
188
+
189
+ ## Use case 4: Assign a variant without WardxCore
190
+
191
+ **When:** You verify experiment math, or you assign a variant in a test.
192
+
193
+ **Objective:** Use the same FNV-1a 32-bit function as the SDK.
194
+
195
+ ```js
196
+ import { assignVariant, assignmentHash, hashToUnitInterval, subjectHash } from '@wardx/core';
197
+
198
+ const experiment = {
199
+ id: 'message-delay-v1',
200
+ enabled: true,
201
+ allocation: 0.5,
202
+ salt: '3ad8f9',
203
+ variants: [
204
+ { key: 'control', weight: 50, values: { 'message.delayMs': 1000 } },
205
+ { key: 'fast', weight: 50, values: { 'message.delayMs': 400 } }
206
+ ]
207
+ };
208
+
209
+ const variant = assignVariant(experiment, 'user-1');
210
+ const hash = assignmentHash(experiment.id, 'user-1', experiment.salt);
211
+ const bucket = hashToUnitInterval(hash);
212
+ const hashedSubject = subjectHash('dev_project_key', 'user-1');
213
+ ```
214
+
215
+ Hash input:
216
+
217
+ ```text
218
+ hash = fnv1a32(experimentId + ':' + subjectId + ':' + salt)
219
+ bucket = hash / 2^32
220
+ ```
221
+
222
+ If `bucket >= allocation`, `assignVariant` returns `null`.
223
+
224
+ `subjectHash` returns 8 lowercase hex digits of `fnv1a32(privacySalt + ':' + subjectId)`.
225
+
226
+ ## Use case 5: Build a frame for a custom transport
227
+
228
+ **When:** You send frames with your transport. You do not use the Node SDK.
229
+
230
+ **Objective:** Snapshot dirty data, then take the pending frames.
231
+
232
+ ```js
233
+ import { FrameBuilder, PROTOCOL_VERSION, SDK_NAME, PLATFORM } from '@wardx/core';
234
+
235
+ core.counter('match.completed').inc();
236
+ const fitted = core.snapshotIfDirty();
237
+ if (fitted) {
238
+ const frames = core.takePendingFrames();
239
+ const envelope = {
240
+ protocol: PROTOCOL_VERSION,
241
+ project: settings.project,
242
+ sdk: { name: SDK_NAME, version: '0.1.0' },
243
+ client: {
244
+ instanceId: '01…',
245
+ sessionId: '01…',
246
+ role: settings.role,
247
+ appVersion: settings.appVersion,
248
+ environment: settings.environment,
249
+ platform: PLATFORM
250
+ },
251
+ configVersion: core.configStore.version,
252
+ frames
253
+ };
254
+ }
255
+ ```
256
+
257
+ `snapshotIfDirty` returns `null` when there is no new data.
258
+
259
+ If a frame is larger than `maxFrameBytes`, `FrameBuilder.fitToMaxBytes` discards data in this order:
260
+
261
+ 1. Logs with the lowest severity.
262
+ 2. Events from the end of the buffer.
263
+ 3. Application histograms.
264
+ 4. Application gauges. Internal gauges stay.
265
+
266
+ Internal series use the prefix `wardx.internal.`.
267
+
268
+ ## Exports
269
+
270
+ | Export | Function |
271
+ | --- | --- |
272
+ | `WardxCore` | Engine. |
273
+ | `ConfigStore` | Stores one Remote Config snapshot. |
274
+ | `ExperimentResolver` | Assigns variants and records exposure. |
275
+ | `assignVariant` | Assigns one variant. |
276
+ | `fnv1a32`, `assignmentHash`, `hashToUnitInterval`, `subjectHash` | Hash helpers. |
277
+ | `Counter`, `Gauge`, `Histogram`, `MetricsRegistry` | Metric types. |
278
+ | `EventBuffer`, `LogBuffer` | In-memory buffers. |
279
+ | `FrameBuilder` | Builds and trims frames. |
280
+ | `resolveSettings`, `loadSdkDefaults`, `nextSyncDelayMs` | Settings helpers. |
281
+ | `PROTOCOL_VERSION`, `SDK_NAME`, `PLATFORM`, `INTERNAL` | Protocol constants. |
282
+ | `ulid` | Identifier helper. |
283
+
284
+ ## Related packages
285
+
286
+ - Node.js SDK: `wardx`
287
+ - Ingest server: `@wardx/server`
288
+
289
+ The wire contract is protocol version 1. A runtime sends `POST /v1/sync` with JSON and gzip. The request header is `X-Wardx-Key`.
package/defaults.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "aggregateIntervalMs": 1000,
3
+ "syncIntervalMs": 15000,
4
+ "syncJitterMin": 0.85,
5
+ "syncJitterMax": 1.15,
6
+ "maxBufferedEvents": 5000,
7
+ "maxBufferedLogs": 2000,
8
+ "maxFrameBytes": 524288,
9
+ "maxSeriesPerMetric": 1000,
10
+ "maxDimensionKeys": 8,
11
+ "maxDimensionValueLength": 64,
12
+ "httpTimeoutMs": 10000,
13
+ "histogramBuckets": [10, 25, 50, 100, 250, 500, 1000]
14
+ }
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@wardx/core",
3
+ "version": "0.1.0",
4
+ "description": "Runtime-agnostic Wardx engine for metrics, events, logs, Remote Config, and experiments.",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "defaults.json"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ }
19
+ }
@@ -0,0 +1,198 @@
1
+ import { MetricsRegistry } from './metrics/MetricsRegistry.js';
2
+ import { EventBuffer } from './buffers/EventBuffer.js';
3
+ import { LogBuffer } from './buffers/LogBuffer.js';
4
+ import { ConfigStore } from './config/ConfigStore.js';
5
+ import { ExperimentResolver } from './config/ExperimentResolver.js';
6
+ import { FrameBuilder } from './frame/FrameBuilder.js';
7
+ import { InternalMetrics } from './internal/InternalMetrics.js';
8
+ import { NOOP_COUNTER } from './metrics/Counter.js';
9
+ import { NOOP_GAUGE } from './metrics/Gauge.js';
10
+ import { NOOP_HISTOGRAM } from './metrics/Histogram.js';
11
+ import { startTimer } from './metrics/Timer.js';
12
+ import { emit } from './trace/emit.js';
13
+ import { wrapCounter, wrapGauge, wrapHistogram } from './trace/wrap.js';
14
+
15
+ export class WardxCore {
16
+ constructor(settings) {
17
+ this.settings = settings;
18
+ this.stopped = false;
19
+ this._tracer = settings.tracer ?? null;
20
+ this._wrappers = this._tracer ? new WeakMap() : null;
21
+ this.internal = new InternalMetrics();
22
+ this.metrics = new MetricsRegistry({
23
+ maxSeriesPerMetric: settings.maxSeriesPerMetric,
24
+ maxDimensionKeys: settings.maxDimensionKeys,
25
+ maxDimensionValueLength: settings.maxDimensionValueLength,
26
+ defaultHistogramBuckets: settings.histogramBuckets,
27
+ onCardinalityDropped: () => {
28
+ this.internal.cardinalityDropped += 1;
29
+ }
30
+ });
31
+ this.events = new EventBuffer(settings.maxBufferedEvents);
32
+ this.logs = new LogBuffer(settings.maxBufferedLogs);
33
+ this.configStore = new ConfigStore();
34
+ this.experiments = new ExperimentResolver({
35
+ privacySalt: settings.privacySalt,
36
+ onExposure: (payload) => {
37
+ this.event('experiment.exposure', payload);
38
+ }
39
+ });
40
+ this.seq = 0;
41
+ this.pendingFrames = [];
42
+ this.windowStart = Date.now();
43
+ this.log = {
44
+ debug: (message, attrs) => this._log('debug', message, attrs),
45
+ info: (message, attrs) => this._log('info', message, attrs),
46
+ warn: (message, attrs) => this._log('warn', message, attrs),
47
+ error: (message, attrs) => this._log('error', message, attrs)
48
+ };
49
+ }
50
+
51
+ counter(name, dims) {
52
+ return this._wrap(this.metrics.counter(name, dims), NOOP_COUNTER, (series, noop) =>
53
+ wrapCounter(this._tracer, series, noop, name, dims)
54
+ );
55
+ }
56
+
57
+ gauge(name, dims) {
58
+ return this._wrap(this.metrics.gauge(name, dims), NOOP_GAUGE, (series, noop) =>
59
+ wrapGauge(this._tracer, series, noop, name, dims)
60
+ );
61
+ }
62
+
63
+ histogram(name, a, b) {
64
+ return this._wrap(this.metrics.histogram(name, a, b), NOOP_HISTOGRAM, (series, noop) =>
65
+ wrapHistogram(this._tracer, series, noop, name)
66
+ );
67
+ }
68
+
69
+ timer(name, dims) {
70
+ if (this._tracer === null) return this.metrics.timer(name, dims);
71
+ return startTimer((duration, endDims) => {
72
+ const merged = endDims ? { ...(dims || {}), ...endDims } : dims;
73
+ this.histogram(name, merged).observe(duration);
74
+ });
75
+ }
76
+
77
+ event(name, attrs) {
78
+ const dropped = !this.events.push(name, attrs);
79
+ if (dropped) this.internal.eventsDropped += 1;
80
+ emit(this._tracer, 'event', { name, attrs: attrs ?? null, dropped });
81
+ }
82
+
83
+ _log(level, message, attrs) {
84
+ const dropped = !this.logs.push(level, message, attrs);
85
+ if (dropped) this.internal.logsDropped += 1;
86
+ emit(this._tracer, 'log', { level, message, attrs: attrs ?? null, dropped });
87
+ }
88
+
89
+ _wrap(series, noopSentinel, factory) {
90
+ if (this._tracer === null) return series;
91
+ if (series === noopSentinel) return factory(series, true);
92
+ let wrapped = this._wrappers.get(series);
93
+ if (wrapped) return wrapped;
94
+ wrapped = factory(series, false);
95
+ this._wrappers.set(series, wrapped);
96
+ return wrapped;
97
+ }
98
+
99
+ configGet(key, fallback, context) {
100
+ if (!this.configStore.has(key)) return fallback;
101
+ const remote = this.configStore.getRaw(key);
102
+ if (!context || context.subjectId === undefined || context.subjectId === null) {
103
+ return remote;
104
+ }
105
+ return this.experiments.resolve(
106
+ key,
107
+ remote,
108
+ context.subjectId,
109
+ this.configStore.experimentsByKey
110
+ );
111
+ }
112
+
113
+ experimentGoal(name, context) {
114
+ if (!context || context.subjectId === undefined || context.subjectId === null) {
115
+ throw new Error('experiment.goal requires subjectId');
116
+ }
117
+ if (typeof name !== 'string' || name.length === 0) {
118
+ throw new Error('experiment.goal requires a metric name');
119
+ }
120
+ const subject = this.experiments.hashSubject(context.subjectId);
121
+ const experiments = this.experiments.relevantExperiments(
122
+ context.subjectId,
123
+ this.configStore.experiments
124
+ );
125
+ const payload = {
126
+ metric: name,
127
+ subject,
128
+ experiments
129
+ };
130
+ if (context.value !== undefined) payload.value = context.value;
131
+ this.event('experiment.goal', payload);
132
+ }
133
+
134
+ applyConfig(version, config) {
135
+ this.configStore.applySnapshot({
136
+ version,
137
+ values: config.values,
138
+ experiments: config.experiments
139
+ });
140
+ this.internal.configVersion = version;
141
+ }
142
+
143
+ snapshotIfDirty() {
144
+ if (
145
+ !this.metrics.isDirty() &&
146
+ this.events.length === 0 &&
147
+ this.logs.length === 0 &&
148
+ !this.internal.hasCounterActivity()
149
+ ) {
150
+ return null;
151
+ }
152
+ return this.snapshotFrame();
153
+ }
154
+
155
+ snapshotFrame() {
156
+ const to = Date.now();
157
+ const from = this.windowStart;
158
+ this.windowStart = to;
159
+ this.internal.eventsBuffered = this.events.length;
160
+ this.internal.logsBuffered = this.logs.length;
161
+ const metrics = this.metrics.snapshotAndReset();
162
+ const events = this.events.swap();
163
+ const logs = this.logs.swap();
164
+ const internal = this.internal.snapshotAndReset();
165
+ const frame = FrameBuilder.build({
166
+ seq: ++this.seq,
167
+ from,
168
+ to,
169
+ metrics,
170
+ events,
171
+ logs,
172
+ internal
173
+ });
174
+ const fitted = FrameBuilder.fitToMaxBytes(frame, this.settings.maxFrameBytes);
175
+ this.internal.logsDropped += fitted.droppedLogs;
176
+ this.internal.eventsDropped += fitted.droppedEvents;
177
+ this.pendingFrames.push(fitted.frame);
178
+ emit(this._tracer, 'frame', {
179
+ seq: fitted.frame.seq,
180
+ from: fitted.frame.from,
181
+ to: fitted.frame.to,
182
+ counters: fitted.frame.metrics.counters.length,
183
+ gauges: fitted.frame.metrics.gauges.length,
184
+ histograms: fitted.frame.metrics.histograms.length,
185
+ events: fitted.frame.events.length,
186
+ logs: fitted.frame.logs.length,
187
+ droppedLogs: fitted.droppedLogs,
188
+ droppedEvents: fitted.droppedEvents
189
+ });
190
+ return fitted;
191
+ }
192
+
193
+ takePendingFrames() {
194
+ const frames = this.pendingFrames;
195
+ this.pendingFrames = [];
196
+ return frames;
197
+ }
198
+ }
@@ -0,0 +1,28 @@
1
+ export class EventBuffer {
2
+ constructor(maxBufferedEvents) {
3
+ if (!Number.isFinite(maxBufferedEvents) || maxBufferedEvents < 1) {
4
+ throw new Error('maxBufferedEvents must be >= 1');
5
+ }
6
+ this.max = maxBufferedEvents;
7
+ this.buf = [];
8
+ }
9
+
10
+ get length() {
11
+ return this.buf.length;
12
+ }
13
+
14
+ push(name, attrs) {
15
+ if (typeof name !== 'string' || name.length === 0) {
16
+ throw new Error('event name must be a non-empty string');
17
+ }
18
+ if (this.buf.length >= this.max) return false;
19
+ this.buf.push([Date.now(), name, attrs ?? null]);
20
+ return true;
21
+ }
22
+
23
+ swap() {
24
+ const sealed = this.buf;
25
+ this.buf = [];
26
+ return sealed;
27
+ }
28
+ }
@@ -0,0 +1,48 @@
1
+ import { LOG_RANK } from '../protocol.js';
2
+
3
+ export class LogBuffer {
4
+ constructor(maxBufferedLogs) {
5
+ if (!Number.isFinite(maxBufferedLogs) || maxBufferedLogs < 1) {
6
+ throw new Error('maxBufferedLogs must be >= 1');
7
+ }
8
+ this.max = maxBufferedLogs;
9
+ this.buf = [];
10
+ }
11
+
12
+ get length() {
13
+ return this.buf.length;
14
+ }
15
+
16
+ push(level, message, attrs) {
17
+ if (!Object.prototype.hasOwnProperty.call(LOG_RANK, level)) {
18
+ throw new Error(`invalid log level: ${level}`);
19
+ }
20
+ if (typeof message !== 'string' || message.length === 0) {
21
+ throw new Error('log message must be a non-empty string');
22
+ }
23
+ const entry = [Date.now(), level, message, attrs ?? null];
24
+ if (this.buf.length < this.max) {
25
+ this.buf.push(entry);
26
+ return true;
27
+ }
28
+ const incomingRank = LOG_RANK[level];
29
+ let victim = -1;
30
+ let victimRank = incomingRank;
31
+ for (let i = 0; i < this.buf.length; i++) {
32
+ const rank = LOG_RANK[this.buf[i][1]];
33
+ if (rank < victimRank) {
34
+ victimRank = rank;
35
+ victim = i;
36
+ }
37
+ }
38
+ if (victim === -1) return false;
39
+ this.buf[victim] = entry;
40
+ return false;
41
+ }
42
+
43
+ swap() {
44
+ const sealed = this.buf;
45
+ this.buf = [];
46
+ return sealed;
47
+ }
48
+ }
@@ -0,0 +1,31 @@
1
+ import { indexExperimentsByKey } from './ExperimentResolver.js';
2
+
3
+ export class ConfigStore {
4
+ constructor() {
5
+ this.version = 0;
6
+ this.values = Object.create(null);
7
+ this.experiments = [];
8
+ this.experimentsByKey = new Map();
9
+ }
10
+
11
+ applySnapshot(snapshot) {
12
+ if (!snapshot || typeof snapshot !== 'object') {
13
+ throw new Error('config snapshot must be an object');
14
+ }
15
+ if (typeof snapshot.version !== 'number' || !Number.isFinite(snapshot.version)) {
16
+ throw new Error('config snapshot version must be a finite number');
17
+ }
18
+ this.version = snapshot.version;
19
+ this.values = snapshot.values && typeof snapshot.values === 'object' ? snapshot.values : Object.create(null);
20
+ this.experiments = Array.isArray(snapshot.experiments) ? snapshot.experiments : [];
21
+ this.experimentsByKey = indexExperimentsByKey(this.experiments);
22
+ }
23
+
24
+ has(key) {
25
+ return Object.prototype.hasOwnProperty.call(this.values, key);
26
+ }
27
+
28
+ getRaw(key) {
29
+ return this.values[key];
30
+ }
31
+ }