From c00060f40c4855a8f35398b7d49d1aca4f799db4 Mon Sep 17 00:00:00 2001 From: EzekielApetu Date: Sat, 16 May 2026 18:39:58 +0100 Subject: [PATCH] feat: add core types, defineConfig, and resolvePlan with inheritance --- src/config.test.ts | 49 ++++++++++++ src/config.ts | 9 +++ src/index.test.ts | 10 +-- src/index.ts | 4 +- src/resolve.test.ts | 177 ++++++++++++++++++++++++++++++++++++++++++ src/resolve.ts | 64 +++++++++++++++ src/types.ts | 37 +++++++++ tests/types.test-d.ts | 51 ++++++++++++ vitest.config.ts | 5 ++ 9 files changed, 399 insertions(+), 7 deletions(-) create mode 100644 src/config.test.ts create mode 100644 src/config.ts create mode 100644 src/resolve.test.ts create mode 100644 src/resolve.ts create mode 100644 src/types.ts create mode 100644 tests/types.test-d.ts diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..047b545 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { defineConfig } from './config.js'; + +describe('defineConfig', () => { + it('returns the config object unchanged (identity)', () => { + const cfg = defineConfig({ + plans: { + free: { features: ['read'] }, + pro: { features: ['read', 'write'] }, + }, + }); + expect(cfg.plans.free.features).toEqual(['read']); + expect(cfg.plans.pro.features).toEqual(['read', 'write']); + }); + + it('preserves limits on plans', () => { + const cfg = defineConfig({ + plans: { + pro: { + features: ['export'], + limits: { seats: { quota: 5, period: 'monthly' } }, + }, + }, + }); + expect(cfg.plans.pro.limits?.seats).toEqual({ quota: 5, period: 'monthly' }); + }); + + it('preserves extends field', () => { + const cfg = defineConfig({ + plans: { + free: { features: ['read'] }, + pro: { extends: 'free', features: ['write'] }, + }, + }); + expect(cfg.plans.pro.extends).toBe('free'); + }); + + it('preserves null quota', () => { + const cfg = defineConfig({ + plans: { + enterprise: { + features: ['all'], + limits: { seats: { quota: null, period: 'yearly' } }, + }, + }, + }); + expect(cfg.plans.enterprise.limits?.seats.quota).toBeNull(); + }); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..daf5cf2 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,9 @@ +import type { ConfigInput } from './types.js'; + +/** + * Identity function that preserves literal types from the static config object. + * Pass a `const`-asserted or inline object literal to get full literal inference. + */ +export function defineConfig(config: T): T { + return config; +} diff --git a/src/index.test.ts b/src/index.test.ts index 8c245f1..d16961c 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,8 +1,8 @@ -import { describe, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; -// scaffold placeholder — real tests added in PR 2 and PR 3 -describe('tiergraph', () => { - it('module loads', async () => { - await import('./index.js'); +describe('public exports', () => { + it('exports defineConfig', async () => { + const mod = await import('./index.js'); + expect(typeof mod.defineConfig).toBe('function'); }); }); diff --git a/src/index.ts b/src/index.ts index 61c9878..8f2c1c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ -// tiergraph public API — exports added in subsequent PRs -export {}; +export { defineConfig } from './config.js'; +export type { Config, ConfigInput, Limit, Period, PlanInput, ResolvedPlan } from './types.js'; diff --git a/src/resolve.test.ts b/src/resolve.test.ts new file mode 100644 index 0000000..1a240f1 --- /dev/null +++ b/src/resolve.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; +import { CircularExtendsError, UnknownPlanError, resolvePlan } from './resolve.js'; + +const baseConfig = { + plans: { + free: { + features: ['read'], + limits: { apiCalls: { quota: 100, period: 'monthly' as const } }, + }, + pro: { + extends: 'free', + features: ['write'], + limits: { apiCalls: { quota: 1000, period: 'monthly' as const } }, + }, + enterprise: { + extends: 'pro', + features: ['admin'], + limits: { apiCalls: { quota: null, period: 'monthly' as const } }, + }, + }, +}; + +describe('resolvePlan — feature accumulation', () => { + it('depth 0: returns own features only when no extends', () => { + const result = resolvePlan(baseConfig, 'free'); + expect(result.features).toEqual(expect.arrayContaining(['read'])); + expect(result.features).toHaveLength(1); + }); + + it('depth 1: accumulates parent features', () => { + const result = resolvePlan(baseConfig, 'pro'); + expect(result.features).toEqual(expect.arrayContaining(['read', 'write'])); + expect(result.features).toHaveLength(2); + }); + + it('depth 2: accumulates grandparent + parent + own features', () => { + const result = resolvePlan(baseConfig, 'enterprise'); + expect(result.features).toEqual(expect.arrayContaining(['read', 'write', 'admin'])); + expect(result.features).toHaveLength(3); + }); + + it('depth 3: accumulates through three levels', () => { + const cfg = { + plans: { + a: { features: ['a1'] }, + b: { extends: 'a', features: ['b1'] }, + c: { extends: 'b', features: ['c1'] }, + d: { extends: 'c', features: ['d1'] }, + }, + }; + const result = resolvePlan(cfg, 'd'); + expect(result.features).toEqual(expect.arrayContaining(['a1', 'b1', 'c1', 'd1'])); + expect(result.features).toHaveLength(4); + }); + + it('deduplicates features that appear in both ancestor and descendant', () => { + const cfg = { + plans: { + base: { features: ['read', 'write'] }, + child: { extends: 'base', features: ['read', 'admin'] }, + }, + }; + const result = resolvePlan(cfg, 'child'); + expect(result.features).toEqual(expect.arrayContaining(['read', 'write', 'admin'])); + expect(result.features).toHaveLength(3); + }); +}); + +describe('resolvePlan — limit inheritance', () => { + it('inherits limits from ancestor when not overridden', () => { + const cfg = { + plans: { + free: { features: [], limits: { seats: { quota: 1, period: 'monthly' as const } } }, + pro: { extends: 'free', features: [] }, + }, + }; + const result = resolvePlan(cfg, 'pro'); + expect(result.limits.seats).toEqual({ quota: 1, period: 'monthly' }); + }); + + it('descendant limit overrides ancestor limit on same key', () => { + const result = resolvePlan(baseConfig, 'pro'); + expect(result.limits.apiCalls?.quota).toBe(1000); + }); + + it('deepest plan limit wins when all three levels define the same key', () => { + const result = resolvePlan(baseConfig, 'enterprise'); + expect(result.limits.apiCalls?.quota).toBeNull(); + }); + + it('preserves ancestor-only limit keys alongside descendant overrides', () => { + const cfg = { + plans: { + free: { + features: [], + limits: { + seats: { quota: 1, period: 'monthly' as const }, + storage: { quota: 500, period: 'monthly' as const }, + }, + }, + pro: { + extends: 'free', + features: [], + limits: { seats: { quota: 10, period: 'monthly' as const } }, + }, + }, + }; + const result = resolvePlan(cfg, 'pro'); + expect(result.limits.seats?.quota).toBe(10); + expect(result.limits.storage?.quota).toBe(500); + }); + + it('null quota (unlimited) round-trips correctly', () => { + const result = resolvePlan(baseConfig, 'enterprise'); + expect(result.limits.apiCalls?.quota).toBeNull(); + }); +}); + +describe('resolvePlan — error cases', () => { + it('throws UnknownPlanError when root plan does not exist', () => { + expect(() => resolvePlan(baseConfig, 'ghost')).toThrow(UnknownPlanError); + }); + + it('throws UnknownPlanError when extends target does not exist', () => { + const cfg = { + plans: { + broken: { extends: 'nonexistent', features: [] }, + }, + }; + expect(() => resolvePlan(cfg, 'broken')).toThrow(UnknownPlanError); + }); + + it('UnknownPlanError message names the missing plan', () => { + const cfg = { + plans: { + broken: { extends: 'ghost', features: [] }, + }, + }; + expect(() => resolvePlan(cfg, 'broken')).toThrow(/ghost/); + }); + + it('throws CircularExtendsError on a direct self-reference', () => { + const cfg = { + plans: { + loop: { extends: 'loop', features: [] }, + }, + }; + expect(() => resolvePlan(cfg, 'loop')).toThrow(CircularExtendsError); + }); + + it('throws CircularExtendsError on an indirect cycle (A → B → A)', () => { + const cfg = { + plans: { + a: { extends: 'b', features: [] }, + b: { extends: 'a', features: [] }, + }, + }; + expect(() => resolvePlan(cfg, 'a')).toThrow(CircularExtendsError); + }); + + it('CircularExtendsError message includes the cycle chain', () => { + const cfg = { + plans: { + a: { extends: 'b', features: [] }, + b: { extends: 'a', features: [] }, + }, + }; + expect(() => resolvePlan(cfg, 'a')).toThrow(/a/); + }); + + it('resolves a plan that has no features or limits', () => { + const cfg = { plans: { empty: { features: [] } } }; + const result = resolvePlan(cfg, 'empty'); + expect(result.features).toEqual([]); + expect(result.limits).toEqual({}); + }); +}); diff --git a/src/resolve.ts b/src/resolve.ts new file mode 100644 index 0000000..4445a2c --- /dev/null +++ b/src/resolve.ts @@ -0,0 +1,64 @@ +import type { ConfigInput, Limit, ResolvedPlan } from './types.js'; + +export class CircularExtendsError extends Error { + readonly name = 'CircularExtendsError'; + constructor(chain: string[]) { + super(`Circular extends detected: ${chain.join(' → ')}`); + } +} + +export class UnknownPlanError extends Error { + readonly name = 'UnknownPlanError'; + constructor(plan: string, referencedBy?: string) { + super( + referencedBy !== undefined + ? `Plan "${plan}" referenced in extends of "${referencedBy}" does not exist` + : `Plan "${plan}" does not exist`, + ); + } +} + +/** + * Walks the extends chain for `planName` and returns the fully merged + * effective feature set and limits. Ancestor values are base; descendants + * override limits and accumulate features. + */ +export function resolvePlan(config: ConfigInput, planName: string): ResolvedPlan { + if (config.plans[planName] === undefined) { + throw new UnknownPlanError(planName); + } + + // Tracks the current DFS path to detect cycles + const onPath = new Set(); + + function walk(name: string, referencedBy: string): ResolvedPlan { + if (onPath.has(name)) { + throw new CircularExtendsError([...onPath, name]); + } + + const plan = config.plans[name]; + if (plan === undefined) { + throw new UnknownPlanError(name, referencedBy); + } + + onPath.add(name); + + let base: ResolvedPlan = { features: [], limits: {} }; + + if (plan.extends !== undefined) { + base = walk(plan.extends, name); + } + + // Accumulate features (ancestor + current, deduplicated) + const featureSet = new Set([...base.features, ...plan.features]); + + // Descendant limits override ancestor limits on the same key + const limits: Record = { ...base.limits, ...(plan.limits ?? {}) }; + + onPath.delete(name); + + return { features: [...featureSet], limits }; + } + + return walk(planName, planName); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..c9dbdba --- /dev/null +++ b/src/types.ts @@ -0,0 +1,37 @@ +export type Period = 'monthly' | 'yearly' | 'lifetime'; + +export interface Limit { + quota: number | null; + period: Period; +} + +export interface PlanInput { + extends?: string; + features: string[]; + limits?: Record; +} + +export interface ConfigInput { + plans: Record; +} + +export interface ResolvedPlan { + features: string[]; + limits: Record; +} + +// Narrow config type that preserves literal unions from static defineConfig calls +export type Config< + TPlanName extends string = string, + TFeature extends string = string, + TLimitKey extends string = string, +> = { + plans: Record< + TPlanName, + { + extends?: TPlanName; + features: TFeature[]; + limits?: Record; + } + >; +}; diff --git a/tests/types.test-d.ts b/tests/types.test-d.ts new file mode 100644 index 0000000..53f5528 --- /dev/null +++ b/tests/types.test-d.ts @@ -0,0 +1,51 @@ +import { assertType, describe, expectTypeOf, it } from 'vitest'; +import { defineConfig } from '../src/index.js'; + +describe('defineConfig type inference', () => { + it('infers literal plan names from static config', () => { + const cfg = defineConfig({ + plans: { + free: { features: ['read'] as const }, + pro: { extends: 'free', features: ['write'] as const }, + }, + }); + + // Plan keys are literal types + expectTypeOf(cfg.plans).toHaveProperty('free'); + expectTypeOf(cfg.plans).toHaveProperty('pro'); + + // Features are literal string tuple, not string[] + expectTypeOf(cfg.plans.free.features).toEqualTypeOf(); + expectTypeOf(cfg.plans.pro.features).toEqualTypeOf(); + }); + + it('extends field accepts string (compatible with plan name)', () => { + const cfg = defineConfig({ + plans: { + free: { features: [] as const }, + pro: { extends: 'free', features: [] as const }, + }, + }); + assertType(cfg.plans.pro.extends); + }); + + it('limits quota accepts number or null', () => { + const cfg = defineConfig({ + plans: { + pro: { + features: [] as const, + limits: { + seats: { quota: 5, period: 'monthly' as const }, + storage: { quota: null, period: 'yearly' as const }, + }, + }, + }, + }); + // Access via a narrowed reference to avoid non-null assertion + const limits = cfg.plans.pro.limits; + if (limits !== undefined) { + assertType(limits.seats.quota); + assertType(limits.storage.quota); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 31c23c8..dc2442f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,9 +2,14 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + typecheck: { + enabled: true, + include: ['tests/**/*.test-d.ts'], + }, coverage: { provider: 'v8', include: ['src/**'], + exclude: ['src/**/*.test.ts'], thresholds: { lines: 100, functions: 100,