@camstack/addon-waste-collections 0.1.37 → 0.1.38

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.
@@ -6001,7 +6001,7 @@ var ZodIssueCode = {
6001
6001
  var ZodFirstPartyTypeKind;
6002
6002
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
6003
6003
  //#endregion
6004
- //#region ../types/dist/sleep-AEnTN3b4.mjs
6004
+ //#region ../types/dist/sleep-CQP-REJp.mjs
6005
6005
  /**
6006
6006
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6007
6007
  * window to float samples (D455).
@@ -6365,6 +6365,59 @@ Object.fromEntries([
6365
6365
  function isValuelessField(field) {
6366
6366
  return field.type === "separator" || field.type === "info" || field.type === "qr-code" || field.type === "button" || field.type === "object-array" || field.type === "widget" || field.type === "addon-action-button" || field.type === "device-action-button" || field.type === "account-sign-in" || field.type === "secret-presence";
6367
6367
  }
6368
+ /**
6369
+ * Base collector: flatten a hydrated `ConfigUISchemaWithValues` into
6370
+ * `key → { value, default }`, recursing through `group` and `sub-tabs`
6371
+ * layout fields. Keeping `value` and `default` SEPARATE (rather than
6372
+ * pre-resolving) lets callers both read the effective value AND detect
6373
+ * overrides (`value` differs from `default`). Structural over `unknown`;
6374
+ * malformed input yields an empty map, never throws.
6375
+ */
6376
+ function collectHydratedFieldEntries(schema) {
6377
+ const out = /* @__PURE__ */ new Map();
6378
+ if (typeof schema !== "object" || schema === null) return out;
6379
+ const sections = schema.sections;
6380
+ if (!Array.isArray(sections)) return out;
6381
+ for (const section of sections) if (typeof section === "object" && section !== null) collectFieldEntriesInto(section.fields, out);
6382
+ return out;
6383
+ }
6384
+ /** Recursive field walker for {@link collectHydratedFieldEntries}. */
6385
+ function collectFieldEntriesInto(fields, into) {
6386
+ if (!Array.isArray(fields)) return;
6387
+ for (const field of fields) {
6388
+ if (typeof field !== "object" || field === null) continue;
6389
+ const type = field.type;
6390
+ if (type === "group") {
6391
+ collectFieldEntriesInto(field.fields, into);
6392
+ continue;
6393
+ }
6394
+ if (type === "sub-tabs") {
6395
+ const tabs = field.tabs;
6396
+ if (Array.isArray(tabs)) {
6397
+ for (const tab of tabs) if (typeof tab === "object" && tab !== null) collectFieldEntriesInto(tab.fields, into);
6398
+ }
6399
+ continue;
6400
+ }
6401
+ const key = field.key;
6402
+ if (typeof key !== "string" || key.length === 0) continue;
6403
+ if (!("value" in field)) continue;
6404
+ into.set(key, {
6405
+ value: field.value,
6406
+ default: field.default
6407
+ });
6408
+ }
6409
+ }
6410
+ /**
6411
+ * Flatten a hydrated settings payload into `key → EFFECTIVE value`
6412
+ * (`value ?? default`). The read-side inverse of `hydrateSchema` for the
6413
+ * common "just give me the resolved values" case. Built on
6414
+ * {@link collectHydratedFieldEntries}.
6415
+ */
6416
+ function collectHydratedFieldValues(schema) {
6417
+ const out = /* @__PURE__ */ new Map();
6418
+ for (const [key, entry] of collectHydratedFieldEntries(schema)) out.set(key, entry.value ?? entry.default);
6419
+ return out;
6420
+ }
6368
6421
  function hydrateSchema(schema, values, options = {}) {
6369
6422
  return {
6370
6423
  ...schema.tabs ? { tabs: [...schema.tabs] } : {},
@@ -7745,6 +7798,21 @@ var SCOPED_SETTINGS_SCOPES = [
7745
7798
  "device-defaults",
7746
7799
  "node"
7747
7800
  ];
7801
+ /** The one refusal of a key a scope did not declare. */
7802
+ function assertDeclaredKeys(keys, declared) {
7803
+ const undeclared = keys.filter((key) => !declared.has(key));
7804
+ if (undeclared.length > 0) throw new Error(`scoped settings: [${undeclared.join(", ")}] not declared for this scope (declared: [${[...declared].join(", ")}])`);
7805
+ }
7806
+ /** Build the handle over an addon's scoped read, write and unset. */
7807
+ function createScopedSettings(deps) {
7808
+ return {
7809
+ async get() {
7810
+ return deps.schema.parse(await deps.read());
7811
+ },
7812
+ patch: (patch) => deps.write(Object.fromEntries(Object.entries(patch))),
7813
+ unset: (keys) => deps.unset(keys)
7814
+ };
7815
+ }
7748
7816
  /** A write to a secret path could not be stored in the keychain, so it was refused. */
7749
7817
  var KeychainWriteRefusedError = class extends Error {
7750
7818
  fieldPath;
@@ -8320,7 +8388,16 @@ function createHandle(raw, opts) {
8320
8388
  return blob;
8321
8389
  }
8322
8390
  },
8323
- writeAddonStore: writeAddon
8391
+ writeAddonStore: writeAddon,
8392
+ removeClearedKeys: (keys) => serialize("addon", async () => {
8393
+ const stored = await readStoredAddonBlob(raw);
8394
+ index.seen("addon", stored);
8395
+ const removable = keys.filter((key) => collectSecretMarkerRefs(stored[key]).length === 0);
8396
+ if (removable.length === 0) return;
8397
+ const patch = Object.fromEntries(removable.map((key) => [key, void 0]));
8398
+ await raw.writeAddonStore(patch);
8399
+ index.wrote("addon", patch);
8400
+ })
8324
8401
  };
8325
8402
  }
8326
8403
  /** The context the addon sees, plus the private raw view and write outcome. */
@@ -8865,6 +8942,151 @@ var BaseAddon = class {
8865
8942
  }
8866
8943
  }
8867
8944
  /**
8945
+ * Hook: values layered over the stored ones when the FORM of one scope is served
8946
+ * ({@link getScopedSettings}) — a preview that derives dependent options without writing.
8947
+ * Never applied to {@link ScopedSettings.get} or to a write. `null` ⇒ no overlay.
8948
+ */
8949
+ async scopedSettingsOverlay(_scope, _cap, _nodeId) {
8950
+ return null;
8951
+ }
8952
+ /**
8953
+ * Hook: a write of one scope changed stored values (not called when the store reports no change,
8954
+ * e.g. the write-back of a stored secret). `keys` are the bare field keys of the write, node keys included.
8955
+ */
8956
+ async onScopedSettingsChanged(_scope, _cap, _keys) {}
8957
+ /** Typed handle over the CLUSTER settings `cap` declares (`clusterSettingsSchema`). */
8958
+ clusterSettings(cap, schema) {
8959
+ return this.scopedHandle("cluster", cap, schema);
8960
+ }
8961
+ /** Typed handle over the fleet defaults of the per-device settings `cap` declares. */
8962
+ deviceDefaults(cap, schema) {
8963
+ return this.scopedHandle("device-defaults", cap, schema);
8964
+ }
8965
+ /**
8966
+ * Typed handle over the settings `cap` owns PER NODE. `nodeId` omitted ⇒ the local node. A node
8967
+ * with no row yields the schema defaults — never a bare-key fallback.
8968
+ */
8969
+ nodeSettings(cap, schema, nodeId) {
8970
+ return this.scopedHandle("node", cap, schema, nodeId);
8971
+ }
8972
+ /**
8973
+ * {@link state} for a value that is kept per node: stored under `<key>@<nodeId>`, and the
8974
+ * settings store carries `nodeId` in the row data (D986 §1), so a forgotten node's rows go in one
8975
+ * central delete. `nodeId` omitted ⇒ the local node.
8976
+ */
8977
+ nodeState(key, schema, fallback, nodeId) {
8978
+ this._declaredStateKeys.add(key);
8979
+ return createDurableState({
8980
+ key: nodeScopedKey(key, nodeId ?? this._ctx?.kernel?.localNodeId ?? ""),
8981
+ schema,
8982
+ fallback,
8983
+ read: async () => this.resolveStoredSecrets(await this.readAddonStoreWithRetry()),
8984
+ write: async (patch) => {
8985
+ await this._ctx?.settings?.writeAddonStore(patch);
8986
+ },
8987
+ onParseError: (k, e) => this._ctx?.logger?.warn?.(`durable-state: stored "${k}" failed validation — using fallback`, { meta: {
8988
+ addonId: this._ctx?.id,
8989
+ key: k,
8990
+ error: String(e)
8991
+ } })
8992
+ });
8993
+ }
8994
+ /** The loaded schema `cap` declares for `scope`, or `null`. */
8995
+ async declaredScopedSchema(scope, cap) {
8996
+ await this.loadScopedSchemas(cap);
8997
+ return this.loadedScopedSchemas(cap).find((entry) => entry.scope === scope)?.schema ?? null;
8998
+ }
8999
+ async requireScopedSchema(scope, cap) {
9000
+ const schema = await this.declaredScopedSchema(scope, cap);
9001
+ if (schema === null) throw new Error(`${this.constructor.name}: cap "${cap}" declares no ${scope} settings`);
9002
+ return schema;
9003
+ }
9004
+ scopedKeys(schema) {
9005
+ return new Set(schema.sections.flatMap((section) => collectValueFieldKeys(section.fields)));
9006
+ }
9007
+ /** `store` narrowed to the keys `schema` declares. */
9008
+ narrowToScope(store, schema) {
9009
+ const keys = this.scopedKeys(schema);
9010
+ return Object.fromEntries(Object.entries(store).filter(([key]) => keys.has(key)));
9011
+ }
9012
+ scopedHandle(scope, cap, schema, nodeId) {
9013
+ return createScopedSettings({
9014
+ schema,
9015
+ read: async () => {
9016
+ const declared = await this.requireScopedSchema(scope, cap);
9017
+ const stored = this.narrowToScope(await this.resolveGlobalStore(nodeId, cap), declared);
9018
+ const effective = collectHydratedFieldValues(hydrateSchema(declared, stored));
9019
+ return {
9020
+ ...Object.fromEntries([...effective].filter(([, value]) => value !== void 0)),
9021
+ ...stored
9022
+ };
9023
+ },
9024
+ write: (patch) => this.writeScoped(scope, cap, patch, nodeId),
9025
+ unset: (keys) => this.unsetScoped(scope, cap, keys, nodeId)
9026
+ });
9027
+ }
9028
+ /** The one write path of the scoped API: declared keys only, then the global patch. */
9029
+ async writeScoped(scope, cap, patch, nodeId) {
9030
+ assertDeclaredKeys(Object.keys(patch), this.scopedKeys(await this.requireScopedSchema(scope, cap)));
9031
+ if (await this.applyGlobalPatch(patch, scope === "node" ? nodeId : void 0, cap)) await this.onScopedSettingsChanged(scope, cap, Object.keys(patch));
9032
+ }
9033
+ /**
9034
+ * Remove declared keys of one scope. A secret goes through {@link clearSecrets} — its keychain
9035
+ * value is released exactly as there — and the emptied row is then removed; every other key is
9036
+ * removed by an `undefined` patch (which a secret transform would read as "keep").
9037
+ */
9038
+ async unsetScoped(scope, cap, keys, nodeId) {
9039
+ assertDeclaredKeys(keys, this.scopedKeys(await this.requireScopedSchema(scope, cap)));
9040
+ const target = scope === "node" ? nodeId : void 0;
9041
+ const tree = this.addonSecretTree();
9042
+ const secretKeys = keys.filter((key) => tree.has(key));
9043
+ const plainKeys = keys.filter((key) => !tree.has(key));
9044
+ let changed = false;
9045
+ if (plainKeys.length > 0) {
9046
+ const patch = Object.fromEntries(plainKeys.map((key) => [key, void 0]));
9047
+ changed = await this.applyGlobalPatch(patch, target, cap) || changed;
9048
+ }
9049
+ if (secretKeys.length > 0) {
9050
+ await this.clearSecretKeys(secretKeys, target, cap);
9051
+ const node = normalizeNodeId(target ?? this._ctx?.kernel?.localNodeId);
9052
+ const rows = secretKeys.map((key) => scope === "node" ? nodeScopedKey(key, node) : key);
9053
+ if (this._secretSettings !== null) await this._secretSettings.removeClearedKeys(rows);
9054
+ else await this._ctx?.settings?.writeAddonStore(Object.fromEntries(rows.map((row) => [row, void 0])));
9055
+ changed = true;
9056
+ }
9057
+ if (changed) await this.onScopedSettingsChanged(scope, cap, [...keys]);
9058
+ }
9059
+ /**
9060
+ * The hydrated form of one scope of one capability's settings (any
9061
+ * {@link scopedSettingsOverlay} applied), or `null` when the capability declares nothing there.
9062
+ */
9063
+ async getScopedSettings(scope, cap, nodeId) {
9064
+ const schema = await this.declaredScopedSchema(scope, cap);
9065
+ if (schema === null) return null;
9066
+ const stored = this.narrowToScope(await this.readRawProjectedStore(nodeId, cap), schema);
9067
+ const overlay = await this.scopedSettingsOverlay(scope, cap, nodeId);
9068
+ return hydrateSchema(schema, overlay ? await this.overlayKeepingMarkers(stored, overlay) : stored, { inlineSecretsAsViews: true });
9069
+ }
9070
+ /** The OPERATOR's write of declared keys of one scope (D787 secret semantics). */
9071
+ async updateScopedSettings(scope, cap, patch, nodeId) {
9072
+ this._operatorWriteDepth++;
9073
+ try {
9074
+ await this.writeScoped(scope, cap, patch, nodeId);
9075
+ } finally {
9076
+ this._operatorWriteDepth--;
9077
+ }
9078
+ }
9079
+ /**
9080
+ * The effective values (`stored ?? default`) of one scope, a secret as its `set` view — never
9081
+ * its value. `null` when the capability declares nothing for the scope.
9082
+ */
9083
+ async readScopedValues(scope, cap, nodeId) {
9084
+ const schema = await this.declaredScopedSchema(scope, cap);
9085
+ if (schema === null) return null;
9086
+ const form = hydrateSchema(schema, this.narrowToScope(await this.readRawProjectedStore(nodeId, cap), schema), { inlineSecretsAsViews: true });
9087
+ return Object.fromEntries([...collectHydratedFieldValues(form)].filter(([, value]) => value !== void 0));
9088
+ }
9089
+ /**
8868
9090
  * What the create wizard and the integration edit page render for one instance: the addon's
8869
9091
  * per-instance schema when it declares one, else its global schema (what they always showed).
8870
9092
  */
@@ -9016,6 +9238,9 @@ var BaseAddon = class {
9016
9238
  * a keychain (an older kernel) it writes `''`, as such code always did.
9017
9239
  */
9018
9240
  async clearSecrets(keys, nodeId) {
9241
+ await this.clearSecretKeys(keys, nodeId);
9242
+ }
9243
+ async clearSecretKeys(keys, nodeId, cap) {
9019
9244
  const tree = this.addonSecretTree();
9020
9245
  const keychain = this._ctx?.secrets !== void 0;
9021
9246
  const stored = keychain ? await this.readAddonStoreWithRetry() : {};
@@ -9028,25 +9253,27 @@ var BaseAddon = class {
9028
9253
  const keychainStored = tree.has(key) || collectSecretMarkerRefs(stored[key]).length > 0;
9029
9254
  patch[key] = keychain && keychainStored ? SECRET_CLEAR : "";
9030
9255
  }
9031
- await this.applyGlobalPatch(patch, nodeId);
9256
+ await this.applyGlobalPatch(patch, nodeId, cap);
9032
9257
  }
9033
- async applyGlobalPatch(barePatch, nodeId) {
9034
- await this.loadScopedSchemas();
9035
- const keys = this.perNodeKeys();
9258
+ /** Resolves `true` when the write changed stored values. */
9259
+ async applyGlobalPatch(barePatch, nodeId, cap) {
9260
+ await this.loadScopedSchemas(cap);
9261
+ const keys = this.perNodeKeys(cap);
9036
9262
  const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
9037
9263
  const target = normalizeNodeId(nodeId ?? this._ctx?.kernel?.localNodeId);
9038
9264
  const scoped = keys.size > 0 ? scopePatch(barePatch, keys, target) : barePatch;
9039
9265
  const handle = this._secretSettings;
9040
9266
  if (handle !== null) {
9041
9267
  const { changed } = await handle.writeAddonStore(scoped);
9042
- if (!changed) return;
9268
+ if (!changed) return false;
9043
9269
  } else await this._ctx?.settings?.writeAddonStore(scoped);
9044
- if (target !== localNode) return;
9270
+ if (target !== localNode) return true;
9045
9271
  await this.resolveConfig();
9046
9272
  await this.onConfigChanged();
9047
9273
  this.emitLifecycle(EventCategory.AddonUpdated, { level: "global" });
9048
9274
  const legacy = this.globalSettingsSchema();
9049
9275
  this.maybeAutoRestart(Object.keys(barePatch), [...legacy === null ? [] : [legacy], ...this.loadedScopedSchemas().map((entry) => entry.schema)]);
9276
+ return true;
9050
9277
  }
9051
9278
  /**
9052
9279
  * The set of field keys the global settings schema declares `perNode: true`
@@ -44881,6 +45108,8 @@ var SettingsSchemaWithValuesSchema = object({
44881
45108
  });
44882
45109
  /** Patch object — keys are field names, values are the new field values. */
44883
45110
  var SettingsPatchSchema = record(string(), unknown());
45111
+ /** The scope of a scoped-settings call (D986): where the capability declared the values. */
45112
+ var ScopedSettingsScopeSchema = _enum(SCOPED_SETTINGS_SCOPES);
44884
45113
  /** Standard success response for update operations. */
44885
45114
  var SettingsUpdateResultSchema = object({ success: literal(true) });
44886
45115
  method(object({
@@ -44896,6 +45125,25 @@ method(object({
44896
45125
  kind: "mutation",
44897
45126
  auth: "admin"
44898
45127
  }), method(object({
45128
+ addonId: string(),
45129
+ cap: string(),
45130
+ scope: ScopedSettingsScopeSchema,
45131
+ nodeId: string().optional()
45132
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
45133
+ addonId: string(),
45134
+ cap: string(),
45135
+ scope: ScopedSettingsScopeSchema,
45136
+ nodeId: string().optional(),
45137
+ patch: SettingsPatchSchema
45138
+ }), SettingsUpdateResultSchema, {
45139
+ kind: "mutation",
45140
+ auth: "admin"
45141
+ }), method(object({
45142
+ addonId: string(),
45143
+ cap: string(),
45144
+ scope: ScopedSettingsScopeSchema,
45145
+ nodeId: string().optional()
45146
+ }), record(string(), unknown()).nullable()), method(object({
44899
45147
  addonId: string(),
44900
45148
  nodeId: string().optional()
44901
45149
  }), SettingsSchemaWithValuesSchema.nullable()), method(object({
@@ -76784,6 +77032,20 @@ Object.freeze({
76784
77032
  access: "view",
76785
77033
  role: "admin"
76786
77034
  },
77035
+ "addonSettings.getScopedSettings": {
77036
+ capName: "addon-settings",
77037
+ capScope: "system",
77038
+ addonId: null,
77039
+ access: "view",
77040
+ role: "admin"
77041
+ },
77042
+ "addonSettings.readScopedValues": {
77043
+ capName: "addon-settings",
77044
+ capScope: "system",
77045
+ addonId: null,
77046
+ access: "view",
77047
+ role: "admin"
77048
+ },
76787
77049
  "addonSettings.unsetStoredSettingsKeys": {
76788
77050
  capName: "addon-settings",
76789
77051
  capScope: "system",
@@ -76805,6 +77067,13 @@ Object.freeze({
76805
77067
  access: "create",
76806
77068
  role: "admin"
76807
77069
  },
77070
+ "addonSettings.updateScopedSettings": {
77071
+ capName: "addon-settings",
77072
+ capScope: "system",
77073
+ addonId: null,
77074
+ access: "create",
77075
+ role: "admin"
77076
+ },
76808
77077
  "addonWidgets.listWidgets": {
76809
77078
  capName: "addon-widgets",
76810
77079
  capScope: "system",
@@ -91423,42 +91692,6 @@ async function copySourcesToRecords(ports) {
91423
91692
  }
91424
91693
  };
91425
91694
  }
91426
- /** The `waste-collections` catalogue (D987): offered to the engine when the addon initialises. */
91427
- var ADDON_MIGRATIONS = [{
91428
- meta: {
91429
- id: "waste-collections/2026-11-06-sources-from-settings-and-argsjson",
91430
- owner: "waste-collections",
91431
- level: "addon-state",
91432
- seq: 1,
91433
- dependsOn: [],
91434
- store: "settings",
91435
- scope: "cluster",
91436
- onFailure: "tolerate",
91437
- bodyKind: "procedure",
91438
- description: "Copy the addon-level sources setting (typed args, or a legacy argsJson string) into waste-source records, credentials in the keychain, each read back; an existing record is never overwritten",
91439
- fixtureMark: "the credential of a legacy argsJson row, read back from its record"
91440
- },
91441
- procedure: copySourcesToRecords
91442
- }];
91443
- //#endregion
91444
- //#region src/python-paths.ts
91445
- /**
91446
- * Locate the addon's bundled `python/` dir at runtime (deployed package
91447
- * root or workspace package root). Same candidate walk as detection-pipeline.
91448
- */
91449
- function resolvePythonDir(fromModuleUrl = import.meta.url) {
91450
- const here = dirname(fileURLToPath(fromModuleUrl));
91451
- const candidates = [
91452
- join(here, "../python"),
91453
- join(here, "../../python"),
91454
- join(here, "python")
91455
- ];
91456
- for (const c of candidates) if (existsSync(join(c, "fetch_source.py"))) return c;
91457
- throw new Error(`addon-waste-collections: python/ dir not found. Searched:\n${candidates.join("\n")}`);
91458
- }
91459
- function resolveFetchScript(fromModuleUrl = import.meta.url) {
91460
- return join(resolvePythonDir(fromModuleUrl), "fetch_source.py");
91461
- }
91462
91695
  //#endregion
91463
91696
  //#region src/waste-source-record-kinds.ts
91464
91697
  var WASTE_COLLECTIONS_OWNER = "waste-collections";
@@ -91504,6 +91737,202 @@ var plainWasteSourceKind = defineEntryKind("waste-source", {
91504
91737
  writeTrigger: WRITE_TRIGGER
91505
91738
  });
91506
91739
  //#endregion
91740
+ //#region src/waste-source-store.ts
91741
+ /**
91742
+ * A stored body as a source. A row read while the keychain is locked still carries its markers
91743
+ * in `args`; the config schema keeps them, so the source lists and its fetch refuses by name.
91744
+ */
91745
+ function rowToSource(id, data) {
91746
+ const parsed = WasteSourceConfigSchema.safeParse({
91747
+ id,
91748
+ label: data.name,
91749
+ module: data.module,
91750
+ args: data.args,
91751
+ enabled: data.enabled
91752
+ });
91753
+ return parsed.success ? parsed.data : null;
91754
+ }
91755
+ function bodyOf(source) {
91756
+ const { id, label, module, args, enabled } = source;
91757
+ const body = {
91758
+ name: label,
91759
+ module,
91760
+ args,
91761
+ enabled
91762
+ };
91763
+ const parsed = wasteSourceEntryKind.schema.safeParse(body);
91764
+ if (!parsed.success) throw new Error(`invalid waste source '${id}': ${parsed.error.message}`);
91765
+ return parsed.data;
91766
+ }
91767
+ /**
91768
+ * While the sources migration has not landed: every record, plus the legacy rows that have no
91769
+ * record yet (a read-only view of those). A legacy row leaves the view as soon as a record has
91770
+ * its id, so the two never diverge; writes go to the records.
91771
+ */
91772
+ function withUnmigratedLegacyRows(records, legacy) {
91773
+ const have = new Set(records.map((r) => r.id));
91774
+ return [...records, ...legacy.filter((r) => !have.has(r.id))];
91775
+ }
91776
+ var WasteSourceStore = class WasteSourceStore {
91777
+ kind;
91778
+ constructor(kind) {
91779
+ this.kind = kind;
91780
+ }
91781
+ static of(records, kind = wasteSourceEntryKind) {
91782
+ return new WasteSourceStore(records.kind(kind));
91783
+ }
91784
+ /** The record ids, whatever their content. */
91785
+ async listIds() {
91786
+ return (await this.kind.query({ limit: 1e3 })).records.map((record) => record.id);
91787
+ }
91788
+ /** One source with its credentials resolved; markers stay where the keychain cannot serve. */
91789
+ async readResolved(id) {
91790
+ const stored = await this.kind.get(id);
91791
+ if (stored === null) return null;
91792
+ let data = stored.data;
91793
+ try {
91794
+ const resolved = await this.kind.getResolved(id);
91795
+ if (resolved !== null) data = resolved.data;
91796
+ } catch (err) {
91797
+ if (!(err instanceof RecordSecretUnavailableError)) throw err;
91798
+ }
91799
+ return rowToSource(id, data);
91800
+ }
91801
+ async listResolved() {
91802
+ const out = [];
91803
+ for (const id of await this.listIds()) {
91804
+ const row = await this.readResolved(id);
91805
+ if (row !== null) out.push(row);
91806
+ }
91807
+ return out;
91808
+ }
91809
+ /** Create the record when the id is free; an existing record is never touched. */
91810
+ async putIfAbsent(source) {
91811
+ if (await this.kind.get(source.id) !== null) return false;
91812
+ await this.kind.put({
91813
+ id: source.id,
91814
+ data: bodyOf(source)
91815
+ }, { ifRev: 0 });
91816
+ return true;
91817
+ }
91818
+ /**
91819
+ * Make the records equal `sources`. A source whose credential the keychain did not serve is
91820
+ * left exactly as stored: rewriting it would save a marker as the credential.
91821
+ */
91822
+ async replaceAll(sources) {
91823
+ const nextIds = new Set(sources.map((s) => s.id));
91824
+ for (const id of await this.listIds()) if (!nextIds.has(id)) await this.kind.delete(id);
91825
+ for (const source of sources) {
91826
+ if (hasUnresolvedCredential(source.args)) continue;
91827
+ await this.kind.put({
91828
+ id: source.id,
91829
+ data: bodyOf(source)
91830
+ });
91831
+ }
91832
+ }
91833
+ };
91834
+ //#endregion
91835
+ //#region src/migrations/ports.ts
91836
+ var PAGE = 1e3;
91837
+ /**
91838
+ * The sources migration's ports over {@link MigrationApiV1}, and nothing else (planning#447): the
91839
+ * addon's own settings store, its keychain handle and its `waste-source` record kind.
91840
+ */
91841
+ function wasteSourcePortsOver(api, kind) {
91842
+ const handle = api.records.kind(kind);
91843
+ const secrets = { resolve: async (refs) => {
91844
+ const { values } = await api.secrets.resolve({ refs: [...refs] });
91845
+ return Object.fromEntries(refs.map((ref) => {
91846
+ const value = values[ref];
91847
+ return [ref, value === null || value === void 0 ? { kind: "missing" } : {
91848
+ kind: "value",
91849
+ value,
91850
+ version: 0
91851
+ }];
91852
+ }));
91853
+ } };
91854
+ const readStored = async (key) => {
91855
+ let store;
91856
+ try {
91857
+ store = (await api.settings.read({})).store;
91858
+ } catch (err) {
91859
+ return {
91860
+ kind: "unreadable",
91861
+ reason: "store-read-failed",
91862
+ error: err instanceof Error ? err.message : String(err)
91863
+ };
91864
+ }
91865
+ if (!Object.hasOwn(store, key)) return { kind: "absent" };
91866
+ const { value } = await resolveSecretMarkers(store[key], secrets, { unresolved: "keep-marker" });
91867
+ return {
91868
+ kind: "present",
91869
+ value
91870
+ };
91871
+ };
91872
+ const asRow = (row) => rowToSource(row.id, row.data);
91873
+ return {
91874
+ readStored,
91875
+ listSourceIds: async () => (await handle.query({ limit: PAGE })).records.map((row) => row.id),
91876
+ putSource: async (source) => {
91877
+ if (await handle.get({ id: source.id }) !== null) return;
91878
+ await handle.put({
91879
+ id: source.id,
91880
+ data: bodyOf(source),
91881
+ ifRev: 0
91882
+ });
91883
+ },
91884
+ readSource: async (id) => {
91885
+ const stored = await handle.get({ id });
91886
+ if (stored === null) return null;
91887
+ try {
91888
+ return asRow(await handle.getResolved({ id }) ?? stored);
91889
+ } catch (err) {
91890
+ if (!(err instanceof RecordSecretUnavailableError)) throw err;
91891
+ return asRow(stored);
91892
+ }
91893
+ },
91894
+ warn: (message) => {
91895
+ api.log.warn({ message });
91896
+ }
91897
+ };
91898
+ }
91899
+ /** The `waste-collections` catalogue (D987): offered to the engine when the addon initialises. */
91900
+ var ADDON_MIGRATIONS = [{
91901
+ meta: {
91902
+ id: "waste-collections/2026-11-06-sources-from-settings-and-argsjson",
91903
+ owner: "waste-collections",
91904
+ level: "addon-state",
91905
+ seq: 1,
91906
+ dependsOn: [],
91907
+ store: "settings",
91908
+ scope: "cluster",
91909
+ onFailure: "tolerate",
91910
+ bodyKind: "procedure",
91911
+ description: "Copy the addon-level sources setting (typed args, or a legacy argsJson string) into waste-source records, credentials in the keychain, each read back; an existing record is never overwritten",
91912
+ fixtureMark: "the credential of a legacy argsJson row, read back from its record"
91913
+ },
91914
+ procedure: copySourcesToRecords
91915
+ }];
91916
+ //#endregion
91917
+ //#region src/python-paths.ts
91918
+ /**
91919
+ * Locate the addon's bundled `python/` dir at runtime (deployed package
91920
+ * root or workspace package root). Same candidate walk as detection-pipeline.
91921
+ */
91922
+ function resolvePythonDir(fromModuleUrl = import.meta.url) {
91923
+ const here = dirname(fileURLToPath(fromModuleUrl));
91924
+ const candidates = [
91925
+ join(here, "../python"),
91926
+ join(here, "../../python"),
91927
+ join(here, "python")
91928
+ ];
91929
+ for (const c of candidates) if (existsSync(join(c, "fetch_source.py"))) return c;
91930
+ throw new Error(`addon-waste-collections: python/ dir not found. Searched:\n${candidates.join("\n")}`);
91931
+ }
91932
+ function resolveFetchScript(fromModuleUrl = import.meta.url) {
91933
+ return join(resolvePythonDir(fromModuleUrl), "fetch_source.py");
91934
+ }
91935
+ //#endregion
91507
91936
  //#region src/seal-plaintext.ts
91508
91937
  /**
91509
91938
  * A credential an older version stored as plain text in a `waste-source` record is moved into the
@@ -177679,101 +178108,6 @@ function allocateSourceId(label, existingIds) {
177679
178108
  throw new Error("could not allocate a unique source id");
177680
178109
  }
177681
178110
  //#endregion
177682
- //#region src/waste-source-store.ts
177683
- /**
177684
- * A stored body as a source. A row read while the keychain is locked still carries its markers
177685
- * in `args`; the config schema keeps them, so the source lists and its fetch refuses by name.
177686
- */
177687
- function rowToSource(id, data) {
177688
- const parsed = WasteSourceConfigSchema.safeParse({
177689
- id,
177690
- label: data.name,
177691
- module: data.module,
177692
- args: data.args,
177693
- enabled: data.enabled
177694
- });
177695
- return parsed.success ? parsed.data : null;
177696
- }
177697
- function bodyOf(source) {
177698
- const { id, label, module, args, enabled } = source;
177699
- const body = {
177700
- name: label,
177701
- module,
177702
- args,
177703
- enabled
177704
- };
177705
- const parsed = wasteSourceEntryKind.schema.safeParse(body);
177706
- if (!parsed.success) throw new Error(`invalid waste source '${id}': ${parsed.error.message}`);
177707
- return parsed.data;
177708
- }
177709
- /**
177710
- * While the sources migration has not landed: every record, plus the legacy rows that have no
177711
- * record yet (a read-only view of those). A legacy row leaves the view as soon as a record has
177712
- * its id, so the two never diverge; writes go to the records.
177713
- */
177714
- function withUnmigratedLegacyRows(records, legacy) {
177715
- const have = new Set(records.map((r) => r.id));
177716
- return [...records, ...legacy.filter((r) => !have.has(r.id))];
177717
- }
177718
- var WasteSourceStore = class WasteSourceStore {
177719
- kind;
177720
- constructor(kind) {
177721
- this.kind = kind;
177722
- }
177723
- static of(records, kind = wasteSourceEntryKind) {
177724
- return new WasteSourceStore(records.kind(kind));
177725
- }
177726
- /** The record ids, whatever their content. */
177727
- async listIds() {
177728
- return (await this.kind.query({ limit: 1e3 })).records.map((record) => record.id);
177729
- }
177730
- /** One source with its credentials resolved; markers stay where the keychain cannot serve. */
177731
- async readResolved(id) {
177732
- const stored = await this.kind.get(id);
177733
- if (stored === null) return null;
177734
- let data = stored.data;
177735
- try {
177736
- const resolved = await this.kind.getResolved(id);
177737
- if (resolved !== null) data = resolved.data;
177738
- } catch (err) {
177739
- if (!(err instanceof RecordSecretUnavailableError)) throw err;
177740
- }
177741
- return rowToSource(id, data);
177742
- }
177743
- async listResolved() {
177744
- const out = [];
177745
- for (const id of await this.listIds()) {
177746
- const row = await this.readResolved(id);
177747
- if (row !== null) out.push(row);
177748
- }
177749
- return out;
177750
- }
177751
- /** Create the record when the id is free; an existing record is never touched. */
177752
- async putIfAbsent(source) {
177753
- if (await this.kind.get(source.id) !== null) return false;
177754
- await this.kind.put({
177755
- id: source.id,
177756
- data: bodyOf(source)
177757
- }, { ifRev: 0 });
177758
- return true;
177759
- }
177760
- /**
177761
- * Make the records equal `sources`. A source whose credential the keychain did not serve is
177762
- * left exactly as stored: rewriting it would save a marker as the credential.
177763
- */
177764
- async replaceAll(sources) {
177765
- const nextIds = new Set(sources.map((s) => s.id));
177766
- for (const id of await this.listIds()) if (!nextIds.has(id)) await this.kind.delete(id);
177767
- for (const source of sources) {
177768
- if (hasUnresolvedCredential(source.args)) continue;
177769
- await this.kind.put({
177770
- id: source.id,
177771
- data: bodyOf(source)
177772
- });
177773
- }
177774
- }
177775
- };
177776
- //#endregion
177777
178111
  //#region src/waste-source-instances.ts
177778
178112
  /**
177779
178113
  * The waste sources as `integration-instances` (planning#440 step 6): one instance per
@@ -178134,7 +178468,7 @@ var WasteCollectionsAddon = class extends BaseAddon {
178134
178468
  if (!plainBoot) await records.declare({ "waste-source": wasteSourceEntryKind });
178135
178469
  const store = WasteSourceStore.of(records, plainBoot ? plainWasteSourceKind : wasteSourceEntryKind);
178136
178470
  this.sourceStore = store;
178137
- await this.migrateLegacySources(store);
178471
+ await this.migrateLegacySources(plainBoot ? plainWasteSourceKind : wasteSourceEntryKind);
178138
178472
  this.activeSources = await this.loadPersistedSources();
178139
178473
  const instances = plainBoot ? null : new WasteSourceInstances(records, {
178140
178474
  onChanged: (id, change) => this.onInstanceChanged(id, change),
@@ -178216,16 +178550,8 @@ var WasteCollectionsAddon = class extends BaseAddon {
178216
178550
  * fails leaves the settings where they are and is logged by id: the sources stay unavailable
178217
178551
  * until it lands, never half-copied.
178218
178552
  */
178219
- async migrateLegacySources(store) {
178220
- const report = await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, {
178221
- readStored: (key) => this.readStoredGlobalKey(key),
178222
- listSourceIds: () => store.listIds(),
178223
- putSource: async (source) => {
178224
- await store.putIfAbsent(source);
178225
- },
178226
- readSource: (id) => store.readResolved(id),
178227
- warn: (message) => this.ctx.logger.warn(message)
178228
- }));
178553
+ async migrateLegacySources(kind) {
178554
+ const report = await this.runMigrations(ADDON_MIGRATIONS, (api) => wasteSourcePortsOver(api, kind));
178229
178555
  this.legacyServed = !report.declared || report.failed.includes("waste-collections/2026-11-06-sources-from-settings-and-argsjson") || report.blocked.includes("waste-collections/2026-11-06-sources-from-settings-and-argsjson");
178230
178556
  if (this.legacyServed) this.ctx.logger.warn("waste-collections: the sources migration has not landed - the legacy sources not yet in records are served read-only beside the records until it does", { meta: {
178231
178557
  declared: report.declared,