signalk-parquet
Version:
Vessel data Parquet file archive with automated value and geospatial triggers. History API compliant with cloud backups and queries.
841 lines • 32.1 kB
JavaScript
"use strict";
/**
* 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.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.SQLiteBuffer = void 0;
exports.pathToTableName = pathToTableName;
const path = __importStar(require("path"));
const fs = __importStar(require("fs-extra"));
// Lazy-loaded: node:sqlite requires Node 22.5+
let DatabaseSync;
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
}
/**
* Convert a SignalK path to a SQLite table name.
* Dots become underscores, any non-alphanumeric/underscore chars are stripped,
* prefixed with `buffer_`.
*/
function pathToTableName(signalkPath) {
return `buffer_${signalkPath.replace(/\./g, '_').replace(/[^a-zA-Z0-9_]/g, '_')}`;
}
class SQLiteBuffer {
constructor(config) {
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();
}
createMetadataSchema() {
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.
*/
migrateFromLegacy() {
// 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().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().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().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).cnt;
const isObject = hasJson > 0;
// For object paths, discover which value_* columns have data for this path
const pathValueCols = [];
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).cnt;
if (hasData > 0) {
pathValueCols.push(col);
}
}
}
const tableName = pathToTableName(signalkPath);
// Build CREATE TABLE
const columns = [
'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.
*/
loadExistingTables() {
const rows = this.db
.prepare(`SELECT path, table_name, is_object FROM buffer_tables`)
.all();
for (const row of rows) {
const columns = new Set();
const tableInfo = this.db
.prepare(`PRAGMA table_info(${row.table_name})`)
.all();
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.
*/
buildInsertStmt(tableName, columns, isObject) {
const insertCols = [];
const placeholders = [];
// 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.
*/
ensureTable(signalkPath, record) {
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 = [
'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();
const tableInfoRows = this.db
.prepare(`PRAGMA table_info(${tableName})`)
.all();
for (const col of tableInfoRows) {
columns.add(col.name);
}
const insertStmt = this.buildInsertStmt(tableName, columns, isObject);
const info = { 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.
*/
ensureColumn(tableInfo, columnName, value) {
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() {
return this._open;
}
/**
* Insert a single record into the buffer
*/
insert(record) {
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);
}
/**
* Insert multiple records in a single transaction (much faster)
*/
insertBatch(records) {
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);
}
this.db.exec('COMMIT');
}
catch (e) {
this.db.exec('ROLLBACK');
throw e;
}
}
prepareRecord(record, tableInfo) {
const params = {
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 = 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 = 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.
*/
bufferRecordToDataRecord(record, signalkPath) {
let value = record.value;
let valueJson = 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 = record.source;
if (record.source) {
try {
source = JSON.parse(record.source);
}
catch {
source = record.source;
}
}
// Parse meta if JSON
let meta = record.meta;
if (record.meta) {
try {
meta = JSON.parse(record.meta);
}
catch {
meta = record.meta;
}
}
const dataRecord = {
received_timestamp: record.received_timestamp,
signalk_timestamp: record.signalk_timestamp,
context: record.context,
path: signalkPath,
value: value,
value_json: valueJson,
source: source,
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,
};
// Restore dynamic value_* columns from the record
const rec = record;
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() {
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() {
let totalRecords = 0;
let pendingRecords = 0;
let exportedRecords = 0;
let oldestPendingTimestamp = null;
let newestRecordTimestamp = 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();
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() {
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();
total += row.count;
}
return total;
}
/**
* Checkpoint WAL file (useful before backup or to reduce WAL size)
*/
checkpoint() {
this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
}
/**
* Get distinct dates that have unexported records, excluding today.
* Scans all per-path tables.
*/
getDatesWithUnexportedRecords(excludeToday = true) {
if (!this._open) {
return [];
}
const allDates = new Set();
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();
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) {
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 = [];
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);
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, signalkPath, date) {
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);
return bufferRecords.map(r => this.bufferRecordToDataRecord(r, signalkPath));
}
/**
* Count unexported records for a specific context, path, and date (UTC).
*/
getRecordCountForPathAndDate(context, signalkPath, date) {
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);
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, signalkPath, date, limit, offset) {
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);
return bufferRecords.map(r => this.bufferRecordToDataRecord(r, signalkPath));
}
/**
* Mark records for a specific date as exported.
* Queries the specific path's table.
*/
markDateExported(context, signalkPath, date, batchId) {
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() {
return new Set(this.tableMap.keys());
}
/**
* Check if a buffer table exists for a given SignalK path.
*/
hasTable(signalkPath) {
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) {
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) {
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();
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, context, fromIso, toIso, afterId, limit) {
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);
}
/**
* Get the database path
*/
getDbPath() {
return this.dbPath;
}
/**
* Close the database connection
*/
close() {
try {
this.checkpoint();
}
catch {
// Ignore checkpoint errors during shutdown
}
this.db.close();
this._open = false;
}
}
exports.SQLiteBuffer = SQLiteBuffer;
//# sourceMappingURL=sqlite-buffer.js.map