Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
9 changes: 9 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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<const T extends ConfigInput>(config: T): T {
return config;
}
10 changes: 5 additions & 5 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
177 changes: 177 additions & 0 deletions src/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
64 changes: 64 additions & 0 deletions src/resolve.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

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<string, Limit> = { ...base.limits, ...(plan.limits ?? {}) };

onPath.delete(name);

return { features: [...featureSet], limits };
}

return walk(planName, planName);
}
37 changes: 37 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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<string, Limit>;
}

export interface ConfigInput {
plans: Record<string, PlanInput>;
}

export interface ResolvedPlan {
features: string[];
limits: Record<string, Limit>;
}

// 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<TLimitKey, Limit>;
}
>;
};
Loading
Loading