chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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<string, {uri: string, cid: string, value: Uint8Array}>} */
const records = new Map();
/** @type {Map<string, {data: Uint8Array, mimeType: string}>} */
const blobData = new Map();
/** @type {Set<string>} */
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);
});
});