feat: send dashboard read-model deltas
This commit is contained in:
@@ -29,7 +29,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"es-toolkit": "^1.43.0"
|
||||
"es-toolkit": "^1.43.0",
|
||||
"rfc6902": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsdown": "^0.22.14",
|
||||
|
||||
@@ -16,6 +16,7 @@ export * from './tournament/autoStart.js';
|
||||
export * from './turnDaemon/types.js';
|
||||
export * from './realtime/keys.js';
|
||||
export * from './realtime/types.js';
|
||||
export * from './realtime/delta.js';
|
||||
export * from './ranking/types.js';
|
||||
export * from './ranking/legacyColor.js';
|
||||
export * from './auth/accountIconProjection.js';
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { applyPatch, createPatch, 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;
|
||||
data: T;
|
||||
}
|
||||
| {
|
||||
kind: 'unchanged';
|
||||
revision: string;
|
||||
}
|
||||
| {
|
||||
kind: 'patch';
|
||||
baseRevision: string;
|
||||
revision: string;
|
||||
operations: JsonPatchOperation[];
|
||||
};
|
||||
|
||||
export class ReadModelDeltaMismatchError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ReadModelDeltaMismatchError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppliedReadModelDelta<T> {
|
||||
data: T;
|
||||
revision: string;
|
||||
}
|
||||
|
||||
const cloneJsonValue = <T>(value: T): T => structuredClone(value);
|
||||
|
||||
export const createJsonPatch = (current: unknown, next: unknown): JsonPatchOperation[] => createPatch(current, next);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
if (currentRevision !== delta.baseRevision) {
|
||||
throw new ReadModelDeltaMismatchError(
|
||||
`Patch base revision mismatch: have ${currentRevision}, expected ${delta.baseRevision}.`
|
||||
);
|
||||
}
|
||||
|
||||
const next = cloneJsonValue(current);
|
||||
const errors = applyPatch(next, delta.operations as Operation[]);
|
||||
const failure = errors.find((error) => error !== null);
|
||||
if (failure) {
|
||||
throw new ReadModelDeltaMismatchError(`JSON Patch application failed: ${failure.message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
data: next,
|
||||
revision: delta.revision,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { applyReadModelDelta, ReadModelDeltaMismatchError } from '../src/realtime/delta.js';
|
||||
|
||||
describe('applyReadModelDelta', () => {
|
||||
it('applies a JSON Patch without mutating the previous snapshot', () => {
|
||||
const current = {
|
||||
general: [{ key: '휴식', possible: true, status: 'available' }],
|
||||
inputOptions: { cities: [{ value: 1, label: '업' }] },
|
||||
};
|
||||
|
||||
const applied = applyReadModelDelta(current, 'revision-1', {
|
||||
kind: 'patch',
|
||||
baseRevision: 'revision-1',
|
||||
revision: 'revision-2',
|
||||
operations: [{ op: 'replace', path: '/general/0/possible', value: false }],
|
||||
});
|
||||
|
||||
expect(applied).toEqual({
|
||||
revision: 'revision-2',
|
||||
data: {
|
||||
general: [{ key: '휴식', possible: false, status: 'available' }],
|
||||
inputOptions: { cities: [{ value: 1, label: '업' }] },
|
||||
},
|
||||
});
|
||||
expect(current.general[0]?.possible).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the current object for an unchanged revision', () => {
|
||||
const current = { value: 1 };
|
||||
const applied = applyReadModelDelta(current, 'revision-1', {
|
||||
kind: 'unchanged',
|
||||
revision: 'revision-1',
|
||||
});
|
||||
|
||||
expect(applied.data).toBe(current);
|
||||
});
|
||||
|
||||
it('rejects a patch based on a different snapshot', () => {
|
||||
expect(() =>
|
||||
applyReadModelDelta({ value: 1 }, 'revision-2', {
|
||||
kind: 'patch',
|
||||
baseRevision: 'revision-1',
|
||||
revision: 'revision-3',
|
||||
operations: [{ op: 'replace', path: '/value', value: 2 }],
|
||||
})
|
||||
).toThrow(ReadModelDeltaMismatchError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user