@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.
- package/dist/{dist-Bezh0l-m.js → dist-B5fLGGEF.js} +7566 -7398
- package/dist/{dist-Dm1I_IVp.mjs → dist-EaH6ywaC.mjs} +7566 -7398
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-DsYWuKgE.js → node-PvutBRd4.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BfZn4SxE.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CziJ_lMq.mjs} +2 -2
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CbYZbG3H.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DCdWQhcm.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-PN20an9o.mjs → hostInit-DQHgLTeH.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +635 -605
- package/dist/pipeline-analytics/index.mjs +634 -604
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -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-
|
|
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/
|
|
4386
|
-
|
|
4387
|
-
var
|
|
4388
|
-
var
|
|
4389
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
4437
|
-
type: "
|
|
4619
|
+
name: "occupied",
|
|
4620
|
+
type: "BOOLEAN",
|
|
4438
4621
|
notNull: true
|
|
4439
4622
|
},
|
|
4440
4623
|
{
|
|
4441
|
-
name: "
|
|
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
|
-
|
|
4452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
5056
|
+
this.logger.debug("outbox history query failed", { meta: { error: String(err) } });
|
|
5152
5057
|
return [];
|
|
5153
5058
|
}
|
|
5154
5059
|
}
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
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
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
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:
|
|
5185
|
-
key
|
|
5101
|
+
collection: NC_OUTBOX_COLLECTION,
|
|
5102
|
+
key: row.id
|
|
5186
5103
|
});
|
|
5187
|
-
this.
|
|
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
|
-
|
|
5200
|
-
function rowToValue(row) {
|
|
5194
|
+
function entryToRow(entry) {
|
|
5201
5195
|
return {
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
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
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
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
|
|
5222
|
-
const
|
|
5223
|
-
const
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
const
|
|
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
|
-
|
|
5233
|
+
id,
|
|
5234
|
+
ruleId,
|
|
5235
|
+
targetId,
|
|
5229
5236
|
deviceId,
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
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 {
|