Files
Toju/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts

375 lines
12 KiB
TypeScript

import { Injectable, inject } from '@angular/core';
import { take } from 'rxjs';
import { Store } from '@ngrx/store';
import { selectCurrentRoomName } from '../../../../store/rooms/rooms.selectors';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../../domain/constants/attachment-transfer.constants';
import {
ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES,
decodeBase64ToUint8Array,
yieldToAttachmentHydrationLoop
} from '../../domain/logic/attachment-blob.rules';
import { isBlobObjectUrl, needsBlobObjectUrlForInlineDisplay } from '../../domain/logic/attachment-display-url.rules';
import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
@Injectable({ providedIn: 'root' })
export class AttachmentPersistenceService {
private initPromise: Promise<void> | null = null;
private readonly runtimeStore = inject(AttachmentRuntimeStore);
private readonly ngrxStore = inject(Store);
private readonly attachmentStorage = inject(AttachmentStorageService);
private readonly database = inject(DatabaseService);
async deleteForMessage(messageId: string): Promise<void> {
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
const hadCachedAttachments = attachments.length > 0 || this.runtimeStore.hasAttachmentsForMessage(messageId);
const retainedSavedPaths = await this.getRetainedSavedPathsForOtherMessages(messageId);
const savedPathsToDelete = new Set<string>();
for (const attachment of attachments) {
if (attachment.objectUrl) {
try {
URL.revokeObjectURL(attachment.objectUrl);
} catch { /* ignore */ }
}
if (attachment.savedPath && !retainedSavedPaths.has(attachment.savedPath)) {
savedPathsToDelete.add(attachment.savedPath);
}
}
this.runtimeStore.deleteAttachmentsForMessage(messageId);
this.runtimeStore.deleteMessageRoom(messageId);
this.runtimeStore.clearMessageScopedState(messageId);
if (hadCachedAttachments) {
this.runtimeStore.touch();
}
if (this.database.isReady()) {
await this.database.deleteAttachmentsForMessage(messageId);
}
for (const diskPath of savedPathsToDelete) {
await this.attachmentStorage.deleteFile(diskPath);
}
}
whenReady(): Promise<void> {
if (this.database.isReady()) {
return this.initFromDatabase();
}
return this.initPromise ?? Promise.resolve();
}
async persistAttachmentMeta(attachment: Attachment): Promise<void> {
if (!this.database.isReady())
return;
try {
const storedRecords = await this.database.getAttachmentsForMessage(attachment.messageId);
const storedRecord = storedRecords.find((record) => record.id === attachment.id);
const localPaths = mergeAttachmentLocalPaths(attachment, storedRecord);
attachment.filePath = localPaths.filePath ?? undefined;
attachment.savedPath = localPaths.savedPath ?? undefined;
await this.database.saveAttachment({
id: attachment.id,
messageId: attachment.messageId,
filename: attachment.filename,
size: attachment.size,
mime: attachment.mime,
isImage: attachment.isImage,
uploaderPeerId: attachment.uploaderPeerId,
filePath: localPaths.filePath ?? undefined,
savedPath: localPaths.savedPath ?? undefined
});
} catch { /* persistence is best-effort */ }
}
async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<string | null> {
try {
const storageContainer = await this.resolveStorageContainerName(attachment);
const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, storageContainer);
if (!diskPath)
return null;
attachment.savedPath = diskPath;
void this.persistAttachmentMeta(attachment);
return diskPath;
} catch { /* disk save is best-effort */ }
return null;
}
async initFromDatabase(): Promise<void> {
if (!this.initPromise) {
this.initPromise = this.runInitFromDatabase();
}
return this.initPromise;
}
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
const restored = await this.ensureInlineDisplayObjectUrl(attachment);
if (restored) {
attachment.requestError = undefined;
}
return restored;
}
async ensureInlineDisplayObjectUrl(attachment: Attachment): Promise<boolean> {
if (!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl)) {
return true;
}
let diskPath = await this.attachmentStorage.resolveExistingPath(attachment);
if (!diskPath) {
const roomName = await this.resolveStorageContainerName(attachment);
diskPath = await this.attachmentStorage.resolveCanonicalStoredPath(attachment, roomName);
if (diskPath) {
attachment.savedPath = diskPath;
void this.persistAttachmentMeta(attachment);
}
}
if (!diskPath) {
return false;
}
if (this.attachmentStorage.providesInlineObjectUrl()) {
const nativeUrl = await this.attachmentStorage.getFileUrl(diskPath);
if (nativeUrl) {
this.revokeAttachmentObjectUrl(attachment);
attachment.objectUrl = nativeUrl;
attachment.available = true;
return true;
}
}
this.revokeAttachmentObjectUrl(attachment);
const restored = await this.restoreAttachmentBlobFromDiskPath(attachment, diskPath);
return restored;
}
async persistUploadCopyFromSourcePath(attachment: Attachment, sourcePath: string): Promise<string | null> {
try {
const storageContainer = await this.resolveStorageContainerName(attachment);
const diskPath = await this.attachmentStorage.createWritableFile(attachment, storageContainer);
if (!diskPath) {
return null;
}
const copied = await this.attachmentStorage.copyFile(sourcePath, diskPath);
if (!copied) {
await this.attachmentStorage.deleteFile(diskPath);
return null;
}
attachment.savedPath = diskPath;
await this.persistAttachmentMeta(attachment);
return diskPath;
} catch {
return null;
}
}
async resolveMessageRoomId(messageId: string): Promise<string | null> {
const cachedRoomId = this.runtimeStore.getMessageRoomId(messageId);
if (cachedRoomId)
return cachedRoomId;
if (!this.database.isReady())
return null;
try {
const message = await this.database.getMessageById(messageId);
if (!message?.roomId)
return null;
this.runtimeStore.rememberMessageRoom(messageId, message.roomId);
return message.roomId;
} catch {
return null;
}
}
async resolveCurrentRoomName(): Promise<string> {
return new Promise<string>((resolve) => {
this.ngrxStore
.select(selectCurrentRoomName)
.pipe(take(1))
.subscribe((name) => resolve(name || ''));
});
}
async resolveStorageContainerName(attachment: Pick<Attachment, 'messageId'>): Promise<string> {
return this.runtimeStore.getMessageRoomId(attachment.messageId) ?? await this.resolveCurrentRoomName();
}
private async loadFromDatabase(): Promise<void> {
try {
const allRecords: AttachmentMeta[] = await this.database.getAllAttachments();
const grouped = new Map<string, Attachment[]>();
for (const record of allRecords) {
const attachment: Attachment = { ...record,
available: false };
const bucket = grouped.get(record.messageId) ?? [];
bucket.push(attachment);
grouped.set(record.messageId, bucket);
}
this.runtimeStore.replaceAttachments(grouped);
this.runtimeStore.touch();
} catch { /* load is best-effort */ }
}
private async migrateFromLocalStorage(): Promise<void> {
try {
const raw = localStorage.getItem(LEGACY_ATTACHMENTS_STORAGE_KEY);
if (!raw)
return;
const legacyRecords: AttachmentMeta[] = JSON.parse(raw);
for (const meta of legacyRecords) {
const existing = [...this.runtimeStore.getAttachmentsForMessage(meta.messageId)];
if (!existing.find((entry) => entry.id === meta.id)) {
const attachment: Attachment = { ...meta,
available: false };
existing.push(attachment);
this.runtimeStore.setAttachmentsForMessage(meta.messageId, existing);
void this.persistAttachmentMeta(attachment);
}
}
localStorage.removeItem(LEGACY_ATTACHMENTS_STORAGE_KEY);
this.runtimeStore.touch();
} catch { /* migration is best-effort */ }
}
private async runInitFromDatabase(): Promise<void> {
await this.loadFromDatabase();
await this.migrateFromLocalStorage();
}
private async restoreAttachmentBlobFromDiskPath(attachment: Attachment, diskPath: string): Promise<boolean> {
if (this.attachmentStorage.canReadFileChunks()) {
const fileSize = await this.attachmentStorage.getFileSize(diskPath);
if (!fileSize || fileSize < 1) {
return false;
}
const blobParts: Uint8Array[] = [];
for (let start = 0; start < fileSize; start += ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES) {
const end = Math.min(start + ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES, fileSize);
const chunkBase64 = await this.attachmentStorage.readFileChunk(diskPath, start, end);
if (!chunkBase64) {
return false;
}
blobParts.push(decodeBase64ToUint8Array(chunkBase64));
if (end < fileSize) {
await yieldToAttachmentHydrationLoop();
}
}
this.applyAttachmentBlob(attachment, new Blob(blobParts as BlobPart[], { type: attachment.mime }));
return true;
}
const base64 = await this.attachmentStorage.readFile(diskPath);
if (!base64) {
return false;
}
const bytes = decodeBase64ToUint8Array(base64);
this.applyAttachmentBlob(
attachment,
new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime })
);
return true;
}
private applyAttachmentBlob(attachment: Attachment, blob: Blob): void {
attachment.objectUrl = URL.createObjectURL(blob);
attachment.available = true;
this.runtimeStore.setOriginalFile(
`${attachment.messageId}:${attachment.id}`,
new File([blob], attachment.filename, { type: attachment.mime })
);
}
private revokeAttachmentObjectUrl(attachment: Attachment): void {
if (!attachment.objectUrl || !isBlobObjectUrl(attachment.objectUrl)) {
return;
}
try {
URL.revokeObjectURL(attachment.objectUrl);
} catch { /* ignore */ }
}
private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise<Set<string>> {
const retainedSavedPaths = new Set<string>();
for (const [existingMessageId, attachments] of this.runtimeStore.getAttachmentEntries()) {
if (existingMessageId === messageId)
continue;
for (const attachment of attachments) {
if (attachment.savedPath) {
retainedSavedPaths.add(attachment.savedPath);
}
}
}
if (!this.database.isReady()) {
return retainedSavedPaths;
}
const persistedAttachments = await this.database.getAllAttachments();
for (const attachment of persistedAttachments) {
if (attachment.messageId !== messageId && attachment.savedPath) {
retainedSavedPaths.add(attachment.savedPath);
}
}
return retainedSavedPaths;
}
}