Files
Jyotisha/frontend/tests/birth-time-journey-memory-store.ts
2026-07-20 14:58:57 +08:00

254 lines
9.1 KiB
TypeScript

import { isDeepStrictEqual } from "node:util";
import { samePersistedDynamicReceipt } from "../src/lib/birth-time-dynamic-action-replay.ts";
import type {
BirthTimeJourneyStore,
DynamicScoringJobCommand,
DynamicScoringJobFailureCommand,
DynamicStoredRectificationCase,
PersistedJourneyAssessment,
StoredRectificationCase,
} from "../src/lib/birth-time-journey-service.ts";
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
import {
isTerminalLegacyCase,
prepareLegacyDynamicUpgrade,
} from "../src/lib/birth-time-journey-dynamic-state.ts";
import { createMemoryScoringJobs } from "./birth-time-scoring-memory-store.ts";
class MissingTestCaseError extends Error {
readonly name = "MissingTestCaseError";
}
export const journeyCaseId = "7299894c-10a8-4b45-91d1-339007282c50";
type DynamicScoringReceipt = {
readonly kind: "complete" | "fail";
readonly command: DynamicScoringJobCommand | DynamicScoringJobFailureCommand;
readonly result: DynamicStoredRectificationCase["candidateResult"];
};
function sameScoringOperation(
receipt: DynamicScoringReceipt,
next: DynamicScoringReceipt,
): boolean {
const prior = receipt.command;
const command = next.command;
return receipt.kind === next.kind
&& prior.expectedVersion === command.expectedVersion
&& prior.jobId.toLowerCase() === command.jobId.toLowerCase()
&& prior.evidenceFingerprint === command.evidenceFingerprint
&& prior.algorithmVersion === command.algorithmVersion
&& (receipt.kind !== "fail" || (
"failureCode" in prior && "failureCode" in command
&& prior.failureCode === command.failureCode
))
&& (receipt.kind !== "complete" || isDeepStrictEqual(receipt.result, next.result));
}
export function memoryStore(
initialCase?: StoredRectificationCase,
asOfDate = "2026-07-18",
) {
let savedAssessment: PersistedJourneyAssessment | null = null;
let savedCase = initialCase ?? null;
let savedDynamicCase: DynamicStoredRectificationCase | null =
initialCase?.journeyProtocol === "dynamic-choice-v2" ? initialCase : null;
let committedTurnWrites = 0;
let legacyWrites = 0;
let guidedCandidateWrites = 0;
let dynamicScoringReceipt: DynamicScoringReceipt | null = null;
const scoringJobs = createMemoryScoringJobs({
read: () => savedCase,
write: (value) => {
savedCase = value;
},
committed: () => {
committedTurnWrites += 1;
},
});
function persistDynamicScoring(
value: DynamicStoredRectificationCase,
receipt: DynamicScoringReceipt,
): DynamicStoredRectificationCase {
if (!savedDynamicCase) throw new MissingTestCaseError();
const expectedVersion = receipt.command.expectedVersion;
if (savedDynamicCase.turnVersion === expectedVersion + 1) {
if (dynamicScoringReceipt && sameScoringOperation(dynamicScoringReceipt, receipt)) {
return savedDynamicCase;
}
throw new StaleJourneyTurnError(value.id, expectedVersion, savedDynamicCase.turnVersion);
}
if (savedDynamicCase.turnVersion !== expectedVersion) {
throw new StaleJourneyTurnError(value.id, expectedVersion, savedDynamicCase.turnVersion);
}
const saved = {
...value,
turnVersion: expectedVersion + 1,
dynamicTurnState: { ...value.dynamicTurnState, turnVersion: expectedVersion + 1 },
};
savedCase = saved;
savedDynamicCase = saved;
dynamicScoringReceipt = receipt;
return saved;
}
const store: BirthTimeJourneyStore = {
async saveAssessment(value) {
savedAssessment = value;
return journeyCaseId;
},
async loadCase() {
return savedCase;
},
async saveScoring(value) {
legacyWrites += 1;
savedCase = value;
},
async saveTurn(value, expectedVersion, actionId) {
if (!savedCase) {
throw new MissingTestCaseError();
}
if (savedCase.journeyProtocol !== "legacy-guided-v1") {
throw new StaleJourneyTurnError(savedCase.id, expectedVersion, savedCase.turnVersion);
}
const processedActionIds = savedCase.processedActionIds ?? [];
if (processedActionIds.includes(actionId)) {
return savedCase;
}
if (savedCase.turnVersion !== expectedVersion) {
throw new StaleJourneyTurnError(savedCase.id, expectedVersion, savedCase.turnVersion ?? 0);
}
savedCase = {
...value,
turnVersion: expectedVersion + 1,
processedActionIds: [...processedActionIds, actionId],
};
committedTurnWrites += 1;
return savedCase;
},
async saveDynamicTurn(value, expectedVersion, actionId) {
if (!savedCase) throw new MissingTestCaseError();
const receipt = actionId.toLowerCase();
const receipts = savedCase.processedActionIds ?? [];
if (receipts.includes(receipt)) {
if (!savedDynamicCase) throw new MissingTestCaseError();
if (samePersistedDynamicReceipt(value, savedDynamicCase, receipt, expectedVersion)) {
return savedDynamicCase;
}
throw new StaleJourneyTurnError(value.id, expectedVersion, savedDynamicCase.turnVersion);
}
if (savedCase.turnVersion !== expectedVersion) {
throw new StaleJourneyTurnError(savedCase.id, expectedVersion, savedCase.turnVersion ?? 0);
}
const savedDynamic = {
...value,
turnVersion: expectedVersion + 1,
dynamicTurnState: { ...value.dynamicTurnState, turnVersion: expectedVersion + 1 },
processedActionIds: [...receipts, receipt],
};
savedCase = savedDynamic;
savedDynamicCase = savedDynamic;
committedTurnWrites += 1;
return savedDynamic;
},
async confirmDynamicCandidate(value, command) {
if (!savedCase || !savedDynamicCase) throw new MissingTestCaseError();
const receipt = command.actionId.toLowerCase();
if ((savedCase.processedActionIds ?? []).includes(receipt)) {
if (samePersistedDynamicReceipt(value, savedDynamicCase, receipt, command.expectedVersion)) {
return savedDynamicCase;
}
throw new StaleJourneyTurnError(value.id, command.expectedVersion, savedDynamicCase.turnVersion);
}
if (savedCase.journeyProtocol !== "dynamic-choice-v2" || savedCase.turnVersion !== command.expectedVersion) {
throw new StaleJourneyTurnError(savedCase.id, command.expectedVersion, savedCase.turnVersion ?? 0);
}
const saved = {
...value,
turnVersion: command.expectedVersion + 1,
dynamicTurnState: { ...value.dynamicTurnState, turnVersion: command.expectedVersion + 1 },
processedActionIds: [...(savedCase.processedActionIds ?? []), receipt],
};
savedCase = saved;
savedDynamicCase = saved;
committedTurnWrites += 1;
return saved;
},
async completeDynamicScoringJob(value, command) {
return persistDynamicScoring(value, { kind: "complete", command, result: value.candidateResult });
},
async failDynamicScoringJob(value, command) {
return persistDynamicScoring(value, { kind: "fail", command, result: null });
},
async upgradeLegacyActiveCase(value) {
if (isTerminalLegacyCase(value)) {
return value;
}
const upgraded = prepareLegacyDynamicUpgrade(value, asOfDate);
savedCase = upgraded;
savedDynamicCase = upgraded;
return upgraded;
},
...scoringJobs.methods,
async saveCandidateResult(value) {
legacyWrites += 1;
savedCase = value;
},
async saveCandidate(value) {
legacyWrites += 1;
savedCase = value;
},
async confirmCandidate(value) {
legacyWrites += 1;
savedCase = value;
},
async commitGuidedCandidate(value, command) {
if (!savedCase) {
throw new MissingTestCaseError();
}
if (savedCase.journeyProtocol !== "legacy-guided-v1") {
throw new StaleJourneyTurnError(
savedCase.id,
command.expectedVersion,
savedCase.turnVersion,
);
}
const receipt = command.actionId.toLowerCase();
const receipts = savedCase.processedActionIds ?? [];
if (receipts.includes(receipt)) {
return savedCase;
}
if (savedCase.turnVersion !== command.expectedVersion) {
throw new StaleJourneyTurnError(
savedCase.id,
command.expectedVersion,
savedCase.turnVersion ?? 0,
);
}
savedCase = {
...value,
turnVersion: command.expectedVersion + 1,
processedActionIds: [...receipts, receipt],
};
guidedCandidateWrites += 1;
return savedCase;
},
};
return {
store,
savedAssessment: () => savedAssessment,
savedCase: () => savedCase,
committedTurnWrites: () => committedTurnWrites,
legacyWrites: () => legacyWrites,
guidedCandidateWrites: () => guidedCandidateWrites,
scoringJobStatus: scoringJobs.status,
scoringJobCount: scoringJobs.count,
setScoringJobAlgorithm: scoringJobs.setAlgorithm,
createdScoringCase: scoringJobs.createdCase,
replaceCase: (value: StoredRectificationCase) => {
savedCase = value;
savedDynamicCase = value.journeyProtocol === "dynamic-choice-v2" ? value : null;
dynamicScoringReceipt = null;
},
};
}