WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

feat: add SQLite as alternative database backend (#64)

* feat(db): add @libsql/client dependency for SQLite support

* feat(db): add SQLite schema file

* feat(db): add role_permissions table to Postgres schema (permissions column still present)

* feat(db): URL-based driver detection in createDb (postgres vs SQLite)

* feat(appview): add dialect-specific Drizzle configs and update db scripts

* feat(db): migration 0011 — add role_permissions table

* feat(appview): add migrate-permissions data migration script

Copies permissions from roles.permissions[] into the role_permissions
join table before the column is dropped in migration 0012.

* feat(db): migration 0012 — drop permissions column from roles (data already in role_permissions)

* feat(db): add SQLite migrations (single clean initial migration)

* feat(appview): update checkPermission and getUserRole to use role_permissions table

* feat(appview): update indexer to store role permissions in role_permissions table

- Remove permissions field from roleConfig.toInsertValues and toUpdateValues
(the permissions text[] column no longer exists on the roles table)
- Add optional afterUpsert hook to CollectionConfig for post-row child writes
- Implement roleConfig.afterUpsert to delete+insert into role_permissions
within the same transaction, using .returning({ id }) to get the row id
- Update genericCreate and genericUpdate to call afterUpsert when defined
- Rewrite indexer-roles test assertions to query role_permissions table
- Remove permissions field from direct db.insert(roles) test setup calls

* feat(appview): update admin routes to return permissions from role_permissions table

- GET /api/admin/roles: removed roles.permissions from select, enriches each
role with permissions fetched from role_permissions join table via Promise.all
- GET /api/admin/members/me: removed roles.permissions from join select, adds
roleId to select, then fetches permissions separately from role_permissions
- Updated all test files to remove permissions field from db.insert(roles).values()
calls, replacing with separate db.insert(rolePermissions).values() calls after
capturing the returned role id via .returning({ id: roles.id })
- Fixed admin-backfill.test.ts mock count: checkPermission now makes 3 DB
selects (membership + role + role_permissions) instead of 2
- Updated seed-roles.ts CLI step to insert permissions into role_permissions
table separately instead of the removed permissions column

* feat(appview): update test context to support SQLite via createDb factory

- Replace hardcoded postgres.js/drizzle-orm/postgres-js with createDb() from @atbb/db,
which auto-detects the URL prefix (postgres:// vs file:) and selects the right driver
- Add runSqliteMigrations() to @atbb/db so migrations run via the same drizzle-orm
instance used by createDb(), avoiding cross-package module boundary issues
- For SQLite in-memory mode, upgrade file::memory: to file::memory:?cache=shared so
that @libsql/client's transaction() handoff (which sets #db=null and lazily reconnects)
reattaches to the same shared in-memory database rather than creating an empty new one
- For SQLite cleanDatabase(): delete all rows without WHERE clauses (isolated in-memory DB)
- For Postgres cleanDatabase(): retain existing DID-based patterns
- Remove sql.end() from cleanup() — createDb owns the connection lifecycle
- Fix boards.test.ts and categories.test.ts "returns 503 on database error" tests to use
vi.spyOn() instead of relying on sql.end() to break the connection
- Replace count(*)::int (Postgres-specific cast) with Drizzle's count() helper in admin.ts
so the backfill/:id endpoint works on both Postgres and SQLite

* feat: add docker-compose.sqlite.yml for SQLite deployments

* feat(nix): add database.type option to NixOS module (postgresql | sqlite)

* feat(nix): include SQLite migrations and dialect configs in Nix package output

* feat(devenv): make postgres optional via mkDefault, document SQLite alternative

* refactor(appview): embed data migration into 0012 postgres migration

Copy permissions array into role_permissions join table inside the same
migration that drops the column. ON CONFLICT DO NOTHING keeps the script
idempotent for DBs that already ran migrate-permissions.ts manually.

* fix(appview): guard against out-of-order UPDATE in indexer; populate role_permissions in mod tests

- indexer.ts: add `if (!updated) return` before afterUpsert call so an
out-of-order firehose UPDATE that matches zero rows (CREATE not yet
received) doesn't crash with TypeError on `updated.id`
- mod.test.ts: add rolePermissions inserts for all 4 role setups so
test DB state accurately reflects the permissions each role claims
to have (ban admin: banUsers; lock/unlock mod: lockTopics)

* fix(appview): replace fabricated editPosts permission with real lockTopics in admin test

space.atbb.permission.editPosts is not defined in the lexicon or any
default role. Swap it for space.atbb.permission.lockTopics so the
GET /api/admin/members/me test uses a real permission value and would
catch a regression that silently dropped a real permission from a role.

authored by

Malpercio and committed by
GitHub
f33475f9 d1640713

+5010 -233
+5
.env.example
··· 8 8 LOG_LEVEL=info # debug | info | warn | error | fatal 9 9 10 10 # Database 11 + # PostgreSQL (default, used with devenv): 11 12 DATABASE_URL=postgres://atbb:atbb@localhost:5432/atbb 13 + 14 + # SQLite alternative (no devenv postgres needed): 15 + # DATABASE_URL=file:./data/atbb.db 16 + # DATABASE_URL=file::memory:?cache=shared # in-memory, for tests only 12 17 13 18 # Web UI configuration 14 19 # WEB_PORT=3001 # set in web package, or override here
+169
apps/appview/drizzle-sqlite/0000_thankful_mister_fear.sql
··· 1 + CREATE TABLE `backfill_errors` ( 2 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 + `backfill_id` integer NOT NULL, 4 + `did` text NOT NULL, 5 + `collection` text NOT NULL, 6 + `error_message` text NOT NULL, 7 + `created_at` integer NOT NULL, 8 + FOREIGN KEY (`backfill_id`) REFERENCES `backfill_progress`(`id`) ON UPDATE no action ON DELETE no action 9 + ); 10 + --> statement-breakpoint 11 + CREATE INDEX `backfill_errors_backfill_id_idx` ON `backfill_errors` (`backfill_id`);--> statement-breakpoint 12 + CREATE TABLE `backfill_progress` ( 13 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 14 + `status` text NOT NULL, 15 + `backfill_type` text NOT NULL, 16 + `last_processed_did` text, 17 + `dids_total` integer DEFAULT 0 NOT NULL, 18 + `dids_processed` integer DEFAULT 0 NOT NULL, 19 + `records_indexed` integer DEFAULT 0 NOT NULL, 20 + `started_at` integer NOT NULL, 21 + `completed_at` integer, 22 + `error_message` text 23 + ); 24 + --> statement-breakpoint 25 + CREATE TABLE `boards` ( 26 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 27 + `did` text NOT NULL, 28 + `rkey` text NOT NULL, 29 + `cid` text NOT NULL, 30 + `name` text NOT NULL, 31 + `description` text, 32 + `slug` text, 33 + `sort_order` integer, 34 + `category_id` integer, 35 + `category_uri` text NOT NULL, 36 + `created_at` integer NOT NULL, 37 + `indexed_at` integer NOT NULL, 38 + FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE no action 39 + ); 40 + --> statement-breakpoint 41 + CREATE UNIQUE INDEX `boards_did_rkey_idx` ON `boards` (`did`,`rkey`);--> statement-breakpoint 42 + CREATE INDEX `boards_category_id_idx` ON `boards` (`category_id`);--> statement-breakpoint 43 + CREATE TABLE `categories` ( 44 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 45 + `did` text NOT NULL, 46 + `rkey` text NOT NULL, 47 + `cid` text NOT NULL, 48 + `name` text NOT NULL, 49 + `description` text, 50 + `slug` text, 51 + `sort_order` integer, 52 + `forum_id` integer, 53 + `created_at` integer NOT NULL, 54 + `indexed_at` integer NOT NULL, 55 + FOREIGN KEY (`forum_id`) REFERENCES `forums`(`id`) ON UPDATE no action ON DELETE no action 56 + ); 57 + --> statement-breakpoint 58 + CREATE UNIQUE INDEX `categories_did_rkey_idx` ON `categories` (`did`,`rkey`);--> statement-breakpoint 59 + CREATE TABLE `firehose_cursor` ( 60 + `service` text PRIMARY KEY DEFAULT 'jetstream' NOT NULL, 61 + `cursor` integer NOT NULL, 62 + `updated_at` integer NOT NULL 63 + ); 64 + --> statement-breakpoint 65 + CREATE TABLE `forums` ( 66 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 67 + `did` text NOT NULL, 68 + `rkey` text NOT NULL, 69 + `cid` text NOT NULL, 70 + `name` text NOT NULL, 71 + `description` text, 72 + `indexed_at` integer NOT NULL 73 + ); 74 + --> statement-breakpoint 75 + CREATE UNIQUE INDEX `forums_did_rkey_idx` ON `forums` (`did`,`rkey`);--> statement-breakpoint 76 + CREATE TABLE `memberships` ( 77 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 78 + `did` text NOT NULL, 79 + `rkey` text NOT NULL, 80 + `cid` text NOT NULL, 81 + `forum_id` integer, 82 + `forum_uri` text NOT NULL, 83 + `role` text, 84 + `role_uri` text, 85 + `joined_at` integer, 86 + `created_at` integer NOT NULL, 87 + `indexed_at` integer NOT NULL, 88 + FOREIGN KEY (`did`) REFERENCES `users`(`did`) ON UPDATE no action ON DELETE no action, 89 + FOREIGN KEY (`forum_id`) REFERENCES `forums`(`id`) ON UPDATE no action ON DELETE no action 90 + ); 91 + --> statement-breakpoint 92 + CREATE UNIQUE INDEX `memberships_did_rkey_idx` ON `memberships` (`did`,`rkey`);--> statement-breakpoint 93 + CREATE INDEX `memberships_did_idx` ON `memberships` (`did`);--> statement-breakpoint 94 + CREATE TABLE `mod_actions` ( 95 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 96 + `did` text NOT NULL, 97 + `rkey` text NOT NULL, 98 + `cid` text NOT NULL, 99 + `action` text NOT NULL, 100 + `subject_did` text, 101 + `subject_post_uri` text, 102 + `forum_id` integer, 103 + `reason` text, 104 + `created_by` text NOT NULL, 105 + `expires_at` integer, 106 + `created_at` integer NOT NULL, 107 + `indexed_at` integer NOT NULL, 108 + FOREIGN KEY (`forum_id`) REFERENCES `forums`(`id`) ON UPDATE no action ON DELETE no action 109 + ); 110 + --> statement-breakpoint 111 + CREATE UNIQUE INDEX `mod_actions_did_rkey_idx` ON `mod_actions` (`did`,`rkey`);--> statement-breakpoint 112 + CREATE INDEX `mod_actions_subject_did_idx` ON `mod_actions` (`subject_did`);--> statement-breakpoint 113 + CREATE INDEX `mod_actions_subject_post_uri_idx` ON `mod_actions` (`subject_post_uri`);--> statement-breakpoint 114 + CREATE TABLE `posts` ( 115 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 116 + `did` text NOT NULL, 117 + `rkey` text NOT NULL, 118 + `cid` text NOT NULL, 119 + `title` text, 120 + `text` text NOT NULL, 121 + `forum_uri` text, 122 + `board_uri` text, 123 + `board_id` integer, 124 + `root_post_id` integer, 125 + `parent_post_id` integer, 126 + `root_uri` text, 127 + `parent_uri` text, 128 + `created_at` integer NOT NULL, 129 + `indexed_at` integer NOT NULL, 130 + `banned_by_mod` integer DEFAULT false NOT NULL, 131 + `deleted_by_user` integer DEFAULT false NOT NULL, 132 + FOREIGN KEY (`did`) REFERENCES `users`(`did`) ON UPDATE no action ON DELETE no action, 133 + FOREIGN KEY (`board_id`) REFERENCES `boards`(`id`) ON UPDATE no action ON DELETE no action, 134 + FOREIGN KEY (`root_post_id`) REFERENCES `posts`(`id`) ON UPDATE no action ON DELETE no action, 135 + FOREIGN KEY (`parent_post_id`) REFERENCES `posts`(`id`) ON UPDATE no action ON DELETE no action 136 + ); 137 + --> statement-breakpoint 138 + CREATE UNIQUE INDEX `posts_did_rkey_idx` ON `posts` (`did`,`rkey`);--> statement-breakpoint 139 + CREATE INDEX `posts_forum_uri_idx` ON `posts` (`forum_uri`);--> statement-breakpoint 140 + CREATE INDEX `posts_board_id_idx` ON `posts` (`board_id`);--> statement-breakpoint 141 + CREATE INDEX `posts_board_uri_idx` ON `posts` (`board_uri`);--> statement-breakpoint 142 + CREATE INDEX `posts_root_post_id_idx` ON `posts` (`root_post_id`);--> statement-breakpoint 143 + CREATE TABLE `role_permissions` ( 144 + `role_id` integer NOT NULL, 145 + `permission` text NOT NULL, 146 + PRIMARY KEY(`role_id`, `permission`), 147 + FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade 148 + ); 149 + --> statement-breakpoint 150 + CREATE TABLE `roles` ( 151 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 152 + `did` text NOT NULL, 153 + `rkey` text NOT NULL, 154 + `cid` text NOT NULL, 155 + `name` text NOT NULL, 156 + `description` text, 157 + `priority` integer NOT NULL, 158 + `created_at` integer NOT NULL, 159 + `indexed_at` integer NOT NULL 160 + ); 161 + --> statement-breakpoint 162 + CREATE UNIQUE INDEX `roles_did_rkey_idx` ON `roles` (`did`,`rkey`);--> statement-breakpoint 163 + CREATE INDEX `roles_did_idx` ON `roles` (`did`);--> statement-breakpoint 164 + CREATE INDEX `roles_did_name_idx` ON `roles` (`did`,`name`);--> statement-breakpoint 165 + CREATE TABLE `users` ( 166 + `did` text PRIMARY KEY NOT NULL, 167 + `handle` text, 168 + `indexed_at` integer NOT NULL 169 + );
+1172
apps/appview/drizzle-sqlite/meta/0000_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "84f6d742-677b-4159-86e3-9c5abadccec5", 5 + "prevId": "00000000-0000-0000-0000-000000000000", 6 + "tables": { 7 + "backfill_errors": { 8 + "name": "backfill_errors", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "integer", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": true 16 + }, 17 + "backfill_id": { 18 + "name": "backfill_id", 19 + "type": "integer", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "did": { 25 + "name": "did", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "collection": { 32 + "name": "collection", 33 + "type": "text", 34 + "primaryKey": false, 35 + "notNull": true, 36 + "autoincrement": false 37 + }, 38 + "error_message": { 39 + "name": "error_message", 40 + "type": "text", 41 + "primaryKey": false, 42 + "notNull": true, 43 + "autoincrement": false 44 + }, 45 + "created_at": { 46 + "name": "created_at", 47 + "type": "integer", 48 + "primaryKey": false, 49 + "notNull": true, 50 + "autoincrement": false 51 + } 52 + }, 53 + "indexes": { 54 + "backfill_errors_backfill_id_idx": { 55 + "name": "backfill_errors_backfill_id_idx", 56 + "columns": [ 57 + "backfill_id" 58 + ], 59 + "isUnique": false 60 + } 61 + }, 62 + "foreignKeys": { 63 + "backfill_errors_backfill_id_backfill_progress_id_fk": { 64 + "name": "backfill_errors_backfill_id_backfill_progress_id_fk", 65 + "tableFrom": "backfill_errors", 66 + "tableTo": "backfill_progress", 67 + "columnsFrom": [ 68 + "backfill_id" 69 + ], 70 + "columnsTo": [ 71 + "id" 72 + ], 73 + "onDelete": "no action", 74 + "onUpdate": "no action" 75 + } 76 + }, 77 + "compositePrimaryKeys": {}, 78 + "uniqueConstraints": {}, 79 + "checkConstraints": {} 80 + }, 81 + "backfill_progress": { 82 + "name": "backfill_progress", 83 + "columns": { 84 + "id": { 85 + "name": "id", 86 + "type": "integer", 87 + "primaryKey": true, 88 + "notNull": true, 89 + "autoincrement": true 90 + }, 91 + "status": { 92 + "name": "status", 93 + "type": "text", 94 + "primaryKey": false, 95 + "notNull": true, 96 + "autoincrement": false 97 + }, 98 + "backfill_type": { 99 + "name": "backfill_type", 100 + "type": "text", 101 + "primaryKey": false, 102 + "notNull": true, 103 + "autoincrement": false 104 + }, 105 + "last_processed_did": { 106 + "name": "last_processed_did", 107 + "type": "text", 108 + "primaryKey": false, 109 + "notNull": false, 110 + "autoincrement": false 111 + }, 112 + "dids_total": { 113 + "name": "dids_total", 114 + "type": "integer", 115 + "primaryKey": false, 116 + "notNull": true, 117 + "autoincrement": false, 118 + "default": 0 119 + }, 120 + "dids_processed": { 121 + "name": "dids_processed", 122 + "type": "integer", 123 + "primaryKey": false, 124 + "notNull": true, 125 + "autoincrement": false, 126 + "default": 0 127 + }, 128 + "records_indexed": { 129 + "name": "records_indexed", 130 + "type": "integer", 131 + "primaryKey": false, 132 + "notNull": true, 133 + "autoincrement": false, 134 + "default": 0 135 + }, 136 + "started_at": { 137 + "name": "started_at", 138 + "type": "integer", 139 + "primaryKey": false, 140 + "notNull": true, 141 + "autoincrement": false 142 + }, 143 + "completed_at": { 144 + "name": "completed_at", 145 + "type": "integer", 146 + "primaryKey": false, 147 + "notNull": false, 148 + "autoincrement": false 149 + }, 150 + "error_message": { 151 + "name": "error_message", 152 + "type": "text", 153 + "primaryKey": false, 154 + "notNull": false, 155 + "autoincrement": false 156 + } 157 + }, 158 + "indexes": {}, 159 + "foreignKeys": {}, 160 + "compositePrimaryKeys": {}, 161 + "uniqueConstraints": {}, 162 + "checkConstraints": {} 163 + }, 164 + "boards": { 165 + "name": "boards", 166 + "columns": { 167 + "id": { 168 + "name": "id", 169 + "type": "integer", 170 + "primaryKey": true, 171 + "notNull": true, 172 + "autoincrement": true 173 + }, 174 + "did": { 175 + "name": "did", 176 + "type": "text", 177 + "primaryKey": false, 178 + "notNull": true, 179 + "autoincrement": false 180 + }, 181 + "rkey": { 182 + "name": "rkey", 183 + "type": "text", 184 + "primaryKey": false, 185 + "notNull": true, 186 + "autoincrement": false 187 + }, 188 + "cid": { 189 + "name": "cid", 190 + "type": "text", 191 + "primaryKey": false, 192 + "notNull": true, 193 + "autoincrement": false 194 + }, 195 + "name": { 196 + "name": "name", 197 + "type": "text", 198 + "primaryKey": false, 199 + "notNull": true, 200 + "autoincrement": false 201 + }, 202 + "description": { 203 + "name": "description", 204 + "type": "text", 205 + "primaryKey": false, 206 + "notNull": false, 207 + "autoincrement": false 208 + }, 209 + "slug": { 210 + "name": "slug", 211 + "type": "text", 212 + "primaryKey": false, 213 + "notNull": false, 214 + "autoincrement": false 215 + }, 216 + "sort_order": { 217 + "name": "sort_order", 218 + "type": "integer", 219 + "primaryKey": false, 220 + "notNull": false, 221 + "autoincrement": false 222 + }, 223 + "category_id": { 224 + "name": "category_id", 225 + "type": "integer", 226 + "primaryKey": false, 227 + "notNull": false, 228 + "autoincrement": false 229 + }, 230 + "category_uri": { 231 + "name": "category_uri", 232 + "type": "text", 233 + "primaryKey": false, 234 + "notNull": true, 235 + "autoincrement": false 236 + }, 237 + "created_at": { 238 + "name": "created_at", 239 + "type": "integer", 240 + "primaryKey": false, 241 + "notNull": true, 242 + "autoincrement": false 243 + }, 244 + "indexed_at": { 245 + "name": "indexed_at", 246 + "type": "integer", 247 + "primaryKey": false, 248 + "notNull": true, 249 + "autoincrement": false 250 + } 251 + }, 252 + "indexes": { 253 + "boards_did_rkey_idx": { 254 + "name": "boards_did_rkey_idx", 255 + "columns": [ 256 + "did", 257 + "rkey" 258 + ], 259 + "isUnique": true 260 + }, 261 + "boards_category_id_idx": { 262 + "name": "boards_category_id_idx", 263 + "columns": [ 264 + "category_id" 265 + ], 266 + "isUnique": false 267 + } 268 + }, 269 + "foreignKeys": { 270 + "boards_category_id_categories_id_fk": { 271 + "name": "boards_category_id_categories_id_fk", 272 + "tableFrom": "boards", 273 + "tableTo": "categories", 274 + "columnsFrom": [ 275 + "category_id" 276 + ], 277 + "columnsTo": [ 278 + "id" 279 + ], 280 + "onDelete": "no action", 281 + "onUpdate": "no action" 282 + } 283 + }, 284 + "compositePrimaryKeys": {}, 285 + "uniqueConstraints": {}, 286 + "checkConstraints": {} 287 + }, 288 + "categories": { 289 + "name": "categories", 290 + "columns": { 291 + "id": { 292 + "name": "id", 293 + "type": "integer", 294 + "primaryKey": true, 295 + "notNull": true, 296 + "autoincrement": true 297 + }, 298 + "did": { 299 + "name": "did", 300 + "type": "text", 301 + "primaryKey": false, 302 + "notNull": true, 303 + "autoincrement": false 304 + }, 305 + "rkey": { 306 + "name": "rkey", 307 + "type": "text", 308 + "primaryKey": false, 309 + "notNull": true, 310 + "autoincrement": false 311 + }, 312 + "cid": { 313 + "name": "cid", 314 + "type": "text", 315 + "primaryKey": false, 316 + "notNull": true, 317 + "autoincrement": false 318 + }, 319 + "name": { 320 + "name": "name", 321 + "type": "text", 322 + "primaryKey": false, 323 + "notNull": true, 324 + "autoincrement": false 325 + }, 326 + "description": { 327 + "name": "description", 328 + "type": "text", 329 + "primaryKey": false, 330 + "notNull": false, 331 + "autoincrement": false 332 + }, 333 + "slug": { 334 + "name": "slug", 335 + "type": "text", 336 + "primaryKey": false, 337 + "notNull": false, 338 + "autoincrement": false 339 + }, 340 + "sort_order": { 341 + "name": "sort_order", 342 + "type": "integer", 343 + "primaryKey": false, 344 + "notNull": false, 345 + "autoincrement": false 346 + }, 347 + "forum_id": { 348 + "name": "forum_id", 349 + "type": "integer", 350 + "primaryKey": false, 351 + "notNull": false, 352 + "autoincrement": false 353 + }, 354 + "created_at": { 355 + "name": "created_at", 356 + "type": "integer", 357 + "primaryKey": false, 358 + "notNull": true, 359 + "autoincrement": false 360 + }, 361 + "indexed_at": { 362 + "name": "indexed_at", 363 + "type": "integer", 364 + "primaryKey": false, 365 + "notNull": true, 366 + "autoincrement": false 367 + } 368 + }, 369 + "indexes": { 370 + "categories_did_rkey_idx": { 371 + "name": "categories_did_rkey_idx", 372 + "columns": [ 373 + "did", 374 + "rkey" 375 + ], 376 + "isUnique": true 377 + } 378 + }, 379 + "foreignKeys": { 380 + "categories_forum_id_forums_id_fk": { 381 + "name": "categories_forum_id_forums_id_fk", 382 + "tableFrom": "categories", 383 + "tableTo": "forums", 384 + "columnsFrom": [ 385 + "forum_id" 386 + ], 387 + "columnsTo": [ 388 + "id" 389 + ], 390 + "onDelete": "no action", 391 + "onUpdate": "no action" 392 + } 393 + }, 394 + "compositePrimaryKeys": {}, 395 + "uniqueConstraints": {}, 396 + "checkConstraints": {} 397 + }, 398 + "firehose_cursor": { 399 + "name": "firehose_cursor", 400 + "columns": { 401 + "service": { 402 + "name": "service", 403 + "type": "text", 404 + "primaryKey": true, 405 + "notNull": true, 406 + "autoincrement": false, 407 + "default": "'jetstream'" 408 + }, 409 + "cursor": { 410 + "name": "cursor", 411 + "type": "integer", 412 + "primaryKey": false, 413 + "notNull": true, 414 + "autoincrement": false 415 + }, 416 + "updated_at": { 417 + "name": "updated_at", 418 + "type": "integer", 419 + "primaryKey": false, 420 + "notNull": true, 421 + "autoincrement": false 422 + } 423 + }, 424 + "indexes": {}, 425 + "foreignKeys": {}, 426 + "compositePrimaryKeys": {}, 427 + "uniqueConstraints": {}, 428 + "checkConstraints": {} 429 + }, 430 + "forums": { 431 + "name": "forums", 432 + "columns": { 433 + "id": { 434 + "name": "id", 435 + "type": "integer", 436 + "primaryKey": true, 437 + "notNull": true, 438 + "autoincrement": true 439 + }, 440 + "did": { 441 + "name": "did", 442 + "type": "text", 443 + "primaryKey": false, 444 + "notNull": true, 445 + "autoincrement": false 446 + }, 447 + "rkey": { 448 + "name": "rkey", 449 + "type": "text", 450 + "primaryKey": false, 451 + "notNull": true, 452 + "autoincrement": false 453 + }, 454 + "cid": { 455 + "name": "cid", 456 + "type": "text", 457 + "primaryKey": false, 458 + "notNull": true, 459 + "autoincrement": false 460 + }, 461 + "name": { 462 + "name": "name", 463 + "type": "text", 464 + "primaryKey": false, 465 + "notNull": true, 466 + "autoincrement": false 467 + }, 468 + "description": { 469 + "name": "description", 470 + "type": "text", 471 + "primaryKey": false, 472 + "notNull": false, 473 + "autoincrement": false 474 + }, 475 + "indexed_at": { 476 + "name": "indexed_at", 477 + "type": "integer", 478 + "primaryKey": false, 479 + "notNull": true, 480 + "autoincrement": false 481 + } 482 + }, 483 + "indexes": { 484 + "forums_did_rkey_idx": { 485 + "name": "forums_did_rkey_idx", 486 + "columns": [ 487 + "did", 488 + "rkey" 489 + ], 490 + "isUnique": true 491 + } 492 + }, 493 + "foreignKeys": {}, 494 + "compositePrimaryKeys": {}, 495 + "uniqueConstraints": {}, 496 + "checkConstraints": {} 497 + }, 498 + "memberships": { 499 + "name": "memberships", 500 + "columns": { 501 + "id": { 502 + "name": "id", 503 + "type": "integer", 504 + "primaryKey": true, 505 + "notNull": true, 506 + "autoincrement": true 507 + }, 508 + "did": { 509 + "name": "did", 510 + "type": "text", 511 + "primaryKey": false, 512 + "notNull": true, 513 + "autoincrement": false 514 + }, 515 + "rkey": { 516 + "name": "rkey", 517 + "type": "text", 518 + "primaryKey": false, 519 + "notNull": true, 520 + "autoincrement": false 521 + }, 522 + "cid": { 523 + "name": "cid", 524 + "type": "text", 525 + "primaryKey": false, 526 + "notNull": true, 527 + "autoincrement": false 528 + }, 529 + "forum_id": { 530 + "name": "forum_id", 531 + "type": "integer", 532 + "primaryKey": false, 533 + "notNull": false, 534 + "autoincrement": false 535 + }, 536 + "forum_uri": { 537 + "name": "forum_uri", 538 + "type": "text", 539 + "primaryKey": false, 540 + "notNull": true, 541 + "autoincrement": false 542 + }, 543 + "role": { 544 + "name": "role", 545 + "type": "text", 546 + "primaryKey": false, 547 + "notNull": false, 548 + "autoincrement": false 549 + }, 550 + "role_uri": { 551 + "name": "role_uri", 552 + "type": "text", 553 + "primaryKey": false, 554 + "notNull": false, 555 + "autoincrement": false 556 + }, 557 + "joined_at": { 558 + "name": "joined_at", 559 + "type": "integer", 560 + "primaryKey": false, 561 + "notNull": false, 562 + "autoincrement": false 563 + }, 564 + "created_at": { 565 + "name": "created_at", 566 + "type": "integer", 567 + "primaryKey": false, 568 + "notNull": true, 569 + "autoincrement": false 570 + }, 571 + "indexed_at": { 572 + "name": "indexed_at", 573 + "type": "integer", 574 + "primaryKey": false, 575 + "notNull": true, 576 + "autoincrement": false 577 + } 578 + }, 579 + "indexes": { 580 + "memberships_did_rkey_idx": { 581 + "name": "memberships_did_rkey_idx", 582 + "columns": [ 583 + "did", 584 + "rkey" 585 + ], 586 + "isUnique": true 587 + }, 588 + "memberships_did_idx": { 589 + "name": "memberships_did_idx", 590 + "columns": [ 591 + "did" 592 + ], 593 + "isUnique": false 594 + } 595 + }, 596 + "foreignKeys": { 597 + "memberships_did_users_did_fk": { 598 + "name": "memberships_did_users_did_fk", 599 + "tableFrom": "memberships", 600 + "tableTo": "users", 601 + "columnsFrom": [ 602 + "did" 603 + ], 604 + "columnsTo": [ 605 + "did" 606 + ], 607 + "onDelete": "no action", 608 + "onUpdate": "no action" 609 + }, 610 + "memberships_forum_id_forums_id_fk": { 611 + "name": "memberships_forum_id_forums_id_fk", 612 + "tableFrom": "memberships", 613 + "tableTo": "forums", 614 + "columnsFrom": [ 615 + "forum_id" 616 + ], 617 + "columnsTo": [ 618 + "id" 619 + ], 620 + "onDelete": "no action", 621 + "onUpdate": "no action" 622 + } 623 + }, 624 + "compositePrimaryKeys": {}, 625 + "uniqueConstraints": {}, 626 + "checkConstraints": {} 627 + }, 628 + "mod_actions": { 629 + "name": "mod_actions", 630 + "columns": { 631 + "id": { 632 + "name": "id", 633 + "type": "integer", 634 + "primaryKey": true, 635 + "notNull": true, 636 + "autoincrement": true 637 + }, 638 + "did": { 639 + "name": "did", 640 + "type": "text", 641 + "primaryKey": false, 642 + "notNull": true, 643 + "autoincrement": false 644 + }, 645 + "rkey": { 646 + "name": "rkey", 647 + "type": "text", 648 + "primaryKey": false, 649 + "notNull": true, 650 + "autoincrement": false 651 + }, 652 + "cid": { 653 + "name": "cid", 654 + "type": "text", 655 + "primaryKey": false, 656 + "notNull": true, 657 + "autoincrement": false 658 + }, 659 + "action": { 660 + "name": "action", 661 + "type": "text", 662 + "primaryKey": false, 663 + "notNull": true, 664 + "autoincrement": false 665 + }, 666 + "subject_did": { 667 + "name": "subject_did", 668 + "type": "text", 669 + "primaryKey": false, 670 + "notNull": false, 671 + "autoincrement": false 672 + }, 673 + "subject_post_uri": { 674 + "name": "subject_post_uri", 675 + "type": "text", 676 + "primaryKey": false, 677 + "notNull": false, 678 + "autoincrement": false 679 + }, 680 + "forum_id": { 681 + "name": "forum_id", 682 + "type": "integer", 683 + "primaryKey": false, 684 + "notNull": false, 685 + "autoincrement": false 686 + }, 687 + "reason": { 688 + "name": "reason", 689 + "type": "text", 690 + "primaryKey": false, 691 + "notNull": false, 692 + "autoincrement": false 693 + }, 694 + "created_by": { 695 + "name": "created_by", 696 + "type": "text", 697 + "primaryKey": false, 698 + "notNull": true, 699 + "autoincrement": false 700 + }, 701 + "expires_at": { 702 + "name": "expires_at", 703 + "type": "integer", 704 + "primaryKey": false, 705 + "notNull": false, 706 + "autoincrement": false 707 + }, 708 + "created_at": { 709 + "name": "created_at", 710 + "type": "integer", 711 + "primaryKey": false, 712 + "notNull": true, 713 + "autoincrement": false 714 + }, 715 + "indexed_at": { 716 + "name": "indexed_at", 717 + "type": "integer", 718 + "primaryKey": false, 719 + "notNull": true, 720 + "autoincrement": false 721 + } 722 + }, 723 + "indexes": { 724 + "mod_actions_did_rkey_idx": { 725 + "name": "mod_actions_did_rkey_idx", 726 + "columns": [ 727 + "did", 728 + "rkey" 729 + ], 730 + "isUnique": true 731 + }, 732 + "mod_actions_subject_did_idx": { 733 + "name": "mod_actions_subject_did_idx", 734 + "columns": [ 735 + "subject_did" 736 + ], 737 + "isUnique": false 738 + }, 739 + "mod_actions_subject_post_uri_idx": { 740 + "name": "mod_actions_subject_post_uri_idx", 741 + "columns": [ 742 + "subject_post_uri" 743 + ], 744 + "isUnique": false 745 + } 746 + }, 747 + "foreignKeys": { 748 + "mod_actions_forum_id_forums_id_fk": { 749 + "name": "mod_actions_forum_id_forums_id_fk", 750 + "tableFrom": "mod_actions", 751 + "tableTo": "forums", 752 + "columnsFrom": [ 753 + "forum_id" 754 + ], 755 + "columnsTo": [ 756 + "id" 757 + ], 758 + "onDelete": "no action", 759 + "onUpdate": "no action" 760 + } 761 + }, 762 + "compositePrimaryKeys": {}, 763 + "uniqueConstraints": {}, 764 + "checkConstraints": {} 765 + }, 766 + "posts": { 767 + "name": "posts", 768 + "columns": { 769 + "id": { 770 + "name": "id", 771 + "type": "integer", 772 + "primaryKey": true, 773 + "notNull": true, 774 + "autoincrement": true 775 + }, 776 + "did": { 777 + "name": "did", 778 + "type": "text", 779 + "primaryKey": false, 780 + "notNull": true, 781 + "autoincrement": false 782 + }, 783 + "rkey": { 784 + "name": "rkey", 785 + "type": "text", 786 + "primaryKey": false, 787 + "notNull": true, 788 + "autoincrement": false 789 + }, 790 + "cid": { 791 + "name": "cid", 792 + "type": "text", 793 + "primaryKey": false, 794 + "notNull": true, 795 + "autoincrement": false 796 + }, 797 + "title": { 798 + "name": "title", 799 + "type": "text", 800 + "primaryKey": false, 801 + "notNull": false, 802 + "autoincrement": false 803 + }, 804 + "text": { 805 + "name": "text", 806 + "type": "text", 807 + "primaryKey": false, 808 + "notNull": true, 809 + "autoincrement": false 810 + }, 811 + "forum_uri": { 812 + "name": "forum_uri", 813 + "type": "text", 814 + "primaryKey": false, 815 + "notNull": false, 816 + "autoincrement": false 817 + }, 818 + "board_uri": { 819 + "name": "board_uri", 820 + "type": "text", 821 + "primaryKey": false, 822 + "notNull": false, 823 + "autoincrement": false 824 + }, 825 + "board_id": { 826 + "name": "board_id", 827 + "type": "integer", 828 + "primaryKey": false, 829 + "notNull": false, 830 + "autoincrement": false 831 + }, 832 + "root_post_id": { 833 + "name": "root_post_id", 834 + "type": "integer", 835 + "primaryKey": false, 836 + "notNull": false, 837 + "autoincrement": false 838 + }, 839 + "parent_post_id": { 840 + "name": "parent_post_id", 841 + "type": "integer", 842 + "primaryKey": false, 843 + "notNull": false, 844 + "autoincrement": false 845 + }, 846 + "root_uri": { 847 + "name": "root_uri", 848 + "type": "text", 849 + "primaryKey": false, 850 + "notNull": false, 851 + "autoincrement": false 852 + }, 853 + "parent_uri": { 854 + "name": "parent_uri", 855 + "type": "text", 856 + "primaryKey": false, 857 + "notNull": false, 858 + "autoincrement": false 859 + }, 860 + "created_at": { 861 + "name": "created_at", 862 + "type": "integer", 863 + "primaryKey": false, 864 + "notNull": true, 865 + "autoincrement": false 866 + }, 867 + "indexed_at": { 868 + "name": "indexed_at", 869 + "type": "integer", 870 + "primaryKey": false, 871 + "notNull": true, 872 + "autoincrement": false 873 + }, 874 + "banned_by_mod": { 875 + "name": "banned_by_mod", 876 + "type": "integer", 877 + "primaryKey": false, 878 + "notNull": true, 879 + "autoincrement": false, 880 + "default": false 881 + }, 882 + "deleted_by_user": { 883 + "name": "deleted_by_user", 884 + "type": "integer", 885 + "primaryKey": false, 886 + "notNull": true, 887 + "autoincrement": false, 888 + "default": false 889 + } 890 + }, 891 + "indexes": { 892 + "posts_did_rkey_idx": { 893 + "name": "posts_did_rkey_idx", 894 + "columns": [ 895 + "did", 896 + "rkey" 897 + ], 898 + "isUnique": true 899 + }, 900 + "posts_forum_uri_idx": { 901 + "name": "posts_forum_uri_idx", 902 + "columns": [ 903 + "forum_uri" 904 + ], 905 + "isUnique": false 906 + }, 907 + "posts_board_id_idx": { 908 + "name": "posts_board_id_idx", 909 + "columns": [ 910 + "board_id" 911 + ], 912 + "isUnique": false 913 + }, 914 + "posts_board_uri_idx": { 915 + "name": "posts_board_uri_idx", 916 + "columns": [ 917 + "board_uri" 918 + ], 919 + "isUnique": false 920 + }, 921 + "posts_root_post_id_idx": { 922 + "name": "posts_root_post_id_idx", 923 + "columns": [ 924 + "root_post_id" 925 + ], 926 + "isUnique": false 927 + } 928 + }, 929 + "foreignKeys": { 930 + "posts_did_users_did_fk": { 931 + "name": "posts_did_users_did_fk", 932 + "tableFrom": "posts", 933 + "tableTo": "users", 934 + "columnsFrom": [ 935 + "did" 936 + ], 937 + "columnsTo": [ 938 + "did" 939 + ], 940 + "onDelete": "no action", 941 + "onUpdate": "no action" 942 + }, 943 + "posts_board_id_boards_id_fk": { 944 + "name": "posts_board_id_boards_id_fk", 945 + "tableFrom": "posts", 946 + "tableTo": "boards", 947 + "columnsFrom": [ 948 + "board_id" 949 + ], 950 + "columnsTo": [ 951 + "id" 952 + ], 953 + "onDelete": "no action", 954 + "onUpdate": "no action" 955 + }, 956 + "posts_root_post_id_posts_id_fk": { 957 + "name": "posts_root_post_id_posts_id_fk", 958 + "tableFrom": "posts", 959 + "tableTo": "posts", 960 + "columnsFrom": [ 961 + "root_post_id" 962 + ], 963 + "columnsTo": [ 964 + "id" 965 + ], 966 + "onDelete": "no action", 967 + "onUpdate": "no action" 968 + }, 969 + "posts_parent_post_id_posts_id_fk": { 970 + "name": "posts_parent_post_id_posts_id_fk", 971 + "tableFrom": "posts", 972 + "tableTo": "posts", 973 + "columnsFrom": [ 974 + "parent_post_id" 975 + ], 976 + "columnsTo": [ 977 + "id" 978 + ], 979 + "onDelete": "no action", 980 + "onUpdate": "no action" 981 + } 982 + }, 983 + "compositePrimaryKeys": {}, 984 + "uniqueConstraints": {}, 985 + "checkConstraints": {} 986 + }, 987 + "role_permissions": { 988 + "name": "role_permissions", 989 + "columns": { 990 + "role_id": { 991 + "name": "role_id", 992 + "type": "integer", 993 + "primaryKey": false, 994 + "notNull": true, 995 + "autoincrement": false 996 + }, 997 + "permission": { 998 + "name": "permission", 999 + "type": "text", 1000 + "primaryKey": false, 1001 + "notNull": true, 1002 + "autoincrement": false 1003 + } 1004 + }, 1005 + "indexes": {}, 1006 + "foreignKeys": { 1007 + "role_permissions_role_id_roles_id_fk": { 1008 + "name": "role_permissions_role_id_roles_id_fk", 1009 + "tableFrom": "role_permissions", 1010 + "tableTo": "roles", 1011 + "columnsFrom": [ 1012 + "role_id" 1013 + ], 1014 + "columnsTo": [ 1015 + "id" 1016 + ], 1017 + "onDelete": "cascade", 1018 + "onUpdate": "no action" 1019 + } 1020 + }, 1021 + "compositePrimaryKeys": { 1022 + "role_permissions_role_id_permission_pk": { 1023 + "columns": [ 1024 + "role_id", 1025 + "permission" 1026 + ], 1027 + "name": "role_permissions_role_id_permission_pk" 1028 + } 1029 + }, 1030 + "uniqueConstraints": {}, 1031 + "checkConstraints": {} 1032 + }, 1033 + "roles": { 1034 + "name": "roles", 1035 + "columns": { 1036 + "id": { 1037 + "name": "id", 1038 + "type": "integer", 1039 + "primaryKey": true, 1040 + "notNull": true, 1041 + "autoincrement": true 1042 + }, 1043 + "did": { 1044 + "name": "did", 1045 + "type": "text", 1046 + "primaryKey": false, 1047 + "notNull": true, 1048 + "autoincrement": false 1049 + }, 1050 + "rkey": { 1051 + "name": "rkey", 1052 + "type": "text", 1053 + "primaryKey": false, 1054 + "notNull": true, 1055 + "autoincrement": false 1056 + }, 1057 + "cid": { 1058 + "name": "cid", 1059 + "type": "text", 1060 + "primaryKey": false, 1061 + "notNull": true, 1062 + "autoincrement": false 1063 + }, 1064 + "name": { 1065 + "name": "name", 1066 + "type": "text", 1067 + "primaryKey": false, 1068 + "notNull": true, 1069 + "autoincrement": false 1070 + }, 1071 + "description": { 1072 + "name": "description", 1073 + "type": "text", 1074 + "primaryKey": false, 1075 + "notNull": false, 1076 + "autoincrement": false 1077 + }, 1078 + "priority": { 1079 + "name": "priority", 1080 + "type": "integer", 1081 + "primaryKey": false, 1082 + "notNull": true, 1083 + "autoincrement": false 1084 + }, 1085 + "created_at": { 1086 + "name": "created_at", 1087 + "type": "integer", 1088 + "primaryKey": false, 1089 + "notNull": true, 1090 + "autoincrement": false 1091 + }, 1092 + "indexed_at": { 1093 + "name": "indexed_at", 1094 + "type": "integer", 1095 + "primaryKey": false, 1096 + "notNull": true, 1097 + "autoincrement": false 1098 + } 1099 + }, 1100 + "indexes": { 1101 + "roles_did_rkey_idx": { 1102 + "name": "roles_did_rkey_idx", 1103 + "columns": [ 1104 + "did", 1105 + "rkey" 1106 + ], 1107 + "isUnique": true 1108 + }, 1109 + "roles_did_idx": { 1110 + "name": "roles_did_idx", 1111 + "columns": [ 1112 + "did" 1113 + ], 1114 + "isUnique": false 1115 + }, 1116 + "roles_did_name_idx": { 1117 + "name": "roles_did_name_idx", 1118 + "columns": [ 1119 + "did", 1120 + "name" 1121 + ], 1122 + "isUnique": false 1123 + } 1124 + }, 1125 + "foreignKeys": {}, 1126 + "compositePrimaryKeys": {}, 1127 + "uniqueConstraints": {}, 1128 + "checkConstraints": {} 1129 + }, 1130 + "users": { 1131 + "name": "users", 1132 + "columns": { 1133 + "did": { 1134 + "name": "did", 1135 + "type": "text", 1136 + "primaryKey": true, 1137 + "notNull": true, 1138 + "autoincrement": false 1139 + }, 1140 + "handle": { 1141 + "name": "handle", 1142 + "type": "text", 1143 + "primaryKey": false, 1144 + "notNull": false, 1145 + "autoincrement": false 1146 + }, 1147 + "indexed_at": { 1148 + "name": "indexed_at", 1149 + "type": "integer", 1150 + "primaryKey": false, 1151 + "notNull": true, 1152 + "autoincrement": false 1153 + } 1154 + }, 1155 + "indexes": {}, 1156 + "foreignKeys": {}, 1157 + "compositePrimaryKeys": {}, 1158 + "uniqueConstraints": {}, 1159 + "checkConstraints": {} 1160 + } 1161 + }, 1162 + "views": {}, 1163 + "enums": {}, 1164 + "_meta": { 1165 + "schemas": {}, 1166 + "tables": {}, 1167 + "columns": {} 1168 + }, 1169 + "internal": { 1170 + "indexes": {} 1171 + } 1172 + }
+13
apps/appview/drizzle-sqlite/meta/_journal.json
··· 1 + { 2 + "version": "7", 3 + "dialect": "sqlite", 4 + "entries": [ 5 + { 6 + "idx": 0, 7 + "version": "6", 8 + "when": 1772035997110, 9 + "tag": "0000_thankful_mister_fear", 10 + "breakpoints": true 11 + } 12 + ] 13 + }
apps/appview/drizzle.config.ts apps/appview/drizzle.postgres.config.ts
+10
apps/appview/drizzle.sqlite.config.ts
··· 1 + import { defineConfig } from "drizzle-kit"; 2 + 3 + export default defineConfig({ 4 + schema: "../../packages/db/src/schema.sqlite.ts", 5 + out: "./drizzle-sqlite", 6 + dialect: "sqlite", 7 + dbCredentials: { 8 + url: process.env.DATABASE_URL!, 9 + }, 10 + });
+7
apps/appview/drizzle/0011_first_apocalypse.sql
··· 1 + CREATE TABLE "role_permissions" ( 2 + "role_id" bigint NOT NULL, 3 + "permission" text NOT NULL, 4 + CONSTRAINT "role_permissions_role_id_permission_pk" PRIMARY KEY("role_id","permission") 5 + ); 6 + --> statement-breakpoint 7 + ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
+10
apps/appview/drizzle/0012_acoustic_swordsman.sql
··· 1 + -- Copy permissions array into the join table before dropping the source column. 2 + -- ON CONFLICT DO NOTHING makes this idempotent: safe to run on a DB where the 3 + -- data was already migrated manually (e.g. via scripts/migrate-permissions.ts). 4 + INSERT INTO "role_permissions" ("role_id", "permission") 5 + SELECT id, unnest(permissions) 6 + FROM "roles" 7 + WHERE permissions IS NOT NULL AND cardinality(permissions) > 0 8 + ON CONFLICT DO NOTHING; 9 + 10 + ALTER TABLE "roles" DROP COLUMN "permissions";
+1296
apps/appview/drizzle/meta/0011_snapshot.json
··· 1 + { 2 + "id": "b5c9f7f9-f34d-4dbc-8aba-394cbaca2c77", 3 + "prevId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.backfill_errors": { 8 + "name": "backfill_errors", 9 + "schema": "", 10 + "columns": { 11 + "id": { 12 + "name": "id", 13 + "type": "bigserial", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "backfill_id": { 18 + "name": "backfill_id", 19 + "type": "bigint", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "did": { 24 + "name": "did", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "collection": { 30 + "name": "collection", 31 + "type": "text", 32 + "primaryKey": false, 33 + "notNull": true 34 + }, 35 + "error_message": { 36 + "name": "error_message", 37 + "type": "text", 38 + "primaryKey": false, 39 + "notNull": true 40 + }, 41 + "created_at": { 42 + "name": "created_at", 43 + "type": "timestamp with time zone", 44 + "primaryKey": false, 45 + "notNull": true 46 + } 47 + }, 48 + "indexes": { 49 + "backfill_errors_backfill_id_idx": { 50 + "name": "backfill_errors_backfill_id_idx", 51 + "columns": [ 52 + { 53 + "expression": "backfill_id", 54 + "isExpression": false, 55 + "asc": true, 56 + "nulls": "last" 57 + } 58 + ], 59 + "isUnique": false, 60 + "concurrently": false, 61 + "method": "btree", 62 + "with": {} 63 + } 64 + }, 65 + "foreignKeys": { 66 + "backfill_errors_backfill_id_backfill_progress_id_fk": { 67 + "name": "backfill_errors_backfill_id_backfill_progress_id_fk", 68 + "tableFrom": "backfill_errors", 69 + "tableTo": "backfill_progress", 70 + "columnsFrom": [ 71 + "backfill_id" 72 + ], 73 + "columnsTo": [ 74 + "id" 75 + ], 76 + "onDelete": "no action", 77 + "onUpdate": "no action" 78 + } 79 + }, 80 + "compositePrimaryKeys": {}, 81 + "uniqueConstraints": {}, 82 + "policies": {}, 83 + "checkConstraints": {}, 84 + "isRLSEnabled": false 85 + }, 86 + "public.backfill_progress": { 87 + "name": "backfill_progress", 88 + "schema": "", 89 + "columns": { 90 + "id": { 91 + "name": "id", 92 + "type": "bigserial", 93 + "primaryKey": true, 94 + "notNull": true 95 + }, 96 + "status": { 97 + "name": "status", 98 + "type": "text", 99 + "primaryKey": false, 100 + "notNull": true 101 + }, 102 + "backfill_type": { 103 + "name": "backfill_type", 104 + "type": "text", 105 + "primaryKey": false, 106 + "notNull": true 107 + }, 108 + "last_processed_did": { 109 + "name": "last_processed_did", 110 + "type": "text", 111 + "primaryKey": false, 112 + "notNull": false 113 + }, 114 + "dids_total": { 115 + "name": "dids_total", 116 + "type": "integer", 117 + "primaryKey": false, 118 + "notNull": true, 119 + "default": 0 120 + }, 121 + "dids_processed": { 122 + "name": "dids_processed", 123 + "type": "integer", 124 + "primaryKey": false, 125 + "notNull": true, 126 + "default": 0 127 + }, 128 + "records_indexed": { 129 + "name": "records_indexed", 130 + "type": "integer", 131 + "primaryKey": false, 132 + "notNull": true, 133 + "default": 0 134 + }, 135 + "started_at": { 136 + "name": "started_at", 137 + "type": "timestamp with time zone", 138 + "primaryKey": false, 139 + "notNull": true 140 + }, 141 + "completed_at": { 142 + "name": "completed_at", 143 + "type": "timestamp with time zone", 144 + "primaryKey": false, 145 + "notNull": false 146 + }, 147 + "error_message": { 148 + "name": "error_message", 149 + "type": "text", 150 + "primaryKey": false, 151 + "notNull": false 152 + } 153 + }, 154 + "indexes": {}, 155 + "foreignKeys": {}, 156 + "compositePrimaryKeys": {}, 157 + "uniqueConstraints": {}, 158 + "policies": {}, 159 + "checkConstraints": {}, 160 + "isRLSEnabled": false 161 + }, 162 + "public.boards": { 163 + "name": "boards", 164 + "schema": "", 165 + "columns": { 166 + "id": { 167 + "name": "id", 168 + "type": "bigserial", 169 + "primaryKey": true, 170 + "notNull": true 171 + }, 172 + "did": { 173 + "name": "did", 174 + "type": "text", 175 + "primaryKey": false, 176 + "notNull": true 177 + }, 178 + "rkey": { 179 + "name": "rkey", 180 + "type": "text", 181 + "primaryKey": false, 182 + "notNull": true 183 + }, 184 + "cid": { 185 + "name": "cid", 186 + "type": "text", 187 + "primaryKey": false, 188 + "notNull": true 189 + }, 190 + "name": { 191 + "name": "name", 192 + "type": "text", 193 + "primaryKey": false, 194 + "notNull": true 195 + }, 196 + "description": { 197 + "name": "description", 198 + "type": "text", 199 + "primaryKey": false, 200 + "notNull": false 201 + }, 202 + "slug": { 203 + "name": "slug", 204 + "type": "text", 205 + "primaryKey": false, 206 + "notNull": false 207 + }, 208 + "sort_order": { 209 + "name": "sort_order", 210 + "type": "integer", 211 + "primaryKey": false, 212 + "notNull": false 213 + }, 214 + "category_id": { 215 + "name": "category_id", 216 + "type": "bigint", 217 + "primaryKey": false, 218 + "notNull": false 219 + }, 220 + "category_uri": { 221 + "name": "category_uri", 222 + "type": "text", 223 + "primaryKey": false, 224 + "notNull": true 225 + }, 226 + "created_at": { 227 + "name": "created_at", 228 + "type": "timestamp with time zone", 229 + "primaryKey": false, 230 + "notNull": true 231 + }, 232 + "indexed_at": { 233 + "name": "indexed_at", 234 + "type": "timestamp with time zone", 235 + "primaryKey": false, 236 + "notNull": true 237 + } 238 + }, 239 + "indexes": { 240 + "boards_did_rkey_idx": { 241 + "name": "boards_did_rkey_idx", 242 + "columns": [ 243 + { 244 + "expression": "did", 245 + "isExpression": false, 246 + "asc": true, 247 + "nulls": "last" 248 + }, 249 + { 250 + "expression": "rkey", 251 + "isExpression": false, 252 + "asc": true, 253 + "nulls": "last" 254 + } 255 + ], 256 + "isUnique": true, 257 + "concurrently": false, 258 + "method": "btree", 259 + "with": {} 260 + }, 261 + "boards_category_id_idx": { 262 + "name": "boards_category_id_idx", 263 + "columns": [ 264 + { 265 + "expression": "category_id", 266 + "isExpression": false, 267 + "asc": true, 268 + "nulls": "last" 269 + } 270 + ], 271 + "isUnique": false, 272 + "concurrently": false, 273 + "method": "btree", 274 + "with": {} 275 + } 276 + }, 277 + "foreignKeys": { 278 + "boards_category_id_categories_id_fk": { 279 + "name": "boards_category_id_categories_id_fk", 280 + "tableFrom": "boards", 281 + "tableTo": "categories", 282 + "columnsFrom": [ 283 + "category_id" 284 + ], 285 + "columnsTo": [ 286 + "id" 287 + ], 288 + "onDelete": "no action", 289 + "onUpdate": "no action" 290 + } 291 + }, 292 + "compositePrimaryKeys": {}, 293 + "uniqueConstraints": {}, 294 + "policies": {}, 295 + "checkConstraints": {}, 296 + "isRLSEnabled": false 297 + }, 298 + "public.categories": { 299 + "name": "categories", 300 + "schema": "", 301 + "columns": { 302 + "id": { 303 + "name": "id", 304 + "type": "bigserial", 305 + "primaryKey": true, 306 + "notNull": true 307 + }, 308 + "did": { 309 + "name": "did", 310 + "type": "text", 311 + "primaryKey": false, 312 + "notNull": true 313 + }, 314 + "rkey": { 315 + "name": "rkey", 316 + "type": "text", 317 + "primaryKey": false, 318 + "notNull": true 319 + }, 320 + "cid": { 321 + "name": "cid", 322 + "type": "text", 323 + "primaryKey": false, 324 + "notNull": true 325 + }, 326 + "name": { 327 + "name": "name", 328 + "type": "text", 329 + "primaryKey": false, 330 + "notNull": true 331 + }, 332 + "description": { 333 + "name": "description", 334 + "type": "text", 335 + "primaryKey": false, 336 + "notNull": false 337 + }, 338 + "slug": { 339 + "name": "slug", 340 + "type": "text", 341 + "primaryKey": false, 342 + "notNull": false 343 + }, 344 + "sort_order": { 345 + "name": "sort_order", 346 + "type": "integer", 347 + "primaryKey": false, 348 + "notNull": false 349 + }, 350 + "forum_id": { 351 + "name": "forum_id", 352 + "type": "bigint", 353 + "primaryKey": false, 354 + "notNull": false 355 + }, 356 + "created_at": { 357 + "name": "created_at", 358 + "type": "timestamp with time zone", 359 + "primaryKey": false, 360 + "notNull": true 361 + }, 362 + "indexed_at": { 363 + "name": "indexed_at", 364 + "type": "timestamp with time zone", 365 + "primaryKey": false, 366 + "notNull": true 367 + } 368 + }, 369 + "indexes": { 370 + "categories_did_rkey_idx": { 371 + "name": "categories_did_rkey_idx", 372 + "columns": [ 373 + { 374 + "expression": "did", 375 + "isExpression": false, 376 + "asc": true, 377 + "nulls": "last" 378 + }, 379 + { 380 + "expression": "rkey", 381 + "isExpression": false, 382 + "asc": true, 383 + "nulls": "last" 384 + } 385 + ], 386 + "isUnique": true, 387 + "concurrently": false, 388 + "method": "btree", 389 + "with": {} 390 + } 391 + }, 392 + "foreignKeys": { 393 + "categories_forum_id_forums_id_fk": { 394 + "name": "categories_forum_id_forums_id_fk", 395 + "tableFrom": "categories", 396 + "tableTo": "forums", 397 + "columnsFrom": [ 398 + "forum_id" 399 + ], 400 + "columnsTo": [ 401 + "id" 402 + ], 403 + "onDelete": "no action", 404 + "onUpdate": "no action" 405 + } 406 + }, 407 + "compositePrimaryKeys": {}, 408 + "uniqueConstraints": {}, 409 + "policies": {}, 410 + "checkConstraints": {}, 411 + "isRLSEnabled": false 412 + }, 413 + "public.firehose_cursor": { 414 + "name": "firehose_cursor", 415 + "schema": "", 416 + "columns": { 417 + "service": { 418 + "name": "service", 419 + "type": "text", 420 + "primaryKey": true, 421 + "notNull": true, 422 + "default": "'jetstream'" 423 + }, 424 + "cursor": { 425 + "name": "cursor", 426 + "type": "bigint", 427 + "primaryKey": false, 428 + "notNull": true 429 + }, 430 + "updated_at": { 431 + "name": "updated_at", 432 + "type": "timestamp with time zone", 433 + "primaryKey": false, 434 + "notNull": true 435 + } 436 + }, 437 + "indexes": {}, 438 + "foreignKeys": {}, 439 + "compositePrimaryKeys": {}, 440 + "uniqueConstraints": {}, 441 + "policies": {}, 442 + "checkConstraints": {}, 443 + "isRLSEnabled": false 444 + }, 445 + "public.forums": { 446 + "name": "forums", 447 + "schema": "", 448 + "columns": { 449 + "id": { 450 + "name": "id", 451 + "type": "bigserial", 452 + "primaryKey": true, 453 + "notNull": true 454 + }, 455 + "did": { 456 + "name": "did", 457 + "type": "text", 458 + "primaryKey": false, 459 + "notNull": true 460 + }, 461 + "rkey": { 462 + "name": "rkey", 463 + "type": "text", 464 + "primaryKey": false, 465 + "notNull": true 466 + }, 467 + "cid": { 468 + "name": "cid", 469 + "type": "text", 470 + "primaryKey": false, 471 + "notNull": true 472 + }, 473 + "name": { 474 + "name": "name", 475 + "type": "text", 476 + "primaryKey": false, 477 + "notNull": true 478 + }, 479 + "description": { 480 + "name": "description", 481 + "type": "text", 482 + "primaryKey": false, 483 + "notNull": false 484 + }, 485 + "indexed_at": { 486 + "name": "indexed_at", 487 + "type": "timestamp with time zone", 488 + "primaryKey": false, 489 + "notNull": true 490 + } 491 + }, 492 + "indexes": { 493 + "forums_did_rkey_idx": { 494 + "name": "forums_did_rkey_idx", 495 + "columns": [ 496 + { 497 + "expression": "did", 498 + "isExpression": false, 499 + "asc": true, 500 + "nulls": "last" 501 + }, 502 + { 503 + "expression": "rkey", 504 + "isExpression": false, 505 + "asc": true, 506 + "nulls": "last" 507 + } 508 + ], 509 + "isUnique": true, 510 + "concurrently": false, 511 + "method": "btree", 512 + "with": {} 513 + } 514 + }, 515 + "foreignKeys": {}, 516 + "compositePrimaryKeys": {}, 517 + "uniqueConstraints": {}, 518 + "policies": {}, 519 + "checkConstraints": {}, 520 + "isRLSEnabled": false 521 + }, 522 + "public.memberships": { 523 + "name": "memberships", 524 + "schema": "", 525 + "columns": { 526 + "id": { 527 + "name": "id", 528 + "type": "bigserial", 529 + "primaryKey": true, 530 + "notNull": true 531 + }, 532 + "did": { 533 + "name": "did", 534 + "type": "text", 535 + "primaryKey": false, 536 + "notNull": true 537 + }, 538 + "rkey": { 539 + "name": "rkey", 540 + "type": "text", 541 + "primaryKey": false, 542 + "notNull": true 543 + }, 544 + "cid": { 545 + "name": "cid", 546 + "type": "text", 547 + "primaryKey": false, 548 + "notNull": true 549 + }, 550 + "forum_id": { 551 + "name": "forum_id", 552 + "type": "bigint", 553 + "primaryKey": false, 554 + "notNull": false 555 + }, 556 + "forum_uri": { 557 + "name": "forum_uri", 558 + "type": "text", 559 + "primaryKey": false, 560 + "notNull": true 561 + }, 562 + "role": { 563 + "name": "role", 564 + "type": "text", 565 + "primaryKey": false, 566 + "notNull": false 567 + }, 568 + "role_uri": { 569 + "name": "role_uri", 570 + "type": "text", 571 + "primaryKey": false, 572 + "notNull": false 573 + }, 574 + "joined_at": { 575 + "name": "joined_at", 576 + "type": "timestamp with time zone", 577 + "primaryKey": false, 578 + "notNull": false 579 + }, 580 + "created_at": { 581 + "name": "created_at", 582 + "type": "timestamp with time zone", 583 + "primaryKey": false, 584 + "notNull": true 585 + }, 586 + "indexed_at": { 587 + "name": "indexed_at", 588 + "type": "timestamp with time zone", 589 + "primaryKey": false, 590 + "notNull": true 591 + } 592 + }, 593 + "indexes": { 594 + "memberships_did_rkey_idx": { 595 + "name": "memberships_did_rkey_idx", 596 + "columns": [ 597 + { 598 + "expression": "did", 599 + "isExpression": false, 600 + "asc": true, 601 + "nulls": "last" 602 + }, 603 + { 604 + "expression": "rkey", 605 + "isExpression": false, 606 + "asc": true, 607 + "nulls": "last" 608 + } 609 + ], 610 + "isUnique": true, 611 + "concurrently": false, 612 + "method": "btree", 613 + "with": {} 614 + }, 615 + "memberships_did_idx": { 616 + "name": "memberships_did_idx", 617 + "columns": [ 618 + { 619 + "expression": "did", 620 + "isExpression": false, 621 + "asc": true, 622 + "nulls": "last" 623 + } 624 + ], 625 + "isUnique": false, 626 + "concurrently": false, 627 + "method": "btree", 628 + "with": {} 629 + } 630 + }, 631 + "foreignKeys": { 632 + "memberships_did_users_did_fk": { 633 + "name": "memberships_did_users_did_fk", 634 + "tableFrom": "memberships", 635 + "tableTo": "users", 636 + "columnsFrom": [ 637 + "did" 638 + ], 639 + "columnsTo": [ 640 + "did" 641 + ], 642 + "onDelete": "no action", 643 + "onUpdate": "no action" 644 + }, 645 + "memberships_forum_id_forums_id_fk": { 646 + "name": "memberships_forum_id_forums_id_fk", 647 + "tableFrom": "memberships", 648 + "tableTo": "forums", 649 + "columnsFrom": [ 650 + "forum_id" 651 + ], 652 + "columnsTo": [ 653 + "id" 654 + ], 655 + "onDelete": "no action", 656 + "onUpdate": "no action" 657 + } 658 + }, 659 + "compositePrimaryKeys": {}, 660 + "uniqueConstraints": {}, 661 + "policies": {}, 662 + "checkConstraints": {}, 663 + "isRLSEnabled": false 664 + }, 665 + "public.mod_actions": { 666 + "name": "mod_actions", 667 + "schema": "", 668 + "columns": { 669 + "id": { 670 + "name": "id", 671 + "type": "bigserial", 672 + "primaryKey": true, 673 + "notNull": true 674 + }, 675 + "did": { 676 + "name": "did", 677 + "type": "text", 678 + "primaryKey": false, 679 + "notNull": true 680 + }, 681 + "rkey": { 682 + "name": "rkey", 683 + "type": "text", 684 + "primaryKey": false, 685 + "notNull": true 686 + }, 687 + "cid": { 688 + "name": "cid", 689 + "type": "text", 690 + "primaryKey": false, 691 + "notNull": true 692 + }, 693 + "action": { 694 + "name": "action", 695 + "type": "text", 696 + "primaryKey": false, 697 + "notNull": true 698 + }, 699 + "subject_did": { 700 + "name": "subject_did", 701 + "type": "text", 702 + "primaryKey": false, 703 + "notNull": false 704 + }, 705 + "subject_post_uri": { 706 + "name": "subject_post_uri", 707 + "type": "text", 708 + "primaryKey": false, 709 + "notNull": false 710 + }, 711 + "forum_id": { 712 + "name": "forum_id", 713 + "type": "bigint", 714 + "primaryKey": false, 715 + "notNull": false 716 + }, 717 + "reason": { 718 + "name": "reason", 719 + "type": "text", 720 + "primaryKey": false, 721 + "notNull": false 722 + }, 723 + "created_by": { 724 + "name": "created_by", 725 + "type": "text", 726 + "primaryKey": false, 727 + "notNull": true 728 + }, 729 + "expires_at": { 730 + "name": "expires_at", 731 + "type": "timestamp with time zone", 732 + "primaryKey": false, 733 + "notNull": false 734 + }, 735 + "created_at": { 736 + "name": "created_at", 737 + "type": "timestamp with time zone", 738 + "primaryKey": false, 739 + "notNull": true 740 + }, 741 + "indexed_at": { 742 + "name": "indexed_at", 743 + "type": "timestamp with time zone", 744 + "primaryKey": false, 745 + "notNull": true 746 + } 747 + }, 748 + "indexes": { 749 + "mod_actions_did_rkey_idx": { 750 + "name": "mod_actions_did_rkey_idx", 751 + "columns": [ 752 + { 753 + "expression": "did", 754 + "isExpression": false, 755 + "asc": true, 756 + "nulls": "last" 757 + }, 758 + { 759 + "expression": "rkey", 760 + "isExpression": false, 761 + "asc": true, 762 + "nulls": "last" 763 + } 764 + ], 765 + "isUnique": true, 766 + "concurrently": false, 767 + "method": "btree", 768 + "with": {} 769 + }, 770 + "mod_actions_subject_did_idx": { 771 + "name": "mod_actions_subject_did_idx", 772 + "columns": [ 773 + { 774 + "expression": "subject_did", 775 + "isExpression": false, 776 + "asc": true, 777 + "nulls": "last" 778 + } 779 + ], 780 + "isUnique": false, 781 + "concurrently": false, 782 + "method": "btree", 783 + "with": {} 784 + }, 785 + "mod_actions_subject_post_uri_idx": { 786 + "name": "mod_actions_subject_post_uri_idx", 787 + "columns": [ 788 + { 789 + "expression": "subject_post_uri", 790 + "isExpression": false, 791 + "asc": true, 792 + "nulls": "last" 793 + } 794 + ], 795 + "isUnique": false, 796 + "concurrently": false, 797 + "method": "btree", 798 + "with": {} 799 + } 800 + }, 801 + "foreignKeys": { 802 + "mod_actions_forum_id_forums_id_fk": { 803 + "name": "mod_actions_forum_id_forums_id_fk", 804 + "tableFrom": "mod_actions", 805 + "tableTo": "forums", 806 + "columnsFrom": [ 807 + "forum_id" 808 + ], 809 + "columnsTo": [ 810 + "id" 811 + ], 812 + "onDelete": "no action", 813 + "onUpdate": "no action" 814 + } 815 + }, 816 + "compositePrimaryKeys": {}, 817 + "uniqueConstraints": {}, 818 + "policies": {}, 819 + "checkConstraints": {}, 820 + "isRLSEnabled": false 821 + }, 822 + "public.posts": { 823 + "name": "posts", 824 + "schema": "", 825 + "columns": { 826 + "id": { 827 + "name": "id", 828 + "type": "bigserial", 829 + "primaryKey": true, 830 + "notNull": true 831 + }, 832 + "did": { 833 + "name": "did", 834 + "type": "text", 835 + "primaryKey": false, 836 + "notNull": true 837 + }, 838 + "rkey": { 839 + "name": "rkey", 840 + "type": "text", 841 + "primaryKey": false, 842 + "notNull": true 843 + }, 844 + "cid": { 845 + "name": "cid", 846 + "type": "text", 847 + "primaryKey": false, 848 + "notNull": true 849 + }, 850 + "title": { 851 + "name": "title", 852 + "type": "text", 853 + "primaryKey": false, 854 + "notNull": false 855 + }, 856 + "text": { 857 + "name": "text", 858 + "type": "text", 859 + "primaryKey": false, 860 + "notNull": true 861 + }, 862 + "forum_uri": { 863 + "name": "forum_uri", 864 + "type": "text", 865 + "primaryKey": false, 866 + "notNull": false 867 + }, 868 + "board_uri": { 869 + "name": "board_uri", 870 + "type": "text", 871 + "primaryKey": false, 872 + "notNull": false 873 + }, 874 + "board_id": { 875 + "name": "board_id", 876 + "type": "bigint", 877 + "primaryKey": false, 878 + "notNull": false 879 + }, 880 + "root_post_id": { 881 + "name": "root_post_id", 882 + "type": "bigint", 883 + "primaryKey": false, 884 + "notNull": false 885 + }, 886 + "parent_post_id": { 887 + "name": "parent_post_id", 888 + "type": "bigint", 889 + "primaryKey": false, 890 + "notNull": false 891 + }, 892 + "root_uri": { 893 + "name": "root_uri", 894 + "type": "text", 895 + "primaryKey": false, 896 + "notNull": false 897 + }, 898 + "parent_uri": { 899 + "name": "parent_uri", 900 + "type": "text", 901 + "primaryKey": false, 902 + "notNull": false 903 + }, 904 + "created_at": { 905 + "name": "created_at", 906 + "type": "timestamp with time zone", 907 + "primaryKey": false, 908 + "notNull": true 909 + }, 910 + "indexed_at": { 911 + "name": "indexed_at", 912 + "type": "timestamp with time zone", 913 + "primaryKey": false, 914 + "notNull": true 915 + }, 916 + "banned_by_mod": { 917 + "name": "banned_by_mod", 918 + "type": "boolean", 919 + "primaryKey": false, 920 + "notNull": true, 921 + "default": false 922 + }, 923 + "deleted_by_user": { 924 + "name": "deleted_by_user", 925 + "type": "boolean", 926 + "primaryKey": false, 927 + "notNull": true, 928 + "default": false 929 + } 930 + }, 931 + "indexes": { 932 + "posts_did_rkey_idx": { 933 + "name": "posts_did_rkey_idx", 934 + "columns": [ 935 + { 936 + "expression": "did", 937 + "isExpression": false, 938 + "asc": true, 939 + "nulls": "last" 940 + }, 941 + { 942 + "expression": "rkey", 943 + "isExpression": false, 944 + "asc": true, 945 + "nulls": "last" 946 + } 947 + ], 948 + "isUnique": true, 949 + "concurrently": false, 950 + "method": "btree", 951 + "with": {} 952 + }, 953 + "posts_forum_uri_idx": { 954 + "name": "posts_forum_uri_idx", 955 + "columns": [ 956 + { 957 + "expression": "forum_uri", 958 + "isExpression": false, 959 + "asc": true, 960 + "nulls": "last" 961 + } 962 + ], 963 + "isUnique": false, 964 + "concurrently": false, 965 + "method": "btree", 966 + "with": {} 967 + }, 968 + "posts_board_id_idx": { 969 + "name": "posts_board_id_idx", 970 + "columns": [ 971 + { 972 + "expression": "board_id", 973 + "isExpression": false, 974 + "asc": true, 975 + "nulls": "last" 976 + } 977 + ], 978 + "isUnique": false, 979 + "concurrently": false, 980 + "method": "btree", 981 + "with": {} 982 + }, 983 + "posts_board_uri_idx": { 984 + "name": "posts_board_uri_idx", 985 + "columns": [ 986 + { 987 + "expression": "board_uri", 988 + "isExpression": false, 989 + "asc": true, 990 + "nulls": "last" 991 + } 992 + ], 993 + "isUnique": false, 994 + "concurrently": false, 995 + "method": "btree", 996 + "with": {} 997 + }, 998 + "posts_root_post_id_idx": { 999 + "name": "posts_root_post_id_idx", 1000 + "columns": [ 1001 + { 1002 + "expression": "root_post_id", 1003 + "isExpression": false, 1004 + "asc": true, 1005 + "nulls": "last" 1006 + } 1007 + ], 1008 + "isUnique": false, 1009 + "concurrently": false, 1010 + "method": "btree", 1011 + "with": {} 1012 + } 1013 + }, 1014 + "foreignKeys": { 1015 + "posts_did_users_did_fk": { 1016 + "name": "posts_did_users_did_fk", 1017 + "tableFrom": "posts", 1018 + "tableTo": "users", 1019 + "columnsFrom": [ 1020 + "did" 1021 + ], 1022 + "columnsTo": [ 1023 + "did" 1024 + ], 1025 + "onDelete": "no action", 1026 + "onUpdate": "no action" 1027 + }, 1028 + "posts_board_id_boards_id_fk": { 1029 + "name": "posts_board_id_boards_id_fk", 1030 + "tableFrom": "posts", 1031 + "tableTo": "boards", 1032 + "columnsFrom": [ 1033 + "board_id" 1034 + ], 1035 + "columnsTo": [ 1036 + "id" 1037 + ], 1038 + "onDelete": "no action", 1039 + "onUpdate": "no action" 1040 + }, 1041 + "posts_root_post_id_posts_id_fk": { 1042 + "name": "posts_root_post_id_posts_id_fk", 1043 + "tableFrom": "posts", 1044 + "tableTo": "posts", 1045 + "columnsFrom": [ 1046 + "root_post_id" 1047 + ], 1048 + "columnsTo": [ 1049 + "id" 1050 + ], 1051 + "onDelete": "no action", 1052 + "onUpdate": "no action" 1053 + }, 1054 + "posts_parent_post_id_posts_id_fk": { 1055 + "name": "posts_parent_post_id_posts_id_fk", 1056 + "tableFrom": "posts", 1057 + "tableTo": "posts", 1058 + "columnsFrom": [ 1059 + "parent_post_id" 1060 + ], 1061 + "columnsTo": [ 1062 + "id" 1063 + ], 1064 + "onDelete": "no action", 1065 + "onUpdate": "no action" 1066 + } 1067 + }, 1068 + "compositePrimaryKeys": {}, 1069 + "uniqueConstraints": {}, 1070 + "policies": {}, 1071 + "checkConstraints": {}, 1072 + "isRLSEnabled": false 1073 + }, 1074 + "public.role_permissions": { 1075 + "name": "role_permissions", 1076 + "schema": "", 1077 + "columns": { 1078 + "role_id": { 1079 + "name": "role_id", 1080 + "type": "bigint", 1081 + "primaryKey": false, 1082 + "notNull": true 1083 + }, 1084 + "permission": { 1085 + "name": "permission", 1086 + "type": "text", 1087 + "primaryKey": false, 1088 + "notNull": true 1089 + } 1090 + }, 1091 + "indexes": {}, 1092 + "foreignKeys": { 1093 + "role_permissions_role_id_roles_id_fk": { 1094 + "name": "role_permissions_role_id_roles_id_fk", 1095 + "tableFrom": "role_permissions", 1096 + "tableTo": "roles", 1097 + "columnsFrom": [ 1098 + "role_id" 1099 + ], 1100 + "columnsTo": [ 1101 + "id" 1102 + ], 1103 + "onDelete": "cascade", 1104 + "onUpdate": "no action" 1105 + } 1106 + }, 1107 + "compositePrimaryKeys": { 1108 + "role_permissions_role_id_permission_pk": { 1109 + "name": "role_permissions_role_id_permission_pk", 1110 + "columns": [ 1111 + "role_id", 1112 + "permission" 1113 + ] 1114 + } 1115 + }, 1116 + "uniqueConstraints": {}, 1117 + "policies": {}, 1118 + "checkConstraints": {}, 1119 + "isRLSEnabled": false 1120 + }, 1121 + "public.roles": { 1122 + "name": "roles", 1123 + "schema": "", 1124 + "columns": { 1125 + "id": { 1126 + "name": "id", 1127 + "type": "bigserial", 1128 + "primaryKey": true, 1129 + "notNull": true 1130 + }, 1131 + "did": { 1132 + "name": "did", 1133 + "type": "text", 1134 + "primaryKey": false, 1135 + "notNull": true 1136 + }, 1137 + "rkey": { 1138 + "name": "rkey", 1139 + "type": "text", 1140 + "primaryKey": false, 1141 + "notNull": true 1142 + }, 1143 + "cid": { 1144 + "name": "cid", 1145 + "type": "text", 1146 + "primaryKey": false, 1147 + "notNull": true 1148 + }, 1149 + "name": { 1150 + "name": "name", 1151 + "type": "text", 1152 + "primaryKey": false, 1153 + "notNull": true 1154 + }, 1155 + "description": { 1156 + "name": "description", 1157 + "type": "text", 1158 + "primaryKey": false, 1159 + "notNull": false 1160 + }, 1161 + "permissions": { 1162 + "name": "permissions", 1163 + "type": "text[]", 1164 + "primaryKey": false, 1165 + "notNull": true, 1166 + "default": "'{}'::text[]" 1167 + }, 1168 + "priority": { 1169 + "name": "priority", 1170 + "type": "integer", 1171 + "primaryKey": false, 1172 + "notNull": true 1173 + }, 1174 + "created_at": { 1175 + "name": "created_at", 1176 + "type": "timestamp with time zone", 1177 + "primaryKey": false, 1178 + "notNull": true 1179 + }, 1180 + "indexed_at": { 1181 + "name": "indexed_at", 1182 + "type": "timestamp with time zone", 1183 + "primaryKey": false, 1184 + "notNull": true 1185 + } 1186 + }, 1187 + "indexes": { 1188 + "roles_did_rkey_idx": { 1189 + "name": "roles_did_rkey_idx", 1190 + "columns": [ 1191 + { 1192 + "expression": "did", 1193 + "isExpression": false, 1194 + "asc": true, 1195 + "nulls": "last" 1196 + }, 1197 + { 1198 + "expression": "rkey", 1199 + "isExpression": false, 1200 + "asc": true, 1201 + "nulls": "last" 1202 + } 1203 + ], 1204 + "isUnique": true, 1205 + "concurrently": false, 1206 + "method": "btree", 1207 + "with": {} 1208 + }, 1209 + "roles_did_idx": { 1210 + "name": "roles_did_idx", 1211 + "columns": [ 1212 + { 1213 + "expression": "did", 1214 + "isExpression": false, 1215 + "asc": true, 1216 + "nulls": "last" 1217 + } 1218 + ], 1219 + "isUnique": false, 1220 + "concurrently": false, 1221 + "method": "btree", 1222 + "with": {} 1223 + }, 1224 + "roles_did_name_idx": { 1225 + "name": "roles_did_name_idx", 1226 + "columns": [ 1227 + { 1228 + "expression": "did", 1229 + "isExpression": false, 1230 + "asc": true, 1231 + "nulls": "last" 1232 + }, 1233 + { 1234 + "expression": "name", 1235 + "isExpression": false, 1236 + "asc": true, 1237 + "nulls": "last" 1238 + } 1239 + ], 1240 + "isUnique": false, 1241 + "concurrently": false, 1242 + "method": "btree", 1243 + "with": {} 1244 + } 1245 + }, 1246 + "foreignKeys": {}, 1247 + "compositePrimaryKeys": {}, 1248 + "uniqueConstraints": {}, 1249 + "policies": {}, 1250 + "checkConstraints": {}, 1251 + "isRLSEnabled": false 1252 + }, 1253 + "public.users": { 1254 + "name": "users", 1255 + "schema": "", 1256 + "columns": { 1257 + "did": { 1258 + "name": "did", 1259 + "type": "text", 1260 + "primaryKey": true, 1261 + "notNull": true 1262 + }, 1263 + "handle": { 1264 + "name": "handle", 1265 + "type": "text", 1266 + "primaryKey": false, 1267 + "notNull": false 1268 + }, 1269 + "indexed_at": { 1270 + "name": "indexed_at", 1271 + "type": "timestamp with time zone", 1272 + "primaryKey": false, 1273 + "notNull": true 1274 + } 1275 + }, 1276 + "indexes": {}, 1277 + "foreignKeys": {}, 1278 + "compositePrimaryKeys": {}, 1279 + "uniqueConstraints": {}, 1280 + "policies": {}, 1281 + "checkConstraints": {}, 1282 + "isRLSEnabled": false 1283 + } 1284 + }, 1285 + "enums": {}, 1286 + "schemas": {}, 1287 + "sequences": {}, 1288 + "roles": {}, 1289 + "policies": {}, 1290 + "views": {}, 1291 + "_meta": { 1292 + "columns": {}, 1293 + "schemas": {}, 1294 + "tables": {} 1295 + } 1296 + }
+1289
apps/appview/drizzle/meta/0012_snapshot.json
··· 1 + { 2 + "id": "0179c1d9-9fbb-4bc8-9606-475e10ba22dc", 3 + "prevId": "b5c9f7f9-f34d-4dbc-8aba-394cbaca2c77", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.backfill_errors": { 8 + "name": "backfill_errors", 9 + "schema": "", 10 + "columns": { 11 + "id": { 12 + "name": "id", 13 + "type": "bigserial", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "backfill_id": { 18 + "name": "backfill_id", 19 + "type": "bigint", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "did": { 24 + "name": "did", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "collection": { 30 + "name": "collection", 31 + "type": "text", 32 + "primaryKey": false, 33 + "notNull": true 34 + }, 35 + "error_message": { 36 + "name": "error_message", 37 + "type": "text", 38 + "primaryKey": false, 39 + "notNull": true 40 + }, 41 + "created_at": { 42 + "name": "created_at", 43 + "type": "timestamp with time zone", 44 + "primaryKey": false, 45 + "notNull": true 46 + } 47 + }, 48 + "indexes": { 49 + "backfill_errors_backfill_id_idx": { 50 + "name": "backfill_errors_backfill_id_idx", 51 + "columns": [ 52 + { 53 + "expression": "backfill_id", 54 + "isExpression": false, 55 + "asc": true, 56 + "nulls": "last" 57 + } 58 + ], 59 + "isUnique": false, 60 + "concurrently": false, 61 + "method": "btree", 62 + "with": {} 63 + } 64 + }, 65 + "foreignKeys": { 66 + "backfill_errors_backfill_id_backfill_progress_id_fk": { 67 + "name": "backfill_errors_backfill_id_backfill_progress_id_fk", 68 + "tableFrom": "backfill_errors", 69 + "tableTo": "backfill_progress", 70 + "columnsFrom": [ 71 + "backfill_id" 72 + ], 73 + "columnsTo": [ 74 + "id" 75 + ], 76 + "onDelete": "no action", 77 + "onUpdate": "no action" 78 + } 79 + }, 80 + "compositePrimaryKeys": {}, 81 + "uniqueConstraints": {}, 82 + "policies": {}, 83 + "checkConstraints": {}, 84 + "isRLSEnabled": false 85 + }, 86 + "public.backfill_progress": { 87 + "name": "backfill_progress", 88 + "schema": "", 89 + "columns": { 90 + "id": { 91 + "name": "id", 92 + "type": "bigserial", 93 + "primaryKey": true, 94 + "notNull": true 95 + }, 96 + "status": { 97 + "name": "status", 98 + "type": "text", 99 + "primaryKey": false, 100 + "notNull": true 101 + }, 102 + "backfill_type": { 103 + "name": "backfill_type", 104 + "type": "text", 105 + "primaryKey": false, 106 + "notNull": true 107 + }, 108 + "last_processed_did": { 109 + "name": "last_processed_did", 110 + "type": "text", 111 + "primaryKey": false, 112 + "notNull": false 113 + }, 114 + "dids_total": { 115 + "name": "dids_total", 116 + "type": "integer", 117 + "primaryKey": false, 118 + "notNull": true, 119 + "default": 0 120 + }, 121 + "dids_processed": { 122 + "name": "dids_processed", 123 + "type": "integer", 124 + "primaryKey": false, 125 + "notNull": true, 126 + "default": 0 127 + }, 128 + "records_indexed": { 129 + "name": "records_indexed", 130 + "type": "integer", 131 + "primaryKey": false, 132 + "notNull": true, 133 + "default": 0 134 + }, 135 + "started_at": { 136 + "name": "started_at", 137 + "type": "timestamp with time zone", 138 + "primaryKey": false, 139 + "notNull": true 140 + }, 141 + "completed_at": { 142 + "name": "completed_at", 143 + "type": "timestamp with time zone", 144 + "primaryKey": false, 145 + "notNull": false 146 + }, 147 + "error_message": { 148 + "name": "error_message", 149 + "type": "text", 150 + "primaryKey": false, 151 + "notNull": false 152 + } 153 + }, 154 + "indexes": {}, 155 + "foreignKeys": {}, 156 + "compositePrimaryKeys": {}, 157 + "uniqueConstraints": {}, 158 + "policies": {}, 159 + "checkConstraints": {}, 160 + "isRLSEnabled": false 161 + }, 162 + "public.boards": { 163 + "name": "boards", 164 + "schema": "", 165 + "columns": { 166 + "id": { 167 + "name": "id", 168 + "type": "bigserial", 169 + "primaryKey": true, 170 + "notNull": true 171 + }, 172 + "did": { 173 + "name": "did", 174 + "type": "text", 175 + "primaryKey": false, 176 + "notNull": true 177 + }, 178 + "rkey": { 179 + "name": "rkey", 180 + "type": "text", 181 + "primaryKey": false, 182 + "notNull": true 183 + }, 184 + "cid": { 185 + "name": "cid", 186 + "type": "text", 187 + "primaryKey": false, 188 + "notNull": true 189 + }, 190 + "name": { 191 + "name": "name", 192 + "type": "text", 193 + "primaryKey": false, 194 + "notNull": true 195 + }, 196 + "description": { 197 + "name": "description", 198 + "type": "text", 199 + "primaryKey": false, 200 + "notNull": false 201 + }, 202 + "slug": { 203 + "name": "slug", 204 + "type": "text", 205 + "primaryKey": false, 206 + "notNull": false 207 + }, 208 + "sort_order": { 209 + "name": "sort_order", 210 + "type": "integer", 211 + "primaryKey": false, 212 + "notNull": false 213 + }, 214 + "category_id": { 215 + "name": "category_id", 216 + "type": "bigint", 217 + "primaryKey": false, 218 + "notNull": false 219 + }, 220 + "category_uri": { 221 + "name": "category_uri", 222 + "type": "text", 223 + "primaryKey": false, 224 + "notNull": true 225 + }, 226 + "created_at": { 227 + "name": "created_at", 228 + "type": "timestamp with time zone", 229 + "primaryKey": false, 230 + "notNull": true 231 + }, 232 + "indexed_at": { 233 + "name": "indexed_at", 234 + "type": "timestamp with time zone", 235 + "primaryKey": false, 236 + "notNull": true 237 + } 238 + }, 239 + "indexes": { 240 + "boards_did_rkey_idx": { 241 + "name": "boards_did_rkey_idx", 242 + "columns": [ 243 + { 244 + "expression": "did", 245 + "isExpression": false, 246 + "asc": true, 247 + "nulls": "last" 248 + }, 249 + { 250 + "expression": "rkey", 251 + "isExpression": false, 252 + "asc": true, 253 + "nulls": "last" 254 + } 255 + ], 256 + "isUnique": true, 257 + "concurrently": false, 258 + "method": "btree", 259 + "with": {} 260 + }, 261 + "boards_category_id_idx": { 262 + "name": "boards_category_id_idx", 263 + "columns": [ 264 + { 265 + "expression": "category_id", 266 + "isExpression": false, 267 + "asc": true, 268 + "nulls": "last" 269 + } 270 + ], 271 + "isUnique": false, 272 + "concurrently": false, 273 + "method": "btree", 274 + "with": {} 275 + } 276 + }, 277 + "foreignKeys": { 278 + "boards_category_id_categories_id_fk": { 279 + "name": "boards_category_id_categories_id_fk", 280 + "tableFrom": "boards", 281 + "tableTo": "categories", 282 + "columnsFrom": [ 283 + "category_id" 284 + ], 285 + "columnsTo": [ 286 + "id" 287 + ], 288 + "onDelete": "no action", 289 + "onUpdate": "no action" 290 + } 291 + }, 292 + "compositePrimaryKeys": {}, 293 + "uniqueConstraints": {}, 294 + "policies": {}, 295 + "checkConstraints": {}, 296 + "isRLSEnabled": false 297 + }, 298 + "public.categories": { 299 + "name": "categories", 300 + "schema": "", 301 + "columns": { 302 + "id": { 303 + "name": "id", 304 + "type": "bigserial", 305 + "primaryKey": true, 306 + "notNull": true 307 + }, 308 + "did": { 309 + "name": "did", 310 + "type": "text", 311 + "primaryKey": false, 312 + "notNull": true 313 + }, 314 + "rkey": { 315 + "name": "rkey", 316 + "type": "text", 317 + "primaryKey": false, 318 + "notNull": true 319 + }, 320 + "cid": { 321 + "name": "cid", 322 + "type": "text", 323 + "primaryKey": false, 324 + "notNull": true 325 + }, 326 + "name": { 327 + "name": "name", 328 + "type": "text", 329 + "primaryKey": false, 330 + "notNull": true 331 + }, 332 + "description": { 333 + "name": "description", 334 + "type": "text", 335 + "primaryKey": false, 336 + "notNull": false 337 + }, 338 + "slug": { 339 + "name": "slug", 340 + "type": "text", 341 + "primaryKey": false, 342 + "notNull": false 343 + }, 344 + "sort_order": { 345 + "name": "sort_order", 346 + "type": "integer", 347 + "primaryKey": false, 348 + "notNull": false 349 + }, 350 + "forum_id": { 351 + "name": "forum_id", 352 + "type": "bigint", 353 + "primaryKey": false, 354 + "notNull": false 355 + }, 356 + "created_at": { 357 + "name": "created_at", 358 + "type": "timestamp with time zone", 359 + "primaryKey": false, 360 + "notNull": true 361 + }, 362 + "indexed_at": { 363 + "name": "indexed_at", 364 + "type": "timestamp with time zone", 365 + "primaryKey": false, 366 + "notNull": true 367 + } 368 + }, 369 + "indexes": { 370 + "categories_did_rkey_idx": { 371 + "name": "categories_did_rkey_idx", 372 + "columns": [ 373 + { 374 + "expression": "did", 375 + "isExpression": false, 376 + "asc": true, 377 + "nulls": "last" 378 + }, 379 + { 380 + "expression": "rkey", 381 + "isExpression": false, 382 + "asc": true, 383 + "nulls": "last" 384 + } 385 + ], 386 + "isUnique": true, 387 + "concurrently": false, 388 + "method": "btree", 389 + "with": {} 390 + } 391 + }, 392 + "foreignKeys": { 393 + "categories_forum_id_forums_id_fk": { 394 + "name": "categories_forum_id_forums_id_fk", 395 + "tableFrom": "categories", 396 + "tableTo": "forums", 397 + "columnsFrom": [ 398 + "forum_id" 399 + ], 400 + "columnsTo": [ 401 + "id" 402 + ], 403 + "onDelete": "no action", 404 + "onUpdate": "no action" 405 + } 406 + }, 407 + "compositePrimaryKeys": {}, 408 + "uniqueConstraints": {}, 409 + "policies": {}, 410 + "checkConstraints": {}, 411 + "isRLSEnabled": false 412 + }, 413 + "public.firehose_cursor": { 414 + "name": "firehose_cursor", 415 + "schema": "", 416 + "columns": { 417 + "service": { 418 + "name": "service", 419 + "type": "text", 420 + "primaryKey": true, 421 + "notNull": true, 422 + "default": "'jetstream'" 423 + }, 424 + "cursor": { 425 + "name": "cursor", 426 + "type": "bigint", 427 + "primaryKey": false, 428 + "notNull": true 429 + }, 430 + "updated_at": { 431 + "name": "updated_at", 432 + "type": "timestamp with time zone", 433 + "primaryKey": false, 434 + "notNull": true 435 + } 436 + }, 437 + "indexes": {}, 438 + "foreignKeys": {}, 439 + "compositePrimaryKeys": {}, 440 + "uniqueConstraints": {}, 441 + "policies": {}, 442 + "checkConstraints": {}, 443 + "isRLSEnabled": false 444 + }, 445 + "public.forums": { 446 + "name": "forums", 447 + "schema": "", 448 + "columns": { 449 + "id": { 450 + "name": "id", 451 + "type": "bigserial", 452 + "primaryKey": true, 453 + "notNull": true 454 + }, 455 + "did": { 456 + "name": "did", 457 + "type": "text", 458 + "primaryKey": false, 459 + "notNull": true 460 + }, 461 + "rkey": { 462 + "name": "rkey", 463 + "type": "text", 464 + "primaryKey": false, 465 + "notNull": true 466 + }, 467 + "cid": { 468 + "name": "cid", 469 + "type": "text", 470 + "primaryKey": false, 471 + "notNull": true 472 + }, 473 + "name": { 474 + "name": "name", 475 + "type": "text", 476 + "primaryKey": false, 477 + "notNull": true 478 + }, 479 + "description": { 480 + "name": "description", 481 + "type": "text", 482 + "primaryKey": false, 483 + "notNull": false 484 + }, 485 + "indexed_at": { 486 + "name": "indexed_at", 487 + "type": "timestamp with time zone", 488 + "primaryKey": false, 489 + "notNull": true 490 + } 491 + }, 492 + "indexes": { 493 + "forums_did_rkey_idx": { 494 + "name": "forums_did_rkey_idx", 495 + "columns": [ 496 + { 497 + "expression": "did", 498 + "isExpression": false, 499 + "asc": true, 500 + "nulls": "last" 501 + }, 502 + { 503 + "expression": "rkey", 504 + "isExpression": false, 505 + "asc": true, 506 + "nulls": "last" 507 + } 508 + ], 509 + "isUnique": true, 510 + "concurrently": false, 511 + "method": "btree", 512 + "with": {} 513 + } 514 + }, 515 + "foreignKeys": {}, 516 + "compositePrimaryKeys": {}, 517 + "uniqueConstraints": {}, 518 + "policies": {}, 519 + "checkConstraints": {}, 520 + "isRLSEnabled": false 521 + }, 522 + "public.memberships": { 523 + "name": "memberships", 524 + "schema": "", 525 + "columns": { 526 + "id": { 527 + "name": "id", 528 + "type": "bigserial", 529 + "primaryKey": true, 530 + "notNull": true 531 + }, 532 + "did": { 533 + "name": "did", 534 + "type": "text", 535 + "primaryKey": false, 536 + "notNull": true 537 + }, 538 + "rkey": { 539 + "name": "rkey", 540 + "type": "text", 541 + "primaryKey": false, 542 + "notNull": true 543 + }, 544 + "cid": { 545 + "name": "cid", 546 + "type": "text", 547 + "primaryKey": false, 548 + "notNull": true 549 + }, 550 + "forum_id": { 551 + "name": "forum_id", 552 + "type": "bigint", 553 + "primaryKey": false, 554 + "notNull": false 555 + }, 556 + "forum_uri": { 557 + "name": "forum_uri", 558 + "type": "text", 559 + "primaryKey": false, 560 + "notNull": true 561 + }, 562 + "role": { 563 + "name": "role", 564 + "type": "text", 565 + "primaryKey": false, 566 + "notNull": false 567 + }, 568 + "role_uri": { 569 + "name": "role_uri", 570 + "type": "text", 571 + "primaryKey": false, 572 + "notNull": false 573 + }, 574 + "joined_at": { 575 + "name": "joined_at", 576 + "type": "timestamp with time zone", 577 + "primaryKey": false, 578 + "notNull": false 579 + }, 580 + "created_at": { 581 + "name": "created_at", 582 + "type": "timestamp with time zone", 583 + "primaryKey": false, 584 + "notNull": true 585 + }, 586 + "indexed_at": { 587 + "name": "indexed_at", 588 + "type": "timestamp with time zone", 589 + "primaryKey": false, 590 + "notNull": true 591 + } 592 + }, 593 + "indexes": { 594 + "memberships_did_rkey_idx": { 595 + "name": "memberships_did_rkey_idx", 596 + "columns": [ 597 + { 598 + "expression": "did", 599 + "isExpression": false, 600 + "asc": true, 601 + "nulls": "last" 602 + }, 603 + { 604 + "expression": "rkey", 605 + "isExpression": false, 606 + "asc": true, 607 + "nulls": "last" 608 + } 609 + ], 610 + "isUnique": true, 611 + "concurrently": false, 612 + "method": "btree", 613 + "with": {} 614 + }, 615 + "memberships_did_idx": { 616 + "name": "memberships_did_idx", 617 + "columns": [ 618 + { 619 + "expression": "did", 620 + "isExpression": false, 621 + "asc": true, 622 + "nulls": "last" 623 + } 624 + ], 625 + "isUnique": false, 626 + "concurrently": false, 627 + "method": "btree", 628 + "with": {} 629 + } 630 + }, 631 + "foreignKeys": { 632 + "memberships_did_users_did_fk": { 633 + "name": "memberships_did_users_did_fk", 634 + "tableFrom": "memberships", 635 + "tableTo": "users", 636 + "columnsFrom": [ 637 + "did" 638 + ], 639 + "columnsTo": [ 640 + "did" 641 + ], 642 + "onDelete": "no action", 643 + "onUpdate": "no action" 644 + }, 645 + "memberships_forum_id_forums_id_fk": { 646 + "name": "memberships_forum_id_forums_id_fk", 647 + "tableFrom": "memberships", 648 + "tableTo": "forums", 649 + "columnsFrom": [ 650 + "forum_id" 651 + ], 652 + "columnsTo": [ 653 + "id" 654 + ], 655 + "onDelete": "no action", 656 + "onUpdate": "no action" 657 + } 658 + }, 659 + "compositePrimaryKeys": {}, 660 + "uniqueConstraints": {}, 661 + "policies": {}, 662 + "checkConstraints": {}, 663 + "isRLSEnabled": false 664 + }, 665 + "public.mod_actions": { 666 + "name": "mod_actions", 667 + "schema": "", 668 + "columns": { 669 + "id": { 670 + "name": "id", 671 + "type": "bigserial", 672 + "primaryKey": true, 673 + "notNull": true 674 + }, 675 + "did": { 676 + "name": "did", 677 + "type": "text", 678 + "primaryKey": false, 679 + "notNull": true 680 + }, 681 + "rkey": { 682 + "name": "rkey", 683 + "type": "text", 684 + "primaryKey": false, 685 + "notNull": true 686 + }, 687 + "cid": { 688 + "name": "cid", 689 + "type": "text", 690 + "primaryKey": false, 691 + "notNull": true 692 + }, 693 + "action": { 694 + "name": "action", 695 + "type": "text", 696 + "primaryKey": false, 697 + "notNull": true 698 + }, 699 + "subject_did": { 700 + "name": "subject_did", 701 + "type": "text", 702 + "primaryKey": false, 703 + "notNull": false 704 + }, 705 + "subject_post_uri": { 706 + "name": "subject_post_uri", 707 + "type": "text", 708 + "primaryKey": false, 709 + "notNull": false 710 + }, 711 + "forum_id": { 712 + "name": "forum_id", 713 + "type": "bigint", 714 + "primaryKey": false, 715 + "notNull": false 716 + }, 717 + "reason": { 718 + "name": "reason", 719 + "type": "text", 720 + "primaryKey": false, 721 + "notNull": false 722 + }, 723 + "created_by": { 724 + "name": "created_by", 725 + "type": "text", 726 + "primaryKey": false, 727 + "notNull": true 728 + }, 729 + "expires_at": { 730 + "name": "expires_at", 731 + "type": "timestamp with time zone", 732 + "primaryKey": false, 733 + "notNull": false 734 + }, 735 + "created_at": { 736 + "name": "created_at", 737 + "type": "timestamp with time zone", 738 + "primaryKey": false, 739 + "notNull": true 740 + }, 741 + "indexed_at": { 742 + "name": "indexed_at", 743 + "type": "timestamp with time zone", 744 + "primaryKey": false, 745 + "notNull": true 746 + } 747 + }, 748 + "indexes": { 749 + "mod_actions_did_rkey_idx": { 750 + "name": "mod_actions_did_rkey_idx", 751 + "columns": [ 752 + { 753 + "expression": "did", 754 + "isExpression": false, 755 + "asc": true, 756 + "nulls": "last" 757 + }, 758 + { 759 + "expression": "rkey", 760 + "isExpression": false, 761 + "asc": true, 762 + "nulls": "last" 763 + } 764 + ], 765 + "isUnique": true, 766 + "concurrently": false, 767 + "method": "btree", 768 + "with": {} 769 + }, 770 + "mod_actions_subject_did_idx": { 771 + "name": "mod_actions_subject_did_idx", 772 + "columns": [ 773 + { 774 + "expression": "subject_did", 775 + "isExpression": false, 776 + "asc": true, 777 + "nulls": "last" 778 + } 779 + ], 780 + "isUnique": false, 781 + "concurrently": false, 782 + "method": "btree", 783 + "with": {} 784 + }, 785 + "mod_actions_subject_post_uri_idx": { 786 + "name": "mod_actions_subject_post_uri_idx", 787 + "columns": [ 788 + { 789 + "expression": "subject_post_uri", 790 + "isExpression": false, 791 + "asc": true, 792 + "nulls": "last" 793 + } 794 + ], 795 + "isUnique": false, 796 + "concurrently": false, 797 + "method": "btree", 798 + "with": {} 799 + } 800 + }, 801 + "foreignKeys": { 802 + "mod_actions_forum_id_forums_id_fk": { 803 + "name": "mod_actions_forum_id_forums_id_fk", 804 + "tableFrom": "mod_actions", 805 + "tableTo": "forums", 806 + "columnsFrom": [ 807 + "forum_id" 808 + ], 809 + "columnsTo": [ 810 + "id" 811 + ], 812 + "onDelete": "no action", 813 + "onUpdate": "no action" 814 + } 815 + }, 816 + "compositePrimaryKeys": {}, 817 + "uniqueConstraints": {}, 818 + "policies": {}, 819 + "checkConstraints": {}, 820 + "isRLSEnabled": false 821 + }, 822 + "public.posts": { 823 + "name": "posts", 824 + "schema": "", 825 + "columns": { 826 + "id": { 827 + "name": "id", 828 + "type": "bigserial", 829 + "primaryKey": true, 830 + "notNull": true 831 + }, 832 + "did": { 833 + "name": "did", 834 + "type": "text", 835 + "primaryKey": false, 836 + "notNull": true 837 + }, 838 + "rkey": { 839 + "name": "rkey", 840 + "type": "text", 841 + "primaryKey": false, 842 + "notNull": true 843 + }, 844 + "cid": { 845 + "name": "cid", 846 + "type": "text", 847 + "primaryKey": false, 848 + "notNull": true 849 + }, 850 + "title": { 851 + "name": "title", 852 + "type": "text", 853 + "primaryKey": false, 854 + "notNull": false 855 + }, 856 + "text": { 857 + "name": "text", 858 + "type": "text", 859 + "primaryKey": false, 860 + "notNull": true 861 + }, 862 + "forum_uri": { 863 + "name": "forum_uri", 864 + "type": "text", 865 + "primaryKey": false, 866 + "notNull": false 867 + }, 868 + "board_uri": { 869 + "name": "board_uri", 870 + "type": "text", 871 + "primaryKey": false, 872 + "notNull": false 873 + }, 874 + "board_id": { 875 + "name": "board_id", 876 + "type": "bigint", 877 + "primaryKey": false, 878 + "notNull": false 879 + }, 880 + "root_post_id": { 881 + "name": "root_post_id", 882 + "type": "bigint", 883 + "primaryKey": false, 884 + "notNull": false 885 + }, 886 + "parent_post_id": { 887 + "name": "parent_post_id", 888 + "type": "bigint", 889 + "primaryKey": false, 890 + "notNull": false 891 + }, 892 + "root_uri": { 893 + "name": "root_uri", 894 + "type": "text", 895 + "primaryKey": false, 896 + "notNull": false 897 + }, 898 + "parent_uri": { 899 + "name": "parent_uri", 900 + "type": "text", 901 + "primaryKey": false, 902 + "notNull": false 903 + }, 904 + "created_at": { 905 + "name": "created_at", 906 + "type": "timestamp with time zone", 907 + "primaryKey": false, 908 + "notNull": true 909 + }, 910 + "indexed_at": { 911 + "name": "indexed_at", 912 + "type": "timestamp with time zone", 913 + "primaryKey": false, 914 + "notNull": true 915 + }, 916 + "banned_by_mod": { 917 + "name": "banned_by_mod", 918 + "type": "boolean", 919 + "primaryKey": false, 920 + "notNull": true, 921 + "default": false 922 + }, 923 + "deleted_by_user": { 924 + "name": "deleted_by_user", 925 + "type": "boolean", 926 + "primaryKey": false, 927 + "notNull": true, 928 + "default": false 929 + } 930 + }, 931 + "indexes": { 932 + "posts_did_rkey_idx": { 933 + "name": "posts_did_rkey_idx", 934 + "columns": [ 935 + { 936 + "expression": "did", 937 + "isExpression": false, 938 + "asc": true, 939 + "nulls": "last" 940 + }, 941 + { 942 + "expression": "rkey", 943 + "isExpression": false, 944 + "asc": true, 945 + "nulls": "last" 946 + } 947 + ], 948 + "isUnique": true, 949 + "concurrently": false, 950 + "method": "btree", 951 + "with": {} 952 + }, 953 + "posts_forum_uri_idx": { 954 + "name": "posts_forum_uri_idx", 955 + "columns": [ 956 + { 957 + "expression": "forum_uri", 958 + "isExpression": false, 959 + "asc": true, 960 + "nulls": "last" 961 + } 962 + ], 963 + "isUnique": false, 964 + "concurrently": false, 965 + "method": "btree", 966 + "with": {} 967 + }, 968 + "posts_board_id_idx": { 969 + "name": "posts_board_id_idx", 970 + "columns": [ 971 + { 972 + "expression": "board_id", 973 + "isExpression": false, 974 + "asc": true, 975 + "nulls": "last" 976 + } 977 + ], 978 + "isUnique": false, 979 + "concurrently": false, 980 + "method": "btree", 981 + "with": {} 982 + }, 983 + "posts_board_uri_idx": { 984 + "name": "posts_board_uri_idx", 985 + "columns": [ 986 + { 987 + "expression": "board_uri", 988 + "isExpression": false, 989 + "asc": true, 990 + "nulls": "last" 991 + } 992 + ], 993 + "isUnique": false, 994 + "concurrently": false, 995 + "method": "btree", 996 + "with": {} 997 + }, 998 + "posts_root_post_id_idx": { 999 + "name": "posts_root_post_id_idx", 1000 + "columns": [ 1001 + { 1002 + "expression": "root_post_id", 1003 + "isExpression": false, 1004 + "asc": true, 1005 + "nulls": "last" 1006 + } 1007 + ], 1008 + "isUnique": false, 1009 + "concurrently": false, 1010 + "method": "btree", 1011 + "with": {} 1012 + } 1013 + }, 1014 + "foreignKeys": { 1015 + "posts_did_users_did_fk": { 1016 + "name": "posts_did_users_did_fk", 1017 + "tableFrom": "posts", 1018 + "tableTo": "users", 1019 + "columnsFrom": [ 1020 + "did" 1021 + ], 1022 + "columnsTo": [ 1023 + "did" 1024 + ], 1025 + "onDelete": "no action", 1026 + "onUpdate": "no action" 1027 + }, 1028 + "posts_board_id_boards_id_fk": { 1029 + "name": "posts_board_id_boards_id_fk", 1030 + "tableFrom": "posts", 1031 + "tableTo": "boards", 1032 + "columnsFrom": [ 1033 + "board_id" 1034 + ], 1035 + "columnsTo": [ 1036 + "id" 1037 + ], 1038 + "onDelete": "no action", 1039 + "onUpdate": "no action" 1040 + }, 1041 + "posts_root_post_id_posts_id_fk": { 1042 + "name": "posts_root_post_id_posts_id_fk", 1043 + "tableFrom": "posts", 1044 + "tableTo": "posts", 1045 + "columnsFrom": [ 1046 + "root_post_id" 1047 + ], 1048 + "columnsTo": [ 1049 + "id" 1050 + ], 1051 + "onDelete": "no action", 1052 + "onUpdate": "no action" 1053 + }, 1054 + "posts_parent_post_id_posts_id_fk": { 1055 + "name": "posts_parent_post_id_posts_id_fk", 1056 + "tableFrom": "posts", 1057 + "tableTo": "posts", 1058 + "columnsFrom": [ 1059 + "parent_post_id" 1060 + ], 1061 + "columnsTo": [ 1062 + "id" 1063 + ], 1064 + "onDelete": "no action", 1065 + "onUpdate": "no action" 1066 + } 1067 + }, 1068 + "compositePrimaryKeys": {}, 1069 + "uniqueConstraints": {}, 1070 + "policies": {}, 1071 + "checkConstraints": {}, 1072 + "isRLSEnabled": false 1073 + }, 1074 + "public.role_permissions": { 1075 + "name": "role_permissions", 1076 + "schema": "", 1077 + "columns": { 1078 + "role_id": { 1079 + "name": "role_id", 1080 + "type": "bigint", 1081 + "primaryKey": false, 1082 + "notNull": true 1083 + }, 1084 + "permission": { 1085 + "name": "permission", 1086 + "type": "text", 1087 + "primaryKey": false, 1088 + "notNull": true 1089 + } 1090 + }, 1091 + "indexes": {}, 1092 + "foreignKeys": { 1093 + "role_permissions_role_id_roles_id_fk": { 1094 + "name": "role_permissions_role_id_roles_id_fk", 1095 + "tableFrom": "role_permissions", 1096 + "tableTo": "roles", 1097 + "columnsFrom": [ 1098 + "role_id" 1099 + ], 1100 + "columnsTo": [ 1101 + "id" 1102 + ], 1103 + "onDelete": "cascade", 1104 + "onUpdate": "no action" 1105 + } 1106 + }, 1107 + "compositePrimaryKeys": { 1108 + "role_permissions_role_id_permission_pk": { 1109 + "name": "role_permissions_role_id_permission_pk", 1110 + "columns": [ 1111 + "role_id", 1112 + "permission" 1113 + ] 1114 + } 1115 + }, 1116 + "uniqueConstraints": {}, 1117 + "policies": {}, 1118 + "checkConstraints": {}, 1119 + "isRLSEnabled": false 1120 + }, 1121 + "public.roles": { 1122 + "name": "roles", 1123 + "schema": "", 1124 + "columns": { 1125 + "id": { 1126 + "name": "id", 1127 + "type": "bigserial", 1128 + "primaryKey": true, 1129 + "notNull": true 1130 + }, 1131 + "did": { 1132 + "name": "did", 1133 + "type": "text", 1134 + "primaryKey": false, 1135 + "notNull": true 1136 + }, 1137 + "rkey": { 1138 + "name": "rkey", 1139 + "type": "text", 1140 + "primaryKey": false, 1141 + "notNull": true 1142 + }, 1143 + "cid": { 1144 + "name": "cid", 1145 + "type": "text", 1146 + "primaryKey": false, 1147 + "notNull": true 1148 + }, 1149 + "name": { 1150 + "name": "name", 1151 + "type": "text", 1152 + "primaryKey": false, 1153 + "notNull": true 1154 + }, 1155 + "description": { 1156 + "name": "description", 1157 + "type": "text", 1158 + "primaryKey": false, 1159 + "notNull": false 1160 + }, 1161 + "priority": { 1162 + "name": "priority", 1163 + "type": "integer", 1164 + "primaryKey": false, 1165 + "notNull": true 1166 + }, 1167 + "created_at": { 1168 + "name": "created_at", 1169 + "type": "timestamp with time zone", 1170 + "primaryKey": false, 1171 + "notNull": true 1172 + }, 1173 + "indexed_at": { 1174 + "name": "indexed_at", 1175 + "type": "timestamp with time zone", 1176 + "primaryKey": false, 1177 + "notNull": true 1178 + } 1179 + }, 1180 + "indexes": { 1181 + "roles_did_rkey_idx": { 1182 + "name": "roles_did_rkey_idx", 1183 + "columns": [ 1184 + { 1185 + "expression": "did", 1186 + "isExpression": false, 1187 + "asc": true, 1188 + "nulls": "last" 1189 + }, 1190 + { 1191 + "expression": "rkey", 1192 + "isExpression": false, 1193 + "asc": true, 1194 + "nulls": "last" 1195 + } 1196 + ], 1197 + "isUnique": true, 1198 + "concurrently": false, 1199 + "method": "btree", 1200 + "with": {} 1201 + }, 1202 + "roles_did_idx": { 1203 + "name": "roles_did_idx", 1204 + "columns": [ 1205 + { 1206 + "expression": "did", 1207 + "isExpression": false, 1208 + "asc": true, 1209 + "nulls": "last" 1210 + } 1211 + ], 1212 + "isUnique": false, 1213 + "concurrently": false, 1214 + "method": "btree", 1215 + "with": {} 1216 + }, 1217 + "roles_did_name_idx": { 1218 + "name": "roles_did_name_idx", 1219 + "columns": [ 1220 + { 1221 + "expression": "did", 1222 + "isExpression": false, 1223 + "asc": true, 1224 + "nulls": "last" 1225 + }, 1226 + { 1227 + "expression": "name", 1228 + "isExpression": false, 1229 + "asc": true, 1230 + "nulls": "last" 1231 + } 1232 + ], 1233 + "isUnique": false, 1234 + "concurrently": false, 1235 + "method": "btree", 1236 + "with": {} 1237 + } 1238 + }, 1239 + "foreignKeys": {}, 1240 + "compositePrimaryKeys": {}, 1241 + "uniqueConstraints": {}, 1242 + "policies": {}, 1243 + "checkConstraints": {}, 1244 + "isRLSEnabled": false 1245 + }, 1246 + "public.users": { 1247 + "name": "users", 1248 + "schema": "", 1249 + "columns": { 1250 + "did": { 1251 + "name": "did", 1252 + "type": "text", 1253 + "primaryKey": true, 1254 + "notNull": true 1255 + }, 1256 + "handle": { 1257 + "name": "handle", 1258 + "type": "text", 1259 + "primaryKey": false, 1260 + "notNull": false 1261 + }, 1262 + "indexed_at": { 1263 + "name": "indexed_at", 1264 + "type": "timestamp with time zone", 1265 + "primaryKey": false, 1266 + "notNull": true 1267 + } 1268 + }, 1269 + "indexes": {}, 1270 + "foreignKeys": {}, 1271 + "compositePrimaryKeys": {}, 1272 + "uniqueConstraints": {}, 1273 + "policies": {}, 1274 + "checkConstraints": {}, 1275 + "isRLSEnabled": false 1276 + } 1277 + }, 1278 + "enums": {}, 1279 + "schemas": {}, 1280 + "sequences": {}, 1281 + "roles": {}, 1282 + "policies": {}, 1283 + "views": {}, 1284 + "_meta": { 1285 + "columns": {}, 1286 + "schemas": {}, 1287 + "tables": {} 1288 + } 1289 + }
+14
apps/appview/drizzle/meta/_journal.json
··· 78 78 "when": 1771951670000, 79 79 "tag": "0010_add_deleted_by_user", 80 80 "breakpoints": true 81 + }, 82 + { 83 + "idx": 11, 84 + "version": "7", 85 + "when": 1772034989879, 86 + "tag": "0011_first_apocalypse", 87 + "breakpoints": true 88 + }, 89 + { 90 + "idx": 12, 91 + "version": "7", 92 + "when": 1772035630111, 93 + "tag": "0012_acoustic_swordsman", 94 + "breakpoints": true 81 95 } 82 96 ] 83 97 }
+5 -2
apps/appview/package.json
··· 11 11 "lint:fix": "oxlint --fix src/", 12 12 "test": "vitest run", 13 13 "clean": "rm -rf dist", 14 - "db:generate": "drizzle-kit generate", 15 - "db:migrate": "drizzle-kit migrate" 14 + "db:generate": "drizzle-kit generate --config=drizzle.postgres.config.ts", 15 + "db:migrate": "drizzle-kit migrate --config=drizzle.postgres.config.ts", 16 + "db:generate:sqlite": "drizzle-kit generate --config=drizzle.sqlite.config.ts", 17 + "db:migrate:sqlite": "drizzle-kit migrate --config=drizzle.sqlite.config.ts", 18 + "migrate-permissions": "tsx --env-file=../../.env scripts/migrate-permissions.ts" 16 19 }, 17 20 "dependencies": { 18 21 "@atbb/atproto": "workspace:*",
+67
apps/appview/scripts/migrate-permissions.ts
··· 1 + /** 2 + * One-time data migration: copies permissions from roles.permissions[] 3 + * into the role_permissions join table. 4 + * 5 + * Safe to re-run (ON CONFLICT DO NOTHING). 6 + * Must be run AFTER migration 0011 (role_permissions table exists) 7 + * and BEFORE migration 0012 (permissions column is dropped). 8 + */ 9 + import postgres from "postgres"; 10 + import { drizzle } from "drizzle-orm/postgres-js"; 11 + import * as schema from "@atbb/db/schema"; 12 + import { sql } from "drizzle-orm"; 13 + 14 + const databaseUrl = process.env.DATABASE_URL; 15 + if (!databaseUrl) { 16 + console.error("DATABASE_URL is required"); 17 + process.exit(1); 18 + } 19 + 20 + const client = postgres(databaseUrl); 21 + const db = drizzle(client, { schema }); 22 + 23 + async function run() { 24 + // Read roles that still have permissions in the array column. 25 + // We use raw SQL here because the Drizzle schema will have the 26 + // permissions column removed by the time this script ships. 27 + const roles = await db.execute( 28 + sql`SELECT id, permissions FROM roles WHERE array_length(permissions, 1) > 0` 29 + ); 30 + 31 + if (roles.length === 0) { 32 + console.log("No roles with permissions to migrate."); 33 + await client.end(); 34 + return; 35 + } 36 + 37 + let totalPermissions = 0; 38 + 39 + for (const role of roles) { 40 + const roleId = role.id as bigint; 41 + const permissions = role.permissions as string[]; 42 + 43 + if (!permissions || permissions.length === 0) continue; 44 + 45 + // Insert each permission as a row, skip duplicates (idempotent) 46 + await db.execute( 47 + sql`INSERT INTO role_permissions (role_id, permission) 48 + SELECT ${roleId}, unnest(${sql.raw(`ARRAY[${permissions.map((p: string) => `'${p.replace(/'/g, "''")}'`).join(",")}]`)}::text[]) 49 + ON CONFLICT DO NOTHING` 50 + ); 51 + 52 + totalPermissions += permissions.length; 53 + console.log(` Role ${roleId}: migrated ${permissions.length} permissions`); 54 + } 55 + 56 + console.log( 57 + `\nMigrated ${totalPermissions} permissions across ${roles.length} roles.` 58 + ); 59 + console.log("Safe to proceed with migration 0012 (drop permissions column)."); 60 + 61 + await client.end(); 62 + } 63 + 64 + run().catch((err) => { 65 + console.error("Migration failed:", err); 66 + process.exit(1); 67 + });
+23 -7
apps/appview/src/lib/__tests__/indexer-roles.test.ts
··· 1 1 import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 2 import { Indexer } from "../indexer.js"; 3 3 import { createTestContext, type TestContext } from "./test-context.js"; 4 - import { roles } from "@atbb/db"; 4 + import { roles, rolePermissions } from "@atbb/db"; 5 5 import { eq } from "drizzle-orm"; 6 6 import type { 7 7 CommitCreateEvent, ··· 56 56 expect(role).toBeDefined(); 57 57 expect(role.name).toBe("Moderator"); 58 58 expect(role.description).toBe("Can moderate posts"); 59 - expect(role.permissions).toEqual(["space.atbb.permission.moderatePosts"]); 60 59 expect(role.priority).toBe(10); 60 + 61 + const perms = await ctx.db 62 + .select({ permission: rolePermissions.permission }) 63 + .from(rolePermissions) 64 + .where(eq(rolePermissions.roleId, role.id)); 65 + expect(perms.map((p) => p.permission)).toEqual([ 66 + "space.atbb.permission.moderatePosts", 67 + ]); 61 68 }); 62 69 63 70 it("handleRoleCreate indexes role without optional description", async () => { ··· 94 101 }); 95 102 96 103 it("handleRoleUpdate updates role fields", async () => { 97 - // First create a role 104 + // First create a role (no permissions column — permissions live in role_permissions) 98 105 await ctx.db.insert(roles).values({ 99 106 did: "did:plc:test-forum", 100 107 rkey: "role3", 101 108 cid: "bafyold", 102 109 name: "Old Name", 103 110 description: "Old description", 104 - permissions: ["space.atbb.permission.createPosts"], 105 111 priority: 30, 106 112 createdAt: new Date(), 107 113 indexedAt: new Date(), ··· 141 147 142 148 expect(role.name).toBe("Updated Name"); 143 149 expect(role.description).toBe("Updated description"); 144 - expect(role.permissions).toHaveLength(2); 145 150 expect(role.priority).toBe(20); 146 151 expect(role.cid).toBe("bafynew"); 152 + 153 + const perms = await ctx.db 154 + .select({ permission: rolePermissions.permission }) 155 + .from(rolePermissions) 156 + .where(eq(rolePermissions.roleId, role.id)); 157 + expect(perms).toHaveLength(2); 158 + expect(perms.map((p) => p.permission)).toEqual( 159 + expect.arrayContaining([ 160 + "space.atbb.permission.createPosts", 161 + "space.atbb.permission.moderatePosts", 162 + ]) 163 + ); 147 164 }); 148 165 149 166 it("handleRoleDelete removes role record", async () => { 150 - // First create a role 167 + // First create a role (no permissions column — permissions live in role_permissions) 151 168 await ctx.db.insert(roles).values({ 152 169 did: "did:plc:test-forum", 153 170 rkey: "role4", 154 171 cid: "bafyrole4", 155 172 name: "To Delete", 156 - permissions: [], 157 173 priority: 99, 158 174 createdAt: new Date(), 159 175 indexedAt: new Date(),
+7 -4
apps/appview/src/lib/__tests__/membership.test.ts
··· 1 1 import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 2 import { createMembershipForUser } from "../membership.js"; 3 3 import { createTestContext, type TestContext } from "./test-context.js"; 4 - import { memberships, users, roles } from "@atbb/db"; 4 + import { memberships, users, roles, rolePermissions } from "@atbb/db"; 5 5 import { eq, and } from "drizzle-orm"; 6 6 7 7 describe("createMembershipForUser", () => { ··· 243 243 const memberRoleRkey = "memberrole123"; 244 244 const memberRoleCid = "bafymemberrole456"; 245 245 246 - await ctx.db.insert(roles).values({ 246 + const [memberRole] = await ctx.db.insert(roles).values({ 247 247 did: ctx.config.forumDid, 248 248 rkey: memberRoleRkey, 249 249 cid: memberRoleCid, 250 250 name: "Member", 251 251 description: "Regular forum member", 252 - permissions: ["space.atbb.permission.createTopics", "space.atbb.permission.createPosts"], 253 252 priority: 30, 254 253 createdAt: new Date(), 255 254 indexedAt: new Date(), 256 - }); 255 + }).returning({ id: roles.id }); 256 + await ctx.db.insert(rolePermissions).values([ 257 + { roleId: memberRole.id, permission: "space.atbb.permission.createTopics" }, 258 + { roleId: memberRole.id, permission: "space.atbb.permission.createPosts" }, 259 + ]); 257 260 258 261 const mockAgent = { 259 262 com: {
+52 -14
apps/appview/src/lib/__tests__/test-context.ts
··· 1 1 import { eq, or, like } from "drizzle-orm"; 2 - import { drizzle } from "drizzle-orm/postgres-js"; 3 - import postgres from "postgres"; 2 + import { createDb, runSqliteMigrations } from "@atbb/db"; 4 3 import { forums, posts, users, categories, memberships, boards, roles, modActions, backfillProgress, backfillErrors } from "@atbb/db"; 5 - import * as schema from "@atbb/db"; 6 4 import { createLogger } from "@atbb/logger"; 5 + import path from "path"; 6 + import { fileURLToPath } from "url"; 7 7 import type { AppConfig } from "../config.js"; 8 8 import type { AppContext } from "../app-context.js"; 9 + 10 + const __dirname = fileURLToPath(new URL(".", import.meta.url)); 9 11 10 12 export interface TestContext extends AppContext { 11 13 cleanup: () => Promise<void>; ··· 19 21 /** 20 22 * Create test context with database and sample data. 21 23 * Call cleanup() after tests to remove test data. 24 + * Supports both Postgres (DATABASE_URL=postgres://...) and SQLite (DATABASE_URL=file::memory:). 25 + * 26 + * SQLite note: Uses file::memory:?cache=shared so that @libsql/client's transaction() 27 + * handoff (which sets #db = null and lazily recreates the connection) reconnects to the 28 + * same shared in-memory database rather than creating a new empty one. Without 29 + * cache=shared, migrations are lost after the first transaction. 22 30 */ 23 31 export async function createTestContext( 24 32 options: TestContextOptions = {} 25 33 ): Promise<TestContext> { 34 + const rawDatabaseUrl = process.env.DATABASE_URL ?? ""; 35 + const isPostgres = rawDatabaseUrl.startsWith("postgres"); 36 + 37 + // For SQLite in-memory databases: upgrade to cache=shared so that @libsql/client's 38 + // transaction() pattern (which sets #db=null and lazily recreates the connection) 39 + // reconnects to the same database rather than creating a new empty in-memory DB. 40 + const databaseUrl = 41 + rawDatabaseUrl === "file::memory:" || rawDatabaseUrl === ":memory:" 42 + ? "file::memory:?cache=shared" 43 + : rawDatabaseUrl; 44 + 26 45 const config: AppConfig = { 27 46 port: 3000, 28 47 forumDid: "did:plc:test-forum", 29 48 pdsUrl: "https://test.pds", 30 - databaseUrl: process.env.DATABASE_URL ?? "", 49 + databaseUrl, 31 50 jetstreamUrl: "wss://test.jetstream", 32 51 logLevel: "warn", 33 52 oauthPublicUrl: "http://localhost:3000", ··· 38 57 backfillCursorMaxAgeHours: 48, 39 58 }; 40 59 41 - // Create postgres client so we can close it later 42 - const sql = postgres(config.databaseUrl); 43 - const db = drizzle(sql, { schema }); 60 + const db = createDb(config.databaseUrl); 61 + const isSqlite = !isPostgres; 62 + 63 + // For SQLite: run migrations programmatically before any tests. 64 + // Uses runSqliteMigrations from @atbb/db to ensure the same drizzle-orm instance 65 + // is used for both database creation and migration (avoids cross-package module issues). 66 + if (isSqlite) { 67 + const migrationsFolder = path.resolve(__dirname, "../../../drizzle-sqlite"); 68 + await runSqliteMigrations(db, migrationsFolder); 69 + } 44 70 45 71 // Create stub OAuth dependencies (unused in read-path tests) 46 72 const stubFirehose = { ··· 55 81 const stubForumAgent = null; // Mock ForumAgent is null by default (can be overridden in tests) 56 82 57 83 const cleanDatabase = async () => { 58 - // Aggressive cleanup - delete ALL test data for forum DID 59 - // This ensures a clean slate even if previous runs failed 84 + if (isSqlite) { 85 + // SQLite in-memory: delete all rows in FK order (role_permissions cascade from roles) 86 + await db.delete(posts).catch(() => {}); 87 + await db.delete(memberships).catch(() => {}); 88 + await db.delete(users).catch(() => {}); 89 + await db.delete(boards).catch(() => {}); 90 + await db.delete(categories).catch(() => {}); 91 + await db.delete(roles).catch(() => {}); // cascades to role_permissions 92 + await db.delete(modActions).catch(() => {}); 93 + await db.delete(backfillErrors).catch(() => {}); 94 + await db.delete(backfillProgress).catch(() => {}); 95 + await db.delete(forums).catch(() => {}); 96 + return; 97 + } 98 + 99 + // Postgres: delete by test DID patterns 60 100 await db.delete(posts).where(eq(posts.did, config.forumDid)).catch(() => {}); 61 101 await db.delete(posts).where(like(posts.did, "did:plc:test-%")).catch(() => {}); 62 102 await db.delete(memberships).where(like(memberships.did, "did:plc:test-%")).catch(() => {}); 63 103 await db.delete(users).where(like(users.did, "did:plc:test-%")).catch(() => {}); 64 104 await db.delete(boards).where(eq(boards.did, config.forumDid)).catch(() => {}); 65 105 await db.delete(categories).where(eq(categories.did, config.forumDid)).catch(() => {}); 66 - await db.delete(roles).where(eq(roles.did, config.forumDid)).catch(() => {}); 106 + await db.delete(roles).where(eq(roles.did, config.forumDid)).catch(() => {}); // cascades to role_permissions 67 107 await db.delete(modActions).where(eq(modActions.did, config.forumDid)).catch(() => {}); 68 108 await db.delete(backfillErrors).catch(() => {}); 69 109 await db.delete(backfillProgress).catch(() => {}); ··· 136 176 ); 137 177 await db.delete(users).where(testUserPattern); 138 178 139 - // Delete boards, categories, roles, mod_actions, and forums in order (FK constraints) 140 179 await db.delete(boards).where(eq(boards.did, config.forumDid)); 141 180 await db.delete(categories).where(eq(categories.did, config.forumDid)); 142 - await db.delete(roles).where(eq(roles.did, config.forumDid)); 181 + await db.delete(roles).where(eq(roles.did, config.forumDid)); // cascades to role_permissions 143 182 await db.delete(modActions).where(eq(modActions.did, config.forumDid)); 144 183 await db.delete(backfillErrors).catch(() => {}); 145 184 await db.delete(backfillProgress).catch(() => {}); 146 185 await db.delete(forums).where(eq(forums.did, config.forumDid)); 147 - // Close postgres connection to prevent leaks 148 - await sql.end(); 186 + // No sql.end() needed — createDb owns the client lifecycle 149 187 }, 150 188 } as TestContext; 151 189 }
+59 -11
apps/appview/src/lib/indexer.ts
··· 14 14 memberships, 15 15 modActions, 16 16 roles, 17 + rolePermissions, 17 18 } from "@atbb/db"; 18 19 import { eq, and } from "drizzle-orm"; 19 20 import { parseAtUri } from "./at-uri.js"; ··· 62 63 record: TRecord, 63 64 tx: DbOrTransaction 64 65 ) => Promise<Record<string, any> | null>; 66 + /** 67 + * Optional hook called after a row is inserted or updated, within the same 68 + * transaction. Receives the row's numeric id (bigint) so callers can write 69 + * to child tables (e.g. role_permissions). 70 + */ 71 + afterUpsert?: ( 72 + event: any, 73 + record: TRecord, 74 + rowId: bigint, 75 + tx: DbOrTransaction 76 + ) => Promise<void>; 65 77 } 66 78 67 79 ··· 314 326 cid: event.commit.cid, 315 327 name: record.name, 316 328 description: record.description ?? null, 317 - permissions: record.permissions, 318 329 priority: record.priority, 319 330 createdAt: new Date(record.createdAt), 320 331 indexedAt: new Date(), ··· 323 334 cid: event.commit.cid, 324 335 name: record.name, 325 336 description: record.description ?? null, 326 - permissions: record.permissions, 327 337 priority: record.priority, 328 338 indexedAt: new Date(), 329 339 }), 340 + afterUpsert: async (event, record, roleId, tx) => { 341 + // Replace all permissions for this role atomically 342 + await tx 343 + .delete(rolePermissions) 344 + .where(eq(rolePermissions.roleId, roleId)); 345 + 346 + if (record.permissions && record.permissions.length > 0) { 347 + await tx.insert(rolePermissions).values( 348 + record.permissions.map((permission: string) => ({ 349 + roleId, 350 + permission, 351 + })) 352 + ); 353 + } 354 + }, 330 355 }; 331 356 332 357 private membershipConfig: CollectionConfig<Membership.Record> = { ··· 491 516 return; // Skip insert (e.g. foreign key not found) 492 517 } 493 518 494 - await tx.insert(config.table).values(values); 519 + if (config.afterUpsert) { 520 + const [inserted] = await tx 521 + .insert(config.table) 522 + .values(values) 523 + .returning({ id: config.table.id }); 524 + await config.afterUpsert(event, record, inserted.id, tx); 525 + } else { 526 + await tx.insert(config.table).values(values); 527 + } 495 528 }); 496 529 497 530 // Only log success if insert actually happened ··· 532 565 return; // Skip update (e.g. foreign key not found) 533 566 } 534 567 535 - await tx 536 - .update(config.table) 537 - .set(values) 538 - .where( 539 - and( 540 - eq(config.table.did, event.did), 541 - eq(config.table.rkey, event.commit.rkey) 568 + if (config.afterUpsert) { 569 + const [updated] = await tx 570 + .update(config.table) 571 + .set(values) 572 + .where( 573 + and( 574 + eq(config.table.did, event.did), 575 + eq(config.table.rkey, event.commit.rkey) 576 + ) 542 577 ) 543 - ); 578 + .returning({ id: config.table.id }); 579 + if (!updated) return; // Out-of-order UPDATE before CREATE: no row to update yet 580 + await config.afterUpsert(event, record, updated.id, tx); 581 + } else { 582 + await tx 583 + .update(config.table) 584 + .set(values) 585 + .where( 586 + and( 587 + eq(config.table.did, event.did), 588 + eq(config.table.rkey, event.commit.rkey) 589 + ) 590 + ); 591 + } 544 592 }); 545 593 546 594 // Only log success if update actually happened
+13 -15
apps/appview/src/middleware/__tests__/permissions.test.ts
··· 1 1 import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 2 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 3 - import { roles, memberships, users } from "@atbb/db"; 3 + import { roles, rolePermissions, memberships, users } from "@atbb/db"; 4 4 import { 5 5 checkPermission, 6 6 checkMinRole, ··· 21 21 describe("checkPermission", () => { 22 22 it("returns true when user has required permission", async () => { 23 23 // Create a test role with createTopics permission 24 - await ctx.db.insert(roles).values({ 24 + const [memberRole] = await ctx.db.insert(roles).values({ 25 25 did: ctx.config.forumDid, 26 26 rkey: "test-role-123", 27 27 cid: "test-cid", 28 28 name: "Member", 29 29 description: "Test member role", 30 - permissions: ["space.atbb.permission.createTopics"], 31 30 priority: 30, 32 31 createdAt: new Date(), 33 32 indexedAt: new Date(), 34 - }); 33 + }).returning({ id: roles.id }); 34 + 35 + await ctx.db.insert(rolePermissions).values([ 36 + { roleId: memberRole.id, permission: "space.atbb.permission.createTopics" }, 37 + ]); 35 38 36 39 // Create a test user 37 40 await ctx.db.insert(users).values({ ··· 62 65 63 66 it("returns true for Owner role with wildcard permission", async () => { 64 67 // Create Owner role with wildcard 65 - await ctx.db.insert(roles).values({ 68 + const [ownerRole] = await ctx.db.insert(roles).values({ 66 69 did: ctx.config.forumDid, 67 70 rkey: "owner-role", 68 71 cid: "test-cid", 69 72 name: "Owner", 70 73 description: "Forum owner", 71 - permissions: ["*"], // Wildcard 72 74 priority: 0, 73 75 createdAt: new Date(), 74 76 indexedAt: new Date(), 75 - }); 77 + }).returning({ id: roles.id }); 78 + 79 + await ctx.db.insert(rolePermissions).values([ 80 + { roleId: ownerRole.id, permission: "*" }, 81 + ]); 76 82 77 83 await ctx.db.insert(users).values({ 78 84 did: "did:plc:test-owner", ··· 180 186 rkey: "admin-role", 181 187 cid: "test-cid", 182 188 name: "Admin", 183 - permissions: [], 184 189 priority: 10, 185 190 createdAt: new Date(), 186 191 indexedAt: new Date(), ··· 214 219 rkey: "owner-role-2", 215 220 cid: "test-cid", 216 221 name: "Owner", 217 - permissions: ["*"], 218 222 priority: 0, 219 223 createdAt: new Date(), 220 224 indexedAt: new Date(), ··· 248 252 rkey: "mod-role", 249 253 cid: "test-cid", 250 254 name: "Moderator", 251 - permissions: [], 252 255 priority: 20, 253 256 createdAt: new Date(), 254 257 indexedAt: new Date(), ··· 294 297 rkey: "admin-role-2", 295 298 cid: "test-cid", 296 299 name: "Admin", 297 - permissions: [], 298 300 priority: 10, 299 301 createdAt: new Date(), 300 302 indexedAt: new Date(), ··· 306 308 rkey: "mod-role-2", 307 309 cid: "test-cid", 308 310 name: "Moderator", 309 - permissions: [], 310 311 priority: 20, 311 312 createdAt: new Date(), 312 313 indexedAt: new Date(), ··· 358 359 rkey: "admin-role-3", 359 360 cid: "test-cid", 360 361 name: "Admin", 361 - permissions: [], 362 362 priority: 10, 363 363 createdAt: new Date(), 364 364 indexedAt: new Date(), ··· 410 410 rkey: "admin-role-4", 411 411 cid: "test-cid", 412 412 name: "Admin", 413 - permissions: [], 414 413 priority: 10, 415 414 createdAt: new Date(), 416 415 indexedAt: new Date(), ··· 422 421 rkey: "mod-role-4", 423 422 cid: "test-cid", 424 423 name: "Moderator", 425 - permissions: [], 426 424 priority: 20, 427 425 createdAt: new Date(), 428 426 indexedAt: new Date(),
+18 -10
apps/appview/src/middleware/permissions.ts
··· 1 1 import type { AppContext } from "../lib/app-context.js"; 2 2 import type { Context, Next } from "hono"; 3 3 import type { Variables } from "../types.js"; 4 - import { memberships, roles } from "@atbb/db"; 5 - import { eq, and } from "drizzle-orm"; 4 + import { memberships, roles, rolePermissions } from "@atbb/db"; 5 + import { eq, and, or } from "drizzle-orm"; 6 6 7 7 /** 8 8 * Check if a user has a specific permission. ··· 53 53 return false; // Role not found = treat as Guest (fail closed) 54 54 } 55 55 56 - // 4. Check for wildcard (Owner role) 57 - if (role.permissions.includes("*")) { 58 - return true; 59 - } 56 + // 4. Check if user has the permission (wildcard or specific) 57 + const [match] = await ctx.db 58 + .select() 59 + .from(rolePermissions) 60 + .where( 61 + and( 62 + eq(rolePermissions.roleId, role.id), 63 + or( 64 + eq(rolePermissions.permission, permission), 65 + eq(rolePermissions.permission, "*") 66 + ) 67 + ) 68 + ) 69 + .limit(1); 60 70 61 - // 5. Check if specific permission is in role's permissions array 62 - return role.permissions.includes(permission); 71 + return !!match; 63 72 } catch (error) { 64 73 // Re-throw programming errors (typos, undefined variables, etc.) 65 74 // These should crash during development, not silently deny access ··· 88 97 async function getUserRole( 89 98 ctx: AppContext, 90 99 did: string 91 - ): Promise<{ id: bigint; name: string; priority: number; permissions: string[] } | null> { 100 + ): Promise<{ id: bigint; name: string; priority: number } | null> { 92 101 try { 93 102 const [membership] = await ctx.db 94 103 .select() ··· 110 119 id: roles.id, 111 120 name: roles.name, 112 121 priority: roles.priority, 113 - permissions: roles.permissions, 114 122 }) 115 123 .from(roles) 116 124 .where(
+8 -6
apps/appview/src/routes/__tests__/admin-backfill.test.ts
··· 2 2 import { Hono } from "hono"; 3 3 import { createAdminRoutes } from "../admin.js"; 4 4 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 5 - import { roles, memberships, users, backfillProgress, backfillErrors } from "@atbb/db"; 5 + import { roles, rolePermissions, memberships, users, backfillProgress, backfillErrors } from "@atbb/db"; 6 6 import { BackfillStatus } from "../../lib/backfill-manager.js"; 7 7 8 8 // Mock restoreOAuthSession so tests control auth without real OAuth ··· 76 76 77 77 // Helper: insert admin user with manageForum permission in DB 78 78 async function setupAdminUser() { 79 - await ctx.db.insert(roles).values({ 79 + const [ownerRole] = await ctx.db.insert(roles).values({ 80 80 did: ctx.config.forumDid, 81 81 rkey: ROLE_RKEY, 82 82 cid: "test-cid", 83 83 name: "Owner", 84 84 description: "Forum owner", 85 - permissions: ["*"], 86 85 priority: 0, 87 86 createdAt: new Date(), 88 87 indexedAt: new Date(), 89 - }); 88 + }).returning({ id: roles.id }); 89 + await ctx.db.insert(rolePermissions).values([{ roleId: ownerRole.id, permission: "*" }]); 90 90 91 91 await ctx.db.insert(users).values({ 92 92 did: ADMIN_DID, ··· 272 272 await setupAdminUser(); 273 273 const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 274 274 275 - // requirePermission makes 2 DB selects (membership + role); let them pass, 276 - // then fail on the handler's backfill_progress query (call 3). 275 + // requirePermission makes 3 DB selects (membership + role + role_permissions); let them pass, 276 + // then fail on the handler's backfill_progress query (call 4). 277 277 const origSelect = ctx.db.select.bind(ctx.db); 278 278 vi.spyOn(ctx.db, "select") 279 279 .mockImplementationOnce(() => origSelect() as any) // permissions: membership 280 280 .mockImplementationOnce(() => origSelect() as any) // permissions: role 281 + .mockImplementationOnce(() => origSelect() as any) // permissions: role_permissions 281 282 .mockReturnValueOnce({ // handler: backfill_progress 282 283 from: vi.fn().mockReturnValue({ 283 284 where: vi.fn().mockReturnValue({ ··· 393 394 vi.spyOn(ctx.db, "select") 394 395 .mockImplementationOnce(() => origSelect() as any) // permissions: membership 395 396 .mockImplementationOnce(() => origSelect() as any) // permissions: role 397 + .mockImplementationOnce(() => origSelect() as any) // permissions: role_permissions 396 398 .mockReturnValueOnce({ // handler: backfill_errors query 397 399 from: vi.fn().mockReturnValue({ 398 400 where: vi.fn().mockReturnValue({
+85 -82
apps/appview/src/routes/__tests__/admin.test.ts
··· 2 2 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 3 3 import { Hono } from "hono"; 4 4 import type { Variables } from "../../types.js"; 5 - import { memberships, roles, users, forums } from "@atbb/db"; 5 + import { memberships, roles, rolePermissions, users, forums } from "@atbb/db"; 6 6 7 7 // Mock middleware at module level 8 8 let mockUser: any; ··· 69 69 describe("POST /api/admin/members/:did/role", () => { 70 70 beforeEach(async () => { 71 71 // Create test roles: Owner (priority 0), Admin (priority 10), Moderator (priority 20) 72 - await ctx.db.insert(roles).values([ 73 - { 74 - did: ctx.config.forumDid, 75 - rkey: "owner", 76 - cid: "bafyowner", 77 - name: "Owner", 78 - description: "Forum owner", 79 - permissions: ["*"], 80 - priority: 0, 81 - createdAt: new Date(), 82 - indexedAt: new Date(), 83 - }, 84 - { 85 - did: ctx.config.forumDid, 86 - rkey: "admin", 87 - cid: "bafyadmin", 88 - name: "Admin", 89 - description: "Administrator", 90 - permissions: ["space.atbb.permission.manageRoles"], 91 - priority: 10, 92 - createdAt: new Date(), 93 - indexedAt: new Date(), 94 - }, 95 - { 96 - did: ctx.config.forumDid, 97 - rkey: "moderator", 98 - cid: "bafymoderator", 99 - name: "Moderator", 100 - description: "Moderator", 101 - permissions: ["space.atbb.permission.createPosts"], 102 - priority: 20, 103 - createdAt: new Date(), 104 - indexedAt: new Date(), 105 - }, 106 - ]); 72 + const [ownerRole] = await ctx.db.insert(roles).values({ 73 + did: ctx.config.forumDid, 74 + rkey: "owner", 75 + cid: "bafyowner", 76 + name: "Owner", 77 + description: "Forum owner", 78 + priority: 0, 79 + createdAt: new Date(), 80 + indexedAt: new Date(), 81 + }).returning({ id: roles.id }); 82 + await ctx.db.insert(rolePermissions).values([{ roleId: ownerRole.id, permission: "*" }]); 83 + 84 + const [adminRole] = await ctx.db.insert(roles).values({ 85 + did: ctx.config.forumDid, 86 + rkey: "admin", 87 + cid: "bafyadmin", 88 + name: "Admin", 89 + description: "Administrator", 90 + priority: 10, 91 + createdAt: new Date(), 92 + indexedAt: new Date(), 93 + }).returning({ id: roles.id }); 94 + await ctx.db.insert(rolePermissions).values([{ roleId: adminRole.id, permission: "space.atbb.permission.manageRoles" }]); 95 + 96 + const [moderatorRole] = await ctx.db.insert(roles).values({ 97 + did: ctx.config.forumDid, 98 + rkey: "moderator", 99 + cid: "bafymoderator", 100 + name: "Moderator", 101 + description: "Moderator", 102 + priority: 20, 103 + createdAt: new Date(), 104 + indexedAt: new Date(), 105 + }).returning({ id: roles.id }); 106 + await ctx.db.insert(rolePermissions).values([{ roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }]); 107 107 108 108 // Create target user and membership (use onConflictDoNothing to handle test re-runs) 109 109 await ctx.db.insert(users).values({ ··· 387 387 describe("GET /api/admin/roles", () => { 388 388 it("lists all roles sorted by priority", async () => { 389 389 // Create test roles 390 - await ctx.db.insert(roles).values([ 391 - { 392 - did: ctx.config.forumDid, 393 - rkey: "owner", 394 - cid: "bafyowner", 395 - name: "Owner", 396 - description: "Forum owner", 397 - permissions: ["*"], 398 - priority: 0, 399 - createdAt: new Date(), 400 - indexedAt: new Date(), 401 - }, 402 - { 403 - did: ctx.config.forumDid, 404 - rkey: "moderator", 405 - cid: "bafymoderator", 406 - name: "Moderator", 407 - description: "Moderator", 408 - permissions: ["space.atbb.permission.createPosts"], 409 - priority: 20, 410 - createdAt: new Date(), 411 - indexedAt: new Date(), 412 - }, 413 - { 414 - did: ctx.config.forumDid, 415 - rkey: "admin", 416 - cid: "bafyadmin", 417 - name: "Admin", 418 - description: "Administrator", 419 - permissions: ["space.atbb.permission.manageRoles"], 420 - priority: 10, 421 - createdAt: new Date(), 422 - indexedAt: new Date(), 423 - }, 424 - ]); 390 + const [ownerRole] = await ctx.db.insert(roles).values({ 391 + did: ctx.config.forumDid, 392 + rkey: "owner", 393 + cid: "bafyowner", 394 + name: "Owner", 395 + description: "Forum owner", 396 + priority: 0, 397 + createdAt: new Date(), 398 + indexedAt: new Date(), 399 + }).returning({ id: roles.id }); 400 + await ctx.db.insert(rolePermissions).values([{ roleId: ownerRole.id, permission: "*" }]); 401 + 402 + const [moderatorRole] = await ctx.db.insert(roles).values({ 403 + did: ctx.config.forumDid, 404 + rkey: "moderator", 405 + cid: "bafymoderator", 406 + name: "Moderator", 407 + description: "Moderator", 408 + priority: 20, 409 + createdAt: new Date(), 410 + indexedAt: new Date(), 411 + }).returning({ id: roles.id }); 412 + await ctx.db.insert(rolePermissions).values([{ roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }]); 413 + 414 + const [adminRole] = await ctx.db.insert(roles).values({ 415 + did: ctx.config.forumDid, 416 + rkey: "admin", 417 + cid: "bafyadmin", 418 + name: "Admin", 419 + description: "Administrator", 420 + priority: 10, 421 + createdAt: new Date(), 422 + indexedAt: new Date(), 423 + }).returning({ id: roles.id }); 424 + await ctx.db.insert(rolePermissions).values([{ roleId: adminRole.id, permission: "space.atbb.permission.manageRoles" }]); 425 425 426 426 const res = await app.request("/api/admin/roles"); 427 427 ··· 469 469 }); 470 470 471 471 // Create test role 472 - await ctx.db.insert(roles).values({ 472 + const [moderatorRole] = await ctx.db.insert(roles).values({ 473 473 did: ctx.config.forumDid, 474 474 rkey: "moderator", 475 475 cid: "bafymoderator", 476 476 name: "Moderator", 477 477 description: "Moderator", 478 - permissions: ["space.atbb.permission.createPosts"], 479 478 priority: 20, 480 479 createdAt: new Date(), 481 480 indexedAt: new Date(), 482 - }); 481 + }).returning({ id: roles.id }); 482 + await ctx.db.insert(rolePermissions).values([{ roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }]); 483 483 }); 484 484 485 485 it("lists members with assigned roles", async () => { ··· 615 615 616 616 it("returns 200 with membership, role, and permissions for a user with a linked role", async () => { 617 617 // Insert role 618 - await ctx.db.insert(roles).values({ 618 + const [moderatorRole] = await ctx.db.insert(roles).values({ 619 619 did: ctx.config.forumDid, 620 620 rkey: "moderator", 621 621 cid: "bafymoderator", 622 622 name: "Moderator", 623 623 description: "Moderator role", 624 - permissions: ["space.atbb.permission.createPosts", "space.atbb.permission.editPosts"], 625 624 priority: 20, 626 625 createdAt: new Date(), 627 626 indexedAt: new Date(), 628 - }); 627 + }).returning({ id: roles.id }); 628 + await ctx.db.insert(rolePermissions).values([ 629 + { roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }, 630 + { roleId: moderatorRole.id, permission: "space.atbb.permission.lockTopics" }, 631 + ]); 629 632 630 633 // Insert user 631 634 await ctx.db.insert(users).values({ ··· 655 658 handle: "me.test", 656 659 role: "Moderator", 657 660 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 658 - permissions: ["space.atbb.permission.createPosts", "space.atbb.permission.editPosts"], 661 + permissions: ["space.atbb.permission.createPosts", "space.atbb.permission.lockTopics"], 659 662 }); 660 663 }); 661 664 ··· 667 670 cid: "bafyguestrole", 668 671 name: "Guest Role", 669 672 description: "Role with no permissions", 670 - permissions: [], 671 673 priority: 100, 672 674 createdAt: new Date(), 673 675 indexedAt: new Date(), 674 676 }); 677 + // No rolePermissions inserted — role has no permissions 675 678 676 679 // Insert user 677 680 await ctx.db.insert(users).values({ ··· 702 705 703 706 it("only returns the current user's membership, not other users'", async () => { 704 707 // Insert role 705 - await ctx.db.insert(roles).values({ 708 + const [adminRole] = await ctx.db.insert(roles).values({ 706 709 did: ctx.config.forumDid, 707 710 rkey: "admin", 708 711 cid: "bafyadmin", 709 712 name: "Admin", 710 713 description: "Admin role", 711 - permissions: ["*"], 712 714 priority: 10, 713 715 createdAt: new Date(), 714 716 indexedAt: new Date(), 715 - }); 717 + }).returning({ id: roles.id }); 718 + await ctx.db.insert(rolePermissions).values([{ roleId: adminRole.id, permission: "*" }]); 716 719 717 720 // Insert current user with membership 718 721 await ctx.db.insert(users).values({
+9 -6
apps/appview/src/routes/__tests__/boards.test.ts
··· 1 - import { describe, it, expect, beforeEach, afterEach } from "vitest"; 1 + import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 2 2 import { Hono } from "hono"; 3 3 import { createBoardsRoutes } from "../boards.js"; 4 4 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; ··· 117 117 }); 118 118 119 119 it("returns 503 on database error", async () => { 120 - // Close the database connection to simulate a database error 121 - await ctx.cleanup(); 122 - cleanedUp = true; 120 + // Mock database query to throw a database error (isDatabaseError returns true for "Database") 121 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 122 + throw new Error("Database connection lost"); 123 + }); 123 124 124 125 const res = await app.request("/api/boards"); 125 126 expect(res.status).toBe(503); ··· 532 533 }); 533 534 534 535 it("returns 503 on database error", async () => { 535 - await ctx.cleanup(); 536 - cleanedUp = true; 536 + // Mock database query to throw a database error (isDatabaseError returns true for "Database") 537 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 538 + throw new Error("Database connection lost"); 539 + }); 537 540 538 541 const res = await app.request("/api/boards/1"); 539 542 expect(res.status).toBe(503);
+5 -3
apps/appview/src/routes/__tests__/categories.test.ts
··· 1 - import { describe, it, expect, beforeEach, afterEach } from "vitest"; 1 + import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 2 2 import { Hono } from "hono"; 3 3 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 4 4 import { createCategoriesRoutes } from "../categories.js"; ··· 284 284 }); 285 285 286 286 it("returns 503 on database error", async () => { 287 - await ctx.cleanup(); 288 - cleanedUp = true; 287 + // Mock database query to throw a database error (isDatabaseError returns true for "Database") 288 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 289 + throw new Error("Database connection lost"); 290 + }); 289 291 290 292 const res = await app.request("/1"); 291 293 expect(res.status).toBe(503);
+28 -8
apps/appview/src/routes/__tests__/mod.test.ts
··· 65 65 describe("POST /api/mod/ban", () => { 66 66 it("bans user successfully when admin has authority", async () => { 67 67 // Create admin and member users 68 - const { users, memberships, roles } = await import("@atbb/db"); 68 + const { users, memberships, roles, rolePermissions } = await import("@atbb/db"); 69 69 const { eq } = await import("drizzle-orm"); 70 70 71 71 // Use unique DIDs for this test ··· 92 92 rkey: "admin-role", 93 93 cid: "bafyadmin", 94 94 name: "Admin", 95 - permissions: ["space.atbb.permission.banUsers"], 96 95 priority: 10, 97 96 createdAt: new Date(), 98 97 indexedAt: new Date(), ··· 104 103 .from(roles) 105 104 .where(eq(roles.rkey, "admin-role")) 106 105 .limit(1); 106 + 107 + // Grant banUsers permission to admin role 108 + await ctx.db.insert(rolePermissions).values({ 109 + roleId: adminRole.id, 110 + permission: "space.atbb.permission.banUsers", 111 + }); 107 112 108 113 // Insert memberships 109 114 const now = new Date(); ··· 634 639 describe("DELETE /api/mod/ban/:did", () => { 635 640 it("unbans user successfully when admin has authority", async () => { 636 641 // Create admin and member users 637 - const { users, memberships, roles, modActions, forums } = await import("@atbb/db"); 642 + const { users, memberships, roles, rolePermissions, modActions, forums } = await import("@atbb/db"); 638 643 const { eq } = await import("drizzle-orm"); 639 644 640 645 // Use unique DIDs for this test ··· 661 666 rkey: "unban-admin-role", 662 667 cid: "bafyunbanadmin", 663 668 name: "Admin", 664 - permissions: ["space.atbb.permission.banUsers"], 665 669 priority: 10, 666 670 createdAt: new Date(), 667 671 indexedAt: new Date(), ··· 673 677 .from(roles) 674 678 .where(eq(roles.rkey, "unban-admin-role")) 675 679 .limit(1); 680 + 681 + // Grant banUsers permission to admin role 682 + await ctx.db.insert(rolePermissions).values({ 683 + roleId: adminRole.id, 684 + permission: "space.atbb.permission.banUsers", 685 + }); 676 686 677 687 // Insert memberships 678 688 const now = new Date(); ··· 1195 1205 1196 1206 describe("POST /api/mod/lock", () => { 1197 1207 it("locks topic successfully when moderator has authority", async () => { 1198 - const { users, memberships, roles, posts } = await import("@atbb/db"); 1208 + const { users, memberships, roles, rolePermissions, posts } = await import("@atbb/db"); 1199 1209 const { eq } = await import("drizzle-orm"); 1200 1210 1201 1211 // Use unique DIDs for this test ··· 1222 1232 rkey: "lock-mod-role", 1223 1233 cid: "bafylockmod", 1224 1234 name: "Moderator", 1225 - permissions: ["space.atbb.permission.lockTopics"], 1226 1235 priority: 20, 1227 1236 createdAt: new Date(), 1228 1237 indexedAt: new Date(), ··· 1234 1243 .from(roles) 1235 1244 .where(eq(roles.rkey, "lock-mod-role")) 1236 1245 .limit(1); 1246 + 1247 + // Grant lockTopics permission to moderator role 1248 + await ctx.db.insert(rolePermissions).values({ 1249 + roleId: modRole.id, 1250 + permission: "space.atbb.permission.lockTopics", 1251 + }); 1237 1252 1238 1253 // Insert memberships 1239 1254 const now = new Date(); ··· 1907 1922 1908 1923 describe("DELETE /api/mod/lock/:topicId", () => { 1909 1924 it("unlocks topic successfully when moderator has authority", async () => { 1910 - const { users, memberships, roles, posts, forums, modActions } = await import("@atbb/db"); 1925 + const { users, memberships, roles, rolePermissions, posts, forums, modActions } = await import("@atbb/db"); 1911 1926 const { eq } = await import("drizzle-orm"); 1912 1927 1913 1928 // Use unique DIDs for this test ··· 1934 1949 rkey: "unlock-mod-role", 1935 1950 cid: "bafyunlockmod", 1936 1951 name: "Moderator", 1937 - permissions: ["space.atbb.permission.lockTopics"], 1938 1952 priority: 20, 1939 1953 createdAt: new Date(), 1940 1954 indexedAt: new Date(), ··· 1946 1960 .from(roles) 1947 1961 .where(eq(roles.rkey, "unlock-mod-role")) 1948 1962 .limit(1); 1963 + 1964 + // Grant lockTopics permission to moderator role 1965 + await ctx.db.insert(rolePermissions).values({ 1966 + roleId: modRole.id, 1967 + permission: "space.atbb.permission.lockTopics", 1968 + }); 1949 1969 1950 1970 // Insert memberships 1951 1971 const now = new Date();
+31 -15
apps/appview/src/routes/admin.ts
··· 3 3 import type { Variables } from "../types.js"; 4 4 import { requireAuth } from "../middleware/auth.js"; 5 5 import { requirePermission, getUserRole } from "../middleware/permissions.js"; 6 - import { memberships, roles, users, forums, backfillProgress, backfillErrors } from "@atbb/db"; 7 - import { eq, and, sql, asc } from "drizzle-orm"; 6 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors } from "@atbb/db"; 7 + import { eq, and, sql, asc, count } from "drizzle-orm"; 8 8 import { isProgrammingError } from "../lib/errors.js"; 9 9 import { BackfillStatus } from "../lib/backfill-manager.js"; 10 10 import { CursorManager } from "../lib/cursor-manager.js"; ··· 162 162 id: roles.id, 163 163 name: roles.name, 164 164 description: roles.description, 165 - permissions: roles.permissions, 166 165 priority: roles.priority, 167 166 }) 168 167 .from(roles) 169 168 .where(eq(roles.did, ctx.config.forumDid)) 170 169 .orderBy(asc(roles.priority)); 171 170 172 - return c.json({ 173 - roles: rolesList.map(role => ({ 174 - id: role.id.toString(), 175 - name: role.name, 176 - description: role.description, 177 - permissions: role.permissions, 178 - priority: role.priority, 179 - })), 180 - }); 171 + const rolesWithPermissions = await Promise.all( 172 + rolesList.map(async (role) => { 173 + const perms = await ctx.db 174 + .select({ permission: rolePermissions.permission }) 175 + .from(rolePermissions) 176 + .where(eq(rolePermissions.roleId, role.id)); 177 + return { 178 + id: role.id.toString(), 179 + name: role.name, 180 + description: role.description, 181 + permissions: perms.map((p) => p.permission), 182 + priority: role.priority, 183 + }; 184 + }) 185 + ); 186 + 187 + return c.json({ roles: rolesWithPermissions }); 181 188 } catch (error) { 182 189 return handleReadError(c, error, "Failed to retrieve roles", { 183 190 operation: "GET /api/admin/roles", ··· 254 261 handle: users.handle, 255 262 roleUri: memberships.roleUri, 256 263 roleName: roles.name, 257 - permissions: roles.permissions, 264 + roleId: roles.id, 258 265 }) 259 266 .from(memberships) 260 267 .leftJoin(users, eq(memberships.did, users.did)) ··· 274 281 return c.json({ error: "Membership not found" }, 404); 275 282 } 276 283 284 + let permissions: string[] = []; 285 + if (member.roleId) { 286 + const perms = await ctx.db 287 + .select({ permission: rolePermissions.permission }) 288 + .from(rolePermissions) 289 + .where(eq(rolePermissions.roleId, member.roleId)); 290 + permissions = perms.map((p) => p.permission); 291 + } 292 + 277 293 return c.json({ 278 294 did: member.did, 279 295 handle: member.handle || user.did, 280 296 role: member.roleName || "Guest", 281 297 roleUri: member.roleUri, 282 - permissions: member.permissions || [], 298 + permissions, 283 299 }); 284 300 } catch (error) { 285 301 return handleReadError(c, error, "Failed to retrieve your membership", { ··· 396 412 } 397 413 398 414 const [errorCount] = await ctx.db 399 - .select({ count: sql<number>`count(*)::int` }) 415 + .select({ count: count() }) 400 416 .from(backfillErrors) 401 417 .where(eq(backfillErrors.backfillId, row.id)); 402 418
+5 -1
devenv.nix
··· 11 11 pkgs.nginx 12 12 ]; 13 13 14 + # PostgreSQL is enabled by default for development. 15 + # To use SQLite instead, create devenv.local.nix with: 16 + # { ... }: { services.postgres.enable = false; } 17 + # Then set DATABASE_URL=file:./data/atbb.db in your .env file. 14 18 services.postgres = { 15 - enable = true; 19 + enable = pkgs.lib.mkDefault true; 16 20 package = pkgs.postgresql_17; 17 21 listen_addresses = "127.0.0.1"; 18 22 port = 5432;
+23
docker-compose.sqlite.yml
··· 1 + # docker-compose for SQLite deployments (no external database required). 2 + # 3 + # Usage: 4 + # docker compose -f docker-compose.sqlite.yml up 5 + # 6 + # The existing docker-compose.yml (PostgreSQL) is unchanged. 7 + services: 8 + appview: 9 + build: . 10 + environment: 11 + DATABASE_URL: file:/data/atbb.db 12 + NODE_ENV: production 13 + env_file: 14 + - .env 15 + volumes: 16 + - atbb_data:/data 17 + ports: 18 + - "80:80" 19 + restart: unless-stopped 20 + 21 + volumes: 22 + atbb_data: 23 + driver: local
+25 -5
nix/module.nix
··· 62 62 }; 63 63 64 64 database = { 65 + type = lib.mkOption { 66 + type = lib.types.enum [ "postgresql" "sqlite" ]; 67 + default = "postgresql"; 68 + description = "Database backend. Use 'sqlite' for embedded single-file storage without a separate PostgreSQL service."; 69 + }; 70 + 71 + path = lib.mkOption { 72 + type = lib.types.path; 73 + default = "/var/lib/atbb/atbb.db"; 74 + description = "Path to the SQLite database file. Only used when database.type = \"sqlite\"."; 75 + }; 76 + 65 77 enable = lib.mkOption { 66 78 type = lib.types.bool; 67 - default = true; 68 - description = "Whether to configure a local PostgreSQL 17 instance."; 79 + default = cfg.database.type == "postgresql"; 80 + description = "Enable local PostgreSQL 17 service. Ignored when database.type = \"sqlite\"."; 69 81 }; 70 82 71 83 name = lib.mkOption { ··· 155 167 users.groups.${cfg.group} = { }; 156 168 157 169 # ── PostgreSQL ─────────────────────────────────────────────── 158 - services.postgresql = lib.mkIf cfg.database.enable { 170 + services.postgresql = lib.mkIf (cfg.database.type == "postgresql" && cfg.database.enable) { 159 171 enable = true; 160 172 package = pkgs.postgresql_17; 161 173 ensureDatabases = [ cfg.database.name ]; ··· 189 201 User = cfg.user; 190 202 Group = cfg.group; 191 203 WorkingDirectory = "${cfg.package}/apps/appview"; 192 - ExecStart = "${cfg.package}/apps/appview/node_modules/.bin/drizzle-kit migrate"; 204 + ExecStart = if cfg.database.type == "sqlite" 205 + then "${cfg.package}/apps/appview/node_modules/.bin/drizzle-kit migrate --config=drizzle.sqlite.config.ts" 206 + else "${cfg.package}/apps/appview/node_modules/.bin/drizzle-kit migrate --config=drizzle.postgres.config.ts"; 193 207 EnvironmentFile = cfg.environmentFile; 194 208 RemainAfterExit = true; 195 209 ··· 223 237 PDS_URL = cfg.pdsUrl; 224 238 OAUTH_PUBLIC_URL = cfg.oauthPublicUrl; 225 239 SEED_DEFAULT_ROLES = lib.boolToString cfg.seedDefaultRoles; 226 - } // lib.optionalAttrs cfg.database.enable { 240 + } // lib.optionalAttrs (cfg.database.type == "sqlite") { 241 + # SQLite: set DATABASE_URL from module config (not env file) 242 + DATABASE_URL = "file:${cfg.database.path}"; 243 + } // lib.optionalAttrs (cfg.database.type == "postgresql" && cfg.database.enable) { 227 244 # Explicit socket directory so postgres.js uses Unix peer auth 228 245 # regardless of how it parses the DATABASE_URL host parameter. 229 246 PGHOST = "/run/postgresql"; ··· 238 255 EnvironmentFile = cfg.environmentFile; 239 256 Restart = "on-failure"; 240 257 RestartSec = 5; 258 + 259 + # SQLite: create /var/lib/atbb/ and grant write access to the service user 260 + StateDirectory = lib.mkIf (cfg.database.type == "sqlite") "atbb"; 241 261 242 262 # Hardening 243 263 NoNewPrivileges = true;
+4 -2
nix/package.nix
··· 67 67 68 68 # Drizzle migrations (needed for db:migrate at deploy time) 69 69 cp -r apps/appview/drizzle $out/apps/appview/ 70 + cp -r apps/appview/drizzle-sqlite $out/apps/appview/ 70 71 71 - # drizzle.config.ts (needed by drizzle-kit migrate) 72 - cp apps/appview/drizzle.config.ts $out/apps/appview/ 72 + # Drizzle config files (needed by drizzle-kit migrate) 73 + cp apps/appview/drizzle.postgres.config.ts $out/apps/appview/ 74 + cp apps/appview/drizzle.sqlite.config.ts $out/apps/appview/ 73 75 74 76 # Web static assets (CSS, favicon — served by hono serveStatic) 75 77 cp -r apps/web/public $out/apps/web/
+14 -4
packages/cli/src/__tests__/seed-roles.test.ts
··· 6 6 7 7 function mockDb(existingRoleNames: string[] = []) { 8 8 const existingQueue = [...existingRoleNames]; 9 + let roleIdCounter = 1n; 9 10 10 11 return { 11 12 select: vi.fn().mockReturnValue({ ··· 26 27 }), 27 28 }), 28 29 }), 29 - insert: vi.fn().mockReturnValue({ 30 - values: vi.fn().mockResolvedValue(undefined), 30 + insert: vi.fn().mockImplementation(() => { 31 + const id = roleIdCounter++; 32 + // values() returns a Promise (for rolePermissions inserts that are awaited directly) 33 + // but also has a .returning() method (for role inserts) 34 + const resolvedPromise = Promise.resolve(undefined); 35 + const valuesResult = Object.assign(resolvedPromise, { 36 + returning: vi.fn().mockResolvedValue([{ id }]), 37 + }); 38 + return { 39 + values: vi.fn().mockReturnValue(valuesResult), 40 + }; 31 41 }), 32 42 } as any; 33 43 } ··· 70 80 expect(result.created).toBe(4); 71 81 expect(result.skipped).toBe(0); 72 82 expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledTimes(4); 73 - // Verify DB insertions 74 - expect(db.insert).toHaveBeenCalledTimes(4); 83 + // Verify DB insertions: 4 role inserts + 4 rolePermissions inserts (one per role) 84 + expect(db.insert).toHaveBeenCalledTimes(8); 75 85 // Verify returned role data 76 86 expect(result.roles).toHaveLength(4); 77 87 expect(result.roles[0].name).toBe("Owner");
+12 -4
packages/cli/src/lib/steps/seed-roles.ts
··· 1 1 import type { AtpAgent } from "@atproto/api"; 2 2 import type { Database } from "@atbb/db"; 3 - import { roles } from "@atbb/db"; 3 + import { roles, rolePermissions } from "@atbb/db"; 4 4 import { eq } from "drizzle-orm"; 5 5 6 6 interface DefaultRole { ··· 119 119 const rkey = response.data.uri.split("/").pop()!; 120 120 121 121 // Insert into database so downstream steps can query it 122 - await db.insert(roles).values({ 122 + const [insertedRole] = await db.insert(roles).values({ 123 123 did: forumDid, 124 124 rkey, 125 125 cid: response.data.cid, 126 126 name: defaultRole.name, 127 127 description: defaultRole.description, 128 - permissions: defaultRole.permissions, 129 128 priority: defaultRole.priority, 130 129 createdAt: new Date(), 131 130 indexedAt: new Date(), 132 - }); 131 + }).returning({ id: roles.id }); 132 + 133 + if (defaultRole.permissions.length > 0) { 134 + await db.insert(rolePermissions).values( 135 + defaultRole.permissions.map((permission) => ({ 136 + roleId: insertedRole.id, 137 + permission, 138 + })) 139 + ); 140 + } 133 141 134 142 seededRoles.push({ 135 143 name: defaultRole.name,
+1
packages/db/package.json
··· 23 23 "test": "vitest run" 24 24 }, 25 25 "dependencies": { 26 + "@libsql/client": "^0.14.0", 26 27 "drizzle-orm": "^0.45.1", 27 28 "postgres": "^3.4.8" 28 29 },
+44 -28
packages/db/src/index.ts
··· 1 - import { drizzle } from "drizzle-orm/postgres-js"; 1 + import { drizzle as drizzlePg } from "drizzle-orm/postgres-js"; 2 + import { drizzle as drizzleSqlite } from "drizzle-orm/libsql"; 3 + import { migrate as libsqlMigrate } from "drizzle-orm/libsql/migrator"; 4 + import { createClient } from "@libsql/client"; 2 5 import postgres from "postgres"; 3 - import * as schema from "./schema.js"; 4 - 5 - export function createDb(databaseUrl: string) { 6 - const client = postgres(databaseUrl); 7 - return drizzle(client, { schema }); 8 - } 9 - 10 - export type Database = ReturnType<typeof createDb>; 6 + import * as pgSchema from "./schema.js"; 7 + import * as sqliteSchema from "./schema.sqlite.js"; 11 8 12 9 /** 13 - * Transaction type extracted from Drizzle's database instance. 14 - * Use this when you need to work with a transaction object directly. 10 + * Create a Drizzle database instance from a connection URL. 11 + * 12 + * URL prefix determines the driver: 13 + * postgres:// or postgresql:// → postgres.js (PostgreSQL) 14 + * file: → @libsql/client (SQLite file) 15 + * file::memory: → @libsql/client (SQLite in-memory, tests) 16 + * libsql:// → @libsql/client (Turso cloud) 15 17 */ 16 - export type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0]; 18 + export function createDb(databaseUrl: string): Database { 19 + if (databaseUrl.startsWith("postgres")) { 20 + return drizzlePg(postgres(databaseUrl), { schema: pgSchema }) as Database; 21 + } 22 + return drizzleSqlite( 23 + createClient({ url: databaseUrl }), 24 + { schema: sqliteSchema } 25 + ) as unknown as Database; 26 + } 17 27 18 28 /** 19 - * Union type for functions that need to work with either the database 20 - * instance or an active transaction. This is useful for helper functions 21 - * that can be called both standalone and within a transaction context. 22 - * 23 - * Example: 24 - * ```typescript 25 - * async function getUser(id: string, dbOrTx: DbOrTransaction = db) { 26 - * return dbOrTx.select().from(users).where(eq(users.id, id)); 27 - * } 29 + * Run SQLite migrations on a database created with createDb(). 30 + * Uses the same drizzle-orm instance as createDb() to avoid cross-package 31 + * module boundary issues that occur when using the migrator from a different 32 + * drizzle-orm installation. 28 33 * 29 - * // Can be called standalone 30 - * await getUser('123', db); 34 + * Only works with SQLite databases (file: or libsql: URLs). 35 + * For PostgreSQL, use drizzle-kit migrate directly. 31 36 * 32 - * // Or within a transaction 33 - * await db.transaction(async (tx) => { 34 - * await getUser('123', tx); 35 - * }); 36 - * ``` 37 + * @param db - Database created with createDb() for a SQLite URL 38 + * @param migrationsFolder - Absolute path to the folder containing migration SQL files 37 39 */ 40 + export async function runSqliteMigrations( 41 + db: Database, 42 + migrationsFolder: string 43 + ): Promise<void> { 44 + await libsqlMigrate(db as any, { migrationsFolder }); 45 + } 46 + 47 + // Database type uses the Postgres schema as the TypeScript source of truth. 48 + // Both dialects produce compatible column names and TypeScript types, 49 + // so the cast is safe at the app layer. 50 + export type Database = ReturnType<typeof drizzlePg<typeof pgSchema>>; 51 + 52 + export type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]; 53 + 38 54 export type DbOrTransaction = Database | Transaction; 39 55 40 56 export * from "./schema.js";
+251
packages/db/src/schema.sqlite.ts
··· 1 + import { 2 + sqliteTable, 3 + text, 4 + integer, 5 + uniqueIndex, 6 + index, 7 + primaryKey, 8 + } from "drizzle-orm/sqlite-core"; 9 + 10 + // ── forums ────────────────────────────────────────────── 11 + // Singleton forum metadata record, owned by Forum DID. 12 + // Key: literal:self (rkey is always "self"). 13 + export const forums = sqliteTable( 14 + "forums", 15 + { 16 + id: integer("id").primaryKey({ autoIncrement: true }), 17 + did: text("did").notNull(), 18 + rkey: text("rkey").notNull(), 19 + cid: text("cid").notNull(), 20 + name: text("name").notNull(), 21 + description: text("description"), 22 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 23 + }, 24 + (table) => [uniqueIndex("forums_did_rkey_idx").on(table.did, table.rkey)] 25 + ); 26 + 27 + // ── categories ────────────────────────────────────────── 28 + // Subforum / category definitions, owned by Forum DID. 29 + export const categories = sqliteTable( 30 + "categories", 31 + { 32 + id: integer("id").primaryKey({ autoIncrement: true }), 33 + did: text("did").notNull(), 34 + rkey: text("rkey").notNull(), 35 + cid: text("cid").notNull(), 36 + name: text("name").notNull(), 37 + description: text("description"), 38 + slug: text("slug"), 39 + sortOrder: integer("sort_order"), 40 + forumId: integer("forum_id").references(() => forums.id), 41 + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 42 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 43 + }, 44 + (table) => [ 45 + uniqueIndex("categories_did_rkey_idx").on(table.did, table.rkey), 46 + ] 47 + ); 48 + 49 + // ── boards ────────────────────────────────────────────── 50 + // Board (subforum) definitions within categories, owned by Forum DID. 51 + export const boards = sqliteTable( 52 + "boards", 53 + { 54 + id: integer("id").primaryKey({ autoIncrement: true }), 55 + did: text("did").notNull(), 56 + rkey: text("rkey").notNull(), 57 + cid: text("cid").notNull(), 58 + name: text("name").notNull(), 59 + description: text("description"), 60 + slug: text("slug"), 61 + sortOrder: integer("sort_order"), 62 + categoryId: integer("category_id").references(() => categories.id), 63 + categoryUri: text("category_uri").notNull(), 64 + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 65 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 66 + }, 67 + (table) => [ 68 + uniqueIndex("boards_did_rkey_idx").on(table.did, table.rkey), 69 + index("boards_category_id_idx").on(table.categoryId), 70 + ] 71 + ); 72 + 73 + // ── users ─────────────────────────────────────────────── 74 + // Known AT Proto identities. Populated when any record 75 + // from a DID is indexed. DID is the primary key. 76 + export const users = sqliteTable("users", { 77 + did: text("did").primaryKey(), 78 + handle: text("handle"), 79 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 80 + }); 81 + 82 + // ── memberships ───────────────────────────────────────── 83 + // User membership in a forum. Owned by user DID. 84 + // `did` is both the record owner and the member. 85 + export const memberships = sqliteTable( 86 + "memberships", 87 + { 88 + id: integer("id").primaryKey({ autoIncrement: true }), 89 + did: text("did") 90 + .notNull() 91 + .references(() => users.did), 92 + rkey: text("rkey").notNull(), 93 + cid: text("cid").notNull(), 94 + forumId: integer("forum_id").references(() => forums.id), 95 + forumUri: text("forum_uri").notNull(), 96 + role: text("role"), 97 + roleUri: text("role_uri"), 98 + joinedAt: integer("joined_at", { mode: "timestamp" }), 99 + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 100 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 101 + }, 102 + (table) => [ 103 + uniqueIndex("memberships_did_rkey_idx").on(table.did, table.rkey), 104 + index("memberships_did_idx").on(table.did), 105 + ] 106 + ); 107 + 108 + // ── posts ─────────────────────────────────────────────── 109 + // Unified post model. NULL root/parent = thread starter (topic). 110 + // Non-null root/parent = reply. Mirrors app.bsky.feed.post pattern. 111 + // Owned by user DID. 112 + export const posts = sqliteTable( 113 + "posts", 114 + { 115 + id: integer("id").primaryKey({ autoIncrement: true }), 116 + did: text("did") 117 + .notNull() 118 + .references(() => users.did), 119 + rkey: text("rkey").notNull(), 120 + cid: text("cid").notNull(), 121 + title: text("title"), 122 + text: text("text").notNull(), 123 + forumUri: text("forum_uri"), 124 + boardUri: text("board_uri"), 125 + boardId: integer("board_id").references(() => boards.id), 126 + rootPostId: integer("root_post_id").references((): any => posts.id), 127 + parentPostId: integer("parent_post_id").references((): any => posts.id), 128 + rootUri: text("root_uri"), 129 + parentUri: text("parent_uri"), 130 + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 131 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 132 + bannedByMod: integer("banned_by_mod", { mode: "boolean" }) 133 + .notNull() 134 + .default(false), 135 + deletedByUser: integer("deleted_by_user", { mode: "boolean" }) 136 + .notNull() 137 + .default(false), 138 + }, 139 + (table) => [ 140 + uniqueIndex("posts_did_rkey_idx").on(table.did, table.rkey), 141 + index("posts_forum_uri_idx").on(table.forumUri), 142 + index("posts_board_id_idx").on(table.boardId), 143 + index("posts_board_uri_idx").on(table.boardUri), 144 + index("posts_root_post_id_idx").on(table.rootPostId), 145 + ] 146 + ); 147 + 148 + // ── mod_actions ───────────────────────────────────────── 149 + // Moderation actions, owned by Forum DID. Written by AppView 150 + // on behalf of authorized moderators after role verification. 151 + export const modActions = sqliteTable( 152 + "mod_actions", 153 + { 154 + id: integer("id").primaryKey({ autoIncrement: true }), 155 + did: text("did").notNull(), 156 + rkey: text("rkey").notNull(), 157 + cid: text("cid").notNull(), 158 + action: text("action").notNull(), 159 + subjectDid: text("subject_did"), 160 + subjectPostUri: text("subject_post_uri"), 161 + forumId: integer("forum_id").references(() => forums.id), 162 + reason: text("reason"), 163 + createdBy: text("created_by").notNull(), 164 + expiresAt: integer("expires_at", { mode: "timestamp" }), 165 + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 166 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 167 + }, 168 + (table) => [ 169 + uniqueIndex("mod_actions_did_rkey_idx").on(table.did, table.rkey), 170 + index("mod_actions_subject_did_idx").on(table.subjectDid), 171 + index("mod_actions_subject_post_uri_idx").on(table.subjectPostUri), 172 + ] 173 + ); 174 + 175 + // ── firehose_cursor ───────────────────────────────────── 176 + // Tracks the last processed event from the Jetstream firehose. 177 + // Singleton table (service is primary key). 178 + export const firehoseCursor = sqliteTable("firehose_cursor", { 179 + service: text("service").primaryKey().default("jetstream"), 180 + cursor: integer("cursor").notNull(), // time_us value from Jetstream 181 + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), 182 + }); 183 + 184 + // ── roles ─────────────────────────────────────────────── 185 + // Role definitions, owned by Forum DID. 186 + // Note: permissions are stored in the role_permissions join table (not as an array column). 187 + export const roles = sqliteTable( 188 + "roles", 189 + { 190 + id: integer("id").primaryKey({ autoIncrement: true }), 191 + did: text("did").notNull(), 192 + rkey: text("rkey").notNull(), 193 + cid: text("cid").notNull(), 194 + name: text("name").notNull(), 195 + description: text("description"), 196 + priority: integer("priority").notNull(), 197 + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 198 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 199 + }, 200 + (table) => [ 201 + uniqueIndex("roles_did_rkey_idx").on(table.did, table.rkey), 202 + index("roles_did_idx").on(table.did), 203 + index("roles_did_name_idx").on(table.did, table.name), 204 + ] 205 + ); 206 + 207 + // ── role_permissions ──────────────────────────────────── 208 + // Many-to-many join table for role permissions. 209 + // Replaces the permissions text[] array column from the Postgres schema. 210 + export const rolePermissions = sqliteTable( 211 + "role_permissions", 212 + { 213 + roleId: integer("role_id") 214 + .notNull() 215 + .references(() => roles.id, { onDelete: "cascade" }), 216 + permission: text("permission").notNull(), 217 + }, 218 + (t) => [primaryKey({ columns: [t.roleId, t.permission] })] 219 + ); 220 + 221 + // ── backfill_progress ─────────────────────────────────── 222 + // Tracks backfill job state for crash-resilient resume. 223 + export const backfillProgress = sqliteTable("backfill_progress", { 224 + id: integer("id").primaryKey({ autoIncrement: true }), 225 + status: text("status").notNull(), // 'in_progress', 'completed', 'failed' 226 + backfillType: text("backfill_type").notNull(), // 'full_sync', 'catch_up' 227 + lastProcessedDid: text("last_processed_did"), 228 + didsTotal: integer("dids_total").notNull().default(0), 229 + didsProcessed: integer("dids_processed").notNull().default(0), 230 + recordsIndexed: integer("records_indexed").notNull().default(0), 231 + startedAt: integer("started_at", { mode: "timestamp" }).notNull(), 232 + completedAt: integer("completed_at", { mode: "timestamp" }), 233 + errorMessage: text("error_message"), 234 + }); 235 + 236 + // ── backfill_errors ───────────────────────────────────── 237 + // Per-DID error log for failed backfill syncs. 238 + export const backfillErrors = sqliteTable( 239 + "backfill_errors", 240 + { 241 + id: integer("id").primaryKey({ autoIncrement: true }), 242 + backfillId: integer("backfill_id") 243 + .notNull() 244 + .references(() => backfillProgress.id), 245 + did: text("did").notNull(), 246 + collection: text("collection").notNull(), 247 + errorMessage: text("error_message").notNull(), 248 + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 249 + }, 250 + (table) => [index("backfill_errors_backfill_id_idx").on(table.backfillId)] 251 + );
+15 -2
packages/db/src/schema.ts
··· 8 8 bigint, 9 9 uniqueIndex, 10 10 index, 11 + primaryKey, 11 12 } from "drizzle-orm/pg-core"; 12 - import { sql } from "drizzle-orm"; 13 13 14 14 // ── forums ────────────────────────────────────────────── 15 15 // Singleton forum metadata record, owned by Forum DID. ··· 202 202 cid: text("cid").notNull(), 203 203 name: text("name").notNull(), 204 204 description: text("description"), 205 - permissions: text("permissions").array().notNull().default(sql`'{}'::text[]`), 206 205 priority: integer("priority").notNull(), 207 206 createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 208 207 indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), ··· 212 211 index("roles_did_idx").on(table.did), 213 212 index("roles_did_name_idx").on(table.did, table.name), 214 213 ] 214 + ); 215 + 216 + // ── role_permissions ───────────────────────────────────────────────────────── 217 + // Normalized join table replacing the permissions text[] column. 218 + // Cascade delete ensures permissions are cleaned up when a role is deleted. 219 + export const rolePermissions = pgTable( 220 + "role_permissions", 221 + { 222 + roleId: bigint("role_id", { mode: "bigint" }) 223 + .notNull() 224 + .references(() => roles.id, { onDelete: "cascade" }), 225 + permission: text("permission").notNull(), 226 + }, 227 + (t) => [primaryKey({ columns: [t.roleId, t.permission] })] 215 228 ); 216 229 217 230 // ── backfill_progress ───────────────────────────────────
+221 -4
pnpm-lock.yaml
··· 61 61 version: 0.31.8 62 62 drizzle-orm: 63 63 specifier: ^0.45.1 64 - version: 0.45.1(@opentelemetry/api@1.9.0)(postgres@3.4.8) 64 + version: 0.45.1(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(postgres@3.4.8) 65 65 hono: 66 66 specifier: ^4.7.0 67 67 version: 4.11.8 ··· 154 154 version: 3.4.2 155 155 drizzle-orm: 156 156 specifier: ^0.45.1 157 - version: 0.45.1(@opentelemetry/api@1.9.0)(postgres@3.4.8) 157 + version: 0.45.1(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(postgres@3.4.8) 158 158 postgres: 159 159 specifier: ^3.4.8 160 160 version: 3.4.8 ··· 174 174 175 175 packages/db: 176 176 dependencies: 177 + '@libsql/client': 178 + specifier: ^0.14.0 179 + version: 0.14.0 177 180 drizzle-orm: 178 181 specifier: ^0.45.1 179 - version: 0.45.1(@opentelemetry/api@1.9.0)(postgres@3.4.8) 182 + version: 0.45.1(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(postgres@3.4.8) 180 183 postgres: 181 184 specifier: ^3.4.8 182 185 version: 3.4.8 ··· 973 976 '@jridgewell/sourcemap-codec@1.5.5': 974 977 resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} 975 978 979 + '@libsql/client@0.14.0': 980 + resolution: {integrity: sha512-/9HEKfn6fwXB5aTEEoMeFh4CtG0ZzbncBb1e++OCdVpgKZ/xyMsIVYXm0w7Pv4RUel803vE6LwniB3PqD72R0Q==} 981 + 982 + '@libsql/core@0.14.0': 983 + resolution: {integrity: sha512-nhbuXf7GP3PSZgdCY2Ecj8vz187ptHlZQ0VRc751oB2C1W8jQUXKKklvt7t1LJiUTQBVJuadF628eUk+3cRi4Q==} 984 + 985 + '@libsql/darwin-arm64@0.4.7': 986 + resolution: {integrity: sha512-yOL742IfWUlUevnI5PdnIT4fryY3LYTdLm56bnY0wXBw7dhFcnjuA7jrH3oSVz2mjZTHujxoITgAE7V6Z+eAbg==} 987 + cpu: [arm64] 988 + os: [darwin] 989 + 990 + '@libsql/darwin-x64@0.4.7': 991 + resolution: {integrity: sha512-ezc7V75+eoyyH07BO9tIyJdqXXcRfZMbKcLCeF8+qWK5nP8wWuMcfOVywecsXGRbT99zc5eNra4NEx6z5PkSsA==} 992 + cpu: [x64] 993 + os: [darwin] 994 + 995 + '@libsql/hrana-client@0.7.0': 996 + resolution: {integrity: sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==} 997 + 998 + '@libsql/isomorphic-fetch@0.3.1': 999 + resolution: {integrity: sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==} 1000 + engines: {node: '>=18.0.0'} 1001 + 1002 + '@libsql/isomorphic-ws@0.1.5': 1003 + resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} 1004 + 1005 + '@libsql/linux-arm64-gnu@0.4.7': 1006 + resolution: {integrity: sha512-WlX2VYB5diM4kFfNaYcyhw5y+UJAI3xcMkEUJZPtRDEIu85SsSFrQ+gvoKfcVh76B//ztSeEX2wl9yrjF7BBCA==} 1007 + cpu: [arm64] 1008 + os: [linux] 1009 + 1010 + '@libsql/linux-arm64-musl@0.4.7': 1011 + resolution: {integrity: sha512-6kK9xAArVRlTCpWeqnNMCoXW1pe7WITI378n4NpvU5EJ0Ok3aNTIC2nRPRjhro90QcnmLL1jPcrVwO4WD1U0xw==} 1012 + cpu: [arm64] 1013 + os: [linux] 1014 + 1015 + '@libsql/linux-x64-gnu@0.4.7': 1016 + resolution: {integrity: sha512-CMnNRCmlWQqqzlTw6NeaZXzLWI8bydaXDke63JTUCvu8R+fj/ENsLrVBtPDlxQ0wGsYdXGlrUCH8Qi9gJep0yQ==} 1017 + cpu: [x64] 1018 + os: [linux] 1019 + 1020 + '@libsql/linux-x64-musl@0.4.7': 1021 + resolution: {integrity: sha512-nI6tpS1t6WzGAt1Kx1n1HsvtBbZ+jHn0m7ogNNT6pQHZQj7AFFTIMeDQw/i/Nt5H38np1GVRNsFe99eSIMs9XA==} 1022 + cpu: [x64] 1023 + os: [linux] 1024 + 1025 + '@libsql/win32-x64-msvc@0.4.7': 1026 + resolution: {integrity: sha512-7pJzOWzPm6oJUxml+PCDRzYQ4A1hTMHAciTAHfFK4fkbDZX33nWPVG7Y3vqdKtslcwAzwmrNDc6sXy2nwWnbiw==} 1027 + cpu: [x64] 1028 + os: [win32] 1029 + 1030 + '@neon-rs/load@0.0.4': 1031 + resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} 1032 + 976 1033 '@opentelemetry/api-logs@0.200.0': 977 1034 resolution: {integrity: sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q==} 978 1035 engines: {node: '>=8.0.0'} ··· 1201 1258 '@types/node@22.19.9': 1202 1259 resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==} 1203 1260 1261 + '@types/ws@8.18.1': 1262 + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} 1263 + 1204 1264 '@vitest/expect@3.2.4': 1205 1265 resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} 1206 1266 ··· 1352 1412 resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} 1353 1413 engines: {node: '>= 8'} 1354 1414 1415 + data-uri-to-buffer@4.0.1: 1416 + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} 1417 + engines: {node: '>= 12'} 1418 + 1355 1419 debug@4.4.3: 1356 1420 resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} 1357 1421 engines: {node: '>=6.0'} ··· 1365 1429 resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} 1366 1430 engines: {node: '>=6'} 1367 1431 1432 + detect-libc@2.0.2: 1433 + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} 1434 + engines: {node: '>=8'} 1435 + 1368 1436 dotenv@17.3.1: 1369 1437 resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} 1370 1438 engines: {node: '>=12'} ··· 1525 1593 picomatch: 1526 1594 optional: true 1527 1595 1596 + fetch-blob@3.2.0: 1597 + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} 1598 + engines: {node: ^12.20 || >= 14.13} 1599 + 1528 1600 foreground-child@3.3.1: 1529 1601 resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} 1530 1602 engines: {node: '>=14'} 1603 + 1604 + formdata-polyfill@4.0.10: 1605 + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} 1606 + engines: {node: '>=12.20.0'} 1531 1607 1532 1608 fsevents@2.3.3: 1533 1609 resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} ··· 1578 1654 1579 1655 jose@5.10.0: 1580 1656 resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} 1657 + 1658 + js-base64@3.7.8: 1659 + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} 1581 1660 1582 1661 js-tokens@9.0.1: 1583 1662 resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} ··· 1636 1715 resolution: {integrity: sha512-ojj4/4IJ29Xn4drd5emqVgilegAPN3Kf0FQM2p/9+lwSTpU+SZ1v4Ig++NF+9MOa99UKY8bElmVrLhnUUNFh5g==} 1637 1716 hasBin: true 1638 1717 1718 + libsql@0.4.7: 1719 + resolution: {integrity: sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==} 1720 + cpu: [x64, arm64, wasm32] 1721 + os: [darwin, linux, win32] 1722 + 1639 1723 loupe@3.2.1: 1640 1724 resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} 1641 1725 ··· 1678 1762 resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} 1679 1763 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 1680 1764 hasBin: true 1765 + 1766 + node-domexception@1.0.0: 1767 + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} 1768 + engines: {node: '>=10.5.0'} 1769 + deprecated: Use your platform's native DOMException instead 1770 + 1771 + node-fetch@3.3.2: 1772 + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} 1773 + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 1681 1774 1682 1775 obug@2.1.1: 1683 1776 resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} ··· 1751 1844 process@0.11.10: 1752 1845 resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} 1753 1846 engines: {node: '>= 0.6.0'} 1847 + 1848 + promise-limit@2.7.0: 1849 + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} 1754 1850 1755 1851 quick-format-unescaped@4.0.4: 1756 1852 resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} ··· 2056 2152 jsdom: 2057 2153 optional: true 2058 2154 2155 + web-streams-polyfill@3.3.3: 2156 + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} 2157 + engines: {node: '>= 8'} 2158 + 2059 2159 which@2.0.2: 2060 2160 resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} 2061 2161 engines: {node: '>= 8'} ··· 2070 2170 resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} 2071 2171 engines: {node: '>=8'} 2072 2172 2173 + ws@8.19.0: 2174 + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} 2175 + engines: {node: '>=10.0.0'} 2176 + peerDependencies: 2177 + bufferutil: ^4.0.1 2178 + utf-8-validate: '>=5.0.2' 2179 + peerDependenciesMeta: 2180 + bufferutil: 2181 + optional: true 2182 + utf-8-validate: 2183 + optional: true 2184 + 2073 2185 yaml@2.8.2: 2074 2186 resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} 2075 2187 engines: {node: '>= 14.6'} ··· 2663 2775 2664 2776 '@jridgewell/sourcemap-codec@1.5.5': {} 2665 2777 2778 + '@libsql/client@0.14.0': 2779 + dependencies: 2780 + '@libsql/core': 0.14.0 2781 + '@libsql/hrana-client': 0.7.0 2782 + js-base64: 3.7.8 2783 + libsql: 0.4.7 2784 + promise-limit: 2.7.0 2785 + transitivePeerDependencies: 2786 + - bufferutil 2787 + - utf-8-validate 2788 + 2789 + '@libsql/core@0.14.0': 2790 + dependencies: 2791 + js-base64: 3.7.8 2792 + 2793 + '@libsql/darwin-arm64@0.4.7': 2794 + optional: true 2795 + 2796 + '@libsql/darwin-x64@0.4.7': 2797 + optional: true 2798 + 2799 + '@libsql/hrana-client@0.7.0': 2800 + dependencies: 2801 + '@libsql/isomorphic-fetch': 0.3.1 2802 + '@libsql/isomorphic-ws': 0.1.5 2803 + js-base64: 3.7.8 2804 + node-fetch: 3.3.2 2805 + transitivePeerDependencies: 2806 + - bufferutil 2807 + - utf-8-validate 2808 + 2809 + '@libsql/isomorphic-fetch@0.3.1': {} 2810 + 2811 + '@libsql/isomorphic-ws@0.1.5': 2812 + dependencies: 2813 + '@types/ws': 8.18.1 2814 + ws: 8.19.0 2815 + transitivePeerDependencies: 2816 + - bufferutil 2817 + - utf-8-validate 2818 + 2819 + '@libsql/linux-arm64-gnu@0.4.7': 2820 + optional: true 2821 + 2822 + '@libsql/linux-arm64-musl@0.4.7': 2823 + optional: true 2824 + 2825 + '@libsql/linux-x64-gnu@0.4.7': 2826 + optional: true 2827 + 2828 + '@libsql/linux-x64-musl@0.4.7': 2829 + optional: true 2830 + 2831 + '@libsql/win32-x64-msvc@0.4.7': 2832 + optional: true 2833 + 2834 + '@neon-rs/load@0.0.4': {} 2835 + 2666 2836 '@opentelemetry/api-logs@0.200.0': 2667 2837 dependencies: 2668 2838 '@opentelemetry/api': 1.9.0 ··· 2828 2998 dependencies: 2829 2999 undici-types: 6.21.0 2830 3000 3001 + '@types/ws@8.18.1': 3002 + dependencies: 3003 + '@types/node': 22.19.9 3004 + 2831 3005 '@vitest/expect@3.2.4': 2832 3006 dependencies: 2833 3007 '@types/chai': 5.2.3 ··· 2987 3161 shebang-command: 2.0.0 2988 3162 which: 2.0.2 2989 3163 3164 + data-uri-to-buffer@4.0.1: {} 3165 + 2990 3166 debug@4.4.3: 2991 3167 dependencies: 2992 3168 ms: 2.1.3 2993 3169 2994 3170 deep-eql@5.0.2: {} 2995 3171 3172 + detect-libc@2.0.2: {} 3173 + 2996 3174 dotenv@17.3.1: {} 2997 3175 2998 3176 drizzle-kit@0.31.8: ··· 3004 3182 transitivePeerDependencies: 3005 3183 - supports-color 3006 3184 3007 - drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(postgres@3.4.8): 3185 + drizzle-orm@0.45.1(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(postgres@3.4.8): 3008 3186 optionalDependencies: 3187 + '@libsql/client': 0.14.0 3009 3188 '@opentelemetry/api': 1.9.0 3010 3189 postgres: 3.4.8 3011 3190 ··· 3123 3302 optionalDependencies: 3124 3303 picomatch: 4.0.3 3125 3304 3305 + fetch-blob@3.2.0: 3306 + dependencies: 3307 + node-domexception: 1.0.0 3308 + web-streams-polyfill: 3.3.3 3309 + 3126 3310 foreground-child@3.3.1: 3127 3311 dependencies: 3128 3312 cross-spawn: 7.0.6 3129 3313 signal-exit: 4.1.0 3314 + 3315 + formdata-polyfill@4.0.10: 3316 + dependencies: 3317 + fetch-blob: 3.2.0 3130 3318 3131 3319 fsevents@2.3.3: 3132 3320 optional: true ··· 3168 3356 3169 3357 jose@5.10.0: {} 3170 3358 3359 + js-base64@3.7.8: {} 3360 + 3171 3361 js-tokens@9.0.1: {} 3172 3362 3173 3363 lefthook-darwin-arm64@1.13.6: ··· 3213 3403 lefthook-windows-arm64: 1.13.6 3214 3404 lefthook-windows-x64: 1.13.6 3215 3405 3406 + libsql@0.4.7: 3407 + dependencies: 3408 + '@neon-rs/load': 0.0.4 3409 + detect-libc: 2.0.2 3410 + optionalDependencies: 3411 + '@libsql/darwin-arm64': 0.4.7 3412 + '@libsql/darwin-x64': 0.4.7 3413 + '@libsql/linux-arm64-gnu': 0.4.7 3414 + '@libsql/linux-arm64-musl': 0.4.7 3415 + '@libsql/linux-x64-gnu': 0.4.7 3416 + '@libsql/linux-x64-musl': 0.4.7 3417 + '@libsql/win32-x64-msvc': 0.4.7 3418 + 3216 3419 loupe@3.2.1: {} 3217 3420 3218 3421 lru-cache@10.4.3: {} ··· 3242 3445 mute-stream@2.0.0: {} 3243 3446 3244 3447 nanoid@3.3.11: {} 3448 + 3449 + node-domexception@1.0.0: {} 3450 + 3451 + node-fetch@3.3.2: 3452 + dependencies: 3453 + data-uri-to-buffer: 4.0.1 3454 + fetch-blob: 3.2.0 3455 + formdata-polyfill: 4.0.10 3245 3456 3246 3457 obug@2.1.1: {} 3247 3458 ··· 3315 3526 process-warning@3.0.0: {} 3316 3527 3317 3528 process@0.11.10: {} 3529 + 3530 + promise-limit@2.7.0: {} 3318 3531 3319 3532 quick-format-unescaped@4.0.4: {} 3320 3533 ··· 3618 3831 - tsx 3619 3832 - yaml 3620 3833 3834 + web-streams-polyfill@3.3.3: {} 3835 + 3621 3836 which@2.0.2: 3622 3837 dependencies: 3623 3838 isexe: 2.0.0 ··· 3632 3847 ansi-styles: 4.3.0 3633 3848 string-width: 4.2.3 3634 3849 strip-ansi: 6.0.1 3850 + 3851 + ws@8.19.0: {} 3635 3852 3636 3853 yaml@2.8.2: {} 3637 3854