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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
1067 lines · 39.5 KB Raw History
// 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<import('@pdsjs/core/ports').SpaceStoragePort>} 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<string, unknown>} */ (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<import('@pdsjs/core/ports').SpaceDeletedNotifyRow>} 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([]);
      });
    });
  });
}