@camstack/addon-import-alexa 0.2.183 → 0.2.185

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,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.185] - 2026-11-04
11
+
12
+ ### Changed
13
+
14
+ - Alexa starts without waiting for the first appliance list and state poll, so a large account no longer exceeds the runner init budget during an update
15
+ - Alexa broker migrations no longer read as 'skipped-newer' (and no false 'Database is newer' alert) after the addon boots
16
+
17
+ ## [0.2.184] - 2026-11-04
18
+
19
+ ### Changed
20
+
21
+ - The integration states its role (devices, services or both) to the Integrations page
22
+
10
23
  ## [0.2.183] - 2026-11-04
11
24
 
12
25
  ### Changed
package/dist/addon.js CHANGED
@@ -6044,7 +6044,7 @@ function number(params) {
6044
6044
  return /* @__PURE__ */ _coercedNumber(ZodNumber, params);
6045
6045
  }
6046
6046
  //#endregion
6047
- //#region ../types/dist/sleep-BkzQdpzF.mjs
6047
+ //#region ../types/dist/sleep-Cv4Zmie9.mjs
6048
6048
  /**
6049
6049
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6050
6050
  * window to float samples (D455).
@@ -6644,7 +6644,7 @@ var NOT_RUN = {
6644
6644
  * throws: an unreachable or refusing engine is logged and reported `declared: false`,
6645
6645
  * because the hub (and this addon's boot) must not hang on a migration.
6646
6646
  */
6647
- async function runAddonMigrations(host, migrations, logger) {
6647
+ async function runAddonMigrations(host, migrations, logger, options = {}) {
6648
6648
  const first = migrations[0];
6649
6649
  if (first === void 0) return NOT_RUN;
6650
6650
  const owner = first.meta.owner;
@@ -6673,7 +6673,9 @@ async function runAddonMigrations(host, migrations, logger) {
6673
6673
  else if (status === "blocked") blocked.push(id);
6674
6674
  else skipped.push(id);
6675
6675
  };
6676
+ const selected = options.only === void 0 ? null : new Set(options.only);
6676
6677
  for (const step of declaration.steps) {
6678
+ if (selected !== null && !selected.has(step.id)) continue;
6677
6679
  const bound = byId.get(step.id);
6678
6680
  if (bound === void 0 || step.action === "skip") {
6679
6681
  skipped.push(step.id);
@@ -7833,14 +7835,18 @@ var BaseAddon = class {
7833
7835
  * nothing; the boot continues. A migration that FAILED (or was blocked by one)
7834
7836
  * and declares `onFailure: 'block-owner'` makes this throw, so the addon does not
7835
7837
  * serve data whose shape it cannot trust; `tolerate` migrations never do.
7838
+ *
7839
+ * ALWAYS pass the whole catalogue; a phase that must run only some of it passes
7840
+ * `{ only: [ids] }` — never a filtered catalogue (the engine would judge the
7841
+ * omitted applied ids "newer").
7836
7842
  */
7837
- async runMigrations(migrations) {
7843
+ async runMigrations(migrations, options) {
7838
7844
  const api = this.ctx.api.migrations;
7839
7845
  const report = await runAddonMigrations({
7840
7846
  declareOwner: (input) => api.declareOwner.mutate(input),
7841
7847
  startMigration: (input) => api.startMigration.mutate(input),
7842
7848
  finishMigration: (input) => api.finishMigration.mutate(input)
7843
- }, migrations, this.ctx.logger);
7849
+ }, migrations, this.ctx.logger, options);
7844
7850
  if (report.blockOwner.length > 0) throw new AddonMigrationsBlockedError(report.blockOwner);
7845
7851
  return report;
7846
7852
  }
@@ -8880,6 +8886,10 @@ var RECORD_SECRET_COLLECTION_PREFIX = "records:";
8880
8886
  * (`collectZodSecretPaths`): dotted keys, `[]` for "each element of this array"
8881
8887
  * (`auth.accessToken`, `headers[].value`).
8882
8888
  *
8889
+ * `{}` after a key means "each value of this map" (`applianceSecrets{}`): the map's own key
8890
+ * names the value (`applianceSecrets{fridge}`), so no id key is declared and a body that drops a
8891
+ * key releases exactly that key's secret.
8892
+ *
8883
8893
  * Array elements are matched by a DECLARED id key (`secretArrayKeys`:
8884
8894
  * `{ headers: 'name' }`), never by index, so reordering a list keeps every
8885
8895
  * keychain ref (the same rule as `arrayItemFieldPath`). The concrete path of a
@@ -8892,11 +8902,13 @@ function parseSecretPath(path) {
8892
8902
  if (path.length === 0) throw new InvalidRecordError("an empty secret path");
8893
8903
  return path.split(".").map((raw) => {
8894
8904
  const each = raw.endsWith("[]");
8895
- const key = each ? raw.slice(0, -2) : raw;
8905
+ const map = raw.endsWith("{}");
8906
+ const key = each || map ? raw.slice(0, -2) : raw;
8896
8907
  if (key.length === 0) throw new InvalidRecordError(`secret path "${path}" has an empty segment`);
8897
8908
  return {
8898
8909
  key,
8899
- each
8910
+ each,
8911
+ map
8900
8912
  };
8901
8913
  });
8902
8914
  }
@@ -8948,6 +8960,22 @@ function mapAt(node, segments, index, walkedKeys, concretePrefix, declared, arra
8948
8960
  [segment.key]: mapped
8949
8961
  };
8950
8962
  }
8963
+ if (segment.map) {
8964
+ if (!isRecord$4$1(child)) return node;
8965
+ const kept = Object.entries(child).map(([mapKey, value]) => {
8966
+ const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}{${encodeURIComponent(mapKey)}}`;
8967
+ if (!last) return [mapKey, mapAt(value, segments, index + 1, keys, concrete, declared, arrayKeys, visit)];
8968
+ return [mapKey, visit({
8969
+ declared,
8970
+ concrete,
8971
+ value
8972
+ })];
8973
+ }).filter(([, value]) => value !== REMOVE_LEAF);
8974
+ return {
8975
+ ...node,
8976
+ [segment.key]: Object.fromEntries(kept)
8977
+ };
8978
+ }
8951
8979
  const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}`;
8952
8980
  if (last) {
8953
8981
  const replacement = visit({
@@ -9089,7 +9117,8 @@ function isZodSecret(schema) {
9089
9117
  }
9090
9118
  /**
9091
9119
  * Every declared secret path in a config schema: dotted keys, `[]` for "each
9092
- * element of this array" (`connection.password`, `rows[].token`). A declared
9120
+ * element of this array" (`connection.password`, `rows[].token`), `{}` for "each value of this
9121
+ * map" (`applianceSecrets{}`). A declared
9093
9122
  * object or array is reported as one path and not descended into — everything
9094
9123
  * under it is secret.
9095
9124
  */
@@ -9100,6 +9129,7 @@ function collectZodSecretPaths(schema, prefix = "") {
9100
9129
  const def = defOf(layer);
9101
9130
  if (def.type === "object" && def.shape) for (const [key, child] of Object.entries(def.shape)) paths.push(...collectZodSecretPaths(child, prefix ? `${prefix}.${key}` : key));
9102
9131
  else if (def.type === "array" && def.element) paths.push(...collectZodSecretPaths(def.element, `${prefix}[]`));
9132
+ else if (def.type === "record" && def.valueType) paths.push(...collectZodSecretPaths(def.valueType, `${prefix}{}`));
9103
9133
  else if (def.type === "union" && def.options) for (const option of def.options) paths.push(...collectZodSecretPaths(option, prefix));
9104
9134
  else if (def.type === "intersection") {
9105
9135
  if (def.left) paths.push(...collectZodSecretPaths(def.left, prefix));
@@ -13414,11 +13444,49 @@ function buildCapturingReply(envelope) {
13414
13444
  };
13415
13445
  return wrapper;
13416
13446
  }
13447
+ /**
13448
+ * `RecordIntegrationInstances` - the `integration-instances` provider over ONE
13449
+ * entry kind, so an addon that holds a connection serves it in ~30 lines.
13450
+ *
13451
+ * Every instance is one record of the addon's own kind, keyed by the
13452
+ * `integrations` registry id. The records door does the secret work: a field the
13453
+ * kind marks `.meta({ secret: true })` is a keychain marker bound to the row, so
13454
+ * deleting the row deletes the secret, and one row's value is never another's.
13455
+ *
13456
+ * Patch rules for a secret (`updateInstance`): omitted, `''`, the redaction
13457
+ * token or the view the form was given KEEP the stored secret;
13458
+ * `{ $secretClear: true }` clears it; any other string replaces it.
13459
+ */
13417
13460
  /** The most instances one addon lists (an integrations page, not a table). */
13418
13461
  var INSTANCES_LIST_MAX = 1e3;
13419
13462
  /** The keys of the kind's secret fields that sit at the top level of the body. */
13420
13463
  function topLevelSecretKeys(paths) {
13421
- return new Set(paths.filter((p) => !p.includes(".") && !p.includes("[")));
13464
+ return new Set(paths.filter((p) => !p.includes(".") && !p.includes("[") && !p.includes("{")));
13465
+ }
13466
+ /** The keys of the kind's top-level MAPS whose values are secrets (`applianceSecrets{}`). */
13467
+ function secretMapKeys(paths) {
13468
+ return new Set(paths.filter((p) => p.endsWith("{}") && !p.slice(0, -2).match(/[.[{]/)).map((p) => p.slice(0, -2)));
13469
+ }
13470
+ /**
13471
+ * A patch value for a map of secrets: an entry that is omitted, `''`, the redaction token or the
13472
+ * view the form was given KEEPS its stored secret (it is left out of the merge); `{ $secretClear }`
13473
+ * or `null` removes the entry; any other string replaces it. Null when nothing is left to merge.
13474
+ */
13475
+ function secretMapPatch(value) {
13476
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
13477
+ const out = {};
13478
+ for (const [key, entry] of Object.entries(value)) if (entry === null || isSecretClear(entry)) out[key] = null;
13479
+ else if (typeof entry === "string" && entry !== "" && entry !== "__camstack_redacted__" && !isSecretFieldView(entry)) out[key] = entry;
13480
+ return Object.keys(out).length > 0 ? out : null;
13481
+ }
13482
+ /** Does the kind's body schema carry an `enabled` field? */
13483
+ function declaresEnabled(kind) {
13484
+ const json = toJSONSchema(kind.schema, {
13485
+ unrepresentable: "any",
13486
+ io: "input"
13487
+ });
13488
+ const properties = Reflect.get(json, "properties");
13489
+ return typeof properties === "object" && properties !== null && "enabled" in properties;
13422
13490
  }
13423
13491
  var RecordIntegrationInstances = class {
13424
13492
  records;
@@ -13426,14 +13494,20 @@ var RecordIntegrationInstances = class {
13426
13494
  hooks;
13427
13495
  handle;
13428
13496
  secretKeys;
13497
+ secretMaps;
13429
13498
  nameField;
13499
+ /** The `integrations` registry owns the on/off switch (`role: devices`); the body has none. */
13500
+ registryOwnsEnabled;
13430
13501
  constructor(records, kind, hooks) {
13431
13502
  this.records = records;
13432
13503
  this.kind = kind;
13433
13504
  this.hooks = hooks;
13434
13505
  this.handle = records.kind(kind);
13435
13506
  this.secretKeys = topLevelSecretKeys(kind.declaration.secretPaths);
13507
+ this.secretMaps = secretMapKeys(kind.declaration.secretPaths);
13436
13508
  this.nameField = kind.declaration.entry?.nameField ?? "name";
13509
+ this.registryOwnsEnabled = hooks.description.role === "devices";
13510
+ if (this.registryOwnsEnabled && declaresEnabled(kind)) throw new Error(`kind "${kind.name}": the integrations registry owns the on/off switch of a role "devices" addon, so the body must not declare "enabled"`);
13437
13511
  }
13438
13512
  /**
13439
13513
  * One instance read for the addon's own runtime; null when absent. When the keychain is
@@ -13484,7 +13558,7 @@ var RecordIntegrationInstances = class {
13484
13558
  id: record.id,
13485
13559
  name: typeof rawName === "string" ? rawName : record.id,
13486
13560
  configured: this.hooks.configured(record.data),
13487
- enabled: record.data.enabled !== false,
13561
+ ...this.registryOwnsEnabled ? {} : { enabled: record.data.enabled !== false },
13488
13562
  formValues: shown,
13489
13563
  ...kind !== void 0 ? { kind } : {},
13490
13564
  ...health?.status !== void 0 ? { status: health.status } : {},
@@ -13514,32 +13588,36 @@ var RecordIntegrationInstances = class {
13514
13588
  id: minted,
13515
13589
  data: this.hooks.initialBody(name, kind)
13516
13590
  }, { ifRev: 0 });
13517
- await this.hooks.onChanged?.(minted);
13591
+ await this.hooks.onChanged?.(minted, "created");
13518
13592
  return { id: minted };
13519
13593
  };
13520
13594
  updateInstance = async ({ id, name, values }) => {
13521
13595
  const patch = {};
13522
13596
  for (const [key, value] of Object.entries(values)) {
13523
13597
  if (value === void 0) continue;
13524
- if (!this.secretKeys.has(key)) patch[key] = value;
13598
+ if (this.secretMaps.has(key)) {
13599
+ const entries = secretMapPatch(value);
13600
+ if (entries !== null) patch[key] = entries;
13601
+ } else if (!this.secretKeys.has(key)) patch[key] = value;
13525
13602
  else if (isSecretClear(value)) patch[key] = null;
13526
13603
  else if (typeof value === "string" && value !== "" && value !== "__camstack_redacted__" && !isSecretFieldView(value)) patch[key] = value;
13527
13604
  }
13528
13605
  if (name !== void 0) patch[this.nameField] = name;
13529
13606
  await this.handle.patch(id, patch);
13530
- await this.hooks.onChanged?.(id);
13607
+ await this.hooks.onChanged?.(id, "updated");
13531
13608
  return { ok: true };
13532
13609
  };
13533
13610
  /** The switch lives in the record body; the addon is told so it applies it (D996). */
13534
13611
  setInstanceEnabled = async ({ id, enabled }) => {
13612
+ if (this.registryOwnsEnabled) throw new Error(`instance "${id}": the integrations registry owns the on/off switch of this addon; set it with integrations.update`);
13535
13613
  await this.handle.patch(id, { enabled });
13536
- await this.hooks.onChanged?.(id);
13614
+ await this.hooks.onChanged?.(id, "enabled");
13537
13615
  return { ok: true };
13538
13616
  };
13539
13617
  /** The row and its keychain refs go together (the records door). */
13540
13618
  deleteInstance = async ({ id }) => {
13541
13619
  await this.handle.delete(id);
13542
- await this.hooks.onChanged?.(id);
13620
+ await this.hooks.onChanged?.(id, "deleted");
13543
13621
  return { ok: true };
13544
13622
  };
13545
13623
  testInstance = async (input) => {
@@ -14600,7 +14678,13 @@ var MigrationLedgerEntrySchema = object({
14600
14678
  * joined by the engine from the catalogues it knows this boot. Absent when no
14601
14679
  * catalogue lists the id (a newer database).
14602
14680
  */
14603
- meta: MigrationMetaSchema.optional()
14681
+ meta: MigrationMetaSchema.optional(),
14682
+ /**
14683
+ * Set when this is an applied row of an addon owner whose catalogue the answering
14684
+ * process has not loaded (the owner has not declared yet): the row is applied, the
14685
+ * process just cannot judge it. Never `skipped-newer` — that needs the owner loaded.
14686
+ */
14687
+ ownerNotLoaded: boolean().optional()
14604
14688
  });
14605
14689
  /** A catalogued migration that is not applied, with where it stands. */
14606
14690
  var MigrationPendingEntrySchema = object({
@@ -56307,6 +56391,21 @@ var IntegrationInstanceKindSchema = object({
56307
56391
  disconnectPath: string().min(1).optional()
56308
56392
  });
56309
56393
  var IntegrationInstanceDescriptionSchema = object({
56394
+ /** What the instances are for the Integrations page; see {@link IntegrationInstanceRoleSchema}. */
56395
+ role: _enum([
56396
+ "devices",
56397
+ "services",
56398
+ "both"
56399
+ ]),
56400
+ /** How the devices of an instance are found; absent means `{ mode: 'none' }`. */
56401
+ discovery: object({
56402
+ mode: _enum([
56403
+ "none",
56404
+ "lan",
56405
+ "account"
56406
+ ]),
56407
+ scopedToInstance: boolean()
56408
+ }).optional(),
56310
56409
  /** `single`: at most one instance can exist (Loki). `multiple`: an account each. */
56311
56410
  instanceMode: IntegrationInstanceModeSchema,
56312
56411
  /** What the instance is, for the UI (`destination`, `relay`, `account`). */
@@ -56346,10 +56445,11 @@ var IntegrationInstanceSchema = object({
56346
56445
  /** The addon's own rule (an URL is present, a client id is present...). */
56347
56446
  configured: boolean(),
56348
56447
  /**
56349
- * The instance's own on/off switch (D996): always present, derived from the record body
56350
- * (`enabled !== false`, so a row written before the field existed is on).
56448
+ * The instance's own on/off switch (D996), derived from the record body (`enabled !== false`,
56449
+ * so a row written before the field existed is on). Absent when the `integrations` registry
56450
+ * owns the switch (`describe().role` is `devices`): read it from the registry row.
56351
56451
  */
56352
- enabled: boolean(),
56452
+ enabled: boolean().optional(),
56353
56453
  /** The kind of this instance (one of `describe().kinds[].id`), when the provider has kinds. */
56354
56454
  kind: string().optional(),
56355
56455
  /** Values for the instance form; a secret is a `SecretFieldView`, never text. */
@@ -141550,10 +141650,8 @@ var AlexaIntegrationManager = class {
141550
141650
  this.#status = "connected";
141551
141651
  this.#lastCheckedAt = Date.now();
141552
141652
  this.#onConnected(this.#id);
141553
- await this.refreshAppliances();
141554
- await this.poll();
141555
- this.#schedulePoll();
141556
141653
  this.#logger.info("AlexaIntegrationManager: started", { tags: { brokerId: this.#id } });
141654
+ this.#firstSync(client);
141557
141655
  } catch (err) {
141558
141656
  if (err instanceof AlexaAuthError) {
141559
141657
  this.#status = "auth-failed";
@@ -141574,6 +141672,24 @@ var AlexaIntegrationManager = class {
141574
141672
  });
141575
141673
  }
141576
141674
  }
141675
+ /** Background first refresh + poll, then the poll loop. Does nothing once `client` is stale. */
141676
+ async #firstSync(client) {
141677
+ let refreshed = false;
141678
+ try {
141679
+ await this.refreshAppliances();
141680
+ refreshed = true;
141681
+ } catch (err) {
141682
+ this.#error = errMsg(err);
141683
+ this.#logger.warn("AlexaIntegrationManager: first appliance refresh failed", {
141684
+ tags: { brokerId: this.#id },
141685
+ meta: { error: errMsg(err) }
141686
+ });
141687
+ }
141688
+ if (this.#client !== client) return;
141689
+ if (refreshed) await this.poll();
141690
+ if (this.#client !== client) return;
141691
+ this.#schedulePoll();
141692
+ }
141577
141693
  /** List the account's appliances + publish them to the resolver. */
141578
141694
  async refreshAppliances() {
141579
141695
  const client = this.#client;
@@ -143315,6 +143431,7 @@ function statusOf(info, configured) {
143315
143431
  function buildAlexaInstances(records, deps) {
143316
143432
  return new RecordIntegrationInstances(records, alexaBrokersEntryKind, {
143317
143433
  description: {
143434
+ role: "both",
143318
143435
  instanceMode: "multiple",
143319
143436
  kind: "account",
143320
143437
  canTest: true,
@@ -145027,7 +145144,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
145027
145144
  infoOf: (id) => this.registry?.get(id) ?? null,
145028
145145
  onChanged: (id) => this.syncBrokerRuntime(id)
145029
145146
  });
145030
- await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS.filter((m) => m.meta.id === FLATTEN_BROKER_RECORDS_ID), this.migrationPorts(records)));
145147
+ await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, this.migrationPorts(records)), { only: [FLATTEN_BROKER_RECORDS_ID] });
145031
145148
  this.activeBrokers = await this.loadPersistedBrokers();
145032
145149
  this.registry = new AlexaBrokerRegistry(this.ctx.logger, {
145033
145150
  dataDir: this.ctx.dataDir,
@@ -145072,7 +145189,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
145072
145189
  deviceCode: this.deviceCodeLogin,
145073
145190
  commitCookie: (input) => this.commitCapturedCookie(input)
145074
145191
  }), ...speakIconRoutes()];
145075
- await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS.filter((m) => m.meta.id !== FLATTEN_BROKER_RECORDS_ID), this.migrationPorts(records)));
145192
+ await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, this.migrationPorts(records)), { only: ADDON_MIGRATIONS.map((m) => m.meta.id).filter((id) => id !== FLATTEN_BROKER_RECORDS_ID) });
145076
145193
  const speak = new AlexaSpeakController({
145077
145194
  logger: this.ctx.logger.child("speak"),
145078
145195
  readConfig: () => this.config,
package/dist/addon.mjs CHANGED
@@ -6042,7 +6042,7 @@ function number(params) {
6042
6042
  return /* @__PURE__ */ _coercedNumber(ZodNumber, params);
6043
6043
  }
6044
6044
  //#endregion
6045
- //#region ../types/dist/sleep-BkzQdpzF.mjs
6045
+ //#region ../types/dist/sleep-Cv4Zmie9.mjs
6046
6046
  /**
6047
6047
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6048
6048
  * window to float samples (D455).
@@ -6642,7 +6642,7 @@ var NOT_RUN = {
6642
6642
  * throws: an unreachable or refusing engine is logged and reported `declared: false`,
6643
6643
  * because the hub (and this addon's boot) must not hang on a migration.
6644
6644
  */
6645
- async function runAddonMigrations(host, migrations, logger) {
6645
+ async function runAddonMigrations(host, migrations, logger, options = {}) {
6646
6646
  const first = migrations[0];
6647
6647
  if (first === void 0) return NOT_RUN;
6648
6648
  const owner = first.meta.owner;
@@ -6671,7 +6671,9 @@ async function runAddonMigrations(host, migrations, logger) {
6671
6671
  else if (status === "blocked") blocked.push(id);
6672
6672
  else skipped.push(id);
6673
6673
  };
6674
+ const selected = options.only === void 0 ? null : new Set(options.only);
6674
6675
  for (const step of declaration.steps) {
6676
+ if (selected !== null && !selected.has(step.id)) continue;
6675
6677
  const bound = byId.get(step.id);
6676
6678
  if (bound === void 0 || step.action === "skip") {
6677
6679
  skipped.push(step.id);
@@ -7831,14 +7833,18 @@ var BaseAddon = class {
7831
7833
  * nothing; the boot continues. A migration that FAILED (or was blocked by one)
7832
7834
  * and declares `onFailure: 'block-owner'` makes this throw, so the addon does not
7833
7835
  * serve data whose shape it cannot trust; `tolerate` migrations never do.
7836
+ *
7837
+ * ALWAYS pass the whole catalogue; a phase that must run only some of it passes
7838
+ * `{ only: [ids] }` — never a filtered catalogue (the engine would judge the
7839
+ * omitted applied ids "newer").
7834
7840
  */
7835
- async runMigrations(migrations) {
7841
+ async runMigrations(migrations, options) {
7836
7842
  const api = this.ctx.api.migrations;
7837
7843
  const report = await runAddonMigrations({
7838
7844
  declareOwner: (input) => api.declareOwner.mutate(input),
7839
7845
  startMigration: (input) => api.startMigration.mutate(input),
7840
7846
  finishMigration: (input) => api.finishMigration.mutate(input)
7841
- }, migrations, this.ctx.logger);
7847
+ }, migrations, this.ctx.logger, options);
7842
7848
  if (report.blockOwner.length > 0) throw new AddonMigrationsBlockedError(report.blockOwner);
7843
7849
  return report;
7844
7850
  }
@@ -8878,6 +8884,10 @@ var RECORD_SECRET_COLLECTION_PREFIX = "records:";
8878
8884
  * (`collectZodSecretPaths`): dotted keys, `[]` for "each element of this array"
8879
8885
  * (`auth.accessToken`, `headers[].value`).
8880
8886
  *
8887
+ * `{}` after a key means "each value of this map" (`applianceSecrets{}`): the map's own key
8888
+ * names the value (`applianceSecrets{fridge}`), so no id key is declared and a body that drops a
8889
+ * key releases exactly that key's secret.
8890
+ *
8881
8891
  * Array elements are matched by a DECLARED id key (`secretArrayKeys`:
8882
8892
  * `{ headers: 'name' }`), never by index, so reordering a list keeps every
8883
8893
  * keychain ref (the same rule as `arrayItemFieldPath`). The concrete path of a
@@ -8890,11 +8900,13 @@ function parseSecretPath(path) {
8890
8900
  if (path.length === 0) throw new InvalidRecordError("an empty secret path");
8891
8901
  return path.split(".").map((raw) => {
8892
8902
  const each = raw.endsWith("[]");
8893
- const key = each ? raw.slice(0, -2) : raw;
8903
+ const map = raw.endsWith("{}");
8904
+ const key = each || map ? raw.slice(0, -2) : raw;
8894
8905
  if (key.length === 0) throw new InvalidRecordError(`secret path "${path}" has an empty segment`);
8895
8906
  return {
8896
8907
  key,
8897
- each
8908
+ each,
8909
+ map
8898
8910
  };
8899
8911
  });
8900
8912
  }
@@ -8946,6 +8958,22 @@ function mapAt(node, segments, index, walkedKeys, concretePrefix, declared, arra
8946
8958
  [segment.key]: mapped
8947
8959
  };
8948
8960
  }
8961
+ if (segment.map) {
8962
+ if (!isRecord$4$1(child)) return node;
8963
+ const kept = Object.entries(child).map(([mapKey, value]) => {
8964
+ const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}{${encodeURIComponent(mapKey)}}`;
8965
+ if (!last) return [mapKey, mapAt(value, segments, index + 1, keys, concrete, declared, arrayKeys, visit)];
8966
+ return [mapKey, visit({
8967
+ declared,
8968
+ concrete,
8969
+ value
8970
+ })];
8971
+ }).filter(([, value]) => value !== REMOVE_LEAF);
8972
+ return {
8973
+ ...node,
8974
+ [segment.key]: Object.fromEntries(kept)
8975
+ };
8976
+ }
8949
8977
  const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}`;
8950
8978
  if (last) {
8951
8979
  const replacement = visit({
@@ -9087,7 +9115,8 @@ function isZodSecret(schema) {
9087
9115
  }
9088
9116
  /**
9089
9117
  * Every declared secret path in a config schema: dotted keys, `[]` for "each
9090
- * element of this array" (`connection.password`, `rows[].token`). A declared
9118
+ * element of this array" (`connection.password`, `rows[].token`), `{}` for "each value of this
9119
+ * map" (`applianceSecrets{}`). A declared
9091
9120
  * object or array is reported as one path and not descended into — everything
9092
9121
  * under it is secret.
9093
9122
  */
@@ -9098,6 +9127,7 @@ function collectZodSecretPaths(schema, prefix = "") {
9098
9127
  const def = defOf(layer);
9099
9128
  if (def.type === "object" && def.shape) for (const [key, child] of Object.entries(def.shape)) paths.push(...collectZodSecretPaths(child, prefix ? `${prefix}.${key}` : key));
9100
9129
  else if (def.type === "array" && def.element) paths.push(...collectZodSecretPaths(def.element, `${prefix}[]`));
9130
+ else if (def.type === "record" && def.valueType) paths.push(...collectZodSecretPaths(def.valueType, `${prefix}{}`));
9101
9131
  else if (def.type === "union" && def.options) for (const option of def.options) paths.push(...collectZodSecretPaths(option, prefix));
9102
9132
  else if (def.type === "intersection") {
9103
9133
  if (def.left) paths.push(...collectZodSecretPaths(def.left, prefix));
@@ -13412,11 +13442,49 @@ function buildCapturingReply(envelope) {
13412
13442
  };
13413
13443
  return wrapper;
13414
13444
  }
13445
+ /**
13446
+ * `RecordIntegrationInstances` - the `integration-instances` provider over ONE
13447
+ * entry kind, so an addon that holds a connection serves it in ~30 lines.
13448
+ *
13449
+ * Every instance is one record of the addon's own kind, keyed by the
13450
+ * `integrations` registry id. The records door does the secret work: a field the
13451
+ * kind marks `.meta({ secret: true })` is a keychain marker bound to the row, so
13452
+ * deleting the row deletes the secret, and one row's value is never another's.
13453
+ *
13454
+ * Patch rules for a secret (`updateInstance`): omitted, `''`, the redaction
13455
+ * token or the view the form was given KEEP the stored secret;
13456
+ * `{ $secretClear: true }` clears it; any other string replaces it.
13457
+ */
13415
13458
  /** The most instances one addon lists (an integrations page, not a table). */
13416
13459
  var INSTANCES_LIST_MAX = 1e3;
13417
13460
  /** The keys of the kind's secret fields that sit at the top level of the body. */
13418
13461
  function topLevelSecretKeys(paths) {
13419
- return new Set(paths.filter((p) => !p.includes(".") && !p.includes("[")));
13462
+ return new Set(paths.filter((p) => !p.includes(".") && !p.includes("[") && !p.includes("{")));
13463
+ }
13464
+ /** The keys of the kind's top-level MAPS whose values are secrets (`applianceSecrets{}`). */
13465
+ function secretMapKeys(paths) {
13466
+ return new Set(paths.filter((p) => p.endsWith("{}") && !p.slice(0, -2).match(/[.[{]/)).map((p) => p.slice(0, -2)));
13467
+ }
13468
+ /**
13469
+ * A patch value for a map of secrets: an entry that is omitted, `''`, the redaction token or the
13470
+ * view the form was given KEEPS its stored secret (it is left out of the merge); `{ $secretClear }`
13471
+ * or `null` removes the entry; any other string replaces it. Null when nothing is left to merge.
13472
+ */
13473
+ function secretMapPatch(value) {
13474
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
13475
+ const out = {};
13476
+ for (const [key, entry] of Object.entries(value)) if (entry === null || isSecretClear(entry)) out[key] = null;
13477
+ else if (typeof entry === "string" && entry !== "" && entry !== "__camstack_redacted__" && !isSecretFieldView(entry)) out[key] = entry;
13478
+ return Object.keys(out).length > 0 ? out : null;
13479
+ }
13480
+ /** Does the kind's body schema carry an `enabled` field? */
13481
+ function declaresEnabled(kind) {
13482
+ const json = toJSONSchema(kind.schema, {
13483
+ unrepresentable: "any",
13484
+ io: "input"
13485
+ });
13486
+ const properties = Reflect.get(json, "properties");
13487
+ return typeof properties === "object" && properties !== null && "enabled" in properties;
13420
13488
  }
13421
13489
  var RecordIntegrationInstances = class {
13422
13490
  records;
@@ -13424,14 +13492,20 @@ var RecordIntegrationInstances = class {
13424
13492
  hooks;
13425
13493
  handle;
13426
13494
  secretKeys;
13495
+ secretMaps;
13427
13496
  nameField;
13497
+ /** The `integrations` registry owns the on/off switch (`role: devices`); the body has none. */
13498
+ registryOwnsEnabled;
13428
13499
  constructor(records, kind, hooks) {
13429
13500
  this.records = records;
13430
13501
  this.kind = kind;
13431
13502
  this.hooks = hooks;
13432
13503
  this.handle = records.kind(kind);
13433
13504
  this.secretKeys = topLevelSecretKeys(kind.declaration.secretPaths);
13505
+ this.secretMaps = secretMapKeys(kind.declaration.secretPaths);
13434
13506
  this.nameField = kind.declaration.entry?.nameField ?? "name";
13507
+ this.registryOwnsEnabled = hooks.description.role === "devices";
13508
+ if (this.registryOwnsEnabled && declaresEnabled(kind)) throw new Error(`kind "${kind.name}": the integrations registry owns the on/off switch of a role "devices" addon, so the body must not declare "enabled"`);
13435
13509
  }
13436
13510
  /**
13437
13511
  * One instance read for the addon's own runtime; null when absent. When the keychain is
@@ -13482,7 +13556,7 @@ var RecordIntegrationInstances = class {
13482
13556
  id: record.id,
13483
13557
  name: typeof rawName === "string" ? rawName : record.id,
13484
13558
  configured: this.hooks.configured(record.data),
13485
- enabled: record.data.enabled !== false,
13559
+ ...this.registryOwnsEnabled ? {} : { enabled: record.data.enabled !== false },
13486
13560
  formValues: shown,
13487
13561
  ...kind !== void 0 ? { kind } : {},
13488
13562
  ...health?.status !== void 0 ? { status: health.status } : {},
@@ -13512,32 +13586,36 @@ var RecordIntegrationInstances = class {
13512
13586
  id: minted,
13513
13587
  data: this.hooks.initialBody(name, kind)
13514
13588
  }, { ifRev: 0 });
13515
- await this.hooks.onChanged?.(minted);
13589
+ await this.hooks.onChanged?.(minted, "created");
13516
13590
  return { id: minted };
13517
13591
  };
13518
13592
  updateInstance = async ({ id, name, values }) => {
13519
13593
  const patch = {};
13520
13594
  for (const [key, value] of Object.entries(values)) {
13521
13595
  if (value === void 0) continue;
13522
- if (!this.secretKeys.has(key)) patch[key] = value;
13596
+ if (this.secretMaps.has(key)) {
13597
+ const entries = secretMapPatch(value);
13598
+ if (entries !== null) patch[key] = entries;
13599
+ } else if (!this.secretKeys.has(key)) patch[key] = value;
13523
13600
  else if (isSecretClear(value)) patch[key] = null;
13524
13601
  else if (typeof value === "string" && value !== "" && value !== "__camstack_redacted__" && !isSecretFieldView(value)) patch[key] = value;
13525
13602
  }
13526
13603
  if (name !== void 0) patch[this.nameField] = name;
13527
13604
  await this.handle.patch(id, patch);
13528
- await this.hooks.onChanged?.(id);
13605
+ await this.hooks.onChanged?.(id, "updated");
13529
13606
  return { ok: true };
13530
13607
  };
13531
13608
  /** The switch lives in the record body; the addon is told so it applies it (D996). */
13532
13609
  setInstanceEnabled = async ({ id, enabled }) => {
13610
+ if (this.registryOwnsEnabled) throw new Error(`instance "${id}": the integrations registry owns the on/off switch of this addon; set it with integrations.update`);
13533
13611
  await this.handle.patch(id, { enabled });
13534
- await this.hooks.onChanged?.(id);
13612
+ await this.hooks.onChanged?.(id, "enabled");
13535
13613
  return { ok: true };
13536
13614
  };
13537
13615
  /** The row and its keychain refs go together (the records door). */
13538
13616
  deleteInstance = async ({ id }) => {
13539
13617
  await this.handle.delete(id);
13540
- await this.hooks.onChanged?.(id);
13618
+ await this.hooks.onChanged?.(id, "deleted");
13541
13619
  return { ok: true };
13542
13620
  };
13543
13621
  testInstance = async (input) => {
@@ -14598,7 +14676,13 @@ var MigrationLedgerEntrySchema = object({
14598
14676
  * joined by the engine from the catalogues it knows this boot. Absent when no
14599
14677
  * catalogue lists the id (a newer database).
14600
14678
  */
14601
- meta: MigrationMetaSchema.optional()
14679
+ meta: MigrationMetaSchema.optional(),
14680
+ /**
14681
+ * Set when this is an applied row of an addon owner whose catalogue the answering
14682
+ * process has not loaded (the owner has not declared yet): the row is applied, the
14683
+ * process just cannot judge it. Never `skipped-newer` — that needs the owner loaded.
14684
+ */
14685
+ ownerNotLoaded: boolean().optional()
14602
14686
  });
14603
14687
  /** A catalogued migration that is not applied, with where it stands. */
14604
14688
  var MigrationPendingEntrySchema = object({
@@ -56305,6 +56389,21 @@ var IntegrationInstanceKindSchema = object({
56305
56389
  disconnectPath: string().min(1).optional()
56306
56390
  });
56307
56391
  var IntegrationInstanceDescriptionSchema = object({
56392
+ /** What the instances are for the Integrations page; see {@link IntegrationInstanceRoleSchema}. */
56393
+ role: _enum([
56394
+ "devices",
56395
+ "services",
56396
+ "both"
56397
+ ]),
56398
+ /** How the devices of an instance are found; absent means `{ mode: 'none' }`. */
56399
+ discovery: object({
56400
+ mode: _enum([
56401
+ "none",
56402
+ "lan",
56403
+ "account"
56404
+ ]),
56405
+ scopedToInstance: boolean()
56406
+ }).optional(),
56308
56407
  /** `single`: at most one instance can exist (Loki). `multiple`: an account each. */
56309
56408
  instanceMode: IntegrationInstanceModeSchema,
56310
56409
  /** What the instance is, for the UI (`destination`, `relay`, `account`). */
@@ -56344,10 +56443,11 @@ var IntegrationInstanceSchema = object({
56344
56443
  /** The addon's own rule (an URL is present, a client id is present...). */
56345
56444
  configured: boolean(),
56346
56445
  /**
56347
- * The instance's own on/off switch (D996): always present, derived from the record body
56348
- * (`enabled !== false`, so a row written before the field existed is on).
56446
+ * The instance's own on/off switch (D996), derived from the record body (`enabled !== false`,
56447
+ * so a row written before the field existed is on). Absent when the `integrations` registry
56448
+ * owns the switch (`describe().role` is `devices`): read it from the registry row.
56349
56449
  */
56350
- enabled: boolean(),
56450
+ enabled: boolean().optional(),
56351
56451
  /** The kind of this instance (one of `describe().kinds[].id`), when the provider has kinds. */
56352
56452
  kind: string().optional(),
56353
56453
  /** Values for the instance form; a secret is a `SecretFieldView`, never text. */
@@ -141548,10 +141648,8 @@ var AlexaIntegrationManager = class {
141548
141648
  this.#status = "connected";
141549
141649
  this.#lastCheckedAt = Date.now();
141550
141650
  this.#onConnected(this.#id);
141551
- await this.refreshAppliances();
141552
- await this.poll();
141553
- this.#schedulePoll();
141554
141651
  this.#logger.info("AlexaIntegrationManager: started", { tags: { brokerId: this.#id } });
141652
+ this.#firstSync(client);
141555
141653
  } catch (err) {
141556
141654
  if (err instanceof AlexaAuthError) {
141557
141655
  this.#status = "auth-failed";
@@ -141572,6 +141670,24 @@ var AlexaIntegrationManager = class {
141572
141670
  });
141573
141671
  }
141574
141672
  }
141673
+ /** Background first refresh + poll, then the poll loop. Does nothing once `client` is stale. */
141674
+ async #firstSync(client) {
141675
+ let refreshed = false;
141676
+ try {
141677
+ await this.refreshAppliances();
141678
+ refreshed = true;
141679
+ } catch (err) {
141680
+ this.#error = errMsg(err);
141681
+ this.#logger.warn("AlexaIntegrationManager: first appliance refresh failed", {
141682
+ tags: { brokerId: this.#id },
141683
+ meta: { error: errMsg(err) }
141684
+ });
141685
+ }
141686
+ if (this.#client !== client) return;
141687
+ if (refreshed) await this.poll();
141688
+ if (this.#client !== client) return;
141689
+ this.#schedulePoll();
141690
+ }
141575
141691
  /** List the account's appliances + publish them to the resolver. */
141576
141692
  async refreshAppliances() {
141577
141693
  const client = this.#client;
@@ -143313,6 +143429,7 @@ function statusOf(info, configured) {
143313
143429
  function buildAlexaInstances(records, deps) {
143314
143430
  return new RecordIntegrationInstances(records, alexaBrokersEntryKind, {
143315
143431
  description: {
143432
+ role: "both",
143316
143433
  instanceMode: "multiple",
143317
143434
  kind: "account",
143318
143435
  canTest: true,
@@ -145025,7 +145142,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
145025
145142
  infoOf: (id) => this.registry?.get(id) ?? null,
145026
145143
  onChanged: (id) => this.syncBrokerRuntime(id)
145027
145144
  });
145028
- await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS.filter((m) => m.meta.id === FLATTEN_BROKER_RECORDS_ID), this.migrationPorts(records)));
145145
+ await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, this.migrationPorts(records)), { only: [FLATTEN_BROKER_RECORDS_ID] });
145029
145146
  this.activeBrokers = await this.loadPersistedBrokers();
145030
145147
  this.registry = new AlexaBrokerRegistry(this.ctx.logger, {
145031
145148
  dataDir: this.ctx.dataDir,
@@ -145070,7 +145187,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
145070
145187
  deviceCode: this.deviceCodeLogin,
145071
145188
  commitCookie: (input) => this.commitCapturedCookie(input)
145072
145189
  }), ...speakIconRoutes()];
145073
- await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS.filter((m) => m.meta.id !== FLATTEN_BROKER_RECORDS_ID), this.migrationPorts(records)));
145190
+ await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, this.migrationPorts(records)), { only: ADDON_MIGRATIONS.map((m) => m.meta.id).filter((id) => id !== FLATTEN_BROKER_RECORDS_ID) });
145074
145191
  const speak = new AlexaSpeakController({
145075
145192
  logger: this.ctx.logger.child("speak"),
145076
145193
  readConfig: () => this.config,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-import-alexa",
3
- "version": "0.2.183",
3
+ "version": "0.2.185",
4
4
  "description": "Alexa device-import provider for CamStack — imports the smart-home devices in a user's Alexa account via the unofficial alexa-remote2 cookie/token client (the inverse of the Alexa exporter)",
5
5
  "keywords": [
6
6
  "camstack",
@@ -72,7 +72,7 @@
72
72
  }
73
73
  ],
74
74
  "requires": {
75
- "server": ">=1.2.761"
75
+ "server": ">=1.2.763"
76
76
  }
77
77
  },
78
78
  "files": [