[READ-ONLY] Mirror of https://github.com/flo-bit/contrail. atproto backend in a bottle flo-bit.dev/contrail/
0

Configure Feed

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

switch sqlite to node:sqlite

Florian (Apr 2, 2026, 1:14 AM +0200) 7dafe449 3999f13e

+67 -179
+4 -3
README.md
··· 134 134 ### SQLite adapter (Node.js / local dev) 135 135 136 136 ```ts 137 - import { SqliteDatabase } from "@atmo-dev/contrail/sqlite"; 138 - import Database from "better-sqlite3"; 137 + import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; 139 138 140 - const db = new SqliteDatabase(new Database("data.db")); 139 + const db = createSqliteDatabase("data.db"); 141 140 const contrail = new Contrail({ ...config, db }); 142 141 ``` 142 + 143 + > **Note:** The SQLite adapter uses Node's built-in `node:sqlite` (Node 22+). Full-text search (`searchable`) is not supported with this adapter because `node:sqlite` doesn't include the FTS5 extension. Search works on Cloudflare D1, which has FTS5 enabled. 143 144 144 145 ## Running the example (Cloudflare Workers) 145 146
-2
package.json
··· 64 64 "@atcute/lex-cli": "^2.5.3", 65 65 "@atcute/lexicon-doc": "^2.1.2", 66 66 "@cloudflare/workers-types": "^4.20250124.0", 67 - "@types/better-sqlite3": "^7.6.13", 68 67 "@types/node": "^25.5.0", 69 - "better-sqlite3": "^12.8.0", 70 68 "@changesets/cli": "^2.29.4", 71 69 "tsup": "^8.5.0", 72 70 "tsx": "^4.21.0",
-15
pnpm-lock.yaml
··· 39 39 '@cloudflare/workers-types': 40 40 specifier: ^4.20250124.0 41 41 version: 4.20260205.0 42 - '@types/better-sqlite3': 43 - specifier: ^7.6.13 44 - version: 7.6.13 45 42 '@types/node': 46 43 specifier: ^25.5.0 47 44 version: 25.5.0 48 - better-sqlite3: 49 - specifier: ^12.8.0 50 - version: 12.8.0 51 45 tsup: 52 46 specifier: ^8.5.0 53 47 version: 8.5.1(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3) ··· 995 989 996 990 better-sqlite3@11.10.0: 997 991 resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==, tarball: https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz} 998 - 999 - better-sqlite3@12.8.0: 1000 - resolution: {integrity: sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==} 1001 - engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} 1002 992 1003 993 bindings@1.5.0: 1004 994 resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} ··· 2708 2698 is-windows: 1.0.2 2709 2699 2710 2700 better-sqlite3@11.10.0: 2711 - dependencies: 2712 - bindings: 1.5.0 2713 - prebuild-install: 7.1.3 2714 - 2715 - better-sqlite3@12.8.0: 2716 2701 dependencies: 2717 2702 bindings: 1.5.0 2718 2703 prebuild-install: 7.1.3
+1 -1
tsup.config.ts
··· 12 12 sourcemap: true, 13 13 clean: true, 14 14 tsconfig: "tsconfig.build.json", 15 - external: ["better-sqlite3"], 15 + external: ["node:sqlite"], 16 16 });
+49 -151
tests/search.test.ts
··· 5 5 import { initSchema } from "../src/core/db/schema"; 6 6 import { applyEvents, queryRecords } from "../src/core/db/records"; 7 7 8 + // Detect FTS5 support at module level (node:sqlite doesn't include it) 9 + let hasFts = false; 10 + try { 11 + const testDb = createTestDb(); 12 + await testDb.prepare("CREATE VIRTUAL TABLE __fts_test USING fts5(content)").run(); 13 + hasFts = true; 14 + } catch {} 15 + 8 16 const SEARCH_CONFIG = resolveConfig({ 9 17 namespace: "com.example", 10 18 collections: { ··· 23 31 body: {}, 24 32 category: {}, 25 33 }, 26 - searchable: ["title", "body"], // explicit: only title and body 34 + searchable: ["title", "body"], 27 35 }, 28 36 "test.disabled.collection": { 29 37 queryable: { 30 38 name: {}, 31 39 }, 32 - searchable: false, // disabled 40 + searchable: false, 33 41 }, 34 42 }, 35 43 }); ··· 37 45 let db: Database; 38 46 39 47 beforeEach(async () => { 48 + if (!hasFts) return; 40 49 db = createTestDb(); 41 50 await initSchema(db, SEARCH_CONFIG); 42 51 }); 43 52 44 - describe("FTS with explicit searchable fields", () => { 53 + describe.skipIf(!hasFts)("FTS with explicit searchable fields", () => { 45 54 const collection = "community.lexicon.calendar.event"; 46 55 47 56 beforeEach(async () => { ··· 78 87 }); 79 88 80 89 it("finds records matching a search term", async () => { 81 - const result = await queryRecords(db, SEARCH_CONFIG, { 82 - collection, 83 - search: "Rust", 84 - }); 90 + const result = await queryRecords(db, SEARCH_CONFIG, { collection, search: "Rust" }); 85 91 expect(result.records).toHaveLength(2); 86 92 const names = result.records.map((r) => JSON.parse(r.record!).name); 87 93 expect(names).toContain("Rust Meetup"); ··· 89 95 }); 90 96 91 97 it("searches across multiple fields", async () => { 92 - const result = await queryRecords(db, SEARCH_CONFIG, { 93 - collection, 94 - search: "Rustaceans", 95 - }); 98 + const result = await queryRecords(db, SEARCH_CONFIG, { collection, search: "Rustaceans" }); 96 99 expect(result.records).toHaveLength(1); 97 100 expect(JSON.parse(result.records[0].record!).name).toBe("Rust Meetup"); 98 101 }); 99 102 100 103 it("returns nothing for non-matching search", async () => { 101 - const result = await queryRecords(db, SEARCH_CONFIG, { 102 - collection, 103 - search: "Python", 104 - }); 104 + const result = await queryRecords(db, SEARCH_CONFIG, { collection, search: "Python" }); 105 105 expect(result.records).toHaveLength(0); 106 106 }); 107 107 108 108 it("supports prefix search", async () => { 109 - const result = await queryRecords(db, SEARCH_CONFIG, { 110 - collection, 111 - search: "Type*", 112 - }); 109 + const result = await queryRecords(db, SEARCH_CONFIG, { collection, search: "Type*" }); 113 110 expect(result.records).toHaveLength(2); 114 111 }); 115 112 116 113 it("combines search with filters", async () => { 117 - const result = await queryRecords(db, SEARCH_CONFIG, { 118 - collection, 119 - search: "Rust", 120 - filters: { mode: "in-person" }, 121 - }); 114 + const result = await queryRecords(db, SEARCH_CONFIG, { collection, search: "Rust", filters: { mode: "in-person" } }); 122 115 expect(result.records).toHaveLength(1); 123 116 expect(JSON.parse(result.records[0].record!).name).toBe("Rust Meetup"); 124 117 }); 125 118 126 119 it("does not search range fields (startsAt)", async () => { 127 - // startsAt is range, so not included in FTS. Searching for its value should not match. 128 120 await applyEvents( 129 121 db, 130 122 [ ··· 139 131 ], 140 132 SEARCH_CONFIG 141 133 ); 142 - // "T10" would appear in the startsAt value but not in any searchable field 143 - const result = await queryRecords(db, SEARCH_CONFIG, { 144 - collection, 145 - search: "T10", 146 - }); 134 + const result = await queryRecords(db, SEARCH_CONFIG, { collection, search: "T10" }); 147 135 expect(result.records).toHaveLength(0); 148 136 }); 149 137 }); 150 138 151 - describe("FTS sync", () => { 139 + describe.skipIf(!hasFts)("FTS sync", () => { 152 140 const collection = "community.lexicon.calendar.event"; 153 141 154 142 it("updates FTS on record update", async () => { 155 - await applyEvents( 156 - db, 157 - [ 158 - makeEvent({ 159 - uri: "at://did:plc:a/community.lexicon.calendar.event/1", 160 - collection, 161 - rkey: "1", 162 - record: { name: "Old Name", mode: "online", description: "test" }, 163 - time_us: 1000, 164 - }), 165 - ], 166 - SEARCH_CONFIG 167 - ); 168 - 169 - // Update the record 170 - await applyEvents( 171 - db, 172 - [ 173 - makeEvent({ 174 - uri: "at://did:plc:a/community.lexicon.calendar.event/1", 175 - collection, 176 - rkey: "1", 177 - record: { name: "New Name", mode: "online", description: "test" }, 178 - operation: "update", 179 - time_us: 2000, 180 - }), 181 - ], 182 - SEARCH_CONFIG 183 - ); 184 - 185 - const oldResult = await queryRecords(db, SEARCH_CONFIG, { collection, search: "Old" }); 186 - expect(oldResult.records).toHaveLength(0); 187 - 188 - const newResult = await queryRecords(db, SEARCH_CONFIG, { collection, search: "New" }); 189 - expect(newResult.records).toHaveLength(1); 143 + await applyEvents(db, [ 144 + makeEvent({ uri: "at://did:plc:a/community.lexicon.calendar.event/1", collection, rkey: "1", record: { name: "Old Name", mode: "online", description: "test" }, time_us: 1000 }), 145 + ], SEARCH_CONFIG); 146 + await applyEvents(db, [ 147 + makeEvent({ uri: "at://did:plc:a/community.lexicon.calendar.event/1", collection, rkey: "1", record: { name: "New Name", mode: "online", description: "test" }, operation: "update", time_us: 2000 }), 148 + ], SEARCH_CONFIG); 149 + expect((await queryRecords(db, SEARCH_CONFIG, { collection, search: "Old" })).records).toHaveLength(0); 150 + expect((await queryRecords(db, SEARCH_CONFIG, { collection, search: "New" })).records).toHaveLength(1); 190 151 }); 191 152 192 153 it("removes from FTS on delete", async () => { 193 - await applyEvents( 194 - db, 195 - [ 196 - makeEvent({ 197 - uri: "at://did:plc:a/community.lexicon.calendar.event/1", 198 - collection, 199 - rkey: "1", 200 - record: { name: "Deletable", mode: "online", description: "test" }, 201 - time_us: 1000, 202 - }), 203 - ], 204 - SEARCH_CONFIG 205 - ); 206 - 207 - await applyEvents( 208 - db, 209 - [ 210 - makeEvent({ 211 - uri: "at://did:plc:a/community.lexicon.calendar.event/1", 212 - collection, 213 - rkey: "1", 214 - operation: "delete", 215 - record: { name: "Deletable", mode: "online", description: "test" }, 216 - time_us: 2000, 217 - }), 218 - ], 219 - SEARCH_CONFIG 220 - ); 221 - 222 - const result = await queryRecords(db, SEARCH_CONFIG, { collection, search: "Deletable" }); 223 - expect(result.records).toHaveLength(0); 154 + await applyEvents(db, [ 155 + makeEvent({ uri: "at://did:plc:a/community.lexicon.calendar.event/1", collection, rkey: "1", record: { name: "Deletable", mode: "online", description: "test" }, time_us: 1000 }), 156 + ], SEARCH_CONFIG); 157 + await applyEvents(db, [ 158 + makeEvent({ uri: "at://did:plc:a/community.lexicon.calendar.event/1", collection, rkey: "1", operation: "delete", record: { name: "Deletable", mode: "online", description: "test" }, time_us: 2000 }), 159 + ], SEARCH_CONFIG); 160 + expect((await queryRecords(db, SEARCH_CONFIG, { collection, search: "Deletable" })).records).toHaveLength(0); 224 161 }); 225 162 }); 226 163 227 - describe("explicit searchable fields", () => { 164 + describe.skipIf(!hasFts)("explicit searchable fields", () => { 228 165 const collection = "test.explicit.collection"; 229 166 230 167 beforeEach(async () => { 231 - await applyEvents( 232 - db, 233 - [ 234 - makeEvent({ 235 - uri: "at://did:plc:a/test.explicit.collection/1", 236 - did: "did:plc:a", 237 - collection, 238 - rkey: "1", 239 - record: { title: "Interesting Article", body: "Some content here", category: "tech" }, 240 - time_us: 1000, 241 - }), 242 - ], 243 - SEARCH_CONFIG 244 - ); 168 + await applyEvents(db, [ 169 + makeEvent({ uri: "at://did:plc:a/test.explicit.collection/1", did: "did:plc:a", collection, rkey: "1", record: { title: "Interesting Article", body: "Some content here", category: "tech" }, time_us: 1000 }), 170 + ], SEARCH_CONFIG); 245 171 }); 246 172 247 173 it("searches in explicitly listed fields", async () => { 248 - const result = await queryRecords(db, SEARCH_CONFIG, { collection, search: "Interesting" }); 249 - expect(result.records).toHaveLength(1); 250 - 251 - const result2 = await queryRecords(db, SEARCH_CONFIG, { collection, search: "content" }); 252 - expect(result2.records).toHaveLength(1); 174 + expect((await queryRecords(db, SEARCH_CONFIG, { collection, search: "Interesting" })).records).toHaveLength(1); 175 + expect((await queryRecords(db, SEARCH_CONFIG, { collection, search: "content" })).records).toHaveLength(1); 253 176 }); 254 177 255 178 it("does not search non-listed fields", async () => { 256 - // "tech" is in category, which is not in searchable 257 - const result = await queryRecords(db, SEARCH_CONFIG, { collection, search: "tech" }); 258 - expect(result.records).toHaveLength(0); 179 + expect((await queryRecords(db, SEARCH_CONFIG, { collection, search: "tech" })).records).toHaveLength(0); 259 180 }); 260 181 }); 261 182 262 - describe("searchable: false", () => { 183 + describe.skipIf(!hasFts)("searchable: false", () => { 263 184 const collection = "test.disabled.collection"; 264 185 265 186 it("search param is ignored when FTS is disabled", async () => { 266 - await applyEvents( 267 - db, 268 - [ 269 - makeEvent({ 270 - uri: "at://did:plc:a/test.disabled.collection/1", 271 - did: "did:plc:a", 272 - collection, 273 - rkey: "1", 274 - record: { name: "Should Not Be Searchable" }, 275 - time_us: 1000, 276 - }), 277 - ], 278 - SEARCH_CONFIG 279 - ); 280 - 281 - // Search is a no-op — returns all records (no FTS join) 187 + await applyEvents(db, [ 188 + makeEvent({ uri: "at://did:plc:a/test.disabled.collection/1", did: "did:plc:a", collection, rkey: "1", record: { name: "Should Not Be Searchable" }, time_us: 1000 }), 189 + ], SEARCH_CONFIG); 282 190 const result = await queryRecords(db, SEARCH_CONFIG, { collection, search: "Searchable" }); 283 - expect(result.records).toHaveLength(1); // returned because no FTS filtering applied 191 + expect(result.records).toHaveLength(1); 284 192 }); 285 193 }); 286 194 287 - describe("search pagination", () => { 195 + describe.skipIf(!hasFts)("search pagination", () => { 288 196 const collection = "community.lexicon.calendar.event"; 289 197 290 198 beforeEach(async () => { ··· 302 210 }); 303 211 304 212 it("paginates search results", async () => { 305 - const page1 = await queryRecords(db, SEARCH_CONFIG, { 306 - collection, 307 - search: "Rust", 308 - limit: 3, 309 - }); 213 + const page1 = await queryRecords(db, SEARCH_CONFIG, { collection, search: "Rust", limit: 3 }); 310 214 expect(page1.records).toHaveLength(3); 311 215 expect(page1.cursor).toBeDefined(); 312 - 313 - const page2 = await queryRecords(db, SEARCH_CONFIG, { 314 - collection, 315 - search: "Rust", 316 - limit: 3, 317 - cursor: page1.cursor, 318 - }); 216 + const page2 = await queryRecords(db, SEARCH_CONFIG, { collection, search: "Rust", limit: 3, cursor: page1.cursor }); 319 217 expect(page2.records).toHaveLength(2); 320 218 expect(page2.cursor).toBeUndefined(); 321 219 });
-3
.github/workflows/release.yml
··· 32 32 - name: Install dependencies 33 33 run: pnpm install 34 34 35 - - name: Rebuild native modules 36 - run: pnpm rebuild better-sqlite3 37 - 38 35 - name: Build 39 36 run: pnpm build 40 37
+3 -3
src/adapters/sqlite.ts
··· 1 - import BetterSqlite3 from "better-sqlite3"; 1 + import { DatabaseSync } from "node:sqlite"; 2 2 import type { Database, Statement } from "../core/types"; 3 3 4 4 export function createSqliteDatabase(path: string): Database { 5 - const raw = new BetterSqlite3(path); 6 - raw.pragma("journal_mode = WAL"); 5 + const raw = new DatabaseSync(path); 6 + raw.exec("PRAGMA journal_mode = WAL"); 7 7 8 8 function wrapStatement(sql: string, boundValues: any[] = []): Statement { 9 9 return {
+10 -1
src/core/db/schema.ts
··· 207 207 const indexStatements = buildDynamicIndexes(config); 208 208 const ftsStatements = buildFtsTables(config); 209 209 const feedStatements = buildFeedTables(config); 210 - const all = [...baseStatements, ...collectionStatements, ...indexStatements, ...ftsStatements, ...feedStatements]; 210 + const all = [...baseStatements, ...collectionStatements, ...indexStatements, ...feedStatements]; 211 211 212 212 await db.batch(all.map((s) => db.prepare(s))); 213 + 214 + // FTS5 may not be available (e.g. node:sqlite) — skip gracefully 215 + for (const stmt of ftsStatements) { 216 + try { 217 + await db.prepare(stmt).run(); 218 + } catch { 219 + // FTS5 not supported in this environment 220 + } 221 + } 213 222 await runMigrations(db); 214 223 215 224 // Add count columns (ALTER TABLE — may already exist)