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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
276 lines · 8.8 KB Raw History
// 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);
  });
});