feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
import type { IndexedDbDatasetScope } from "../../../application/ports/browser-file-storage/indexeddb-port.ts";
|
||||
import {
|
||||
assertValidStoragePolicy,
|
||||
type BrowserStoragePolicy,
|
||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
|
||||
export const INDEXEDDB_DATASET_BINDING_KEY = "dataset-binding";
|
||||
export const INDEXEDDB_DATASET_BUDGET_KEY = "dataset-budget";
|
||||
|
||||
const OPAQUE_SCOPE_TOKEN = /^[A-Za-z0-9_-]{16,48}$/u;
|
||||
|
||||
type StoredDatasetBinding = Readonly<{
|
||||
bindingKey: typeof INDEXEDDB_DATASET_BINDING_KEY;
|
||||
bindingVersion: 1;
|
||||
scope: IndexedDbDatasetScope;
|
||||
storagePolicy: BrowserStoragePolicy;
|
||||
}>;
|
||||
|
||||
export type IndexedDbBindingVerification =
|
||||
| Readonly<{ ok: true }>
|
||||
| Readonly<{
|
||||
ok: false;
|
||||
reason: "ABORTED" | "CORRUPT" | "MISMATCH" | "MISSING" | "NATIVE_ERROR";
|
||||
error?: unknown;
|
||||
}>;
|
||||
|
||||
function validOpaqueToken(value: unknown): value is string {
|
||||
return typeof value === "string" && OPAQUE_SCOPE_TOKEN.test(value);
|
||||
}
|
||||
|
||||
export function assertValidIndexedDbDatasetGovernance(
|
||||
scope: IndexedDbDatasetScope,
|
||||
storagePolicy: BrowserStoragePolicy,
|
||||
): void {
|
||||
assertValidStoragePolicy(storagePolicy);
|
||||
if (
|
||||
!scope ||
|
||||
typeof scope !== "object" ||
|
||||
!validOpaqueToken(scope.authorityToken) ||
|
||||
!validOpaqueToken(scope.namespaceToken) ||
|
||||
!validOpaqueToken(scope.partitionToken) ||
|
||||
new Set([
|
||||
scope.authorityToken,
|
||||
scope.namespaceToken,
|
||||
scope.partitionToken,
|
||||
]).size !== 3 ||
|
||||
scope.accountScope !== storagePolicy.accountScope ||
|
||||
scope.authorityToken === storagePolicy.owner ||
|
||||
scope.namespaceToken === storagePolicy.namespace ||
|
||||
scope.partitionToken === storagePolicy.namespace ||
|
||||
(storagePolicy.classification === "PERSONAL" &&
|
||||
scope.accountScope !== "OPAQUE_PARTITION") ||
|
||||
(storagePolicy.classification === "CONFIDENTIAL" &&
|
||||
scope.accountScope !== "OPAQUE_PARTITION")
|
||||
) {
|
||||
throw new TypeError("IndexedDB dataset governance is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical identity is derived exclusively from opaque registry tokens. The
|
||||
* readable policy namespace and all business/account identifiers are excluded.
|
||||
*/
|
||||
export function indexedDbPhysicalDatabaseName(
|
||||
scope: IndexedDbDatasetScope,
|
||||
): string {
|
||||
if (
|
||||
!scope ||
|
||||
typeof scope !== "object" ||
|
||||
!validOpaqueToken(scope.authorityToken) ||
|
||||
!validOpaqueToken(scope.namespaceToken) ||
|
||||
!validOpaqueToken(scope.partitionToken)
|
||||
) {
|
||||
throw new TypeError("IndexedDB dataset scope is invalid.");
|
||||
}
|
||||
return `ca-idb-v1:${scope.authorityToken}.${scope.namespaceToken}.${scope.partitionToken}`;
|
||||
}
|
||||
|
||||
export function createIndexedDbDatasetBinding(
|
||||
scope: IndexedDbDatasetScope,
|
||||
storagePolicy: BrowserStoragePolicy,
|
||||
): StoredDatasetBinding {
|
||||
assertValidIndexedDbDatasetGovernance(scope, storagePolicy);
|
||||
return Object.freeze({
|
||||
bindingKey: INDEXEDDB_DATASET_BINDING_KEY,
|
||||
bindingVersion: 1,
|
||||
scope: Object.freeze({ ...scope }),
|
||||
storagePolicy: Object.freeze({
|
||||
...storagePolicy,
|
||||
retention: Object.freeze({ ...storagePolicy.retention }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function isStoredDatasetBinding(
|
||||
value: unknown,
|
||||
): value is StoredDatasetBinding {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const binding = value as Partial<StoredDatasetBinding>;
|
||||
if (
|
||||
binding.bindingKey !== INDEXEDDB_DATASET_BINDING_KEY ||
|
||||
binding.bindingVersion !== 1 ||
|
||||
!binding.scope ||
|
||||
!binding.storagePolicy
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
assertValidIndexedDbDatasetGovernance(
|
||||
binding.scope,
|
||||
binding.storagePolicy,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalPolicy(policy: BrowserStoragePolicy): string {
|
||||
return JSON.stringify([
|
||||
policy.owner,
|
||||
policy.namespace,
|
||||
policy.classification,
|
||||
policy.authority,
|
||||
policy.accountScope,
|
||||
policy.retention.kind,
|
||||
policy.retention.kind === "TTL"
|
||||
? policy.retention.maxAgeMs
|
||||
: null,
|
||||
policy.softBudgetBytes,
|
||||
policy.hardBudgetBytes,
|
||||
policy.evictionPriority,
|
||||
policy.logoutAction,
|
||||
policy.accountDeletionAction,
|
||||
policy.pressureAction,
|
||||
policy.unavailableFallback,
|
||||
]);
|
||||
}
|
||||
|
||||
export function sameIndexedDbDatasetBinding(
|
||||
value: unknown,
|
||||
expected: StoredDatasetBinding,
|
||||
): boolean {
|
||||
if (!isStoredDatasetBinding(value)) return false;
|
||||
return (
|
||||
value.scope.authorityToken === expected.scope.authorityToken &&
|
||||
value.scope.namespaceToken === expected.scope.namespaceToken &&
|
||||
value.scope.partitionToken === expected.scope.partitionToken &&
|
||||
value.scope.accountScope === expected.scope.accountScope &&
|
||||
canonicalPolicy(value.storagePolicy) ===
|
||||
canonicalPolicy(expected.storagePolicy)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues binding validation inside the versionchange transaction. Any mismatch
|
||||
* aborts that transaction, so schema changes cannot commit under the wrong
|
||||
* namespace or policy.
|
||||
*/
|
||||
export function queueIndexedDbUpgradeBinding(
|
||||
transaction: IDBTransaction,
|
||||
governanceStore: string,
|
||||
expected: StoredDatasetBinding,
|
||||
oldVersion: number,
|
||||
onRejected: () => void,
|
||||
): void {
|
||||
const store = transaction.objectStore(governanceStore);
|
||||
if (oldVersion === 0) {
|
||||
let addRequest: IDBRequest<IDBValidKey>;
|
||||
try {
|
||||
addRequest = store.add(expected);
|
||||
} catch {
|
||||
onRejected();
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
addRequest.onerror = () => onRejected();
|
||||
let budgetRequest: IDBRequest<IDBValidKey>;
|
||||
try {
|
||||
budgetRequest = store.add(
|
||||
Object.freeze({
|
||||
bindingKey: INDEXEDDB_DATASET_BUDGET_KEY,
|
||||
budgetVersion: 1,
|
||||
usedBytes: 0,
|
||||
receiptCount: 0,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
onRejected();
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
budgetRequest.onerror = () => onRejected();
|
||||
return;
|
||||
}
|
||||
const request = store.get(INDEXEDDB_DATASET_BINDING_KEY);
|
||||
request.onerror = () => {
|
||||
onRejected();
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
// The native request/transaction error owns the terminal state.
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
if (!sameIndexedDbDatasetBinding(request.result, expected)) {
|
||||
onRejected();
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
// The mismatch remains fail-closed even if abort already won.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-open verification protects non-upgrade opens and maintenance callers.
|
||||
*/
|
||||
export function verifyIndexedDbDatasetBinding(
|
||||
database: IDBDatabase,
|
||||
governanceStore: string,
|
||||
expected: StoredDatasetBinding,
|
||||
signal?: AbortSignal,
|
||||
): Promise<IndexedDbBindingVerification> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.resolve({ ok: false, reason: "ABORTED" });
|
||||
}
|
||||
let transaction: IDBTransaction;
|
||||
try {
|
||||
transaction = database.transaction(governanceStore, "readonly");
|
||||
} catch (error) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
reason: "NATIVE_ERROR",
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let observed: unknown;
|
||||
let observedBudget: unknown;
|
||||
let requestError: unknown;
|
||||
let callerAborted = false;
|
||||
const finish = (result: IndexedDbBindingVerification) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(result);
|
||||
};
|
||||
function onAbort(): void {
|
||||
callerAborted = true;
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
// Completion determines the race.
|
||||
}
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
transaction.onerror = () => {
|
||||
requestError ??= transaction.error;
|
||||
};
|
||||
transaction.onabort = () =>
|
||||
finish(
|
||||
callerAborted
|
||||
? { ok: false, reason: "ABORTED" }
|
||||
: {
|
||||
ok: false,
|
||||
reason: "NATIVE_ERROR",
|
||||
error: requestError ?? transaction.error,
|
||||
},
|
||||
);
|
||||
transaction.oncomplete = () => {
|
||||
if (observed === undefined) {
|
||||
finish({ ok: false, reason: "MISSING" });
|
||||
} else if (!isStoredDatasetBinding(observed)) {
|
||||
finish({ ok: false, reason: "CORRUPT" });
|
||||
} else if (!sameIndexedDbDatasetBinding(observed, expected)) {
|
||||
finish({ ok: false, reason: "MISMATCH" });
|
||||
} else if (
|
||||
!observedBudget ||
|
||||
typeof observedBudget !== "object" ||
|
||||
(observedBudget as { bindingKey?: unknown }).bindingKey !==
|
||||
INDEXEDDB_DATASET_BUDGET_KEY ||
|
||||
(observedBudget as { budgetVersion?: unknown }).budgetVersion !==
|
||||
1 ||
|
||||
!Number.isSafeInteger(
|
||||
(observedBudget as { usedBytes?: unknown }).usedBytes,
|
||||
) ||
|
||||
typeof (observedBudget as { usedBytes?: unknown }).usedBytes !==
|
||||
"number" ||
|
||||
(observedBudget as { usedBytes: number }).usedBytes < 0 ||
|
||||
(observedBudget as { usedBytes: number }).usedBytes >
|
||||
expected.storagePolicy.hardBudgetBytes
|
||||
||
|
||||
!Number.isSafeInteger(
|
||||
(observedBudget as { receiptCount?: unknown }).receiptCount,
|
||||
) ||
|
||||
typeof (observedBudget as { receiptCount?: unknown })
|
||||
.receiptCount !== "number" ||
|
||||
(observedBudget as { receiptCount: number }).receiptCount < 0
|
||||
) {
|
||||
finish({ ok: false, reason: "CORRUPT" });
|
||||
} else {
|
||||
finish({ ok: true });
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const request = transaction
|
||||
.objectStore(governanceStore)
|
||||
.get(INDEXEDDB_DATASET_BINDING_KEY);
|
||||
request.onerror = () => {
|
||||
requestError ??= request.error;
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
observed = request.result;
|
||||
};
|
||||
const budgetRequest = transaction
|
||||
.objectStore(governanceStore)
|
||||
.get(INDEXEDDB_DATASET_BUDGET_KEY);
|
||||
budgetRequest.onerror = () => {
|
||||
requestError ??= budgetRequest.error;
|
||||
};
|
||||
budgetRequest.onsuccess = () => {
|
||||
observedBudget = budgetRequest.result;
|
||||
};
|
||||
} catch (error) {
|
||||
requestError = error;
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
finish({ ok: false, reason: "NATIVE_ERROR", error });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user