chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
Permissioned Data — Phase 2: Storage Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Define SpaceStoragePort and implement it three times — an in-memory fake, storage-sqlite, and the Cloudflare Durable Object — with a single shared conformance suite that all three must pass.
Architecture: The port is declared as JSDoc in packages/core/src/ports.js, following the existing ActorStoragePort convention. Writes go through one commit-shaped method, commitSpaceWrite, so transaction control stays inside each adapter rather than leaking through the port. A conformance suite in test/helpers/ is the single definition of correct behaviour; each adapter's test file is a thin wrapper that runs it.
Tech Stack: plain ESM JS + JSDoc types, vitest, biome (single quotes, 2-space indent), TDD per task, one commit per task.
Global Constraints
- Spec:
docs/superpowers/specs/2026-07-30-permissioned-data-design.md§4. Reference behavior (bluesky-social/atprotoPR #5187, head24a81bf5ab8d39557da35a4c4c0978c9aa62c45a) is authoritative for the schema. @pdsjs/spaceshas zero external dependencies;dependenciesstays{"@pdsjs/core": "workspace:*"}.@pdsjs/coremust never import@pdsjs/spaces.packages/core/src/ports.jsstays runtime-free — pure JSDoc, ending inexport {}.- Plain ESM JS with JSDoc annotations. No TypeScript source files.
- Every new function gets JSDoc param and return annotations. Phase 1 shipped 44 implicit-
anyerrors; do not add more. Measure withnpx tsc --noEmit 2>&1 | grep -c "error TS"— the phase-1 exit count is 114, and this phase must not raise it. - biome: single quotes, 2-space indent. Scope
--writeto the files you touched; running it broadly reformats unrelated files. - Every task: failing test first, verify RED, minimal implementation, verify GREEN, biome, commit.
- Branch:
permissioned-data.
Deviation from the spec, and why
Spec §4 says "An in-memory SpaceStoragePort implementation backed by Maps serves the unit tests." This plan generalizes that into a shared conformance suite (test/helpers/space-storage-conformance.js) exported as a function that takes a factory and runs the full behavioural contract against it. All three implementations import and run it.
Rationale: §4 accepts duplicated DDL across backends as a cost, mitigated only by the e2e matrix. A conformance suite catches divergence at unit-test speed instead, and makes the "both backends in lockstep" decision cheap rather than triple-written. Amend §4 when this phase lands (Task 6).
Second deviation: the DDL is defined once, not per backend. §4 says "Each backend declares the seven tables itself," accepting ~55 duplicated lines because sharing "would require core or the storage packages to import @pdsjs/spaces, which would invert the dependency direction established in §1."
That reasoning holds for @pdsjs/spaces but not for @pdsjs/core. Core already declares SpaceStoragePort in ports.js; declaring the schema beside it is the same move — core states the contract, adapters implement it. SPACE_SCHEMA_SQL therefore lives in packages/core/src/space-schema.js, and both storage adapters import it. No storage package gains a dependency on @pdsjs/spaces, so spaces stays optional exactly as §1 requires, and the duplication disappears rather than merely being policed. Task 6 amends §4's "Accepted cost: duplicated DDL" subsection accordingly.
File structure
| File | Responsibility |
|---|---|
packages/core/src/ports.js | SpaceStoragePort + row typedefs (JSDoc only) |
packages/core/src/space-schema.js | SPACE_SCHEMA_SQL — the seven-table DDL, defined once |
test/helpers/space-storage-conformance.js | the behavioural contract, as a runnable suite |
packages/spaces/src/memory-storage.js | in-memory Map implementation |
packages/spaces/test/memory-storage.test.js | runs the conformance suite |
packages/storage-sqlite/src/space.js | SQLite DDL + createSpaceStorage(db) |
packages/storage-sqlite/test/space.test.js | runs the conformance suite |
packages/cloudflare/src/space.js | DO SQL DDL + createSpaceStorage(sql, storage) |
packages/cloudflare/test/space.test.js | runs the conformance suite via a D1 shim |
test/helpers/d1-shim.js | adapts better-sqlite3 to the DO sql.exec interface |
The port contract
Every task below implements part of this. It is reproduced here once; each task's own section repeats the signatures it needs.
// Spaces
getSpace(uri) → Promise<SpaceRow|null>
putSpace(space) → Promise<void> // upsert by uri
deleteSpace(uri, deletedAt) → Promise<void> // soft delete
listSpaces({type, did, cursor, limit}) → Promise<{spaces: SpaceRow[], cursor: string|null}>
// Members
addMember(space, did) → Promise<void> // idempotent
removeMember(space, did) → Promise<void> // idempotent
listMembers(space, cursor, limit) → Promise<{dids: string[], cursor: string|null}>
isMember(space, did) → Promise<boolean>
// Records (read)
getSpaceRecord(space, collection, rkey) → Promise<SpaceRecordRow|null>
getSpaceRecordCid(space, collection, rkey) → Promise<string|null>
listSpaceRecords(space, collection, cursor, limit)
→ Promise<{records: SpaceRecordRow[], cursor: string|null}>
listAllSpaceRecords(space) → Promise<Array<{collection, rkey, cid}>>
// Write (atomic across space_record, space_record_oplog, space_repo)
commitSpaceWrite(space, {writes, setHash, rev, indexedAt}) → Promise<void>
// Repo state
getSpaceRepo(space) → Promise<{setHash: Uint8Array|null, rev: string|null}|null>
// Oplog
listSpaceOps(space, since, limit) → Promise<SpaceOpRow[]>
// Writer set
putSpaceWriter(space, did, rev, hash) → Promise<void> // upsert
listSpaceWriters(space, cursor, limit) → Promise<{writers: SpaceWriterRow[], cursor: string|null}>
// Credential recipients
putCredentialRecipient(space, serviceDid, serviceEndpoint, lastIssuedAt) → Promise<void>
listCredentialRecipients(space) → Promise<SpaceRecipientRow[]>
Row shapes:
SpaceRow { uri, isOwner: boolean, policy, managingApp: string|null,
appAccessType, appAllowed: string[], createdAt, deletedAt: string|null }
SpaceRecordRow { collection, rkey, cid, value: Uint8Array, repoRev, indexedAt }
SpaceOpRow { rev, idx: number, action: 'create'|'update'|'delete',
collection, rkey, cid: string|null, prev: string|null }
SpaceWriterRow { did, rev, hash: Uint8Array }
SpaceRecipientRow { serviceDid, serviceEndpoint, lastIssuedAt }
SpaceWrite { action: 'create'|'update'|'delete', collection, rkey,
cid: string|null, value: Uint8Array|null, prev: string|null }
Contract rules that hold for every implementation:
listAllSpaceRecordsreturns records ordered lexicographically by"{collection}/{rkey}".getRepo(phase 4) serialises in exactly this order, so it is load-bearing, not cosmetic.- Cursors are opaque strings.
nullmeans no more pages. Pagination is keyset, not offset. commitSpaceWriteis atomic: on failure, none of the three tables change.- Oplog
idxnumbers the writes within onerev, starting at 0, in the order given. listSpaceOps(space, since, limit)returns ops withrev > since(exclusive), ordered by(rev, idx).sinceofnullmeans from the beginning.- Blob columns come back as
Uint8Array, neverArrayBufferorBuffer. The Cloudflare adapter must normalise; the SQLite adapter must convertBuffer. deleteSpaceis a soft delete: the row stays,deletedAtis set.getSpacestill returns it.
Task 1: SpaceStoragePort typedef and conformance harness
Files:
- Modify:
packages/core/src/ports.js - Create:
test/helpers/space-storage-conformance.js - Create:
packages/spaces/src/memory-storage.js - Create:
packages/spaces/test/memory-storage.test.js
Interfaces:
- Consumes: nothing from phase 1.
- Produces:
- JSDoc typedefs in
ports.js:SpaceRow,SpaceRecordRow,SpaceOpRow,SpaceWriterRow,SpaceRecipientRow,SpaceWrite,SpaceStoragePort. runSpaceStorageConformance(name, createStorage)fromtest/helpers/space-storage-conformance.js, wherecreateStorageis() => Promise<SpaceStoragePort>returning a fresh empty store. Tasks 4 and 5 call this.createMemorySpaceStorage()frompackages/spaces/src/memory-storage.js.
- JSDoc typedefs in
This task covers the spaces and members groups only. Tasks 2 and 3 extend the same three files.
-
Step 1: Add the typedefs to
ports.js
Append to packages/core/src/ports.js, before the final export {}:
/**
* @typedef {Object} SpaceRow
* @property {string} uri - space AT-URI
* @property {boolean} isOwner - whether this actor is the space authority
* @property {string} policy - 'public' | 'member-list' | 'managing-app'
* @property {string|null} managingApp - service identifier for managing-app policy
* @property {string} appAccessType - 'open' | 'allowList'
* @property {string[]} appAllowed - client ids when appAccessType is 'allowList'
* @property {string} createdAt - ISO timestamp
* @property {string|null} deletedAt - ISO timestamp, set by soft delete
*/
/**
* @typedef {Object} SpaceRecordRow
* @property {string} collection
* @property {string} rkey
* @property {string} cid
* @property {Uint8Array} value - DAG-CBOR encoded record
* @property {string} repoRev - rev of the commit that wrote it
* @property {string} indexedAt - ISO timestamp
*/
/**
* @typedef {Object} SpaceOpRow
* @property {string} rev
* @property {number} idx - position within the rev, from 0
* @property {'create'|'update'|'delete'} action
* @property {string} collection
* @property {string} rkey
* @property {string|null} cid - null for deletes
* @property {string|null} prev - null for creates
*/
/**
* @typedef {Object} SpaceWriterRow
* @property {string} did
* @property {string} rev
* @property {Uint8Array} hash
*/
/**
* @typedef {Object} SpaceRecipientRow
* @property {string} serviceDid
* @property {string} serviceEndpoint
* @property {string} lastIssuedAt
*/
/**
* A single write within a commit.
* @typedef {Object} SpaceWrite
* @property {'create'|'update'|'delete'} action
* @property {string} collection
* @property {string} rkey
* @property {string|null} cid - null for deletes
* @property {Uint8Array|null} value - null for deletes
* @property {string|null} prev - null for creates
*/
/**
* Per-actor storage for permissioned spaces (proposal 0016).
*
* Separate from ActorStoragePort so that a deployment without spaces carries
* no space schema. Writes go through the single commit-shaped
* `commitSpaceWrite` rather than per-table methods, so transaction control
* stays inside the adapter instead of leaking through the port.
*
* @typedef {Object} SpaceStoragePort
* @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 {(space: string, did: string) => Promise<void>} addMember
* @property {(space: string, did: string) => Promise<void>} removeMember
* @property {(space: string, cursor: string|null, limit: number) => Promise<{dids: string[], cursor: string|null}>} listMembers
* @property {(space: string, did: string) => Promise<boolean>} isMember
* @property {(space: string, collection: string, rkey: string) => Promise<SpaceRecordRow|null>} getSpaceRecord
* @property {(space: string, collection: string, rkey: string) => Promise<string|null>} getSpaceRecordCid
* @property {(space: string, collection: string, cursor: string|null, limit: number) => Promise<{records: SpaceRecordRow[], cursor: string|null}>} listSpaceRecords
* @property {(space: string) => Promise<Array<{collection: string, rkey: string, cid: string}>>} listAllSpaceRecords
* @property {(space: string, commit: {writes: SpaceWrite[], setHash: Uint8Array, rev: string, indexedAt: string}) => Promise<void>} commitSpaceWrite
* @property {(space: string) => Promise<{setHash: Uint8Array|null, rev: string|null}|null>} getSpaceRepo
* @property {(space: string, since: string|null, limit: number) => Promise<SpaceOpRow[]>} listSpaceOps
* @property {(space: string, did: string, rev: string, hash: Uint8Array) => Promise<void>} putSpaceWriter
* @property {(space: string, cursor: string|null, limit: number) => Promise<{writers: SpaceWriterRow[], cursor: string|null}>} listSpaceWriters
* @property {(space: string, serviceDid: string, serviceEndpoint: string, lastIssuedAt: string) => Promise<void>} putCredentialRecipient
* @property {(space: string) => Promise<SpaceRecipientRow[]>} listCredentialRecipients
*/
Confirm the file still ends with export {} and contains no runtime code.
- Step 2: Write the conformance suite — spaces and members
Create test/helpers/space-storage-conformance.js:
// 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 = {}) {
return {
uri,
isOwner: true,
policy: 'member-list',
managingApp: null,
appAccessType: 'open',
appAllowed: [],
createdAt: '2026-07-30T12:00:00.000Z',
deletedAt: null,
...overrides,
};
}
/**
* @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 () => {
await store.putSpace(
spaceRow(SPACE, {
isOwner: false,
appAccessType: 'allowList',
appAllowed: ['https://a.example/c.json', 'https://b.example/c.json'],
managingApp: 'did:web:app.example',
}),
);
const got = await store.getSpace(SPACE);
expect(got.isOwner).toBe(false);
expect(got.appAllowed).toEqual([
'https://a.example/c.json',
'https://b.example/c.json',
]);
expect(got.managingApp).toBe('did:web:app.example');
});
it('putSpace upserts rather than duplicating', async () => {
await store.putSpace(spaceRow());
await store.putSpace(spaceRow(SPACE, { policy: 'public' }));
expect((await store.getSpace(SPACE)).policy).toBe('public');
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('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',
]);
});
});
describe('members', () => {
it('isMember is false for an unknown did', async () => {
expect(await store.isMember(SPACE, 'did:plc:zzzz')).toBe(false);
});
it('adds and detects a member', async () => {
await store.addMember(SPACE, 'did:plc:bbbb');
expect(await store.isMember(SPACE, 'did:plc:bbbb')).toBe(true);
});
it('addMember is idempotent', async () => {
await store.addMember(SPACE, 'did:plc:bbbb');
await store.addMember(SPACE, 'did:plc:bbbb');
const { dids } = await store.listMembers(SPACE, null, 50);
expect(dids).toEqual(['did:plc:bbbb']);
});
it('removeMember is idempotent and does not throw on a missing did', async () => {
await store.removeMember(SPACE, 'did:plc:nope');
await store.addMember(SPACE, 'did:plc:bbbb');
await store.removeMember(SPACE, 'did:plc:bbbb');
await store.removeMember(SPACE, 'did:plc:bbbb');
expect(await store.isMember(SPACE, 'did:plc:bbbb')).toBe(false);
});
it('scopes members per space', async () => {
await store.addMember(SPACE, 'did:plc:bbbb');
expect(await store.isMember(OTHER, 'did:plc:bbbb')).toBe(false);
const { dids } = await store.listMembers(OTHER, null, 50);
expect(dids).toEqual([]);
});
it('paginates members', async () => {
await store.addMember(SPACE, 'did:plc:aaa1');
await store.addMember(SPACE, 'did:plc:aaa2');
const first = await store.listMembers(SPACE, null, 1);
expect(first.dids).toHaveLength(1);
const second = await store.listMembers(SPACE, first.cursor, 1);
expect(second.dids).toHaveLength(1);
expect(second.dids[0]).not.toBe(first.dids[0]);
const third = await store.listMembers(SPACE, second.cursor, 1);
expect(third.dids).toEqual([]);
expect(third.cursor).toBeNull();
});
});
});
}
- Step 3: Write the adapter test and run it to verify RED
Create packages/spaces/test/memory-storage.test.js:
import { runSpaceStorageConformance } from '../../../test/helpers/space-storage-conformance.js';
import { createMemorySpaceStorage } from '../src/memory-storage.js';
runSpaceStorageConformance('memory', async () => createMemorySpaceStorage());
Run: npx vitest run packages/spaces/test/memory-storage.test.js
Expected: FAIL — Failed to resolve import "../src/memory-storage.js".
- Step 4: Implement the in-memory adapter — spaces and members
Create packages/spaces/src/memory-storage.js:
// @pdsjs/spaces/memory-storage - in-memory SpaceStoragePort.
//
// Backs unit tests and gives handler tests a store with no database. Keyed
// exactly like the SQL adapters so the shared conformance suite exercises the
// same behaviour everywhere.
import { parseSpaceUri } from './uri.js';
/**
* Keyset pagination over a sorted key list.
* @template T
* @param {string[]} sortedKeys
* @param {string|null} cursor - exclusive lower bound
* @param {number} limit
* @param {(key: string) => T} map
* @returns {{items: T[], cursor: string|null}}
*/
function page(sortedKeys, cursor, limit, map) {
const start = cursor
? sortedKeys.findIndex((k) => k > cursor)
: 0;
if (start === -1) return { items: [], cursor: null };
const slice = sortedKeys.slice(start, start + limit);
return {
items: slice.map(map),
// A full page always yields a cursor, even if it happened to be the last
// one. The next call then returns empty with a null cursor. This matches
// `WHERE key > ? LIMIT n` in the SQL adapters — do not "optimise" it to
// return null on the final full page, or the two diverge.
cursor: slice.length === limit ? slice[slice.length - 1] : null,
};
}
/**
* @returns {import('@pdsjs/core/ports').SpaceStoragePort}
*/
export function createMemorySpaceStorage() {
/** @type {Map<string, import('@pdsjs/core/ports').SpaceRow>} */
const spaces = new Map();
/** @type {Map<string, Set<string>>} space uri -> dids */
const members = new Map();
return {
async getSpace(uri) {
const row = spaces.get(uri);
return row ? { ...row, appAllowed: [...row.appAllowed] } : null;
},
async putSpace(space) {
spaces.set(space.uri, { ...space, appAllowed: [...space.appAllowed] });
},
async deleteSpace(uri, deletedAt) {
const row = spaces.get(uri);
if (row) spaces.set(uri, { ...row, deletedAt });
},
async listSpaces({ type = null, did = null, cursor = null, limit = 50 }) {
let keys = [...spaces.keys()].sort();
if (type || did) {
keys = keys.filter((uri) => {
const parsed = parseSpaceUri(uri);
if (type && parsed.spaceType !== type) return false;
if (did && parsed.spaceDid !== did) return false;
return true;
});
}
const { items, cursor: next } = page(keys, cursor, limit, (uri) => {
const row = spaces.get(uri);
return { ...row, appAllowed: [...row.appAllowed] };
});
return { spaces: items, cursor: next };
},
async addMember(space, did) {
let set = members.get(space);
if (!set) {
set = new Set();
members.set(space, set);
}
set.add(did);
},
async removeMember(space, did) {
members.get(space)?.delete(did);
},
async listMembers(space, cursor, limit) {
const keys = [...(members.get(space) ?? [])].sort();
const { items, cursor: next } = page(keys, cursor, limit, (d) => d);
return { dids: items, cursor: next };
},
async isMember(space, did) {
return members.get(space)?.has(did) ?? false;
},
};
}
Note the defensive copies on appAllowed and on returned rows: without them a caller mutating a result would corrupt the store, and the SQL adapters cannot behave that way. The conformance suite must hold for all three.
- Step 5: Run the test to verify it passes
Run: npx vitest run packages/spaces/test/memory-storage.test.js
Expected: PASS, 14 tests.
The returned object is deliberately not yet a complete SpaceStoragePort — Tasks 2 and 3 add the remaining methods. Do not add stubs for them.
- Step 6: Format and commit
npx biome check --write packages/core/src/ports.js packages/spaces/src/memory-storage.js packages/spaces/test/memory-storage.test.js test/helpers/space-storage-conformance.js
npx vitest run packages/core packages/spaces
git add packages/core/src/ports.js packages/spaces test/helpers
git commit -m "feat(spaces): SpaceStoragePort contract with in-memory spaces and members"
Task 2: Records, repo state, oplog, and the commit write
Files:
- Modify:
test/helpers/space-storage-conformance.js - Modify:
packages/spaces/src/memory-storage.js
Interfaces:
- Consumes:
createMemorySpaceStorage()and the conformance suite from Task 1. - Produces:
getSpaceRecord,getSpaceRecordCid,listSpaceRecords,listAllSpaceRecords,commitSpaceWrite,getSpaceRepo,listSpaceOpson every implementation.
This is the heart of the port. commitSpaceWrite is the only mutation path for records.
- Step 1: Extend the conformance suite
Add these describe blocks inside runSpaceStorageConformance, after the members block. Add this helper next to spaceRow at the top of the file:
const CID_A = 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a';
const CID_B = 'bafyreih4nsjr7wgnhqpsdvqhqmwzwbtwqrdrn7q6mdhhnbjsplqjjbgcbe';
/** @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 };
}
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');
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('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);
expect(repo.rev).toBe(REV1);
expect(repo.setHash).toBeInstanceOf(Uint8Array);
expect(repo.setHash.length).toBe(2048);
expect(repo.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');
expect(row.cid).toBe(CID_B);
expect(row.repoRev).toBe(REV2);
expect((await store.getSpaceRepo(SPACE)).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',
]);
});
it('listSpaceRecords filters by collection and paginates by rkey', async () => {
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,
});
const first = await store.listSpaceRecords(SPACE, 'com.e.post', null, 1);
expect(first.records.map((r) => r.rkey)).toEqual(['a']);
const second = await store.listSpaceRecords(
SPACE,
'com.e.post',
first.cursor,
1,
);
expect(second.records.map((r) => r.rkey)).toEqual(['b']);
const third = await store.listSpaceRecords(
SPACE,
'com.e.post',
second.cursor,
1,
);
expect(third.records).toEqual([]);
expect(third.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,
});
expect((await store.getSpaceRepo(SPACE)).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();
expect((await store.getSpaceRepo(SPACE)).rev).toBe(REV1);
const ops = await store.listSpaceOps(SPACE, null, 50);
expect(ops.map((o) => o.rev)).toEqual([REV1]);
});
});
The last test is the atomicity gate. Each adapter must validate that every write's value is a Uint8Array (for non-deletes) before mutating anything, and must roll back if the underlying store throws partway.
- Step 2: Run to verify RED
Run: npx vitest run packages/spaces/test/memory-storage.test.js
Expected: FAIL — store.commitSpaceWrite is not a function and similar.
- Step 3: Extend the in-memory adapter
Add to createMemorySpaceStorage's closure, alongside spaces and members:
/** @type {Map<string, Map<string, import('@pdsjs/core/ports').SpaceRecordRow>>} */
const records = new Map();
/** @type {Map<string, {setHash: Uint8Array|null, rev: string|null}>} */
const repos = new Map();
/** @type {Map<string, import('@pdsjs/core/ports').SpaceOpRow[]>} */
const oplog = new Map();
Records are keyed "{collection}/{rkey}" within a per-space Map, matching the SQL primary key.
Add these methods to the returned object:
async getSpaceRecord(space, collection, rkey) {
const row = records.get(space)?.get(`${collection}/${rkey}`);
return row ? { ...row, value: new Uint8Array(row.value) } : null;
},
async getSpaceRecordCid(space, collection, rkey) {
return records.get(space)?.get(`${collection}/${rkey}`)?.cid ?? null;
},
async listSpaceRecords(space, collection, cursor, limit) {
const inSpace = records.get(space) ?? new Map();
const keys = [...inSpace.values()]
.filter((r) => r.collection === collection)
.map((r) => r.rkey)
.sort();
const { items, cursor: next } = page(keys, cursor, limit, (rkey) => {
const row = inSpace.get(`${collection}/${rkey}`);
return { ...row, value: new Uint8Array(row.value) };
});
return { records: items, cursor: next };
},
async listAllSpaceRecords(space) {
const inSpace = records.get(space) ?? new Map();
return [...inSpace.keys()]
.sort()
.map((key) => {
const row = inSpace.get(key);
return { collection: row.collection, rkey: row.rkey, cid: row.cid };
});
},
async commitSpaceWrite(space, { writes, setHash, rev, indexedAt }) {
// Validate everything before mutating: this method is atomic.
for (const w of writes) {
if (w.action !== 'delete' && !(w.value instanceof Uint8Array)) {
throw new Error(
`space write value must be Uint8Array: ${w.collection}/${w.rkey}`,
);
}
}
let inSpace = records.get(space);
if (!inSpace) {
inSpace = new Map();
records.set(space, inSpace);
}
const ops = oplog.get(space) ?? [];
writes.forEach((w, idx) => {
const key = `${w.collection}/${w.rkey}`;
if (w.action === 'delete') {
inSpace.delete(key);
} else {
inSpace.set(key, {
collection: w.collection,
rkey: w.rkey,
cid: w.cid,
value: new Uint8Array(w.value),
repoRev: rev,
indexedAt,
});
}
ops.push({
rev,
idx,
action: w.action,
collection: w.collection,
rkey: w.rkey,
cid: w.cid,
prev: w.prev,
});
});
oplog.set(space, ops);
repos.set(space, { setHash: new Uint8Array(setHash), rev });
},
async getSpaceRepo(space) {
const repo = repos.get(space);
if (!repo) return null;
return {
setHash: repo.setHash ? new Uint8Array(repo.setHash) : null,
rev: repo.rev,
};
},
async listSpaceOps(space, since, limit) {
return (oplog.get(space) ?? [])
.filter((op) => (since === null ? true : op.rev > since))
.sort((a, b) => (a.rev === b.rev ? a.idx - b.idx : a.rev < b.rev ? -1 : 1))
.slice(0, limit)
.map((op) => ({ ...op }));
},
- Step 4: Run to verify GREEN
Run: npx vitest run packages/spaces/test/memory-storage.test.js
Expected: PASS, 29 tests (14 from Task 1 plus 15 here).
- Step 5: Format and commit
npx biome check --write packages/spaces/src/memory-storage.js test/helpers/space-storage-conformance.js
git add packages/spaces test/helpers
git commit -m "feat(spaces): records, oplog, and commitSpaceWrite in the memory adapter"
Task 3: Writer set and credential recipients
Files:
- Modify:
test/helpers/space-storage-conformance.js - Modify:
packages/spaces/src/memory-storage.js - Modify:
packages/spaces/src/index.js
Interfaces:
-
Consumes: Task 2's adapter.
-
Produces:
putSpaceWriter,listSpaceWriters,putCredentialRecipient,listCredentialRecipients, completingSpaceStoragePort. ExportscreateMemorySpaceStoragefrom the package index. -
Step 1: Extend the conformance suite
Append inside runSpaceStorageConformance, after the records and commits block:
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 rev and hash', async () => {
await store.putSpaceWriter(SPACE, 'did:plc:bbbb', '3lkaaa', HASH);
const { writers } = await store.listSpaceWriters(SPACE, null, 50);
expect(writers).toHaveLength(1);
expect(writers[0].did).toBe('did:plc:bbbb');
expect(writers[0].rev).toBe('3lkaaa');
expect(writers[0].hash).toBeInstanceOf(Uint8Array);
expect([...writers[0].hash]).toEqual([...HASH]);
});
it('putSpaceWriter upserts on did', async () => {
await store.putSpaceWriter(SPACE, 'did:plc:bbbb', '3lkaaa', HASH);
const newer = new Uint8Array(32).fill(8);
await store.putSpaceWriter(SPACE, 'did:plc:bbbb', '3lkzzz', newer);
const { writers } = await store.listSpaceWriters(SPACE, null, 50);
expect(writers).toHaveLength(1);
expect(writers[0].rev).toBe('3lkzzz');
expect([...writers[0].hash]).toEqual([...newer]);
});
it('paginates writers', async () => {
await store.putSpaceWriter(SPACE, 'did:plc:aaa1', 'r1', HASH);
await store.putSpaceWriter(SPACE, 'did:plc:aaa2', 'r2', HASH);
const first = await store.listSpaceWriters(SPACE, null, 1);
expect(first.writers).toHaveLength(1);
const second = await store.listSpaceWriters(SPACE, first.cursor, 1);
expect(second.writers[0].did).not.toBe(first.writers[0].did);
const third = await store.listSpaceWriters(SPACE, second.cursor, 1);
expect(third.writers).toEqual([]);
expect(third.cursor).toBeNull();
});
it('scopes writers per space', async () => {
await store.putSpaceWriter(SPACE, 'did:plc:bbbb', 'r', HASH);
const { writers } = await store.listSpaceWriters(OTHER, null, 50);
expect(writers).toEqual([]);
});
it('records and upserts a credential recipient', async () => {
await store.putCredentialRecipient(
SPACE,
'did:web:app.example',
'https://app.example',
'2026-07-30T12:00:00.000Z',
);
await store.putCredentialRecipient(
SPACE,
'did:web:app.example',
'https://app2.example',
'2026-07-31T12:00:00.000Z',
);
const list = await store.listCredentialRecipients(SPACE);
expect(list).toHaveLength(1);
expect(list[0]).toEqual({
serviceDid: 'did:web:app.example',
serviceEndpoint: 'https://app2.example',
lastIssuedAt: '2026-07-31T12:00:00.000Z',
});
});
it('scopes recipients per space', async () => {
await store.putCredentialRecipient(
SPACE,
'did:web:app.example',
'https://app.example',
'2026-07-30T12:00:00.000Z',
);
expect(await store.listCredentialRecipients(OTHER)).toEqual([]);
});
});
- Step 2: Run to verify RED
Run: npx vitest run packages/spaces/test/memory-storage.test.js
Expected: FAIL — store.putSpaceWriter is not a function.
- Step 3: Extend the in-memory adapter
Add to the closure:
/** @type {Map<string, Map<string, import('@pdsjs/core/ports').SpaceWriterRow>>} */
const writers = new Map();
/** @type {Map<string, Map<string, import('@pdsjs/core/ports').SpaceRecipientRow>>} */
const recipients = new Map();
Add to the returned object:
async putSpaceWriter(space, did, rev, hash) {
let inSpace = writers.get(space);
if (!inSpace) {
inSpace = new Map();
writers.set(space, inSpace);
}
inSpace.set(did, { did, rev, hash: new Uint8Array(hash) });
},
async listSpaceWriters(space, cursor, limit) {
const inSpace = writers.get(space) ?? new Map();
const keys = [...inSpace.keys()].sort();
const { items, cursor: next } = page(keys, cursor, limit, (did) => {
const row = inSpace.get(did);
return { ...row, hash: new Uint8Array(row.hash) };
});
return { writers: items, cursor: next };
},
async putCredentialRecipient(space, serviceDid, serviceEndpoint, lastIssuedAt) {
let inSpace = recipients.get(space);
if (!inSpace) {
inSpace = new Map();
recipients.set(space, inSpace);
}
inSpace.set(serviceDid, { serviceDid, serviceEndpoint, lastIssuedAt });
},
async listCredentialRecipients(space) {
const inSpace = recipients.get(space) ?? new Map();
return [...inSpace.keys()].sort().map((k) => ({ ...inSpace.get(k) }));
},
- Step 4: Run to verify GREEN
Run: npx vitest run packages/spaces/test/memory-storage.test.js
Expected: PASS, 36 tests (14 + 15 + 7).
- Step 5: Export from the package index
Add to packages/spaces/src/index.js:
export { createMemorySpaceStorage } from './memory-storage.js';
Add "./memory-storage": "./src/memory-storage.js" to the exports map in packages/spaces/package.json.
- Step 6: Format and commit
npx biome check --write packages/spaces test/helpers/space-storage-conformance.js
npx vitest run packages/spaces
git add packages/spaces test/helpers
git commit -m "feat(spaces): writer set and credential recipients complete SpaceStoragePort"
Task 4: SQLite implementation
Files:
- Create:
packages/storage-sqlite/src/space.js - Create:
packages/storage-sqlite/test/space.test.js - Modify:
packages/storage-sqlite/src/index.js(re-export)
Interfaces:
- Consumes:
runSpaceStorageConformance(Task 1), the complete contract (Tasks 1–3). - Produces:
SPACE_SCHEMA_SQLfrom@pdsjs/core/space-schema— the seven-table DDL, which Task 5's Cloudflare adapter also imports. AlsocreateSpaceStorage(db)from@pdsjs/storage-sqlite, wheredbis the existingSQLiteDatabasedriver typedef (exec+prepare).
Transactions: the driver typedef guarantees only exec and prepare, so better-sqlite3's db.transaction() is not available on node:sqlite. Use explicit db.exec('BEGIN') / COMMIT / ROLLBACK, which both drivers support.
- Step 1: Write the adapter test and run it to verify RED
Create packages/storage-sqlite/test/space.test.js:
import { runSpaceStorageConformance } from '../../../test/helpers/space-storage-conformance.js';
import { createSpaceStorage } from '../src/space.js';
runSpaceStorageConformance('storage-sqlite', async () => {
const Database = (await import('better-sqlite3')).default;
return createSpaceStorage(new Database(':memory:'));
});
Run: npx vitest run packages/storage-sqlite/test/space.test.js
Expected: FAIL — Failed to resolve import "../src/space.js".
- Step 2: Define the schema once, in core
Both storage adapters use the same seven tables. The DDL is declared in @pdsjs/core beside SpaceStoragePort — core states the contract, adapters implement it — so neither storage package needs a dependency on @pdsjs/spaces.
Create packages/core/src/space-schema.js:
// @pdsjs/core/space-schema - permissioned space tables (proposal 0016).
//
// Declared here, beside the SpaceStoragePort typedef in ports.js, because both
// SQLite adapters need it and neither may depend on @pdsjs/spaces without making
// the spaces package non-optional. Ported from the reference implementation's
// packages/pds/src/actor-store/db/migrations/002-space.ts.
//
// Idempotent: safe to run on every construction. Nothing here executes unless
// an adapter calls it, so a deployment that never enables spaces never creates
// these tables.
export const SPACE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS space (
uri TEXT PRIMARY KEY,
is_owner INTEGER NOT NULL,
policy TEXT NOT NULL DEFAULT 'member-list',
managing_app TEXT,
app_access_type TEXT NOT NULL DEFAULT 'open',
app_allowed TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS space_member (
space TEXT NOT NULL,
did TEXT NOT NULL,
PRIMARY KEY (space, did)
);
CREATE TABLE IF NOT EXISTS space_record (
space TEXT NOT NULL,
collection TEXT NOT NULL,
rkey TEXT NOT NULL,
cid TEXT NOT NULL,
value BLOB NOT NULL,
repo_rev TEXT NOT NULL,
indexed_at TEXT NOT NULL,
PRIMARY KEY (space, collection, rkey)
);
CREATE INDEX IF NOT EXISTS space_record_rev_idx
ON space_record(space, repo_rev);
CREATE TABLE IF NOT EXISTS space_repo (
space TEXT PRIMARY KEY,
set_hash BLOB,
rev TEXT
);
CREATE TABLE IF NOT EXISTS space_record_oplog (
space TEXT NOT NULL,
rev TEXT NOT NULL,
idx INTEGER NOT NULL,
action TEXT NOT NULL,
collection TEXT NOT NULL,
rkey TEXT NOT NULL,
cid TEXT,
prev TEXT,
PRIMARY KEY (space, rev, idx)
);
CREATE TABLE IF NOT EXISTS space_writer (
space TEXT NOT NULL,
did TEXT NOT NULL,
rev TEXT NOT NULL,
hash BLOB NOT NULL,
PRIMARY KEY (space, did)
);
CREATE TABLE IF NOT EXISTS space_credential_recipient (
space TEXT NOT NULL,
service_did TEXT NOT NULL,
service_endpoint TEXT NOT NULL,
last_issued_at TEXT NOT NULL,
PRIMARY KEY (space, service_did)
);
`;
Add to the exports map in packages/core/package.json:
"./space-schema": "./src/space-schema.js"
packages/storage-sqlite/package.json currently has an empty dependencies object, yet its source already references @pdsjs/core/ports in JSDoc. Add the dependency so the import is honest:
"dependencies": { "@pdsjs/core": "workspace:*" }
Run pnpm install to link it.
- Step 3: Implement the adapter
Create packages/storage-sqlite/src/space.js:
// @pdsjs/storage-sqlite/space - SQLite SpaceStoragePort (proposal 0016).
//
// The schema is defined once in @pdsjs/core/space-schema and shared with the
// Cloudflare adapter; only the driver differs between them.
import { SPACE_SCHEMA_SQL } from '@pdsjs/core/space-schema';
/** @typedef {{uri: string, is_owner: number, policy: string, managing_app: string|null, app_access_type: string, app_allowed: string, created_at: string, deleted_at: string|null}} SpaceDbRow */
/** @typedef {{collection: string, rkey: string, cid: string, value: Uint8Array|Buffer, repo_rev: string, indexed_at: string}} SpaceRecordDbRow */
/** @typedef {{rev: string, idx: number, action: string, collection: string, rkey: string, cid: string|null, prev: string|null}} SpaceOpDbRow */
/** @typedef {{did: string, rev: string, hash: Uint8Array|Buffer}} SpaceWriterDbRow */
/** @typedef {{service_did: string, service_endpoint: string, last_issued_at: string}} SpaceRecipientDbRow */
/**
* @param {Uint8Array|Buffer|null} value
* @returns {Uint8Array|null}
*/
function toBytes(value) {
if (!value) return null;
return value instanceof Uint8Array && value.constructor === Uint8Array
? value
: new Uint8Array(value);
}
/**
* @param {SpaceDbRow} row
* @returns {import('@pdsjs/core/ports').SpaceRow}
*/
function toSpaceRow(row) {
return {
uri: row.uri,
isOwner: row.is_owner === 1,
policy: row.policy,
managingApp: row.managing_app,
appAccessType: row.app_access_type,
appAllowed: JSON.parse(row.app_allowed),
createdAt: row.created_at,
deletedAt: row.deleted_at,
};
}
/**
* @param {SpaceRecordDbRow} row
* @returns {import('@pdsjs/core/ports').SpaceRecordRow}
*/
function toRecordRow(row) {
return {
collection: row.collection,
rkey: row.rkey,
cid: row.cid,
value: /** @type {Uint8Array} */ (toBytes(row.value)),
repoRev: row.repo_rev,
indexedAt: row.indexed_at,
};
}
/**
* @param {import('./driver.js').SQLiteDatabase} db
* @returns {import('@pdsjs/core/ports').SpaceStoragePort}
*/
export function createSpaceStorage(db) {
db.exec(SPACE_SCHEMA_SQL);
const stmts = {
getSpace: db.prepare('SELECT * FROM space WHERE uri = ?'),
putSpace: db.prepare(
`INSERT OR REPLACE INTO space
(uri, is_owner, policy, managing_app, app_access_type, app_allowed, created_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
),
deleteSpace: db.prepare('UPDATE space SET deleted_at = ? WHERE uri = ?'),
listSpaces: db.prepare(
'SELECT * FROM space WHERE uri > ? ORDER BY uri LIMIT ?',
),
addMember: db.prepare(
'INSERT OR IGNORE INTO space_member (space, did) VALUES (?, ?)',
),
removeMember: db.prepare(
'DELETE FROM space_member WHERE space = ? AND did = ?',
),
listMembers: db.prepare(
'SELECT did FROM space_member WHERE space = ? AND did > ? ORDER BY did LIMIT ?',
),
isMember: db.prepare(
'SELECT 1 AS found FROM space_member WHERE space = ? AND did = ?',
),
getRecord: db.prepare(
'SELECT collection, rkey, cid, value, repo_rev, indexed_at FROM space_record WHERE space = ? AND collection = ? AND rkey = ?',
),
getRecordCid: db.prepare(
'SELECT cid FROM space_record WHERE space = ? AND collection = ? AND rkey = ?',
),
listRecords: db.prepare(
'SELECT collection, rkey, cid, value, repo_rev, indexed_at FROM space_record WHERE space = ? AND collection = ? AND rkey > ? ORDER BY rkey LIMIT ?',
),
listAllRecords: db.prepare(
"SELECT collection, rkey, cid FROM space_record WHERE space = ? ORDER BY (collection || '/' || rkey)",
),
putRecord: db.prepare(
`INSERT OR REPLACE INTO space_record
(space, collection, rkey, cid, value, repo_rev, indexed_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
),
deleteRecord: db.prepare(
'DELETE FROM space_record WHERE space = ? AND collection = ? AND rkey = ?',
),
putOp: db.prepare(
`INSERT INTO space_record_oplog
(space, rev, idx, action, collection, rkey, cid, prev)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
),
listOps: db.prepare(
'SELECT rev, idx, action, collection, rkey, cid, prev FROM space_record_oplog WHERE space = ? AND rev > ? ORDER BY rev, idx LIMIT ?',
),
putRepo: db.prepare(
'INSERT OR REPLACE INTO space_repo (space, set_hash, rev) VALUES (?, ?, ?)',
),
getRepo: db.prepare('SELECT set_hash, rev FROM space_repo WHERE space = ?'),
putWriter: db.prepare(
'INSERT OR REPLACE INTO space_writer (space, did, rev, hash) VALUES (?, ?, ?, ?)',
),
listWriters: db.prepare(
'SELECT did, rev, hash FROM space_writer WHERE space = ? AND did > ? ORDER BY did LIMIT ?',
),
putRecipient: db.prepare(
`INSERT OR REPLACE INTO space_credential_recipient
(space, service_did, service_endpoint, last_issued_at)
VALUES (?, ?, ?, ?)`,
),
listRecipients: db.prepare(
'SELECT service_did, service_endpoint, last_issued_at FROM space_credential_recipient WHERE space = ? ORDER BY service_did',
),
};
return {
async getSpace(uri) {
const row = /** @type {SpaceDbRow|undefined} */ (stmts.getSpace.get(uri));
return row ? toSpaceRow(row) : null;
},
async putSpace(space) {
stmts.putSpace.run(
space.uri,
space.isOwner ? 1 : 0,
space.policy,
space.managingApp,
space.appAccessType,
JSON.stringify(space.appAllowed),
space.createdAt,
space.deletedAt,
);
},
async deleteSpace(uri, deletedAt) {
stmts.deleteSpace.run(deletedAt, uri);
},
async listSpaces({ type = null, did = null, cursor = null, limit = 50 }) {
// Filtering is by URI shape, so it is applied after the keyset page.
// Over-fetch so a filtered page can still fill.
const rows = /** @type {SpaceDbRow[]} */ (
stmts.listSpaces.all(cursor ?? '', type || did ? limit * 10 : limit)
);
let spaces = rows.map(toSpaceRow);
if (type || did) {
spaces = spaces.filter((s) => {
const segments = s.uri.slice('at://'.length).split('/');
if (did && segments[0] !== did) return false;
if (type && segments[2] !== type) return false;
return true;
});
}
const pageRows = spaces.slice(0, limit);
return {
spaces: pageRows,
// Same rule as every other paginated method: a full page always yields
// a cursor. See the note in the memory adapter's `page` helper.
cursor:
pageRows.length === limit
? pageRows[pageRows.length - 1].uri
: null,
};
},
async addMember(space, did) {
stmts.addMember.run(space, did);
},
async removeMember(space, did) {
stmts.removeMember.run(space, did);
},
async listMembers(space, cursor, limit) {
const rows = /** @type {{did: string}[]} */ (
stmts.listMembers.all(space, cursor ?? '', limit)
);
const dids = rows.map((r) => r.did);
return {
dids,
cursor: dids.length === limit ? dids[dids.length - 1] : null,
};
},
async isMember(space, did) {
return stmts.isMember.get(space, did) !== undefined;
},
async getSpaceRecord(space, collection, rkey) {
const row = /** @type {SpaceRecordDbRow|undefined} */ (
stmts.getRecord.get(space, collection, rkey)
);
return row ? toRecordRow(row) : null;
},
async getSpaceRecordCid(space, collection, rkey) {
const row = /** @type {{cid: string}|undefined} */ (
stmts.getRecordCid.get(space, collection, rkey)
);
return row ? row.cid : null;
},
async listSpaceRecords(space, collection, cursor, limit) {
const rows = /** @type {SpaceRecordDbRow[]} */ (
stmts.listRecords.all(space, collection, cursor ?? '', limit)
);
const records = rows.map(toRecordRow);
return {
records,
cursor:
records.length === limit ? records[records.length - 1].rkey : null,
};
},
async listAllSpaceRecords(space) {
return /** @type {Array<{collection: string, rkey: string, cid: string}>} */ (
stmts.listAllRecords.all(space)
);
},
async commitSpaceWrite(space, { writes, setHash, rev, indexedAt }) {
for (const w of writes) {
if (w.action !== 'delete' && !(w.value instanceof Uint8Array)) {
throw new Error(
`space write value must be Uint8Array: ${w.collection}/${w.rkey}`,
);
}
}
db.exec('BEGIN');
try {
writes.forEach((w, idx) => {
if (w.action === 'delete') {
stmts.deleteRecord.run(space, w.collection, w.rkey);
} else {
stmts.putRecord.run(
space,
w.collection,
w.rkey,
w.cid,
w.value,
rev,
indexedAt,
);
}
stmts.putOp.run(
space,
rev,
idx,
w.action,
w.collection,
w.rkey,
w.cid,
w.prev,
);
});
stmts.putRepo.run(space, setHash, rev);
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
},
async getSpaceRepo(space) {
const row = /** @type {{set_hash: Uint8Array|Buffer|null, rev: string|null}|undefined} */ (
stmts.getRepo.get(space)
);
if (!row) return null;
return { setHash: toBytes(row.set_hash), rev: row.rev };
},
async listSpaceOps(space, since, limit) {
const rows = /** @type {SpaceOpDbRow[]} */ (
stmts.listOps.all(space, since ?? '', limit)
);
return rows.map((r) => ({
rev: r.rev,
idx: r.idx,
action: /** @type {'create'|'update'|'delete'} */ (r.action),
collection: r.collection,
rkey: r.rkey,
cid: r.cid,
prev: r.prev,
}));
},
async putSpaceWriter(space, did, rev, hash) {
stmts.putWriter.run(space, did, rev, hash);
},
async listSpaceWriters(space, cursor, limit) {
const rows = /** @type {SpaceWriterDbRow[]} */ (
stmts.listWriters.all(space, cursor ?? '', limit)
);
const writers = rows.map((r) => ({
did: r.did,
rev: r.rev,
hash: /** @type {Uint8Array} */ (toBytes(r.hash)),
}));
return {
cursor: writers.length === limit ? writers[writers.length - 1].did : null,
writers,
};
},
async putCredentialRecipient(space, serviceDid, serviceEndpoint, lastIssuedAt) {
stmts.putRecipient.run(space, serviceDid, serviceEndpoint, lastIssuedAt);
},
async listCredentialRecipients(space) {
const rows = /** @type {SpaceRecipientDbRow[]} */ (
stmts.listRecipients.all(space)
);
return rows.map((r) => ({
serviceDid: r.service_did,
serviceEndpoint: r.service_endpoint,
lastIssuedAt: r.last_issued_at,
}));
},
};
}
Two things to watch:
-
idxis a SQLite keyword-adjacent identifier but is legal unquoted. If any driver rejects it, quote it as"idx"consistently in the DDL and all statements. -
better-sqlite3bindsUint8Arrayto BLOB directly and returnsBuffer.Bufferis aUint8Arraysubclass, sotoByteschecksconstructor === Uint8Arrayto force a plain copy — the conformance suite assertstoBeInstanceOf(Uint8Array), which aBufferwould satisfy, so this is about downstream code that serialises the value, not about passing the test. -
Step 4: Run to verify GREEN
Run: npx vitest run packages/storage-sqlite/test/space.test.js
Expected: PASS, 36 tests — the same suite the memory adapter passes.
If the cursor tests fail, the likely cause is the cursor ?? '' sentinel: the empty string sorts before every real key, which is what makes WHERE x > ? work for the first page. Do not replace it with a NULL comparison — NULL > x is never true in SQL.
- Step 5: Re-export and commit
Add to packages/storage-sqlite/src/index.js:
export { createSpaceStorage } from './space.js';
npx biome check --write packages/storage-sqlite packages/core/src/space-schema.js
npx vitest run packages/storage-sqlite packages/spaces packages/core
git add packages/storage-sqlite packages/core pnpm-lock.yaml
git commit -m "feat(storage-sqlite): SpaceStoragePort implementation with shared schema"
Task 5: Cloudflare Durable Object implementation
Files:
- Create:
test/helpers/d1-shim.js - Create:
packages/cloudflare/src/space.js - Create:
packages/cloudflare/test/space.test.js
Interfaces:
- Consumes:
runSpaceStorageConformance(Task 1);SPACE_SCHEMA_SQLfrom@pdsjs/core/space-schema(created in Task 4 — the same DDL both adapters run). - Produces:
createSpaceStorage(sql, storage)frompackages/cloudflare/src/space.js, wheresqlisstate.storage.sqlandstorageisstate.storage(needed for transactions).
Why two arguments. Durable Object SQL rejects explicit BEGIN/COMMIT through sql.exec; atomic multi-statement work uses state.storage.transactionSync(callback). The existing createActorStorage(sql) takes only sql because it never needs a transaction. createSpaceStorage needs state.storage as well.
Verify this before building on it. Confirm transactionSync exists on the DO storage object in this project's wrangler/workers-types version before writing the adapter around it — check node_modules/@cloudflare/workers-types for transactionSync on the SQLite storage interface. If it is absent, fall back to relying on the DO input gate (all sql.exec calls inside one synchronous function run without interleaving) and record that decision in your report, because it changes the atomicity guarantee under an exception.
- Step 1: Build the D1 shim
The DO runtime is not available under plain vitest, so the conformance suite runs the adapter against a shim that presents better-sqlite3 through the DO sql.exec interface.
Create test/helpers/d1-shim.js:
// Adapts better-sqlite3 to the Cloudflare Durable Object SQL interface, so the
// DO adapter's SQL and logic can run under plain vitest. This does NOT test the
// DO runtime itself — that is covered by the PLATFORM=cloudflare e2e path.
/**
* @param {import('better-sqlite3').Database} db
* @returns {{sql: {exec: (query: string, ...params: any[]) => {toArray: () => any[]}},
* storage: {transactionSync: (fn: () => void) => void}}}
*/
export function createD1Shim(db) {
return {
sql: {
exec(query, ...params) {
// DO's exec accepts multi-statement DDL; better-sqlite3 needs .exec for that.
const trimmed = query.trim();
if (params.length === 0 && trimmed.includes(';')) {
db.exec(query);
return { toArray: () => [] };
}
const stmt = db.prepare(query);
if (/^\s*select/i.test(trimmed)) {
const rows = stmt.all(...params);
return { toArray: () => rows };
}
stmt.run(...params);
return { toArray: () => [] };
},
},
storage: {
transactionSync(fn) {
db.exec('BEGIN');
try {
fn();
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
},
},
};
}
- Step 2: Write the adapter test and run it to verify RED
Create packages/cloudflare/test/space.test.js:
import { createD1Shim } from '../../../test/helpers/d1-shim.js';
import { runSpaceStorageConformance } from '../../../test/helpers/space-storage-conformance.js';
import { createSpaceStorage } from '../src/space.js';
runSpaceStorageConformance('cloudflare (D1 shim)', async () => {
const Database = (await import('better-sqlite3')).default;
const { sql, storage } = createD1Shim(new Database(':memory:'));
return createSpaceStorage(sql, storage);
});
Run: npx vitest run packages/cloudflare/test/space.test.js
Expected: FAIL — Failed to resolve import "../src/space.js".
- Step 3: Implement the DO adapter
Create packages/cloudflare/src/space.js:
// @pdsjs/cloudflare/space - Durable Object SQLite SpaceStoragePort (proposal 0016).
//
// Shares its schema with the storage-sqlite adapter via @pdsjs/core/space-schema;
// only the driver differs. Behaviour is pinned by the shared conformance suite
// in test/helpers/space-storage-conformance.js.
import { SPACE_SCHEMA_SQL } from '@pdsjs/core/space-schema';
/** @typedef {{uri: string, is_owner: number, policy: string, managing_app: string|null, app_access_type: string, app_allowed: string, created_at: string, deleted_at: string|null}} SpaceDbRow */
/** @typedef {{collection: string, rkey: string, cid: string, value: ArrayBuffer|Uint8Array, repo_rev: string, indexed_at: string}} SpaceRecordDbRow */
/** @typedef {{rev: string, idx: number, action: string, collection: string, rkey: string, cid: string|null, prev: string|null}} SpaceOpDbRow */
/** @typedef {{did: string, rev: string, hash: ArrayBuffer|Uint8Array}} SpaceWriterDbRow */
/** @typedef {{service_did: string, service_endpoint: string, last_issued_at: string}} SpaceRecipientDbRow */
/**
* Durable Object SQLite returns ArrayBuffer for BLOB columns.
* @param {ArrayBuffer|Uint8Array|null|undefined} value
* @returns {Uint8Array|null}
*/
function normalizeBlob(value) {
if (!value) return null;
if (value instanceof Uint8Array) return value;
return new Uint8Array(value);
}
/**
* @param {SpaceDbRow} row
* @returns {import('@pdsjs/core/ports').SpaceRow}
*/
function toSpaceRow(row) {
return {
uri: row.uri,
isOwner: row.is_owner === 1,
policy: row.policy,
managingApp: row.managing_app,
appAccessType: row.app_access_type,
appAllowed: JSON.parse(row.app_allowed),
createdAt: row.created_at,
deletedAt: row.deleted_at,
};
}
/**
* @param {SpaceRecordDbRow} row
* @returns {import('@pdsjs/core/ports').SpaceRecordRow}
*/
function toRecordRow(row) {
return {
collection: row.collection,
rkey: row.rkey,
cid: row.cid,
value: /** @type {Uint8Array} */ (normalizeBlob(row.value)),
repoRev: row.repo_rev,
indexedAt: row.indexed_at,
};
}
/**
* @param {any} sql - D1SqlInterface from state.storage.sql
* @param {{transactionSync: (fn: () => void) => void}} storage - state.storage
* @returns {import('@pdsjs/core/ports').SpaceStoragePort}
*/
export function createSpaceStorage(sql, storage) {
sql.exec(SPACE_SCHEMA_SQL);
/**
* Single-row read. Do NOT use cursor.one() — it throws on an empty result.
* @param {string} query
* @param {...any} params
* @returns {any|null}
*/
function one(query, ...params) {
const rows = sql.exec(query, ...params).toArray();
return rows.length > 0 ? rows[0] : null;
}
/**
* @param {string} query
* @param {...any} params
* @returns {any[]}
*/
function all(query, ...params) {
return sql.exec(query, ...params).toArray();
}
return {
async getSpace(uri) {
const row = one('SELECT * FROM space WHERE uri = ?', uri);
return row ? toSpaceRow(row) : null;
},
async putSpace(space) {
sql.exec(
`INSERT OR REPLACE INTO space
(uri, is_owner, policy, managing_app, app_access_type, app_allowed, created_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
space.uri,
space.isOwner ? 1 : 0,
space.policy,
space.managingApp,
space.appAccessType,
JSON.stringify(space.appAllowed),
space.createdAt,
space.deletedAt,
);
},
async deleteSpace(uri, deletedAt) {
sql.exec('UPDATE space SET deleted_at = ? WHERE uri = ?', deletedAt, uri);
},
async listSpaces({ type = null, did = null, cursor = null, limit = 50 }) {
// Filtering is by URI shape, so it is applied after the keyset page.
// Over-fetch so a filtered page can still fill.
const rows = all(
'SELECT * FROM space WHERE uri > ? ORDER BY uri LIMIT ?',
cursor ?? '',
type || did ? limit * 10 : limit,
);
let spaces = rows.map(toSpaceRow);
if (type || did) {
spaces = spaces.filter((s) => {
const segments = s.uri.slice('at://'.length).split('/');
if (did && segments[0] !== did) return false;
if (type && segments[2] !== type) return false;
return true;
});
}
const pageRows = spaces.slice(0, limit);
return {
spaces: pageRows,
cursor:
pageRows.length === limit
? pageRows[pageRows.length - 1].uri
: null,
};
},
async addMember(space, did) {
sql.exec(
'INSERT OR IGNORE INTO space_member (space, did) VALUES (?, ?)',
space,
did,
);
},
async removeMember(space, did) {
sql.exec(
'DELETE FROM space_member WHERE space = ? AND did = ?',
space,
did,
);
},
async listMembers(space, cursor, limit) {
const rows = all(
'SELECT did FROM space_member WHERE space = ? AND did > ? ORDER BY did LIMIT ?',
space,
cursor ?? '',
limit,
);
const dids = rows.map((r) => r.did);
return {
dids,
cursor: dids.length === limit ? dids[dids.length - 1] : null,
};
},
async isMember(space, did) {
return (
one(
'SELECT 1 AS found FROM space_member WHERE space = ? AND did = ?',
space,
did,
) !== null
);
},
async getSpaceRecord(space, collection, rkey) {
const row = one(
'SELECT collection, rkey, cid, value, repo_rev, indexed_at FROM space_record WHERE space = ? AND collection = ? AND rkey = ?',
space,
collection,
rkey,
);
return row ? toRecordRow(row) : null;
},
async getSpaceRecordCid(space, collection, rkey) {
const row = one(
'SELECT cid FROM space_record WHERE space = ? AND collection = ? AND rkey = ?',
space,
collection,
rkey,
);
return row ? row.cid : null;
},
async listSpaceRecords(space, collection, cursor, limit) {
const rows = all(
'SELECT collection, rkey, cid, value, repo_rev, indexed_at FROM space_record WHERE space = ? AND collection = ? AND rkey > ? ORDER BY rkey LIMIT ?',
space,
collection,
cursor ?? '',
limit,
);
const records = rows.map(toRecordRow);
return {
records,
cursor:
records.length === limit ? records[records.length - 1].rkey : null,
};
},
async listAllSpaceRecords(space) {
return all(
"SELECT collection, rkey, cid FROM space_record WHERE space = ? ORDER BY (collection || '/' || rkey)",
space,
);
},
async commitSpaceWrite(space, { writes, setHash, rev, indexedAt }) {
// Validate everything before opening the transaction.
for (const w of writes) {
if (w.action !== 'delete' && !(w.value instanceof Uint8Array)) {
throw new Error(
`space write value must be Uint8Array: ${w.collection}/${w.rkey}`,
);
}
}
// Durable Object SQL rejects explicit BEGIN/COMMIT; transactionSync is
// the supported way to make several statements atomic.
storage.transactionSync(() => {
writes.forEach((w, idx) => {
if (w.action === 'delete') {
sql.exec(
'DELETE FROM space_record WHERE space = ? AND collection = ? AND rkey = ?',
space,
w.collection,
w.rkey,
);
} else {
sql.exec(
`INSERT OR REPLACE INTO space_record
(space, collection, rkey, cid, value, repo_rev, indexed_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
space,
w.collection,
w.rkey,
w.cid,
w.value,
rev,
indexedAt,
);
}
sql.exec(
`INSERT INTO space_record_oplog
(space, rev, idx, action, collection, rkey, cid, prev)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
space,
rev,
idx,
w.action,
w.collection,
w.rkey,
w.cid,
w.prev,
);
});
sql.exec(
'INSERT OR REPLACE INTO space_repo (space, set_hash, rev) VALUES (?, ?, ?)',
space,
setHash,
rev,
);
});
},
async getSpaceRepo(space) {
const row = one(
'SELECT set_hash, rev FROM space_repo WHERE space = ?',
space,
);
if (!row) return null;
return { setHash: normalizeBlob(row.set_hash), rev: row.rev };
},
async listSpaceOps(space, since, limit) {
const rows = all(
'SELECT rev, idx, action, collection, rkey, cid, prev FROM space_record_oplog WHERE space = ? AND rev > ? ORDER BY rev, idx LIMIT ?',
space,
since ?? '',
limit,
);
return rows.map((r) => ({
rev: r.rev,
idx: r.idx,
action: /** @type {'create'|'update'|'delete'} */ (r.action),
collection: r.collection,
rkey: r.rkey,
cid: r.cid,
prev: r.prev,
}));
},
async putSpaceWriter(space, did, rev, hash) {
sql.exec(
'INSERT OR REPLACE INTO space_writer (space, did, rev, hash) VALUES (?, ?, ?, ?)',
space,
did,
rev,
hash,
);
},
async listSpaceWriters(space, cursor, limit) {
const rows = all(
'SELECT did, rev, hash FROM space_writer WHERE space = ? AND did > ? ORDER BY did LIMIT ?',
space,
cursor ?? '',
limit,
);
const writers = rows.map((r) => ({
did: r.did,
rev: r.rev,
hash: /** @type {Uint8Array} */ (normalizeBlob(r.hash)),
}));
return {
writers,
cursor:
writers.length === limit ? writers[writers.length - 1].did : null,
};
},
async putCredentialRecipient(space, serviceDid, serviceEndpoint, lastIssuedAt) {
sql.exec(
`INSERT OR REPLACE INTO space_credential_recipient
(space, service_did, service_endpoint, last_issued_at)
VALUES (?, ?, ?, ?)`,
space,
serviceDid,
serviceEndpoint,
lastIssuedAt,
);
},
async listCredentialRecipients(space) {
const rows = all(
'SELECT service_did, service_endpoint, last_issued_at FROM space_credential_recipient WHERE space = ? ORDER BY service_did',
space,
);
return rows.map((r) => ({
serviceDid: r.service_did,
serviceEndpoint: r.service_endpoint,
lastIssuedAt: r.last_issued_at,
}));
},
};
}
Two notes:
-
The
one/allhelpers exist becausecursor.one()throws on an empty result — the same reasonpackages/cloudflare/src/index.jscarries its ownsafeOne. Do not replace them with.one(). -
sqlis typedanyrather than the package'sD1SqlInterfacetypedef, because the D1 shim in the test passes a plain object. If you can reference the existing typedef without breaking the shim's type, prefer it. -
Step 4: Run to verify GREEN
Run: npx vitest run packages/cloudflare/test/space.test.js
Expected: PASS, 36 tests — the identical suite the other two adapters pass.
- Step 5: Do not wire it into the Durable Object
Leave packages/cloudflare/src/index.js untouched. createSpaceStorage is exported and tested, but nothing constructs it yet — phase 3 does that when it registers the routes that consume it, which is one line at that point.
Resist the temptation to stash it on the PDSDurableObject instance "ready for phase 3". An instance field nothing reads is dead code: it creates the seven tables in every deployment whether or not spaces are enabled, and it contradicts the flag design in spec §3, where the whole feature is meant to be inert until PDS_ENABLE_SPACES turns it on.
Confirm with git status that packages/cloudflare/src/index.js is not modified before committing.
- Step 6: Format and commit
npx biome check --write packages/cloudflare test/helpers/d1-shim.js
npx vitest run
git add packages/cloudflare test/helpers
git commit -m "feat(cloudflare): SpaceStoragePort implementation for the Durable Object"
Task 6: Reconcile the spec
Files:
- Modify:
docs/superpowers/specs/2026-07-30-permissioned-data-design.md
Interfaces:
-
Consumes: everything built in Tasks 1–5.
-
Produces: a spec that matches the code.
-
Step 1: Amend §4
Three things in §4 are now out of date:
- The method table lists 18 methods in groups. Replace it with the 19 actual method names as implemented, grouped the same way.
- "An in-memory
SpaceStoragePortimplementation backed byMaps serves the unit tests" understates what was built. Describe the shared conformance suite: one behavioural contract intest/helpers/space-storage-conformance.jsthat all three implementations run, and say why — it converts the accepted DDL-duplication risk from an e2e-only check into a unit-test-speed one. - The "Accepted cost: duplicated DDL" subsection is now wrong and must be rewritten, not annotated. Its premise was that sharing the DDL "would require core or the storage packages to import
@pdsjs/spaces, which would invert the dependency direction established in §1." That is true of@pdsjs/spacesbut not of@pdsjs/core, which already declaresSpaceStoragePort. The DDL lives inpackages/core/src/space-schema.jsand both adapters import it, so there is no duplication and no drift to police. Replace the subsection with a short note on where the schema lives and why core is the right home — and state plainly that@pdsjs/spacesremains optional, since no storage package depends on it.
Also correct the sentence in §4 reading "Each backend declares the seven tables itself."
- Step 2: Amend §1's file tree
Add to the packages/spaces/src/ listing:
memory-storage.js in-memory SpaceStoragePort for tests
- Step 3: Record the transaction asymmetry
Add a short subsection to §4 noting that the two backends cannot obtain atomicity the same way — storage-sqlite uses BEGIN/COMMIT through the driver's exec, while the Durable Object uses state.storage.transactionSync because DO SQL rejects explicit transaction statements — and that this is why the Cloudflare factory takes (sql, storage) while the SQLite one takes (db).
- Step 4: Commit
git add docs/superpowers/specs/2026-07-30-permissioned-data-design.md
git commit -m "docs: reconcile spec section 4 with the implemented storage port"
Phase 2 exit criteria
-
npx vitest runpasses with no regressions (phase-1 baseline: 393 tests) -
All three adapters pass the identical conformance suite — grep for
runSpaceStorageConformancereturns exactly three call sites -
npx tsc --noEmit 2>&1 | grep -c "error TS"is no greater than 114 (the phase-1 exit count) -
packages/spaces/package.jsondependenciesstill contains only@pdsjs/core -
packages/core/src/ports.jscontains no runtime code and still ends withexport {} -
grep -rnE "(^|[^*])\s*import .*@pdsjs/spaces|require\(['\"]@pdsjs/spaces" packages/core/src | wc -lreturns 0 -
The seven-table DDL exists in exactly one place:
grep -rln "CREATE TABLE IF NOT EXISTS space_record_oplog" packages | wc -lreturns 1, and that file ispackages/core/src/space-schema.js -
packages/cloudflare/src/index.jsis unmodified by this phase — nothing constructs space storage until phase 3 registers routes that use it
Not in this phase
- Wiring
spaceStorageinto the PDS constructor. Nothing consumes it until phase 3's write path; adding config now would be dead weight. Task 5 stashes it on the Durable Object instance and the Node/Deno platform packages are left alone. - The
PDS_ENABLE_SPACESflag and route registration. Phase 3, when there are routes to register. - Real Durable Object runtime coverage. Task 5's shim tests the adapter's SQL and logic under vitest; the DO runtime itself is exercised by the existing
PLATFORM=cloudflaree2e path once phase 3 adds endpoints that touch space storage.