working on api versioning system

This commit is contained in:
John Yeo
2025-10-06 10:03:12 +01:00
parent 700e96bdcc
commit e7a78bfc8d
36 changed files with 2545 additions and 37 deletions

2
.gitignore vendored
View File

@@ -106,5 +106,5 @@ supabase/
migration.sh
stat.sh
CLAUDE.md
interview

32
CLAUDE.md Normal file
View File

@@ -0,0 +1,32 @@
# Basic rules
- Never run a "dev" or "build" command, chances are I'm already running it in the background. Just ask me to check for updates or whatever you need
- Never ever ever write a "TO DO" comment. If you've been told to do something, DO IT. Don't stop halfway. Never give up and just leave a "to do" comment and say - "haha heres working code :)" - that is unacceptible. Always finish your task, no matter how many iterations you need to perform.
# Linting and Codebase rules
- You can access the biome linter by running `npx biome check <folder or file path>`. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `npx biome check --write <folder or file path>`
- Note, biome does not perform typechecking. In which case you need to, you may run `tsc --noEmit --skipLibCheck <folder or file path>`
- This codebase uses Bun as its preferred package manager and Node runtime.
- Always prefer foo({ bar }) over foo(bar) method signatures - no matter if we are using only one argument or not, object as param are always better, as in the future when wanting to change the order of parameters, or add new ones - its easier.
- When creating "hooks" folders, don't nest them under "components"
- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand.
## Bad example
/ root
-> components
|-> hooks
## Good example
/ root
-> components
-> hooks
# Figma MCP guidance
- When you are using the Figma MCP server, you **must** follow our design system. Below is an example implementation of CVA with out design system
## File Naming
DON'T name files one word (like index.ts, model.ts, etc.). Give proper indication in the filename to which resource it's targeting. For example, a utility file for organizations should be named orgUtils.ts. This is because it's easier to search for files like this. That being said, the filename shouldn't be overly long (less than three words is ideal)

View File

@@ -1,33 +1,78 @@
import { ErrCode } from "@autumn/shared";
import {
ApiVersionClass,
ErrCode,
legacyToSemVer,
parseVersion,
} from "@autumn/shared";
import type { Context, Next } from "hono";
import semver, { type SemVer } from "semver";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import RecaseError from "@/utils/errorUtils.js";
import { floatToVersion } from "@/utils/versionUtils/legacyVersionUtils.js";
/**
* Middleware to verify and set API version from x-api-version header
* Middleware to verify and set API version
*
* Resolution order:
* 1. x-api-version header (if provided)
* 2. org.api_version (version system 2: 1.0, 1.1, 1.2, 1.4)
* 3. org.config.api_version (version system 1: 0.1, 0.2)
* 4. Default to org's default version
*
* Supports:
* - CalVer format (YYYY-MM-DD) e.g., "2025-04-17"
* - Legacy float format (X.X) e.g., "1.1"
* - SemVer format (X.Y.Z) e.g., "1.1.0"
*
* Stores ApiVersionClass instance in ctx.apiVersion for easy access:
* @example
* if (ctx.apiVersion.gte(ApiVersion.V1_1)) { ... }
*/
export const apiVersionMiddleware = async (c: Context<HonoEnv>, next: Next) => {
const ctx = c.get("ctx");
const version = c.req.header("x-api-version");
const versionHeader = c.req.header("x-api-version");
const org = ctx.org;
if (version) {
const versionFloat = parseFloat(version);
const apiVersion = floatToVersion(versionFloat);
let finalVersion: ApiVersionClass;
if (Number.isNaN(versionFloat) || !apiVersion) {
// 1. Check header first
if (versionHeader) {
const parsedVersion = parseVersion({ versionStr: versionHeader });
if (!parsedVersion) {
throw new RecaseError({
message: `${version} is not a valid API version`,
message: `"${versionHeader}" is not a valid API version. Use CalVer (e.g., "2025-04-17") or SemVer (e.g., "1.1.0")`,
code: ErrCode.InvalidApiVersion,
statusCode: 400,
});
}
// Store in context
// ctx.apiVersion = apiVersion.toString();
ctx.apiVersion = semver.parse(version) as SemVer;
// ctx.apiVersion.
finalVersion = new ApiVersionClass(parsedVersion);
}
// 2. Check org.api_version (version system 2)
else if (org?.api_version) {
const semver = legacyToSemVer({ legacyVersion: org.api_version });
if (semver) {
finalVersion = new ApiVersionClass(semver);
}
}
// 3. Check org.config.api_version (version system 1)
else if (org?.config?.api_version) {
const semver = legacyToSemVer({ legacyVersion: org.config.api_version });
if (semver) {
finalVersion = new ApiVersionClass(semver);
}
}
// 4. Fallback
if (!finalVersion) {
const legacyVersion = org?.api_version || org?.config?.api_version || 1.0;
const defaultVersion = legacyToSemVer({ legacyVersion });
if (defaultVersion) {
finalVersion = new ApiVersionClass(defaultVersion);
}
}
// Store in context - now you can do ctx.apiVersion.gte(ApiVersion.V1_1)
ctx.apiVersion = finalVersion;
await next();
};

View File

@@ -3,6 +3,8 @@ import type { ZodType, z } from "zod/v4";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { validator } from "./validatorMiddleware.js";
import { versionedValidator } from "./versionedValidator.js";
import type { AffectedResource, ApiVersion } from "@autumn/shared";
/**
* Extended context type that includes validated input
@@ -27,31 +29,44 @@ type ValidatedContext<
}
>;
/**
* Version-specific schemas configuration
*/
type VersionedSchemas<T extends ZodType> = Partial<Record<ApiVersion, ZodType>> & {
latest: T; // Latest version schema (required)
};
/**
* Create a type-safe route with validation that preserves full type inference!
* By typing the context parameter, TypeScript knows what's available on c.req.valid()
*
* @example
* Supports two patterns:
*
* **Pattern 1: Single version (most endpoints)**
* ```ts
* export const createProduct = createRoute({
* body: CreateProductParamsSchema,
* body: CreateProductSchema,
* handler: async (c) => {
* const body = c.req.valid("json"); // ✅ Fully typed from schema!
* const body = c.req.valid("json"); // ✅ Fully typed!
* return c.json({ success: true });
* }
* });
* ```
*
* // With query validation too:
* export const listProducts = createRoute({
* query: ListProductsQuerySchema,
* **Pattern 2: Multiple versions (when API changed)**
* ```ts
* export const createProduct = createRoute({
* versionedBody: {
* latest: CreateProductV3Schema, // Required
* [ApiVersion.V1_1]: CreateProductV2Schema,
* [ApiVersion.V0_2]: CreateProductV1Schema,
* },
* resource: AffectedResource.Product,
* handler: async (c) => {
* const query = c.req.valid("query"); // ✅ Fully typed!
* return c.json({ products: [] });
* const body = c.req.valid("json"); // ✅ Always latest schema type!
* // Old versions auto-transformed to latest format
* return c.json({ success: true });
* }
* });
*
* // In router:
* honoProductRouter.post("", ...createProduct);
* ```
*/
export function createRoute<
@@ -59,7 +74,10 @@ export function createRoute<
Query extends ZodType | undefined = undefined,
>(opts: {
body?: Body;
versionedBody?: Body extends ZodType ? VersionedSchemas<Body> : never;
query?: Query;
versionedQuery?: Query extends ZodType ? VersionedSchemas<Query> : never;
resource?: AffectedResource;
withTx?: boolean;
handler: (
c: ValidatedContext<HonoEnv, Body, Query>,
@@ -67,10 +85,30 @@ export function createRoute<
}) {
const middlewares: MiddlewareHandler[] = [];
if (opts.body) {
// Use versioned validator if versionedBody provided
if (opts.versionedBody && opts.resource) {
middlewares.push(
versionedValidator({
target: "json",
schemas: opts.versionedBody,
resource: opts.resource,
}),
);
} else if (opts.body) {
// Fallback to regular validator for single-version endpoints
middlewares.push(validator("json", opts.body));
}
if (opts.query) {
// Same for query
if (opts.versionedQuery && opts.resource) {
middlewares.push(
versionedValidator({
target: "query",
schemas: opts.versionedQuery,
resource: opts.resource,
}),
);
} else if (opts.query) {
middlewares.push(validator("query", opts.query));
}

View File

@@ -0,0 +1,87 @@
import { zValidator } from "@hono/zod-validator";
import type { ZodType } from "zod/v4";
import type { MiddlewareHandler } from "hono";
import {
ApiVersionClass,
type ApiVersion,
type AffectedResource,
LATEST_VERSION,
applyRequestVersionChanges,
} from "@autumn/shared";
/**
* Version-aware validator middleware
*
* Flow:
* 1. Determine user's API version from ctx.apiVersion
* 2. Select appropriate schema for that version
* 3. Validate data against version-specific schema (clear errors for their version)
* 4. Transform validated data to latest format
* 5. Store transformed data for handler
*
* @example
* ```ts
* router.post(
* "/products",
* versionedValidator({
* target: "json",
* schemas: {
* latest: CreateProductV3Schema,
* [ApiVersion.V1_1]: CreateProductV2Schema,
* [ApiVersion.V0_2]: CreateProductV1Schema,
* },
* resource: AffectedResource.Product,
* }),
* async (c) => {
* const body = c.req.valid("json"); // Always latest format!
* }
* );
* ```
*/
export const versionedValidator = ({
target,
schemas,
resource,
}: {
target: "json" | "query" | "param" | "header" | "form";
schemas: Partial<Record<ApiVersion, ZodType>> & { latest: ZodType };
resource: AffectedResource;
}): MiddlewareHandler => {
return async (c, next) => {
const ctx = c.get("ctx");
const userVersion = ctx.apiVersion;
// Select schema for user's version, fallback to latest
const schema = schemas[userVersion.value] ?? schemas.latest;
// Validate with version-specific schema using zValidator
const validatorMiddleware = zValidator(target, schema, (result, _c) => {
if (!result.success) {
// Validation errors reference fields from user's version ✅
throw result.error;
}
});
// Run validation
await validatorMiddleware(c, async () => {});
// Get validated data
const validatedData = c.req.valid(target);
// If user is on older version, transform to latest
if (!userVersion.eq(new ApiVersionClass(LATEST_VERSION))) {
const transformed = applyRequestVersionChanges({
data: validatedData,
targetVersion: userVersion,
currentVersion: new ApiVersionClass(LATEST_VERSION),
resource,
});
// Replace validated data with transformed version
// This ensures handler always receives latest format
c.req.addValidatedData(target, transformed);
}
await next();
};
};

View File

@@ -1,6 +1,11 @@
import type { AppEnv, AuthType, Feature, Organization } from "@autumn/shared";
import type {
ApiVersionClass,
AppEnv,
AuthType,
Feature,
Organization,
} from "@autumn/shared";
import type { ClickHouseClient } from "@clickhouse/client";
import type { SemVer } from "semver";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { Logger } from "@/external/logtail/logtailUtils.js";
@@ -20,7 +25,7 @@ export type RequestContext = {
id: string;
isPublic: boolean;
authType: AuthType;
apiVersion: SemVer;
apiVersion: ApiVersionClass;
timestamp: number;
};

View File

@@ -0,0 +1,68 @@
import {
AffectedResource,
type ApiVersion,
ApiVersionClass,
applyResponseVersionChanges,
CusEntResponseSchema,
type Feature,
FeatureType,
LATEST_VERSION,
} from "@autumn/shared";
/**
* Transforms feature balances to API format for requested version
*
* Latest format (V1_2):
* - Object keyed by feature_id
* - Has usage/included_usage fields (not used/allowance)
*
* V1_1:
* - Array format
* - Has usage/included_usage fields
*
* V1_0:
* - Array format (split response, not in customer object)
* - Has used/allowance fields
*/
export const getApiCusFeature = ({
balances,
features,
apiVersion,
}: {
balances: any[]; // Raw balances from getCusBalances
features: Feature[]; // Feature definitions
apiVersion: ApiVersion;
}): any => {
// Transform balances to latest format (V1_1+: usage/included_usage)
const transformedBalances = balances.map((b) => {
const isBoolean =
features.find((f: Feature) => f.id === b.feature_id)?.type ===
FeatureType.Boolean;
if (b.unlimited || isBoolean) {
return b;
}
return CusEntResponseSchema.parse({
...b,
usage: b.used,
included_usage: b.allowance,
});
});
// Build in latest format (V1_2: object keyed by feature_id)
const featuresObject: Record<string, any> = {};
for (const balance of transformedBalances) {
featuresObject[balance.feature_id] = balance;
}
// Apply version changes to transform to requested version
// V1_2 stays as object
// V1_1 → array (via V1_2_FeaturesArrayToObject transform)
return applyResponseVersionChanges({
input: featuresObject,
currentVersion: new ApiVersionClass(LATEST_VERSION),
targetVersion: new ApiVersionClass(apiVersion),
resource: AffectedResource.CusFeature,
});
};

View File

@@ -0,0 +1,99 @@
import {
AffectedResource,
APICusProductSchema,
type ApiVersion,
ApiVersionClass,
applyResponseVersionChanges,
CusProductStatus,
type Feature,
type FullCusProduct,
LATEST_VERSION,
type Subscription,
} from "@autumn/shared";
import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js";
import { fullCusProductToProduct } from "../../cusProducts/cusProductUtils.js";
/**
* Builds a customer product in LATEST format, then applies version changes
*
* Latest format (V1_1+):
* - Has `items` field with product features
* - Has `started_at` instead of `starts_at`
* - Includes current_period_start/end for trials
*/
export const getApiCusProduct = async ({
cusProduct,
subs,
features,
apiVersion,
entity,
}: {
cusProduct: FullCusProduct;
subs?: Subscription[];
features: Feature[];
apiVersion: ApiVersion;
entity?: any;
}): Promise<any> => {
// Determine if trialing
const trialing =
cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now();
// Build stripe subscription data
const subIds = cusProduct.subscription_ids;
let stripeSubData = {};
if ((!subIds || subIds.length === 0) && trialing) {
stripeSubData = {
current_period_start: cusProduct.starts_at,
current_period_end: cusProduct.trial_ends_at,
};
} else if (subIds && subIds.length > 0 && subs) {
const baseSub = subs.find(
(s) => s.id === subIds[0] || (s as Subscription).stripe_id === subIds[0],
);
if (baseSub) {
stripeSubData = {
current_period_end: baseSub.current_period_end
? baseSub.current_period_end * 1000
: null,
current_period_start: baseSub.current_period_start
? baseSub.current_period_start * 1000
: null,
};
}
}
// Build product in LATEST format (V1_1+)
const fullProduct = fullCusProductToProduct(cusProduct);
const v2Product = await getProductResponse({
product: fullProduct,
features,
withDisplay: false,
options: cusProduct.options,
});
const latestProduct = APICusProductSchema.parse({
id: fullProduct.id,
name: fullProduct.name,
group: fullProduct.group || null,
status: trialing ? CusProductStatus.Trialing : cusProduct.status,
canceled_at: cusProduct.canceled_at || null,
is_default: fullProduct.is_default || false,
is_add_on: fullProduct.is_add_on || false,
version: fullProduct.version,
quantity: cusProduct.quantity,
started_at: cusProduct.starts_at,
entity_id: entity?.id || cusProduct.entity_id || undefined,
...stripeSubData,
items: v2Product.items, // V1_1+ has items field
});
// Apply version changes to transform to requested version
// This will remove items field for V0_1
return applyResponseVersionChanges({
input: latestProduct,
currentVersion: new ApiVersionClass(LATEST_VERSION),
targetVersion: new ApiVersionClass(apiVersion),
resource: AffectedResource.CusProduct,
});
};

View File

@@ -0,0 +1,153 @@
import {
ACTIVE_STATUSES,
AffectedResource,
type APICusProduct,
APICustomerSchema,
type ApiVersion,
ApiVersionClass,
applyResponseVersionChanges,
type CusProductStatus,
type Feature,
type FullCusProduct,
type FullCustomer,
LATEST_VERSION,
} from "@autumn/shared";
import { getApiCusFeature } from "./getApiCusFeature.js";
import { getApiCusProduct } from "./getApiCusProduct.js";
/**
* Merges customer products by id and status
* This is how V1_1+ handles multiple subscriptions to the same product
*/
const mergeApiCusProducts = ({
cusProductResponses,
}: {
cusProductResponses: APICusProduct[];
}) => {
const getProductKey = (product: APICusProduct) => {
const status = ACTIVE_STATUSES.includes(product.status as CusProductStatus)
? "active"
: product.status;
return `${product.id}:${status}`;
};
const record: Record<string, any> = {};
for (const curr of cusProductResponses) {
const key = getProductKey(curr);
const latest = record[key];
const currStartedAt = curr.started_at;
record[key] = {
...(latest || curr),
version: Math.max(latest?.version || 1, curr?.version || 1),
canceled_at: curr.canceled_at
? curr.canceled_at
: latest?.canceled_at || null,
started_at: latest?.started_at
? Math.min(latest?.started_at, currStartedAt)
: currStartedAt,
quantity: (latest?.quantity || 0) + (curr?.quantity || 0),
};
}
return Object.values(record);
};
export const getApiCustomer = async ({
customer,
cusProducts,
balances,
features,
apiVersion,
invoices,
trialsUsed,
rewards,
entities,
referrals,
upcomingInvoice,
paymentMethod,
withAutumnId = false,
}: {
customer: FullCustomer;
cusProducts: FullCusProduct[];
balances: any[];
features: Feature[];
apiVersion: ApiVersion;
invoices?: any[];
trialsUsed?: any[];
rewards?: any;
entities?: any[];
referrals?: any[];
upcomingInvoice?: any;
paymentMethod?: any;
withAutumnId?: boolean;
}): Promise<any> => {
const subs = customer.subscriptions || [];
// Process each product using getApiCusProduct (builds latest format + applies transforms)
let main: APICusProduct[] = [];
let addOns: APICusProduct[] = [];
for (const cusProduct of cusProducts) {
const processed = await getApiCusProduct({
cusProduct,
subs,
features,
apiVersion,
});
const isAddOn = cusProduct.product.is_add_on;
if (isAddOn) {
addOns.push(processed);
} else {
main.push(processed);
}
}
// Merge products (V1_1+ behavior, always do this in latest format)
main = mergeApiCusProducts({ cusProductResponses: main });
addOns = mergeApiCusProducts({ cusProductResponses: addOns });
// Merge main and addOns into single products array (V1_1+ behavior)
const allProducts = [...main, ...addOns];
// Get versioned features (handles field mapping + object vs array format)
const apiFeatures = getApiCusFeature({
balances,
features,
apiVersion,
});
// Build customer in latest format (V1_1+: merged response with features/products)
const latestCustomer = APICustomerSchema.parse({
autumn_id: withAutumnId ? customer.internal_id : undefined,
id: customer.id,
email: customer.email,
name: customer.name,
fingerprint: customer.fingerprint,
stripe_id: customer.processor?.id,
env: customer.env,
created_at: customer.created_at,
features: apiFeatures, // Already versioned (object for V1_2, array for V1_1)
products: allProducts, // Merged products (V1_1+ format)
invoices,
trials_used: trialsUsed,
rewards,
metadata: customer.metadata || {},
entities,
referrals,
upcoming_invoice: upcomingInvoice,
payment_method: paymentMethod,
});
// Apply customer-level version changes (e.g., V1_1_MergedResponse)
// This will split the merged response for V1_0 users
return applyResponseVersionChanges({
input: latestCustomer,
currentVersion: new ApiVersionClass(LATEST_VERSION),
targetVersion: new ApiVersionClass(apiVersion),
resource: AffectedResource.Customer,
});
};

View File

@@ -187,6 +187,7 @@ export const getCustomerDetails = async ({
return cusResponse;
}
} else {
// Probably don't need items...?
const withItems = org.config.api_version >= BREAK_API_VERSION;
const processedInvoices = await getCusInvoices({

View File

@@ -0,0 +1,172 @@
import {
ApiVersion,
CusExpand,
CusProductStatus,
cusProductsToCusEnts,
cusProductsToCusPrices,
ErrCode,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { invoicesToResponse } from "@/internal/invoices/invoiceUtils.js";
import { getCusWithCache } from "../../cusCache/getCusWithCache.js";
import { getApiCustomer } from "../../cusUtils/apiCusUtils/getApiCustomer.js";
import { getCusBalances } from "../../cusUtils/cusFeatureResponseUtils/getCusBalances.js";
import { getCusPaymentMethodRes } from "../../cusUtils/cusResponseUtils/getCusPaymentMethodRes.js";
import { getCusReferrals } from "../../cusUtils/cusResponseUtils/getCusReferrals.js";
import { getCusRewards } from "../../cusUtils/cusResponseUtils/getCusRewards.js";
import { getCusUpcomingInvoice } from "../../cusUtils/cusResponseUtils/getCusUpcomingInvoice.js";
import { parseCusExpand } from "../../cusUtils/cusUtils.js";
/**
* GET /customers/:customer_id (V2 with versioning system)
*
* This is the NEW implementation using the versioning system.
* DO NOT touch the old handleGetCustomer.ts until this is validated.
*
* Key differences:
* 1. Each resource (customer/product/feature) handles its own versioning
* 2. getApiCustomer/getApiCusProduct/getApiCusFeature apply version changes
* 3. No version branching in handler logic
* 4. Side effects handled explicitly (expand invoices for V1_0)
*/
export const handleGetCustomerV2 = createRoute({
handler: async (c) => {
const ctx = c.get("ctx");
const customerId = c.req.param("customer_id");
const { env, db, logger, org, features } = ctx;
const { expand } = c.req.query();
const expandArray = parseCusExpand(expand);
// Side effect: V1_0 always expands invoices
if (ctx.apiVersion.lt(ApiVersion.V1_1)) {
expandArray.push(CusExpand.Invoices);
}
logger.info(`[V2] Getting customer ${customerId} for org ${org.slug}`);
const startTime = Date.now();
const customer = await getCusWithCache({
db,
idOrInternalId: customerId,
org,
env,
expand: expandArray,
allowNotFound: true,
logger,
});
logger.info(`[V2] Get customer took ${Date.now() - startTime}ms`);
if (!customer) {
logger.warn(`[V2] Customer ${customerId} not found | Org: ${org.slug}`);
return c.json(
{
message: `Customer ${customerId} not found`,
code: ErrCode.CustomerNotFound,
},
StatusCodes.NOT_FOUND,
);
}
// Get feature balances
const inStatuses = org.config.include_past_due
? [CusProductStatus.Active, CusProductStatus.PastDue]
: [CusProductStatus.Active];
const cusEnts = cusProductsToCusEnts({
cusProducts: customer.customer_products,
inStatuses,
});
const balances = await getCusBalances({
cusEntsWithCusProduct: cusEnts,
cusPrices: cusProductsToCusPrices({
cusProducts: customer.customer_products,
inStatuses,
}),
org,
apiVersion: ctx.apiVersion.semver as any, // TODO: fix type
});
// Fetch optional expanded fields
const subIds = customer.customer_products.flatMap(
(cp: any) => cp.subscription_ids || [],
);
const rewards = await getCusRewards({
org,
env,
fullCus: customer,
subIds,
expand: expandArray,
});
const upcomingInvoice = await getCusUpcomingInvoice({
db,
org,
env,
fullCus: customer,
expand: expandArray,
});
const referrals = await getCusReferrals({
db,
fullCus: customer,
expand: expandArray,
});
const paymentMethod = await getCusPaymentMethodRes({
org,
env,
fullCus: customer,
expand: expandArray,
});
const invoices = expandArray.includes(CusExpand.Invoices)
? invoicesToResponse({
invoices: customer.invoices || [],
logger,
})
: undefined;
const entities = expandArray.includes(CusExpand.Entities)
? customer.entities.map((e: any) => ({
id: e.id,
name: e.name,
customer_id: customer.id,
feature_id: e.feature_id,
created_at: e.created_at,
env: customer.env,
}))
: undefined;
const trialsUsed = expandArray.includes(CusExpand.TrialsUsed)
? customer.trials_used
: undefined;
const { with_autumn_id } = c.req.query();
// Use getApiCustomer - it handles all versioning internally!
// - Calls getApiCusProduct for each product (builds latest + applies transforms)
// - Calls getApiCusFeature for features (field mapping + object↔array)
// - Applies customer-level changes (splits response for V1_0)
const customerResponse = await getApiCustomer({
customer,
cusProducts: customer.customer_products,
balances,
features,
apiVersion: ctx.apiVersion.semver,
invoices,
trialsUsed,
rewards,
entities,
referrals,
upcomingInvoice,
paymentMethod,
withAutumnId: with_autumn_id === "true",
});
return c.json(customerResponse);
},
});

0
shared/CLAUDE.md Normal file
View File

View File

@@ -2,7 +2,7 @@ import { EntityDataSchema } from "@api/common/entityData.js";
import { APIProductSchema } from "@api/products/apiProduct.js";
import { z } from "zod/v4";
import { CustomerDataSchema } from "../common/customerData.js";
import { CoreCusFeatureSchema } from "../customers/components/apiCusFeature.js";
import { CoreCusFeatureSchema } from "../customers/cusFeatures/apiCusFeature.js";
// Check Feature Enums
export const CheckFeatureScenarioSchema = z

View File

@@ -1,6 +1,6 @@
import { APICusFeatureSchema } from "@api/customers/components/apiCusFeature.js";
import { APICusProductSchema } from "@api/customers/components/apiCusProduct.js";
import { APICusReferralSchema } from "@api/customers/components/apiCusReferral.js";
import { APICusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
import { APICusRewardsSchema } from "@api/models.js";
import { APIInvoiceSchema } from "@api/others/apiInvoice.js";
import { EntityResponseSchema } from "@models/cusModels/entityModels/entityResModels.js";

View File

@@ -0,0 +1,41 @@
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
VersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import { z } from "zod/v4";
/**
* V1_1: Invoices expansion behavior changed (side effect only)
*
* V1_1+: Invoices require explicit expand parameter
* V1_0: Invoices always included (side effect - must be handled in handler)
*
* This change has side effects and doesn't transform data.
* The handler must add CusExpand.Invoices for V1_0 requests.
*/
// Schema is just any since this is a side-effect only change
const NoOpSchema = z.any();
export class V1_1_LegacyExpandInvoices extends VersionChange<
typeof NoOpSchema,
typeof NoOpSchema
> {
readonly version = ApiVersion.V1_1;
readonly description = "Invoices always expanded before V1_1";
readonly affectedResources = [AffectedResource.Customer];
readonly hasSideEffects = true;
readonly newSchema = NoOpSchema;
readonly oldSchema = NoOpSchema;
// No transformation needed - this is a side effect handled in the handler
transformResponse({
input,
}: {
input: z.infer<typeof NoOpSchema>;
}): z.infer<typeof NoOpSchema> {
return input;
}
}

View File

@@ -0,0 +1,74 @@
import { APICustomerSchema } from "@api/customers/apiCustomer.js";
import { APICusProductSchema } from "@api/customers/components/apiCusProduct.js";
import { APICusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
VersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import { z } from "zod/v4";
/**
* V1_1: Customer response structure changed from split to merged
*
* V1_1+ format: Merged single object with features/products
* V1_0 format: Split into {customer, products, add_ons, entitlements, invoices}
*/
// V1_1+ merged response schema
const V1_1_CustomerResponseSchema = APICustomerSchema;
// V1_0 split response schema
const V1_0_CustomerResponseSchema = z.object({
customer: APICustomerSchema.omit({
features: true,
products: true,
invoices: true,
trials_used: true,
}),
products: z.array(APICusProductSchema),
add_ons: z.array(APICusProductSchema),
entitlements: z.array(APICusFeatureSchema),
invoices: z.array(z.any()),
trials_used: z.array(z.any()).optional(),
});
export class V1_1_MergedResponse extends VersionChange<
typeof V1_1_CustomerResponseSchema,
typeof V1_0_CustomerResponseSchema
> {
readonly version = ApiVersion.V1_1;
readonly description = "Merged customer response → split structure";
readonly affectedResources = [AffectedResource.Customer];
readonly affectsRequest = false;
readonly affectsResponse = true;
readonly newSchema = V1_1_CustomerResponseSchema;
readonly oldSchema = V1_0_CustomerResponseSchema;
// Response: V1_1 merged → V1_0 split
transformResponse({
input,
}: {
input: z.infer<typeof V1_1_CustomerResponseSchema>;
}): z.infer<typeof V1_0_CustomerResponseSchema> {
const {
features,
products = [],
invoices,
trials_used,
...customerFields
} = input;
return {
customer: customerFields,
products: products.filter((p) => !p.is_add_on),
add_ons: products.filter((p) => p.is_add_on),
entitlements: Array.isArray(features)
? features
: Object.values(features),
invoices: invoices || [],
trials_used,
};
}
}

View File

@@ -68,5 +68,4 @@ export const APICusFeatureSchema = z
export type CusEntResponse = z.infer<typeof CusEntResponseSchema>;
export type CusEntResponseV2 = z.infer<typeof APICusFeatureSchema>;
export type CusRollover = z.infer<typeof CusRolloverSchema>;

View File

@@ -0,0 +1,44 @@
import { APICusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
VersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import { z } from "zod/v4";
/**
* V1_2: Features changed from array to object
*
* V1_2+ format: Object keyed by feature_id
* V1_1 format: Array of features
*/
// V1_2+ features schema (object format)
const V1_2_FeaturesSchema = z.record(z.string(), APICusFeatureSchema);
// V1_1 features schema (array format)
const V1_1_FeaturesSchema = z.array(APICusFeatureSchema);
export class V1_2_FeaturesArrayToObject extends VersionChange<
typeof V1_2_FeaturesSchema,
typeof V1_1_FeaturesSchema
> {
readonly version = ApiVersion.V1_2;
readonly description = "Features: object → array";
readonly affectedResources = [AffectedResource.CusFeature];
readonly affectsRequest = false;
readonly affectsResponse = true;
readonly newSchema = V1_2_FeaturesSchema;
readonly oldSchema = V1_1_FeaturesSchema;
// Response: V1_2 object → V1_1 array
transformResponse({
input,
}: {
input: z.infer<typeof V1_2_FeaturesSchema>;
}): z.infer<typeof V1_1_FeaturesSchema> {
// Convert object to array
return Object.values(input);
}
}

View File

@@ -0,0 +1,88 @@
import { EntInterval } from "@models/productModels/entModels/entEnums.js";
import { ProductItemFeatureType } from "@models/productV2Models/productItemModels/productItemModels.js";
import { z } from "zod/v4";
// const res: any = {
// feature_id: ent.feature.id,
// unlimited: isBoolean ? undefined : unlimited,
// interval: isBoolean || unlimited ? null : ent.interval || undefined,
// balance: isBoolean ? undefined : unlimited ? null : 0,
// total: isBoolean || unlimited ? undefined : 0,
// adjustment: isBoolean || unlimited ? undefined : 0,
// used: isBoolean ? undefined : unlimited ? null : 0,
// unused: 0,
// };
/**
* ApiCusFeatureV0Schema - The very first version of the customer feature API model
*/
export const ApiCusFeatureV0Schema = z.object({
feature_id: z.string(),
interval: z.enum(EntInterval).nullish(),
interval_count: z.number().nullish(),
unlimited: z.boolean().nullish(),
balance: z.number().nullish(),
});
// OLD CUS FEATURE RESPONSE
export const CusEntResponseSchema = z.object({
feature_id: z.string(),
interval: z.enum(EntInterval).nullish(),
interval_count: z.number().nullish(),
unlimited: z.boolean().nullish(),
balance: z.number().nullish(), //
usage: z.number().nullish(),
included_usage: z.number().nullish(),
next_reset_at: z.number().nullish(),
overage_allowed: z.boolean().nullish(),
usage_limit: z.number().nullish(),
// rollovers: z.array(CusRolloverSchema).nullish(),
});
// NEW CUS FEATURE RESPONSE
export const CoreCusFeatureSchema = z.object({
interval: z.enum(EntInterval).or(z.literal("multiple")).nullish(),
interval_count: z.number().nullish(),
unlimited: z.boolean().nullish(),
balance: z.number().nullish(),
usage: z.number().nullish(),
included_usage: z.number().nullish(),
next_reset_at: z.number().nullish(),
overage_allowed: z.boolean().nullish(),
breakdown: z
.array(
z.object({
interval: z.enum(EntInterval),
interval_count: z.number().nullish(),
balance: z.number().nullish(),
usage: z.number().nullish(),
included_usage: z.number().nullish(),
next_reset_at: z.number().nullish(),
}),
)
.nullish(),
credit_schema: z
.array(
z.object({
feature_id: z.string(),
credit_amount: z.number(),
}),
)
.nullish(),
usage_limit: z.number().nullish(),
rollovers: z.array(CusRolloverSchema).nullish(),
});
export const APICusFeatureSchema = z
.object({
id: z.string(),
type: z.enum(ProductItemFeatureType),
name: z.string().nullish(),
})
.extend(CoreCusFeatureSchema.shape);
export type CusEntResponse = z.infer<typeof CusEntResponseSchema>;
export type CusEntResponseV2 = z.infer<typeof APICusFeatureSchema>;
export type CusRollover = z.infer<typeof CusRolloverSchema>;

View File

@@ -0,0 +1,50 @@
import { APICusProductSchema } from "@api/customers/components/apiCusProduct.js";
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
VersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import type { z } from "zod/v4";
/**
* V0_2: Customer products gained 'items' field
*
* V0_2+ format: Has items field with product features
* V0_1 format: No items field, no current_period_end/start
*/
// V0_2+ product schema (with items)
const V0_2_CusProductSchema = APICusProductSchema;
// V0_1 product schema (without items and period fields)
const V0_1_CusProductSchema = APICusProductSchema.omit({
items: true,
current_period_end: true,
current_period_start: true,
});
export class V0_2_ProductItems extends VersionChange<
typeof V0_2_CusProductSchema,
typeof V0_1_CusProductSchema
> {
readonly version = ApiVersion.V0_2;
readonly description = "Products gained 'items' field";
readonly affectedResources = [AffectedResource.CusProduct];
readonly affectsRequest = false;
readonly affectsResponse = true;
readonly newSchema = V0_2_CusProductSchema;
readonly oldSchema = V0_1_CusProductSchema;
transformResponse({
input,
}: {
input: z.infer<typeof V0_2_CusProductSchema>;
}): z.infer<typeof V0_1_CusProductSchema> {
// Remove items and period fields for V0_1
// biome-ignore lint/correctness/noUnusedVariables: Using destructuring to omit fields
const { items, current_period_end, current_period_start, ...rest } = input;
return rest;
}
}

View File

@@ -8,9 +8,9 @@ export * from "./core/coreOpModels.js";
// Customers
export * from "./customers/apiCustomer.js";
export * from "./customers/components/apiCusFeature.js";
export * from "./customers/components/apiCusProduct.js";
export * from "./customers/components/apiCusReferral.js";
export * from "./customers/cusFeatures/apiCusFeature.js";
export * from "./customers/customerOpModels.js";
export * from "./customers/customersOpenApi.js";

View File

@@ -0,0 +1,18 @@
/**
* API Version enum (SemVer format, descending order)
* Users send CalVer in x-api-version header (e.g., "2025-04-17")
* Internally we use SemVer for comparison (e.g., "1.1.0")
*/
export enum ApiVersion {
V1_4 = "1.4.0",
V1_2 = "1.2.0",
V1_1 = "1.1.0",
V0_2 = "0.2.0",
V0_1 = "0.1.0",
}
export type ApiVersionString = `${ApiVersion}`;
export const API_VERSIONS = Object.values(ApiVersion);
export const LATEST_VERSION = ApiVersion.V1_2;

View File

@@ -0,0 +1,140 @@
import { ApiVersion, API_VERSIONS } from "./ApiVersion.js";
import type { VersionMetadata } from "./versionRegistry.js";
import { getVersionMetadata, getVersionsSorted } from "./versionRegistryUtils.js";
/**
* ApiVersionClass - Encapsulates version comparison logic
*
* Provides semantic comparison methods (gt, lt, gte, lte, eq) for API versions.
* Only accepts valid versions from the ApiVersion enum.
*
* @example
* const v1 = new ApiVersionClass(ApiVersion.V1_1);
* const v2 = new ApiVersionClass(ApiVersion.V1_2);
* v2.gt(v1); // true
* v1.gte(ApiVersion.V1_1); // true
*/
export class ApiVersionClass {
private readonly version: ApiVersion;
private readonly metadata: VersionMetadata;
private readonly sortedVersions: ApiVersion[];
constructor(version: ApiVersion) {
if (!API_VERSIONS.includes(version)) {
throw new Error(`Invalid API version: ${version}`);
}
this.version = version;
this.metadata = getVersionMetadata({ version });
this.sortedVersions = getVersionsSorted();
}
/**
* Get the current version
*/
get value(): ApiVersion {
return this.version;
}
/**
* Get version metadata
*/
get meta(): VersionMetadata {
return this.metadata;
}
/**
* Get the CalVer representation (for headers)
*/
get calver(): string {
return this.metadata.calver;
}
/**
* Get the SemVer representation
*/
get semver(): ApiVersion {
return this.version;
}
/**
* Get the index of this version in the sorted list
*/
private getIndex(version: ApiVersion): number {
return this.sortedVersions.indexOf(version);
}
/**
* Greater than
*/
gt(other: ApiVersion | ApiVersionClass): boolean {
const otherVersion = other instanceof ApiVersionClass ? other.value : other;
return this.getIndex(this.version) > this.getIndex(otherVersion);
}
/**
* Greater than or equal
*/
gte(other: ApiVersion | ApiVersionClass): boolean {
const otherVersion = other instanceof ApiVersionClass ? other.value : other;
return this.getIndex(this.version) >= this.getIndex(otherVersion);
}
/**
* Less than
*/
lt(other: ApiVersion | ApiVersionClass): boolean {
const otherVersion = other instanceof ApiVersionClass ? other.value : other;
return this.getIndex(this.version) < this.getIndex(otherVersion);
}
/**
* Less than or equal
*/
lte(other: ApiVersion | ApiVersionClass): boolean {
const otherVersion = other instanceof ApiVersionClass ? other.value : other;
return this.getIndex(this.version) <= this.getIndex(otherVersion);
}
/**
* Equal
*/
eq(other: ApiVersion | ApiVersionClass): boolean {
const otherVersion = other instanceof ApiVersionClass ? other.value : other;
return this.version === otherVersion;
}
/**
* Not equal
*/
neq(other: ApiVersion | ApiVersionClass): boolean {
return !this.eq(other);
}
/**
* Check if this version is deprecated
*/
isDeprecated(): boolean {
return this.metadata.deprecated === true;
}
/**
* Get the version to migrate to (if deprecated)
*/
getMigrationVersion(): ApiVersion | null {
return this.metadata.migrateToVersion || null;
}
/**
* String representation
*/
toString(): string {
return this.version;
}
/**
* JSON representation
*/
toJSON(): string {
return this.version;
}
}

View File

@@ -0,0 +1,221 @@
# API Versioning System
Stripe-inspired versioning with CalVer (external) and SemVer (internal).
## Core Concept
**Always build latest format → Transform backwards automatically**
```typescript
// Your handler
const latestData = { features: { f1: {...} } }; // V1_2 format
return applyVersionChanges({
data: latestData,
currentVersion: new ApiVersionClass(LATEST_VERSION),
targetVersion: ctx.apiVersion, // From middleware
resource: AffectedResource.Customer
});
```
## Quick Reference
### Version Comparison
```typescript
// ctx.apiVersion ready in middleware
if (ctx.apiVersion.gte(ApiVersion.V1_1)) { ... }
if (ctx.apiVersion.lt(ApiVersion.V1_2)) { ... }
```
### Version Mapping
| SemVer | CalVer | Legacy v1 | Legacy v2 |
|--------|--------|-----------|-----------|
| V1_4 | 2025-06-01 | - | 1.4 (beta) |
| V1_2 | 2025-05-05 | - | 1.2 |
| V1_1 | 2025-04-17 | - | 1.1 |
| V0_2 | 2025-04-01 | 0.2 | 1.0 |
| V0_1 | 2025-02-01 | 0.1 | - |
### Side Effects
```typescript
if (ctx.apiVersion.lt(ApiVersion.V1_1)) {
expandArray.push(CusExpand.Invoices);
}
```
## How Transforms Work
1. **User requests V1_1**, your data is V1_2:
```
V1_2 { features: { f1: {...} } }
↓ FeaturesArrayToObject.transform()
V1_1 { features: [{ feature_id: 'f1', ...}] }
```
2. **Multiple versions back** (V1_2 → V0_2):
```
V1_2 → V1_1 → V0_2
(Each transform applied in sequence)
```
## File Structure
```
shared/api/
├── versionUtils/
│ ├── ApiVersion.ts # Version enum
│ ├── ApiVersionClass.ts # Comparison methods
│ ├── versionRegistry.ts # SemVer ↔ CalVer mappings
│ ├── versionRegistryUtils.ts # Helper functions
│ ├── convertVersionUtils.ts # Conversion utils
│ ├── versionBranchUtils.ts # Branching helpers
│ └── versionChangeUtils/
│ ├── VersionChange.ts # Abstract base
│ ├── VersionChangeRegistryClass.ts # Registry class
│ ├── versionChangeRegistry.ts # Register all changes
│ └── applyVersionChanges.ts # Transform engine
└── customers/
└── changes/ # Customer-specific changes
├── V1_2_FeaturesArrayToObject.ts
├── V1_1_MergedResponse.ts
├── V1_1_LegacyExpandInvoices.ts # Side effect
└── V0_2_ProductItems.ts
```
### Change Organization
**Version changes live with the resource they affect:**
- Customer changes → `shared/api/customers/changes/`
- Product changes → `shared/api/products/changes/`
- Invoice changes → `shared/api/invoices/changes/`
**Naming convention:** `V{version}_{Description}.ts`
- `V1_2_FeaturesArrayToObject.ts`
- `V1_1_MergedResponse.ts`
## Creating Version Changes
### 1. Create Change Class
```typescript
// shared/api/customers/changes/V1_3_MyChange.ts
import { ApiVersion, VersionChange, AffectedResource } from "@autumn/shared";
export class V1_3_MyChange extends VersionChange {
readonly version = ApiVersion.V1_3;
readonly description = "Brief description";
readonly affectedResources = [AffectedResource.Customer];
transform({ data }: { data: any }): any {
// Transform FROM V1_3 TO V1_2
return { ...data, oldField: data.newField };
}
}
```
### 2. Register in Registry
```typescript
// versionChangeUtils/versionChangeRegistry.ts
export const V1_3_CHANGES = [
V1_3_MyChange,
V1_3_AnotherChange,
];
export function registerAllVersionChanges() {
VersionChangeRegistryClass.register({
version: ApiVersion.V1_3,
changes: V1_3_CHANGES
});
// ... other versions
}
```
### Side Effect Changes
```typescript
export class V1_3_MySideEffect extends VersionChange {
readonly hasSideEffects = true; // Mark as side effect
// ... rest
}
// In handler:
if (ctx.apiVersion.lt(ApiVersion.V1_3)) {
// Handle side effect logic
}
```
## CalVer with .clover Support
System supports `.clover` suffix for non-breaking changes:
- `2025-04-17` → Breaking change
- `2025-04-17.clover` → Non-breaking update (future use)
Both map to same SemVer internally.
## Usage Patterns
### Standard Handler
```typescript
export const handleGet = createRoute({
handler: async (c) => {
const ctx = c.get("ctx");
// Build latest
const data = buildLatest();
// Transform
return c.json(applyVersionChanges({
data,
currentVersion: new ApiVersionClass(LATEST_VERSION),
targetVersion: ctx.apiVersion,
resource: AffectedResource.Customer
}));
}
});
```
### With Version Logic
```typescript
// Check version
if (ctx.apiVersion.lt(ApiVersion.V1_1)) {
expandArray.push(CusExpand.Invoices);
}
// Use helper
const withItems = ctx.apiVersion.gte(ApiVersion.V0_2);
```
## Middleware
`apiVersionMiddleware` resolves version from:
1. `x-api-version` header (CalVer: "2025-04-17")
2. `org.api_version` (legacy: 1.1)
3. `org.config.api_version` (legacy: 0.2)
4. Default: V0_2
Result stored in `ctx.apiVersion` (ApiVersionClass).
## Migration from Old System
### Before
```typescript
const apiVersion = orgToVersion({ org, reqApiVersion });
if (apiVersion >= LegacyVersion.v1_1) { ... }
```
### After
```typescript
if (ctx.apiVersion.gte(ApiVersion.V1_1)) { ... }
```
## Key Principles
1. **Always build latest** - Let transforms handle old versions
2. **Transforms go backwards** - New → Old, never Old → New
3. **Object parameters** - All functions use `{ param }` signature
4. **Descending order** - Version lists newest first
5. **Resource organization** - Changes live with affected resources

View File

@@ -0,0 +1,112 @@
import { LegacyVersion } from "../../enums/APIVersion.js";
import { ApiVersion } from "./ApiVersion.js";
import { VERSION_REGISTRY } from "./versionRegistry.js";
import { CALVER_TO_SEMVER_MAP } from "./versionRegistryUtils.js";
/**
* CalVer → SemVer (supports .clover suffix for future non-breaking changes)
* @example calVerToSemVer({ calver: "2025-04-17" }) // ApiVersion.V1_1
* @example calVerToSemVer({ calver: "2025-04-17.clover" }) // ApiVersion.V1_1
*/
export function calVerToSemVer({
calver,
}: {
calver: string;
}): ApiVersion | null {
return CALVER_TO_SEMVER_MAP[calver] || null;
}
/**
* SemVer → CalVer
* @example semVerToCalVer({ semver: ApiVersion.V1_1 }) // "2025-04-17"
*/
export function semVerToCalVer({ semver }: { semver: ApiVersion }): string {
const meta = VERSION_REGISTRY[semver];
if (!meta) {
throw new Error(`Unknown version: ${semver}`);
}
return meta.calver;
}
/**
* Legacy float → SemVer
* @example legacyToSemVer({ legacyVersion: 1.1 }) // ApiVersion.V1_1
*/
export function legacyToSemVer({
legacyVersion,
}: {
legacyVersion: number;
}): ApiVersion | null {
switch (legacyVersion) {
case LegacyVersion.v1:
case 0.2:
return ApiVersion.V0_2;
case 0.1:
return ApiVersion.V0_1;
case LegacyVersion.v1_1:
case 1.1:
return ApiVersion.V1_1;
case LegacyVersion.v1_2:
case 1.2:
return ApiVersion.V1_2;
case LegacyVersion.v1_4:
case 1.4:
return ApiVersion.V1_4;
default:
return null;
}
}
/**
* SemVer → Legacy float
* @example semVerToLegacy({ semver: ApiVersion.V1_1 }) // 1.1
*/
export function semVerToLegacy({
semver,
}: {
semver: ApiVersion;
}): number | null {
switch (semver) {
case ApiVersion.V0_1:
return 0.1;
case ApiVersion.V0_2:
return LegacyVersion.v1;
case ApiVersion.V1_1:
return LegacyVersion.v1_1;
case ApiVersion.V1_2:
return LegacyVersion.v1_2;
case ApiVersion.V1_4:
return LegacyVersion.v1_4;
default:
return null;
}
}
/**
* Parse version string (CalVer, SemVer, or legacy)
* Supports .clover suffix: "2025-04-17.clover"
* @example parseVersion({ versionStr: "2025-04-17" }) // ApiVersion.V1_1
*/
export function parseVersion({
versionStr,
}: {
versionStr: string;
}): ApiVersion | null {
// CalVer with optional .clover suffix
if (/^\d{4}-\d{2}-\d{2}(\.clover)?$/.test(versionStr)) {
return calVerToSemVer({ calver: versionStr });
}
// SemVer (X.Y.Z)
if (Object.values(ApiVersion).includes(versionStr as ApiVersion)) {
return versionStr as ApiVersion;
}
// Legacy float
const floatVersion = Number.parseFloat(versionStr);
if (!Number.isNaN(floatVersion)) {
return legacyToSemVer({ legacyVersion: floatVersion });
}
return null;
}

View File

@@ -0,0 +1,41 @@
import type { Organization } from "@models/orgModels/orgTable.js";
import { ApiVersionClass } from "./ApiVersionClass.js";
import { legacyToSemVer, semVerToLegacy } from "./convertVersionUtils.js";
/**
* DEPRECATED: Use ctx.apiVersion directly
* Middleware handles version resolution automatically
*/
export function getOrgApiVersion({
org,
reqApiVersion,
}: {
org: Organization;
reqApiVersion?: ApiVersionClass;
}): ApiVersionClass {
if (reqApiVersion) {
return reqApiVersion;
}
const legacyVersion = org.api_version || org.config?.api_version || 1.0;
const semver = legacyToSemVer({ legacyVersion });
if (!semver) {
throw new Error(`Invalid version for org: ${legacyVersion}`);
}
return new ApiVersionClass(semver);
}
/**
* Convert ApiVersionClass → legacy float
* @example toLegacyVersion({ apiVersion: ctx.apiVersion }) // 1.1
*/
export function toLegacyVersion({
apiVersion,
}: {
apiVersion: ApiVersionClass;
}): number {
const legacy = semVerToLegacy({ semver: apiVersion.value });
return legacy || 1.0;
}

View File

@@ -0,0 +1,199 @@
/**
* Standardized utilities for version-based branching logic
*
* These utilities provide clean, readable ways to handle version-dependent
* logic (side effects) that can't be encapsulated in response transforms.
*
* Use these instead of raw if/else checks for better maintainability.
*/
import type { ApiVersion } from "./ApiVersion.js";
import type { ApiVersionClass } from "./ApiVersionClass.js";
/**
* Execute callback if version meets condition
*
* @example
* ifVersion(apiVersion, {
* gte: ApiVersion.V1_1,
* callback: () => {
* expandArray.push(CusExpand.Invoices);
* }
* });
*/
export function ifVersion({
version,
condition,
callback,
}: {
version: ApiVersionClass;
condition:
| { gt: ApiVersion }
| { gte: ApiVersion }
| { lt: ApiVersion }
| { lte: ApiVersion }
| { eq: ApiVersion };
callback: () => void;
}): void {
let shouldExecute = false;
if ("gt" in condition) {
shouldExecute = version.gt(condition.gt);
} else if ("gte" in condition) {
shouldExecute = version.gte(condition.gte);
} else if ("lt" in condition) {
shouldExecute = version.lt(condition.lt);
} else if ("lte" in condition) {
shouldExecute = version.lte(condition.lte);
} else if ("eq" in condition) {
shouldExecute = version.eq(condition.eq);
}
if (shouldExecute) {
callback();
}
}
/**
* Return value based on version
*
* @example
* const schema = versionSwitch(apiVersion, {
* [ApiVersion.V1_2]: SchemaV1_2,
* [ApiVersion.V1_1]: SchemaV1_1,
* default: SchemaV1
* });
*/
export function versionSwitch<T>({
version,
cases,
}: {
version: ApiVersionClass;
cases: Partial<Record<ApiVersion, T>> & { default: T };
}): T {
// Try exact match first
const exactMatch = cases[version.value];
if (exactMatch !== undefined) {
return exactMatch;
}
// Walk down from current version to find closest match
const sortedCases = Object.keys(cases)
.filter((k) => k !== "default")
.sort()
.reverse() as ApiVersion[];
for (const caseVersion of sortedCases) {
if (version.gte(caseVersion)) {
return cases[caseVersion]!;
}
}
return cases.default;
}
/**
* Get value from map based on version range
*
* @example
* const withItems = versionRange(apiVersion, {
* gte: ApiVersion.V0_2,
* value: true,
* default: false
* });
*/
export function versionRange<T>({
version,
range,
}: {
version: ApiVersionClass;
range:
| { gte: ApiVersion; value: T; default: T }
| { gt: ApiVersion; value: T; default: T }
| { lte: ApiVersion; value: T; default: T }
| { lt: ApiVersion; value: T; default: T };
}): T {
if ("gte" in range && version.gte(range.gte)) {
return range.value;
}
if ("gt" in range && version.gt(range.gt)) {
return range.value;
}
if ("lte" in range && version.lte(range.lte)) {
return range.value;
}
if ("lt" in range && version.lt(range.lt)) {
return range.value;
}
return range.default;
}
/**
* Guard clause - require minimum version
*
* @example
* requireVersion(apiVersion, {
* min: ApiVersion.V1_1,
* error: "This endpoint requires API version 2025-04-17 or later"
* });
*/
export function requireVersion({
version,
min,
error,
}: {
version: ApiVersionClass;
min: ApiVersion;
error?: string;
}): void {
if (version.lt(min)) {
throw new Error(
error ||
`This feature requires API version ${min} or later (current: ${version.value})`,
);
}
}
/**
* Execute one of two callbacks based on version comparison
*
* @example
* const result = versionTernary({
* version: apiVersion,
* condition: { gte: ApiVersion.V1_1 },
* ifTrue: () => getNewFormat(),
* ifFalse: () => getLegacyFormat()
* });
*/
export function versionTernary<T>({
version,
condition,
ifTrue,
ifFalse,
}: {
version: ApiVersionClass;
condition:
| { gt: ApiVersion }
| { gte: ApiVersion }
| { lt: ApiVersion }
| { lte: ApiVersion }
| { eq: ApiVersion };
ifTrue: () => T;
ifFalse: () => T;
}): T {
let shouldExecuteTrue = false;
if ("gt" in condition) {
shouldExecuteTrue = version.gt(condition.gt);
} else if ("gte" in condition) {
shouldExecuteTrue = version.gte(condition.gte);
} else if ("lt" in condition) {
shouldExecuteTrue = version.lt(condition.lt);
} else if ("lte" in condition) {
shouldExecuteTrue = version.lte(condition.lte);
} else if ("eq" in condition) {
shouldExecuteTrue = version.eq(condition.eq);
}
return shouldExecuteTrue ? ifTrue() : ifFalse();
}

View File

@@ -0,0 +1,154 @@
import type { ZodType, z } from "zod/v4";
import type { ApiVersion } from "../ApiVersion.js";
/**
* Resources that can be affected by version changes
*/
export enum AffectedResource {
Customer = "customer",
CusProduct = "cus_product",
CusFeature = "cus_feature",
CusBalance = "cus_balance",
Invoice = "invoice",
Product = "product",
// Add more as needed
}
/**
* Abstract base class for bidirectional version changes with Zod schema validation
*
* Uses Zod schemas for runtime validation and type inference.
*
* @example
* // Features changed from array to object in V1_2
* const V1_2_FeaturesSchema = z.record(z.string(), APICusFeatureSchema);
* const V1_1_FeaturesSchema = z.array(APICusFeatureSchema);
*
* class V1_2_FeaturesArrayToObject extends VersionChange {
* version = ApiVersion.V1_2;
* description = "Features: array ↔ object";
* affectedResources = [AffectedResource.CusFeature];
*
* newSchema = V1_2_FeaturesSchema;
* oldSchema = V1_1_FeaturesSchema;
*
* transformResponse({ input }) {
* // input is validated against newSchema
* return Object.values(input); // Returns V1_1 format (array)
* }
* }
*/
export abstract class VersionChange<
TNewSchema extends ZodType = ZodType,
TOldSchema extends ZodType = ZodType,
TDataSchema extends ZodType = ZodType,
> {
/**
* The version this change was introduced in
*/
abstract readonly version: ApiVersion;
/**
* Human-readable description of the change
*/
abstract readonly description: string;
/**
* Resources affected by this change
*/
abstract readonly affectedResources: AffectedResource[];
/**
* Zod schema for the newer version format
*/
abstract readonly newSchema: TNewSchema;
/**
* Zod schema for the older version format
*/
abstract readonly oldSchema: TOldSchema;
/**
* Optional Zod schema for additional context data
*/
readonly dataSchema?: TDataSchema;
/**
* Whether this change affects request transformations
* Default: true
*/
readonly affectsRequest: boolean = true;
/**
* Whether this change affects response transformations
* Default: true
*/
readonly affectsResponse: boolean = true;
/**
* Whether this change has side effects beyond transformation
* If true, transforms become no-ops and you must handle logic elsewhere
*/
readonly hasSideEffects: boolean = false;
/**
* Transform request data forward (old → new format)
* Applied when user sends old version, we transform to latest
*
* @param input - Request data in previous version format (validated against oldSchema)
* @param data - Additional context data for transformation (validated against dataSchema if provided)
* @returns Data in current version format (should match newSchema)
*/
transformRequest({
input,
data: _data,
}: {
input: z.infer<TOldSchema>;
data?: TDataSchema extends ZodType ? z.infer<TDataSchema> : never;
}): z.infer<TNewSchema> {
// Default: no-op (override if change affects requests)
return input as unknown as z.infer<TNewSchema>;
}
/**
* Transform response data backward (new → old format)
* Applied when user expects old version, we transform from latest
*
* @param input - Response data in current version format (validated against newSchema)
* @param data - Additional context data for transformation (validated against dataSchema if provided)
* @returns Data in previous version format (should match oldSchema)
*/
transformResponse({
input,
data: _data,
}: {
input: z.infer<TNewSchema>;
data?: TDataSchema extends ZodType ? z.infer<TDataSchema> : never;
}): z.infer<TOldSchema> {
// Default: no-op (override if change affects responses)
return input as unknown as z.infer<TOldSchema>;
}
/**
* Check if this change affects a specific resource
*/
affects(resource: AffectedResource): boolean {
return this.affectedResources.includes(resource);
}
/**
* Get the name of this change class
*/
get name(): string {
return this.constructor.name;
}
}
/**
* Helper type for constructing version changes
*/
export type VersionChangeConstructor = new () => VersionChange<
ZodType,
ZodType,
ZodType
>;

View File

@@ -0,0 +1,63 @@
import type { ApiVersion } from "../ApiVersion.js";
import type { VersionChange, VersionChangeConstructor } from "./VersionChange.js";
/**
* Registry for version changes
* Maps versions → change classes
*/
export class VersionChangeRegistryClass {
private static changes: Map<ApiVersion, VersionChangeConstructor[]> = new Map();
private static instances: Map<string, VersionChange> = new Map();
static register({
version,
changes,
}: {
version: ApiVersion;
changes: VersionChangeConstructor[];
}) {
this.changes.set(version, changes);
}
static getChangesForVersion({ version }: { version: ApiVersion }): VersionChange[] {
const changeClasses = this.changes.get(version) || [];
return changeClasses.map((ChangeClass) => {
const key = `${version}-${ChangeClass.name}`;
if (!this.instances.has(key)) {
this.instances.set(key, new ChangeClass());
}
const instance = this.instances.get(key);
if (!instance) {
throw new Error(`Failed to create instance for ${ChangeClass.name}`);
}
return instance;
});
}
static getRegisteredVersions(): ApiVersion[] {
return Array.from(this.changes.keys());
}
static isChangeActive({
targetVersion,
currentVersion,
changeClass,
}: {
targetVersion: ApiVersion;
currentVersion: ApiVersion;
changeClass: VersionChangeConstructor;
}): boolean {
const change = new changeClass();
return this.versionLt({ v1: targetVersion, v2: change.version });
}
private static versionLt({ v1, v2 }: { v1: ApiVersion; v2: ApiVersion }): boolean {
const versions = Array.from(this.changes.keys()).sort();
return versions.indexOf(v1) < versions.indexOf(v2);
}
static clear() {
this.changes.clear();
this.instances.clear();
}
}

View File

@@ -0,0 +1,304 @@
import type { ApiVersion } from "../ApiVersion.js";
import type { ApiVersionClass } from "../ApiVersionClass.js";
import { getVersionsBetween } from "../versionRegistryUtils.js";
import type { AffectedResource } from "./VersionChange.js";
import { VersionChangeRegistryClass } from "./VersionChangeRegistryClass.js";
/**
* Apply response transformations (backward: new → old)
*
* Walks backwards from currentVersion to targetVersion,
* applying each version change's transformResponse() function.
*
* @param input - Data in the newest version format
* @param data - Additional context data for transformations (optional)
* @param currentVersion - Version of the input data
* @param targetVersion - Version to transform to (older)
* @param resource - Resource being transformed
*
* @example
* // Data is in V1_2 format, transform to V1_1
* const v1_1_data = applyResponseVersionChanges({
* input: v1_2_customer,
* currentVersion: new ApiVersionClass(ApiVersion.V1_2),
* targetVersion: new ApiVersionClass(ApiVersion.V1_1),
* resource: AffectedResource.Customer
* });
*/
// biome-ignore lint/suspicious/noExplicitAny: Generic type parameter needs flexibility
export function applyResponseVersionChanges<T = any, TData = any>({
input,
data,
currentVersion,
targetVersion,
resource,
}: {
input: T;
data?: TData;
currentVersion: ApiVersionClass;
targetVersion: ApiVersionClass;
resource: AffectedResource;
}): T {
// If versions are equal, no transformation needed
if (currentVersion.eq(targetVersion)) {
return input;
}
// If target is newer than current, throw error (can't transform forward)
if (targetVersion.gt(currentVersion)) {
throw new Error(
`Cannot transform forward from ${currentVersion} to ${targetVersion}. ` +
"Transforms only work backwards to older versions.",
);
}
// Get all versions between current and target (exclusive of target, inclusive of current)
const versionsToApply = getVersionsBetween({
from: targetVersion.value,
to: currentVersion.value,
}).filter((v) => v !== targetVersion.value); // Exclude target itself
// Sort versions from newest to oldest (we apply backwards)
versionsToApply.reverse();
// Apply each version's changes
let transformedData = input;
for (const version of versionsToApply) {
const changes = VersionChangeRegistryClass.getChangesForVersion({
version,
});
for (const change of changes) {
// Skip if this change doesn't affect our resource
if (!change.affects(resource)) {
continue;
}
// Skip if doesn't affect responses
if (!change.affectsResponse) {
continue;
}
// Skip if this change has side effects (must be handled elsewhere)
if (change.hasSideEffects) {
continue;
}
// Apply the response transformation (backward)
transformedData = change.transformResponse({
input: transformedData,
// biome-ignore lint/suspicious/noExplicitAny: Runtime type flexibility needed for version changes
data: data as any,
}) as T;
}
}
return transformedData;
}
/**
* Apply request transformations (forward: old → new)
*
* Walks FORWARD from targetVersion to currentVersion,
* applying each version change's transformRequest() function.
*
* This is used to transform old request formats to the latest version.
*
* @param input - Data in the older version format
* @param data - Additional context data for transformations (optional)
* @param targetVersion - User's version (older)
* @param currentVersion - Latest version (newer)
* @param resource - Resource being transformed
*
* @example
* // Data is in V1_1 format, transform to V1_2
* const v1_2_data = applyRequestVersionChanges({
* input: v1_1_request,
* targetVersion: new ApiVersionClass(ApiVersion.V1_1), // User's version
* currentVersion: new ApiVersionClass(ApiVersion.V1_2), // Latest
* resource: AffectedResource.Product
* });
*/
// biome-ignore lint/suspicious/noExplicitAny: Generic type parameter needs flexibility
export function applyRequestVersionChanges<T = any, TData = any>({
input,
data,
targetVersion,
currentVersion,
resource,
}: {
input: T;
data?: TData;
targetVersion: ApiVersionClass; // User's version (old)
currentVersion: ApiVersionClass; // Latest version (new)
resource: AffectedResource;
}): T {
// If versions are equal, no transformation needed
if (targetVersion.eq(currentVersion)) {
return input;
}
// If target is newer than current, throw error
if (targetVersion.gt(currentVersion)) {
throw new Error(
`Cannot transform forward from ${currentVersion} to ${targetVersion}. ` +
"Current version should be >= target version.",
);
}
// Get all versions between target and current (exclusive of current, inclusive of target)
const versionsToApply = getVersionsBetween({
from: targetVersion.value,
to: currentVersion.value,
}).filter((v) => v !== currentVersion.value); // Exclude current itself
// Don't reverse - we want to go forward (old → new)
// versionsToApply is already in ascending order from getVersionsBetween
// Apply each version's changes
let transformedData = input;
for (const version of versionsToApply) {
const changes = VersionChangeRegistryClass.getChangesForVersion({
version,
});
for (const change of changes) {
// Skip if this change doesn't affect our resource
if (!change.affects(resource)) {
continue;
}
// Skip if doesn't affect requests
if (!change.affectsRequest) {
continue;
}
// Skip if this change has side effects (must be handled elsewhere)
if (change.hasSideEffects) {
continue;
}
// Apply the request transformation (forward)
transformedData = change.transformRequest({
input: transformedData,
// biome-ignore lint/suspicious/noExplicitAny: Runtime type flexibility needed for version changes
data: data as any,
}) as T;
}
}
return transformedData;
}
/**
* Check if a specific change is active for the given target version
*
* Used for side-effect changes that can't be encapsulated in transforms.
* A change is "active" if the target version is older than the version
* where the change was introduced.
*
* @example
* // In your code:
* if (isChangeActive(targetVersion, LegacyExpandInvoicesChange)) {
* // Add expand=invoices to the query
* expandArray.push(CusExpand.Invoices);
* }
*/
export function isChangeActive(
targetVersion: ApiVersionClass,
// biome-ignore lint/suspicious/noExplicitAny: Generic version change class constructor
changeClass: new () => any,
): boolean {
const change = new changeClass();
return targetVersion.lt(change.version);
}
/**
* Helper to apply response changes to an array of objects
*/
// biome-ignore lint/suspicious/noExplicitAny: Generic type parameter needs flexibility
export function applyResponseVersionChangesToArray<T = any, TData = any>({
inputArray,
data,
currentVersion,
targetVersion,
resource,
}: {
inputArray: T[];
data?: TData;
currentVersion: ApiVersionClass;
targetVersion: ApiVersionClass;
resource: AffectedResource;
}): T[] {
return inputArray.map((item) =>
applyResponseVersionChanges({
input: item,
data,
currentVersion,
targetVersion,
resource,
}),
);
}
/**
* Helper to apply request changes to an array of objects
*/
// biome-ignore lint/suspicious/noExplicitAny: Generic type parameter needs flexibility
export function applyRequestVersionChangesToArray<T = any, TData = any>({
inputArray,
data,
targetVersion,
currentVersion,
resource,
}: {
inputArray: T[];
data?: TData;
targetVersion: ApiVersionClass;
currentVersion: ApiVersionClass;
resource: AffectedResource;
}): T[] {
return inputArray.map((item) =>
applyRequestVersionChanges({
input: item,
data,
targetVersion,
currentVersion,
resource,
}),
);
}
/**
* Get all version changes that affect a specific resource between two versions
*/
export function getChangesForResource({
currentVersion,
targetVersion,
resource,
}: {
currentVersion: ApiVersion;
targetVersion: ApiVersion;
resource: AffectedResource;
}) {
const versionsToApply = getVersionsBetween({
from: targetVersion,
to: currentVersion,
}).filter((v) => v !== targetVersion);
versionsToApply.reverse();
const allChanges = [];
for (const version of versionsToApply) {
const changes = VersionChangeRegistryClass.getChangesForVersion({
version,
});
for (const change of changes) {
if (change.affects(resource)) {
allChanges.push(change);
}
}
}
return allChanges;
}

View File

@@ -0,0 +1,93 @@
import { ApiVersion } from "../ApiVersion.js";
import { VersionChangeRegistryClass } from "./VersionChangeRegistryClass.js";
import type { VersionChangeConstructor } from "./VersionChange.js";
// Import customer changes
import { V1_1_MergedResponse } from "@api/customers/changes/V1_1_MergedResponse.js";
import { V1_1_LegacyExpandInvoices } from "@api/customers/changes/V1_1_LegacyExpandInvoices.js";
// Import customer feature changes
import { V1_2_FeaturesArrayToObject } from "@api/customers/cusFeatures/changes/V1_2_FeaturesArrayToObject.js";
// Import customer product changes
import { V0_2_ProductItems } from "@api/customers/cusProducts/changes/V0_2_ProductItems.js";
/**
* V1_4 (2025-06-01) - Beta Features
*
* Breaking changes:
* - TBD (beta version)
*/
export const V1_4_CHANGES: VersionChangeConstructor[] = [
// Add beta changes here when needed
];
/**
* V1_2 (2025-05-05) - Features Redesign
*
* Breaking changes:
* - customer.features: array → object (keyed by feature_id)
*/
export const V1_2_CHANGES: VersionChangeConstructor[] = [
V1_2_FeaturesArrayToObject,
];
/**
* V1_1 (2025-04-17) - Unified Customer Response
*
* Breaking changes:
* - Merged split customer response into single object
* - Renamed entitlements → features
* - invoices now require explicit expand parameter (side effect)
*/
export const V1_1_CHANGES: VersionChangeConstructor[] = [
V1_1_MergedResponse,
V1_1_LegacyExpandInvoices, // Side effect
];
/**
* V0_2 (2025-04-01) - Product Items
*
* Breaking changes:
* - Customer products gained 'items' field
* - Enhanced product structure
*/
export const V0_2_CHANGES: VersionChangeConstructor[] = [V0_2_ProductItems];
/**
* V0_1 (2025-02-01) - Original
*
* No changes (original version)
*/
export const V0_1_CHANGES: VersionChangeConstructor[] = [];
/**
* Register all version changes (newest first)
*
* Auto-runs on import
*/
export function registerAllVersionChanges() {
VersionChangeRegistryClass.register({
version: ApiVersion.V1_4,
changes: V1_4_CHANGES,
});
VersionChangeRegistryClass.register({
version: ApiVersion.V1_2,
changes: V1_2_CHANGES,
});
VersionChangeRegistryClass.register({
version: ApiVersion.V1_1,
changes: V1_1_CHANGES,
});
VersionChangeRegistryClass.register({
version: ApiVersion.V0_2,
changes: V0_2_CHANGES,
});
VersionChangeRegistryClass.register({
version: ApiVersion.V0_1,
changes: V0_1_CHANGES,
});
}
// Auto-register on import
registerAllVersionChanges();

View File

@@ -0,0 +1,47 @@
import { ApiVersion } from "./ApiVersion.js";
export interface VersionMetadata {
semver: ApiVersion;
calver: string;
releasedAt: number;
description: string;
deprecated?: boolean;
migrateToVersion?: ApiVersion;
}
/**
* Version Registry (descending order - newest first)
* SemVer ↔ CalVer mappings and metadata
*/
export const VERSION_REGISTRY: Record<ApiVersion, VersionMetadata> = {
[ApiVersion.V1_4]: {
semver: ApiVersion.V1_4,
calver: "2025-06-01",
releasedAt: new Date("2025-06-01").getTime(),
description: "Beta version with experimental features",
},
[ApiVersion.V1_2]: {
semver: ApiVersion.V1_2,
calver: "2025-05-05",
releasedAt: new Date("2025-05-05").getTime(),
description: "Features as object (keyed by feature_id)",
},
[ApiVersion.V1_1]: {
semver: ApiVersion.V1_1,
calver: "2025-04-17",
releasedAt: new Date("2025-04-17").getTime(),
description: "Merged customer response, features as array",
},
[ApiVersion.V0_2]: {
semver: ApiVersion.V0_2,
calver: "2025-04-01",
releasedAt: new Date("2025-04-01").getTime(),
description: "Customer products with items field",
},
[ApiVersion.V0_1]: {
semver: ApiVersion.V0_1,
calver: "2025-02-01",
releasedAt: new Date("2025-02-01").getTime(),
description: "Original customer schema",
},
};

View File

@@ -0,0 +1,53 @@
import { API_VERSIONS, ApiVersion } from "./ApiVersion.js";
import { VERSION_REGISTRY, type VersionMetadata } from "./versionRegistry.js";
/**
* CalVer → SemVer lookup map
*/
export const CALVER_TO_SEMVER_MAP: Record<string, ApiVersion> = Object.values(
VERSION_REGISTRY,
).reduce(
(acc, meta) => {
acc[meta.calver] = meta.semver;
// Support .clover suffix for future non-breaking changes
acc[`${meta.calver}.clover`] = meta.semver;
return acc;
},
{} as Record<string, ApiVersion>,
);
export function getVersionMetadata({
version,
}: {
version: ApiVersion;
}): VersionMetadata {
return VERSION_REGISTRY[version];
}
export function isValidVersion(params: { version: string }): params is { version: ApiVersion } {
return API_VERSIONS.includes(params.version as ApiVersion);
}
export function getVersionsSorted(): ApiVersion[] {
return Object.values(VERSION_REGISTRY)
.sort((a, b) => a.releasedAt - b.releasedAt)
.map((meta) => meta.semver);
}
export function getVersionsBetween({
from,
to,
}: {
from: ApiVersion;
to: ApiVersion;
}): ApiVersion[] {
const sorted = getVersionsSorted();
const fromIndex = sorted.indexOf(from);
const toIndex = sorted.indexOf(to);
if (fromIndex === -1 || toIndex === -1) {
throw new Error(`Invalid version range: ${from} to ${to}`);
}
return sorted.slice(fromIndex, toIndex + 1);
}

View File

@@ -0,0 +1,64 @@
/**
* Main exports for the unified API versioning system
*/
// Version types and core
export type { ApiVersionString } from "./ApiVersion.js";
export { API_VERSIONS, ApiVersion, LATEST_VERSION } from "./ApiVersion.js";
export { ApiVersionClass } from "./ApiVersionClass.js";
// Version registry
export type { VersionMetadata } from "./versionRegistry.js";
export { VERSION_REGISTRY } from "./versionRegistry.js";
// Version registry utilities
export {
CALVER_TO_SEMVER_MAP,
getVersionMetadata,
getVersionsBetween,
getVersionsSorted,
isValidVersion,
} from "./versionRegistryUtils.js";
// Conversion utilities
export {
calVerToSemVer,
legacyToSemVer,
parseVersion,
semVerToCalVer,
semVerToLegacy,
} from "./convertVersionUtils.js";
// Org-specific utilities (deprecated)
export { getOrgApiVersion, toLegacyVersion } from "./orgVersionUtils.js";
// Branching utilities
export {
ifVersion,
requireVersion,
versionRange,
versionSwitch,
versionTernary,
} from "./versionBranchUtils.js";
// Version changes
export {
AffectedResource,
VersionChange,
type VersionChangeConstructor,
} from "./versionChangeUtils/VersionChange.js";
export { VersionChangeRegistryClass } from "./versionChangeUtils/VersionChangeRegistryClass.js";
export {
applyResponseVersionChanges,
applyResponseVersionChangesToArray,
applyRequestVersionChanges,
applyRequestVersionChangesToArray,
getChangesForResource,
isChangeActive,
// Deprecated aliases (for backward compatibility)
applyResponseVersionChanges as applyVersionChanges,
applyResponseVersionChangesToArray as applyVersionChangesToArray,
} from "./versionChangeUtils/applyVersionChanges.js";
// Auto-register all version changes
import "./versionChangeUtils/versionChangeRegistry.js";

View File

@@ -6,6 +6,9 @@ export { schemas };
export * from "./api/models.js";
export * from "./api/operations.js";
// API VERSIONING SYSTEM
export * from "./api/versionUtils/versionUtils.js";
// Auth Models
export * from "./db/auth-schema.js";
export * from "./enums/APIVersion.js";

View File

@@ -1,5 +1,5 @@
import { APICusFeatureSchema } from "@api/customers/components/apiCusFeature.js";
import { APICusProductSchema } from "@api/customers/components/apiCusProduct.js";
import { APICusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
import { APIInvoiceSchema } from "@api/others/apiInvoice.js";
import { z } from "zod/v4";
import { AppEnv } from "../../genModels/genEnums.js";