browse the protocol like its 2008 ibex.desertthunder.dev
ubuntu atproto svelte
7

Configure Feed

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

feat: replace pglite with dexie

Owais Jamil (Jun 9, 2026, 2:43 PM -0500) ee1de4a3 bab2c1c0

+371 -449
+57 -32
TODO.md
··· 1 - # Intrepid Ibex To-Do's 1 + # Intrepid Ibex To-Dos 2 + 3 + v1 is a static, public, read-only ATProto browser deployed to Cloudflare Pages. 4 + It should feel like Ubuntu 8.10 / GNOME 2, run entirely in the browser, and require 5 + no authentication or server. 6 + 7 + ## Routing 8 + 9 + - Add shareable record routes: 10 + - `/records/:did/:collection/:rkey` 11 + - Use DIDs, not handles, as canonical URL identity because DIDs are stable. 12 + - When opening a record route directly, boot the full desktop shell, select the 13 + repo/collection in Nautilus, and open the record in gedit. 14 + - Fetch direct record links client-side with public ATProto, likely 15 + `com.atproto.repo.getRecord`. 16 + - Opportunistically hydrate public identity metadata for nicer labels: handle, 17 + display name, avatar, DID, and PDS. 18 + 19 + ## Browser 2 20 3 - This is a parking lot of ideas and tasks for the project. 21 + - Preserve the first-run public handle setup flow for browsing a repo. 22 + - Add handle typeahead to setup using `app.bsky.actor.searchActorsTypeahead`. 23 + - Keep repository collection browsing in Nautilus. 24 + - Keep cursor-based pagination / “Load more records” for large collections. 25 + - Keep public record JSON viewing in gedit. 26 + - Keep local record caching and full-text search across cached records. 27 + - Keep user-readable network and cache errors. 4 28 5 - ## Ideas 29 + ## Apps 6 30 7 31 Every new surface should feel like a GNOME 2 app that happens to speak `at://`. 8 32 9 - ### Persistence 10 - 11 - - Dexie or PGLite to make records searchable 12 - - Cache fetched collections and records in IndexedDB so the app works offline 13 - - Sync indicator in the panel showing last-synced time per account 14 - - Starred / bookmarked records stored locally and surfaced as a "Bookmarks" 15 - sidebar item in Nautilus 16 - - Full-text search across locally cached records (Dexie `liveQuery` + a simple 17 - input in the toolbar) 18 - - Multi-account support: store multiple `AccountIdentity` rows and let the user 19 - switch between them from the panel 33 + - **Nautilus** 34 + - Browse public repo collections. 35 + - Select collections and records. 36 + - Open records in gedit or media blobs in Eye of GNOME. 37 + - Surface cached/search states clearly. 38 + - **gedit** 39 + - Read-only JSON viewer for records. 40 + - Preserve copy, wrapping, syntax highlighting, and native GTK-style window behavior. 41 + - **Eye of GNOME (eog)** 42 + - Add image/blob viewing. 43 + - Open from Nautilus when a record contains embedded images or media blobs. 44 + - Support `app.bsky.feed.post` embedded images as the first target. 45 + - **About This Computer** 46 + - Show OS/theme info and current public repo identity metadata when available. 47 + - **Desktop shell** 48 + - Preserve brown panels, tan chrome, Humanity icons, small dense spacing, task buttons, 49 + tray affordances, and movable/resizable windows. 20 50 21 - ### New apps 51 + ## V2 22 52 23 53 - **gnome-terminal** 24 - - AT Protocol REPL: resolve handles, inspect DID documents, 25 - run raw `com.atproto.*` / `app.bsky.*` queries, and page through results as 26 - scrolling terminal output 27 - - **Eye of GNOME (eog)** 28 - - Image and video viewer that opens from Nautilus when a 29 - `app.bsky.feed.post` record contains embedded images or a video blob 54 + - AT Protocol REPL: resolve handles, inspect DID documents, run raw 55 + `com.atproto.*` / `app.bsky.*` queries, and page through results as 56 + scrolling terminal output. 30 57 - **Evolution / Thunderbird** 31 - - Conversation viewer for `chat.bsky.convo.*` 32 - records, presented as a classic mail client with an inbox list and a reading pane 58 + - Conversation viewer for `chat.bsky.convo.*` records, presented as a classic mail 59 + client with an inbox list and reading pane. 33 60 - **Contacts** 34 - - Follows / followers graph browser built on 35 - `app.bsky.graph.follow`; shows avatar, handle, and mutual-follow status in an 36 - address-book layout 61 + - Follows / followers graph browser built on `app.bsky.graph.follow`; show avatar, 62 + handle, and mutual-follow status in an address-book layout. 37 63 - **File Roller** 38 - - Export any collection to a `.json` archive or a CAR file for 39 - local backup; also import to preview records without a live PDS connection 40 - 41 - ### Infra 42 - 43 - - Cursor-based pagination in Nautilus 64 + - Export any collection to a `.json` archive or CAR file for local backup. 65 + - Import archives to preview records without a live PDS connection. 66 + - Local starred/bookmarked records surfaced as a “Bookmarks” sidebar item in Nautilus. 67 + - Sync indicator in the panel showing last-synced time per repo/collection. 68 + - Multi-repo switching from the panel without implying authentication.
+4 -2
package.json
··· 27 27 "eslint": "^10.4.0", 28 28 "eslint-config-prettier": "^10.1.8", 29 29 "eslint-plugin-svelte": "^3.17.0", 30 + "fake-indexeddb": "^6.2.5", 30 31 "globals": "^17.4.0", 31 32 "mdsvex": "^0.12.7", 32 33 "playwright": "^1.60.0", ··· 42 43 }, 43 44 "dependencies": { 44 45 "@atcute/atproto": "^4.0.2", 45 - "@atcute/bluesky": "^4.0.6", 46 + "@atcute/bluesky": "^4.0.7", 46 47 "@atcute/client": "^5.0.0", 47 48 "@atcute/lexicons": "^2.0.0", 48 - "@electric-sql/pglite": "^0.5.1", 49 49 "@shikijs/core": "^4.2.0", 50 50 "@shikijs/engine-javascript": "^4.2.0", 51 51 "@shikijs/langs": "^4.2.0", 52 + "dexie": "^4.4.3", 53 + "flexsearch": "^0.8.212", 52 54 "shiki": "^4.2.0" 53 55 } 54 56 }
+30 -13
pnpm-lock.yaml
··· 12 12 specifier: ^4.0.2 13 13 version: 4.0.2(@atcute/lexicons@2.0.0) 14 14 '@atcute/bluesky': 15 - specifier: ^4.0.6 16 - version: 4.0.6(@atcute/lexicons@2.0.0) 15 + specifier: ^4.0.7 16 + version: 4.0.7(@atcute/lexicons@2.0.0) 17 17 '@atcute/client': 18 18 specifier: ^5.0.0 19 19 version: 5.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3) 20 20 '@atcute/lexicons': 21 21 specifier: ^2.0.0 22 22 version: 2.0.0 23 - '@electric-sql/pglite': 24 - specifier: ^0.5.1 25 - version: 0.5.1 26 23 '@shikijs/core': 27 24 specifier: ^4.2.0 28 25 version: 4.2.0 ··· 32 29 '@shikijs/langs': 33 30 specifier: ^4.2.0 34 31 version: 4.2.0 32 + dexie: 33 + specifier: ^4.4.3 34 + version: 4.4.3 35 + flexsearch: 36 + specifier: ^0.8.212 37 + version: 0.8.212 35 38 shiki: 36 39 specifier: ^4.2.0 37 40 version: 4.2.0 ··· 69 72 eslint-plugin-svelte: 70 73 specifier: ^3.17.0 71 74 version: 3.19.0(eslint@10.4.1)(svelte@5.56.3(@typescript-eslint/types@8.61.0)) 75 + fake-indexeddb: 76 + specifier: ^6.2.5 77 + version: 6.2.5 72 78 globals: 73 79 specifier: ^17.4.0 74 80 version: 17.6.0 ··· 113 119 peerDependencies: 114 120 '@atcute/lexicons': ^2.0.0 115 121 116 - '@atcute/bluesky@4.0.6': 117 - resolution: {integrity: sha512-h0q6cSkil85MpISJ958fFbEhMWBaiujEwOSmgF7iz+Y8ZeZGHycOD2mjXaQ1CnpHb3yWul6nn8rBwRRrGE3RsA==} 122 + '@atcute/bluesky@4.0.7': 123 + resolution: {integrity: sha512-0bcqYvfRfFpBa9gHxHkA/s8bwCU9WUNk649T/yQ4DVHA/Z6Swf3AZM9zVhuJoXZ7Ng0BsXCA5VV4S/ZZOnMBWw==} 118 124 peerDependencies: 119 125 '@atcute/lexicons': ^2.0.0 120 126 ··· 140 146 '@blazediff/core@1.9.1': 141 147 resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} 142 148 143 - '@electric-sql/pglite@0.5.1': 144 - resolution: {integrity: sha512-h2Vc+qkQqsEL5kvyN5nBAxn3Vbyvka7QfDW7Io+CdcwU1+X8JbCAN2og+5dI11S3eJuDfroUCxzJaap6k+ezEw==} 145 - 146 149 '@emnapi/core@1.10.0': 147 150 resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} 148 151 ··· 664 667 devlop@1.1.0: 665 668 resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} 666 669 670 + dexie@4.4.3: 671 + resolution: {integrity: sha512-N+3IGQ3HPlyO2YAkntGAwitm42BpBGV86MttzUMiRzWLa4NGh0pltVRcUVF4ybL/OnXjCrr9k7SDPIKkFYP2Lg==} 672 + 667 673 es-module-lexer@2.1.0: 668 674 resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} 669 675 ··· 759 765 resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} 760 766 engines: {node: '>=12.0.0'} 761 767 768 + fake-indexeddb@6.2.5: 769 + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} 770 + engines: {node: '>=18'} 771 + 762 772 fast-deep-equal@3.1.3: 763 773 resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} 764 774 ··· 791 801 792 802 flatted@3.4.2: 793 803 resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} 804 + 805 + flexsearch@0.8.212: 806 + resolution: {integrity: sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==} 794 807 795 808 fsevents@2.3.2: 796 809 resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} ··· 1454 1467 dependencies: 1455 1468 '@atcute/lexicons': 2.0.0 1456 1469 1457 - '@atcute/bluesky@4.0.6(@atcute/lexicons@2.0.0)': 1470 + '@atcute/bluesky@4.0.7(@atcute/lexicons@2.0.0)': 1458 1471 dependencies: 1459 1472 '@atcute/atproto': 4.0.2(@atcute/lexicons@2.0.0) 1460 1473 '@atcute/lexicons': 2.0.0 ··· 1487 1500 unicode-segmenter: 0.14.5 1488 1501 1489 1502 '@blazediff/core@1.9.1': {} 1490 - 1491 - '@electric-sql/pglite@0.5.1': {} 1492 1503 1493 1504 '@emnapi/core@1.10.0': 1494 1505 dependencies: ··· 1999 2010 dependencies: 2000 2011 dequal: 2.0.3 2001 2012 2013 + dexie@4.4.3: {} 2014 + 2002 2015 es-module-lexer@2.1.0: {} 2003 2016 2004 2017 escape-string-regexp@4.0.0: {} ··· 2116 2129 2117 2130 expect-type@1.3.0: {} 2118 2131 2132 + fake-indexeddb@6.2.5: {} 2133 + 2119 2134 fast-deep-equal@3.1.3: {} 2120 2135 2121 2136 fast-json-stable-stringify@2.1.0: {} ··· 2141 2156 keyv: 4.5.4 2142 2157 2143 2158 flatted@3.4.2: {} 2159 + 2160 + flexsearch@0.8.212: {} 2144 2161 2145 2162 fsevents@2.3.2: 2146 2163 optional: true
+3
src/lib/components/SetupDialog.svelte
··· 52 52 <form class="handle-form" onsubmit={(event) => event.preventDefault()}> 53 53 <label for="handle">Handle</label> 54 54 <div class="handle-row"> 55 + <!-- TODO: setup typeahead 56 + https://docs.bsky.app/docs/api/app-bsky-actor-search-actors-typeahead 57 + --> 55 58 <input id="handle" bind:value={handle} placeholder="desertthunder.dev" autocomplete="username" /> 56 59 <button type="button" onclick={resolveHandle} disabled={isResolving}> 57 60 {isResolving ? 'Resolving…' : 'Resolve'}
+4 -5
src/lib/db/client.svelte.test.ts
··· 1 1 import { afterEach, describe, expect, it } from 'vitest'; 2 2 import { closeDatabase, getDatabase, getDatabaseStartupReport } from './client'; 3 3 4 - describe('PGlite client', () => { 4 + describe('Dexie client', () => { 5 5 afterEach(async () => { 6 6 await closeDatabase(); 7 7 }); 8 8 9 - it('loads the WASM bundle and answers a query in the browser', async () => { 9 + it('opens IndexedDB and exposes cache stores in the browser', async () => { 10 10 expect.assertions(3); 11 11 12 12 const db = await getDatabase(); 13 - const result = await db.query<{ ok: number }>('select 1::int as ok'); 14 13 const report = getDatabaseStartupReport(); 15 14 16 - expect(result.rows[0]?.ok).toBe(1); 15 + expect(db.cachedRecords).toBeDefined(); 17 16 expect(report.status).toBe('ready'); 18 - expect(report.wasm).toBe('loaded'); 17 + expect(report.indexedDb).toBe('available'); 19 18 }); 20 19 });
+73 -26
src/lib/db/client.ts
··· 1 - import { PGlite } from '@electric-sql/pglite'; 1 + import Dexie, { type EntityTable } from 'dexie'; 2 2 import { errorMessage } from '$lib/utils/errors'; 3 3 import { resetLocalCacheDatabase } from './migrations'; 4 - import { DB_DATA_DIR, SQL } from './schema'; 4 + import { DB_NAME } from './schema'; 5 + 6 + export type SyncStatus = 'fresh' | 'stale' | 'deleted'; 7 + 8 + export type DbAccount = { 9 + did: string; 10 + handle: string; 11 + pds: string | null; 12 + label: string | null; 13 + createdAt: string; 14 + updatedAt: string; 15 + }; 16 + 17 + export type DbCachedRecord = { 18 + id?: number; 19 + accountDid: string; 20 + repoDid: string; 21 + collection: string; 22 + rkey: string; 23 + uri: string; 24 + cid: string; 25 + value: unknown; 26 + indexedText: string; 27 + createdAt: string | null; 28 + indexedAt: string | null; 29 + updatedAt: string | null; 30 + storedAt: string; 31 + sortKey: string; 32 + syncStatus: SyncStatus; 33 + }; 34 + 35 + export type DbCollectionSyncState = { 36 + key: string; 37 + accountDid: string; 38 + repoDid: string; 39 + collection: string; 40 + cursor: string | null; 41 + lastSyncedAt: string | null; 42 + lastError: string | null; 43 + updatedAt: string; 44 + }; 45 + 46 + export class IntrepidIbexDb extends Dexie { 47 + accounts!: EntityTable<DbAccount, 'did'>; 48 + cachedRecords!: EntityTable<DbCachedRecord, 'id'>; 49 + collectionSyncState!: EntityTable<DbCollectionSyncState, 'key'>; 50 + 51 + constructor(name = DB_NAME) { 52 + super(name); 53 + 54 + this.version(1).stores({ 55 + accounts: '&did, handle', 56 + cachedRecords: 57 + '++id, &uri, &[repoDid+collection+rkey], repoDid, collection, [repoDid+collection], [repoDid+collection+sortKey]', 58 + collectionSyncState: '&key, [accountDid+repoDid], [accountDid+repoDid+collection]' 59 + }); 60 + } 61 + } 5 62 6 - export type DbClient = PGlite; 63 + export type DbClient = IntrepidIbexDb; 7 64 8 65 export type DatabaseStartupReport = { 9 66 status: 'idle' | 'loading' | 'ready' | 'error'; 10 67 dataDir: string | null; 11 68 startedAt: string | null; 12 69 finishedAt: string | null; 13 - wasm: 'not_checked' | 'loaded' | 'error'; 70 + driver: 'not_checked' | 'loaded' | 'error'; 14 71 indexedDb: 'not_used' | 'available' | 'unavailable'; 15 72 persisted: boolean | null; 16 73 error: string | null; ··· 34 91 35 92 export async function closeDatabase(): Promise<void> { 36 93 if (database) { 37 - await database.close(); 94 + database.close(); 38 95 } 39 96 40 97 database = null; ··· 52 109 } 53 110 54 111 async function initializeDatabase(): Promise<DbClient> { 55 - const dataDir = getDefaultDataDir(); 56 112 startupReport = { 57 113 status: 'loading', 58 - dataDir: dataDir ?? null, 114 + dataDir: DB_NAME, 59 115 startedAt: new Date().toISOString(), 60 116 finishedAt: null, 61 - wasm: 'not_checked', 62 - indexedDb: getIndexedDbStatus(dataDir), 117 + driver: 'not_checked', 118 + indexedDb: getIndexedDbStatus(), 63 119 persisted: null, 64 120 error: null 65 121 }; 66 122 67 123 try { 68 - const client = await PGlite.create(dataDir); 69 - await client.query(SQL.ping); 124 + const client = new IntrepidIbexDb(); 125 + await client.open(); 70 126 71 127 database = client; 72 128 startupReport = { 73 129 ...startupReport, 74 130 status: 'ready', 75 131 finishedAt: new Date().toISOString(), 76 - wasm: 'loaded', 77 - persisted: dataDir?.startsWith('idb://') === true && startupReport.indexedDb === 'available' 132 + driver: 'loaded', 133 + persisted: startupReport.indexedDb === 'available' 78 134 }; 79 135 80 136 return client; ··· 83 139 ...startupReport, 84 140 status: 'error', 85 141 finishedAt: new Date().toISOString(), 86 - wasm: 'error', 142 + driver: 'error', 87 143 error: errorMessage(error) 88 144 }; 89 145 databasePromise = null; ··· 91 147 } 92 148 } 93 149 94 - function getDefaultDataDir() { 95 - return isBrowser() ? DB_DATA_DIR : undefined; 96 - } 97 - 98 - function getIndexedDbStatus(dataDir: string | undefined): DatabaseStartupReport['indexedDb'] { 99 - if (!dataDir?.startsWith('idb://')) return 'not_used'; 100 - return isBrowser() && 'indexedDB' in globalThis ? 'available' : 'unavailable'; 101 - } 102 - 103 - function isBrowser() { 104 - return typeof globalThis.window !== 'undefined'; 150 + function getIndexedDbStatus(): DatabaseStartupReport['indexedDb'] { 151 + return typeof globalThis.indexedDB === 'undefined' ? 'unavailable' : 'available'; 105 152 } 106 153 107 154 function createIdleReport(): DatabaseStartupReport { ··· 110 157 dataDir: null, 111 158 startedAt: null, 112 159 finishedAt: null, 113 - wasm: 'not_checked', 160 + driver: 'not_checked', 114 161 indexedDb: 'not_used', 115 162 persisted: null, 116 163 error: null
+15 -20
src/lib/db/migrations.test.ts
··· 1 - import { PGlite } from '@electric-sql/pglite'; 1 + import 'fake-indexeddb/auto'; 2 2 import { afterEach, describe, expect, it } from 'vitest'; 3 + import { IntrepidIbexDb } from './client'; 3 4 import { getMigrationStatus, runMigrations } from './migrations'; 4 5 import { TABLES } from './schema'; 5 6 6 - const clients: PGlite[] = []; 7 + const clients: IntrepidIbexDb[] = []; 7 8 8 9 async function createDb() { 9 - const db = await PGlite.create(); 10 + const db = new IntrepidIbexDb(`test-migrations-${crypto.randomUUID()}`); 10 11 clients.push(db); 11 12 return db; 12 13 } 13 14 14 15 afterEach(async () => { 15 - await Promise.all(clients.splice(0).map((db) => db.close())); 16 + for (const db of clients.splice(0)) { 17 + await db.delete(); 18 + } 16 19 }); 17 20 18 21 describe('migrations', () => { 19 - it('applies the initial schema on a fresh database', async () => { 22 + it('opens the Dexie schema on a fresh database', async () => { 20 23 expect.assertions(3); 21 24 const db = await createDb(); 22 25 23 26 const status = await runMigrations(db); 24 - const tables = await db.query<{ table_name: string }>( 25 - `select table_name from information_schema.tables where table_schema = 'public' order by table_name` 26 - ); 27 + const storeNames = db.tables.map((table) => table.name).sort(); 27 28 28 29 expect(status.pending).toHaveLength(0); 29 - expect(status.currentVersion).toBe('202606090001_initial_cache_schema'); 30 - expect(tables.rows.map((row) => row.table_name)).toEqual( 31 - expect.arrayContaining([ 32 - TABLES.migrations, 33 - TABLES.accounts, 34 - TABLES.cachedRecords, 35 - TABLES.collectionSyncState, 36 - TABLES.recordSearch 37 - ]) 30 + expect(status.currentVersion).toBe('202606090001_dexie_cache_schema'); 31 + expect(storeNames).toEqual( 32 + expect.arrayContaining([TABLES.accounts, TABLES.cachedRecords, TABLES.collectionSyncState]) 38 33 ); 39 34 }); 40 35 ··· 49 44 expect(status.applied).toHaveLength(1); 50 45 }); 51 46 52 - it('reports pending migrations before the runner is called', async () => { 47 + it('reports no pending migrations because Dexie applies versions during open', async () => { 53 48 expect.assertions(2); 54 49 const db = await createDb(); 55 50 56 51 const status = await getMigrationStatus(db); 57 52 58 - expect(status.applied).toHaveLength(0); 59 - expect(status.pending.map((migration) => migration.id)).toEqual(['202606090001_initial_cache_schema']); 53 + expect(status.applied).toHaveLength(1); 54 + expect(status.pending).toHaveLength(0); 60 55 }); 61 56 });
+19 -144
src/lib/db/migrations.ts
··· 1 + import Dexie from 'dexie'; 1 2 import type { DbClient } from './client'; 2 - import { DB_DATA_DIR, INDEXES, TABLES } from './schema'; 3 + import { DB_NAME } from './schema'; 3 4 4 - export type MigrationConnection = Pick<DbClient, 'exec' | 'query'>; 5 - 6 - export type Migration = { id: string; description: string; up: (db: MigrationConnection) => Promise<void> }; 5 + export type Migration = { id: string; description: string }; 7 6 8 7 export type AppliedMigration = { version: string; appliedAt: string }; 9 8 ··· 13 12 currentVersion: string | null; 14 13 }; 15 14 16 - export const migrations: readonly Migration[] = validateMigrations([ 15 + export const migrations: readonly Migration[] = [ 17 16 { 18 - id: '202606090001_initial_cache_schema', 19 - description: 'Create local cache tables and record search index.', 20 - up: async (db) => { 21 - await db.exec(` 22 - create table ${TABLES.accounts} ( 23 - did text primary key, 24 - handle text not null, 25 - pds text, 26 - label text, 27 - created_at text not null default (timezone('utc', now())::text), 28 - updated_at text not null default (timezone('utc', now())::text) 29 - ); 30 - 31 - create table ${TABLES.cachedRecords} ( 32 - id bigserial primary key, 33 - account_did text not null, 34 - repo_did text not null, 35 - collection text not null, 36 - rkey text not null, 37 - uri text not null unique, 38 - cid text not null, 39 - raw_json jsonb not null, 40 - indexed_text text not null default '', 41 - created_at text, 42 - indexed_at text, 43 - updated_at text, 44 - stored_at text not null default (timezone('utc', now())::text), 45 - sync_status text not null default 'fresh', 46 - unique (repo_did, collection, rkey) 47 - ); 48 - 49 - create table ${TABLES.collectionSyncState} ( 50 - account_did text not null, 51 - repo_did text not null, 52 - collection text not null, 53 - cursor text, 54 - last_synced_at text, 55 - last_error text, 56 - updated_at text not null default (timezone('utc', now())::text), 57 - primary key (account_did, repo_did, collection) 58 - ); 59 - 60 - create table ${TABLES.recordSearch} ( 61 - record_uri text primary key references ${TABLES.cachedRecords}(uri) on delete cascade, 62 - repo_did text not null, 63 - collection text not null, 64 - search_text text not null, 65 - search_vector tsvector generated always as (to_tsvector('simple', search_text)) stored 66 - ); 67 - 68 - create index ${INDEXES.cachedRecordsCollection} 69 - on ${TABLES.cachedRecords} (repo_did, collection, rkey); 70 - create index ${INDEXES.cachedRecordsUri} 71 - on ${TABLES.cachedRecords} (uri); 72 - create index ${INDEXES.cachedRecordsRecent} 73 - on ${TABLES.cachedRecords} (repo_did, collection, coalesce(created_at, indexed_at, updated_at, stored_at) desc); 74 - create index ${INDEXES.collectionSyncStateAccount} 75 - on ${TABLES.collectionSyncState} (account_did, repo_did); 76 - create index ${INDEXES.recordSearchText} 77 - on ${TABLES.recordSearch} using gin (search_vector); 78 - `); 79 - } 17 + id: '202606090001_dexie_cache_schema', 18 + description: 'Create local Dexie cache stores and indexes.' 80 19 } 81 - ]); 20 + ]; 82 21 83 22 export async function getMigrationStatus(db: DbClient): Promise<MigrationStatus> { 84 - await ensureMigrationsTable(db); 85 - const applied = await getAppliedMigrations(db); 86 - const appliedVersions = new Set(applied.map((migration) => migration.version)); 87 - 88 - return { 89 - pending: migrations.filter((migration) => !appliedVersions.has(migration.id)), 90 - applied, 91 - currentVersion: applied.at(-1)?.version ?? null 92 - }; 23 + await db.open(); 24 + return currentStatus(); 93 25 } 94 26 95 27 export async function runMigrations(db: DbClient): Promise<MigrationStatus> { 96 - await ensureMigrationsTable(db); 97 - const status = await getMigrationStatus(db); 98 - 99 - for (const migration of status.pending) { 100 - await db.transaction(async (tx) => { 101 - await migration.up(tx); 102 - await tx.query( 103 - `insert into ${TABLES.migrations} (version, applied_at) values ($1, timezone('utc', now())::text)`, 104 - [migration.id] 105 - ); 106 - }); 107 - } 108 - 109 - return getMigrationStatus(db); 28 + await db.open(); 29 + return currentStatus(); 110 30 } 111 31 112 32 export async function resetLocalCacheDatabase(): Promise<void> { ··· 114 34 throw new Error('Resetting the local cache is only available in development builds.'); 115 35 } 116 36 117 - if (typeof indexedDB === 'undefined') { 118 - return; 119 - } 120 - 121 - const databaseNames = new Set([DB_DATA_DIR, DB_DATA_DIR.replace(/^idb:\/\//, '')]); 122 - 123 - if ('databases' in indexedDB) { 124 - const databases = await indexedDB.databases(); 125 - for (const database of databases) { 126 - if (database.name?.includes('intrepid-ibex-cache')) { 127 - databaseNames.add(database.name); 128 - } 129 - } 130 - } 131 - 132 - await Promise.all([...databaseNames].map((name) => deleteIndexedDbDatabase(name))); 37 + await Dexie.delete(DB_NAME); 133 38 } 134 39 135 - async function ensureMigrationsTable(db: DbClient): Promise<void> { 136 - await db.exec(` 137 - create table if not exists ${TABLES.migrations} ( 138 - version text primary key, 139 - applied_at text not null 140 - ); 141 - `); 142 - } 40 + function currentStatus(): MigrationStatus { 41 + const version = migrations.at(-1)?.id ?? null; 143 42 144 - async function getAppliedMigrations(db: DbClient): Promise<AppliedMigration[]> { 145 - const result = await db.query<{ version: string; applied_at: string }>( 146 - `select version, applied_at from ${TABLES.migrations} order by version asc` 147 - ); 148 - 149 - return result.rows.map((row) => ({ version: row.version, appliedAt: row.applied_at })); 150 - } 151 - 152 - function validateMigrations(items: readonly Migration[]): readonly Migration[] { 153 - const sorted = [...items].sort((a, b) => a.id.localeCompare(b.id)); 154 - const ids = new Set<string>(); 155 - 156 - for (const migration of sorted) { 157 - if (ids.has(migration.id)) { 158 - throw new Error(`Duplicate migration id: ${migration.id}`); 159 - } 160 - ids.add(migration.id); 161 - } 162 - 163 - return sorted; 164 - } 165 - 166 - function deleteIndexedDbDatabase(name: string): Promise<void> { 167 - return new Promise((resolve, reject) => { 168 - const request = indexedDB.deleteDatabase(name); 169 - request.onsuccess = () => resolve(); 170 - request.onerror = () => reject(request.error ?? new Error(`Could not delete IndexedDB database ${name}.`)); 171 - request.onblocked = () => reject(new Error(`Could not delete IndexedDB database ${name} because it is open.`)); 172 - }); 43 + return { 44 + pending: [], 45 + applied: version ? [{ version, appliedAt: new Date().toISOString() }] : [], 46 + currentVersion: version 47 + }; 173 48 }
+7 -4
src/lib/db/repositories/records.test.ts
··· 1 - import { PGlite } from '@electric-sql/pglite'; 1 + import 'fake-indexeddb/auto'; 2 2 import { afterEach, describe, expect, it } from 'vitest'; 3 + import { IntrepidIbexDb } from '../client'; 3 4 import { runMigrations } from '../migrations'; 4 5 import { searchCachedRecords } from '../search'; 5 6 import { getCachedRecordByUri, listCachedRecords, upsertCachedRecord } from './records'; 6 7 7 - const clients: PGlite[] = []; 8 + const clients: IntrepidIbexDb[] = []; 8 9 9 10 async function createMigratedDb() { 10 - const db = await PGlite.create(); 11 + const db = new IntrepidIbexDb(`test-records-${crypto.randomUUID()}`); 11 12 clients.push(db); 12 13 await runMigrations(db); 13 14 return db; 14 15 } 15 16 16 17 afterEach(async () => { 17 - await Promise.all(clients.splice(0).map((db) => db.close())); 18 + for (const db of clients.splice(0)) { 19 + await db.delete(); 20 + } 18 21 }); 19 22 20 23 describe('cached records repository', () => {
+78 -123
src/lib/db/repositories/records.ts
··· 1 - import type { DbClient } from '../client'; 2 - import { TABLES } from '../schema'; 1 + import type { DbCachedRecord, DbClient, SyncStatus } from '../client'; 3 2 4 3 export type CachedRecordInput = { 5 4 accountDid: string; ··· 15 14 updatedAt?: string | null; 16 15 }; 17 16 18 - export type CachedRecord = CachedRecordInput & { storedAt: string; syncStatus: 'fresh' | 'stale' | 'deleted' }; 17 + export type CachedRecord = CachedRecordInput & { storedAt: string; syncStatus: SyncStatus }; 19 18 20 19 export type ListCachedRecordsOptions = { repoDid: string; collection?: string; limit?: number; offset?: number }; 21 20 22 21 export type CachedCollectionSummary = { name: string; loadedCount: number; lastStoredAt: string | null }; 23 22 24 - type CachedRecordRow = { 25 - account_did: string; 26 - repo_did: string; 27 - collection: string; 28 - rkey: string; 29 - uri: string; 30 - cid: string; 31 - raw_json: unknown; 32 - indexed_text: string; 33 - created_at: string | null; 34 - indexed_at: string | null; 35 - updated_at: string | null; 36 - stored_at: string; 37 - sync_status: CachedRecord['syncStatus']; 38 - }; 39 - 40 23 export async function upsertCachedRecord(db: DbClient, record: CachedRecordInput): Promise<void> { 41 - await db.transaction(async (tx) => { 42 - await tx.query( 43 - ` 44 - insert into ${TABLES.cachedRecords} ( 45 - account_did, repo_did, collection, rkey, uri, cid, raw_json, 46 - indexed_text, created_at, indexed_at, updated_at, stored_at, sync_status 47 - ) values ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, $11, timezone('utc', now())::text, 'fresh') 48 - on conflict (repo_did, collection, rkey) do update set 49 - account_did = excluded.account_did, 50 - uri = excluded.uri, 51 - cid = excluded.cid, 52 - raw_json = excluded.raw_json, 53 - indexed_text = excluded.indexed_text, 54 - created_at = excluded.created_at, 55 - indexed_at = excluded.indexed_at, 56 - updated_at = excluded.updated_at, 57 - stored_at = excluded.stored_at, 58 - sync_status = 'fresh' 59 - `, 60 - [ 61 - record.accountDid, 62 - record.repoDid, 63 - record.collection, 64 - record.rkey, 65 - record.uri, 66 - record.cid, 67 - JSON.stringify(record.value), 68 - record.indexedText ?? '', 69 - record.createdAt ?? null, 70 - record.indexedAt ?? null, 71 - record.updatedAt ?? null 72 - ] 73 - ); 74 - 75 - await tx.query( 76 - ` 77 - insert into ${TABLES.recordSearch} (record_uri, repo_did, collection, search_text) 78 - values ($1, $2, $3, $4) 79 - on conflict (record_uri) do update set 80 - repo_did = excluded.repo_did, 81 - collection = excluded.collection, 82 - search_text = excluded.search_text 83 - `, 84 - [record.uri, record.repoDid, record.collection, record.indexedText ?? ''] 85 - ); 24 + await db.transaction('rw', db.cachedRecords, async () => { 25 + await putCachedRecord(db, record); 86 26 }); 87 27 } 88 28 89 29 export async function upsertCachedRecords(db: DbClient, records: readonly CachedRecordInput[]): Promise<void> { 90 - for (const record of records) { 91 - await upsertCachedRecord(db, record); 92 - } 30 + await db.transaction('rw', db.cachedRecords, async () => { 31 + for (const record of records) { 32 + await putCachedRecord(db, record); 33 + } 34 + }); 93 35 } 94 36 95 37 export async function listCachedRecords(db: DbClient, options: ListCachedRecordsOptions): Promise<CachedRecord[]> { 96 38 const limit = Math.max(1, Math.min(options.limit ?? 50, 200)); 97 39 const offset = Math.max(0, options.offset ?? 0); 98 - const params: unknown[] = [options.repoDid, limit, offset]; 99 - const collectionFilter = options.collection ? 'and collection = $4' : ''; 40 + const rows = options.collection 41 + ? await db.cachedRecords.where('[repoDid+collection]').equals([options.repoDid, options.collection]).toArray() 42 + : await db.cachedRecords.where('repoDid').equals(options.repoDid).toArray(); 100 43 101 - if (options.collection) { 102 - params.push(options.collection); 103 - } 44 + return rows 45 + .filter((row) => row.syncStatus !== 'deleted') 46 + .sort(compareCachedRecords) 47 + .slice(offset, offset + limit) 48 + .map(rowToCachedRecord); 49 + } 104 50 105 - const result = await db.query<CachedRecordRow>( 106 - ` 107 - select account_did, repo_did, collection, rkey, uri, cid, raw_json, indexed_text, 108 - created_at, indexed_at, updated_at, stored_at, sync_status 109 - from ${TABLES.cachedRecords} 110 - where repo_did = $1 ${collectionFilter} 111 - order by coalesce(created_at, indexed_at, updated_at, stored_at) desc, rkey desc 112 - limit $2 offset $3 113 - `, 114 - params 115 - ); 51 + export async function listCachedCollections(db: DbClient, repoDid: string): Promise<CachedCollectionSummary[]> { 52 + const rows = await db.cachedRecords.where('repoDid').equals(repoDid).toArray(); 53 + const summaries = new Map<string, { loadedCount: number; lastStoredAt: string | null }>(); 116 54 117 - return result.rows.map(rowToCachedRecord); 118 - } 55 + for (const row of rows) { 56 + if (row.syncStatus === 'deleted') continue; 119 57 120 - export async function listCachedCollections(db: DbClient, repoDid: string): Promise<CachedCollectionSummary[]> { 121 - const result = await db.query<{ collection: string; loaded_count: number; last_stored_at: string | null }>( 122 - ` 123 - select collection, count(*)::int as loaded_count, max(stored_at) as last_stored_at 124 - from ${TABLES.cachedRecords} 125 - where repo_did = $1 and sync_status <> 'deleted' 126 - group by collection 127 - order by collection asc 128 - `, 129 - [repoDid] 130 - ); 58 + const summary = summaries.get(row.collection) ?? { loadedCount: 0, lastStoredAt: null }; 59 + summary.loadedCount += 1; 60 + summary.lastStoredAt = latestIsoTimestamp(summary.lastStoredAt, row.storedAt); 61 + summaries.set(row.collection, summary); 62 + } 131 63 132 - return result.rows.map((row) => ({ 133 - name: row.collection, 134 - loadedCount: row.loaded_count, 135 - lastStoredAt: row.last_stored_at 136 - })); 64 + return [...summaries.entries()] 65 + .sort(([left], [right]) => left.localeCompare(right)) 66 + .map(([name, summary]) => ({ name, ...summary })); 137 67 } 138 68 139 69 export async function getCachedRecordByUri(db: DbClient, uri: string): Promise<CachedRecord | null> { 140 - const result = await db.query<CachedRecordRow>( 141 - ` 142 - select account_did, repo_did, collection, rkey, uri, cid, raw_json, indexed_text, 143 - created_at, indexed_at, updated_at, stored_at, sync_status 144 - from ${TABLES.cachedRecords} 145 - where uri = $1 146 - limit 1 147 - `, 148 - [uri] 149 - ); 150 - 151 - return result.rows[0] ? rowToCachedRecord(result.rows[0]) : null; 70 + const row = await db.cachedRecords.where('uri').equals(uri).first(); 71 + return row ? rowToCachedRecord(row) : null; 152 72 } 153 73 154 - export function rowToCachedRecord(row: CachedRecordRow): CachedRecord { 74 + export function rowToCachedRecord(row: DbCachedRecord): CachedRecord { 155 75 return { 156 - accountDid: row.account_did, 157 - repoDid: row.repo_did, 76 + accountDid: row.accountDid, 77 + repoDid: row.repoDid, 158 78 collection: row.collection, 159 79 rkey: row.rkey, 160 80 uri: row.uri, 161 81 cid: row.cid, 162 - value: row.raw_json, 163 - indexedText: row.indexed_text, 164 - createdAt: row.created_at, 165 - indexedAt: row.indexed_at, 166 - updatedAt: row.updated_at, 167 - storedAt: row.stored_at, 168 - syncStatus: row.sync_status 82 + value: row.value, 83 + indexedText: row.indexedText, 84 + createdAt: row.createdAt, 85 + indexedAt: row.indexedAt, 86 + updatedAt: row.updatedAt, 87 + storedAt: row.storedAt, 88 + syncStatus: row.syncStatus 169 89 }; 170 90 } 91 + 92 + async function putCachedRecord(db: DbClient, record: CachedRecordInput): Promise<void> { 93 + const now = new Date().toISOString(); 94 + const existing = await db.cachedRecords 95 + .where('[repoDid+collection+rkey]') 96 + .equals([record.repoDid, record.collection, record.rkey]) 97 + .first(); 98 + const sortKey = record.createdAt ?? record.indexedAt ?? record.updatedAt ?? now; 99 + 100 + await db.cachedRecords.put({ 101 + id: existing?.id, 102 + accountDid: record.accountDid, 103 + repoDid: record.repoDid, 104 + collection: record.collection, 105 + rkey: record.rkey, 106 + uri: record.uri, 107 + cid: record.cid, 108 + value: record.value, 109 + indexedText: record.indexedText ?? '', 110 + createdAt: record.createdAt ?? null, 111 + indexedAt: record.indexedAt ?? null, 112 + updatedAt: record.updatedAt ?? null, 113 + storedAt: now, 114 + sortKey, 115 + syncStatus: 'fresh' 116 + }); 117 + } 118 + 119 + function compareCachedRecords(left: DbCachedRecord, right: DbCachedRecord): number { 120 + return right.sortKey.localeCompare(left.sortKey) || right.rkey.localeCompare(left.rkey); 121 + } 122 + 123 + function latestIsoTimestamp(left: string | null, right: string): string { 124 + return left && left > right ? left : right; 125 + }
+3 -15
src/lib/db/schema.ts
··· 1 - export const DB_DATA_DIR = 'idb://intrepid-ibex-cache-v1'; 1 + export const DB_NAME = 'intrepid-ibex-cache-v1'; 2 2 3 3 export const TABLES = { 4 - migrations: 'schema_migrations', 5 4 accounts: 'accounts', 6 - cachedRecords: 'cached_records', 7 - collectionSyncState: 'collection_sync_state', 8 - recordSearch: 'record_search' 9 - } as const; 10 - 11 - export const INDEXES = { 12 - cachedRecordsCollection: 'cached_records_repo_collection_idx', 13 - cachedRecordsUri: 'cached_records_uri_idx', 14 - cachedRecordsRecent: 'cached_records_recent_idx', 15 - collectionSyncStateAccount: 'collection_sync_state_account_idx', 16 - recordSearchText: 'record_search_text_idx' 5 + cachedRecords: 'cachedRecords', 6 + collectionSyncState: 'collectionSyncState' 17 7 } as const; 18 - 19 - export const SQL = { ping: 'select 1::int as ok', currentTimestamp: "timezone('utc', now())" } as const;
+61 -24
src/lib/db/search.ts
··· 1 - import type { DbClient } from './client'; 2 - import { TABLES } from './schema'; 1 + import { Index, type Id } from 'flexsearch'; 2 + import type { DbCachedRecord, DbClient } from './client'; 3 3 import { rowToCachedRecord, type CachedRecord } from './repositories/records'; 4 4 5 5 export type SearchScope = { repoDid: string; collection?: string }; 6 6 7 7 export type SearchResult = CachedRecord & { rank: number; snippet: string | null }; 8 - 9 - type SearchResultRow = Parameters<typeof rowToCachedRecord>[0] & { rank: number; snippet: string | null }; 10 8 11 9 export function normalizeSearchQuery(query: string): string { 12 10 return query.trim().replace(/\s+/g, ' '); ··· 16 14 const normalizedQuery = normalizeSearchQuery(query); 17 15 if (!normalizedQuery) return []; 18 16 19 - const params: unknown[] = [scope.repoDid, normalizedQuery]; 20 - const collectionFilter = scope.collection ? 'and cr.collection = $3' : ''; 21 - if (scope.collection) params.push(scope.collection); 17 + const rows = scope.collection 18 + ? await db.cachedRecords.where('[repoDid+collection]').equals([scope.repoDid, scope.collection]).toArray() 19 + : await db.cachedRecords.where('repoDid').equals(scope.repoDid).toArray(); 20 + const searchableRows = rows.filter((row) => row.syncStatus !== 'deleted' && row.indexedText.trim()); 21 + if (searchableRows.length === 0) return []; 22 + 23 + const index = new Index({ tokenize: 'forward', resolution: 9, cache: false }); 24 + const recordsByUri = new Map<string, DbCachedRecord>(); 25 + 26 + for (const row of searchableRows) { 27 + recordsByUri.set(row.uri, row); 28 + index.add(row.uri, row.indexedText); 29 + } 30 + 31 + const ids = index.search(normalizedQuery, { limit: 50, suggest: true }) as Id[]; 32 + 33 + return ids 34 + .map((id) => recordsByUri.get(String(id))) 35 + .filter((row): row is DbCachedRecord => row !== undefined) 36 + .map((row) => ({ 37 + ...rowToCachedRecord(row), 38 + rank: rankRecord(row.indexedText, normalizedQuery), 39 + snippet: makeSnippet(row.indexedText, normalizedQuery) 40 + })) 41 + .sort((left, right) => right.rank - left.rank || right.storedAt.localeCompare(left.storedAt)); 42 + } 43 + 44 + function rankRecord(text: string, query: string): number { 45 + const lowerText = text.toLowerCase(); 46 + const terms = searchTerms(query); 47 + const exactBonus = lowerText.includes(query.toLowerCase()) ? terms.length : 0; 48 + const termScore = terms.reduce((score, term) => score + countOccurrences(lowerText, term), 0); 49 + 50 + return exactBonus + termScore; 51 + } 52 + 53 + function countOccurrences(text: string, term: string): number { 54 + if (!term) return 0; 55 + return text.split(term).length - 1; 56 + } 22 57 23 - const result = await db.query<SearchResultRow>( 24 - ` 25 - select cr.account_did, cr.repo_did, cr.collection, cr.rkey, cr.uri, cr.cid, 26 - cr.raw_json, cr.indexed_text, cr.created_at, cr.indexed_at, cr.updated_at, 27 - cr.stored_at, cr.sync_status, 28 - ts_rank(rs.search_vector, plainto_tsquery('simple', $2)) as rank, 29 - ts_headline('simple', rs.search_text, plainto_tsquery('simple', $2), 'MaxFragments=2, MinWords=4, MaxWords=18') as snippet 30 - from ${TABLES.recordSearch} rs 31 - join ${TABLES.cachedRecords} cr on cr.uri = rs.record_uri 32 - where cr.repo_did = $1 ${collectionFilter} 33 - and rs.search_vector @@ plainto_tsquery('simple', $2) 34 - order by rank desc, coalesce(cr.created_at, cr.indexed_at, cr.updated_at, cr.stored_at) desc 35 - limit 50 36 - `, 37 - params 38 - ); 58 + function makeSnippet(text: string, query: string): string | null { 59 + const lowerText = text.toLowerCase(); 60 + const indexes = searchTerms(query) 61 + .map((term) => lowerText.indexOf(term)) 62 + .filter((index) => index >= 0); 63 + const firstMatch = Math.min(...indexes); 64 + 65 + if (!Number.isFinite(firstMatch)) return text.slice(0, 180) || null; 66 + 67 + const start = Math.max(0, firstMatch - 80); 68 + const end = Math.min(text.length, firstMatch + 160); 69 + return `${start > 0 ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}`; 70 + } 39 71 40 - return result.rows.map((row) => ({ ...rowToCachedRecord(row), rank: row.rank, snippet: row.snippet })); 72 + function searchTerms(query: string): string[] { 73 + return query 74 + .toLowerCase() 75 + .split(/[^\p{L}\p{N}_:@.-]+/u) 76 + .map((term) => term.trim()) 77 + .filter(Boolean); 41 78 }
+10 -21
src/lib/db/sync.ts
··· 1 1 import type { DbClient } from './client'; 2 2 import { upsertCachedRecords, type CachedRecordInput } from './repositories/records'; 3 - import { TABLES } from './schema'; 4 3 5 4 export type CollectionSyncStateInput = { 6 5 accountDid: string; ··· 17 16 } 18 17 19 18 export async function updateCollectionSyncState(db: DbClient, state: CollectionSyncStateInput): Promise<void> { 20 - await db.query( 21 - ` 22 - insert into ${TABLES.collectionSyncState} ( 23 - account_did, repo_did, collection, cursor, last_synced_at, last_error, updated_at 24 - ) values ($1, $2, $3, $4, $5, $6, timezone('utc', now())::text) 25 - on conflict (account_did, repo_did, collection) do update set 26 - cursor = excluded.cursor, 27 - last_synced_at = excluded.last_synced_at, 28 - last_error = excluded.last_error, 29 - updated_at = excluded.updated_at 30 - `, 31 - [ 32 - state.accountDid, 33 - state.repoDid, 34 - state.collection, 35 - state.cursor ?? null, 36 - state.lastSyncedAt ?? null, 37 - state.lastError ?? null 38 - ] 39 - ); 19 + await db.collectionSyncState.put({ 20 + key: `${state.accountDid}|${state.repoDid}|${state.collection}`, 21 + accountDid: state.accountDid, 22 + repoDid: state.repoDid, 23 + collection: state.collection, 24 + cursor: state.cursor ?? null, 25 + lastSyncedAt: state.lastSyncedAt ?? null, 26 + lastError: state.lastError ?? null, 27 + updatedAt: new Date().toISOString() 28 + }); 40 29 }
+7 -8
src/routes/docs/CHANGELOG.md
··· 8 8 9 9 #### Added 10 10 11 - - PGlite-backed local cache/search foundation with WASM build support. 12 - - Transactional schema migrations for accounts, cached records, collection 13 - sync state, and record search. 14 - - Ubuntu-purple boot splash that initializes PGlite, runs migrations, and offers 15 - recovery actions. 16 - - Cache-aware AT Protocol browsing: live record fetches populate PGlite and offline 17 - failures can fall back to cached collections/records. 18 - - Full-text cache search in the Nautilus-style collection browser. 11 + - Dexie-backed local cache/search foundation using IndexedDB. 12 + - Versioned stores for accounts, cached records, and collection sync state. 13 + - Ubuntu-purple boot splash that initializes the local cache and offers recovery 14 + actions. 15 + - Cache-aware AT Protocol browsing: live record fetches populate IndexedDB and 16 + offline failures can fall back to cached collections/records. 17 + - FlexSearch-powered cache search in the Nautilus-style collection browser. 19 18 - Atmosphere app logo mapping for known AT Protocol collection namespaces. 20 19 - Cursor-based record pagination with a generator-backed “Load more records” flow. 21 20
-12
vite.config.ts
··· 4 4 5 5 export default defineConfig({ 6 6 plugins: [sveltekit()], 7 - assetsInclude: ['**/*.wasm'], 8 - optimizeDeps: { exclude: ['@electric-sql/pglite'] }, 9 7 build: { 10 8 rolldownOptions: { 11 9 onLog(level, log, defaultHandler) { 12 10 const message = typeof log === 'string' ? log : log.message; 13 - 14 - /** 15 - * PGlite currently emits Rolldown direct-eval warnings from its own 16 - * packaged code. The app lazy-loads PGlite behind DB operations, and 17 - * this filter is intentionally scoped to that third-party package so 18 - * eval warnings from app code still surface. 19 - */ 20 - if (level === 'warn' && message.includes('Use of direct `eval`') && message.includes('@electric-sql/pglite')) { 21 - return; 22 - } 23 11 24 12 if (level === 'warn' && message.includes('[PLUGIN_TIMINGS]')) { 25 13 return;