// A migrated account is the failure case here: loadRepositoryFromCar imports // records without populating the record_blobs link table, so getOrphanedBlobs // reports every referenced blob as an orphan. Cleanup must cross-check the // blobs actually referenced by record content before deleting, or it silently // destroys the account's blobs a day after migration. import { PersonalDataServer } from '@pdsjs/core'; import { describe, expect, test } from 'vitest'; const DID = 'did:plc:blobcleanupaaaaaaaaaaaaa'; const REFERENCED = 'bafkreireferencedblobaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const ORPHAN = 'bafkreiorphanblobaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; /** * Storage that mimics a freshly migrated account: a profile record references * REFERENCED, but the orphan query (which trusts the empty link table) reports * BOTH blobs as orphaned. * @param {string[]} deleted - collects cids passed to deleteBlob * @param {{cid: string, uri: string}[]} linked - collects rebuilt blob links */ function migratedStorage(deleted, linked) { const profileUri = `at://${DID}/app.bsky.actor.profile/self`; return { async getDid() { return DID; }, async getOrphanedBlobs() { return [REFERENCED, ORPHAN]; }, async linkBlobToRecord( /** @type {string} */ cid, /** @type {string} */ uri, ) { linked.push({ cid, uri }); }, async listAllRecords() { return [{ key: 'app.bsky.actor.profile/self', cid: 'bafyprofile' }]; }, async getRecord(/** @type {string} */ uri) { if (uri !== profileUri) return null; return { cid: 'bafyprofile', value: { $type: 'app.bsky.actor.profile', avatar: { $type: 'blob', ref: { $link: REFERENCED }, mimeType: 'image/jpeg', size: 100, }, }, }; }, async deleteBlob(/** @type {string} */ cid) { deleted.push(cid); }, }; } describe('orphaned-blob cleanup', () => { test('never deletes a blob a record still references', async () => { /** @type {string[]} */ const deleted = []; /** @type {{cid: string, uri: string}[]} */ const linked = []; const pds = new PersonalDataServer({ actorStorage: /** @type {any} */ (migratedStorage(deleted, linked)), sharedStorage: /** @type {any} */ ({}), blobs: { async get() { return null; }, async put() {}, async delete(/** @type {string} */ _did, /** @type {string} */ cid) { deleted.push(cid); }, }, jwtSecret: 'test-secret', hostname: 'pds.example.com', password: 'pw', }); await pds.runBlobCleanup(); // The referenced blob survives; only the true orphan is reaped. expect(deleted).not.toContain(REFERENCED); expect(deleted).toContain(ORPHAN); // And the cleanup healed the link table on its way through. expect(linked).toEqual([ { cid: REFERENCED, uri: `at://${DID}/app.bsky.actor.profile/self` }, ]); }); test('a reap limited to some blobs leaves every other orphan', async () => { /** @type {string[]} */ const deleted = []; const pds = new PersonalDataServer({ actorStorage: /** @type {any} */ (migratedStorage(deleted, [])), sharedStorage: /** @type {any} */ ({}), blobs: { async get() { return null; }, async put() {}, async delete() {}, }, jwtSecret: 'test-secret', hostname: 'pds.example.com', password: 'pw', }); await pds.reapOrphanedBlobs(DID, Date.now(), ['bafkreinotanorphan']); expect(deleted).toEqual([]); await pds.reapOrphanedBlobs(DID, Date.now(), [ORPHAN, REFERENCED]); expect(deleted).toEqual([ORPHAN]); }); });