@toggld/node 0.2.0 → 0.3.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,4 +1,7 @@
1
+ import { a as OfrepSuccess, i as OfrepFailure, n as SyncServerMessage, r as OfrepBulkResponse, t as FlagSnapshot } from "./src-CCPvsrli.mjs";
1
2
  import { Context, Deferred, Duration, Effect, Layer, Schema } from "effect";
3
+ import { readFile, watch } from "node:fs/promises";
4
+ import { basename, dirname } from "node:path";
2
5
  import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
3
6
  import { Socket } from "effect/unstable/socket";
4
7
  //#region ../spec/src/index.ts
@@ -149,8 +152,37 @@ const matchCondition = (condition, ctx) => {
149
152
  }
150
153
  }
151
154
  };
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));
155
+ /**
156
+ * A rule matches when every one of its conditions matches **and** every
157
+ * referenced segment resolves (member XOR `negate`) through `isMemberOf`
158
+ * (logical AND throughout). A rule with no segment references behaves exactly as
159
+ * conditions-only. `isMemberOf` is consulted only when the rule has segments;
160
+ * when absent, any segment reference resolves `false` (closed).
161
+ */
162
+ const matchRule = (rule, ctx, isMemberOf) => {
163
+ if (!rule.conditions.every((c) => matchCondition(c, ctx))) return false;
164
+ if (rule.segments === void 0 || rule.segments.length === 0) return true;
165
+ return rule.segments.every((ref) => {
166
+ const member = isMemberOf?.(ref.segment) ?? false;
167
+ return ref.negate ? !member : member;
168
+ });
169
+ };
170
+ /**
171
+ * Resolves segment membership for a subject from a synced segment *definition*
172
+ * (ADR-0006) — the local path for rule-based and `inline`-list segments. Pure
173
+ * and isomorphic: the server and the Node SDK build their `isMemberOf` over
174
+ * this. Membership is the union of a rule match (OR of rules, each an AND of
175
+ * conditions) and presence in the inline member list (keyed by `keyAttribute`).
176
+ * `remote` segments carry no `members` here and must be resolved out of band.
177
+ */
178
+ const matchSegment = (segment, ctx) => {
179
+ if (segment.rules.some((r) => r.conditions.every((c) => matchCondition(c, ctx)))) return true;
180
+ if (segment.members !== void 0 && segment.members.length > 0) {
181
+ const subject = attributeOf(ctx, segment.keyAttribute);
182
+ if (typeof subject === "string" && segment.members.includes(subject)) return true;
183
+ }
184
+ return false;
185
+ };
154
186
  /**
155
187
  * Selects a rollout variant for a subject. Weights are normalised across all
156
188
  * splits, and the subject's bucket (consistent for the flag/rule/subject) is
@@ -233,11 +265,15 @@ const serveVariant = (flag, variant, reason) => {
233
265
  * - fixed variant → `TARGETING_MATCH`
234
266
  * - rollout → `SPLIT` (or `TARGETING_KEY_MISSING` if no subject)
235
267
  * 3. no rule matches → `defaultVariant` with reason `STATIC`
268
+ *
269
+ * `isMemberOf` resolves any {@link SegmentRef} a rule references (ADR-0006). It
270
+ * is optional: flags with no segment references never call it, and when it is
271
+ * omitted a segment reference resolves `false` (closed).
236
272
  */
237
- const evaluateFlag = (flag, ctx) => {
273
+ const evaluateFlag = (flag, ctx, isMemberOf) => {
238
274
  if (!flag.enabled) return serveVariant(flag, flag.offVariant ?? flag.defaultVariant, "DISABLED");
239
275
  for (const rule of flag.targeting) {
240
- if (!matchRule(rule, ctx)) continue;
276
+ if (!matchRule(rule, ctx, isMemberOf)) continue;
241
277
  if (rule.serve.type === "variant") return serveVariant(flag, rule.serve.variant, "TARGETING_MATCH");
242
278
  const outcome = selectRolloutVariant(flag.key, rule.id, rule.serve.splits, rule.serve.bucketBy, ctx);
243
279
  if (outcome._tag === "MissingSubject") return Resolution.error({
@@ -252,466 +288,6 @@ const evaluateFlag = (flag, ctx) => {
252
288
  return serveVariant(flag, flag.defaultVariant, "STATIC");
253
289
  };
254
290
  //#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)
707
- });
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
291
  //#region src/internal/core.ts
716
292
  /**
717
293
  * The Effect core of the Toggld Node SDK.
@@ -814,6 +390,7 @@ const makeRemote = (http, mergedContext, timeout) => {
814
390
  evaluateAll
815
391
  };
816
392
  };
393
+ const DEFAULT_FLAGS_FILE = "./toggld.flags.json";
817
394
  /** The endpoint's `ws(s)://` form for the sync stream. */
818
395
  const toStreamUrl = (endpoint) => {
819
396
  const url = new URL(endpoint);
@@ -821,9 +398,7 @@ const toStreamUrl = (endpoint) => {
821
398
  url.pathname = url.pathname.replace(/\/$/, "") + "/sync/v1/stream";
822
399
  return url.toString();
823
400
  };
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;
401
+ const makeSnapshotStore = (mergedContext) => Effect.gen(function* () {
827
402
  let current;
828
403
  let epoch = 0;
829
404
  const firstSnapshot = yield* Deferred.make();
@@ -832,10 +407,56 @@ const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function
832
407
  current = {
833
408
  version: snapshot.version,
834
409
  flags: snapshot.flags,
835
- byKey: new Map(snapshot.flags.map((f) => [f.key, f]))
410
+ byKey: new Map(snapshot.flags.map((f) => [f.key, f])),
411
+ segmentsByKey: new Map((snapshot.segments ?? []).map((s) => [s.key, s]))
836
412
  };
837
413
  return Effect.asVoid(Deferred.succeed(firstSnapshot, void 0));
838
414
  });
415
+ const warnedRemoteSegments = /* @__PURE__ */ new Set();
416
+ const memberOf = (state, ctx) => (segmentKey) => {
417
+ const segment = state.segmentsByKey.get(segmentKey);
418
+ if (segment === void 0) return false;
419
+ if (matchSegment(segment, ctx)) return true;
420
+ if (segment.listMode === "remote" && !warnedRemoteSegments.has(segmentKey)) {
421
+ warnedRemoteSegments.add(segmentKey);
422
+ 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.`);
423
+ }
424
+ return false;
425
+ };
426
+ const evaluate = (key, defaultValue, context) => Effect.sync(() => {
427
+ const state = current;
428
+ if (state === void 0) return errorDetails(key, defaultValue, "PROVIDER_NOT_READY", "No flag snapshot loaded yet");
429
+ const flag = state.byKey.get(key);
430
+ if (flag === void 0) return errorDetails(key, defaultValue, "FLAG_NOT_FOUND", `Flag '${key}' not found`);
431
+ const ctx = mergedContext(context);
432
+ return resolutionDetails(key, evaluateFlag(flag, ctx, memberOf(state, ctx)), defaultValue);
433
+ });
434
+ const evaluateAll = (context) => Effect.sync(() => {
435
+ const state = current;
436
+ if (state === void 0) return [];
437
+ const ctx = mergedContext(context);
438
+ const isMemberOf = memberOf(state, ctx);
439
+ return state.flags.map((flag) => resolutionDetails(flag.key, evaluateFlag(flag, ctx, isMemberOf), null));
440
+ });
441
+ return {
442
+ adopt,
443
+ epoch: () => epoch,
444
+ version: () => current?.version,
445
+ impl: {
446
+ ready: Deferred.await(firstSnapshot),
447
+ evaluate,
448
+ evaluateAll
449
+ }
450
+ };
451
+ });
452
+ /**
453
+ * The network source (ADR-0012): bootstrap + safety-net poll over
454
+ * `GET /sync/v1/flags`, plus a live WebSocket stream. Forks its fibers into the
455
+ * caller's scope and feeds `store.adopt`.
456
+ */
457
+ const startNetworkSource = (options, http, store, timeout) => Effect.gen(function* () {
458
+ const transport = options.sync?.transport ?? "ws";
459
+ const pollIntervalMs = options.sync?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
839
460
  const fetchSnapshot = Effect.gen(function* () {
840
461
  const response = yield* http.execute(HttpClientRequest.get("/sync/v1/flags"));
841
462
  return yield* HttpClientResponse.matchStatus(response, {
@@ -844,17 +465,17 @@ const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function
844
465
  });
845
466
  }).pipe(Effect.timeout(timeout));
846
467
  const pollOnce = Effect.gen(function* () {
847
- const before = epoch;
468
+ const before = store.epoch();
848
469
  const snapshot = yield* fetchSnapshot;
849
- if (epoch !== before) return;
850
- if (current !== void 0 && current.version === snapshot.version) return;
851
- yield* adopt(snapshot);
470
+ if (store.epoch() !== before) return;
471
+ if (store.version() === snapshot.version) return;
472
+ yield* store.adopt(snapshot);
852
473
  });
853
474
  const ticker = Effect.gen(function* () {
854
475
  let delay = 250;
855
- while (current === void 0) {
476
+ while (store.version() === void 0) {
856
477
  yield* pollOnce.pipe(Effect.ignore);
857
- if (current !== void 0) break;
478
+ if (store.version() !== void 0) break;
858
479
  yield* Effect.sleep(Duration.millis(delay));
859
480
  delay = Math.min(delay * 2, 5e3);
860
481
  }
@@ -863,14 +484,14 @@ const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function
863
484
  const decodeMessage = Schema.decodeUnknownEffect(SyncServerMessage);
864
485
  const handleMessage = (raw) => Effect.gen(function* () {
865
486
  const message = yield* decodeMessage(JSON.parse(String(raw)));
866
- if (current !== void 0 && current.version === message.version) return;
867
- yield* adopt(message);
487
+ if (store.version() === message.version) return;
488
+ yield* store.adopt(message);
868
489
  }).pipe(Effect.ignore);
869
- const streamUrl = toStreamUrl(options.endpoint);
490
+ const streamUrl = toStreamUrl(options.endpoint ?? "");
870
491
  let consecutiveFailures = 0;
871
492
  const streamLoop = Effect.gen(function* () {
872
493
  const socket = yield* Socket.makeWebSocket(streamUrl, {
873
- protocols: [SYNC_PROTOCOL, `${SYNC_BEARER_PROTOCOL_PREFIX}${options.sdkKey}`],
494
+ protocols: [SYNC_PROTOCOL, `${SYNC_BEARER_PROTOCOL_PREFIX}${options.sdkKey ?? ""}`],
874
495
  openTimeout: timeout
875
496
  });
876
497
  yield* Effect.forever(Effect.gen(function* () {
@@ -884,32 +505,109 @@ const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function
884
505
  });
885
506
  yield* Effect.forkScoped(ticker);
886
507
  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);
508
+ });
509
+ /**
510
+ * The file source (ADR-0013): read a `FlagSnapshot` from disk, watch it for
511
+ * edits, and adopt on change. The envelope keys a `pull`-generated file carries
512
+ * (`$schema`, `toggld`) are ignored by the snapshot decoder. No network, key, or
513
+ * stream — purely local. Forks its fibers into the caller's scope.
514
+ */
515
+ const startFileSource = (path, store, options) => Effect.gen(function* () {
516
+ const pollIntervalMs = options.sync?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
517
+ const decodeSnapshot = Schema.decodeUnknownEffect(FlagSnapshot);
518
+ let lastError;
519
+ const readOnce = Effect.gen(function* () {
520
+ const text = yield* Effect.tryPromise(() => readFile(path, "utf8"));
521
+ const snapshot = yield* decodeSnapshot(JSON.parse(text));
522
+ lastError = void 0;
523
+ if (store.version() === snapshot.version) return;
524
+ yield* store.adopt(snapshot);
525
+ }).pipe(Effect.catch((error) => Effect.sync(() => {
526
+ const message = describe(error);
527
+ if (message === lastError) return;
528
+ lastError = message;
529
+ console.warn(`[toggld] Could not load flags file '${path}': ${message}`);
530
+ })));
531
+ const ticker = Effect.gen(function* () {
532
+ let delay = 100;
533
+ while (store.version() === void 0) {
534
+ yield* readOnce;
535
+ if (store.version() !== void 0) break;
536
+ yield* Effect.sleep(Duration.millis(delay));
537
+ delay = Math.min(delay * 2, 2e3);
538
+ }
539
+ yield* Effect.forever(Effect.sleep(Duration.millis(pollIntervalMs)).pipe(Effect.andThen(readOnce)));
892
540
  });
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));
541
+ const dir = dirname(path);
542
+ const base = basename(path);
543
+ const controller = new AbortController();
544
+ yield* Effect.addFinalizer(() => Effect.sync(() => controller.abort()));
545
+ const watchLoop = Effect.gen(function* () {
546
+ while (!controller.signal.aborted) {
547
+ const iterator = yield* Effect.sync(() => {
548
+ try {
549
+ return watch(dir, { signal: controller.signal })[Symbol.asyncIterator]();
550
+ } catch {
551
+ return;
552
+ }
553
+ });
554
+ if (iterator === void 0) {
555
+ yield* Effect.sleep(Duration.millis(1e3));
556
+ continue;
557
+ }
558
+ let live = true;
559
+ while (live) {
560
+ const next = yield* Effect.tryPromise(() => iterator.next()).pipe(Effect.catch(() => Effect.succeed({
561
+ done: true,
562
+ value: void 0
563
+ })));
564
+ if (next.done) {
565
+ live = false;
566
+ break;
567
+ }
568
+ const filename = next.value?.filename;
569
+ if (filename === base || filename === null || filename === void 0) yield* readOnce;
570
+ }
571
+ if (controller.signal.aborted) break;
572
+ yield* Effect.sleep(Duration.millis(500));
573
+ }
897
574
  });
898
- return {
899
- ready: Deferred.await(firstSnapshot),
900
- evaluate,
901
- evaluateAll
902
- };
575
+ yield* Effect.forkScoped(ticker);
576
+ yield* Effect.forkScoped(watchLoop);
577
+ });
578
+ /**
579
+ * Resolve a local flag-file path, or `undefined` for network sync. Highest
580
+ * precedence first: the `TOGGLD_FLAGS_FILE` env var (so a deployed app can be
581
+ * redirected at a file with no code change), then an explicit `sync.file`, then
582
+ * `./toggld.flags.json` when `sync.file` is `true`.
583
+ */
584
+ const resolveFilePath = (options) => {
585
+ const env = process.env.TOGGLD_FLAGS_FILE;
586
+ if (typeof env === "string" && env.length > 0) return env;
587
+ const file = options.sync?.file;
588
+ if (typeof file === "string" && file.length > 0) return file;
589
+ if (file === true) return DEFAULT_FLAGS_FILE;
590
+ };
591
+ /**
592
+ * Assemble the local-mode evaluation surface, choosing the file source when a
593
+ * path resolves (env var or `sync.file`) and the network source otherwise.
594
+ */
595
+ const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function* () {
596
+ const store = yield* makeSnapshotStore(mergedContext);
597
+ const filePath = resolveFilePath(options);
598
+ if (filePath !== void 0) yield* startFileSource(filePath, store, options);
599
+ else yield* startNetworkSource(options, http, store, timeout);
600
+ return store.impl;
903
601
  });
904
602
  const make = (options) => Effect.gen(function* () {
905
603
  const base = yield* HttpClient.HttpClient;
906
604
  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)));
605
+ const http = base.pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl(options.endpoint ?? "")), HttpClient.mapRequest(HttpClientRequest.bearerToken(options.sdkKey ?? "")));
908
606
  const mergedContext = (context) => ({
909
607
  ...options.defaultContext,
910
608
  ...context
911
609
  });
912
- const { evaluate, evaluateAll, ready } = options.mode === "local" ? yield* makeLocal(options, http, mergedContext, timeout) : makeRemote(http, mergedContext, timeout);
610
+ const { evaluate, evaluateAll, ready } = options.mode === "local" || resolveFilePath(options) !== void 0 ? yield* makeLocal(options, http, mergedContext, timeout) : makeRemote(http, mergedContext, timeout);
913
611
  /** Resolve, then enforce the requested value type (else `TYPE_MISMATCH`). */
914
612
  const typed = (key, defaultValue, context, type, guard) => Effect.map(evaluate(key, defaultValue, context), (d) => {
915
613
  if (d.errorCode !== void 0) return d;