signalk-parquet
Version:
Vessel data Parquet file archive with automated value and geospatial triggers. History API compliant with cloud backups and queries.
617 lines (564 loc) • 20.2 kB
text/typescript
/**
* SignalK History API Provider
*
* This module implements the SignalK HistoryApi interface to register
* this plugin as the official history data provider for the SignalK server.
*/
import { Temporal } from '@js-temporal/polyfill';
import { ZonedDateTime, ZoneOffset, Instant } from '@js-joda/core';
import { Context, Timestamp, ServerAPI } from '@signalk/server-api';
import {
HistoryApi,
ValuesRequest,
ValuesResponse,
ContextsRequest,
ContextsResponse,
PathsRequest,
PathsResponse,
PathSpec as SignalKPathSpec,
AggregateMethod,
} from '@signalk/server-api/dist/history';
import {
filtersFromFields,
buildParquetFilterClause,
availableFilterColumns,
filterEcho,
} from './utils/path-filters';
import { getAvailablePathsArray } from './utils/path-discovery';
import { getAvailableContextsForTimeRange } from './utils/context-discovery';
import { DuckDBPool } from './utils/duckdb-pool';
import { getPathComponentSchema } from './utils/schema-cache';
import { HivePathBuilder } from './utils/hive-path-builder';
import { isAngularPath } from './utils/angular-paths';
import {
buildBufferScalarSubquery,
buildBufferObjectSubquery,
} from './utils/buffer-sql-builder';
import { stageBufferTable, BufferStagingSource } from './utils/buffer-staging';
/** The slice of SQLiteBuffer the provider needs: staging plus schema lookups. */
type ProviderBufferSource = BufferStagingSource & {
getKnownPaths(): Set<string>;
getTableColumns(path: string): Set<string> | undefined;
};
/**
* Convert Temporal.Instant or ISO string to ZonedDateTime (UTC)
*/
function temporalToZonedDateTime(
instant: Temporal.Instant | string
): ZonedDateTime {
// Handle ISO string (e.g., "2025-06-06T00:00:00Z")
if (typeof instant === 'string') {
return ZonedDateTime.parse(instant);
}
// Handle Temporal.Instant
const epochMillis = instant.epochMilliseconds;
return ZonedDateTime.ofInstant(
Instant.ofEpochMilli(epochMillis),
ZoneOffset.UTC
);
}
/**
* Convert Temporal.Duration to milliseconds
*/
function durationToMillis(duration: Temporal.Duration | number): number {
if (typeof duration === 'number') {
return duration;
}
return duration.total({ unit: 'milliseconds' });
}
/**
* Parse time range parameters into from/to ZonedDateTime
*/
function parseTimeRange(
params: ValuesRequest | ContextsRequest | PathsRequest
): { from: ZonedDateTime; to: ZonedDateTime } {
const now = ZonedDateTime.now(ZoneOffset.UTC);
if ('from' in params && params.from && 'to' in params && params.to) {
// Both from and to specified
return {
from: temporalToZonedDateTime(params.from),
to: temporalToZonedDateTime(params.to),
};
} else if (
'from' in params &&
params.from &&
'duration' in params &&
params.duration
) {
// From + duration: query forward
const from = temporalToZonedDateTime(params.from);
const durationMs = durationToMillis(params.duration);
const to = from.plusNanos(durationMs * 1_000_000);
return { from, to };
} else if (
'to' in params &&
params.to &&
'duration' in params &&
params.duration
) {
// To + duration: query backward
const to = temporalToZonedDateTime(params.to);
const durationMs = durationToMillis(params.duration);
const from = to.minusNanos(durationMs * 1_000_000);
return { from, to };
} else if ('from' in params && params.from) {
// From only: query to now
return {
from: temporalToZonedDateTime(params.from),
to: now,
};
} else if ('duration' in params && params.duration) {
// Duration only: query back from now
const durationMs = durationToMillis(params.duration);
return {
from: now.minusNanos(durationMs * 1_000_000),
to: now,
};
}
// Default: last hour
return {
from: now.minusHours(1),
to: now,
};
}
/**
* Unique key for a requested path spec. The same path may be requested more
* than once with a different filter, aggregate, or parameters; keying stored
* results by path alone would collapse those into one column, so the key
* includes every distinguishing field. Fields never contain spaces
* (paths/aggregates/filter values are sanitised upstream), so a space
* separator is unambiguous.
*/
function pathSpecKey(ps: SignalKPathSpec): string {
const parameter = (ps.parameter ?? []).join(',');
const filters = filtersFromFields(ps as unknown as Record<string, unknown>)
.map(f => `${f.column}=${f.value}`)
.join(' ');
return [ps.path, ps.aggregate, parameter, filters].join(' ');
}
/**
* History API Provider implementation
*/
export class HistoryProvider implements HistoryApi {
private sqliteBuffer?: ProviderBufferSource;
constructor(
private selfId: string,
private dataDir: string,
private app: ServerAPI,
private debug: (msg: string) => void
) {}
setSqliteBuffer(buffer: ProviderBufferSource): void {
this.sqliteBuffer = buffer;
}
/**
* Get historical values for the specified query
*/
async getValues(query: ValuesRequest): Promise<ValuesResponse> {
this.debug(
`[HistoryProvider] getValues called with: ${JSON.stringify(
query,
(_, v) => (typeof v === 'bigint' ? v.toString() : v)
)}`
);
const { from, to } = parseTimeRange(query);
// Translate 'vessels.self' to actual vessel URN (same as HTTP endpoint)
const context =
!query.context ||
query.context === 'vessels.self' ||
query.context === ('self' as Context)
? (`vessels.${this.selfId}` as Context)
: query.context;
// query.resolution is in seconds per the SignalK History API spec;
// the DuckDB bucketing SQL below works in milliseconds. Reject 0,
// negative, NaN, and Infinity here so they don't reach SQL as a
// divide-by-zero, negative bucket, or unbounded cardinality query.
const r = query.resolution;
const resolutionFromQuery = r != null && Number.isFinite(r) && r > 0;
// Compute the auto fallback in milliseconds so a sub-second range
// doesn't truncate to a 0 ms divisor in the SQL bucketing.
const autoResolutionMs = Math.max(
1,
Math.round(
(to.toInstant().toEpochMilli() - from.toInstant().toEpochMilli()) / 500
)
);
const resolutionMs = resolutionFromQuery ? r * 1000 : autoResolutionMs;
this.debug(
`[HistoryProvider] getValues: context=${context}, from=${from}, to=${to}, resolution=${resolutionMs}ms (${resolutionFromQuery ? 'from query' : 'auto'}), paths=${query.pathSpecs.length}`
);
const fromIso = from.toInstant().toString();
const toIso = to.toInstant().toString();
// Query each path
const allData: { [key: string]: Array<[Timestamp, unknown]> } = {};
for (const pathSpec of query.pathSpecs) {
// Key by the full spec, not just path: the same path may appear multiple
// times with different sourceRef/aggregate and must stay separate.
const key = pathSpecKey(pathSpec);
try {
const pathData = await this.queryPath(
context,
pathSpec,
fromIso,
toIso,
resolutionMs
);
allData[key] = pathData;
} catch (error) {
this.debug(
`[HistoryProvider] Error querying path ${pathSpec.path}: ${error}`
);
allData[key] = [];
}
}
// Merge all path data into time-ordered rows
const mergedData = this.mergePathData(allData, query.pathSpecs);
return {
context,
range: {
from: fromIso as Timestamp,
to: toIso as Timestamp,
},
// Echo each filter (e.g. sourceRef) back per path. Cast covers the
// @signalk/server-api version gap until ValueList declares the field.
values: query.pathSpecs.map(ps => {
const echo = filterEcho(
filtersFromFields(ps as unknown as Record<string, unknown>)
);
return { path: ps.path, method: ps.aggregate, ...echo };
}) as ValuesResponse['values'],
data: mergedData,
};
}
/**
* Get available contexts for the time range
*/
async getContexts(query: ContextsRequest): Promise<ContextsResponse> {
const { from, to } = parseTimeRange(query);
this.debug(`[HistoryProvider] getContexts: from=${from}, to=${to}`);
const contexts = await getAvailableContextsForTimeRange(
this.dataDir,
from,
to
);
return contexts as ContextsResponse;
}
/**
* Get available paths for the time range
*/
async getPaths(query: PathsRequest): Promise<PathsResponse> {
const { from, to } = parseTimeRange(query);
this.debug(`[HistoryProvider] getPaths: from=${from}, to=${to}`);
// Extract context if present (PathsRequest type doesn't include context, but callers may pass it)
const queryContext = (query as any).context;
const context = queryContext
? !queryContext ||
queryContext === 'vessels.self' ||
queryContext === 'self'
? `vessels.${this.selfId}`
: queryContext.replace(/ /gi, '')
: undefined;
const paths = getAvailablePathsArray(this.dataDir, this.app, context);
return paths as PathsResponse;
}
/**
* Query a single path from parquet files
*/
private async queryPath(
context: Context,
pathSpec: SignalKPathSpec,
fromIso: string,
toIso: string,
resolutionMs: number
): Promise<Array<[Timestamp, unknown]>> {
// Use HivePathBuilder for correct Hive-partitioned paths
const hiveBuilder = new HivePathBuilder();
// Build glob pattern for Hive partitions
const filePath = hiveBuilder.getGlobPattern(
this.dataDir,
'raw',
context,
pathSpec.path
);
this.debug(`[HistoryProvider] Querying Hive path: ${filePath}`);
// Stage this path's buffer rows into a temp table if the buffer is available
const hasBuffer = DuckDBPool.isSQLiteBufferInitialized();
const connection = await DuckDBPool.getConnection();
try {
const stagedBufferTable =
hasBuffer && this.sqliteBuffer
? await stageBufferTable(
connection,
this.sqliteBuffer,
String(context),
String(pathSpec.path),
fromIso,
toIso,
(msg: string) => this.debug(msg)
)
: null;
// Check if this is an object path (has value_* columns)
const componentSchema = await getPathComponentSchema(
this.dataDir,
context,
pathSpec.path
);
const aggFunc = this.getAggregateFunction(pathSpec.aggregate);
// Inline filters (e.g. sourceRef) come from the server-parsed PathSpec.
// The fields are populated by a newer @signalk/server-api than this plugin
// pins, so they are read defensively via the registry. This provider only
// queries raw-tier parquet; probe it for the filter columns so files
// without them are excluded rather than throwing.
const filters = filtersFromFields(
pathSpec as unknown as Record<string, unknown>
);
const available = await availableFilterColumns(
connection,
[filePath],
filters
);
const sourceFilter = buildParquetFilterClause(filters, available);
// Build parquet FROM clause with filename filtering
const parquetFrom = `(SELECT * FROM read_parquet('${filePath}', 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/%'${sourceFilter})`;
if (componentSchema && componentSchema.components.size > 0) {
// Object path - aggregate each component
const componentSelects = Array.from(
componentSchema.components.entries()
)
.map(([name, comp]) => {
const compAggFunc = comp.dataType === 'numeric' ? aggFunc : 'FIRST';
// TRY_CAST handles mixed-type parquet files (some store lat/lon as VARCHAR)
const colExpr =
comp.dataType === 'numeric'
? `TRY_CAST(${comp.columnName} AS DOUBLE)`
: comp.columnName;
return `${compAggFunc}(${colExpr}) as ${name}`;
})
.join(', ');
const componentWhereConditions = Array.from(
componentSchema.components.values()
)
.map(comp => `${comp.columnName} IS NOT NULL`)
.join(' OR ');
const componentCols = Array.from(componentSchema.components.values())
.map(c => c.columnName)
.join(', ');
// Build federated FROM: parquet UNION ALL buffer
let federatedFrom: string;
if (stagedBufferTable) {
const bufferTableCols = this.sqliteBuffer?.getTableColumns(
pathSpec.path as string
);
const bufferSubquery = buildBufferObjectSubquery(
stagedBufferTable,
context,
fromIso,
toIso,
componentSchema.components,
bufferTableCols,
filters
);
federatedFrom = `(
SELECT signalk_timestamp, ${componentCols} FROM ${parquetFrom}
UNION ALL
SELECT signalk_timestamp, ${componentCols} FROM ${bufferSubquery}
)`;
} else {
federatedFrom = parquetFrom;
}
const query = `
SELECT
strftime(DATE_TRUNC('seconds',
EPOCH_MS(CAST(FLOOR(EPOCH_MS(signalk_timestamp::TIMESTAMP) / ${resolutionMs}) * ${resolutionMs} AS BIGINT))
), '%Y-%m-%dT%H:%M:%SZ') as timestamp,
${componentSelects}
FROM ${federatedFrom} AS source_data
WHERE
signalk_timestamp >= '${fromIso}'
AND signalk_timestamp < '${toIso}'
AND (${componentWhereConditions})
GROUP BY timestamp
ORDER BY timestamp
`;
const result = await connection.runAndReadAll(query);
const rows = result.getRowObjects();
return rows.map((row: any) => {
const timestamp = row.timestamp as Timestamp;
// For navigation.position, return as [longitude, latitude] array for compatibility
// with plugins like signalk-pmtiles-plugin that expect this format
if (
pathSpec.path === 'navigation.position' &&
row.longitude !== undefined &&
row.latitude !== undefined
) {
return [timestamp, [row.longitude, row.latitude]];
}
// For other object paths, return as object
const obj: any = {};
componentSchema.components.forEach((_, name) => {
if (row[name] !== null && row[name] !== undefined) {
obj[name] = row[name];
}
});
return [timestamp, obj];
});
} else {
// Scalar path — use vector averaging for angular paths when aggregating by average
const angular = isAngularPath(
pathSpec.path,
this.app,
context as string
);
const valueExpression =
angular && (pathSpec.aggregate === 'average' || !pathSpec.aggregate)
? 'ATAN2(AVG(SIN(TRY_CAST(value AS DOUBLE))), AVG(COS(TRY_CAST(value AS DOUBLE))))'
: `${aggFunc}(TRY_CAST(value AS DOUBLE))`;
// Build federated FROM: parquet UNION ALL buffer
let federatedFrom: string;
if (stagedBufferTable) {
const bufferSubquery = buildBufferScalarSubquery(
stagedBufferTable,
context,
pathSpec.path,
fromIso,
toIso,
filters
);
federatedFrom = `(
SELECT signalk_timestamp, value FROM ${parquetFrom}
UNION ALL
SELECT signalk_timestamp, value FROM ${bufferSubquery}
)`;
} else {
federatedFrom = parquetFrom;
}
const query = `
SELECT
strftime(DATE_TRUNC('seconds',
EPOCH_MS(CAST(FLOOR(EPOCH_MS(signalk_timestamp::TIMESTAMP) / ${resolutionMs}) * ${resolutionMs} AS BIGINT))
), '%Y-%m-%dT%H:%M:%SZ') as timestamp,
${valueExpression} as value
FROM ${federatedFrom} AS source_data
WHERE
signalk_timestamp >= '${fromIso}'
AND signalk_timestamp < '${toIso}'
AND value IS NOT NULL
GROUP BY timestamp
ORDER BY timestamp
`;
const result = await connection.runAndReadAll(query);
const rows = result.getRowObjects();
return rows.map((row: any) => [row.timestamp as Timestamp, row.value]);
}
} finally {
connection.disconnectSync();
}
}
/**
* Convert aggregate method to SQL function
*/
private getAggregateFunction(method: AggregateMethod): string {
switch (method) {
case 'average':
return 'AVG';
case 'min':
return 'MIN';
case 'max':
return 'MAX';
case 'first':
return 'FIRST';
case 'last':
return 'LAST';
case 'mid':
return 'MEDIAN';
case 'middle_index':
return 'FIRST'; // Fallback
default:
return 'AVG';
}
}
/**
* Merge data from multiple paths into time-aligned rows
*/
private mergePathData(
allData: { [key: string]: Array<[Timestamp, unknown]> },
pathSpecs: SignalKPathSpec[]
): Array<[Timestamp, ...unknown[]]> {
// Collect all unique timestamps
const timestampSet = new Set<string>();
Object.values(allData).forEach(pathData => {
pathData.forEach(([ts]) => timestampSet.add(ts));
});
// Sort timestamps
const timestamps = Array.from(timestampSet).sort();
// One lookup map per spec (by position), each fetched with the same
// composite key used to store it, so duplicate paths with different
// sourceRef/aggregate remain distinct columns.
const specMaps = pathSpecs.map(ps => {
const map = new Map<string, unknown>();
(allData[pathSpecKey(ps)] || []).forEach(([ts, val]) => map.set(ts, val));
return map;
});
// Build merged rows
return timestamps.map(ts => {
const row: [Timestamp, ...unknown[]] = [ts as Timestamp];
specMaps.forEach(map => row.push(map.get(ts) ?? null));
return row;
});
}
}
/**
* Register this plugin as the History API provider
*/
export function registerHistoryApiProvider(
app: ServerAPI,
selfId: string,
dataDir: string,
debug: (msg: string) => void,
sqliteBuffer?: ProviderBufferSource
): void {
const provider = new HistoryProvider(selfId, dataDir, app, debug);
if (sqliteBuffer) {
provider.setSqliteBuffer(sqliteBuffer);
}
// Debug: Check if registerHistoryApiProvider exists on app
console.log(
'[signalk-parquet] app.registerHistoryApiProvider exists:',
typeof (app as any).registerHistoryApiProvider
);
if (typeof (app as any).registerHistoryApiProvider !== 'function') {
console.error(
'[signalk-parquet] ERROR: app.registerHistoryApiProvider is not a function!'
);
console.error(
'[signalk-parquet] Available app methods:',
Object.keys(app)
.filter(k => typeof (app as any)[k] === 'function')
.join(', ')
);
return;
}
try {
app.registerHistoryApiProvider(provider);
debug('[HistoryProvider] Successfully registered as History API provider');
console.log('[signalk-parquet] Registered as SignalK History API provider');
} catch (error) {
console.error(
'[signalk-parquet] Failed to register as History API provider:',
error
);
debug(`[HistoryProvider] Registration failed: ${error}`);
}
}
/**
* Unregister this plugin as the History API provider
*/
export function unregisterHistoryApiProvider(app: ServerAPI): void {
try {
app.unregisterHistoryApiProvider();
console.log(
'[signalk-parquet] Unregistered as SignalK History API provider'
);
} catch (error) {
// Ignore errors during unregistration
}
}