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