@camstack/addon-post-analysis 1.2.130 → 1.2.131

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { $ as customAction, A as NcTaxonomySchema, At as sleep, B as TIMELAPSE_DENSE_FLOOR_SEC, Bt as string, C as NcRuleSchema, Ct as errMsg, D as NcSnoozeSchema, Dt as hydrateSchema, E as NcSnoozeInputSchema, Et as createEvent, Ft as literal, G as VISIT_MERGE_GAP_MS, H as TimelapseRulePatchSchema, Ht as EventCategory, I as SCENE_DEFAULT_ANCHOR_THRESHOLD, It as number, J as assertTimelapseCadences, K as addonWidgetsSourceCapability, L as SCENE_DEFAULT_UNCOVERED_POLICY, Lt as object, Mt as array, N as RECORDING_EXPORT_MAX_READ_BYTES, Nt as boolean, O as NcSnoozeSuppressedSchema, Ot as isDeviceScopedCap, P as RetrainStatusSchema, Pt as discriminatedUnion, Q as cosineSimilarity$1, R as SCENE_DIVERGED, Rt as partialRecord, S as NcRulePatchSchema, St as zoneAnalyticsCapability, T as NcScheduleSchema, Tt as DeviceType, U as TimelapseRuleSchema, V as TimelapseRuleInputSchema, Vt as unknown, W as TrackSourceSchema, X as audioModeOf, Y as audioMetricsCapability, Z as buildEventKindDescriptor, _ as NC_DEFAULT_SNOOZE_MINUTES, _t as sceneMonitorCapability, a as DEFAULT_EVENT_COLOR, b as NcConditionDescriptorSchema, bt as vectorDimFromBase64, ct as kebabToCamel, d as EVENT_PAD_MS, dt as pickClusterStepModels, et as defineCustomActions, f as FULL_IMAGE_BBOX, ft as pipelineAnalyticsCapability, g as NC_CONDITION_CATALOG, h as NC_ALARM_SYSTEM_EVENT_KINDS, ht as readTimelapseGeneratedAt, i as CLUSTER_MODEL_SCOPED_STEPS, it as faceGalleryCapability, j as OpsLogEntrySchema, jt as _enum, k as NcSystemEventKindSchema, kt as nodePin, l as DeclaredDevices, lt as notificationRulesCapability, m as MACRO_LABELS, mt as readDeviceStateFrom, n as AnalyticsGroupRecordSchema, ot as isDetectionMacroClass, p as LabelAttributionSchema, pt as plateGalleryCapability, q as alarmPanelCapability, r as BaseDevice, rt as encodeVectorBase64, s as DETECTION_MACRO_CLASSES, st as isScheduleActive, t as AnalyticsGroupDetailSchema, tt as deriveRecordingMode, u as EVENT_KIND_BY_CAP, vt as subKindsOf, w as NcRuleTargetSchema, wt as BaseAddon, x as NcRuleInputSchema, xt as videoclipsCapability, y as NC_TAXONOMY, yt as systemEventFilterApplies, z as SceneMonitorSchema, zt as record } from "../dist-CBCs0-eP.mjs";
1
+ import { $ as customAction, A as NcTaxonomySchema, At as sleep, B as TIMELAPSE_DENSE_FLOOR_SEC, Bt as string, C as NcRuleSchema, Ct as errMsg, D as NcSnoozeSchema, Dt as hydrateSchema, E as NcSnoozeInputSchema, Et as createEvent, Ft as literal, G as VISIT_MERGE_GAP_MS, H as TimelapseRulePatchSchema, Ht as EventCategory, I as SCENE_DEFAULT_ANCHOR_THRESHOLD, It as number, J as assertTimelapseCadences, K as addonWidgetsSourceCapability, L as SCENE_DEFAULT_UNCOVERED_POLICY, Lt as object, Mt as array, N as RECORDING_EXPORT_MAX_READ_BYTES, Nt as boolean, O as NcSnoozeSuppressedSchema, Ot as isDeviceScopedCap, P as RetrainStatusSchema, Pt as discriminatedUnion, Q as cosineSimilarity$1, R as SCENE_DIVERGED, Rt as partialRecord, S as NcRulePatchSchema, St as zoneAnalyticsCapability, T as NcScheduleSchema, Tt as DeviceType, U as TimelapseRuleSchema, V as TimelapseRuleInputSchema, Vt as unknown, W as TrackSourceSchema, X as audioModeOf, Y as audioMetricsCapability, Z as buildEventKindDescriptor, _ as NC_DEFAULT_SNOOZE_MINUTES, _t as sceneMonitorCapability, a as DEFAULT_EVENT_COLOR, b as NcConditionDescriptorSchema, bt as vectorDimFromBase64, ct as kebabToCamel, d as EVENT_PAD_MS, dt as pickClusterStepModels, et as defineCustomActions, f as FULL_IMAGE_BBOX, ft as pipelineAnalyticsCapability, g as NC_CONDITION_CATALOG, h as NC_ALARM_SYSTEM_EVENT_KINDS, ht as readTimelapseGeneratedAt, i as CLUSTER_MODEL_SCOPED_STEPS, it as faceGalleryCapability, j as OpsLogEntrySchema, jt as _enum, k as NcSystemEventKindSchema, kt as nodePin, l as DeclaredDevices, lt as notificationRulesCapability, m as MACRO_LABELS, mt as readDeviceStateFrom, n as AnalyticsGroupRecordSchema, ot as isDetectionMacroClass, p as LabelAttributionSchema, pt as plateGalleryCapability, q as alarmPanelCapability, r as BaseDevice, rt as encodeVectorBase64, s as DETECTION_MACRO_CLASSES, st as isScheduleActive, t as AnalyticsGroupDetailSchema, tt as deriveRecordingMode, u as EVENT_KIND_BY_CAP, vt as subKindsOf, w as NcRuleTargetSchema, wt as BaseAddon, x as NcRuleInputSchema, xt as videoclipsCapability, y as NC_TAXONOMY, yt as systemEventFilterApplies, z as SceneMonitorSchema, zt as record } from "../dist-C8JUWFmR.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -5406,6 +5406,12 @@ function buildAlarmButtons(input) {
5406
5406
  }];
5407
5407
  }
5408
5408
  //#endregion
5409
+ //#region src/notification-center/cap-caller.ts
5410
+ function requireCapCaller(method, caller) {
5411
+ if (caller === void 0 || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error(`notification-rules.${method} declares caller: 'required' and this call carried no caller identity — an addon-to-addon cap call has no authenticated user`);
5412
+ return caller;
5413
+ }
5414
+ //#endregion
5409
5415
  //#region src/notification-center/audio-rule-matcher.ts
5410
5416
  /**
5411
5417
  * AudioWatcher — the Notification Center's matcher for audio rules
@@ -21186,10 +21192,13 @@ var NotificationCenter = class NotificationCenter {
21186
21192
  });
21187
21193
  return { success: true };
21188
21194
  },
21189
- listSnoozes: async ({ caller }) => ({ snoozes: [...this.snoozes.listVisibleTo(caller.userId, caller.isAdmin)] }),
21190
- createSnooze: async ({ snooze, caller }) => ({ snooze: await this.createSnooze(snooze, caller) }),
21195
+ listSnoozes: async ({ caller }) => {
21196
+ const who = requireCapCaller("listSnoozes", caller);
21197
+ return { snoozes: [...this.snoozes.listVisibleTo(who.userId, who.isAdmin)] };
21198
+ },
21199
+ createSnooze: async ({ snooze, caller }) => ({ snooze: await this.createSnooze(snooze, requireCapCaller("createSnooze", caller)) }),
21191
21200
  cancelSnooze: async ({ snoozeId, caller }) => {
21192
- await this.cancelSnooze(snoozeId, caller);
21201
+ await this.cancelSnooze(snoozeId, requireCapCaller("cancelSnooze", caller));
21193
21202
  return { success: true };
21194
21203
  },
21195
21204
  getAlarmConfig: async () => this.alarmConfig(),
@@ -26814,6 +26823,639 @@ function updateTrackAggregate(prev, match, opts) {
26814
26823
  };
26815
26824
  }
26816
26825
  //#endregion
26826
+ //#region src/pipeline-analytics/store/face-sort.ts
26827
+ var DEFAULT_FACE_SORT = {
26828
+ by: "timestamp",
26829
+ direction: "desc"
26830
+ };
26831
+ /** Resolve a caller's partial request into a complete order. */
26832
+ function resolveFaceSort(input) {
26833
+ return {
26834
+ by: input?.sortBy ?? DEFAULT_FACE_SORT.by,
26835
+ direction: input?.sortDirection ?? DEFAULT_FACE_SORT.direction
26836
+ };
26837
+ }
26838
+ /**
26839
+ * True when the store's `timestamp`-indexed paging already emits rows in the
26840
+ * requested order, so a paged read may STOP as soon as `limit` rows have passed
26841
+ * the filter.
26842
+ *
26843
+ * False for `suggestionConfidence`: the most certain suggestion may be the
26844
+ * oldest row in the window, so an early stop would silently answer with the
26845
+ * newest N ranked — the exact defect this order was added to remove.
26846
+ */
26847
+ function sortIsServedByTimestampPaging(order) {
26848
+ return order.by === "timestamp";
26849
+ }
26850
+ /** Certainty, or `null` when the row has none. Null and undefined are the same
26851
+ * answer — the relational backend surfaces an unset REAL column as SQL NULL. */
26852
+ function certaintyOf(face) {
26853
+ return face.suggestedMatchScore ?? null;
26854
+ }
26855
+ /** Newest first, then `id` ascending. The tail of every comparison. */
26856
+ function compareTiebreak(a, b) {
26857
+ if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
26858
+ if (a.id === b.id) return 0;
26859
+ return a.id < b.id ? -1 : 1;
26860
+ }
26861
+ function compareFaces(a, b, order) {
26862
+ if (order.by === "timestamp") {
26863
+ if (a.timestamp !== b.timestamp) return order.direction === "desc" ? b.timestamp - a.timestamp : a.timestamp - b.timestamp;
26864
+ return compareTiebreak(a, b);
26865
+ }
26866
+ const aScore = certaintyOf(a);
26867
+ const bScore = certaintyOf(b);
26868
+ if (aScore === null && bScore !== null) return 1;
26869
+ if (aScore !== null && bScore === null) return -1;
26870
+ if (aScore !== null && bScore !== null && aScore !== bScore) return order.direction === "desc" ? bScore - aScore : aScore - bScore;
26871
+ return compareTiebreak(a, b);
26872
+ }
26873
+ /** Immutable sort — the input array is never reordered in place. */
26874
+ function sortFaces(faces, order) {
26875
+ return faces.toSorted((a, b) => compareFaces(a, b, order));
26876
+ }
26877
+ /**
26878
+ * Rank `faces` and keep at most `limit` of them.
26879
+ *
26880
+ * This is what lets the confidence order stay bounded: a paged sweep folds each
26881
+ * page in through here, so the retained set is the caller's `limit`, never the
26882
+ * corpus — even though the sweep itself has to walk the whole window.
26883
+ */
26884
+ function rankAndCap(faces, order, limit) {
26885
+ const sorted = sortFaces(faces, order);
26886
+ return limit === void 0 ? sorted : sorted.slice(0, limit);
26887
+ }
26888
+ //#endregion
26889
+ //#region src/pipeline-analytics/store/face-store.ts
26890
+ /**
26891
+ * @durable class=ledger owner=pipeline-analytics
26892
+ * write="one UPSERT per track that produced a face detail — the id is
26893
+ * 'face-<trackId>', so a track has at most one row and the recognizer
26894
+ * overwrites it as a better read lands (embedding, match cosine, crop keys).
26895
+ * The row is rewritten again when the operator enrols it, which flips
26896
+ * `assigned` and stamps the identity."
26897
+ * retention="TRACK-OWNED and clockless (D61): unassigned rows leave with their
26898
+ * track through FaceStore.deleteByTracks, and enrolled (`assigned`) rows are
26899
+ * exempt from that and from every sweep — an assignment is a curation act.
26900
+ * The only other bound is capacity: pruneCapOverflow holds each camera's
26901
+ * UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50). At 10.8 KB
26902
+ * of JSON embedding per row, that cap is what keeps the table small."
26903
+ */
26904
+ var FACES_COLLECTION = "pipeline-analytics:faces";
26905
+ /**
26906
+ * Rows per page for the two whole-collection sweeps below.
26907
+ *
26908
+ * A face row is one of the two WIDEST row shapes in the hub's database: it
26909
+ * carries a JSON `embedding` on every single row, measured at **10.8 KB
26910
+ * average** where a motion event is 78 bytes. Both sweeps used to read the
26911
+ * collection in ONE statement with no `limit`.
26912
+ *
26913
+ * To be exact about what that did and did not cause: this table holds **372
26914
+ * rows / 4.0 MB** on the live hub, so it was *not* the read that killed
26915
+ * hub-main on 2026-08-07 (that was the multi-camera track feed — see
26916
+ * `RECENT_FANOUT_ROW_BUDGET` in `track-store.ts`). It is a full scan whose cost
26917
+ * grows with a table nothing else bounds, which is the same defect one incident
26918
+ * earlier. `pruneCapOverflow` is what keeps the row count small — and it was
26919
+ * itself one of the two unbounded readers, so the bound and the thing enforcing
26920
+ * it were the same loop.
26921
+ *
26922
+ * The engine now refuses an unbounded read (`query-bounds.ts`), so paging here
26923
+ * is not belt-and-braces: without it these two methods would silently see only
26924
+ * the newest N rows and the buffer would grow forever.
26925
+ *
26926
+ * 1 000 keeps peak transfer around 11 MB while staying under the engine's own
26927
+ * cap, so the page is always the caller's bound and never the engine's.
26928
+ */
26929
+ var FACE_SWEEP_PAGE = 1e3;
26930
+ /**
26931
+ * Refuses to loop forever if a page ever fails to advance. 1 000 pages is a
26932
+ * million faces — far past anything real, and reached only by a bug.
26933
+ */
26934
+ var FACE_SWEEP_MAX_PAGES = 1e3;
26935
+ var FACE_COLUMNS = [
26936
+ {
26937
+ name: "id",
26938
+ type: "TEXT",
26939
+ primaryKey: true,
26940
+ notNull: true
26941
+ },
26942
+ {
26943
+ name: "deviceId",
26944
+ type: "INTEGER",
26945
+ notNull: true
26946
+ },
26947
+ {
26948
+ name: "trackId",
26949
+ type: "TEXT",
26950
+ notNull: true
26951
+ },
26952
+ {
26953
+ name: "timestamp",
26954
+ type: "INTEGER",
26955
+ notNull: true
26956
+ },
26957
+ {
26958
+ name: "embedding",
26959
+ type: "JSON",
26960
+ notNull: true
26961
+ },
26962
+ {
26963
+ name: "embeddingModelId",
26964
+ type: "TEXT"
26965
+ },
26966
+ {
26967
+ name: "mediaKey",
26968
+ type: "TEXT"
26969
+ },
26970
+ {
26971
+ name: "recognizedIdentityId",
26972
+ type: "TEXT"
26973
+ },
26974
+ {
26975
+ name: "assigned",
26976
+ type: "BOOLEAN",
26977
+ notNull: true
26978
+ },
26979
+ {
26980
+ name: "assignedSampleId",
26981
+ type: "TEXT"
26982
+ },
26983
+ {
26984
+ name: "keyFrameMediaKey",
26985
+ type: "TEXT"
26986
+ },
26987
+ {
26988
+ name: "faceBbox",
26989
+ type: "JSON"
26990
+ },
26991
+ {
26992
+ name: "bestMatchScore",
26993
+ type: "REAL"
26994
+ },
26995
+ {
26996
+ name: "nativeFaceShortSidePx",
26997
+ type: "REAL"
26998
+ },
26999
+ {
27000
+ name: "suggestedIdentityId",
27001
+ type: "TEXT"
27002
+ },
27003
+ {
27004
+ name: "suggestedMatchScore",
27005
+ type: "REAL"
27006
+ }
27007
+ ];
27008
+ var FACE_INDEXES = [{
27009
+ name: "idx_faces_device_ts",
27010
+ columns: ["deviceId", "timestamp"]
27011
+ }, {
27012
+ name: "idx_faces_track",
27013
+ columns: ["trackId"]
27014
+ }];
27015
+ /**
27016
+ * `since` / `until` as a store predicate.
27017
+ *
27018
+ * The filter surface has exactly one range form, `whereBetween`, and it is
27019
+ * inclusive on both ends — so a one-sided window is expressed by pinning the
27020
+ * other end to the edge of the safe integer range rather than by omitting it.
27021
+ * Returns `{}` when the caller named no window, so the read stays exactly the
27022
+ * unwindowed one.
27023
+ */
27024
+ function buildTimestampWindow(input) {
27025
+ if (input.since === void 0 && input.until === void 0) return {};
27026
+ return { whereBetween: { timestamp: [input.since ?? Number.MIN_SAFE_INTEGER, input.until ?? Number.MAX_SAFE_INTEGER] } };
27027
+ }
27028
+ /**
27029
+ * One stored row → one {@link Face}.
27030
+ *
27031
+ * The single documented type boundary of this store: `SettingsRecord.data` is
27032
+ * `Record<string, unknown>` on the wire and the column set is declared right
27033
+ * here in {@link FACE_COLUMNS}, so this is the one place allowed to name the
27034
+ * shape it wrote.
27035
+ *
27036
+ * It also owns the NULL normalisation, and that is not cosmetic: the relational
27037
+ * backend surfaces an unset optional column as SQL NULL, and `FaceInfo`'s
27038
+ * optional strings REJECT null — an un-normalised row fails
27039
+ * `faceGallery.listRecentFaces` output validation and the People page renders
27040
+ * empty. This used to be copied out three times, which is three chances to
27041
+ * forget the next additive column.
27042
+ */
27043
+ function rowToFace(id, raw) {
27044
+ const data = raw;
27045
+ return {
27046
+ ...data,
27047
+ id,
27048
+ assigned: Boolean(data.assigned),
27049
+ mediaKey: data.mediaKey ?? void 0,
27050
+ recognizedIdentityId: data.recognizedIdentityId ?? void 0,
27051
+ assignedSampleId: data.assignedSampleId ?? void 0,
27052
+ keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
27053
+ faceBbox: data.faceBbox ?? void 0,
27054
+ bestMatchScore: data.bestMatchScore ?? void 0,
27055
+ nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
27056
+ suggestedIdentityId: data.suggestedIdentityId ?? void 0,
27057
+ suggestedMatchScore: data.suggestedMatchScore ?? void 0
27058
+ };
27059
+ }
27060
+ var FaceStore = class {
27061
+ store;
27062
+ logger;
27063
+ constructor(deps) {
27064
+ this.store = deps.store;
27065
+ this.logger = deps.logger;
27066
+ }
27067
+ static async declare(store) {
27068
+ await store.declareCollection.mutate({
27069
+ collection: FACES_COLLECTION,
27070
+ columns: [...FACE_COLUMNS],
27071
+ indexes: [...FACE_INDEXES]
27072
+ });
27073
+ }
27074
+ /**
27075
+ * Upsert one buffered face. Keyed by `face.id` (the worker uses one stable
27076
+ * id per track → one entry per track). `set` replaces an existing row.
27077
+ */
27078
+ async insert(face) {
27079
+ const { id, ...rest } = face;
27080
+ try {
27081
+ await this.store.set.mutate({
27082
+ collection: FACES_COLLECTION,
27083
+ key: id,
27084
+ value: rest
27085
+ });
27086
+ } catch (err) {
27087
+ this.logger.warn("FaceStore.insert failed", {
27088
+ tags: { deviceId: face.deviceId },
27089
+ meta: {
27090
+ faceId: id,
27091
+ error: String(err)
27092
+ }
27093
+ });
27094
+ }
27095
+ }
27096
+ /**
27097
+ * Recent UNASSIGNED faces (the enrollment buffer), newest first.
27098
+ *
27099
+ * NOTE: `assigned` is normalized via `Boolean()` on read so the production
27100
+ * SQLite backend (which returns INTEGER 0/1) is handled correctly.
27101
+ */
27102
+ async listRecentFaces(input) {
27103
+ const where = {};
27104
+ if (input.deviceId !== void 0) where["deviceId"] = input.deviceId;
27105
+ return (await this.store.query.query({
27106
+ collection: FACES_COLLECTION,
27107
+ filter: {
27108
+ where,
27109
+ orderBy: {
27110
+ field: "timestamp",
27111
+ direction: "desc"
27112
+ },
27113
+ limit: input.limit ?? 100
27114
+ }
27115
+ })).map((r) => rowToFace(r.id, r.data)).filter((f) => !f.assigned);
27116
+ }
27117
+ /**
27118
+ * The per-device `prune(deviceId, cutoffMs, maxPerDevice)` that used to sit
27119
+ * here is GONE (D61). It had no production caller — only tests — and it was
27120
+ * an age-keyed deletion of a track-OWNED entity, i.e. the exact thing the
27121
+ * retention model now forbids. A leftover that describes the wrong design
27122
+ * still reads as permission to use it.
27123
+ */
27124
+ /**
27125
+ * Hold the unassigned face buffer to `maxPerDevice` newest rows per camera.
27126
+ *
27127
+ * **This is a CAPACITY bound, not an age one.** It used to be both: a face
27128
+ * was deleted if it was older than `bufferRetentionDays` OR beyond the cap.
27129
+ * The age half was a fifth clock over a track-OWNED entity — a face row
27130
+ * carries a `trackId`, so it belongs to its track and cascades with it — and
27131
+ * it aged crops out from under tracks that were still well inside their own
27132
+ * retention window. It is gone (D61). What remains is the ring-buffer
27133
+ * property the recognizer actually depends on: the gallery of recent
27134
+ * unassigned faces stays small.
27135
+ *
27136
+ * ENROLLED (`assigned`) rows are skipped entirely and do not consume cap
27137
+ * slots. Returns all deleted ids (the caller removes their crops).
27138
+ */
27139
+ async pruneCapOverflow(input) {
27140
+ /**
27141
+ * Paged, not one statement. Only three scalars per row survive the page —
27142
+ * the `embedding` string that dominates the row is dropped as soon as the
27143
+ * page goes out of scope, so peak heap is one page rather than the whole
27144
+ * face table. The prune still considers EVERY row, which is what makes it a
27145
+ * capacity bound rather than a bound on the newest page.
27146
+ */
27147
+ const byDevice = /* @__PURE__ */ new Map();
27148
+ for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
27149
+ const rows = await this.store.query.query({
27150
+ collection: FACES_COLLECTION,
27151
+ filter: {
27152
+ orderBy: {
27153
+ field: "timestamp",
27154
+ direction: "desc"
27155
+ },
27156
+ limit: FACE_SWEEP_PAGE,
27157
+ offset: page * FACE_SWEEP_PAGE
27158
+ }
27159
+ });
27160
+ if (rows.length === 0) break;
27161
+ for (const r of rows) {
27162
+ const data = r.data;
27163
+ if (data.assigned) continue;
27164
+ const deviceId = Number(data.deviceId ?? -1);
27165
+ const list = byDevice.get(deviceId) ?? [];
27166
+ list.push({
27167
+ id: r.id,
27168
+ timestamp: Number(data.timestamp ?? 0)
27169
+ });
27170
+ byDevice.set(deviceId, list);
27171
+ }
27172
+ if (rows.length < 1e3) break;
27173
+ }
27174
+ const toDelete = /* @__PURE__ */ new Set();
27175
+ for (const list of byDevice.values()) {
27176
+ list.sort((a, b) => b.timestamp - a.timestamp);
27177
+ list.forEach((row, idx) => {
27178
+ if (idx >= input.maxPerDevice) toDelete.add(row.id);
27179
+ });
27180
+ }
27181
+ const deleted = [];
27182
+ for (const id of toDelete) try {
27183
+ await this.store.delete.mutate({
27184
+ collection: FACES_COLLECTION,
27185
+ key: id
27186
+ });
27187
+ deleted.push(id);
27188
+ } catch (err) {
27189
+ this.logger.warn("FaceStore.pruneCapOverflow delete failed", { meta: {
27190
+ faceId: id,
27191
+ error: String(err)
27192
+ } });
27193
+ }
27194
+ return deleted;
27195
+ }
27196
+ /** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
27197
+ async markAssigned(faceId, identityId, sampleId) {
27198
+ const data = {
27199
+ assigned: true,
27200
+ recognizedIdentityId: identityId
27201
+ };
27202
+ if (sampleId !== void 0) data["assignedSampleId"] = sampleId;
27203
+ try {
27204
+ await this.store.update.mutate({
27205
+ collection: FACES_COLLECTION,
27206
+ id: faceId,
27207
+ data
27208
+ });
27209
+ } catch (err) {
27210
+ this.logger.warn("FaceStore.markAssigned failed", { meta: {
27211
+ faceId,
27212
+ error: String(err)
27213
+ } });
27214
+ }
27215
+ }
27216
+ /** Clear the assignment: set assigned=false, clear recognizedIdentityId and assignedSampleId. */
27217
+ async clearAssignment(faceId) {
27218
+ try {
27219
+ await this.store.update.mutate({
27220
+ collection: FACES_COLLECTION,
27221
+ id: faceId,
27222
+ data: {
27223
+ assigned: false,
27224
+ recognizedIdentityId: null,
27225
+ assignedSampleId: null
27226
+ }
27227
+ });
27228
+ } catch (err) {
27229
+ this.logger.warn("FaceStore.clearAssignment failed", { meta: {
27230
+ faceId,
27231
+ error: String(err)
27232
+ } });
27233
+ }
27234
+ }
27235
+ /**
27236
+ * Persist a `face.rescoreTrack` result onto the SUGGESTED tier — the ONE
27237
+ * write that explicitly-requested action makes. `suggestion: null` means
27238
+ * the recompute found no candidate worth offering and CLEARS both columns
27239
+ * (a genuine "the gallery has nothing for this face right now" result, not
27240
+ * a no-op left at whatever they held before). Never touches `assigned` /
27241
+ * `recognizedIdentityId` — a suggestion is never an assignment. The caller
27242
+ * is responsible for never calling this with a non-comparable (`model-
27243
+ * mismatch`) report; this method persists whatever it is given.
27244
+ */
27245
+ async setSuggestion(faceId, suggestion) {
27246
+ const data = suggestion === null ? {
27247
+ suggestedIdentityId: null,
27248
+ suggestedMatchScore: null
27249
+ } : {
27250
+ suggestedIdentityId: suggestion.identityId,
27251
+ suggestedMatchScore: suggestion.score
27252
+ };
27253
+ try {
27254
+ await this.store.update.mutate({
27255
+ collection: FACES_COLLECTION,
27256
+ id: faceId,
27257
+ data
27258
+ });
27259
+ } catch (err) {
27260
+ this.logger.warn("FaceStore.setSuggestion failed", { meta: {
27261
+ faceId,
27262
+ error: String(err)
27263
+ } });
27264
+ }
27265
+ }
27266
+ /**
27267
+ * `TrackScopedStore` contract for the retention cascade: delete the buffered
27268
+ * face rows of the given tracks that are NOT assigned. Reuses the EXACT
27269
+ * `assigned` exemption that `prune`/`pruneAll` apply (see the
27270
+ * `!(r.data.assigned)` predicate above) so ENROLLED faces SURVIVE retention.
27271
+ * Per-track ISOLATED + best-effort per row — one bad track or row never
27272
+ * aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
27273
+ */
27274
+ async deleteByTracks(trackIds) {
27275
+ let removed = 0;
27276
+ for (const trackId of trackIds) try {
27277
+ const eligible = (await this.store.query.query({
27278
+ collection: FACES_COLLECTION,
27279
+ filter: { where: { trackId } }
27280
+ })).filter((r) => !r.data.assigned);
27281
+ for (const row of eligible) try {
27282
+ await this.store.delete.mutate({
27283
+ collection: FACES_COLLECTION,
27284
+ key: row.id
27285
+ });
27286
+ removed++;
27287
+ } catch (err) {
27288
+ this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
27289
+ faceId: row.id,
27290
+ error: String(err)
27291
+ } });
27292
+ }
27293
+ } catch (err) {
27294
+ this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
27295
+ trackId,
27296
+ error: String(err)
27297
+ } });
27298
+ }
27299
+ return [{
27300
+ family: "faces",
27301
+ rows: removed,
27302
+ bytes: 0
27303
+ }];
27304
+ }
27305
+ /** Delete a single buffered face row by id (its crop media is removed by the
27306
+ * caller — the FaceStore owns rows, not blobs). Best-effort. */
27307
+ async delete(faceId) {
27308
+ try {
27309
+ await this.store.delete.mutate({
27310
+ collection: FACES_COLLECTION,
27311
+ key: faceId
27312
+ });
27313
+ } catch (err) {
27314
+ this.logger.warn("FaceStore.delete failed", { meta: {
27315
+ faceId,
27316
+ error: String(err)
27317
+ } });
27318
+ }
27319
+ }
27320
+ /**
27321
+ * Retrieve a single face by id. Returns null if not found.
27322
+ */
27323
+ async get(faceId) {
27324
+ const raw = await this.store.get.query({
27325
+ collection: FACES_COLLECTION,
27326
+ key: faceId
27327
+ });
27328
+ if (!raw) return null;
27329
+ return rowToFace(faceId, raw);
27330
+ }
27331
+ /**
27332
+ * Update internal fields of a face (used for partial mutation, e.g. setting recognizedIdentityId).
27333
+ * Not exposed publicly — intended for internal use and tests only.
27334
+ */
27335
+ async update(faceId, data) {
27336
+ try {
27337
+ await this.store.update.mutate({
27338
+ collection: FACES_COLLECTION,
27339
+ id: faceId,
27340
+ data
27341
+ });
27342
+ } catch (err) {
27343
+ this.logger.warn("FaceStore.update failed", { meta: {
27344
+ faceId,
27345
+ error: String(err)
27346
+ } });
27347
+ }
27348
+ }
27349
+ /**
27350
+ * List recent faces across the cluster, or across a named set of cameras.
27351
+ *
27352
+ * filter:
27353
+ * - 'all' (default) → every row
27354
+ * - 'unassigned' → !assigned
27355
+ * - 'recognized' → !assigned && recognizedIdentityId != null
27356
+ * - 'identified' → recognizedIdentityId != null, including manual assignments
27357
+ *
27358
+ * `deviceIds`, `since` and `until` are pushed INTO the query — they narrow
27359
+ * what is read, not what is returned. `filter` and the ordering cannot be
27360
+ * (the first is a computed predicate, the second is a contract this backend
27361
+ * does not implement — see `face-sort.ts`), so they are applied here.
27362
+ *
27363
+ * **`limit` counts rows that PASSED the filter, never rows that were
27364
+ * discarded.** That is the whole reason this pages instead of asking the
27365
+ * store for `limit` rows: with `filter:'unassigned'` the newest N rows may be
27366
+ * entirely assigned while matches wait just beyond them.
27367
+ *
27368
+ * The previous answer was to fetch the WHOLE collection and slice after
27369
+ * filtering. That is the read that killed hub-main (`query-bounds.ts`) —
27370
+ * every face row carries a JSON `embedding`, so "all rows" is the widest
27371
+ * table in the database materialised at once, to render a gallery of at most
27372
+ * a few hundred tiles.
27373
+ *
27374
+ * So: PAGE. Under the default `timestamp` order the store's paging already
27375
+ * emits rows in the answer's order, so the sweep stops the moment enough
27376
+ * MATCHES exist and the common case costs one page. Under
27377
+ * `suggestionConfidence` it cannot — the most certain suggestion may be the
27378
+ * oldest row — so the sweep walks the window and folds each page through
27379
+ * `rankAndCap`, which holds the RETAINED set to `limit` even though the read
27380
+ * is wider. Narrow the read with `since` / `until`.
27381
+ *
27382
+ * An **empty `deviceIds` reads nothing at all**: `[]` is a request for no
27383
+ * cameras, not an omission (the `deviceManager.listFleet` contract).
27384
+ */
27385
+ async listAllRecentFaces(input) {
27386
+ if (input.deviceIds !== void 0 && input.deviceIds.length === 0) return [];
27387
+ const order = resolveFaceSort(input);
27388
+ const pagingServesTheOrder = sortIsServedByTimestampPaging(order);
27389
+ const filterMode = input.filter ?? "all";
27390
+ const matches = (f) => {
27391
+ if (filterMode === "unassigned") return !f.assigned;
27392
+ if (filterMode === "recognized") return !f.assigned && f.recognizedIdentityId != null;
27393
+ if (filterMode === "identified") return f.recognizedIdentityId != null;
27394
+ return true;
27395
+ };
27396
+ const storeFilter = {
27397
+ ...input.deviceIds !== void 0 ? { whereIn: { deviceId: [...new Set(input.deviceIds)] } } : {},
27398
+ ...buildTimestampWindow(input),
27399
+ orderBy: {
27400
+ field: "timestamp",
27401
+ direction: pagingServesTheOrder ? order.direction : "desc"
27402
+ }
27403
+ };
27404
+ let result = [];
27405
+ for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
27406
+ const rows = await this.store.query.query({
27407
+ collection: FACES_COLLECTION,
27408
+ filter: {
27409
+ ...storeFilter,
27410
+ limit: FACE_SWEEP_PAGE,
27411
+ offset: page * FACE_SWEEP_PAGE
27412
+ }
27413
+ });
27414
+ if (rows.length === 0) break;
27415
+ const matched = [];
27416
+ for (const r of rows) {
27417
+ const face = rowToFace(r.id, r.data);
27418
+ if (matches(face)) matched.push(face);
27419
+ }
27420
+ if (pagingServesTheOrder) {
27421
+ const merged = [...result, ...matched];
27422
+ if (input.limit !== void 0 && merged.length >= input.limit) return merged.slice(0, input.limit);
27423
+ result = merged;
27424
+ } else result = rankAndCap([...result, ...matched], order, input.limit);
27425
+ if (rows.length < 1e3) break;
27426
+ }
27427
+ return pagingServesTheOrder ? result : rankAndCap(result, order, input.limit);
27428
+ }
27429
+ /** One real face row per camera currently attributed to an identity. */
27430
+ async listCameraRefsForIdentity(identityId) {
27431
+ const byDevice = /* @__PURE__ */ new Map();
27432
+ for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
27433
+ const rows = await this.store.query.query({
27434
+ collection: FACES_COLLECTION,
27435
+ filter: {
27436
+ where: { recognizedIdentityId: identityId },
27437
+ orderBy: {
27438
+ field: "timestamp",
27439
+ direction: "desc"
27440
+ },
27441
+ limit: FACE_SWEEP_PAGE,
27442
+ offset: page * FACE_SWEEP_PAGE
27443
+ }
27444
+ });
27445
+ if (rows.length === 0) break;
27446
+ for (const row of rows) {
27447
+ const deviceId = Number(row.data["deviceId"]);
27448
+ if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
27449
+ }
27450
+ if (rows.length < 1e3) break;
27451
+ }
27452
+ return [...byDevice].map(([deviceId, faceId]) => ({
27453
+ deviceId,
27454
+ faceId
27455
+ }));
27456
+ }
27457
+ };
27458
+ //#endregion
26817
27459
  //#region src/pipeline-analytics/face-gallery-provider.ts
26818
27460
  /** Attribution of a face identity NAMED BY THE OPERATOR in the gallery. */
26819
27461
  var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
@@ -26827,6 +27469,36 @@ var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
26827
27469
  * stalled pipeline.
26828
27470
  */
26829
27471
  var FACE_CROP_FETCH_CONCURRENCY = 16;
27472
+ /**
27473
+ * How many unassigned faces `suggestFaceClusters` reads when the caller names
27474
+ * no {@link SuggestFaceClustersInput.maxFacesScanned}.
27475
+ *
27476
+ * Clustering used to read EVERY unassigned face on the hub, unconditionally:
27477
+ * its only bound (`limit`) cut the finished clusters, so it shortened the
27478
+ * ANSWER and never the work. A UI showing a window of 100 paid for a scan of
27479
+ * the whole corpus — on an addon whose disk is under contention — and no caller
27480
+ * had any way to ask for less.
27481
+ *
27482
+ * `FACE_SWEEP_PAGE` is chosen on purpose rather than a round number: it is
27483
+ * exactly ONE store page, so on the live corpus (372 face rows) the default
27484
+ * reads what it read before, one page, and nothing about today's answers
27485
+ * changes. What changes is the ceiling — the unbounded scan cannot come back as
27486
+ * the table grows.
27487
+ */
27488
+ var SUGGEST_CLUSTERS_DEFAULT_FACE_POOL = FACE_SWEEP_PAGE;
27489
+ /**
27490
+ * The camera set a face listing is scoped to, as ONE value.
27491
+ *
27492
+ * `deviceIds` is the authority whenever it is present — including when it is
27493
+ * EMPTY, which reads nothing (a request for no cameras is a request, not an
27494
+ * omission). `deviceId` is the legacy single-camera form and only applies when
27495
+ * no set was named, so the two can never disagree about the answer.
27496
+ */
27497
+ function resolveFaceDeviceScope(input) {
27498
+ if (input?.deviceIds !== void 0) return { deviceIds: input.deviceIds };
27499
+ if (input?.deviceId !== void 0) return { deviceIds: [input.deviceId] };
27500
+ return {};
27501
+ }
26830
27502
  var FaceGalleryProvider = class {
26831
27503
  identityStore;
26832
27504
  faceStore;
@@ -26962,9 +27634,13 @@ var FaceGalleryProvider = class {
26962
27634
  }
26963
27635
  async listRecentFaces(input) {
26964
27636
  const faces = await this.faceStore.listAllRecentFaces({
26965
- deviceId: input?.deviceId,
27637
+ ...resolveFaceDeviceScope(input),
26966
27638
  limit: input?.limit,
26967
- filter: input?.filter
27639
+ filter: input?.filter,
27640
+ since: input?.since,
27641
+ until: input?.until,
27642
+ sortBy: input?.sortBy,
27643
+ sortDirection: input?.sortDirection
26968
27644
  });
26969
27645
  const identities = await this.identityStore.listIdentities();
26970
27646
  const nameMap = new Map(identities.map((i) => [i.id, i.name]));
@@ -27220,16 +27896,34 @@ var FaceGalleryProvider = class {
27220
27896
  * Suggest clusters of look-alike UNASSIGNED faces (greedy cosine on their
27221
27897
  * embeddings) so the operator can batch-assign a whole group. Embeddings stay
27222
27898
  * server-side; only faceId groupings are returned.
27899
+ *
27900
+ * TWO bounds, and they cut different things — which is the whole point of the
27901
+ * rename:
27902
+ * - `maxFacesScanned` bounds the POOL, i.e. the work. Newest unassigned
27903
+ * faces first, default {@link SUGGEST_CLUSTERS_DEFAULT_FACE_POOL}.
27904
+ * - `maxClusters` (formerly the ambiguous `limit`) bounds the ANSWER.
27905
+ * It has never made this method read one row less.
27223
27906
  */
27224
27907
  async suggestFaceClusters(input) {
27225
27908
  const threshold = input?.threshold ?? .5;
27226
27909
  const minClusterSize = input?.minClusterSize ?? 2;
27227
- let clusters = clusterByEmbedding((await this.faceStore.listAllRecentFaces({ filter: "unassigned" })).filter((f) => Array.isArray(f.embedding) && f.embedding.length > 0).map((f) => ({
27910
+ const maxFacesScanned = input?.maxFacesScanned ?? SUGGEST_CLUSTERS_DEFAULT_FACE_POOL;
27911
+ const maxClusters = input?.maxClusters ?? input?.limit;
27912
+ const faces = await this.faceStore.listAllRecentFaces({
27913
+ filter: "unassigned",
27914
+ limit: maxFacesScanned
27915
+ });
27916
+ if (faces.length >= maxFacesScanned) this.logger.info("suggestFaceClusters pool saturated — older faces not considered", { meta: {
27917
+ maxFacesScanned,
27918
+ scanned: faces.length,
27919
+ threshold,
27920
+ minClusterSize
27921
+ } });
27922
+ const clusters = clusterByEmbedding(faces.filter((f) => Array.isArray(f.embedding) && f.embedding.length > 0).map((f) => ({
27228
27923
  id: f.id,
27229
27924
  embedding: f.embedding
27230
27925
  })), threshold).filter((c) => c.size >= minClusterSize);
27231
- if (input?.limit !== void 0) clusters = clusters.slice(0, input.limit);
27232
- return clusters;
27926
+ return maxClusters === void 0 ? clusters : clusters.slice(0, maxClusters);
27233
27927
  }
27234
27928
  };
27235
27929
  //#endregion
@@ -47433,553 +48127,6 @@ function stripNulls$1(data) {
47433
48127
  return out;
47434
48128
  }
47435
48129
  //#endregion
47436
- //#region src/pipeline-analytics/store/face-store.ts
47437
- /**
47438
- * @durable class=ledger owner=pipeline-analytics
47439
- * write="one UPSERT per track that produced a face detail — the id is
47440
- * 'face-<trackId>', so a track has at most one row and the recognizer
47441
- * overwrites it as a better read lands (embedding, match cosine, crop keys).
47442
- * The row is rewritten again when the operator enrols it, which flips
47443
- * `assigned` and stamps the identity."
47444
- * retention="TRACK-OWNED and clockless (D61): unassigned rows leave with their
47445
- * track through FaceStore.deleteByTracks, and enrolled (`assigned`) rows are
47446
- * exempt from that and from every sweep — an assignment is a curation act.
47447
- * The only other bound is capacity: pruneCapOverflow holds each camera's
47448
- * UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50). At 10.8 KB
47449
- * of JSON embedding per row, that cap is what keeps the table small."
47450
- */
47451
- var FACES_COLLECTION = "pipeline-analytics:faces";
47452
- /**
47453
- * Rows per page for the two whole-collection sweeps below.
47454
- *
47455
- * A face row is one of the two WIDEST row shapes in the hub's database: it
47456
- * carries a JSON `embedding` on every single row, measured at **10.8 KB
47457
- * average** where a motion event is 78 bytes. Both sweeps used to read the
47458
- * collection in ONE statement with no `limit`.
47459
- *
47460
- * To be exact about what that did and did not cause: this table holds **372
47461
- * rows / 4.0 MB** on the live hub, so it was *not* the read that killed
47462
- * hub-main on 2026-08-07 (that was the multi-camera track feed — see
47463
- * `RECENT_FANOUT_ROW_BUDGET` in `track-store.ts`). It is a full scan whose cost
47464
- * grows with a table nothing else bounds, which is the same defect one incident
47465
- * earlier. `pruneCapOverflow` is what keeps the row count small — and it was
47466
- * itself one of the two unbounded readers, so the bound and the thing enforcing
47467
- * it were the same loop.
47468
- *
47469
- * The engine now refuses an unbounded read (`query-bounds.ts`), so paging here
47470
- * is not belt-and-braces: without it these two methods would silently see only
47471
- * the newest N rows and the buffer would grow forever.
47472
- *
47473
- * 1 000 keeps peak transfer around 11 MB while staying under the engine's own
47474
- * cap, so the page is always the caller's bound and never the engine's.
47475
- */
47476
- var FACE_SWEEP_PAGE = 1e3;
47477
- /**
47478
- * Refuses to loop forever if a page ever fails to advance. 1 000 pages is a
47479
- * million faces — far past anything real, and reached only by a bug.
47480
- */
47481
- var FACE_SWEEP_MAX_PAGES = 1e3;
47482
- var FACE_COLUMNS = [
47483
- {
47484
- name: "id",
47485
- type: "TEXT",
47486
- primaryKey: true,
47487
- notNull: true
47488
- },
47489
- {
47490
- name: "deviceId",
47491
- type: "INTEGER",
47492
- notNull: true
47493
- },
47494
- {
47495
- name: "trackId",
47496
- type: "TEXT",
47497
- notNull: true
47498
- },
47499
- {
47500
- name: "timestamp",
47501
- type: "INTEGER",
47502
- notNull: true
47503
- },
47504
- {
47505
- name: "embedding",
47506
- type: "JSON",
47507
- notNull: true
47508
- },
47509
- {
47510
- name: "embeddingModelId",
47511
- type: "TEXT"
47512
- },
47513
- {
47514
- name: "mediaKey",
47515
- type: "TEXT"
47516
- },
47517
- {
47518
- name: "recognizedIdentityId",
47519
- type: "TEXT"
47520
- },
47521
- {
47522
- name: "assigned",
47523
- type: "BOOLEAN",
47524
- notNull: true
47525
- },
47526
- {
47527
- name: "assignedSampleId",
47528
- type: "TEXT"
47529
- },
47530
- {
47531
- name: "keyFrameMediaKey",
47532
- type: "TEXT"
47533
- },
47534
- {
47535
- name: "faceBbox",
47536
- type: "JSON"
47537
- },
47538
- {
47539
- name: "bestMatchScore",
47540
- type: "REAL"
47541
- },
47542
- {
47543
- name: "nativeFaceShortSidePx",
47544
- type: "REAL"
47545
- },
47546
- {
47547
- name: "suggestedIdentityId",
47548
- type: "TEXT"
47549
- },
47550
- {
47551
- name: "suggestedMatchScore",
47552
- type: "REAL"
47553
- }
47554
- ];
47555
- var FACE_INDEXES = [{
47556
- name: "idx_faces_device_ts",
47557
- columns: ["deviceId", "timestamp"]
47558
- }, {
47559
- name: "idx_faces_track",
47560
- columns: ["trackId"]
47561
- }];
47562
- var FaceStore = class {
47563
- store;
47564
- logger;
47565
- constructor(deps) {
47566
- this.store = deps.store;
47567
- this.logger = deps.logger;
47568
- }
47569
- static async declare(store) {
47570
- await store.declareCollection.mutate({
47571
- collection: FACES_COLLECTION,
47572
- columns: [...FACE_COLUMNS],
47573
- indexes: [...FACE_INDEXES]
47574
- });
47575
- }
47576
- /**
47577
- * Upsert one buffered face. Keyed by `face.id` (the worker uses one stable
47578
- * id per track → one entry per track). `set` replaces an existing row.
47579
- */
47580
- async insert(face) {
47581
- const { id, ...rest } = face;
47582
- try {
47583
- await this.store.set.mutate({
47584
- collection: FACES_COLLECTION,
47585
- key: id,
47586
- value: rest
47587
- });
47588
- } catch (err) {
47589
- this.logger.warn("FaceStore.insert failed", {
47590
- tags: { deviceId: face.deviceId },
47591
- meta: {
47592
- faceId: id,
47593
- error: String(err)
47594
- }
47595
- });
47596
- }
47597
- }
47598
- /**
47599
- * Recent UNASSIGNED faces (the enrollment buffer), newest first.
47600
- *
47601
- * NOTE: `assigned` is normalized via `Boolean()` on read so the production
47602
- * SQLite backend (which returns INTEGER 0/1) is handled correctly.
47603
- */
47604
- async listRecentFaces(input) {
47605
- const where = {};
47606
- if (input.deviceId !== void 0) where["deviceId"] = input.deviceId;
47607
- return (await this.store.query.query({
47608
- collection: FACES_COLLECTION,
47609
- filter: {
47610
- where,
47611
- orderBy: {
47612
- field: "timestamp",
47613
- direction: "desc"
47614
- },
47615
- limit: input.limit ?? 100
47616
- }
47617
- })).map((r) => {
47618
- const data = r.data;
47619
- return {
47620
- id: r.id,
47621
- ...data,
47622
- assigned: Boolean(r.data.assigned),
47623
- mediaKey: data.mediaKey ?? void 0,
47624
- recognizedIdentityId: data.recognizedIdentityId ?? void 0,
47625
- assignedSampleId: data.assignedSampleId ?? void 0,
47626
- keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
47627
- faceBbox: data.faceBbox ?? void 0,
47628
- bestMatchScore: data.bestMatchScore ?? void 0,
47629
- nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
47630
- suggestedIdentityId: data.suggestedIdentityId ?? void 0,
47631
- suggestedMatchScore: data.suggestedMatchScore ?? void 0
47632
- };
47633
- }).filter((f) => !f.assigned);
47634
- }
47635
- /**
47636
- * The per-device `prune(deviceId, cutoffMs, maxPerDevice)` that used to sit
47637
- * here is GONE (D61). It had no production caller — only tests — and it was
47638
- * an age-keyed deletion of a track-OWNED entity, i.e. the exact thing the
47639
- * retention model now forbids. A leftover that describes the wrong design
47640
- * still reads as permission to use it.
47641
- */
47642
- /**
47643
- * Hold the unassigned face buffer to `maxPerDevice` newest rows per camera.
47644
- *
47645
- * **This is a CAPACITY bound, not an age one.** It used to be both: a face
47646
- * was deleted if it was older than `bufferRetentionDays` OR beyond the cap.
47647
- * The age half was a fifth clock over a track-OWNED entity — a face row
47648
- * carries a `trackId`, so it belongs to its track and cascades with it — and
47649
- * it aged crops out from under tracks that were still well inside their own
47650
- * retention window. It is gone (D61). What remains is the ring-buffer
47651
- * property the recognizer actually depends on: the gallery of recent
47652
- * unassigned faces stays small.
47653
- *
47654
- * ENROLLED (`assigned`) rows are skipped entirely and do not consume cap
47655
- * slots. Returns all deleted ids (the caller removes their crops).
47656
- */
47657
- async pruneCapOverflow(input) {
47658
- /**
47659
- * Paged, not one statement. Only three scalars per row survive the page —
47660
- * the `embedding` string that dominates the row is dropped as soon as the
47661
- * page goes out of scope, so peak heap is one page rather than the whole
47662
- * face table. The prune still considers EVERY row, which is what makes it a
47663
- * capacity bound rather than a bound on the newest page.
47664
- */
47665
- const byDevice = /* @__PURE__ */ new Map();
47666
- for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
47667
- const rows = await this.store.query.query({
47668
- collection: FACES_COLLECTION,
47669
- filter: {
47670
- orderBy: {
47671
- field: "timestamp",
47672
- direction: "desc"
47673
- },
47674
- limit: FACE_SWEEP_PAGE,
47675
- offset: page * FACE_SWEEP_PAGE
47676
- }
47677
- });
47678
- if (rows.length === 0) break;
47679
- for (const r of rows) {
47680
- const data = r.data;
47681
- if (data.assigned) continue;
47682
- const deviceId = Number(data.deviceId ?? -1);
47683
- const list = byDevice.get(deviceId) ?? [];
47684
- list.push({
47685
- id: r.id,
47686
- timestamp: Number(data.timestamp ?? 0)
47687
- });
47688
- byDevice.set(deviceId, list);
47689
- }
47690
- if (rows.length < 1e3) break;
47691
- }
47692
- const toDelete = /* @__PURE__ */ new Set();
47693
- for (const list of byDevice.values()) {
47694
- list.sort((a, b) => b.timestamp - a.timestamp);
47695
- list.forEach((row, idx) => {
47696
- if (idx >= input.maxPerDevice) toDelete.add(row.id);
47697
- });
47698
- }
47699
- const deleted = [];
47700
- for (const id of toDelete) try {
47701
- await this.store.delete.mutate({
47702
- collection: FACES_COLLECTION,
47703
- key: id
47704
- });
47705
- deleted.push(id);
47706
- } catch (err) {
47707
- this.logger.warn("FaceStore.pruneCapOverflow delete failed", { meta: {
47708
- faceId: id,
47709
- error: String(err)
47710
- } });
47711
- }
47712
- return deleted;
47713
- }
47714
- /** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
47715
- async markAssigned(faceId, identityId, sampleId) {
47716
- const data = {
47717
- assigned: true,
47718
- recognizedIdentityId: identityId
47719
- };
47720
- if (sampleId !== void 0) data["assignedSampleId"] = sampleId;
47721
- try {
47722
- await this.store.update.mutate({
47723
- collection: FACES_COLLECTION,
47724
- id: faceId,
47725
- data
47726
- });
47727
- } catch (err) {
47728
- this.logger.warn("FaceStore.markAssigned failed", { meta: {
47729
- faceId,
47730
- error: String(err)
47731
- } });
47732
- }
47733
- }
47734
- /** Clear the assignment: set assigned=false, clear recognizedIdentityId and assignedSampleId. */
47735
- async clearAssignment(faceId) {
47736
- try {
47737
- await this.store.update.mutate({
47738
- collection: FACES_COLLECTION,
47739
- id: faceId,
47740
- data: {
47741
- assigned: false,
47742
- recognizedIdentityId: null,
47743
- assignedSampleId: null
47744
- }
47745
- });
47746
- } catch (err) {
47747
- this.logger.warn("FaceStore.clearAssignment failed", { meta: {
47748
- faceId,
47749
- error: String(err)
47750
- } });
47751
- }
47752
- }
47753
- /**
47754
- * Persist a `face.rescoreTrack` result onto the SUGGESTED tier — the ONE
47755
- * write that explicitly-requested action makes. `suggestion: null` means
47756
- * the recompute found no candidate worth offering and CLEARS both columns
47757
- * (a genuine "the gallery has nothing for this face right now" result, not
47758
- * a no-op left at whatever they held before). Never touches `assigned` /
47759
- * `recognizedIdentityId` — a suggestion is never an assignment. The caller
47760
- * is responsible for never calling this with a non-comparable (`model-
47761
- * mismatch`) report; this method persists whatever it is given.
47762
- */
47763
- async setSuggestion(faceId, suggestion) {
47764
- const data = suggestion === null ? {
47765
- suggestedIdentityId: null,
47766
- suggestedMatchScore: null
47767
- } : {
47768
- suggestedIdentityId: suggestion.identityId,
47769
- suggestedMatchScore: suggestion.score
47770
- };
47771
- try {
47772
- await this.store.update.mutate({
47773
- collection: FACES_COLLECTION,
47774
- id: faceId,
47775
- data
47776
- });
47777
- } catch (err) {
47778
- this.logger.warn("FaceStore.setSuggestion failed", { meta: {
47779
- faceId,
47780
- error: String(err)
47781
- } });
47782
- }
47783
- }
47784
- /**
47785
- * `TrackScopedStore` contract for the retention cascade: delete the buffered
47786
- * face rows of the given tracks that are NOT assigned. Reuses the EXACT
47787
- * `assigned` exemption that `prune`/`pruneAll` apply (see the
47788
- * `!(r.data.assigned)` predicate above) so ENROLLED faces SURVIVE retention.
47789
- * Per-track ISOLATED + best-effort per row — one bad track or row never
47790
- * aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
47791
- */
47792
- async deleteByTracks(trackIds) {
47793
- let removed = 0;
47794
- for (const trackId of trackIds) try {
47795
- const eligible = (await this.store.query.query({
47796
- collection: FACES_COLLECTION,
47797
- filter: { where: { trackId } }
47798
- })).filter((r) => !r.data.assigned);
47799
- for (const row of eligible) try {
47800
- await this.store.delete.mutate({
47801
- collection: FACES_COLLECTION,
47802
- key: row.id
47803
- });
47804
- removed++;
47805
- } catch (err) {
47806
- this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
47807
- faceId: row.id,
47808
- error: String(err)
47809
- } });
47810
- }
47811
- } catch (err) {
47812
- this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
47813
- trackId,
47814
- error: String(err)
47815
- } });
47816
- }
47817
- return [{
47818
- family: "faces",
47819
- rows: removed,
47820
- bytes: 0
47821
- }];
47822
- }
47823
- /** Delete a single buffered face row by id (its crop media is removed by the
47824
- * caller — the FaceStore owns rows, not blobs). Best-effort. */
47825
- async delete(faceId) {
47826
- try {
47827
- await this.store.delete.mutate({
47828
- collection: FACES_COLLECTION,
47829
- key: faceId
47830
- });
47831
- } catch (err) {
47832
- this.logger.warn("FaceStore.delete failed", { meta: {
47833
- faceId,
47834
- error: String(err)
47835
- } });
47836
- }
47837
- }
47838
- /**
47839
- * Retrieve a single face by id. Returns null if not found.
47840
- */
47841
- async get(faceId) {
47842
- const raw = await this.store.get.query({
47843
- collection: FACES_COLLECTION,
47844
- key: faceId
47845
- });
47846
- if (!raw) return null;
47847
- const data = raw;
47848
- return {
47849
- id: faceId,
47850
- ...data,
47851
- assigned: Boolean(raw.assigned),
47852
- mediaKey: data.mediaKey ?? void 0,
47853
- recognizedIdentityId: data.recognizedIdentityId ?? void 0,
47854
- assignedSampleId: data.assignedSampleId ?? void 0,
47855
- keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
47856
- faceBbox: data.faceBbox ?? void 0,
47857
- bestMatchScore: data.bestMatchScore ?? void 0,
47858
- nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
47859
- suggestedIdentityId: data.suggestedIdentityId ?? void 0,
47860
- suggestedMatchScore: data.suggestedMatchScore ?? void 0
47861
- };
47862
- }
47863
- /**
47864
- * Update internal fields of a face (used for partial mutation, e.g. setting recognizedIdentityId).
47865
- * Not exposed publicly — intended for internal use and tests only.
47866
- */
47867
- async update(faceId, data) {
47868
- try {
47869
- await this.store.update.mutate({
47870
- collection: FACES_COLLECTION,
47871
- id: faceId,
47872
- data
47873
- });
47874
- } catch (err) {
47875
- this.logger.warn("FaceStore.update failed", { meta: {
47876
- faceId,
47877
- error: String(err)
47878
- } });
47879
- }
47880
- }
47881
- /**
47882
- * List ALL recent faces across all devices, newest-first.
47883
- *
47884
- * filter:
47885
- * - 'all' (default) → every row
47886
- * - 'unassigned' → !assigned
47887
- * - 'recognized' → !assigned && recognizedIdentityId != null
47888
- * - 'identified' → recognizedIdentityId != null, including manual assignments
47889
- */
47890
- async listAllRecentFaces(input) {
47891
- /**
47892
- * The filter is applied in memory, so a DB `limit` of N cannot be the
47893
- * caller's N: with `filter:'unassigned'` the newest N rows may be entirely
47894
- * assigned while matches wait just beyond them.
47895
- *
47896
- * The previous answer was to fetch the WHOLE collection and slice after
47897
- * filtering. That is the read that killed hub-main (`query-bounds.ts`) —
47898
- * every face row carries a JSON `embedding`, so "all rows" is the widest
47899
- * table in the database materialised at once, to render a gallery of at
47900
- * most a few hundred tiles.
47901
- *
47902
- * PAGE instead, and stop as soon as enough MATCHES exist. The pathological
47903
- * case (a filter that matches nothing) walks the whole collection exactly
47904
- * as before but one bounded page at a time; the common case stops after the
47905
- * first page. Semantics are unchanged: the limit is still applied after the
47906
- * filter, never before it.
47907
- */
47908
- const filterMode = input.filter ?? "all";
47909
- const matches = (f) => {
47910
- if (filterMode === "unassigned") return !f.assigned;
47911
- if (filterMode === "recognized") return !f.assigned && f.recognizedIdentityId != null;
47912
- if (filterMode === "identified") return f.recognizedIdentityId != null;
47913
- return true;
47914
- };
47915
- const result = [];
47916
- for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
47917
- const rows = await this.store.query.query({
47918
- collection: FACES_COLLECTION,
47919
- filter: {
47920
- ...input.deviceId !== void 0 ? { where: { deviceId: input.deviceId } } : {},
47921
- orderBy: {
47922
- field: "timestamp",
47923
- direction: "desc"
47924
- },
47925
- limit: FACE_SWEEP_PAGE,
47926
- offset: page * FACE_SWEEP_PAGE
47927
- }
47928
- });
47929
- if (rows.length === 0) break;
47930
- for (const r of rows) {
47931
- const data = r.data;
47932
- const face = {
47933
- id: r.id,
47934
- ...data,
47935
- assigned: Boolean(r.data.assigned),
47936
- mediaKey: data.mediaKey ?? void 0,
47937
- recognizedIdentityId: data.recognizedIdentityId ?? void 0,
47938
- assignedSampleId: data.assignedSampleId ?? void 0,
47939
- keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
47940
- faceBbox: data.faceBbox ?? void 0,
47941
- bestMatchScore: data.bestMatchScore ?? void 0,
47942
- nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
47943
- suggestedIdentityId: data.suggestedIdentityId ?? void 0,
47944
- suggestedMatchScore: data.suggestedMatchScore ?? void 0
47945
- };
47946
- if (matches(face)) result.push(face);
47947
- if (input.limit !== void 0 && result.length >= input.limit) return result;
47948
- }
47949
- if (rows.length < 1e3) break;
47950
- }
47951
- return result;
47952
- }
47953
- /** One real face row per camera currently attributed to an identity. */
47954
- async listCameraRefsForIdentity(identityId) {
47955
- const byDevice = /* @__PURE__ */ new Map();
47956
- for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
47957
- const rows = await this.store.query.query({
47958
- collection: FACES_COLLECTION,
47959
- filter: {
47960
- where: { recognizedIdentityId: identityId },
47961
- orderBy: {
47962
- field: "timestamp",
47963
- direction: "desc"
47964
- },
47965
- limit: FACE_SWEEP_PAGE,
47966
- offset: page * FACE_SWEEP_PAGE
47967
- }
47968
- });
47969
- if (rows.length === 0) break;
47970
- for (const row of rows) {
47971
- const deviceId = Number(row.data["deviceId"]);
47972
- if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
47973
- }
47974
- if (rows.length < 1e3) break;
47975
- }
47976
- return [...byDevice].map(([deviceId, faceId]) => ({
47977
- deviceId,
47978
- faceId
47979
- }));
47980
- }
47981
- };
47982
- //#endregion
47983
48130
  //#region src/pipeline-analytics/store/identity-store.ts
47984
48131
  /**
47985
48132
  * IdentityStore — per-person identity registry for face recognition.