@objectstack/metadata 17.0.0-rc.3 → 17.0.0-rc.5

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.js CHANGED
@@ -173,6 +173,10 @@ import {
173
173
  MetadataEventType,
174
174
  MetadataEventSchema
175
175
  } from "@objectstack/spec/api";
176
+ import {
177
+ ApiEndpointSchema as ApiEndpointSchema2,
178
+ validateApiEndpointDeclarations
179
+ } from "@objectstack/spec/api";
176
180
  import { createLogger } from "@objectstack/core";
177
181
 
178
182
  // src/serializers/json-serializer.ts
@@ -1090,6 +1094,57 @@ var DatabaseLoader = class {
1090
1094
  };
1091
1095
  }
1092
1096
  // ==========================================
1097
+ // Read-failure classification (#5108)
1098
+ // ==========================================
1099
+ /**
1100
+ * Decide what a failed READ against {@link tableName} means, and rethrow
1101
+ * unless it is the ONE benign reason.
1102
+ *
1103
+ * #5108 (rule from #4632; same shape as #4728 and #4825) — discriminate by
1104
+ * error TYPE. Every read method below used to `catch {}` into its own empty
1105
+ * value: `load` → `null`, `loadMany` → `[]`, `exists` → `false`, `stat` →
1106
+ * `null`, `list` → `[]`. That made a database the metadata plane cannot
1107
+ * reach **indistinguishable** from an environment where nothing of that type
1108
+ * was ever declared — and it erased the failure *inside the loader*, so
1109
+ * neither `MetadataManager`'s own `try/catch` degradation branches nor
1110
+ * {@link import('../metadata-manager.js').MetadataManager.loadDiagnosed}
1111
+ * (ADR-0110 D3, whose whole purpose is to tell a miss from an outage) could
1112
+ * report anything. Nowhere on the chain was there a line saying the read
1113
+ * failed.
1114
+ *
1115
+ * Why that is worse than a noisy error: every consumer that gates on a
1116
+ * *declared set* — permissions, sharing rules, policies, endpoint
1117
+ * declarations — reads the empty answer as "the author declared none". Some
1118
+ * then fail open (grant), some fail closed (lock out); both look healthy
1119
+ * from outside. This is the AGENTS.md → "Degradation log levels" shape the
1120
+ * repo has already paid for twice, one layer up from #4825.
1121
+ *
1122
+ * Exactly one failure reason is benign: `sys_metadata` has not been
1123
+ * provisioned yet. There are then genuinely no rows, so "nothing declared"
1124
+ * IS the truth, and a first boot must not explode. Every other reason —
1125
+ * connection drop, timeout, insufficient privileges, malformed query — means
1126
+ * the rows may well be there and simply were not seen.
1127
+ *
1128
+ * Classification is conservative in the same direction as
1129
+ * {@link isMissingTableError} itself: an unrecognised error is NOT benign.
1130
+ * A false "benign" silently mis-answers a security question; a false "real"
1131
+ * costs one loud error.
1132
+ *
1133
+ * @param error The value thrown by `_find` / `_findOne` / `_count`.
1134
+ * @throws The underlying driver error, unchanged — deliberately, matching
1135
+ * {@link nextEventSeq}. The loader does not log it: the caller owns
1136
+ * the consequence and is the only layer that knows what an
1137
+ * incomplete answer costs it (`MetadataManager.list()` reports it at
1138
+ * `error`; `listForIndex()`/`matchEndpoint` let it propagate so an
1139
+ * outage can never be served as a 404).
1140
+ * @returns normally ONLY for the benign case, licensing the caller to answer
1141
+ * with its empty value.
1142
+ */
1143
+ rethrowUnlessTableUnprovisioned(error) {
1144
+ if (isMissingTableError(error)) return;
1145
+ throw error;
1146
+ }
1147
+ // ==========================================
1093
1148
  // MetadataLoader Interface Implementation
1094
1149
  // ==========================================
1095
1150
  async load(type, name, _options) {
@@ -1128,7 +1183,8 @@ var DatabaseLoader = class {
1128
1183
  etag: record.checksum,
1129
1184
  loadTime: Date.now() - startTime
1130
1185
  };
1131
- } catch {
1186
+ } catch (error) {
1187
+ this.rethrowUnlessTableUnprovisioned(error);
1132
1188
  return {
1133
1189
  data: null,
1134
1190
  loadTime: Date.now() - startTime
@@ -1148,7 +1204,8 @@ var DatabaseLoader = class {
1148
1204
  const result = rows.map((row) => this.rowToData(row)).filter((data) => data !== null);
1149
1205
  this.loadManyCache?.set(type, result);
1150
1206
  return result;
1151
- } catch {
1207
+ } catch (error) {
1208
+ this.rethrowUnlessTableUnprovisioned(error);
1152
1209
  return [];
1153
1210
  }
1154
1211
  }
@@ -1163,7 +1220,8 @@ var DatabaseLoader = class {
1163
1220
  where: this.baseFilter(type, name)
1164
1221
  });
1165
1222
  return count > 0;
1166
- } catch {
1223
+ } catch (error) {
1224
+ this.rethrowUnlessTableUnprovisioned(error);
1167
1225
  return false;
1168
1226
  }
1169
1227
  }
@@ -1192,7 +1250,8 @@ var DatabaseLoader = class {
1192
1250
  };
1193
1251
  this.statCache?.set(key, stats);
1194
1252
  return stats;
1195
- } catch {
1253
+ } catch (error) {
1254
+ this.rethrowUnlessTableUnprovisioned(error);
1196
1255
  return null;
1197
1256
  }
1198
1257
  }
@@ -1210,7 +1269,8 @@ var DatabaseLoader = class {
1210
1269
  const names = rows.map((row) => row.name).filter((name) => typeof name === "string");
1211
1270
  this.listCache?.set(type, names);
1212
1271
  return names;
1213
- } catch {
1272
+ } catch (error) {
1273
+ this.rethrowUnlessTableUnprovisioned(error);
1214
1274
  return [];
1215
1275
  }
1216
1276
  }
@@ -1451,7 +1511,124 @@ function generateId() {
1451
1511
  return `meta_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
1452
1512
  }
1453
1513
 
1514
+ // src/endpoint-matcher.ts
1515
+ import {
1516
+ ApiEndpointSchema,
1517
+ identityFreeEndpointGateFailure,
1518
+ normalizeEndpointPath
1519
+ } from "@objectstack/spec/api";
1520
+ function normalizeEndpointMethod(method) {
1521
+ return String(method ?? "").toUpperCase();
1522
+ }
1523
+ function endpointIndexKey(method, path3) {
1524
+ return `${normalizeEndpointMethod(method)} ${normalizeEndpointPath(path3)}`;
1525
+ }
1526
+ function buildEndpointIndex(items, logger) {
1527
+ const index = /* @__PURE__ */ new Map();
1528
+ for (const item of items) {
1529
+ const parsed = ApiEndpointSchema.safeParse(item);
1530
+ if (!parsed.success) {
1531
+ const declaredName = item && typeof item === "object" && typeof item.name === "string" ? item.name : "<unnamed>";
1532
+ logger.error(
1533
+ `[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
+ void 0,
1535
+ { issues: parsed.error.issues }
1536
+ );
1537
+ continue;
1538
+ }
1539
+ const endpoint = parsed.data;
1540
+ const gateFailure = identityFreeEndpointGateFailure(endpoint);
1541
+ if (gateFailure) {
1542
+ logger.error(
1543
+ `[EndpointMatcher] stored api item '${endpoint.name}' was stored WITHOUT passing the endpoint publish gates (#5040 E7 / ADR-0121) \u2014 it is EXCLUDED from endpoint matching and its declared route will answer 404. Republish it through a gated path (a stack artifact, or \`publishPackage\` with the package's \`manifest.namespace\`); a direct metadata write is not a publish. Gate failure: ${gateFailure.message}`,
1544
+ void 0,
1545
+ { name: endpoint.name, issue: { path: gateFailure.path, message: gateFailure.message } }
1546
+ );
1547
+ continue;
1548
+ }
1549
+ const key = endpointIndexKey(endpoint.method, endpoint.path);
1550
+ const incumbent = index.get(key);
1551
+ if (!incumbent) {
1552
+ index.set(key, endpoint);
1553
+ continue;
1554
+ }
1555
+ const challengerWins = endpoint.name < incumbent.name;
1556
+ const winner = challengerWins ? endpoint : incumbent;
1557
+ const loser = challengerWins ? incumbent : endpoint;
1558
+ if (challengerWins) index.set(key, endpoint);
1559
+ logger.error(
1560
+ `[EndpointMatcher] duplicate endpoint claim on '${key}': api items '${incumbent.name}' and '${endpoint.name}' both declare it. '${winner.name}' KEEPS the route and '${loser.name}' is IGNORED \u2014 the rule is lexicographically-first \`name\` wins, chosen so every node and every boot resolves it identically. Rename or repath '${loser.name}' to make it reachable.`,
1561
+ void 0,
1562
+ { key, winner: winner.name, ignored: loser.name }
1563
+ );
1564
+ }
1565
+ return index;
1566
+ }
1567
+ var EndpointMatcher = class {
1568
+ constructor(deps) {
1569
+ this.deps = deps;
1570
+ }
1571
+ /** Mark the index stale; the next {@link match} rebuilds it. */
1572
+ invalidate() {
1573
+ this.index = void 0;
1574
+ this.building = void 0;
1575
+ }
1576
+ /**
1577
+ * Resolve `method`+`path` to the owning declaration.
1578
+ *
1579
+ * @returns the parsed endpoint plus `params: {}`, or `undefined` on a miss.
1580
+ * @throws whatever the store read threw — an outage is never a miss.
1581
+ */
1582
+ async match(query) {
1583
+ const index = await this.ensureIndex();
1584
+ const endpoint = index.get(endpointIndexKey(query.method, query.path));
1585
+ if (!endpoint) return void 0;
1586
+ return { endpoint, params: {} };
1587
+ }
1588
+ async ensureIndex() {
1589
+ if (this.index) return this.index;
1590
+ if (this.building) return this.building;
1591
+ const build = (async () => {
1592
+ const items = await this.deps.listApiItems();
1593
+ return buildEndpointIndex(items, this.deps.logger);
1594
+ })();
1595
+ this.building = build;
1596
+ try {
1597
+ const built = await build;
1598
+ if (this.building === build) {
1599
+ this.index = built;
1600
+ this.building = void 0;
1601
+ }
1602
+ return built;
1603
+ } catch (error) {
1604
+ if (this.building === build) this.building = void 0;
1605
+ throw error;
1606
+ }
1607
+ }
1608
+ };
1609
+
1454
1610
  // src/metadata-manager.ts
1611
+ var WRITABLE_LOADER_METHODS = ["save", "delete"];
1612
+ var WRITABLE_LOADER_METHOD_SIGNATURE = {
1613
+ save: "save(type: string, name: string, data: any, options?: MetadataSaveOptions): Promise<MetadataSaveResult>",
1614
+ delete: "delete(type: string, name: string): Promise<void>"
1615
+ };
1616
+ function buildWritableLoaderMissingMethodsMessage(loaderName, missing) {
1617
+ const missingPhrase = missing.length === 2 ? "implements neither a `save()` nor a `delete()` method" : `implements no \`${missing[0]}()\` method`;
1618
+ const consequences = missing.map(
1619
+ (method) => method === "save" ? `Registered as-is, every write would be a silent lie: \`register()\` skips a loader that cannot save, then writes the in-memory registry, invalidates the list cache, announces a \`created\`/\`updated\` event and notifies watchers, so the caller (Studio/Setup, REST PUT, the CLI, a package publish) is told the write succeeded while nothing ever reaches \`${loaderName}\` \u2014 the item reads back correctly for the life of this process and is gone at the next restart, with nothing to retry it. ` : `Registered as-is, every deletion would be a silent lie: \`unregister()\` skips a loader that cannot delete, then drops the registry entry, invalidates the list cache and announces a \`deleted\` event, so the caller (Studio/Setup, REST DELETE, the CLI, a package teardown) is told the delete succeeded while the row stays in \`${loaderName}\` and is read straight back out by the very next \`list()\`/\`get()\` \u2014 across restarts, with nothing to retry it. `
1620
+ );
1621
+ const repair = missing.map((method) => `\`${WRITABLE_LOADER_METHOD_SIGNATURE[method]}\``).join(" and ");
1622
+ return `[MetadataManager] Refusing to register metadata loader \`${loaderName}\`: it declares \`protocol: 'datasource:'\` with \`capabilities.write: true\` but ${missingPhrase}. A write-capable datasource loader is written to AND deleted from \u2014 \`register()\` persists every item into it, and \`unregister()\` has to take those rows back out again. ` + consequences.join("") + `Fix: either implement ${repair} on \`${loaderName}\` (\`DatabaseLoader\` in this package is the reference implementation), or, if the loader is genuinely read-only, declare \`capabilities.write: false\` \u2014 a read-only \`datasource:\` loader registers without complaint and is never written to in the first place.`;
1623
+ }
1624
+ function assertWritableLoaderContract(loader) {
1625
+ const { name, protocol, capabilities } = loader.contract;
1626
+ if (protocol !== "datasource:" || capabilities.write !== true) return;
1627
+ const missing = WRITABLE_LOADER_METHODS.filter((method) => typeof loader[method] !== "function");
1628
+ if (missing.length === 0) return;
1629
+ throw new Error(buildWritableLoaderMissingMethodsMessage(name, missing));
1630
+ }
1631
+ var PUBLISH_NAMESPACE_REMEDY = "From `MetadataManager.publishPackage` specifically: this method indexes items by `packageId` and carries no manifest, so it cannot prove a namespace on its own and will not infer one from the items being published (an author-supplied value would make the carve-out gate vacuous). Pass the package's explicit namespace as `publishPackage(id, { namespace })`, or publish the endpoints as part of a stack artifact (`defineStack` \u2192 compile \u2192 artifact ingest), which carries the manifest and runs these same gates at parse time.";
1455
1632
  function generateEventUuid() {
1456
1633
  const c = globalThis.crypto;
1457
1634
  if (c && typeof c.randomUUID === "function") {
@@ -1482,14 +1659,127 @@ var _MetadataManager = class _MetadataManager {
1482
1659
  // acquire a fresh knex connection while the transaction is still holding
1483
1660
  // SQLite's single connection — knex waits the full `acquireConnectionTimeout`
1484
1661
  // (60s) before returning []. The cache absorbs the repeated lookups so the
1485
- // loader is only hit once per TTL window.
1662
+ // loader is only hit once per TTL window — for CONCURRENT callers as well as
1663
+ // sequential ones, since #5253. The cache on its own could only ever deliver
1664
+ // the sequential half of that promise: nothing is written until a read
1665
+ // completes, so everything issued before the first read returned used to miss
1666
+ // and walk every loader — N callers, N × 60s on the very stall described
1667
+ // above. The concurrent half is delivered by `inflightListReads` below, which
1668
+ // is why the two fields are one policy and are documented together.
1669
+ //
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.
1680
+ //
1681
+ // [#5184] WHAT IS ACTUALLY CACHED, AND FOR HOW LONG — this paragraph is the
1682
+ // contract, and it describes `cacheListResult()` / `readCachedList()` below.
1683
+ // (An earlier version of this comment claimed the cache kept "only positive
1684
+ // (non-empty) hits or repeated hits with a stable miss signature". No such
1685
+ // condition ever existed in the code. Comment is contract; a comment that
1686
+ // describes a policy nothing implements is a declared ≠ enforced defect in
1687
+ // its own right, so it is replaced rather than patched.)
1688
+ //
1689
+ // • EVERY completed `list()` is cached, empty results included. There is
1690
+ // no non-empty test and no "miss signature" concept.
1691
+ // • An entry assembled while at least one loader THREW is a known-partial
1692
+ // answer: it is cached with `degraded: true` and expires after
1693
+ // `DEGRADED_LIST_CACHE_TTL_MS`, not `LIST_CACHE_TTL_MS`. So the burst of
1694
+ // repeated lookups the knex path above depends on is still absorbed,
1695
+ // while the window in which the manager serves a known-short set without
1696
+ // re-asking anyone shrinks from 30s to ~2s. Recovery is therefore also
1697
+ // noticed (and `reportLoaderReadRecovered` logged) within ~2s of storage
1698
+ // healing instead of up to 30s later.
1699
+ // • `degraded` lives ON the entry, not in a side table, so every consumer
1700
+ // of the cache can tell a complete answer from a partial one. Read
1701
+ // entries through `readCachedList()` rather than `listCache.get()`, so
1702
+ // the flag and its TTL are applied in one place.
1486
1703
  //
1487
1704
  // Invalidated on every `register()` / `unregister()` to keep CRUD writes
1488
1705
  // visible to subsequent reads.
1706
+ //
1707
+ // [#5259] WHERE in a write the invalidation sits is part of that promise, not
1708
+ // an implementation detail. `list()` merges registry ∪ loaders, so an
1709
+ // invalidation issued while only ONE of the two has been updated lets the
1710
+ // next read memoize the half-applied view for a full TTL. The rule both
1711
+ // writers follow: **invalidate last, once every store already holds the state
1712
+ // being announced** — `register()` satisfies it by writing the registry
1713
+ // first (the registry outranks loaders in the merge, so its save window
1714
+ // already shows the post-write value); `unregister()` satisfies it by
1715
+ // deleting from storage first and invalidating after, with nothing awaited
1716
+ // between the registry drop and the invalidation. See `unregister()`.
1489
1717
  this.listCache = /* @__PURE__ */ new Map();
1718
+ /**
1719
+ * [#5253] The `list()` read currently in flight for a metadata type — the
1720
+ * concurrent half of the `listCache` policy above.
1721
+ *
1722
+ * `listCache` memoizes an answer only once a read has *finished*, so it can
1723
+ * absorb the caller that arrives second in time but never the caller that
1724
+ * arrives second in flight. Everything issued while the first read is still
1725
+ * walking the loaders used to miss and start its own identical walk; on the
1726
+ * knex/SQLite path the field comment above is built for, that is 60s burned
1727
+ * per concurrent caller instead of once for all of them. A type is read once
1728
+ * at a time: whoever finds a read already running joins it.
1729
+ *
1730
+ * **Sharers share the outcome. This is a contract, not an accident.** Every
1731
+ * caller joining an in-flight read receives that read's exact result — the
1732
+ * same array instance, and, when a loader was unreadable, the same
1733
+ * known-partial set that gets memoized `degraded: true` on the short TTL.
1734
+ * There is no per-caller retry: `list()` is the best-effort listing seam and
1735
+ * does not throw (see {@link reportLoaderReadFailure}; the strict
1736
+ * counterparts are `listForIndex()` and {@link loadDiagnosed}), so a lost
1737
+ * loader is not an error to fail over from — it is the answer. Re-running the
1738
+ * read privately for a joiner would walk the same loaders in the same window
1739
+ * against the same outage, which is precisely what this map exists to
1740
+ * prevent. Should the seam ever acquire a rejecting path, that rejection is
1741
+ * shared by the same mechanism and for the same reason.
1742
+ *
1743
+ * **The registration is also the permission to cache.** An entry here says
1744
+ * "this read still describes the current state". {@link invalidateListCache}
1745
+ * retracts it, which is what makes a write landing mid-read safe in both
1746
+ * directions:
1747
+ * • the retracted read does NOT write its result into `listCache` when it
1748
+ * settles, so an answer assembled before the write cannot outlive the
1749
+ * write it predates (the invalidation wins — it is the later, better
1750
+ * informed fact);
1751
+ * • a `list()` issued after the invalidation starts a FRESH read instead of
1752
+ * joining one that predates the write.
1753
+ * That second point is the #5219 / #5229 ordering bar restated for
1754
+ * concurrency: a consumer woken by a metadata change must not observe the
1755
+ * event and pre-event state together, and handing a woken watcher an
1756
+ * in-flight read that began before the event would be exactly that.
1757
+ * Callers *already waiting* on the retracted read still receive its (now
1758
+ * possibly stale) result — they asked before the write, and restarting the
1759
+ * read under them would turn a write burst into an unbounded retry loop on
1760
+ * the one path the cache exists to keep off the loaders.
1761
+ *
1762
+ * Self-cleaning: the entry is dropped when the read settles, by that read
1763
+ * only, so a fresh read that already replaced it keeps its slot. Nothing
1764
+ * accumulates — a wave of callers arriving after settle finds the cache the
1765
+ * settle just wrote, and once that lapses it starts one new read.
1766
+ */
1767
+ this.inflightListReads = /* @__PURE__ */ new Map();
1768
+ // [#5108] Loader names whose read failure has already been reported at
1769
+ // `error` by `list()`. AGENTS.md → "Degradation log levels": say it once, at
1770
+ // the first degradation — `list()` is hot enough that one line per failed
1771
+ // read would bury the one line that matters. Cleared when the loader answers
1772
+ // again, so a second outage is reported again. Same once-only discipline as
1773
+ // `DatabaseLoader.schemaFailureReported`.
1774
+ this.loaderReadFailureReported = /* @__PURE__ */ new Set();
1490
1775
  this.repoWatchClosed = false;
1491
1776
  this.config = config;
1492
1777
  this.logger = createLogger({ level: "info", format: "pretty" });
1778
+ this.endpointMatcher = new EndpointMatcher({
1779
+ listApiItems: () => this.listForIndex(_MetadataManager.ENDPOINT_METADATA_TYPE),
1780
+ logger: this.logger
1781
+ });
1782
+ this.subscribe(_MetadataManager.ENDPOINT_METADATA_TYPE, () => this.endpointMatcher.invalidate());
1493
1783
  this.serializers = /* @__PURE__ */ new Map();
1494
1784
  const formats = config.formats || ["typescript", "json", "yaml"];
1495
1785
  if (formats.includes("json")) {
@@ -1639,8 +1929,16 @@ var _MetadataManager = class _MetadataManager {
1639
1929
  }
1640
1930
  /**
1641
1931
  * Register a new metadata loader (data source)
1932
+ *
1933
+ * [#5276, #5654] Rejects — loudly, before the loader is stored — a
1934
+ * `datasource:` loader that declares `capabilities.write` without
1935
+ * implementing `save()` **and** `delete()`. This is the **only** way into
1936
+ * `this.loaders` (the constructor's `config.loaders` come through here too),
1937
+ * which is what lets every later write-capability guard be defensive rather
1938
+ * than load-bearing.
1642
1939
  */
1643
1940
  registerLoader(loader) {
1941
+ assertWritableLoaderContract(loader);
1644
1942
  this.loaders.set(loader.contract.name, loader);
1645
1943
  this.logger.info(`Registered metadata loader: ${loader.contract.name} (${loader.contract.protocol})`);
1646
1944
  }
@@ -1676,9 +1974,9 @@ var _MetadataManager = class _MetadataManager {
1676
1974
  this.registry.get(type).set(name, data);
1677
1975
  this.invalidateListCache(type);
1678
1976
  for (const loader of this.loaders.values()) {
1679
- if (loader.save && loader.contract.protocol === "datasource:" && loader.contract.capabilities.write) {
1680
- await loader.save(type, name, data);
1681
- }
1977
+ if (loader.contract.protocol !== "datasource:" || !loader.contract.capabilities.write) continue;
1978
+ if (typeof loader.save !== "function") continue;
1979
+ await loader.save(type, name, data);
1682
1980
  }
1683
1981
  await this.publishRealtimeMetadataEvent(existed ? "updated" : "created", type, name, {
1684
1982
  definition: data,
@@ -1722,6 +2020,23 @@ var _MetadataManager = class _MetadataManager {
1722
2020
  /**
1723
2021
  * Get a metadata item by type and name.
1724
2022
  * Checks in-memory registry first, then falls back to loaders.
2023
+ *
2024
+ * Returns `undefined` both when nothing declares the item and when every
2025
+ * loader that could have held it FAILED — see {@link getDiagnosed} when the
2026
+ * caller must tell those apart. This is the same relationship {@link load}
2027
+ * has with {@link loadDiagnosed}, so every existing caller keeps its exact
2028
+ * behaviour and only callers that ASK for the verdict pay for it.
2029
+ *
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`.
1725
2040
  */
1726
2041
  async get(type, name) {
1727
2042
  const typeStore = this.registry.get(type);
@@ -1732,13 +2047,85 @@ var _MetadataManager = class _MetadataManager {
1732
2047
  return result ?? void 0;
1733
2048
  }
1734
2049
  /**
1735
- * List all metadata items of a given type
2050
+ * `get`, plus whether the answer can be trusted as complete.
2051
+ *
2052
+ * [#5840] {@link loadDiagnosed} already computes this verdict — and `get()`
2053
+ * threw it away two hops later (`load` kept only `.data`, `get` turned that
2054
+ * `null` into `undefined`), so no caller of `get` could reach the one fact
2055
+ * ADR-0110 D3 exists to preserve: **a miss and an outage are different facts
2056
+ * with opposite security meanings.** A consumer that gates on a declaration
2057
+ * MUST NOT read `undefined` as "the author declared nothing" — an
2058
+ * availability failure would silently widen access (the REST `/actions`
2059
+ * fail-open branch, #3935) or make a positive claim about authorship from a
2060
+ * read that never happened (`code: null` in the layered read, #5707/#5532).
2061
+ *
2062
+ * This is the registry-first counterpart of {@link loadDiagnosed}, and that
2063
+ * difference is why callers of `get` cannot simply switch to `loadDiagnosed`:
2064
+ * doing so would skip the in-memory registry and change what they resolve.
2065
+ *
2066
+ * `degraded` is true when at least one loader threw AND nothing answered with
2067
+ * the item — never when the in-memory registry answered, because that answer
2068
+ * needed no loader. A clean miss (every loader answered, none had it) is NOT
2069
+ * degraded. The posture is deliberately conservative: with a loader down we
2070
+ * cannot prove the item is absent, so we decline to claim it is.
2071
+ */
2072
+ async getDiagnosed(type, name) {
2073
+ const typeStore = this.registry.get(type);
2074
+ if (typeStore?.has(name)) {
2075
+ return { data: typeStore.get(name), degraded: false, errors: [] };
2076
+ }
2077
+ const { data, degraded, errors } = await this.loadDiagnosed(type, name);
2078
+ return { data: data ?? void 0, degraded, errors };
2079
+ }
2080
+ /**
2081
+ * List all metadata items of a given type.
2082
+ *
2083
+ * Best-effort by contract: a loader that cannot be read is reported once and
2084
+ * skipped ({@link reportLoaderReadFailure}), so this resolves with what the
2085
+ * reachable loaders hold rather than throwing.
2086
+ *
2087
+ * [#5253] Reads of one type are single-flight — concurrent callers join the
2088
+ * read already running instead of each walking every loader. What they are
2089
+ * promised, and what happens when a write lands mid-read, is the contract on
2090
+ * `inflightListReads`; what is memoized afterwards is the contract on
2091
+ * `listCache`.
1736
2092
  */
1737
2093
  async list(type) {
1738
- const cached = this.listCache.get(type);
1739
- if (cached && Date.now() - cached.ts < _MetadataManager.LIST_CACHE_TTL_MS) {
2094
+ const cached = this.readCachedList(type);
2095
+ if (cached) {
1740
2096
  return cached.items;
1741
2097
  }
2098
+ const joined = this.inflightListReads.get(type);
2099
+ if (joined) {
2100
+ return joined;
2101
+ }
2102
+ const shared = this.readListUncached(type).then(({ items, degraded }) => {
2103
+ if (this.inflightListReads.get(type) === shared) {
2104
+ this.cacheListResult(type, items, degraded);
2105
+ }
2106
+ return items;
2107
+ });
2108
+ this.inflightListReads.set(type, shared);
2109
+ try {
2110
+ return await shared;
2111
+ } finally {
2112
+ if (this.inflightListReads.get(type) === shared) {
2113
+ this.inflightListReads.delete(type);
2114
+ }
2115
+ }
2116
+ }
2117
+ /**
2118
+ * Assemble the `list()` answer for `type` from the in-memory registry plus
2119
+ * every loader, reporting (but not rethrowing) loaders that could not be
2120
+ * read.
2121
+ *
2122
+ * The body {@link list} used to inline, extracted so the caching and
2123
+ * single-flight bookkeeping around it has one thing to run at most once per
2124
+ * type (#5253). Deliberately does NOT touch `listCache` itself: whether this
2125
+ * result may be memoized depends on what happened to the read's registration
2126
+ * while it ran, which only `list()` can see.
2127
+ */
2128
+ async readListUncached(type) {
1742
2129
  const items = /* @__PURE__ */ new Map();
1743
2130
  const typeStore = this.registry.get(type);
1744
2131
  if (typeStore) {
@@ -1746,6 +2133,7 @@ var _MetadataManager = class _MetadataManager {
1746
2133
  items.set(name, data);
1747
2134
  }
1748
2135
  }
2136
+ let degraded = false;
1749
2137
  for (const loader of this.loaders.values()) {
1750
2138
  try {
1751
2139
  const loaderItems = await loader.loadMany(type);
@@ -1755,20 +2143,161 @@ var _MetadataManager = class _MetadataManager {
1755
2143
  items.set(itemAny.name, item);
1756
2144
  }
1757
2145
  }
2146
+ this.reportLoaderReadRecovered(loader.contract.name);
1758
2147
  } catch (e) {
1759
- this.logger.warn(`Loader ${loader.contract.name} failed to loadMany ${type}`, { error: e });
2148
+ degraded = true;
2149
+ this.reportLoaderReadFailure(loader.contract.name, type, e);
1760
2150
  }
1761
2151
  }
1762
- const result = Array.from(items.values());
1763
- this.cacheListResult(type, result);
1764
- return result;
2152
+ return { items: Array.from(items.values()), degraded };
1765
2153
  }
1766
- cacheListResult(type, items) {
1767
- this.listCache.set(type, { ts: Date.now(), items });
2154
+ /**
2155
+ * Report at `error`, once per outage episode — that a loader could not be
2156
+ * read while serving {@link list}.
2157
+ *
2158
+ * [#5108] This branch used to be dead for the loader that matters. Before
2159
+ * #5108 `DatabaseLoader` caught its own read failures and answered `[]`, so
2160
+ * `list()` received a *successful empty read* and never entered this `catch`
2161
+ * at all: an unreachable `sys_metadata` and "this environment declares no
2162
+ * `permission`" produced byte-identical results with not one line logged.
2163
+ * With the loader rethrowing everything but the benign not-provisioned case,
2164
+ * this is where the outage finally becomes speakable.
2165
+ *
2166
+ * `error`, not `warn`, per AGENTS.md → "Degradation log levels". Apply its
2167
+ * one question — *does the system still look normal from outside while
2168
+ * something it claims to know has not actually landed?* — and the answer is
2169
+ * yes: `list()` still returns, callers still get an array, nothing 500s, and
2170
+ * the set they gate on is quietly short. Which way that cuts depends on the
2171
+ * consumer, and both ways are silent (#3935 is the fail-open precedent).
2172
+ *
2173
+ * Said **once** per loader, and un-said on recovery, because `list()` is a
2174
+ * hot path — one line per outage, not one per read.
2175
+ *
2176
+ * [#5184] The once-only guard carries more weight than it used to: a
2177
+ * degraded `list()` result is now memoized for `DEGRADED_LIST_CACHE_TTL_MS`
2178
+ * rather than `LIST_CACHE_TTL_MS`, so during an outage the loader is
2179
+ * re-asked (and this method re-entered) roughly every 2s instead of every
2180
+ * 30s. That is the point — the outage stops being a 30s silent window and
2181
+ * recovery is noticed within seconds — and it costs nothing in log volume
2182
+ * precisely because `loaderReadFailureReported` still speaks only once.
2183
+ *
2184
+ * Deliberately does NOT rethrow: `list()` is the best-effort listing seam and
2185
+ * must keep serving what the reachable loaders hold. The strict counterpart
2186
+ * for callers whose answer is a security decision is `listForIndex()` (no
2187
+ * `catch`, feeding `matchEndpoint`) and {@link loadDiagnosed} (ADR-0110 D3)
2188
+ * for the singular read — both of which only became honest for
2189
+ * `DatabaseLoader` with the same #5108 change.
2190
+ */
2191
+ reportLoaderReadFailure(loaderName, type, error) {
2192
+ if (this.loaderReadFailureReported.has(loaderName)) return;
2193
+ this.loaderReadFailureReported.add(loaderName);
2194
+ this.logger.error(
2195
+ `[MetadataManager] Loader \`${loaderName}\` could NOT be read (first failure seen while listing \`${type}\`) \u2014 every list served from now on is a PARTIAL set presented as a complete one, and the server keeps reporting healthy. Consumers that gate on a declared set (permissions, sharing rules, policies, api endpoints) will read the declarations this loader holds as "never declared" \u2014 which grants or locks out depending on the consumer, silently either way. Fix: check the datasource behind \`${loaderName}\` \u2014 connection, credentials, and that its metadata table exists. The read is retried on the next list once the ${_MetadataManager.DEGRADED_LIST_CACHE_TTL_MS}ms degraded-result list cache lapses (a known-partial listing is memoized far more briefly than a complete one \u2014 #5184), so a transient cause recovers on its own within seconds and the recovery is logged.`,
2196
+ error instanceof Error ? error : void 0,
2197
+ { loader: loaderName, type, error }
2198
+ );
1768
2199
  }
1769
- /** Internal helper: drop the cached `list()` result for a type. */
2200
+ /** Un-say {@link reportLoaderReadFailure} once the loader answers again. */
2201
+ reportLoaderReadRecovered(loaderName) {
2202
+ if (!this.loaderReadFailureReported.delete(loaderName)) return;
2203
+ this.logger.info(
2204
+ `[MetadataManager] Loader \`${loaderName}\` is readable again \u2014 listings are complete once more.`
2205
+ );
2206
+ }
2207
+ /**
2208
+ * Memoize a completed {@link list} result.
2209
+ *
2210
+ * [#5184] `degraded` is not optional at the call site by accident — it is the
2211
+ * one thing this cache used to throw away. A result assembled while a loader
2212
+ * was unreadable is stored, but stored *as* what it is, so it expires on the
2213
+ * degraded TTL and any reader can tell it apart from a complete answer.
2214
+ */
2215
+ cacheListResult(type, items, degraded) {
2216
+ this.listCache.set(type, { ts: Date.now(), items, degraded });
2217
+ }
2218
+ /**
2219
+ * Read a still-fresh {@link listCache} entry, or `undefined` when there is
2220
+ * none / it has expired.
2221
+ *
2222
+ * [#5184] The single place the TTL policy is applied, so "a degraded entry
2223
+ * expires sooner" cannot be forgotten by a second reader. Returns the whole
2224
+ * entry rather than just `items` so callers keep access to `degraded`.
2225
+ */
2226
+ readCachedList(type) {
2227
+ const cached = this.listCache.get(type);
2228
+ if (!cached) return void 0;
2229
+ const ttl = cached.degraded ? _MetadataManager.DEGRADED_LIST_CACHE_TTL_MS : _MetadataManager.LIST_CACHE_TTL_MS;
2230
+ return Date.now() - cached.ts < ttl ? cached : void 0;
2231
+ }
2232
+ /**
2233
+ * Internal helper: drop every memoized or in-progress `list()` answer for a
2234
+ * type, so the next read observes the write that called this.
2235
+ *
2236
+ * [#5253] Retracting the in-flight read (not just the finished entry) is the
2237
+ * whole mid-read story, and it is pinned by test: the read keeps running for
2238
+ * the callers already waiting on it, but it loses the right to memoize its
2239
+ * pre-write answer, and a caller arriving after this point gets a fresh read
2240
+ * instead of joining a pre-write one. The reasoning — including why waiting
2241
+ * callers are NOT restarted — is on the `inflightListReads` field.
2242
+ *
2243
+ * [#5259] Both halves are only as good as WHEN the caller invokes this. This
2244
+ * clears what is stale *as of now*; it cannot pre-empt a store the caller has
2245
+ * not finished updating yet. Callers must therefore invalidate only once
2246
+ * every store already holds the state they are about to announce — see the
2247
+ * `listCache` field comment and {@link unregister}, whose pre-#5259 ordering
2248
+ * invalidated one await too early and let the next read cache a view in which
2249
+ * the registry was empty and the loader was not.
2250
+ */
1770
2251
  invalidateListCache(type) {
1771
2252
  this.listCache.delete(type);
2253
+ this.inflightListReads.delete(type);
2254
+ if (type === _MetadataManager.ENDPOINT_METADATA_TYPE) {
2255
+ this.endpointMatcher.invalidate();
2256
+ }
2257
+ }
2258
+ /**
2259
+ * Enumerate stored items of `type` for an index build — like {@link list},
2260
+ * but a store that cannot be read THROWS instead of contributing nothing.
2261
+ *
2262
+ * [#5089] `list()` deliberately logs a failing loader and skips it so a
2263
+ * partially-available metadata plane still serves what it can. That posture
2264
+ * is wrong for `matchEndpoint`: its `undefined` becomes an HTTP 404, and a
2265
+ * store outage that silently yields "zero declarations" would turn every
2266
+ * declared endpoint into a semantic "nothing declares this route". Same
2267
+ * distinction {@link loadDiagnosed} draws on the singular read (ADR-0110
2268
+ * D3) — a miss and an outage are different facts with opposite meanings.
2269
+ *
2270
+ * Deliberately private and single-purpose: it is not a second `list()`, it
2271
+ * is `list()`'s failure posture inverted for the one caller whose answer is
2272
+ * a security/availability decision rather than a best-effort listing.
2273
+ *
2274
+ * This surfaces only failures a loader actually reports — which, since
2275
+ * #5108, includes `DatabaseLoader`: it used to swallow its own read errors
2276
+ * into `[]`, making a DB outage invisible even here. It now rethrows every
2277
+ * read failure except the benign "table not provisioned yet", so this seam
2278
+ * holds against the real datasource-backed loader and not just the memory /
2279
+ * remote ones. (`database-loader.test.ts` pins that end to end: a broken
2280
+ * driver behind a real `DatabaseLoader` makes `matchEndpoint` reject rather
2281
+ * than answer a 404-shaped `undefined`.)
2282
+ */
2283
+ async listForIndex(type) {
2284
+ const items = /* @__PURE__ */ new Map();
2285
+ const typeStore = this.registry.get(type);
2286
+ if (typeStore) {
2287
+ for (const [name, data] of typeStore) {
2288
+ items.set(name, data);
2289
+ }
2290
+ }
2291
+ for (const loader of this.loaders.values()) {
2292
+ const loaderItems = await loader.loadMany(type);
2293
+ for (const item of loaderItems) {
2294
+ const itemAny = item;
2295
+ if (itemAny && typeof itemAny.name === "string" && !items.has(itemAny.name)) {
2296
+ items.set(itemAny.name, item);
2297
+ }
2298
+ }
2299
+ }
2300
+ return Array.from(items.values());
1772
2301
  }
1773
2302
  /**
1774
2303
  * Unregister/remove a metadata item by type and name.
@@ -1778,8 +2307,74 @@ var _MetadataManager = class _MetadataManager {
1778
2307
  * {@link MetadataWatchEvent} — the delete half of the {@link register}
1779
2308
  * contract. Pass `{ notify: false }` only for teardown that announces by
1780
2309
  * other means.
2310
+ *
2311
+ * ## [#5259] Storage FIRST, in-memory second — the order is the fix
2312
+ *
2313
+ * This method used to drop the registry entry and call
2314
+ * {@link invalidateListCache} *before* awaiting `loader.delete()`. Those two
2315
+ * steps are separated by a real await window (one DB round-trip per writable
2316
+ * loader), and inside it the manager was in a state that exists nowhere else:
2317
+ * **registry already empty, loader not yet empty**. `list()` merges the two,
2318
+ * so a read arriving in that window
2319
+ *
2320
+ * • missed the cache (it had just been invalidated),
2321
+ * • assembled the still-stored row into its answer, and
2322
+ * • memoized that answer as a COMPLETE read — the full 30s healthy TTL,
2323
+ * because no loader threw, so #5184's 2s degraded TTL never applied.
2324
+ *
2325
+ * Nothing invalidated again afterwards ({@link notifyWatchers} does not touch
2326
+ * `listCache`), so a row that was gone from storage kept being enumerated for
2327
+ * up to 30s — and `get()`, which never consulted that cache, disagreed with
2328
+ * `list()` the whole time. For a gating type (`permission`, `api`) the two
2329
+ * faces of the same manager answered opposite questions about whether a
2330
+ * declaration exists.
2331
+ *
2332
+ * {@link register} never had this defect, and the reason is instructive: it
2333
+ * writes the registry *first*, and the registry outranks every loader in the
2334
+ * merge, so throughout its own save window the merged view already equals the
2335
+ * post-write state. The invariant that makes register correct is not "where
2336
+ * the invalidate sits" but **the invalidate must be the last thing after
2337
+ * every store already holds the announced state**. Restated for delete, that
2338
+ * means storage first:
2339
+ *
2340
+ * 1. `await loader.delete()` on every writable loader. Throughout this
2341
+ * window registry AND loaders still hold the item, so a concurrent
2342
+ * `list()` observes a coherent pre-delete state — which is the truth,
2343
+ * because the delete has not landed and has not been announced.
2344
+ * 2. Drop the registry entry and `invalidateListCache(type)` — with **no
2345
+ * await between them**, so no read can interleave and observe the
2346
+ * half-applied state that produced the bug. Everything cached or
2347
+ * in-flight from step 1 is dropped here, at the moment the final state
2348
+ * becomes true.
2349
+ * 3. Publish + announce. #5219's invalidate-before-notify bar, unchanged:
2350
+ * a watcher woken by the `deleted` event and re-reading through `list()`
2351
+ * gets a fresh read of the post-delete state.
2352
+ *
2353
+ * **Composition with #5253's single-flight (this is the load-bearing half).**
2354
+ * A `list()` that is still walking the loaders when step 2 runs cannot be
2355
+ * fixed by dropping `listCache` alone — it has not written its entry yet, and
2356
+ * it would write the pre-delete answer *after* the invalidation. The
2357
+ * mechanism that covers it is `invalidateListCache()` also retracting the
2358
+ * read's registration in `inflightListReads`: a retracted read still resolves
2359
+ * for the callers already waiting on it (they asked before the delete) but
2360
+ * loses the right to memoize, and any caller arriving after step 2 starts a
2361
+ * fresh read rather than joining the pre-delete one. So every read is
2362
+ * covered: one that FINISHED in the window has its entry deleted, one still
2363
+ * IN FLIGHT loses its permission to cache, and one starting later reads the
2364
+ * post-delete state. That is why the invalidate must come after the deletes
2365
+ * rather than being duplicated on both sides of them — a second invalidate
2366
+ * before the await would buy nothing and would re-open step 1's window.
1781
2367
  */
1782
2368
  async unregister(type, name, options) {
2369
+ for (const loader of this.loaders.values()) {
2370
+ if (loader.contract.protocol !== "datasource:" || !loader.contract.capabilities.write) continue;
2371
+ if (typeof loader.delete !== "function") continue;
2372
+ try {
2373
+ await this.deleteMetaItemFromLoader(loader, type, name);
2374
+ } catch (error) {
2375
+ this.reportMetaItemDeleteFailure(loader.contract.name, type, name, error);
2376
+ }
2377
+ }
1783
2378
  const typeStore = this.registry.get(type);
1784
2379
  if (typeStore) {
1785
2380
  typeStore.delete(name);
@@ -1788,16 +2383,6 @@ var _MetadataManager = class _MetadataManager {
1788
2383
  }
1789
2384
  }
1790
2385
  this.invalidateListCache(type);
1791
- for (const loader of this.loaders.values()) {
1792
- if (loader.contract.protocol !== "datasource:" || !loader.contract.capabilities.write) continue;
1793
- if (typeof loader.delete === "function") {
1794
- try {
1795
- await loader.delete(type, name);
1796
- } catch (error) {
1797
- this.logger.warn(`Failed to delete ${type}/${name} from loader ${loader.contract.name}`, { error });
1798
- }
1799
- }
1800
- }
1801
2386
  await this.publishRealtimeMetadataEvent("deleted", type, name, {
1802
2387
  userId: options?.userId
1803
2388
  });
@@ -1812,6 +2397,73 @@ var _MetadataManager = class _MetadataManager {
1812
2397
  });
1813
2398
  }
1814
2399
  }
2400
+ /**
2401
+ * Delete one metadata item from one writable loader — the storage half of
2402
+ * {@link unregister}.
2403
+ *
2404
+ * A one-line wrapper on purpose: it gives this durability seam a **name**.
2405
+ * `check:durability-log-level` matches by callee name against an explicit
2406
+ * vocabulary, and the raw call is `loader.delete(...)` — putting `delete` in
2407
+ * that vocabulary would claim every `.delete()` in the monorepo (`Map`,
2408
+ * `Set`, cache handles, `URLSearchParams`) and the gate would drown in false
2409
+ * positives, which is exactly the failure mode its own header warns about.
2410
+ * Named here, `deleteMetaItemFromLoader` is in `DURABILITY_CRITICAL_CALLEES`
2411
+ * with a blast radius of precisely this call site, mirroring `saveMetaItem`
2412
+ * on the write side (#4754).
2413
+ *
2414
+ * [#5276] `MetadataLoader` now declares `delete?`, so no cast is left here.
2415
+ * It stays *optional* on the interface — `file:`/`memory:`/`http:`/`s3:`
2416
+ * loaders legitimately have none — and the guard below is therefore a type
2417
+ * narrowing rather than a policy decision. The policy lives at
2418
+ * `registerLoader()`: a `datasource:` loader that declares
2419
+ * `capabilities.write` cannot be registered without a `delete()`, which is
2420
+ * exactly the set of loaders this method is ever called for.
2421
+ */
2422
+ async deleteMetaItemFromLoader(loader, type, name) {
2423
+ const del = loader.delete;
2424
+ if (typeof del !== "function") return;
2425
+ await del.call(loader, type, name);
2426
+ }
2427
+ /**
2428
+ * Report — at `error` — that a loader refused to delete an item the runtime
2429
+ * has already dropped and announced as deleted.
2430
+ *
2431
+ * [#5259] This used to be a `logger.warn('Failed to delete …')` and continue.
2432
+ * AGENTS.md → "Degradation log levels" decides the level with one question:
2433
+ * *after the degradation, does the system still look normal from the outside
2434
+ * while something it claims is persisted has not actually landed?* Here it is
2435
+ * the deletion that did not land, which is the same class and the same
2436
+ * silence: `unregister()` resolves normally, the caller is told the delete
2437
+ * succeeded, and the surviving row is read straight back out of storage —
2438
+ * permanently, since nothing ever retries this. Durability/consistency
2439
+ * degradation ⇒ `error`, naming the **consequence** and the **fix**.
2440
+ *
2441
+ * **Why the registry entry is still dropped when this fires.** The
2442
+ * alternative — keep the item registered so runtime state matches storage —
2443
+ * looks safer and is not. The loader still holds the row, and `list()`/`get()`
2444
+ * merge registry ∪ loaders, so the item is served either way; the only thing
2445
+ * the surviving registry entry would change is *which copy wins*, pinning an
2446
+ * in-memory definition that outranks the stored row nobody is maintaining
2447
+ * anymore. Dropping it makes the very next read fall through to storage,
2448
+ * which is the actual truth after a failed delete — the item still exists —
2449
+ * and it surfaces that immediately (the item visibly reappears) instead of at
2450
+ * the next restart. One truth, read from where it lives; the divergence is
2451
+ * reported here rather than papered over with a second in-memory copy.
2452
+ *
2453
+ * **Said once per un-deleted item, not once per loader.** The once-per-outage
2454
+ * discipline of {@link reportLoaderReadFailure} exists because `list()` is hot
2455
+ * and its repeats are *identical*; these are not. Each line names a different
2456
+ * item that is still in storage and that nothing will ever retry, so
2457
+ * collapsing them would hand an operator the first casualty and silently drop
2458
+ * the rest of the list — the failure this level was raised to prevent.
2459
+ */
2460
+ reportMetaItemDeleteFailure(loaderName, type, name, error) {
2461
+ this.logger.error(
2462
+ `[MetadataManager] Loader \`${loaderName}\` could NOT delete \`${type}/${name}\` \u2014 the row is STILL in its store, while the runtime has already dropped the item from its registry and announced it as deleted. Nothing looks broken: \`unregister()\` resolves normally and the caller (Studio/Setup, REST DELETE, the CLI, a package teardown) is told the delete succeeded \u2014 but the surviving row is read straight back out of storage by the very next \`list()\`/\`get()\`, so the "deleted" item reappears and keeps reappearing across restarts. Nothing retries this delete. Fix: check the datasource behind \`${loaderName}\` \u2014 connection, credentials, and that its metadata table exists and is writable \u2014 then re-issue the delete for \`${type}/${name}\`. Until that succeeds the item is NOT deleted, whatever the delete call reported.`,
2463
+ error instanceof Error ? error : void 0,
2464
+ { loader: loaderName, type, name, error }
2465
+ );
2466
+ }
1815
2467
  /**
1816
2468
  * Check if a metadata item exists
1817
2469
  */
@@ -1932,6 +2584,12 @@ var _MetadataManager = class _MetadataManager {
1932
2584
  * 2. Snapshot all items in the package (publishedDefinition = clone(metadata))
1933
2585
  * 3. Increment version
1934
2586
  * 4. Set all items state → active
2587
+ *
2588
+ * [#5189, #5040 E7b] Step 1 additionally runs the **endpoint publish gates**
2589
+ * over every `api` item — see {@link gateApiItemsForPublish}. That pass is
2590
+ * NOT governed by `options.validate`: the gates are a contract, not a
2591
+ * lint (ADR-0121 D6 says publish REJECTS an unmetered anonymous endpoint),
2592
+ * and an opt-out flag on a security gate is the bypass this issue closed.
1935
2593
  */
1936
2594
  async publishPackage(packageId, options) {
1937
2595
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1956,8 +2614,9 @@ var _MetadataManager = class _MetadataManager {
1956
2614
  validationErrors: [{ type: "", name: "", message: `No metadata items found for package '${packageId}'` }]
1957
2615
  };
1958
2616
  }
2617
+ const validationErrors = [];
2618
+ validationErrors.push(...this.gateApiItemsForPublish(packageItems, options?.namespace));
1959
2619
  if (shouldValidate) {
1960
- const validationErrors = [];
1961
2620
  for (const item of packageItems) {
1962
2621
  const result = await this.validate(item.type, item.data);
1963
2622
  if (!result.valid && result.errors) {
@@ -1995,16 +2654,16 @@ var _MetadataManager = class _MetadataManager {
1995
2654
  }
1996
2655
  }
1997
2656
  }
1998
- if (validationErrors.length > 0) {
1999
- return {
2000
- success: false,
2001
- packageId,
2002
- version: 0,
2003
- publishedAt: now,
2004
- itemsPublished: 0,
2005
- validationErrors
2006
- };
2007
- }
2657
+ }
2658
+ if (validationErrors.length > 0) {
2659
+ return {
2660
+ success: false,
2661
+ packageId,
2662
+ version: 0,
2663
+ publishedAt: now,
2664
+ itemsPublished: 0,
2665
+ validationErrors
2666
+ };
2008
2667
  }
2009
2668
  let maxVersion = 0;
2010
2669
  for (const item of packageItems) {
@@ -2031,6 +2690,82 @@ var _MetadataManager = class _MetadataManager {
2031
2690
  itemsPublished: packageItems.length
2032
2691
  };
2033
2692
  }
2693
+ /**
2694
+ * [#5189, #5040 E7b] Run the endpoint publish gates over a package's `api`
2695
+ * items and report every failure as a publish-blocking validation error.
2696
+ *
2697
+ * ## Why this exists at all
2698
+ *
2699
+ * E7 (#5111) hung the five per-endpoint gates on
2700
+ * `ObjectStackDefinitionSchema`, which covers every path that parses a
2701
+ * STACK — `defineStack`, `os validate`, the lint scorer, artifact ingest,
2702
+ * `EnvironmentArtifactSchema.metadata`. It does not cover this one: an `api`
2703
+ * item can be minted item-by-item (`metadata.register()`, a Studio write)
2704
+ * and published here without a stack ever being parsed. Three of the gates
2705
+ * degrade safely when bypassed (the executor answers a structured 501; a
2706
+ * mis-namespaced path matches nothing), but **ADR-0121 D6 has no runtime
2707
+ * counterpart**: `authRequired: false` is honoured faithfully and an
2708
+ * unarmed `rateLimit` meters nothing, so the bypass mints an anonymous,
2709
+ * zero-quota execution entry point. Hence a gate here, on the same
2710
+ * function, rather than a second set of criteria that would drift.
2711
+ *
2712
+ * ## What it judges, and on what
2713
+ *
2714
+ * 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
2719
+ * `ApiEndpointSchema` fails here too — not extra strictness but a
2720
+ * precondition: an unparsed shape cannot be gated, and it could never be
2721
+ * served either (the matcher's own loud skip refuses it at load).
2722
+ *
2723
+ * @param packageItems every item collected for this package (all types).
2724
+ * @param namespace the caller-supplied `manifest.namespace`; `undefined`
2725
+ * fails the namespace gate, deliberately — see `publishPackage`'s option.
2726
+ * @returns one entry per gate failure, `[]` when the package declares no
2727
+ * `api` items (a package without endpoints is untouched by this pass).
2728
+ */
2729
+ gateApiItemsForPublish(packageItems, namespace) {
2730
+ const apiItems = packageItems.filter((i) => i.type === _MetadataManager.ENDPOINT_METADATA_TYPE);
2731
+ if (apiItems.length === 0) return [];
2732
+ const errors = [];
2733
+ const endpoints = [];
2734
+ const gatedItems = [];
2735
+ for (const item of apiItems) {
2736
+ const document = item.data?.metadata ?? item.data;
2737
+ const parsed = ApiEndpointSchema2.safeParse(document);
2738
+ if (!parsed.success) {
2739
+ for (const issue of parsed.error.issues) {
2740
+ errors.push({
2741
+ type: item.type,
2742
+ name: item.name,
2743
+ message: `api item '${item.name}' does not satisfy ApiEndpointSchema and cannot be published: ${issue.message} (at ${issue.path.join(".") || "<root>"}). An endpoint that does not parse cannot be gated and would be excluded from endpoint matching at load anyway.`
2744
+ });
2745
+ }
2746
+ continue;
2747
+ }
2748
+ endpoints.push(parsed.data);
2749
+ gatedItems.push({ name: item.name });
2750
+ }
2751
+ for (const issue of validateApiEndpointDeclarations(endpoints, { namespace })) {
2752
+ const index = typeof issue.path[1] === "number" ? issue.path[1] : void 0;
2753
+ if (index === void 0) {
2754
+ errors.push({
2755
+ type: _MetadataManager.ENDPOINT_METADATA_TYPE,
2756
+ name: "",
2757
+ message: `${issue.message} ${PUBLISH_NAMESPACE_REMEDY}`
2758
+ });
2759
+ continue;
2760
+ }
2761
+ errors.push({
2762
+ type: _MetadataManager.ENDPOINT_METADATA_TYPE,
2763
+ name: gatedItems[index]?.name ?? "",
2764
+ message: issue.message
2765
+ });
2766
+ }
2767
+ return errors;
2768
+ }
2034
2769
  /**
2035
2770
  * Revert entire package to last published state.
2036
2771
  * Restores all metadata definitions from their published snapshots.
@@ -2505,6 +3240,38 @@ var _MetadataManager = class _MetadataManager {
2505
3240
  }
2506
3241
  }
2507
3242
  // ==========================================
3243
+ // API Endpoint Resolution
3244
+ // ==========================================
3245
+ /**
3246
+ * Resolve a request's `method`+`path` to the declared `api` metadata item
3247
+ * that owns it — `IMetadataService.matchEndpoint` (#5080 contract, #5089
3248
+ * implementation, #5040 E2).
3249
+ *
3250
+ * The behaviour is specified by the contract text in
3251
+ * `packages/spec/src/contracts/metadata-service.ts`; the mechanics
3252
+ * (normalization, lazy index, loud parse-skip, duplicate resolution) live in
3253
+ * `./endpoint-matcher.ts` and are documented there.
3254
+ *
3255
+ * Scope is THIS instance. There is no environment parameter, because callers
3256
+ * already resolve the `metadata` service for the environment they serve —
3257
+ * adding one here would create a second scoping mechanism.
3258
+ *
3259
+ * This method is reached over HTTP on a real boot. The dispatcher seam
3260
+ * landed as #5090 (`packages/runtime/src/api-endpoint-step.ts`, called from
3261
+ * the `setFallbackHandler` the dispatcher plugin installs), and #4936's
3262
+ * wholesale publish refusal of a non-empty `apis:` was replaced by the
3263
+ * #5040 E7 per-shape gates (`packages/spec/src/api/endpoint-publish-gate.ts`)
3264
+ * — so declarations exist and requests arrive here. The showcase's two
3265
+ * declared endpoints are matched and executed through this path in
3266
+ * `packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`.
3267
+ *
3268
+ * @throws when the metadata store cannot be read — an outage must never be
3269
+ * reported as a miss, because a miss becomes a 404.
3270
+ */
3271
+ async matchEndpoint(query) {
3272
+ return this.endpointMatcher.match(query);
3273
+ }
3274
+ // ==========================================
2508
3275
  // Legacy Loader API (backward compatible)
2509
3276
  // ==========================================
2510
3277
  /**
@@ -2566,8 +3333,9 @@ var _MetadataManager = class _MetadataManager {
2566
3333
  }
2567
3334
  results.push(item);
2568
3335
  }
3336
+ this.reportLoaderReadRecovered(loader.contract.name);
2569
3337
  } catch (e) {
2570
- this.logger.warn(`Loader ${loader.contract.name} failed to loadMany ${type}`, { error: e });
3338
+ this.reportLoaderReadFailure(loader.contract.name, type, e);
2571
3339
  }
2572
3340
  }
2573
3341
  return results;
@@ -2693,6 +3461,7 @@ var _MetadataManager = class _MetadataManager {
2693
3461
  await this.stopWatching().catch(() => void 0);
2694
3462
  await this.stopRepositoryWatch().catch(() => void 0);
2695
3463
  this.listCache.clear();
3464
+ this.endpointMatcher.invalidate();
2696
3465
  }
2697
3466
  async startRepositoryWatch() {
2698
3467
  const repo = this.repository;
@@ -2722,17 +3491,57 @@ var _MetadataManager = class _MetadataManager {
2722
3491
  if (this.repoWatchIter === iter) this.repoWatchIter = void 0;
2723
3492
  }
2724
3493
  }
3494
+ /**
3495
+ * Drop every local cache of `type` (and of `name` within it) that a change
3496
+ * we did not perform ourselves has just invalidated, so the next read falls
3497
+ * through to the source of truth.
3498
+ *
3499
+ * The callers are the manager's *foreign-write* seams — the repository watch
3500
+ * loop ({@link applyRepoEvent}), the cluster peer replay in
3501
+ * {@link attachClusterPubSub}, and — since #5218 — `NodeMetadataManager`'s
3502
+ * chokidar handler, which is why this is `protected` rather than `private`.
3503
+ * All three learn about a write that landed somewhere else (the repo head;
3504
+ * another node's `sys_metadata`; an editor writing `rootDir/view/x.json`) and
3505
+ * hold caches that the write silently aged out. A file event qualifies on
3506
+ * exactly the definition that matters here: it did not come through this
3507
+ * manager's write API, so nothing has updated the caches on its behalf.
3508
+ * Local writes do not come through here: `register()` / `unregister()` /
3509
+ * `registerInMemory()` update the registry to the value they just wrote and
3510
+ * call `invalidateListCache()` themselves.
3511
+ *
3512
+ * **Delete, do not pre-fill.** Even when the event carries a body we drop the
3513
+ * registry entry rather than writing the body into it: the body reaching us
3514
+ * is a snapshot of *someone else's* write, already possibly superseded, and
3515
+ * pre-filling would race with the true head and require us to re-canonicalise
3516
+ * a definition we did not load. Lazy invalidation is the safer default —
3517
+ * `get()` then falls through to the loaders / repository, which is where the
3518
+ * truth is. (This paragraph is the rationale `applyRepoEvent` carried since
3519
+ * ADR-0008 PR-6; #5109 extended the same choice to the cluster path, #5218 to
3520
+ * the filesystem watcher — where "the truth" is the file chokidar just
3521
+ * reported, served by the `FilesystemLoader` the registry entry was shadowing.)
3522
+ *
3523
+ * `name` is optional because `MetadataWatchEvent.name` is: a nameless event
3524
+ * cannot address a registry entry, so it invalidates the list cache only.
3525
+ * Dropping the whole type store instead would evict `registerInMemory()`
3526
+ * artefacts (code-owned datasources, ADR-0015 Addendum) that no loader can
3527
+ * restore — an unrecoverable loss in exchange for a guess.
3528
+ */
3529
+ invalidateForForeignWrite(type, name) {
3530
+ if (name) {
3531
+ const typeStore = this.registry.get(type);
3532
+ if (typeStore) {
3533
+ typeStore.delete(name);
3534
+ if (typeStore.size === 0) this.registry.delete(type);
3535
+ }
3536
+ }
3537
+ this.invalidateListCache(type);
3538
+ }
2725
3539
  /** Translate a repo event to the legacy MetadataWatchEvent + invalidate caches. */
2726
3540
  applyRepoEvent(evt) {
2727
3541
  const ref = evt.ref;
2728
3542
  const type = ref.type;
2729
3543
  const name = ref.name;
2730
- const typeStore = this.registry.get(type);
2731
- if (typeStore) {
2732
- typeStore.delete(name);
2733
- if (typeStore.size === 0) this.registry.delete(type);
2734
- }
2735
- this.listCache.delete(type);
3544
+ this.invalidateForForeignWrite(type, name);
2736
3545
  const legacyType = evt.op === "create" ? "added" : evt.op === "delete" ? "deleted" : "changed";
2737
3546
  const legacyEvent = {
2738
3547
  type: legacyType,
@@ -2806,6 +3615,14 @@ var _MetadataManager = class _MetadataManager {
2806
3615
  const p = msg.payload;
2807
3616
  if (p?.originNode && p.originNode === this.clusterNodeId) return;
2808
3617
  if (!p?.type || !p.event) return;
3618
+ try {
3619
+ this.invalidateForForeignWrite(p.type, p.event.name);
3620
+ } catch (err) {
3621
+ this.logger.error("Cluster remote invalidation failed", void 0, {
3622
+ type: p.type,
3623
+ error: err instanceof Error ? err.message : String(err)
3624
+ });
3625
+ }
2809
3626
  setImmediate(() => {
2810
3627
  try {
2811
3628
  this.notifyWatchersLocal(p.type, p.event);
@@ -2936,7 +3753,24 @@ var _MetadataManager = class _MetadataManager {
2936
3753
  }
2937
3754
  };
2938
3755
  _MetadataManager.LIST_CACHE_TTL_MS = 3e4;
3756
+ /**
3757
+ * [#5184] TTL for an entry produced by a degraded read (≥1 loader threw).
3758
+ *
3759
+ * Deliberately at the top of the 1–2s band: the point of keeping degraded
3760
+ * results cached at all is to absorb a burst of `list()` calls issued from
3761
+ * inside one open transaction, and those bursts are milliseconds apart but
3762
+ * can be spread by per-row work. Two seconds covers that while still being
3763
+ * 15× shorter than the healthy TTL.
3764
+ */
3765
+ _MetadataManager.DEGRADED_LIST_CACHE_TTL_MS = 2e3;
2939
3766
  _MetadataManager.CLUSTER_CHANNEL = "metadata.changed";
3767
+ // ── #5089 (#5040 E2): declared-endpoint index ────────────────────────
3768
+ // Backs `matchEndpoint`. Lazily built from `api` items on the first call
3769
+ // and invalidated by every path that can change them — see
3770
+ // `invalidateListCache` (local writes, repo events, HMR/artifact ingest
3771
+ // which registers with `notify:false`, and — since #5109 — cluster peer
3772
+ // replay) and the `subscribe('api', …)` registration below.
3773
+ _MetadataManager.ENDPOINT_METADATA_TYPE = "api";
2940
3774
  var MetadataManager = _MetadataManager;
2941
3775
 
2942
3776
  // src/plugin.ts
@@ -3330,17 +4164,20 @@ var NodeMetadataManager = class extends MetadataManager {
3330
4164
  const type = parts[0];
3331
4165
  const fileName = parts[parts.length - 1];
3332
4166
  const name = path2.basename(fileName, path2.extname(fileName));
4167
+ this.invalidateForForeignWrite(type, name);
3333
4168
  let data = void 0;
3334
4169
  if (eventType !== "deleted") {
3335
- try {
3336
- data = await this.load(type, name, { useCache: false });
3337
- } catch (error) {
4170
+ const read = await this.loadDiagnosed(type, name, { useCache: false });
4171
+ if (read.degraded) {
3338
4172
  this.logger.error("Failed to load changed file", void 0, {
3339
4173
  filePath,
3340
- error: error instanceof Error ? error.message : String(error)
4174
+ metadataType: type,
4175
+ name,
4176
+ errors: read.errors
3341
4177
  });
3342
4178
  return;
3343
4179
  }
4180
+ data = read.data;
3344
4181
  }
3345
4182
  const event = {
3346
4183
  type: eventType,
@@ -4106,7 +4943,6 @@ var HistoryCleanupManager = class {
4106
4943
  const baseWhere = {};
4107
4944
  if (organizationId) baseWhere.organization_id = organizationId;
4108
4945
  const metaItems = await driver.find(historyTableName, {
4109
- object: historyTableName,
4110
4946
  where: baseWhere,
4111
4947
  fields: ["type", "name"]
4112
4948
  });
@@ -4123,7 +4959,6 @@ var HistoryCleanupManager = class {
4123
4959
  const filter = { type, name, ...baseWhere };
4124
4960
  try {
4125
4961
  const historyRecords = await driver.find(historyTableName, {
4126
- object: historyTableName,
4127
4962
  where: filter,
4128
4963
  orderBy: [{ field: "version", order: "desc" }],
4129
4964
  fields: ["id"]
@@ -4158,7 +4993,7 @@ var HistoryCleanupManager = class {
4158
4993
  const count = await driverAny.deleteMany(table, filter);
4159
4994
  return { deleted: typeof count === "number" ? count : 0, errors: 0 };
4160
4995
  }
4161
- const records = await driver.find(table, { object: table, where: filter, fields: ["id"] });
4996
+ const records = await driver.find(table, { where: filter, fields: ["id"] });
4162
4997
  const ids = records.map((r) => r.id).filter(Boolean);
4163
4998
  return this.bulkDeleteByIds(driver, table, ids);
4164
4999
  }
@@ -4214,13 +5049,11 @@ var HistoryCleanupManager = class {
4214
5049
  filter.type = { $nin: pinnedTypes };
4215
5050
  }
4216
5051
  recordsByAge = await driver.count(historyTableName, {
4217
- object: historyTableName,
4218
5052
  where: filter
4219
5053
  });
4220
5054
  }
4221
5055
  if (this.policy.maxVersions) {
4222
5056
  const metaItems = await driver.find(historyTableName, {
4223
- object: historyTableName,
4224
5057
  where: baseWhere,
4225
5058
  fields: ["type", "name"]
4226
5059
  });
@@ -4236,7 +5069,6 @@ var HistoryCleanupManager = class {
4236
5069
  const [type, name] = key.split("");
4237
5070
  const filter = { type, name, ...baseWhere };
4238
5071
  const count = await driver.count(historyTableName, {
4239
- object: historyTableName,
4240
5072
  where: filter
4241
5073
  });
4242
5074
  if (count > this.policy.maxVersions) {