mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Add server-side folders and files page UI (#6383)
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { FileId, BaseFileMetadata } from "@app/types/file";
|
||||
import { FolderId } from "@app/types/folder";
|
||||
import {
|
||||
StirlingFile,
|
||||
StirlingFileStub,
|
||||
@@ -26,7 +27,7 @@ export interface StoredStirlingFileRecord extends BaseFileMetadata {
|
||||
fileId: FileId; // Matches runtime StirlingFile.fileId exactly
|
||||
quickKey: string; // Matches runtime StirlingFile.quickKey exactly
|
||||
thumbnail?: string;
|
||||
thumbnailStoredAt?: number; // Epoch ms — sliding 30-day TTL
|
||||
thumbnailStoredAt?: number; // Epoch ms - sliding 30-day TTL
|
||||
url?: string; // For compatibility with existing components
|
||||
}
|
||||
|
||||
@@ -66,7 +67,7 @@ class FileStorageService {
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
|
||||
// Issue all gets up front — each onsuccess creates a put before the
|
||||
// Issue all gets up front - each onsuccess creates a put before the
|
||||
// transaction can auto-commit, keeping it alive until all puts settle.
|
||||
ids.forEach((id) => {
|
||||
const req = store.get(id);
|
||||
@@ -123,6 +124,9 @@ class FileStorageService {
|
||||
originalFileId: stub.originalFileId ?? stirlingFile.fileId,
|
||||
parentFileId: stub.parentFileId ?? undefined,
|
||||
toolHistory: stub.toolHistory ?? [],
|
||||
|
||||
// Folder organisation (root when null)
|
||||
folderId: stub.folderId ?? null,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -215,9 +219,12 @@ class FileStorageService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create StirlingFileStub from metadata (no file data)
|
||||
// No per-id thumbnail TTL bump here - the bulk getAll/leaf paths
|
||||
// already keep TTL fresh, and bumping on every single-id read
|
||||
// generated a writable transaction per call (write amplification).
|
||||
// We still gate thumbnailUrl on freshness so stale thumbnails
|
||||
// don't leak through this read path.
|
||||
const fresh = this.isThumbnailFresh(record);
|
||||
void this.bumpThumbnailTTL([record.id], !fresh);
|
||||
|
||||
const stub: StirlingFileStub = {
|
||||
id: record.id,
|
||||
@@ -240,6 +247,7 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory,
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
};
|
||||
|
||||
@@ -295,13 +303,24 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId || record.id,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory || [],
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
});
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
void this.bumpThumbnailTTL(tobump);
|
||||
void this.bumpThumbnailTTL(toexpire, true);
|
||||
// Only open the writeback transaction when there's something to do -
|
||||
// previously fired two empty transactions per refresh.
|
||||
if (tobump.length > 0) {
|
||||
void this.bumpThumbnailTTL(tobump).catch((e) =>
|
||||
console.warn("[fileStorage] thumbnail TTL bump failed", e),
|
||||
);
|
||||
}
|
||||
if (toexpire.length > 0) {
|
||||
void this.bumpThumbnailTTL(toexpire, true).catch((e) =>
|
||||
console.warn("[fileStorage] thumbnail expire failed", e),
|
||||
);
|
||||
}
|
||||
resolve(stubs);
|
||||
}
|
||||
};
|
||||
@@ -372,19 +391,112 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId || record.id,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory || [],
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
});
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
void this.bumpThumbnailTTL(tobump);
|
||||
void this.bumpThumbnailTTL(toexpire, true);
|
||||
if (tobump.length > 0) {
|
||||
void this.bumpThumbnailTTL(tobump).catch((e) =>
|
||||
console.warn("[fileStorage] thumbnail TTL bump failed", e),
|
||||
);
|
||||
}
|
||||
if (toexpire.length > 0) {
|
||||
void this.bumpThumbnailTTL(toexpire, true).catch((e) =>
|
||||
console.warn("[fileStorage] thumbnail expire failed", e),
|
||||
);
|
||||
}
|
||||
resolve(leafStubs);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one or more files into a folder (or to the root when folderId is null).
|
||||
* Returns the ids of records that were actually updated.
|
||||
*/
|
||||
async moveFilesToFolder(
|
||||
fileIds: FileId[],
|
||||
folderId: FolderId | null,
|
||||
): Promise<FileId[]> {
|
||||
if (fileIds.length === 0) return [];
|
||||
const db = await this.getDatabase();
|
||||
const updated: FileId[] = [];
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () =>
|
||||
reject(transaction.error ?? new Error("Move transaction aborted"));
|
||||
|
||||
fileIds.forEach((id) => {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => {
|
||||
const record = request.result as StoredStirlingFileRecord | undefined;
|
||||
if (!record) return;
|
||||
record.folderId = folderId;
|
||||
store.put(record);
|
||||
updated.push(id);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the folderId for every file currently inside any of the given
|
||||
* folders. Used when a folder (or subtree) is deleted so the contents
|
||||
* fall back to the root rather than dangling against a missing folder.
|
||||
*/
|
||||
async clearFolderForFiles(folderIds: FolderId[]): Promise<number> {
|
||||
if (folderIds.length === 0) return 0;
|
||||
const db = await this.getDatabase();
|
||||
let cleared = 0;
|
||||
|
||||
// Use the `folderId` index (declared on the files store in
|
||||
// indexedDBManager DATABASE_CONFIGS) with one keyRange-bounded cursor
|
||||
// per folderId. The previous full-store openCursor() was O(total files);
|
||||
// this is O(files-in-affected-folders + folderIds.length). On users
|
||||
// with thousands of files and a single deleted folder this is a 100x+
|
||||
// win and keeps the UI responsive while the transaction runs.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const index = store.index("folderId");
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () =>
|
||||
reject(
|
||||
transaction.error ?? new Error("Clear folder transaction aborted"),
|
||||
);
|
||||
|
||||
for (const folderId of folderIds) {
|
||||
const cursorRequest = index.openCursor(
|
||||
IDBKeyRange.only(folderId as string),
|
||||
);
|
||||
cursorRequest.onerror = () => reject(cursorRequest.error);
|
||||
cursorRequest.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest)
|
||||
.result as IDBCursorWithValue | null;
|
||||
if (!cursor) return;
|
||||
const record = cursor.value as StoredStirlingFileRecord;
|
||||
record.folderId = null;
|
||||
cursor.update(record);
|
||||
cleared += 1;
|
||||
cursor.continue();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return cleared;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete StirlingFile - single operation, no sync issues
|
||||
*/
|
||||
@@ -628,6 +740,11 @@ class FileStorageService {
|
||||
|
||||
/**
|
||||
* Update metadata fields for a stored file record.
|
||||
*
|
||||
* Resolves on transaction.oncomplete, NOT on the individual put's onsuccess,
|
||||
* so callers only receive `true` once the write actually commits. If the
|
||||
* transaction aborts after put() succeeded but before commit, we return false
|
||||
* - the previous behavior incorrectly claimed success in that window.
|
||||
*/
|
||||
async updateFileMetadata(
|
||||
fileId: FileId,
|
||||
@@ -635,29 +752,31 @@ class FileStorageService {
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const record = await new Promise<StoredStirlingFileRecord | undefined>(
|
||||
(resolve, reject) => {
|
||||
const request = store.get(fileId);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () =>
|
||||
resolve(request.result as StoredStirlingFileRecord | undefined);
|
||||
},
|
||||
);
|
||||
return await new Promise<boolean>((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
let recordFound = false;
|
||||
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
const getRequest = store.get(fileId);
|
||||
getRequest.onsuccess = () => {
|
||||
const record = getRequest.result as
|
||||
| StoredStirlingFileRecord
|
||||
| undefined;
|
||||
if (!record) {
|
||||
// Don't commit anything; caller wants false.
|
||||
return;
|
||||
}
|
||||
recordFound = true;
|
||||
const updatedRecord = { ...record, ...updates };
|
||||
store.put(updatedRecord);
|
||||
};
|
||||
getRequest.onerror = () => reject(getRequest.error);
|
||||
|
||||
const updatedRecord = { ...record, ...updates };
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.put(updatedRecord);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
transaction.oncomplete = () => resolve(recordFound);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () =>
|
||||
reject(transaction.error ?? new Error("updateFileMetadata aborted"));
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to update file metadata:", error);
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Reconciles the local IDB file stub list with the server's view of which
|
||||
* files the user can see. Server is source of truth for cloud files; local
|
||||
* IDB caches them so the grid renders instantly while the server call is in
|
||||
* flight and so local-only files survive offline.
|
||||
*/
|
||||
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { StirlingFileStub, StirlingFile } from "@app/types/fileContext";
|
||||
import { FileId } from "@app/types/fileContext";
|
||||
import { FolderId, parseFolderId } from "@app/types/folder";
|
||||
import {
|
||||
isZipBundle,
|
||||
loadShareBundleEntries,
|
||||
parseContentDispositionFilename,
|
||||
} from "@app/services/shareBundleUtils";
|
||||
|
||||
/**
|
||||
* Trust-boundary parser for folderId values coming back from the server.
|
||||
* Matches folderSyncService's discipline - we never want a garbage server
|
||||
* payload to corrupt the local IDB `folderId` index. Returns null on any
|
||||
* invalid input so the file falls back to the root folder.
|
||||
*/
|
||||
function safeParseFolderId(value: unknown): FolderId | null {
|
||||
if (value == null || value === "") return null;
|
||||
try {
|
||||
return parseFolderId(value);
|
||||
} catch {
|
||||
console.warn("[fileSyncService] dropping invalid server folderId", value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface StoredFileResponse {
|
||||
id: number;
|
||||
fileName: string;
|
||||
contentType?: string | null;
|
||||
sizeBytes: number;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
owner?: string | null;
|
||||
ownedByCurrentUser?: boolean;
|
||||
accessRole?: string | null;
|
||||
shareLinks?: Array<{ token?: string | null }>;
|
||||
sharedUsers?: Array<{ username?: string | null }>;
|
||||
sharedWithUsers?: string[];
|
||||
filePurpose?: string | null;
|
||||
folderId?: string | null;
|
||||
}
|
||||
|
||||
interface AccessedShareLinkResponse {
|
||||
shareToken?: string | null;
|
||||
fileId?: number | null;
|
||||
fileName?: string | null;
|
||||
owner?: string | null;
|
||||
ownedByCurrentUser?: boolean;
|
||||
createdAt?: string | null;
|
||||
lastAccessedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ReconcileOptions {
|
||||
storageEnabled: boolean;
|
||||
shareLinksEnabled: boolean;
|
||||
}
|
||||
|
||||
function normalizeServerFileName(fileName: string | undefined | null): string {
|
||||
const fallback = fileName?.trim() || "server-file";
|
||||
const lower = fallback.toLowerCase();
|
||||
const historySuffix = "-history.zip";
|
||||
if (lower.endsWith(historySuffix)) {
|
||||
return (
|
||||
fallback.slice(0, fallback.length - historySuffix.length) || fallback
|
||||
);
|
||||
}
|
||||
if (lower.endsWith(".zip")) {
|
||||
const innerExts = [
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"tif",
|
||||
"tiff",
|
||||
"txt",
|
||||
"csv",
|
||||
"rtf",
|
||||
"html",
|
||||
"epub",
|
||||
];
|
||||
for (const ext of innerExts) {
|
||||
if (lower.endsWith(`.${ext}.zip`)) {
|
||||
return fallback.slice(0, fallback.length - 4) || fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Pull the server file list (and share-links) and reconcile with local stubs. */
|
||||
export async function reconcileServerFiles(
|
||||
localStubs: StirlingFileStub[],
|
||||
opts: ReconcileOptions,
|
||||
): Promise<StirlingFileStub[]> {
|
||||
if (!opts.storageEnabled) {
|
||||
return localStubs;
|
||||
}
|
||||
|
||||
let combinedStubs: StirlingFileStub[];
|
||||
const localRemoteIds = new Set(
|
||||
localStubs
|
||||
.map((s) => s.remoteStorageId)
|
||||
.filter((id): id is number => typeof id === "number"),
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<StoredFileResponse[]>(
|
||||
"/api/v1/storage/files",
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
} as any,
|
||||
);
|
||||
const serverFiles = Array.isArray(response.data) ? response.data : [];
|
||||
const serverMap = new Map<number, StoredFileResponse>();
|
||||
for (const file of serverFiles) {
|
||||
if (file && typeof file.id === "number") {
|
||||
serverMap.set(file.id, file);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedLocalStubs = localStubs.map((stub) => {
|
||||
if (!stub.remoteStorageId) {
|
||||
return stub;
|
||||
}
|
||||
const serverFile = serverMap.get(stub.remoteStorageId);
|
||||
if (!serverFile) {
|
||||
// Server no longer knows this file; if it was a shared-link link,
|
||||
// demote rather than detach. Otherwise drop remote metadata so the
|
||||
// file becomes local-only.
|
||||
if (stub.remoteSharedViaLink) {
|
||||
return { ...stub, remoteOwnedByCurrentUser: false };
|
||||
}
|
||||
return {
|
||||
...stub,
|
||||
remoteStorageId: undefined,
|
||||
remoteStorageUpdatedAt: undefined,
|
||||
remoteOwnerUsername: undefined,
|
||||
remoteOwnedByCurrentUser: undefined,
|
||||
remoteAccessRole: undefined,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: undefined,
|
||||
};
|
||||
}
|
||||
if (serverFile.filePurpose && serverFile.filePurpose !== "generic") {
|
||||
// Signing-workflow files aren't user-facing in the file manager.
|
||||
return {
|
||||
...stub,
|
||||
remoteStorageId: undefined,
|
||||
remoteStorageUpdatedAt: undefined,
|
||||
remoteOwnerUsername: undefined,
|
||||
remoteOwnedByCurrentUser: undefined,
|
||||
remoteAccessRole: undefined,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: undefined,
|
||||
};
|
||||
}
|
||||
const updatedAtMs = serverFile.updatedAt
|
||||
? new Date(serverFile.updatedAt).getTime()
|
||||
: serverFile.createdAt
|
||||
? new Date(serverFile.createdAt).getTime()
|
||||
: undefined;
|
||||
return {
|
||||
...stub,
|
||||
remoteOwnerUsername: serverFile.owner ?? stub.remoteOwnerUsername,
|
||||
remoteOwnedByCurrentUser:
|
||||
typeof serverFile.ownedByCurrentUser === "boolean"
|
||||
? serverFile.ownedByCurrentUser
|
||||
: stub.remoteOwnedByCurrentUser,
|
||||
remoteAccessRole: serverFile.accessRole ?? stub.remoteAccessRole,
|
||||
remoteSharedViaLink: stub.remoteSharedViaLink,
|
||||
remoteHasShareLinks: Boolean(serverFile.shareLinks?.length),
|
||||
remoteHasUserShares: Boolean(
|
||||
serverFile.sharedUsers?.length || serverFile.sharedWithUsers?.length,
|
||||
),
|
||||
remoteStorageUpdatedAt:
|
||||
typeof updatedAtMs === "number" && Number.isFinite(updatedAtMs)
|
||||
? updatedAtMs
|
||||
: stub.remoteStorageUpdatedAt,
|
||||
folderId: safeParseFolderId(serverFile.folderId) ?? stub.folderId,
|
||||
};
|
||||
});
|
||||
|
||||
// Server files that this browser hasn't cached yet become ephemeral
|
||||
// stubs (id="server-{N}", no IDB row). Bytes get fetched on demand.
|
||||
const serverStubs: StirlingFileStub[] = [];
|
||||
for (const file of serverFiles) {
|
||||
if (!file || typeof file.id !== "number") continue;
|
||||
if (localRemoteIds.has(file.id)) continue;
|
||||
if (file.filePurpose && file.filePurpose !== "generic") continue;
|
||||
const updatedAtMs = file.updatedAt
|
||||
? new Date(file.updatedAt).getTime()
|
||||
: file.createdAt
|
||||
? new Date(file.createdAt).getTime()
|
||||
: Date.now();
|
||||
const name = normalizeServerFileName(file.fileName);
|
||||
const lastModified = Number.isFinite(updatedAtMs)
|
||||
? updatedAtMs
|
||||
: Date.now();
|
||||
const id = `server-${file.id}` as FileId;
|
||||
serverStubs.push({
|
||||
id,
|
||||
name,
|
||||
type: file.contentType || "application/octet-stream",
|
||||
size: file.sizeBytes ?? 0,
|
||||
lastModified,
|
||||
createdAt: lastModified,
|
||||
isLeaf: true,
|
||||
originalFileId: id,
|
||||
versionNumber: 1,
|
||||
toolHistory: [],
|
||||
quickKey: `${name}|${file.sizeBytes ?? 0}|${lastModified}`,
|
||||
remoteStorageId: file.id,
|
||||
remoteStorageUpdatedAt: lastModified,
|
||||
remoteOwnerUsername: file.owner ?? undefined,
|
||||
remoteOwnedByCurrentUser:
|
||||
typeof file.ownedByCurrentUser === "boolean"
|
||||
? file.ownedByCurrentUser
|
||||
: undefined,
|
||||
remoteAccessRole: file.accessRole ?? undefined,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: Boolean(file.shareLinks?.length),
|
||||
remoteHasUserShares: Boolean(
|
||||
file.sharedUsers?.length || file.sharedWithUsers?.length,
|
||||
),
|
||||
folderId: safeParseFolderId(file.folderId),
|
||||
});
|
||||
}
|
||||
|
||||
combinedStubs = [...updatedLocalStubs, ...serverStubs];
|
||||
} catch (err) {
|
||||
// Surface to the user so they don't silently see only locally-cached
|
||||
// files and assume their cloud data is lost. Toast deduplicates by title
|
||||
// so a repeated failure isn't an avalanche of identical popups.
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
console.warn("[fileSyncService] failed to pull server files", err);
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title:
|
||||
status === 401
|
||||
? "Sign-in required to load cloud files"
|
||||
: "Could not reach the cloud library",
|
||||
body: "Showing only files cached in this browser. Refresh to retry once the connection is back.",
|
||||
expandable: false,
|
||||
durationMs: 5000,
|
||||
});
|
||||
return localStubs;
|
||||
}
|
||||
|
||||
if (!opts.shareLinksEnabled) {
|
||||
return combinedStubs;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<AccessedShareLinkResponse[]>(
|
||||
"/api/v1/storage/share-links/accessed",
|
||||
{ suppressErrorToast: true, skipAuthRedirect: true } as any,
|
||||
);
|
||||
const sharedLinks = Array.isArray(response.data) ? response.data : [];
|
||||
const allowed = new Set(
|
||||
sharedLinks
|
||||
.map((l) => l.shareToken)
|
||||
.filter((t): t is string => Boolean(t)),
|
||||
);
|
||||
const writes: Array<Promise<boolean>> = [];
|
||||
combinedStubs = combinedStubs.map((stub) => {
|
||||
if (
|
||||
stub.remoteSharedViaLink &&
|
||||
stub.remoteShareToken &&
|
||||
!allowed.has(stub.remoteShareToken)
|
||||
) {
|
||||
writes.push(
|
||||
fileStorage.updateFileMetadata(stub.id, {
|
||||
remoteStorageId: undefined,
|
||||
remoteStorageUpdatedAt: undefined,
|
||||
remoteOwnerUsername: undefined,
|
||||
remoteOwnedByCurrentUser: undefined,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: undefined,
|
||||
remoteShareToken: undefined,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
...stub,
|
||||
remoteStorageId: undefined,
|
||||
remoteStorageUpdatedAt: undefined,
|
||||
remoteOwnerUsername: undefined,
|
||||
remoteOwnedByCurrentUser: undefined,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: undefined,
|
||||
remoteShareToken: undefined,
|
||||
};
|
||||
}
|
||||
return stub;
|
||||
});
|
||||
if (writes.length > 0) {
|
||||
// Fire-and-forget; the in-memory list is the user-visible source.
|
||||
void Promise.all(writes).catch(() => {});
|
||||
}
|
||||
|
||||
// Synthesize ephemeral shared-{token} stubs for share-links the user has
|
||||
// accessed but doesn't have cached locally yet. Materialize on demand.
|
||||
const existingShareTokens = new Set(
|
||||
combinedStubs
|
||||
.map((stub) => stub.remoteShareToken)
|
||||
.filter((token): token is string => Boolean(token)),
|
||||
);
|
||||
const sharedStubs: StirlingFileStub[] = [];
|
||||
for (const link of sharedLinks) {
|
||||
if (!link || !link.shareToken) continue;
|
||||
if (existingShareTokens.has(link.shareToken)) continue;
|
||||
const accessedMs = link.lastAccessedAt
|
||||
? new Date(link.lastAccessedAt).getTime()
|
||||
: link.createdAt
|
||||
? new Date(link.createdAt).getTime()
|
||||
: Date.now();
|
||||
const lastModified = Number.isFinite(accessedMs)
|
||||
? accessedMs
|
||||
: Date.now();
|
||||
const name = normalizeServerFileName(link.fileName || "shared-file");
|
||||
const id = `shared-${link.shareToken}` as FileId;
|
||||
sharedStubs.push({
|
||||
id,
|
||||
name,
|
||||
type: "application/octet-stream",
|
||||
size: 0,
|
||||
lastModified,
|
||||
createdAt: lastModified,
|
||||
isLeaf: true,
|
||||
originalFileId: id,
|
||||
versionNumber: 1,
|
||||
toolHistory: [],
|
||||
quickKey: `${name}|0|${lastModified}`,
|
||||
remoteStorageId: link.fileId ?? undefined,
|
||||
remoteStorageUpdatedAt: lastModified,
|
||||
remoteOwnerUsername: link.owner ?? undefined,
|
||||
remoteOwnedByCurrentUser: false,
|
||||
remoteSharedViaLink: true,
|
||||
remoteHasShareLinks: false,
|
||||
remoteShareToken: link.shareToken,
|
||||
});
|
||||
}
|
||||
combinedStubs = [...combinedStubs, ...sharedStubs];
|
||||
} catch (err) {
|
||||
console.warn("[fileSyncService] failed to pull share-links", err);
|
||||
}
|
||||
|
||||
return combinedStubs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download bytes for any server-only stubs (id starts with "server-") and
|
||||
* ingest them into IDB. Returns a stub list where the server-only entries
|
||||
* are replaced with proper local stubs that point to the freshly-cached
|
||||
* IDB rows. Local stubs are passed through untouched.
|
||||
*
|
||||
* Pass `addFiles` (from FileContext.addFilesWithOptions) and
|
||||
* `updateStub` (from FileContext.updateStirlingFileStub) so this util
|
||||
* stays React-free; the caller provides the wiring.
|
||||
*/
|
||||
export async function materializeServerStubs(
|
||||
stubs: StirlingFileStub[],
|
||||
helpers: {
|
||||
addFiles: (
|
||||
files: File[],
|
||||
options: {
|
||||
selectFiles: boolean;
|
||||
autoUnzip: boolean;
|
||||
skipAutoUnzip: boolean;
|
||||
allowDuplicates: boolean;
|
||||
},
|
||||
) => Promise<StirlingFile[]>;
|
||||
updateStub: (id: FileId, updates: Partial<StirlingFileStub>) => void;
|
||||
},
|
||||
): Promise<StirlingFileStub[]> {
|
||||
const out: StirlingFileStub[] = [];
|
||||
// Collect per-stub failures so we can surface ONE summarized toast at the
|
||||
// end instead of N popups (or worse, silently dropping files from the grid
|
||||
// with zero user signal as the previous code did).
|
||||
const failed: { name: string; status?: number }[] = [];
|
||||
for (const stub of stubs) {
|
||||
const isServerStub =
|
||||
typeof stub.id === "string" &&
|
||||
stub.id.startsWith("server-") &&
|
||||
typeof stub.remoteStorageId === "number";
|
||||
const isSharedStub =
|
||||
typeof stub.id === "string" &&
|
||||
stub.id.startsWith("shared-") &&
|
||||
typeof stub.remoteShareToken === "string";
|
||||
if (!isServerStub && !isSharedStub) {
|
||||
out.push(stub);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const downloadUrl = isSharedStub
|
||||
? `/api/v1/storage/share-links/${stub.remoteShareToken}`
|
||||
: `/api/v1/storage/files/${stub.remoteStorageId}/download`;
|
||||
const response = await apiClient.get(downloadUrl, {
|
||||
responseType: "blob",
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
} as any);
|
||||
const rawHeaders = (response.headers ?? {}) as Record<string, unknown> & {
|
||||
get?: (name: string) => string | null;
|
||||
};
|
||||
const readHeader = (name: string): string => {
|
||||
if (typeof rawHeaders.get === "function") {
|
||||
return rawHeaders.get(name) ?? "";
|
||||
}
|
||||
const lower = rawHeaders[name];
|
||||
const upper =
|
||||
rawHeaders[
|
||||
name.replace(/(^|-)([a-z])/g, (_, p1, p2) => p1 + p2.toUpperCase())
|
||||
];
|
||||
return (
|
||||
(typeof lower === "string" && lower) ||
|
||||
(typeof upper === "string" && upper) ||
|
||||
""
|
||||
);
|
||||
};
|
||||
const contentType = readHeader("content-type");
|
||||
const disposition = readHeader("content-disposition");
|
||||
const filename =
|
||||
parseContentDispositionFilename(disposition) || stub.name;
|
||||
const blob = response.data as Blob;
|
||||
|
||||
// Server bundle: extract latest file(s) inside.
|
||||
const bundle = isZipBundle(contentType, filename)
|
||||
? await loadShareBundleEntries(blob).catch(() => null)
|
||||
: null;
|
||||
const files: File[] = bundle
|
||||
? bundle.files
|
||||
: [new File([blob], filename, { type: contentType || blob.type })];
|
||||
|
||||
const ingested = await helpers.addFiles(files, {
|
||||
selectFiles: false,
|
||||
autoUnzip: false,
|
||||
skipAutoUnzip: true,
|
||||
allowDuplicates: true,
|
||||
});
|
||||
if (ingested.length === 0) continue;
|
||||
const primary = ingested[ingested.length - 1]!;
|
||||
const newId = primary.fileId as FileId;
|
||||
const remoteUpdates = {
|
||||
remoteStorageId: stub.remoteStorageId,
|
||||
remoteStorageUpdatedAt: stub.remoteStorageUpdatedAt,
|
||||
remoteOwnerUsername: stub.remoteOwnerUsername,
|
||||
remoteOwnedByCurrentUser: stub.remoteOwnedByCurrentUser,
|
||||
remoteAccessRole: stub.remoteAccessRole,
|
||||
remoteSharedViaLink: isSharedStub ? true : false,
|
||||
remoteHasShareLinks: stub.remoteHasShareLinks,
|
||||
remoteShareToken: isSharedStub ? stub.remoteShareToken : undefined,
|
||||
};
|
||||
helpers.updateStub(newId, remoteUpdates);
|
||||
await fileStorage.updateFileMetadata(newId, remoteUpdates);
|
||||
out.push({ ...stub, ...remoteUpdates, id: newId, originalFileId: newId });
|
||||
} catch (err) {
|
||||
console.warn("[fileSyncService] failed to materialize server stub", err);
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
failed.push({ name: stub.name, status });
|
||||
}
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
// Single summarized toast - far less noisy than per-stub alerts but
|
||||
// still surfaces what would otherwise be a silent drop from the grid.
|
||||
const first = failed[0]!;
|
||||
const bodyText =
|
||||
failed.length === 1
|
||||
? `Couldn't open "${first.name}"${first.status ? ` (HTTP ${first.status})` : ""}.`
|
||||
: `Couldn't open ${failed.length} files including "${first.name}".`;
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: "Some files couldn't be opened",
|
||||
body: bodyText,
|
||||
expandable: false,
|
||||
durationMs: 5000,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Folder Storage Service - passive read-cache of the server's folder hierarchy.
|
||||
*
|
||||
* Folders are server-owned. This module just persists the most recent server
|
||||
* response so the UI can paint instantly on next mount (and remain readable
|
||||
* offline). Every mutation must go through {@code folderSyncService} first;
|
||||
* on success the caller invokes {@link FolderStorageService.replaceAll} or
|
||||
* one of the targeted updaters to keep the cache in step.
|
||||
*
|
||||
* No id generation here, no cycle detection, no idempotency tricks - those
|
||||
* are all the server's job now.
|
||||
*/
|
||||
|
||||
import { FolderId, FolderRecord } from "@app/types/folder";
|
||||
import {
|
||||
indexedDBManager,
|
||||
DATABASE_CONFIGS,
|
||||
} from "@app/services/indexedDBManager";
|
||||
|
||||
class FolderStorageService {
|
||||
private readonly dbConfig = DATABASE_CONFIGS.FILES;
|
||||
private readonly storeName = "folders";
|
||||
|
||||
private async getDatabase(): Promise<IDBDatabase> {
|
||||
return indexedDBManager.openDatabase(this.dbConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically replace the entire cached folder set with the supplied list.
|
||||
* Used after a successful {@code pullFromServer} - the server is the
|
||||
* source of truth, so any folder absent from the response is dropped
|
||||
* locally too (no orphan rows surviving a server-side delete).
|
||||
*/
|
||||
async replaceAll(folders: FolderRecord[]): Promise<void> {
|
||||
const db = await this.getDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () =>
|
||||
reject(transaction.error ?? new Error("folder cache replace failed"));
|
||||
transaction.onabort = () =>
|
||||
reject(transaction.error ?? new Error("folder cache replace aborted"));
|
||||
store.clear();
|
||||
for (const folder of folders) {
|
||||
store.put(folder);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Insert or overwrite a single folder in the cache. */
|
||||
async upsertFolder(folder: FolderRecord): Promise<void> {
|
||||
const db = await this.getDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const req = store.put(folder);
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a set of folders from the cache (after a successful server delete). */
|
||||
async removeFolders(ids: FolderId[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const db = await this.getDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () =>
|
||||
reject(transaction.error ?? new Error("folder cache delete failed"));
|
||||
transaction.onabort = () =>
|
||||
reject(transaction.error ?? new Error("folder cache delete aborted"));
|
||||
for (const id of ids) store.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
async getAllFolders(): Promise<FolderRecord[]> {
|
||||
const db = await this.getDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.getAll();
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const records = (request.result as FolderRecord[]) ?? [];
|
||||
resolve(records);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getFolder(id: FolderId): Promise<FolderRecord | null> {
|
||||
const db = await this.getDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const record = request.result as FolderRecord | undefined;
|
||||
resolve(record ?? null);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
const db = await this.getDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.clear();
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const folderStorage = new FolderStorageService();
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Thin HTTP client for the Phase A folder endpoints.
|
||||
*
|
||||
* Returns DTOs that look like the local {@link FolderRecord}; the
|
||||
* caller is responsible for merging them into IndexedDB via
|
||||
* {@link folderStorage}.
|
||||
*/
|
||||
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { FolderId, FolderRecord, parseFolderId } from "@app/types/folder";
|
||||
|
||||
interface ServerFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
parentFolderId: string | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
version: number | null;
|
||||
// ISO timestamps - older server builds occasionally send `null` when
|
||||
// the entity hasn't been flushed; we defend against that in parseTimestamp.
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
function parseTimestamp(
|
||||
value: string | null | undefined,
|
||||
field: string,
|
||||
): number {
|
||||
// Tolerate null/missing - older server builds may serialise pre-flush
|
||||
// timestamps as null. Log a warning so a real schema drift still gets
|
||||
// attention, but fall back to "now" rather than failing the whole pull.
|
||||
if (value == null || value === "") {
|
||||
console.warn(
|
||||
`[folderSyncService] missing ${field} from server response; defaulting to now`,
|
||||
);
|
||||
return Date.now();
|
||||
}
|
||||
const ms = Date.parse(value);
|
||||
if (Number.isNaN(ms)) {
|
||||
throw new Error(`Invalid ${field} timestamp from server: ${value}`);
|
||||
}
|
||||
return ms;
|
||||
}
|
||||
|
||||
function toFolderRecord(dto: ServerFolder): FolderRecord {
|
||||
// Validate at the trust boundary - server may have a bug or contract drift.
|
||||
// parseFolderId throws if `dto.id` isn't a UUID; better a loud failure than
|
||||
// a corrupt local cache.
|
||||
const id = parseFolderId(dto.id);
|
||||
const parentFolderId =
|
||||
dto.parentFolderId === null ? null : parseFolderId(dto.parentFolderId);
|
||||
return {
|
||||
id,
|
||||
name: dto.name,
|
||||
parentFolderId,
|
||||
color: dto.color ?? undefined,
|
||||
icon: dto.icon ?? undefined,
|
||||
createdAt: parseTimestamp(dto.createdAt, "createdAt"),
|
||||
updatedAt: parseTimestamp(dto.updatedAt, "updatedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
export const folderSyncService = {
|
||||
async list(): Promise<FolderRecord[]> {
|
||||
const response = await apiClient.get<ServerFolder[]>(
|
||||
"/api/v1/storage/folders",
|
||||
);
|
||||
return (response.data ?? []).map(toFolderRecord);
|
||||
},
|
||||
|
||||
async create(input: {
|
||||
id?: FolderId;
|
||||
name: string;
|
||||
parentFolderId: FolderId | null;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
}): Promise<FolderRecord> {
|
||||
const response = await apiClient.post<ServerFolder>(
|
||||
"/api/v1/storage/folders",
|
||||
{
|
||||
id: input.id ?? undefined,
|
||||
name: input.name,
|
||||
parentFolderId: input.parentFolderId,
|
||||
color: input.color,
|
||||
icon: input.icon,
|
||||
},
|
||||
);
|
||||
return toFolderRecord(response.data);
|
||||
},
|
||||
|
||||
async update(
|
||||
id: FolderId,
|
||||
patch: {
|
||||
name?: string;
|
||||
reparent?: boolean;
|
||||
parentFolderId?: FolderId | null;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
},
|
||||
): Promise<FolderRecord> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (patch.name !== undefined) body.name = patch.name;
|
||||
if (patch.reparent) {
|
||||
body.reparent = true;
|
||||
body.parentFolderId = patch.parentFolderId ?? null;
|
||||
}
|
||||
if (patch.color !== undefined) body.color = patch.color ?? "";
|
||||
if (patch.icon !== undefined) body.icon = patch.icon ?? "";
|
||||
const response = await apiClient.patch<ServerFolder>(
|
||||
`/api/v1/storage/folders/${id}`,
|
||||
body,
|
||||
);
|
||||
return toFolderRecord(response.data);
|
||||
},
|
||||
|
||||
async delete(id: FolderId): Promise<FolderId[]> {
|
||||
const response = await apiClient.delete<{ removedFolderIds: string[] }>(
|
||||
`/api/v1/storage/folders/${id}`,
|
||||
);
|
||||
// Validate at the trust boundary - same posture as toFolderRecord.
|
||||
return (response.data?.removedFolderIds ?? []).map(parseFolderId);
|
||||
},
|
||||
|
||||
async moveFileToFolder(
|
||||
fileRemoteId: number,
|
||||
folderId: FolderId | null,
|
||||
): Promise<void> {
|
||||
await apiClient.patch(`/api/v1/storage/files/${fileRemoteId}/folder`, {
|
||||
folderId,
|
||||
});
|
||||
},
|
||||
|
||||
async bulkMoveFiles(
|
||||
fileRemoteIds: number[],
|
||||
folderId: FolderId | null,
|
||||
): Promise<{ movedFileIds: number[]; skippedFileIds: number[] }> {
|
||||
const response = await apiClient.patch<{
|
||||
movedFileIds: number[];
|
||||
skippedFileIds: number[];
|
||||
}>("/api/v1/storage/files/folder", {
|
||||
folderId,
|
||||
fileIds: fileRemoteIds,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, expect, test, beforeEach } from "vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
|
||||
import {
|
||||
DATABASE_CONFIGS,
|
||||
indexedDBManager,
|
||||
} from "@app/services/indexedDBManager";
|
||||
|
||||
/**
|
||||
* Regression test for the IDB v2->v4 migration silent-clobber bug fixed in
|
||||
* commit 57e056026 "Merge IDB migrations". Pre-fix: two separate cursor
|
||||
* walks (v2->v3 and v3->v4) ran in the same versionchange transaction;
|
||||
* the second cursor's `value` was a structured-clone snapshot taken
|
||||
* BEFORE the first cursor's update() committed, so the v4 walk wrote
|
||||
* back its pre-v3 snapshot and silently erased every v3 field on every
|
||||
* row (isLeaf, versionNumber, originalFileId, parentFileId, toolHistory).
|
||||
*
|
||||
* A future v5 migration written as a third separate cursor walk would
|
||||
* re-introduce the exact same failure mode for anyone jumping multiple
|
||||
* versions. This test pins the behaviour by seeding a v2 DB and asserting
|
||||
* every v3+v4 field is present and correctly set after the upgrade.
|
||||
*/
|
||||
|
||||
const DB_NAME = DATABASE_CONFIGS.FILES.name;
|
||||
const TARGET_VERSION = DATABASE_CONFIGS.FILES.version;
|
||||
|
||||
/**
|
||||
* Open a v2-shaped FILES DB and seed it with two v2-shape records. v2
|
||||
* had only the `files` store with `id` as keyPath; none of the v3 fields
|
||||
* (isLeaf, versionNumber, originalFileId, parentFileId, toolHistory) or
|
||||
* the v4 field (folderId) yet existed on records.
|
||||
*/
|
||||
function seedV2Database(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 2);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains("files")) {
|
||||
db.createObjectStore("files", { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const tx = db.transaction("files", "readwrite");
|
||||
const store = tx.objectStore("files");
|
||||
store.add({
|
||||
id: "file-a",
|
||||
name: "alpha.pdf",
|
||||
type: "application/pdf",
|
||||
size: 1024,
|
||||
lastModified: 1000,
|
||||
data: new Blob(["hello"], { type: "application/pdf" }),
|
||||
});
|
||||
store.add({
|
||||
id: "file-b",
|
||||
name: "beta.pdf",
|
||||
type: "application/pdf",
|
||||
size: 2048,
|
||||
lastModified: 2000,
|
||||
data: new Blob(["world"], { type: "application/pdf" }),
|
||||
});
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => reject(tx.error ?? new Error("seed tx failed"));
|
||||
};
|
||||
req.onerror = () => reject(req.error ?? new Error("seed open failed"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a v3-shape DB (so we can also verify that a v3->v4 jump still
|
||||
* lands correctly under the merged migration).
|
||||
*/
|
||||
function seedV3Database(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 3);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains("files")) {
|
||||
db.createObjectStore("files", { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const tx = db.transaction("files", "readwrite");
|
||||
const store = tx.objectStore("files");
|
||||
store.add({
|
||||
id: "file-c",
|
||||
name: "gamma.pdf",
|
||||
type: "application/pdf",
|
||||
size: 4096,
|
||||
lastModified: 3000,
|
||||
data: new Blob(["v3"], { type: "application/pdf" }),
|
||||
// v3 fields, set explicitly so this is genuinely a v3 record
|
||||
isLeaf: true,
|
||||
versionNumber: 1,
|
||||
originalFileId: "file-c",
|
||||
parentFileId: undefined,
|
||||
toolHistory: [],
|
||||
});
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => reject(tx.error ?? new Error("v3 seed tx failed"));
|
||||
};
|
||||
req.onerror = () => reject(req.error ?? new Error("v3 seed open failed"));
|
||||
});
|
||||
}
|
||||
|
||||
function readAllFiles(): Promise<unknown[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME);
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const tx = db.transaction("files", "readonly");
|
||||
const store = tx.objectStore("files");
|
||||
const all = store.getAll();
|
||||
all.onsuccess = () => {
|
||||
db.close();
|
||||
resolve(all.result);
|
||||
};
|
||||
all.onerror = () => reject(all.error);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
indexedDBManager.closeAllDatabases();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = indexedDB.deleteDatabase(DB_NAME);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onblocked = () => resolve(); // best-effort in jsdom
|
||||
});
|
||||
}
|
||||
|
||||
describe("IndexedDB migration (FILES store)", () => {
|
||||
beforeEach(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test("v2 -> latest applies v3 AND v4 fields without clobbering", async () => {
|
||||
await seedV2Database();
|
||||
// Trigger the migration via the production code path.
|
||||
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
|
||||
indexedDBManager.closeDatabase(DB_NAME);
|
||||
|
||||
const rows = (await readAllFiles()) as Array<Record<string, unknown>>;
|
||||
expect(rows).toHaveLength(2);
|
||||
|
||||
for (const row of rows) {
|
||||
// v3 fields - this is the critical assertion. Pre-fix, the v4
|
||||
// cursor walk's structured-clone snapshot would write these back
|
||||
// as undefined, silently erasing them.
|
||||
expect(row.isLeaf).toBe(true);
|
||||
expect(row.versionNumber).toBe(1);
|
||||
expect(row.originalFileId).toBe(row.id);
|
||||
expect(row.toolHistory).toEqual([]);
|
||||
// parentFileId is intentionally set to undefined for legacy records
|
||||
// (they're version roots, not children of anything).
|
||||
expect(row.parentFileId).toBeUndefined();
|
||||
// v4 field
|
||||
expect(row.folderId).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("v3 -> latest applies v4 folderId without erasing existing v3 fields", async () => {
|
||||
await seedV3Database();
|
||||
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
|
||||
indexedDBManager.closeDatabase(DB_NAME);
|
||||
|
||||
const rows = (await readAllFiles()) as Array<Record<string, unknown>>;
|
||||
expect(rows).toHaveLength(1);
|
||||
const row = rows[0]!;
|
||||
|
||||
// Existing v3 fields should be unchanged
|
||||
expect(row.isLeaf).toBe(true);
|
||||
expect(row.versionNumber).toBe(1);
|
||||
expect(row.originalFileId).toBe("file-c");
|
||||
expect(row.toolHistory).toEqual([]);
|
||||
// v4 field added
|
||||
expect(row.folderId).toBeNull();
|
||||
});
|
||||
|
||||
test("fresh install at latest version requires no migration", async () => {
|
||||
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
|
||||
expect(await indexedDBManager.getDatabaseVersion(DB_NAME)).toBe(
|
||||
TARGET_VERSION,
|
||||
);
|
||||
indexedDBManager.closeDatabase(DB_NAME);
|
||||
});
|
||||
});
|
||||
@@ -147,7 +147,7 @@ class IndexedDBManager {
|
||||
storeConfig.name === "files" &&
|
||||
store
|
||||
) {
|
||||
this.migrateFileHistoryFields(store, oldVersion);
|
||||
this.migrateFilesStore(store, oldVersion);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -155,78 +155,97 @@ class IndexedDBManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate existing file records to include new file history fields
|
||||
* Single-pass migration for the `files` store on stirling-pdf-files.
|
||||
*
|
||||
* Runs ONE openCursor() walk and applies every applicable per-version
|
||||
* delta to each record before `cursor.update()` writes it back. The
|
||||
* previous design called migrateFileHistoryFields and migrateFolderField
|
||||
* as two separate cursor walks inside the same onupgradeneeded
|
||||
* transaction; their requests interleaved in the IDB request queue so
|
||||
* the second walk's `cursor.value` was a stale snapshot taken before
|
||||
* the first walk's `update()` had been processed - the second
|
||||
* `update()` then wrote that stale object back, silently erasing
|
||||
* isLeaf / versionNumber / originalFileId / parentFileId / toolHistory
|
||||
* on every row both cursors touched. Folding into one cursor + one
|
||||
* write per record removes the race entirely.
|
||||
*
|
||||
* New per-version blocks should be added as additional
|
||||
* `if (oldVersion < N) { ... }` sections below.
|
||||
*/
|
||||
private migrateFileHistoryFields(
|
||||
store: IDBObjectStore,
|
||||
oldVersion: number,
|
||||
): void {
|
||||
// Only migrate if upgrading from a version before file history was added (version < 3)
|
||||
if (oldVersion >= 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Starting file history migration for existing records...");
|
||||
private migrateFilesStore(store: IDBObjectStore, oldVersion: number): void {
|
||||
if (oldVersion >= 4) return; // nothing to migrate at the current schema
|
||||
|
||||
const cursor = store.openCursor();
|
||||
let migratedCount = 0;
|
||||
let migrated = 0;
|
||||
|
||||
cursor.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor) {
|
||||
const record = cursor.value;
|
||||
let needsUpdate = false;
|
||||
|
||||
// Add missing file history fields with sensible defaults
|
||||
if (record.isLeaf === undefined) {
|
||||
record.isLeaf = true; // Existing files are unprocessed, should appear in recent files
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (record.versionNumber === undefined) {
|
||||
record.versionNumber = 1; // Existing files are first version
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (record.originalFileId === undefined) {
|
||||
record.originalFileId = record.id; // Existing files are their own root
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (record.parentFileId === undefined) {
|
||||
record.parentFileId = undefined; // No parent for existing files
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (record.toolHistory === undefined) {
|
||||
record.toolHistory = []; // No history for existing files
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
// Update the record if any fields were missing
|
||||
if (needsUpdate) {
|
||||
try {
|
||||
cursor.update(record);
|
||||
migratedCount++;
|
||||
} catch (error) {
|
||||
console.error("Failed to migrate record:", record.id, error);
|
||||
}
|
||||
}
|
||||
|
||||
cursor.continue();
|
||||
} else {
|
||||
// Migration complete
|
||||
console.log(
|
||||
`File history migration completed. Migrated ${migratedCount} records.`,
|
||||
);
|
||||
const result = (event.target as IDBRequest)
|
||||
.result as IDBCursorWithValue | null;
|
||||
if (!result) {
|
||||
console.log(`Files-store migration complete (${migrated} records).`);
|
||||
return;
|
||||
}
|
||||
const record = result.value;
|
||||
let needsUpdate = false;
|
||||
|
||||
// v3: file history fields. Sensible defaults so existing files keep
|
||||
// showing up in the recent view and act as their own version root.
|
||||
if (oldVersion < 3) {
|
||||
if (record.isLeaf === undefined) {
|
||||
record.isLeaf = true;
|
||||
needsUpdate = true;
|
||||
}
|
||||
if (record.versionNumber === undefined) {
|
||||
record.versionNumber = 1;
|
||||
needsUpdate = true;
|
||||
}
|
||||
if (record.originalFileId === undefined) {
|
||||
record.originalFileId = record.id;
|
||||
needsUpdate = true;
|
||||
}
|
||||
if (record.parentFileId === undefined) {
|
||||
record.parentFileId = undefined;
|
||||
needsUpdate = true;
|
||||
}
|
||||
if (record.toolHistory === undefined) {
|
||||
record.toolHistory = [];
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// v4: folderId. Required to exist on every row so the folderId
|
||||
// index doesn't drop the record out of bounded-key cursor scans.
|
||||
if (oldVersion < 4 && record.folderId === undefined) {
|
||||
record.folderId = null;
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
try {
|
||||
result.update(record);
|
||||
migrated += 1;
|
||||
} catch (error) {
|
||||
// Aborting the upgrade transaction here forces IndexedDB to roll back
|
||||
// the schema version bump too - the user retries on next page load
|
||||
// instead of silently losing folderId / isLeaf / etc on partial rows.
|
||||
console.error("Failed to migrate record:", record.id, error);
|
||||
store.transaction.abort();
|
||||
return;
|
||||
}
|
||||
}
|
||||
result.continue();
|
||||
};
|
||||
|
||||
cursor.onerror = (event) => {
|
||||
console.error(
|
||||
"File history migration failed:",
|
||||
(event.target as IDBRequest).error,
|
||||
);
|
||||
// Same reasoning as the per-record catch above: abort the upgrade so the
|
||||
// schema doesn't get marked as v4 with rows still on the v3 shape.
|
||||
const err = (event.target as IDBRequest).error;
|
||||
console.error("Files-store migration cursor failed:", err);
|
||||
try {
|
||||
store.transaction.abort();
|
||||
} catch {
|
||||
// Already aborted - ignore.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -305,7 +324,7 @@ class IndexedDBManager {
|
||||
export const DATABASE_CONFIGS = {
|
||||
FILES: {
|
||||
name: "stirling-pdf-files",
|
||||
version: 3,
|
||||
version: 4,
|
||||
stores: [
|
||||
{
|
||||
name: "files",
|
||||
@@ -316,6 +335,20 @@ export const DATABASE_CONFIGS = {
|
||||
{ name: "originalFileId", keyPath: "originalFileId", unique: false },
|
||||
{ name: "parentFileId", keyPath: "parentFileId", unique: false },
|
||||
{ name: "versionNumber", keyPath: "versionNumber", unique: false },
|
||||
{ name: "folderId", keyPath: "folderId", unique: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "folders",
|
||||
keyPath: "id",
|
||||
indexes: [
|
||||
{
|
||||
name: "parentFolderId",
|
||||
keyPath: "parentFolderId",
|
||||
unique: false,
|
||||
},
|
||||
{ name: "name", keyPath: "name", unique: false },
|
||||
{ name: "createdAt", keyPath: "createdAt", unique: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user