feat: execute route and release recovery contracts

This commit is contained in:
donghyeon-ka
2026-07-26 14:26:39 +09:00
parent a33e93d4d4
commit ce0040e407
49 changed files with 1761 additions and 373 deletions
+45
View File
@@ -5,6 +5,7 @@ import type {
RenderFailureReport,
} from "./ports/in/application-api.js";
import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.js";
import { decideChunkRecovery } from "./use-cases/decide-chunk-recovery.js";
export type { ApplicationApi, ApplicationOutputPorts };
@@ -64,10 +65,54 @@ export function createApplication(
},
});
const recovery = Object.freeze({
async recoverChunk(input: {
chunkId: string;
failureKind: "CHUNK_LOAD_FAILURE" | "DEPLOY_MISMATCH";
}) {
try {
const current = await outputPorts.releaseInfo.getCurrent();
const active = await outputPorts.releaseInfo.refresh();
if (!active.routeChunks[input.chunkId]) {
return {
action: "support" as const,
reason: "active-chunk-unknown",
};
}
const decision = decideChunkRecovery({
failureKind: input.failureKind,
manifestLoaded: true,
currentBuildId: current.buildId,
currentReleaseId: current.releaseId,
activeBuildId: active.buildId,
activeReleaseId: active.releaseId,
storage: outputPorts.preferences,
});
if (decision.action === "reload-once") {
try {
outputPorts.navigation.reload();
} catch {
return {
action: "support" as const,
reason: "reload-failed",
};
}
}
return decision;
} catch {
return {
action: "support" as const,
reason: "manifest-unavailable",
};
}
},
});
return Object.freeze({
session,
preferences,
diagnostics,
runtime,
recovery,
});
}
@@ -38,4 +38,13 @@ export type ApplicationApi = Readonly<{
runtime: Readonly<{
getReleaseSummary(): Promise<ReleaseSummary>;
}>;
recovery: Readonly<{
recoverChunk(input: Readonly<{
chunkId: string;
failureKind: "CHUNK_LOAD_FAILURE" | "DEPLOY_MISMATCH";
}>): Promise<
| Readonly<{ action: "reload-once"; releasePair: string }>
| Readonly<{ action: "support"; reason: string }>
>;
}>;
}>;
@@ -15,4 +15,5 @@ export type ApplicationOutputPorts = Readonly<{
preferences: StoragePort;
diagnostics: TelemetryPort;
releaseInfo: ReleaseInfoPort;
navigation: Readonly<{ reload(): void }>;
}>;
+10 -1
View File
@@ -9,7 +9,16 @@
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string,
* builtAt?: string
* builtAt?: string,
* routeChunks: Record<string, string>
* }>,
* refresh(): Promise<{
* buildId: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string,
* routeChunks: Record<string, string>
* }>
* }} ReleaseInfoPort
*/
@@ -1,13 +1,21 @@
const RECOVERABLE_KINDS = new Set(["CHUNK_LOAD_FAILURE", "DEPLOY_MISMATCH"]);
/**
* @typedef {{action: "reload-once", releasePair: string} |
* {action: "support", reason: string}} ChunkRecoveryDecision
*/
/**
* @param {{
* failureKind: string,
* manifestLoaded: boolean,
* currentBuildId: string,
* currentReleaseId: string,
* activeBuildId: string,
* activeReleaseId: string,
* storage: import("../ports/storage-port.js").StoragePort
* }} input
* @returns {ChunkRecoveryDecision}
*/
export function decideChunkRecovery(input) {
if (!RECOVERABLE_KINDS.has(input.failureKind)) {
@@ -16,13 +24,21 @@ export function decideChunkRecovery(input) {
if (!input.manifestLoaded) {
return { action: "support", reason: "manifest-unavailable" };
}
if (input.activeReleaseId === input.currentBuildId) {
if (
input.activeBuildId === input.currentBuildId &&
input.activeReleaseId === input.currentReleaseId
) {
return { action: "support", reason: "same-release" };
}
const releasePair = `${input.currentBuildId}->${input.activeReleaseId}`;
const releasePair =
`${input.currentBuildId}/${input.currentReleaseId}` +
`->${input.activeBuildId}/${input.activeReleaseId}`;
const guard = input.storage.read("CHUNK_RELOAD_GUARD");
if (!guard.ok || guard.value === releasePair) {
if (!guard.ok) {
return { action: "support", reason: "guard-read-failed" };
}
if (guard.value === releasePair) {
return { action: "support", reason: "reload-already-attempted" };
}