@camstack/system 1.2.437 → 1.2.438

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.
Files changed (91) hide show
  1. package/dist/addon-runner.js +64 -12
  2. package/dist/addon-runner.mjs +65 -13
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +7 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +7 -1
  9. package/dist/builtins/autotrack/index.js +1 -1
  10. package/dist/builtins/autotrack/index.mjs +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
  13. package/dist/builtins/calendars/calendars.addon.js +1 -1
  14. package/dist/builtins/calendars/calendars.addon.mjs +1 -1
  15. package/dist/builtins/camera-grid/index.js +9 -1
  16. package/dist/builtins/camera-grid/index.mjs +9 -1
  17. package/dist/builtins/composer/composer.addon.js +2 -2
  18. package/dist/builtins/composer/composer.addon.mjs +2 -2
  19. package/dist/builtins/console-logging/index.js +12 -5
  20. package/dist/builtins/console-logging/index.mjs +12 -5
  21. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  22. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  23. package/dist/builtins/device-manager/device-manager.addon.d.ts +5 -0
  24. package/dist/builtins/device-manager/device-manager.addon.js +702 -28
  25. package/dist/builtins/device-manager/device-manager.addon.mjs +702 -28
  26. package/dist/builtins/device-manager/device-meta-types.d.ts +4 -0
  27. package/dist/builtins/device-manager/device-provider-context.d.ts +9 -0
  28. package/dist/builtins/device-manager/device-removal-hooks.d.ts +103 -0
  29. package/dist/builtins/device-manager/device-row-store.d.ts +1 -0
  30. package/dist/builtins/device-manager/device-tombstone-store.d.ts +58 -0
  31. package/dist/builtins/device-manager/removal-cleanup.d.ts +50 -0
  32. package/dist/builtins/hub-forwarder/index.js +1 -1
  33. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  34. package/dist/builtins/keychain/index.js +37 -4
  35. package/dist/builtins/keychain/index.mjs +37 -4
  36. package/dist/builtins/keychain/keychain-service.d.ts +14 -0
  37. package/dist/builtins/keychain/keychain-store.d.ts +1 -1
  38. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  39. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  40. package/dist/builtins/local-auth/local-auth.addon.js +12 -12
  41. package/dist/builtins/local-auth/local-auth.addon.mjs +12 -12
  42. package/dist/builtins/local-network/local-network.addon.js +10 -1
  43. package/dist/builtins/local-network/local-network.addon.mjs +10 -1
  44. package/dist/builtins/loki-logging/index.js +28 -5
  45. package/dist/builtins/loki-logging/index.mjs +28 -5
  46. package/dist/builtins/native-metrics/native-metrics.addon.js +11 -1
  47. package/dist/builtins/native-metrics/native-metrics.addon.mjs +11 -1
  48. package/dist/builtins/platform-probe/index.js +1 -1
  49. package/dist/builtins/platform-probe/index.mjs +1 -1
  50. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  51. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  52. package/dist/builtins/snapshot/index.js +11 -1
  53. package/dist/builtins/snapshot/index.mjs +11 -1
  54. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +4 -1
  55. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +4 -1
  56. package/dist/builtins/sqlite-storage/index.js +1 -1
  57. package/dist/builtins/sqlite-storage/index.mjs +1 -1
  58. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +3 -3
  59. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +3 -3
  60. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  61. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  62. package/dist/builtins/system-config/system-config.addon.js +20 -2
  63. package/dist/builtins/system-config/system-config.addon.mjs +20 -2
  64. package/dist/builtins/winston-logging/index.js +14 -5
  65. package/dist/builtins/winston-logging/index.mjs +14 -5
  66. package/dist/{composition-sources-BIT8IUKm.js → composition-sources-DI3Z83vG.js} +1 -1
  67. package/dist/{composition-sources-tpYR0f1x.mjs → composition-sources-DpHGCjdL.mjs} +1 -1
  68. package/dist/{constants-Bc6trhoW.mjs → constants-DQK705Sb.mjs} +1 -1
  69. package/dist/{constants-CwRY32Nx.js → constants-wkcn5Ij3.js} +1 -1
  70. package/dist/{device-ca-DImD8IzR.js → device-ca-BSvkXqyI.js} +1 -1
  71. package/dist/{device-ca-BhdBByGD.mjs → device-ca-DvIZjXIl.mjs} +1 -1
  72. package/dist/{dist-m5JkT8UN.mjs → dist-Dc8GKC5H.mjs} +272 -7
  73. package/dist/{dist-CEAyXW3Q.js → dist-GgG-7amw.js} +307 -6
  74. package/dist/file-host-worker.js +1 -1
  75. package/dist/file-host-worker.mjs +1 -1
  76. package/dist/i18n/catalog-registry.d.ts +35 -0
  77. package/dist/i18n/index.d.ts +2 -0
  78. package/dist/index.d.ts +1 -0
  79. package/dist/index.js +663 -20
  80. package/dist/index.mjs +659 -22
  81. package/dist/kernel/gc-census.d.ts +18 -1
  82. package/dist/kernel/settings-view-types.d.ts +2 -0
  83. package/dist/kernel/transport/local-transport.d.ts +3 -0
  84. package/dist/kernel/transport/peer-answered-error.d.ts +5 -1
  85. package/dist/{restore-archive-C75srVbG.js → restore-archive-CTRz8v9o.js} +1 -1
  86. package/dist/{restore-archive-DNxAStrJ.mjs → restore-archive-DPg9duVY.mjs} +1 -1
  87. package/dist/{retired-settings-keys-DNfV28R_.js → retired-settings-keys-Dr_4lfKu.js} +1 -1
  88. package/dist/{retired-settings-keys-C5qP9QIf.mjs → retired-settings-keys-Is0zO8gX.mjs} +1 -1
  89. package/dist/{wal-maintenance-DvnVO5IK.mjs → wal-maintenance-Bx3lcVA7.mjs} +0 -0
  90. package/dist/{wal-maintenance-Ch79gy9S.js → wal-maintenance-mjB2i-VR.js} +0 -0
  91. package/package.json +1 -1
@@ -3,8 +3,8 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-CEAyXW3Q.js");
7
- const require_retired_settings_keys = require("../../retired-settings-keys-DNfV28R_.js");
6
+ const require_dist = require("../../dist-GgG-7amw.js");
7
+ const require_retired_settings_keys = require("../../retired-settings-keys-Dr_4lfKu.js");
8
8
  let zod = require("zod");
9
9
  let node_crypto = require("node:crypto");
10
10
  let _camstack_types_node = require("@camstack/types/node");
@@ -969,7 +969,9 @@ function toWireShape(input) {
969
969
  const out = { sections: input.sections.map((s) => ({
970
970
  id: s.id,
971
971
  title: s.title,
972
+ ...s.titleKey !== void 0 ? { titleKey: s.titleKey } : {},
972
973
  ...s.description !== void 0 ? { description: s.description } : {},
974
+ ...s.descriptionKey !== void 0 ? { descriptionKey: s.descriptionKey } : {},
973
975
  ...s.style !== void 0 ? { style: s.style } : {},
974
976
  ...s.defaultCollapsed !== void 0 ? { defaultCollapsed: s.defaultCollapsed } : {},
975
977
  ...s.columns !== void 0 ? { columns: s.columns } : {},
@@ -1780,6 +1782,7 @@ function buildDayNightConfigSchema(options, status) {
1780
1782
  type: "select",
1781
1783
  key: "dayNight_mode",
1782
1784
  label: "Mode",
1785
+ labelKey: "server:dayNightConfig.dayNight_mode.label",
1783
1786
  options: options.modes.map((m) => ({
1784
1787
  value: m,
1785
1788
  label: MODE_LABELS[m]
@@ -1790,6 +1793,7 @@ function buildDayNightConfigSchema(options, status) {
1790
1793
  type: "slider",
1791
1794
  key: "dayNight_sensitivity",
1792
1795
  label: "IR-cut sensitivity",
1796
+ labelKey: "server:dayNightConfig.dayNight_sensitivity.label",
1793
1797
  min: options.sensitivity.min,
1794
1798
  max: options.sensitivity.max,
1795
1799
  step: options.sensitivity.step,
@@ -1800,6 +1804,7 @@ function buildDayNightConfigSchema(options, status) {
1800
1804
  type: "number",
1801
1805
  key: "dayNight_switchDelaySec",
1802
1806
  label: "Switch delay",
1807
+ labelKey: "server:dayNightConfig.dayNight_switchDelaySec.label",
1803
1808
  unit: "s",
1804
1809
  min: options.switchDelaySec.min,
1805
1810
  max: options.switchDelaySec.max,
@@ -1811,7 +1816,9 @@ function buildDayNightConfigSchema(options, status) {
1811
1816
  id: "day-night",
1812
1817
  tab: "image",
1813
1818
  title: "Day / Night",
1819
+ titleKey: "server:section.s_day-night.title",
1814
1820
  description: "IR-cut switching mode and the photocell knobs that gate it.",
1821
+ descriptionKey: "server:section.s_day-night.description",
1815
1822
  columns: 2,
1816
1823
  fields
1817
1824
  }] };
@@ -1904,6 +1911,7 @@ function buildImageSettingsConfigSchema(options, status) {
1904
1911
  type: "slider",
1905
1912
  key: "imageSettings_brightness",
1906
1913
  label: "Brightness",
1914
+ labelKey: "server:imageSettingsConfig.imageSettings_brightness.label",
1907
1915
  min: options.brightness.min,
1908
1916
  max: options.brightness.max,
1909
1917
  step: options.brightness.step,
@@ -1914,6 +1922,7 @@ function buildImageSettingsConfigSchema(options, status) {
1914
1922
  type: "slider",
1915
1923
  key: "imageSettings_contrast",
1916
1924
  label: "Contrast",
1925
+ labelKey: "server:imageSettingsConfig.imageSettings_contrast.label",
1917
1926
  min: options.contrast.min,
1918
1927
  max: options.contrast.max,
1919
1928
  step: options.contrast.step,
@@ -1924,6 +1933,7 @@ function buildImageSettingsConfigSchema(options, status) {
1924
1933
  type: "slider",
1925
1934
  key: "imageSettings_saturation",
1926
1935
  label: "Saturation",
1936
+ labelKey: "server:imageSettingsConfig.imageSettings_saturation.label",
1927
1937
  min: options.saturation.min,
1928
1938
  max: options.saturation.max,
1929
1939
  step: options.saturation.step,
@@ -1934,6 +1944,7 @@ function buildImageSettingsConfigSchema(options, status) {
1934
1944
  type: "slider",
1935
1945
  key: "imageSettings_sharpness",
1936
1946
  label: "Sharpness",
1947
+ labelKey: "server:imageSettingsConfig.imageSettings_sharpness.label",
1937
1948
  min: options.sharpness.min,
1938
1949
  max: options.sharpness.max,
1939
1950
  step: options.sharpness.step,
@@ -1944,6 +1955,7 @@ function buildImageSettingsConfigSchema(options, status) {
1944
1955
  type: "boolean",
1945
1956
  key: "imageSettings_mirror",
1946
1957
  label: "Mirror",
1958
+ labelKey: "server:imageSettingsConfig.imageSettings_mirror.label",
1947
1959
  style: "switch",
1948
1960
  default: status?.mirror ?? false
1949
1961
  });
@@ -1951,6 +1963,7 @@ function buildImageSettingsConfigSchema(options, status) {
1951
1963
  type: "boolean",
1952
1964
  key: "imageSettings_flip",
1953
1965
  label: "Flip",
1966
+ labelKey: "server:imageSettingsConfig.imageSettings_flip.label",
1954
1967
  style: "switch",
1955
1968
  default: status?.flip ?? false
1956
1969
  });
@@ -1958,6 +1971,7 @@ function buildImageSettingsConfigSchema(options, status) {
1958
1971
  type: "select",
1959
1972
  key: "imageSettings_rotate",
1960
1973
  label: "Rotation",
1974
+ labelKey: "server:imageSettingsConfig.imageSettings_rotate.label",
1961
1975
  options: options.rotateOptions.map((r) => ({
1962
1976
  value: r,
1963
1977
  label: ROTATE_LABELS[r]
@@ -1968,6 +1982,7 @@ function buildImageSettingsConfigSchema(options, status) {
1968
1982
  type: "select",
1969
1983
  key: "imageSettings_whiteBalance",
1970
1984
  label: "White balance",
1985
+ labelKey: "server:imageSettingsConfig.imageSettings_whiteBalance.label",
1971
1986
  options: options.whiteBalanceModes.map((m) => ({
1972
1987
  value: m,
1973
1988
  label: WHITE_BALANCE_LABELS[m]
@@ -1978,6 +1993,7 @@ function buildImageSettingsConfigSchema(options, status) {
1978
1993
  type: "slider",
1979
1994
  key: "imageSettings_warmth",
1980
1995
  label: "Warmth",
1996
+ labelKey: "server:imageSettingsConfig.imageSettings_warmth.label",
1981
1997
  min: options.warmth.min,
1982
1998
  max: options.warmth.max,
1983
1999
  step: options.warmth.step,
@@ -1992,6 +2008,7 @@ function buildImageSettingsConfigSchema(options, status) {
1992
2008
  type: "select",
1993
2009
  key: "imageSettings_exposureMode",
1994
2010
  label: "Exposure mode",
2011
+ labelKey: "server:imageSettingsConfig.imageSettings_exposureMode.label",
1995
2012
  options: options.exposureModes.map((m) => ({
1996
2013
  value: m,
1997
2014
  label: EXPOSURE_LABELS[m]
@@ -2002,6 +2019,7 @@ function buildImageSettingsConfigSchema(options, status) {
2002
2019
  type: "select",
2003
2020
  key: "imageSettings_backlightMode",
2004
2021
  label: "Backlight compensation",
2022
+ labelKey: "server:imageSettingsConfig.imageSettings_backlightMode.label",
2005
2023
  options: options.backlightModes.map((m) => ({
2006
2024
  value: m,
2007
2025
  label: BACKLIGHT_LABELS[m]
@@ -2013,7 +2031,9 @@ function buildImageSettingsConfigSchema(options, status) {
2013
2031
  id: "image-settings",
2014
2032
  tab: "image",
2015
2033
  title: "Image adjustment",
2034
+ titleKey: "server:section.s_image-settings.title",
2016
2035
  description: "Picture sliders, orientation and exposure. Option lists are read live from the camera — fields the firmware doesn't expose are hidden.",
2036
+ descriptionKey: "server:section.s_image-settings.description",
2017
2037
  columns: 2,
2018
2038
  fields
2019
2039
  }] };
@@ -2147,26 +2167,31 @@ function volumesField(status) {
2147
2167
  {
2148
2168
  key: "id",
2149
2169
  label: "Id",
2170
+ labelKey: "server:volumesField.id.label",
2150
2171
  kind: "monospace",
2151
2172
  width: "70px"
2152
2173
  },
2153
2174
  {
2154
2175
  key: "label",
2155
2176
  label: "Name",
2177
+ labelKey: "server:volumesField.label.label",
2156
2178
  kind: "monospace"
2157
2179
  },
2158
2180
  {
2159
2181
  key: "status",
2160
2182
  label: "Status",
2183
+ labelKey: "server:volumesField.status.label",
2161
2184
  kind: "status"
2162
2185
  },
2163
2186
  {
2164
2187
  key: "capacity",
2165
- label: "Capacity"
2188
+ label: "Capacity",
2189
+ labelKey: "server:volumesField.capacity.label"
2166
2190
  },
2167
2191
  {
2168
2192
  key: "free",
2169
- label: "Free"
2193
+ label: "Free",
2194
+ labelKey: "server:volumesField.free.label"
2170
2195
  }
2171
2196
  ],
2172
2197
  value: rows,
@@ -2190,27 +2215,32 @@ function tracksField(status) {
2190
2215
  {
2191
2216
  key: "id",
2192
2217
  label: "Track",
2218
+ labelKey: "server:tracksField.id.label",
2193
2219
  kind: "monospace",
2194
2220
  width: "80px"
2195
2221
  },
2196
2222
  {
2197
2223
  key: "primary",
2198
2224
  label: "Primary",
2225
+ labelKey: "server:tracksField.primary.label",
2199
2226
  width: "80px"
2200
2227
  },
2201
2228
  {
2202
2229
  key: "enabled",
2203
2230
  label: "Enabled",
2231
+ labelKey: "server:tracksField.enabled.label",
2204
2232
  kind: "status",
2205
2233
  width: "90px"
2206
2234
  },
2207
2235
  {
2208
2236
  key: "codec",
2209
- label: "Codec"
2237
+ label: "Codec",
2238
+ labelKey: "server:tracksField.codec.label"
2210
2239
  },
2211
2240
  {
2212
2241
  key: "resolution",
2213
- label: "Resolution"
2242
+ label: "Resolution",
2243
+ labelKey: "server:tracksField.resolution.label"
2214
2244
  }
2215
2245
  ],
2216
2246
  value: rows,
@@ -2412,7 +2442,9 @@ function buildRecordingOnboardConfigSchema(options, status) {
2412
2442
  id: "recording-onboard-storage",
2413
2443
  tab: "recording",
2414
2444
  title: "Camera storage",
2445
+ titleKey: "server:storageSection.s_recording-onboard-storage.title",
2415
2446
  description: "The card in the camera, as the camera itself describes it.",
2447
+ descriptionKey: "server:storageSection.s_recording-onboard-storage.description",
2416
2448
  columns: 1,
2417
2449
  fields: [
2418
2450
  storageBanner(status),
@@ -2423,7 +2455,9 @@ function buildRecordingOnboardConfigSchema(options, status) {
2423
2455
  id: "recording-onboard-recording",
2424
2456
  tab: "recording",
2425
2457
  title: "Camera recording",
2458
+ titleKey: "server:recordingSection.s_recording-onboard-recording.title",
2426
2459
  description: "What the camera records to its own card. This writes the camera, not CamStack's recorder.",
2460
+ descriptionKey: "server:recordingSection.s_recording-onboard-recording.description",
2427
2461
  columns: 2,
2428
2462
  fields: [
2429
2463
  booleanField(RECORDING_ONBOARD_KEYS.enabled, "Record to card", "The camera-side master switch.", options.enabled, status?.enabled ?? null),
@@ -3032,7 +3066,7 @@ async function getStreamSources(pctx, input) {
3032
3066
  }));
3033
3067
  }
3034
3068
  }
3035
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
3069
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw require_dist.userError("device.not-found", { id: deviceId }, `[device-manager] Device with id ${deviceId} not found`);
3036
3070
  return (await pctx.requireDeviceOps(deviceId).getStreamSources({ deviceId })).map((s) => ({ ...s }));
3037
3071
  }
3038
3072
  async function getConfigSchema(pctx, input) {
@@ -3045,7 +3079,7 @@ async function getConfigSchema(pctx, input) {
3045
3079
  ...entry.description !== void 0 ? { description: entry.description } : {}
3046
3080
  }));
3047
3081
  }
3048
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
3082
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw require_dist.userError("device.not-found", { id: deviceId }, `[device-manager] Device with id ${deviceId} not found`);
3049
3083
  return (await pctx.requireDeviceOps(deviceId).getConfigEntries({ deviceId })).map((e) => ({ ...e }));
3050
3084
  }
3051
3085
  async function getSettingsSchema(pctx, input) {
@@ -3066,7 +3100,7 @@ async function updateConfig(pctx, input) {
3066
3100
  return { success: true };
3067
3101
  }
3068
3102
  }
3069
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
3103
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw require_dist.userError("device.not-found", { id: deviceId }, `[device-manager] Device with id ${deviceId} not found`);
3070
3104
  await pctx.requireDeviceOps(deviceId).setConfig({
3071
3105
  deviceId,
3072
3106
  values: input.values
@@ -3166,7 +3200,7 @@ async function setStreamProfileMap(pctx, input) {
3166
3200
  return { success: true };
3167
3201
  }
3168
3202
  }
3169
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
3203
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw require_dist.userError("device.not-found", { id: deviceId }, `[device-manager] Device with id ${deviceId} not found`);
3170
3204
  await pctx.requireDeviceOps(deviceId).setConfig({
3171
3205
  deviceId,
3172
3206
  values: { _profileMap: input.profileMap }
@@ -3203,7 +3237,7 @@ async function probeStreams(pctx, input) {
3203
3237
  }
3204
3238
  async function discoverDevices(pctx, input) {
3205
3239
  const dp = await pctx.host.requireDeviceProvider(input.addonId);
3206
- if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device discovery`);
3240
+ if (!await dp.supportsDiscovery({})) throw require_dist.userError("system.addon-no-discovery", { addonId: String(input.addonId) }, `Addon "${input.addonId}" does not support device discovery`);
3207
3241
  return (await dp.discoverDevices({})).map((d) => ({
3208
3242
  stableId: d.stableId,
3209
3243
  type: d.type,
@@ -3216,7 +3250,7 @@ async function discoverDevices(pctx, input) {
3216
3250
  }
3217
3251
  async function adoptDevice(pctx, input) {
3218
3252
  const dp = await pctx.host.requireDeviceProvider(input.addonId);
3219
- if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device adoption`);
3253
+ if (!await dp.supportsDiscovery({})) throw require_dist.userError("system.addon-no-adoption", { addonId: String(input.addonId) }, `Addon "${input.addonId}" does not support device adoption`);
3220
3254
  const summary = await pctx.removalFence.runUnfenced(() => dp.adoptDiscoveredDevice({ candidate: input.candidate }));
3221
3255
  if (input.integrationId !== void 0) try {
3222
3256
  await pctx.stampIntegrationId(summary.id, input.integrationId);
@@ -3238,7 +3272,7 @@ async function getCreationSchema(pctx, input) {
3238
3272
  }
3239
3273
  async function createDevice(pctx, input) {
3240
3274
  const dp = await pctx.host.requireDeviceProvider(input.addonId);
3241
- if (!await dp.supportsManualCreation({})) throw new Error(`Addon "${input.addonId}" does not support manual device creation`);
3275
+ if (!await dp.supportsManualCreation({})) throw require_dist.userError("system.addon-no-manual-create", { addonId: String(input.addonId) }, `Addon "${input.addonId}" does not support manual device creation`);
3242
3276
  const summary = await pctx.removalFence.runUnfenced(() => dp.createDevice({
3243
3277
  type: input.type,
3244
3278
  config: input.config
@@ -3331,7 +3365,7 @@ async function testField(pctx, input) {
3331
3365
  let owningAddonId = null;
3332
3366
  if (pctx.registry) owningAddonId = pctx.registry.getAddonId(deviceId);
3333
3367
  if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(deviceId))?.addonId ?? null;
3334
- if (!owningAddonId) throw new Error(`Device with id ${deviceId} not found`);
3368
+ if (!owningAddonId) throw require_dist.userError("device.not-found", { id: deviceId }, `Device with id ${deviceId} not found`);
3335
3369
  const dp = await pctx.host.waitDeviceProvider(owningAddonId);
3336
3370
  if (!dp) return {
3337
3371
  status: "ok",
@@ -3840,12 +3874,14 @@ async function buildBaseDeviceSection(deps, deviceId) {
3840
3874
  } else if (driverResult.status === "unavailable") sections.push({
3841
3875
  id: "driver-settings-unavailable",
3842
3876
  title: "Driver Settings",
3877
+ titleKey: "server:baseDeviceSection.s_driver-settings-unavailable.title",
3843
3878
  tab: "general",
3844
3879
  order: 1,
3845
3880
  fields: [{
3846
3881
  type: "info",
3847
3882
  key: "driver-settings-unavailable-notice",
3848
3883
  label: "Temporarily unavailable",
3884
+ labelKey: "server:baseDeviceSection.s_driver-settings-unavailable.driver-settings-unavailable-notice.label",
3849
3885
  content: "Driver settings are temporarily unavailable — the addon may be restarting. Refresh the page in a moment.",
3850
3886
  variant: "warning"
3851
3887
  }]
@@ -5546,6 +5582,250 @@ async function runLocationBackfillAtBoot(pctx, bootRows) {
5546
5582
  });
5547
5583
  }
5548
5584
  //#endregion
5585
+ //#region src/builtins/device-manager/device-removal-hooks.ts
5586
+ /**
5587
+ * device-manager's side of `device-removal-hook` (D865): tell every implementer
5588
+ * a device was deleted, and answer the admin question "which stored data belongs
5589
+ * to devices that no longer exist?".
5590
+ *
5591
+ * Two entry points, one rule: **an implementer is told to delete only on a
5592
+ * recorded delete (a tombstone), or on an explicit operator confirm.** Nothing
5593
+ * here infers a deletion from a device being absent from a single read.
5594
+ */
5595
+ /** The capability name implementers register under. */
5596
+ var DEVICE_REMOVAL_HOOK_CAP = "device-removal-hook";
5597
+ async function hookProviders(api) {
5598
+ return (await api.addons.listCapabilityProviders.query({ capName: DEVICE_REMOVAL_HOOK_CAP })).map((p) => p.addonId);
5599
+ }
5600
+ /**
5601
+ * The removal step that calls every implementer. Each implementer is its own
5602
+ * nested step, so a failing one is retried by the bounded reconcile without
5603
+ * re-calling the others. Failing to ENUMERATE the implementers throws, and the
5604
+ * outer cleanup retries the whole step.
5605
+ */
5606
+ function removalHookStep(deps, input) {
5607
+ return {
5608
+ store: "removal-hooks",
5609
+ run: async () => {
5610
+ const api = deps.api;
5611
+ if (api === void 0) {
5612
+ deps.logger.warn("removal hooks not called — no api on this context", { tags: { deviceId: input.deviceId } });
5613
+ return 0;
5614
+ }
5615
+ const addonIds = await hookProviders(api);
5616
+ if (addonIds.length === 0) return 0;
5617
+ const steps = addonIds.map((addonId) => ({
5618
+ store: `hook:${addonId}`,
5619
+ run: async () => {
5620
+ const result = await api.deviceRemovalHook.onDeviceRemoved.mutate({
5621
+ ...input,
5622
+ addonId
5623
+ });
5624
+ return Object.values(result.counts).reduce((sum, n) => sum + n, 0);
5625
+ }
5626
+ }));
5627
+ const report = await deps.cleanup.run(input.deviceId, steps);
5628
+ return Object.values(report.counts).reduce((sum, n) => sum + n, 0);
5629
+ }
5630
+ };
5631
+ }
5632
+ /**
5633
+ * The ids device-manager knows, from TWO consistent reads (D49). Refuses when a
5634
+ * read throws, answers empty (an empty fleet and a failed read look the same),
5635
+ * or returns fewer ids than the ledger has rows (a malformed row is skipped by
5636
+ * the decoder, and a device skipped is a device whose data would be deleted).
5637
+ * The known set is the UNION of both reads: an id is absent only when BOTH
5638
+ * reads lack it.
5639
+ */
5640
+ async function checkFleetTwice(reads) {
5641
+ const known = /* @__PURE__ */ new Set();
5642
+ for (const attempt of [1, 2]) {
5643
+ let read;
5644
+ try {
5645
+ read = await reads.readFleet();
5646
+ } catch (err) {
5647
+ return {
5648
+ ok: false,
5649
+ reason: `device list read ${attempt} failed: ${require_dist.errMsg(err)}`
5650
+ };
5651
+ }
5652
+ if (read.ids.length === 0) return {
5653
+ ok: false,
5654
+ reason: `device list read ${attempt} answered empty`
5655
+ };
5656
+ if (read.ids.length !== read.rowCount) return {
5657
+ ok: false,
5658
+ reason: `device list read ${attempt} returned ${read.ids.length} of ${read.rowCount} rows`
5659
+ };
5660
+ for (const id of read.ids) known.add(id);
5661
+ }
5662
+ return {
5663
+ ok: true,
5664
+ known
5665
+ };
5666
+ }
5667
+ /** Stores holding data for devices that are not in `known`, grouped per device. */
5668
+ function orphanDevices(held, known) {
5669
+ const byDevice = /* @__PURE__ */ new Map();
5670
+ const holders = /* @__PURE__ */ new Set();
5671
+ let overlap = 0;
5672
+ for (const [, entry] of held) if (!holders.has(entry.deviceId)) {
5673
+ holders.add(entry.deviceId);
5674
+ if (known.has(entry.deviceId)) overlap += 1;
5675
+ }
5676
+ for (const [addonId, entry] of held) {
5677
+ if (known.has(entry.deviceId) || entry.count === 0) continue;
5678
+ const stores = byDevice.get(entry.deviceId) ?? {};
5679
+ stores[`${addonId}:${entry.store}`] = (stores[`${addonId}:${entry.store}`] ?? 0) + entry.count;
5680
+ byDevice.set(entry.deviceId, stores);
5681
+ }
5682
+ const idSpaceMismatch = holders.size >= 5 && overlap === 0;
5683
+ return {
5684
+ devices: [...byDevice.entries()].toSorted(([a], [b]) => a - b).map(([deviceId, stores]) => ({
5685
+ deviceId,
5686
+ stores
5687
+ })),
5688
+ idSpaceMismatch
5689
+ };
5690
+ }
5691
+ /** The admin "Clean data of deleted devices" action: dry run unless `confirm`. */
5692
+ async function cleanupDeletedDevicesData(deps, input) {
5693
+ const skipped = (reason, unreachable = []) => {
5694
+ deps.logger.warn("cleanup of deleted devices data skipped — nothing listed or deleted", { meta: { reason } });
5695
+ return {
5696
+ status: "skipped",
5697
+ reason,
5698
+ devices: [],
5699
+ unreachable: [...unreachable]
5700
+ };
5701
+ };
5702
+ const api = deps.api;
5703
+ if (api === void 0) return skipped("no api on this context");
5704
+ const fleet = await checkFleetTwice(deps.reads);
5705
+ if (!fleet.ok) return skipped(fleet.reason);
5706
+ let addonIds;
5707
+ try {
5708
+ addonIds = await hookProviders(api);
5709
+ } catch (err) {
5710
+ return skipped(`implementers could not be listed: ${require_dist.errMsg(err)}`);
5711
+ }
5712
+ const unreachable = /* @__PURE__ */ new Set();
5713
+ const held = [];
5714
+ for (const addonId of addonIds) try {
5715
+ for (const entry of await api.deviceRemovalHook.listDeviceData.query({ addonId })) held.push([addonId, entry]);
5716
+ } catch (err) {
5717
+ unreachable.add(addonId);
5718
+ deps.logger.warn("cleanup of deleted devices data: an implementer did not answer", { meta: {
5719
+ addonId,
5720
+ error: require_dist.errMsg(err)
5721
+ } });
5722
+ }
5723
+ if (deps.settings !== void 0) try {
5724
+ for (const scope of await deps.settings.list()) held.push(["settings", {
5725
+ store: scope.addonId,
5726
+ deviceId: scope.deviceId,
5727
+ count: 1
5728
+ }]);
5729
+ } catch (err) {
5730
+ return skipped(`device settings could not be listed: ${require_dist.errMsg(err)}`, [...unreachable]);
5731
+ }
5732
+ const { devices, idSpaceMismatch } = orphanDevices(held, fleet.known);
5733
+ if (idSpaceMismatch) return skipped("none of the device ids holding data is a known device — the id space looks wrong, refusing", [...unreachable]);
5734
+ if (!input.confirm) {
5735
+ deps.logger.info("cleanup of deleted devices data: dry run", { meta: {
5736
+ devices: devices.length,
5737
+ unreachable: [...unreachable]
5738
+ } });
5739
+ return {
5740
+ status: "dry-run",
5741
+ reason: null,
5742
+ devices: [...devices],
5743
+ unreachable: [...unreachable]
5744
+ };
5745
+ }
5746
+ const now = (deps.now ?? (() => Date.now()))();
5747
+ const cleaned = [];
5748
+ for (const device of devices) {
5749
+ const deleted = {};
5750
+ for (const addonId of addonIds) try {
5751
+ const result = await api.deviceRemovalHook.onDeviceRemoved.mutate({
5752
+ addonId,
5753
+ deviceId: device.deviceId,
5754
+ deletedAt: now,
5755
+ providerAddonId: "unknown"
5756
+ });
5757
+ for (const [store, count] of Object.entries(result.counts)) deleted[`${addonId}:${store}`] = count;
5758
+ } catch (err) {
5759
+ deps.logger.warn("cleanup of deleted devices data: an implementer failed", {
5760
+ tags: { deviceId: device.deviceId },
5761
+ meta: {
5762
+ addonId,
5763
+ error: require_dist.errMsg(err)
5764
+ }
5765
+ });
5766
+ }
5767
+ if (deps.settings !== void 0 && Object.keys(device.stores).some((k) => k.startsWith("settings:"))) try {
5768
+ deleted["settings"] = await deps.settings.clear(device.deviceId);
5769
+ } catch (err) {
5770
+ deps.logger.warn("cleanup of deleted devices data: settings not cleared", {
5771
+ tags: { deviceId: device.deviceId },
5772
+ meta: { error: require_dist.errMsg(err) }
5773
+ });
5774
+ }
5775
+ deps.logger.info("cleaned data of a device deleted before tombstones", {
5776
+ tags: { deviceId: device.deviceId },
5777
+ meta: { counts: deleted }
5778
+ });
5779
+ cleaned.push({
5780
+ deviceId: device.deviceId,
5781
+ stores: deleted
5782
+ });
5783
+ }
5784
+ return {
5785
+ status: "cleaned",
5786
+ reason: null,
5787
+ devices: cleaned,
5788
+ unreachable: [...unreachable]
5789
+ };
5790
+ }
5791
+ /** The settings-store collection every addon's per-device settings share. */
5792
+ var ADDON_DEVICE_SETTINGS_COLLECTION = "addon-device-settings";
5793
+ /** The engine's hard ceiling on one read; a result this long may be a truncated one. */
5794
+ var SCOPE_READ_LIMIT = 2e4;
5795
+ /** Scope access over the settings store; undefined when the store is unavailable. */
5796
+ function settingsScopes(store) {
5797
+ if (store === void 0) return void 0;
5798
+ return {
5799
+ list: async () => {
5800
+ const rows = await store.query.query({
5801
+ collection: ADDON_DEVICE_SETTINGS_COLLECTION,
5802
+ filter: { limit: SCOPE_READ_LIMIT }
5803
+ });
5804
+ if (rows.length >= SCOPE_READ_LIMIT) throw new Error(`the settings read returned ${rows.length} rows — possibly truncated`);
5805
+ const seen = /* @__PURE__ */ new Map();
5806
+ for (const row of rows) {
5807
+ const addonId = row.data["addonId"];
5808
+ const raw = row.data["deviceId"];
5809
+ if (typeof addonId !== "string" || typeof raw !== "string") continue;
5810
+ const deviceId = Number(raw);
5811
+ if (!Number.isInteger(deviceId) || deviceId <= 0) continue;
5812
+ seen.set(`${addonId}:${deviceId}`, {
5813
+ deviceId,
5814
+ addonId
5815
+ });
5816
+ }
5817
+ return [...seen.values()];
5818
+ },
5819
+ clear: async (deviceId) => {
5820
+ const { deleted } = await store.deleteWhere.mutate({
5821
+ collection: ADDON_DEVICE_SETTINGS_COLLECTION,
5822
+ filter: { where: { deviceId: String(deviceId) } }
5823
+ });
5824
+ return deleted;
5825
+ }
5826
+ };
5827
+ }
5828
+ //#endregion
5549
5829
  //#region src/builtins/device-manager/device-meta-actions.ts
5550
5830
  /**
5551
5831
  * Device meta-mutation + persistence actions for the device-manager addon —
@@ -5944,6 +6224,91 @@ async function registerDevice(pctx, input) {
5944
6224
  }
5945
6225
  });
5946
6226
  }
6227
+ /**
6228
+ * The stores device-manager (or the keychain, for secrets) keeps for a device.
6229
+ * Another addon's own per-device data is cleaned by that addon, off
6230
+ * `DeviceUnregistered` — the kernel never reaches into its tables (D864).
6231
+ */
6232
+ function removalSteps(pctx, deviceId, providerAddonId, deletedAt) {
6233
+ const secrets = pctx.host.ctx.secrets;
6234
+ return [
6235
+ {
6236
+ store: "tombstone",
6237
+ run: async () => {
6238
+ await pctx.tombstones.record({
6239
+ deviceId,
6240
+ deletedAt,
6241
+ providerAddonId
6242
+ });
6243
+ return 1;
6244
+ }
6245
+ },
6246
+ {
6247
+ store: "keychain-secrets",
6248
+ run: async () => {
6249
+ const purge = secrets?.deleteDeviceScope;
6250
+ if (purge === void 0) {
6251
+ pctx.host.ctx.logger.warn("device secrets not purged — no keychain door", { tags: { deviceId } });
6252
+ return 0;
6253
+ }
6254
+ return (await purge(deviceId)).length;
6255
+ }
6256
+ },
6257
+ {
6258
+ store: "device-config",
6259
+ run: async () => {
6260
+ const stored = await pctx.settings.readDeviceStore(deviceId);
6261
+ await pctx.settings.clearDeviceStore(deviceId);
6262
+ return Object.keys(stored).length;
6263
+ }
6264
+ },
6265
+ {
6266
+ store: "addon-device-settings",
6267
+ run: async () => {
6268
+ const api = pctx.host.ctx.api?.settingsStore;
6269
+ if (api === void 0) throw new Error("settings-store api not available");
6270
+ const { deleted } = await api.deleteWhere.mutate({
6271
+ collection: "addon-device-settings",
6272
+ filter: { where: { deviceId: String(deviceId) } }
6273
+ });
6274
+ return deleted;
6275
+ }
6276
+ },
6277
+ {
6278
+ store: "runtime-state",
6279
+ run: async () => {
6280
+ await pctx.host.forgetMirrorDevice(deviceId);
6281
+ const stored = await pctx.settings.readDeviceRuntimeState(deviceId);
6282
+ await pctx.settings.clearDeviceRuntimeState(deviceId);
6283
+ return Object.keys(stored).length;
6284
+ }
6285
+ },
6286
+ {
6287
+ store: "field-claims",
6288
+ run: async () => {
6289
+ await pctx.host.fieldClaims.pruneDevice(deviceId, pctx.settings);
6290
+ return 0;
6291
+ }
6292
+ },
6293
+ {
6294
+ store: "bindings",
6295
+ run: () => pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
6296
+ const key = String(deviceId);
6297
+ const bindingsStore = await readBindingsStore(pctx.bindingsDeps);
6298
+ const slice = bindingsStore.deviceBindings[key];
6299
+ if (!slice) return 0;
6300
+ const { [key]: _removed, ...rest } = bindingsStore.deviceBindings;
6301
+ await writeBindingsStore(pctx.bindingsDeps, { deviceBindings: rest });
6302
+ return Object.keys(slice).length;
6303
+ })
6304
+ },
6305
+ removalHookStep(pctx.removalHooks, {
6306
+ deviceId,
6307
+ deletedAt,
6308
+ providerAddonId
6309
+ })
6310
+ ];
6311
+ }
5947
6312
  async function removeDevice(pctx, input) {
5948
6313
  const { deviceId } = input;
5949
6314
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
@@ -5959,17 +6324,7 @@ async function removeDevice(pctx, input) {
5959
6324
  });
5960
6325
  });
5961
6326
  pctx.secretConfigPaths.delete(deviceId);
5962
- await pctx.settings.clearDeviceStore(deviceId);
5963
- await pctx.host.forgetMirrorDevice(deviceId);
5964
- await pctx.settings.clearDeviceRuntimeState(deviceId);
5965
- await pctx.host.fieldClaims.pruneDevice(deviceId, pctx.settings);
5966
- const bindingKey = String(deviceId);
5967
- await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
5968
- const bindingsStore = await readBindingsStore(pctx.bindingsDeps);
5969
- if (!bindingsStore.deviceBindings[bindingKey]) return;
5970
- const { [bindingKey]: _removedBindings, ...restBindings } = bindingsStore.deviceBindings;
5971
- await writeBindingsStore(pctx.bindingsDeps, { deviceBindings: restBindings });
5972
- });
6327
+ await pctx.removalCleanup.run(deviceId, removalSteps(pctx, deviceId, addonId, Date.now()));
5973
6328
  pctx.host.remoteNativeCaps.delete(deviceId);
5974
6329
  pctx.host.capabilityRegistry?.unregisterAllNativeForDevice(deviceId);
5975
6330
  pctx.metaStore.idToAddonId.delete(deviceId);
@@ -6666,7 +7021,7 @@ async function listLocations(pctx) {
6666
7021
  */
6667
7022
  async function addLocation(pctx, input) {
6668
7023
  const trimmed = input.name.trim();
6669
- if (trimmed.length === 0) throw new Error("[device-manager] addLocation: name must be non-empty");
7024
+ if (trimmed.length === 0) throw require_dist.userError("system.location-name-required", void 0, "[device-manager] addLocation: name must be non-empty");
6670
7025
  await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
6671
7026
  const current = (await pctx.metaStore.readStore()).locations ?? [];
6672
7027
  if (current.some((l) => l.toLowerCase() === trimmed.toLowerCase())) return;
@@ -6712,7 +7067,7 @@ async function listLocationIcons(pctx) {
6712
7067
  */
6713
7068
  async function setLocationIcon(pctx, input) {
6714
7069
  const trimmed = input.location.trim();
6715
- if (trimmed.length === 0) throw new Error("[device-manager] setLocationIcon: location must be non-empty");
7070
+ if (trimmed.length === 0) throw require_dist.userError("system.location-required", void 0, "[device-manager] setLocationIcon: location must be non-empty");
6716
7071
  const key = require_dist.locationIconKey(trimmed);
6717
7072
  await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
6718
7073
  const icons = (await pctx.metaStore.readStore()).locationIcons ?? {};
@@ -6812,7 +7167,7 @@ async function listSiteZones(pctx) {
6812
7167
  */
6813
7168
  async function setSiteZone(pctx, input) {
6814
7169
  const label = input.name.trim();
6815
- if (label.length === 0) throw new Error("[device-manager] setSiteZone: name must be non-empty");
7170
+ if (label.length === 0) throw require_dist.userError("system.site-zone-name-required", void 0, "[device-manager] setSiteZone: name must be non-empty");
6816
7171
  const key = require_dist.siteZoneKey(label);
6817
7172
  await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
6818
7173
  const zones = (await pctx.metaStore.readStore()).siteZones ?? {};
@@ -8299,6 +8654,171 @@ var DeviceRowStore = class {
8299
8654
  }
8300
8655
  };
8301
8656
  //#endregion
8657
+ //#region src/builtins/device-manager/device-tombstone-store.ts
8658
+ /**
8659
+ * The tombstones of deleted devices (D865).
8660
+ *
8661
+ * A device DELETE is a fact other addons must be able to learn reliably, long
8662
+ * after the `DeviceUnregistered` event (telemetry, droppable — D8/D11) is gone.
8663
+ * device-manager is the only authority on whether a device exists, so the record
8664
+ * of a deletion lives here, in its own collection, beside the device ledger.
8665
+ *
8666
+ * ## What a tombstone is NOT
8667
+ *
8668
+ * It is not "the device is unregistered / offline / its provider is restarting".
8669
+ * Those are states of a device that still exists; only {@link record}'s one
8670
+ * caller — `removeDevice`, the single door every delete converges on — writes a
8671
+ * tombstone. Anything that reconciles from tombstones therefore can never
8672
+ * destroy data of a device that merely went quiet.
8673
+ *
8674
+ * ## Expiry
8675
+ *
8676
+ * {@link DEVICE_TOMBSTONE_RETENTION_MS} (90 days). A tombstone older than that
8677
+ * is deleted the next time one is written or read. Ninety days is generous on
8678
+ * purpose: an addon that stayed disabled for a quarter still learns of the
8679
+ * deletions on its return. Data of a device deleted longer ago than that is
8680
+ * reachable only by the admin "clean data of deleted devices" action.
8681
+ */
8682
+ /**
8683
+ * @durable class=ledger owner=device-manager
8684
+ * write="`removeDevice` records one row per deleted device (key = device id) after the
8685
+ * device row is gone"
8686
+ * retention="90 days (`DEVICE_TOMBSTONE_RETENTION_MS`), pruned whenever a tombstone is
8687
+ * written or the list is read. Bounded by the deletions an operator makes in a quarter."
8688
+ */
8689
+ var DEVICE_TOMBSTONES_COLLECTION = "device-manager:tombstones";
8690
+ var DEVICE_TOMBSTONE_COLUMNS = [
8691
+ {
8692
+ name: "id",
8693
+ type: "TEXT",
8694
+ primaryKey: true,
8695
+ notNull: true
8696
+ },
8697
+ {
8698
+ name: "deviceId",
8699
+ type: "INTEGER",
8700
+ notNull: true
8701
+ },
8702
+ {
8703
+ name: "deletedAt",
8704
+ type: "INTEGER",
8705
+ notNull: true
8706
+ },
8707
+ {
8708
+ name: "providerAddonId",
8709
+ type: "TEXT",
8710
+ notNull: true
8711
+ }
8712
+ ];
8713
+ var DEVICE_TOMBSTONE_INDEXES = [{
8714
+ name: "idx_dm_tombstones_deleted_at",
8715
+ columns: ["deletedAt"]
8716
+ }];
8717
+ /** More than the engine's default row cap, so a read is never silently truncated. */
8718
+ var TOMBSTONE_READ_LIMIT = 2e4;
8719
+ function decodeTombstone(raw) {
8720
+ const deviceId = raw["deviceId"];
8721
+ const deletedAt = raw["deletedAt"];
8722
+ const providerAddonId = raw["providerAddonId"];
8723
+ if (typeof deviceId !== "number" || !Number.isInteger(deviceId) || deviceId <= 0) return null;
8724
+ if (typeof deletedAt !== "number" || typeof providerAddonId !== "string") return null;
8725
+ return {
8726
+ deviceId,
8727
+ deletedAt,
8728
+ providerAddonId
8729
+ };
8730
+ }
8731
+ var DeviceTombstoneStore = class {
8732
+ backend;
8733
+ logger;
8734
+ now;
8735
+ retentionMs;
8736
+ declared = null;
8737
+ constructor(opts) {
8738
+ this.backend = opts.backend;
8739
+ this.logger = opts.logger;
8740
+ this.now = opts.now ?? (() => Date.now());
8741
+ this.retentionMs = opts.retentionMs ?? 7776e6;
8742
+ }
8743
+ /** Lazy, idempotent declaration; a rejection is not latched. */
8744
+ declare() {
8745
+ if (this.declared !== null) return this.declared;
8746
+ const run = this.backend.declareCollection({
8747
+ collection: DEVICE_TOMBSTONES_COLLECTION,
8748
+ columns: DEVICE_TOMBSTONE_COLUMNS,
8749
+ indexes: DEVICE_TOMBSTONE_INDEXES
8750
+ }).catch((err) => {
8751
+ this.declared = null;
8752
+ throw err;
8753
+ });
8754
+ this.declared = run;
8755
+ return run;
8756
+ }
8757
+ /** Record a deletion. Re-recording the same id keeps the EARLIER `deletedAt`. */
8758
+ async record(tombstone) {
8759
+ await this.declare();
8760
+ const key = String(tombstone.deviceId);
8761
+ const existing = await this.backend.get({
8762
+ collection: DEVICE_TOMBSTONES_COLLECTION,
8763
+ key
8764
+ });
8765
+ if ((isPlainObject(existing) ? decodeTombstone(existing) : null) === null) await this.backend.set({
8766
+ collection: DEVICE_TOMBSTONES_COLLECTION,
8767
+ key,
8768
+ value: {
8769
+ deviceId: tombstone.deviceId,
8770
+ deletedAt: tombstone.deletedAt,
8771
+ providerAddonId: tombstone.providerAddonId
8772
+ }
8773
+ });
8774
+ await this.pruneExpired();
8775
+ }
8776
+ /** Tombstones with `deletedAt >= sinceMs`, oldest first. Expired ones are pruned first. */
8777
+ async listSince(sinceMs) {
8778
+ await this.declare();
8779
+ await this.pruneExpired();
8780
+ const floor = Math.max(sinceMs, this.now() - this.retentionMs);
8781
+ const rows = await this.backend.query({
8782
+ collection: DEVICE_TOMBSTONES_COLLECTION,
8783
+ filter: {
8784
+ whereBetween: { deletedAt: [floor, Number.MAX_SAFE_INTEGER] },
8785
+ orderBy: {
8786
+ field: "deletedAt",
8787
+ direction: "asc"
8788
+ },
8789
+ limit: TOMBSTONE_READ_LIMIT
8790
+ }
8791
+ });
8792
+ const out = [];
8793
+ for (const row of rows) {
8794
+ const decoded = decodeTombstone(row.data);
8795
+ if (decoded !== null) out.push(decoded);
8796
+ }
8797
+ return out;
8798
+ }
8799
+ /** Delete tombstones older than the retention window. Best-effort: a failure is logged. */
8800
+ async pruneExpired() {
8801
+ try {
8802
+ const cutoff = this.now() - this.retentionMs;
8803
+ const stale = await this.backend.query({
8804
+ collection: DEVICE_TOMBSTONES_COLLECTION,
8805
+ filter: {
8806
+ whereBetween: { deletedAt: [0, cutoff - 1] },
8807
+ limit: TOMBSTONE_READ_LIMIT
8808
+ }
8809
+ });
8810
+ for (const row of stale) await this.backend.delete({
8811
+ collection: DEVICE_TOMBSTONES_COLLECTION,
8812
+ key: row.id
8813
+ });
8814
+ return stale.length;
8815
+ } catch (err) {
8816
+ this.logger.warn("device tombstone prune failed — retried on the next write", { meta: { error: require_dist.errMsg(err) } });
8817
+ return 0;
8818
+ }
8819
+ }
8820
+ };
8821
+ //#endregion
8302
8822
  //#region src/builtins/device-manager/device-state-claims.ts
8303
8823
  function createDeviceStateClaimMethods(deps) {
8304
8824
  const { mirror, index, settings, logger } = deps;
@@ -9765,6 +10285,119 @@ function createMigrationGuard(now = Date.now) {
9765
10285
  }
9766
10286
  };
9767
10287
  }
10288
+ //#endregion
10289
+ //#region src/builtins/device-manager/removal-cleanup.ts
10290
+ /**
10291
+ * What a device removal leaves behind is cleaned AT REMOVAL, by steps that
10292
+ * cannot block one another (D864).
10293
+ *
10294
+ * `removeDevice` used to run its clean-up as one straight line: the first step
10295
+ * that threw skipped every later one, and the keychain was not in the line at
10296
+ * all — a device-scoped secret (`secret://provider-dreame/device/4483/…`)
10297
+ * outlived its device for good. Each store is a {@link RemovalStep} now: run
10298
+ * independently, counted, logged ONCE per device with `tags: { deviceId }`,
10299
+ * and a step that failed is retried by a BOUNDED in-memory reconcile
10300
+ * (a few attempts, growing delays, then it gives up loudly).
10301
+ *
10302
+ * There is deliberately no boot-time sweep (the operator forbids purges on
10303
+ * boot): a retry that dies with the process is picked up by the keychain
10304
+ * admin's orphan listing (System -> Secrets), which names a device-scoped
10305
+ * secret whose device no longer points at it.
10306
+ */
10307
+ /** Retry delays after a failed step; the list length bounds the attempts. */
10308
+ var REMOVAL_RETRY_DELAYS_MS = [
10309
+ 3e4,
10310
+ 12e4,
10311
+ 6e5
10312
+ ];
10313
+ async function attempt(steps) {
10314
+ const counts = {};
10315
+ const failed = [];
10316
+ const errors = {};
10317
+ for (const step of steps) try {
10318
+ counts[step.store] = await step.run();
10319
+ } catch (err) {
10320
+ failed.push(step);
10321
+ errors[step.store] = require_dist.errMsg(err);
10322
+ }
10323
+ return {
10324
+ counts,
10325
+ failed,
10326
+ errors
10327
+ };
10328
+ }
10329
+ function createRemovalCleanup(opts) {
10330
+ const label = opts.label ?? "device removal cleanup";
10331
+ const delays = opts.retryDelaysMs ?? REMOVAL_RETRY_DELAYS_MS;
10332
+ const setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
10333
+ const clearTimer = opts.clearTimer ?? ((handle) => clearTimeout(handle));
10334
+ const pending = /* @__PURE__ */ new Set();
10335
+ let disposed = false;
10336
+ const schedule = (deviceId, steps, retryIndex, first) => {
10337
+ if (disposed) return;
10338
+ const delay = delays[retryIndex];
10339
+ if (delay === void 0) {
10340
+ opts.logger.error(`${label} gave up — the data stays until cleaned by hand`, {
10341
+ tags: { deviceId },
10342
+ meta: {
10343
+ stores: steps.map((s) => s.store),
10344
+ attempts: retryIndex + (first ? 1 : 0)
10345
+ }
10346
+ });
10347
+ return;
10348
+ }
10349
+ const handle = setTimer(() => {
10350
+ pending.delete(handle);
10351
+ retry(deviceId, steps, retryIndex);
10352
+ }, delay);
10353
+ handle.unref?.();
10354
+ pending.add(handle);
10355
+ };
10356
+ const retry = async (deviceId, steps, retryIndex) => {
10357
+ const result = await attempt(steps);
10358
+ opts.logger.info(`${label} retried`, {
10359
+ tags: { deviceId },
10360
+ meta: {
10361
+ counts: result.counts,
10362
+ failed: result.failed.map((s) => s.store),
10363
+ retryIndex
10364
+ }
10365
+ });
10366
+ if (result.failed.length > 0) schedule(deviceId, result.failed, retryIndex + 1, false);
10367
+ };
10368
+ return {
10369
+ async run(deviceId, steps) {
10370
+ const result = await attempt(steps);
10371
+ const failedStores = result.failed.map((s) => s.store);
10372
+ const meta = {
10373
+ counts: result.counts,
10374
+ failed: failedStores,
10375
+ errors: result.errors
10376
+ };
10377
+ if (failedStores.length === 0) opts.logger.info(label, {
10378
+ tags: { deviceId },
10379
+ meta
10380
+ });
10381
+ else {
10382
+ opts.logger.warn(`${label} incomplete — failed steps are retried`, {
10383
+ tags: { deviceId },
10384
+ meta
10385
+ });
10386
+ schedule(deviceId, result.failed, 0, true);
10387
+ }
10388
+ return {
10389
+ deviceId,
10390
+ counts: result.counts,
10391
+ failed: failedStores
10392
+ };
10393
+ },
10394
+ dispose() {
10395
+ disposed = true;
10396
+ for (const handle of pending) clearTimer(handle);
10397
+ pending.clear();
10398
+ }
10399
+ };
10400
+ }
9768
10401
  function pairKey(addonId, stableId) {
9769
10402
  return `${addonId}${stableId}`;
9770
10403
  }
@@ -9887,6 +10520,9 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
9887
10520
  * `allocateDeviceId` never consults.
9888
10521
  */
9889
10522
  removalFence = createRemovalFence();
10523
+ /** Store-by-store clean-up of a removed device, with a bounded retry (D864). */
10524
+ removalCleanupImpl = null;
10525
+ removalHooksCleanupImpl = null;
9890
10526
  /** Declared secret config paths per device, as last registered (D783). */
9891
10527
  secretConfigPaths = createSecretConfigPathRegistry();
9892
10528
  /** The keychain side of driver device forms (D789), wired in onInitialize. */
@@ -10098,6 +10734,10 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
10098
10734
  const deviceRows = new DeviceRowStore(deviceRowBackendOf(settingsStoreApi), this.ctx.logger.child("rows"));
10099
10735
  await deviceRows.declare();
10100
10736
  this.deviceRows = deviceRows;
10737
+ const tombstones = new DeviceTombstoneStore({
10738
+ backend: deviceRowBackendOf(settingsStoreApi),
10739
+ logger: this.ctx.logger.child("tombstones")
10740
+ });
10101
10741
  this.staleWrapperActivations.checkOnce(() => readBindingsStore(this.bindingsDeps));
10102
10742
  try {
10103
10743
  await require_retired_settings_keys.purgeRetiredSettingsRows(deviceRows.retiredRowStore(), this.ctx.logger.child("RetiredRows"));
@@ -10133,6 +10773,13 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
10133
10773
  bindingsDeps: this.bindingsDeps,
10134
10774
  migrationGuard: createMigrationGuard(),
10135
10775
  removalFence: this.removalFence,
10776
+ removalCleanup: this.removalCleanup,
10777
+ tombstones,
10778
+ removalHooks: {
10779
+ api: this.ctx.api,
10780
+ logger: this.ctx.logger,
10781
+ cleanup: this.removalHooksCleanup
10782
+ },
10136
10783
  secretConfigPaths: this.secretConfigPaths,
10137
10784
  requireDeviceOps,
10138
10785
  stampIntegrationId: stampIntegrationId$1,
@@ -10166,6 +10813,18 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
10166
10813
  migrateDevice: (input) => migrateDevice(pctx, input),
10167
10814
  registerDevice: (input) => registerDevice(pctx, input),
10168
10815
  removeDevice: (input) => removeDevice(pctx, input),
10816
+ listDeletedSince: ({ sinceMs }) => tombstones.listSince(sinceMs),
10817
+ cleanupDeletedDevicesData: (input) => cleanupDeletedDevicesData({
10818
+ api: this.ctx.api,
10819
+ logger: this.ctx.logger,
10820
+ settings: settingsScopes(this.ctx.api?.settingsStore),
10821
+ reads: { readFleet: async () => {
10822
+ return {
10823
+ ids: (await deviceRows.listAll()).map((r) => r.meta.id),
10824
+ rowCount: await deviceRows.count()
10825
+ };
10826
+ } }
10827
+ }, input),
10169
10828
  persistConfig: (input) => persistConfig(pctx, input),
10170
10829
  loadConfig: (input) => loadConfig(pctx, input),
10171
10830
  loadMeta: (input) => loadMeta(pctx, input),
@@ -10389,7 +11048,22 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
10389
11048
  }
10390
11049
  ];
10391
11050
  }
11051
+ get removalCleanup() {
11052
+ if (this.removalCleanupImpl === null) this.removalCleanupImpl = createRemovalCleanup({ logger: this.ctx.logger });
11053
+ return this.removalCleanupImpl;
11054
+ }
11055
+ get removalHooksCleanup() {
11056
+ if (this.removalHooksCleanupImpl === null) this.removalHooksCleanupImpl = createRemovalCleanup({
11057
+ logger: this.ctx.logger,
11058
+ label: "device removal hooks"
11059
+ });
11060
+ return this.removalHooksCleanupImpl;
11061
+ }
10392
11062
  async onShutdown() {
11063
+ this.removalCleanupImpl?.dispose();
11064
+ this.removalCleanupImpl = null;
11065
+ this.removalHooksCleanupImpl?.dispose();
11066
+ this.removalHooksCleanupImpl = null;
10393
11067
  this.propagator?.stop();
10394
11068
  this.propagator = null;
10395
11069
  this.uninstallStatusOverlay?.();