@camstack/addon-post-analysis 1.2.129 → 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.
@@ -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-DFeeUt_t.js");
5
+ const require_dist = require("../dist-B8_nKey5.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");
@@ -2446,6 +2446,45 @@ var NcArtifactPlane = class {
2446
2446
  }
2447
2447
  }
2448
2448
  /**
2449
+ * Mint a FRESH signed URL for an artefact already sitting on the shelf —
2450
+ * no bytes read, no re-store. The other half of {@link publishIndexed}: that
2451
+ * one stores AND mints once at publish time; this one re-mints on demand for
2452
+ * an id a caller already holds (a history row's `artifactIds`), because the
2453
+ * link `publishIndexed` returned has its own TTL and a caller reaching for
2454
+ * the artefact hours later needs a new one.
2455
+ *
2456
+ * `null` on any of three causes, deliberately indistinguishable to the
2457
+ * caller: no index is wired (nothing to resolve against), the id is unknown
2458
+ * or its artefact has expired past the retained shelf's own age bound, or no
2459
+ * externally-reachable base URL exists for this install. A caller (the Home
2460
+ * Assistant export) must render all three as "no image right now" — never as
2461
+ * a broken link, and never by reusing a URL this call refused to mint.
2462
+ */
2463
+ async mintUrl(artifactId) {
2464
+ const index = this.deps.index;
2465
+ if (index === void 0) return null;
2466
+ try {
2467
+ const record = await index.resolve(artifactId);
2468
+ if (record === null) return null;
2469
+ if (record.expiresAt <= this.now()) return null;
2470
+ const baseUrl = await this.resolveBaseUrl();
2471
+ if (baseUrl === null) return null;
2472
+ return buildArtifactUrl({
2473
+ baseUrl,
2474
+ routePrefix: this.deps.routePrefix,
2475
+ id: artifactId,
2476
+ secret: this.deps.secret,
2477
+ expMs: this.now() + (this.deps.ttlMs ?? DEFAULT_TTL_MS)
2478
+ });
2479
+ } catch (err) {
2480
+ this.deps.logger.debug("artifact url mint failed", { meta: {
2481
+ artifactId,
2482
+ error: String(err)
2483
+ } });
2484
+ return null;
2485
+ }
2486
+ }
2487
+ /**
2449
2488
  * Absolutize a path against the SAME base URL an artifact link would use.
2450
2489
  *
2451
2490
  * For links this plane does not host but a RECIPIENT still has to open — a
@@ -5374,6 +5413,12 @@ function buildAlarmButtons(input) {
5374
5413
  }];
5375
5414
  }
5376
5415
  //#endregion
5416
+ //#region src/notification-center/cap-caller.ts
5417
+ function requireCapCaller(method, caller) {
5418
+ 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`);
5419
+ return caller;
5420
+ }
5421
+ //#endregion
5377
5422
  //#region src/notification-center/audio-rule-matcher.ts
5378
5423
  /**
5379
5424
  * AudioWatcher — the Notification Center's matcher for audio rules
@@ -8932,7 +8977,7 @@ var NcDispatcher = class {
8932
8977
  return { ok: true };
8933
8978
  }
8934
8979
  }
8935
- const notification = await this.buildNotification(entry, target);
8980
+ const { notification, artifactIds } = await this.buildNotification(entry, target);
8936
8981
  const verdict = await this.runConfirmGate(entry, notification);
8937
8982
  if (verdict !== null && verdict.decision === "suppressed") {
8938
8983
  this.deps.logger.info("notification suppressed by AI confirm", {
@@ -8990,10 +9035,11 @@ var NcDispatcher = class {
8990
9035
  ...dropped.length > 0 ? { dropped } : {}
8991
9036
  }
8992
9037
  });
8993
- return verdict !== null ? {
9038
+ return {
8994
9039
  ok: true,
8995
- confirm: verdict
8996
- } : { ok: true };
9040
+ ...verdict !== null ? { confirm: verdict } : {},
9041
+ ...artifactIds.length > 0 ? { artifactIds } : {}
9042
+ };
8997
9043
  } catch (err) {
8998
9044
  return {
8999
9045
  ok: false,
@@ -9304,6 +9350,12 @@ var NcDispatcher = class {
9304
9350
  });
9305
9351
  return input.frozen ?? "";
9306
9352
  }
9353
+ /**
9354
+ * Builds the notification AND returns the artefact ids its media resolve
9355
+ * indexed — see {@link withArtifactUrls}. A tuple rather than a field on
9356
+ * `NcNotificationInput`: the ids are internal (outbox provenance), never
9357
+ * part of what `notification-output` sends to a backend.
9358
+ */
9307
9359
  async buildNotification(entry, target) {
9308
9360
  const subject = entry.payload.subject;
9309
9361
  const systemEvent = subject.systemEvent;
@@ -9332,7 +9384,8 @@ var NcDispatcher = class {
9332
9384
  vars: { body: rendered },
9333
9385
  frozen: rendered
9334
9386
  }) : rendered;
9335
- const attachments = [...await this.withArtifactUrls(entry, await this.resolveAttachments(entry)), ...frozenArtifactAttachments(entry)];
9387
+ const resolvedMedia = await this.withArtifactUrls(entry, await this.resolveAttachments(entry));
9388
+ const attachments = [...resolvedMedia.attachments, ...frozenArtifactAttachments(entry)];
9336
9389
  const params = pickParams(entry.payload.params);
9337
9390
  const tapUrl = params.clickUrl === void 0 && this.deps.viewerUrlBase !== void 0 ? defaultTapUrl({
9338
9391
  deviceId: systemEvent !== void 0 ? 0 : subject.deviceId,
@@ -9342,21 +9395,24 @@ var NcDispatcher = class {
9342
9395
  }) : null;
9343
9396
  const actions = await this.mintActions(entry, target);
9344
9397
  return {
9345
- body,
9346
- title,
9347
- priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
9348
- ...params,
9349
- ...tapUrl !== null ? { clickUrl: tapUrl } : {},
9350
- tag: entry.payload.group?.key ?? entry.ruleId,
9351
- ...systemEvent !== void 0 ? systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {} : { deviceId: subject.deviceId },
9352
- ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
9353
- ...systemEvent !== void 0 ? { metadata: {
9354
- systemEventKind: systemEvent.kind,
9355
- subject: systemEvent.subject,
9356
- ...systemEvent.data
9357
- } } : {},
9358
- ...attachments.length > 0 ? { attachments } : {},
9359
- ...actions.length > 0 ? { actions } : {}
9398
+ notification: {
9399
+ body,
9400
+ title,
9401
+ priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
9402
+ ...params,
9403
+ ...tapUrl !== null ? { clickUrl: tapUrl } : {},
9404
+ tag: entry.payload.group?.key ?? entry.ruleId,
9405
+ ...systemEvent !== void 0 ? systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {} : { deviceId: subject.deviceId },
9406
+ ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
9407
+ ...systemEvent !== void 0 ? { metadata: {
9408
+ systemEventKind: systemEvent.kind,
9409
+ subject: systemEvent.subject,
9410
+ ...systemEvent.data
9411
+ } } : {},
9412
+ ...attachments.length > 0 ? { attachments } : {},
9413
+ ...actions.length > 0 ? { actions } : {}
9414
+ },
9415
+ artifactIds: resolvedMedia.artifactIds
9360
9416
  };
9361
9417
  }
9362
9418
  /**
@@ -9400,11 +9456,28 @@ var NcDispatcher = class {
9400
9456
  }
9401
9457
  });
9402
9458
  }
9459
+ /**
9460
+ * What one delivery's media resolve produced: the attachments ready to ship
9461
+ * (bytes plus a signed url when the plane could mint one) and the ids the
9462
+ * artefact INDEX assigned them, in the same order — still first, then gif,
9463
+ * then clip. The ids never ride on the attachment objects themselves: those
9464
+ * cross to `notification-output` and a notifier backend has no use for an
9465
+ * internal store id, only for `url`/`bytes`.
9466
+ *
9467
+ * `artifactIds` is what lets a delivery's outbox row later answer "which
9468
+ * artefact was this trigger's picture" (`getHistory.artifactIds`) without a
9469
+ * second write path — the outbox writes back exactly the ids this method
9470
+ * handed to a SUCCESSFUL send, never re-deriving them.
9471
+ */
9403
9472
  async withArtifactUrls(entry, attachments) {
9404
9473
  const index = this.deps.indexArtifact;
9405
9474
  const publish = this.deps.publishArtifact;
9406
- if (index === void 0 && publish === void 0) return [...attachments];
9475
+ if (index === void 0 && publish === void 0) return {
9476
+ attachments: [...attachments],
9477
+ artifactIds: []
9478
+ };
9407
9479
  const out = [];
9480
+ const artifactIds = [];
9408
9481
  let posterId = null;
9409
9482
  const deviceId = entry.payload.subject.deviceId;
9410
9483
  const common = {
@@ -9427,6 +9500,7 @@ var NcDispatcher = class {
9427
9500
  artifactId: null
9428
9501
  }));
9429
9502
  if (kind === "event-still" && pub.artifactId !== null) posterId = pub.artifactId;
9503
+ if (pub.artifactId !== null) artifactIds.push(pub.artifactId);
9430
9504
  out.push(pub.url === null ? { ...att } : {
9431
9505
  ...att,
9432
9506
  url: pub.url
@@ -9439,7 +9513,10 @@ var NcDispatcher = class {
9439
9513
  url
9440
9514
  });
9441
9515
  }
9442
- return out;
9516
+ return {
9517
+ attachments: out,
9518
+ artifactIds
9519
+ };
9443
9520
  }
9444
9521
  /** The full attachment list: the policy still (zone-cropped when the rule
9445
9522
  * froze zone ids) plus the footage cut from the broker's clip ring when
@@ -12543,13 +12620,17 @@ var NcOutbox = class {
12543
12620
  }
12544
12621
  const now = this.now();
12545
12622
  if (result.ok) {
12623
+ const payloadPatch = {
12624
+ ...result.confirm !== void 0 ? { confirmVerdict: result.confirm } : {},
12625
+ ...result.artifactIds !== void 0 && result.artifactIds.length > 0 ? { artifactIds: result.artifactIds } : {}
12626
+ };
12546
12627
  const sent = {
12547
12628
  ...entry,
12548
12629
  status: "sent",
12549
12630
  updatedAt: now,
12550
- ...result.confirm !== void 0 ? { payload: {
12631
+ ...Object.keys(payloadPatch).length > 0 ? { payload: {
12551
12632
  ...entry.payload,
12552
- confirmVerdict: result.confirm
12633
+ ...payloadPatch
12553
12634
  } } : {}
12554
12635
  };
12555
12636
  this.pending.delete(entry.id);
@@ -19583,7 +19664,8 @@ function outboxEntryToHistory(texts, entry) {
19583
19664
  zones: [...s.zones],
19584
19665
  timestamp: s.timestamp,
19585
19666
  ...s.systemEvent !== void 0 ? { systemEvent: historySystemEvent(texts, s.systemEvent) } : {}
19586
- }
19667
+ },
19668
+ ...entry.payload.artifactIds !== void 0 && entry.payload.artifactIds.length > 0 ? { artifactIds: [...entry.payload.artifactIds] } : {}
19587
19669
  };
19588
19670
  }
19589
19671
  /** The watched question in one grep-able token. */
@@ -21122,6 +21204,7 @@ var NotificationCenter = class NotificationCenter {
21122
21204
  }
21123
21205
  return { entries: mapped };
21124
21206
  },
21207
+ resolveArtifactUrl: async ({ artifactId }) => ({ url: await this.deps.resolveArtifactUrl?.(artifactId) ?? null }),
21125
21208
  listDeviceMutes: async () => ({ mutedDeviceIds: this.deviceMutes.listMuted() }),
21126
21209
  setDeviceMuted: async ({ deviceId, muted }) => {
21127
21210
  await this.deviceMutes.setMuted(deviceId, muted);
@@ -21131,10 +21214,13 @@ var NotificationCenter = class NotificationCenter {
21131
21214
  });
21132
21215
  return { success: true };
21133
21216
  },
21134
- listSnoozes: async ({ caller }) => ({ snoozes: [...this.snoozes.listVisibleTo(caller.userId, caller.isAdmin)] }),
21135
- createSnooze: async ({ snooze, caller }) => ({ snooze: await this.createSnooze(snooze, caller) }),
21217
+ listSnoozes: async ({ caller }) => {
21218
+ const who = requireCapCaller("listSnoozes", caller);
21219
+ return { snoozes: [...this.snoozes.listVisibleTo(who.userId, who.isAdmin)] };
21220
+ },
21221
+ createSnooze: async ({ snooze, caller }) => ({ snooze: await this.createSnooze(snooze, requireCapCaller("createSnooze", caller)) }),
21136
21222
  cancelSnooze: async ({ snoozeId, caller }) => {
21137
- await this.cancelSnooze(snoozeId, caller);
21223
+ await this.cancelSnooze(snoozeId, requireCapCaller("cancelSnooze", caller));
21138
21224
  return { success: true };
21139
21225
  },
21140
21226
  getAlarmConfig: async () => this.alarmConfig(),
@@ -26759,6 +26845,639 @@ function updateTrackAggregate(prev, match, opts) {
26759
26845
  };
26760
26846
  }
26761
26847
  //#endregion
26848
+ //#region src/pipeline-analytics/store/face-sort.ts
26849
+ var DEFAULT_FACE_SORT = {
26850
+ by: "timestamp",
26851
+ direction: "desc"
26852
+ };
26853
+ /** Resolve a caller's partial request into a complete order. */
26854
+ function resolveFaceSort(input) {
26855
+ return {
26856
+ by: input?.sortBy ?? DEFAULT_FACE_SORT.by,
26857
+ direction: input?.sortDirection ?? DEFAULT_FACE_SORT.direction
26858
+ };
26859
+ }
26860
+ /**
26861
+ * True when the store's `timestamp`-indexed paging already emits rows in the
26862
+ * requested order, so a paged read may STOP as soon as `limit` rows have passed
26863
+ * the filter.
26864
+ *
26865
+ * False for `suggestionConfidence`: the most certain suggestion may be the
26866
+ * oldest row in the window, so an early stop would silently answer with the
26867
+ * newest N ranked — the exact defect this order was added to remove.
26868
+ */
26869
+ function sortIsServedByTimestampPaging(order) {
26870
+ return order.by === "timestamp";
26871
+ }
26872
+ /** Certainty, or `null` when the row has none. Null and undefined are the same
26873
+ * answer — the relational backend surfaces an unset REAL column as SQL NULL. */
26874
+ function certaintyOf(face) {
26875
+ return face.suggestedMatchScore ?? null;
26876
+ }
26877
+ /** Newest first, then `id` ascending. The tail of every comparison. */
26878
+ function compareTiebreak(a, b) {
26879
+ if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
26880
+ if (a.id === b.id) return 0;
26881
+ return a.id < b.id ? -1 : 1;
26882
+ }
26883
+ function compareFaces(a, b, order) {
26884
+ if (order.by === "timestamp") {
26885
+ if (a.timestamp !== b.timestamp) return order.direction === "desc" ? b.timestamp - a.timestamp : a.timestamp - b.timestamp;
26886
+ return compareTiebreak(a, b);
26887
+ }
26888
+ const aScore = certaintyOf(a);
26889
+ const bScore = certaintyOf(b);
26890
+ if (aScore === null && bScore !== null) return 1;
26891
+ if (aScore !== null && bScore === null) return -1;
26892
+ if (aScore !== null && bScore !== null && aScore !== bScore) return order.direction === "desc" ? bScore - aScore : aScore - bScore;
26893
+ return compareTiebreak(a, b);
26894
+ }
26895
+ /** Immutable sort — the input array is never reordered in place. */
26896
+ function sortFaces(faces, order) {
26897
+ return faces.toSorted((a, b) => compareFaces(a, b, order));
26898
+ }
26899
+ /**
26900
+ * Rank `faces` and keep at most `limit` of them.
26901
+ *
26902
+ * This is what lets the confidence order stay bounded: a paged sweep folds each
26903
+ * page in through here, so the retained set is the caller's `limit`, never the
26904
+ * corpus — even though the sweep itself has to walk the whole window.
26905
+ */
26906
+ function rankAndCap(faces, order, limit) {
26907
+ const sorted = sortFaces(faces, order);
26908
+ return limit === void 0 ? sorted : sorted.slice(0, limit);
26909
+ }
26910
+ //#endregion
26911
+ //#region src/pipeline-analytics/store/face-store.ts
26912
+ /**
26913
+ * @durable class=ledger owner=pipeline-analytics
26914
+ * write="one UPSERT per track that produced a face detail — the id is
26915
+ * 'face-<trackId>', so a track has at most one row and the recognizer
26916
+ * overwrites it as a better read lands (embedding, match cosine, crop keys).
26917
+ * The row is rewritten again when the operator enrols it, which flips
26918
+ * `assigned` and stamps the identity."
26919
+ * retention="TRACK-OWNED and clockless (D61): unassigned rows leave with their
26920
+ * track through FaceStore.deleteByTracks, and enrolled (`assigned`) rows are
26921
+ * exempt from that and from every sweep — an assignment is a curation act.
26922
+ * The only other bound is capacity: pruneCapOverflow holds each camera's
26923
+ * UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50). At 10.8 KB
26924
+ * of JSON embedding per row, that cap is what keeps the table small."
26925
+ */
26926
+ var FACES_COLLECTION = "pipeline-analytics:faces";
26927
+ /**
26928
+ * Rows per page for the two whole-collection sweeps below.
26929
+ *
26930
+ * A face row is one of the two WIDEST row shapes in the hub's database: it
26931
+ * carries a JSON `embedding` on every single row, measured at **10.8 KB
26932
+ * average** where a motion event is 78 bytes. Both sweeps used to read the
26933
+ * collection in ONE statement with no `limit`.
26934
+ *
26935
+ * To be exact about what that did and did not cause: this table holds **372
26936
+ * rows / 4.0 MB** on the live hub, so it was *not* the read that killed
26937
+ * hub-main on 2026-08-07 (that was the multi-camera track feed — see
26938
+ * `RECENT_FANOUT_ROW_BUDGET` in `track-store.ts`). It is a full scan whose cost
26939
+ * grows with a table nothing else bounds, which is the same defect one incident
26940
+ * earlier. `pruneCapOverflow` is what keeps the row count small — and it was
26941
+ * itself one of the two unbounded readers, so the bound and the thing enforcing
26942
+ * it were the same loop.
26943
+ *
26944
+ * The engine now refuses an unbounded read (`query-bounds.ts`), so paging here
26945
+ * is not belt-and-braces: without it these two methods would silently see only
26946
+ * the newest N rows and the buffer would grow forever.
26947
+ *
26948
+ * 1 000 keeps peak transfer around 11 MB while staying under the engine's own
26949
+ * cap, so the page is always the caller's bound and never the engine's.
26950
+ */
26951
+ var FACE_SWEEP_PAGE = 1e3;
26952
+ /**
26953
+ * Refuses to loop forever if a page ever fails to advance. 1 000 pages is a
26954
+ * million faces — far past anything real, and reached only by a bug.
26955
+ */
26956
+ var FACE_SWEEP_MAX_PAGES = 1e3;
26957
+ var FACE_COLUMNS = [
26958
+ {
26959
+ name: "id",
26960
+ type: "TEXT",
26961
+ primaryKey: true,
26962
+ notNull: true
26963
+ },
26964
+ {
26965
+ name: "deviceId",
26966
+ type: "INTEGER",
26967
+ notNull: true
26968
+ },
26969
+ {
26970
+ name: "trackId",
26971
+ type: "TEXT",
26972
+ notNull: true
26973
+ },
26974
+ {
26975
+ name: "timestamp",
26976
+ type: "INTEGER",
26977
+ notNull: true
26978
+ },
26979
+ {
26980
+ name: "embedding",
26981
+ type: "JSON",
26982
+ notNull: true
26983
+ },
26984
+ {
26985
+ name: "embeddingModelId",
26986
+ type: "TEXT"
26987
+ },
26988
+ {
26989
+ name: "mediaKey",
26990
+ type: "TEXT"
26991
+ },
26992
+ {
26993
+ name: "recognizedIdentityId",
26994
+ type: "TEXT"
26995
+ },
26996
+ {
26997
+ name: "assigned",
26998
+ type: "BOOLEAN",
26999
+ notNull: true
27000
+ },
27001
+ {
27002
+ name: "assignedSampleId",
27003
+ type: "TEXT"
27004
+ },
27005
+ {
27006
+ name: "keyFrameMediaKey",
27007
+ type: "TEXT"
27008
+ },
27009
+ {
27010
+ name: "faceBbox",
27011
+ type: "JSON"
27012
+ },
27013
+ {
27014
+ name: "bestMatchScore",
27015
+ type: "REAL"
27016
+ },
27017
+ {
27018
+ name: "nativeFaceShortSidePx",
27019
+ type: "REAL"
27020
+ },
27021
+ {
27022
+ name: "suggestedIdentityId",
27023
+ type: "TEXT"
27024
+ },
27025
+ {
27026
+ name: "suggestedMatchScore",
27027
+ type: "REAL"
27028
+ }
27029
+ ];
27030
+ var FACE_INDEXES = [{
27031
+ name: "idx_faces_device_ts",
27032
+ columns: ["deviceId", "timestamp"]
27033
+ }, {
27034
+ name: "idx_faces_track",
27035
+ columns: ["trackId"]
27036
+ }];
27037
+ /**
27038
+ * `since` / `until` as a store predicate.
27039
+ *
27040
+ * The filter surface has exactly one range form, `whereBetween`, and it is
27041
+ * inclusive on both ends — so a one-sided window is expressed by pinning the
27042
+ * other end to the edge of the safe integer range rather than by omitting it.
27043
+ * Returns `{}` when the caller named no window, so the read stays exactly the
27044
+ * unwindowed one.
27045
+ */
27046
+ function buildTimestampWindow(input) {
27047
+ if (input.since === void 0 && input.until === void 0) return {};
27048
+ return { whereBetween: { timestamp: [input.since ?? Number.MIN_SAFE_INTEGER, input.until ?? Number.MAX_SAFE_INTEGER] } };
27049
+ }
27050
+ /**
27051
+ * One stored row → one {@link Face}.
27052
+ *
27053
+ * The single documented type boundary of this store: `SettingsRecord.data` is
27054
+ * `Record<string, unknown>` on the wire and the column set is declared right
27055
+ * here in {@link FACE_COLUMNS}, so this is the one place allowed to name the
27056
+ * shape it wrote.
27057
+ *
27058
+ * It also owns the NULL normalisation, and that is not cosmetic: the relational
27059
+ * backend surfaces an unset optional column as SQL NULL, and `FaceInfo`'s
27060
+ * optional strings REJECT null — an un-normalised row fails
27061
+ * `faceGallery.listRecentFaces` output validation and the People page renders
27062
+ * empty. This used to be copied out three times, which is three chances to
27063
+ * forget the next additive column.
27064
+ */
27065
+ function rowToFace(id, raw) {
27066
+ const data = raw;
27067
+ return {
27068
+ ...data,
27069
+ id,
27070
+ assigned: Boolean(data.assigned),
27071
+ mediaKey: data.mediaKey ?? void 0,
27072
+ recognizedIdentityId: data.recognizedIdentityId ?? void 0,
27073
+ assignedSampleId: data.assignedSampleId ?? void 0,
27074
+ keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
27075
+ faceBbox: data.faceBbox ?? void 0,
27076
+ bestMatchScore: data.bestMatchScore ?? void 0,
27077
+ nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
27078
+ suggestedIdentityId: data.suggestedIdentityId ?? void 0,
27079
+ suggestedMatchScore: data.suggestedMatchScore ?? void 0
27080
+ };
27081
+ }
27082
+ var FaceStore = class {
27083
+ store;
27084
+ logger;
27085
+ constructor(deps) {
27086
+ this.store = deps.store;
27087
+ this.logger = deps.logger;
27088
+ }
27089
+ static async declare(store) {
27090
+ await store.declareCollection.mutate({
27091
+ collection: FACES_COLLECTION,
27092
+ columns: [...FACE_COLUMNS],
27093
+ indexes: [...FACE_INDEXES]
27094
+ });
27095
+ }
27096
+ /**
27097
+ * Upsert one buffered face. Keyed by `face.id` (the worker uses one stable
27098
+ * id per track → one entry per track). `set` replaces an existing row.
27099
+ */
27100
+ async insert(face) {
27101
+ const { id, ...rest } = face;
27102
+ try {
27103
+ await this.store.set.mutate({
27104
+ collection: FACES_COLLECTION,
27105
+ key: id,
27106
+ value: rest
27107
+ });
27108
+ } catch (err) {
27109
+ this.logger.warn("FaceStore.insert failed", {
27110
+ tags: { deviceId: face.deviceId },
27111
+ meta: {
27112
+ faceId: id,
27113
+ error: String(err)
27114
+ }
27115
+ });
27116
+ }
27117
+ }
27118
+ /**
27119
+ * Recent UNASSIGNED faces (the enrollment buffer), newest first.
27120
+ *
27121
+ * NOTE: `assigned` is normalized via `Boolean()` on read so the production
27122
+ * SQLite backend (which returns INTEGER 0/1) is handled correctly.
27123
+ */
27124
+ async listRecentFaces(input) {
27125
+ const where = {};
27126
+ if (input.deviceId !== void 0) where["deviceId"] = input.deviceId;
27127
+ return (await this.store.query.query({
27128
+ collection: FACES_COLLECTION,
27129
+ filter: {
27130
+ where,
27131
+ orderBy: {
27132
+ field: "timestamp",
27133
+ direction: "desc"
27134
+ },
27135
+ limit: input.limit ?? 100
27136
+ }
27137
+ })).map((r) => rowToFace(r.id, r.data)).filter((f) => !f.assigned);
27138
+ }
27139
+ /**
27140
+ * The per-device `prune(deviceId, cutoffMs, maxPerDevice)` that used to sit
27141
+ * here is GONE (D61). It had no production caller — only tests — and it was
27142
+ * an age-keyed deletion of a track-OWNED entity, i.e. the exact thing the
27143
+ * retention model now forbids. A leftover that describes the wrong design
27144
+ * still reads as permission to use it.
27145
+ */
27146
+ /**
27147
+ * Hold the unassigned face buffer to `maxPerDevice` newest rows per camera.
27148
+ *
27149
+ * **This is a CAPACITY bound, not an age one.** It used to be both: a face
27150
+ * was deleted if it was older than `bufferRetentionDays` OR beyond the cap.
27151
+ * The age half was a fifth clock over a track-OWNED entity — a face row
27152
+ * carries a `trackId`, so it belongs to its track and cascades with it — and
27153
+ * it aged crops out from under tracks that were still well inside their own
27154
+ * retention window. It is gone (D61). What remains is the ring-buffer
27155
+ * property the recognizer actually depends on: the gallery of recent
27156
+ * unassigned faces stays small.
27157
+ *
27158
+ * ENROLLED (`assigned`) rows are skipped entirely and do not consume cap
27159
+ * slots. Returns all deleted ids (the caller removes their crops).
27160
+ */
27161
+ async pruneCapOverflow(input) {
27162
+ /**
27163
+ * Paged, not one statement. Only three scalars per row survive the page —
27164
+ * the `embedding` string that dominates the row is dropped as soon as the
27165
+ * page goes out of scope, so peak heap is one page rather than the whole
27166
+ * face table. The prune still considers EVERY row, which is what makes it a
27167
+ * capacity bound rather than a bound on the newest page.
27168
+ */
27169
+ const byDevice = /* @__PURE__ */ new Map();
27170
+ for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
27171
+ const rows = await this.store.query.query({
27172
+ collection: FACES_COLLECTION,
27173
+ filter: {
27174
+ orderBy: {
27175
+ field: "timestamp",
27176
+ direction: "desc"
27177
+ },
27178
+ limit: FACE_SWEEP_PAGE,
27179
+ offset: page * FACE_SWEEP_PAGE
27180
+ }
27181
+ });
27182
+ if (rows.length === 0) break;
27183
+ for (const r of rows) {
27184
+ const data = r.data;
27185
+ if (data.assigned) continue;
27186
+ const deviceId = Number(data.deviceId ?? -1);
27187
+ const list = byDevice.get(deviceId) ?? [];
27188
+ list.push({
27189
+ id: r.id,
27190
+ timestamp: Number(data.timestamp ?? 0)
27191
+ });
27192
+ byDevice.set(deviceId, list);
27193
+ }
27194
+ if (rows.length < 1e3) break;
27195
+ }
27196
+ const toDelete = /* @__PURE__ */ new Set();
27197
+ for (const list of byDevice.values()) {
27198
+ list.sort((a, b) => b.timestamp - a.timestamp);
27199
+ list.forEach((row, idx) => {
27200
+ if (idx >= input.maxPerDevice) toDelete.add(row.id);
27201
+ });
27202
+ }
27203
+ const deleted = [];
27204
+ for (const id of toDelete) try {
27205
+ await this.store.delete.mutate({
27206
+ collection: FACES_COLLECTION,
27207
+ key: id
27208
+ });
27209
+ deleted.push(id);
27210
+ } catch (err) {
27211
+ this.logger.warn("FaceStore.pruneCapOverflow delete failed", { meta: {
27212
+ faceId: id,
27213
+ error: String(err)
27214
+ } });
27215
+ }
27216
+ return deleted;
27217
+ }
27218
+ /** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
27219
+ async markAssigned(faceId, identityId, sampleId) {
27220
+ const data = {
27221
+ assigned: true,
27222
+ recognizedIdentityId: identityId
27223
+ };
27224
+ if (sampleId !== void 0) data["assignedSampleId"] = sampleId;
27225
+ try {
27226
+ await this.store.update.mutate({
27227
+ collection: FACES_COLLECTION,
27228
+ id: faceId,
27229
+ data
27230
+ });
27231
+ } catch (err) {
27232
+ this.logger.warn("FaceStore.markAssigned failed", { meta: {
27233
+ faceId,
27234
+ error: String(err)
27235
+ } });
27236
+ }
27237
+ }
27238
+ /** Clear the assignment: set assigned=false, clear recognizedIdentityId and assignedSampleId. */
27239
+ async clearAssignment(faceId) {
27240
+ try {
27241
+ await this.store.update.mutate({
27242
+ collection: FACES_COLLECTION,
27243
+ id: faceId,
27244
+ data: {
27245
+ assigned: false,
27246
+ recognizedIdentityId: null,
27247
+ assignedSampleId: null
27248
+ }
27249
+ });
27250
+ } catch (err) {
27251
+ this.logger.warn("FaceStore.clearAssignment failed", { meta: {
27252
+ faceId,
27253
+ error: String(err)
27254
+ } });
27255
+ }
27256
+ }
27257
+ /**
27258
+ * Persist a `face.rescoreTrack` result onto the SUGGESTED tier — the ONE
27259
+ * write that explicitly-requested action makes. `suggestion: null` means
27260
+ * the recompute found no candidate worth offering and CLEARS both columns
27261
+ * (a genuine "the gallery has nothing for this face right now" result, not
27262
+ * a no-op left at whatever they held before). Never touches `assigned` /
27263
+ * `recognizedIdentityId` — a suggestion is never an assignment. The caller
27264
+ * is responsible for never calling this with a non-comparable (`model-
27265
+ * mismatch`) report; this method persists whatever it is given.
27266
+ */
27267
+ async setSuggestion(faceId, suggestion) {
27268
+ const data = suggestion === null ? {
27269
+ suggestedIdentityId: null,
27270
+ suggestedMatchScore: null
27271
+ } : {
27272
+ suggestedIdentityId: suggestion.identityId,
27273
+ suggestedMatchScore: suggestion.score
27274
+ };
27275
+ try {
27276
+ await this.store.update.mutate({
27277
+ collection: FACES_COLLECTION,
27278
+ id: faceId,
27279
+ data
27280
+ });
27281
+ } catch (err) {
27282
+ this.logger.warn("FaceStore.setSuggestion failed", { meta: {
27283
+ faceId,
27284
+ error: String(err)
27285
+ } });
27286
+ }
27287
+ }
27288
+ /**
27289
+ * `TrackScopedStore` contract for the retention cascade: delete the buffered
27290
+ * face rows of the given tracks that are NOT assigned. Reuses the EXACT
27291
+ * `assigned` exemption that `prune`/`pruneAll` apply (see the
27292
+ * `!(r.data.assigned)` predicate above) so ENROLLED faces SURVIVE retention.
27293
+ * Per-track ISOLATED + best-effort per row — one bad track or row never
27294
+ * aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
27295
+ */
27296
+ async deleteByTracks(trackIds) {
27297
+ let removed = 0;
27298
+ for (const trackId of trackIds) try {
27299
+ const eligible = (await this.store.query.query({
27300
+ collection: FACES_COLLECTION,
27301
+ filter: { where: { trackId } }
27302
+ })).filter((r) => !r.data.assigned);
27303
+ for (const row of eligible) try {
27304
+ await this.store.delete.mutate({
27305
+ collection: FACES_COLLECTION,
27306
+ key: row.id
27307
+ });
27308
+ removed++;
27309
+ } catch (err) {
27310
+ this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
27311
+ faceId: row.id,
27312
+ error: String(err)
27313
+ } });
27314
+ }
27315
+ } catch (err) {
27316
+ this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
27317
+ trackId,
27318
+ error: String(err)
27319
+ } });
27320
+ }
27321
+ return [{
27322
+ family: "faces",
27323
+ rows: removed,
27324
+ bytes: 0
27325
+ }];
27326
+ }
27327
+ /** Delete a single buffered face row by id (its crop media is removed by the
27328
+ * caller — the FaceStore owns rows, not blobs). Best-effort. */
27329
+ async delete(faceId) {
27330
+ try {
27331
+ await this.store.delete.mutate({
27332
+ collection: FACES_COLLECTION,
27333
+ key: faceId
27334
+ });
27335
+ } catch (err) {
27336
+ this.logger.warn("FaceStore.delete failed", { meta: {
27337
+ faceId,
27338
+ error: String(err)
27339
+ } });
27340
+ }
27341
+ }
27342
+ /**
27343
+ * Retrieve a single face by id. Returns null if not found.
27344
+ */
27345
+ async get(faceId) {
27346
+ const raw = await this.store.get.query({
27347
+ collection: FACES_COLLECTION,
27348
+ key: faceId
27349
+ });
27350
+ if (!raw) return null;
27351
+ return rowToFace(faceId, raw);
27352
+ }
27353
+ /**
27354
+ * Update internal fields of a face (used for partial mutation, e.g. setting recognizedIdentityId).
27355
+ * Not exposed publicly — intended for internal use and tests only.
27356
+ */
27357
+ async update(faceId, data) {
27358
+ try {
27359
+ await this.store.update.mutate({
27360
+ collection: FACES_COLLECTION,
27361
+ id: faceId,
27362
+ data
27363
+ });
27364
+ } catch (err) {
27365
+ this.logger.warn("FaceStore.update failed", { meta: {
27366
+ faceId,
27367
+ error: String(err)
27368
+ } });
27369
+ }
27370
+ }
27371
+ /**
27372
+ * List recent faces across the cluster, or across a named set of cameras.
27373
+ *
27374
+ * filter:
27375
+ * - 'all' (default) → every row
27376
+ * - 'unassigned' → !assigned
27377
+ * - 'recognized' → !assigned && recognizedIdentityId != null
27378
+ * - 'identified' → recognizedIdentityId != null, including manual assignments
27379
+ *
27380
+ * `deviceIds`, `since` and `until` are pushed INTO the query — they narrow
27381
+ * what is read, not what is returned. `filter` and the ordering cannot be
27382
+ * (the first is a computed predicate, the second is a contract this backend
27383
+ * does not implement — see `face-sort.ts`), so they are applied here.
27384
+ *
27385
+ * **`limit` counts rows that PASSED the filter, never rows that were
27386
+ * discarded.** That is the whole reason this pages instead of asking the
27387
+ * store for `limit` rows: with `filter:'unassigned'` the newest N rows may be
27388
+ * entirely assigned while matches wait just beyond them.
27389
+ *
27390
+ * The previous answer was to fetch the WHOLE collection and slice after
27391
+ * filtering. That is the read that killed hub-main (`query-bounds.ts`) —
27392
+ * every face row carries a JSON `embedding`, so "all rows" is the widest
27393
+ * table in the database materialised at once, to render a gallery of at most
27394
+ * a few hundred tiles.
27395
+ *
27396
+ * So: PAGE. Under the default `timestamp` order the store's paging already
27397
+ * emits rows in the answer's order, so the sweep stops the moment enough
27398
+ * MATCHES exist and the common case costs one page. Under
27399
+ * `suggestionConfidence` it cannot — the most certain suggestion may be the
27400
+ * oldest row — so the sweep walks the window and folds each page through
27401
+ * `rankAndCap`, which holds the RETAINED set to `limit` even though the read
27402
+ * is wider. Narrow the read with `since` / `until`.
27403
+ *
27404
+ * An **empty `deviceIds` reads nothing at all**: `[]` is a request for no
27405
+ * cameras, not an omission (the `deviceManager.listFleet` contract).
27406
+ */
27407
+ async listAllRecentFaces(input) {
27408
+ if (input.deviceIds !== void 0 && input.deviceIds.length === 0) return [];
27409
+ const order = resolveFaceSort(input);
27410
+ const pagingServesTheOrder = sortIsServedByTimestampPaging(order);
27411
+ const filterMode = input.filter ?? "all";
27412
+ const matches = (f) => {
27413
+ if (filterMode === "unassigned") return !f.assigned;
27414
+ if (filterMode === "recognized") return !f.assigned && f.recognizedIdentityId != null;
27415
+ if (filterMode === "identified") return f.recognizedIdentityId != null;
27416
+ return true;
27417
+ };
27418
+ const storeFilter = {
27419
+ ...input.deviceIds !== void 0 ? { whereIn: { deviceId: [...new Set(input.deviceIds)] } } : {},
27420
+ ...buildTimestampWindow(input),
27421
+ orderBy: {
27422
+ field: "timestamp",
27423
+ direction: pagingServesTheOrder ? order.direction : "desc"
27424
+ }
27425
+ };
27426
+ let result = [];
27427
+ for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
27428
+ const rows = await this.store.query.query({
27429
+ collection: FACES_COLLECTION,
27430
+ filter: {
27431
+ ...storeFilter,
27432
+ limit: FACE_SWEEP_PAGE,
27433
+ offset: page * FACE_SWEEP_PAGE
27434
+ }
27435
+ });
27436
+ if (rows.length === 0) break;
27437
+ const matched = [];
27438
+ for (const r of rows) {
27439
+ const face = rowToFace(r.id, r.data);
27440
+ if (matches(face)) matched.push(face);
27441
+ }
27442
+ if (pagingServesTheOrder) {
27443
+ const merged = [...result, ...matched];
27444
+ if (input.limit !== void 0 && merged.length >= input.limit) return merged.slice(0, input.limit);
27445
+ result = merged;
27446
+ } else result = rankAndCap([...result, ...matched], order, input.limit);
27447
+ if (rows.length < 1e3) break;
27448
+ }
27449
+ return pagingServesTheOrder ? result : rankAndCap(result, order, input.limit);
27450
+ }
27451
+ /** One real face row per camera currently attributed to an identity. */
27452
+ async listCameraRefsForIdentity(identityId) {
27453
+ const byDevice = /* @__PURE__ */ new Map();
27454
+ for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
27455
+ const rows = await this.store.query.query({
27456
+ collection: FACES_COLLECTION,
27457
+ filter: {
27458
+ where: { recognizedIdentityId: identityId },
27459
+ orderBy: {
27460
+ field: "timestamp",
27461
+ direction: "desc"
27462
+ },
27463
+ limit: FACE_SWEEP_PAGE,
27464
+ offset: page * FACE_SWEEP_PAGE
27465
+ }
27466
+ });
27467
+ if (rows.length === 0) break;
27468
+ for (const row of rows) {
27469
+ const deviceId = Number(row.data["deviceId"]);
27470
+ if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
27471
+ }
27472
+ if (rows.length < 1e3) break;
27473
+ }
27474
+ return [...byDevice].map(([deviceId, faceId]) => ({
27475
+ deviceId,
27476
+ faceId
27477
+ }));
27478
+ }
27479
+ };
27480
+ //#endregion
26762
27481
  //#region src/pipeline-analytics/face-gallery-provider.ts
26763
27482
  /** Attribution of a face identity NAMED BY THE OPERATOR in the gallery. */
26764
27483
  var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
@@ -26772,6 +27491,36 @@ var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
26772
27491
  * stalled pipeline.
26773
27492
  */
26774
27493
  var FACE_CROP_FETCH_CONCURRENCY = 16;
27494
+ /**
27495
+ * How many unassigned faces `suggestFaceClusters` reads when the caller names
27496
+ * no {@link SuggestFaceClustersInput.maxFacesScanned}.
27497
+ *
27498
+ * Clustering used to read EVERY unassigned face on the hub, unconditionally:
27499
+ * its only bound (`limit`) cut the finished clusters, so it shortened the
27500
+ * ANSWER and never the work. A UI showing a window of 100 paid for a scan of
27501
+ * the whole corpus — on an addon whose disk is under contention — and no caller
27502
+ * had any way to ask for less.
27503
+ *
27504
+ * `FACE_SWEEP_PAGE` is chosen on purpose rather than a round number: it is
27505
+ * exactly ONE store page, so on the live corpus (372 face rows) the default
27506
+ * reads what it read before, one page, and nothing about today's answers
27507
+ * changes. What changes is the ceiling — the unbounded scan cannot come back as
27508
+ * the table grows.
27509
+ */
27510
+ var SUGGEST_CLUSTERS_DEFAULT_FACE_POOL = FACE_SWEEP_PAGE;
27511
+ /**
27512
+ * The camera set a face listing is scoped to, as ONE value.
27513
+ *
27514
+ * `deviceIds` is the authority whenever it is present — including when it is
27515
+ * EMPTY, which reads nothing (a request for no cameras is a request, not an
27516
+ * omission). `deviceId` is the legacy single-camera form and only applies when
27517
+ * no set was named, so the two can never disagree about the answer.
27518
+ */
27519
+ function resolveFaceDeviceScope(input) {
27520
+ if (input?.deviceIds !== void 0) return { deviceIds: input.deviceIds };
27521
+ if (input?.deviceId !== void 0) return { deviceIds: [input.deviceId] };
27522
+ return {};
27523
+ }
26775
27524
  var FaceGalleryProvider = class {
26776
27525
  identityStore;
26777
27526
  faceStore;
@@ -26907,9 +27656,13 @@ var FaceGalleryProvider = class {
26907
27656
  }
26908
27657
  async listRecentFaces(input) {
26909
27658
  const faces = await this.faceStore.listAllRecentFaces({
26910
- deviceId: input?.deviceId,
27659
+ ...resolveFaceDeviceScope(input),
26911
27660
  limit: input?.limit,
26912
- filter: input?.filter
27661
+ filter: input?.filter,
27662
+ since: input?.since,
27663
+ until: input?.until,
27664
+ sortBy: input?.sortBy,
27665
+ sortDirection: input?.sortDirection
26913
27666
  });
26914
27667
  const identities = await this.identityStore.listIdentities();
26915
27668
  const nameMap = new Map(identities.map((i) => [i.id, i.name]));
@@ -27165,16 +27918,34 @@ var FaceGalleryProvider = class {
27165
27918
  * Suggest clusters of look-alike UNASSIGNED faces (greedy cosine on their
27166
27919
  * embeddings) so the operator can batch-assign a whole group. Embeddings stay
27167
27920
  * server-side; only faceId groupings are returned.
27921
+ *
27922
+ * TWO bounds, and they cut different things — which is the whole point of the
27923
+ * rename:
27924
+ * - `maxFacesScanned` bounds the POOL, i.e. the work. Newest unassigned
27925
+ * faces first, default {@link SUGGEST_CLUSTERS_DEFAULT_FACE_POOL}.
27926
+ * - `maxClusters` (formerly the ambiguous `limit`) bounds the ANSWER.
27927
+ * It has never made this method read one row less.
27168
27928
  */
27169
27929
  async suggestFaceClusters(input) {
27170
27930
  const threshold = input?.threshold ?? .5;
27171
27931
  const minClusterSize = input?.minClusterSize ?? 2;
27172
- let clusters = clusterByEmbedding((await this.faceStore.listAllRecentFaces({ filter: "unassigned" })).filter((f) => Array.isArray(f.embedding) && f.embedding.length > 0).map((f) => ({
27932
+ const maxFacesScanned = input?.maxFacesScanned ?? SUGGEST_CLUSTERS_DEFAULT_FACE_POOL;
27933
+ const maxClusters = input?.maxClusters ?? input?.limit;
27934
+ const faces = await this.faceStore.listAllRecentFaces({
27935
+ filter: "unassigned",
27936
+ limit: maxFacesScanned
27937
+ });
27938
+ if (faces.length >= maxFacesScanned) this.logger.info("suggestFaceClusters pool saturated — older faces not considered", { meta: {
27939
+ maxFacesScanned,
27940
+ scanned: faces.length,
27941
+ threshold,
27942
+ minClusterSize
27943
+ } });
27944
+ const clusters = clusterByEmbedding(faces.filter((f) => Array.isArray(f.embedding) && f.embedding.length > 0).map((f) => ({
27173
27945
  id: f.id,
27174
27946
  embedding: f.embedding
27175
27947
  })), threshold).filter((c) => c.size >= minClusterSize);
27176
- if (input?.limit !== void 0) clusters = clusters.slice(0, input.limit);
27177
- return clusters;
27948
+ return maxClusters === void 0 ? clusters : clusters.slice(0, maxClusters);
27178
27949
  }
27179
27950
  };
27180
27951
  //#endregion
@@ -47419,553 +48190,6 @@ function stripNulls$1(data) {
47419
48190
  return out;
47420
48191
  }
47421
48192
  //#endregion
47422
- //#region src/pipeline-analytics/store/face-store.ts
47423
- /**
47424
- * @durable class=ledger owner=pipeline-analytics
47425
- * write="one UPSERT per track that produced a face detail — the id is
47426
- * 'face-<trackId>', so a track has at most one row and the recognizer
47427
- * overwrites it as a better read lands (embedding, match cosine, crop keys).
47428
- * The row is rewritten again when the operator enrols it, which flips
47429
- * `assigned` and stamps the identity."
47430
- * retention="TRACK-OWNED and clockless (D61): unassigned rows leave with their
47431
- * track through FaceStore.deleteByTracks, and enrolled (`assigned`) rows are
47432
- * exempt from that and from every sweep — an assignment is a curation act.
47433
- * The only other bound is capacity: pruneCapOverflow holds each camera's
47434
- * UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50). At 10.8 KB
47435
- * of JSON embedding per row, that cap is what keeps the table small."
47436
- */
47437
- var FACES_COLLECTION = "pipeline-analytics:faces";
47438
- /**
47439
- * Rows per page for the two whole-collection sweeps below.
47440
- *
47441
- * A face row is one of the two WIDEST row shapes in the hub's database: it
47442
- * carries a JSON `embedding` on every single row, measured at **10.8 KB
47443
- * average** where a motion event is 78 bytes. Both sweeps used to read the
47444
- * collection in ONE statement with no `limit`.
47445
- *
47446
- * To be exact about what that did and did not cause: this table holds **372
47447
- * rows / 4.0 MB** on the live hub, so it was *not* the read that killed
47448
- * hub-main on 2026-08-07 (that was the multi-camera track feed — see
47449
- * `RECENT_FANOUT_ROW_BUDGET` in `track-store.ts`). It is a full scan whose cost
47450
- * grows with a table nothing else bounds, which is the same defect one incident
47451
- * earlier. `pruneCapOverflow` is what keeps the row count small — and it was
47452
- * itself one of the two unbounded readers, so the bound and the thing enforcing
47453
- * it were the same loop.
47454
- *
47455
- * The engine now refuses an unbounded read (`query-bounds.ts`), so paging here
47456
- * is not belt-and-braces: without it these two methods would silently see only
47457
- * the newest N rows and the buffer would grow forever.
47458
- *
47459
- * 1 000 keeps peak transfer around 11 MB while staying under the engine's own
47460
- * cap, so the page is always the caller's bound and never the engine's.
47461
- */
47462
- var FACE_SWEEP_PAGE = 1e3;
47463
- /**
47464
- * Refuses to loop forever if a page ever fails to advance. 1 000 pages is a
47465
- * million faces — far past anything real, and reached only by a bug.
47466
- */
47467
- var FACE_SWEEP_MAX_PAGES = 1e3;
47468
- var FACE_COLUMNS = [
47469
- {
47470
- name: "id",
47471
- type: "TEXT",
47472
- primaryKey: true,
47473
- notNull: true
47474
- },
47475
- {
47476
- name: "deviceId",
47477
- type: "INTEGER",
47478
- notNull: true
47479
- },
47480
- {
47481
- name: "trackId",
47482
- type: "TEXT",
47483
- notNull: true
47484
- },
47485
- {
47486
- name: "timestamp",
47487
- type: "INTEGER",
47488
- notNull: true
47489
- },
47490
- {
47491
- name: "embedding",
47492
- type: "JSON",
47493
- notNull: true
47494
- },
47495
- {
47496
- name: "embeddingModelId",
47497
- type: "TEXT"
47498
- },
47499
- {
47500
- name: "mediaKey",
47501
- type: "TEXT"
47502
- },
47503
- {
47504
- name: "recognizedIdentityId",
47505
- type: "TEXT"
47506
- },
47507
- {
47508
- name: "assigned",
47509
- type: "BOOLEAN",
47510
- notNull: true
47511
- },
47512
- {
47513
- name: "assignedSampleId",
47514
- type: "TEXT"
47515
- },
47516
- {
47517
- name: "keyFrameMediaKey",
47518
- type: "TEXT"
47519
- },
47520
- {
47521
- name: "faceBbox",
47522
- type: "JSON"
47523
- },
47524
- {
47525
- name: "bestMatchScore",
47526
- type: "REAL"
47527
- },
47528
- {
47529
- name: "nativeFaceShortSidePx",
47530
- type: "REAL"
47531
- },
47532
- {
47533
- name: "suggestedIdentityId",
47534
- type: "TEXT"
47535
- },
47536
- {
47537
- name: "suggestedMatchScore",
47538
- type: "REAL"
47539
- }
47540
- ];
47541
- var FACE_INDEXES = [{
47542
- name: "idx_faces_device_ts",
47543
- columns: ["deviceId", "timestamp"]
47544
- }, {
47545
- name: "idx_faces_track",
47546
- columns: ["trackId"]
47547
- }];
47548
- var FaceStore = class {
47549
- store;
47550
- logger;
47551
- constructor(deps) {
47552
- this.store = deps.store;
47553
- this.logger = deps.logger;
47554
- }
47555
- static async declare(store) {
47556
- await store.declareCollection.mutate({
47557
- collection: FACES_COLLECTION,
47558
- columns: [...FACE_COLUMNS],
47559
- indexes: [...FACE_INDEXES]
47560
- });
47561
- }
47562
- /**
47563
- * Upsert one buffered face. Keyed by `face.id` (the worker uses one stable
47564
- * id per track → one entry per track). `set` replaces an existing row.
47565
- */
47566
- async insert(face) {
47567
- const { id, ...rest } = face;
47568
- try {
47569
- await this.store.set.mutate({
47570
- collection: FACES_COLLECTION,
47571
- key: id,
47572
- value: rest
47573
- });
47574
- } catch (err) {
47575
- this.logger.warn("FaceStore.insert failed", {
47576
- tags: { deviceId: face.deviceId },
47577
- meta: {
47578
- faceId: id,
47579
- error: String(err)
47580
- }
47581
- });
47582
- }
47583
- }
47584
- /**
47585
- * Recent UNASSIGNED faces (the enrollment buffer), newest first.
47586
- *
47587
- * NOTE: `assigned` is normalized via `Boolean()` on read so the production
47588
- * SQLite backend (which returns INTEGER 0/1) is handled correctly.
47589
- */
47590
- async listRecentFaces(input) {
47591
- const where = {};
47592
- if (input.deviceId !== void 0) where["deviceId"] = input.deviceId;
47593
- return (await this.store.query.query({
47594
- collection: FACES_COLLECTION,
47595
- filter: {
47596
- where,
47597
- orderBy: {
47598
- field: "timestamp",
47599
- direction: "desc"
47600
- },
47601
- limit: input.limit ?? 100
47602
- }
47603
- })).map((r) => {
47604
- const data = r.data;
47605
- return {
47606
- id: r.id,
47607
- ...data,
47608
- assigned: Boolean(r.data.assigned),
47609
- mediaKey: data.mediaKey ?? void 0,
47610
- recognizedIdentityId: data.recognizedIdentityId ?? void 0,
47611
- assignedSampleId: data.assignedSampleId ?? void 0,
47612
- keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
47613
- faceBbox: data.faceBbox ?? void 0,
47614
- bestMatchScore: data.bestMatchScore ?? void 0,
47615
- nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
47616
- suggestedIdentityId: data.suggestedIdentityId ?? void 0,
47617
- suggestedMatchScore: data.suggestedMatchScore ?? void 0
47618
- };
47619
- }).filter((f) => !f.assigned);
47620
- }
47621
- /**
47622
- * The per-device `prune(deviceId, cutoffMs, maxPerDevice)` that used to sit
47623
- * here is GONE (D61). It had no production caller — only tests — and it was
47624
- * an age-keyed deletion of a track-OWNED entity, i.e. the exact thing the
47625
- * retention model now forbids. A leftover that describes the wrong design
47626
- * still reads as permission to use it.
47627
- */
47628
- /**
47629
- * Hold the unassigned face buffer to `maxPerDevice` newest rows per camera.
47630
- *
47631
- * **This is a CAPACITY bound, not an age one.** It used to be both: a face
47632
- * was deleted if it was older than `bufferRetentionDays` OR beyond the cap.
47633
- * The age half was a fifth clock over a track-OWNED entity — a face row
47634
- * carries a `trackId`, so it belongs to its track and cascades with it — and
47635
- * it aged crops out from under tracks that were still well inside their own
47636
- * retention window. It is gone (D61). What remains is the ring-buffer
47637
- * property the recognizer actually depends on: the gallery of recent
47638
- * unassigned faces stays small.
47639
- *
47640
- * ENROLLED (`assigned`) rows are skipped entirely and do not consume cap
47641
- * slots. Returns all deleted ids (the caller removes their crops).
47642
- */
47643
- async pruneCapOverflow(input) {
47644
- /**
47645
- * Paged, not one statement. Only three scalars per row survive the page —
47646
- * the `embedding` string that dominates the row is dropped as soon as the
47647
- * page goes out of scope, so peak heap is one page rather than the whole
47648
- * face table. The prune still considers EVERY row, which is what makes it a
47649
- * capacity bound rather than a bound on the newest page.
47650
- */
47651
- const byDevice = /* @__PURE__ */ new Map();
47652
- for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
47653
- const rows = await this.store.query.query({
47654
- collection: FACES_COLLECTION,
47655
- filter: {
47656
- orderBy: {
47657
- field: "timestamp",
47658
- direction: "desc"
47659
- },
47660
- limit: FACE_SWEEP_PAGE,
47661
- offset: page * FACE_SWEEP_PAGE
47662
- }
47663
- });
47664
- if (rows.length === 0) break;
47665
- for (const r of rows) {
47666
- const data = r.data;
47667
- if (data.assigned) continue;
47668
- const deviceId = Number(data.deviceId ?? -1);
47669
- const list = byDevice.get(deviceId) ?? [];
47670
- list.push({
47671
- id: r.id,
47672
- timestamp: Number(data.timestamp ?? 0)
47673
- });
47674
- byDevice.set(deviceId, list);
47675
- }
47676
- if (rows.length < 1e3) break;
47677
- }
47678
- const toDelete = /* @__PURE__ */ new Set();
47679
- for (const list of byDevice.values()) {
47680
- list.sort((a, b) => b.timestamp - a.timestamp);
47681
- list.forEach((row, idx) => {
47682
- if (idx >= input.maxPerDevice) toDelete.add(row.id);
47683
- });
47684
- }
47685
- const deleted = [];
47686
- for (const id of toDelete) try {
47687
- await this.store.delete.mutate({
47688
- collection: FACES_COLLECTION,
47689
- key: id
47690
- });
47691
- deleted.push(id);
47692
- } catch (err) {
47693
- this.logger.warn("FaceStore.pruneCapOverflow delete failed", { meta: {
47694
- faceId: id,
47695
- error: String(err)
47696
- } });
47697
- }
47698
- return deleted;
47699
- }
47700
- /** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
47701
- async markAssigned(faceId, identityId, sampleId) {
47702
- const data = {
47703
- assigned: true,
47704
- recognizedIdentityId: identityId
47705
- };
47706
- if (sampleId !== void 0) data["assignedSampleId"] = sampleId;
47707
- try {
47708
- await this.store.update.mutate({
47709
- collection: FACES_COLLECTION,
47710
- id: faceId,
47711
- data
47712
- });
47713
- } catch (err) {
47714
- this.logger.warn("FaceStore.markAssigned failed", { meta: {
47715
- faceId,
47716
- error: String(err)
47717
- } });
47718
- }
47719
- }
47720
- /** Clear the assignment: set assigned=false, clear recognizedIdentityId and assignedSampleId. */
47721
- async clearAssignment(faceId) {
47722
- try {
47723
- await this.store.update.mutate({
47724
- collection: FACES_COLLECTION,
47725
- id: faceId,
47726
- data: {
47727
- assigned: false,
47728
- recognizedIdentityId: null,
47729
- assignedSampleId: null
47730
- }
47731
- });
47732
- } catch (err) {
47733
- this.logger.warn("FaceStore.clearAssignment failed", { meta: {
47734
- faceId,
47735
- error: String(err)
47736
- } });
47737
- }
47738
- }
47739
- /**
47740
- * Persist a `face.rescoreTrack` result onto the SUGGESTED tier — the ONE
47741
- * write that explicitly-requested action makes. `suggestion: null` means
47742
- * the recompute found no candidate worth offering and CLEARS both columns
47743
- * (a genuine "the gallery has nothing for this face right now" result, not
47744
- * a no-op left at whatever they held before). Never touches `assigned` /
47745
- * `recognizedIdentityId` — a suggestion is never an assignment. The caller
47746
- * is responsible for never calling this with a non-comparable (`model-
47747
- * mismatch`) report; this method persists whatever it is given.
47748
- */
47749
- async setSuggestion(faceId, suggestion) {
47750
- const data = suggestion === null ? {
47751
- suggestedIdentityId: null,
47752
- suggestedMatchScore: null
47753
- } : {
47754
- suggestedIdentityId: suggestion.identityId,
47755
- suggestedMatchScore: suggestion.score
47756
- };
47757
- try {
47758
- await this.store.update.mutate({
47759
- collection: FACES_COLLECTION,
47760
- id: faceId,
47761
- data
47762
- });
47763
- } catch (err) {
47764
- this.logger.warn("FaceStore.setSuggestion failed", { meta: {
47765
- faceId,
47766
- error: String(err)
47767
- } });
47768
- }
47769
- }
47770
- /**
47771
- * `TrackScopedStore` contract for the retention cascade: delete the buffered
47772
- * face rows of the given tracks that are NOT assigned. Reuses the EXACT
47773
- * `assigned` exemption that `prune`/`pruneAll` apply (see the
47774
- * `!(r.data.assigned)` predicate above) so ENROLLED faces SURVIVE retention.
47775
- * Per-track ISOLATED + best-effort per row — one bad track or row never
47776
- * aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
47777
- */
47778
- async deleteByTracks(trackIds) {
47779
- let removed = 0;
47780
- for (const trackId of trackIds) try {
47781
- const eligible = (await this.store.query.query({
47782
- collection: FACES_COLLECTION,
47783
- filter: { where: { trackId } }
47784
- })).filter((r) => !r.data.assigned);
47785
- for (const row of eligible) try {
47786
- await this.store.delete.mutate({
47787
- collection: FACES_COLLECTION,
47788
- key: row.id
47789
- });
47790
- removed++;
47791
- } catch (err) {
47792
- this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
47793
- faceId: row.id,
47794
- error: String(err)
47795
- } });
47796
- }
47797
- } catch (err) {
47798
- this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
47799
- trackId,
47800
- error: String(err)
47801
- } });
47802
- }
47803
- return [{
47804
- family: "faces",
47805
- rows: removed,
47806
- bytes: 0
47807
- }];
47808
- }
47809
- /** Delete a single buffered face row by id (its crop media is removed by the
47810
- * caller — the FaceStore owns rows, not blobs). Best-effort. */
47811
- async delete(faceId) {
47812
- try {
47813
- await this.store.delete.mutate({
47814
- collection: FACES_COLLECTION,
47815
- key: faceId
47816
- });
47817
- } catch (err) {
47818
- this.logger.warn("FaceStore.delete failed", { meta: {
47819
- faceId,
47820
- error: String(err)
47821
- } });
47822
- }
47823
- }
47824
- /**
47825
- * Retrieve a single face by id. Returns null if not found.
47826
- */
47827
- async get(faceId) {
47828
- const raw = await this.store.get.query({
47829
- collection: FACES_COLLECTION,
47830
- key: faceId
47831
- });
47832
- if (!raw) return null;
47833
- const data = raw;
47834
- return {
47835
- id: faceId,
47836
- ...data,
47837
- assigned: Boolean(raw.assigned),
47838
- mediaKey: data.mediaKey ?? void 0,
47839
- recognizedIdentityId: data.recognizedIdentityId ?? void 0,
47840
- assignedSampleId: data.assignedSampleId ?? void 0,
47841
- keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
47842
- faceBbox: data.faceBbox ?? void 0,
47843
- bestMatchScore: data.bestMatchScore ?? void 0,
47844
- nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
47845
- suggestedIdentityId: data.suggestedIdentityId ?? void 0,
47846
- suggestedMatchScore: data.suggestedMatchScore ?? void 0
47847
- };
47848
- }
47849
- /**
47850
- * Update internal fields of a face (used for partial mutation, e.g. setting recognizedIdentityId).
47851
- * Not exposed publicly — intended for internal use and tests only.
47852
- */
47853
- async update(faceId, data) {
47854
- try {
47855
- await this.store.update.mutate({
47856
- collection: FACES_COLLECTION,
47857
- id: faceId,
47858
- data
47859
- });
47860
- } catch (err) {
47861
- this.logger.warn("FaceStore.update failed", { meta: {
47862
- faceId,
47863
- error: String(err)
47864
- } });
47865
- }
47866
- }
47867
- /**
47868
- * List ALL recent faces across all devices, newest-first.
47869
- *
47870
- * filter:
47871
- * - 'all' (default) → every row
47872
- * - 'unassigned' → !assigned
47873
- * - 'recognized' → !assigned && recognizedIdentityId != null
47874
- * - 'identified' → recognizedIdentityId != null, including manual assignments
47875
- */
47876
- async listAllRecentFaces(input) {
47877
- /**
47878
- * The filter is applied in memory, so a DB `limit` of N cannot be the
47879
- * caller's N: with `filter:'unassigned'` the newest N rows may be entirely
47880
- * assigned while matches wait just beyond them.
47881
- *
47882
- * The previous answer was to fetch the WHOLE collection and slice after
47883
- * filtering. That is the read that killed hub-main (`query-bounds.ts`) —
47884
- * every face row carries a JSON `embedding`, so "all rows" is the widest
47885
- * table in the database materialised at once, to render a gallery of at
47886
- * most a few hundred tiles.
47887
- *
47888
- * PAGE instead, and stop as soon as enough MATCHES exist. The pathological
47889
- * case (a filter that matches nothing) walks the whole collection exactly
47890
- * as before but one bounded page at a time; the common case stops after the
47891
- * first page. Semantics are unchanged: the limit is still applied after the
47892
- * filter, never before it.
47893
- */
47894
- const filterMode = input.filter ?? "all";
47895
- const matches = (f) => {
47896
- if (filterMode === "unassigned") return !f.assigned;
47897
- if (filterMode === "recognized") return !f.assigned && f.recognizedIdentityId != null;
47898
- if (filterMode === "identified") return f.recognizedIdentityId != null;
47899
- return true;
47900
- };
47901
- const result = [];
47902
- for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
47903
- const rows = await this.store.query.query({
47904
- collection: FACES_COLLECTION,
47905
- filter: {
47906
- ...input.deviceId !== void 0 ? { where: { deviceId: input.deviceId } } : {},
47907
- orderBy: {
47908
- field: "timestamp",
47909
- direction: "desc"
47910
- },
47911
- limit: FACE_SWEEP_PAGE,
47912
- offset: page * FACE_SWEEP_PAGE
47913
- }
47914
- });
47915
- if (rows.length === 0) break;
47916
- for (const r of rows) {
47917
- const data = r.data;
47918
- const face = {
47919
- id: r.id,
47920
- ...data,
47921
- assigned: Boolean(r.data.assigned),
47922
- mediaKey: data.mediaKey ?? void 0,
47923
- recognizedIdentityId: data.recognizedIdentityId ?? void 0,
47924
- assignedSampleId: data.assignedSampleId ?? void 0,
47925
- keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
47926
- faceBbox: data.faceBbox ?? void 0,
47927
- bestMatchScore: data.bestMatchScore ?? void 0,
47928
- nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
47929
- suggestedIdentityId: data.suggestedIdentityId ?? void 0,
47930
- suggestedMatchScore: data.suggestedMatchScore ?? void 0
47931
- };
47932
- if (matches(face)) result.push(face);
47933
- if (input.limit !== void 0 && result.length >= input.limit) return result;
47934
- }
47935
- if (rows.length < 1e3) break;
47936
- }
47937
- return result;
47938
- }
47939
- /** One real face row per camera currently attributed to an identity. */
47940
- async listCameraRefsForIdentity(identityId) {
47941
- const byDevice = /* @__PURE__ */ new Map();
47942
- for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
47943
- const rows = await this.store.query.query({
47944
- collection: FACES_COLLECTION,
47945
- filter: {
47946
- where: { recognizedIdentityId: identityId },
47947
- orderBy: {
47948
- field: "timestamp",
47949
- direction: "desc"
47950
- },
47951
- limit: FACE_SWEEP_PAGE,
47952
- offset: page * FACE_SWEEP_PAGE
47953
- }
47954
- });
47955
- if (rows.length === 0) break;
47956
- for (const row of rows) {
47957
- const deviceId = Number(row.data["deviceId"]);
47958
- if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
47959
- }
47960
- if (rows.length < 1e3) break;
47961
- }
47962
- return [...byDevice].map(([deviceId, faceId]) => ({
47963
- deviceId,
47964
- faceId
47965
- }));
47966
- }
47967
- };
47968
- //#endregion
47969
48193
  //#region src/pipeline-analytics/store/identity-store.ts
47970
48194
  /**
47971
48195
  * IdentityStore — per-person identity registry for face recognition.
@@ -60159,6 +60383,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
60159
60383
  cancelVision: async (requestId) => {
60160
60384
  await api.llm.cancel.mutate({ requestId });
60161
60385
  },
60386
+ resolveArtifactUrl: async (artifactId) => await this.ncArtifactPlane?.mintUrl(artifactId) ?? null,
60162
60387
  mintActionUrl: (input) => this.ncActionMintUrl?.(input) ?? Promise.resolve(null),
60163
60388
  readDeviceStates: async (ids) => {
60164
60389
  const out = /* @__PURE__ */ new Map();