A barebones implementation of an atproto PDS in PHP and Slim Framework 4.
pds php atproto
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: scaffold sqlite connection and add sql schemas

Andrés Ignacio Torres (May 18, 2026, 7:12 PM -0700) caacbdef cdb694e9

+570 -4
+1
.env.sample
··· 1 1 # required 2 2 PDS_HOSTNAME="" 3 3 PDS_BSKY_APP_VIEW_URL="https://api.bsky.app" 4 + PDS_DATA_DIRECTORY="/pds/data" 4 5 5 6 # optional 6 7 PDS_PRIVACY_POLICY_URL=""
+1
.gitignore
··· 9 9 .phpunit.result.cache 10 10 .phpunit.cache 11 11 .env 12 + /data
+29 -4
app/settings.php
··· 8 8 use Monolog\Level; 9 9 10 10 return function (ContainerBuilder $containerBuilder) { 11 + $getRequiredEnv = static function (string $key) { 12 + $value = $_ENV[$key] ?? null; 13 + if (!is_string($value) || $value === '') { 14 + throw new \RuntimeException("$key is required"); 15 + } 16 + return $value; 17 + }; 11 18 12 19 // Global Settings Object 13 20 $containerBuilder->addDefinitions([ 14 - SettingsInterface::class => function () { 21 + SettingsInterface::class => function () use ($getRequiredEnv) { 15 22 return new Settings([ 16 23 'displayErrorDetails' => true, // Should be set to false in production 17 24 'logError' => false, ··· 23 30 ], 24 31 // PDS-specific settings 25 32 'pds' => [ 26 - 'hostname' => $_ENV['PDS_HOSTNAME'] ?? throw new \RuntimeException('PDS_HOSTNAME is required'), 27 - 'bskyAppViewUrl' => $_ENV['PDS_BSKY_APP_VIEW_URL'] ?? throw new \RuntimeException('PDS_BSKY_APP_VIEW_URL is required'), 33 + 'hostname' => $getRequiredEnv('PDS_HOSTNAME'), 34 + 'bskyAppViewUrl' => $getRequiredEnv('PDS_BSKY_APP_VIEW_URL'), 28 35 'privacyPolicyUrl' => $_ENV['PDS_PRIVACY_POLICY_URL'] ?? null, 29 36 'termsOfServiceUrl' => $_ENV['PDS_TERMS_OF_SERVICE_URL'] ?? null, 30 37 'email' => $_ENV['PDS_CONTACT_EMAIL_ADDRESS'] ?? null, 31 - ] 38 + ], 39 + // Persistence (SQLite) settings 40 + 'database' => (static function () use ($getRequiredEnv): array { 41 + $dataDir = $getRequiredEnv('PDS_DATA_DIRECTORY'); 42 + $resolve = static function (string $suffix) use ($dataDir): string { 43 + if ($dataDir === ':memory:') { 44 + return ':memory:'; 45 + } 46 + return rtrim($dataDir, '/') . '/' . $suffix; 47 + }; 48 + 49 + return [ 50 + 'accountDb' => $resolve('account.sqlite'), 51 + 'sequencerDb' => $resolve('sequencer.sqlite'), 52 + 'didCacheDb' => $resolve('did_cache.sqlite'), 53 + 'actorStoreDir' => $resolve('actors'), 54 + 'blobstoreDir' => $resolve('blocks'), 55 + ]; 56 + })(), 32 57 ]); 33 58 } 34 59 ]);
+5
src/Domain/Account/AccountRepository.php
··· 25 25 * @throws AccountNotFoundException 26 26 */ 27 27 public function findAccountByEmail(string $email): Account; 28 + 29 + /** 30 + * Persist an account. 31 + */ 32 + public function save(Account $account): void; 28 33 }
+5
src/Domain/Actor/ActorRepository.php
··· 20 20 * @throws ActorNotFoundException 21 21 */ 22 22 public function findActorByHandle(string $handle): Actor; 23 + 24 + /** 25 + * Persist an actor. 26 + */ 27 + public function save(Actor $actor): void; 23 28 }
+141
src/Infrastructure/Database/Database.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Database; 6 + 7 + use PDO; 8 + 9 + /** 10 + * Thin wrapper around a PDO connection to a single SQLite database file. 11 + * 12 + * If not using `:memory`, the specified location is treated as a filesystem path 13 + * and the directory is created on demand. The database file itself is created by 14 + * SQLite when the first connection is made. 15 + */ 16 + class Database 17 + { 18 + private readonly PDO $pdo; 19 + 20 + private readonly string $location; 21 + 22 + public function __construct(string $location) 23 + { 24 + $this->location = $location; 25 + 26 + if ($location !== ':memory:') { 27 + $dir = dirname($location); 28 + if (!is_dir($dir)) { 29 + if (!mkdir($dir, 0o755, true) && !is_dir($dir)) { 30 + throw new \RuntimeException("Could not create database directory: {$dir}"); 31 + } 32 + } 33 + } 34 + 35 + $dsn = 'sqlite:' . $location; 36 + 37 + $this->pdo = new PDO($dsn, null, null, [ 38 + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, 39 + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, 40 + PDO::ATTR_EMULATE_PREPARES => false, 41 + ]); 42 + 43 + $this->pdo->exec('PRAGMA foreign_keys = ON'); 44 + 45 + if ($location !== ':memory:') { 46 + $this->pdo->exec('PRAGMA journal_mode = WAL'); 47 + $this->pdo->exec('PRAGMA synchronous = NORMAL'); 48 + } 49 + } 50 + 51 + public function pdo(): PDO 52 + { 53 + return $this->pdo; 54 + } 55 + 56 + public function getLocation(): string 57 + { 58 + return $this->location; 59 + } 60 + 61 + /** 62 + * Prepare and execute a statement, returning the underlying PDOStatement. 63 + * 64 + * Because the connection runs with ERRMODE_EXCEPTION, prepare()/execute() 65 + * never return false in practice. This wrapper exists primarily to give 66 + * static analysers a non-nullable PDOStatement back. 67 + * 68 + * @param array<string|int, scalar|null> $params 69 + */ 70 + public function prepared(string $sql, array $params = []): \PDOStatement 71 + { 72 + $stmt = $this->pdo->prepare($sql); 73 + $stmt->execute($params); 74 + return $stmt; 75 + } 76 + 77 + /** 78 + * Fetch a single row as an associative array, or null when no row matches. 79 + * 80 + * @param array<string|int, scalar|null> $params 81 + * @return array<string, mixed>|null 82 + */ 83 + public function fetchOne(string $sql, array $params = []): ?array 84 + { 85 + $stmt = $this->prepared($sql, $params); 86 + $row = $stmt->fetch(PDO::FETCH_ASSOC); 87 + if ($row === false) { 88 + return null; 89 + } 90 + /** @var array<string, mixed> $row */ 91 + return $row; 92 + } 93 + 94 + /** 95 + * Fetch all matching rows as a list of associative arrays. 96 + * 97 + * @param array<string|int, scalar|null> $params 98 + * @return list<array<string, mixed>> 99 + */ 100 + public function fetchAll(string $sql, array $params = []): array 101 + { 102 + $stmt = $this->prepared($sql, $params); 103 + /** @var list<array<string, mixed>> $rows */ 104 + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); 105 + return $rows; 106 + } 107 + 108 + /** 109 + * Execute an INSERT/UPDATE/DELETE/DDL statement and return the affected 110 + * row count. 111 + * 112 + * @param array<string|int, scalar|null> $params 113 + */ 114 + public function execute(string $sql, array $params = []): int 115 + { 116 + return $this->prepared($sql, $params)->rowCount(); 117 + } 118 + 119 + /** 120 + * Run $callback inside a transaction; commit on success, rollback on 121 + * any throwable. 122 + * 123 + * @template T 124 + * @param callable(PDO): T $callback 125 + * @return T 126 + */ 127 + public function transaction(callable $callback): mixed 128 + { 129 + $this->pdo->beginTransaction(); 130 + try { 131 + $result = $callback($this->pdo); 132 + $this->pdo->commit(); 133 + return $result; 134 + } catch (\Throwable $e) { 135 + if ($this->pdo->inTransaction()) { 136 + $this->pdo->rollBack(); 137 + } 138 + throw $e; 139 + } 140 + } 141 + }
+60
src/Infrastructure/Database/Row.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Database; 6 + 7 + /** 8 + * Type-safe accessors for values fetched from a PDO row. 9 + * 10 + * SQLite columns are returned as mixed by PHPStan, but our schemas guarantee 11 + * specific column types. 12 + * 13 + * We narrow values with `assert` for type guarding, and throwing at runtime 14 + * on unexpected shapes/types. 15 + */ 16 + final class Row 17 + { 18 + /** @param array<string, mixed> $row */ 19 + public static function str(array $row, string $key): string 20 + { 21 + $v = $row[$key] ?? null; 22 + assert(is_string($v)); 23 + return $v; 24 + } 25 + 26 + /** @param array<string, mixed> $row */ 27 + public static function nstr(array $row, string $key): ?string 28 + { 29 + $v = $row[$key] ?? null; 30 + assert($v === null || is_string($v)); 31 + return $v; 32 + } 33 + 34 + /** @param array<string, mixed> $row */ 35 + public static function int(array $row, string $key): int 36 + { 37 + $v = $row[$key] ?? null; 38 + assert(is_int($v) || (is_string($v) && $v !== '' && (string) (int) $v === $v)); 39 + return (int) $v; 40 + } 41 + 42 + /** @param array<string, mixed> $row */ 43 + public static function nint(array $row, string $key): ?int 44 + { 45 + $v = $row[$key] ?? null; 46 + if ($v === null) { 47 + return null; 48 + } 49 + assert(is_int($v) || is_string($v)); 50 + return (int) $v; 51 + } 52 + 53 + /** @param array<string, mixed> $row */ 54 + public static function bool(array $row, string $key): bool 55 + { 56 + $v = $row[$key] ?? null; 57 + assert(is_int($v) || is_bool($v) || is_string($v)); 58 + return (bool) (int) $v; 59 + } 60 + }
+185
src/Infrastructure/Database/Schema/AccountSchema.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Database\Schema; 6 + 7 + use App\Infrastructure\Database\Database; 8 + 9 + /** 10 + * Initialises the schema for the service-level "account" database, which 11 + * holds account-related and OAuth tables. 12 + */ 13 + final class AccountSchema 14 + { 15 + public static function apply(Database $db): void 16 + { 17 + $pdo = $db->pdo(); 18 + 19 + $pdo->exec( 20 + 'CREATE TABLE IF NOT EXISTS account ( 21 + did TEXT PRIMARY KEY, 22 + email TEXT NOT NULL UNIQUE, 23 + password_scrypt TEXT NOT NULL, 24 + email_confirmed_at TEXT, 25 + invites_disabled INTEGER NOT NULL DEFAULT 0 26 + )' 27 + ); 28 + 29 + $pdo->exec( 30 + 'CREATE TABLE IF NOT EXISTS actor ( 31 + did TEXT PRIMARY KEY, 32 + handle TEXT UNIQUE, 33 + created_at TEXT NOT NULL, 34 + takedown_ref TEXT, 35 + deactivated_at TEXT, 36 + delete_after TEXT 37 + )' 38 + ); 39 + 40 + $pdo->exec( 41 + 'CREATE TABLE IF NOT EXISTS app_password ( 42 + did TEXT NOT NULL, 43 + name TEXT NOT NULL, 44 + password_scrypt TEXT NOT NULL, 45 + created_at TEXT NOT NULL, 46 + privileged INTEGER NOT NULL DEFAULT 0, 47 + PRIMARY KEY (did, name) 48 + )' 49 + ); 50 + 51 + $pdo->exec( 52 + 'CREATE TABLE IF NOT EXISTS email_token ( 53 + purpose TEXT NOT NULL, 54 + did TEXT NOT NULL, 55 + token TEXT NOT NULL, 56 + requested_at TEXT NOT NULL, 57 + PRIMARY KEY (purpose, did), 58 + UNIQUE (purpose, token) 59 + )' 60 + ); 61 + 62 + $pdo->exec( 63 + 'CREATE TABLE IF NOT EXISTS invite_code ( 64 + code TEXT PRIMARY KEY, 65 + available_uses INTEGER NOT NULL, 66 + disabled INTEGER NOT NULL DEFAULT 0, 67 + for_account TEXT NOT NULL, 68 + created_by TEXT NOT NULL, 69 + created_at TEXT NOT NULL 70 + )' 71 + ); 72 + 73 + $pdo->exec( 74 + 'CREATE TABLE IF NOT EXISTS invite_code_use ( 75 + code TEXT NOT NULL, 76 + used_by TEXT NOT NULL, 77 + used_at TEXT NOT NULL, 78 + PRIMARY KEY (code, used_by, used_at) 79 + )' 80 + ); 81 + 82 + $pdo->exec( 83 + 'CREATE TABLE IF NOT EXISTS refresh_token ( 84 + id TEXT PRIMARY KEY, 85 + did TEXT NOT NULL, 86 + expires_at TEXT NOT NULL, 87 + app_password_name TEXT, 88 + next_id TEXT 89 + )' 90 + ); 91 + $pdo->exec('CREATE INDEX IF NOT EXISTS refresh_token_did_idx ON refresh_token (did)'); 92 + 93 + $pdo->exec( 94 + 'CREATE TABLE IF NOT EXISTS account_device ( 95 + did TEXT NOT NULL, 96 + device_id TEXT NOT NULL, 97 + created_at TEXT NOT NULL, 98 + updated_at TEXT NOT NULL, 99 + PRIMARY KEY (did, device_id) 100 + )' 101 + ); 102 + 103 + $pdo->exec( 104 + 'CREATE TABLE IF NOT EXISTS authorized_client ( 105 + did TEXT NOT NULL, 106 + client_id TEXT NOT NULL, 107 + created_at TEXT NOT NULL, 108 + updated_at TEXT NOT NULL, 109 + data_json TEXT NOT NULL, 110 + PRIMARY KEY (did, client_id) 111 + )' 112 + ); 113 + 114 + $pdo->exec( 115 + 'CREATE TABLE IF NOT EXISTS device ( 116 + id TEXT PRIMARY KEY, 117 + session_id TEXT NOT NULL, 118 + user_agent TEXT, 119 + ip_address TEXT NOT NULL, 120 + last_seen_at TEXT NOT NULL 121 + )' 122 + ); 123 + 124 + $pdo->exec( 125 + 'CREATE TABLE IF NOT EXISTS oauth_token ( 126 + id INTEGER PRIMARY KEY AUTOINCREMENT, 127 + did TEXT NOT NULL, 128 + token_id TEXT NOT NULL UNIQUE, 129 + created_at TEXT NOT NULL, 130 + updated_at TEXT NOT NULL, 131 + expires_at TEXT NOT NULL, 132 + client_id TEXT NOT NULL, 133 + client_auth_json TEXT NOT NULL, 134 + device_id TEXT, 135 + parameters_json TEXT NOT NULL, 136 + details_json TEXT, 137 + code TEXT, 138 + current_refresh_token TEXT, 139 + scope TEXT 140 + )' 141 + ); 142 + $pdo->exec('CREATE INDEX IF NOT EXISTS oauth_token_did_idx ON oauth_token (did)'); 143 + $pdo->exec('CREATE INDEX IF NOT EXISTS oauth_token_code_idx ON oauth_token (code)'); 144 + $pdo->exec( 145 + 'CREATE INDEX IF NOT EXISTS oauth_token_refresh_idx ON oauth_token (current_refresh_token)' 146 + ); 147 + 148 + $pdo->exec( 149 + 'CREATE TABLE IF NOT EXISTS used_refresh_token ( 150 + refresh_token TEXT PRIMARY KEY, 151 + token_id INTEGER NOT NULL 152 + )' 153 + ); 154 + $pdo->exec( 155 + 'CREATE INDEX IF NOT EXISTS used_refresh_token_token_id_idx ON used_refresh_token (token_id)' 156 + ); 157 + 158 + $pdo->exec( 159 + 'CREATE TABLE IF NOT EXISTS authorization_request ( 160 + id TEXT PRIMARY KEY, 161 + did TEXT, 162 + device_id TEXT, 163 + client_id TEXT NOT NULL, 164 + client_auth_json TEXT, 165 + parameters_json TEXT NOT NULL, 166 + expires_at TEXT NOT NULL, 167 + code TEXT 168 + )' 169 + ); 170 + $pdo->exec( 171 + 'CREATE INDEX IF NOT EXISTS authorization_request_code_idx ON authorization_request (code)' 172 + ); 173 + 174 + $pdo->exec( 175 + 'CREATE TABLE IF NOT EXISTS lexicon ( 176 + nsid TEXT PRIMARY KEY, 177 + created_at TEXT NOT NULL, 178 + updated_at TEXT NOT NULL, 179 + last_succeeded_at TEXT, 180 + uri TEXT, 181 + lexicon_json TEXT 182 + )' 183 + ); 184 + } 185 + }
+93
src/Infrastructure/Database/Schema/ActorStoreSchema.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Database\Schema; 6 + 7 + use App\Infrastructure\Database\Database; 8 + 9 + /** 10 + * Initialises the per-actor SQLite database schema. 11 + * Mirrors the reference TS actor-store layout. 12 + */ 13 + final class ActorStoreSchema 14 + { 15 + public static function apply(Database $db): void 16 + { 17 + $pdo = $db->pdo(); 18 + 19 + $pdo->exec( 20 + 'CREATE TABLE IF NOT EXISTS record ( 21 + uri TEXT PRIMARY KEY, 22 + cid TEXT NOT NULL, 23 + collection TEXT NOT NULL, 24 + rkey TEXT NOT NULL, 25 + repo_rev TEXT NOT NULL, 26 + indexed_at TEXT NOT NULL, 27 + takedown_ref TEXT 28 + )' 29 + ); 30 + $pdo->exec('CREATE INDEX IF NOT EXISTS record_collection_idx ON record (collection)'); 31 + 32 + $pdo->exec( 33 + 'CREATE TABLE IF NOT EXISTS record_blob ( 34 + blob_cid TEXT NOT NULL, 35 + record_uri TEXT NOT NULL, 36 + PRIMARY KEY (blob_cid, record_uri) 37 + )' 38 + ); 39 + $pdo->exec( 40 + 'CREATE INDEX IF NOT EXISTS record_blob_record_uri_idx ON record_blob (record_uri)' 41 + ); 42 + 43 + $pdo->exec( 44 + 'CREATE TABLE IF NOT EXISTS backlink ( 45 + uri TEXT NOT NULL, 46 + path TEXT NOT NULL, 47 + link_to TEXT NOT NULL, 48 + PRIMARY KEY (uri, path, link_to) 49 + )' 50 + ); 51 + $pdo->exec('CREATE INDEX IF NOT EXISTS backlink_link_to_idx ON backlink (link_to)'); 52 + 53 + $pdo->exec( 54 + 'CREATE TABLE IF NOT EXISTS blob ( 55 + cid TEXT PRIMARY KEY, 56 + mime_type TEXT NOT NULL, 57 + size INTEGER NOT NULL, 58 + temp_key TEXT, 59 + created_at TEXT NOT NULL, 60 + takedown_ref TEXT 61 + )' 62 + ); 63 + $pdo->exec('CREATE INDEX IF NOT EXISTS blob_temp_key_idx ON blob (temp_key)'); 64 + 65 + $pdo->exec( 66 + 'CREATE TABLE IF NOT EXISTS repo_block ( 67 + cid TEXT PRIMARY KEY, 68 + repo_rev TEXT NOT NULL, 69 + size INTEGER NOT NULL, 70 + content BLOB NOT NULL 71 + )' 72 + ); 73 + $pdo->exec('CREATE INDEX IF NOT EXISTS repo_block_repo_rev_idx ON repo_block (repo_rev)'); 74 + 75 + $pdo->exec( 76 + 'CREATE TABLE IF NOT EXISTS repo_root ( 77 + did TEXT PRIMARY KEY, 78 + cid TEXT NOT NULL, 79 + rev TEXT NOT NULL, 80 + indexed_at TEXT NOT NULL 81 + )' 82 + ); 83 + 84 + $pdo->exec( 85 + 'CREATE TABLE IF NOT EXISTS account_pref ( 86 + id INTEGER PRIMARY KEY AUTOINCREMENT, 87 + name TEXT NOT NULL, 88 + value_json TEXT NOT NULL 89 + )' 90 + ); 91 + $pdo->exec('CREATE INDEX IF NOT EXISTS account_pref_name_idx ON account_pref (name)'); 92 + } 93 + }
+23
src/Infrastructure/Database/Schema/DidCacheSchema.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Database\Schema; 6 + 7 + use App\Infrastructure\Database\Database; 8 + 9 + final class DidCacheSchema 10 + { 11 + public static function apply(Database $db): void 12 + { 13 + $pdo = $db->pdo(); 14 + 15 + $pdo->exec( 16 + 'CREATE TABLE IF NOT EXISTS did_doc ( 17 + did TEXT PRIMARY KEY, 18 + doc_json TEXT NOT NULL, 19 + updated_at TEXT NOT NULL 20 + )' 21 + ); 22 + } 23 + }
+27
src/Infrastructure/Database/Schema/SequencerSchema.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Database\Schema; 6 + 7 + use App\Infrastructure\Database\Database; 8 + 9 + final class SequencerSchema 10 + { 11 + public static function apply(Database $db): void 12 + { 13 + $pdo = $db->pdo(); 14 + 15 + $pdo->exec( 16 + 'CREATE TABLE IF NOT EXISTS repo_seq ( 17 + seq INTEGER PRIMARY KEY AUTOINCREMENT, 18 + did TEXT NOT NULL, 19 + event_type TEXT NOT NULL, 20 + event BLOB NOT NULL, 21 + sequenced_at TEXT NOT NULL, 22 + invalidated INTEGER NOT NULL DEFAULT 0 23 + )' 24 + ); 25 + $pdo->exec('CREATE INDEX IF NOT EXISTS repo_seq_did_idx ON repo_seq (did)'); 26 + } 27 + }