@camstack/system 1.2.106 → 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.
@@ -1,5 +1,5 @@
1
1
  import { AddonContext, deviceManagerCapability, InferProvider } from '@camstack/types';
2
- import { DeviceManagerSettings, DeviceMetaStore } from './device-meta-store.js';
2
+ import { DeviceMetaStore } from './device-meta-store.js';
3
3
  import { ProviderContext } from './device-provider-context.js';
4
4
  type IDeviceManagerProvider = InferProvider<typeof deviceManagerCapability>;
5
5
  /**
@@ -11,7 +11,7 @@ type IDeviceManagerProvider = InferProvider<typeof deviceManagerCapability>;
11
11
  * because `ProviderContext.stampIntegrationId` delegates HERE — passing the
12
12
  * context would be a capture cycle.
13
13
  */
14
- export declare function stampIntegrationId(metaStore: DeviceMetaStore, settings: DeviceManagerSettings, ctx: AddonContext, deviceId: number, integrationId: string): Promise<void>;
14
+ export declare function stampIntegrationId(metaStore: DeviceMetaStore, ctx: AddonContext, deviceId: number, integrationId: string): Promise<void>;
15
15
  export declare function allocateDeviceId(pctx: ProviderContext, input: Parameters<IDeviceManagerProvider['allocateDeviceId']>[0]): ReturnType<IDeviceManagerProvider['allocateDeviceId']>;
16
16
  export declare function registerDevice(pctx: ProviderContext, input: Parameters<IDeviceManagerProvider['registerDevice']>[0]): ReturnType<IDeviceManagerProvider['registerDevice']>;
17
17
  export declare function removeDevice(pctx: ProviderContext, input: Parameters<IDeviceManagerProvider['removeDevice']>[0]): ReturnType<IDeviceManagerProvider['removeDevice']>;
@@ -106,14 +106,14 @@ export declare function getRoleDisplayDefaults(pctx: ProviderContext, _input: Pa
106
106
  * Replace the per-role display defaults whole-record (full replace). Override
107
107
  * units are normalized (`normalizeUnit`) at write so the render path always
108
108
  * looks up canonical spellings. Single-writer top-level key — no lock, no RMW,
109
- * no interaction with the `deviceMeta` write lock. Not per-device, so no event
109
+ * no interaction with the device write lock. Not per-device, so no event
110
110
  * is emitted; the UI invalidates its own query on mutate.
111
111
  */
112
112
  export declare function setRoleDisplayDefaults(pctx: ProviderContext, input: Parameters<IDeviceManagerProvider['setRoleDisplayDefaults']>[0]): ReturnType<IDeviceManagerProvider['setRoleDisplayDefaults']>;
113
113
  /**
114
114
  * Batched meta pre-seed. Applies every provided field to the
115
115
  * device's meta row in ONE read-modify-write under a single
116
- * `withMetaWriteLock` acquisition (one `deviceMeta` blob write),
116
+ * `withMetaWriteLock` acquisition (one row write),
117
117
  * then emits one `DeviceMetaChanged` event per field that was
118
118
  * supplied — preserving the exact semantics of the individual
119
119
  * setters (`setName` / `setLocation` / `setType` /
@@ -1,38 +1,51 @@
1
1
  import { AddonContext, IDeviceRegistry } from '@camstack/types';
2
2
  import { AddonStore, PersistedDeviceMeta } from './device-meta-types.js';
3
+ import { DeviceRow, DeviceRowStore } from './device-row-store.js';
3
4
  /** The settings surface the meta store uses (`ctx.settings`, narrowed non-null). */
4
5
  export type DeviceManagerSettings = NonNullable<AddonContext['settings']>;
5
6
  export declare class DeviceMetaStore {
6
7
  private readonly settings;
7
8
  /** Hub-process device registry (null when this node owns no registry). */
8
9
  readonly registry: IDeviceRegistry | null;
10
+ /** Row access for `device-manager:devices`. */
11
+ readonly rows: DeviceRowStore;
9
12
  /** Synchronous ownership cache, keyed by NUMERIC deviceId → owning addonId.
10
- * The persisted meta store is authoritative but reads are async; hub-side
13
+ * The persisted row store is authoritative but reads are async; hub-side
11
14
  * callers (e.g. `CapabilityRegistry.getNativeProvider` fallback) need
12
15
  * ownership without awaiting. Kept in sync with every register/remove and
13
16
  * warmed from persistence on boot. */
14
17
  readonly idToAddonId: Map<number, string>;
15
- /** Serialises every read-modify-write of the deviceMeta / deviceIndex blob
16
- * through one promise chain (see `withMetaWriteLock`). Per-instance state
17
- * identical to the former `onInitialize` closure variable. */
18
+ /** Serialises every read-modify-write of a device row through one promise
19
+ * chain (see `withMetaWriteLock`). Per-instance state. */
18
20
  private metaWriteChain;
19
21
  constructor(settings: DeviceManagerSettings,
20
22
  /** Hub-process device registry (null when this node owns no registry). */
21
- registry: IDeviceRegistry | null);
23
+ registry: IDeviceRegistry | null,
24
+ /** Row access for `device-manager:devices`. */
25
+ rows: DeviceRowStore);
26
+ /** The read currently in flight, or null. Never a settled value — see
27
+ * {@link readStore}. */
28
+ private inFlightRead;
29
+ /**
30
+ * The addon's own settings row set — `nextDeviceId`, `roleDisplayDefaults`,
31
+ * `locations`. NOT the fleet: no device has lived here since the flatten.
32
+ *
33
+ * **Concurrent callers join the read already in flight.** This is not a
34
+ * cache and nothing survives settlement: a caller that awaited the running
35
+ * promise could not have observed anything older than its result, so the
36
+ * only thing that changes is cost.
37
+ *
38
+ * A rejection is NOT latched: the slot is cleared before the promise
39
+ * settles either way, so a failed read costs the joiners that one failure
40
+ * and the next caller reaches the store again.
41
+ */
22
42
  readStore: () => Promise<AddonStore>;
23
- readIndex: () => Promise<Record<string, string[]>>;
24
- readMeta: () => Promise<Record<string, PersistedDeviceMeta>>;
25
- /** Hardware-identity metadata map. Lives in a sibling key on the
26
- * device-manager addon store so its writers (`setMetadata`) never
27
- * collide with the lifecycle writers on `deviceMeta`
28
- * (`registerDevice` / `setName` / `setLocation` / `setDisabled`).
29
- * Single-writer per row eliminates the "writer X clobbers writer
30
- * Y's field" bug class — `setMetadata` is the only producer. */
31
- readMetadataMap: () => Promise<Record<string, Record<string, unknown>>>;
32
43
  withMetaWriteLock: <T>(fn: () => Promise<T>) => Promise<T>;
44
+ /** The whole persisted row for one device, or `null`. */
45
+ getRow: (deviceId: number) => Promise<DeviceRow | null>;
33
46
  /**
34
47
  * Resolve a numeric deviceId to the owning `(addonId, stableId)` pair.
35
- * Scans persisted meta — live IDevice lookup (hub registry) is handled
48
+ * Reads the device's own row — live IDevice lookup (hub registry) is handled
36
49
  * separately per call site so callers can decide whether to route to
37
50
  * an in-process driver or to the cross-process `device-ops` bridge.
38
51
  * Returns null when no device with that id is known to the hub.
@@ -42,8 +55,10 @@ export declare class DeviceMetaStore {
42
55
  stableId: string;
43
56
  meta: PersistedDeviceMeta;
44
57
  } | null>;
58
+ /** The device's hardware-identity metadata blob, or `null`. */
59
+ readMetadata: (deviceId: number) => Promise<Record<string, unknown> | null>;
45
60
  /** Direct children of a device: the union of the live registry's children
46
- * and the persisted-meta scan (`parentDeviceId === parentId`), deduplicated
61
+ * and the persisted rows whose `parentDeviceId` is `parentId`, deduplicated
47
62
  * and excluding self. Shared by the `remove` cascade and the `resetToSource`
48
63
  * resync purge (#19). */
49
64
  directChildIds: (parentId: number) => Promise<readonly number[]>;
@@ -134,23 +134,17 @@ export interface PersistedDeviceMeta {
134
134
  * only — storage stays in source units. */
135
135
  display?: DeviceDisplayOverride;
136
136
  }
137
+ /**
138
+ * The device-manager's own `addon-settings` row set — everything that is NOT a
139
+ * device.
140
+ *
141
+ * The three fleet blobs that used to live here (`deviceMeta`, `deviceMetadata`,
142
+ * `deviceIndex`) are gone: devices are rows in `device-manager:devices`
143
+ * (`device-row-store.ts`). What is left is four small keys, together under a
144
+ * kilobyte on the reference hub — which matters because `writeAddonStore` for a
145
+ * hub builtin is `setAllAddon`, a DELETE + re-INSERT of every key the addon owns.
146
+ */
137
147
  export interface AddonStore {
138
- deviceIndex?: Record<string, string[]>;
139
- /** Device meta records keyed by the numeric device `id` (`String(id)`).
140
- * `addonId` and `stableId` are FIELDS on each `PersistedDeviceMeta` record
141
- * — the map key is the numeric id, not the former composite
142
- * `${addonId}:${stableId}` string. */
143
- deviceMeta?: Record<string, PersistedDeviceMeta>;
144
- /** Hardware-identity metadata blob keyed by the numeric device `id`
145
- * (`String(id)`), the same primary key as `deviceMeta`.
146
- * Stored in a separate key from `deviceMeta` so the lifecycle
147
- * writers (registerDevice / setName / setLocation / setDisabled)
148
- * never need to read or preserve the metadata field — single-writer
149
- * per row eliminates the "writer X clobbers writer Y's field" bug
150
- * class. The `metadata` field on `PersistedDeviceMeta` is a legacy
151
- * fallback consulted only at read time during the lazy migration —
152
- * new writes always go to `deviceMetadata`. */
153
- deviceMetadata?: Record<string, Record<string, unknown>>;
154
148
  /** Monotonic counter that feeds `PersistedDeviceMeta.id` for every
155
149
  * new device. Incremented exactly once per registerDevice; never
156
150
  * decremented. */
@@ -171,6 +165,14 @@ export interface AddonStore {
171
165
  * by `DeviceRole` string. Resolution merges these UNDER any per-device
172
166
  * `display` override. Written whole-record by `setRoleDisplayDefaults`
173
167
  * (full replace — no read-modify-write, single writer, so it never
174
- * interacts with the `deviceMeta` write lock). */
168
+ * interacts with the device write lock). */
175
169
  roleDisplayDefaults?: Record<string, RoleDisplayDefault>;
176
170
  }
171
+ /**
172
+ * Decode the raw `ctx.settings.readAddonStore()` record into {@link AddonStore}.
173
+ *
174
+ * A field of the wrong shape reads as ABSENT rather than being trusted through
175
+ * a cast: the store is JSON on disk and a hand-edit or a partial restore must
176
+ * cost the caller its default, never a downstream `TypeError`.
177
+ */
178
+ export declare function decodeAddonStore(raw: Record<string, unknown>): AddonStore;
@@ -0,0 +1,229 @@
1
+ import { ChildLayout, CollectionColumn, CollectionIndex, DeviceDisplayOverride, IScopedLogger, MutationFilter, QueryFilter, SettingsRecord, SettingsStoreClient } from '@camstack/types';
2
+ import { RetiredRowStore } from '../sqlite-storage/retired-settings-keys.js';
3
+ import { PersistedDeviceMeta } from './device-meta-types.js';
4
+ /**
5
+ * @durable class=registry owner=device-manager
6
+ * write="one row per device, written by `allocateDeviceId` (identity placeholder),
7
+ * `registerDevice` (full reconcile) and every meta setter (single-column patch);
8
+ * `setMetadata` patches the `metadata` column of the same row"
9
+ * retention="none — a row goes only when the operator removes the device
10
+ * (`removeDevice`), or when an integration is deleted and cascades. Bounded by the
11
+ * fleet an operator configures (974 rows on the reference hub)."
12
+ */
13
+ export declare const DEVICE_ROWS_COLLECTION = "device-manager:devices";
14
+ /**
15
+ * Fleet reads pass this explicitly.
16
+ *
17
+ * `settings-store.query` applies a DEFAULT row cap of 2 000 when the caller
18
+ * names no `limit` (`query-bounds.ts`), and truncation there is silent to the
19
+ * caller — it warns in the engine's log and returns a short list that looks
20
+ * complete. The reference hub is already at 974 devices; half the default cap
21
+ * is not a margin worth betting the fleet listing on. 20 000 is the engine's
22
+ * hard ceiling, so this asks for everything the engine will ever serve in one
23
+ * call and any future need to page is a loud failure rather than a quiet one.
24
+ */
25
+ export declare const DEVICE_ROWS_FLEET_LIMIT = 20000;
26
+ export declare const DEVICE_ROWS_COLUMNS: readonly CollectionColumn[];
27
+ export declare const DEVICE_ROWS_INDEXES: readonly CollectionIndex[];
28
+ /**
29
+ * The settings-store door this store needs, as a plain object surface.
30
+ *
31
+ * The production door is the tRPC client on `ctx.api.settingsStore`
32
+ * (`client.get.query(…)` / `client.set.mutate(…)`); {@link deviceRowBackendOf}
33
+ * adapts it. Declaring the port separately is what lets a test hand over an
34
+ * in-memory table without reconstructing a tRPC router, and it names exactly
35
+ * the seven methods this store is allowed to reach for.
36
+ */
37
+ export interface DeviceRowBackend {
38
+ declareCollection(input: {
39
+ collection: string;
40
+ columns: readonly CollectionColumn[];
41
+ indexes?: readonly CollectionIndex[];
42
+ }): Promise<void>;
43
+ get(input: {
44
+ collection: string;
45
+ key: string;
46
+ }): Promise<unknown>;
47
+ set(input: {
48
+ collection: string;
49
+ key: string;
50
+ value: Record<string, unknown>;
51
+ }): Promise<void>;
52
+ query(input: {
53
+ collection: string;
54
+ filter?: QueryFilter;
55
+ }): Promise<readonly SettingsRecord<Record<string, unknown>>[]>;
56
+ updateWhere(input: {
57
+ collection: string;
58
+ filter: MutationFilter;
59
+ data: Record<string, unknown>;
60
+ }): Promise<{
61
+ updated: number;
62
+ }>;
63
+ delete(input: {
64
+ collection: string;
65
+ key: string;
66
+ }): Promise<void>;
67
+ count(input: {
68
+ collection: string;
69
+ filter?: QueryFilter;
70
+ }): Promise<number>;
71
+ }
72
+ /** Adapt `ctx.api.settingsStore` (tRPC namespace) to {@link DeviceRowBackend}. */
73
+ export declare function deviceRowBackendOf(client: SettingsStoreClient): DeviceRowBackend;
74
+ /**
75
+ * The fields a single-column patch may set.
76
+ *
77
+ * Every key is OPTIONAL and only the keys PRESENT reach the `SET` list, so an
78
+ * omitted key leaves that column exactly as it was. `null` is a real value and
79
+ * CLEARS the column; it is how `setDisplay(null)` removes an override.
80
+ *
81
+ * A patch is an UPDATE, never an insert — see {@link DeviceRowStore.patch}.
82
+ */
83
+ export interface DeviceRowPatch {
84
+ readonly type?: string;
85
+ readonly name?: string;
86
+ readonly userNamed?: boolean;
87
+ readonly location?: string | null;
88
+ readonly disabled?: boolean;
89
+ readonly parentDeviceId?: number | null;
90
+ readonly registered?: boolean;
91
+ readonly features?: readonly string[];
92
+ readonly exportFingerprint?: string;
93
+ readonly integrationId?: string | null;
94
+ readonly linkDeviceId?: number | null;
95
+ readonly primaryChildEntityId?: string | null;
96
+ readonly childLayout?: ChildLayout | null;
97
+ readonly role?: string | null;
98
+ readonly display?: DeviceDisplayOverride | null;
99
+ readonly metadata?: Record<string, unknown> | null;
100
+ }
101
+ /**
102
+ * Every column `registerDevice` writes.
103
+ *
104
+ * It is spelled out rather than reusing {@link DeviceRowPatch} because this
105
+ * statement doubles as the INSERT for a device whose row does not exist yet
106
+ * (`allocateDeviceId` is not on every path into `registerDevice`), and SQLite
107
+ * checks `NOT NULL` on the insert attempt BEFORE the upsert clause gets to
108
+ * intercept the primary-key conflict. Every `NOT NULL` column is therefore
109
+ * required here, and the type is what keeps it that way.
110
+ *
111
+ * Columns NOT listed — `integrationId`, `linkDeviceId`, `primaryChildEntityId`,
112
+ * `childLayout`, `role`, `display`, `metadata` — are not in the statement at
113
+ * all, so re-registering preserves them. The blob shape had to re-state each
114
+ * one to avoid clobbering it, and every field added since has been one more
115
+ * spread somebody had to remember.
116
+ */
117
+ export interface DeviceRegistrationRow {
118
+ readonly deviceId: number;
119
+ readonly addonId: string;
120
+ readonly stableId: string;
121
+ readonly type: string;
122
+ readonly name: string;
123
+ readonly userNamed: boolean;
124
+ readonly location: string | null;
125
+ readonly disabled: boolean;
126
+ readonly parentDeviceId: number | null;
127
+ readonly registered: boolean;
128
+ readonly features: readonly string[];
129
+ readonly exportFingerprint: string;
130
+ }
131
+ /** A device row as this store hands it back: the meta record plus its metadata blob. */
132
+ export interface DeviceRow {
133
+ readonly meta: PersistedDeviceMeta;
134
+ /** `null` when the device has no hardware-identity metadata. */
135
+ readonly metadata: Record<string, unknown> | null;
136
+ /** Has `registerDevice` ever run for this device? (former `deviceIndex` membership) */
137
+ readonly registered: boolean;
138
+ }
139
+ /**
140
+ * Decode one stored row.
141
+ *
142
+ * Returns `null` for a row missing an identity field the rest of the system
143
+ * treats as an invariant (`deviceId` / `addonId` / `stableId` / `type` /
144
+ * `name`). A row like that cannot be projected into a `DeviceInfo` and taking
145
+ * the whole fleet read down for it would be worse — the caller logs the skip.
146
+ */
147
+ export declare function decodeDeviceRow(data: Record<string, unknown>): DeviceRow | null;
148
+ /** Full-row value for an upsert: identity + every field of the meta record. */
149
+ export declare function encodeDeviceRow(meta: PersistedDeviceMeta, extra?: {
150
+ readonly registered?: boolean;
151
+ readonly metadata?: Record<string, unknown> | null;
152
+ }): Record<string, unknown>;
153
+ /** Column map for a partial write — only the keys the caller actually named. */
154
+ export declare function encodeDeviceRowPatch(patch: DeviceRowPatch): Record<string, unknown>;
155
+ /**
156
+ * Row access for the device fleet. Every method is a single statement against
157
+ * `device-manager:devices` — there is no in-memory copy of the fleet here and
158
+ * no cache: a per-device question is a primary-key lookup, a fleet question is
159
+ * one indexed scan.
160
+ */
161
+ export declare class DeviceRowStore {
162
+ private readonly backend;
163
+ private readonly logger;
164
+ private declared;
165
+ constructor(backend: DeviceRowBackend, logger: IScopedLogger);
166
+ /**
167
+ * Lazy, idempotent `declareCollection`, memoised on the PROMISE so N
168
+ * concurrent first-callers issue one declaration rather than N. A rejection
169
+ * is not latched — the slot is cleared so the next caller retries instead of
170
+ * inheriting a dead collection forever.
171
+ */
172
+ declare(): Promise<void>;
173
+ /** One device, by numeric id. `null` when the fleet does not know it. */
174
+ get(deviceId: number): Promise<DeviceRow | null>;
175
+ /** Every device, ordered by numeric id. */
176
+ listAll(): Promise<readonly DeviceRow[]>;
177
+ /**
178
+ * The device an addon knows as `stableId`, or `null`.
179
+ *
180
+ * `(addonId, stableId)` is the addon-facing identity — unique by
181
+ * construction, since `allocateDeviceId` is the only thing that mints a row
182
+ * and it returns the existing id for a pair it already knows. A second row
183
+ * for the pair would be a corruption, so this takes the LOWEST id and says
184
+ * so rather than picking arbitrarily.
185
+ */
186
+ findByStableId(addonId: string, stableId: string): Promise<DeviceRow | null>;
187
+ /** Every device owned by one addon, ordered by numeric id. */
188
+ listByAddon(addonId: string): Promise<readonly DeviceRow[]>;
189
+ /** Every device an integration owns, ordered by numeric id. */
190
+ listByIntegration(integrationId: string): Promise<readonly DeviceRow[]>;
191
+ /** Direct children of one device, ordered by numeric id. */
192
+ listByParent(parentDeviceId: number): Promise<readonly DeviceRow[]>;
193
+ /** How many devices the fleet holds. Used to tell "empty store" from "gone device". */
194
+ count(): Promise<number>;
195
+ private list;
196
+ /** Insert or replace the whole identity row. */
197
+ upsert(meta: PersistedDeviceMeta, extra?: {
198
+ readonly registered?: boolean;
199
+ readonly metadata?: Record<string, unknown> | null;
200
+ }): Promise<void>;
201
+ /**
202
+ * Write the named columns of an EXISTING row and nothing else.
203
+ *
204
+ * An UPDATE, deliberately not an upsert: a partial upsert would try to INSERT
205
+ * a row carrying only the patched columns, and SQLite rejects that on the
206
+ * `NOT NULL` identity columns before the primary-key conflict can turn it
207
+ * into an update. Every caller resolves the row under the write lock and
208
+ * throws when it is gone, so matching zero rows means a device was removed
209
+ * between the resolve and the write — the patch is lost, and a lost write
210
+ * that says nothing reads as a write that happened.
211
+ */
212
+ patch(deviceId: number, patch: DeviceRowPatch): Promise<void>;
213
+ /**
214
+ * Insert-or-update the registration columns. See {@link DeviceRegistrationRow}
215
+ * for why this is a separate, fully-specified statement rather than a patch.
216
+ */
217
+ upsertRegistration(row: DeviceRegistrationRow): Promise<void>;
218
+ /** Drop the device's row. Idempotent. */
219
+ remove(deviceId: number): Promise<void>;
220
+ /**
221
+ * The retirement door for the three blobs this collection replaced.
222
+ *
223
+ * The purge is gated on THIS collection being non-empty, and the count has to
224
+ * be taken after `declare()` — which is why the owning addon runs it and the
225
+ * settings engine cannot: at the engine's own boot no addon has declared
226
+ * anything, so every successor would read as unreadable, forever.
227
+ */
228
+ retiredRowStore(): RetiredRowStore;
229
+ }
@@ -42,6 +42,27 @@ export interface RetiredKeyStore {
42
42
  /** Replace the row with `value`. */
43
43
  write(spec: RetiredKeySpec, value: Record<string, unknown>): Promise<void>;
44
44
  }
45
+ /**
46
+ * The store access the ROW purge needs. Separate from {@link RetiredKeyStore}
47
+ * because it has a different owner: a retired row is purged by the ADDON that
48
+ * stopped writing it, right after it has declared the successor collection —
49
+ * not by the settings engine at its own boot, which runs before any addon has
50
+ * declared anything and would find every successor unreadable, forever.
51
+ */
52
+ export interface RetiredRowStore {
53
+ /** Does the row exist at all? Throws on fault. */
54
+ hasRow(spec: RetiredRowSpec): Promise<boolean>;
55
+ /** Delete the row outright. Idempotent. */
56
+ deleteRow(spec: RetiredRowSpec): Promise<void>;
57
+ /** How many rows the evidence collection holds. Throws on fault. */
58
+ countRows(collection: SettingsCollection): Promise<number>;
59
+ }
60
+ /** What a row purge deleted. */
61
+ export interface RetiredRowPurgeResult {
62
+ readonly owner: string;
63
+ readonly collection: string;
64
+ readonly row: string;
65
+ }
45
66
  /**
46
67
  * Is THIS node the one whose settings store is the cluster's authority?
47
68
  *
@@ -83,6 +104,69 @@ export interface RetiredKeyLogger {
83
104
  * `addon-pipeline/src/detection-pipeline/engine-store-keys.ts`.
84
105
  */
85
106
  export declare const RETIRED_SETTINGS_KEYS: readonly RetiredKeySpec[];
107
+ /**
108
+ * Whole ROWS whose owner stopped writing them.
109
+ *
110
+ * A key purge cannot express this. It reads a row, drops keys, writes the rest
111
+ * back — so retiring everything in a row leaves an empty row behind, and
112
+ * retiring a row that IS a map of devices would mean listing 974 keys. What is
113
+ * dead here is the row itself.
114
+ *
115
+ * ### Addressing: these rows are NOT namespaced
116
+ *
117
+ * `namespace` on a {@link RetiredKeySpec} is the settings-store namespace, and
118
+ * it resolves to the table `<namespace>:<collection>`. That is right for an
119
+ * addon whose `ctx.settings` came from `addon-context-factory` (a forked
120
+ * runner), which writes `<addonId>:addon-settings` row `root`. A HUB BUILTIN
121
+ * does not: its `ctx.settings` comes from `ConfigManager.createSettingsView`,
122
+ * which is `getAllAddon` / `setAllAddon` — one row per key in the UNSCOPED
123
+ * `addon-settings` table, keyed `<addonId>.<key>`.
124
+ *
125
+ * The device-manager is a hub builtin, so its rows are
126
+ * `addon-settings` / `device-manager.deviceMeta` and never lived in
127
+ * `device-manager:addon-settings`. The previous key-spec pointed at the scoped
128
+ * table and was therefore a no-op on every hub that ever ran it — measured on
129
+ * the 2026-08-19 evidence copy: `device-manager:addon-settings` has zero rows,
130
+ * while `addon-settings` holds all six device-manager keys.
131
+ *
132
+ * ### Ordering: the guard is not optional
133
+ *
134
+ * These rows are the ONLY copy of the fleet until
135
+ * `scripts/migrate-device-manager-blobs.mjs` has written
136
+ * `device-manager:devices`. Purging them on a hub that has not been migrated
137
+ * destroys every device. {@link purgeRetiredSettingsRows} therefore refuses
138
+ * unless `evidence` — a collection that must be non-empty — actually is.
139
+ */
140
+ export interface RetiredRowSpec {
141
+ /** Settings-store namespace, or absent for an unscoped collection. */
142
+ readonly namespace?: string;
143
+ readonly collection: SettingsCollection;
144
+ /** Row id inside that collection. */
145
+ readonly row: string;
146
+ /** Addon that owned the row — reported, never used to address it. */
147
+ readonly owner: string;
148
+ /**
149
+ * A collection that must hold at least one row before this one may go.
150
+ * The successor of the retired data: if it is empty, the migration has not
151
+ * run here and deleting is data loss, not cleanup.
152
+ */
153
+ readonly evidence: {
154
+ readonly collection: SettingsCollection;
155
+ };
156
+ readonly reason: string;
157
+ }
158
+ /**
159
+ * The device-manager's three fleet blobs, replaced by one row per device in
160
+ * `device-manager:devices` (`device-row-store.ts`).
161
+ *
162
+ * 625 KB of the `addon-settings` table's 627 KB, re-read and re-parsed on every
163
+ * projection and rewritten in full on every rename. `deviceIndex` is listed
164
+ * here like the other two even though it was never migrated: `addonId` and
165
+ * `stableId` are columns of every row, so an addon's device list is a query,
166
+ * and the one bit that was not derivable — "has `registerDevice` run?" — is the
167
+ * `registered` column.
168
+ */
169
+ export declare const RETIRED_SETTINGS_ROWS: readonly RetiredRowSpec[];
86
170
  /**
87
171
  * Adapt the settings backend to {@link RetiredKeyStore}.
88
172
  *
@@ -107,3 +191,18 @@ export declare function planRetiredKeyPurge(blob: Readonly<Record<string, unknow
107
191
  * after the first boot that ran it.
108
192
  */
109
193
  export declare function purgeRetiredSettingsKeys(store: RetiredKeyStore, logger: RetiredKeyLogger, specs?: readonly RetiredKeySpec[], env?: NodeJS.ProcessEnv): Promise<readonly RetiredKeyPurgeResult[]>;
194
+ /**
195
+ * Delete every retired ROW whose successor is populated — on the hub.
196
+ *
197
+ * Off the authoritative node this is a no-op that touches nothing and says
198
+ * nothing, same gate as {@link purgeRetiredSettingsKeys}.
199
+ *
200
+ * **The evidence check is the whole safety of this function.** A retired row is
201
+ * the only copy of its data until the migration has written the successor
202
+ * collection, and the migration is an OFFLINE step an operator runs — it is not
203
+ * part of boot. So a hub that boots the new code before being migrated must
204
+ * find its blobs intact, not deleted: the purge SKIPS, loudly, and the next
205
+ * boot after the migration cleans up. An evidence read that FAILS is treated
206
+ * exactly like an empty one — a fault is never permission to delete.
207
+ */
208
+ export declare function purgeRetiredSettingsRows(store: RetiredRowStore, logger: RetiredKeyLogger, specs?: readonly RetiredRowSpec[], env?: NodeJS.ProcessEnv): Promise<readonly RetiredRowPurgeResult[]>;
@@ -1,4 +1,4 @@
1
- import { CollectionColumn, CollectionIndex, DataStoreEngineInfo, HistogramBucket, ISettingsBackend, SettingsCountInput, SettingsDeleteInput, SettingsGetInput, SettingsHistogramInput, SettingsInsertInput, SettingsIsEmptyInput, SettingsQueryInput, SettingsRecord, SettingsSetInput, SettingsUpdateInput, IScopedLogger } from '@camstack/types';
1
+ import { CollectionColumn, CollectionIndex, DataStoreEngineInfo, HistogramBucket, IScopedLogger, ISettingsBackend, SettingsCountInput, SettingsDeleteInput, SettingsGetInput, SettingsHistogramInput, SettingsInsertInput, SettingsIsEmptyInput, SettingsQueryInput, SettingsRecord, SettingsSetInput, SettingsUpdateInput } from '@camstack/types';
2
2
  import { default as Database } from 'better-sqlite3';
3
3
  import { MutationFilterInput } from './filter-compiler.js';
4
4
  /** Input for {@link SqliteSettingsBackend.deleteWhere}. */
@@ -123,7 +123,29 @@ export declare class SqliteSettingsBackend implements ISettingsBackend {
123
123
  setSystem(key: string, value: unknown): void;
124
124
  /** Get all system settings as flat key-value */
125
125
  getAllSystem(): Record<string, unknown>;
126
- /** Get all settings for an addon */
126
+ /**
127
+ * Get all settings for an addon.
128
+ *
129
+ * Selected by the SAME key range {@link setAllAddon} deletes and re-inserts
130
+ * — `prefixWhere("<addonId>.")` — and for the same reason `getAllScoped`
131
+ * uses it: `"<addonId>.<key>"` is the PRIMARY KEY, so the range is a
132
+ * `SEARCH … USING INDEX` while anything else is a full `SCAN`.
133
+ *
134
+ * It used to select on `json_extract(data, '$.addonId')`, which is a second
135
+ * authority for "this addon's rows" and disagreed with the writer in both
136
+ * directions: a row inside the JSON's idea of the addon but outside the key
137
+ * range was READ and never DELETED (a config key that could not be cleared),
138
+ * and `json_extract` was evaluated on EVERY row — so a four-key addon paid
139
+ * for the whole table and one unparseable neighbour aborted the statement,
140
+ * taking out every addon's config read at once.
141
+ *
142
+ * The cost was not theoretical. On the live hub `addon-settings` is 627 KB
143
+ * in 24 rows, 625 KB of it device-manager's three fleet blobs; a V8 profile
144
+ * of a boot (2026-08-19) had hub-main's JS thread 99.9% busy with **64% of
145
+ * it inside this method**, which is what put a ~45 s queue in front of every
146
+ * runner's first store read. Measured on that table: 0.947 ms → ~0.02 ms for
147
+ * a four-key addon, 2.90 ms → 2.13 ms for device-manager's own.
148
+ */
127
149
  getAllAddon(addonId: string): Record<string, unknown>;
128
150
  /** Bulk-set all settings for an addon */
129
151
  setAllAddon(addonId: string, config: Record<string, unknown>): void;