refactor: 리펙토링
This commit is contained in:
@@ -6,23 +6,32 @@ import {
|
||||
} from "../adapters/auth/external-session-adapter.ts";
|
||||
import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.ts";
|
||||
import { createHttpClient } from "../adapters/http/client.ts";
|
||||
import { createContractHttpExecutor } from "../adapters/http/http-execution-v3.ts";
|
||||
import { createBrowserCrossContextInvalidationFromHost } from "../adapters/cross-context-invalidation/index.ts";
|
||||
import { createTanStackCacheCoordinator } from "../adapters/query-cache/tanstack-cache-coordinator.ts";
|
||||
import {
|
||||
createTanStackCacheCoordinator,
|
||||
type InstalledQueryInvalidationDefinition,
|
||||
} from "../adapters/query-cache/tanstack-cache-coordinator.ts";
|
||||
import { createQueryClient } from "../adapters/query-cache/tanstack-query-cache.ts";
|
||||
import { createServerStateScopeRuntime } from "../adapters/query-cache/server-state-scope-runtime.ts";
|
||||
import { createConditionalValidatorStore } from "../adapters/query-cache/conditional-validator-store.ts";
|
||||
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.ts";
|
||||
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts";
|
||||
import type { AuthSessionPort } from "../application/ports/auth-session-port.ts";
|
||||
import type { ReleaseInfo } from "../application/ports/release-info-port.ts";
|
||||
import { createRestProviderProfile } from "../contracts/rest-profiles.ts";
|
||||
import type { ClockPort } from "../application/ports/clock-port.ts";
|
||||
import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.ts";
|
||||
import { QUERY_REGISTRY } from "../features/installed-feature-contracts.ts";
|
||||
import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts";
|
||||
import { describeRuntimeCapabilities } from "../contracts/runtime-capabilities.ts";
|
||||
import {
|
||||
fetchReleaseManifest,
|
||||
type ReleaseManifest,
|
||||
} from "./load-release-manifest.ts";
|
||||
import type { RuntimeConfigLoadResult } from "./load-runtime-config.ts";
|
||||
import { createServerStateGenerationStore } from "./server-state-generation-store.ts";
|
||||
import { COMPOSED_CONTRACT_CONTRIBUTIONS } from "../features/installed-contract-contributions.ts";
|
||||
|
||||
type HttpClientDependencies = Parameters<typeof createHttpClient>[0];
|
||||
export type RuntimeHttpContract = Pick<
|
||||
@@ -47,6 +56,26 @@ export type RuntimeAdaptersContext = Readonly<{
|
||||
fetcher?: typeof fetch;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* §5.2. The composition root is where a manifest becomes release info. A V2
|
||||
* manifest states contract identity as a verified contract set and a V1
|
||||
* manifest as the legacy scalar; the application layer sees one shape and never
|
||||
* branches on the schema version to find the identity.
|
||||
*/
|
||||
function toReleaseInfo(manifest: Readonly<ReleaseManifest>): ReleaseInfo {
|
||||
const { contractSet, legacyApiContractVersion, ...rest } =
|
||||
structuredClone(manifest);
|
||||
return Object.freeze({
|
||||
...rest,
|
||||
...(legacyApiContractVersion === undefined
|
||||
? {}
|
||||
: { apiContractVersion: legacyApiContractVersion }),
|
||||
...(contractSet === null
|
||||
? {}
|
||||
: { contractSetDigest: contractSet.setDigest }),
|
||||
});
|
||||
}
|
||||
|
||||
const EXTERNAL_OWNER_METHODS = Object.freeze([
|
||||
"readState",
|
||||
"subscribe",
|
||||
@@ -155,48 +184,69 @@ export async function createRuntimeAdapters(
|
||||
});
|
||||
},
|
||||
});
|
||||
const queryClient = createQueryClient({ diagnostics });
|
||||
const crossContextInvalidation =
|
||||
createBrowserCrossContextInvalidationFromHost({
|
||||
...(context.host === undefined ? {} : { host: context.host }),
|
||||
cacheEpoch: `release.${context.release.releaseId}`,
|
||||
topics: Object.values(QUERY_REGISTRY).map((definition) =>
|
||||
Object.freeze({
|
||||
topic: definition.invalidationTopic,
|
||||
topicVersion: definition.version,
|
||||
}),
|
||||
),
|
||||
observe(observation) {
|
||||
if (
|
||||
observation.outcome !== "FAILED" &&
|
||||
observation.outcome !== "DEGRADED"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
diagnostics.record({
|
||||
level: "warn",
|
||||
eventId: "cache.operation.failed",
|
||||
context: {
|
||||
operation: observation.operation,
|
||||
outcome: observation.outcome,
|
||||
reason: observation.reason,
|
||||
},
|
||||
});
|
||||
},
|
||||
// The composition root states the registry shape it consumes rather than
|
||||
// inferring it from whichever features happen to be installed, so a build
|
||||
// with zero installed features still type-checks.
|
||||
const queryRegistry: Readonly<
|
||||
Record<string, InstalledQueryInvalidationDefinition>
|
||||
> = QUERY_REGISTRY;
|
||||
const conditionalValidators = createConditionalValidatorStore();
|
||||
const serverStateGeneration = createServerStateGenerationStore(() => {
|
||||
const queryClient = createQueryClient({ diagnostics });
|
||||
const crossContextInvalidation =
|
||||
createBrowserCrossContextInvalidationFromHost({
|
||||
...(context.host === undefined ? {} : { host: context.host }),
|
||||
cacheEpoch: `release.${context.release.releaseId}`,
|
||||
topics: Object.values(queryRegistry).map((definition) =>
|
||||
Object.freeze({
|
||||
topic: definition.invalidationTopic,
|
||||
topicVersion: definition.version,
|
||||
}),
|
||||
),
|
||||
observe(observation) {
|
||||
if (
|
||||
observation.outcome !== "FAILED" &&
|
||||
observation.outcome !== "DEGRADED"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
diagnostics.record({
|
||||
level: "warn",
|
||||
eventId: "cache.operation.failed",
|
||||
context: {
|
||||
operation: observation.operation,
|
||||
outcome: observation.outcome,
|
||||
reason: observation.reason,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const queryInvalidation = createTanStackCacheCoordinator({
|
||||
queryClient,
|
||||
queryRegistry,
|
||||
crossContext: crossContextInvalidation,
|
||||
diagnostics,
|
||||
});
|
||||
return Object.freeze({
|
||||
queryClient,
|
||||
queryInvalidation,
|
||||
crossContextStatus: () =>
|
||||
crossContextInvalidation?.getStatus() ?? "DEGRADED_LOCAL_ONLY",
|
||||
});
|
||||
const queryInvalidation = createTanStackCacheCoordinator({
|
||||
queryClient,
|
||||
queryRegistry: QUERY_REGISTRY,
|
||||
crossContext: crossContextInvalidation,
|
||||
diagnostics,
|
||||
});
|
||||
const serverStateScope = createServerStateScopeRuntime({
|
||||
session: authSession,
|
||||
queryInvalidation,
|
||||
});
|
||||
const conditionalValidators = createConditionalValidatorStore();
|
||||
const unsubscribeConditionalScope = serverStateScope.subscribe(() => {
|
||||
conditionalValidators.clear();
|
||||
queryInvalidation: {
|
||||
resetLocal: () => serverStateGeneration.resetCurrent(),
|
||||
},
|
||||
participants: [
|
||||
{
|
||||
order: 4,
|
||||
label: "conditional-validators",
|
||||
close: () => conditionalValidators.clear(),
|
||||
},
|
||||
],
|
||||
activateNextGeneration: () => serverStateGeneration.activateNext(),
|
||||
});
|
||||
const storage = createBrowserStorageAdapter({
|
||||
localStorage: storageOrUndefined(hostValue(host, "localStorage")),
|
||||
@@ -207,14 +257,24 @@ export async function createRuntimeAdapters(
|
||||
});
|
||||
const releaseInfo = Object.freeze({
|
||||
async getCurrent() {
|
||||
return structuredClone(context.release);
|
||||
return toReleaseInfo(context.release);
|
||||
},
|
||||
async refresh() {
|
||||
return fetchReleaseManifest(config.RELEASE_MANIFEST_URL, {
|
||||
fetcher: context.fetcher,
|
||||
buildId: context.release.buildId,
|
||||
releaseId: context.release.releaseId,
|
||||
});
|
||||
return toReleaseInfo(
|
||||
await fetchReleaseManifest(config.RELEASE_MANIFEST_URL, {
|
||||
fetcher: context.fetcher,
|
||||
buildId: context.release.buildId,
|
||||
releaseId: context.release.releaseId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const runtimeCapabilities = Object.freeze({
|
||||
getSnapshot() {
|
||||
return describeRuntimeCapabilities(
|
||||
INSTALLED_RUNTIME_CAPABILITIES,
|
||||
config.CAPABILITY_OVERRIDES,
|
||||
);
|
||||
},
|
||||
});
|
||||
const navigation = Object.freeze({
|
||||
@@ -226,18 +286,105 @@ export async function createRuntimeAdapters(
|
||||
location.reload();
|
||||
},
|
||||
});
|
||||
const contractHttp = createContractHttpExecutor({
|
||||
baseUrl: config.API_BASE_URL,
|
||||
maxRetryAttempts: config.MAX_RETRY_ATTEMPTS,
|
||||
fetcher: context.fetcher,
|
||||
async attachCredentials(operation) {
|
||||
if (serverStateScope.getPhase() !== "READY") {
|
||||
return Object.freeze({ kind: "SCOPE_FENCED" as const });
|
||||
}
|
||||
const state = authSession.getState();
|
||||
if (state === "integration-failed") {
|
||||
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
||||
}
|
||||
if (state !== "authenticated") {
|
||||
return Object.freeze({ kind: "UNAUTHENTICATED" as const });
|
||||
}
|
||||
try {
|
||||
const patch = await authSession.credentialPatch({
|
||||
origin: new URL(config.API_BASE_URL).origin,
|
||||
method: operation.method,
|
||||
operationId: operation.operationId,
|
||||
});
|
||||
if (serverStateScope.getPhase() !== "READY") {
|
||||
return Object.freeze({ kind: "SCOPE_FENCED" as const });
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: "READY" as const,
|
||||
headers: patch.headers,
|
||||
credentials: "omit" as const,
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
||||
}
|
||||
},
|
||||
observe(observation) {
|
||||
try {
|
||||
diagnostics.record({
|
||||
level:
|
||||
observation.outcome === "SUCCESS" ? "info" : "warn",
|
||||
eventId: "http.request.completed",
|
||||
context: {
|
||||
operation_id: observation.diagnosticsOperation,
|
||||
outcome: observation.outcome,
|
||||
attempts: observation.attempts,
|
||||
certainty: observation.certainty,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Diagnostics cannot change a contract execution outcome.
|
||||
}
|
||||
},
|
||||
});
|
||||
let contractExecutionSequence = 0;
|
||||
const contractOperations = Object.freeze({
|
||||
async execute(
|
||||
operationId: string,
|
||||
input: unknown,
|
||||
executionContext: Readonly<{ signal?: AbortSignal }> = {},
|
||||
) {
|
||||
const operation =
|
||||
COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId);
|
||||
if (!operation) {
|
||||
return Object.freeze({
|
||||
kind: "CONTRACT_VIOLATION" as const,
|
||||
effect: "NOT_STARTED" as const,
|
||||
violation: Object.freeze({
|
||||
kind: "FINAL_REQUEST_INVARIANT_FAILED" as const,
|
||||
operation: "REQUEST" as const,
|
||||
}),
|
||||
});
|
||||
}
|
||||
contractExecutionSequence += 1;
|
||||
const intentId = `http-intent-${contractExecutionSequence}`;
|
||||
const isCommand = operation.contract.commandEffect !== null;
|
||||
const requiresKey = operation.contract.retrySemantics === "KEYED";
|
||||
const outcome = await contractHttp.execute(operation, input, {
|
||||
scope: serverStateScope.getSnapshot(),
|
||||
...(executionContext.signal === undefined
|
||||
? {}
|
||||
: { signal: executionContext.signal }),
|
||||
...(isCommand
|
||||
? {
|
||||
intent: Object.freeze({
|
||||
intentId,
|
||||
startedBy: "USER" as const,
|
||||
...(requiresKey
|
||||
? { idempotencyKey: `http-key-${contractExecutionSequence}` }
|
||||
: {}),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (outcome.kind === "UNAUTHENTICATED") {
|
||||
authSession.onUnauthenticated();
|
||||
}
|
||||
return outcome;
|
||||
},
|
||||
});
|
||||
const featureInputs = createInstalledFeatureInputs({
|
||||
createHttpClient: (contract) =>
|
||||
createRuntimeHttpClient(
|
||||
{
|
||||
runtime: context.runtime,
|
||||
authSession,
|
||||
fetcher: context.fetcher,
|
||||
diagnostics,
|
||||
telemetry,
|
||||
},
|
||||
contract,
|
||||
),
|
||||
contractOperations,
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
@@ -247,20 +394,25 @@ export async function createRuntimeAdapters(
|
||||
diagnostics,
|
||||
telemetry,
|
||||
releaseInfo,
|
||||
runtimeCapabilities,
|
||||
navigation,
|
||||
}),
|
||||
infrastructure: Object.freeze({
|
||||
queryClient,
|
||||
queryInvalidation,
|
||||
get queryClient() {
|
||||
return serverStateGeneration.getSnapshot().queryClient;
|
||||
},
|
||||
get queryInvalidation() {
|
||||
return serverStateGeneration.getSnapshot().queryInvalidation;
|
||||
},
|
||||
serverStateGeneration,
|
||||
serverStateScope,
|
||||
conditionalValidators,
|
||||
crossContextInvalidationStatus: () =>
|
||||
crossContextInvalidation?.getStatus() ?? "DEGRADED_LOCAL_ONLY",
|
||||
serverStateGeneration.getSnapshot().crossContextStatus(),
|
||||
dispose() {
|
||||
unsubscribeConditionalScope();
|
||||
conditionalValidators.clear();
|
||||
serverStateScope.dispose();
|
||||
queryInvalidation.dispose();
|
||||
serverStateGeneration.dispose();
|
||||
},
|
||||
}),
|
||||
featureInputs,
|
||||
|
||||
Reference in New Issue
Block a user