Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ coverage/
# Temporary files
tmp/
temp/

# Bun lock files for developers using bun instead of pnpm
bun.lock
53 changes: 53 additions & 0 deletions electron/controllers/firestore/documentList.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const PHANTOM_SCAN_MAX_DOCS = 1000;

function toDocumentEntry(doc) {
return { id: doc.id, data: doc.data(), path: doc.ref.path };
}

function toPhantomEntry(ref) {
return { id: ref.id, data: {}, path: ref.path, missing: true };
}

function byId(a, b) {
if (a.id === b.id) return 0;
return a.id < b.id ? -1 : 1;
}

async function shouldScanPhantoms(collection, pageFull) {
if (pageFull) return false;
try {
const aggregate = await collection.count().get();
return aggregate.data().count <= PHANTOM_SCAN_MAX_DOCS;
} catch {
return true;
}
}

async function listPhantoms(collection, documents, startAfter) {
try {
const refs = await collection.listDocuments();
const presentIds = new Set(documents.map((doc) => doc.id));
return refs.filter((ref) => !presentIds.has(ref.id) && (!startAfter || ref.id > startAfter)).map(toPhantomEntry);
} catch {
return [];
}
}

async function fetchDocumentsPage(dbRef, { collectionPath, limit, startAfter }) {
const collection = dbRef.collection(collectionPath);
let query = collection.limit(limit);
if (startAfter) {
const cursor = await collection.doc(startAfter).get();
if (cursor.exists) query = query.startAfter(cursor);
}

const snapshot = await query.get();
const documents = snapshot.docs.map(toDocumentEntry);
if (!(await shouldScanPhantoms(collection, snapshot.size >= limit))) return documents;

const phantoms = await listPhantoms(collection, documents, startAfter);
if (!phantoms.length) return documents;
return [...documents, ...phantoms].sort(byId).slice(0, limit);
}

module.exports = { fetchDocumentsPage };
111 changes: 111 additions & 0 deletions electron/controllers/firestore/documentList.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// @vitest-environment node
import { describe, it, expect, vi } from 'vitest';
import { createRequire } from 'module';

const require_ = createRequire(import.meta.url);
const { fetchDocumentsPage } = require_('./documentList');

function snapshotDoc(id, data = { field: 1 }) {
return { id, data: () => data, ref: { path: `col/${id}` } };
}

function mockDb({ docs, listedIds = [], listDocumentsError, count = docs.length, countError, cursorExists = true }) {
const snapshot = { docs, size: docs.length };
const listDocuments = listDocumentsError
? vi.fn().mockRejectedValue(listDocumentsError)
: vi.fn().mockResolvedValue(listedIds.map((id) => ({ id, path: `col/${id}` })));
const countGet = countError
? vi.fn().mockRejectedValue(countError)
: vi.fn().mockResolvedValue({ data: () => ({ count }) });
const query = { get: vi.fn().mockResolvedValue(snapshot), startAfter: vi.fn() };
query.startAfter.mockReturnValue(query);
const collection = {
limit: vi.fn().mockReturnValue(query),
doc: vi.fn().mockReturnValue({ get: vi.fn().mockResolvedValue({ exists: cursorExists }) }),
listDocuments,
count: vi.fn().mockReturnValue({ get: countGet }),
};
return { db: { collection: vi.fn().mockReturnValue(collection) }, query, collection };
}

describe('fetchDocumentsPage', () => {
it('merges phantom documents into the page sorted by id', async () => {
const { db } = mockDb({ docs: [snapshotDoc('a'), snapshotDoc('c')], listedIds: ['a', 'b', 'c'] });

const documents = await fetchDocumentsPage(db, { collectionPath: 'col', limit: 50, startAfter: null });

expect(documents.map((d) => d.id)).toEqual(['a', 'b', 'c']);
expect(documents[1]).toEqual({ id: 'b', data: {}, path: 'col/b', missing: true });
expect(documents[0].missing).toBeUndefined();
});

it('returns query results when listDocuments fails', async () => {
const { db } = mockDb({ docs: [snapshotDoc('a')], listDocumentsError: new Error('unavailable') });

const documents = await fetchDocumentsPage(db, { collectionPath: 'col', limit: 50, startAfter: null });

expect(documents.map((d) => d.id)).toEqual(['a']);
});

it('shows all phantoms in an empty collection', async () => {
const { db } = mockDb({ docs: [], listedIds: ['x', 'y'] });

const documents = await fetchDocumentsPage(db, { collectionPath: 'col', limit: 50, startAfter: null });

expect(documents.map((d) => d.id)).toEqual(['x', 'y']);
expect(documents.every((d) => d.missing)).toBe(true);
});

it('caps the merged page at the requested limit', async () => {
const { db } = mockDb({ docs: [snapshotDoc('a')], listedIds: ['b', 'c', 'd'] });

const documents = await fetchDocumentsPage(db, { collectionPath: 'col', limit: 2, startAfter: null });

expect(documents.map((d) => d.id)).toEqual(['a', 'b']);
});

it('excludes phantoms at or before the startAfter cursor', async () => {
const { db } = mockDb({ docs: [snapshotDoc('d')], listedIds: ['a', 'c', 'e'] });

const documents = await fetchDocumentsPage(db, { collectionPath: 'col', limit: 50, startAfter: 'b' });

expect(documents.map((d) => d.id)).toEqual(['c', 'd', 'e']);
});

it('skips the phantom scan when the page is full', async () => {
const docs = [snapshotDoc('a'), snapshotDoc('b')];
const { db, collection } = mockDb({ docs, listedIds: ['a', 'b', 'c'] });

const documents = await fetchDocumentsPage(db, { collectionPath: 'col', limit: 2, startAfter: null });

expect(documents.map((d) => d.id)).toEqual(['a', 'b']);
expect(collection.listDocuments).not.toHaveBeenCalled();
expect(collection.count).not.toHaveBeenCalled();
});

it('skips the phantom scan for large collections', async () => {
const { db, collection } = mockDb({ docs: [snapshotDoc('a')], listedIds: ['a', 'b'], count: 9875 });

const documents = await fetchDocumentsPage(db, { collectionPath: 'col', limit: 50, startAfter: null });

expect(documents.map((d) => d.id)).toEqual(['a']);
expect(collection.listDocuments).not.toHaveBeenCalled();
});

it('scans phantoms when the count aggregate is unavailable', async () => {
const { db } = mockDb({ docs: [snapshotDoc('a')], listedIds: ['a', 'b'], countError: new Error('unsupported') });

const documents = await fetchDocumentsPage(db, { collectionPath: 'col', limit: 50, startAfter: null });

expect(documents.map((d) => d.id)).toEqual(['a', 'b']);
});

it('applies the startAfter cursor to the query', async () => {
const { db, query, collection } = mockDb({ docs: [snapshotDoc('b')], listedIds: ['b'] });

await fetchDocumentsPage(db, { collectionPath: 'col', limit: 50, startAfter: 'a' });

expect(collection.doc).toHaveBeenCalledWith('a');
expect(query.startAfter).toHaveBeenCalled();
});
});
79 changes: 79 additions & 0 deletions electron/controllers/firestore/recursiveDeleteRest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
const BATCH_SIZE = 500;

function toRestError(result) {
if (!result.ok) {
const error = new Error(result.error?.error || 'Authentication failed');
error.ipcResult = result.error;
return error;
}
const apiError = result.data.error;
const error = new Error(apiError.message);
error.ipcResult = { success: false, error: apiError.message, requiresReauth: apiError.code === 401 };
return error;
}

async function fetchJson(authenticatedFetch, url, options = {}) {
const result = await authenticatedFetch(url, options);
if (!result.ok || result.data?.error) throw toRestError(result);
return result.data || {};
}

function postJson(authenticatedFetch, url, body) {
return fetchJson(authenticatedFetch, url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}

function relativeDocPath(resourceName) {
return resourceName.split('/documents/')[1];
}

async function listCollectionIds({ authenticatedFetch, urlRoot }, docPath) {
const ids = [];
let pageToken;
do {
const data = await postJson(
authenticatedFetch,
`${urlRoot}/${docPath}:listCollectionIds`,
pageToken ? { pageToken } : {},
);
ids.push(...(data.collectionIds || []));
pageToken = data.nextPageToken;
} while (pageToken);
return ids;
}

async function listDocumentPaths({ authenticatedFetch, urlRoot }, collectionPath) {
const paths = [];
let pageToken;
do {
const query = new URLSearchParams({ pageSize: '300', showMissing: 'true' });
if (pageToken) query.set('pageToken', pageToken);
const data = await fetchJson(authenticatedFetch, `${urlRoot}/${collectionPath}?${query}`);
paths.push(...(data.documents || []).map((doc) => relativeDocPath(doc.name)));
pageToken = data.nextPageToken;
} while (pageToken);
return paths;
}

async function collectTreePaths(ctx, docPath, acc) {
for (const collectionId of await listCollectionIds(ctx, docPath)) {
for (const childPath of await listDocumentPaths(ctx, `${docPath}/${collectionId}`)) {
await collectTreePaths(ctx, childPath, acc);
}
}
acc.push(docPath);
return acc;
}

async function deleteDocumentTree({ authenticatedFetch, urlRoot, nameRoot, docPath }) {
const paths = await collectTreePaths({ authenticatedFetch, urlRoot }, docPath, []);
for (let start = 0; start < paths.length; start += BATCH_SIZE) {
const writes = paths.slice(start, start + BATCH_SIZE).map((path) => ({ delete: `${nameRoot}/${path}` }));
await postJson(authenticatedFetch, `${urlRoot}:commit`, { writes });
}
}

module.exports = { deleteDocumentTree, listCollectionIds };
73 changes: 73 additions & 0 deletions electron/controllers/firestore/recursiveDeleteRest.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// @vitest-environment node
import { describe, it, expect, vi } from 'vitest';
import { createRequire } from 'module';

const require_ = createRequire(import.meta.url);
const { deleteDocumentTree } = require_('./recursiveDeleteRest');

const URL_ROOT = 'https://firestore.googleapis.com/v1/projects/p/databases/(default)/documents';
const NAME_ROOT = 'projects/p/databases/(default)/documents';

function fakeFirestore(tree) {
const commits = [];
const authenticatedFetch = vi.fn(async (url, options = {}) => {
if (url === `${URL_ROOT}:commit`) {
commits.push(JSON.parse(options.body).writes.map((w) => w.delete));
return { ok: true, data: {} };
}
const listMatch = url.match(/\/documents\/(.+):listCollectionIds$/);
if (listMatch) {
const node = tree[listMatch[1]] || {};
return { ok: true, data: { collectionIds: Object.keys(node) } };
}
const collectionPath = url.replace(`${URL_ROOT}/`, '').split('?')[0];
const segments = collectionPath.split('/');
const docPath = segments.slice(0, -1).join('/');
const collectionId = segments[segments.length - 1];
const childIds = docPath ? (tree[docPath]?.[collectionId] ?? []) : (tree['']?.[collectionId] ?? []);
return {
ok: true,
data: { documents: childIds.map((id) => ({ name: `${NAME_ROOT}/${collectionPath}/${id}` })) },
};
});
return { authenticatedFetch, commits };
}

describe('deleteDocumentTree', () => {
it('deletes descendants before the root document', async () => {
const { authenticatedFetch, commits } = fakeFirestore({
'col/doc1': { sub: ['child1', 'child2'] },
'col/doc1/sub/child1': {},
'col/doc1/sub/child2': {},
});

await deleteDocumentTree({ authenticatedFetch, urlRoot: URL_ROOT, nameRoot: NAME_ROOT, docPath: 'col/doc1' });

expect(commits.flat()).toEqual([
`${NAME_ROOT}/col/doc1/sub/child1`,
`${NAME_ROOT}/col/doc1/sub/child2`,
`${NAME_ROOT}/col/doc1`,
]);
});

it('deletes a leaf document without subcollections', async () => {
const { authenticatedFetch, commits } = fakeFirestore({ 'col/doc1': {} });

await deleteDocumentTree({ authenticatedFetch, urlRoot: URL_ROOT, nameRoot: NAME_ROOT, docPath: 'col/doc1' });

expect(commits.flat()).toEqual([`${NAME_ROOT}/col/doc1`]);
});

it('surfaces API errors with reauth metadata', async () => {
const authenticatedFetch = vi
.fn()
.mockResolvedValue({ ok: true, data: { error: { code: 401, message: 'Unauthenticated' } } });

await expect(
deleteDocumentTree({ authenticatedFetch, urlRoot: URL_ROOT, nameRoot: NAME_ROOT, docPath: 'col/doc1' }),
).rejects.toMatchObject({
message: 'Unauthenticated',
ipcResult: { success: false, error: 'Unauthenticated', requiresReauth: true },
});
});
});
Loading