forked from jsnbuchanan/crowd-funder-for-time-pwa
feat(db): Implement SQLite database layer with migration support
Add SQLite database implementation with comprehensive features: - Core database functionality: - Connection management and pooling - Schema creation and validation - Transaction support with rollback - Backup and restore capabilities - Health checks and integrity verification - Data migration: - Migration utilities from Dexie to SQLite - Data transformation and validation - Migration verification and rollback - Backup before migration - CRUD operations for all entities: - Accounts, contacts, and contact methods - Settings and secrets - Logging and audit trails - Type safety and error handling: - Full TypeScript type definitions - Runtime data validation - Comprehensive error handling - Transaction safety Note: Requires @wa-sqlite/sql.js package to be installed
This commit is contained in:
449
src/db/sqlite/operations.ts
Normal file
449
src/db/sqlite/operations.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* SQLite Database Operations
|
||||
*
|
||||
* This module provides utility functions for common database operations,
|
||||
* including CRUD operations, queries, and transactions.
|
||||
*/
|
||||
|
||||
import { Database } from '@wa-sqlite/sql.js';
|
||||
import { initDatabase } from './init';
|
||||
import {
|
||||
SQLiteAccount,
|
||||
SQLiteContact,
|
||||
SQLiteContactMethod,
|
||||
SQLiteSettings,
|
||||
SQLiteLog,
|
||||
SQLiteSecret,
|
||||
isSQLiteAccount,
|
||||
isSQLiteContact,
|
||||
isSQLiteSettings
|
||||
} from './types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// ============================================================================
|
||||
// Transaction Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Execute a function within a transaction
|
||||
*/
|
||||
export async function withTransaction<T>(
|
||||
operation: (db: Database) => Promise<T>
|
||||
): Promise<T> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
try {
|
||||
return await db.transaction(operation);
|
||||
} catch (error) {
|
||||
logger.error('[SQLite] Transaction failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with retries
|
||||
*/
|
||||
export async function withRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries = 3,
|
||||
delay = 1000
|
||||
): Promise<T> {
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay * (i + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Account Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get account by DID
|
||||
*/
|
||||
export async function getAccountByDid(did: string): Promise<SQLiteAccount | null> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
const accounts = await db.selectAll<SQLiteAccount>(
|
||||
'SELECT * FROM accounts WHERE did = ?',
|
||||
[did]
|
||||
);
|
||||
|
||||
return accounts[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all accounts
|
||||
*/
|
||||
export async function getAllAccounts(): Promise<SQLiteAccount[]> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
return db.selectAll<SQLiteAccount>(
|
||||
'SELECT * FROM accounts ORDER BY created_at DESC'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update account
|
||||
*/
|
||||
export async function upsertAccount(account: SQLiteAccount): Promise<void> {
|
||||
if (!isSQLiteAccount(account)) {
|
||||
throw new Error('Invalid account data');
|
||||
}
|
||||
|
||||
await withTransaction(async (db) => {
|
||||
const existing = await db.selectOne<{ did: string }>(
|
||||
'SELECT did FROM accounts WHERE did = ?',
|
||||
[account.did]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await db.exec(`
|
||||
UPDATE accounts SET
|
||||
public_key_hex = ?,
|
||||
updated_at = ?,
|
||||
identity_json = ?,
|
||||
mnemonic_encrypted = ?,
|
||||
passkey_cred_id_hex = ?,
|
||||
derivation_path = ?
|
||||
WHERE did = ?
|
||||
`, [
|
||||
account.public_key_hex,
|
||||
Date.now(),
|
||||
account.identity_json || null,
|
||||
account.mnemonic_encrypted || null,
|
||||
account.passkey_cred_id_hex || null,
|
||||
account.derivation_path || null,
|
||||
account.did
|
||||
]);
|
||||
} else {
|
||||
await db.exec(`
|
||||
INSERT INTO accounts (
|
||||
did, public_key_hex, created_at, updated_at,
|
||||
identity_json, mnemonic_encrypted, passkey_cred_id_hex, derivation_path
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
account.did,
|
||||
account.public_key_hex,
|
||||
account.created_at,
|
||||
account.updated_at,
|
||||
account.identity_json || null,
|
||||
account.mnemonic_encrypted || null,
|
||||
account.passkey_cred_id_hex || null,
|
||||
account.derivation_path || null
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Contact Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get contact by ID
|
||||
*/
|
||||
export async function getContactById(id: string): Promise<SQLiteContact | null> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
const contacts = await db.selectAll<SQLiteContact>(
|
||||
'SELECT * FROM contacts WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
return contacts[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contacts by account DID
|
||||
*/
|
||||
export async function getContactsByAccountDid(did: string): Promise<SQLiteContact[]> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
return db.selectAll<SQLiteContact>(
|
||||
'SELECT * FROM contacts WHERE did = ? ORDER BY created_at DESC',
|
||||
[did]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contact methods for a contact
|
||||
*/
|
||||
export async function getContactMethods(contactId: string): Promise<SQLiteContactMethod[]> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
return db.selectAll<SQLiteContactMethod>(
|
||||
'SELECT * FROM contact_methods WHERE contact_id = ? ORDER BY created_at DESC',
|
||||
[contactId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update contact with methods
|
||||
*/
|
||||
export async function upsertContact(
|
||||
contact: SQLiteContact,
|
||||
methods: SQLiteContactMethod[] = []
|
||||
): Promise<void> {
|
||||
if (!isSQLiteContact(contact)) {
|
||||
throw new Error('Invalid contact data');
|
||||
}
|
||||
|
||||
await withTransaction(async (db) => {
|
||||
const existing = await db.selectOne<{ id: string }>(
|
||||
'SELECT id FROM contacts WHERE id = ?',
|
||||
[contact.id]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await db.exec(`
|
||||
UPDATE contacts SET
|
||||
did = ?,
|
||||
name = ?,
|
||||
notes = ?,
|
||||
profile_image_url = ?,
|
||||
public_key_base64 = ?,
|
||||
next_pub_key_hash_b64 = ?,
|
||||
sees_me = ?,
|
||||
registered = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`, [
|
||||
contact.did,
|
||||
contact.name || null,
|
||||
contact.notes || null,
|
||||
contact.profile_image_url || null,
|
||||
contact.public_key_base64 || null,
|
||||
contact.next_pub_key_hash_b64 || null,
|
||||
contact.sees_me ? 1 : 0,
|
||||
contact.registered ? 1 : 0,
|
||||
Date.now(),
|
||||
contact.id
|
||||
]);
|
||||
} else {
|
||||
await db.exec(`
|
||||
INSERT INTO contacts (
|
||||
id, did, name, notes, profile_image_url,
|
||||
public_key_base64, next_pub_key_hash_b64,
|
||||
sees_me, registered, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
contact.id,
|
||||
contact.did,
|
||||
contact.name || null,
|
||||
contact.notes || null,
|
||||
contact.profile_image_url || null,
|
||||
contact.public_key_base64 || null,
|
||||
contact.next_pub_key_hash_b64 || null,
|
||||
contact.sees_me ? 1 : 0,
|
||||
contact.registered ? 1 : 0,
|
||||
contact.created_at,
|
||||
contact.updated_at
|
||||
]);
|
||||
}
|
||||
|
||||
// Update contact methods
|
||||
if (methods.length > 0) {
|
||||
// Delete existing methods
|
||||
await db.exec(
|
||||
'DELETE FROM contact_methods WHERE contact_id = ?',
|
||||
[contact.id]
|
||||
);
|
||||
|
||||
// Insert new methods
|
||||
for (const method of methods) {
|
||||
await db.exec(`
|
||||
INSERT INTO contact_methods (
|
||||
id, contact_id, label, type, value,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
method.id,
|
||||
contact.id,
|
||||
method.label,
|
||||
method.type,
|
||||
method.value,
|
||||
method.created_at,
|
||||
method.updated_at
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Settings Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get setting by key
|
||||
*/
|
||||
export async function getSetting(key: string): Promise<SQLiteSettings | null> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
const settings = await db.selectAll<SQLiteSettings>(
|
||||
'SELECT * FROM settings WHERE key = ?',
|
||||
[key]
|
||||
);
|
||||
|
||||
return settings[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get settings by account DID
|
||||
*/
|
||||
export async function getSettingsByAccountDid(did: string): Promise<SQLiteSettings[]> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
return db.selectAll<SQLiteSettings>(
|
||||
'SELECT * FROM settings WHERE account_did = ? ORDER BY updated_at DESC',
|
||||
[did]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set setting value
|
||||
*/
|
||||
export async function setSetting(setting: SQLiteSettings): Promise<void> {
|
||||
if (!isSQLiteSettings(setting)) {
|
||||
throw new Error('Invalid settings data');
|
||||
}
|
||||
|
||||
await withTransaction(async (db) => {
|
||||
const existing = await db.selectOne<{ key: string }>(
|
||||
'SELECT key FROM settings WHERE key = ?',
|
||||
[setting.key]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await db.exec(`
|
||||
UPDATE settings SET
|
||||
account_did = ?,
|
||||
value_json = ?,
|
||||
updated_at = ?
|
||||
WHERE key = ?
|
||||
`, [
|
||||
setting.account_did || null,
|
||||
setting.value_json,
|
||||
Date.now(),
|
||||
setting.key
|
||||
]);
|
||||
} else {
|
||||
await db.exec(`
|
||||
INSERT INTO settings (
|
||||
key, account_did, value_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
`, [
|
||||
setting.key,
|
||||
setting.account_did || null,
|
||||
setting.value_json,
|
||||
setting.created_at,
|
||||
setting.updated_at
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Log Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Add log entry
|
||||
*/
|
||||
export async function addLog(log: SQLiteLog): Promise<void> {
|
||||
await withTransaction(async (db) => {
|
||||
await db.exec(`
|
||||
INSERT INTO logs (
|
||||
id, level, message, metadata_json, created_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
`, [
|
||||
log.id,
|
||||
log.level,
|
||||
log.message,
|
||||
log.metadata_json || null,
|
||||
log.created_at
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logs by level
|
||||
*/
|
||||
export async function getLogsByLevel(
|
||||
level: string,
|
||||
limit = 100,
|
||||
offset = 0
|
||||
): Promise<SQLiteLog[]> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
return db.selectAll<SQLiteLog>(
|
||||
'SELECT * FROM logs WHERE level = ? ORDER BY created_at DESC LIMIT ? OFFSET ?',
|
||||
[level, limit, offset]
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Secret Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get secret by key
|
||||
*/
|
||||
export async function getSecret(key: string): Promise<SQLiteSecret | null> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
const secrets = await db.selectAll<SQLiteSecret>(
|
||||
'SELECT * FROM secrets WHERE key = ?',
|
||||
[key]
|
||||
);
|
||||
|
||||
return secrets[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set secret value
|
||||
*/
|
||||
export async function setSecret(secret: SQLiteSecret): Promise<void> {
|
||||
await withTransaction(async (db) => {
|
||||
const existing = await db.selectOne<{ key: string }>(
|
||||
'SELECT key FROM secrets WHERE key = ?',
|
||||
[secret.key]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await db.exec(`
|
||||
UPDATE secrets SET
|
||||
value_encrypted = ?,
|
||||
updated_at = ?
|
||||
WHERE key = ?
|
||||
`, [
|
||||
secret.value_encrypted,
|
||||
Date.now(),
|
||||
secret.key
|
||||
]);
|
||||
} else {
|
||||
await db.exec(`
|
||||
INSERT INTO secrets (
|
||||
key, value_encrypted, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?)
|
||||
`, [
|
||||
secret.key,
|
||||
secret.value_encrypted,
|
||||
secret.created_at,
|
||||
secret.updated_at
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user