@tehw0lf/yaft 0.0.15 → 0.0.16

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.
Files changed (50) hide show
  1. package/FeatureToggle.d.ts +20 -0
  2. package/FeatureToggle.js +72 -0
  3. package/README.md +10 -1
  4. package/evaluate.d.ts +42 -0
  5. package/evaluate.js +102 -0
  6. package/examples/ApiServiceBooleanProvider.d.ts +11 -0
  7. package/examples/ApiServiceBooleanProvider.js +82 -0
  8. package/examples/ApiServiceFeatureProvider.d.ts +17 -0
  9. package/examples/ApiServiceFeatureProvider.js +54 -0
  10. package/examples/LocalStorageBooleanProvider.d.ts +7 -0
  11. package/examples/LocalStorageBooleanProvider.js +26 -0
  12. package/examples/LocalStorageFeatureProvider.d.ts +14 -0
  13. package/examples/LocalStorageFeatureProvider.js +31 -0
  14. package/{src/index.ts → index.d.ts} +1 -6
  15. package/index.js +13 -0
  16. package/mapping.d.ts +27 -0
  17. package/mapping.js +79 -0
  18. package/package.json +21 -9
  19. package/.github/workflows/build.yml +0 -28
  20. package/.github/workflows/security-scan.yml +0 -79
  21. package/CLAUDE.md +0 -151
  22. package/LICENSE +0 -21
  23. package/conformance.lock +0 -2
  24. package/jest.config.js +0 -16
  25. package/scripts/fetch-conformance.sh +0 -67
  26. package/src/FeatureToggle.ts +0 -95
  27. package/src/evaluate.ts +0 -122
  28. package/src/examples/ApiServiceBooleanProvider.ts +0 -82
  29. package/src/examples/ApiServiceFeatureProvider.ts +0 -52
  30. package/src/examples/LocalStorageBooleanProvider.ts +0 -25
  31. package/src/examples/LocalStorageFeatureProvider.ts +0 -31
  32. package/src/mapping.ts +0 -92
  33. package/src/test/conformance-adapter/cases.ts +0 -104
  34. package/src/test/conformance-adapter/decorator.spec.ts +0 -376
  35. package/src/test/conformance-adapter/evaluation.spec.ts +0 -31
  36. package/src/test/conformance-adapter/mapping.spec.ts +0 -79
  37. package/src/test/decorator-behavior.spec.ts +0 -359
  38. package/src/test/error-handling.spec.ts +0 -396
  39. package/src/test/evaluate.spec.ts +0 -260
  40. package/src/test/feature-data.spec.ts +0 -102
  41. package/src/test/injectable-clock.spec.ts +0 -112
  42. package/src/test/integration.spec.ts +0 -472
  43. package/src/test/providers.spec.ts +0 -218
  44. package/src/test/test-boolean.json +0 -1
  45. package/src/test/test-feature.json +0 -20
  46. package/src/test/test-setup.ts +0 -2
  47. package/src/test/time-based-logic.spec.ts +0 -169
  48. package/src/test/yaft.demo.spec.ts +0 -53
  49. package/tsconfig.json +0 -14
  50. package/tsconfig.spec.json +0 -10
@@ -1,82 +0,0 @@
1
- import axios from "axios";
2
-
3
- import { FeatureProvider } from "../FeatureToggle";
4
- import { normaliseCollection } from "../mapping";
5
-
6
- export class ApiServiceBooleanProvider implements FeatureProvider<boolean> {
7
- apiUrl: string;
8
- baseUUID: string;
9
- data: Record<string, boolean> = {};
10
- collectionHash = "";
11
-
12
- constructor(apiUrl: string, baseUUID: string) {
13
- this.apiUrl = apiUrl;
14
- this.baseUUID = baseUUID;
15
- this.getCollectionHash(`${this.apiUrl}/collectionHash/${this.baseUUID}`);
16
- }
17
-
18
- async getCollectionHash(configPathOrUrl: string): Promise<void> {
19
- try {
20
- const response = await axios.get(configPathOrUrl);
21
- const newHash = response.data.collectionHash || response.data.value;
22
- if (this.collectionHash !== newHash) {
23
- this.collectionHash = newHash;
24
- await this.getConfig(`${this.apiUrl}/features/${this.baseUUID}`);
25
- }
26
- } catch (error) {
27
- console.error("Failed to fetch feature toggle from API:", error);
28
- }
29
- }
30
-
31
- async getConfig(configPathOrUrl: string): Promise<void> {
32
- try {
33
- const response = await axios.get(configPathOrUrl);
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);
53
- this.data = {};
54
- for (const [key, feature] of Object.entries(features)) {
55
- this.data[key] = feature.value === 'true';
56
- }
57
- } catch (error) {
58
- console.error("Failed to fetch feature toggle from API:", error);
59
- }
60
- }
61
-
62
- isEnabled(key: string): boolean {
63
- const feature = this.data[key];
64
- if (feature === undefined || feature === null) return false;
65
- return feature;
66
- }
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,52 +0,0 @@
1
- import axios from "axios";
2
-
3
- import { Clock, evaluate, systemClock } from "../evaluate";
4
- import { normaliseCollection } from "../mapping";
5
- import { Feature, FeatureProvider } from "../FeatureToggle";
6
-
7
- export class ApiServiceFeatureProvider implements FeatureProvider<Feature> {
8
- apiUrl: string;
9
- baseUUID: string;
10
- data: Record<string, Feature> = {};
11
- collectionHash = "";
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;
20
- this.apiUrl = apiUrl;
21
- this.baseUUID = baseUUID;
22
- this.getCollectionHash(`${this.apiUrl}/collectionHash/${this.baseUUID}`);
23
- }
24
-
25
- async getCollectionHash(configPathOrUrl: string): Promise<void> {
26
- try {
27
- const response = await axios.get(configPathOrUrl);
28
- const newHash = response.data.collectionHash || response.data.value;
29
- if (this.collectionHash !== newHash) {
30
- this.collectionHash = newHash;
31
- await this.getConfig(`${this.apiUrl}/features/${this.baseUUID}`);
32
- }
33
- } catch (error) {
34
- console.error("Failed to fetch feature toggle from API:", error);
35
- }
36
- }
37
-
38
- async getConfig(configPathOrUrl: string): Promise<void> {
39
- try {
40
- const response = await axios.get(configPathOrUrl);
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);
44
- } catch (error) {
45
- console.error("Failed to fetch feature toggle from API:", error);
46
- }
47
- }
48
-
49
- isEnabled(key: string): boolean {
50
- return evaluate(this.data[key], this.clock());
51
- }
52
- }
@@ -1,25 +0,0 @@
1
- import { FeatureProvider } from "../FeatureToggle";
2
-
3
- export class LocalStorageBooleanProvider implements FeatureProvider<boolean> {
4
- data: Record<string, boolean> = {};
5
-
6
- constructor(configPath: string) {
7
- this.getConfig(configPath);
8
- }
9
-
10
- getConfig(configPathOrUrl: string): void {
11
- try {
12
- const configData = require(configPathOrUrl);
13
- this.data = configData;
14
- } catch (error) {
15
- console.error("Failed to load configuration from local file:", error);
16
- this.data = {};
17
- }
18
- }
19
-
20
- isEnabled(key: string): boolean {
21
- const feature = this.data[key];
22
- if (feature === undefined || feature === null) return false;
23
- return feature;
24
- }
25
- }
@@ -1,31 +0,0 @@
1
- import { Clock, evaluate, systemClock } from "../evaluate";
2
- import { Feature, FeatureProvider } from "../FeatureToggle";
3
-
4
- export class LocalStorageFeatureProvider implements FeatureProvider<Feature> {
5
- data: Record<string, Feature> = {};
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;
15
- this.getConfig(configPath);
16
- }
17
-
18
- getConfig(configPathOrUrl: string): void {
19
- try {
20
- const configData = require(configPathOrUrl);
21
- this.data = configData;
22
- } catch (error) {
23
- console.error("Failed to load configuration from local file:", error);
24
- this.data = {};
25
- }
26
- }
27
-
28
- isEnabled(key: string): boolean {
29
- return evaluate(this.data[key], this.clock());
30
- }
31
- }
package/src/mapping.ts DELETED
@@ -1,92 +0,0 @@
1
- import { Feature } from './FeatureToggle';
2
-
3
- /**
4
- * Turns a backend response into provider data.
5
- *
6
- * This is the single definition of YaFT's mapping rules, the way `evaluate` is
7
- * the single definition of the evaluation rules. Providers call it instead of
8
- * unpacking responses themselves, so every provider -- and every port that
9
- * mirrors it -- agrees on what a response means.
10
- */
11
-
12
- /** A raw entry as it arrives over the wire, in either field spelling. */
13
- type RawFeature = Record<string, unknown>;
14
-
15
- /**
16
- * Reads a field by presence, not by truthiness.
17
- *
18
- * The obvious `raw.value || raw.Value` is wrong: a present but empty value
19
- * falls through to the other spelling, so a feature stored as `""` reads as
20
- * whatever the capitalised field holds. An off feature then reports on. The
21
- * same trap applies to `tags: []`.
22
- *
23
- * Backends from 0.2.0 on send only the lowercase spelling; the capitalised one
24
- * is read because instances before that are still around.
25
- */
26
- function field(raw: RawFeature, lower: string, upper: string): unknown {
27
- if (lower in raw) return raw[lower];
28
- if (upper in raw) return raw[upper];
29
- return undefined;
30
- }
31
-
32
- /**
33
- * Normalises a date field. The backend sends `null` for an unset bound and
34
- * local fixtures use `""`; both mean "no bound", and `evaluate` ignores either.
35
- */
36
- function date(value: unknown): string {
37
- return typeof value === 'string' ? value : '';
38
- }
39
-
40
- /** Normalises one entry into a `Feature`, whichever spelling it arrived in. */
41
- export function normaliseFeature(raw: RawFeature): Feature {
42
- const tags = field(raw, 'tags', 'Tags');
43
-
44
- return {
45
- key: String(field(raw, 'key', 'Key') ?? ''),
46
- value: String(field(raw, 'value', 'Value') ?? ''),
47
- activeAt: date(field(raw, 'activeAt', 'ActiveAt')),
48
- disabledAt: date(field(raw, 'disabledAt', 'DisabledAt')),
49
- // Filtered rather than asserted: `as string[]` is a compile-time claim
50
- // that a backend sending a mixed array would quietly break, handing
51
- // callers a non-string through a field typed as string.
52
- tags: Array.isArray(tags)
53
- ? tags.filter((tag): tag is string => typeof tag === 'string')
54
- : [],
55
- };
56
- }
57
-
58
- /**
59
- * Normalises a whole response into features keyed by their key.
60
- *
61
- * Three envelopes are accepted, because the backend uses all three:
62
- *
63
- * - `{ "toggles": [...] }` for a UUID group;
64
- * - `{ "value": [...] }`, the same thing under a different name;
65
- * - a flat object for a single toggle.
66
- *
67
- * An entry without a usable key is skipped rather than stored under `""`,
68
- * where `isEnabled("")` could reach it.
69
- */
70
- export function normaliseCollection(response: unknown): Record<string, Feature> {
71
- if (response === null || typeof response !== 'object') return {};
72
-
73
- const body = response as Record<string, unknown>;
74
- const collection = Array.isArray(body['toggles'])
75
- ? body['toggles']
76
- : Array.isArray(body['value'])
77
- ? body['value']
78
- : undefined;
79
-
80
- // A single toggle comes back flat, not wrapped. Older code only handled the
81
- // collections and dropped this shape entirely.
82
- const entries = collection ?? [body];
83
-
84
- const data: Record<string, Feature> = {};
85
- for (const entry of entries) {
86
- if (entry === null || typeof entry !== 'object') continue;
87
-
88
- const feature = normaliseFeature(entry as RawFeature);
89
- if (feature.key !== '') data[feature.key] = feature;
90
- }
91
- return data;
92
- }
@@ -1,104 +0,0 @@
1
- import { existsSync, readFileSync } from 'fs';
2
- import { join } from 'path';
3
-
4
- /**
5
- * Loads the conformance case files.
6
- *
7
- * The cases live in `cases/`, fetched by `scripts/fetch-conformance.sh` from
8
- * the version pinned in `conformance.lock`. They are not checked in: the lock
9
- * file plus its checksum is what makes a suite bump a reviewable one-line
10
- * diff, and a committed copy would drift from the tag it claims to be.
11
- */
12
-
13
- const CASES_DIR = join(__dirname, '..', 'conformance', 'cases');
14
-
15
- /** A rule id from SPEC.md, such as `R5` or `R22a`. */
16
- export type Rule = string;
17
-
18
- interface CaseFile<T> {
19
- suite: string;
20
- version: number;
21
- cases: T[];
22
- }
23
-
24
- export interface EvaluationCase {
25
- name: string;
26
- rules: Rule[];
27
- why?: string;
28
- now: string;
29
- features: Record<string, unknown>;
30
- key: string;
31
- expected: boolean;
32
- }
33
-
34
- export interface DecoratorCase {
35
- name: string;
36
- rules: Rule[];
37
- why?: string;
38
- target: 'class' | 'method' | 'async-method';
39
- toggle: 'on' | 'off' | 'on-then-off' | 'off-then-on' | 'no-provider';
40
- fallback: 'none' | 'class' | 'method';
41
- expected:
42
- | 'original'
43
- | 'fallback'
44
- | 'nothing'
45
- | 'resolved-nothing'
46
- | 'empty-shell'
47
- | 'decoration-error';
48
- assertions?: ('same-arguments' | 'same-receiver')[];
49
- }
50
-
51
- export interface MappingCase {
52
- name: string;
53
- rules: Rule[];
54
- why?: string;
55
- shape: 'feature' | 'boolean';
56
- response: unknown;
57
- expected: Record<string, unknown>;
58
- }
59
-
60
- function load<T>(suite: string): CaseFile<T> {
61
- const path = join(CASES_DIR, `${suite}.json`);
62
-
63
- if (!existsSync(path)) {
64
- throw new Error(
65
- `Conformance cases missing at ${path}.\n` +
66
- 'Run scripts/fetch-conformance.sh before the tests; CI does this in a ' +
67
- 'dedicated step.'
68
- );
69
- }
70
-
71
- const file = JSON.parse(readFileSync(path, 'utf8')) as CaseFile<T>;
72
- if (file.suite !== suite) {
73
- throw new Error(`${path} declares suite "${file.suite}", expected "${suite}"`);
74
- }
75
- return file;
76
- }
77
-
78
- export const evaluationCases = () => load<EvaluationCase>('evaluation').cases;
79
- export const decoratorCases = () => load<DecoratorCase>('decorator').cases;
80
- export const mappingCases = () => load<MappingCase>('mapping').cases;
81
-
82
- /**
83
- * Names a case for the test output, so a failure points at a rule without
84
- * anyone having to open the case file.
85
- */
86
- export function title(c: { name: string; rules: Rule[] }): string {
87
- return `${c.name} [${c.rules.join(', ')}]`;
88
- }
89
-
90
- /**
91
- * Rejects a value the adapter does not know how to handle.
92
- *
93
- * A case whose `target`, `toggle` or `expected` this adapter has never seen is
94
- * a rule that nothing enforces here. Skipping it would leave the suite looking
95
- * green while a requirement goes unchecked, so an unknown value is a failure
96
- * instead -- it means the suite grew and the adapter has to catch up.
97
- */
98
- export function unsupported(kind: string, value: string, caseName: string): never {
99
- throw new Error(
100
- `Case "${caseName}" uses ${kind} "${value}", which this adapter does not ` +
101
- 'implement. The conformance suite has gained a case this port does not ' +
102
- 'cover yet -- extend the adapter rather than skipping the case.'
103
- );
104
- }