UNPKG

@mxtommy/kip

Version:

An advanced and versatile marine instrumentation package to display Signal K data.

1,123 lines (1,122 loc) 40.3 kB
"use strict"; 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.SqliteHistoryStorageService = void 0; const fs_1 = require("fs"); const path_1 = require("path"); const DEFAULT_STORAGE_CONFIG = { engine: 'node:sqlite', databaseFile: '', flushIntervalMs: 30_000 }; /** * Provides node:sqlite storage for captured history samples. */ class SqliteHistoryStorageService { static EIGHT_HOURS_INTERVAL = 8 * 60 * 60 * 1000; static FOUR_HOURS_INTERVAL = 4 * 60 * 60 * 1000; static STALE_SERIES_AGE_MS = 180 * 24 * 60 * 60 * 1000; static PRUNE_BATCH_SIZE = 10_000; config; logger = { debug: () => undefined, error: () => undefined }; db = null; pendingRows = []; lastInitError = null; lifecycleToken = 0; initialized = false; runtimeAvailable = true; maintenanceInProgress = false; flushInProgress = false; vacuumJob = null; pruneJob = null; staleSeriesCleanupJob = null; constructor(dataDirPath) { const normalizedDataDirPath = String(dataDirPath); if (!normalizedDataDirPath) { throw new Error('SqliteHistoryStorageService requires a valid dataDirPath from server.getDataDirPath()'); } this.config = { ...DEFAULT_STORAGE_CONFIG, databaseFile: (0, path_1.join)(normalizedDataDirPath, 'historicalData', 'kip-history.sqlite') }; } /** * Sets logger callbacks used by the storage service. * * @param {TLogger} logger Logger implementation from plugin runtime. * @returns {void} * * @example * storage.setLogger({ debug: console.log, error: console.error }); */ setLogger(logger) { this.logger = logger; } /** * Applies the fixed storage backend configuration. * * @returns {ISqliteHistoryStorageConfig} Fixed storage configuration. * * @example * const cfg = storage.configure(); * console.log(cfg.engine); */ configure() { this.initialized = false; this.config = { ...DEFAULT_STORAGE_CONFIG, databaseFile: this.config.databaseFile }; return this.config; } /** * Updates runtime availability of node:sqlite, clearing stored errors when enabled. * * @param {boolean} available Whether node:sqlite is available at runtime. * @param {string | undefined} errorMessage Optional runtime error message. * @returns {void} * * @example * storage.setRuntimeAvailability(false, 'node:sqlite unavailable'); */ setRuntimeAvailability(available, errorMessage) { this.runtimeAvailable = available; this.lastInitError = available ? null : (errorMessage ?? 'node:sqlite unavailable'); if (!available) { this.initialized = false; this.db = null; } } /** * Initializes node:sqlite storage. * * @returns {Promise<boolean>} True when node:sqlite is initialized and ready. * * @example * const ready = await storage.initialize(); */ async initialize() { if (!this.isSqliteEnabled() || !this.runtimeAvailable) { return false; } this.initialized = false; this.lifecycleToken += 1; try { const sqlite = await this.loadSqliteModule(); if (!sqlite?.DatabaseSync) { throw new Error('node:sqlite DatabaseSync is unavailable'); } const dbPath = (0, path_1.resolve)(this.config.databaseFile); (0, fs_1.mkdirSync)((0, path_1.dirname)(dbPath), { recursive: true }); this.db = new sqlite.DatabaseSync(dbPath, { timeout: 5000 }); this.db.exec('PRAGMA journal_mode=WAL;'); this.db.exec('PRAGMA synchronous=NORMAL;'); this.db.exec('PRAGMA temp_store=MEMORY;'); this.db.exec('PRAGMA foreign_keys=ON;'); await this.createCoreTables(); await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_series_scope_ts ON history_samples(series_id, ts_ms)'); await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_series_scope_id ON history_series(series_id)'); await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_samples_scope_context_path_ts ON history_samples(context, path, ts_ms)'); await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_samples_scope_ts_path ON history_samples(ts_ms, path)'); await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_samples_scope_ts_context ON history_samples(ts_ms, context)'); this.logger.debug(`[SERIES STORAGE] node:sqlite initialized at ${dbPath}`); this.lastInitError = null; this.initialized = true; this.startVacuumJob(); this.startPruneJob(); this.startStaleSeriesCleanupJob(); return true; } catch (error) { const message = error?.message ?? String(error); this.lastInitError = message; this.logger.error(`[SERIES STORAGE] node:sqlite initialization failed: ${message}`); this.db = null; this.pendingRows = []; this.initialized = false; this.stopVacuumJob(); this.stopPruneJob(); this.stopStaleSeriesCleanupJob(); return false; } } /** * Returns last node:sqlite initialization error when initialization failed. * * @returns {string | null} Initialization error text or null. * * @example * const err = storage.getLastInitError(); */ getLastInitError() { return this.lastInitError; } /** * Indicates whether node:sqlite mode is selected. * * @returns {boolean} True when the selected engine is `node:sqlite`. * * @example * if (storage.isSqliteEnabled()) { * console.log('node:sqlite mode enabled'); * } */ isSqliteEnabled() { return this.config.engine === 'node:sqlite'; } /** * Indicates whether node:sqlite mode is initialized and ready. * * @returns {boolean} True when node:sqlite mode is selected and an active connection exists. * * @example * if (storage.isSqliteReady()) { * console.log('node:sqlite ready'); * } */ isSqliteReady() { return this.isSqliteEnabled() && this.initialized && this.db !== null && this.runtimeAvailable; } /** * Returns the current storage lifecycle token. * * @returns {number} Current lifecycle token. * * @example * const token = storage.getLifecycleToken(); */ getLifecycleToken() { return this.lifecycleToken; } /** * Adds a captured sample row to the pending storage queue. * * @param {IRecordedSample} sample Captured sample metadata and value. * @returns {void} * * @example * storage.enqueueSample(sample); */ enqueueSample(sample) { if (!this.isSqliteReady()) { return; } this.pendingRows.push(sample); } /** * Flushes queued samples into node:sqlite. * * @param {number} [expectedLifecycleToken] Optional lifecycle token guard to skip stale flushes. * @returns {Promise<{ inserted: number; exported: number }>} Number of inserted rows (exported is always 0). * * @example * const result = await storage.flush(); */ async flush(expectedLifecycleToken) { if (expectedLifecycleToken !== undefined && expectedLifecycleToken !== this.lifecycleToken) { return { inserted: 0, exported: 0 }; } if (!this.isSqliteEnabled() || !this.db || this.pendingRows.length === 0) { return { inserted: 0, exported: 0 }; } if (this.flushInProgress) { return { inserted: 0, exported: 0 }; } this.flushInProgress = true; const rows = this.pendingRows; this.pendingRows = []; const startedAt = Date.now(); try { await this.insertRows(rows); const elapsedMs = Date.now() - startedAt; this.logger.debug(`[SERIES STORAGE] flush inserted=${rows.length} durationMs=${elapsedMs}`); return { inserted: rows.length, exported: 0 }; } catch (error) { this.pendingRows = [...rows, ...this.pendingRows]; throw error; } finally { this.flushInProgress = false; } } /** * Returns persisted series definitions from node:sqlite. * * @returns {Promise<ISeriesDefinition[]>} Stored series definitions. * * @example * const series = await storage.getSeriesDefinitions(); */ async getSeriesDefinitions() { if (!this.isSqliteEnabled() || !this.db) { return []; } const rows = await this.querySql(` SELECT series_id, dataset_uuid, owner_widget_uuid, owner_widget_selector, path, source, context, time_scale, period, retention_duration_ms, sample_time, enabled, methods_json, reconcile_ts FROM history_series ORDER BY series_id ASC `); return rows.map(row => ({ seriesId: row.series_id, datasetUuid: row.dataset_uuid, ownerWidgetUuid: row.owner_widget_uuid, ownerWidgetSelector: row.owner_widget_selector ?? null, path: row.path, source: row.source ?? undefined, context: row.context ?? undefined, timeScale: row.time_scale ?? undefined, period: this.toNumberOrUndefined(row.period), retentionDurationMs: this.toNumberOrUndefined(row.retention_duration_ms), sampleTime: this.toNumberOrUndefined(row.sample_time), enabled: this.toBoolean(row.enabled), methods: this.parseMethods(row.methods_json), reconcileTs: this.toNumberOrUndefined(row.reconcile_ts) })); } /** * Persists one series definition in node:sqlite. * * @param {ISeriesDefinition} series Series definition to persist. * @returns {Promise<void>} * * @example * await storage.upsertSeriesDefinition(series); */ async upsertSeriesDefinition(series) { if (!this.isSqliteEnabled() || !this.db) { return; } await this.runSql(`DELETE FROM history_series WHERE series_id = ${this.escape(series.seriesId)}`); await this.runSql(` INSERT INTO history_series ( series_id, dataset_uuid, owner_widget_uuid, owner_widget_selector, path, source, context, time_scale, period, retention_duration_ms, sample_time, enabled, methods_json, reconcile_ts ) VALUES ( ${this.escape(series.seriesId)}, ${this.escape(series.datasetUuid)}, ${this.escape(series.ownerWidgetUuid)}, ${this.nullableString(series.ownerWidgetSelector)}, ${this.escape(series.path)}, ${this.nullableString(series.source)}, ${this.nullableString(series.context)}, ${this.nullableString(series.timeScale)}, ${this.nullableNumber(series.period)}, ${this.nullableNumber(series.retentionDurationMs)}, ${this.nullableNumber(series.sampleTime)}, ${series.enabled === false ? '0' : '1'}, ${this.nullableString(series.methods ? JSON.stringify(series.methods) : null)}, ${this.nullableNumber(series.reconcileTs)} ) `); } /** * Deletes one persisted series definition in node:sqlite. * * @param {string} seriesId Series identifier. * @returns {Promise<void>} * * @example * await storage.deleteSeriesDefinition('series-1'); */ async deleteSeriesDefinition(seriesId) { if (!this.isSqliteEnabled() || !this.db) { return; } await this.runSql(`DELETE FROM history_series WHERE series_id = ${this.escape(seriesId)}`); } /** * Replaces persisted series definitions with desired set. * * @param {ISeriesDefinition[]} series Full desired series set. * @returns {Promise<void>} * * @example * await storage.replaceSeriesDefinitions(series); */ async replaceSeriesDefinitions(series) { if (!this.isSqliteEnabled() || !this.db) { return; } await this.runSql('DELETE FROM history_series'); for (const item of series) { await this.upsertSeriesDefinition(item); } } /** * Deletes series not reconciled since the given cutoff timestamp. * * @param {number} cutoffMs Milliseconds since epoch; series with reconcile_ts < cutoffMs will be deleted. * @returns {Promise<number>} Number of deleted series. * * @example * const deleted = await storage.deleteStaleSeries(Date.now() - 180 * 24 * 60 * 60 * 1000); */ async deleteStaleSeries(cutoffMs) { if (!this.isSqliteEnabled() || !this.db) { return 0; } const rows = await this.querySql(` SELECT series_id FROM history_series WHERE reconcile_ts IS NULL OR reconcile_ts < ${Math.trunc(cutoffMs)} `); const ids = rows.map(row => row.series_id).filter(Boolean); for (const id of ids) { await this.deleteSeriesDefinition(id); } return ids.length; } /** * Removes persisted samples that are older than each series retention window. * * @param {number} [nowMs=Date.now()] Current timestamp in milliseconds used to compute per-series cutoffs. * @param {number} [expectedLifecycleToken] Optional lifecycle token guard to skip stale sweeps. * @returns {Promise<number>} Number of deleted sample rows. * * @example * const removed = await storage.pruneExpiredSamples(); */ async pruneExpiredSamples(nowMs = Date.now(), expectedLifecycleToken) { if (expectedLifecycleToken !== undefined && expectedLifecycleToken !== this.lifecycleToken) { return 0; } if (!this.isSqliteEnabled() || !this.db) { return 0; } const anchorMs = Math.trunc(Number.isFinite(nowMs) ? nowMs : Date.now()); const whereClause = ` EXISTS ( SELECT 1 FROM history_series AS hs WHERE hs.series_id = history_samples.series_id AND hs.retention_duration_ms IS NOT NULL AND hs.retention_duration_ms > 0 AND history_samples.ts_ms < (${anchorMs} - hs.retention_duration_ms) ) `; let removedRows = 0; while (true) { const batch = await this.querySql(` SELECT rowid FROM history_samples WHERE ${whereClause} LIMIT ${SqliteHistoryStorageService.PRUNE_BATCH_SIZE} `); if (batch.length === 0) { break; } const rowIds = batch.map(row => Math.trunc(Number(row.rowid))).filter(Number.isFinite); if (rowIds.length === 0) { break; } await this.runSql(` DELETE FROM history_samples WHERE rowid IN (${rowIds.join(', ')}) `); removedRows += rowIds.length; if (rowIds.length < SqliteHistoryStorageService.PRUNE_BATCH_SIZE) { break; } } return removedRows; } /** * Removes persisted samples that no longer have a matching series definition. * * @param {number} [expectedLifecycleToken] Optional lifecycle token guard to skip stale sweeps. * @returns {Promise<number>} Number of deleted orphan sample rows. * * @example * const removed = await storage.pruneOrphanedSamples(); */ async pruneOrphanedSamples(expectedLifecycleToken) { if (expectedLifecycleToken !== undefined && expectedLifecycleToken !== this.lifecycleToken) { return 0; } if (!this.isSqliteEnabled() || !this.db) { return 0; } const whereClause = ` NOT EXISTS ( SELECT 1 FROM history_series AS hs WHERE hs.series_id = history_samples.series_id ) `; let removedRows = 0; while (true) { const batch = await this.querySql(` SELECT rowid FROM history_samples WHERE ${whereClause} LIMIT ${SqliteHistoryStorageService.PRUNE_BATCH_SIZE} `); if (batch.length === 0) { break; } const rowIds = batch.map(row => Math.trunc(Number(row.rowid))).filter(Number.isFinite); if (rowIds.length === 0) { break; } await this.runSql(` DELETE FROM history_samples WHERE rowid IN (${rowIds.join(', ')}) `); removedRows += rowIds.length; if (rowIds.length < SqliteHistoryStorageService.PRUNE_BATCH_SIZE) { break; } } return removedRows; } /** * Lists known history paths from persisted samples. * * @param {IHistoryRangeQuery} [query] Optional range filter. * @returns {Promise<string[]>} Ordered path names. * * @example * const paths = await storage.getStoredPaths(); */ async getStoredPaths(query) { if (!this.isSqliteEnabled() || !this.db) { return []; } const nowMs = Date.now(); const range = this.resolveRange(nowMs, query?.from, query?.to, query?.duration); const rows = await this.querySql(` SELECT DISTINCT path AS value FROM history_samples WHERE path IS NOT NULL AND ts_ms >= ${Math.trunc(range.fromMs)} AND ts_ms <= ${Math.trunc(range.toMs)} ORDER BY value ASC `); return rows.map(row => row.value).filter(Boolean); } /** * Lists known history contexts from persisted samples. * * @param {IHistoryRangeQuery} [query] Optional range filter. * @returns {Promise<string[]>} Ordered context names. * * @example * const contexts = await storage.getStoredContexts(); */ async getStoredContexts(query) { if (!this.isSqliteEnabled() || !this.db) { return []; } const nowMs = Date.now(); const range = this.resolveRange(nowMs, query?.from, query?.to, query?.duration); const rows = await this.querySql(` SELECT DISTINCT context AS value FROM history_samples WHERE context IS NOT NULL AND ts_ms >= ${Math.trunc(range.fromMs)} AND ts_ms <= ${Math.trunc(range.toMs)} ORDER BY value ASC `); return rows.map(row => row.value).filter(Boolean); } /** * Queries history values directly from node:sqlite in History API-compatible shape. * * @param {IHistoryQueryParams} query Incoming history values query parameters. * @returns {Promise<IHistoryValuesResponse | null>} History payload when node:sqlite is ready, otherwise null. * * @example * const result = await storage.getValues({ paths: 'navigation.speedOverGround:avg', duration: 'PT1H' }); */ async getValues(query) { if (!this.isSqliteEnabled() || !this.db) { return null; } const nowMs = Date.now(); const requested = this.parseRequestedPaths(query.paths); if (requested.length === 0) { return null; } const range = this.resolveRange(nowMs, query.from, query.to, query.duration); const context = query.context ?? 'vessels.self'; const resolutionMs = this.resolveResolutionMs(query.resolution); const uniquePaths = Array.from(new Set(requested.map(item => item.path))); const rowsByPath = await this.selectRowsForPaths(uniquePaths, context, range.fromMs, range.toMs); const timestampRows = new Map(); for (let index = 0; index < requested.length; index += 1) { const item = requested[index]; const rows = rowsByPath.get(item.path) ?? []; const transformed = this.applyMethod(item, rows); const merged = this.downsampleIfNeeded(transformed, resolutionMs, item.method ?? 'avg'); merged.forEach(entry => { const row = timestampRows.get(entry.timestamp) ?? Array.from({ length: requested.length }, () => null); row[index] = entry.value; timestampRows.set(entry.timestamp, row); }); } const data = Array.from(timestampRows.entries()) .sort((left, right) => left[0] - right[0]) .map(([timestamp, values]) => [new Date(timestamp).toISOString(), ...values]); return { context, range: { from: new Date(range.fromMs).toISOString(), to: new Date(range.toMs).toISOString() }, values: requested.map(item => ({ path: item.path, method: item.method ?? 'avg' })), data }; } /** * Closes open storage resources. * * @param {number} [expectedLifecycleToken] Optional lifecycle token guard to skip stale closes. * @returns {Promise<void>} * * @example * await storage.close(); */ async close(expectedLifecycleToken) { if (expectedLifecycleToken !== undefined && expectedLifecycleToken !== this.lifecycleToken) { return; } this.initialized = false; this.stopVacuumJob(); this.stopPruneJob(); this.stopStaleSeriesCleanupJob(); if (!this.db) { return; } const db = this.db; this.db = null; try { db.close(); } catch { // ignore close failures during shutdown } } async loadSqliteModule() { try { return await Promise.resolve().then(() => __importStar(require('node:sqlite'))); } catch { return null; } } startVacuumJob() { this.stopVacuumJob(); if (!this.isSqliteReady()) return; this.vacuumJob = setInterval(() => { if (this.shouldSkipMaintenance()) { return; } void this.runWithMaintenanceLock('vacuum', async () => { this.logger.debug('[SERIES STORAGE] Running scheduled node:sqlite VACUUM'); await this.runSql('VACUUM;'); await this.runSql('PRAGMA optimize;'); }).catch(err => { this.logger.error(`[SERIES STORAGE] VACUUM failed: ${err?.message ?? err}`); }); }, SqliteHistoryStorageService.EIGHT_HOURS_INTERVAL); this.vacuumJob.unref?.(); } stopVacuumJob() { if (this.vacuumJob) { clearInterval(this.vacuumJob); this.vacuumJob = null; } } startPruneJob() { this.stopPruneJob(); if (!this.isSqliteReady()) return; this.pruneJob = setInterval(async () => { if (this.shouldSkipMaintenance()) { return; } try { await this.runWithMaintenanceLock('prune', async () => { this.logger.debug('[SERIES STORAGE] Running scheduled prune of expired and orphaned samples'); const expired = await this.pruneExpiredSamples(Date.now(), this.lifecycleToken); const orphaned = await this.pruneOrphanedSamples(this.lifecycleToken); this.logger.debug(`[SERIES STORAGE] Pruned ${expired} expired and ${orphaned} orphaned samples`); }); } catch (err) { this.logger.error(`[SERIES STORAGE] Prune failed: ${err?.message ?? err}`); } }, SqliteHistoryStorageService.FOUR_HOURS_INTERVAL); this.pruneJob.unref?.(); } stopPruneJob() { if (this.pruneJob) { clearInterval(this.pruneJob); this.pruneJob = null; } } startStaleSeriesCleanupJob() { this.stopStaleSeriesCleanupJob(); if (!this.isSqliteReady()) return; this.staleSeriesCleanupJob = setInterval(async () => { if (this.shouldSkipMaintenance()) { return; } try { await this.runWithMaintenanceLock('stale-cleanup', async () => { const cutoff = Date.now() - SqliteHistoryStorageService.STALE_SERIES_AGE_MS; this.logger.debug(`[SERIES STORAGE] Running scheduled stale series cleanup (cutoff: ${new Date(cutoff).toISOString()})`); const deleted = await this.deleteStaleSeries(cutoff); if (deleted > 0) { this.logger.debug(`[SERIES STORAGE] Deleted ${deleted} series not reconciled in the last 6 months`); } }); } catch (err) { this.logger.error(`[SERIES STORAGE] Stale series cleanup failed: ${err?.message ?? err}`); } }, SqliteHistoryStorageService.EIGHT_HOURS_INTERVAL); this.staleSeriesCleanupJob.unref?.(); } stopStaleSeriesCleanupJob() { if (this.staleSeriesCleanupJob) { clearInterval(this.staleSeriesCleanupJob); this.staleSeriesCleanupJob = null; } } shouldSkipMaintenance() { if (!this.isSqliteReady() || !this.db) { return true; } if (this.maintenanceInProgress || this.flushInProgress) { return true; } if (this.pendingRows.length > 0) { return true; } return false; } async runWithMaintenanceLock(label, task) { if (this.maintenanceInProgress) { this.logger.debug(`[SERIES STORAGE] Skipping ${label} (maintenance already running)`); return; } this.maintenanceInProgress = true; const startedAt = Date.now(); try { await task(); const elapsedMs = Date.now() - startedAt; this.logger.debug(`[SERIES STORAGE] ${label} completed in ${elapsedMs}ms`); } finally { this.maintenanceInProgress = false; } } async createCoreTables() { await this.runSql(` CREATE TABLE IF NOT EXISTS history_samples ( series_id TEXT, dataset_uuid TEXT, owner_widget_uuid TEXT, path TEXT, context TEXT, source TEXT, ts_ms INTEGER, value REAL ) `); await this.runSql(` CREATE TABLE IF NOT EXISTS history_series ( series_id TEXT NOT NULL, dataset_uuid TEXT NOT NULL, owner_widget_uuid TEXT NOT NULL, owner_widget_selector TEXT, path TEXT NOT NULL, source TEXT, context TEXT, time_scale TEXT, period INTEGER, retention_duration_ms INTEGER, sample_time INTEGER, enabled INTEGER, methods_json TEXT, reconcile_ts INTEGER, PRIMARY KEY (series_id) ) `); } async insertRows(rows) { if (!this.db || rows.length === 0) { return; } const valuesSql = rows .map(sample => `(${this.escape(sample.seriesId)}, ${this.escape(sample.datasetUuid)}, ${this.escape(sample.ownerWidgetUuid)}, ${this.escape(sample.path)}, ${this.escape(sample.context)}, ${this.escape(sample.source)}, ${Math.trunc(sample.timestamp)}, ${Number(sample.value)})`) .join(',\n'); const sql = ` INSERT INTO history_samples ( series_id, dataset_uuid, owner_widget_uuid, path, context, source, ts_ms, value ) VALUES ${valuesSql} `; this.db.exec('BEGIN'); try { await this.runSql(sql); this.db.exec('COMMIT'); } catch (error) { this.db.exec('ROLLBACK'); throw error; } } async runSql(sql) { if (!this.db) { throw new Error('node:sqlite database is not initialized'); } this.db.exec(sql); } async querySql(sql) { if (!this.db) { throw new Error('node:sqlite database is not initialized'); } const statement = this.db.prepare(sql); return statement.all(); } async selectRowsForPaths(paths, context, fromMs, toMs) { const rowsByPath = new Map(); if (paths.length === 0) { return rowsByPath; } const pathFilter = paths.map(path => this.escape(path)).join(', '); const sql = ` SELECT path, ts_ms, value FROM history_samples WHERE context = ${this.escape(context)} AND path IN (${pathFilter}) AND ts_ms >= ${Math.trunc(fromMs)} AND ts_ms <= ${Math.trunc(toMs)} ORDER BY path ASC, ts_ms ASC `; const rows = await this.querySql(sql); rows.forEach(row => { const list = rowsByPath.get(row.path) ?? []; list.push({ ts_ms: Number(row.ts_ms), value: Number(row.value) }); rowsByPath.set(row.path, list); }); return rowsByPath; } parseRequestedPaths(paths) { return String(paths) .split(',') .map(item => item.trim()) .filter(Boolean) .map(raw => { const [pathToken, maybeMethod, maybePeriod] = raw.split(':'); const path = this.normalizePathIdentifier(pathToken); const method = this.parseMethod(maybeMethod); const parsedPeriod = maybePeriod !== undefined ? Number(maybePeriod) : undefined; return { path, method, period: Number.isFinite(parsedPeriod) ? parsedPeriod : undefined }; }); } parseMethod(value) { if (!value) return undefined; const normalized = value.toLowerCase(); if (normalized === 'min' || normalized === 'max' || normalized === 'avg' || normalized === 'sma' || normalized === 'ema') { return normalized; } return undefined; } normalizePathIdentifier(path) { const trimmed = String(path).trim(); if (!trimmed) { return ''; } if (trimmed.startsWith('vessels.self.')) { return trimmed.slice('vessels.self.'.length); } if (trimmed.startsWith('self.')) { return trimmed.slice('self.'.length); } return trimmed; } resolveRange(nowMs, from, to, duration) { const toMs = to ? Date.parse(to) : nowMs; if (!Number.isFinite(toMs)) { throw new Error('Invalid to date-time. Expected an ISO 8601 date-time string.'); } const fromMs = from ? Date.parse(from) : toMs - this.parseDurationMs(duration); if (!Number.isFinite(fromMs)) { throw new Error('Invalid from date-time. Expected an ISO 8601 date-time string.'); } return { fromMs, toMs }; } parseDurationMs(duration) { if (duration === undefined || duration === null) { return 60 * 60_000; } if (typeof duration === 'number' && Number.isFinite(duration) && duration > 0) { return duration; } if (typeof duration === 'number') { throw new Error('Invalid duration. Expected a positive number of milliseconds or an ISO 8601 duration (e.g. PT10M).'); } const value = String(duration).trim(); if (/^\d+(\.\d+)?$/.test(value)) { const parsedMs = Number(value); if (Number.isFinite(parsedMs) && parsedMs > 0) { return parsedMs; } throw new Error('Invalid duration. Expected a positive number of milliseconds or an ISO 8601 duration (e.g. PT10M).'); } const iso = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/i.exec(value); if (!iso) { throw new Error('Invalid duration. Expected a positive number of milliseconds or an ISO 8601 duration (e.g. PT10M).'); } const hours = Number(iso[1] || 0); const minutes = Number(iso[2] || 0); const seconds = Number(iso[3] || 0); const totalMs = (((hours * 60) + minutes) * 60 + seconds) * 1000; if (totalMs <= 0) { throw new Error('Invalid duration. Expected a positive number of milliseconds or an ISO 8601 duration (e.g. PT10M).'); } return totalMs; } resolveResolutionMs(resolution) { if (resolution === undefined || resolution === null) { return 0; } if (typeof resolution === 'number') { if (!Number.isFinite(resolution) || resolution <= 0) { throw new Error('Invalid resolution. Expected a positive number of seconds or an ISO 8601 duration (e.g. PT10S).'); } return Math.max(1, Math.trunc(resolution * 1000)); } const value = String(resolution).trim(); if (!value) { throw new Error('Invalid resolution. Expected a positive number of seconds or an ISO 8601 duration (e.g. PT10S).'); } if (/^\d+(\.\d+)?$/.test(value)) { const parsedSeconds = Number(value); if (!Number.isFinite(parsedSeconds) || parsedSeconds <= 0) { throw new Error('Invalid resolution. Expected a positive number of seconds or an ISO 8601 duration (e.g. PT10S).'); } return Math.max(1, Math.trunc(parsedSeconds * 1000)); } try { return this.parseDurationMs(value); } catch { throw new Error('Invalid resolution. Expected a positive number of seconds or an ISO 8601 duration (e.g. PT10S).'); } } applyMethod(request, rows) { if (rows.length === 0) return []; const method = request.method ?? 'avg'; if (method === 'min' || method === 'max' || method === 'avg') { return rows.map(entry => ({ timestamp: Number(entry.ts_ms), value: Number(entry.value) })); } if (method === 'sma') { const period = Math.max(1, request.period ?? 5); return rows.map((entry, index) => { const start = Math.max(0, index - period + 1); const window = rows.slice(start, index + 1); const sum = window.reduce((acc, item) => acc + Number(item.value), 0); return { timestamp: Number(entry.ts_ms), value: sum / window.length }; }); } const period = Math.max(1, request.period ?? 5); const multiplier = 2 / (period + 1); let previous = null; return rows.map(entry => { const value = Number(entry.value); if (previous === null) { previous = value; } else { previous = ((value - previous) * multiplier) + previous; } return { timestamp: Number(entry.ts_ms), value: previous }; }); } downsampleIfNeeded(values, resolutionMs, method) { if (resolutionMs <= 0 || values.length === 0) { return values; } const buckets = new Map(); values.forEach(entry => { if (!Number.isFinite(entry.value)) { return; } const bucket = Math.floor(entry.timestamp / resolutionMs) * resolutionMs; const list = buckets.get(bucket) ?? []; list.push(entry.value); buckets.set(bucket, list); }); return Array.from(buckets.entries()) .sort((left, right) => left[0] - right[0]) .map(([timestamp, list]) => { const value = this.aggregateBucket(list, method); return { timestamp, value }; }); } aggregateBucket(values, method) { if (values.length === 0) { return null; } if (method === 'min') { return Math.min(...values); } if (method === 'max') { return Math.max(...values); } const sum = values.reduce((acc, value) => acc + value, 0); return sum / values.length; } escape(value) { return `'${String(value).replace(/'/g, "''")}'`; } nullableString(value) { if (value === undefined || value === null || value === '') { return 'NULL'; } return this.escape(value); } nullableNumber(value) { if (value === undefined || value === null || !Number.isFinite(value)) { return 'NULL'; } return String(Math.trunc(value)); } parseMethods(value) { if (!value) return undefined; try { const parsed = JSON.parse(value); if (!Array.isArray(parsed)) return undefined; const methods = parsed.filter(entry => entry === 'min' || entry === 'max' || entry === 'avg' || entry === 'sma' || entry === 'ema'); return methods.length > 0 ? methods : undefined; } catch { return undefined; } } toNumberOrUndefined(value) { if (value === undefined || value === null) { return undefined; } if (typeof value === 'bigint') { return Number(value); } const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; } toBoolean(value) { if (typeof value === 'boolean') { return value; } if (typeof value === 'bigint') { return value !== 0n; } if (typeof value === 'number') { return value !== 0; } if (typeof value === 'string') { const normalized = value.trim().toLowerCase(); if (normalized === 'true' || normalized === '1') { return true; } if (normalized === 'false' || normalized === '0') { return false; } } return Boolean(value); } } exports.SqliteHistoryStorageService = SqliteHistoryStorageService;