@mxtommy/kip 4.5.2 → 4.6.0

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +12 -3
  2. package/package.json +13 -15
  3. package/plugin/history-series.service.js +14 -24
  4. package/plugin/index.js +139 -95
  5. package/plugin/{duckdb-parquet-storage.service.js → sqlite-history-storage.service.js} +330 -503
  6. package/public/assets/help-docs/widget-historical-series.md +5 -5
  7. package/public/{chunk-TVNXBPFF.js → chunk-356CW47X.js} +1 -1
  8. package/public/{chunk-NFJ4RQSE.js → chunk-3JA4CQ7T.js} +1 -1
  9. package/public/{chunk-VXTTEFRP.js → chunk-5SAXWR6Z.js} +8 -8
  10. package/public/{chunk-67V4XHCY.js → chunk-6A4NRSCL.js} +1 -1
  11. package/public/{chunk-P7JKENHI.js → chunk-AC6VD2FN.js} +1 -1
  12. package/public/{chunk-TBNKOU7M.js → chunk-B4NYOD6L.js} +1 -1
  13. package/public/{chunk-WH5CIUSB.js → chunk-BGGO4PGD.js} +1 -1
  14. package/public/{chunk-KQEEYPK3.js → chunk-BMHMHQFO.js} +1 -1
  15. package/public/{chunk-RCYOZLZB.js → chunk-CSIELI2Z.js} +2 -2
  16. package/public/{chunk-R36UY4Q4.js → chunk-CYTLQDGF.js} +1 -1
  17. package/public/{chunk-YI3MZWRZ.js → chunk-HSKVTFFQ.js} +1 -1
  18. package/public/{chunk-IH4CEW4C.js → chunk-MDNGWQNG.js} +8 -8
  19. package/public/{chunk-VPF5756E.js → chunk-MGLD6QDJ.js} +1 -1
  20. package/public/{chunk-P4CRTB7N.js → chunk-NJISHUGY.js} +1 -1
  21. package/public/{chunk-ISF5E3CX.js → chunk-P3M6SJQT.js} +11 -11
  22. package/public/{chunk-WQSJFJLW.js → chunk-POMIQBAL.js} +2 -2
  23. package/public/{chunk-SJFJEOSG.js → chunk-PPF5S5CV.js} +1 -1
  24. package/public/{chunk-OPTBDYBL.js → chunk-PUPM3HUQ.js} +1 -1
  25. package/public/chunk-PZ6I6W3H.js +16 -0
  26. package/public/{chunk-VXCYPAWR.js → chunk-QU3JR4YV.js} +1 -1
  27. package/public/{chunk-Q2ANAJAD.js → chunk-SUWMN3AE.js} +1 -1
  28. package/public/{chunk-CD5TQSCS.js → chunk-UYHRT3PR.js} +1 -1
  29. package/public/{chunk-FZFDGAQO.js → chunk-WJFXI5PQ.js} +1 -1
  30. package/public/{chunk-I4SJ5UNN.js → chunk-ZXO4VMEH.js} +1 -1
  31. package/public/{chunk-XBSU7OGT.js → chunk-ZY3U4H4Z.js} +1 -1
  32. package/public/index.html +1 -1
  33. package/public/{main-B6TXB3EB.js → main-I33LH3HC.js} +1 -1
  34. package/plugin/plugin-auth.service.js +0 -75
  35. package/public/chunk-BTFZS2TW.js +0 -16
@@ -1,46 +1,71 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DuckDbParquetStorageService = void 0;
36
+ exports.SqliteHistoryStorageService = void 0;
4
37
  const fs_1 = require("fs");
5
38
  const path_1 = require("path");
6
- const node_api_1 = require("@duckdb/node-api");
7
- const parquetjs_1 = require("@dsnp/parquetjs");
8
- /**
9
- * Provides DuckDB storage and Parquet flush support for captured history samples.
10
- */
11
39
  const DEFAULT_STORAGE_CONFIG = {
12
- engine: 'duckdb-parquet',
13
- databaseFile: 'plugin-config-data/kip/historicalData/kip-history.duckdb',
14
- parquetDirectory: 'plugin-config-data/kip/historicalData/parquet',
15
- flushIntervalMs: 30_000,
16
- parquetWindowMs: 60 * 60_000,
17
- parquetCompression: 'snappy'
40
+ engine: 'node:sqlite',
41
+ databaseFile: 'plugin-config-data/kip/historicalData/kip-history.sqlite',
42
+ flushIntervalMs: 30_000
18
43
  };
19
- class DuckDbParquetStorageService {
20
- config = { ...DEFAULT_STORAGE_CONFIG };
21
- // 8 hour job interval (8 hours)
44
+ /**
45
+ * Provides node:sqlite storage for captured history samples.
46
+ */
47
+ class SqliteHistoryStorageService {
22
48
  static EIGHT_HOURS_INTERVAL = 8 * 60 * 60 * 1000;
23
- vacuumJob = null;
24
- // 4 hour job interval (4 hours)
25
49
  static FOUR_HOURS_INTERVAL = 4 * 60 * 60 * 1000;
26
- pruneJob = null;
27
- // Stale series cleanup interval (6 months)
28
- static STALE_SERIES_AGE_MS = 180 * 24 * 60 * 60 * 1000; // 6 months
29
- staleSeriesCleanupJob = null;
50
+ static STALE_SERIES_AGE_MS = 180 * 24 * 60 * 60 * 1000;
30
51
  static PRUNE_BATCH_SIZE = 10_000;
52
+ config = { ...DEFAULT_STORAGE_CONFIG };
53
+ dataDirPath = null;
31
54
  logger = {
32
55
  debug: () => undefined,
33
56
  error: () => undefined
34
57
  };
35
58
  db = null;
36
- connection = null;
37
59
  pendingRows = [];
38
- pendingRangesBySeriesId = new Map();
39
60
  lastInitError = null;
40
61
  lifecycleToken = 0;
41
62
  initialized = false;
63
+ runtimeAvailable = true;
42
64
  maintenanceInProgress = false;
43
65
  flushInProgress = false;
66
+ vacuumJob = null;
67
+ pruneJob = null;
68
+ staleSeriesCleanupJob = null;
44
69
  /**
45
70
  * Sets logger callbacks used by the storage service.
46
71
  *
@@ -56,7 +81,7 @@ class DuckDbParquetStorageService {
56
81
  /**
57
82
  * Applies the fixed storage backend configuration.
58
83
  *
59
- * @returns {IDuckDbParquetStorageConfig} Fixed storage configuration.
84
+ * @returns {ISqliteHistoryStorageConfig} Fixed storage configuration.
60
85
  *
61
86
  * @example
62
87
  * const cfg = storage.configure();
@@ -64,229 +89,100 @@ class DuckDbParquetStorageService {
64
89
  */
65
90
  configure() {
66
91
  this.initialized = false;
67
- this.config = { ...DEFAULT_STORAGE_CONFIG };
92
+ const databaseFile = this.dataDirPath
93
+ ? (0, path_1.join)(this.dataDirPath, 'historicalData', 'kip-history.sqlite')
94
+ : DEFAULT_STORAGE_CONFIG.databaseFile;
95
+ this.config = {
96
+ ...DEFAULT_STORAGE_CONFIG,
97
+ databaseFile
98
+ };
68
99
  return this.config;
69
100
  }
70
101
  /**
71
- * Initializes DuckDB storage if DuckDB engine is selected.
102
+ * Sets the base directory for persisted history data.
103
+ *
104
+ * @param {string | null} baseDir Absolute directory path for plugin data.
105
+ * @returns {void}
106
+ *
107
+ * @example
108
+ * storage.setDataDirPath('/var/lib/signalk');
109
+ */
110
+ setDataDirPath(baseDir) {
111
+ this.dataDirPath = typeof baseDir === 'string' && baseDir.trim() ? baseDir.trim() : null;
112
+ }
113
+ /**
114
+ * Updates runtime availability of node:sqlite, clearing stored errors when enabled.
115
+ *
116
+ * @param {boolean} available Whether node:sqlite is available at runtime.
117
+ * @param {string | undefined} errorMessage Optional runtime error message.
118
+ * @returns {void}
119
+ *
120
+ * @example
121
+ * storage.setRuntimeAvailability(false, 'node:sqlite unavailable');
122
+ */
123
+ setRuntimeAvailability(available, errorMessage) {
124
+ this.runtimeAvailable = available;
125
+ this.lastInitError = available ? null : (errorMessage ?? 'node:sqlite unavailable');
126
+ if (!available) {
127
+ this.initialized = false;
128
+ this.db = null;
129
+ }
130
+ }
131
+ /**
132
+ * Initializes node:sqlite storage.
72
133
  *
73
- * @returns {Promise<boolean>} True when DuckDB is initialized and ready.
134
+ * @returns {Promise<boolean>} True when node:sqlite is initialized and ready.
74
135
  *
75
136
  * @example
76
137
  * const ready = await storage.initialize();
77
138
  */
78
139
  async initialize() {
79
- if (!this.isDuckDbParquetEnabled()) {
140
+ if (!this.isSqliteEnabled() || !this.runtimeAvailable) {
80
141
  return false;
81
142
  }
82
143
  this.initialized = false;
83
144
  this.lifecycleToken += 1;
84
145
  try {
146
+ const sqlite = await this.loadSqliteModule();
147
+ if (!sqlite?.DatabaseSync) {
148
+ throw new Error('node:sqlite DatabaseSync is unavailable');
149
+ }
85
150
  const dbPath = (0, path_1.resolve)(this.config.databaseFile);
86
151
  (0, fs_1.mkdirSync)((0, path_1.dirname)(dbPath), { recursive: true });
87
- (0, fs_1.mkdirSync)((0, path_1.resolve)(this.config.parquetDirectory), { recursive: true });
88
- this.db = await node_api_1.DuckDBInstance.create(dbPath);
89
- this.connection = await this.db.connect();
152
+ this.db = new sqlite.DatabaseSync(dbPath, { timeout: 5000 });
153
+ this.db.exec('PRAGMA journal_mode=WAL;');
154
+ this.db.exec('PRAGMA synchronous=NORMAL;');
155
+ this.db.exec('PRAGMA temp_store=MEMORY;');
156
+ this.db.exec('PRAGMA foreign_keys=ON;');
90
157
  await this.createCoreTables();
91
158
  await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_series_scope_ts ON history_samples(series_id, ts_ms)');
92
159
  await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_series_scope_id ON history_series(series_id)');
93
160
  await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_samples_scope_context_path_ts ON history_samples(context, path, ts_ms)');
94
161
  await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_samples_scope_ts_path ON history_samples(ts_ms, path)');
95
162
  await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_samples_scope_ts_context ON history_samples(ts_ms, context)');
96
- this.logger.debug(`[SERIES STORAGE] DuckDB initialized at ${dbPath}`);
163
+ this.logger.debug(`[SERIES STORAGE] node:sqlite initialized at ${dbPath}`);
97
164
  this.lastInitError = null;
98
165
  this.initialized = true;
99
- // Start VACUUM job
100
166
  this.startVacuumJob();
101
- // Start prune job
102
167
  this.startPruneJob();
103
- // Start stale series cleanup job
104
168
  this.startStaleSeriesCleanupJob();
105
169
  return true;
106
170
  }
107
171
  catch (error) {
108
172
  const message = error?.message ?? String(error);
109
173
  this.lastInitError = message;
110
- this.logger.error(`[SERIES STORAGE] DuckDB initialization failed: ${message}`);
111
- this.logger.error('[SERIES STORAGE] DuckDB Node API is required. Install runtime dependency with: npm i @duckdb/node-api in the installed plugin directory, then restart Signal K.');
112
- this.connection = null;
174
+ this.logger.error(`[SERIES STORAGE] node:sqlite initialization failed: ${message}`);
113
175
  this.db = null;
114
176
  this.pendingRows = [];
115
- this.pendingRangesBySeriesId.clear();
116
177
  this.initialized = false;
117
178
  this.stopVacuumJob();
179
+ this.stopPruneJob();
180
+ this.stopStaleSeriesCleanupJob();
118
181
  return false;
119
182
  }
120
183
  }
121
184
  /**
122
- * Starts VACUUM job for DuckDB.
123
- */
124
- startVacuumJob() {
125
- this.stopVacuumJob();
126
- if (!this.isDuckDbParquetReady() || !this.connection)
127
- return;
128
- this.vacuumJob = setInterval(() => {
129
- if (this.shouldSkipMaintenance()) {
130
- return;
131
- }
132
- void this.runWithMaintenanceLock('vacuum', async () => {
133
- this.logger.debug('[SERIES STORAGE] Running scheduled DuckDB VACUUM');
134
- await this.runSql('VACUUM;');
135
- }).catch(err => {
136
- this.logger.error(`[SERIES STORAGE] VACUUM failed: ${err?.message ?? err}`);
137
- });
138
- }, DuckDbParquetStorageService.EIGHT_HOURS_INTERVAL);
139
- this.vacuumJob.unref?.();
140
- }
141
- /**
142
- * Stops the scheduled VACUUM job if running.
143
- */
144
- stopVacuumJob() {
145
- if (this.vacuumJob) {
146
- clearInterval(this.vacuumJob);
147
- this.vacuumJob = null;
148
- }
149
- }
150
- /**
151
- * Starts the prune job for expired and orphaned samples.
152
- */
153
- startPruneJob() {
154
- this.stopPruneJob();
155
- if (!this.isDuckDbParquetReady() || !this.connection)
156
- return;
157
- this.pruneJob = setInterval(async () => {
158
- if (this.shouldSkipMaintenance()) {
159
- return;
160
- }
161
- try {
162
- await this.runWithMaintenanceLock('prune', async () => {
163
- this.logger.debug('[SERIES STORAGE] Running scheduled prune of expired and orphaned samples');
164
- const expired = await this.pruneExpiredSamples(Date.now(), this.lifecycleToken);
165
- const orphaned = await this.pruneOrphanedSamples(this.lifecycleToken);
166
- const parquetRemoved = await this.pruneParquetFilesByRetention(Date.now());
167
- this.logger.debug(`[SERIES STORAGE] Pruned ${expired} expired and ${orphaned} orphaned samples`);
168
- if (parquetRemoved > 0) {
169
- this.logger.debug(`[SERIES STORAGE] Pruned ${parquetRemoved} parquet files/directories`);
170
- }
171
- });
172
- }
173
- catch (err) {
174
- this.logger.error(`[SERIES STORAGE] Prune failed: ${err?.message ?? err}`);
175
- }
176
- }, DuckDbParquetStorageService.FOUR_HOURS_INTERVAL);
177
- this.pruneJob.unref?.();
178
- }
179
- /**
180
- * Stops the scheduled prune job if running.
181
- */
182
- stopPruneJob() {
183
- if (this.pruneJob) {
184
- clearInterval(this.pruneJob);
185
- this.pruneJob = null;
186
- }
187
- }
188
- /**
189
- * Starts the scheduled job to delete series not reconciled in the last 6 months.
190
- */
191
- startStaleSeriesCleanupJob() {
192
- this.stopStaleSeriesCleanupJob();
193
- if (!this.isDuckDbParquetReady() || !this.connection)
194
- return;
195
- this.staleSeriesCleanupJob = setInterval(async () => {
196
- if (this.shouldSkipMaintenance()) {
197
- return;
198
- }
199
- try {
200
- await this.runWithMaintenanceLock('stale-cleanup', async () => {
201
- const cutoff = Date.now() - DuckDbParquetStorageService.STALE_SERIES_AGE_MS;
202
- this.logger.debug(`[SERIES STORAGE] Running scheduled stale series cleanup (cutoff: ${new Date(cutoff).toISOString()})`);
203
- const deleted = await this.deleteStaleSeries(cutoff);
204
- if (deleted > 0) {
205
- this.logger.debug(`[SERIES STORAGE] Deleted ${deleted} series not reconciled in the last 6 months`);
206
- }
207
- });
208
- }
209
- catch (err) {
210
- this.logger.error(`[SERIES STORAGE] Stale series cleanup failed: ${err?.message ?? err}`);
211
- }
212
- }, DuckDbParquetStorageService.EIGHT_HOURS_INTERVAL);
213
- this.staleSeriesCleanupJob.unref?.();
214
- }
215
- /**
216
- * Deletes series not reconciled since the given cutoff timestamp.
217
- * @param {number} cutoffMs - Milliseconds since epoch; series with reconcile_ts < cutoffMs will be deleted.
218
- * @returns {Promise<number>} Number of deleted series.
219
- *
220
- * @example
221
- * const deleted = await storage.deleteStaleSeries(Date.now() - 180 * 24 * 60 * 60 * 1000);
222
- */
223
- async deleteStaleSeries(cutoffMs) {
224
- if (!this.isDuckDbParquetEnabled() || !this.connection) {
225
- return 0;
226
- }
227
- // Find series to delete
228
- const rows = await this.querySql(`
229
- SELECT series_id FROM history_series
230
- WHERE reconcile_ts IS NULL OR reconcile_ts < ${Math.trunc(cutoffMs)}
231
- `);
232
- const ids = rows.map(r => r.series_id);
233
- if (ids.length === 0)
234
- return 0;
235
- for (const id of ids) {
236
- await this.deleteSeriesDefinition(id);
237
- }
238
- return ids.length;
239
- }
240
- /**
241
- * Stops the scheduled stale series cleanup job if running.
242
- */
243
- stopStaleSeriesCleanupJob() {
244
- if (this.staleSeriesCleanupJob) {
245
- clearInterval(this.staleSeriesCleanupJob);
246
- this.staleSeriesCleanupJob = null;
247
- }
248
- }
249
- shouldSkipMaintenance() {
250
- if (!this.isDuckDbParquetReady() || !this.connection) {
251
- return true;
252
- }
253
- if (this.maintenanceInProgress || this.flushInProgress) {
254
- return true;
255
- }
256
- if (this.pendingRows.length > 0) {
257
- return true;
258
- }
259
- return false;
260
- }
261
- async runWithMaintenanceLock(label, task) {
262
- if (this.maintenanceInProgress) {
263
- this.logger.debug(`[SERIES STORAGE] Skipping ${label} (maintenance already running)`);
264
- return;
265
- }
266
- this.maintenanceInProgress = true;
267
- const startedAt = Date.now();
268
- try {
269
- await task();
270
- const elapsedMs = Date.now() - startedAt;
271
- this.logger.debug(`[SERIES STORAGE] ${label} completed in ${elapsedMs}ms`);
272
- }
273
- finally {
274
- this.maintenanceInProgress = false;
275
- }
276
- }
277
- /**
278
- * Returns the active storage configuration.
279
- *
280
- * @returns {IDuckDbParquetStorageConfig} Current storage configuration.
281
- *
282
- * @example
283
- * const cfg = storage.getConfig();
284
- */
285
- getConfig() {
286
- return this.config;
287
- }
288
- /**
289
- * Returns last DuckDB initialization error when initialization failed.
185
+ * Returns last node:sqlite initialization error when initialization failed.
290
186
  *
291
187
  * @returns {string | null} Initialization error text or null.
292
188
  *
@@ -297,38 +193,34 @@ class DuckDbParquetStorageService {
297
193
  return this.lastInitError;
298
194
  }
299
195
  /**
300
- * Indicates whether DuckDB/Parquet mode is selected.
196
+ * Indicates whether node:sqlite mode is selected.
301
197
  *
302
- * @returns {boolean} True when the selected engine is `duckdb-parquet`.
198
+ * @returns {boolean} True when the selected engine is `node:sqlite`.
303
199
  *
304
200
  * @example
305
- * if (storage.isDuckDbParquetEnabled()) {
306
- * console.log('DuckDB mode enabled');
201
+ * if (storage.isSqliteEnabled()) {
202
+ * console.log('node:sqlite mode enabled');
307
203
  * }
308
204
  */
309
- isDuckDbParquetEnabled() {
310
- return this.config.engine === 'duckdb-parquet';
205
+ isSqliteEnabled() {
206
+ return this.config.engine === 'node:sqlite';
311
207
  }
312
208
  /**
313
- * Indicates whether DuckDB/Parquet mode is initialized and ready.
209
+ * Indicates whether node:sqlite mode is initialized and ready.
314
210
  *
315
- * @returns {boolean} True when DuckDB mode is selected and an active connection exists.
211
+ * @returns {boolean} True when node:sqlite mode is selected and an active connection exists.
316
212
  *
317
213
  * @example
318
- * if (storage.isDuckDbParquetReady()) {
319
- * console.log('DuckDB ready');
214
+ * if (storage.isSqliteReady()) {
215
+ * console.log('node:sqlite ready');
320
216
  * }
321
217
  */
322
- isDuckDbParquetReady() {
323
- return this.isDuckDbParquetEnabled() && this.initialized && this.connection !== null;
218
+ isSqliteReady() {
219
+ return this.isSqliteEnabled() && this.initialized && this.db !== null && this.runtimeAvailable;
324
220
  }
325
221
  /**
326
222
  * Returns the current storage lifecycle token.
327
223
  *
328
- * The token changes whenever a new initialization attempt starts and can be
329
- * used by callers to scope async stop operations (flush/close) so stale work
330
- * does not affect a newer startup session.
331
- *
332
224
  * @returns {number} Current lifecycle token.
333
225
  *
334
226
  * @example
@@ -347,30 +239,16 @@ class DuckDbParquetStorageService {
347
239
  * storage.enqueueSample(sample);
348
240
  */
349
241
  enqueueSample(sample) {
350
- if (!this.isDuckDbParquetReady()) {
242
+ if (!this.isSqliteReady()) {
351
243
  return;
352
244
  }
353
245
  this.pendingRows.push(sample);
354
- const rangeKey = sample.seriesId;
355
- const existing = this.pendingRangesBySeriesId.get(rangeKey);
356
- if (!existing) {
357
- this.pendingRangesBySeriesId.set(rangeKey, {
358
- seriesId: sample.seriesId,
359
- minTs: sample.timestamp,
360
- maxTs: sample.timestamp
361
- });
362
- return;
363
- }
364
- this.pendingRangesBySeriesId.set(rangeKey, {
365
- seriesId: existing.seriesId,
366
- minTs: Math.min(existing.minTs, sample.timestamp),
367
- maxTs: Math.max(existing.maxTs, sample.timestamp)
368
- });
369
246
  }
370
247
  /**
371
- * Flushes queued samples into DuckDB and exports changed ranges to Parquet chunks.
248
+ * Flushes queued samples into node:sqlite.
372
249
  *
373
- * @returns {Promise<{inserted: number; exported: number}>} Number of inserted rows and exported parquet files.
250
+ * @param {number} [expectedLifecycleToken] Optional lifecycle token guard to skip stale flushes.
251
+ * @returns {Promise<{ inserted: number; exported: number }>} Number of inserted rows (exported is always 0).
374
252
  *
375
253
  * @example
376
254
  * const result = await storage.flush();
@@ -379,7 +257,7 @@ class DuckDbParquetStorageService {
379
257
  if (expectedLifecycleToken !== undefined && expectedLifecycleToken !== this.lifecycleToken) {
380
258
  return { inserted: 0, exported: 0 };
381
259
  }
382
- if (!this.isDuckDbParquetEnabled() || !this.connection || this.pendingRows.length === 0) {
260
+ if (!this.isSqliteEnabled() || !this.db || this.pendingRows.length === 0) {
383
261
  return { inserted: 0, exported: 0 };
384
262
  }
385
263
  if (this.flushInProgress) {
@@ -387,35 +265,16 @@ class DuckDbParquetStorageService {
387
265
  }
388
266
  this.flushInProgress = true;
389
267
  const rows = this.pendingRows;
390
- const ranges = new Map(this.pendingRangesBySeriesId);
391
268
  this.pendingRows = [];
392
- this.pendingRangesBySeriesId.clear();
393
269
  const startedAt = Date.now();
394
270
  try {
395
271
  await this.insertRows(rows);
396
- let exported = 0;
397
- for (const range of ranges.values()) {
398
- await this.exportSeriesRange(range.seriesId, range.minTs, range.maxTs);
399
- exported += 1;
400
- }
401
272
  const elapsedMs = Date.now() - startedAt;
402
- this.logger.debug(`[SERIES STORAGE] flush inserted=${rows.length} exported=${exported} durationMs=${elapsedMs}`);
403
- return { inserted: rows.length, exported };
273
+ this.logger.debug(`[SERIES STORAGE] flush inserted=${rows.length} durationMs=${elapsedMs}`);
274
+ return { inserted: rows.length, exported: 0 };
404
275
  }
405
276
  catch (error) {
406
277
  this.pendingRows = [...rows, ...this.pendingRows];
407
- ranges.forEach((range, rangeKey) => {
408
- const current = this.pendingRangesBySeriesId.get(rangeKey);
409
- if (!current) {
410
- this.pendingRangesBySeriesId.set(rangeKey, range);
411
- return;
412
- }
413
- this.pendingRangesBySeriesId.set(rangeKey, {
414
- seriesId: current.seriesId,
415
- minTs: Math.min(current.minTs, range.minTs),
416
- maxTs: Math.max(current.maxTs, range.maxTs)
417
- });
418
- });
419
278
  throw error;
420
279
  }
421
280
  finally {
@@ -423,7 +282,7 @@ class DuckDbParquetStorageService {
423
282
  }
424
283
  }
425
284
  /**
426
- * Returns persisted series definitions from DuckDB.
285
+ * Returns persisted series definitions from node:sqlite.
427
286
  *
428
287
  * @returns {Promise<ISeriesDefinition[]>} Stored series definitions.
429
288
  *
@@ -431,7 +290,7 @@ class DuckDbParquetStorageService {
431
290
  * const series = await storage.getSeriesDefinitions();
432
291
  */
433
292
  async getSeriesDefinitions() {
434
- if (!this.isDuckDbParquetEnabled() || !this.connection) {
293
+ if (!this.isSqliteEnabled() || !this.db) {
435
294
  return [];
436
295
  }
437
296
  const rows = await this.querySql(`
@@ -448,7 +307,8 @@ class DuckDbParquetStorageService {
448
307
  retention_duration_ms,
449
308
  sample_time,
450
309
  enabled,
451
- methods_json
310
+ methods_json,
311
+ reconcile_ts
452
312
  FROM history_series
453
313
  ORDER BY series_id ASC
454
314
  `);
@@ -465,11 +325,12 @@ class DuckDbParquetStorageService {
465
325
  retentionDurationMs: this.toNumberOrUndefined(row.retention_duration_ms),
466
326
  sampleTime: this.toNumberOrUndefined(row.sample_time),
467
327
  enabled: this.toBoolean(row.enabled),
468
- methods: this.parseMethods(row.methods_json)
328
+ methods: this.parseMethods(row.methods_json),
329
+ reconcileTs: this.toNumberOrUndefined(row.reconcile_ts)
469
330
  }));
470
331
  }
471
332
  /**
472
- * Persists one series definition in DuckDB.
333
+ * Persists one series definition in node:sqlite.
473
334
  *
474
335
  * @param {ISeriesDefinition} series Series definition to persist.
475
336
  * @returns {Promise<void>}
@@ -478,7 +339,7 @@ class DuckDbParquetStorageService {
478
339
  * await storage.upsertSeriesDefinition(series);
479
340
  */
480
341
  async upsertSeriesDefinition(series) {
481
- if (!this.isDuckDbParquetEnabled() || !this.connection) {
342
+ if (!this.isSqliteEnabled() || !this.db) {
482
343
  return;
483
344
  }
484
345
  await this.runSql(`DELETE FROM history_series WHERE series_id = ${this.escape(series.seriesId)}`);
@@ -496,7 +357,8 @@ class DuckDbParquetStorageService {
496
357
  retention_duration_ms,
497
358
  sample_time,
498
359
  enabled,
499
- methods_json
360
+ methods_json,
361
+ reconcile_ts
500
362
  ) VALUES (
501
363
  ${this.escape(series.seriesId)},
502
364
  ${this.escape(series.datasetUuid)},
@@ -509,13 +371,14 @@ class DuckDbParquetStorageService {
509
371
  ${this.nullableNumber(series.period)},
510
372
  ${this.nullableNumber(series.retentionDurationMs)},
511
373
  ${this.nullableNumber(series.sampleTime)},
512
- ${series.enabled === false ? 'FALSE' : 'TRUE'},
513
- ${this.nullableString(series.methods ? JSON.stringify(series.methods) : null)}
374
+ ${series.enabled === false ? '0' : '1'},
375
+ ${this.nullableString(series.methods ? JSON.stringify(series.methods) : null)},
376
+ ${this.nullableNumber(series.reconcileTs)}
514
377
  )
515
378
  `);
516
379
  }
517
380
  /**
518
- * Deletes one persisted series definition in DuckDB.
381
+ * Deletes one persisted series definition in node:sqlite.
519
382
  *
520
383
  * @param {string} seriesId Series identifier.
521
384
  * @returns {Promise<void>}
@@ -524,11 +387,10 @@ class DuckDbParquetStorageService {
524
387
  * await storage.deleteSeriesDefinition('series-1');
525
388
  */
526
389
  async deleteSeriesDefinition(seriesId) {
527
- if (!this.isDuckDbParquetEnabled() || !this.connection) {
390
+ if (!this.isSqliteEnabled() || !this.db) {
528
391
  return;
529
392
  }
530
393
  await this.runSql(`DELETE FROM history_series WHERE series_id = ${this.escape(seriesId)}`);
531
- this.deleteParquetSeriesDir(seriesId);
532
394
  }
533
395
  /**
534
396
  * Replaces persisted series definitions with desired set.
@@ -540,7 +402,7 @@ class DuckDbParquetStorageService {
540
402
  * await storage.replaceSeriesDefinitions(series);
541
403
  */
542
404
  async replaceSeriesDefinitions(series) {
543
- if (!this.isDuckDbParquetEnabled() || !this.connection) {
405
+ if (!this.isSqliteEnabled() || !this.db) {
544
406
  return;
545
407
  }
546
408
  await this.runSql('DELETE FROM history_series');
@@ -548,6 +410,29 @@ class DuckDbParquetStorageService {
548
410
  await this.upsertSeriesDefinition(item);
549
411
  }
550
412
  }
413
+ /**
414
+ * Deletes series not reconciled since the given cutoff timestamp.
415
+ *
416
+ * @param {number} cutoffMs Milliseconds since epoch; series with reconcile_ts < cutoffMs will be deleted.
417
+ * @returns {Promise<number>} Number of deleted series.
418
+ *
419
+ * @example
420
+ * const deleted = await storage.deleteStaleSeries(Date.now() - 180 * 24 * 60 * 60 * 1000);
421
+ */
422
+ async deleteStaleSeries(cutoffMs) {
423
+ if (!this.isSqliteEnabled() || !this.db) {
424
+ return 0;
425
+ }
426
+ const rows = await this.querySql(`
427
+ SELECT series_id FROM history_series
428
+ WHERE reconcile_ts IS NULL OR reconcile_ts < ${Math.trunc(cutoffMs)}
429
+ `);
430
+ const ids = rows.map(row => row.series_id).filter(Boolean);
431
+ for (const id of ids) {
432
+ await this.deleteSeriesDefinition(id);
433
+ }
434
+ return ids.length;
435
+ }
551
436
  /**
552
437
  * Removes persisted samples that are older than each series retention window.
553
438
  *
@@ -562,7 +447,7 @@ class DuckDbParquetStorageService {
562
447
  if (expectedLifecycleToken !== undefined && expectedLifecycleToken !== this.lifecycleToken) {
563
448
  return 0;
564
449
  }
565
- if (!this.isDuckDbParquetEnabled() || !this.connection) {
450
+ if (!this.isSqliteEnabled() || !this.db) {
566
451
  return 0;
567
452
  }
568
453
  const anchorMs = Math.trunc(Number.isFinite(nowMs) ? nowMs : Date.now());
@@ -582,7 +467,7 @@ class DuckDbParquetStorageService {
582
467
  SELECT rowid
583
468
  FROM history_samples
584
469
  WHERE ${whereClause}
585
- LIMIT ${DuckDbParquetStorageService.PRUNE_BATCH_SIZE}
470
+ LIMIT ${SqliteHistoryStorageService.PRUNE_BATCH_SIZE}
586
471
  `);
587
472
  if (batch.length === 0) {
588
473
  break;
@@ -596,7 +481,7 @@ class DuckDbParquetStorageService {
596
481
  WHERE rowid IN (${rowIds.join(', ')})
597
482
  `);
598
483
  removedRows += rowIds.length;
599
- if (rowIds.length < DuckDbParquetStorageService.PRUNE_BATCH_SIZE) {
484
+ if (rowIds.length < SqliteHistoryStorageService.PRUNE_BATCH_SIZE) {
600
485
  break;
601
486
  }
602
487
  }
@@ -615,7 +500,7 @@ class DuckDbParquetStorageService {
615
500
  if (expectedLifecycleToken !== undefined && expectedLifecycleToken !== this.lifecycleToken) {
616
501
  return 0;
617
502
  }
618
- if (!this.isDuckDbParquetEnabled() || !this.connection) {
503
+ if (!this.isSqliteEnabled() || !this.db) {
619
504
  return 0;
620
505
  }
621
506
  const whereClause = `
@@ -631,7 +516,7 @@ class DuckDbParquetStorageService {
631
516
  SELECT rowid
632
517
  FROM history_samples
633
518
  WHERE ${whereClause}
634
- LIMIT ${DuckDbParquetStorageService.PRUNE_BATCH_SIZE}
519
+ LIMIT ${SqliteHistoryStorageService.PRUNE_BATCH_SIZE}
635
520
  `);
636
521
  if (batch.length === 0) {
637
522
  break;
@@ -645,7 +530,7 @@ class DuckDbParquetStorageService {
645
530
  WHERE rowid IN (${rowIds.join(', ')})
646
531
  `);
647
532
  removedRows += rowIds.length;
648
- if (rowIds.length < DuckDbParquetStorageService.PRUNE_BATCH_SIZE) {
533
+ if (rowIds.length < SqliteHistoryStorageService.PRUNE_BATCH_SIZE) {
649
534
  break;
650
535
  }
651
536
  }
@@ -654,13 +539,14 @@ class DuckDbParquetStorageService {
654
539
  /**
655
540
  * Lists known history paths from persisted samples.
656
541
  *
542
+ * @param {IHistoryRangeQuery} [query] Optional range filter.
657
543
  * @returns {Promise<string[]>} Ordered path names.
658
544
  *
659
545
  * @example
660
546
  * const paths = await storage.getStoredPaths();
661
547
  */
662
548
  async getStoredPaths(query) {
663
- if (!this.isDuckDbParquetEnabled() || !this.connection) {
549
+ if (!this.isSqliteEnabled() || !this.db) {
664
550
  return [];
665
551
  }
666
552
  const nowMs = Date.now();
@@ -678,13 +564,14 @@ class DuckDbParquetStorageService {
678
564
  /**
679
565
  * Lists known history contexts from persisted samples.
680
566
  *
567
+ * @param {IHistoryRangeQuery} [query] Optional range filter.
681
568
  * @returns {Promise<string[]>} Ordered context names.
682
569
  *
683
570
  * @example
684
571
  * const contexts = await storage.getStoredContexts();
685
572
  */
686
573
  async getStoredContexts(query) {
687
- if (!this.isDuckDbParquetEnabled() || !this.connection) {
574
+ if (!this.isSqliteEnabled() || !this.db) {
688
575
  return [];
689
576
  }
690
577
  const nowMs = Date.now();
@@ -700,16 +587,16 @@ class DuckDbParquetStorageService {
700
587
  return rows.map(row => row.value).filter(Boolean);
701
588
  }
702
589
  /**
703
- * Queries history values directly from DuckDB in History API-compatible shape.
590
+ * Queries history values directly from node:sqlite in History API-compatible shape.
704
591
  *
705
592
  * @param {IHistoryQueryParams} query Incoming history values query parameters.
706
- * @returns {Promise<IHistoryValuesResponse | null>} History payload when DuckDB is ready, otherwise null.
593
+ * @returns {Promise<IHistoryValuesResponse | null>} History payload when node:sqlite is ready, otherwise null.
707
594
  *
708
595
  * @example
709
596
  * const result = await storage.getValues({ paths: 'navigation.speedOverGround:avg', duration: 'PT1H' });
710
597
  */
711
598
  async getValues(query) {
712
- if (!this.isDuckDbParquetEnabled() || !this.connection) {
599
+ if (!this.isSqliteEnabled() || !this.db) {
713
600
  return null;
714
601
  }
715
602
  const nowMs = Date.now();
@@ -753,6 +640,7 @@ class DuckDbParquetStorageService {
753
640
  /**
754
641
  * Closes open storage resources.
755
642
  *
643
+ * @param {number} [expectedLifecycleToken] Optional lifecycle token guard to skip stale closes.
756
644
  * @returns {Promise<void>}
757
645
  *
758
646
  * @example
@@ -763,64 +651,174 @@ class DuckDbParquetStorageService {
763
651
  return;
764
652
  }
765
653
  this.initialized = false;
654
+ this.stopVacuumJob();
766
655
  this.stopPruneJob();
767
656
  this.stopStaleSeriesCleanupJob();
768
- if (!this.connection) {
769
- this.db = null;
657
+ if (!this.db) {
770
658
  return;
771
659
  }
772
- const connection = this.connection;
773
660
  const db = this.db;
774
- this.connection = null;
661
+ this.db = null;
775
662
  try {
776
- connection.disconnectSync();
663
+ db.close();
777
664
  }
778
665
  catch {
779
- // ignore disconnect failures during shutdown
666
+ // ignore close failures during shutdown
780
667
  }
668
+ }
669
+ async loadSqliteModule() {
781
670
  try {
782
- db?.closeSync();
671
+ return await Promise.resolve().then(() => __importStar(require('node:sqlite')));
783
672
  }
784
673
  catch {
785
- // ignore close failures during shutdown
674
+ return null;
675
+ }
676
+ }
677
+ startVacuumJob() {
678
+ this.stopVacuumJob();
679
+ if (!this.isSqliteReady())
680
+ return;
681
+ this.vacuumJob = setInterval(() => {
682
+ if (this.shouldSkipMaintenance()) {
683
+ return;
684
+ }
685
+ void this.runWithMaintenanceLock('vacuum', async () => {
686
+ this.logger.debug('[SERIES STORAGE] Running scheduled node:sqlite VACUUM');
687
+ await this.runSql('VACUUM;');
688
+ await this.runSql('PRAGMA optimize;');
689
+ }).catch(err => {
690
+ this.logger.error(`[SERIES STORAGE] VACUUM failed: ${err?.message ?? err}`);
691
+ });
692
+ }, SqliteHistoryStorageService.EIGHT_HOURS_INTERVAL);
693
+ this.vacuumJob.unref?.();
694
+ }
695
+ stopVacuumJob() {
696
+ if (this.vacuumJob) {
697
+ clearInterval(this.vacuumJob);
698
+ this.vacuumJob = null;
699
+ }
700
+ }
701
+ startPruneJob() {
702
+ this.stopPruneJob();
703
+ if (!this.isSqliteReady())
704
+ return;
705
+ this.pruneJob = setInterval(async () => {
706
+ if (this.shouldSkipMaintenance()) {
707
+ return;
708
+ }
709
+ try {
710
+ await this.runWithMaintenanceLock('prune', async () => {
711
+ this.logger.debug('[SERIES STORAGE] Running scheduled prune of expired and orphaned samples');
712
+ const expired = await this.pruneExpiredSamples(Date.now(), this.lifecycleToken);
713
+ const orphaned = await this.pruneOrphanedSamples(this.lifecycleToken);
714
+ this.logger.debug(`[SERIES STORAGE] Pruned ${expired} expired and ${orphaned} orphaned samples`);
715
+ });
716
+ }
717
+ catch (err) {
718
+ this.logger.error(`[SERIES STORAGE] Prune failed: ${err?.message ?? err}`);
719
+ }
720
+ }, SqliteHistoryStorageService.FOUR_HOURS_INTERVAL);
721
+ this.pruneJob.unref?.();
722
+ }
723
+ stopPruneJob() {
724
+ if (this.pruneJob) {
725
+ clearInterval(this.pruneJob);
726
+ this.pruneJob = null;
727
+ }
728
+ }
729
+ startStaleSeriesCleanupJob() {
730
+ this.stopStaleSeriesCleanupJob();
731
+ if (!this.isSqliteReady())
732
+ return;
733
+ this.staleSeriesCleanupJob = setInterval(async () => {
734
+ if (this.shouldSkipMaintenance()) {
735
+ return;
736
+ }
737
+ try {
738
+ await this.runWithMaintenanceLock('stale-cleanup', async () => {
739
+ const cutoff = Date.now() - SqliteHistoryStorageService.STALE_SERIES_AGE_MS;
740
+ this.logger.debug(`[SERIES STORAGE] Running scheduled stale series cleanup (cutoff: ${new Date(cutoff).toISOString()})`);
741
+ const deleted = await this.deleteStaleSeries(cutoff);
742
+ if (deleted > 0) {
743
+ this.logger.debug(`[SERIES STORAGE] Deleted ${deleted} series not reconciled in the last 6 months`);
744
+ }
745
+ });
746
+ }
747
+ catch (err) {
748
+ this.logger.error(`[SERIES STORAGE] Stale series cleanup failed: ${err?.message ?? err}`);
749
+ }
750
+ }, SqliteHistoryStorageService.EIGHT_HOURS_INTERVAL);
751
+ this.staleSeriesCleanupJob.unref?.();
752
+ }
753
+ stopStaleSeriesCleanupJob() {
754
+ if (this.staleSeriesCleanupJob) {
755
+ clearInterval(this.staleSeriesCleanupJob);
756
+ this.staleSeriesCleanupJob = null;
757
+ }
758
+ }
759
+ shouldSkipMaintenance() {
760
+ if (!this.isSqliteReady() || !this.db) {
761
+ return true;
762
+ }
763
+ if (this.maintenanceInProgress || this.flushInProgress) {
764
+ return true;
765
+ }
766
+ if (this.pendingRows.length > 0) {
767
+ return true;
768
+ }
769
+ return false;
770
+ }
771
+ async runWithMaintenanceLock(label, task) {
772
+ if (this.maintenanceInProgress) {
773
+ this.logger.debug(`[SERIES STORAGE] Skipping ${label} (maintenance already running)`);
774
+ return;
775
+ }
776
+ this.maintenanceInProgress = true;
777
+ const startedAt = Date.now();
778
+ try {
779
+ await task();
780
+ const elapsedMs = Date.now() - startedAt;
781
+ this.logger.debug(`[SERIES STORAGE] ${label} completed in ${elapsedMs}ms`);
782
+ }
783
+ finally {
784
+ this.maintenanceInProgress = false;
786
785
  }
787
- this.db = null;
788
786
  }
789
787
  async createCoreTables() {
790
788
  await this.runSql(`
791
789
  CREATE TABLE IF NOT EXISTS history_samples (
792
- series_id VARCHAR,
793
- dataset_uuid VARCHAR,
794
- owner_widget_uuid VARCHAR,
795
- path VARCHAR,
796
- context VARCHAR,
797
- source VARCHAR,
798
- ts_ms BIGINT,
799
- value DOUBLE
790
+ series_id TEXT,
791
+ dataset_uuid TEXT,
792
+ owner_widget_uuid TEXT,
793
+ path TEXT,
794
+ context TEXT,
795
+ source TEXT,
796
+ ts_ms INTEGER,
797
+ value REAL
800
798
  )
801
799
  `);
802
800
  await this.runSql(`
803
801
  CREATE TABLE IF NOT EXISTS history_series (
804
- series_id VARCHAR NOT NULL,
805
- dataset_uuid VARCHAR NOT NULL,
806
- owner_widget_uuid VARCHAR NOT NULL,
807
- owner_widget_selector VARCHAR,
808
- path VARCHAR NOT NULL,
809
- source VARCHAR,
810
- context VARCHAR,
811
- time_scale VARCHAR,
802
+ series_id TEXT NOT NULL,
803
+ dataset_uuid TEXT NOT NULL,
804
+ owner_widget_uuid TEXT NOT NULL,
805
+ owner_widget_selector TEXT,
806
+ path TEXT NOT NULL,
807
+ source TEXT,
808
+ context TEXT,
809
+ time_scale TEXT,
812
810
  period INTEGER,
813
- retention_duration_ms BIGINT,
811
+ retention_duration_ms INTEGER,
814
812
  sample_time INTEGER,
815
- enabled BOOLEAN,
816
- methods_json VARCHAR,
817
- reconcile_ts BIGINT,
813
+ enabled INTEGER,
814
+ methods_json TEXT,
815
+ reconcile_ts INTEGER,
818
816
  PRIMARY KEY (series_id)
819
817
  )
820
818
  `);
821
819
  }
822
820
  async insertRows(rows) {
823
- if (rows.length === 0) {
821
+ if (!this.db || rows.length === 0) {
824
822
  return;
825
823
  }
826
824
  const valuesSql = rows
@@ -838,196 +836,28 @@ class DuckDbParquetStorageService {
838
836
  value
839
837
  ) VALUES ${valuesSql}
840
838
  `;
841
- await this.runSql(sql);
842
- }
843
- async exportSeriesRange(seriesId, fromMs, toMs) {
844
- const windowMs = this.resolveParquetWindowMs();
845
- const baseDir = (0, path_1.resolve)(this.config.parquetDirectory);
846
- const seriesDir = (0, path_1.join)(baseDir, this.safePath(seriesId));
847
- (0, fs_1.mkdirSync)(seriesDir, { recursive: true });
848
- const startWindow = Math.floor(fromMs / windowMs) * windowMs;
849
- const endWindow = Math.floor(toMs / windowMs) * windowMs;
850
- for (let windowStart = startWindow; windowStart <= endWindow; windowStart += windowMs) {
851
- const windowEnd = windowStart + windowMs - 1;
852
- const filePath = (0, path_1.join)(seriesDir, `${windowStart}-${windowEnd}.parquet`);
853
- const rows = await this.querySql(`
854
- SELECT
855
- series_id,
856
- dataset_uuid,
857
- owner_widget_uuid,
858
- path,
859
- context,
860
- source,
861
- ts_ms,
862
- value
863
- FROM history_samples
864
- WHERE series_id = ${this.escape(seriesId)}
865
- AND ts_ms >= ${Math.trunc(windowStart)}
866
- AND ts_ms <= ${Math.trunc(windowEnd)}
867
- ORDER BY ts_ms
868
- `);
869
- if (rows.length === 0) {
870
- (0, fs_1.rmSync)(filePath, { force: true });
871
- continue;
872
- }
873
- const compression = this.resolveParquetCompression();
874
- const schema = new parquetjs_1.ParquetSchema({
875
- series_id: { type: 'UTF8', compression },
876
- dataset_uuid: { type: 'UTF8', compression },
877
- owner_widget_uuid: { type: 'UTF8', compression },
878
- path: { type: 'UTF8', compression },
879
- context: { type: 'UTF8', compression },
880
- source: { type: 'UTF8', optional: true, compression },
881
- ts_ms: { type: 'INT64', compression },
882
- ts: { type: 'TIMESTAMP_MILLIS', compression },
883
- value: { type: 'DOUBLE', compression }
884
- });
885
- const tempPath = `${filePath}.tmp-${Date.now()}`;
886
- const writer = await parquetjs_1.ParquetWriter.openFile(schema, tempPath);
887
- try {
888
- for (const row of rows) {
889
- const timestampMs = this.toNumberOrUndefined(row.ts_ms);
890
- const numericValue = this.toNumberOrUndefined(row.value);
891
- if (timestampMs === undefined || numericValue === undefined) {
892
- continue;
893
- }
894
- await writer.appendRow({
895
- series_id: row.series_id,
896
- dataset_uuid: row.dataset_uuid,
897
- owner_widget_uuid: row.owner_widget_uuid,
898
- path: row.path,
899
- context: row.context,
900
- source: row.source ?? undefined,
901
- ts_ms: Math.trunc(timestampMs),
902
- ts: new Date(timestampMs),
903
- value: numericValue
904
- });
905
- }
906
- }
907
- finally {
908
- await writer.close();
909
- }
910
- (0, fs_1.rmSync)(filePath, { force: true });
911
- (0, fs_1.renameSync)(tempPath, filePath);
912
- }
913
- }
914
- resolveParquetWindowMs() {
915
- const parsed = Number(this.config.parquetWindowMs);
916
- if (Number.isFinite(parsed) && parsed > 0) {
917
- return Math.max(1, Math.trunc(parsed));
918
- }
919
- return 60 * 60_000;
920
- }
921
- resolveParquetCompression() {
922
- const raw = String(this.config.parquetCompression ?? 'none').trim().toLowerCase();
923
- if (raw === 'snappy') {
924
- return 'SNAPPY';
925
- }
926
- if (raw === 'gzip') {
927
- return 'GZIP';
928
- }
929
- if (raw === 'brotli') {
930
- return 'BROTLI';
931
- }
932
- if (raw === 'lz4') {
933
- return 'LZ4';
934
- }
935
- if (raw === 'lzo') {
936
- return 'LZO';
937
- }
938
- return undefined;
939
- }
940
- pruneParquetFilesByRetention(nowMs) {
941
- if (!this.isDuckDbParquetEnabled() || !this.connection) {
942
- return Promise.resolve(0);
943
- }
944
- return this.querySql(`
945
- SELECT series_id, retention_duration_ms
946
- FROM history_series
947
- `).then(rows => {
948
- const baseDir = (0, path_1.resolve)(this.config.parquetDirectory);
949
- const seriesConfig = new Map(rows.map(row => [this.safePath(row.series_id), row.retention_duration_ms]));
950
- let removed = 0;
951
- let seriesDirs = [];
952
- try {
953
- seriesDirs = (0, fs_1.readdirSync)(baseDir);
954
- }
955
- catch {
956
- return 0;
957
- }
958
- for (const dirName of seriesDirs) {
959
- const fullDir = (0, path_1.join)(baseDir, dirName);
960
- let dirStat;
961
- try {
962
- dirStat = (0, fs_1.statSync)(fullDir);
963
- }
964
- catch {
965
- continue;
966
- }
967
- if (!dirStat.isDirectory()) {
968
- continue;
969
- }
970
- const seriesKey = dirName;
971
- if (!seriesConfig.has(seriesKey)) {
972
- (0, fs_1.rmSync)(fullDir, { recursive: true, force: true });
973
- removed += 1;
974
- continue;
975
- }
976
- const retentionMs = seriesConfig.get(seriesKey);
977
- if (!Number.isFinite(retentionMs) || (retentionMs ?? 0) <= 0) {
978
- continue;
979
- }
980
- const cutoff = nowMs - Number(retentionMs);
981
- let files = [];
982
- try {
983
- files = (0, fs_1.readdirSync)(fullDir);
984
- }
985
- catch {
986
- continue;
987
- }
988
- files.forEach(fileName => {
989
- const range = this.parseParquetRangeFromFileName(fileName);
990
- if (!range) {
991
- return;
992
- }
993
- if (range.toMs < cutoff) {
994
- (0, fs_1.rmSync)((0, path_1.join)(fullDir, fileName), { force: true });
995
- removed += 1;
996
- }
997
- });
998
- }
999
- return removed;
1000
- });
1001
- }
1002
- parseParquetRangeFromFileName(fileName) {
1003
- const match = /^([0-9]+)-([0-9]+)\.parquet$/.exec(fileName);
1004
- if (!match) {
1005
- return null;
839
+ this.db.exec('BEGIN');
840
+ try {
841
+ await this.runSql(sql);
842
+ this.db.exec('COMMIT');
1006
843
  }
1007
- const fromMs = Number(match[1]);
1008
- const toMs = Number(match[2]);
1009
- if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) {
1010
- return null;
844
+ catch (error) {
845
+ this.db.exec('ROLLBACK');
846
+ throw error;
1011
847
  }
1012
- return { fromMs, toMs };
1013
- }
1014
- deleteParquetSeriesDir(seriesId) {
1015
- const baseDir = (0, path_1.resolve)(this.config.parquetDirectory);
1016
- const seriesDir = (0, path_1.join)(baseDir, this.safePath(seriesId));
1017
- (0, fs_1.rmSync)(seriesDir, { recursive: true, force: true });
1018
848
  }
1019
849
  async runSql(sql) {
1020
- if (!this.connection) {
1021
- throw new Error('DuckDB connection is not initialized');
850
+ if (!this.db) {
851
+ throw new Error('node:sqlite database is not initialized');
1022
852
  }
1023
- await this.connection.run(sql);
853
+ this.db.exec(sql);
1024
854
  }
1025
855
  async querySql(sql) {
1026
- if (!this.connection) {
1027
- throw new Error('DuckDB connection is not initialized');
856
+ if (!this.db) {
857
+ throw new Error('node:sqlite database is not initialized');
1028
858
  }
1029
- const result = await this.connection.runAndReadAll(sql);
1030
- return result.getRowObjectsJson();
859
+ const statement = this.db.prepare(sql);
860
+ return statement.all();
1031
861
  }
1032
862
  async selectRowsForPaths(paths, context, fromMs, toMs) {
1033
863
  const rowsByPath = new Map();
@@ -1234,9 +1064,6 @@ class DuckDbParquetStorageService {
1234
1064
  const sum = values.reduce((acc, value) => acc + value, 0);
1235
1065
  return sum / values.length;
1236
1066
  }
1237
- safePath(value) {
1238
- return value.replace(/[^a-zA-Z0-9._-]/g, '_');
1239
- }
1240
1067
  escape(value) {
1241
1068
  return `'${String(value).replace(/'/g, "''")}'`;
1242
1069
  }
@@ -1298,4 +1125,4 @@ class DuckDbParquetStorageService {
1298
1125
  return Boolean(value);
1299
1126
  }
1300
1127
  }
1301
- exports.DuckDbParquetStorageService = DuckDbParquetStorageService;
1128
+ exports.SqliteHistoryStorageService = SqliteHistoryStorageService;