feat: 기능 추가 과정중
This commit is contained in:
+17
-17
@@ -1,27 +1,27 @@
|
||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
|
||||
import { createApplication } from "../../src/application/create-application.js";
|
||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
||||
import {
|
||||
createApplication,
|
||||
type ApplicationOutputPorts,
|
||||
} from "../../src/application/create-application.ts";
|
||||
import type { ApplicationFeatureInputs } from "../../src/application/ports/in/application-api.ts";
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* session?: import("../../src/application/ports/auth-session-port.js").AuthSessionPort,
|
||||
* preferences?: import("../../src/application/ports/storage-port.js").StoragePort,
|
||||
* diagnostics?: import("../../src/application/ports/diagnostics-port.js").DiagnosticsPort,
|
||||
* telemetry?: import("../../src/application/ports/telemetry-port.js").TelemetryPort,
|
||||
* releaseInfo?: import("../../src/application/ports/release-info-port.js").ReleaseInfoPort,
|
||||
* navigation?: { reload(): void },
|
||||
* featureInputs?: Readonly<Record<string, unknown>>
|
||||
* }} [overrides]
|
||||
*/
|
||||
export function createTestApplication(overrides = {}) {
|
||||
type TestApplicationOverrides = Partial<ApplicationOutputPorts> &
|
||||
Readonly<{
|
||||
featureInputs?: Readonly<Partial<ApplicationFeatureInputs>>;
|
||||
}>;
|
||||
|
||||
export function createTestApplication(
|
||||
overrides: TestApplicationOverrides = {},
|
||||
) {
|
||||
return createApplication(
|
||||
{
|
||||
session: overrides.session ?? createAnonymousSessionAdapter(),
|
||||
preferences:
|
||||
overrides.preferences ??
|
||||
{
|
||||
read: () => ({ ok: /** @type {const} */ (true), value: "system" }),
|
||||
write: () => ({ ok: /** @type {const} */ (true) }),
|
||||
remove: () => ({ ok: /** @type {const} */ (true) }),
|
||||
read: () => ({ ok: true as const, value: "system" }),
|
||||
write: () => ({ ok: true as const }),
|
||||
remove: () => ({ ok: true as const }),
|
||||
},
|
||||
diagnostics: overrides.diagnostics ?? { record: () => {} },
|
||||
telemetry: overrides.telemetry ?? { emit: () => {} },
|
||||
@@ -0,0 +1,228 @@
|
||||
import type {
|
||||
IndexedDbCompareAndSwapInput,
|
||||
IndexedDbDeleteInput,
|
||||
IndexedDbWriteReceipt,
|
||||
} from "../../src/application/ports/browser-file-storage/indexeddb-port.ts";
|
||||
import type {
|
||||
BrowserDataResult,
|
||||
} from "../../src/application/ports/browser-file-storage/shared.ts";
|
||||
import type {
|
||||
PushControlV1,
|
||||
} from "../../src/contracts/web-push.ts";
|
||||
import type {
|
||||
PushControlRepository,
|
||||
} from "../../src/adapters/web-push/push-association-fence-store.ts";
|
||||
import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../../src/adapters/browser-file-storage/result.ts";
|
||||
|
||||
type StoredControl = Readonly<{
|
||||
value: unknown;
|
||||
revision: number;
|
||||
}>;
|
||||
|
||||
type Deferred = Readonly<{
|
||||
promise: Promise<void>;
|
||||
resolve(): void;
|
||||
}>;
|
||||
|
||||
export type PausedPushControlWrite = Readonly<{
|
||||
reached: Promise<void>;
|
||||
release(): void;
|
||||
}>;
|
||||
|
||||
export class FakePushControlRepository
|
||||
implements PushControlRepository
|
||||
{
|
||||
#stored: StoredControl | null = null;
|
||||
#closed = false;
|
||||
#pause:
|
||||
| Readonly<{
|
||||
predicate(value: PushControlV1): boolean;
|
||||
reached: Deferred;
|
||||
released: Deferred;
|
||||
}>
|
||||
| null = null;
|
||||
#pausedRemove:
|
||||
| Readonly<{
|
||||
reached: Deferred;
|
||||
released: Deferred;
|
||||
}>
|
||||
| null = null;
|
||||
|
||||
open(
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<void>> {
|
||||
return Promise.resolve(this.#available(signal, undefined));
|
||||
}
|
||||
|
||||
read(
|
||||
_key: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
BrowserDataResult<
|
||||
Readonly<{ value: PushControlV1; revision: number }> | null
|
||||
>
|
||||
> {
|
||||
const unavailable = this.#unavailable(signal);
|
||||
if (unavailable) return Promise.resolve(unavailable);
|
||||
if (!this.#stored) return Promise.resolve(browserDataSuccess(null));
|
||||
return Promise.resolve(
|
||||
browserDataSuccess(
|
||||
Object.freeze({
|
||||
value: this.#stored.value as PushControlV1,
|
||||
revision: this.#stored.revision,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async compareAndSwap(
|
||||
input: IndexedDbCompareAndSwapInput<PushControlV1>,
|
||||
): Promise<BrowserDataResult<IndexedDbWriteReceipt>> {
|
||||
const unavailable = this.#unavailable(input.signal);
|
||||
if (unavailable) return unavailable;
|
||||
const pause = this.#pause;
|
||||
if (pause?.predicate(input.value)) {
|
||||
this.#pause = null;
|
||||
pause.reached.resolve();
|
||||
await pause.released.promise;
|
||||
}
|
||||
const lateUnavailable = this.#unavailable(input.signal);
|
||||
if (lateUnavailable) return lateUnavailable;
|
||||
const currentRevision = this.#stored?.revision ?? null;
|
||||
if (currentRevision !== input.expectedRevision) {
|
||||
return browserDataFailure("CONFLICT", "INDEXEDDB_WRITE");
|
||||
}
|
||||
const revision = (currentRevision ?? 0) + 1;
|
||||
this.#stored = Object.freeze({
|
||||
value: structuredClone(input.value),
|
||||
revision,
|
||||
});
|
||||
return browserDataSuccess(
|
||||
Object.freeze({
|
||||
key: input.key,
|
||||
revision,
|
||||
replayed: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async remove(
|
||||
input: IndexedDbDeleteInput,
|
||||
): Promise<BrowserDataResult<IndexedDbWriteReceipt>> {
|
||||
const unavailable = this.#unavailable(input.signal);
|
||||
if (unavailable) return unavailable;
|
||||
const pause = this.#pausedRemove;
|
||||
if (pause) {
|
||||
this.#pausedRemove = null;
|
||||
pause.reached.resolve();
|
||||
await pause.released.promise;
|
||||
}
|
||||
const lateUnavailable = this.#unavailable(input.signal);
|
||||
if (lateUnavailable) return lateUnavailable;
|
||||
if (
|
||||
!this.#stored ||
|
||||
this.#stored.revision !== input.expectedRevision
|
||||
) {
|
||||
return browserDataFailure("CONFLICT", "INDEXEDDB_WRITE");
|
||||
}
|
||||
const revision = input.expectedRevision + 1;
|
||||
this.#stored = null;
|
||||
return browserDataSuccess(
|
||||
Object.freeze({
|
||||
key: input.key,
|
||||
revision,
|
||||
replayed: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#closed = true;
|
||||
}
|
||||
|
||||
seedRaw(value: unknown, revision = 1): void {
|
||||
this.#stored = Object.freeze({ value, revision });
|
||||
}
|
||||
|
||||
pauseNextWrite(
|
||||
predicate: (value: PushControlV1) => boolean,
|
||||
): PausedPushControlWrite {
|
||||
if (this.#pause) {
|
||||
throw new Error("A push-control write is already paused.");
|
||||
}
|
||||
const reached = deferred();
|
||||
const released = deferred();
|
||||
this.#pause = Object.freeze({ predicate, reached, released });
|
||||
return Object.freeze({
|
||||
reached: reached.promise,
|
||||
release: released.resolve,
|
||||
});
|
||||
}
|
||||
|
||||
pauseNextRemove(): PausedPushControlWrite {
|
||||
if (this.#pausedRemove) {
|
||||
throw new Error("A push-control remove is already paused.");
|
||||
}
|
||||
const reached = deferred();
|
||||
const released = deferred();
|
||||
this.#pausedRemove = Object.freeze({ reached, released });
|
||||
return Object.freeze({
|
||||
reached: reached.promise,
|
||||
release: released.resolve,
|
||||
});
|
||||
}
|
||||
|
||||
#available<Value>(
|
||||
signal: AbortSignal | undefined,
|
||||
value: Value,
|
||||
): BrowserDataResult<Value> {
|
||||
return (
|
||||
this.#unavailable(signal) ?? browserDataSuccess(value)
|
||||
);
|
||||
}
|
||||
|
||||
#unavailable(
|
||||
signal: AbortSignal | undefined,
|
||||
): BrowserDataResult<never> | null {
|
||||
if (signal?.aborted) {
|
||||
return browserDataFailure("ABORTED", "INDEXEDDB_READ");
|
||||
}
|
||||
return this.#closed
|
||||
? browserDataFailure("UNAVAILABLE", "INDEXEDDB_OPEN", {
|
||||
retryable: true,
|
||||
recovery: "REOPEN",
|
||||
})
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createFakePushControlStoreDependencies(): Readonly<{
|
||||
repository: FakePushControlRepository;
|
||||
idempotencyKeyFactory(): string;
|
||||
}> {
|
||||
const repository = new FakePushControlRepository();
|
||||
let sequence = 0;
|
||||
return Object.freeze({
|
||||
repository,
|
||||
idempotencyKeyFactory() {
|
||||
sequence += 1;
|
||||
return `push-control-test-${sequence}`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function deferred(): Deferred {
|
||||
let resolvePromise: (() => void) | undefined;
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
return Object.freeze({
|
||||
promise,
|
||||
resolve() {
|
||||
resolvePromise?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { createHttpClient } from "../../src/adapters/http/client.js";
|
||||
import { canonicalize } from "../../src/contracts/query-keys.js";
|
||||
import type { createHttpClient } from "../../src/adapters/http/client.ts";
|
||||
import { canonicalize } from "../../src/contracts/query-keys.ts";
|
||||
import { mappingSuccess } from "../../src/contracts/boundary-mapper.ts";
|
||||
import { createDemoSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
||||
|
||||
const entitySchema = z
|
||||
.object({
|
||||
@@ -73,6 +75,7 @@ function project(schema: z.ZodType | undefined, value: unknown): Validation {
|
||||
type HttpDependencies = Parameters<typeof createHttpClient>[0];
|
||||
|
||||
export const TEST_HTTP_CONTRACT = Object.freeze({
|
||||
authSession: createDemoSessionAdapter("authenticated"),
|
||||
getOperation(operationId: string) {
|
||||
const operation =
|
||||
TEST_OPERATIONS[operationId as keyof typeof TEST_OPERATIONS];
|
||||
@@ -97,12 +100,16 @@ export const TEST_HTTP_CONTRACT = Object.freeze({
|
||||
return { id: entity.id, displayName: entity.name };
|
||||
};
|
||||
if (operationId === "LIST_ENTITIES") {
|
||||
return (payload as readonly unknown[]).map(mapOne);
|
||||
return mappingSuccess((payload as readonly unknown[]).map(mapOne));
|
||||
}
|
||||
if (operationId === "CREATE_ENTITY") return mapOne(payload);
|
||||
if (operationId === "CREATE_ENTITY") return mappingSuccess(mapOne(payload));
|
||||
throw new Error(`Unknown test mapper: ${operationId}`);
|
||||
},
|
||||
}) satisfies Pick<
|
||||
HttpDependencies,
|
||||
"getOperation" | "validatePayload" | "validateRequest" | "mapPayload"
|
||||
| "authSession"
|
||||
| "getOperation"
|
||||
| "validatePayload"
|
||||
| "validateRequest"
|
||||
| "mapPayload"
|
||||
>;
|
||||
|
||||
@@ -0,0 +1,922 @@
|
||||
type MutableRequest<Value> = {
|
||||
result: Value;
|
||||
error: DOMException | null;
|
||||
onsuccess: ((event: Event) => unknown) | null;
|
||||
onerror: ((event: Event) => unknown) | null;
|
||||
};
|
||||
|
||||
type MutableOpenRequest = MutableRequest<IDBDatabase> & {
|
||||
transaction: IDBTransaction | null;
|
||||
onblocked: ((event: IDBVersionChangeEvent) => unknown) | null;
|
||||
onupgradeneeded: ((event: IDBVersionChangeEvent) => unknown) | null;
|
||||
};
|
||||
|
||||
type IndexState = {
|
||||
keyPath: string | string[];
|
||||
unique: boolean;
|
||||
multiEntry: boolean;
|
||||
};
|
||||
|
||||
type StoreState = {
|
||||
keyPath: string;
|
||||
autoIncrement: boolean;
|
||||
data: Map<string, unknown>;
|
||||
indexes: Map<string, IndexState>;
|
||||
};
|
||||
|
||||
type FakeKeyRange =
|
||||
| Readonly<{
|
||||
fakeKind: "ONLY";
|
||||
boundary: IDBValidKey;
|
||||
}>
|
||||
| Readonly<{
|
||||
fakeKind: "LOWER";
|
||||
boundary: IDBValidKey;
|
||||
open: boolean;
|
||||
}>
|
||||
| Readonly<{
|
||||
fakeKind: "UPPER";
|
||||
boundary: IDBValidKey;
|
||||
open: boolean;
|
||||
}>
|
||||
| Readonly<{
|
||||
fakeKind: "BOUND";
|
||||
lower: IDBValidKey;
|
||||
upper: IDBValidKey;
|
||||
lowerOpen: boolean;
|
||||
upperOpen: boolean;
|
||||
}>;
|
||||
|
||||
function createRequest<Value>(): MutableRequest<Value> {
|
||||
return {
|
||||
result: undefined as Value,
|
||||
error: null,
|
||||
onsuccess: null,
|
||||
onerror: null,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneStore(state: StoreState): StoreState {
|
||||
return {
|
||||
keyPath: state.keyPath,
|
||||
autoIncrement: state.autoIncrement,
|
||||
data: new Map(
|
||||
Array.from(state.data, ([key, value]) => [
|
||||
key,
|
||||
structuredClone(value),
|
||||
]),
|
||||
),
|
||||
indexes: new Map(
|
||||
Array.from(state.indexes, ([name, index]) => [
|
||||
name,
|
||||
{
|
||||
...index,
|
||||
keyPath: Array.isArray(index.keyPath)
|
||||
? [...index.keyPath]
|
||||
: index.keyPath,
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneStores(
|
||||
stores: ReadonlyMap<string, StoreState>,
|
||||
): Map<string, StoreState> {
|
||||
return new Map(
|
||||
Array.from(stores, ([name, state]) => [name, cloneStore(state)]),
|
||||
);
|
||||
}
|
||||
|
||||
function asException(error: unknown): DOMException {
|
||||
return error instanceof DOMException
|
||||
? error
|
||||
: new DOMException("Fake IndexedDB failure.", "UnknownError");
|
||||
}
|
||||
|
||||
function stringList(values: Iterable<string>): DOMStringList {
|
||||
const items = [...values];
|
||||
return {
|
||||
contains: (value: string) => items.includes(value),
|
||||
item: (index: number) => items[index] ?? null,
|
||||
get length() {
|
||||
return items.length;
|
||||
},
|
||||
[Symbol.iterator]: () => items[Symbol.iterator](),
|
||||
} as DOMStringList;
|
||||
}
|
||||
|
||||
function readPath(value: unknown, keyPath: string): unknown {
|
||||
let current = value;
|
||||
for (const segment of keyPath.split(".")) {
|
||||
if (!current || typeof current !== "object") return undefined;
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function primaryKey(state: StoreState, value: unknown): string {
|
||||
const key = readPath(value, state.keyPath);
|
||||
if (
|
||||
(typeof key !== "string" && typeof key !== "number") ||
|
||||
String(key).length === 0
|
||||
) {
|
||||
throw new DOMException("Invalid fake key.", "DataError");
|
||||
}
|
||||
return String(key);
|
||||
}
|
||||
|
||||
function compareKeys(first: IDBValidKey, second: IDBValidKey): number {
|
||||
const left =
|
||||
first instanceof Date
|
||||
? first.getTime()
|
||||
: typeof first === "number" || typeof first === "string"
|
||||
? first
|
||||
: JSON.stringify(first);
|
||||
const right =
|
||||
second instanceof Date
|
||||
? second.getTime()
|
||||
: typeof second === "number" || typeof second === "string"
|
||||
? second
|
||||
: JSON.stringify(second);
|
||||
if (left === right) return 0;
|
||||
if (typeof left === "number" && typeof right === "number") {
|
||||
return left < right ? -1 : 1;
|
||||
}
|
||||
return String(left) < String(right) ? -1 : 1;
|
||||
}
|
||||
|
||||
class FakeUpgradeTransaction {
|
||||
aborted = false;
|
||||
|
||||
constructor(private readonly database: FakeDatabase) {}
|
||||
|
||||
abort(): void {
|
||||
this.aborted = true;
|
||||
}
|
||||
|
||||
objectStore(name: string): IDBObjectStore {
|
||||
return this.database.upgradeObjectStore(name, this);
|
||||
}
|
||||
|
||||
request<Value>(operation: () => Value): IDBRequest<Value> {
|
||||
const request = createRequest<Value>();
|
||||
try {
|
||||
request.result = structuredClone(operation());
|
||||
queueMicrotask(() => request.onsuccess?.(new Event("success")));
|
||||
} catch (error) {
|
||||
request.error = asException(error);
|
||||
this.aborted = true;
|
||||
queueMicrotask(() => request.onerror?.(new Event("error")));
|
||||
}
|
||||
return request as unknown as IDBRequest<Value>;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDatabase {
|
||||
version = 0;
|
||||
closed = false;
|
||||
onversionchange: ((event: IDBVersionChangeEvent) => unknown) | null = null;
|
||||
onclose: ((event: Event) => unknown) | null = null;
|
||||
|
||||
constructor(private readonly owner: MemoryIndexedDbFactory) {}
|
||||
|
||||
get objectStoreNames(): DOMStringList {
|
||||
return stringList(this.owner.storeNames());
|
||||
}
|
||||
|
||||
createObjectStore(
|
||||
name: string,
|
||||
options?: IDBObjectStoreParameters,
|
||||
): IDBObjectStore {
|
||||
if (this.owner.hasStore(name)) {
|
||||
throw new DOMException("Store already exists.", "ConstraintError");
|
||||
}
|
||||
if (typeof options?.keyPath !== "string") {
|
||||
throw new DOMException("The fake requires a string key path.", "DataError");
|
||||
}
|
||||
this.owner.createStore(name, {
|
||||
keyPath: options.keyPath,
|
||||
autoIncrement: options.autoIncrement ?? false,
|
||||
data: new Map(),
|
||||
indexes: new Map(),
|
||||
});
|
||||
return this.schemaObjectStore(name);
|
||||
}
|
||||
|
||||
deleteObjectStore(name: string): void {
|
||||
if (!this.owner.deleteStore(name)) {
|
||||
throw new DOMException("Store does not exist.", "NotFoundError");
|
||||
}
|
||||
}
|
||||
|
||||
schemaObjectStore(name: string): IDBObjectStore {
|
||||
const state = this.owner.store(name);
|
||||
return new FakeObjectStore(null, state) as unknown as IDBObjectStore;
|
||||
}
|
||||
|
||||
upgradeObjectStore(
|
||||
name: string,
|
||||
transaction: FakeUpgradeTransaction,
|
||||
): IDBObjectStore {
|
||||
const state = this.owner.store(name);
|
||||
return new FakeObjectStore(
|
||||
null,
|
||||
state,
|
||||
transaction,
|
||||
) as unknown as IDBObjectStore;
|
||||
}
|
||||
|
||||
transaction(
|
||||
storeNames: string | Iterable<string>,
|
||||
mode: IDBTransactionMode = "readonly",
|
||||
): IDBTransaction {
|
||||
if (this.closed) {
|
||||
throw new DOMException("Connection is closed.", "InvalidStateError");
|
||||
}
|
||||
const names =
|
||||
typeof storeNames === "string" ? [storeNames] : [...storeNames];
|
||||
const transaction = new FakeTransaction(this.owner, names, mode);
|
||||
this.owner.noteTransaction(transaction);
|
||||
return transaction as unknown as IDBTransaction;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
fireVersionChange(newVersion: number): void {
|
||||
this.onversionchange?.({
|
||||
oldVersion: this.version,
|
||||
newVersion,
|
||||
} as IDBVersionChangeEvent);
|
||||
}
|
||||
|
||||
fireForcedClose(): void {
|
||||
this.closed = true;
|
||||
this.onclose?.(new Event("close"));
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTransaction {
|
||||
oncomplete: ((event: Event) => unknown) | null = null;
|
||||
onerror: ((event: Event) => unknown) | null = null;
|
||||
onabort: ((event: Event) => unknown) | null = null;
|
||||
error: DOMException | null = null;
|
||||
readonly mode: IDBTransactionMode;
|
||||
private active = true;
|
||||
private completionQueued = false;
|
||||
private pending = 0;
|
||||
private readonly workingStores: Map<string, StoreState>;
|
||||
|
||||
constructor(
|
||||
private readonly owner: MemoryIndexedDbFactory,
|
||||
private readonly names: readonly string[],
|
||||
mode: IDBTransactionMode,
|
||||
) {
|
||||
this.mode = mode;
|
||||
this.workingStores = new Map(
|
||||
names.map((name) => [name, cloneStore(owner.store(name))]),
|
||||
);
|
||||
}
|
||||
|
||||
objectStore(name: string): IDBObjectStore {
|
||||
if (!this.names.includes(name)) {
|
||||
throw new DOMException("Store is outside transaction.", "NotFoundError");
|
||||
}
|
||||
const state = this.workingStores.get(name);
|
||||
if (!state) {
|
||||
throw new DOMException("Store does not exist.", "NotFoundError");
|
||||
}
|
||||
return new FakeObjectStore(this, state) as unknown as IDBObjectStore;
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
if (!this.active) {
|
||||
throw new DOMException("Transaction is inactive.", "InvalidStateError");
|
||||
}
|
||||
this.abortWith(new DOMException("Transaction aborted.", "AbortError"));
|
||||
}
|
||||
|
||||
request<Value>(operation: () => Value): IDBRequest<Value> {
|
||||
const request = createRequest<Value>();
|
||||
this.pending += 1;
|
||||
this.owner.scheduleTransaction(() => {
|
||||
if (!this.active) return;
|
||||
try {
|
||||
request.result = structuredClone(operation());
|
||||
request.onsuccess?.(new Event("success"));
|
||||
} catch (error) {
|
||||
request.error = asException(error);
|
||||
request.onerror?.(new Event("error"));
|
||||
this.abortWith(request.error);
|
||||
}
|
||||
if (!this.active) return;
|
||||
this.pending -= 1;
|
||||
this.queueCompletion();
|
||||
}, this.mode);
|
||||
return request as unknown as IDBRequest<Value>;
|
||||
}
|
||||
|
||||
cursor(
|
||||
rows: readonly Readonly<{
|
||||
key: IDBValidKey;
|
||||
primaryKey: IDBValidKey;
|
||||
value: unknown;
|
||||
}>[],
|
||||
direction: IDBCursorDirection,
|
||||
): IDBRequest<IDBCursorWithValue | null> {
|
||||
const request = createRequest<IDBCursorWithValue | null>();
|
||||
let position = 0;
|
||||
this.pending += 1;
|
||||
|
||||
const emit = () => {
|
||||
this.owner.scheduleTransaction(() => {
|
||||
if (!this.active) return;
|
||||
let continued = false;
|
||||
let eventReturned = false;
|
||||
const row = rows[position];
|
||||
if (!row) {
|
||||
request.result = null;
|
||||
} else {
|
||||
request.result = {
|
||||
key: structuredClone(row.key),
|
||||
primaryKey: structuredClone(row.primaryKey),
|
||||
value: structuredClone(row.value),
|
||||
continue: (key?: IDBValidKey) => {
|
||||
if (continued) {
|
||||
throw new DOMException(
|
||||
"Cursor already continued.",
|
||||
"InvalidStateError",
|
||||
);
|
||||
}
|
||||
continued = true;
|
||||
if (eventReturned) this.pending += 1;
|
||||
if (key === undefined) {
|
||||
position += 1;
|
||||
} else {
|
||||
const nextPosition = rows.findIndex(
|
||||
(candidate, candidateIndex) =>
|
||||
candidateIndex > position &&
|
||||
(direction === "prev" ||
|
||||
direction === "prevunique"
|
||||
? compareKeys(candidate.key, key) <= 0
|
||||
: compareKeys(candidate.key, key) >= 0),
|
||||
);
|
||||
position =
|
||||
nextPosition < 0 ? rows.length : nextPosition;
|
||||
}
|
||||
emit();
|
||||
},
|
||||
continuePrimaryKey: (
|
||||
key: IDBValidKey,
|
||||
primaryKey: IDBValidKey,
|
||||
) => {
|
||||
if (continued) {
|
||||
throw new DOMException(
|
||||
"Cursor already continued.",
|
||||
"InvalidStateError",
|
||||
);
|
||||
}
|
||||
continued = true;
|
||||
if (eventReturned) this.pending += 1;
|
||||
const nextPosition = rows.findIndex(
|
||||
(candidate, candidateIndex) => {
|
||||
if (candidateIndex <= position) return false;
|
||||
const keyOrder = compareKeys(candidate.key, key);
|
||||
const tupleOrder =
|
||||
keyOrder === 0
|
||||
? compareKeys(
|
||||
candidate.primaryKey,
|
||||
primaryKey,
|
||||
)
|
||||
: keyOrder;
|
||||
return direction === "prev" ||
|
||||
direction === "prevunique"
|
||||
? tupleOrder <= 0
|
||||
: tupleOrder >= 0;
|
||||
},
|
||||
);
|
||||
position =
|
||||
nextPosition < 0 ? rows.length : nextPosition;
|
||||
emit();
|
||||
},
|
||||
} as unknown as IDBCursorWithValue;
|
||||
}
|
||||
request.onsuccess?.(new Event("success"));
|
||||
eventReturned = true;
|
||||
if (continued || !this.active) return;
|
||||
this.pending -= 1;
|
||||
this.queueCompletion();
|
||||
}, this.mode);
|
||||
};
|
||||
|
||||
emit();
|
||||
return request as unknown as IDBRequest<IDBCursorWithValue | null>;
|
||||
}
|
||||
|
||||
assertWritable(): void {
|
||||
if (this.mode !== "readwrite") {
|
||||
throw new DOMException("Transaction is read-only.", "ReadOnlyError");
|
||||
}
|
||||
}
|
||||
|
||||
private queueCompletion(): void {
|
||||
if (
|
||||
!this.active ||
|
||||
this.pending !== 0 ||
|
||||
this.completionQueued
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.completionQueued = true;
|
||||
this.owner.scheduleTransaction(() => {
|
||||
this.completionQueued = false;
|
||||
if (!this.active || this.pending !== 0) return;
|
||||
const commitFailure =
|
||||
this.mode === "readwrite"
|
||||
? this.owner.consumeCommitFailure()
|
||||
: null;
|
||||
if (commitFailure) {
|
||||
this.abortWith(commitFailure);
|
||||
return;
|
||||
}
|
||||
if (this.mode === "readwrite") {
|
||||
for (const [name, state] of this.workingStores) {
|
||||
this.owner.replaceStore(name, cloneStore(state));
|
||||
}
|
||||
}
|
||||
this.active = false;
|
||||
this.oncomplete?.(new Event("complete"));
|
||||
}, this.mode);
|
||||
}
|
||||
|
||||
private abortWith(error: DOMException): void {
|
||||
if (!this.active) return;
|
||||
this.active = false;
|
||||
this.error = error;
|
||||
this.owner.scheduleLifecycle(() => {
|
||||
this.onerror?.(new Event("error"));
|
||||
this.onabort?.(new Event("abort"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class FakeObjectStore {
|
||||
constructor(
|
||||
private readonly transaction: FakeTransaction | null,
|
||||
private readonly state: StoreState,
|
||||
private readonly upgradeTransaction: FakeUpgradeTransaction | null = null,
|
||||
) {}
|
||||
|
||||
get indexNames(): DOMStringList {
|
||||
return stringList(this.state.indexes.keys());
|
||||
}
|
||||
|
||||
get(query: IDBValidKey | IDBKeyRange): IDBRequest<unknown> {
|
||||
return this.request(() =>
|
||||
this.state.data.get(String(query)),
|
||||
);
|
||||
}
|
||||
|
||||
put(value: unknown): IDBRequest<IDBValidKey> {
|
||||
this.assertWritable();
|
||||
return this.request(() => {
|
||||
const key = primaryKey(this.state, value);
|
||||
this.state.data.set(key, structuredClone(value));
|
||||
return key as IDBValidKey;
|
||||
});
|
||||
}
|
||||
|
||||
add(value: unknown): IDBRequest<IDBValidKey> {
|
||||
this.assertWritable();
|
||||
return this.request(() => {
|
||||
const key = primaryKey(this.state, value);
|
||||
if (this.state.data.has(key)) {
|
||||
throw new DOMException("Key already exists.", "ConstraintError");
|
||||
}
|
||||
this.state.data.set(key, structuredClone(value));
|
||||
return key as IDBValidKey;
|
||||
});
|
||||
}
|
||||
|
||||
delete(query: IDBValidKey | IDBKeyRange): IDBRequest<undefined> {
|
||||
this.assertWritable();
|
||||
return this.request(() => {
|
||||
this.state.data.delete(String(query));
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
openCursor(
|
||||
query?: IDBValidKey | IDBKeyRange | null,
|
||||
direction: IDBCursorDirection = "next",
|
||||
): IDBRequest<IDBCursorWithValue | null> {
|
||||
return this.requireTransaction().cursor(
|
||||
this.rows(undefined, direction, query),
|
||||
direction,
|
||||
);
|
||||
}
|
||||
|
||||
index(name: string): IDBIndex {
|
||||
if (!this.state.indexes.has(name)) {
|
||||
throw new DOMException("Index does not exist.", "NotFoundError");
|
||||
}
|
||||
return new FakeIndex(
|
||||
this.requireTransaction(),
|
||||
this.state,
|
||||
name,
|
||||
) as unknown as IDBIndex;
|
||||
}
|
||||
|
||||
createIndex(
|
||||
name: string,
|
||||
keyPath: string | string[],
|
||||
options?: IDBIndexParameters,
|
||||
): IDBIndex {
|
||||
if (this.state.indexes.has(name)) {
|
||||
throw new DOMException("Index already exists.", "ConstraintError");
|
||||
}
|
||||
this.state.indexes.set(name, {
|
||||
keyPath: Array.isArray(keyPath) ? [...keyPath] : keyPath,
|
||||
unique: options?.unique ?? false,
|
||||
multiEntry: options?.multiEntry ?? false,
|
||||
});
|
||||
return {} as IDBIndex;
|
||||
}
|
||||
|
||||
deleteIndex(name: string): void {
|
||||
if (!this.state.indexes.delete(name)) {
|
||||
throw new DOMException("Index does not exist.", "NotFoundError");
|
||||
}
|
||||
}
|
||||
|
||||
rows(
|
||||
indexName: string | undefined,
|
||||
direction: IDBCursorDirection,
|
||||
query?: IDBValidKey | IDBKeyRange | null,
|
||||
): readonly Readonly<{
|
||||
key: IDBValidKey;
|
||||
primaryKey: IDBValidKey;
|
||||
value: unknown;
|
||||
}>[] {
|
||||
const index = indexName
|
||||
? this.state.indexes.get(indexName)
|
||||
: undefined;
|
||||
let rows = Array.from(this.state.data, ([key, value]) => {
|
||||
const indexKey =
|
||||
index && typeof index.keyPath === "string"
|
||||
? readPath(value, index.keyPath)
|
||||
: key;
|
||||
if (index && indexKey === undefined) return null;
|
||||
return {
|
||||
key: indexKey as IDBValidKey,
|
||||
primaryKey: key,
|
||||
value,
|
||||
};
|
||||
}).filter(
|
||||
(
|
||||
row,
|
||||
): row is {
|
||||
key: IDBValidKey;
|
||||
primaryKey: string;
|
||||
value: unknown;
|
||||
} => row !== null,
|
||||
);
|
||||
if (
|
||||
query &&
|
||||
typeof query === "object" &&
|
||||
"fakeKind" in query
|
||||
) {
|
||||
const range = query as unknown as FakeKeyRange;
|
||||
rows = rows.filter((row) => {
|
||||
if (range.fakeKind === "BOUND") {
|
||||
const lower = compareKeys(row.key, range.lower);
|
||||
const upper = compareKeys(row.key, range.upper);
|
||||
return (
|
||||
(range.lowerOpen ? lower > 0 : lower >= 0) &&
|
||||
(range.upperOpen ? upper < 0 : upper <= 0)
|
||||
);
|
||||
}
|
||||
const comparison = compareKeys(row.key, range.boundary);
|
||||
if (range.fakeKind === "ONLY") return comparison === 0;
|
||||
if (range.fakeKind === "LOWER") {
|
||||
return range.open
|
||||
? comparison > 0
|
||||
: comparison >= 0;
|
||||
}
|
||||
return range.open
|
||||
? comparison < 0
|
||||
: comparison <= 0;
|
||||
});
|
||||
}
|
||||
rows.sort((left, right) => {
|
||||
const indexOrder = compareKeys(left.key, right.key);
|
||||
return indexOrder === 0
|
||||
? compareKeys(left.primaryKey, right.primaryKey)
|
||||
: indexOrder;
|
||||
});
|
||||
if (direction === "prev" || direction === "prevunique") {
|
||||
rows.reverse();
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private requireTransaction(): FakeTransaction {
|
||||
if (!this.transaction) {
|
||||
throw new DOMException(
|
||||
"Schema object store has no data transaction.",
|
||||
"TransactionInactiveError",
|
||||
);
|
||||
}
|
||||
return this.transaction;
|
||||
}
|
||||
|
||||
private request<Value>(operation: () => Value): IDBRequest<Value> {
|
||||
if (this.transaction) return this.transaction.request(operation);
|
||||
if (this.upgradeTransaction) {
|
||||
return this.upgradeTransaction.request(operation);
|
||||
}
|
||||
throw new DOMException(
|
||||
"Schema object store has no data transaction.",
|
||||
"TransactionInactiveError",
|
||||
);
|
||||
}
|
||||
|
||||
private assertWritable(): void {
|
||||
if (this.transaction) {
|
||||
this.transaction.assertWritable();
|
||||
return;
|
||||
}
|
||||
if (!this.upgradeTransaction) {
|
||||
throw new DOMException(
|
||||
"Schema object store has no data transaction.",
|
||||
"TransactionInactiveError",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeIndex {
|
||||
constructor(
|
||||
private readonly transaction: FakeTransaction,
|
||||
private readonly state: StoreState,
|
||||
private readonly name: string,
|
||||
) {}
|
||||
|
||||
openCursor(
|
||||
query?: IDBValidKey | IDBKeyRange | null,
|
||||
direction: IDBCursorDirection = "next",
|
||||
): IDBRequest<IDBCursorWithValue | null> {
|
||||
const store = new FakeObjectStore(this.transaction, this.state);
|
||||
return this.transaction.cursor(
|
||||
store.rows(this.name, direction, query),
|
||||
direction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal deterministic IndexedDB model for adapter contract tests. It models
|
||||
* request ordering, transaction-level commit/rollback and upgrade blocking;
|
||||
* it is not intended to polyfill IndexedDB for application code.
|
||||
*/
|
||||
export class MemoryIndexedDbFactory {
|
||||
private stores = new Map<string, StoreState>();
|
||||
private databaseVersion = 0;
|
||||
private latestDatabase = new FakeDatabase(this);
|
||||
private blockedRequest: Readonly<{
|
||||
request: MutableOpenRequest;
|
||||
targetVersion: number;
|
||||
database: FakeDatabase;
|
||||
}> | null = null;
|
||||
private transactionQueue: Array<() => void> = [];
|
||||
private transactionsPaused = false;
|
||||
private nextCommitFailure: DOMException | null = null;
|
||||
private shouldBlockNextOpen = false;
|
||||
lastTransaction: FakeTransaction | null = null;
|
||||
|
||||
readonly factory: IDBFactory = {
|
||||
open: (_name: string, version?: number) =>
|
||||
this.open(version ?? 1) as unknown as IDBOpenDBRequest,
|
||||
cmp: compareKeys,
|
||||
} as IDBFactory;
|
||||
|
||||
readonly keyRange: Pick<
|
||||
typeof IDBKeyRange,
|
||||
"only" | "lowerBound" | "upperBound" | "bound"
|
||||
> = {
|
||||
only: (value: IDBValidKey) =>
|
||||
({
|
||||
fakeKind: "ONLY",
|
||||
boundary: structuredClone(value),
|
||||
}) as unknown as IDBKeyRange,
|
||||
lowerBound: (bound: IDBValidKey, open = false) =>
|
||||
({
|
||||
fakeKind: "LOWER",
|
||||
boundary: structuredClone(bound),
|
||||
open,
|
||||
}) as unknown as IDBKeyRange,
|
||||
upperBound: (bound: IDBValidKey, open = false) =>
|
||||
({
|
||||
fakeKind: "UPPER",
|
||||
boundary: structuredClone(bound),
|
||||
open,
|
||||
}) as unknown as IDBKeyRange,
|
||||
bound: (
|
||||
lower: IDBValidKey,
|
||||
upper: IDBValidKey,
|
||||
lowerOpen = false,
|
||||
upperOpen = false,
|
||||
) =>
|
||||
({
|
||||
fakeKind: "BOUND",
|
||||
lower: structuredClone(lower),
|
||||
upper: structuredClone(upper),
|
||||
lowerOpen,
|
||||
upperOpen,
|
||||
}) as unknown as IDBKeyRange,
|
||||
};
|
||||
|
||||
open(version: number): MutableOpenRequest {
|
||||
const database = new FakeDatabase(this);
|
||||
database.version = this.databaseVersion;
|
||||
this.latestDatabase = database;
|
||||
const request: MutableOpenRequest = {
|
||||
...createRequest<IDBDatabase>(),
|
||||
result: database as unknown as IDBDatabase,
|
||||
transaction: null,
|
||||
onblocked: null,
|
||||
onupgradeneeded: null,
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
if (this.shouldBlockNextOpen) {
|
||||
this.shouldBlockNextOpen = false;
|
||||
this.blockedRequest = {
|
||||
request,
|
||||
targetVersion: version,
|
||||
database,
|
||||
};
|
||||
request.onblocked?.({
|
||||
oldVersion: this.databaseVersion,
|
||||
newVersion: version,
|
||||
} as IDBVersionChangeEvent);
|
||||
return;
|
||||
}
|
||||
this.finishOpen(request, version, database);
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
blockNextOpen(): void {
|
||||
this.shouldBlockNextOpen = true;
|
||||
}
|
||||
|
||||
releaseBlockedOpen(): void {
|
||||
const blocked = this.blockedRequest;
|
||||
this.blockedRequest = null;
|
||||
if (blocked) {
|
||||
this.finishOpen(
|
||||
blocked.request,
|
||||
blocked.targetVersion,
|
||||
blocked.database,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pauseTransactions(): void {
|
||||
this.transactionsPaused = true;
|
||||
}
|
||||
|
||||
clearLastTransaction(): void {
|
||||
this.lastTransaction = null;
|
||||
}
|
||||
|
||||
resumeTransactions(): void {
|
||||
this.transactionsPaused = false;
|
||||
const queued = this.transactionQueue;
|
||||
this.transactionQueue = [];
|
||||
for (const callback of queued) queueMicrotask(callback);
|
||||
}
|
||||
|
||||
failNextWriteCommit(error: DOMException): void {
|
||||
this.nextCommitFailure = error;
|
||||
}
|
||||
|
||||
triggerVersionChange(newVersion: number): void {
|
||||
this.latestDatabase.fireVersionChange(newVersion);
|
||||
}
|
||||
|
||||
triggerForcedClose(): void {
|
||||
this.latestDatabase.fireForcedClose();
|
||||
}
|
||||
|
||||
isConnectionClosed(): boolean {
|
||||
return this.latestDatabase.closed;
|
||||
}
|
||||
|
||||
seed(storeName: string, value: unknown): void {
|
||||
const state = this.store(storeName);
|
||||
const key = primaryKey(state, value);
|
||||
state.data.set(key, structuredClone(value));
|
||||
}
|
||||
|
||||
readRaw(storeName: string, key: string): unknown {
|
||||
const value = this.store(storeName).data.get(key);
|
||||
return value === undefined ? undefined : structuredClone(value);
|
||||
}
|
||||
|
||||
scheduleTransaction(
|
||||
callback: () => void,
|
||||
mode?: IDBTransactionMode,
|
||||
): void {
|
||||
if (this.transactionsPaused && mode === "readwrite") {
|
||||
this.transactionQueue.push(callback);
|
||||
return;
|
||||
}
|
||||
queueMicrotask(callback);
|
||||
}
|
||||
|
||||
scheduleLifecycle(callback: () => void): void {
|
||||
queueMicrotask(callback);
|
||||
}
|
||||
|
||||
consumeCommitFailure(): DOMException | null {
|
||||
const failure = this.nextCommitFailure;
|
||||
this.nextCommitFailure = null;
|
||||
return failure;
|
||||
}
|
||||
|
||||
noteTransaction(transaction: FakeTransaction): void {
|
||||
this.lastTransaction = transaction;
|
||||
}
|
||||
|
||||
storeNames(): Iterable<string> {
|
||||
return this.stores.keys();
|
||||
}
|
||||
|
||||
hasStore(name: string): boolean {
|
||||
return this.stores.has(name);
|
||||
}
|
||||
|
||||
store(name: string): StoreState {
|
||||
const state = this.stores.get(name);
|
||||
if (!state) {
|
||||
throw new DOMException("Store does not exist.", "NotFoundError");
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
createStore(name: string, state: StoreState): void {
|
||||
this.stores.set(name, state);
|
||||
}
|
||||
|
||||
replaceStore(name: string, state: StoreState): void {
|
||||
this.stores.set(name, state);
|
||||
}
|
||||
|
||||
deleteStore(name: string): boolean {
|
||||
return this.stores.delete(name);
|
||||
}
|
||||
|
||||
private finishOpen(
|
||||
request: MutableOpenRequest,
|
||||
targetVersion: number,
|
||||
database: FakeDatabase,
|
||||
): void {
|
||||
if (targetVersion < this.databaseVersion) {
|
||||
request.error = new DOMException(
|
||||
"Requested version is older.",
|
||||
"VersionError",
|
||||
);
|
||||
request.onerror?.(new Event("error"));
|
||||
return;
|
||||
}
|
||||
if (targetVersion > this.databaseVersion) {
|
||||
const before = cloneStores(this.stores);
|
||||
const oldVersion = this.databaseVersion;
|
||||
const upgrade = new FakeUpgradeTransaction(database);
|
||||
request.transaction = upgrade as unknown as IDBTransaction;
|
||||
database.version = targetVersion;
|
||||
request.onupgradeneeded?.({
|
||||
oldVersion,
|
||||
newVersion: targetVersion,
|
||||
} as IDBVersionChangeEvent);
|
||||
request.transaction = null;
|
||||
if (upgrade.aborted) {
|
||||
this.stores = before;
|
||||
database.version = oldVersion;
|
||||
request.error = new DOMException(
|
||||
"Upgrade transaction aborted.",
|
||||
"AbortError",
|
||||
);
|
||||
request.onerror?.(new Event("error"));
|
||||
return;
|
||||
}
|
||||
this.databaseVersion = targetVersion;
|
||||
}
|
||||
database.version = this.databaseVersion;
|
||||
request.result = database as unknown as IDBDatabase;
|
||||
request.onsuccess?.(new Event("success"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user