chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import {
CID,
cborEncodeDagCbor,
cidToString,
createBlobCid,
createCid,
} from '@pdsjs/core/repo';
import { describe, expect, it } from 'vitest';
import { createDriveBrowser } from '../src/browser.js';
const DID = 'did:plc:drivetest';
/**
* @typedef {Object} FolderSpec
* @property {string} rkey
* @property {string} name
* @property {string} [parent]
*/
/**
* @typedef {Object} FileSpec
* @property {string} rkey
* @property {string} name
* @property {string} [folder]
* @property {string} [mimeType]
* @property {Uint8Array[]} parts
*/
/**
* @param {{folders?: FolderSpec[], files?: FileSpec[]}} tree
*/
async function harnessWith({ folders = [], files = [] }) {
/** @type {Array<{uri: string, cid: string, value: Uint8Array}>} */
const rows = [];
/** @type {Map<string, Uint8Array>} */
const blobData = new Map();
for (const folder of folders) {
const value = cborEncodeDagCbor({
$type: 'dev.pdsjs.drive.folder',
name: folder.name,
...(folder.parent ? { parent: folder.parent } : {}),
createdAt: '2026-08-14T00:00:00.000Z',
});
rows.push({
uri: `at://${DID}/dev.pdsjs.drive.folder/${folder.rkey}`,
cid: cidToString(await createCid(value)),
value,
});
}
for (const file of files) {
const parts = [];
for (const data of file.parts) {
const cid = cidToString(await createBlobCid(data));
blobData.set(cid, data);
parts.push({
$type: 'blob',
ref: new CID(await createBlobCid(data)),
mimeType: 'application/octet-stream',
size: data.length,
});
}
const value = cborEncodeDagCbor({
$type: 'dev.pdsjs.drive.file',
name: file.name,
...(file.folder ? { folder: file.folder } : {}),
parts,
mimeType: file.mimeType ?? 'text/plain',
createdAt: '2026-08-14T00:00:00.000Z',
});
rows.push({
uri: `at://${DID}/dev.pdsjs.drive.file/${file.rkey}`,
cid: cidToString(await createCid(value)),
value,
});
}
const actorStorage = /** @type {any} */ ({
getDid: async () => DID,
listRecords: async (
/** @type {string} */ collection,
/** @type {string|null} */ cursor,
/** @type {number} */ limit,
) => {
const matching = rows.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,
};
},
getRecord: async (/** @type {string} */ uri) =>
rows.find((row) => row.uri === uri) ?? null,
});
const blobs = /** @type {any} */ ({
get: async (/** @type {string} */ _did, /** @type {string} */ cid) => {
const data = blobData.get(cid);
return data ? { data, mimeType: 'application/octet-stream' } : null;
},
});
return {
browser: createDriveBrowser({
actorStorage,
blobs,
getDid: async () => DID,
}),
blobData,
};
}
const bytesOf = (/** @type {string} */ text) => new TextEncoder().encode(text);
describe('createDriveBrowser', () => {
it('throws on missing ctx entries', () => {
expect(() => createDriveBrowser(/** @type {any} */ ({}))).toThrow(
/actorStorage/,
);
expect(() =>
createDriveBrowser(/** @type {any} */ ({ actorStorage: {} })),
).toThrow(/blobs/);
expect(() =>
createDriveBrowser(/** @type {any} */ ({ actorStorage: {}, blobs: {} })),
).toThrow(/getDid/);
});
});
describe('listFolder', () => {
const tree = {
folders: [
{ rkey: 'fphotos', name: 'photos' },
{ rkey: 'f2026', name: '2026', parent: 'fphotos' },
],
files: [
{ rkey: 'a', name: 'top.txt', parts: [bytesOf('hi')] },
{
rkey: 'b',
name: 'shot.jpg',
folder: 'fphotos',
parts: [bytesOf('12345')],
},
{ rkey: 'c', name: 'deep.txt', folder: 'f2026', parts: [bytesOf('abc')] },
],
};
it('separates the files in a folder from the folders under it', async () => {
const { browser } = await harnessWith(tree);
const root = await browser.listFolder('');
expect(root?.folder).toBe('');
expect(root?.path).toBe('');
expect(root?.files.map((file) => file.name)).toEqual(['top.txt']);
// A folder's tally counts everything beneath it, at any depth.
expect(root?.folders).toEqual([
{
rkey: 'fphotos',
name: 'photos',
files: 2,
size: 8,
createdAt: '2026-08-14T00:00:00.000Z',
},
]);
expect(root?.totalFiles).toBe(3);
expect(root?.totalSize).toBe(10);
const photos = await browser.listFolder('fphotos');
expect(photos?.files.map((file) => file.name)).toEqual(['shot.jpg']);
expect(photos?.folders.map((folder) => folder.name)).toEqual(['2026']);
});
it('reports the path and ancestry a breadcrumb needs', async () => {
const { browser } = await harnessWith(tree);
const listing = await browser.listFolder('f2026');
expect(listing?.path).toBe('photos/2026');
expect(listing?.ancestry).toEqual([
{ rkey: 'fphotos', name: 'photos' },
{ rkey: 'f2026', name: '2026' },
]);
});
it('reports a folder that is not there as no listing', async () => {
const { browser } = await harnessWith(tree);
expect(await browser.listFolder('nosuchfolder')).toBe(null);
});
it('keeps an empty folder, which is the point of it being a record', async () => {
const { browser } = await harnessWith({
folders: [{ rkey: 'fempty', name: 'empty' }],
});
const root = await browser.listFolder('');
expect(root?.folders).toEqual([
{
rkey: 'fempty',
name: 'empty',
files: 0,
size: 0,
createdAt: '2026-08-14T00:00:00.000Z',
},
]);
expect((await browser.listFolder('fempty'))?.files).toEqual([]);
});
it('sums the part sizes without reading a blob', async () => {
const { browser, blobData } = await harnessWith({
files: [
{
rkey: 'a',
name: 'big.bin',
parts: [new Uint8Array(400), new Uint8Array(600)],
},
],
});
// A read would have to go through this map, so emptying it proves the
// listing never touches blob storage.
blobData.clear();
const listing = await browser.listFolder('');
expect(listing?.files[0].size).toBe(1000);
expect(listing?.files[0].partCount).toBe(2);
});
it('lists a file whose folder is gone at the top level', async () => {
const { browser } = await harnessWith({
files: [
{
rkey: 'a',
name: 'orphan.txt',
folder: 'vanished',
parts: [bytesOf('x')],
},
],
});
const listing = await browser.listFolder('');
expect(listing?.files.map((file) => file.name)).toEqual(['orphan.txt']);
});
it('orders by name', async () => {
const { browser } = await harnessWith({
files: [
{ rkey: 'a', name: 'zebra.txt', parts: [bytesOf('x')] },
{ rkey: 'b', name: 'apple.txt', parts: [bytesOf('x')] },
],
});
const listing = await browser.listFolder('');
expect(listing?.files.map((file) => file.name)).toEqual([
'apple.txt',
'zebra.txt',
]);
});
});
describe('readFile', () => {
it('joins the parts in order', async () => {
const { browser } = await harnessWith({
files: [
{
rkey: 'a',
name: 'split.txt',
mimeType: 'text/plain',
parts: [bytesOf('hello '), bytesOf('world')],
},
],
});
const file = await browser.readFile('a');
expect(new TextDecoder().decode(file?.bytes)).toBe('hello world');
expect(file?.size).toBe(11);
expect(file?.name).toBe('split.txt');
expect(file?.mimeType).toBe('text/plain');
});
it('reads nothing for an unknown record', async () => {
const { browser } = await harnessWith({});
expect(await browser.readFile('missing')).toBe(null);
});
it('refuses the whole file when one part is gone', async () => {
const { browser, blobData } = await harnessWith({
files: [
{
rkey: 'a',
name: 'split.txt',
parts: [bytesOf('first'), bytesOf('second')],
},
],
});
const [firstCid] = [...blobData.keys()];
blobData.delete(firstCid);
expect(await browser.readFile('a')).toBe(null);
});
});
describe('statFile', () => {
it('reports the length without reading a part', async () => {
const { browser, blobData } = await harnessWith({
files: [
{
rkey: 'a',
name: 'big.bin',
mimeType: 'application/zip',
parts: [new Uint8Array(100), new Uint8Array(50)],
},
],
});
blobData.clear();
expect(await browser.statFile('a')).toEqual({
name: 'big.bin',
mimeType: 'application/zip',
size: 150,
});
});
it('reports nothing for an unknown record', async () => {
const { browser } = await harnessWith({});
expect(await browser.statFile('missing')).toBe(null);
});
});
describe('readRange', () => {
/** Three parts spelling out a known string, so offsets are checkable. */
const split = {
files: [
{
rkey: 'a',
name: 'split.txt',
mimeType: 'text/plain',
parts: [bytesOf('AAAA'), bytesOf('BBBB'), bytesOf('CCCC')],
},
],
};
it('returns a range that spans several parts', async () => {
const { browser } = await harnessWith(split);
const part = await browser.readRange('a', 2, 9);
expect(new TextDecoder().decode(part?.bytes)).toBe('AABBBBCC');
expect(part?.start).toBe(2);
expect(part?.end).toBe(9);
expect(part?.total).toBe(12);
});
it('reads only the parts a range touches', async () => {
const { browser, blobData } = await harnessWith(split);
// Drop the first and last parts: a range inside the middle one must not
// need them, which is what keeps a large file off the heap.
const cids = [...blobData.keys()];
blobData.delete(cids[0]);
blobData.delete(cids[2]);
const part = await browser.readRange('a', 4, 7);
expect(new TextDecoder().decode(part?.bytes)).toBe('BBBB');
});
it('clamps a range that runs past the end', async () => {
const { browser } = await harnessWith(split);
const part = await browser.readRange('a', 8, 999);
expect(new TextDecoder().decode(part?.bytes)).toBe('CCCC');
expect(part?.end).toBe(11);
});
it('refuses a range that starts past the end', async () => {
const { browser } = await harnessWith(split);
expect(await browser.readRange('a', 99, 200)).toBe(null);
});
});