237 lines
7.8 KiB
TypeScript
237 lines
7.8 KiB
TypeScript
import {
|
|
assertValidStoragePolicy,
|
|
isValidByteLength,
|
|
type BrowserDataFailureCode,
|
|
type BrowserDataOperation,
|
|
type BrowserStoragePolicy,
|
|
} from "../../../application/ports/browser-file-storage/shared.ts";
|
|
import type { OpfsStorageScope } from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
|
|
|
export type OpfsRuntimePolicy = Readonly<{
|
|
rootDirectoryName: string;
|
|
mutationLockName: string;
|
|
chunkSizeBytes: number;
|
|
maxObjectBytes: number;
|
|
maxChunkCount: number;
|
|
rpcTimeoutMs: number;
|
|
reconciliationBudgetMs: number;
|
|
reconciliationBatchSize: number;
|
|
orphanGracePeriodMs: number;
|
|
orphanGcBatchSize: number;
|
|
maxCancellationTombstones: number;
|
|
allowAsyncWritableChunkFallback: boolean;
|
|
isObjectIdAllowed: (objectId: string) => boolean;
|
|
isMediaTypeAllowed: (mediaType: string) => boolean;
|
|
}>;
|
|
|
|
export type OpfsSafeObservation = Readonly<{
|
|
operation: BrowserDataOperation;
|
|
outcome: "STARTED" | "SUCCEEDED" | "FAILED";
|
|
failureCode?: BrowserDataFailureCode;
|
|
byteBucket?: "0" | "1B_1MiB" | "1MiB_16MiB" | "16MiB_256MiB" | "GT_256MiB";
|
|
transactionBucket?: "0" | "1_10" | "11_100" | "GT_100";
|
|
}>;
|
|
|
|
export type OpfsSafeObserver = (observation: OpfsSafeObservation) => void;
|
|
|
|
const SAFE_SEGMENT = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
|
const OPAQUE_OBJECT_ID = /^[A-Za-z0-9_-]{8,128}$/u;
|
|
const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+(?:\s*;.*)?$/iu;
|
|
|
|
export const DEFAULT_OPFS_RUNTIME_POLICY: OpfsRuntimePolicy = Object.freeze({
|
|
rootDirectoryName: "ca-frontend-opfs-v1",
|
|
mutationLockName: "ca-frontend-opfs-v1:mutation",
|
|
chunkSizeBytes: 4 * 1024 * 1024,
|
|
maxObjectBytes: 2 * 1024 * 1024 * 1024,
|
|
maxChunkCount: 512,
|
|
rpcTimeoutMs: 60_000,
|
|
reconciliationBudgetMs: 5_000,
|
|
reconciliationBatchSize: 100,
|
|
orphanGracePeriodMs: 24 * 60 * 60 * 1_000,
|
|
orphanGcBatchSize: 100,
|
|
maxCancellationTombstones: 1_024,
|
|
allowAsyncWritableChunkFallback: true,
|
|
isObjectIdAllowed: (objectId) => OPAQUE_OBJECT_ID.test(objectId),
|
|
isMediaTypeAllowed: (mediaType) => MEDIA_TYPE.test(mediaType),
|
|
});
|
|
|
|
export function resolveOpfsRuntimePolicy(
|
|
policy: Partial<OpfsRuntimePolicy> = {},
|
|
): OpfsRuntimePolicy {
|
|
const resolved: OpfsRuntimePolicy = Object.freeze({
|
|
...DEFAULT_OPFS_RUNTIME_POLICY,
|
|
...policy,
|
|
});
|
|
assertOpfsRuntimePolicy(resolved);
|
|
return resolved;
|
|
}
|
|
|
|
export function assertOpfsRuntimePolicy(policy: OpfsRuntimePolicy): void {
|
|
if (
|
|
!SAFE_SEGMENT.test(policy.rootDirectoryName) ||
|
|
policy.mutationLockName.length === 0 ||
|
|
!Number.isSafeInteger(policy.chunkSizeBytes) ||
|
|
policy.chunkSizeBytes < 64 * 1024 ||
|
|
policy.chunkSizeBytes > 64 * 1024 * 1024 ||
|
|
!isValidByteLength(policy.maxObjectBytes) ||
|
|
policy.maxObjectBytes < policy.chunkSizeBytes ||
|
|
!Number.isSafeInteger(policy.maxChunkCount) ||
|
|
policy.maxChunkCount < 1 ||
|
|
policy.maxObjectBytes > policy.chunkSizeBytes * policy.maxChunkCount ||
|
|
!Number.isSafeInteger(policy.rpcTimeoutMs) ||
|
|
policy.rpcTimeoutMs < 1_000 ||
|
|
!Number.isSafeInteger(policy.reconciliationBudgetMs) ||
|
|
policy.reconciliationBudgetMs < 1 ||
|
|
policy.reconciliationBudgetMs > 60_000 ||
|
|
!Number.isSafeInteger(policy.reconciliationBatchSize) ||
|
|
policy.reconciliationBatchSize < 1 ||
|
|
policy.reconciliationBatchSize > 1_000 ||
|
|
!Number.isSafeInteger(policy.orphanGracePeriodMs) ||
|
|
policy.orphanGracePeriodMs < 60_000 ||
|
|
!Number.isSafeInteger(policy.orphanGcBatchSize) ||
|
|
policy.orphanGcBatchSize < 1 ||
|
|
policy.orphanGcBatchSize > 1_000 ||
|
|
!Number.isSafeInteger(policy.maxCancellationTombstones) ||
|
|
policy.maxCancellationTombstones < 16 ||
|
|
policy.maxCancellationTombstones > 10_000 ||
|
|
typeof policy.isObjectIdAllowed !== "function" ||
|
|
typeof policy.isMediaTypeAllowed !== "function"
|
|
) {
|
|
throw new TypeError("OPFS runtime policy is invalid.");
|
|
}
|
|
}
|
|
|
|
export function validateObjectWriteInput(
|
|
input: Readonly<{
|
|
scope: OpfsStorageScope;
|
|
objectId: string;
|
|
expectedGeneration: number | null;
|
|
mediaType: string;
|
|
byteLength: number | null;
|
|
storagePolicy: Parameters<typeof assertValidStoragePolicy>[0];
|
|
}>,
|
|
policy: OpfsRuntimePolicy,
|
|
): boolean {
|
|
try {
|
|
assertValidStoragePolicy(input.storagePolicy);
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
isValidOpfsStorageScope(input.scope) &&
|
|
input.scope.namespace === input.storagePolicy.namespace &&
|
|
policy.isObjectIdAllowed(input.objectId) &&
|
|
policy.isMediaTypeAllowed(input.mediaType) &&
|
|
(input.expectedGeneration === null ||
|
|
(Number.isSafeInteger(input.expectedGeneration) &&
|
|
input.expectedGeneration > 0)) &&
|
|
input.byteLength !== null &&
|
|
isValidByteLength(input.byteLength) &&
|
|
input.byteLength <= policy.maxObjectBytes &&
|
|
Math.ceil(input.byteLength / policy.chunkSizeBytes) <=
|
|
policy.maxChunkCount
|
|
);
|
|
}
|
|
|
|
export function isValidOpfsStorageScope(
|
|
scope: OpfsStorageScope,
|
|
): boolean {
|
|
return (
|
|
scope.namespace.length > 0 &&
|
|
scope.namespace.length <= 64 &&
|
|
OPAQUE_OBJECT_ID.test(scope.authorityToken) &&
|
|
OPAQUE_OBJECT_ID.test(scope.namespaceToken) &&
|
|
OPAQUE_OBJECT_ID.test(scope.partitionToken)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Captures the registry binding at composition time. Callers may own mutable
|
|
* config objects, so no OPFS operation is allowed to retain those references.
|
|
*/
|
|
export function snapshotOpfsStorageScope(
|
|
input: OpfsStorageScope,
|
|
): OpfsStorageScope {
|
|
try {
|
|
const snapshot: OpfsStorageScope = Object.freeze({
|
|
namespace: input.namespace,
|
|
authorityToken: input.authorityToken,
|
|
namespaceToken: input.namespaceToken,
|
|
partitionToken: input.partitionToken,
|
|
});
|
|
if (!isValidOpfsStorageScope(snapshot)) throw new TypeError();
|
|
return snapshot;
|
|
} catch {
|
|
throw new TypeError("OPFS storage scope is invalid.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deep enough for the closed BrowserStoragePolicy contract: retention is the
|
|
* only nested value. Fields are copied explicitly so later caller mutation or
|
|
* extension properties cannot alter the bound policy fingerprint.
|
|
*/
|
|
export function snapshotOpfsStoragePolicy(
|
|
input: BrowserStoragePolicy,
|
|
): BrowserStoragePolicy {
|
|
try {
|
|
const retention: BrowserStoragePolicy["retention"] =
|
|
input.retention.kind === "TTL"
|
|
? Object.freeze({
|
|
kind: "TTL",
|
|
maxAgeMs: input.retention.maxAgeMs,
|
|
})
|
|
: Object.freeze({ kind: input.retention.kind });
|
|
const snapshot: BrowserStoragePolicy = Object.freeze({
|
|
owner: input.owner,
|
|
namespace: input.namespace,
|
|
classification: input.classification,
|
|
authority: input.authority,
|
|
accountScope: input.accountScope,
|
|
retention,
|
|
softBudgetBytes: input.softBudgetBytes,
|
|
hardBudgetBytes: input.hardBudgetBytes,
|
|
evictionPriority: input.evictionPriority,
|
|
logoutAction: input.logoutAction,
|
|
accountDeletionAction: input.accountDeletionAction,
|
|
pressureAction: input.pressureAction,
|
|
unavailableFallback: input.unavailableFallback,
|
|
});
|
|
assertValidStoragePolicy(snapshot);
|
|
return snapshot;
|
|
} catch {
|
|
throw new TypeError("OPFS storage policy is invalid.");
|
|
}
|
|
}
|
|
|
|
export function byteBucket(
|
|
byteLength: number,
|
|
): NonNullable<OpfsSafeObservation["byteBucket"]> {
|
|
if (byteLength === 0) return "0";
|
|
if (byteLength <= 1024 * 1024) return "1B_1MiB";
|
|
if (byteLength <= 16 * 1024 * 1024) return "1MiB_16MiB";
|
|
if (byteLength <= 256 * 1024 * 1024) return "16MiB_256MiB";
|
|
return "GT_256MiB";
|
|
}
|
|
|
|
export function transactionBucket(
|
|
count: number,
|
|
): NonNullable<OpfsSafeObservation["transactionBucket"]> {
|
|
if (count === 0) return "0";
|
|
if (count <= 10) return "1_10";
|
|
if (count <= 100) return "11_100";
|
|
return "GT_100";
|
|
}
|
|
|
|
export function observeOpfsSafely(
|
|
observer: OpfsSafeObserver | undefined,
|
|
observation: OpfsSafeObservation,
|
|
): void {
|
|
try {
|
|
observer?.(Object.freeze({ ...observation }));
|
|
} catch {
|
|
// Persistence behavior never depends on observability.
|
|
}
|
|
}
|