229 lines
5.8 KiB
TypeScript
229 lines
5.8 KiB
TypeScript
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?.();
|
|
},
|
|
});
|
|
}
|