@camstack/addon-post-analysis 1.2.23 → 1.2.25
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-DIhTL_gD.js → dist-Bi1fiPc0.js} +332 -6
- package/dist/{dist-CRCAv9JV.mjs → dist-DsCSHZ9N.mjs} +315 -7
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-C5of2Vlq.js → node-DFKBSXUx.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CZ8fx1RA.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-QDqT3XHa.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-Dsd5sKoq.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CHPq_p1Y.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-DG3K9L1Y.mjs → hostInit-DvTbKmnz.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1029 -67
- package/dist/pipeline-analytics/index.mjs +1028 -66
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as subKindsOf, B as array, C as defineCustomActions, D as notificationRulesCapability, F as DeviceType, G as string, H as literal, I as createEvent, K as EventCategory, L as hydrateSchema, M as zoneAnalyticsCapability, N as errMsg, O as pipelineAnalyticsCapability, P as BaseAddon, R as nodePin, S as customAction, T as faceGalleryCapability, U as number, V as boolean, W as object, _ as TimelapseRuleSchema, a as NC_CONDITION_CATALOG, b as buildEventKindDescriptor, c as NcRuleInputSchema, d as NcSnoozeInputSchema, f as NcSnoozeSchema, g as TimelapseRuleInputSchema, h as OpsLogEntrySchema, i as MACRO_LABELS, j as videoclipsCapability, k as plateGalleryCapability, l as NcRulePatchSchema, m as NcTaxonomySchema, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as NcSnoozeSuppressedSchema, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as addonWidgetsSourceCapability, x as cosineSimilarity, y as audioMetricsCapability, z as _enum } from "../dist-DsCSHZ9N.mjs";
|
|
2
2
|
import { promises } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -1536,6 +1536,33 @@ function pointInPolygon$1(point, polygon) {
|
|
|
1536
1536
|
}
|
|
1537
1537
|
return inside;
|
|
1538
1538
|
}
|
|
1539
|
+
/** Parametric line-segment intersection. Returns intersection point or null. */
|
|
1540
|
+
function lineIntersection(a, b) {
|
|
1541
|
+
const dx1 = a.p2.x - a.p1.x;
|
|
1542
|
+
const dy1 = a.p2.y - a.p1.y;
|
|
1543
|
+
const dx2 = b.p2.x - b.p1.x;
|
|
1544
|
+
const dy2 = b.p2.y - b.p1.y;
|
|
1545
|
+
const denom = dx1 * dy2 - dy1 * dx2;
|
|
1546
|
+
if (Math.abs(denom) < 1e-10) return null;
|
|
1547
|
+
const t = ((b.p1.x - a.p1.x) * dy2 - (b.p1.y - a.p1.y) * dx2) / denom;
|
|
1548
|
+
const u = ((b.p1.x - a.p1.x) * dy1 - (b.p1.y - a.p1.y) * dx1) / denom;
|
|
1549
|
+
if (t < 0 || t > 1 || u < 0 || u > 1) return null;
|
|
1550
|
+
return {
|
|
1551
|
+
x: a.p1.x + t * dx1,
|
|
1552
|
+
y: a.p1.y + t * dy1
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
/** Detect tripwire crossing and direction via cross product. */
|
|
1556
|
+
function tripwireCrossing(prev, curr, wire) {
|
|
1557
|
+
if (!lineIntersection({
|
|
1558
|
+
p1: prev,
|
|
1559
|
+
p2: curr
|
|
1560
|
+
}, wire)) return null;
|
|
1561
|
+
return {
|
|
1562
|
+
crossed: true,
|
|
1563
|
+
direction: (curr.x - prev.x) * (wire.p2.y - wire.p1.y) - (curr.y - prev.y) * (wire.p2.x - wire.p1.x) > 0 ? "right" : "left"
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1539
1566
|
/** Centroid of a bounding box { x, y, w, h } */
|
|
1540
1567
|
function bboxCentroid(bbox) {
|
|
1541
1568
|
return {
|
|
@@ -1550,6 +1577,95 @@ function normalizeToPixel(p, width, height) {
|
|
|
1550
1577
|
y: p.y * height
|
|
1551
1578
|
};
|
|
1552
1579
|
}
|
|
1580
|
+
var NONE = [];
|
|
1581
|
+
/**
|
|
1582
|
+
* Which side of the directed line the point is on.
|
|
1583
|
+
*
|
|
1584
|
+
* Sign of the 2D cross product of the line's own vector with (point − p1):
|
|
1585
|
+
* positive is the right-hand side of p1 → p2. Exactly zero means ON the line,
|
|
1586
|
+
* which is deliberately its own value: it is neither side, so it can neither
|
|
1587
|
+
* confirm nor cancel a crossing.
|
|
1588
|
+
*/
|
|
1589
|
+
function sideOf(point, wire) {
|
|
1590
|
+
const cross = (wire.p2.x - wire.p1.x) * (point.y - wire.p1.y) - (wire.p2.y - wire.p1.y) * (point.x - wire.p1.x);
|
|
1591
|
+
if (cross > 0) return 1;
|
|
1592
|
+
if (cross < 0) return -1;
|
|
1593
|
+
return 0;
|
|
1594
|
+
}
|
|
1595
|
+
var TripwireTracker = class {
|
|
1596
|
+
config;
|
|
1597
|
+
/** `trackId` → `wireId` → state. Keyed by track so `forget` is one delete. */
|
|
1598
|
+
tracks = /* @__PURE__ */ new Map();
|
|
1599
|
+
constructor(config = {}) {
|
|
1600
|
+
this.config = {
|
|
1601
|
+
confirmFrames: 2,
|
|
1602
|
+
...config
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* Feed one frame's centroid for one track against one wire.
|
|
1607
|
+
*
|
|
1608
|
+
* Returns the crossings CONFIRMED on this frame — usually none. Coordinates
|
|
1609
|
+
* are whatever space the caller uses consistently; the zone geometry is
|
|
1610
|
+
* normalised (0–1), so the frame processor passes normalised centroids.
|
|
1611
|
+
*/
|
|
1612
|
+
observe(trackId, wireId, wire, centroid) {
|
|
1613
|
+
const wires = this.tracks.get(trackId) ?? /* @__PURE__ */ new Map();
|
|
1614
|
+
this.tracks.set(trackId, wires);
|
|
1615
|
+
const side = sideOf(centroid, wire);
|
|
1616
|
+
const state = wires.get(wireId);
|
|
1617
|
+
if (state === void 0) {
|
|
1618
|
+
wires.set(wireId, {
|
|
1619
|
+
last: centroid,
|
|
1620
|
+
confirmedSide: side,
|
|
1621
|
+
pendingSide: side,
|
|
1622
|
+
pendingFrames: 0,
|
|
1623
|
+
sawIntersection: false,
|
|
1624
|
+
pendingDirection: "in"
|
|
1625
|
+
});
|
|
1626
|
+
return NONE;
|
|
1627
|
+
}
|
|
1628
|
+
const crossedNow = tripwireCrossing(state.last, centroid, wire) !== null;
|
|
1629
|
+
state.last = centroid;
|
|
1630
|
+
if (side === 0) return NONE;
|
|
1631
|
+
if (side === state.confirmedSide) {
|
|
1632
|
+
state.pendingSide = side;
|
|
1633
|
+
state.pendingFrames = 0;
|
|
1634
|
+
state.sawIntersection = false;
|
|
1635
|
+
return NONE;
|
|
1636
|
+
}
|
|
1637
|
+
if (side !== state.pendingSide) {
|
|
1638
|
+
state.pendingSide = side;
|
|
1639
|
+
state.pendingFrames = 1;
|
|
1640
|
+
state.sawIntersection = crossedNow;
|
|
1641
|
+
state.pendingDirection = side > 0 ? "in" : "out";
|
|
1642
|
+
} else {
|
|
1643
|
+
state.pendingFrames += 1;
|
|
1644
|
+
state.sawIntersection = state.sawIntersection || crossedNow;
|
|
1645
|
+
}
|
|
1646
|
+
if (state.pendingFrames < this.config.confirmFrames) return NONE;
|
|
1647
|
+
if (!state.sawIntersection) {
|
|
1648
|
+
state.confirmedSide = side;
|
|
1649
|
+
state.pendingFrames = 0;
|
|
1650
|
+
return NONE;
|
|
1651
|
+
}
|
|
1652
|
+
state.confirmedSide = side;
|
|
1653
|
+
state.pendingFrames = 0;
|
|
1654
|
+
state.sawIntersection = false;
|
|
1655
|
+
return [{
|
|
1656
|
+
wireId,
|
|
1657
|
+
direction: state.pendingDirection
|
|
1658
|
+
}];
|
|
1659
|
+
}
|
|
1660
|
+
/** Drop a track. Called when the subject leaves, so state cannot accumulate. */
|
|
1661
|
+
forget(trackId) {
|
|
1662
|
+
this.tracks.delete(trackId);
|
|
1663
|
+
}
|
|
1664
|
+
/** Number of tracks held — a leak check for the caller's tests. */
|
|
1665
|
+
size() {
|
|
1666
|
+
return this.tracks.size;
|
|
1667
|
+
}
|
|
1668
|
+
};
|
|
1553
1669
|
//#endregion
|
|
1554
1670
|
//#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
|
|
1555
1671
|
var DEFAULT_TRACKER_CONFIG = {
|
|
@@ -2238,10 +2354,11 @@ var DetectionEventEmitter = class {
|
|
|
2238
2354
|
const eventType = ZONE_TYPE_TO_EVENT[ze.type];
|
|
2239
2355
|
if (!eventType) continue;
|
|
2240
2356
|
if (!this.config.enabledTypes.includes(eventType)) continue;
|
|
2241
|
-
const
|
|
2357
|
+
const present = trackMap.get(ze.trackId);
|
|
2358
|
+
const track = present ?? this.lastKnownTracks.get(ze.trackId);
|
|
2242
2359
|
if (!track || track.trackAge < this.config.minTrackAge) continue;
|
|
2243
2360
|
const state = stateMap.get(ze.trackId);
|
|
2244
|
-
this.valuedTracks.add(ze.trackId);
|
|
2361
|
+
if (present) this.valuedTracks.add(ze.trackId);
|
|
2245
2362
|
events.push({
|
|
2246
2363
|
id: `evt-${++eventIdCounter}`,
|
|
2247
2364
|
type: eventType,
|
|
@@ -2588,6 +2705,19 @@ var FrameProcessor = class {
|
|
|
2588
2705
|
/** Zone crossings per track — the producer of `zone.enter` / `zone.exit`. */
|
|
2589
2706
|
zoneTransitions = new ZoneTransitionTracker();
|
|
2590
2707
|
/**
|
|
2708
|
+
* Tripwire crossings. Separate from `zoneTransitions` because a tripwire is
|
|
2709
|
+
* a DIRECTED line, not an area: membership is meaningless and the question is
|
|
2710
|
+
* which way the subject went over it.
|
|
2711
|
+
*/
|
|
2712
|
+
tripwires = new TripwireTracker();
|
|
2713
|
+
/**
|
|
2714
|
+
* Last detection seen per track. An edge exit fires for a track that is
|
|
2715
|
+
* ABSENT from the frame, so there is no current detection to attach — this is
|
|
2716
|
+
* the one the subject had when it was last seen. Cleared with the rest of the
|
|
2717
|
+
* per-track caches when the track leaves.
|
|
2718
|
+
*/
|
|
2719
|
+
lastDetectionByTrack = /* @__PURE__ */ new Map();
|
|
2720
|
+
/**
|
|
2591
2721
|
* Last zone membership seen per track, so an event for a track that is GONE
|
|
2592
2722
|
* this frame still carries the zones it was in. See the use site.
|
|
2593
2723
|
*/
|
|
@@ -2783,6 +2913,7 @@ var FrameProcessor = class {
|
|
|
2783
2913
|
}
|
|
2784
2914
|
this.lastZoneOverlaps = overlapsByTrack;
|
|
2785
2915
|
this.lastZoneRejections = rejectedByTrack;
|
|
2916
|
+
for (const td of trackedDetections) this.lastDetectionByTrack.set(td.trackId, td);
|
|
2786
2917
|
for (const [trackId, zoneIds] of zonesByTrack) this.lastZonesByTrack.set(trackId, zoneIds);
|
|
2787
2918
|
for (const state of objectStates) {
|
|
2788
2919
|
if (zonesByTrack.has(state.trackId)) continue;
|
|
@@ -2801,16 +2932,58 @@ var FrameProcessor = class {
|
|
|
2801
2932
|
timestamp
|
|
2802
2933
|
});
|
|
2803
2934
|
};
|
|
2935
|
+
for (const state of objectStates) {
|
|
2936
|
+
if (state.state !== "leaving") continue;
|
|
2937
|
+
const stillInside = this.zoneTransitions.forget(state.trackId);
|
|
2938
|
+
this.tripwires.forget(state.trackId);
|
|
2939
|
+
if (stillInside.length === 0) continue;
|
|
2940
|
+
const last = this.lastDetectionByTrack.get(state.trackId);
|
|
2941
|
+
if (last === void 0) continue;
|
|
2942
|
+
for (const zoneId of stillInside) pushZoneEvent("zone-exit", zoneId, last);
|
|
2943
|
+
}
|
|
2804
2944
|
for (const td of trackedDetections) {
|
|
2805
2945
|
const crossings = this.zoneTransitions.observe(td.trackId, zonesByTrack.get(td.trackId) ?? []);
|
|
2806
2946
|
for (const zoneId of crossings.entered) pushZoneEvent("zone-enter", zoneId, td);
|
|
2807
2947
|
for (const zoneId of crossings.exited) pushZoneEvent("zone-exit", zoneId, td);
|
|
2808
2948
|
}
|
|
2949
|
+
const wires = this.zones.filter((z) => z.kind === "tripwire" && z.polygon.length >= 2);
|
|
2950
|
+
if (wires.length > 0) for (const td of trackedDetections) {
|
|
2951
|
+
const centroid = bboxCentroid({
|
|
2952
|
+
x: td.bbox.x / frameWidth,
|
|
2953
|
+
y: td.bbox.y / frameHeight,
|
|
2954
|
+
w: td.bbox.w / frameWidth,
|
|
2955
|
+
h: td.bbox.h / frameHeight
|
|
2956
|
+
});
|
|
2957
|
+
for (const zone of wires) {
|
|
2958
|
+
const a = zone.polygon[0];
|
|
2959
|
+
const b = zone.polygon[1];
|
|
2960
|
+
if (a === void 0 || b === void 0) continue;
|
|
2961
|
+
const wire = {
|
|
2962
|
+
p1: {
|
|
2963
|
+
x: a.x,
|
|
2964
|
+
y: a.y
|
|
2965
|
+
},
|
|
2966
|
+
p2: {
|
|
2967
|
+
x: b.x,
|
|
2968
|
+
y: b.y
|
|
2969
|
+
}
|
|
2970
|
+
};
|
|
2971
|
+
for (const cross of this.tripwires.observe(td.trackId, zone.id, wire, centroid)) zoneEvents.push({
|
|
2972
|
+
type: "tripwire-cross",
|
|
2973
|
+
zoneId: zone.id,
|
|
2974
|
+
zoneName: zone.name,
|
|
2975
|
+
trackId: td.trackId,
|
|
2976
|
+
detection: td,
|
|
2977
|
+
direction: cross.direction,
|
|
2978
|
+
timestamp
|
|
2979
|
+
});
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2809
2982
|
const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, zoneEvents, [], String(this.deviceId));
|
|
2810
2983
|
for (const state of objectStates) {
|
|
2811
2984
|
if (state.state !== "leaving") continue;
|
|
2812
|
-
this.zoneTransitions.forget(state.trackId);
|
|
2813
2985
|
this.lastZonesByTrack.delete(state.trackId);
|
|
2986
|
+
this.lastDetectionByTrack.delete(state.trackId);
|
|
2814
2987
|
}
|
|
2815
2988
|
const tracked = trackedDetections.map((td) => {
|
|
2816
2989
|
const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
|
|
@@ -2850,9 +3023,9 @@ var FrameProcessor = class {
|
|
|
2850
3023
|
};
|
|
2851
3024
|
});
|
|
2852
3025
|
const crossingOf = (e) => {
|
|
2853
|
-
const direction = e.type === "zone.enter" ? "enter" : e.type === "zone.exit" ? "exit" : void 0;
|
|
2854
|
-
if (direction === void 0) return void 0;
|
|
2855
3026
|
const ze = e.zoneEvents[0];
|
|
3027
|
+
const direction = e.type === "zone.enter" ? "enter" : e.type === "zone.exit" ? "exit" : e.type === "tripwire.cross" ? ze?.direction === "out" ? "exit" : "enter" : void 0;
|
|
3028
|
+
if (direction === void 0) return void 0;
|
|
2856
3029
|
if (ze === void 0) return void 0;
|
|
2857
3030
|
return {
|
|
2858
3031
|
direction,
|
|
@@ -4944,6 +5117,29 @@ var NcDispatcher = class {
|
|
|
4944
5117
|
} });
|
|
4945
5118
|
return { ok: true };
|
|
4946
5119
|
}
|
|
5120
|
+
if (this.deps.snoozeGate !== void 0) {
|
|
5121
|
+
const snoozeId = await this.deps.snoozeGate({
|
|
5122
|
+
ruleId: entry.ruleId,
|
|
5123
|
+
ruleName: entry.payload.ruleName,
|
|
5124
|
+
targetId: target.id,
|
|
5125
|
+
deviceId: entry.deviceId,
|
|
5126
|
+
...target.ownerUserId !== void 0 ? { ownerUserId: target.ownerUserId } : {},
|
|
5127
|
+
at: this.now()
|
|
5128
|
+
});
|
|
5129
|
+
if (snoozeId !== null) {
|
|
5130
|
+
this.deps.logger.info("notification suppressed by snooze", {
|
|
5131
|
+
tags: { deviceId: entry.deviceId },
|
|
5132
|
+
meta: {
|
|
5133
|
+
snoozeId,
|
|
5134
|
+
ruleId: entry.ruleId,
|
|
5135
|
+
targetId: target.id,
|
|
5136
|
+
target: target.name,
|
|
5137
|
+
eventId: entry.recordId
|
|
5138
|
+
}
|
|
5139
|
+
});
|
|
5140
|
+
return { ok: true };
|
|
5141
|
+
}
|
|
5142
|
+
}
|
|
4947
5143
|
const notification = await this.buildNotification(entry);
|
|
4948
5144
|
try {
|
|
4949
5145
|
const result = await this.deps.send({
|
|
@@ -4976,6 +5172,81 @@ var NcDispatcher = class {
|
|
|
4976
5172
|
};
|
|
4977
5173
|
}
|
|
4978
5174
|
}
|
|
5175
|
+
/**
|
|
5176
|
+
* Deliver ONE end-of-window snooze digest to one target.
|
|
5177
|
+
*
|
|
5178
|
+
* Best-effort by design, and the return value says which it was rather than
|
|
5179
|
+
* throwing: the caller marks the window handled either way, because a digest
|
|
5180
|
+
* retried every drain tick for hours is worse than a digest missed once — and
|
|
5181
|
+
* a partially-delivered one must never be re-sent to the targets that already
|
|
5182
|
+
* have it. A failure is logged with the window id, so a missing summary is a
|
|
5183
|
+
* findable line rather than a silence inside a silence.
|
|
5184
|
+
*
|
|
5185
|
+
* The digest carries no media and no `tag`: it is not an event, and tagging
|
|
5186
|
+
* it with a rule id would let a phone collapse it into the very
|
|
5187
|
+
* notifications it is reporting on.
|
|
5188
|
+
*/
|
|
5189
|
+
async deliverDigest(input) {
|
|
5190
|
+
let target;
|
|
5191
|
+
try {
|
|
5192
|
+
target = await this.resolveTarget(input.targetId);
|
|
5193
|
+
} catch (err) {
|
|
5194
|
+
this.deps.logger.warn("snooze digest: target catalog unreachable", { meta: {
|
|
5195
|
+
snoozeId: input.snoozeId,
|
|
5196
|
+
targetId: input.targetId,
|
|
5197
|
+
error: String(err)
|
|
5198
|
+
} });
|
|
5199
|
+
return false;
|
|
5200
|
+
}
|
|
5201
|
+
if (target === null || !target.enabled) {
|
|
5202
|
+
this.deps.logger.warn("snooze digest: target gone or disabled", { meta: {
|
|
5203
|
+
snoozeId: input.snoozeId,
|
|
5204
|
+
targetId: input.targetId
|
|
5205
|
+
} });
|
|
5206
|
+
return false;
|
|
5207
|
+
}
|
|
5208
|
+
try {
|
|
5209
|
+
const result = await this.deps.send({
|
|
5210
|
+
addonId: target.addonId,
|
|
5211
|
+
targetId: target.id,
|
|
5212
|
+
notification: {
|
|
5213
|
+
title: input.title,
|
|
5214
|
+
body: input.body,
|
|
5215
|
+
priority: 2
|
|
5216
|
+
}
|
|
5217
|
+
});
|
|
5218
|
+
if (!result.success) {
|
|
5219
|
+
this.deps.logger.warn("snooze digest send failed", { meta: {
|
|
5220
|
+
snoozeId: input.snoozeId,
|
|
5221
|
+
targetId: target.id,
|
|
5222
|
+
error: result.error
|
|
5223
|
+
} });
|
|
5224
|
+
return false;
|
|
5225
|
+
}
|
|
5226
|
+
this.deps.logger.info("snooze digest delivered", { meta: {
|
|
5227
|
+
snoozeId: input.snoozeId,
|
|
5228
|
+
target: target.name,
|
|
5229
|
+
hidden: input.total
|
|
5230
|
+
} });
|
|
5231
|
+
return true;
|
|
5232
|
+
} catch (err) {
|
|
5233
|
+
this.deps.logger.warn("snooze digest send threw", { meta: {
|
|
5234
|
+
snoozeId: input.snoozeId,
|
|
5235
|
+
targetId: target.id,
|
|
5236
|
+
error: String(err)
|
|
5237
|
+
} });
|
|
5238
|
+
return false;
|
|
5239
|
+
}
|
|
5240
|
+
}
|
|
5241
|
+
/** Device display names for a set of ids, for the digest's lines. */
|
|
5242
|
+
async resolveDeviceNames(deviceIds) {
|
|
5243
|
+
const out = /* @__PURE__ */ new Map();
|
|
5244
|
+
for (const id of new Set(deviceIds)) {
|
|
5245
|
+
const name = await this.deps.getDeviceName(id).catch(() => null);
|
|
5246
|
+
if (name !== null) out.set(id, name);
|
|
5247
|
+
}
|
|
5248
|
+
return out;
|
|
5249
|
+
}
|
|
4979
5250
|
async resolveTarget(targetId) {
|
|
4980
5251
|
const cached = this.cachedTarget(targetId);
|
|
4981
5252
|
if (cached !== null) return cached;
|
|
@@ -5094,7 +5365,8 @@ var NcDispatcher = class {
|
|
|
5094
5365
|
format: want.format,
|
|
5095
5366
|
...entry.payload.mediaClipPreRollSec !== void 0 ? { preRollSec: entry.payload.mediaClipPreRollSec } : {},
|
|
5096
5367
|
...entry.payload.mediaClipPostRollSec !== void 0 ? { postRollSec: entry.payload.mediaClipPostRollSec } : {},
|
|
5097
|
-
...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {}
|
|
5368
|
+
...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {},
|
|
5369
|
+
...entry.payload.mediaClipSpeed !== void 0 ? { speed: entry.payload.mediaClipSpeed } : {}
|
|
5098
5370
|
});
|
|
5099
5371
|
if (rendered !== null && rendered.byteLength > 0) {
|
|
5100
5372
|
const bytes = new Uint8Array(rendered.byteLength);
|
|
@@ -6335,6 +6607,474 @@ var NcRuleStore = class {
|
|
|
6335
6607
|
}
|
|
6336
6608
|
};
|
|
6337
6609
|
//#endregion
|
|
6610
|
+
//#region src/notification-center/snooze-digest.ts
|
|
6611
|
+
/** `14:03` in the node's local zone — a digest is read by a person, at home. */
|
|
6612
|
+
function clock(ms) {
|
|
6613
|
+
const d = new Date(ms);
|
|
6614
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
6615
|
+
}
|
|
6616
|
+
function plural(n, one, many) {
|
|
6617
|
+
return n === 1 ? `1 ${one}` : `${n} ${many}`;
|
|
6618
|
+
}
|
|
6619
|
+
/**
|
|
6620
|
+
* What the window covered, in the words the operator chose it with — the rule's
|
|
6621
|
+
* NAME, the camera's name. "a rule was snoozed" tells the reader nothing they
|
|
6622
|
+
* could act on.
|
|
6623
|
+
*/
|
|
6624
|
+
function scopeLabel(snooze, rows, deviceNames) {
|
|
6625
|
+
switch (snooze.scope) {
|
|
6626
|
+
case "all": return "Everything";
|
|
6627
|
+
case "device": {
|
|
6628
|
+
const id = snooze.deviceId;
|
|
6629
|
+
if (id === void 0) return "A camera";
|
|
6630
|
+
return deviceNames.get(id) ?? `Camera ${id}`;
|
|
6631
|
+
}
|
|
6632
|
+
case "rule": {
|
|
6633
|
+
const named = rows.find((r) => r.ruleId === snooze.ruleId);
|
|
6634
|
+
return named !== void 0 ? `“${named.ruleName}”` : "A rule";
|
|
6635
|
+
}
|
|
6636
|
+
}
|
|
6637
|
+
}
|
|
6638
|
+
/**
|
|
6639
|
+
* One digest for ONE target — the counters passed in are already the rows
|
|
6640
|
+
* belonging to it, so the message says what THAT destination missed rather
|
|
6641
|
+
* than what the household did.
|
|
6642
|
+
*
|
|
6643
|
+
* Returns null when the window hid nothing: "snooze ended, 0 notifications" is
|
|
6644
|
+
* a notification the operator did not need, and sending it for every quiet
|
|
6645
|
+
* window is how a summary becomes the noise it was meant to suppress.
|
|
6646
|
+
*/
|
|
6647
|
+
function buildSnoozeDigest(snooze, rows, deviceNames) {
|
|
6648
|
+
const total = rows.reduce((sum, r) => sum + r.count, 0);
|
|
6649
|
+
if (total === 0) return null;
|
|
6650
|
+
const from = clock(Math.min(...rows.map((r) => r.firstAt)));
|
|
6651
|
+
const to = clock(snooze.expiresAt);
|
|
6652
|
+
const title = `Snooze ended — ${plural(total, "notification", "notifications")} hidden`;
|
|
6653
|
+
const lines = rows.toSorted((a, b) => b.count - a.count || a.ruleName.localeCompare(b.ruleName)).map((r) => {
|
|
6654
|
+
const where = deviceNames.get(r.deviceId) ?? `camera ${r.deviceId}`;
|
|
6655
|
+
return `• ${r.ruleName} on ${where} — ${r.count}`;
|
|
6656
|
+
});
|
|
6657
|
+
return {
|
|
6658
|
+
title,
|
|
6659
|
+
body: [`${scopeLabel(snooze, rows, deviceNames)} was snoozed${snooze.global ? " for everyone" : ""}, ${from}–${to}.`, ...lines].join("\n"),
|
|
6660
|
+
total
|
|
6661
|
+
};
|
|
6662
|
+
}
|
|
6663
|
+
/** The distinct targets a window owes a digest to. */
|
|
6664
|
+
function digestTargets(rows) {
|
|
6665
|
+
return [...new Set(rows.map((r) => r.targetId))];
|
|
6666
|
+
}
|
|
6667
|
+
//#endregion
|
|
6668
|
+
//#region src/notification-center/snooze-policy.ts
|
|
6669
|
+
/** How broad a scope is, for choosing between overlapping snoozes. */
|
|
6670
|
+
var BREADTH = {
|
|
6671
|
+
rule: 1,
|
|
6672
|
+
device: 2,
|
|
6673
|
+
all: 3
|
|
6674
|
+
};
|
|
6675
|
+
function covers(entry, q) {
|
|
6676
|
+
if (q.nowMs < entry.startedAt) return false;
|
|
6677
|
+
if (q.nowMs >= entry.expiresAt) return false;
|
|
6678
|
+
if (!entry.global && entry.userId !== q.userId) return false;
|
|
6679
|
+
switch (entry.scope) {
|
|
6680
|
+
case "all": return true;
|
|
6681
|
+
case "rule": return entry.ruleId !== void 0 && entry.ruleId === q.ruleId;
|
|
6682
|
+
case "device": return entry.deviceId !== void 0 && entry.deviceId === q.deviceId;
|
|
6683
|
+
}
|
|
6684
|
+
}
|
|
6685
|
+
/**
|
|
6686
|
+
* The snooze that silences this notification, or null.
|
|
6687
|
+
*
|
|
6688
|
+
* The BROADEST match wins so one window produces one digest: an "all" snooze
|
|
6689
|
+
* overlapping a rule snooze should not leave the recipient two summaries for
|
|
6690
|
+
* the same silence.
|
|
6691
|
+
*/
|
|
6692
|
+
function activeSnoozeFor(entries, q) {
|
|
6693
|
+
let best = null;
|
|
6694
|
+
for (const e of entries) {
|
|
6695
|
+
if (!covers(e, q)) continue;
|
|
6696
|
+
if (best === null || BREADTH[e.scope] > BREADTH[best.scope]) best = e;
|
|
6697
|
+
}
|
|
6698
|
+
return best;
|
|
6699
|
+
}
|
|
6700
|
+
/**
|
|
6701
|
+
* May this caller silence EVERYONE, not just themselves?
|
|
6702
|
+
*
|
|
6703
|
+
* Global is the only part of a snooze that can affect a person who did not ask
|
|
6704
|
+
* for it, so it is never reachable by accident:
|
|
6705
|
+
*
|
|
6706
|
+
* - **rule scope** — the rule itself must opt in (`snoozeAllowGlobal`, default
|
|
6707
|
+
* off). That is the gardener case: this rule is known to be noisy for
|
|
6708
|
+
* everybody in a way that is legitimate to silence for everybody.
|
|
6709
|
+
* - **device or whole-feed scope** — there is no rule to carry the opt-in, and
|
|
6710
|
+
* "silence this camera for the household" is an administrative act. Admin
|
|
6711
|
+
* only.
|
|
6712
|
+
*
|
|
6713
|
+
* A personal snooze needs no permission at all: it silences only its author.
|
|
6714
|
+
*/
|
|
6715
|
+
function canSetGlobal(req) {
|
|
6716
|
+
if (req.isAdmin) return true;
|
|
6717
|
+
if (req.scope !== "rule") return false;
|
|
6718
|
+
return req.ruleAllowsGlobal === true;
|
|
6719
|
+
}
|
|
6720
|
+
/** Entries whose window has closed and whose digest has not been sent yet. */
|
|
6721
|
+
function dueSummaries(entries, nowMs, alreadySent) {
|
|
6722
|
+
return entries.filter((e) => e.summary && nowMs >= e.expiresAt && !alreadySent.has(e.id));
|
|
6723
|
+
}
|
|
6724
|
+
//#endregion
|
|
6725
|
+
//#region src/notification-center/snooze-store.ts
|
|
6726
|
+
/**
|
|
6727
|
+
* NcSnoozeStore — the durable half of snooze.
|
|
6728
|
+
*
|
|
6729
|
+
* Two collections, and the split matters:
|
|
6730
|
+
*
|
|
6731
|
+
* - **windows** (`notification-center:snoozes`) — small, mirrored into RAM,
|
|
6732
|
+
* read on the dispatcher's hot path with zero I/O. Refreshed on the drain
|
|
6733
|
+
* tick, so a window opened on the hub takes effect on the evaluating node
|
|
6734
|
+
* within one tick (D8: reconcile, never trust an event to arrive).
|
|
6735
|
+
* - **suppression counters** (`notification-center:snooze-suppressed`) — one
|
|
6736
|
+
* row per (window, target, rule, camera), holding a COUNT and the first/last
|
|
6737
|
+
* instant. Not one row per hidden notification: a snooze exists because
|
|
6738
|
+
* something is noisy, so the row-per-event version of this table is unbounded
|
|
6739
|
+
* exactly when it is used most. The digest reads "42 events, 14:03–17:58",
|
|
6740
|
+
* which is the aggregate anyway.
|
|
6741
|
+
*
|
|
6742
|
+
* Expiry is never a job here either. Nothing in this file ends a window; the
|
|
6743
|
+
* clock does, and {@link NcSnoozeStore.prune} only reclaims rows long after the
|
|
6744
|
+
* fact. A prune that never runs delays a digest and wastes a few KB — it can
|
|
6745
|
+
* never leave the operator silenced.
|
|
6746
|
+
*/
|
|
6747
|
+
var NC_SNOOZES_COLLECTION = "notification-center:snoozes";
|
|
6748
|
+
var NC_SNOOZE_SUPPRESSED_COLLECTION = "notification-center:snooze-suppressed";
|
|
6749
|
+
var NC_SNOOZES_COLUMNS = [
|
|
6750
|
+
{
|
|
6751
|
+
name: "id",
|
|
6752
|
+
type: "TEXT",
|
|
6753
|
+
primaryKey: true,
|
|
6754
|
+
notNull: true
|
|
6755
|
+
},
|
|
6756
|
+
{
|
|
6757
|
+
name: "userId",
|
|
6758
|
+
type: "TEXT",
|
|
6759
|
+
notNull: true
|
|
6760
|
+
},
|
|
6761
|
+
{
|
|
6762
|
+
name: "expiresAt",
|
|
6763
|
+
type: "INTEGER",
|
|
6764
|
+
notNull: true
|
|
6765
|
+
},
|
|
6766
|
+
(
|
|
6767
|
+
/** The FULL window (Zod-validated on read); the scalars are projections. */
|
|
6768
|
+
{
|
|
6769
|
+
name: "snooze",
|
|
6770
|
+
type: "JSON",
|
|
6771
|
+
notNull: true
|
|
6772
|
+
})
|
|
6773
|
+
];
|
|
6774
|
+
var NC_SNOOZES_INDEXES = [{
|
|
6775
|
+
name: "idx_nc_snoozes_expires",
|
|
6776
|
+
columns: ["expiresAt"]
|
|
6777
|
+
}];
|
|
6778
|
+
var NC_SNOOZE_SUPPRESSED_COLUMNS = [
|
|
6779
|
+
{
|
|
6780
|
+
name: "id",
|
|
6781
|
+
type: "TEXT",
|
|
6782
|
+
primaryKey: true,
|
|
6783
|
+
notNull: true
|
|
6784
|
+
},
|
|
6785
|
+
{
|
|
6786
|
+
name: "snoozeId",
|
|
6787
|
+
type: "TEXT",
|
|
6788
|
+
notNull: true
|
|
6789
|
+
},
|
|
6790
|
+
{
|
|
6791
|
+
name: "targetId",
|
|
6792
|
+
type: "TEXT",
|
|
6793
|
+
notNull: true
|
|
6794
|
+
},
|
|
6795
|
+
{
|
|
6796
|
+
name: "ruleId",
|
|
6797
|
+
type: "TEXT",
|
|
6798
|
+
notNull: true
|
|
6799
|
+
},
|
|
6800
|
+
{
|
|
6801
|
+
name: "ruleName",
|
|
6802
|
+
type: "TEXT",
|
|
6803
|
+
notNull: true
|
|
6804
|
+
},
|
|
6805
|
+
{
|
|
6806
|
+
name: "deviceId",
|
|
6807
|
+
type: "INTEGER",
|
|
6808
|
+
notNull: true
|
|
6809
|
+
},
|
|
6810
|
+
{
|
|
6811
|
+
name: "count",
|
|
6812
|
+
type: "INTEGER",
|
|
6813
|
+
notNull: true
|
|
6814
|
+
},
|
|
6815
|
+
{
|
|
6816
|
+
name: "firstAt",
|
|
6817
|
+
type: "INTEGER",
|
|
6818
|
+
notNull: true
|
|
6819
|
+
},
|
|
6820
|
+
{
|
|
6821
|
+
name: "lastAt",
|
|
6822
|
+
type: "INTEGER",
|
|
6823
|
+
notNull: true
|
|
6824
|
+
}
|
|
6825
|
+
];
|
|
6826
|
+
var NC_SNOOZE_SUPPRESSED_INDEXES = [{
|
|
6827
|
+
name: "idx_nc_snooze_suppressed_snooze",
|
|
6828
|
+
columns: ["snoozeId"]
|
|
6829
|
+
}];
|
|
6830
|
+
/** How long a closed window's rows survive, for a retried digest and for
|
|
6831
|
+
* "what did I miss" to still answer after a restart. */
|
|
6832
|
+
var PRUNE_HORIZON_MS = 6 * 36e5;
|
|
6833
|
+
/** Counter key — one row per (window, target, rule, camera). */
|
|
6834
|
+
function counterKey(s) {
|
|
6835
|
+
return `${s.snoozeId}|${s.targetId}|${s.ruleId}|${s.deviceId}`;
|
|
6836
|
+
}
|
|
6837
|
+
var NcSnoozeStore = class {
|
|
6838
|
+
byId = /* @__PURE__ */ new Map();
|
|
6839
|
+
store;
|
|
6840
|
+
logger;
|
|
6841
|
+
now;
|
|
6842
|
+
newId;
|
|
6843
|
+
constructor(deps) {
|
|
6844
|
+
this.store = deps.store;
|
|
6845
|
+
this.logger = deps.logger;
|
|
6846
|
+
this.now = deps.now ?? (() => Date.now());
|
|
6847
|
+
this.newId = deps.newId ?? (() => randomUUID());
|
|
6848
|
+
}
|
|
6849
|
+
static async declare(store) {
|
|
6850
|
+
await store.declareCollection.mutate({
|
|
6851
|
+
collection: NC_SNOOZES_COLLECTION,
|
|
6852
|
+
columns: [...NC_SNOOZES_COLUMNS],
|
|
6853
|
+
indexes: [...NC_SNOOZES_INDEXES]
|
|
6854
|
+
});
|
|
6855
|
+
await store.declareCollection.mutate({
|
|
6856
|
+
collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
|
|
6857
|
+
columns: [...NC_SNOOZE_SUPPRESSED_COLUMNS],
|
|
6858
|
+
indexes: [...NC_SNOOZE_SUPPRESSED_INDEXES]
|
|
6859
|
+
});
|
|
6860
|
+
}
|
|
6861
|
+
/**
|
|
6862
|
+
* (Re)hydrate every window. Called at boot and on the drain tick — the table
|
|
6863
|
+
* holds active windows and recently closed ones, so it stays small enough to
|
|
6864
|
+
* read whole. A row whose JSON no longer validates is SKIPPED: a malformed
|
|
6865
|
+
* window must never be able to silence anything.
|
|
6866
|
+
*/
|
|
6867
|
+
async load() {
|
|
6868
|
+
try {
|
|
6869
|
+
const rows = await this.store.query.query({
|
|
6870
|
+
collection: NC_SNOOZES_COLLECTION,
|
|
6871
|
+
filter: { limit: 1e3 }
|
|
6872
|
+
});
|
|
6873
|
+
this.byId.clear();
|
|
6874
|
+
let skipped = 0;
|
|
6875
|
+
for (const row of rows) {
|
|
6876
|
+
const parsed = NcSnoozeSchema.safeParse(row.data["snooze"]);
|
|
6877
|
+
if (!parsed.success) {
|
|
6878
|
+
skipped += 1;
|
|
6879
|
+
continue;
|
|
6880
|
+
}
|
|
6881
|
+
this.byId.set(parsed.data.id, parsed.data);
|
|
6882
|
+
}
|
|
6883
|
+
if (skipped > 0) this.logger.warn("snooze rows skipped as malformed — they silence NOTHING", { meta: { skipped } });
|
|
6884
|
+
} catch (err) {
|
|
6885
|
+
this.logger.warn("snooze load failed — keeping the windows already in memory", { meta: { error: String(err) } });
|
|
6886
|
+
}
|
|
6887
|
+
}
|
|
6888
|
+
/** Every known window, newest first. */
|
|
6889
|
+
list() {
|
|
6890
|
+
return [...this.byId.values()].toSorted((a, b) => b.startedAt - a.startedAt);
|
|
6891
|
+
}
|
|
6892
|
+
/** The windows a caller may SEE: their own, plus the global ones that
|
|
6893
|
+
* silence them. Never another person's private silence. */
|
|
6894
|
+
listVisibleTo(userId, isAdmin) {
|
|
6895
|
+
if (isAdmin) return this.list();
|
|
6896
|
+
return this.list().filter((s) => s.userId === userId || s.global);
|
|
6897
|
+
}
|
|
6898
|
+
get(snoozeId) {
|
|
6899
|
+
return this.byId.get(snoozeId) ?? null;
|
|
6900
|
+
}
|
|
6901
|
+
/** Hot-path read — the window silencing this delivery, or null. */
|
|
6902
|
+
activeFor(query) {
|
|
6903
|
+
return activeSnoozeFor([...this.byId.values()], query);
|
|
6904
|
+
}
|
|
6905
|
+
/**
|
|
6906
|
+
* Open a window. Permission for `global` is the CALLER's problem (see
|
|
6907
|
+
* `canSetGlobal`) — this store persists what it is given, exactly like the
|
|
6908
|
+
* rule store leaves ownership to the bridge action.
|
|
6909
|
+
*/
|
|
6910
|
+
async create(input) {
|
|
6911
|
+
const startedAt = this.now();
|
|
6912
|
+
const snooze = NcSnoozeSchema.parse({
|
|
6913
|
+
id: this.newId(),
|
|
6914
|
+
userId: input.userId,
|
|
6915
|
+
scope: input.scope,
|
|
6916
|
+
...input.ruleId !== void 0 ? { ruleId: input.ruleId } : {},
|
|
6917
|
+
...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
|
|
6918
|
+
startedAt,
|
|
6919
|
+
expiresAt: startedAt + input.durationMinutes * 6e4,
|
|
6920
|
+
global: input.global,
|
|
6921
|
+
summary: input.summary
|
|
6922
|
+
});
|
|
6923
|
+
await this.persist(snooze);
|
|
6924
|
+
this.byId.set(snooze.id, snooze);
|
|
6925
|
+
return snooze;
|
|
6926
|
+
}
|
|
6927
|
+
/**
|
|
6928
|
+
* End a window NOW by moving its expiry to this instant — never by deleting
|
|
6929
|
+
* it. The digest of what it hid is still owed to whoever opened it, and a
|
|
6930
|
+
* deleted row owes nothing.
|
|
6931
|
+
*/
|
|
6932
|
+
async cancel(snoozeId) {
|
|
6933
|
+
const existing = this.byId.get(snoozeId);
|
|
6934
|
+
if (existing === void 0) return null;
|
|
6935
|
+
const now = this.now();
|
|
6936
|
+
if (existing.expiresAt <= now) return existing;
|
|
6937
|
+
const ended = {
|
|
6938
|
+
...existing,
|
|
6939
|
+
expiresAt: now
|
|
6940
|
+
};
|
|
6941
|
+
await this.persist(ended);
|
|
6942
|
+
this.byId.set(ended.id, ended);
|
|
6943
|
+
return ended;
|
|
6944
|
+
}
|
|
6945
|
+
/**
|
|
6946
|
+
* Record that a window hid one notification. Read-modify-write on the
|
|
6947
|
+
* counter row; best-effort, because losing a line of a SUMMARY must never
|
|
6948
|
+
* cost the suppression decision that produced it.
|
|
6949
|
+
*/
|
|
6950
|
+
async recordSuppressed(s) {
|
|
6951
|
+
const key = counterKey(s);
|
|
6952
|
+
try {
|
|
6953
|
+
const existing = await this.readCounter(key);
|
|
6954
|
+
const next = {
|
|
6955
|
+
snoozeId: s.snoozeId,
|
|
6956
|
+
targetId: s.targetId,
|
|
6957
|
+
ruleId: s.ruleId,
|
|
6958
|
+
ruleName: s.ruleName,
|
|
6959
|
+
deviceId: s.deviceId,
|
|
6960
|
+
count: (existing?.count ?? 0) + 1,
|
|
6961
|
+
firstAt: existing?.firstAt ?? s.at,
|
|
6962
|
+
lastAt: s.at
|
|
6963
|
+
};
|
|
6964
|
+
await this.store.set.mutate({
|
|
6965
|
+
collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
|
|
6966
|
+
key,
|
|
6967
|
+
value: { ...next }
|
|
6968
|
+
});
|
|
6969
|
+
} catch (err) {
|
|
6970
|
+
this.logger.warn("snooze suppression counter write failed (digest will under-report)", {
|
|
6971
|
+
tags: { deviceId: s.deviceId },
|
|
6972
|
+
meta: {
|
|
6973
|
+
snoozeId: s.snoozeId,
|
|
6974
|
+
ruleId: s.ruleId,
|
|
6975
|
+
error: String(err)
|
|
6976
|
+
}
|
|
6977
|
+
});
|
|
6978
|
+
}
|
|
6979
|
+
}
|
|
6980
|
+
/** Windows that have closed and still owe a digest. */
|
|
6981
|
+
dueDigests(nowMs) {
|
|
6982
|
+
const sent = new Set([...this.byId.values()].filter((s) => s.digestSentAt !== void 0).map((s) => s.id));
|
|
6983
|
+
return dueSummaries([...this.byId.values()], nowMs, sent);
|
|
6984
|
+
}
|
|
6985
|
+
/** What one window hid, grouped per target. */
|
|
6986
|
+
async suppressedFor(snoozeId) {
|
|
6987
|
+
try {
|
|
6988
|
+
const rows = await this.store.query.query({
|
|
6989
|
+
collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
|
|
6990
|
+
filter: {
|
|
6991
|
+
where: { snoozeId },
|
|
6992
|
+
limit: 1e3
|
|
6993
|
+
}
|
|
6994
|
+
});
|
|
6995
|
+
const out = [];
|
|
6996
|
+
for (const row of rows) {
|
|
6997
|
+
const parsed = NcSnoozeSuppressedSchema.safeParse(row.data);
|
|
6998
|
+
if (parsed.success) out.push(parsed.data);
|
|
6999
|
+
}
|
|
7000
|
+
return out;
|
|
7001
|
+
} catch (err) {
|
|
7002
|
+
this.logger.warn("snooze suppression read failed", { meta: {
|
|
7003
|
+
snoozeId,
|
|
7004
|
+
error: String(err)
|
|
7005
|
+
} });
|
|
7006
|
+
return [];
|
|
7007
|
+
}
|
|
7008
|
+
}
|
|
7009
|
+
/**
|
|
7010
|
+
* Mark a digest as handled.
|
|
7011
|
+
*
|
|
7012
|
+
* Called after the ATTEMPT, not after a success: a digest that cannot be
|
|
7013
|
+
* delivered is not worth retrying every two seconds for six hours, and a
|
|
7014
|
+
* partially-sent one must not be re-sent to the targets that already got it.
|
|
7015
|
+
* The failure is logged with the window id — a missing digest is then a
|
|
7016
|
+
* findable line, not a silence inside a silence.
|
|
7017
|
+
*/
|
|
7018
|
+
async markDigestSent(snoozeId) {
|
|
7019
|
+
const existing = this.byId.get(snoozeId);
|
|
7020
|
+
if (existing === void 0) return;
|
|
7021
|
+
const marked = {
|
|
7022
|
+
...existing,
|
|
7023
|
+
digestSentAt: this.now()
|
|
7024
|
+
};
|
|
7025
|
+
await this.persist(marked);
|
|
7026
|
+
this.byId.set(marked.id, marked);
|
|
7027
|
+
}
|
|
7028
|
+
/**
|
|
7029
|
+
* Drop windows that closed more than {@link PRUNE_HORIZON_MS} ago, and their
|
|
7030
|
+
* counters. Only ever touches CLOSED windows — an active one cannot be
|
|
7031
|
+
* pruned into oblivion by a clock argument.
|
|
7032
|
+
*/
|
|
7033
|
+
async prune(nowMs) {
|
|
7034
|
+
const horizon = nowMs - PRUNE_HORIZON_MS;
|
|
7035
|
+
const stale = [...this.byId.values()].filter((s) => s.expiresAt < horizon);
|
|
7036
|
+
let dropped = 0;
|
|
7037
|
+
for (const s of stale) try {
|
|
7038
|
+
await this.store.deleteWhere.mutate({
|
|
7039
|
+
collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
|
|
7040
|
+
filter: { where: { snoozeId: s.id } }
|
|
7041
|
+
});
|
|
7042
|
+
await this.store.delete.mutate({
|
|
7043
|
+
collection: NC_SNOOZES_COLLECTION,
|
|
7044
|
+
key: s.id
|
|
7045
|
+
});
|
|
7046
|
+
this.byId.delete(s.id);
|
|
7047
|
+
dropped += 1;
|
|
7048
|
+
} catch (err) {
|
|
7049
|
+
this.logger.warn("snooze prune failed", { meta: {
|
|
7050
|
+
snoozeId: s.id,
|
|
7051
|
+
error: String(err)
|
|
7052
|
+
} });
|
|
7053
|
+
}
|
|
7054
|
+
return dropped;
|
|
7055
|
+
}
|
|
7056
|
+
/** `get` returns the stored VALUE, not a `{ id, data }` row. */
|
|
7057
|
+
async readCounter(key) {
|
|
7058
|
+
const value = await this.store.get.query({
|
|
7059
|
+
collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
|
|
7060
|
+
key
|
|
7061
|
+
});
|
|
7062
|
+
const parsed = NcSnoozeSuppressedSchema.safeParse(value);
|
|
7063
|
+
return parsed.success ? parsed.data : null;
|
|
7064
|
+
}
|
|
7065
|
+
async persist(snooze) {
|
|
7066
|
+
await this.store.set.mutate({
|
|
7067
|
+
collection: NC_SNOOZES_COLLECTION,
|
|
7068
|
+
key: snooze.id,
|
|
7069
|
+
value: {
|
|
7070
|
+
userId: snooze.userId,
|
|
7071
|
+
expiresAt: snooze.expiresAt,
|
|
7072
|
+
snooze
|
|
7073
|
+
}
|
|
7074
|
+
});
|
|
7075
|
+
}
|
|
7076
|
+
};
|
|
7077
|
+
//#endregion
|
|
6338
7078
|
//#region src/notification-center/timelapse/timelapse-store.ts
|
|
6339
7079
|
var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
|
|
6340
7080
|
var NC_TIMELAPSE_RULES_COLUMNS = [
|
|
@@ -6696,6 +7436,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6696
7436
|
* snapshots; watched keys are recomputed from the enabled occupancy rules. */
|
|
6697
7437
|
occupancyWatcher = new OccupancyWatcher();
|
|
6698
7438
|
occupancyStore;
|
|
7439
|
+
/** Snooze windows + what they hid. Mirrored into RAM; the dispatcher's gate
|
|
7440
|
+
* reads it with no I/O, the drain tick refreshes it. */
|
|
7441
|
+
snoozes;
|
|
6699
7442
|
/** True when ≥1 enabled `device-event` rule declares an occupancy condition —
|
|
6700
7443
|
* the watcher is idle (zero per-frame cost) otherwise. */
|
|
6701
7444
|
occupancyEnabled = false;
|
|
@@ -6728,10 +7471,17 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6728
7471
|
store: deps.store,
|
|
6729
7472
|
logger: this.logger.child("occupancy")
|
|
6730
7473
|
});
|
|
7474
|
+
this.snoozes = new NcSnoozeStore({
|
|
7475
|
+
store: deps.store,
|
|
7476
|
+
logger: this.logger.child("snooze"),
|
|
7477
|
+
...deps.now !== void 0 ? { now: deps.now } : {}
|
|
7478
|
+
});
|
|
6731
7479
|
this.dispatcher = new NcDispatcher({
|
|
6732
7480
|
...deps.dispatcher,
|
|
6733
7481
|
logger: this.logger.child("dispatch"),
|
|
6734
|
-
|
|
7482
|
+
...deps.now !== void 0 ? { now: deps.now } : {},
|
|
7483
|
+
isRuleTargetDisabled: (ruleId, targetId) => this.rules.isRuleTargetDisabled(ruleId, targetId),
|
|
7484
|
+
snoozeGate: (input) => this.snoozeGate(input)
|
|
6735
7485
|
});
|
|
6736
7486
|
this.outbox = new NcOutbox({
|
|
6737
7487
|
store: deps.store,
|
|
@@ -6761,6 +7511,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6761
7511
|
await NcRuleStore.declare(store);
|
|
6762
7512
|
await NcOutbox.declare(store);
|
|
6763
7513
|
await OccupancyStore.declare(store);
|
|
7514
|
+
await NcSnoozeStore.declare(store);
|
|
6764
7515
|
await TimelapseStore.declare(store);
|
|
6765
7516
|
}
|
|
6766
7517
|
/**
|
|
@@ -6771,6 +7522,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6771
7522
|
*/
|
|
6772
7523
|
async start(opts) {
|
|
6773
7524
|
await this.rules.load();
|
|
7525
|
+
await this.snoozes.load();
|
|
6774
7526
|
this.refreshOccupancyWatch();
|
|
6775
7527
|
if (!opts.evaluation) return;
|
|
6776
7528
|
this.evaluationActive = true;
|
|
@@ -6999,6 +7751,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6999
7751
|
this.logger.debug("getHistory: skipping malformed outbox row", { meta: { id: entry.id } });
|
|
7000
7752
|
}
|
|
7001
7753
|
return { entries: mapped };
|
|
7754
|
+
},
|
|
7755
|
+
listSnoozes: async ({ caller }) => ({ snoozes: [...this.snoozes.listVisibleTo(caller.userId, caller.isAdmin)] }),
|
|
7756
|
+
createSnooze: async ({ snooze, caller }) => ({ snooze: await this.createSnooze(snooze, caller) }),
|
|
7757
|
+
cancelSnooze: async ({ snoozeId, caller }) => {
|
|
7758
|
+
await this.cancelSnooze(snoozeId, caller);
|
|
7759
|
+
return { success: true };
|
|
7002
7760
|
}
|
|
7003
7761
|
};
|
|
7004
7762
|
}
|
|
@@ -7129,6 +7887,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
7129
7887
|
...rule.media.gif === true ? { mediaGif: true } : {},
|
|
7130
7888
|
...rule.media.clip === true ? { mediaClip: true } : {},
|
|
7131
7889
|
...rule.media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: rule.media.clipPreRollSec } : {},
|
|
7890
|
+
...rule.media?.clipSpeed !== void 0 ? { mediaClipSpeed: rule.media.clipSpeed } : {},
|
|
7132
7891
|
...rule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: rule.media.clipPostRollSec } : {},
|
|
7133
7892
|
...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
|
|
7134
7893
|
...target.params !== void 0 ? { params: target.params } : {},
|
|
@@ -7321,9 +8080,127 @@ var NotificationCenter = class NotificationCenter {
|
|
|
7321
8080
|
await this.outbox.setWatermark(now);
|
|
7322
8081
|
}
|
|
7323
8082
|
async drainTick() {
|
|
8083
|
+
await this.snoozes.load();
|
|
7324
8084
|
await this.outbox.drainOnce();
|
|
8085
|
+
await this.deliverDueDigests();
|
|
7325
8086
|
this.drainTicks += 1;
|
|
7326
|
-
if (this.drainTicks % WATERMARK_EVERY_TICKS === 0)
|
|
8087
|
+
if (this.drainTicks % WATERMARK_EVERY_TICKS === 0) {
|
|
8088
|
+
await this.outbox.setWatermark(this.now());
|
|
8089
|
+
await this.snoozes.prune(this.now());
|
|
8090
|
+
}
|
|
8091
|
+
}
|
|
8092
|
+
/**
|
|
8093
|
+
* The dispatcher's gate: is this delivery silenced, and if so, count it.
|
|
8094
|
+
*
|
|
8095
|
+
* Returns the window's id (the dispatcher logs it) or null. Recording is
|
|
8096
|
+
* awaited rather than fired off so a suppression cannot be lost to a shutdown
|
|
8097
|
+
* between the decision and the row — an under-reported digest is the one
|
|
8098
|
+
* failure of this feature the operator cannot detect.
|
|
8099
|
+
*/
|
|
8100
|
+
async snoozeGate(input) {
|
|
8101
|
+
const active = this.snoozes.activeFor({
|
|
8102
|
+
nowMs: input.at,
|
|
8103
|
+
...input.ownerUserId !== void 0 ? { userId: input.ownerUserId } : {},
|
|
8104
|
+
ruleId: input.ruleId,
|
|
8105
|
+
deviceId: input.deviceId
|
|
8106
|
+
});
|
|
8107
|
+
if (active === null) return null;
|
|
8108
|
+
if (active.summary) await this.snoozes.recordSuppressed({
|
|
8109
|
+
snoozeId: active.id,
|
|
8110
|
+
targetId: input.targetId,
|
|
8111
|
+
ruleId: input.ruleId,
|
|
8112
|
+
ruleName: input.ruleName,
|
|
8113
|
+
deviceId: input.deviceId,
|
|
8114
|
+
at: input.at
|
|
8115
|
+
});
|
|
8116
|
+
return active.id;
|
|
8117
|
+
}
|
|
8118
|
+
/**
|
|
8119
|
+
* Send the summary owed by every window that has closed since the last tick.
|
|
8120
|
+
*
|
|
8121
|
+
* The digest goes exactly where the silenced notifications would have gone —
|
|
8122
|
+
* one per target that was actually suppressed — so it needs no routing of its
|
|
8123
|
+
* own and can never reach someone the snooze never touched.
|
|
8124
|
+
*/
|
|
8125
|
+
async deliverDueDigests() {
|
|
8126
|
+
const due = this.snoozes.dueDigests(this.now());
|
|
8127
|
+
for (const snooze of due) try {
|
|
8128
|
+
const rows = await this.snoozes.suppressedFor(snooze.id);
|
|
8129
|
+
const names = await this.dispatcher.resolveDeviceNames(rows.map((r) => r.deviceId));
|
|
8130
|
+
for (const targetId of digestTargets(rows)) {
|
|
8131
|
+
const digest = buildSnoozeDigest(snooze, rows.filter((r) => r.targetId === targetId), names);
|
|
8132
|
+
if (digest === null) continue;
|
|
8133
|
+
await this.dispatcher.deliverDigest({
|
|
8134
|
+
snoozeId: snooze.id,
|
|
8135
|
+
targetId,
|
|
8136
|
+
title: digest.title,
|
|
8137
|
+
body: digest.body,
|
|
8138
|
+
total: digest.total
|
|
8139
|
+
});
|
|
8140
|
+
}
|
|
8141
|
+
await this.snoozes.markDigestSent(snooze.id);
|
|
8142
|
+
} catch (err) {
|
|
8143
|
+
this.logger.warn("snooze digest tick failed", { meta: {
|
|
8144
|
+
snoozeId: snooze.id,
|
|
8145
|
+
error: String(err)
|
|
8146
|
+
} });
|
|
8147
|
+
}
|
|
8148
|
+
}
|
|
8149
|
+
/** The durable snooze store — the cap provider and the `nc.*` bridge serve
|
|
8150
|
+
* CRUD from it, the same write-through cache the gate reads. */
|
|
8151
|
+
get snoozeStore() {
|
|
8152
|
+
return this.snoozes;
|
|
8153
|
+
}
|
|
8154
|
+
/**
|
|
8155
|
+
* Open a window on behalf of a caller, with the permission check that makes
|
|
8156
|
+
* `global` safe. Lives here rather than in the store because it needs the
|
|
8157
|
+
* RULE (its `snoozeAllowGlobal` opt-in), and a permission decision that can
|
|
8158
|
+
* silence other people belongs where both halves are visible.
|
|
8159
|
+
*/
|
|
8160
|
+
async createSnooze(input, caller) {
|
|
8161
|
+
if (input.scope === "rule" && input.ruleId === void 0) throw new Error("a rule snooze needs a ruleId");
|
|
8162
|
+
if (input.scope === "device" && input.deviceId === void 0) throw new Error("a camera snooze needs a deviceId");
|
|
8163
|
+
const wantsGlobal = input.global === true;
|
|
8164
|
+
if (wantsGlobal) {
|
|
8165
|
+
const rule = input.ruleId !== void 0 ? this.rules.get(input.ruleId) : null;
|
|
8166
|
+
if (!canSetGlobal({
|
|
8167
|
+
scope: input.scope,
|
|
8168
|
+
...rule !== null ? { ruleAllowsGlobal: rule.snoozeAllowGlobal === true } : {},
|
|
8169
|
+
isAdmin: caller.isAdmin
|
|
8170
|
+
})) throw new Error("forbidden: this snooze may not be made global");
|
|
8171
|
+
}
|
|
8172
|
+
const snooze = await this.snoozes.create({
|
|
8173
|
+
userId: caller.userId,
|
|
8174
|
+
scope: input.scope,
|
|
8175
|
+
...input.ruleId !== void 0 ? { ruleId: input.ruleId } : {},
|
|
8176
|
+
...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
|
|
8177
|
+
durationMinutes: input.durationMinutes,
|
|
8178
|
+
global: wantsGlobal,
|
|
8179
|
+
summary: input.summary !== false
|
|
8180
|
+
});
|
|
8181
|
+
this.logger.info("snooze opened", {
|
|
8182
|
+
...snooze.deviceId !== void 0 ? { tags: { deviceId: snooze.deviceId } } : {},
|
|
8183
|
+
meta: {
|
|
8184
|
+
snoozeId: snooze.id,
|
|
8185
|
+
scope: snooze.scope,
|
|
8186
|
+
by: caller.userId,
|
|
8187
|
+
global: snooze.global,
|
|
8188
|
+
minutes: input.durationMinutes,
|
|
8189
|
+
...snooze.ruleId !== void 0 ? { ruleId: snooze.ruleId } : {}
|
|
8190
|
+
}
|
|
8191
|
+
});
|
|
8192
|
+
return snooze;
|
|
8193
|
+
}
|
|
8194
|
+
/** End a window early. A user may end their own; an admin may end any. */
|
|
8195
|
+
async cancelSnooze(snoozeId, caller) {
|
|
8196
|
+
const existing = this.snoozes.get(snoozeId);
|
|
8197
|
+
if (existing === null) throw new Error(`snooze not found: ${snoozeId}`);
|
|
8198
|
+
if (!caller.isAdmin && existing.userId !== caller.userId) throw new Error(`forbidden: snooze not owned: ${snoozeId}`);
|
|
8199
|
+
await this.snoozes.cancel(snoozeId);
|
|
8200
|
+
this.logger.info("snooze cancelled", { meta: {
|
|
8201
|
+
snoozeId,
|
|
8202
|
+
by: caller.userId
|
|
8203
|
+
} });
|
|
7327
8204
|
}
|
|
7328
8205
|
/** The "at least one addressee" invariant lives here, not in Zod: the
|
|
7329
8206
|
* schema allows empty `targets` (a users-only rule) and a cross-field
|
|
@@ -7440,6 +8317,21 @@ var ncActions = defineCustomActions({
|
|
|
7440
8317
|
}), object({ success: literal(true) }), {
|
|
7441
8318
|
kind: "mutation",
|
|
7442
8319
|
caller: "required"
|
|
8320
|
+
}),
|
|
8321
|
+
/**
|
|
8322
|
+
* Snooze, from the phone — the surface that matters most for this feature,
|
|
8323
|
+
* because "silence this until morning" is a thing you press in bed, not at a
|
|
8324
|
+
* desk. Scoped to the caller: their own windows plus the global ones that
|
|
8325
|
+
* silence them.
|
|
8326
|
+
*/
|
|
8327
|
+
"nc.listSnoozes": customAction(object({}), object({ snoozes: array(NcSnoozeSchema) }), { caller: "required" }),
|
|
8328
|
+
"nc.createSnooze": customAction(object({ snooze: NcSnoozeInputSchema }), object({ snooze: NcSnoozeSchema }), {
|
|
8329
|
+
kind: "mutation",
|
|
8330
|
+
caller: "required"
|
|
8331
|
+
}),
|
|
8332
|
+
"nc.cancelSnooze": customAction(object({ snoozeId: string() }), object({ success: literal(true) }), {
|
|
8333
|
+
kind: "mutation",
|
|
8334
|
+
caller: "required"
|
|
7443
8335
|
})
|
|
7444
8336
|
});
|
|
7445
8337
|
/** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
|
|
@@ -7532,6 +8424,28 @@ function makeNcActionHandlers(deps) {
|
|
|
7532
8424
|
} });
|
|
7533
8425
|
return { success: true };
|
|
7534
8426
|
},
|
|
8427
|
+
"nc.listSnoozes": async (_input, caller) => {
|
|
8428
|
+
const c = requireCaller(caller);
|
|
8429
|
+
return { snoozes: [...deps.snooze.list(c.userId, c.isAdmin)] };
|
|
8430
|
+
},
|
|
8431
|
+
"nc.createSnooze": async (input, caller) => {
|
|
8432
|
+
const c = requireCaller(caller);
|
|
8433
|
+
const parsed = NcSnoozeInputSchema.parse(input.snooze);
|
|
8434
|
+
if (parsed.ruleId !== void 0) assertRuleVisible(parsed.ruleId, c);
|
|
8435
|
+
const snooze = await deps.snooze.create(parsed, c);
|
|
8436
|
+
deps.logger.info("nc snooze opened", { meta: {
|
|
8437
|
+
snoozeId: snooze.id,
|
|
8438
|
+
scope: snooze.scope,
|
|
8439
|
+
by: c.userId,
|
|
8440
|
+
global: snooze.global
|
|
8441
|
+
} });
|
|
8442
|
+
return { snooze };
|
|
8443
|
+
},
|
|
8444
|
+
"nc.cancelSnooze": async (input, caller) => {
|
|
8445
|
+
const c = requireCaller(caller);
|
|
8446
|
+
await deps.snooze.cancel(input.snoozeId, c);
|
|
8447
|
+
return { success: true };
|
|
8448
|
+
},
|
|
7535
8449
|
"nc.setRuleTargetEnabled": async (input, caller) => {
|
|
7536
8450
|
const c = requireCaller(caller);
|
|
7537
8451
|
assertRuleVisible(input.ruleId, c);
|
|
@@ -11142,6 +12056,54 @@ var EventStore = class {
|
|
|
11142
12056
|
});
|
|
11143
12057
|
}
|
|
11144
12058
|
}
|
|
12059
|
+
/**
|
|
12060
|
+
* Collect the ids matching `filter`, then delete every matching row in ONE
|
|
12061
|
+
* statement. The shared body of every retention sweep here.
|
|
12062
|
+
*
|
|
12063
|
+
* The ids are still read because the callers delete each event's media in
|
|
12064
|
+
* lockstep and a bulk delete returns only a count. What is gone is the
|
|
12065
|
+
* per-row delete loop: ~7,200 round trips to prune a day of motion events,
|
|
12066
|
+
* each carrying every column of the row it deleted.
|
|
12067
|
+
*
|
|
12068
|
+
* Three properties, each of which cost a red test to learn:
|
|
12069
|
+
*
|
|
12070
|
+
* - **the filter is used for BOTH halves**, so what is reported and what is
|
|
12071
|
+
* removed cannot drift apart;
|
|
12072
|
+
* - **paging carries an explicit offset over ROWS READ.** Deleting each page
|
|
12073
|
+
* used to advance the cursor implicitly; reading without deleting re-reads
|
|
12074
|
+
* page one forever. And a row with a non-string id yields no id but still
|
|
12075
|
+
* occupies a place, so ids collected is the wrong cursor;
|
|
12076
|
+
* - **only an EMPTY page ends the scan.** The store caps pages on its own
|
|
12077
|
+
* (~500), so "fewer rows than requested" is normal, not the end — treating
|
|
12078
|
+
* it as the end silently deletes just the first page while reporting a
|
|
12079
|
+
* complete sweep;
|
|
12080
|
+
* - **a delete that removed nothing reports NO ids.** Callers remove media
|
|
12081
|
+
* against this list; claiming a deletion that did not happen would orphan
|
|
12082
|
+
* media in the opposite direction.
|
|
12083
|
+
*/
|
|
12084
|
+
async drainMatching(collection, filter) {
|
|
12085
|
+
const ids = [];
|
|
12086
|
+
let offset = 0;
|
|
12087
|
+
for (;;) {
|
|
12088
|
+
const rows = await this.store.query.query({
|
|
12089
|
+
collection,
|
|
12090
|
+
filter: {
|
|
12091
|
+
...filter,
|
|
12092
|
+
limit: 500,
|
|
12093
|
+
offset
|
|
12094
|
+
}
|
|
12095
|
+
});
|
|
12096
|
+
if (rows.length === 0) break;
|
|
12097
|
+
offset += rows.length;
|
|
12098
|
+
for (const row of rows) if (typeof row.id === "string") ids.push(row.id);
|
|
12099
|
+
}
|
|
12100
|
+
if (ids.length === 0) return ids;
|
|
12101
|
+
const { deleted } = await this.store.deleteWhere.mutate({
|
|
12102
|
+
collection,
|
|
12103
|
+
filter
|
|
12104
|
+
});
|
|
12105
|
+
return deleted === 0 ? [] : ids;
|
|
12106
|
+
}
|
|
11145
12107
|
buildFilter(q) {
|
|
11146
12108
|
const filter = {
|
|
11147
12109
|
where: { deviceId: q.deviceId },
|
|
@@ -11462,35 +12424,10 @@ var EventStore = class {
|
|
|
11462
12424
|
* page at a time (PRUNE_PAGE_SIZE) until no stale rows remain.
|
|
11463
12425
|
*/
|
|
11464
12426
|
async evictBefore(params) {
|
|
11465
|
-
const batch = async (collection, cutoffMs) => {
|
|
11466
|
-
|
|
11467
|
-
|
|
11468
|
-
|
|
11469
|
-
collection,
|
|
11470
|
-
filter: {
|
|
11471
|
-
...params.deviceId !== void 0 ? { where: { deviceId: params.deviceId } } : {},
|
|
11472
|
-
whereBetween: { timestamp: [0, cutoffMs] },
|
|
11473
|
-
limit: 500
|
|
11474
|
-
}
|
|
11475
|
-
});
|
|
11476
|
-
if (rows.length === 0) break;
|
|
11477
|
-
let deletedInPage = 0;
|
|
11478
|
-
for (const row of rows) {
|
|
11479
|
-
const id = row.id;
|
|
11480
|
-
if (typeof id !== "string") continue;
|
|
11481
|
-
try {
|
|
11482
|
-
await this.store.delete.mutate({
|
|
11483
|
-
collection,
|
|
11484
|
-
key: id
|
|
11485
|
-
});
|
|
11486
|
-
ids.push(id);
|
|
11487
|
-
deletedInPage++;
|
|
11488
|
-
} catch {}
|
|
11489
|
-
}
|
|
11490
|
-
if (deletedInPage === 0) break;
|
|
11491
|
-
}
|
|
11492
|
-
return ids;
|
|
11493
|
-
};
|
|
12427
|
+
const batch = async (collection, cutoffMs) => this.drainMatching(collection, {
|
|
12428
|
+
...params.deviceId !== void 0 ? { where: { deviceId: params.deviceId } } : {},
|
|
12429
|
+
whereBetween: { timestamp: [0, cutoffMs] }
|
|
12430
|
+
});
|
|
11494
12431
|
return {
|
|
11495
12432
|
motion: await batch(MOTION_EVENTS_COLLECTION, params.motionCutoffMs),
|
|
11496
12433
|
object: await batch(OBJECT_EVENTS_COLLECTION, params.objectCutoffMs),
|
|
@@ -11513,35 +12450,16 @@ var EventStore = class {
|
|
|
11513
12450
|
* so ALL matching rows are deleted regardless of total count.
|
|
11514
12451
|
*/
|
|
11515
12452
|
async pruneBefore(input) {
|
|
11516
|
-
|
|
11517
|
-
|
|
11518
|
-
|
|
11519
|
-
|
|
11520
|
-
|
|
11521
|
-
|
|
11522
|
-
|
|
11523
|
-
|
|
11524
|
-
limit: 500
|
|
11525
|
-
}
|
|
11526
|
-
});
|
|
11527
|
-
if (rows.length === 0) break;
|
|
11528
|
-
let deletedInPage = 0;
|
|
11529
|
-
for (const row of rows) {
|
|
11530
|
-
const id = row.id;
|
|
11531
|
-
if (typeof id !== "string") continue;
|
|
11532
|
-
try {
|
|
11533
|
-
await this.store.delete.mutate({
|
|
11534
|
-
collection,
|
|
11535
|
-
key: id
|
|
11536
|
-
});
|
|
11537
|
-
ids.push(id);
|
|
11538
|
-
deletedInPage++;
|
|
11539
|
-
} catch {}
|
|
11540
|
-
}
|
|
11541
|
-
if (deletedInPage === 0) break;
|
|
11542
|
-
}
|
|
11543
|
-
return ids;
|
|
12453
|
+
/**
|
|
12454
|
+
* The filter is written ONCE and used for both halves below, so the rows
|
|
12455
|
+
* reported and the rows deleted cannot drift apart. Inclusive `[0, cutoff-1]`
|
|
12456
|
+
* → selects timestamps strictly < `cutoffMs`.
|
|
12457
|
+
*/
|
|
12458
|
+
const staleForDevice = {
|
|
12459
|
+
where: { deviceId: input.deviceId },
|
|
12460
|
+
whereBetween: { timestamp: [0, input.cutoffMs - 1] }
|
|
11544
12461
|
};
|
|
12462
|
+
const allForDevice = async (collection) => this.drainMatching(collection, staleForDevice);
|
|
11545
12463
|
const [motionIds, objectIds, audioIds] = await Promise.all([
|
|
11546
12464
|
allForDevice(MOTION_EVENTS_COLLECTION),
|
|
11547
12465
|
allForDevice(OBJECT_EVENTS_COLLECTION),
|
|
@@ -11856,6 +12774,37 @@ var OpsLogStore = class {
|
|
|
11856
12774
|
}
|
|
11857
12775
|
return out;
|
|
11858
12776
|
}
|
|
12777
|
+
/**
|
|
12778
|
+
* Delete audit entries older than `cutoffMs`. Returns how many went.
|
|
12779
|
+
*
|
|
12780
|
+
* The audit log is the one collection that records everyone else's
|
|
12781
|
+
* retention and had none of its own — it grows monotonically for the life of
|
|
12782
|
+
* the node (11,519 rows on the operator's hub before this existed).
|
|
12783
|
+
*
|
|
12784
|
+
* ONE statement, not a select-then-delete drain: `deleteWhere` is exactly
|
|
12785
|
+
* the bulk path that removed the N+1 loops from every other retention path.
|
|
12786
|
+
* `whereBetween` on `at` is inclusive, so `[0, cutoffMs - 1]` selects
|
|
12787
|
+
* strictly-older rows — the same boundary `EventStore.pruneBefore` uses.
|
|
12788
|
+
*
|
|
12789
|
+
* BEST-EFFORT, like `append`: a failed audit trim must never fail the
|
|
12790
|
+
* retention sweep that called it. It warns rather than throwing, because a
|
|
12791
|
+
* trim that silently does nothing is how a table gets to 11,519 rows.
|
|
12792
|
+
*/
|
|
12793
|
+
async pruneBefore(cutoffMs) {
|
|
12794
|
+
try {
|
|
12795
|
+
const { deleted } = await this.store.deleteWhere.mutate({
|
|
12796
|
+
collection: OPS_LOG_COLLECTION,
|
|
12797
|
+
filter: { whereBetween: { at: [0, cutoffMs - 1] } }
|
|
12798
|
+
});
|
|
12799
|
+
return deleted;
|
|
12800
|
+
} catch (err) {
|
|
12801
|
+
this.logger.warn("ops-log: prune failed — the audit log will keep growing", { meta: {
|
|
12802
|
+
cutoffMs,
|
|
12803
|
+
error: err instanceof Error ? err.message : String(err)
|
|
12804
|
+
} });
|
|
12805
|
+
return 0;
|
|
12806
|
+
}
|
|
12807
|
+
}
|
|
11859
12808
|
};
|
|
11860
12809
|
/** SQLite stores nullable columns as `null`; the row schema uses `.nullable()`
|
|
11861
12810
|
* for deviceId/detail (accepts null) but drop any stray null on required
|
|
@@ -20331,6 +21280,12 @@ var NcArtifactSecretSchema = string();
|
|
|
20331
21280
|
var EmbeddingEnabledSchema = boolean();
|
|
20332
21281
|
var SILENCE_FLOOR_DBFS = -55;
|
|
20333
21282
|
var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
|
|
21283
|
+
/**
|
|
21284
|
+
* How long the events ops-log is kept. 30 days: long enough to answer "what
|
|
21285
|
+
* deleted my footage last month", short enough that the audit never becomes
|
|
21286
|
+
* the largest thing the node stores.
|
|
21287
|
+
*/
|
|
21288
|
+
var OPS_LOG_RETENTION_MS = 720 * 60 * 6e4;
|
|
20334
21289
|
/** Throttle for the "overlay synthesis failed" warn — one line / minute / device. */
|
|
20335
21290
|
var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
|
|
20336
21291
|
var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
|
|
@@ -20844,6 +21799,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20844
21799
|
return makeNcActionHandlers({
|
|
20845
21800
|
ruleStore: center.ruleStore,
|
|
20846
21801
|
logger: this.ctx.logger.child("nc-actions"),
|
|
21802
|
+
snooze: {
|
|
21803
|
+
list: (userId, isAdmin) => center.snoozeStore.listVisibleTo(userId, isAdmin),
|
|
21804
|
+
create: (input, caller) => center.createSnooze(input, caller),
|
|
21805
|
+
cancel: (snoozeId, caller) => center.cancelSnooze(snoozeId, caller)
|
|
21806
|
+
},
|
|
20847
21807
|
listCallerTargetIds: async (userId) => {
|
|
20848
21808
|
return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
|
|
20849
21809
|
}
|
|
@@ -21256,6 +22216,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21256
22216
|
postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC,
|
|
21257
22217
|
maxWidth: NC_FOOTAGE_MAX_WIDTH,
|
|
21258
22218
|
fps: NC_FOOTAGE_FPS,
|
|
22219
|
+
...req.speed !== void 0 ? { speed: req.speed } : {},
|
|
21259
22220
|
...req.profile === "high" || req.profile === "mid" || req.profile === "low" ? { profile: req.profile } : {}
|
|
21260
22221
|
});
|
|
21261
22222
|
const buf = Buffer.from(res.base64, "base64");
|
|
@@ -21413,6 +22374,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21413
22374
|
this.sweepRetention();
|
|
21414
22375
|
this.runTrackRetentionSweep();
|
|
21415
22376
|
this.stationaryRegistry?.sweep(Date.now());
|
|
22377
|
+
this.eventsOpsLog?.pruneBefore(Date.now() - OPS_LOG_RETENTION_MS);
|
|
21416
22378
|
}, RETENTION_SWEEP_INTERVAL_MS);
|
|
21417
22379
|
}
|
|
21418
22380
|
/** Cap provider registrations returned from `onInitialize` — the widgets
|