UNPKG

signalk-parquet

Version:

Vessel data Parquet file archive with automated value and geospatial triggers. History API compliant with cloud backups and queries.

1,126 lines (995 loc) 31.4 kB
/** * SQLite Write-Ahead Buffer for crash-safe data ingestion * * Per-path table architecture: each SignalK path gets its own table in buffer.db. * Scalar paths have a `value` column; object paths have `value_json` + flattened `value_*` columns. * This eliminates column pollution from ALTER TABLE ADD COLUMN on a shared table. */ import * as path from 'path'; import * as fs from 'fs-extra'; import { DataRecord } from '../types'; // Lazy-loaded: node:sqlite requires Node 22.5+ let DatabaseSync: typeof import('node:sqlite').DatabaseSync; type StatementSync = import('node:sqlite').StatementSync; type SQLInputValue = import('node:sqlite').SQLInputValue; try { // eslint-disable-next-line @typescript-eslint/no-require-imports ({ DatabaseSync } = require('node:sqlite')); } catch { // node:sqlite not available — SQLiteBuffer constructor will throw a clear error } export interface BufferRecord { id: number; context: string; received_timestamp: string; signalk_timestamp: string; value: number | string | boolean | null; value_json: string | null; source: string | null; source_label: string | null; source_type: string | null; source_pgn: number | null; source_src: string | null; meta: string | null; exported: number; export_batch_id: string | null; created_at: string; [key: string]: unknown; // Dynamic value_* columns } export interface BufferStats { totalRecords: number; pendingRecords: number; exportedRecords: number; oldestPendingTimestamp: string | null; newestRecordTimestamp: string | null; dbSizeBytes: number; walSizeBytes: number; } export interface SQLiteBufferConfig { dbPath: string; maxBatchSize?: number; retentionHours?: number; } interface TableInfo { tableName: string; isObject: boolean; columns: Set<string>; insertStmt: StatementSync; } /** * Convert a SignalK path to a SQLite table name. * Dots become underscores, any non-alphanumeric/underscore chars are stripped, * prefixed with `buffer_`. */ export function pathToTableName(signalkPath: string): string { return `buffer_${signalkPath.replace(/\./g, '_').replace(/[^a-zA-Z0-9_]/g, '_')}`; } export class SQLiteBuffer { private db: InstanceType<typeof DatabaseSync>; private _open: boolean; private readonly dbPath: string; private readonly retentionHours: number; private tableMap: Map<string, TableInfo>; // keyed by SignalK path constructor(config: SQLiteBufferConfig) { if (!DatabaseSync) { throw new Error( 'node:sqlite is not available (requires Node.js 22.5+). SQLite buffer disabled — falling back to in-memory LRU.' ); } this.dbPath = config.dbPath; this.retentionHours = config.retentionHours || 24; // Ensure directory exists fs.ensureDirSync(path.dirname(this.dbPath)); // Open database with WAL mode for crash safety and better concurrency this.db = new DatabaseSync(this.dbPath); this._open = true; // Configure for performance and crash safety this.db.exec('PRAGMA journal_mode = WAL'); this.db.exec('PRAGMA synchronous = NORMAL'); this.db.exec('PRAGMA cache_size = -64000'); // 64MB cache this.db.exec('PRAGMA temp_store = MEMORY'); this.db.exec('PRAGMA mmap_size = 268435456'); // 256MB memory-mapped I/O // Create metadata table this.createMetadataSchema(); // Migrate from old single-table layout if needed this.migrateFromLegacy(); // Rebuild tableMap from buffer_tables metadata this.tableMap = new Map(); this.loadExistingTables(); } private createMetadataSchema(): void { this.db.exec(` CREATE TABLE IF NOT EXISTS buffer_tables ( path TEXT PRIMARY KEY, table_name TEXT NOT NULL, is_object INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); } /** * Migrate from the legacy single buffer_records table to per-path tables. * Runs once automatically if buffer_records exists but buffer_tables is empty. */ private migrateFromLegacy(): void { // Check if old table exists const oldTableExists = this.db .prepare( `SELECT name FROM sqlite_master WHERE type='table' AND name='buffer_records'` ) .get(); if (!oldTableExists) return; // Check if we already migrated (buffer_tables has entries) const tableCount = ( this.db.prepare(`SELECT COUNT(*) as cnt FROM buffer_tables`).get() as { cnt: number; } ).cnt; if (tableCount > 0) { // Already migrated — drop legacy table if it still exists this.db.exec(`DROP TABLE IF EXISTS buffer_records`); return; } // Get all columns from the old table const oldColumns = ( this.db.prepare('PRAGMA table_info(buffer_records)').all() as Array<{ name: string; type: string; }> ).map(c => c.name); // Discover all dynamic value_* columns (beyond base schema) const dynamicValueCols = oldColumns.filter( c => c.startsWith('value_') && c !== 'value_json' ); // Get distinct paths const paths = ( this.db .prepare(`SELECT DISTINCT path FROM buffer_records ORDER BY path`) .all() as Array<{ path: string }> ).map(r => r.path); if (paths.length === 0) { // No data — just drop the old table this.db.exec(`DROP TABLE IF EXISTS buffer_records`); return; } this.db.exec('BEGIN'); try { for (const signalkPath of paths) { // Determine if this path is an object path const hasJson = ( this.db .prepare( `SELECT COUNT(*) as cnt FROM buffer_records WHERE path = ? AND value_json IS NOT NULL` ) .get(signalkPath) as { cnt: number } ).cnt; const isObject = hasJson > 0; // For object paths, discover which value_* columns have data for this path const pathValueCols: string[] = []; if (isObject && dynamicValueCols.length > 0) { // Check which dynamic columns have non-NULL data for this path for (const col of dynamicValueCols) { const hasData = ( this.db .prepare( `SELECT COUNT(*) as cnt FROM buffer_records WHERE path = ? AND ${col} IS NOT NULL` ) .get(signalkPath) as { cnt: number } ).cnt; if (hasData > 0) { pathValueCols.push(col); } } } const tableName = pathToTableName(signalkPath); // Build CREATE TABLE const columns: string[] = [ 'id INTEGER PRIMARY KEY AUTOINCREMENT', 'context TEXT NOT NULL', 'received_timestamp TEXT NOT NULL', 'signalk_timestamp TEXT NOT NULL', ]; if (isObject) { columns.push('value_json TEXT'); for (const col of pathValueCols) { columns.push(`${col} REAL`); } } else { columns.push('value TEXT'); } columns.push( 'source TEXT', 'source_label TEXT', 'source_type TEXT', 'source_pgn INTEGER', 'source_src TEXT', 'meta TEXT', 'exported INTEGER NOT NULL DEFAULT 0', 'export_batch_id TEXT', `created_at TEXT NOT NULL DEFAULT (datetime('now'))` ); this.db.exec(`CREATE TABLE ${tableName} (${columns.join(', ')})`); this.db.exec( `CREATE INDEX idx_${tableName}_ctx_exp ON ${tableName} (context, exported)` ); this.db.exec( `CREATE INDEX idx_${tableName}_received ON ${tableName} (received_timestamp)` ); // Copy data const selectCols = [ 'context', 'received_timestamp', 'signalk_timestamp', ]; if (isObject) { selectCols.push('value_json'); selectCols.push(...pathValueCols); } else { selectCols.push('value'); } selectCols.push( 'source', 'source_label', 'source_type', 'source_pgn', 'source_src', 'meta', 'exported', 'export_batch_id', 'created_at' ); this.db.exec( `INSERT INTO ${tableName} (${selectCols.join(', ')}) SELECT ${selectCols.join(', ')} FROM buffer_records WHERE path = '${signalkPath.replace(/'/g, "''")}'` ); // Register in metadata this.db .prepare( `INSERT INTO buffer_tables (path, table_name, is_object) VALUES (?, ?, ?)` ) .run(signalkPath, tableName, isObject ? 1 : 0); } // Drop legacy table this.db.exec(`DROP TABLE buffer_records`); this.db.exec('COMMIT'); } catch (e) { this.db.exec('ROLLBACK'); throw e; } } /** * Load existing per-path tables from buffer_tables metadata and prepare INSERT statements. */ private loadExistingTables(): void { const rows = this.db .prepare(`SELECT path, table_name, is_object FROM buffer_tables`) .all() as Array<{ path: string; table_name: string; is_object: number }>; for (const row of rows) { const columns = new Set<string>(); const tableInfo = this.db .prepare(`PRAGMA table_info(${row.table_name})`) .all() as Array<{ name: string }>; for (const col of tableInfo) { columns.add(col.name); } const insertStmt = this.buildInsertStmt( row.table_name, columns, row.is_object === 1 ); this.tableMap.set(row.path, { tableName: row.table_name, isObject: row.is_object === 1, columns, insertStmt, }); } } /** * Build an INSERT statement for a per-path table. */ private buildInsertStmt( tableName: string, columns: Set<string>, isObject: boolean ): StatementSync { const insertCols: string[] = []; const placeholders: string[] = []; // Order: context, received_timestamp, signalk_timestamp, value/value_json+value_*, source*, meta, exported, export_batch_id, created_at const orderedCols = ['context', 'received_timestamp', 'signalk_timestamp']; if (isObject) { orderedCols.push('value_json'); // Add any dynamic value_* columns for (const col of columns) { if (col.startsWith('value_') && col !== 'value_json') { orderedCols.push(col); } } } else { orderedCols.push('value'); } orderedCols.push( 'source', 'source_label', 'source_type', 'source_pgn', 'source_src', 'meta' ); for (const col of orderedCols) { if (columns.has(col)) { insertCols.push(col); placeholders.push(`@${col}`); } } // Automatic columns insertCols.push('exported', 'export_batch_id', 'created_at'); placeholders.push('0', 'NULL', "datetime('now')"); return this.db.prepare( `INSERT INTO ${tableName} (${insertCols.join(', ')}) VALUES (${placeholders.join(', ')})` ); } /** * Ensure a per-path table exists. Creates it on first insert for a new path. */ private ensureTable(signalkPath: string, record: DataRecord): TableInfo { const existing = this.tableMap.get(signalkPath); if (existing) return existing; const tableName = pathToTableName(signalkPath); // Detect object vs scalar from the record const valueKeys = Object.keys(record).filter( k => k.startsWith('value_') && k !== 'value_json' && record[k] !== undefined && record[k] !== null ); const isObject = valueKeys.length > 0 || (record.value_json !== undefined && record.value_json !== null); const columnDefs: string[] = [ 'id INTEGER PRIMARY KEY AUTOINCREMENT', 'context TEXT NOT NULL', 'received_timestamp TEXT NOT NULL', 'signalk_timestamp TEXT NOT NULL', ]; if (isObject) { columnDefs.push('value_json TEXT'); for (const key of valueKeys) { const val = record[key]; const colType = typeof val === 'number' ? 'REAL' : 'TEXT'; columnDefs.push(`${key} ${colType}`); } } else { columnDefs.push('value TEXT'); } columnDefs.push( 'source TEXT', 'source_label TEXT', 'source_type TEXT', 'source_pgn INTEGER', 'source_src TEXT', 'meta TEXT', 'exported INTEGER NOT NULL DEFAULT 0', 'export_batch_id TEXT', `created_at TEXT NOT NULL DEFAULT (datetime('now'))` ); this.db.exec(`CREATE TABLE ${tableName} (${columnDefs.join(', ')})`); this.db.exec( `CREATE INDEX idx_${tableName}_ctx_exp ON ${tableName} (context, exported)` ); this.db.exec( `CREATE INDEX idx_${tableName}_received ON ${tableName} (received_timestamp)` ); // Register in metadata this.db .prepare( `INSERT INTO buffer_tables (path, table_name, is_object) VALUES (?, ?, ?)` ) .run(signalkPath, tableName, isObject ? 1 : 0); const columns = new Set<string>(); const tableInfoRows = this.db .prepare(`PRAGMA table_info(${tableName})`) .all() as Array<{ name: string }>; for (const col of tableInfoRows) { columns.add(col.name); } const insertStmt = this.buildInsertStmt(tableName, columns, isObject); const info: TableInfo = { tableName, isObject, columns, insertStmt }; this.tableMap.set(signalkPath, info); return info; } /** * Ensure a dynamic value_* column exists on a per-path table. * Only affects the single table for that path. */ private ensureColumn( tableInfo: TableInfo, columnName: string, value: unknown ): void { if (tableInfo.columns.has(columnName)) return; const colType = typeof value === 'number' ? 'REAL' : 'TEXT'; this.db.exec( `ALTER TABLE ${tableInfo.tableName} ADD COLUMN ${columnName} ${colType}` ); tableInfo.columns.add(columnName); tableInfo.insertStmt = this.buildInsertStmt( tableInfo.tableName, tableInfo.columns, tableInfo.isObject ); } /** * Check if the database connection is open */ isOpen(): boolean { return this._open; } /** * Insert a single record into the buffer */ insert(record: DataRecord): void { if (!this._open) { throw new Error('SQLite buffer is closed'); } const tableInfo = this.ensureTable(record.path, record); const params = this.prepareRecord(record, tableInfo); tableInfo.insertStmt.run(params as Record<string, SQLInputValue>); } /** * Insert multiple records in a single transaction (much faster) */ insertBatch(records: DataRecord[]): void { if (records.length === 0) return; this.db.exec('BEGIN'); try { for (const record of records) { const tableInfo = this.ensureTable(record.path, record); const params = this.prepareRecord(record, tableInfo); tableInfo.insertStmt.run(params as Record<string, SQLInputValue>); } this.db.exec('COMMIT'); } catch (e) { this.db.exec('ROLLBACK'); throw e; } } private prepareRecord( record: DataRecord, tableInfo: TableInfo ): Record<string, unknown> { const params: Record<string, unknown> = { context: record.context, received_timestamp: record.received_timestamp, signalk_timestamp: record.signalk_timestamp, }; if (tableInfo.isObject) { // Object path: value_json + flattened value_* columns let valueJson: string | null = null; if ( record.value !== null && record.value !== undefined && typeof record.value === 'object' ) { valueJson = JSON.stringify(record.value); } if (record.value_json !== undefined && record.value_json !== null) { valueJson = typeof record.value_json === 'string' ? record.value_json : JSON.stringify(record.value_json); } params.value_json = valueJson; // Extract dynamic value_* columns for (const key of Object.keys(record)) { if ( key.startsWith('value_') && key !== 'value_json' && record[key] !== undefined && record[key] !== null ) { this.ensureColumn(tableInfo, key, record[key]); const val = record[key]; if (typeof val === 'number') { params[key] = val; } else if (typeof val === 'boolean') { params[key] = val ? 1 : 0; } else { params[key] = String(val); } } } // NULL-fill any known value_* columns not in this record for (const col of tableInfo.columns) { if ( col.startsWith('value_') && col !== 'value_json' && !(col in params) ) { params[col] = null; } } } else { // Scalar path: value column let valueStr: string | null = null; if (record.value !== null && record.value !== undefined) { valueStr = String(record.value); } params.value = valueStr; } // Serialize source params.source = record.source ? typeof record.source === 'object' ? JSON.stringify(record.source) : String(record.source) : null; params.source_label = record.source_label || null; params.source_type = record.source_type || null; params.source_pgn = record.source_pgn || null; params.source_src = record.source_src || null; // Serialize meta params.meta = record.meta ? typeof record.meta === 'object' ? JSON.stringify(record.meta) : String(record.meta) : null; return params; } /** * Convert a BufferRecord back to a DataRecord. * Path must be passed in since per-path tables have no path column. */ private bufferRecordToDataRecord( record: BufferRecord, signalkPath: string ): DataRecord { let value: unknown = record.value; let valueJson: unknown = undefined; // Parse value_json if present if (record.value_json) { try { valueJson = JSON.parse(record.value_json); } catch { valueJson = record.value_json; } } // Parse numeric values if (value !== null && value !== undefined && !isNaN(Number(value))) { value = Number(value); } else if (value === 'true') { value = true; } else if (value === 'false') { value = false; } // Parse source if JSON let source: unknown = record.source; if (record.source) { try { source = JSON.parse(record.source); } catch { source = record.source; } } // Parse meta if JSON let meta: unknown = record.meta; if (record.meta) { try { meta = JSON.parse(record.meta); } catch { meta = record.meta; } } const dataRecord: DataRecord = { received_timestamp: record.received_timestamp, signalk_timestamp: record.signalk_timestamp, context: record.context, path: signalkPath, value: value, value_json: valueJson as string | object | undefined, source: source as string | object | undefined, source_label: record.source_label || undefined, source_type: record.source_type || undefined, source_pgn: record.source_pgn || undefined, source_src: record.source_src || undefined, meta: meta as string | object | undefined, }; // Restore dynamic value_* columns from the record const rec = record as Record<string, unknown>; for (const key of Object.keys(rec)) { if ( key.startsWith('value_') && key !== 'value_json' && rec[key] !== null && rec[key] !== undefined ) { dataRecord[key] = rec[key]; } } return dataRecord; } /** * Clean up old exported records from all per-path tables */ cleanup(): number { let totalCleaned = 0; for (const [, info] of this.tableMap) { const result = this.db .prepare( `DELETE FROM ${info.tableName} WHERE exported = 1 AND created_at < datetime('now', '-' || ? || ' hours')` ) .run(this.retentionHours); totalCleaned += Number(result.changes); } return totalCleaned; } /** * Get buffer statistics aggregated across all per-path tables */ getStats(): BufferStats { let totalRecords = 0; let pendingRecords = 0; let exportedRecords = 0; let oldestPendingTimestamp: string | null = null; let newestRecordTimestamp: string | null = null; for (const [, info] of this.tableMap) { const row = this.db .prepare( ` SELECT COUNT(*) as totalRecords, SUM(CASE WHEN exported = 0 THEN 1 ELSE 0 END) as pendingRecords, SUM(CASE WHEN exported = 1 THEN 1 ELSE 0 END) as exportedRecords, MIN(CASE WHEN exported = 0 THEN received_timestamp END) as oldestPendingTimestamp, MAX(received_timestamp) as newestRecordTimestamp FROM ${info.tableName} ` ) .get() as { totalRecords: number; pendingRecords: number; exportedRecords: number; oldestPendingTimestamp: string | null; newestRecordTimestamp: string | null; }; totalRecords += row.totalRecords || 0; pendingRecords += row.pendingRecords || 0; exportedRecords += row.exportedRecords || 0; if (row.oldestPendingTimestamp) { if ( !oldestPendingTimestamp || row.oldestPendingTimestamp < oldestPendingTimestamp ) { oldestPendingTimestamp = row.oldestPendingTimestamp; } } if (row.newestRecordTimestamp) { if ( !newestRecordTimestamp || row.newestRecordTimestamp > newestRecordTimestamp ) { newestRecordTimestamp = row.newestRecordTimestamp; } } } // Get file sizes let dbSizeBytes = 0; let walSizeBytes = 0; try { dbSizeBytes = fs.statSync(this.dbPath).size; } catch { // File may not exist yet } try { const walPath = this.dbPath + '-wal'; if (fs.existsSync(walPath)) { walSizeBytes = fs.statSync(walPath).size; } } catch { // WAL file may not exist } return { totalRecords, pendingRecords, exportedRecords, oldestPendingTimestamp, newestRecordTimestamp, dbSizeBytes, walSizeBytes, }; } /** * Get count of pending records (faster than full stats) */ getPendingCount(): number { if (!this._open) { return 0; } let total = 0; for (const [, info] of this.tableMap) { const row = this.db .prepare( `SELECT COUNT(*) as count FROM ${info.tableName} WHERE exported = 0` ) .get() as { count: number }; total += row.count; } return total; } /** * Checkpoint WAL file (useful before backup or to reduce WAL size) */ checkpoint(): void { this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } /** * Get distinct dates that have unexported records, excluding today. * Scans all per-path tables. */ getDatesWithUnexportedRecords(excludeToday: boolean = true): string[] { if (!this._open) { return []; } const allDates = new Set<string>(); for (const [, info] of this.tableMap) { let query = ` SELECT DISTINCT date(received_timestamp) as record_date FROM ${info.tableName} WHERE exported = 0 `; if (excludeToday) { query += ` AND date(received_timestamp) < date('now')`; } const rows = this.db.prepare(query).all() as Array<{ record_date: string; }>; for (const r of rows) { allDates.add(r.record_date); } } return Array.from(allDates).sort(); } /** * Get distinct context/path combinations for a specific date (UTC). * Scans all per-path tables. */ getPathsForDate(date: Date): Array<{ context: string; path: string }> { if (!this._open) { return []; } const dateStr = date.toISOString().slice(0, 10); const startOfDay = `${dateStr}T00:00:00.000Z`; const endOfDay = `${dateStr}T23:59:59.999Z`; const results: Array<{ context: string; path: string }> = []; for (const [signalkPath, info] of this.tableMap) { const rows = this.db .prepare( ` SELECT DISTINCT context FROM ${info.tableName} WHERE received_timestamp >= ? AND received_timestamp <= ? AND exported = 0 ` ) .all(startOfDay, endOfDay) as Array<{ context: string }>; for (const row of rows) { results.push({ context: row.context, path: signalkPath }); } } return results.sort((a, b) => a.context < b.context ? -1 : a.context > b.context ? 1 : a.path < b.path ? -1 : a.path > b.path ? 1 : 0 ); } /** * Get all records for a specific context, path, and date (UTC). * Returns just DataRecord[] — no IDs needed since markDateExported() handles marking. */ getRecordsForPathAndDate( context: string, signalkPath: string, date: Date ): DataRecord[] { if (!this._open) { return []; } const tableInfo = this.tableMap.get(signalkPath); if (!tableInfo) return []; const dateStr = date.toISOString().slice(0, 10); const startOfDay = `${dateStr}T00:00:00.000Z`; const endOfDay = `${dateStr}T23:59:59.999Z`; const bufferRecords = this.db .prepare( ` SELECT * FROM ${tableInfo.tableName} WHERE context = ? AND received_timestamp >= ? AND received_timestamp <= ? AND exported = 0 ORDER BY received_timestamp ASC ` ) .all(context, startOfDay, endOfDay) as BufferRecord[]; return bufferRecords.map(r => this.bufferRecordToDataRecord(r, signalkPath) ); } /** * Count unexported records for a specific context, path, and date (UTC). */ getRecordCountForPathAndDate( context: string, signalkPath: string, date: Date ): number { if (!this._open) return 0; const tableInfo = this.tableMap.get(signalkPath); if (!tableInfo) return 0; const dateStr = date.toISOString().slice(0, 10); const startOfDay = `${dateStr}T00:00:00.000Z`; const endOfDay = `${dateStr}T23:59:59.999Z`; const row = this.db .prepare( ` SELECT COUNT(*) as cnt FROM ${tableInfo.tableName} WHERE context = ? AND received_timestamp >= ? AND received_timestamp <= ? AND exported = 0 ` ) .get(context, startOfDay, endOfDay) as { cnt: number } | undefined; return row?.cnt ?? 0; } /** * Get a batch of unexported records for a specific context, path, and date (UTC). * Uses LIMIT/OFFSET to avoid materializing all rows at once. */ getRecordsForPathAndDateBatched( context: string, signalkPath: string, date: Date, limit: number, offset: number ): DataRecord[] { if (!this._open) return []; const tableInfo = this.tableMap.get(signalkPath); if (!tableInfo) return []; const dateStr = date.toISOString().slice(0, 10); const startOfDay = `${dateStr}T00:00:00.000Z`; const endOfDay = `${dateStr}T23:59:59.999Z`; const bufferRecords = this.db .prepare( ` SELECT * FROM ${tableInfo.tableName} WHERE context = ? AND received_timestamp >= ? AND received_timestamp <= ? AND exported = 0 ORDER BY received_timestamp ASC LIMIT ? OFFSET ? ` ) .all(context, startOfDay, endOfDay, limit, offset) as BufferRecord[]; return bufferRecords.map(r => this.bufferRecordToDataRecord(r, signalkPath) ); } /** * Mark records for a specific date as exported. * Queries the specific path's table. */ markDateExported( context: string, signalkPath: string, date: Date, batchId: string ): void { if (!this._open) return; const tableInfo = this.tableMap.get(signalkPath); if (!tableInfo) return; const dateStr = date.toISOString().slice(0, 10); const startOfDay = `${dateStr}T00:00:00.000Z`; const endOfDay = `${dateStr}T23:59:59.999Z`; this.db .prepare( ` UPDATE ${tableInfo.tableName} SET exported = 1, export_batch_id = ? WHERE context = ? AND received_timestamp >= ? AND received_timestamp <= ? AND exported = 0 ` ) .run(batchId, context, startOfDay, endOfDay); } /** * Get the set of known SignalK paths that have buffer tables. * Used by SQL builders to check if a buffer table exists for federation. */ getKnownPaths(): Set<string> { return new Set(this.tableMap.keys()); } /** * Check if a buffer table exists for a given SignalK path. */ hasTable(signalkPath: string): boolean { return this.tableMap.has(signalkPath); } /** * Get the set of columns for a given path's buffer table. * Returns undefined if no table exists for this path. */ getTableColumns(signalkPath: string): Set<string> | undefined { const info = this.tableMap.get(signalkPath); return info?.columns; } /** * Get the column schema (name + declared SQLite type) for a path's buffer table. * Returns undefined if no table exists for this path. */ getTableSchema( signalkPath: string ): Array<{ name: string; type: string }> | undefined { if (!this._open) return undefined; const info = this.tableMap.get(signalkPath); if (!info) return undefined; const rows = this.db .prepare(`PRAGMA table_info(${info.tableName})`) .all() as Array<{ name: string; type: string }>; return rows.map(r => ({ name: r.name, type: r.type })); } /** * Read a batch of unexported rows for federated history queries, keyset-paginated * by id so callers can stream large windows without materializing them all. * Rows are raw table rows (all columns), matching the table schema. */ getRowsForFederation( signalkPath: string, context: string, fromIso: string, toIso: string, afterId: number, limit: number ): Array<Record<string, unknown>> { if (!this._open) return []; const tableInfo = this.tableMap.get(signalkPath); if (!tableInfo) return []; return this.db .prepare( ` SELECT * FROM ${tableInfo.tableName} WHERE context = ? AND signalk_timestamp >= ? AND signalk_timestamp < ? AND exported = 0 AND id > ? ORDER BY id ASC LIMIT ? ` ) .all(context, fromIso, toIso, afterId, limit) as Array< Record<string, unknown> >; } /** * Get the database path */ getDbPath(): string { return this.dbPath; } /** * Close the database connection */ close(): void { try { this.checkpoint(); } catch { // Ignore checkpoint errors during shutdown } this.db.close(); this._open = false; } }