@camstack/addon-export-alexa 1.2.22 → 1.2.24

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.
@@ -11085,6 +11085,8 @@ var QueryFilterSchema = object({
11085
11085
  where: record(string(), unknown()).optional(),
11086
11086
  whereIn: record(string(), array(unknown())).optional(),
11087
11087
  whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
11088
+ /** NULL-safe exclusion: matches rows whose field is NULL OR != the value. */
11089
+ whereNot: record(string(), unknown()).optional(),
11088
11090
  orderBy: object({
11089
11091
  field: string(),
11090
11092
  direction: _enum(["asc", "desc"])
@@ -11104,7 +11106,8 @@ var QueryFilterSchema = object({
11104
11106
  var MutationFilterSchema = object({
11105
11107
  where: record(string(), unknown()).optional(),
11106
11108
  whereIn: record(string(), array(unknown())).optional(),
11107
- whereBetween: record(string(), tuple([unknown(), unknown()])).optional()
11109
+ whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
11110
+ whereNot: record(string(), unknown()).optional()
11108
11111
  });
11109
11112
  /** A single stored record: `{ id, data }`. */
11110
11113
  var SettingsRecordSchema = object({
@@ -17011,7 +17014,10 @@ var RecentTracksQueryInput = object({
17011
17014
  * Encodes the (lastSeen, trackId) sort position — treat as opaque. */
17012
17015
  cursor: string().optional(),
17013
17016
  /** See {@link TrackProjectionSchema}. Default `full`. */
17014
- projection: TrackProjectionSchema.optional()
17017
+ projection: TrackProjectionSchema.optional(),
17018
+ /** Include stationary-promoted rows (parked objects). Default false: the
17019
+ * feed lists passages; parking records live on the stationary registry. */
17020
+ includeStationary: boolean().optional()
17015
17021
  });
17016
17022
  var RecentTracksPageSchema = object({
17017
17023
  /** Merged page, ordered by (`lastSeen` DESC, `trackId` DESC). */
@@ -17229,7 +17235,11 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
17229
17235
  zone: TrackZoneFilterSchema.optional(),
17230
17236
  /** See {@link TrackProjectionSchema}. Default `full` (backward
17231
17237
  * compatible — omitting the field keeps today's exact behaviour). */
17232
- projection: TrackProjectionSchema.optional()
17238
+ projection: TrackProjectionSchema.optional(),
17239
+ /** Include stationary-promoted rows (parked objects handed to the
17240
+ * stationary registry). Default false: the timeline lists passages,
17241
+ * not parking records (operator decision, 2026-08-15). */
17242
+ includeStationary: boolean().optional()
17233
17243
  }), array(TrackSchema).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(object({ deviceId: number() }), _void(), {
17234
17244
  kind: "mutation",
17235
17245
  auth: "admin"
@@ -17393,11 +17403,16 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
17393
17403
  auth: "admin"
17394
17404
  }), method(object({
17395
17405
  eventId: string(),
17396
- kind: MediaFileKindEnum.optional()
17406
+ kind: MediaFileKindEnum.optional(),
17407
+ deviceId: number()
17408
+ }), array(MediaFileSchema).readonly()), method(object({
17409
+ trackId: string(),
17410
+ kinds: array(MediaFileKindEnum).optional(),
17411
+ deviceId: number()
17397
17412
  }), array(MediaFileSchema).readonly()), method(object({
17398
17413
  trackId: string(),
17399
- kinds: array(MediaFileKindEnum).optional()
17400
- }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
17414
+ deviceId: number()
17415
+ }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
17401
17416
  kind: "mutation",
17402
17417
  auth: "admin"
17403
17418
  }), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
@@ -19376,7 +19391,16 @@ targets: array(object({
19376
19391
  /** A sleeping battery camera: the frame is deliberately stale and will
19377
19392
  * NOT refresh in the background. A surface should say so rather than
19378
19393
  * present it as current. */
19379
- sleeping: boolean()
19394
+ sleeping: boolean(),
19395
+ /** Current device state rendered over the cached frame. State images
19396
+ * remain authoritative even when their photographic background is
19397
+ * old; null means the link must carry a current camera frame. */
19398
+ stateReason: _enum([
19399
+ "disabled",
19400
+ "sleeping",
19401
+ "unreachable",
19402
+ "waking"
19403
+ ]).nullable()
19380
19404
  })));
19381
19405
  /**
19382
19406
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
@@ -32634,6 +32658,11 @@ Object.freeze({
32634
32658
  form: "single",
32635
32659
  optional: false
32636
32660
  }],
32661
+ "pipelineAnalytics.getEventMedia": [{
32662
+ name: "deviceId",
32663
+ form: "single",
32664
+ optional: false
32665
+ }],
32637
32666
  "pipelineAnalytics.getKeyEvents": [{
32638
32667
  name: "deviceId",
32639
32668
  form: "single",
@@ -32664,6 +32693,11 @@ Object.freeze({
32664
32693
  form: "single",
32665
32694
  optional: false
32666
32695
  }],
32696
+ "pipelineAnalytics.getTrackMedia": [{
32697
+ name: "deviceId",
32698
+ form: "single",
32699
+ optional: false
32700
+ }],
32667
32701
  "pipelineAnalytics.getTrainingExportSummary": [{
32668
32702
  name: "deviceIds",
32669
32703
  form: "array",
@@ -32699,6 +32733,11 @@ Object.freeze({
32699
32733
  form: "array",
32700
32734
  optional: true
32701
32735
  }],
32736
+ "pipelineAnalytics.listTrackMedia": [{
32737
+ name: "deviceId",
32738
+ form: "single",
32739
+ optional: false
32740
+ }],
32702
32741
  "pipelineAnalytics.listTracks": [{
32703
32742
  name: "deviceId",
32704
32743
  form: "single",
@@ -33139,6 +33178,12 @@ Object.freeze({
33139
33178
  form: "single",
33140
33179
  optional: false
33141
33180
  }],
33181
+ "snapshot.getSnapshotLinks": [{
33182
+ name: "targets",
33183
+ form: "object-array",
33184
+ optional: false,
33185
+ itemField: "deviceId"
33186
+ }],
33142
33187
  "snapshot.getSnapshotOverview": [{
33143
33188
  name: "deviceIds",
33144
33189
  form: "array",
@@ -34340,6 +34385,9 @@ function errorEnvelope(header, type, message) {
34340
34385
  * route to the right cap on the right device.
34341
34386
  */
34342
34387
  var discoveryPayloadSchema = DiscoveryDirectiveSchema.shape.payload;
34388
+ /** Devices built at once in `handleDiscovery` — latency-bound tRPC reads, so
34389
+ * six lanes hide the round-trips without stampeding a booting hub. */
34390
+ var DISCOVERY_BUILD_CONCURRENCY = 6;
34343
34391
  var CAP_DOORBELL = "doorbell";
34344
34392
  var CAP_INTERCOM = "intercom";
34345
34393
  var CAP_MOTION = "motion";
@@ -34352,16 +34400,28 @@ var CAP_PRIVACY_MASK = "privacy-mask";
34352
34400
  async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
34353
34401
  if (!discoveryPayloadSchema.safeParse(payloadRaw).success) return errorEnvelope(header, "INVALID_DIRECTIVE", "Discovery payload shape unexpected");
34354
34402
  const exposed = await deps.listExposed();
34355
- const endpoints = [];
34356
- for (const entry of exposed) try {
34357
- const built = await buildEndpointsForExposedDevice(entry.deviceId, deps);
34358
- endpoints.push(...built);
34359
- } catch (err) {
34360
- deps.logger.warn("alexa discovery (on-demand): skipping device — build failed", {
34361
- tags: { deviceId: entry.deviceId },
34362
- meta: { error: err instanceof Error ? err.message : String(err) }
34363
- });
34364
- }
34403
+ const built = new Array(exposed.length).fill(null);
34404
+ const queue = exposed.map((entry, index) => ({
34405
+ entry,
34406
+ index
34407
+ }));
34408
+ const lane = async () => {
34409
+ for (;;) {
34410
+ const item = queue.shift();
34411
+ if (item === void 0) return;
34412
+ try {
34413
+ built[item.index] = await buildEndpointsForExposedDevice(item.entry.deviceId, deps);
34414
+ } catch (err) {
34415
+ deps.logger.warn("alexa discovery (on-demand): skipping device — build failed", {
34416
+ tags: { deviceId: item.entry.deviceId },
34417
+ meta: { error: err instanceof Error ? err.message : String(err) }
34418
+ });
34419
+ }
34420
+ }
34421
+ };
34422
+ const lanes = Math.min(DISCOVERY_BUILD_CONCURRENCY, Math.max(1, queue.length));
34423
+ await Promise.all(Array.from({ length: lanes }, lane));
34424
+ const endpoints = built.flatMap((b) => b ?? []);
34365
34425
  deps.logger.info("alexa discovery returned endpoints", { meta: {
34366
34426
  count: endpoints.length,
34367
34427
  userId: callerIdentity.userId
@@ -36726,8 +36786,30 @@ function endpointsSignature(endpoints) {
36726
36786
  }
36727
36787
  //#endregion
36728
36788
  //#region src/sync-engine.ts
36789
+ /**
36790
+ * Devices reconciled at once in {@link SyncEngine.reconcileAllExposed}. Each
36791
+ * lane's device build is a handful of cross-process tRPC calls — latency, not
36792
+ * CPU — so four lanes hide most of it without turning a boot-time cap
36793
+ * registration burst into a second stampede.
36794
+ */
36795
+ var RECONCILE_ALL_CONCURRENCY = 4;
36729
36796
  var SyncEngine = class {
36730
36797
  deps;
36798
+ /**
36799
+ * Serializes the `recordEndpoints` read-modify-write. The build/push half of
36800
+ * a reconcile runs parallel across devices, but the persisted maps are
36801
+ * whole-object snapshots: two concurrent `get → mutate → set` cycles would
36802
+ * silently drop one device's ids/signature (last writer wins).
36803
+ */
36804
+ recordChain = Promise.resolve();
36805
+ /**
36806
+ * One in-flight reconcile per device. At boot the 60 s full pass and the
36807
+ * debounced bindings-change reconciles overlap, and each one rebuilt the
36808
+ * same device serially — the live hub built camera 615's endpoint shape
36809
+ * four times inside one boot (2026-08-15). Joining the running pass costs
36810
+ * the duplicate nothing.
36811
+ */
36812
+ reconcileFlights = /* @__PURE__ */ new Map();
36731
36813
  constructor(deps) {
36732
36814
  this.deps = deps;
36733
36815
  }
@@ -36838,6 +36920,15 @@ var SyncEngine = class {
36838
36920
  * re-push a shape Alexa already has.
36839
36921
  */
36840
36922
  async reconcileDevice(deviceId) {
36923
+ const inFlight = this.reconcileFlights.get(deviceId);
36924
+ if (inFlight) return inFlight;
36925
+ const flight = this.reconcileDeviceNow(deviceId).finally(() => {
36926
+ if (this.reconcileFlights.get(deviceId) === flight) this.reconcileFlights.delete(deviceId);
36927
+ });
36928
+ this.reconcileFlights.set(deviceId, flight);
36929
+ return flight;
36930
+ }
36931
+ async reconcileDeviceNow(deviceId) {
36841
36932
  if (!this.deps.listExposed().some((e) => e.deviceId === deviceId)) return {
36842
36933
  deviceId,
36843
36934
  pushed: false
@@ -36878,26 +36969,42 @@ var SyncEngine = class {
36878
36969
  async reconcileAllExposed() {
36879
36970
  const checkedDeviceIds = [];
36880
36971
  const pushedDeviceIds = [];
36881
- for (const { deviceId } of this.deps.listExposed()) {
36882
- checkedDeviceIds.push(deviceId);
36883
- if ((await this.reconcileDevice(deviceId)).pushed) pushedDeviceIds.push(deviceId);
36884
- }
36972
+ const queue = [...this.deps.listExposed()];
36973
+ const lane = async () => {
36974
+ for (;;) {
36975
+ const entry = queue.shift();
36976
+ if (entry === void 0) return;
36977
+ checkedDeviceIds.push(entry.deviceId);
36978
+ if ((await this.reconcileDevice(entry.deviceId)).pushed) pushedDeviceIds.push(entry.deviceId);
36979
+ }
36980
+ };
36981
+ const lanes = Math.min(RECONCILE_ALL_CONCURRENCY, Math.max(1, queue.length));
36982
+ await Promise.all(Array.from({ length: lanes }, lane));
36885
36983
  return {
36886
36984
  checkedDeviceIds,
36887
36985
  pushedDeviceIds
36888
36986
  };
36889
36987
  }
36890
36988
  /** Persist the endpoint ids AND the shape signature for a device.
36891
- * `signature === null` forgets both (delete / empty build). */
36989
+ * `signature === null` forgets both (delete / empty build).
36990
+ *
36991
+ * Serialized through `recordChain`: the maps are whole-object snapshots
36992
+ * (`get` → spread → `set`), so parallel reconciles would otherwise race —
36993
+ * two devices' writes computed off the same stale snapshot, the second
36994
+ * `set` erasing the first's entry. */
36892
36995
  async recordEndpoints(deviceId, ids, signature) {
36893
- const next = { ...this.deps.getExportedEndpoints() };
36894
- if (ids.length > 0) next[deviceId] = ids;
36895
- else delete next[deviceId];
36896
- await this.deps.setExportedEndpoints(next);
36897
- const nextSignatures = { ...this.deps.getExportedSignatures() };
36898
- if (signature !== null) nextSignatures[deviceId] = signature;
36899
- else delete nextSignatures[deviceId];
36900
- await this.deps.setExportedSignatures(nextSignatures);
36996
+ const write = this.recordChain.then(async () => {
36997
+ const next = { ...this.deps.getExportedEndpoints() };
36998
+ if (ids.length > 0) next[deviceId] = ids;
36999
+ else delete next[deviceId];
37000
+ await this.deps.setExportedEndpoints(next);
37001
+ const nextSignatures = { ...this.deps.getExportedSignatures() };
37002
+ if (signature !== null) nextSignatures[deviceId] = signature;
37003
+ else delete nextSignatures[deviceId];
37004
+ await this.deps.setExportedSignatures(nextSignatures);
37005
+ });
37006
+ this.recordChain = write.then(() => void 0, () => void 0);
37007
+ return write;
36901
37008
  }
36902
37009
  };
36903
37010
  //#endregion
@@ -11058,6 +11058,8 @@ var QueryFilterSchema = object({
11058
11058
  where: record(string(), unknown()).optional(),
11059
11059
  whereIn: record(string(), array(unknown())).optional(),
11060
11060
  whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
11061
+ /** NULL-safe exclusion: matches rows whose field is NULL OR != the value. */
11062
+ whereNot: record(string(), unknown()).optional(),
11061
11063
  orderBy: object({
11062
11064
  field: string(),
11063
11065
  direction: _enum(["asc", "desc"])
@@ -11077,7 +11079,8 @@ var QueryFilterSchema = object({
11077
11079
  var MutationFilterSchema = object({
11078
11080
  where: record(string(), unknown()).optional(),
11079
11081
  whereIn: record(string(), array(unknown())).optional(),
11080
- whereBetween: record(string(), tuple([unknown(), unknown()])).optional()
11082
+ whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
11083
+ whereNot: record(string(), unknown()).optional()
11081
11084
  });
11082
11085
  /** A single stored record: `{ id, data }`. */
11083
11086
  var SettingsRecordSchema = object({
@@ -16984,7 +16987,10 @@ var RecentTracksQueryInput = object({
16984
16987
  * Encodes the (lastSeen, trackId) sort position — treat as opaque. */
16985
16988
  cursor: string().optional(),
16986
16989
  /** See {@link TrackProjectionSchema}. Default `full`. */
16987
- projection: TrackProjectionSchema.optional()
16990
+ projection: TrackProjectionSchema.optional(),
16991
+ /** Include stationary-promoted rows (parked objects). Default false: the
16992
+ * feed lists passages; parking records live on the stationary registry. */
16993
+ includeStationary: boolean().optional()
16988
16994
  });
16989
16995
  var RecentTracksPageSchema = object({
16990
16996
  /** Merged page, ordered by (`lastSeen` DESC, `trackId` DESC). */
@@ -17202,7 +17208,11 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
17202
17208
  zone: TrackZoneFilterSchema.optional(),
17203
17209
  /** See {@link TrackProjectionSchema}. Default `full` (backward
17204
17210
  * compatible — omitting the field keeps today's exact behaviour). */
17205
- projection: TrackProjectionSchema.optional()
17211
+ projection: TrackProjectionSchema.optional(),
17212
+ /** Include stationary-promoted rows (parked objects handed to the
17213
+ * stationary registry). Default false: the timeline lists passages,
17214
+ * not parking records (operator decision, 2026-08-15). */
17215
+ includeStationary: boolean().optional()
17206
17216
  }), array(TrackSchema).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(object({ deviceId: number() }), _void(), {
17207
17217
  kind: "mutation",
17208
17218
  auth: "admin"
@@ -17366,11 +17376,16 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
17366
17376
  auth: "admin"
17367
17377
  }), method(object({
17368
17378
  eventId: string(),
17369
- kind: MediaFileKindEnum.optional()
17379
+ kind: MediaFileKindEnum.optional(),
17380
+ deviceId: number()
17381
+ }), array(MediaFileSchema).readonly()), method(object({
17382
+ trackId: string(),
17383
+ kinds: array(MediaFileKindEnum).optional(),
17384
+ deviceId: number()
17370
17385
  }), array(MediaFileSchema).readonly()), method(object({
17371
17386
  trackId: string(),
17372
- kinds: array(MediaFileKindEnum).optional()
17373
- }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
17387
+ deviceId: number()
17388
+ }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
17374
17389
  kind: "mutation",
17375
17390
  auth: "admin"
17376
17391
  }), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
@@ -19349,7 +19364,16 @@ targets: array(object({
19349
19364
  /** A sleeping battery camera: the frame is deliberately stale and will
19350
19365
  * NOT refresh in the background. A surface should say so rather than
19351
19366
  * present it as current. */
19352
- sleeping: boolean()
19367
+ sleeping: boolean(),
19368
+ /** Current device state rendered over the cached frame. State images
19369
+ * remain authoritative even when their photographic background is
19370
+ * old; null means the link must carry a current camera frame. */
19371
+ stateReason: _enum([
19372
+ "disabled",
19373
+ "sleeping",
19374
+ "unreachable",
19375
+ "waking"
19376
+ ]).nullable()
19353
19377
  })));
19354
19378
  /**
19355
19379
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
@@ -32607,6 +32631,11 @@ Object.freeze({
32607
32631
  form: "single",
32608
32632
  optional: false
32609
32633
  }],
32634
+ "pipelineAnalytics.getEventMedia": [{
32635
+ name: "deviceId",
32636
+ form: "single",
32637
+ optional: false
32638
+ }],
32610
32639
  "pipelineAnalytics.getKeyEvents": [{
32611
32640
  name: "deviceId",
32612
32641
  form: "single",
@@ -32637,6 +32666,11 @@ Object.freeze({
32637
32666
  form: "single",
32638
32667
  optional: false
32639
32668
  }],
32669
+ "pipelineAnalytics.getTrackMedia": [{
32670
+ name: "deviceId",
32671
+ form: "single",
32672
+ optional: false
32673
+ }],
32640
32674
  "pipelineAnalytics.getTrainingExportSummary": [{
32641
32675
  name: "deviceIds",
32642
32676
  form: "array",
@@ -32672,6 +32706,11 @@ Object.freeze({
32672
32706
  form: "array",
32673
32707
  optional: true
32674
32708
  }],
32709
+ "pipelineAnalytics.listTrackMedia": [{
32710
+ name: "deviceId",
32711
+ form: "single",
32712
+ optional: false
32713
+ }],
32675
32714
  "pipelineAnalytics.listTracks": [{
32676
32715
  name: "deviceId",
32677
32716
  form: "single",
@@ -33112,6 +33151,12 @@ Object.freeze({
33112
33151
  form: "single",
33113
33152
  optional: false
33114
33153
  }],
33154
+ "snapshot.getSnapshotLinks": [{
33155
+ name: "targets",
33156
+ form: "object-array",
33157
+ optional: false,
33158
+ itemField: "deviceId"
33159
+ }],
33115
33160
  "snapshot.getSnapshotOverview": [{
33116
33161
  name: "deviceIds",
33117
33162
  form: "array",
@@ -34313,6 +34358,9 @@ function errorEnvelope(header, type, message) {
34313
34358
  * route to the right cap on the right device.
34314
34359
  */
34315
34360
  var discoveryPayloadSchema = DiscoveryDirectiveSchema.shape.payload;
34361
+ /** Devices built at once in `handleDiscovery` — latency-bound tRPC reads, so
34362
+ * six lanes hide the round-trips without stampeding a booting hub. */
34363
+ var DISCOVERY_BUILD_CONCURRENCY = 6;
34316
34364
  var CAP_DOORBELL = "doorbell";
34317
34365
  var CAP_INTERCOM = "intercom";
34318
34366
  var CAP_MOTION = "motion";
@@ -34325,16 +34373,28 @@ var CAP_PRIVACY_MASK = "privacy-mask";
34325
34373
  async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
34326
34374
  if (!discoveryPayloadSchema.safeParse(payloadRaw).success) return errorEnvelope(header, "INVALID_DIRECTIVE", "Discovery payload shape unexpected");
34327
34375
  const exposed = await deps.listExposed();
34328
- const endpoints = [];
34329
- for (const entry of exposed) try {
34330
- const built = await buildEndpointsForExposedDevice(entry.deviceId, deps);
34331
- endpoints.push(...built);
34332
- } catch (err) {
34333
- deps.logger.warn("alexa discovery (on-demand): skipping device — build failed", {
34334
- tags: { deviceId: entry.deviceId },
34335
- meta: { error: err instanceof Error ? err.message : String(err) }
34336
- });
34337
- }
34376
+ const built = new Array(exposed.length).fill(null);
34377
+ const queue = exposed.map((entry, index) => ({
34378
+ entry,
34379
+ index
34380
+ }));
34381
+ const lane = async () => {
34382
+ for (;;) {
34383
+ const item = queue.shift();
34384
+ if (item === void 0) return;
34385
+ try {
34386
+ built[item.index] = await buildEndpointsForExposedDevice(item.entry.deviceId, deps);
34387
+ } catch (err) {
34388
+ deps.logger.warn("alexa discovery (on-demand): skipping device — build failed", {
34389
+ tags: { deviceId: item.entry.deviceId },
34390
+ meta: { error: err instanceof Error ? err.message : String(err) }
34391
+ });
34392
+ }
34393
+ }
34394
+ };
34395
+ const lanes = Math.min(DISCOVERY_BUILD_CONCURRENCY, Math.max(1, queue.length));
34396
+ await Promise.all(Array.from({ length: lanes }, lane));
34397
+ const endpoints = built.flatMap((b) => b ?? []);
34338
34398
  deps.logger.info("alexa discovery returned endpoints", { meta: {
34339
34399
  count: endpoints.length,
34340
34400
  userId: callerIdentity.userId
@@ -36699,8 +36759,30 @@ function endpointsSignature(endpoints) {
36699
36759
  }
36700
36760
  //#endregion
36701
36761
  //#region src/sync-engine.ts
36762
+ /**
36763
+ * Devices reconciled at once in {@link SyncEngine.reconcileAllExposed}. Each
36764
+ * lane's device build is a handful of cross-process tRPC calls — latency, not
36765
+ * CPU — so four lanes hide most of it without turning a boot-time cap
36766
+ * registration burst into a second stampede.
36767
+ */
36768
+ var RECONCILE_ALL_CONCURRENCY = 4;
36702
36769
  var SyncEngine = class {
36703
36770
  deps;
36771
+ /**
36772
+ * Serializes the `recordEndpoints` read-modify-write. The build/push half of
36773
+ * a reconcile runs parallel across devices, but the persisted maps are
36774
+ * whole-object snapshots: two concurrent `get → mutate → set` cycles would
36775
+ * silently drop one device's ids/signature (last writer wins).
36776
+ */
36777
+ recordChain = Promise.resolve();
36778
+ /**
36779
+ * One in-flight reconcile per device. At boot the 60 s full pass and the
36780
+ * debounced bindings-change reconciles overlap, and each one rebuilt the
36781
+ * same device serially — the live hub built camera 615's endpoint shape
36782
+ * four times inside one boot (2026-08-15). Joining the running pass costs
36783
+ * the duplicate nothing.
36784
+ */
36785
+ reconcileFlights = /* @__PURE__ */ new Map();
36704
36786
  constructor(deps) {
36705
36787
  this.deps = deps;
36706
36788
  }
@@ -36811,6 +36893,15 @@ var SyncEngine = class {
36811
36893
  * re-push a shape Alexa already has.
36812
36894
  */
36813
36895
  async reconcileDevice(deviceId) {
36896
+ const inFlight = this.reconcileFlights.get(deviceId);
36897
+ if (inFlight) return inFlight;
36898
+ const flight = this.reconcileDeviceNow(deviceId).finally(() => {
36899
+ if (this.reconcileFlights.get(deviceId) === flight) this.reconcileFlights.delete(deviceId);
36900
+ });
36901
+ this.reconcileFlights.set(deviceId, flight);
36902
+ return flight;
36903
+ }
36904
+ async reconcileDeviceNow(deviceId) {
36814
36905
  if (!this.deps.listExposed().some((e) => e.deviceId === deviceId)) return {
36815
36906
  deviceId,
36816
36907
  pushed: false
@@ -36851,26 +36942,42 @@ var SyncEngine = class {
36851
36942
  async reconcileAllExposed() {
36852
36943
  const checkedDeviceIds = [];
36853
36944
  const pushedDeviceIds = [];
36854
- for (const { deviceId } of this.deps.listExposed()) {
36855
- checkedDeviceIds.push(deviceId);
36856
- if ((await this.reconcileDevice(deviceId)).pushed) pushedDeviceIds.push(deviceId);
36857
- }
36945
+ const queue = [...this.deps.listExposed()];
36946
+ const lane = async () => {
36947
+ for (;;) {
36948
+ const entry = queue.shift();
36949
+ if (entry === void 0) return;
36950
+ checkedDeviceIds.push(entry.deviceId);
36951
+ if ((await this.reconcileDevice(entry.deviceId)).pushed) pushedDeviceIds.push(entry.deviceId);
36952
+ }
36953
+ };
36954
+ const lanes = Math.min(RECONCILE_ALL_CONCURRENCY, Math.max(1, queue.length));
36955
+ await Promise.all(Array.from({ length: lanes }, lane));
36858
36956
  return {
36859
36957
  checkedDeviceIds,
36860
36958
  pushedDeviceIds
36861
36959
  };
36862
36960
  }
36863
36961
  /** Persist the endpoint ids AND the shape signature for a device.
36864
- * `signature === null` forgets both (delete / empty build). */
36962
+ * `signature === null` forgets both (delete / empty build).
36963
+ *
36964
+ * Serialized through `recordChain`: the maps are whole-object snapshots
36965
+ * (`get` → spread → `set`), so parallel reconciles would otherwise race —
36966
+ * two devices' writes computed off the same stale snapshot, the second
36967
+ * `set` erasing the first's entry. */
36865
36968
  async recordEndpoints(deviceId, ids, signature) {
36866
- const next = { ...this.deps.getExportedEndpoints() };
36867
- if (ids.length > 0) next[deviceId] = ids;
36868
- else delete next[deviceId];
36869
- await this.deps.setExportedEndpoints(next);
36870
- const nextSignatures = { ...this.deps.getExportedSignatures() };
36871
- if (signature !== null) nextSignatures[deviceId] = signature;
36872
- else delete nextSignatures[deviceId];
36873
- await this.deps.setExportedSignatures(nextSignatures);
36969
+ const write = this.recordChain.then(async () => {
36970
+ const next = { ...this.deps.getExportedEndpoints() };
36971
+ if (ids.length > 0) next[deviceId] = ids;
36972
+ else delete next[deviceId];
36973
+ await this.deps.setExportedEndpoints(next);
36974
+ const nextSignatures = { ...this.deps.getExportedSignatures() };
36975
+ if (signature !== null) nextSignatures[deviceId] = signature;
36976
+ else delete nextSignatures[deviceId];
36977
+ await this.deps.setExportedSignatures(nextSignatures);
36978
+ });
36979
+ this.recordChain = write.then(() => void 0, () => void 0);
36980
+ return write;
36874
36981
  }
36875
36982
  };
36876
36983
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-export-alexa",
3
- "version": "1.2.22",
3
+ "version": "1.2.24",
4
4
  "description": "Alexa Smart Home Skill export — hub-side OAuth provider + directive handler. The companion AWS Lambda forwards Alexa directives to this addon's /addon/export-alexa/directive endpoint.",
5
5
  "keywords": [
6
6
  "camstack",