UNPKG

signalk-parquet

Version:

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

1,402 lines (1,292 loc) 99.7 kB
import { Router, Request, Response } from 'express'; import { AggregateMethod, DataResult, FromToContextRequest, PathSpec, } from './HistoryAPI-types'; import { ZonedDateTime, ZoneOffset, ZoneId } from '@js-joda/core'; import { Context, Path, Timestamp } from '@signalk/server-api'; import { ParamsDictionary } from 'express-serve-static-core'; import { ParsedQs } from 'qs'; import { DuckDBPool } from './utils/duckdb-pool'; import path from 'path'; import { getAvailablePathsArray, getAvailablePathsForTimeRange, } from './utils/path-discovery'; import { getCachedPaths, setCachedPaths, getCachedContexts, setCachedContexts, } from './utils/path-cache'; import { getAvailableContextsForTimeRange, getContextsInSpatialFilter, } from './utils/context-discovery'; import { getPathComponentSchema, ComponentInfo } from './utils/schema-cache'; import { ConcurrencyLimiter } from './utils/concurrency-limiter'; import { CONCURRENCY } from './config/cache-defaults'; import { SQLiteBufferInterface } from './types'; import { HivePathBuilder, AggregationTier } from './utils/hive-path-builder'; import { AutoDiscoveryService, AutoDiscoveryResult, } from './services/auto-discovery'; import { SpatialFilter, parseSpatialParams, buildSpatialSqlClause, isPositionPath, } from './utils/spatial-queries'; import { calculateDistance } from './utils/geo-calculator'; import { buildBufferScalarSubquery, buildBufferObjectSubquery, } from './utils/buffer-sql-builder'; import { stageBufferTable } from './utils/buffer-staging'; import { parseDurationToMillis, parseResolutionToMillis, InvalidResolutionError, } from './utils/duration-parser'; import { isAngularPath } from './utils/angular-paths'; import { PathRetentionRule, RetentionRuleSet } from './utils/retention-rules'; import { parsePathFilters, buildParquetFilterClause, availableFilterColumns, filterEcho, FILTER_DELIMITERS, } from './utils/path-filters'; // Allowed characters in the `paths` query parameter: path/aggregate tokens // (alphanumerics, `.,:_`), the inline-filter delimiters from PATH_FILTER_DEFS, // and `-`. Delimiters are escaped for use inside a character class; `-` is kept // last so it stays a literal. const PATHS_SANITIZER = new RegExp( `[^0-9a-z.,:_${FILTER_DELIMITERS.replace(/[\\\]^-]/g, '\\$&')}-]`, 'gi' ); export function registerHistoryApiRoute( router: Pick<Router, 'get'>, selfId: string, dataDir: string, debug: (k: string) => void, app: any, sqliteBuffer?: SQLiteBufferInterface, autoDiscoveryService?: AutoDiscoveryService, s3Config?: S3QueryConfig, pathRetentionOverrides?: PathRetentionRule[] ) { const historyApi = new HistoryAPI( selfId, dataDir, sqliteBuffer, autoDiscoveryService, s3Config, pathRetentionOverrides ); // Handler for values endpoint const handleValues = (req: Request, res: Response) => { const { from, to, context, spatialFilter } = getRequestParams( req as FromToContextRequest, selfId ); historyApi.getValues( context, from, to, spatialFilter, app, debug, req, res ); }; // V1 route: Direct routes with all signalk-parquet extensions // (spatial filtering, timezone conversion, moving averages, etc.) router.get('/signalk/v1/history/values', handleValues); // Handler for contexts endpoint const handleContexts = async (req: Request, res: Response) => { try { // Check if time range parameters are provided const hasTimeParams = req.query.duration || req.query.from || req.query.to; if (hasTimeParams) { // Time-range-aware: return only contexts with data in the specified time range const { from, to } = getRequestParams( req as FromToContextRequest, selfId ); // Check cache first let contexts = getCachedContexts(from, to); if (!contexts) { // Cache miss - query the parquet files contexts = await getAvailableContextsForTimeRange(dataDir, from, to); // Cache the result setCachedContexts(from, to, contexts); } res.json(contexts); } else { // No time range specified: return only self context (legacy behavior) res.json([`vessels.${selfId}`] as Context[]); } } catch (error) { debug(`Error in /contexts: ${error}`); res.status(500).json({ error: (error as Error).message }); } }; // V1 route: Direct routes with extensions (time-range-aware context discovery) router.get('/signalk/v1/history/contexts', handleContexts); // Note: V2 routes (/signalk/v2/api/history/*) are handled by the registered // HistoryApi provider in history-provider.ts via app.registerHistoryApiProvider() // Handler for paths endpoint const handlePaths = async (req: Request, res: Response) => { try { // Check if time range parameters are provided const hasTimeParams = req.query.duration || req.query.from || req.query.to; if (hasTimeParams) { // Time-range-aware: return only paths with data in the specified time range const { from, to, context } = getRequestParams( req as FromToContextRequest, selfId ); // Check cache first let paths = getCachedPaths(context, from, to); if (!paths) { // Cache miss - query the parquet files paths = await getAvailablePathsForTimeRange( dataDir, context, from, to ); // Cache the result setCachedPaths(context, from, to, paths); } res.json(paths); } else { // No time range specified: return all available paths (legacy behavior) const context = req.query.context ? getContext(req.query.context as string, selfId) : undefined; const paths = getAvailablePathsArray(dataDir, app, context); res.json(paths); } } catch (error) { debug(`Error in /paths: ${error}`); res.status(500).json({ error: (error as Error).message }); } }; // V1 route: Direct routes with extensions (time-range-aware path discovery) router.get('/signalk/v1/history/paths', handlePaths); // Also register as plugin-style routes for testing router.get('/api/history/values', (req: Request, res: Response) => { const { from, to, context, spatialFilter } = getRequestParams( req as FromToContextRequest, selfId ); historyApi.getValues( context, from, to, spatialFilter, app, debug, req, res ); }); router.get('/api/history/contexts', async (req: Request, res: Response) => { try { // Check if time range parameters are provided const hasTimeParams = req.query.duration || req.query.from || req.query.to; if (hasTimeParams) { // Time-range-aware: return only contexts with data in the specified time range const { from, to } = getRequestParams( req as FromToContextRequest, selfId ); // Check cache first let contexts = getCachedContexts(from, to); if (!contexts) { // Cache miss - query the parquet files contexts = await getAvailableContextsForTimeRange(dataDir, from, to); // Cache the result setCachedContexts(from, to, contexts); } res.json(contexts); } else { // No time range specified: return only self context (legacy behavior) res.json([`vessels.${selfId}`] as Context[]); } } catch (error) { debug(`Error in /api/history/contexts: ${error}`); res.status(500).json({ error: (error as Error).message }); } }); router.get( '/api/history/contexts/spatial', async (req: Request, res: Response) => { try { const { from, to } = getRequestParams( req as FromToContextRequest, selfId ); const spatialFilter = parseSpatialParams( req.query.bbox as string | undefined, req.query.radius as string | undefined ); if (!spatialFilter) { res.status(400).json({ error: 'bbox (west,south,east,north) or radius (lon,lat,meters) is required', }); return; } const contexts = await getContextsInSpatialFilter( dataDir, from, to, spatialFilter ); res.json(contexts); } catch (error) { debug(`Error in /api/history/contexts/spatial: ${error}`); res.status(500).json({ error: (error as Error).message }); } } ); router.get('/api/history/paths', async (req: Request, res: Response) => { try { // Check if time range parameters are provided const hasTimeParams = req.query.duration || req.query.from || req.query.to; if (hasTimeParams) { // Time-range-aware: return only paths with data in the specified time range const { from, to, context } = getRequestParams( req as FromToContextRequest, selfId ); // Check cache first let paths = getCachedPaths(context, from, to); if (!paths) { // Cache miss - query the parquet files paths = await getAvailablePathsForTimeRange( dataDir, context, from, to ); // Cache the result setCachedPaths(context, from, to, paths); } res.json(paths); } else { // No time range specified: return all available paths (legacy behavior) const context = req.query.context ? getContext(req.query.context as string, selfId) : undefined; const paths = getAvailablePathsArray(dataDir, app, context); res.json(paths); } } catch (error) { debug(`Error in /api/history/paths: ${error}`); res.status(500).json({ error: (error as Error).message }); } }); } const getRequestParams = ({ query }: FromToContextRequest, selfId: string) => { try { let from: ZonedDateTime; let to: ZonedDateTime; // ============================================================================ // STANDARD SIGNALK TIME RANGE PATTERNS // ============================================================================ // Pattern 1: duration only → query back from now if (query.duration && !query.from && !query.to) { const durationMs = parseDuration(query.duration); to = ZonedDateTime.now(ZoneOffset.UTC); from = to.minusNanos(durationMs * 1000000); } // Pattern 2: from + duration → query forward from start else if (query.from && query.duration && !query.to) { from = parseDateTime(query.from); const durationMs = parseDuration(query.duration); to = from.plusNanos(durationMs * 1000000); } // Pattern 3: to + duration → query backward to end else if (query.to && query.duration && !query.from) { to = parseDateTime(query.to); const durationMs = parseDuration(query.duration); from = to.minusNanos(durationMs * 1000000); } // Pattern 4: from only → from start to now else if (query.from && !query.duration && !query.to) { from = parseDateTime(query.from); to = ZonedDateTime.now(ZoneOffset.UTC); } // Pattern 5: from + to → specific range else if (query.from && query.to && !query.duration) { from = parseDateTime(query.from); to = parseDateTime(query.to); } else { throw new Error( 'Invalid time range parameters. Use one of the following patterns:\n' + ' 1. ?duration=1h (query back from now)\n' + ' 2. ?from=2025-08-01T00:00:00Z&duration=1h (query forward)\n' + ' 3. ?to=2025-08-01T12:00:00Z&duration=1h (query backward)\n' + ' 4. ?from=2025-08-01T00:00:00Z (from start to now)\n' + ' 5. ?from=2025-08-01T00:00:00Z&to=2025-08-02T00:00:00Z (specific range)' ); } const context: Context = getContext(query.context, selfId); const spatialFilter = parseSpatialParams(query.bbox, query.radius); return { from, to, context, spatialFilter }; } catch (e: unknown) { console.error('Full error details:', e); throw new Error( `Error extracting query parameters from ${JSON.stringify(query)}: ${e instanceof Error ? e.stack : e}` ); } }; // Parse duration string (supports ISO 8601, integer seconds, shorthand) function parseDuration(duration: string | undefined): number { if (!duration) { throw new Error('Duration parameter is required'); } return parseDurationToMillis(duration); } // Check if datetime string has timezone information function hasTimezoneInfo(dateTimeStr: string): boolean { // Check for 'Z' at the end, or '+'/'-' followed by timezone offset pattern return ( dateTimeStr.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(dateTimeStr) || /[+-]\d{4}$/.test(dateTimeStr) ); } // Parse datetime string per ISO 8601: // - Bare timestamps (no Z, no offset) → local time, converted to UTC // - Timestamps with Z or offset → parsed as-is function parseDateTime(dateTimeStr: string): ZonedDateTime { // Normalize the datetime string to include seconds if missing let normalizedStr = dateTimeStr; if (dateTimeStr.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/)) { // Add seconds if only HH:MM is provided normalizedStr = dateTimeStr + ':00'; } if (hasTimezoneInfo(normalizedStr)) { // Has timezone info (Z or offset), parse as-is and convert to UTC return ZonedDateTime.parse(normalizedStr).withZoneSameInstant( ZoneOffset.UTC ); } else { // No timezone info — per ISO 8601, treat as local time and convert to UTC try { // JavaScript Date constructor treats ISO strings without timezone as local time const localDate = new Date(normalizedStr); if (isNaN(localDate.getTime())) { throw new Error('Invalid date'); } // Convert to UTC ISO string and parse with ZonedDateTime const utcIsoString = localDate.toISOString(); return ZonedDateTime.parse(utcIsoString); } catch (e) { throw new Error( `Unable to parse datetime '${dateTimeStr}': ${e}. Use format like '2025-08-13T08:00:00' or '2025-08-13T08:00:00Z'` ); } } } function getContext( contextFromQuery: string | undefined, selfId: string ): Context { if ( !contextFromQuery || contextFromQuery === 'vessels.self' || contextFromQuery === 'self' ) { return `vessels.${selfId}` as Context; } return contextFromQuery.replace(/ /gi, '') as Context; } type QuerySource = 'local' | 's3' | 'hybrid' | 'auto'; export interface S3QueryConfig { enabled: boolean; bucket: string; keyPrefix: string; region: string; } /** * Stream a DataResult to the response as a single JSON document, * progressively, instead of buffering it via res.json(). Output is * byte-equivalent to the prior res.json(result) path: same field * order (context, range, values, data, units?, meta?), same UTC-> * local timestamp conversion on the range and on each row[0]. * * Wrapper construction: build the wrapper object with a `data: []` * placeholder, JSON.stringify it once, then split at that placeholder. * The two halves frame the row-by-row writes. This avoids hand-rolling * `,` / `{}` / `[]` framing tokens. * Input wrapper -> '{...,"data":[],"units":{...}}' * prefix -> '{...,"data":[' * suffix -> ']' + ',"units":{...}}' * * Backpressure: if res.write returns false the loop awaits 'drain' * before continuing, so a slow client cannot grow Node's internal * write buffer beyond the high-water mark. The wait races against * 'close' so a client that disconnects mid-flight does not hang the * stream forever. * * Cancellation: a 'close' listener on res sets a flag so the loop * breaks early on client disconnect. The DuckDB result has already * been materialised before this function is called, so cancellation * is purely about cutting the write loop short. * * Logging: on success or post-headers failure, emits one structured * line via the optional `log` callback with row count, byte count * and wall-clock duration. Operators chasing "endpoint feels slow" * have a single line to grep for. */ async function streamDataResult( res: Response, result: DataResult, log?: (msg: string) => void ): Promise<void> { const startMs = Date.now(); let rowsWritten = 0; let bytesWritten = 0; let clientGone = false; const onClose = (): void => { clientGone = true; }; // A mid-stream socket failure (ECONNRESET / EPIPE) emits 'error' on res. // With no listener Node rethrows it as an uncaughtException and takes down // the whole server process. Treat it like a disconnect: flip the flag so the // write loop unwinds instead of writing into a broken socket. const onError = (): void => { clientGone = true; }; res.on('close', onClose); res.on('error', onError); try { res.setHeader('Content-Type', 'application/json; charset=utf-8'); // Streaming responses must not be cached by an intermediary. // X-Accel-Buffering is nginx-specific (no-op elsewhere) and tells // nginx not to buffer the response body, so chunks reach the // client as we write them rather than after res.end(). res.setHeader('Cache-Control', 'no-store'); res.setHeader('X-Accel-Buffering', 'no'); const localRange = { from: utcToLocalTimestamp(result.range.from), to: utcToLocalTimestamp(result.range.to), }; // Build the wrapper with `data: []` placeholder so JSON.stringify // produces the framing for us. Optional fields (units, meta) come // after data per DataResult declaration order; insertion order in // a plain object is preserved by JSON.stringify. const wrapper: Record<string, unknown> = { context: result.context, range: localRange, values: result.values, data: [], }; if (result.units) wrapper.units = result.units; if (result.meta) wrapper.meta = result.meta; const wrapperJson = JSON.stringify(wrapper); const marker = '"data":[]'; const markerIdx = wrapperJson.indexOf(marker); if (markerIdx === -1) { // The wrapper is constructed locally with data:[] as a literal // field; this branch only fires if a future JSON.stringify ever // omits empty arrays. Cheaper to assert than to debug a silent // wire-format break. throw new Error('streamDataResult: wrapper marker not found'); } const prefix = wrapperJson.slice(0, markerIdx) + '"data":['; const suffix = ']' + wrapperJson.slice(markerIdx + marker.length); const writeOrAwait = async (chunk: string): Promise<void> => { bytesWritten += Buffer.byteLength(chunk, 'utf8'); if (!res.write(chunk)) { // Wait for drain (kernel buffer flushed), close (client gone), or // error (socket broke). Whichever fires first wins; all three unhook // the others so we don't leak handlers across the loop, and we never // await a 'drain' that a dead socket will never emit. await new Promise<void>(resolve => { let settled = false; const finish = (): void => { if (settled) return; settled = true; res.off('drain', finish); res.off('close', finish); res.off('error', finish); resolve(); }; res.once('drain', finish); res.once('close', finish); res.once('error', finish); }); } }; await writeOrAwait(prefix); const data = result.data; for (let i = 0; i < data.length; i++) { if (clientGone) break; const row = data[i]; // Allocate a converted row instead of mutating in place. The // input is logically owned by the caller, and a future second // caller (audit log, dual-format response) would silently see // local time where it expected UTC. const converted = row.slice() as typeof row; converted[0] = utcToLocalTimestamp(row[0]); const rowChunk = (i > 0 ? ',' : '') + JSON.stringify(converted); await writeOrAwait(rowChunk); rowsWritten++; } if (clientGone) { log?.( `streamDataResult: client disconnected after ${rowsWritten}/${data.length} rows, ${bytesWritten} bytes, ${Date.now() - startMs}ms` ); return; } await writeOrAwait(suffix); res.end(); log?.( `streamDataResult: rows=${rowsWritten} bytes=${bytesWritten} duration=${Date.now() - startMs}ms` ); } finally { res.off('close', onClose); res.off('error', onError); } } /** * Buffered UTC->local conversion of a full DataResult. Used by callers * that pass a non-streaming response (e.g. internal mocks that only * implement res.json) so they receive the same locally-converted shape * the streaming path produces. */ function convertToLocalTime(result: DataResult): DataResult { return { ...result, range: { from: utcToLocalTimestamp(result.range.from), to: utcToLocalTimestamp(result.range.to), }, data: result.data.map(row => { const newRow = row.slice() as typeof row; newRow[0] = utcToLocalTimestamp(row[0]); return newRow; }), }; } /** * Returns true when `res` exposes the Node stream methods streamDataResult * relies on. The plugin's HTTP routes pass a real Express Response, but * historical-streaming.ts reuses getValues with a mock that only implements * res.json / res.status().json — those callers go through the buffered path. */ function canStreamResponse(res: Response): boolean { return ( typeof (res as Partial<Response>).write === 'function' && typeof (res as Partial<Response>).end === 'function' && typeof (res as Partial<Response>).setHeader === 'function' && typeof (res as Partial<Response>).on === 'function' ); } /** * Convert a UTC timestamp string to server-local time with offset suffix. * e.g. "2026-03-06T19:00:00Z" → "2026-03-06T14:00:00-05:00" (EST) */ function utcToLocalTimestamp(utcTs: Timestamp): Timestamp { try { const zdt = ZonedDateTime.parse(utcTs).withZoneSameInstant( ZoneId.systemDefault() ); // ZonedDateTime.toString() appends "[SYSTEM]" zone ID — strip it return zdt.toString().replace(/\[.*\]$/, '') as Timestamp; } catch { // If parsing fails (e.g. already has offset or unusual format), try via Date const d = new Date(utcTs); if (isNaN(d.getTime())) return utcTs; const offsetMin = d.getTimezoneOffset(); const local = new Date(d.getTime() - offsetMin * 60000); const sign = offsetMin <= 0 ? '+' : '-'; const absH = String(Math.floor(Math.abs(offsetMin) / 60)).padStart(2, '0'); const absM = String(Math.abs(offsetMin) % 60).padStart(2, '0'); return (local.toISOString().replace('Z', '') + sign + absH + ':' + absM) as Timestamp; } } // A response `values` entry: path + aggregate method, optional smoothing // metadata, plus any echoed inline-filter fields (e.g. `sourceRef`). The index // signature keeps it open to new filters without a type change here. interface HistoryValueEntry { path: Path; method: AggregateMethod; smoothing?: string; window?: number; [field: string]: unknown; } export class HistoryAPI { private sqliteBuffer?: SQLiteBufferInterface; private hivePathBuilder: HivePathBuilder; private autoDiscoveryService?: AutoDiscoveryService; private s3Config?: S3QueryConfig; // Mirrors AggregationService's rule set so the read path can fall // back to tier=raw for skipAggregation paths. The aggregation // pipeline doesn't write 5s/60s/1h files for those, so a higher- // resolution query against an aggregated tier would otherwise return // empty. private retentionRules: RetentionRuleSet; constructor( private selfId: string, private dataDir: string, sqliteBuffer?: SQLiteBufferInterface, autoDiscoveryService?: AutoDiscoveryService, s3Config?: S3QueryConfig, pathRetentionOverrides?: PathRetentionRule[] ) { this.sqliteBuffer = sqliteBuffer; this.hivePathBuilder = new HivePathBuilder(); this.autoDiscoveryService = autoDiscoveryService; this.s3Config = s3Config; this.retentionRules = new RetentionRuleSet(pathRetentionOverrides || []); } /** * Set S3 configuration for federated queries */ setS3Config(config: S3QueryConfig | undefined): void { this.s3Config = config; } /** * Set the auto-discovery service */ setAutoDiscoveryService(service: AutoDiscoveryService | undefined): void { this.autoDiscoveryService = service; } /** * Set the SQLite buffer for federated queries */ setSqliteBuffer(buffer: SQLiteBufferInterface | undefined): void { this.sqliteBuffer = buffer; } /** * Auto-select the optimal tier based on requested resolution * Returns undefined to use raw/flat data, or a tier name for aggregated data * * Logic: * - resolution >= 1 hour (3600000ms) → use 1h tier * - resolution >= 1 minute (60000ms) → use 60s tier * - resolution >= 5 seconds (5000ms) → use 5s tier * - resolution < 5 seconds → use raw data (no tier) * * Falls back through tiers if preferred tier doesn't exist */ private selectOptimalTier( resolutionMillis: number ): AggregationTier | undefined { // eslint-disable-next-line @typescript-eslint/no-require-imports const fs = require('fs'); // Determine preferred tier based on resolution let preferredTiers: AggregationTier[] = []; if (resolutionMillis >= 3600000) { preferredTiers = ['1h', '60s', '5s']; } else if (resolutionMillis >= 60000) { preferredTiers = ['60s', '5s']; } else if (resolutionMillis >= 5000) { preferredTiers = ['5s']; } else { // Use raw data for sub-5-second resolution return undefined; } // Check which tiers exist and return the best available for (const tier of preferredTiers) { const tierPath = path.join(this.dataDir, `tier=${tier}`); try { if (fs.existsSync(tierPath)) { return tier; } } catch { // Directory doesn't exist or not accessible } } // No aggregated tiers available, use raw data return undefined; } /** * Get timestamps where vessel position was within the spatial filter * Used to correlate non-position paths with spatial filtering */ private async getSpatialTimestamps( context: Context, from: ZonedDateTime, to: ZonedDateTime, timeResolutionMillis: number, spatialFilter: SpatialFilter, positionPath: string, _tier: AggregationTier | undefined, _querySource: QuerySource, debug: (k: string) => void ): Promise<Set<string>> { const timestamps = new Set<string>(); // Build file path for raw position data const sanitizedContext = this.hivePathBuilder.sanitizeContext(context); const sanitizedPath = this.hivePathBuilder.sanitizePath(positionPath); const localFilePath = path.join( this.dataDir, 'tier=raw', `context=${sanitizedContext}`, `path=${sanitizedPath}`, '**', '*.parquet' ); const fromIso = from.toInstant().toString(); const toIso = to.toInstant().toString(); try { const hasBuffer = DuckDBPool.isSQLiteBufferInitialized(); const connection = await DuckDBPool.getConnection(); try { // Bucket-lookup approach: instead of scanning all raw position data, // bucket by time resolution, grab FIRST lat/lon per bucket, then filter by bbox/radius. // This reads far less data than a full scan with spatial SQL. const bucketExpr = `strftime(DATE_TRUNC('seconds', EPOCH_MS(CAST(FLOOR(EPOCH_MS(signalk_timestamp::TIMESTAMP) / ${timeResolutionMillis}) * ${timeResolutionMillis} AS BIGINT)) ), '%Y-%m-%dT%H:%M:%SZ')`; // Build FROM: parquet UNION ALL buffer const parquetFrom = `SELECT signalk_timestamp, value_latitude, value_longitude FROM ( SELECT * FROM read_parquet('${localFilePath}', union_by_name=true, filename=true) WHERE filename NOT LIKE '%/processed/%' AND filename NOT LIKE '%/quarantine/%' AND filename NOT LIKE '%/failed/%' AND filename NOT LIKE '%/repaired/%')`; let fromSource = `(${parquetFrom})`; if (hasBuffer && this.sqliteBuffer) { const bufferTableCols = this.sqliteBuffer.getTableColumns(positionPath); const stagedTable = await stageBufferTable( connection, this.sqliteBuffer, String(context), positionPath, fromIso, toIso, debug ); const bufferSubquery = stagedTable ? buildBufferObjectSubquery( stagedTable, context, fromIso, toIso, new Map([ [ 'latitude', { name: 'latitude', columnName: 'value_latitude', dataType: 'numeric' as const, }, ], [ 'longitude', { name: 'longitude', columnName: 'value_longitude', dataType: 'numeric' as const, }, ], ]), bufferTableCols ) : null; if (bufferSubquery) { fromSource = `(${parquetFrom} UNION ALL SELECT signalk_timestamp, TRY_CAST(value_latitude AS DOUBLE) as value_latitude, TRY_CAST(value_longitude AS DOUBLE) as value_longitude FROM ${bufferSubquery})`; } } const query = ` SELECT ${bucketExpr} as timestamp, FIRST(TRY_CAST(value_latitude AS DOUBLE)) as lat, FIRST(TRY_CAST(value_longitude AS DOUBLE)) as lon FROM ${fromSource} WHERE signalk_timestamp >= '${fromIso}' AND signalk_timestamp < '${toIso}' AND TRY_CAST(value_latitude AS DOUBLE) IS NOT NULL AND TRY_CAST(value_longitude AS DOUBLE) IS NOT NULL GROUP BY timestamp ORDER BY timestamp `; const result = await connection.runAndReadAll(query); const rows = result.getRowObjects() as Array<{ timestamp: string; lat: number; lon: number; }>; // Filter buckets by spatial filter in JS const { bbox } = spatialFilter; for (const row of rows) { if (row.lat === null || row.lon === null) continue; // Lat check if (row.lat < bbox.south || row.lat > bbox.north) continue; // Lon check (handles 180° meridian crossing) if (bbox.west <= bbox.east) { if (row.lon < bbox.west || row.lon > bbox.east) continue; } else { if (row.lon < bbox.west && row.lon > bbox.east) continue; } // Precise radius check if applicable if ( spatialFilter.type === 'radius' && spatialFilter.centerLat !== undefined && spatialFilter.centerLon !== undefined && spatialFilter.radiusMeters !== undefined ) { const dist = calculateDistance( row.lat, row.lon, spatialFilter.centerLat, spatialFilter.centerLon ); if (dist > spatialFilter.radiusMeters) continue; } timestamps.add(row.timestamp); } debug( `[Spatial Correlation] Bucketed ${rows.length} positions, ${timestamps.size} within spatial filter` ); } finally { connection.disconnectSync(); } } catch (error) { debug(`[Spatial Correlation] Error querying position data: ${error}`); // On error, return empty set (no filtering will occur) } return timestamps; } async getValues( context: Context, from: ZonedDateTime, to: ZonedDateTime, spatialFilter: SpatialFilter | null, app: any, debug: (k: string) => void, // eslint-disable-next-line @typescript-eslint/no-explicit-any req: Request<ParamsDictionary, any, any, ParsedQs, Record<string, any>>, // eslint-disable-next-line @typescript-eslint/no-explicit-any res: Response<any, Record<string, any>> ) { try { // Resolution now in SECONDS (breaking change from v0.7.0) const timeResolutionMillis = req.query.resolution ? parseResolutionToMillis(req.query.resolution as string) : ((to.toEpochSecond() - from.toEpochSecond()) / 500) * 1000; // Strip everything except path/aggregate characters, the filter // delimiters from PATH_FILTER_DEFS, and `-` (common in source // references). This is a coarse injection guard; values are also SQL- // escaped downstream. Adding a delimiter to the registry extends this // automatically. const pathExpressions = ((req.query.paths as string) || '') .replace(PATHS_SANITIZER, '') .split(','); const pathSpecs: PathSpec[] = pathExpressions.map(splitPathExpression); // Auto-select tier based on resolution (provider selects automatically) const tier = this.selectOptimalTier(timeResolutionMillis); if (tier) { debug( `Auto-selected tier=${tier} for resolution=${timeResolutionMillis}ms` ); } // Log spatial filter if present if (spatialFilter) { debug( `Spatial filter: type=${spatialFilter.type}, bbox=[${spatialFilter.bbox.west},${spatialFilter.bbox.south},${spatialFilter.bbox.east},${spatialFilter.bbox.north}]` ); } // Handle position and numeric paths together const positionPath = 'navigation.position'; const allResult = pathSpecs.length ? await this.getNumericValues( context, from, to, timeResolutionMillis, pathSpecs, debug, tier, spatialFilter, 'local', positionPath, app ) : { context, range: { from: from.toString() as Timestamp, to: to.toString() as Timestamp, }, values: [], data: [], }; // Check for auto-discovery on paths with no data debug( `[AutoDiscovery] Checking auto-discovery: service=${!!this.autoDiscoveryService}, pathSpecs.length=${pathSpecs.length}` ); if (this.autoDiscoveryService && pathSpecs.length > 0) { const autoConfiguredPaths: AutoDiscoveryResult[] = []; for (let i = 0; i < pathSpecs.length; i++) { const pathSpec = pathSpecs[i]; // Check if this path has any data in the result // Data array format: [timestamp, value1, value2, ...] // Each path corresponds to a position in the values array const hasData = allResult.data.some(row => { const valueIndex = i + 1; // +1 because index 0 is timestamp return row[valueIndex] !== null && row[valueIndex] !== undefined; }); if (!hasData) { debug( `[AutoDiscovery] No data found for path ${pathSpec.path}, checking auto-discovery` ); const result = await this.autoDiscoveryService.maybeAutoConfigurePath( pathSpec.path as Path, context ); if (result.configured) { autoConfiguredPaths.push(result); debug(`[AutoDiscovery] Auto-configured path: ${pathSpec.path}`); } else { debug( `[AutoDiscovery] Path ${pathSpec.path} not auto-configured: ${result.reason}` ); } } } // Add meta to response if any paths were auto-configured if (autoConfiguredPaths.length > 0) { allResult.meta = { autoConfigured: true, paths: autoConfiguredPaths.map(r => r.path), message: `${autoConfiguredPaths.length} path(s) auto-configured for recording. Data will be available shortly.`, }; } } if (canStreamResponse(res)) { await streamDataResult(res, allResult, debug); } else { // Internal callers (historical-streaming.ts) pass a mock res with // only json()/status().json(); fall back to the buffered shape so // they keep working without an Express Response. res.json(convertToLocalTime(allResult)); } } catch (error) { if (error instanceof InvalidResolutionError) { debug(`Bad request in getValues: ${error.message}`); res.status(400).json({ error: error.message }); return; } // Note: the response may have been streamed, so by the time an // error reaches here, headers may already be on the wire. The two // branches below are deliberately not consolidated into a single // res.status(500) call as in the rest of this module. if (res.headersSent) { // Stream already started -- can't switch to a 500. Destroy the socket // rather than res.end(): ending would close a half-written JSON array // cleanly, so the client would see an orderly 200 indistinguishable // from a complete body. Destroying surfaces a broken transfer instead. // The message is distinct from the pre-stream failure path so an // operator can tell a truncated mid-stream failure apart from it. debug( `Error in getValues after headersSent (truncated response): ${error}` ); res.destroy(); return; } debug(`Error in getValues: ${error}`); res.status(500).json({ error: 'Internal server error', message: error instanceof Error ? error.message : String(error), }); } } async getNumericValues( context: Context, from: ZonedDateTime, to: ZonedDateTime, timeResolutionMillis: number, pathSpecs: PathSpec[], debug: (k: string) => void, tier?: AggregationTier, spatialFilter?: SpatialFilter | null, querySource: QuerySource = 'local', positionPath: string = 'navigation.position', app?: any ): Promise<DataResult> { // Keyed by pathSpecKey(spec), not bare path, so the same path requested // with different sources/aggregates keeps separate series. const allData: { [key: string]: Array<[Timestamp, unknown]> } = {}; const objectPaths = new Set<string>(); // Track which paths are object paths // Convert ZonedDateTime to Date for S3 pattern building const fromDate = new Date(from.toInstant().toString()); const toDate = new Date(to.toInstant().toString()); // If spatial filter is set, get valid timestamps from position data // This allows filtering non-position paths by "when vessel was in this area" let spatialTimestamps: Set<string> | null = null; const hasPositionPath = spatialFilter && pathSpecs.some(ps => isPositionPath(ps.path)); if (spatialFilter) { const hasNonPositionPaths = pathSpecs.some( ps => !isPositionPath(ps.path) ); if (hasNonPositionPaths && !hasPositionPath) { // Position not in requested paths — need a separate scan for correlation debug( `[Spatial Correlation] Querying ${positionPath} for valid timestamps within spatial filter` ); spatialTimestamps = await this.getSpatialTimestamps( context, from, to, timeResolutionMillis, spatialFilter, positionPath, undefined, // Force raw tier — position is an object path, aggregated tiers lack value_latitude/value_longitude querySource, debug ); debug( `[Spatial Correlation] Found ${spatialTimestamps.size} valid timestamps` ); } if (hasPositionPath) { // Position IS in requested paths — use fast bucket+FIRST query for both // position results AND spatial correlation timestamps (one query, no full raw scan) const posPathSpec = pathSpecs.find(ps => isPositionPath(ps.path))!; debug( `[Spatial] Fast bucket query for ${posPathSpec.path} (FIRST lat/lon per bucket + JS filter)` ); const sanitizedCtx = this.hivePathBuilder.sanitizeContext(context); const sanitizedPos = this.hivePathBuilder.sanitizePath( posPathSpec.path ); const posFilePath = path.join( this.dataDir, 'tier=raw', `context=${sanitizedCtx}`, `path=${sanitizedPos}`, '**', '*.parquet' ); const fromIso = from.toInstant().toString(); const toIso = to.toInstant().toString(); try { const hasBuffer = DuckDBPool.isSQLiteBufferInitialized(); const connection = await DuckDBPool.getConnection(); try { const bucketExpr = `strftime(DATE_TRUNC('seconds', EPOCH_MS(CAST(FLOOR(EPOCH_MS(signalk_timestamp::TIMESTAMP) / ${timeResolutionMillis}) * ${timeResolutionMillis} AS BIGINT)) ), '%Y-%m-%dT%H:%M:%SZ')`; // Optional filters for the position path (e.g. one of several GPS // receivers). This fast path is local-only (raw parquet + buffer), // so probe just the local glob for the filter columns. const posAvailable = await availableFilterColumns( connection, [posFilePath], posPathSpec.filters ); const posSourceFilter = buildParquetFilterClause( posPathSpec.filters, posAvailable ); // Build FROM: parquet UNION ALL buffer (for today's unexported data) const parquetFrom = `SELECT signalk_timestamp, value_latitude, value_longitude FROM ( SELECT * FROM read_parquet('${posFilePath}', union_by_name=true, filename=true) WHERE filename NOT LIKE '%/processed/%' AND filename NOT LIKE '%/quarantine/%' AND filename NOT LIKE '%/failed/%' AND filename NOT LIKE '%/repaired/%'${posSourceFilter})`; let fromSource = `(${parquetFrom})`; if (hasBuffer && this.sqliteBuffer) { const bufferTableCols = this.sqliteBuffer.getTableColumns( posPathSpec.path ); const stagedTable = await stageBufferTable( connection, this.sqliteBuffer, String(context), posPathSpec.path, fromIso, toIso, debug ); const bufferSubquery = stagedTable ? buildBufferObjectSubquery( stagedTable, context, fromIso, toIso, new Map([ [ 'latitude', { name: 'latitude', columnName: 'value_latitude', dataType: 'numeric' as const, }, ], [ 'longitude', { name: 'longitude', columnName: 'value_longitude', dataType: 'numeric' as const, }, ], ]), bufferTableCols, posPathSpec.filters ) : null; if (bufferSubquery) { fromSource = `(${parquetFrom} UNION ALL SELECT signalk_timestamp, TRY_CAST(value_latitude AS DOUBLE) as value_latitude, TRY_CAST(value_longitude AS DOUBLE) as value_longitude FROM ${bufferSubquery})`; } } const query = ` SELECT ${bucketExpr} as timestamp, FIRST(TRY_CAST(value_latitude AS DOUBLE)) as lat, FIRST(TRY_CAST(value_longitude AS DOUBLE)) as lon FROM ${fromSource} WHERE signalk_timestamp >= '${fromIso}' AND signalk_timestamp < '${toIso}' AND TRY_CAST(value_latitude AS DOUBLE) IS NOT NULL AND TRY_CAST(value_longitude AS DOUBLE) IS NOT NULL GROUP BY timestamp ORDER BY timestamp `; const result = await connection.runAndReadAll(query); const rows = result.getRowObjects() as Array<{ timestamp: string; lat: number; lon: number; }>; // Filter by spatial and build position results + timestamps const posData: Array<[Timestamp, unknown]> = []; spatialTimestamps = new Set<string>(); const { bbox } = spatialFilter; for (const row of rows) { if (row.lat === null || row.lon === null) continue; let inArea = true; // Lat check if (row.lat < bbox.south || row.lat > bbox.north) inArea = false; // Lon check if (inArea) { if (bbox.west <= bbox.east) { if (row.lon < bbox.west || row.lon > bbox.east) inArea = false; } else { if (row.lon < bbox.west && row.lon > bbox.east) inArea = false; } } // Precise radius check if ( inArea && spatialFilter.type === 'radius' && spatialFilter.centerLat !== undefined && spatialFilter.centerLon !== undefined && spatialFilter.radiusMeters !== undefined ) { const dist = calculateDistance( row.lat, row.lon, spatialFilter.centerLat, spatialFilter.centerLon ); if (dist > spatialFilter.radiusMeters) inArea = false; } if (inArea) { spatialTimestamps.add(row.timestamp); posData.push([ row.timestamp as Timestamp, { latitude: row.lat, longitude: row.lon }, ]); } } allData[pathSpecKey(posPathSpec)] = posData; debug( `[Spatial] Bucketed ${rows.length} positions, ${posData.length} within filter` ); } finally { connection.disconnectSync(); } } catch (error) { debug(`[Spatial] Error in fast position query: ${error}`); allData[pathSpecKey(posPathSpec)] = []; spatialTimestamps = new Set<string>(); } } } // Process each path and collect data with concurrency limiting // Limit concurrent queries to prevent resource exhaustion (configured in cache-defaults) const limiter = new ConcurrencyLimiter(CONCURRENCY.MAX_QUERIES); await limiter.map(pathSpecs, async pathSpec => { try { // Skip position if already handled by fast spatial bucket query if ( hasPositionPath && isPositionPath(pathSpec.path) && allData[pathSpecKey(pathSpec)] ) { return; } // Build file patterns based on query source let localFilePath: string | null = null; let s3FilePath: string | null = null; let effectiveTier = tier || 'raw'; // Honour per-path skipAggregation: AggregationService never wrote // 5s/60s/1h files for these paths, so reading any aggregated tier // would return empty. Fall back to raw — the existing object-path // and string-path overrides further down handle their own special // cases and run after this. if ( effectiveTier !== 'raw' && this.retentionRules.shouldSkipAggregation(pathSpec.path) ) { debug( `Path ${pathSpec.path}: skipAggregation rule matched — overriding tier=${effectiveTier} to raw` ); effectiveTier = 'raw'; } // Inline filters require raw data: aggregated tiers (5s/60s/1h) blend // every source into each bucket and don't carry the source_* columns, // so they cannot be filtered. if (effectiveTier !== 'raw' && pathSpec.filters.length > 0) { debug( `Path ${pathSpec.path}: filter present — overriding tier=${effectiveTier} to raw` ); effectiveTier = 'raw'; } // Always query local first const sanitizedContext = this.hivePathBuilder.sanitizeContext(context); const sanitizedSkPath = this.hivePathBuilder.sanitizePath( pathSpec.path ); localFilePath = path.join( this.dataDir, `tier=${effectiveTier}`, `context=${sanitizedContext}`, `path=${sanitizedSkPath}`, '**', '*.parquet' ); debug(`Querying local Hive tier=${effectiveTier} at: ${localFilePath}`);