Files
core2026/packages/common/src/realtime/delta.ts
T
Hide_D 299470590a perf: dashboard revision-first 조회 계약을 추가
content revision과 별도인 source revision을 선택적으로 교환하고 coverage가 충족될 때만 payload loader를 생략한다. 구형 client와 미완성 producer coverage는 기존 full computation으로 안전하게 복구한다.
2026-08-16 18:26:15 +00:00

197 lines
6.6 KiB
TypeScript

import { applyPatch, type Operation } from 'rfc6902';
export interface JsonPatchOperation {
op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
path: string;
from?: string;
value?: unknown;
}
export type ReadModelDelta<T> =
| {
kind: 'snapshot';
revision: string;
sourceRevision?: string;
data: T;
}
| {
kind: 'unchanged';
revision: string;
sourceRevision?: string;
}
| {
kind: 'patch';
baseRevision: string;
revision: string;
sourceRevision?: string;
operations: JsonPatchOperation[];
};
export class ReadModelDeltaMismatchError extends Error {
constructor(message: string) {
super(message);
this.name = 'ReadModelDeltaMismatchError';
}
}
export class ReadModelDeltaApplyError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'ReadModelDeltaApplyError';
}
}
export interface AppliedReadModelDelta<T> {
data: T;
revision: string;
sourceRevision?: string;
}
/**
* Read-model deltas operate on JSON documents received over tRPC. Serializing
* through JSON also unwraps Vue's nested reactive proxies, which a root-level
* `toRaw()` does not remove and `structuredClone()` cannot clone.
*/
export const cloneReadModelJson = <T>(value: T): T => {
try {
const serialized = JSON.stringify(value);
if (serialized === undefined) {
throw new TypeError('The read-model value is not a JSON document.');
}
return JSON.parse(serialized) as T;
} catch (error) {
if (error instanceof ReadModelDeltaApplyError) {
throw error;
}
throw new ReadModelDeltaApplyError('Failed to clone the read-model JSON document.', { cause: error });
}
};
const escapeJsonPointerToken = (token: string): string => token.replaceAll('~', '~0').replaceAll('/', '~1');
const appendJsonPointer = (path: string, token: string): string => `${path}/${escapeJsonPointerToken(token)}`;
const isJsonObject = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
/**
* Build a valid patch in one positional pass over arrays.
*
* The upstream rfc6902 generator minimizes array edit count with a Levenshtein
* matrix. Dashboard read models use stable, positional arrays and only need an
* exact reconstruction; the cache layer already rejects patches larger than a
* snapshot. Positional recursion is therefore linear: shifted positions are
* reconciled once, then excess tail items are removed or appended.
*/
const appendLinearJsonDiff = (
operations: JsonPatchOperation[],
current: unknown,
next: unknown,
path: string
): void => {
if (Object.is(current, next)) {
return;
}
if (Array.isArray(current) && Array.isArray(next)) {
const sharedLength = Math.min(current.length, next.length);
for (let index = 0; index < sharedLength; index += 1) {
appendLinearJsonDiff(operations, current[index], next[index], appendJsonPointer(path, String(index)));
}
for (let index = current.length - 1; index >= next.length; index -= 1) {
operations.push({ op: 'remove', path: appendJsonPointer(path, String(index)) });
}
for (let index = current.length; index < next.length; index += 1) {
operations.push({ op: 'add', path: appendJsonPointer(path, '-'), value: next[index] });
}
return;
}
if (isJsonObject(current) && isJsonObject(next)) {
const currentKeys = Object.keys(current).filter((key) => current[key] !== undefined);
const nextKeys = Object.keys(next).filter((key) => next[key] !== undefined);
const nextKeySet = new Set(nextKeys);
const currentKeySet = new Set(currentKeys);
for (const key of currentKeys) {
if (!nextKeySet.has(key)) {
operations.push({ op: 'remove', path: appendJsonPointer(path, key) });
}
}
for (const key of nextKeys) {
const itemPath = appendJsonPointer(path, key);
if (!currentKeySet.has(key)) {
operations.push({ op: 'add', path: itemPath, value: next[key] });
continue;
}
appendLinearJsonDiff(operations, current[key], next[key], itemPath);
}
return;
}
operations.push({ op: 'replace', path, value: next });
};
export const createJsonPatch = (current: unknown, next: unknown): JsonPatchOperation[] => {
const operations: JsonPatchOperation[] = [];
appendLinearJsonDiff(operations, current, next, '');
return operations;
};
export const applyReadModelDelta = <T>(
current: T | undefined,
currentRevision: string | null,
delta: ReadModelDelta<T>
): AppliedReadModelDelta<T> => {
if (delta.kind === 'snapshot') {
return {
data: delta.data,
revision: delta.revision,
...(delta.sourceRevision ? { sourceRevision: delta.sourceRevision } : {}),
};
}
if (current === undefined || currentRevision === null) {
throw new ReadModelDeltaMismatchError('A delta cannot be applied before the initial snapshot.');
}
if (delta.kind === 'unchanged') {
if (currentRevision !== delta.revision) {
throw new ReadModelDeltaMismatchError(
`Unchanged revision mismatch: have ${currentRevision}, received ${delta.revision}.`
);
}
return {
data: current,
revision: currentRevision,
...(delta.sourceRevision ? { sourceRevision: delta.sourceRevision } : {}),
};
}
if (currentRevision !== delta.baseRevision) {
throw new ReadModelDeltaMismatchError(
`Patch base revision mismatch: have ${currentRevision}, expected ${delta.baseRevision}.`
);
}
const next = cloneReadModelJson(current);
try {
const errors = applyPatch(next, delta.operations as Operation[]);
const failure = errors.find((error) => error !== null);
if (failure) {
throw new ReadModelDeltaApplyError(`JSON Patch application failed: ${failure.message}`);
}
} catch (error) {
if (error instanceof ReadModelDeltaApplyError) {
throw error;
}
throw new ReadModelDeltaApplyError('JSON Patch application failed.', { cause: error });
}
return {
data: next,
revision: delta.revision,
...(delta.sourceRevision ? { sourceRevision: delta.sourceRevision } : {}),
};
};