@camstack/addon-post-analysis 1.2.152 → 1.2.154
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-DSg_jDMt.mjs → dist-D6iC--Gh.mjs} +346 -2
- package/dist/{dist-Bw6cidiJ.js → dist-Ds6x9Agm.js} +351 -1
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DTO1HvN7.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CnB4y-xY.mjs} +3 -3
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DdANt7Ia.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-r4RyRXk7.mjs → hostInit-BqnSOQO7.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +1387 -198
- package/dist/pipeline-analytics/index.mjs +1387 -198
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DxVeMmnD.mjs +0 -26
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-Ds6x9Agm.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
node_fs = require_dist.__toESM(node_fs, 1);
|
|
8
8
|
let node_path = require("node:path");
|
|
@@ -112,6 +112,7 @@ function actionTtlMs(subject) {
|
|
|
112
112
|
case "snooze": return SNOOZE_ACTION_TTL_MS;
|
|
113
113
|
case "alarm": return subject.action === "disarm" ? ALARM_DISARM_TTL_MS : DEFAULT_ACTION_TTL_MS;
|
|
114
114
|
case "sequence": return DEFAULT_ACTION_TTL_MS;
|
|
115
|
+
case "addon-update": return DEFAULT_ACTION_TTL_MS;
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
118
|
var NcActionTokenRegistry = class {
|
|
@@ -191,6 +192,7 @@ function describeGrant(grant) {
|
|
|
191
192
|
case "sequence": return `${grant.ruleName} — ${grant.sequence}`;
|
|
192
193
|
case "snooze": return grant.classes === void 0 || grant.classes.length === 0 ? `Silenced for ${grant.minutes} min` : `Silenced for ${grant.minutes} min — ${grant.classes.join(", ")}`;
|
|
193
194
|
case "alarm": return grant.action === "disarm" ? "Alarm disarmed" : `Alarm armed${grant.mode !== void 0 ? ` (${grant.mode})` : ""}`;
|
|
195
|
+
case "addon-update": return `${grant.packages.length} addon update(s) on ${grant.nodeId}`;
|
|
194
196
|
}
|
|
195
197
|
}
|
|
196
198
|
/** The audit line's `meta`, per family. A snooze that logged a `sequence` it
|
|
@@ -221,6 +223,11 @@ function grantMeta(grant) {
|
|
|
221
223
|
mode: grant.mode ?? null,
|
|
222
224
|
panelDeviceId: grant.panelDeviceId
|
|
223
225
|
};
|
|
226
|
+
case "addon-update": return {
|
|
227
|
+
...common,
|
|
228
|
+
nodeId: grant.nodeId,
|
|
229
|
+
packages: grant.packages
|
|
230
|
+
};
|
|
224
231
|
}
|
|
225
232
|
}
|
|
226
233
|
/** What the human standing in front of the page is told. */
|
|
@@ -4731,6 +4738,40 @@ var SceneMonitorProvider = class {
|
|
|
4731
4738
|
async listScenes(input) {
|
|
4732
4739
|
return this.statusFor(input.deviceId);
|
|
4733
4740
|
}
|
|
4741
|
+
/**
|
|
4742
|
+
* `listScenes` for a SET, in one round trip.
|
|
4743
|
+
*
|
|
4744
|
+
* `/scenes` renders every camera and re-reads them behind the push slice on a
|
|
4745
|
+
* 30 s safety net. Fanned out client-side that was one request per camera to
|
|
4746
|
+
* read `statusFor`, which is an in-memory merge this provider already holds —
|
|
4747
|
+
* all cost, no work. The work here is identical; only the transport
|
|
4748
|
+
* collapses.
|
|
4749
|
+
*
|
|
4750
|
+
* **Every requested id gets a row.** A camera `statusFor` cannot answer for
|
|
4751
|
+
* is `status: null`, which is NOT `monitors: []`: this feature's whole
|
|
4752
|
+
* failure mode is silence reading as "nothing configured", and a batch that
|
|
4753
|
+
* dropped the row would have made an unreachable camera invisible instead of
|
|
4754
|
+
* reported.
|
|
4755
|
+
*/
|
|
4756
|
+
async listScenesBatch(input) {
|
|
4757
|
+
return input.deviceIds.map((deviceId) => {
|
|
4758
|
+
try {
|
|
4759
|
+
return {
|
|
4760
|
+
deviceId,
|
|
4761
|
+
status: this.statusFor(deviceId)
|
|
4762
|
+
};
|
|
4763
|
+
} catch (err) {
|
|
4764
|
+
this.deps.logger.warn("scene status could not be read", {
|
|
4765
|
+
tags: { deviceId },
|
|
4766
|
+
meta: { error: String(err) }
|
|
4767
|
+
});
|
|
4768
|
+
return {
|
|
4769
|
+
deviceId,
|
|
4770
|
+
status: null
|
|
4771
|
+
};
|
|
4772
|
+
}
|
|
4773
|
+
});
|
|
4774
|
+
}
|
|
4734
4775
|
async getStatus(input) {
|
|
4735
4776
|
return this.statusFor(input.deviceId);
|
|
4736
4777
|
}
|
|
@@ -5413,6 +5454,67 @@ function buildAlarmButtons(input) {
|
|
|
5413
5454
|
}];
|
|
5414
5455
|
}
|
|
5415
5456
|
//#endregion
|
|
5457
|
+
//#region src/notification-center/addon-update-apply.ts
|
|
5458
|
+
/**
|
|
5459
|
+
* The frozen packages that STILL have an update, in the order the row listed
|
|
5460
|
+
* them.
|
|
5461
|
+
*
|
|
5462
|
+
* Order matters: the notification's body listed them in that order, and an
|
|
5463
|
+
* operator watching addons restart should see them go in the order they read.
|
|
5464
|
+
*/
|
|
5465
|
+
function pendingPackages(frozen, updatable) {
|
|
5466
|
+
const available = new Set(updatable);
|
|
5467
|
+
return frozen.filter((name) => available.has(name));
|
|
5468
|
+
}
|
|
5469
|
+
/**
|
|
5470
|
+
* `done` only when every package the button promised was applied and nothing
|
|
5471
|
+
* was skipped or failed — the ONE case where a single number is the whole
|
|
5472
|
+
* truth. Anything else states all three counts, because a sentence that hides
|
|
5473
|
+
* one of them is a sentence that reports work it did not do.
|
|
5474
|
+
*/
|
|
5475
|
+
function addonUpdateSentence(report) {
|
|
5476
|
+
if (report.applied.length === 0 && report.failed.length === 0) return "uptodate";
|
|
5477
|
+
if (report.applied.length === report.requested && report.failed.length === 0) return "done";
|
|
5478
|
+
return "partial";
|
|
5479
|
+
}
|
|
5480
|
+
//#endregion
|
|
5481
|
+
//#region src/notification-center/addon-update-buttons.ts
|
|
5482
|
+
/** The id this button travels under — in the callback and in the audit line. */
|
|
5483
|
+
var ADDON_UPDATE_BUTTON_ID = "addon-update-apply";
|
|
5484
|
+
function addonUpdateButtonRefusal(subject, recipient) {
|
|
5485
|
+
if (subject.kind !== "addon-update-available") return "not-an-addon-update";
|
|
5486
|
+
if ((subject.packageNames ?? []).length === 0) return "no-packages";
|
|
5487
|
+
if (subject.nodeId === void 0) return "several-nodes";
|
|
5488
|
+
if (recipient.ownerUserId === void 0) return "unowned-target";
|
|
5489
|
+
return null;
|
|
5490
|
+
}
|
|
5491
|
+
/**
|
|
5492
|
+
* The button, or none.
|
|
5493
|
+
*
|
|
5494
|
+
* Returns an array rather than a nullable, so the caller composes it with the
|
|
5495
|
+
* other families by spreading — and so "which buttons survived" has one shape.
|
|
5496
|
+
*/
|
|
5497
|
+
function buildAddonUpdateButtons(input) {
|
|
5498
|
+
const subject = input.subject;
|
|
5499
|
+
if (addonUpdateButtonRefusal(subject, input.recipient) !== null) return [];
|
|
5500
|
+
const packages = subject.packageNames ?? [];
|
|
5501
|
+
const nodeId = subject.nodeId ?? "";
|
|
5502
|
+
return [{
|
|
5503
|
+
id: ADDON_UPDATE_BUTTON_ID,
|
|
5504
|
+
label: input.label({
|
|
5505
|
+
count: packages.length,
|
|
5506
|
+
nodeId
|
|
5507
|
+
}),
|
|
5508
|
+
url: input.mintUrl({
|
|
5509
|
+
actionId: ADDON_UPDATE_BUTTON_ID,
|
|
5510
|
+
packages,
|
|
5511
|
+
nodeId
|
|
5512
|
+
}),
|
|
5513
|
+
destructive: true,
|
|
5514
|
+
mode: "background"
|
|
5515
|
+
}];
|
|
5516
|
+
}
|
|
5517
|
+
//#endregion
|
|
5416
5518
|
//#region src/notification-center/audio-rule-matcher.ts
|
|
5417
5519
|
/**
|
|
5418
5520
|
* AudioWatcher — the Notification Center's matcher for audio rules
|
|
@@ -8478,6 +8580,22 @@ var en_default = {
|
|
|
8478
8580
|
"button.alarm.arm.night": "Arm night",
|
|
8479
8581
|
"button.alarm.arm.vacation": "Arm vacation",
|
|
8480
8582
|
"button.alarm.arm.custom_bypass": "Arm custom",
|
|
8583
|
+
"button.addon-update.apply": {
|
|
8584
|
+
"one": "Update {{count}} addon on {{nodeId}}",
|
|
8585
|
+
"other": "Update {{count}} addons on {{nodeId}}"
|
|
8586
|
+
},
|
|
8587
|
+
"action.addon-update.title": "Addon update",
|
|
8588
|
+
"action.addon-update.started": {
|
|
8589
|
+
"one": "Updating {{count}} addon on {{nodeId}}…",
|
|
8590
|
+
"other": "Updating {{count}} addons on {{nodeId}}…"
|
|
8591
|
+
},
|
|
8592
|
+
"action.addon-update.done": {
|
|
8593
|
+
"one": "{{applied}} addon updated on {{nodeId}}",
|
|
8594
|
+
"other": "{{applied}} addons updated on {{nodeId}}"
|
|
8595
|
+
},
|
|
8596
|
+
"action.addon-update.partial": "{{nodeId}}: {{applied}} updated, {{skipped}} already current, {{failed}} failed",
|
|
8597
|
+
"action.addon-update.uptodate": "Already up to date on {{nodeId}} — nothing to do",
|
|
8598
|
+
"action.addon-update.unknown": "Could not read what needs updating on {{nodeId}} — nothing was changed. Open CamStack.",
|
|
8481
8599
|
"summary.title": "Summary — {{rule}}",
|
|
8482
8600
|
"summary.body.window": "{{from}}→{{to}}",
|
|
8483
8601
|
"summary.body.events": {
|
|
@@ -8700,6 +8818,22 @@ var it_default = {
|
|
|
8700
8818
|
"button.alarm.arm.night": "Arma notte",
|
|
8701
8819
|
"button.alarm.arm.vacation": "Arma vacanza",
|
|
8702
8820
|
"button.alarm.arm.custom_bypass": "Arma personalizzato",
|
|
8821
|
+
"button.addon-update.apply": {
|
|
8822
|
+
"one": "Aggiorna {{count}} addon su {{nodeId}}",
|
|
8823
|
+
"other": "Aggiorna {{count}} addon su {{nodeId}}"
|
|
8824
|
+
},
|
|
8825
|
+
"action.addon-update.title": "Aggiornamento addon",
|
|
8826
|
+
"action.addon-update.started": {
|
|
8827
|
+
"one": "Aggiornamento di {{count}} addon su {{nodeId}}…",
|
|
8828
|
+
"other": "Aggiornamento di {{count}} addon su {{nodeId}}…"
|
|
8829
|
+
},
|
|
8830
|
+
"action.addon-update.done": {
|
|
8831
|
+
"one": "{{applied}} addon aggiornato su {{nodeId}}",
|
|
8832
|
+
"other": "{{applied}} addon aggiornati su {{nodeId}}"
|
|
8833
|
+
},
|
|
8834
|
+
"action.addon-update.partial": "{{nodeId}}: {{applied}} aggiornati, {{skipped}} già aggiornati, {{failed}} falliti",
|
|
8835
|
+
"action.addon-update.uptodate": "Già aggiornato su {{nodeId}} — niente da fare",
|
|
8836
|
+
"action.addon-update.unknown": "Non è stato possibile leggere cosa aggiornare su {{nodeId}} — non è stato cambiato nulla. Apri CamStack.",
|
|
8703
8837
|
"summary.title": "Riepilogo — {{rule}}",
|
|
8704
8838
|
"summary.body.window": "{{from}}→{{to}}",
|
|
8705
8839
|
"summary.body.events": {
|
|
@@ -9427,7 +9561,7 @@ var NcDispatcher = class {
|
|
|
9427
9561
|
targetId: target.id,
|
|
9428
9562
|
...target.ownerUserId !== void 0 ? { ownerUserId: target.ownerUserId } : {},
|
|
9429
9563
|
...entry.payload.subject.className !== void 0 ? { className: entry.payload.subject.className } : {},
|
|
9430
|
-
...entry.payload.subject.systemEvent !== void 0 ? {
|
|
9564
|
+
...entry.payload.subject.systemEvent !== void 0 ? { systemEvent: entry.payload.subject.systemEvent } : {}
|
|
9431
9565
|
});
|
|
9432
9566
|
} catch (err) {
|
|
9433
9567
|
this.deps.logger.warn("notification buttons could not be minted — sending without them", {
|
|
@@ -20176,7 +20310,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20176
20310
|
return id;
|
|
20177
20311
|
}
|
|
20178
20312
|
});
|
|
20179
|
-
const alarmKind = alarmButtonKind(input.
|
|
20313
|
+
const alarmKind = alarmButtonKind(input.systemEvent?.kind);
|
|
20180
20314
|
const panel = this.alarmPanel;
|
|
20181
20315
|
const alarmButtons = alarmKind === null ? [] : buildAlarmButtons({
|
|
20182
20316
|
kind: alarmKind,
|
|
@@ -20212,6 +20346,49 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20212
20346
|
return id;
|
|
20213
20347
|
}
|
|
20214
20348
|
});
|
|
20349
|
+
const systemEvent = input.systemEvent;
|
|
20350
|
+
const recipient = input.ownerUserId !== void 0 ? { ownerUserId: input.ownerUserId } : {};
|
|
20351
|
+
const updateRefusal = systemEvent === void 0 ? "not-an-addon-update" : addonUpdateButtonRefusal(systemEvent, recipient);
|
|
20352
|
+
const updateButtons = systemEvent === void 0 ? [] : buildAddonUpdateButtons({
|
|
20353
|
+
subject: systemEvent,
|
|
20354
|
+
recipient,
|
|
20355
|
+
label: ({ count, nodeId }) => this.texts.text({
|
|
20356
|
+
key: "button.addon-update.apply",
|
|
20357
|
+
vars: {
|
|
20358
|
+
count: String(count),
|
|
20359
|
+
nodeId
|
|
20360
|
+
},
|
|
20361
|
+
count
|
|
20362
|
+
}),
|
|
20363
|
+
mintUrl: ({ actionId, packages, nodeId }) => {
|
|
20364
|
+
const id = (0, node_crypto.randomUUID)();
|
|
20365
|
+
drafts.push({
|
|
20366
|
+
id,
|
|
20367
|
+
ttlMs: actionTtlMs({ kind: "addon-update" }),
|
|
20368
|
+
grant: (expiresAt) => ({
|
|
20369
|
+
kind: "addon-update",
|
|
20370
|
+
id,
|
|
20371
|
+
packages,
|
|
20372
|
+
nodeId,
|
|
20373
|
+
deviceId: input.deviceId,
|
|
20374
|
+
actionId,
|
|
20375
|
+
targetId: input.targetId,
|
|
20376
|
+
expiresAt
|
|
20377
|
+
})
|
|
20378
|
+
});
|
|
20379
|
+
return id;
|
|
20380
|
+
}
|
|
20381
|
+
});
|
|
20382
|
+
if (updateRefusal === "several-nodes" || updateRefusal === "unowned-target") this.logger.info("addon-update notification carries no apply button", {
|
|
20383
|
+
tags: { deviceId: input.deviceId },
|
|
20384
|
+
meta: {
|
|
20385
|
+
ruleId: input.ruleId,
|
|
20386
|
+
reason: updateRefusal,
|
|
20387
|
+
targetId: input.targetId,
|
|
20388
|
+
nodeIds: systemEvent?.nodeIds ?? [],
|
|
20389
|
+
packages: systemEvent?.packageNames ?? []
|
|
20390
|
+
}
|
|
20391
|
+
});
|
|
20215
20392
|
if (alarmKind !== null && panel === null) this.logger.info("alarm notification carries no buttons — this node owns no panel", {
|
|
20216
20393
|
tags: { deviceId: input.deviceId },
|
|
20217
20394
|
meta: {
|
|
@@ -20222,7 +20399,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20222
20399
|
const out = [
|
|
20223
20400
|
...sequenceButtons,
|
|
20224
20401
|
...snoozeButtons,
|
|
20225
|
-
...alarmButtons
|
|
20402
|
+
...alarmButtons,
|
|
20403
|
+
...updateButtons
|
|
20226
20404
|
];
|
|
20227
20405
|
if (out.length === 0) return [];
|
|
20228
20406
|
const now = this.now();
|
|
@@ -20343,8 +20521,139 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20343
20521
|
case "snooze": return this.applyGrantedSnooze(grant);
|
|
20344
20522
|
case "alarm": return this.applyGrantedAlarm(grant);
|
|
20345
20523
|
case "sequence": return this.runGrantedSequence(grant);
|
|
20524
|
+
case "addon-update": return this.applyGrantedAddonUpdate(grant);
|
|
20346
20525
|
}
|
|
20347
20526
|
}
|
|
20527
|
+
/**
|
|
20528
|
+
* Apply the addon updates one notification was about.
|
|
20529
|
+
*
|
|
20530
|
+
* The whole shape of this method is the answer to "a button that reports
|
|
20531
|
+
* success for work it did not do":
|
|
20532
|
+
*
|
|
20533
|
+
* 1. **The cheap question first.** Ask the registry what is still out of
|
|
20534
|
+
* date on that node. A read that FAILS changes nothing and says so — it
|
|
20535
|
+
* is never read as "everything is current" and never as "apply the frozen
|
|
20536
|
+
* list anyway".
|
|
20537
|
+
* 2. **Nothing to do is an outcome, not a success.** A frozen list the
|
|
20538
|
+
* operator already applied (from the Addons page, from auto-update, or by
|
|
20539
|
+
* tapping the copy of this notification that went to their other phone)
|
|
20540
|
+
* produces one announcement and zero `updatePackage` calls.
|
|
20541
|
+
* 3. **The operator hears twice.** A background POST returns while the
|
|
20542
|
+
* updates run for minutes, so the tap is acknowledged BEFORE the first
|
|
20543
|
+
* apply and the verdict follows it. Tapping into silence is worse than no
|
|
20544
|
+
* button.
|
|
20545
|
+
*
|
|
20546
|
+
* The announcements go back to `grant.targetId` — the delivery this button
|
|
20547
|
+
* was minted for, which is the only addressee a tap can honestly name (a tap
|
|
20548
|
+
* identifies nobody; same reasoning as the snooze grant's frozen `userId`).
|
|
20549
|
+
*
|
|
20550
|
+
* Throws only when NOTHING was applied and something went wrong, so the
|
|
20551
|
+
* plane's line says the run failed rather than reporting a spent token as a
|
|
20552
|
+
* success.
|
|
20553
|
+
*/
|
|
20554
|
+
async applyGrantedAddonUpdate(grant) {
|
|
20555
|
+
const apply = this.deps.applyAddonUpdate;
|
|
20556
|
+
const list = this.deps.listAddonUpdates;
|
|
20557
|
+
if (apply === void 0 || list === void 0) throw new Error("no addon updater is wired on this node");
|
|
20558
|
+
let updatable;
|
|
20559
|
+
try {
|
|
20560
|
+
updatable = await list(grant.nodeId);
|
|
20561
|
+
} catch (err) {
|
|
20562
|
+
this.logger.warn("addon update button changed nothing — could not read what needs updating", { meta: {
|
|
20563
|
+
nodeId: grant.nodeId,
|
|
20564
|
+
packages: grant.packages,
|
|
20565
|
+
error: String(err)
|
|
20566
|
+
} });
|
|
20567
|
+
await this.announceAddonUpdate(grant, "unknown", { nodeId: grant.nodeId });
|
|
20568
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
20569
|
+
}
|
|
20570
|
+
const pending = pendingPackages(grant.packages, updatable);
|
|
20571
|
+
const skipped = grant.packages.filter((name) => !pending.includes(name));
|
|
20572
|
+
if (pending.length === 0) {
|
|
20573
|
+
this.logger.info("addon update button applied nothing — every package is already current", { meta: {
|
|
20574
|
+
nodeId: grant.nodeId,
|
|
20575
|
+
packages: grant.packages
|
|
20576
|
+
} });
|
|
20577
|
+
await this.announceAddonUpdate(grant, "uptodate", { nodeId: grant.nodeId }, {
|
|
20578
|
+
requested: grant.packages.length,
|
|
20579
|
+
applied: [],
|
|
20580
|
+
skipped,
|
|
20581
|
+
failed: []
|
|
20582
|
+
});
|
|
20583
|
+
return;
|
|
20584
|
+
}
|
|
20585
|
+
await this.announceAddonUpdate(grant, "started", {
|
|
20586
|
+
count: String(pending.length),
|
|
20587
|
+
nodeId: grant.nodeId
|
|
20588
|
+
}, void 0, pending.length);
|
|
20589
|
+
const applied = [];
|
|
20590
|
+
const failed = [];
|
|
20591
|
+
for (const packageName of pending) try {
|
|
20592
|
+
const result = await apply({
|
|
20593
|
+
packageName,
|
|
20594
|
+
nodeId: grant.nodeId
|
|
20595
|
+
});
|
|
20596
|
+
if (result.ok) applied.push(packageName);
|
|
20597
|
+
else {
|
|
20598
|
+
failed.push(packageName);
|
|
20599
|
+
this.logger.warn("addon update from a notification button failed", { meta: {
|
|
20600
|
+
nodeId: grant.nodeId,
|
|
20601
|
+
packageName,
|
|
20602
|
+
error: result.error
|
|
20603
|
+
} });
|
|
20604
|
+
}
|
|
20605
|
+
} catch (err) {
|
|
20606
|
+
failed.push(packageName);
|
|
20607
|
+
this.logger.warn("addon update from a notification button threw", { meta: {
|
|
20608
|
+
nodeId: grant.nodeId,
|
|
20609
|
+
packageName,
|
|
20610
|
+
error: String(err)
|
|
20611
|
+
} });
|
|
20612
|
+
}
|
|
20613
|
+
const report = {
|
|
20614
|
+
requested: grant.packages.length,
|
|
20615
|
+
applied,
|
|
20616
|
+
skipped,
|
|
20617
|
+
failed
|
|
20618
|
+
};
|
|
20619
|
+
this.logger.info("addon updates applied from a notification button", { meta: {
|
|
20620
|
+
nodeId: grant.nodeId,
|
|
20621
|
+
requested: report.requested,
|
|
20622
|
+
applied: applied.length,
|
|
20623
|
+
skipped: skipped.length,
|
|
20624
|
+
failed
|
|
20625
|
+
} });
|
|
20626
|
+
await this.announceAddonUpdate(grant, addonUpdateSentence(report), {
|
|
20627
|
+
nodeId: grant.nodeId,
|
|
20628
|
+
applied: String(applied.length),
|
|
20629
|
+
skipped: String(skipped.length),
|
|
20630
|
+
failed: String(failed.length)
|
|
20631
|
+
}, report, applied.length);
|
|
20632
|
+
if (applied.length === 0 && failed.length > 0) throw new Error(`no package updated on ${grant.nodeId} (${failed.join(", ")})`);
|
|
20633
|
+
}
|
|
20634
|
+
/**
|
|
20635
|
+
* One sentence about an update tap, to the target the notification went to.
|
|
20636
|
+
*
|
|
20637
|
+
* Best-effort by construction — `deliverAnnouncement` never throws and never
|
|
20638
|
+
* retries — because an announcement that failed must not turn an update that
|
|
20639
|
+
* HAPPENED into a run the plane reports as failed.
|
|
20640
|
+
*/
|
|
20641
|
+
async announceAddonUpdate(grant, sentence, vars, report, count) {
|
|
20642
|
+
const targetId = grant.targetId;
|
|
20643
|
+
if (targetId === void 0) return;
|
|
20644
|
+
const body = this.texts.text({
|
|
20645
|
+
key: `action.addon-update.${sentence}`,
|
|
20646
|
+
vars,
|
|
20647
|
+
...count !== void 0 ? { count } : {}
|
|
20648
|
+
});
|
|
20649
|
+
const failedNames = report?.failed ?? [];
|
|
20650
|
+
await this.dispatcher.deliverAnnouncement({
|
|
20651
|
+
reason: `addon-update-${sentence}`,
|
|
20652
|
+
targetId,
|
|
20653
|
+
title: this.texts.text({ key: "action.addon-update.title" }),
|
|
20654
|
+
body: failedNames.length > 0 ? `${body}\n${failedNames.join(", ")}` : body
|
|
20655
|
+
});
|
|
20656
|
+
}
|
|
20348
20657
|
async runGrantedSequence(grant) {
|
|
20349
20658
|
if (this.actionRunner === null) throw new Error("no actuator is wired on this node");
|
|
20350
20659
|
const rule = this.rules.get(grant.ruleId);
|
|
@@ -25694,6 +26003,67 @@ var MEDIA_INDEXES = [
|
|
|
25694
26003
|
{
|
|
25695
26004
|
name: "idx_media_location",
|
|
25696
26005
|
columns: ["locationId"]
|
|
26006
|
+
}),
|
|
26007
|
+
(
|
|
26008
|
+
/**
|
|
26009
|
+
* The events-management footprint — `GROUP BY deviceId` and
|
|
26010
|
+
* `SUM(sizeBytes) WHERE deviceId = ?`. See {@link MediaStore.footprintByDevice}.
|
|
26011
|
+
*
|
|
26012
|
+
* **Why not `idx_media_device_ts`.** That index already leads with
|
|
26013
|
+
* `deviceId`, so it can find the range — but it does not carry `sizeBytes`,
|
|
26014
|
+
* so the sum has to fetch the table row behind every index entry it visits.
|
|
26015
|
+
* Over the whole collection that is one scattered read of the entire ~1.25 M
|
|
26016
|
+
* row table (`path` alone is ~80 characters per row) on the volume the
|
|
26017
|
+
* recorder is writing to. With `sizeBytes` in the index the planner never
|
|
26018
|
+
* touches the table: `SCAN … USING COVERING INDEX idx_media_device_size`.
|
|
26019
|
+
*
|
|
26020
|
+
* It has to be a NEW index rather than a third column on the existing one.
|
|
26021
|
+
* `declareCollection` emits `CREATE INDEX IF NOT EXISTS` keyed on the index
|
|
26022
|
+
* NAME, so widening `idx_media_device_ts` in place would be a silent no-op on
|
|
26023
|
+
* every hub that already has it — the declaration would read as done and the
|
|
26024
|
+
* query would still hit the table.
|
|
26025
|
+
*
|
|
26026
|
+
* **What it costs.** ~19 bytes of index per row (two integers plus the
|
|
26027
|
+
* rowid), so ~24 MB at 1.25 M rows, plus one more b-tree maintained by every
|
|
26028
|
+
* insert into a table taking ~92 k rows/day. At ~1 insert/s that maintenance
|
|
26029
|
+
* is noise beside the JPEG write the same call performs. The cost that is not
|
|
26030
|
+
* noise is the one-off: the first `declareCollection` after this ships builds
|
|
26031
|
+
* the index over every existing row. Do not deploy it beside a running drain
|
|
26032
|
+
* or seal — the same caveat `idx_media_location` carries, for the same
|
|
26033
|
+
* reason.
|
|
26034
|
+
*/
|
|
26035
|
+
{
|
|
26036
|
+
name: "idx_media_device_size",
|
|
26037
|
+
columns: ["deviceId", "sizeBytes"]
|
|
26038
|
+
}),
|
|
26039
|
+
(
|
|
26040
|
+
/**
|
|
26041
|
+
* The per-KIND footprint — `COUNT(*)`/`SUM(sizeBytes) WHERE kind = ?`, and
|
|
26042
|
+
* the same scoped to one camera. See {@link MediaStore.footprintByKind}.
|
|
26043
|
+
*
|
|
26044
|
+
* `kind` leads no existing index, so without this each of the thirteen
|
|
26045
|
+
* per-kind questions is a full table scan and the breakdown costs thirteen of
|
|
26046
|
+
* them. With it, each kind is a contiguous covering range and the thirteen
|
|
26047
|
+
* together scan the index once — the cost of the whole breakdown is one pass
|
|
26048
|
+
* over ~36 MB of index instead of thirteen passes over the table.
|
|
26049
|
+
*
|
|
26050
|
+
* `deviceId` sits between `kind` and `sizeBytes` so the per-camera variant
|
|
26051
|
+
* (`WHERE kind = ? AND deviceId = ?`) stays covering too, for ~3 bytes a row
|
|
26052
|
+
* more than `(kind, sizeBytes)` would cost. Without it that query finds its
|
|
26053
|
+
* range from the index and then reads the table for every row in it.
|
|
26054
|
+
*
|
|
26055
|
+
* ~29 bytes of index per row, so ~36 MB at 1.25 M rows, and one more b-tree
|
|
26056
|
+
* on every insert. Same one-off caveat as the two indexes above: the first
|
|
26057
|
+
* declare builds it over every existing row, so do not ship it beside a
|
|
26058
|
+
* running drain or seal.
|
|
26059
|
+
*/
|
|
26060
|
+
{
|
|
26061
|
+
name: "idx_media_kind_device_size",
|
|
26062
|
+
columns: [
|
|
26063
|
+
"kind",
|
|
26064
|
+
"deviceId",
|
|
26065
|
+
"sizeBytes"
|
|
26066
|
+
]
|
|
25697
26067
|
})
|
|
25698
26068
|
];
|
|
25699
26069
|
/** The storage location of a media row/record: its stamped `locationId`, or
|
|
@@ -26348,47 +26718,136 @@ var MediaStore = class {
|
|
|
26348
26718
|
};
|
|
26349
26719
|
}
|
|
26350
26720
|
/**
|
|
26351
|
-
* On-demand footprint aggregation for the events-management UI:
|
|
26352
|
-
* `sizeBytes`
|
|
26353
|
-
*
|
|
26354
|
-
*
|
|
26355
|
-
*
|
|
26721
|
+
* On-demand footprint aggregation for the events-management UI: rows and
|
|
26722
|
+
* summed `sizeBytes` per camera, across the whole media collection.
|
|
26723
|
+
*
|
|
26724
|
+
* ## Why this is a histogram plus one aggregate per camera
|
|
26725
|
+
*
|
|
26726
|
+
* It used to page the ENTIRE collection — `ORDER BY id LIMIT 1000 OFFSET n`,
|
|
26727
|
+
* no predicate — and add `sizeBytes` up in JS. SQLite honours an OFFSET by
|
|
26728
|
+
* stepping over `n` entries, so the walk is quadratic in the table: at the
|
|
26729
|
+
* live hub's 1.25 M rows that is ~1 250 round trips and ~780 M index steps,
|
|
26730
|
+
* with every row serialised across an RPC boundary and rebuilt in V8 to be
|
|
26731
|
+
* added to a number. It timed out at 60 s on every call it ever made, and it
|
|
26732
|
+
* is the only thing that can size the surplus-media deletion it gates.
|
|
26733
|
+
*
|
|
26734
|
+
* The answer is at most a few dozen `(deviceId, rows, bytes)` triples.
|
|
26735
|
+
*
|
|
26736
|
+
* - The DEVICE SET comes from a histogram on `deviceId` with `bucketSize: 1`
|
|
26737
|
+
* — `GROUP BY deviceId`, served from `idx_media_device_size` as a covering
|
|
26738
|
+
* scan, the same trick `TrackStore.listDeviceIds` already uses (D56).
|
|
26739
|
+
* `bucket` IS the deviceId: the column is INTEGER and the bucket
|
|
26740
|
+
* expression is `CAST((deviceId - 0) / 1 AS INTEGER)`.
|
|
26741
|
+
* - The NUMBERS come from one `aggregate` per camera — `COUNT(*)` and
|
|
26742
|
+
* `SUM(sizeBytes)` in a single statement over an indexed `deviceId` range.
|
|
26743
|
+
*
|
|
26744
|
+
* Both figures for a camera come from the SAME statement deliberately. Taking
|
|
26745
|
+
* `rows` from the histogram and `bytes` from the aggregate would let a write
|
|
26746
|
+
* landing between the two produce a footprint whose row count and byte count
|
|
26747
|
+
* describe different populations — a disagreement an operator sizing a
|
|
26748
|
+
* deletion has no way to detect.
|
|
26749
|
+
*
|
|
26750
|
+
* A read that fails THROWS. It must never degrade to a zero: "this camera
|
|
26751
|
+
* holds nothing" is precisely the claim a failed measurement must not be
|
|
26752
|
+
* allowed to make to something about to delete (D295, D312).
|
|
26356
26753
|
*/
|
|
26357
26754
|
async footprintByDevice() {
|
|
26358
|
-
const
|
|
26755
|
+
const buckets = await this.store.histogram.query({
|
|
26756
|
+
collection: MEDIA_COLLECTION,
|
|
26757
|
+
field: "deviceId",
|
|
26758
|
+
bucketSize: 1,
|
|
26759
|
+
origin: 0
|
|
26760
|
+
});
|
|
26359
26761
|
const out = /* @__PURE__ */ new Map();
|
|
26360
|
-
|
|
26361
|
-
|
|
26362
|
-
const
|
|
26762
|
+
for (const bucket of buckets) {
|
|
26763
|
+
if (bucket.count === 0 || !Number.isFinite(bucket.bucket)) continue;
|
|
26764
|
+
const deviceId = bucket.bucket;
|
|
26765
|
+
const agg = await this.store.aggregate.query({
|
|
26363
26766
|
collection: MEDIA_COLLECTION,
|
|
26364
|
-
|
|
26365
|
-
|
|
26366
|
-
|
|
26367
|
-
|
|
26368
|
-
|
|
26369
|
-
|
|
26370
|
-
|
|
26371
|
-
|
|
26767
|
+
fields: [{
|
|
26768
|
+
as: "bytes",
|
|
26769
|
+
field: "sizeBytes",
|
|
26770
|
+
op: "sum"
|
|
26771
|
+
}],
|
|
26772
|
+
filter: { where: { deviceId } }
|
|
26773
|
+
});
|
|
26774
|
+
if (agg.count === 0) continue;
|
|
26775
|
+
const bytes = agg.values["bytes"];
|
|
26776
|
+
out.set(deviceId, {
|
|
26777
|
+
bytes: bytes ?? 0,
|
|
26778
|
+
rows: agg.count
|
|
26372
26779
|
});
|
|
26373
|
-
if (rows.length === 0) break;
|
|
26374
|
-
for (const row of rows) {
|
|
26375
|
-
const data = row.data;
|
|
26376
|
-
const deviceId = Number(data["deviceId"]);
|
|
26377
|
-
const sizeBytes = Number(data["sizeBytes"]);
|
|
26378
|
-
if (!Number.isFinite(deviceId)) continue;
|
|
26379
|
-
const acc = out.get(deviceId) ?? {
|
|
26380
|
-
bytes: 0,
|
|
26381
|
-
rows: 0
|
|
26382
|
-
};
|
|
26383
|
-
acc.bytes += Number.isFinite(sizeBytes) ? sizeBytes : 0;
|
|
26384
|
-
acc.rows += 1;
|
|
26385
|
-
out.set(deviceId, acc);
|
|
26386
|
-
}
|
|
26387
|
-
if (rows.length < PAGE) break;
|
|
26388
|
-
offset += PAGE;
|
|
26389
26780
|
}
|
|
26390
26781
|
return out;
|
|
26391
26782
|
}
|
|
26783
|
+
/**
|
|
26784
|
+
* The media footprint broken down by KIND — fleet-wide, or for one camera.
|
|
26785
|
+
*
|
|
26786
|
+
* `footprintByDevice` answers "how much is where"; this answers "how much is
|
|
26787
|
+
* WHAT", which is the axis a deletion decision turns on. The deletable set
|
|
26788
|
+
* (the periodic `snapshot` filmstrip, the surplus per-edge motion stills) and
|
|
26789
|
+
* the keep set (`firstFrame`, rolling `lastFrame`, `thumbnail` /
|
|
26790
|
+
* `thumbnailSmall`, `keyFrame` / `keyFrameSmall`, the face and plate buffers,
|
|
26791
|
+
* gallery media, the CLIP `crop`) are told apart by `kind` and by nothing
|
|
26792
|
+
* else. A byte total cannot size a deletion; this can.
|
|
26793
|
+
*
|
|
26794
|
+
* One `aggregate` per kind, each an indexed covering range on
|
|
26795
|
+
* `idx_media_kind_device_size`, plus ONE unfiltered aggregate for the totals.
|
|
26796
|
+
*
|
|
26797
|
+
* That last statement is the point of the design and not a spare round trip.
|
|
26798
|
+
* `MediaFileKindEnum.options` is the closed set the writers use, but the
|
|
26799
|
+
* TABLE is not closed: a row written by a retired path, or by a version that
|
|
26800
|
+
* knew a kind this one does not, matches no per-kind query. Adding the kinds
|
|
26801
|
+
* up to produce the total would make such a row invisible and the denominator
|
|
26802
|
+
* quietly too small — the failure mode that matters most here, because the
|
|
26803
|
+
* caller is about to delete against it. So the total is measured
|
|
26804
|
+
* independently and the difference is REPORTED as `unaccounted*` rather than
|
|
26805
|
+
* being absorbed.
|
|
26806
|
+
*/
|
|
26807
|
+
async footprintByKind(deviceId) {
|
|
26808
|
+
const scope = deviceId === void 0 ? void 0 : { where: { deviceId } };
|
|
26809
|
+
const fields = [{
|
|
26810
|
+
as: "bytes",
|
|
26811
|
+
field: "sizeBytes",
|
|
26812
|
+
op: "sum"
|
|
26813
|
+
}];
|
|
26814
|
+
const totals = await this.store.aggregate.query({
|
|
26815
|
+
collection: MEDIA_COLLECTION,
|
|
26816
|
+
fields: [...fields],
|
|
26817
|
+
...scope === void 0 ? {} : { filter: scope }
|
|
26818
|
+
});
|
|
26819
|
+
const kinds = [];
|
|
26820
|
+
let countedRows = 0;
|
|
26821
|
+
let countedBytes = 0;
|
|
26822
|
+
for (const kind of require_dist.MediaFileKindEnum.options) {
|
|
26823
|
+
const agg = await this.store.aggregate.query({
|
|
26824
|
+
collection: MEDIA_COLLECTION,
|
|
26825
|
+
fields: [...fields],
|
|
26826
|
+
filter: { where: {
|
|
26827
|
+
kind,
|
|
26828
|
+
...deviceId === void 0 ? {} : { deviceId }
|
|
26829
|
+
} }
|
|
26830
|
+
});
|
|
26831
|
+
if (agg.count === 0) continue;
|
|
26832
|
+
const bytes = agg.values["bytes"] ?? 0;
|
|
26833
|
+
countedRows += agg.count;
|
|
26834
|
+
countedBytes += bytes;
|
|
26835
|
+
kinds.push({
|
|
26836
|
+
kind,
|
|
26837
|
+
rows: agg.count,
|
|
26838
|
+
bytes
|
|
26839
|
+
});
|
|
26840
|
+
}
|
|
26841
|
+
kinds.sort((a, b) => b.bytes - a.bytes);
|
|
26842
|
+
const totalBytes = totals.values["bytes"] ?? 0;
|
|
26843
|
+
return {
|
|
26844
|
+
totalRows: totals.count,
|
|
26845
|
+
totalBytes,
|
|
26846
|
+
kinds,
|
|
26847
|
+
unaccountedRows: totals.count - countedRows,
|
|
26848
|
+
unaccountedBytes: totalBytes - countedBytes
|
|
26849
|
+
};
|
|
26850
|
+
}
|
|
26392
26851
|
/** Move a single media entry from one owner to another.
|
|
26393
26852
|
* The new copy is written before the old one is removed.
|
|
26394
26853
|
* The old-entry deletion is best-effort: on failure a warning is logged
|
|
@@ -28910,30 +29369,57 @@ var LEGACY_FACE_MODEL_ID = "arcface-r100";
|
|
|
28910
29369
|
*/
|
|
28911
29370
|
var FACES_COLLECTION = "pipeline-analytics:faces";
|
|
28912
29371
|
/**
|
|
28913
|
-
* Rows per page for the
|
|
28914
|
-
*
|
|
28915
|
-
* A face row is one of the two WIDEST row shapes in the hub's database: it
|
|
28916
|
-
* carries a JSON `embedding` on every single row, measured at **10.8 KB
|
|
28917
|
-
* average** where a motion event is 78 bytes. Both sweeps used to read the
|
|
28918
|
-
* collection in ONE statement with no `limit`.
|
|
29372
|
+
* Rows per page for `pruneCapOverflow`, the ONE remaining walk of this
|
|
29373
|
+
* collection.
|
|
28919
29374
|
*
|
|
28920
|
-
*
|
|
28921
|
-
*
|
|
28922
|
-
*
|
|
28923
|
-
*
|
|
28924
|
-
* grows with a table nothing else bounds, which is the same defect one incident
|
|
28925
|
-
* earlier. `pruneCapOverflow` is what keeps the row count small — and it was
|
|
28926
|
-
* itself one of the two unbounded readers, so the bound and the thing enforcing
|
|
28927
|
-
* it were the same loop.
|
|
29375
|
+
* It is a walk that DOES WORK: the capacity bound has to consider every row to
|
|
29376
|
+
* decide which ones leave, and it runs on the maintenance pass, not on a
|
|
29377
|
+
* request. That is the shape D320 permits. What it must not do is drag the
|
|
29378
|
+
* whole row across for four scalars — see the projection there.
|
|
28928
29379
|
*
|
|
28929
|
-
* The
|
|
28930
|
-
*
|
|
28931
|
-
*
|
|
29380
|
+
* The two READS that used to page here do not any more. They asked a question
|
|
29381
|
+
* ("the newest N faces that are still unassigned"), the question is a
|
|
29382
|
+
* predicate, and a predicate answers it in one statement whose cost is the
|
|
29383
|
+
* ANSWER rather than the table.
|
|
28932
29384
|
*
|
|
28933
|
-
*
|
|
28934
|
-
*
|
|
29385
|
+
* The engine refuses an unbounded read (`query-bounds.ts`) and truncates a
|
|
29386
|
+
* limitless one at `SETTINGS_QUERY_DEFAULT_ROW_CAP` with a WARN nobody can act
|
|
29387
|
+
* on, so every read below carries an explicit `limit`.
|
|
28935
29388
|
*/
|
|
28936
29389
|
var FACE_SWEEP_PAGE = 1e3;
|
|
29390
|
+
/**
|
|
29391
|
+
* The widest window a single gallery read may materialise before it REFUSES.
|
|
29392
|
+
*
|
|
29393
|
+
* It bounds only the reads whose size the CALLER did not name: an unbounded
|
|
29394
|
+
* `listAllRecentFaces`, and the `suggestionConfidence` order, which cannot stop
|
|
29395
|
+
* early because the most certain suggestion may be the oldest row (`face-sort.ts`).
|
|
29396
|
+
* A caller's own `limit` is honoured verbatim at any table size — asking for
|
|
29397
|
+
* 500 and getting 500 is being served, not being truncated.
|
|
29398
|
+
*
|
|
29399
|
+
* 10 000 because that is above what `pruneCapOverflow` actually permits to
|
|
29400
|
+
* exist: 29 cameras × (50 ordinary + 200 suggested) = 7 250 unassigned rows,
|
|
29401
|
+
* plus whatever the operator has enrolled. It sits under the engine's
|
|
29402
|
+
* `SETTINGS_QUERY_HARD_ROW_CAP` (20 000) so the engine never truncates first —
|
|
29403
|
+
* this bound is the one that speaks, and it speaks by throwing.
|
|
29404
|
+
*
|
|
29405
|
+
* The alternative was to answer with the rows it managed to read. A short list
|
|
29406
|
+
* and a complete one are the same array, and a gallery that quietly stops
|
|
29407
|
+
* showing a face is exactly the failure this whole change exists to avoid.
|
|
29408
|
+
*/
|
|
29409
|
+
var FACE_WINDOW_MAX_ROWS = 1e4;
|
|
29410
|
+
/**
|
|
29411
|
+
* A gallery read whose window is wider than {@link FACE_WINDOW_MAX_ROWS}.
|
|
29412
|
+
*
|
|
29413
|
+
* Distinct type so a router can map it to a 400 and an operator can be told
|
|
29414
|
+
* *"the buffer is too large to rank in one pass — narrow the window"* instead
|
|
29415
|
+
* of being shown a page that silently lost its tail.
|
|
29416
|
+
*/
|
|
29417
|
+
var FaceWindowTooWideError = class extends Error {
|
|
29418
|
+
constructor(rows) {
|
|
29419
|
+
super(`face window exceeds ${String(FACE_WINDOW_MAX_ROWS)} rows (read ${String(rows)}) — refusing to answer with the part that fit; narrow with deviceIds / since / until, or pass an explicit limit`);
|
|
29420
|
+
this.name = "FaceWindowTooWideError";
|
|
29421
|
+
}
|
|
29422
|
+
};
|
|
28937
29423
|
/** How many evicted face ids one per-camera eviction line names before it
|
|
28938
29424
|
* stops — enough to find the rows, not enough to turn a log line into a page. */
|
|
28939
29425
|
var SUGGESTED_EVICTION_IDS_LOGGED = 10;
|
|
@@ -29035,13 +29521,128 @@ var FACE_COLUMNS = [
|
|
|
29035
29521
|
type: "REAL"
|
|
29036
29522
|
}
|
|
29037
29523
|
];
|
|
29038
|
-
|
|
29039
|
-
|
|
29040
|
-
|
|
29041
|
-
|
|
29042
|
-
|
|
29043
|
-
|
|
29044
|
-
|
|
29524
|
+
/**
|
|
29525
|
+
* What every gallery query orders by, and what the three of them filter on.
|
|
29526
|
+
*
|
|
29527
|
+
* Before these existed, EVERY read of this collection was
|
|
29528
|
+
* `SCAN … | USE TEMP B-TREE FOR ORDER BY` — a full table scan plus a sort of
|
|
29529
|
+
* the whole table, to hand back the newest 500 rows. Measured with
|
|
29530
|
+
* `EXPLAIN QUERY PLAN` over a 20 000-row copy of this exact schema:
|
|
29531
|
+
*
|
|
29532
|
+
* | query | before | after |
|
|
29533
|
+
* | --- | --- | --- |
|
|
29534
|
+
* | newest N | `SCAN + TEMP B-TREE` | `SCAN USING idx_faces_ts` |
|
|
29535
|
+
* | newest N unassigned | `SCAN + TEMP B-TREE` | `SEARCH USING idx_faces_assigned_ts (assigned=?)` |
|
|
29536
|
+
* | newest N recognized | `SCAN + TEMP B-TREE` | `SEARCH USING idx_faces_assigned_ts (assigned=?)` |
|
|
29537
|
+
* | faces of one identity | `SCAN + TEMP B-TREE` | `SEARCH USING idx_faces_identity_ts (recognizedIdentityId=?)` |
|
|
29538
|
+
*
|
|
29539
|
+
* The sort disappearing is the point: with an ordered index the statement stops
|
|
29540
|
+
* at the `LIMIT`, so the cost is the ANSWER. Without it the cost is the table,
|
|
29541
|
+
* and this table is bounded only by `pruneCapOverflow` — it went from 372 rows
|
|
29542
|
+
* (2026-08-25) to 1 612 (2026-08-31) while a UI polled it every 30 seconds.
|
|
29543
|
+
*
|
|
29544
|
+
* `idx_faces_ts` is NOT redundant beside `idx_faces_assigned_ts`: a composite
|
|
29545
|
+
* leading on `assigned` cannot serve an unfiltered `ORDER BY timestamp`.
|
|
29546
|
+
* Three b-trees over a few thousand narrow rows is tens of KB, against one
|
|
29547
|
+
* UPSERT per track that produces a face — the write rate this pays at.
|
|
29548
|
+
*/
|
|
29549
|
+
var FACE_INDEXES = [
|
|
29550
|
+
{
|
|
29551
|
+
name: "idx_faces_device_ts",
|
|
29552
|
+
columns: ["deviceId", "timestamp"]
|
|
29553
|
+
},
|
|
29554
|
+
{
|
|
29555
|
+
name: "idx_faces_track",
|
|
29556
|
+
columns: ["trackId"]
|
|
29557
|
+
},
|
|
29558
|
+
{
|
|
29559
|
+
name: "idx_faces_ts",
|
|
29560
|
+
columns: ["timestamp"]
|
|
29561
|
+
},
|
|
29562
|
+
{
|
|
29563
|
+
name: "idx_faces_assigned_ts",
|
|
29564
|
+
columns: ["assigned", "timestamp"]
|
|
29565
|
+
},
|
|
29566
|
+
{
|
|
29567
|
+
name: "idx_faces_identity_ts",
|
|
29568
|
+
columns: ["recognizedIdentityId", "timestamp"]
|
|
29569
|
+
}
|
|
29570
|
+
];
|
|
29571
|
+
/**
|
|
29572
|
+
* The columns a {@link Face} is built from — every declared column EXCEPT the
|
|
29573
|
+
* drained legacy `embedding`, derived mechanically so a column added tomorrow
|
|
29574
|
+
* arrives without anyone remembering this list.
|
|
29575
|
+
*
|
|
29576
|
+
* Mechanical on purpose. A hand-written projection is how a field silently
|
|
29577
|
+
* stops arriving: the row comes back missing the key, `rowToFace` reads
|
|
29578
|
+
* `undefined`, and "the database says nothing" is indistinguishable from "I did
|
|
29579
|
+
* not ask". The engine REFUSES a column it cannot serve
|
|
29580
|
+
* (`resolveProjection`) — the projection is the one part of a query that is not
|
|
29581
|
+
* allowed to be forgiving — so a stale name here is a throw, not a wrong value.
|
|
29582
|
+
*
|
|
29583
|
+
* `embedding` is the only exclusion, and it is the whole reason to project:
|
|
29584
|
+
* it is a `notNull` JSON column on the widest row shape in the database, it is
|
|
29585
|
+
* dropped by `rowToFace` on arrival, and {@link Face} has no field for it. A
|
|
29586
|
+
* caller that wants floats asks by name ({@link FaceStore.getWithEmbedding},
|
|
29587
|
+
* {@link FaceStore.loadEmbeddings}). Since D304 drained it to `[]` the saving
|
|
29588
|
+
* is small — 23 B/row, measured on the live hub — but the column is still
|
|
29589
|
+
* declared, still `notNull`, and an unconverted row still carries ~10.8 KB of
|
|
29590
|
+
* it. Projecting is what keeps that a fact about ONE row rather than about
|
|
29591
|
+
* every gallery poll.
|
|
29592
|
+
*/
|
|
29593
|
+
var FACE_LIST_COLUMNS = FACE_COLUMNS.map((c) => c.name).filter((name) => name !== "embedding" && name !== "id");
|
|
29594
|
+
/**
|
|
29595
|
+
* The four scalars `pruneCapOverflow` decides on.
|
|
29596
|
+
*
|
|
29597
|
+
* Its docblock has always said *"only four scalars per row survive the page"*.
|
|
29598
|
+
* They survived the PAGE; the query still dragged every column — the embedding
|
|
29599
|
+
* included — across the RPC boundary and through V8 first. A leftover that
|
|
29600
|
+
* describes the right design reads as verification, so the projection now makes
|
|
29601
|
+
* the sentence true.
|
|
29602
|
+
*/
|
|
29603
|
+
var FACE_CAP_COLUMNS = [
|
|
29604
|
+
"deviceId",
|
|
29605
|
+
"timestamp",
|
|
29606
|
+
"assigned",
|
|
29607
|
+
"suggestedIdentityId"
|
|
29608
|
+
];
|
|
29609
|
+
/** One camera reference per identity — see {@link FaceStore.listCameraRefsForIdentity}. */
|
|
29610
|
+
var FACE_REF_COLUMNS = ["deviceId"];
|
|
29611
|
+
/**
|
|
29612
|
+
* A gallery view as a STORE PREDICATE.
|
|
29613
|
+
*
|
|
29614
|
+
* Every one of the four is expressible, and that is the whole finding behind
|
|
29615
|
+
* this change. They were applied in JS, over pages the store had already
|
|
29616
|
+
* chosen, which is why `limit` counted rows that PASSED and the read had to
|
|
29617
|
+
* sweep until enough had: with `filter: 'unassigned'` the newest N rows may be
|
|
29618
|
+
* entirely assigned while matches wait just beyond them. Pushed down, `limit`
|
|
29619
|
+
* IS the store's limit, and the sweep has nothing left to do.
|
|
29620
|
+
*
|
|
29621
|
+
* **Two of them name null, and null is the trap.** `recognizedIdentityId` is a
|
|
29622
|
+
* nullable TEXT column, so "has an identity" is `IS NOT NULL` — which
|
|
29623
|
+
* `whereNot: { field: null }` compiles to since D312, and which before D312
|
|
29624
|
+
* compiled to `(expr IS NULL OR expr != NULL)`: TRUE for a NULL row and NULL
|
|
29625
|
+
* for every other, i.e. precisely the rows it was asked to exclude. A predicate
|
|
29626
|
+
* that silently matched nothing would report "no recognized faces" forever and
|
|
29627
|
+
* every assertion about the rows returned would agree with it. Verified against
|
|
29628
|
+
* the live hub before shipping: 1 612 rows, `where {assigned:false}` → 1 444,
|
|
29629
|
+
* `whereNot {recognizedIdentityId:null}` → 247.
|
|
29630
|
+
*
|
|
29631
|
+
* `assigned` is `BOOLEAN NOT NULL`; the backend serialises `false` to `0` at
|
|
29632
|
+
* the bind (`serializeColumnValue`) and decodes `0` back to `false` on read, so
|
|
29633
|
+
* plain equality is correct and no null semantics are involved.
|
|
29634
|
+
*/
|
|
29635
|
+
function faceFilterPredicate(filter) {
|
|
29636
|
+
switch (filter) {
|
|
29637
|
+
case "unassigned": return { where: { assigned: false } };
|
|
29638
|
+
case "recognized": return {
|
|
29639
|
+
where: { assigned: false },
|
|
29640
|
+
whereNot: { recognizedIdentityId: null }
|
|
29641
|
+
};
|
|
29642
|
+
case "identified": return { whereNot: { recognizedIdentityId: null } };
|
|
29643
|
+
case "all": return {};
|
|
29644
|
+
}
|
|
29645
|
+
}
|
|
29045
29646
|
/**
|
|
29046
29647
|
* `since` / `until` as a store predicate.
|
|
29047
29648
|
*
|
|
@@ -29076,6 +29677,7 @@ function rowToFace(id, raw) {
|
|
|
29076
29677
|
return {
|
|
29077
29678
|
...rest,
|
|
29078
29679
|
id,
|
|
29680
|
+
embeddingModelId: data.embeddingModelId ?? void 0,
|
|
29079
29681
|
assigned: Boolean(data.assigned),
|
|
29080
29682
|
mediaKey: data.mediaKey ?? void 0,
|
|
29081
29683
|
recognizedIdentityId: data.recognizedIdentityId ?? void 0,
|
|
@@ -29304,16 +29906,21 @@ var FaceStore = class {
|
|
|
29304
29906
|
*/
|
|
29305
29907
|
async pruneCapOverflow(input) {
|
|
29306
29908
|
/**
|
|
29307
|
-
* Paged, not one statement
|
|
29308
|
-
*
|
|
29309
|
-
*
|
|
29310
|
-
*
|
|
29311
|
-
*
|
|
29909
|
+
* Paged, not one statement, and PROJECTED to the four scalars the decision
|
|
29910
|
+
* needs — this sentence used to be true only of what survived the page,
|
|
29911
|
+
* while the query still dragged every column, `embedding` included, across
|
|
29912
|
+
* the RPC boundary and through V8 first.
|
|
29913
|
+
*
|
|
29914
|
+
* This is the one walk of the collection that remains, and D320 permits it:
|
|
29915
|
+
* it must touch every row to ACT on it, and it runs on the maintenance
|
|
29916
|
+
* pass, not on a request. The prune still considers EVERY row, which is
|
|
29917
|
+
* what makes it a capacity bound rather than a bound on the newest page.
|
|
29312
29918
|
*/
|
|
29313
29919
|
const byDevice = /* @__PURE__ */ new Map();
|
|
29314
29920
|
for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
|
|
29315
29921
|
const rows = await this.store.query.query({
|
|
29316
29922
|
collection: FACES_COLLECTION,
|
|
29923
|
+
columns: FACE_CAP_COLUMNS,
|
|
29317
29924
|
filter: {
|
|
29318
29925
|
orderBy: {
|
|
29319
29926
|
field: "timestamp",
|
|
@@ -29607,29 +30214,38 @@ var FaceStore = class {
|
|
|
29607
30214
|
* - 'recognized' → !assigned && recognizedIdentityId != null
|
|
29608
30215
|
* - 'identified' → recognizedIdentityId != null, including manual assignments
|
|
29609
30216
|
*
|
|
29610
|
-
*
|
|
29611
|
-
*
|
|
29612
|
-
*
|
|
29613
|
-
*
|
|
29614
|
-
*
|
|
29615
|
-
*
|
|
29616
|
-
*
|
|
29617
|
-
*
|
|
29618
|
-
*
|
|
29619
|
-
*
|
|
29620
|
-
*
|
|
29621
|
-
*
|
|
29622
|
-
*
|
|
29623
|
-
*
|
|
29624
|
-
*
|
|
29625
|
-
*
|
|
29626
|
-
*
|
|
29627
|
-
*
|
|
29628
|
-
*
|
|
29629
|
-
*
|
|
29630
|
-
*
|
|
29631
|
-
* `
|
|
29632
|
-
*
|
|
30217
|
+
* **Every part of the question is now a store predicate** — `deviceIds`,
|
|
30218
|
+
* `since` / `until` AND `filter` ({@link faceFilterPredicate}) — so `limit`
|
|
30219
|
+
* is the STORE's limit and one statement is the whole answer.
|
|
30220
|
+
*
|
|
30221
|
+
* That is the change. This used to page: `filter` was a JS predicate applied
|
|
30222
|
+
* to rows the store had already chosen, so `limit` counted rows that PASSED
|
|
30223
|
+
* and the read had to sweep until enough had, up to `FACE_SWEEP_MAX_PAGES`
|
|
30224
|
+
* (1 000) × `FACE_SWEEP_PAGE` (1 000) — a million rows, `offset`-paged,
|
|
30225
|
+
* behind a UI polling every 30 seconds. On the live hub that meant reading
|
|
30226
|
+
* all 1 612 rows over 2 round trips to produce a 60-row `recognized` page,
|
|
30227
|
+
* every 30 s, per open tab, and growing with a table only `pruneCapOverflow`
|
|
30228
|
+
* bounds. It is the same family as `MediaRelocateEngine.pageRows`, which put
|
|
30229
|
+
* a ~16 s standing queue in front of every settings-store call in the cluster
|
|
30230
|
+
* (D320) — smaller, on a longer timer, and the same shape.
|
|
30231
|
+
*
|
|
30232
|
+
* `offset` is gone with it, not merely unnecessary: the recognizer UPSERTs
|
|
30233
|
+
* into this collection continuously, so page two at `offset: N` starts N rows
|
|
30234
|
+
* past a set that may have gained or lost its first N (D295 rule 1, D320 R3).
|
|
30235
|
+
*
|
|
30236
|
+
* ## What still cannot be pushed, and what that costs
|
|
30237
|
+
*
|
|
30238
|
+
* The `suggestionConfidence` ORDER stays here. `ORDER BY suggestedMatchScore`
|
|
30239
|
+
* would let the backend decide where a scoreless row goes — SQLite collates
|
|
30240
|
+
* NULL first on ASC and last on DESC — and the contract is that it sorts LAST
|
|
30241
|
+
* in BOTH directions (`face-sort.ts`). So that order reads the whole FILTERED
|
|
30242
|
+
* window and ranks it. Filtered, which is the improvement: the window is the
|
|
30243
|
+
* unassigned buffer, not the table.
|
|
30244
|
+
*
|
|
30245
|
+
* A window it cannot read whole REFUSES ({@link FaceWindowTooWideError}).
|
|
30246
|
+
* Ranking the part that fit is exactly "most certain among the newest N",
|
|
30247
|
+
* which is the defect that order was added to remove, and it is invisible in
|
|
30248
|
+
* the rows returned.
|
|
29633
30249
|
*
|
|
29634
30250
|
* An **empty `deviceIds` reads nothing at all**: `[]` is a request for no
|
|
29635
30251
|
* cameras, not an omission (the `deviceManager.listFleet` contract).
|
|
@@ -29638,74 +30254,86 @@ var FaceStore = class {
|
|
|
29638
30254
|
if (input.deviceIds !== void 0 && input.deviceIds.length === 0) return [];
|
|
29639
30255
|
const order = resolveFaceSort(input);
|
|
29640
30256
|
const pagingServesTheOrder = sortIsServedByTimestampPaging(order);
|
|
29641
|
-
|
|
29642
|
-
|
|
29643
|
-
|
|
29644
|
-
|
|
29645
|
-
|
|
29646
|
-
|
|
29647
|
-
|
|
29648
|
-
const
|
|
29649
|
-
|
|
29650
|
-
|
|
29651
|
-
|
|
29652
|
-
|
|
29653
|
-
|
|
30257
|
+
/**
|
|
30258
|
+
* The caller's `limit` bounds the READ only when the store's ordering is
|
|
30259
|
+
* already the answer's. Under `suggestionConfidence` the most certain
|
|
30260
|
+
* suggestion may be the oldest row, so the read is the window and `limit`
|
|
30261
|
+
* bounds only what is RETAINED (`rankAndCap`).
|
|
30262
|
+
*/
|
|
30263
|
+
const callerBounded = pagingServesTheOrder && input.limit !== void 0;
|
|
30264
|
+
const readLimit = callerBounded ? input.limit ?? 0 : 10001;
|
|
30265
|
+
const rows = await this.store.query.query({
|
|
30266
|
+
collection: FACES_COLLECTION,
|
|
30267
|
+
columns: FACE_LIST_COLUMNS,
|
|
30268
|
+
filter: {
|
|
30269
|
+
...input.deviceIds !== void 0 ? { whereIn: { deviceId: [...new Set(input.deviceIds)] } } : {},
|
|
30270
|
+
...buildTimestampWindow(input),
|
|
30271
|
+
...faceFilterPredicate(input.filter ?? "all"),
|
|
30272
|
+
orderBy: {
|
|
30273
|
+
field: "timestamp",
|
|
30274
|
+
direction: pagingServesTheOrder ? order.direction : "desc"
|
|
30275
|
+
},
|
|
30276
|
+
limit: readLimit
|
|
29654
30277
|
}
|
|
29655
|
-
};
|
|
29656
|
-
|
|
29657
|
-
|
|
29658
|
-
|
|
29659
|
-
collection: FACES_COLLECTION,
|
|
29660
|
-
filter: {
|
|
29661
|
-
...storeFilter,
|
|
29662
|
-
limit: FACE_SWEEP_PAGE,
|
|
29663
|
-
offset: page * FACE_SWEEP_PAGE
|
|
29664
|
-
}
|
|
29665
|
-
});
|
|
29666
|
-
if (rows.length === 0) break;
|
|
29667
|
-
const matched = [];
|
|
29668
|
-
for (const r of rows) {
|
|
29669
|
-
const face = rowToFace(r.id, r.data);
|
|
29670
|
-
if (matches(face)) matched.push(face);
|
|
29671
|
-
}
|
|
29672
|
-
if (pagingServesTheOrder) {
|
|
29673
|
-
const merged = [...result, ...matched];
|
|
29674
|
-
if (input.limit !== void 0 && merged.length >= input.limit) return merged.slice(0, input.limit);
|
|
29675
|
-
result = merged;
|
|
29676
|
-
} else result = rankAndCap([...result, ...matched], order, input.limit);
|
|
29677
|
-
if (rows.length < 1e3) break;
|
|
29678
|
-
}
|
|
29679
|
-
return pagingServesTheOrder ? result : rankAndCap(result, order, input.limit);
|
|
30278
|
+
});
|
|
30279
|
+
if (!callerBounded && rows.length > 1e4) throw new FaceWindowTooWideError(rows.length);
|
|
30280
|
+
const faces = rows.map((r) => rowToFace(r.id, r.data));
|
|
30281
|
+
return pagingServesTheOrder ? faces : rankAndCap(faces, order, input.limit);
|
|
29680
30282
|
}
|
|
29681
|
-
/**
|
|
30283
|
+
/**
|
|
30284
|
+
* One real face row per camera currently attributed to an identity.
|
|
30285
|
+
*
|
|
30286
|
+
* `recognizedIdentityId` is indexed (`idx_faces_identity_ts`), so this is a
|
|
30287
|
+
* bounded index walk over the identity's own rows rather than the
|
|
30288
|
+
* `offset`-paged sweep of the whole collection it used to be. It projects
|
|
30289
|
+
* `deviceId` alone — the answer is a device id and a row id, and the row id
|
|
30290
|
+
* is the record key.
|
|
30291
|
+
*/
|
|
29682
30292
|
async listCameraRefsForIdentity(identityId) {
|
|
30293
|
+
const rows = await this.identityRows(identityId, FACE_REF_COLUMNS);
|
|
29683
30294
|
const byDevice = /* @__PURE__ */ new Map();
|
|
29684
|
-
for (
|
|
29685
|
-
const
|
|
29686
|
-
|
|
29687
|
-
filter: {
|
|
29688
|
-
where: { recognizedIdentityId: identityId },
|
|
29689
|
-
orderBy: {
|
|
29690
|
-
field: "timestamp",
|
|
29691
|
-
direction: "desc"
|
|
29692
|
-
},
|
|
29693
|
-
limit: FACE_SWEEP_PAGE,
|
|
29694
|
-
offset: page * FACE_SWEEP_PAGE
|
|
29695
|
-
}
|
|
29696
|
-
});
|
|
29697
|
-
if (rows.length === 0) break;
|
|
29698
|
-
for (const row of rows) {
|
|
29699
|
-
const deviceId = Number(row.data["deviceId"]);
|
|
29700
|
-
if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
|
|
29701
|
-
}
|
|
29702
|
-
if (rows.length < 1e3) break;
|
|
30295
|
+
for (const row of rows) {
|
|
30296
|
+
const deviceId = Number(row.data["deviceId"]);
|
|
30297
|
+
if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
|
|
29703
30298
|
}
|
|
29704
30299
|
return [...byDevice].map(([deviceId, faceId]) => ({
|
|
29705
30300
|
deviceId,
|
|
29706
30301
|
faceId
|
|
29707
30302
|
}));
|
|
29708
30303
|
}
|
|
30304
|
+
/**
|
|
30305
|
+
* Every face row currently attributed to an identity, id only.
|
|
30306
|
+
*
|
|
30307
|
+
* `deleteIdentity` detaches these before the identity goes. It used to get
|
|
30308
|
+
* them by listing the WHOLE collection with `filter: 'all'` and no limit and
|
|
30309
|
+
* comparing in JS — which past `SETTINGS_QUERY_DEFAULT_ROW_CAP` would have
|
|
30310
|
+
* detached the rows that fit and left the rest pointing at a deleted
|
|
30311
|
+
* identity, silently, with a WARN in the engine that no caller can see.
|
|
30312
|
+
*/
|
|
30313
|
+
async listFaceIdsForIdentity(identityId) {
|
|
30314
|
+
return (await this.identityRows(identityId, FACE_REF_COLUMNS)).map((r) => r.id);
|
|
30315
|
+
}
|
|
30316
|
+
/**
|
|
30317
|
+
* The rows of one identity, newest first, bounded and REFUSING rather than
|
|
30318
|
+
* narrowing. Shared by the two callers above so they cannot disagree about
|
|
30319
|
+
* what "this identity's faces" means.
|
|
30320
|
+
*/
|
|
30321
|
+
async identityRows(identityId, columns) {
|
|
30322
|
+
const rows = await this.store.query.query({
|
|
30323
|
+
collection: FACES_COLLECTION,
|
|
30324
|
+
columns,
|
|
30325
|
+
filter: {
|
|
30326
|
+
where: { recognizedIdentityId: identityId },
|
|
30327
|
+
orderBy: {
|
|
30328
|
+
field: "timestamp",
|
|
30329
|
+
direction: "desc"
|
|
30330
|
+
},
|
|
30331
|
+
limit: 10001
|
|
30332
|
+
}
|
|
30333
|
+
});
|
|
30334
|
+
if (rows.length > 1e4) throw new FaceWindowTooWideError(rows.length);
|
|
30335
|
+
return rows;
|
|
30336
|
+
}
|
|
29709
30337
|
};
|
|
29710
30338
|
//#endregion
|
|
29711
30339
|
//#region src/pipeline-analytics/face-gallery-provider.ts
|
|
@@ -29822,8 +30450,7 @@ var FaceGalleryProvider = class {
|
|
|
29822
30450
|
});
|
|
29823
30451
|
}
|
|
29824
30452
|
async deleteIdentity(input) {
|
|
29825
|
-
const
|
|
29826
|
-
for (const f of faces) if (f.recognizedIdentityId === input.id) await this.unassignFace({ faceId: f.id });
|
|
30453
|
+
for (const faceId of await this.faceStore.listFaceIdsForIdentity(input.id)) await this.unassignFace({ faceId });
|
|
29827
30454
|
await this.mediaStore.deleteForOwner("identity", [input.id]);
|
|
29828
30455
|
await this.identityStore.deleteIdentity(input.id);
|
|
29829
30456
|
this.refreshGallery();
|
|
@@ -29896,8 +30523,7 @@ var FaceGalleryProvider = class {
|
|
|
29896
30523
|
sortBy: input?.sortBy,
|
|
29897
30524
|
sortDirection: input?.sortDirection
|
|
29898
30525
|
});
|
|
29899
|
-
const
|
|
29900
|
-
const nameMap = new Map(identities.map((i) => [i.id, i.name]));
|
|
30526
|
+
const nameMap = await this.identityStore.namesFor(faces.flatMap((f) => f.recognizedIdentityId != null ? [f.recognizedIdentityId] : []));
|
|
29901
30527
|
const includeCrops = resolveIncludeCrops(input);
|
|
29902
30528
|
return mapWithConcurrency(faces, FACE_CROP_FETCH_CONCURRENCY, async (face) => {
|
|
29903
30529
|
const base64 = includeCrops ? await this.faceCropBase64(face) : void 0;
|
|
@@ -29933,8 +30559,7 @@ var FaceGalleryProvider = class {
|
|
|
29933
30559
|
const face = await this.faceStore.get("face-" + input.trackId);
|
|
29934
30560
|
if (!face) return null;
|
|
29935
30561
|
if (face.deviceId !== input.deviceId) return null;
|
|
29936
|
-
const
|
|
29937
|
-
const nameMap = new Map(identities.map((i) => [i.id, i.name]));
|
|
30562
|
+
const nameMap = await this.identityStore.namesFor(face.recognizedIdentityId != null ? [face.recognizedIdentityId] : []);
|
|
29938
30563
|
const identityName = face.recognizedIdentityId ? nameMap.get(face.recognizedIdentityId) : void 0;
|
|
29939
30564
|
const cropUrl = this.cropUrlFor(face);
|
|
29940
30565
|
return {
|
|
@@ -34753,9 +35378,14 @@ var PlateGalleryProvider = class {
|
|
|
34753
35378
|
...cropUrl !== void 0 ? { cropUrl } : {}
|
|
34754
35379
|
};
|
|
34755
35380
|
}
|
|
34756
|
-
|
|
34757
|
-
|
|
34758
|
-
|
|
35381
|
+
/**
|
|
35382
|
+
* Names for the vehicles THESE PLATES reference — not the collection.
|
|
35383
|
+
*
|
|
35384
|
+
* A page of unassigned reads references none, and then this costs no round
|
|
35385
|
+
* trip at all. See `VehicleStore.namesFor`.
|
|
35386
|
+
*/
|
|
35387
|
+
async vehicleNameMap(plates) {
|
|
35388
|
+
return this.vehicleStore.namesFor(plates.flatMap((p) => p.recognizedVehicleId != null ? [p.recognizedVehicleId] : []));
|
|
34759
35389
|
}
|
|
34760
35390
|
/**
|
|
34761
35391
|
* `includeCrops` defaults to `true` — the pre-existing behaviour, kept so no
|
|
@@ -34770,14 +35400,14 @@ var PlateGalleryProvider = class {
|
|
|
34770
35400
|
...input?.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
|
|
34771
35401
|
...input?.limit !== void 0 ? { limit: input.limit } : {}
|
|
34772
35402
|
});
|
|
34773
|
-
const nameMap = await this.vehicleNameMap();
|
|
35403
|
+
const nameMap = await this.vehicleNameMap(rows);
|
|
34774
35404
|
const includeCrops = resolveIncludeCrops(input);
|
|
34775
35405
|
return Promise.all(rows.map((p) => this.toPlateInfo(p, nameMap, { includeCrops })));
|
|
34776
35406
|
}
|
|
34777
35407
|
async getPlateByTrack(input) {
|
|
34778
35408
|
const plate = await this.plateStore.get(`plate-${input.trackId}`);
|
|
34779
35409
|
if (!plate || plate.deviceId !== input.deviceId) return null;
|
|
34780
|
-
const nameMap = await this.vehicleNameMap();
|
|
35410
|
+
const nameMap = await this.vehicleNameMap([plate]);
|
|
34781
35411
|
return this.toPlateInfo(plate, nameMap, { includeCrops: false });
|
|
34782
35412
|
}
|
|
34783
35413
|
async getPlateMedia(input) {
|
|
@@ -34790,7 +35420,7 @@ var PlateGalleryProvider = class {
|
|
|
34790
35420
|
dist: plateDistance(input.text, p.text)
|
|
34791
35421
|
})).filter((x) => x.dist <= maxDistance).toSorted((a, b) => a.dist - b.dist || b.p.timestamp - a.p.timestamp);
|
|
34792
35422
|
const limited = input.limit !== void 0 ? scored.slice(0, input.limit) : scored;
|
|
34793
|
-
const nameMap = await this.vehicleNameMap();
|
|
35423
|
+
const nameMap = await this.vehicleNameMap(limited.map((x) => x.p));
|
|
34794
35424
|
const includeCrops = input.includeCrops ?? true;
|
|
34795
35425
|
return Promise.all(limited.map((x) => this.toPlateInfo(x.p, nameMap, { includeCrops })));
|
|
34796
35426
|
}
|
|
@@ -34865,8 +35495,7 @@ var PlateGalleryProvider = class {
|
|
|
34865
35495
|
await this.vehicleStore.renameVehicle(input.id, input.name);
|
|
34866
35496
|
}
|
|
34867
35497
|
async deleteVehicle(input) {
|
|
34868
|
-
const
|
|
34869
|
-
for (const p of plates) if (p.recognizedVehicleId === input.id) await this.unassignPlate({ plateId: p.id });
|
|
35498
|
+
for (const plateId of await this.plateStore.listPlateIdsForVehicle(input.id)) await this.unassignPlate({ plateId });
|
|
34870
35499
|
await this.mediaStore.deleteForOwner("vehicle", [input.id]);
|
|
34871
35500
|
await this.vehicleStore.deleteVehicle(input.id);
|
|
34872
35501
|
this.refreshGallery();
|
|
@@ -37042,10 +37671,34 @@ var AnalyticsQueryFacade = class {
|
|
|
37042
37671
|
}
|
|
37043
37672
|
/**
|
|
37044
37673
|
* Durable event-store footprint for the events-management UI: event ROWS
|
|
37045
|
-
* (motion + object + audio)
|
|
37046
|
-
*
|
|
37047
|
-
*
|
|
37048
|
-
*
|
|
37674
|
+
* (motion + object + audio) per camera + total, and event-owned media BYTES
|
|
37675
|
+
* on disk per camera + total. Cameras with neither rows nor bytes are
|
|
37676
|
+
* omitted; the rest come back ordered by bytes, descending, which is the
|
|
37677
|
+
* order the operator's table renders.
|
|
37678
|
+
*
|
|
37679
|
+
* ## The device set is a union of THREE sources
|
|
37680
|
+
*
|
|
37681
|
+
* It used to be `media ∪ tracks`, and a camera holding events but no media
|
|
37682
|
+
* row and no persisted track was therefore invisible — not listed, and its
|
|
37683
|
+
* events missing from `totalRows`. That combination is not exotic: media
|
|
37684
|
+
* retention, a media deletion and `pruneTracksBefore` all run on clocks of
|
|
37685
|
+
* their own, and every one of them can outlive the event rows. The number
|
|
37686
|
+
* this method exists to produce is the denominator for a deletion, so an
|
|
37687
|
+
* undercount is the one failure mode that matters, and it was silent.
|
|
37688
|
+
*
|
|
37689
|
+
* Events now contribute their own device set, so a camera is present if ANY
|
|
37690
|
+
* of the three sources has ever heard of it.
|
|
37691
|
+
*
|
|
37692
|
+
* ## The cost is the answer, not the table
|
|
37693
|
+
*
|
|
37694
|
+
* Three grouped reads for the event rows (one per event collection, never
|
|
37695
|
+
* three per camera), one grouped read plus one aggregate per camera for the
|
|
37696
|
+
* media bytes, one for the track device set. The retired shape paid ~1 250
|
|
37697
|
+
* round trips for the media half alone and timed out at 60 s on every call.
|
|
37698
|
+
* See {@link MediaStore.footprintByDevice} and {@link EventStore.countByDevice}.
|
|
37699
|
+
*
|
|
37700
|
+
* A store that cannot answer THROWS rather than returning zeroes — see the
|
|
37701
|
+
* same note on both.
|
|
37049
37702
|
*/
|
|
37050
37703
|
async getEventStoreFootprint() {
|
|
37051
37704
|
const eventStore = this.deps.eventStore();
|
|
@@ -37055,15 +37708,16 @@ var AnalyticsQueryFacade = class {
|
|
|
37055
37708
|
totalBytes: 0,
|
|
37056
37709
|
devices: []
|
|
37057
37710
|
};
|
|
37058
|
-
const mediaByDevice = await mediaStore.footprintByDevice();
|
|
37711
|
+
const [mediaByDevice, eventRowsByDevice] = await Promise.all([mediaStore.footprintByDevice(), eventStore.countByDevice()]);
|
|
37059
37712
|
const deviceIds = new Set(mediaByDevice.keys());
|
|
37713
|
+
for (const id of eventRowsByDevice.keys()) deviceIds.add(id);
|
|
37060
37714
|
const trackStore = this.deps.trackStore();
|
|
37061
37715
|
if (trackStore) for (const id of await trackStore.listDeviceIds()) deviceIds.add(id);
|
|
37062
37716
|
const devices = [];
|
|
37063
37717
|
let totalRows = 0;
|
|
37064
37718
|
let totalBytes = 0;
|
|
37065
37719
|
for (const deviceId of deviceIds) {
|
|
37066
|
-
const rows =
|
|
37720
|
+
const rows = eventRowsByDevice.get(deviceId) ?? 0;
|
|
37067
37721
|
const bytes = mediaByDevice.get(deviceId)?.bytes ?? 0;
|
|
37068
37722
|
if (rows === 0 && bytes === 0) continue;
|
|
37069
37723
|
totalRows += rows;
|
|
@@ -37082,6 +37736,26 @@ var AnalyticsQueryFacade = class {
|
|
|
37082
37736
|
};
|
|
37083
37737
|
}
|
|
37084
37738
|
/**
|
|
37739
|
+
* The media footprint by KIND — the axis that says what is safe to delete.
|
|
37740
|
+
* See {@link MediaStore.footprintByKind}.
|
|
37741
|
+
*
|
|
37742
|
+
* An absent media store is an empty breakdown, not a refusal: it is the same
|
|
37743
|
+
* "the addon has not finished booting" branch every other method here takes,
|
|
37744
|
+
* and the zeroes are accompanied by an empty `kinds` list, which no reader
|
|
37745
|
+
* can mistake for a measured "there is nothing".
|
|
37746
|
+
*/
|
|
37747
|
+
async getEventMediaFootprintByKind(input) {
|
|
37748
|
+
const mediaStore = this.deps.mediaStore();
|
|
37749
|
+
if (!mediaStore) return {
|
|
37750
|
+
totalRows: 0,
|
|
37751
|
+
totalBytes: 0,
|
|
37752
|
+
kinds: [],
|
|
37753
|
+
unaccountedRows: 0,
|
|
37754
|
+
unaccountedBytes: 0
|
|
37755
|
+
};
|
|
37756
|
+
return mediaStore.footprintByKind(input.deviceId);
|
|
37757
|
+
}
|
|
37758
|
+
/**
|
|
37085
37759
|
* Cluster-wide prune of events with `timestamp < olderThanMs` across every
|
|
37086
37760
|
* camera (via the device-agnostic `evictBefore`), deleting each pruned event's
|
|
37087
37761
|
* media in lockstep. Logs one global (`deviceId:null`) events ops-log row.
|
|
@@ -44715,6 +45389,45 @@ function pickWritableMediaLocation(locations, currentDefaultId) {
|
|
|
44715
45389
|
}
|
|
44716
45390
|
//#endregion
|
|
44717
45391
|
//#region src/pipeline-analytics/media-relocate-engine.ts
|
|
45392
|
+
/**
|
|
45393
|
+
* Event-media relocation engine (entity-routing spec, Phase 4) — the
|
|
45394
|
+
* analytics sibling of the recorder's footage mover. Each addon moves ONLY
|
|
45395
|
+
* what it owns (addons never touch each other's files).
|
|
45396
|
+
*
|
|
45397
|
+
* Per row: read blob from its CURRENT location → write to the target →
|
|
45398
|
+
* stamp `row.locationId` → delete the old blob. The row update lands between
|
|
45399
|
+
* the two blob operations, so a crash leaves the row pointing at a location
|
|
45400
|
+
* that still holds valid bytes (the old blob is only deleted after the row
|
|
45401
|
+
* points away from it). Rows already at the target are skipped — a re-run
|
|
45402
|
+
* RESUMES.
|
|
45403
|
+
*
|
|
45404
|
+
* Paging: timestamp-cursor over the media collection, with a seen-id set
|
|
45405
|
+
* bridging equal-timestamp boundaries. The PASS pages because it must visit
|
|
45406
|
+
* every row to act on it; that is not true of every question asked about this
|
|
45407
|
+
* collection, and `countUnstamped` no longer pages — see its docblock, and the
|
|
45408
|
+
* 60 s timeout that shape cost.
|
|
45409
|
+
*
|
|
45410
|
+
* (This header used to claim `WHERE locationId IS NULL` was inexpressible
|
|
45411
|
+
* through the store's filter. It was expressible, and that was the problem:
|
|
45412
|
+
* `where: { locationId: null }` compiled to `= NULL`, which matches nothing
|
|
45413
|
+
* and reports it as `0`. The filter layer now compiles it to `IS NULL`.)
|
|
45414
|
+
*
|
|
45415
|
+
* ── Three passes, one engine (see `MediaRelocateModeSchema`) ──────────────
|
|
45416
|
+
*
|
|
45417
|
+
* `move` is the historical pass. `seal` and `gallery` are the SAME walk with a
|
|
45418
|
+
* different row predicate and a different per-row action, deliberately not a
|
|
45419
|
+
* second mover: the walk, the cursor, the tie-bridging and the failure
|
|
45420
|
+
* accounting are the hard parts and they are already right here.
|
|
45421
|
+
*
|
|
45422
|
+
* - `seal` exists because a row with `locationId: NULL` means "wherever
|
|
45423
|
+
* `eventMedia` points NOW" (`media-store.ts:396`). A repoint moves that
|
|
45424
|
+
* pointer and every NULL row starts reading from the new disk while its
|
|
45425
|
+
* bytes are on the old one. `seal` is a ROW-ONLY pass — no blob is read,
|
|
45426
|
+
* written or deleted — that nails those rows to the location they are
|
|
45427
|
+
* actually on before anything repoints.
|
|
45428
|
+
* - `gallery` is the inverse selection of `move`: only the retention-exempt
|
|
45429
|
+
* enrolled-gallery rows, which `move` skips on purpose (D197).
|
|
45430
|
+
*/
|
|
44718
45431
|
var PAGE_SIZE = 200;
|
|
44719
45432
|
var DEFAULT_THROTTLE_MBPS = 40;
|
|
44720
45433
|
/** What a pass touches, reported on the job so an ops-log row says which of the
|
|
@@ -44750,6 +45463,68 @@ function snapshot(j) {
|
|
|
44750
45463
|
*/
|
|
44751
45464
|
var LOCATION_ID_FIELD = "locationId";
|
|
44752
45465
|
/**
|
|
45466
|
+
* Unstamped rows claimed per bulk seal statement.
|
|
45467
|
+
*
|
|
45468
|
+
* Bounds two things at once: the number of bound parameters in the `IN` list,
|
|
45469
|
+
* and how long one `UPDATE` holds the write lock on the database every process
|
|
45470
|
+
* in the cluster reads configuration through. Deliberately well under SQLite's
|
|
45471
|
+
* variable ceiling, and small enough that a cancel lands within one statement.
|
|
45472
|
+
*/
|
|
45473
|
+
var SEAL_BATCH_SIZE = 500;
|
|
45474
|
+
/**
|
|
45475
|
+
* Rows one CENSUS may visit before it answers `null`.
|
|
45476
|
+
*
|
|
45477
|
+
* A census is the read-only question behind `storageMigration.residue` — "how
|
|
45478
|
+
* much would a pass still act on". It is NOT the pass: a pass walks the whole
|
|
45479
|
+
* collection because it has to touch every row, and pays for that walk once,
|
|
45480
|
+
* under an operator's explicit start.
|
|
45481
|
+
*
|
|
45482
|
+
* 20 000 rows is 100 round trips at {@link PAGE_SIZE} — ~26 ms of engine time
|
|
45483
|
+
* on a healthy store, and a ceiling that cannot become the cluster's queue. A
|
|
45484
|
+
* collection larger than this is one a WALK cannot answer questions about; the
|
|
45485
|
+
* honest reply is "I could not answer" (D295), and the structural fix is an
|
|
45486
|
+
* indexed `COUNT(*)`, which needs `locationId` sealed first and is deliberately
|
|
45487
|
+
* not smuggled in behind a bound.
|
|
45488
|
+
*/
|
|
45489
|
+
var MEDIA_CENSUS_ROW_BUDGET = 2e4;
|
|
45490
|
+
/**
|
|
45491
|
+
* Wall clock one CENSUS may spend before it answers `null`.
|
|
45492
|
+
*
|
|
45493
|
+
* The row budget bounds a healthy store; this one bounds a sick store, where
|
|
45494
|
+
* 100 round trips at a 16 s queue depth is 27 minutes. Read through
|
|
45495
|
+
* `deps.now`, so it is deterministic under test and does not need a timer.
|
|
45496
|
+
*/
|
|
45497
|
+
var MEDIA_CENSUS_DEADLINE_MS = 2e4;
|
|
45498
|
+
/**
|
|
45499
|
+
* What one census may spend, and what it has spent.
|
|
45500
|
+
*
|
|
45501
|
+
* Explicit rather than folded into the `stop` callback: `stop` means "someone
|
|
45502
|
+
* asked me to stop" and the budget means "I am not allowed to continue", and a
|
|
45503
|
+
* pass passes `null` here because a pass is allowed to walk the whole
|
|
45504
|
+
* collection. Two concepts, two parameters — the walk that ran unbounded for
|
|
45505
|
+
* a day did so because ONE `stop: () => false` stood for both.
|
|
45506
|
+
*/
|
|
45507
|
+
var CensusBudget = class {
|
|
45508
|
+
now;
|
|
45509
|
+
maxRows;
|
|
45510
|
+
rows = 0;
|
|
45511
|
+
deadlineAt;
|
|
45512
|
+
constructor(now, maxRows, maxMs) {
|
|
45513
|
+
this.now = now;
|
|
45514
|
+
this.maxRows = maxRows;
|
|
45515
|
+
this.deadlineAt = now() + maxMs;
|
|
45516
|
+
}
|
|
45517
|
+
get rowsVisited() {
|
|
45518
|
+
return this.rows;
|
|
45519
|
+
}
|
|
45520
|
+
/** Visit one row. `true` means the budget is spent and the census must stop. */
|
|
45521
|
+
spend() {
|
|
45522
|
+
this.rows += 1;
|
|
45523
|
+
if (this.rows > this.maxRows) return true;
|
|
45524
|
+
return this.rows % PAGE_SIZE === 0 && this.now() >= this.deadlineAt;
|
|
45525
|
+
}
|
|
45526
|
+
};
|
|
45527
|
+
/**
|
|
44753
45528
|
* True when the row names NO location — the population `seal` exists for.
|
|
44754
45529
|
*
|
|
44755
45530
|
* Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
|
|
@@ -44806,9 +45581,26 @@ function selectsRow(mode, toLocationId, data, isRetrain) {
|
|
|
44806
45581
|
var MediaRelocateEngine = class {
|
|
44807
45582
|
deps;
|
|
44808
45583
|
jobs = /* @__PURE__ */ new Map();
|
|
45584
|
+
/** Censuses in flight, keyed by the QUESTION — see {@link countSelectable}. */
|
|
45585
|
+
censuses = /* @__PURE__ */ new Map();
|
|
44809
45586
|
constructor(deps) {
|
|
44810
45587
|
this.deps = deps;
|
|
44811
45588
|
}
|
|
45589
|
+
/**
|
|
45590
|
+
* Retire every census in flight. They answer `null`, and stop reading.
|
|
45591
|
+
*
|
|
45592
|
+
* @returns how many were retired.
|
|
45593
|
+
*/
|
|
45594
|
+
cancelCensuses(reason) {
|
|
45595
|
+
const retired = this.censuses.size;
|
|
45596
|
+
if (retired === 0) return 0;
|
|
45597
|
+
for (const run of this.censuses.values()) run.abort();
|
|
45598
|
+
this.deps.logger.info("media relocate: standing censuses retired", { meta: {
|
|
45599
|
+
retired,
|
|
45600
|
+
reason
|
|
45601
|
+
} });
|
|
45602
|
+
return retired;
|
|
45603
|
+
}
|
|
44812
45604
|
list() {
|
|
44813
45605
|
return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
|
|
44814
45606
|
}
|
|
@@ -44907,6 +45699,7 @@ var MediaRelocateEngine = class {
|
|
|
44907
45699
|
}
|
|
44908
45700
|
start(input) {
|
|
44909
45701
|
for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
|
|
45702
|
+
this.cancelCensuses("a relocate pass started");
|
|
44910
45703
|
const job = {
|
|
44911
45704
|
jobId: this.deps.newId(),
|
|
44912
45705
|
state: "running",
|
|
@@ -44977,15 +45770,66 @@ var MediaRelocateEngine = class {
|
|
|
44977
45770
|
* must not be allowed to make (D295).
|
|
44978
45771
|
*/
|
|
44979
45772
|
async countSelectable(input) {
|
|
44980
|
-
|
|
45773
|
+
const mode = input.mode ?? "move";
|
|
45774
|
+
const key = `${mode}|${input.toLocationId}`;
|
|
45775
|
+
const running = this.censuses.get(key);
|
|
45776
|
+
if (running !== void 0) return running.promise;
|
|
45777
|
+
let aborted = false;
|
|
45778
|
+
const budget = new CensusBudget(this.deps.now, MEDIA_CENSUS_ROW_BUDGET, MEDIA_CENSUS_DEADLINE_MS);
|
|
45779
|
+
const promise = this.countSelected(mode, input.toLocationId, () => aborted, budget).finally(() => {
|
|
45780
|
+
this.censuses.delete(key);
|
|
45781
|
+
});
|
|
45782
|
+
this.censuses.set(key, {
|
|
45783
|
+
promise,
|
|
45784
|
+
abort: () => {
|
|
45785
|
+
aborted = true;
|
|
45786
|
+
}
|
|
45787
|
+
});
|
|
45788
|
+
return promise;
|
|
44981
45789
|
}
|
|
44982
|
-
|
|
45790
|
+
/**
|
|
45791
|
+
* @param stop someone asked this walk to stop (an operator cancel, or
|
|
45792
|
+
* {@link cancelCensuses}). Answers `null`.
|
|
45793
|
+
* @param budget what this walk is allowed to spend, or `null` for a PASS —
|
|
45794
|
+
* a pass walks the whole collection because it must touch every row, and
|
|
45795
|
+
* an operator started it explicitly.
|
|
45796
|
+
*/
|
|
45797
|
+
async countSelected(mode, toLocationId, stop, budget) {
|
|
44983
45798
|
try {
|
|
45799
|
+
if (mode === "seal") {
|
|
45800
|
+
let sealable = 0;
|
|
45801
|
+
for (const collection of collectionsFor(mode)) {
|
|
45802
|
+
if (stop()) return null;
|
|
45803
|
+
sealable += await this.deps.store.count.query({
|
|
45804
|
+
collection: collection.name,
|
|
45805
|
+
filter: { where: { [LOCATION_ID_FIELD]: null } }
|
|
45806
|
+
});
|
|
45807
|
+
}
|
|
45808
|
+
return { rows: sealable };
|
|
45809
|
+
}
|
|
44984
45810
|
let rows = 0;
|
|
44985
45811
|
for (const collection of collectionsFor(mode)) {
|
|
44986
45812
|
const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
|
|
44987
45813
|
for await (const row of this.pageRows(collection.name, collection.order)) {
|
|
44988
|
-
if (stop())
|
|
45814
|
+
if (stop()) {
|
|
45815
|
+
this.deps.logger.warn("media relocate: census stopped before it finished — reporting the residue as unknown, not as empty", { meta: {
|
|
45816
|
+
mode,
|
|
45817
|
+
toLocationId,
|
|
45818
|
+
collection: collection.name
|
|
45819
|
+
} });
|
|
45820
|
+
return null;
|
|
45821
|
+
}
|
|
45822
|
+
if (budget !== null && budget.spend()) {
|
|
45823
|
+
this.deps.logger.warn("media relocate: census exceeded its budget — this collection is larger than a walk can answer", { meta: {
|
|
45824
|
+
mode,
|
|
45825
|
+
toLocationId,
|
|
45826
|
+
collection: collection.name,
|
|
45827
|
+
rowsVisited: budget.rowsVisited,
|
|
45828
|
+
rowBudget: MEDIA_CENSUS_ROW_BUDGET,
|
|
45829
|
+
deadlineMs: MEDIA_CENSUS_DEADLINE_MS
|
|
45830
|
+
} });
|
|
45831
|
+
return null;
|
|
45832
|
+
}
|
|
44989
45833
|
if (selectsRow(mode, toLocationId, row.data, isRetrain)) rows++;
|
|
44990
45834
|
}
|
|
44991
45835
|
}
|
|
@@ -45011,7 +45855,15 @@ var MediaRelocateEngine = class {
|
|
|
45011
45855
|
* rows in silence reads as "there were none", which is how a migration
|
|
45012
45856
|
* gets believed. */
|
|
45013
45857
|
let pathlessRows = 0;
|
|
45014
|
-
job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested))?.rows ?? null;
|
|
45858
|
+
job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested, null))?.rows ?? null;
|
|
45859
|
+
if (job.mode === "seal") {
|
|
45860
|
+
for (const collection of collectionsFor(job.mode)) {
|
|
45861
|
+
if (job.cancelRequested) break;
|
|
45862
|
+
await this.sealCollection(collection.name, job);
|
|
45863
|
+
}
|
|
45864
|
+
job.state = job.cancelRequested ? "cancelled" : "done";
|
|
45865
|
+
return;
|
|
45866
|
+
}
|
|
45015
45867
|
for (const collection of collectionsFor(job.mode)) {
|
|
45016
45868
|
if (job.cancelRequested) break;
|
|
45017
45869
|
const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
|
|
@@ -45019,20 +45871,6 @@ var MediaRelocateEngine = class {
|
|
|
45019
45871
|
if (job.cancelRequested) break;
|
|
45020
45872
|
const data = row.data;
|
|
45021
45873
|
if (!selectsRow(job.mode, job.toLocationId, data, isRetrain)) continue;
|
|
45022
|
-
if (job.mode === "seal") {
|
|
45023
|
-
try {
|
|
45024
|
-
await this.stamp(collection.name, row.id, data, job.toLocationId);
|
|
45025
|
-
job.filesMoved++;
|
|
45026
|
-
} catch (err) {
|
|
45027
|
-
failedRows++;
|
|
45028
|
-
this.deps.logger.debug("event-media seal row failed", { meta: {
|
|
45029
|
-
collection: collection.name,
|
|
45030
|
-
key: row.id,
|
|
45031
|
-
error: String(err)
|
|
45032
|
-
} });
|
|
45033
|
-
}
|
|
45034
|
-
continue;
|
|
45035
|
-
}
|
|
45036
45874
|
const from = mediaRowLocation(data);
|
|
45037
45875
|
const relativePath = String(data["path"] ?? "");
|
|
45038
45876
|
if (relativePath.length === 0) {
|
|
@@ -45095,6 +45933,73 @@ var MediaRelocateEngine = class {
|
|
|
45095
45933
|
this.deps.onFinished?.(snapshot(job));
|
|
45096
45934
|
}
|
|
45097
45935
|
}
|
|
45936
|
+
/**
|
|
45937
|
+
* Stamp every unstamped row of one collection with the job's target, in
|
|
45938
|
+
* bounded bulk statements.
|
|
45939
|
+
*
|
|
45940
|
+
* ## What this replaced
|
|
45941
|
+
*
|
|
45942
|
+
* A per-row loop: page the whole collection through {@link pageRows}, then
|
|
45943
|
+
* one `store.set.mutate` per selected row — a full-row UPSERT rewriting every
|
|
45944
|
+
* column to change one field, one RPC each, plus a second full walk before it
|
|
45945
|
+
* to produce the denominator. Measured on the live hub against the 464 902
|
|
45946
|
+
* unstamped rows: **2.3 rows/s, falling to 0.7 rows/s under a competing
|
|
45947
|
+
* drain**, i.e. over two and a half days for a metadata stamp that reads no
|
|
45948
|
+
* file and moves no byte. It was cancelled. This is the write-side twin of
|
|
45949
|
+
* the read-side walk {@link countUnstamped} retired, and the fourth instance
|
|
45950
|
+
* of the same pattern found in one day — work looped over the door instead of
|
|
45951
|
+
* pushed into the statement.
|
|
45952
|
+
*
|
|
45953
|
+
* ## The shape
|
|
45954
|
+
*
|
|
45955
|
+
* Probe the indexed `IS NULL` range for a batch of ids, then ONE
|
|
45956
|
+
* `updateWhere` over those ids. Two round trips per {@link SEAL_BATCH_SIZE}
|
|
45957
|
+
* rows instead of one write per row.
|
|
45958
|
+
*
|
|
45959
|
+
* The probe carries no cursor and needs none: the predicate CONSUMES itself.
|
|
45960
|
+
* A stamped row leaves the range, so the next probe returns the next batch,
|
|
45961
|
+
* and a run that is interrupted anywhere simply resumes on what is left. That
|
|
45962
|
+
* is also why the seal is idempotent (a second run probes an empty range and
|
|
45963
|
+
* stamps nothing) and why it cannot repoint a row that already names a
|
|
45964
|
+
* different location — `IS NULL` never selects one.
|
|
45965
|
+
*
|
|
45966
|
+
* A single unbounded `UPDATE … WHERE locationId IS NULL` would be one
|
|
45967
|
+
* statement instead of ~1 860, and is not used: half a million row rewrites,
|
|
45968
|
+
* each maintaining four indexes, is an unbounded statement holding the write
|
|
45969
|
+
* lock on a database the whole cluster reads configuration through, under an
|
|
45970
|
+
* RPC deadline that must not be raised. Batching bounds the statement, keeps
|
|
45971
|
+
* `cancel` responsive between batches, and keeps `filesMoved` honest.
|
|
45972
|
+
*
|
|
45973
|
+
* ## The termination guard
|
|
45974
|
+
*
|
|
45975
|
+
* If the probe finds rows and the update reports **0**, this THROWS. That is
|
|
45976
|
+
* not defensive noise: it is precisely what a backend compiling
|
|
45977
|
+
* `locationId = NULL` does — matches nothing, reports success (D312) — and
|
|
45978
|
+
* against it a self-consuming loop never terminates and a non-looping one
|
|
45979
|
+
* reports a completed seal having stamped nothing. Zero unstamped rows is the
|
|
45980
|
+
* single value the migration gate reads as "safe to cut over".
|
|
45981
|
+
*/
|
|
45982
|
+
async sealCollection(collection, job) {
|
|
45983
|
+
for (;;) {
|
|
45984
|
+
if (job.cancelRequested) return;
|
|
45985
|
+
const batch = await this.deps.store.query.query({
|
|
45986
|
+
collection,
|
|
45987
|
+
filter: {
|
|
45988
|
+
where: { [LOCATION_ID_FIELD]: null },
|
|
45989
|
+
limit: SEAL_BATCH_SIZE
|
|
45990
|
+
},
|
|
45991
|
+
columns: [LOCATION_ID_FIELD]
|
|
45992
|
+
});
|
|
45993
|
+
if (batch.length === 0) return;
|
|
45994
|
+
const { updated } = await this.deps.store.updateWhere.mutate({
|
|
45995
|
+
collection,
|
|
45996
|
+
filter: { whereIn: { id: batch.map((row) => row.id) } },
|
|
45997
|
+
data: { [LOCATION_ID_FIELD]: job.toLocationId }
|
|
45998
|
+
});
|
|
45999
|
+
if (updated === 0) throw new Error(`event-media seal stamped 0 of ${batch.length} unstamped row(s) in "${collection}" — the bulk predicate matched nothing, so the population cannot be sealed`);
|
|
46000
|
+
job.filesMoved += updated;
|
|
46001
|
+
}
|
|
46002
|
+
}
|
|
45098
46003
|
async stamp(collection, key, data, locationId) {
|
|
45099
46004
|
await this.deps.store.set.mutate({
|
|
45100
46005
|
collection,
|
|
@@ -51826,6 +52731,43 @@ var EventStore = class {
|
|
|
51826
52731
|
return motion + object + audio;
|
|
51827
52732
|
}
|
|
51828
52733
|
/**
|
|
52734
|
+
* Persisted event rows (motion + object + audio) for EVERY device that has
|
|
52735
|
+
* any — three round trips for the whole fleet.
|
|
52736
|
+
*
|
|
52737
|
+
* {@link countForDevice} answers one camera in three `COUNT(*)`s, which is
|
|
52738
|
+
* right for one camera and wrong in a loop: the events-management footprint
|
|
52739
|
+
* called it once per device, so the cost of "how many events does each camera
|
|
52740
|
+
* hold" grew with the camera count for a result that is a few dozen integers.
|
|
52741
|
+
*
|
|
52742
|
+
* A histogram on `deviceId` with `bucketSize: 1` is `GROUP BY deviceId`, and
|
|
52743
|
+
* every event collection already carries `idx_<kind>_device_ts`, which leads
|
|
52744
|
+
* with `deviceId` — so the planner serves each of these from a covering index
|
|
52745
|
+
* with no table access at all. `bucket` IS the deviceId (the column is
|
|
52746
|
+
* INTEGER, the bucket expression is `CAST((deviceId - 0) / 1 AS INTEGER)`).
|
|
52747
|
+
*
|
|
52748
|
+
* Not best-effort: a failure propagates. The one caller is sizing a deletion,
|
|
52749
|
+
* and a camera silently reported as holding zero events is the answer that
|
|
52750
|
+
* must never come from a read that did not happen.
|
|
52751
|
+
*/
|
|
52752
|
+
async countByDevice() {
|
|
52753
|
+
const perCollection = await Promise.all([
|
|
52754
|
+
MOTION_EVENTS_COLLECTION,
|
|
52755
|
+
OBJECT_EVENTS_COLLECTION,
|
|
52756
|
+
AUDIO_EVENTS_COLLECTION
|
|
52757
|
+
].map((collection) => this.store.histogram.query({
|
|
52758
|
+
collection,
|
|
52759
|
+
field: "deviceId",
|
|
52760
|
+
bucketSize: 1,
|
|
52761
|
+
origin: 0
|
|
52762
|
+
})));
|
|
52763
|
+
const out = /* @__PURE__ */ new Map();
|
|
52764
|
+
for (const buckets of perCollection) for (const bucket of buckets) {
|
|
52765
|
+
if (bucket.count === 0 || !Number.isFinite(bucket.bucket)) continue;
|
|
52766
|
+
out.set(bucket.bucket, (out.get(bucket.bucket) ?? 0) + bucket.count);
|
|
52767
|
+
}
|
|
52768
|
+
return out;
|
|
52769
|
+
}
|
|
52770
|
+
/**
|
|
51829
52771
|
* Return per-kind event counts in equal-width time buckets.
|
|
51830
52772
|
*
|
|
51831
52773
|
* Each bucket maps to a `bucketStart = since + i * bucketMs`.
|
|
@@ -52442,6 +53384,50 @@ var IdentityStore = class {
|
|
|
52442
53384
|
key: id
|
|
52443
53385
|
});
|
|
52444
53386
|
}
|
|
53387
|
+
/**
|
|
53388
|
+
* `identityId → name` for exactly the identities asked about.
|
|
53389
|
+
*
|
|
53390
|
+
* The decorating half of a face or plate page needs a NAME per referenced
|
|
53391
|
+
* identity, and it used to get it by reading the whole `identities`
|
|
53392
|
+
* collection — unbounded, unprojected, on every 30 s poll of
|
|
53393
|
+
* `faceGallery.listRecentFaces`, and again on every `getFaceByTrack`. The
|
|
53394
|
+
* table is small (4 rows on the live hub on 2026-08-31) so the bytes were
|
|
53395
|
+
* never the problem; the SHAPE was. It is a second question whose cost is
|
|
53396
|
+
* proportional to a collection, asked on a timer, to answer a question about
|
|
53397
|
+
* a page — and it carried no `limit`, so past
|
|
53398
|
+
* `SETTINGS_QUERY_DEFAULT_ROW_CAP` it would have handed back a name map
|
|
53399
|
+
* missing entries, shape-identical to a complete one (D320).
|
|
53400
|
+
*
|
|
53401
|
+
* **An empty request costs no round trip at all.** With
|
|
53402
|
+
* `filter: 'unassigned'` — the view the operator lives in — a page
|
|
53403
|
+
* references no identity, and the honest number of queries for that is zero.
|
|
53404
|
+
* `whereIn` with an empty list matches nothing, so asking anyway would be an
|
|
53405
|
+
* RPC to be told what the caller already knew.
|
|
53406
|
+
*
|
|
53407
|
+
* A cache belongs in the owner of the fact, and this store IS the owner —
|
|
53408
|
+
* but a name here changes only when the operator renames, through this same
|
|
53409
|
+
* store, and the read is now one indexed `IN` over at most a page's worth of
|
|
53410
|
+
* ids. There is nothing left to cache that would not be a second authority
|
|
53411
|
+
* over a fact one round trip away (D224).
|
|
53412
|
+
*/
|
|
53413
|
+
async namesFor(identityIds) {
|
|
53414
|
+
const ids = [...new Set(identityIds)];
|
|
53415
|
+
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
|
53416
|
+
const rows = await this.store.query.query({
|
|
53417
|
+
collection: IDENTITIES_COLLECTION,
|
|
53418
|
+
columns: ["name"],
|
|
53419
|
+
filter: {
|
|
53420
|
+
whereIn: { id: ids },
|
|
53421
|
+
limit: ids.length
|
|
53422
|
+
}
|
|
53423
|
+
});
|
|
53424
|
+
const names = /* @__PURE__ */ new Map();
|
|
53425
|
+
for (const r of rows) {
|
|
53426
|
+
const name = r.data["name"];
|
|
53427
|
+
if (typeof name === "string") names.set(r.id, name);
|
|
53428
|
+
}
|
|
53429
|
+
return names;
|
|
53430
|
+
}
|
|
52445
53431
|
async listIdentities() {
|
|
52446
53432
|
return (await this.store.query.query({
|
|
52447
53433
|
collection: IDENTITIES_COLLECTION,
|
|
@@ -52983,13 +53969,55 @@ var PLATE_COLUMNS = [
|
|
|
52983
53969
|
type: "JSON"
|
|
52984
53970
|
}
|
|
52985
53971
|
];
|
|
52986
|
-
|
|
52987
|
-
|
|
52988
|
-
|
|
52989
|
-
|
|
52990
|
-
|
|
52991
|
-
|
|
52992
|
-
|
|
53972
|
+
/**
|
|
53973
|
+
* What the gallery orders by, and what the vehicle-scoped read filters on.
|
|
53974
|
+
*
|
|
53975
|
+
* The face twin's `EXPLAIN QUERY PLAN` measurement applies verbatim: without an
|
|
53976
|
+
* index on `timestamp`, `ORDER BY timestamp DESC LIMIT 500` is
|
|
53977
|
+
* `SCAN + USE TEMP B-TREE FOR ORDER BY` — the read was already BOUNDED at 200,
|
|
53978
|
+
* and still sorted the whole table to produce the newest N. A bound caps what
|
|
53979
|
+
* comes back; only an ordered index caps what is read.
|
|
53980
|
+
*/
|
|
53981
|
+
var PLATE_INDEXES = [
|
|
53982
|
+
{
|
|
53983
|
+
name: "idx_plates_device_ts",
|
|
53984
|
+
columns: ["deviceId", "timestamp"]
|
|
53985
|
+
},
|
|
53986
|
+
{
|
|
53987
|
+
name: "idx_plates_track",
|
|
53988
|
+
columns: ["trackId"]
|
|
53989
|
+
},
|
|
53990
|
+
{
|
|
53991
|
+
name: "idx_plates_ts",
|
|
53992
|
+
columns: ["timestamp"]
|
|
53993
|
+
},
|
|
53994
|
+
{
|
|
53995
|
+
name: "idx_plates_vehicle_ts",
|
|
53996
|
+
columns: ["recognizedVehicleId", "timestamp"]
|
|
53997
|
+
}
|
|
53998
|
+
];
|
|
53999
|
+
/**
|
|
54000
|
+
* The widest plate window a single read may materialise before it REFUSES.
|
|
54001
|
+
*
|
|
54002
|
+
* The corpus reads (`searchPlates`, `suggestPlateClusters`) are genuinely
|
|
54003
|
+
* corpus-shaped — fuzzy text distance is not a predicate — so they cannot be
|
|
54004
|
+
* narrowed the way the gallery list was. What they CAN do is stop lying: they
|
|
54005
|
+
* carried no `limit` at all, so the engine imposed
|
|
54006
|
+
* `SETTINGS_QUERY_DEFAULT_ROW_CAP` and handed back the first 2 000 rows with a
|
|
54007
|
+
* WARN no caller can see. A search over the newest 2 000 of 50 000 plates and a
|
|
54008
|
+
* search over all of them are the same array (D320 R3).
|
|
54009
|
+
*
|
|
54010
|
+
* 10 000 mirrors the faces ceiling and stays under the engine's hard cap so the
|
|
54011
|
+
* engine never truncates first.
|
|
54012
|
+
*/
|
|
54013
|
+
var PLATE_WINDOW_MAX_ROWS = 1e4;
|
|
54014
|
+
/** A corpus read whose window is wider than {@link PLATE_WINDOW_MAX_ROWS}. */
|
|
54015
|
+
var PlateWindowTooWideError = class extends Error {
|
|
54016
|
+
constructor(rows) {
|
|
54017
|
+
super(`plate window exceeds ${String(PLATE_WINDOW_MAX_ROWS)} rows (read ${String(rows)}) — refusing to answer with the part that fit`);
|
|
54018
|
+
this.name = "PlateWindowTooWideError";
|
|
54019
|
+
}
|
|
54020
|
+
};
|
|
52993
54021
|
var PlateStore = class {
|
|
52994
54022
|
store;
|
|
52995
54023
|
logger;
|
|
@@ -53054,18 +54082,53 @@ var PlateStore = class {
|
|
|
53054
54082
|
}
|
|
53055
54083
|
})).map((r) => this.normalizeRow(r));
|
|
53056
54084
|
}
|
|
53057
|
-
/**
|
|
54085
|
+
/**
|
|
54086
|
+
* List ALL recent plate reads across devices, newest first.
|
|
54087
|
+
*
|
|
54088
|
+
* The corpus read, for the two passes whose question is not a predicate
|
|
54089
|
+
* (fuzzy text search, text clustering). It carries an explicit `limit` and
|
|
54090
|
+
* REFUSES past it rather than handing back the part that fit: without one the
|
|
54091
|
+
* engine imposed its own cap and truncated silently, and a search over the
|
|
54092
|
+
* newest 2 000 plates looks exactly like a search over all of them.
|
|
54093
|
+
*/
|
|
53058
54094
|
async listAllRecentPlates(input) {
|
|
53059
|
-
|
|
54095
|
+
const bounded = input.limit !== void 0;
|
|
54096
|
+
const rows = await this.store.query.query({
|
|
53060
54097
|
collection: PLATES_COLLECTION,
|
|
53061
54098
|
filter: {
|
|
53062
54099
|
orderBy: {
|
|
53063
54100
|
field: "timestamp",
|
|
53064
54101
|
direction: "desc"
|
|
53065
54102
|
},
|
|
53066
|
-
|
|
54103
|
+
limit: bounded ? input.limit ?? 0 : 10001
|
|
53067
54104
|
}
|
|
53068
|
-
})
|
|
54105
|
+
});
|
|
54106
|
+
if (!bounded && rows.length > 1e4) throw new PlateWindowTooWideError(rows.length);
|
|
54107
|
+
return rows.map((r) => this.normalizeRow(r));
|
|
54108
|
+
}
|
|
54109
|
+
/**
|
|
54110
|
+
* Every plate row currently attributed to a vehicle, id only.
|
|
54111
|
+
*
|
|
54112
|
+
* `deleteVehicle` detaches these before the vehicle goes. It used to list the
|
|
54113
|
+
* WHOLE buffer and compare in JS — the `deleteIdentity` defect verbatim, and
|
|
54114
|
+
* past the engine's row cap it would detach the plates that fit and leave the
|
|
54115
|
+
* rest pointing at a vehicle that no longer exists.
|
|
54116
|
+
*/
|
|
54117
|
+
async listPlateIdsForVehicle(vehicleId) {
|
|
54118
|
+
const rows = await this.store.query.query({
|
|
54119
|
+
collection: PLATES_COLLECTION,
|
|
54120
|
+
columns: ["deviceId"],
|
|
54121
|
+
filter: {
|
|
54122
|
+
where: { recognizedVehicleId: vehicleId },
|
|
54123
|
+
orderBy: {
|
|
54124
|
+
field: "timestamp",
|
|
54125
|
+
direction: "desc"
|
|
54126
|
+
},
|
|
54127
|
+
limit: 10001
|
|
54128
|
+
}
|
|
54129
|
+
});
|
|
54130
|
+
if (rows.length > 1e4) throw new PlateWindowTooWideError(rows.length);
|
|
54131
|
+
return rows.map((r) => r.id);
|
|
53069
54132
|
}
|
|
53070
54133
|
async get(plateId) {
|
|
53071
54134
|
const raw = await this.store.get.query({
|
|
@@ -55725,6 +56788,38 @@ var VehicleStore = class {
|
|
|
55725
56788
|
key: id
|
|
55726
56789
|
});
|
|
55727
56790
|
}
|
|
56791
|
+
/**
|
|
56792
|
+
* `vehicleId → name` for exactly the vehicles asked about — the plate twin of
|
|
56793
|
+
* `IdentityStore.namesFor`, and it exists for the same reason.
|
|
56794
|
+
*
|
|
56795
|
+
* The decorating half of a plate page needs a NAME per referenced vehicle,
|
|
56796
|
+
* and it got it by reading the whole `vehicles` collection: unbounded,
|
|
56797
|
+
* unprojected, on every 30 s poll of `plateGallery.listPlates` and again on
|
|
56798
|
+
* every `getPlateByTrack` and `searchPlates`. The live table holds **0 rows**
|
|
56799
|
+
* — the bytes were never the point. The shape was: a question proportional to
|
|
56800
|
+
* a collection, on a timer, to decorate a page.
|
|
56801
|
+
*
|
|
56802
|
+
* An empty request costs no round trip: `whereIn` over an empty list matches
|
|
56803
|
+
* nothing, so asking would be an RPC to be told what the caller knew.
|
|
56804
|
+
*/
|
|
56805
|
+
async namesFor(vehicleIds) {
|
|
56806
|
+
const ids = [...new Set(vehicleIds)];
|
|
56807
|
+
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
|
56808
|
+
const rows = await this.store.query.query({
|
|
56809
|
+
collection: VEHICLES_COLLECTION,
|
|
56810
|
+
columns: ["name"],
|
|
56811
|
+
filter: {
|
|
56812
|
+
whereIn: { id: ids },
|
|
56813
|
+
limit: ids.length
|
|
56814
|
+
}
|
|
56815
|
+
});
|
|
56816
|
+
const names = /* @__PURE__ */ new Map();
|
|
56817
|
+
for (const r of rows) {
|
|
56818
|
+
const name = r.data["name"];
|
|
56819
|
+
if (typeof name === "string") names.set(r.id, name);
|
|
56820
|
+
}
|
|
56821
|
+
return names;
|
|
56822
|
+
}
|
|
55728
56823
|
async listVehicles() {
|
|
55729
56824
|
return (await this.store.query.query({
|
|
55730
56825
|
collection: VEHICLES_COLLECTION,
|
|
@@ -62696,6 +63791,50 @@ var ZoneAnalyticsProvider = class {
|
|
|
62696
63791
|
return this.snapshots.get(deviceId) ?? await this.hydrateFromRegistry(deviceId);
|
|
62697
63792
|
}
|
|
62698
63793
|
/**
|
|
63794
|
+
* The same snapshot, for a SET of cameras, in one round trip.
|
|
63795
|
+
*
|
|
63796
|
+
* The Events page's Stationary section polls this every 15 s for every
|
|
63797
|
+
* selected camera. Fanned out client-side that is one browser → hub →
|
|
63798
|
+
* post-analysis round trip per camera to read a `Map.get`; the answer costs
|
|
63799
|
+
* nothing and the transport costs everything. The per-device work here is
|
|
63800
|
+
* unchanged — the same live snapshot, the same parked-registry hydration when
|
|
63801
|
+
* there is no live one — and it runs concurrently inside the owner.
|
|
63802
|
+
*
|
|
63803
|
+
* **Three outcomes stay three.** A camera with a reading, a camera with none
|
|
63804
|
+
* yet (`snapshot: null`, `read: 'read'`), and a camera the owner could not
|
|
63805
|
+
* answer for (`read: 'unreadable'`). The hydration reaches a durable store
|
|
63806
|
+
* and can throw; letting that throw become `snapshot: null` would render an
|
|
63807
|
+
* unread camera as a camera with nothing parked, which is a definite claim
|
|
63808
|
+
* nobody made.
|
|
63809
|
+
*/
|
|
63810
|
+
async getCurrentSnapshotBatch({ deviceIds }) {
|
|
63811
|
+
return Promise.all(deviceIds.map(async (deviceId) => {
|
|
63812
|
+
const live = this.snapshots.get(deviceId);
|
|
63813
|
+
if (live !== void 0) return {
|
|
63814
|
+
deviceId,
|
|
63815
|
+
read: "read",
|
|
63816
|
+
snapshot: live
|
|
63817
|
+
};
|
|
63818
|
+
try {
|
|
63819
|
+
return {
|
|
63820
|
+
deviceId,
|
|
63821
|
+
read: "read",
|
|
63822
|
+
snapshot: await this.hydrateFromRegistry(deviceId)
|
|
63823
|
+
};
|
|
63824
|
+
} catch (err) {
|
|
63825
|
+
this.ctx.logger.warn("occupancy snapshot could not be read", {
|
|
63826
|
+
tags: { deviceId },
|
|
63827
|
+
meta: { error: String(err) }
|
|
63828
|
+
});
|
|
63829
|
+
return {
|
|
63830
|
+
deviceId,
|
|
63831
|
+
read: "unreadable",
|
|
63832
|
+
snapshot: null
|
|
63833
|
+
};
|
|
63834
|
+
}
|
|
63835
|
+
}));
|
|
63836
|
+
}
|
|
63837
|
+
/**
|
|
62699
63838
|
* The cameras a LIVE snapshot exists for.
|
|
62700
63839
|
*
|
|
62701
63840
|
* The Notification Center's occupancy dry-run ("as of now, would this rule
|
|
@@ -65254,6 +66393,23 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
65254
66393
|
},
|
|
65255
66394
|
resolveArtifactUrl: async (artifactId) => await this.ncArtifactPlane?.mintUrl(artifactId) ?? null,
|
|
65256
66395
|
mintActionUrl: (input) => this.ncActionMintUrl?.(input) ?? Promise.resolve(null),
|
|
66396
|
+
listAddonUpdates: async (nodeId) => {
|
|
66397
|
+
return (await this.ctx.api.addons.listUpdates.query({ nodeId })).filter((update) => !update.isSystem).map((update) => update.name);
|
|
66398
|
+
},
|
|
66399
|
+
applyAddonUpdate: async ({ packageName, nodeId }) => {
|
|
66400
|
+
try {
|
|
66401
|
+
await this.ctx.api.addons.updatePackage.mutate({
|
|
66402
|
+
name: packageName,
|
|
66403
|
+
nodeId
|
|
66404
|
+
});
|
|
66405
|
+
return { ok: true };
|
|
66406
|
+
} catch (err) {
|
|
66407
|
+
return {
|
|
66408
|
+
ok: false,
|
|
66409
|
+
error: require_dist.errMsg(err)
|
|
66410
|
+
};
|
|
66411
|
+
}
|
|
66412
|
+
},
|
|
65257
66413
|
readDeviceStates: async (ids) => {
|
|
65258
66414
|
const out = /* @__PURE__ */ new Map();
|
|
65259
66415
|
for (const id of ids) try {
|
|
@@ -70213,6 +71369,36 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
70213
71369
|
return [];
|
|
70214
71370
|
}
|
|
70215
71371
|
}
|
|
71372
|
+
/**
|
|
71373
|
+
* `getKeyEvents` for a SET of cameras, in one round trip.
|
|
71374
|
+
*
|
|
71375
|
+
* The Detection Intelligence events feed asked this of every selected camera
|
|
71376
|
+
* on a 30 s timer — N browser → hub → post-analysis round trips for N
|
|
71377
|
+
* independent, already-indexed store queries. The queries are unchanged and
|
|
71378
|
+
* still run per camera (each is `where deviceId` + a `firstSeen` range
|
|
71379
|
+
* against its own index, and `limit` stays PER CAMERA so a busy camera cannot
|
|
71380
|
+
* starve a quiet one of its rows); what collapses is the transport.
|
|
71381
|
+
*
|
|
71382
|
+
* Every requested id gets a row. `getKeyEvents` already degrades to `[]`
|
|
71383
|
+
* rather than throwing, so a per-camera failure is reported exactly as it was
|
|
71384
|
+
* before this method existed — this does not make it worse, and the row
|
|
71385
|
+
* carries the `deviceId` the single method's output never did.
|
|
71386
|
+
*/
|
|
71387
|
+
async getKeyEventsBatch(input) {
|
|
71388
|
+
return Promise.all(input.deviceIds.map(async (deviceId) => {
|
|
71389
|
+
return {
|
|
71390
|
+
deviceId,
|
|
71391
|
+
events: await this.getKeyEvents({
|
|
71392
|
+
deviceId,
|
|
71393
|
+
since: input.since,
|
|
71394
|
+
until: input.until,
|
|
71395
|
+
...input.limit !== void 0 ? { limit: input.limit } : {},
|
|
71396
|
+
...input.minImportance !== void 0 ? { minImportance: input.minImportance } : {},
|
|
71397
|
+
...input.classFilter !== void 0 ? { classFilter: input.classFilter } : {}
|
|
71398
|
+
})
|
|
71399
|
+
};
|
|
71400
|
+
}));
|
|
71401
|
+
}
|
|
70216
71402
|
async getAudioEvents(input) {
|
|
70217
71403
|
return this.queryFacade.getAudioEvents(input);
|
|
70218
71404
|
}
|
|
@@ -71549,6 +72735,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
71549
72735
|
async getEventStoreFootprint() {
|
|
71550
72736
|
return this.queryFacade.getEventStoreFootprint();
|
|
71551
72737
|
}
|
|
72738
|
+
async getEventMediaFootprintByKind(input) {
|
|
72739
|
+
return this.queryFacade.getEventMediaFootprintByKind(input);
|
|
72740
|
+
}
|
|
71552
72741
|
async pruneEvents(input) {
|
|
71553
72742
|
return this.queryFacade.pruneEvents(input);
|
|
71554
72743
|
}
|