@devicechain/dashboards 0.22.0 → 0.23.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/dist/index.js CHANGED
@@ -1615,6 +1615,19 @@ var DEVICES_FOR_ANCHOR = `
1615
1615
  }
1616
1616
  }
1617
1617
  `;
1618
+ var CUSTOMER_DEVICES = `
1619
+ query DashboardCustomerDevices($criteria: CustomerDeviceSearchCriteria!) {
1620
+ customerDevices(criteria: $criteria) {
1621
+ results {
1622
+ token
1623
+ name
1624
+ }
1625
+ pagination {
1626
+ totalRecords
1627
+ }
1628
+ }
1629
+ }
1630
+ `;
1618
1631
  var DEVICES_BY_TOKEN = `
1619
1632
  query DashboardDevicesByToken($tokens: [String!]!) {
1620
1633
  devicesByToken(tokens: $tokens) {
@@ -1815,7 +1828,7 @@ var ConnectionTracker = class {
1815
1828
  };
1816
1829
 
1817
1830
  // src/hub.ts
1818
- import { gql as gql2, isForbiddenError, subscribe } from "@devicechain/client";
1831
+ import { gql as gql2, isForbiddenError, isScopeUnsupportedError, subscribe } from "@devicechain/client";
1819
1832
 
1820
1833
  // src/internal/alarm-doc.ts
1821
1834
  var ALARMS_QUERY = `
@@ -2069,7 +2082,11 @@ var DashboardHub = class {
2069
2082
  sink.roster(roster);
2070
2083
  for (const member of roster.members) {
2071
2084
  detachers.push(
2072
- this.attach(member.token, names, { next: (sample) => sink.next(sample), error: (err) => sink.error?.(err) })
2085
+ this.attach(member.token, names, {
2086
+ next: (sample) => sink.next(sample),
2087
+ error: (err) => sink.error?.(err),
2088
+ reconnected: () => sink.reconnected?.()
2089
+ })
2073
2090
  );
2074
2091
  }
2075
2092
  }).catch((err) => {
@@ -2483,6 +2500,19 @@ var DashboardHub = class {
2483
2500
  return true;
2484
2501
  }
2485
2502
  }
2503
+ // isDatasourceSupported reports whether the viewer is offered the binding at all: false only
2504
+ // when resolving an anchor is refused as SCOPE_UNSUPPORTED (an area or asset anchor, for a
2505
+ // customer user). The resolution is the same memoized one the widget's channel makes, so a
2506
+ // supported anchor costs no second read. Every other answer, including a failure, is true.
2507
+ async isDatasourceSupported(datasource) {
2508
+ if (datasource?.kind !== "anchor") return true;
2509
+ try {
2510
+ await this.resolver.devicesForAnchor(datasource.anchor);
2511
+ return true;
2512
+ } catch (err) {
2513
+ return !isScopeUnsupportedError(err);
2514
+ }
2515
+ }
2486
2516
  // availabilityToken returns the single device token whose existence gates a widget's
2487
2517
  // availability, or undefined when there is nothing device-specific to validate (an
2488
2518
  // anchor's membership is self-validating; an unbound slot is a placeholder; a reserved
@@ -2568,14 +2598,18 @@ var DashboardHub = class {
2568
2598
  const open = () => {
2569
2599
  closeSocket = subscribe(EVENT_AREA, MEASUREMENT_STREAM, variables, adapter);
2570
2600
  };
2601
+ const resumed = () => {
2602
+ stream.subscribers.forEach((s) => s.sink.reconnected?.());
2603
+ };
2571
2604
  const adapter = {
2572
2605
  next: (data) => {
2573
2606
  ended = 0;
2574
2607
  this.fanout(deviceToken, data.measurementStream);
2575
2608
  },
2576
- connected: () => {
2609
+ connected: (wasRetry) => {
2577
2610
  up = true;
2578
2611
  handle.connected();
2612
+ if (wasRetry) resumed();
2579
2613
  },
2580
2614
  closed: () => {
2581
2615
  up = false;
@@ -2593,7 +2627,10 @@ var DashboardHub = class {
2593
2627
  complete: () => {
2594
2628
  if (!operationOpen || !up) return;
2595
2629
  closeSocket?.();
2596
- reopen = setTimeout(open, Math.min(1e3 * 2 ** ended++, 3e4));
2630
+ reopen = setTimeout(() => {
2631
+ open();
2632
+ resumed();
2633
+ }, Math.min(1e3 * 2 ** ended++, 3e4));
2597
2634
  },
2598
2635
  error: (err) => {
2599
2636
  if (this.streams.get(deviceToken) === stream) this.streams.delete(deviceToken);
@@ -2940,7 +2977,8 @@ var SyntheticDataSource = class {
2940
2977
  };
2941
2978
 
2942
2979
  // src/recording.ts
2943
- var RECORDING_FORMAT_VERSION = 1;
2980
+ var RECORDING_FORMAT_VERSION = 2;
2981
+ var RECORDING_FORMAT_VERSIONS = [1, 2];
2944
2982
  var RECORDING_KIND = "sitepulse-board-recording";
2945
2983
  var ALARM_STATES = ["ACTIVE", "CLEARED"];
2946
2984
  var ALARM_SEVERITIES = ["CRITICAL", "MAJOR", "MINOR", "WARNING"];
@@ -3076,10 +3114,11 @@ function parseBoardRecording(input) {
3076
3114
  if (root.kind !== RECORDING_KIND) {
3077
3115
  throw new RecordingFormatError("$.kind", `expected ${JSON.stringify(RECORDING_KIND)}`);
3078
3116
  }
3079
- if (root.formatVersion !== RECORDING_FORMAT_VERSION) {
3117
+ const version = root.formatVersion;
3118
+ if (version !== 1 && version !== 2) {
3080
3119
  throw new RecordingFormatError(
3081
3120
  "$.formatVersion",
3082
- `unsupported version ${JSON.stringify(root.formatVersion)}; this reader understands ${RECORDING_FORMAT_VERSION}`
3121
+ `unsupported version ${JSON.stringify(root.formatVersion)}; this reader understands ${RECORDING_FORMAT_VERSIONS.join(", ")}`
3083
3122
  );
3084
3123
  }
3085
3124
  const durationMs = num2(root.durationMs, "$.durationMs");
@@ -3107,8 +3146,8 @@ function parseBoardRecording(input) {
3107
3146
  throw new RecordingFormatError("$.devices", "duplicate device id or token");
3108
3147
  }
3109
3148
  const ch = strict(root.channels, "$.channels", ["measurements", "alarms", "locations", "commands"]);
3110
- if (ch.locations !== false) {
3111
- throw new RecordingFormatError("$.channels.locations", "this version records no positions; must be false");
3149
+ if (version === 1 && ch.locations !== false) {
3150
+ throw new RecordingFormatError("$.channels.locations", "version 1 records no positions; must be false");
3112
3151
  }
3113
3152
  if (ch.commands !== false) {
3114
3153
  throw new RecordingFormatError("$.channels.commands", "this version records no command data; must be false");
@@ -3116,7 +3155,7 @@ function parseBoardRecording(input) {
3116
3155
  const channels = {
3117
3156
  measurements: column(ch.measurements, "$.channels.measurements", str),
3118
3157
  alarms: bool(ch.alarms, "$.channels.alarms"),
3119
- locations: false,
3158
+ locations: bool(ch.locations, "$.channels.locations"),
3120
3159
  commands: false
3121
3160
  };
3122
3161
  if (new Set(channels.measurements).size !== channels.measurements.length) {
@@ -3135,8 +3174,9 @@ function parseBoardRecording(input) {
3135
3174
  if (key === "__proto__") throw new RecordingFormatError(`$.sourceHashes.${key}`, "reserved key");
3136
3175
  sourceHashes[key] = matching(sh[key], `$.sourceHashes.${key}`, HEX64, "a lowercase hex SHA-256");
3137
3176
  }
3138
- const cv = strict(root.converter, "$.converter", ["version"]);
3177
+ const cv = strict(root.converter, "$.converter", ["version"], version >= 2 ? ["name"] : []);
3139
3178
  const converter = { version: num2(cv.version, "$.converter.version") };
3179
+ if (cv.name !== void 0) converter.name = str(cv.name, "$.converter.name");
3140
3180
  let excerpt;
3141
3181
  if (root.excerpt !== void 0) {
3142
3182
  const e = strict(root.excerpt, "$.excerpt", ["fromMs", "toMs"]);
@@ -3158,13 +3198,17 @@ function parseBoardRecording(input) {
3158
3198
  if (i > 0 && c.tMs < chapters[i - 1].tMs) throw new RecordingFormatError(`$.chapters[${i}].tMs`, "chapters must not decrease in time");
3159
3199
  });
3160
3200
  const m = strict(root.measurements, "$.measurements", ["d", "n", "t", "v", "s"]);
3161
- const t = column(m.t, "$.measurements.t", num2);
3162
- let prev = lo;
3163
- t.forEach((x, i) => {
3164
- if (x < lo || x > hi) throw new RecordingFormatError(`$.measurements.t[${i}]`, `${x} is outside [${lo}, ${hi}]`);
3165
- if (x < prev) throw new RecordingFormatError(`$.measurements.t[${i}]`, "time column must not decrease");
3166
- prev = x;
3167
- });
3201
+ const times = (v, path) => {
3202
+ const col = column(v, path, num2);
3203
+ let prev = lo;
3204
+ col.forEach((x, i) => {
3205
+ if (x < lo || x > hi) throw new RecordingFormatError(`${path}[${i}]`, `${x} is outside [${lo}, ${hi}]`);
3206
+ if (x < prev) throw new RecordingFormatError(`${path}[${i}]`, "time column must not decrease");
3207
+ prev = x;
3208
+ });
3209
+ return col;
3210
+ };
3211
+ const t = times(m.t, "$.measurements.t");
3168
3212
  const index = (name, limit) => (x, p) => {
3169
3213
  const n = num2(x, p);
3170
3214
  if (!Number.isInteger(n) || n < 0 || n >= limit) throw new RecordingFormatError(p, `${name} index out of range`);
@@ -3191,6 +3235,9 @@ function parseBoardRecording(input) {
3191
3235
  const dev = str(o.dev, `${p}.dev`);
3192
3236
  if (!tokens.has(dev)) throw new RecordingFormatError(`${p}.dev`, `device '${dev}' is not in $.devices`);
3193
3237
  if (o.ack !== void 0 && o.ack !== true) throw new RecordingFormatError(`${p}.ack`, "present only when true");
3238
+ if (o.val !== void 0 && (typeof o.val !== "number" || !Number.isFinite(o.val))) {
3239
+ throw new RecordingFormatError(`${p}.val`, "expected a finite number");
3240
+ }
3194
3241
  const out = {
3195
3242
  id,
3196
3243
  dev,
@@ -3201,16 +3248,18 @@ function parseBoardRecording(input) {
3201
3248
  occ: instant(o.occ, `${p}.occ`)
3202
3249
  };
3203
3250
  if (o.ack === true) out.ack = true;
3251
+ if (typeof o.val === "number") out.val = o.val;
3204
3252
  return out;
3205
3253
  };
3206
3254
  const ALARM_KEYS = ["id", "dev", "key", "metric", "state", "sev", "occ"];
3255
+ const ALARM_OPTIONAL = version >= 2 ? ["ack", "val"] : ["ack"];
3207
3256
  const checkTime = (tMs, p) => {
3208
3257
  if (tMs < 0 || tMs > hi) throw new RecordingFormatError(p, `${tMs} is outside [0, ${hi}]`);
3209
3258
  return tMs;
3210
3259
  };
3211
3260
  const snapshots = column(al.snapshots, "$.alarms.snapshots", (x, p) => {
3212
3261
  const o = strict(x, p, ["tMs", "requestedAtMs", "total", "alarms"]);
3213
- const alarms = column(o.alarms, `${p}.alarms`, (a, ap) => alarm(strict(a, ap, ALARM_KEYS, ["ack"]), ap));
3262
+ const alarms = column(o.alarms, `${p}.alarms`, (a, ap) => alarm(strict(a, ap, ALARM_KEYS, ALARM_OPTIONAL), ap));
3214
3263
  const ids = /* @__PURE__ */ new Set();
3215
3264
  alarms.forEach((a, i) => {
3216
3265
  if (ids.has(a.id)) throw new RecordingFormatError(`${p}.alarms[${i}].id`, `duplicate alarm ${a.id} in one snapshot`);
@@ -3227,7 +3276,7 @@ function parseBoardRecording(input) {
3227
3276
  return { tMs, requestedAtMs, total, alarms };
3228
3277
  });
3229
3278
  const events = column(al.events, "$.alarms.events", (x, p) => {
3230
- const o = strict(x, p, [...ALARM_KEYS, "tMs"], ["ack"]);
3279
+ const o = strict(x, p, [...ALARM_KEYS, "tMs"], ALARM_OPTIONAL);
3231
3280
  return { ...alarm(o, p), tMs: checkTime(num2(o.tMs, `${p}.tMs`), `${p}.tMs`) };
3232
3281
  });
3233
3282
  for (const [name, list] of [["snapshots", snapshots], ["events", events]]) {
@@ -3250,12 +3299,36 @@ function parseBoardRecording(input) {
3250
3299
  if (!channels.alarms && (snapshots.length > 0 || events.length > 0)) {
3251
3300
  throw new RecordingFormatError("$.alarms", "history present but $.channels.alarms is false");
3252
3301
  }
3253
- if (!Array.isArray(root.locations) || root.locations.length !== 0) {
3254
- throw new RecordingFormatError("$.locations", "this version carries no positions; must be an empty array");
3302
+ let locations;
3303
+ if (version === 1) {
3304
+ if (!Array.isArray(root.locations) || root.locations.length !== 0) {
3305
+ throw new RecordingFormatError("$.locations", "version 1 carries no positions; must be an empty array");
3306
+ }
3307
+ locations = { d: [], t: [], lat: [], lon: [], hdg: [], spd: [] };
3308
+ } else {
3309
+ const lc = strict(root.locations, "$.locations", ["d", "t", "lat", "lon", "hdg", "spd"]);
3310
+ const lt = times(lc.t, "$.locations.t");
3311
+ const within = (min, max, what) => (x, p) => {
3312
+ const n = num2(x, p);
3313
+ if (n < min || n > max) throw new RecordingFormatError(p, `${what} ${n} is outside [${min}, ${max}]`);
3314
+ return n;
3315
+ };
3316
+ const optional = (min, max, what) => (x, p) => x === null ? null : within(min, max, what)(x, p);
3317
+ locations = {
3318
+ d: column(lc.d, "$.locations.d", index("device", devices.length), lt.length),
3319
+ t: lt,
3320
+ lat: column(lc.lat, "$.locations.lat", within(-90, 90, "latitude"), lt.length),
3321
+ lon: column(lc.lon, "$.locations.lon", within(-180, 180, "longitude"), lt.length),
3322
+ hdg: column(lc.hdg, "$.locations.hdg", optional(0, 360, "heading"), lt.length),
3323
+ spd: column(lc.spd, "$.locations.spd", optional(0, Number.MAX_VALUE, "speed"), lt.length)
3324
+ };
3325
+ if (!channels.locations && lt.length > 0) {
3326
+ throw new RecordingFormatError("$.locations", "positions present but $.channels.locations is false");
3327
+ }
3255
3328
  }
3256
3329
  const rec = {
3257
3330
  kind: RECORDING_KIND,
3258
- formatVersion: RECORDING_FORMAT_VERSION,
3331
+ formatVersion: version,
3259
3332
  runId: str(root.runId, "$.runId"),
3260
3333
  startedAtUtc: instant(root.startedAtUtc, "$.startedAtUtc"),
3261
3334
  durationMs,
@@ -3272,7 +3345,7 @@ function parseBoardRecording(input) {
3272
3345
  chapters,
3273
3346
  measurements,
3274
3347
  alarms: { snapshots, events },
3275
- locations: []
3348
+ locations
3276
3349
  };
3277
3350
  if (excerpt) rec.excerpt = excerpt;
3278
3351
  return rec;
@@ -3436,6 +3509,9 @@ function createRecordedClock(rec, options = {}) {
3436
3509
  const { fromMs, toMs } = recordingBounds(rec);
3437
3510
  return new RecordedClock(toMs, { minMs: fromMs, ticker: options.ticker });
3438
3511
  }
3512
+ function retimeShiftMs(rec, cursorMs, nowMs = Date.now()) {
3513
+ return Math.round(nowMs - recordedWallTimeMs(rec, cursorMs));
3514
+ }
3439
3515
  function sameAnchor(a, b) {
3440
3516
  return a.relationship === b.relationship && a.targetType === b.targetType && a.targetToken === b.targetToken;
3441
3517
  }
@@ -3475,6 +3551,7 @@ function createRecordedLister(rec, options = {}) {
3475
3551
  return site && site.targetType === kind ? [{ token: site.targetToken, name: null }] : [];
3476
3552
  };
3477
3553
  }
3554
+ var loadExtra = () => import("./chunks/recorded-extra-C2BHFOCB.js");
3478
3555
  var indexCache = /* @__PURE__ */ new WeakMap();
3479
3556
  function indexOf(rec) {
3480
3557
  const cached = indexCache.get(rec);
@@ -3506,7 +3583,8 @@ function indexOf(rec) {
3506
3583
  }
3507
3584
  var RecordedDataSource = class {
3508
3585
  // `clock` is the shared replay clock; the view starts at its current time. `options`
3509
- // names the site anchors this recording's devices belong to (see RecordedSiteOptions).
3586
+ // names the site anchors this recording's devices belong to (see RecordedSiteOptions)
3587
+ // and the time shift, if any (see "Re-timing" above).
3510
3588
  constructor(rec, clock, options = {}) {
3511
3589
  this.subs = /* @__PURE__ */ new Set();
3512
3590
  this.stale = false;
@@ -3514,6 +3592,9 @@ var RecordedDataSource = class {
3514
3592
  this.site = declaredSite(options);
3515
3593
  this.index = indexOf(rec);
3516
3594
  this.cursor = clock.timeMs;
3595
+ const shift = options.timeShiftMs ?? 0;
3596
+ if (!Number.isFinite(shift)) throw new RangeError(`RecordedDataSource: timeShiftMs must be finite, got ${shift}`);
3597
+ this.shiftMs = shift;
3517
3598
  this.startMs = Date.parse(rec.startedAtUtc);
3518
3599
  this.devices = new Set(rec.devices.map((d) => d.token));
3519
3600
  this.unlisten = clock.subscribe((ev) => this.onClock(ev));
@@ -3523,10 +3604,14 @@ var RecordedDataSource = class {
3523
3604
  get isStale() {
3524
3605
  return this.stale;
3525
3606
  }
3526
- // The recorded instant the view is at: samples are dated by the run, so a time span is measured
3527
- // from here, not from the wall clock.
3607
+ // The instant the view is at: samples are dated by the run (moved by the shift, if any), so a
3608
+ // time span is measured from here, not from the wall clock.
3528
3609
  now() {
3529
- return this.startMs + this.cursor;
3610
+ return this.startMs + this.cursor + this.shiftMs;
3611
+ }
3612
+ // The shift every instant is moved by (0 for the run's own times).
3613
+ get timeShiftMs() {
3614
+ return this.shiftMs;
3530
3615
  }
3531
3616
  onClock(ev) {
3532
3617
  if (this.stale) return;
@@ -3540,7 +3625,13 @@ var RecordedDataSource = class {
3540
3625
  for (const sub of [...this.subs]) sub.advance(this.cursor);
3541
3626
  }
3542
3627
  iso(offsetMs) {
3543
- return new Date(this.startMs + offsetMs).toISOString();
3628
+ return new Date(this.startMs + offsetMs + this.shiftMs).toISOString();
3629
+ }
3630
+ // A recorded instant (an alarm's raise or clear time), moved by the shift. Unshifted, the
3631
+ // recording's own string is kept, digits and all.
3632
+ shifted(instant2) {
3633
+ if (instant2 === null || this.shiftMs === 0) return instant2;
3634
+ return new Date(Date.parse(instant2) + this.shiftMs).toISOString();
3544
3635
  }
3545
3636
  // open tracks a subscription and returns its disposer. `start` runs once, in a
3546
3637
  // microtask, so the disposer is in the caller's hands before anything is emitted (the
@@ -3745,6 +3836,9 @@ var RecordedDataSource = class {
3745
3836
  // Each record's own `ack` decides; acknowledgement is not carried over from an
3746
3837
  // earlier record of the same alarm.
3747
3838
  ack: a.ack === true,
3839
+ // The reading that raised the alarm. A cleared alarm keeps its raise's reading, as the
3840
+ // platform's row does (a resolve carries no value).
3841
+ val: a.val ?? (a.state === "CLEARED" ? prior?.val ?? null : null),
3748
3842
  asOfMs
3749
3843
  });
3750
3844
  };
@@ -3777,7 +3871,10 @@ var RecordedDataSource = class {
3777
3871
  const signature = JSON.stringify([matches.length, page]);
3778
3872
  if (signature === lastSignature || isDisposed()) return;
3779
3873
  lastSignature = signature;
3780
- const snapshot = { alarms: page.map(toAlarmRow), total: matches.length };
3874
+ const snapshot = {
3875
+ alarms: page.map((a) => toAlarmRow({ ...a, raised: this.shifted(a.raised), cleared: this.shifted(a.cleared) })),
3876
+ total: matches.length
3877
+ };
3781
3878
  sink.next(snapshot);
3782
3879
  };
3783
3880
  return this.open({ start: emit, advance: emit });
@@ -3788,10 +3885,14 @@ var RecordedDataSource = class {
3788
3885
  return this.failLater(new NotInRecordingError("commands"), sink);
3789
3886
  }
3790
3887
  // ── locations ─────────────────────────────────────────────────────────────
3791
- // This format version records no positions. A selector that names no location series
3792
- // asks for nothing, and one that resolves to no device has nothing to place: both get the
3793
- // empty snapshot, as from the hub. Naming a location series for devices that exist is a
3794
- // request for data the recording does not have, which is an error, never an empty map.
3888
+ // A selector that names no location series asks for nothing, and one that resolves to no
3889
+ // device has nothing to place: both get the empty snapshot, as from the hub. Naming a
3890
+ // location series for devices that exist, on a recording with no positions, is a request
3891
+ // for data the recording does not have, which is an error, never an empty map.
3892
+ //
3893
+ // With positions recorded, each snapshot is every bound device's LAST position at the
3894
+ // cursor, as device-state answers the live map: a device not yet located is absent (the
3895
+ // service's contract), and the snapshot is sent again whenever one of them moves on.
3795
3896
  subscribeLocations(subscription, sink) {
3796
3897
  const ds = subscription.datasource;
3797
3898
  let tokens = [];
@@ -3801,18 +3902,58 @@ var RecordedDataSource = class {
3801
3902
  } catch (err) {
3802
3903
  return this.failLater(err, sink);
3803
3904
  }
3804
- if (tokens.length > 0) {
3905
+ if (tokens.length > 0 && !this.rec.channels.locations) {
3805
3906
  return this.failLater(new NotInRecordingError("locations", "no positions were recorded"), sink);
3806
3907
  }
3807
3908
  }
3909
+ let extra = null;
3910
+ let lastSignature = null;
3911
+ const emit = (cursor, isDisposed) => {
3912
+ if (!extra) return;
3913
+ const { signature, locations } = extra.lastPositions(this.rec, tokens, cursor, (t) => this.iso(t));
3914
+ if (signature === lastSignature || isDisposed()) return;
3915
+ lastSignature = signature;
3916
+ sink.next({ kind: "positions", deviceTokens: tokens, locations });
3917
+ };
3808
3918
  return this.open({
3809
3919
  start: (_cursor, isDisposed) => {
3810
- if (!isDisposed()) sink.next({ kind: "positions", deviceTokens: [], locations: [] });
3920
+ if (tokens.length === 0) {
3921
+ if (!isDisposed()) sink.next({ kind: "positions", deviceTokens: [], locations: [] });
3922
+ return;
3923
+ }
3924
+ loadExtra().then(
3925
+ (m) => {
3926
+ extra = m;
3927
+ if (!this.stale) emit(this.cursor, isDisposed);
3928
+ },
3929
+ (err) => {
3930
+ if (!isDisposed()) sink.error?.(err);
3931
+ }
3932
+ );
3811
3933
  },
3812
- advance: () => {
3813
- }
3934
+ advance: emit
3814
3935
  });
3815
3936
  }
3937
+ // ── aggregates ────────────────────────────────────────────────────────────
3938
+ // The buckets a stat widget folds into a span's aggregates, computed from the recording's
3939
+ // own rows: every (device, measurement) row whose time falls in the window AND at or
3940
+ // before the cursor (a replay must not know what happens next), grouped into buckets
3941
+ // `intervalSeconds` wide aligned on the epoch like the event store's, each with its exact
3942
+ // count, minimum, maximum and sum. Like the store, a bucket that holds no reading is not
3943
+ // returned. The window is in the source's own (shifted) time and is moved back by the
3944
+ // shift before the rows are read, so a re-timed board aggregates the same rows.
3945
+ async bucketMeasurements(datasource, window2) {
3946
+ const groups = this.resolve(datasource);
3947
+ for (const g of groups) {
3948
+ for (const name of g.names) {
3949
+ if (!this.rec.channels.measurements.includes(name)) {
3950
+ throw new NotInRecordingError("measurements", `no '${name}' series was recorded`);
3951
+ }
3952
+ }
3953
+ }
3954
+ const { foldBuckets } = await loadExtra();
3955
+ return foldBuckets(this.rec, groups, this.index.byDevice, window2, this.startMs + this.shiftMs, this.cursor);
3956
+ }
3816
3957
  // ── availability ──────────────────────────────────────────────────────────
3817
3958
  // A device selector is available iff the run has that device. Anchors, unbound slots and
3818
3959
  // no datasource have a legitimate empty state and are available, as on the hub.
@@ -3841,9 +3982,10 @@ function toAlarmRow(a) {
3841
3982
  // The recording notes THAT an alarm was acknowledged, not when or by whom.
3842
3983
  acknowledgedTime: null,
3843
3984
  acknowledgedBy: null,
3844
- // The stored alarm's last value is not recorded, and a nearby measurement would
3845
- // invent the integrator's state. Null renders as an em dash.
3846
- lastValue: null
3985
+ // The reading that raised it, as the recording holds it (recording.ts: `val`). A recording
3986
+ // without one plays with none; a nearby measurement would invent the integrator's state.
3987
+ // Null renders as an em dash.
3988
+ lastValue: a.val
3847
3989
  };
3848
3990
  }
3849
3991
 
@@ -4575,6 +4717,39 @@ function duplicateWidget(def, id) {
4575
4717
  // src/resolver.ts
4576
4718
  import { gql as gql3 } from "@devicechain/client";
4577
4719
  var ANCHOR_PAGE_SIZE = 500;
4720
+ var trueSize = (reported, page) => Math.max(reported ?? page, page);
4721
+ function readAnchorPage(anchor) {
4722
+ if (anchor.targetType === "customer") {
4723
+ return gql3("device-management", CUSTOMER_DEVICES, {
4724
+ criteria: {
4725
+ pageNumber: 1,
4726
+ pageSize: ANCHOR_PAGE_SIZE,
4727
+ customer: anchor.targetToken,
4728
+ relationshipType: anchor.relationship
4729
+ }
4730
+ }).then((r) => {
4731
+ const rows = r.customerDevices.results;
4732
+ return {
4733
+ tokens: rows.map((d) => d.token),
4734
+ total: trueSize(r.customerDevices.pagination?.totalRecords, rows.length),
4735
+ names: new Map(rows.map((d) => [d.token, d.name]))
4736
+ };
4737
+ });
4738
+ }
4739
+ return gql3("device-management", DEVICES_FOR_ANCHOR, {
4740
+ criteria: {
4741
+ pageNumber: 1,
4742
+ pageSize: ANCHOR_PAGE_SIZE,
4743
+ sourceType: "device",
4744
+ targetType: anchor.targetType,
4745
+ target: anchor.targetToken,
4746
+ relationshipType: anchor.relationship
4747
+ }
4748
+ }).then((r) => {
4749
+ const tokens = r.entityRelationships.results.map((rel) => rel.source.token);
4750
+ return { tokens, total: trueSize(r.entityRelationships.pagination?.totalRecords, tokens.length) };
4751
+ });
4752
+ }
4578
4753
  function createDeviceResolver() {
4579
4754
  const anchorCache = /* @__PURE__ */ new Map();
4580
4755
  const memberCache = /* @__PURE__ */ new Map();
@@ -4586,20 +4761,7 @@ function createDeviceResolver() {
4586
4761
  const key = anchorKey(anchor);
4587
4762
  let pending = anchorCache.get(key);
4588
4763
  if (!pending) {
4589
- pending = gql3("device-management", DEVICES_FOR_ANCHOR, {
4590
- criteria: {
4591
- pageNumber: 1,
4592
- pageSize: ANCHOR_PAGE_SIZE,
4593
- sourceType: "device",
4594
- targetType: anchor.targetType,
4595
- target: anchor.targetToken,
4596
- relationshipType: anchor.relationship
4597
- }
4598
- }).then((r) => {
4599
- const tokens = r.entityRelationships.results.map((rel) => rel.source.token);
4600
- const total = r.entityRelationships.pagination?.totalRecords ?? tokens.length;
4601
- return { tokens, total: Math.max(total, tokens.length) };
4602
- }).catch((err) => {
4764
+ pending = readAnchorPage(anchor).catch((err) => {
4603
4765
  anchorCache.delete(key);
4604
4766
  throw err;
4605
4767
  });
@@ -4611,17 +4773,17 @@ function createDeviceResolver() {
4611
4773
  devicesForAnchor(anchor) {
4612
4774
  return anchorPage(anchor).then((page) => page.tokens);
4613
4775
  },
4614
- // The members of an anchor with their names and the anchor's true size. One relationship page
4615
- // (shared with devicesForAnchor) plus ONE batched name read, whatever the size of the fleet. A
4616
- // failed name read is not a failed roster: the tokens still stream, and the widget labels
4617
- // them by token.
4776
+ // The members of an anchor with their names and the anchor's true size. One page (shared with
4777
+ // devicesForAnchor) plus, when that page carried no names, ONE batched name read, whatever the
4778
+ // size of the fleet. A failed name read is not a failed roster: the tokens still stream, and
4779
+ // the widget labels them by token.
4618
4780
  anchorMembers(anchor) {
4619
4781
  const key = anchorKey(anchor);
4620
4782
  let pending = memberCache.get(key);
4621
4783
  if (!pending) {
4622
- pending = anchorPage(anchor).then(async ({ tokens, total }) => {
4623
- const names = /* @__PURE__ */ new Map();
4624
- if (tokens.length > 0) {
4784
+ pending = anchorPage(anchor).then(async ({ tokens, total, names: listed }) => {
4785
+ const names = listed ?? /* @__PURE__ */ new Map();
4786
+ if (!listed && tokens.length > 0) {
4625
4787
  try {
4626
4788
  const r = await gql3("device-management", DEVICE_NAMES, { tokens });
4627
4789
  for (const d of r.devicesByToken) names.set(d.token, d.name);
@@ -4672,7 +4834,7 @@ function historyWindowFor(spanSeconds, end) {
4672
4834
  function defaultHistoryWindow() {
4673
4835
  return historyWindowFor(DEFAULT_TIME_RANGE_SECONDS, Date.now());
4674
4836
  }
4675
- async function fetchWidgetHistory(widget, window2) {
4837
+ async function fetchWidgetHistory(widget, window2, options = {}) {
4676
4838
  const ds = widget.datasource;
4677
4839
  if (!ds || ds.kind !== "device") return [];
4678
4840
  try {
@@ -4700,7 +4862,8 @@ async function fetchWidgetHistory(widget, window2) {
4700
4862
  value: b.avg,
4701
4863
  classifier: null
4702
4864
  })).sort((a, b) => a.occurredTime < b.occurredTime ? -1 : 1);
4703
- } catch {
4865
+ } catch (err) {
4866
+ if (options.rethrow) throw err;
4704
4867
  return [];
4705
4868
  }
4706
4869
  }
@@ -5457,6 +5620,7 @@ export {
5457
5620
  PUBLISH_BLOCKING_LAYOUT_CODES,
5458
5621
  RECORDED_HISTORY_WINDOW_MS,
5459
5622
  RECORDING_FORMAT_VERSION,
5623
+ RECORDING_FORMAT_VERSIONS,
5460
5624
  RECORDING_KIND,
5461
5625
  RESERVED_PLACEMENT_MODES,
5462
5626
  RecordedClock,
@@ -5581,6 +5745,7 @@ export {
5581
5745
  resolveSlotCandidates,
5582
5746
  resolveWidgetDatasource,
5583
5747
  resumeRange,
5748
+ retimeShiftMs,
5584
5749
  sameBinding,
5585
5750
  sameLabel,
5586
5751
  sealHistory,