@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.
- package/README.md +44 -1
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +237 -0
- package/dist/{core-Bx0PVoEm.mjs → core-DBVGNtur.mjs} +192 -494
- package/dist/effect.d.mts +1 -1
- package/dist/effect.mjs +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +3 -5
- package/dist/src-CCPvsrli.mjs +571 -0
- package/dist/{types-DItF1mIU.d.mts → types-CPtRV5-5.d.mts} +31 -5
- package/package.json +4 -1
|
@@ -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
|
-
/**
|
|
153
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
476
|
+
while (store.version() === void 0) {
|
|
856
477
|
yield* pollOnce.pipe(Effect.ignore);
|
|
857
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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;
|