ultravisor 1.0.25 → 1.0.26
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/docs/_sidebar.md +1 -0
- package/docs/features/persistence-via-databeacon.md +1211 -0
- package/package.json +6 -6
- package/source/cli/Ultravisor-CLIProgram.cjs +62 -0
- package/source/config/Ultravisor-Default-Command-Configuration.cjs +9 -1
- package/source/persistence/UltravisorPersistenceSchema.json +240 -0
- package/source/services/Ultravisor-AuthBeaconBridge.cjs +271 -0
- package/source/services/Ultravisor-Beacon-Coordinator.cjs +242 -149
- package/source/services/Ultravisor-Beacon-Scheduler.cjs +65 -29
- package/source/services/Ultravisor-ExecutionManifest.cjs +99 -4
- package/source/services/Ultravisor-FleetManager.cjs +19 -1
- package/source/services/Ultravisor-ManifestStoreBridge.cjs +1134 -0
- package/source/services/Ultravisor-QueuePersistenceBridge.cjs +1336 -0
- package/source/web_server/Ultravisor-API-Server.cjs +951 -90
- package/webinterface/package.json +1 -0
- package/webinterface/source/Pict-Application-Ultravisor.js +57 -2
- package/webinterface/source/providers/PictRouter-Ultravisor-Configuration.json +8 -0
- package/webinterface/source/views/PictView-Ultravisor-Login.js +74 -0
- package/webinterface/source/views/PictView-Ultravisor-TopBar.js +25 -0
- package/webinterface/source/views/PictView-Ultravisor-UserManagement.js +159 -0
|
@@ -0,0 +1,1336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ultravisor-QueuePersistenceBridge
|
|
3
|
+
*
|
|
4
|
+
* Single front door for "where does the work-queue history live?".
|
|
5
|
+
* Two backends, picked at call time:
|
|
6
|
+
*
|
|
7
|
+
* 1. The optional ultravisor-queue-beacon (capability =
|
|
8
|
+
* QueuePersistence). When connected, every write dispatches to
|
|
9
|
+
* the beacon and reads come back from it.
|
|
10
|
+
*
|
|
11
|
+
* 2. The in-process UltravisorBeaconQueueStore. Used as the fallback
|
|
12
|
+
* whenever the beacon isn't connected — the existing behavior the
|
|
13
|
+
* coordinator + scheduler had before this bridge existed.
|
|
14
|
+
*
|
|
15
|
+
* Why a bridge instead of always going through the beacon?
|
|
16
|
+
* ========================================================
|
|
17
|
+
* Two reasons:
|
|
18
|
+
*
|
|
19
|
+
* (a) Chicken-and-egg. Ultravisor's scheduler runs work items
|
|
20
|
+
* (including the queue beacon's own registration / heartbeat
|
|
21
|
+
* traffic). If queue persistence MUST be a beacon, the hub
|
|
22
|
+
* can't even start until the beacon is up. The bridge lets
|
|
23
|
+
* the hub run with in-process persistence by default and
|
|
24
|
+
* upgrade to beacon-backed persistence whenever the beacon
|
|
25
|
+
* arrives.
|
|
26
|
+
*
|
|
27
|
+
* (b) Operator opt-in. The queue beacon is OPTIONAL — most lab
|
|
28
|
+
* deployments don't want a separate persistence process.
|
|
29
|
+
* Defaulting to in-process means a vanilla `ultravisor start`
|
|
30
|
+
* still works.
|
|
31
|
+
*
|
|
32
|
+
* All write methods return Promises and are fire-and-await — they
|
|
33
|
+
* resolve to {Success} so the caller can log a warning on failure
|
|
34
|
+
* but never block the dispatch path on persistence. The local
|
|
35
|
+
* fallback is synchronous (calls into UltravisorBeaconQueueStore
|
|
36
|
+
* directly); we wrap it in Promise.resolve to keep a uniform
|
|
37
|
+
* interface.
|
|
38
|
+
*
|
|
39
|
+
* Loop prevention: the coordinator gates every persistence call on
|
|
40
|
+
* `_isMetaCapability(work.Capability)`. QueuePersistence dispatches
|
|
41
|
+
* are themselves work items routed through the queue, but they
|
|
42
|
+
* skip the bridge so we don't end up writing the QP_RecordEvent
|
|
43
|
+
* event for the QP_RecordEvent event for the QP_RecordEvent event...
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const libPictService = require('pict-serviceproviderbase');
|
|
47
|
+
const libFs = require('fs');
|
|
48
|
+
const libPath = require('path');
|
|
49
|
+
|
|
50
|
+
// Conservative timeout — persistence calls should be quick. Long
|
|
51
|
+
// timeouts here block the coordinator's dispatch path and back up
|
|
52
|
+
// the queue. 5s is enough for SQLite/Postgres on a healthy network.
|
|
53
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
54
|
+
|
|
55
|
+
// Cap a single flush sweep so a beacon coming online doesn't get
|
|
56
|
+
// hammered by a years-long backlog all at once. Successive
|
|
57
|
+
// reconnects (or the next operator-triggered re-flush hook) can
|
|
58
|
+
// drain the rest. 5000 items at ~5ms each is ~25s of beacon time.
|
|
59
|
+
const FLUSH_BATCH_LIMIT = 5000;
|
|
60
|
+
|
|
61
|
+
// Filename for persisted high-water-marks. Stored under the
|
|
62
|
+
// ultravisor data path so it survives process restarts. Schema:
|
|
63
|
+
// { Queue: { <beaconID>: <ISO timestamp> }, Manifest: { ... } }.
|
|
64
|
+
// Both bridges share the same file so operators have one place
|
|
65
|
+
// to inspect / reset; each bridge owns its own top-level key.
|
|
66
|
+
const HWM_FILENAME = 'persistence-bridge-hwm.json';
|
|
67
|
+
|
|
68
|
+
// Filename for the persisted persistence-beacon assignment. Set by
|
|
69
|
+
// the lab via setPersistenceAssignment; survives UV restarts so a
|
|
70
|
+
// rebooted UV resumes routing through the same databeacon without
|
|
71
|
+
// the lab having to re-push. Schema:
|
|
72
|
+
// { Queue: { BeaconID, IDBeaconConnection, AssignedAt } | null,
|
|
73
|
+
// Manifest: { ... } | null }.
|
|
74
|
+
// Bridges share one file; each owns its top-level key.
|
|
75
|
+
const ASSIGNMENT_FILENAME = 'persistence-assignment.json';
|
|
76
|
+
|
|
77
|
+
// Beacon Tag the lab stamps on a databeacon when assigning it as a
|
|
78
|
+
// UV's persistence backend. Carries the IDBeaconConnection (in the
|
|
79
|
+
// databeacon's internal SQLite) of the live external connection that
|
|
80
|
+
// hosts the UV* tables. Bridges scan registered beacons for this tag
|
|
81
|
+
// to discover the MeadowProxy persistence path; absence falls back
|
|
82
|
+
// to legacy QueuePersistence dispatch (or local store).
|
|
83
|
+
const PERSISTENCE_TAG = 'PersistenceConnectionID';
|
|
84
|
+
|
|
85
|
+
// Schema descriptor for the UV tables. Loaded once at bridge
|
|
86
|
+
// construction so onBeaconConnected can fire EnsureSchema without
|
|
87
|
+
// re-reading from disk on every reconnect. Path is relative to the
|
|
88
|
+
// ultravisor module root.
|
|
89
|
+
const SCHEMA_DESCRIPTOR_PATH = libPath.join(__dirname, '..', 'persistence', 'UltravisorPersistenceSchema.json');
|
|
90
|
+
|
|
91
|
+
const QUEUE_TABLES = ['UVQueueWorkItem', 'UVQueueWorkItemEvent', 'UVQueueWorkItemAttempt'];
|
|
92
|
+
|
|
93
|
+
// Path patterns the lab pushes to the databeacon's MeadowProxy when
|
|
94
|
+
// assigning UV persistence — extends the default lowercase-only
|
|
95
|
+
// allowlist so the PascalCase /1.0/.../UV*/ paths can pass through.
|
|
96
|
+
// The leading `^/?1\.0/` match keeps it scoped to the meadow REST
|
|
97
|
+
// surface; the wildcard middle segment accepts the databeacon's
|
|
98
|
+
// connection-namespaced route hash.
|
|
99
|
+
const UV_PROXY_PATH_PATTERNS = ['^/?1\\.0/[^/]+/UV[A-Za-z0-9]*'];
|
|
100
|
+
|
|
101
|
+
class UltravisorQueuePersistenceBridge extends libPictService
|
|
102
|
+
{
|
|
103
|
+
constructor(pPict, pOptions, pServiceHash)
|
|
104
|
+
{
|
|
105
|
+
super(pPict, pOptions, pServiceHash);
|
|
106
|
+
this.serviceType = 'UltravisorQueuePersistenceBridge';
|
|
107
|
+
this._TimeoutMs = (pOptions && pOptions.TimeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
108
|
+
// Bootstrap-flush state: HWM per-beacon, plus a guard so a
|
|
109
|
+
// second connect notification doesn't race a flush already
|
|
110
|
+
// in flight. HWM survives ultravisor restarts; the guard
|
|
111
|
+
// is process-local.
|
|
112
|
+
this._FlushHWMs = this._loadHWMs();
|
|
113
|
+
this._FlushInFlight = new Set();
|
|
114
|
+
// MeadowProxy bootstrap state. Once a databeacon's schema +
|
|
115
|
+
// allowlist has been confirmed, the bridge stops re-running
|
|
116
|
+
// the bootstrap on subsequent reconnects of the same beacon
|
|
117
|
+
// — both EnsureSchema and UpdateProxyConfig are idempotent
|
|
118
|
+
// but the round-trip is wasted work. Per-beacon endpoint base
|
|
119
|
+
// strings (returned by EnableEndpoint, e.g. /1.0/<hash>/UVQueueWorkItem)
|
|
120
|
+
// are cached so dispatch doesn't have to re-discover the
|
|
121
|
+
// connection's route hash on each call.
|
|
122
|
+
this._BootstrappedBeacons = new Set();
|
|
123
|
+
this._BootstrapInFlight = new Set();
|
|
124
|
+
this._EndpointBaseByBeacon = {};
|
|
125
|
+
// Loaded once. If the file is missing or unreadable the
|
|
126
|
+
// bridge keeps working in legacy / local-fallback mode and
|
|
127
|
+
// just declines to bootstrap any MeadowProxy beacon.
|
|
128
|
+
this._SchemaDescriptor = this._loadSchemaDescriptor();
|
|
129
|
+
// Explicit lab assignment (Session 3). Tag-scan stays as the
|
|
130
|
+
// CLI-only fallback for sidecar deployments where the operator
|
|
131
|
+
// configures the databeacon via env vars + tags.
|
|
132
|
+
this._PersistenceAssignment = this._loadAssignment();
|
|
133
|
+
this._LastBootstrapError = null;
|
|
134
|
+
this._BootstrappedAt = null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_loadSchemaDescriptor()
|
|
138
|
+
{
|
|
139
|
+
try
|
|
140
|
+
{
|
|
141
|
+
let tmpRaw = libFs.readFileSync(SCHEMA_DESCRIPTOR_PATH, 'utf8');
|
|
142
|
+
return JSON.parse(tmpRaw);
|
|
143
|
+
}
|
|
144
|
+
catch (pErr)
|
|
145
|
+
{
|
|
146
|
+
if (this.log)
|
|
147
|
+
{
|
|
148
|
+
this.log.warn(`QueuePersistenceBridge: schema descriptor not loadable at ${SCHEMA_DESCRIPTOR_PATH} (${pErr.message}). MeadowProxy persistence path disabled.`);
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @returns {string|null} BeaconID of the connected QueuePersistence
|
|
156
|
+
* beacon, or null if none is registered.
|
|
157
|
+
*/
|
|
158
|
+
getBeaconID()
|
|
159
|
+
{
|
|
160
|
+
let tmpCoord = this._coord();
|
|
161
|
+
if (!tmpCoord) return null;
|
|
162
|
+
let tmpBeacons = tmpCoord.listBeacons() || [];
|
|
163
|
+
for (let i = 0; i < tmpBeacons.length; i++)
|
|
164
|
+
{
|
|
165
|
+
let tmpCaps = tmpBeacons[i].Capabilities || [];
|
|
166
|
+
if (tmpCaps.indexOf('QueuePersistence') >= 0)
|
|
167
|
+
{
|
|
168
|
+
return tmpBeacons[i].BeaconID;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @returns {boolean} true iff a QueuePersistence beacon is connected.
|
|
176
|
+
*/
|
|
177
|
+
isBeaconAvailable()
|
|
178
|
+
{
|
|
179
|
+
return this.getBeaconID() !== null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Locate a MeadowProxy persistence beacon. Two sources, explicit
|
|
184
|
+
* lab assignment wins:
|
|
185
|
+
*
|
|
186
|
+
* 1. `_PersistenceAssignment` — set by setPersistenceAssignment
|
|
187
|
+
* from the lab API. Returned as-is regardless of online state;
|
|
188
|
+
* the dispatch path gates on `isMeadowProxyMode()` (which
|
|
189
|
+
* requires a successful bootstrap), so a "set but offline"
|
|
190
|
+
* assignment doesn't accidentally route writes to nowhere.
|
|
191
|
+
* 2. Tag scan — for CLI-only deployments where an operator
|
|
192
|
+
* stamps a sidecar databeacon with `Tags.PersistenceConnectionID`
|
|
193
|
+
* via env vars. Online status filtered here because there's
|
|
194
|
+
* no other gate for tag-scan callers.
|
|
195
|
+
*
|
|
196
|
+
* @returns {object|null} { BeaconID, IDBeaconConnection } or null.
|
|
197
|
+
*/
|
|
198
|
+
getPersistenceBeacon()
|
|
199
|
+
{
|
|
200
|
+
if (!this._SchemaDescriptor) return null;
|
|
201
|
+
if (this._PersistenceAssignment && this._PersistenceAssignment.BeaconID)
|
|
202
|
+
{
|
|
203
|
+
return {
|
|
204
|
+
BeaconID: this._PersistenceAssignment.BeaconID,
|
|
205
|
+
IDBeaconConnection: this._PersistenceAssignment.IDBeaconConnection
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
let tmpCoord = this._coord();
|
|
209
|
+
if (!tmpCoord) return null;
|
|
210
|
+
let tmpBeacons = tmpCoord.listBeacons() || [];
|
|
211
|
+
for (let i = 0; i < tmpBeacons.length; i++)
|
|
212
|
+
{
|
|
213
|
+
let tmpBeacon = tmpBeacons[i];
|
|
214
|
+
if (tmpBeacon.Status !== 'Online' && tmpBeacon.Status !== 'Busy') continue;
|
|
215
|
+
let tmpCaps = tmpBeacon.Capabilities || [];
|
|
216
|
+
if (tmpCaps.indexOf('MeadowProxy') < 0) continue;
|
|
217
|
+
let tmpConnID = tmpBeacon.Tags && tmpBeacon.Tags[PERSISTENCE_TAG];
|
|
218
|
+
if (tmpConnID === undefined || tmpConnID === null || tmpConnID === '') continue;
|
|
219
|
+
return { BeaconID: tmpBeacon.BeaconID, IDBeaconConnection: tmpConnID };
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @returns {boolean} true iff a MeadowProxy beacon is registered AND
|
|
226
|
+
* its bootstrap (EnsureSchema + UpdateProxyConfig + EnableEndpoint)
|
|
227
|
+
* has completed successfully.
|
|
228
|
+
*/
|
|
229
|
+
isMeadowProxyMode()
|
|
230
|
+
{
|
|
231
|
+
let tmpAssigned = this.getPersistenceBeacon();
|
|
232
|
+
if (!tmpAssigned) return false;
|
|
233
|
+
return this._BootstrappedBeacons.has(tmpAssigned.BeaconID);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ============== Write API ==============
|
|
237
|
+
|
|
238
|
+
upsertWorkItem(pItem)
|
|
239
|
+
{
|
|
240
|
+
return this._writeOrLocal('QP_UpsertWorkItem', { WorkItem: pItem },
|
|
241
|
+
(pStore) => pStore.upsertWorkItem(pItem));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
updateWorkItem(pHash, pPatch)
|
|
245
|
+
{
|
|
246
|
+
return this._writeOrLocal('QP_UpdateWorkItem',
|
|
247
|
+
{ WorkItemHash: pHash, Patch: pPatch },
|
|
248
|
+
(pStore) => pStore.updateWorkItem(pHash, pPatch));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
appendEvent(pEvent)
|
|
252
|
+
{
|
|
253
|
+
return this._writeOrLocal('QP_AppendEvent', { Event: pEvent },
|
|
254
|
+
(pStore) => pStore.appendEvent(pEvent));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
insertAttempt(pAttempt)
|
|
258
|
+
{
|
|
259
|
+
return this._writeOrLocal('QP_InsertAttempt', { Attempt: pAttempt },
|
|
260
|
+
(pStore) => pStore.insertAttempt(pAttempt));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
updateAttemptOutcome(pHash, pAttemptNumber, pPatch)
|
|
264
|
+
{
|
|
265
|
+
return this._writeOrLocal('QP_UpdateAttemptOutcome',
|
|
266
|
+
{ WorkItemHash: pHash, AttemptNumber: pAttemptNumber, Patch: pPatch },
|
|
267
|
+
(pStore) => pStore.updateAttemptOutcome(pHash, pAttemptNumber, pPatch));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ============== Read API ==============
|
|
271
|
+
|
|
272
|
+
getWorkItemByHash(pHash)
|
|
273
|
+
{
|
|
274
|
+
return this._readOrLocal('QP_GetWorkItemByHash', { WorkItemHash: pHash },
|
|
275
|
+
(pStore) =>
|
|
276
|
+
{
|
|
277
|
+
let tmpItem = pStore.getWorkItemByHash(pHash);
|
|
278
|
+
return { Success: !!tmpItem, WorkItem: tmpItem };
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
listWorkItems(pFilter)
|
|
283
|
+
{
|
|
284
|
+
return this._readOrLocal('QP_ListWorkItems', { Filter: pFilter || {} },
|
|
285
|
+
(pStore) =>
|
|
286
|
+
{
|
|
287
|
+
let tmpList = pStore.listWorkItems(pFilter || {});
|
|
288
|
+
return { Success: true, WorkItems: tmpList || [] };
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
getEvents(pHash, pLimit)
|
|
293
|
+
{
|
|
294
|
+
return this._readOrLocal('QP_GetEvents', { WorkItemHash: pHash, Limit: pLimit || 0 },
|
|
295
|
+
(pStore) =>
|
|
296
|
+
{
|
|
297
|
+
let tmpList = pStore.listEventsForWorkItem(pHash, pLimit || 500);
|
|
298
|
+
return { Success: true, Events: tmpList || [] };
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ============== Bootstrap-flush ==============
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Called by the coordinator when ANY beacon registers (or
|
|
306
|
+
* re-registers after a reconnect). We filter by capability — the
|
|
307
|
+
* notification is fan-out, but only QueuePersistence beacons
|
|
308
|
+
* trigger our flush sweep.
|
|
309
|
+
*
|
|
310
|
+
* The sweep walks the local QueueStore for items newer than the
|
|
311
|
+
* per-beacon HWM and pushes them via QP_UpsertWorkItem. After
|
|
312
|
+
* each successful push the HWM advances; on any failure the
|
|
313
|
+
* sweep aborts and the HWM stays put so the next reconnect
|
|
314
|
+
* picks up from the same spot.
|
|
315
|
+
*/
|
|
316
|
+
onBeaconConnected(pBeaconID)
|
|
317
|
+
{
|
|
318
|
+
if (!pBeaconID) return;
|
|
319
|
+
// First — try the MeadowProxy bootstrap. The two paths are
|
|
320
|
+
// mutually exclusive at the per-beacon level (a single beacon
|
|
321
|
+
// either advertises QueuePersistence or MeadowProxy with the
|
|
322
|
+
// persistence tag, never both), so this can run unconditionally
|
|
323
|
+
// and the helper returns early when the assignment doesn't
|
|
324
|
+
// match.
|
|
325
|
+
this._handleMeadowProxyBootstrap(pBeaconID);
|
|
326
|
+
// Confirm this beacon actually carries QueuePersistence —
|
|
327
|
+
// otherwise we'd flush on every random beacon register.
|
|
328
|
+
let tmpQPID = this.getBeaconID();
|
|
329
|
+
if (tmpQPID !== pBeaconID) return;
|
|
330
|
+
// Already flushing? Don't stack.
|
|
331
|
+
if (this._FlushInFlight.has(pBeaconID)) return;
|
|
332
|
+
let tmpStore = this._localStore();
|
|
333
|
+
if (!tmpStore)
|
|
334
|
+
{
|
|
335
|
+
// No local store means nothing to flush. Common in
|
|
336
|
+
// stateless deployments where the beacon is the sole
|
|
337
|
+
// persistence layer; HWM tracking is a no-op there.
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
this._FlushInFlight.add(pBeaconID);
|
|
341
|
+
this._flushQueueToBeacon(pBeaconID, tmpStore)
|
|
342
|
+
.then((pCount) =>
|
|
343
|
+
{
|
|
344
|
+
this._FlushInFlight.delete(pBeaconID);
|
|
345
|
+
if (pCount > 0)
|
|
346
|
+
{
|
|
347
|
+
this.log.info(`QueuePersistenceBridge: bootstrap-flush pushed ${pCount} item(s) to beacon [${pBeaconID}].`);
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
.catch((pErr) =>
|
|
351
|
+
{
|
|
352
|
+
this._FlushInFlight.delete(pBeaconID);
|
|
353
|
+
this.log.warn(`QueuePersistenceBridge: bootstrap-flush failed: ${pErr && pErr.message}`);
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Walk the local store for items newer than the HWM and push
|
|
359
|
+
* them through the beacon. Returns a Promise resolving to the
|
|
360
|
+
* count of items successfully pushed.
|
|
361
|
+
*
|
|
362
|
+
* Sequential rather than parallel: keeps the beacon's write
|
|
363
|
+
* load predictable, makes "abort on first failure" semantics
|
|
364
|
+
* easy, and avoids HWM races between concurrent pushes.
|
|
365
|
+
*/
|
|
366
|
+
async _flushQueueToBeacon(pBeaconID, pStore)
|
|
367
|
+
{
|
|
368
|
+
let tmpHWM = this._FlushHWMs[pBeaconID] || '';
|
|
369
|
+
// Pull a batch in EnqueuedAt ASC order so HWM advances
|
|
370
|
+
// monotonically. Filter is best-effort — the local store
|
|
371
|
+
// doesn't expose a "since timestamp" filter directly, so
|
|
372
|
+
// we skip already-flushed items in JavaScript instead.
|
|
373
|
+
let tmpAll = pStore.listWorkItems({ Limit: FLUSH_BATCH_LIMIT, OrderBy: 'EnqueuedAt ASC' }) || [];
|
|
374
|
+
let tmpPushed = 0;
|
|
375
|
+
for (let i = 0; i < tmpAll.length; i++)
|
|
376
|
+
{
|
|
377
|
+
let tmpItem = tmpAll[i];
|
|
378
|
+
let tmpEnqueuedAt = tmpItem.EnqueuedAt || tmpItem.CreatedAt || '';
|
|
379
|
+
if (tmpHWM && tmpEnqueuedAt && tmpEnqueuedAt <= tmpHWM) continue;
|
|
380
|
+
// Push the work item itself. Idempotent on WorkItemHash
|
|
381
|
+
// (the beacon's QP_UpsertWorkItem uses upsert semantics).
|
|
382
|
+
let tmpUpsertResult = await this._dispatch('QP_UpsertWorkItem', { WorkItem: tmpItem });
|
|
383
|
+
if (!tmpUpsertResult || !tmpUpsertResult.Success)
|
|
384
|
+
{
|
|
385
|
+
// Beacon went away mid-flush, or the action failed.
|
|
386
|
+
// Stop here; HWM stays put; next reconnect retries.
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
// Push any locally-recorded events for this item too,
|
|
390
|
+
// so timeline reads on the beacon match what was
|
|
391
|
+
// captured locally during the outage.
|
|
392
|
+
if (typeof pStore.listEventsForWorkItem === 'function')
|
|
393
|
+
{
|
|
394
|
+
let tmpEvents = pStore.listEventsForWorkItem(tmpItem.WorkItemHash, 1000) || [];
|
|
395
|
+
let tmpEventOK = true;
|
|
396
|
+
for (let e = 0; e < tmpEvents.length; e++)
|
|
397
|
+
{
|
|
398
|
+
let tmpEventResult = await this._dispatch('QP_AppendEvent', { Event: tmpEvents[e] });
|
|
399
|
+
if (!tmpEventResult || !tmpEventResult.Success) { tmpEventOK = false; break; }
|
|
400
|
+
}
|
|
401
|
+
if (!tmpEventOK) break;
|
|
402
|
+
}
|
|
403
|
+
tmpHWM = tmpEnqueuedAt || tmpHWM;
|
|
404
|
+
this._FlushHWMs[pBeaconID] = tmpHWM;
|
|
405
|
+
this._saveHWMs();
|
|
406
|
+
tmpPushed += 1;
|
|
407
|
+
}
|
|
408
|
+
return tmpPushed;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Read the HWM file from disk, returning the queue subtree
|
|
413
|
+
* (or {} if absent / corrupt). Other bridges share the same
|
|
414
|
+
* file with their own top-level key.
|
|
415
|
+
*/
|
|
416
|
+
_loadHWMs()
|
|
417
|
+
{
|
|
418
|
+
try
|
|
419
|
+
{
|
|
420
|
+
let tmpPath = this._hwmPath();
|
|
421
|
+
if (!tmpPath || !libFs.existsSync(tmpPath)) return {};
|
|
422
|
+
let tmpRaw = libFs.readFileSync(tmpPath, 'utf8');
|
|
423
|
+
let tmpDoc = JSON.parse(tmpRaw);
|
|
424
|
+
return (tmpDoc && tmpDoc.Queue) || {};
|
|
425
|
+
}
|
|
426
|
+
catch (pErr) { return {}; }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Persist the HWM file. Read-modify-write so we don't clobber
|
|
431
|
+
* the manifest bridge's subtree; both bridges share the file.
|
|
432
|
+
*/
|
|
433
|
+
_saveHWMs()
|
|
434
|
+
{
|
|
435
|
+
try
|
|
436
|
+
{
|
|
437
|
+
let tmpPath = this._hwmPath();
|
|
438
|
+
if (!tmpPath) return;
|
|
439
|
+
let tmpDoc = {};
|
|
440
|
+
if (libFs.existsSync(tmpPath))
|
|
441
|
+
{
|
|
442
|
+
try { tmpDoc = JSON.parse(libFs.readFileSync(tmpPath, 'utf8')) || {}; }
|
|
443
|
+
catch (e) { tmpDoc = {}; }
|
|
444
|
+
}
|
|
445
|
+
tmpDoc.Queue = this._FlushHWMs;
|
|
446
|
+
libFs.writeFileSync(tmpPath, JSON.stringify(tmpDoc, null, '\t'), 'utf8');
|
|
447
|
+
}
|
|
448
|
+
catch (pErr)
|
|
449
|
+
{
|
|
450
|
+
// Best effort — losing the HWM only means the next
|
|
451
|
+
// flush is wider than necessary, not incorrect.
|
|
452
|
+
this.log.warn(`QueuePersistenceBridge: HWM save failed: ${pErr.message}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
_hwmPath()
|
|
457
|
+
{
|
|
458
|
+
let tmpDataPath = (this.fable && this.fable.settings && this.fable.settings.UltravisorFileStorePath)
|
|
459
|
+
|| (this.fable && this.fable.settings && this.fable.settings.DataPath)
|
|
460
|
+
|| null;
|
|
461
|
+
if (!tmpDataPath) return null;
|
|
462
|
+
return libPath.join(tmpDataPath, HWM_FILENAME);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ============== Persistence assignment (Session 3) ==============
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Apply an explicit lab assignment. Drops bootstrap state for any
|
|
469
|
+
* previously-assigned beacon, persists the new assignment to disk,
|
|
470
|
+
* and — if the new beacon is already registered + Online — kicks
|
|
471
|
+
* the bootstrap state machine immediately. Otherwise the next
|
|
472
|
+
* `onBeaconConnected` notification will trigger it.
|
|
473
|
+
*
|
|
474
|
+
* Pass `pBeaconID = null` (or call `clearPersistenceAssignment`)
|
|
475
|
+
* to drop the assignment entirely; the bridge falls back to legacy
|
|
476
|
+
* QueuePersistence dispatch (or local store) on subsequent calls.
|
|
477
|
+
*/
|
|
478
|
+
setPersistenceAssignment(pBeaconID, pIDBeaconConnection)
|
|
479
|
+
{
|
|
480
|
+
let tmpPrev = this._PersistenceAssignment;
|
|
481
|
+
if (tmpPrev && tmpPrev.BeaconID && tmpPrev.BeaconID !== pBeaconID)
|
|
482
|
+
{
|
|
483
|
+
this._BootstrappedBeacons.delete(tmpPrev.BeaconID);
|
|
484
|
+
delete this._EndpointBaseByBeacon[tmpPrev.BeaconID];
|
|
485
|
+
}
|
|
486
|
+
if (!pBeaconID)
|
|
487
|
+
{
|
|
488
|
+
this._PersistenceAssignment = null;
|
|
489
|
+
}
|
|
490
|
+
else
|
|
491
|
+
{
|
|
492
|
+
this._PersistenceAssignment =
|
|
493
|
+
{
|
|
494
|
+
BeaconID: pBeaconID,
|
|
495
|
+
IDBeaconConnection: pIDBeaconConnection || 0,
|
|
496
|
+
AssignedAt: new Date().toISOString()
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
this._LastBootstrapError = null;
|
|
500
|
+
this._BootstrappedAt = null;
|
|
501
|
+
this._saveAssignment();
|
|
502
|
+
if (!pBeaconID) return;
|
|
503
|
+
let tmpCoord = this._coord();
|
|
504
|
+
let tmpBeacon = tmpCoord && typeof tmpCoord.getBeacon === 'function' ? tmpCoord.getBeacon(pBeaconID) : null;
|
|
505
|
+
if (tmpBeacon && (tmpBeacon.Status === 'Online' || tmpBeacon.Status === 'Busy'))
|
|
506
|
+
{
|
|
507
|
+
setImmediate(() => this._handleMeadowProxyBootstrap(pBeaconID));
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
clearPersistenceAssignment()
|
|
512
|
+
{
|
|
513
|
+
this.setPersistenceAssignment(null, 0);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Snapshot of the bridge's persistence state for the lab status
|
|
518
|
+
* pill. Derived from _PersistenceAssignment + the bootstrap state
|
|
519
|
+
* sets, so it stays in sync without requiring callers to poke
|
|
520
|
+
* internals.
|
|
521
|
+
*/
|
|
522
|
+
getPersistenceStatus()
|
|
523
|
+
{
|
|
524
|
+
let tmpAssigned = this._PersistenceAssignment || null;
|
|
525
|
+
let tmpBeaconID = tmpAssigned && tmpAssigned.BeaconID || null;
|
|
526
|
+
let tmpState;
|
|
527
|
+
if (!tmpBeaconID)
|
|
528
|
+
{
|
|
529
|
+
tmpState = 'unassigned';
|
|
530
|
+
}
|
|
531
|
+
else if (this._LastBootstrapError)
|
|
532
|
+
{
|
|
533
|
+
tmpState = 'error';
|
|
534
|
+
}
|
|
535
|
+
else if (this._BootstrappedBeacons.has(tmpBeaconID))
|
|
536
|
+
{
|
|
537
|
+
tmpState = 'bootstrapped';
|
|
538
|
+
}
|
|
539
|
+
else if (this._BootstrapInFlight.has(tmpBeaconID))
|
|
540
|
+
{
|
|
541
|
+
tmpState = 'bootstrapping';
|
|
542
|
+
}
|
|
543
|
+
else
|
|
544
|
+
{
|
|
545
|
+
tmpState = 'waiting-for-beacon';
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
State: tmpState,
|
|
549
|
+
AssignedBeaconID: tmpBeaconID,
|
|
550
|
+
IDBeaconConnection: tmpAssigned ? (tmpAssigned.IDBeaconConnection || 0) : 0,
|
|
551
|
+
LastError: this._LastBootstrapError || null,
|
|
552
|
+
BootstrappedAt: this._BootstrappedAt || null,
|
|
553
|
+
AssignedAt: tmpAssigned ? (tmpAssigned.AssignedAt || null) : null
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
_loadAssignment()
|
|
558
|
+
{
|
|
559
|
+
try
|
|
560
|
+
{
|
|
561
|
+
let tmpPath = this._assignmentPath();
|
|
562
|
+
if (!tmpPath || !libFs.existsSync(tmpPath)) return null;
|
|
563
|
+
let tmpDoc = JSON.parse(libFs.readFileSync(tmpPath, 'utf8'));
|
|
564
|
+
let tmpEntry = tmpDoc && tmpDoc.Queue;
|
|
565
|
+
if (!tmpEntry || !tmpEntry.BeaconID) return null;
|
|
566
|
+
return tmpEntry;
|
|
567
|
+
}
|
|
568
|
+
catch (pErr) { return null; }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
_saveAssignment()
|
|
572
|
+
{
|
|
573
|
+
try
|
|
574
|
+
{
|
|
575
|
+
let tmpPath = this._assignmentPath();
|
|
576
|
+
if (!tmpPath) return;
|
|
577
|
+
let tmpDoc = {};
|
|
578
|
+
if (libFs.existsSync(tmpPath))
|
|
579
|
+
{
|
|
580
|
+
try { tmpDoc = JSON.parse(libFs.readFileSync(tmpPath, 'utf8')) || {}; }
|
|
581
|
+
catch (e) { tmpDoc = {}; }
|
|
582
|
+
}
|
|
583
|
+
tmpDoc.Queue = this._PersistenceAssignment || null;
|
|
584
|
+
libFs.writeFileSync(tmpPath, JSON.stringify(tmpDoc, null, '\t'), 'utf8');
|
|
585
|
+
}
|
|
586
|
+
catch (pErr)
|
|
587
|
+
{
|
|
588
|
+
if (this.log) this.log.warn(`QueuePersistenceBridge: assignment save failed: ${pErr.message}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
_assignmentPath()
|
|
593
|
+
{
|
|
594
|
+
let tmpDataPath = (this.fable && this.fable.settings && this.fable.settings.UltravisorFileStorePath)
|
|
595
|
+
|| (this.fable && this.fable.settings && this.fable.settings.DataPath)
|
|
596
|
+
|| null;
|
|
597
|
+
if (!tmpDataPath) return null;
|
|
598
|
+
return libPath.join(tmpDataPath, ASSIGNMENT_FILENAME);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ============== Internals ==============
|
|
602
|
+
|
|
603
|
+
_coord()
|
|
604
|
+
{
|
|
605
|
+
let tmpMap = this.fable && this.fable.servicesMap
|
|
606
|
+
&& this.fable.servicesMap.UltravisorBeaconCoordinator;
|
|
607
|
+
return tmpMap ? Object.values(tmpMap)[0] : null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
_localStore()
|
|
611
|
+
{
|
|
612
|
+
// Resolve lazily: the store can be added/replaced after the
|
|
613
|
+
// bridge is constructed, and not every deployment installs it.
|
|
614
|
+
let tmpMap = this.fable && this.fable.servicesMap
|
|
615
|
+
&& this.fable.servicesMap.UltravisorBeaconQueueStore;
|
|
616
|
+
let tmpStore = tmpMap ? Object.values(tmpMap)[0] : null;
|
|
617
|
+
if (!tmpStore || (typeof tmpStore.isEnabled === 'function' && !tmpStore.isEnabled()))
|
|
618
|
+
{
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
return tmpStore;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Write through the beacon when available; otherwise the local
|
|
626
|
+
* QueueStore. Both paths return {Success}. Beacon dispatch errors
|
|
627
|
+
* fall through to {Available:true, Success:false, Reason} so the
|
|
628
|
+
* caller can log without crashing the dispatch path.
|
|
629
|
+
*/
|
|
630
|
+
_writeOrLocal(pAction, pSettings, fLocal)
|
|
631
|
+
{
|
|
632
|
+
if (this.isMeadowProxyMode())
|
|
633
|
+
{
|
|
634
|
+
return this._dispatchViaMeadowProxy(pAction, pSettings);
|
|
635
|
+
}
|
|
636
|
+
if (this.isBeaconAvailable())
|
|
637
|
+
{
|
|
638
|
+
return this._dispatch(pAction, pSettings);
|
|
639
|
+
}
|
|
640
|
+
let tmpStore = this._localStore();
|
|
641
|
+
if (!tmpStore)
|
|
642
|
+
{
|
|
643
|
+
// Fail open: persistence is optional. The coordinator caller
|
|
644
|
+
// only logs a warning on failure; the work item itself
|
|
645
|
+
// still completes via the in-memory queue state.
|
|
646
|
+
return Promise.resolve({ Available: false, Success: false, Reason: 'No persistence backend (beacon or local) available' });
|
|
647
|
+
}
|
|
648
|
+
try
|
|
649
|
+
{
|
|
650
|
+
let tmpResult = fLocal(tmpStore);
|
|
651
|
+
// Local store methods return mixed shapes (sometimes the
|
|
652
|
+
// inserted row, sometimes void). Normalize to {Success}.
|
|
653
|
+
return Promise.resolve({ Available: true, Success: true, LocalResult: tmpResult });
|
|
654
|
+
}
|
|
655
|
+
catch (pErr)
|
|
656
|
+
{
|
|
657
|
+
return Promise.resolve(
|
|
658
|
+
{
|
|
659
|
+
Available: true, Success: false,
|
|
660
|
+
Reason: (pErr && pErr.message) || String(pErr)
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Read through the beacon when available; otherwise the local
|
|
667
|
+
* QueueStore. The read shape uses {Success, ...payload} from the
|
|
668
|
+
* beacon and synthesizes the same shape from the synchronous
|
|
669
|
+
* local result.
|
|
670
|
+
*/
|
|
671
|
+
_readOrLocal(pAction, pSettings, fLocal)
|
|
672
|
+
{
|
|
673
|
+
if (this.isMeadowProxyMode())
|
|
674
|
+
{
|
|
675
|
+
return this._dispatchViaMeadowProxy(pAction, pSettings);
|
|
676
|
+
}
|
|
677
|
+
if (this.isBeaconAvailable())
|
|
678
|
+
{
|
|
679
|
+
return this._dispatch(pAction, pSettings);
|
|
680
|
+
}
|
|
681
|
+
let tmpStore = this._localStore();
|
|
682
|
+
if (!tmpStore)
|
|
683
|
+
{
|
|
684
|
+
return Promise.resolve(
|
|
685
|
+
{
|
|
686
|
+
Available: false, Success: false,
|
|
687
|
+
Reason: 'No persistence backend available',
|
|
688
|
+
WorkItem: null, WorkItems: [], Events: []
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
try
|
|
692
|
+
{
|
|
693
|
+
let tmpResult = fLocal(tmpStore);
|
|
694
|
+
return Promise.resolve(Object.assign({ Available: true }, tmpResult));
|
|
695
|
+
}
|
|
696
|
+
catch (pErr)
|
|
697
|
+
{
|
|
698
|
+
return Promise.resolve(
|
|
699
|
+
{
|
|
700
|
+
Available: true, Success: false,
|
|
701
|
+
Reason: (pErr && pErr.message) || String(pErr)
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
_dispatch(pAction, pSettings)
|
|
707
|
+
{
|
|
708
|
+
return new Promise((fResolve) =>
|
|
709
|
+
{
|
|
710
|
+
let tmpCoord = this._coord();
|
|
711
|
+
if (!tmpCoord)
|
|
712
|
+
{
|
|
713
|
+
return fResolve(
|
|
714
|
+
{
|
|
715
|
+
Available: false, Success: false,
|
|
716
|
+
Reason: 'BeaconCoordinator not available'
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
tmpCoord.dispatchAndWait(
|
|
720
|
+
{
|
|
721
|
+
Capability: 'QueuePersistence',
|
|
722
|
+
Action: pAction,
|
|
723
|
+
Settings: pSettings || {},
|
|
724
|
+
AffinityKey: 'queue-persistence',
|
|
725
|
+
TimeoutMs: this._TimeoutMs
|
|
726
|
+
},
|
|
727
|
+
(pError, pResult) =>
|
|
728
|
+
{
|
|
729
|
+
if (pError)
|
|
730
|
+
{
|
|
731
|
+
return fResolve(
|
|
732
|
+
{
|
|
733
|
+
Available: true, Success: false,
|
|
734
|
+
Reason: pError.message || String(pError)
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
let tmpOut = (pResult && pResult.Outputs) || {};
|
|
738
|
+
return fResolve(Object.assign({ Available: true }, tmpOut));
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ============== MeadowProxy dispatch path ==============
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Translate a QP_* action onto the corresponding MeadowProxy
|
|
747
|
+
* REST request and dispatch it through the assigned databeacon.
|
|
748
|
+
*
|
|
749
|
+
* Translation table (see `docs/features/persistence-via-databeacon.md`):
|
|
750
|
+
*
|
|
751
|
+
* QP_UpsertWorkItem → POST /1.0/<hash>/UVQueueWorkItem
|
|
752
|
+
* (relies on `WorkItemHash` unique index for
|
|
753
|
+
* idempotency; PUT/PATCH-by-hash require an
|
|
754
|
+
* extra GET-by-hash → IDRecord round-trip
|
|
755
|
+
* that's deferred to Session 3 with the lab
|
|
756
|
+
* UI work).
|
|
757
|
+
* QP_UpdateWorkItem → POST as well — without a hash→ID lookup
|
|
758
|
+
* the bridge can't address the row by its
|
|
759
|
+
* meadow-side primary key. Documented as a
|
|
760
|
+
* known gap; queue's source-of-truth is the
|
|
761
|
+
* event log (QP_AppendEvent) which has full
|
|
762
|
+
* fidelity here.
|
|
763
|
+
* QP_AppendEvent → POST /1.0/<hash>/UVQueueWorkItemEvent
|
|
764
|
+
* QP_InsertAttempt → POST /1.0/<hash>/UVQueueWorkItemAttempt
|
|
765
|
+
* QP_UpdateAttemptOutcome→ same caveat as QP_UpdateWorkItem.
|
|
766
|
+
* QP_GetWorkItemByHash → GET /1.0/<hash>/UVQueueWorkItems/FilteredTo/FBV~WorkItemHash~EQ~<hash>
|
|
767
|
+
* QP_ListWorkItems → GET /1.0/<hash>/UVQueueWorkItems
|
|
768
|
+
* QP_GetEvents → GET /1.0/<hash>/UVQueueWorkItemEvents/FilteredTo/FBV~WorkItemHash~EQ~<hash>
|
|
769
|
+
*
|
|
770
|
+
* Returns the same `{Available, Success, ...}` shape as `_dispatch`
|
|
771
|
+
* so the existing call sites don't care which backend fired.
|
|
772
|
+
*/
|
|
773
|
+
_dispatchViaMeadowProxy(pAction, pSettings)
|
|
774
|
+
{
|
|
775
|
+
let tmpAssigned = this.getPersistenceBeacon();
|
|
776
|
+
if (!tmpAssigned)
|
|
777
|
+
{
|
|
778
|
+
return Promise.resolve(
|
|
779
|
+
{
|
|
780
|
+
Available: false, Success: false,
|
|
781
|
+
Reason: 'No MeadowProxy persistence beacon assigned'
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
// Two-step actions: meadow's PUT/DELETE addresses rows by PK,
|
|
785
|
+
// not by our natural keys, so we issue a filtered GET first to
|
|
786
|
+
// discover the IDRecord, then mutate.
|
|
787
|
+
if (pAction === 'QP_UpdateWorkItem')
|
|
788
|
+
{
|
|
789
|
+
return this._dispatchUpdateByHash(tmpAssigned.BeaconID,
|
|
790
|
+
'UVQueueWorkItem', 'IDUVQueueWorkItem',
|
|
791
|
+
'WorkItemHash', pSettings && pSettings.WorkItemHash,
|
|
792
|
+
pSettings && pSettings.Patch);
|
|
793
|
+
}
|
|
794
|
+
if (pAction === 'QP_UpdateAttemptOutcome')
|
|
795
|
+
{
|
|
796
|
+
return this._dispatchUpdateByTwoColumns(tmpAssigned.BeaconID,
|
|
797
|
+
'UVQueueWorkItemAttempt', 'IDUVQueueWorkItemAttempt',
|
|
798
|
+
'WorkItemHash', pSettings && pSettings.WorkItemHash,
|
|
799
|
+
'AttemptNumber', pSettings && pSettings.AttemptNumber,
|
|
800
|
+
pSettings && pSettings.Patch);
|
|
801
|
+
}
|
|
802
|
+
return new Promise((fResolve) =>
|
|
803
|
+
{
|
|
804
|
+
let tmpReq;
|
|
805
|
+
try
|
|
806
|
+
{
|
|
807
|
+
tmpReq = this._buildMeadowProxyRequest(pAction, pSettings, tmpAssigned.BeaconID);
|
|
808
|
+
}
|
|
809
|
+
catch (pErr)
|
|
810
|
+
{
|
|
811
|
+
return fResolve(
|
|
812
|
+
{
|
|
813
|
+
Available: true, Success: false,
|
|
814
|
+
Reason: pErr.message || String(pErr)
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
if (!tmpReq)
|
|
818
|
+
{
|
|
819
|
+
return fResolve(
|
|
820
|
+
{
|
|
821
|
+
Available: true, Success: false,
|
|
822
|
+
Reason: `Action [${pAction}] is not yet wired through MeadowProxy`
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
let tmpCoord = this._coord();
|
|
827
|
+
if (!tmpCoord)
|
|
828
|
+
{
|
|
829
|
+
return fResolve(
|
|
830
|
+
{
|
|
831
|
+
Available: false, Success: false,
|
|
832
|
+
Reason: 'BeaconCoordinator not available'
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
tmpCoord.dispatchAndWait(
|
|
836
|
+
{
|
|
837
|
+
Capability: 'MeadowProxy',
|
|
838
|
+
Action: 'Request',
|
|
839
|
+
Settings: tmpReq,
|
|
840
|
+
AffinityKey: 'queue-persistence',
|
|
841
|
+
TimeoutMs: this._TimeoutMs
|
|
842
|
+
},
|
|
843
|
+
(pError, pResult) =>
|
|
844
|
+
{
|
|
845
|
+
if (pError)
|
|
846
|
+
{
|
|
847
|
+
return fResolve(
|
|
848
|
+
{
|
|
849
|
+
Available: true, Success: false,
|
|
850
|
+
Reason: pError.message || String(pError)
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
let tmpOut = (pResult && pResult.Outputs) || {};
|
|
854
|
+
let tmpStatus = tmpOut.Status || 0;
|
|
855
|
+
let tmpBody = tmpOut.Body;
|
|
856
|
+
let tmpSuccess = (tmpStatus >= 200 && tmpStatus < 300);
|
|
857
|
+
return fResolve(this._normalizeMeadowProxyResult(pAction, tmpStatus, tmpBody, tmpSuccess));
|
|
858
|
+
});
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
_buildMeadowProxyRequest(pAction, pSettings, pBeaconID)
|
|
863
|
+
{
|
|
864
|
+
let tmpBase = this._endpointBase(pBeaconID, 'UVQueueWorkItem');
|
|
865
|
+
let tmpEventsBase = this._endpointBase(pBeaconID, 'UVQueueWorkItemEvent');
|
|
866
|
+
let tmpAttemptsBase = this._endpointBase(pBeaconID, 'UVQueueWorkItemAttempt');
|
|
867
|
+
let tmpUser = this._resolveRemoteUser();
|
|
868
|
+
// Settings carry the bridge-level args; we re-shape them into
|
|
869
|
+
// REST { Method, Path, Body, RemoteUser }. Each branch returns
|
|
870
|
+
// null for actions intentionally unmapped here — the two-step
|
|
871
|
+
// update / remove paths are handled by _dispatchUpdateByHash /
|
|
872
|
+
// _dispatchUpdateByTwoColumns from _dispatchViaMeadowProxy.
|
|
873
|
+
switch (pAction)
|
|
874
|
+
{
|
|
875
|
+
case 'QP_UpsertWorkItem':
|
|
876
|
+
{
|
|
877
|
+
let tmpItem = pSettings && pSettings.WorkItem;
|
|
878
|
+
if (!tmpItem) { throw new Error('QP_UpsertWorkItem: WorkItem is required.'); }
|
|
879
|
+
return { Method: 'POST', Path: tmpBase, Body: JSON.stringify(tmpItem), RemoteUser: tmpUser };
|
|
880
|
+
}
|
|
881
|
+
case 'QP_AppendEvent':
|
|
882
|
+
{
|
|
883
|
+
let tmpEvent = pSettings && pSettings.Event;
|
|
884
|
+
if (!tmpEvent) { throw new Error('QP_AppendEvent: Event is required.'); }
|
|
885
|
+
return { Method: 'POST', Path: tmpEventsBase, Body: JSON.stringify(tmpEvent), RemoteUser: tmpUser };
|
|
886
|
+
}
|
|
887
|
+
case 'QP_InsertAttempt':
|
|
888
|
+
{
|
|
889
|
+
let tmpAttempt = pSettings && pSettings.Attempt;
|
|
890
|
+
if (!tmpAttempt) { throw new Error('QP_InsertAttempt: Attempt is required.'); }
|
|
891
|
+
return { Method: 'POST', Path: tmpAttemptsBase, Body: JSON.stringify(tmpAttempt), RemoteUser: tmpUser };
|
|
892
|
+
}
|
|
893
|
+
case 'QP_GetWorkItemByHash':
|
|
894
|
+
{
|
|
895
|
+
let tmpHash = pSettings && pSettings.WorkItemHash;
|
|
896
|
+
if (!tmpHash) { throw new Error('QP_GetWorkItemByHash: WorkItemHash is required.'); }
|
|
897
|
+
return { Method: 'GET', Path: `${tmpBase}s/FilteredTo/FBV~WorkItemHash~EQ~${encodeURIComponent(tmpHash)}`, RemoteUser: tmpUser };
|
|
898
|
+
}
|
|
899
|
+
case 'QP_ListWorkItems':
|
|
900
|
+
{
|
|
901
|
+
return { Method: 'GET', Path: `${tmpBase}s`, RemoteUser: tmpUser };
|
|
902
|
+
}
|
|
903
|
+
case 'QP_GetEvents':
|
|
904
|
+
{
|
|
905
|
+
let tmpHash = pSettings && pSettings.WorkItemHash;
|
|
906
|
+
if (!tmpHash) { throw new Error('QP_GetEvents: WorkItemHash is required.'); }
|
|
907
|
+
return { Method: 'GET', Path: `${tmpEventsBase}s/FilteredTo/FBV~WorkItemHash~EQ~${encodeURIComponent(tmpHash)}`, RemoteUser: tmpUser };
|
|
908
|
+
}
|
|
909
|
+
default:
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
_normalizeMeadowProxyResult(pAction, pStatus, pBody, pSuccess)
|
|
915
|
+
{
|
|
916
|
+
// Meadow REST returns:
|
|
917
|
+
// - GET-by-id → single object or 404 (we don't hit this path).
|
|
918
|
+
// - GET-list → array.
|
|
919
|
+
// - POST → the inserted/upserted record (object).
|
|
920
|
+
// - PUT/DELETE → the affected record (object).
|
|
921
|
+
// Map back into the bridge's QP_* result shapes so callers don't
|
|
922
|
+
// have to special-case the backend.
|
|
923
|
+
let tmpParsed = null;
|
|
924
|
+
if (typeof pBody === 'string' && pBody.length > 0)
|
|
925
|
+
{
|
|
926
|
+
try { tmpParsed = JSON.parse(pBody); }
|
|
927
|
+
catch (pErr) { tmpParsed = null; }
|
|
928
|
+
}
|
|
929
|
+
else if (typeof pBody === 'object')
|
|
930
|
+
{
|
|
931
|
+
tmpParsed = pBody;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
switch (pAction)
|
|
935
|
+
{
|
|
936
|
+
case 'QP_GetWorkItemByHash':
|
|
937
|
+
{
|
|
938
|
+
let tmpItem = Array.isArray(tmpParsed) ? (tmpParsed[0] || null) : null;
|
|
939
|
+
return { Available: true, Success: !!tmpItem, WorkItem: tmpItem };
|
|
940
|
+
}
|
|
941
|
+
case 'QP_ListWorkItems':
|
|
942
|
+
return { Available: true, Success: pSuccess, WorkItems: Array.isArray(tmpParsed) ? tmpParsed : [] };
|
|
943
|
+
case 'QP_GetEvents':
|
|
944
|
+
return { Available: true, Success: pSuccess, Events: Array.isArray(tmpParsed) ? tmpParsed : [] };
|
|
945
|
+
default:
|
|
946
|
+
if (pSuccess)
|
|
947
|
+
{
|
|
948
|
+
return { Available: true, Success: true, Body: tmpParsed };
|
|
949
|
+
}
|
|
950
|
+
return {
|
|
951
|
+
Available: true, Success: false,
|
|
952
|
+
Status: pStatus,
|
|
953
|
+
Reason: (tmpParsed && (tmpParsed.error || tmpParsed.message)) || `MeadowProxy ${pStatus}`
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
_endpointBase(pBeaconID, pTableName)
|
|
959
|
+
{
|
|
960
|
+
let tmpCache = this._EndpointBaseByBeacon[pBeaconID] || {};
|
|
961
|
+
if (tmpCache[pTableName]) { return tmpCache[pTableName]; }
|
|
962
|
+
// Fallback when EnableEndpoint hasn't reported back yet — meadow
|
|
963
|
+
// REST routes without a route hash live at /1.0/<TableName>. The
|
|
964
|
+
// databeacon's actual prefix has the connection's sanitized name
|
|
965
|
+
// so this is a best-effort guess; the smoke test uses the cached
|
|
966
|
+
// path populated during bootstrap.
|
|
967
|
+
return `/1.0/${pTableName}`;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Pass-through value for MeadowProxy.Request's `RemoteUser` field.
|
|
972
|
+
* Today returns the synthetic `'ultravisor-system'` so the audit
|
|
973
|
+
* trail can distinguish UV-driven writes from manual mesh activity.
|
|
974
|
+
* Future: thread the originating session user through from
|
|
975
|
+
* /Ultravisor/Persistence/* and the dispatch path.
|
|
976
|
+
*/
|
|
977
|
+
_resolveRemoteUser()
|
|
978
|
+
{
|
|
979
|
+
return 'ultravisor-system';
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Issue a filtered GET against a UV* table, return the IDRecord
|
|
984
|
+
* from the first matching row. Resolves to null on miss (no error)
|
|
985
|
+
* so callers can choose to no-op or report NotFound.
|
|
986
|
+
*
|
|
987
|
+
* @param {string} pBeaconID
|
|
988
|
+
* @param {string} pTable — schema scope, e.g. 'UVQueueWorkItem'.
|
|
989
|
+
* @param {string} pIDColumn — PK column, e.g. 'IDUVQueueWorkItem'.
|
|
990
|
+
* @param {string} pHashColumn — natural-key column to filter on.
|
|
991
|
+
* @param {string} pHashValue — filter value.
|
|
992
|
+
*/
|
|
993
|
+
_lookupIDByHash(pBeaconID, pTable, pIDColumn, pHashColumn, pHashValue)
|
|
994
|
+
{
|
|
995
|
+
return new Promise((fResolve) =>
|
|
996
|
+
{
|
|
997
|
+
let tmpCoord = this._coord();
|
|
998
|
+
if (!tmpCoord)
|
|
999
|
+
{
|
|
1000
|
+
return fResolve({ Success: false, Reason: 'BeaconCoordinator not available' });
|
|
1001
|
+
}
|
|
1002
|
+
if (!pHashValue)
|
|
1003
|
+
{
|
|
1004
|
+
return fResolve({ Success: false, Reason: `${pHashColumn} is required for lookup` });
|
|
1005
|
+
}
|
|
1006
|
+
let tmpBase = this._endpointBase(pBeaconID, pTable);
|
|
1007
|
+
let tmpReq =
|
|
1008
|
+
{
|
|
1009
|
+
Method: 'GET',
|
|
1010
|
+
Path: `${tmpBase}s/FilteredTo/FBV~${pHashColumn}~EQ~${encodeURIComponent(pHashValue)}`,
|
|
1011
|
+
RemoteUser: this._resolveRemoteUser()
|
|
1012
|
+
};
|
|
1013
|
+
tmpCoord.dispatchAndWait(
|
|
1014
|
+
{
|
|
1015
|
+
Capability: 'MeadowProxy',
|
|
1016
|
+
Action: 'Request',
|
|
1017
|
+
Settings: tmpReq,
|
|
1018
|
+
AffinityKey: 'queue-persistence',
|
|
1019
|
+
TimeoutMs: this._TimeoutMs
|
|
1020
|
+
},
|
|
1021
|
+
(pError, pResult) =>
|
|
1022
|
+
{
|
|
1023
|
+
if (pError) return fResolve({ Success: false, Reason: pError.message || String(pError) });
|
|
1024
|
+
let tmpOut = (pResult && pResult.Outputs) || {};
|
|
1025
|
+
let tmpStatus = tmpOut.Status || 0;
|
|
1026
|
+
if (tmpStatus < 200 || tmpStatus >= 300)
|
|
1027
|
+
{
|
|
1028
|
+
return fResolve({ Success: false, Reason: `Lookup failed: HTTP ${tmpStatus}` });
|
|
1029
|
+
}
|
|
1030
|
+
let tmpParsed = null;
|
|
1031
|
+
if (typeof tmpOut.Body === 'string' && tmpOut.Body.length > 0)
|
|
1032
|
+
{
|
|
1033
|
+
try { tmpParsed = JSON.parse(tmpOut.Body); }
|
|
1034
|
+
catch (e) { tmpParsed = null; }
|
|
1035
|
+
}
|
|
1036
|
+
else if (typeof tmpOut.Body === 'object')
|
|
1037
|
+
{
|
|
1038
|
+
tmpParsed = tmpOut.Body;
|
|
1039
|
+
}
|
|
1040
|
+
let tmpRow = Array.isArray(tmpParsed) ? (tmpParsed[0] || null) : null;
|
|
1041
|
+
if (!tmpRow)
|
|
1042
|
+
{
|
|
1043
|
+
return fResolve({ Success: true, Found: false, IDRecord: null });
|
|
1044
|
+
}
|
|
1045
|
+
return fResolve({ Success: true, Found: true, IDRecord: tmpRow[pIDColumn], Row: tmpRow });
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Two-column variant of _lookupIDByHash. Stacks filters via
|
|
1052
|
+
* meadow's `~FBV~` glue: `FBV~Col1~EQ~v1~FBV~Col2~EQ~v2`.
|
|
1053
|
+
*/
|
|
1054
|
+
_lookupIDByTwoColumns(pBeaconID, pTable, pIDColumn, pCol1, pVal1, pCol2, pVal2)
|
|
1055
|
+
{
|
|
1056
|
+
return new Promise((fResolve) =>
|
|
1057
|
+
{
|
|
1058
|
+
let tmpCoord = this._coord();
|
|
1059
|
+
if (!tmpCoord)
|
|
1060
|
+
{
|
|
1061
|
+
return fResolve({ Success: false, Reason: 'BeaconCoordinator not available' });
|
|
1062
|
+
}
|
|
1063
|
+
if (pVal1 === undefined || pVal1 === null || pVal2 === undefined || pVal2 === null)
|
|
1064
|
+
{
|
|
1065
|
+
return fResolve({ Success: false, Reason: 'Both filter values required' });
|
|
1066
|
+
}
|
|
1067
|
+
let tmpBase = this._endpointBase(pBeaconID, pTable);
|
|
1068
|
+
let tmpFilter = `FBV~${pCol1}~EQ~${encodeURIComponent(pVal1)}~FBV~${pCol2}~EQ~${encodeURIComponent(pVal2)}`;
|
|
1069
|
+
let tmpReq =
|
|
1070
|
+
{
|
|
1071
|
+
Method: 'GET',
|
|
1072
|
+
Path: `${tmpBase}s/FilteredTo/${tmpFilter}`,
|
|
1073
|
+
RemoteUser: this._resolveRemoteUser()
|
|
1074
|
+
};
|
|
1075
|
+
tmpCoord.dispatchAndWait(
|
|
1076
|
+
{
|
|
1077
|
+
Capability: 'MeadowProxy',
|
|
1078
|
+
Action: 'Request',
|
|
1079
|
+
Settings: tmpReq,
|
|
1080
|
+
AffinityKey: 'queue-persistence',
|
|
1081
|
+
TimeoutMs: this._TimeoutMs
|
|
1082
|
+
},
|
|
1083
|
+
(pError, pResult) =>
|
|
1084
|
+
{
|
|
1085
|
+
if (pError) return fResolve({ Success: false, Reason: pError.message || String(pError) });
|
|
1086
|
+
let tmpOut = (pResult && pResult.Outputs) || {};
|
|
1087
|
+
let tmpStatus = tmpOut.Status || 0;
|
|
1088
|
+
if (tmpStatus < 200 || tmpStatus >= 300)
|
|
1089
|
+
{
|
|
1090
|
+
return fResolve({ Success: false, Reason: `Lookup failed: HTTP ${tmpStatus}` });
|
|
1091
|
+
}
|
|
1092
|
+
let tmpParsed = null;
|
|
1093
|
+
if (typeof tmpOut.Body === 'string' && tmpOut.Body.length > 0)
|
|
1094
|
+
{
|
|
1095
|
+
try { tmpParsed = JSON.parse(tmpOut.Body); }
|
|
1096
|
+
catch (e) { tmpParsed = null; }
|
|
1097
|
+
}
|
|
1098
|
+
else if (typeof tmpOut.Body === 'object')
|
|
1099
|
+
{
|
|
1100
|
+
tmpParsed = tmpOut.Body;
|
|
1101
|
+
}
|
|
1102
|
+
let tmpRow = Array.isArray(tmpParsed) ? (tmpParsed[0] || null) : null;
|
|
1103
|
+
if (!tmpRow)
|
|
1104
|
+
{
|
|
1105
|
+
return fResolve({ Success: true, Found: false, IDRecord: null });
|
|
1106
|
+
}
|
|
1107
|
+
return fResolve({ Success: true, Found: true, IDRecord: tmpRow[pIDColumn], Row: tmpRow });
|
|
1108
|
+
});
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Two-step update: filtered GET to discover IDRecord, then PUT
|
|
1114
|
+
* with the patch (PK included so meadow's update-by-id accepts it).
|
|
1115
|
+
* Resolves to {Available, Success, Reason?} matching the
|
|
1116
|
+
* single-step QP_* result shape.
|
|
1117
|
+
*/
|
|
1118
|
+
async _dispatchUpdateByHash(pBeaconID, pTable, pIDColumn, pHashColumn, pHashValue, pPatch)
|
|
1119
|
+
{
|
|
1120
|
+
let tmpLookup = await this._lookupIDByHash(pBeaconID, pTable, pIDColumn, pHashColumn, pHashValue);
|
|
1121
|
+
if (!tmpLookup.Success)
|
|
1122
|
+
{
|
|
1123
|
+
return { Available: true, Success: false, Reason: tmpLookup.Reason };
|
|
1124
|
+
}
|
|
1125
|
+
if (!tmpLookup.Found)
|
|
1126
|
+
{
|
|
1127
|
+
return { Available: true, Success: false, Reason: `${pTable} row with ${pHashColumn}=${pHashValue} not found` };
|
|
1128
|
+
}
|
|
1129
|
+
let tmpBody = Object.assign({}, pPatch || {});
|
|
1130
|
+
tmpBody[pIDColumn] = tmpLookup.IDRecord;
|
|
1131
|
+
let tmpBase = this._endpointBase(pBeaconID, pTable);
|
|
1132
|
+
return this._putByID(pBeaconID, tmpBase, tmpLookup.IDRecord, tmpBody, 'queue-persistence');
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
async _dispatchUpdateByTwoColumns(pBeaconID, pTable, pIDColumn, pCol1, pVal1, pCol2, pVal2, pPatch)
|
|
1136
|
+
{
|
|
1137
|
+
let tmpLookup = await this._lookupIDByTwoColumns(pBeaconID, pTable, pIDColumn, pCol1, pVal1, pCol2, pVal2);
|
|
1138
|
+
if (!tmpLookup.Success)
|
|
1139
|
+
{
|
|
1140
|
+
return { Available: true, Success: false, Reason: tmpLookup.Reason };
|
|
1141
|
+
}
|
|
1142
|
+
if (!tmpLookup.Found)
|
|
1143
|
+
{
|
|
1144
|
+
return { Available: true, Success: false, Reason: `${pTable} row with ${pCol1}=${pVal1}, ${pCol2}=${pVal2} not found` };
|
|
1145
|
+
}
|
|
1146
|
+
let tmpBody = Object.assign({}, pPatch || {});
|
|
1147
|
+
tmpBody[pIDColumn] = tmpLookup.IDRecord;
|
|
1148
|
+
let tmpBase = this._endpointBase(pBeaconID, pTable);
|
|
1149
|
+
return this._putByID(pBeaconID, tmpBase, tmpLookup.IDRecord, tmpBody, 'queue-persistence');
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
_putByID(pBeaconID, pEndpointBase, pIDRecord, pBody, pAffinityKey)
|
|
1153
|
+
{
|
|
1154
|
+
return new Promise((fResolve) =>
|
|
1155
|
+
{
|
|
1156
|
+
let tmpCoord = this._coord();
|
|
1157
|
+
if (!tmpCoord)
|
|
1158
|
+
{
|
|
1159
|
+
return fResolve({ Available: false, Success: false, Reason: 'BeaconCoordinator not available' });
|
|
1160
|
+
}
|
|
1161
|
+
// Meadow's Update endpoint is `PUT <base>` (no /:IDRecord) —
|
|
1162
|
+
// the PK rides in the body. See meadow-endpoints route table:
|
|
1163
|
+
// `putWithBodyParser` only registers '' and 's', not '/:IDRecord'.
|
|
1164
|
+
let tmpReq =
|
|
1165
|
+
{
|
|
1166
|
+
Method: 'PUT',
|
|
1167
|
+
Path: pEndpointBase,
|
|
1168
|
+
Body: JSON.stringify(pBody),
|
|
1169
|
+
RemoteUser: this._resolveRemoteUser()
|
|
1170
|
+
};
|
|
1171
|
+
tmpCoord.dispatchAndWait(
|
|
1172
|
+
{
|
|
1173
|
+
Capability: 'MeadowProxy',
|
|
1174
|
+
Action: 'Request',
|
|
1175
|
+
Settings: tmpReq,
|
|
1176
|
+
AffinityKey: pAffinityKey,
|
|
1177
|
+
TimeoutMs: this._TimeoutMs
|
|
1178
|
+
},
|
|
1179
|
+
(pError, pResult) =>
|
|
1180
|
+
{
|
|
1181
|
+
if (pError)
|
|
1182
|
+
{
|
|
1183
|
+
return fResolve({ Available: true, Success: false, Reason: pError.message || String(pError) });
|
|
1184
|
+
}
|
|
1185
|
+
let tmpOut = (pResult && pResult.Outputs) || {};
|
|
1186
|
+
let tmpStatus = tmpOut.Status || 0;
|
|
1187
|
+
let tmpSuccess = (tmpStatus >= 200 && tmpStatus < 300);
|
|
1188
|
+
if (!tmpSuccess)
|
|
1189
|
+
{
|
|
1190
|
+
return fResolve({ Available: true, Success: false, Status: tmpStatus, Reason: `PUT returned ${tmpStatus}` });
|
|
1191
|
+
}
|
|
1192
|
+
return fResolve({ Available: true, Success: true });
|
|
1193
|
+
});
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// ============== Schema bootstrap state machine ==============
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Fired by the coordinator on every (re)connect. For QueuePersistence
|
|
1201
|
+
* beacons this drives bootstrap-flush as before. For MeadowProxy
|
|
1202
|
+
* persistence beacons it kicks off the EnsureSchema → UpdateProxyConfig
|
|
1203
|
+
* → EnableEndpoint sequence so the bridge can start dispatching
|
|
1204
|
+
* through MeadowProxy. Both flows are idempotent.
|
|
1205
|
+
*/
|
|
1206
|
+
_handleMeadowProxyBootstrap(pBeaconID)
|
|
1207
|
+
{
|
|
1208
|
+
if (!pBeaconID) return;
|
|
1209
|
+
if (!this._SchemaDescriptor) return;
|
|
1210
|
+
if (this._BootstrappedBeacons.has(pBeaconID)) return;
|
|
1211
|
+
if (this._BootstrapInFlight.has(pBeaconID)) return;
|
|
1212
|
+
let tmpAssigned = this.getPersistenceBeacon();
|
|
1213
|
+
if (!tmpAssigned || tmpAssigned.BeaconID !== pBeaconID) return;
|
|
1214
|
+
this._BootstrapInFlight.add(pBeaconID);
|
|
1215
|
+
this._runBootstrap(pBeaconID, tmpAssigned.IDBeaconConnection)
|
|
1216
|
+
.then((pResult) =>
|
|
1217
|
+
{
|
|
1218
|
+
this._BootstrapInFlight.delete(pBeaconID);
|
|
1219
|
+
if (pResult && pResult.Success)
|
|
1220
|
+
{
|
|
1221
|
+
this._BootstrappedBeacons.add(pBeaconID);
|
|
1222
|
+
this._BootstrappedAt = new Date().toISOString();
|
|
1223
|
+
this._LastBootstrapError = null;
|
|
1224
|
+
this.log.info(`QueuePersistenceBridge: MeadowProxy bootstrap complete for beacon [${pBeaconID}].`);
|
|
1225
|
+
}
|
|
1226
|
+
else
|
|
1227
|
+
{
|
|
1228
|
+
this._LastBootstrapError = (pResult && pResult.Reason) || 'Unknown bootstrap error';
|
|
1229
|
+
this.log.warn(`QueuePersistenceBridge: MeadowProxy bootstrap failed for beacon [${pBeaconID}]: ${this._LastBootstrapError}`);
|
|
1230
|
+
}
|
|
1231
|
+
})
|
|
1232
|
+
.catch((pErr) =>
|
|
1233
|
+
{
|
|
1234
|
+
this._BootstrapInFlight.delete(pBeaconID);
|
|
1235
|
+
this._LastBootstrapError = (pErr && pErr.message) || String(pErr);
|
|
1236
|
+
this.log.warn(`QueuePersistenceBridge: MeadowProxy bootstrap threw for beacon [${pBeaconID}]: ${this._LastBootstrapError}`);
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
async _runBootstrap(pBeaconID, pIDBeaconConnection)
|
|
1241
|
+
{
|
|
1242
|
+
let tmpCoord = this._coord();
|
|
1243
|
+
if (!tmpCoord) return { Success: false, Reason: 'BeaconCoordinator not available' };
|
|
1244
|
+
|
|
1245
|
+
// Step 1: EnsureSchema. Idempotent; second call is a no-op.
|
|
1246
|
+
let tmpEnsure = await this._mestDispatch(tmpCoord, 'DataBeaconSchema', 'EnsureSchema',
|
|
1247
|
+
{
|
|
1248
|
+
IDBeaconConnection: pIDBeaconConnection,
|
|
1249
|
+
SchemaName: this._SchemaDescriptor.SchemaName || 'ultravisor',
|
|
1250
|
+
SchemaJSON: this._SchemaDescriptor
|
|
1251
|
+
});
|
|
1252
|
+
if (!tmpEnsure.Success)
|
|
1253
|
+
{
|
|
1254
|
+
return { Success: false, Reason: `EnsureSchema failed: ${tmpEnsure.Reason}` };
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Step 2: Introspect. Populates the databeacon's IntrospectedTable
|
|
1258
|
+
// cache for the newly-created UV* tables. Without this,
|
|
1259
|
+
// EnableEndpoint can't find the cached column metadata it needs
|
|
1260
|
+
// to wire up meadow REST routes.
|
|
1261
|
+
let tmpIntrospect = await this._mestDispatch(tmpCoord, 'DataBeaconManagement', 'Introspect',
|
|
1262
|
+
{
|
|
1263
|
+
IDBeaconConnection: pIDBeaconConnection
|
|
1264
|
+
});
|
|
1265
|
+
if (!tmpIntrospect.Success)
|
|
1266
|
+
{
|
|
1267
|
+
return { Success: false, Reason: `Introspect failed: ${tmpIntrospect.Reason}` };
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Step 3: UpdateProxyConfig — extend the MeadowProxy allowlist
|
|
1271
|
+
// so PascalCase /1.0/.../UV* paths can pass through. Idempotent
|
|
1272
|
+
// on the databeacon side.
|
|
1273
|
+
let tmpProxy = await this._mestDispatch(tmpCoord, 'DataBeaconManagement', 'UpdateProxyConfig',
|
|
1274
|
+
{
|
|
1275
|
+
PathAllowlist: UV_PROXY_PATH_PATTERNS
|
|
1276
|
+
});
|
|
1277
|
+
if (!tmpProxy.Success)
|
|
1278
|
+
{
|
|
1279
|
+
return { Success: false, Reason: `UpdateProxyConfig failed: ${tmpProxy.Reason}` };
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Step 4: EnableEndpoint for each queue table. Captures the
|
|
1283
|
+
// databeacon-namespaced /1.0/<routeHash>/<Table> base so dispatch
|
|
1284
|
+
// doesn't have to discover it.
|
|
1285
|
+
this._EndpointBaseByBeacon[pBeaconID] = this._EndpointBaseByBeacon[pBeaconID] || {};
|
|
1286
|
+
for (let i = 0; i < QUEUE_TABLES.length; i++)
|
|
1287
|
+
{
|
|
1288
|
+
let tmpTable = QUEUE_TABLES[i];
|
|
1289
|
+
let tmpEnable = await this._mestDispatch(tmpCoord, 'DataBeaconManagement', 'EnableEndpoint',
|
|
1290
|
+
{
|
|
1291
|
+
IDBeaconConnection: pIDBeaconConnection,
|
|
1292
|
+
TableName: tmpTable
|
|
1293
|
+
});
|
|
1294
|
+
if (!tmpEnable.Success)
|
|
1295
|
+
{
|
|
1296
|
+
return { Success: false, Reason: `EnableEndpoint(${tmpTable}) failed: ${tmpEnable.Reason}` };
|
|
1297
|
+
}
|
|
1298
|
+
let tmpBase = (tmpEnable.Outputs && tmpEnable.Outputs.EndpointBase) || `/1.0/${tmpTable}`;
|
|
1299
|
+
this._EndpointBaseByBeacon[pBeaconID][tmpTable] = tmpBase;
|
|
1300
|
+
}
|
|
1301
|
+
return { Success: true };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Mesh-side dispatch helper used during bootstrap. Wraps
|
|
1306
|
+
* `dispatchAndWait` in a Promise that always resolves so the
|
|
1307
|
+
* bootstrap state machine stays linear. Returns a flat
|
|
1308
|
+
* `{Success, Reason, Outputs}` envelope.
|
|
1309
|
+
*/
|
|
1310
|
+
_mestDispatch(pCoord, pCapability, pAction, pSettings)
|
|
1311
|
+
{
|
|
1312
|
+
return new Promise((fResolve) =>
|
|
1313
|
+
{
|
|
1314
|
+
pCoord.dispatchAndWait(
|
|
1315
|
+
{
|
|
1316
|
+
Capability: pCapability,
|
|
1317
|
+
Action: pAction,
|
|
1318
|
+
Settings: pSettings || {},
|
|
1319
|
+
AffinityKey: 'queue-persistence-bootstrap',
|
|
1320
|
+
TimeoutMs: this._TimeoutMs * 4
|
|
1321
|
+
},
|
|
1322
|
+
(pError, pResult) =>
|
|
1323
|
+
{
|
|
1324
|
+
if (pError)
|
|
1325
|
+
{
|
|
1326
|
+
return fResolve({ Success: false, Reason: pError.message || String(pError) });
|
|
1327
|
+
}
|
|
1328
|
+
let tmpOut = (pResult && pResult.Outputs) || {};
|
|
1329
|
+
let tmpSuccess = (tmpOut.Success !== false);
|
|
1330
|
+
return fResolve({ Success: tmpSuccess, Reason: tmpOut.Reason || '', Outputs: tmpOut });
|
|
1331
|
+
});
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
module.exports = UltravisorQueuePersistenceBridge;
|