// Deleting puts a thing in the trash rather than destroying it, and a folder // goes with everything under it. The parts of a trashed file stay linked, // which is the whole reason taking it back out can give the bytes back. import { describe, expect, it } from 'vitest'; import { createDriveBrowser } from '../src/browser.js'; import { createDriveWriter } from '../src/writer.js'; const DID = 'did:plc:drivetrash'; function harness() { /** @type {Map} */ const records = new Map(); /** @type {Map} */ const blobData = new Map(); /** @type {Set} */ const linked = new Set(); /** @type {string[]} */ const unlinked = []; let commits = 0; const actorStorage = /** @type {any} */ ({ getDid: async () => DID, getRecord: async (/** @type {string} */ uri) => records.get(uri) ?? null, listRecords: async ( /** @type {string} */ collection, /** @type {string|null} */ cursor, /** @type {number} */ limit, ) => { const matching = [...records.values()].filter((row) => row.uri.includes(`/${collection}/`), ); const start = cursor ? Number(cursor) : 0; const page = matching.slice(start, start + limit); const next = start + page.length; return { records: page, cursor: next < matching.length ? String(next) : null, }; }, putBlock: async () => {}, putRecord: async ( /** @type {string} */ uri, /** @type {string} */ cid, /** @type {string} */ _collection, /** @type {string} */ _rkey, /** @type {Uint8Array} */ value, ) => { records.set(uri, { uri, cid, value }); }, deleteRecord: async (/** @type {string} */ uri) => { records.delete(uri); }, putBlob: async () => {}, linkBlobToRecord: async ( /** @type {string} */ cid, /** @type {string} */ uri, ) => { linked.add(`${uri}#${cid}`); }, unlinkBlobsFromRecord: async (/** @type {string} */ uri) => { unlinked.push(uri); for (const key of [...linked]) { if (key.startsWith(`${uri}#`)) linked.delete(key); } }, }); const blobs = /** @type {any} */ ({ put: async ( /** @type {string} */ _did, /** @type {string} */ cid, /** @type {Uint8Array} */ data, /** @type {string} */ mimeType, ) => { blobData.set(cid, { data, mimeType }); }, get: async (/** @type {string} */ _did, /** @type {string} */ cid) => blobData.get(cid) ?? null, }); const ports = { actorStorage, blobs, getDid: async () => DID }; return { writer: createDriveWriter({ ...ports, createCommit: async () => { commits += 1; return {}; }, chunkSize: 64, }), browser: createDriveBrowser(ports), linked, unlinked, records, commitCount: () => commits, }; } /** @param {string} text */ function streamOf(text) { const bytes = new TextEncoder().encode(text); return new ReadableStream({ start(controller) { controller.enqueue(bytes); controller.close(); }, }); } /** A folder holding two files, and one more beside it at the top level. */ async function seeded() { const h = harness(); const folder = await h.writer.createFolder({ name: 'trip', parent: null }); const inner = await h.writer.createFolder({ name: 'raw', parent: folder.rkey, }); const a = await h.writer.createFile({ name: 'a.txt', folder: folder.rkey, mimeType: 'text/plain', body: streamOf('aaa'), }); const b = await h.writer.createFile({ name: 'b.txt', folder: inner.rkey, mimeType: 'text/plain', body: streamOf('bbb'), }); const loose = await h.writer.createFile({ name: 'loose.txt', folder: null, mimeType: 'text/plain', body: streamOf('ccc'), }); return { ...h, folder, inner, a, b, loose }; } describe('deleting a file', () => { it('hides it but keeps the bytes, so it can come back', async () => { const { writer, browser, linked, loose } = await seeded(); const before = linked.size; expect(await writer.deleteFile(loose.rkey)).toBe(true); expect( (await browser.listFolder(''))?.files.map((f) => f.name), ).not.toContain('loose.txt'); // Nothing was unlinked, so the orphan sweep leaves the parts alone. expect(linked.size).toBe(before); // And the file still reads, which is what a restore depends on. expect( new TextDecoder().decode((await browser.readFile(loose.rkey))?.bytes), ).toBe('ccc'); }); it('lists what the trash holds, and what it still costs', async () => { const { writer, browser, a, loose } = await seeded(); await writer.deleteFile(a.rkey); await writer.deleteFile(loose.rkey); const trash = await browser.listTrash(); // Two separate deletes may or may not share a millisecond, so the set is // what this pins; the ordering within one timestamp is checked below. expect(trash?.items.map((item) => item.name).sort()).toEqual([ 'a.txt', 'loose.txt', ]); // The bytes are still there, so they still count against storage. expect(trash?.totalSize).toBe(6); }); it("orders one folder's worth by name, since they share a timestamp", async () => { const { writer, browser, folder } = await seeded(); // A cascade stamps every record with one time, so nothing else can // decide the order. await writer.deleteFolder(folder.rkey); const trash = await browser.listTrash(); expect(trash?.items.map((item) => item.name)).toEqual([ 'a.txt', 'b.txt', 'raw', 'trip', ]); }); it('comes back where it was', async () => { const { writer, browser, a, folder } = await seeded(); await writer.deleteFile(a.rkey); expect(await writer.restore(a.rkey)).toBe(1); expect( (await browser.listFolder(folder.rkey))?.files.map((f) => f.name), ).toEqual(['a.txt']); expect((await browser.listTrash())?.items).toEqual([]); }); }); describe('deleting a folder', () => { it('takes everything under it, at any depth', async () => { const { writer, browser, folder } = await seeded(); expect(await writer.deleteFolder(folder.rkey)).toBe(true); const root = await browser.listFolder(''); expect(root?.folders).toEqual([]); // The file beside it is untouched, and nothing from inside the folder // reappears loose at the top level. expect(root?.files.map((f) => f.name)).toEqual(['loose.txt']); const trash = await browser.listTrash(); expect(trash?.items.map((item) => item.name).sort()).toEqual([ 'a.txt', 'b.txt', 'raw', 'trip', ]); }); it('does it in one commit, not one per file', async () => { const { writer, folder, commitCount } = await seeded(); const before = commitCount(); await writer.deleteFolder(folder.rkey); expect(commitCount() - before).toBe(1); }); it('comes back whole', async () => { const { writer, browser, folder, inner } = await seeded(); await writer.deleteFolder(folder.rkey); expect(await writer.restore(folder.rkey)).toBe(4); expect( (await browser.listFolder(folder.rkey))?.files.map((f) => f.name), ).toEqual(['a.txt']); expect( (await browser.listFolder(inner.rkey))?.files.map((f) => f.name), ).toEqual(['b.txt']); expect((await browser.listTrash())?.items).toEqual([]); }); it('refuses to take a file when told to take a folder', async () => { const { writer, loose } = await seeded(); expect(await writer.deleteFolder(loose.rkey)).toBe(false); }); }); describe('emptying the trash', () => { it('removes the records and unlinks the parts', async () => { const { writer, browser, linked, unlinked, folder } = await seeded(); await writer.deleteFolder(folder.rkey); expect(await writer.purge(folder.rkey)).toBe(4); expect((await browser.listTrash())?.items).toEqual([]); // Unlinked, so the orphan sweep is what finally frees the bytes. expect(unlinked.some((uri) => uri.includes('dev.pdsjs.drive.file'))).toBe( true, ); // One link stands: the file beside the folder was never in the trash. expect(linked.size).toBe(1); // And the file no longer reads at all. expect(await browser.readFile('a')).toBe(null); }); it('leaves what was not in the trash alone', async () => { const { writer, browser, folder, loose } = await seeded(); await writer.deleteFolder(folder.rkey); await writer.purge(folder.rkey); expect((await browser.listFolder(''))?.files.map((f) => f.name)).toEqual([ 'loose.txt', ]); expect(await browser.readFile(loose.rkey)).not.toBe(null); }); it('reports nothing for an item that is not there', async () => { const { writer } = await seeded(); expect(await writer.purge('nosuch')).toBe(0); expect(await writer.restore('nosuch')).toBe(0); }); });