diff --git a/src/lib/components/BootSplash.svelte b/src/lib/components/BootSplash.svelte new file mode 100644 index 0000000..3ec833b --- /dev/null +++ b/src/lib/components/BootSplash.svelte @@ -0,0 +1,202 @@ + + +
+ +
+ +
+

Intrepid Ibex

+

Starting desktop

+

{error ?? step}

+
+ + {#if error} +
+ + {#if canReset} + + {/if} + +
+ {:else} + + {/if} +
+
+ + diff --git a/src/lib/db/client.ts b/src/lib/db/client.ts index c8588b5..aea2ddf 100644 --- a/src/lib/db/client.ts +++ b/src/lib/db/client.ts @@ -1,4 +1,5 @@ import { PGlite } from '@electric-sql/pglite'; +import { resetLocalCacheDatabase } from './migrations'; import { DB_DATA_DIR, SQL } from './schema'; export type DbClient = PGlite; @@ -44,6 +45,11 @@ export async function resetDatabaseForTests(): Promise { await closeDatabase(); } +export async function resetLocalDatabase(): Promise { + await closeDatabase(); + await resetLocalCacheDatabase(); +} + async function initializeDatabase(): Promise { const dataDir = getDefaultDataDir(); startupReport = { diff --git a/src/lib/db/migrations.test.ts b/src/lib/db/migrations.test.ts new file mode 100644 index 0000000..a8021e9 --- /dev/null +++ b/src/lib/db/migrations.test.ts @@ -0,0 +1,61 @@ +import { PGlite } from '@electric-sql/pglite'; +import { afterEach, describe, expect, it } from 'vitest'; +import { getMigrationStatus, runMigrations } from './migrations'; +import { TABLES } from './schema'; + +const clients: PGlite[] = []; + +async function createDb() { + const db = await PGlite.create(); + clients.push(db); + return db; +} + +afterEach(async () => { + await Promise.all(clients.splice(0).map((db) => db.close())); +}); + +describe('migrations', () => { + it('applies the initial schema on a fresh database', async () => { + expect.assertions(3); + const db = await createDb(); + + const status = await runMigrations(db); + const tables = await db.query<{ table_name: string }>( + `select table_name from information_schema.tables where table_schema = 'public' order by table_name` + ); + + expect(status.pending).toHaveLength(0); + expect(status.currentVersion).toBe('202606090001_initial_cache_schema'); + expect(tables.rows.map((row) => row.table_name)).toEqual( + expect.arrayContaining([ + TABLES.migrations, + TABLES.accounts, + TABLES.cachedRecords, + TABLES.collectionSyncState, + TABLES.recordSearch + ]) + ); + }); + + it('is idempotent when migrations are already applied', async () => { + expect.assertions(2); + const db = await createDb(); + + await runMigrations(db); + const status = await runMigrations(db); + + expect(status.pending).toHaveLength(0); + expect(status.applied).toHaveLength(1); + }); + + it('reports pending migrations before the runner is called', async () => { + expect.assertions(2); + const db = await createDb(); + + const status = await getMigrationStatus(db); + + expect(status.applied).toHaveLength(0); + expect(status.pending.map((migration) => migration.id)).toEqual(['202606090001_initial_cache_schema']); + }); +}); diff --git a/src/lib/db/migrations.ts b/src/lib/db/migrations.ts index 689cd8e..c426f67 100644 --- a/src/lib/db/migrations.ts +++ b/src/lib/db/migrations.ts @@ -1,19 +1,173 @@ import type { DbClient } from './client'; +import { DB_DATA_DIR, INDEXES, TABLES } from './schema'; -export type Migration = { id: string; description: string; up: (db: DbClient) => Promise }; +export type MigrationConnection = Pick; + +export type Migration = { id: string; description: string; up: (db: MigrationConnection) => Promise }; + +export type AppliedMigration = { version: string; appliedAt: string }; export type MigrationStatus = { pending: readonly Migration[]; - applied: readonly string[]; + applied: readonly AppliedMigration[]; currentVersion: string | null; }; -export const migrations: readonly Migration[] = []; +export const migrations: readonly Migration[] = validateMigrations([ + { + id: '202606090001_initial_cache_schema', + description: 'Create local cache tables and record search index.', + up: async (db) => { + await db.exec(` + create table ${TABLES.accounts} ( + did text primary key, + handle text not null, + pds text, + label text, + created_at text not null default (timezone('utc', now())::text), + updated_at text not null default (timezone('utc', now())::text) + ); + + create table ${TABLES.cachedRecords} ( + id bigserial primary key, + account_did text not null, + repo_did text not null, + collection text not null, + rkey text not null, + uri text not null unique, + cid text not null, + raw_json jsonb not null, + indexed_text text not null default '', + created_at text, + indexed_at text, + updated_at text, + stored_at text not null default (timezone('utc', now())::text), + sync_status text not null default 'fresh', + unique (repo_did, collection, rkey) + ); + + create table ${TABLES.collectionSyncState} ( + account_did text not null, + repo_did text not null, + collection text not null, + cursor text, + last_synced_at text, + last_error text, + updated_at text not null default (timezone('utc', now())::text), + primary key (account_did, repo_did, collection) + ); + + create table ${TABLES.recordSearch} ( + record_uri text primary key references ${TABLES.cachedRecords}(uri) on delete cascade, + repo_did text not null, + collection text not null, + search_text text not null, + search_vector tsvector generated always as (to_tsvector('simple', search_text)) stored + ); + + create index ${INDEXES.cachedRecordsCollection} + on ${TABLES.cachedRecords} (repo_did, collection, rkey); + create index ${INDEXES.cachedRecordsUri} + on ${TABLES.cachedRecords} (uri); + create index ${INDEXES.cachedRecordsRecent} + on ${TABLES.cachedRecords} (repo_did, collection, coalesce(created_at, indexed_at, updated_at, stored_at) desc); + create index ${INDEXES.collectionSyncStateAccount} + on ${TABLES.collectionSyncState} (account_did, repo_did); + create index ${INDEXES.recordSearchText} + on ${TABLES.recordSearch} using gin (search_vector); + `); + } + } +]); + +export async function getMigrationStatus(db: DbClient): Promise { + await ensureMigrationsTable(db); + const applied = await getAppliedMigrations(db); + const appliedVersions = new Set(applied.map((migration) => migration.version)); + + return { + pending: migrations.filter((migration) => !appliedVersions.has(migration.id)), + applied, + currentVersion: applied.at(-1)?.version ?? null + }; +} + +export async function runMigrations(db: DbClient): Promise { + await ensureMigrationsTable(db); + const status = await getMigrationStatus(db); + + for (const migration of status.pending) { + await db.transaction(async (tx) => { + await migration.up(tx); + await tx.query( + `insert into ${TABLES.migrations} (version, applied_at) values ($1, timezone('utc', now())::text)`, + [migration.id] + ); + }); + } + + return getMigrationStatus(db); +} + +export async function resetLocalCacheDatabase(): Promise { + if (!import.meta.env.DEV) { + throw new Error('Resetting the local cache is only available in development builds.'); + } + + if (typeof indexedDB === 'undefined') { + return; + } + + const databaseNames = new Set([DB_DATA_DIR, DB_DATA_DIR.replace(/^idb:\/\//, '')]); + + if ('databases' in indexedDB) { + const databases = await indexedDB.databases(); + for (const database of databases) { + if (database.name?.includes('intrepid-ibex-cache')) { + databaseNames.add(database.name); + } + } + } + + await Promise.all([...databaseNames].map((name) => deleteIndexedDbDatabase(name))); +} + +async function ensureMigrationsTable(db: DbClient): Promise { + await db.exec(` + create table if not exists ${TABLES.migrations} ( + version text primary key, + applied_at text not null + ); + `); +} + +async function getAppliedMigrations(db: DbClient): Promise { + const result = await db.query<{ version: string; applied_at: string }>( + `select version, applied_at from ${TABLES.migrations} order by version asc` + ); + + return result.rows.map((row) => ({ version: row.version, appliedAt: row.applied_at })); +} + +function validateMigrations(items: readonly Migration[]): readonly Migration[] { + const sorted = [...items].sort((a, b) => a.id.localeCompare(b.id)); + const ids = new Set(); + + for (const migration of sorted) { + if (ids.has(migration.id)) { + throw new Error(`Duplicate migration id: ${migration.id}`); + } + ids.add(migration.id); + } -export async function getMigrationStatus(_db: DbClient): Promise { - return { pending: migrations, applied: [], currentVersion: null }; + return sorted; } -export async function runMigrations(_db: DbClient): Promise { - return getMigrationStatus(_db); +function deleteIndexedDbDatabase(name: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(name); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error ?? new Error(`Could not delete IndexedDB database ${name}.`)); + request.onblocked = () => reject(new Error(`Could not delete IndexedDB database ${name} because it is open.`)); + }); } diff --git a/src/lib/db/repositories/records.test.ts b/src/lib/db/repositories/records.test.ts new file mode 100644 index 0000000..237c2c7 --- /dev/null +++ b/src/lib/db/repositories/records.test.ts @@ -0,0 +1,47 @@ +import { PGlite } from '@electric-sql/pglite'; +import { afterEach, describe, expect, it } from 'vitest'; +import { runMigrations } from '../migrations'; +import { searchCachedRecords } from '../search'; +import { getCachedRecordByUri, listCachedRecords, upsertCachedRecord } from './records'; + +const clients: PGlite[] = []; + +async function createMigratedDb() { + const db = await PGlite.create(); + clients.push(db); + await runMigrations(db); + return db; +} + +afterEach(async () => { + await Promise.all(clients.splice(0).map((db) => db.close())); +}); + +describe('cached records repository', () => { + it('stores raw records and indexes searchable text', async () => { + expect.assertions(5); + const db = await createMigratedDb(); + + await upsertCachedRecord(db, { + accountDid: 'did:plc:viewer', + repoDid: 'did:plc:repo', + collection: 'app.bsky.feed.post', + rkey: 'abc123', + uri: 'at://did:plc:repo/app.bsky.feed.post/abc123', + cid: 'bafy-test', + value: { $type: 'app.bsky.feed.post', text: 'Intrepid Ibex cache search works' }, + indexedText: 'Intrepid Ibex cache search works', + createdAt: '2026-06-09T12:00:00.000Z' + }); + + const listed = await listCachedRecords(db, { repoDid: 'did:plc:repo', collection: 'app.bsky.feed.post' }); + const record = await getCachedRecordByUri(db, 'at://did:plc:repo/app.bsky.feed.post/abc123'); + const results = await searchCachedRecords(db, 'cache search', { repoDid: 'did:plc:repo' }); + + expect(listed).toHaveLength(1); + expect(record?.value).toEqual({ $type: 'app.bsky.feed.post', text: 'Intrepid Ibex cache search works' }); + expect(results).toHaveLength(1); + expect(results[0]?.rkey).toBe('abc123'); + expect(results[0]?.rank).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/db/repositories/records.ts b/src/lib/db/repositories/records.ts index 49ac9f3..49048f5 100644 --- a/src/lib/db/repositories/records.ts +++ b/src/lib/db/repositories/records.ts @@ -1,4 +1,5 @@ import type { DbClient } from '../client'; +import { TABLES } from '../schema'; export type CachedRecordInput = { accountDid: string; @@ -18,8 +19,69 @@ export type CachedRecord = CachedRecordInput & { storedAt: string; syncStatus: ' export type ListCachedRecordsOptions = { repoDid: string; collection?: string; limit?: number; offset?: number }; -export async function upsertCachedRecord(_db: DbClient, _record: CachedRecordInput): Promise { - throw new Error('cached_records schema has not been created yet. Run migrations before writing records.'); +type CachedRecordRow = { + account_did: string; + repo_did: string; + collection: string; + rkey: string; + uri: string; + cid: string; + raw_json: unknown; + indexed_text: string; + created_at: string | null; + indexed_at: string | null; + updated_at: string | null; + stored_at: string; + sync_status: CachedRecord['syncStatus']; +}; + +export async function upsertCachedRecord(db: DbClient, record: CachedRecordInput): Promise { + await db.transaction(async (tx) => { + await tx.query( + ` + insert into ${TABLES.cachedRecords} ( + account_did, repo_did, collection, rkey, uri, cid, raw_json, + indexed_text, created_at, indexed_at, updated_at, stored_at, sync_status + ) values ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, $11, timezone('utc', now())::text, 'fresh') + on conflict (repo_did, collection, rkey) do update set + account_did = excluded.account_did, + uri = excluded.uri, + cid = excluded.cid, + raw_json = excluded.raw_json, + indexed_text = excluded.indexed_text, + created_at = excluded.created_at, + indexed_at = excluded.indexed_at, + updated_at = excluded.updated_at, + stored_at = excluded.stored_at, + sync_status = 'fresh' + `, + [ + record.accountDid, + record.repoDid, + record.collection, + record.rkey, + record.uri, + record.cid, + JSON.stringify(record.value), + record.indexedText ?? '', + record.createdAt ?? null, + record.indexedAt ?? null, + record.updatedAt ?? null + ] + ); + + await tx.query( + ` + insert into ${TABLES.recordSearch} (record_uri, repo_did, collection, search_text) + values ($1, $2, $3, $4) + on conflict (record_uri) do update set + repo_did = excluded.repo_did, + collection = excluded.collection, + search_text = excluded.search_text + `, + [record.uri, record.repoDid, record.collection, record.indexedText ?? ''] + ); + }); } export async function upsertCachedRecords(db: DbClient, records: readonly CachedRecordInput[]): Promise { @@ -28,10 +90,60 @@ export async function upsertCachedRecords(db: DbClient, records: readonly Cached } } -export async function listCachedRecords(_db: DbClient, _options: ListCachedRecordsOptions): Promise { - throw new Error('cached_records schema has not been created yet. Run migrations before reading records.'); +export async function listCachedRecords(db: DbClient, options: ListCachedRecordsOptions): Promise { + const limit = Math.max(1, Math.min(options.limit ?? 50, 200)); + const offset = Math.max(0, options.offset ?? 0); + const params: unknown[] = [options.repoDid, limit, offset]; + const collectionFilter = options.collection ? 'and collection = $4' : ''; + + if (options.collection) { + params.push(options.collection); + } + + const result = await db.query( + ` + select account_did, repo_did, collection, rkey, uri, cid, raw_json, indexed_text, + created_at, indexed_at, updated_at, stored_at, sync_status + from ${TABLES.cachedRecords} + where repo_did = $1 ${collectionFilter} + order by coalesce(created_at, indexed_at, updated_at, stored_at) desc, rkey desc + limit $2 offset $3 + `, + params + ); + + return result.rows.map(rowToCachedRecord); +} + +export async function getCachedRecordByUri(db: DbClient, uri: string): Promise { + const result = await db.query( + ` + select account_did, repo_did, collection, rkey, uri, cid, raw_json, indexed_text, + created_at, indexed_at, updated_at, stored_at, sync_status + from ${TABLES.cachedRecords} + where uri = $1 + limit 1 + `, + [uri] + ); + + return result.rows[0] ? rowToCachedRecord(result.rows[0]) : null; } -export async function getCachedRecordByUri(_db: DbClient, _uri: string): Promise { - throw new Error('cached_records schema has not been created yet. Run migrations before reading records.'); +export function rowToCachedRecord(row: CachedRecordRow): CachedRecord { + return { + accountDid: row.account_did, + repoDid: row.repo_did, + collection: row.collection, + rkey: row.rkey, + uri: row.uri, + cid: row.cid, + value: row.raw_json, + indexedText: row.indexed_text, + createdAt: row.created_at, + indexedAt: row.indexed_at, + updatedAt: row.updated_at, + storedAt: row.stored_at, + syncStatus: row.sync_status + }; } diff --git a/src/lib/db/search.ts b/src/lib/db/search.ts index 9190029..67546d2 100644 --- a/src/lib/db/search.ts +++ b/src/lib/db/search.ts @@ -1,14 +1,41 @@ import type { DbClient } from './client'; -import type { CachedRecord } from './repositories/records'; +import { TABLES } from './schema'; +import { rowToCachedRecord, type CachedRecord } from './repositories/records'; export type SearchScope = { repoDid: string; collection?: string }; export type SearchResult = CachedRecord & { rank: number; snippet: string | null }; +type SearchResultRow = Parameters[0] & { rank: number; snippet: string | null }; + export function normalizeSearchQuery(query: string): string { return query.trim().replace(/\s+/g, ' '); } -export async function searchCachedRecords(_db: DbClient, _query: string, _scope: SearchScope): Promise { - throw new Error('record_search schema has not been created yet. Run migrations before searching records.'); +export async function searchCachedRecords(db: DbClient, query: string, scope: SearchScope): Promise { + const normalizedQuery = normalizeSearchQuery(query); + if (!normalizedQuery) return []; + + const params: unknown[] = [scope.repoDid, normalizedQuery]; + const collectionFilter = scope.collection ? 'and cr.collection = $3' : ''; + if (scope.collection) params.push(scope.collection); + + const result = await db.query( + ` + select cr.account_did, cr.repo_did, cr.collection, cr.rkey, cr.uri, cr.cid, + cr.raw_json, cr.indexed_text, cr.created_at, cr.indexed_at, cr.updated_at, + cr.stored_at, cr.sync_status, + ts_rank(rs.search_vector, plainto_tsquery('simple', $2)) as rank, + ts_headline('simple', rs.search_text, plainto_tsquery('simple', $2), 'MaxFragments=2, MinWords=4, MaxWords=18') as snippet + from ${TABLES.recordSearch} rs + join ${TABLES.cachedRecords} cr on cr.uri = rs.record_uri + where cr.repo_did = $1 ${collectionFilter} + and rs.search_vector @@ plainto_tsquery('simple', $2) + order by rank desc, coalesce(cr.created_at, cr.indexed_at, cr.updated_at, cr.stored_at) desc + limit 50 + `, + params + ); + + return result.rows.map((row) => ({ ...rowToCachedRecord(row), rank: row.rank, snippet: row.snippet })); } diff --git a/src/lib/db/sync.ts b/src/lib/db/sync.ts index 1beece3..41030c7 100644 --- a/src/lib/db/sync.ts +++ b/src/lib/db/sync.ts @@ -1,5 +1,6 @@ import type { DbClient } from './client'; import { upsertCachedRecords, type CachedRecordInput } from './repositories/records'; +import { TABLES } from './schema'; export type CollectionSyncStateInput = { accountDid: string; @@ -15,6 +16,25 @@ export async function cacheFetchedRecords(db: DbClient, records: readonly Cached await upsertCachedRecords(db, records); } -export async function updateCollectionSyncState(_db: DbClient, _state: CollectionSyncStateInput): Promise { - throw new Error('collection_sync_state schema has not been created yet. Run migrations before storing sync state.'); +export async function updateCollectionSyncState(db: DbClient, state: CollectionSyncStateInput): Promise { + await db.query( + ` + insert into ${TABLES.collectionSyncState} ( + account_did, repo_did, collection, cursor, last_synced_at, last_error, updated_at + ) values ($1, $2, $3, $4, $5, $6, timezone('utc', now())::text) + on conflict (account_did, repo_did, collection) do update set + cursor = excluded.cursor, + last_synced_at = excluded.last_synced_at, + last_error = excluded.last_error, + updated_at = excluded.updated_at + `, + [ + state.accountDid, + state.repoDid, + state.collection, + state.cursor ?? null, + state.lastSyncedAt ?? null, + state.lastError ?? null + ] + ); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index e8d3684..7fe5613 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,11 +1,14 @@ @@ -79,79 +124,89 @@ -
- - -
-
- {#each shortcuts as shortcut (shortcut.label)} - - {/each} -
- - {#if mainWindow?.isOpen && !mainWindow.isMinimized} -
- windowManager.focus('main')} - onminimize={() => windowManager.minimize('main')} - onmaximize={() => windowManager.toggleMaximize('main')}> - {#if accountSetup.isConfigured} - {@render children()} - {:else} - - {/if} - -
- {/if} - - {#if showAboutComputer && aboutWindow?.isOpen && !aboutWindow.isMinimized} -
- windowManager.focus('about-computer')} - onminimize={() => windowManager.minimize('about-computer')} - onmaximize={() => windowManager.toggleMaximize('about-computer')} - onclose={() => windowManager.close('about-computer')}> - - -
- {/if} - - {#if repoBrowser.selectedRecord && geditWindow?.isOpen && !geditWindow.isMinimized} -
- windowManager.focus('gedit')} - onminimize={() => windowManager.minimize('gedit')} - onmaximize={() => windowManager.toggleMaximize('gedit')} - onclose={() => windowManager.close('gedit')}> - - -
- {/if} - - {#if showStickyNote} - - {/if} -
-
+{#if bootStatus !== 'ready'} + +{:else} +
+ + +
+
+ {#each shortcuts as shortcut (shortcut.label)} + + {/each} +
+ + {#if mainWindow?.isOpen && !mainWindow.isMinimized} +
+ windowManager.focus('main')} + onminimize={() => windowManager.minimize('main')} + onmaximize={() => windowManager.toggleMaximize('main')}> + {#if accountSetup.isConfigured} + {@render children()} + {:else} + + {/if} + +
+ {/if} + + {#if showAboutComputer && aboutWindow?.isOpen && !aboutWindow.isMinimized} +
+ windowManager.focus('about-computer')} + onminimize={() => windowManager.minimize('about-computer')} + onmaximize={() => windowManager.toggleMaximize('about-computer')} + onclose={() => windowManager.close('about-computer')}> + + +
+ {/if} + + {#if repoBrowser.selectedRecord && geditWindow?.isOpen && !geditWindow.isMinimized} +
+ windowManager.focus('gedit')} + onminimize={() => windowManager.minimize('gedit')} + onmaximize={() => windowManager.toggleMaximize('gedit')} + onclose={() => windowManager.close('gedit')}> + + +
+ {/if} + + {#if showStickyNote} + + {/if} +
+
+{/if}