@camstack/system 1.2.172 → 1.2.173

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.
@@ -5151,6 +5151,86 @@ var DeviceRowStore = class {
5151
5151
  });
5152
5152
  }
5153
5153
  /** Drop the device's row. Idempotent. */
5154
+ /**
5155
+ * Exchange the ids of two devices, carrying their children with them.
5156
+ *
5157
+ * ## Why this exists as a primitive
5158
+ *
5159
+ * Replacing a camera means the new hardware has to become the device the rest
5160
+ * of the system already knows. Everything general about a camera is keyed on
5161
+ * the NUMERIC id and on nothing else — the recording path on disk is
5162
+ * `<deviceId>/profile/…`, the hour ledger, the media store and zone ownership
5163
+ * carry `deviceId` and not one `stableId` between them, and all four
5164
+ * ecosystem exports (Home Assistant, Alexa, HomeKit, Google) key on it too.
5165
+ * Moving that work is the expensive, lossy operation. Moving the NUMBER is
5166
+ * two rows.
5167
+ *
5168
+ * ## Children are re-pointed, not renumbered
5169
+ *
5170
+ * A child keeps its own id and follows its parent: every row whose
5171
+ * `parentDeviceId` was `a` now names `b`, and the reverse. Renumbering the
5172
+ * children as well would move THEIR general work for no reason — the
5173
+ * accessory's own recordings and rules are keyed on the child id, which is
5174
+ * not the id being inherited.
5175
+ *
5176
+ * ## There are no transactions here, so the ORDER is the safety
5177
+ *
5178
+ * The backing store is a collection API (`set` / `delete` / `updateWhere`);
5179
+ * there is no rollback to lean on. So the sequence is chosen to make every
5180
+ * crash point LOUD rather than plausible: `a` first moves to a freshly
5181
+ * allocated id, which the monotonic counter guarantees was never used and
5182
+ * will never be reused. A crash before the end therefore leaves a device
5183
+ * missing from its number and a stray at an unfamiliar one — visible in the
5184
+ * device list at a glance. What it can never leave is two rows on one number,
5185
+ * or a row that looks correct while its children point elsewhere.
5186
+ *
5187
+ * The caller holds {@link DeviceMetaStore.withMetaWriteLock}. This method does
5188
+ * not take it: taking it here would deadlock the migration that has to disable
5189
+ * functions on both devices in the same critical section.
5190
+ */
5191
+ async swapIds(a, b, tempId) {
5192
+ if (a === b) throw new Error(`[device-manager] swapIds: ${a} and ${b} are the same device`);
5193
+ await this.declare();
5194
+ const rowA = await this.get(a);
5195
+ const rowB = await this.get(b);
5196
+ if (rowA === null) throw new Error(`[device-manager] swapIds: device ${a} does not exist`);
5197
+ if (rowB === null) throw new Error(`[device-manager] swapIds: device ${b} does not exist`);
5198
+ if (await this.get(tempId) !== null) throw new Error(`[device-manager] swapIds: temp id ${tempId} is taken`);
5199
+ const childrenOfA = (await this.listByParent(a)).map((r) => r.meta.id);
5200
+ const childrenOfB = (await this.listByParent(b)).map((r) => r.meta.id);
5201
+ await this.moveRow(rowA, tempId);
5202
+ await this.moveRow(rowB, a);
5203
+ await this.moveRow({
5204
+ ...rowA,
5205
+ meta: {
5206
+ ...rowA.meta,
5207
+ id: tempId
5208
+ }
5209
+ }, b);
5210
+ for (const childId of childrenOfA) await this.patch(childId, { parentDeviceId: b });
5211
+ for (const childId of childrenOfB) await this.patch(childId, { parentDeviceId: a });
5212
+ this.logger.info("device ids swapped", {
5213
+ tags: { deviceId: a },
5214
+ meta: {
5215
+ a,
5216
+ b,
5217
+ childrenOfA,
5218
+ childrenOfB
5219
+ }
5220
+ });
5221
+ }
5222
+ /** Rewrite a row under a new id and drop the old key. Not exported: the only
5223
+ * legitimate reason to move a row is {@link swapIds}. */
5224
+ async moveRow(row, toId) {
5225
+ await this.upsert({
5226
+ ...row.meta,
5227
+ id: toId
5228
+ }, {
5229
+ registered: row.registered,
5230
+ metadata: row.metadata
5231
+ });
5232
+ await this.remove(row.meta.id);
5233
+ }
5154
5234
  async remove(deviceId) {
5155
5235
  await this.declare();
5156
5236
  await this.backend.delete({
@@ -5146,6 +5146,86 @@ var DeviceRowStore = class {
5146
5146
  });
5147
5147
  }
5148
5148
  /** Drop the device's row. Idempotent. */
5149
+ /**
5150
+ * Exchange the ids of two devices, carrying their children with them.
5151
+ *
5152
+ * ## Why this exists as a primitive
5153
+ *
5154
+ * Replacing a camera means the new hardware has to become the device the rest
5155
+ * of the system already knows. Everything general about a camera is keyed on
5156
+ * the NUMERIC id and on nothing else — the recording path on disk is
5157
+ * `<deviceId>/profile/…`, the hour ledger, the media store and zone ownership
5158
+ * carry `deviceId` and not one `stableId` between them, and all four
5159
+ * ecosystem exports (Home Assistant, Alexa, HomeKit, Google) key on it too.
5160
+ * Moving that work is the expensive, lossy operation. Moving the NUMBER is
5161
+ * two rows.
5162
+ *
5163
+ * ## Children are re-pointed, not renumbered
5164
+ *
5165
+ * A child keeps its own id and follows its parent: every row whose
5166
+ * `parentDeviceId` was `a` now names `b`, and the reverse. Renumbering the
5167
+ * children as well would move THEIR general work for no reason — the
5168
+ * accessory's own recordings and rules are keyed on the child id, which is
5169
+ * not the id being inherited.
5170
+ *
5171
+ * ## There are no transactions here, so the ORDER is the safety
5172
+ *
5173
+ * The backing store is a collection API (`set` / `delete` / `updateWhere`);
5174
+ * there is no rollback to lean on. So the sequence is chosen to make every
5175
+ * crash point LOUD rather than plausible: `a` first moves to a freshly
5176
+ * allocated id, which the monotonic counter guarantees was never used and
5177
+ * will never be reused. A crash before the end therefore leaves a device
5178
+ * missing from its number and a stray at an unfamiliar one — visible in the
5179
+ * device list at a glance. What it can never leave is two rows on one number,
5180
+ * or a row that looks correct while its children point elsewhere.
5181
+ *
5182
+ * The caller holds {@link DeviceMetaStore.withMetaWriteLock}. This method does
5183
+ * not take it: taking it here would deadlock the migration that has to disable
5184
+ * functions on both devices in the same critical section.
5185
+ */
5186
+ async swapIds(a, b, tempId) {
5187
+ if (a === b) throw new Error(`[device-manager] swapIds: ${a} and ${b} are the same device`);
5188
+ await this.declare();
5189
+ const rowA = await this.get(a);
5190
+ const rowB = await this.get(b);
5191
+ if (rowA === null) throw new Error(`[device-manager] swapIds: device ${a} does not exist`);
5192
+ if (rowB === null) throw new Error(`[device-manager] swapIds: device ${b} does not exist`);
5193
+ if (await this.get(tempId) !== null) throw new Error(`[device-manager] swapIds: temp id ${tempId} is taken`);
5194
+ const childrenOfA = (await this.listByParent(a)).map((r) => r.meta.id);
5195
+ const childrenOfB = (await this.listByParent(b)).map((r) => r.meta.id);
5196
+ await this.moveRow(rowA, tempId);
5197
+ await this.moveRow(rowB, a);
5198
+ await this.moveRow({
5199
+ ...rowA,
5200
+ meta: {
5201
+ ...rowA.meta,
5202
+ id: tempId
5203
+ }
5204
+ }, b);
5205
+ for (const childId of childrenOfA) await this.patch(childId, { parentDeviceId: b });
5206
+ for (const childId of childrenOfB) await this.patch(childId, { parentDeviceId: a });
5207
+ this.logger.info("device ids swapped", {
5208
+ tags: { deviceId: a },
5209
+ meta: {
5210
+ a,
5211
+ b,
5212
+ childrenOfA,
5213
+ childrenOfB
5214
+ }
5215
+ });
5216
+ }
5217
+ /** Rewrite a row under a new id and drop the old key. Not exported: the only
5218
+ * legitimate reason to move a row is {@link swapIds}. */
5219
+ async moveRow(row, toId) {
5220
+ await this.upsert({
5221
+ ...row.meta,
5222
+ id: toId
5223
+ }, {
5224
+ registered: row.registered,
5225
+ metadata: row.metadata
5226
+ });
5227
+ await this.remove(row.meta.id);
5228
+ }
5149
5229
  async remove(deviceId) {
5150
5230
  await this.declare();
5151
5231
  await this.backend.delete({
@@ -340,6 +340,47 @@ export declare class DeviceRowStore {
340
340
  */
341
341
  upsertRegistration(row: DeviceRegistrationRow): Promise<void>;
342
342
  /** Drop the device's row. Idempotent. */
343
+ /**
344
+ * Exchange the ids of two devices, carrying their children with them.
345
+ *
346
+ * ## Why this exists as a primitive
347
+ *
348
+ * Replacing a camera means the new hardware has to become the device the rest
349
+ * of the system already knows. Everything general about a camera is keyed on
350
+ * the NUMERIC id and on nothing else — the recording path on disk is
351
+ * `<deviceId>/profile/…`, the hour ledger, the media store and zone ownership
352
+ * carry `deviceId` and not one `stableId` between them, and all four
353
+ * ecosystem exports (Home Assistant, Alexa, HomeKit, Google) key on it too.
354
+ * Moving that work is the expensive, lossy operation. Moving the NUMBER is
355
+ * two rows.
356
+ *
357
+ * ## Children are re-pointed, not renumbered
358
+ *
359
+ * A child keeps its own id and follows its parent: every row whose
360
+ * `parentDeviceId` was `a` now names `b`, and the reverse. Renumbering the
361
+ * children as well would move THEIR general work for no reason — the
362
+ * accessory's own recordings and rules are keyed on the child id, which is
363
+ * not the id being inherited.
364
+ *
365
+ * ## There are no transactions here, so the ORDER is the safety
366
+ *
367
+ * The backing store is a collection API (`set` / `delete` / `updateWhere`);
368
+ * there is no rollback to lean on. So the sequence is chosen to make every
369
+ * crash point LOUD rather than plausible: `a` first moves to a freshly
370
+ * allocated id, which the monotonic counter guarantees was never used and
371
+ * will never be reused. A crash before the end therefore leaves a device
372
+ * missing from its number and a stray at an unfamiliar one — visible in the
373
+ * device list at a glance. What it can never leave is two rows on one number,
374
+ * or a row that looks correct while its children point elsewhere.
375
+ *
376
+ * The caller holds {@link DeviceMetaStore.withMetaWriteLock}. This method does
377
+ * not take it: taking it here would deadlock the migration that has to disable
378
+ * functions on both devices in the same critical section.
379
+ */
380
+ swapIds(a: number, b: number, tempId: number): Promise<void>;
381
+ /** Rewrite a row under a new id and drop the old key. Not exported: the only
382
+ * legitimate reason to move a row is {@link swapIds}. */
383
+ private moveRow;
343
384
  remove(deviceId: number): Promise<void>;
344
385
  /**
345
386
  * The retirement door for the three blobs this collection replaced.
@@ -0,0 +1,44 @@
1
+ import { CameraSwitchId } from '@camstack/types';
2
+ /** What actually happened to one switch on one device. */
3
+ export type SwitchOutcome = 'off' | 'on' | 'not-offered' | 'unreachable';
4
+ export interface SwitchReport {
5
+ readonly deviceId: number;
6
+ readonly switchId: CameraSwitchId;
7
+ readonly outcome: SwitchOutcome;
8
+ /** The refusal, verbatim, when the outcome is not the requested one. */
9
+ readonly detail?: string;
10
+ }
11
+ export interface MigrateDeviceResult {
12
+ readonly sourceId: number;
13
+ readonly targetId: number;
14
+ /** Every switch write attempted, in order, on both devices. */
15
+ readonly switches: readonly SwitchReport[];
16
+ /**
17
+ * Switches that could NOT be turned off on the source. Empty is the only
18
+ * value that means "the old camera is fully quiet"; a caller that renders
19
+ * only a success flag is not entitled to one.
20
+ */
21
+ readonly sourceStillLive: readonly CameraSwitchId[];
22
+ readonly swapped: boolean;
23
+ }
24
+ export interface MigrateDeviceDeps {
25
+ /** `pipelineOrchestrator.setCameraSwitch`, through `ctx.api`. */
26
+ readonly setSwitch: (deviceId: number, switchId: CameraSwitchId, enabled: boolean) => Promise<void>;
27
+ /** `DeviceRowStore.swapIds`, already under the meta write lock. */
28
+ readonly swapIds: (a: number, b: number) => Promise<void>;
29
+ /** Respawn the runners that hold either number in memory. Disabling stops
30
+ * data PRODUCTION; it does not flush a cache that maps id → addon. */
31
+ readonly respawnRunners: (deviceIds: readonly number[]) => Promise<void>;
32
+ readonly log: (message: string, meta: Record<string, unknown>) => void;
33
+ }
34
+ /**
35
+ * Put both cameras to sleep, exchange their identities, wake the target.
36
+ *
37
+ * Throws only if the swap itself fails — a switch that could not be written is
38
+ * reported, not raised, because refusing to migrate a camera whose old hardware
39
+ * is unreachable would refuse the exact case this exists for.
40
+ */
41
+ export declare function migrateDevice(deps: MigrateDeviceDeps, input: {
42
+ readonly sourceId: number;
43
+ readonly targetId: number;
44
+ }): Promise<MigrateDeviceResult>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.2.172",
3
+ "version": "1.2.173",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",