chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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([]);
});
});
});
}