feat: harden shared platform contracts

This commit is contained in:
2026-07-30 14:26:36 +02:00
parent 6970bf7457
commit 9b88ae388b
24 changed files with 2034 additions and 57 deletions
+376
View File
@@ -0,0 +1,376 @@
import { isApiError } from "./client";
export type RevisionConflictDetail = {
code: "revision_conflict";
resource: {
type: string;
id: string;
};
current_revision: number;
submitted_base_revision: number;
retryable: boolean;
refresh_path?: string;
current_etag?: string;
};
export type MergeConflict = {
path: string;
kind: string;
baseValue: unknown;
localValue: unknown;
currentValue: unknown;
};
export type ThreeWayMergeResult<T = unknown> = {
value: T;
conflicts: MergeConflict[];
appliedPaths: string[];
};
export type ConflictChoice = "current" | "local";
type MissingValue = { readonly missing: true };
const MISSING: MissingValue = Object.freeze({ missing: true });
export function revisionConflictFromError(error: unknown): RevisionConflictDetail | null {
if (!isApiError(error, 409, 412)) return null;
try {
const parsed = JSON.parse(error.body) as { detail?: unknown };
const detail = asRecord(parsed.detail);
const resource = asRecord(detail.resource);
if (
detail.code !== "revision_conflict"
|| typeof resource.type !== "string"
|| typeof resource.id !== "string"
|| !Number.isInteger(detail.current_revision)
|| !Number.isInteger(detail.submitted_base_revision)
) return null;
return {
code: "revision_conflict",
resource: {
type: resource.type,
id: resource.id
},
current_revision: Number(detail.current_revision),
submitted_base_revision: Number(detail.submitted_base_revision),
retryable: detail.retryable !== false,
refresh_path: typeof detail.refresh_path === "string" ? detail.refresh_path : undefined,
current_etag: typeof detail.current_etag === "string" ? detail.current_etag : undefined
};
} catch {
return null;
}
}
export function threeWayMerge<T>(
base: T,
local: T,
current: T,
options: {
protectedPaths?: readonly string[];
stableIdFields?: readonly string[];
} = {}
): ThreeWayMergeResult<T> {
return mergeValue(
cloneValue(base),
cloneValue(local),
cloneValue(current),
"",
options.protectedPaths ?? [],
options.stableIdFields ?? ["id"]
) as ThreeWayMergeResult<T>;
}
export function applyConflictChoices<T>(
merge: ThreeWayMergeResult<T>,
choices: Readonly<Record<string, ConflictChoice>>
): T {
let result: unknown = cloneValue(merge.value);
for (const conflict of merge.conflicts) {
if ((choices[conflict.path] ?? "current") !== "local") continue;
result = writePath(result, conflict.path, cloneValue(conflict.localValue));
}
return result as T;
}
function mergeValue(
base: unknown,
local: unknown,
current: unknown,
path: string,
protectedPaths: readonly string[],
stableIdFields: readonly string[]
): ThreeWayMergeResult {
const localChanged = !deepEqual(local, base);
const currentChanged = !deepEqual(current, base);
if (!localChanged) return result(current);
if (deepEqual(local, current)) return result(current);
if (isProtected(path, protectedPaths)) {
return conflictResult(path, "protected_path", base, local, current);
}
if (!currentChanged) return result(local, [], [path || "/"]);
if (isRecord(base) && isRecord(local) && isRecord(current)) {
return mergeRecord(base, local, current, path, protectedPaths, stableIdFields);
}
if (Array.isArray(base) && Array.isArray(local) && Array.isArray(current)) {
return mergeList(base, local, current, path, protectedPaths, stableIdFields);
}
return conflictResult(path, "same_path_changed", base, local, current);
}
function mergeRecord(
base: Record<string, unknown>,
local: Record<string, unknown>,
current: Record<string, unknown>,
path: string,
protectedPaths: readonly string[],
stableIdFields: readonly string[]
): ThreeWayMergeResult<Record<string, unknown>> {
const value: Record<string, unknown> = {};
const conflicts: MergeConflict[] = [];
const appliedPaths: string[] = [];
const keys = [...new Set([...Object.keys(current), ...Object.keys(local), ...Object.keys(base)])];
for (const key of keys) {
const childPath = joinPath(path, key);
const merged = mergePresence(
hasOwn(base, key) ? base[key] : MISSING,
hasOwn(local, key) ? local[key] : MISSING,
hasOwn(current, key) ? current[key] : MISSING,
childPath,
protectedPaths,
stableIdFields
);
conflicts.push(...merged.conflicts);
appliedPaths.push(...merged.appliedPaths);
if (!isMissing(merged.value)) value[key] = merged.value;
}
return result(value, conflicts, appliedPaths);
}
function mergePresence(
base: unknown | MissingValue,
local: unknown | MissingValue,
current: unknown | MissingValue,
path: string,
protectedPaths: readonly string[],
stableIdFields: readonly string[]
): ThreeWayMergeResult {
if (isMissing(local) && isMissing(current)) return result(MISSING);
if (isMissing(base)) {
if (isMissing(local)) return result(current);
if (isMissing(current)) {
if (isProtected(path, protectedPaths)) {
return conflictResult(path, "protected_path", undefined, local, undefined);
}
return result(local, [], [path]);
}
if (deepEqual(local, current)) return result(current);
return conflictResult(path, "concurrent_add", undefined, local, current);
}
if (isMissing(local)) {
if (deepEqual(current, base)) {
if (isProtected(path, protectedPaths)) {
return conflictResult(path, "protected_path", base, undefined, current);
}
return result(MISSING, [], [path]);
}
return conflictResult(path, "delete_vs_edit", base, undefined, current);
}
if (isMissing(current)) {
if (deepEqual(local, base)) return result(MISSING);
return conflictResult(path, "edit_vs_delete", base, local, undefined);
}
return mergeValue(base, local, current, path, protectedPaths, stableIdFields);
}
function mergeList(
base: unknown[],
local: unknown[],
current: unknown[],
path: string,
protectedPaths: readonly string[],
stableIdFields: readonly string[]
): ThreeWayMergeResult<unknown[]> {
const identityField = stableIdentityField([base, local, current], stableIdFields);
if (!identityField) {
return conflictResult(path, "unkeyed_collection", base, local, current);
}
const byId = (items: unknown[]) => new Map(
items.map((item) => [String((item as Record<string, unknown>)[identityField]), item])
);
const baseById = byId(base);
const localById = byId(local);
const currentById = byId(current);
const baseOrder = [...baseById.keys()];
const localOrder = [...localById.keys()];
const currentOrder = [...currentById.keys()];
const localReordered = !deepEqual(commonOrder(localOrder, baseOrder), commonOrder(baseOrder, localOrder));
const currentReordered = !deepEqual(commonOrder(currentOrder, baseOrder), commonOrder(baseOrder, currentOrder));
if (localReordered && currentReordered && !deepEqual(localOrder, currentOrder)) {
return conflictResult(path, "collection_reorder", baseOrder, localOrder, currentOrder);
}
const mergedById = new Map<string, unknown>();
const conflicts: MergeConflict[] = [];
const appliedPaths: string[] = [];
const identities = [...new Set([...currentOrder, ...localOrder, ...baseOrder])];
for (const identity of identities) {
const merged = mergePresence(
baseById.has(identity) ? baseById.get(identity) : MISSING,
localById.has(identity) ? localById.get(identity) : MISSING,
currentById.has(identity) ? currentById.get(identity) : MISSING,
joinPath(path, `${identityField}=${identity}`),
protectedPaths,
stableIdFields
);
conflicts.push(...merged.conflicts);
appliedPaths.push(...merged.appliedPaths);
if (!isMissing(merged.value)) mergedById.set(identity, merged.value);
}
const orderSource = localReordered && !currentReordered ? localOrder : currentOrder;
const mergedOrder = orderSource.filter((identity) => mergedById.has(identity));
for (const identity of identities) {
if (mergedById.has(identity) && !mergedOrder.includes(identity)) mergedOrder.push(identity);
}
return result(
mergedOrder.map((identity) => mergedById.get(identity)),
conflicts,
appliedPaths
);
}
function writePath(root: unknown, path: string, value: unknown): unknown {
if (!path || path === "/") return value;
const segments = path.slice(1).split("/").map(decodePointerSegment);
const cloned = cloneValue(root);
let target: unknown = cloned;
for (let index = 0; index < segments.length - 1; index += 1) {
target = descend(target, segments[index]);
}
const finalSegment = segments[segments.length - 1];
if (Array.isArray(target)) {
const itemIndex = arraySegmentIndex(target, finalSegment);
if (itemIndex < 0) return cloned;
if (value === undefined) target.splice(itemIndex, 1);
else target[itemIndex] = value;
} else if (isRecord(target)) {
if (value === undefined) delete target[finalSegment];
else target[finalSegment] = value;
}
return cloned;
}
function descend(target: unknown, segment: string): unknown {
if (Array.isArray(target)) {
const index = arraySegmentIndex(target, segment);
return index >= 0 ? target[index] : undefined;
}
return isRecord(target) ? target[segment] : undefined;
}
function arraySegmentIndex(items: unknown[], segment: string): number {
const separator = segment.indexOf("=");
if (separator > 0) {
const key = segment.slice(0, separator);
const value = segment.slice(separator + 1);
return items.findIndex((item) => isRecord(item) && String(item[key]) === value);
}
const index = Number(segment);
return Number.isInteger(index) ? index : -1;
}
function stableIdentityField(values: unknown[][], candidates: readonly string[]): string | null {
const allItems = values.flat();
if (allItems.length === 0 || !allItems.every(isRecord)) return null;
for (const candidate of candidates) {
const valid = values.every((items) => {
const identities = items.map((item) => String((item as Record<string, unknown>)[candidate] ?? "").trim());
return identities.every(Boolean) && new Set(identities).size === identities.length;
});
if (valid) return candidate;
}
return null;
}
function commonOrder(left: string[], right: string[]): string[] {
const rightSet = new Set(right);
return left.filter((item) => rightSet.has(item));
}
function isProtected(path: string, protectedPaths: readonly string[]): boolean {
const normalized = path || "/";
return protectedPaths.some((rawPrefix) => {
const prefix = rawPrefix.replace(/\/+$/, "") || "/";
return normalized === prefix || normalized.startsWith(`${prefix}/`);
});
}
function conflictResult<T>(
path: string,
kind: string,
baseValue: unknown,
localValue: unknown,
currentValue: T
): ThreeWayMergeResult<T> {
return result(currentValue, [{
path: path || "/",
kind,
baseValue,
localValue,
currentValue
}]);
}
function result<T>(
value: T,
conflicts: MergeConflict[] = [],
appliedPaths: string[] = []
): ThreeWayMergeResult<T> {
return { value, conflicts, appliedPaths };
}
function joinPath(parent: string, segment: string): string {
const escaped = segment.replace(/~/g, "~0").replace(/\//g, "~1");
return parent ? `${parent}/${escaped}` : `/${escaped}`;
}
function decodePointerSegment(segment: string): string {
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
}
function isMissing(value: unknown): value is MissingValue {
return value === MISSING;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function asRecord(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
function cloneValue<T>(value: T): T {
if (value === undefined || value === null || typeof value !== "object") return value;
if (typeof structuredClone === "function") return structuredClone(value);
return JSON.parse(JSON.stringify(value)) as T;
}
function deepEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (Array.isArray(left) && Array.isArray(right)) {
return left.length === right.length && left.every((item, index) => deepEqual(item, right[index]));
}
if (isRecord(left) && isRecord(right)) {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
return leftKeys.length === rightKeys.length
&& leftKeys.every((key) => hasOwn(right, key) && deepEqual(left[key], right[key]));
}
return false;
}
function hasOwn(value: object, key: PropertyKey): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}