feat: govern frontend contract compatibility

This commit is contained in:
donghyeon-ka
2026-07-25 21:19:20 +09:00
parent 5ad6fb032e
commit 89f3c69413
6 changed files with 298 additions and 1 deletions
+102
View File
@@ -0,0 +1,102 @@
export const COMPATIBILITY_TUPLE_FIELDS = Object.freeze([
"buildId",
"configSchemaVersion",
"apiContractVersion",
"assetManifestHash",
"releaseId",
]);
/** @param {string} version */
export function parseNumericVersion(version) {
const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(version);
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2] ?? 0),
patch: Number(match[3] ?? 0),
};
}
/** @param {string} supported @param {string} actual */
export function isVersionCompatible(supported, actual) {
const expected = parseNumericVersion(supported);
const candidate = parseNumericVersion(actual);
if (!expected || !candidate) return false;
return (
expected.major === candidate.major &&
candidate.minor >= expected.minor
);
}
/**
* @param {{
* frontend: {
* buildId: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string
* },
* runtime: {
* buildId: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string
* }
* }} input
*/
export function verifyCompatibilityTuple(input) {
const mismatches = [];
if (input.frontend.buildId !== input.runtime.buildId) mismatches.push("buildId");
if (
!isVersionCompatible(
input.frontend.configSchemaVersion,
input.runtime.configSchemaVersion,
)
) {
mismatches.push("configSchemaVersion");
}
if (
!isVersionCompatible(
input.frontend.apiContractVersion,
input.runtime.apiContractVersion,
)
) {
mismatches.push("apiContractVersion");
}
if (input.frontend.assetManifestHash !== input.runtime.assetManifestHash) {
mismatches.push("assetManifestHash");
}
const releaseWarning =
input.frontend.releaseId === input.runtime.releaseId
? null
: "releaseId";
return Object.freeze({
compatible: mismatches.length === 0,
mismatches: Object.freeze(mismatches),
warnings: Object.freeze(releaseWarning ? [releaseWarning] : []),
});
}
/**
* @param {{ required?: string[], properties?: Record<string, unknown> }} before
* @param {{ required?: string[], properties?: Record<string, unknown> }} after
*/
export function classifyObjectSchemaChange(before, after) {
const beforeRequired = new Set(before.required ?? []);
const afterRequired = new Set(after.required ?? []);
const removedProperties = Object.keys(before.properties ?? {}).filter(
(key) => !(key in (after.properties ?? {})),
);
const addedRequired = [...afterRequired].filter(
(key) => !beforeRequired.has(key),
);
if (removedProperties.length > 0 || addedRequired.length > 0) return "breaking";
const addedProperties = Object.keys(after.properties ?? {}).filter(
(key) => !(key in (before.properties ?? {})),
);
return addedProperties.length > 0 ? "additive" : "none";
}