feat: connect application input and output boundaries

This commit is contained in:
donghyeon-ka
2026-07-26 13:52:35 +09:00
parent 38ad69236b
commit 2dda17cf19
43 changed files with 697 additions and 215 deletions
-46
View File
@@ -1,46 +0,0 @@
/**
* Application facade factory. Concrete dependencies are supplied by bootstrap.
*
* @param {{
* resources?: {
* query: import("./ports/resource-ports.js").ResourceQueryPort<unknown, unknown>,
* command: import("./ports/resource-ports.js").ResourceCommandPort<unknown, unknown>
* },
* cache: import("./ports/query-cache-port.js").QueryCachePort,
* storage: import("./ports/storage-port.js").StoragePort,
* telemetry: import("./ports/telemetry-port.js").TelemetryPort
* }} ports
*/
export function createApplication(ports) {
/**
* @param {unknown} query
* @param {import("./ports/resource-ports.js").RequestContext} [context]
*/
function queryResources(query, context) {
return /** @type {NonNullable<typeof ports.resources>} */ (
ports.resources
).query.execute(query, context);
}
/**
* @param {unknown} command
* @param {import("./ports/resource-ports.js").RequestContext} [context]
*/
function commandResources(command, context) {
return /** @type {NonNullable<typeof ports.resources>} */ (
ports.resources
).command.execute(command, context);
}
return Object.freeze({
resources: ports.resources
? Object.freeze({
query: queryResources,
command: commandResources,
})
: null,
cache: ports.cache,
storage: ports.storage,
telemetry: ports.telemetry,
});
}
+73
View File
@@ -0,0 +1,73 @@
import { normalizeColorSchemePreference } from "./policies/color-scheme.js";
import type {
ApplicationApi,
ColorSchemePreference,
RenderFailureReport,
} from "./ports/in/application-api.js";
import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.js";
export type { ApplicationApi, ApplicationOutputPorts };
/**
* Builds the driving API consumed by inbound adapters. Concrete output ports
* remain inside these closures and are never returned to React.
*/
export function createApplication(
outputPorts: ApplicationOutputPorts,
): ApplicationApi {
const session = Object.freeze({
getSnapshot: () => outputPorts.session.getState(),
subscribe: (listener: () => void) =>
outputPorts.session.subscribe(listener),
beginSignIn: (returnTo?: string) =>
outputPorts.session.beginSignIn(returnTo),
signOut: () => outputPorts.session.signOut(),
recover: () => outputPorts.session.recover(),
});
const preferences = Object.freeze({
getColorScheme(): ColorSchemePreference {
const result = outputPorts.preferences.read("COLOR_SCHEME");
return normalizeColorSchemePreference(
result.ok ? result.value : undefined,
);
},
setColorScheme(preference: ColorSchemePreference) {
const normalized = normalizeColorSchemePreference(preference);
return outputPorts.preferences.write("COLOR_SCHEME", normalized);
},
});
const diagnostics = Object.freeze({
reportRenderFailure(report: RenderFailureReport) {
try {
outputPorts.diagnostics.emit("ui.render.failed", {
route_id: report.routeId,
build_id: report.buildId,
component_boundary: report.boundaryName,
});
} catch {
// Diagnostics are best-effort and cannot become an application failure.
}
},
});
const runtime = Object.freeze({
async getReleaseSummary() {
const release = await outputPorts.releaseInfo.getCurrent();
return Object.freeze({
buildId: release.buildId,
releaseId: release.releaseId,
configSchemaVersion: release.configSchemaVersion,
apiContractVersion: release.apiContractVersion,
});
},
});
return Object.freeze({
session,
preferences,
diagnostics,
runtime,
});
}
+16 -2
View File
@@ -10,10 +10,24 @@
* subscribe(listener: () => void): () => void,
* beginSignIn(returnTo?: string): Promise<void>,
* signOut(): Promise<void>,
* recover(): Promise<"restored" | "no-session">
* }} SessionGateway
*/
/**
* Credential attachment is an HTTP-adapter collaboration, not an application
* input capability.
*
* @typedef {{
* attach(request: Request): Promise<Request>,
* recover(): Promise<"restored" | "no-session">,
* onUnauthenticated(): void
* }} AuthSessionPort
* }} CredentialAttacher
*/
/**
* External auth adapters implement both segregated capabilities.
*
* @typedef {SessionGateway & CredentialAttacher} AuthSessionPort
*/
export {};
+1 -22
View File
@@ -5,25 +5,4 @@
* }} ClockPort
*/
/** @type {ClockPort} */
export const systemClock = Object.freeze({
now: () => Date.now(),
sleep(milliseconds, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
const timer = setTimeout(resolve, milliseconds);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(signal.reason);
},
{ once: true },
);
});
},
});
export {};
@@ -0,0 +1,41 @@
import type { SessionState } from "../auth-session-port.js";
import type { StoragePort } from "../storage-port.js";
export type { SessionState } from "../auth-session-port.js";
export type ColorSchemePreference = "system" | "light" | "dark";
export type RenderFailureReport = Readonly<{
routeId: string;
buildId: string;
boundaryName: "route" | "feature";
}>;
export type ReleaseSummary = Readonly<{
buildId: string;
releaseId: string;
configSchemaVersion: string;
apiContractVersion: string;
}>;
export type ApplicationApi = Readonly<{
session: Readonly<{
getSnapshot(): SessionState;
subscribe(listener: () => void): () => void;
beginSignIn(returnTo?: string): Promise<void>;
signOut(): Promise<void>;
recover(): Promise<"restored" | "no-session">;
}>;
preferences: Readonly<{
getColorScheme(): ColorSchemePreference;
setColorScheme(
preference: ColorSchemePreference,
): ReturnType<StoragePort["write"]>;
}>;
diagnostics: Readonly<{
reportRenderFailure(report: RenderFailureReport): void;
}>;
runtime: Readonly<{
getReleaseSummary(): Promise<ReleaseSummary>;
}>;
}>;
+7
View File
@@ -0,0 +1,7 @@
export type {
ApplicationApi,
ColorSchemePreference,
ReleaseSummary,
RenderFailureReport,
SessionState,
} from "./application-api.js";
@@ -0,0 +1,18 @@
import type { AuthSessionPort } from "../auth-session-port.js";
import type { ReleaseInfoPort } from "../release-info-port.js";
import type { StoragePort } from "../storage-port.js";
import type { TelemetryPort } from "../telemetry-port.js";
/**
* Capabilities required by application use cases. Implementations live in
* outbound adapters and are selected only by bootstrap.
*/
export type ApplicationOutputPorts = Readonly<{
session: Pick<
AuthSessionPort,
"getState" | "subscribe" | "beginSignIn" | "signOut" | "recover"
>;
preferences: StoragePort;
diagnostics: TelemetryPort;
releaseInfo: ReleaseInfoPort;
}>;
+17
View File
@@ -0,0 +1,17 @@
export type { ApplicationOutputPorts } from "./application-output-ports.js";
export type {
AuthSessionPort,
CredentialAttacher,
SessionGateway,
} from "../auth-session-port.js";
export type { ClockPort } from "../clock-port.js";
export type { QueryCachePort } from "../query-cache-port.js";
export type { ReleaseInfoPort } from "../release-info-port.js";
export type {
RequestContext,
ResourceCommandPort,
ResourceQueryPort,
Result,
} from "../resource-ports.js";
export type { StoragePort } from "../storage-port.js";
export type { TelemetryPort } from "../telemetry-port.js";
+5 -1
View File
@@ -1,11 +1,15 @@
/**
* @typedef {{
* getCurrent(): Promise<{
* schemaVersion?: number,
* appVersion?: string,
* buildId: string,
* commitSha?: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string
* releaseId: string,
* builtAt?: string
* }>
* }} ReleaseInfoPort
*/