refactor(app): introduce attachment storage layer and draft lifecycle management
Replace inline data-URL image handling with a persistent attachment storage service, add draft lifecycle states (active/abandoned/sent) with generation tracking, and update fetchAgent to return project placement alongside agent snapshots.
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
import * as FileSystem from "expo-file-system/legacy";
|
||||
import {
|
||||
type AttachmentStore,
|
||||
type AttachmentStorageType,
|
||||
type AttachmentMetadata,
|
||||
type SaveAttachmentInput,
|
||||
} from "@/attachments/types";
|
||||
import {
|
||||
blobToBase64,
|
||||
fileUriToPath,
|
||||
generateAttachmentId,
|
||||
getFileExtensionFromName,
|
||||
normalizeMimeType,
|
||||
parseDataUrl,
|
||||
pathToFileUri,
|
||||
} from "@/attachments/utils";
|
||||
|
||||
const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/avif": ".avif",
|
||||
"image/heic": ".heic",
|
||||
"image/heif": ".heif",
|
||||
"image/tiff": ".tiff",
|
||||
"image/bmp": ".bmp",
|
||||
"image/svg+xml": ".svg",
|
||||
};
|
||||
|
||||
function extensionForAttachment(params: { fileName?: string | null; mimeType: string }): string {
|
||||
const fromName = getFileExtensionFromName(params.fileName);
|
||||
if (fromName) {
|
||||
return fromName;
|
||||
}
|
||||
return IMAGE_EXTENSION_BY_MIME_TYPE[params.mimeType] ?? ".img";
|
||||
}
|
||||
|
||||
async function ensureDirectory(uri: string): Promise<void> {
|
||||
const info = await FileSystem.getInfoAsync(uri);
|
||||
if (info.exists && info.isDirectory) {
|
||||
return;
|
||||
}
|
||||
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
|
||||
}
|
||||
|
||||
async function writeFromSource(input: {
|
||||
source: SaveAttachmentInput["source"];
|
||||
targetUri: string;
|
||||
mimeType: string;
|
||||
}): Promise<void> {
|
||||
if (input.source.kind === "file_uri") {
|
||||
const from = pathToFileUri(input.source.uri);
|
||||
if (from === input.targetUri) {
|
||||
return;
|
||||
}
|
||||
await FileSystem.copyAsync({ from, to: input.targetUri });
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.source.kind === "data_url") {
|
||||
const parsed = parseDataUrl(input.source.dataUrl);
|
||||
const mimeType = normalizeMimeType(parsed.mimeType || input.mimeType);
|
||||
const base64 = parsed.base64;
|
||||
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
if (mimeType !== input.mimeType) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const base64 = await blobToBase64(input.source.blob);
|
||||
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
}
|
||||
|
||||
function attachmentUri(metadata: AttachmentMetadata): string {
|
||||
return pathToFileUri(metadata.storageKey);
|
||||
}
|
||||
|
||||
export function createLocalFileAttachmentStore(params: {
|
||||
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
|
||||
baseDirectoryName: string;
|
||||
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
|
||||
releasePreviewUrl?: (input: {
|
||||
attachment: AttachmentMetadata;
|
||||
url: string;
|
||||
}) => Promise<void>;
|
||||
}): AttachmentStore {
|
||||
const baseDirectory = FileSystem.cacheDirectory
|
||||
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
|
||||
: null;
|
||||
|
||||
async function resolveTarget(input: SaveAttachmentInput): Promise<{
|
||||
id: string;
|
||||
mimeType: string;
|
||||
fileName: string | null;
|
||||
createdAt: number;
|
||||
targetUri: string;
|
||||
storageKey: string;
|
||||
}> {
|
||||
if (!baseDirectory) {
|
||||
throw new Error("expo-file-system cacheDirectory is unavailable.");
|
||||
}
|
||||
|
||||
await ensureDirectory(baseDirectory);
|
||||
|
||||
const id = input.id ?? generateAttachmentId();
|
||||
const mimeTypeFromSource =
|
||||
input.source.kind === "data_url"
|
||||
? parseDataUrl(input.source.dataUrl).mimeType
|
||||
: input.source.kind === "blob"
|
||||
? input.source.blob.type
|
||||
: undefined;
|
||||
const mimeType = normalizeMimeType(input.mimeType ?? mimeTypeFromSource);
|
||||
const fileName = input.fileName ?? null;
|
||||
const extension = extensionForAttachment({ fileName, mimeType });
|
||||
const createdAt = Date.now();
|
||||
const targetUri = `${baseDirectory}${id}${extension}`;
|
||||
const storageKey = fileUriToPath(targetUri);
|
||||
|
||||
return {
|
||||
id,
|
||||
mimeType,
|
||||
fileName,
|
||||
createdAt,
|
||||
targetUri,
|
||||
storageKey,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
storageType: params.storageType,
|
||||
|
||||
async save(input): Promise<AttachmentMetadata> {
|
||||
const target = await resolveTarget(input);
|
||||
await writeFromSource({
|
||||
source: input.source,
|
||||
targetUri: target.targetUri,
|
||||
mimeType: target.mimeType,
|
||||
});
|
||||
|
||||
const info = await FileSystem.getInfoAsync(target.targetUri);
|
||||
const byteSize =
|
||||
info.exists && typeof (info as { size?: number }).size === "number"
|
||||
? (info as { size: number }).size
|
||||
: null;
|
||||
return {
|
||||
id: target.id,
|
||||
mimeType: target.mimeType,
|
||||
storageType: params.storageType,
|
||||
storageKey: target.storageKey,
|
||||
fileName: target.fileName,
|
||||
byteSize,
|
||||
createdAt: target.createdAt,
|
||||
};
|
||||
},
|
||||
|
||||
async encodeBase64({ attachment }): Promise<string> {
|
||||
const uri = attachmentUri(attachment);
|
||||
return await FileSystem.readAsStringAsync(uri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
},
|
||||
|
||||
async resolvePreviewUrl({ attachment }): Promise<string> {
|
||||
return await params.resolvePreviewUrl(attachment);
|
||||
},
|
||||
|
||||
...(params.releasePreviewUrl
|
||||
? {
|
||||
async releasePreviewUrl(input: {
|
||||
attachment: AttachmentMetadata;
|
||||
url: string;
|
||||
}): Promise<void> {
|
||||
await params.releasePreviewUrl?.(input);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
async delete({ attachment }): Promise<void> {
|
||||
await FileSystem.deleteAsync(attachmentUri(attachment), { idempotent: true });
|
||||
},
|
||||
|
||||
async garbageCollect({ referencedIds }): Promise<void> {
|
||||
if (!baseDirectory) {
|
||||
return;
|
||||
}
|
||||
await ensureDirectory(baseDirectory);
|
||||
const entries = await FileSystem.readDirectoryAsync(baseDirectory);
|
||||
await Promise.all(
|
||||
entries.map(async (entryName) => {
|
||||
const id = entryName.split(".", 1)[0] ?? "";
|
||||
if (!id || referencedIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
|
||||
idempotent: true,
|
||||
});
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
|
||||
|
||||
export function createNativeFileAttachmentStore() {
|
||||
return createLocalFileAttachmentStore({
|
||||
storageType: "native-file",
|
||||
baseDirectoryName: "paseo-native-attachments",
|
||||
resolvePreviewUrl: async (attachment) => {
|
||||
if (attachment.storageKey.startsWith("file://")) {
|
||||
return attachment.storageKey;
|
||||
}
|
||||
if (attachment.storageKey.startsWith("/")) {
|
||||
return `file://${attachment.storageKey}`;
|
||||
}
|
||||
return attachment.storageKey;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { getAttachmentStore } from "@/attachments/store";
|
||||
|
||||
export async function persistAttachmentFromBlob(input: {
|
||||
blob: Blob;
|
||||
mimeType?: string;
|
||||
fileName?: string | null;
|
||||
id?: string;
|
||||
}): Promise<AttachmentMetadata> {
|
||||
const store = await getAttachmentStore();
|
||||
return await store.save({
|
||||
id: input.id,
|
||||
mimeType: input.mimeType,
|
||||
fileName: input.fileName,
|
||||
source: { kind: "blob", blob: input.blob },
|
||||
});
|
||||
}
|
||||
|
||||
export async function persistAttachmentFromDataUrl(input: {
|
||||
dataUrl: string;
|
||||
mimeType?: string;
|
||||
fileName?: string | null;
|
||||
id?: string;
|
||||
}): Promise<AttachmentMetadata> {
|
||||
const store = await getAttachmentStore();
|
||||
return await store.save({
|
||||
id: input.id,
|
||||
mimeType: input.mimeType,
|
||||
fileName: input.fileName,
|
||||
source: { kind: "data_url", dataUrl: input.dataUrl },
|
||||
});
|
||||
}
|
||||
|
||||
export async function persistAttachmentFromFileUri(input: {
|
||||
uri: string;
|
||||
mimeType?: string;
|
||||
fileName?: string | null;
|
||||
id?: string;
|
||||
}): Promise<AttachmentMetadata> {
|
||||
const store = await getAttachmentStore();
|
||||
return await store.save({
|
||||
id: input.id,
|
||||
mimeType: input.mimeType,
|
||||
fileName: input.fileName,
|
||||
source: { kind: "file_uri", uri: input.uri },
|
||||
});
|
||||
}
|
||||
|
||||
export async function encodeAttachmentsForSend(
|
||||
attachments: readonly AttachmentMetadata[] | undefined
|
||||
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const store = await getAttachmentStore();
|
||||
const encoded = await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
try {
|
||||
const data = await store.encodeBase64({ attachment });
|
||||
return {
|
||||
data,
|
||||
mimeType: attachment.mimeType,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[attachments] Failed to encode attachment for send", {
|
||||
id: attachment.id,
|
||||
error,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const valid = encoded.filter(
|
||||
(entry): entry is { data: string; mimeType: string } => entry !== null
|
||||
);
|
||||
return valid.length > 0 ? valid : undefined;
|
||||
}
|
||||
|
||||
export async function resolveAttachmentPreviewUrl(
|
||||
attachment: AttachmentMetadata
|
||||
): Promise<string> {
|
||||
const store = await getAttachmentStore();
|
||||
return await store.resolvePreviewUrl({ attachment });
|
||||
}
|
||||
|
||||
export async function releaseAttachmentPreviewUrl(input: {
|
||||
attachment: AttachmentMetadata;
|
||||
url: string;
|
||||
}): Promise<void> {
|
||||
const store = await getAttachmentStore();
|
||||
if (!store.releasePreviewUrl) {
|
||||
return;
|
||||
}
|
||||
await store.releasePreviewUrl({ attachment: input.attachment, url: input.url });
|
||||
}
|
||||
|
||||
export async function deleteAttachments(
|
||||
attachments: readonly AttachmentMetadata[] | undefined
|
||||
): Promise<void> {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
const store = await getAttachmentStore();
|
||||
await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
try {
|
||||
await store.delete({ attachment });
|
||||
} catch (error) {
|
||||
console.warn("[attachments] Failed to delete attachment", {
|
||||
id: attachment.id,
|
||||
error,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function garbageCollectAttachments(input: {
|
||||
referencedIds: ReadonlySet<string>;
|
||||
}): Promise<void> {
|
||||
const store = await getAttachmentStore();
|
||||
await store.garbageCollect({ referencedIds: input.referencedIds });
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Platform } from "react-native";
|
||||
import { isTauriEnvironment } from "@/utils/tauri";
|
||||
import type { AttachmentStore } from "@/attachments/types";
|
||||
|
||||
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
||||
|
||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||
if (Platform.OS === "web") {
|
||||
if (isTauriEnvironment()) {
|
||||
const { createDesktopAttachmentStore } = await import(
|
||||
"@/desktop/attachments/desktop-attachment-store"
|
||||
);
|
||||
return createDesktopAttachmentStore();
|
||||
}
|
||||
|
||||
const { createIndexedDbAttachmentStore } = await import(
|
||||
"@/attachments/web/indexeddb-attachment-store"
|
||||
);
|
||||
return createIndexedDbAttachmentStore();
|
||||
}
|
||||
|
||||
const { createNativeFileAttachmentStore } = await import(
|
||||
"@/attachments/native/native-file-attachment-store"
|
||||
);
|
||||
return createNativeFileAttachmentStore();
|
||||
}
|
||||
|
||||
export async function getAttachmentStore(): Promise<AttachmentStore> {
|
||||
if (!attachmentStorePromise) {
|
||||
attachmentStorePromise = createAttachmentStore();
|
||||
}
|
||||
return await attachmentStorePromise;
|
||||
}
|
||||
|
||||
/** Test-only hook to inject a deterministic store implementation. */
|
||||
export function __setAttachmentStoreForTests(store: AttachmentStore | null): void {
|
||||
attachmentStorePromise = store ? Promise.resolve(store) : null;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
|
||||
|
||||
export interface AttachmentMetadata {
|
||||
id: string;
|
||||
mimeType: string;
|
||||
storageType: AttachmentStorageType;
|
||||
/**
|
||||
* Platform-specific location key.
|
||||
* - web-indexeddb: object store key
|
||||
* - desktop-file/native-file: absolute file path without preview URL indirection
|
||||
*/
|
||||
storageKey: string;
|
||||
fileName?: string | null;
|
||||
byteSize?: number | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export type AttachmentDataSource =
|
||||
| { kind: "blob"; blob: Blob }
|
||||
| { kind: "data_url"; dataUrl: string }
|
||||
| { kind: "file_uri"; uri: string };
|
||||
|
||||
export interface SaveAttachmentInput {
|
||||
id?: string;
|
||||
mimeType?: string;
|
||||
fileName?: string | null;
|
||||
source: AttachmentDataSource;
|
||||
}
|
||||
|
||||
export interface ResolvePreviewUrlInput {
|
||||
attachment: AttachmentMetadata;
|
||||
}
|
||||
|
||||
export interface ReleasePreviewUrlInput {
|
||||
attachment: AttachmentMetadata;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface EncodeAttachmentInput {
|
||||
attachment: AttachmentMetadata;
|
||||
}
|
||||
|
||||
export interface DeleteAttachmentInput {
|
||||
attachment: AttachmentMetadata;
|
||||
}
|
||||
|
||||
export interface GarbageCollectInput {
|
||||
referencedIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async storage contract for attachment bytes.
|
||||
* Metadata is persisted in drafts/messages; bytes live in platform stores.
|
||||
*/
|
||||
export interface AttachmentStore {
|
||||
readonly storageType: AttachmentStorageType;
|
||||
save(input: SaveAttachmentInput): Promise<AttachmentMetadata>;
|
||||
encodeBase64(input: EncodeAttachmentInput): Promise<string>;
|
||||
resolvePreviewUrl(input: ResolvePreviewUrlInput): Promise<string>;
|
||||
releasePreviewUrl?(input: ReleasePreviewUrlInput): Promise<void>;
|
||||
delete(input: DeleteAttachmentInput): Promise<void>;
|
||||
garbageCollect(input: GarbageCollectInput): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import {
|
||||
releaseAttachmentPreviewUrl,
|
||||
resolveAttachmentPreviewUrl,
|
||||
} from "@/attachments/service";
|
||||
|
||||
export function useAttachmentPreviewUrl(
|
||||
attachment: AttachmentMetadata | null | undefined
|
||||
): string | null {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let currentUrl: string | null = null;
|
||||
|
||||
activeAttachmentRef.current = attachment ?? null;
|
||||
if (!attachment) {
|
||||
setUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await resolveAttachmentPreviewUrl(attachment);
|
||||
if (disposed) {
|
||||
await releaseAttachmentPreviewUrl({ attachment, url: resolved });
|
||||
return;
|
||||
}
|
||||
currentUrl = resolved;
|
||||
setUrl(resolved);
|
||||
} catch (error) {
|
||||
console.error("[attachments] Failed to resolve preview URL", {
|
||||
attachmentId: attachment.id,
|
||||
error,
|
||||
});
|
||||
if (!disposed) {
|
||||
setUrl(null);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
const activeAttachment = activeAttachmentRef.current;
|
||||
if (!currentUrl || !activeAttachment) {
|
||||
return;
|
||||
}
|
||||
void releaseAttachmentPreviewUrl({
|
||||
attachment: activeAttachment,
|
||||
url: currentUrl,
|
||||
});
|
||||
};
|
||||
}, [
|
||||
attachment?.id,
|
||||
attachment?.storageType,
|
||||
attachment?.storageKey,
|
||||
attachment?.mimeType,
|
||||
]);
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
|
||||
export function generateAttachmentId(): string {
|
||||
return `att_${generateMessageId()}`;
|
||||
}
|
||||
|
||||
export function normalizeMimeType(input: string | undefined | null): string {
|
||||
if (!input) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
const trimmed = input.trim();
|
||||
return trimmed.length > 0 ? trimmed : "image/jpeg";
|
||||
}
|
||||
|
||||
export function parseDataUrl(dataUrl: string): { mimeType: string; base64: string } {
|
||||
const match = /^data:([^;,]+)?;base64,(.+)$/i.exec(dataUrl);
|
||||
if (!match) {
|
||||
throw new Error("Malformed data URL for attachment.");
|
||||
}
|
||||
const [, mimeTypeRaw, base64] = match;
|
||||
if (!base64) {
|
||||
throw new Error("Attachment data URL is missing base64 payload.");
|
||||
}
|
||||
return {
|
||||
mimeType: normalizeMimeType(mimeTypeRaw),
|
||||
base64,
|
||||
};
|
||||
}
|
||||
|
||||
export async function blobToBase64(blob: Blob): Promise<string> {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== "string") {
|
||||
reject(new Error("Unexpected FileReader result while encoding attachment."));
|
||||
return;
|
||||
}
|
||||
const payload = reader.result.split(",", 2)[1];
|
||||
if (!payload) {
|
||||
reject(new Error("Attachment FileReader result did not contain base64 payload."));
|
||||
return;
|
||||
}
|
||||
resolve(payload);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
reject(reader.error ?? new Error("Failed to read attachment blob."));
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export function pathToFileUri(path: string): string {
|
||||
if (path.startsWith("file://")) {
|
||||
return path;
|
||||
}
|
||||
if (path.startsWith("/")) {
|
||||
return `file://${path}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function fileUriToPath(uri: string): string {
|
||||
if (!uri.startsWith("file://")) {
|
||||
return uri;
|
||||
}
|
||||
return decodeURIComponent(uri.replace(/^file:\/\//, ""));
|
||||
}
|
||||
|
||||
export function getFileExtensionFromName(fileName: string | null | undefined): string {
|
||||
if (!fileName) {
|
||||
return "";
|
||||
}
|
||||
const idx = fileName.lastIndexOf(".");
|
||||
if (idx <= 0 || idx === fileName.length - 1) {
|
||||
return "";
|
||||
}
|
||||
return fileName.slice(idx);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
type AttachmentStore,
|
||||
type AttachmentMetadata,
|
||||
type SaveAttachmentInput,
|
||||
} from "@/attachments/types";
|
||||
import {
|
||||
blobToBase64,
|
||||
generateAttachmentId,
|
||||
normalizeMimeType,
|
||||
parseDataUrl,
|
||||
} from "@/attachments/utils";
|
||||
|
||||
type StoredBlobRecord = {
|
||||
id: string;
|
||||
blob: Blob;
|
||||
createdAt: number;
|
||||
fileName: string | null;
|
||||
};
|
||||
|
||||
const DB_NAME = "paseo-attachment-bytes";
|
||||
const STORE_NAME = "attachments";
|
||||
const DB_VERSION = 1;
|
||||
|
||||
function ensureIndexedDb(): IDBFactory {
|
||||
const idb = globalThis.indexedDB;
|
||||
if (!idb) {
|
||||
throw new Error("IndexedDB is unavailable in this runtime.");
|
||||
}
|
||||
return idb;
|
||||
}
|
||||
|
||||
function openAttachmentDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = ensureIndexedDb().open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error ?? new Error("Failed to open attachment IndexedDB."));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function runTx<T>(
|
||||
db: IDBDatabase,
|
||||
mode: IDBTransactionMode,
|
||||
run: (store: IDBObjectStore) => IDBRequest<T>
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, mode);
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = run(store);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result as T);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error ?? new Error("IndexedDB transaction request failed."));
|
||||
};
|
||||
|
||||
transaction.onerror = () => {
|
||||
reject(transaction.error ?? new Error("IndexedDB transaction failed."));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> {
|
||||
const source = input.source;
|
||||
if (source.kind === "blob") {
|
||||
const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type);
|
||||
const blob = source.blob.type === mimeType ? source.blob : source.blob.slice(0, source.blob.size, mimeType);
|
||||
return { blob, mimeType };
|
||||
}
|
||||
|
||||
if (source.kind === "data_url") {
|
||||
const parsed = parseDataUrl(source.dataUrl);
|
||||
const response = await fetch(source.dataUrl);
|
||||
const blob = await response.blob();
|
||||
const mimeType = normalizeMimeType(input.mimeType ?? parsed.mimeType ?? blob.type);
|
||||
return {
|
||||
blob: blob.type === mimeType ? blob : blob.slice(0, blob.size, mimeType),
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(source.uri);
|
||||
const blob = await response.blob();
|
||||
const mimeType = normalizeMimeType(input.mimeType ?? blob.type);
|
||||
return {
|
||||
blob: blob.type === mimeType ? blob : blob.slice(0, blob.size, mimeType),
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadBlob(db: IDBDatabase, id: string): Promise<Blob> {
|
||||
const record = await runTx<StoredBlobRecord | undefined>(db, "readonly", (store) =>
|
||||
store.get(id)
|
||||
);
|
||||
if (!record?.blob) {
|
||||
throw new Error(`Attachment ${id} was not found in IndexedDB.`);
|
||||
}
|
||||
return record.blob;
|
||||
}
|
||||
|
||||
export function createIndexedDbAttachmentStore(): AttachmentStore {
|
||||
return {
|
||||
storageType: "web-indexeddb",
|
||||
|
||||
async save(input): Promise<AttachmentMetadata> {
|
||||
const id = input.id ?? generateAttachmentId();
|
||||
const createdAt = Date.now();
|
||||
const { blob, mimeType } = await sourceToBlob(input);
|
||||
const fileName = input.fileName ?? null;
|
||||
const db = await openAttachmentDb();
|
||||
|
||||
try {
|
||||
await runTx(db, "readwrite", (store) =>
|
||||
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord)
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
mimeType,
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: id,
|
||||
fileName,
|
||||
byteSize: blob.size,
|
||||
createdAt,
|
||||
};
|
||||
},
|
||||
|
||||
async encodeBase64({ attachment }): Promise<string> {
|
||||
const db = await openAttachmentDb();
|
||||
try {
|
||||
const blob = await loadBlob(db, attachment.storageKey);
|
||||
return await blobToBase64(blob);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
|
||||
async resolvePreviewUrl({ attachment }): Promise<string> {
|
||||
const db = await openAttachmentDb();
|
||||
try {
|
||||
const blob = await loadBlob(db, attachment.storageKey);
|
||||
return URL.createObjectURL(blob);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
|
||||
async releasePreviewUrl({ url }): Promise<void> {
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
async delete({ attachment }): Promise<void> {
|
||||
const db = await openAttachmentDb();
|
||||
try {
|
||||
await runTx(db, "readwrite", (store) => store.delete(attachment.storageKey));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
|
||||
async garbageCollect({ referencedIds }): Promise<void> {
|
||||
const db = await openAttachmentDb();
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const cursorRequest = store.openCursor();
|
||||
|
||||
cursorRequest.onerror = () => {
|
||||
reject(cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."));
|
||||
};
|
||||
|
||||
cursorRequest.onsuccess = () => {
|
||||
const cursor = cursorRequest.result;
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const key = String(cursor.key);
|
||||
if (!referencedIds.has(key)) {
|
||||
cursor.delete();
|
||||
}
|
||||
cursor.continue();
|
||||
};
|
||||
|
||||
tx.onerror = () => {
|
||||
reject(tx.error ?? new Error("Failed to garbage collect IndexedDB attachments."));
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user