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