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:
Mohamed Boudra
2026-02-22 16:22:21 +07:00
parent 2652bdda2e
commit 0c710b09f5
55 changed files with 2381 additions and 350 deletions
@@ -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;
},
});
}
+125
View File
@@ -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 });
}
+38
View File
@@ -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;
}
+63
View File
@@ -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;
}
+78
View File
@@ -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();
}
},
};
}