245 lines
7.8 KiB
TypeScript
245 lines
7.8 KiB
TypeScript
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,
|
|
});
|
|
});
|