@toggld/node 0.2.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.
@@ -1,7 +1,10 @@
1
- import { Context, Deferred, Duration, Effect, Layer, Schema } from "effect";
2
- import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
3
- import { Socket } from "effect/unstable/socket";
4
- //#region ../spec/src/index.ts
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
+ import { readFile, watch } from "node:fs/promises";
4
+ import { basename, dirname } from "node:path";
5
+ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
6
+ import { Socket } from "effect/socket";
7
+ // ../spec/src/index.ts
5
8
  /** True when `value` is assignable to a flag declared as `type`. */
6
9
  const valueMatchesType = (value, type) => {
7
10
  switch (type) {
@@ -53,8 +56,7 @@ const Resolution = {
53
56
  flagMetadata: args.flagMetadata ?? {}
54
57
  })
55
58
  };
56
- //#endregion
57
- //#region ../engine/src/hash.ts
59
+ // ../engine/src/hash.ts
58
60
  /**
59
61
  * MurmurHash3 (x86, 32-bit). A fast, well-distributed, non-cryptographic hash.
60
62
  * Used to deterministically bucket a subject into a percentage rollout slice so
@@ -112,8 +114,7 @@ const BUCKET_SCALE = 1e4;
112
114
  const bucketFor = (flagKey, ruleId, subject) => {
113
115
  return murmur3(`${flagKey}:${ruleId}:${subject}`) % BUCKET_SCALE;
114
116
  };
115
- //#endregion
116
- //#region ../engine/src/targeting.ts
117
+ // ../engine/src/targeting.ts
117
118
  const asNumber = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
118
119
  const asString = (v) => typeof v === "string" ? v : void 0;
119
120
  /** Evaluates a single condition against the context. */
@@ -149,8 +150,37 @@ const matchCondition = (condition, ctx) => {
149
150
  }
150
151
  }
151
152
  };
152
- /** A rule matches when every one of its conditions matches (logical AND). */
153
- const matchRule = (rule, ctx) => rule.conditions.every((c) => matchCondition(c, ctx));
153
+ /**
154
+ * A rule matches when every one of its conditions matches **and** every
155
+ * referenced segment resolves (member XOR `negate`) through `isMemberOf`
156
+ * (logical AND throughout). A rule with no segment references behaves exactly as
157
+ * conditions-only. `isMemberOf` is consulted only when the rule has segments;
158
+ * when absent, any segment reference resolves `false` (closed).
159
+ */
160
+ const matchRule = (rule, ctx, isMemberOf) => {
161
+ if (!rule.conditions.every((c) => matchCondition(c, ctx))) return false;
162
+ if (rule.segments === void 0 || rule.segments.length === 0) return true;
163
+ return rule.segments.every((ref) => {
164
+ const member = isMemberOf?.(ref.segment) ?? false;
165
+ return ref.negate ? !member : member;
166
+ });
167
+ };
168
+ /**
169
+ * Resolves segment membership for a subject from a synced segment *definition*
170
+ * (ADR-0006) — the local path for rule-based and `inline`-list segments. Pure
171
+ * and isomorphic: the server and the Node SDK build their `isMemberOf` over
172
+ * this. Membership is the union of a rule match (OR of rules, each an AND of
173
+ * conditions) and presence in the inline member list (keyed by `keyAttribute`).
174
+ * `remote` segments carry no `members` here and must be resolved out of band.
175
+ */
176
+ const matchSegment = (segment, ctx) => {
177
+ if (segment.rules.some((r) => r.conditions.every((c) => matchCondition(c, ctx)))) return true;
178
+ if (segment.members !== void 0 && segment.members.length > 0) {
179
+ const subject = attributeOf(ctx, segment.keyAttribute);
180
+ if (typeof subject === "string" && segment.members.includes(subject)) return true;
181
+ }
182
+ return false;
183
+ };
154
184
  /**
155
185
  * Selects a rollout variant for a subject. Weights are normalised across all
156
186
  * splits, and the subject's bucket (consistent for the flag/rule/subject) is
@@ -202,8 +232,7 @@ const deepEqual = (a, b) => {
202
232
  }
203
233
  return false;
204
234
  };
205
- //#endregion
206
- //#region ../engine/src/evaluate.ts
235
+ // ../engine/src/evaluate.ts
207
236
  /**
208
237
  * Looks up a variant's value and returns a successful resolution, or an
209
238
  * abnormal `GENERAL` resolution if the variant is not declared (a
@@ -233,11 +262,15 @@ const serveVariant = (flag, variant, reason) => {
233
262
  * - fixed variant → `TARGETING_MATCH`
234
263
  * - rollout → `SPLIT` (or `TARGETING_KEY_MISSING` if no subject)
235
264
  * 3. no rule matches → `defaultVariant` with reason `STATIC`
265
+ *
266
+ * `isMemberOf` resolves any {@link SegmentRef} a rule references (ADR-0006). It
267
+ * is optional: flags with no segment references never call it, and when it is
268
+ * omitted a segment reference resolves `false` (closed).
236
269
  */
237
- const evaluateFlag = (flag, ctx) => {
270
+ const evaluateFlag = (flag, ctx, isMemberOf) => {
238
271
  if (!flag.enabled) return serveVariant(flag, flag.offVariant ?? flag.defaultVariant, "DISABLED");
239
272
  for (const rule of flag.targeting) {
240
- if (!matchRule(rule, ctx)) continue;
273
+ if (!matchRule(rule, ctx, isMemberOf)) continue;
241
274
  if (rule.serve.type === "variant") return serveVariant(flag, rule.serve.variant, "TARGETING_MATCH");
242
275
  const outcome = selectRolloutVariant(flag.key, rule.id, rule.serve.splits, rule.serve.bucketBy, ctx);
243
276
  if (outcome._tag === "MissingSubject") return Resolution.error({
@@ -251,468 +284,65 @@ const evaluateFlag = (flag, ctx) => {
251
284
  }
252
285
  return serveVariant(flag, flag.defaultVariant, "STATIC");
253
286
  };
254
- //#endregion
255
- //#region ../schema/src/audit.ts
256
- /**
257
- * Audit log wire contract (ADR-0009). Every management/control-plane mutation is
258
- * recorded as one append-only {@link AuditEvent}; a tenant reads its own trail
259
- * over `GET /api/v1/audit-log`. Evaluation (OFREP) reads are deliberately *not*
260
- * audited — this is a record of configuration *changes*, not traffic.
261
- */
262
- /** Who performed the change. See the server's `Actor` for how each id is sourced. */
263
- const AuditActor = Schema.Struct({
264
- type: Schema.Literals([
265
- "apiKey",
266
- "user",
267
- "service"
268
- ]),
269
- id: Schema.String
270
- });
271
- /**
272
- * Closed vocabulary of audited actions — a closed set (not free text) so filters
273
- * and downstream exports stay stable as resources are added. `flag.update` covers
274
- * both replace (PUT) and patch (PATCH); the before/after states show what moved.
275
- */
276
- const AuditAction = Schema.Literals([
277
- "flag.create",
278
- "flag.update",
279
- "flag.delete",
280
- "environment.create",
281
- "environment.delete",
282
- "apiKey.create",
283
- "apiKey.revoke",
284
- "tenant.create",
285
- "tenant.update"
286
- ]);
287
- /** The kind of resource an event concerns; pairs with `resourceId`. */
288
- const AuditResourceType = Schema.Literals([
289
- "flag",
290
- "environment",
291
- "apiKey",
292
- "tenant"
293
- ]);
294
- Schema.Struct({
295
- id: Schema.String,
296
- /** Owning tenant; null only for control-plane events (tenant provisioning). */
297
- tenantId: Schema.NullOr(Schema.String),
298
- /** The environment touched; null for tenant-wide / definition-level / control-plane events. */
299
- environmentId: Schema.NullOr(Schema.String),
300
- actor: AuditActor,
301
- action: AuditAction,
302
- resourceType: AuditResourceType,
303
- /** Flag key, environment id, API-key id, or tenant id. */
304
- resourceId: Schema.String,
305
- before: Schema.NullOr(Schema.Unknown),
306
- after: Schema.NullOr(Schema.Unknown),
307
- /** Non-authoritative request context (request id, source ip, …); empty in v0. */
308
- metadata: Schema.Record(Schema.String, Schema.Unknown),
309
- /** ISO 8601 timestamp the change was recorded. */
310
- occurredAt: Schema.String
311
- });
312
- //#endregion
313
- //#region ../schema/src/auth.ts
314
- /**
315
- * Authorization role carried by an API key.
316
- *
317
- * - `management` — full CRUD on the tenant's flags, plus evaluation.
318
- * - `evaluation` — read-only evaluation (OFREP) of the tenant's flags.
319
- */
320
- const Role = Schema.Literals(["management", "evaluation"]);
321
- Schema.Struct({
322
- id: Schema.String,
323
- name: Schema.String,
324
- createdAt: Schema.String,
325
- /**
326
- * Max number of flag *definitions* the tenant may hold; `null` = unlimited.
327
- * The free tier is 10 (the DB column default). The flag API treats this as an
328
- * opaque number — the web app owns the plan→limit mapping and pushes changes
329
- * over the control plane (provision + PATCH). Enforced in `FlagService.create`.
330
- */
331
- flagLimit: Schema.NullOr(Schema.Int)
332
- });
333
- Schema.Struct({
334
- id: Schema.String,
335
- tenantId: Schema.String,
336
- /** Stable slug, unique within the tenant (e.g. `production`). */
337
- key: Schema.String,
338
- name: Schema.String,
339
- createdAt: Schema.String
340
- });
341
- /** Body for creating an environment (POST /admin/v1/tenants/:tenantId/environments). */
342
- const CreateEnvironmentInput = Schema.Struct({
343
- key: Schema.String.check(Schema.isMinLength(1)),
344
- name: Schema.String.check(Schema.isMinLength(1))
345
- });
346
- Schema.Struct({
347
- id: Schema.String,
348
- tenantId: Schema.String,
349
- /** The single environment this token is scoped to. */
350
- environmentId: Schema.String,
351
- /** The full token value — intentionally exposed; it is non-secret. */
352
- token: Schema.String,
353
- createdAt: Schema.String
354
- });
355
- /**
356
- * Public view of an API key — safe to list. Never includes the secret token or
357
- * its hash; only a non-secret prefix for human identification.
358
- */
359
- const ApiKeyInfo = Schema.Struct({
360
- id: Schema.String,
361
- tenantId: Schema.String,
362
- /** The single environment this key is scoped to. */
363
- environmentId: Schema.String,
364
- name: Schema.optionalKey(Schema.String),
365
- role: Role,
366
- prefix: Schema.String,
367
- createdAt: Schema.String
368
- });
369
- Schema.Struct({
370
- ...ApiKeyInfo.fields,
371
- token: Schema.String
372
- });
373
- Schema.Struct({
374
- id: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1))),
375
- name: Schema.String.check(Schema.isMinLength(1)),
376
- environments: Schema.optionalKey(Schema.Array(CreateEnvironmentInput)),
377
- /** Flag cap for the new tenant; omitted → the free-tier default (10). */
378
- flagLimit: Schema.optionalKey(Schema.Int)
379
- });
380
- Schema.Struct({
381
- name: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1))),
382
- flagLimit: Schema.optionalKey(Schema.NullOr(Schema.Int))
383
- });
384
- Schema.Struct({
385
- role: Role,
386
- name: Schema.optionalKey(Schema.String),
387
- environment: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1)))
388
- });
389
- //#endregion
390
- //#region ../schema/src/value.ts
391
- /**
392
- * The OpenFeature flag value types. Every flag declares one of these; variant
393
- * values and resolved values are validated against it.
394
- *
395
- * @see https://openfeature.dev/specification/types
396
- */
397
- const FlagType = Schema.Literals([
398
- "boolean",
399
- "string",
400
- "number",
401
- "object"
402
- ]);
403
- /**
404
- * A resolved flag value. OpenFeature permits `boolean | string | number |
405
- * structure`; we store the union as JSON, which also covers arrays and null
406
- * for "object" typed flags.
407
- */
408
- const FlagValue = Schema.Json;
409
- /**
410
- * Flag metadata: "a structure supporting the definition of arbitrary
411
- * properties, with keys of type string, and values of type boolean, string,
412
- * or number." (Requirement 2.2.10)
413
- */
414
- const MetadataValue = Schema.Union([
415
- Schema.String,
416
- Schema.Finite,
417
- Schema.Boolean
418
- ]);
419
- const FlagMetadata = Schema.Record(Schema.String, MetadataValue);
420
- //#endregion
421
- //#region ../schema/src/context.ts
422
- /**
423
- * An OpenFeature evaluation context: an optional `targetingKey` "identifying
424
- * the subject of the flag evaluation" (Requirement 3.1.1) plus arbitrary
425
- * custom fields (Requirement 3.1.2). We accept any JSON-valued attributes.
426
- */
427
- const EvaluationContext = Schema.Record(Schema.String, FlagValue);
428
- Schema.ErrorClass("FlagNotFound")({
429
- _tag: Schema.tag("FlagNotFound"),
430
- flagKey: Schema.String,
431
- message: Schema.String
432
- });
433
- Schema.ErrorClass("FlagAlreadyExists")({
434
- _tag: Schema.tag("FlagAlreadyExists"),
435
- flagKey: Schema.String,
436
- message: Schema.String
437
- });
438
- Schema.ErrorClass("FlagValidationError")({
439
- _tag: Schema.tag("FlagValidationError"),
440
- flagKey: Schema.String,
441
- issues: Schema.Array(Schema.String),
442
- message: Schema.String
443
- });
444
- Schema.ErrorClass("FlagLimitExceeded")({
445
- _tag: Schema.tag("FlagLimitExceeded"),
446
- /** The tenant's current flag cap. */
447
- limit: Schema.Int,
448
- /** How many flags the tenant has now (>= limit). */
449
- current: Schema.Int,
450
- message: Schema.String
451
- });
452
- Schema.ErrorClass("Unauthorized")({
453
- _tag: Schema.tag("Unauthorized"),
454
- message: Schema.String
455
- });
456
- Schema.ErrorClass("Forbidden")({
457
- _tag: Schema.tag("Forbidden"),
458
- message: Schema.String
459
- });
460
- Schema.ErrorClass("TenantNotFound")({
461
- _tag: Schema.tag("TenantNotFound"),
462
- tenantId: Schema.String,
463
- message: Schema.String
464
- });
465
- Schema.ErrorClass("TenantAlreadyExists")({
466
- _tag: Schema.tag("TenantAlreadyExists"),
467
- tenantId: Schema.String,
468
- message: Schema.String
469
- });
470
- Schema.ErrorClass("EnvironmentNotFound")({
471
- _tag: Schema.tag("EnvironmentNotFound"),
472
- environment: Schema.String,
473
- message: Schema.String
474
- });
475
- Schema.ErrorClass("EnvironmentAlreadyExists")({
476
- _tag: Schema.tag("EnvironmentAlreadyExists"),
477
- environment: Schema.String,
478
- message: Schema.String
479
- });
480
- Schema.ErrorClass("ApiKeyNotFound")({
481
- _tag: Schema.tag("ApiKeyNotFound"),
482
- keyId: Schema.String,
483
- message: Schema.String
484
- });
485
- //#endregion
486
- //#region ../schema/src/flag.ts
487
- /**
488
- * Operators usable in a targeting condition. Comparisons coerce sensibly:
489
- * numeric operators require numbers, string operators require strings, and
490
- * `in`/`notIn` test membership in an array literal.
491
- */
492
- const Operator = Schema.Literals([
493
- "eq",
494
- "ne",
495
- "in",
496
- "notIn",
497
- "lt",
498
- "lte",
499
- "gt",
500
- "gte",
501
- "contains",
502
- "startsWith",
503
- "endsWith",
504
- "exists",
505
- "notExists"
506
- ]);
507
- /**
508
- * A single targeting condition tested against an attribute of the evaluation
509
- * context (e.g. `targetingKey`, `email`, `plan`). All conditions in a rule are
510
- * ANDed together.
511
- */
512
- const Condition = Schema.Struct({
513
- attribute: Schema.String,
514
- operator: Operator,
515
- /** Comparison operand. Ignored for `exists`/`notExists`. */
516
- value: Schema.optionalKey(FlagValue)
517
- });
518
- /** A weighted slice of a percentage rollout. */
519
- const Split = Schema.Struct({
520
- variant: Schema.String,
521
- /** Relative weight; the engine normalises weights across all splits. */
522
- weight: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0))
523
- });
524
- /** Serve a single fixed variant when the rule matches → `TARGETING_MATCH`. */
525
- const ServeVariant = Schema.Struct({
526
- type: Schema.Literal("variant"),
527
- variant: Schema.String
528
- });
529
- /**
530
- * Serve a deterministic percentage rollout when the rule matches → `SPLIT`.
531
- * Bucketing is consistent for a given subject (`bucketBy`, default
532
- * `targetingKey`) so a subject always lands in the same slice.
533
- */
534
- const ServeRollout = Schema.Struct({
535
- type: Schema.Literal("rollout"),
536
- bucketBy: Schema.optionalKey(Schema.String),
537
- splits: Schema.Array(Split)
538
- });
539
- const Serve = Schema.Union([ServeVariant, ServeRollout]);
540
- /** An ordered targeting rule. The first rule whose conditions all match wins. */
541
- const Rule = Schema.Struct({
542
- id: Schema.String,
543
- description: Schema.optionalKey(Schema.String),
544
- /** ANDed conditions. An empty list always matches (useful as a catch-all). */
545
- conditions: Schema.Array(Condition),
546
- serve: Serve
547
- });
548
- /**
549
- * A feature flag definition.
550
- *
551
- * Evaluation order: if `enabled` is false → serve `offVariant` (default
552
- * `defaultVariant`) with reason `DISABLED`; otherwise walk `targeting` in
553
- * order and serve the first matching rule (`TARGETING_MATCH`/`SPLIT`); if no
554
- * rule matches, serve `defaultVariant` with reason `STATIC`.
555
- */
556
- const Flag = Schema.Struct({
557
- key: Schema.String,
558
- name: Schema.optionalKey(Schema.String),
559
- description: Schema.optionalKey(Schema.String),
560
- type: FlagType,
561
- enabled: Schema.Boolean,
562
- /** Named values; every served variant name must be a key here. */
563
- variants: Schema.Record(Schema.String, FlagValue),
564
- /** Variant served on a normal evaluation with no targeting match. */
565
- defaultVariant: Schema.String,
566
- /** Variant served when the flag is disabled. Defaults to `defaultVariant`. */
567
- offVariant: Schema.optionalKey(Schema.String),
568
- targeting: Schema.Array(Rule),
569
- metadata: FlagMetadata,
570
- createdAt: Schema.String,
571
- updatedAt: Schema.String
572
- });
573
- /**
574
- * Freeform organization labels for a flag (ADR-0008). A flag may carry any
575
- * number; they group/filter flags in the dashboard and have **no** effect on
576
- * evaluation. Deliberately separate from the spec `Flag` (and `metadata`) so
577
- * they never reach the OFREP wire or the engine.
578
- */
579
- const Tags = Schema.Array(Schema.String.check(Schema.isMinLength(1)));
580
- Schema.Struct({
581
- ...Flag.fields,
582
- tags: Tags
583
- });
584
- //#endregion
585
- //#region ../schema/src/resolution.ts
586
- /**
587
- * Resolution reasons. The provider "SHOULD populate the resolution details
588
- * structure's reason field with STATIC, DEFAULT, TARGETING_MATCH, SPLIT,
589
- * CACHED, DISABLED, UNKNOWN, STALE, ERROR or some other string."
590
- * (Requirement 2.2.5)
591
- */
592
- const Reason = Schema.Literals([
593
- "STATIC",
594
- "DEFAULT",
595
- "TARGETING_MATCH",
596
- "SPLIT",
597
- "CACHED",
598
- "DISABLED",
599
- "UNKNOWN",
600
- "STALE",
601
- "ERROR"
602
- ]);
603
- /**
604
- * OpenFeature error codes.
605
- * @see https://openfeature.dev/specification/types
606
- */
607
- const ErrorCode = Schema.Literals([
608
- "PROVIDER_NOT_READY",
609
- "PROVIDER_FATAL",
610
- "FLAG_NOT_FOUND",
611
- "PARSE_ERROR",
612
- "TYPE_MISMATCH",
613
- "TARGETING_KEY_MISSING",
614
- "INVALID_CONTEXT",
615
- "GENERAL"
616
- ]);
617
- Schema.Struct({ context: Schema.optionalKey(EvaluationContext) });
618
- /** A successful single/bulk evaluation result. */
619
- const OfrepSuccess = Schema.Struct({
620
- key: Schema.String,
621
- value: FlagValue,
622
- reason: Reason,
623
- variant: Schema.optionalKey(Schema.String),
624
- metadata: Schema.optionalKey(FlagMetadata)
625
- });
626
- /** A per-flag failure embedded in a bulk response. */
627
- const OfrepFailure = Schema.Struct({
628
- key: Schema.String,
629
- errorCode: ErrorCode,
630
- errorDetails: Schema.optionalKey(Schema.String),
631
- metadata: Schema.optionalKey(FlagMetadata)
632
- });
633
- /** Bulk evaluation response: a mixed list of successes and failures. */
634
- const OfrepBulkResponse = Schema.Struct({
635
- flags: Schema.Array(Schema.Union([OfrepSuccess, OfrepFailure])),
636
- metadata: Schema.optionalKey(FlagMetadata)
637
- });
638
- Schema.ErrorClass("OfrepFlagNotFound")({
639
- key: Schema.String,
640
- errorCode: Schema.Literal("FLAG_NOT_FOUND"),
641
- errorDetails: Schema.optionalKey(Schema.String)
642
- });
643
- Schema.ErrorClass("OfrepEvaluationError")({
644
- key: Schema.String,
645
- errorCode: ErrorCode,
646
- errorDetails: Schema.optionalKey(Schema.String)
647
- });
648
- Schema.Struct({
649
- key: Schema.String.check(Schema.isMinLength(1)),
650
- name: Schema.optionalKey(Schema.String),
651
- description: Schema.optionalKey(Schema.String),
652
- type: FlagType,
653
- enabled: Schema.optionalKey(Schema.Boolean),
654
- variants: Schema.Record(Schema.String, FlagValue),
655
- defaultVariant: Schema.String,
656
- offVariant: Schema.optionalKey(Schema.String),
657
- targeting: Schema.optionalKey(Schema.Array(Rule)),
658
- metadata: Schema.optionalKey(FlagMetadata),
659
- /** Freeform organization labels (ADR-0008). Normalized server-side. */
660
- tags: Schema.optionalKey(Tags)
661
- });
662
- Schema.Struct({
663
- name: Schema.optionalKey(Schema.String),
664
- description: Schema.optionalKey(Schema.String),
665
- type: FlagType,
666
- enabled: Schema.optionalKey(Schema.Boolean),
667
- variants: Schema.Record(Schema.String, FlagValue),
668
- defaultVariant: Schema.String,
669
- offVariant: Schema.optionalKey(Schema.String),
670
- targeting: Schema.optionalKey(Schema.Array(Rule)),
671
- metadata: Schema.optionalKey(FlagMetadata),
672
- tags: Schema.optionalKey(Tags)
673
- });
674
- Schema.Struct({
675
- name: Schema.optionalKey(Schema.String),
676
- description: Schema.optionalKey(Schema.String),
677
- type: Schema.optionalKey(FlagType),
678
- enabled: Schema.optionalKey(Schema.Boolean),
679
- variants: Schema.optionalKey(Schema.Record(Schema.String, FlagValue)),
680
- defaultVariant: Schema.optionalKey(Schema.String),
681
- offVariant: Schema.optionalKey(Schema.String),
682
- targeting: Schema.optionalKey(Schema.Array(Rule)),
683
- metadata: Schema.optionalKey(FlagMetadata),
684
- tags: Schema.optionalKey(Tags)
685
- });
686
- //#endregion
687
- //#region ../schema/src/sync.ts
688
- /**
689
- * Definition-sync wire schemas (ADR-0001 §4): the versioned full snapshot the
690
- * sync surface serves to locally-evaluating SDKs, and the messages exchanged on
691
- * the live sync stream. Like the OFREP schemas, these are shared by the server
692
- * (producer) and `@toggld/node` (consumer) so the contract cannot drift.
693
- *
694
- * `version` is opaque and equality-compared (ADR-0005 phase 1); the ordered
695
- * `config_version` lands in this same field later without a wire change.
696
- */
697
- /** A versioned full snapshot of one environment's resolved flag set. */
698
- const FlagSnapshot = Schema.Struct({
699
- version: Schema.String,
700
- flags: Schema.Array(Flag)
701
- });
702
- /** Server→client: the current snapshot (connect greeting and change pushes). */
703
- const SyncSnapshotMessage = Schema.Struct({
704
- type: Schema.Literal("snapshot"),
705
- version: Schema.String,
706
- flags: Schema.Array(Flag)
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
+ };
707
344
  });
708
- /**
709
- * Every server→client sync-stream message. A single-member union today; the
710
- * `type` discriminant is the extension point.
711
- */
712
- const SyncServerMessage = Schema.Union([SyncSnapshotMessage]);
713
- Schema.Struct({ type: Schema.Literal("resync") });
714
- //#endregion
715
- //#region src/internal/core.ts
345
+ // src/internal/core.ts
716
346
  /**
717
347
  * The Effect core of the Toggld Node SDK.
718
348
  *
@@ -766,19 +396,19 @@ const errorDetails = (key, value, errorCode, errorMessage, flagMetadata = {}) =>
766
396
  reason: "ERROR",
767
397
  errorCode,
768
398
  errorMessage,
769
- flagMetadata
399
+ flagMetadata: { ...flagMetadata }
770
400
  });
771
401
  /** Map an engine {@link Resolution} to evaluation details, OpenFeature-style:
772
402
  * an abnormal resolution serves the **caller's** default, not the engine's
773
403
  * internal fallback — exactly what the remote wire does on a 400. */
774
404
  const resolutionDetails = (key, resolution, fallback) => resolution.errorCode !== void 0 ? errorDetails(key, fallback, resolution.errorCode, resolution.errorMessage ?? "", resolution.flagMetadata) : {
775
405
  flagKey: key,
776
- value: resolution.value,
406
+ value: typeof resolution.value === "object" && resolution.value !== null ? structuredClone(resolution.value) : resolution.value,
777
407
  variant: resolution.variant,
778
408
  reason: resolution.reason,
779
409
  errorCode: void 0,
780
410
  errorMessage: void 0,
781
- flagMetadata: resolution.flagMetadata
411
+ flagMetadata: { ...resolution.flagMetadata }
782
412
  };
783
413
  /** A readable message for an arbitrary thrown/failed value. */
784
414
  const describe = (e) => typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" ? e.message : String(e);
@@ -814,6 +444,7 @@ const makeRemote = (http, mergedContext, timeout) => {
814
444
  evaluateAll
815
445
  };
816
446
  };
447
+ const DEFAULT_FLAGS_FILE = "./toggld.flags.json";
817
448
  /** The endpoint's `ws(s)://` form for the sync stream. */
818
449
  const toStreamUrl = (endpoint) => {
819
450
  const url = new URL(endpoint);
@@ -821,9 +452,7 @@ const toStreamUrl = (endpoint) => {
821
452
  url.pathname = url.pathname.replace(/\/$/, "") + "/sync/v1/stream";
822
453
  return url.toString();
823
454
  };
824
- const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function* () {
825
- const transport = options.sync?.transport ?? "ws";
826
- const pollIntervalMs = options.sync?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
455
+ const makeSnapshotStore = (mergedContext, record) => Effect.gen(function* () {
827
456
  let current;
828
457
  let epoch = 0;
829
458
  const firstSnapshot = yield* Deferred.make();
@@ -832,10 +461,62 @@ const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function
832
461
  current = {
833
462
  version: snapshot.version,
834
463
  flags: snapshot.flags,
835
- byKey: new Map(snapshot.flags.map((f) => [f.key, f]))
464
+ byKey: new Map(snapshot.flags.map((f) => [f.key, f])),
465
+ segmentsByKey: new Map((snapshot.segments ?? []).map((s) => [s.key, s]))
836
466
  };
837
467
  return Effect.asVoid(Deferred.succeed(firstSnapshot, void 0));
838
468
  });
469
+ const warnedRemoteSegments = /* @__PURE__ */ new Set();
470
+ const memberOf = (state, ctx) => (segmentKey) => {
471
+ const segment = state.segmentsByKey.get(segmentKey);
472
+ if (segment === void 0) return false;
473
+ if (matchSegment(segment, ctx)) return true;
474
+ if (segment.listMode === "remote" && !warnedRemoteSegments.has(segmentKey)) {
475
+ warnedRemoteSegments.add(segmentKey);
476
+ console.warn(`[toggld] Segment '${segmentKey}' uses a remote member list, which is not available to local evaluation yet — treating subjects its rules don't match as non-members.`);
477
+ }
478
+ return false;
479
+ };
480
+ const evaluate = (key, defaultValue, context) => Effect.sync(() => {
481
+ const state = current;
482
+ if (state === void 0) return errorDetails(key, defaultValue, "PROVIDER_NOT_READY", "No flag snapshot loaded yet");
483
+ const flag = state.byKey.get(key);
484
+ if (flag === void 0) return errorDetails(key, defaultValue, "FLAG_NOT_FOUND", `Flag '${key}' not found`);
485
+ const ctx = mergedContext(context);
486
+ const resolution = evaluateFlag(flag, ctx, memberOf(state, ctx));
487
+ record(key, resolution);
488
+ return resolutionDetails(key, resolution, defaultValue);
489
+ });
490
+ const evaluateAll = (context) => Effect.sync(() => {
491
+ const state = current;
492
+ if (state === void 0) return [];
493
+ const ctx = mergedContext(context);
494
+ const isMemberOf = memberOf(state, ctx);
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
+ });
500
+ });
501
+ return {
502
+ adopt,
503
+ epoch: () => epoch,
504
+ version: () => current?.version,
505
+ impl: {
506
+ ready: Deferred.await(firstSnapshot),
507
+ evaluate,
508
+ evaluateAll
509
+ }
510
+ };
511
+ });
512
+ /**
513
+ * The network source (ADR-0012): bootstrap + safety-net poll over
514
+ * `GET /sync/v1/flags`, plus a live WebSocket stream. Forks its fibers into the
515
+ * caller's scope and feeds `store.adopt`.
516
+ */
517
+ const startNetworkSource = (options, http, store, timeout) => Effect.gen(function* () {
518
+ const transport = options.sync?.transport ?? "ws";
519
+ const pollIntervalMs = options.sync?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
839
520
  const fetchSnapshot = Effect.gen(function* () {
840
521
  const response = yield* http.execute(HttpClientRequest.get("/sync/v1/flags"));
841
522
  return yield* HttpClientResponse.matchStatus(response, {
@@ -844,17 +525,17 @@ const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function
844
525
  });
845
526
  }).pipe(Effect.timeout(timeout));
846
527
  const pollOnce = Effect.gen(function* () {
847
- const before = epoch;
528
+ const before = store.epoch();
848
529
  const snapshot = yield* fetchSnapshot;
849
- if (epoch !== before) return;
850
- if (current !== void 0 && current.version === snapshot.version) return;
851
- yield* adopt(snapshot);
530
+ if (store.epoch() !== before) return;
531
+ if (store.version() === snapshot.version) return;
532
+ yield* store.adopt(snapshot);
852
533
  });
853
534
  const ticker = Effect.gen(function* () {
854
535
  let delay = 250;
855
- while (current === void 0) {
536
+ while (store.version() === void 0) {
856
537
  yield* pollOnce.pipe(Effect.ignore);
857
- if (current !== void 0) break;
538
+ if (store.version() !== void 0) break;
858
539
  yield* Effect.sleep(Duration.millis(delay));
859
540
  delay = Math.min(delay * 2, 5e3);
860
541
  }
@@ -863,20 +544,25 @@ const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function
863
544
  const decodeMessage = Schema.decodeUnknownEffect(SyncServerMessage);
864
545
  const handleMessage = (raw) => Effect.gen(function* () {
865
546
  const message = yield* decodeMessage(JSON.parse(String(raw)));
866
- if (current !== void 0 && current.version === message.version) return;
867
- yield* adopt(message);
547
+ if (store.version() === message.version) return;
548
+ yield* store.adopt(message);
868
549
  }).pipe(Effect.ignore);
869
- const streamUrl = toStreamUrl(options.endpoint);
550
+ const streamUrl = toStreamUrl(options.endpoint ?? "");
870
551
  let consecutiveFailures = 0;
871
552
  const streamLoop = Effect.gen(function* () {
872
553
  const socket = yield* Socket.makeWebSocket(streamUrl, {
873
- protocols: [SYNC_PROTOCOL, `${SYNC_BEARER_PROTOCOL_PREFIX}${options.sdkKey}`],
554
+ protocols: [SYNC_PROTOCOL, `${SYNC_BEARER_PROTOCOL_PREFIX}${options.sdkKey ?? ""}`],
874
555
  openTimeout: timeout
875
556
  });
876
557
  yield* Effect.forever(Effect.gen(function* () {
877
- yield* socket.runRaw(handleMessage, { onOpen: Effect.sync(() => {
558
+ yield* Effect.scoped(Effect.gen(function* () {
559
+ const pull = yield* Socket.readerString(socket);
878
560
  consecutiveFailures = 0;
879
- }) }).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);
880
566
  consecutiveFailures++;
881
567
  const base = Math.min(3e4, 1e3 * 2 ** Math.min(consecutiveFailures - 1, 5));
882
568
  yield* Effect.sleep(Duration.millis(base * (.5 + Math.random() * .5)));
@@ -884,32 +570,114 @@ const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function
884
570
  });
885
571
  yield* Effect.forkScoped(ticker);
886
572
  if (transport === "ws") yield* Effect.forkScoped(streamLoop);
887
- const evaluate = (key, defaultValue, context) => Effect.sync(() => {
888
- if (current === void 0) return errorDetails(key, defaultValue, "PROVIDER_NOT_READY", "No flag snapshot synced yet");
889
- const flag = current.byKey.get(key);
890
- if (flag === void 0) return errorDetails(key, defaultValue, "FLAG_NOT_FOUND", `Flag '${key}' not found`);
891
- return resolutionDetails(key, evaluateFlag(flag, mergedContext(context)), defaultValue);
573
+ });
574
+ /**
575
+ * The file source (ADR-0013): read a `FlagSnapshot` from disk, watch it for
576
+ * edits, and adopt on change. The envelope keys a `pull`-generated file carries
577
+ * (`$schema`, `toggld`) are ignored by the snapshot decoder. No network, key, or
578
+ * stream — purely local. Forks its fibers into the caller's scope.
579
+ */
580
+ const startFileSource = (path, store, options) => Effect.gen(function* () {
581
+ const pollIntervalMs = options.sync?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
582
+ const decodeSnapshot = Schema.decodeUnknownEffect(FlagSnapshot);
583
+ let lastError;
584
+ const readOnce = Effect.gen(function* () {
585
+ const json = yield* Effect.tryPromise(async () => JSON.parse(await readFile(path, "utf8")));
586
+ const snapshot = yield* decodeSnapshot(json);
587
+ lastError = void 0;
588
+ if (store.version() === snapshot.version) return;
589
+ yield* store.adopt(snapshot);
590
+ }).pipe(Effect.catch((error) => Effect.sync(() => {
591
+ const message = describe(error);
592
+ if (message === lastError) return;
593
+ lastError = message;
594
+ console.warn(`[toggld] Could not load flags file '${path}': ${message}`);
595
+ })));
596
+ const ticker = Effect.gen(function* () {
597
+ let delay = 100;
598
+ while (store.version() === void 0) {
599
+ yield* readOnce;
600
+ if (store.version() !== void 0) break;
601
+ yield* Effect.sleep(Duration.millis(delay));
602
+ delay = Math.min(delay * 2, 2e3);
603
+ }
604
+ yield* Effect.forever(Effect.sleep(Duration.millis(pollIntervalMs)).pipe(Effect.andThen(readOnce)));
892
605
  });
893
- const evaluateAll = (context) => Effect.sync(() => {
894
- if (current === void 0) return [];
895
- const ctx = mergedContext(context);
896
- return current.flags.map((flag) => resolutionDetails(flag.key, evaluateFlag(flag, ctx), null));
606
+ const dir = dirname(path);
607
+ const base = basename(path);
608
+ const controller = new AbortController();
609
+ yield* Effect.addFinalizer(() => Effect.sync(() => controller.abort()));
610
+ const watchLoop = Effect.gen(function* () {
611
+ while (!controller.signal.aborted) {
612
+ const iterator = yield* Effect.sync(() => {
613
+ try {
614
+ return watch(dir, { signal: controller.signal })[Symbol.asyncIterator]();
615
+ } catch {
616
+ return;
617
+ }
618
+ });
619
+ if (iterator === void 0) {
620
+ yield* Effect.sleep(Duration.millis(1e3));
621
+ continue;
622
+ }
623
+ let live = true;
624
+ while (live) {
625
+ const next = yield* Effect.tryPromise(() => iterator.next()).pipe(Effect.catch(() => Effect.succeed({
626
+ done: true,
627
+ value: void 0
628
+ })));
629
+ if (next.done) {
630
+ live = false;
631
+ break;
632
+ }
633
+ const filename = next.value?.filename;
634
+ if (filename === base || filename === null || filename === void 0) yield* readOnce;
635
+ }
636
+ if (controller.signal.aborted) break;
637
+ yield* Effect.sleep(Duration.millis(500));
638
+ }
897
639
  });
898
- return {
899
- ready: Deferred.await(firstSnapshot),
900
- evaluate,
901
- evaluateAll
902
- };
640
+ yield* Effect.forkScoped(watchLoop, { startImmediately: true });
641
+ yield* Effect.forkScoped(ticker);
642
+ });
643
+ /**
644
+ * Resolve a local flag-file path, or `undefined` for network sync. Highest
645
+ * precedence first: the `TOGGLD_FLAGS_FILE` env var (so a deployed app can be
646
+ * redirected at a file with no code change), then an explicit `sync.file`, then
647
+ * `./toggld.flags.json` when `sync.file` is `true`.
648
+ */
649
+ const resolveFilePath = (options) => {
650
+ const env = process.env.TOGGLD_FLAGS_FILE;
651
+ if (typeof env === "string" && env.length > 0) return env;
652
+ const file = options.sync?.file;
653
+ if (typeof file === "string" && file.length > 0) return file;
654
+ if (file === true) return DEFAULT_FLAGS_FILE;
655
+ };
656
+ /**
657
+ * Assemble the local-mode evaluation surface, choosing the file source when a
658
+ * path resolves (env var or `sync.file`) and the network source otherwise.
659
+ */
660
+ const makeLocal = (options, http, mergedContext, timeout, record) => Effect.gen(function* () {
661
+ const store = yield* makeSnapshotStore(mergedContext, record);
662
+ const filePath = resolveFilePath(options);
663
+ if (filePath !== void 0) yield* startFileSource(filePath, store, options);
664
+ else yield* startNetworkSource(options, http, store, timeout);
665
+ return store.impl;
903
666
  });
904
667
  const make = (options) => Effect.gen(function* () {
905
668
  const base = yield* HttpClient.HttpClient;
906
669
  const timeout = Duration.millis(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
907
- const http = base.pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl(options.endpoint)), HttpClient.mapRequest(HttpClientRequest.bearerToken(options.sdkKey)));
670
+ const http = base.pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl(options.endpoint ?? "")), HttpClient.mapRequest(HttpClientRequest.bearerToken(options.sdkKey ?? "")));
908
671
  const mergedContext = (context) => ({
909
672
  ...options.defaultContext,
910
673
  ...context
911
674
  });
912
- const { evaluate, evaluateAll, ready } = options.mode === "local" ? 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);
913
681
  /** Resolve, then enforce the requested value type (else `TYPE_MISMATCH`). */
914
682
  const typed = (key, defaultValue, context, type, guard) => Effect.map(evaluate(key, defaultValue, context), (d) => {
915
683
  if (d.errorCode !== void 0) return d;
@@ -919,6 +687,7 @@ const make = (options) => Effect.gen(function* () {
919
687
  const valueOf = (details) => Effect.map(details, (d) => d.value);
920
688
  return {
921
689
  ready,
690
+ flushTelemetry: telemetry.flush,
922
691
  evaluate,
923
692
  evaluateAll,
924
693
  getBooleanDetails: (key, defaultValue, context) => typed(key, defaultValue, context, "boolean", isBoolean),
@@ -934,7 +703,7 @@ const make = (options) => Effect.gen(function* () {
934
703
  /** The HttpClient layer, optionally backed by a caller-supplied `fetch`. */
935
704
  const httpLayer = (fetchImpl) => fetchImpl ? FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchImpl))) : FetchHttpClient.layer;
936
705
  /** The WebSocket constructor for the sync stream, optionally caller-supplied. */
937
- 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;
938
707
  /**
939
708
  * A self-contained `Layer` providing {@link ToggldClient} — it wires its own
940
709
  * HTTP and WebSocket backends, so Effect users need only provide this one
@@ -942,5 +711,4 @@ const webSocketLayer = (ctor) => ctor ? Layer.succeed(Socket.WebSocketConstructo
942
711
  * closing it (e.g. `ManagedRuntime.dispose`) stops sync and closes the stream.
943
712
  */
944
713
  const layer = (options) => Layer.effect(ToggldClient, make(options)).pipe(Layer.provide(httpLayer(options.fetch)), Layer.provide(webSocketLayer(options.sync?.webSocketConstructor)));
945
- //#endregion
946
714
  export { layer as n, ToggldClient as t };