@yarkivaev/scada 2.3.69 → 2.3.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.69",
3
+ "version": "2.3.70",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -98,6 +98,9 @@ export default function machineClient(baseUrl, machineId, fetcher, eventSource,
98
98
  if (options && options.step) {
99
99
  params.set('step', String(options.step));
100
100
  }
101
+ if (options && options.mode) {
102
+ params.set('mode', options.mode);
103
+ }
101
104
  const qs = params.toString();
102
105
  return request(`/measurements${qs ? `?${qs}` : ''}`);
103
106
  },
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Reads a downsampled series, rising prefixes, or level changes.
3
+ *
4
+ * @param {object} sensor - machine sensor
5
+ * @param {{start: Date, end: Date}} range - query window
6
+ * @param {number} step - downsample step in ms
7
+ * @param {string} [mode] - rises or changes
8
+ * @returns {Promise<Array>} series rows
9
+ */
10
+ export default function measurementRead(sensor, range, step, mode) {
11
+ if (mode === 'rises') {
12
+ return sensor.rises(range);
13
+ }
14
+ if (mode === 'changes') {
15
+ return sensor.changes(range);
16
+ }
17
+ return sensor.measurements(range, step);
18
+ }
19
+
20
+ /**
21
+ * Packs one sensor series into the measurements JSON item.
22
+ *
23
+ * @param {string} key - sensor key
24
+ * @param {object} sensor - machine sensor
25
+ * @param {Array<{timestamp: Date, value: number, unit: string}>} rows - series
26
+ * @returns {{key: string, name: string, unit: string, values: Array}} item
27
+ */
28
+ export function measurementItem(key, sensor, rows) {
29
+ const unit = rows.length > 0 ? rows[0].unit : '';
30
+ const values = rows.map((row) => {
31
+ return {
32
+ timestamp: row.timestamp.toISOString(),
33
+ value: row.value
34
+ };
35
+ });
36
+ return { key, name: sensor.name(), unit, values };
37
+ }
@@ -1,5 +1,6 @@
1
1
  import machineInPlant from '../../../../application/machineInPlant.js';
2
2
  import { jsonResponse, route, timeExpression } from '@yarkivaev/simple-server';
3
+ import measurementRead, { measurementItem } from './measurementRead.js';
3
4
 
4
5
  /**
5
6
  * Measurement routes factory.
@@ -13,6 +14,31 @@ import { jsonResponse, route, timeExpression } from '@yarkivaev/simple-server';
13
14
  * @example
14
15
  * const routes = measurementRoute('/api/v1', plant, clock);
15
16
  */
17
+ /**
18
+ * Collects measurement items for the requested keys and mode.
19
+ *
20
+ * @param {object} machine - plant machine
21
+ * @param {object} query - HTTP query
22
+ * @param {function} clock - time provider
23
+ * @param {function} beginning - range floor
24
+ * @returns {Promise<Array>} items
25
+ */
26
+ async function measure(machine, query, clock, beginning) {
27
+ const requested = query.keys ? query.keys.split(',') : Object.keys(machine.sensors);
28
+ const keys = requested.filter((key) => {
29
+ return machine.sensors[key];
30
+ });
31
+ const from = timeExpression(query.from || 'now-1M', clock, beginning).resolve();
32
+ const to = timeExpression(query.to || 'now', clock, beginning).resolve();
33
+ const step = query.step ? parseInt(query.step, 10) * 1000 : 1000;
34
+ const range = { start: from, end: to };
35
+ return await Promise.all(keys.map(async (key) => {
36
+ const sensor = machine.sensors[key];
37
+ const rows = await measurementRead(sensor, range, step, query.mode);
38
+ return measurementItem(key, sensor, rows);
39
+ }));
40
+ }
41
+
16
42
  export default function measurementRoute(basePath, plant, clock) {
17
43
  function beginning() {
18
44
  return new Date(clock().getTime() - 30 * 24 * 60 * 60 * 1000);
@@ -34,25 +60,7 @@ export default function measurementRoute(basePath, plant, clock) {
34
60
  jsonResponse({ items: [] }).send(res);
35
61
  return;
36
62
  }
37
- const requested = query.keys ? query.keys.split(',') : Object.keys(machine.sensors);
38
- const keys = requested.filter((key) => {return machine.sensors[key]});
39
- const fromExpr = query.from || 'now-1M';
40
- const toExpr = query.to || 'now';
41
- const from = timeExpression(fromExpr, clock, beginning).resolve();
42
- const to = timeExpression(toExpr, clock, beginning).resolve();
43
- const step = query.step ? parseInt(query.step, 10) * 1000 : 1000;
44
- const range = { start: from, end: to };
45
- const promises = keys.map(async (key) => {
46
- const sensor = machine.sensors[key];
47
- const measurements = await sensor.measurements(range, step);
48
- const unit = measurements.length > 0 ? measurements[0].unit : '';
49
- const values = measurements.map((row) => {return {
50
- timestamp: row.timestamp.toISOString(),
51
- value: row.value
52
- }});
53
- return { key, name: sensor.name(), unit, values };
54
- });
55
- const items = await Promise.all(promises);
63
+ const items = await measure(machine, query, clock, beginning);
56
64
  jsonResponse({ items }).send(res);
57
65
  }
58
66
  )
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Level changes of one ClickHouse metric, including the sample held at the start.
3
+ *
4
+ * Heartbeat repeats of the same value stay in the table and out of the result.
5
+ *
6
+ * @example
7
+ * await clickhouseChanges(conn, 'm1/flag', { start, end }, '');
8
+ *
9
+ * @param {object} connection - ClickHouse connection with query method
10
+ * @param {string} topic - metric topic
11
+ * @param {{start: Date, end: Date}} range - inclusive window
12
+ * @param {string} unit - measurement unit
13
+ * @returns {Promise<Array<{timestamp: Date, value: number, unit: string}>>} changes
14
+ */
15
+ function formatDateTime(date) {
16
+ return date.toISOString().replace('Z', '').replace('T', ' ');
17
+ }
18
+
19
+ const CHANGES_SQL = `WITH prior AS (
20
+ SELECT ts, value
21
+ FROM scada.metrics
22
+ WHERE topic = {topic:String} AND ts < {start:DateTime64(3)}
23
+ ORDER BY ts DESC
24
+ LIMIT 1
25
+ ), windowed AS (
26
+ SELECT ts, value
27
+ FROM scada.metrics
28
+ WHERE topic = {topic:String}
29
+ AND ts >= {start:DateTime64(3)}
30
+ AND ts <= {end:DateTime64(3)}
31
+ ), series AS (
32
+ SELECT ts, value FROM prior
33
+ UNION ALL
34
+ SELECT ts, value FROM windowed
35
+ ), lagged AS (
36
+ SELECT
37
+ ts,
38
+ value,
39
+ row_number() OVER (ORDER BY ts) AS n,
40
+ lagInFrame(value, 1, NULL) OVER (ORDER BY ts) AS prev
41
+ FROM series
42
+ )
43
+ SELECT if(ts < {start:DateTime64(3)}, {start:DateTime64(3)}, ts) AS ts, value
44
+ FROM lagged
45
+ WHERE n = 1 OR value != prev
46
+ ORDER BY ts`;
47
+
48
+ export default async function clickhouseChanges(connection, topic, range, unit) {
49
+ const rows = await connection.query(CHANGES_SQL, {
50
+ topic,
51
+ start: formatDateTime(range.start),
52
+ end: formatDateTime(range.end)
53
+ });
54
+ return rows.map((row) => {
55
+ return { timestamp: new Date(`${row.ts}Z`), value: row.value, unit };
56
+ });
57
+ }
58
+
59
+ export { CHANGES_SQL };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Latest sample of one ClickHouse metric topic.
3
+ *
4
+ * @param {object} connection - ClickHouse connection with query method
5
+ * @param {string} topic - metric topic
6
+ * @param {string} unit - measurement unit
7
+ * @returns {Promise<{found: boolean, timestamp?: Date, value?: number, unit?: string}>} reading
8
+ */
9
+ export default async function clickhouseLatest(connection, topic, unit) {
10
+ const rows = await connection.query(
11
+ `SELECT ts, value FROM scada.metrics
12
+ WHERE topic = {topic:String}
13
+ ORDER BY ts DESC LIMIT 1`,
14
+ { topic }
15
+ );
16
+ if (rows.length === 0) {
17
+ return { found: false };
18
+ }
19
+ return { found: true, timestamp: new Date(`${rows[0].ts}Z`), value: rows[0].value, unit };
20
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Prefix sum of 0→1 edges for one ClickHouse metric topic.
3
+ *
4
+ * @example
5
+ * await clickhouseRises(conn, 'm1/flag', { start, end }, '');
6
+ *
7
+ * @param {object} connection - ClickHouse connection with query method
8
+ * @param {string} topic - metric topic
9
+ * @param {{start: Date, end: Date}} range - half-open window
10
+ * @param {string} unit - measurement unit
11
+ * @returns {Promise<Array<{timestamp: Date, value: number, unit: string}>>} prefixes
12
+ */
13
+ function formatDateTime(date) {
14
+ return date.toISOString().replace('Z', '').replace('T', ' ');
15
+ }
16
+
17
+ const RISES_SQL = `WITH prior AS (
18
+ SELECT ts, value
19
+ FROM scada.metrics
20
+ WHERE topic = {topic:String} AND ts < {start:DateTime64(3)}
21
+ ORDER BY ts DESC
22
+ LIMIT 1
23
+ ), windowed AS (
24
+ SELECT ts, value
25
+ FROM scada.metrics
26
+ WHERE topic = {topic:String}
27
+ AND ts >= {start:DateTime64(3)}
28
+ AND ts < {end:DateTime64(3)}
29
+ ), series AS (
30
+ SELECT ts, value FROM prior
31
+ UNION ALL
32
+ SELECT ts, value FROM windowed
33
+ ), lagged AS (
34
+ SELECT
35
+ ts,
36
+ value,
37
+ lagInFrame(value, 1, NULL) OVER (ORDER BY ts) AS prev
38
+ FROM series
39
+ )
40
+ SELECT ts, sum(1) OVER (ORDER BY ts) AS value
41
+ FROM lagged
42
+ WHERE ts >= {start:DateTime64(3)} AND prev = 0 AND value = 1
43
+ ORDER BY ts`;
44
+
45
+ export default async function clickhouseRises(connection, topic, range, unit) {
46
+ const rows = await connection.query(RISES_SQL, {
47
+ topic,
48
+ start: formatDateTime(range.start),
49
+ end: formatDateTime(range.end)
50
+ });
51
+ return rows.map((row) => {
52
+ return { timestamp: new Date(`${row.ts}Z`), value: row.value, unit };
53
+ });
54
+ }
55
+
56
+ export { RISES_SQL };
@@ -11,7 +11,7 @@
11
11
  * @param {string} topic - Metric topic in format '{machine}/{sensor}'
12
12
  * @param {string} displayName - Human-readable sensor name
13
13
  * @param {string} unit - Measurement unit (e.g., 'V', 'cos(φ)')
14
- * @returns {object} sensor with name, current, measurements and stream methods
14
+ * @returns {object} sensor with name, current, measurements, rises, changes and stream methods
15
15
  *
16
16
  * @example
17
17
  * const sensor = clickhouseSensor(conn, 'm1/voltage', 'Voltage', 'V');
@@ -21,49 +21,27 @@
21
21
  * sensor.stream(since, 1000, callback); // live stream
22
22
  */
23
23
  import clickhouseStreamHub from './streamHub.js';
24
-
25
- function formatDateTime(date) {
26
- return date.toISOString().replace('Z', '').replace('T', ' ');
27
- }
24
+ import clickhouseRises from './rises.js';
25
+ import clickhouseChanges from './changes.js';
26
+ import clickhouseSeries from './series.js';
27
+ import clickhouseLatest from './latest.js';
28
28
 
29
29
  export default function clickhouseSensor(connection, topic, displayName, unit) {
30
30
  return {
31
31
  name() {
32
32
  return displayName;
33
33
  },
34
- async current() {
35
- const rows = await connection.query(
36
- `SELECT ts, value FROM scada.metrics
37
- WHERE topic = {topic:String}
38
- ORDER BY ts DESC LIMIT 1`,
39
- { topic }
40
- );
41
- if (rows.length === 0) {
42
- return { found: false };
43
- }
44
- return { found: true, timestamp: new Date(`${rows[0].ts}Z`), value: rows[0].value, unit };
34
+ current() {
35
+ return clickhouseLatest(connection, topic, unit);
45
36
  },
46
- async measurements(range, step) {
47
- const seconds = Math.max(1, Math.floor(step / 1000));
48
- const rows = await connection.query(
49
- `SELECT
50
- toStartOfInterval(ts, INTERVAL ${seconds} SECOND) as ts,
51
- anyLast(value) as value
52
- FROM scada.metrics
53
- WHERE topic = {topic:String}
54
- AND ts >= toStartOfInterval({start:DateTime64(3)}, INTERVAL ${seconds} SECOND)
55
- AND ts <= {end:DateTime64(3)}
56
- GROUP BY ts
57
- ORDER BY ts`,
58
- {
59
- topic,
60
- start: formatDateTime(range.start),
61
- end: formatDateTime(range.end)
62
- }
63
- );
64
- return rows.map((row) => {
65
- return { timestamp: new Date(`${row.ts}Z`), value: row.value, unit };
66
- });
37
+ measurements(range, step) {
38
+ return clickhouseSeries(connection, topic, range, step, unit);
39
+ },
40
+ rises(range) {
41
+ return clickhouseRises(connection, topic, range, unit);
42
+ },
43
+ changes(range) {
44
+ return clickhouseChanges(connection, topic, range, unit);
67
45
  },
68
46
  stream(since, step, callback, clock) {
69
47
  return clickhouseStreamHub(connection).watch(topic, since, step, callback, {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Downsampled ClickHouse metric series for one topic.
3
+ *
4
+ * @param {object} connection - ClickHouse connection with query method
5
+ * @param {string} topic - metric topic
6
+ * @param {{start: Date, end: Date}} range - inclusive window
7
+ * @param {number} step - downsample step in ms
8
+ * @param {string} unit - measurement unit
9
+ * @returns {Promise<Array<{timestamp: Date, value: number, unit: string}>>} buckets
10
+ */
11
+ export default async function clickhouseSeries(connection, topic, range, step, unit) {
12
+ const seconds = Math.max(1, Math.floor(step / 1000));
13
+ const start = range.start.toISOString().replace('Z', '').replace('T', ' ');
14
+ const end = range.end.toISOString().replace('Z', '').replace('T', ' ');
15
+ const rows = await connection.query(
16
+ `SELECT
17
+ toStartOfInterval(ts, INTERVAL ${seconds} SECOND) as ts,
18
+ anyLast(value) as value
19
+ FROM scada.metrics
20
+ WHERE topic = {topic:String}
21
+ AND ts >= toStartOfInterval({start:DateTime64(3)}, INTERVAL ${seconds} SECOND)
22
+ AND ts <= {end:DateTime64(3)}
23
+ GROUP BY ts
24
+ ORDER BY ts`,
25
+ { topic, start, end }
26
+ );
27
+ return rows.map((row) => {
28
+ return { timestamp: new Date(`${row.ts}Z`), value: row.value, unit };
29
+ });
30
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Drops repeated samples from an ordered metric series.
3
+ *
4
+ * The first row is kept so a window can start from the level already held.
5
+ * Later rows are kept only when the numeric value changes.
6
+ *
7
+ * @example
8
+ * levelChanges([
9
+ * { ts: new Date('2026-09-30T00:00:00.000Z'), value: 0 },
10
+ * { ts: new Date('2026-09-30T00:00:01.000Z'), value: 0 },
11
+ * { ts: new Date('2026-09-30T00:00:02.000Z'), value: 1 }
12
+ * ]);
13
+ *
14
+ * @param {Array<{ts: Date|string, value: number}>} rows - ordered samples
15
+ * @returns {Array<{ts: Date|string, value: number}>} level changes
16
+ */
17
+ export default function levelChanges(rows) {
18
+ const kept = [];
19
+ let previous;
20
+ (rows || []).forEach((row) => {
21
+ const value = Number(row.value);
22
+ if (kept.length === 0 || value !== previous) {
23
+ kept.push(row);
24
+ previous = value;
25
+ }
26
+ });
27
+ return kept;
28
+ }
@@ -1,3 +1,7 @@
1
+ import risingPrefixes from '../risingPrefixes.js';
2
+ import levelChanges from '../levelChanges.js';
3
+ import { seriesWindow, topicRows } from '../topicWindow.js';
4
+
1
5
  function bucketKey(tsMs, originMs, stepMs) {
2
6
  const slot = Math.floor((tsMs - originMs) / stepMs);
3
7
  return originMs + slot * stepMs;
@@ -23,6 +27,28 @@ function rowsForRange(store, topic, startMs, endMs, stepMs) {
23
27
  });
24
28
  }
25
29
 
30
+ function risesOf(rows, topic, startIso, endIso) {
31
+ const start = new Date(startIso).getTime();
32
+ const end = new Date(endIso).getTime();
33
+ const series = seriesWindow(topicRows(rows, topic), start, end, false);
34
+ return risingPrefixes(series).filter((row) => {
35
+ const ts = new Date(row.ts).getTime();
36
+ return ts >= start && ts < end;
37
+ });
38
+ }
39
+
40
+ function changesOf(rows, topic, startIso, endIso) {
41
+ const start = new Date(startIso).getTime();
42
+ const end = new Date(endIso).getTime();
43
+ const series = seriesWindow(topicRows(rows, topic), start, end, true);
44
+ return levelChanges(series).map((row, index) => {
45
+ if (index === 0 && new Date(row.ts).getTime() < start) {
46
+ return { ts: new Date(start), value: row.value };
47
+ }
48
+ return { ts: row.ts, value: row.value };
49
+ });
50
+ }
51
+
26
52
  /**
27
53
  * In-memory metrics state port for tests and local runs.
28
54
  *
@@ -45,6 +71,12 @@ export default function metricsStateMemory(store) {
45
71
  const stepSec = Math.max(1, Math.floor(stepMs / 1000));
46
72
  return rowsForRange(store, topic, start, end, stepSec * 1000);
47
73
  },
74
+ risesForTopic(topic, startIso, endIso) {
75
+ return risesOf(store.metrics, topic, startIso, endIso);
76
+ },
77
+ changesForTopic(topic, startIso, endIso) {
78
+ return changesOf(store.metrics, topic, startIso, endIso);
79
+ },
48
80
  pollTopic(topic, afterIso, untilIso) {
49
81
  const after = new Date(afterIso).getTime();
50
82
  const until = new Date(untilIso).getTime();
@@ -9,6 +9,12 @@ export default function metricsStateDisabled() {
9
9
  rangeForTopic() {
10
10
  return Promise.resolve([]);
11
11
  },
12
+ risesForTopic() {
13
+ return Promise.resolve([]);
14
+ },
15
+ changesForTopic() {
16
+ return Promise.resolve([]);
17
+ },
12
18
  pollTopic() {
13
19
  return Promise.resolve([]);
14
20
  },
@@ -1,6 +1,4 @@
1
- function parseTimestamp(ts) {
2
- return ts instanceof Date ? ts : new Date(ts);
3
- }
1
+ import seriesRows, { parseTimestamp } from './seriesRows.js';
4
2
 
5
3
  function pollStream(spec) {
6
4
  const { read, topic, since, step, callback, clock, unit } = spec;
@@ -45,6 +43,12 @@ export function pgMetricsRead(metrics) {
45
43
  range(topic, startIso, endIso, stepMs) {
46
44
  return metrics.rangeForTopic(topic, startIso, endIso, stepMs);
47
45
  },
46
+ rises(topic, startIso, endIso) {
47
+ return metrics.risesForTopic(topic, startIso, endIso);
48
+ },
49
+ changes(topic, startIso, endIso) {
50
+ return metrics.changesForTopic(topic, startIso, endIso);
51
+ },
48
52
  poll(topic, afterIso, untilIso) {
49
53
  return metrics.pollTopic(topic, afterIso, untilIso);
50
54
  }
@@ -84,13 +88,23 @@ export default function metricsSensor(read, topic, displayName, unit) {
84
88
  range.end.toISOString(),
85
89
  step
86
90
  );
87
- return rows.map((row) => {
88
- return {
89
- timestamp: parseTimestamp(row.ts),
90
- value: row.value,
91
- unit
92
- };
93
- });
91
+ return seriesRows(rows, unit);
92
+ },
93
+ async rises(range) {
94
+ const rows = await read.rises(
95
+ topic,
96
+ range.start.toISOString(),
97
+ range.end.toISOString()
98
+ );
99
+ return seriesRows(rows, unit);
100
+ },
101
+ async changes(range) {
102
+ const rows = await read.changes(
103
+ topic,
104
+ range.start.toISOString(),
105
+ range.end.toISOString()
106
+ );
107
+ return seriesRows(rows, unit);
94
108
  },
95
109
  stream(since, step, callback, clock) {
96
110
  return pollStream({ read, topic, since, step, callback, clock, unit });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Level changes for one Postgres metrics topic, including the held start.
3
+ *
4
+ * $1 topic, $2 start, $3 inclusive end. The sample before start seeds lag.
5
+ *
6
+ * @returns {string} SQL
7
+ */
8
+ export default function changesSql() {
9
+ return `WITH prior AS (
10
+ SELECT ts, value FROM metrics
11
+ WHERE topic = $1 AND ts < $2
12
+ ORDER BY ts DESC LIMIT 1
13
+ ), windowed AS (
14
+ SELECT ts, value FROM metrics
15
+ WHERE topic = $1 AND ts >= $2 AND ts <= $3
16
+ ), series AS (
17
+ SELECT ts, value FROM prior
18
+ UNION ALL
19
+ SELECT ts, value FROM windowed
20
+ ), marked AS (
21
+ SELECT ts, value,
22
+ LAG(value) OVER (ORDER BY ts) AS prev,
23
+ ROW_NUMBER() OVER (ORDER BY ts) AS n
24
+ FROM series
25
+ )
26
+ SELECT CASE WHEN ts < $2 THEN $2 ELSE ts END AS ts, value
27
+ FROM marked
28
+ WHERE n = 1 OR value IS DISTINCT FROM prev
29
+ ORDER BY ts`;
30
+ }
@@ -1,3 +1,8 @@
1
+ import risesSql from './risesSql.js';
2
+ import changesSql from './changesSql.js';
3
+ import queryTopic from './queryTopic.js';
4
+ import rangeSql from './rangeSql.js';
5
+
1
6
  export default function metricsStatePg(pool) {
2
7
  return {
3
8
  async latestForTopic(topic) {
@@ -7,18 +12,15 @@ export default function metricsStatePg(pool) {
7
12
  );
8
13
  return result.rows[0] ?? null;
9
14
  },
10
- async rangeForTopic(topic, startIso, endIso, stepMs) {
15
+ rangeForTopic(topic, startIso, endIso, stepMs) {
11
16
  const seconds = Math.max(1, Math.floor(stepMs / 1000));
12
- const sql = `SELECT bucket AS ts, value FROM (
13
- SELECT date_bin($1::interval, ts, '1970-01-01'::timestamptz) AS bucket, value,
14
- ROW_NUMBER() OVER (PARTITION BY date_bin($1::interval, ts, '1970-01-01'::timestamptz) ORDER BY ts DESC) AS rn
15
- FROM metrics WHERE topic = $2 AND ts >= $3 AND ts <= $4
16
- ) sub WHERE rn = 1 ORDER BY ts`;
17
- const prm = [`${seconds} seconds`, topic, startIso, endIso];
18
- const result = await pool.query(sql, prm);
19
- return result.rows.map((row) => {
20
- return { ts: row.ts, value: row.value };
21
- });
17
+ return queryTopic(pool, rangeSql(), [`${seconds} seconds`, topic, startIso, endIso]);
18
+ },
19
+ risesForTopic(topic, startIso, endIso) {
20
+ return queryTopic(pool, risesSql(), [topic, startIso, endIso]);
21
+ },
22
+ changesForTopic(topic, startIso, endIso) {
23
+ return queryTopic(pool, changesSql(), [topic, startIso, endIso]);
22
24
  },
23
25
  async pollTopic(topic, afterIso, untilIso) {
24
26
  const result = await pool.query(
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Runs a topic SQL and maps ts/value rows.
3
+ *
4
+ * @param {object} pool - Postgres pool
5
+ * @param {string} sql - query text
6
+ * @param {Array} params - query params
7
+ * @returns {Promise<Array<{ts: Date|string, value: number}>>} rows
8
+ */
9
+ export default async function queryTopic(pool, sql, params) {
10
+ const result = await pool.query(sql, params);
11
+ return result.rows.map((row) => {
12
+ return { ts: row.ts, value: Number(row.value) };
13
+ });
14
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Downsampled Postgres range for one metrics topic.
3
+ *
4
+ * $1 interval, $2 topic, $3 start, $4 end.
5
+ *
6
+ * @returns {string} SQL
7
+ */
8
+ export default function rangeSql() {
9
+ return `SELECT bucket AS ts, value FROM (
10
+ SELECT date_bin($1::interval, ts, '1970-01-01'::timestamptz) AS bucket, value,
11
+ ROW_NUMBER() OVER (PARTITION BY date_bin($1::interval, ts, '1970-01-01'::timestamptz) ORDER BY ts DESC) AS rn
12
+ FROM metrics WHERE topic = $2 AND ts >= $3 AND ts <= $4
13
+ ) sub WHERE rn = 1 ORDER BY ts`;
14
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Prefix sum of 0→1 edges for one Postgres metrics topic.
3
+ *
4
+ * $1 topic, $2 start, $3 exclusive end. The sample before start seeds lag.
5
+ *
6
+ * @returns {string} SQL
7
+ */
8
+ export default function risesSql() {
9
+ return `WITH prior AS (
10
+ SELECT ts, value FROM metrics
11
+ WHERE topic = $1 AND ts < $2
12
+ ORDER BY ts DESC LIMIT 1
13
+ ), windowed AS (
14
+ SELECT ts, value FROM metrics
15
+ WHERE topic = $1 AND ts >= $2 AND ts < $3
16
+ ), series AS (
17
+ SELECT ts, value FROM prior
18
+ UNION ALL
19
+ SELECT ts, value FROM windowed
20
+ ), marked AS (
21
+ SELECT ts, value, LAG(value) OVER (ORDER BY ts) AS prev
22
+ FROM series
23
+ )
24
+ SELECT marked.ts AS ts,
25
+ SUM(1) OVER (ORDER BY marked.ts) AS value
26
+ FROM marked
27
+ WHERE marked.ts >= $2 AND marked.prev = 0 AND marked.value = 1
28
+ ORDER BY marked.ts`;
29
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Builds a prefix sum of 0→1 edges on an ordered numeric series.
3
+ *
4
+ * Heartbeat repeats of 1 do not increment. A series that starts already
5
+ * high does not produce a prefix until a later 0→1 pair.
6
+ *
7
+ * @example
8
+ * risingPrefixes([
9
+ * { ts: new Date('2026-09-30T00:00:00.000Z'), value: 0 },
10
+ * { ts: new Date('2026-09-30T00:00:01.000Z'), value: 1 }
11
+ * ]);
12
+ *
13
+ * @param {Array<{ts: Date|string, value: number}>} rows - ordered samples
14
+ * @returns {Array<{ts: Date|string, value: number}>} prefix points
15
+ */
16
+ export default function risingPrefixes(rows) {
17
+ let previous;
18
+ let count = 0;
19
+ const prefixes = [];
20
+ (rows || []).forEach((row) => {
21
+ const value = Number(row.value);
22
+ if (previous === 0 && value === 1) {
23
+ count += 1;
24
+ prefixes.push({ ts: row.ts, value: count });
25
+ }
26
+ previous = value;
27
+ });
28
+ return prefixes;
29
+ }
@@ -0,0 +1,22 @@
1
+ function parseTimestamp(ts) {
2
+ return ts instanceof Date ? ts : new Date(ts);
3
+ }
4
+
5
+ /**
6
+ * Maps storage rows onto the sensor measurement shape.
7
+ *
8
+ * @param {Array<{ts: Date|string, value: number}>} rows - storage rows
9
+ * @param {string} unit - measurement unit
10
+ * @returns {Array<{timestamp: Date, value: number, unit: string}>} series
11
+ */
12
+ export default function seriesRows(rows, unit) {
13
+ return rows.map((row) => {
14
+ return {
15
+ timestamp: parseTimestamp(row.ts),
16
+ value: row.value,
17
+ unit
18
+ };
19
+ });
20
+ }
21
+
22
+ export { parseTimestamp };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Orders one topic and splits a prior sample from the requested window.
3
+ *
4
+ * @param {Array<{topic: string, ts: Date|string, value: number}>} rows - store
5
+ * @param {string} topic - metric topic
6
+ * @returns {Array<{topic: string, ts: Date, value: number}>} time-ordered hits
7
+ */
8
+ export function topicRows(rows, topic) {
9
+ return (rows || []).filter((row) => {
10
+ return row.topic === topic;
11
+ }).map((row) => {
12
+ return { topic: row.topic, ts: new Date(row.ts), value: row.value };
13
+ }).sort((left, right) => {
14
+ return left.ts.getTime() - right.ts.getTime();
15
+ });
16
+ }
17
+
18
+ /**
19
+ * Preceding sample plus in-window rows for lag-based queries.
20
+ *
21
+ * @param {Array<{ts: Date, value: number}>} rows - one topic ordered
22
+ * @param {number} startMs - window start
23
+ * @param {number} endMs - window end
24
+ * @param {boolean} closed - true when the end is inclusive
25
+ * @returns {Array<{ts: Date, value: number}>} prior plus window
26
+ */
27
+ export function seriesWindow(rows, startMs, endMs, closed) {
28
+ const prior = rows.filter((row) => {
29
+ return row.ts.getTime() < startMs;
30
+ }).pop();
31
+ const window = rows.filter((row) => {
32
+ const ts = row.ts.getTime();
33
+ if (ts < startMs) {
34
+ return false;
35
+ }
36
+ return closed ? ts <= endMs : ts < endMs;
37
+ });
38
+ return prior ? [prior].concat(window) : window;
39
+ }