@camstack/system 1.2.107 → 1.2.108

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.
@@ -4,6 +4,7 @@ Object.defineProperties(exports, {
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
6
  const require_dist = require("../../dist-Ctfhlrtl.js");
7
+ const require_retired_settings_keys = require("../../retired-settings-keys-pNmoHPTg.js");
7
8
  let node_crypto = require("node:crypto");
8
9
  let node_fs = require("node:fs");
9
10
  let node_module = require("node:module");
@@ -84,181 +85,6 @@ var SQLITE_MMAP_SIZE_BYTES = 268435456;
84
85
  */
85
86
  var SQLITE_JOURNAL_SIZE_LIMIT_BYTES = 67108864;
86
87
  //#endregion
87
- //#region src/builtins/sqlite-storage/retired-settings-keys.ts
88
- /**
89
- * Is THIS node the one whose settings store is the cluster's authority?
90
- *
91
- * Mirrors `resolveNodeRole` (server/backend `node-role.ts`) — unset or `'hub'`
92
- * means hub, which is the launcher's own default. It is duplicated rather than
93
- * imported because `@camstack/system` is a dependency OF the backend, not the
94
- * other way round; the parse is four lines and the env var is the contract.
95
- *
96
- * Anything else answers `false`. Skipping where the purge should have run is a
97
- * missed cleanup the next hub boot fixes; running where it should not have is a
98
- * write to a store this node does not own. Only one of those is recoverable.
99
- */
100
- function settingsStoreIsAuthoritativeHere(env) {
101
- const raw = (env["CAMSTACK_ROLE"] ?? "").trim().toLowerCase();
102
- return raw === "" || raw === "hub";
103
- }
104
- /**
105
- * The dead keys, per row.
106
- *
107
- * `detection-pipeline` / `addon-settings` / `root` — every key in this row is
108
- * dead; the row itself is kept because the addon writes live keys into it
109
- * (`pipelineTemplates`, `videoPipelineSteps`).
110
- *
111
- * - `pipelineSteps` / `pipelineEngine`: the persisted GLOBAL step + engine
112
- * seed. Removed with `getGlobalSteps` becoming pure — dispatch builds steps
113
- * from the orchestrator's `agentSettings` plus per-camera overrides, and
114
- * never consulted the seed. See `addon-pipeline/src/detection-pipeline/provider.ts`.
115
- * - `engineRuntime` / `engineBackend` / `engineDevice` / `probedBestEngine`:
116
- * the per-node engine CASCADE, removed with the operator-facing engine
117
- * election. Each node now runs a device-array of inference pools balanced
118
- * per session, and the node-default pool derives live from the node's own
119
- * hardware (`resolveAutoEngine`). See
120
- * `addon-pipeline/src/detection-pipeline/engine-store-keys.ts`.
121
- */
122
- var RETIRED_SETTINGS_KEYS = [{
123
- namespace: "detection-pipeline",
124
- collection: "addon-settings",
125
- row: "root",
126
- keys: ["pipelineSteps", "pipelineEngine"],
127
- perNodeKeys: [
128
- "engineRuntime",
129
- "engineBackend",
130
- "engineDevice",
131
- "probedBestEngine"
132
- ],
133
- reason: "global step/engine seed and the per-node engine cascade — both removed; the live per-camera dispatch path never read either"
134
- }, {
135
- namespace: "device-manager",
136
- collection: "addon-settings",
137
- row: "deviceMeta",
138
- keys: [],
139
- perNodeKeys: [],
140
- mapValueKeys: ["deviceLinks"],
141
- reason: "cross-device field wiring, deleted 2026-08-08 — 0 links across 302 devices a month after the UI shipped. There is no reader left: the resolver, the overlay, the cycle guard, the fifth binding kind and the tab are gone"
142
- }];
143
- /**
144
- * Adapt the settings backend to {@link RetiredKeyStore}.
145
- *
146
- * `get` answering a non-object (missing row) is a MISS, not a fault: it maps to
147
- * `null` and the purge skips the row. A backend throw propagates, which is what
148
- * routes it to the "read failed, row left untouched" branch.
149
- */
150
- function retiredKeyStoreOf(backend) {
151
- return {
152
- async read(spec) {
153
- return require_dist.asJsonObject(await backend.get({
154
- namespace: spec.namespace,
155
- collection: spec.collection,
156
- key: spec.row
157
- }));
158
- },
159
- async write(spec, value) {
160
- await backend.set({
161
- namespace: spec.namespace,
162
- collection: spec.collection,
163
- key: spec.row,
164
- value
165
- });
166
- }
167
- };
168
- }
169
- /**
170
- * Nested `<mapKey>.<retiredKey>` paths that `spec` retires inside a map-shaped
171
- * row. Pure. Values that are not plain objects are skipped, never rewritten.
172
- */
173
- function planNestedRetiredKeyPurge(blob, spec) {
174
- const nestedKeys = spec.mapValueKeys ?? [];
175
- if (nestedKeys.length === 0) return [];
176
- const found = [];
177
- for (const [mapKey, value] of Object.entries(blob)) {
178
- const inner = require_dist.asJsonObject(value);
179
- if (inner === null) continue;
180
- for (const retired of nestedKeys) if (retired in inner) found.push(`${mapKey}.${retired}`);
181
- }
182
- return found;
183
- }
184
- /** Keys of `blob` that `spec` retires. Pure. */
185
- function planRetiredKeyPurge(blob, spec) {
186
- const exact = new Set(spec.keys);
187
- const found = [];
188
- for (const key of Object.keys(blob)) {
189
- if (exact.has(key)) {
190
- found.push(key);
191
- continue;
192
- }
193
- for (const perNode of spec.perNodeKeys) if (key === perNode || key.startsWith(`${perNode}@`)) {
194
- found.push(key);
195
- break;
196
- }
197
- }
198
- return found;
199
- }
200
- /**
201
- * Delete every retired key present in the declared rows — on the hub.
202
- *
203
- * Off the authoritative node this is a no-op that touches nothing and says
204
- * nothing (see the module header). Returns one entry per row that actually
205
- * changed — empty when the store is already clean, which is the steady state
206
- * after the first boot that ran it.
207
- */
208
- async function purgeRetiredSettingsKeys(store, logger, specs = RETIRED_SETTINGS_KEYS, env = process.env) {
209
- if (!settingsStoreIsAuthoritativeHere(env)) return [];
210
- const results = [];
211
- for (const spec of specs) {
212
- let blob;
213
- try {
214
- blob = await store.read(spec);
215
- } catch (err) {
216
- logger.warn("retired settings keys — read failed, row left untouched", { meta: {
217
- addon: spec.namespace,
218
- collection: spec.collection,
219
- row: spec.row,
220
- error: err instanceof Error ? err.message : String(err)
221
- } });
222
- continue;
223
- }
224
- if (blob === null) continue;
225
- const doomed = planRetiredKeyPurge(blob, spec);
226
- const doomedNested = planNestedRetiredKeyPurge(blob, spec);
227
- if (doomed.length === 0 && doomedNested.length === 0) continue;
228
- const doomedSet = new Set(doomed);
229
- const nestedByMapKey = /* @__PURE__ */ new Map();
230
- for (const path of doomedNested) {
231
- const cut = path.indexOf(".");
232
- const mapKey = path.slice(0, cut);
233
- const inner = path.slice(cut + 1);
234
- const bucket = nestedByMapKey.get(mapKey) ?? /* @__PURE__ */ new Set();
235
- bucket.add(inner);
236
- nestedByMapKey.set(mapKey, bucket);
237
- }
238
- const next = Object.fromEntries(Object.entries(blob).filter(([key]) => !doomedSet.has(key)).map(([key, value]) => {
239
- const retired = nestedByMapKey.get(key);
240
- if (retired === void 0) return [key, value];
241
- const inner = require_dist.asJsonObject(value);
242
- if (inner === null) return [key, value];
243
- return [key, Object.fromEntries(Object.entries(inner).filter(([k]) => !retired.has(k)))];
244
- }));
245
- await store.write(spec, next);
246
- const result = {
247
- addon: spec.namespace,
248
- collection: spec.collection,
249
- row: spec.row,
250
- keys: doomed,
251
- nested: doomedNested
252
- };
253
- results.push(result);
254
- logger.info(`purged ${doomed.length + doomedNested.length} retired settings keys`, { meta: {
255
- ...result,
256
- reason: spec.reason
257
- } });
258
- }
259
- return results;
260
- }
261
- //#endregion
262
88
  //#region src/builtins/sqlite-storage/filter-compiler.ts
263
89
  /** Thrown by `mutate` mode. Distinct type so a caller can map it to a 400. */
264
90
  var UnsafeFilterError = class extends Error {
@@ -1823,7 +1649,7 @@ var SqliteSettingsAddon = class extends require_dist.BaseAddon {
1823
1649
  this.backend = new SqliteSettingsBackend(dbPath, { ...require_dist.RUNTIME_DEFAULTS }, this.ctx.logger.child("Query"));
1824
1650
  await this.backend.initialize();
1825
1651
  try {
1826
- await purgeRetiredSettingsKeys(retiredKeyStoreOf(this.backend), this.ctx.logger.child("RetiredKeys"));
1652
+ await require_retired_settings_keys.purgeRetiredSettingsKeys(require_retired_settings_keys.retiredKeyStoreOf(this.backend), this.ctx.logger.child("RetiredKeys"));
1827
1653
  } catch (err) {
1828
1654
  this.ctx.logger.warn("Retired-key purge failed", { meta: { error: require_dist.errMsg(err) } });
1829
1655
  }
@@ -1,4 +1,5 @@
1
1
  import { A as dataStoreProviderCapability, Bt as parseJsonUnknown, _t as vectorStoreCapability, f as RUNTIME_DEFAULTS, gt as vectorDimFromBase64, j as decodeVectorBase64, kt as asJsonObject, vt as errMsg, w as bareAddonId, yt as BaseAddon } from "../../dist-_oC_QkQA.mjs";
2
+ import { r as retiredKeyStoreOf, t as purgeRetiredSettingsKeys } from "../../retired-settings-keys-Bsjf7HQ-.mjs";
2
3
  import { createRequire } from "node:module";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { statSync } from "node:fs";
@@ -78,181 +79,6 @@ var SQLITE_MMAP_SIZE_BYTES = 268435456;
78
79
  */
79
80
  var SQLITE_JOURNAL_SIZE_LIMIT_BYTES = 67108864;
80
81
  //#endregion
81
- //#region src/builtins/sqlite-storage/retired-settings-keys.ts
82
- /**
83
- * Is THIS node the one whose settings store is the cluster's authority?
84
- *
85
- * Mirrors `resolveNodeRole` (server/backend `node-role.ts`) — unset or `'hub'`
86
- * means hub, which is the launcher's own default. It is duplicated rather than
87
- * imported because `@camstack/system` is a dependency OF the backend, not the
88
- * other way round; the parse is four lines and the env var is the contract.
89
- *
90
- * Anything else answers `false`. Skipping where the purge should have run is a
91
- * missed cleanup the next hub boot fixes; running where it should not have is a
92
- * write to a store this node does not own. Only one of those is recoverable.
93
- */
94
- function settingsStoreIsAuthoritativeHere(env) {
95
- const raw = (env["CAMSTACK_ROLE"] ?? "").trim().toLowerCase();
96
- return raw === "" || raw === "hub";
97
- }
98
- /**
99
- * The dead keys, per row.
100
- *
101
- * `detection-pipeline` / `addon-settings` / `root` — every key in this row is
102
- * dead; the row itself is kept because the addon writes live keys into it
103
- * (`pipelineTemplates`, `videoPipelineSteps`).
104
- *
105
- * - `pipelineSteps` / `pipelineEngine`: the persisted GLOBAL step + engine
106
- * seed. Removed with `getGlobalSteps` becoming pure — dispatch builds steps
107
- * from the orchestrator's `agentSettings` plus per-camera overrides, and
108
- * never consulted the seed. See `addon-pipeline/src/detection-pipeline/provider.ts`.
109
- * - `engineRuntime` / `engineBackend` / `engineDevice` / `probedBestEngine`:
110
- * the per-node engine CASCADE, removed with the operator-facing engine
111
- * election. Each node now runs a device-array of inference pools balanced
112
- * per session, and the node-default pool derives live from the node's own
113
- * hardware (`resolveAutoEngine`). See
114
- * `addon-pipeline/src/detection-pipeline/engine-store-keys.ts`.
115
- */
116
- var RETIRED_SETTINGS_KEYS = [{
117
- namespace: "detection-pipeline",
118
- collection: "addon-settings",
119
- row: "root",
120
- keys: ["pipelineSteps", "pipelineEngine"],
121
- perNodeKeys: [
122
- "engineRuntime",
123
- "engineBackend",
124
- "engineDevice",
125
- "probedBestEngine"
126
- ],
127
- reason: "global step/engine seed and the per-node engine cascade — both removed; the live per-camera dispatch path never read either"
128
- }, {
129
- namespace: "device-manager",
130
- collection: "addon-settings",
131
- row: "deviceMeta",
132
- keys: [],
133
- perNodeKeys: [],
134
- mapValueKeys: ["deviceLinks"],
135
- reason: "cross-device field wiring, deleted 2026-08-08 — 0 links across 302 devices a month after the UI shipped. There is no reader left: the resolver, the overlay, the cycle guard, the fifth binding kind and the tab are gone"
136
- }];
137
- /**
138
- * Adapt the settings backend to {@link RetiredKeyStore}.
139
- *
140
- * `get` answering a non-object (missing row) is a MISS, not a fault: it maps to
141
- * `null` and the purge skips the row. A backend throw propagates, which is what
142
- * routes it to the "read failed, row left untouched" branch.
143
- */
144
- function retiredKeyStoreOf(backend) {
145
- return {
146
- async read(spec) {
147
- return asJsonObject(await backend.get({
148
- namespace: spec.namespace,
149
- collection: spec.collection,
150
- key: spec.row
151
- }));
152
- },
153
- async write(spec, value) {
154
- await backend.set({
155
- namespace: spec.namespace,
156
- collection: spec.collection,
157
- key: spec.row,
158
- value
159
- });
160
- }
161
- };
162
- }
163
- /**
164
- * Nested `<mapKey>.<retiredKey>` paths that `spec` retires inside a map-shaped
165
- * row. Pure. Values that are not plain objects are skipped, never rewritten.
166
- */
167
- function planNestedRetiredKeyPurge(blob, spec) {
168
- const nestedKeys = spec.mapValueKeys ?? [];
169
- if (nestedKeys.length === 0) return [];
170
- const found = [];
171
- for (const [mapKey, value] of Object.entries(blob)) {
172
- const inner = asJsonObject(value);
173
- if (inner === null) continue;
174
- for (const retired of nestedKeys) if (retired in inner) found.push(`${mapKey}.${retired}`);
175
- }
176
- return found;
177
- }
178
- /** Keys of `blob` that `spec` retires. Pure. */
179
- function planRetiredKeyPurge(blob, spec) {
180
- const exact = new Set(spec.keys);
181
- const found = [];
182
- for (const key of Object.keys(blob)) {
183
- if (exact.has(key)) {
184
- found.push(key);
185
- continue;
186
- }
187
- for (const perNode of spec.perNodeKeys) if (key === perNode || key.startsWith(`${perNode}@`)) {
188
- found.push(key);
189
- break;
190
- }
191
- }
192
- return found;
193
- }
194
- /**
195
- * Delete every retired key present in the declared rows — on the hub.
196
- *
197
- * Off the authoritative node this is a no-op that touches nothing and says
198
- * nothing (see the module header). Returns one entry per row that actually
199
- * changed — empty when the store is already clean, which is the steady state
200
- * after the first boot that ran it.
201
- */
202
- async function purgeRetiredSettingsKeys(store, logger, specs = RETIRED_SETTINGS_KEYS, env = process.env) {
203
- if (!settingsStoreIsAuthoritativeHere(env)) return [];
204
- const results = [];
205
- for (const spec of specs) {
206
- let blob;
207
- try {
208
- blob = await store.read(spec);
209
- } catch (err) {
210
- logger.warn("retired settings keys — read failed, row left untouched", { meta: {
211
- addon: spec.namespace,
212
- collection: spec.collection,
213
- row: spec.row,
214
- error: err instanceof Error ? err.message : String(err)
215
- } });
216
- continue;
217
- }
218
- if (blob === null) continue;
219
- const doomed = planRetiredKeyPurge(blob, spec);
220
- const doomedNested = planNestedRetiredKeyPurge(blob, spec);
221
- if (doomed.length === 0 && doomedNested.length === 0) continue;
222
- const doomedSet = new Set(doomed);
223
- const nestedByMapKey = /* @__PURE__ */ new Map();
224
- for (const path of doomedNested) {
225
- const cut = path.indexOf(".");
226
- const mapKey = path.slice(0, cut);
227
- const inner = path.slice(cut + 1);
228
- const bucket = nestedByMapKey.get(mapKey) ?? /* @__PURE__ */ new Set();
229
- bucket.add(inner);
230
- nestedByMapKey.set(mapKey, bucket);
231
- }
232
- const next = Object.fromEntries(Object.entries(blob).filter(([key]) => !doomedSet.has(key)).map(([key, value]) => {
233
- const retired = nestedByMapKey.get(key);
234
- if (retired === void 0) return [key, value];
235
- const inner = asJsonObject(value);
236
- if (inner === null) return [key, value];
237
- return [key, Object.fromEntries(Object.entries(inner).filter(([k]) => !retired.has(k)))];
238
- }));
239
- await store.write(spec, next);
240
- const result = {
241
- addon: spec.namespace,
242
- collection: spec.collection,
243
- row: spec.row,
244
- keys: doomed,
245
- nested: doomedNested
246
- };
247
- results.push(result);
248
- logger.info(`purged ${doomed.length + doomedNested.length} retired settings keys`, { meta: {
249
- ...result,
250
- reason: spec.reason
251
- } });
252
- }
253
- return results;
254
- }
255
- //#endregion
256
82
  //#region src/builtins/sqlite-storage/filter-compiler.ts
257
83
  /** Thrown by `mutate` mode. Distinct type so a caller can map it to a 400. */
258
84
  var UnsafeFilterError = class extends Error {
@@ -0,0 +1,278 @@
1
+ import { kt as asJsonObject } from "./dist-_oC_QkQA.mjs";
2
+ //#region src/builtins/sqlite-storage/retired-settings-keys.ts
3
+ /**
4
+ * Is THIS node the one whose settings store is the cluster's authority?
5
+ *
6
+ * Mirrors `resolveNodeRole` (server/backend `node-role.ts`) — unset or `'hub'`
7
+ * means hub, which is the launcher's own default. It is duplicated rather than
8
+ * imported because `@camstack/system` is a dependency OF the backend, not the
9
+ * other way round; the parse is four lines and the env var is the contract.
10
+ *
11
+ * Anything else answers `false`. Skipping where the purge should have run is a
12
+ * missed cleanup the next hub boot fixes; running where it should not have is a
13
+ * write to a store this node does not own. Only one of those is recoverable.
14
+ */
15
+ function settingsStoreIsAuthoritativeHere(env) {
16
+ const raw = (env["CAMSTACK_ROLE"] ?? "").trim().toLowerCase();
17
+ return raw === "" || raw === "hub";
18
+ }
19
+ /**
20
+ * The dead keys, per row.
21
+ *
22
+ * `detection-pipeline` / `addon-settings` / `root` — every key in this row is
23
+ * dead; the row itself is kept because the addon writes live keys into it
24
+ * (`pipelineTemplates`, `videoPipelineSteps`).
25
+ *
26
+ * - `pipelineSteps` / `pipelineEngine`: the persisted GLOBAL step + engine
27
+ * seed. Removed with `getGlobalSteps` becoming pure — dispatch builds steps
28
+ * from the orchestrator's `agentSettings` plus per-camera overrides, and
29
+ * never consulted the seed. See `addon-pipeline/src/detection-pipeline/provider.ts`.
30
+ * - `engineRuntime` / `engineBackend` / `engineDevice` / `probedBestEngine`:
31
+ * the per-node engine CASCADE, removed with the operator-facing engine
32
+ * election. Each node now runs a device-array of inference pools balanced
33
+ * per session, and the node-default pool derives live from the node's own
34
+ * hardware (`resolveAutoEngine`). See
35
+ * `addon-pipeline/src/detection-pipeline/engine-store-keys.ts`.
36
+ */
37
+ var RETIRED_SETTINGS_KEYS = [{
38
+ namespace: "detection-pipeline",
39
+ collection: "addon-settings",
40
+ row: "root",
41
+ keys: ["pipelineSteps", "pipelineEngine"],
42
+ perNodeKeys: [
43
+ "engineRuntime",
44
+ "engineBackend",
45
+ "engineDevice",
46
+ "probedBestEngine"
47
+ ],
48
+ reason: "global step/engine seed and the per-node engine cascade — both removed; the live per-camera dispatch path never read either"
49
+ }];
50
+ /**
51
+ * The device-manager's three fleet blobs, replaced by one row per device in
52
+ * `device-manager:devices` (`device-row-store.ts`).
53
+ *
54
+ * 625 KB of the `addon-settings` table's 627 KB, re-read and re-parsed on every
55
+ * projection and rewritten in full on every rename. `deviceIndex` is listed
56
+ * here like the other two even though it was never migrated: `addonId` and
57
+ * `stableId` are columns of every row, so an addon's device list is a query,
58
+ * and the one bit that was not derivable — "has `registerDevice` run?" — is the
59
+ * `registered` column.
60
+ */
61
+ var RETIRED_SETTINGS_ROWS = [
62
+ {
63
+ collection: "addon-settings",
64
+ row: "device-manager.deviceMeta",
65
+ owner: "device-manager",
66
+ evidence: { collection: "device-manager:devices" },
67
+ reason: "the fleet is one row per device in `device-manager:devices` since the flatten; this blob was 467 KB read and JSON.parsed on hub-main per projection"
68
+ },
69
+ {
70
+ collection: "addon-settings",
71
+ row: "device-manager.deviceMetadata",
72
+ owner: "device-manager",
73
+ evidence: { collection: "device-manager:devices" },
74
+ reason: "hardware-identity metadata is the `metadata` column of the device row; the separate blob existed only because a blob write is whole-value"
75
+ },
76
+ {
77
+ collection: "addon-settings",
78
+ row: "device-manager.deviceIndex",
79
+ owner: "device-manager",
80
+ evidence: { collection: "device-manager:devices" },
81
+ reason: "derivable: `addonId`/`stableId` are indexed columns, and index membership is the `registered` column"
82
+ }
83
+ ];
84
+ /**
85
+ * Adapt the settings backend to {@link RetiredKeyStore}.
86
+ *
87
+ * `get` answering a non-object (missing row) is a MISS, not a fault: it maps to
88
+ * `null` and the purge skips the row. A backend throw propagates, which is what
89
+ * routes it to the "read failed, row left untouched" branch.
90
+ */
91
+ function retiredKeyStoreOf(backend) {
92
+ return {
93
+ async read(spec) {
94
+ return asJsonObject(await backend.get({
95
+ namespace: spec.namespace,
96
+ collection: spec.collection,
97
+ key: spec.row
98
+ }));
99
+ },
100
+ async write(spec, value) {
101
+ await backend.set({
102
+ namespace: spec.namespace,
103
+ collection: spec.collection,
104
+ key: spec.row,
105
+ value
106
+ });
107
+ }
108
+ };
109
+ }
110
+ /**
111
+ * Nested `<mapKey>.<retiredKey>` paths that `spec` retires inside a map-shaped
112
+ * row. Pure. Values that are not plain objects are skipped, never rewritten.
113
+ */
114
+ function planNestedRetiredKeyPurge(blob, spec) {
115
+ const nestedKeys = spec.mapValueKeys ?? [];
116
+ if (nestedKeys.length === 0) return [];
117
+ const found = [];
118
+ for (const [mapKey, value] of Object.entries(blob)) {
119
+ const inner = asJsonObject(value);
120
+ if (inner === null) continue;
121
+ for (const retired of nestedKeys) if (retired in inner) found.push(`${mapKey}.${retired}`);
122
+ }
123
+ return found;
124
+ }
125
+ /** Keys of `blob` that `spec` retires. Pure. */
126
+ function planRetiredKeyPurge(blob, spec) {
127
+ const exact = new Set(spec.keys);
128
+ const found = [];
129
+ for (const key of Object.keys(blob)) {
130
+ if (exact.has(key)) {
131
+ found.push(key);
132
+ continue;
133
+ }
134
+ for (const perNode of spec.perNodeKeys) if (key === perNode || key.startsWith(`${perNode}@`)) {
135
+ found.push(key);
136
+ break;
137
+ }
138
+ }
139
+ return found;
140
+ }
141
+ /**
142
+ * Delete every retired key present in the declared rows — on the hub.
143
+ *
144
+ * Off the authoritative node this is a no-op that touches nothing and says
145
+ * nothing (see the module header). Returns one entry per row that actually
146
+ * changed — empty when the store is already clean, which is the steady state
147
+ * after the first boot that ran it.
148
+ */
149
+ async function purgeRetiredSettingsKeys(store, logger, specs = RETIRED_SETTINGS_KEYS, env = process.env) {
150
+ if (!settingsStoreIsAuthoritativeHere(env)) return [];
151
+ const results = [];
152
+ for (const spec of specs) {
153
+ let blob;
154
+ try {
155
+ blob = await store.read(spec);
156
+ } catch (err) {
157
+ logger.warn("retired settings keys — read failed, row left untouched", { meta: {
158
+ addon: spec.namespace,
159
+ collection: spec.collection,
160
+ row: spec.row,
161
+ error: err instanceof Error ? err.message : String(err)
162
+ } });
163
+ continue;
164
+ }
165
+ if (blob === null) continue;
166
+ const doomed = planRetiredKeyPurge(blob, spec);
167
+ const doomedNested = planNestedRetiredKeyPurge(blob, spec);
168
+ if (doomed.length === 0 && doomedNested.length === 0) continue;
169
+ const doomedSet = new Set(doomed);
170
+ const nestedByMapKey = /* @__PURE__ */ new Map();
171
+ for (const path of doomedNested) {
172
+ const cut = path.indexOf(".");
173
+ const mapKey = path.slice(0, cut);
174
+ const inner = path.slice(cut + 1);
175
+ const bucket = nestedByMapKey.get(mapKey) ?? /* @__PURE__ */ new Set();
176
+ bucket.add(inner);
177
+ nestedByMapKey.set(mapKey, bucket);
178
+ }
179
+ const next = Object.fromEntries(Object.entries(blob).filter(([key]) => !doomedSet.has(key)).map(([key, value]) => {
180
+ const retired = nestedByMapKey.get(key);
181
+ if (retired === void 0) return [key, value];
182
+ const inner = asJsonObject(value);
183
+ if (inner === null) return [key, value];
184
+ return [key, Object.fromEntries(Object.entries(inner).filter(([k]) => !retired.has(k)))];
185
+ }));
186
+ await store.write(spec, next);
187
+ const result = {
188
+ addon: spec.namespace,
189
+ collection: spec.collection,
190
+ row: spec.row,
191
+ keys: doomed,
192
+ nested: doomedNested
193
+ };
194
+ results.push(result);
195
+ logger.info(`purged ${doomed.length + doomedNested.length} retired settings keys`, { meta: {
196
+ ...result,
197
+ reason: spec.reason
198
+ } });
199
+ }
200
+ return results;
201
+ }
202
+ /**
203
+ * Delete every retired ROW whose successor is populated — on the hub.
204
+ *
205
+ * Off the authoritative node this is a no-op that touches nothing and says
206
+ * nothing, same gate as {@link purgeRetiredSettingsKeys}.
207
+ *
208
+ * **The evidence check is the whole safety of this function.** A retired row is
209
+ * the only copy of its data until the migration has written the successor
210
+ * collection, and the migration is an OFFLINE step an operator runs — it is not
211
+ * part of boot. So a hub that boots the new code before being migrated must
212
+ * find its blobs intact, not deleted: the purge SKIPS, loudly, and the next
213
+ * boot after the migration cleans up. An evidence read that FAILS is treated
214
+ * exactly like an empty one — a fault is never permission to delete.
215
+ */
216
+ async function purgeRetiredSettingsRows(store, logger, specs = RETIRED_SETTINGS_ROWS, env = process.env) {
217
+ if (!settingsStoreIsAuthoritativeHere(env)) return [];
218
+ const results = [];
219
+ /** Evidence counts, memoised per collection — the specs share successors. */
220
+ const evidence = /* @__PURE__ */ new Map();
221
+ for (const spec of specs) {
222
+ let present;
223
+ try {
224
+ present = await store.hasRow(spec);
225
+ } catch (err) {
226
+ logger.warn("retired settings row — read failed, row left untouched", { meta: {
227
+ addon: spec.owner,
228
+ collection: spec.collection,
229
+ row: spec.row,
230
+ error: errText(err)
231
+ } });
232
+ continue;
233
+ }
234
+ if (!present) continue;
235
+ const successor = spec.evidence.collection;
236
+ let count = evidence.get(successor);
237
+ if (count === void 0) {
238
+ try {
239
+ count = await store.countRows(successor);
240
+ } catch (err) {
241
+ logger.warn("retired settings row — successor unreadable, row left untouched", { meta: {
242
+ addon: spec.owner,
243
+ row: spec.row,
244
+ successor,
245
+ error: errText(err)
246
+ } });
247
+ count = 0;
248
+ }
249
+ evidence.set(successor, count);
250
+ }
251
+ if (count === 0) {
252
+ logger.warn("retired settings row kept — its successor is empty, migration has not run", { meta: {
253
+ addon: spec.owner,
254
+ collection: spec.collection,
255
+ row: spec.row,
256
+ successor
257
+ } });
258
+ continue;
259
+ }
260
+ await store.deleteRow(spec);
261
+ const result = {
262
+ owner: spec.owner,
263
+ collection: spec.collection,
264
+ row: spec.row
265
+ };
266
+ results.push(result);
267
+ logger.info("purged a retired settings row", { meta: {
268
+ ...result,
269
+ reason: spec.reason
270
+ } });
271
+ }
272
+ return results;
273
+ }
274
+ function errText(err) {
275
+ return err instanceof Error ? err.message : String(err);
276
+ }
277
+ //#endregion
278
+ export { purgeRetiredSettingsRows as n, retiredKeyStoreOf as r, purgeRetiredSettingsKeys as t };