@camstack/system 1.2.179 → 1.2.181
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/addon-runner.js +2 -2
- package/dist/addon-runner.mjs +2 -2
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.d.ts +56 -10
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +657 -62
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +658 -63
- package/dist/builtins/backup-orchestrator/run-queue.d.ts +103 -0
- package/dist/builtins/backup-orchestrator/staging-paths.d.ts +36 -0
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +349 -65
- package/dist/builtins/device-manager/device-manager.addon.mjs +349 -65
- package/dist/builtins/device-manager/device-meta-actions.d.ts +8 -19
- package/dist/builtins/device-manager/device-provider-context.d.ts +4 -0
- package/dist/builtins/device-manager/migrate-device.d.ts +47 -3
- package/dist/builtins/device-manager/migrate-guard.d.ts +39 -0
- package/dist/builtins/device-manager/migrate-hardware-state.d.ts +24 -0
- package/dist/builtins/device-manager/migrate-reload.d.ts +8 -0
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-backup/system-backup.service.d.ts +55 -2
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-L_39vRmH.mjs → dist-DbnNk4Lw.mjs} +211 -2
- package/dist/{dist-C7qWwrK1.js → dist-whe0b4_3.js} +211 -2
- package/dist/index.js +530 -2
- package/dist/index.mjs +533 -5
- package/dist/kernel/addon-installer.d.ts +69 -0
- package/dist/kernel/deps/closure-dedupe.d.ts +105 -0
- package/dist/{manifest-system-deps-CMOGc5nC.mjs → manifest-system-deps-DOcMuUWK.mjs} +1 -1
- package/dist/{manifest-system-deps-PAIfmNun.js → manifest-system-deps-y5vZBO6q.js} +1 -1
- package/dist/{retired-settings-keys-3soU9pfP.js → retired-settings-keys-BdvmiaK7.js} +1 -1
- package/dist/{retired-settings-keys-CnbKAxmX.mjs → retired-settings-keys-CyQG8b-A.mjs} +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { $t as isDeviceConfigCap, Bt as DeviceRole, Ft as BaseAddon, H as deviceManagerCapability, J as enumerateSchemaFields, P as buildStreamParamsConfigSchema, Pt as errMsg, U as deviceStateCapability, Vt as DeviceType, W as deviceStatusCapability, Wt as WELL_KNOWN_TAB_MAP, a as CAMERA_SWITCH_ORDER, b as STREAM_PROFILE_META, f as DeviceStatusSchema, ft as normalizeUnit, mt as parseStreamParamsFormPatch, o as CAP_NAMES_WITH_STATUS, on as sleep, q as enumerateItemArrayFields, sn as EventCategory, t as ALL_CAPABILITY_DEFINITIONS, xt as runtimeStatePolicyFor, yt as resolveContainerPrimaryChild, zt as DeviceFeature } from "../../dist-
|
|
2
|
-
import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-
|
|
1
|
+
import { $t as isDeviceConfigCap, Bt as DeviceRole, Ft as BaseAddon, H as deviceManagerCapability, J as enumerateSchemaFields, P as buildStreamParamsConfigSchema, Pt as errMsg, U as deviceStateCapability, Vt as DeviceType, W as deviceStatusCapability, Wt as WELL_KNOWN_TAB_MAP, a as CAMERA_SWITCH_ORDER, b as STREAM_PROFILE_META, f as DeviceStatusSchema, ft as normalizeUnit, mt as parseStreamParamsFormPatch, o as CAP_NAMES_WITH_STATUS, on as sleep, q as enumerateItemArrayFields, sn as EventCategory, t as ALL_CAPABILITY_DEFINITIONS, xt as runtimeStatePolicyFor, yt as resolveContainerPrimaryChild, zt as DeviceFeature } from "../../dist-DbnNk4Lw.mjs";
|
|
2
|
+
import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-CyQG8b-A.mjs";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { canonicalDeviceFingerprint } from "@camstack/types/node";
|
|
5
5
|
//#region src/builtins/device-manager/adoption-job-engine.ts
|
|
@@ -2109,7 +2109,13 @@ function resolveLinkedDeviceIds(params) {
|
|
|
2109
2109
|
if (config.mode === "auto") {
|
|
2110
2110
|
if (selfLocation !== null && selfLocation.length > 0) for (const c of candidates) {
|
|
2111
2111
|
if (c.type === DeviceType.Container) continue;
|
|
2112
|
-
if (c.location
|
|
2112
|
+
if (c.location !== selfLocation) continue;
|
|
2113
|
+
const parent = c.parentDeviceId !== null ? byId.get(c.parentDeviceId) : void 0;
|
|
2114
|
+
if (parent !== void 0 && parent.type === DeviceType.Container) {
|
|
2115
|
+
expand(parent.id);
|
|
2116
|
+
continue;
|
|
2117
|
+
}
|
|
2118
|
+
ids.add(c.id);
|
|
2113
2119
|
}
|
|
2114
2120
|
} else for (const id of config.manualIds) if (byId.has(id)) expand(id);
|
|
2115
2121
|
ids.delete(selfId);
|
|
@@ -3269,8 +3275,6 @@ var DeviceEventPropagator = class {
|
|
|
3269
3275
|
return chain;
|
|
3270
3276
|
}
|
|
3271
3277
|
};
|
|
3272
|
-
//#endregion
|
|
3273
|
-
//#region src/builtins/device-manager/migrate-device.ts
|
|
3274
3278
|
/** A refusal that names a switch the camera does not offer, rather than one it
|
|
3275
3279
|
* could not be asked about. The cap documents rejecting the former; anything
|
|
3276
3280
|
* else is treated as the camera being out of reach, which is the safe reading:
|
|
@@ -3282,10 +3286,31 @@ function classify(err) {
|
|
|
3282
3286
|
detail
|
|
3283
3287
|
};
|
|
3284
3288
|
}
|
|
3289
|
+
/**
|
|
3290
|
+
* One switch write under the bound. The race, not the write, is what fails on
|
|
3291
|
+
* timeout — the underlying promise is marked handled so a late rejection from
|
|
3292
|
+
* a camera that eventually answers "no" cannot surface as an unhandled
|
|
3293
|
+
* rejection after the migration already reported `unreachable`.
|
|
3294
|
+
*/
|
|
3295
|
+
async function setSwitchBounded(deps, deviceId, switchId, enabled) {
|
|
3296
|
+
const budget = deps.switchTimeoutMs ?? 15e3;
|
|
3297
|
+
const write = deps.setSwitch(deviceId, switchId, enabled);
|
|
3298
|
+
write.catch(() => {});
|
|
3299
|
+
let timer;
|
|
3300
|
+
try {
|
|
3301
|
+
await Promise.race([write, new Promise((_resolve, reject) => {
|
|
3302
|
+
timer = setTimeout(() => {
|
|
3303
|
+
reject(/* @__PURE__ */ new Error(`switch write did not answer within ${budget}ms — reported unreachable; if it lands late, a duplicate write of the same value is a no-op`));
|
|
3304
|
+
}, budget);
|
|
3305
|
+
})]);
|
|
3306
|
+
} finally {
|
|
3307
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3285
3310
|
async function applyAll(deps, deviceId, enabled) {
|
|
3286
3311
|
const out = [];
|
|
3287
3312
|
for (const switchId of CAMERA_SWITCH_ORDER) try {
|
|
3288
|
-
await deps
|
|
3313
|
+
await setSwitchBounded(deps, deviceId, switchId, enabled);
|
|
3289
3314
|
out.push({
|
|
3290
3315
|
deviceId,
|
|
3291
3316
|
switchId,
|
|
@@ -3322,8 +3347,17 @@ async function migrateDevice$1(deps, input) {
|
|
|
3322
3347
|
const down = [...await applyAll(deps, sourceId, false), ...await applyAll(deps, targetId, false)];
|
|
3323
3348
|
const sourceStillLive = down.filter((r) => r.deviceId === sourceId && r.outcome === "unreachable").map((r) => r.switchId);
|
|
3324
3349
|
await deps.swapIds(sourceId, targetId);
|
|
3350
|
+
await deps.swapHardwareState(sourceId, targetId);
|
|
3351
|
+
for (const deviceId of [sourceId, targetId]) try {
|
|
3352
|
+
await deps.forgetStreamState(deviceId);
|
|
3353
|
+
} catch (err) {
|
|
3354
|
+
deps.log("migration: broker hardware state NOT forgotten — stale stream state survives", {
|
|
3355
|
+
deviceId,
|
|
3356
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3357
|
+
remedy: "delete the device's derived streams and reassign its stream profiles, or update the stream-broker addon and re-run streamBroker.forgetDeviceHardware"
|
|
3358
|
+
});
|
|
3359
|
+
}
|
|
3325
3360
|
const up = await applyAll(deps, sourceId, true);
|
|
3326
|
-
await deps.respawnRunners([sourceId, targetId]);
|
|
3327
3361
|
deps.log("camera migrated", {
|
|
3328
3362
|
sourceId,
|
|
3329
3363
|
targetId,
|
|
@@ -3339,6 +3373,148 @@ async function migrateDevice$1(deps, input) {
|
|
|
3339
3373
|
};
|
|
3340
3374
|
}
|
|
3341
3375
|
//#endregion
|
|
3376
|
+
//#region src/builtins/device-manager/migrate-reload.ts
|
|
3377
|
+
async function reloadMigratedDevices(pctx, targets) {
|
|
3378
|
+
const ctx = pctx.host.ctx;
|
|
3379
|
+
for (const target of targets) {
|
|
3380
|
+
pctx.metaStore.idToAddonId.set(target.deviceId, target.addonId);
|
|
3381
|
+
pctx.host.remoteNativeCaps.delete(target.deviceId);
|
|
3382
|
+
pctx.host.capabilityRegistry?.unregisterAllNativeForDevice(target.deviceId);
|
|
3383
|
+
}
|
|
3384
|
+
const fallbackAddons = /* @__PURE__ */ new Map();
|
|
3385
|
+
await Promise.all(targets.map(async (target) => {
|
|
3386
|
+
try {
|
|
3387
|
+
const reloaded = await (await pctx.host.requireDeviceProvider(target.addonId)).reloadDevice({ stableId: target.stableId });
|
|
3388
|
+
ctx.logger.info("migration: device reloaded in place — no other device disturbed", {
|
|
3389
|
+
tags: { deviceId: target.deviceId },
|
|
3390
|
+
meta: {
|
|
3391
|
+
addonId: target.addonId,
|
|
3392
|
+
stableId: target.stableId,
|
|
3393
|
+
reloadedAs: reloaded.deviceId
|
|
3394
|
+
}
|
|
3395
|
+
});
|
|
3396
|
+
} catch (err) {
|
|
3397
|
+
ctx.logger.error("migration: in-place reload FAILED — falling back to a provider restart", {
|
|
3398
|
+
tags: { deviceId: target.deviceId },
|
|
3399
|
+
meta: {
|
|
3400
|
+
addonId: target.addonId,
|
|
3401
|
+
stableId: target.stableId,
|
|
3402
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3403
|
+
}
|
|
3404
|
+
});
|
|
3405
|
+
fallbackAddons.set(target.addonId, target.deviceId);
|
|
3406
|
+
}
|
|
3407
|
+
}));
|
|
3408
|
+
if (fallbackAddons.size === 0) return;
|
|
3409
|
+
await Promise.all([...fallbackAddons.entries()].map(async ([addonId, migratedDeviceId]) => {
|
|
3410
|
+
const owned = [...pctx.metaStore.idToAddonId.entries()].filter(([, a]) => a === addonId).length;
|
|
3411
|
+
ctx.logger.info("migration: restarting a whole provider to flush the id maps", {
|
|
3412
|
+
tags: { deviceId: migratedDeviceId },
|
|
3413
|
+
meta: {
|
|
3414
|
+
addonId,
|
|
3415
|
+
devicesTakenDown: owned
|
|
3416
|
+
}
|
|
3417
|
+
});
|
|
3418
|
+
await ctx.api.addons.restartAddon.mutate({ addonId });
|
|
3419
|
+
}));
|
|
3420
|
+
}
|
|
3421
|
+
//#endregion
|
|
3422
|
+
//#region src/builtins/device-manager/migrate-hardware-state.ts
|
|
3423
|
+
/**
|
|
3424
|
+
* The per-device state half of a migration (`deviceManager.migrateDevice`).
|
|
3425
|
+
*
|
|
3426
|
+
* ## The rule
|
|
3427
|
+
*
|
|
3428
|
+
* **Role-scoped state follows the NUMBER. Hardware-scoped state follows the
|
|
3429
|
+
* DEVICE.** `swapIds` moves the device ROWS — `addonId` and `stableId` follow
|
|
3430
|
+
* the hardware — but the per-device CONFIG blob and the runtime-state blob are
|
|
3431
|
+
* keyed by the number in a separate store, and until 2026-09-01 they simply
|
|
3432
|
+
* stayed put. Measured on the live hub after replacing a Hikvision (id 618)
|
|
3433
|
+
* with a Reolink: device 618 — Reolink hardware — was dialled at
|
|
3434
|
+
* `192.168.1.245:80` with `deviceCache.model="DS-2DE2A404IW-DE3/W"`, the
|
|
3435
|
+
* Hikvision's address, credentials and cached model, and never came online.
|
|
3436
|
+
* Device 4362 wore the Reolink's config on Hikvision hardware. The two devices
|
|
3437
|
+
* were wearing each other's identity.
|
|
3438
|
+
*
|
|
3439
|
+
* The proof the config belongs to the hardware, not the number: writing the
|
|
3440
|
+
* Hikvision blob onto the Reolink was REJECTED by the Reolink driver's own
|
|
3441
|
+
* zod schema (`motionSensitivity: 60` vs max 50). A config that fails the
|
|
3442
|
+
* other provider's schema is a config for different hardware.
|
|
3443
|
+
*
|
|
3444
|
+
* ## What swaps, what stays
|
|
3445
|
+
*
|
|
3446
|
+
* - The DRIVER config keys (host, port, credentials, `deviceCache`, every
|
|
3447
|
+
* schema field) SWAP — they describe the box.
|
|
3448
|
+
* - The device-manager-owned section keys ({@link DEVICE_STORE_SECTIONS}:
|
|
3449
|
+
* linked-devices mode/ids) STAY with the number — a sensor is linked to
|
|
3450
|
+
* "the driveway camera", not to a serial number.
|
|
3451
|
+
* - `_profileMap` is DROPPED from both. It is the legacy spelling of the
|
|
3452
|
+
* stream-profile assignment (the live authority is the stream broker's own
|
|
3453
|
+
* map — see `StreamsTab.tsx`, which stopped writing it), and either copy
|
|
3454
|
+
* names stream ids of the wrong hardware after a swap.
|
|
3455
|
+
* - The runtime-state blobs (`__device-state:<id>` — battery snapshot, sleep
|
|
3456
|
+
* flag, feature probe) SWAP wholesale: every slice mirrors the box.
|
|
3457
|
+
*
|
|
3458
|
+
* ## Crash points are loud, same as `swapIds`
|
|
3459
|
+
*
|
|
3460
|
+
* There is no transaction: each blob is cleared and rewritten. A crash between
|
|
3461
|
+
* clear and write leaves a device with NO config — a provider that fails to
|
|
3462
|
+
* construct and says so — never two devices wearing each other's, which is the
|
|
3463
|
+
* state that reads as plausible and costs a day.
|
|
3464
|
+
*/
|
|
3465
|
+
/** Legacy stream-profile assignment key (cf. `device-queries.ts`). */
|
|
3466
|
+
var PROFILE_MAP_CONFIG_KEY = "_profileMap";
|
|
3467
|
+
/** Config keys that belong to the camera's ROLE and stay with the number. */
|
|
3468
|
+
var ROLE_SCOPED_CONFIG_KEYS = new Set(DEVICE_STORE_SECTIONS.flatMap((section) => [...section.keys]));
|
|
3469
|
+
function splitConfig(config) {
|
|
3470
|
+
const hardware = {};
|
|
3471
|
+
const role = {};
|
|
3472
|
+
for (const [key, value] of Object.entries(config)) {
|
|
3473
|
+
if (key === PROFILE_MAP_CONFIG_KEY) continue;
|
|
3474
|
+
if (ROLE_SCOPED_CONFIG_KEYS.has(key)) role[key] = value;
|
|
3475
|
+
else hardware[key] = value;
|
|
3476
|
+
}
|
|
3477
|
+
return {
|
|
3478
|
+
hardware,
|
|
3479
|
+
role
|
|
3480
|
+
};
|
|
3481
|
+
}
|
|
3482
|
+
/**
|
|
3483
|
+
* Swap the hardware-scoped halves of two devices' persisted state. Runs right
|
|
3484
|
+
* after `swapIds`, under the same meta write lock. THROWS on failure: a
|
|
3485
|
+
* migration whose rows swapped but whose configs did not is exactly the
|
|
3486
|
+
* incident this module exists to prevent, and it must never be reported as a
|
|
3487
|
+
* success.
|
|
3488
|
+
*/
|
|
3489
|
+
async function swapHardwareState(settings, sourceId, targetId) {
|
|
3490
|
+
const sourceConfig = await settings.readDeviceStore(sourceId);
|
|
3491
|
+
const targetConfig = await settings.readDeviceStore(targetId);
|
|
3492
|
+
const source = splitConfig(sourceConfig);
|
|
3493
|
+
const target = splitConfig(targetConfig);
|
|
3494
|
+
await settings.clearDeviceStore(sourceId);
|
|
3495
|
+
await settings.writeDeviceStore(sourceId, {
|
|
3496
|
+
...target.hardware,
|
|
3497
|
+
...source.role
|
|
3498
|
+
});
|
|
3499
|
+
await settings.clearDeviceStore(targetId);
|
|
3500
|
+
await settings.writeDeviceStore(targetId, {
|
|
3501
|
+
...source.hardware,
|
|
3502
|
+
...target.role
|
|
3503
|
+
});
|
|
3504
|
+
const sourceRuntimeState = await settings.readDeviceRuntimeState(sourceId);
|
|
3505
|
+
const targetRuntimeState = await settings.readDeviceRuntimeState(targetId);
|
|
3506
|
+
await settings.writeDeviceRuntimeState(sourceId, targetRuntimeState);
|
|
3507
|
+
await settings.writeDeviceRuntimeState(targetId, sourceRuntimeState);
|
|
3508
|
+
return {
|
|
3509
|
+
runtimeStateNowAtSource: targetRuntimeState,
|
|
3510
|
+
runtimeStateNowAtTarget: sourceRuntimeState,
|
|
3511
|
+
hardwareKeysToSource: Object.keys(target.hardware).toSorted(),
|
|
3512
|
+
hardwareKeysToTarget: Object.keys(source.hardware).toSorted(),
|
|
3513
|
+
roleKeysKeptAtSource: Object.keys(source.role).toSorted(),
|
|
3514
|
+
roleKeysKeptAtTarget: Object.keys(target.role).toSorted()
|
|
3515
|
+
};
|
|
3516
|
+
}
|
|
3517
|
+
//#endregion
|
|
3342
3518
|
//#region src/builtins/device-manager/device-meta-actions.ts
|
|
3343
3519
|
/**
|
|
3344
3520
|
* Device meta-mutation + persistence actions for the device-manager addon —
|
|
@@ -3418,48 +3594,126 @@ async function allocateDeviceId(pctx, input) {
|
|
|
3418
3594
|
* The composition lives in `migrate-device.ts`, deliberately free of this
|
|
3419
3595
|
* file's context so the decisions it carries — above all "never report a switch
|
|
3420
3596
|
* as off when it could not be written" — are testable without a hub. This is
|
|
3421
|
-
* the wiring: the
|
|
3597
|
+
* the wiring: the real dependencies, and the write lock.
|
|
3422
3598
|
*
|
|
3423
3599
|
* The temp id comes from `allocateNextDeviceId`, the monotonic counter, so the
|
|
3424
3600
|
* value a crash could park a row on was never used and will never be reused.
|
|
3425
3601
|
* `swapIds` therefore runs INSIDE the same lock the id was allocated under.
|
|
3602
|
+
*
|
|
3603
|
+
* Retry-safe since 2026-09-04 (`migrate-guard.ts`): a concurrent call touching
|
|
3604
|
+
* either device is REFUSED before it can queue on the lock, and a repeat of a
|
|
3605
|
+
* completed swap — matched against the `addonId`/`stableId` fingerprint the
|
|
3606
|
+
* swap left on the rows — is a no-op returning the original report. A client
|
|
3607
|
+
* retry ran a second full cycle on 2026-09-01 and swapped two cameras back;
|
|
3608
|
+
* neither path can re-run a swap any more.
|
|
3426
3609
|
*/
|
|
3427
3610
|
async function migrateDevice(pctx, input) {
|
|
3428
3611
|
const { sourceId, targetId } = input;
|
|
3429
3612
|
const ctx = pctx.host.ctx;
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
const
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
},
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
}
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
for (const deviceId of deviceIds) {
|
|
3447
|
-
const addonId = pctx.metaStore.idToAddonId.get(deviceId);
|
|
3448
|
-
if (addonId !== void 0) owners.add(addonId);
|
|
3449
|
-
}
|
|
3450
|
-
for (const addonId of owners) await ctx.api.addons.restartAddon.mutate({ addonId });
|
|
3451
|
-
},
|
|
3452
|
-
log: (message, meta) => {
|
|
3453
|
-
ctx.logger.info(message, {
|
|
3613
|
+
pctx.migrationGuard.begin(sourceId, targetId);
|
|
3614
|
+
try {
|
|
3615
|
+
const outcome = await pctx.metaStore.withMetaWriteLock(async () => {
|
|
3616
|
+
const sourceRow = await pctx.metaStore.rows.get(sourceId);
|
|
3617
|
+
if (sourceRow === null) throw new Error(`[device-manager] migrateDevice: device ${sourceId} does not exist`);
|
|
3618
|
+
const targetRow = await pctx.metaStore.rows.get(targetId);
|
|
3619
|
+
if (targetRow === null) throw new Error(`[device-manager] migrateDevice: device ${targetId} does not exist`);
|
|
3620
|
+
const replay = pctx.migrationGuard.matchReplay(sourceId, targetId, {
|
|
3621
|
+
addonId: sourceRow.meta.addonId,
|
|
3622
|
+
stableId: sourceRow.meta.stableId
|
|
3623
|
+
}, {
|
|
3624
|
+
addonId: targetRow.meta.addonId,
|
|
3625
|
+
stableId: targetRow.meta.stableId
|
|
3626
|
+
});
|
|
3627
|
+
if (replay !== null) {
|
|
3628
|
+
ctx.logger.info("migrateDevice: this swap already completed — replaying the original report, NOTHING re-run", {
|
|
3454
3629
|
tags: { deviceId: sourceId },
|
|
3455
|
-
meta
|
|
3630
|
+
meta: {
|
|
3631
|
+
sourceId,
|
|
3632
|
+
targetId
|
|
3633
|
+
}
|
|
3456
3634
|
});
|
|
3635
|
+
return {
|
|
3636
|
+
result: replay,
|
|
3637
|
+
reloadTargets: null
|
|
3638
|
+
};
|
|
3457
3639
|
}
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3640
|
+
const tempId = await pctx.metaStore.allocateNextDeviceId();
|
|
3641
|
+
const result = await migrateDevice$1({
|
|
3642
|
+
setSwitch: async (deviceId, switchId, enabled) => {
|
|
3643
|
+
if (switchId === "stream-broker") {
|
|
3644
|
+
const moved = await writeDisabledFlagUnderLock(pctx, deviceId, !enabled);
|
|
3645
|
+
emitDisabledEvents(pctx, deviceId, !enabled, moved);
|
|
3646
|
+
return;
|
|
3647
|
+
}
|
|
3648
|
+
await ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
|
|
3649
|
+
deviceId,
|
|
3650
|
+
switchId,
|
|
3651
|
+
enabled
|
|
3652
|
+
});
|
|
3653
|
+
},
|
|
3654
|
+
swapIds: async (a, b) => {
|
|
3655
|
+
await pctx.metaStore.rows.swapIds(a, b, tempId);
|
|
3656
|
+
},
|
|
3657
|
+
swapHardwareState: async (a, b) => {
|
|
3658
|
+
const swapped = await swapHardwareState(pctx.settings, a, b);
|
|
3659
|
+
pctx.host.seedMirror(a, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtSource));
|
|
3660
|
+
pctx.host.seedMirror(b, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtTarget));
|
|
3661
|
+
ctx.logger.info("migration: hardware-scoped device state swapped", {
|
|
3662
|
+
tags: { deviceId: a },
|
|
3663
|
+
meta: {
|
|
3664
|
+
sourceId: a,
|
|
3665
|
+
targetId: b,
|
|
3666
|
+
hardwareKeysToSource: swapped.hardwareKeysToSource,
|
|
3667
|
+
hardwareKeysToTarget: swapped.hardwareKeysToTarget,
|
|
3668
|
+
roleKeysKeptAtSource: swapped.roleKeysKeptAtSource,
|
|
3669
|
+
roleKeysKeptAtTarget: swapped.roleKeysKeptAtTarget
|
|
3670
|
+
}
|
|
3671
|
+
});
|
|
3672
|
+
},
|
|
3673
|
+
forgetStreamState: async (deviceId) => {
|
|
3674
|
+
await ctx.api.streamBroker.forgetDeviceHardware.mutate({ deviceId });
|
|
3675
|
+
},
|
|
3676
|
+
log: (message, meta) => {
|
|
3677
|
+
ctx.logger.info(message, {
|
|
3678
|
+
tags: { deviceId: sourceId },
|
|
3679
|
+
meta
|
|
3680
|
+
});
|
|
3681
|
+
}
|
|
3682
|
+
}, {
|
|
3683
|
+
sourceId,
|
|
3684
|
+
targetId
|
|
3685
|
+
});
|
|
3686
|
+
pctx.migrationGuard.recordCompleted({
|
|
3687
|
+
sourceId,
|
|
3688
|
+
targetId,
|
|
3689
|
+
identityAtSource: {
|
|
3690
|
+
addonId: targetRow.meta.addonId,
|
|
3691
|
+
stableId: targetRow.meta.stableId
|
|
3692
|
+
},
|
|
3693
|
+
identityAtTarget: {
|
|
3694
|
+
addonId: sourceRow.meta.addonId,
|
|
3695
|
+
stableId: sourceRow.meta.stableId
|
|
3696
|
+
},
|
|
3697
|
+
result
|
|
3698
|
+
});
|
|
3699
|
+
return {
|
|
3700
|
+
result,
|
|
3701
|
+
reloadTargets: [{
|
|
3702
|
+
deviceId: sourceId,
|
|
3703
|
+
addonId: targetRow.meta.addonId,
|
|
3704
|
+
stableId: targetRow.meta.stableId
|
|
3705
|
+
}, {
|
|
3706
|
+
deviceId: targetId,
|
|
3707
|
+
addonId: sourceRow.meta.addonId,
|
|
3708
|
+
stableId: sourceRow.meta.stableId
|
|
3709
|
+
}]
|
|
3710
|
+
};
|
|
3461
3711
|
});
|
|
3462
|
-
|
|
3712
|
+
if (outcome.reloadTargets !== null) await reloadMigratedDevices(pctx, outcome.reloadTargets);
|
|
3713
|
+
return outcome.result;
|
|
3714
|
+
} finally {
|
|
3715
|
+
pctx.migrationGuard.end(sourceId, targetId);
|
|
3716
|
+
}
|
|
3463
3717
|
}
|
|
3464
3718
|
async function registerDevice(pctx, input) {
|
|
3465
3719
|
const { addonId, stableId, id, type, name, parentDeviceId, features, config } = input;
|
|
@@ -4146,35 +4400,23 @@ async function setMetadata(pctx, input) {
|
|
|
4146
4400
|
});
|
|
4147
4401
|
}
|
|
4148
4402
|
/**
|
|
4149
|
-
*
|
|
4150
|
-
*
|
|
4151
|
-
*
|
|
4152
|
-
*
|
|
4153
|
-
*
|
|
4154
|
-
* TWO events leave here, and they are not the same message:
|
|
4155
|
-
*
|
|
4156
|
-
* - `device.meta-changed` fires on EVERY call. It is the meta-surface echo
|
|
4157
|
-
* every UI redraws on, and idempotence is not interesting to it.
|
|
4158
|
-
* - `device.disabled` / `device.enabled` fire ONLY when the flag actually
|
|
4159
|
-
* MOVES. They are the lifecycle announcement — the operator asked to be
|
|
4160
|
-
* told when a camera is switched off, and a write that re-states a flag the
|
|
4161
|
-
* device already had is not that. Telemetry semantics (D8) are fine here: a
|
|
4162
|
-
* dropped announcement costs a notification, never durable state, and the
|
|
4163
|
-
* meta row is the authority either way (D62 — this switch stores nothing of
|
|
4164
|
-
* its own).
|
|
4403
|
+
* Read + patch the `disabled` flag. The caller HOLDS the meta write lock —
|
|
4404
|
+
* this is split out of {@link setDisabled} so `migrateDevice`, which holds the
|
|
4405
|
+
* lock for its whole run, can write the same authority without re-acquiring
|
|
4406
|
+
* it (the self-deadlock documented at its `setSwitch`).
|
|
4165
4407
|
*/
|
|
4166
|
-
async function
|
|
4167
|
-
const
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4408
|
+
async function writeDisabledFlagUnderLock(pctx, deviceId, disabled) {
|
|
4409
|
+
const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
|
|
4410
|
+
if (!persisted) throw new Error(`[device-manager] setDisabled: unknown device id=${deviceId}`);
|
|
4411
|
+
const { meta: m } = persisted;
|
|
4412
|
+
await pctx.metaStore.rows.patch(deviceId, { disabled });
|
|
4413
|
+
return {
|
|
4414
|
+
changed: (m.disabled ?? false) !== disabled,
|
|
4415
|
+
integrationId: m.integrationId ?? ""
|
|
4416
|
+
};
|
|
4417
|
+
}
|
|
4418
|
+
/** The two announcements — meta echo always, lifecycle only on a transition. */
|
|
4419
|
+
function emitDisabledEvents(pctx, deviceId, disabled, moved) {
|
|
4178
4420
|
pctx.host.ctx.eventBus.emit({
|
|
4179
4421
|
id: randomUUID(),
|
|
4180
4422
|
timestamp: /* @__PURE__ */ new Date(),
|
|
@@ -4204,6 +4446,10 @@ async function setDisabled(pctx, input) {
|
|
|
4204
4446
|
}
|
|
4205
4447
|
});
|
|
4206
4448
|
}
|
|
4449
|
+
async function setDisabled(pctx, input) {
|
|
4450
|
+
const { deviceId, disabled } = input;
|
|
4451
|
+
emitDisabledEvents(pctx, deviceId, disabled, await pctx.metaStore.withMetaWriteLock(() => writeDisabledFlagUnderLock(pctx, deviceId, disabled)));
|
|
4452
|
+
}
|
|
4207
4453
|
async function loadRuntimeState(pctx, input) {
|
|
4208
4454
|
const { deviceId } = input;
|
|
4209
4455
|
if (!await pctx.metaStore.resolvePersistedById(deviceId)) return {};
|
|
@@ -5818,6 +6064,43 @@ function resolveNativeCapOwnerSync(pctx, capName, deviceId) {
|
|
|
5818
6064
|
};
|
|
5819
6065
|
return resolveRemoteNativeCapFromRegistry(pctx.bindingsDeps, capName, deviceId);
|
|
5820
6066
|
}
|
|
6067
|
+
function sameIdentity(a, b) {
|
|
6068
|
+
return a.addonId === b.addonId && a.stableId === b.stableId;
|
|
6069
|
+
}
|
|
6070
|
+
/** One guard per device-manager instance. `now` is injectable for the specs. */
|
|
6071
|
+
function createMigrationGuard(now = Date.now) {
|
|
6072
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
6073
|
+
const completed = /* @__PURE__ */ new Map();
|
|
6074
|
+
const pairKey = (sourceId, targetId) => `${sourceId}->${targetId}`;
|
|
6075
|
+
return {
|
|
6076
|
+
begin(sourceId, targetId) {
|
|
6077
|
+
for (const id of [sourceId, targetId]) if (inFlight.has(id)) throw new Error(`[device-manager] migrateDevice: a migration involving device ${id} is already in flight — this call was refused, not queued. The running migration's report stands; re-read the device rows before retrying.`);
|
|
6078
|
+
inFlight.add(sourceId);
|
|
6079
|
+
inFlight.add(targetId);
|
|
6080
|
+
},
|
|
6081
|
+
end(sourceId, targetId) {
|
|
6082
|
+
inFlight.delete(sourceId);
|
|
6083
|
+
inFlight.delete(targetId);
|
|
6084
|
+
},
|
|
6085
|
+
recordCompleted(record) {
|
|
6086
|
+
completed.set(pairKey(record.sourceId, record.targetId), {
|
|
6087
|
+
...record,
|
|
6088
|
+
completedAt: now()
|
|
6089
|
+
});
|
|
6090
|
+
},
|
|
6091
|
+
matchReplay(sourceId, targetId, atSource, atTarget) {
|
|
6092
|
+
const key = pairKey(sourceId, targetId);
|
|
6093
|
+
const record = completed.get(key);
|
|
6094
|
+
if (record === void 0) return null;
|
|
6095
|
+
if (now() - record.completedAt > 9e5) {
|
|
6096
|
+
completed.delete(key);
|
|
6097
|
+
return null;
|
|
6098
|
+
}
|
|
6099
|
+
if (!sameIdentity(record.identityAtSource, atSource) || !sameIdentity(record.identityAtTarget, atTarget)) return null;
|
|
6100
|
+
return record.result;
|
|
6101
|
+
}
|
|
6102
|
+
};
|
|
6103
|
+
}
|
|
5821
6104
|
//#endregion
|
|
5822
6105
|
//#region src/builtins/device-manager/device-manager.addon.ts
|
|
5823
6106
|
var DeviceManagerAddon = class extends BaseAddon {
|
|
@@ -6058,6 +6341,7 @@ var DeviceManagerAddon = class extends BaseAddon {
|
|
|
6058
6341
|
settings,
|
|
6059
6342
|
registry,
|
|
6060
6343
|
bindingsDeps: this.bindingsDeps,
|
|
6344
|
+
migrationGuard: createMigrationGuard(),
|
|
6061
6345
|
requireDeviceOps,
|
|
6062
6346
|
stampIntegrationId: stampIntegrationId$1,
|
|
6063
6347
|
get provider() {
|
|
@@ -19,11 +19,18 @@ export declare function allocateDeviceId(pctx: ProviderContext, input: Parameter
|
|
|
19
19
|
* The composition lives in `migrate-device.ts`, deliberately free of this
|
|
20
20
|
* file's context so the decisions it carries — above all "never report a switch
|
|
21
21
|
* as off when it could not be written" — are testable without a hub. This is
|
|
22
|
-
* the wiring: the
|
|
22
|
+
* the wiring: the real dependencies, and the write lock.
|
|
23
23
|
*
|
|
24
24
|
* The temp id comes from `allocateNextDeviceId`, the monotonic counter, so the
|
|
25
25
|
* value a crash could park a row on was never used and will never be reused.
|
|
26
26
|
* `swapIds` therefore runs INSIDE the same lock the id was allocated under.
|
|
27
|
+
*
|
|
28
|
+
* Retry-safe since 2026-09-04 (`migrate-guard.ts`): a concurrent call touching
|
|
29
|
+
* either device is REFUSED before it can queue on the lock, and a repeat of a
|
|
30
|
+
* completed swap — matched against the `addonId`/`stableId` fingerprint the
|
|
31
|
+
* swap left on the rows — is a no-op returning the original report. A client
|
|
32
|
+
* retry ran a second full cycle on 2026-09-01 and swapped two cameras back;
|
|
33
|
+
* neither path can re-run a swap any more.
|
|
27
34
|
*/
|
|
28
35
|
export declare function migrateDevice(pctx: ProviderContext, input: Parameters<IDeviceManagerProvider['migrateDevice']>[0]): ReturnType<IDeviceManagerProvider['migrateDevice']>;
|
|
29
36
|
export declare function registerDevice(pctx: ProviderContext, input: Parameters<IDeviceManagerProvider['registerDevice']>[0]): ReturnType<IDeviceManagerProvider['registerDevice']>;
|
|
@@ -188,24 +195,6 @@ export declare function applyInitialMeta(pctx: ProviderContext, input: Parameter
|
|
|
188
195
|
* the meta-changed event.
|
|
189
196
|
*/
|
|
190
197
|
export declare function setMetadata(pctx: ProviderContext, input: Parameters<IDeviceManagerProvider['setMetadata']>[0]): ReturnType<IDeviceManagerProvider['setMetadata']>;
|
|
191
|
-
/**
|
|
192
|
-
* Soft-disable the device. Persisted on the meta row;
|
|
193
|
-
* lifecycle gating is the driver's responsibility (BaseDevice
|
|
194
|
-
* exposes `this.disabled` for the driver to consult at the top
|
|
195
|
-
* of its lifecycle methods).
|
|
196
|
-
*
|
|
197
|
-
* TWO events leave here, and they are not the same message:
|
|
198
|
-
*
|
|
199
|
-
* - `device.meta-changed` fires on EVERY call. It is the meta-surface echo
|
|
200
|
-
* every UI redraws on, and idempotence is not interesting to it.
|
|
201
|
-
* - `device.disabled` / `device.enabled` fire ONLY when the flag actually
|
|
202
|
-
* MOVES. They are the lifecycle announcement — the operator asked to be
|
|
203
|
-
* told when a camera is switched off, and a write that re-states a flag the
|
|
204
|
-
* device already had is not that. Telemetry semantics (D8) are fine here: a
|
|
205
|
-
* dropped announcement costs a notification, never durable state, and the
|
|
206
|
-
* meta row is the authority either way (D62 — this switch stores nothing of
|
|
207
|
-
* its own).
|
|
208
|
-
*/
|
|
209
198
|
export declare function setDisabled(pctx: ProviderContext, input: Parameters<IDeviceManagerProvider['setDisabled']>[0]): ReturnType<IDeviceManagerProvider['setDisabled']>;
|
|
210
199
|
export declare function loadRuntimeState(pctx: ProviderContext, input: Parameters<IDeviceManagerProvider['loadRuntimeState']>[0]): ReturnType<IDeviceManagerProvider['loadRuntimeState']>;
|
|
211
200
|
/**
|
|
@@ -2,6 +2,7 @@ import { AddonContext, ICapabilityRegistry, IDeviceAdoptionProvider, IDeviceMana
|
|
|
2
2
|
import { AdoptionJobEngine } from './adoption-job-engine.js';
|
|
3
3
|
import { BindingsDeps } from './device-bindings-store.js';
|
|
4
4
|
import { DeviceManagerSettings, DeviceMetaStore } from './device-meta-store.js';
|
|
5
|
+
import { MigrationGuard } from './migrate-guard.js';
|
|
5
6
|
type IDeviceProviderCap = InferProvider<typeof deviceProviderCapability>;
|
|
6
7
|
/**
|
|
7
8
|
* The addon-instance surface the extracted provider functions need. Exposed by
|
|
@@ -47,6 +48,9 @@ export interface ProviderContext {
|
|
|
47
48
|
/** Hub-process device registry (null when this node owns no registry). */
|
|
48
49
|
readonly registry: IDeviceRegistry | null;
|
|
49
50
|
readonly bindingsDeps: BindingsDeps;
|
|
51
|
+
/** In-flight refusal + completed-swap replay for `migrateDevice` — the
|
|
52
|
+
* reason a retried migration can no longer undo the one that ran. */
|
|
53
|
+
readonly migrationGuard: MigrationGuard;
|
|
50
54
|
/** Resolve the per-device `device-ops` native provider (throws when absent). */
|
|
51
55
|
requireDeviceOps(deviceId: number): IDeviceOpsProvider;
|
|
52
56
|
/** Stamp an integration id onto a device's meta row + emit DeviceMetaChanged. */
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { CameraSwitchId } from '@camstack/types';
|
|
2
|
+
/**
|
|
3
|
+
* Upper bound for ONE switch write. Generous against a live camera (a real
|
|
4
|
+
* write is sub-second; the camera-plane pair is a few seconds of HTTP) and
|
|
5
|
+
* small against the alternative: the 60 s transport default that let three
|
|
6
|
+
* phases burn three minutes on 2026-09-01.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SWITCH_WRITE_TIMEOUT_MS = 15000;
|
|
2
9
|
/** What actually happened to one switch on one device. */
|
|
3
10
|
export type SwitchOutcome = 'off' | 'on' | 'not-offered' | 'unreachable';
|
|
4
11
|
export interface SwitchReport {
|
|
@@ -26,10 +33,47 @@ export interface MigrateDeviceDeps {
|
|
|
26
33
|
readonly setSwitch: (deviceId: number, switchId: CameraSwitchId, enabled: boolean) => Promise<void>;
|
|
27
34
|
/** `DeviceRowStore.swapIds`, already under the meta write lock. */
|
|
28
35
|
readonly swapIds: (a: number, b: number) => Promise<void>;
|
|
29
|
-
/**
|
|
30
|
-
*
|
|
31
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Swap the HARDWARE-scoped per-device state along with the rows: the driver
|
|
38
|
+
* config blob (host/port/credentials/`deviceCache` — everything the
|
|
39
|
+
* provider's schema owns) and the runtime-state blob. Role-scoped keys stay
|
|
40
|
+
* with the number. See `migrate-hardware-state.ts` for the rule, and for
|
|
41
|
+
* the incident where the rows swapped and the configs did not — the two
|
|
42
|
+
* devices came out wearing each other's identity.
|
|
43
|
+
*
|
|
44
|
+
* A failure here THROWS, and the migration throws with it: rows swapped
|
|
45
|
+
* with configs unswapped is exactly that incident, and it must never be
|
|
46
|
+
* reported as a success.
|
|
47
|
+
*/
|
|
48
|
+
readonly swapHardwareState: (sourceId: number, targetId: number) => Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* Tell the stream broker the hardware behind this number is gone
|
|
51
|
+
* (`streamBroker.forgetDeviceHardware`): every DERIVED stream definition is
|
|
52
|
+
* DELETED, the profile-slot assignment entry is purged, and the probed
|
|
53
|
+
* snapshots and RTSP token rows of the old streams go with it.
|
|
54
|
+
*
|
|
55
|
+
* Measured on the live hub after replacing a Hikvision with a Reolink on id
|
|
56
|
+
* 618: the assignment still said `mid → native:slot-3`, a raw slot only the
|
|
57
|
+
* Hikvision had, and the `derived:Mid to low` feeder — defined against the
|
|
58
|
+
* old profile layout — respawned every ten seconds forever (observed at
|
|
59
|
+
* attempt 2732). The recorder reported `no profile attached for device
|
|
60
|
+
* 618`, live view answered `No broker for stream "618/adaptive"`, and no
|
|
61
|
+
* line named the stale assignment as the cause.
|
|
62
|
+
*
|
|
63
|
+
* Forgetting is deliberately what this does, rather than guessing a
|
|
64
|
+
* mapping: the catalog is the authority on which stream is which, and it
|
|
65
|
+
* belongs to the camera that is now there. An RPC, never an event — a
|
|
66
|
+
* dropped event (D8) would leave the feeder respawning forever.
|
|
67
|
+
*/
|
|
68
|
+
readonly forgetStreamState: (deviceId: number) => Promise<void>;
|
|
32
69
|
readonly log: (message: string, meta: Record<string, unknown>) => void;
|
|
70
|
+
/**
|
|
71
|
+
* Override for {@link SWITCH_WRITE_TIMEOUT_MS}, for the specs. A write that
|
|
72
|
+
* outlives the bound is reported `unreachable` — never `off`/`on` — and may
|
|
73
|
+
* still land later, which every switch authority tolerates (a duplicate
|
|
74
|
+
* write of the same value is a no-op at all eight of them).
|
|
75
|
+
*/
|
|
76
|
+
readonly switchTimeoutMs?: number;
|
|
33
77
|
}
|
|
34
78
|
/**
|
|
35
79
|
* Put both cameras to sleep, exchange their identities, wake the target.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { MigrateDeviceResult } from './migrate-device.js';
|
|
2
|
+
/** The addon-facing identity a device row carries — what `swapIds` moves. */
|
|
3
|
+
export interface DeviceIdentity {
|
|
4
|
+
readonly addonId: string;
|
|
5
|
+
readonly stableId: string;
|
|
6
|
+
}
|
|
7
|
+
/** What a finished migration left behind, for replay matching. */
|
|
8
|
+
export interface CompletedMigration {
|
|
9
|
+
readonly sourceId: number;
|
|
10
|
+
readonly targetId: number;
|
|
11
|
+
/** Identity the completed swap left at `sourceId` (the pre-swap target's). */
|
|
12
|
+
readonly identityAtSource: DeviceIdentity;
|
|
13
|
+
/** Identity the completed swap left at `targetId` (the pre-swap source's). */
|
|
14
|
+
readonly identityAtTarget: DeviceIdentity;
|
|
15
|
+
readonly result: MigrateDeviceResult;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* How long a completed migration absorbs an identical repeat call. Sized to
|
|
19
|
+
* comfortably cover the cap's own `timeoutMs` (10 min): a retry fired by a
|
|
20
|
+
* client that gave up arrives inside that envelope; a call arriving later is
|
|
21
|
+
* an operator's deliberate new operation and runs.
|
|
22
|
+
*/
|
|
23
|
+
export declare const MIGRATION_REPLAY_WINDOW_MS: number;
|
|
24
|
+
export interface MigrationGuard {
|
|
25
|
+
/** Claim both ids for a run. Throws when either is already in flight. */
|
|
26
|
+
begin(sourceId: number, targetId: number): void;
|
|
27
|
+
/** Release both ids. Always paired with {@link begin} via try/finally. */
|
|
28
|
+
end(sourceId: number, targetId: number): void;
|
|
29
|
+
/** Record a completed swap's fingerprint for replay matching. */
|
|
30
|
+
recordCompleted(record: CompletedMigration): void;
|
|
31
|
+
/**
|
|
32
|
+
* The original report, when the call is a repeat of a completed swap: same
|
|
33
|
+
* ordered pair, current row identities equal to the recorded post-swap
|
|
34
|
+
* identities, within the window. `null` means "this is a new operation".
|
|
35
|
+
*/
|
|
36
|
+
matchReplay(sourceId: number, targetId: number, atSource: DeviceIdentity, atTarget: DeviceIdentity): MigrateDeviceResult | null;
|
|
37
|
+
}
|
|
38
|
+
/** One guard per device-manager instance. `now` is injectable for the specs. */
|
|
39
|
+
export declare function createMigrationGuard(now?: () => number): MigrationGuard;
|