// Shared behavioural contract for SpaceStoragePort implementations. // // Every adapter (in-memory, storage-sqlite, Cloudflare DO) runs this same // suite. It is the single definition of correct behaviour: if two adapters // disagree, one of them fails here rather than in an e2e run. import { beforeEach, describe, expect, it } from 'vitest'; const SPACE = 'at://did:plc:aaaa/space/com.example.forum/general'; const OTHER = 'at://did:plc:aaaa/space/com.example.forum/other'; /** @returns {import('@pdsjs/core/ports').SpaceRow} */ function spaceRow(uri = SPACE, overrides = {}) { // The domain owns space-URI parsing, so callers supply the authority and type // rather than the adapter re-deriving them. Split here the way @pdsjs/spaces // would, so fixtures stay consistent with `uri`. const segments = uri.slice('at://'.length).split('/'); return { uri, spaceDid: segments[0], spaceType: segments[2], isOwner: true, policy: { $type: 'com.atproto.memberspace.policy', publicRead: false, publicWrite: false, publishPolicy: false, publishMembers: false, }, createdAt: '2026-07-30T12:00:00.000Z', deletedAt: null, ...overrides, }; } const CID_A = 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a'; const CID_B = 'bafyreih4nsjr7wgnhqpsdvqhqmwzwbtwqrdrn7q6mdhhnbjsplqjjbgcbe'; /** * @param {string} collection * @param {string} rkey * @param {string} cid * @param {Uint8Array} [value] * @returns {import('@pdsjs/core/ports').SpaceWrite} */ function create(collection, rkey, cid, value = new Uint8Array([1, 2, 3])) { return { action: 'create', collection, rkey, cid, value, prev: null }; } /** * @param {string} name - implementation name, used in the describe block * @param {() => Promise} createStorage * must return a FRESH, EMPTY store on every call */ export function runSpaceStorageConformance(name, createStorage) { describe(`SpaceStoragePort conformance: ${name}`, () => { /** @type {import('@pdsjs/core/ports').SpaceStoragePort} */ let store; beforeEach(async () => { store = await createStorage(); }); describe('spaces', () => { it('returns null for an unknown space', async () => { expect(await store.getSpace(SPACE)).toBeNull(); }); it('round-trips a space', async () => { const row = spaceRow(); await store.putSpace(row); expect(await store.getSpace(SPACE)).toEqual(row); }); it('preserves boolean and array field types', async () => { const policy = { $type: 'com.atproto.memberspace.policy', publicRead: true, publicWrite: false, allowedApps: ['https://a.example/c.json', 'https://b.example/c.json'], publishPolicy: false, publishMembers: true, }; await store.putSpace(spaceRow(SPACE, { isOwner: false, policy })); const got = await store.getSpace(SPACE); expect(got?.isOwner).toBe(false); expect(got?.policy).toEqual(policy); }); it('keeps a null policy', async () => { await store.putSpace(spaceRow(SPACE, { isOwner: false, policy: null })); expect((await store.getSpace(SPACE))?.policy).toBeNull(); }); it('putSpace upserts rather than duplicating', async () => { await store.putSpace(spaceRow()); await store.putSpace( spaceRow(SPACE, { policy: { $type: 'com.atproto.personalspace.policy' }, }), ); expect((await store.getSpace(SPACE))?.policy).toEqual({ $type: 'com.atproto.personalspace.policy', }); const { spaces } = await store.listSpaces({ limit: 50 }); expect(spaces).toHaveLength(1); }); it('deleteSpace is a soft delete', async () => { await store.putSpace(spaceRow()); await store.deleteSpace(SPACE, '2026-07-31T00:00:00.000Z'); const got = await store.getSpace(SPACE); expect(got).not.toBeNull(); expect(got?.deletedAt).toBe('2026-07-31T00:00:00.000Z'); }); it('lists spaces and paginates by cursor', async () => { await store.putSpace(spaceRow(SPACE)); await store.putSpace(spaceRow(OTHER)); const first = await store.listSpaces({ limit: 1 }); expect(first.spaces).toHaveLength(1); expect(first.cursor).not.toBeNull(); const second = await store.listSpaces({ limit: 1, cursor: first.cursor, }); expect(second.spaces).toHaveLength(1); expect(second.spaces[0].uri).not.toBe(first.spaces[0].uri); const third = await store.listSpaces({ limit: 1, cursor: second.cursor, }); expect(third.spaces).toHaveLength(0); expect(third.cursor).toBeNull(); }); it('listSpaces leaves deleted spaces out only when asked', async () => { await store.putSpace(spaceRow(SPACE)); await store.putSpace(spaceRow(OTHER)); await store.deleteSpace(SPACE, '2026-07-31T00:00:00.000Z'); const all = await store.listSpaces({ limit: 50 }); expect(all.spaces.map((s) => s.uri).sort()).toEqual( [SPACE, OTHER].sort(), ); const live = await store.listSpaces({ limit: 50, excludeDeleted: true, }); expect(live.spaces.map((s) => s.uri)).toEqual([OTHER]); }); it('filters listSpaces by space type', async () => { await store.putSpace(spaceRow(SPACE)); await store.putSpace( spaceRow('at://did:plc:aaaa/space/com.other.kind/k'), ); const { spaces } = await store.listSpaces({ type: 'com.example.forum', limit: 50, }); expect(spaces.map((s) => s.uri)).toEqual([SPACE]); }); it('filters listSpaces by authority did', async () => { await store.putSpace(spaceRow(SPACE)); await store.putSpace( spaceRow('at://did:plc:bbbb/space/com.example.forum/g'), ); const { spaces } = await store.listSpaces({ did: 'did:plc:bbbb', limit: 50, }); expect(spaces.map((s) => s.uri)).toEqual([ 'at://did:plc:bbbb/space/com.example.forum/g', ]); }); it('filtered listSpaces pages completely across a sparse match window', async () => { // A URI is at://{did}/space/{type}/{rkey}, so filtering by `type` // under a single fixed `did` always yields one contiguous run in // keyset order — any over-fetch window big enough to reach it // returns the whole run, which can't expose short-window // truncation. Spreading one matching space across each of many // DIDs, surrounded by non-matching spaces under the same DID, // interleaves matches with non-matches throughout the keyspace: a // fixed-size over-fetch window (limit * 10 rows) then contains only // a handful of DIDs' worth of matches, fewer than `limit`, while // many more matching DIDs remain further on. An adapter that // fetches a single window per call and never re-queries for more // returns a short page with cursor: null there, silently // terminating pagination early. const N_DIDS = 20; const NON_MATCH_PER_DID = 15; const matching = []; for (let d = 0; d < N_DIDS; d++) { const did = `did:plc:d${String(d).padStart(3, '0')}`; const matchUri = `at://${did}/space/com.example.forum/m`; await store.putSpace(spaceRow(matchUri)); matching.push(matchUri); for (let n = 0; n < NON_MATCH_PER_DID; n++) { await store.putSpace( spaceRow( `at://${did}/space/com.other.kind/n${String(n).padStart(2, '0')}`, ), ); } } expect(matching).toHaveLength(20); const seen = new Set(); let cursor = null; let pages = 0; do { const page = await store.listSpaces({ type: 'com.example.forum', cursor, limit: 4, }); for (const s of page.spaces) { expect(s.uri).toContain('com.example.forum'); seen.add(s.uri); } cursor = page.cursor; pages++; expect(pages).toBeLessThan(100); // guard against an infinite loop } while (cursor !== null); expect(seen.size).toBe(20); }); it('does not let mutating a getSpace result affect a subsequent read', async () => { await store.putSpace(spaceRow()); const got = await store.getSpace(SPACE); if (!got?.policy) throw new Error('expected a space'); got.policy.publicRead = true; const again = await store.getSpace(SPACE); expect(again?.policy?.publicRead).toBe(false); }); it('does not let mutating a listSpaces result affect a subsequent read', async () => { await store.putSpace(spaceRow()); const { spaces } = await store.listSpaces({ limit: 50 }); /** @type {Record} */ (spaces[0].policy).publicRead = true; const again = await store.listSpaces({ limit: 50 }); expect(again.spaces[0].policy?.publicRead).toBe(false); }); }); describe('members', () => { const BOTH = { read: true, write: true }; it('getMember is null for an unknown did', async () => { expect(await store.getMember(SPACE, 'did:plc:zzzz')).toBeNull(); }); it('adds and reads back a member', async () => { await store.putMember(SPACE, 'did:plc:bbbb', BOTH); expect(await store.getMember(SPACE, 'did:plc:bbbb')).toEqual(BOTH); }); it('stores each right on its own', async () => { await store.putMember(SPACE, 'did:plc:bbbb', { read: true, write: false, }); expect(await store.getMember(SPACE, 'did:plc:bbbb')).toEqual({ read: true, write: false, }); }); it('putMember replaces the rights of an existing member', async () => { await store.putMember(SPACE, 'did:plc:bbbb', BOTH); await store.putMember(SPACE, 'did:plc:bbbb', { read: false, write: true, }); const { members } = await store.listMembers(SPACE, null, 50); expect(members).toEqual([ { did: 'did:plc:bbbb', read: false, write: true }, ]); }); it('removeMember is idempotent and does not throw on a missing did', async () => { await store.removeMember(SPACE, 'did:plc:nope'); await store.putMember(SPACE, 'did:plc:bbbb', BOTH); await store.removeMember(SPACE, 'did:plc:bbbb'); await store.removeMember(SPACE, 'did:plc:bbbb'); expect(await store.getMember(SPACE, 'did:plc:bbbb')).toBeNull(); }); it('removeAllMembers empties one space only', async () => { await store.putMember(SPACE, 'did:plc:bbbb', BOTH); await store.putMember(SPACE, 'did:plc:cccc', BOTH); await store.putMember(OTHER, 'did:plc:bbbb', BOTH); await store.removeAllMembers(SPACE); expect((await store.listMembers(SPACE, null, 50)).members).toEqual([]); expect(await store.getMember(OTHER, 'did:plc:bbbb')).toEqual(BOTH); }); it('scopes members per space', async () => { await store.putMember(SPACE, 'did:plc:bbbb', BOTH); expect(await store.getMember(OTHER, 'did:plc:bbbb')).toBeNull(); const { members } = await store.listMembers(OTHER, null, 50); expect(members).toEqual([]); }); it('paginates members', async () => { await store.putMember(SPACE, 'did:plc:aaa1', BOTH); await store.putMember(SPACE, 'did:plc:aaa2', BOTH); const first = await store.listMembers(SPACE, null, 1); expect(first.members).toHaveLength(1); const second = await store.listMembers(SPACE, first.cursor, 1); expect(second.members).toHaveLength(1); expect(second.members[0].did).not.toBe(first.members[0].did); const third = await store.listMembers(SPACE, second.cursor, 1); expect(third.members).toEqual([]); expect(third.cursor).toBeNull(); }); it('does not let mutating a listMembers result affect a subsequent read', async () => { await store.putMember(SPACE, 'did:plc:bbbb', BOTH); const { members } = await store.listMembers(SPACE, null, 50); members.push({ did: 'did:plc:injected', read: true, write: true }); members[0].read = false; const again = await store.listMembers(SPACE, null, 50); expect(again.members).toEqual([ { did: 'did:plc:bbbb', read: true, write: true }, ]); }); }); describe('records and commits', () => { const REV1 = '3lkaaaaaaaa2x'; const REV2 = '3lkbbbbbbbb2x'; const HASH1 = new Uint8Array(2048).fill(1); const HASH2 = new Uint8Array(2048).fill(2); const AT = '2026-07-30T12:00:00.000Z'; it('returns null for an unknown record', async () => { expect(await store.getSpaceRecord(SPACE, 'com.e.post', 'a')).toBeNull(); expect( await store.getSpaceRecordCid(SPACE, 'com.e.post', 'a'), ).toBeNull(); }); it('getSpaceRepo returns null before any write', async () => { const repo = await store.getSpaceRepo(SPACE); expect(repo === null || repo.rev === null).toBe(true); }); it('commits a create and reads it back', async () => { await store.commitSpaceWrite(SPACE, { writes: [create('com.e.post', 'a', CID_A)], setHash: HASH1, rev: REV1, indexedAt: AT, }); const row = await store.getSpaceRecord(SPACE, 'com.e.post', 'a'); if (!row) throw new Error('expected a record'); expect(row.cid).toBe(CID_A); expect(row.value).toBeInstanceOf(Uint8Array); expect([...row.value]).toEqual([1, 2, 3]); expect(row.repoRev).toBe(REV1); expect(row.indexedAt).toBe(AT); expect(await store.getSpaceRecordCid(SPACE, 'com.e.post', 'a')).toBe( CID_A, ); }); it('does not let mutating the value passed to commitSpaceWrite affect a subsequent read', async () => { const value = new Uint8Array([1, 2, 3]); await store.commitSpaceWrite(SPACE, { writes: [create('com.e.post', 'a', CID_A, value)], setHash: HASH1, rev: REV1, indexedAt: AT, }); value.fill(9); const row = await store.getSpaceRecord(SPACE, 'com.e.post', 'a'); if (!row) throw new Error('expected a record'); expect([...row.value]).toEqual([1, 2, 3]); }); it('does not let mutating a getSpaceRecord value result affect a subsequent read', async () => { await store.commitSpaceWrite(SPACE, { writes: [create('com.e.post', 'a', CID_A)], setHash: HASH1, rev: REV1, indexedAt: AT, }); const row = await store.getSpaceRecord(SPACE, 'com.e.post', 'a'); if (!row) throw new Error('expected a record'); row.value.fill(9); const again = await store.getSpaceRecord(SPACE, 'com.e.post', 'a'); if (!again) throw new Error('expected a record'); expect([...again.value]).toEqual([1, 2, 3]); }); it('stores the repo set hash as 2048 bytes', async () => { await store.commitSpaceWrite(SPACE, { writes: [create('com.e.post', 'a', CID_A)], setHash: HASH1, rev: REV1, indexedAt: AT, }); const repo = await store.getSpaceRepo(SPACE); if (!repo?.setHash) throw new Error('expected a repo with a set hash'); expect(repo.rev).toBe(REV1); expect(repo.setHash).toBeInstanceOf(Uint8Array); expect(repo.setHash.length).toBe(2048); expect(repo.setHash[0]).toBe(1); }); it('does not let mutating the setHash passed to commitSpaceWrite affect a subsequent read', async () => { const hash = new Uint8Array(2048).fill(1); await store.commitSpaceWrite(SPACE, { writes: [create('com.e.post', 'a', CID_A)], setHash: hash, rev: REV1, indexedAt: AT, }); hash.fill(9); const repo = await store.getSpaceRepo(SPACE); if (!repo?.setHash) throw new Error('expected a repo with a set hash'); expect(repo.setHash[0]).toBe(1); }); it('does not let mutating a getSpaceRepo setHash result affect a subsequent read', async () => { await store.commitSpaceWrite(SPACE, { writes: [create('com.e.post', 'a', CID_A)], setHash: HASH1, rev: REV1, indexedAt: AT, }); const repo = await store.getSpaceRepo(SPACE); if (!repo?.setHash) throw new Error('expected a repo with a set hash'); repo.setHash.fill(9); const again = await store.getSpaceRepo(SPACE); if (!again?.setHash) throw new Error('expected a repo with a set hash'); expect(again.setHash[0]).toBe(1); }); it('an update replaces the record and advances the rev', async () => { await store.commitSpaceWrite(SPACE, { writes: [create('com.e.post', 'a', CID_A)], setHash: HASH1, rev: REV1, indexedAt: AT, }); await store.commitSpaceWrite(SPACE, { writes: [ { action: 'update', collection: 'com.e.post', rkey: 'a', cid: CID_B, value: new Uint8Array([9]), prev: CID_A, }, ], setHash: HASH2, rev: REV2, indexedAt: AT, }); const row = await store.getSpaceRecord(SPACE, 'com.e.post', 'a'); if (!row) throw new Error('expected a record'); expect(row.cid).toBe(CID_B); expect(row.repoRev).toBe(REV2); const repo = await store.getSpaceRepo(SPACE); if (!repo) throw new Error('expected a repo'); expect(repo.rev).toBe(REV2); }); it('a delete removes the record', async () => { await store.commitSpaceWrite(SPACE, { writes: [create('com.e.post', 'a', CID_A)], setHash: HASH1, rev: REV1, indexedAt: AT, }); await store.commitSpaceWrite(SPACE, { writes: [ { action: 'delete', collection: 'com.e.post', rkey: 'a', cid: null, value: null, prev: CID_A, }, ], setHash: HASH2, rev: REV2, indexedAt: AT, }); expect(await store.getSpaceRecord(SPACE, 'com.e.post', 'a')).toBeNull(); }); it('listAllSpaceRecords orders lexicographically by collection/rkey', async () => { await store.commitSpaceWrite(SPACE, { writes: [ create('com.e.zeta', 'a', CID_A), create('com.e.post', 'b', CID_A), create('com.e.post', 'a', CID_A), ], setHash: HASH1, rev: REV1, indexedAt: AT, }); const all = await store.listAllSpaceRecords(SPACE); expect(all.map((r) => `${r.collection}/${r.rkey}`)).toEqual([ 'com.e.post/a', 'com.e.post/b', 'com.e.zeta/a', ]); }); /** @param {any} [opts] */ async function threeRecords(opts) { await store.commitSpaceWrite(SPACE, { writes: [ create('com.e.post', 'a', CID_A), create('com.e.post', 'b', CID_A), create('com.e.other', 'c', CID_A), ], setHash: HASH1, rev: REV1, indexedAt: AT, }); return store.listSpaceRecords(SPACE, { limit: 50, ...opts }); } it('listSpaceRecords orders by collection/rkey, newest first', async () => { const { records } = await threeRecords(); expect(records.map((r) => `${r.collection}/${r.rkey}`)).toEqual([ 'com.e.post/b', 'com.e.post/a', 'com.e.other/c', ]); }); it('listSpaceRecords reverses on request', async () => { const { records } = await threeRecords({ reverse: true }); expect(records.map((r) => `${r.collection}/${r.rkey}`)).toEqual([ 'com.e.other/c', 'com.e.post/a', 'com.e.post/b', ]); }); it('listSpaceRecords filters by collection', async () => { const { records } = await threeRecords({ collection: 'com.e.post' }); expect(records.map((r) => r.rkey)).toEqual(['b', 'a']); }); it('listSpaceRecords omits values on request', async () => { const { records } = await threeRecords({ excludeValues: true }); expect(records.every((r) => r.value === undefined)).toBe(true); expect(records.every((r) => r.cid === CID_A)).toBe(true); }); it('listSpaceRecords paginates by the sort key', async () => { const first = await threeRecords({ collection: 'com.e.post', limit: 1, }); expect(first.records.map((r) => r.rkey)).toEqual(['b']); const second = await store.listSpaceRecords(SPACE, { collection: 'com.e.post', cursor: first.cursor, limit: 1, }); expect(second.records.map((r) => r.rkey)).toEqual(['a']); const third = await store.listSpaceRecords(SPACE, { collection: 'com.e.post', cursor: second.cursor, limit: 1, }); expect(third.records).toEqual([]); expect(third.cursor).toBeNull(); }); it('listSpaceRecords paginates in reverse too', async () => { const first = await threeRecords({ reverse: true, limit: 2 }); expect(first.records.map((r) => r.rkey)).toEqual(['c', 'a']); const second = await store.listSpaceRecords(SPACE, { cursor: first.cursor, limit: 2, reverse: true, }); expect(second.records.map((r) => r.rkey)).toEqual(['b']); expect(second.cursor).toBeNull(); }); it('scopes records per space', async () => { await store.commitSpaceWrite(SPACE, { writes: [create('com.e.post', 'a', CID_A)], setHash: HASH1, rev: REV1, indexedAt: AT, }); expect(await store.getSpaceRecord(OTHER, 'com.e.post', 'a')).toBeNull(); expect(await store.listAllSpaceRecords(OTHER)).toEqual([]); }); it('records oplog entries with idx from 0 in write order', async () => { await store.commitSpaceWrite(SPACE, { writes: [ create('com.e.post', 'a', CID_A), create('com.e.post', 'b', CID_B), ], setHash: HASH1, rev: REV1, indexedAt: AT, }); const ops = await store.listSpaceOps(SPACE, null, 50); expect(ops).toHaveLength(2); expect(ops[0]).toMatchObject({ rev: REV1, idx: 0, action: 'create', collection: 'com.e.post', rkey: 'a', cid: CID_A, prev: null, }); expect(ops[1].idx).toBe(1); expect(ops[1].rkey).toBe('b'); }); it('oplog records null cid for deletes and null prev for creates', async () => { await store.commitSpaceWrite(SPACE, { writes: [create('com.e.post', 'a', CID_A)], setHash: HASH1, rev: REV1, indexedAt: AT, }); await store.commitSpaceWrite(SPACE, { writes: [ { action: 'delete', collection: 'com.e.post', rkey: 'a', cid: null, value: null, prev: CID_A, }, ], setHash: HASH2, rev: REV2, indexedAt: AT, }); const ops = await store.listSpaceOps(SPACE, null, 50); expect(ops[0].prev).toBeNull(); expect(ops[1].cid).toBeNull(); expect(ops[1].prev).toBe(CID_A); }); it('listSpaceOps since is exclusive and ordered by rev then idx', async () => { await store.commitSpaceWrite(SPACE, { writes: [create('com.e.post', 'a', CID_A)], setHash: HASH1, rev: REV1, indexedAt: AT, }); await store.commitSpaceWrite(SPACE, { writes: [create('com.e.post', 'b', CID_B)], setHash: HASH2, rev: REV2, indexedAt: AT, }); const ops = await store.listSpaceOps(SPACE, REV1, 50); expect(ops.map((o) => o.rev)).toEqual([REV2]); expect(await store.listSpaceOps(SPACE, REV2, 50)).toEqual([]); }); it('listSpaceOps respects limit', async () => { await store.commitSpaceWrite(SPACE, { writes: [ create('com.e.post', 'a', CID_A), create('com.e.post', 'b', CID_B), ], setHash: HASH1, rev: REV1, indexedAt: AT, }); expect(await store.listSpaceOps(SPACE, null, 1)).toHaveLength(1); }); it('an empty write list still advances the repo rev', async () => { await store.commitSpaceWrite(SPACE, { writes: [], setHash: HASH1, rev: REV1, indexedAt: AT, }); const repo = await store.getSpaceRepo(SPACE); if (!repo) throw new Error('expected a repo'); expect(repo.rev).toBe(REV1); expect(await store.listSpaceOps(SPACE, null, 50)).toEqual([]); }); it('a failed commit leaves all three tables unchanged', async () => { await store.commitSpaceWrite(SPACE, { writes: [create('com.e.post', 'a', CID_A)], setHash: HASH1, rev: REV1, indexedAt: AT, }); // A write whose value is not bytes must be rejected by the adapter. await expect( store.commitSpaceWrite(SPACE, { writes: [ create('com.e.post', 'b', CID_B), { action: 'create', collection: 'com.e.post', rkey: 'c', cid: CID_B, value: /** @type {any} */ ('not bytes'), prev: null, }, ], setHash: HASH2, rev: REV2, indexedAt: AT, }), ).rejects.toThrow(); expect(await store.getSpaceRecord(SPACE, 'com.e.post', 'b')).toBeNull(); const repo = await store.getSpaceRepo(SPACE); if (!repo) throw new Error('expected a repo'); expect(repo.rev).toBe(REV1); const ops = await store.listSpaceOps(SPACE, null, 50); expect(ops.map((o) => o.rev)).toEqual([REV1]); }); }); describe('credential revocations', () => { const LIVE = '2126-07-30T12:00:00.000Z'; const PAST = '2001-01-01T00:00:00.000Z'; it('holds a revocation per space and jti', async () => { await store.putCredentialRevocations(SPACE, ['j1', 'j2'], LIVE); expect(await store.isCredentialRevoked(SPACE, 'j1')).toBe(true); expect(await store.isCredentialRevoked(SPACE, 'j2')).toBe(true); expect(await store.isCredentialRevoked(SPACE, 'j3')).toBe(false); expect(await store.isCredentialRevoked(OTHER, 'j1')).toBe(false); }); it('takes a repeated revocation without shortening it', async () => { await store.putCredentialRevocations(SPACE, ['j1'], LIVE); await store.putCredentialRevocations(SPACE, ['j1'], PAST); expect(await store.isCredentialRevoked(SPACE, 'j1')).toBe(true); }); it('lets an expired revocation lapse', async () => { await store.putCredentialRevocations(SPACE, ['old'], PAST); expect(await store.isCredentialRevoked(SPACE, 'old')).toBe(false); }); }); describe('write notice outbox', () => { const HASH = new Uint8Array(32).fill(3); const T0 = '2026-10-01T00:00:00.000Z'; const T1 = '2026-10-01T00:01:00.000Z'; const DEADLINE = '2026-10-02T00:00:00.000Z'; it('is empty to begin with', async () => { expect(await store.listDueSpaceNotify(T1, 10)).toEqual([]); expect(await store.nextSpaceNotifyAt()).toBeNull(); }); it('lists what is due and keeps the rest', async () => { await store.enqueueSpaceNotify({ space: SPACE, repoRev: 'r1', data: HASH, nextAttemptAt: T0, deadlineAt: DEADLINE, }); await store.enqueueSpaceNotify({ space: OTHER, repoRev: 'r1', data: HASH, nextAttemptAt: T1, deadlineAt: DEADLINE, }); const due = await store.listDueSpaceNotify(T0, 10); expect(due.map((r) => r.space)).toEqual([SPACE]); expect(due[0]).toMatchObject({ repoRev: 'r1', attempts: 0, nextAttemptAt: T0, deadlineAt: DEADLINE, }); expect([...due[0].data]).toEqual([...HASH]); expect(await store.nextSpaceNotifyAt()).toBe(T0); }); it('keeps one notice per space, the newest', async () => { const row = { space: SPACE, data: HASH, deadlineAt: DEADLINE }; await store.enqueueSpaceNotify({ ...row, repoRev: 'r1', nextAttemptAt: T0, }); await store.rescheduleSpaceNotify(SPACE, 'r1', 3, T1); await store.enqueueSpaceNotify({ ...row, repoRev: 'r2', nextAttemptAt: T0, }); const due = await store.listDueSpaceNotify(T1, 10); expect(due).toHaveLength(1); expect(due[0]).toMatchObject({ repoRev: 'r2', attempts: 0 }); }); it('leaves a newer notice alone when an older one is settled', async () => { const row = { space: SPACE, data: HASH, deadlineAt: DEADLINE }; await store.enqueueSpaceNotify({ ...row, repoRev: 'r2', nextAttemptAt: T0, }); await store.rescheduleSpaceNotify(SPACE, 'r1', 5, T1); await store.deleteSpaceNotify(SPACE, 'r1'); const due = await store.listDueSpaceNotify(T0, 10); expect(due[0]).toMatchObject({ repoRev: 'r2', attempts: 0 }); await store.rescheduleSpaceNotify(SPACE, 'r2', 1, T1); expect(await store.listDueSpaceNotify(T0, 10)).toEqual([]); expect(await store.nextSpaceNotifyAt()).toBe(T1); await store.deleteSpaceNotify(SPACE, 'r2'); expect(await store.nextSpaceNotifyAt()).toBeNull(); }); }); describe('deletion notice outbox', () => { const T0 = '2026-10-01T00:00:00.000Z'; const T1 = '2026-10-01T00:01:00.000Z'; const DEADLINE = '2026-10-02T00:00:00.000Z'; /** @param {Partial} over */ const row = (over = {}) => ({ space: SPACE, service: 'did:web:syncer.example#atproto_space_syncer', serviceEndpoint: 'https://syncer.example', attempts: 0, nextAttemptAt: T0, deadlineAt: DEADLINE, ...over, }); it('is empty to begin with', async () => { expect(await store.listDueSpaceDeletedNotify(T1, 10)).toEqual([]); }); it('lists what is due, one per space and service', async () => { await store.enqueueSpaceDeletedNotify(row()); await store.enqueueSpaceDeletedNotify(row({ attempts: 2 })); await store.enqueueSpaceDeletedNotify( row({ service: 'did:web:other.example', nextAttemptAt: T1 }), ); const due = await store.listDueSpaceDeletedNotify(T0, 10); expect(due).toEqual([row({ attempts: 2 })]); expect(await store.nextSpaceNotifyAt()).toBe(T0); }); it('reschedules and settles a notice', async () => { await store.enqueueSpaceDeletedNotify(row()); await store.rescheduleSpaceDeletedNotify(SPACE, row().service, 1, T1); expect(await store.listDueSpaceDeletedNotify(T0, 10)).toEqual([]); expect(await store.nextSpaceNotifyAt()).toBe(T1); const [due] = await store.listDueSpaceDeletedNotify(T1, 10); expect(due).toMatchObject({ attempts: 1, nextAttemptAt: T1 }); await store.deleteSpaceDeletedNotify(SPACE, row().service); expect(await store.nextSpaceNotifyAt()).toBeNull(); }); it('falls due with the write notices, earliest first', async () => { await store.enqueueSpaceDeletedNotify(row({ nextAttemptAt: T1 })); await store.enqueueSpaceNotify({ space: OTHER, repoRev: 'r1', data: new Uint8Array(32), nextAttemptAt: T0, deadlineAt: DEADLINE, }); expect(await store.nextSpaceNotifyAt()).toBe(T0); await store.deleteSpaceNotify(OTHER, 'r1'); expect(await store.nextSpaceNotifyAt()).toBe(T1); }); }); describe('writers and recipients', () => { const HASH = new Uint8Array(32).fill(7); it('lists no writers for an unknown space', async () => { const { writers, cursor } = await store.listSpaceWriters( SPACE, null, 50, ); expect(writers).toEqual([]); expect(cursor).toBeNull(); }); it('records a writer with its repo rev, data and space rev', async () => { await store.putSpaceWriter(SPACE, 'did:plc:bbbb', '3lkaaa', HASH, 's1'); const { writers } = await store.listSpaceWriters(SPACE, null, 50); expect(writers).toHaveLength(1); expect(writers[0].did).toBe('did:plc:bbbb'); expect(writers[0].repoRev).toBe('3lkaaa'); expect(writers[0].spaceRev).toBe('s1'); expect(writers[0].data).toBeInstanceOf(Uint8Array); expect([...writers[0].data]).toEqual([...HASH]); const one = await store.getSpaceWriter(SPACE, 'did:plc:bbbb'); expect(one?.repoRev).toBe('3lkaaa'); expect(await store.getSpaceWriter(SPACE, 'did:plc:none')).toBeNull(); }); it('does not let mutating the data passed to putSpaceWriter affect a subsequent read', async () => { const hash = new Uint8Array(32).fill(7); await store.putSpaceWriter(SPACE, 'did:plc:bbbb', '3lkaaa', hash, 's1'); hash.fill(9); const { writers } = await store.listSpaceWriters(SPACE, null, 50); expect([...writers[0].data]).toEqual([...HASH]); }); it('does not let mutating a listSpaceWriters data result affect a subsequent read', async () => { await store.putSpaceWriter(SPACE, 'did:plc:bbbb', '3lkaaa', HASH, 's1'); const { writers } = await store.listSpaceWriters(SPACE, null, 50); writers[0].data.fill(9); const again = await store.listSpaceWriters(SPACE, null, 50); expect([...again.writers[0].data]).toEqual([...HASH]); }); it('removeSpaceWriter drops one writer, and a missing one is no error', async () => { await store.putSpaceWriter(SPACE, 'did:plc:bbbb', '3lkaaa', HASH, 's1'); await store.putSpaceWriter(SPACE, 'did:plc:cccc', '3lkaab', HASH, 's2'); await store.removeSpaceWriter(SPACE, 'did:plc:bbbb'); await store.removeSpaceWriter(SPACE, 'did:plc:nope'); expect(await store.getSpaceWriter(SPACE, 'did:plc:bbbb')).toBeNull(); const { writers } = await store.listSpaceWriters(SPACE, null, 50); expect(writers.map((w) => w.did)).toEqual(['did:plc:cccc']); }); it('putSpaceWriter upserts on did', async () => { await store.putSpaceWriter(SPACE, 'did:plc:bbbb', '3lkaaa', HASH, 's1'); const newer = new Uint8Array(32).fill(8); await store.putSpaceWriter( SPACE, 'did:plc:bbbb', '3lkzzz', newer, 's2', ); const { writers } = await store.listSpaceWriters(SPACE, null, 50); expect(writers).toHaveLength(1); expect(writers[0].repoRev).toBe('3lkzzz'); expect(writers[0].spaceRev).toBe('s2'); expect([...writers[0].data]).toEqual([...newer]); }); it('lists writers in space rev order, not by did', async () => { await store.putSpaceWriter(SPACE, 'did:plc:aaa1', 'r1', HASH, 's2'); await store.putSpaceWriter(SPACE, 'did:plc:aaa2', 'r2', HASH, 's1'); const { writers } = await store.listSpaceWriters(SPACE, null, 50); expect(writers.map((w) => w.did)).toEqual([ 'did:plc:aaa2', 'did:plc:aaa1', ]); expect(await store.getSpaceRevHead(SPACE)).toBe('s2'); expect(await store.getSpaceRevHead(OTHER)).toBeNull(); }); it('pages writers by space rev, with a cursor on every non-empty page', async () => { await store.putSpaceWriter(SPACE, 'did:plc:aaa1', 'r1', HASH, 's1'); await store.putSpaceWriter(SPACE, 'did:plc:aaa2', 'r2', HASH, 's2'); const first = await store.listSpaceWriters(SPACE, null, 1); expect(first.writers.map((w) => w.did)).toEqual(['did:plc:aaa1']); expect(first.cursor).toBe('s1'); // A short page still ends on a cursor. const second = await store.listSpaceWriters(SPACE, first.cursor, 5); expect(second.writers.map((w) => w.did)).toEqual(['did:plc:aaa2']); expect(second.cursor).toBe('s2'); const third = await store.listSpaceWriters(SPACE, second.cursor, 5); expect(third.writers).toEqual([]); expect(third.cursor).toBeNull(); }); it('scopes writers per space', async () => { await store.putSpaceWriter(SPACE, 'did:plc:bbbb', 'r', HASH, 's1'); const { writers } = await store.listSpaceWriters(OTHER, null, 50); expect(writers).toEqual([]); }); // Expiries are far enough out that the suite's own clock cannot reach // them; the expiry filter has its own case below. const LIVE = '2126-07-30T12:00:00.000Z'; const LATER = '2126-07-31T12:00:00.000Z'; it('records and upserts a credential recipient', async () => { await store.putCredentialRecipient( SPACE, 'did:web:app.example', 'https://app.example', LIVE, ); await store.putCredentialRecipient( SPACE, 'did:web:app.example', 'https://app2.example', LATER, ); const list = await store.listCredentialRecipients(SPACE); expect(list).toHaveLength(1); expect(list[0]).toEqual({ serviceDid: 'did:web:app.example', serviceEndpoint: 'https://app2.example', expiresAt: LATER, }); }); it('omits a recipient whose registration has expired', async () => { await store.putCredentialRecipient( SPACE, 'did:web:app.example', 'https://app.example', '2020-01-01T00:00:00.000Z', ); expect(await store.listCredentialRecipients(SPACE)).toEqual([]); }); it('removes a recipient', async () => { await store.putCredentialRecipient( SPACE, 'did:web:app.example', 'https://app.example', LIVE, ); await store.deleteCredentialRecipient(SPACE, 'did:web:app.example'); expect(await store.listCredentialRecipients(SPACE)).toEqual([]); }); it('removes nothing when no registration matches', async () => { await store.putCredentialRecipient( SPACE, 'did:web:app.example', 'https://app.example', LIVE, ); await store.deleteCredentialRecipient(SPACE, 'did:web:other.example'); expect(await store.listCredentialRecipients(SPACE)).toHaveLength(1); }); it('scopes recipients per space', async () => { await store.putCredentialRecipient( SPACE, 'did:web:app.example', 'https://app.example', LIVE, ); expect(await store.listCredentialRecipients(OTHER)).toEqual([]); }); }); }); }