Add server-side folders and files page UI (#6383)

This commit is contained in:
Anthony Stirling
2026-05-27 12:52:46 +01:00
committed by GitHub
parent 4564ed5bec
commit d42b779644
90 changed files with 14720 additions and 663 deletions
+147 -28
View File
@@ -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 },
],
},
],