@camstack/addon-provider-unraid 0.2.153 → 0.2.154

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/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.154] - 2026-11-01
11
+
12
+ ### Changed
13
+
14
+ - An integration deleted while its provider was down now has its orphan devices removed after two concordant reads of the integration list
15
+
10
16
  ## [0.2.153] - 2026-11-01
11
17
 
12
18
  ### Changed
package/dist/addon.js CHANGED
@@ -94497,6 +94497,88 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
94497
94497
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
94498
94498
  DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
94499
94499
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
94500
+ /**
94501
+ * Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
94502
+ * event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
94503
+ * devices go (through the provider's own removeDevices) when a SECOND answered read, at least
94504
+ * `minIntervalMs` later, still lacks it. A read that threw changes nothing; reappearing clears.
94505
+ */
94506
+ var OrphanIntegrationSweep = class {
94507
+ #logger;
94508
+ #minIntervalMs;
94509
+ #now;
94510
+ #suspects = /* @__PURE__ */ new Map();
94511
+ constructor(options) {
94512
+ this.#logger = options.logger;
94513
+ this.#minIntervalMs = options.minIntervalMs ?? 6e4;
94514
+ this.#now = options.now ?? Date.now;
94515
+ }
94516
+ get minIntervalMs() {
94517
+ return this.#minIntervalMs;
94518
+ }
94519
+ async sweep(input) {
94520
+ if (input.listedIds === null) return {
94521
+ newSuspects: 0,
94522
+ removed: []
94523
+ };
94524
+ const listed = new Set(input.listedIds);
94525
+ const owners = new Set(input.ownerIds);
94526
+ const now = this.#now();
94527
+ let newSuspects = 0;
94528
+ const removed = [];
94529
+ for (const id of [...this.#suspects.keys()]) if (listed.has(id) || !owners.has(id)) {
94530
+ this.#suspects.delete(id);
94531
+ this.#logger.info("Orphan integration suspect cleared", { meta: {
94532
+ integrationId: id,
94533
+ reason: listed.has(id) ? "listed again" : "owns no devices"
94534
+ } });
94535
+ }
94536
+ for (const id of owners) {
94537
+ if (listed.has(id)) continue;
94538
+ const since = this.#suspects.get(id);
94539
+ if (since === void 0) {
94540
+ this.#suspects.set(id, now);
94541
+ newSuspects += 1;
94542
+ this.#logger.info("Integration owns devices but is absent from the list — suspect", { meta: {
94543
+ integrationId: id,
94544
+ minIntervalMs: this.#minIntervalMs
94545
+ } });
94546
+ continue;
94547
+ }
94548
+ if (now - since < this.#minIntervalMs) continue;
94549
+ try {
94550
+ const count = await input.removeDevices(id);
94551
+ this.#suspects.delete(id);
94552
+ removed.push(id);
94553
+ this.#logger.warn("Orphan integration devices removed — absent from two answered lists", { meta: {
94554
+ integrationId: id,
94555
+ removed: count,
94556
+ firstMissingAt: since
94557
+ } });
94558
+ } catch (err) {
94559
+ this.#logger.warn("Orphan integration device removal failed — kept as suspect", { meta: {
94560
+ integrationId: id,
94561
+ error: errMsg(err)
94562
+ } });
94563
+ }
94564
+ }
94565
+ return {
94566
+ newSuspects,
94567
+ removed
94568
+ };
94569
+ }
94570
+ };
94571
+ /** The integration ids that own top-level devices of one provider (a config that cannot be read is skipped). */
94572
+ async function collectOwnerIntegrationIds(input) {
94573
+ const ids = /* @__PURE__ */ new Set();
94574
+ for (const d of input.reg.getAllForAddon(input.addonId)) {
94575
+ if (d.parentDeviceId !== null) continue;
94576
+ const cfg = await input.devices.loadConfig(d.id).catch(() => ({}));
94577
+ const owner = input.ownerOf(cfg);
94578
+ if (owner !== null) ids.add(owner);
94579
+ }
94580
+ return [...ids];
94581
+ }
94500
94582
  String.raw`(?<=^|["'(\s>,\[{])`;
94501
94583
  var MB = 1024 * 1024;
94502
94584
  1024 * MB, 3072 * MB;
@@ -96085,8 +96167,14 @@ function buildUnraidDeviceAdopter(input) {
96085
96167
  */
96086
96168
  var UnraidReconciler = class {
96087
96169
  #deps;
96170
+ #sweep;
96171
+ #rerun = null;
96088
96172
  constructor(deps) {
96089
96173
  this.#deps = deps;
96174
+ this.#sweep = new OrphanIntegrationSweep({
96175
+ logger: deps.logger,
96176
+ ...deps.sweepOptions
96177
+ });
96090
96178
  }
96091
96179
  /** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
96092
96180
  async reconcile() {
@@ -96109,6 +96197,37 @@ var UnraidReconciler = class {
96109
96197
  }
96110
96198
  const enabledIds = new Set(enabled.map((i) => i.id));
96111
96199
  for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
96200
+ await this.#sweepOrphans(all.map((i) => i.id));
96201
+ }
96202
+ /** Stop the scheduled second read (addon stop). */
96203
+ dispose() {
96204
+ if (this.#rerun !== null) clearTimeout(this.#rerun);
96205
+ this.#rerun = null;
96206
+ }
96207
+ /**
96208
+ * An integration deleted while this addon was down emits nothing it sees: its id owns devices
96209
+ * yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
96210
+ * re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
96211
+ */
96212
+ async #sweepOrphans(listedIds) {
96213
+ const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
96214
+ if (ownerIntegrationIds === void 0) return;
96215
+ try {
96216
+ const ownerIds = await ownerIntegrationIds();
96217
+ if ((await this.#sweep.sweep({
96218
+ ownerIds,
96219
+ listedIds,
96220
+ removeDevices
96221
+ })).newSuspects > 0 && this.#rerun === null) {
96222
+ this.#rerun = setTimeout(() => {
96223
+ this.#rerun = null;
96224
+ this.reconcile();
96225
+ }, this.#sweep.minIntervalMs + 1e3);
96226
+ this.#rerun.unref();
96227
+ }
96228
+ } catch (err) {
96229
+ logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
96230
+ }
96112
96231
  }
96113
96232
  /** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
96114
96233
  async onLifecycle(kind, integrationId) {
@@ -96233,6 +96352,7 @@ var UnraidProviderAddon = class extends BaseDeviceProvider {
96233
96352
  this.ctx.logger.warn("Unraid shutdown error", { meta: { error: errMsg(err) } });
96234
96353
  }
96235
96354
  this.clients = null;
96355
+ this.integrationReconciler?.dispose();
96236
96356
  this.integrationReconciler = null;
96237
96357
  unraidInstances.clear();
96238
96358
  await super.onShutdown();
@@ -96267,7 +96387,8 @@ var UnraidProviderAddon = class extends BaseDeviceProvider {
96267
96387
  await reg.upsert(integration.id, integration.name, read.connection, read.apiKeyLocked);
96268
96388
  return true;
96269
96389
  },
96270
- removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId)
96390
+ removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
96391
+ ownerIntegrationIds: () => this.ownerIntegrationIds()
96271
96392
  });
96272
96393
  return this.integrationReconciler;
96273
96394
  }
@@ -96286,6 +96407,21 @@ var UnraidProviderAddon = class extends BaseDeviceProvider {
96286
96407
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
96287
96408
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
96288
96409
  }
96410
+ /** The integrations that own devices here, for the orphan sweep. */
96411
+ async ownerIntegrationIds() {
96412
+ const reg = this.ctx.kernel.deviceRegistry;
96413
+ const devices = this.ctx.kernel.devices;
96414
+ if (!reg || !devices) return [];
96415
+ return await collectOwnerIntegrationIds({
96416
+ reg,
96417
+ devices,
96418
+ addonId: this.addonId,
96419
+ ownerOf: (cfg) => {
96420
+ const owner = cfg["integrationId"];
96421
+ return cfg["system"] === "unraid" && typeof owner === "string" ? owner : null;
96422
+ }
96423
+ });
96424
+ }
96289
96425
  async cascadeRemoveDevicesForIntegration(integrationId) {
96290
96426
  const reg = this.ctx.kernel.deviceRegistry;
96291
96427
  const devices = this.ctx.kernel.devices;
package/dist/addon.mjs CHANGED
@@ -94496,6 +94496,88 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
94496
94496
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
94497
94497
  DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
94498
94498
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
94499
+ /**
94500
+ * Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
94501
+ * event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
94502
+ * devices go (through the provider's own removeDevices) when a SECOND answered read, at least
94503
+ * `minIntervalMs` later, still lacks it. A read that threw changes nothing; reappearing clears.
94504
+ */
94505
+ var OrphanIntegrationSweep = class {
94506
+ #logger;
94507
+ #minIntervalMs;
94508
+ #now;
94509
+ #suspects = /* @__PURE__ */ new Map();
94510
+ constructor(options) {
94511
+ this.#logger = options.logger;
94512
+ this.#minIntervalMs = options.minIntervalMs ?? 6e4;
94513
+ this.#now = options.now ?? Date.now;
94514
+ }
94515
+ get minIntervalMs() {
94516
+ return this.#minIntervalMs;
94517
+ }
94518
+ async sweep(input) {
94519
+ if (input.listedIds === null) return {
94520
+ newSuspects: 0,
94521
+ removed: []
94522
+ };
94523
+ const listed = new Set(input.listedIds);
94524
+ const owners = new Set(input.ownerIds);
94525
+ const now = this.#now();
94526
+ let newSuspects = 0;
94527
+ const removed = [];
94528
+ for (const id of [...this.#suspects.keys()]) if (listed.has(id) || !owners.has(id)) {
94529
+ this.#suspects.delete(id);
94530
+ this.#logger.info("Orphan integration suspect cleared", { meta: {
94531
+ integrationId: id,
94532
+ reason: listed.has(id) ? "listed again" : "owns no devices"
94533
+ } });
94534
+ }
94535
+ for (const id of owners) {
94536
+ if (listed.has(id)) continue;
94537
+ const since = this.#suspects.get(id);
94538
+ if (since === void 0) {
94539
+ this.#suspects.set(id, now);
94540
+ newSuspects += 1;
94541
+ this.#logger.info("Integration owns devices but is absent from the list — suspect", { meta: {
94542
+ integrationId: id,
94543
+ minIntervalMs: this.#minIntervalMs
94544
+ } });
94545
+ continue;
94546
+ }
94547
+ if (now - since < this.#minIntervalMs) continue;
94548
+ try {
94549
+ const count = await input.removeDevices(id);
94550
+ this.#suspects.delete(id);
94551
+ removed.push(id);
94552
+ this.#logger.warn("Orphan integration devices removed — absent from two answered lists", { meta: {
94553
+ integrationId: id,
94554
+ removed: count,
94555
+ firstMissingAt: since
94556
+ } });
94557
+ } catch (err) {
94558
+ this.#logger.warn("Orphan integration device removal failed — kept as suspect", { meta: {
94559
+ integrationId: id,
94560
+ error: errMsg(err)
94561
+ } });
94562
+ }
94563
+ }
94564
+ return {
94565
+ newSuspects,
94566
+ removed
94567
+ };
94568
+ }
94569
+ };
94570
+ /** The integration ids that own top-level devices of one provider (a config that cannot be read is skipped). */
94571
+ async function collectOwnerIntegrationIds(input) {
94572
+ const ids = /* @__PURE__ */ new Set();
94573
+ for (const d of input.reg.getAllForAddon(input.addonId)) {
94574
+ if (d.parentDeviceId !== null) continue;
94575
+ const cfg = await input.devices.loadConfig(d.id).catch(() => ({}));
94576
+ const owner = input.ownerOf(cfg);
94577
+ if (owner !== null) ids.add(owner);
94578
+ }
94579
+ return [...ids];
94580
+ }
94499
94581
  String.raw`(?<=^|["'(\s>,\[{])`;
94500
94582
  var MB = 1024 * 1024;
94501
94583
  1024 * MB, 3072 * MB;
@@ -96084,8 +96166,14 @@ function buildUnraidDeviceAdopter(input) {
96084
96166
  */
96085
96167
  var UnraidReconciler = class {
96086
96168
  #deps;
96169
+ #sweep;
96170
+ #rerun = null;
96087
96171
  constructor(deps) {
96088
96172
  this.#deps = deps;
96173
+ this.#sweep = new OrphanIntegrationSweep({
96174
+ logger: deps.logger,
96175
+ ...deps.sweepOptions
96176
+ });
96089
96177
  }
96090
96178
  /** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
96091
96179
  async reconcile() {
@@ -96108,6 +96196,37 @@ var UnraidReconciler = class {
96108
96196
  }
96109
96197
  const enabledIds = new Set(enabled.map((i) => i.id));
96110
96198
  for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
96199
+ await this.#sweepOrphans(all.map((i) => i.id));
96200
+ }
96201
+ /** Stop the scheduled second read (addon stop). */
96202
+ dispose() {
96203
+ if (this.#rerun !== null) clearTimeout(this.#rerun);
96204
+ this.#rerun = null;
96205
+ }
96206
+ /**
96207
+ * An integration deleted while this addon was down emits nothing it sees: its id owns devices
96208
+ * yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
96209
+ * re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
96210
+ */
96211
+ async #sweepOrphans(listedIds) {
96212
+ const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
96213
+ if (ownerIntegrationIds === void 0) return;
96214
+ try {
96215
+ const ownerIds = await ownerIntegrationIds();
96216
+ if ((await this.#sweep.sweep({
96217
+ ownerIds,
96218
+ listedIds,
96219
+ removeDevices
96220
+ })).newSuspects > 0 && this.#rerun === null) {
96221
+ this.#rerun = setTimeout(() => {
96222
+ this.#rerun = null;
96223
+ this.reconcile();
96224
+ }, this.#sweep.minIntervalMs + 1e3);
96225
+ this.#rerun.unref();
96226
+ }
96227
+ } catch (err) {
96228
+ logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
96229
+ }
96111
96230
  }
96112
96231
  /** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
96113
96232
  async onLifecycle(kind, integrationId) {
@@ -96232,6 +96351,7 @@ var UnraidProviderAddon = class extends BaseDeviceProvider {
96232
96351
  this.ctx.logger.warn("Unraid shutdown error", { meta: { error: errMsg(err) } });
96233
96352
  }
96234
96353
  this.clients = null;
96354
+ this.integrationReconciler?.dispose();
96235
96355
  this.integrationReconciler = null;
96236
96356
  unraidInstances.clear();
96237
96357
  await super.onShutdown();
@@ -96266,7 +96386,8 @@ var UnraidProviderAddon = class extends BaseDeviceProvider {
96266
96386
  await reg.upsert(integration.id, integration.name, read.connection, read.apiKeyLocked);
96267
96387
  return true;
96268
96388
  },
96269
- removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId)
96389
+ removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
96390
+ ownerIntegrationIds: () => this.ownerIntegrationIds()
96270
96391
  });
96271
96392
  return this.integrationReconciler;
96272
96393
  }
@@ -96285,6 +96406,21 @@ var UnraidProviderAddon = class extends BaseDeviceProvider {
96285
96406
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
96286
96407
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
96287
96408
  }
96409
+ /** The integrations that own devices here, for the orphan sweep. */
96410
+ async ownerIntegrationIds() {
96411
+ const reg = this.ctx.kernel.deviceRegistry;
96412
+ const devices = this.ctx.kernel.devices;
96413
+ if (!reg || !devices) return [];
96414
+ return await collectOwnerIntegrationIds({
96415
+ reg,
96416
+ devices,
96417
+ addonId: this.addonId,
96418
+ ownerOf: (cfg) => {
96419
+ const owner = cfg["integrationId"];
96420
+ return cfg["system"] === "unraid" && typeof owner === "string" ? owner : null;
96421
+ }
96422
+ });
96423
+ }
96288
96424
  async cascadeRemoveDevicesForIntegration(integrationId) {
96289
96425
  const reg = this.ctx.kernel.deviceRegistry;
96290
96426
  const devices = this.ctx.kernel.devices;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-unraid",
3
- "version": "0.2.153",
3
+ "version": "0.2.154",
4
4
  "description": "Unraid device-provider addon for CamStack — one Container per Unraid instance fanning out array, disk, docker and notification entities",
5
5
  "keywords": [
6
6
  "camstack",
@@ -59,7 +59,7 @@
59
59
  }
60
60
  ],
61
61
  "requires": {
62
- "server": ">=1.2.719"
62
+ "server": ">=1.2.722"
63
63
  }
64
64
  },
65
65
  "files": [