@mxtommy/kip 4.6.0 → 4.7.0-beta.3

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 (43) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/package.json +5 -5
  3. package/plugin/history-series.service.js +91 -13
  4. package/plugin/index.js +148 -48
  5. package/plugin/kip-plugin/src/history-series.service.js +537 -0
  6. package/plugin/kip-plugin/src/index.js +1194 -0
  7. package/plugin/kip-plugin/src/openApi.json +787 -0
  8. package/plugin/kip-plugin/src/sqlite-history-storage.service.js +1122 -0
  9. package/plugin/openApi.json +14 -0
  10. package/plugin/sqlite-history-storage.service.js +13 -19
  11. package/plugin/src/app/core/contracts/kip-series-contract.js +14 -0
  12. package/plugin/src/app/core/contracts/kip-series-contract.spec.js +68 -0
  13. package/public/3rdpartylicenses.txt +36 -0
  14. package/public/assets/help-docs/dashboards.md +3 -3
  15. package/public/assets/help-docs/datainspector.md +3 -3
  16. package/public/assets/help-docs/history-api.md +31 -21
  17. package/public/assets/help-docs/menu.json +1 -1
  18. package/public/assets/help-docs/nodered-control-flows.md +4 -4
  19. package/public/assets/help-docs/putcontrols.md +6 -6
  20. package/public/assets/help-docs/widget-historical-series.md +55 -11
  21. package/public/assets/help-docs/zones.md +1 -1
  22. package/public/assets/svg/icons.svg +17 -0
  23. package/public/{chunk-AC6VD2FN.js → chunk-2GOHQZH5.js} +4 -4
  24. package/public/{chunk-3JA4CQ7T.js → chunk-4YDVZHMH.js} +4 -4
  25. package/public/{chunk-UYHRT3PR.js → chunk-7ZZK5KBC.js} +1 -1
  26. package/public/{chunk-MDNGWQNG.js → chunk-AB34PRGT.js} +7 -7
  27. package/public/{chunk-356CW47X.js → chunk-AQROQY2F.js} +1 -1
  28. package/public/{chunk-ZY3U4H4Z.js → chunk-AZC2WKQI.js} +1 -1
  29. package/public/{chunk-CYTLQDGF.js → chunk-BQPPRM7O.js} +1 -1
  30. package/public/{chunk-BMHMHQFO.js → chunk-CRJ2S7XQ.js} +2 -2
  31. package/public/{chunk-QU3JR4YV.js → chunk-FYDLTNP4.js} +1 -1
  32. package/public/{chunk-B4NYOD6L.js → chunk-IENESD5Q.js} +1 -1
  33. package/public/{chunk-PPF5S5CV.js → chunk-M37BLWHF.js} +5 -5
  34. package/public/chunk-MXUEYEZU.js +5 -0
  35. package/public/chunk-OXKOGBYN.js +50 -0
  36. package/public/{chunk-NJISHUGY.js → chunk-PTLDR7X7.js} +1 -1
  37. package/public/{chunk-5SAXWR6Z.js → chunk-QV7OHVD5.js} +8 -8
  38. package/public/{chunk-ZXO4VMEH.js → chunk-SDPSUOCJ.js} +1 -1
  39. package/public/{chunk-MGLD6QDJ.js → chunk-YY4ZUJFI.js} +5 -5
  40. package/public/index.html +1 -1
  41. package/public/{main-I33LH3HC.js → main-5HQ6OYM7.js} +1 -1
  42. package/public/chunk-6A4NRSCL.js +0 -5
  43. package/public/chunk-P3M6SJQT.js +0 -50
@@ -0,0 +1,1122 @@
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
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.SqliteHistoryStorageService = void 0;
37
+ const fs_1 = require("fs");
38
+ const path_1 = require("path");
39
+ const DEFAULT_STORAGE_CONFIG = {
40
+ engine: 'node:sqlite',
41
+ databaseFile: '',
42
+ flushIntervalMs: 30_000
43
+ };
44
+ /**
45
+ * Provides node:sqlite storage for captured history samples.
46
+ */
47
+ class SqliteHistoryStorageService {
48
+ static EIGHT_HOURS_INTERVAL = 8 * 60 * 60 * 1000;
49
+ static FOUR_HOURS_INTERVAL = 4 * 60 * 60 * 1000;
50
+ static STALE_SERIES_AGE_MS = 180 * 24 * 60 * 60 * 1000;
51
+ static PRUNE_BATCH_SIZE = 10_000;
52
+ config;
53
+ logger = {
54
+ debug: () => undefined,
55
+ error: () => undefined
56
+ };
57
+ db = null;
58
+ pendingRows = [];
59
+ lastInitError = null;
60
+ lifecycleToken = 0;
61
+ initialized = false;
62
+ runtimeAvailable = true;
63
+ maintenanceInProgress = false;
64
+ flushInProgress = false;
65
+ vacuumJob = null;
66
+ pruneJob = null;
67
+ staleSeriesCleanupJob = null;
68
+ constructor(dataDirPath) {
69
+ const normalizedDataDirPath = typeof dataDirPath === 'string' ? dataDirPath.trim() : '';
70
+ if (!normalizedDataDirPath) {
71
+ throw new Error('SqliteHistoryStorageService requires a valid dataDirPath from server.getDataDirPath()');
72
+ }
73
+ this.config = {
74
+ ...DEFAULT_STORAGE_CONFIG,
75
+ databaseFile: (0, path_1.join)(normalizedDataDirPath, 'historicalData', 'kip-history.sqlite')
76
+ };
77
+ }
78
+ /**
79
+ * Sets logger callbacks used by the storage service.
80
+ *
81
+ * @param {TLogger} logger Logger implementation from plugin runtime.
82
+ * @returns {void}
83
+ *
84
+ * @example
85
+ * storage.setLogger({ debug: console.log, error: console.error });
86
+ */
87
+ setLogger(logger) {
88
+ this.logger = logger;
89
+ }
90
+ /**
91
+ * Applies the fixed storage backend configuration.
92
+ *
93
+ * @returns {ISqliteHistoryStorageConfig} Fixed storage configuration.
94
+ *
95
+ * @example
96
+ * const cfg = storage.configure();
97
+ * console.log(cfg.engine);
98
+ */
99
+ configure() {
100
+ this.initialized = false;
101
+ this.config = {
102
+ ...DEFAULT_STORAGE_CONFIG,
103
+ databaseFile: this.config.databaseFile
104
+ };
105
+ return this.config;
106
+ }
107
+ /**
108
+ * Updates runtime availability of node:sqlite, clearing stored errors when enabled.
109
+ *
110
+ * @param {boolean} available Whether node:sqlite is available at runtime.
111
+ * @param {string | undefined} errorMessage Optional runtime error message.
112
+ * @returns {void}
113
+ *
114
+ * @example
115
+ * storage.setRuntimeAvailability(false, 'node:sqlite unavailable');
116
+ */
117
+ setRuntimeAvailability(available, errorMessage) {
118
+ this.runtimeAvailable = available;
119
+ this.lastInitError = available ? null : (errorMessage ?? 'node:sqlite unavailable');
120
+ if (!available) {
121
+ this.initialized = false;
122
+ this.db = null;
123
+ }
124
+ }
125
+ /**
126
+ * Initializes node:sqlite storage.
127
+ *
128
+ * @returns {Promise<boolean>} True when node:sqlite is initialized and ready.
129
+ *
130
+ * @example
131
+ * const ready = await storage.initialize();
132
+ */
133
+ async initialize() {
134
+ if (!this.isSqliteEnabled() || !this.runtimeAvailable) {
135
+ return false;
136
+ }
137
+ this.initialized = false;
138
+ this.lifecycleToken += 1;
139
+ try {
140
+ const sqlite = await this.loadSqliteModule();
141
+ if (!sqlite?.DatabaseSync) {
142
+ throw new Error('node:sqlite DatabaseSync is unavailable');
143
+ }
144
+ const dbPath = (0, path_1.resolve)(this.config.databaseFile);
145
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(dbPath), { recursive: true });
146
+ this.db = new sqlite.DatabaseSync(dbPath, { timeout: 5000 });
147
+ this.db.exec('PRAGMA journal_mode=WAL;');
148
+ this.db.exec('PRAGMA synchronous=NORMAL;');
149
+ this.db.exec('PRAGMA temp_store=MEMORY;');
150
+ this.db.exec('PRAGMA foreign_keys=ON;');
151
+ await this.createCoreTables();
152
+ await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_series_scope_ts ON history_samples(series_id, ts_ms)');
153
+ await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_series_scope_id ON history_series(series_id)');
154
+ await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_samples_scope_context_path_ts ON history_samples(context, path, ts_ms)');
155
+ await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_samples_scope_ts_path ON history_samples(ts_ms, path)');
156
+ await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_samples_scope_ts_context ON history_samples(ts_ms, context)');
157
+ this.logger.debug(`[SERIES STORAGE] node:sqlite initialized at ${dbPath}`);
158
+ this.lastInitError = null;
159
+ this.initialized = true;
160
+ this.startVacuumJob();
161
+ this.startPruneJob();
162
+ this.startStaleSeriesCleanupJob();
163
+ return true;
164
+ }
165
+ catch (error) {
166
+ const message = error?.message ?? String(error);
167
+ this.lastInitError = message;
168
+ this.logger.error(`[SERIES STORAGE] node:sqlite initialization failed: ${message}`);
169
+ this.db = null;
170
+ this.pendingRows = [];
171
+ this.initialized = false;
172
+ this.stopVacuumJob();
173
+ this.stopPruneJob();
174
+ this.stopStaleSeriesCleanupJob();
175
+ return false;
176
+ }
177
+ }
178
+ /**
179
+ * Returns last node:sqlite initialization error when initialization failed.
180
+ *
181
+ * @returns {string | null} Initialization error text or null.
182
+ *
183
+ * @example
184
+ * const err = storage.getLastInitError();
185
+ */
186
+ getLastInitError() {
187
+ return this.lastInitError;
188
+ }
189
+ /**
190
+ * Indicates whether node:sqlite mode is selected.
191
+ *
192
+ * @returns {boolean} True when the selected engine is `node:sqlite`.
193
+ *
194
+ * @example
195
+ * if (storage.isSqliteEnabled()) {
196
+ * console.log('node:sqlite mode enabled');
197
+ * }
198
+ */
199
+ isSqliteEnabled() {
200
+ return this.config.engine === 'node:sqlite';
201
+ }
202
+ /**
203
+ * Indicates whether node:sqlite mode is initialized and ready.
204
+ *
205
+ * @returns {boolean} True when node:sqlite mode is selected and an active connection exists.
206
+ *
207
+ * @example
208
+ * if (storage.isSqliteReady()) {
209
+ * console.log('node:sqlite ready');
210
+ * }
211
+ */
212
+ isSqliteReady() {
213
+ return this.isSqliteEnabled() && this.initialized && this.db !== null && this.runtimeAvailable;
214
+ }
215
+ /**
216
+ * Returns the current storage lifecycle token.
217
+ *
218
+ * @returns {number} Current lifecycle token.
219
+ *
220
+ * @example
221
+ * const token = storage.getLifecycleToken();
222
+ */
223
+ getLifecycleToken() {
224
+ return this.lifecycleToken;
225
+ }
226
+ /**
227
+ * Adds a captured sample row to the pending storage queue.
228
+ *
229
+ * @param {IRecordedSample} sample Captured sample metadata and value.
230
+ * @returns {void}
231
+ *
232
+ * @example
233
+ * storage.enqueueSample(sample);
234
+ */
235
+ enqueueSample(sample) {
236
+ if (!this.isSqliteReady()) {
237
+ return;
238
+ }
239
+ this.pendingRows.push(sample);
240
+ }
241
+ /**
242
+ * Flushes queued samples into node:sqlite.
243
+ *
244
+ * @param {number} [expectedLifecycleToken] Optional lifecycle token guard to skip stale flushes.
245
+ * @returns {Promise<{ inserted: number; exported: number }>} Number of inserted rows (exported is always 0).
246
+ *
247
+ * @example
248
+ * const result = await storage.flush();
249
+ */
250
+ async flush(expectedLifecycleToken) {
251
+ if (expectedLifecycleToken !== undefined && expectedLifecycleToken !== this.lifecycleToken) {
252
+ return { inserted: 0, exported: 0 };
253
+ }
254
+ if (!this.isSqliteEnabled() || !this.db || this.pendingRows.length === 0) {
255
+ return { inserted: 0, exported: 0 };
256
+ }
257
+ if (this.flushInProgress) {
258
+ return { inserted: 0, exported: 0 };
259
+ }
260
+ this.flushInProgress = true;
261
+ const rows = this.pendingRows;
262
+ this.pendingRows = [];
263
+ const startedAt = Date.now();
264
+ try {
265
+ await this.insertRows(rows);
266
+ const elapsedMs = Date.now() - startedAt;
267
+ this.logger.debug(`[SERIES STORAGE] flush inserted=${rows.length} durationMs=${elapsedMs}`);
268
+ return { inserted: rows.length, exported: 0 };
269
+ }
270
+ catch (error) {
271
+ this.pendingRows = [...rows, ...this.pendingRows];
272
+ throw error;
273
+ }
274
+ finally {
275
+ this.flushInProgress = false;
276
+ }
277
+ }
278
+ /**
279
+ * Returns persisted series definitions from node:sqlite.
280
+ *
281
+ * @returns {Promise<ISeriesDefinition[]>} Stored series definitions.
282
+ *
283
+ * @example
284
+ * const series = await storage.getSeriesDefinitions();
285
+ */
286
+ async getSeriesDefinitions() {
287
+ if (!this.isSqliteEnabled() || !this.db) {
288
+ return [];
289
+ }
290
+ const rows = await this.querySql(`
291
+ SELECT
292
+ series_id,
293
+ dataset_uuid,
294
+ owner_widget_uuid,
295
+ owner_widget_selector,
296
+ path,
297
+ source,
298
+ context,
299
+ time_scale,
300
+ period,
301
+ retention_duration_ms,
302
+ sample_time,
303
+ enabled,
304
+ methods_json,
305
+ reconcile_ts
306
+ FROM history_series
307
+ ORDER BY series_id ASC
308
+ `);
309
+ return rows.map(row => ({
310
+ seriesId: row.series_id,
311
+ datasetUuid: row.dataset_uuid,
312
+ ownerWidgetUuid: row.owner_widget_uuid,
313
+ ownerWidgetSelector: row.owner_widget_selector ?? null,
314
+ path: row.path,
315
+ source: row.source ?? undefined,
316
+ context: row.context ?? undefined,
317
+ timeScale: row.time_scale ?? undefined,
318
+ period: this.toNumberOrUndefined(row.period),
319
+ retentionDurationMs: this.toNumberOrUndefined(row.retention_duration_ms),
320
+ sampleTime: this.toNumberOrUndefined(row.sample_time),
321
+ enabled: this.toBoolean(row.enabled),
322
+ methods: this.parseMethods(row.methods_json),
323
+ reconcileTs: this.toNumberOrUndefined(row.reconcile_ts)
324
+ }));
325
+ }
326
+ /**
327
+ * Persists one series definition in node:sqlite.
328
+ *
329
+ * @param {ISeriesDefinition} series Series definition to persist.
330
+ * @returns {Promise<void>}
331
+ *
332
+ * @example
333
+ * await storage.upsertSeriesDefinition(series);
334
+ */
335
+ async upsertSeriesDefinition(series) {
336
+ if (!this.isSqliteEnabled() || !this.db) {
337
+ return;
338
+ }
339
+ await this.runSql(`DELETE FROM history_series WHERE series_id = ${this.escape(series.seriesId)}`);
340
+ await this.runSql(`
341
+ INSERT INTO history_series (
342
+ series_id,
343
+ dataset_uuid,
344
+ owner_widget_uuid,
345
+ owner_widget_selector,
346
+ path,
347
+ source,
348
+ context,
349
+ time_scale,
350
+ period,
351
+ retention_duration_ms,
352
+ sample_time,
353
+ enabled,
354
+ methods_json,
355
+ reconcile_ts
356
+ ) VALUES (
357
+ ${this.escape(series.seriesId)},
358
+ ${this.escape(series.datasetUuid)},
359
+ ${this.escape(series.ownerWidgetUuid)},
360
+ ${this.nullableString(series.ownerWidgetSelector)},
361
+ ${this.escape(series.path)},
362
+ ${this.nullableString(series.source)},
363
+ ${this.nullableString(series.context)},
364
+ ${this.nullableString(series.timeScale)},
365
+ ${this.nullableNumber(series.period)},
366
+ ${this.nullableNumber(series.retentionDurationMs)},
367
+ ${this.nullableNumber(series.sampleTime)},
368
+ ${series.enabled === false ? '0' : '1'},
369
+ ${this.nullableString(series.methods ? JSON.stringify(series.methods) : null)},
370
+ ${this.nullableNumber(series.reconcileTs)}
371
+ )
372
+ `);
373
+ }
374
+ /**
375
+ * Deletes one persisted series definition in node:sqlite.
376
+ *
377
+ * @param {string} seriesId Series identifier.
378
+ * @returns {Promise<void>}
379
+ *
380
+ * @example
381
+ * await storage.deleteSeriesDefinition('series-1');
382
+ */
383
+ async deleteSeriesDefinition(seriesId) {
384
+ if (!this.isSqliteEnabled() || !this.db) {
385
+ return;
386
+ }
387
+ await this.runSql(`DELETE FROM history_series WHERE series_id = ${this.escape(seriesId)}`);
388
+ }
389
+ /**
390
+ * Replaces persisted series definitions with desired set.
391
+ *
392
+ * @param {ISeriesDefinition[]} series Full desired series set.
393
+ * @returns {Promise<void>}
394
+ *
395
+ * @example
396
+ * await storage.replaceSeriesDefinitions(series);
397
+ */
398
+ async replaceSeriesDefinitions(series) {
399
+ if (!this.isSqliteEnabled() || !this.db) {
400
+ return;
401
+ }
402
+ await this.runSql('DELETE FROM history_series');
403
+ for (const item of series) {
404
+ await this.upsertSeriesDefinition(item);
405
+ }
406
+ }
407
+ /**
408
+ * Deletes series not reconciled since the given cutoff timestamp.
409
+ *
410
+ * @param {number} cutoffMs Milliseconds since epoch; series with reconcile_ts < cutoffMs will be deleted.
411
+ * @returns {Promise<number>} Number of deleted series.
412
+ *
413
+ * @example
414
+ * const deleted = await storage.deleteStaleSeries(Date.now() - 180 * 24 * 60 * 60 * 1000);
415
+ */
416
+ async deleteStaleSeries(cutoffMs) {
417
+ if (!this.isSqliteEnabled() || !this.db) {
418
+ return 0;
419
+ }
420
+ const rows = await this.querySql(`
421
+ SELECT series_id FROM history_series
422
+ WHERE reconcile_ts IS NULL OR reconcile_ts < ${Math.trunc(cutoffMs)}
423
+ `);
424
+ const ids = rows.map(row => row.series_id).filter(Boolean);
425
+ for (const id of ids) {
426
+ await this.deleteSeriesDefinition(id);
427
+ }
428
+ return ids.length;
429
+ }
430
+ /**
431
+ * Removes persisted samples that are older than each series retention window.
432
+ *
433
+ * @param {number} [nowMs=Date.now()] Current timestamp in milliseconds used to compute per-series cutoffs.
434
+ * @param {number} [expectedLifecycleToken] Optional lifecycle token guard to skip stale sweeps.
435
+ * @returns {Promise<number>} Number of deleted sample rows.
436
+ *
437
+ * @example
438
+ * const removed = await storage.pruneExpiredSamples();
439
+ */
440
+ async pruneExpiredSamples(nowMs = Date.now(), expectedLifecycleToken) {
441
+ if (expectedLifecycleToken !== undefined && expectedLifecycleToken !== this.lifecycleToken) {
442
+ return 0;
443
+ }
444
+ if (!this.isSqliteEnabled() || !this.db) {
445
+ return 0;
446
+ }
447
+ const anchorMs = Math.trunc(Number.isFinite(nowMs) ? nowMs : Date.now());
448
+ const whereClause = `
449
+ EXISTS (
450
+ SELECT 1
451
+ FROM history_series AS hs
452
+ WHERE hs.series_id = history_samples.series_id
453
+ AND hs.retention_duration_ms IS NOT NULL
454
+ AND hs.retention_duration_ms > 0
455
+ AND history_samples.ts_ms < (${anchorMs} - hs.retention_duration_ms)
456
+ )
457
+ `;
458
+ let removedRows = 0;
459
+ while (true) {
460
+ const batch = await this.querySql(`
461
+ SELECT rowid
462
+ FROM history_samples
463
+ WHERE ${whereClause}
464
+ LIMIT ${SqliteHistoryStorageService.PRUNE_BATCH_SIZE}
465
+ `);
466
+ if (batch.length === 0) {
467
+ break;
468
+ }
469
+ const rowIds = batch.map(row => Math.trunc(Number(row.rowid))).filter(Number.isFinite);
470
+ if (rowIds.length === 0) {
471
+ break;
472
+ }
473
+ await this.runSql(`
474
+ DELETE FROM history_samples
475
+ WHERE rowid IN (${rowIds.join(', ')})
476
+ `);
477
+ removedRows += rowIds.length;
478
+ if (rowIds.length < SqliteHistoryStorageService.PRUNE_BATCH_SIZE) {
479
+ break;
480
+ }
481
+ }
482
+ return removedRows;
483
+ }
484
+ /**
485
+ * Removes persisted samples that no longer have a matching series definition.
486
+ *
487
+ * @param {number} [expectedLifecycleToken] Optional lifecycle token guard to skip stale sweeps.
488
+ * @returns {Promise<number>} Number of deleted orphan sample rows.
489
+ *
490
+ * @example
491
+ * const removed = await storage.pruneOrphanedSamples();
492
+ */
493
+ async pruneOrphanedSamples(expectedLifecycleToken) {
494
+ if (expectedLifecycleToken !== undefined && expectedLifecycleToken !== this.lifecycleToken) {
495
+ return 0;
496
+ }
497
+ if (!this.isSqliteEnabled() || !this.db) {
498
+ return 0;
499
+ }
500
+ const whereClause = `
501
+ NOT EXISTS (
502
+ SELECT 1
503
+ FROM history_series AS hs
504
+ WHERE hs.series_id = history_samples.series_id
505
+ )
506
+ `;
507
+ let removedRows = 0;
508
+ while (true) {
509
+ const batch = await this.querySql(`
510
+ SELECT rowid
511
+ FROM history_samples
512
+ WHERE ${whereClause}
513
+ LIMIT ${SqliteHistoryStorageService.PRUNE_BATCH_SIZE}
514
+ `);
515
+ if (batch.length === 0) {
516
+ break;
517
+ }
518
+ const rowIds = batch.map(row => Math.trunc(Number(row.rowid))).filter(Number.isFinite);
519
+ if (rowIds.length === 0) {
520
+ break;
521
+ }
522
+ await this.runSql(`
523
+ DELETE FROM history_samples
524
+ WHERE rowid IN (${rowIds.join(', ')})
525
+ `);
526
+ removedRows += rowIds.length;
527
+ if (rowIds.length < SqliteHistoryStorageService.PRUNE_BATCH_SIZE) {
528
+ break;
529
+ }
530
+ }
531
+ return removedRows;
532
+ }
533
+ /**
534
+ * Lists known history paths from persisted samples.
535
+ *
536
+ * @param {IHistoryRangeQuery} [query] Optional range filter.
537
+ * @returns {Promise<string[]>} Ordered path names.
538
+ *
539
+ * @example
540
+ * const paths = await storage.getStoredPaths();
541
+ */
542
+ async getStoredPaths(query) {
543
+ if (!this.isSqliteEnabled() || !this.db) {
544
+ return [];
545
+ }
546
+ const nowMs = Date.now();
547
+ const range = this.resolveRange(nowMs, query?.from, query?.to, query?.duration);
548
+ const rows = await this.querySql(`
549
+ SELECT DISTINCT path AS value
550
+ FROM history_samples
551
+ WHERE path IS NOT NULL
552
+ AND ts_ms >= ${Math.trunc(range.fromMs)}
553
+ AND ts_ms <= ${Math.trunc(range.toMs)}
554
+ ORDER BY value ASC
555
+ `);
556
+ return rows.map(row => row.value).filter(Boolean);
557
+ }
558
+ /**
559
+ * Lists known history contexts from persisted samples.
560
+ *
561
+ * @param {IHistoryRangeQuery} [query] Optional range filter.
562
+ * @returns {Promise<string[]>} Ordered context names.
563
+ *
564
+ * @example
565
+ * const contexts = await storage.getStoredContexts();
566
+ */
567
+ async getStoredContexts(query) {
568
+ if (!this.isSqliteEnabled() || !this.db) {
569
+ return [];
570
+ }
571
+ const nowMs = Date.now();
572
+ const range = this.resolveRange(nowMs, query?.from, query?.to, query?.duration);
573
+ const rows = await this.querySql(`
574
+ SELECT DISTINCT context AS value
575
+ FROM history_samples
576
+ WHERE context IS NOT NULL
577
+ AND ts_ms >= ${Math.trunc(range.fromMs)}
578
+ AND ts_ms <= ${Math.trunc(range.toMs)}
579
+ ORDER BY value ASC
580
+ `);
581
+ return rows.map(row => row.value).filter(Boolean);
582
+ }
583
+ /**
584
+ * Queries history values directly from node:sqlite in History API-compatible shape.
585
+ *
586
+ * @param {IHistoryQueryParams} query Incoming history values query parameters.
587
+ * @returns {Promise<IHistoryValuesResponse | null>} History payload when node:sqlite is ready, otherwise null.
588
+ *
589
+ * @example
590
+ * const result = await storage.getValues({ paths: 'navigation.speedOverGround:avg', duration: 'PT1H' });
591
+ */
592
+ async getValues(query) {
593
+ if (!this.isSqliteEnabled() || !this.db) {
594
+ return null;
595
+ }
596
+ const nowMs = Date.now();
597
+ const requested = this.parseRequestedPaths(query.paths);
598
+ if (requested.length === 0) {
599
+ return null;
600
+ }
601
+ const range = this.resolveRange(nowMs, query.from, query.to, query.duration);
602
+ const context = query.context ?? 'vessels.self';
603
+ const resolutionMs = this.resolveResolutionMs(query.resolution);
604
+ const uniquePaths = Array.from(new Set(requested.map(item => item.path)));
605
+ const rowsByPath = await this.selectRowsForPaths(uniquePaths, context, range.fromMs, range.toMs);
606
+ const timestampRows = new Map();
607
+ for (let index = 0; index < requested.length; index += 1) {
608
+ const item = requested[index];
609
+ const rows = rowsByPath.get(item.path) ?? [];
610
+ const transformed = this.applyMethod(item, rows);
611
+ const merged = this.downsampleIfNeeded(transformed, resolutionMs, item.method ?? 'avg');
612
+ merged.forEach(entry => {
613
+ const row = timestampRows.get(entry.timestamp) ?? Array.from({ length: requested.length }, () => null);
614
+ row[index] = entry.value;
615
+ timestampRows.set(entry.timestamp, row);
616
+ });
617
+ }
618
+ const data = Array.from(timestampRows.entries())
619
+ .sort((left, right) => left[0] - right[0])
620
+ .map(([timestamp, values]) => [new Date(timestamp).toISOString(), ...values]);
621
+ return {
622
+ context,
623
+ range: {
624
+ from: new Date(range.fromMs).toISOString(),
625
+ to: new Date(range.toMs).toISOString()
626
+ },
627
+ values: requested.map(item => ({
628
+ path: item.path,
629
+ method: item.method ?? 'avg'
630
+ })),
631
+ data
632
+ };
633
+ }
634
+ /**
635
+ * Closes open storage resources.
636
+ *
637
+ * @param {number} [expectedLifecycleToken] Optional lifecycle token guard to skip stale closes.
638
+ * @returns {Promise<void>}
639
+ *
640
+ * @example
641
+ * await storage.close();
642
+ */
643
+ async close(expectedLifecycleToken) {
644
+ if (expectedLifecycleToken !== undefined && expectedLifecycleToken !== this.lifecycleToken) {
645
+ return;
646
+ }
647
+ this.initialized = false;
648
+ this.stopVacuumJob();
649
+ this.stopPruneJob();
650
+ this.stopStaleSeriesCleanupJob();
651
+ if (!this.db) {
652
+ return;
653
+ }
654
+ const db = this.db;
655
+ this.db = null;
656
+ try {
657
+ db.close();
658
+ }
659
+ catch {
660
+ // ignore close failures during shutdown
661
+ }
662
+ }
663
+ async loadSqliteModule() {
664
+ try {
665
+ return await Promise.resolve().then(() => __importStar(require('node:sqlite')));
666
+ }
667
+ catch {
668
+ return null;
669
+ }
670
+ }
671
+ startVacuumJob() {
672
+ this.stopVacuumJob();
673
+ if (!this.isSqliteReady())
674
+ return;
675
+ this.vacuumJob = setInterval(() => {
676
+ if (this.shouldSkipMaintenance()) {
677
+ return;
678
+ }
679
+ void this.runWithMaintenanceLock('vacuum', async () => {
680
+ this.logger.debug('[SERIES STORAGE] Running scheduled node:sqlite VACUUM');
681
+ await this.runSql('VACUUM;');
682
+ await this.runSql('PRAGMA optimize;');
683
+ }).catch(err => {
684
+ this.logger.error(`[SERIES STORAGE] VACUUM failed: ${err?.message ?? err}`);
685
+ });
686
+ }, SqliteHistoryStorageService.EIGHT_HOURS_INTERVAL);
687
+ this.vacuumJob.unref?.();
688
+ }
689
+ stopVacuumJob() {
690
+ if (this.vacuumJob) {
691
+ clearInterval(this.vacuumJob);
692
+ this.vacuumJob = null;
693
+ }
694
+ }
695
+ startPruneJob() {
696
+ this.stopPruneJob();
697
+ if (!this.isSqliteReady())
698
+ return;
699
+ this.pruneJob = setInterval(async () => {
700
+ if (this.shouldSkipMaintenance()) {
701
+ return;
702
+ }
703
+ try {
704
+ await this.runWithMaintenanceLock('prune', async () => {
705
+ this.logger.debug('[SERIES STORAGE] Running scheduled prune of expired and orphaned samples');
706
+ const expired = await this.pruneExpiredSamples(Date.now(), this.lifecycleToken);
707
+ const orphaned = await this.pruneOrphanedSamples(this.lifecycleToken);
708
+ this.logger.debug(`[SERIES STORAGE] Pruned ${expired} expired and ${orphaned} orphaned samples`);
709
+ });
710
+ }
711
+ catch (err) {
712
+ this.logger.error(`[SERIES STORAGE] Prune failed: ${err?.message ?? err}`);
713
+ }
714
+ }, SqliteHistoryStorageService.FOUR_HOURS_INTERVAL);
715
+ this.pruneJob.unref?.();
716
+ }
717
+ stopPruneJob() {
718
+ if (this.pruneJob) {
719
+ clearInterval(this.pruneJob);
720
+ this.pruneJob = null;
721
+ }
722
+ }
723
+ startStaleSeriesCleanupJob() {
724
+ this.stopStaleSeriesCleanupJob();
725
+ if (!this.isSqliteReady())
726
+ return;
727
+ this.staleSeriesCleanupJob = setInterval(async () => {
728
+ if (this.shouldSkipMaintenance()) {
729
+ return;
730
+ }
731
+ try {
732
+ await this.runWithMaintenanceLock('stale-cleanup', async () => {
733
+ const cutoff = Date.now() - SqliteHistoryStorageService.STALE_SERIES_AGE_MS;
734
+ this.logger.debug(`[SERIES STORAGE] Running scheduled stale series cleanup (cutoff: ${new Date(cutoff).toISOString()})`);
735
+ const deleted = await this.deleteStaleSeries(cutoff);
736
+ if (deleted > 0) {
737
+ this.logger.debug(`[SERIES STORAGE] Deleted ${deleted} series not reconciled in the last 6 months`);
738
+ }
739
+ });
740
+ }
741
+ catch (err) {
742
+ this.logger.error(`[SERIES STORAGE] Stale series cleanup failed: ${err?.message ?? err}`);
743
+ }
744
+ }, SqliteHistoryStorageService.EIGHT_HOURS_INTERVAL);
745
+ this.staleSeriesCleanupJob.unref?.();
746
+ }
747
+ stopStaleSeriesCleanupJob() {
748
+ if (this.staleSeriesCleanupJob) {
749
+ clearInterval(this.staleSeriesCleanupJob);
750
+ this.staleSeriesCleanupJob = null;
751
+ }
752
+ }
753
+ shouldSkipMaintenance() {
754
+ if (!this.isSqliteReady() || !this.db) {
755
+ return true;
756
+ }
757
+ if (this.maintenanceInProgress || this.flushInProgress) {
758
+ return true;
759
+ }
760
+ if (this.pendingRows.length > 0) {
761
+ return true;
762
+ }
763
+ return false;
764
+ }
765
+ async runWithMaintenanceLock(label, task) {
766
+ if (this.maintenanceInProgress) {
767
+ this.logger.debug(`[SERIES STORAGE] Skipping ${label} (maintenance already running)`);
768
+ return;
769
+ }
770
+ this.maintenanceInProgress = true;
771
+ const startedAt = Date.now();
772
+ try {
773
+ await task();
774
+ const elapsedMs = Date.now() - startedAt;
775
+ this.logger.debug(`[SERIES STORAGE] ${label} completed in ${elapsedMs}ms`);
776
+ }
777
+ finally {
778
+ this.maintenanceInProgress = false;
779
+ }
780
+ }
781
+ async createCoreTables() {
782
+ await this.runSql(`
783
+ CREATE TABLE IF NOT EXISTS history_samples (
784
+ series_id TEXT,
785
+ dataset_uuid TEXT,
786
+ owner_widget_uuid TEXT,
787
+ path TEXT,
788
+ context TEXT,
789
+ source TEXT,
790
+ ts_ms INTEGER,
791
+ value REAL
792
+ )
793
+ `);
794
+ await this.runSql(`
795
+ CREATE TABLE IF NOT EXISTS history_series (
796
+ series_id TEXT NOT NULL,
797
+ dataset_uuid TEXT NOT NULL,
798
+ owner_widget_uuid TEXT NOT NULL,
799
+ owner_widget_selector TEXT,
800
+ path TEXT NOT NULL,
801
+ source TEXT,
802
+ context TEXT,
803
+ time_scale TEXT,
804
+ period INTEGER,
805
+ retention_duration_ms INTEGER,
806
+ sample_time INTEGER,
807
+ enabled INTEGER,
808
+ methods_json TEXT,
809
+ reconcile_ts INTEGER,
810
+ PRIMARY KEY (series_id)
811
+ )
812
+ `);
813
+ }
814
+ async insertRows(rows) {
815
+ if (!this.db || rows.length === 0) {
816
+ return;
817
+ }
818
+ const valuesSql = rows
819
+ .map(sample => `(${this.escape(sample.seriesId)}, ${this.escape(sample.datasetUuid)}, ${this.escape(sample.ownerWidgetUuid)}, ${this.escape(sample.path)}, ${this.escape(sample.context)}, ${this.escape(sample.source)}, ${Math.trunc(sample.timestamp)}, ${Number(sample.value)})`)
820
+ .join(',\n');
821
+ const sql = `
822
+ INSERT INTO history_samples (
823
+ series_id,
824
+ dataset_uuid,
825
+ owner_widget_uuid,
826
+ path,
827
+ context,
828
+ source,
829
+ ts_ms,
830
+ value
831
+ ) VALUES ${valuesSql}
832
+ `;
833
+ this.db.exec('BEGIN');
834
+ try {
835
+ await this.runSql(sql);
836
+ this.db.exec('COMMIT');
837
+ }
838
+ catch (error) {
839
+ this.db.exec('ROLLBACK');
840
+ throw error;
841
+ }
842
+ }
843
+ async runSql(sql) {
844
+ if (!this.db) {
845
+ throw new Error('node:sqlite database is not initialized');
846
+ }
847
+ this.db.exec(sql);
848
+ }
849
+ async querySql(sql) {
850
+ if (!this.db) {
851
+ throw new Error('node:sqlite database is not initialized');
852
+ }
853
+ const statement = this.db.prepare(sql);
854
+ return statement.all();
855
+ }
856
+ async selectRowsForPaths(paths, context, fromMs, toMs) {
857
+ const rowsByPath = new Map();
858
+ if (paths.length === 0) {
859
+ return rowsByPath;
860
+ }
861
+ const pathFilter = paths.map(path => this.escape(path)).join(', ');
862
+ const sql = `
863
+ SELECT path, ts_ms, value
864
+ FROM history_samples
865
+ WHERE context = ${this.escape(context)}
866
+ AND path IN (${pathFilter})
867
+ AND ts_ms >= ${Math.trunc(fromMs)}
868
+ AND ts_ms <= ${Math.trunc(toMs)}
869
+ ORDER BY path ASC, ts_ms ASC
870
+ `;
871
+ const rows = await this.querySql(sql);
872
+ rows.forEach(row => {
873
+ const list = rowsByPath.get(row.path) ?? [];
874
+ list.push({ ts_ms: Number(row.ts_ms), value: Number(row.value) });
875
+ rowsByPath.set(row.path, list);
876
+ });
877
+ return rowsByPath;
878
+ }
879
+ parseRequestedPaths(paths) {
880
+ return String(paths)
881
+ .split(',')
882
+ .map(item => item.trim())
883
+ .filter(Boolean)
884
+ .map(raw => {
885
+ const [pathToken, maybeMethod, maybePeriod] = raw.split(':');
886
+ const path = this.normalizePathIdentifier(pathToken);
887
+ const method = this.parseMethod(maybeMethod);
888
+ const parsedPeriod = maybePeriod !== undefined ? Number(maybePeriod) : undefined;
889
+ return {
890
+ path,
891
+ method,
892
+ period: Number.isFinite(parsedPeriod) ? parsedPeriod : undefined
893
+ };
894
+ });
895
+ }
896
+ parseMethod(value) {
897
+ if (!value)
898
+ return undefined;
899
+ const normalized = value.toLowerCase();
900
+ if (normalized === 'min' || normalized === 'max' || normalized === 'avg' || normalized === 'sma' || normalized === 'ema') {
901
+ return normalized;
902
+ }
903
+ return undefined;
904
+ }
905
+ normalizePathIdentifier(path) {
906
+ const trimmed = String(path).trim();
907
+ if (!trimmed) {
908
+ return '';
909
+ }
910
+ if (trimmed.startsWith('vessels.self.')) {
911
+ return trimmed.slice('vessels.self.'.length);
912
+ }
913
+ if (trimmed.startsWith('self.')) {
914
+ return trimmed.slice('self.'.length);
915
+ }
916
+ return trimmed;
917
+ }
918
+ resolveRange(nowMs, from, to, duration) {
919
+ const toMs = to ? Date.parse(to) : nowMs;
920
+ if (!Number.isFinite(toMs)) {
921
+ throw new Error('Invalid to date-time. Expected an ISO 8601 date-time string.');
922
+ }
923
+ const fromMs = from ? Date.parse(from) : toMs - this.parseDurationMs(duration);
924
+ if (!Number.isFinite(fromMs)) {
925
+ throw new Error('Invalid from date-time. Expected an ISO 8601 date-time string.');
926
+ }
927
+ return { fromMs, toMs };
928
+ }
929
+ parseDurationMs(duration) {
930
+ if (duration === undefined || duration === null) {
931
+ return 60 * 60_000;
932
+ }
933
+ if (typeof duration === 'number' && Number.isFinite(duration) && duration > 0) {
934
+ return duration;
935
+ }
936
+ if (typeof duration === 'number') {
937
+ throw new Error('Invalid duration. Expected a positive number of milliseconds or an ISO 8601 duration (e.g. PT10M).');
938
+ }
939
+ const value = String(duration).trim();
940
+ if (/^\d+(\.\d+)?$/.test(value)) {
941
+ const parsedMs = Number(value);
942
+ if (Number.isFinite(parsedMs) && parsedMs > 0) {
943
+ return parsedMs;
944
+ }
945
+ throw new Error('Invalid duration. Expected a positive number of milliseconds or an ISO 8601 duration (e.g. PT10M).');
946
+ }
947
+ const iso = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/i.exec(value);
948
+ if (!iso) {
949
+ throw new Error('Invalid duration. Expected a positive number of milliseconds or an ISO 8601 duration (e.g. PT10M).');
950
+ }
951
+ const hours = Number(iso[1] || 0);
952
+ const minutes = Number(iso[2] || 0);
953
+ const seconds = Number(iso[3] || 0);
954
+ const totalMs = (((hours * 60) + minutes) * 60 + seconds) * 1000;
955
+ if (totalMs <= 0) {
956
+ throw new Error('Invalid duration. Expected a positive number of milliseconds or an ISO 8601 duration (e.g. PT10M).');
957
+ }
958
+ return totalMs;
959
+ }
960
+ resolveResolutionMs(resolution) {
961
+ if (resolution === undefined || resolution === null) {
962
+ return 0;
963
+ }
964
+ if (typeof resolution === 'number') {
965
+ if (!Number.isFinite(resolution) || resolution <= 0) {
966
+ throw new Error('Invalid resolution. Expected a positive number of seconds or an ISO 8601 duration (e.g. PT10S).');
967
+ }
968
+ return Math.max(1, Math.trunc(resolution * 1000));
969
+ }
970
+ const value = String(resolution).trim();
971
+ if (!value) {
972
+ throw new Error('Invalid resolution. Expected a positive number of seconds or an ISO 8601 duration (e.g. PT10S).');
973
+ }
974
+ if (/^\d+(\.\d+)?$/.test(value)) {
975
+ const parsedSeconds = Number(value);
976
+ if (!Number.isFinite(parsedSeconds) || parsedSeconds <= 0) {
977
+ throw new Error('Invalid resolution. Expected a positive number of seconds or an ISO 8601 duration (e.g. PT10S).');
978
+ }
979
+ return Math.max(1, Math.trunc(parsedSeconds * 1000));
980
+ }
981
+ try {
982
+ return this.parseDurationMs(value);
983
+ }
984
+ catch {
985
+ throw new Error('Invalid resolution. Expected a positive number of seconds or an ISO 8601 duration (e.g. PT10S).');
986
+ }
987
+ }
988
+ applyMethod(request, rows) {
989
+ if (rows.length === 0)
990
+ return [];
991
+ const method = request.method ?? 'avg';
992
+ if (method === 'min' || method === 'max' || method === 'avg') {
993
+ return rows.map(entry => ({ timestamp: Number(entry.ts_ms), value: Number(entry.value) }));
994
+ }
995
+ if (method === 'sma') {
996
+ const period = Math.max(1, request.period ?? 5);
997
+ return rows.map((entry, index) => {
998
+ const start = Math.max(0, index - period + 1);
999
+ const window = rows.slice(start, index + 1);
1000
+ const sum = window.reduce((acc, item) => acc + Number(item.value), 0);
1001
+ return {
1002
+ timestamp: Number(entry.ts_ms),
1003
+ value: sum / window.length
1004
+ };
1005
+ });
1006
+ }
1007
+ const period = Math.max(1, request.period ?? 5);
1008
+ const multiplier = 2 / (period + 1);
1009
+ let previous = null;
1010
+ return rows.map(entry => {
1011
+ const value = Number(entry.value);
1012
+ if (previous === null) {
1013
+ previous = value;
1014
+ }
1015
+ else {
1016
+ previous = ((value - previous) * multiplier) + previous;
1017
+ }
1018
+ return {
1019
+ timestamp: Number(entry.ts_ms),
1020
+ value: previous
1021
+ };
1022
+ });
1023
+ }
1024
+ downsampleIfNeeded(values, resolutionMs, method) {
1025
+ if (resolutionMs <= 0 || values.length === 0) {
1026
+ return values;
1027
+ }
1028
+ const buckets = new Map();
1029
+ values.forEach(entry => {
1030
+ if (!Number.isFinite(entry.value)) {
1031
+ return;
1032
+ }
1033
+ const bucket = Math.floor(entry.timestamp / resolutionMs) * resolutionMs;
1034
+ const list = buckets.get(bucket) ?? [];
1035
+ list.push(entry.value);
1036
+ buckets.set(bucket, list);
1037
+ });
1038
+ return Array.from(buckets.entries())
1039
+ .sort((left, right) => left[0] - right[0])
1040
+ .map(([timestamp, list]) => {
1041
+ const value = this.aggregateBucket(list, method);
1042
+ return {
1043
+ timestamp,
1044
+ value
1045
+ };
1046
+ });
1047
+ }
1048
+ aggregateBucket(values, method) {
1049
+ if (values.length === 0) {
1050
+ return null;
1051
+ }
1052
+ if (method === 'min') {
1053
+ return Math.min(...values);
1054
+ }
1055
+ if (method === 'max') {
1056
+ return Math.max(...values);
1057
+ }
1058
+ const sum = values.reduce((acc, value) => acc + value, 0);
1059
+ return sum / values.length;
1060
+ }
1061
+ escape(value) {
1062
+ return `'${String(value).replace(/'/g, "''")}'`;
1063
+ }
1064
+ nullableString(value) {
1065
+ if (value === undefined || value === null || value === '') {
1066
+ return 'NULL';
1067
+ }
1068
+ return this.escape(value);
1069
+ }
1070
+ nullableNumber(value) {
1071
+ if (value === undefined || value === null || !Number.isFinite(value)) {
1072
+ return 'NULL';
1073
+ }
1074
+ return String(Math.trunc(value));
1075
+ }
1076
+ parseMethods(value) {
1077
+ if (!value)
1078
+ return undefined;
1079
+ try {
1080
+ const parsed = JSON.parse(value);
1081
+ if (!Array.isArray(parsed))
1082
+ return undefined;
1083
+ const methods = parsed.filter(entry => entry === 'min' || entry === 'max' || entry === 'avg' || entry === 'sma' || entry === 'ema');
1084
+ return methods.length > 0 ? methods : undefined;
1085
+ }
1086
+ catch {
1087
+ return undefined;
1088
+ }
1089
+ }
1090
+ toNumberOrUndefined(value) {
1091
+ if (value === undefined || value === null) {
1092
+ return undefined;
1093
+ }
1094
+ if (typeof value === 'bigint') {
1095
+ return Number(value);
1096
+ }
1097
+ const parsed = Number(value);
1098
+ return Number.isFinite(parsed) ? parsed : undefined;
1099
+ }
1100
+ toBoolean(value) {
1101
+ if (typeof value === 'boolean') {
1102
+ return value;
1103
+ }
1104
+ if (typeof value === 'bigint') {
1105
+ return value !== 0n;
1106
+ }
1107
+ if (typeof value === 'number') {
1108
+ return value !== 0;
1109
+ }
1110
+ if (typeof value === 'string') {
1111
+ const normalized = value.trim().toLowerCase();
1112
+ if (normalized === 'true' || normalized === '1') {
1113
+ return true;
1114
+ }
1115
+ if (normalized === 'false' || normalized === '0') {
1116
+ return false;
1117
+ }
1118
+ }
1119
+ return Boolean(value);
1120
+ }
1121
+ }
1122
+ exports.SqliteHistoryStorageService = SqliteHistoryStorageService;