@tehw0lf/yaft 0.0.10 → 0.0.15

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.
@@ -49,7 +49,7 @@ jobs:
49
49
 
50
50
  steps:
51
51
  - name: Download latest dist artifacts from CI
52
- uses: dawidd6/action-download-artifact@v6
52
+ uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11 # v6
53
53
  with:
54
54
  workflow: build.yml
55
55
  name: dist
@@ -58,7 +58,7 @@ jobs:
58
58
 
59
59
  - name: Upload artifacts for scanning
60
60
  if: hashFiles('dist/**/*') != ''
61
- uses: actions/upload-artifact@v4
61
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
62
62
  with:
63
63
  name: build
64
64
  path: dist/
package/CLAUDE.md CHANGED
@@ -58,18 +58,31 @@ npx jest -t "test name" # Run specific test by name pattern
58
58
  - Support both boolean and Feature data types
59
59
  - Fallback to empty configuration on file load errors
60
60
 
61
+ **Evaluation Core (`src/evaluate.ts`)**
62
+ - `evaluate(feature, now)` is the single definition of the evaluation rules
63
+ - Providers call it instead of implementing the logic themselves; before this
64
+ existed, the feature providers each carried their own copy
65
+ - `Clock` is a `() => number`; providers take one and default to `systemClock`,
66
+ so tests and the conformance adapter can pin `now`
67
+ - `parseTimestamp` accepts only RFC 3339 with an offset and returns
68
+ `undefined` for anything else, warning rather than throwing
69
+
61
70
  ### Feature Data Model
62
71
 
63
72
  ```typescript
64
73
  type Feature = {
65
- key: string; // Unique feature identifier
66
- value: string; // Boolean value as string
67
- activeAt: string; // ISO date when feature becomes active
68
- disabledAt: string; // ISO date when feature gets disabled
74
+ key: string; // Unique feature identifier
75
+ value: string; // Boolean value as string; only "true" is on
76
+ activeAt: string; // RFC 3339 with offset, or empty
77
+ disabledAt: string; // RFC 3339 with offset, or empty
78
+ tags?: string[];
69
79
  }
70
80
  ```
71
81
 
72
82
  Features support time-based activation/deactivation logic evaluated at runtime.
83
+ The window is half-open: `now == activeAt` is on, `now == disabledAt` is off.
84
+ Dates without an offset are ignored, because languages disagree on how to read
85
+ them and a feature would otherwise flip at a different instant per port.
73
86
 
74
87
  ### Decorator Behavior
75
88
 
package/README.md CHANGED
@@ -94,9 +94,85 @@ export type Feature = {
94
94
  value: string;
95
95
  activeAt: string;
96
96
  disabledAt: string;
97
+ tags?: string[];
97
98
  };
98
99
  ```
99
100
 
101
+ ## Evaluation rules
102
+
103
+ These rules are not this library's own: they are
104
+ [yaft-conformance](https://github.com/tehw0lf/yaft-conformance), the shared
105
+ specification every YaFT implementation is checked against. This port passes
106
+ the whole suite (see [Conformance](#conformance) below), so a feature evaluates
107
+ identically here and in any other port.
108
+
109
+ A feature is on when all of the following hold. `evaluate` is exported, so the
110
+ rules can be applied directly to a feature without going through a provider.
111
+
112
+ - The value is exactly `"true"`. `"TRUE"`, `"1"` and `""` are off -- the value
113
+ is stored as a string, and anything else would be a silent disagreement
114
+ between backend and client.
115
+ - `activeAt` has passed, if set. The bound is inclusive: at exactly `activeAt`
116
+ the feature is on.
117
+ - `disabledAt` has not been reached, if set. This bound is exclusive: at
118
+ exactly `disabledAt` the feature is off.
119
+
120
+ A missing feature is off. Unset, `null` or unparseable dates are ignored rather
121
+ than treated as an error, and never throw.
122
+
123
+ ### Date format
124
+
125
+ Dates must be **RFC 3339 with an offset** (`2026-09-18T15:00:00Z` or
126
+ `2026-09-18T15:00:00+02:00`). Anything else -- a bare date such as
127
+ `2026-09-18`, or a timestamp without an offset -- is ignored and logged as a
128
+ warning.
129
+
130
+ This is stricter than `Date.parse`, on purpose: JavaScript reads a bare date as
131
+ UTC midnight and an offset-less timestamp as local time, while most other
132
+ languages read both as local. Accepting them would make a feature flip at a
133
+ different instant depending on which client evaluated it.
134
+
135
+ ## Testing with a fixed time
136
+
137
+ Both `Feature` providers take an optional clock, so a test can evaluate against
138
+ a fixed instant instead of the current time:
139
+
140
+ ```ts
141
+ import { LocalStorageFeatureProvider } from "./provider";
142
+
143
+ const provider = new LocalStorageFeatureProvider(
144
+ "./test-feature.json",
145
+ () => Date.parse("2026-09-18T12:00:00Z")
146
+ );
147
+ ```
148
+
149
+ The clock defaults to the system time, so existing code needs no change.
150
+
151
+ ## Conformance
152
+
153
+ The rules above are specified once, language-neutrally, in
154
+ [yaft-conformance](https://github.com/tehw0lf/yaft-conformance), and this port
155
+ is tested against that suite rather than only against its own expectations.
156
+
157
+ The version is pinned in `conformance.lock`:
158
+
159
+ ```
160
+ version=v1.1.0
161
+ sha256=d83ff1c960ad29830c00b57727591da628f4323b777faea245f603565d1c9ae9
162
+ ```
163
+
164
+ `npm test` fetches that release, verifies the checksum and unpacks it before
165
+ Jest runs, so there is no separate step to forget and no way to get a green run
166
+ against stale cases. Upgrading the suite is a one-line change to that file,
167
+ visible in review.
168
+
169
+ The checksum is not decoration: a Git tag can be moved, and without verifying
170
+ the asset a port's tests could change with no diff at all.
171
+
172
+ The adapter lives in `src/test/conformance-adapter/`. It fails loudly on a case
173
+ whose `target`, `toggle` or `expected` it does not implement, rather than
174
+ skipping it -- a silently skipped case is a rule that nothing enforces.
175
+
100
176
  # Licenses
101
177
 
102
178
  - Code: MIT License
@@ -0,0 +1,2 @@
1
+ version=v1.1.0
2
+ sha256=d83ff1c960ad29830c00b57727591da628f4323b777faea245f603565d1c9ae9
package/logo.svg CHANGED
@@ -1,4 +1,4 @@
1
- <svg viewBox="0 0 60 60" stroke="" fill="grey" xmlns="http://www.w3.org/2000/svg">
1
+ <svg viewBox="0 0 60 60" stroke="" fill="#8e8e8e" xmlns="http://www.w3.org/2000/svg">
2
2
  <title>YaFT Logo</title>
3
3
  <desc>Yet Another Feature Toggle - Copyright 2025 tehw0lf</desc>
4
4
  <metadata>
@@ -11,8 +11,8 @@
11
11
  </rdf:RDF>
12
12
  </metadata>
13
13
  <!-- Y -->
14
- <path d="M5,5 L30,28" stroke="grey" stroke-width="4" fill="none" />
15
- <path d="M55,5 L30,28" stroke="grey" stroke-width="4" fill="none" />
14
+ <path d="M5,5 L30,28" stroke="#8e8e8e" stroke-width="4" fill="none" />
15
+ <path d="M55,5 L30,28" stroke="#8e8e8e" stroke-width="4" fill="none" />
16
16
  <!-- A -->
17
17
  <rect x="19" y="17.5" width="22" height="3" />
18
18
  <!-- F -->
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "@tehw0lf/yaft",
3
- "version": "0.0.10",
3
+ "version": "0.0.15",
4
4
  "description": "YaFT - Feature Toggles using Go&PostgreSQL or any source!",
5
5
  "type": "commonjs",
6
6
  "scripts": {
7
7
  "build": "tsc && cp package.json README.md logo.svg dist/yaft/ && npm pkg delete devDependencies --prefix dist/yaft",
8
- "test": "npx jest"
8
+ "test": "npx jest",
9
+ "conformance:fetch": "./scripts/fetch-conformance.sh src/test/conformance",
10
+ "pretest": "npm run conformance:fetch"
9
11
  },
10
12
  "repository": {
11
13
  "type": "git",
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Fetches the YaFT conformance suite pinned in conformance.lock.
4
+ #
5
+ # Copy this into a port, next to a conformance.lock of the form:
6
+ #
7
+ # version=v1.0.0
8
+ # sha256=<checksum of cases.tar.gz>
9
+ #
10
+ # The checksum is not optional. A Git tag can be moved; verifying the asset is
11
+ # what stops a port's tests from changing without a diff.
12
+ #
13
+ # Usage: scripts/fetch-conformance.sh [target-dir] (default: src/test/conformance)
14
+ #
15
+ # Set the default to wherever the port's adapter reads the cases from. Ports
16
+ # differ -- yaft-ts uses src/test/conformance -- and a default that does not
17
+ # match leaves the next test run reporting missing cases.
18
+
19
+ set -euo pipefail
20
+
21
+ REPO="tehw0lf/yaft-conformance"
22
+ LOCK="${LOCK:-conformance.lock}"
23
+ TARGET="${1:-src/test/conformance}"
24
+
25
+ if [[ ! -f "$LOCK" ]]; then
26
+ echo "error: $LOCK not found; run from the port's root" >&2
27
+ exit 1
28
+ fi
29
+
30
+ version="$(grep -E '^version=' "$LOCK" | cut -d= -f2-)"
31
+ expected="$(grep -E '^sha256=' "$LOCK" | cut -d= -f2-)"
32
+
33
+ if [[ -z "$version" || -z "$expected" ]]; then
34
+ echo "error: $LOCK needs both version= and sha256=" >&2
35
+ exit 1
36
+ fi
37
+
38
+ tmp="$(mktemp -d)"
39
+ trap 'rm -rf "$tmp"' EXIT
40
+
41
+ url="https://github.com/${REPO}/releases/download/${version}/cases.tar.gz"
42
+ echo "fetching conformance suite ${version}"
43
+ curl --fail --location --silent --show-error --output "$tmp/cases.tar.gz" "$url"
44
+
45
+ # sha256sum is GNU coreutils and is not present on macOS; shasum ships with
46
+ # both. Preferring sha256sum keeps Linux CI on the faster binary.
47
+ if command -v sha256sum >/dev/null 2>&1; then
48
+ actual="$(sha256sum "$tmp/cases.tar.gz" | cut -d' ' -f1)"
49
+ elif command -v shasum >/dev/null 2>&1; then
50
+ actual="$(shasum -a 256 "$tmp/cases.tar.gz" | cut -d' ' -f1)"
51
+ else
52
+ echo "error: neither sha256sum nor shasum found; cannot verify the download" >&2
53
+ exit 1
54
+ fi
55
+ if [[ "$actual" != "$expected" ]]; then
56
+ echo "error: checksum mismatch for cases.tar.gz" >&2
57
+ echo " expected $expected" >&2
58
+ echo " actual $actual" >&2
59
+ echo "The tag may have been moved. Do not update the lock without reading the diff." >&2
60
+ exit 1
61
+ fi
62
+
63
+ rm -rf "$TARGET"
64
+ mkdir -p "$TARGET"
65
+ tar --extract --gzip --file "$tmp/cases.tar.gz" --directory "$TARGET"
66
+
67
+ echo "conformance suite ${version} unpacked into ${TARGET}"
@@ -34,14 +34,27 @@ export function FeatureToggle(key: string, fallback?: any) {
34
34
  // Method
35
35
  const originalMethod = descriptor.value;
36
36
 
37
+ // An async method must keep returning a promise when it is switched
38
+ // off, or `await` at the call site breaks on a plain undefined. The
39
+ // empty class shell below already makes this distinction; without it
40
+ // here, turning a feature off would throw inside unrelated code.
41
+ const isAsync =
42
+ originalMethod?.[Symbol.toStringTag] === "AsyncFunction";
43
+
37
44
  descriptor.value = function (...args: any[]) {
38
45
  const isEnabled = FeatureToggleBase.featureProvider.isEnabled(key);
39
46
  if (isEnabled) {
40
47
  return originalMethod.apply(this, args);
41
48
  } else {
42
- return fallback !== undefined
43
- ? fallback.apply(this, args as [])
44
- : (() => {}).apply(this, args as []);
49
+ if (fallback !== undefined) {
50
+ const result = fallback.apply(this, args as []);
51
+ // A synchronous fallback on an async method would otherwise hand
52
+ // back a plain value, breaking the promise the signature
53
+ // advertises. Promise.resolve passes an existing promise through
54
+ // unchanged, so an async fallback is unaffected.
55
+ return isAsync ? Promise.resolve(result) : result;
56
+ }
57
+ return isAsync ? Promise.resolve() : undefined;
45
58
  }
46
59
  };
47
60
  return descriptor;
@@ -0,0 +1,122 @@
1
+ import { Feature } from "./FeatureToggle";
2
+
3
+ /**
4
+ * A source of the current time, in milliseconds since the epoch.
5
+ *
6
+ * Everything that evaluates a feature takes one of these instead of calling
7
+ * `Date.now()` directly, so tests -- and the conformance suite, which supplies
8
+ * a `now` with every case -- can evaluate against a fixed instant.
9
+ */
10
+ export type Clock = () => number;
11
+
12
+ /** The default clock: the system time. */
13
+ export const systemClock: Clock = () => Date.now();
14
+
15
+ /**
16
+ * Matches RFC 3339 timestamps that carry an explicit offset (`Z` or `±hh:mm`).
17
+ *
18
+ * Only this format is accepted. A bare date such as `2026-09-18` or a
19
+ * timestamp without an offset is rejected, because languages disagree on how
20
+ * to read them -- JavaScript treats a bare date as UTC midnight and an
21
+ * offset-less timestamp as local time, while most other languages read both as
22
+ * local. A feature would then flip at a different instant depending on which
23
+ * port evaluated it, so such values are ignored rather than guessed at.
24
+ */
25
+ const RFC3339_WITH_OFFSET =
26
+ /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
27
+
28
+ /** Days per month, index 1-12; February is handled by the leap-year branch. */
29
+ const DAYS_IN_MONTH = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
30
+
31
+ function isLeapYear(year: number): boolean {
32
+ return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
33
+ }
34
+
35
+ function isRealDate(year: number, month: number, day: number): boolean {
36
+ if (month < 1 || month > 12 || day < 1) return false;
37
+ const max = month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month];
38
+ return day <= max;
39
+ }
40
+
41
+ function isRealTime(hour: number, minute: number, second: number): boolean {
42
+ // RFC 3339 permits second 60 for a leap second; it is allowed through here
43
+ // and then rejected by the NaN guard, because Date.parse cannot represent
44
+ // one. The value ends up ignored either way.
45
+ return hour <= 23 && minute <= 59 && second <= 60;
46
+ }
47
+
48
+ /**
49
+ * Parses an RFC 3339 timestamp with an offset.
50
+ *
51
+ * Returns `undefined` for anything unset, malformed or in another format;
52
+ * callers treat that as "no bound", never as an error. An invalid value is
53
+ * warned about but never throws, so a bad timestamp in the backend cannot take
54
+ * an application down.
55
+ */
56
+ export function parseTimestamp(value: string | null | undefined): number | undefined {
57
+ if (value === null || value === undefined || value === "") return undefined;
58
+
59
+ const match = RFC3339_WITH_OFFSET.exec(value);
60
+ if (!match) {
61
+ console.warn(
62
+ `YaFT: ignoring "${value}", expected RFC 3339 with an offset (e.g. 2026-09-18T15:00:00Z)`
63
+ );
64
+ return undefined;
65
+ }
66
+
67
+ // The pattern only checks the shape, and Date.parse does not reject an
68
+ // impossible calendar date -- it rolls it over, turning 2027-02-30 into
69
+ // 2027-03-02. Silently shifting a bound by days is worse than ignoring it,
70
+ // so the components are range-checked first.
71
+ const [, year, month, day, hour, minute, second] = match;
72
+ if (!isRealDate(+year, +month, +day) || !isRealTime(+hour, +minute, +second)) {
73
+ console.warn(`YaFT: ignoring "${value}", not a valid date or time`);
74
+ return undefined;
75
+ }
76
+
77
+ const parsed = Date.parse(value);
78
+ if (Number.isNaN(parsed)) {
79
+ console.warn(`YaFT: ignoring "${value}", not a valid timestamp`);
80
+ return undefined;
81
+ }
82
+
83
+ return parsed;
84
+ }
85
+
86
+ /**
87
+ * Decides whether a feature is on at the instant `now`.
88
+ *
89
+ * This is the single definition of YaFT's evaluation rules. Providers call it
90
+ * rather than implementing the logic themselves, so every provider -- and
91
+ * every port that mirrors this function -- agrees on the same answer.
92
+ *
93
+ * The rules:
94
+ *
95
+ * - A missing feature is off.
96
+ * - Only the exact string `"true"` is on. `"TRUE"`, `"1"` and `""` are off,
97
+ * because the backend stores the value as a string and anything else would
98
+ * be a silent disagreement between backend and client.
99
+ * - `activeAt` and `disabledAt` are optional bounds. Unset, null or
100
+ * unparseable values are ignored rather than treated as an error.
101
+ * - The window is half-open: at exactly `activeAt` the feature is on
102
+ * (`now < activeAt` is off), at exactly `disabledAt` it is off
103
+ * (`now >= disabledAt` is off).
104
+ * - `activeAt` after `disabledAt` is not special-cased; it simply yields a
105
+ * window that is never open.
106
+ */
107
+ export function evaluate(
108
+ feature: Feature | null | undefined,
109
+ now: number
110
+ ): boolean {
111
+ if (feature === undefined || feature === null) return false;
112
+
113
+ if (feature.value !== "true") return false;
114
+
115
+ const activeAt = parseTimestamp(feature.activeAt);
116
+ if (activeAt !== undefined && now < activeAt) return false;
117
+
118
+ const disabledAt = parseTimestamp(feature.disabledAt);
119
+ if (disabledAt !== undefined && now >= disabledAt) return false;
120
+
121
+ return true;
122
+ }
@@ -1,6 +1,7 @@
1
1
  import axios from "axios";
2
2
 
3
3
  import { FeatureProvider } from "../FeatureToggle";
4
+ import { normaliseCollection } from "../mapping";
4
5
 
5
6
  export class ApiServiceBooleanProvider implements FeatureProvider<boolean> {
6
7
  apiUrl: string;
@@ -30,22 +31,28 @@ export class ApiServiceBooleanProvider implements FeatureProvider<boolean> {
30
31
  async getConfig(configPathOrUrl: string): Promise<void> {
31
32
  try {
32
33
  const response = await axios.get(configPathOrUrl);
33
- // Handle Go backend response format
34
- const featuresArray = response.data.toggles || response.data.value || [];
35
-
36
- // Convert array to keyed boolean object and handle capitalized field names
34
+
35
+ // A keyed boolean object is already in this provider's shape and is
36
+ // taken as-is; anything else is a feature-shaped response and goes
37
+ // through the core normaliser, so the two providers cannot disagree
38
+ // about what a response means.
39
+ if (isKeyedBooleans(response.data)) {
40
+ this.data = response.data;
41
+ return;
42
+ }
43
+
44
+ // Only the value matters here. The boolean shape has no time logic by
45
+ // design (R21), so a feature collapses to whether its value is exactly
46
+ // "true" -- activeAt and disabledAt are dropped.
47
+ //
48
+ // That is a real trap when this provider is pointed at a backend that
49
+ // schedules toggles: the window is then enforced only by the backend's
50
+ // cron job, which lags by up to a minute, instead of being evaluated
51
+ // locally. Use ApiServiceFeatureProvider when the toggles carry dates.
52
+ const features = normaliseCollection(response.data);
37
53
  this.data = {};
38
- if (Array.isArray(featuresArray)) {
39
- featuresArray.forEach((feature: any) => {
40
- const key = feature.key || feature.Key;
41
- const value = feature.value || feature.Value;
42
- if (key) {
43
- this.data[key] = value === 'true' || value === true;
44
- }
45
- });
46
- } else {
47
- // Fallback for object format
48
- this.data = featuresArray;
54
+ for (const [key, feature] of Object.entries(features)) {
55
+ this.data[key] = feature.value === 'true';
49
56
  }
50
57
  } catch (error) {
51
58
  console.error("Failed to fetch feature toggle from API:", error);
@@ -58,3 +65,18 @@ export class ApiServiceBooleanProvider implements FeatureProvider<boolean> {
58
65
  return feature;
59
66
  }
60
67
  }
68
+
69
+ /**
70
+ * True when the payload is already `{ "myToggle": true }`.
71
+ *
72
+ * Distinguishing this from a feature-shaped response matters: running a keyed
73
+ * boolean object through the feature normaliser would look for a `key` field,
74
+ * find none and discard every entry.
75
+ */
76
+ function isKeyedBooleans(data: unknown): data is Record<string, boolean> {
77
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
78
+ return false;
79
+ }
80
+ const values = Object.values(data as Record<string, unknown>);
81
+ return values.length > 0 && values.every((v) => typeof v === 'boolean');
82
+ }
@@ -1,5 +1,7 @@
1
1
  import axios from "axios";
2
2
 
3
+ import { Clock, evaluate, systemClock } from "../evaluate";
4
+ import { normaliseCollection } from "../mapping";
3
5
  import { Feature, FeatureProvider } from "../FeatureToggle";
4
6
 
5
7
  export class ApiServiceFeatureProvider implements FeatureProvider<Feature> {
@@ -7,8 +9,14 @@ export class ApiServiceFeatureProvider implements FeatureProvider<Feature> {
7
9
  baseUUID: string;
8
10
  data: Record<string, Feature> = {};
9
11
  collectionHash = "";
10
-
11
- constructor(apiUrl: string, baseUUID: string) {
12
+ private readonly clock: Clock;
13
+
14
+ /**
15
+ * @param clock source of the current time; override it to evaluate against a
16
+ * fixed instant in tests
17
+ */
18
+ constructor(apiUrl: string, baseUUID: string, clock: Clock = systemClock) {
19
+ this.clock = clock;
12
20
  this.apiUrl = apiUrl;
13
21
  this.baseUUID = baseUUID;
14
22
  this.getCollectionHash(`${this.apiUrl}/collectionHash/${this.baseUUID}`);
@@ -30,58 +38,15 @@ export class ApiServiceFeatureProvider implements FeatureProvider<Feature> {
30
38
  async getConfig(configPathOrUrl: string): Promise<void> {
31
39
  try {
32
40
  const response = await axios.get(configPathOrUrl);
33
- // Handle Go backend response format
34
- const featuresArray = response.data.toggles || response.data.value || [];
35
-
36
- // Convert array to keyed object and handle capitalized field names
37
- this.data = {};
38
- if (Array.isArray(featuresArray)) {
39
- featuresArray.forEach((feature: any) => {
40
- const normalizedFeature = {
41
- key: feature.key || feature.Key,
42
- value: feature.value || feature.Value,
43
- activeAt: feature.activeAt || feature.ActiveAt,
44
- disabledAt: feature.disabledAt || feature.DisabledAt,
45
- tags: feature.tags || feature.Tags || [],
46
- };
47
- if (normalizedFeature.key) {
48
- this.data[normalizedFeature.key] = normalizedFeature;
49
- }
50
- });
51
- } else {
52
- // Fallback for object format
53
- this.data = featuresArray;
54
- }
41
+ // The mapping rules live in the core, next to the evaluation rules,
42
+ // rather than being reimplemented per provider.
43
+ this.data = normaliseCollection(response.data);
55
44
  } catch (error) {
56
45
  console.error("Failed to fetch feature toggle from API:", error);
57
46
  }
58
47
  }
59
48
 
60
49
  isEnabled(key: string): boolean {
61
- const feature = this.data[key];
62
-
63
- if (feature === undefined || feature === null) return false;
64
-
65
- // First check: value must be "true"
66
- if (feature.value !== "true") return false;
67
-
68
- // Second check: if activeAt is set and in future, not yet active
69
- if (feature.activeAt && feature.activeAt !== "") {
70
- const activeTime = Date.parse(feature.activeAt);
71
- if (!isNaN(activeTime) && Date.now() < activeTime) {
72
- return false;
73
- }
74
- }
75
-
76
- // Third check: if disabledAt is set and in past, already disabled
77
- if (feature.disabledAt && feature.disabledAt !== "") {
78
- const disabledTime = Date.parse(feature.disabledAt);
79
- if (!isNaN(disabledTime) && Date.now() >= disabledTime) {
80
- return false;
81
- }
82
- }
83
-
84
- // All checks passed, feature is enabled
85
- return true;
50
+ return evaluate(this.data[key], this.clock());
86
51
  }
87
52
  }
@@ -1,9 +1,17 @@
1
+ import { Clock, evaluate, systemClock } from "../evaluate";
1
2
  import { Feature, FeatureProvider } from "../FeatureToggle";
2
3
 
3
4
  export class LocalStorageFeatureProvider implements FeatureProvider<Feature> {
4
5
  data: Record<string, Feature> = {};
5
-
6
- constructor(configPath: string) {
6
+ private readonly clock: Clock;
7
+
8
+ /**
9
+ * @param configPath path passed to `require()`
10
+ * @param clock source of the current time; override it to evaluate against a
11
+ * fixed instant in tests
12
+ */
13
+ constructor(configPath: string, clock: Clock = systemClock) {
14
+ this.clock = clock;
7
15
  this.getConfig(configPath);
8
16
  }
9
17
 
@@ -18,30 +26,6 @@ export class LocalStorageFeatureProvider implements FeatureProvider<Feature> {
18
26
  }
19
27
 
20
28
  isEnabled(key: string): boolean {
21
- const feature = this.data[key] as Feature;
22
-
23
- if (feature === undefined || feature === null) return false;
24
-
25
- // First check: value must be "true"
26
- if (feature.value !== "true") return false;
27
-
28
- // Second check: if activeAt is set and in future, not yet active
29
- if (feature.activeAt && feature.activeAt !== "") {
30
- const activeTime = Date.parse(feature.activeAt);
31
- if (!isNaN(activeTime) && Date.now() < activeTime) {
32
- return false;
33
- }
34
- }
35
-
36
- // Third check: if disabledAt is set and in past, already disabled
37
- if (feature.disabledAt && feature.disabledAt !== "") {
38
- const disabledTime = Date.parse(feature.disabledAt);
39
- if (!isNaN(disabledTime) && Date.now() >= disabledTime) {
40
- return false;
41
- }
42
- }
43
-
44
- // All checks passed, feature is enabled
45
- return true;
29
+ return evaluate(this.data[key], this.clock());
46
30
  }
47
31
  }
package/src/index.ts CHANGED
@@ -4,3 +4,5 @@ export {
4
4
  FeatureToggleBase,
5
5
  FeatureProvider,
6
6
  } from "./FeatureToggle";
7
+ export { Clock, evaluate, parseTimestamp, systemClock } from "./evaluate";
8
+ export { normaliseCollection, normaliseFeature } from "./mapping";