chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// A drive held in a permissioned space. The space side never touches the
// public repo: it reads through the injected reader and writes through the
// injected space commit, so a private drive stays out of the public firehose.
import { describe, expect, it } from 'vitest';
import { createDriveBrowser } from '../src/browser.js';
import { createDriveWriter } from '../src/writer.js';
const DID = 'did:plc:drivespace';
const SPACE = `at://${DID}/space/com.example.team/main`;
const CHUNK = 32;
function harness({ withSpace = true } = {}) {
/** @type {Map<string, {cid: string, value: any}>} keyed space#collection#rkey */
const spaceRecords = new Map();
/** @type {Map<string, Uint8Array>} */
const blobData = new Map();
/** @type {Array<{space: string, writes: any[]}>} */
const spaceCommits = [];
/** @type {string[]} */
const repoWrites = [];
/** @type {Array<{cid: string, uri: string}>} */
const links = [];
/** @type {string[]} */
const unlinked = [];
const actorStorage = /** @type {any} */ ({
getDid: async () => DID,
getRecord: async () => null,
listRecords: async () => ({ records: [], cursor: null }),
putBlock: async () => {},
putRecord: async (/** @type {string} */ uri) => {
repoWrites.push(uri);
},
putBlob: async () => {},
linkBlobToRecord: async (
/** @type {string} */ cid,
/** @type {string} */ uri,
) => {
links.push({ cid, uri });
},
deleteRecord: async () => {},
unlinkBlobsFromRecord: async (/** @type {string} */ uri) => {
unlinked.push(uri);
},
});
const blobs = /** @type {any} */ ({
put: async (
/** @type {string} */ _did,
/** @type {string} */ cid,
/** @type {Uint8Array} */ data,
) => {
blobData.set(cid, data);
},
get: async () => null,
});
const spaceReader = {
listRecords: async (
/** @type {string} */ space,
/** @type {string} */ collection,
) => ({
records: [...spaceRecords.entries()]
.filter(([key]) => key.startsWith(`${space}#${collection}#`))
.map(([key, row]) => ({
rkey: key.split('#')[2],
cid: row.cid,
value: row.value,
})),
}),
getRecord: async (
/** @type {string} */ space,
/** @type {string} */ rkey,
) => spaceRecords.get(`${space}#dev.pdsjs.drive.file#${rkey}`) ?? null,
getBlob: async (/** @type {string} */ cid) => blobData.get(cid) ?? null,
};
/** Stands in for applyWrites: records the commit and keeps the value. */
const spaceWrite = async (
/** @type {string} */ space,
/** @type {any[]} */ writes,
) => {
spaceCommits.push({ space, writes });
const results = [];
for (const write of writes) {
const key = `${space}#${write.collection}#${write.rkey}`;
if (write.action === 'delete') {
if (!spaceRecords.has(key)) throw new Error('RecordNotFound');
spaceRecords.delete(key);
results.push({ cid: null });
continue;
}
const cid = `bafyspace${spaceRecords.size}`;
spaceRecords.set(key, { cid, value: write.record });
results.push({ cid });
}
return { rev: '3rev', results };
};
const ports = { actorStorage, blobs, getDid: async () => DID };
return {
browser: createDriveBrowser({
...ports,
spaceReader: withSpace ? spaceReader : undefined,
}),
writer: createDriveWriter({
...ports,
createCommit: async () => ({}),
chunkSize: CHUNK,
spaceReader: withSpace ? spaceReader : undefined,
spaceWrite: withSpace ? spaceWrite : undefined,
}),
spaceCommits,
spaceRecords,
repoWrites,
links,
unlinked,
};
}
/** @param {Uint8Array|string} data */
function streamOf(data) {
const bytes =
typeof data === 'string' ? new TextEncoder().encode(data) : data;
return new ReadableStream({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
}
describe('a drive in a space', () => {
it('commits into the space and never into the public repo', async () => {
const { writer, spaceCommits, repoWrites } = harness();
const row = await writer.createFile({
name: 'secret.txt',
folder: null,
mimeType: 'text/plain',
body: streamOf('for the team'),
space: SPACE,
});
expect(row.uri).toBe(`${SPACE}/${DID}/dev.pdsjs.drive.file/${row.rkey}`);
expect(repoWrites).toEqual([]);
expect(spaceCommits).toHaveLength(1);
expect(spaceCommits[0].space).toBe(SPACE);
expect(spaceCommits[0].writes[0]).toMatchObject({
collection: 'dev.pdsjs.drive.file',
rkey: row.rkey,
});
});
it('carries blob refs the space write path can link', async () => {
const { writer, spaceCommits } = harness();
await writer.createFile({
name: 'a.txt',
folder: null,
mimeType: 'text/plain',
body: streamOf('hello'),
space: SPACE,
});
// applyWrites finds a part through findBlobRefs, which reads `$link` and
// not a CID instance. A part written any other way would go unlinked and
// be reaped out from under the record.
const record = spaceCommits[0].writes[0].record;
expect(typeof record.parts[0].ref.$link).toBe('string');
});
it('links every part to the space record, so the reap and getBlob see it', async () => {
const { writer, links } = harness();
const data = new Uint8Array(CHUNK * 2 + 1).fill(3);
const row = await writer.createFile({
name: 'big.bin',
folder: null,
mimeType: 'application/octet-stream',
body: streamOf(data),
space: SPACE,
});
// applyWrites advances the set hash but indexes no blobs: only the space
// write route does that. An unlinked part is reaped, and until then the
// public getBlob endpoint serves it, because that endpoint only withholds
// a blob whose every link is a space record.
expect(links).toHaveLength(row.partCount);
for (const link of links) {
expect(link.uri).toBe(`${SPACE}/${DID}/dev.pdsjs.drive.file/${row.rkey}`);
}
});
it('makes folders inside the space too', async () => {
const { writer, browser } = harness();
const folder = await writer.createFolder({
name: 'plans',
parent: null,
space: SPACE,
});
const row = await writer.createFile({
name: 'notes.txt',
folder: folder.rkey,
mimeType: 'text/plain',
body: streamOf('team notes'),
space: SPACE,
});
const listing = await browser.listFolder(folder.rkey, SPACE);
expect(listing?.space).toBe(SPACE);
expect(listing?.path).toBe('plans');
expect(listing?.files.map((file) => file.name)).toEqual(['notes.txt']);
const file = await browser.readFile(row.rkey, SPACE);
expect(new TextDecoder().decode(file?.bytes)).toBe('team notes');
});
it('splits a large space file the same way', async () => {
const { writer, browser } = harness();
const data = new Uint8Array(CHUNK * 2 + 3).fill(7);
const row = await writer.createFile({
name: 'big.bin',
folder: null,
mimeType: 'application/octet-stream',
body: streamOf(data),
space: SPACE,
});
expect(row.partCount).toBe(3);
expect((await browser.readFile(row.rkey, SPACE))?.bytes).toEqual(data);
});
it('serves a byte range from a space file', async () => {
const { writer, browser } = harness();
const row = await writer.createFile({
name: 'ranged.txt',
folder: null,
mimeType: 'text/plain',
body: streamOf('0123456789'),
space: SPACE,
});
const part = await browser.readRange(row.rkey, 2, 5, SPACE);
expect(new TextDecoder().decode(part?.bytes)).toBe('2345');
});
it('keeps the space drive apart from the public one', async () => {
const { writer, browser } = harness();
await writer.createFile({
name: 'private.txt',
folder: null,
mimeType: 'text/plain',
body: streamOf('x'),
space: SPACE,
});
// The public listing reads the repo, which this write never touched.
expect((await browser.listFolder(''))?.files).toEqual([]);
expect((await browser.listFolder('', SPACE))?.files).toHaveLength(1);
});
it('trashes through the space commit, keeping the parts linked', async () => {
const { writer, browser, spaceRecords, unlinked } = harness();
const row = await writer.createFile({
name: 'gone.txt',
folder: null,
mimeType: 'text/plain',
body: streamOf('x'),
space: SPACE,
});
const linkedOnUpload = unlinked.length;
expect(await writer.deleteFile(row.rkey, SPACE)).toBe(true);
// The record stays, carrying deletedAt, and no part is unlinked: a space
// drive comes back out of the trash the same way the repo one does.
expect(spaceRecords.size).toBe(1);
expect(unlinked.length).toBe(linkedOnUpload);
expect((await browser.listFolder('', SPACE))?.files).toEqual([]);
expect((await browser.listTrash(SPACE))?.items.map((i) => i.name)).toEqual([
'gone.txt',
]);
// A record that is not there reads as a miss, not an error.
expect(await writer.deleteFile('nosuch', SPACE)).toBe(false);
});
it('frees the bytes only when the trash is emptied', async () => {
const { writer, browser, spaceRecords, unlinked } = harness();
const row = await writer.createFile({
name: 'gone.txt',
folder: null,
mimeType: 'text/plain',
body: streamOf('x'),
space: SPACE,
});
await writer.deleteFile(row.rkey, SPACE);
expect(await writer.purge(row.rkey, SPACE)).toBe(1);
expect(spaceRecords.size).toBe(0);
expect(unlinked).toContain(
`${SPACE}/${DID}/dev.pdsjs.drive.file/${row.rkey}`,
);
expect((await browser.listTrash(SPACE))?.items).toEqual([]);
});
it('refuses a space when the server composes no space write path', async () => {
const { writer } = harness({ withSpace: false });
await expect(
writer.createFile({
name: 'a.txt',
folder: null,
mimeType: 'text/plain',
body: streamOf('x'),
space: SPACE,
}),
).rejects.toThrow(/cannot write to a space/);
});
it('reports no listing for a space it cannot read', async () => {
const { browser } = harness({ withSpace: false });
expect(await browser.listFolder('', SPACE)).toBe(null);
expect(await browser.readFile('any', SPACE)).toBe(null);
});
});