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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
949 lines · 29.0 KB Raw History
// @pdsjs/storage-sqlite - SQLite storage adapter for Node.js

import { APP_PASSWORD_SCHEMA_SQL } from '@pdsjs/core/app-password';
import { SESSION_SCHEMA_SQL } from '@pdsjs/core/auth';
import {
  BLOB_FACETS_APPS_SQL,
  BLOB_FACETS_KINDS_SQL,
  BLOB_FACETS_TOTAL_SQL,
  BLOB_FACETS_UNUSED_SQL,
  blobListCursor,
  buildBlobListQuery,
} from '@pdsjs/core/blob-sql';
import { PASSKEY_SCHEMA_SQL } from '@pdsjs/core/webauthn';

export { createSpaceStorage } from './space.js';

/** @typedef {{name: string, privileged: number, created_at: string}} AppPasswordRow */

/**
 * @param {AppPasswordRow} row
 * @returns {import('@pdsjs/core/ports').AppPasswordRecord}
 */
function toAppPassword(row) {
  return {
    name: row.name,
    privileged: row.privileged === 1,
    createdAt: row.created_at,
  };
}

// ============================================================================
// Row type definitions for SQLite queries
// ============================================================================

/** @typedef {{cid: string, value: Uint8Array}} RecordResult */
/** @typedef {{uri: string, cid: string, rkey: string, value: Uint8Array|Buffer}} RecordRow */
/** @typedef {{collection: string, rkey: string, cid: string}} RecordKeyRow */
/** @typedef {{data: Uint8Array}} BlockRow */
/** @typedef {{seq: number, cid: string, rev: string}} CommitRow */
/** @typedef {{seq: number, did: string, commit_cid: string, evt: Uint8Array}} EventRow */
/** @typedef {{mime_type: string, size: number, created_at: number}} BlobMetaRow */
/** @typedef {{cid: string, mime_type: string, size: number, created_at: number, uris: string|null, time_ms: number, time_known: number}} BlobDetailRow */
/** @typedef {{count: number, bytes: number}} BlobBucketRow */
/** @typedef {{kind: import('@pdsjs/core/ports').BlobKind, count: number, bytes: number}} BlobKindRow */
/** @typedef {{authority: string, count: number, bytes: number}} BlobAppRow */
/** @typedef {{cid: string}} CidRow */
/** @typedef {{value: string}} MetadataStringRow */
/** @typedef {{value: Buffer}} MetadataBlobRow */
/** @typedef {{data: string, expires_at: number}} OAuthRequestRow */
/** @typedef {{data: string}} OAuthTokenRow */
/** @typedef {{credential_id: string, did: string, name: string, public_key: string, algorithm: number, sign_count: number, created_at: string, last_used_at: string|null}} PasskeyRow */

/** @typedef {{jti: string, did: string, label: string, scope: string, user_agent: string|null, created_at: string, refreshed_at: string|null, expires_at: number|null, next_jti: string|null}} SessionRow */

/**
 * @param {SessionRow} row
 * @returns {import('@pdsjs/core/ports').SessionRecord}
 */
function toSessionRecord(row) {
  return {
    jti: row.jti,
    did: row.did,
    label: row.label,
    scope: row.scope,
    userAgent: row.user_agent,
    createdAt: row.created_at,
    refreshedAt: row.refreshed_at,
    expiresAt: row.expires_at ?? undefined,
    nextJti: row.next_jti ?? null,
  };
}

/**
 * @param {PasskeyRow} row
 * @returns {import('@pdsjs/core/ports').PasskeyRecord}
 */
function toPasskeyRecord(row) {
  return {
    credentialId: row.credential_id,
    did: row.did,
    name: row.name,
    publicKey: row.public_key,
    algorithm: row.algorithm,
    signCount: row.sign_count,
    createdAt: row.created_at,
    lastUsedAt: row.last_used_at,
  };
}
/** @typedef {{token_id: string, data: string}} OAuthTokenListRow */

// ============================================================================
// Storage adapter
// ============================================================================

/**
 * Create storage adapter for better-sqlite3 (Node.js)
 * @param {import('./driver.js').SQLiteDatabase} db
 * @returns {import('@pdsjs/core/ports').ActorStoragePort}
 */
export function createActorStorage(db) {
  // Initialize schema
  db.exec(`
    CREATE TABLE IF NOT EXISTS metadata (
      key TEXT PRIMARY KEY,
      value BLOB
    );

    CREATE TABLE IF NOT EXISTS blocks (
      cid TEXT PRIMARY KEY,
      data BLOB
    );

    CREATE TABLE IF NOT EXISTS records (
      uri TEXT PRIMARY KEY,
      cid TEXT,
      collection TEXT,
      rkey TEXT,
      value BLOB
    );
    CREATE INDEX IF NOT EXISTS idx_records_collection ON records(collection, rkey);
    CREATE INDEX IF NOT EXISTS idx_records_cid ON records(cid);

    CREATE TABLE IF NOT EXISTS commits (
      seq INTEGER PRIMARY KEY,
      cid TEXT,
      rev TEXT,
      prev TEXT
    );

    CREATE TABLE IF NOT EXISTS seq_events (
      seq INTEGER PRIMARY KEY,
      did TEXT,
      commit_cid TEXT,
      evt BLOB
    );

    CREATE TABLE IF NOT EXISTS blobs (
      cid TEXT PRIMARY KEY,
      mime_type TEXT,
      size INTEGER,
      created_at INTEGER
    );

    CREATE TABLE IF NOT EXISTS record_blobs (
      blob_cid TEXT,
      record_uri TEXT,
      record_time INTEGER,
      PRIMARY KEY (blob_cid, record_uri)
    );
  `);

  // Databases created before record_time existed gain the column in place.
  try {
    db.exec('ALTER TABLE record_blobs ADD COLUMN record_time INTEGER');
  } catch {
    // Already present
  }

  const stmts = {
    getRecord: db.prepare('SELECT cid, value FROM records WHERE uri = ?'),
    listRecords: db.prepare(
      'SELECT uri, cid, rkey, value FROM records WHERE collection = ? ORDER BY rkey LIMIT ?',
    ),
    listRecordsAfter: db.prepare(
      'SELECT uri, cid, rkey, value FROM records WHERE collection = ? AND rkey > ? ORDER BY rkey LIMIT ?',
    ),
    listRecordsDesc: db.prepare(
      'SELECT uri, cid, rkey, value FROM records WHERE collection = ? ORDER BY rkey DESC LIMIT ?',
    ),
    listRecordsBefore: db.prepare(
      'SELECT uri, cid, rkey, value FROM records WHERE collection = ? AND rkey < ? ORDER BY rkey DESC LIMIT ?',
    ),
    listAllRecords: db.prepare(
      "SELECT collection, rkey, cid FROM records ORDER BY (collection || '/' || rkey)",
    ),
    putRecord: db.prepare(
      'INSERT OR REPLACE INTO records (uri, cid, collection, rkey, value) VALUES (?, ?, ?, ?, ?)',
    ),
    deleteRecord: db.prepare('DELETE FROM records WHERE uri = ?'),
    deleteRecordBlobs: db.prepare(
      'DELETE FROM record_blobs WHERE record_uri = ?',
    ),
    getBlock: db.prepare('SELECT data FROM blocks WHERE cid = ?'),
    putBlock: db.prepare(
      'INSERT OR IGNORE INTO blocks (cid, data) VALUES (?, ?)',
    ),
    deleteBlock: db.prepare('DELETE FROM blocks WHERE cid = ?'),
    listBlocks: db.prepare('SELECT cid, data FROM blocks ORDER BY cid LIMIT ?'),
    listBlocksAfter: db.prepare(
      'SELECT cid, data FROM blocks WHERE cid > ? ORDER BY cid LIMIT ?',
    ),
    recordWithCid: db.prepare('SELECT 1 FROM records WHERE cid = ? LIMIT 1'),
    getLatestCommit: db.prepare(
      'SELECT seq, cid, rev FROM commits ORDER BY seq DESC LIMIT 1',
    ),
    putCommit: db.prepare(
      'INSERT INTO commits (seq, cid, rev, prev) VALUES (?, ?, ?, ?)',
    ),
    getEvents: db.prepare(
      'SELECT seq, did, commit_cid, evt FROM seq_events ORDER BY seq LIMIT ?',
    ),
    getEventsAfter: db.prepare(
      'SELECT seq, did, commit_cid, evt FROM seq_events WHERE seq > ? ORDER BY seq LIMIT ?',
    ),
    getEventsBefore: db.prepare(
      'SELECT seq, evt FROM seq_events WHERE seq < ? ORDER BY seq DESC LIMIT ?',
    ),
    putEvent: db.prepare(
      'INSERT INTO seq_events (seq, did, commit_cid, evt) VALUES (?, ?, ?, ?)',
    ),
    getBlob: db.prepare(
      'SELECT mime_type, size, created_at FROM blobs WHERE cid = ?',
    ),
    putBlob: db.prepare(
      'INSERT OR REPLACE INTO blobs (cid, mime_type, size, created_at) VALUES (?, ?, ?, ?)',
    ),
    deleteBlob: db.prepare('DELETE FROM blobs WHERE cid = ?'),
    listBlobs: db.prepare('SELECT cid FROM blobs ORDER BY cid LIMIT ?'),
    listBlobsAfter: db.prepare(
      'SELECT cid FROM blobs WHERE cid > ? ORDER BY cid LIMIT ?',
    ),
    blobFacetsTotal: db.prepare(BLOB_FACETS_TOTAL_SQL),
    blobFacetsUnused: db.prepare(BLOB_FACETS_UNUSED_SQL),
    blobFacetsKinds: db.prepare(BLOB_FACETS_KINDS_SQL),
    blobFacetsApps: db.prepare(BLOB_FACETS_APPS_SQL),
    getOrphanedBlobs: db.prepare(
      'SELECT b.cid FROM blobs b LEFT JOIN record_blobs rb ON b.cid = rb.blob_cid WHERE rb.blob_cid IS NULL AND b.created_at < ?',
    ),
    linkBlobToRecord: db.prepare(
      'INSERT OR REPLACE INTO record_blobs (blob_cid, record_uri, record_time) VALUES (?, ?, ?)',
    ),
    unlinkBlobsFromRecord: db.prepare(
      'DELETE FROM record_blobs WHERE record_uri = ?',
    ),
    listBlobRecordUris: db.prepare(
      'SELECT record_uri FROM record_blobs WHERE blob_cid = ?',
    ),
    getMetadata: db.prepare('SELECT value FROM metadata WHERE key = ?'),
    setMetadata: db.prepare(
      'INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)',
    ),
  };

  return {
    async getRecord(uri) {
      const row = /** @type {RecordResult|undefined} */ (
        stmts.getRecord.get(uri)
      );
      return row ? { cid: row.cid, value: row.value } : null;
    },

    async listRecords(collection, cursor, limit, reverse = false) {
      const stmt = reverse
        ? cursor
          ? stmts.listRecordsBefore
          : stmts.listRecordsDesc
        : cursor
          ? stmts.listRecordsAfter
          : stmts.listRecords;
      const rows = /** @type {RecordRow[]} */ (
        cursor
          ? stmt.all(collection, cursor, limit)
          : stmt.all(collection, limit)
      );
      return {
        records: rows.map((r) => ({ uri: r.uri, cid: r.cid, value: r.value })),
        cursor: rows.length === limit ? rows[rows.length - 1].rkey : null,
      };
    },

    async listAllRecords() {
      const rows = /** @type {RecordKeyRow[]} */ (stmts.listAllRecords.all());
      return rows.map((r) => ({
        key: `${r.collection}/${r.rkey}`,
        cid: r.cid,
      }));
    },

    async putRecord(uri, cid, collection, rkey, value) {
      stmts.putRecord.run(uri, cid, collection, rkey, value);
    },

    async deleteRecord(uri) {
      stmts.deleteRecordBlobs.run(uri);
      stmts.deleteRecord.run(uri);
    },

    async getBlock(cid) {
      const row = /** @type {BlockRow|undefined} */ (stmts.getBlock.get(cid));
      return row ? row.data : null;
    },

    async putBlock(cid, data) {
      stmts.putBlock.run(cid, data);
    },

    async deleteBlocks(cids) {
      for (const cid of cids) stmts.deleteBlock.run(cid);
    },

    async listBlocks(cursor, limit) {
      const rows = /** @type {Array<{cid: string, data: Uint8Array}>} */ (
        cursor
          ? stmts.listBlocksAfter.all(cursor, limit)
          : stmts.listBlocks.all(limit)
      );
      const last = rows.length === limit ? rows[limit - 1].cid : null;
      return { blocks: rows, cursor: last };
    },

    async recordCidsAmong(cids) {
      return cids.filter((cid) => stmts.recordWithCid.get(cid));
    },

    async getBlocksCollected() {
      const row = /** @type {MetadataStringRow|undefined} */ (
        stmts.getMetadata.get('blocksCollected')
      );
      return row?.value === '1';
    },

    async setBlocksCollected(collected) {
      stmts.setMetadata.run('blocksCollected', collected ? '1' : '0');
    },

    async getLatestCommit() {
      const row = /** @type {CommitRow|undefined} */ (
        stmts.getLatestCommit.get()
      );
      return row || null;
    },

    async putCommit(seq, cid, rev, prev) {
      stmts.putCommit.run(seq, cid, rev, prev);
    },

    async getEvents(cursor, limit) {
      const rows = /** @type {EventRow[]} */ (
        cursor
          ? stmts.getEventsAfter.all(cursor, limit)
          : stmts.getEvents.all(limit)
      );
      return {
        events: rows.map((r) => ({
          seq: r.seq,
          commit_cid: r.commit_cid,
          evt: r.evt,
        })),
        cursor: rows.length > 0 ? rows[rows.length - 1].seq : cursor,
      };
    },

    async putEvent(seq, did, commitCid, evt) {
      stmts.putEvent.run(seq, did, commitCid, evt);
    },

    async getEventsBefore(before, limit) {
      const rows = /** @type {{seq: number, evt: Uint8Array}[]} */ (
        stmts.getEventsBefore.all(before, limit)
      );
      return rows.map((r) => ({ seq: r.seq, evt: r.evt }));
    },

    async getLatestSeq() {
      // Max across both sequenced tables: a crash between putCommit and
      // putEvent leaves a commit whose seq the events table never saw, and
      // counting only events would hand that seq out again forever.
      const row = /** @type {{seq: number|null}|undefined} */ (
        db
          .prepare(
            'SELECT MAX(s) as seq FROM (SELECT MAX(seq) as s FROM seq_events UNION ALL SELECT MAX(seq) as s FROM commits)',
          )
          .get()
      );
      return row?.seq ?? 0;
    },

    async getBlob(cid) {
      const row = /** @type {BlobMetaRow|undefined} */ (stmts.getBlob.get(cid));
      return row
        ? { mimeType: row.mime_type, size: row.size, createdAt: row.created_at }
        : null;
    },

    async putBlob(cid, mimeType, size) {
      stmts.putBlob.run(cid, mimeType, size, Date.now());
    },

    async deleteBlob(cid) {
      stmts.deleteBlob.run(cid);
    },

    async listBlobs(cursor, limit) {
      const rows = /** @type {CidRow[]} */ (
        cursor
          ? stmts.listBlobsAfter.all(cursor, limit)
          : stmts.listBlobs.all(limit)
      );
      return {
        cids: rows.map((r) => r.cid),
        cursor: rows.length === limit ? rows[rows.length - 1].cid : null,
      };
    },

    async listBlobDetails(cursor, limit, options = {}) {
      const { sql, params } = buildBlobListQuery(cursor, limit, options);
      const rows = /** @type {BlobDetailRow[]} */ (
        db.prepare(sql).all(...params)
      );
      const blobs = rows.map((r) => ({
        cid: r.cid,
        mimeType: r.mime_type,
        size: r.size,
        createdAt: r.created_at,
        time: r.time_ms,
        timeFromRecord: r.time_known === 1,
        records: r.uris ? r.uris.split(',') : [],
      }));
      return {
        blobs,
        cursor:
          blobs.length === limit
            ? blobListCursor(blobs[blobs.length - 1], options)
            : null,
      };
    },

    async getBlobFacets() {
      const total = /** @type {BlobBucketRow} */ (stmts.blobFacetsTotal.get());
      const unused = /** @type {BlobBucketRow} */ (
        stmts.blobFacetsUnused.get()
      );
      const kinds = /** @type {BlobKindRow[]} */ (stmts.blobFacetsKinds.all());
      const apps = /** @type {BlobAppRow[]} */ (stmts.blobFacetsApps.all());
      return { total, unused, kinds, apps };
    },

    async getOrphanedBlobs(olderThan) {
      const rows = /** @type {CidRow[]} */ (
        stmts.getOrphanedBlobs.all(olderThan)
      );
      return rows.map((r) => r.cid);
    },

    async linkBlobToRecord(blobCid, recordUri, recordTime = null) {
      stmts.linkBlobToRecord.run(blobCid, recordUri, recordTime);
    },

    async unlinkBlobsFromRecord(recordUri) {
      stmts.unlinkBlobsFromRecord.run(recordUri);
    },

    async listBlobRecordUris(blobCid) {
      const rows = /** @type {{record_uri: string}[]} */ (
        stmts.listBlobRecordUris.all(blobCid)
      );
      return rows.map((row) => row.record_uri);
    },

    async getDid() {
      const row = /** @type {MetadataStringRow|undefined} */ (
        stmts.getMetadata.get('did')
      );
      return row ? row.value : null;
    },

    async setDid(did) {
      stmts.setMetadata.run('did', did);
    },

    async getPrivateKey() {
      const row = /** @type {MetadataBlobRow|undefined} */ (
        stmts.getMetadata.get('privateKey')
      );
      return row ? new Uint8Array(row.value) : null;
    },

    async setPrivateKey(key) {
      stmts.setMetadata.run('privateKey', key);
    },

    async getKemKey() {
      const row = /** @type {MetadataBlobRow|undefined} */ (
        stmts.getMetadata.get('kemKey')
      );
      return row ? new Uint8Array(row.value) : null;
    },

    async setKemKey(key) {
      stmts.setMetadata.run('kemKey', key);
    },

    async getHandle() {
      const row = /** @type {MetadataStringRow|undefined} */ (
        stmts.getMetadata.get('handle')
      );
      return row ? row.value : null;
    },

    async getEmail() {
      const row = /** @type {MetadataStringRow|undefined} */ (
        stmts.getMetadata.get('email')
      );
      if (!row) return null;
      try {
        return JSON.parse(row.value);
      } catch {
        return null;
      }
    },

    async setEmail(state) {
      stmts.setMetadata.run('email', JSON.stringify(state));
    },

    async getAccountPassword() {
      const row = /** @type {MetadataStringRow|undefined} */ (
        stmts.getMetadata.get('accountPassword')
      );
      if (!row) return null;
      try {
        return JSON.parse(row.value);
      } catch {
        return null;
      }
    },

    async setAccountPassword(state) {
      stmts.setMetadata.run('accountPassword', JSON.stringify(state));
    },

    async getPlcToken() {
      const row = /** @type {MetadataStringRow|undefined} */ (
        stmts.getMetadata.get('plcToken')
      );
      if (!row) return null;
      try {
        return JSON.parse(row.value);
      } catch {
        return null;
      }
    },

    async setPlcToken(token) {
      stmts.setMetadata.run('plcToken', JSON.stringify(token));
    },

    async getAccountStatus() {
      const row = /** @type {MetadataStringRow|undefined} */ (
        stmts.getMetadata.get('accountStatus')
      );
      return row ? row.value : null;
    },

    async setAccountStatus(status) {
      stmts.setMetadata.run('accountStatus', status);
    },

    async setHandle(handle) {
      stmts.setMetadata.run('handle', handle);
    },

    // Preferences storage
    async getPreferences() {
      const row = /** @type {MetadataStringRow|undefined} */ (
        stmts.getMetadata.get('preferences')
      );
      return row ? JSON.parse(row.value) : [];
    },

    async setPreferences(preferences) {
      stmts.setMetadata.run('preferences', JSON.stringify(preferences));
    },

    // Backup settings and run history, one JSON document
    async getBackupState() {
      const row = /** @type {MetadataStringRow|undefined} */ (
        stmts.getMetadata.get('backupState')
      );
      if (!row) return null;
      try {
        return JSON.parse(row.value);
      } catch {
        return null;
      }
    },

    async setBackupState(state) {
      stmts.setMetadata.run('backupState', JSON.stringify(state));
    },

    // The account app's own preferences, one JSON document
    async getAccountUiState() {
      const row = /** @type {MetadataStringRow|undefined} */ (
        stmts.getMetadata.get('accountUiState')
      );
      if (!row) return null;
      try {
        return JSON.parse(row.value);
      } catch {
        return null;
      }
    },

    async setAccountUiState(state) {
      stmts.setMetadata.run('accountUiState', JSON.stringify(state));
    },
  };
}

// ============================================================================
// Shared storage adapter for Node.js
// ============================================================================

/** @typedef {{did: string, handle: string|null, created_at: number}} ActorRow */

/**
 * Create shared storage adapter for better-sqlite3 (Node.js)
 * @param {import('./driver.js').SQLiteDatabase} db
 * @returns {import('@pdsjs/core/ports').SharedStoragePort}
 */
export function createSharedStorage(db) {
  // Initialize schema
  db.exec(`
    CREATE TABLE IF NOT EXISTS actors (
      did TEXT PRIMARY KEY,
      handle TEXT,
      created_at INTEGER NOT NULL
    );
    CREATE UNIQUE INDEX IF NOT EXISTS actors_handle_lower_idx ON actors(lower(handle));

    CREATE TABLE IF NOT EXISTS oauth_requests (
      request_uri TEXT PRIMARY KEY,
      data TEXT,
      expires_at INTEGER
    );
    CREATE INDEX IF NOT EXISTS oauth_requests_expires_idx ON oauth_requests(expires_at);

    CREATE TABLE IF NOT EXISTS oauth_tokens (
      token_id TEXT PRIMARY KEY,
      did TEXT NOT NULL,
      data TEXT
    );
    CREATE INDEX IF NOT EXISTS oauth_tokens_did_idx ON oauth_tokens(did);

    CREATE TABLE IF NOT EXISTS dpop_jtis (
      jti TEXT PRIMARY KEY,
      expires_at INTEGER
    );
    CREATE INDEX IF NOT EXISTS dpop_jtis_expires_idx ON dpop_jtis(expires_at);
  `);
  db.exec(APP_PASSWORD_SCHEMA_SQL);
  db.exec(PASSKEY_SCHEMA_SQL);
  db.exec(SESSION_SCHEMA_SQL);
  // Databases created before sessions carried an expiry lack the column; add
  // it once. SQLite has no ADD COLUMN IF NOT EXISTS, so a re-run just throws.
  try {
    db.exec('ALTER TABLE sessions ADD COLUMN expires_at INTEGER');
  } catch {
    // Column already present.
  }
  // Likewise for the successor a rotation names when it spends a token.
  try {
    db.exec('ALTER TABLE sessions ADD COLUMN next_jti TEXT');
  } catch {
    // Column already present.
  }

  const stmts = {
    getActor: db.prepare(
      'SELECT did, handle, created_at FROM actors WHERE did = ?',
    ),
    resolveHandle: db.prepare(
      'SELECT did FROM actors WHERE lower(handle) = lower(?)',
    ),
    putActor: db.prepare(
      'INSERT OR REPLACE INTO actors (did, handle, created_at) VALUES (?, ?, ?)',
    ),
    deleteActor: db.prepare('DELETE FROM actors WHERE did = ?'),

    getOAuthRequest: db.prepare(
      'SELECT data, expires_at FROM oauth_requests WHERE request_uri = ?',
    ),
    putOAuthRequest: db.prepare(
      'INSERT OR REPLACE INTO oauth_requests (request_uri, data, expires_at) VALUES (?, ?, ?)',
    ),
    deleteOAuthRequest: db.prepare(
      'DELETE FROM oauth_requests WHERE request_uri = ?',
    ),

    getOAuthToken: db.prepare(
      'SELECT data FROM oauth_tokens WHERE token_id = ?',
    ),
    putOAuthToken: db.prepare(
      'INSERT OR REPLACE INTO oauth_tokens (token_id, did, data) VALUES (?, ?, ?)',
    ),
    deleteOAuthToken: db.prepare('DELETE FROM oauth_tokens WHERE token_id = ?'),
    listOAuthTokensByDid: db.prepare(
      'SELECT token_id, data FROM oauth_tokens WHERE did = ?',
    ),

    storeDpopJti: db.prepare(
      'INSERT OR IGNORE INTO dpop_jtis (jti, expires_at) VALUES (?, ?)',
    ),
    cleanupExpiredDpopJtis: db.prepare(
      'DELETE FROM dpop_jtis WHERE expires_at < ?',
    ),

    putAppPassword: db.prepare(
      'INSERT OR IGNORE INTO app_passwords (did, name, hash, privileged, created_at) VALUES (?, ?, ?, ?, ?)',
    ),
    getAppPasswordByHash: db.prepare(
      'SELECT name, privileged, created_at FROM app_passwords WHERE did = ? AND hash = ?',
    ),
    listAppPasswords: db.prepare(
      'SELECT name, privileged, created_at FROM app_passwords WHERE did = ? ORDER BY created_at',
    ),
    putSession: db.prepare(
      'INSERT OR REPLACE INTO sessions (jti, did, label, scope, user_agent, created_at, refreshed_at, expires_at, next_jti) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
    ),
    getSession: db.prepare('SELECT * FROM sessions WHERE jti = ?'),
    listSessions: db.prepare(
      'SELECT * FROM sessions WHERE did = ? ORDER BY created_at',
    ),
    deleteSession: db.prepare('DELETE FROM sessions WHERE jti = ?'),
    // Conditional on the successor, so that of two rotations racing the same
    // token only the first takes effect. Statements run to completion here, so
    // the test and the write cannot be separated.
    spendSession: db.prepare(
      'UPDATE sessions SET expires_at = ?, next_jti = ? WHERE jti = ? AND (next_jti IS NULL OR next_jti = ?)',
    ),

    putPasskey: db.prepare(
      'INSERT OR IGNORE INTO passkeys (credential_id, did, name, public_key, algorithm, sign_count, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, 0, ?, NULL)',
    ),
    getPasskey: db.prepare('SELECT * FROM passkeys WHERE credential_id = ?'),
    listPasskeys: db.prepare(
      'SELECT * FROM passkeys WHERE did = ? ORDER BY created_at',
    ),
    deletePasskey: db.prepare(
      'DELETE FROM passkeys WHERE did = ? AND credential_id = ?',
    ),
    touchPasskey: db.prepare(
      'UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE credential_id = ?',
    ),

    deleteAppPassword: db.prepare(
      'DELETE FROM app_passwords WHERE did = ? AND name = ?',
    ),
  };

  return {
    async getActor(did) {
      const row = /** @type {ActorRow|undefined} */ (stmts.getActor.get(did));
      return row
        ? { did: row.did, handle: row.handle, createdAt: row.created_at }
        : null;
    },

    async resolveHandle(handle) {
      const row = /** @type {{did: string}|undefined} */ (
        stmts.resolveHandle.get(handle)
      );
      return row ? row.did : null;
    },

    async putActor(did, handle, createdAt) {
      stmts.putActor.run(did, handle, createdAt);
    },

    async deleteActor(did) {
      stmts.deleteActor.run(did);
    },

    async getOAuthRequest(requestUri) {
      const row = /** @type {OAuthRequestRow|undefined} */ (
        stmts.getOAuthRequest.get(requestUri)
      );
      if (!row) return null;
      if (row.expires_at < Date.now()) {
        stmts.deleteOAuthRequest.run(requestUri);
        return null;
      }
      try {
        return JSON.parse(row.data);
      } catch {
        stmts.deleteOAuthRequest.run(requestUri);
        return null;
      }
    },

    async putOAuthRequest(requestUri, data, expiresAt) {
      stmts.putOAuthRequest.run(requestUri, JSON.stringify(data), expiresAt);
    },

    async deleteOAuthRequest(requestUri) {
      stmts.deleteOAuthRequest.run(requestUri);
    },

    async getOAuthToken(tokenId) {
      const row = /** @type {OAuthTokenRow|undefined} */ (
        stmts.getOAuthToken.get(tokenId)
      );
      if (!row) return null;
      try {
        return JSON.parse(row.data);
      } catch {
        stmts.deleteOAuthToken.run(tokenId);
        return null;
      }
    },

    async putOAuthToken(tokenId, data) {
      const tokenData = /** @type {{did: string}} */ (data);
      stmts.putOAuthToken.run(tokenId, tokenData.did, JSON.stringify(data));
    },

    async deleteOAuthToken(tokenId) {
      stmts.deleteOAuthToken.run(tokenId);
    },

    async putSession(session) {
      stmts.putSession.run(
        session.jti,
        session.did,
        session.label,
        session.scope,
        session.userAgent,
        session.createdAt,
        session.refreshedAt,
        session.expiresAt ?? null,
        session.nextJti ?? null,
      );
    },

    async getSession(jti) {
      const row = /** @type {SessionRow|undefined} */ (
        stmts.getSession.get(jti)
      );
      return row ? toSessionRecord(row) : null;
    },

    async listSessions(did) {
      const rows = /** @type {SessionRow[]} */ (stmts.listSessions.all(did));
      return rows.map(toSessionRecord);
    },

    async deleteSession(jti) {
      return stmts.deleteSession.run(jti).changes > 0;
    },

    async spendSession(jti, expiresAt, nextJti) {
      return (
        stmts.spendSession.run(expiresAt, nextJti, jti, nextJti).changes > 0
      );
    },

    async putPasskey(record) {
      const result = stmts.putPasskey.run(
        record.credentialId,
        record.did,
        record.name,
        record.publicKey,
        record.algorithm,
        record.createdAt,
      );
      return result.changes > 0;
    },

    async getPasskey(credentialId) {
      const row = /** @type {PasskeyRow|undefined} */ (
        stmts.getPasskey.get(credentialId)
      );
      return row ? toPasskeyRecord(row) : null;
    },

    async listPasskeys(did) {
      const rows = /** @type {PasskeyRow[]} */ (stmts.listPasskeys.all(did));
      return rows.map(toPasskeyRecord);
    },

    async deletePasskey(did, credentialId) {
      const result = stmts.deletePasskey.run(did, credentialId);
      return result.changes > 0;
    },

    async touchPasskey(credentialId, signCount, lastUsedAt) {
      stmts.touchPasskey.run(signCount, lastUsedAt, credentialId);
    },

    async listOAuthTokensByDid(did) {
      const rows = /** @type {OAuthTokenListRow[]} */ (
        stmts.listOAuthTokensByDid.all(did)
      );
      return rows
        .map((r) => {
          try {
            // The row key travels with the data so a caller can revoke a
            // single session without a second lookup.
            return { ...JSON.parse(r.data), tokenId: r.token_id };
          } catch {
            return null;
          }
        })
        .filter(Boolean);
    },

    async checkAndStoreDpopJti(jti, expiresAt) {
      const result = stmts.storeDpopJti.run(jti, expiresAt);
      return result.changes > 0;
    },

    async cleanupExpiredDpopJtis() {
      stmts.cleanupExpiredDpopJtis.run(Date.now());
    },

    async putAppPassword(did, name, hash, privileged, createdAt) {
      // INSERT OR IGNORE rather than REPLACE: reusing a name must not
      // silently invalidate the password already issued under it.
      const result = stmts.putAppPassword.run(
        did,
        name,
        hash,
        privileged ? 1 : 0,
        createdAt,
      );
      return Number(result.changes) > 0;
    },

    async getAppPasswordByHash(did, hash) {
      const row = /** @type {AppPasswordRow|undefined} */ (
        stmts.getAppPasswordByHash.get(did, hash)
      );
      return row ? toAppPassword(row) : null;
    },

    async listAppPasswords(did) {
      const rows = /** @type {AppPasswordRow[]} */ (
        stmts.listAppPasswords.all(did)
      );
      return rows.map(toAppPassword);
    },

    async deleteAppPassword(did, name) {
      const result = stmts.deleteAppPassword.run(did, name);
      return Number(result.changes) > 0;
    },
  };
}