refactor: 프론트 템플릿 리펙토링
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user