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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
210 lines · 7.5 KB Raw History
// The Files page reads blobs through listBlobDetails and getBlobFacets:
// metadata plus referencing records, filterable by kind, app, and usage,
// ordered newest-first or largest-first with a keyset cursor.

import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createActorStorage } from '../src/index.js';

/** A TID rkey for the given epoch ms, clock id 0. @param {number} ms */
function tid(ms) {
  const chars = '234567abcdefghijklmnopqrstuvwxyz';
  let value = BigInt(ms) * 1000n * 1024n;
  let out = '';
  for (let i = 0; i < 13; i++) {
    out = chars[Number(value % 32n)] + out;
    value /= 32n;
  }
  return out;
}

describe('listBlobDetails', () => {
  /** @type {Required<import('@pdsjs/core/ports').ActorStoragePort>} */
  let storage;

  beforeEach(() => {
    vi.useFakeTimers();
    storage =
      /** @type {Required<import('@pdsjs/core/ports').ActorStoragePort>} */ (
        createActorStorage(new Database(':memory:'))
      );
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  const seed = async () => {
    vi.setSystemTime(1000);
    await storage.putBlob('cid-a', 'image/jpeg', 100);
    vi.setSystemTime(2000);
    await storage.putBlob('cid-b', 'video/mp4', 300);
    vi.setSystemTime(3000);
    await storage.putBlob('cid-c', 'image/png', 50);
    await storage.linkBlobToRecord('cid-a', 'at://did:x/app.bsky.feed.post/1');
    await storage.linkBlobToRecord('cid-a', 'at://did:x/app.bsky.feed.post/2');
    await storage.linkBlobToRecord('cid-c', 'at://did:x/social.grain.photo/3');
  };

  it('lists newest first with metadata and referencing records', async () => {
    await seed();
    const { blobs, cursor } = await storage.listBlobDetails(null, 10);
    expect(cursor).toBe(null);
    expect(blobs.map((b) => b.cid)).toEqual(['cid-c', 'cid-b', 'cid-a']);
    expect(blobs[2]).toEqual({
      cid: 'cid-a',
      mimeType: 'image/jpeg',
      size: 100,
      createdAt: 1000,
      // Non-TID rkeys carry no time; falls back to when the blob was stored.
      time: 1000,
      timeFromRecord: false,
      records: [
        'at://did:x/app.bsky.feed.post/1',
        'at://did:x/app.bsky.feed.post/2',
      ],
    });
    // A blob nothing links to still lists, with no records.
    expect(blobs[1].records).toEqual([]);
  });

  it('pages with a keyset cursor across equal timestamps', async () => {
    vi.setSystemTime(5000);
    for (const cid of ['cid-1', 'cid-2', 'cid-3']) {
      await storage.putBlob(cid, 'image/jpeg', 10);
    }
    const first = await storage.listBlobDetails(null, 2);
    expect(first.blobs.map((b) => b.cid)).toEqual(['cid-3', 'cid-2']);
    expect(first.cursor).toBe('5000:cid-2');
    const second = await storage.listBlobDetails(first.cursor, 2);
    expect(second.blobs.map((b) => b.cid)).toEqual(['cid-1']);
    expect(second.cursor).toBe(null);
  });

  it('orders by the referencing record time, not arrival time', async () => {
    // A migrated repo: blobs stored during the import (same arrival time),
    // while their records keep the original TID timestamps.
    vi.setSystemTime(5000);
    await storage.putBlob('cid-old-post', 'image/jpeg', 10);
    await storage.putBlob('cid-new-post', 'image/jpeg', 10);
    await storage.linkBlobToRecord(
      'cid-old-post',
      `at://did:x/social.grain.photo/${tid(1000)}`,
    );
    await storage.linkBlobToRecord(
      'cid-new-post',
      `at://did:x/social.grain.photo/${tid(3000)}`,
    );
    vi.setSystemTime(2000);
    await storage.putBlob('cid-unlinked', 'image/jpeg', 10);

    const first = await storage.listBlobDetails(null, 2);
    expect(first.blobs.map((b) => [b.cid, b.time])).toEqual([
      ['cid-new-post', 3000],
      ['cid-unlinked', 2000],
    ]);
    expect(first.cursor).toBe('2000:cid-unlinked');
    const second = await storage.listBlobDetails(first.cursor, 2);
    expect(second.blobs.map((b) => [b.cid, b.time])).toEqual([
      ['cid-old-post', 1000],
    ]);
  });

  it("prefers the record's stored timestamp over its TID and arrival", async () => {
    vi.setSystemTime(5000);
    await storage.putBlob('cid-x', 'image/jpeg', 10);
    await storage.putBlob('cid-y', 'image/jpeg', 10);
    // A self-keyed record (no TID) whose value carried createdAt 1500.
    await storage.linkBlobToRecord(
      'cid-x',
      'at://did:x/actor.rpg.sprite/self',
      1500,
    );
    // A TID-keyed record: the stored record time still wins over the TID.
    await storage.linkBlobToRecord(
      'cid-y',
      `at://did:x/app.test.photo/${tid(4000)}`,
      2500,
    );

    const { blobs } = await storage.listBlobDetails(null, 10);
    expect(blobs.map((b) => [b.cid, b.time, b.timeFromRecord])).toEqual([
      ['cid-y', 2500, true],
      ['cid-x', 1500, true],
    ]);
  });

  it('sorts largest first, with a size-keyed cursor', async () => {
    await seed();
    const first = await storage.listBlobDetails(null, 2, { sort: 'largest' });
    expect(first.blobs.map((b) => b.cid)).toEqual(['cid-b', 'cid-a']);
    expect(first.cursor).toBe('100:cid-a');
    const second = await storage.listBlobDetails(first.cursor, 2, {
      sort: 'largest',
    });
    expect(second.blobs.map((b) => b.cid)).toEqual(['cid-c']);
  });

  it('filters by media kind, including the other bucket', async () => {
    await seed();
    vi.setSystemTime(4000);
    await storage.putBlob('cid-d', 'application/pdf', 70);
    const images = await storage.listBlobDetails(null, 10, { kind: 'image' });
    expect(images.blobs.map((b) => b.cid)).toEqual(['cid-c', 'cid-a']);
    const other = await storage.listBlobDetails(null, 10, { kind: 'other' });
    expect(other.blobs.map((b) => b.cid)).toEqual(['cid-d']);
  });

  it('filters by referencing app authority', async () => {
    await seed();
    const bsky = await storage.listBlobDetails(null, 10, {
      appAuthority: 'app.bsky',
    });
    expect(bsky.blobs.map((b) => b.cid)).toEqual(['cid-a']);
    const grain = await storage.listBlobDetails(null, 10, {
      appAuthority: 'social.grain',
    });
    expect(grain.blobs.map((b) => b.cid)).toEqual(['cid-c']);
  });

  it('filters to unused blobs and composes filters', async () => {
    await seed();
    const unused = await storage.listBlobDetails(null, 10, { unused: true });
    expect(unused.blobs.map((b) => b.cid)).toEqual(['cid-b']);
    const unusedImages = await storage.listBlobDetails(null, 10, {
      unused: true,
      kind: 'image',
    });
    expect(unusedImages.blobs).toEqual([]);
  });

  it('aggregates facets by kind, app, and usage', async () => {
    await seed();
    // cid-c used by two apps: counts once in each. cid-a used twice by one
    // app: counts once there.
    await storage.linkBlobToRecord('cid-c', 'at://did:x/app.bsky.feed.post/9');
    const facets = await storage.getBlobFacets();
    expect(facets.total).toEqual({ count: 3, bytes: 450 });
    expect(facets.unused).toEqual({ count: 1, bytes: 300 });
    expect(facets.kinds).toEqual([
      { kind: 'video', count: 1, bytes: 300 },
      { kind: 'image', count: 2, bytes: 150 },
    ]);
    expect(facets.apps).toEqual([
      { authority: 'app.bsky', count: 2, bytes: 150 },
      { authority: 'social.grain', count: 1, bytes: 50 },
    ]);
  });

  it('reports empty storage as zero', async () => {
    expect(await storage.getBlobFacets()).toEqual({
      total: { count: 0, bytes: 0 },
      unused: { count: 0, bytes: 0 },
      kinds: [],
      apps: [],
    });
    const { blobs, cursor } = await storage.listBlobDetails(null, 10);
    expect(blobs).toEqual([]);
    expect(cursor).toBe(null);
  });
});