@camstack/addon-post-analysis 1.2.277 → 1.2.278

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.
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.251",
39
+ version: "1.2.252",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -81,7 +81,7 @@ async function r() {
81
81
  }
82
82
  },
83
83
  "@camstack/ui-library": {
84
- version: "1.2.201",
84
+ version: "1.2.202",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-MPbaDp6L.js");
5
+ const require_dist = require("../dist-BUtk8EU2.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -8219,6 +8219,99 @@ var NcAlarmLiveReadLog = class {
8219
8219
  }
8220
8220
  };
8221
8221
  //#endregion
8222
+ //#region src/notification-center/alerts/alert-episode.ts
8223
+ function decideAlert(input) {
8224
+ const previous = input.previous;
8225
+ if (previous === void 0) return input.alerting ? { kind: "raise" } : { kind: "seeded" };
8226
+ if (previous.alerting !== input.alerting) {
8227
+ if (input.alerting) return { kind: "raise" };
8228
+ if (previous.lastNotifiedAt <= 0) return { kind: "silent" };
8229
+ return input.notifyRecovery ? { kind: "recover" } : { kind: "silent" };
8230
+ }
8231
+ if (!input.alerting) return { kind: "silent" };
8232
+ if (input.renotifyMs <= 0) return { kind: "silent" };
8233
+ if (previous.lastNotifiedAt <= 0) return { kind: "silent" };
8234
+ if (input.nowMs - previous.lastNotifiedAt < input.renotifyMs) return { kind: "silent" };
8235
+ return {
8236
+ kind: "remind",
8237
+ reminderIndex: previous.reminders + 1
8238
+ };
8239
+ }
8240
+ /**
8241
+ * The row that survives a verdict.
8242
+ *
8243
+ * Split from {@link decideAlert} rather than returned with it because the
8244
+ * caller must be able to persist a row for a verdict it did NOT deliver — the
8245
+ * suppressed recovery is the whole reason (rule 3), and so is the silent seed.
8246
+ */
8247
+ function nextAlertRow(subject, previous, alerting, verdict, nowMs) {
8248
+ switch (verdict.kind) {
8249
+ case "seeded": return {
8250
+ subject,
8251
+ alerting,
8252
+ since: nowMs,
8253
+ lastNotifiedAt: 0,
8254
+ reminders: 0
8255
+ };
8256
+ case "raise":
8257
+ case "recover": return {
8258
+ subject,
8259
+ alerting,
8260
+ since: nowMs,
8261
+ lastNotifiedAt: nowMs,
8262
+ reminders: 0
8263
+ };
8264
+ case "remind": return {
8265
+ subject,
8266
+ alerting,
8267
+ since: previous?.since ?? nowMs,
8268
+ lastNotifiedAt: nowMs,
8269
+ reminders: verdict.reminderIndex
8270
+ };
8271
+ case "silent":
8272
+ if (previous === void 0) return {
8273
+ subject,
8274
+ alerting,
8275
+ since: nowMs,
8276
+ lastNotifiedAt: 0,
8277
+ reminders: 0
8278
+ };
8279
+ if (previous.alerting !== alerting) return {
8280
+ subject,
8281
+ alerting,
8282
+ since: nowMs,
8283
+ lastNotifiedAt: previous.lastNotifiedAt,
8284
+ reminders: 0
8285
+ };
8286
+ return previous;
8287
+ }
8288
+ }
8289
+ /**
8290
+ * The `recordId` a notification for this episode carries — and therefore, via
8291
+ * the outbox's fallback, its `dedupRef`.
8292
+ *
8293
+ * `reminderIndex: 0` is the opening notification (and the recovery, which
8294
+ * opens the `normal` episode). Every reminder appends `:r<n>`, which is what
8295
+ * stops the 7-day outbox dedup swallowing it.
8296
+ */
8297
+ function alertRecordId(subject, since, reminderIndex) {
8298
+ const base = `${subject}:${since}`;
8299
+ return reminderIndex > 0 ? `${base}:r${reminderIndex}` : base;
8300
+ }
8301
+ /**
8302
+ * The device a subject is about: `battery:3602` → 3602, and
8303
+ * `consumable:4385:sensor` → 4385. The id is the SECOND segment, never the
8304
+ * last — reading the last one turned every consumable subject into "no
8305
+ * device", which lost the `deviceId` tag on its log lines and the indexed
8306
+ * column on its durable row. `undefined` for a subject that names no device.
8307
+ */
8308
+ function alertSubjectDeviceId(subject) {
8309
+ const raw = subject.split(":")[1];
8310
+ if (raw === void 0 || raw === "") return void 0;
8311
+ const parsed = Number(raw);
8312
+ return Number.isInteger(parsed) ? parsed : void 0;
8313
+ }
8314
+ //#endregion
8222
8315
  //#region src/notification-center/alerts/alert-store.ts
8223
8316
  /**
8224
8317
  * @durable class=ledger owner=notification-center
@@ -8276,9 +8369,9 @@ var NC_ALERTS_INDEXES = [{
8276
8369
  name: "idx_nc_alerts_device",
8277
8370
  columns: ["deviceId"]
8278
8371
  }];
8372
+ /** The device a subject is about — see {@link alertSubjectDeviceId}. */
8279
8373
  function deviceIdOf(subject) {
8280
- const parsed = Number(subject.slice(subject.lastIndexOf(":") + 1));
8281
- return Number.isInteger(parsed) ? parsed : void 0;
8374
+ return alertSubjectDeviceId(subject);
8282
8375
  }
8283
8376
  var NC_ALERTS_SPEC = {
8284
8377
  collection: NC_ALERTS_COLLECTION,
@@ -8338,7 +8431,8 @@ var NcAlertStore = class {
8338
8431
  put: (row) => {
8339
8432
  this.ledger.put(row).catch(() => void 0);
8340
8433
  },
8341
- all: () => this.ledger.snapshot()
8434
+ all: () => this.ledger.snapshot(),
8435
+ remove: (subject) => this.ledger.forget(subject)
8342
8436
  };
8343
8437
  }
8344
8438
  };
@@ -9949,6 +10043,10 @@ function systemEventExclusion(condition, subject) {
9949
10043
  if (condition.deviceIds !== void 0 && applies("deviceIds")) {
9950
10044
  if (subject.deviceId === void 0 || !condition.deviceIds.includes(subject.deviceId)) return "deviceIds";
9951
10045
  }
10046
+ const excluded = condition.excludeDeviceIds;
10047
+ if (excluded !== void 0 && applies("deviceIds")) {
10048
+ if (subject.deviceId !== void 0 && excluded.includes(subject.deviceId)) return "excludeDeviceIds";
10049
+ }
9952
10050
  if (condition.deviceTypes !== void 0 && applies("deviceTypes")) {
9953
10051
  if (subject.deviceType === void 0 || !condition.deviceTypes.includes(subject.deviceType)) return "deviceTypes";
9954
10052
  }
@@ -11511,85 +11609,6 @@ function deliveryForKind(kind) {
11511
11609
  return kind;
11512
11610
  }
11513
11611
  //#endregion
11514
- //#region src/notification-center/alerts/alert-episode.ts
11515
- function decideAlert(input) {
11516
- const previous = input.previous;
11517
- if (previous === void 0) return input.alerting ? { kind: "raise" } : { kind: "seeded" };
11518
- if (previous.alerting !== input.alerting) {
11519
- if (input.alerting) return { kind: "raise" };
11520
- return input.notifyRecovery ? { kind: "recover" } : { kind: "silent" };
11521
- }
11522
- if (!input.alerting) return { kind: "silent" };
11523
- if (input.renotifyMs <= 0) return { kind: "silent" };
11524
- if (previous.lastNotifiedAt <= 0) return { kind: "silent" };
11525
- if (input.nowMs - previous.lastNotifiedAt < input.renotifyMs) return { kind: "silent" };
11526
- return {
11527
- kind: "remind",
11528
- reminderIndex: previous.reminders + 1
11529
- };
11530
- }
11531
- /**
11532
- * The row that survives a verdict.
11533
- *
11534
- * Split from {@link decideAlert} rather than returned with it because the
11535
- * caller must be able to persist a row for a verdict it did NOT deliver — the
11536
- * suppressed recovery is the whole reason (rule 3), and so is the silent seed.
11537
- */
11538
- function nextAlertRow(subject, previous, alerting, verdict, nowMs) {
11539
- switch (verdict.kind) {
11540
- case "seeded": return {
11541
- subject,
11542
- alerting,
11543
- since: nowMs,
11544
- lastNotifiedAt: 0,
11545
- reminders: 0
11546
- };
11547
- case "raise":
11548
- case "recover": return {
11549
- subject,
11550
- alerting,
11551
- since: nowMs,
11552
- lastNotifiedAt: nowMs,
11553
- reminders: 0
11554
- };
11555
- case "remind": return {
11556
- subject,
11557
- alerting,
11558
- since: previous?.since ?? nowMs,
11559
- lastNotifiedAt: nowMs,
11560
- reminders: verdict.reminderIndex
11561
- };
11562
- case "silent":
11563
- if (previous === void 0) return {
11564
- subject,
11565
- alerting,
11566
- since: nowMs,
11567
- lastNotifiedAt: 0,
11568
- reminders: 0
11569
- };
11570
- if (previous.alerting !== alerting) return {
11571
- subject,
11572
- alerting,
11573
- since: nowMs,
11574
- lastNotifiedAt: previous.lastNotifiedAt,
11575
- reminders: 0
11576
- };
11577
- return previous;
11578
- }
11579
- }
11580
- /**
11581
- * The `recordId` a notification for this episode carries — and therefore, via
11582
- * the outbox's fallback, its `dedupRef`.
11583
- *
11584
- * `reminderIndex: 0` is the opening notification (and the recovery, which
11585
- * opens the `normal` episode). Every reminder appends `:r<n>`, which is what
11586
- * stops the 7-day outbox dedup swallowing it.
11587
- */
11588
- function alertRecordId(subject, since, reminderIndex) {
11589
- const base = `${subject}:${since}`;
11590
- return reminderIndex > 0 ? `${base}:r${reminderIndex}` : base;
11591
- }
11592
- //#endregion
11593
11612
  //#region src/notification-center/alerts/battery-band.ts
11594
11613
  /**
11595
11614
  * The stand-in a provider writes for a binary NORMAL. Anything below it, on a
@@ -11651,6 +11670,18 @@ function deriveBatteryBand(input) {
11651
11670
  * that is already a FAULT — see `alert-episode.ts` rule 2 for why that is D141
11652
11671
  * rather than a loophole.
11653
11672
  */
11673
+ /** One hour — the longest a pending raise waits before it is offered again. */
11674
+ var PENDING_CADENCE_MAX_MS = 36e5;
11675
+ /**
11676
+ * How often a pending raise is offered again with the rules unchanged: the
11677
+ * re-notify interval, capped at one hour. The cap matters because the
11678
+ * cadence counts from the raise: with a 12 h interval, a rule whose schedule
11679
+ * opens for one hour a day could keep being missed. Reminders off ("say it
11680
+ * once") still has to say it once, hourly.
11681
+ */
11682
+ function pendingCadenceMs(settings) {
11683
+ return settings.renotifyMinutes > 0 ? Math.min(settings.renotifyMinutes * 6e4, PENDING_CADENCE_MAX_MS) : PENDING_CADENCE_MAX_MS;
11684
+ }
11654
11685
  var NcDeviceAlerts = class {
11655
11686
  deps;
11656
11687
  now;
@@ -11664,6 +11695,13 @@ var NcDeviceAlerts = class {
11664
11695
  lastReading = /* @__PURE__ */ new Map();
11665
11696
  /** The last reading per consumable SUBJECT, for the reminder sweep. */
11666
11697
  lastConsumable = /* @__PURE__ */ new Map();
11698
+ /**
11699
+ * Subject → WHEN its PENDING raise was last offered, and to which
11700
+ * {@link NcDeviceAlertsDeps.rulesVersion}. RAM only, on purpose: after a
11701
+ * restart every pending raise is offered once more, which is bounded by the
11702
+ * number of open episodes.
11703
+ */
11704
+ offeredAt = /* @__PURE__ */ new Map();
11667
11705
  constructor(deps) {
11668
11706
  this.deps = deps;
11669
11707
  this.now = deps.now ?? Date.now;
@@ -11749,34 +11787,94 @@ var NcDeviceAlerts = class {
11749
11787
  */
11750
11788
  tick() {
11751
11789
  const settings = this.deps.settings();
11790
+ this.retryPending(settings);
11752
11791
  if (settings.renotifyMinutes <= 0) return;
11753
11792
  for (const row of this.deps.rows.all()) {
11754
- if (!row.alerting) continue;
11755
- const consumable = this.lastConsumable.get(row.subject);
11756
- if (consumable !== void 0) {
11757
- this.apply(row.subject, true, settings, (facts) => incomingFromConsumableBand({
11758
- facts,
11759
- deviceId: consumable.deviceId,
11760
- band: "low",
11761
- item: consumable.item,
11762
- identity: this.deps.lookup(consumable.deviceId)
11763
- }));
11764
- continue;
11765
- }
11766
- const restatement = this.lastReading.get(row.subject);
11767
- if (restatement === void 0) continue;
11768
- this.apply(row.subject, true, settings, (facts) => incomingFromBatteryBand({
11769
- facts,
11770
- deviceId: restatement.deviceId,
11771
- band: "low",
11772
- reading: restatement.reading,
11773
- identity: this.deps.lookup(restatement.deviceId)
11774
- }));
11793
+ if (!row.alerting || isPending(row)) continue;
11794
+ const build = this.restatementOf(row.subject);
11795
+ if (build === null) continue;
11796
+ this.apply(row.subject, true, settings, build);
11775
11797
  }
11776
11798
  }
11777
- /** Drop a subject (device removed), so a re-adoption starts a fresh episode. */
11778
- forget(deviceId) {
11799
+ /**
11800
+ * Every device that holds an alert row — what the deleted-device reconcile
11801
+ * must look at besides the devices a rule names (a battery device no rule
11802
+ * names still has a row).
11803
+ */
11804
+ deviceIds() {
11805
+ const ids = /* @__PURE__ */ new Set();
11806
+ for (const row of this.deps.rows.all()) {
11807
+ const id = alertSubjectDeviceId(row.subject);
11808
+ if (id !== void 0) ids.add(id);
11809
+ }
11810
+ return [...ids];
11811
+ }
11812
+ /**
11813
+ * A DELETED device: drop its battery row and every consumable row, durable
11814
+ * and in RAM, so nothing restates it and a re-adoption starts a fresh
11815
+ * episode. Called only by the deleted-device prune (D633), after the second
11816
+ * read agreed the device is gone. Returns how many rows went.
11817
+ */
11818
+ async forget(deviceId) {
11819
+ const subjects = this.deps.rows.all().map((row) => row.subject).filter((subject) => alertSubjectDeviceId(subject) === deviceId);
11820
+ for (const subject of subjects) {
11821
+ this.lastReading.delete(subject);
11822
+ this.lastConsumable.delete(subject);
11823
+ this.offeredAt.delete(subject);
11824
+ await this.deps.rows.remove(subject);
11825
+ }
11779
11826
  this.lastReading.delete(`battery:${deviceId}`);
11827
+ return subjects.length;
11828
+ }
11829
+ /**
11830
+ * Offer every PENDING raise once more — when the rule set changed since its
11831
+ * last offer, OR when {@link pendingCadenceMs} has passed since it. The
11832
+ * cadence is what delivers a raise refused only by a rule's SCHEDULE (or any
11833
+ * other time-dependent gate): the rules do not change when the window
11834
+ * opens. The restatement is RAM: a row whose reading has not arrived since
11835
+ * the restart waits for it (the slice arrives on its own).
11836
+ */
11837
+ retryPending(settings) {
11838
+ const version = this.deps.rulesVersion();
11839
+ const nowMs = this.now();
11840
+ const cadenceMs = pendingCadenceMs(settings);
11841
+ for (const row of this.deps.rows.all()) {
11842
+ if (!isPending(row)) continue;
11843
+ const last = this.offeredAt.get(row.subject);
11844
+ const changed = last === void 0 || last.version !== version;
11845
+ if (!changed && nowMs - last.at < cadenceMs) continue;
11846
+ const build = this.restatementOf(row.subject);
11847
+ if (build === null) continue;
11848
+ this.logger()?.debug("device alert re-offered — no rule has matched it yet", {
11849
+ ...tagsOf(row.subject),
11850
+ meta: {
11851
+ subject: row.subject,
11852
+ since: row.since,
11853
+ reason: changed ? "rules" : "cadence"
11854
+ }
11855
+ });
11856
+ this.speak(row.subject, row, { kind: "raise" }, build);
11857
+ }
11858
+ }
11859
+ /** How to re-word a subject's alert from the last reading, or `null`. */
11860
+ restatementOf(subject) {
11861
+ const consumable = this.lastConsumable.get(subject);
11862
+ if (consumable !== void 0) return (facts) => incomingFromConsumableBand({
11863
+ facts,
11864
+ deviceId: consumable.deviceId,
11865
+ band: "low",
11866
+ item: consumable.item,
11867
+ identity: this.deps.lookup(consumable.deviceId)
11868
+ });
11869
+ const battery = this.lastReading.get(subject);
11870
+ if (battery !== void 0) return (facts) => incomingFromBatteryBand({
11871
+ facts,
11872
+ deviceId: battery.deviceId,
11873
+ band: "low",
11874
+ reading: battery.reading,
11875
+ identity: this.deps.lookup(battery.deviceId)
11876
+ });
11877
+ return null;
11780
11878
  }
11781
11879
  /**
11782
11880
  * The shared half: decide, persist, and speak only when the verdict says to.
@@ -11801,16 +11899,63 @@ var NcDeviceAlerts = class {
11801
11899
  this.report(subject, verdict, alerting);
11802
11900
  return;
11803
11901
  }
11902
+ this.speak(subject, next, verdict, (facts) => build(facts, alerting));
11903
+ }
11904
+ /**
11905
+ * Emit one verdict through the door, and — for a RAISE — hear whether any
11906
+ * rule matched it. A raise no rule matched is put back to PENDING
11907
+ * (`lastNotifiedAt: 0`), unless the row moved meanwhile; it is re-offered
11908
+ * by {@link retryPending} when the rules change. A reminder or a recovery is
11909
+ * not re-offered: the episode was already announced (or is over).
11910
+ */
11911
+ speak(subject, row, verdict, build) {
11912
+ const nowMs = this.now();
11804
11913
  const reminderIndex = verdict.kind === "remind" ? verdict.reminderIndex : 0;
11805
- this.deps.emit(build({
11806
- eventId: alertRecordId(subject, next.since, reminderIndex),
11914
+ const event = build({
11915
+ eventId: alertRecordId(subject, row.since, reminderIndex),
11807
11916
  timestamp: nowMs,
11808
11917
  category: require_dist.EventCategory.DeviceStateChanged,
11809
11918
  data: {
11810
11919
  alertVerdict: verdict.kind,
11811
11920
  reminderIndex
11812
11921
  }
11813
- }, alerting));
11922
+ });
11923
+ if (verdict.kind !== "raise") {
11924
+ this.deps.emit(event).catch(() => void 0);
11925
+ return;
11926
+ }
11927
+ this.offeredAt.set(subject, {
11928
+ version: this.deps.rulesVersion(),
11929
+ at: nowMs
11930
+ });
11931
+ this.deps.emit(event).then((heard) => this.settleRaise(subject, row.since, heard, nowMs)).catch(() => void 0);
11932
+ }
11933
+ /** The door answered a raise. */
11934
+ settleRaise(subject, since, heard, nowMs) {
11935
+ const current = this.deps.rows.get(subject);
11936
+ if (current === void 0 || !current.alerting || current.since !== since) return;
11937
+ if (heard) {
11938
+ this.offeredAt.delete(subject);
11939
+ if (current.lastNotifiedAt !== nowMs) this.deps.rows.put({
11940
+ ...current,
11941
+ lastNotifiedAt: nowMs
11942
+ });
11943
+ return;
11944
+ }
11945
+ if (current.lastNotifiedAt !== 0) this.deps.rows.put({
11946
+ ...current,
11947
+ lastNotifiedAt: 0
11948
+ });
11949
+ this.logger()?.info("device alert matched NO rule — kept pending, offered again on a rule change or within the hour", {
11950
+ ...tagsOf(subject),
11951
+ meta: {
11952
+ subject,
11953
+ since
11954
+ }
11955
+ });
11956
+ }
11957
+ logger() {
11958
+ return this.deps.logger;
11814
11959
  }
11815
11960
  /**
11816
11961
  * A dropped branch is never silent. The suppressed recovery in particular:
@@ -11820,7 +11965,7 @@ var NcDeviceAlerts = class {
11820
11965
  report(subject, verdict, alerting) {
11821
11966
  const logger = this.deps.logger;
11822
11967
  if (logger === void 0) return;
11823
- const deviceId = deviceIdOfSubject(subject);
11968
+ const deviceId = alertSubjectDeviceId(subject);
11824
11969
  const tags = deviceId !== void 0 ? { deviceId } : {};
11825
11970
  if (verdict.kind === "silent" && !alerting) {
11826
11971
  logger.debug("device alert cleared without a notification", {
@@ -11844,11 +11989,16 @@ var NcDeviceAlerts = class {
11844
11989
  function speaks(verdict) {
11845
11990
  return verdict.kind === "raise" || verdict.kind === "recover" || verdict.kind === "remind";
11846
11991
  }
11847
- /** `battery:3602` → 3602. Undefined for a subject that names no device. */
11848
- function deviceIdOfSubject(subject) {
11849
- const raw = subject.slice(subject.lastIndexOf(":") + 1);
11850
- const parsed = Number(raw);
11851
- return Number.isInteger(parsed) ? parsed : void 0;
11992
+ /**
11993
+ * A raise no rule matched: alerting, and never announced. A row in the
11994
+ * alerting state is otherwise always stamped, because it was raised.
11995
+ */
11996
+ function isPending(row) {
11997
+ return row.alerting && row.lastNotifiedAt <= 0;
11998
+ }
11999
+ function tagsOf(subject) {
12000
+ const deviceId = alertSubjectDeviceId(subject);
12001
+ return deviceId !== void 0 ? { tags: { deviceId } } : {};
11852
12002
  }
11853
12003
  //#endregion
11854
12004
  //#region src/notification-center/cap-caller.ts
@@ -19084,6 +19234,7 @@ var NcDeletedSubjectPruner = class {
19084
19234
  return this.serialise(async () => {
19085
19235
  if (!this.ready("event", deviceId)) return;
19086
19236
  if (!await this.confirmDeleted(deviceId, "event")) return;
19237
+ await this.forgetAlerts(deviceId, "event");
19087
19238
  const deleted = new Set([deviceId]);
19088
19239
  await this.apply(planDevicePrune(this.snapshot(), deleted, this.guard()), "event", (fresh) => planDevicePrune(fresh, deleted, this.guard()));
19089
19240
  }, deviceId);
@@ -19093,15 +19244,17 @@ var NcDeletedSubjectPruner = class {
19093
19244
  return this.serialise(async () => {
19094
19245
  if (!this.ready("reconcile")) return;
19095
19246
  const directory = this.deps.directory;
19096
- const candidates = [...referencedDeviceIds(this.snapshot())].filter((id) => directory.absentFromLastTwoAnswers(id));
19247
+ const alerting = new Set(this.deps.alerts?.deviceIds() ?? []);
19248
+ const candidates = [...new Set([...referencedDeviceIds(this.snapshot()), ...alerting])].filter((id) => directory.absentFromLastTwoAnswers(id));
19097
19249
  if (candidates.length === 0) return;
19098
19250
  const first = planDevicePrune(this.snapshot(), new Set(candidates), this.guard());
19099
- if (isEmptyPlan(first)) return;
19100
- const subjects = [...planSubjects(first)].map(Number);
19251
+ const subjects = [...new Set([...[...planSubjects(first)].map(Number), ...candidates.filter((id) => alerting.has(id))])];
19252
+ if (subjects.length === 0) return;
19101
19253
  if (!this.withinBound("device", subjects.map(String))) return;
19102
19254
  const confirmed = /* @__PURE__ */ new Set();
19103
19255
  for (const id of subjects) if (await this.confirmDeleted(id, "reconcile")) confirmed.add(id);
19104
19256
  if (confirmed.size === 0) return;
19257
+ for (const id of confirmed) if (alerting.has(id)) await this.forgetAlerts(id, "reconcile");
19105
19258
  await this.apply(planDevicePrune(this.snapshot(), confirmed, this.guard()), "reconcile", (fresh) => planDevicePrune(fresh, confirmed, this.guard()));
19106
19259
  });
19107
19260
  }
@@ -19179,6 +19332,33 @@ var NcDeletedSubjectPruner = class {
19179
19332
  return false;
19180
19333
  }
19181
19334
  }
19335
+ /**
19336
+ * Drop a CONFIRMED-deleted device's alert rows. A failure is logged and
19337
+ * retried by the next reconcile, which still sees the row.
19338
+ */
19339
+ async forgetAlerts(deviceId, source) {
19340
+ const alerts = this.deps.alerts;
19341
+ if (alerts === void 0) return;
19342
+ try {
19343
+ const forgotten = await alerts.forget(deviceId);
19344
+ if (forgotten === 0) return;
19345
+ this.deps.logger.info("deleted device alerts forgotten", {
19346
+ tags: { deviceId },
19347
+ meta: {
19348
+ source,
19349
+ rows: forgotten
19350
+ }
19351
+ });
19352
+ } catch (err) {
19353
+ this.deps.logger.warn("deleted device alerts NOT forgotten — retried next tick", {
19354
+ tags: { deviceId },
19355
+ meta: {
19356
+ source,
19357
+ error: String(err)
19358
+ }
19359
+ });
19360
+ }
19361
+ }
19182
19362
  withinBound(subjectKind, subjects) {
19183
19363
  if (subjects.length <= 16) {
19184
19364
  this.lastAbort.delete(subjectKind);
@@ -28268,9 +28448,10 @@ var NotificationCenter = class NotificationCenter {
28268
28448
  logger: this.logger.child("device-alerts")
28269
28449
  });
28270
28450
  this.deviceAlerts = new NcDeviceAlerts({
28271
- emit: (event) => {
28272
- this.consumeEvent(event);
28451
+ emit: async (event) => {
28452
+ return (await this.consumeEventAndReport(event)).outcomes.some((outcome) => outcome.matched);
28273
28453
  },
28454
+ rulesVersion: () => this.rulesFingerprint(),
28274
28455
  settings: () => this.deviceAlertSettings(),
28275
28456
  lookup: (deviceId) => this.deviceDirectory.get(deviceId),
28276
28457
  rows: this.alertStore.port(),
@@ -28372,6 +28553,7 @@ var NotificationCenter = class NotificationCenter {
28372
28553
  summary: this.summaryRules,
28373
28554
  mutes: this.deviceMutes,
28374
28555
  alarm: () => this.alarmPanel,
28556
+ alerts: this.deviceAlerts,
28375
28557
  directory: this.deviceDirectory,
28376
28558
  isDeviceDeleted: deps.isDeviceDeleted,
28377
28559
  targetCatalog: () => this.dispatcher.catalogSnapshot()
@@ -31077,6 +31259,14 @@ var NotificationCenter = class NotificationCenter {
31077
31259
  * wiring that silently disabled the feature would be indistinguishable from
31078
31260
  * a hub with no low batteries on it.
31079
31261
  */
31262
+ /**
31263
+ * Which rules exist, and at which revision — the question "did anything
31264
+ * change that could make an undelivered alert deliverable now?". Cheap: the
31265
+ * rule mirror is in RAM and holds tens of rows.
31266
+ */
31267
+ rulesFingerprint() {
31268
+ return this.rules.list().map((rule) => `${rule.id}@${String(rule.updatedAt)}`).join("|");
31269
+ }
31080
31270
  deviceAlertSettings() {
31081
31271
  return this.deps.readDeviceAlertSettings?.() ?? {
31082
31272
  batteryLowPercent: NC_DEVICE_ALERT_FALLBACK.batteryLowPercent,