refactor: 프론트 템플릿 리펙토링

This commit is contained in:
donghyeon-ka
2026-09-18 15:16:58 +09:00
parent c10a709f2c
commit 5cc41467ae
80 changed files with 7227 additions and 4672 deletions
@@ -0,0 +1,97 @@
import type {
BrowserDataResult,
IndexedDbRepositoryPort,
} from "../../../../src/application/ports/browser-file-storage/index.ts";
export type LocalDraft = Readonly<{
draftId: string;
title: string;
body: string;
}>;
export type SaveLocalDraftCommand = Readonly<{
draft: LocalDraft;
expectedRevision: number | null;
idempotencyKey: string;
}>;
export type RemoveLocalDraftCommand = Readonly<{
draftId: string;
expectedRevision: number;
idempotencyKey: string;
}>;
export type LocalDraftRecord = Readonly<{
draft: LocalDraft;
revision: number;
}>;
export interface LocalDraftStore {
save(
command: SaveLocalDraftCommand,
signal?: AbortSignal,
): Promise<BrowserDataResult<Readonly<{ revision: number }>>>;
find(
draftId: string,
signal?: AbortSignal,
): Promise<BrowserDataResult<LocalDraftRecord | null>>;
remove(
command: RemoveLocalDraftCommand,
signal?: AbortSignal,
): Promise<BrowserDataResult<void>>;
}
/**
* Feature-owned binding over the technology-neutral IndexedDB application port.
*
* The feature knows its domain type and optimistic concurrency inputs. It does
* not know database names, stores, transactions, native IDB objects, codecs,
* migrations, quota handling or connection lifecycle.
*/
export function createLocalDraftStore(
repository: IndexedDbRepositoryPort<LocalDraft, never>,
): LocalDraftStore {
const store: LocalDraftStore = {
async save(command, signal) {
const result = await repository.compareAndSwap({
key: command.draft.draftId,
value: command.draft,
expectedRevision: command.expectedRevision,
idempotencyKey: command.idempotencyKey,
signal,
});
if (!result.ok) return result;
return Object.freeze({
ok: true as const,
value: Object.freeze({ revision: result.value.revision }),
});
},
async find(draftId, signal) {
const result = await repository.read(draftId, signal);
if (!result.ok) return result;
return Object.freeze({
ok: true as const,
value:
result.value === null
? null
: Object.freeze({
draft: result.value.value,
revision: result.value.revision,
}),
});
},
async remove(command, signal) {
const result = await repository.remove({
key: command.draftId,
expectedRevision: command.expectedRevision,
idempotencyKey: command.idempotencyKey,
signal,
});
if (!result.ok) return result;
return Object.freeze({ ok: true as const, value: undefined });
},
};
return Object.freeze(store);
}
@@ -0,0 +1,161 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import type {
BrowserDataResult,
IndexedDbRepositoryPort,
} from "../../../src/application/ports/browser-file-storage/index.ts";
import {
createLocalDraftStore,
type LocalDraft,
} from "./fixtures/local-draft-feature.ts";
function success<Value>(value: Value): BrowserDataResult<Value> {
return Object.freeze({ ok: true as const, value });
}
function repositoryFixture(): IndexedDbRepositoryPort<LocalDraft, never> {
let stored: Readonly<{ value: LocalDraft; revision: number }> | null = null;
const repository: IndexedDbRepositoryPort<LocalDraft, never> = {
async open() {
return success(undefined);
},
async read(key) {
if (stored === null || stored.value.draftId !== key) {
return success(null);
}
return success(stored);
},
async query() {
return success(Object.freeze({ items: [], nextCursor: null }));
},
async compareAndSwap(input) {
const currentRevision = stored?.revision ?? null;
if (currentRevision !== input.expectedRevision) {
return Object.freeze({
ok: false as const,
error: Object.freeze({
code: "CONFLICT" as const,
operation: "INDEXEDDB_WRITE" as const,
retryable: false,
recovery: "RECONCILE" as const,
}),
});
}
const revision = (currentRevision ?? 0) + 1;
stored = Object.freeze({ value: input.value, revision });
return success(
Object.freeze({
key: input.key,
revision,
replayed: false,
}),
);
},
async remove(input) {
if (stored === null || stored.revision !== input.expectedRevision) {
return Object.freeze({
ok: false as const,
error: Object.freeze({
code: "CONFLICT" as const,
operation: "INDEXEDDB_WRITE" as const,
retryable: false,
recovery: "RECONCILE" as const,
}),
});
}
stored = null;
return success(
Object.freeze({
key: input.key,
revision: input.expectedRevision + 1,
replayed: false,
}),
);
},
async enforceLifecycleBatch() {
stored = null;
return success(
Object.freeze({
state: "COMPLETE" as const,
scannedRows: 0,
deletedRows: 0,
budgetExhausted: false,
}),
);
},
getStatus() {
return Object.freeze({ kind: "READY" as const, schemaVersion: 1 });
},
subscribeStatus() {
return () => {};
},
close() {},
};
return Object.freeze(repository);
}
describe("IndexedDB local-draft consumer experience", () => {
it("implements save/find/remove through the public application port", async () => {
const store = createLocalDraftStore(repositoryFixture());
const draft = Object.freeze({
draftId: "draft-1",
title: "Architecture notes",
body: "Feature code owns the draft model.",
});
await expect(
store.save({
draft,
expectedRevision: null,
idempotencyKey: "draft-save-0001",
}),
).resolves.toEqual({ ok: true, value: { revision: 1 } });
await expect(store.find("draft-1")).resolves.toEqual({
ok: true,
value: { draft, revision: 1 },
});
await expect(
store.remove({
draftId: "draft-1",
expectedRevision: 1,
idempotencyKey: "draft-remove-0001",
}),
).resolves.toEqual({ ok: true, value: undefined });
await expect(store.find("draft-1")).resolves.toEqual({
ok: true,
value: null,
});
});
it("keeps native IndexedDB and runtime internals out of feature-owned code", async () => {
const source = await readFile(
new URL("./fixtures/local-draft-feature.ts", import.meta.url),
"utf8",
);
const importLines = source
.split("\n")
.filter((line) => line.startsWith("import "));
expect(importLines).toHaveLength(1);
expect(source).toContain(
"src/application/ports/browser-file-storage/index.ts",
);
for (const forbidden of [
"src/adapters/storage/indexeddb",
"globalThis.indexedDB",
"IDBFactory",
"IDBDatabase",
"IDBTransaction",
"IDBObjectStore",
]) {
expect(source).not.toContain(forbidden);
}
});
});
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from "vitest";
import {
createFeatureHttpBinding,
defineFeatureHttpOperation,
type InstalledHttpOperationExecutor,
} from "../../../src/adapters/http/index.ts";
import {
mappingFailure,
mappingSuccess,
} from "../../../src/contracts/boundary-mapper.ts";
type Resource = Readonly<{ id: string; title: string }>;
const OPERATIONS = Object.freeze({
LOAD_RESOURCE: defineFeatureHttpOperation<
Readonly<{ resourceId: string }>,
Resource
>({
operationId: "LOAD_RESOURCE",
routeId: "RESOURCE_DETAIL",
mapSuccess(value) {
if (
!value ||
typeof value !== "object" ||
typeof (value as Record<string, unknown>).id !== "string" ||
typeof (value as Record<string, unknown>).title !== "string"
) {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
const candidate = value as Readonly<{ id: string; title: string }>;
return mappingSuccess(
Object.freeze({ id: candidate.id, title: candidate.title }),
);
},
}),
} as const);
describe("feature HTTP binding", () => {
it("keeps typed feature input while platform owns route/context execution", async () => {
const execute = vi.fn<InstalledHttpOperationExecutor["execute"]>(
async (_operationId, input, context) => {
expect(input).toEqual({ resourceId: "resource-1" });
expect(context.routeId).toBe("RESOURCE_DETAIL");
return Object.freeze({
kind: "SUCCESS" as const,
value: Object.freeze({ id: "resource-1", title: "Reference" }),
metadata: Object.freeze({ status: 200 }),
effect: "NOT_APPLICABLE" as const,
});
},
);
const binding = createFeatureHttpBinding(
Object.freeze({ execute }),
OPERATIONS,
);
const result = await binding.execute("LOAD_RESOURCE", {
resourceId: "resource-1",
});
expect(result).toEqual({
ok: true,
value: { id: "resource-1", title: "Reference" },
});
expect(execute).toHaveBeenCalledTimes(1);
});
it("normalizes transport failure before it crosses the feature gateway", async () => {
const executor: InstalledHttpOperationExecutor = Object.freeze({
async execute() {
return Object.freeze({
kind: "TRANSPORT_FAILURE" as const,
failure: Object.freeze({
kind: "TIMEOUT" as const,
retryable: true,
}),
effect: "NOT_STARTED" as const,
});
},
});
const binding = createFeatureHttpBinding(executor, OPERATIONS);
const result = await binding.execute("LOAD_RESOURCE", {
resourceId: "resource-1",
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.kind).toBe("REQUEST_TIMEOUT");
expect(result.error.operationId).toBe("LOAD_RESOURCE");
expect(result.error.effect).toBe("NOT_STARTED");
});
it("turns feature mapper rejection into the shared mapping failure", async () => {
const executor: InstalledHttpOperationExecutor = Object.freeze({
async execute() {
return Object.freeze({
kind: "SUCCESS" as const,
value: Object.freeze({ unexpected: true }),
metadata: Object.freeze({ status: 200 }),
effect: "NOT_APPLICABLE" as const,
});
},
});
const binding = createFeatureHttpBinding(executor, OPERATIONS);
const result = await binding.execute("LOAD_RESOURCE", {
resourceId: "resource-1",
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.kind).toBe("MAPPING_CONTRACT_VIOLATION");
expect(result.error.code).toBe("MAPPING_INVARIANT_REJECTED");
});
});