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
|
// Empty module to satisfy Node.js built-in module imports
|
||||
export default {}; |
export default {}; |
||||
|
@ -1,18 +1,18 @@ |
|||||
// Minimal fs module implementation for browser
|
// Minimal fs module implementation for browser
|
||||
const fs = { |
const fs = { |
||||
readFileSync: () => { |
readFileSync: () => { |
||||
throw new Error('fs.readFileSync is not supported in browser'); |
throw new Error("fs.readFileSync is not supported in browser"); |
||||
}, |
}, |
||||
writeFileSync: () => { |
writeFileSync: () => { |
||||
throw new Error('fs.writeFileSync is not supported in browser'); |
throw new Error("fs.writeFileSync is not supported in browser"); |
||||
}, |
}, |
||||
existsSync: () => false, |
existsSync: () => false, |
||||
mkdirSync: () => {}, |
mkdirSync: () => {}, |
||||
readdirSync: () => [], |
readdirSync: () => [], |
||||
statSync: () => ({ |
statSync: () => ({ |
||||
isDirectory: () => false, |
isDirectory: () => false, |
||||
isFile: () => false |
isFile: () => false, |
||||
}) |
}), |
||||
}; |
}; |
||||
|
|
||||
export default fs; |
export default fs; |
||||
|
@ -1,13 +1,13 @@ |
|||||
// Minimal path module implementation for browser
|
// Minimal path module implementation for browser
|
||||
const path = { |
const path = { |
||||
resolve: (...parts) => parts.join('/'), |
resolve: (...parts) => parts.join("/"), |
||||
join: (...parts) => parts.join('/'), |
join: (...parts) => parts.join("/"), |
||||
dirname: (p) => p.split('/').slice(0, -1).join('/'), |
dirname: (p) => p.split("/").slice(0, -1).join("/"), |
||||
basename: (p) => p.split('/').pop(), |
basename: (p) => p.split("/").pop(), |
||||
extname: (p) => { |
extname: (p) => { |
||||
const parts = p.split('.'); |
const parts = p.split("."); |
||||
return parts.length > 1 ? '.' + parts.pop() : ''; |
return parts.length > 1 ? "." + parts.pop() : ""; |
||||
} |
}, |
||||
}; |
}; |
||||
|
|
||||
export default path; |
export default path; |
||||
|
@ -1,4 +1,12 @@ |
|||||
import { defineConfig } from "vite"; |
import { defineConfig } from "vite"; |
||||
import { createBuildConfig } from "./vite.config.common.mts"; |
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