@objectstack/metadata 17.3.0 → 17.4.0

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.
package/dist/node.d.cts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { MetadataManager, MetadataManagerOptions, MetadataLoader, MetadataSerializer, MetadataKeyedItem } from './index.cjs';
2
- export { DatabaseLoader, DatabaseLoaderOptions, HistoryCleanupManager, JSONSerializer, MemoryLoader, MetadataPlugin, Migration, RemoteLoader, SerializeOptions, TypeScriptSerializer, WatchCallback, YAMLSerializer, calculateChecksum, deriveViewContainerObject, generateDiffSummary, generateSimpleDiff } from './index.cjs';
2
+ export { AMBIGUOUS_METADATA_STEM_CODE, AMBIGUOUS_METADATA_STEM_STATUS, AmbiguousMetadataStemError, DatabaseLoader, DatabaseLoaderOptions, HistoryCleanupManager, JSONSerializer, MemoryLoader, MetadataPlugin, Migration, RemoteLoader, SerializeOptions, TypeScriptSerializer, WatchCallback, YAMLSerializer, calculateChecksum, generateDiffSummary, generateSimpleDiff, isAmbiguousMetadataStemError } from './index.cjs';
3
3
  export { HistoryOptions, MetaRef, MetadataEvent, MetadataItem, MetadataItemHeader, MetadataRepository, SysMetadataHistoryObject, SysMetadataObject, WatchFilter } from '@objectstack/metadata-core';
4
+ export { deriveViewContainerObject } from './view-container.cjs';
4
5
  import { MetadataLoaderContract, MetadataFormat, MetadataLoadOptions, MetadataLoadResult, MetadataStats, MetadataSaveOptions, MetadataSaveResult } from '@objectstack/spec/system';
5
6
  export { MetadataCollectionInfo, MetadataDiffResult, MetadataFormat, MetadataHistoryQueryOptions, MetadataHistoryQueryResult, MetadataHistoryRecord, MetadataHistoryRetentionPolicy, MetadataLoadOptions, MetadataLoadResult, MetadataLoaderContract, MetadataManagerConfig, MetadataSaveOptions, MetadataSaveResult, MetadataStats, MetadataWatchEvent } from '@objectstack/spec/system';
6
7
  export { IMetadataService, MetadataExportOptions, MetadataImportOptions, MetadataImportResult, MetadataTypeInfo, MetadataWatchCallback, MetadataWatchHandle } from '@objectstack/spec/contracts';
@@ -171,6 +172,28 @@ declare class FilesystemLoader implements MetadataLoader {
171
172
  * key no other door can open.
172
173
  */
173
174
  private resolvableNameForPath;
175
+ /**
176
+ * [#14921] The names this loader reports for `files` — and the ONE place an
177
+ * ambiguous stem is refused.
178
+ *
179
+ * Shared by {@link list} and {@link loadManyEntries} so the two can never
180
+ * disagree about which trees are admissible: a stem that `list()` refuses
181
+ * must not still be walked and returned as two bodies by `loadMany()`, which
182
+ * is exactly the split this card measured.
183
+ *
184
+ * Refuses on the FIRST colliding name in sorted order, so a tree holding more
185
+ * than one collision always names the same one — a refusal that moves
186
+ * between runs reads as flakiness rather than as the fixed authoring error it
187
+ * is. Paths are deduplicated because two overlapping `patterns` legitimately
188
+ * match one file twice, and counting that as a collision would refuse a
189
+ * perfectly good tree.
190
+ *
191
+ * ⛔ Not a precedence resolver. Picking a winner here is what the ruling
192
+ * declined (option 2, keep the precedence and log): the loser would stay
193
+ * unreachable and the listed set would stay different from the addressable
194
+ * one.
195
+ */
196
+ private resolvableNames;
174
197
  /**
175
198
  * Find file for a given type and name
176
199
  */
package/dist/node.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { MetadataManager, MetadataManagerOptions, MetadataLoader, MetadataSerializer, MetadataKeyedItem } from './index.js';
2
- export { DatabaseLoader, DatabaseLoaderOptions, HistoryCleanupManager, JSONSerializer, MemoryLoader, MetadataPlugin, Migration, RemoteLoader, SerializeOptions, TypeScriptSerializer, WatchCallback, YAMLSerializer, calculateChecksum, deriveViewContainerObject, generateDiffSummary, generateSimpleDiff } from './index.js';
2
+ export { AMBIGUOUS_METADATA_STEM_CODE, AMBIGUOUS_METADATA_STEM_STATUS, AmbiguousMetadataStemError, DatabaseLoader, DatabaseLoaderOptions, HistoryCleanupManager, JSONSerializer, MemoryLoader, MetadataPlugin, Migration, RemoteLoader, SerializeOptions, TypeScriptSerializer, WatchCallback, YAMLSerializer, calculateChecksum, generateDiffSummary, generateSimpleDiff, isAmbiguousMetadataStemError } from './index.js';
3
3
  export { HistoryOptions, MetaRef, MetadataEvent, MetadataItem, MetadataItemHeader, MetadataRepository, SysMetadataHistoryObject, SysMetadataObject, WatchFilter } from '@objectstack/metadata-core';
4
+ export { deriveViewContainerObject } from './view-container.js';
4
5
  import { MetadataLoaderContract, MetadataFormat, MetadataLoadOptions, MetadataLoadResult, MetadataStats, MetadataSaveOptions, MetadataSaveResult } from '@objectstack/spec/system';
5
6
  export { MetadataCollectionInfo, MetadataDiffResult, MetadataFormat, MetadataHistoryQueryOptions, MetadataHistoryQueryResult, MetadataHistoryRecord, MetadataHistoryRetentionPolicy, MetadataLoadOptions, MetadataLoadResult, MetadataLoaderContract, MetadataManagerConfig, MetadataSaveOptions, MetadataSaveResult, MetadataStats, MetadataWatchEvent } from '@objectstack/spec/system';
6
7
  export { IMetadataService, MetadataExportOptions, MetadataImportOptions, MetadataImportResult, MetadataTypeInfo, MetadataWatchCallback, MetadataWatchHandle } from '@objectstack/spec/contracts';
@@ -171,6 +172,28 @@ declare class FilesystemLoader implements MetadataLoader {
171
172
  * key no other door can open.
172
173
  */
173
174
  private resolvableNameForPath;
175
+ /**
176
+ * [#14921] The names this loader reports for `files` — and the ONE place an
177
+ * ambiguous stem is refused.
178
+ *
179
+ * Shared by {@link list} and {@link loadManyEntries} so the two can never
180
+ * disagree about which trees are admissible: a stem that `list()` refuses
181
+ * must not still be walked and returned as two bodies by `loadMany()`, which
182
+ * is exactly the split this card measured.
183
+ *
184
+ * Refuses on the FIRST colliding name in sorted order, so a tree holding more
185
+ * than one collision always names the same one — a refusal that moves
186
+ * between runs reads as flakiness rather than as the fixed authoring error it
187
+ * is. Paths are deduplicated because two overlapping `patterns` legitimately
188
+ * match one file twice, and counting that as a collision would refuse a
189
+ * perfectly good tree.
190
+ *
191
+ * ⛔ Not a precedence resolver. Picking a winner here is what the ruling
192
+ * declined (option 2, keep the precedence and log): the loser would stay
193
+ * unreachable and the listed set would stay different from the addressable
194
+ * one.
195
+ */
196
+ private resolvableNames;
174
197
  /**
175
198
  * Find file for a given type and name
176
199
  */
package/dist/node.js CHANGED
@@ -606,7 +606,7 @@ async function _columnExists(exec, table, column) {
606
606
  // src/loaders/database-loader.ts
607
607
  function canonicalIsoInstant(value) {
608
608
  if (value === null || value === void 0) return void 0;
609
- if (value instanceof Date) return value.toISOString();
609
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? void 0 : value.toISOString();
610
610
  if (typeof value === "string") return value;
611
611
  return String(value);
612
612
  }
@@ -662,7 +662,7 @@ var DatabaseLoader = class {
662
662
  if (cacheEnabled) {
663
663
  const lruOpts = {
664
664
  maxSize: cacheOpts?.maxSize ?? 500,
665
- ttl: cacheOpts?.ttl ?? 6e4
665
+ ttl: cacheOpts?.ttlMs ?? 6e4
666
666
  };
667
667
  this.loadCache = new LRUCache(lruOpts);
668
668
  this.loadManyCache = new LRUCache(lruOpts);
@@ -1512,6 +1512,33 @@ function generateId() {
1512
1512
  return `meta_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
1513
1513
  }
1514
1514
 
1515
+ // src/loaders/ambiguous-metadata-stem.ts
1516
+ var AMBIGUOUS_METADATA_STEM_CODE = "AMBIGUOUS_METADATA_STEM";
1517
+ var AMBIGUOUS_METADATA_STEM_STATUS = 500;
1518
+ var AMBIGUOUS_METADATA_STEM_BRAND = /* @__PURE__ */ Symbol.for("objectstack.metadata.ambiguousStem");
1519
+ var _a, _b;
1520
+ var AmbiguousMetadataStemError = class extends (_b = Error, _a = AMBIGUOUS_METADATA_STEM_BRAND, _b) {
1521
+ constructor(type, stem, paths) {
1522
+ const sorted = [...paths].sort();
1523
+ super(
1524
+ `Ambiguous metadata name \`${stem}\` for type \`${type}\`: ${sorted.length} files resolve to the same name \u2014 ${sorted.map((p) => `\`${p}\``).join(", ")}. Only the first would ever be served (extension precedence: .json, .yaml, .yml, .ts, .js), so the others are listed and unreachable. Delete or rename all but one.`
1525
+ );
1526
+ /** Brand — see the module doc on why this is not `instanceof`. */
1527
+ this[_a] = true;
1528
+ /** ADR-0112 wire code. */
1529
+ this.code = AMBIGUOUS_METADATA_STEM_CODE;
1530
+ /** HTTP status a transport should answer. */
1531
+ this.status = AMBIGUOUS_METADATA_STEM_STATUS;
1532
+ this.name = "AmbiguousMetadataStemError";
1533
+ this.type = type;
1534
+ this.stem = stem;
1535
+ this.paths = sorted;
1536
+ }
1537
+ };
1538
+ function isAmbiguousMetadataStemError(err) {
1539
+ return typeof err === "object" && err !== null && err[AMBIGUOUS_METADATA_STEM_BRAND] === true;
1540
+ }
1541
+
1515
1542
  // src/endpoint-matcher.ts
1516
1543
  import {
1517
1544
  ApiEndpointSchema,
@@ -1669,6 +1696,8 @@ import {
1669
1696
  isAggregatedViewContainer
1670
1697
  } from "@objectstack/spec";
1671
1698
  import { applyProtection } from "@objectstack/spec/shared";
1699
+
1700
+ // src/view-container.ts
1672
1701
  function deriveViewContainerObject(container) {
1673
1702
  if (!container || typeof container !== "object") return void 0;
1674
1703
  const c = container;
@@ -1676,6 +1705,8 @@ function deriveViewContainerObject(container) {
1676
1705
  const byName = typeof c.name === "string" && c.name ? c.name : void 0;
1677
1706
  return own ?? c?.list?.data?.object ?? c?.form?.data?.object ?? byName;
1678
1707
  }
1708
+
1709
+ // src/view-container-expansion.ts
1679
1710
  function expandRuntimeViewContainer(data) {
1680
1711
  if (!isAggregatedViewContainer(data)) return [];
1681
1712
  const container = data;
@@ -2381,6 +2412,9 @@ var _MetadataManager = class _MetadataManager {
2381
2412
  await this.admitLoaderItems(loader, type, items);
2382
2413
  this.reportLoaderReadRecovered(loader.contract.name);
2383
2414
  } catch (e) {
2415
+ if (isAmbiguousMetadataStemError(e)) {
2416
+ throw e;
2417
+ }
2384
2418
  degraded = true;
2385
2419
  errors.push(`${loader.contract.name}: ${e instanceof Error ? e.message : String(e)}`);
2386
2420
  this.reportLoaderReadFailure(loader.contract.name, type, e);
@@ -2718,6 +2752,30 @@ var _MetadataManager = class _MetadataManager {
2718
2752
  }
2719
2753
  /**
2720
2754
  * List all names of metadata items of a given type
2755
+ *
2756
+ * ## [#14423] One loader's fault does not take the whole enumeration down
2757
+ *
2758
+ * This loop used to be bare — `const result = await loader.list(type)` with
2759
+ * no `try`, while the two sibling plural reads (`list()` via
2760
+ * {@link admitLoaderItems}, and {@link loadMany}) have carried a per-loader
2761
+ * `catch` since #5108. That asymmetry is the defect, independent of any one
2762
+ * caller: the SAME storage outage was swallowed by one plural read and
2763
+ * thrown out of the other, so which answer a caller got depended only on
2764
+ * which method it happened to call. A caller reading both — the action
2765
+ * governance audit is one — saw `loadMany` report a short-but-successful
2766
+ * set and `listNames` throw, and had no way to tell that one fact was
2767
+ * behind both.
2768
+ *
2769
+ * Same shape as `loadMany`'s, deliberately, down to the helpers: the outage
2770
+ * is spoken once per loader through {@link reportLoaderReadFailure} and
2771
+ * un-said through {@link reportLoaderReadRecovered}. ⛔ Not a third spelling
2772
+ * for "a loader faulted" — a second vocabulary for one event is how the two
2773
+ * reads drifted apart in the first place.
2774
+ *
2775
+ * The degradation is the same one `list()` documents and is graded the same
2776
+ * way (AGENTS.md → "Degradation log levels"): the caller still gets an
2777
+ * array, nothing 500s, and the set is quietly short — so it is reported at
2778
+ * `error`, by the shared helper, rather than being re-graded here.
2721
2779
  */
2722
2780
  async listNames(type) {
2723
2781
  type = canonicalMetadataServiceType(type);
@@ -2729,8 +2787,16 @@ var _MetadataManager = class _MetadataManager {
2729
2787
  }
2730
2788
  }
2731
2789
  for (const loader of this.loaders.values()) {
2732
- const result = await loader.list(type);
2733
- result.forEach((item) => names.add(item));
2790
+ try {
2791
+ const result = await loader.list(type);
2792
+ result.forEach((item) => names.add(item));
2793
+ this.reportLoaderReadRecovered(loader.contract.name);
2794
+ } catch (e) {
2795
+ if (isAmbiguousMetadataStemError(e)) {
2796
+ throw e;
2797
+ }
2798
+ this.reportLoaderReadFailure(loader.contract.name, type, e);
2799
+ }
2734
2800
  }
2735
2801
  return Array.from(names);
2736
2802
  }
@@ -3590,6 +3656,99 @@ var _MetadataManager = class _MetadataManager {
3590
3656
  }
3591
3657
  return results;
3592
3658
  }
3659
+ /**
3660
+ * [#14423] {@link loadMany}, read under the identity the STORE holds each
3661
+ * item by — the keyed plural read, beside the unkeyed one.
3662
+ *
3663
+ * ## Why a second method and not a widened `loadMany`
3664
+ *
3665
+ * `loadMany` keys nothing: it returns bodies, and every consumer that needs
3666
+ * an identity reads `body.name` off them. #14205 already ruled what identity
3667
+ * IS — the key the store holds the item under (`register(type, name, data)`
3668
+ * takes it as the ARGUMENT, and a body is not required to name itself) — so
3669
+ * `body.name` is a guess that happens to be right for most items and drops
3670
+ * the rest ENTIRELY: an item whose body carries no `name` is served by
3671
+ * `load(type, name)` and is not nameable from `loadMany`'s answer at all.
3672
+ *
3673
+ * Widening `loadMany`'s return would fix that and break every consumer of a
3674
+ * published shape (the ones counted on this card all read `body.name` as the
3675
+ * identity). So this is additive: `loadMany`'s return shape is untouched,
3676
+ * and a caller that needs the key asks for the key.
3677
+ *
3678
+ * ## What it reads — the same population `loadMany` reads
3679
+ *
3680
+ * Loaders only, deliberately, so this is `loadMany` keyed and nothing more.
3681
+ * It is NOT `list()`/{@link listNames}, which also merge the in-memory
3682
+ * `register()` registry; a caller wanting that set has those. Reading the
3683
+ * loaders alone is also what makes this the enumerable twin of
3684
+ * {@link loadDiagnosed}, which walks the same loaders by name — that pairing
3685
+ * is the point on the audit side of #14423, where an enumeration and a
3686
+ * by-name read that disagree about a population make one subsystem accuse
3687
+ * another of a defect neither has.
3688
+ *
3689
+ * ## Delegate first, fall back second — and why that order is not a style
3690
+ *
3691
+ * Per loader: {@link MetadataLoader.loadManyKeyed} where the loader offers
3692
+ * one, else its `list()` + a per-name `load()`. Measured, on
3693
+ * `DatabaseLoader`: the keyed method shares `loadMany`'s single query
3694
+ * (`{find:1, findOne:0}` — zero extra cost), while enumerate-then-read-each
3695
+ * on that same loader is a real N+1 (`{find:1, findOne:5}` for five items).
3696
+ * The fallback exists for loaders that cannot produce keys at all
3697
+ * (`RemoteLoader`'s wire format carries bodies only), and it recovers the
3698
+ * nameless item the pre-#14205 `loadMany`-and-key-by-`body.name` fallback
3699
+ * drops — which is why it is `list()` + `load()` and not `loadMany()`.
3700
+ *
3701
+ * ## Failure posture
3702
+ *
3703
+ * Per-loader `try`/`catch`, the same seam and the same helpers as
3704
+ * {@link loadMany} and `list()` — one loader's outage does not take the
3705
+ * enumeration down, and it is reported once through
3706
+ * {@link reportLoaderReadFailure} rather than in a third vocabulary.
3707
+ * Earlier loaders win a key collision, mirroring `list()`.
3708
+ */
3709
+ async loadManyKeyed(type, options) {
3710
+ const items = /* @__PURE__ */ new Map();
3711
+ for (const loader of this.loaders.values()) {
3712
+ try {
3713
+ await this.admitKeyedLoaderItems(loader, type, items, options);
3714
+ this.reportLoaderReadRecovered(loader.contract.name);
3715
+ } catch (e) {
3716
+ this.reportLoaderReadFailure(loader.contract.name, type, e);
3717
+ }
3718
+ }
3719
+ return Array.from(items, ([name, data]) => ({ name, data }));
3720
+ }
3721
+ /**
3722
+ * Merge ONE loader's answer for `type` into `items`, keyed by that loader's
3723
+ * own key for each item — {@link loadManyKeyed}'s per-loader body.
3724
+ *
3725
+ * Distinct from {@link admitLoaderItems} on exactly one axis, and that axis
3726
+ * is the whole of #14423: the fallback for a loader with no
3727
+ * `loadManyKeyed`. `admitLoaderItems` falls back to `loadMany` keyed by
3728
+ * `data.name` — the pre-#14205 behaviour, verbatim, which drops a nameless
3729
+ * body. Here the fallback is `list()` + a per-name `load()`, so a loader
3730
+ * that cannot enumerate keys and bodies together still answers with both.
3731
+ *
3732
+ * Read failures are NOT caught here — the caller owns that verdict, as in
3733
+ * {@link admitLoaderItems}.
3734
+ */
3735
+ async admitKeyedLoaderItems(loader, type, items, options) {
3736
+ if (typeof loader.loadManyKeyed === "function") {
3737
+ const keyed = await loader.loadManyKeyed(type, options);
3738
+ for (const entry of keyed) {
3739
+ if (!entry || typeof entry.name !== "string" || entry.name === "") continue;
3740
+ if (items.has(entry.name)) continue;
3741
+ items.set(entry.name, entry.data);
3742
+ }
3743
+ return;
3744
+ }
3745
+ for (const name of await loader.list(type)) {
3746
+ if (typeof name !== "string" || name === "" || items.has(name)) continue;
3747
+ const result = await loader.load(type, name, options);
3748
+ if (result?.data == null) continue;
3749
+ items.set(name, result.data);
3750
+ }
3751
+ }
3593
3752
  /**
3594
3753
  * Save metadata item to a loader
3595
3754
  */
@@ -4222,33 +4381,34 @@ var _FilesystemLoader = class _FilesystemLoader {
4222
4381
  const globPatterns = patterns.map(
4223
4382
  (pattern) => path.join(typeDir, pattern)
4224
4383
  );
4384
+ const files = [];
4225
4385
  for (const pattern of globPatterns) {
4226
- const files = await glob(pattern, {
4227
- ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*", "**/*[*]*"],
4228
- nodir: true
4229
- });
4230
- for (const file of files) {
4231
- if (limit && items.length >= limit) {
4232
- break;
4233
- }
4234
- try {
4235
- const content = await fs.readFile(file, "utf-8");
4236
- const format = this.detectFormat(file);
4237
- const serializer = this.getSerializer(format);
4238
- if (serializer) {
4239
- const data = serializer.deserialize(content);
4240
- items.push({ file, data });
4241
- }
4242
- } catch (error) {
4243
- this.logger?.warn("Failed to load file", {
4244
- file,
4245
- error: error instanceof Error ? error.message : String(error)
4246
- });
4247
- }
4248
- }
4386
+ files.push(
4387
+ ...await glob(pattern, {
4388
+ ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*", "**/*[*]*"],
4389
+ nodir: true
4390
+ })
4391
+ );
4392
+ }
4393
+ this.resolvableNames(type, typeDir, files);
4394
+ for (const file of files) {
4249
4395
  if (limit && items.length >= limit) {
4250
4396
  break;
4251
4397
  }
4398
+ try {
4399
+ const content = await fs.readFile(file, "utf-8");
4400
+ const format = this.detectFormat(file);
4401
+ const serializer = this.getSerializer(format);
4402
+ if (serializer) {
4403
+ const data = serializer.deserialize(content);
4404
+ items.push({ file, data });
4405
+ }
4406
+ } catch (error) {
4407
+ this.logger?.warn("Failed to load file", {
4408
+ file,
4409
+ error: error instanceof Error ? error.message : String(error)
4410
+ });
4411
+ }
4252
4412
  }
4253
4413
  return items;
4254
4414
  } catch (error) {
@@ -4317,13 +4477,13 @@ var _FilesystemLoader = class _FilesystemLoader {
4317
4477
  */
4318
4478
  async list(type) {
4319
4479
  const typeDir = path.join(this.rootDir, type);
4480
+ let files;
4320
4481
  try {
4321
- const files = await glob("**/*", {
4482
+ files = await glob("**/*", {
4322
4483
  cwd: typeDir,
4323
4484
  ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*"],
4324
4485
  nodir: true
4325
4486
  });
4326
- return files.map((file) => this.resolvableNameForPath(typeDir, path.join(typeDir, file))).filter((name) => name !== null);
4327
4487
  } catch (error) {
4328
4488
  this.logger?.error("Failed to list", void 0, {
4329
4489
  type,
@@ -4331,6 +4491,7 @@ var _FilesystemLoader = class _FilesystemLoader {
4331
4491
  });
4332
4492
  return [];
4333
4493
  }
4494
+ return this.resolvableNames(type, typeDir, files.map((file) => path.join(typeDir, file)));
4334
4495
  }
4335
4496
  async save(type, name, data, options) {
4336
4497
  const startTime = Date.now();
@@ -4455,6 +4616,49 @@ var _FilesystemLoader = class _FilesystemLoader {
4455
4616
  }
4456
4617
  return _FilesystemLoader.nameFromFilename(rel);
4457
4618
  }
4619
+ /**
4620
+ * [#14921] The names this loader reports for `files` — and the ONE place an
4621
+ * ambiguous stem is refused.
4622
+ *
4623
+ * Shared by {@link list} and {@link loadManyEntries} so the two can never
4624
+ * disagree about which trees are admissible: a stem that `list()` refuses
4625
+ * must not still be walked and returned as two bodies by `loadMany()`, which
4626
+ * is exactly the split this card measured.
4627
+ *
4628
+ * Refuses on the FIRST colliding name in sorted order, so a tree holding more
4629
+ * than one collision always names the same one — a refusal that moves
4630
+ * between runs reads as flakiness rather than as the fixed authoring error it
4631
+ * is. Paths are deduplicated because two overlapping `patterns` legitimately
4632
+ * match one file twice, and counting that as a collision would refuse a
4633
+ * perfectly good tree.
4634
+ *
4635
+ * ⛔ Not a precedence resolver. Picking a winner here is what the ruling
4636
+ * declined (option 2, keep the precedence and log): the loser would stay
4637
+ * unreachable and the listed set would stay different from the addressable
4638
+ * one.
4639
+ */
4640
+ resolvableNames(type, typeDir, files) {
4641
+ const byName = /* @__PURE__ */ new Map();
4642
+ for (const file of files) {
4643
+ const name = this.resolvableNameForPath(typeDir, file);
4644
+ if (name === null) {
4645
+ continue;
4646
+ }
4647
+ let paths = byName.get(name);
4648
+ if (!paths) {
4649
+ paths = /* @__PURE__ */ new Set();
4650
+ byName.set(name, paths);
4651
+ }
4652
+ paths.add(file);
4653
+ }
4654
+ for (const name of [...byName.keys()].sort()) {
4655
+ const paths = byName.get(name);
4656
+ if (paths.size > 1) {
4657
+ throw new AmbiguousMetadataStemError(type, name, [...paths]);
4658
+ }
4659
+ }
4660
+ return [...byName.keys()];
4661
+ }
4458
4662
  /**
4459
4663
  * Find file for a given type and name
4460
4664
  */
@@ -5552,9 +5756,43 @@ var RemoteLoader = class {
5552
5756
  format: "json"
5553
5757
  };
5554
5758
  }
5759
+ /**
5760
+ * [#15037] Report only the names that ARE names.
5761
+ *
5762
+ * This read used to be `loadMany<{ name: string }>(type)` mapped straight to
5763
+ * `items.map(i => i.name)`. That type argument is an ASSERTION about bodies
5764
+ * that arrived over HTTP, and nothing checked it: a body with no top-level
5765
+ * `name` yielded `undefined`, which went into an array this signature
5766
+ * declares as `string[]` and reached consumers through
5767
+ * `MetadataManager.listNames()` — a runtime violation of a declared type,
5768
+ * not an untidy entry. A consumer that keys by it, lower-cases it, or feeds
5769
+ * it back to a by-name `load()` gets `undefined` where the type says it
5770
+ * cannot be.
5771
+ *
5772
+ * The guard is `DatabaseLoader.list()`'s, one file away: same cast-then-map
5773
+ * spelling, one `typeof` filter behind it. Silently dropping is the landed
5774
+ * direction, not a preference — `DatabaseLoader` drops rather than throws,
5775
+ * and `FilesystemLoader`'s narrowing carries a maintainer ruling (via the
5776
+ * director seat on #14486, 2026-09-02) that chose narrowing (A) over
5777
+ * refusing loudly (B), because a name in the list that the door answers
5778
+ * `null` for is the silent failure an author reads as their own typo. An
5779
+ * `undefined` here is the extreme form of that name.
5780
+ *
5781
+ * ⛔ NOT copied from the siblings: `MemoryLoader` answers with its store
5782
+ * keys, and #14205 ruled that identity is the key the store holds an item
5783
+ * under rather than `body.name`. This loader reads over HTTP and holds no
5784
+ * store key, so `body.name` is the only identity it has — the list is
5785
+ * narrowed to agree with the door instead. `loadMany()` is deliberately
5786
+ * untouched: it keys nothing, so a nameless body is still served there.
5787
+ *
5788
+ * The predicate is spelled as a type guard, and the mapped element type left
5789
+ * `unknown`, so `tsc` PROVES the declared `string[]` instead of a cast
5790
+ * asserting it — otherwise the compiler reads the filter as always-true and
5791
+ * a later reader deletes it as dead.
5792
+ */
5555
5793
  async list(type) {
5556
5794
  const items = await this.loadMany(type);
5557
- return items.map((i) => i.name);
5795
+ return items.map((item) => item.name).filter((name) => typeof name === "string");
5558
5796
  }
5559
5797
  async save(type, name, data, _options) {
5560
5798
  const response = await fetch(`${this.baseUrl}/${type}/${name}`, {
@@ -5594,9 +5832,9 @@ var HistoryCleanupManager = class {
5594
5832
  return;
5595
5833
  }
5596
5834
  const intervalMs = (this.policy.cleanupIntervalHours ?? 24) * 60 * 60 * 1e3;
5597
- void this.runCleanup();
5835
+ void runCleanupAndReport(this);
5598
5836
  this.cleanupTimer = setInterval(() => {
5599
- void this.runCleanup();
5837
+ void runCleanupAndReport(this);
5600
5838
  }, intervalMs);
5601
5839
  }
5602
5840
  /**
@@ -5623,7 +5861,7 @@ var HistoryCleanupManager = class {
5623
5861
  try {
5624
5862
  if (this.policy.maxAgeDays) {
5625
5863
  const cutoffDate = /* @__PURE__ */ new Date();
5626
- cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);
5864
+ cutoffDate.setUTCDate(cutoffDate.getUTCDate() - this.policy.maxAgeDays);
5627
5865
  const cutoffISO = cutoffDate.toISOString();
5628
5866
  const filter = {
5629
5867
  recorded_at: { $lt: cutoffISO }
@@ -5743,7 +5981,7 @@ var HistoryCleanupManager = class {
5743
5981
  if (organizationId) baseWhere.organization_id = organizationId;
5744
5982
  if (this.policy.maxAgeDays) {
5745
5983
  const cutoffDate = /* @__PURE__ */ new Date();
5746
- cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);
5984
+ cutoffDate.setUTCDate(cutoffDate.getUTCDate() - this.policy.maxAgeDays);
5747
5985
  const cutoffISO = cutoffDate.toISOString();
5748
5986
  const filter = {
5749
5987
  recorded_at: { $lt: cutoffISO },
@@ -5790,6 +6028,23 @@ var HistoryCleanupManager = class {
5790
6028
  };
5791
6029
  }
5792
6030
  };
6031
+ async function runCleanupAndReport(manager) {
6032
+ let outcome;
6033
+ try {
6034
+ outcome = await manager.runCleanup();
6035
+ } catch (error) {
6036
+ console.error(
6037
+ "History cleanup: the run did not complete, so no history row past the retention policy was deleted and the table keeps growing while the system reports healthy. Fix: the cause below comes from the configured data driver, not from the retention policy; call `runCleanup()` directly to reproduce it. Cause:",
6038
+ error
6039
+ );
6040
+ return;
6041
+ }
6042
+ if (outcome.errors > 0) {
6043
+ console.error(
6044
+ `History cleanup: ${outcome.errors} delete operation(s) failed and ${outcome.deleted} row(s) were deleted. The history rows those deletes were meant to remove are still in the table, nothing retries them, and the table grows past the retention policy while the system keeps reporting healthy. Fix: check the data driver delete path for the metadata history table. The per-failure causes are not carried out of \`runCleanup()\`, so reproduce them against the driver directly.`
6045
+ );
6046
+ }
6047
+ }
5793
6048
 
5794
6049
  // src/migration/index.ts
5795
6050
  var migration_exports = {};
@@ -5847,6 +6102,9 @@ var MigrationExecutor = class {
5847
6102
  }
5848
6103
  };
5849
6104
  export {
6105
+ AMBIGUOUS_METADATA_STEM_CODE,
6106
+ AMBIGUOUS_METADATA_STEM_STATUS,
6107
+ AmbiguousMetadataStemError,
5850
6108
  DatabaseLoader,
5851
6109
  FilesystemLoader,
5852
6110
  HistoryCleanupManager,
@@ -5864,6 +6122,7 @@ export {
5864
6122
  calculateChecksum,
5865
6123
  deriveViewContainerObject,
5866
6124
  generateDiffSummary,
5867
- generateSimpleDiff
6125
+ generateSimpleDiff,
6126
+ isAmbiguousMetadataStemError
5868
6127
  };
5869
6128
  //# sourceMappingURL=node.js.map