feat: close application architecture gaps
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@wrnexus/metering",
|
||||
"version": "0.8.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
export interface EntitlementPlan {
|
||||
code: string;
|
||||
name: string;
|
||||
features: readonly string[];
|
||||
allowance?: number;
|
||||
price?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface EntitlementsOptions<TPlan extends EntitlementPlan> {
|
||||
plans: () => Promise<readonly TPlan[]> | readonly TPlan[];
|
||||
subscriptionFor: (subjectId: string) => Promise<string | null | undefined>;
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
/** Resolve plans, features, and allowances from one server-owned catalog. */
|
||||
export function defineEntitlements<TPlan extends EntitlementPlan>(
|
||||
options: EntitlementsOptions<TPlan>,
|
||||
) {
|
||||
const all = async () => [...(await options.plans())];
|
||||
const planFor = async (subjectId: string): Promise<TPlan> => {
|
||||
const plans = await all();
|
||||
const requested = await options.subscriptionFor(subjectId);
|
||||
const plan =
|
||||
plans.find((entry) => entry.code === requested) ??
|
||||
plans.find((entry) => entry.code === options.fallback);
|
||||
if (!plan) throw new Error(`WRN-ENTITLEMENTS: fallback plan '${options.fallback}' is missing`);
|
||||
return plan;
|
||||
};
|
||||
return {
|
||||
plans: all,
|
||||
planFor,
|
||||
async enabled(subjectId: string, feature: string) {
|
||||
return (await planFor(subjectId)).features.includes(feature);
|
||||
},
|
||||
async allowance(subjectId: string) {
|
||||
return Number((await planFor(subjectId)).allowance ?? 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface MeterPack {
|
||||
code: string;
|
||||
units: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Prototype-safe, immutable pack lookup; clients select a code, never an amount. */
|
||||
export function definePacks<TPack extends MeterPack>(entries: readonly TPack[]) {
|
||||
const packs = new Map<string, Readonly<TPack>>();
|
||||
for (const entry of entries) {
|
||||
if (!entry.code.trim()) throw new TypeError("meter pack code cannot be empty");
|
||||
if (!Number.isSafeInteger(entry.units) || entry.units < 1)
|
||||
throw new RangeError(`meter pack '${entry.code}' units must be a positive safe integer`);
|
||||
if (packs.has(entry.code)) throw new TypeError(`duplicate meter pack '${entry.code}'`);
|
||||
packs.set(entry.code, Object.freeze({ ...entry }));
|
||||
}
|
||||
return Object.freeze({
|
||||
get: (code: string) => packs.get(code),
|
||||
has: (code: string) => packs.has(code),
|
||||
list: () => [...packs.values()],
|
||||
});
|
||||
}
|
||||
|
||||
export type MeterResult = { ok: true } | { ok: false; reason: string; fault?: true };
|
||||
export type MeterKind = "grant" | "purchase" | "refund" | "adjust";
|
||||
|
||||
export interface MeterStore {
|
||||
balance(subjectId: string): Promise<number>;
|
||||
reserve(subjectId: string, units: number, reason: string, reference: string): Promise<boolean>;
|
||||
write(
|
||||
subjectId: string,
|
||||
units: number,
|
||||
kind: MeterKind,
|
||||
reason: string,
|
||||
reference: string,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MeterOptions {
|
||||
store: MeterStore;
|
||||
label?: string;
|
||||
onFault?: (error: unknown, operation: string) => void;
|
||||
}
|
||||
|
||||
/** Non-throwing metering facade with validated units and explicit storage faults. */
|
||||
export function defineMeter(options: MeterOptions) {
|
||||
const label = options.label ?? "units";
|
||||
const valid = (units: number): MeterResult | null =>
|
||||
Number.isSafeInteger(units) && units > 0
|
||||
? null
|
||||
: { ok: false, reason: `${label} must be a positive whole number` };
|
||||
const fault = (error: unknown, operation: string): MeterResult => {
|
||||
options.onFault?.(error, operation);
|
||||
return {
|
||||
ok: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
fault: true,
|
||||
};
|
||||
};
|
||||
const add = async (
|
||||
kind: MeterKind,
|
||||
subjectId: string,
|
||||
units: number,
|
||||
reason: string,
|
||||
reference = "",
|
||||
): Promise<MeterResult> => {
|
||||
const invalid = valid(units);
|
||||
if (invalid) return invalid;
|
||||
try {
|
||||
await options.store.write(subjectId, units, kind, reason, reference);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return fault(error, kind);
|
||||
}
|
||||
};
|
||||
return {
|
||||
balance: options.store.balance,
|
||||
async reserve(
|
||||
subjectId: string,
|
||||
units: number,
|
||||
reason: string,
|
||||
reference = "",
|
||||
): Promise<MeterResult> {
|
||||
const invalid = valid(units);
|
||||
if (invalid) return invalid;
|
||||
try {
|
||||
return (await options.store.reserve(subjectId, units, reason, reference))
|
||||
? { ok: true }
|
||||
: { ok: false, reason: "insufficient balance" };
|
||||
} catch (error) {
|
||||
return fault(error, "reserve");
|
||||
}
|
||||
},
|
||||
grant: (subjectId: string, units: number, reason: string, reference = "") =>
|
||||
add("grant", subjectId, units, reason, reference),
|
||||
purchase: (subjectId: string, units: number, reason: string, reference = "") =>
|
||||
add("purchase", subjectId, units, reason, reference),
|
||||
refund: (subjectId: string, units: number, reason: string, reference = "") =>
|
||||
add("refund", subjectId, units, reason, reference),
|
||||
adjust: (subjectId: string, units: number, reason: string, reference = "") =>
|
||||
add("adjust", subjectId, units, reason, reference),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { defineEntitlements, defineMeter, definePacks } from "../src/index.ts";
|
||||
|
||||
test("resolves fallback entitlements and features", async () => {
|
||||
const entitlements = defineEntitlements({
|
||||
plans: () => [
|
||||
{ code: "free", name: "Free", features: [], allowance: 10 },
|
||||
{ code: "pro", name: "Pro", features: ["schedule"], allowance: 100 },
|
||||
],
|
||||
subscriptionFor: async () => "pro",
|
||||
fallback: "free",
|
||||
});
|
||||
expect(await entitlements.enabled("u1", "schedule")).toBe(true);
|
||||
expect(await entitlements.allowance("u1")).toBe(100);
|
||||
});
|
||||
|
||||
test("packs are immutable and selected only by code", () => {
|
||||
const packs = definePacks([{ code: "small", units: 100, label: "Small" }]);
|
||||
expect(packs.get("constructor")).toBeUndefined();
|
||||
expect(packs.get("small")?.units).toBe(100);
|
||||
});
|
||||
|
||||
test("meter distinguishes refusals from storage faults", async () => {
|
||||
const meter = defineMeter({
|
||||
store: {
|
||||
balance: async () => 5,
|
||||
reserve: async (_subject, units) => units <= 5,
|
||||
write: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await meter.reserve("u1", 6, "send")).toEqual({
|
||||
ok: false,
|
||||
reason: "insufficient balance",
|
||||
});
|
||||
expect(await meter.grant("u1", 5, "plan")).toEqual({
|
||||
ok: false,
|
||||
reason: "offline",
|
||||
fault: true,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user