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
4
fork

Configure Feed

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

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

Malpercio 9cda5a8d 5cfcfd2b

+88 -25
+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 }
+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);
+2 -2
apps/appview/src/routes/admin.ts
··· 4 4 import { requireAuth } from "../middleware/auth.js"; 5 5 import { requirePermission, getUserRole } from "../middleware/permissions.js"; 6 6 import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors } from "@atbb/db"; 7 - import { eq, and, sql, asc } from "drizzle-orm"; 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"; ··· 412 412 } 413 413 414 414 const [errorCount] = await ctx.db 415 - .select({ count: sql<number>`count(*)::int` }) 415 + .select({ count: count() }) 416 416 .from(backfillErrors) 417 417 .where(eq(backfillErrors.backfillId, row.id)); 418 418
+20
packages/db/src/index.ts
··· 1 1 import { drizzle as drizzlePg } from "drizzle-orm/postgres-js"; 2 2 import { drizzle as drizzleSqlite } from "drizzle-orm/libsql"; 3 + import { migrate as libsqlMigrate } from "drizzle-orm/libsql/migrator"; 3 4 import { createClient } from "@libsql/client"; 4 5 import postgres from "postgres"; 5 6 import * as pgSchema from "./schema.js"; ··· 22 23 createClient({ url: databaseUrl }), 23 24 { schema: sqliteSchema } 24 25 ) as unknown as Database; 26 + } 27 + 28 + /** 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. 33 + * 34 + * Only works with SQLite databases (file: or libsql: URLs). 35 + * For PostgreSQL, use drizzle-kit migrate directly. 36 + * 37 + * @param db - Database created with createDb() for a SQLite URL 38 + * @param migrationsFolder - Absolute path to the folder containing migration SQL files 39 + */ 40 + export async function runSqliteMigrations( 41 + db: Database, 42 + migrationsFolder: string 43 + ): Promise<void> { 44 + await libsqlMigrate(db as any, { migrationsFolder }); 25 45 } 26 46 27 47 // Database type uses the Postgres schema as the TypeScript source of truth.