feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,690 @@
|
||||
import type {
|
||||
IndexedDbRepositoryPort,
|
||||
IndexedDbWriteReceipt,
|
||||
} from "../../application/ports/browser-file-storage/indexeddb-port.ts";
|
||||
import type {
|
||||
BrowserDataFailure,
|
||||
BrowserDataResult,
|
||||
} from "../../application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
WEB_PUSH_LIMITS,
|
||||
WEB_PUSH_PROTOCOLS,
|
||||
samePushAuthority,
|
||||
webPushFailure,
|
||||
webPushSuccess,
|
||||
type PushAuthoritySnapshot,
|
||||
type PushControlAssociationV1,
|
||||
type PushControlV1,
|
||||
type WebPushOperation,
|
||||
type WebPushResult,
|
||||
} from "../../contracts/web-push.ts";
|
||||
import {
|
||||
withAbortableDeadline,
|
||||
type TimeoutScheduler,
|
||||
} from "./runtime-support.ts";
|
||||
|
||||
const CONTROL_KEY = "push-control-v1";
|
||||
const OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
|
||||
const ISO_INSTANT =
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u;
|
||||
|
||||
export type PushControlReceipt = Readonly<{
|
||||
control: PushControlV1;
|
||||
revision: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The generic repository owns IndexedDB connection, migration, transaction,
|
||||
* timeout, codec and version-change policy. This adapter adds only the
|
||||
* Web Push authority transition rules on top of its revisioned CAS.
|
||||
*/
|
||||
export type PushControlRepository = Pick<
|
||||
IndexedDbRepositoryPort<PushControlV1, never>,
|
||||
"open" | "read" | "compareAndSwap" | "remove" | "close"
|
||||
>;
|
||||
|
||||
export interface PushAssociationFenceStore {
|
||||
read(input?: Readonly<{
|
||||
signal?: AbortSignal;
|
||||
}>): Promise<WebPushResult<PushControlReceipt | null>>;
|
||||
|
||||
prepare(input: Readonly<{
|
||||
authority: PushAuthoritySnapshot;
|
||||
updatedAt: string;
|
||||
signal?: AbortSignal;
|
||||
}>): Promise<WebPushResult<PushControlReceipt>>;
|
||||
|
||||
activate(input: Readonly<{
|
||||
expectedRevision: number;
|
||||
authority: PushAuthoritySnapshot;
|
||||
associationEpoch: string;
|
||||
updatedAt: string;
|
||||
signal?: AbortSignal;
|
||||
}>): Promise<WebPushResult<PushControlReceipt>>;
|
||||
|
||||
markRevoked(input: Readonly<{
|
||||
expectedRevision: number;
|
||||
authority: PushAuthoritySnapshot;
|
||||
updatedAt: string;
|
||||
signal?: AbortSignal;
|
||||
}>): Promise<WebPushResult<PushControlReceipt>>;
|
||||
|
||||
rotateAndRevoke(input: Readonly<{
|
||||
expectedRevision: number;
|
||||
previousAuthority: PushAuthoritySnapshot;
|
||||
nextAuthority: PushAuthoritySnapshot;
|
||||
updatedAt: string;
|
||||
signal?: AbortSignal;
|
||||
}>): Promise<WebPushResult<PushControlReceipt>>;
|
||||
|
||||
purge(input: Readonly<{
|
||||
expectedRevision: number;
|
||||
authority: PushAuthoritySnapshot;
|
||||
associationEpoch: string;
|
||||
signal?: AbortSignal;
|
||||
}>): Promise<WebPushResult<void>>;
|
||||
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export type PushAssociationFenceStoreDependencies = Readonly<{
|
||||
repository: PushControlRepository;
|
||||
idempotencyKeyFactory?: () => string;
|
||||
operationDeadlineMs?: number;
|
||||
scheduler?: TimeoutScheduler;
|
||||
}>;
|
||||
|
||||
export function createPushAssociationFenceStore(
|
||||
dependencies: PushAssociationFenceStoreDependencies,
|
||||
): PushAssociationFenceStore {
|
||||
if (
|
||||
!dependencies ||
|
||||
typeof dependencies !== "object" ||
|
||||
!validRepository(dependencies.repository)
|
||||
) {
|
||||
throw new TypeError("Web Push fence store configuration is invalid.");
|
||||
}
|
||||
const repository = dependencies.repository;
|
||||
const operationDeadlineMs =
|
||||
dependencies.operationDeadlineMs ??
|
||||
WEB_PUSH_LIMITS.fenceOperationDeadlineMs;
|
||||
if (
|
||||
!Number.isSafeInteger(operationDeadlineMs) ||
|
||||
operationDeadlineMs < 1 ||
|
||||
operationDeadlineMs > WEB_PUSH_LIMITS.fenceOperationDeadlineMs
|
||||
) {
|
||||
throw new TypeError("Web Push fence deadline is invalid.");
|
||||
}
|
||||
const idempotencyKeyFactory =
|
||||
dependencies.idempotencyKeyFactory ??
|
||||
(() => `push-control-${globalThis.crypto.randomUUID()}`);
|
||||
let closed = false;
|
||||
|
||||
const store: PushAssociationFenceStore = {
|
||||
async read(input = {}) {
|
||||
return await bounded(
|
||||
"CONTROL_READ",
|
||||
input.signal,
|
||||
(signal) => readReceipt("CONTROL_READ", signal),
|
||||
);
|
||||
},
|
||||
|
||||
async prepare(input) {
|
||||
if (
|
||||
!validAuthority(input.authority) ||
|
||||
!validInstant(input.updatedAt)
|
||||
) {
|
||||
return webPushFailure("INVALID_INPUT", "CONTROL_PREPARE");
|
||||
}
|
||||
return await bounded(
|
||||
"CONTROL_PREPARE",
|
||||
input.signal,
|
||||
async (signal) => {
|
||||
const current = await readReceipt(
|
||||
"CONTROL_PREPARE",
|
||||
signal,
|
||||
);
|
||||
if (!current.ok) return current;
|
||||
if (current.value) {
|
||||
return samePushAuthority(
|
||||
current.value.control,
|
||||
input.authority,
|
||||
)
|
||||
? webPushSuccess(current.value)
|
||||
: webPushFailure(
|
||||
"STALE_AUTHORITY",
|
||||
"CONTROL_PREPARE",
|
||||
);
|
||||
}
|
||||
return await compareAndSwap(
|
||||
"CONTROL_PREPARE",
|
||||
null,
|
||||
controlSnapshot({
|
||||
protocol: WEB_PUSH_PROTOCOLS.control,
|
||||
...input.authority,
|
||||
updatedAt: input.updatedAt,
|
||||
association: Object.freeze({
|
||||
state: "UNASSOCIATED",
|
||||
}),
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async activate(input) {
|
||||
if (
|
||||
!validRevision(input.expectedRevision) ||
|
||||
!validAuthority(input.authority) ||
|
||||
!validOpaqueId(input.associationEpoch) ||
|
||||
!validInstant(input.updatedAt)
|
||||
) {
|
||||
return webPushFailure("INVALID_INPUT", "CONTROL_ACTIVATE");
|
||||
}
|
||||
return await bounded(
|
||||
"CONTROL_ACTIVATE",
|
||||
input.signal,
|
||||
async (signal) => {
|
||||
const current = await currentForMutation(
|
||||
"CONTROL_ACTIVATE",
|
||||
input.expectedRevision,
|
||||
input.authority,
|
||||
signal,
|
||||
);
|
||||
if (!current.ok) return current;
|
||||
if (
|
||||
current.value.control.association.state === "ACTIVE" &&
|
||||
current.value.control.association.associationEpoch ===
|
||||
input.associationEpoch
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
if (
|
||||
current.value.control.association.state === "REVOKED" &&
|
||||
current.value.control.association.associationEpoch ===
|
||||
input.associationEpoch
|
||||
) {
|
||||
return webPushFailure(
|
||||
"TOMBSTONE_CONFLICT",
|
||||
"CONTROL_ACTIVATE",
|
||||
);
|
||||
}
|
||||
return await compareAndSwap(
|
||||
"CONTROL_ACTIVATE",
|
||||
input.expectedRevision,
|
||||
controlSnapshot({
|
||||
...current.value.control,
|
||||
updatedAt: input.updatedAt,
|
||||
association: Object.freeze({
|
||||
state: "ACTIVE",
|
||||
associationEpoch: input.associationEpoch,
|
||||
}),
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async markRevoked(input) {
|
||||
if (
|
||||
!validRevision(input.expectedRevision) ||
|
||||
!validAuthority(input.authority) ||
|
||||
!validInstant(input.updatedAt)
|
||||
) {
|
||||
return webPushFailure("INVALID_INPUT", "CONTROL_REVOKE");
|
||||
}
|
||||
return await bounded(
|
||||
"CONTROL_REVOKE",
|
||||
input.signal,
|
||||
async (signal) => {
|
||||
const current = await currentForMutation(
|
||||
"CONTROL_REVOKE",
|
||||
input.expectedRevision,
|
||||
input.authority,
|
||||
signal,
|
||||
);
|
||||
if (!current.ok) return current;
|
||||
if (
|
||||
current.value.control.association.state ===
|
||||
"UNASSOCIATED"
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return await compareAndSwap(
|
||||
"CONTROL_REVOKE",
|
||||
input.expectedRevision,
|
||||
controlSnapshot({
|
||||
...current.value.control,
|
||||
updatedAt: input.updatedAt,
|
||||
association: Object.freeze({
|
||||
state: "REVOKED",
|
||||
associationEpoch:
|
||||
current.value.control.association
|
||||
.associationEpoch,
|
||||
}),
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async rotateAndRevoke(input) {
|
||||
if (
|
||||
!validRevision(input.expectedRevision) ||
|
||||
!validAuthority(input.previousAuthority) ||
|
||||
!validAuthority(input.nextAuthority) ||
|
||||
input.previousAuthority.fenceGeneration ===
|
||||
input.nextAuthority.fenceGeneration ||
|
||||
!validInstant(input.updatedAt)
|
||||
) {
|
||||
return webPushFailure("INVALID_INPUT", "CONTROL_REVOKE");
|
||||
}
|
||||
return await bounded(
|
||||
"CONTROL_REVOKE",
|
||||
input.signal,
|
||||
async (signal) => {
|
||||
const current = await currentForMutation(
|
||||
"CONTROL_REVOKE",
|
||||
input.expectedRevision,
|
||||
input.previousAuthority,
|
||||
signal,
|
||||
);
|
||||
if (!current.ok) return current;
|
||||
const association: PushControlAssociationV1 =
|
||||
current.value.control.association.state ===
|
||||
"UNASSOCIATED"
|
||||
? Object.freeze({ state: "UNASSOCIATED" })
|
||||
: Object.freeze({
|
||||
state: "REVOKED",
|
||||
associationEpoch:
|
||||
current.value.control.association
|
||||
.associationEpoch,
|
||||
});
|
||||
return await compareAndSwap(
|
||||
"CONTROL_REVOKE",
|
||||
input.expectedRevision,
|
||||
controlSnapshot({
|
||||
protocol: WEB_PUSH_PROTOCOLS.control,
|
||||
...input.nextAuthority,
|
||||
updatedAt: input.updatedAt,
|
||||
association,
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async purge(input) {
|
||||
if (
|
||||
!validRevision(input.expectedRevision) ||
|
||||
!validAuthority(input.authority) ||
|
||||
!validOpaqueId(input.associationEpoch)
|
||||
) {
|
||||
return webPushFailure("INVALID_INPUT", "CONTROL_PURGE");
|
||||
}
|
||||
return await bounded(
|
||||
"CONTROL_PURGE",
|
||||
input.signal,
|
||||
async (signal) => {
|
||||
const current = await currentForMutation(
|
||||
"CONTROL_PURGE",
|
||||
input.expectedRevision,
|
||||
input.authority,
|
||||
signal,
|
||||
);
|
||||
if (!current.ok) return current;
|
||||
if (
|
||||
current.value.control.association.state !== "REVOKED" ||
|
||||
current.value.control.association.associationEpoch !==
|
||||
input.associationEpoch
|
||||
) {
|
||||
return webPushFailure(
|
||||
"ASSOCIATION_MISMATCH",
|
||||
"CONTROL_PURGE",
|
||||
);
|
||||
}
|
||||
return await removeControl(input.expectedRevision, signal);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
repository.close();
|
||||
} catch {
|
||||
// The local authority is terminal even if host cleanup throws.
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return Object.freeze(store);
|
||||
|
||||
async function bounded<Value>(
|
||||
operation: WebPushOperation,
|
||||
signal: AbortSignal | undefined,
|
||||
task: (
|
||||
boundedSignal: AbortSignal,
|
||||
) => Promise<WebPushResult<Value>>,
|
||||
): Promise<WebPushResult<Value>> {
|
||||
return await withAbortableDeadline(task, {
|
||||
deadlineMs: operationDeadlineMs,
|
||||
operation,
|
||||
signal,
|
||||
scheduler: dependencies.scheduler,
|
||||
});
|
||||
}
|
||||
|
||||
async function currentForMutation(
|
||||
operation:
|
||||
| "CONTROL_ACTIVATE"
|
||||
| "CONTROL_REVOKE"
|
||||
| "CONTROL_PURGE",
|
||||
expectedRevision: number,
|
||||
authority: PushAuthoritySnapshot,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<WebPushResult<PushControlReceipt>> {
|
||||
const current = await readReceipt(operation, signal);
|
||||
if (!current.ok) return current;
|
||||
if (!current.value || current.value.revision !== expectedRevision) {
|
||||
return webPushFailure("STALE_REVISION", operation);
|
||||
}
|
||||
if (!samePushAuthority(current.value.control, authority)) {
|
||||
return webPushFailure("STALE_AUTHORITY", operation);
|
||||
}
|
||||
return webPushSuccess(current.value);
|
||||
}
|
||||
|
||||
async function readReceipt(
|
||||
operation: WebPushOperation,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<WebPushResult<PushControlReceipt | null>> {
|
||||
if (closed) return webPushFailure("NATIVE_FAILURE", operation);
|
||||
if (signal?.aborted) return webPushFailure("ABORTED", operation);
|
||||
const opened = await callRepository(
|
||||
() => repository.open(signal),
|
||||
operation,
|
||||
);
|
||||
if (!opened.ok) return opened;
|
||||
const read = await callRepository(
|
||||
() => repository.read(CONTROL_KEY, signal),
|
||||
operation,
|
||||
);
|
||||
if (!read.ok) return read;
|
||||
if (!read.value) return webPushSuccess(null);
|
||||
const control = decodeControl(read.value.value);
|
||||
if (!control || !validRevision(read.value.revision)) {
|
||||
return webPushFailure("CONTROL_CORRUPT", operation);
|
||||
}
|
||||
return webPushSuccess(
|
||||
Object.freeze({
|
||||
control,
|
||||
revision: read.value.revision,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function compareAndSwap(
|
||||
operation:
|
||||
| "CONTROL_PREPARE"
|
||||
| "CONTROL_ACTIVATE"
|
||||
| "CONTROL_REVOKE",
|
||||
expectedRevision: number | null,
|
||||
control: PushControlV1,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<WebPushResult<PushControlReceipt>> {
|
||||
let idempotencyKey: string;
|
||||
try {
|
||||
idempotencyKey = idempotencyKeyFactory();
|
||||
} catch {
|
||||
return webPushFailure("NATIVE_FAILURE", operation);
|
||||
}
|
||||
if (!validIdempotencyKey(idempotencyKey)) {
|
||||
return webPushFailure("INVALID_INPUT", operation);
|
||||
}
|
||||
const written = await callRepository(
|
||||
() =>
|
||||
repository.compareAndSwap({
|
||||
key: CONTROL_KEY,
|
||||
value: control,
|
||||
expectedRevision,
|
||||
idempotencyKey,
|
||||
...(signal ? { signal } : {}),
|
||||
}),
|
||||
operation,
|
||||
);
|
||||
if (!written.ok) return written;
|
||||
if (!validWriteReceipt(written.value)) {
|
||||
return webPushFailure("CONTROL_CORRUPT", operation);
|
||||
}
|
||||
return webPushSuccess(
|
||||
Object.freeze({
|
||||
control,
|
||||
revision: written.value.revision,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function removeControl(
|
||||
expectedRevision: number,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<WebPushResult<void>> {
|
||||
let idempotencyKey: string;
|
||||
try {
|
||||
idempotencyKey = idempotencyKeyFactory();
|
||||
} catch {
|
||||
return webPushFailure("NATIVE_FAILURE", "CONTROL_PURGE");
|
||||
}
|
||||
if (!validIdempotencyKey(idempotencyKey)) {
|
||||
return webPushFailure("INVALID_INPUT", "CONTROL_PURGE");
|
||||
}
|
||||
const removed = await callRepository(
|
||||
() =>
|
||||
repository.remove({
|
||||
key: CONTROL_KEY,
|
||||
expectedRevision,
|
||||
idempotencyKey,
|
||||
...(signal ? { signal } : {}),
|
||||
}),
|
||||
"CONTROL_PURGE",
|
||||
);
|
||||
if (!removed.ok) return removed;
|
||||
if (
|
||||
!validWriteReceipt(removed.value) ||
|
||||
removed.value.revision !== expectedRevision + 1
|
||||
) {
|
||||
return webPushFailure("CONTROL_CORRUPT", "CONTROL_PURGE");
|
||||
}
|
||||
return webPushSuccess(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function callRepository<Value>(
|
||||
call: () => Promise<BrowserDataResult<Value>>,
|
||||
operation: WebPushOperation,
|
||||
): Promise<WebPushResult<Value>> {
|
||||
try {
|
||||
const result = await call();
|
||||
return result.ok
|
||||
? webPushSuccess(result.value)
|
||||
: mapRepositoryFailure(result.error, operation);
|
||||
} catch {
|
||||
return webPushFailure("NATIVE_FAILURE", operation, true);
|
||||
}
|
||||
}
|
||||
|
||||
function mapRepositoryFailure(
|
||||
failure: BrowserDataFailure,
|
||||
operation: WebPushOperation,
|
||||
): WebPushResult<never> {
|
||||
switch (failure.code) {
|
||||
case "ABORTED":
|
||||
return webPushFailure("ABORTED", operation);
|
||||
case "BLOCKED":
|
||||
return webPushFailure("BLOCKED", operation, failure.retryable);
|
||||
case "CONFLICT":
|
||||
case "STALE_RESULT":
|
||||
return webPushFailure(
|
||||
"STALE_REVISION",
|
||||
operation,
|
||||
failure.retryable,
|
||||
);
|
||||
case "CORRUPT_DATA":
|
||||
case "EXPIRED_RESOURCE":
|
||||
case "INTEGRITY_FAILED":
|
||||
case "MIGRATION_FAILED":
|
||||
return webPushFailure(
|
||||
"CONTROL_CORRUPT",
|
||||
operation,
|
||||
failure.retryable,
|
||||
);
|
||||
case "INVALID_INPUT":
|
||||
return webPushFailure("INVALID_INPUT", operation);
|
||||
case "LIMIT_EXCEEDED":
|
||||
return webPushFailure(
|
||||
"LIMIT_EXCEEDED",
|
||||
operation,
|
||||
failure.retryable,
|
||||
);
|
||||
case "UNSUPPORTED":
|
||||
return webPushFailure("UNSUPPORTED", operation);
|
||||
default:
|
||||
return webPushFailure(
|
||||
"NATIVE_FAILURE",
|
||||
operation,
|
||||
failure.retryable,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function controlSnapshot(value: PushControlV1): PushControlV1 {
|
||||
const association: PushControlAssociationV1 =
|
||||
value.association.state === "UNASSOCIATED"
|
||||
? Object.freeze({ state: "UNASSOCIATED" })
|
||||
: Object.freeze({
|
||||
state: value.association.state,
|
||||
associationEpoch: value.association.associationEpoch,
|
||||
});
|
||||
return Object.freeze({
|
||||
protocol: WEB_PUSH_PROTOCOLS.control,
|
||||
fenceGeneration: value.fenceGeneration,
|
||||
sessionBindingEpoch: value.sessionBindingEpoch,
|
||||
releaseEpoch: value.releaseEpoch,
|
||||
updatedAt: value.updatedAt,
|
||||
association,
|
||||
});
|
||||
}
|
||||
|
||||
function decodeControl(value: unknown): PushControlV1 | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
if (
|
||||
keys.length !== 6 ||
|
||||
keys.join("|") !==
|
||||
"association|fenceGeneration|protocol|releaseEpoch|sessionBindingEpoch|updatedAt" ||
|
||||
record.protocol !== WEB_PUSH_PROTOCOLS.control ||
|
||||
!validOpaqueId(record.fenceGeneration) ||
|
||||
!validOpaqueId(record.sessionBindingEpoch) ||
|
||||
!validOpaqueId(record.releaseEpoch) ||
|
||||
!validInstant(record.updatedAt) ||
|
||||
!validAssociation(record.association)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return controlSnapshot({
|
||||
protocol: WEB_PUSH_PROTOCOLS.control,
|
||||
fenceGeneration: record.fenceGeneration,
|
||||
sessionBindingEpoch: record.sessionBindingEpoch,
|
||||
releaseEpoch: record.releaseEpoch,
|
||||
updatedAt: record.updatedAt,
|
||||
association: record.association,
|
||||
});
|
||||
}
|
||||
|
||||
function validAssociation(
|
||||
value: unknown,
|
||||
): value is PushControlAssociationV1 {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
if (record.state === "UNASSOCIATED") {
|
||||
return keys.length === 1 && keys[0] === "state";
|
||||
}
|
||||
return (
|
||||
(record.state === "ACTIVE" || record.state === "REVOKED") &&
|
||||
keys.length === 2 &&
|
||||
keys[0] === "associationEpoch" &&
|
||||
keys[1] === "state" &&
|
||||
validOpaqueId(record.associationEpoch)
|
||||
);
|
||||
}
|
||||
|
||||
function validAuthority(
|
||||
value: PushAuthoritySnapshot,
|
||||
): value is PushAuthoritySnapshot {
|
||||
return (
|
||||
Boolean(value) &&
|
||||
validOpaqueId(value.fenceGeneration) &&
|
||||
validOpaqueId(value.sessionBindingEpoch) &&
|
||||
validOpaqueId(value.releaseEpoch)
|
||||
);
|
||||
}
|
||||
|
||||
function validRepository(
|
||||
value: unknown,
|
||||
): value is PushControlRepository {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const repository = value as Partial<PushControlRepository>;
|
||||
return (
|
||||
typeof repository.open === "function" &&
|
||||
typeof repository.read === "function" &&
|
||||
typeof repository.compareAndSwap === "function" &&
|
||||
typeof repository.remove === "function" &&
|
||||
typeof repository.close === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function validWriteReceipt(
|
||||
value: IndexedDbWriteReceipt,
|
||||
): value is IndexedDbWriteReceipt {
|
||||
return (
|
||||
Boolean(value) &&
|
||||
value.key === CONTROL_KEY &&
|
||||
validRevision(value.revision) &&
|
||||
typeof value.replayed === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
function validIdempotencyKey(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length >= 1 &&
|
||||
value.length <= 200
|
||||
);
|
||||
}
|
||||
|
||||
function validOpaqueId(value: unknown): value is string {
|
||||
return typeof value === "string" && OPAQUE_ID.test(value);
|
||||
}
|
||||
|
||||
function validRevision(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === "number" &&
|
||||
Number.isSafeInteger(value) &&
|
||||
value >= 1
|
||||
);
|
||||
}
|
||||
|
||||
function validInstant(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
ISO_INSTANT.test(value) &&
|
||||
Number.isFinite(Date.parse(value))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user