@toggld/node 0.3.0 → 0.4.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 CHANGED
@@ -16,9 +16,10 @@ Two evaluation modes:
16
16
  evaluation" over the OFREP endpoints).
17
17
  - **Local** (`mode: "local"`) — recommended for long-lived servers. Flag
18
18
  definitions sync in the background (a snapshot fetch plus a live WebSocket
19
- stream, with polling fallback) and every evaluation runs **in-process** on
20
- the bundled engine: microsecond latency, zero server requests per
21
- evaluation, and changes pushed to your process within milliseconds. Requires
19
+ stream where supported, with polling fallback) and every evaluation runs **in-process** on
20
+ the bundled engine without a server request per evaluation. Workers currently
21
+ uses polling for definition updates. Cache freshness, polling intervals, and
22
+ network conditions affect when changed definitions arrive. Requires
22
23
  a secret SDK key (`ffk_…`) — public browser tokens cannot read rulesets. See
23
24
  [ADR-0012](../../docs/adr/0012-definition-sync-and-local-evaluation.md). Local
24
25
  mode can also read its snapshot from a **file** instead of the network — see
@@ -96,12 +97,17 @@ scope.
96
97
  | `timeoutMs` | `number` | `5000` | Per-request timeout. |
97
98
  | `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch (tests, proxies, instrumentation). |
98
99
 
99
- In local mode `create` resolves once the first snapshot has synced (or after
100
- `sync.readyTimeoutMs`); until a snapshot arrives, evaluations return your
101
- defaults with `errorCode: "PROVIDER_NOT_READY"` and sync keeps retrying.
102
- `client.ready()` awaits sync explicitly. Updates arrive as WebSocket pushes
103
- within milliseconds of a management change; on runtimes where the stream is
104
- unavailable the client polls instead, automatically.
100
+ In local mode, `create` waits for the first snapshot or `sync.readyTimeoutMs`.
101
+ Until a snapshot arrives, evaluations return your defaults with
102
+ `errorCode: "PROVIDER_NOT_READY"`, and sync keeps retrying. `client.ready()` waits
103
+ for the first snapshot without a timeout.
104
+
105
+ Changes on the connected Node instance normally arrive through WebSocket pushes.
106
+ The SDK also polls every 30 seconds, even while the stream stays healthy.
107
+ Polling recovers writes from other instances and announcements that the bounded
108
+ server buffer drops. With healthy requests, the conservative bound is
109
+ `sync.pollIntervalMs + 2 * timeoutMs`, plus scheduling delays, or 40 seconds by default.
110
+ The [Node sync guide](../../docs/node-sync.md) defines the assumptions and deployment requirements.
105
111
 
106
112
  ### How fast is local mode?
107
113
 
@@ -160,3 +166,53 @@ error, or a value whose type doesn't match the requested getter, the call
160
166
  resolves to your **default value**. The `*Details` methods carry `reason:
161
167
  "ERROR"` and an OpenFeature `errorCode` (`FLAG_NOT_FOUND`, `TYPE_MISMATCH`,
162
168
  `TARGETING_KEY_MISSING`, `PARSE_ERROR`, `GENERAL`, …) describing what happened.
169
+
170
+
171
+ ## Evaluation telemetry
172
+
173
+ In network-synced local mode, the SDK collects evaluation counts automatically.
174
+ It groups counts by flag, variant, result reason, and UTC minute. The SDK sends
175
+ batches to `POST /v1/telemetry` every 30 seconds and during `close()`.
176
+ Evaluation calls do not wait for these requests. Both SDK APIs use this reporter.
177
+
178
+ The SDK reports engine results for known flags, including engine errors.
179
+ It excludes unknown flags, calls before readiness, and errors from typed getters.
180
+ Each bulk evaluation counts each flag once. The SDK sends no user identities,
181
+ evaluation context, values, or metadata. Remote mode relies on server collection.
182
+ File-based mode sends no telemetry, even when credentials are present.
183
+
184
+ ```ts
185
+ const client = await ToggldClient.create({
186
+ endpoint,
187
+ sdkKey,
188
+ mode: "local",
189
+ telemetry: { flushIntervalMs: 30_000 },
190
+ })
191
+
192
+ await client.getBooleanValue("checkout", false, { targetingKey: "user-123" })
193
+ await client.flushTelemetry()
194
+ await client.close()
195
+ ```
196
+
197
+ Set `telemetry: false` to disable reporting. Use `flushTelemetry()` to send counts
198
+ before the next interval. Effect users can yield `client.flushTelemetry` and close
199
+ the layer scope to flush remaining counts. A flush failure leaves the batch
200
+ queued for the next attempt. Retries keep the same batch ID to prevent duplicate
201
+ counts after a lost response.
202
+
203
+ The SDK holds at most 10,000 active groups and 10,000 groups awaiting retry.
204
+ If the active buffer fills, the SDK drops new groups. Each request contains at
205
+ most 1,000 groups. Each count stops at 1,000,000,000. Keys and variants longer
206
+ than 256 characters do not produce telemetry. Process crashes can lose buffered
207
+ counts. Closing the client limits its final telemetry attempt to `timeoutMs`,
208
+ which defaults to five seconds. Counts remain approximate.
209
+
210
+
211
+ The integration suite can use Postgres for the complete SDK-to-storage path.
212
+ It creates tenants and flags, so use a disposable database. From the repository
213
+ root, set `DATABASE_URL` to that database and run the command below. The test
214
+ server applies migrations before it starts.
215
+
216
+ ```bash
217
+ TOGGLD_TEST_POSTGRES=1 DATABASE_URL=postgres://postgres:postgres@localhost:55439/toggld pnpm --filter @toggld/node exec vitest run test/telemetry.test.ts
218
+ ```
package/dist/cli.d.mts CHANGED
@@ -1 +1 @@
1
- export { };
1
+ export {}
package/dist/cli.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { t as FlagSnapshot } from "./src-CCPvsrli.mjs";
2
+ import { t as FlagSnapshot } from "./src-B_M-Oix8.mjs";
3
3
  import { Effect, Schema } from "effect";
4
4
  import { readFile, writeFile } from "node:fs/promises";
5
- //#region src/internal/cli.ts
5
+ // src/internal/cli.ts
6
6
  /**
7
7
  * The `toggld` CLI (ADR-0013). Today it has one command:
8
8
  *
@@ -115,7 +115,7 @@ Options for "pull":
115
115
 
116
116
  Point a local-mode client at the file with TOGGLD_FLAGS_FILE or
117
117
  { sync: { file: "<path>" } }.`;
118
- const BOOLEAN_FLAGS = new Set([
118
+ const BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
119
119
  "force",
120
120
  "f",
121
121
  "help",
@@ -223,8 +223,7 @@ const runMain = async (argv) => {
223
223
  };
224
224
  const asString = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
225
225
  const short = (version) => version.length > 12 ? `${version.slice(0, 12)}…` : version;
226
- //#endregion
227
- //#region src/cli.ts
226
+ // src/cli.ts
228
227
  /**
229
228
  * The `toggld` executable. All logic lives in `./internal/cli.ts` (so tests can
230
229
  * call it without spawning a process); this is just the entry that runs it.
@@ -233,5 +232,4 @@ runMain(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
233
232
  console.error(error);
234
233
  process.exit(1);
235
234
  });
236
- //#endregion
237
235
  export {};
@@ -1,10 +1,10 @@
1
- import { a as OfrepSuccess, i as OfrepFailure, n as SyncServerMessage, r as OfrepBulkResponse, t as FlagSnapshot } from "./src-CCPvsrli.mjs";
2
- import { Context, Deferred, Duration, Effect, Layer, Schema } from "effect";
1
+ import { a as OfrepSuccess, i as OfrepFailure, n as SyncServerMessage, r as OfrepBulkResponse, t as FlagSnapshot } from "./src-B_M-Oix8.mjs";
2
+ import { Context, Deferred, Duration, Effect, Layer, Schema, Semaphore } from "effect";
3
3
  import { readFile, watch } from "node:fs/promises";
4
4
  import { basename, dirname } from "node:path";
5
- import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
6
- import { Socket } from "effect/unstable/socket";
7
- //#region ../spec/src/index.ts
5
+ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
6
+ import { Socket } from "effect/socket";
7
+ // ../spec/src/index.ts
8
8
  /** True when `value` is assignable to a flag declared as `type`. */
9
9
  const valueMatchesType = (value, type) => {
10
10
  switch (type) {
@@ -56,8 +56,7 @@ const Resolution = {
56
56
  flagMetadata: args.flagMetadata ?? {}
57
57
  })
58
58
  };
59
- //#endregion
60
- //#region ../engine/src/hash.ts
59
+ // ../engine/src/hash.ts
61
60
  /**
62
61
  * MurmurHash3 (x86, 32-bit). A fast, well-distributed, non-cryptographic hash.
63
62
  * Used to deterministically bucket a subject into a percentage rollout slice so
@@ -115,8 +114,7 @@ const BUCKET_SCALE = 1e4;
115
114
  const bucketFor = (flagKey, ruleId, subject) => {
116
115
  return murmur3(`${flagKey}:${ruleId}:${subject}`) % BUCKET_SCALE;
117
116
  };
118
- //#endregion
119
- //#region ../engine/src/targeting.ts
117
+ // ../engine/src/targeting.ts
120
118
  const asNumber = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
121
119
  const asString = (v) => typeof v === "string" ? v : void 0;
122
120
  /** Evaluates a single condition against the context. */
@@ -234,8 +232,7 @@ const deepEqual = (a, b) => {
234
232
  }
235
233
  return false;
236
234
  };
237
- //#endregion
238
- //#region ../engine/src/evaluate.ts
235
+ // ../engine/src/evaluate.ts
239
236
  /**
240
237
  * Looks up a variant's value and returns a successful resolution, or an
241
238
  * abnormal `GENERAL` resolution if the variant is not declared (a
@@ -287,8 +284,65 @@ const evaluateFlag = (flag, ctx, isMemberOf) => {
287
284
  }
288
285
  return serveVariant(flag, flag.defaultVariant, "STATIC");
289
286
  };
290
- //#endregion
291
- //#region src/internal/core.ts
287
+ // src/internal/telemetry.ts
288
+ const startTelemetry = (http, options) => Effect.gen(function* () {
289
+ const instanceId = crypto.randomUUID();
290
+ const lock = yield* Semaphore.make(1);
291
+ const interval = options.telemetry === false ? 3e4 : options.telemetry?.flushIntervalMs ?? 3e4;
292
+ const timeout = Duration.millis(options.timeoutMs ?? 5e3);
293
+ let cells = /* @__PURE__ */ new Map();
294
+ let pending = [];
295
+ const flush = lock.withPermit(Effect.gen(function* () {
296
+ for (let pass = 0; pass < 2; pass++) {
297
+ if (pending.length === 0) {
298
+ const rollups = [...cells.values()];
299
+ cells = /* @__PURE__ */ new Map();
300
+ for (let i = 0; i < rollups.length; i += 1e3) pending.push({
301
+ sdkInstanceId: instanceId,
302
+ windowId: crypto.randomUUID(),
303
+ rollups: rollups.slice(i, i + 1e3)
304
+ });
305
+ }
306
+ while (pending.length > 0) {
307
+ if (!(yield* Effect.gen(function* () {
308
+ const response = yield* http.execute(HttpClientRequest.post("/v1/telemetry").pipe(HttpClientRequest.bodyJsonUnsafe(pending[0])));
309
+ return response.status >= 200 && response.status < 300;
310
+ }).pipe(Effect.timeout(timeout), Effect.catchCause(() => Effect.succeed(false))))) return;
311
+ pending.shift();
312
+ }
313
+ }
314
+ }));
315
+ yield* Effect.addFinalizer(() => flush.pipe(Effect.timeout(timeout), Effect.catchCause(() => Effect.void)));
316
+ yield* Effect.forkScoped(Effect.forever(Effect.sleep(Duration.millis(Number.isFinite(interval) && interval > 0 ? interval : 3e4)).pipe(Effect.andThen(flush))));
317
+ return {
318
+ flush,
319
+ record: (flagKey, resolution) => {
320
+ if (flagKey.length === 0 || flagKey.length > 256 || (resolution.variant?.length ?? 0) > 256) return;
321
+ const variant = resolution.variant ?? "";
322
+ const bucketStart = (/* @__PURE__ */ new Date(Math.floor(Date.now() / 6e4) * 6e4)).toISOString();
323
+ const key = JSON.stringify([
324
+ flagKey,
325
+ variant,
326
+ resolution.reason,
327
+ bucketStart
328
+ ]);
329
+ const cell = cells.get(key);
330
+ if (cell) {
331
+ if (cell.count < 1e9) cells.set(key, {
332
+ ...cell,
333
+ count: cell.count + 1
334
+ });
335
+ } else if (cells.size < 1e4) cells.set(key, {
336
+ flagKey,
337
+ variant,
338
+ reason: resolution.reason,
339
+ bucketStart,
340
+ count: 1
341
+ });
342
+ }
343
+ };
344
+ });
345
+ // src/internal/core.ts
292
346
  /**
293
347
  * The Effect core of the Toggld Node SDK.
294
348
  *
@@ -342,19 +396,19 @@ const errorDetails = (key, value, errorCode, errorMessage, flagMetadata = {}) =>
342
396
  reason: "ERROR",
343
397
  errorCode,
344
398
  errorMessage,
345
- flagMetadata
399
+ flagMetadata: { ...flagMetadata }
346
400
  });
347
401
  /** Map an engine {@link Resolution} to evaluation details, OpenFeature-style:
348
402
  * an abnormal resolution serves the **caller's** default, not the engine's
349
403
  * internal fallback — exactly what the remote wire does on a 400. */
350
404
  const resolutionDetails = (key, resolution, fallback) => resolution.errorCode !== void 0 ? errorDetails(key, fallback, resolution.errorCode, resolution.errorMessage ?? "", resolution.flagMetadata) : {
351
405
  flagKey: key,
352
- value: resolution.value,
406
+ value: typeof resolution.value === "object" && resolution.value !== null ? structuredClone(resolution.value) : resolution.value,
353
407
  variant: resolution.variant,
354
408
  reason: resolution.reason,
355
409
  errorCode: void 0,
356
410
  errorMessage: void 0,
357
- flagMetadata: resolution.flagMetadata
411
+ flagMetadata: { ...resolution.flagMetadata }
358
412
  };
359
413
  /** A readable message for an arbitrary thrown/failed value. */
360
414
  const describe = (e) => typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" ? e.message : String(e);
@@ -398,7 +452,7 @@ const toStreamUrl = (endpoint) => {
398
452
  url.pathname = url.pathname.replace(/\/$/, "") + "/sync/v1/stream";
399
453
  return url.toString();
400
454
  };
401
- const makeSnapshotStore = (mergedContext) => Effect.gen(function* () {
455
+ const makeSnapshotStore = (mergedContext, record) => Effect.gen(function* () {
402
456
  let current;
403
457
  let epoch = 0;
404
458
  const firstSnapshot = yield* Deferred.make();
@@ -429,14 +483,20 @@ const makeSnapshotStore = (mergedContext) => Effect.gen(function* () {
429
483
  const flag = state.byKey.get(key);
430
484
  if (flag === void 0) return errorDetails(key, defaultValue, "FLAG_NOT_FOUND", `Flag '${key}' not found`);
431
485
  const ctx = mergedContext(context);
432
- return resolutionDetails(key, evaluateFlag(flag, ctx, memberOf(state, ctx)), defaultValue);
486
+ const resolution = evaluateFlag(flag, ctx, memberOf(state, ctx));
487
+ record(key, resolution);
488
+ return resolutionDetails(key, resolution, defaultValue);
433
489
  });
434
490
  const evaluateAll = (context) => Effect.sync(() => {
435
491
  const state = current;
436
492
  if (state === void 0) return [];
437
493
  const ctx = mergedContext(context);
438
494
  const isMemberOf = memberOf(state, ctx);
439
- return state.flags.map((flag) => resolutionDetails(flag.key, evaluateFlag(flag, ctx, isMemberOf), null));
495
+ return state.flags.map((flag) => {
496
+ const resolution = evaluateFlag(flag, ctx, isMemberOf);
497
+ record(flag.key, resolution);
498
+ return resolutionDetails(flag.key, resolution, null);
499
+ });
440
500
  });
441
501
  return {
442
502
  adopt,
@@ -495,9 +555,14 @@ const startNetworkSource = (options, http, store, timeout) => Effect.gen(functio
495
555
  openTimeout: timeout
496
556
  });
497
557
  yield* Effect.forever(Effect.gen(function* () {
498
- yield* socket.runRaw(handleMessage, { onOpen: Effect.sync(() => {
558
+ yield* Effect.scoped(Effect.gen(function* () {
559
+ const pull = yield* Socket.readerString(socket);
499
560
  consecutiveFailures = 0;
500
- }) }).pipe(Effect.ignore);
561
+ while (true) {
562
+ const messages = yield* pull;
563
+ for (const message of messages) yield* handleMessage(message);
564
+ }
565
+ })).pipe(Effect.ignore);
501
566
  consecutiveFailures++;
502
567
  const base = Math.min(3e4, 1e3 * 2 ** Math.min(consecutiveFailures - 1, 5));
503
568
  yield* Effect.sleep(Duration.millis(base * (.5 + Math.random() * .5)));
@@ -517,8 +582,8 @@ const startFileSource = (path, store, options) => Effect.gen(function* () {
517
582
  const decodeSnapshot = Schema.decodeUnknownEffect(FlagSnapshot);
518
583
  let lastError;
519
584
  const readOnce = Effect.gen(function* () {
520
- const text = yield* Effect.tryPromise(() => readFile(path, "utf8"));
521
- const snapshot = yield* decodeSnapshot(JSON.parse(text));
585
+ const json = yield* Effect.tryPromise(async () => JSON.parse(await readFile(path, "utf8")));
586
+ const snapshot = yield* decodeSnapshot(json);
522
587
  lastError = void 0;
523
588
  if (store.version() === snapshot.version) return;
524
589
  yield* store.adopt(snapshot);
@@ -572,8 +637,8 @@ const startFileSource = (path, store, options) => Effect.gen(function* () {
572
637
  yield* Effect.sleep(Duration.millis(500));
573
638
  }
574
639
  });
640
+ yield* Effect.forkScoped(watchLoop, { startImmediately: true });
575
641
  yield* Effect.forkScoped(ticker);
576
- yield* Effect.forkScoped(watchLoop);
577
642
  });
578
643
  /**
579
644
  * Resolve a local flag-file path, or `undefined` for network sync. Highest
@@ -592,8 +657,8 @@ const resolveFilePath = (options) => {
592
657
  * Assemble the local-mode evaluation surface, choosing the file source when a
593
658
  * path resolves (env var or `sync.file`) and the network source otherwise.
594
659
  */
595
- const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function* () {
596
- const store = yield* makeSnapshotStore(mergedContext);
660
+ const makeLocal = (options, http, mergedContext, timeout, record) => Effect.gen(function* () {
661
+ const store = yield* makeSnapshotStore(mergedContext, record);
597
662
  const filePath = resolveFilePath(options);
598
663
  if (filePath !== void 0) yield* startFileSource(filePath, store, options);
599
664
  else yield* startNetworkSource(options, http, store, timeout);
@@ -607,7 +672,12 @@ const make = (options) => Effect.gen(function* () {
607
672
  ...options.defaultContext,
608
673
  ...context
609
674
  });
610
- const { evaluate, evaluateAll, ready } = options.mode === "local" || resolveFilePath(options) !== void 0 ? yield* makeLocal(options, http, mergedContext, timeout) : makeRemote(http, mergedContext, timeout);
675
+ const local = options.mode === "local" || resolveFilePath(options) !== void 0;
676
+ const telemetry = local && resolveFilePath(options) === void 0 && options.telemetry !== false ? yield* startTelemetry(http, options) : {
677
+ record: (_key, _resolution) => {},
678
+ flush: Effect.void
679
+ };
680
+ const { evaluate, evaluateAll, ready } = local ? yield* makeLocal(options, http, mergedContext, timeout, telemetry.record) : makeRemote(http, mergedContext, timeout);
611
681
  /** Resolve, then enforce the requested value type (else `TYPE_MISMATCH`). */
612
682
  const typed = (key, defaultValue, context, type, guard) => Effect.map(evaluate(key, defaultValue, context), (d) => {
613
683
  if (d.errorCode !== void 0) return d;
@@ -617,6 +687,7 @@ const make = (options) => Effect.gen(function* () {
617
687
  const valueOf = (details) => Effect.map(details, (d) => d.value);
618
688
  return {
619
689
  ready,
690
+ flushTelemetry: telemetry.flush,
620
691
  evaluate,
621
692
  evaluateAll,
622
693
  getBooleanDetails: (key, defaultValue, context) => typed(key, defaultValue, context, "boolean", isBoolean),
@@ -632,7 +703,7 @@ const make = (options) => Effect.gen(function* () {
632
703
  /** The HttpClient layer, optionally backed by a caller-supplied `fetch`. */
633
704
  const httpLayer = (fetchImpl) => fetchImpl ? FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchImpl))) : FetchHttpClient.layer;
634
705
  /** The WebSocket constructor for the sync stream, optionally caller-supplied. */
635
- const webSocketLayer = (ctor) => ctor ? Layer.succeed(Socket.WebSocketConstructor, (url, protocols) => new ctor(url, protocols)) : Socket.layerWebSocketConstructorGlobal;
706
+ const webSocketLayer = (ctor) => ctor ? Layer.succeed(Socket.WebSocketConstructor, (url, options) => new ctor(url, typeof options === "object" && !Array.isArray(options) ? options.protocols : options)) : Socket.layerWebSocketConstructorGlobal;
636
707
  /**
637
708
  * A self-contained `Layer` providing {@link ToggldClient} — it wires its own
638
709
  * HTTP and WebSocket backends, so Effect users need only provide this one
@@ -640,5 +711,4 @@ const webSocketLayer = (ctor) => ctor ? Layer.succeed(Socket.WebSocketConstructo
640
711
  * closing it (e.g. `ManagedRuntime.dispose`) stops sync and closes the stream.
641
712
  */
642
713
  const layer = (options) => Layer.effect(ToggldClient, make(options)).pipe(Layer.provide(httpLayer(options.fetch)), Layer.provide(webSocketLayer(options.sync?.webSocketConstructor)));
643
- //#endregion
644
714
  export { layer as n, ToggldClient as t };
package/dist/effect.d.mts CHANGED
@@ -1,7 +1,5 @@
1
- import { a as ErrorCode, c as FlagValue, i as ToggldClientOptions, l as Reason, n as EvaluationMode, o as EvaluationContext, r as SyncOptions, s as FlagMetadata, t as EvaluationDetails } from "./types-CPtRV5-5.mjs";
1
+ import { a as ErrorCode, c as FlagValue, i as ToggldClientOptions, l as Reason, n as EvaluationMode, o as EvaluationContext, r as SyncOptions, s as FlagMetadata, t as EvaluationDetails } from "./types-hdTQWEM5.mjs";
2
2
  import { Context, Effect, Layer } from "effect";
3
-
4
- //#region src/internal/core.d.ts
5
3
  /** The evaluation surface of a Toggld client. Every method is non-failing. */
6
4
  interface ToggldClientShape {
7
5
  /**
@@ -10,6 +8,8 @@ interface ToggldClientShape {
10
8
  * resolve to the caller's default with `PROVIDER_NOT_READY`.
11
9
  */
12
10
  readonly ready: Effect.Effect<void>;
11
+ /** Send buffered telemetry. Failed windows remain queued for retry. */
12
+ readonly flushTelemetry: Effect.Effect<void>;
13
13
  /** Resolve a flag, returning full details with an untyped (JSON) value. */
14
14
  readonly evaluate: (key: string, defaultValue: FlagValue, context?: EvaluationContext) => Effect.Effect<EvaluationDetails<FlagValue>>;
15
15
  /** Resolve every flag for the environment (one request in remote mode; in-process in local mode). */
@@ -28,13 +28,12 @@ declare const ToggldClient_base: Context.ServiceClass<ToggldClient, "@toggld/nod
28
28
  * The Toggld client as an Effect service. Build a {@link layer} from options and
29
29
  * compose it into your runtime, then `yield* ToggldClient` to evaluate flags.
30
30
  */
31
- declare class ToggldClient extends ToggldClient_base {}
31
+ export declare class ToggldClient extends ToggldClient_base {}
32
32
  /**
33
33
  * A self-contained `Layer` providing {@link ToggldClient} — it wires its own
34
34
  * HTTP and WebSocket backends, so Effect users need only provide this one
35
35
  * layer. In local mode the background sync fibers live in the layer's scope:
36
36
  * closing it (e.g. `ManagedRuntime.dispose`) stops sync and closes the stream.
37
37
  */
38
- declare const layer: (options: ToggldClientOptions) => Layer.Layer<ToggldClient>;
39
- //#endregion
40
- export { type ErrorCode, type EvaluationContext, type EvaluationDetails, type EvaluationMode, type FlagMetadata, type FlagValue, type Reason, type SyncOptions, ToggldClient, type ToggldClientOptions, type ToggldClientShape, layer };
38
+ export declare const layer: (options: ToggldClientOptions) => Layer.Layer<ToggldClient>;
39
+ export type { ErrorCode, EvaluationContext, EvaluationDetails, EvaluationMode, FlagMetadata, FlagValue, Reason, SyncOptions, ToggldClientOptions, ToggldClientShape };
package/dist/effect.mjs CHANGED
@@ -1,2 +1 @@
1
- import { n as layer, t as ToggldClient } from "./core-DBVGNtur.mjs";
2
- export { ToggldClient, layer };
1
+ export { t as ToggldClient, n as layer } from "./core-CezJWPV4.mjs";
package/dist/index.d.mts CHANGED
@@ -1,12 +1,10 @@
1
- import { a as ErrorCode, c as FlagValue, i as ToggldClientOptions, l as Reason, n as EvaluationMode, o as EvaluationContext, r as SyncOptions, s as FlagMetadata, t as EvaluationDetails } from "./types-CPtRV5-5.mjs";
2
-
3
- //#region src/index.d.ts
1
+ import { a as ErrorCode, c as FlagValue, i as ToggldClientOptions, l as Reason, n as EvaluationMode, o as EvaluationContext, r as SyncOptions, s as FlagMetadata, t as EvaluationDetails } from "./types-hdTQWEM5.mjs";
4
2
  /**
5
3
  * A Toggld client backed by a private Effect runtime. Create one with {@link
6
4
  * ToggldClient.create} and {@link ToggldClient.close} it on shutdown to release
7
5
  * the runtime.
8
6
  */
9
- declare class ToggldClient {
7
+ export declare class ToggldClient {
10
8
  #private;
11
9
  private constructor();
12
10
  /**
@@ -22,6 +20,8 @@ declare class ToggldClient {
22
20
  * want to await sync explicitly.
23
21
  */
24
22
  ready(): Promise<void>;
23
+ /** Send buffered counts without failing evaluations. Failed windows remain queued. */
24
+ flushTelemetry(): Promise<void>;
25
25
  /** Resolve a flag, returning full details with an untyped (JSON) value. */
26
26
  evaluate(key: string, defaultValue: FlagValue, context?: EvaluationContext): Promise<EvaluationDetails<FlagValue>>;
27
27
  /** Resolve every flag for the environment in one request (OFREP bulk). */
@@ -37,5 +37,4 @@ declare class ToggldClient {
37
37
  /** Dispose the underlying runtime. The client is unusable afterwards. */
38
38
  close(): Promise<void>;
39
39
  }
40
- //#endregion
41
- export { type ErrorCode, type EvaluationContext, type EvaluationDetails, type EvaluationMode, type FlagMetadata, type FlagValue, type Reason, type SyncOptions, ToggldClient, type ToggldClientOptions };
40
+ export type { ErrorCode, EvaluationContext, EvaluationDetails, EvaluationMode, FlagMetadata, FlagValue, Reason, SyncOptions, ToggldClientOptions };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { n as layer, t as ToggldClient$1 } from "./core-DBVGNtur.mjs";
1
+ import { n as layer, t as ToggldClient$1 } from "./core-CezJWPV4.mjs";
2
2
  import { Duration, Effect, ManagedRuntime } from "effect";
3
- //#region src/index.ts
3
+ // src/index.ts
4
4
  /**
5
5
  * `@toggld/node` — the default entry: a plain Promise API.
6
6
  *
@@ -56,6 +56,10 @@ var ToggldClient = class ToggldClient {
56
56
  ready() {
57
57
  return this.#runtime.runPromise(this.#client.ready);
58
58
  }
59
+ /** Send buffered counts without failing evaluations. Failed windows remain queued. */
60
+ flushTelemetry() {
61
+ return this.#runtime.runPromise(this.#client.flushTelemetry);
62
+ }
59
63
  /** Resolve a flag, returning full details with an untyped (JSON) value. */
60
64
  evaluate(key, defaultValue, context) {
61
65
  return this.#runtime.runPromise(this.#client.evaluate(key, defaultValue, context));
@@ -93,5 +97,4 @@ var ToggldClient = class ToggldClient {
93
97
  return this.#runtime.dispose();
94
98
  }
95
99
  };
96
- //#endregion
97
100
  export { ToggldClient };
@@ -1,5 +1,5 @@
1
1
  import { Schema } from "effect";
2
- //#region ../schema/src/audit.ts
2
+ // ../schema/src/audit.ts
3
3
  /**
4
4
  * Audit log wire contract (ADR-0009). Every management/control-plane mutation is
5
5
  * recorded as one append-only {@link AuditEvent}; a tenant reads its own trail
@@ -27,12 +27,14 @@ const AuditAction = Schema.Literals([
27
27
  "segment.create",
28
28
  "segment.update",
29
29
  "segment.delete",
30
+ "environment.update",
30
31
  "environment.create",
31
32
  "environment.delete",
32
33
  "apiKey.create",
33
34
  "apiKey.revoke",
34
35
  "tenant.create",
35
- "tenant.update"
36
+ "tenant.update",
37
+ "tenant.delete"
36
38
  ]);
37
39
  /** The kind of resource an event concerns; pairs with `resourceId`. */
38
40
  const AuditResourceType = Schema.Literals([
@@ -60,8 +62,7 @@ Schema.Struct({
60
62
  /** ISO 8601 timestamp the change was recorded. */
61
63
  occurredAt: Schema.String
62
64
  });
63
- //#endregion
64
- //#region ../schema/src/auth.ts
65
+ // ../schema/src/auth.ts
65
66
  /**
66
67
  * Authorization role carried by an API key.
67
68
  *
@@ -73,6 +74,7 @@ Schema.Struct({
73
74
  id: Schema.String,
74
75
  name: Schema.String,
75
76
  createdAt: Schema.String,
77
+ defaultEnvironmentId: Schema.optional(Schema.NullOr(Schema.String)),
76
78
  /**
77
79
  * Max number of flag *definitions* the tenant may hold; `null` = unlimited.
78
80
  * The free tier is 10 (the DB column default). The flag API treats this as an
@@ -84,16 +86,26 @@ Schema.Struct({
84
86
  Schema.Struct({
85
87
  id: Schema.String,
86
88
  tenantId: Schema.String,
87
- /** Stable slug, unique within the tenant (e.g. `production`). */
89
+ isProduction: Schema.optional(Schema.Boolean),
90
+ isDefault: Schema.optional(Schema.Boolean),
91
+ /** Key, unique within the tenant (e.g. `production`). */
88
92
  key: Schema.String,
89
93
  name: Schema.String,
90
94
  createdAt: Schema.String
91
95
  });
92
96
  /** Body for creating an environment (POST /admin/v1/tenants/:tenantId/environments). */
93
97
  const CreateEnvironmentInput = Schema.Struct({
98
+ isProduction: Schema.optional(Schema.Boolean),
99
+ makeDefault: Schema.optional(Schema.Boolean),
94
100
  key: Schema.String.check(Schema.isMinLength(1)),
95
101
  name: Schema.String.check(Schema.isMinLength(1))
96
102
  });
103
+ Schema.Struct({
104
+ key: Schema.optional(Schema.String.check(Schema.isMinLength(1))),
105
+ name: Schema.optional(Schema.String.check(Schema.isMinLength(1))),
106
+ isProduction: Schema.optional(Schema.Boolean),
107
+ makeDefault: Schema.optional(Schema.Boolean)
108
+ });
97
109
  Schema.Struct({
98
110
  id: Schema.String,
99
111
  tenantId: Schema.String,
@@ -137,8 +149,7 @@ Schema.Struct({
137
149
  name: Schema.optionalKey(Schema.String),
138
150
  environment: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1)))
139
151
  });
140
- //#endregion
141
- //#region ../schema/src/value.ts
152
+ // ../schema/src/value.ts
142
153
  /**
143
154
  * The OpenFeature flag value types. Every flag declares one of these; variant
144
155
  * values and resolved values are validated against it.
@@ -168,31 +179,39 @@ const MetadataValue = Schema.Union([
168
179
  Schema.Boolean
169
180
  ]);
170
181
  const FlagMetadata = Schema.Record(Schema.String, MetadataValue);
171
- //#endregion
172
- //#region ../schema/src/context.ts
182
+ // ../schema/src/context.ts
173
183
  /**
174
184
  * An OpenFeature evaluation context: an optional `targetingKey` "identifying
175
185
  * the subject of the flag evaluation" (Requirement 3.1.1) plus arbitrary
176
186
  * custom fields (Requirement 3.1.2). We accept any JSON-valued attributes.
177
187
  */
178
188
  const EvaluationContext = Schema.Record(Schema.String, FlagValue);
179
- Schema.ErrorClass("FlagNotFound")({
189
+ Schema.Error("EnvironmentConflict")({
190
+ _tag: Schema.tag("EnvironmentConflict"),
191
+ message: Schema.String
192
+ });
193
+ Schema.Error("FlagNotFound")({
180
194
  _tag: Schema.tag("FlagNotFound"),
181
195
  flagKey: Schema.String,
182
196
  message: Schema.String
183
197
  });
184
- Schema.ErrorClass("FlagAlreadyExists")({
198
+ Schema.Error("FlagConflict")({
199
+ _tag: Schema.tag("FlagConflict"),
200
+ flagKey: Schema.String,
201
+ message: Schema.String
202
+ });
203
+ Schema.Error("FlagAlreadyExists")({
185
204
  _tag: Schema.tag("FlagAlreadyExists"),
186
205
  flagKey: Schema.String,
187
206
  message: Schema.String
188
207
  });
189
- Schema.ErrorClass("FlagValidationError")({
208
+ Schema.Error("FlagValidationError")({
190
209
  _tag: Schema.tag("FlagValidationError"),
191
210
  flagKey: Schema.String,
192
211
  issues: Schema.Array(Schema.String),
193
212
  message: Schema.String
194
213
  });
195
- Schema.ErrorClass("FlagLimitExceeded")({
214
+ Schema.Error("FlagLimitExceeded")({
196
215
  _tag: Schema.tag("FlagLimitExceeded"),
197
216
  /** The tenant's current flag cap. */
198
217
  limit: Schema.Int,
@@ -200,64 +219,73 @@ Schema.ErrorClass("FlagLimitExceeded")({
200
219
  current: Schema.Int,
201
220
  message: Schema.String
202
221
  });
203
- Schema.ErrorClass("Unauthorized")({
222
+ Schema.Error("Unauthorized")({
204
223
  _tag: Schema.tag("Unauthorized"),
205
224
  message: Schema.String
206
225
  });
207
- Schema.ErrorClass("Forbidden")({
226
+ Schema.Error("Forbidden")({
208
227
  _tag: Schema.tag("Forbidden"),
209
228
  message: Schema.String
210
229
  });
211
- Schema.ErrorClass("TenantNotFound")({
230
+ Schema.Error("TenantNotFound")({
212
231
  _tag: Schema.tag("TenantNotFound"),
213
232
  tenantId: Schema.String,
214
233
  message: Schema.String
215
234
  });
216
- Schema.ErrorClass("TenantAlreadyExists")({
235
+ Schema.Error("TenantNotEmpty")({
236
+ _tag: Schema.tag("TenantNotEmpty"),
237
+ tenantId: Schema.String,
238
+ message: Schema.String
239
+ });
240
+ Schema.Error("TenantAlreadyExists")({
217
241
  _tag: Schema.tag("TenantAlreadyExists"),
218
242
  tenantId: Schema.String,
219
243
  message: Schema.String
220
244
  });
221
- Schema.ErrorClass("EnvironmentNotFound")({
245
+ Schema.Error("EnvironmentNotFound")({
222
246
  _tag: Schema.tag("EnvironmentNotFound"),
223
247
  environment: Schema.String,
224
248
  message: Schema.String
225
249
  });
226
- Schema.ErrorClass("EnvironmentAlreadyExists")({
250
+ Schema.Error("EnvironmentAlreadyExists")({
227
251
  _tag: Schema.tag("EnvironmentAlreadyExists"),
228
252
  environment: Schema.String,
229
253
  message: Schema.String
230
254
  });
231
- Schema.ErrorClass("ApiKeyNotFound")({
255
+ Schema.Error("ApiKeyNotFound")({
232
256
  _tag: Schema.tag("ApiKeyNotFound"),
233
257
  keyId: Schema.String,
234
258
  message: Schema.String
235
259
  });
236
- Schema.ErrorClass("SegmentNotFound")({
260
+ Schema.Error("SegmentNotFound")({
237
261
  _tag: Schema.tag("SegmentNotFound"),
238
262
  segmentKey: Schema.String,
239
263
  message: Schema.String
240
264
  });
241
- Schema.ErrorClass("SegmentAlreadyExists")({
265
+ Schema.Error("SegmentAlreadyExists")({
242
266
  _tag: Schema.tag("SegmentAlreadyExists"),
243
267
  segmentKey: Schema.String,
244
268
  message: Schema.String
245
269
  });
246
- Schema.ErrorClass("SegmentValidationError")({
270
+ Schema.Error("SegmentConflict")({
271
+ _tag: Schema.tag("SegmentConflict"),
272
+ segmentKey: Schema.String,
273
+ message: Schema.String
274
+ });
275
+ Schema.Error("SegmentValidationError")({
247
276
  _tag: Schema.tag("SegmentValidationError"),
248
277
  segmentKey: Schema.String,
249
278
  issues: Schema.Array(Schema.String),
250
279
  message: Schema.String
251
280
  });
252
- Schema.ErrorClass("SegmentReferenced")({
281
+ Schema.Error("SegmentReferenced")({
253
282
  _tag: Schema.tag("SegmentReferenced"),
254
283
  segmentKey: Schema.String,
255
284
  /** Keys of the flags whose targeting still references the segment. */
256
285
  flagKeys: Schema.Array(Schema.String),
257
286
  message: Schema.String
258
287
  });
259
- //#endregion
260
- //#region ../schema/src/flag.ts
288
+ // ../schema/src/flag.ts
261
289
  /**
262
290
  * Operators usable in a targeting condition. Comparisons coerce sensibly:
263
291
  * numeric operators require numbers, string operators require strings, and
@@ -389,10 +417,10 @@ const Segment = Schema.Struct({
389
417
  const Tags = Schema.Array(Schema.String.check(Schema.isMinLength(1)));
390
418
  Schema.Struct({
391
419
  ...Flag.fields,
392
- tags: Tags
420
+ tags: Tags,
421
+ version: Schema.String
393
422
  });
394
- //#endregion
395
- //#region ../schema/src/resolution.ts
423
+ // ../schema/src/resolution.ts
396
424
  /**
397
425
  * Resolution reasons. The provider "SHOULD populate the resolution details
398
426
  * structure's reason field with STATIC, DEFAULT, TARGETING_MATCH, SPLIT,
@@ -445,12 +473,12 @@ const OfrepBulkResponse = Schema.Struct({
445
473
  flags: Schema.Array(Schema.Union([OfrepSuccess, OfrepFailure])),
446
474
  metadata: Schema.optionalKey(FlagMetadata)
447
475
  });
448
- Schema.ErrorClass("OfrepFlagNotFound")({
476
+ Schema.Error("OfrepFlagNotFound")({
449
477
  key: Schema.String,
450
478
  errorCode: Schema.Literal("FLAG_NOT_FOUND"),
451
479
  errorDetails: Schema.optionalKey(Schema.String)
452
480
  });
453
- Schema.ErrorClass("OfrepEvaluationError")({
481
+ Schema.Error("OfrepEvaluationError")({
454
482
  key: Schema.String,
455
483
  errorCode: ErrorCode,
456
484
  errorDetails: Schema.optionalKey(Schema.String)
@@ -470,6 +498,7 @@ Schema.Struct({
470
498
  tags: Schema.optionalKey(Tags)
471
499
  });
472
500
  Schema.Struct({
501
+ expectedVersion: Schema.optionalKey(Schema.String),
473
502
  name: Schema.optionalKey(Schema.String),
474
503
  description: Schema.optionalKey(Schema.String),
475
504
  type: FlagType,
@@ -482,6 +511,7 @@ Schema.Struct({
482
511
  tags: Schema.optionalKey(Tags)
483
512
  });
484
513
  Schema.Struct({
514
+ expectedVersion: Schema.optionalKey(Schema.String),
485
515
  name: Schema.optionalKey(Schema.String),
486
516
  description: Schema.optionalKey(Schema.String),
487
517
  type: Schema.optionalKey(FlagType),
@@ -517,6 +547,8 @@ Schema.Struct({
517
547
  rules: Schema.optionalKey(Schema.Array(SegmentRule))
518
548
  });
519
549
  Schema.Struct({
550
+ /** Reject a stale definition draft when supplied. */
551
+ expectedVersion: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
520
552
  name: Schema.optionalKey(Schema.String),
521
553
  description: Schema.optionalKey(Schema.String),
522
554
  keyAttribute: Schema.optionalKey(Schema.String),
@@ -536,8 +568,7 @@ Schema.Struct({
536
568
  /** Total members matching the (optional) query — the count line in the UI. */
537
569
  total: Schema.Number
538
570
  });
539
- //#endregion
540
- //#region ../schema/src/sync.ts
571
+ // ../schema/src/sync.ts
541
572
  /**
542
573
  * Definition-sync wire schemas (ADR-0001 §4): the versioned full snapshot the
543
574
  * sync surface serves to locally-evaluating SDKs, and the messages exchanged on
@@ -567,5 +598,41 @@ const SyncSnapshotMessage = Schema.Struct({
567
598
  */
568
599
  const SyncServerMessage = Schema.Union([SyncSnapshotMessage]);
569
600
  Schema.Struct({ type: Schema.Literal("resync") });
570
- //#endregion
601
+ // ../schema/src/telemetry.ts
602
+ const Identifier = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256));
603
+ const EvaluationRollup = Schema.Struct({
604
+ flagKey: Identifier,
605
+ variant: Schema.String.check(Schema.isMaxLength(256)),
606
+ reason: Reason,
607
+ bucketStart: Schema.String.check(Schema.makeFilter((s) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00\.000Z$/.test(s) && Number.isFinite(Date.parse(s)) && new Date(s).toISOString() === s)),
608
+ count: Schema.Number.check(Schema.isInt(), Schema.isBetween({
609
+ minimum: 1,
610
+ maximum: 1e9
611
+ }))
612
+ });
613
+ Schema.Struct({
614
+ sdkInstanceId: Identifier,
615
+ windowId: Identifier,
616
+ rollups: Schema.Array(EvaluationRollup).check(Schema.isMinLength(1), Schema.isMaxLength(1e3))
617
+ });
618
+ Schema.Struct({
619
+ range: Schema.Literals([
620
+ "1h",
621
+ "24h",
622
+ "7d"
623
+ ]),
624
+ from: Schema.String,
625
+ to: Schema.String,
626
+ total: Schema.Number,
627
+ errors: Schema.Number,
628
+ lastEvaluatedAt: Schema.NullOr(Schema.String),
629
+ timeline: Schema.Array(Schema.Struct({
630
+ bucketStart: Schema.String,
631
+ count: Schema.Number
632
+ })),
633
+ variants: Schema.Array(Schema.Struct({
634
+ variant: Schema.String,
635
+ count: Schema.Number
636
+ }))
637
+ });
571
638
  export { OfrepSuccess as a, OfrepFailure as i, SyncServerMessage as n, OfrepBulkResponse as r, FlagSnapshot as t };
@@ -1,4 +1,3 @@
1
- //#region ../spec/src/index.d.ts
2
1
  /**
3
2
  * A resolved flag value. OpenFeature permits `boolean | string | number |
4
3
  * structure`; we model the union as JSON, which also covers arrays and null for
@@ -32,8 +31,6 @@ type Reason = "STATIC" | "DEFAULT" | "TARGETING_MATCH" | "SPLIT" | "CACHED" | "D
32
31
  * @see https://openfeature.dev/specification/types
33
32
  */
34
33
  type ErrorCode = "PROVIDER_NOT_READY" | "PROVIDER_FATAL" | "FLAG_NOT_FOUND" | "PARSE_ERROR" | "TYPE_MISMATCH" | "TARGETING_KEY_MISSING" | "INVALID_CONTEXT" | "GENERAL";
35
- //#endregion
36
- //#region src/internal/types.d.ts
37
34
  /**
38
35
  * How the client resolves flags.
39
36
  *
@@ -59,9 +56,12 @@ interface SyncOptions {
59
56
  */
60
57
  readonly transport?: "ws" | "poll";
61
58
  /**
62
- * Interval between snapshot polls in milliseconds. Default `30000`. Until
63
- * the first snapshot arrives, the client retries faster (exponential from
64
- * 250 ms) so cold starts aren't gated on this interval.
59
+ * Delay after each snapshot poll, in milliseconds. Default `30000`.
60
+ * Polling stays active while the stream is healthy to recover changes from
61
+ * other Node instances and dropped announcements. With successful reads,
62
+ * allow this delay plus two request timeouts and scheduling delays.
63
+ * Use a positive, finite value. Until the first snapshot arrives, the client
64
+ * retries faster (exponential from 250 ms).
65
65
  */
66
66
  readonly pollIntervalMs?: number;
67
67
  /**
@@ -126,6 +126,10 @@ interface ToggldClientOptions {
126
126
  readonly mode?: EvaluationMode;
127
127
  /** Background sync tuning for `mode: "local"`. Ignored in remote mode. */
128
128
  readonly sync?: SyncOptions;
129
+ /** Local network evaluations report grouped counts by default. Set false to disable. */
130
+ readonly telemetry?: false | {
131
+ readonly flushIntervalMs?: number;
132
+ };
129
133
  /**
130
134
  * Attributes merged into the evaluation context of **every** request, under
131
135
  * any per-call context (per-call attributes win on key collision). Handy for
@@ -159,5 +163,4 @@ interface EvaluationDetails<T extends FlagValue> {
159
163
  readonly errorMessage: string | undefined;
160
164
  readonly flagMetadata: FlagMetadata;
161
165
  }
162
- //#endregion
163
166
  export { ErrorCode as a, FlagValue as c, ToggldClientOptions as i, Reason as l, EvaluationMode as n, EvaluationContext as o, SyncOptions as r, FlagMetadata as s, EvaluationDetails as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toggld/node",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Toggld SDK for Node.js — local flag evaluation with a background definition sync. Plain Promise API by default; native Effect API via @toggld/node/effect.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://toggld.io",
@@ -40,11 +40,11 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "effect": "4.0.0-beta.78"
43
+ "effect": "4.0.6"
44
44
  },
45
45
  "devDependencies": {
46
- "tsdown": "^0.22.2",
47
- "typescript": "5.8.3",
46
+ "tsdown": "^0.23.2",
47
+ "typescript": "6.0.3",
48
48
  "unrun": "^0.3.1",
49
49
  "@toggld/engine": "0.1.0",
50
50
  "@toggld/schema": "0.1.0",