@yarkivaev/scada 2.3.59 → 2.3.61

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/README.md CHANGED
@@ -91,6 +91,7 @@ Main entry (`import { … } from '@yarkivaev/scada'`):
91
91
  | `timeline`, `alerts`, `alert`, `acknowledgedAlert` | Timeline and alerting |
92
92
  | `plantApi`, `plantServer`, `siteServer` | HTTP composition |
93
93
  | `exportQuery`, `exportStream`, `exportSink`, `exportJob` | Generic export ports over plantApi / `@yarkivaev/scada/client` |
94
+ | `siteSync`, `siteSyncSites`, `siteSyncBind` | Pull kinds from an allowlisted remote plantApi into local persistence |
94
95
  | `metricsPlant`, `shopWithTimeline`, `machineInPlant` | Plant wiring helpers |
95
96
  | `supervisorSink`, `readDeploymentConfig` | STOMP ingest + PG persistence |
96
97
  | `edgeApi`, `stateHttpClient`, `metricsSensor` | Edge HTTP read/write |
@@ -125,6 +126,19 @@ const job = exportJob({ query, transform: (rows) => rows, sink });
125
126
  await job.run({ kind: 'segments', machine: 'furnace-α', from, to });
126
127
  ```
127
128
 
129
+ `POST /api/v1/sync` on any `siteServer` pulls `segments` / `operations` / `measurements` / `alerts` from `SYNC_SITES` / `EDGE_SITES` / `CENTRAL_PLANT_URL`. Body: `{ site, from, to, machines?, kinds? }`. Optional `SYNC_TOKEN`. Does not republish to RabbitMQ.
130
+
131
+ ```javascript
132
+ import { siteSync } from '@yarkivaev/scada';
133
+
134
+ await siteSync({ sites, queryFor, targets }).run({
135
+ site: 'edge-icht-1',
136
+ from,
137
+ to,
138
+ kinds: ['segments']
139
+ });
140
+ ```
141
+
128
142
  Downstream plant packages should pin a released tag (e.g. `#v2.3.46`).
129
143
 
130
144
  Alert and HMI copy are inject-only: pass `translations` into `siteServer` / `plantServer` / `alertPipeline` config, and override `TAG_CATALOG_PATH` or `plantApi({ tagCatalog })` for site taxonomy. Defaults ship empty or English stubs.
package/index.js CHANGED
@@ -20,7 +20,8 @@ export { default as shopWithTimeline } from './src/application/shopWithTimeline.
20
20
  export { default as plantOperations } from './src/application/plantOperations.js';
21
21
  export { default as metricsPlant } from './src/application/metricsPlant.js';
22
22
  export { default as plantServer } from './src/application/plantServer.js';
23
- export { default as siteServer } from './src/application/siteServer.js';
23
+ export { default as siteServer, bindSiteOperations } from './src/application/siteServer.js';
24
+ export { acceptOperationDeliver } from './src/infrastructure/sync/operationSyncIngest.js';
24
25
  export { default as foldedMetricsSink } from './src/application/foldedMetricsSink.js';
25
26
  export {
26
27
  default as siteOperatorCatalog,
@@ -79,3 +80,7 @@ export { default as exportQuery } from './src/application/export/exportQuery.js'
79
80
  export { default as exportStream } from './src/application/export/exportStream.js';
80
81
  export { default as exportSink } from './src/application/export/exportSink.js';
81
82
  export { default as exportJob } from './src/application/export/exportJob.js';
83
+ export { default as siteSync } from './src/application/sync/siteSync.js';
84
+ export { default as siteSyncSites } from './src/application/sync/siteSyncSites.js';
85
+ export { default as siteSyncTargets } from './src/application/sync/siteSyncTargets.js';
86
+ export { default as siteSyncBind } from './src/application/sync/siteSyncBind.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.59",
3
+ "version": "2.3.61",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -6,9 +6,9 @@ import pubsub from '../domain/shared/pubsub.js';
6
6
  *
7
7
  * Optional kindSources inject non-PG kinds into listForMachine merges.
8
8
  *
9
- * @param {object} persistence - operations store with upsert, upsertMany, get, remove, listForMachine, latestForMachine
9
+ * @param {object} persistence - operations store with upsert, upsertMany, get, remove, drop, listForMachine, latestForMachine
10
10
  * @param {object} [kindSources] - map of kind to { list(machineId, range) }
11
- * @returns {object} operations with listForMachine, latestForMachine, upsert, upsertMany, get, remove, and stream
11
+ * @returns {object} operations with listForMachine, latestForMachine, upsert, upsertMany, get, remove, drop, and stream
12
12
  *
13
13
  * @example
14
14
  * const ops = plantOperations(dataAccess.operations, { temp: temperaturePort });
@@ -10,6 +10,7 @@ import { metricsSinkFromPool } from '../infrastructure/persistence/pg/metrics.js
10
10
  import startTelemetryIngest from './siteTelemetry.js';
11
11
  import timelineOperatorFromEnv from './timelineOperatorFromEnv.js';
12
12
  import { buildSiteOperatorCatalog } from './siteOperatorCatalog.js';
13
+ import siteSyncBind from './sync/siteSyncBind.js';
13
14
 
14
15
  function stompFromEnv(env) {
15
16
  return {
@@ -55,6 +56,19 @@ function startMqtt(sink, env) {
55
56
  return pipeline;
56
57
  }
57
58
 
59
+ /**
60
+ * Replaces sink persistence with the domain operations port used by plant HTTP and AMQP sync.
61
+ *
62
+ * @param {object} sink - supervisor sink with dataAccess.operations
63
+ * @param {object} [kindSources] - optional non-PG kind sources
64
+ * @returns {object} wrapped operations port
65
+ */
66
+ export function bindSiteOperations(sink, kindSources) {
67
+ const ops = plantOperations(sink.dataAccess.operations, kindSources);
68
+ sink.dataAccess.operations = ops;
69
+ return ops;
70
+ }
71
+
58
72
  function startOperationSync(sink, env) {
59
73
  if (env.SINK_DB_PROFILE === 'edge' || !env.AMQP_URL) {
60
74
  return undefined;
@@ -87,10 +101,10 @@ function plantFactoryWithOperations(plantFactory, ops, sink) {
87
101
  };
88
102
  }
89
103
 
90
- function siteExtraRoutes(catalog, extraRoutes) {
104
+ function siteExtraRoutes(catalog, extraRoutes, syncRoutes) {
91
105
  return (path, plant, clock) => {
92
106
  const userExtra = extraRoutes ? extraRoutes(path, plant, clock) : [];
93
- return [...catalog.routes, ...userExtra];
107
+ return [...catalog.routes, ...syncRoutes, ...userExtra];
94
108
  };
95
109
  }
96
110
 
@@ -106,11 +120,7 @@ function siteExtraRoutes(catalog, extraRoutes) {
106
120
  export default async function siteServer(config) {
107
121
  const env = config.env || process.env;
108
122
  const sink = supervisorSink(env);
109
- const ops = plantOperations(
110
- sink.dataAccess.operations,
111
- config.kindSources || config.operationSources
112
- );
113
- sink.dataAccess.operations = ops;
123
+ const ops = bindSiteOperations(sink, config.kindSources || config.operationSources);
114
124
  const http = edgeApi(sink.dataAccess, {
115
125
  port: sink.apiPort,
116
126
  token: sink.apiToken,
@@ -136,6 +146,7 @@ export default async function siteServer(config) {
136
146
  if (catalog.sync) {
137
147
  await catalog.sync.start();
138
148
  }
149
+ const syncRoutes = siteSyncBind(env, sink, ops, { topic: config.topic, basePath });
139
150
  const plant = await plantServer({
140
151
  port: config.port || parseInt(env.PORT || '3000', 10),
141
152
  basePath,
@@ -143,7 +154,7 @@ export default async function siteServer(config) {
143
154
  requirePool: config.requirePool,
144
155
  stomp: stompFromEnv(env),
145
156
  plantFactory: plantFactoryWithOperations(config.plantFactory, ops, sink),
146
- extraRoutes: siteExtraRoutes(catalog, config.extraRoutes),
157
+ extraRoutes: siteExtraRoutes(catalog, config.extraRoutes, syncRoutes),
147
158
  timelineOperator: timelineOperatorFromEnv(catalog, env, config),
148
159
  operationDecisions: catalog.decisions,
149
160
  owners: config.owners,
@@ -0,0 +1,102 @@
1
+ import { listedItems } from './siteSyncRows.js';
2
+ import { siteById } from './siteSyncSites.js';
3
+
4
+ /**
5
+ * Pulls selected kinds from a remote plantApi into local targets.
6
+ *
7
+ * @param {object} ports - { sites, queryFor, targets }
8
+ * @returns {object} frozen sync with run(request)
9
+ *
10
+ * @example
11
+ * const sync = siteSync({ sites, queryFor, targets });
12
+ * await sync.run({ site: 'edge-icht-1', from, to, machines: ['icht1'], kinds: ['segments'] });
13
+ */
14
+ function pull(query, kind, machine, range) {
15
+ if (kind === 'segments') {
16
+ return query.segments(machine, range);
17
+ }
18
+ if (kind === 'operations') {
19
+ return query.operations(machine, range);
20
+ }
21
+ if (kind === 'measurements') {
22
+ return query.measurements(machine, range);
23
+ }
24
+ if (kind === 'alerts') {
25
+ return query.alerts(machine, range);
26
+ }
27
+ throw new Error(`site sync cannot pull unknown kind ${kind}`);
28
+ }
29
+
30
+ function kindsOf(request, targets) {
31
+ const selected = request.kinds && request.kinds.length > 0 ? request.kinds : Object.keys(targets);
32
+ selected.forEach((kind) => {
33
+ if (!targets[kind]) {
34
+ throw new Error(`site sync unknown kind ${kind}`);
35
+ }
36
+ });
37
+ return selected;
38
+ }
39
+
40
+ function machinesOf(request, site, query) {
41
+ if (request.machines && request.machines.length > 0) {
42
+ return Promise.resolve(request.machines);
43
+ }
44
+ if (site.machines && site.machines.length > 0) {
45
+ return Promise.resolve(site.machines);
46
+ }
47
+ return query.machines().then((body) => {
48
+ return listedItems(body).map((item) => {
49
+ return item.id;
50
+ });
51
+ });
52
+ }
53
+
54
+ function zero(kinds) {
55
+ return Object.fromEntries(kinds.map((kind) => {
56
+ return [kind, 0];
57
+ }));
58
+ }
59
+
60
+ function jobsOf(machines, kinds) {
61
+ return machines.flatMap((machine) => {
62
+ return kinds.map((kind) => {
63
+ return { machine, kind };
64
+ });
65
+ });
66
+ }
67
+
68
+ function add(counts, kind, added) {
69
+ return { ...counts, [kind]: counts[kind] + added };
70
+ }
71
+
72
+ function writeKind(query, targets, job, range) {
73
+ return pull(query, job.kind, job.machine, range).then((body) => {
74
+ return targets[job.kind].write(job.machine, body);
75
+ });
76
+ }
77
+
78
+ function fill(query, targets, kinds, machines, range) {
79
+ return jobsOf(machines, kinds).reduce((chain, job) => {
80
+ return chain.then((counts) => {
81
+ return writeKind(query, targets, job, range).then((added) => {
82
+ return add(counts, job.kind, added);
83
+ });
84
+ });
85
+ }, Promise.resolve(zero(kinds)));
86
+ }
87
+
88
+ export default function siteSync(ports) {
89
+ return Object.freeze({
90
+ async run(request) {
91
+ if (!request.from || !request.to) {
92
+ throw new Error('site sync requires from and to');
93
+ }
94
+ const site = siteById(ports.sites, request.site);
95
+ const query = ports.queryFor(site);
96
+ const kinds = kindsOf(request, ports.targets);
97
+ const machines = await machinesOf(request, site, query);
98
+ const counts = await fill(query, ports.targets, kinds, machines, request);
99
+ return { site: site.id, machines, counts };
100
+ }
101
+ });
102
+ }
@@ -0,0 +1,64 @@
1
+ import { clickhouseSink } from '@yarkivaev/source-to-sink';
2
+ import exportQuery from '../export/exportQuery.js';
3
+ import scadaClient from '../../infrastructure/client/scadaClient.js';
4
+ import { metricsSinkFromPool } from '../../infrastructure/persistence/pg/metrics.js';
5
+ import siteSync from './siteSync.js';
6
+ import siteSyncSites from './siteSyncSites.js';
7
+ import siteSyncTargets from './siteSyncTargets.js';
8
+ import syncRoute from '../../infrastructure/http/plant/routes/syncRoute.js';
9
+
10
+ /**
11
+ * Wires siteSync routes from env, sink persistence, and an optional topic port.
12
+ *
13
+ * @param {object} env - process environment
14
+ * @param {object} sink - supervisor sink with pool and dataAccess
15
+ * @param {object} operations - operations port
16
+ * @param {object} [extras] - topic(machine, key), basePath
17
+ * @returns {object[]} plant extra routes, empty when no sites are configured
18
+ *
19
+ * @example
20
+ * siteSyncBind(env, sink, ops, { topic, basePath: '/api/v1' });
21
+ */
22
+ function fetchWithToken(token) {
23
+ if (!token) {
24
+ return fetch;
25
+ }
26
+ return (url, options) => {
27
+ const headers = { ...(options && options.headers), Authorization: `Bearer ${token}` };
28
+ return fetch(url, { ...options, headers });
29
+ };
30
+ }
31
+
32
+ function metricsOf(sink, env) {
33
+ if (env.SINK_DB_PROFILE !== 'central') {
34
+ return metricsSinkFromPool(sink.pool);
35
+ }
36
+ const url = env.CLICKHOUSE_URL
37
+ || (env.CLICKHOUSE_HOST ? `http://${env.CLICKHOUSE_HOST}:8123` : undefined);
38
+ if (!url) {
39
+ return undefined;
40
+ }
41
+ return clickhouseSink(url, 'scada.metrics');
42
+ }
43
+
44
+ function queryFor(site) {
45
+ return exportQuery(scadaClient(site.url, fetchWithToken(site.token)));
46
+ }
47
+
48
+ export default function siteSyncBind(env, sink, operations, extras) {
49
+ const sites = siteSyncSites(env);
50
+ if (sites.length === 0) {
51
+ return [];
52
+ }
53
+ const extra = extras || {};
54
+ const targets = siteSyncTargets({
55
+ postgres: env.POSTGRES_URL,
56
+ pool: sink.pool,
57
+ operations,
58
+ alerts: sink.dataAccess.alerts,
59
+ metrics: metricsOf(sink, env),
60
+ topic: extra.topic
61
+ });
62
+ const sync = siteSync({ sites, queryFor, targets });
63
+ return syncRoute(extra.basePath || '/api/v1', sync, env.SYNC_TOKEN);
64
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Maps plant API JSON into local persistence rows for site sync.
3
+ *
4
+ * @example
5
+ * segmentRow('icht1', { name: 'on', start: '2026-01-01T00:00:00.000Z', end: '...', duration: 60 });
6
+ */
7
+
8
+ /**
9
+ * Maps one segment API item onto the segments table shape.
10
+ *
11
+ * @param {string} machine - machine id
12
+ * @param {object} item - plant API segment
13
+ * @returns {object} persistence row
14
+ */
15
+ export function segmentRow(machine, item) {
16
+ return {
17
+ machine,
18
+ kind: item.kind || 'phase',
19
+ name: item.name,
20
+ start_time: item.start || item.start_time,
21
+ end_time: item.end || item.end_time,
22
+ duration: item.duration,
23
+ options: item.options,
24
+ tags: item.tags,
25
+ properties: item.properties,
26
+ resolved: item.resolved !== false
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Maps one operation API item onto the operations port shape.
32
+ *
33
+ * @param {string} machine - machine id fallback
34
+ * @param {object} item - plant API operation
35
+ * @returns {object} persistence item
36
+ */
37
+ export function operationRow(machine, item) {
38
+ return {
39
+ machine: item.machine || machine,
40
+ key: item.external_key || item.key,
41
+ occurred_at: item.occurred_at,
42
+ kind: item.kind,
43
+ payload: item.payload || {}
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Maps measurement series onto metrics sink records via a topic port.
49
+ *
50
+ * @param {string} machine - machine id
51
+ * @param {object} body - plant API { items: [{ key, values }] }
52
+ * @param {function} topic - (machine, key) => MQTT/CH topic
53
+ * @returns {object[]} records with topic, ts, value
54
+ */
55
+ export function measurementRows(machine, body, topic) {
56
+ const items = body.items || [];
57
+ return items.flatMap((series) => {
58
+ const dest = topic(machine, series.key);
59
+ return (series.values || []).map((point) => {
60
+ return { topic: dest, ts: point.timestamp, value: point.value };
61
+ });
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Maps one alert API item onto the alerts table shape.
67
+ *
68
+ * @param {string} machine - machine id
69
+ * @param {object} item - plant API alert
70
+ * @returns {object} persistence row
71
+ */
72
+ export function alertRow(machine, item) {
73
+ return {
74
+ name: item.name,
75
+ message: item.message,
76
+ machine,
77
+ severity: item.severity || 'warning',
78
+ timestamp: item.timestamp,
79
+ acknowledged: item.acknowledged === true
80
+ };
81
+ }
82
+
83
+ export function listedItems(body) {
84
+ if (Array.isArray(body)) {
85
+ return body;
86
+ }
87
+ if (body && Array.isArray(body.items)) {
88
+ return body.items;
89
+ }
90
+ return [];
91
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Builds the allowlist of remote plant APIs a site may pull from.
3
+ *
4
+ * Reads SYNC_SITES, EDGE_SITES, and CENTRAL_PLANT_URL. First id wins.
5
+ *
6
+ * @param {NodeJS.ProcessEnv} env - process environment
7
+ * @returns {object[]} frozen sites with id, url, optional token and machines
8
+ *
9
+ * @example
10
+ * siteSyncSites({ SYNC_SITES: '[{"id":"edge-icht-1","url":"http://edge/api/v1"}]' });
11
+ */
12
+ function parseJson(raw, label) {
13
+ try {
14
+ return JSON.parse(raw);
15
+ } catch (cause) {
16
+ throw new Error(`${label} must be valid JSON: ${cause.message}`);
17
+ }
18
+ }
19
+
20
+ function asSite(raw, index, label) {
21
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
22
+ throw new Error(`${label}[${index}] must be an object`);
23
+ }
24
+ const url = raw.url || raw.baseUrl;
25
+ if (typeof url !== 'string' || url.length === 0) {
26
+ throw new Error(`${label}[${index}] requires url`);
27
+ }
28
+ const id = raw.id || (Array.isArray(raw.machines) && raw.machines[0]) || `${label}-${index}`;
29
+ const site = { id, url };
30
+ if (typeof raw.token === 'string' && raw.token.length > 0) {
31
+ site.token = raw.token;
32
+ }
33
+ if (Array.isArray(raw.machines) && raw.machines.length > 0) {
34
+ site.machines = raw.machines;
35
+ }
36
+ return Object.freeze(site);
37
+ }
38
+
39
+ function parseList(raw, label) {
40
+ const parsed = parseJson(raw, label);
41
+ if (!Array.isArray(parsed)) {
42
+ throw new Error(`${label} must be a JSON array`);
43
+ }
44
+ return parsed.map((item, index) => {
45
+ return asSite(item, index, label);
46
+ });
47
+ }
48
+
49
+ function merge(lists) {
50
+ const seen = new Map();
51
+ lists.flat().forEach((site) => {
52
+ if (!seen.has(site.id)) {
53
+ seen.set(site.id, site);
54
+ }
55
+ });
56
+ return [...seen.values()];
57
+ }
58
+
59
+ export default function siteSyncSites(env) {
60
+ const lists = [];
61
+ if (typeof env.SYNC_SITES === 'string' && env.SYNC_SITES.length > 0) {
62
+ lists.push(parseList(env.SYNC_SITES, 'SYNC_SITES'));
63
+ }
64
+ if (typeof env.EDGE_SITES === 'string' && env.EDGE_SITES.length > 0) {
65
+ lists.push(parseList(env.EDGE_SITES, 'EDGE_SITES'));
66
+ }
67
+ if (typeof env.CENTRAL_PLANT_URL === 'string' && env.CENTRAL_PLANT_URL.length > 0) {
68
+ lists.push([Object.freeze({ id: 'central', url: env.CENTRAL_PLANT_URL })]);
69
+ }
70
+ return merge(lists);
71
+ }
72
+
73
+ export function siteById(sites, id) {
74
+ const found = sites.find((site) => {
75
+ return site.id === id;
76
+ });
77
+ if (!found) {
78
+ throw new Error(`site sync unknown site ${id}`);
79
+ }
80
+ return found;
81
+ }
@@ -0,0 +1,98 @@
1
+ import { postgresSink } from '@yarkivaev/source-to-sink';
2
+ import {
3
+ alertRow,
4
+ listedItems,
5
+ measurementRows,
6
+ operationRow,
7
+ segmentRow
8
+ } from './siteSyncRows.js';
9
+ import {
10
+ segmentColumns,
11
+ segmentConflict,
12
+ segmentUpdateColumns
13
+ } from '../../infrastructure/ingest/pipelines/segmentPipeline.js';
14
+
15
+ /**
16
+ * Default local write targets for site sync kinds.
17
+ *
18
+ * @param {object} ports - pool, postgres, operations, alerts, metrics, topic
19
+ * @returns {object} kind → { write(machine, body) }
20
+ *
21
+ * @example
22
+ * siteSyncTargets({ operations, postgres, pool, alerts, metrics, topic });
23
+ */
24
+ function segmentTarget(postgres, pool) {
25
+ const sink = postgresSink(postgres, 'segments', segmentColumns, {
26
+ pool,
27
+ conflict: segmentConflict,
28
+ update: segmentUpdateColumns
29
+ });
30
+ return {
31
+ async write(machine, body) {
32
+ const rows = listedItems(body).map((item) => {
33
+ return segmentRow(machine, item);
34
+ });
35
+ if (rows.length > 0) {
36
+ await sink.write(rows);
37
+ }
38
+ return rows.length;
39
+ }
40
+ };
41
+ }
42
+
43
+ function operationTarget(operations) {
44
+ return {
45
+ async write(machine, body) {
46
+ const rows = listedItems(body).map((item) => {
47
+ return operationRow(machine, item);
48
+ });
49
+ if (rows.length > 0) {
50
+ await operations.upsertMany(rows);
51
+ }
52
+ return rows.length;
53
+ }
54
+ };
55
+ }
56
+
57
+ function measurementTarget(metrics, topic) {
58
+ return {
59
+ async write(machine, body) {
60
+ const rows = measurementRows(machine, body, topic);
61
+ if (rows.length > 0) {
62
+ await metrics.write(rows);
63
+ }
64
+ return rows.length;
65
+ }
66
+ };
67
+ }
68
+
69
+ function alertTarget(alerts) {
70
+ return {
71
+ async write(machine, body) {
72
+ const rows = listedItems(body).map((item) => {
73
+ return alertRow(machine, item);
74
+ });
75
+ await Promise.all(rows.map((row) => {
76
+ return alerts.put(row);
77
+ }));
78
+ return rows.length;
79
+ }
80
+ };
81
+ }
82
+
83
+ export default function siteSyncTargets(ports) {
84
+ const targets = {};
85
+ if (ports.postgres && ports.pool) {
86
+ targets.segments = segmentTarget(ports.postgres, ports.pool);
87
+ }
88
+ if (ports.operations) {
89
+ targets.operations = operationTarget(ports.operations);
90
+ }
91
+ if (ports.metrics && ports.topic) {
92
+ targets.measurements = measurementTarget(ports.metrics, ports.topic);
93
+ }
94
+ if (ports.alerts && typeof ports.alerts.put === 'function') {
95
+ targets.alerts = alertTarget(ports.alerts);
96
+ }
97
+ return Object.freeze(targets);
98
+ }
@@ -62,10 +62,10 @@ function writeMany(persistence, bus, items) {
62
62
  * sorted by occurred_at. Omitted kinds default to injectable source keys.
63
63
  * latestForMachine reads only from persistence (single kind).
64
64
  *
65
- * @param {object} persistence - store with upsert, get, remove, listForMachine, latestForMachine
65
+ * @param {object} persistence - store with upsert, get, remove, drop, listForMachine, latestForMachine
66
66
  * @param {object} bus - pubsub instance with stream and emit methods
67
67
  * @param {object} [kindSources] - map of kind to { list(machineId, range) }
68
- * @returns {object} operations with listForMachine, latestForMachine, upsert, upsertMany, get, remove, stream
68
+ * @returns {object} operations with listForMachine, latestForMachine, upsert, upsertMany, get, remove, drop, stream
69
69
  *
70
70
  * @example
71
71
  * const ops = operations(store, bus, { temp: temperaturePort });
@@ -107,6 +107,9 @@ export default function operations(persistence, bus, kindSources) {
107
107
  return operation;
108
108
  });
109
109
  },
110
+ drop(machineId, key) {
111
+ return persistence.drop(machineId, key);
112
+ },
110
113
  stream: bus.stream
111
114
  };
112
115
  }
@@ -0,0 +1,50 @@
1
+ import { errorResponse, jsonResponse, readBody, route } from '@yarkivaev/simple-server';
2
+
3
+ /**
4
+ * POST /sync — pull selected kinds from an allowlisted remote plantApi.
5
+ *
6
+ * @param {string} basePath - API prefix
7
+ * @param {object} sync - siteSync port with run(request)
8
+ * @param {string} [token] - optional Bearer token
9
+ * @returns {object[]} routes
10
+ *
11
+ * @example
12
+ * syncRoute('/api/v1', sync, process.env.SYNC_TOKEN);
13
+ */
14
+ function rejectAuth(token, req, res) {
15
+ if (!token) {
16
+ return false;
17
+ }
18
+ if (req.headers.authorization === `Bearer ${token}`) {
19
+ return false;
20
+ }
21
+ errorResponse('UNAUTHORIZED', 'site sync token is required', 401).send(res);
22
+ return true;
23
+ }
24
+
25
+ function rejectScope(parsed, res) {
26
+ if (parsed.site && parsed.from && parsed.to) {
27
+ return false;
28
+ }
29
+ errorResponse('BAD_REQUEST', 'site sync requires site from and to', 400).send(res);
30
+ return true;
31
+ }
32
+
33
+ export default function syncRoute(basePath, sync, token) {
34
+ return [
35
+ route('POST', `${basePath}/sync`, async (req, res) => {
36
+ if (rejectAuth(token, req, res)) {
37
+ return;
38
+ }
39
+ const parsed = JSON.parse(await readBody(req));
40
+ if (rejectScope(parsed, res)) {
41
+ return;
42
+ }
43
+ try {
44
+ jsonResponse(await sync.run(parsed)).send(res);
45
+ } catch (error) {
46
+ errorResponse('BAD_REQUEST', error.message, 400).send(res);
47
+ }
48
+ })
49
+ ];
50
+ }
@@ -2,10 +2,21 @@
2
2
  * In-memory alert hydration port for tests and local runs.
3
3
  *
4
4
  * @param {object} store - shared mutable store with alerts array
5
- * @returns {object} alerts port with listUnacknowledged
5
+ * @returns {object} alerts port with put and listUnacknowledged
6
6
  */
7
7
  export default function alertsStateMemory(store) {
8
8
  return {
9
+ put(row) {
10
+ const found = store.alerts.find((item) => {
11
+ return item.machine === row.machine && item.timestamp === row.timestamp && item.name === row.name;
12
+ });
13
+ if (found) {
14
+ return found;
15
+ }
16
+ const created = { id: store.alerts.length + 1, ...row };
17
+ store.alerts.push(created);
18
+ return created;
19
+ },
9
20
  listUnacknowledged(filters) {
10
21
  let rows = store.alerts.filter((row) => {
11
22
  return row.acknowledged === false;
@@ -2,7 +2,7 @@
2
2
  * PostgreSQL alert hydration port for supervisor-sink and plant STOMP alerts.
3
3
  *
4
4
  * @param {object} pool - pg pool
5
- * @returns {object} alerts port with listUnacknowledged
5
+ * @returns {object} alerts port with put and listUnacknowledged
6
6
  *
7
7
  * @example
8
8
  * const store = alertsStatePg(pool);
@@ -10,6 +10,21 @@
10
10
  */
11
11
  export default function alertsStatePg(pool) {
12
12
  return {
13
+ async put(row) {
14
+ const existing = await pool.query(
15
+ 'SELECT id FROM alerts WHERE machine = $1 AND timestamp = $2 AND name = $3',
16
+ [row.machine, row.timestamp, row.name]
17
+ );
18
+ if (existing.rows.length > 0) {
19
+ return existing.rows[0];
20
+ }
21
+ const inserted = await pool.query(
22
+ `INSERT INTO alerts (name, message, machine, severity, timestamp, acknowledged)
23
+ VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
24
+ [row.name, row.message, row.machine, row.severity, row.timestamp, row.acknowledged === true]
25
+ );
26
+ return inserted.rows[0];
27
+ },
13
28
  async listUnacknowledged(filters) {
14
29
  let sql = 'SELECT * FROM alerts WHERE acknowledged = FALSE';
15
30
  const prm = [];