// 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} */ let storage; beforeEach(() => { vi.useFakeTimers(); storage = /** @type {Required} */ ( 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); }); });