diff --git a/eslint.config.js b/eslint.config.js index 6e87cf8..a554a39 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -28,5 +28,20 @@ export default defineConfig( parserOptions: { projectService: true, extraFileExtensions: ['.svelte'], parser: ts.parser, svelteConfig } } }, - { rules: {} } + { + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'all', + argsIgnorePattern: '^_', + caughtErrors: 'all', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true + } + ] + } + } ); diff --git a/package.json b/package.json index 87b2081..ab2f848 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@atcute/bluesky": "^4.0.6", "@atcute/client": "^5.0.0", "@atcute/lexicons": "^2.0.0", + "@electric-sql/pglite": "^0.5.1", "@shikijs/core": "^4.2.0", "@shikijs/engine-javascript": "^4.2.0", "@shikijs/langs": "^4.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e120377..68c9d6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@atcute/lexicons': specifier: ^2.0.0 version: 2.0.0 + '@electric-sql/pglite': + specifier: ^0.5.1 + version: 0.5.1 '@shikijs/core': specifier: ^4.2.0 version: 4.2.0 @@ -134,6 +137,9 @@ packages: '@blazediff/core@1.9.1': resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@electric-sql/pglite@0.5.1': + resolution: {integrity: sha512-h2Vc+qkQqsEL5kvyN5nBAxn3Vbyvka7QfDW7Io+CdcwU1+X8JbCAN2og+5dI11S3eJuDfroUCxzJaap6k+ezEw==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -1449,6 +1455,8 @@ snapshots: '@blazediff/core@1.9.1': {} + '@electric-sql/pglite@0.5.1': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 diff --git a/src/lib/db/client.svelte.test.ts b/src/lib/db/client.svelte.test.ts new file mode 100644 index 0000000..97b0005 --- /dev/null +++ b/src/lib/db/client.svelte.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { closeDatabase, getDatabase, getDatabaseStartupReport } from './client'; + +describe('PGlite client', () => { + afterEach(async () => { + await closeDatabase(); + }); + + it('loads the WASM bundle and answers a query in the browser', async () => { + expect.assertions(3); + + const db = await getDatabase(); + const result = await db.query<{ ok: number }>('select 1::int as ok'); + const report = getDatabaseStartupReport(); + + expect(result.rows[0]?.ok).toBe(1); + expect(report.status).toBe('ready'); + expect(report.wasm).toBe('loaded'); + }); +}); diff --git a/src/lib/db/client.ts b/src/lib/db/client.ts new file mode 100644 index 0000000..c8588b5 --- /dev/null +++ b/src/lib/db/client.ts @@ -0,0 +1,115 @@ +import { PGlite } from '@electric-sql/pglite'; +import { DB_DATA_DIR, SQL } from './schema'; + +export type DbClient = PGlite; + +export type DatabaseStartupReport = { + status: 'idle' | 'loading' | 'ready' | 'error'; + dataDir: string | null; + startedAt: string | null; + finishedAt: string | null; + wasm: 'not_checked' | 'loaded' | 'error'; + indexedDb: 'not_used' | 'available' | 'unavailable'; + persisted: boolean | null; + error: string | null; +}; + +let databasePromise: Promise | null = null; +let database: DbClient | null = null; +let startupReport: DatabaseStartupReport = createIdleReport(); + +export function getDatabaseStartupReport(): DatabaseStartupReport { + return { ...startupReport }; +} + +export async function getDatabase(): Promise { + if (!databasePromise) { + databasePromise = initializeDatabase(); + } + + return databasePromise; +} + +export async function closeDatabase(): Promise { + if (database) { + await database.close(); + } + + database = null; + databasePromise = null; + startupReport = createIdleReport(); +} + +export async function resetDatabaseForTests(): Promise { + await closeDatabase(); +} + +async function initializeDatabase(): Promise { + const dataDir = getDefaultDataDir(); + startupReport = { + status: 'loading', + dataDir: dataDir ?? null, + startedAt: new Date().toISOString(), + finishedAt: null, + wasm: 'not_checked', + indexedDb: getIndexedDbStatus(dataDir), + persisted: null, + error: null + }; + + try { + const client = await PGlite.create(dataDir); + await client.query(SQL.ping); + + database = client; + startupReport = { + ...startupReport, + status: 'ready', + finishedAt: new Date().toISOString(), + wasm: 'loaded', + persisted: dataDir?.startsWith('idb://') === true && startupReport.indexedDb === 'available' + }; + + return client; + } catch (error) { + startupReport = { + ...startupReport, + status: 'error', + finishedAt: new Date().toISOString(), + wasm: 'error', + error: messageFromError(error) + }; + databasePromise = null; + throw error; + } +} + +function getDefaultDataDir() { + return isBrowser() ? DB_DATA_DIR : undefined; +} + +function getIndexedDbStatus(dataDir: string | undefined): DatabaseStartupReport['indexedDb'] { + if (!dataDir?.startsWith('idb://')) return 'not_used'; + return isBrowser() && 'indexedDB' in globalThis ? 'available' : 'unavailable'; +} + +function isBrowser() { + return typeof globalThis.window !== 'undefined'; +} + +function createIdleReport(): DatabaseStartupReport { + return { + status: 'idle', + dataDir: null, + startedAt: null, + finishedAt: null, + wasm: 'not_checked', + indexedDb: 'not_used', + persisted: null, + error: null + }; +} + +function messageFromError(error: unknown) { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts new file mode 100644 index 0000000..ae4d4da --- /dev/null +++ b/src/lib/db/index.ts @@ -0,0 +1,6 @@ +export * from './client'; +export * from './migrations'; +export * from './schema'; +export * from './search'; +export * from './sync'; +export * from './repositories/records'; diff --git a/src/lib/db/migrations.ts b/src/lib/db/migrations.ts new file mode 100644 index 0000000..689cd8e --- /dev/null +++ b/src/lib/db/migrations.ts @@ -0,0 +1,19 @@ +import type { DbClient } from './client'; + +export type Migration = { id: string; description: string; up: (db: DbClient) => Promise }; + +export type MigrationStatus = { + pending: readonly Migration[]; + applied: readonly string[]; + currentVersion: string | null; +}; + +export const migrations: readonly Migration[] = []; + +export async function getMigrationStatus(_db: DbClient): Promise { + return { pending: migrations, applied: [], currentVersion: null }; +} + +export async function runMigrations(_db: DbClient): Promise { + return getMigrationStatus(_db); +} diff --git a/src/lib/db/repositories/records.ts b/src/lib/db/repositories/records.ts new file mode 100644 index 0000000..49ac9f3 --- /dev/null +++ b/src/lib/db/repositories/records.ts @@ -0,0 +1,37 @@ +import type { DbClient } from '../client'; + +export type CachedRecordInput = { + accountDid: string; + repoDid: string; + collection: string; + rkey: string; + uri: string; + cid: string; + value: unknown; + indexedText?: string | null; + createdAt?: string | null; + indexedAt?: string | null; + updatedAt?: string | null; +}; + +export type CachedRecord = CachedRecordInput & { storedAt: string; syncStatus: 'fresh' | 'stale' | 'deleted' }; + +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.'); +} + +export async function upsertCachedRecords(db: DbClient, records: readonly CachedRecordInput[]): Promise { + for (const record of records) { + await upsertCachedRecord(db, record); + } +} + +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 getCachedRecordByUri(_db: DbClient, _uri: string): Promise { + throw new Error('cached_records schema has not been created yet. Run migrations before reading records.'); +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts new file mode 100644 index 0000000..a89c805 --- /dev/null +++ b/src/lib/db/schema.ts @@ -0,0 +1,19 @@ +export const DB_DATA_DIR = 'idb://intrepid-ibex-cache-v1'; + +export const TABLES = { + migrations: 'schema_migrations', + accounts: 'accounts', + cachedRecords: 'cached_records', + collectionSyncState: 'collection_sync_state', + recordSearch: 'record_search' +} as const; + +export const INDEXES = { + cachedRecordsCollection: 'cached_records_repo_collection_idx', + cachedRecordsUri: 'cached_records_uri_idx', + cachedRecordsRecent: 'cached_records_recent_idx', + collectionSyncStateAccount: 'collection_sync_state_account_idx', + recordSearchText: 'record_search_text_idx' +} as const; + +export const SQL = { ping: 'select 1::int as ok', currentTimestamp: "timezone('utc', now())" } as const; diff --git a/src/lib/db/search.ts b/src/lib/db/search.ts new file mode 100644 index 0000000..9190029 --- /dev/null +++ b/src/lib/db/search.ts @@ -0,0 +1,14 @@ +import type { DbClient } from './client'; +import type { CachedRecord } from './repositories/records'; + +export type SearchScope = { repoDid: string; collection?: string }; + +export type SearchResult = CachedRecord & { 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.'); +} diff --git a/src/lib/db/sync.ts b/src/lib/db/sync.ts new file mode 100644 index 0000000..1beece3 --- /dev/null +++ b/src/lib/db/sync.ts @@ -0,0 +1,20 @@ +import type { DbClient } from './client'; +import { upsertCachedRecords, type CachedRecordInput } from './repositories/records'; + +export type CollectionSyncStateInput = { + accountDid: string; + repoDid: string; + collection: string; + cursor?: string | null; + lastSyncedAt?: string | null; + lastError?: string | null; +}; + +export async function cacheFetchedRecords(db: DbClient, records: readonly CachedRecordInput[]): Promise { + if (records.length === 0) return; + 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.'); +} diff --git a/vite.config.ts b/vite.config.ts index 3e3d703..21e95d5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,14 +4,22 @@ import { sveltekit } from '@sveltejs/kit/vite'; export default defineConfig({ plugins: [sveltekit()], + assetsInclude: ['**/*.wasm'], + optimizeDeps: { exclude: ['@electric-sql/pglite'] }, test: { expect: { requireAssertions: true }, + ui: false, projects: [ { extends: './vite.config.ts', test: { name: 'client', - browser: { enabled: true, provider: playwright(), instances: [{ browser: 'chromium', headless: true }] }, + browser: { + headless: true, + enabled: true, + provider: playwright(), + instances: [{ browser: 'chromium', headless: true }] + }, include: ['src/**/*.svelte.{test,spec}.{js,ts}'], exclude: ['src/lib/server/**'] }