signalk-questdb 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.coderabbit.yaml +17 -2
  2. package/.release-please-manifest.json +1 -1
  3. package/AGENTS.md +80 -0
  4. package/CLAUDE.md +1 -79
  5. package/README.md +34 -12
  6. package/dist/api-contract.d.ts +1 -1
  7. package/dist/change-gate.d.ts +6 -6
  8. package/dist/change-gate.js +8 -6
  9. package/dist/change-gate.js.map +1 -1
  10. package/dist/config/schema.d.ts +1 -0
  11. package/dist/config/schema.js +6 -1
  12. package/dist/config/schema.js.map +1 -1
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.js +42 -54
  15. package/dist/index.js.map +1 -1
  16. package/dist/path-matcher.d.ts +5 -4
  17. package/dist/path-matcher.js +17 -10
  18. package/dist/path-matcher.js.map +1 -1
  19. package/dist/restore.d.ts +10 -6
  20. package/dist/restore.js +22 -8
  21. package/dist/restore.js.map +1 -1
  22. package/package.json +4 -4
  23. package/public/assets/{AppPanel-C293yEHl.js → AppPanel-D_HLFa10.js} +1 -1
  24. package/public/assets/PluginConfigurationPanel-DhWC94G3.js +13 -0
  25. package/public/assets/{_virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-BH9mY6vj.js → _virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-CTTydXPl.js} +1 -1
  26. package/public/assets/{_virtual_mf___mfe_internal__signalk_questdb__mf_owner__97224418320974__loadShare__shared-H1lSL0nN.js → _virtual_mf___mfe_internal__signalk_questdb__mf_owner__97224418320974__loadShare__shared-BgRwx7LZ.js} +1 -1
  27. package/public/assets/{dist-BhVCbinB.js → dist-B4My1YVh.js} +1 -1
  28. package/public/assets/helpers-PTLizuYm.js +1 -0
  29. package/public/assets/hostInit-Dvn0aCSd.js +1 -0
  30. package/public/assets/{index-QJC86Uvi.js → index-j5iNctbW.js} +2 -2
  31. package/public/assets/pendingShares-BHVf8sM9.js +2 -0
  32. package/public/assets/{utils-D62kKhzq.js → utils-BesU7hIa.js} +1 -1
  33. package/public/assets/{vite-preload-helper-CRzLMJPS.js → vite-preload-helper-DclECHGd.js} +1 -1
  34. package/public/remoteEntry.js +2 -2
  35. package/public/assets/PluginConfigurationPanel-7qfy8fIR.js +0 -11
  36. package/public/assets/helpers-CvnX61Ju.js +0 -1
  37. package/public/assets/hostInit-Ds8KD_fS.js +0 -1
  38. package/public/assets/pendingShares-DemvETkM.js +0 -2
package/dist/index.js CHANGED
@@ -255,13 +255,6 @@ export default (app) => {
255
255
  return;
256
256
  app.setPluginStatus(recordingStatus(host, port));
257
257
  };
258
- // Contexts seen live while a startup restore is in flight. A stored fix
259
- // must never overwrite a vessel that has already transmitted since boot —
260
- // that would move it backwards on the chart. Populated by the recorder
261
- // subscription and cleared as soon as the restore settles, so this stays a
262
- // startup-window set rather than an unbounded one.
263
- const liveContexts = new Set();
264
- let trackLiveContexts = false;
265
258
  const withLifecycleLock = (fn) => {
266
259
  const run = lifecycleChain.then(fn, fn);
267
260
  // Keep the chain alive regardless of this op's outcome.
@@ -556,10 +549,10 @@ export default (app) => {
556
549
  const matches = matcher.matches(path);
557
550
  return mode === "exclude" ? !matches : matches;
558
551
  }
559
- function isThrottled(path, context, defaultRate) {
552
+ function isThrottled(path, context, defaultRate, source) {
560
553
  // Per-path override wins when one matches; otherwise the default rate.
561
554
  const overrideRate = rateMatcher?.rateFor(path) ?? null;
562
- return throttle.shouldDrop(path, context, overrideRate ?? defaultRate, Date.now());
555
+ return throttle.shouldDrop(path, context, overrideRate ?? defaultRate, Date.now(), source);
563
556
  }
564
557
  const WAL_NOTIFICATION_PATH = "notifications.signalk-questdb.walSuspended";
565
558
  // Dedupe key of the last emitted notification. The monitor reports every
@@ -1189,21 +1182,22 @@ export default (app) => {
1189
1182
  app.registerHistoryApiProvider(v2Provider);
1190
1183
  const v1Provider = createHistoryProviderV1(queryClient, app.selfContext, (msg) => app.debug(msg), archive);
1191
1184
  app.registerHistoryProvider(v1Provider);
1192
- // Arm live-context tracking before the first delta can arrive, so the
1193
- // restore sees every vessel that transmitted during startup.
1194
- liveContexts.clear();
1195
- trackLiveContexts = config.restoreOnStart;
1196
- const bus = app.streambundle.getBus();
1185
+ // getBus() carries only the source that Signal K's source priorities
1186
+ // prefer on each path, the value instruments show. recordAllSources
1187
+ // reads the unfiltered bus instead, so a source that loses the ranking
1188
+ // is recorded as well: the raw paddlewheel next to a corrected STW
1189
+ // (issue #235).
1190
+ const bus = config.recordAllSources
1191
+ ? app.streambundle.getUnfilteredBus()
1192
+ : app.streambundle.getBus();
1197
1193
  const unsub = bus.onValue((delta) => {
1198
1194
  if (!writer)
1199
1195
  return;
1200
1196
  const { path, value, context } = delta;
1201
1197
  // Values this plugin replayed at startup come straight back around
1202
1198
  // through the streambundle. Recording them would re-stamp historical
1203
- // fixes with the current receive time — inventing present-tense
1204
- // positions for vessels that may be long gone — and marking their
1205
- // context live would defeat the guard that stops a restore from
1206
- // overwriting a vessel that actually transmitted.
1199
+ // fixes with the current receive time, inventing present-tense
1200
+ // positions for vessels that may be long gone.
1207
1201
  if (delta.$source === RESTORE_SOURCE)
1208
1202
  return;
1209
1203
  // The delta's sourceRef, stored as the rows' `source` column so
@@ -1212,13 +1206,21 @@ export default (app) => {
1212
1206
  // syntax. Undefined when the delta carries none — the column stays
1213
1207
  // null, same as rows written before it existed.
1214
1208
  const source = typeof delta.$source === "string" ? delta.$source : undefined;
1215
- // Note the context BEFORE any early return or filter: identity-only
1216
- // deltas return below, and path filters and throttles decide what gets
1217
- // STORED — but any delta at all proves the vessel is transmitting now,
1218
- // which is what makes its stored position obsolete for the restore.
1219
- if (trackLiveContexts) {
1220
- liveContexts.add(context === app.selfContext ? "self" : context);
1221
- }
1209
+ // Recording every source keeps each one apart. It gets its own
1210
+ // sampling window: the shared one would keep whichever source arrives
1211
+ // first, so the fastest receiver would crowd out the ones recorded to
1212
+ // compare against it. And its nulls forget only its own last values:
1213
+ // a gateway sending "not available" several times a second would
1214
+ // otherwise make every other source's unchanged values due again at
1215
+ // each sampling window instead of each heartbeat.
1216
+ const perSource = config.recordAllSources ? source : undefined;
1217
+ // Meta updates ride the same bus as values, flagged isMeta. They
1218
+ // describe a path (units, description, displayName), they are not a
1219
+ // reading of it. Routed like a value object, their keys were stored
1220
+ // as rows such as `….units` and `….displayName`, again each time a
1221
+ // plugin republished its meta.
1222
+ if (delta.isMeta)
1223
+ return;
1222
1224
  // Static vessel identity (issue #91): names arrive as empty-path
1223
1225
  // object deltas, which the path guard below would drop. Stored as
1224
1226
  // path "name" in the string table; history-v1 replays them in the
@@ -1257,7 +1259,7 @@ export default (app) => {
1257
1259
  // the change gate must forget the path, so the first reading after the
1258
1260
  // gap is recorded even when it equals the last one before it.
1259
1261
  if (path && value === null) {
1260
- changeGate?.forget(path, context === app.selfContext ? "self" : context);
1262
+ changeGate?.forget(path, context === app.selfContext ? "self" : context, perSource);
1261
1263
  }
1262
1264
  if (!path || value === undefined || value === null)
1263
1265
  return;
@@ -1304,13 +1306,14 @@ export default (app) => {
1304
1306
  return;
1305
1307
  // Throttled per path AND context: the sampling rate bounds each
1306
1308
  // vessel's stream, not the fleet's (issue #93). The stored context
1307
- // ("self" vs raw) is the key, matching what the rows carry. The key
1308
- // deliberately does NOT include source: the sampling rate bounds the
1309
- // per-path row volume, and keying per source would multiply it by the
1310
- // number of receivers. With two sources alternating, each window
1311
- // keeps whichever arrived first — a source-filtered read then sees
1312
- // roughly every other sample, which the rate already allows for.
1313
- if (isThrottled(path, isSelf ? "self" : context, config.defaultSamplingRate ?? 2000))
1309
+ // ("self" vs raw) is the key, matching what the rows carry. Unless
1310
+ // recordAllSources asks for every source, the key deliberately does
1311
+ // NOT include source: the sampling rate bounds the per-path row
1312
+ // volume, and keying per source would multiply it by the number of
1313
+ // receivers. With two sources alternating, each window keeps
1314
+ // whichever arrived first — a source-filtered read then sees roughly
1315
+ // every other sample, which the rate already allows for.
1316
+ if (isThrottled(path, isSelf ? "self" : context, config.defaultSamplingRate ?? 2000, perSource))
1314
1317
  return;
1315
1318
  }
1316
1319
  // Rows are stamped with the server receive time, deliberately NOT the
@@ -1365,7 +1368,7 @@ export default (app) => {
1365
1368
  continue;
1366
1369
  if (changeGate?.isRepeat(leaf.path, ctx, source, leaf.value, now))
1367
1370
  continue;
1368
- if (isThrottled(leaf.path, ctx, config.defaultSamplingRate ?? 2000))
1371
+ if (isThrottled(leaf.path, ctx, config.defaultSamplingRate ?? 2000, perSource))
1369
1372
  continue;
1370
1373
  changeGate?.wrote(leaf.path, ctx, source, leaf.value, now);
1371
1374
  if (typeof leaf.value === "number") {
@@ -1547,11 +1550,10 @@ export default (app) => {
1547
1550
  // reads pluginRunning and everything before this point can still throw
1548
1551
  // and abandon the start.
1549
1552
  //
1550
- // The recorder is already subscribed, so live deltas keep arriving while
1551
- // the (slow, whole-table) restore query runs. Those are newer by
1552
- // definition: replaying a stored fix over a vessel that just transmitted
1553
- // would move it BACKWARDS on the chart. liveContexts records what the
1554
- // recorder saw and the restore skips those contexts entirely.
1553
+ // Live deltas keep arriving while the (slow, whole-table) restore query
1554
+ // runs, and some arrived before the recorder subscribed. The restore
1555
+ // reads the model and leaves alone whatever it already holds, so neither
1556
+ // is overwritten by an older stored value.
1555
1557
  if (config.restoreOnStart) {
1556
1558
  const restoreGeneration = lifecycleGeneration;
1557
1559
  void restoreFromHistory({
@@ -1566,7 +1568,7 @@ export default (app) => {
1566
1568
  },
1567
1569
  selfContext: app.selfContext,
1568
1570
  debug: (msg) => app.debug(msg),
1569
- hasLiveData: (context) => liveContexts.has(context),
1571
+ getPath: (path) => app.getPath?.(path),
1570
1572
  }, {
1571
1573
  maxAgeMs: config.restoreMaxAgeMinutes * 60_000,
1572
1574
  restoreSelf: config.recordSelf,
@@ -1606,16 +1608,6 @@ export default (app) => {
1606
1608
  const { host, port } = questdbEndpoints.ilp;
1607
1609
  publishRecordingStatus(host, port);
1608
1610
  }
1609
- })
1610
- .finally(() => {
1611
- // Only needed to shield the restore; tracking every context for the
1612
- // life of the plugin would be an unbounded set. Guarded on the
1613
- // generation so a slow restore settling after a restart does not
1614
- // disarm the tracking the NEW start just armed.
1615
- if (lifecycleGeneration !== restoreGeneration)
1616
- return;
1617
- liveContexts.clear();
1618
- trackLiveContexts = false;
1619
1611
  });
1620
1612
  }
1621
1613
  }
@@ -1656,10 +1648,6 @@ export default (app) => {
1656
1648
  startAbort?.abort();
1657
1649
  imageCheckAbort?.abort();
1658
1650
  pluginRunning = false;
1659
- // A start aborted before its restore ran would otherwise leave tracking
1660
- // armed, accumulating contexts for a restore that never happens.
1661
- trackLiveContexts = false;
1662
- liveContexts.clear();
1663
1651
  // Stale across a restart otherwise: the card would still claim a
1664
1652
  // restore count from the previous run.
1665
1653
  restoreSummary = null;