fix: Refactor play tranforming to dogfood steps and fix caching #495

* Re-use lifecycle steps/patch to build diffs
* Break out step generation into own function to simplify logic
* Cache steps instead of play to avoid returning a cached play when data is the same #495
* Replace patch object/patching logic with json-diff-ts as its more robust and actually maintained
  * Reduces need to stringify everywhere due to lack of cloning in jsonpatchdiff
This commit is contained in:
FoxxMD
2026-03-25 18:48:47 +00:00
parent ccbd5e304a
commit 82c0a4faec
13 changed files with 313 additions and 262 deletions
+7 -84
View File
@@ -72,7 +72,7 @@
"icecast-metadata-stats": "^0.1.12",
"iso-websocket": "^0.3.0",
"iti": "^0.7.0",
"jest-diff": "^30.2.0",
"json-diff-ts": "^5.0.0-alpha.2",
"json5": "^2.2.3",
"jsondiffpatch": "^0.7.3",
"jsondiffpatch-react": "^1.0.8",
@@ -2568,36 +2568,6 @@
"axios": "^1.3.4"
}
},
"node_modules/@jest/diff-sequences": {
"version": "30.0.1",
"resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz",
"integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==",
"license": "MIT",
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/get-type": {
"version": "30.1.0",
"resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz",
"integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==",
"license": "MIT",
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/schemas": {
"version": "30.0.5",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz",
"integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==",
"license": "MIT",
"dependencies": {
"@sinclair/typebox": "^0.34.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@joshwooding/vite-plugin-react-docgen-typescript": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.6.4.tgz",
@@ -3928,12 +3898,6 @@
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
"node_modules/@sinclair/typebox": {
"version": "0.34.41",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
"integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==",
"license": "MIT"
},
"node_modules/@sindresorhus/is": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-2.1.1.tgz",
@@ -13525,53 +13489,6 @@
}
}
},
"node_modules/jest-diff": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz",
"integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==",
"license": "MIT",
"dependencies": {
"@jest/diff-sequences": "30.0.1",
"@jest/get-type": "30.1.0",
"chalk": "^4.1.2",
"pretty-format": "30.2.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/jest-diff/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/jest-diff/node_modules/pretty-format": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz",
"integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==",
"license": "MIT",
"dependencies": {
"@jest/schemas": "30.0.5",
"ansi-styles": "^5.2.0",
"react-is": "^18.3.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/jest-diff/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/jest-get-type": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz",
@@ -13684,6 +13601,12 @@
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"license": "MIT"
},
"node_modules/json-diff-ts": {
"version": "5.0.0-alpha.2",
"resolved": "https://registry.npmjs.org/json-diff-ts/-/json-diff-ts-5.0.0-alpha.2.tgz",
"integrity": "sha512-7AI6rZNrvqW3Bkmm9Wsq6985MeC8KkK68mgtW7z3aq39QBGMcoyClrbAXlUr6yQ0VbafVtAM+f5b4bAwlxVSAg==",
"license": "MIT"
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+1 -1
View File
@@ -110,7 +110,7 @@
"icecast-metadata-stats": "^0.1.12",
"iso-websocket": "^0.3.0",
"iti": "^0.7.0",
"jest-diff": "^30.2.0",
"json-diff-ts": "^5.0.0-alpha.2",
"json5": "^2.2.3",
"jsondiffpatch": "^0.7.3",
"jsondiffpatch-react": "^1.0.8",
+185 -144
View File
@@ -10,28 +10,24 @@ import { CommonClientConfig } from "./infrastructure/config/client/index.js";
import { CommonSourceConfig } from "./infrastructure/config/source/index.js";
import { mergeSimpleError, SkipTransformStageError, StagePrerequisiteError, StageTransformError, TransformRulesError } from "./errors/MSErrors.js";
import {
FLOW_CONTROL_TERM,
PlayTransformRules,
StageConfig,
TRANSFORM_HOOK,
TransformHook
} from "./infrastructure/Transform.js";
import AbstractInitializable from "./AbstractInitializable.js";
import play = Simulate.play;
import TransformerManager from "./transforms/TransformerManager.js";
import { getRoot } from "../ioc.js";
import { nanoid } from "nanoid";
import {diffStringsUnified, DiffOptionsColor} from 'jest-diff';
import chalk from 'chalk';
import { isDebugMode } from "../utils.js";
import { findCauseByReference } from "../utils/ErrorUtils.js";
import { hashObject } from "../utils/StringUtils.js";
import { metaInvariantTransform, playContentInvariantTransform } from "../utils/PlayComparisonUtils.js";
import { MSCache } from "./Cache.js";
import { jdiff } from "../../core/DataUtils.js";
import ConsoleFormatter from "jsondiffpatch/formatters/console";
import { diffObjects, diffObjectsConsoleOutput, patchObject } from "../../core/DataUtils.js";
import clone from "clone";
const console = new ConsoleFormatter();
import { loggerNoop } from "./MaybeLogger.js";
export type AbstractComponentConfig = (CommonClientConfig | CommonSourceConfig) & { transformManager?: TransformerManager };
@@ -169,7 +165,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
return partArr.map(x => this.transformManager.parseTransformerConfig(x));
}
public transformPlay = async (play: PlayObject, hookType: TransformHook, log?: boolean) => {
public transformPlay = async (play: PlayObject, hookType: TransformHook, log?: boolean | 'all') => {
const asyncId = nanoid(6);
@@ -200,177 +196,123 @@ export default abstract class AbstractComponent extends AbstractInitializable {
const shouldLog = log ?? this.config.options?.playTransform?.log ?? isDebugMode();
const transformHash = `playTransform-${hashObject(hook)}-${hashObject(playContentInvariantTransform(play))}`;
const cachedTransformPlay = await this.cache.cacheTransform.get<PlayObject>(transformHash);
if(cachedTransformPlay !== undefined) {
logger.trace(`Cache hit ${transformHash}`);
return cachedTransformPlay;
const cachedSteps = await this.cache.cacheTransform.get<LifecycleStep[]>(transformHash);
if(cachedSteps !== undefined) {
logger.trace(`Cache hit for Steps => ${transformHash}`);
//return cachedTransformPlay;
}
logger.debug(`Transform start for => ${buildTrackString(play)}`);
let transformedPlay: PlayObject = play;
let transformHistory: TransformResult[] = [];
let shouldBreak = false;
let transformedPlay: PlayObject = clone(play);
let cacheOk: boolean = true;
for(const hookItem of hook) {
const steps: LifecycleStep[] = [];
const {
onSuccess = 'continue',
onFailure = 'stop',
onSkip = 'continue',
failureReturnPartial = false
} = hookItem;
const opts = {
logger,
asyncId
}
const existingStepIndex = transformedPlay.meta.lifecycle.steps.findIndex(x => x.name === 'hookType' && x.source === this.getIdentifier());
const step: LifecycleStep = existingStepIndex !== -1 ? transformedPlay.meta.lifecycle.steps[existingStepIndex] : {
name: hookType,
source: this.getIdentifier(),
if(cachedSteps !== undefined) {
// don't re-cache cached steps
cacheOk = false;
// only patch play if steps didn't end in a failure that we are okay with returning partial from
let shouldTransform = true;
const lastCachedStep = cachedSteps[cachedSteps.length - 1];
if(lastCachedStep.error !== undefined && lastCachedStep.flowKnownState !== 'skip' && !lastCachedStep.returnPartial) {
shouldTransform = false;
}
let newTransformedPlay: PlayObject,
stageName: string = 'Unnamed',
err: Error;
try {
[newTransformedPlay, stageName] = await this.transformManager.handleStage(hookItem, transformedPlay, asyncId);
} catch (e) {
err = e;
if(e instanceof StageTransformError) {
stageName = e.stageName;
for(const s of cachedSteps) {
steps.push({...s, cached: true});
if(shouldTransform && s.patch !== undefined) {
transformedPlay.data = patchObject(transformedPlay.data, s.patch); // jdiff.patch(clone(transformedPlay.data),s.patch);
}
}
if(err !== undefined) {
const merged = mergeSimpleError(err);
step.error = merged;
const skipError = findCauseByReference(err, SkipTransformStageError);
if(skipError !== undefined) {
let skipMsg = `Stage '${stageName}' was skipped`;
step.flowResult = onSkip;
step.flowKnownState = 'skip';
if(onSkip === 'stop') {
skipMsg += ' and will stop transform due to onSkip: stop';
}
step.flowReason = skipMsg;
logger.debug(merged, skipMsg);
if(onSkip === 'stop') {
shouldBreak = true;
} else {
for(const hookItem of hook) {
const [step, stepPlay] = await this.generateStepFromStage(transformedPlay, hookItem, hookType, opts);
steps.push(step);
cacheOk = step.error === undefined || (step.error !== undefined && step.flowKnownState !== undefined);
if(step.flowResult === FLOW_CONTROL_TERM.stop) {
if(step.error !== undefined && step.flowKnownState !== 'skip' && !step.returnPartial) {
// revert to original play but keep steps for paper trail
transformedPlay = play;
transformedPlay.meta.lifecycle.steps = steps;
} else {
transformedPlay = stepPlay;
}
break;
} else {
step.flowResult = onFailure;
let reason: string;
const reqError = findCauseByReference(err, StagePrerequisiteError);
if(reqError !== undefined) {
reason = 'Transform could not be completed due to prerequisite failure';
step.flowKnownState = 'prereq';
} else {
reason = 'Transform encountered an error';
cacheOk = false;
}
if(onFailure === 'continue') {
reason += ' but will continue due to onFailure: continue';
logger.warn(merged, reason);
} else {
logger[reqError !== undefined ? 'warn' : 'error'](merged, reason);
if(!failureReturnPartial) {
// rewind to original play so we don't return partial transform
transformedPlay = play;
transformHistory = [];
} else if(transformHistory.length > 0) {
cacheOk = true;
step.flowReason += ' | Preserving play transformations up to this point due to failureReturnPartial=true'
}
shouldBreak = true;
}
step.flowReason = reason;
transformedPlay = stepPlay;
}
} else {
// reset cache state
// in the event it was false due to previous step error but onFailure: continue
cacheOk = true;
step.flowResult = onSuccess;
if(!deepEqual(play, newTransformedPlay)) {
const o = JSON.parse(JSON.stringify(play));
const t = JSON.parse(JSON.stringify(newTransformedPlay));
const patch = jdiff.diff(o,t);
step.patch = patch;
}
if(newTransformedPlay.meta.lifecycleInputs?.length > 0) {
step.inputs = clone(newTransformedPlay.meta.lifecycleInputs)
} else if(transformedPlay.meta.lifecycleInputs?.length > 0) {
step.inputs = clone(transformedPlay.meta.lifecycleInputs)
}
delete transformedPlay.meta.lifecycleInputs;
// for logging only
transformHistory.push({
type: hookItem.type,
name: stageName,
play: newTransformedPlay.data
});
transformedPlay = newTransformedPlay;
}
if(err === undefined && onSuccess === 'stop') {
logger.debug(`Stopping transform due to onSuccess: stop`);
shouldBreak = true;
}
if(existingStepIndex !== -1) {
transformedPlay.meta.lifecycle.steps[existingStepIndex] = step;
} else {
transformedPlay.meta.lifecycle.steps.push(step);
}
if(shouldBreak) {
break;
}
}
const historyToDiff: {name: string, data?: PlayData}[] = [
{name: 'Pre Transform', data: play.data}
];
if(steps.length > 0 && transformedPlay.meta?.lifecycle?.steps === undefined) {
const {
meta: {
lifecycle: {
steps = [],
...lifecycleRest
},
} = {}
} = transformedPlay;
transformedPlay.meta.lifecycle = {
...lifecycleRest,
steps
}
}
steps.forEach((s, index) => {
const existingStepIndex = transformedPlay.meta.lifecycle.steps.findIndex(x => x.name === 'hookType' && x.source === this.getIdentifier());
if(existingStepIndex !== -1) {
transformedPlay.meta.lifecycle.steps[existingStepIndex] = s;
} else {
transformedPlay.meta.lifecycle.steps.push(s);
}
if(shouldLog === 'all') {
if(s.patch === undefined) {
historyToDiff.push({name: `${s.source}-${s.name}`});
} else {
const patched = patchObject(historyToDiff[historyToDiff.length - 1].data, s.patch);
historyToDiff.push({name: `${s.source}-${s.name}${s.cached ? ' (Cached)' : ''}`, data: patched});
}
}
});
if(shouldLog !== false) {
if(transformHistory.length === 0) {
if(steps.filter(x => x.patch !== undefined).length === 0) {
logger.debug('Transform Diff: No Change');
} else {
const historyToDiff: {name: string, data: PlayData}[] = [
{name: 'Original', data: play.data}
];
if(shouldLog === true) {
const last = transformHistory[transformHistory.length - 1];
historyToDiff.push({name: `${last.type}-${last.name}`, data: last.play});
} else {
for(const t of transformHistory) {
historyToDiff.push({name: `${t.type}-${t.name}`, data: t.play})
}
}
const diffs: string[] = [];
historyToDiff.forEach((curr, index) => {
if(index === 0) {
return;
}
const last = historyToDiff[index - 1];
if(deepEqual(last.data, curr.data)) {
if(curr.data === undefined) {
diffs.push(`${last.name} => ${curr.name} -- No Change`);
} else {
const unchanged = index - 1 === 0 ? last.data : undefined;
const formattedDiff = console.format(jdiff.diff(last.data, curr.data), unchanged);
const formattedDiff = diffObjectsConsoleOutput(last.data, curr.data);
diffs.push(`${last.name} => ${curr.name}\n${formattedDiff}`);
}
});
if(shouldLog === true || steps.filter(x => x.patch !== undefined).length > 2) {
const finalData = transformedPlay.data;
const formattedDiff = diffObjectsConsoleOutput(play.data, finalData, true);
diffs.push(`Original => Final\n${formattedDiff}`);
}
logger.debug(`Transform Diff\n${diffs.join('\n')}`);
}
}
if(cacheOk) {
await this.cache.cacheTransform.set<PlayObject>(transformHash, transformedPlay, '10m');
await this.cache.cacheTransform.set<LifecycleStep[]>(transformHash, steps, '10m');
}
return transformedPlay;
@@ -379,4 +321,103 @@ export default abstract class AbstractComponent extends AbstractInitializable {
return play;
}
}
protected generateStepFromStage = async (playTruth: PlayObject, hookItem: StageConfig, hookType: TransformHook, opts: { logger?: Logger, asyncId?: string } = {}): Promise<[LifecycleStep, PlayObject]> => {
const {
onSuccess = 'continue',
onFailure = 'stop',
onSkip = 'continue',
failureReturnPartial = false
} = hookItem;
const {
logger = loggerNoop,
asyncId = nanoid(6)
} = opts;
const stepName = `${hookType} - ${hookItem.type} - ${hookItem.name}`
const existingStepIndex = playTruth.meta.lifecycle.steps.findIndex(x => x.name === stepName && x.source === this.getIdentifier());
const step: LifecycleStep = existingStepIndex !== -1 ? playTruth.meta.lifecycle.steps[existingStepIndex] : {
name: stepName,
source: this.getIdentifier(),
}
let newTransformedPlay: PlayObject,
stageName: string = 'Unnamed',
err: Error;
try {
[newTransformedPlay, stageName] = await this.transformManager.handleStage(hookItem, playTruth, asyncId);
newTransformedPlay = clone(newTransformedPlay);
} catch (e) {
err = e;
if (e instanceof StageTransformError) {
stageName = e.stageName;
}
}
if (err !== undefined) {
const merged = mergeSimpleError(err);
step.error = merged;
const skipError = findCauseByReference(err, SkipTransformStageError);
if (skipError !== undefined) {
let skipMsg = `Stage '${stageName}' was skipped`;
step.flowResult = onSkip;
step.flowKnownState = 'skip';
if (onSkip === 'stop') {
skipMsg += ' and will stop transform due to onSkip: stop';
}
step.flowReason = skipMsg;
logger.debug(merged, skipMsg);
} else {
step.flowResult = onFailure;
let reason: string;
const reqError = findCauseByReference(err, StagePrerequisiteError);
if (reqError !== undefined) {
reason = 'Transform could not be completed due to prerequisite failure';
step.flowKnownState = 'prereq';
} else {
reason = 'Transform encountered an error';
}
if (onFailure === 'continue') {
reason += ' but will continue due to onFailure: continue';
logger.warn(merged, reason);
} else {
if (failureReturnPartial) {
reason += ' | Preserving play transformations up to this point due to failureReturnPartial=true';
step.returnPartial = true;
}
logger[reqError !== undefined ? 'warn' : 'error'](merged, reason);
}
step.flowReason = reason;
return [step, playTruth];
}
} else {
step.flowResult = onSuccess;
if (!deepEqual(playTruth.data, newTransformedPlay.data)) {
const o = JSON.parse(JSON.stringify(playTruth.data));
const t = JSON.parse(JSON.stringify(newTransformedPlay.data));
const patch = diffObjects(o, t); // jdiff.diff(o, t);
step.patch = patch;
}
if (newTransformedPlay.meta.lifecycleInputs?.length > 0) {
step.inputs = clone(newTransformedPlay.meta.lifecycleInputs)
} else if (playTruth.meta.lifecycleInputs?.length > 0) {
logger.warn({ label: `${hookItem.type} - ${hookItem.name}` }, `Should only be adding inputs to transformed play!`);
step.inputs = clone(playTruth.meta.lifecycleInputs)
}
delete newTransformedPlay.meta.lifecycleInputs;
}
return [step, newTransformedPlay];
}
}
@@ -50,6 +50,10 @@ export interface Whennable {
}
export type FlowControlTerm = 'continue' | 'stop'
export const FLOW_CONTROL_TERM = {
continue: 'continue',
stop: 'stop'
} as const satisfies Record<string, FlowControlTerm>;
export interface FlowControl {
onSuccess: FlowControlTerm
@@ -16,6 +16,8 @@ import { Cacheable } from "cacheable";
import { TransformerCommonConfig } from "../../../core/Atomic.js";
import TransformerManager from "../../common/transforms/TransformerManager.js";
import { transientCache } from "../utils/CacheTestUtils.js";
import dayjs from "dayjs";
import clone from "clone";
chai.use(asPromised);
@@ -638,6 +640,49 @@ describe('Play Transforms', function () {
});
describe("Stage Caching", function () {
it('Re-uses steps without modifying other Play properties', async function () {
component.config = {
options: {
playTransform: {
preCompare: [
{
name: "barChange",
title: [
{
search: "something",
replace: "bar"
}
]
},
{
type: 'native'
}
]
}
}
}
const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, ambiguousJoinedNames: true, trailingAmpersand: true, finalJoiner: false}});
component.buildTransformRules();
const play = generatePlay({ track: 'My cool something track', artists: [str], playDate: dayjs().subtract(10, 'm') });
const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare, 'all');
expect(transformed.data.track).equal('My cool bar track');
expect(transformed.data.artists).eql(primaries.concat(secondaries));
const cachablePlay = clone(play);
const laterDate = dayjs().subtract(5, 'm');
cachablePlay.data.playDate = laterDate;
const cacheTransformed = await component.transformPlay(cachablePlay, TRANSFORM_HOOK.preCompare, 'all');
expect(cacheTransformed.data.track).equal('My cool bar track');
expect(cacheTransformed.data.artists).eql(primaries.concat(secondaries));
expect(cacheTransformed.data.playDate.isSame(cachablePlay.data.playDate));
});
});
describe('Transform Manager', function() {
it('Uses user transforms in the order supplied within component', async function() {
+5 -5
View File
@@ -1,10 +1,10 @@
import React, { ComponentProps } from 'react';
import JsonDiffReact from 'jsondiffpatch-react';
import { MSErrorBoundary } from './ErrorBoundary';
import { Delta } from 'jsondiffpatch';
import { MarkOptional } from 'ts-essentials';
import { jdiff } from '../../core/DataUtils';
import { patchObject } from '../../core/DataUtils';
import './JsonDiff.css';
import { IJsonDelta } from 'json-diff-ts';
type DiffableVal = [] | object | string;
@@ -18,7 +18,7 @@ export interface JsonDiffReactProps {
}
export type JsonDiffPatchProps = MarkOptional<JsonDiffReactProps, 'right'> & {
diff?: Delta | object
diff?: IJsonDelta
}
export const JsonDiffPatch = (props: JsonDiffPatchProps) => {
@@ -31,9 +31,9 @@ export const JsonDiffPatch = (props: JsonDiffPatchProps) => {
const detachedLeft = JSON.parse(JSON.stringify(left));
let realRight: DiffableVal;
if (right !== undefined) {
realRight = JSON.parse(JSON.stringify(right));
realRight = structuredClone(right);
} else if (diff !== undefined) {
realRight = jdiff.patch(JSON.parse(JSON.stringify(left)), diff as Delta) as DiffableVal;
realRight = patchObject(left, diff);
} else {
throw new Error(`must provide either 'right' or 'diff'`);
}
+6 -6
View File
@@ -12,7 +12,7 @@ import { FaStop } from "react-icons/fa";
import { MdMusicNote } from "react-icons/md";
import { ChakraCodeBlockShort, ChakraPlainBlockShort } from "./CodeBlock";
import { JsonDiffPatch } from "./JsonDiff";
import { jdiff } from "../../core/DataUtils";
import { jdiff, patchObject } from "../../core/DataUtils";
import { MSCollapsible, MSCollapsibleExternalProps } from "./MSCollapsible";
import { Muted } from "./Typography";
@@ -23,7 +23,7 @@ export interface LifeycleStepsTimelineProps extends MSCollapsibleExternalProps {
const diffElements = (original: JsonPlayObject, steps: LifecycleStep[]): [JSX.Element[], JsonPlayObject?] => {
let currentPlay: JsonPlayObject = JSON.parse(JSON.stringify(original));
let currentPlay: JsonPlayObject = structuredClone(original); // JSON.parse(JSON.stringify(original));
let patchFailed = false;
const diffElements: JSX.Element[] | null = [];
@@ -49,7 +49,7 @@ const diffElements = (original: JsonPlayObject, steps: LifecycleStep[]): [JSX.El
diffElements.push(<ChakraCodeBlockShort key={`diffblockfallback-${index}`} title="Diff Patch" code={patch} />);
continue;
}
let left: JsonPlayObject = JSON.parse(JSON.stringify(currentPlay));
let left: JsonPlayObject = structuredClone(currentPlay); // JSON.parse(JSON.stringify(currentPlay));
left.data.meta = {
...(left.data.meta ?? {}),
brainz: {
@@ -64,10 +64,10 @@ const diffElements = (original: JsonPlayObject, steps: LifecycleStep[]): [JSX.El
}
try {
currentPlay = jdiff.patch(currentPlay, patch) as JsonPlayObject;
currentPlay.data = patchObject(currentPlay.data, patch)// jdiff.patch(currentPlay, patch) as JsonPlayObject;
diffElements.push(
<ChakraPlainBlockShort title="Play Diff" key={`diffblock-${index}`} code={left}>
<JsonDiffPatch key={`diff-${index}`} left={left} right={JSON.parse(JSON.stringify(currentPlay))} />
<ChakraPlainBlockShort title="Play Diff" key={`diffblock-${index}`} code={left.data}>
<JsonDiffPatch key={`diff-${index}`} left={left.data} right={currentPlay.data} />
</ChakraPlainBlockShort>
)
} catch (e) {
+3 -1
View File
@@ -7,6 +7,7 @@ import { MarkOptional } from "ts-essentials";
import { ErrorObject } from "serialize-error";
import { PlayPlatformIdStr } from "../backend/common/infrastructure/Atomic.js";
import { FlowControlTerm } from "../backend/common/infrastructure/Transform.js";
import { IJsonDelta } from "json-diff-ts";
export interface SourceStatusData {
status: string;
@@ -311,11 +312,12 @@ export interface LifecycleStep {
name: string
source: string
cached?: boolean
returnPartial?: boolean
flowResult?: FlowControlTerm
flowReason?: string
flowKnownState?: 'skip' | 'prereq'
error?: ErrorLike
patch?: Delta
patch?: IJsonDelta
inputs?: LifecycleInput[]
}
+36
View File
@@ -1,6 +1,14 @@
import { create as diffCreate } from "jsondiffpatch";
import { numberFormatOptions } from './Atomic.js';
import { diffDelta, applyDelta, IJsonDelta, DeltaOptions } from 'json-diff-ts';
// may want to return to this one day
// but currently the jsondiffpatch formatter is the best console/ansi diff output for humans :(
//import {DiffOptions, DiffOptionsColor, diff as jestDiff} from 'jest-diff';
import chalk from 'chalk';
import clone from "clone";
import ConsoleFormatter from "jsondiffpatch/formatters/console";
const console = new ConsoleFormatter();
export const jdiff = diffCreate({
propertyFilter(name, context) {
@@ -10,6 +18,34 @@ export const jdiff = diffCreate({
//omitRemovedValues: true
});
const diffOptions: DeltaOptions = {
/*arrayIdentityKeys: {artists: '$value'},*/
reversible: false,
keysToSkip: ['playDate','playDateCompleted','listenRanges']
};
export const diffObjects = (a: object, b: object) => {
return diffDelta(a, b, diffOptions);
}
export const patchObject = <T>(a: T, b: IJsonDelta): T => {
return applyDelta(clone(a), b);
}
// const jestDiffOptions: DiffOptions = {
// aAnnotation: 'Old',
// bAnnotation: 'New',
// aColor: chalk.red,
// bColor: chalk.green
// }
export const diffObjectsConsoleOutput = (a: object, b: object, showUnchanged: boolean = false) => {
//return jestDiff(a, b, jestDiffOptions);
const left = JSON.parse(JSON.stringify(a));
return console.format(jdiff.diff(left, JSON.parse(JSON.stringify(b))), showUnchanged ? left : undefined);
}
export const formatNumber = (val: number | string, options?: numberFormatOptions) => {
const {
toFixed = 2, defaultVal = null, prefix = '', suffix = '', round,
+2 -2
View File
@@ -8,7 +8,7 @@ import { MarkOptional } from 'ts-essentials';
import { generateBrainz, generateMbid, generatePlay, GeneratePlayOpts, generatePlays } from '../../PlayTestUtils.js';
import { lifecyclelessInvariantTransform } from '../../PlayUtils.js';
import clone from 'clone';
import { jdiff } from '../../DataUtils.js';
import { diffObjects } from '../../DataUtils.js';
import { existingScrobble } from '../../../backend/utils/PlayComparisonUtils.js';
import { UpstreamError } from '../../../backend/common/errors/UpstreamError.js';
import { playToListenPayload } from '../../../backend/common/vendor/listenbrainz/lzUtils.js';
@@ -312,7 +312,7 @@ export const generateLifecycleStep = (play: PlayObject, opts: GenerateLifecycleO
});
}
step.patch = jdiff.diff(play, modifiedPlay);
step.patch = diffObjects(play.data, modifiedPlay.data);// jdiff.diff(play, modifiedPlay);
return [step, modifiedPlay];
}
+12 -12
View File
@@ -8,7 +8,7 @@ import {Provider} from "../client/components/Provider";
import { generateJsonPlays } from "../core/PlayTestUtils.js";
import { ErrorLike, JsonPlayObject, PlayLifecycle } from "../core/Atomic.js";
import { examplePlay, lastfmErrorExample } from "./storyUtils.js";
import { generatePlayWithLifecycle, playWithLifecycleScrobble } from "../core/tests/utils/fixtures.js";
import { asJsonPlayObject, generatePlayWithLifecycle, playWithLifecycleScrobble } from "../core/tests/utils/fixtures.js";
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = preview.meta({
@@ -35,14 +35,14 @@ decorators: [
export const ActivityTimelineStory = meta.story({
loaders: [
async () => {
const scrobbleError = await playWithLifecycleScrobble(generatePlayWithLifecycle(
const scrobbleError = asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle(
{
lifecycleSteps: {
preCompare: 1,
postCompare: 1,
}
}
))
)))
return {play: scrobbleError};
}
],
@@ -51,7 +51,7 @@ export const ActivityTimelineStory = meta.story({
export const ScrobbleError = meta.story({
loaders: [
async () => {
const scrobbleError = await playWithLifecycleScrobble(generatePlayWithLifecycle(), {error: true});
const scrobbleError = asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle(), {error: true}));
return {play: scrobbleError};
}
],
@@ -60,12 +60,12 @@ export const ScrobbleError = meta.story({
export const TransformError = meta.story({
loaders: [
async () => {
const scrobbleError = await generatePlayWithLifecycle({
const scrobbleError = asJsonPlayObject(await generatePlayWithLifecycle({
lifecycleSteps: {
preCompare: 2,
postCompare: [false],
}
});
}));
return {play: scrobbleError};
}
],
@@ -74,11 +74,11 @@ export const TransformError = meta.story({
export const TransformSkip = meta.story({
loaders: [
async () => {
const play = await generatePlayWithLifecycle({
const play = asJsonPlayObject(await generatePlayWithLifecycle({
lifecycleSteps: {
preCompare: [true, 'skipped', true],
}
});
}));
return {play};
}
],
@@ -87,11 +87,11 @@ export const TransformSkip = meta.story({
export const TransformPrereq = meta.story({
loaders: [
async () => {
const play = await generatePlayWithLifecycle({
const play = asJsonPlayObject(await generatePlayWithLifecycle({
lifecycleSteps: {
preCompare: [true, 'prereq'],
}
});
}));
return {play};
}
],
@@ -100,11 +100,11 @@ export const TransformPrereq = meta.story({
export const TransformStop = meta.story({
loaders: [
async () => {
const play = await generatePlayWithLifecycle({
const play = asJsonPlayObject(await generatePlayWithLifecycle({
lifecycleSteps: {
preCompare: [true, 'stop'],
}
});
}));
return {play};
}
],
+5 -5
View File
@@ -8,7 +8,7 @@ import {Provider} from "../client/components/Provider";
import { generateJsonPlays, normalizePlays } from "../core/PlayTestUtils.js";
import { ErrorLike, JsonPlayObject } from "../core/Atomic.js";
import {examplePlay, lastfmErrorExample} from './storyUtils.js';
import {playWithLifecycleScrobble, generatePlayWithLifecycle} from '../core/tests/utils/fixtures'
import {playWithLifecycleScrobble, generatePlayWithLifecycle, asJsonPlayObject} from '../core/tests/utils/fixtures'
import { generateArray } from "../core/DataUtils.js";
import dayjs from "dayjs";
@@ -55,14 +55,14 @@ decorators: [
export const List = meta.story({
loaders: [
async () => {
const queued = normalizePlays(generateArray(7,() => generatePlayWithLifecycle()), {endDate: dayjs()}).map(x => ({play: x, status: 'queued'}));
const queued = normalizePlays(generateArray(7,() => generatePlayWithLifecycle()), {endDate: dayjs()}).map(x => ({play: asJsonPlayObject(x), status: 'queued'}));
const scrobbled = await playWithLifecycleScrobble(generatePlayWithLifecycle({lifecycleSteps: {preCompare: [true, 'skipped', true]}}));
const scrobbleError = await playWithLifecycleScrobble(generatePlayWithLifecycle(), {error: true});
const scrobbled = asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle({lifecycleSteps: {preCompare: [true, 'skipped', true]}})));
const scrobbleError = asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle(), {error: true}));
const promisedScrobbled = generateArray(10,() => playWithLifecycleScrobble(generatePlayWithLifecycle({lifecycleSteps: {preCompare: [true, 'skipped', true]}})));
const promised = await Promise.all(promisedScrobbled);
const yesterdayScrobbled = normalizePlays(promised, {endDate: dayjs().subtract(1, 'd').subtract(100, 'm')}).map((x) => ({play: x, status: 'scrobbled'}));
const yesterdayScrobbled = normalizePlays(promised, {endDate: dayjs().subtract(1, 'd').subtract(100, 'm')}).map((x) => ({play: asJsonPlayObject(x), status: 'scrobbled'}));
return {data: [
...queued,
{play: scrobbled, status: 'scrobbled'},
+2 -2
View File
@@ -8,7 +8,7 @@ import {Provider} from "../client/components/Provider";
import { generateJsonPlays } from "../core/PlayTestUtils.js";
import { ErrorLike, JsonPlayObject, PlayLifecycle } from "../core/Atomic.js";
import { examplePlay, lastfmErrorExample } from "./storyUtils.js";
import {generatePlayWithLifecycle} from '../core/tests/utils/fixtures'
import {asJsonPlayObject, generatePlayWithLifecycle} from '../core/tests/utils/fixtures'
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = preview.meta({
@@ -27,7 +27,7 @@ decorators: [
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args
});
const multiPlay = generatePlayWithLifecycle({lifecycleSteps: { preCompare: 2}});
const multiPlay = asJsonPlayObject(generatePlayWithLifecycle({lifecycleSteps: { preCompare: 2}}));
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const Multiple = meta.story({
args: {