@camstack/system 1.2.107 → 1.2.109

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.
@@ -20,6 +20,11 @@ interface SettingsUpdateWhereInput {
20
20
  interface SettingsUpdateWhereResult {
21
21
  readonly updated: number;
22
22
  }
23
+ /** What {@link SqliteSettingsBackend.dropRetiredTable} destroyed. */
24
+ export interface DroppedTable {
25
+ /** Rows the table held at the moment it was dropped. */
26
+ readonly rows: number;
27
+ }
23
28
  /**
24
29
  * SQLite implementation of ISettingsBackend.
25
30
  *
@@ -213,6 +218,23 @@ export declare class SqliteSettingsBackend implements ISettingsBackend {
213
218
  /** Serialise per-column values for SQL binding: objects → JSON, booleans → 0/1. */
214
219
  private serializeColumnValue;
215
220
  private ensureTable;
221
+ /**
222
+ * Drop a table nothing declares any more, reporting what went with it.
223
+ *
224
+ * The DDL lives here because the engine owns the handle AND the two memos
225
+ * that outlive a table: `structuredTables` (schema fingerprint) and
226
+ * `declaredCollections` (the column set `compileFilter` resolves against).
227
+ * Dropping behind their backs would leave a ghost — a later declaration of
228
+ * the same name would be memoised as "already materialised" against a table
229
+ * that no longer exists.
230
+ *
231
+ * WHICH tables are retired, and the logging, are policy: they live in
232
+ * `retired-collections.ts`. This method knows only how to drop one.
233
+ *
234
+ * Answers `null` when the table is not there — the steady state after the
235
+ * first boot that ran the purge, and the case on a fresh install.
236
+ */
237
+ dropRetiredTable(table: string): DroppedTable | null;
216
238
  private tableInsert;
217
239
  private tableUpdate;
218
240
  private tableDelete;
@@ -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,174 +85,70 @@ 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
+ //#region src/builtins/sqlite-storage/retired-collections.ts
88
89
  /**
89
- * Is THIS node the one whose settings store is the cluster's authority?
90
+ * The dead tables.
90
91
  *
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.
92
+ * `devices` / `device_settings_kv` — the device half of the
93
+ * integration-registry ([D183](../../../../../docs/decisions/adr-0183.md)).
94
+ * The registry's INTEGRATION half is live (`integrations`,
95
+ * `integration_settings`); its device half never had a production caller and
96
+ * never had a row. The fleet lives in `device-manager:devices`, under numeric
97
+ * ids, while these tables key on `dev_NNNN` the two id-spaces could not be
98
+ * reconciled even if something had wanted to.
99
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"
100
+ var RETIRED_COLLECTIONS = [{
101
+ collection: "devices",
102
+ owner: "integration-registry",
103
+ reason: "the device half of the integration-registry is deleted; the fleet is `device-manager:devices` and always was (0 rows here against 974 live devices)"
134
104
  }, {
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"
105
+ collection: "device_settings_kv",
106
+ owner: "integration-registry",
107
+ reason: "per-device settings of a device table that never had a row; the live per-camera store is the device-manager row plus `addon-device-settings`"
142
108
  }];
143
109
  /**
144
- * Adapt the settings backend to {@link RetiredKeyStore}.
110
+ * Adapt the SQLite engine to {@link RetiredCollectionStore}.
145
111
  *
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.
112
+ * The DDL itself is the engine's it owns the handle and the memos a drop has
113
+ * to evict. This module owns only the list and the reporting.
149
114
  */
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
- };
115
+ function retiredCollectionStoreOf(backend) {
116
+ return { async drop(collection) {
117
+ return backend.dropRetiredTable(collection);
118
+ } };
168
119
  }
169
120
  /**
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.
121
+ * Drop every retired table that is still present. Returns one entry per table
122
+ * actually dropped empty when the schema is already clean, which is the
123
+ * steady state after the first boot that ran it.
172
124
  */
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 [];
125
+ async function purgeRetiredCollections(store, logger, specs = RETIRED_COLLECTIONS) {
210
126
  const results = [];
211
127
  for (const spec of specs) {
212
- let blob;
128
+ let dropped;
213
129
  try {
214
- blob = await store.read(spec);
130
+ dropped = await store.drop(spec.collection);
215
131
  } catch (err) {
216
- logger.warn("retired settings keys read failed, row left untouched", { meta: {
217
- addon: spec.namespace,
132
+ logger.warn("retired collectiondrop failed, table left in place", { meta: {
133
+ addon: spec.owner,
218
134
  collection: spec.collection,
219
- row: spec.row,
220
135
  error: err instanceof Error ? err.message : String(err)
221
136
  } });
222
137
  continue;
223
138
  }
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);
139
+ if (dropped === null) continue;
246
140
  const result = {
247
- addon: spec.namespace,
248
141
  collection: spec.collection,
249
- row: spec.row,
250
- keys: doomed,
251
- nested: doomedNested
142
+ owner: spec.owner,
143
+ rows: dropped.rows
252
144
  };
253
145
  results.push(result);
254
- logger.info(`purged ${doomed.length + doomedNested.length} retired settings keys`, { meta: {
146
+ const line = "dropped a retired collection";
147
+ if (dropped.rows > 0) logger.warn(`${line} — it still held rows`, { meta: {
148
+ ...result,
149
+ reason: spec.reason
150
+ } });
151
+ else logger.info(line, { meta: {
255
152
  ...result,
256
153
  reason: spec.reason
257
154
  } });
@@ -464,6 +361,9 @@ function isImposedBound(source) {
464
361
  }
465
362
  //#endregion
466
363
  //#region src/builtins/sqlite-storage/sqlite-settings-backend.ts
364
+ function isCountRow(raw) {
365
+ return raw !== null && typeof raw === "object" && typeof Reflect.get(raw, "n") === "number";
366
+ }
467
367
  function parseRowData(raw) {
468
368
  return require_dist.asJsonObject(require_dist.parseJsonUnknown(raw)) ?? {};
469
369
  }
@@ -1191,6 +1091,31 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
1191
1091
  }
1192
1092
  this.structuredTables.set(table, fingerprint);
1193
1093
  }
1094
+ /**
1095
+ * Drop a table nothing declares any more, reporting what went with it.
1096
+ *
1097
+ * The DDL lives here because the engine owns the handle AND the two memos
1098
+ * that outlive a table: `structuredTables` (schema fingerprint) and
1099
+ * `declaredCollections` (the column set `compileFilter` resolves against).
1100
+ * Dropping behind their backs would leave a ghost — a later declaration of
1101
+ * the same name would be memoised as "already materialised" against a table
1102
+ * that no longer exists.
1103
+ *
1104
+ * WHICH tables are retired, and the logging, are policy: they live in
1105
+ * `retired-collections.ts`. This method knows only how to drop one.
1106
+ *
1107
+ * Answers `null` when the table is not there — the steady state after the
1108
+ * first boot that ran the purge, and the case on a fresh install.
1109
+ */
1110
+ dropRetiredTable(table) {
1111
+ if (this.getDb().prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(table) === void 0) return null;
1112
+ const counted = this.getDb().prepare(`SELECT COUNT(*) AS n FROM "${table}"`).get();
1113
+ const rows = isCountRow(counted) ? counted.n : 0;
1114
+ this.getDb().exec(`DROP TABLE "${table}"`);
1115
+ this.structuredTables.delete(table);
1116
+ this.declaredCollections.delete(table);
1117
+ return { rows };
1118
+ }
1194
1119
  async tableInsert(table, row) {
1195
1120
  const keys = Object.keys(row);
1196
1121
  const cols = keys.map((k) => `"${k}"`).join(", ");
@@ -1823,10 +1748,11 @@ var SqliteSettingsAddon = class extends require_dist.BaseAddon {
1823
1748
  this.backend = new SqliteSettingsBackend(dbPath, { ...require_dist.RUNTIME_DEFAULTS }, this.ctx.logger.child("Query"));
1824
1749
  await this.backend.initialize();
1825
1750
  try {
1826
- await purgeRetiredSettingsKeys(retiredKeyStoreOf(this.backend), this.ctx.logger.child("RetiredKeys"));
1751
+ await require_retired_settings_keys.purgeRetiredSettingsKeys(require_retired_settings_keys.retiredKeyStoreOf(this.backend), this.ctx.logger.child("RetiredKeys"));
1827
1752
  } catch (err) {
1828
1753
  this.ctx.logger.warn("Retired-key purge failed", { meta: { error: require_dist.errMsg(err) } });
1829
1754
  }
1755
+ await purgeRetiredCollections(retiredCollectionStoreOf(this.backend), this.ctx.logger.child("RetiredCollections"));
1830
1756
  const vectorIndex = this.createVectorIndex(this.backend);
1831
1757
  await vectorIndex.loadIndexRegistry();
1832
1758
  this.vectorIndex = vectorIndex;
@@ -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,174 +79,70 @@ 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
+ //#region src/builtins/sqlite-storage/retired-collections.ts
82
83
  /**
83
- * Is THIS node the one whose settings store is the cluster's authority?
84
+ * The dead tables.
84
85
  *
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.
86
+ * `devices` / `device_settings_kv` — the device half of the
87
+ * integration-registry ([D183](../../../../../docs/decisions/adr-0183.md)).
88
+ * The registry's INTEGRATION half is live (`integrations`,
89
+ * `integration_settings`); its device half never had a production caller and
90
+ * never had a row. The fleet lives in `device-manager:devices`, under numeric
91
+ * ids, while these tables key on `dev_NNNN` the two id-spaces could not be
92
+ * reconciled even if something had wanted to.
93
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"
94
+ var RETIRED_COLLECTIONS = [{
95
+ collection: "devices",
96
+ owner: "integration-registry",
97
+ reason: "the device half of the integration-registry is deleted; the fleet is `device-manager:devices` and always was (0 rows here against 974 live devices)"
128
98
  }, {
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"
99
+ collection: "device_settings_kv",
100
+ owner: "integration-registry",
101
+ reason: "per-device settings of a device table that never had a row; the live per-camera store is the device-manager row plus `addon-device-settings`"
136
102
  }];
137
103
  /**
138
- * Adapt the settings backend to {@link RetiredKeyStore}.
104
+ * Adapt the SQLite engine to {@link RetiredCollectionStore}.
139
105
  *
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.
106
+ * The DDL itself is the engine's it owns the handle and the memos a drop has
107
+ * to evict. This module owns only the list and the reporting.
143
108
  */
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
- };
109
+ function retiredCollectionStoreOf(backend) {
110
+ return { async drop(collection) {
111
+ return backend.dropRetiredTable(collection);
112
+ } };
162
113
  }
163
114
  /**
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.
115
+ * Drop every retired table that is still present. Returns one entry per table
116
+ * actually dropped empty when the schema is already clean, which is the
117
+ * steady state after the first boot that ran it.
166
118
  */
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 [];
119
+ async function purgeRetiredCollections(store, logger, specs = RETIRED_COLLECTIONS) {
204
120
  const results = [];
205
121
  for (const spec of specs) {
206
- let blob;
122
+ let dropped;
207
123
  try {
208
- blob = await store.read(spec);
124
+ dropped = await store.drop(spec.collection);
209
125
  } catch (err) {
210
- logger.warn("retired settings keys read failed, row left untouched", { meta: {
211
- addon: spec.namespace,
126
+ logger.warn("retired collectiondrop failed, table left in place", { meta: {
127
+ addon: spec.owner,
212
128
  collection: spec.collection,
213
- row: spec.row,
214
129
  error: err instanceof Error ? err.message : String(err)
215
130
  } });
216
131
  continue;
217
132
  }
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);
133
+ if (dropped === null) continue;
240
134
  const result = {
241
- addon: spec.namespace,
242
135
  collection: spec.collection,
243
- row: spec.row,
244
- keys: doomed,
245
- nested: doomedNested
136
+ owner: spec.owner,
137
+ rows: dropped.rows
246
138
  };
247
139
  results.push(result);
248
- logger.info(`purged ${doomed.length + doomedNested.length} retired settings keys`, { meta: {
140
+ const line = "dropped a retired collection";
141
+ if (dropped.rows > 0) logger.warn(`${line} — it still held rows`, { meta: {
142
+ ...result,
143
+ reason: spec.reason
144
+ } });
145
+ else logger.info(line, { meta: {
249
146
  ...result,
250
147
  reason: spec.reason
251
148
  } });
@@ -458,6 +355,9 @@ function isImposedBound(source) {
458
355
  }
459
356
  //#endregion
460
357
  //#region src/builtins/sqlite-storage/sqlite-settings-backend.ts
358
+ function isCountRow(raw) {
359
+ return raw !== null && typeof raw === "object" && typeof Reflect.get(raw, "n") === "number";
360
+ }
461
361
  function parseRowData(raw) {
462
362
  return asJsonObject(parseJsonUnknown(raw)) ?? {};
463
363
  }
@@ -1185,6 +1085,31 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
1185
1085
  }
1186
1086
  this.structuredTables.set(table, fingerprint);
1187
1087
  }
1088
+ /**
1089
+ * Drop a table nothing declares any more, reporting what went with it.
1090
+ *
1091
+ * The DDL lives here because the engine owns the handle AND the two memos
1092
+ * that outlive a table: `structuredTables` (schema fingerprint) and
1093
+ * `declaredCollections` (the column set `compileFilter` resolves against).
1094
+ * Dropping behind their backs would leave a ghost — a later declaration of
1095
+ * the same name would be memoised as "already materialised" against a table
1096
+ * that no longer exists.
1097
+ *
1098
+ * WHICH tables are retired, and the logging, are policy: they live in
1099
+ * `retired-collections.ts`. This method knows only how to drop one.
1100
+ *
1101
+ * Answers `null` when the table is not there — the steady state after the
1102
+ * first boot that ran the purge, and the case on a fresh install.
1103
+ */
1104
+ dropRetiredTable(table) {
1105
+ if (this.getDb().prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(table) === void 0) return null;
1106
+ const counted = this.getDb().prepare(`SELECT COUNT(*) AS n FROM "${table}"`).get();
1107
+ const rows = isCountRow(counted) ? counted.n : 0;
1108
+ this.getDb().exec(`DROP TABLE "${table}"`);
1109
+ this.structuredTables.delete(table);
1110
+ this.declaredCollections.delete(table);
1111
+ return { rows };
1112
+ }
1188
1113
  async tableInsert(table, row) {
1189
1114
  const keys = Object.keys(row);
1190
1115
  const cols = keys.map((k) => `"${k}"`).join(", ");
@@ -1821,6 +1746,7 @@ var SqliteSettingsAddon = class extends BaseAddon {
1821
1746
  } catch (err) {
1822
1747
  this.ctx.logger.warn("Retired-key purge failed", { meta: { error: errMsg(err) } });
1823
1748
  }
1749
+ await purgeRetiredCollections(retiredCollectionStoreOf(this.backend), this.ctx.logger.child("RetiredCollections"));
1824
1750
  const vectorIndex = this.createVectorIndex(this.backend);
1825
1751
  await vectorIndex.loadIndexRegistry();
1826
1752
  this.vectorIndex = vectorIndex;