Browse Source
- Add account management methods to PlatformService interface - Implement account operations in all platform services - Fix PlatformCapabilities interface by adding sqlite property - Update util.ts to use PlatformService for account operations - Standardize account and settings management across platforms This change improves code organization by: - Centralizing database operations through PlatformService - Ensuring consistent account management across platforms - Making platform-specific implementations more maintainable - Reducing direct database access in utility functions Note: Some linter errors remain regarding db.accounts access and sqlite capabilities that need to be addressed in a follow-up commit.sql-absurd-sql
36 changed files with 2619 additions and 266 deletions
@ -0,0 +1,172 @@ |
|||
--- |
|||
description: |
|||
globs: |
|||
alwaysApply: true |
|||
--- |
|||
# @capacitor-community/sqlite MDC Ruleset |
|||
|
|||
## Project Overview |
|||
This ruleset is for the `@capacitor-community/sqlite` plugin, a Capacitor community plugin that provides native and Electron SQLite database functionality with encryption support. |
|||
|
|||
## Key Features |
|||
- Native SQLite database support for iOS, Android, and Electron |
|||
- Database encryption support using SQLCipher (Native) and better-sqlite3-multiple-ciphers (Electron) |
|||
- Biometric authentication support |
|||
- Cross-platform database operations |
|||
- JSON import/export capabilities |
|||
- Database migration support |
|||
- Sync table functionality |
|||
|
|||
## Platform Support Matrix |
|||
|
|||
### Core Database Operations |
|||
| Operation | Android | iOS | Electron | Web | |
|||
|-----------|---------|-----|----------|-----| |
|||
| Create Connection (RW) | ✅ | ✅ | ✅ | ✅ | |
|||
| Create Connection (RO) | ✅ | ✅ | ✅ | ❌ | |
|||
| Open DB (non-encrypted) | ✅ | ✅ | ✅ | ✅ | |
|||
| Open DB (encrypted) | ✅ | ✅ | ✅ | ❌ | |
|||
| Execute/Query | ✅ | ✅ | ✅ | ✅ | |
|||
| Import/Export JSON | ✅ | ✅ | ✅ | ✅ | |
|||
|
|||
### Security Features |
|||
| Feature | Android | iOS | Electron | Web | |
|||
|---------|---------|-----|----------|-----| |
|||
| Encryption | ✅ | ✅ | ✅ | ❌ | |
|||
| Biometric Auth | ✅ | ✅ | ✅ | ❌ | |
|||
| Secret Management | ✅ | ✅ | ✅ | ❌ | |
|||
|
|||
## Configuration Requirements |
|||
|
|||
### Base Configuration |
|||
```typescript |
|||
// capacitor.config.ts |
|||
{ |
|||
plugins: { |
|||
CapacitorSQLite: { |
|||
iosDatabaseLocation: 'Library/CapacitorDatabase', |
|||
iosIsEncryption: true, |
|||
iosKeychainPrefix: 'your-app-prefix', |
|||
androidIsEncryption: true, |
|||
electronIsEncryption: true |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### Platform-Specific Requirements |
|||
|
|||
#### Android |
|||
- Minimum SDK: 23 |
|||
- Target SDK: 35 |
|||
- Required Gradle JDK: 21 |
|||
- Required Android Gradle Plugin: 8.7.2 |
|||
- Required manifest settings for backup prevention |
|||
- Required data extraction rules |
|||
|
|||
#### iOS |
|||
- No additional configuration needed beyond base setup |
|||
- Supports biometric authentication |
|||
- Uses keychain for encryption |
|||
|
|||
#### Electron |
|||
Required dependencies: |
|||
```json |
|||
{ |
|||
"dependencies": { |
|||
"better-sqlite3-multiple-ciphers": "latest", |
|||
"electron-json-storage": "latest", |
|||
"jszip": "latest", |
|||
"node-fetch": "2.6.7", |
|||
"crypto": "latest", |
|||
"crypto-js": "latest" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
#### Web |
|||
- Requires `sql.js` and `jeep-sqlite` |
|||
- Manual copy of `sql-wasm.wasm` to assets folder |
|||
- Framework-specific asset placement: |
|||
- Angular: `src/assets/` |
|||
- Vue/React: `public/assets/` |
|||
|
|||
## Best Practices |
|||
|
|||
### Database Operations |
|||
1. Always close connections after use |
|||
2. Use transactions for multiple operations |
|||
3. Implement proper error handling |
|||
4. Use prepared statements for queries |
|||
5. Implement proper database versioning |
|||
|
|||
### Security |
|||
1. Always use encryption for sensitive data |
|||
2. Implement proper secret management |
|||
3. Use biometric authentication when available |
|||
4. Follow platform-specific security guidelines |
|||
|
|||
### Performance |
|||
1. Use appropriate indexes |
|||
2. Implement connection pooling |
|||
3. Use transactions for bulk operations |
|||
4. Implement proper database cleanup |
|||
|
|||
## Common Issues and Solutions |
|||
|
|||
### Android |
|||
- Build data properties conflict: Add to `app/build.gradle`: |
|||
```gradle |
|||
packagingOptions { |
|||
exclude 'build-data.properties' |
|||
} |
|||
``` |
|||
|
|||
### Electron |
|||
- Node-fetch version must be ≤2.6.7 |
|||
- For Capacitor Electron v5: |
|||
- Use Electron@25.8.4 |
|||
- Add `"skipLibCheck": true` to tsconfig.json |
|||
|
|||
### Web |
|||
- Ensure proper WASM file placement |
|||
- Handle browser compatibility |
|||
- Implement proper fallbacks |
|||
|
|||
## Version Compatibility |
|||
- Requires Node.js ≥16.0.0 |
|||
- Compatible with Capacitor ≥7.0.0 |
|||
- Supports TypeScript 4.1.5+ |
|||
|
|||
## Testing Requirements |
|||
- Unit tests for database operations |
|||
- Platform-specific integration tests |
|||
- Encryption/decryption tests |
|||
- Biometric authentication tests |
|||
- Migration tests |
|||
- Sync functionality tests |
|||
|
|||
## Documentation |
|||
- API Documentation: `/docs/API.md` |
|||
- Connection API: `/docs/APIConnection.md` |
|||
- DB Connection API: `/docs/APIDBConnection.md` |
|||
- Release Notes: `/docs/info_releases.md` |
|||
- Changelog: `CHANGELOG.md` |
|||
|
|||
## Contributing Guidelines |
|||
- Follow Ionic coding standards |
|||
- Use provided linting and formatting tools |
|||
- Maintain platform compatibility |
|||
- Update documentation |
|||
- Add appropriate tests |
|||
- Follow semantic versioning |
|||
|
|||
## Maintenance |
|||
- Regular security updates |
|||
- Platform compatibility checks |
|||
- Performance optimization |
|||
- Documentation updates |
|||
- Dependency updates |
|||
|
|||
## License |
|||
MIT License - See LICENSE file for details |
@ -0,0 +1,139 @@ |
|||
import { |
|||
PlatformService, |
|||
PlatformCapabilities, |
|||
SQLiteOperations, |
|||
} from "./PlatformService"; |
|||
import { CapacitorSQLiteService } from "./sqlite/CapacitorSQLiteService"; |
|||
import { Capacitor } from "@capacitor/core"; |
|||
import { Camera } from "@capacitor/camera"; |
|||
import { Filesystem, Directory } from "@capacitor/filesystem"; |
|||
import { logger } from "../utils/logger"; |
|||
|
|||
export class CapacitorPlatformService implements PlatformService { |
|||
private sqliteService: CapacitorSQLiteService | null = null; |
|||
|
|||
getCapabilities(): PlatformCapabilities { |
|||
const platform = Capacitor.getPlatform(); |
|||
return { |
|||
hasFileSystem: true, |
|||
hasCamera: true, |
|||
isMobile: true, |
|||
isIOS: platform === "ios", |
|||
hasFileDownload: true, |
|||
needsFileHandlingInstructions: false, |
|||
sqlite: { |
|||
supported: true, |
|||
runsInWorker: false, |
|||
hasSharedArrayBuffer: false, |
|||
supportsWAL: true, |
|||
maxSize: 1024 * 1024 * 1024 * 2, // 2GB limit for mobile SQLite
|
|||
}, |
|||
}; |
|||
} |
|||
|
|||
async getSQLite(): Promise<SQLiteOperations> { |
|||
if (!this.sqliteService) { |
|||
this.sqliteService = new CapacitorSQLiteService(); |
|||
} |
|||
return this.sqliteService; |
|||
} |
|||
|
|||
async readFile(path: string): Promise<string> { |
|||
try { |
|||
const result = await Filesystem.readFile({ |
|||
path, |
|||
directory: Directory.Data, |
|||
}); |
|||
return result.data; |
|||
} catch (error) { |
|||
logger.error("Failed to read file:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async writeFile(path: string, content: string): Promise<void> { |
|||
try { |
|||
await Filesystem.writeFile({ |
|||
path, |
|||
data: content, |
|||
directory: Directory.Data, |
|||
}); |
|||
} catch (error) { |
|||
logger.error("Failed to write file:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async deleteFile(path: string): Promise<void> { |
|||
try { |
|||
await Filesystem.deleteFile({ |
|||
path, |
|||
directory: Directory.Data, |
|||
}); |
|||
} catch (error) { |
|||
logger.error("Failed to delete file:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async listFiles(directory: string): Promise<string[]> { |
|||
try { |
|||
const result = await Filesystem.readdir({ |
|||
path: directory, |
|||
directory: Directory.Data, |
|||
}); |
|||
return result.files.map((file) => file.name); |
|||
} catch (error) { |
|||
logger.error("Failed to list files:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async takePicture(): Promise<{ blob: Blob; fileName: string }> { |
|||
try { |
|||
const image = await Camera.getPhoto({ |
|||
quality: 90, |
|||
allowEditing: true, |
|||
resultType: "base64", |
|||
}); |
|||
|
|||
const response = await fetch( |
|||
`data:image/jpeg;base64,${image.base64String}`, |
|||
); |
|||
const blob = await response.blob(); |
|||
const fileName = `photo_${Date.now()}.jpg`; |
|||
|
|||
return { blob, fileName }; |
|||
} catch (error) { |
|||
logger.error("Failed to take picture:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async pickImage(): Promise<{ blob: Blob; fileName: string }> { |
|||
try { |
|||
const image = await Camera.getPhoto({ |
|||
quality: 90, |
|||
allowEditing: true, |
|||
resultType: "base64", |
|||
source: "PHOTOLIBRARY", |
|||
}); |
|||
|
|||
const response = await fetch( |
|||
`data:image/jpeg;base64,${image.base64String}`, |
|||
); |
|||
const blob = await response.blob(); |
|||
const fileName = `image_${Date.now()}.jpg`; |
|||
|
|||
return { blob, fileName }; |
|||
} catch (error) { |
|||
logger.error("Failed to pick image:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async handleDeepLink(url: string): Promise<void> { |
|||
// Implement deep link handling for Capacitor platform
|
|||
logger.info("Handling deep link:", url); |
|||
} |
|||
} |
@ -0,0 +1,295 @@ |
|||
import { |
|||
PlatformService, |
|||
PlatformCapabilities, |
|||
SQLiteOperations, |
|||
SQLiteConfig, |
|||
PreparedStatement, |
|||
} from "./PlatformService"; |
|||
import { BaseSQLiteService } from "./sqlite/BaseSQLiteService"; |
|||
import { app } from "electron"; |
|||
import { dialog } from "electron"; |
|||
import { promises as fs } from "fs"; |
|||
import { join } from "path"; |
|||
import sqlite3 from "sqlite3"; |
|||
import { open, Database } from "sqlite"; |
|||
import { logger } from "../utils/logger"; |
|||
|
|||
/** |
|||
* SQLite implementation for Electron using native sqlite3 |
|||
*/ |
|||
class ElectronSQLiteService extends BaseSQLiteService { |
|||
private db: Database | null = null; |
|||
private config: SQLiteConfig | null = null; |
|||
|
|||
async initialize(config: SQLiteConfig): Promise<void> { |
|||
if (this.initialized) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
this.config = config; |
|||
const dbPath = join(app.getPath("userData"), `${config.name}.db`); |
|||
|
|||
this.db = await open({ |
|||
filename: dbPath, |
|||
driver: sqlite3.Database, |
|||
}); |
|||
|
|||
// Configure database settings
|
|||
if (config.useWAL) { |
|||
await this.execute("PRAGMA journal_mode = WAL"); |
|||
this.stats.walMode = true; |
|||
} |
|||
|
|||
// Set other pragmas for performance
|
|||
await this.execute("PRAGMA synchronous = NORMAL"); |
|||
await this.execute("PRAGMA temp_store = MEMORY"); |
|||
await this.execute("PRAGMA cache_size = -2000"); // Use 2MB of cache
|
|||
|
|||
this.initialized = true; |
|||
await this.updateStats(); |
|||
} catch (error) { |
|||
logger.error("Failed to initialize Electron SQLite:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async close(): Promise<void> { |
|||
if (!this.initialized || !this.db) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
await this.db.close(); |
|||
this.db = null; |
|||
this.initialized = false; |
|||
} catch (error) { |
|||
logger.error("Failed to close Electron SQLite connection:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
protected async _executeQuery<T>( |
|||
sql: string, |
|||
params: unknown[] = [], |
|||
operation: "query" | "execute" = "query", |
|||
): Promise<SQLiteResult<T>> { |
|||
if (!this.db) { |
|||
throw new Error("Database not initialized"); |
|||
} |
|||
|
|||
try { |
|||
if (operation === "query") { |
|||
const rows = await this.db.all<T>(sql, params); |
|||
const result = await this.db.run("SELECT last_insert_rowid() as id"); |
|||
return { |
|||
rows, |
|||
rowsAffected: this.db.changes, |
|||
lastInsertId: result.lastID, |
|||
executionTime: 0, // Will be set by base class
|
|||
}; |
|||
} else { |
|||
const result = await this.db.run(sql, params); |
|||
return { |
|||
rows: [], |
|||
rowsAffected: this.db.changes, |
|||
lastInsertId: result.lastID, |
|||
executionTime: 0, // Will be set by base class
|
|||
}; |
|||
} |
|||
} catch (error) { |
|||
logger.error("Electron SQLite query failed:", { |
|||
sql, |
|||
params, |
|||
error: error instanceof Error ? error.message : String(error), |
|||
}); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
protected async _beginTransaction(): Promise<void> { |
|||
if (!this.db) { |
|||
throw new Error("Database not initialized"); |
|||
} |
|||
await this.db.run("BEGIN TRANSACTION"); |
|||
} |
|||
|
|||
protected async _commitTransaction(): Promise<void> { |
|||
if (!this.db) { |
|||
throw new Error("Database not initialized"); |
|||
} |
|||
await this.db.run("COMMIT"); |
|||
} |
|||
|
|||
protected async _rollbackTransaction(): Promise<void> { |
|||
if (!this.db) { |
|||
throw new Error("Database not initialized"); |
|||
} |
|||
await this.db.run("ROLLBACK"); |
|||
} |
|||
|
|||
protected async _prepareStatement<T>( |
|||
sql: string, |
|||
): Promise<PreparedStatement<T>> { |
|||
if (!this.db) { |
|||
throw new Error("Database not initialized"); |
|||
} |
|||
|
|||
const stmt = await this.db.prepare(sql); |
|||
return { |
|||
execute: async (params: unknown[] = []) => { |
|||
if (!this.db) { |
|||
throw new Error("Database not initialized"); |
|||
} |
|||
|
|||
const rows = await stmt.all<T>(params); |
|||
return { |
|||
rows, |
|||
rowsAffected: this.db.changes, |
|||
lastInsertId: (await this.db.run("SELECT last_insert_rowid() as id")) |
|||
.lastID, |
|||
executionTime: 0, // Will be set by base class
|
|||
}; |
|||
}, |
|||
finalize: async () => { |
|||
await stmt.finalize(); |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
protected async _finalizeStatement(_sql: string): Promise<void> { |
|||
// Statements are finalized when the PreparedStatement is finalized
|
|||
} |
|||
|
|||
async getDatabaseSize(): Promise<number> { |
|||
if (!this.db || !this.config) { |
|||
throw new Error("Database not initialized"); |
|||
} |
|||
|
|||
try { |
|||
const dbPath = join(app.getPath("userData"), `${this.config.name}.db`); |
|||
const stats = await fs.stat(dbPath); |
|||
return stats.size; |
|||
} catch (error) { |
|||
logger.error("Failed to get database size:", error); |
|||
return 0; |
|||
} |
|||
} |
|||
} |
|||
|
|||
export class ElectronPlatformService implements PlatformService { |
|||
private sqliteService: ElectronSQLiteService | null = null; |
|||
|
|||
getCapabilities(): PlatformCapabilities { |
|||
return { |
|||
hasFileSystem: true, |
|||
hasCamera: true, |
|||
isMobile: false, |
|||
isIOS: false, |
|||
hasFileDownload: true, |
|||
needsFileHandlingInstructions: false, |
|||
sqlite: { |
|||
supported: true, |
|||
runsInWorker: false, |
|||
hasSharedArrayBuffer: true, |
|||
supportsWAL: true, |
|||
maxSize: 1024 * 1024 * 1024 * 10, // 10GB limit for desktop SQLite
|
|||
}, |
|||
}; |
|||
} |
|||
|
|||
async getSQLite(): Promise<SQLiteOperations> { |
|||
if (!this.sqliteService) { |
|||
this.sqliteService = new ElectronSQLiteService(); |
|||
} |
|||
return this.sqliteService; |
|||
} |
|||
|
|||
async readFile(path: string): Promise<string> { |
|||
try { |
|||
return await fs.readFile(path, "utf-8"); |
|||
} catch (error) { |
|||
logger.error("Failed to read file:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async writeFile(path: string, content: string): Promise<void> { |
|||
try { |
|||
await fs.writeFile(path, content, "utf-8"); |
|||
} catch (error) { |
|||
logger.error("Failed to write file:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async deleteFile(path: string): Promise<void> { |
|||
try { |
|||
await fs.unlink(path); |
|||
} catch (error) { |
|||
logger.error("Failed to delete file:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async listFiles(directory: string): Promise<string[]> { |
|||
try { |
|||
const files = await fs.readdir(directory); |
|||
return files; |
|||
} catch (error) { |
|||
logger.error("Failed to list files:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async takePicture(): Promise<{ blob: Blob; fileName: string }> { |
|||
try { |
|||
const { canceled, filePaths } = await dialog.showOpenDialog({ |
|||
properties: ["openFile"], |
|||
filters: [{ name: "Images", extensions: ["jpg", "png", "gif"] }], |
|||
}); |
|||
|
|||
if (canceled || !filePaths.length) { |
|||
throw new Error("No image selected"); |
|||
} |
|||
|
|||
const filePath = filePaths[0]; |
|||
const buffer = await fs.readFile(filePath); |
|||
const blob = new Blob([buffer], { type: "image/jpeg" }); |
|||
const fileName = `photo_${Date.now()}.jpg`; |
|||
|
|||
return { blob, fileName }; |
|||
} catch (error) { |
|||
logger.error("Failed to take picture:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async pickImage(): Promise<{ blob: Blob; fileName: string }> { |
|||
try { |
|||
const { canceled, filePaths } = await dialog.showOpenDialog({ |
|||
properties: ["openFile"], |
|||
filters: [{ name: "Images", extensions: ["jpg", "png", "gif"] }], |
|||
}); |
|||
|
|||
if (canceled || !filePaths.length) { |
|||
throw new Error("No image selected"); |
|||
} |
|||
|
|||
const filePath = filePaths[0]; |
|||
const buffer = await fs.readFile(filePath); |
|||
const blob = new Blob([buffer], { type: "image/jpeg" }); |
|||
const fileName = `image_${Date.now()}.jpg`; |
|||
|
|||
return { blob, fileName }; |
|||
} catch (error) { |
|||
logger.error("Failed to pick image:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async handleDeepLink(url: string): Promise<void> { |
|||
// Implement deep link handling for Electron platform
|
|||
logger.info("Handling deep link:", url); |
|||
} |
|||
} |
@ -0,0 +1,43 @@ |
|||
import { |
|||
PlatformService, |
|||
PlatformCapabilities, |
|||
SQLiteOperations, |
|||
} from "./PlatformService"; |
|||
import { AbsurdSQLService } from "./sqlite/AbsurdSQLService"; |
|||
import { logger } from "../utils/logger"; |
|||
|
|||
export class WebPlatformService implements PlatformService { |
|||
private sqliteService: AbsurdSQLService | null = null; |
|||
|
|||
getCapabilities(): PlatformCapabilities { |
|||
return { |
|||
hasFileSystem: false, |
|||
hasCamera: "mediaDevices" in navigator, |
|||
isMobile: /iPhone|iPad|iPod|Android/i.test(navigator.userAgent), |
|||
isIOS: /iPhone|iPad|iPod/i.test(navigator.userAgent), |
|||
hasFileDownload: true, |
|||
needsFileHandlingInstructions: true, |
|||
sqlite: { |
|||
supported: true, |
|||
runsInWorker: true, |
|||
hasSharedArrayBuffer: typeof SharedArrayBuffer !== "undefined", |
|||
supportsWAL: true, |
|||
maxSize: 1024 * 1024 * 1024, // 1GB limit for IndexedDB
|
|||
}, |
|||
}; |
|||
} |
|||
|
|||
async getSQLite(): Promise<SQLiteOperations> { |
|||
if (!this.sqliteService) { |
|||
this.sqliteService = new AbsurdSQLService(); |
|||
} |
|||
return this.sqliteService; |
|||
} |
|||
|
|||
// ... existing file system and camera methods ...
|
|||
|
|||
async handleDeepLink(url: string): Promise<void> { |
|||
// Implement deep link handling for web platform
|
|||
logger.info("Handling deep link:", url); |
|||
} |
|||
} |
@ -0,0 +1,248 @@ |
|||
import initSqlJs, { Database } from "@jlongster/sql.js"; |
|||
import { SQLiteFS } from "absurd-sql"; |
|||
import { IndexedDBBackend } from "absurd-sql/dist/indexeddb-backend"; |
|||
import { BaseSQLiteService } from "./BaseSQLiteService"; |
|||
import { |
|||
SQLiteConfig, |
|||
SQLiteResult, |
|||
PreparedStatement, |
|||
} from "../PlatformService"; |
|||
import { logger } from "../../utils/logger"; |
|||
|
|||
/** |
|||
* SQLite implementation using absurd-sql for web browsers. |
|||
* Provides SQLite access in the browser using Web Workers and IndexedDB. |
|||
*/ |
|||
export class AbsurdSQLService extends BaseSQLiteService { |
|||
private db: Database | null = null; |
|||
private worker: Worker | null = null; |
|||
private config: SQLiteConfig | null = null; |
|||
|
|||
async initialize(config: SQLiteConfig): Promise<void> { |
|||
if (this.initialized) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
this.config = config; |
|||
const SQL = await initSqlJs({ |
|||
locateFile: (file) => `/sql-wasm/${file}`, |
|||
}); |
|||
|
|||
// Initialize the virtual file system
|
|||
const backend = new IndexedDBBackend(this.config.name); |
|||
const fs = new SQLiteFS(SQL.FS, backend); |
|||
SQL.register_for_idb(fs); |
|||
|
|||
// Create and initialize the database
|
|||
this.db = new SQL.Database(this.config.name, { |
|||
filename: true, |
|||
}); |
|||
|
|||
// Configure database settings
|
|||
if (this.config.useWAL) { |
|||
await this.execute("PRAGMA journal_mode = WAL"); |
|||
this.stats.walMode = true; |
|||
} |
|||
|
|||
if (this.config.useMMap) { |
|||
const mmapSize = this.config.mmapSize ?? 30000000000; |
|||
await this.execute(`PRAGMA mmap_size = ${mmapSize}`); |
|||
this.stats.mmapActive = true; |
|||
} |
|||
|
|||
// Set other pragmas for performance
|
|||
await this.execute("PRAGMA synchronous = NORMAL"); |
|||
await this.execute("PRAGMA temp_store = MEMORY"); |
|||
await this.execute("PRAGMA cache_size = -2000"); // Use 2MB of cache
|
|||
|
|||
// Start the Web Worker for async operations
|
|||
this.worker = new Worker(new URL("./sqlite.worker.ts", import.meta.url), { |
|||
type: "module", |
|||
}); |
|||
|
|||
this.initialized = true; |
|||
await this.updateStats(); |
|||
} catch (error) { |
|||
logger.error("Failed to initialize Absurd SQL:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async close(): Promise<void> { |
|||
if (!this.initialized || !this.db) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
// Finalize all prepared statements
|
|||
for (const [_sql, stmt] of this.preparedStatements) { |
|||
logger.debug("finalizing statement", _sql); |
|||
await stmt.finalize(); |
|||
} |
|||
this.preparedStatements.clear(); |
|||
|
|||
// Close the database
|
|||
this.db.close(); |
|||
this.db = null; |
|||
|
|||
// Terminate the worker
|
|||
if (this.worker) { |
|||
this.worker.terminate(); |
|||
this.worker = null; |
|||
} |
|||
|
|||
this.initialized = false; |
|||
} catch (error) { |
|||
logger.error("Failed to close Absurd SQL connection:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
protected async _executeQuery<T>( |
|||
sql: string, |
|||
params: unknown[] = [], |
|||
operation: "query" | "execute" = "query", |
|||
): Promise<SQLiteResult<T>> { |
|||
if (!this.db) { |
|||
throw new Error("Database not initialized"); |
|||
} |
|||
|
|||
try { |
|||
let lastInsertId: number | undefined = undefined; |
|||
|
|||
if (operation === "query") { |
|||
const stmt = this.db.prepare(sql); |
|||
const rows: T[] = []; |
|||
|
|||
try { |
|||
while (stmt.step()) { |
|||
rows.push(stmt.getAsObject() as T); |
|||
} |
|||
} finally { |
|||
stmt.free(); |
|||
} |
|||
|
|||
// Get last insert ID safely
|
|||
const result = this.db.exec("SELECT last_insert_rowid() AS id"); |
|||
lastInsertId = |
|||
(result?.[0]?.values?.[0]?.[0] as number | undefined) ?? undefined; |
|||
|
|||
return { |
|||
rows, |
|||
rowsAffected: this.db.getRowsModified(), |
|||
lastInsertId, |
|||
executionTime: 0, // Will be set by base class
|
|||
}; |
|||
} else { |
|||
this.db.run(sql, params); |
|||
|
|||
// Get last insert ID after execute
|
|||
const result = this.db.exec("SELECT last_insert_rowid() AS id"); |
|||
lastInsertId = |
|||
(result?.[0]?.values?.[0]?.[0] as number | undefined) ?? undefined; |
|||
|
|||
return { |
|||
rows: [], |
|||
rowsAffected: this.db.getRowsModified(), |
|||
lastInsertId, |
|||
executionTime: 0, |
|||
}; |
|||
} |
|||
} catch (error) { |
|||
logger.error("Absurd SQL query failed:", { |
|||
sql, |
|||
params, |
|||
error: error instanceof Error ? error.message : String(error), |
|||
}); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
protected async _beginTransaction(): Promise<void> { |
|||
if (!this.db) { |
|||
throw new Error("Database not initialized"); |
|||
} |
|||
this.db.exec("BEGIN TRANSACTION"); |
|||
} |
|||
|
|||
protected async _commitTransaction(): Promise<void> { |
|||
if (!this.db) { |
|||
throw new Error("Database not initialized"); |
|||
} |
|||
this.db.exec("COMMIT"); |
|||
} |
|||
|
|||
protected async _rollbackTransaction(): Promise<void> { |
|||
if (!this.db) { |
|||
throw new Error("Database not initialized"); |
|||
} |
|||
this.db.exec("ROLLBACK"); |
|||
} |
|||
|
|||
protected async _prepareStatement<T>( |
|||
_sql: string, |
|||
): Promise<PreparedStatement<T>> { |
|||
if (!this.db) { |
|||
throw new Error("Database not initialized"); |
|||
} |
|||
|
|||
const stmt = this.db.prepare(_sql); |
|||
return { |
|||
execute: async (params: unknown[] = []) => { |
|||
if (!this.db) { |
|||
throw new Error("Database not initialized"); |
|||
} |
|||
|
|||
try { |
|||
const rows: T[] = []; |
|||
stmt.bind(params); |
|||
while (stmt.step()) { |
|||
rows.push(stmt.getAsObject() as T); |
|||
} |
|||
|
|||
// Safely extract lastInsertId
|
|||
const result = this.db.exec("SELECT last_insert_rowid()"); |
|||
const rawId = result?.[0]?.values?.[0]?.[0]; |
|||
const lastInsertId = typeof rawId === "number" ? rawId : undefined; |
|||
|
|||
return { |
|||
rows, |
|||
rowsAffected: this.db.getRowsModified(), |
|||
lastInsertId, |
|||
executionTime: 0, // Will be set by base class
|
|||
}; |
|||
} finally { |
|||
stmt.reset(); |
|||
} |
|||
}, |
|||
finalize: async () => { |
|||
stmt.free(); |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
protected async _finalizeStatement(_sql: string): Promise<void> { |
|||
// Statements are finalized when the PreparedStatement is finalized
|
|||
} |
|||
|
|||
async getDatabaseSize(): Promise<number> { |
|||
if (!this.db) { |
|||
throw new Error("Database not initialized"); |
|||
} |
|||
|
|||
try { |
|||
const result = this.db.exec( |
|||
"SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()", |
|||
); |
|||
|
|||
const rawSize = result?.[0]?.values?.[0]?.[0]; |
|||
const size = typeof rawSize === "number" ? rawSize : 0; |
|||
|
|||
return size; |
|||
} catch (error) { |
|||
logger.error("Failed to get database size:", error); |
|||
return 0; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,383 @@ |
|||
import { |
|||
SQLiteOperations, |
|||
SQLiteConfig, |
|||
SQLiteResult, |
|||
PreparedStatement, |
|||
SQLiteStats, |
|||
} from "../PlatformService"; |
|||
import { Settings, MASTER_SETTINGS_KEY } from "../../db/tables/settings"; |
|||
import { logger } from "../../utils/logger"; |
|||
|
|||
/** |
|||
* Base class for SQLite implementations across different platforms. |
|||
* Provides common functionality and error handling. |
|||
*/ |
|||
export abstract class BaseSQLiteService implements SQLiteOperations { |
|||
protected initialized = false; |
|||
protected stats: SQLiteStats = { |
|||
totalQueries: 0, |
|||
avgExecutionTime: 0, |
|||
preparedStatements: 0, |
|||
databaseSize: 0, |
|||
walMode: false, |
|||
mmapActive: false, |
|||
}; |
|||
protected preparedStatements: Map<string, PreparedStatement<unknown>> = |
|||
new Map(); |
|||
|
|||
abstract initialize(config: SQLiteConfig): Promise<void>; |
|||
abstract close(): Promise<void>; |
|||
abstract getDatabaseSize(): Promise<number>; |
|||
|
|||
protected async executeQuery<T>( |
|||
sql: string, |
|||
params: unknown[] = [], |
|||
operation: "query" | "execute" = "query", |
|||
): Promise<SQLiteResult<T>> { |
|||
if (!this.initialized) { |
|||
throw new Error("SQLite database not initialized"); |
|||
} |
|||
|
|||
const startTime = performance.now(); |
|||
try { |
|||
const result = await this._executeQuery<T>(sql, params, operation); |
|||
const executionTime = performance.now() - startTime; |
|||
|
|||
// Update stats
|
|||
this.stats.totalQueries++; |
|||
this.stats.avgExecutionTime = |
|||
(this.stats.avgExecutionTime * (this.stats.totalQueries - 1) + |
|||
executionTime) / |
|||
this.stats.totalQueries; |
|||
|
|||
return { |
|||
...result, |
|||
executionTime, |
|||
}; |
|||
} catch (error) { |
|||
logger.error("SQLite query failed:", { |
|||
sql, |
|||
params, |
|||
error: error instanceof Error ? error.message : String(error), |
|||
}); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
protected abstract _executeQuery<T>( |
|||
sql: string, |
|||
params: unknown[], |
|||
operation: "query" | "execute", |
|||
): Promise<SQLiteResult<T>>; |
|||
|
|||
async query<T>( |
|||
sql: string, |
|||
params: unknown[] = [], |
|||
): Promise<SQLiteResult<T>> { |
|||
return this.executeQuery<T>(sql, params, "query"); |
|||
} |
|||
|
|||
async execute(sql: string, params: unknown[] = []): Promise<number> { |
|||
const result = await this.executeQuery<unknown>(sql, params, "execute"); |
|||
return result.rowsAffected; |
|||
} |
|||
|
|||
async transaction( |
|||
statements: { sql: string; params?: unknown[] }[], |
|||
): Promise<void> { |
|||
if (!this.initialized) { |
|||
throw new Error("SQLite database not initialized"); |
|||
} |
|||
|
|||
try { |
|||
await this._beginTransaction(); |
|||
for (const { sql, params = [] } of statements) { |
|||
await this.executeQuery(sql, params, "execute"); |
|||
} |
|||
await this._commitTransaction(); |
|||
} catch (error) { |
|||
await this._rollbackTransaction(); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
protected abstract _beginTransaction(): Promise<void>; |
|||
protected abstract _commitTransaction(): Promise<void>; |
|||
protected abstract _rollbackTransaction(): Promise<void>; |
|||
|
|||
async getMaxValue<T>( |
|||
table: string, |
|||
column: string, |
|||
where?: string, |
|||
params: unknown[] = [], |
|||
): Promise<T | null> { |
|||
const sql = `SELECT MAX(${column}) as max_value FROM ${table}${where ? ` WHERE ${where}` : ""}`; |
|||
const result = await this.query<{ max_value: T }>(sql, params); |
|||
return result.rows[0]?.max_value ?? null; |
|||
} |
|||
|
|||
async prepare<T>(sql: string): Promise<PreparedStatement<T>> { |
|||
if (!this.initialized) { |
|||
throw new Error("SQLite database not initialized"); |
|||
} |
|||
|
|||
const stmt = await this._prepareStatement<T>(sql); |
|||
this.stats.preparedStatements++; |
|||
this.preparedStatements.set(sql, stmt); |
|||
|
|||
return { |
|||
execute: async (params: unknown[] = []) => { |
|||
return this.executeQuery<T>(sql, params, "query"); |
|||
}, |
|||
finalize: async () => { |
|||
await this._finalizeStatement(sql); |
|||
this.preparedStatements.delete(sql); |
|||
this.stats.preparedStatements--; |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
protected abstract _prepareStatement<T>( |
|||
sql: string, |
|||
): Promise<PreparedStatement<T>>; |
|||
protected abstract _finalizeStatement(sql: string): Promise<void>; |
|||
|
|||
async getStats(): Promise<SQLiteStats> { |
|||
return { |
|||
...this.stats, |
|||
databaseSize: await this.getDatabaseSize(), |
|||
}; |
|||
} |
|||
|
|||
protected async updateStats(): Promise<void> { |
|||
this.stats.databaseSize = await this.getDatabaseSize(); |
|||
// Platform-specific stats updates can be implemented in subclasses
|
|||
} |
|||
|
|||
protected async setupSchema(): Promise<void> { |
|||
await this.execute(` |
|||
CREATE TABLE IF NOT EXISTS settings ( |
|||
id INTEGER PRIMARY KEY, |
|||
accountDid TEXT, |
|||
activeDid TEXT, |
|||
apiServer TEXT, |
|||
filterFeedByNearby INTEGER, |
|||
filterFeedByVisible INTEGER, |
|||
finishedOnboarding INTEGER, |
|||
firstName TEXT, |
|||
hideRegisterPromptOnNewContact INTEGER, |
|||
isRegistered INTEGER, |
|||
lastName TEXT, |
|||
lastAckedOfferToUserJwtId TEXT, |
|||
lastAckedOfferToUserProjectsJwtId TEXT, |
|||
lastNotifiedClaimId TEXT, |
|||
lastViewedClaimId TEXT, |
|||
notifyingNewActivityTime TEXT, |
|||
notifyingReminderMessage TEXT, |
|||
notifyingReminderTime TEXT, |
|||
partnerApiServer TEXT, |
|||
passkeyExpirationMinutes INTEGER, |
|||
profileImageUrl TEXT, |
|||
searchBoxes TEXT, |
|||
showContactGivesInline INTEGER, |
|||
showGeneralAdvanced INTEGER, |
|||
showShortcutBvc INTEGER, |
|||
vapid TEXT, |
|||
warnIfProdServer INTEGER, |
|||
warnIfTestServer INTEGER, |
|||
webPushServer TEXT |
|||
) |
|||
`);
|
|||
} |
|||
|
|||
protected async settingsToRow( |
|||
settings: Partial<Settings>, |
|||
): Promise<Record<string, unknown>> { |
|||
const row: Record<string, unknown> = {}; |
|||
|
|||
// Convert boolean values to integers for SQLite
|
|||
if ("filterFeedByNearby" in settings) |
|||
row.filterFeedByNearby = settings.filterFeedByNearby ? 1 : 0; |
|||
if ("filterFeedByVisible" in settings) |
|||
row.filterFeedByVisible = settings.filterFeedByVisible ? 1 : 0; |
|||
if ("finishedOnboarding" in settings) |
|||
row.finishedOnboarding = settings.finishedOnboarding ? 1 : 0; |
|||
if ("hideRegisterPromptOnNewContact" in settings) |
|||
row.hideRegisterPromptOnNewContact = |
|||
settings.hideRegisterPromptOnNewContact ? 1 : 0; |
|||
if ("isRegistered" in settings) |
|||
row.isRegistered = settings.isRegistered ? 1 : 0; |
|||
if ("showContactGivesInline" in settings) |
|||
row.showContactGivesInline = settings.showContactGivesInline ? 1 : 0; |
|||
if ("showGeneralAdvanced" in settings) |
|||
row.showGeneralAdvanced = settings.showGeneralAdvanced ? 1 : 0; |
|||
if ("showShortcutBvc" in settings) |
|||
row.showShortcutBvc = settings.showShortcutBvc ? 1 : 0; |
|||
if ("warnIfProdServer" in settings) |
|||
row.warnIfProdServer = settings.warnIfProdServer ? 1 : 0; |
|||
if ("warnIfTestServer" in settings) |
|||
row.warnIfTestServer = settings.warnIfTestServer ? 1 : 0; |
|||
|
|||
// Handle JSON fields
|
|||
if ("searchBoxes" in settings) |
|||
row.searchBoxes = JSON.stringify(settings.searchBoxes); |
|||
|
|||
// Copy all other fields as is
|
|||
Object.entries(settings).forEach(([key, value]) => { |
|||
if (!(key in row)) { |
|||
row[key] = value; |
|||
} |
|||
}); |
|||
|
|||
return row; |
|||
} |
|||
|
|||
protected async rowToSettings( |
|||
row: Record<string, unknown>, |
|||
): Promise<Settings> { |
|||
const settings: Settings = {}; |
|||
|
|||
// Convert integer values back to booleans
|
|||
if ("filterFeedByNearby" in row) |
|||
settings.filterFeedByNearby = !!row.filterFeedByNearby; |
|||
if ("filterFeedByVisible" in row) |
|||
settings.filterFeedByVisible = !!row.filterFeedByVisible; |
|||
if ("finishedOnboarding" in row) |
|||
settings.finishedOnboarding = !!row.finishedOnboarding; |
|||
if ("hideRegisterPromptOnNewContact" in row) |
|||
settings.hideRegisterPromptOnNewContact = |
|||
!!row.hideRegisterPromptOnNewContact; |
|||
if ("isRegistered" in row) settings.isRegistered = !!row.isRegistered; |
|||
if ("showContactGivesInline" in row) |
|||
settings.showContactGivesInline = !!row.showContactGivesInline; |
|||
if ("showGeneralAdvanced" in row) |
|||
settings.showGeneralAdvanced = !!row.showGeneralAdvanced; |
|||
if ("showShortcutBvc" in row) |
|||
settings.showShortcutBvc = !!row.showShortcutBvc; |
|||
if ("warnIfProdServer" in row) |
|||
settings.warnIfProdServer = !!row.warnIfProdServer; |
|||
if ("warnIfTestServer" in row) |
|||
settings.warnIfTestServer = !!row.warnIfTestServer; |
|||
|
|||
// Parse JSON fields
|
|||
if ("searchBoxes" in row && row.searchBoxes) { |
|||
try { |
|||
settings.searchBoxes = JSON.parse(row.searchBoxes); |
|||
} catch (error) { |
|||
logger.error("Error parsing searchBoxes JSON:", error); |
|||
} |
|||
} |
|||
|
|||
// Copy all other fields as is
|
|||
Object.entries(row).forEach(([key, value]) => { |
|||
if (!(key in settings)) { |
|||
(settings as Record<string, unknown>)[key] = value; |
|||
} |
|||
}); |
|||
|
|||
return settings; |
|||
} |
|||
|
|||
async updateMasterSettings( |
|||
settingsChanges: Partial<Settings>, |
|||
): Promise<void> { |
|||
try { |
|||
const row = await this.settingsToRow(settingsChanges); |
|||
row.id = MASTER_SETTINGS_KEY; |
|||
delete row.accountDid; |
|||
|
|||
const result = await this.execute( |
|||
`UPDATE settings SET ${Object.keys(row) |
|||
.map((k) => `${k} = ?`) |
|||
.join(", ")} WHERE id = ?`,
|
|||
[...Object.values(row), MASTER_SETTINGS_KEY], |
|||
); |
|||
|
|||
if (result === 0) { |
|||
// If no record was updated, create a new one
|
|||
await this.execute( |
|||
`INSERT INTO settings (${Object.keys(row).join(", ")}) VALUES (${Object.keys( |
|||
row, |
|||
) |
|||
.map(() => "?") |
|||
.join(", ")})`,
|
|||
Object.values(row), |
|||
); |
|||
} |
|||
} catch (error) { |
|||
logger.error("Error updating master settings:", error); |
|||
throw new Error("Failed to update settings"); |
|||
} |
|||
} |
|||
|
|||
async getActiveAccountSettings(): Promise<Settings> { |
|||
try { |
|||
const defaultSettings = await this.query<Record<string, unknown>>( |
|||
"SELECT * FROM settings WHERE id = ?", |
|||
[MASTER_SETTINGS_KEY], |
|||
); |
|||
|
|||
if (!defaultSettings.rows.length) { |
|||
return {}; |
|||
} |
|||
|
|||
const settings = await this.rowToSettings(defaultSettings.rows[0]); |
|||
|
|||
if (!settings.activeDid) { |
|||
return settings; |
|||
} |
|||
|
|||
const overrideSettings = await this.query<Record<string, unknown>>( |
|||
"SELECT * FROM settings WHERE accountDid = ?", |
|||
[settings.activeDid], |
|||
); |
|||
|
|||
if (!overrideSettings.rows.length) { |
|||
return settings; |
|||
} |
|||
|
|||
const override = await this.rowToSettings(overrideSettings.rows[0]); |
|||
return { ...settings, ...override }; |
|||
} catch (error) { |
|||
logger.error("Error getting active account settings:", error); |
|||
throw new Error("Failed to get settings"); |
|||
} |
|||
} |
|||
|
|||
async updateAccountSettings( |
|||
accountDid: string, |
|||
settingsChanges: Partial<Settings>, |
|||
): Promise<void> { |
|||
try { |
|||
const row = await this.settingsToRow(settingsChanges); |
|||
row.accountDid = accountDid; |
|||
|
|||
const result = await this.execute( |
|||
`UPDATE settings SET ${Object.keys(row) |
|||
.map((k) => `${k} = ?`) |
|||
.join(", ")} WHERE accountDid = ?`,
|
|||
[...Object.values(row), accountDid], |
|||
); |
|||
|
|||
if (result === 0) { |
|||
// If no record was updated, create a new one
|
|||
const idResult = await this.query<{ max: number }>( |
|||
"SELECT MAX(id) as max FROM settings", |
|||
); |
|||
row.id = (idResult.rows[0]?.max || 0) + 1; |
|||
|
|||
await this.execute( |
|||
`INSERT INTO settings (${Object.keys(row).join(", ")}) VALUES (${Object.keys( |
|||
row, |
|||
) |
|||
.map(() => "?") |
|||
.join(", ")})`,
|
|||
Object.values(row), |
|||
); |
|||
} |
|||
} catch (error) { |
|||
logger.error("Error updating account settings:", error); |
|||
throw new Error("Failed to update settings"); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,176 @@ |
|||
import { |
|||
CapacitorSQLite, |
|||
SQLiteConnection, |
|||
SQLiteDBConnection, |
|||
} from "@capacitor-community/sqlite"; |
|||
import { BaseSQLiteService } from "./BaseSQLiteService"; |
|||
import { |
|||
SQLiteConfig, |
|||
SQLiteResult, |
|||
PreparedStatement, |
|||
} from "../PlatformService"; |
|||
import { logger } from "../../utils/logger"; |
|||
|
|||
/** |
|||
* SQLite implementation using the Capacitor SQLite plugin. |
|||
* Provides native SQLite access on mobile platforms. |
|||
*/ |
|||
export class CapacitorSQLiteService extends BaseSQLiteService { |
|||
private connection: SQLiteDBConnection | null = null; |
|||
private sqlite: SQLiteConnection | null = null; |
|||
|
|||
async initialize(config: SQLiteConfig): Promise<void> { |
|||
if (this.initialized) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
this.sqlite = new SQLiteConnection(CapacitorSQLite); |
|||
const db = await this.sqlite.createConnection( |
|||
config.name, |
|||
config.useWAL ?? false, |
|||
"no-encryption", |
|||
1, |
|||
false, |
|||
); |
|||
|
|||
await db.open(); |
|||
this.connection = db; |
|||
|
|||
// Configure database settings
|
|||
if (config.useWAL) { |
|||
await this.execute("PRAGMA journal_mode = WAL"); |
|||
this.stats.walMode = true; |
|||
} |
|||
|
|||
// Set other pragmas for performance
|
|||
await this.execute("PRAGMA synchronous = NORMAL"); |
|||
await this.execute("PRAGMA temp_store = MEMORY"); |
|||
await this.execute("PRAGMA mmap_size = 30000000000"); |
|||
this.stats.mmapActive = true; |
|||
|
|||
// Set up database schema
|
|||
await this.setupSchema(); |
|||
|
|||
this.initialized = true; |
|||
await this.updateStats(); |
|||
} catch (error) { |
|||
logger.error("Failed to initialize Capacitor SQLite:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async close(): Promise<void> { |
|||
if (!this.initialized || !this.connection || !this.sqlite) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
await this.connection.close(); |
|||
await this.sqlite.closeConnection(this.connection); |
|||
this.connection = null; |
|||
this.sqlite = null; |
|||
this.initialized = false; |
|||
} catch (error) { |
|||
logger.error("Failed to close Capacitor SQLite connection:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
protected async _executeQuery<T>( |
|||
sql: string, |
|||
params: unknown[] = [], |
|||
operation: "query" | "execute" = "query", |
|||
): Promise<SQLiteResult<T>> { |
|||
if (!this.connection) { |
|||
throw new Error("Database connection not initialized"); |
|||
} |
|||
|
|||
try { |
|||
if (operation === "query") { |
|||
const result = await this.connection.query(sql, params); |
|||
return { |
|||
rows: result.values as T[], |
|||
rowsAffected: result.changes?.changes ?? 0, |
|||
lastInsertId: result.changes?.lastId, |
|||
executionTime: 0, // Will be set by base class
|
|||
}; |
|||
} else { |
|||
const result = await this.connection.run(sql, params); |
|||
return { |
|||
rows: [], |
|||
rowsAffected: result.changes?.changes ?? 0, |
|||
lastInsertId: result.changes?.lastId, |
|||
executionTime: 0, // Will be set by base class
|
|||
}; |
|||
} |
|||
} catch (error) { |
|||
logger.error("Capacitor SQLite query failed:", { |
|||
sql, |
|||
params, |
|||
error: error instanceof Error ? error.message : String(error), |
|||
}); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
protected async _beginTransaction(): Promise<void> { |
|||
if (!this.connection) { |
|||
throw new Error("Database connection not initialized"); |
|||
} |
|||
await this.connection.execute("BEGIN TRANSACTION"); |
|||
} |
|||
|
|||
protected async _commitTransaction(): Promise<void> { |
|||
if (!this.connection) { |
|||
throw new Error("Database connection not initialized"); |
|||
} |
|||
await this.connection.execute("COMMIT"); |
|||
} |
|||
|
|||
protected async _rollbackTransaction(): Promise<void> { |
|||
if (!this.connection) { |
|||
throw new Error("Database connection not initialized"); |
|||
} |
|||
await this.connection.execute("ROLLBACK"); |
|||
} |
|||
|
|||
protected async _prepareStatement<T>( |
|||
sql: string, |
|||
): Promise<PreparedStatement<T>> { |
|||
if (!this.connection) { |
|||
throw new Error("Database connection not initialized"); |
|||
} |
|||
|
|||
// Capacitor SQLite doesn't support prepared statements directly,
|
|||
// so we'll simulate it by storing the SQL
|
|||
return { |
|||
execute: async (params: unknown[] = []) => { |
|||
return this.executeQuery<T>(sql, params, "query"); |
|||
}, |
|||
finalize: async () => { |
|||
// No cleanup needed for Capacitor SQLite
|
|||
}, |
|||
}; |
|||
} |
|||
|
|||
protected async _finalizeStatement(_sql: string): Promise<void> { |
|||
// No cleanup needed for Capacitor SQLite
|
|||
} |
|||
|
|||
async getDatabaseSize(): Promise<number> { |
|||
if (!this.connection) { |
|||
throw new Error("Database connection not initialized"); |
|||
} |
|||
|
|||
try { |
|||
const result = await this.connection.query( |
|||
"SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()", |
|||
); |
|||
return result.values?.[0]?.size ?? 0; |
|||
} catch (error) { |
|||
logger.error("Failed to get database size:", error); |
|||
return 0; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,150 @@ |
|||
import initSqlJs, { Database } from "@jlongster/sql.js"; |
|||
import { SQLiteFS } from "absurd-sql"; |
|||
import { IndexedDBBackend } from "absurd-sql/dist/indexeddb-backend"; |
|||
|
|||
interface WorkerMessage { |
|||
type: "init" | "query" | "execute" | "transaction" | "close"; |
|||
id: string; |
|||
dbName?: string; |
|||
sql?: string; |
|||
params?: unknown[]; |
|||
statements?: { sql: string; params?: unknown[] }[]; |
|||
} |
|||
|
|||
interface WorkerResponse { |
|||
id: string; |
|||
error?: string; |
|||
result?: unknown; |
|||
} |
|||
|
|||
let db: Database | null = null; |
|||
|
|||
async function initialize(dbName: string): Promise<void> { |
|||
if (db) { |
|||
return; |
|||
} |
|||
|
|||
const SQL = await initSqlJs({ |
|||
locateFile: (file: string) => `/sql-wasm/${file}`, |
|||
}); |
|||
|
|||
// Initialize the virtual file system
|
|||
const backend = new IndexedDBBackend(dbName); |
|||
const fs = new SQLiteFS(SQL.FS, backend); |
|||
SQL.register_for_idb(fs); |
|||
|
|||
// Create and initialize the database
|
|||
db = new SQL.Database(dbName, { |
|||
filename: true, |
|||
}); |
|||
|
|||
// Configure database settings
|
|||
db.exec("PRAGMA synchronous = NORMAL"); |
|||
db.exec("PRAGMA temp_store = MEMORY"); |
|||
db.exec("PRAGMA cache_size = -2000"); // Use 2MB of cache
|
|||
} |
|||
|
|||
async function executeQuery( |
|||
sql: string, |
|||
params: unknown[] = [], |
|||
): Promise<unknown> { |
|||
if (!db) { |
|||
throw new Error("Database not initialized"); |
|||
} |
|||
|
|||
const stmt = db.prepare(sql); |
|||
try { |
|||
const rows: unknown[] = []; |
|||
stmt.bind(params); |
|||
while (stmt.step()) { |
|||
rows.push(stmt.getAsObject()); |
|||
} |
|||
return { |
|||
rows, |
|||
rowsAffected: db.getRowsModified(), |
|||
lastInsertId: db.exec("SELECT last_insert_rowid()")[0]?.values[0]?.[0], |
|||
}; |
|||
} finally { |
|||
stmt.free(); |
|||
} |
|||
} |
|||
|
|||
async function executeTransaction( |
|||
statements: { sql: string; params?: unknown[] }[], |
|||
): Promise<void> { |
|||
if (!db) { |
|||
throw new Error("Database not initialized"); |
|||
} |
|||
|
|||
try { |
|||
db.exec("BEGIN TRANSACTION"); |
|||
for (const { sql, params = [] } of statements) { |
|||
const stmt = db.prepare(sql); |
|||
try { |
|||
stmt.bind(params); |
|||
stmt.step(); |
|||
} finally { |
|||
stmt.free(); |
|||
} |
|||
} |
|||
db.exec("COMMIT"); |
|||
} catch (error) { |
|||
db.exec("ROLLBACK"); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async function close(): Promise<void> { |
|||
if (db) { |
|||
db.close(); |
|||
db = null; |
|||
} |
|||
} |
|||
|
|||
self.onmessage = async (event: MessageEvent<WorkerMessage>) => { |
|||
const { type, id, dbName, sql, params, statements } = event.data; |
|||
const response: WorkerResponse = { id }; |
|||
|
|||
try { |
|||
switch (type) { |
|||
case "init": |
|||
if (!dbName) { |
|||
throw new Error("Database name is required for initialization"); |
|||
} |
|||
await initialize(dbName); |
|||
break; |
|||
|
|||
case "query": |
|||
if (!sql) { |
|||
throw new Error("SQL query is required"); |
|||
} |
|||
response.result = await executeQuery(sql, params); |
|||
break; |
|||
|
|||
case "execute": |
|||
if (!sql) { |
|||
throw new Error("SQL statement is required"); |
|||
} |
|||
response.result = await executeQuery(sql, params); |
|||
break; |
|||
|
|||
case "transaction": |
|||
if (!statements?.length) { |
|||
throw new Error("Transaction statements are required"); |
|||
} |
|||
await executeTransaction(statements); |
|||
break; |
|||
|
|||
case "close": |
|||
await close(); |
|||
break; |
|||
|
|||
default: |
|||
throw new Error(`Unknown message type: ${type}`); |
|||
} |
|||
} catch (error) { |
|||
response.error = error instanceof Error ? error.message : String(error); |
|||
} |
|||
|
|||
self.postMessage(response); |
|||
}; |
@ -0,0 +1,45 @@ |
|||
declare module "@jlongster/sql.js" { |
|||
export interface Database { |
|||
exec( |
|||
sql: string, |
|||
params?: unknown[], |
|||
): { columns: string[]; values: unknown[][] }[]; |
|||
prepare(sql: string): Statement; |
|||
run(sql: string, params?: unknown[]): void; |
|||
getRowsModified(): number; |
|||
close(): void; |
|||
} |
|||
|
|||
export interface Statement { |
|||
step(): boolean; |
|||
getAsObject(): Record<string, unknown>; |
|||
bind(params: unknown[]): void; |
|||
reset(): void; |
|||
free(): void; |
|||
} |
|||
|
|||
export interface InitSqlJsStatic { |
|||
Database: new ( |
|||
filename?: string, |
|||
options?: { filename: boolean }, |
|||
) => Database; |
|||
FS: unknown; |
|||
register_for_idb(fs: unknown): void; |
|||
} |
|||
|
|||
export default function initSqlJs(options?: { |
|||
locateFile?: (file: string) => string; |
|||
}): Promise<InitSqlJsStatic>; |
|||
} |
|||
|
|||
declare module "absurd-sql" { |
|||
export class SQLiteFS { |
|||
constructor(fs: unknown, backend: unknown); |
|||
} |
|||
} |
|||
|
|||
declare module "absurd-sql/dist/indexeddb-backend" { |
|||
export class IndexedDBBackend { |
|||
constructor(dbName: string); |
|||
} |
|||
} |
@ -1,2 +1,2 @@ |
|||
// Empty module to satisfy Node.js built-in module imports
|
|||
export default {}; |
|||
export default {}; |
|||
|
@ -1,18 +1,18 @@ |
|||
// Minimal fs module implementation for browser
|
|||
const fs = { |
|||
readFileSync: () => { |
|||
throw new Error('fs.readFileSync is not supported in browser'); |
|||
throw new Error("fs.readFileSync is not supported in browser"); |
|||
}, |
|||
writeFileSync: () => { |
|||
throw new Error('fs.writeFileSync is not supported in browser'); |
|||
throw new Error("fs.writeFileSync is not supported in browser"); |
|||
}, |
|||
existsSync: () => false, |
|||
mkdirSync: () => {}, |
|||
readdirSync: () => [], |
|||
statSync: () => ({ |
|||
isDirectory: () => false, |
|||
isFile: () => false |
|||
}) |
|||
isFile: () => false, |
|||
}), |
|||
}; |
|||
|
|||
export default fs; |
|||
export default fs; |
|||
|
@ -1,13 +1,13 @@ |
|||
// Minimal path module implementation for browser
|
|||
const path = { |
|||
resolve: (...parts) => parts.join('/'), |
|||
join: (...parts) => parts.join('/'), |
|||
dirname: (p) => p.split('/').slice(0, -1).join('/'), |
|||
basename: (p) => p.split('/').pop(), |
|||
resolve: (...parts) => parts.join("/"), |
|||
join: (...parts) => parts.join("/"), |
|||
dirname: (p) => p.split("/").slice(0, -1).join("/"), |
|||
basename: (p) => p.split("/").pop(), |
|||
extname: (p) => { |
|||
const parts = p.split('.'); |
|||
return parts.length > 1 ? '.' + parts.pop() : ''; |
|||
} |
|||
const parts = p.split("."); |
|||
return parts.length > 1 ? "." + parts.pop() : ""; |
|||
}, |
|||
}; |
|||
|
|||
export default path; |
|||
export default path; |
|||
|
@ -1,4 +1,12 @@ |
|||
import { defineConfig } from "vite"; |
|||
import { createBuildConfig } from "./vite.config.common.mts"; |
|||
|
|||
export default defineConfig(async () => createBuildConfig('capacitor')); |
|||
export default defineConfig( |
|||
async () => { |
|||
const baseConfig = await createBuildConfig('capacitor'); |
|||
return mergeConfig(baseConfig, { |
|||
optimizeDeps: { |
|||
include: ['@capacitor-community/sqlite'] |
|||
} |
|||
}); |
|||
}); |
Loading…
Reference in new issue