Compare commits
27 Commits
search-map
...
sql-absurd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9ce884513 | ||
|
|
a1a1543ae1 | ||
|
|
93591a5815 | ||
|
|
b30c4c8b30 | ||
|
|
1f9db0ba94 | ||
|
|
bdc2d71d3c | ||
| 2647c5a77d | |||
|
|
682fceb1c6 | ||
|
|
e0013008b4 | ||
| 0674d98670 | |||
|
|
ee441d1aea | ||
|
|
75f6e99200 | ||
|
|
52c9e57ef4 | ||
| 603823d808 | |||
| 5f24f4975d | |||
| 5057d7d07f | |||
| 946e88d903 | |||
|
|
cbfb1ebf57 | ||
| a38934e38d | |||
| a3bdcfd168 | |||
| 83771caee1 | |||
| da35b225cd | |||
| 8c3920e108 | |||
| 54f269054f | |||
|
|
574520d9b3 | ||
| 6556eb55a3 | |||
| 634e2bb2fb |
172
.cursor/rules/SQLITE.mdc
Normal file
@@ -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
|
||||
153
.cursor/rules/absurd-sql.mdc
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# Absurd SQL - Cursor Development Guide
|
||||
|
||||
## Project Overview
|
||||
Absurd SQL is a backend implementation for sql.js that enables persistent SQLite databases in the browser by using IndexedDB as a block storage system. This guide provides rules and best practices for developing with this project in Cursor.
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
absurd-sql/
|
||||
├── src/ # Source code
|
||||
├── dist/ # Built files
|
||||
├── package.json # Dependencies and scripts
|
||||
├── rollup.config.js # Build configuration
|
||||
└── jest.config.js # Test configuration
|
||||
```
|
||||
|
||||
## Development Rules
|
||||
|
||||
### 1. Worker Thread Requirements
|
||||
- All SQL operations MUST be performed in a worker thread
|
||||
- Main thread should only handle worker initialization and communication
|
||||
- Never block the main thread with database operations
|
||||
|
||||
### 2. Code Organization
|
||||
- Keep worker code in separate files (e.g., `*.worker.js`)
|
||||
- Use ES modules for imports/exports
|
||||
- Follow the project's existing module structure
|
||||
|
||||
### 3. Required Headers
|
||||
When developing locally or deploying, ensure these headers are set:
|
||||
```
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
```
|
||||
|
||||
### 4. Browser Compatibility
|
||||
- Primary target: Modern browsers with SharedArrayBuffer support
|
||||
- Fallback mode: Safari (with limitations)
|
||||
- Always test in both modes
|
||||
|
||||
### 5. Database Configuration
|
||||
Recommended database settings:
|
||||
```sql
|
||||
PRAGMA journal_mode=MEMORY;
|
||||
PRAGMA page_size=8192; -- Optional, but recommended
|
||||
```
|
||||
|
||||
### 6. Development Workflow
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
yarn add @jlongster/sql.js absurd-sql
|
||||
```
|
||||
|
||||
2. Development commands:
|
||||
- `yarn build` - Build the project
|
||||
- `yarn jest` - Run tests
|
||||
- `yarn serve` - Start development server
|
||||
|
||||
### 7. Testing Guidelines
|
||||
- Write tests for both SharedArrayBuffer and fallback modes
|
||||
- Use Jest for testing
|
||||
- Include performance benchmarks for critical operations
|
||||
|
||||
### 8. Performance Considerations
|
||||
- Use bulk operations when possible
|
||||
- Monitor read/write performance
|
||||
- Consider using transactions for multiple operations
|
||||
- Avoid unnecessary database connections
|
||||
|
||||
### 9. Error Handling
|
||||
- Implement proper error handling for:
|
||||
- Worker initialization failures
|
||||
- Database connection issues
|
||||
- Concurrent access conflicts (in fallback mode)
|
||||
- Storage quota exceeded scenarios
|
||||
|
||||
### 10. Security Best Practices
|
||||
- Never expose database operations directly to the client
|
||||
- Validate all SQL queries
|
||||
- Implement proper access controls
|
||||
- Handle sensitive data appropriately
|
||||
|
||||
### 11. Code Style
|
||||
- Follow ESLint configuration
|
||||
- Use async/await for asynchronous operations
|
||||
- Document complex database operations
|
||||
- Include comments for non-obvious optimizations
|
||||
|
||||
### 12. Debugging
|
||||
- Use `jest-debug` for debugging tests
|
||||
- Monitor IndexedDB usage in browser dev tools
|
||||
- Check worker communication in console
|
||||
- Use performance monitoring tools
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Worker Initialization
|
||||
```javascript
|
||||
// Main thread
|
||||
import { initBackend } from 'absurd-sql/dist/indexeddb-main-thread';
|
||||
|
||||
function init() {
|
||||
let worker = new Worker(new URL('./index.worker.js', import.meta.url));
|
||||
initBackend(worker);
|
||||
}
|
||||
```
|
||||
|
||||
### Database Setup
|
||||
```javascript
|
||||
// Worker thread
|
||||
import initSqlJs from '@jlongster/sql.js';
|
||||
import { SQLiteFS } from 'absurd-sql';
|
||||
import IndexedDBBackend from 'absurd-sql/dist/indexeddb-backend';
|
||||
|
||||
async function setupDatabase() {
|
||||
let SQL = await initSqlJs({ locateFile: file => file });
|
||||
let sqlFS = new SQLiteFS(SQL.FS, new IndexedDBBackend());
|
||||
SQL.register_for_idb(sqlFS);
|
||||
|
||||
SQL.FS.mkdir('/sql');
|
||||
SQL.FS.mount(sqlFS, {}, '/sql');
|
||||
|
||||
return new SQL.Database('/sql/db.sqlite', { filename: true });
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
1. SharedArrayBuffer not available
|
||||
- Check COOP/COEP headers
|
||||
- Verify browser support
|
||||
- Test fallback mode
|
||||
|
||||
2. Worker initialization failures
|
||||
- Check file paths
|
||||
- Verify module imports
|
||||
- Check browser console for errors
|
||||
|
||||
3. Performance issues
|
||||
- Monitor IndexedDB usage
|
||||
- Check for unnecessary operations
|
||||
- Verify transaction usage
|
||||
|
||||
## Resources
|
||||
- [Project Demo](https://priceless-keller-d097e5.netlify.app/)
|
||||
- [Example Project](https://github.com/jlongster/absurd-example-project)
|
||||
- [Blog Post](https://jlongster.com/future-sql-web)
|
||||
- [SQL.js Documentation](https://github.com/sql-js/sql.js/)
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
# Camera Implementation Documentation
|
||||
|
||||
|
||||
5
.gitignore
vendored
@@ -51,7 +51,6 @@ vendor/
|
||||
# Build logs
|
||||
build_logs/
|
||||
|
||||
# PWA icon files generated by capacitor-assets
|
||||
icons
|
||||
|
||||
android/app/src/main/assets/public
|
||||
android/app/src/main/res
|
||||
|
||||
|
||||
@@ -241,7 +241,9 @@ docker run -d \
|
||||
1. Build the electron app in production mode:
|
||||
|
||||
```bash
|
||||
npm run build:electron-prod
|
||||
npm run build:web
|
||||
npm run build:electron
|
||||
npm run electron:build-mac
|
||||
```
|
||||
|
||||
2. Package the Electron app for macOS:
|
||||
@@ -343,11 +345,7 @@ Prerequisites: macOS with Xcode installed
|
||||
3. Copy the assets:
|
||||
|
||||
```bash
|
||||
# It makes no sense why capacitor-assets will not run without these but it actually changes the contents.
|
||||
mkdir -p ios/App/App/Assets.xcassets/AppIcon.appiconset
|
||||
echo '{"images":[]}' > ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
|
||||
mkdir -p ios/App/App/Assets.xcassets/Splash.imageset
|
||||
echo '{"images":[]}' > ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
|
||||
npx capacitor-assets generate --ios
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"appId": "app.timesafari.app",
|
||||
"appId": "app.timesafari",
|
||||
"appName": "TimeSafari",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false,
|
||||
|
||||
BIN
android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
android/app/src/main/res/drawable-land-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
android/app/src/main/res/drawable-land-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
android/app/src/main/res/drawable-port-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-port-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
@@ -1,9 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background>
|
||||
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||
</background>
|
||||
<foreground>
|
||||
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||
</foreground>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,9 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background>
|
||||
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||
</background>
|
||||
<foreground>
|
||||
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||
</foreground>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 15 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
assets/icon-only.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
assets/icon.png
|
Before Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
399
doc/dexie-to-sqlite-mapping.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# Dexie to absurd-sql Mapping Guide
|
||||
|
||||
## Schema Mapping
|
||||
|
||||
### Current Dexie Schema
|
||||
```typescript
|
||||
// Current Dexie schema
|
||||
const db = new Dexie('TimeSafariDB');
|
||||
|
||||
db.version(1).stores({
|
||||
accounts: 'did, publicKeyHex, createdAt, updatedAt',
|
||||
settings: 'key, value, updatedAt',
|
||||
contacts: 'id, did, name, createdAt, updatedAt'
|
||||
});
|
||||
```
|
||||
|
||||
### New SQLite Schema
|
||||
```sql
|
||||
-- New SQLite schema
|
||||
CREATE TABLE accounts (
|
||||
did TEXT PRIMARY KEY,
|
||||
public_key_hex TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
did TEXT NOT NULL,
|
||||
name TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (did) REFERENCES accounts(did)
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_accounts_created_at ON accounts(created_at);
|
||||
CREATE INDEX idx_contacts_did ON contacts(did);
|
||||
CREATE INDEX idx_settings_updated_at ON settings(updated_at);
|
||||
```
|
||||
|
||||
## Query Mapping
|
||||
|
||||
### 1. Account Operations
|
||||
|
||||
#### Get Account by DID
|
||||
```typescript
|
||||
// Dexie
|
||||
const account = await db.accounts.get(did);
|
||||
|
||||
// absurd-sql
|
||||
const result = await db.exec(`
|
||||
SELECT * FROM accounts WHERE did = ?
|
||||
`, [did]);
|
||||
const account = result[0]?.values[0];
|
||||
```
|
||||
|
||||
#### Get All Accounts
|
||||
```typescript
|
||||
// Dexie
|
||||
const accounts = await db.accounts.toArray();
|
||||
|
||||
// absurd-sql
|
||||
const result = await db.exec(`
|
||||
SELECT * FROM accounts ORDER BY created_at DESC
|
||||
`);
|
||||
const accounts = result[0]?.values || [];
|
||||
```
|
||||
|
||||
#### Add Account
|
||||
```typescript
|
||||
// Dexie
|
||||
await db.accounts.add({
|
||||
did,
|
||||
publicKeyHex,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
// absurd-sql
|
||||
await db.run(`
|
||||
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [did, publicKeyHex, Date.now(), Date.now()]);
|
||||
```
|
||||
|
||||
#### Update Account
|
||||
```typescript
|
||||
// Dexie
|
||||
await db.accounts.update(did, {
|
||||
publicKeyHex,
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
// absurd-sql
|
||||
await db.run(`
|
||||
UPDATE accounts
|
||||
SET public_key_hex = ?, updated_at = ?
|
||||
WHERE did = ?
|
||||
`, [publicKeyHex, Date.now(), did]);
|
||||
```
|
||||
|
||||
### 2. Settings Operations
|
||||
|
||||
#### Get Setting
|
||||
```typescript
|
||||
// Dexie
|
||||
const setting = await db.settings.get(key);
|
||||
|
||||
// absurd-sql
|
||||
const result = await db.exec(`
|
||||
SELECT * FROM settings WHERE key = ?
|
||||
`, [key]);
|
||||
const setting = result[0]?.values[0];
|
||||
```
|
||||
|
||||
#### Set Setting
|
||||
```typescript
|
||||
// Dexie
|
||||
await db.settings.put({
|
||||
key,
|
||||
value,
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
// absurd-sql
|
||||
await db.run(`
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at
|
||||
`, [key, value, Date.now()]);
|
||||
```
|
||||
|
||||
### 3. Contact Operations
|
||||
|
||||
#### Get Contacts by Account
|
||||
```typescript
|
||||
// Dexie
|
||||
const contacts = await db.contacts
|
||||
.where('did')
|
||||
.equals(accountDid)
|
||||
.toArray();
|
||||
|
||||
// absurd-sql
|
||||
const result = await db.exec(`
|
||||
SELECT * FROM contacts
|
||||
WHERE did = ?
|
||||
ORDER BY created_at DESC
|
||||
`, [accountDid]);
|
||||
const contacts = result[0]?.values || [];
|
||||
```
|
||||
|
||||
#### Add Contact
|
||||
```typescript
|
||||
// Dexie
|
||||
await db.contacts.add({
|
||||
id: generateId(),
|
||||
did: accountDid,
|
||||
name,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
// absurd-sql
|
||||
await db.run(`
|
||||
INSERT INTO contacts (id, did, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, [generateId(), accountDid, name, Date.now(), Date.now()]);
|
||||
```
|
||||
|
||||
## Transaction Mapping
|
||||
|
||||
### Batch Operations
|
||||
```typescript
|
||||
// Dexie
|
||||
await db.transaction('rw', [db.accounts, db.contacts], async () => {
|
||||
await db.accounts.add(account);
|
||||
await db.contacts.bulkAdd(contacts);
|
||||
});
|
||||
|
||||
// absurd-sql
|
||||
await db.exec('BEGIN TRANSACTION;');
|
||||
try {
|
||||
await db.run(`
|
||||
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]);
|
||||
|
||||
for (const contact of contacts) {
|
||||
await db.run(`
|
||||
INSERT INTO contacts (id, did, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, [contact.id, contact.did, contact.name, contact.createdAt, contact.updatedAt]);
|
||||
}
|
||||
await db.exec('COMMIT;');
|
||||
} catch (error) {
|
||||
await db.exec('ROLLBACK;');
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Helper Functions
|
||||
|
||||
### 1. Data Export (Dexie to JSON)
|
||||
```typescript
|
||||
async function exportDexieData(): Promise<MigrationData> {
|
||||
const db = new Dexie('TimeSafariDB');
|
||||
|
||||
return {
|
||||
accounts: await db.accounts.toArray(),
|
||||
settings: await db.settings.toArray(),
|
||||
contacts: await db.contacts.toArray(),
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
timestamp: Date.now(),
|
||||
dexieVersion: Dexie.version
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Data Import (JSON to absurd-sql)
|
||||
```typescript
|
||||
async function importToAbsurdSql(data: MigrationData): Promise<void> {
|
||||
await db.exec('BEGIN TRANSACTION;');
|
||||
try {
|
||||
// Import accounts
|
||||
for (const account of data.accounts) {
|
||||
await db.run(`
|
||||
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]);
|
||||
}
|
||||
|
||||
// Import settings
|
||||
for (const setting of data.settings) {
|
||||
await db.run(`
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
`, [setting.key, setting.value, setting.updatedAt]);
|
||||
}
|
||||
|
||||
// Import contacts
|
||||
for (const contact of data.contacts) {
|
||||
await db.run(`
|
||||
INSERT INTO contacts (id, did, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, [contact.id, contact.did, contact.name, contact.createdAt, contact.updatedAt]);
|
||||
}
|
||||
await db.exec('COMMIT;');
|
||||
} catch (error) {
|
||||
await db.exec('ROLLBACK;');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Verification
|
||||
```typescript
|
||||
async function verifyMigration(dexieData: MigrationData): Promise<boolean> {
|
||||
// Verify account count
|
||||
const accountResult = await db.exec('SELECT COUNT(*) as count FROM accounts');
|
||||
const accountCount = accountResult[0].values[0][0];
|
||||
if (accountCount !== dexieData.accounts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify settings count
|
||||
const settingsResult = await db.exec('SELECT COUNT(*) as count FROM settings');
|
||||
const settingsCount = settingsResult[0].values[0][0];
|
||||
if (settingsCount !== dexieData.settings.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify contacts count
|
||||
const contactsResult = await db.exec('SELECT COUNT(*) as count FROM contacts');
|
||||
const contactsCount = contactsResult[0].values[0][0];
|
||||
if (contactsCount !== dexieData.contacts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify data integrity
|
||||
for (const account of dexieData.accounts) {
|
||||
const result = await db.exec(
|
||||
'SELECT * FROM accounts WHERE did = ?',
|
||||
[account.did]
|
||||
);
|
||||
const migratedAccount = result[0]?.values[0];
|
||||
if (!migratedAccount ||
|
||||
migratedAccount[1] !== account.publicKeyHex) { // public_key_hex is second column
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### 1. Indexing
|
||||
- Dexie automatically creates indexes based on the schema
|
||||
- absurd-sql requires explicit index creation
|
||||
- Added indexes for frequently queried fields
|
||||
- Use `PRAGMA journal_mode=MEMORY;` for better performance
|
||||
|
||||
### 2. Batch Operations
|
||||
- Dexie has built-in bulk operations
|
||||
- absurd-sql uses transactions for batch operations
|
||||
- Consider chunking large datasets
|
||||
- Use prepared statements for repeated queries
|
||||
|
||||
### 3. Query Optimization
|
||||
- Dexie uses IndexedDB's native indexing
|
||||
- absurd-sql requires explicit query optimization
|
||||
- Use prepared statements for repeated queries
|
||||
- Consider using `PRAGMA synchronous=NORMAL;` for better performance
|
||||
|
||||
## Error Handling
|
||||
|
||||
### 1. Common Errors
|
||||
```typescript
|
||||
// Dexie errors
|
||||
try {
|
||||
await db.accounts.add(account);
|
||||
} catch (error) {
|
||||
if (error instanceof Dexie.ConstraintError) {
|
||||
// Handle duplicate key
|
||||
}
|
||||
}
|
||||
|
||||
// absurd-sql errors
|
||||
try {
|
||||
await db.run(`
|
||||
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]);
|
||||
} catch (error) {
|
||||
if (error.message.includes('UNIQUE constraint failed')) {
|
||||
// Handle duplicate key
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Transaction Recovery
|
||||
```typescript
|
||||
// Dexie transaction
|
||||
try {
|
||||
await db.transaction('rw', db.accounts, async () => {
|
||||
// Operations
|
||||
});
|
||||
} catch (error) {
|
||||
// Dexie automatically rolls back
|
||||
}
|
||||
|
||||
// absurd-sql transaction
|
||||
try {
|
||||
await db.exec('BEGIN TRANSACTION;');
|
||||
// Operations
|
||||
await db.exec('COMMIT;');
|
||||
} catch (error) {
|
||||
await db.exec('ROLLBACK;');
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
1. **Preparation**
|
||||
- Export all Dexie data
|
||||
- Verify data integrity
|
||||
- Create SQLite schema
|
||||
- Setup indexes
|
||||
|
||||
2. **Migration**
|
||||
- Import data in transactions
|
||||
- Verify each batch
|
||||
- Handle errors gracefully
|
||||
- Maintain backup
|
||||
|
||||
3. **Verification**
|
||||
- Compare record counts
|
||||
- Verify data integrity
|
||||
- Test common queries
|
||||
- Validate relationships
|
||||
|
||||
4. **Cleanup**
|
||||
- Remove Dexie database
|
||||
- Clear IndexedDB storage
|
||||
- Update application code
|
||||
- Remove old dependencies
|
||||
@@ -1,8 +1,8 @@
|
||||
# Migration Guide: Dexie to wa-sqlite
|
||||
# Migration Guide: Dexie to absurd-sql
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the migration process from Dexie.js to wa-sqlite for the TimeSafari app's storage implementation. The migration aims to provide a consistent SQLite-based storage solution across all platforms while maintaining data integrity and ensuring a smooth transition for users.
|
||||
This document outlines the migration process from Dexie.js to absurd-sql for the TimeSafari app's storage implementation. The migration aims to provide a consistent SQLite-based storage solution across all platforms while maintaining data integrity and ensuring a smooth transition for users.
|
||||
|
||||
## Migration Goals
|
||||
|
||||
@@ -43,12 +43,20 @@ This document outlines the migration process from Dexie.js to wa-sqlite for the
|
||||
}
|
||||
```
|
||||
|
||||
2. **Storage Requirements**
|
||||
2. **Dependencies**
|
||||
```json
|
||||
{
|
||||
"@jlongster/sql.js": "^1.8.0",
|
||||
"absurd-sql": "^1.8.0"
|
||||
}
|
||||
```
|
||||
|
||||
3. **Storage Requirements**
|
||||
- Sufficient IndexedDB quota
|
||||
- Available disk space for SQLite
|
||||
- Backup storage space
|
||||
|
||||
3. **Platform Support**
|
||||
4. **Platform Support**
|
||||
- Web: Modern browser with IndexedDB support
|
||||
- iOS: iOS 13+ with SQLite support
|
||||
- Android: Android 5+ with SQLite support
|
||||
@@ -60,9 +68,15 @@ This document outlines the migration process from Dexie.js to wa-sqlite for the
|
||||
|
||||
```typescript
|
||||
// src/services/storage/migration/MigrationService.ts
|
||||
import initSqlJs from '@jlongster/sql.js';
|
||||
import { SQLiteFS } from 'absurd-sql';
|
||||
import IndexedDBBackend from 'absurd-sql/dist/indexeddb-backend';
|
||||
|
||||
export class MigrationService {
|
||||
private static instance: MigrationService;
|
||||
private backup: MigrationBackup | null = null;
|
||||
private sql: any = null;
|
||||
private db: any = null;
|
||||
|
||||
async prepare(): Promise<void> {
|
||||
try {
|
||||
@@ -75,8 +89,8 @@ export class MigrationService {
|
||||
// 3. Verify backup integrity
|
||||
await this.verifyBackup();
|
||||
|
||||
// 4. Initialize wa-sqlite
|
||||
await this.initializeWaSqlite();
|
||||
// 4. Initialize absurd-sql
|
||||
await this.initializeAbsurdSql();
|
||||
} catch (error) {
|
||||
throw new StorageError(
|
||||
'Migration preparation failed',
|
||||
@@ -86,6 +100,42 @@ export class MigrationService {
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeAbsurdSql(): Promise<void> {
|
||||
// Initialize SQL.js
|
||||
this.sql = await initSqlJs({
|
||||
locateFile: (file: string) => {
|
||||
return new URL(`/node_modules/@jlongster/sql.js/dist/${file}`, import.meta.url).href;
|
||||
}
|
||||
});
|
||||
|
||||
// Setup SQLiteFS with IndexedDB backend
|
||||
const sqlFS = new SQLiteFS(this.sql.FS, new IndexedDBBackend());
|
||||
this.sql.register_for_idb(sqlFS);
|
||||
|
||||
// Create and mount filesystem
|
||||
this.sql.FS.mkdir('/sql');
|
||||
this.sql.FS.mount(sqlFS, {}, '/sql');
|
||||
|
||||
// Open database
|
||||
const path = '/sql/db.sqlite';
|
||||
if (typeof SharedArrayBuffer === 'undefined') {
|
||||
let stream = this.sql.FS.open(path, 'a+');
|
||||
await stream.node.contents.readIfFallback();
|
||||
this.sql.FS.close(stream);
|
||||
}
|
||||
|
||||
this.db = new this.sql.Database(path, { filename: true });
|
||||
if (!this.db) {
|
||||
throw new StorageError(
|
||||
'Database initialization failed',
|
||||
StorageErrorCodes.INITIALIZATION_FAILED
|
||||
);
|
||||
}
|
||||
|
||||
// Configure database
|
||||
await this.db.exec(`PRAGMA journal_mode=MEMORY;`);
|
||||
}
|
||||
|
||||
private async checkPrerequisites(): Promise<void> {
|
||||
// Check IndexedDB availability
|
||||
if (!window.indexedDB) {
|
||||
@@ -160,12 +210,11 @@ export class DataMigration {
|
||||
}
|
||||
|
||||
private async migrateAccounts(accounts: Account[]): Promise<void> {
|
||||
const db = await this.getWaSqliteConnection();
|
||||
|
||||
// Use transaction for atomicity
|
||||
await db.transaction(async (tx) => {
|
||||
await this.db.exec('BEGIN TRANSACTION;');
|
||||
try {
|
||||
for (const account of accounts) {
|
||||
await tx.execute(`
|
||||
await this.db.run(`
|
||||
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [
|
||||
@@ -175,16 +224,18 @@ export class DataMigration {
|
||||
account.updatedAt
|
||||
]);
|
||||
}
|
||||
});
|
||||
await this.db.exec('COMMIT;');
|
||||
} catch (error) {
|
||||
await this.db.exec('ROLLBACK;');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyMigration(backup: MigrationBackup): Promise<void> {
|
||||
const db = await this.getWaSqliteConnection();
|
||||
|
||||
// Verify account count
|
||||
const accountCount = await db.selectValue(
|
||||
'SELECT COUNT(*) FROM accounts'
|
||||
);
|
||||
const result = await this.db.exec('SELECT COUNT(*) as count FROM accounts');
|
||||
const accountCount = result[0].values[0][0];
|
||||
|
||||
if (accountCount !== backup.accounts.length) {
|
||||
throw new StorageError(
|
||||
'Account count mismatch',
|
||||
@@ -214,8 +265,8 @@ export class RollbackService {
|
||||
// 3. Verify restoration
|
||||
await this.verifyRestoration(backup);
|
||||
|
||||
// 4. Clean up wa-sqlite
|
||||
await this.cleanupWaSqlite();
|
||||
// 4. Clean up absurd-sql
|
||||
await this.cleanupAbsurdSql();
|
||||
} catch (error) {
|
||||
throw new StorageError(
|
||||
'Rollback failed',
|
||||
@@ -371,6 +422,14 @@ button:hover {
|
||||
```typescript
|
||||
// src/services/storage/migration/__tests__/MigrationService.spec.ts
|
||||
describe('MigrationService', () => {
|
||||
it('should initialize absurd-sql correctly', async () => {
|
||||
const service = MigrationService.getInstance();
|
||||
await service.initializeAbsurdSql();
|
||||
|
||||
expect(service.isInitialized()).toBe(true);
|
||||
expect(service.getDatabase()).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create valid backup', async () => {
|
||||
const service = MigrationService.getInstance();
|
||||
const backup = await service.createBackup();
|
||||
284
doc/secure-storage-implementation.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# Secure Storage Implementation Guide for TimeSafari App
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the implementation of secure storage for the TimeSafari app using a platform-agnostic approach with Capacitor and absurd-sql solutions. The implementation focuses on:
|
||||
|
||||
1. **Platform-Specific Storage Solutions**:
|
||||
- Web: absurd-sql with IndexedDB backend and Web Worker support
|
||||
- iOS/Android: Capacitor SQLite with native SQLite implementation
|
||||
- Electron: Node SQLite (planned, not implemented)
|
||||
|
||||
2. **Key Features**:
|
||||
- Platform-agnostic SQLite interface
|
||||
- Web Worker support for web platform
|
||||
- Consistent API across platforms
|
||||
- Performance optimizations (WAL, mmap)
|
||||
- Comprehensive error handling and logging
|
||||
- Type-safe database operations
|
||||
- Storage quota management
|
||||
- Platform-specific security features
|
||||
|
||||
## Architecture
|
||||
|
||||
The storage implementation follows a layered architecture:
|
||||
|
||||
1. **Platform Service Layer**
|
||||
- `PlatformService` interface defines platform capabilities
|
||||
- Platform-specific implementations:
|
||||
- `WebPlatformService`: Web platform with absurd-sql
|
||||
- `CapacitorPlatformService`: Mobile platforms with native SQLite
|
||||
- `ElectronPlatformService`: Desktop platform (planned)
|
||||
- Platform detection and capability reporting
|
||||
- Storage quota and feature detection
|
||||
|
||||
2. **SQLite Service Layer**
|
||||
- `SQLiteOperations` interface for database operations
|
||||
- Base implementation in `BaseSQLiteService`
|
||||
- Platform-specific implementations:
|
||||
- `AbsurdSQLService`: Web platform with Web Worker
|
||||
- `CapacitorSQLiteService`: Mobile platforms with native SQLite
|
||||
- `ElectronSQLiteService`: Desktop platform (planned)
|
||||
- Common features:
|
||||
- Transaction support
|
||||
- Prepared statements
|
||||
- Performance monitoring
|
||||
- Error handling
|
||||
- Database statistics
|
||||
|
||||
3. **Data Access Layer**
|
||||
- Type-safe database operations
|
||||
- Transaction support
|
||||
- Prepared statements
|
||||
- Performance monitoring
|
||||
- Error recovery
|
||||
- Data integrity verification
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Web Platform (absurd-sql)
|
||||
|
||||
The web implementation uses absurd-sql with the following features:
|
||||
|
||||
1. **Web Worker Support**
|
||||
- SQLite operations run in a dedicated worker thread
|
||||
- Main thread remains responsive
|
||||
- SharedArrayBuffer support when available
|
||||
- Worker initialization in `sqlite.worker.ts`
|
||||
|
||||
2. **IndexedDB Backend**
|
||||
- Persistent storage using IndexedDB
|
||||
- Automatic data synchronization
|
||||
- Storage quota management (1GB limit)
|
||||
- Virtual file system configuration
|
||||
|
||||
3. **Performance Optimizations**
|
||||
- WAL mode for better concurrency
|
||||
- Memory-mapped I/O (30GB when available)
|
||||
- Prepared statement caching
|
||||
- 2MB cache size
|
||||
- Configurable performance settings
|
||||
|
||||
Example configuration:
|
||||
```typescript
|
||||
const webConfig: SQLiteConfig = {
|
||||
name: 'timesafari',
|
||||
useWAL: true,
|
||||
useMMap: typeof SharedArrayBuffer !== 'undefined',
|
||||
mmapSize: 30000000000,
|
||||
usePreparedStatements: true,
|
||||
maxPreparedStatements: 100
|
||||
};
|
||||
```
|
||||
|
||||
### Mobile Platform (Capacitor SQLite)
|
||||
|
||||
The mobile implementation uses Capacitor SQLite with:
|
||||
|
||||
1. **Native SQLite**
|
||||
- Direct access to platform SQLite
|
||||
- Native performance
|
||||
- Platform-specific optimizations
|
||||
- 2GB storage limit
|
||||
|
||||
2. **Platform Integration**
|
||||
- iOS: Native SQLite with WAL support
|
||||
- Android: Native SQLite with WAL support
|
||||
- Platform-specific permissions handling
|
||||
- Storage quota management
|
||||
|
||||
Example configuration:
|
||||
```typescript
|
||||
const mobileConfig: SQLiteConfig = {
|
||||
name: 'timesafari',
|
||||
useWAL: true,
|
||||
useMMap: false, // Not supported on mobile
|
||||
usePreparedStatements: true
|
||||
};
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
The implementation uses the following schema:
|
||||
|
||||
```sql
|
||||
-- Accounts table
|
||||
CREATE TABLE accounts (
|
||||
did TEXT PRIMARY KEY,
|
||||
public_key_hex TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Settings table
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Contacts table
|
||||
CREATE TABLE contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
did TEXT NOT NULL,
|
||||
name TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (did) REFERENCES accounts(did)
|
||||
);
|
||||
|
||||
-- Performance indexes
|
||||
CREATE INDEX idx_accounts_created_at ON accounts(created_at);
|
||||
CREATE INDEX idx_contacts_did ON contacts(did);
|
||||
CREATE INDEX idx_settings_updated_at ON settings(updated_at);
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The implementation includes comprehensive error handling:
|
||||
|
||||
1. **Error Types**
|
||||
```typescript
|
||||
export enum StorageErrorCodes {
|
||||
INITIALIZATION_FAILED = 'STORAGE_INIT_FAILED',
|
||||
QUERY_FAILED = 'STORAGE_QUERY_FAILED',
|
||||
TRANSACTION_FAILED = 'STORAGE_TRANSACTION_FAILED',
|
||||
PREPARED_STATEMENT_FAILED = 'STORAGE_PREPARED_STATEMENT_FAILED',
|
||||
DATABASE_CORRUPTED = 'STORAGE_DB_CORRUPTED',
|
||||
STORAGE_FULL = 'STORAGE_FULL',
|
||||
CONCURRENT_ACCESS = 'STORAGE_CONCURRENT_ACCESS'
|
||||
}
|
||||
```
|
||||
|
||||
2. **Error Recovery**
|
||||
- Automatic transaction rollback
|
||||
- Connection recovery
|
||||
- Data integrity verification
|
||||
- Platform-specific error handling
|
||||
- Comprehensive logging
|
||||
|
||||
## Performance Monitoring
|
||||
|
||||
The implementation includes built-in performance monitoring:
|
||||
|
||||
1. **Statistics**
|
||||
```typescript
|
||||
interface SQLiteStats {
|
||||
totalQueries: number;
|
||||
avgExecutionTime: number;
|
||||
preparedStatements: number;
|
||||
databaseSize: number;
|
||||
walMode: boolean;
|
||||
mmapActive: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Monitoring Features**
|
||||
- Query execution time tracking
|
||||
- Database size monitoring
|
||||
- Prepared statement usage
|
||||
- WAL and mmap status
|
||||
- Platform-specific metrics
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Web Platform**
|
||||
- Worker thread isolation
|
||||
- Storage quota monitoring
|
||||
- Origin isolation
|
||||
- Cross-origin protection
|
||||
- SharedArrayBuffer availability check
|
||||
|
||||
2. **Mobile Platform**
|
||||
- Platform-specific permissions
|
||||
- Storage access control
|
||||
- File system security
|
||||
- Platform sandboxing
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit Tests**
|
||||
- Platform service tests
|
||||
- SQLite service tests
|
||||
- Error handling tests
|
||||
- Performance tests
|
||||
|
||||
2. **Integration Tests**
|
||||
- Cross-platform tests
|
||||
- Migration tests
|
||||
- Transaction tests
|
||||
- Concurrency tests
|
||||
|
||||
3. **E2E Tests**
|
||||
- Platform-specific workflows
|
||||
- Error recovery scenarios
|
||||
- Performance benchmarks
|
||||
- Data integrity verification
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. **Performance**
|
||||
- Query response time < 100ms
|
||||
- Transaction completion < 500ms
|
||||
- Memory usage < 50MB
|
||||
- Database size < platform limits:
|
||||
- Web: 1GB
|
||||
- Mobile: 2GB
|
||||
|
||||
2. **Reliability**
|
||||
- 99.9% uptime
|
||||
- Zero data loss
|
||||
- Automatic recovery
|
||||
- Transaction atomicity
|
||||
|
||||
3. **Security**
|
||||
- Platform-specific security features
|
||||
- Storage access control
|
||||
- Data protection
|
||||
- Audit logging
|
||||
|
||||
4. **User Experience**
|
||||
- Smooth platform transitions
|
||||
- Clear error messages
|
||||
- Progress indicators
|
||||
- Recovery options
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Planned Features**
|
||||
- SQLCipher integration for mobile
|
||||
- Electron platform support
|
||||
- Advanced backup/restore
|
||||
- Cross-platform sync
|
||||
|
||||
2. **Security Enhancements**
|
||||
- Biometric authentication
|
||||
- Secure enclave usage
|
||||
- Advanced encryption
|
||||
- Key management
|
||||
|
||||
3. **Performance Optimizations**
|
||||
- Advanced caching
|
||||
- Query optimization
|
||||
- Memory management
|
||||
- Storage efficiency
|
||||
759
doc/storage-implementation-checklist.md
Normal file
@@ -0,0 +1,759 @@
|
||||
# Storage Implementation Checklist
|
||||
|
||||
## Core Services
|
||||
|
||||
### 1. Platform Service Layer
|
||||
- [x] Create base `PlatformService` interface
|
||||
- [x] Define platform capabilities
|
||||
- [x] File system access detection
|
||||
- [x] Camera availability
|
||||
- [x] Mobile platform detection
|
||||
- [x] iOS specific detection
|
||||
- [x] File download capability
|
||||
- [x] SQLite capabilities
|
||||
- [x] Add SQLite operations interface
|
||||
- [x] Database initialization
|
||||
- [x] Query execution
|
||||
- [x] Transaction management
|
||||
- [x] Prepared statements
|
||||
- [x] Database statistics
|
||||
- [x] Include platform detection
|
||||
- [x] Web platform detection
|
||||
- [x] Mobile platform detection
|
||||
- [x] Desktop platform detection
|
||||
- [x] Add file system operations
|
||||
- [x] File read operations
|
||||
- [x] File write operations
|
||||
- [x] File delete operations
|
||||
- [x] Directory listing
|
||||
|
||||
- [x] Implement platform-specific services
|
||||
- [x] `WebPlatformService`
|
||||
- [x] AbsurdSQL integration
|
||||
- [x] SQL.js initialization
|
||||
- [x] IndexedDB backend setup
|
||||
- [x] Virtual file system configuration
|
||||
- [x] Web Worker support
|
||||
- [x] Worker thread initialization
|
||||
- [x] Message passing
|
||||
- [x] Error handling
|
||||
- [x] IndexedDB backend
|
||||
- [x] Database creation
|
||||
- [x] Transaction handling
|
||||
- [x] Storage quota management (1GB limit)
|
||||
- [x] SharedArrayBuffer detection
|
||||
- [x] Feature detection
|
||||
- [x] Fallback handling
|
||||
- [x] File system operations (intentionally not supported)
|
||||
- [x] File read operations (not available in web)
|
||||
- [x] File write operations (not available in web)
|
||||
- [x] File delete operations (not available in web)
|
||||
- [x] Directory operations (not available in web)
|
||||
- [x] Settings implementation
|
||||
- [x] AbsurdSQL settings operations
|
||||
- [x] Worker-based settings updates
|
||||
- [x] IndexedDB transaction handling
|
||||
- [x] SharedArrayBuffer support
|
||||
- [x] Web-specific settings features
|
||||
- [x] Storage quota management
|
||||
- [x] Worker thread isolation
|
||||
- [x] Cross-origin settings
|
||||
- [x] Web performance optimizations
|
||||
- [x] Settings caching
|
||||
- [x] Batch updates
|
||||
- [x] Worker message optimization
|
||||
- [x] Account implementation
|
||||
- [x] Web-specific account handling
|
||||
- [x] Browser storage persistence
|
||||
- [x] Session management
|
||||
- [x] Cross-tab synchronization
|
||||
- [x] Web security features
|
||||
- [x] Origin isolation
|
||||
- [x] Worker thread security
|
||||
- [x] Storage access control
|
||||
- [x] `CapacitorPlatformService`
|
||||
- [x] Native SQLite integration
|
||||
- [x] Database connection
|
||||
- [x] Query execution
|
||||
- [x] Transaction handling
|
||||
- [x] Platform capabilities
|
||||
- [x] iOS detection
|
||||
- [x] Android detection
|
||||
- [x] Feature availability
|
||||
- [x] File system operations
|
||||
- [x] File read/write
|
||||
- [x] Directory operations
|
||||
- [x] Storage permissions
|
||||
- [x] iOS permissions
|
||||
- [x] Android permissions
|
||||
- [x] Permission request handling
|
||||
- [x] Settings implementation
|
||||
- [x] Native SQLite settings operations
|
||||
- [x] Platform-specific SQLite optimizations
|
||||
- [x] Native transaction handling
|
||||
- [x] Platform storage management
|
||||
- [x] Mobile-specific settings features
|
||||
- [x] Platform preferences sync
|
||||
- [x] Background state handling
|
||||
- [x] Mobile performance optimizations
|
||||
- [x] Native caching
|
||||
- [x] Battery-efficient updates
|
||||
- [x] Memory management
|
||||
- [x] Account implementation
|
||||
- [x] Mobile-specific account handling
|
||||
- [x] Platform storage integration
|
||||
- [x] Background state handling
|
||||
- [x] Mobile security features
|
||||
- [x] Platform sandboxing
|
||||
- [x] Storage access control
|
||||
- [x] App sandboxing
|
||||
- [ ] `ElectronPlatformService` (planned)
|
||||
- [ ] Node SQLite integration
|
||||
- [ ] Database connection
|
||||
- [ ] Query execution
|
||||
- [ ] Transaction handling
|
||||
- [ ] File system access
|
||||
- [ ] File read operations
|
||||
- [ ] File write operations
|
||||
- [ ] File delete operations
|
||||
- [ ] Directory operations
|
||||
- [ ] IPC communication
|
||||
- [ ] Main process communication
|
||||
- [ ] Renderer process handling
|
||||
- [ ] Message passing
|
||||
- [ ] Native features implementation
|
||||
- [ ] System dialogs
|
||||
- [ ] Native menus
|
||||
- [ ] System integration
|
||||
- [ ] Settings implementation
|
||||
- [ ] Node SQLite settings operations
|
||||
- [ ] Main process SQLite handling
|
||||
- [ ] IPC-based updates
|
||||
- [ ] File system persistence
|
||||
- [ ] Desktop-specific settings features
|
||||
- [ ] System preferences integration
|
||||
- [ ] Multi-window sync
|
||||
- [ ] Offline state handling
|
||||
- [ ] Desktop performance optimizations
|
||||
- [ ] Process-based caching
|
||||
- [ ] Window state management
|
||||
- [ ] Resource optimization
|
||||
- [ ] Account implementation
|
||||
- [ ] Desktop-specific account handling
|
||||
- [ ] System keychain integration
|
||||
- [ ] Native authentication
|
||||
- [ ] Process isolation
|
||||
- [ ] Desktop security features
|
||||
- [ ] Process sandboxing
|
||||
- [ ] IPC security
|
||||
- [ ] File system protection
|
||||
|
||||
### 2. SQLite Service Layer
|
||||
- [x] Create base `BaseSQLiteService`
|
||||
- [x] Common SQLite operations
|
||||
- [x] Query execution
|
||||
- [x] Transaction management
|
||||
- [x] Prepared statements
|
||||
- [x] Database statistics
|
||||
- [x] Performance monitoring
|
||||
- [x] Query timing
|
||||
- [x] Memory usage
|
||||
- [x] Database size
|
||||
- [x] Statement caching
|
||||
- [x] Error handling
|
||||
- [x] Connection errors
|
||||
- [x] Query errors
|
||||
- [x] Transaction errors
|
||||
- [x] Resource errors
|
||||
- [x] Transaction support
|
||||
- [x] Begin transaction
|
||||
- [x] Commit transaction
|
||||
- [x] Rollback transaction
|
||||
- [x] Nested transactions
|
||||
|
||||
- [x] Implement platform-specific SQLite services
|
||||
- [x] `AbsurdSQLService`
|
||||
- [x] Web Worker initialization
|
||||
- [x] Worker creation
|
||||
- [x] Message handling
|
||||
- [x] Error propagation
|
||||
- [x] IndexedDB backend setup
|
||||
- [x] Database creation
|
||||
- [x] Transaction handling
|
||||
- [x] Storage management
|
||||
- [x] Prepared statements
|
||||
- [x] Statement preparation
|
||||
- [x] Parameter binding
|
||||
- [x] Statement caching
|
||||
- [x] Performance optimizations
|
||||
- [x] WAL mode
|
||||
- [x] Memory mapping
|
||||
- [x] Cache configuration
|
||||
- [x] WAL mode support
|
||||
- [x] Journal mode configuration
|
||||
- [x] Synchronization settings
|
||||
- [x] Checkpoint handling
|
||||
- [x] Memory-mapped I/O
|
||||
- [x] MMAP size configuration (30GB)
|
||||
- [x] Memory management
|
||||
- [x] Performance monitoring
|
||||
- [x] `CapacitorSQLiteService`
|
||||
- [x] Native SQLite connection
|
||||
- [x] Database initialization
|
||||
- [x] Connection management
|
||||
- [x] Error handling
|
||||
- [x] Basic platform features
|
||||
- [x] Query execution
|
||||
- [x] Transaction handling
|
||||
- [x] Statement management
|
||||
- [x] Error handling
|
||||
- [x] Connection errors
|
||||
- [x] Query errors
|
||||
- [x] Resource errors
|
||||
- [x] WAL mode support
|
||||
- [x] Journal mode
|
||||
- [x] Synchronization
|
||||
- [x] Checkpointing
|
||||
- [ ] SQLCipher integration (planned)
|
||||
- [ ] Encryption setup
|
||||
- [ ] Key management
|
||||
- [ ] Secure storage
|
||||
- [ ] `ElectronSQLiteService` (planned)
|
||||
- [ ] Node SQLite integration
|
||||
- [ ] Database connection
|
||||
- [ ] Query execution
|
||||
- [ ] Transaction handling
|
||||
- [ ] IPC communication
|
||||
- [ ] Process communication
|
||||
- [ ] Error handling
|
||||
- [ ] Resource management
|
||||
- [ ] File system access
|
||||
- [ ] Native file operations
|
||||
- [ ] Path handling
|
||||
- [ ] Permissions
|
||||
- [ ] Native features
|
||||
- [ ] System integration
|
||||
- [ ] Native dialogs
|
||||
- [ ] Process management
|
||||
|
||||
### 3. Security Layer
|
||||
- [x] Implement platform-specific security
|
||||
- [x] Web platform
|
||||
- [x] Worker isolation
|
||||
- [x] Thread separation
|
||||
- [x] Message security
|
||||
- [x] Resource isolation
|
||||
- [x] Storage quota management
|
||||
- [x] Quota detection
|
||||
- [x] Usage monitoring
|
||||
- [x] Error handling
|
||||
- [x] Origin isolation
|
||||
- [x] Cross-origin protection
|
||||
- [x] Resource isolation
|
||||
- [x] Security policy
|
||||
- [x] Storage security
|
||||
- [x] Access control
|
||||
- [x] Data protection
|
||||
- [x] Quota management
|
||||
- [x] Mobile platform
|
||||
- [x] Platform permissions
|
||||
- [x] Storage access
|
||||
- [x] File operations
|
||||
- [x] System integration
|
||||
- [x] Platform security
|
||||
- [x] App sandboxing
|
||||
- [x] Storage protection
|
||||
- [x] Access control
|
||||
- [ ] SQLCipher integration (planned)
|
||||
- [ ] Encryption setup
|
||||
- [ ] Key management
|
||||
- [ ] Secure storage
|
||||
- [ ] Electron platform (planned)
|
||||
- [ ] IPC security
|
||||
- [ ] Message validation
|
||||
- [ ] Process isolation
|
||||
- [ ] Resource protection
|
||||
- [ ] File system security
|
||||
- [ ] Access control
|
||||
- [ ] Path validation
|
||||
- [ ] Permission management
|
||||
- [ ] Auto-update security
|
||||
- [ ] Update verification
|
||||
- [ ] Code signing
|
||||
- [ ] Rollback protection
|
||||
- [ ] Native security features
|
||||
- [ ] System integration
|
||||
- [ ] Security policies
|
||||
- [ ] Resource protection
|
||||
|
||||
## Platform-Specific Implementation
|
||||
|
||||
### Web Platform
|
||||
- [x] Setup absurd-sql
|
||||
- [x] Install dependencies
|
||||
```json
|
||||
{
|
||||
"@jlongster/sql.js": "^1.8.0",
|
||||
"absurd-sql": "^1.8.0"
|
||||
}
|
||||
```
|
||||
- [x] Configure Web Worker
|
||||
- [x] Worker initialization
|
||||
- [x] Message handling
|
||||
- [x] Error propagation
|
||||
- [x] Setup IndexedDB backend
|
||||
- [x] Database creation
|
||||
- [x] Transaction handling
|
||||
- [x] Storage management
|
||||
- [x] Configure database pragmas
|
||||
```sql
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA temp_store = MEMORY;
|
||||
PRAGMA cache_size = -2000;
|
||||
PRAGMA mmap_size = 30000000000;
|
||||
```
|
||||
|
||||
- [x] Update build configuration
|
||||
- [x] Configure worker bundling
|
||||
- [x] Worker file handling
|
||||
- [x] Asset management
|
||||
- [x] Source maps
|
||||
- [x] Setup asset handling
|
||||
- [x] SQL.js WASM
|
||||
- [x] Worker scripts
|
||||
- [x] Static assets
|
||||
- [x] Configure chunk splitting
|
||||
- [x] Code splitting
|
||||
- [x] Dynamic imports
|
||||
- [x] Asset optimization
|
||||
|
||||
- [x] Implement fallback mechanisms
|
||||
- [x] SharedArrayBuffer detection
|
||||
- [x] Feature detection
|
||||
- [x] Fallback handling
|
||||
- [x] Error reporting
|
||||
- [x] Storage quota monitoring
|
||||
- [x] Quota detection
|
||||
- [x] Usage tracking
|
||||
- [x] Error handling
|
||||
- [x] Worker initialization fallback
|
||||
- [x] Fallback detection
|
||||
- [x] Alternative initialization
|
||||
- [x] Error recovery
|
||||
- [x] Error recovery
|
||||
- [x] Connection recovery
|
||||
- [x] Transaction rollback
|
||||
- [x] State restoration
|
||||
|
||||
### Mobile Platform
|
||||
- [x] Setup Capacitor SQLite
|
||||
- [x] Install dependencies
|
||||
- [x] Core SQLite plugin
|
||||
- [x] Platform plugins
|
||||
- [x] Native dependencies
|
||||
- [x] Configure native SQLite
|
||||
- [x] Database initialization
|
||||
- [x] Connection management
|
||||
- [x] Query handling
|
||||
- [x] Configure basic permissions
|
||||
- [x] Storage access
|
||||
- [x] File operations
|
||||
- [x] System integration
|
||||
|
||||
- [x] Update Capacitor config
|
||||
- [x] Add basic platform permissions
|
||||
- [x] iOS permissions
|
||||
- [x] Android permissions
|
||||
- [x] Feature flags
|
||||
- [x] Configure storage limits
|
||||
- [x] iOS storage limits
|
||||
- [x] Android storage limits
|
||||
- [x] Quota management
|
||||
- [x] Setup platform security
|
||||
- [x] App sandboxing
|
||||
- [x] Storage protection
|
||||
- [x] Access control
|
||||
|
||||
### Electron Platform (planned)
|
||||
- [ ] Setup Node SQLite
|
||||
- [ ] Install dependencies
|
||||
- [ ] SQLite3 module
|
||||
- [ ] Native bindings
|
||||
- [ ] Development tools
|
||||
- [ ] Configure IPC
|
||||
- [ ] Main process setup
|
||||
- [ ] Renderer process handling
|
||||
- [ ] Message passing
|
||||
- [ ] Setup file system access
|
||||
- [ ] Native file operations
|
||||
- [ ] Path handling
|
||||
- [ ] Permission management
|
||||
- [ ] Implement secure storage
|
||||
- [ ] Encryption setup
|
||||
- [ ] Key management
|
||||
- [ ] Secure containers
|
||||
|
||||
- [ ] Update Electron config
|
||||
- [ ] Add security policies
|
||||
- [ ] CSP configuration
|
||||
- [ ] Process isolation
|
||||
- [ ] Resource protection
|
||||
- [ ] Configure file access
|
||||
- [ ] Access control
|
||||
- [ ] Path validation
|
||||
- [ ] Permission management
|
||||
- [ ] Setup auto-updates
|
||||
- [ ] Update server
|
||||
- [ ] Code signing
|
||||
- [ ] Rollback protection
|
||||
- [ ] Configure IPC security
|
||||
- [ ] Message validation
|
||||
- [ ] Process isolation
|
||||
- [ ] Resource protection
|
||||
|
||||
## Data Models and Types
|
||||
|
||||
### 1. Database Schema
|
||||
- [x] Define tables
|
||||
```sql
|
||||
-- Accounts table
|
||||
CREATE TABLE accounts (
|
||||
did TEXT PRIMARY KEY,
|
||||
public_key_hex TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Settings table
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Contacts table
|
||||
CREATE TABLE contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
did TEXT NOT NULL,
|
||||
name TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (did) REFERENCES accounts(did)
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_accounts_created_at ON accounts(created_at);
|
||||
CREATE INDEX idx_contacts_did ON contacts(did);
|
||||
CREATE INDEX idx_settings_updated_at ON settings(updated_at);
|
||||
```
|
||||
|
||||
### 2. Type Definitions
|
||||
- [x] Create interfaces
|
||||
```typescript
|
||||
interface PlatformCapabilities {
|
||||
hasFileSystem: boolean;
|
||||
hasCamera: boolean;
|
||||
isMobile: boolean;
|
||||
isIOS: boolean;
|
||||
hasFileDownload: boolean;
|
||||
needsFileHandlingInstructions: boolean;
|
||||
sqlite: {
|
||||
supported: boolean;
|
||||
runsInWorker: boolean;
|
||||
hasSharedArrayBuffer: boolean;
|
||||
supportsWAL: boolean;
|
||||
maxSize?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface SQLiteConfig {
|
||||
name: string;
|
||||
useWAL?: boolean;
|
||||
useMMap?: boolean;
|
||||
mmapSize?: number;
|
||||
usePreparedStatements?: boolean;
|
||||
maxPreparedStatements?: number;
|
||||
}
|
||||
|
||||
interface SQLiteStats {
|
||||
totalQueries: number;
|
||||
avgExecutionTime: number;
|
||||
preparedStatements: number;
|
||||
databaseSize: number;
|
||||
walMode: boolean;
|
||||
mmapActive: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### 1. Unit Tests
|
||||
- [x] Test platform services
|
||||
- [x] Platform detection
|
||||
- [x] Web platform
|
||||
- [x] Mobile platform
|
||||
- [x] Desktop platform
|
||||
- [x] Capability reporting
|
||||
- [x] Feature detection
|
||||
- [x] Platform specifics
|
||||
- [x] Error cases
|
||||
- [x] Basic SQLite operations
|
||||
- [x] Query execution
|
||||
- [x] Transaction handling
|
||||
- [x] Error cases
|
||||
- [x] Basic error handling
|
||||
- [x] Connection errors
|
||||
- [x] Query errors
|
||||
- [x] Resource errors
|
||||
|
||||
### 2. Integration Tests
|
||||
- [x] Test SQLite services
|
||||
- [x] Web platform tests
|
||||
- [x] Worker integration
|
||||
- [x] IndexedDB backend
|
||||
- [x] Performance tests
|
||||
- [x] Basic mobile platform tests
|
||||
- [x] Native SQLite
|
||||
- [x] Platform features
|
||||
- [x] Error handling
|
||||
- [ ] Electron platform tests (planned)
|
||||
- [ ] Node SQLite
|
||||
- [ ] IPC communication
|
||||
- [ ] File system
|
||||
- [x] Cross-platform tests
|
||||
- [x] Feature parity
|
||||
- [x] Data consistency
|
||||
- [x] Performance comparison
|
||||
|
||||
### 3. E2E Tests
|
||||
- [x] Test workflows
|
||||
- [x] Basic database operations
|
||||
- [x] CRUD operations
|
||||
- [x] Transaction handling
|
||||
- [x] Error recovery
|
||||
- [x] Platform transitions
|
||||
- [x] Web to mobile
|
||||
- [x] Mobile to web
|
||||
- [x] State preservation
|
||||
- [x] Basic error recovery
|
||||
- [x] Connection loss
|
||||
- [x] Transaction failure
|
||||
- [x] Resource errors
|
||||
- [x] Performance benchmarks
|
||||
- [x] Query performance
|
||||
- [x] Transaction speed
|
||||
- [x] Memory usage
|
||||
- [x] Storage efficiency
|
||||
|
||||
## Documentation
|
||||
|
||||
### 1. Technical Documentation
|
||||
- [x] Update architecture docs
|
||||
- [x] System overview
|
||||
- [x] Component interaction
|
||||
- [x] Platform specifics
|
||||
- [x] Add basic API documentation
|
||||
- [x] Interface definitions
|
||||
- [x] Method signatures
|
||||
- [x] Usage examples
|
||||
- [x] Document platform capabilities
|
||||
- [x] Feature matrix
|
||||
- [x] Platform support
|
||||
- [x] Limitations
|
||||
- [x] Document security measures
|
||||
- [x] Platform security
|
||||
- [x] Access control
|
||||
- [x] Security policies
|
||||
|
||||
### 2. User Documentation
|
||||
- [x] Update basic user guides
|
||||
- [x] Installation
|
||||
- [x] Configuration
|
||||
- [x] Basic usage
|
||||
- [x] Add basic troubleshooting guides
|
||||
- [x] Common issues
|
||||
- [x] Error messages
|
||||
- [x] Recovery steps
|
||||
- [x] Document implemented platform features
|
||||
- [x] Web platform
|
||||
- [x] Mobile platform
|
||||
- [x] Desktop platform
|
||||
- [x] Add basic performance tips
|
||||
- [x] Optimization techniques
|
||||
- [x] Best practices
|
||||
- [x] Platform specifics
|
||||
|
||||
## Monitoring and Analytics
|
||||
|
||||
### 1. Performance Monitoring
|
||||
- [x] Basic query execution time
|
||||
- [x] Query timing
|
||||
- [x] Transaction timing
|
||||
- [x] Statement timing
|
||||
- [x] Database size monitoring
|
||||
- [x] Size tracking
|
||||
- [x] Growth patterns
|
||||
- [x] Quota management
|
||||
- [x] Basic memory usage
|
||||
- [x] Heap usage
|
||||
- [x] Cache usage
|
||||
- [x] Worker memory
|
||||
- [x] Worker performance
|
||||
- [x] Message timing
|
||||
- [x] Processing time
|
||||
- [x] Resource usage
|
||||
|
||||
### 2. Error Tracking
|
||||
- [x] Basic error logging
|
||||
- [x] Error capture
|
||||
- [x] Stack traces
|
||||
- [x] Context data
|
||||
- [x] Basic performance monitoring
|
||||
- [x] Query metrics
|
||||
- [x] Resource usage
|
||||
- [x] Timing data
|
||||
- [x] Platform-specific errors
|
||||
- [x] Web platform
|
||||
- [x] Mobile platform
|
||||
- [x] Desktop platform
|
||||
- [x] Basic recovery tracking
|
||||
- [x] Recovery success
|
||||
- [x] Failure patterns
|
||||
- [x] User impact
|
||||
|
||||
## Security Audit
|
||||
|
||||
### 1. Code Review
|
||||
- [x] Review platform services
|
||||
- [x] Interface security
|
||||
- [x] Data handling
|
||||
- [x] Error management
|
||||
- [x] Check basic SQLite implementations
|
||||
- [x] Query security
|
||||
- [x] Transaction safety
|
||||
- [x] Resource management
|
||||
- [x] Verify basic error handling
|
||||
- [x] Error propagation
|
||||
- [x] Recovery procedures
|
||||
- [x] User feedback
|
||||
- [x] Complete dependency audit
|
||||
- [x] Security vulnerabilities
|
||||
- [x] License compliance
|
||||
- [x] Update requirements
|
||||
|
||||
### 2. Platform Security
|
||||
- [x] Web platform
|
||||
- [x] Worker isolation
|
||||
- [x] Thread separation
|
||||
- [x] Message security
|
||||
- [x] Resource isolation
|
||||
- [x] Basic storage security
|
||||
- [x] Access control
|
||||
- [x] Data protection
|
||||
- [x] Quota management
|
||||
- [x] Origin isolation
|
||||
- [x] Cross-origin protection
|
||||
- [x] Resource isolation
|
||||
- [x] Security policy
|
||||
- [x] Mobile platform
|
||||
- [x] Platform permissions
|
||||
- [x] Storage access
|
||||
- [x] File operations
|
||||
- [x] System integration
|
||||
- [x] Platform security
|
||||
- [x] App sandboxing
|
||||
- [x] Storage protection
|
||||
- [x] Access control
|
||||
- [ ] SQLCipher integration (planned)
|
||||
- [ ] Encryption setup
|
||||
- [ ] Key management
|
||||
- [ ] Secure storage
|
||||
- [ ] Electron platform (planned)
|
||||
- [ ] IPC security
|
||||
- [ ] Message validation
|
||||
- [ ] Process isolation
|
||||
- [ ] Resource protection
|
||||
- [ ] File system security
|
||||
- [ ] Access control
|
||||
- [ ] Path validation
|
||||
- [ ] Permission management
|
||||
- [ ] Auto-update security
|
||||
- [ ] Update verification
|
||||
- [ ] Code signing
|
||||
- [ ] Rollback protection
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### 1. Performance
|
||||
- [x] Basic query response time < 100ms
|
||||
- [x] Simple queries
|
||||
- [x] Indexed queries
|
||||
- [x] Prepared statements
|
||||
- [x] Basic transaction completion < 500ms
|
||||
- [x] Single operations
|
||||
- [x] Batch operations
|
||||
- [x] Complex transactions
|
||||
- [x] Basic memory usage < 50MB
|
||||
- [x] Normal operation
|
||||
- [x] Peak usage
|
||||
- [x] Background state
|
||||
- [x] Database size < platform limits
|
||||
- [x] Web platform (1GB)
|
||||
- [x] Mobile platform (2GB)
|
||||
- [ ] Desktop platform (10GB, planned)
|
||||
|
||||
### 2. Reliability
|
||||
- [x] Basic uptime
|
||||
- [x] Service availability
|
||||
- [x] Connection stability
|
||||
- [x] Error recovery
|
||||
- [x] Basic data integrity
|
||||
- [x] Transaction atomicity
|
||||
- [x] Data consistency
|
||||
- [x] Error handling
|
||||
- [x] Basic recovery
|
||||
- [x] Connection recovery
|
||||
- [x] Transaction rollback
|
||||
- [x] State restoration
|
||||
- [x] Basic transaction atomicity
|
||||
- [x] Commit success
|
||||
- [x] Rollback handling
|
||||
- [x] Error recovery
|
||||
|
||||
### 3. Security
|
||||
- [x] Platform-specific security
|
||||
- [x] Web platform security
|
||||
- [x] Mobile platform security
|
||||
- [ ] Desktop platform security (planned)
|
||||
- [x] Basic access control
|
||||
- [x] User permissions
|
||||
- [x] Resource access
|
||||
- [x] Operation limits
|
||||
- [x] Basic audit logging
|
||||
- [x] Access logs
|
||||
- [x] Operation logs
|
||||
- [x] Security events
|
||||
- [ ] Advanced security features (planned)
|
||||
- [ ] SQLCipher encryption
|
||||
- [ ] Biometric authentication
|
||||
- [ ] Secure enclave
|
||||
- [ ] Key management
|
||||
|
||||
### 4. User Experience
|
||||
- [x] Basic platform transitions
|
||||
- [x] Web to mobile
|
||||
- [x] Mobile to web
|
||||
- [x] State preservation
|
||||
- [x] Basic error messages
|
||||
- [x] User feedback
|
||||
- [x] Recovery guidance
|
||||
- [x] Error context
|
||||
- [x] Basic progress indicators
|
||||
- [x] Operation status
|
||||
- [x] Loading states
|
||||
- [x] Completion feedback
|
||||
- [x] Basic recovery options
|
||||
- [x] Automatic recovery
|
||||
- [x] Manual intervention
|
||||
- [x] Data restoration
|
||||
13
ios/.gitignore
vendored
@@ -11,16 +11,3 @@ capacitor-cordova-ios-plugins
|
||||
# Generated Config files
|
||||
App/App/capacitor.config.json
|
||||
App/App/config.xml
|
||||
|
||||
# User-specific Xcode files
|
||||
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
|
||||
App/App.xcodeproj/*.xcuserstate
|
||||
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
|
||||
# Generated Icons from capacitor-assets (also Contents.json which is confusing; see BUILDING.md)
|
||||
App/App/Assets.xcassets/AppIcon.appiconset
|
||||
App/App/Assets.xcassets/Splash.imageset
|
||||
|
||||
@@ -380,7 +380,6 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
@@ -407,7 +406,6 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
|
||||
|
After Width: | Height: | Size: 116 KiB |
14
ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"size": "1024x1024",
|
||||
"filename": "AppIcon-512@2x.png",
|
||||
"platform": "ios"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
23
ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-2.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png
vendored
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png
vendored
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png
vendored
Normal file
|
After Width: | Height: | Size: 40 KiB |
29
main.js
@@ -1,29 +0,0 @@
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const path = require('path');
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false
|
||||
}
|
||||
});
|
||||
|
||||
win.loadFile(path.join(__dirname, 'dist-electron/www/index.html'));
|
||||
}
|
||||
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
4932
package-lock.json
generated
12
package.json
@@ -11,7 +11,7 @@
|
||||
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.mts",
|
||||
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
|
||||
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
|
||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js",
|
||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
|
||||
"test:all": "npm run test:prerequisites && npm run build && npm run test:web && npm run test:mobile",
|
||||
"test:prerequisites": "node scripts/check-prerequisites.js",
|
||||
"test:web": "npx playwright test -c playwright.config-local.ts --trace on",
|
||||
@@ -46,6 +46,7 @@
|
||||
"electron:build-mac-universal": "npm run build:electron-prod && electron-builder --mac --universal"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/sqlite": "6.0.0",
|
||||
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
|
||||
"@capacitor/android": "^6.2.0",
|
||||
"@capacitor/app": "^6.0.0",
|
||||
@@ -63,6 +64,7 @@
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.6",
|
||||
"@jlongster/sql.js": "^1.6.7",
|
||||
"@peculiar/asn1-ecc": "^2.3.8",
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
||||
@@ -81,6 +83,7 @@
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
"@vueuse/core": "^12.3.0",
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"absurd-sql": "^0.0.54",
|
||||
"asn1-ber": "^1.2.2",
|
||||
"axios": "^1.6.8",
|
||||
"cbor-x": "^1.5.9",
|
||||
@@ -113,6 +116,7 @@
|
||||
"reflect-metadata": "^0.1.14",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"simple-vue-camera": "^1.1.3",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"three": "^0.156.1",
|
||||
@@ -144,7 +148,9 @@
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/eslint-config-typescript": "^11.0.3",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"browserify-fs": "^1.0.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"crypto-browserify": "^3.12.1",
|
||||
"electron": "^33.2.1",
|
||||
"electron-builder": "^25.1.8",
|
||||
"eslint": "^8.57.0",
|
||||
@@ -155,17 +161,19 @@
|
||||
"markdownlint": "^0.37.4",
|
||||
"markdownlint-cli": "^0.44.0",
|
||||
"npm-check-updates": "^17.1.13",
|
||||
"path-browserify": "^1.0.1",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"rimraf": "^6.0.1",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "~5.2.2",
|
||||
"vite": "^5.2.0",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"vite-plugin-pwa": "^0.19.8"
|
||||
},
|
||||
"main": "./dist-electron/main.js",
|
||||
"build": {
|
||||
"appId": "app.timesafari.app",
|
||||
"appId": "app.timesafari",
|
||||
"productName": "TimeSafari",
|
||||
"directories": {
|
||||
"output": "dist-electron-packages"
|
||||
|
||||
@@ -51,7 +51,7 @@ const { existsSync } = require('fs');
|
||||
*/
|
||||
function checkCommand(command, errorMessage) {
|
||||
try {
|
||||
execSync(command, { stdio: 'ignore' });
|
||||
execSync(command + ' --version', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`❌ ${errorMessage}`);
|
||||
@@ -164,10 +164,10 @@ function main() {
|
||||
|
||||
// Check required command line tools
|
||||
// These are essential for building and testing the application
|
||||
success &= checkCommand('node --version', 'Node.js is required');
|
||||
success &= checkCommand('npm --version', 'npm is required');
|
||||
success &= checkCommand('gradle --version', 'Gradle is required for Android builds');
|
||||
success &= checkCommand('xcodebuild --help', 'Xcode is required for iOS builds');
|
||||
success &= checkCommand('node', 'Node.js is required');
|
||||
success &= checkCommand('npm', 'npm is required');
|
||||
success &= checkCommand('gradle', 'Gradle is required for Android builds');
|
||||
success &= checkCommand('xcodebuild', 'Xcode is required for iOS builds');
|
||||
|
||||
// Check platform-specific development environments
|
||||
success &= checkAndroidSetup();
|
||||
|
||||
15
scripts/copy-wasm.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Create public/wasm directory if it doesn't exist
|
||||
const wasmDir = path.join(__dirname, '../public/wasm');
|
||||
if (!fs.existsSync(wasmDir)) {
|
||||
fs.mkdirSync(wasmDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Copy the WASM file from node_modules to public/wasm
|
||||
const sourceFile = path.join(__dirname, '../node_modules/@jlongster/sql.js/dist/sql-wasm.wasm');
|
||||
const targetFile = path.join(wasmDir, 'sql-wasm.wasm');
|
||||
|
||||
fs.copyFileSync(sourceFile, targetFile);
|
||||
console.log('WASM file copied successfully!');
|
||||
@@ -170,7 +170,7 @@ const executeDeeplink = async (url, description, log) => {
|
||||
|
||||
try {
|
||||
// Stop the app before executing the deep link
|
||||
execSync('adb shell am force-stop app.timesafari.app');
|
||||
execSync('adb shell am force-stop app.timesafari');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1s
|
||||
|
||||
execSync(`adb shell am start -W -a android.intent.action.VIEW -d "${url}" -c android.intent.category.BROWSABLE`);
|
||||
|
||||
12
src/App.vue
@@ -4,7 +4,7 @@
|
||||
<!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind -->
|
||||
<NotificationGroup group="alert">
|
||||
<div
|
||||
class="fixed z-[90] top-[max(1rem,env(safe-area-inset-top))] right-4 left-4 sm:left-auto sm:w-full sm:max-w-sm flex flex-col items-start justify-end"
|
||||
class="fixed top-[calc(env(safe-area-inset-top)+1rem)] right-4 left-4 sm:left-auto sm:w-full sm:max-w-sm flex flex-col items-start justify-end"
|
||||
>
|
||||
<Notification
|
||||
v-slot="{ notifications, close }"
|
||||
@@ -541,13 +541,13 @@ export default class App extends Vue {
|
||||
|
||||
<style>
|
||||
#Content {
|
||||
padding-left: max(1.5rem, env(safe-area-inset-left));
|
||||
padding-right: max(1.5rem, env(safe-area-inset-right));
|
||||
padding-top: max(1.5rem, env(safe-area-inset-top));
|
||||
padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-top: calc(env(safe-area-inset-top) + 1.5rem);
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 1.5rem);
|
||||
}
|
||||
|
||||
#QuickNav ~ #Content {
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 6.333rem);
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 6rem);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -99,8 +99,6 @@ import {
|
||||
LTileLayer,
|
||||
} from "@vue-leaflet/vue-leaflet";
|
||||
import { Router } from "vue-router";
|
||||
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
@@ -122,7 +120,8 @@ export default class FeedFilters extends Vue {
|
||||
async open(onCloseIfChanged: () => void) {
|
||||
this.onCloseIfChanged = onCloseIfChanged;
|
||||
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
const platform = this.$platform;
|
||||
const settings = await platform.getActiveAccountSettings();
|
||||
this.hasVisibleDid = !!settings.filterFeedByVisible;
|
||||
this.isNearby = !!settings.filterFeedByNearby;
|
||||
if (settings.searchBoxes && settings.searchBoxes.length > 0) {
|
||||
@@ -136,7 +135,8 @@ export default class FeedFilters extends Vue {
|
||||
async toggleHasVisibleDid() {
|
||||
this.settingChanged = true;
|
||||
this.hasVisibleDid = !this.hasVisibleDid;
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
const platform = this.$platform;
|
||||
await platform.updateMasterSettings({
|
||||
filterFeedByVisible: this.hasVisibleDid,
|
||||
});
|
||||
}
|
||||
@@ -144,7 +144,8 @@ export default class FeedFilters extends Vue {
|
||||
async toggleNearby() {
|
||||
this.settingChanged = true;
|
||||
this.isNearby = !this.isNearby;
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
const platform = this.$platform;
|
||||
await platform.updateMasterSettings({
|
||||
filterFeedByNearby: this.isNearby,
|
||||
});
|
||||
}
|
||||
@@ -154,7 +155,8 @@ export default class FeedFilters extends Vue {
|
||||
this.settingChanged = true;
|
||||
}
|
||||
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
const platform = this.$platform;
|
||||
await platform.updateMasterSettings({
|
||||
filterFeedByNearby: false,
|
||||
filterFeedByVisible: false,
|
||||
});
|
||||
@@ -168,7 +170,8 @@ export default class FeedFilters extends Vue {
|
||||
this.settingChanged = true;
|
||||
}
|
||||
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
const platform = this.$platform;
|
||||
await platform.updateMasterSettings({
|
||||
filterFeedByNearby: true,
|
||||
filterFeedByVisible: true,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="text-lg text-center font-bold relative">
|
||||
<h1 id="ViewHeading" class="text-center font-bold">
|
||||
<span v-if="uploading">Uploading Image…</span>
|
||||
<span v-else-if="blob">{{ crop ? 'Crop Image' : 'Preview Image' }}</span>
|
||||
<span v-else-if="blob">Crop Image</span>
|
||||
<span v-else-if="showCameraPreview">Upload Image</span>
|
||||
<span v-else>Add Photo</span>
|
||||
</h1>
|
||||
@@ -119,21 +119,12 @@
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<div class="absolute bottom-4 inset-x-0 flex items-center justify-center gap-4">
|
||||
<button
|
||||
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||
@click="capturePhoto"
|
||||
>
|
||||
<font-awesome icon="camera" class="w-[1em]" />
|
||||
</button>
|
||||
<button
|
||||
v-if="platformCapabilities.isMobile"
|
||||
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||
@click="rotateCamera"
|
||||
>
|
||||
<font-awesome icon="rotate" class="w-[1em]" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||
@click="capturePhoto"
|
||||
>
|
||||
<font-awesome icon="camera" class="w-[1em]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -271,11 +262,6 @@ const inputImageFileNameRef = ref<Blob>();
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
defaultCameraMode: {
|
||||
type: String,
|
||||
default: 'environment',
|
||||
validator: (value: string) => ['environment', 'user'].includes(value)
|
||||
}
|
||||
},
|
||||
})
|
||||
export default class ImageMethodDialog extends Vue {
|
||||
@@ -317,9 +303,6 @@ export default class ImageMethodDialog extends Vue {
|
||||
/** Camera stream reference */
|
||||
private cameraStream: MediaStream | null = null;
|
||||
|
||||
/** Current camera facing mode */
|
||||
private currentFacingMode: 'environment' | 'user' = 'environment';
|
||||
|
||||
private platformService = PlatformServiceFactory.getInstance();
|
||||
URL = window.URL || window.webkitURL;
|
||||
|
||||
@@ -350,7 +333,6 @@ export default class ImageMethodDialog extends Vue {
|
||||
* @throws {Error} When settings retrieval fails
|
||||
*/
|
||||
async mounted() {
|
||||
logger.log("ImageMethodDialog mounted");
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
@@ -379,16 +361,15 @@ export default class ImageMethodDialog extends Vue {
|
||||
}
|
||||
|
||||
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
|
||||
logger.debug("ImageMethodDialog.open called");
|
||||
this.claimType = claimType;
|
||||
this.crop = !!crop;
|
||||
this.imageCallback = setImageFn;
|
||||
this.visible = true;
|
||||
this.currentFacingMode = this.defaultCameraMode as 'environment' | 'user';
|
||||
|
||||
// Start camera preview immediately
|
||||
logger.debug("Starting camera preview from open()");
|
||||
this.startCameraPreview();
|
||||
// Start camera preview immediately if not on mobile
|
||||
if (!this.platformCapabilities.isNativeApp) {
|
||||
this.startCameraPreview();
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImageFile(event: Event) {
|
||||
@@ -457,21 +438,46 @@ export default class ImageMethodDialog extends Vue {
|
||||
logger.debug("startCameraPreview called");
|
||||
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
|
||||
logger.debug("Platform capabilities:", this.platformCapabilities);
|
||||
logger.debug("MediaDevices available:", !!navigator.mediaDevices);
|
||||
logger.debug("getUserMedia available:", !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia));
|
||||
|
||||
if (this.platformCapabilities.isNativeApp) {
|
||||
logger.debug("Using platform service for mobile device");
|
||||
this.cameraState = "initializing";
|
||||
this.cameraStateMessage = "Using platform camera service...";
|
||||
try {
|
||||
const result = await this.platformService.takePicture();
|
||||
this.blob = result.blob;
|
||||
this.fileName = result.fileName;
|
||||
this.cameraState = "ready";
|
||||
this.cameraStateMessage = "Photo captured successfully";
|
||||
} catch (error) {
|
||||
logger.error("Error taking picture:", error);
|
||||
this.cameraState = "error";
|
||||
this.cameraStateMessage =
|
||||
error instanceof Error ? error.message : "Failed to take picture";
|
||||
this.error =
|
||||
error instanceof Error ? error.message : "Failed to take picture";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to take picture. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Starting camera preview for desktop browser");
|
||||
try {
|
||||
this.cameraState = "initializing";
|
||||
this.cameraStateMessage = "Requesting camera access...";
|
||||
this.showCameraPreview = true;
|
||||
await this.$nextTick();
|
||||
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error("Camera API not available in this browser");
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: this.currentFacingMode },
|
||||
video: { facingMode: "environment" },
|
||||
});
|
||||
logger.debug("Camera access granted");
|
||||
this.cameraStream = stream;
|
||||
@@ -486,41 +492,31 @@ export default class ImageMethodDialog extends Vue {
|
||||
await new Promise((resolve) => {
|
||||
videoElement.onloadedmetadata = () => {
|
||||
videoElement.play().then(() => {
|
||||
logger.debug("Video element started playing");
|
||||
resolve(true);
|
||||
}).catch(error => {
|
||||
logger.error("Error playing video:", error);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
});
|
||||
} else {
|
||||
logger.error("Video element not found");
|
||||
throw new Error("Video element not found");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error starting camera preview:", error);
|
||||
let errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to access camera";
|
||||
if (
|
||||
error instanceof Error && (
|
||||
error.name === "NotReadableError" ||
|
||||
error.name === "TrackStartError"
|
||||
)) {
|
||||
) {
|
||||
errorMessage =
|
||||
"Camera is in use by another application. Please close any other apps or browser tabs using the camera and try again.";
|
||||
} else if (
|
||||
error instanceof Error && (
|
||||
error.name === "NotAllowedError" ||
|
||||
error.name === "PermissionDeniedError"
|
||||
)) {
|
||||
) {
|
||||
errorMessage =
|
||||
"Camera access was denied. Please allow camera access in your browser settings.";
|
||||
}
|
||||
this.cameraState = "error";
|
||||
this.cameraStateMessage = errorMessage;
|
||||
this.error = errorMessage;
|
||||
this.showCameraPreview = false;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -530,6 +526,7 @@ export default class ImageMethodDialog extends Vue {
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.showCameraPreview = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,20 +578,6 @@ export default class ImageMethodDialog extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async rotateCamera() {
|
||||
// Toggle between front and back cameras
|
||||
this.currentFacingMode = this.currentFacingMode === 'environment' ? 'user' : 'environment';
|
||||
|
||||
// Stop current stream
|
||||
if (this.cameraStream) {
|
||||
this.cameraStream.getTracks().forEach(track => track.stop());
|
||||
this.cameraStream = null;
|
||||
}
|
||||
|
||||
// Start new stream with updated facing mode
|
||||
await this.startCameraPreview();
|
||||
}
|
||||
|
||||
private createBlobURL(blob: Blob): string {
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
@@ -629,7 +612,6 @@ export default class ImageMethodDialog extends Vue {
|
||||
5000,
|
||||
);
|
||||
this.uploading = false;
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
formData.append("image", this.blob, this.fileName || "photo.jpg");
|
||||
@@ -684,7 +666,6 @@ export default class ImageMethodDialog extends Vue {
|
||||
);
|
||||
this.uploading = false;
|
||||
this.blob = undefined;
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@ export default class MembersList extends Vue {
|
||||
this.decryptedMembers.length === 0 ||
|
||||
this.decryptedMembers[0].member.memberId !== this.members[0].memberId
|
||||
) {
|
||||
return "Your password is not the same as the organizer. Retry or have them check their password.";
|
||||
return "Your password is not the same as the organizer. Reload or have them check their password.";
|
||||
} else {
|
||||
// the first (organizer) member was decrypted OK
|
||||
return "";
|
||||
@@ -337,7 +337,7 @@ export default class MembersList extends Vue {
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Contact Exists",
|
||||
text: "They are in your contacts. To remove them, use the contacts page.",
|
||||
text: "They are in your contacts. If you want to remove them, you must do that from the contacts screen.",
|
||||
},
|
||||
10000,
|
||||
);
|
||||
@@ -347,7 +347,7 @@ export default class MembersList extends Vue {
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Contact Available",
|
||||
text: "This is to add them to your contacts. To remove them later, use the contacts page.",
|
||||
text: "This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.",
|
||||
},
|
||||
10000,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="absolute right-5 top-[max(0.75rem,env(safe-area-inset-top))]">
|
||||
<div class="absolute right-5 top-[calc(env(safe-area-inset-top)+0.75rem)]">
|
||||
<span class="align-center text-red-500 mr-2">{{ message }}</span>
|
||||
<span class="ml-2">
|
||||
<router-link
|
||||
|
||||
106
src/db-sql/migration.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import migrationService from "../services/migrationService";
|
||||
import type { QueryExecResult, SqlValue } from "../interfaces/database";
|
||||
|
||||
// Each migration can include multiple SQL statements (with semicolons)
|
||||
const MIGRATIONS = [
|
||||
{
|
||||
name: "001_initial",
|
||||
// see ../db/tables files for explanations of the fields
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dateCreated TEXT NOT NULL,
|
||||
derivationPath TEXT,
|
||||
did TEXT NOT NULL,
|
||||
identity TEXT,
|
||||
mnemonic TEXT,
|
||||
passkeyCredIdHex TEXT,
|
||||
publicKeyHex TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_did ON accounts(did);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS secret (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
secret TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
accountDid TEXT,
|
||||
activeDid TEXT,
|
||||
apiServer TEXT,
|
||||
filterFeedByNearby BOOLEAN,
|
||||
filterFeedByVisible BOOLEAN,
|
||||
finishedOnboarding BOOLEAN,
|
||||
firstName TEXT,
|
||||
hideRegisterPromptOnNewContact BOOLEAN,
|
||||
isRegistered BOOLEAN,
|
||||
lastName TEXT,
|
||||
lastAckedOfferToUserJwtId TEXT,
|
||||
lastAckedOfferToUserProjectsJwtId TEXT,
|
||||
lastNotifiedClaimId TEXT,
|
||||
lastViewedClaimId TEXT,
|
||||
notifyingNewActivityTime TEXT,
|
||||
notifyingReminderMessage TEXT,
|
||||
notifyingReminderTime TEXT,
|
||||
partnerApiServer TEXT,
|
||||
passkeyExpirationMinutes INTEGER,
|
||||
profileImageUrl TEXT,
|
||||
searchBoxes TEXT, -- Stored as JSON string
|
||||
showContactGivesInline BOOLEAN,
|
||||
showGeneralAdvanced BOOLEAN,
|
||||
showShortcutBvc BOOLEAN,
|
||||
vapid TEXT,
|
||||
warnIfProdServer BOOLEAN,
|
||||
warnIfTestServer BOOLEAN,
|
||||
webPushServer TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
did TEXT NOT NULL,
|
||||
name TEXT,
|
||||
contactMethods TEXT, -- Stored as JSON string
|
||||
nextPubKeyHashB64 TEXT,
|
||||
notes TEXT,
|
||||
profileImageUrl TEXT,
|
||||
publicKeyBase64 TEXT,
|
||||
seesMe BOOLEAN,
|
||||
registered BOOLEAN
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
date TEXT PRIMARY KEY,
|
||||
message TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS temp (
|
||||
id TEXT PRIMARY KEY,
|
||||
blobB64 TEXT
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export async function registerMigrations(): Promise<void> {
|
||||
// Register all migrations
|
||||
for (const migration of MIGRATIONS) {
|
||||
await migrationService.registerMigration(migration);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runMigrations(
|
||||
sqlExec: (
|
||||
sql: string,
|
||||
params?: SqlValue[],
|
||||
) => Promise<Array<QueryExecResult>>,
|
||||
): Promise<void> {
|
||||
await registerMigrations();
|
||||
await migrationService.runMigrations(sqlExec);
|
||||
}
|
||||
@@ -90,21 +90,18 @@ db.on("populate", async () => {
|
||||
try {
|
||||
await db.settings.add(DEFAULT_SETTINGS);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error populating the database with default settings:",
|
||||
error,
|
||||
);
|
||||
logger.error("Error populating the database with default settings:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to safely open the database with retries
|
||||
async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
|
||||
// console.log("Starting safeOpenDatabase with retries:", retries);
|
||||
// logger.log("Starting safeOpenDatabase with retries:", retries);
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
// console.log(`Attempt ${i + 1}: Checking if database is open...`);
|
||||
// logger.log(`Attempt ${i + 1}: Checking if database is open...`);
|
||||
if (!db.isOpen()) {
|
||||
// console.log(`Attempt ${i + 1}: Database is closed, attempting to open...`);
|
||||
// logger.log(`Attempt ${i + 1}: Database is closed, attempting to open...`);
|
||||
|
||||
// Create a promise that rejects after 5 seconds
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
@@ -113,19 +110,19 @@ async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
|
||||
|
||||
// Race between the open operation and the timeout
|
||||
const openPromise = db.open();
|
||||
// console.log(`Attempt ${i + 1}: Waiting for db.open() promise...`);
|
||||
// logger.log(`Attempt ${i + 1}: Waiting for db.open() promise...`);
|
||||
await Promise.race([openPromise, timeoutPromise]);
|
||||
|
||||
// If we get here, the open succeeded
|
||||
// console.log(`Attempt ${i + 1}: Database opened successfully`);
|
||||
// logger.log(`Attempt ${i + 1}: Database opened successfully`);
|
||||
return;
|
||||
}
|
||||
// console.log(`Attempt ${i + 1}: Database was already open`);
|
||||
// logger.log(`Attempt ${i + 1}: Database was already open`);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error(`Attempt ${i + 1}: Database open failed:`, error);
|
||||
logger.error(`Attempt ${i + 1}: Database open failed:`, error);
|
||||
if (i < retries - 1) {
|
||||
console.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`);
|
||||
logger.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} else {
|
||||
throw error;
|
||||
@@ -142,16 +139,14 @@ export async function updateDefaultSettings(
|
||||
delete settingsChanges.id;
|
||||
try {
|
||||
try {
|
||||
// console.log("Database state before open:", db.isOpen() ? "open" : "closed");
|
||||
// console.log("Database name:", db.name);
|
||||
// console.log("Database version:", db.verno);
|
||||
// logger.log("Database state before open:", db.isOpen() ? "open" : "closed");
|
||||
// logger.log("Database name:", db.name);
|
||||
// logger.log("Database version:", db.verno);
|
||||
await safeOpenDatabase();
|
||||
} catch (openError: unknown) {
|
||||
console.error("Failed to open database:", openError);
|
||||
const errorMessage =
|
||||
openError instanceof Error ? openError.message : String(openError);
|
||||
logger.error("Failed to open database:", openError, String(openError));
|
||||
throw new Error(
|
||||
`Database connection failed: ${errorMessage}. Please try again or restart the app.`,
|
||||
`The database connection failed. We recommend you try again or restart the app.`,
|
||||
);
|
||||
}
|
||||
const result = await db.settings.update(
|
||||
@@ -160,11 +155,13 @@ export async function updateDefaultSettings(
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error updating default settings:", error);
|
||||
logger.error("Error updating default settings:", error);
|
||||
if (error instanceof Error) {
|
||||
throw error; // Re-throw if it's already an Error with a message
|
||||
} else {
|
||||
throw new Error(`Failed to update settings: ${error}`);
|
||||
throw new Error(
|
||||
`Failed to update settings. We recommend you try again or restart the app.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
src/interfaces/database.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type SqlValue = string | number | null | Uint8Array;
|
||||
|
||||
export interface QueryExecResult {
|
||||
columns: Array<string>;
|
||||
values: Array<Array<SqlValue>>;
|
||||
}
|
||||
|
||||
export interface DatabaseService {
|
||||
initialize(): Promise<void>;
|
||||
query(sql: string, params?: unknown[]): Promise<QueryExecResult[]>;
|
||||
run(
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
): Promise<{ changes: number; lastId?: number }>;
|
||||
getOneRow(sql: string, params?: unknown[]): Promise<unknown[] | undefined>;
|
||||
getAll(sql: string, params?: unknown[]): Promise<unknown[][]>;
|
||||
}
|
||||
115
src/libs/util.ts
@@ -6,28 +6,24 @@ import * as R from "ramda";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app";
|
||||
import {
|
||||
accountsDBPromise,
|
||||
retrieveSettingsForActiveAccount,
|
||||
updateAccountSettings,
|
||||
updateDefaultSettings,
|
||||
} from "../db/index";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { Account } from "../db/tables/accounts";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings";
|
||||
import { deriveAddress, generateSeed, newIdentifier } from "../libs/crypto";
|
||||
import * as serverUtil from "../libs/endorserServer";
|
||||
import {
|
||||
containsHiddenDid,
|
||||
GenericCredWrapper,
|
||||
GenericVerifiableCredential,
|
||||
GiveSummaryRecord,
|
||||
OfferVerifiableCredential,
|
||||
} from "../libs/endorserServer";
|
||||
} from "../interfaces";
|
||||
import { containsHiddenDid } from "../libs/endorserServer";
|
||||
import { KeyMeta } from "../libs/crypto/vc";
|
||||
import { createPeerDid } from "../libs/crypto/vc/didPeer";
|
||||
import { registerCredential } from "../libs/crypto/vc/passkeyDidPeer";
|
||||
import { logger } from "../utils/logger";
|
||||
import type { PlatformService } from "../services/PlatformService";
|
||||
|
||||
export interface GiverReceiverInputInfo {
|
||||
did?: string;
|
||||
@@ -459,45 +455,38 @@ export function findAllVisibleToDids(
|
||||
|
||||
export interface AccountKeyInfo extends Account, KeyMeta {}
|
||||
|
||||
export const retrieveAccountCount = async (): Promise<number> => {
|
||||
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
||||
const accountsDB = await accountsDBPromise;
|
||||
return await accountsDB.accounts.count();
|
||||
export const retrieveAccountCount = async (
|
||||
platform: PlatformService,
|
||||
): Promise<number> => {
|
||||
const accounts = await platform.getAccounts();
|
||||
return accounts.length;
|
||||
};
|
||||
|
||||
export const retrieveAccountDids = async (): Promise<string[]> => {
|
||||
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
||||
const accountsDB = await accountsDBPromise;
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
const allDids = allAccounts.map((acc) => acc.did);
|
||||
return allDids;
|
||||
export const retrieveAccountDids = async (
|
||||
platform: PlatformService,
|
||||
): Promise<string[]> => {
|
||||
const accounts = await platform.getAccounts();
|
||||
return accounts.map((acc: Account) => acc.did);
|
||||
};
|
||||
|
||||
// This is provided and recommended when the full key is not necessary so that
|
||||
// future work could separate this info from the sensitive key material.
|
||||
export const retrieveAccountMetadata = async (
|
||||
platform: PlatformService,
|
||||
activeDid: string,
|
||||
): Promise<AccountKeyInfo | undefined> => {
|
||||
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
||||
const accountsDB = await accountsDBPromise;
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first()) as Account;
|
||||
const account = await platform.getAccount(activeDid);
|
||||
if (account) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { identity, mnemonic, ...metadata } = account;
|
||||
return metadata;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => {
|
||||
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
||||
const accountsDB = await accountsDBPromise;
|
||||
const array = await accountsDB.accounts.toArray();
|
||||
return array.map((account) => {
|
||||
export const retrieveAllAccountsMetadata = async (
|
||||
platform: PlatformService,
|
||||
): Promise<Account[]> => {
|
||||
const accounts = await platform.getAccounts();
|
||||
return accounts.map((account: Account) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { identity, mnemonic, ...metadata } = account;
|
||||
return metadata;
|
||||
@@ -505,43 +494,30 @@ export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => {
|
||||
};
|
||||
|
||||
export const retrieveFullyDecryptedAccount = async (
|
||||
platform: PlatformService,
|
||||
activeDid: string,
|
||||
): Promise<AccountKeyInfo | undefined> => {
|
||||
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
||||
const accountsDB = await accountsDBPromise;
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first()) as Account;
|
||||
return account;
|
||||
return await platform.getAccount(activeDid);
|
||||
};
|
||||
|
||||
// let's try and eliminate this
|
||||
export const retrieveAllFullyDecryptedAccounts = async (): Promise<
|
||||
Array<AccountKeyInfo>
|
||||
> => {
|
||||
const accountsDB = await accountsDBPromise;
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
return allAccounts;
|
||||
export const retrieveAllFullyDecryptedAccounts = async (
|
||||
platform: PlatformService,
|
||||
): Promise<Array<AccountKeyInfo>> => {
|
||||
return await platform.getAccounts();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a new identity, saves it to the database, and sets it as the active identity.
|
||||
* @return {Promise<string>} with the DID of the new identity
|
||||
*/
|
||||
export const generateSaveAndActivateIdentity = async (): Promise<string> => {
|
||||
export const generateSaveAndActivateIdentity = async (
|
||||
platform: PlatformService,
|
||||
): Promise<string> => {
|
||||
const mnemonic = generateSeed();
|
||||
// address is 0x... ETH address, without "did:eth:"
|
||||
const [address, privateHex, publicHex, derivationPath] =
|
||||
deriveAddress(mnemonic);
|
||||
|
||||
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
|
||||
const identity = JSON.stringify(newId);
|
||||
|
||||
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
||||
try {
|
||||
const accountsDB = await accountsDBPromise;
|
||||
await accountsDB.accounts.add({
|
||||
await platform.addAccount({
|
||||
dateCreated: new Date().toISOString(),
|
||||
derivationPath: derivationPath,
|
||||
did: newId.did,
|
||||
@@ -550,18 +526,19 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
|
||||
publicKeyHex: newId.keys[0].publicKeyHex,
|
||||
});
|
||||
|
||||
await updateDefaultSettings({ activeDid: newId.did });
|
||||
await platform.updateMasterSettings({ activeDid: newId.did });
|
||||
await platform.updateAccountSettings(newId.did, { isRegistered: false });
|
||||
} catch (error) {
|
||||
console.error("Failed to update default settings:", error);
|
||||
logger.error("Failed to save new identity:", error);
|
||||
throw new Error(
|
||||
"Failed to set default settings. Please try again or restart the app.",
|
||||
"Failed to save new identity. Please try again or restart the app.",
|
||||
);
|
||||
}
|
||||
await updateAccountSettings(newId.did, { isRegistered: false });
|
||||
return newId.did;
|
||||
};
|
||||
|
||||
export const registerAndSavePasskey = async (
|
||||
platform: PlatformService,
|
||||
keyName: string,
|
||||
): Promise<Account> => {
|
||||
const cred = await registerCredential(keyName);
|
||||
@@ -575,23 +552,25 @@ export const registerAndSavePasskey = async (
|
||||
passkeyCredIdHex,
|
||||
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"),
|
||||
};
|
||||
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
||||
const accountsDB = await accountsDBPromise;
|
||||
await accountsDB.accounts.add(account);
|
||||
|
||||
await platform.addAccount(account);
|
||||
return account;
|
||||
};
|
||||
|
||||
export const registerSaveAndActivatePasskey = async (
|
||||
platform: PlatformService,
|
||||
keyName: string,
|
||||
): Promise<Account> => {
|
||||
const account = await registerAndSavePasskey(keyName);
|
||||
await updateDefaultSettings({ activeDid: account.did });
|
||||
await updateAccountSettings(account.did, { isRegistered: false });
|
||||
const account = await registerAndSavePasskey(platform, keyName);
|
||||
await platform.updateMasterSettings({ activeDid: account.did });
|
||||
await platform.updateAccountSettings(account.did, { isRegistered: false });
|
||||
return account;
|
||||
};
|
||||
|
||||
export const getPasskeyExpirationSeconds = async (): Promise<number> => {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
export const getPasskeyExpirationSeconds = async (
|
||||
platform: PlatformService,
|
||||
): Promise<number> => {
|
||||
const settings = await platform.getActiveAccountSettings();
|
||||
return (
|
||||
(settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) *
|
||||
60
|
||||
|
||||
@@ -86,5 +86,19 @@ const handleDeepLink = async (data: { url: string }) => {
|
||||
App.addListener("appUrlOpen", handleDeepLink);
|
||||
|
||||
logger.log("[Capacitor] Mounting app");
|
||||
app.mount("#app");
|
||||
|
||||
// Initialize and mount the app
|
||||
initializeApp().then((app) => {
|
||||
app.mount("#app");
|
||||
}).catch((error) => {
|
||||
console.error("Failed to initialize app:", error);
|
||||
document.body.innerHTML = `
|
||||
<div style="color: red; padding: 20px; font-family: sans-serif;">
|
||||
<h1>Failed to initialize app</h1>
|
||||
<p>${error instanceof Error ? error.message : "Unknown error"}</p>
|
||||
<p>Please try restarting the app or contact support if the problem persists.</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
logger.log("[Capacitor] App mounted");
|
||||
|
||||
@@ -9,6 +9,7 @@ import "./assets/styles/tailwind.css";
|
||||
import { FontAwesomeIcon } from "./libs/fontawesome";
|
||||
import Camera from "simple-vue-camera";
|
||||
import { logger } from "./utils/logger";
|
||||
import { PlatformServiceFactory } from "./services/PlatformServiceFactory";
|
||||
|
||||
// Global Error Handler
|
||||
function setupGlobalErrorHandler(app: VueApp) {
|
||||
@@ -31,7 +32,7 @@ function setupGlobalErrorHandler(app: VueApp) {
|
||||
}
|
||||
|
||||
// Function to initialize the app
|
||||
export function initializeApp() {
|
||||
export async function initializeApp() {
|
||||
logger.log("[App Init] Starting app initialization");
|
||||
logger.log("[App Init] Platform:", process.env.VITE_PLATFORM);
|
||||
|
||||
@@ -54,6 +55,22 @@ export function initializeApp() {
|
||||
app.use(Notifications);
|
||||
logger.log("[App Init] Notifications initialized");
|
||||
|
||||
// Initialize platform service
|
||||
const platform = await PlatformServiceFactory.getInstance();
|
||||
app.config.globalProperties.$platform = platform;
|
||||
logger.log("[App Init] Platform service initialized");
|
||||
|
||||
// Initialize SQLite
|
||||
try {
|
||||
const sqlite = await platform.getSQLite();
|
||||
const config = { name: "TimeSafariDB", useWAL: true };
|
||||
await sqlite.initialize(config);
|
||||
logger.log("[App Init] SQLite database initialized");
|
||||
} catch (error) {
|
||||
logger.error("[App Init] Failed to initialize SQLite:", error);
|
||||
// Don't throw here - we want the app to start even if SQLite fails
|
||||
}
|
||||
|
||||
setupGlobalErrorHandler(app);
|
||||
logger.log("[App Init] App initialization complete");
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { initializeApp } from "./main.common";
|
||||
|
||||
const app = initializeApp();
|
||||
app.mount("#app");
|
||||
// Initialize and mount the app
|
||||
initializeApp().then((app) => {
|
||||
app.mount("#app");
|
||||
}).catch((error) => {
|
||||
console.error("Failed to initialize app:", error);
|
||||
document.body.innerHTML = `
|
||||
<div style="color: red; padding: 20px; font-family: sans-serif;">
|
||||
<h1>Failed to initialize app</h1>
|
||||
<p>${error instanceof Error ? error.message : "Unknown error"}</p>
|
||||
<p>Please try restarting the app or contact support if the problem persists.</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { initializeApp } from "./main.common";
|
||||
|
||||
const app = initializeApp();
|
||||
app.mount("#app");
|
||||
// Initialize and mount the app
|
||||
initializeApp().then((app) => {
|
||||
app.mount("#app");
|
||||
}).catch((error) => {
|
||||
console.error("Failed to initialize app:", error);
|
||||
document.body.innerHTML = `
|
||||
<div style="color: red; padding: 20px; font-family: sans-serif;">
|
||||
<h1>Failed to initialize app</h1>
|
||||
<p>${error instanceof Error ? error.message : "Unknown error"}</p>
|
||||
<p>Please try restarting the app or contact support if the problem persists.</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
215
src/main.ts
@@ -1,215 +0,0 @@
|
||||
import { createPinia } from "pinia";
|
||||
import { App as VueApp, ComponentPublicInstance, createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./registerServiceWorker";
|
||||
import router from "./router";
|
||||
import axios from "axios";
|
||||
import VueAxios from "vue-axios";
|
||||
import Notifications from "notiwind";
|
||||
import "./assets/styles/tailwind.css";
|
||||
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faArrowRotateBackward,
|
||||
faArrowUpRightFromSquare,
|
||||
faArrowUp,
|
||||
faBan,
|
||||
faBitcoinSign,
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faCamera,
|
||||
faCameraRotate,
|
||||
faCaretDown,
|
||||
faChair,
|
||||
faCheck,
|
||||
faChevronDown,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faChevronUp,
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCircleQuestion,
|
||||
faCircleUser,
|
||||
faClock,
|
||||
faCoins,
|
||||
faComment,
|
||||
faCopy,
|
||||
faDollar,
|
||||
faEllipsis,
|
||||
faEllipsisVertical,
|
||||
faEnvelopeOpenText,
|
||||
faEraser,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faFileContract,
|
||||
faFileLines,
|
||||
faFilter,
|
||||
faFloppyDisk,
|
||||
faFolderOpen,
|
||||
faForward,
|
||||
faGift,
|
||||
faGlobe,
|
||||
faHammer,
|
||||
faHand,
|
||||
faHandHoldingDollar,
|
||||
faHandHoldingHeart,
|
||||
faHouseChimney,
|
||||
faImage,
|
||||
faImagePortrait,
|
||||
faLeftRight,
|
||||
faLightbulb,
|
||||
faLink,
|
||||
faLocationDot,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
faMagnifyingGlass,
|
||||
faMessage,
|
||||
faMinus,
|
||||
faPen,
|
||||
faPersonCircleCheck,
|
||||
faPersonCircleQuestion,
|
||||
faPlus,
|
||||
faQuestion,
|
||||
faQrcode,
|
||||
faRightFromBracket,
|
||||
faRotate,
|
||||
faShareNodes,
|
||||
faSpinner,
|
||||
faSquare,
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faSquarePlus,
|
||||
faTrashCan,
|
||||
faTriangleExclamation,
|
||||
faUser,
|
||||
faUsers,
|
||||
faXmark,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
library.add(
|
||||
faArrowDown,
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faArrowRotateBackward,
|
||||
faArrowUpRightFromSquare,
|
||||
faArrowUp,
|
||||
faBan,
|
||||
faBitcoinSign,
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faCamera,
|
||||
faCameraRotate,
|
||||
faCaretDown,
|
||||
faChair,
|
||||
faCheck,
|
||||
faChevronDown,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faChevronUp,
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCircleQuestion,
|
||||
faCircleUser,
|
||||
faClock,
|
||||
faCoins,
|
||||
faComment,
|
||||
faCopy,
|
||||
faDollar,
|
||||
faEllipsis,
|
||||
faEllipsisVertical,
|
||||
faEnvelopeOpenText,
|
||||
faEraser,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faFileContract,
|
||||
faFileLines,
|
||||
faFilter,
|
||||
faFloppyDisk,
|
||||
faFolderOpen,
|
||||
faForward,
|
||||
faGift,
|
||||
faGlobe,
|
||||
faHammer,
|
||||
faHand,
|
||||
faHandHoldingDollar,
|
||||
faHandHoldingHeart,
|
||||
faHouseChimney,
|
||||
faImage,
|
||||
faImagePortrait,
|
||||
faLeftRight,
|
||||
faLightbulb,
|
||||
faLink,
|
||||
faLocationDot,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
faMagnifyingGlass,
|
||||
faMessage,
|
||||
faMinus,
|
||||
faPen,
|
||||
faPersonCircleCheck,
|
||||
faPersonCircleQuestion,
|
||||
faPlus,
|
||||
faQrcode,
|
||||
faQuestion,
|
||||
faRotate,
|
||||
faRightFromBracket,
|
||||
faShareNodes,
|
||||
faSpinner,
|
||||
faSquare,
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faSquarePlus,
|
||||
faTrashCan,
|
||||
faTriangleExclamation,
|
||||
faUser,
|
||||
faUsers,
|
||||
faXmark,
|
||||
);
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import Camera from "simple-vue-camera";
|
||||
import { logger } from "./utils/logger";
|
||||
|
||||
// Can trigger this with a 'throw' inside some top-level function, eg. on the HomeView
|
||||
function setupGlobalErrorHandler(app: VueApp) {
|
||||
// @ts-expect-error 'cause we cannot see why config is not defined
|
||||
app.config.errorHandler = (
|
||||
err: Error,
|
||||
instance: ComponentPublicInstance | null,
|
||||
info: string,
|
||||
) => {
|
||||
logger.error(
|
||||
"Ouch! Global Error Handler.",
|
||||
"Error:",
|
||||
err,
|
||||
"- Error toString:",
|
||||
err.toString(),
|
||||
"- Info:",
|
||||
info,
|
||||
"- Instance:",
|
||||
instance,
|
||||
);
|
||||
// Want to show a nice notiwind notification but can't figure out how.
|
||||
alert(
|
||||
(err.message || "Something bad happened") +
|
||||
" - Try reloading or restarting the app.",
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
.component("fa", FontAwesomeIcon)
|
||||
.component("camera", Camera)
|
||||
.use(createPinia())
|
||||
.use(VueAxios, axios)
|
||||
.use(router)
|
||||
.use(Notifications);
|
||||
|
||||
setupGlobalErrorHandler(app);
|
||||
|
||||
app.mount("#app");
|
||||
@@ -1,5 +1,34 @@
|
||||
import { initBackend } from "absurd-sql/dist/indexeddb-main-thread";
|
||||
import { initializeApp } from "./main.common";
|
||||
import "./registerServiceWorker"; // Web PWA support
|
||||
|
||||
const app = initializeApp();
|
||||
app.mount("#app");
|
||||
|
||||
function sqlInit() {
|
||||
// see https://github.com/jlongster/absurd-sql
|
||||
const worker = new Worker(
|
||||
new URL("./registerSQLWorker.js", import.meta.url),
|
||||
{
|
||||
type: "module",
|
||||
},
|
||||
);
|
||||
// This is only required because Safari doesn't support nested
|
||||
// workers. This installs a handler that will proxy creating web
|
||||
// workers through the main thread
|
||||
initBackend(worker);
|
||||
}
|
||||
sqlInit();
|
||||
|
||||
// Initialize and mount the app
|
||||
initializeApp().then((app) => {
|
||||
app.mount("#app");
|
||||
}).catch((error) => {
|
||||
console.error("Failed to initialize app:", error);
|
||||
document.body.innerHTML = `
|
||||
<div style="color: red; padding: 20px; font-family: sans-serif;">
|
||||
<h1>Failed to initialize app</h1>
|
||||
<p>${error instanceof Error ? error.message : "Unknown error"}</p>
|
||||
<p>Please try refreshing the page or contact support if the problem persists.</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
6
src/registerSQLWorker.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import databaseService from "./services/database";
|
||||
|
||||
async function run() {
|
||||
await databaseService.initialize();
|
||||
}
|
||||
run();
|
||||
370
src/services/ElectronPlatformService.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import {
|
||||
PlatformService,
|
||||
PlatformCapabilities,
|
||||
SQLiteOperations,
|
||||
SQLiteConfig,
|
||||
PreparedStatement,
|
||||
SQLiteResult,
|
||||
ImageResult,
|
||||
} from "./PlatformService";
|
||||
import { BaseSQLiteService } from "./sqlite/BaseSQLiteService";
|
||||
import { app } from "electron";
|
||||
import { dialog } from "electron";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import sqlite3 from "sqlite3";
|
||||
import { open, Database } from "sqlite";
|
||||
import { logger } from "../utils/logger";
|
||||
import { Settings } from "../db/tables/settings";
|
||||
import { Account } from "../db/tables/accounts";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { db } from "../db";
|
||||
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
|
||||
import { accountsDBPromise } from "../db";
|
||||
import { accessToken } from "../libs/crypto";
|
||||
import { getPlanFromCache as getPlanFromCacheImpl } from "../libs/endorserServer";
|
||||
import { PlanSummaryRecord } from "../interfaces/records";
|
||||
import { Axios } from "axios";
|
||||
|
||||
interface SQLiteDatabase extends Database {
|
||||
changes: number;
|
||||
}
|
||||
|
||||
// Create Promise-based versions of fs functions
|
||||
const readFileAsync = (filePath: string, encoding: BufferEncoding): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile(filePath, { encoding }, (err: NodeJS.ErrnoException | null, data: string) => {
|
||||
if (err) reject(err);
|
||||
else resolve(data);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const readFileBufferAsync = (filePath: string): Promise<Buffer> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile(filePath, (err: NodeJS.ErrnoException | null, data: Buffer) => {
|
||||
if (err) reject(err);
|
||||
else resolve(data);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const writeFileAsync = (filePath: string, data: string, encoding: BufferEncoding): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.writeFile(filePath, data, { encoding }, (err: NodeJS.ErrnoException | null) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const unlinkAsync = (filePath: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.unlink(filePath, (err: NodeJS.ErrnoException | null) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const readdirAsync = (dirPath: string): Promise<string[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readdir(dirPath, (err: NodeJS.ErrnoException | null, files: string[]) => {
|
||||
if (err) reject(err);
|
||||
else resolve(files);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const statAsync = (filePath: string): Promise<fs.Stats> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.stat(filePath, (err: NodeJS.ErrnoException | null, stats: fs.Stats) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stats);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* SQLite implementation for Electron using native sqlite3
|
||||
*/
|
||||
class ElectronSQLiteService extends BaseSQLiteService {
|
||||
private db: SQLiteDatabase | null = null;
|
||||
private config: SQLiteConfig | null = null;
|
||||
|
||||
async initialize(config: SQLiteConfig): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.config = config;
|
||||
const dbPath = path.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 = path.join(app.getPath("userData"), `${this.config.name}.db`);
|
||||
const stats = await statAsync(dbPath);
|
||||
return stats.size;
|
||||
} catch (error) {
|
||||
logger.error("Failed to get database size:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only import Electron-specific code in Electron environment
|
||||
let ElectronPlatformServiceImpl: typeof import("./platforms/ElectronPlatformService").ElectronPlatformService;
|
||||
|
||||
async function initializeElectronPlatformService() {
|
||||
if (process.env.ELECTRON) {
|
||||
// Dynamic import for Electron environment
|
||||
const { ElectronPlatformService } = await import("./platforms/ElectronPlatformService");
|
||||
ElectronPlatformServiceImpl = ElectronPlatformService;
|
||||
} else {
|
||||
// Stub implementation for non-Electron environments
|
||||
class StubElectronPlatformService implements PlatformService {
|
||||
#sqliteService: SQLiteOperations | null = null;
|
||||
|
||||
getCapabilities(): PlatformCapabilities {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async getSQLite(): Promise<SQLiteOperations> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<string> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: string): Promise<void> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async deleteFile(path: string): Promise<void> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async listFiles(directory: string): Promise<string[]> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async takePicture(): Promise<ImageResult> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async pickImage(): Promise<ImageResult> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async handleDeepLink(url: string): Promise<void> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async getAccounts(): Promise<Account[]> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async getAccount(did: string): Promise<Account | undefined> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async addAccount(account: Account): Promise<void> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async getContacts(): Promise<Contact[]> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async getAllContacts(): Promise<Contact[]> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async updateMasterSettings(settingsChanges: Partial<Settings>): Promise<void> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async getActiveAccountSettings(): Promise<Settings> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async updateAccountSettings(accountDid: string, settingsChanges: Partial<Settings>): Promise<void> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async getHeaders(did?: string): Promise<Record<string, string>> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
async getPlanFromCache(
|
||||
handleId: string | undefined,
|
||||
axios: Axios,
|
||||
apiServer: string,
|
||||
requesterDid?: string,
|
||||
): Promise<PlanSummaryRecord | undefined> {
|
||||
throw new Error("Electron platform service is not available in this environment");
|
||||
}
|
||||
|
||||
isCapacitor(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
isElectron(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
isPyWebView(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
isWeb(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
ElectronPlatformServiceImpl = StubElectronPlatformService;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the service
|
||||
initializeElectronPlatformService().catch(error => {
|
||||
logger.error("Failed to initialize Electron platform service:", error);
|
||||
});
|
||||
|
||||
export class ElectronPlatformService extends ElectronPlatformServiceImpl {}
|
||||
@@ -1,3 +1,9 @@
|
||||
import { Settings } from "../db/tables/settings";
|
||||
import { Account } from "../db/tables/accounts";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { Axios } from "axios";
|
||||
import { PlanSummaryRecord } from "../interfaces/records";
|
||||
|
||||
/**
|
||||
* Represents the result of an image capture or selection operation.
|
||||
* Contains both the image data as a Blob and the associated filename.
|
||||
@@ -26,8 +32,154 @@ export interface PlatformCapabilities {
|
||||
hasFileDownload: boolean;
|
||||
/** Whether the platform requires special file handling instructions */
|
||||
needsFileHandlingInstructions: boolean;
|
||||
/** Whether the platform is a native app (Capacitor, Electron, etc.) */
|
||||
isNativeApp: boolean;
|
||||
/** SQLite capabilities of the platform */
|
||||
sqlite: {
|
||||
/** Whether SQLite is supported on this platform */
|
||||
supported: boolean;
|
||||
/** Whether SQLite runs in a Web Worker (browser) */
|
||||
runsInWorker: boolean;
|
||||
/** Whether the platform supports SharedArrayBuffer (required for optimal performance) */
|
||||
hasSharedArrayBuffer: boolean;
|
||||
/** Whether the platform supports WAL mode */
|
||||
supportsWAL: boolean;
|
||||
/** Maximum database size in bytes (if known) */
|
||||
maxSize?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite configuration options
|
||||
*/
|
||||
export interface SQLiteConfig {
|
||||
/** Database name */
|
||||
name: string;
|
||||
/** Whether to use WAL mode (if supported) */
|
||||
useWAL?: boolean;
|
||||
/** Whether to use memory-mapped I/O (if supported) */
|
||||
useMMap?: boolean;
|
||||
/** Size of memory map in bytes (if using mmap) */
|
||||
mmapSize?: number;
|
||||
/** Whether to use prepared statements cache */
|
||||
usePreparedStatements?: boolean;
|
||||
/** Maximum number of prepared statements to cache */
|
||||
maxPreparedStatements?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a SQLite query result with typed rows
|
||||
*/
|
||||
export interface SQLiteResult<T> {
|
||||
/** The rows returned by the query */
|
||||
rows: T[];
|
||||
/** The number of rows affected by the query */
|
||||
rowsAffected: number;
|
||||
/** The last inserted row ID (if applicable) */
|
||||
lastInsertId?: number;
|
||||
/** Execution time in milliseconds */
|
||||
executionTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite operations interface for platform-agnostic database access
|
||||
*/
|
||||
export interface SQLiteOperations {
|
||||
/**
|
||||
* Initializes the SQLite database with the given configuration
|
||||
* @param config - SQLite configuration options
|
||||
* @returns Promise resolving when initialization is complete
|
||||
*/
|
||||
initialize(config: SQLiteConfig): Promise<void>;
|
||||
|
||||
/**
|
||||
* Executes a SQL query and returns typed results
|
||||
* @param sql - The SQL query to execute
|
||||
* @param params - Optional parameters for the query
|
||||
* @returns Promise resolving to the query results
|
||||
*/
|
||||
query<T>(sql: string, params?: unknown[]): Promise<SQLiteResult<T>>;
|
||||
|
||||
/**
|
||||
* Executes a SQL query that modifies data (INSERT, UPDATE, DELETE)
|
||||
* @param sql - The SQL query to execute
|
||||
* @param params - Optional parameters for the query
|
||||
* @returns Promise resolving to the number of rows affected
|
||||
*/
|
||||
execute(sql: string, params?: unknown[]): Promise<number>;
|
||||
|
||||
/**
|
||||
* Executes multiple SQL statements in a transaction
|
||||
* @param statements - Array of SQL statements to execute
|
||||
* @returns Promise resolving when the transaction is complete
|
||||
*/
|
||||
transaction(statements: { sql: string; params?: unknown[] }[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Gets the maximum value of a column for matching rows
|
||||
* @param table - The table to query
|
||||
* @param column - The column to find the maximum value of
|
||||
* @param where - Optional WHERE clause conditions
|
||||
* @param params - Optional parameters for the WHERE clause
|
||||
* @returns Promise resolving to the maximum value
|
||||
*/
|
||||
getMaxValue<T>(
|
||||
table: string,
|
||||
column: string,
|
||||
where?: string,
|
||||
params?: unknown[],
|
||||
): Promise<T | null>;
|
||||
|
||||
/**
|
||||
* Prepares a SQL statement for repeated execution
|
||||
* @param sql - The SQL statement to prepare
|
||||
* @returns A prepared statement that can be executed multiple times
|
||||
*/
|
||||
prepare<T>(sql: string): Promise<PreparedStatement<T>>;
|
||||
|
||||
/**
|
||||
* Gets the current database size in bytes
|
||||
* @returns Promise resolving to the database size
|
||||
*/
|
||||
getDatabaseSize(): Promise<number>;
|
||||
|
||||
/**
|
||||
* Gets the current database statistics
|
||||
* @returns Promise resolving to database statistics
|
||||
*/
|
||||
getStats(): Promise<SQLiteStats>;
|
||||
|
||||
/**
|
||||
* Closes the database connection
|
||||
* @returns Promise resolving when the connection is closed
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a prepared SQL statement
|
||||
*/
|
||||
export interface PreparedStatement<T> {
|
||||
/** Executes the prepared statement with the given parameters */
|
||||
execute(params?: unknown[]): Promise<SQLiteResult<T>>;
|
||||
/** Frees the prepared statement */
|
||||
finalize(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database statistics
|
||||
*/
|
||||
export interface SQLiteStats {
|
||||
/** Total number of queries executed */
|
||||
totalQueries: number;
|
||||
/** Average query execution time in milliseconds */
|
||||
avgExecutionTime: number;
|
||||
/** Number of prepared statements in cache */
|
||||
preparedStatements: number;
|
||||
/** Current database size in bytes */
|
||||
databaseSize: number;
|
||||
/** Whether WAL mode is active */
|
||||
walMode: boolean;
|
||||
/** Whether memory mapping is active */
|
||||
mmapActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,11 +213,12 @@ export interface PlatformService {
|
||||
|
||||
/**
|
||||
* Writes content to a file at the specified path and shares it.
|
||||
* Optional method - not all platforms need to implement this.
|
||||
* @param fileName - The filename of the file to write
|
||||
* @param content - The content to write to the file
|
||||
* @returns Promise that resolves when the write is complete
|
||||
*/
|
||||
writeAndShareFile(fileName: string, content: string): Promise<void>;
|
||||
writeAndShareFile?(fileName: string, content: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deletes a file at the specified path.
|
||||
@@ -94,16 +247,98 @@ export interface PlatformService {
|
||||
*/
|
||||
pickImage(): Promise<ImageResult>;
|
||||
|
||||
/**
|
||||
* Rotates the camera between front and back cameras.
|
||||
* @returns Promise that resolves when the camera is rotated
|
||||
*/
|
||||
rotateCamera(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Handles deep link URLs for the application.
|
||||
* @param url - The deep link URL to handle
|
||||
* @returns Promise that resolves when the deep link has been handled
|
||||
*/
|
||||
handleDeepLink(url: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Gets the SQLite operations interface for the platform.
|
||||
* For browsers, this will use absurd-sql with Web Worker support.
|
||||
* @returns Promise resolving to the SQLite operations interface
|
||||
*/
|
||||
getSQLite(): Promise<SQLiteOperations>;
|
||||
|
||||
/**
|
||||
* Gets the headers for HTTP requests, including authorization if needed
|
||||
* @param did - Optional DID to include in authorization
|
||||
* @returns Promise resolving to headers object
|
||||
*/
|
||||
getHeaders(did?: string): Promise<Record<string, string>>;
|
||||
|
||||
// Account Management
|
||||
/**
|
||||
* Gets all accounts in the database
|
||||
* @returns Promise resolving to array of accounts
|
||||
*/
|
||||
getAccounts(): Promise<Account[]>;
|
||||
|
||||
/**
|
||||
* Gets a specific account by DID
|
||||
* @param did - The DID of the account to retrieve
|
||||
* @returns Promise resolving to the account or undefined if not found
|
||||
*/
|
||||
getAccount(did: string): Promise<Account | undefined>;
|
||||
|
||||
/**
|
||||
* Adds a new account to the database
|
||||
* @param account - The account to add
|
||||
* @returns Promise resolving when the account is added
|
||||
*/
|
||||
addAccount(account: Account): Promise<void>;
|
||||
|
||||
// Settings Management
|
||||
/**
|
||||
* Updates the master settings with the provided changes
|
||||
* @param settingsChanges - The settings to update
|
||||
* @returns Promise resolving when the update is complete
|
||||
*/
|
||||
updateMasterSettings(settingsChanges: Partial<Settings>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Gets the settings for the active account
|
||||
* @returns Promise resolving to the active account settings
|
||||
*/
|
||||
getActiveAccountSettings(): Promise<Settings>;
|
||||
|
||||
/**
|
||||
* Updates settings for a specific account
|
||||
* @param accountDid - The DID of the account to update settings for
|
||||
* @param settingsChanges - The settings to update
|
||||
* @returns Promise resolving when the update is complete
|
||||
*/
|
||||
updateAccountSettings(
|
||||
accountDid: string,
|
||||
settingsChanges: Partial<Settings>,
|
||||
): Promise<void>;
|
||||
|
||||
// Contact Management
|
||||
/**
|
||||
* Gets all contacts from the database
|
||||
* @returns Promise resolving to array of contacts
|
||||
*/
|
||||
getContacts(): Promise<Contact[]>;
|
||||
|
||||
/**
|
||||
* Gets all contacts from the database (alias for getContacts)
|
||||
* @returns Promise resolving to array of contacts
|
||||
*/
|
||||
getAllContacts(): Promise<Contact[]>;
|
||||
|
||||
/**
|
||||
* Retrieves plan data from cache or server
|
||||
* @param handleId - Plan handle ID
|
||||
* @param axios - Axios instance for making HTTP requests
|
||||
* @param apiServer - API server URL
|
||||
* @param requesterDid - Optional requester DID for private info
|
||||
* @returns Promise resolving to plan data or undefined if not found
|
||||
*/
|
||||
getPlanFromCache(
|
||||
handleId: string | undefined,
|
||||
axios: Axios,
|
||||
apiServer: string,
|
||||
requesterDid?: string,
|
||||
): Promise<PlanSummaryRecord | undefined>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { PlatformService } from "./PlatformService";
|
||||
import { WebPlatformService } from "./platforms/WebPlatformService";
|
||||
import { CapacitorPlatformService } from "./platforms/CapacitorPlatformService";
|
||||
import { ElectronPlatformService } from "./platforms/ElectronPlatformService";
|
||||
import { PyWebViewPlatformService } from "./platforms/PyWebViewPlatformService";
|
||||
|
||||
/**
|
||||
* Factory class for creating platform-specific service implementations.
|
||||
@@ -17,7 +14,7 @@ import { PyWebViewPlatformService } from "./platforms/PyWebViewPlatformService";
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const platformService = PlatformServiceFactory.getInstance();
|
||||
* const platformService = await PlatformServiceFactory.getInstance();
|
||||
* await platformService.takePicture();
|
||||
* ```
|
||||
*/
|
||||
@@ -28,31 +25,48 @@ export class PlatformServiceFactory {
|
||||
* Gets or creates the singleton instance of PlatformService.
|
||||
* Creates the appropriate platform-specific implementation based on environment.
|
||||
*
|
||||
* @returns {PlatformService} The singleton instance of PlatformService
|
||||
* @returns {Promise<PlatformService>} Promise resolving to the singleton instance of PlatformService
|
||||
*/
|
||||
public static getInstance(): PlatformService {
|
||||
public static async getInstance(): Promise<PlatformService> {
|
||||
if (PlatformServiceFactory.instance) {
|
||||
return PlatformServiceFactory.instance;
|
||||
}
|
||||
|
||||
const platform = process.env.VITE_PLATFORM || "web";
|
||||
|
||||
switch (platform) {
|
||||
case "capacitor":
|
||||
PlatformServiceFactory.instance = new CapacitorPlatformService();
|
||||
break;
|
||||
case "electron":
|
||||
PlatformServiceFactory.instance = new ElectronPlatformService();
|
||||
break;
|
||||
case "pywebview":
|
||||
PlatformServiceFactory.instance = new PyWebViewPlatformService();
|
||||
break;
|
||||
case "web":
|
||||
default:
|
||||
PlatformServiceFactory.instance = new WebPlatformService();
|
||||
break;
|
||||
}
|
||||
try {
|
||||
switch (platform) {
|
||||
case "capacitor": {
|
||||
const { CapacitorPlatformService } = await import("./platforms/CapacitorPlatformService");
|
||||
PlatformServiceFactory.instance = new CapacitorPlatformService();
|
||||
break;
|
||||
}
|
||||
case "electron": {
|
||||
const { ElectronPlatformService } = await import("./ElectronPlatformService");
|
||||
PlatformServiceFactory.instance = new ElectronPlatformService();
|
||||
break;
|
||||
}
|
||||
case "pywebview": {
|
||||
const { PyWebViewPlatformService } = await import("./platforms/PyWebViewPlatformService");
|
||||
PlatformServiceFactory.instance = new PyWebViewPlatformService();
|
||||
break;
|
||||
}
|
||||
case "web":
|
||||
default:
|
||||
PlatformServiceFactory.instance = new WebPlatformService();
|
||||
break;
|
||||
}
|
||||
|
||||
return PlatformServiceFactory.instance;
|
||||
if (!PlatformServiceFactory.instance) {
|
||||
throw new Error(`Failed to initialize platform service for ${platform}`);
|
||||
}
|
||||
|
||||
return PlatformServiceFactory.instance;
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize ${platform} platform service:`, error);
|
||||
// Fallback to web platform if initialization fails
|
||||
PlatformServiceFactory.instance = new WebPlatformService();
|
||||
return PlatformServiceFactory.instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
29
src/services/database.d.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
import { DatabaseService } from "../interfaces/database";
|
||||
|
||||
declare module "@jlongster/sql.js" {
|
||||
interface SQL {
|
||||
Database: unknown;
|
||||
FS: unknown;
|
||||
register_for_idb: (fs: unknown) => void;
|
||||
}
|
||||
|
||||
function initSqlJs(config: {
|
||||
locateFile: (file: string) => string;
|
||||
}): Promise<SQL>;
|
||||
export default initSqlJs;
|
||||
}
|
||||
|
||||
declare module "absurd-sql" {
|
||||
export class SQLiteFS {
|
||||
constructor(fs: unknown, backend: unknown);
|
||||
}
|
||||
}
|
||||
|
||||
declare module "absurd-sql/dist/indexeddb-backend" {
|
||||
export default class IndexedDBBackend {
|
||||
constructor();
|
||||
}
|
||||
}
|
||||
|
||||
declare const databaseService: DatabaseService;
|
||||
export default databaseService;
|
||||
163
src/services/database.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
// Add type declarations for external modules
|
||||
declare module "@jlongster/sql.js";
|
||||
declare module "absurd-sql";
|
||||
declare module "absurd-sql/dist/indexeddb-backend";
|
||||
|
||||
import initSqlJs from "@jlongster/sql.js";
|
||||
import { SQLiteFS } from "absurd-sql";
|
||||
import IndexedDBBackend from "absurd-sql/dist/indexeddb-backend";
|
||||
|
||||
import { runMigrations } from "../db-sql/migration";
|
||||
import type { QueryExecResult } from "../interfaces/database";
|
||||
import { logger } from "@/utils/logger";
|
||||
|
||||
interface SQLDatabase {
|
||||
exec: (sql: string, params?: unknown[]) => Promise<QueryExecResult[]>;
|
||||
run: (
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
) => Promise<{ changes: number; lastId?: number }>;
|
||||
}
|
||||
|
||||
class DatabaseService {
|
||||
private static instance: DatabaseService | null = null;
|
||||
private db: SQLDatabase | null;
|
||||
private initialized: boolean;
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.db = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
static getInstance(): DatabaseService {
|
||||
if (!DatabaseService.instance) {
|
||||
DatabaseService.instance = new DatabaseService();
|
||||
}
|
||||
return DatabaseService.instance;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// If already initialized, return immediately
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If initialization is in progress, wait for it
|
||||
if (this.initializationPromise) {
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
// Start initialization
|
||||
this.initializationPromise = this._initialize();
|
||||
try {
|
||||
await this.initializationPromise;
|
||||
} catch (error) {
|
||||
logger.error(`DatabaseService initialize method failed:`, error);
|
||||
this.initializationPromise = null; // Reset on failure
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async _initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const SQL = await initSqlJs({
|
||||
locateFile: (file: string) => {
|
||||
return new URL(
|
||||
`/node_modules/@jlongster/sql.js/dist/${file}`,
|
||||
import.meta.url,
|
||||
).href;
|
||||
},
|
||||
});
|
||||
|
||||
const sqlFS = new SQLiteFS(SQL.FS, new IndexedDBBackend());
|
||||
SQL.register_for_idb(sqlFS);
|
||||
|
||||
SQL.FS.mkdir("/sql");
|
||||
SQL.FS.mount(sqlFS, {}, "/sql");
|
||||
|
||||
const path = "/sql/db.sqlite";
|
||||
if (typeof SharedArrayBuffer === "undefined") {
|
||||
const stream = SQL.FS.open(path, "a+");
|
||||
await stream.node.contents.readIfFallback();
|
||||
SQL.FS.close(stream);
|
||||
}
|
||||
|
||||
this.db = new SQL.Database(path, { filename: true });
|
||||
if (!this.db) {
|
||||
throw new Error(
|
||||
"The database initialization failed. We recommend you restart or reinstall.",
|
||||
);
|
||||
}
|
||||
|
||||
await this.db.exec(`PRAGMA journal_mode=MEMORY;`);
|
||||
const sqlExec = this.db.exec.bind(this.db);
|
||||
|
||||
// Run migrations
|
||||
await runMigrations(sqlExec);
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
private async waitForInitialization(): Promise<void> {
|
||||
// If we have an initialization promise, wait for it
|
||||
if (this.initializationPromise) {
|
||||
await this.initializationPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
// If not initialized and no promise, start initialization
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
return;
|
||||
}
|
||||
|
||||
// If initialized but no db, something went wrong
|
||||
if (!this.db) {
|
||||
logger.error(
|
||||
`Database not properly initialized after await waitForInitialization() - initialized flag is true but db is null`,
|
||||
);
|
||||
throw new Error(
|
||||
`The database could not be initialized. We recommend you restart or reinstall.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Used for inserts, updates, and deletes
|
||||
async run(
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<{ changes: number; lastId?: number }> {
|
||||
await this.waitForInitialization();
|
||||
return this.db!.run(sql, params);
|
||||
}
|
||||
|
||||
// Note that the resulting array may be empty if there are no results from the query
|
||||
async query(sql: string, params: unknown[] = []): Promise<QueryExecResult[]> {
|
||||
await this.waitForInitialization();
|
||||
return this.db!.exec(sql, params);
|
||||
}
|
||||
|
||||
async getOneRow(
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<unknown[] | undefined> {
|
||||
await this.waitForInitialization();
|
||||
const result = await this.db!.exec(sql, params);
|
||||
return result[0]?.values[0];
|
||||
}
|
||||
|
||||
async all(sql: string, params: unknown[] = []): Promise<unknown[][]> {
|
||||
await this.waitForInitialization();
|
||||
const result = await this.db!.exec(sql, params);
|
||||
return result[0]?.values || [];
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
const databaseService = DatabaseService.getInstance();
|
||||
|
||||
export default databaseService;
|
||||
72
src/services/migrationService.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { logger } from "@/utils/logger";
|
||||
import { QueryExecResult } from "../interfaces/database";
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
export class MigrationService {
|
||||
private static instance: MigrationService;
|
||||
private migrations: Migration[] = [];
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): MigrationService {
|
||||
if (!MigrationService.instance) {
|
||||
MigrationService.instance = new MigrationService();
|
||||
}
|
||||
return MigrationService.instance;
|
||||
}
|
||||
|
||||
async registerMigration(migration: Migration): Promise<void> {
|
||||
this.migrations.push(migration);
|
||||
}
|
||||
|
||||
async runMigrations(
|
||||
sqlExec: (
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
) => Promise<Array<QueryExecResult>>,
|
||||
): Promise<void> {
|
||||
// Create migrations table if it doesn't exist
|
||||
await sqlExec(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// Get list of executed migrations
|
||||
const result: QueryExecResult[] = await sqlExec(
|
||||
"SELECT name FROM migrations;",
|
||||
);
|
||||
let executedMigrations: Set<unknown> = new Set();
|
||||
// Even with that query, the QueryExecResult may be [] (which doesn't make sense to me).
|
||||
if (result.length > 0) {
|
||||
const singleResult = result[0];
|
||||
executedMigrations = new Set(
|
||||
singleResult.values.map((row: unknown[]) => row[0]),
|
||||
);
|
||||
}
|
||||
|
||||
// Run pending migrations in order
|
||||
for (const migration of this.migrations) {
|
||||
if (!executedMigrations.has(migration.name)) {
|
||||
try {
|
||||
await sqlExec(migration.sql);
|
||||
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
|
||||
migration.name,
|
||||
]);
|
||||
logger.log(`Migration ${migration.name} executed successfully`);
|
||||
} catch (error) {
|
||||
logger.error(`Error executing migration ${migration.name}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MigrationService.getInstance();
|
||||
@@ -4,9 +4,13 @@ import {
|
||||
PlatformCapabilities,
|
||||
} from "../PlatformService";
|
||||
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
|
||||
import { Camera, CameraResultType, CameraSource, CameraDirection } from "@capacitor/camera";
|
||||
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
|
||||
import { Share } from "@capacitor/share";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { Account } from "../../db/tables/accounts";
|
||||
import { Settings } from "../../db/tables/settings";
|
||||
import { db } from "../../db";
|
||||
import { Contact } from "../../db/tables/contacts";
|
||||
|
||||
/**
|
||||
* Platform service implementation for Capacitor (mobile) platform.
|
||||
@@ -16,9 +20,6 @@ import { logger } from "../../utils/logger";
|
||||
* - Platform-specific features
|
||||
*/
|
||||
export class CapacitorPlatformService implements PlatformService {
|
||||
/** Current camera direction */
|
||||
private currentDirection: CameraDirection = 'BACK';
|
||||
|
||||
/**
|
||||
* Gets the capabilities of the Capacitor platform
|
||||
* @returns Platform capabilities object
|
||||
@@ -31,7 +32,6 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
|
||||
hasFileDownload: false,
|
||||
needsFileHandlingInstructions: true,
|
||||
isNativeApp: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -405,7 +405,6 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
allowEditing: true,
|
||||
resultType: CameraResultType.Base64,
|
||||
source: CameraSource.Camera,
|
||||
direction: this.currentDirection,
|
||||
});
|
||||
|
||||
const blob = await this.processImageData(image.base64String);
|
||||
@@ -471,15 +470,6 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
return new Blob(byteArrays, { type: "image/jpeg" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates the camera between front and back cameras.
|
||||
* @returns Promise that resolves when the camera is rotated
|
||||
*/
|
||||
async rotateCamera(): Promise<void> {
|
||||
this.currentDirection = this.currentDirection === 'BACK' ? 'FRONT' : 'BACK';
|
||||
logger.debug(`Camera rotated to ${this.currentDirection} camera`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles deep link URLs for the application.
|
||||
* Note: Capacitor handles deep links automatically.
|
||||
@@ -490,4 +480,44 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
// This is just a placeholder for the interface
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Account Management
|
||||
async getAccounts(): Promise<Account[]> {
|
||||
return await db.accounts.toArray();
|
||||
}
|
||||
|
||||
async getAccount(did: string): Promise<Account | undefined> {
|
||||
return await db.accounts.where("did").equals(did).first();
|
||||
}
|
||||
|
||||
async addAccount(account: Account): Promise<void> {
|
||||
await db.accounts.add(account);
|
||||
}
|
||||
|
||||
// Settings Management
|
||||
async updateMasterSettings(
|
||||
settingsChanges: Partial<Settings>,
|
||||
): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async getActiveAccountSettings(): Promise<Settings> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async updateAccountSettings(
|
||||
accountDid: string,
|
||||
settingsChanges: Partial<Settings>,
|
||||
): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
// Contact Management
|
||||
async getContacts(): Promise<Contact[]> {
|
||||
return await db.contacts.toArray();
|
||||
}
|
||||
|
||||
async getAllContacts(): Promise<Contact[]> {
|
||||
return await this.getContacts();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +1,102 @@
|
||||
import {
|
||||
ImageResult,
|
||||
PlatformService,
|
||||
PlatformCapabilities,
|
||||
SQLiteOperations,
|
||||
SQLiteConfig,
|
||||
PreparedStatement,
|
||||
SQLiteResult,
|
||||
ImageResult,
|
||||
} from "../PlatformService";
|
||||
import { BaseSQLiteService } from "../sqlite/BaseSQLiteService";
|
||||
import { app } from "electron";
|
||||
import { dialog } from "electron";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import sqlite3 from "sqlite3";
|
||||
import { open, Database } from "sqlite";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { Settings } from "../../db/tables/settings";
|
||||
import { Account } from "../../db/tables/accounts";
|
||||
import { Contact } from "../../db/tables/contacts";
|
||||
import { db } from "../../db";
|
||||
import { MASTER_SETTINGS_KEY } from "../../db/tables/settings";
|
||||
import { accountsDBPromise } from "../../db";
|
||||
import { accessToken } from "../../libs/crypto";
|
||||
import { getPlanFromCache as getPlanFromCacheImpl } from "../../libs/endorserServer";
|
||||
import { PlanSummaryRecord } from "../../interfaces/records";
|
||||
import { Axios } from "axios";
|
||||
|
||||
// Create Promise-based versions of fs functions
|
||||
const readFileAsync = (filePath: string, encoding: BufferEncoding): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile(filePath, { encoding }, (err: NodeJS.ErrnoException | null, data: string) => {
|
||||
if (err) reject(err);
|
||||
else resolve(data);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const readFileBufferAsync = (filePath: string): Promise<Buffer> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile(filePath, (err: NodeJS.ErrnoException | null, data: Buffer) => {
|
||||
if (err) reject(err);
|
||||
else resolve(data);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const writeFileAsync = (filePath: string, data: string, encoding: BufferEncoding): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.writeFile(filePath, data, { encoding }, (err: NodeJS.ErrnoException | null) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const unlinkAsync = (filePath: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.unlink(filePath, (err: NodeJS.ErrnoException | null) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const readdirAsync = (dirPath: string): Promise<string[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readdir(dirPath, (err: NodeJS.ErrnoException | null, files: string[]) => {
|
||||
if (err) reject(err);
|
||||
else resolve(files);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const statAsync = (filePath: string): Promise<fs.Stats> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.stat(filePath, (err: NodeJS.ErrnoException | null, stats: fs.Stats) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stats);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
interface SQLiteDatabase extends Database {
|
||||
changes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform service implementation for Electron (desktop) platform.
|
||||
* Note: This is a placeholder implementation with most methods currently unimplemented.
|
||||
* Implements the PlatformService interface but throws "Not implemented" errors for most operations.
|
||||
*
|
||||
* @remarks
|
||||
* This service is intended for desktop application functionality through Electron.
|
||||
* Future implementations should provide:
|
||||
* - Native file system access
|
||||
* - Desktop camera integration
|
||||
* - System-level features
|
||||
* SQLite implementation for Electron using native sqlite3
|
||||
*/
|
||||
export class ElectronPlatformService implements PlatformService {
|
||||
/**
|
||||
* Gets the capabilities of the Electron platform
|
||||
* @returns Platform capabilities object
|
||||
*/
|
||||
getCapabilities(): PlatformCapabilities {
|
||||
return {
|
||||
hasFileSystem: false, // Not implemented yet
|
||||
hasCamera: false, // Not implemented yet
|
||||
isMobile: false,
|
||||
isIOS: false,
|
||||
hasFileDownload: false, // Not implemented yet
|
||||
needsFileHandlingInstructions: false,
|
||||
};
|
||||
}
|
||||
class ElectronSQLiteService extends BaseSQLiteService {
|
||||
private db: SQLiteDatabase | null = null;
|
||||
private config: SQLiteConfig | null = null;
|
||||
|
||||
/**
|
||||
* Reads a file from the filesystem.
|
||||
* @param _path - Path to the file to read
|
||||
* @returns Promise that should resolve to file contents
|
||||
* @throws Error with "Not implemented" message
|
||||
* @todo Implement file reading using Electron's file system API
|
||||
*/
|
||||
async readFile(_path: string): Promise<string> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes content to a file.
|
||||
* @param _path - Path where to write the file
|
||||
* @param _content - Content to write to the file
|
||||
* @throws Error with "Not implemented" message
|
||||
* @todo Implement file writing using Electron's file system API
|
||||
*/
|
||||
async writeFile(_path: string, _content: string): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a file from the filesystem.
|
||||
* @param _path - Path to the file to delete
|
||||
* @throws Error with "Not implemented" message
|
||||
* @todo Implement file deletion using Electron's file system API
|
||||
*/
|
||||
async deleteFile(_path: string): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists files in the specified directory.
|
||||
* @param _directory - Path to the directory to list
|
||||
* @returns Promise that should resolve to array of filenames
|
||||
* @throws Error with "Not implemented" message
|
||||
* @todo Implement directory listing using Electron's file system API
|
||||
*/
|
||||
async listFiles(_directory: string): Promise<string[]> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Should open system camera to take a picture.
|
||||
* @returns Promise that should resolve to captured image data
|
||||
* @throws Error with "Not implemented" message
|
||||
* @todo Implement camera access using Electron's media APIs
|
||||
*/
|
||||
async takePicture(): Promise<ImageResult> {
|
||||
logger.error("takePicture not implemented in Electron platform");
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Should open system file picker for selecting an image.
|
||||
* @returns Promise that should resolve to selected image data
|
||||
* @throws Error with "Not implemented" message
|
||||
* @todo Implement file picker using Electron's dialog API
|
||||
*/
|
||||
async pickImage(): Promise<ImageResult> {
|
||||
logger.error("pickImage not implemented in Electron platform");
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Should handle deep link URLs for the desktop application.
|
||||
* @param _url - The deep link URL to handle
|
||||
* @throws Error with "Not implemented" message
|
||||
* @todo Implement deep link handling using Electron's protocol handler
|
||||
*/
|
||||
async handleDeepLink(_url: string): Promise<void> {
|
||||
logger.error("handleDeepLink not implemented in Electron platform");
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
// ... rest of the ElectronSQLiteService implementation ...
|
||||
}
|
||||
|
||||
export class ElectronPlatformService implements PlatformService {
|
||||
private sqliteService: ElectronSQLiteService | null = null;
|
||||
|
||||
// ... rest of the ElectronPlatformService implementation ...
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
PlatformCapabilities,
|
||||
} from "../PlatformService";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { Account } from "../../db/tables/accounts";
|
||||
import { Settings } from "../../db/tables/settings";
|
||||
import { db } from "../../db";
|
||||
import { Contact } from "../../db/tables/contacts";
|
||||
|
||||
/**
|
||||
* Platform service implementation for PyWebView platform.
|
||||
@@ -109,4 +113,44 @@ export class PyWebViewPlatformService implements PlatformService {
|
||||
logger.error("handleDeepLink not implemented in PyWebView platform");
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
// Account Management
|
||||
async getAccounts(): Promise<Account[]> {
|
||||
return await db.accounts.toArray();
|
||||
}
|
||||
|
||||
async getAccount(did: string): Promise<Account | undefined> {
|
||||
return await db.accounts.where("did").equals(did).first();
|
||||
}
|
||||
|
||||
async addAccount(account: Account): Promise<void> {
|
||||
await db.accounts.add(account);
|
||||
}
|
||||
|
||||
// Settings Management
|
||||
async updateMasterSettings(
|
||||
settingsChanges: Partial<Settings>,
|
||||
): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async getActiveAccountSettings(): Promise<Settings> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async updateAccountSettings(
|
||||
accountDid: string,
|
||||
settingsChanges: Partial<Settings>,
|
||||
): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
// Contact Management
|
||||
async getContacts(): Promise<Contact[]> {
|
||||
return await db.contacts.toArray();
|
||||
}
|
||||
|
||||
async getAllContacts(): Promise<Contact[]> {
|
||||
return await this.getContacts();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,20 @@ import {
|
||||
ImageResult,
|
||||
PlatformService,
|
||||
PlatformCapabilities,
|
||||
SQLiteOperations,
|
||||
} from "../PlatformService";
|
||||
import { Settings } from "../../db/tables/settings";
|
||||
import { MASTER_SETTINGS_KEY } from "../../db/tables/settings";
|
||||
import { db } from "../../db";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { Account } from "../../db/tables/accounts";
|
||||
import { Contact } from "../../db/tables/contacts";
|
||||
import { WebSQLiteService } from "../sqlite/WebSQLiteService";
|
||||
import { accountsDBPromise } from "../../db";
|
||||
import { accessToken } from "../../libs/crypto";
|
||||
import { getPlanFromCache as getPlanFromCacheImpl } from "../../libs/endorserServer";
|
||||
import { PlanSummaryRecord } from "../../interfaces/records";
|
||||
import { Axios } from "axios";
|
||||
|
||||
/**
|
||||
* Platform service implementation for web browser platform.
|
||||
@@ -19,6 +31,8 @@ import { logger } from "../../utils/logger";
|
||||
* due to browser security restrictions. These methods throw appropriate errors.
|
||||
*/
|
||||
export class WebPlatformService implements PlatformService {
|
||||
private sqliteService: WebSQLiteService | null = null;
|
||||
|
||||
/**
|
||||
* Gets the capabilities of the web platform
|
||||
* @returns Platform capabilities object
|
||||
@@ -26,11 +40,17 @@ export class WebPlatformService implements PlatformService {
|
||||
getCapabilities(): PlatformCapabilities {
|
||||
return {
|
||||
hasFileSystem: false,
|
||||
hasCamera: true, // Through file input with capture
|
||||
isMobile: /iPhone|iPad|iPod|Android/i.test(navigator.userAgent),
|
||||
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
|
||||
hasCamera: true,
|
||||
isMobile: false,
|
||||
isIOS: false,
|
||||
hasFileDownload: true,
|
||||
needsFileHandlingInstructions: false,
|
||||
sqlite: {
|
||||
supported: true,
|
||||
runsInWorker: true,
|
||||
hasSharedArrayBuffer: typeof SharedArrayBuffer !== "undefined",
|
||||
supportsWAL: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -359,4 +379,139 @@ export class WebPlatformService implements PlatformService {
|
||||
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
|
||||
throw new Error("File system access not available in web platform");
|
||||
}
|
||||
|
||||
async updateMasterSettings(
|
||||
settingsChanges: Partial<Settings>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
delete settingsChanges.accountDid; // just in case
|
||||
delete settingsChanges.id; // ensure there is no "id" that would override the key
|
||||
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges);
|
||||
} catch (error) {
|
||||
logger.error("Error updating master settings:", error);
|
||||
throw new Error(
|
||||
`Failed to update settings. We recommend you try again or restart the app.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveAccountSettings(): Promise<Settings> {
|
||||
const defaultSettings = (await db.settings.get(MASTER_SETTINGS_KEY)) || {};
|
||||
if (!defaultSettings.activeDid) {
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
const overrideSettings =
|
||||
(await db.settings
|
||||
.where("accountDid")
|
||||
.equals(defaultSettings.activeDid)
|
||||
.first()) || {};
|
||||
|
||||
return { ...defaultSettings, ...overrideSettings };
|
||||
}
|
||||
|
||||
async updateAccountSettings(
|
||||
accountDid: string,
|
||||
settingsChanges: Partial<Settings>,
|
||||
): Promise<void> {
|
||||
settingsChanges.accountDid = accountDid;
|
||||
delete settingsChanges.id; // key off account, not ID
|
||||
|
||||
const result = await db.settings
|
||||
.where("accountDid")
|
||||
.equals(accountDid)
|
||||
.modify(settingsChanges);
|
||||
|
||||
if (result === 0) {
|
||||
// If no record was updated, create a new one
|
||||
settingsChanges.id = (await db.settings.count()) + 1;
|
||||
await db.settings.add(settingsChanges);
|
||||
}
|
||||
}
|
||||
|
||||
// Account Management
|
||||
async getAccounts(): Promise<Account[]> {
|
||||
const accountsDB = await accountsDBPromise;
|
||||
return await accountsDB.accounts.toArray();
|
||||
}
|
||||
|
||||
async getAccount(did: string): Promise<Account | undefined> {
|
||||
const accountsDB = await accountsDBPromise;
|
||||
return await accountsDB.accounts.where("did").equals(did).first();
|
||||
}
|
||||
|
||||
async addAccount(account: Account): Promise<void> {
|
||||
const accountsDB = await accountsDBPromise;
|
||||
await accountsDB.accounts.add(account);
|
||||
}
|
||||
|
||||
// Contact Management
|
||||
async getContacts(): Promise<Contact[]> {
|
||||
return await db.contacts.toArray();
|
||||
}
|
||||
|
||||
async getAllContacts(): Promise<Contact[]> {
|
||||
return await this.getContacts();
|
||||
}
|
||||
|
||||
async getHeaders(did?: string): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (did) {
|
||||
try {
|
||||
const account = await this.getAccount(did);
|
||||
if (account?.passkeyCredIdHex) {
|
||||
// Handle passkey authentication
|
||||
const token = await this.getPasskeyToken(did);
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
} else {
|
||||
// Handle regular authentication
|
||||
const token = await this.getAccessToken(did);
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to get headers:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async getPasskeyToken(did: string): Promise<string> {
|
||||
// For now, use the same token mechanism as regular auth
|
||||
// TODO: Implement proper passkey authentication
|
||||
return this.getAccessToken(did);
|
||||
}
|
||||
|
||||
private async getAccessToken(did: string): Promise<string> {
|
||||
try {
|
||||
const token = await accessToken(did);
|
||||
if (!token) {
|
||||
throw new Error("Failed to generate access token");
|
||||
}
|
||||
return token;
|
||||
} catch (error) {
|
||||
logger.error("Error getting access token:", error);
|
||||
throw new Error("Failed to get access token: " + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
async getSQLite(): Promise<SQLiteOperations> {
|
||||
if (!this.sqliteService) {
|
||||
this.sqliteService = new WebSQLiteService();
|
||||
}
|
||||
return this.sqliteService;
|
||||
}
|
||||
|
||||
async getPlanFromCache(
|
||||
handleId: string | undefined,
|
||||
axios: Axios,
|
||||
apiServer: string,
|
||||
requesterDid?: string,
|
||||
): Promise<PlanSummaryRecord | undefined> {
|
||||
return getPlanFromCacheImpl(handleId, axios, apiServer, requesterDid);
|
||||
}
|
||||
}
|
||||
|
||||
248
src/services/sqlite/AbsurdSQLService.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
383
src/services/sqlite/BaseSQLiteService.ts
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
176
src/services/sqlite/CapacitorSQLiteService.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
170
src/services/sqlite/WebSQLiteService.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { BaseSQLiteService } from "./BaseSQLiteService";
|
||||
import { SQLiteConfig, SQLiteOperations, SQLiteResult, PreparedStatement, SQLiteStats } from "../PlatformService";
|
||||
import { logger } from "../../utils/logger";
|
||||
import initSqlJs, { Database } from "@jlongster/sql.js";
|
||||
import { SQLiteFS } from "absurd-sql";
|
||||
import IndexedDBBackend from "absurd-sql/dist/indexeddb-backend";
|
||||
|
||||
/**
|
||||
* SQLite implementation for web platform using absurd-sql
|
||||
*/
|
||||
export class WebSQLiteService extends BaseSQLiteService {
|
||||
private db: Database | null = null;
|
||||
private config: SQLiteConfig | null = null;
|
||||
private worker: Worker | null = null;
|
||||
|
||||
async initialize(config: SQLiteConfig): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.config = config;
|
||||
|
||||
// Initialize SQL.js
|
||||
const SQL = await initSqlJs({
|
||||
locateFile: (file) => `/sql-wasm.wasm`,
|
||||
});
|
||||
|
||||
// Create a worker for SQLite operations
|
||||
this.worker = new Worker("/sql-worker.js");
|
||||
|
||||
// Initialize SQLiteFS with IndexedDB backend
|
||||
const backend = new IndexedDBBackend();
|
||||
const fs = new SQLiteFS(backend, this.worker);
|
||||
|
||||
// Create database file
|
||||
const dbPath = `/${config.name}.db`;
|
||||
if (!(await fs.exists(dbPath))) {
|
||||
await fs.writeFile(dbPath, new Uint8Array(0));
|
||||
}
|
||||
|
||||
// Open database
|
||||
this.db = new SQL.Database(dbPath, { filename: true });
|
||||
|
||||
// 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 Web SQLite:", 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 stmt = this.db.prepare(sql);
|
||||
const results = stmt.get(params) as T[];
|
||||
stmt.free();
|
||||
return { results };
|
||||
} else {
|
||||
const stmt = this.db.prepare(sql);
|
||||
stmt.run(params);
|
||||
const changes = this.db.getRowsModified();
|
||||
stmt.free();
|
||||
return { changes };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("SQLite query failed:", {
|
||||
sql,
|
||||
params,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = null;
|
||||
}
|
||||
if (this.worker) {
|
||||
this.worker.terminate();
|
||||
this.worker = null;
|
||||
}
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
async getDatabaseSize(): Promise<number> {
|
||||
if (!this.db) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
const result = await this.query<{ size: number }>("SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()");
|
||||
return result.results[0]?.size || 0;
|
||||
}
|
||||
|
||||
async prepare<T>(sql: string): Promise<PreparedStatement<T>> {
|
||||
if (!this.db) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(sql);
|
||||
const key = sql;
|
||||
|
||||
const preparedStmt: PreparedStatement<T> = {
|
||||
execute: async (params: unknown[] = []) => {
|
||||
try {
|
||||
const results = stmt.get(params) as T[];
|
||||
return { results };
|
||||
} catch (error) {
|
||||
logger.error("Prepared statement execution failed:", {
|
||||
sql,
|
||||
params,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
finalize: () => {
|
||||
stmt.free();
|
||||
this.preparedStatements.delete(key);
|
||||
this.stats.preparedStatements--;
|
||||
},
|
||||
};
|
||||
|
||||
this.preparedStatements.set(key, preparedStmt);
|
||||
this.stats.preparedStatements++;
|
||||
|
||||
return preparedStmt;
|
||||
}
|
||||
|
||||
async getStats(): Promise<SQLiteStats> {
|
||||
await this.updateStats();
|
||||
return this.stats;
|
||||
}
|
||||
|
||||
private async updateStats(): Promise<void> {
|
||||
if (!this.db) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
|
||||
const size = await this.getDatabaseSize();
|
||||
this.stats.databaseSize = size;
|
||||
|
||||
const walResult = await this.query<{ journal_mode: string }>("PRAGMA journal_mode");
|
||||
this.stats.walMode = walResult.results[0]?.journal_mode === "wal";
|
||||
|
||||
const mmapResult = await this.query<{ mmap_size: number }>("PRAGMA mmap_size");
|
||||
this.stats.mmapActive = mmapResult.results[0]?.mmap_size > 0;
|
||||
}
|
||||
}
|
||||
150
src/services/sqlite/sqlite.worker.ts
Normal file
@@ -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);
|
||||
};
|
||||
45
src/types/absurd-sql.d.ts
vendored
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
2
src/utils/empty-module.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// Empty module to satisfy Node.js built-in module imports
|
||||
export default {};
|
||||
17
src/utils/node-modules/crypto.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// Minimal crypto module implementation for browser using Web Crypto API
|
||||
const crypto = {
|
||||
...window.crypto,
|
||||
// Add any Node.js crypto methods that might be needed
|
||||
randomBytes: (size) => {
|
||||
const buffer = new Uint8Array(size);
|
||||
window.crypto.getRandomValues(buffer);
|
||||
return buffer;
|
||||
},
|
||||
createHash: () => ({
|
||||
update: () => ({
|
||||
digest: () => new Uint8Array(32), // Return empty hash
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
export default crypto;
|
||||
18
src/utils/node-modules/fs.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// Minimal fs module implementation for browser
|
||||
const fs = {
|
||||
readFileSync: () => {
|
||||
throw new Error("fs.readFileSync is not supported in browser");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("fs.writeFileSync is not supported in browser");
|
||||
},
|
||||
existsSync: () => false,
|
||||
mkdirSync: () => {},
|
||||
readdirSync: () => [],
|
||||
statSync: () => ({
|
||||
isDirectory: () => false,
|
||||
isFile: () => false,
|
||||
}),
|
||||
};
|
||||
|
||||
export default fs;
|
||||
13
src/utils/node-modules/path.js
Normal file
@@ -0,0 +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(),
|
||||
extname: (p) => {
|
||||
const parts = p.split(".");
|
||||
return parts.length > 1 ? "." + parts.pop() : "";
|
||||
},
|
||||
};
|
||||
|
||||
export default path;
|
||||
@@ -76,7 +76,8 @@
|
||||
Set Your Name
|
||||
</button>
|
||||
<p class="text-xs text-slate-500 mt-1">
|
||||
(Don't worry: this is not visible to anyone until you share it with them. It's not sent to any servers.)
|
||||
(Don't worry: this is not visible to anyone until you share it with
|
||||
them. It's not sent to any servers.)
|
||||
</p>
|
||||
<UserNameDialog ref="userNameDialog" />
|
||||
</span>
|
||||
@@ -110,12 +111,14 @@
|
||||
<font-awesome icon="camera" class="fa-fw" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- If not registered, they don't need to see this at all. We show a prompt to register below. -->
|
||||
<!--
|
||||
If not registered, they don't need to see this at all. We show a prompt
|
||||
to register below.
|
||||
-->
|
||||
</div>
|
||||
<ImageMethodDialog
|
||||
ref="imageMethodDialog"
|
||||
:is-registered="isRegistered"
|
||||
default-camera-mode="user"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
@@ -965,7 +968,7 @@ import { AxiosError } from "axios";
|
||||
import { Buffer } from "buffer/";
|
||||
import Dexie from "dexie";
|
||||
import "dexie-export-import";
|
||||
// @ts-ignore - they aren't exporting it but it's there
|
||||
// @ts-expect-error - they aren't exporting it but it's there
|
||||
import { ImportProgress } from "dexie-export-import";
|
||||
import { LeafletMouseEvent } from "leaflet";
|
||||
import * as R from "ramda";
|
||||
@@ -991,13 +994,6 @@ import {
|
||||
IMAGE_TYPE_PROFILE,
|
||||
NotificationIface,
|
||||
} from "../constants/app";
|
||||
import {
|
||||
db,
|
||||
logConsoleAndDb,
|
||||
retrieveSettingsForActiveAccount,
|
||||
updateAccountSettings,
|
||||
} from "../db/index";
|
||||
import { Account } from "../db/tables/accounts";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import {
|
||||
DEFAULT_PASSKEY_EXPIRATION_MINUTES,
|
||||
@@ -1212,11 +1208,12 @@ export default class AccountViewView extends Vue {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes component state with values from the database or defaults.
|
||||
* Initializes component state using PlatformService for database operations
|
||||
* Keeps all endorserServer functionality unchanged
|
||||
*/
|
||||
async initializeState() {
|
||||
await db.open();
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
const platform = this.$platform;
|
||||
const settings = await platform.getActiveAccountSettings();
|
||||
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.apiServer = settings.apiServer || "";
|
||||
@@ -1249,6 +1246,29 @@ export default class AccountViewView extends Vue {
|
||||
this.webPushServerInput = settings.webPushServer || this.webPushServerInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates account settings using PlatformService
|
||||
* Keeps all endorserServer functionality unchanged
|
||||
*/
|
||||
async updateSettings(settingsChanges: Record<string, unknown>) {
|
||||
try {
|
||||
const platform = this.$platform;
|
||||
await platform.updateAccountSettings(this.activeDid, settingsChanges);
|
||||
await this.initializeState();
|
||||
} catch (error) {
|
||||
logger.error("Error updating settings:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem updating your settings.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// call fn, copy text to the clipboard, then redo fn after 2 seconds
|
||||
doCopyTwoSecRedo(text: string, fn: () => void) {
|
||||
fn();
|
||||
@@ -1611,12 +1631,13 @@ export default class AccountViewView extends Vue {
|
||||
*/
|
||||
async submitImportFile() {
|
||||
if (inputImportFileNameRef.value != null) {
|
||||
await db.delete()
|
||||
await db
|
||||
.delete()
|
||||
.then(async () => {
|
||||
// BulkError: settings.bulkAdd(): 1 of 21 operations failed. Errors: ConstraintError: Key already exists in the object store.
|
||||
await Dexie.import(inputImportFileNameRef.value as Blob, {
|
||||
progressCallback: this.progressCallback,
|
||||
})
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error("Error importing file:", error);
|
||||
|
||||
@@ -439,13 +439,11 @@ import { useClipboard } from "@vueuse/core";
|
||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as serverUtil from "../libs/endorserServer";
|
||||
import { GenericVerifiableCredential, GiveSummaryRecord } from "../interfaces";
|
||||
import { displayAmount } from "../libs/endorserServer";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { retrieveAccountDids } from "../libs/util";
|
||||
import TopMessage from "../components/TopMessage.vue";
|
||||
import { logger } from "../utils/logger";
|
||||
/**
|
||||
@@ -526,14 +524,17 @@ export default class ConfirmGiftView extends Vue {
|
||||
|
||||
/**
|
||||
* Initializes component settings and user data
|
||||
* Only database operations are migrated to PlatformService
|
||||
* API-related utilities remain using serverUtil
|
||||
*/
|
||||
private async initializeSettings() {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
const platform = this.$platform;
|
||||
const settings = await platform.getActiveAccountSettings();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
this.allContacts = await platform.getAllContacts();
|
||||
this.isRegistered = settings.isRegistered || false;
|
||||
this.allMyDids = await retrieveAccountDids();
|
||||
this.allMyDids = await platform.getAllAccountDids();
|
||||
|
||||
// Check share capability
|
||||
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
|
||||
|
||||
@@ -357,8 +357,7 @@ export default class ContactQRScan extends Vue {
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
if (!contactInfo.did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
@@ -371,7 +370,7 @@ export default class ContactQRScan extends Vue {
|
||||
|
||||
// Create contact object
|
||||
const contact = {
|
||||
did: did,
|
||||
did: contactInfo.did,
|
||||
name: contactInfo.name || "",
|
||||
email: contactInfo.email || "",
|
||||
phone: contactInfo.phone || "",
|
||||
|
||||
@@ -152,6 +152,30 @@
|
||||
@camera-on="onCameraOn"
|
||||
@camera-off="onCameraOff"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute bottom-4 inset-x-0 flex justify-center items-center"
|
||||
>
|
||||
<!-- Camera Stop Button -->
|
||||
<button
|
||||
class="text-center text-slate-600 leading-none bg-white p-2 rounded-full drop-shadow-lg"
|
||||
title="Stop camera"
|
||||
@click="stopScanning"
|
||||
>
|
||||
<font-awesome icon="xmark" class="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center aspect-square overflow-hidden bg-slate-800 w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
|
||||
>
|
||||
<button
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white text-lg px-3 py-2 rounded-lg"
|
||||
@click="startScanning"
|
||||
>
|
||||
Scan QR Code
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -234,7 +258,6 @@ export default class ContactQRScanShow extends Vue {
|
||||
|
||||
// Add property to track if we're on desktop
|
||||
private isDesktop = false;
|
||||
private isFrontCamera = false;
|
||||
|
||||
async created() {
|
||||
try {
|
||||
@@ -483,8 +506,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
if (!contactInfo.did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
@@ -497,7 +519,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
|
||||
// Create contact object
|
||||
const contact = {
|
||||
did: did,
|
||||
did: contactInfo.did,
|
||||
name: contactInfo.name || "",
|
||||
email: contactInfo.email || "",
|
||||
phone: contactInfo.phone || "",
|
||||
@@ -701,6 +723,16 @@ export default class ContactQRScanShow extends Vue {
|
||||
document.addEventListener("resume", this.handleAppResume);
|
||||
// Start scanning automatically when view is loaded
|
||||
this.startScanning();
|
||||
|
||||
// Apply mirroring after a short delay to ensure video element is ready
|
||||
setTimeout(() => {
|
||||
const videoElement = document.querySelector(
|
||||
".qr-scanner video",
|
||||
) as HTMLVideoElement;
|
||||
if (videoElement) {
|
||||
videoElement.style.transform = "scaleX(-1)";
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
beforeDestroy() {
|
||||
@@ -847,8 +879,6 @@ export default class ContactQRScanShow extends Vue {
|
||||
onCameraOn(): void {
|
||||
this.cameraState = "active";
|
||||
this.isInitializing = false;
|
||||
this.isFrontCamera = this.preferredCamera === "user";
|
||||
this.applyCameraMirroring();
|
||||
}
|
||||
|
||||
onCameraOff(): void {
|
||||
@@ -894,8 +924,6 @@ export default class ContactQRScanShow extends Vue {
|
||||
toggleCamera(): void {
|
||||
this.preferredCamera =
|
||||
this.preferredCamera === "user" ? "environment" : "user";
|
||||
this.isFrontCamera = this.preferredCamera === "user";
|
||||
this.applyCameraMirroring();
|
||||
}
|
||||
|
||||
private handleError(error: unknown): void {
|
||||
@@ -922,16 +950,14 @@ export default class ContactQRScanShow extends Vue {
|
||||
);
|
||||
}
|
||||
|
||||
// Add method to apply camera mirroring
|
||||
private applyCameraMirroring(): void {
|
||||
const videoElement = document.querySelector(
|
||||
".qr-scanner video",
|
||||
) as HTMLVideoElement;
|
||||
if (videoElement) {
|
||||
// Mirror if it's desktop or front camera on mobile
|
||||
const shouldMirror = this.isDesktop || (this.isFrontCamera && !this.isDesktop);
|
||||
videoElement.style.transform = shouldMirror ? "scaleX(-1)" : "none";
|
||||
// Update the computed property for camera mirroring
|
||||
get shouldMirrorCamera(): boolean {
|
||||
// On desktop, always mirror the webcam
|
||||
if (this.isDesktop) {
|
||||
return true;
|
||||
}
|
||||
// On mobile, mirror only for front-facing camera
|
||||
return this.preferredCamera === "user";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -946,9 +972,8 @@ export default class ContactQRScanShow extends Vue {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Remove the default mirroring from CSS since we're handling it in JavaScript */
|
||||
:deep(.qr-scanner video) {
|
||||
transform: none;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
/* Ensure the canvas for QR detection is not mirrored */
|
||||
|
||||
@@ -54,12 +54,17 @@
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
|
||||
class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
|
||||
>
|
||||
<font-awesome
|
||||
icon="chair"
|
||||
class="fa-fw text-2xl"
|
||||
@click="this.$router.push({ name: 'onboard-meeting-list' })"
|
||||
@click="
|
||||
warning(
|
||||
'You must get registered before you can initiate an onboarding meeting.',
|
||||
'Not Registered',
|
||||
)
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
@@ -1058,7 +1063,8 @@ export default class ContactsView extends Vue {
|
||||
);
|
||||
if (regResult.success) {
|
||||
contact.registered = true;
|
||||
await db.contacts.update(contact.did, { registered: true });
|
||||
const platform = this.$platform;
|
||||
await platform.updateContact(contact.did, { registered: true });
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
|
||||
@@ -11,9 +11,30 @@
|
||||
|
||||
<OnboardingDialog ref="onboardingDialog" />
|
||||
|
||||
<!-- Quick Search -->
|
||||
<div
|
||||
id="QuickSearch"
|
||||
class="mt-8 mb-4 flex"
|
||||
:style="{ visibility: isSearchVisible ? 'visible' : 'hidden' }"
|
||||
>
|
||||
<input
|
||||
v-model="searchTerms"
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
|
||||
@keyup.enter="searchSelected()"
|
||||
/>
|
||||
<button
|
||||
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
|
||||
@click="searchSelected()"
|
||||
>
|
||||
<font-awesome icon="magnifying-glass" class="fa-fw"></font-awesome>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Result Tabs -->
|
||||
<!-- Top Level Selection -->
|
||||
<div class="text-center text-slate-500 border-b border-slate-300 mt-4 mb-2">
|
||||
<div class="text-center text-slate-500 border-b border-slate-300 mb-4">
|
||||
<ul class="flex flex-wrap justify-center gap-4 -mb-px">
|
||||
<li>
|
||||
<a
|
||||
@@ -125,27 +146,6 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Quick Search -->
|
||||
<div
|
||||
id="QuickSearch"
|
||||
class="mt-6 mb-4 flex"
|
||||
:style="{ display: isSearchVisible ? 'flex' : 'none' }"
|
||||
>
|
||||
<input
|
||||
v-model="searchTerms"
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
|
||||
@keyup.enter="searchSelected()"
|
||||
/>
|
||||
<button
|
||||
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
|
||||
@click="searchSelected()"
|
||||
>
|
||||
<font-awesome icon="magnifying-glass" class="fa-fw"></font-awesome>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isLocalActive">
|
||||
<div class="text-center">
|
||||
<button
|
||||
@@ -159,7 +159,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="isMappedActive && !tempSearchBox">
|
||||
<div class="mt-6 h-96 w-full mx-auto">
|
||||
<div class="mt-4 h-96 w-5/6 mx-auto">
|
||||
<l-map
|
||||
ref="projectMap"
|
||||
@ready="onMapReady"
|
||||
@@ -167,7 +167,6 @@
|
||||
@movestart="onMoveStart"
|
||||
@zoomend="onZoomEnd"
|
||||
@zoomstart="onZoomStart"
|
||||
class="z-40"
|
||||
>
|
||||
<l-tile-layer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
@@ -198,18 +197,14 @@
|
||||
-->
|
||||
</span>
|
||||
<span v-else-if="isAnywhereActive"
|
||||
>No {{ isProjectsActive ? 'projects' : 'people' }} were found with that search.</span
|
||||
>No projects were found with that search.</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<InfiniteScroll @reached-bottom="loadMoreData">
|
||||
<ul
|
||||
id="listDiscoverResults"
|
||||
class="border-t border-slate-300 mt-6"
|
||||
v-if="projects.length > 0 || userProfiles.length > 0"
|
||||
>
|
||||
<ul id="listDiscoverResults">
|
||||
<!-- Projects List -->
|
||||
<template v-if="isProjectsActive">
|
||||
<li
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
||||
<ImageMethodDialog ref="imageDialog" />
|
||||
|
||||
<div class="mt-4 flex justify-between gap-2">
|
||||
<!-- First Column for Giver -->
|
||||
|
||||