@camstack/addon-post-analysis 1.2.9 → 1.2.11

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.
@@ -1,4 +1,4 @@
1
- import { A as BaseAddon, B as EventCategory, C as notificationRulesCapability, D as videoclipsCapability, E as subKindsOf, F as boolean, I as literal, L as number, M as createEvent, N as hydrateSchema, O as zoneAnalyticsCapability, P as array, R as object, S as nodePin, T as plateGalleryCapability, _ as customAction, a as NC_CONDITION_CATALOG, b as faceGalleryCapability, c as NcRuleInputSchema, d as NcTaxonomySchema, f as OpsLogEntrySchema, g as cosineSimilarity, h as buildEventKindDescriptor, i as MACRO_LABELS, j as DeviceType, k as errMsg, l as NcRulePatchSchema, m as audioMetricsCapability, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as addonWidgetsSourceCapability, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as defineCustomActions, w as pipelineAnalyticsCapability, z as string } from "../dist-Dm1I_IVp.mjs";
1
+ import { A as BaseAddon, B as EventCategory, C as notificationRulesCapability, D as videoclipsCapability, E as subKindsOf, F as boolean, I as literal, L as number, M as createEvent, N as hydrateSchema, O as zoneAnalyticsCapability, P as array, R as object, S as nodePin, T as plateGalleryCapability, _ as customAction, a as NC_CONDITION_CATALOG, b as faceGalleryCapability, c as NcRuleInputSchema, d as NcTaxonomySchema, f as OpsLogEntrySchema, g as cosineSimilarity, h as buildEventKindDescriptor, i as MACRO_LABELS, j as DeviceType, k as errMsg, l as NcRulePatchSchema, m as audioMetricsCapability, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as addonWidgetsSourceCapability, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as defineCustomActions, w as pipelineAnalyticsCapability, z as string } from "../dist-EaH6ywaC.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import sharp from "sharp";
4
4
  //#region src/pipeline-analytics/videoclips-provider.ts
@@ -4342,17 +4342,25 @@ var NcDispatcher = class {
4342
4342
  };
4343
4343
  function buildTemplateVars(entry, deviceName) {
4344
4344
  const subject = entry.payload.subject;
4345
+ const occupancy = subject.occupancy;
4345
4346
  return {
4346
4347
  camera: deviceName,
4347
4348
  class: subject.className,
4348
4349
  label: subject.label ?? "",
4349
4350
  zones: subject.zones.join(", "),
4350
- zone: subject.zones[0] ?? "",
4351
+ zone: occupancy?.zone ?? subject.zones[0] ?? "",
4351
4352
  confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
4352
4353
  time: new Date(subject.timestamp).toLocaleTimeString(),
4353
- rule: entry.payload.ruleName
4354
+ rule: entry.payload.ruleName,
4355
+ count: occupancy !== void 0 ? `${occupancy.count}` : "",
4356
+ capacity: occupancy !== void 0 ? `${occupancy.capacity}` : "",
4357
+ op: occupancy !== void 0 ? occupancyOpWord(occupancy.occupied) : ""
4354
4358
  };
4355
4359
  }
4360
+ /** The human-readable edge polarity used by `{{op}}` and the default body. */
4361
+ function occupancyOpWord(occupied) {
4362
+ return occupied ? "occupied" : "free";
4363
+ }
4356
4364
  /** `{{var}}` interpolation; missing vars render empty. Null template → null. */
4357
4365
  function renderTemplate(template, vars) {
4358
4366
  if (template === void 0 || template.trim().length === 0) return null;
@@ -4360,6 +4368,8 @@ function renderTemplate(template, vars) {
4360
4368
  }
4361
4369
  function defaultBody(entry, deviceName) {
4362
4370
  const subject = entry.payload.subject;
4371
+ const occupancy = subject.occupancy;
4372
+ if (occupancy !== void 0) return `${occupancy.zone ?? deviceName} ${occupancyOpWord(occupancy.occupied)} (${occupancy.count}/${occupancy.capacity})`;
4363
4373
  const label = subject.label !== void 0 ? ` (${subject.label})` : "";
4364
4374
  const zones = subject.zones.length > 0 ? ` in ${subject.zones.join(", ")}` : "";
4365
4375
  const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
@@ -4382,63 +4392,236 @@ function clampPriority(priority) {
4382
4392
  return Math.max(1, Math.min(5, Math.round(priority)));
4383
4393
  }
4384
4394
  //#endregion
4385
- //#region src/notification-center/outbox.ts
4386
- var NC_OUTBOX_COLLECTION = "notification-center:outbox";
4387
- var NC_META_COLLECTION = "notification-center:meta";
4388
- var NC_WATERMARK_KEY = "watermark";
4389
- var NC_OUTBOX_COLUMNS = [
4395
+ //#region src/notification-center/occupancy-watcher.ts
4396
+ /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
4397
+ var FRAME_SCOPE = "@frame";
4398
+ var ALL_CLASSES = "@all";
4399
+ /** Prefix marking the trailing threshold segment (`t<n>`) — makes the grammar
4400
+ * self-describing so a legacy three-segment key can never be mis-parsed as a
4401
+ * four-segment one (the last segment of a legacy key is a className, never a
4402
+ * `t<digits>` token). */
4403
+ var THRESHOLD_PREFIX = "t";
4404
+ var THRESHOLD_SEGMENT = /^t(\d+)$/;
4405
+ function occupancyKey(deviceId, zoneId, className, threshold) {
4406
+ return `${deviceId}|${zoneId ?? FRAME_SCOPE}|${className ?? ALL_CLASSES}|${THRESHOLD_PREFIX}${threshold}`;
4407
+ }
4408
+ /**
4409
+ * Inverse of {@link occupancyKey} — the ONE place the key grammar is decoded, so
4410
+ * the durable store (whose column schema drops the derivable
4411
+ * `zoneId`/`className`/`threshold`) can reconstruct the full scope on reseed.
4412
+ *
4413
+ * The four-segment grammar is asserted EXACTLY: at least four segments AND a
4414
+ * trailing `t<digits>` threshold marker. A legacy three-segment key (no marker)
4415
+ * is REJECTED (`null`) rather than positionally mis-parsed — the durable store
4416
+ * then SKIPS that row, so a pre-amendment persisted row cold-re-observes instead
4417
+ * of hydrating a corrupt scope (className←threshold, zoneId←class). `deviceId`
4418
+ * is the leading numeric segment, `threshold` the trailing `t<n>`, `className`
4419
+ * the segment before it, and the (possibly `|`-containing) `zoneId` everything
4420
+ * between. Exact per-segment count is impossible because a `zoneId` may itself
4421
+ * contain `|`; the trailing marker is the unambiguous grammar discriminator.
4422
+ */
4423
+ function parseOccupancyKey(key) {
4424
+ const parts = key.split("|");
4425
+ if (parts.length < 4) return null;
4426
+ const deviceId = Number(parts[0]);
4427
+ if (!Number.isFinite(deviceId)) return null;
4428
+ const thresholdMatch = THRESHOLD_SEGMENT.exec(parts[parts.length - 1] ?? "");
4429
+ if (thresholdMatch === null) return null;
4430
+ const threshold = Number(thresholdMatch[1]);
4431
+ const className = parts[parts.length - 2] ?? ALL_CLASSES;
4432
+ const zoneId = parts.slice(1, -2).join("|");
4433
+ return {
4434
+ deviceId,
4435
+ ...zoneId !== FRAME_SCOPE ? { zoneId } : {},
4436
+ ...className !== ALL_CLASSES ? { className } : {},
4437
+ threshold
4438
+ };
4439
+ }
4440
+ /** Device-agnostic partial key (`zone|class|t<threshold>`) — the merge/watch
4441
+ * granularity. Distinct thresholds are distinct partial keys (per-threshold). */
4442
+ function partialKey(zoneId, className, threshold) {
4443
+ return `${zoneId ?? FRAME_SCOPE}|${className ?? ALL_CLASSES}|${THRESHOLD_PREFIX}${threshold}`;
4444
+ }
4445
+ /** The device-agnostic partial key of a full state/edge key — everything after
4446
+ * the leading `deviceId|` segment. */
4447
+ function partialKeyOf(key) {
4448
+ const idx = key.indexOf("|");
4449
+ return idx < 0 ? key : key.slice(idx + 1);
4450
+ }
4451
+ var OccupancyWatcher = class {
4452
+ /** Watched specs, keyed by their device-agnostic partial key. */
4453
+ watched = /* @__PURE__ */ new Map();
4454
+ /** Confirmed + pending state, keyed by the full {@link OccupancyKey}. */
4455
+ states = /* @__PURE__ */ new Map();
4456
+ /**
4457
+ * Replace the watched key set (rule-driven — recomputed on rule change).
4458
+ * Each distinct `(zone, class, threshold)` is its OWN watched partial key
4459
+ * (per-threshold edges — NO min-threshold merge). Specs that collide on the
4460
+ * SAME `(zone, class, threshold)` merge to the MAX sustain (longest debounce),
4461
+ * so one watcher serves those co-threshold rules. Confirmed state for a key
4462
+ * that is no longer watched is DROPPED (bounds the RAM map + lets the caller
4463
+ * prune the durable row to the active set); a still-watched key retains its
4464
+ * level (re-evaluated on the next `observe`).
4465
+ */
4466
+ setWatchedKeys(specs) {
4467
+ this.watched.clear();
4468
+ for (const spec of specs) {
4469
+ const pk = partialKey(spec.zoneId, spec.className, spec.threshold);
4470
+ const existing = this.watched.get(pk);
4471
+ if (existing === void 0) {
4472
+ this.watched.set(pk, {
4473
+ ...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
4474
+ ...spec.className !== void 0 ? { className: spec.className } : {},
4475
+ threshold: spec.threshold,
4476
+ sustainSeconds: spec.sustainSeconds
4477
+ });
4478
+ continue;
4479
+ }
4480
+ this.watched.set(pk, {
4481
+ ...existing,
4482
+ sustainSeconds: Math.max(existing.sustainSeconds, spec.sustainSeconds)
4483
+ });
4484
+ }
4485
+ for (const key of [...this.states.keys()]) if (!this.watched.has(partialKeyOf(key))) this.states.delete(key);
4486
+ }
4487
+ /**
4488
+ * Feed one camera snapshot at time `now`, returning the edges that COMMIT on
4489
+ * this tick (usually none). Every watched key is evaluated for `deviceId`;
4490
+ * fail-closed keys (absent zone) are skipped and hold no state.
4491
+ */
4492
+ observe(deviceId, snapshot, now) {
4493
+ const edges = [];
4494
+ for (const spec of this.watched.values()) {
4495
+ const resolved = resolveScope(snapshot, spec);
4496
+ if (resolved === null) continue;
4497
+ const key = occupancyKey(deviceId, spec.zoneId, spec.className, spec.threshold);
4498
+ const edge = step(this.stateFor(key, deviceId, spec, now), spec, resolved, now);
4499
+ if (edge !== null) edges.push(edge);
4500
+ }
4501
+ return edges;
4502
+ }
4503
+ /** Reseed confirmed state from durable rows (boot). Only rows whose key is
4504
+ * currently WATCHED are restored — an orphaned durable row (its rule gone)
4505
+ * is dropped, keeping the RAM map bounded to the active set. Pending edges
4506
+ * are not restored (fail-closed — they re-open on the next snapshots). */
4507
+ hydrate(rows) {
4508
+ for (const row of rows) {
4509
+ if (!this.watched.has(partialKeyOf(row.key))) continue;
4510
+ this.states.set(row.key, {
4511
+ deviceId: row.deviceId,
4512
+ ...row.zoneId !== void 0 ? { zoneId: row.zoneId } : {},
4513
+ ...row.className !== void 0 ? { className: row.className } : {},
4514
+ threshold: row.threshold,
4515
+ confirmedCount: row.confirmedCount,
4516
+ occupied: row.occupied,
4517
+ lastChangeAt: row.lastChangeAt
4518
+ });
4519
+ }
4520
+ }
4521
+ /** Snapshot the CONFIRMED state for durable persistence (pending excluded). */
4522
+ snapshotState() {
4523
+ const rows = [];
4524
+ for (const [key, state] of this.states) rows.push({
4525
+ key,
4526
+ deviceId: state.deviceId,
4527
+ ...state.zoneId !== void 0 ? { zoneId: state.zoneId } : {},
4528
+ ...state.className !== void 0 ? { className: state.className } : {},
4529
+ threshold: state.threshold,
4530
+ confirmedCount: state.confirmedCount,
4531
+ occupied: state.occupied,
4532
+ lastChangeAt: state.lastChangeAt,
4533
+ updatedAt: state.lastChangeAt
4534
+ });
4535
+ return rows;
4536
+ }
4537
+ stateFor(key, deviceId, spec, now) {
4538
+ const existing = this.states.get(key);
4539
+ if (existing !== void 0) return existing;
4540
+ const fresh = {
4541
+ deviceId,
4542
+ ...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
4543
+ ...spec.className !== void 0 ? { className: spec.className } : {},
4544
+ threshold: spec.threshold,
4545
+ confirmedCount: 0,
4546
+ occupied: false,
4547
+ lastChangeAt: now
4548
+ };
4549
+ this.states.set(key, fresh);
4550
+ return fresh;
4551
+ }
4552
+ };
4553
+ /** Read the count (+ zone name) for a spec, or `null` when fail-closed. */
4554
+ function resolveScope(snapshot, spec) {
4555
+ if (spec.zoneId === void 0) return { count: spec.className === void 0 ? snapshot.frame.totalObjects : snapshot.frame.byClass[spec.className] ?? 0 };
4556
+ const zone = snapshot.zones.find((z) => z.zoneId === spec.zoneId);
4557
+ if (zone === void 0) return null;
4558
+ return {
4559
+ count: spec.className === void 0 ? zone.totalObjects : zone.byClass[spec.className] ?? 0,
4560
+ zoneName: zone.zoneName
4561
+ };
4562
+ }
4563
+ /**
4564
+ * Advance one key's state by one observation. Mutates `state` in place (the
4565
+ * watcher owns it) and returns a committed edge, or `null`.
4566
+ */
4567
+ function step(state, spec, resolved, now) {
4568
+ const rawOccupied = resolved.count >= spec.threshold;
4569
+ const sustainMs = spec.sustainSeconds * 1e3;
4570
+ if (rawOccupied === state.occupied) {
4571
+ state.pendingTargetOccupied = void 0;
4572
+ state.pendingSince = void 0;
4573
+ return null;
4574
+ }
4575
+ if (state.pendingTargetOccupied !== rawOccupied) {
4576
+ state.pendingTargetOccupied = rawOccupied;
4577
+ state.pendingSince = now;
4578
+ }
4579
+ if (now - (state.pendingSince ?? now) < sustainMs) return null;
4580
+ const previousCount = state.confirmedCount;
4581
+ state.confirmedCount = resolved.count;
4582
+ state.occupied = rawOccupied;
4583
+ state.lastChangeAt = now;
4584
+ state.pendingTargetOccupied = void 0;
4585
+ state.pendingSince = void 0;
4586
+ return {
4587
+ deviceId: state.deviceId,
4588
+ ...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
4589
+ ...resolved.zoneName !== void 0 ? { zoneName: resolved.zoneName } : {},
4590
+ ...spec.className !== void 0 ? { className: spec.className } : {},
4591
+ count: resolved.count,
4592
+ previousCount,
4593
+ occupied: rawOccupied,
4594
+ threshold: spec.threshold,
4595
+ timestamp: now
4596
+ };
4597
+ }
4598
+ //#endregion
4599
+ //#region src/notification-center/occupancy-store.ts
4600
+ var NC_OCCUPANCY_COLLECTION = "notification-center:occupancy";
4601
+ var NC_OCCUPANCY_COLUMNS = [
4390
4602
  {
4391
- name: "id",
4603
+ name: "key",
4392
4604
  type: "TEXT",
4393
4605
  primaryKey: true,
4394
4606
  notNull: true
4395
4607
  },
4396
- {
4397
- name: "ruleId",
4398
- type: "TEXT",
4399
- notNull: true
4400
- },
4401
- {
4402
- name: "targetId",
4403
- type: "TEXT",
4404
- notNull: true
4405
- },
4406
4608
  {
4407
4609
  name: "deviceId",
4408
4610
  type: "INTEGER",
4409
4611
  notNull: true
4410
4612
  },
4411
4613
  {
4412
- name: "recordKind",
4413
- type: "TEXT",
4414
- notNull: true
4415
- },
4416
- {
4417
- name: "recordId",
4418
- type: "TEXT",
4419
- notNull: true
4420
- },
4421
- {
4422
- name: "trackId",
4423
- type: "TEXT"
4424
- },
4425
- {
4426
- name: "status",
4427
- type: "TEXT",
4428
- notNull: true
4429
- },
4430
- {
4431
- name: "attempts",
4614
+ name: "confirmedCount",
4432
4615
  type: "INTEGER",
4433
4616
  notNull: true
4434
4617
  },
4435
4618
  {
4436
- name: "nextAttemptAt",
4437
- type: "INTEGER",
4619
+ name: "occupied",
4620
+ type: "BOOLEAN",
4438
4621
  notNull: true
4439
4622
  },
4440
4623
  {
4441
- name: "createdAt",
4624
+ name: "lastChangeAt",
4442
4625
  type: "INTEGER",
4443
4626
  notNull: true
4444
4627
  },
@@ -4446,10 +4629,216 @@ var NC_OUTBOX_COLUMNS = [
4446
4629
  name: "updatedAt",
4447
4630
  type: "INTEGER",
4448
4631
  notNull: true
4449
- },
4450
- {
4451
- name: "lastError",
4452
- type: "TEXT"
4632
+ }
4633
+ ];
4634
+ var NC_OCCUPANCY_INDEXES = [{
4635
+ name: "idx_nc_occupancy_device",
4636
+ columns: ["deviceId"]
4637
+ }];
4638
+ /** Query cap — a per-(device, zone, class) key set is small; this is a
4639
+ * generous ceiling that still bounds a pathological read. */
4640
+ var LOAD_LIMIT = 1e5;
4641
+ var OccupancyStore = class {
4642
+ cache = /* @__PURE__ */ new Map();
4643
+ store;
4644
+ logger;
4645
+ constructor(deps) {
4646
+ this.store = deps.store;
4647
+ this.logger = deps.logger;
4648
+ }
4649
+ static async declare(store) {
4650
+ await store.declareCollection.mutate({
4651
+ collection: NC_OCCUPANCY_COLLECTION,
4652
+ columns: [...NC_OCCUPANCY_COLUMNS],
4653
+ indexes: [...NC_OCCUPANCY_INDEXES]
4654
+ });
4655
+ }
4656
+ /**
4657
+ * Reseed the confirmed edge-state from the store (boot) — replaces the cache
4658
+ * wholesale and returns the rows for {@link OccupancyWatcher.hydrate}. A row
4659
+ * whose scalars/key no longer parse is skipped with a warning (a degraded row
4660
+ * must never crash the reseed). Best-effort: a store error yields `[]` and a
4661
+ * cold watcher, never a throw into boot.
4662
+ */
4663
+ async load() {
4664
+ try {
4665
+ const records = await this.store.query.query({
4666
+ collection: NC_OCCUPANCY_COLLECTION,
4667
+ filter: { limit: LOAD_LIMIT }
4668
+ });
4669
+ this.cache.clear();
4670
+ let skipped = 0;
4671
+ for (const record of records) {
4672
+ const row = recordToRow(record.id, record.data);
4673
+ if (row === null) {
4674
+ skipped += 1;
4675
+ continue;
4676
+ }
4677
+ this.cache.set(row.key, row);
4678
+ }
4679
+ this.logger.debug("occupancy state loaded", { meta: {
4680
+ keys: this.cache.size,
4681
+ ...skipped > 0 ? { skippedInvalid: skipped } : {}
4682
+ } });
4683
+ return [...this.cache.values()];
4684
+ } catch (err) {
4685
+ this.logger.warn("occupancy state load failed", { meta: { error: String(err) } });
4686
+ return [];
4687
+ }
4688
+ }
4689
+ /** The in-RAM confirmed-state mirror (post-{@link load}/{@link persist}). */
4690
+ snapshot() {
4691
+ return [...this.cache.values()];
4692
+ }
4693
+ /**
4694
+ * Durably upsert one confirmed edge-state row (write-through: the store FIRST,
4695
+ * then the cache — a failed persist never leaves a phantom in-RAM level). The
4696
+ * `key` is the PK, so re-persisting a key advances it in place.
4697
+ */
4698
+ async persist(row) {
4699
+ await this.store.set.mutate({
4700
+ collection: NC_OCCUPANCY_COLLECTION,
4701
+ key: row.key,
4702
+ value: rowToValue(row)
4703
+ });
4704
+ this.cache.set(row.key, row);
4705
+ }
4706
+ /**
4707
+ * Prune every persisted key NOT in `activeKeys` (the currently watched set) —
4708
+ * the bounded-row-count guarantee when rules stop watching a key. Returns the
4709
+ * number of rows dropped. Best-effort per row: a failed delete is logged and
4710
+ * the key retained (retried next prune) rather than aborting the sweep.
4711
+ */
4712
+ async pruneExcept(activeKeys) {
4713
+ let pruned = 0;
4714
+ for (const key of [...this.cache.keys()]) {
4715
+ if (activeKeys.has(key)) continue;
4716
+ try {
4717
+ await this.store.delete.mutate({
4718
+ collection: NC_OCCUPANCY_COLLECTION,
4719
+ key
4720
+ });
4721
+ this.cache.delete(key);
4722
+ pruned += 1;
4723
+ } catch (err) {
4724
+ this.logger.debug("occupancy prune delete failed", { meta: {
4725
+ key,
4726
+ error: String(err)
4727
+ } });
4728
+ }
4729
+ }
4730
+ return pruned;
4731
+ }
4732
+ };
4733
+ /** The persisted column map for a row (the `key` PK is passed separately). */
4734
+ function rowToValue(row) {
4735
+ return {
4736
+ deviceId: row.deviceId,
4737
+ confirmedCount: row.confirmedCount,
4738
+ occupied: row.occupied,
4739
+ lastChangeAt: row.lastChangeAt,
4740
+ updatedAt: row.updatedAt
4741
+ };
4742
+ }
4743
+ /**
4744
+ * Structurally validate a persisted record and reconstruct the full
4745
+ * {@link OccupancyStateRow} (deriving `zoneId`/`className`/`threshold` from the
4746
+ * key). Returns `null` for any malformed row — including a legacy
4747
+ * three-segment key with no `t<n>` threshold marker, which
4748
+ * {@link parseOccupancyKey} rejects — so the caller skips it (cold re-observe)
4749
+ * rather than hydrating a mis-parsed scope.
4750
+ */
4751
+ function recordToRow(key, data) {
4752
+ const scope = parseOccupancyKey(key);
4753
+ if (scope === null) return null;
4754
+ const deviceId = Number(data["deviceId"]);
4755
+ const confirmedCount = Number(data["confirmedCount"]);
4756
+ const lastChangeAt = Number(data["lastChangeAt"]);
4757
+ const updatedAt = Number(data["updatedAt"]);
4758
+ if (!Number.isFinite(deviceId) || !Number.isFinite(confirmedCount) || !Number.isFinite(lastChangeAt) || !Number.isFinite(updatedAt)) return null;
4759
+ const rawOccupied = data["occupied"];
4760
+ const occupied = rawOccupied === true || rawOccupied === 1;
4761
+ return {
4762
+ key,
4763
+ deviceId,
4764
+ ...scope.zoneId !== void 0 ? { zoneId: scope.zoneId } : {},
4765
+ ...scope.className !== void 0 ? { className: scope.className } : {},
4766
+ threshold: scope.threshold,
4767
+ confirmedCount,
4768
+ occupied,
4769
+ lastChangeAt,
4770
+ updatedAt
4771
+ };
4772
+ }
4773
+ //#endregion
4774
+ //#region src/notification-center/outbox.ts
4775
+ var NC_OUTBOX_COLLECTION = "notification-center:outbox";
4776
+ var NC_META_COLLECTION = "notification-center:meta";
4777
+ var NC_WATERMARK_KEY = "watermark";
4778
+ var NC_OUTBOX_COLUMNS = [
4779
+ {
4780
+ name: "id",
4781
+ type: "TEXT",
4782
+ primaryKey: true,
4783
+ notNull: true
4784
+ },
4785
+ {
4786
+ name: "ruleId",
4787
+ type: "TEXT",
4788
+ notNull: true
4789
+ },
4790
+ {
4791
+ name: "targetId",
4792
+ type: "TEXT",
4793
+ notNull: true
4794
+ },
4795
+ {
4796
+ name: "deviceId",
4797
+ type: "INTEGER",
4798
+ notNull: true
4799
+ },
4800
+ {
4801
+ name: "recordKind",
4802
+ type: "TEXT",
4803
+ notNull: true
4804
+ },
4805
+ {
4806
+ name: "recordId",
4807
+ type: "TEXT",
4808
+ notNull: true
4809
+ },
4810
+ {
4811
+ name: "trackId",
4812
+ type: "TEXT"
4813
+ },
4814
+ {
4815
+ name: "status",
4816
+ type: "TEXT",
4817
+ notNull: true
4818
+ },
4819
+ {
4820
+ name: "attempts",
4821
+ type: "INTEGER",
4822
+ notNull: true
4823
+ },
4824
+ {
4825
+ name: "nextAttemptAt",
4826
+ type: "INTEGER",
4827
+ notNull: true
4828
+ },
4829
+ {
4830
+ name: "createdAt",
4831
+ type: "INTEGER",
4832
+ notNull: true
4833
+ },
4834
+ {
4835
+ name: "updatedAt",
4836
+ type: "INTEGER",
4837
+ notNull: true
4838
+ },
4839
+ {
4840
+ name: "lastError",
4841
+ type: "TEXT"
4453
4842
  },
4454
4843
  {
4455
4844
  name: "payload",
@@ -4654,586 +5043,207 @@ var NcOutbox = class {
4654
5043
  field: "createdAt",
4655
5044
  direction: "desc"
4656
5045
  },
4657
- limit: query.limit
4658
- }
4659
- });
4660
- const out = [];
4661
- for (const row of rows) {
4662
- const entry = rowToEntry$1(row.id, row.data);
4663
- if (entry !== null) out.push(entry);
4664
- }
4665
- return out;
4666
- } catch (err) {
4667
- this.logger.debug("outbox history query failed", { meta: { error: String(err) } });
4668
- return [];
4669
- }
4670
- }
4671
- async getWatermark() {
4672
- try {
4673
- const row = await this.store.get.query({
4674
- collection: NC_META_COLLECTION,
4675
- key: NC_WATERMARK_KEY
4676
- });
4677
- if (row === null || row === void 0 || typeof row !== "object") return null;
4678
- const value = row["value"];
4679
- if (value === null || typeof value !== "object") return null;
4680
- const ts = value["ts"];
4681
- return typeof ts === "number" && Number.isFinite(ts) ? ts : null;
4682
- } catch {
4683
- return null;
4684
- }
4685
- }
4686
- async setWatermark(ts) {
4687
- try {
4688
- await this.store.set.mutate({
4689
- collection: NC_META_COLLECTION,
4690
- key: NC_WATERMARK_KEY,
4691
- value: { value: { ts } }
4692
- });
4693
- } catch (err) {
4694
- this.logger.debug("outbox watermark write failed", { meta: { error: String(err) } });
4695
- }
4696
- }
4697
- /** Drop terminal rows older than `cutoffMs` (retention hygiene). */
4698
- async pruneBefore(cutoffMs) {
4699
- let pruned = 0;
4700
- try {
4701
- const rows = await this.store.query.query({
4702
- collection: NC_OUTBOX_COLLECTION,
4703
- filter: {
4704
- whereBetween: { createdAt: [0, cutoffMs] },
4705
- limit: 5e3
4706
- }
4707
- });
4708
- for (const row of rows) {
4709
- const status = row.data["status"];
4710
- if (status !== "sent" && status !== "dead") continue;
4711
- await this.store.delete.mutate({
4712
- collection: NC_OUTBOX_COLLECTION,
4713
- key: row.id
4714
- });
4715
- this.knownIds.delete(row.id);
4716
- pruned += 1;
4717
- }
4718
- } catch (err) {
4719
- this.logger.debug("outbox prune failed", { meta: { error: String(err) } });
4720
- }
4721
- return pruned;
4722
- }
4723
- backoffMs(attempts) {
4724
- return Math.min(this.backoffBaseMs * 2 ** Math.max(0, attempts - 1), this.backoffCapMs);
4725
- }
4726
- async attempt(entry) {
4727
- let result;
4728
- try {
4729
- result = await this.deliver(entry);
4730
- } catch (err) {
4731
- result = {
4732
- ok: false,
4733
- error: String(err),
4734
- permanent: false
4735
- };
4736
- }
4737
- const now = this.now();
4738
- if (result.ok) {
4739
- const sent = {
4740
- ...entry,
4741
- status: "sent",
4742
- updatedAt: now
4743
- };
4744
- this.pending.delete(entry.id);
4745
- await this.mutate(sent);
4746
- return;
4747
- }
4748
- const attempts = entry.attempts + 1;
4749
- const exhausted = attempts >= this.maxAttempts;
4750
- if (result.permanent || exhausted) {
4751
- const dead = {
4752
- ...entry,
4753
- status: "dead",
4754
- attempts,
4755
- updatedAt: now,
4756
- lastError: result.error
4757
- };
4758
- this.pending.delete(entry.id);
4759
- await this.mutate(dead);
4760
- this.logger.warn("outbox entry dead-lettered", {
4761
- tags: { deviceId: entry.deviceId },
4762
- meta: {
4763
- id: entry.id,
4764
- ruleId: entry.ruleId,
4765
- targetId: entry.targetId,
4766
- attempts,
4767
- permanent: result.permanent,
4768
- error: result.error
4769
- }
4770
- });
4771
- return;
4772
- }
4773
- const retry = {
4774
- ...entry,
4775
- attempts,
4776
- nextAttemptAt: now + this.backoffMs(attempts),
4777
- updatedAt: now,
4778
- lastError: result.error
4779
- };
4780
- this.pending.set(entry.id, retry);
4781
- await this.mutate(retry);
4782
- }
4783
- async persist(entry) {
4784
- await this.store.set.mutate({
4785
- collection: NC_OUTBOX_COLLECTION,
4786
- key: entry.id,
4787
- value: entryToRow(entry)
4788
- });
4789
- }
4790
- async mutate(entry) {
4791
- try {
4792
- await this.store.update.mutate({
4793
- collection: NC_OUTBOX_COLLECTION,
4794
- id: entry.id,
4795
- data: entryToRow(entry)
4796
- });
4797
- } catch (err) {
4798
- this.logger.debug("outbox row update failed", { meta: {
4799
- id: entry.id,
4800
- error: String(err)
4801
- } });
4802
- }
4803
- }
4804
- };
4805
- function entryToRow(entry) {
4806
- return {
4807
- ruleId: entry.ruleId,
4808
- targetId: entry.targetId,
4809
- deviceId: entry.deviceId,
4810
- recordKind: entry.recordKind,
4811
- recordId: entry.recordId,
4812
- ...entry.trackId !== void 0 ? { trackId: entry.trackId } : {},
4813
- status: entry.status,
4814
- attempts: entry.attempts,
4815
- nextAttemptAt: entry.nextAttemptAt,
4816
- createdAt: entry.createdAt,
4817
- updatedAt: entry.updatedAt,
4818
- ...entry.lastError !== void 0 ? { lastError: entry.lastError } : {},
4819
- payload: entry.payload
4820
- };
4821
- }
4822
- var OUTBOX_RECORD_KINDS = new Set([
4823
- "object-event",
4824
- "track-end",
4825
- "device-event",
4826
- "package-event",
4827
- "audio-event"
4828
- ]);
4829
- function isOutboxRecordKind(x) {
4830
- return typeof x === "string" && OUTBOX_RECORD_KINDS.has(x);
4831
- }
4832
- function rowToEntry$1(id, data) {
4833
- const ruleId = data["ruleId"];
4834
- const targetId = data["targetId"];
4835
- const deviceId = Number(data["deviceId"]);
4836
- const recordKind = data["recordKind"];
4837
- const recordId = data["recordId"];
4838
- const status = data["status"];
4839
- const payload = data["payload"];
4840
- if (typeof ruleId !== "string" || typeof targetId !== "string" || !Number.isFinite(deviceId) || !isOutboxRecordKind(recordKind) || typeof recordId !== "string" || status !== "pending" && status !== "sent" && status !== "dead" || payload === null || typeof payload !== "object") return null;
4841
- const trackId = data["trackId"];
4842
- const lastError = data["lastError"];
4843
- return {
4844
- id,
4845
- ruleId,
4846
- targetId,
4847
- deviceId,
4848
- recordKind,
4849
- recordId,
4850
- ...typeof trackId === "string" ? { trackId } : {},
4851
- status,
4852
- attempts: Number(data["attempts"] ?? 0),
4853
- nextAttemptAt: Number(data["nextAttemptAt"] ?? 0),
4854
- createdAt: Number(data["createdAt"] ?? 0),
4855
- updatedAt: Number(data["updatedAt"] ?? 0),
4856
- ...typeof lastError === "string" ? { lastError } : {},
4857
- payload
4858
- };
4859
- }
4860
- //#endregion
4861
- //#region src/notification-center/occupancy-watcher.ts
4862
- /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
4863
- var FRAME_SCOPE = "@frame";
4864
- var ALL_CLASSES = "@all";
4865
- /** Prefix marking the trailing threshold segment (`t<n>`) — makes the grammar
4866
- * self-describing so a legacy three-segment key can never be mis-parsed as a
4867
- * four-segment one (the last segment of a legacy key is a className, never a
4868
- * `t<digits>` token). */
4869
- var THRESHOLD_PREFIX = "t";
4870
- var THRESHOLD_SEGMENT = /^t(\d+)$/;
4871
- function occupancyKey(deviceId, zoneId, className, threshold) {
4872
- return `${deviceId}|${zoneId ?? FRAME_SCOPE}|${className ?? ALL_CLASSES}|${THRESHOLD_PREFIX}${threshold}`;
4873
- }
4874
- /**
4875
- * Inverse of {@link occupancyKey} — the ONE place the key grammar is decoded, so
4876
- * the durable store (whose column schema drops the derivable
4877
- * `zoneId`/`className`/`threshold`) can reconstruct the full scope on reseed.
4878
- *
4879
- * The four-segment grammar is asserted EXACTLY: at least four segments AND a
4880
- * trailing `t<digits>` threshold marker. A legacy three-segment key (no marker)
4881
- * is REJECTED (`null`) rather than positionally mis-parsed — the durable store
4882
- * then SKIPS that row, so a pre-amendment persisted row cold-re-observes instead
4883
- * of hydrating a corrupt scope (className←threshold, zoneId←class). `deviceId`
4884
- * is the leading numeric segment, `threshold` the trailing `t<n>`, `className`
4885
- * the segment before it, and the (possibly `|`-containing) `zoneId` everything
4886
- * between. Exact per-segment count is impossible because a `zoneId` may itself
4887
- * contain `|`; the trailing marker is the unambiguous grammar discriminator.
4888
- */
4889
- function parseOccupancyKey(key) {
4890
- const parts = key.split("|");
4891
- if (parts.length < 4) return null;
4892
- const deviceId = Number(parts[0]);
4893
- if (!Number.isFinite(deviceId)) return null;
4894
- const thresholdMatch = THRESHOLD_SEGMENT.exec(parts[parts.length - 1] ?? "");
4895
- if (thresholdMatch === null) return null;
4896
- const threshold = Number(thresholdMatch[1]);
4897
- const className = parts[parts.length - 2] ?? ALL_CLASSES;
4898
- const zoneId = parts.slice(1, -2).join("|");
4899
- return {
4900
- deviceId,
4901
- ...zoneId !== FRAME_SCOPE ? { zoneId } : {},
4902
- ...className !== ALL_CLASSES ? { className } : {},
4903
- threshold
4904
- };
4905
- }
4906
- /** Device-agnostic partial key (`zone|class|t<threshold>`) — the merge/watch
4907
- * granularity. Distinct thresholds are distinct partial keys (per-threshold). */
4908
- function partialKey(zoneId, className, threshold) {
4909
- return `${zoneId ?? FRAME_SCOPE}|${className ?? ALL_CLASSES}|${THRESHOLD_PREFIX}${threshold}`;
4910
- }
4911
- /** The device-agnostic partial key of a full state/edge key — everything after
4912
- * the leading `deviceId|` segment. */
4913
- function partialKeyOf(key) {
4914
- const idx = key.indexOf("|");
4915
- return idx < 0 ? key : key.slice(idx + 1);
4916
- }
4917
- var OccupancyWatcher = class {
4918
- /** Watched specs, keyed by their device-agnostic partial key. */
4919
- watched = /* @__PURE__ */ new Map();
4920
- /** Confirmed + pending state, keyed by the full {@link OccupancyKey}. */
4921
- states = /* @__PURE__ */ new Map();
4922
- /**
4923
- * Replace the watched key set (rule-driven — recomputed on rule change).
4924
- * Each distinct `(zone, class, threshold)` is its OWN watched partial key
4925
- * (per-threshold edges — NO min-threshold merge). Specs that collide on the
4926
- * SAME `(zone, class, threshold)` merge to the MAX sustain (longest debounce),
4927
- * so one watcher serves those co-threshold rules. Confirmed state for a key
4928
- * that is no longer watched is DROPPED (bounds the RAM map + lets the caller
4929
- * prune the durable row to the active set); a still-watched key retains its
4930
- * level (re-evaluated on the next `observe`).
4931
- */
4932
- setWatchedKeys(specs) {
4933
- this.watched.clear();
4934
- for (const spec of specs) {
4935
- const pk = partialKey(spec.zoneId, spec.className, spec.threshold);
4936
- const existing = this.watched.get(pk);
4937
- if (existing === void 0) {
4938
- this.watched.set(pk, {
4939
- ...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
4940
- ...spec.className !== void 0 ? { className: spec.className } : {},
4941
- threshold: spec.threshold,
4942
- sustainSeconds: spec.sustainSeconds
4943
- });
4944
- continue;
4945
- }
4946
- this.watched.set(pk, {
4947
- ...existing,
4948
- sustainSeconds: Math.max(existing.sustainSeconds, spec.sustainSeconds)
4949
- });
4950
- }
4951
- for (const key of [...this.states.keys()]) if (!this.watched.has(partialKeyOf(key))) this.states.delete(key);
4952
- }
4953
- /**
4954
- * Feed one camera snapshot at time `now`, returning the edges that COMMIT on
4955
- * this tick (usually none). Every watched key is evaluated for `deviceId`;
4956
- * fail-closed keys (absent zone) are skipped and hold no state.
4957
- */
4958
- observe(deviceId, snapshot, now) {
4959
- const edges = [];
4960
- for (const spec of this.watched.values()) {
4961
- const resolved = resolveScope(snapshot, spec);
4962
- if (resolved === null) continue;
4963
- const key = occupancyKey(deviceId, spec.zoneId, spec.className, spec.threshold);
4964
- const edge = step(this.stateFor(key, deviceId, spec, now), spec, resolved, now);
4965
- if (edge !== null) edges.push(edge);
4966
- }
4967
- return edges;
4968
- }
4969
- /** Reseed confirmed state from durable rows (boot). Only rows whose key is
4970
- * currently WATCHED are restored — an orphaned durable row (its rule gone)
4971
- * is dropped, keeping the RAM map bounded to the active set. Pending edges
4972
- * are not restored (fail-closed — they re-open on the next snapshots). */
4973
- hydrate(rows) {
4974
- for (const row of rows) {
4975
- if (!this.watched.has(partialKeyOf(row.key))) continue;
4976
- this.states.set(row.key, {
4977
- deviceId: row.deviceId,
4978
- ...row.zoneId !== void 0 ? { zoneId: row.zoneId } : {},
4979
- ...row.className !== void 0 ? { className: row.className } : {},
4980
- threshold: row.threshold,
4981
- confirmedCount: row.confirmedCount,
4982
- occupied: row.occupied,
4983
- lastChangeAt: row.lastChangeAt
4984
- });
4985
- }
4986
- }
4987
- /** Snapshot the CONFIRMED state for durable persistence (pending excluded). */
4988
- snapshotState() {
4989
- const rows = [];
4990
- for (const [key, state] of this.states) rows.push({
4991
- key,
4992
- deviceId: state.deviceId,
4993
- ...state.zoneId !== void 0 ? { zoneId: state.zoneId } : {},
4994
- ...state.className !== void 0 ? { className: state.className } : {},
4995
- threshold: state.threshold,
4996
- confirmedCount: state.confirmedCount,
4997
- occupied: state.occupied,
4998
- lastChangeAt: state.lastChangeAt,
4999
- updatedAt: state.lastChangeAt
5000
- });
5001
- return rows;
5002
- }
5003
- stateFor(key, deviceId, spec, now) {
5004
- const existing = this.states.get(key);
5005
- if (existing !== void 0) return existing;
5006
- const fresh = {
5007
- deviceId,
5008
- ...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
5009
- ...spec.className !== void 0 ? { className: spec.className } : {},
5010
- threshold: spec.threshold,
5011
- confirmedCount: 0,
5012
- occupied: false,
5013
- lastChangeAt: now
5014
- };
5015
- this.states.set(key, fresh);
5016
- return fresh;
5017
- }
5018
- };
5019
- /** Read the count (+ zone name) for a spec, or `null` when fail-closed. */
5020
- function resolveScope(snapshot, spec) {
5021
- if (spec.zoneId === void 0) return { count: spec.className === void 0 ? snapshot.frame.totalObjects : snapshot.frame.byClass[spec.className] ?? 0 };
5022
- const zone = snapshot.zones.find((z) => z.zoneId === spec.zoneId);
5023
- if (zone === void 0) return null;
5024
- return {
5025
- count: spec.className === void 0 ? zone.totalObjects : zone.byClass[spec.className] ?? 0,
5026
- zoneName: zone.zoneName
5027
- };
5028
- }
5029
- /**
5030
- * Advance one key's state by one observation. Mutates `state` in place (the
5031
- * watcher owns it) and returns a committed edge, or `null`.
5032
- */
5033
- function step(state, spec, resolved, now) {
5034
- const rawOccupied = resolved.count >= spec.threshold;
5035
- const sustainMs = spec.sustainSeconds * 1e3;
5036
- if (rawOccupied === state.occupied) {
5037
- state.pendingTargetOccupied = void 0;
5038
- state.pendingSince = void 0;
5039
- return null;
5040
- }
5041
- if (state.pendingTargetOccupied !== rawOccupied) {
5042
- state.pendingTargetOccupied = rawOccupied;
5043
- state.pendingSince = now;
5044
- }
5045
- if (now - (state.pendingSince ?? now) < sustainMs) return null;
5046
- const previousCount = state.confirmedCount;
5047
- state.confirmedCount = resolved.count;
5048
- state.occupied = rawOccupied;
5049
- state.lastChangeAt = now;
5050
- state.pendingTargetOccupied = void 0;
5051
- state.pendingSince = void 0;
5052
- return {
5053
- deviceId: state.deviceId,
5054
- ...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
5055
- ...resolved.zoneName !== void 0 ? { zoneName: resolved.zoneName } : {},
5056
- ...spec.className !== void 0 ? { className: spec.className } : {},
5057
- count: resolved.count,
5058
- previousCount,
5059
- occupied: rawOccupied,
5060
- threshold: spec.threshold,
5061
- timestamp: now
5062
- };
5063
- }
5064
- //#endregion
5065
- //#region src/notification-center/occupancy-store.ts
5066
- var NC_OCCUPANCY_COLLECTION = "notification-center:occupancy";
5067
- var NC_OCCUPANCY_COLUMNS = [
5068
- {
5069
- name: "key",
5070
- type: "TEXT",
5071
- primaryKey: true,
5072
- notNull: true
5073
- },
5074
- {
5075
- name: "deviceId",
5076
- type: "INTEGER",
5077
- notNull: true
5078
- },
5079
- {
5080
- name: "confirmedCount",
5081
- type: "INTEGER",
5082
- notNull: true
5083
- },
5084
- {
5085
- name: "occupied",
5086
- type: "BOOLEAN",
5087
- notNull: true
5088
- },
5089
- {
5090
- name: "lastChangeAt",
5091
- type: "INTEGER",
5092
- notNull: true
5093
- },
5094
- {
5095
- name: "updatedAt",
5096
- type: "INTEGER",
5097
- notNull: true
5098
- }
5099
- ];
5100
- var NC_OCCUPANCY_INDEXES = [{
5101
- name: "idx_nc_occupancy_device",
5102
- columns: ["deviceId"]
5103
- }];
5104
- /** Query cap — a per-(device, zone, class) key set is small; this is a
5105
- * generous ceiling that still bounds a pathological read. */
5106
- var LOAD_LIMIT = 1e5;
5107
- var OccupancyStore = class {
5108
- cache = /* @__PURE__ */ new Map();
5109
- store;
5110
- logger;
5111
- constructor(deps) {
5112
- this.store = deps.store;
5113
- this.logger = deps.logger;
5114
- }
5115
- static async declare(store) {
5116
- await store.declareCollection.mutate({
5117
- collection: NC_OCCUPANCY_COLLECTION,
5118
- columns: [...NC_OCCUPANCY_COLUMNS],
5119
- indexes: [...NC_OCCUPANCY_INDEXES]
5120
- });
5121
- }
5122
- /**
5123
- * Reseed the confirmed edge-state from the store (boot) — replaces the cache
5124
- * wholesale and returns the rows for {@link OccupancyWatcher.hydrate}. A row
5125
- * whose scalars/key no longer parse is skipped with a warning (a degraded row
5126
- * must never crash the reseed). Best-effort: a store error yields `[]` and a
5127
- * cold watcher, never a throw into boot.
5128
- */
5129
- async load() {
5130
- try {
5131
- const records = await this.store.query.query({
5132
- collection: NC_OCCUPANCY_COLLECTION,
5133
- filter: { limit: LOAD_LIMIT }
5134
- });
5135
- this.cache.clear();
5136
- let skipped = 0;
5137
- for (const record of records) {
5138
- const row = recordToRow(record.id, record.data);
5139
- if (row === null) {
5140
- skipped += 1;
5141
- continue;
5046
+ limit: query.limit
5142
5047
  }
5143
- this.cache.set(row.key, row);
5048
+ });
5049
+ const out = [];
5050
+ for (const row of rows) {
5051
+ const entry = rowToEntry$1(row.id, row.data);
5052
+ if (entry !== null) out.push(entry);
5144
5053
  }
5145
- this.logger.debug("occupancy state loaded", { meta: {
5146
- keys: this.cache.size,
5147
- ...skipped > 0 ? { skippedInvalid: skipped } : {}
5148
- } });
5149
- return [...this.cache.values()];
5054
+ return out;
5150
5055
  } catch (err) {
5151
- this.logger.warn("occupancy state load failed", { meta: { error: String(err) } });
5056
+ this.logger.debug("outbox history query failed", { meta: { error: String(err) } });
5152
5057
  return [];
5153
5058
  }
5154
5059
  }
5155
- /** The in-RAM confirmed-state mirror (post-{@link load}/{@link persist}). */
5156
- snapshot() {
5157
- return [...this.cache.values()];
5060
+ async getWatermark() {
5061
+ try {
5062
+ const row = await this.store.get.query({
5063
+ collection: NC_META_COLLECTION,
5064
+ key: NC_WATERMARK_KEY
5065
+ });
5066
+ if (row === null || row === void 0 || typeof row !== "object") return null;
5067
+ const value = row["value"];
5068
+ if (value === null || typeof value !== "object") return null;
5069
+ const ts = value["ts"];
5070
+ return typeof ts === "number" && Number.isFinite(ts) ? ts : null;
5071
+ } catch {
5072
+ return null;
5073
+ }
5158
5074
  }
5159
- /**
5160
- * Durably upsert one confirmed edge-state row (write-through: the store FIRST,
5161
- * then the cache — a failed persist never leaves a phantom in-RAM level). The
5162
- * `key` is the PK, so re-persisting a key advances it in place.
5163
- */
5164
- async persist(row) {
5165
- await this.store.set.mutate({
5166
- collection: NC_OCCUPANCY_COLLECTION,
5167
- key: row.key,
5168
- value: rowToValue(row)
5169
- });
5170
- this.cache.set(row.key, row);
5075
+ async setWatermark(ts) {
5076
+ try {
5077
+ await this.store.set.mutate({
5078
+ collection: NC_META_COLLECTION,
5079
+ key: NC_WATERMARK_KEY,
5080
+ value: { value: { ts } }
5081
+ });
5082
+ } catch (err) {
5083
+ this.logger.debug("outbox watermark write failed", { meta: { error: String(err) } });
5084
+ }
5171
5085
  }
5172
- /**
5173
- * Prune every persisted key NOT in `activeKeys` (the currently watched set)
5174
- * the bounded-row-count guarantee when rules stop watching a key. Returns the
5175
- * number of rows dropped. Best-effort per row: a failed delete is logged and
5176
- * the key retained (retried next prune) rather than aborting the sweep.
5177
- */
5178
- async pruneExcept(activeKeys) {
5086
+ /** Drop terminal rows older than `cutoffMs` (retention hygiene). */
5087
+ async pruneBefore(cutoffMs) {
5179
5088
  let pruned = 0;
5180
- for (const key of [...this.cache.keys()]) {
5181
- if (activeKeys.has(key)) continue;
5182
- try {
5089
+ try {
5090
+ const rows = await this.store.query.query({
5091
+ collection: NC_OUTBOX_COLLECTION,
5092
+ filter: {
5093
+ whereBetween: { createdAt: [0, cutoffMs] },
5094
+ limit: 5e3
5095
+ }
5096
+ });
5097
+ for (const row of rows) {
5098
+ const status = row.data["status"];
5099
+ if (status !== "sent" && status !== "dead") continue;
5183
5100
  await this.store.delete.mutate({
5184
- collection: NC_OCCUPANCY_COLLECTION,
5185
- key
5101
+ collection: NC_OUTBOX_COLLECTION,
5102
+ key: row.id
5186
5103
  });
5187
- this.cache.delete(key);
5104
+ this.knownIds.delete(row.id);
5188
5105
  pruned += 1;
5189
- } catch (err) {
5190
- this.logger.debug("occupancy prune delete failed", { meta: {
5191
- key,
5192
- error: String(err)
5193
- } });
5194
5106
  }
5107
+ } catch (err) {
5108
+ this.logger.debug("outbox prune failed", { meta: { error: String(err) } });
5195
5109
  }
5196
5110
  return pruned;
5197
5111
  }
5112
+ backoffMs(attempts) {
5113
+ return Math.min(this.backoffBaseMs * 2 ** Math.max(0, attempts - 1), this.backoffCapMs);
5114
+ }
5115
+ async attempt(entry) {
5116
+ let result;
5117
+ try {
5118
+ result = await this.deliver(entry);
5119
+ } catch (err) {
5120
+ result = {
5121
+ ok: false,
5122
+ error: String(err),
5123
+ permanent: false
5124
+ };
5125
+ }
5126
+ const now = this.now();
5127
+ if (result.ok) {
5128
+ const sent = {
5129
+ ...entry,
5130
+ status: "sent",
5131
+ updatedAt: now
5132
+ };
5133
+ this.pending.delete(entry.id);
5134
+ await this.mutate(sent);
5135
+ return;
5136
+ }
5137
+ const attempts = entry.attempts + 1;
5138
+ const exhausted = attempts >= this.maxAttempts;
5139
+ if (result.permanent || exhausted) {
5140
+ const dead = {
5141
+ ...entry,
5142
+ status: "dead",
5143
+ attempts,
5144
+ updatedAt: now,
5145
+ lastError: result.error
5146
+ };
5147
+ this.pending.delete(entry.id);
5148
+ await this.mutate(dead);
5149
+ this.logger.warn("outbox entry dead-lettered", {
5150
+ tags: { deviceId: entry.deviceId },
5151
+ meta: {
5152
+ id: entry.id,
5153
+ ruleId: entry.ruleId,
5154
+ targetId: entry.targetId,
5155
+ attempts,
5156
+ permanent: result.permanent,
5157
+ error: result.error
5158
+ }
5159
+ });
5160
+ return;
5161
+ }
5162
+ const retry = {
5163
+ ...entry,
5164
+ attempts,
5165
+ nextAttemptAt: now + this.backoffMs(attempts),
5166
+ updatedAt: now,
5167
+ lastError: result.error
5168
+ };
5169
+ this.pending.set(entry.id, retry);
5170
+ await this.mutate(retry);
5171
+ }
5172
+ async persist(entry) {
5173
+ await this.store.set.mutate({
5174
+ collection: NC_OUTBOX_COLLECTION,
5175
+ key: entry.id,
5176
+ value: entryToRow(entry)
5177
+ });
5178
+ }
5179
+ async mutate(entry) {
5180
+ try {
5181
+ await this.store.update.mutate({
5182
+ collection: NC_OUTBOX_COLLECTION,
5183
+ id: entry.id,
5184
+ data: entryToRow(entry)
5185
+ });
5186
+ } catch (err) {
5187
+ this.logger.debug("outbox row update failed", { meta: {
5188
+ id: entry.id,
5189
+ error: String(err)
5190
+ } });
5191
+ }
5192
+ }
5198
5193
  };
5199
- /** The persisted column map for a row (the `key` PK is passed separately). */
5200
- function rowToValue(row) {
5194
+ function entryToRow(entry) {
5201
5195
  return {
5202
- deviceId: row.deviceId,
5203
- confirmedCount: row.confirmedCount,
5204
- occupied: row.occupied,
5205
- lastChangeAt: row.lastChangeAt,
5206
- updatedAt: row.updatedAt
5196
+ ruleId: entry.ruleId,
5197
+ targetId: entry.targetId,
5198
+ deviceId: entry.deviceId,
5199
+ recordKind: entry.recordKind,
5200
+ recordId: entry.recordId,
5201
+ ...entry.trackId !== void 0 ? { trackId: entry.trackId } : {},
5202
+ status: entry.status,
5203
+ attempts: entry.attempts,
5204
+ nextAttemptAt: entry.nextAttemptAt,
5205
+ createdAt: entry.createdAt,
5206
+ updatedAt: entry.updatedAt,
5207
+ ...entry.lastError !== void 0 ? { lastError: entry.lastError } : {},
5208
+ payload: entry.payload
5207
5209
  };
5208
5210
  }
5209
- /**
5210
- * Structurally validate a persisted record and reconstruct the full
5211
- * {@link OccupancyStateRow} (deriving `zoneId`/`className`/`threshold` from the
5212
- * key). Returns `null` for any malformed row — including a legacy
5213
- * three-segment key with no `t<n>` threshold marker, which
5214
- * {@link parseOccupancyKey} rejects — so the caller skips it (cold re-observe)
5215
- * rather than hydrating a mis-parsed scope.
5216
- */
5217
- function recordToRow(key, data) {
5218
- const scope = parseOccupancyKey(key);
5219
- if (scope === null) return null;
5211
+ var OUTBOX_RECORD_KINDS = new Set([
5212
+ "object-event",
5213
+ "track-end",
5214
+ "device-event",
5215
+ "package-event",
5216
+ "audio-event"
5217
+ ]);
5218
+ function isOutboxRecordKind(x) {
5219
+ return typeof x === "string" && OUTBOX_RECORD_KINDS.has(x);
5220
+ }
5221
+ function rowToEntry$1(id, data) {
5222
+ const ruleId = data["ruleId"];
5223
+ const targetId = data["targetId"];
5220
5224
  const deviceId = Number(data["deviceId"]);
5221
- const confirmedCount = Number(data["confirmedCount"]);
5222
- const lastChangeAt = Number(data["lastChangeAt"]);
5223
- const updatedAt = Number(data["updatedAt"]);
5224
- if (!Number.isFinite(deviceId) || !Number.isFinite(confirmedCount) || !Number.isFinite(lastChangeAt) || !Number.isFinite(updatedAt)) return null;
5225
- const rawOccupied = data["occupied"];
5226
- const occupied = rawOccupied === true || rawOccupied === 1;
5225
+ const recordKind = data["recordKind"];
5226
+ const recordId = data["recordId"];
5227
+ const status = data["status"];
5228
+ const payload = data["payload"];
5229
+ if (typeof ruleId !== "string" || typeof targetId !== "string" || !Number.isFinite(deviceId) || !isOutboxRecordKind(recordKind) || typeof recordId !== "string" || status !== "pending" && status !== "sent" && status !== "dead" || payload === null || typeof payload !== "object") return null;
5230
+ const trackId = data["trackId"];
5231
+ const lastError = data["lastError"];
5227
5232
  return {
5228
- key,
5233
+ id,
5234
+ ruleId,
5235
+ targetId,
5229
5236
  deviceId,
5230
- ...scope.zoneId !== void 0 ? { zoneId: scope.zoneId } : {},
5231
- ...scope.className !== void 0 ? { className: scope.className } : {},
5232
- threshold: scope.threshold,
5233
- confirmedCount,
5234
- occupied,
5235
- lastChangeAt,
5236
- updatedAt
5237
+ recordKind,
5238
+ recordId,
5239
+ ...typeof trackId === "string" ? { trackId } : {},
5240
+ status,
5241
+ attempts: Number(data["attempts"] ?? 0),
5242
+ nextAttemptAt: Number(data["nextAttemptAt"] ?? 0),
5243
+ createdAt: Number(data["createdAt"] ?? 0),
5244
+ updatedAt: Number(data["updatedAt"] ?? 0),
5245
+ ...typeof lastError === "string" ? { lastError } : {},
5246
+ payload
5237
5247
  };
5238
5248
  }
5239
5249
  //#endregion
@@ -5473,6 +5483,25 @@ function occupancySpecFromCondition(occ) {
5473
5483
  sustainSeconds: occ.sustainSeconds
5474
5484
  };
5475
5485
  }
5486
+ /**
5487
+ * Freeze the matched occupancy edge onto the outbox payload subject (A5) — the
5488
+ * source of the dispatcher's `{{zone}}`/`{{count}}`/`{{capacity}}` vars.
5489
+ *
5490
+ * `capacity` is the RULE's configured `occupancy.count`, NOT the edge's key
5491
+ * threshold: a `<=C` rule keys on threshold `C+1`, so the threshold would
5492
+ * misreport the operator's capacity by one. The rule condition is present by
5493
+ * construction (the engine's occupancy branch fires only when it is); the
5494
+ * threshold fallback keeps the freeze total without a cast, and is exact for
5495
+ * every op but `<=`.
5496
+ */
5497
+ function frozenOccupancy(rule, occ) {
5498
+ return {
5499
+ ...occ.zoneName !== void 0 ? { zone: occ.zoneName } : {},
5500
+ count: occ.count,
5501
+ capacity: rule.conditions.occupancy?.count ?? occ.threshold,
5502
+ occupied: occ.occupied
5503
+ };
5504
+ }
5476
5505
  /** Classify an object-event row as a package delivery / pick-up, or `null` when
5477
5506
  * it is an ordinary detection. */
5478
5507
  function packagePhaseOf(ev) {
@@ -5864,7 +5893,8 @@ var NotificationCenter = class {
5864
5893
  ...subject.label !== void 0 ? { label: subject.label } : {},
5865
5894
  ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
5866
5895
  zones: subject.zones,
5867
- timestamp: subject.timestamp
5896
+ timestamp: subject.timestamp,
5897
+ ...subject.occupancy !== void 0 ? { occupancy: frozenOccupancy(rule, subject.occupancy) } : {}
5868
5898
  }
5869
5899
  };
5870
5900
  return {