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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
2215 lines · 81.4 KB Raw History

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/atproto PR #5187, head 24a81bf5ab8d39557da35a4c4c0978c9aa62c45a) is authoritative for the schema.
  • @pdsjs/spaces has zero external dependencies; dependencies stays {"@pdsjs/core": "workspace:*"}.
  • @pdsjs/core must never import @pdsjs/spaces. packages/core/src/ports.js stays runtime-free — pure JSDoc, ending in export {}.
  • Plain ESM JS with JSDoc annotations. No TypeScript source files.
  • Every new function gets JSDoc param and return annotations. Phase 1 shipped 44 implicit-any errors; do not add more. Measure with npx 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 --write to 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

FileResponsibility
packages/core/src/ports.jsSpaceStoragePort + row typedefs (JSDoc only)
packages/core/src/space-schema.jsSPACE_SCHEMA_SQL — the seven-table DDL, defined once
test/helpers/space-storage-conformance.jsthe behavioural contract, as a runnable suite
packages/spaces/src/memory-storage.jsin-memory Map implementation
packages/spaces/test/memory-storage.test.jsruns the conformance suite
packages/storage-sqlite/src/space.jsSQLite DDL + createSpaceStorage(db)
packages/storage-sqlite/test/space.test.jsruns the conformance suite
packages/cloudflare/src/space.jsDO SQL DDL + createSpaceStorage(sql, storage)
packages/cloudflare/test/space.test.jsruns the conformance suite via a D1 shim
test/helpers/d1-shim.jsadapts 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:

  1. listAllSpaceRecords returns records ordered lexicographically by "{collection}/{rkey}". getRepo (phase 4) serialises in exactly this order, so it is load-bearing, not cosmetic.
  2. Cursors are opaque strings. null means no more pages. Pagination is keyset, not offset.
  3. commitSpaceWrite is atomic: on failure, none of the three tables change.
  4. Oplog idx numbers the writes within one rev, starting at 0, in the order given.
  5. listSpaceOps(space, since, limit) returns ops with rev > since (exclusive), ordered by (rev, idx). since of null means from the beginning.
  6. Blob columns come back as Uint8Array, never ArrayBuffer or Buffer. The Cloudflare adapter must normalise; the SQLite adapter must convert Buffer.
  7. deleteSpace is a soft delete: the row stays, deletedAt is set. getSpace still 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) from test/helpers/space-storage-conformance.js, where createStorage is () => Promise<SpaceStoragePort> returning a fresh empty store. Tasks 4 and 5 call this.
    • createMemorySpaceStorage() from packages/spaces/src/memory-storage.js.

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, listSpaceOps on 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, completing SpaceStoragePort. Exports createMemorySpaceStorage from 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_SQL from @pdsjs/core/space-schema — the seven-table DDL, which Task 5's Cloudflare adapter also imports. Also createSpaceStorage(db) from @pdsjs/storage-sqlite, where db is the existing SQLiteDatabase driver 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:

  • idx is 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-sqlite3 binds Uint8Array to BLOB directly and returns Buffer. Buffer is a Uint8Array subclass, so toBytes checks constructor === Uint8Array to force a plain copy — the conformance suite asserts toBeInstanceOf(Uint8Array), which a Buffer would 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_SQL from @pdsjs/core/space-schema (created in Task 4 — the same DDL both adapters run).
  • Produces: createSpaceStorage(sql, storage) from packages/cloudflare/src/space.js, where sql is state.storage.sql and storage is state.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/all helpers exist because cursor.one() throws on an empty result — the same reason packages/cloudflare/src/index.js carries its own safeOne. Do not replace them with .one().

  • sql is typed any rather than the package's D1SqlInterface typedef, 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:

  1. The method table lists 18 methods in groups. Replace it with the 19 actual method names as implemented, grouped the same way.
  2. "An in-memory SpaceStoragePort implementation backed by Maps serves the unit tests" understates what was built. Describe the shared conformance suite: one behavioural contract in test/helpers/space-storage-conformance.js that 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.
  3. 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/spaces but not of @pdsjs/core, which already declares SpaceStoragePort. The DDL lives in packages/core/src/space-schema.js and 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/spaces remains 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 run passes with no regressions (phase-1 baseline: 393 tests)
  • All three adapters pass the identical conformance suite — grep for runSpaceStorageConformance returns 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.json dependencies still contains only @pdsjs/core
  • packages/core/src/ports.js contains no runtime code and still ends with export {}
  • grep -rnE "(^|[^*])\s*import .*@pdsjs/spaces|require\(['\"]@pdsjs/spaces" packages/core/src | wc -l returns 0
  • The seven-table DDL exists in exactly one place: grep -rln "CREATE TABLE IF NOT EXISTS space_record_oplog" packages | wc -l returns 1, and that file is packages/core/src/space-schema.js
  • packages/cloudflare/src/index.js is unmodified by this phase — nothing constructs space storage until phase 3 registers routes that use it

Not in this phase

  • Wiring spaceStorage into 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_SPACES flag 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=cloudflare e2e path once phase 3 adds endpoints that touch space storage.