@objectstack/metadata 17.0.0-rc.5 → 17.0.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.cjs CHANGED
@@ -555,6 +555,7 @@ var LRUCache = class {
555
555
  };
556
556
 
557
557
  // src/utils/schema-sync-errors.ts
558
+ var import_types = require("@objectstack/types");
558
559
  var ALREADY_EXISTS = {
559
560
  codes: /* @__PURE__ */ new Set([
560
561
  // PostgreSQL SQLSTATE (class 42 — syntax error or access rule violation)
@@ -598,14 +599,67 @@ var MISSING_TABLE = {
598
599
  * - PostgreSQL: `relation "sys_metadata_history" does not exist`
599
600
  * - MySQL/MariaDB: `Table 'app.sys_metadata_history' doesn't exist`
600
601
  */
601
- message: /no such table|relation ["'`][^"'`]+["'`] does not exist|table ["'`][^"'`]+["'`] doesn'?t exist|unknown table/i
602
+ message: /no such table|relation ["'`][^"'`]+["'`] does not exist|table ["'`][^"'`]+["'`] doesn'?t exist|unknown table/i,
603
+ excludes: {
604
+ /**
605
+ * Exactly the three SQLSTATEs the docblock above already names as
606
+ * must-stay-loud neighbours of `does not exist`. They are listed here
607
+ * rather than merely trusted to miss the message test, because two of
608
+ * them (42703 columns, 42704 constraints/triggers) have a phrasing that
609
+ * *does* hit it, and because a code is a fact where prose is a guess.
610
+ *
611
+ * Postgres-shaped on purpose: measured, neither MySQL
612
+ * (`Unknown column 'label' in 'field list'`) nor SQLite
613
+ * (`no such column: bogus`, `table t has no column named label`)
614
+ * phrases a sub-object failure so that a missing-table phrase falls out
615
+ * of it, so there is nothing there to exclude. Adding their codes would
616
+ * be surface with no defect behind it.
617
+ */
618
+ codes: /* @__PURE__ */ new Set([
619
+ "42703",
620
+ // undefined_column
621
+ "42704",
622
+ // undefined_object — constraint, trigger, role, type, …
623
+ "3D000"
624
+ // invalid_catalog_name — `database "x" does not exist`
625
+ ]),
626
+ /**
627
+ * `«sub-object» "x" of relation "y" …` — Postgres' phrasing for a
628
+ * failure about something *inside* a relation, which therefore says the
629
+ * relation itself is present. The two in-repo siblings that carry this
630
+ * phrase are `mapDataError` (`packages/rest`, #5352) and
631
+ * `service-analytics`'s missing-column subtraction (#6035/PR #6346).
632
+ *
633
+ * [#6615] All three now read one home — `@objectstack/types` — instead
634
+ * of three hand-kept copies, so the phrase can no longer be taught to
635
+ * the repo a fourth time or drift in one package only. The **width**
636
+ * difference that used to justify the copy is preserved and is the
637
+ * reason the home exports two functions rather than one: those two
638
+ * *extract* the column name to phrase a better error, so a miss costs a
639
+ * vaguer message; this one *excludes*, so a miss restores the
640
+ * corruption. {@link isRelationSubObjectPhrase} is therefore the wider
641
+ * question — it drops their `column`/`[a-z0-9_]+`/`does not exist`
642
+ * anchors: any sub-object, any quoted identifier, any verdict.
643
+ * Over-matching here only ever converts a benign verdict into a loud
644
+ * one, which is the direction this whole module already errs in.
645
+ */
646
+ matchesMessage: import_types.isRelationSubObjectPhrase
647
+ }
602
648
  };
603
649
  var MAX_CAUSE_DEPTH = 4;
604
650
  function matchesDriverError(error, signature, depth) {
605
651
  if (error === null || error === void 0 || depth > MAX_CAUSE_DEPTH) return false;
606
- if (typeof error === "string") return signature.message.test(error);
652
+ if (typeof error === "string") {
653
+ if (signature.excludes?.matchesMessage(error)) return false;
654
+ return signature.message.test(error);
655
+ }
607
656
  if (typeof error !== "object") return false;
608
657
  const err = error;
658
+ const excludes = signature.excludes;
659
+ if (excludes) {
660
+ if (typeof err.code === "string" && excludes.codes.has(err.code)) return false;
661
+ if (typeof err.message === "string" && excludes.matchesMessage(err.message)) return false;
662
+ }
609
663
  if (typeof err.code === "string" && signature.codes.has(err.code)) return true;
610
664
  if (typeof err.errno === "number" && signature.errnos.has(err.errno)) return true;
611
665
  if (typeof err.message === "string" && signature.message.test(err.message)) return true;
@@ -618,48 +672,6 @@ function isMissingTableError(error, depth = 0) {
618
672
  return matchesDriverError(error, MISSING_TABLE, depth);
619
673
  }
620
674
 
621
- // src/migrations/add-sys-metadata-overlay-index.ts
622
- var INDEX_NAME = "idx_sys_metadata_overlay_active";
623
- var TABLE = "sys_metadata";
624
- var COLUMNS = "(type, name, organization_id, environment_id, scope)";
625
- var WHERE = "state = 'active'";
626
- async function addSysMetadataOverlayIndex(driver) {
627
- const driverAny = driver;
628
- const exec = async (sql) => {
629
- if (typeof driverAny.raw === "function") {
630
- await driverAny.raw(sql);
631
- } else if (typeof driverAny.execute === "function") {
632
- await driverAny.execute(sql);
633
- } else {
634
- throw new Error("driver has neither raw nor execute");
635
- }
636
- };
637
- const partialSql = `CREATE UNIQUE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS} WHERE ${WHERE}`;
638
- const fallbackSql = `CREATE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS}`;
639
- try {
640
- await exec(partialSql);
641
- return { index: INDEX_NAME, status: "created" };
642
- } catch (err) {
643
- const msg = err instanceof Error ? err.message : String(err);
644
- if (/partial|where clause|syntax/i.test(msg)) {
645
- try {
646
- await exec(fallbackSql);
647
- return { index: INDEX_NAME, status: "fallback_non_unique" };
648
- } catch (fallbackErr) {
649
- return {
650
- index: INDEX_NAME,
651
- status: "error",
652
- error: fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)
653
- };
654
- }
655
- }
656
- if (/already exists/i.test(msg)) {
657
- return { index: INDEX_NAME, status: "already_exists" };
658
- }
659
- return { index: INDEX_NAME, status: "error", error: msg };
660
- }
661
- }
662
-
663
675
  // src/migrations/migrate-project-id-to-environment-id.ts
664
676
  var AFFECTED_TABLES = [
665
677
  "sys_metadata",
@@ -809,23 +821,40 @@ var DatabaseLoader = class {
809
821
  // ==========================================
810
822
  // Internal CRUD helpers (driver vs engine)
811
823
  // ==========================================
824
+ // NOTE (#6231, closed out by #7178): BOTH branches below now take `query`
825
+ // unchanged and uncast. `DriverQuery` is `Omit<QueryAST, 'object'>`, so the
826
+ // object name travels as argument one only — that was always enough for the
827
+ // driver branch. The ENGINE branch used to carry `as any`, for one reason:
828
+ // `EngineQueryOptionsSchema.search` admitted only the structured
829
+ // `FullTextSearchSchema`, while `QueryAST.search` (hence `DriverQuery`) also
830
+ // admits the bare query string that ADR-0061 D1 calls the canonical Tier-1
831
+ // spelling and that the engine actually serves, so `DriverQuery` was not
832
+ // assignable to `EngineQueryOptionsParsed`. #7178 aligned the two schemas;
833
+ // the casts are now genuinely vestigial and are gone, which restores real
834
+ // `where`/`orderBy`/`fields` checking on the metadata main read path — this
835
+ // schema is not `.strict()`, so an unknown key here is SILENTLY DROPPED
836
+ // (`check:query-options-erasure`'s own rationale) and the erased type was
837
+ // the only thing standing between a typo and that silence.
838
+ //
839
+ // If a future edit makes one of these stop compiling, the honest fix is to
840
+ // reconcile the two schemas again — not to reinstate the cast.
812
841
  async _find(table, query) {
813
842
  if (this.engine) {
814
843
  return this.engine.find(table, query);
815
844
  }
816
- return this.driver.find(table, { object: table, ...query });
845
+ return this.driver.find(table, query);
817
846
  }
818
847
  async _findOne(table, query) {
819
848
  if (this.engine) {
820
849
  return this.engine.findOne(table, query);
821
850
  }
822
- return this.driver.findOne(table, { object: table, ...query });
851
+ return this.driver.findOne(table, query);
823
852
  }
824
853
  async _count(table, query) {
825
854
  if (this.engine) {
826
855
  return this.engine.count(table, query);
827
856
  }
828
- return this.driver.count(table, { object: table, ...query });
857
+ return this.driver.count(table, query);
829
858
  }
830
859
  async _create(table, data) {
831
860
  if (this.engine) {
@@ -907,9 +936,12 @@ var DatabaseLoader = class {
907
936
  }
908
937
  if (driver) {
909
938
  await migrateProjectIdToEnvironmentId(driver).catch(() => void 0);
910
- await addSysMetadataOverlayIndex(driver);
911
939
  }
912
- } catch {
940
+ } catch (error) {
941
+ console.warn(
942
+ `[Metadata] Could not resolve a raw-SQL driver from the engine for \`${this.tableName}\` \u2014 the project_id\u2192environment_id forward migration was SKIPPED. Legacy rows (if any) keep the pre-v5.0 column and read back as unset. Metadata reads and writes are otherwise unaffected. Re-run it explicitly with \`migrateProjectIdToEnvironmentId(driver)\` from \`@objectstack/metadata/migrations\` once the datasource is reachable.`,
943
+ error
944
+ );
913
945
  }
914
946
  return;
915
947
  }
@@ -941,10 +973,6 @@ var DatabaseLoader = class {
941
973
  await migrateProjectIdToEnvironmentId(this.driver);
942
974
  } catch {
943
975
  }
944
- try {
945
- await addSysMetadataOverlayIndex(this.driver);
946
- } catch {
947
- }
948
976
  }
949
977
  /**
950
978
  * Ensure the history table exists.
@@ -1552,6 +1580,60 @@ function generateId() {
1552
1580
 
1553
1581
  // src/endpoint-matcher.ts
1554
1582
  var import_api = require("@objectstack/spec/api");
1583
+
1584
+ // src/stored-envelope.ts
1585
+ var STORED_ENVELOPE_KEYS = Object.freeze([
1586
+ "package",
1587
+ "packageId",
1588
+ "publishedAt",
1589
+ "publishedBy",
1590
+ "publishedDefinition",
1591
+ "state",
1592
+ "version"
1593
+ ]);
1594
+ var STORED_BODY_KEY = "metadata";
1595
+ var ENVELOPE_KEYS = /* @__PURE__ */ new Set([...STORED_ENVELOPE_KEYS, STORED_BODY_KEY]);
1596
+ var EMPTY_ENVELOPE = Object.freeze({});
1597
+ function peelStoredEnvelope(item) {
1598
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
1599
+ return { envelope: EMPTY_ENVELOPE, body: item, wrapped: false };
1600
+ }
1601
+ const row = item;
1602
+ const wrappedBody = row[STORED_BODY_KEY];
1603
+ if (wrappedBody !== void 0 && wrappedBody !== null) {
1604
+ const envelope2 = {};
1605
+ for (const key of Object.keys(row)) {
1606
+ if (key === STORED_BODY_KEY) continue;
1607
+ envelope2[key] = row[key];
1608
+ }
1609
+ return { envelope: Object.freeze(envelope2), body: wrappedBody, wrapped: true };
1610
+ }
1611
+ let envelope;
1612
+ for (const key of Object.keys(row)) {
1613
+ if (!ENVELOPE_KEYS.has(key)) continue;
1614
+ envelope ?? (envelope = {});
1615
+ envelope[key] = row[key];
1616
+ }
1617
+ if (!envelope) return { envelope: EMPTY_ENVELOPE, body: row, wrapped: false };
1618
+ const body = {};
1619
+ for (const key of Object.keys(row)) {
1620
+ if (ENVELOPE_KEYS.has(key)) continue;
1621
+ body[key] = row[key];
1622
+ }
1623
+ return { envelope: Object.freeze(envelope), body, wrapped: false };
1624
+ }
1625
+ function storedItemName(peeled) {
1626
+ const fromEnvelope = peeled.envelope.name;
1627
+ if (typeof fromEnvelope === "string") return fromEnvelope;
1628
+ const body = peeled.body;
1629
+ if (body && typeof body === "object" && !Array.isArray(body)) {
1630
+ const fromBody = body.name;
1631
+ if (typeof fromBody === "string") return fromBody;
1632
+ }
1633
+ return void 0;
1634
+ }
1635
+
1636
+ // src/endpoint-matcher.ts
1555
1637
  function normalizeEndpointMethod(method) {
1556
1638
  return String(method ?? "").toUpperCase();
1557
1639
  }
@@ -1561,9 +1643,10 @@ function endpointIndexKey(method, path3) {
1561
1643
  function buildEndpointIndex(items, logger) {
1562
1644
  const index = /* @__PURE__ */ new Map();
1563
1645
  for (const item of items) {
1564
- const parsed = import_api.ApiEndpointSchema.safeParse(item);
1646
+ const peeled = peelStoredEnvelope(item);
1647
+ const parsed = import_api.ApiEndpointSchema.safeParse(peeled.body);
1565
1648
  if (!parsed.success) {
1566
- const declaredName = item && typeof item === "object" && typeof item.name === "string" ? item.name : "<unnamed>";
1649
+ const declaredName = storedItemName(peeled) ?? "<unnamed>";
1567
1650
  logger.error(
1568
1651
  `[EndpointMatcher] stored api item '${declaredName}' does not satisfy ApiEndpointSchema \u2014 it is EXCLUDED from endpoint matching and its declared route will answer 404. Fix the declaration (or remove it); the endpoint index never serves a half-valid shape.`,
1569
1652
  void 0,
@@ -1702,16 +1785,40 @@ var _MetadataManager = class _MetadataManager {
1702
1785
  // above. The concurrent half is delivered by `inflightListReads` below, which
1703
1786
  // is why the two fields are one policy and are documented together.
1704
1787
  //
1705
- // [#5184] That hazard is NOT historical — it was re-verified on the current
1706
- // driver stack before this policy was chosen. `DatabaseLoader._find()` still
1707
- // issues `engine.find('sys_metadata', …)` without threading the caller's
1708
- // transaction, and `driver-sql` still treats SQLite as a single-connection
1709
- // pool (`activeTransactions`, `assertBareKnexSafe` the latter a dev/test
1710
- // guard that is a no-op in production, so production still waits the timeout
1711
- // out). `plugin-audit`'s `captureBefore` threads the transaction by hand for
1712
- // exactly this reason. Hence the policy below keeps caching degraded reads
1713
- // rather than skipping them: "don't cache a degraded read" would trade one
1714
- // 30s silent window for a fresh 60s stall per call.
1788
+ // [#5184; re-measured under #7708 on 2026-08-11] That hazard is NOT
1789
+ // historical and the re-measurement NARROWED it rather than retiring it.
1790
+ // Measured on the current stack: real `ObjectQL` + real `SqlDriver`
1791
+ // (better-sqlite3), a real `DatabaseLoader.list()` with the loader's own
1792
+ // cache off, `knex.client.pool.max === 1` confirmed for the SQLite dialect
1793
+ // and `acquireConnectionTimeout` left at the knex default of 60s.
1794
+ //
1795
+ // Transaction opened DIRECTLY on the driver (`driver.beginTransaction()`)
1796
+ // the read STALLS for the full timeout and then throws knex's "Timeout
1797
+ // acquiring a connection" (measured: 60_085ms). `DatabaseLoader._find()`
1798
+ // forwards no options, so nothing threads the caller's transaction, and
1799
+ // `driver-sql` still models SQLite as a single-connection pool
1800
+ // (`activeTransactions`, `assertBareKnexSafe` — the latter a dev/test
1801
+ // guard that is a no-op in production, so production still waits the
1802
+ // timeout out). `list()` catches that throw and degrades, which is
1803
+ // precisely the entry whose TTL this policy is choosing.
1804
+ // • Transaction opened through `engine.transaction()` / `ScopedContext`
1805
+ // → returns immediately (measured: 12ms, with `activeTransactions === 1`
1806
+ // and the driver observably receiving the handle on the call). Those
1807
+ // publish the transaction into the engine's ambient `txStore` (ADR-0034)
1808
+ // and `buildDriverOptions` threads it onto the read for the loader.
1809
+ //
1810
+ // So the stall shape is live but CONDITIONAL: it needs an open transaction
1811
+ // that the engine's ambient store cannot see. `SqlDriver.ensureSequencesTable()`
1812
+ // is the live witness that this is worth designing against — it takes
1813
+ // `parentTrx` and runs its DDL on the caller's transaction for exactly this
1814
+ // reason, with `assertBareKnexSafe` as the tripwire for callers that forget;
1815
+ // `sql-driver-sqlite-tx-guard.test.ts` pins both halves. (Until #7708 the
1816
+ // witness cited here was `plugin-audit`'s `captureBefore`, retired by #6656.
1817
+ // It was REPLACED rather than dropped: the example died, the hazard did not.)
1818
+ //
1819
+ // Hence the policy below keeps caching degraded reads rather than skipping
1820
+ // them: "don't cache a degraded read" would trade one 30s silent window for
1821
+ // a fresh 60s stall per call on every caller in the first bullet.
1715
1822
  //
1716
1823
  // [#5184] WHAT IS ACTUALLY CACHED, AND FOR HOW LONG — this paragraph is the
1717
1824
  // contract, and it describes `cacheListResult()` / `readCachedList()` below.
@@ -1798,6 +1905,15 @@ var _MetadataManager = class _MetadataManager {
1798
1905
  * only, so a fresh read that already replaced it keeps its slot. Nothing
1799
1906
  * accumulates — a wave of callers arriving after settle finds the cache the
1800
1907
  * settle just wrote, and once that lapses it starts one new read.
1908
+ *
1909
+ * [#6504] The shared value is the whole {@link ListReadResult}, not just
1910
+ * `items`. "Sharers share the outcome" above is stated about the answer *and*
1911
+ * its degraded verdict, and while the promise carried only `items` that was
1912
+ * true of `list()` alone: a {@link listDiagnosed} caller joining an in-flight
1913
+ * read had no way to reach the verdict that read had already computed, and
1914
+ * would have had to either re-walk the loaders (defeating this map) or invent
1915
+ * a second, unmemoized answer. `list()` narrows to `.items` at its own return
1916
+ * instead, so every sharer still receives the same array instance.
1801
1917
  */
1802
1918
  this.inflightListReads = /* @__PURE__ */ new Map();
1803
1919
  // [#5108] Loader names whose read failure has already been reported at
@@ -1994,6 +2110,8 @@ var _MetadataManager = class _MetadataManager {
1994
2110
  * {@link MetadataWriteOptions.notify} before doing so.
1995
2111
  */
1996
2112
  async register(type, name, data, options) {
2113
+ (0, import_core.assertMetadataRegisterContract)(type, name, data);
2114
+ type = (0, import_core.canonicalMetadataServiceType)(type);
1997
2115
  if (this.config.persistence?.writable === false) {
1998
2116
  const msg = `MetadataManager is read-only (persistence.writable=false); refusing to register ${type}/${name}`;
1999
2117
  if (this.config.validation?.throwOnError) {
@@ -2046,6 +2164,7 @@ var _MetadataManager = class _MetadataManager {
2046
2164
  * consumers will read the pre-write definition until restart.
2047
2165
  */
2048
2166
  registerInMemory(type, name, data) {
2167
+ type = (0, import_core.canonicalMetadataServiceType)(type);
2049
2168
  if (!this.registry.has(type)) {
2050
2169
  this.registry.set(type, /* @__PURE__ */ new Map());
2051
2170
  }
@@ -2062,18 +2181,33 @@ var _MetadataManager = class _MetadataManager {
2062
2181
  * has with {@link loadDiagnosed}, so every existing caller keeps its exact
2063
2182
  * behaviour and only callers that ASK for the verdict pay for it.
2064
2183
  *
2065
- * [#5840] Deliberately NOT expressed as `(await getDiagnosed(…)).data`,
2066
- * although that is what it computes. The obvious delegation adds one
2067
- * `await` hop, and a registry hit here is observed one microtask sooner than
2068
- * it would be through a second async frame — which `register()`'s watchers
2069
- * depend on, because `notifyWatchers` does not await its handlers and
2070
- * ObjectQL's bridge re-reads through `get()` on the event rather than
2071
- * trusting the payload (`register-notifies-watchers.test.ts` pins it, and
2072
- * went red on the delegating version). The duplication is three lines and is
2073
- * pinned from the other side: `get()` and `getDiagnosed().data` are asserted
2074
- * to agree on every case in `metadata-manager-get-diagnosed.test.ts`.
2184
+ * Not expressed as `(await getDiagnosed(…)).data`, although that is what it
2185
+ * computes and the reason has CHANGED, so do not read the duplication as a
2186
+ * standing constraint.
2187
+ *
2188
+ * [#5840] recorded the delegation as unsafe: it adds one `await` hop, and
2189
+ * `register-notifies-watchers.test.ts` went red on the delegating version, so
2190
+ * three lines were duplicated to hold the frame count fixed. [#6043] measured
2191
+ * that test and found it was pinning this method's microtask depth rather than
2192
+ * the ordering guarantee it named `notifyWatchers` never awaits its handlers,
2193
+ * so a subscriber's `await get(…)` had simply been settling inside the
2194
+ * microtasks `await register(…)` yields. That case now asserts the ordering
2195
+ * synchronously against the registry and does not observe this method's frame
2196
+ * count at all; the whole `@objectstack/metadata` suite was re-measured on the
2197
+ * delegating version and stayed green.
2198
+ *
2199
+ * What survives is a plain, local reason: the registry hit is the hot path and
2200
+ * answering it without a second async frame is worth three lines. Nothing
2201
+ * external depends on the hop count any more. Consolidating the two into one
2202
+ * delegation is therefore a viable, deliberately un-taken change (#6043 was
2203
+ * test-scoped) — if you take it, note that `get()`'s callers outside this
2204
+ * package were never surveyed for timing sensitivity, only this package's
2205
+ * tests. Either way the two stay pinned to each other from the other side:
2206
+ * `get()` and `getDiagnosed().data` are asserted to agree on every case in
2207
+ * `metadata-manager-get-diagnosed.test.ts`.
2075
2208
  */
2076
2209
  async get(type, name) {
2210
+ type = (0, import_core.canonicalMetadataServiceType)(type);
2077
2211
  const typeStore = this.registry.get(type);
2078
2212
  if (typeStore?.has(name)) {
2079
2213
  return typeStore.get(name);
@@ -2105,6 +2239,7 @@ var _MetadataManager = class _MetadataManager {
2105
2239
  * cannot prove the item is absent, so we decline to claim it is.
2106
2240
  */
2107
2241
  async getDiagnosed(type, name) {
2242
+ type = (0, import_core.canonicalMetadataServiceType)(type);
2108
2243
  const typeStore = this.registry.get(type);
2109
2244
  if (typeStore?.has(name)) {
2110
2245
  return { data: typeStore.get(name), degraded: false, errors: [] };
@@ -2126,19 +2261,64 @@ var _MetadataManager = class _MetadataManager {
2126
2261
  * `listCache`.
2127
2262
  */
2128
2263
  async list(type) {
2264
+ return (await this.readList((0, import_core.canonicalMetadataServiceType)(type))).items;
2265
+ }
2266
+ /**
2267
+ * `list`, plus whether the answer can be trusted as complete.
2268
+ *
2269
+ * [#6504] The plural counterpart of {@link getDiagnosed}, and the same defect
2270
+ * one read over: `readListUncached` has computed this verdict since #5184 and
2271
+ * `list()` spent it entirely on a cache TTL, so a consumer receiving a short
2272
+ * set could not ask whether it was short because that is all anyone declared
2273
+ * or because a loader was down. {@link reportLoaderReadFailure}'s own message
2274
+ * says what that costs — "every list served from now on is a PARTIAL set
2275
+ * presented as a complete one, and the server keeps reporting healthy" — and
2276
+ * until this member existed that sentence was addressed to a log reader only,
2277
+ * because no caller had a way to ask.
2278
+ *
2279
+ * Sharper than the singular case rather than merely analogous: `list` is the
2280
+ * read whose answer carries a **count**, and a consumer restating
2281
+ * `items.length` as "this environment contains N items" makes a positive,
2282
+ * numeric claim about what an author declared out of a read that partly did
2283
+ * not happen.
2284
+ *
2285
+ * Reads through exactly the same cache and single-flight machinery `list()`
2286
+ * does — same entry, same TTLs, same in-flight join — so asking for the
2287
+ * verdict costs no extra loader walk, and `list()` and
2288
+ * `listDiagnosed().items` cannot drift: they are the same read, narrowed at
2289
+ * different points. `degraded` is true when at least one loader threw while
2290
+ * this set was assembled; unlike {@link getDiagnosed} it does NOT additionally
2291
+ * require that nothing answered, because a plural read that lost one loader is
2292
+ * partial even when the others answered plenty — which is the whole fact.
2293
+ */
2294
+ async listDiagnosed(type) {
2295
+ const { items, degraded, errors } = await this.readList((0, import_core.canonicalMetadataServiceType)(type));
2296
+ return { items, degraded, errors };
2297
+ }
2298
+ /**
2299
+ * The cached / single-flight read behind {@link list} and
2300
+ * {@link listDiagnosed}.
2301
+ *
2302
+ * [#6504] Extracted so the two members are one read seen at two widths rather
2303
+ * than two implementations that have to be kept in agreement — the shape
2304
+ * `get`/`getDiagnosed` pay for with a duplicated body and a test pinning them
2305
+ * to each other. Everything below is unchanged in behaviour from when it was
2306
+ * inlined in `list()`; only the verdict now survives the return.
2307
+ */
2308
+ async readList(type) {
2129
2309
  const cached = this.readCachedList(type);
2130
2310
  if (cached) {
2131
- return cached.items;
2311
+ return cached;
2132
2312
  }
2133
2313
  const joined = this.inflightListReads.get(type);
2134
2314
  if (joined) {
2135
2315
  return joined;
2136
2316
  }
2137
- const shared = this.readListUncached(type).then(({ items, degraded }) => {
2317
+ const shared = this.readListUncached(type).then((result) => {
2138
2318
  if (this.inflightListReads.get(type) === shared) {
2139
- this.cacheListResult(type, items, degraded);
2319
+ this.cacheListResult(type, result);
2140
2320
  }
2141
- return items;
2321
+ return result;
2142
2322
  });
2143
2323
  this.inflightListReads.set(type, shared);
2144
2324
  try {
@@ -2169,6 +2349,7 @@ var _MetadataManager = class _MetadataManager {
2169
2349
  }
2170
2350
  }
2171
2351
  let degraded = false;
2352
+ const errors = [];
2172
2353
  for (const loader of this.loaders.values()) {
2173
2354
  try {
2174
2355
  const loaderItems = await loader.loadMany(type);
@@ -2181,10 +2362,11 @@ var _MetadataManager = class _MetadataManager {
2181
2362
  this.reportLoaderReadRecovered(loader.contract.name);
2182
2363
  } catch (e) {
2183
2364
  degraded = true;
2365
+ errors.push(`${loader.contract.name}: ${e instanceof Error ? e.message : String(e)}`);
2184
2366
  this.reportLoaderReadFailure(loader.contract.name, type, e);
2185
2367
  }
2186
2368
  }
2187
- return { items: Array.from(items.values()), degraded };
2369
+ return { items: Array.from(items.values()), degraded, errors };
2188
2370
  }
2189
2371
  /**
2190
2372
  * Report — at `error`, once per outage episode — that a loader could not be
@@ -2246,9 +2428,14 @@ var _MetadataManager = class _MetadataManager {
2246
2428
  * one thing this cache used to throw away. A result assembled while a loader
2247
2429
  * was unreadable is stored, but stored *as* what it is, so it expires on the
2248
2430
  * degraded TTL and any reader can tell it apart from a complete answer.
2431
+ *
2432
+ * [#6504] Takes the whole read result rather than its parts for the same
2433
+ * reason: a signature that spreads the verdict across positional arguments is
2434
+ * one a later caller can quietly fill with `false`, which is how the verdict
2435
+ * was lost on the way out in the first place.
2249
2436
  */
2250
- cacheListResult(type, items, degraded) {
2251
- this.listCache.set(type, { ts: Date.now(), items, degraded });
2437
+ cacheListResult(type, result) {
2438
+ this.listCache.set(type, { ts: Date.now(), ...result });
2252
2439
  }
2253
2440
  /**
2254
2441
  * Read a still-fresh {@link listCache} entry, or `undefined` when there is
@@ -2401,6 +2588,7 @@ var _MetadataManager = class _MetadataManager {
2401
2588
  * before the await would buy nothing and would re-open step 1's window.
2402
2589
  */
2403
2590
  async unregister(type, name, options) {
2591
+ type = (0, import_core.canonicalMetadataServiceType)(type);
2404
2592
  for (const loader of this.loaders.values()) {
2405
2593
  if (loader.contract.protocol !== "datasource:" || !loader.contract.capabilities.write) continue;
2406
2594
  if (typeof loader.delete !== "function") continue;
@@ -2503,6 +2691,7 @@ var _MetadataManager = class _MetadataManager {
2503
2691
  * Check if a metadata item exists
2504
2692
  */
2505
2693
  async exists(type, name) {
2694
+ type = (0, import_core.canonicalMetadataServiceType)(type);
2506
2695
  if (this.registry.get(type)?.has(name)) {
2507
2696
  return true;
2508
2697
  }
@@ -2517,6 +2706,7 @@ var _MetadataManager = class _MetadataManager {
2517
2706
  * List all names of metadata items of a given type
2518
2707
  */
2519
2708
  async listNames(type) {
2709
+ type = (0, import_core.canonicalMetadataServiceType)(type);
2520
2710
  const names = /* @__PURE__ */ new Set();
2521
2711
  const typeStore = this.registry.get(type);
2522
2712
  if (typeStore) {
@@ -2747,10 +2937,16 @@ var _MetadataManager = class _MetadataManager {
2747
2937
  * ## What it judges, and on what
2748
2938
  *
2749
2939
  * The registry stores either a raw spec document or a publish envelope
2750
- * (`{ name, packageId, state, metadata: {…spec} }`); the endpoint is read
2751
- * out with the SAME rule this method's caller uses for
2752
- * `publishedDefinition` (`data.metadata ?? data`), so publish gates exactly
2753
- * the document publish is about to snapshot. An item that does not satisfy
2940
+ * (`{ name, packageId, state, metadata: {…spec} }`), and in BOTH shapes the
2941
+ * row carries the metadata layer's bookkeeping. [#5309] The envelope is
2942
+ * peeled off first (`peelStoredEnvelope`) and the gate judges the authored
2943
+ * BODY: the wrapped half of that peel is the `data.metadata ?? data` rule
2944
+ * this method used to spell inline — the same document `publishedDefinition`
2945
+ * snapshots — and the flat half additionally removes `packageId` / `state` /
2946
+ * `version` / `published*`, which are storage identity, never endpoint
2947
+ * vocabulary. (What publish SNAPSHOTS is unchanged: `publishedDefinition`
2948
+ * still stores `data.metadata ?? data` verbatim, envelope included, because
2949
+ * `revertPackage` restores from it.) An item whose body does not satisfy
2754
2950
  * `ApiEndpointSchema` fails here too — not extra strictness but a
2755
2951
  * precondition: an unparsed shape cannot be gated, and it could never be
2756
2952
  * served either (the matcher's own loud skip refuses it at load).
@@ -2768,8 +2964,8 @@ var _MetadataManager = class _MetadataManager {
2768
2964
  const endpoints = [];
2769
2965
  const gatedItems = [];
2770
2966
  for (const item of apiItems) {
2771
- const document = item.data?.metadata ?? item.data;
2772
- const parsed = import_api3.ApiEndpointSchema.safeParse(document);
2967
+ const { body } = peelStoredEnvelope(item.data);
2968
+ const parsed = import_api3.ApiEndpointSchema.safeParse(body);
2773
2969
  if (!parsed.success) {
2774
2970
  for (const issue of parsed.error.issues) {
2775
2971
  errors.push({
@@ -2816,11 +3012,21 @@ var _MetadataManager = class _MetadataManager {
2816
3012
  }
2817
3013
  }
2818
3014
  if (packageItems.length === 0) {
2819
- throw new Error(`No metadata items found for package '${packageId}'`);
3015
+ const err = new Error(
3016
+ `No metadata items found for package '${packageId}'`
3017
+ );
3018
+ err.code = "RESOURCE_NOT_FOUND";
3019
+ err.status = 404;
3020
+ throw err;
2820
3021
  }
2821
3022
  const hasPublished = packageItems.some((item) => item.data.publishedDefinition !== void 0);
2822
3023
  if (!hasPublished) {
2823
- throw new Error(`Package '${packageId}' has never been published`);
3024
+ const err = new Error(
3025
+ `Package '${packageId}' has never been published`
3026
+ );
3027
+ err.code = "RESOURCE_CONFLICT";
3028
+ err.status = 409;
3029
+ throw err;
2824
3030
  }
2825
3031
  for (const item of packageItems) {
2826
3032
  if (item.data.publishedDefinition !== void 0) {
@@ -3073,6 +3279,7 @@ var _MetadataManager = class _MetadataManager {
3073
3279
  * @returns An unsubscribe function.
3074
3280
  */
3075
3281
  subscribe(type, callback) {
3282
+ type = (0, import_core.canonicalMetadataServiceType)(type);
3076
3283
  this.addWatchCallback(type, callback);
3077
3284
  return () => this.removeWatchCallback(type, callback);
3078
3285
  }
@@ -4353,8 +4560,16 @@ var ARTIFACT_FIELD_TO_TYPE = {
4353
4560
  connectors: "connector",
4354
4561
  emailTemplates: "email_template",
4355
4562
  docs: "doc",
4356
- books: "book",
4357
- data: "dataset"
4563
+ books: "book"
4564
+ // `data:` (the SEED collection) is deliberately absent — #6242 row 4(a).
4565
+ // It used to map to `'dataset'`, the ADR-0021 analytics kind: the exact name
4566
+ // collision `metadata-plugin.zod.ts` warns about in prose. The entry never
4567
+ // registered anything (SeedSchema declares no `name`, and the loop below
4568
+ // skips nameless items) — a dead pointer aimed at the wrong kind, which
4569
+ // would have begun mis-registering the day either side moved. Removed rather
4570
+ // than repointed at `'seed'`: seeds are APPLIED by SeedLoaderService off the
4571
+ // bundle, never registered as metadata items, so a `seed` mapping would be
4572
+ // new behaviour rather than a corrected name.
4358
4573
  };
4359
4574
  var MetadataPlugin = class {
4360
4575
  constructor(options = {}) {