254 lines
9.1 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|