~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
367 lines · 10.6 KB Raw History
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);
  });
});