552 lines
14 KiB
JavaScript
552 lines
14 KiB
JavaScript
/**
|
|
* Database Module
|
|
*
|
|
* Uses SQLocal directly with BroadcastChannel for cross-tab coordination.
|
|
*
|
|
* Why this approach instead of SharedWorker?
|
|
* - SQLocal already uses its own internal worker for OPFS access
|
|
* - Wrapping it in another SharedWorker adds complexity and causes issues
|
|
* - BroadcastChannel provides simple cross-tab communication
|
|
* - Each tab has its own SQLocal instance but they share the same OPFS database file
|
|
*
|
|
* Usage:
|
|
* import { sql, dbReady, addLocation, getLocations } from './database.js';
|
|
*
|
|
* await dbReady;
|
|
* await addLocation('Point A', -1.5, 7.5);
|
|
* const locations = await getLocations();
|
|
*/
|
|
|
|
import { SQLocal } from 'sqlocal';
|
|
|
|
// Database configuration
|
|
const DATABASE_PATH = 'lupmis.sqlite3';
|
|
const BROADCAST_CHANNEL = 'lupmis-db-sync';
|
|
|
|
// Create SQLocal instance
|
|
const db = new SQLocal(DATABASE_PATH);
|
|
|
|
// Get the sql tagged template function
|
|
const { sql } = db;
|
|
|
|
console.log('[Database] SQLocal instance created for:', DATABASE_PATH);
|
|
|
|
// Export sql for direct queries
|
|
export { sql };
|
|
|
|
// Create broadcast channel for cross-tab coordination
|
|
const channel = new BroadcastChannel(BROADCAST_CHANNEL);
|
|
|
|
// Track if database is ready
|
|
let isReady = false;
|
|
let readyResolve;
|
|
let readyReject;
|
|
|
|
export const dbReady = new Promise((resolve, reject) => {
|
|
readyResolve = resolve;
|
|
readyReject = reject;
|
|
});
|
|
|
|
// Database change listeners
|
|
const changeListeners = new Set();
|
|
|
|
/**
|
|
* Subscribe to database changes (from any tab)
|
|
* @param {Function} listener - Called with { table, action, id }
|
|
* @returns {Function} Unsubscribe function
|
|
*/
|
|
export function onDatabaseChange(listener) {
|
|
changeListeners.add(listener);
|
|
return () => changeListeners.delete(listener);
|
|
}
|
|
|
|
// Handle messages from other tabs
|
|
channel.onmessage = (event) => {
|
|
const { type, payload } = event.data;
|
|
if (type === 'DB_CHANGE') {
|
|
// Notify local listeners about changes from other tabs
|
|
for (const listener of changeListeners) {
|
|
try {
|
|
listener(payload);
|
|
} catch (e) {
|
|
console.error('[Database] Change listener error:', e);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Broadcast a database change to other tabs
|
|
*/
|
|
function broadcastChange(table, action, id = null) {
|
|
channel.postMessage({
|
|
type: 'DB_CHANGE',
|
|
payload: { table, action, id, timestamp: Date.now() }
|
|
});
|
|
|
|
// Also notify local listeners
|
|
for (const listener of changeListeners) {
|
|
try {
|
|
listener({ table, action, id, timestamp: Date.now(), local: true });
|
|
} catch (e) {
|
|
console.error('[Database] Change listener error:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Database Initialization
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Initialize the database schema
|
|
*/
|
|
export async function initSchema() {
|
|
try {
|
|
console.log('[Database] Initializing schema...');
|
|
|
|
// Test connection
|
|
const testResult = await sql`SELECT sqlite_version() as version`;
|
|
console.log('[Database] SQLite version:', testResult[0]?.version);
|
|
|
|
// Create locations table
|
|
console.log('[Database] Creating locations table...');
|
|
await sql`
|
|
CREATE TABLE IF NOT EXISTS locations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
longitude REAL NOT NULL,
|
|
latitude REAL NOT NULL,
|
|
description TEXT,
|
|
category TEXT DEFAULT 'default',
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
synced INTEGER DEFAULT 0
|
|
)
|
|
`;
|
|
|
|
// Verify table exists
|
|
const tablesAfterLocations = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name='locations'`;
|
|
console.log('[Database] Locations table exists:', tablesAfterLocations.length > 0);
|
|
|
|
// Create sync_log table
|
|
console.log('[Database] Creating sync_log table...');
|
|
await sql`
|
|
CREATE TABLE IF NOT EXISTS sync_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
table_name TEXT NOT NULL,
|
|
record_id INTEGER NOT NULL,
|
|
action TEXT NOT NULL,
|
|
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
synced INTEGER DEFAULT 0
|
|
)
|
|
`;
|
|
|
|
// Create indexes
|
|
await sql`CREATE INDEX IF NOT EXISTS idx_locations_category ON locations(category)`;
|
|
await sql`CREATE INDEX IF NOT EXISTS idx_locations_synced ON locations(synced)`;
|
|
|
|
// Final verification
|
|
const allTables = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`;
|
|
console.log('[Database] All tables:', allTables.map(t => t.name));
|
|
|
|
isReady = true;
|
|
readyResolve(true);
|
|
console.log('[Database] ✓ Schema initialized');
|
|
|
|
} catch (error) {
|
|
console.error('[Database] ✗ Schema init failed:', error);
|
|
readyReject(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Location Operations
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Add a new location
|
|
*/
|
|
export async function addLocation(name, longitude, latitude, options = {}) {
|
|
const { description = null, category = 'default' } = options;
|
|
|
|
console.log('[Database] Adding location:', name, longitude, latitude, category);
|
|
|
|
try {
|
|
// Check table exists first
|
|
const tableCheck = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name='locations'`;
|
|
console.log('[Database] Table check before insert:', tableCheck);
|
|
|
|
if (tableCheck.length === 0) {
|
|
console.error('[Database] ✗ locations table does not exist!');
|
|
throw new Error('locations table does not exist');
|
|
}
|
|
|
|
// Insert - using explicit values
|
|
console.log('[Database] Executing INSERT...');
|
|
await sql`
|
|
INSERT INTO locations (name, longitude, latitude, description, category)
|
|
VALUES (${name}, ${longitude}, ${latitude}, ${description}, ${category})
|
|
`;
|
|
console.log('[Database] INSERT completed');
|
|
|
|
// Get the ID
|
|
const idResult = await sql`SELECT last_insert_rowid() as id`;
|
|
const newId = idResult[0]?.id;
|
|
console.log('[Database] New ID:', newId);
|
|
|
|
// Verify it was actually inserted
|
|
const verifyResult = await sql`SELECT * FROM locations WHERE id = ${newId}`;
|
|
console.log('[Database] Verify insert:', verifyResult);
|
|
|
|
if (verifyResult.length === 0) {
|
|
console.error('[Database] ✗ Insert verification failed - row not found!');
|
|
throw new Error('Insert verification failed');
|
|
}
|
|
|
|
// Log for sync
|
|
await sql`
|
|
INSERT INTO sync_log (table_name, record_id, action)
|
|
VALUES ('locations', ${newId}, 'INSERT')
|
|
`;
|
|
|
|
// Broadcast to other tabs
|
|
broadcastChange('locations', 'INSERT', newId);
|
|
|
|
console.log('[Database] ✓ Location added:', newId);
|
|
return { id: newId };
|
|
|
|
} catch (error) {
|
|
console.error('[Database] ✗ Failed to add location:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all locations
|
|
*/
|
|
export async function getLocations(options = {}) {
|
|
const { category = null, limit = 1000 } = options;
|
|
|
|
try {
|
|
// First check if table exists
|
|
const tableCheck = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name='locations'`;
|
|
console.log('[Database] getLocations - table exists:', tableCheck.length > 0);
|
|
|
|
if (tableCheck.length === 0) {
|
|
console.warn('[Database] locations table does not exist yet');
|
|
return [];
|
|
}
|
|
|
|
let results;
|
|
if (category) {
|
|
results = await sql`
|
|
SELECT * FROM locations
|
|
WHERE category = ${category}
|
|
ORDER BY created_at DESC
|
|
LIMIT ${limit}
|
|
`;
|
|
} else {
|
|
results = await sql`
|
|
SELECT * FROM locations
|
|
ORDER BY created_at DESC
|
|
LIMIT ${limit}
|
|
`;
|
|
}
|
|
|
|
console.log('[Database] getLocations returned', results.length, 'rows');
|
|
return results;
|
|
|
|
} catch (error) {
|
|
console.error('[Database] getLocations error:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getLocation(id) {
|
|
try {
|
|
const results = await sql`SELECT * FROM locations WHERE id = ${id}`;
|
|
return results[0] || null;
|
|
} catch (error) {
|
|
console.error('[Database] getLocation error:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update a location
|
|
*/
|
|
export async function updateLocation(id, updates) {
|
|
const { name, longitude, latitude, description, category } = updates;
|
|
|
|
try {
|
|
const location = await getLocation(id);
|
|
if (!location) {
|
|
throw new Error(`Location ${id} not found`);
|
|
}
|
|
|
|
await sql`
|
|
UPDATE locations
|
|
SET
|
|
name = ${name ?? location.name},
|
|
longitude = ${longitude ?? location.longitude},
|
|
latitude = ${latitude ?? location.latitude},
|
|
description = ${description ?? location.description},
|
|
category = ${category ?? location.category},
|
|
updated_at = CURRENT_TIMESTAMP,
|
|
synced = 0
|
|
WHERE id = ${id}
|
|
`;
|
|
|
|
// Log for sync
|
|
await sql`
|
|
INSERT INTO sync_log (table_name, record_id, action)
|
|
VALUES ('locations', ${id}, 'UPDATE')
|
|
`;
|
|
|
|
// Broadcast to other tabs
|
|
broadcastChange('locations', 'UPDATE', id);
|
|
console.log('[Database] ✓ Location updated:', id);
|
|
|
|
} catch (error) {
|
|
console.error('[Database] ✗ updateLocation error:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a location
|
|
*/
|
|
export async function deleteLocation(id) {
|
|
try {
|
|
await sql`
|
|
INSERT INTO sync_log (table_name, record_id, action)
|
|
VALUES ('locations', ${id}, 'DELETE')
|
|
`;
|
|
|
|
await sql`DELETE FROM locations WHERE id = ${id}`;
|
|
|
|
// Broadcast to other tabs
|
|
broadcastChange('locations', 'DELETE', id);
|
|
console.log('[Database] ✓ Location deleted:', id);
|
|
|
|
} catch (error) {
|
|
console.error('[Database] ✗ deleteLocation error:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get location count
|
|
*/
|
|
export async function getLocationCount() {
|
|
try {
|
|
const result = await sql`SELECT COUNT(*) as count FROM locations`;
|
|
return result[0]?.count ?? 0;
|
|
} catch (error) {
|
|
console.error('[Database] getLocationCount error:', error);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Sync Operations
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get unsynced changes
|
|
*/
|
|
export async function getUnsyncedChanges() {
|
|
return sql`SELECT * FROM sync_log WHERE synced = 0 ORDER BY timestamp ASC`;
|
|
}
|
|
|
|
/**
|
|
* Mark changes as synced
|
|
*/
|
|
export async function markSynced(syncLogIds) {
|
|
if (!syncLogIds.length) return;
|
|
for (const id of syncLogIds) {
|
|
await sql`UPDATE sync_log SET synced = 1 WHERE id = ${id}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get locations that need syncing
|
|
*/
|
|
export async function getUnsyncedLocations() {
|
|
return sql`SELECT * FROM locations WHERE synced = 0`;
|
|
}
|
|
|
|
/**
|
|
* Mark locations as synced
|
|
*/
|
|
export async function markLocationsSynced(ids) {
|
|
if (!ids.length) return;
|
|
for (const id of ids) {
|
|
await sql`UPDATE locations SET synced = 1 WHERE id = ${id}`;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Export / Import
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Export database for backup
|
|
*/
|
|
export async function exportDatabase() {
|
|
return db.getDatabaseFile();
|
|
}
|
|
|
|
/**
|
|
* Import database from backup
|
|
*/
|
|
export async function importDatabase(data) {
|
|
await db.overwriteDatabaseFile(data);
|
|
broadcastChange('*', 'IMPORT', null);
|
|
}
|
|
|
|
/**
|
|
* Download database as file
|
|
*/
|
|
export async function downloadDatabase(filename = 'lupmis-backup.sqlite3') {
|
|
const data = await exportDatabase();
|
|
const blob = new Blob([data], { type: 'application/x-sqlite3' });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// Export to GeoJSON
|
|
export async function exportToGeoJSON() {
|
|
const locations = await getLocations();
|
|
|
|
return {
|
|
type: 'FeatureCollection',
|
|
features: locations.map((loc) => ({
|
|
type: 'Feature',
|
|
properties: {
|
|
id: loc.id,
|
|
name: loc.name,
|
|
category: loc.category,
|
|
notes: loc.notes,
|
|
created_at: loc.created_at,
|
|
},
|
|
geometry: {
|
|
type: 'Point',
|
|
coordinates: [loc.lon, loc.lat],
|
|
},
|
|
})),
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Utility & Debug
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get database status
|
|
*/
|
|
export async function getDatabaseStatus() {
|
|
try {
|
|
const tables = await sql`
|
|
SELECT name FROM sqlite_master
|
|
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
|
ORDER BY name
|
|
`;
|
|
|
|
const locationCount = await getLocationCount();
|
|
|
|
return {
|
|
ready: isReady,
|
|
databasePath: DATABASE_PATH,
|
|
tables: tables.map(t => t.name),
|
|
locationCount
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
ready: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
// Debug function - call from console to test
|
|
export async function testDatabase() {
|
|
console.log('=== DATABASE TEST ===');
|
|
|
|
try {
|
|
// 1. Check connection
|
|
const version = await sql`SELECT sqlite_version() as v`;
|
|
console.log('1. SQLite version:', version[0].v);
|
|
|
|
// 2. Check tables
|
|
const tables = await sql`SELECT name FROM sqlite_master WHERE type='table'`;
|
|
console.log('2. Tables:', tables.map(t => t.name));
|
|
|
|
// 3. Try to insert a test row
|
|
console.log('3. Inserting test row...');
|
|
await sql`INSERT INTO locations (name, longitude, latitude, category) VALUES ('TEST', -1.0, 7.0, 'test')`;
|
|
|
|
// 4. Read it back
|
|
const rows = await sql`SELECT * FROM locations WHERE name = 'TEST'`;
|
|
console.log('4. Test row:', rows);
|
|
|
|
// 5. Count all rows
|
|
const count = await sql`SELECT COUNT(*) as c FROM locations`;
|
|
console.log('5. Total rows:', count[0].c);
|
|
|
|
// 6. Delete test row
|
|
await sql`DELETE FROM locations WHERE name = 'TEST'`;
|
|
console.log('6. Test row deleted');
|
|
|
|
console.log('=== TEST PASSED ===');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('=== TEST FAILED ===', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Expose to window for debugging
|
|
if (typeof window !== 'undefined') {
|
|
window.testDatabase = testDatabase;
|
|
window.dbStatus = getDatabaseStatus;
|
|
}
|
|
|
|
export async function closeDatabase() {
|
|
channel.close();
|
|
if (db.destroy) {
|
|
await db.destroy();
|
|
}
|
|
}
|
|
|
|
export default {
|
|
sql,
|
|
dbReady,
|
|
initSchema,
|
|
addLocation,
|
|
getLocations,
|
|
getLocation,
|
|
updateLocation,
|
|
deleteLocation,
|
|
getLocationCount,
|
|
getUnsyncedChanges,
|
|
getUnsyncedLocations,
|
|
markSynced,
|
|
markLocationsSynced,
|
|
exportDatabase,
|
|
importDatabase,
|
|
downloadDatabase,
|
|
getDatabaseStatus,
|
|
testDatabase,
|
|
onDatabaseChange,
|
|
closeDatabase
|
|
};
|