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