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

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

Merged chadtmiller.com asked to merge into main from chadtmiller.com/pds.js:spaces/list-skips-deleted
.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(