@lifi/compose-spec
Version:
Public wire-format types and schemas for Compose flows
83 lines (74 loc) • 3.38 kB
text/typescript
/**
* Compose contract version compatibility.
*
* The version triple carries the compatibility class, and the rule is
* permanent: 1.0 does not change it. A patch bump is additive: new ops, edge
* families, materialisers, guards, preconditions, or new optional config
* fields. A minor or major bump is breaking: anything removed, any existing
* schema changed, or a semantics change under an unchanged wire shape. Both
* sides of the handshake (see `headers.ts`) decide with `checkCompatibility`,
* so the rule lives in exactly one place.
*/
export interface ComposeVersion {
readonly major: number;
readonly minor: number;
readonly patch: number;
}
export type VersionCompatibility =
'compatible' | 'sdk_outdated' | 'server_outdated';
const SEMANTIC_VERSION_PATTERN =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
/**
* Parses the numeric triple of a semantic version. Prerelease and build
* suffixes are accepted and ignored: `0.5.1-staging.3` compares as `0.5.1`.
* Returns `undefined` for anything that is not a complete semver string,
* including the `dev` sentinel the SDK sends when it runs unbundled.
*/
export const parseVersion = (version: string): ComposeVersion | undefined => {
const match = SEMANTIC_VERSION_PATTERN.exec(version);
if (!match) return undefined;
const major = Number(match[1]);
const minor = Number(match[2]);
const patch = Number(match[3]);
if (![major, minor, patch].every(Number.isSafeInteger)) return undefined;
return { major, minor, patch };
};
const compareVersions = (a: ComposeVersion, b: ComposeVersion): number =>
a.major !== b.major
? a.major - b.major
: a.minor !== b.minor
? a.minor - b.minor
: a.patch - b.patch;
/**
* Decides whether a client built against `client` may talk to a server that
* serves `server` and accepts clients down to `minimum`.
*
* - A client ahead of the server in any component is `server_outdated`. Even
* a patch-ahead client may send a field the server has never seen, and the
* guarantee must not depend on every server schema staying strict. This is
* the rollout window between an npm publish and the prod image bump.
* - A client below `minimum` is `sdk_outdated`: the server no longer serves
* the contract that client speaks.
* - Anything in between is compatible. Patch releases only add, so a client
* at or behind the server's patch never emits anything the server lacks;
* a client on an older minor is served because the server still emits the
* shapes that minor reads.
*
* `minimum` defaults to the server's own `major.minor.0`, which makes every
* minor bump a hard cutover. The backend passes its explicit floor so a minor
* bump can ship with the previous line still accepted; the SDK passes no floor
* because the server, not the client, decides how far back it serves.
*/
export const checkCompatibility = (
client: ComposeVersion,
server: ComposeVersion,
minimum: ComposeVersion = {
major: server.major,
minor: server.minor,
patch: 0,
},
): VersionCompatibility => {
if (compareVersions(client, server) > 0) return 'server_outdated';
if (compareVersions(client, minimum) < 0) return 'sdk_outdated';
return 'compatible';
};