feat: 기능 추가 과정중

This commit is contained in:
donghyeon-ka
2026-07-30 15:58:20 +09:00
parent d3ef801fe6
commit 6c52cdb916
648 changed files with 126325 additions and 6680 deletions
@@ -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))
);
}