chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
listSpaces leaves out deleted spaces, as the reference PDS does
.changeset/list-spaces-skips-deleted.mdnew+9−0
| @@ -0,0 +1,9 @@ | ||
--- | ||
'@pdsjs/spaces': patch | ||
'@pdsjs/core': patch | ||
--- | ||
| ||
`com.atproto.space.listSpaces` leaves out deleted spaces, as the reference | ||
PDS does. A client that lists an account's spaces to find what it holds no | ||
longer finds a space the account deleted. The account page's space browser | ||
still shows deleted spaces, marked as deleted. | ||
packages/cloudflare/src/space.js+8−1
| @@ -197,7 +197,13 @@ export function createSpaceStorage(sql, storage) { | ||
sql.exec('UPDATE space SET deleted_at = ? WHERE uri = ?', deletedAt, uri); | ||
}, | ||
| ||
async listSpaces({ type = null, did = null, cursor = null, limit = 50 }) { | ||
async listSpaces({ | ||
type = null, | ||
did = null, | ||
cursor = null, | ||
limit = 50, | ||
excludeDeleted = false, | ||
}) { | ||
// Filtering is a plain WHERE clause because the domain stores the URI's | ||
// authority and type as columns; the adapter never parses space URIs. | ||
// That also means LIMIT applies to matches, so no scan-and-refill loop. | ||
| @@ -211,6 +217,7 @@ export function createSpaceStorage(sql, storage) { | ||
where.push('space_type = ?'); | ||
params.push(type); | ||
} | ||
if (excludeDeleted) where.push('deleted_at IS NULL'); | ||
params.push(limit); | ||
const rows = /** @type {SpaceDbRow[]} */ ( | ||
all( | ||
packages/core/src/ports.js+1−1
| @@ -728,7 +728,7 @@ | ||
* @property {(uri: string) => Promise<SpaceRow|null>} getSpace | ||
* @property {(space: SpaceRow) => Promise<void>} putSpace | ||
* @property {(uri: string, deletedAt: string) => Promise<void>} deleteSpace | ||
* @property {(opts: {type?: string|null, did?: string|null, cursor?: string|null, limit?: number}) => Promise<{spaces: SpaceRow[], cursor: string|null}>} listSpaces | ||
* @property {(opts: {type?: string|null, did?: string|null, cursor?: string|null, limit?: number, excludeDeleted?: boolean}) => Promise<{spaces: SpaceRow[], cursor: string|null}>} listSpaces | ||
* @property {(space: string, did: string, access: SpaceMemberAccess) => Promise<void>} putMember | ||
* @property {(space: string, did: string) => Promise<void>} removeMember | ||
* @property {(space: string, cursor: string|null, limit: number) => Promise<{members: SpaceMemberRow[], cursor: string|null}>} listMembers | ||
packages/spaces/src/handlers/write.js+1−0
| @@ -599,6 +599,7 @@ export function createWriteRoutes(ctx) { | ||
did: params.get('did'), | ||
cursor: params.get('cursor'), | ||
limit, | ||
excludeDeleted: true, | ||
}); | ||
| ||
return Response.json({ | ||
packages/spaces/src/memory-storage.js+9−2
| @@ -121,9 +121,15 @@ export function createMemorySpaceStorage() { | ||
if (row) spaces.set(uri, { ...row, deletedAt }); | ||
}, | ||
| ||
async listSpaces({ type = null, did = null, cursor = null, limit = 50 }) { | ||
async listSpaces({ | ||
type = null, | ||
did = null, | ||
cursor = null, | ||
limit = 50, | ||
excludeDeleted = false, | ||
}) { | ||
let keys = [...spaces.keys()].sort(); | ||
if (type || did) { | ||
if (type || did || excludeDeleted) { | ||
keys = keys.filter((uri) => { | ||
// Filter on the stored components, not by re-parsing the URI: the | ||
// domain supplies them, and every adapter must agree on the result. | ||
| @@ -132,6 +138,7 @@ export function createMemorySpaceStorage() { | ||
); | ||
if (type && row.spaceType !== type) return false; | ||
if (did && row.spaceDid !== did) return false; | ||
if (excludeDeleted && row.deletedAt) return false; | ||
return true; | ||
}); | ||
} | ||
packages/spaces/test/routes.test.js+16−0
| @@ -367,6 +367,22 @@ describe('listSpaces', () => { | ||
expect(hit.spaces).toHaveLength(1); | ||
}); | ||
| ||
it('leaves out a deleted space', async () => { | ||
const { routes, spaceStorage } = setup(); | ||
await call(routes['/xrpc/com.atproto.space.createRecord'], { | ||
space: SPACE, | ||
repo: DID, | ||
collection: COL, | ||
record: {}, | ||
}); | ||
await spaceStorage.deleteSpace(SPACE, '2026-11-01T00:00:00.000Z'); | ||
const res = await call( | ||
routes['/xrpc/com.atproto.space.listSpaces'], | ||
undefined, | ||
); | ||
expect((await res.json()).spaces).toEqual([]); | ||
}); | ||
| ||
it('rejects an out-of-range limit', async () => { | ||
const { routes } = setup(); | ||
const res = await call( | ||
packages/storage-sqlite/src/space.js+8−1
| @@ -296,7 +296,13 @@ export function createSpaceStorage(db) { | ||
stmts.deleteSpace.run(deletedAt, uri); | ||
}, | ||
| ||
async listSpaces({ type = null, did = null, cursor = null, limit = 50 }) { | ||
async listSpaces({ | ||
type = null, | ||
did = null, | ||
cursor = null, | ||
limit = 50, | ||
excludeDeleted = false, | ||
}) { | ||
// Filtering is a plain WHERE clause because the domain stores the URI's | ||
// authority and type as columns; the adapter never parses space URIs. | ||
// That also means LIMIT applies to matches, so no scan-and-refill loop. | ||
| @@ -310,6 +316,7 @@ export function createSpaceStorage(db) { | ||
where.push('space_type = ?'); | ||
params.push(type); | ||
} | ||
if (excludeDeleted) where.push('deleted_at IS NULL'); | ||
params.push(limit); | ||
const rows = /** @type {SpaceDbRow[]} */ ( | ||
db | ||
test/helpers/space-storage-conformance.js+15−0
| @@ -133,6 +133,21 @@ export function runSpaceStorageConformance(name, createStorage) { | ||
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( | ||