@camstack/system 1.2.201 → 1.2.202

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  8. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  9. package/dist/builtins/console-logging/index.js +1 -1
  10. package/dist/builtins/console-logging/index.mjs +1 -1
  11. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  12. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  13. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  14. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  15. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  16. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  17. package/dist/builtins/hub-forwarder/index.js +1 -1
  18. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  19. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  20. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  21. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  22. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  23. package/dist/builtins/local-network/local-network.addon.js +1 -1
  24. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  25. package/dist/builtins/loki-logging/index.js +1 -1
  26. package/dist/builtins/loki-logging/index.mjs +1 -1
  27. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  28. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  29. package/dist/builtins/platform-probe/index.js +1 -1
  30. package/dist/builtins/platform-probe/index.mjs +1 -1
  31. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  32. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  33. package/dist/builtins/snapshot/index.js +1 -1
  34. package/dist/builtins/snapshot/index.mjs +1 -1
  35. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  36. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  37. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  38. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  39. package/dist/builtins/storage-orchestrator/location-store.d.ts +12 -3
  40. package/dist/builtins/storage-orchestrator/storage-migration-coordinator.d.ts +9 -9
  41. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +181 -129
  42. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +181 -129
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.service.d.ts +54 -31
  44. package/dist/builtins/system-config/system-config.addon.js +1 -1
  45. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  46. package/dist/builtins/winston-logging/index.js +1 -1
  47. package/dist/builtins/winston-logging/index.mjs +1 -1
  48. package/dist/{dist-BmJNnb_s.mjs → dist-CiHYspij.mjs} +202 -185
  49. package/dist/{dist-CcV3k-hD.js → dist-jZb-HKI5.js} +213 -184
  50. package/dist/index.js +1 -1
  51. package/dist/index.mjs +1 -1
  52. package/dist/{retired-settings-keys-nLVKlhe-.mjs → retired-settings-keys-DeRxo3vg.mjs} +1 -1
  53. package/dist/{retired-settings-keys-Bd8fuwO1.js → retired-settings-keys-Dxv9S_lO.js} +1 -1
  54. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { C as StorageCleanupJobSchema, Et as settingsStoreCapability, I as collectSecretConfigKeys, It as BaseAddon, Ot as storageCapability, T as StorageMigrationJobSchema, h as REDACTED_SECRET, kt as storageMigrationCapability, nn as parseJsonObject, w as StorageLocationTypeSchema } from "../../dist-BmJNnb_s.mjs";
1
+ import { At as storageCapability, E as StorageMigrationJobSchema, L as collectSecretConfigKeys, Ot as settingsStoreCapability, Rt as BaseAddon, T as StorageLocationTypeSchema, g as REDACTED_SECRET, in as parseJsonObject, jt as storageMigrationCapability, m as LegacyStorageLocationDefaultSchema, rt as isLocationEnabled, w as StorageCleanupJobSchema } from "../../dist-CiHYspij.mjs";
2
2
  import { t as HUB_CAP_FWD_ACTION } from "../../hub-cap-forward-BdT320dq.mjs";
3
3
  import { z } from "zod";
4
4
  import { randomUUID } from "node:crypto";
@@ -188,7 +188,7 @@ function mapLocationToRow(loc) {
188
188
  display_name: loc.displayName,
189
189
  provider_id: loc.providerId,
190
190
  config: storedConfig,
191
- is_default: loc.isDefault ? 1 : 0,
191
+ is_default: 0,
192
192
  is_system: loc.isSystem ? 1 : 0,
193
193
  created_at: loc.createdAt,
194
194
  updated_at: loc.updatedAt
@@ -222,7 +222,7 @@ function mapRowToLocation(row) {
222
222
  config,
223
223
  ...nodeId !== void 0 ? { nodeId } : {},
224
224
  ...enabled !== void 0 ? { enabled } : {},
225
- isDefault: row["is_default"] === 1 || row["is_default"] === true,
225
+ ...LegacyStorageLocationDefaultSchema.parse({ isDefault: row["is_default"] === 1 || row["is_default"] === true }),
226
226
  isSystem: row["is_system"] === 1 || row["is_system"] === true,
227
227
  createdAt: Number(row["created_at"] ?? 0),
228
228
  updatedAt: Number(row["updated_at"] ?? 0)
@@ -290,16 +290,18 @@ var StorageMigrationCoordinator = class {
290
290
  const targetId = input.destinations[storageClass];
291
291
  if (targetId === void 0) continue;
292
292
  const namedSourceId = input.sources?.[storageClass];
293
- const defaultLoc = this.deps.locations.getDefaultLocation(storageClass);
294
- const source = namedSourceId ? this.deps.locations.getLocationById(namedSourceId) : defaultLoc;
295
- if (!source) throw new Error(namedSourceId ? `Storage location "${namedSourceId}" not found` : `No default storage location for "${storageClass}"`);
293
+ const writeSet = this.deps.locations.writeTargets(storageClass);
294
+ const implicitSources = writeSet.filter((loc) => loc.id !== targetId);
295
+ if (!namedSourceId && implicitSources.length > 1) throw new Error(`"${storageClass}" writes to ${String(implicitSources.length)} locations (${implicitSources.map((loc) => loc.id).join(", ")}) — name the one to cut over explicitly with \`sources\`.`);
296
+ const source = namedSourceId ? this.deps.locations.getLocationById(namedSourceId) : implicitSources[0];
297
+ if (!source) throw new Error(namedSourceId ? `Storage location "${namedSourceId}" not found` : `Storage location "${targetId}" is already the only write target for "${storageClass}" — there is nothing to cut over. Use storageMigration.drain to move what is left behind.`);
296
298
  if (source.type !== storageClass) throw new Error(`Storage location "${source.id}" is type "${source.type}", expected "${storageClass}"`);
297
299
  const target = this.deps.locations.getLocationById(targetId);
298
300
  if (!target) throw new Error(`Storage location "${targetId}" not found`);
299
301
  if (target.type !== storageClass) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${storageClass}"`);
300
- if (source.id === target.id) throw new Error(namedSourceId ? `Source and destination are the same location ("${targetId}")` : `Storage location "${targetId}" is already the "${storageClass}" default`);
301
- const freezeSource = defaultLoc === null || source.id !== defaultLoc.id;
302
- if (!MOVER_CLASSES.includes(storageClass)) throw new Error(`No mover owns "${storageClass}" — the migration can repoint its default but cannot move its bytes. Move it by hand, then repoint the default with upsertLocation.`);
302
+ if (source.id === target.id) throw new Error(namedSourceId ? `Source and destination are the same location ("${targetId}")` : `Storage location "${targetId}" is already the only write target for "${storageClass}"`);
303
+ const freezeSource = !writeSet.some((loc) => loc.id === source.id);
304
+ if (!MOVER_CLASSES.includes(storageClass)) throw new Error(`No mover owns "${storageClass}" — the migration can move its write set but cannot move its bytes. Move it by hand, then enable the destination (and disable the source) with upsertLocation.`);
303
305
  moves.push({
304
306
  storageClass,
305
307
  fromLocationId: source.id,
@@ -316,7 +318,7 @@ var StorageMigrationCoordinator = class {
316
318
  if (moves.some((move) => move.storageClass === "eventMedia")) {
317
319
  const unstamped = await this.deps.participants.analytics.countUnstamped();
318
320
  if (unstamped === null) {
319
- if (mode === "nonBlocking") throw new Error("could not determine whether any event-media row still carries no locationId. A non-blocking cutover repoints the default and would silently orphan every such row, so an unmeasured collection is refused exactly like a non-empty one. Retry, or run the blocking mode, which stamps rows as it moves them.");
321
+ if (mode === "nonBlocking") throw new Error("could not determine whether any event-media row still carries no locationId. A non-blocking cutover moves the write set and would silently orphan every such row, so an unmeasured collection is refused exactly like a non-empty one. Retry, or run the blocking mode, which stamps rows as it moves them.");
320
322
  findings.push({
321
323
  code: "unstampedEventMediaRows",
322
324
  storageClass: "eventMedia",
@@ -325,7 +327,7 @@ var StorageMigrationCoordinator = class {
325
327
  } else if (unstamped.anyPresent) {
326
328
  const scale = unstamped.total === null ? "an unknown number of" : unstamped.total.toString();
327
329
  const perLane = `${describeUnstamped(unstamped.media)} media, ${describeUnstamped(unstamped.retrainFrames)} retrain frames`;
328
- if (mode === "nonBlocking") throw new Error(`${scale} event-media row(s) still carry no locationId (${perLane}). A non-blocking cutover would silently orphan them. Run the seal first: pipelineAnalytics.relocateMedia({ mode: "seal", toLocationId: "${this.deps.locations.getDefaultLocation("eventMedia")?.id ?? "eventMedia"}" }).`);
330
+ if (mode === "nonBlocking") throw new Error(`${scale} event-media row(s) still carry no locationId (${perLane}). A non-blocking cutover would silently orphan them. Run the seal first: pipelineAnalytics.relocateMedia({ mode: "seal", toLocationId: "${this.deps.locations.writeTargets("eventMedia")[0]?.id ?? "eventMedia"}" }).`);
329
331
  findings.push({
330
332
  code: "unstampedEventMediaRows",
331
333
  storageClass: "eventMedia",
@@ -464,7 +466,7 @@ var StorageMigrationCoordinator = class {
464
466
  * remaining" action.
465
467
  *
466
468
  * A class appears here only when something is (or might be) left on a
467
- * location that is not its default. An empty result therefore means exactly
469
+ * location outside its write set. An empty result therefore means exactly
468
470
  * "there is nothing to drain", which is what lets the UI offer the action
469
471
  * only when it is true, and what lets {@link drain} refuse rather than start
470
472
  * a job that would move nothing and report `done` — the failure mode D295
@@ -478,11 +480,13 @@ var StorageMigrationCoordinator = class {
478
480
  const out = [];
479
481
  for (const storageClass of STORAGE_CLASSES) {
480
482
  if (!MOVER_CLASSES.includes(storageClass)) continue;
481
- const target = this.deps.locations.getDefaultLocation(storageClass);
483
+ const writeSet = this.deps.locations.writeTargets(storageClass);
484
+ const target = writeSet[0];
482
485
  if (!target) continue;
486
+ const inWriteSet = new Set(writeSet.map((loc) => loc.id));
483
487
  if (laneOf(storageClass) === "media") {
484
488
  for (const source of this.deps.locations.listLocations({ type: storageClass })) {
485
- if (source.id === target.id) continue;
489
+ if (inWriteSet.has(source.id)) continue;
486
490
  const count = await unanswerable(this.deps.participants.analytics.residue({
487
491
  toLocationId: target.id,
488
492
  fromLocationId: source.id,
@@ -500,7 +504,7 @@ var StorageMigrationCoordinator = class {
500
504
  continue;
501
505
  }
502
506
  for (const source of this.deps.locations.listLocations({ type: storageClass })) {
503
- if (source.id === target.id) continue;
507
+ if (inWriteSet.has(source.id)) continue;
504
508
  const census = await unanswerable(this.deps.participants.recorder.residue({
505
509
  fromLocationId: source.id,
506
510
  footageClass: storageClass === "recordingsLow" ? "recordingsLow" : "recordings"
@@ -518,12 +522,12 @@ var StorageMigrationCoordinator = class {
518
522
  return out;
519
523
  }
520
524
  /**
521
- * Run the DRAIN half alone, against classes whose default has already moved.
525
+ * Run the DRAIN half alone, against classes whose write set has already moved.
522
526
  *
523
527
  * ## Why this is a second verb rather than a looser `start`
524
528
  *
525
- * `start` refuses a destination that is already the class's default
526
- * (`"recordingsLow:ssd" is already the "recordingsLow" default`). That refusal
529
+ * `start` refuses a destination that is already the class's only write target
530
+ * (`"recordingsLow:ssd" is already the only write target for "recordingsLow"`). That refusal
527
531
  * is correct and it is load-bearing: there is genuinely nothing left to
528
532
  * repoint, and an operator must never be able to re-repoint a migrated class
529
533
  * by accident. Making `start` idempotent — "an already-repointed class
@@ -532,7 +536,7 @@ var StorageMigrationCoordinator = class {
532
536
  * operator reads ("pauses the writers…") would be a lie half the time.
533
537
  *
534
538
  * `drain` instead cannot repoint AT ALL: it never touches
535
- * `setDefaultLocations`, and its job starts in `draining` with `repointed`
539
+ * `setWriteTargets`, and its job starts in `draining` with `repointed`
536
540
  * already true, so the `repointing` / `refreshing` / `resuming` blocks of
537
541
  * {@link run} are behind it and unreachable. The two verbs are disjoint, and
538
542
  * `start`'s refusal keeps meaning exactly what it meant.
@@ -542,7 +546,7 @@ var StorageMigrationCoordinator = class {
542
546
  * The finished job is the audit of what happened; re-opening it destroys
543
547
  * that. And a drain is needed in cases where no migration job ever existed
544
548
  * (a mover armed by hand, footage stranded on a location an operator added
545
- * and then un-defaulted). One job = one operation, and the work list comes
549
+ * and then disabled again). One job = one operation, and the work list comes
546
550
  * from {@link residue} — the archive — not from what a previous job believed.
547
551
  */
548
552
  async drain(input) {
@@ -565,9 +569,9 @@ var StorageMigrationCoordinator = class {
565
569
  const destinations = {};
566
570
  for (const storageClass of input.classes) {
567
571
  const remaining = residue.filter((entry) => entry.storageClass === storageClass);
568
- if (remaining.length === 0) throw new Error(`"${storageClass}" has nothing left outside its default location — there is nothing to drain. To CHANGE where it writes, use storageMigration.start with a new destination.`);
572
+ if (remaining.length === 0) throw new Error(`"${storageClass}" has nothing left outside its write set — there is nothing to drain. To CHANGE where it writes, use storageMigration.start with a new destination.`);
569
573
  for (const entry of remaining) {
570
- if (this.deps.locations.getLocationById(entry.toLocationId)?.enabled === false) throw new Error(`The "${storageClass}" drain destination "${entry.toLocationId}" is DISABLED — a disabled location takes no writes, and a drain is the largest write there is. Enable it on the Storage screen, or make an enabled location the "${storageClass}" default first.`);
574
+ if (this.deps.locations.getLocationById(entry.toLocationId)?.enabled === false) throw new Error(`The "${storageClass}" drain destination "${entry.toLocationId}" is DISABLED — a disabled location takes no writes, and a drain is the largest write there is. Enable it on the Storage screen first.`);
571
575
  moves.push({
572
576
  storageClass,
573
577
  fromLocationId: entry.fromLocationId,
@@ -685,8 +689,13 @@ var StorageMigrationCoordinator = class {
685
689
  await this.setPhase(job, "repointing");
686
690
  }
687
691
  if (job.phase === "repointing") {
688
- const targets = new Map(job.moves.filter((move) => move.freezeSource !== true).map((move) => [move.storageClass, move.toLocationId]));
689
- if (targets.size > 0) await this.deps.locations.setDefaultLocations(targets);
692
+ const targets = /* @__PURE__ */ new Map();
693
+ for (const move of job.moves) {
694
+ if (move.freezeSource === true) continue;
695
+ targets.set(move.toLocationId, true);
696
+ targets.set(move.fromLocationId, false);
697
+ }
698
+ if (targets.size > 0) await this.deps.locations.setWriteTargets(targets);
690
699
  job.repointed = true;
691
700
  await this.setPhase(job, "refreshing");
692
701
  }
@@ -885,9 +894,9 @@ var StorageMigrationCoordinator = class {
885
894
  for (const move of job.moves) if (move.state !== "done") throw new Error(`${move.storageClass} move did not complete verification`);
886
895
  }
887
896
  /**
888
- * After a from→to copy whose source was NOT the class default: stop new
889
- * writes to `from`. The default is unchanged (that is the whole point of
890
- * naming a non-default source). A location that is already disabled is
897
+ * After a from→to copy whose source was NOT a write target: stop new writes
898
+ * to `from`. The rest of the write set is unchanged (that is the whole point
899
+ * of naming a source outside it). A location that is already disabled is
891
900
  * left alone.
892
901
  */
893
902
  freezeDrainedSources(job) {
@@ -1832,33 +1841,77 @@ var StorageOrchestratorService = class {
1832
1841
  backfilled: backfill.length,
1833
1842
  total: this.locations.size
1834
1843
  } });
1835
- this.reportDisabledDefaults();
1844
+ this.migrateOntoTheWriteSet(rows);
1836
1845
  }
1837
1846
  /**
1838
- * Shout about a persisted row that is BOTH the type default and disabled.
1847
+ * ONE explicit migration onto the enabled write set (D383), run once per
1848
+ * boot against the rows that came off disk. Idempotent: the second boot finds
1849
+ * nothing to do.
1850
+ *
1851
+ * Two steps, in order, and each one is a WRITE — the point is that "absent"
1852
+ * stops existing rather than being re-interpreted on every read forever:
1839
1853
  *
1840
- * `upsertLocation` can now produce this (enabled is the write set; `isDefault`
1841
- * is store residue). Hydrate also writes rows straight into the map, so a
1842
- * hand-edited store or a row written by another build can too. It matters
1843
- * because every bare-type ref still resolves through {@link getDefaultLocation}
1844
- * until Stage 5 of the enabled-write-set design: on such a row
1845
- * `storage.write({ location: 'eventMedia' })` and the migration coordinator's
1846
- * drain destination both point at a disk the operator turned off.
1854
+ * 1. A flagless row (persisted before `enabled` existed) is stamped
1855
+ * `enabled: true`. Absence already meant active, so this preserves
1856
+ * behaviour exactly; it is the operator's "the initial locations are
1857
+ * enabled" spelled into the store.
1858
+ * 2. A type left with NO enabled row — only reachable from a hand-edited
1859
+ * store or a row an older build wrote — gets one enabled back, chosen as
1860
+ * the row the RETIRED default flag named, else the `:default`-slugged
1861
+ * row, else the lexicographically first. Loudly: a class with no write
1862
+ * target records nowhere, and refusing to repair it here would leave the
1863
+ * hub silently unable to write with no operator action pending.
1847
1864
  *
1848
- * Reported, never repaired: flipping an operator's flag back on at boot would
1849
- * be the system overruling the switch instead of obeying it. The coordinator
1850
- * refuses the drain (`storage-migration-coordinator.drain`); the bare-ref read
1851
- * path is deliberately left working, because narrowing a READ is a worse bug
1852
- * than the one this line reports.
1865
+ * An operator's explicit `enabled: false` is never reversed by step 1 — only
1866
+ * step 2 touches it, and only when the alternative is a class that cannot
1867
+ * write at all.
1853
1868
  */
1854
- reportDisabledDefaults() {
1869
+ migrateOntoTheWriteSet(rows) {
1870
+ const legacyDefaultOfType = /* @__PURE__ */ new Map();
1871
+ for (const row of rows) if (row.isDefault === true) legacyDefaultOfType.set(row.type, row.id);
1872
+ const stamped = [];
1855
1873
  for (const loc of this.locations.values()) {
1856
- if (!loc.isDefault || loc.enabled !== false) continue;
1857
- this.logger.error("storage-orchestrator: the type DEFAULT is disabled — every bare-type ref for this type resolves to a location the operator turned off. Enable it, or make another location the default.", { meta: {
1858
- id: loc.id,
1859
- type: loc.type
1860
- } });
1874
+ if (loc.enabled !== void 0) continue;
1875
+ this.persistMigrated({
1876
+ ...loc,
1877
+ enabled: true
1878
+ });
1879
+ stamped.push(loc.id);
1880
+ }
1881
+ const repaired = [];
1882
+ for (const type of new Set([...this.locations.values()].map((l) => l.type))) {
1883
+ const ofType = [...this.locations.values()].filter((l) => l.type === type);
1884
+ if (ofType.some((l) => isLocationEnabled(l))) continue;
1885
+ const legacyDefault = legacyDefaultOfType.get(type);
1886
+ const pick = ofType.find((l) => l.id === legacyDefault) ?? ofType.find((l) => l.id === `${type}:default`) ?? ofType.toSorted((a, b) => a.id.localeCompare(b.id))[0];
1887
+ if (!pick) continue;
1888
+ this.persistMigrated({
1889
+ ...pick,
1890
+ enabled: true
1891
+ });
1892
+ repaired.push(pick.id);
1861
1893
  }
1894
+ if (stamped.length > 0 || repaired.length > 0) this.logger.info("storage-orchestrator: migrated locations onto the enabled write set", { meta: {
1895
+ stamped,
1896
+ repaired
1897
+ } });
1898
+ for (const id of repaired) this.logger.warn("storage-orchestrator: a storage type had NO enabled location — re-enabled one so it can write. Every other location of that type stays read-only until you enable it.", { meta: { id } });
1899
+ }
1900
+ /** Apply one migrated row in memory and mirror it to the store. */
1901
+ persistMigrated(next) {
1902
+ const withStamp = {
1903
+ ...next,
1904
+ updatedAt: Date.now()
1905
+ };
1906
+ this.locations.set(withStamp.id, withStamp);
1907
+ const store = this.locationStore;
1908
+ if (!store) return;
1909
+ store.upsert(withStamp).catch((err) => {
1910
+ this.logger.error("storage-orchestrator: write-set migration persist failed", { meta: {
1911
+ id: withStamp.id,
1912
+ error: err instanceof Error ? err.message : String(err)
1913
+ } });
1914
+ });
1862
1915
  }
1863
1916
  /**
1864
1917
  * Inject the declaration-driven cardinality source. Called once by the
@@ -1913,7 +1966,7 @@ var StorageOrchestratorService = class {
1913
1966
  const rows = await this.locationStore.loadAll();
1914
1967
  for (const loc of rows) this.locations.set(loc.id, loc);
1915
1968
  this.logger.info("storage-orchestrator: hydrated locations from store", { meta: { loaded: this.locations.size } });
1916
- this.reportDisabledDefaults();
1969
+ this.migrateOntoTheWriteSet(rows);
1917
1970
  }
1918
1971
  /**
1919
1972
  * Boot backfill (SP1): stamp `nodeId` on every persisted node-local
@@ -1962,40 +2015,51 @@ var StorageOrchestratorService = class {
1962
2015
  const all = [...this.locations.values()];
1963
2016
  return filter?.type ? all.filter((l) => l.type === filter.type) : all;
1964
2017
  }
1965
- getDefaultLocation(type) {
1966
- for (const loc of this.locations.values()) if (loc.type === type && loc.isDefault) return loc;
1967
- return null;
2018
+ /**
2019
+ * The WRITE SET of a type: every enabled location of it, in stable id order.
2020
+ * This replaced `getDefaultLocation` (D383) — a type does not have one write
2021
+ * target any more, it has a pool, and every consumer that must pick one picks
2022
+ * from here by its own rule (the recorder by headroom, a drain by an operator
2023
+ * -named destination).
2024
+ */
2025
+ writeTargets(type) {
2026
+ return [...this.locations.values()].filter((loc) => loc.type === type && isLocationEnabled(loc)).toSorted((a, b) => a.id.localeCompare(b.id));
1968
2027
  }
1969
2028
  /**
1970
- * Repoint several logical defaults as one in-memory commit. The migration
1971
- * coordinator persists its `repointing` phase before calling this method, so
1972
- * a process crash during the backing-store writes is recovered by replaying
1973
- * the same idempotent target set before anyone resumes writers.
2029
+ * Change the WRITE SET as one compensated commit: each named location is
2030
+ * enabled (it joins the pool) or disabled (it becomes read-only — still read,
2031
+ * still played back, still age-swept, still drained).
2032
+ *
2033
+ * This is the cutover verb, and its only caller is the migration coordinator,
2034
+ * which persists its `repointing` phase BEFORE calling so a crash mid-write
2035
+ * is recovered by replaying the same idempotent set before writers resume.
2036
+ *
2037
+ * It moves ONLY the locations it is given: a cutover from A to C on a class
2038
+ * that also writes to B leaves B in the pool, because the operator's pool is
2039
+ * the authority and a migration is not a licence to shrink it. The commit is
2040
+ * refused when it would leave a write-serving type with nothing enabled —
2041
+ * the same refusal `upsertLocation` applies, judged on the RESULT so an
2042
+ * enable and a disable in one commit are weighed together.
1974
2043
  */
1975
- async setDefaultLocations(targetByType) {
1976
- if (targetByType.size === 0) return;
2044
+ async setWriteTargets(enabledByLocationId) {
2045
+ if (enabledByLocationId.size === 0) return;
1977
2046
  const now = Date.now();
1978
2047
  const nextById = new Map(this.locations);
1979
2048
  const touched = /* @__PURE__ */ new Map();
1980
- for (const [type, targetId] of targetByType) {
1981
- const target = nextById.get(targetId);
1982
- if (!target) throw new Error(`Storage location "${targetId}" not found`);
1983
- if (target.type !== type) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${type}"`);
1984
- for (const [id, location] of nextById) {
1985
- if (location.type !== type) continue;
1986
- const isDefault = id === targetId;
1987
- const enable = isDefault && location.enabled === false;
1988
- if (location.isDefault === isDefault && !enable) continue;
1989
- const updated = {
1990
- ...location,
1991
- isDefault,
1992
- ...enable ? { enabled: true } : {},
1993
- updatedAt: now
1994
- };
1995
- nextById.set(id, updated);
1996
- touched.set(id, updated);
1997
- }
2049
+ for (const [id, enabled] of enabledByLocationId) {
2050
+ const location = nextById.get(id);
2051
+ if (!location) throw new Error(`Storage location "${id}" not found`);
2052
+ if (location.enabled === enabled) continue;
2053
+ const updated = {
2054
+ ...location,
2055
+ enabled,
2056
+ updatedAt: now
2057
+ };
2058
+ nextById.set(id, updated);
2059
+ touched.set(id, updated);
1998
2060
  }
2061
+ if (touched.size === 0) return;
2062
+ refuseTypeWithoutWriteTarget(touched.values(), nextById.values());
1999
2063
  if (this.locationStore) {
2000
2064
  const previous = /* @__PURE__ */ new Map();
2001
2065
  for (const id of touched.keys()) {
@@ -2015,13 +2079,10 @@ var StorageOrchestratorService = class {
2015
2079
  for (const [id, location] of nextById) this.locations.set(id, location);
2016
2080
  }
2017
2081
  /**
2018
- * Insert or update a location. If `isDefault: true`, atomically
2019
- * demotes any other default for the same type — operators always see
2020
- * exactly one default per type.
2082
+ * Insert or update a location.
2021
2083
  *
2022
- * When a persistence store is wired (Task 6), the new record AND any
2023
- * implicitly-demoted siblings are persisted before the in-memory map
2024
- * mutation returns. Persistence errors propagate to the caller.
2084
+ * When a persistence store is wired (Task 6), the record is mirrored to it;
2085
+ * the mirror is fire-and-forget and its errors reach the logger.
2025
2086
  */
2026
2087
  /**
2027
2088
  * Refuse a `(location kind, provider)` pair the kind cannot use.
@@ -2107,30 +2168,17 @@ var StorageOrchestratorService = class {
2107
2168
  updatedAt: now
2108
2169
  };
2109
2170
  refuseLastWriteTargetDisabled(existing, next, this.locations.values());
2110
- const demoted = [];
2111
- if (next.isDefault) {
2112
- for (const [otherId, otherLoc] of this.locations) if (otherLoc.type === next.type && otherLoc.id !== next.id && otherLoc.isDefault) {
2113
- const updated = {
2114
- ...otherLoc,
2115
- isDefault: false,
2116
- updatedAt: now
2117
- };
2118
- this.locations.set(otherId, updated);
2119
- demoted.push(updated);
2120
- }
2121
- }
2122
2171
  this.locations.set(next.id, next);
2123
2172
  this.logger.debug("storage-orchestrator: upsertLocation", { meta: {
2124
2173
  id: next.id,
2125
2174
  type: next.type,
2126
2175
  providerId: next.providerId,
2127
- isDefault: next.isDefault
2176
+ enabled: next.enabled
2128
2177
  } });
2129
2178
  if (this.locationStore) {
2130
2179
  const store = this.locationStore;
2131
2180
  (async () => {
2132
2181
  try {
2133
- for (const d of demoted) await store.upsert(d);
2134
2182
  await store.upsert(next);
2135
2183
  } catch (err) {
2136
2184
  this.logger.error("storage-orchestrator: upsert persistence failed", { meta: {
@@ -2145,8 +2193,7 @@ var StorageOrchestratorService = class {
2145
2193
  /**
2146
2194
  * Remove a location. Refuses the last location of a type and the last
2147
2195
  * enabled location of a type. `isSystem` is informational — a frozen
2148
- * recordings `:default` is deletable once a sibling is live. Deleting
2149
- * the flagged default promotes an enabled sibling. Occupancy still
2196
+ * recordings `:default` is deletable once a sibling is live. Occupancy still
2150
2197
  * refuses unless `force` is set; force never overrides uniqueness /
2151
2198
  * last-enabled.
2152
2199
  */
@@ -2157,8 +2204,6 @@ var StorageOrchestratorService = class {
2157
2204
  if (ofType.length <= 1) throw new Error(`Cannot delete "${id}" — it is the only location for type "${loc.type}"`);
2158
2205
  const enabledOfType = ofType.filter((l) => l.enabled !== false);
2159
2206
  if (loc.enabled !== false && enabledOfType.length <= 1) throw new Error(`Cannot delete "${id}" — it is the only enabled location for type "${loc.type}"`);
2160
- const successor = loc.isDefault === true ? ofType.find((l) => l.id !== id && l.enabled !== false) : void 0;
2161
- if (loc.isDefault === true && successor === void 0) throw new Error(`Cannot delete "${id}" — it is the only enabled location for type "${loc.type}"`);
2162
2207
  const occupancy = await this.probeOccupancy(loc);
2163
2208
  if (occupancy !== "empty") {
2164
2209
  if (options?.force !== true) throw new Error(occupancy === "occupied" ? `Storage location "${id}" still holds data — drain it first (storage migration / relocate), or pass force to delete the record anyway and strand what is on it` : `Storage location "${id}" could not be checked for remaining data (unreachable provider, another node, or an unmounted root) — pass force to delete the record anyway`);
@@ -2168,21 +2213,6 @@ var StorageOrchestratorService = class {
2168
2213
  occupancy
2169
2214
  } });
2170
2215
  }
2171
- if (successor !== void 0 && successor.isDefault !== true) {
2172
- const now = Date.now();
2173
- const promoted = {
2174
- ...successor,
2175
- isDefault: true,
2176
- updatedAt: now
2177
- };
2178
- this.locations.set(successor.id, promoted);
2179
- if (this.locationStore) this.locationStore.upsert(promoted).catch((err) => {
2180
- this.logger.error("storage-orchestrator: default promotion persist failed", { meta: {
2181
- id: successor.id,
2182
- error: err instanceof Error ? err.message : String(err)
2183
- } });
2184
- });
2185
- }
2186
2216
  this.locations.delete(id);
2187
2217
  if (this.locationStore) this.locationStore.delete(id).catch((err) => {
2188
2218
  this.logger.error("storage-orchestrator: delete persistence failed", { meta: {
@@ -2261,9 +2291,17 @@ var StorageOrchestratorService = class {
2261
2291
  if (!loc) throw new Error(`Storage location "${ref}" not found`);
2262
2292
  return loc;
2263
2293
  }
2264
- const def = this.getDefaultLocation(ref);
2265
- if (!def) throw new Error(`No default storage location configured for type "${ref}" — register one or pass a fully-qualified id (\`<type>:<slug>\`)`);
2266
- return def;
2294
+ const ofType = [...this.locations.values()].filter((loc) => loc.type === ref);
2295
+ if (ofType.length === 1) return ofType[0];
2296
+ if (ofType.length === 0) throw new Error(`No storage location configured for type "${ref}" — register one or pass a fully-qualified id (\`<type>:<slug>\`)`);
2297
+ const slugged = ofType.find((loc) => loc.id === `${ref}:default`);
2298
+ if (!slugged) throw new Error(`Storage type "${ref}" has ${String(ofType.length)} locations (${ofType.map((loc) => loc.id).toSorted().join(", ")}) — a bare type ref is ambiguous, pass a fully-qualified id (\`<type>:<slug>\`)`);
2299
+ this.logger.warn("storage-orchestrator: DEPRECATED bare-type ref on a type with several locations — resolved to the legacy `:default`-slugged row. Pass a fully-qualified location id.", { meta: {
2300
+ ref,
2301
+ resolved: slugged.id,
2302
+ candidates: ofType.length
2303
+ } });
2304
+ return slugged;
2267
2305
  }
2268
2306
  /**
2269
2307
  * Find the storage-provider that backs a given location — the entry point
@@ -2367,9 +2405,11 @@ var StorageOrchestratorService = class {
2367
2405
  * declarations on a populated map is a no-op (each declared `id`
2368
2406
  * already resolves to an existing `<id>:default`).
2369
2407
  *
2370
- * Convention: the bare-type ref `'recordings'` resolves via
2371
- * `getDefaultLocation('recordings')` (walks `isDefault === true`); the
2372
- * actual stored `id` is `'recordings:default'` so it satisfies the
2408
+ * The seeded row is born ENABLED — a fresh install must be able to write the
2409
+ * moment it boots, and `enabled` is the only write switch there is (D383).
2410
+ * The `:default` suffix is a NAME kept for idempotency across existing
2411
+ * installs, not a flag: the stored `id` is `'recordings:default'` so it
2412
+ * satisfies the
2373
2413
  * `^[a-z][a-z0-9-]*:[a-z0-9-]+$` regex on `StorageLocationSchema.id`.
2374
2414
  *
2375
2415
  * `basePath` is the storage root (e.g. `<CAMSTACK_DATA>`). Each
@@ -2394,7 +2434,7 @@ var StorageOrchestratorService = class {
2394
2434
  displayName: d.displayName,
2395
2435
  providerId: input.providerId,
2396
2436
  config: { basePath: base },
2397
- isDefault: true,
2437
+ enabled: true,
2398
2438
  isSystem: false
2399
2439
  });
2400
2440
  added++;
@@ -2418,7 +2458,7 @@ function resolveEnabled(input, existing, typeAlreadyHasLocation) {
2418
2458
  const requested = input.enabled ?? existing?.enabled;
2419
2459
  if (requested === void 0) {
2420
2460
  if (existing) return {};
2421
- return { enabled: input.isDefault || !typeAlreadyHasLocation };
2461
+ return { enabled: !typeAlreadyHasLocation };
2422
2462
  }
2423
2463
  return { enabled: requested };
2424
2464
  }
@@ -2427,8 +2467,7 @@ var OPTIONAL_WRITE_TYPES = new Set(["backups"]);
2427
2467
  /**
2428
2468
  * Refuse an UPDATE that would leave a write-serving type with no enabled
2429
2469
  * location. Creates are exempt (an explicit `enabled: false` on the first
2430
- * location is already a named test). `isDefault` is not consulted — enabled
2431
- * is the write set.
2470
+ * location is already a named test). Enabled IS the write set.
2432
2471
  */
2433
2472
  function refuseLastWriteTargetDisabled(existing, next, locations) {
2434
2473
  if (OPTIONAL_WRITE_TYPES.has(next.type)) return;
@@ -2441,6 +2480,23 @@ function refuseLastWriteTargetDisabled(existing, next, locations) {
2441
2480
  }
2442
2481
  throw new Error(`Cannot disable "${next.id}": it is the last enabled location of type "${next.type}". Enable another location of this type first.`);
2443
2482
  }
2483
+ /**
2484
+ * The same refusal, judged on a whole COMMIT rather than one row: a
2485
+ * multi-location change (a cutover enables its destination and disables its
2486
+ * source in one go) must be weighed on its RESULT, or the disable half looks
2487
+ * like the last write target leaving while the enable half is already in the
2488
+ * same map.
2489
+ */
2490
+ function refuseTypeWithoutWriteTarget(touched, result) {
2491
+ const rows = [...result];
2492
+ for (const type of new Set([...touched].map((loc) => loc.type))) {
2493
+ if (OPTIONAL_WRITE_TYPES.has(type)) continue;
2494
+ const ofType = rows.filter((loc) => loc.type === type);
2495
+ if (ofType.length === 0) continue;
2496
+ if (ofType.some((loc) => isLocationEnabled(loc))) continue;
2497
+ throw new Error(`Refusing the write-set change: it would leave type "${type}" with no enabled location. Enable one of ${ofType.map((loc) => loc.id).toSorted().join(", ")} first.`);
2498
+ }
2499
+ }
2444
2500
  //#endregion
2445
2501
  //#region src/builtins/storage-orchestrator/storage-pressure-manager.ts
2446
2502
  var DEFAULT_MAX_ROUNDS = 8;
@@ -2682,13 +2738,9 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2682
2738
  const rows = type !== void 0 ? service.listLocations({ type }) : service.listLocations();
2683
2739
  return Promise.all(rows.map(async (loc) => ({
2684
2740
  ...this.redacted(loc),
2685
- capacity: loc.enabled === false ? null : await this.localCapacityOf(loc)
2741
+ capacity: isLocationEnabled(loc) ? await this.localCapacityOf(loc) : null
2686
2742
  })));
2687
2743
  },
2688
- getDefaultLocation: async ({ type }) => {
2689
- const loc = service.getDefaultLocation(type);
2690
- return loc === null ? null : this.redacted(loc);
2691
- },
2692
2744
  listLocationDeclarations: async () => service.listDeclarations(),
2693
2745
  upsertLocation: async (input) => {
2694
2746
  const stored = service.getLocationById(input.id);