ultravisor 1.0.22 → 1.0.24

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.
@@ -0,0 +1,886 @@
1
+ /**
2
+ * Ultravisor Beacon Queue Store
3
+ *
4
+ * SQLite-backed persistence for the beacon work queue, runs, affinity
5
+ * bindings, per-attempt history, and action defaults. Replaces the
6
+ * former JSONL journal as the durable store of record.
7
+ *
8
+ * Schema is driven by the stricture intermediate model at
9
+ * source/datamodel/Ultravisor-BeaconQueue.json. DDL is generated
10
+ * directly (CREATE TABLE IF NOT EXISTS + ALTER TABLE ADD COLUMN for
11
+ * forward-only drift), avoiding the meadow bootstrap chain.
12
+ *
13
+ * SQLite WAL mode is enabled on open — crash-safe, concurrent reader
14
+ * support, no separate journal file to reason about.
15
+ *
16
+ * @module Ultravisor-Beacon-QueueStore
17
+ */
18
+
19
+ const libPictService = require('pict-serviceproviderbase');
20
+ const libFS = require('fs');
21
+ const libPath = require('path');
22
+
23
+ let libBetterSqlite = null;
24
+ try
25
+ {
26
+ libBetterSqlite = require('better-sqlite3');
27
+ }
28
+ catch (pError)
29
+ {
30
+ // better-sqlite3 is a hard requirement at runtime but we defer the
31
+ // failure to initialize() so unit tests that don't touch persistence
32
+ // can still load the module.
33
+ }
34
+
35
+ const TYPE_INTEGER_RE = /^(Integer|Int|FK|AutoID|AutoIdentity|ID|CreateIDUser|UpdateIDUser|DeleteIDUser|Boolean|Deleted)$/i;
36
+ const TYPE_NUMERIC_INT_SIZES = new Set(['int', 'integer', 'smallint', 'bigint', 'tinyint']);
37
+
38
+ class UltravisorBeaconQueueStore extends libPictService
39
+ {
40
+ constructor(pPict, pOptions, pServiceHash)
41
+ {
42
+ super(pPict, pOptions, pServiceHash);
43
+
44
+ this.serviceType = 'UltravisorBeaconQueueStore';
45
+
46
+ this._DBPath = '';
47
+ this._DB = null;
48
+ this._Schema = null;
49
+ this._Initialized = false;
50
+ this._Enabled = false;
51
+
52
+ // Prepared statement cache — compiled once, reused per call.
53
+ this._Prepared = {};
54
+ }
55
+
56
+ // ====================================================================
57
+ // Lifecycle
58
+ // ====================================================================
59
+
60
+ /**
61
+ * Initialize the store: open SQLite, apply WAL, provision tables.
62
+ *
63
+ * @param {string} pStorePath - Base storage directory
64
+ * @returns {boolean} true on success
65
+ */
66
+ initialize(pStorePath)
67
+ {
68
+ if (this._Initialized)
69
+ {
70
+ return this._Enabled;
71
+ }
72
+ this._Initialized = true;
73
+
74
+ if (!pStorePath)
75
+ {
76
+ this.log.warn('BeaconQueueStore: no store path; persistence disabled.');
77
+ return false;
78
+ }
79
+
80
+ if (!libBetterSqlite)
81
+ {
82
+ this.log.error('BeaconQueueStore: better-sqlite3 not installed; persistence disabled.');
83
+ return false;
84
+ }
85
+
86
+ let tmpDir = libPath.join(pStorePath, 'beacon');
87
+ try
88
+ {
89
+ if (!libFS.existsSync(tmpDir))
90
+ {
91
+ libFS.mkdirSync(tmpDir, { recursive: true });
92
+ }
93
+ }
94
+ catch (pError)
95
+ {
96
+ this.log.error(`BeaconQueueStore: failed to create [${tmpDir}]: ${pError.message}`);
97
+ return false;
98
+ }
99
+
100
+ this._DBPath = libPath.join(tmpDir, 'beacon-queue.sqlite');
101
+
102
+ try
103
+ {
104
+ this._DB = new libBetterSqlite(this._DBPath);
105
+ this._DB.pragma('journal_mode = WAL');
106
+ this._DB.pragma('synchronous = NORMAL');
107
+ this._DB.pragma('foreign_keys = ON');
108
+ }
109
+ catch (pError)
110
+ {
111
+ this.log.error(`BeaconQueueStore: failed to open [${this._DBPath}]: ${pError.message}`);
112
+ return false;
113
+ }
114
+
115
+ let tmpSchemaPath = libPath.join(__dirname, '..', '..', 'datamodel', 'Ultravisor-BeaconQueue.json');
116
+ try
117
+ {
118
+ this._Schema = JSON.parse(libFS.readFileSync(tmpSchemaPath, 'utf8'));
119
+ }
120
+ catch (pError)
121
+ {
122
+ this.log.error(`BeaconQueueStore: failed to load schema [${tmpSchemaPath}]: ${pError.message}`);
123
+ return false;
124
+ }
125
+
126
+ try
127
+ {
128
+ this._provisionTables();
129
+ }
130
+ catch (pError)
131
+ {
132
+ this.log.error(`BeaconQueueStore: schema provisioning failed: ${pError.message}`);
133
+ return false;
134
+ }
135
+
136
+ this._Enabled = true;
137
+ this.log.info(`BeaconQueueStore: ready at [${this._DBPath}].`);
138
+ return true;
139
+ }
140
+
141
+ isEnabled()
142
+ {
143
+ return this._Enabled;
144
+ }
145
+
146
+ close()
147
+ {
148
+ if (this._DB)
149
+ {
150
+ try { this._DB.close(); } catch (pError) { /* ignore */ }
151
+ this._DB = null;
152
+ }
153
+ this._Enabled = false;
154
+ this._Prepared = {};
155
+ }
156
+
157
+ // ====================================================================
158
+ // Schema provisioning
159
+ // ====================================================================
160
+
161
+ /**
162
+ * Translate a stricture column spec to a SQLite column DDL fragment.
163
+ * Forward-only drift uses the same mapping for ALTER TABLE ADD COLUMN.
164
+ */
165
+ _columnToSql(pCol, pIsPrimary)
166
+ {
167
+ let tmpType = pCol.DataType || pCol.Type || 'String';
168
+ let tmpSize = (pCol.Size || '').toString().toLowerCase();
169
+
170
+ let tmpSqlType = 'TEXT';
171
+ if (/^ID$/i.test(tmpType))
172
+ {
173
+ tmpSqlType = 'INTEGER';
174
+ }
175
+ else if (/^Numeric$/i.test(tmpType))
176
+ {
177
+ tmpSqlType = TYPE_NUMERIC_INT_SIZES.has(tmpSize) ? 'INTEGER' : 'REAL';
178
+ }
179
+ else if (TYPE_INTEGER_RE.test(tmpType))
180
+ {
181
+ tmpSqlType = 'INTEGER';
182
+ }
183
+ else if (/^(DateTime|Date)$/i.test(tmpType))
184
+ {
185
+ tmpSqlType = 'TEXT';
186
+ }
187
+ else
188
+ {
189
+ tmpSqlType = 'TEXT';
190
+ }
191
+
192
+ let tmpDDL = `"${pCol.Column}" ${tmpSqlType}`;
193
+ if (pIsPrimary)
194
+ {
195
+ tmpDDL += ' PRIMARY KEY AUTOINCREMENT';
196
+ }
197
+ return tmpDDL;
198
+ }
199
+
200
+ _provisionTables()
201
+ {
202
+ let tmpTables = this._Schema.Tables || {};
203
+ for (let tmpName of Object.keys(tmpTables))
204
+ {
205
+ let tmpTable = tmpTables[tmpName];
206
+ let tmpCols = tmpTable.Columns || [];
207
+ if (tmpCols.length === 0) continue;
208
+
209
+ // Convention: first ID column is the primary key.
210
+ let tmpPrimaryIdx = tmpCols.findIndex((c) => /^ID$/i.test(c.DataType || c.Type || ''));
211
+ let tmpDDLCols = tmpCols.map((c, idx) =>
212
+ this._columnToSql(c, idx === tmpPrimaryIdx));
213
+
214
+ let tmpCreate = `CREATE TABLE IF NOT EXISTS "${tmpTable.TableName}" (${tmpDDLCols.join(', ')})`;
215
+ this._DB.exec(tmpCreate);
216
+
217
+ // Forward-only ADD COLUMN migration
218
+ let tmpExisting = {};
219
+ let tmpInfo = this._DB.prepare(`PRAGMA table_info("${tmpTable.TableName}")`).all();
220
+ for (let r of tmpInfo) tmpExisting[r.name] = r;
221
+
222
+ for (let c of tmpCols)
223
+ {
224
+ if (tmpExisting[c.Column]) continue;
225
+ let tmpAdd = this._columnToSql(c, false);
226
+ try
227
+ {
228
+ this._DB.exec(`ALTER TABLE "${tmpTable.TableName}" ADD COLUMN ${tmpAdd}`);
229
+ this.log.info(`BeaconQueueStore: migrated ${tmpTable.TableName}.${c.Column}`);
230
+ }
231
+ catch (pAlterErr)
232
+ {
233
+ if (!/duplicate column/i.test(pAlterErr.message))
234
+ {
235
+ throw pAlterErr;
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ // Useful indices on hot lookup paths.
242
+ let tmpIndices = [
243
+ 'CREATE INDEX IF NOT EXISTS idx_workitem_hash ON BeaconWorkItem(WorkItemHash)',
244
+ 'CREATE INDEX IF NOT EXISTS idx_workitem_status ON BeaconWorkItem(Status)',
245
+ 'CREATE INDEX IF NOT EXISTS idx_workitem_runid ON BeaconWorkItem(RunID)',
246
+ 'CREATE INDEX IF NOT EXISTS idx_workitem_assigned ON BeaconWorkItem(AssignedBeaconID, Status)',
247
+ 'CREATE INDEX IF NOT EXISTS idx_workitem_priority ON BeaconWorkItem(Status, Priority, EnqueuedAt)',
248
+ 'CREATE INDEX IF NOT EXISTS idx_run_runid ON BeaconRun(RunID)',
249
+ 'CREATE INDEX IF NOT EXISTS idx_run_idempotency ON BeaconRun(IdempotencyKey)',
250
+ 'CREATE INDEX IF NOT EXISTS idx_affinity_key ON BeaconAffinityBinding(AffinityKey)',
251
+ 'CREATE INDEX IF NOT EXISTS idx_event_hash ON BeaconWorkItemEvent(WorkItemHash)',
252
+ 'CREATE INDEX IF NOT EXISTS idx_action_default ON BeaconActionDefault(Capability, Action)'
253
+ ];
254
+ for (let tmpSql of tmpIndices)
255
+ {
256
+ this._DB.exec(tmpSql);
257
+ }
258
+ }
259
+
260
+ // ====================================================================
261
+ // Internal helpers
262
+ // ====================================================================
263
+
264
+ _prepare(pKey, pSql)
265
+ {
266
+ if (!this._Prepared[pKey])
267
+ {
268
+ this._Prepared[pKey] = this._DB.prepare(pSql);
269
+ }
270
+ return this._Prepared[pKey];
271
+ }
272
+
273
+ _nowIso()
274
+ {
275
+ return new Date().toISOString();
276
+ }
277
+
278
+ _serializeJSON(pValue)
279
+ {
280
+ if (pValue == null) return null;
281
+ if (typeof pValue === 'string') return pValue;
282
+ try { return JSON.stringify(pValue); } catch (pErr) { return null; }
283
+ }
284
+
285
+ _parseJSON(pValue, pFallback)
286
+ {
287
+ if (pValue == null || pValue === '') return (pFallback !== undefined) ? pFallback : null;
288
+ if (typeof pValue !== 'string') return pValue;
289
+ try { return JSON.parse(pValue); } catch (pErr) { return (pFallback !== undefined) ? pFallback : null; }
290
+ }
291
+
292
+ // ====================================================================
293
+ // Run lifecycle
294
+ // ====================================================================
295
+
296
+ insertRun(pRun)
297
+ {
298
+ if (!this._Enabled) return null;
299
+ let tmpStmt = this._prepare('insertRun', `
300
+ INSERT INTO BeaconRun
301
+ (GUIDBeaconRun, CreateDate, UpdateDate, Deleted, RunID, IdempotencyKey,
302
+ SubmitterTag, State, StartedAt, EndedAt, CanceledAt, CancelReason, Metadata)
303
+ VALUES (@GUIDBeaconRun, @CreateDate, @UpdateDate, 0, @RunID, @IdempotencyKey,
304
+ @SubmitterTag, @State, @StartedAt, @EndedAt, @CanceledAt, @CancelReason, @Metadata)
305
+ `);
306
+ let tmpNow = this._nowIso();
307
+ let tmpRec = {
308
+ GUIDBeaconRun: pRun.GUIDBeaconRun || '',
309
+ CreateDate: tmpNow,
310
+ UpdateDate: tmpNow,
311
+ RunID: pRun.RunID,
312
+ IdempotencyKey: pRun.IdempotencyKey || '',
313
+ SubmitterTag: pRun.SubmitterTag || '',
314
+ State: pRun.State || 'Active',
315
+ StartedAt: pRun.StartedAt || tmpNow,
316
+ EndedAt: pRun.EndedAt || null,
317
+ CanceledAt: pRun.CanceledAt || null,
318
+ CancelReason: pRun.CancelReason || '',
319
+ Metadata: this._serializeJSON(pRun.Metadata)
320
+ };
321
+ tmpStmt.run(tmpRec);
322
+ return this.getRunByRunID(pRun.RunID);
323
+ }
324
+
325
+ getRunByRunID(pRunID)
326
+ {
327
+ if (!this._Enabled) return null;
328
+ let tmpStmt = this._prepare('getRunByRunID',
329
+ 'SELECT * FROM BeaconRun WHERE RunID = ? AND Deleted = 0 LIMIT 1');
330
+ let tmpRow = tmpStmt.get(pRunID);
331
+ return tmpRow ? this._hydrateRun(tmpRow) : null;
332
+ }
333
+
334
+ getRunByIdempotencyKey(pKey)
335
+ {
336
+ if (!this._Enabled || !pKey) return null;
337
+ let tmpStmt = this._prepare('getRunByIdempotency',
338
+ 'SELECT * FROM BeaconRun WHERE IdempotencyKey = ? AND Deleted = 0 ORDER BY IDBeaconRun DESC LIMIT 1');
339
+ let tmpRow = tmpStmt.get(pKey);
340
+ return tmpRow ? this._hydrateRun(tmpRow) : null;
341
+ }
342
+
343
+ updateRunState(pRunID, pState, pExtras)
344
+ {
345
+ if (!this._Enabled) return;
346
+ let tmpExtras = pExtras || {};
347
+ let tmpStmt = this._prepare('updateRunState', `
348
+ UPDATE BeaconRun
349
+ SET State = @State,
350
+ EndedAt = COALESCE(@EndedAt, EndedAt),
351
+ CanceledAt = COALESCE(@CanceledAt, CanceledAt),
352
+ CancelReason = COALESCE(@CancelReason, CancelReason),
353
+ UpdateDate = @UpdateDate
354
+ WHERE RunID = @RunID
355
+ `);
356
+ tmpStmt.run({
357
+ RunID: pRunID,
358
+ State: pState,
359
+ EndedAt: tmpExtras.EndedAt || null,
360
+ CanceledAt: tmpExtras.CanceledAt || null,
361
+ CancelReason: tmpExtras.CancelReason || null,
362
+ UpdateDate: this._nowIso()
363
+ });
364
+ }
365
+
366
+ _hydrateRun(pRow)
367
+ {
368
+ return {
369
+ IDBeaconRun: pRow.IDBeaconRun,
370
+ GUIDBeaconRun: pRow.GUIDBeaconRun,
371
+ RunID: pRow.RunID,
372
+ IdempotencyKey: pRow.IdempotencyKey,
373
+ SubmitterTag: pRow.SubmitterTag,
374
+ State: pRow.State,
375
+ StartedAt: pRow.StartedAt,
376
+ EndedAt: pRow.EndedAt,
377
+ CanceledAt: pRow.CanceledAt,
378
+ CancelReason: pRow.CancelReason,
379
+ Metadata: this._parseJSON(pRow.Metadata, {}),
380
+ CreateDate: pRow.CreateDate,
381
+ UpdateDate: pRow.UpdateDate
382
+ };
383
+ }
384
+
385
+ // ====================================================================
386
+ // Work item CRUD
387
+ // ====================================================================
388
+
389
+ upsertWorkItem(pItem)
390
+ {
391
+ if (!this._Enabled) return null;
392
+ let tmpExisting = this.getWorkItemByHash(pItem.WorkItemHash);
393
+ let tmpNow = this._nowIso();
394
+
395
+ if (!tmpExisting)
396
+ {
397
+ let tmpInsert = this._prepare('insertWorkItem', `
398
+ INSERT INTO BeaconWorkItem
399
+ (GUIDBeaconWorkItem, CreateDate, UpdateDate, Deleted,
400
+ WorkItemHash, RunID, RunHash, NodeHash, OperationHash,
401
+ Capability, Action, Settings, AffinityKey, Priority,
402
+ Status, AssignedBeaconID, TimeoutMs, MaxAttempts, AttemptNumber,
403
+ RetryBackoffMs, EnqueuedAt, AssignedAt, DispatchedAt, StartedAt,
404
+ CompletedAt, CanceledAt, CancelRequested, CancelReason,
405
+ LastError, LastEventAt, QueueWaitMs,
406
+ Health, HealthLabel, HealthReason, HealthComputedAt, Result)
407
+ VALUES
408
+ (@GUIDBeaconWorkItem, @CreateDate, @UpdateDate, 0,
409
+ @WorkItemHash, @RunID, @RunHash, @NodeHash, @OperationHash,
410
+ @Capability, @Action, @Settings, @AffinityKey, @Priority,
411
+ @Status, @AssignedBeaconID, @TimeoutMs, @MaxAttempts, @AttemptNumber,
412
+ @RetryBackoffMs, @EnqueuedAt, @AssignedAt, @DispatchedAt, @StartedAt,
413
+ @CompletedAt, @CanceledAt, @CancelRequested, @CancelReason,
414
+ @LastError, @LastEventAt, @QueueWaitMs,
415
+ @Health, @HealthLabel, @HealthReason, @HealthComputedAt, @Result)
416
+ `);
417
+ tmpInsert.run({
418
+ GUIDBeaconWorkItem: pItem.GUIDBeaconWorkItem || '',
419
+ CreateDate: tmpNow,
420
+ UpdateDate: tmpNow,
421
+ WorkItemHash: pItem.WorkItemHash,
422
+ RunID: pItem.RunID || '',
423
+ RunHash: pItem.RunHash || '',
424
+ NodeHash: pItem.NodeHash || '',
425
+ OperationHash: pItem.OperationHash || '',
426
+ Capability: pItem.Capability || 'Shell',
427
+ Action: pItem.Action || '',
428
+ Settings: this._serializeJSON(pItem.Settings || {}),
429
+ AffinityKey: pItem.AffinityKey || '',
430
+ Priority: pItem.Priority || 0,
431
+ Status: pItem.Status || 'Queued',
432
+ AssignedBeaconID: pItem.AssignedBeaconID || '',
433
+ TimeoutMs: pItem.TimeoutMs || 300000,
434
+ MaxAttempts: pItem.MaxAttempts || 1,
435
+ AttemptNumber: pItem.AttemptNumber || 0,
436
+ RetryBackoffMs: pItem.RetryBackoffMs || 5000,
437
+ EnqueuedAt: pItem.EnqueuedAt || tmpNow,
438
+ AssignedAt: pItem.AssignedAt || null,
439
+ DispatchedAt: pItem.DispatchedAt || null,
440
+ StartedAt: pItem.StartedAt || null,
441
+ CompletedAt: pItem.CompletedAt || null,
442
+ CanceledAt: pItem.CanceledAt || null,
443
+ CancelRequested: pItem.CancelRequested ? 1 : 0,
444
+ CancelReason: pItem.CancelReason || '',
445
+ LastError: pItem.LastError || '',
446
+ LastEventAt: pItem.LastEventAt || tmpNow,
447
+ QueueWaitMs: pItem.QueueWaitMs || 0,
448
+ Health: (pItem.Health == null) ? null : String(pItem.Health),
449
+ HealthLabel: pItem.HealthLabel || 'Unknown',
450
+ HealthReason: pItem.HealthReason || '',
451
+ HealthComputedAt: pItem.HealthComputedAt || null,
452
+ Result: this._serializeJSON(pItem.Result)
453
+ });
454
+ }
455
+ else
456
+ {
457
+ this.updateWorkItem(pItem.WorkItemHash, pItem);
458
+ }
459
+ return this.getWorkItemByHash(pItem.WorkItemHash);
460
+ }
461
+
462
+ updateWorkItem(pHash, pPatch)
463
+ {
464
+ if (!this._Enabled) return;
465
+ let tmpFields = [];
466
+ let tmpParams = { WorkItemHash: pHash, UpdateDate: this._nowIso() };
467
+ let tmpAllowed = [
468
+ 'RunID', 'RunHash', 'NodeHash', 'OperationHash', 'Capability', 'Action',
469
+ 'Settings', 'AffinityKey', 'Priority', 'Status', 'AssignedBeaconID',
470
+ 'TimeoutMs', 'MaxAttempts', 'AttemptNumber', 'RetryBackoffMs',
471
+ 'EnqueuedAt', 'AssignedAt', 'DispatchedAt', 'StartedAt', 'CompletedAt',
472
+ 'CanceledAt', 'CancelRequested', 'CancelReason', 'LastError',
473
+ 'LastEventAt', 'QueueWaitMs', 'Health', 'HealthLabel', 'HealthReason',
474
+ 'HealthComputedAt', 'Result'
475
+ ];
476
+ for (let tmpKey of tmpAllowed)
477
+ {
478
+ if (!(tmpKey in pPatch)) continue;
479
+ let tmpVal = pPatch[tmpKey];
480
+ if (tmpKey === 'Settings' || tmpKey === 'Result')
481
+ {
482
+ tmpVal = this._serializeJSON(tmpVal);
483
+ }
484
+ else if (tmpKey === 'Health' && tmpVal != null)
485
+ {
486
+ tmpVal = String(tmpVal);
487
+ }
488
+ else if (tmpKey === 'CancelRequested')
489
+ {
490
+ tmpVal = tmpVal ? 1 : 0;
491
+ }
492
+ tmpFields.push(`"${tmpKey}" = @${tmpKey}`);
493
+ tmpParams[tmpKey] = tmpVal;
494
+ }
495
+ if (tmpFields.length === 0) return;
496
+ tmpFields.push('UpdateDate = @UpdateDate');
497
+
498
+ let tmpSql = `UPDATE BeaconWorkItem SET ${tmpFields.join(', ')} WHERE WorkItemHash = @WorkItemHash`;
499
+ // Not cached — field list varies per call.
500
+ this._DB.prepare(tmpSql).run(tmpParams);
501
+ }
502
+
503
+ getWorkItemByHash(pHash)
504
+ {
505
+ if (!this._Enabled) return null;
506
+ let tmpStmt = this._prepare('getWorkItemByHash',
507
+ 'SELECT * FROM BeaconWorkItem WHERE WorkItemHash = ? AND Deleted = 0 LIMIT 1');
508
+ let tmpRow = tmpStmt.get(pHash);
509
+ return tmpRow ? this._hydrateWorkItem(tmpRow) : null;
510
+ }
511
+
512
+ listWorkItems(pFilter)
513
+ {
514
+ if (!this._Enabled) return [];
515
+ let tmpFilter = pFilter || {};
516
+ let tmpWhere = ['Deleted = 0'];
517
+ let tmpParams = {};
518
+
519
+ if (tmpFilter.Status)
520
+ {
521
+ if (Array.isArray(tmpFilter.Status))
522
+ {
523
+ let tmpKeys = tmpFilter.Status.map((s, i) => `@status_${i}`);
524
+ tmpWhere.push(`Status IN (${tmpKeys.join(',')})`);
525
+ tmpFilter.Status.forEach((s, i) => { tmpParams[`status_${i}`] = s; });
526
+ }
527
+ else
528
+ {
529
+ tmpWhere.push('Status = @Status');
530
+ tmpParams.Status = tmpFilter.Status;
531
+ }
532
+ }
533
+ if (tmpFilter.AssignedBeaconID)
534
+ {
535
+ tmpWhere.push('AssignedBeaconID = @AssignedBeaconID');
536
+ tmpParams.AssignedBeaconID = tmpFilter.AssignedBeaconID;
537
+ }
538
+ if (tmpFilter.RunID)
539
+ {
540
+ tmpWhere.push('RunID = @RunID');
541
+ tmpParams.RunID = tmpFilter.RunID;
542
+ }
543
+ if (tmpFilter.Capability)
544
+ {
545
+ tmpWhere.push('Capability = @Capability');
546
+ tmpParams.Capability = tmpFilter.Capability;
547
+ }
548
+
549
+ let tmpOrder = tmpFilter.OrderBy || 'EnqueuedAt ASC';
550
+ let tmpLimit = Math.max(1, Math.min(parseInt(tmpFilter.Limit, 10) || 500, 5000));
551
+
552
+ let tmpSql = `SELECT * FROM BeaconWorkItem WHERE ${tmpWhere.join(' AND ')} ORDER BY ${tmpOrder} LIMIT ${tmpLimit}`;
553
+ let tmpRows = this._DB.prepare(tmpSql).all(tmpParams);
554
+ return tmpRows.map((r) => this._hydrateWorkItem(r));
555
+ }
556
+
557
+ countByStatus()
558
+ {
559
+ if (!this._Enabled) return {};
560
+ let tmpStmt = this._prepare('countByStatus',
561
+ 'SELECT Status, COUNT(*) as Count FROM BeaconWorkItem WHERE Deleted = 0 GROUP BY Status');
562
+ let tmpRows = tmpStmt.all();
563
+ let tmpOut = {};
564
+ for (let r of tmpRows) tmpOut[r.Status] = r.Count;
565
+ return tmpOut;
566
+ }
567
+
568
+ _hydrateWorkItem(pRow)
569
+ {
570
+ let tmpHealth = null;
571
+ if (pRow.Health != null && pRow.Health !== '')
572
+ {
573
+ let tmpParsed = parseFloat(pRow.Health);
574
+ if (!isNaN(tmpParsed)) tmpHealth = tmpParsed;
575
+ }
576
+ return {
577
+ IDBeaconWorkItem: pRow.IDBeaconWorkItem,
578
+ GUIDBeaconWorkItem: pRow.GUIDBeaconWorkItem,
579
+ WorkItemHash: pRow.WorkItemHash,
580
+ RunID: pRow.RunID,
581
+ RunHash: pRow.RunHash,
582
+ NodeHash: pRow.NodeHash,
583
+ OperationHash: pRow.OperationHash,
584
+ Capability: pRow.Capability,
585
+ Action: pRow.Action,
586
+ Settings: this._parseJSON(pRow.Settings, {}),
587
+ AffinityKey: pRow.AffinityKey,
588
+ Priority: pRow.Priority,
589
+ Status: pRow.Status,
590
+ AssignedBeaconID: pRow.AssignedBeaconID || null,
591
+ TimeoutMs: pRow.TimeoutMs,
592
+ MaxAttempts: pRow.MaxAttempts,
593
+ AttemptNumber: pRow.AttemptNumber,
594
+ RetryBackoffMs: pRow.RetryBackoffMs,
595
+ EnqueuedAt: pRow.EnqueuedAt,
596
+ AssignedAt: pRow.AssignedAt,
597
+ DispatchedAt: pRow.DispatchedAt,
598
+ StartedAt: pRow.StartedAt,
599
+ CompletedAt: pRow.CompletedAt,
600
+ CanceledAt: pRow.CanceledAt,
601
+ CancelRequested: !!pRow.CancelRequested,
602
+ CancelReason: pRow.CancelReason,
603
+ LastError: pRow.LastError,
604
+ LastEventAt: pRow.LastEventAt,
605
+ QueueWaitMs: pRow.QueueWaitMs,
606
+ Health: tmpHealth,
607
+ HealthLabel: pRow.HealthLabel || 'Unknown',
608
+ HealthReason: pRow.HealthReason,
609
+ HealthComputedAt: pRow.HealthComputedAt,
610
+ Result: this._parseJSON(pRow.Result)
611
+ };
612
+ }
613
+
614
+ // ====================================================================
615
+ // Events
616
+ // ====================================================================
617
+
618
+ appendEvent(pEvent)
619
+ {
620
+ if (!this._Enabled) return;
621
+ let tmpStmt = this._prepare('appendEvent', `
622
+ INSERT INTO BeaconWorkItemEvent
623
+ (CreateDate, WorkItemHash, RunID, EventType, FromStatus, ToStatus, BeaconID, Payload)
624
+ VALUES (@CreateDate, @WorkItemHash, @RunID, @EventType, @FromStatus, @ToStatus, @BeaconID, @Payload)
625
+ `);
626
+ tmpStmt.run({
627
+ CreateDate: this._nowIso(),
628
+ WorkItemHash: pEvent.WorkItemHash || '',
629
+ RunID: pEvent.RunID || '',
630
+ EventType: pEvent.EventType || '',
631
+ FromStatus: pEvent.FromStatus || '',
632
+ ToStatus: pEvent.ToStatus || '',
633
+ BeaconID: pEvent.BeaconID || '',
634
+ Payload: this._serializeJSON(pEvent.Payload)
635
+ });
636
+ }
637
+
638
+ listEventsForWorkItem(pHash, pLimit)
639
+ {
640
+ if (!this._Enabled) return [];
641
+ let tmpLimit = Math.max(1, Math.min(parseInt(pLimit, 10) || 200, 2000));
642
+ let tmpStmt = this._DB.prepare(
643
+ `SELECT * FROM BeaconWorkItemEvent WHERE WorkItemHash = ? ORDER BY IDBeaconWorkItemEvent ASC LIMIT ${tmpLimit}`);
644
+ return tmpStmt.all(pHash).map((r) => ({
645
+ IDBeaconWorkItemEvent: r.IDBeaconWorkItemEvent,
646
+ CreateDate: r.CreateDate,
647
+ WorkItemHash: r.WorkItemHash,
648
+ RunID: r.RunID,
649
+ EventType: r.EventType,
650
+ FromStatus: r.FromStatus,
651
+ ToStatus: r.ToStatus,
652
+ BeaconID: r.BeaconID,
653
+ Payload: this._parseJSON(r.Payload)
654
+ }));
655
+ }
656
+
657
+ // ====================================================================
658
+ // Attempts
659
+ // ====================================================================
660
+
661
+ insertAttempt(pAttempt)
662
+ {
663
+ if (!this._Enabled) return;
664
+ let tmpStmt = this._prepare('insertAttempt', `
665
+ INSERT INTO BeaconWorkItemAttempt
666
+ (CreateDate, UpdateDate, WorkItemHash, AttemptNumber, BeaconID,
667
+ DispatchedAt, StartedAt, CompletedAt, Outcome, ErrorMessage, DurationMs)
668
+ VALUES (@CreateDate, @UpdateDate, @WorkItemHash, @AttemptNumber, @BeaconID,
669
+ @DispatchedAt, @StartedAt, @CompletedAt, @Outcome, @ErrorMessage, @DurationMs)
670
+ `);
671
+ let tmpNow = this._nowIso();
672
+ tmpStmt.run({
673
+ CreateDate: tmpNow,
674
+ UpdateDate: tmpNow,
675
+ WorkItemHash: pAttempt.WorkItemHash,
676
+ AttemptNumber: pAttempt.AttemptNumber || 1,
677
+ BeaconID: pAttempt.BeaconID || '',
678
+ DispatchedAt: pAttempt.DispatchedAt || tmpNow,
679
+ StartedAt: pAttempt.StartedAt || null,
680
+ CompletedAt: pAttempt.CompletedAt || null,
681
+ Outcome: pAttempt.Outcome || 'Dispatched',
682
+ ErrorMessage: pAttempt.ErrorMessage || '',
683
+ DurationMs: pAttempt.DurationMs || 0
684
+ });
685
+ }
686
+
687
+ updateAttemptOutcome(pHash, pAttemptNumber, pPatch)
688
+ {
689
+ if (!this._Enabled) return;
690
+ let tmpStmt = this._prepare('updateAttemptOutcome', `
691
+ UPDATE BeaconWorkItemAttempt
692
+ SET StartedAt = COALESCE(@StartedAt, StartedAt),
693
+ CompletedAt = COALESCE(@CompletedAt, CompletedAt),
694
+ Outcome = COALESCE(@Outcome, Outcome),
695
+ ErrorMessage = COALESCE(@ErrorMessage, ErrorMessage),
696
+ DurationMs = COALESCE(@DurationMs, DurationMs),
697
+ UpdateDate = @UpdateDate
698
+ WHERE WorkItemHash = @WorkItemHash AND AttemptNumber = @AttemptNumber
699
+ `);
700
+ tmpStmt.run({
701
+ WorkItemHash: pHash,
702
+ AttemptNumber: pAttemptNumber,
703
+ StartedAt: pPatch.StartedAt || null,
704
+ CompletedAt: pPatch.CompletedAt || null,
705
+ Outcome: pPatch.Outcome || null,
706
+ ErrorMessage: pPatch.ErrorMessage || null,
707
+ DurationMs: pPatch.DurationMs || null,
708
+ UpdateDate: this._nowIso()
709
+ });
710
+ }
711
+
712
+ // ====================================================================
713
+ // Affinity bindings
714
+ // ====================================================================
715
+
716
+ upsertAffinityBinding(pBinding)
717
+ {
718
+ if (!this._Enabled) return;
719
+ let tmpExisting = this.getAffinityBinding(pBinding.AffinityKey);
720
+ let tmpNow = this._nowIso();
721
+ if (!tmpExisting)
722
+ {
723
+ let tmpStmt = this._prepare('insertAffinity', `
724
+ INSERT INTO BeaconAffinityBinding
725
+ (CreateDate, UpdateDate, AffinityKey, BeaconID, ExpiresAt, ClearedAt)
726
+ VALUES (@CreateDate, @UpdateDate, @AffinityKey, @BeaconID, @ExpiresAt, @ClearedAt)
727
+ `);
728
+ tmpStmt.run({
729
+ CreateDate: tmpNow,
730
+ UpdateDate: tmpNow,
731
+ AffinityKey: pBinding.AffinityKey,
732
+ BeaconID: pBinding.BeaconID,
733
+ ExpiresAt: pBinding.ExpiresAt || null,
734
+ ClearedAt: null
735
+ });
736
+ }
737
+ else
738
+ {
739
+ let tmpStmt = this._prepare('updateAffinity', `
740
+ UPDATE BeaconAffinityBinding
741
+ SET BeaconID = @BeaconID, ExpiresAt = @ExpiresAt, ClearedAt = NULL, UpdateDate = @UpdateDate
742
+ WHERE AffinityKey = @AffinityKey
743
+ `);
744
+ tmpStmt.run({
745
+ AffinityKey: pBinding.AffinityKey,
746
+ BeaconID: pBinding.BeaconID,
747
+ ExpiresAt: pBinding.ExpiresAt || null,
748
+ UpdateDate: tmpNow
749
+ });
750
+ }
751
+ }
752
+
753
+ getAffinityBinding(pKey)
754
+ {
755
+ if (!this._Enabled) return null;
756
+ let tmpStmt = this._prepare('getAffinity', `
757
+ SELECT * FROM BeaconAffinityBinding
758
+ WHERE AffinityKey = ? AND (ClearedAt IS NULL OR ClearedAt = '')
759
+ ORDER BY IDBeaconAffinityBinding DESC LIMIT 1
760
+ `);
761
+ let tmpRow = tmpStmt.get(pKey);
762
+ return tmpRow ? {
763
+ AffinityKey: tmpRow.AffinityKey,
764
+ BeaconID: tmpRow.BeaconID,
765
+ ExpiresAt: tmpRow.ExpiresAt,
766
+ ClearedAt: tmpRow.ClearedAt,
767
+ CreateDate: tmpRow.CreateDate,
768
+ UpdateDate: tmpRow.UpdateDate
769
+ } : null;
770
+ }
771
+
772
+ clearAffinityBinding(pKey)
773
+ {
774
+ if (!this._Enabled) return;
775
+ let tmpStmt = this._prepare('clearAffinity', `
776
+ UPDATE BeaconAffinityBinding
777
+ SET ClearedAt = @ClearedAt, UpdateDate = @UpdateDate
778
+ WHERE AffinityKey = @AffinityKey
779
+ `);
780
+ let tmpNow = this._nowIso();
781
+ tmpStmt.run({ AffinityKey: pKey, ClearedAt: tmpNow, UpdateDate: tmpNow });
782
+ }
783
+
784
+ listActiveAffinityBindings()
785
+ {
786
+ if (!this._Enabled) return [];
787
+ let tmpStmt = this._prepare('listAffinities', `
788
+ SELECT * FROM BeaconAffinityBinding
789
+ WHERE ClearedAt IS NULL OR ClearedAt = ''
790
+ `);
791
+ return tmpStmt.all();
792
+ }
793
+
794
+ // ====================================================================
795
+ // Action defaults
796
+ // ====================================================================
797
+
798
+ upsertActionDefault(pEntry)
799
+ {
800
+ if (!this._Enabled) return;
801
+ let tmpExisting = this.getActionDefault(pEntry.Capability, pEntry.Action);
802
+ let tmpNow = this._nowIso();
803
+ if (!tmpExisting)
804
+ {
805
+ let tmpStmt = this._prepare('insertActionDefault', `
806
+ INSERT INTO BeaconActionDefault
807
+ (CreateDate, UpdateDate, Capability, Action, TimeoutMs, MaxAttempts,
808
+ RetryBackoffMs, DefaultPriority, ExpectedWaitP95Ms, HeartbeatExpectedMs, MinSamplesForBaseline)
809
+ VALUES (@CreateDate, @UpdateDate, @Capability, @Action, @TimeoutMs, @MaxAttempts,
810
+ @RetryBackoffMs, @DefaultPriority, @ExpectedWaitP95Ms, @HeartbeatExpectedMs, @MinSamplesForBaseline)
811
+ `);
812
+ tmpStmt.run({
813
+ CreateDate: tmpNow,
814
+ UpdateDate: tmpNow,
815
+ Capability: pEntry.Capability,
816
+ Action: pEntry.Action || '',
817
+ TimeoutMs: pEntry.TimeoutMs || 300000,
818
+ MaxAttempts: pEntry.MaxAttempts || 1,
819
+ RetryBackoffMs: pEntry.RetryBackoffMs || 5000,
820
+ DefaultPriority: pEntry.DefaultPriority || 0,
821
+ ExpectedWaitP95Ms: pEntry.ExpectedWaitP95Ms || 0,
822
+ HeartbeatExpectedMs: pEntry.HeartbeatExpectedMs || 0,
823
+ MinSamplesForBaseline: pEntry.MinSamplesForBaseline || 20
824
+ });
825
+ }
826
+ else
827
+ {
828
+ let tmpStmt = this._prepare('updateActionDefault', `
829
+ UPDATE BeaconActionDefault
830
+ SET TimeoutMs = @TimeoutMs, MaxAttempts = @MaxAttempts,
831
+ RetryBackoffMs = @RetryBackoffMs, DefaultPriority = @DefaultPriority,
832
+ ExpectedWaitP95Ms = @ExpectedWaitP95Ms,
833
+ HeartbeatExpectedMs = @HeartbeatExpectedMs,
834
+ MinSamplesForBaseline = @MinSamplesForBaseline,
835
+ UpdateDate = @UpdateDate
836
+ WHERE Capability = @Capability AND Action = @Action
837
+ `);
838
+ tmpStmt.run({
839
+ Capability: pEntry.Capability,
840
+ Action: pEntry.Action || '',
841
+ TimeoutMs: pEntry.TimeoutMs || tmpExisting.TimeoutMs,
842
+ MaxAttempts: pEntry.MaxAttempts || tmpExisting.MaxAttempts,
843
+ RetryBackoffMs: pEntry.RetryBackoffMs || tmpExisting.RetryBackoffMs,
844
+ DefaultPriority: (pEntry.DefaultPriority != null) ? pEntry.DefaultPriority : tmpExisting.DefaultPriority,
845
+ ExpectedWaitP95Ms: (pEntry.ExpectedWaitP95Ms != null) ? pEntry.ExpectedWaitP95Ms : tmpExisting.ExpectedWaitP95Ms,
846
+ HeartbeatExpectedMs: (pEntry.HeartbeatExpectedMs != null) ? pEntry.HeartbeatExpectedMs : tmpExisting.HeartbeatExpectedMs,
847
+ MinSamplesForBaseline: (pEntry.MinSamplesForBaseline != null) ? pEntry.MinSamplesForBaseline : tmpExisting.MinSamplesForBaseline,
848
+ UpdateDate: tmpNow
849
+ });
850
+ }
851
+ }
852
+
853
+ getActionDefault(pCapability, pAction)
854
+ {
855
+ if (!this._Enabled) return null;
856
+ let tmpStmt = this._prepare('getActionDefault', `
857
+ SELECT * FROM BeaconActionDefault
858
+ WHERE Capability = ? AND Action = ? LIMIT 1
859
+ `);
860
+ return tmpStmt.get(pCapability, pAction || '') || null;
861
+ }
862
+
863
+ listActionDefaults()
864
+ {
865
+ if (!this._Enabled) return [];
866
+ return this._DB.prepare('SELECT * FROM BeaconActionDefault').all();
867
+ }
868
+
869
+ // ====================================================================
870
+ // Aggregate queries for observability
871
+ // ====================================================================
872
+
873
+ queueWaitSamples(pCapability, pAction, pLimit)
874
+ {
875
+ if (!this._Enabled) return [];
876
+ let tmpLimit = Math.max(1, Math.min(parseInt(pLimit, 10) || 200, 2000));
877
+ let tmpStmt = this._DB.prepare(`
878
+ SELECT QueueWaitMs FROM BeaconWorkItem
879
+ WHERE Capability = ? AND Action = ? AND QueueWaitMs > 0 AND Deleted = 0
880
+ ORDER BY IDBeaconWorkItem DESC LIMIT ${tmpLimit}
881
+ `);
882
+ return tmpStmt.all(pCapability, pAction || '').map((r) => r.QueueWaitMs);
883
+ }
884
+ }
885
+
886
+ module.exports = UltravisorBeaconQueueStore;