refactor: 프론트엔드 리펙토링
This commit is contained in:
@@ -18,13 +18,13 @@ function databaseName(_prefix: string): string {
|
||||
return `ca-idb-v1:a${token.slice(0, 31)}.n${token.slice(1)}.p${token.split("").reverse().join("").slice(0, 31)}`;
|
||||
}
|
||||
|
||||
async function installLifecycleRuntime(
|
||||
async function startLifecycleRuntime(
|
||||
page: Page,
|
||||
name: string,
|
||||
targetVersion: 1 | 2,
|
||||
blockedTimeoutMs = 1_000,
|
||||
) {
|
||||
return await page.evaluate(
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
async ({
|
||||
currentDatabaseName,
|
||||
currentTargetVersion,
|
||||
@@ -173,8 +173,12 @@ async function installLifecycleRuntime(
|
||||
__indexedDbLifecycleRuntime?: unknown;
|
||||
}
|
||||
).__indexedDbLifecycleRuntime = runtime;
|
||||
const opened = await runtime.open();
|
||||
return { opened, status: runtime.getStatus() };
|
||||
const opening = runtime.open();
|
||||
(
|
||||
globalThis as unknown as {
|
||||
__indexedDbLifecycleOpening?: Promise<unknown>;
|
||||
}
|
||||
).__indexedDbLifecycleOpening = opening;
|
||||
},
|
||||
{
|
||||
currentDatabaseName: name,
|
||||
@@ -184,6 +188,35 @@ async function installLifecycleRuntime(
|
||||
);
|
||||
}
|
||||
|
||||
async function settleLifecycleRuntime(page: Page): Promise<unknown> {
|
||||
return await page.evaluate(async () => {
|
||||
const state = globalThis as unknown as {
|
||||
__indexedDbLifecycleRuntime?: {
|
||||
getStatus(): unknown;
|
||||
};
|
||||
__indexedDbLifecycleOpening?: Promise<unknown>;
|
||||
};
|
||||
if (!state.__indexedDbLifecycleRuntime || !state.__indexedDbLifecycleOpening) {
|
||||
throw new Error("IndexedDB lifecycle runtime is not installed.");
|
||||
}
|
||||
const opened = await state.__indexedDbLifecycleOpening;
|
||||
return {
|
||||
opened,
|
||||
status: state.__indexedDbLifecycleRuntime.getStatus(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function installLifecycleRuntime(
|
||||
page: Page,
|
||||
name: string,
|
||||
targetVersion: 1 | 2,
|
||||
blockedTimeoutMs = 1_000,
|
||||
): Promise<unknown> {
|
||||
await startLifecycleRuntime(page, name, targetVersion, blockedTimeoutMs);
|
||||
return await settleLifecycleRuntime(page);
|
||||
}
|
||||
|
||||
async function lifecycleStatus(page: Page): Promise<unknown> {
|
||||
return await page.evaluate(() => {
|
||||
const runtime = (
|
||||
@@ -977,12 +1010,16 @@ test("fails a blocked v2 upgrade closed, then closes its late connection after t
|
||||
);
|
||||
expect(blocker).toBe("OPEN");
|
||||
|
||||
const blocked = await installLifecycleRuntime(
|
||||
page,
|
||||
name,
|
||||
2,
|
||||
25,
|
||||
);
|
||||
await startLifecycleRuntime(page, name, 2, 500);
|
||||
await expect
|
||||
.poll(async () => await lifecycleStatus(page))
|
||||
.toEqual({
|
||||
kind: "BLOCKED",
|
||||
currentVersion: 1,
|
||||
targetVersion: 2,
|
||||
});
|
||||
|
||||
const blocked = await settleLifecycleRuntime(page);
|
||||
expect(blocked).toMatchObject({
|
||||
opened: {
|
||||
ok: false,
|
||||
@@ -992,9 +1029,8 @@ test("fails a blocked v2 upgrade closed, then closes its late connection after t
|
||||
},
|
||||
},
|
||||
status: {
|
||||
kind: "BLOCKED",
|
||||
currentVersion: 1,
|
||||
targetVersion: 2,
|
||||
kind: "CLOSED",
|
||||
reason: "NOT_OPENED",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import type {
|
||||
IndexedDbRuntimeDependencies,
|
||||
} from "../../src/adapters/storage/indexeddb/indexeddb-types.ts";
|
||||
import type { LocalDraft } from "../../src/features/local-draft-feature/domain/local-draft.ts";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
} from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("composes the local-draft feature over the real browser IndexedDB runtime", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/favicon.ico", (route) =>
|
||||
route.fulfill({ status: 204 }),
|
||||
);
|
||||
await page.goto("/config.json");
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const indexedDbModulePath =
|
||||
"/src/adapters/storage/indexeddb/index.ts";
|
||||
const contributionModulePath =
|
||||
"/src/features/feature-adapter-contribution.ts";
|
||||
const localDraftModulePath =
|
||||
"/src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts";
|
||||
|
||||
const {
|
||||
createIndexedDbRuntime,
|
||||
indexedDbPhysicalDatabaseName,
|
||||
} = (await import(
|
||||
/* @vite-ignore */ indexedDbModulePath
|
||||
)) as typeof import("../../src/adapters/storage/indexeddb/index.ts");
|
||||
const {
|
||||
composeFeatureAdapterInputs,
|
||||
createIndexedDbRepositoryProvider,
|
||||
} = (await import(
|
||||
/* @vite-ignore */ contributionModulePath
|
||||
)) as typeof import("../../src/features/feature-adapter-contribution.ts");
|
||||
const {
|
||||
LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION,
|
||||
} = (await import(
|
||||
/* @vite-ignore */ localDraftModulePath
|
||||
)) as typeof import("../../src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts");
|
||||
|
||||
const suffix = crypto.randomUUID().replaceAll("-", "");
|
||||
const scope = Object.freeze({
|
||||
authorityToken: `a${suffix.slice(0, 31)}`,
|
||||
namespaceToken: `n${suffix.slice(1, 32)}`,
|
||||
partitionToken: `p${[...suffix].reverse().join("").slice(0, 31)}`,
|
||||
accountScope: "ORIGIN_SHARED" as const,
|
||||
});
|
||||
const storagePolicy = Object.freeze({
|
||||
owner: "local-draft-feature",
|
||||
namespace: "local-draft",
|
||||
classification: "INTERNAL" as const,
|
||||
authority: "LOCAL_FIRST" as const,
|
||||
accountScope: "ORIGIN_SHARED" as const,
|
||||
retention: Object.freeze({ kind: "EXPLICIT_DELETE" as const }),
|
||||
softBudgetBytes: 1_000_000,
|
||||
hardBudgetBytes: 2_000_000,
|
||||
evictionPriority: "USER_AUTHORED" as const,
|
||||
logoutAction: "KEEP_ORIGIN_SHARED" as const,
|
||||
accountDeletionAction: "KEEP_ORIGIN_SHARED" as const,
|
||||
pressureAction: "RETAIN" as const,
|
||||
unavailableFallback: "READ_ONLY" as const,
|
||||
});
|
||||
|
||||
const config: IndexedDbRuntimeDependencies<
|
||||
LocalDraft,
|
||||
LocalDraft,
|
||||
never
|
||||
> = {
|
||||
scope,
|
||||
storagePolicy,
|
||||
schemaVersion: 1,
|
||||
recordStore: "records",
|
||||
governanceStore: "governance",
|
||||
retentionStore: "retention",
|
||||
retentionEligibilityIndex: "by-eligibility",
|
||||
lifecycleMetadataStores: [],
|
||||
idempotencyStore: "receipts",
|
||||
idempotencyExpiryIndex: "by-expiry",
|
||||
receiptRetentionMs: 60_000,
|
||||
maxIdempotencyReceipts: 100,
|
||||
migrations: [
|
||||
{
|
||||
id: "local-draft-schema-v1",
|
||||
fromVersion: 0,
|
||||
toVersion: 1,
|
||||
operations: [
|
||||
{
|
||||
kind: "CREATE_STORE",
|
||||
name: "governance",
|
||||
keyPath: "bindingKey",
|
||||
},
|
||||
{
|
||||
kind: "CREATE_STORE",
|
||||
name: "retention",
|
||||
keyPath: "recordKey",
|
||||
indexes: [
|
||||
{
|
||||
name: "by-eligibility",
|
||||
keyPath: "eligibleAtEpochMs",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "CREATE_STORE",
|
||||
name: "records",
|
||||
keyPath: "key",
|
||||
},
|
||||
{
|
||||
kind: "CREATE_STORE",
|
||||
name: "receipts",
|
||||
keyPath: "idempotencyKey",
|
||||
indexes: [
|
||||
{
|
||||
name: "by-expiry",
|
||||
keyPath: "expiresAtEpochMs",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
codec: {
|
||||
currentVersion: 1,
|
||||
encode: (value) => ({ ok: true, value }),
|
||||
measureStoredBytes: (value) =>
|
||||
new TextEncoder().encode(JSON.stringify(value)).byteLength,
|
||||
decode: (version, value) => {
|
||||
if (
|
||||
version !== 1 ||
|
||||
value === null ||
|
||||
typeof value !== "object"
|
||||
) {
|
||||
return { ok: false };
|
||||
}
|
||||
const candidate = value as Partial<LocalDraft>;
|
||||
return typeof candidate.draftId === "string" &&
|
||||
typeof candidate.title === "string" &&
|
||||
typeof candidate.body === "string"
|
||||
? { ok: true, value: candidate as LocalDraft }
|
||||
: { ok: false };
|
||||
},
|
||||
fingerprint: async (value) => {
|
||||
const bytes = new TextEncoder().encode(
|
||||
JSON.stringify([value.draftId, value.title, value.body]),
|
||||
);
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(
|
||||
new Uint8Array(digest),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
},
|
||||
},
|
||||
queryPolicy: {
|
||||
plan: () => ({ limit: 1 }),
|
||||
},
|
||||
authorizeLifecycle: () => ({
|
||||
authorized: true,
|
||||
proofToken: "localdraftauthorityproof_001",
|
||||
}),
|
||||
};
|
||||
|
||||
const runtime = createIndexedDbRuntime(config);
|
||||
const databaseName = indexedDbPhysicalDatabaseName(scope);
|
||||
const opened = await runtime.open();
|
||||
|
||||
try {
|
||||
const indexedDb = createIndexedDbRepositoryProvider(
|
||||
Object.freeze({ "local-draft": runtime }),
|
||||
);
|
||||
const composed = composeFeatureAdapterInputs(
|
||||
Object.freeze([LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION] as const),
|
||||
Object.freeze(["local-draft"]),
|
||||
Object.freeze({ indexedDb }),
|
||||
);
|
||||
const localDraft = composed["local-draft"];
|
||||
if (!localDraft) {
|
||||
throw new Error("local-draft feature input was not composed");
|
||||
}
|
||||
|
||||
const draft = Object.freeze({
|
||||
draftId: "draft-1",
|
||||
title: "Architecture notes",
|
||||
body: "Stored through the reusable IndexedDB capability.",
|
||||
});
|
||||
|
||||
const saved = await localDraft.saveDraft({
|
||||
draft,
|
||||
expectedRevision: null,
|
||||
idempotencyKey: "local-draft-create-1",
|
||||
});
|
||||
const found = await localDraft.findDraft(draft.draftId);
|
||||
const removed = await localDraft.removeDraft({
|
||||
draftId: draft.draftId,
|
||||
expectedRevision: saved.ok ? saved.value.revision : 1,
|
||||
idempotencyKey: "local-draft-remove-1",
|
||||
});
|
||||
const afterRemove = await localDraft.findDraft(draft.draftId);
|
||||
|
||||
return {
|
||||
opened,
|
||||
saved,
|
||||
found,
|
||||
removed,
|
||||
afterRemove,
|
||||
composedFeatureIds: Object.keys(composed),
|
||||
runtimeStatus: runtime.getStatus(),
|
||||
};
|
||||
} finally {
|
||||
runtime.close();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase(databaseName);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () =>
|
||||
reject(request.error ?? new Error("IndexedDB cleanup failed"));
|
||||
request.onblocked = () =>
|
||||
reject(new Error("IndexedDB cleanup was blocked"));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.opened).toEqual({ ok: true, value: undefined });
|
||||
expect(result.composedFeatureIds).toEqual(["local-draft"]);
|
||||
expect(result.saved).toEqual({ ok: true, value: { revision: 1 } });
|
||||
expect(result.found).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
draft: {
|
||||
draftId: "draft-1",
|
||||
title: "Architecture notes",
|
||||
body: "Stored through the reusable IndexedDB capability.",
|
||||
},
|
||||
revision: 1,
|
||||
},
|
||||
});
|
||||
expect(result.removed).toEqual({ ok: true, value: undefined });
|
||||
expect(result.afterRemove).toEqual({ ok: true, value: null });
|
||||
expect(result.runtimeStatus).toMatchObject({
|
||||
kind: "READY",
|
||||
schemaVersion: 1,
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,48 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("executes the storage durability adapter against the real browser StorageManager", async ({
|
||||
test("executes the storage durability adapter against the real browser StorageManager capability", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const observation = await page.evaluate(async () => {
|
||||
const modulePath =
|
||||
"/src/adapters/browser-file-storage/storage-manager-adapter.ts";
|
||||
const { createStorageDurabilityAdapter } = await import(
|
||||
/* @vite-ignore */ modulePath
|
||||
);
|
||||
const hasStorageManager = navigator.storage !== undefined;
|
||||
const adapter = createStorageDurabilityAdapter(navigator.storage);
|
||||
return adapter.inspect();
|
||||
return {
|
||||
hasStorageManager,
|
||||
result: await adapter.inspect(),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
if (!observation.hasStorageManager) {
|
||||
expect(observation.result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNSUPPORTED",
|
||||
operation: "STORAGE_ESTIMATE",
|
||||
retryable: false,
|
||||
recovery: "ONLINE_ONLY",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
expect(observation.result.ok).toBe(true);
|
||||
if (observation.result.ok) {
|
||||
expect(["UNKNOWN", "NORMAL", "PRESSURE", "CRITICAL"]).toContain(
|
||||
result.value.pressure,
|
||||
observation.result.value.pressure,
|
||||
);
|
||||
expect(
|
||||
result.value.usageBytes === null || result.value.usageBytes >= 0,
|
||||
observation.result.value.usageBytes === null ||
|
||||
observation.result.value.usageBytes >= 0,
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.value.quotaBytes === null || result.value.quotaBytes >= 0,
|
||||
observation.result.value.quotaBytes === null ||
|
||||
observation.result.value.quotaBytes >= 0,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user