@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.js CHANGED
@@ -177,7 +177,11 @@ import {
177
177
  ApiEndpointSchema as ApiEndpointSchema2,
178
178
  validateApiEndpointDeclarations
179
179
  } from "@objectstack/spec/api";
180
- import { createLogger } from "@objectstack/core";
180
+ import {
181
+ assertMetadataRegisterContract,
182
+ canonicalMetadataServiceType,
183
+ createLogger
184
+ } from "@objectstack/core";
181
185
 
182
186
  // src/serializers/json-serializer.ts
183
187
  var JSONSerializer = class {
@@ -516,6 +520,7 @@ var LRUCache = class {
516
520
  };
517
521
 
518
522
  // src/utils/schema-sync-errors.ts
523
+ import { isRelationSubObjectPhrase } from "@objectstack/types";
519
524
  var ALREADY_EXISTS = {
520
525
  codes: /* @__PURE__ */ new Set([
521
526
  // PostgreSQL SQLSTATE (class 42 — syntax error or access rule violation)
@@ -559,14 +564,67 @@ var MISSING_TABLE = {
559
564
  * - PostgreSQL: `relation "sys_metadata_history" does not exist`
560
565
  * - MySQL/MariaDB: `Table 'app.sys_metadata_history' doesn't exist`
561
566
  */
562
- message: /no such table|relation ["'`][^"'`]+["'`] does not exist|table ["'`][^"'`]+["'`] doesn'?t exist|unknown table/i
567
+ message: /no such table|relation ["'`][^"'`]+["'`] does not exist|table ["'`][^"'`]+["'`] doesn'?t exist|unknown table/i,
568
+ excludes: {
569
+ /**
570
+ * Exactly the three SQLSTATEs the docblock above already names as
571
+ * must-stay-loud neighbours of `does not exist`. They are listed here
572
+ * rather than merely trusted to miss the message test, because two of
573
+ * them (42703 columns, 42704 constraints/triggers) have a phrasing that
574
+ * *does* hit it, and because a code is a fact where prose is a guess.
575
+ *
576
+ * Postgres-shaped on purpose: measured, neither MySQL
577
+ * (`Unknown column 'label' in 'field list'`) nor SQLite
578
+ * (`no such column: bogus`, `table t has no column named label`)
579
+ * phrases a sub-object failure so that a missing-table phrase falls out
580
+ * of it, so there is nothing there to exclude. Adding their codes would
581
+ * be surface with no defect behind it.
582
+ */
583
+ codes: /* @__PURE__ */ new Set([
584
+ "42703",
585
+ // undefined_column
586
+ "42704",
587
+ // undefined_object — constraint, trigger, role, type, …
588
+ "3D000"
589
+ // invalid_catalog_name — `database "x" does not exist`
590
+ ]),
591
+ /**
592
+ * `«sub-object» "x" of relation "y" …` — Postgres' phrasing for a
593
+ * failure about something *inside* a relation, which therefore says the
594
+ * relation itself is present. The two in-repo siblings that carry this
595
+ * phrase are `mapDataError` (`packages/rest`, #5352) and
596
+ * `service-analytics`'s missing-column subtraction (#6035/PR #6346).
597
+ *
598
+ * [#6615] All three now read one home — `@objectstack/types` — instead
599
+ * of three hand-kept copies, so the phrase can no longer be taught to
600
+ * the repo a fourth time or drift in one package only. The **width**
601
+ * difference that used to justify the copy is preserved and is the
602
+ * reason the home exports two functions rather than one: those two
603
+ * *extract* the column name to phrase a better error, so a miss costs a
604
+ * vaguer message; this one *excludes*, so a miss restores the
605
+ * corruption. {@link isRelationSubObjectPhrase} is therefore the wider
606
+ * question — it drops their `column`/`[a-z0-9_]+`/`does not exist`
607
+ * anchors: any sub-object, any quoted identifier, any verdict.
608
+ * Over-matching here only ever converts a benign verdict into a loud
609
+ * one, which is the direction this whole module already errs in.
610
+ */
611
+ matchesMessage: isRelationSubObjectPhrase
612
+ }
563
613
  };
564
614
  var MAX_CAUSE_DEPTH = 4;
565
615
  function matchesDriverError(error, signature, depth) {
566
616
  if (error === null || error === void 0 || depth > MAX_CAUSE_DEPTH) return false;
567
- if (typeof error === "string") return signature.message.test(error);
617
+ if (typeof error === "string") {
618
+ if (signature.excludes?.matchesMessage(error)) return false;
619
+ return signature.message.test(error);
620
+ }
568
621
  if (typeof error !== "object") return false;
569
622
  const err = error;
623
+ const excludes = signature.excludes;
624
+ if (excludes) {
625
+ if (typeof err.code === "string" && excludes.codes.has(err.code)) return false;
626
+ if (typeof err.message === "string" && excludes.matchesMessage(err.message)) return false;
627
+ }
570
628
  if (typeof err.code === "string" && signature.codes.has(err.code)) return true;
571
629
  if (typeof err.errno === "number" && signature.errnos.has(err.errno)) return true;
572
630
  if (typeof err.message === "string" && signature.message.test(err.message)) return true;
@@ -579,48 +637,6 @@ function isMissingTableError(error, depth = 0) {
579
637
  return matchesDriverError(error, MISSING_TABLE, depth);
580
638
  }
581
639
 
582
- // src/migrations/add-sys-metadata-overlay-index.ts
583
- var INDEX_NAME = "idx_sys_metadata_overlay_active";
584
- var TABLE = "sys_metadata";
585
- var COLUMNS = "(type, name, organization_id, environment_id, scope)";
586
- var WHERE = "state = 'active'";
587
- async function addSysMetadataOverlayIndex(driver) {
588
- const driverAny = driver;
589
- const exec = async (sql) => {
590
- if (typeof driverAny.raw === "function") {
591
- await driverAny.raw(sql);
592
- } else if (typeof driverAny.execute === "function") {
593
- await driverAny.execute(sql);
594
- } else {
595
- throw new Error("driver has neither raw nor execute");
596
- }
597
- };
598
- const partialSql = `CREATE UNIQUE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS} WHERE ${WHERE}`;
599
- const fallbackSql = `CREATE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS}`;
600
- try {
601
- await exec(partialSql);
602
- return { index: INDEX_NAME, status: "created" };
603
- } catch (err) {
604
- const msg = err instanceof Error ? err.message : String(err);
605
- if (/partial|where clause|syntax/i.test(msg)) {
606
- try {
607
- await exec(fallbackSql);
608
- return { index: INDEX_NAME, status: "fallback_non_unique" };
609
- } catch (fallbackErr) {
610
- return {
611
- index: INDEX_NAME,
612
- status: "error",
613
- error: fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)
614
- };
615
- }
616
- }
617
- if (/already exists/i.test(msg)) {
618
- return { index: INDEX_NAME, status: "already_exists" };
619
- }
620
- return { index: INDEX_NAME, status: "error", error: msg };
621
- }
622
- }
623
-
624
640
  // src/migrations/migrate-project-id-to-environment-id.ts
625
641
  var AFFECTED_TABLES = [
626
642
  "sys_metadata",
@@ -770,23 +786,40 @@ var DatabaseLoader = class {
770
786
  // ==========================================
771
787
  // Internal CRUD helpers (driver vs engine)
772
788
  // ==========================================
789
+ // NOTE (#6231, closed out by #7178): BOTH branches below now take `query`
790
+ // unchanged and uncast. `DriverQuery` is `Omit<QueryAST, 'object'>`, so the
791
+ // object name travels as argument one only — that was always enough for the
792
+ // driver branch. The ENGINE branch used to carry `as any`, for one reason:
793
+ // `EngineQueryOptionsSchema.search` admitted only the structured
794
+ // `FullTextSearchSchema`, while `QueryAST.search` (hence `DriverQuery`) also
795
+ // admits the bare query string that ADR-0061 D1 calls the canonical Tier-1
796
+ // spelling and that the engine actually serves, so `DriverQuery` was not
797
+ // assignable to `EngineQueryOptionsParsed`. #7178 aligned the two schemas;
798
+ // the casts are now genuinely vestigial and are gone, which restores real
799
+ // `where`/`orderBy`/`fields` checking on the metadata main read path — this
800
+ // schema is not `.strict()`, so an unknown key here is SILENTLY DROPPED
801
+ // (`check:query-options-erasure`'s own rationale) and the erased type was
802
+ // the only thing standing between a typo and that silence.
803
+ //
804
+ // If a future edit makes one of these stop compiling, the honest fix is to
805
+ // reconcile the two schemas again — not to reinstate the cast.
773
806
  async _find(table, query) {
774
807
  if (this.engine) {
775
808
  return this.engine.find(table, query);
776
809
  }
777
- return this.driver.find(table, { object: table, ...query });
810
+ return this.driver.find(table, query);
778
811
  }
779
812
  async _findOne(table, query) {
780
813
  if (this.engine) {
781
814
  return this.engine.findOne(table, query);
782
815
  }
783
- return this.driver.findOne(table, { object: table, ...query });
816
+ return this.driver.findOne(table, query);
784
817
  }
785
818
  async _count(table, query) {
786
819
  if (this.engine) {
787
820
  return this.engine.count(table, query);
788
821
  }
789
- return this.driver.count(table, { object: table, ...query });
822
+ return this.driver.count(table, query);
790
823
  }
791
824
  async _create(table, data) {
792
825
  if (this.engine) {
@@ -868,9 +901,12 @@ var DatabaseLoader = class {
868
901
  }
869
902
  if (driver) {
870
903
  await migrateProjectIdToEnvironmentId(driver).catch(() => void 0);
871
- await addSysMetadataOverlayIndex(driver);
872
904
  }
873
- } catch {
905
+ } catch (error) {
906
+ console.warn(
907
+ `[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.`,
908
+ error
909
+ );
874
910
  }
875
911
  return;
876
912
  }
@@ -902,10 +938,6 @@ var DatabaseLoader = class {
902
938
  await migrateProjectIdToEnvironmentId(this.driver);
903
939
  } catch {
904
940
  }
905
- try {
906
- await addSysMetadataOverlayIndex(this.driver);
907
- } catch {
908
- }
909
941
  }
910
942
  /**
911
943
  * Ensure the history table exists.
@@ -1517,6 +1549,60 @@ import {
1517
1549
  identityFreeEndpointGateFailure,
1518
1550
  normalizeEndpointPath
1519
1551
  } from "@objectstack/spec/api";
1552
+
1553
+ // src/stored-envelope.ts
1554
+ var STORED_ENVELOPE_KEYS = Object.freeze([
1555
+ "package",
1556
+ "packageId",
1557
+ "publishedAt",
1558
+ "publishedBy",
1559
+ "publishedDefinition",
1560
+ "state",
1561
+ "version"
1562
+ ]);
1563
+ var STORED_BODY_KEY = "metadata";
1564
+ var ENVELOPE_KEYS = /* @__PURE__ */ new Set([...STORED_ENVELOPE_KEYS, STORED_BODY_KEY]);
1565
+ var EMPTY_ENVELOPE = Object.freeze({});
1566
+ function peelStoredEnvelope(item) {
1567
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
1568
+ return { envelope: EMPTY_ENVELOPE, body: item, wrapped: false };
1569
+ }
1570
+ const row = item;
1571
+ const wrappedBody = row[STORED_BODY_KEY];
1572
+ if (wrappedBody !== void 0 && wrappedBody !== null) {
1573
+ const envelope2 = {};
1574
+ for (const key of Object.keys(row)) {
1575
+ if (key === STORED_BODY_KEY) continue;
1576
+ envelope2[key] = row[key];
1577
+ }
1578
+ return { envelope: Object.freeze(envelope2), body: wrappedBody, wrapped: true };
1579
+ }
1580
+ let envelope;
1581
+ for (const key of Object.keys(row)) {
1582
+ if (!ENVELOPE_KEYS.has(key)) continue;
1583
+ envelope ?? (envelope = {});
1584
+ envelope[key] = row[key];
1585
+ }
1586
+ if (!envelope) return { envelope: EMPTY_ENVELOPE, body: row, wrapped: false };
1587
+ const body = {};
1588
+ for (const key of Object.keys(row)) {
1589
+ if (ENVELOPE_KEYS.has(key)) continue;
1590
+ body[key] = row[key];
1591
+ }
1592
+ return { envelope: Object.freeze(envelope), body, wrapped: false };
1593
+ }
1594
+ function storedItemName(peeled) {
1595
+ const fromEnvelope = peeled.envelope.name;
1596
+ if (typeof fromEnvelope === "string") return fromEnvelope;
1597
+ const body = peeled.body;
1598
+ if (body && typeof body === "object" && !Array.isArray(body)) {
1599
+ const fromBody = body.name;
1600
+ if (typeof fromBody === "string") return fromBody;
1601
+ }
1602
+ return void 0;
1603
+ }
1604
+
1605
+ // src/endpoint-matcher.ts
1520
1606
  function normalizeEndpointMethod(method) {
1521
1607
  return String(method ?? "").toUpperCase();
1522
1608
  }
@@ -1526,9 +1612,10 @@ function endpointIndexKey(method, path3) {
1526
1612
  function buildEndpointIndex(items, logger) {
1527
1613
  const index = /* @__PURE__ */ new Map();
1528
1614
  for (const item of items) {
1529
- const parsed = ApiEndpointSchema.safeParse(item);
1615
+ const peeled = peelStoredEnvelope(item);
1616
+ const parsed = ApiEndpointSchema.safeParse(peeled.body);
1530
1617
  if (!parsed.success) {
1531
- const declaredName = item && typeof item === "object" && typeof item.name === "string" ? item.name : "<unnamed>";
1618
+ const declaredName = storedItemName(peeled) ?? "<unnamed>";
1532
1619
  logger.error(
1533
1620
  `[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.`,
1534
1621
  void 0,
@@ -1667,16 +1754,40 @@ var _MetadataManager = class _MetadataManager {
1667
1754
  // above. The concurrent half is delivered by `inflightListReads` below, which
1668
1755
  // is why the two fields are one policy and are documented together.
1669
1756
  //
1670
- // [#5184] That hazard is NOT historical — it was re-verified on the current
1671
- // driver stack before this policy was chosen. `DatabaseLoader._find()` still
1672
- // issues `engine.find('sys_metadata', …)` without threading the caller's
1673
- // transaction, and `driver-sql` still treats SQLite as a single-connection
1674
- // pool (`activeTransactions`, `assertBareKnexSafe` the latter a dev/test
1675
- // guard that is a no-op in production, so production still waits the timeout
1676
- // out). `plugin-audit`'s `captureBefore` threads the transaction by hand for
1677
- // exactly this reason. Hence the policy below keeps caching degraded reads
1678
- // rather than skipping them: "don't cache a degraded read" would trade one
1679
- // 30s silent window for a fresh 60s stall per call.
1757
+ // [#5184; re-measured under #7708 on 2026-08-11] That hazard is NOT
1758
+ // historical and the re-measurement NARROWED it rather than retiring it.
1759
+ // Measured on the current stack: real `ObjectQL` + real `SqlDriver`
1760
+ // (better-sqlite3), a real `DatabaseLoader.list()` with the loader's own
1761
+ // cache off, `knex.client.pool.max === 1` confirmed for the SQLite dialect
1762
+ // and `acquireConnectionTimeout` left at the knex default of 60s.
1763
+ //
1764
+ // Transaction opened DIRECTLY on the driver (`driver.beginTransaction()`)
1765
+ // the read STALLS for the full timeout and then throws knex's "Timeout
1766
+ // acquiring a connection" (measured: 60_085ms). `DatabaseLoader._find()`
1767
+ // forwards no options, so nothing threads the caller's transaction, and
1768
+ // `driver-sql` still models SQLite as a single-connection pool
1769
+ // (`activeTransactions`, `assertBareKnexSafe` — the latter a dev/test
1770
+ // guard that is a no-op in production, so production still waits the
1771
+ // timeout out). `list()` catches that throw and degrades, which is
1772
+ // precisely the entry whose TTL this policy is choosing.
1773
+ // • Transaction opened through `engine.transaction()` / `ScopedContext`
1774
+ // → returns immediately (measured: 12ms, with `activeTransactions === 1`
1775
+ // and the driver observably receiving the handle on the call). Those
1776
+ // publish the transaction into the engine's ambient `txStore` (ADR-0034)
1777
+ // and `buildDriverOptions` threads it onto the read for the loader.
1778
+ //
1779
+ // So the stall shape is live but CONDITIONAL: it needs an open transaction
1780
+ // that the engine's ambient store cannot see. `SqlDriver.ensureSequencesTable()`
1781
+ // is the live witness that this is worth designing against — it takes
1782
+ // `parentTrx` and runs its DDL on the caller's transaction for exactly this
1783
+ // reason, with `assertBareKnexSafe` as the tripwire for callers that forget;
1784
+ // `sql-driver-sqlite-tx-guard.test.ts` pins both halves. (Until #7708 the
1785
+ // witness cited here was `plugin-audit`'s `captureBefore`, retired by #6656.
1786
+ // It was REPLACED rather than dropped: the example died, the hazard did not.)
1787
+ //
1788
+ // Hence the policy below keeps caching degraded reads rather than skipping
1789
+ // them: "don't cache a degraded read" would trade one 30s silent window for
1790
+ // a fresh 60s stall per call on every caller in the first bullet.
1680
1791
  //
1681
1792
  // [#5184] WHAT IS ACTUALLY CACHED, AND FOR HOW LONG — this paragraph is the
1682
1793
  // contract, and it describes `cacheListResult()` / `readCachedList()` below.
@@ -1763,6 +1874,15 @@ var _MetadataManager = class _MetadataManager {
1763
1874
  * only, so a fresh read that already replaced it keeps its slot. Nothing
1764
1875
  * accumulates — a wave of callers arriving after settle finds the cache the
1765
1876
  * settle just wrote, and once that lapses it starts one new read.
1877
+ *
1878
+ * [#6504] The shared value is the whole {@link ListReadResult}, not just
1879
+ * `items`. "Sharers share the outcome" above is stated about the answer *and*
1880
+ * its degraded verdict, and while the promise carried only `items` that was
1881
+ * true of `list()` alone: a {@link listDiagnosed} caller joining an in-flight
1882
+ * read had no way to reach the verdict that read had already computed, and
1883
+ * would have had to either re-walk the loaders (defeating this map) or invent
1884
+ * a second, unmemoized answer. `list()` narrows to `.items` at its own return
1885
+ * instead, so every sharer still receives the same array instance.
1766
1886
  */
1767
1887
  this.inflightListReads = /* @__PURE__ */ new Map();
1768
1888
  // [#5108] Loader names whose read failure has already been reported at
@@ -1959,6 +2079,8 @@ var _MetadataManager = class _MetadataManager {
1959
2079
  * {@link MetadataWriteOptions.notify} before doing so.
1960
2080
  */
1961
2081
  async register(type, name, data, options) {
2082
+ assertMetadataRegisterContract(type, name, data);
2083
+ type = canonicalMetadataServiceType(type);
1962
2084
  if (this.config.persistence?.writable === false) {
1963
2085
  const msg = `MetadataManager is read-only (persistence.writable=false); refusing to register ${type}/${name}`;
1964
2086
  if (this.config.validation?.throwOnError) {
@@ -2011,6 +2133,7 @@ var _MetadataManager = class _MetadataManager {
2011
2133
  * consumers will read the pre-write definition until restart.
2012
2134
  */
2013
2135
  registerInMemory(type, name, data) {
2136
+ type = canonicalMetadataServiceType(type);
2014
2137
  if (!this.registry.has(type)) {
2015
2138
  this.registry.set(type, /* @__PURE__ */ new Map());
2016
2139
  }
@@ -2027,18 +2150,33 @@ var _MetadataManager = class _MetadataManager {
2027
2150
  * has with {@link loadDiagnosed}, so every existing caller keeps its exact
2028
2151
  * behaviour and only callers that ASK for the verdict pay for it.
2029
2152
  *
2030
- * [#5840] Deliberately NOT expressed as `(await getDiagnosed(…)).data`,
2031
- * although that is what it computes. The obvious delegation adds one
2032
- * `await` hop, and a registry hit here is observed one microtask sooner than
2033
- * it would be through a second async frame — which `register()`'s watchers
2034
- * depend on, because `notifyWatchers` does not await its handlers and
2035
- * ObjectQL's bridge re-reads through `get()` on the event rather than
2036
- * trusting the payload (`register-notifies-watchers.test.ts` pins it, and
2037
- * went red on the delegating version). The duplication is three lines and is
2038
- * pinned from the other side: `get()` and `getDiagnosed().data` are asserted
2039
- * to agree on every case in `metadata-manager-get-diagnosed.test.ts`.
2153
+ * Not expressed as `(await getDiagnosed(…)).data`, although that is what it
2154
+ * computes and the reason has CHANGED, so do not read the duplication as a
2155
+ * standing constraint.
2156
+ *
2157
+ * [#5840] recorded the delegation as unsafe: it adds one `await` hop, and
2158
+ * `register-notifies-watchers.test.ts` went red on the delegating version, so
2159
+ * three lines were duplicated to hold the frame count fixed. [#6043] measured
2160
+ * that test and found it was pinning this method's microtask depth rather than
2161
+ * the ordering guarantee it named `notifyWatchers` never awaits its handlers,
2162
+ * so a subscriber's `await get(…)` had simply been settling inside the
2163
+ * microtasks `await register(…)` yields. That case now asserts the ordering
2164
+ * synchronously against the registry and does not observe this method's frame
2165
+ * count at all; the whole `@objectstack/metadata` suite was re-measured on the
2166
+ * delegating version and stayed green.
2167
+ *
2168
+ * What survives is a plain, local reason: the registry hit is the hot path and
2169
+ * answering it without a second async frame is worth three lines. Nothing
2170
+ * external depends on the hop count any more. Consolidating the two into one
2171
+ * delegation is therefore a viable, deliberately un-taken change (#6043 was
2172
+ * test-scoped) — if you take it, note that `get()`'s callers outside this
2173
+ * package were never surveyed for timing sensitivity, only this package's
2174
+ * tests. Either way the two stay pinned to each other from the other side:
2175
+ * `get()` and `getDiagnosed().data` are asserted to agree on every case in
2176
+ * `metadata-manager-get-diagnosed.test.ts`.
2040
2177
  */
2041
2178
  async get(type, name) {
2179
+ type = canonicalMetadataServiceType(type);
2042
2180
  const typeStore = this.registry.get(type);
2043
2181
  if (typeStore?.has(name)) {
2044
2182
  return typeStore.get(name);
@@ -2070,6 +2208,7 @@ var _MetadataManager = class _MetadataManager {
2070
2208
  * cannot prove the item is absent, so we decline to claim it is.
2071
2209
  */
2072
2210
  async getDiagnosed(type, name) {
2211
+ type = canonicalMetadataServiceType(type);
2073
2212
  const typeStore = this.registry.get(type);
2074
2213
  if (typeStore?.has(name)) {
2075
2214
  return { data: typeStore.get(name), degraded: false, errors: [] };
@@ -2091,19 +2230,64 @@ var _MetadataManager = class _MetadataManager {
2091
2230
  * `listCache`.
2092
2231
  */
2093
2232
  async list(type) {
2233
+ return (await this.readList(canonicalMetadataServiceType(type))).items;
2234
+ }
2235
+ /**
2236
+ * `list`, plus whether the answer can be trusted as complete.
2237
+ *
2238
+ * [#6504] The plural counterpart of {@link getDiagnosed}, and the same defect
2239
+ * one read over: `readListUncached` has computed this verdict since #5184 and
2240
+ * `list()` spent it entirely on a cache TTL, so a consumer receiving a short
2241
+ * set could not ask whether it was short because that is all anyone declared
2242
+ * or because a loader was down. {@link reportLoaderReadFailure}'s own message
2243
+ * says what that costs — "every list served from now on is a PARTIAL set
2244
+ * presented as a complete one, and the server keeps reporting healthy" — and
2245
+ * until this member existed that sentence was addressed to a log reader only,
2246
+ * because no caller had a way to ask.
2247
+ *
2248
+ * Sharper than the singular case rather than merely analogous: `list` is the
2249
+ * read whose answer carries a **count**, and a consumer restating
2250
+ * `items.length` as "this environment contains N items" makes a positive,
2251
+ * numeric claim about what an author declared out of a read that partly did
2252
+ * not happen.
2253
+ *
2254
+ * Reads through exactly the same cache and single-flight machinery `list()`
2255
+ * does — same entry, same TTLs, same in-flight join — so asking for the
2256
+ * verdict costs no extra loader walk, and `list()` and
2257
+ * `listDiagnosed().items` cannot drift: they are the same read, narrowed at
2258
+ * different points. `degraded` is true when at least one loader threw while
2259
+ * this set was assembled; unlike {@link getDiagnosed} it does NOT additionally
2260
+ * require that nothing answered, because a plural read that lost one loader is
2261
+ * partial even when the others answered plenty — which is the whole fact.
2262
+ */
2263
+ async listDiagnosed(type) {
2264
+ const { items, degraded, errors } = await this.readList(canonicalMetadataServiceType(type));
2265
+ return { items, degraded, errors };
2266
+ }
2267
+ /**
2268
+ * The cached / single-flight read behind {@link list} and
2269
+ * {@link listDiagnosed}.
2270
+ *
2271
+ * [#6504] Extracted so the two members are one read seen at two widths rather
2272
+ * than two implementations that have to be kept in agreement — the shape
2273
+ * `get`/`getDiagnosed` pay for with a duplicated body and a test pinning them
2274
+ * to each other. Everything below is unchanged in behaviour from when it was
2275
+ * inlined in `list()`; only the verdict now survives the return.
2276
+ */
2277
+ async readList(type) {
2094
2278
  const cached = this.readCachedList(type);
2095
2279
  if (cached) {
2096
- return cached.items;
2280
+ return cached;
2097
2281
  }
2098
2282
  const joined = this.inflightListReads.get(type);
2099
2283
  if (joined) {
2100
2284
  return joined;
2101
2285
  }
2102
- const shared = this.readListUncached(type).then(({ items, degraded }) => {
2286
+ const shared = this.readListUncached(type).then((result) => {
2103
2287
  if (this.inflightListReads.get(type) === shared) {
2104
- this.cacheListResult(type, items, degraded);
2288
+ this.cacheListResult(type, result);
2105
2289
  }
2106
- return items;
2290
+ return result;
2107
2291
  });
2108
2292
  this.inflightListReads.set(type, shared);
2109
2293
  try {
@@ -2134,6 +2318,7 @@ var _MetadataManager = class _MetadataManager {
2134
2318
  }
2135
2319
  }
2136
2320
  let degraded = false;
2321
+ const errors = [];
2137
2322
  for (const loader of this.loaders.values()) {
2138
2323
  try {
2139
2324
  const loaderItems = await loader.loadMany(type);
@@ -2146,10 +2331,11 @@ var _MetadataManager = class _MetadataManager {
2146
2331
  this.reportLoaderReadRecovered(loader.contract.name);
2147
2332
  } catch (e) {
2148
2333
  degraded = true;
2334
+ errors.push(`${loader.contract.name}: ${e instanceof Error ? e.message : String(e)}`);
2149
2335
  this.reportLoaderReadFailure(loader.contract.name, type, e);
2150
2336
  }
2151
2337
  }
2152
- return { items: Array.from(items.values()), degraded };
2338
+ return { items: Array.from(items.values()), degraded, errors };
2153
2339
  }
2154
2340
  /**
2155
2341
  * Report — at `error`, once per outage episode — that a loader could not be
@@ -2211,9 +2397,14 @@ var _MetadataManager = class _MetadataManager {
2211
2397
  * one thing this cache used to throw away. A result assembled while a loader
2212
2398
  * was unreadable is stored, but stored *as* what it is, so it expires on the
2213
2399
  * degraded TTL and any reader can tell it apart from a complete answer.
2400
+ *
2401
+ * [#6504] Takes the whole read result rather than its parts for the same
2402
+ * reason: a signature that spreads the verdict across positional arguments is
2403
+ * one a later caller can quietly fill with `false`, which is how the verdict
2404
+ * was lost on the way out in the first place.
2214
2405
  */
2215
- cacheListResult(type, items, degraded) {
2216
- this.listCache.set(type, { ts: Date.now(), items, degraded });
2406
+ cacheListResult(type, result) {
2407
+ this.listCache.set(type, { ts: Date.now(), ...result });
2217
2408
  }
2218
2409
  /**
2219
2410
  * Read a still-fresh {@link listCache} entry, or `undefined` when there is
@@ -2366,6 +2557,7 @@ var _MetadataManager = class _MetadataManager {
2366
2557
  * before the await would buy nothing and would re-open step 1's window.
2367
2558
  */
2368
2559
  async unregister(type, name, options) {
2560
+ type = canonicalMetadataServiceType(type);
2369
2561
  for (const loader of this.loaders.values()) {
2370
2562
  if (loader.contract.protocol !== "datasource:" || !loader.contract.capabilities.write) continue;
2371
2563
  if (typeof loader.delete !== "function") continue;
@@ -2468,6 +2660,7 @@ var _MetadataManager = class _MetadataManager {
2468
2660
  * Check if a metadata item exists
2469
2661
  */
2470
2662
  async exists(type, name) {
2663
+ type = canonicalMetadataServiceType(type);
2471
2664
  if (this.registry.get(type)?.has(name)) {
2472
2665
  return true;
2473
2666
  }
@@ -2482,6 +2675,7 @@ var _MetadataManager = class _MetadataManager {
2482
2675
  * List all names of metadata items of a given type
2483
2676
  */
2484
2677
  async listNames(type) {
2678
+ type = canonicalMetadataServiceType(type);
2485
2679
  const names = /* @__PURE__ */ new Set();
2486
2680
  const typeStore = this.registry.get(type);
2487
2681
  if (typeStore) {
@@ -2712,10 +2906,16 @@ var _MetadataManager = class _MetadataManager {
2712
2906
  * ## What it judges, and on what
2713
2907
  *
2714
2908
  * The registry stores either a raw spec document or a publish envelope
2715
- * (`{ name, packageId, state, metadata: {…spec} }`); the endpoint is read
2716
- * out with the SAME rule this method's caller uses for
2717
- * `publishedDefinition` (`data.metadata ?? data`), so publish gates exactly
2718
- * the document publish is about to snapshot. An item that does not satisfy
2909
+ * (`{ name, packageId, state, metadata: {…spec} }`), and in BOTH shapes the
2910
+ * row carries the metadata layer's bookkeeping. [#5309] The envelope is
2911
+ * peeled off first (`peelStoredEnvelope`) and the gate judges the authored
2912
+ * BODY: the wrapped half of that peel is the `data.metadata ?? data` rule
2913
+ * this method used to spell inline — the same document `publishedDefinition`
2914
+ * snapshots — and the flat half additionally removes `packageId` / `state` /
2915
+ * `version` / `published*`, which are storage identity, never endpoint
2916
+ * vocabulary. (What publish SNAPSHOTS is unchanged: `publishedDefinition`
2917
+ * still stores `data.metadata ?? data` verbatim, envelope included, because
2918
+ * `revertPackage` restores from it.) An item whose body does not satisfy
2719
2919
  * `ApiEndpointSchema` fails here too — not extra strictness but a
2720
2920
  * precondition: an unparsed shape cannot be gated, and it could never be
2721
2921
  * served either (the matcher's own loud skip refuses it at load).
@@ -2733,8 +2933,8 @@ var _MetadataManager = class _MetadataManager {
2733
2933
  const endpoints = [];
2734
2934
  const gatedItems = [];
2735
2935
  for (const item of apiItems) {
2736
- const document = item.data?.metadata ?? item.data;
2737
- const parsed = ApiEndpointSchema2.safeParse(document);
2936
+ const { body } = peelStoredEnvelope(item.data);
2937
+ const parsed = ApiEndpointSchema2.safeParse(body);
2738
2938
  if (!parsed.success) {
2739
2939
  for (const issue of parsed.error.issues) {
2740
2940
  errors.push({
@@ -2781,11 +2981,21 @@ var _MetadataManager = class _MetadataManager {
2781
2981
  }
2782
2982
  }
2783
2983
  if (packageItems.length === 0) {
2784
- throw new Error(`No metadata items found for package '${packageId}'`);
2984
+ const err = new Error(
2985
+ `No metadata items found for package '${packageId}'`
2986
+ );
2987
+ err.code = "RESOURCE_NOT_FOUND";
2988
+ err.status = 404;
2989
+ throw err;
2785
2990
  }
2786
2991
  const hasPublished = packageItems.some((item) => item.data.publishedDefinition !== void 0);
2787
2992
  if (!hasPublished) {
2788
- throw new Error(`Package '${packageId}' has never been published`);
2993
+ const err = new Error(
2994
+ `Package '${packageId}' has never been published`
2995
+ );
2996
+ err.code = "RESOURCE_CONFLICT";
2997
+ err.status = 409;
2998
+ throw err;
2789
2999
  }
2790
3000
  for (const item of packageItems) {
2791
3001
  if (item.data.publishedDefinition !== void 0) {
@@ -3038,6 +3248,7 @@ var _MetadataManager = class _MetadataManager {
3038
3248
  * @returns An unsubscribe function.
3039
3249
  */
3040
3250
  subscribe(type, callback) {
3251
+ type = canonicalMetadataServiceType(type);
3041
3252
  this.addWatchCallback(type, callback);
3042
3253
  return () => this.removeWatchCallback(type, callback);
3043
3254
  }
@@ -4324,8 +4535,16 @@ var ARTIFACT_FIELD_TO_TYPE = {
4324
4535
  connectors: "connector",
4325
4536
  emailTemplates: "email_template",
4326
4537
  docs: "doc",
4327
- books: "book",
4328
- data: "dataset"
4538
+ books: "book"
4539
+ // `data:` (the SEED collection) is deliberately absent — #6242 row 4(a).
4540
+ // It used to map to `'dataset'`, the ADR-0021 analytics kind: the exact name
4541
+ // collision `metadata-plugin.zod.ts` warns about in prose. The entry never
4542
+ // registered anything (SeedSchema declares no `name`, and the loop below
4543
+ // skips nameless items) — a dead pointer aimed at the wrong kind, which
4544
+ // would have begun mis-registering the day either side moved. Removed rather
4545
+ // than repointed at `'seed'`: seeds are APPLIED by SeedLoaderService off the
4546
+ // bundle, never registered as metadata items, so a `seed` mapping would be
4547
+ // new behaviour rather than a corrected name.
4329
4548
  };
4330
4549
  var MetadataPlugin = class {
4331
4550
  constructor(options = {}) {