List Files through get, because query cannot see a folder before 0.16

Creating a folder on the live 0.15.5 server did nothing visible, with no error
and nothing after a reload. The folder was real all along: FileNode/query masks
its results with document_ids(false) — resources that are *not* containers — so
it returns files and never folders, and says nothing about the omission.

FileNode/get carries no such mask, so on those servers the whole tree comes
from a single get with ids:null instead. That also stops ensureFolder making a
fresh "ihasmail" folder on every signature save, having never been able to find
the one already there.
This commit is contained in:
2026-08-24 10:05:08 -07:00
parent b8263aa785
commit 57ff18bb7f
4 changed files with 73 additions and 28 deletions
+19 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { client } from "@/jmap/client";
import { directoryCreate, fileCreate, fileNodeProps, supportsNodeType, normalizeFileNodes } from "../filenode";
import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "../filenode";
import type { FileNode, JmapSession } from "@/jmap/types";
/**
@@ -117,3 +117,21 @@ describe("rights on a pre-0.16 server", () => {
expect(node!.nodeType).toBe("directory");
});
});
/**
* Before 0.16, FileNode/query masks its results with `document_ids(false)` —
* only resources that are *not* containers. It therefore returns files and
* never folders, with no error to explain the omission: a folder created there
* exists but never comes back in a listing. FileNode/get carries no such mask.
*/
describe("directory-blind query", () => {
it("is worked around on older servers", () => {
client.session = session(OLD_SERVER);
expect(queryOmitsDirectories()).toBe(true);
});
it("is not worked around where query can see folders", () => {
client.session = session(NEW_SERVER);
expect(queryOmitsDirectories()).toBe(false);
});
});
+13
View File
@@ -24,6 +24,19 @@ export function supportsNodeType(): boolean {
const BASE_PROPS = ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable"];
/**
* Whether `FileNode/query` is blind to directories.
*
* Before 0.16 the query masks its results with `document_ids(false)`, which
* keeps only resources that are *not* containers — so it returns files and
* never folders, with no error to say so. A folder created there is real, and
* simply never comes back in a listing. `FileNode/get` has no such mask, so
* asking it for every id is the only way to see the whole tree.
*/
export function queryOmitsDirectories(): boolean {
return !supportsNodeType();
}
/** Properties to request, asking for `nodeType` only where it exists. */
export function fileNodeProps(): string[] {
return supportsNodeType() ? [...BASE_PROPS, "nodeType"] : BASE_PROPS;
+9 -2
View File
@@ -6,7 +6,7 @@
*/
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types";
import { directoryCreate, fileCreate, supportsNodeType, normalizeFileNodes } from "@/lib/filenode";
import { directoryCreate, fileCreate, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "@/lib/filenode";
import { useSession } from "@/store/session";
import { toast } from "@/ui/toast";
@@ -17,6 +17,12 @@ const folderProps = () => (supportsNodeType() ? ["id", "name", "nodeType", "pare
async function ensureFolder(accountId: string): Promise<string> {
let list: FileNode[] = [];
if (queryOmitsDirectories()) {
// Query cannot see a directory on these servers, so it would never find the
// folder and we would make a fresh one on every save. Ask get for the lot.
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: null, properties: folderProps() });
list = normalizeFileNodes(res.list);
} else {
try {
const res = await client.chain([
["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: FOLDER }, limit: 5 }, "q"],
@@ -24,13 +30,14 @@ async function ensureFolder(accountId: string): Promise<string> {
]);
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
} catch {
// Older servers: no filter support scan everything.
// Filters unsupported: scan everything and pick it out here.
const res = await client.chain([
["FileNode/query", { accountId, limit: 1000 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"],
]);
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
}
}
const existing = list.find((n) => n.name === FOLDER && n.nodeType === "directory" && !n.parentId);
if (existing) return existing.id;
const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: directoryCreate(null, FOLDER) } });
+9 -2
View File
@@ -1,6 +1,6 @@
import { create } from "zustand";
import { CAP, JmapMethodError, client, setErrorMessage } from "@/jmap/client";
import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes } from "@/lib/filenode";
import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories } from "@/lib/filenode";
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
import { useSession } from "./session";
@@ -33,6 +33,12 @@ const byName = (a: FileNode, b: FileNode) => (a.nodeType === b.nodeType ? a.name
/** Fetch all nodes (paged, no filter) and rebuild the full children map. */
async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial<FilesState>) => void): Promise<void> {
const all: FileNode[] = [];
if (queryOmitsDirectories()) {
// Query would hand back files only, so every folder — including one just
// created — would be missing with nothing to say why. Ask get for the lot.
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: null, properties: fileNodeProps() });
all.push(...normalizeFileNodes(res.list));
} else {
let position = 0;
for (let guard = 0; guard < 100; guard++) {
const res = await client.chain([
@@ -45,6 +51,7 @@ async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial<
position += q.ids.length;
if (!q.ids.length || (q.total != null && position >= q.total)) break;
}
}
const nodes: Record<Id, FileNode> = {};
const children: Record<string, Id[]> = { root: [] };
for (const n of all) nodes[n.id] = n;
@@ -77,7 +84,7 @@ export const useFiles = create<FilesState>((set, get) => ({
if (!accountId) return;
set({ loading: true });
try {
if (!filtersSupported) {
if (!filtersSupported || queryOmitsDirectories()) {
await loadAllNodes(accountId, set);
return;
}