fix: treat blob-less FileNode as the only folder signal; migrate legacy dir-markers
This commit is contained in:
+168
-38
@@ -123,18 +123,29 @@ function lastLegacySepIndex(name: string): number {
|
||||
return idx;
|
||||
}
|
||||
|
||||
// Whether an old build's `type` marks a node as a directory. Only meaningful for
|
||||
// detecting legacy "folder" nodes that were really stored as 0-byte files; it is
|
||||
// NOT how a real folder is identified (see isFolder).
|
||||
function isDirectoryType(type: string | undefined): boolean {
|
||||
if (!type) return false;
|
||||
return DIRECTORY_TYPES.has(type) || type.includes('directory');
|
||||
}
|
||||
|
||||
// A FileNode is a folder iff it has no content blob. This is the authoritative
|
||||
// signal in the JMAP FileNode spec and in Stalwart (a node is a container when
|
||||
// its `file`/`blobId` is null); a `type` of "d" is not — older builds created
|
||||
// "folders" as blob-backed files, which can't hold children (#379).
|
||||
function isFolder(node: Pick<FileNode, 'blobId'>): boolean {
|
||||
return node.blobId == null;
|
||||
}
|
||||
|
||||
// Direct children of a given parent in the FileNode hierarchy.
|
||||
function childrenOf(nodes: FileNode[], parentId: string | null): FileNode[] {
|
||||
return nodes.filter(n => (n.parentId ?? null) === parentId);
|
||||
}
|
||||
|
||||
function nodeToResource(node: FileNode): FileResource {
|
||||
const isDir = isDirectoryType(node.type);
|
||||
const isDir = isFolder(node);
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
@@ -164,7 +175,7 @@ function resolvePathToId(nodes: FileNode[], path: string): string | null | undef
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
let parentId: string | null = null;
|
||||
for (const segment of segments) {
|
||||
const match: FileNode | undefined = childrenOf(nodes, parentId).find(n => n.name === segment && isDirectoryType(n.type));
|
||||
const match: FileNode | undefined = childrenOf(nodes, parentId).find(n => n.name === segment && isFolder(n));
|
||||
if (!match) return undefined;
|
||||
parentId = match.id;
|
||||
}
|
||||
@@ -256,45 +267,149 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split an encoded name into its real path segments, accepting any of the
|
||||
// legacy separators and dropping empty segments (leading / trailing / dup
|
||||
// separators). A non-legacy name yields a single segment.
|
||||
const splitSegments = (name: string): string[] => {
|
||||
let parts = [name];
|
||||
for (const sep of LEGACY_PATH_SEPS) parts = parts.flatMap(p => p.split(sep));
|
||||
return parts.filter(Boolean);
|
||||
};
|
||||
|
||||
// A legacy "folder marker": an old build stored folders as 0-byte files with
|
||||
// a directory-ish `type` and a blob. The server treats these as files, so
|
||||
// nothing can be parented under them - they must be replaced by real folders.
|
||||
const isLegacyDirMarker = (n: FileNode) => !isFolder(n) && isDirectoryType(n.type);
|
||||
|
||||
const legacy = allNodes.filter(n => lastLegacySepIndex(n.name) >= 0);
|
||||
if (legacy.length === 0) return false;
|
||||
const markers = allNodes.filter(isLegacyDirMarker);
|
||||
if (legacy.length === 0 && markers.length === 0) return false;
|
||||
|
||||
// Resolve parents by their original encoded name. Building the map up front
|
||||
// means the order in which we rewrite nodes does not matter: each node's
|
||||
// parent is located by id, captured before any rename happens.
|
||||
const idByEncodedName = new Map<string, string>();
|
||||
for (const n of allNodes) idByEncodedName.set(n.name, n.id);
|
||||
|
||||
// Build the per-node patches (new leaf name + resolved parentId).
|
||||
const patches: { id: string; name: string; parentId: string | null }[] = [];
|
||||
let skipped = 0;
|
||||
for (const node of legacy) {
|
||||
const idx = lastLegacySepIndex(node.name);
|
||||
const parentName = node.name.slice(0, idx);
|
||||
const leafName = node.name.slice(idx + 1);
|
||||
const parentId = idByEncodedName.get(parentName);
|
||||
// Skip orphans (missing parent) to avoid name collisions at the root;
|
||||
// they keep their encoded name and remain accessible.
|
||||
if (!parentId) {
|
||||
skipped++;
|
||||
console.warn('[Files] migration: no parent for', JSON.stringify(node.name), '(expected', JSON.stringify(parentName) + ')');
|
||||
continue;
|
||||
}
|
||||
patches.push({ id: node.id, name: leafName, parentId });
|
||||
// Real folders that already exist, indexed by the canonical path they
|
||||
// represent, so we reuse them instead of creating duplicates. Keying on the
|
||||
// JSON-encoded segment array avoids separator-collisions between levels.
|
||||
const pathKey = (segs: string[]) => JSON.stringify(segs);
|
||||
const existingDirByPath = new Map<string, FileNode>();
|
||||
for (const n of allNodes) {
|
||||
if (!isFolder(n)) continue;
|
||||
const segs = splitSegments(n.name);
|
||||
if (segs.length > 0) existingDirByPath.set(pathKey(segs), n);
|
||||
}
|
||||
|
||||
set({ migrationProgress: { current: 0, total: patches.length } });
|
||||
// Per-node rename+reparent operations to apply. Crucially, every parentId
|
||||
// here points at a node we have already ensured is a real folder, so the
|
||||
// server's "parent must be a folder" check can't reject them (#379).
|
||||
const updates: Record<string, { name: string; parentId: string | null }> = {};
|
||||
const dirIdByPath = new Map<string, string | null>();
|
||||
dirIdByPath.set('', null); // root
|
||||
let skipped = 0;
|
||||
let createdDirs = 0;
|
||||
let creationBroken = false;
|
||||
|
||||
// Ensure a real folder exists for the given path, returning its id. Reuses an
|
||||
// existing folder at that path (scheduling it for rename/reparent into the
|
||||
// real hierarchy) or creates a fresh one. Sequential because a create hits
|
||||
// the server and deeper levels depend on its id.
|
||||
const ensureDir = async (segs: string[]): Promise<string | null> => {
|
||||
if (segs.length === 0) return null;
|
||||
const key = pathKey(segs);
|
||||
if (dirIdByPath.has(key)) return dirIdByPath.get(key)!;
|
||||
const parentId = await ensureDir(segs.slice(0, -1));
|
||||
const leaf = segs[segs.length - 1];
|
||||
const existing = existingDirByPath.get(key);
|
||||
if (existing) {
|
||||
// Reuse it; only rewrite if its name/parent isn't already correct.
|
||||
if (existing.name !== leaf || (existing.parentId ?? null) !== parentId) {
|
||||
updates[existing.id] = { name: leaf, parentId };
|
||||
}
|
||||
dirIdByPath.set(key, existing.id);
|
||||
return existing.id;
|
||||
}
|
||||
const created = await client.createFileDirectory(leaf, parentId);
|
||||
// Safety net: a real folder has no content blob. If the server (or a stale
|
||||
// build of createFileDirectory) hands back a blob-backed node, it is NOT a
|
||||
// folder - abort before we delete anything irreversible (see below).
|
||||
if (created.blobId != null) {
|
||||
creationBroken = true;
|
||||
throw new Error('createFileDirectory returned a non-folder (has a blobId)');
|
||||
}
|
||||
createdDirs++;
|
||||
dirIdByPath.set(key, created.id);
|
||||
return created.id;
|
||||
};
|
||||
|
||||
const placeDir = async (segs: string[], label: string) => {
|
||||
try {
|
||||
await ensureDir(segs);
|
||||
} catch (err) {
|
||||
skipped++;
|
||||
if (!creationBroken) console.warn('[Files] migration: could not create folder', JSON.stringify(label), '→', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
// Move the legacy marker files out of the way (a reversible rename) so their
|
||||
// names are free for the real folders created in their place. They are only
|
||||
// DELETED at the very end, once the real hierarchy is safely in place - so a
|
||||
// failure can never leave a folder both gone and not recreated.
|
||||
const renamedMarkers: { id: string; name: string }[] = [];
|
||||
for (const m of markers) {
|
||||
try {
|
||||
await client.updateFileNode(m.id, { name: `__bulwark_migrating__.${m.id}` });
|
||||
renamedMarkers.push({ id: m.id, name: m.name });
|
||||
} catch (err) {
|
||||
console.warn('[Files] migration: could not set aside marker', JSON.stringify(m.name), '→', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// Recreate folders that existed only as markers, reparent any real folders
|
||||
// that still carry an encoded name, then reparent the content files.
|
||||
for (const m of markers) {
|
||||
const segs = splitSegments(m.name);
|
||||
if (segs.length > 0) await placeDir(segs, m.name);
|
||||
}
|
||||
for (const node of legacy) {
|
||||
if (!isFolder(node) || isLegacyDirMarker(node)) continue;
|
||||
const segs = splitSegments(node.name);
|
||||
if (segs.length > 0) await placeDir(segs, node.name);
|
||||
}
|
||||
for (const node of legacy) {
|
||||
if (isFolder(node) || isLegacyDirMarker(node)) continue;
|
||||
const segs = splitSegments(node.name);
|
||||
if (segs.length === 0) { skipped++; continue; }
|
||||
try {
|
||||
const parentId = await ensureDir(segs.slice(0, -1));
|
||||
updates[node.id] = { name: segs[segs.length - 1], parentId };
|
||||
} catch (err) {
|
||||
skipped++;
|
||||
if (!creationBroken) console.warn('[Files] migration: could not place', JSON.stringify(node.name), '→', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// If folder creation is fundamentally broken, restore the markers we set
|
||||
// aside and bail out without deleting or reparenting anything. No data lost.
|
||||
if (creationBroken) {
|
||||
console.error('[Files] migration aborted: the server did not return real folders ' +
|
||||
'(createFileDirectory produced blob-backed nodes). Restoring markers; nothing was deleted.');
|
||||
for (const m of renamedMarkers) {
|
||||
try { await client.updateFileNode(m.id, { name: m.name }); } catch { /* best effort */ }
|
||||
}
|
||||
set({ migrationProgress: null });
|
||||
return false;
|
||||
}
|
||||
|
||||
const updateIds = Object.keys(updates);
|
||||
set({ migrationProgress: { current: 0, total: updateIds.length } });
|
||||
|
||||
let migrated = 0;
|
||||
let firstError: string | null = null;
|
||||
const CHUNK = 100;
|
||||
try {
|
||||
for (let i = 0; i < patches.length; i += CHUNK) {
|
||||
const slice = patches.slice(i, i + CHUNK);
|
||||
const updates: Record<string, { name: string; parentId: string | null }> = {};
|
||||
for (const p of slice) updates[p.id] = { name: p.name, parentId: p.parentId };
|
||||
for (let i = 0; i < updateIds.length; i += CHUNK) {
|
||||
const slice = updateIds.slice(i, i + CHUNK);
|
||||
const batch: Record<string, { name: string; parentId: string | null }> = {};
|
||||
for (const id of slice) batch[id] = updates[id];
|
||||
try {
|
||||
const { updated, notUpdated } = await client.updateFileNodes(updates);
|
||||
const { updated, notUpdated } = await client.updateFileNodes(batch);
|
||||
migrated += updated.length;
|
||||
const failedIds = Object.keys(notUpdated);
|
||||
if (failedIds.length > 0 && !firstError) firstError = notUpdated[failedIds[0]];
|
||||
@@ -306,19 +421,34 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
if (!firstError) firstError = msg;
|
||||
console.error('[Files] migration: batch failed →', msg);
|
||||
}
|
||||
set({ migrationProgress: { current: Math.min(i + CHUNK, patches.length), total: patches.length } });
|
||||
set({ migrationProgress: { current: Math.min(i + CHUNK, updateIds.length), total: updateIds.length } });
|
||||
}
|
||||
} finally {
|
||||
set({ migrationProgress: null });
|
||||
}
|
||||
|
||||
if (migrated === 0) {
|
||||
console.error(`[Files] migration found ${legacy.length} legacy node(s) but reparented none ` +
|
||||
`(skipped ${skipped}, first error: ${firstError ?? 'none'}).`);
|
||||
} else {
|
||||
console.info(`[Files] migration reparented ${migrated}/${legacy.length} legacy node(s).`);
|
||||
// The real hierarchy is now in place, so the set-aside marker files are empty
|
||||
// and safe to delete. Done last, on purpose: until here nothing irreversible
|
||||
// has happened.
|
||||
let removedMarkers = 0;
|
||||
if (renamedMarkers.length > 0) {
|
||||
try {
|
||||
const { destroyed } = await client.destroyFileNodes(renamedMarkers.map(m => m.id));
|
||||
removedMarkers = destroyed.length;
|
||||
} catch (err) {
|
||||
console.warn('[Files] migration: could not remove emptied markers →', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
return migrated > 0;
|
||||
|
||||
const didWork = migrated > 0 || createdDirs > 0 || removedMarkers > 0;
|
||||
if (!didWork && (legacy.length > 0 || markers.length > 0)) {
|
||||
console.error(`[Files] migration found ${legacy.length} legacy node(s) but changed nothing ` +
|
||||
`(skipped ${skipped}, first error: ${firstError ?? 'none'}).`);
|
||||
} else if (didWork) {
|
||||
console.info(`[Files] migration reparented ${migrated} file(s), created ${createdDirs} folder(s), ` +
|
||||
`removed ${removedMarkers} legacy marker(s) (skipped ${skipped}).`);
|
||||
}
|
||||
return didWork;
|
||||
},
|
||||
|
||||
navigate: async (parentId: string | null, name?: string) => {
|
||||
|
||||
Reference in New Issue
Block a user