@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.cjs CHANGED
@@ -214,7 +214,8 @@ module.exports = __toCommonJS(node_exports);
214
214
 
215
215
  // src/metadata-manager.ts
216
216
  var import_kernel = require("@objectstack/spec/kernel");
217
- var import_api = require("@objectstack/spec/api");
217
+ var import_api2 = require("@objectstack/spec/api");
218
+ var import_api3 = require("@objectstack/spec/api");
218
219
  var import_core = require("@objectstack/core");
219
220
 
220
221
  // src/serializers/json-serializer.ts
@@ -1132,6 +1133,57 @@ var DatabaseLoader = class {
1132
1133
  };
1133
1134
  }
1134
1135
  // ==========================================
1136
+ // Read-failure classification (#5108)
1137
+ // ==========================================
1138
+ /**
1139
+ * Decide what a failed READ against {@link tableName} means, and rethrow
1140
+ * unless it is the ONE benign reason.
1141
+ *
1142
+ * #5108 (rule from #4632; same shape as #4728 and #4825) — discriminate by
1143
+ * error TYPE. Every read method below used to `catch {}` into its own empty
1144
+ * value: `load` → `null`, `loadMany` → `[]`, `exists` → `false`, `stat` →
1145
+ * `null`, `list` → `[]`. That made a database the metadata plane cannot
1146
+ * reach **indistinguishable** from an environment where nothing of that type
1147
+ * was ever declared — and it erased the failure *inside the loader*, so
1148
+ * neither `MetadataManager`'s own `try/catch` degradation branches nor
1149
+ * {@link import('../metadata-manager.js').MetadataManager.loadDiagnosed}
1150
+ * (ADR-0110 D3, whose whole purpose is to tell a miss from an outage) could
1151
+ * report anything. Nowhere on the chain was there a line saying the read
1152
+ * failed.
1153
+ *
1154
+ * Why that is worse than a noisy error: every consumer that gates on a
1155
+ * *declared set* — permissions, sharing rules, policies, endpoint
1156
+ * declarations — reads the empty answer as "the author declared none". Some
1157
+ * then fail open (grant), some fail closed (lock out); both look healthy
1158
+ * from outside. This is the AGENTS.md → "Degradation log levels" shape the
1159
+ * repo has already paid for twice, one layer up from #4825.
1160
+ *
1161
+ * Exactly one failure reason is benign: `sys_metadata` has not been
1162
+ * provisioned yet. There are then genuinely no rows, so "nothing declared"
1163
+ * IS the truth, and a first boot must not explode. Every other reason —
1164
+ * connection drop, timeout, insufficient privileges, malformed query — means
1165
+ * the rows may well be there and simply were not seen.
1166
+ *
1167
+ * Classification is conservative in the same direction as
1168
+ * {@link isMissingTableError} itself: an unrecognised error is NOT benign.
1169
+ * A false "benign" silently mis-answers a security question; a false "real"
1170
+ * costs one loud error.
1171
+ *
1172
+ * @param error The value thrown by `_find` / `_findOne` / `_count`.
1173
+ * @throws The underlying driver error, unchanged — deliberately, matching
1174
+ * {@link nextEventSeq}. The loader does not log it: the caller owns
1175
+ * the consequence and is the only layer that knows what an
1176
+ * incomplete answer costs it (`MetadataManager.list()` reports it at
1177
+ * `error`; `listForIndex()`/`matchEndpoint` let it propagate so an
1178
+ * outage can never be served as a 404).
1179
+ * @returns normally ONLY for the benign case, licensing the caller to answer
1180
+ * with its empty value.
1181
+ */
1182
+ rethrowUnlessTableUnprovisioned(error) {
1183
+ if (isMissingTableError(error)) return;
1184
+ throw error;
1185
+ }
1186
+ // ==========================================
1135
1187
  // MetadataLoader Interface Implementation
1136
1188
  // ==========================================
1137
1189
  async load(type, name, _options) {
@@ -1170,7 +1222,8 @@ var DatabaseLoader = class {
1170
1222
  etag: record.checksum,
1171
1223
  loadTime: Date.now() - startTime
1172
1224
  };
1173
- } catch {
1225
+ } catch (error) {
1226
+ this.rethrowUnlessTableUnprovisioned(error);
1174
1227
  return {
1175
1228
  data: null,
1176
1229
  loadTime: Date.now() - startTime
@@ -1190,7 +1243,8 @@ var DatabaseLoader = class {
1190
1243
  const result = rows.map((row) => this.rowToData(row)).filter((data) => data !== null);
1191
1244
  this.loadManyCache?.set(type, result);
1192
1245
  return result;
1193
- } catch {
1246
+ } catch (error) {
1247
+ this.rethrowUnlessTableUnprovisioned(error);
1194
1248
  return [];
1195
1249
  }
1196
1250
  }
@@ -1205,7 +1259,8 @@ var DatabaseLoader = class {
1205
1259
  where: this.baseFilter(type, name)
1206
1260
  });
1207
1261
  return count > 0;
1208
- } catch {
1262
+ } catch (error) {
1263
+ this.rethrowUnlessTableUnprovisioned(error);
1209
1264
  return false;
1210
1265
  }
1211
1266
  }
@@ -1234,7 +1289,8 @@ var DatabaseLoader = class {
1234
1289
  };
1235
1290
  this.statCache?.set(key, stats);
1236
1291
  return stats;
1237
- } catch {
1292
+ } catch (error) {
1293
+ this.rethrowUnlessTableUnprovisioned(error);
1238
1294
  return null;
1239
1295
  }
1240
1296
  }
@@ -1252,7 +1308,8 @@ var DatabaseLoader = class {
1252
1308
  const names = rows.map((row) => row.name).filter((name) => typeof name === "string");
1253
1309
  this.listCache?.set(type, names);
1254
1310
  return names;
1255
- } catch {
1311
+ } catch (error) {
1312
+ this.rethrowUnlessTableUnprovisioned(error);
1256
1313
  return [];
1257
1314
  }
1258
1315
  }
@@ -1493,7 +1550,120 @@ function generateId() {
1493
1550
  return `meta_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
1494
1551
  }
1495
1552
 
1553
+ // src/endpoint-matcher.ts
1554
+ var import_api = require("@objectstack/spec/api");
1555
+ function normalizeEndpointMethod(method) {
1556
+ return String(method ?? "").toUpperCase();
1557
+ }
1558
+ function endpointIndexKey(method, path3) {
1559
+ return `${normalizeEndpointMethod(method)} ${(0, import_api.normalizeEndpointPath)(path3)}`;
1560
+ }
1561
+ function buildEndpointIndex(items, logger) {
1562
+ const index = /* @__PURE__ */ new Map();
1563
+ for (const item of items) {
1564
+ const parsed = import_api.ApiEndpointSchema.safeParse(item);
1565
+ if (!parsed.success) {
1566
+ const declaredName = item && typeof item === "object" && typeof item.name === "string" ? item.name : "<unnamed>";
1567
+ logger.error(
1568
+ `[EndpointMatcher] stored api item '${declaredName}' does not satisfy ApiEndpointSchema \u2014 it is EXCLUDED from endpoint matching and its declared route will answer 404. Fix the declaration (or remove it); the endpoint index never serves a half-valid shape.`,
1569
+ void 0,
1570
+ { issues: parsed.error.issues }
1571
+ );
1572
+ continue;
1573
+ }
1574
+ const endpoint = parsed.data;
1575
+ const gateFailure = (0, import_api.identityFreeEndpointGateFailure)(endpoint);
1576
+ if (gateFailure) {
1577
+ logger.error(
1578
+ `[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}`,
1579
+ void 0,
1580
+ { name: endpoint.name, issue: { path: gateFailure.path, message: gateFailure.message } }
1581
+ );
1582
+ continue;
1583
+ }
1584
+ const key = endpointIndexKey(endpoint.method, endpoint.path);
1585
+ const incumbent = index.get(key);
1586
+ if (!incumbent) {
1587
+ index.set(key, endpoint);
1588
+ continue;
1589
+ }
1590
+ const challengerWins = endpoint.name < incumbent.name;
1591
+ const winner = challengerWins ? endpoint : incumbent;
1592
+ const loser = challengerWins ? incumbent : endpoint;
1593
+ if (challengerWins) index.set(key, endpoint);
1594
+ logger.error(
1595
+ `[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.`,
1596
+ void 0,
1597
+ { key, winner: winner.name, ignored: loser.name }
1598
+ );
1599
+ }
1600
+ return index;
1601
+ }
1602
+ var EndpointMatcher = class {
1603
+ constructor(deps) {
1604
+ this.deps = deps;
1605
+ }
1606
+ /** Mark the index stale; the next {@link match} rebuilds it. */
1607
+ invalidate() {
1608
+ this.index = void 0;
1609
+ this.building = void 0;
1610
+ }
1611
+ /**
1612
+ * Resolve `method`+`path` to the owning declaration.
1613
+ *
1614
+ * @returns the parsed endpoint plus `params: {}`, or `undefined` on a miss.
1615
+ * @throws whatever the store read threw — an outage is never a miss.
1616
+ */
1617
+ async match(query) {
1618
+ const index = await this.ensureIndex();
1619
+ const endpoint = index.get(endpointIndexKey(query.method, query.path));
1620
+ if (!endpoint) return void 0;
1621
+ return { endpoint, params: {} };
1622
+ }
1623
+ async ensureIndex() {
1624
+ if (this.index) return this.index;
1625
+ if (this.building) return this.building;
1626
+ const build = (async () => {
1627
+ const items = await this.deps.listApiItems();
1628
+ return buildEndpointIndex(items, this.deps.logger);
1629
+ })();
1630
+ this.building = build;
1631
+ try {
1632
+ const built = await build;
1633
+ if (this.building === build) {
1634
+ this.index = built;
1635
+ this.building = void 0;
1636
+ }
1637
+ return built;
1638
+ } catch (error) {
1639
+ if (this.building === build) this.building = void 0;
1640
+ throw error;
1641
+ }
1642
+ }
1643
+ };
1644
+
1496
1645
  // src/metadata-manager.ts
1646
+ var WRITABLE_LOADER_METHODS = ["save", "delete"];
1647
+ var WRITABLE_LOADER_METHOD_SIGNATURE = {
1648
+ save: "save(type: string, name: string, data: any, options?: MetadataSaveOptions): Promise<MetadataSaveResult>",
1649
+ delete: "delete(type: string, name: string): Promise<void>"
1650
+ };
1651
+ function buildWritableLoaderMissingMethodsMessage(loaderName, missing) {
1652
+ const missingPhrase = missing.length === 2 ? "implements neither a `save()` nor a `delete()` method" : `implements no \`${missing[0]}()\` method`;
1653
+ const consequences = missing.map(
1654
+ (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. `
1655
+ );
1656
+ const repair = missing.map((method) => `\`${WRITABLE_LOADER_METHOD_SIGNATURE[method]}\``).join(" and ");
1657
+ 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.`;
1658
+ }
1659
+ function assertWritableLoaderContract(loader) {
1660
+ const { name, protocol, capabilities } = loader.contract;
1661
+ if (protocol !== "datasource:" || capabilities.write !== true) return;
1662
+ const missing = WRITABLE_LOADER_METHODS.filter((method) => typeof loader[method] !== "function");
1663
+ if (missing.length === 0) return;
1664
+ throw new Error(buildWritableLoaderMissingMethodsMessage(name, missing));
1665
+ }
1666
+ 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.";
1497
1667
  function generateEventUuid() {
1498
1668
  const c = globalThis.crypto;
1499
1669
  if (c && typeof c.randomUUID === "function") {
@@ -1524,14 +1694,127 @@ var _MetadataManager = class _MetadataManager {
1524
1694
  // acquire a fresh knex connection while the transaction is still holding
1525
1695
  // SQLite's single connection — knex waits the full `acquireConnectionTimeout`
1526
1696
  // (60s) before returning []. The cache absorbs the repeated lookups so the
1527
- // loader is only hit once per TTL window.
1697
+ // loader is only hit once per TTL window — for CONCURRENT callers as well as
1698
+ // sequential ones, since #5253. The cache on its own could only ever deliver
1699
+ // the sequential half of that promise: nothing is written until a read
1700
+ // completes, so everything issued before the first read returned used to miss
1701
+ // and walk every loader — N callers, N × 60s on the very stall described
1702
+ // above. The concurrent half is delivered by `inflightListReads` below, which
1703
+ // is why the two fields are one policy and are documented together.
1704
+ //
1705
+ // [#5184] That hazard is NOT historical — it was re-verified on the current
1706
+ // driver stack before this policy was chosen. `DatabaseLoader._find()` still
1707
+ // issues `engine.find('sys_metadata', …)` without threading the caller's
1708
+ // transaction, and `driver-sql` still treats SQLite as a single-connection
1709
+ // pool (`activeTransactions`, `assertBareKnexSafe` — the latter a dev/test
1710
+ // guard that is a no-op in production, so production still waits the timeout
1711
+ // out). `plugin-audit`'s `captureBefore` threads the transaction by hand for
1712
+ // exactly this reason. Hence the policy below keeps caching degraded reads
1713
+ // rather than skipping them: "don't cache a degraded read" would trade one
1714
+ // 30s silent window for a fresh 60s stall per call.
1715
+ //
1716
+ // [#5184] WHAT IS ACTUALLY CACHED, AND FOR HOW LONG — this paragraph is the
1717
+ // contract, and it describes `cacheListResult()` / `readCachedList()` below.
1718
+ // (An earlier version of this comment claimed the cache kept "only positive
1719
+ // (non-empty) hits or repeated hits with a stable miss signature". No such
1720
+ // condition ever existed in the code. Comment is contract; a comment that
1721
+ // describes a policy nothing implements is a declared ≠ enforced defect in
1722
+ // its own right, so it is replaced rather than patched.)
1723
+ //
1724
+ // • EVERY completed `list()` is cached, empty results included. There is
1725
+ // no non-empty test and no "miss signature" concept.
1726
+ // • An entry assembled while at least one loader THREW is a known-partial
1727
+ // answer: it is cached with `degraded: true` and expires after
1728
+ // `DEGRADED_LIST_CACHE_TTL_MS`, not `LIST_CACHE_TTL_MS`. So the burst of
1729
+ // repeated lookups the knex path above depends on is still absorbed,
1730
+ // while the window in which the manager serves a known-short set without
1731
+ // re-asking anyone shrinks from 30s to ~2s. Recovery is therefore also
1732
+ // noticed (and `reportLoaderReadRecovered` logged) within ~2s of storage
1733
+ // healing instead of up to 30s later.
1734
+ // • `degraded` lives ON the entry, not in a side table, so every consumer
1735
+ // of the cache can tell a complete answer from a partial one. Read
1736
+ // entries through `readCachedList()` rather than `listCache.get()`, so
1737
+ // the flag and its TTL are applied in one place.
1528
1738
  //
1529
1739
  // Invalidated on every `register()` / `unregister()` to keep CRUD writes
1530
1740
  // visible to subsequent reads.
1741
+ //
1742
+ // [#5259] WHERE in a write the invalidation sits is part of that promise, not
1743
+ // an implementation detail. `list()` merges registry ∪ loaders, so an
1744
+ // invalidation issued while only ONE of the two has been updated lets the
1745
+ // next read memoize the half-applied view for a full TTL. The rule both
1746
+ // writers follow: **invalidate last, once every store already holds the state
1747
+ // being announced** — `register()` satisfies it by writing the registry
1748
+ // first (the registry outranks loaders in the merge, so its save window
1749
+ // already shows the post-write value); `unregister()` satisfies it by
1750
+ // deleting from storage first and invalidating after, with nothing awaited
1751
+ // between the registry drop and the invalidation. See `unregister()`.
1531
1752
  this.listCache = /* @__PURE__ */ new Map();
1753
+ /**
1754
+ * [#5253] The `list()` read currently in flight for a metadata type — the
1755
+ * concurrent half of the `listCache` policy above.
1756
+ *
1757
+ * `listCache` memoizes an answer only once a read has *finished*, so it can
1758
+ * absorb the caller that arrives second in time but never the caller that
1759
+ * arrives second in flight. Everything issued while the first read is still
1760
+ * walking the loaders used to miss and start its own identical walk; on the
1761
+ * knex/SQLite path the field comment above is built for, that is 60s burned
1762
+ * per concurrent caller instead of once for all of them. A type is read once
1763
+ * at a time: whoever finds a read already running joins it.
1764
+ *
1765
+ * **Sharers share the outcome. This is a contract, not an accident.** Every
1766
+ * caller joining an in-flight read receives that read's exact result — the
1767
+ * same array instance, and, when a loader was unreadable, the same
1768
+ * known-partial set that gets memoized `degraded: true` on the short TTL.
1769
+ * There is no per-caller retry: `list()` is the best-effort listing seam and
1770
+ * does not throw (see {@link reportLoaderReadFailure}; the strict
1771
+ * counterparts are `listForIndex()` and {@link loadDiagnosed}), so a lost
1772
+ * loader is not an error to fail over from — it is the answer. Re-running the
1773
+ * read privately for a joiner would walk the same loaders in the same window
1774
+ * against the same outage, which is precisely what this map exists to
1775
+ * prevent. Should the seam ever acquire a rejecting path, that rejection is
1776
+ * shared by the same mechanism and for the same reason.
1777
+ *
1778
+ * **The registration is also the permission to cache.** An entry here says
1779
+ * "this read still describes the current state". {@link invalidateListCache}
1780
+ * retracts it, which is what makes a write landing mid-read safe in both
1781
+ * directions:
1782
+ * • the retracted read does NOT write its result into `listCache` when it
1783
+ * settles, so an answer assembled before the write cannot outlive the
1784
+ * write it predates (the invalidation wins — it is the later, better
1785
+ * informed fact);
1786
+ * • a `list()` issued after the invalidation starts a FRESH read instead of
1787
+ * joining one that predates the write.
1788
+ * That second point is the #5219 / #5229 ordering bar restated for
1789
+ * concurrency: a consumer woken by a metadata change must not observe the
1790
+ * event and pre-event state together, and handing a woken watcher an
1791
+ * in-flight read that began before the event would be exactly that.
1792
+ * Callers *already waiting* on the retracted read still receive its (now
1793
+ * possibly stale) result — they asked before the write, and restarting the
1794
+ * read under them would turn a write burst into an unbounded retry loop on
1795
+ * the one path the cache exists to keep off the loaders.
1796
+ *
1797
+ * Self-cleaning: the entry is dropped when the read settles, by that read
1798
+ * only, so a fresh read that already replaced it keeps its slot. Nothing
1799
+ * accumulates — a wave of callers arriving after settle finds the cache the
1800
+ * settle just wrote, and once that lapses it starts one new read.
1801
+ */
1802
+ this.inflightListReads = /* @__PURE__ */ new Map();
1803
+ // [#5108] Loader names whose read failure has already been reported at
1804
+ // `error` by `list()`. AGENTS.md → "Degradation log levels": say it once, at
1805
+ // the first degradation — `list()` is hot enough that one line per failed
1806
+ // read would bury the one line that matters. Cleared when the loader answers
1807
+ // again, so a second outage is reported again. Same once-only discipline as
1808
+ // `DatabaseLoader.schemaFailureReported`.
1809
+ this.loaderReadFailureReported = /* @__PURE__ */ new Set();
1532
1810
  this.repoWatchClosed = false;
1533
1811
  this.config = config;
1534
1812
  this.logger = (0, import_core.createLogger)({ level: "info", format: "pretty" });
1813
+ this.endpointMatcher = new EndpointMatcher({
1814
+ listApiItems: () => this.listForIndex(_MetadataManager.ENDPOINT_METADATA_TYPE),
1815
+ logger: this.logger
1816
+ });
1817
+ this.subscribe(_MetadataManager.ENDPOINT_METADATA_TYPE, () => this.endpointMatcher.invalidate());
1535
1818
  this.serializers = /* @__PURE__ */ new Map();
1536
1819
  const formats = config.formats || ["typescript", "json", "yaml"];
1537
1820
  if (formats.includes("json")) {
@@ -1649,7 +1932,7 @@ var _MetadataManager = class _MetadataManager {
1649
1932
  async publishRealtimeMetadataEvent(action, type, name, opts = {}) {
1650
1933
  if (!this.realtimeService) return;
1651
1934
  const eventType = `metadata.${type}.${action}`;
1652
- if (!import_api.MetadataEventType.options.includes(eventType)) {
1935
+ if (!import_api2.MetadataEventType.options.includes(eventType)) {
1653
1936
  this.logger.debug(
1654
1937
  `Metadata type '${type}' has no declared realtime event type (MetadataEventType) \u2014 skipping publish`,
1655
1938
  { eventType, name }
@@ -1657,7 +1940,7 @@ var _MetadataManager = class _MetadataManager {
1657
1940
  return;
1658
1941
  }
1659
1942
  try {
1660
- const event = import_api.MetadataEventSchema.parse({
1943
+ const event = import_api2.MetadataEventSchema.parse({
1661
1944
  id: generateEventUuid(),
1662
1945
  type: eventType,
1663
1946
  metadataType: type,
@@ -1681,8 +1964,16 @@ var _MetadataManager = class _MetadataManager {
1681
1964
  }
1682
1965
  /**
1683
1966
  * Register a new metadata loader (data source)
1967
+ *
1968
+ * [#5276, #5654] Rejects — loudly, before the loader is stored — a
1969
+ * `datasource:` loader that declares `capabilities.write` without
1970
+ * implementing `save()` **and** `delete()`. This is the **only** way into
1971
+ * `this.loaders` (the constructor's `config.loaders` come through here too),
1972
+ * which is what lets every later write-capability guard be defensive rather
1973
+ * than load-bearing.
1684
1974
  */
1685
1975
  registerLoader(loader) {
1976
+ assertWritableLoaderContract(loader);
1686
1977
  this.loaders.set(loader.contract.name, loader);
1687
1978
  this.logger.info(`Registered metadata loader: ${loader.contract.name} (${loader.contract.protocol})`);
1688
1979
  }
@@ -1718,9 +2009,9 @@ var _MetadataManager = class _MetadataManager {
1718
2009
  this.registry.get(type).set(name, data);
1719
2010
  this.invalidateListCache(type);
1720
2011
  for (const loader of this.loaders.values()) {
1721
- if (loader.save && loader.contract.protocol === "datasource:" && loader.contract.capabilities.write) {
1722
- await loader.save(type, name, data);
1723
- }
2012
+ if (loader.contract.protocol !== "datasource:" || !loader.contract.capabilities.write) continue;
2013
+ if (typeof loader.save !== "function") continue;
2014
+ await loader.save(type, name, data);
1724
2015
  }
1725
2016
  await this.publishRealtimeMetadataEvent(existed ? "updated" : "created", type, name, {
1726
2017
  definition: data,
@@ -1764,6 +2055,23 @@ var _MetadataManager = class _MetadataManager {
1764
2055
  /**
1765
2056
  * Get a metadata item by type and name.
1766
2057
  * Checks in-memory registry first, then falls back to loaders.
2058
+ *
2059
+ * Returns `undefined` both when nothing declares the item and when every
2060
+ * loader that could have held it FAILED — see {@link getDiagnosed} when the
2061
+ * caller must tell those apart. This is the same relationship {@link load}
2062
+ * has with {@link loadDiagnosed}, so every existing caller keeps its exact
2063
+ * behaviour and only callers that ASK for the verdict pay for it.
2064
+ *
2065
+ * [#5840] Deliberately NOT expressed as `(await getDiagnosed(…)).data`,
2066
+ * although that is what it computes. The obvious delegation adds one
2067
+ * `await` hop, and a registry hit here is observed one microtask sooner than
2068
+ * it would be through a second async frame — which `register()`'s watchers
2069
+ * depend on, because `notifyWatchers` does not await its handlers and
2070
+ * ObjectQL's bridge re-reads through `get()` on the event rather than
2071
+ * trusting the payload (`register-notifies-watchers.test.ts` pins it, and
2072
+ * went red on the delegating version). The duplication is three lines and is
2073
+ * pinned from the other side: `get()` and `getDiagnosed().data` are asserted
2074
+ * to agree on every case in `metadata-manager-get-diagnosed.test.ts`.
1767
2075
  */
1768
2076
  async get(type, name) {
1769
2077
  const typeStore = this.registry.get(type);
@@ -1774,13 +2082,85 @@ var _MetadataManager = class _MetadataManager {
1774
2082
  return result ?? void 0;
1775
2083
  }
1776
2084
  /**
1777
- * List all metadata items of a given type
2085
+ * `get`, plus whether the answer can be trusted as complete.
2086
+ *
2087
+ * [#5840] {@link loadDiagnosed} already computes this verdict — and `get()`
2088
+ * threw it away two hops later (`load` kept only `.data`, `get` turned that
2089
+ * `null` into `undefined`), so no caller of `get` could reach the one fact
2090
+ * ADR-0110 D3 exists to preserve: **a miss and an outage are different facts
2091
+ * with opposite security meanings.** A consumer that gates on a declaration
2092
+ * MUST NOT read `undefined` as "the author declared nothing" — an
2093
+ * availability failure would silently widen access (the REST `/actions`
2094
+ * fail-open branch, #3935) or make a positive claim about authorship from a
2095
+ * read that never happened (`code: null` in the layered read, #5707/#5532).
2096
+ *
2097
+ * This is the registry-first counterpart of {@link loadDiagnosed}, and that
2098
+ * difference is why callers of `get` cannot simply switch to `loadDiagnosed`:
2099
+ * doing so would skip the in-memory registry and change what they resolve.
2100
+ *
2101
+ * `degraded` is true when at least one loader threw AND nothing answered with
2102
+ * the item — never when the in-memory registry answered, because that answer
2103
+ * needed no loader. A clean miss (every loader answered, none had it) is NOT
2104
+ * degraded. The posture is deliberately conservative: with a loader down we
2105
+ * cannot prove the item is absent, so we decline to claim it is.
2106
+ */
2107
+ async getDiagnosed(type, name) {
2108
+ const typeStore = this.registry.get(type);
2109
+ if (typeStore?.has(name)) {
2110
+ return { data: typeStore.get(name), degraded: false, errors: [] };
2111
+ }
2112
+ const { data, degraded, errors } = await this.loadDiagnosed(type, name);
2113
+ return { data: data ?? void 0, degraded, errors };
2114
+ }
2115
+ /**
2116
+ * List all metadata items of a given type.
2117
+ *
2118
+ * Best-effort by contract: a loader that cannot be read is reported once and
2119
+ * skipped ({@link reportLoaderReadFailure}), so this resolves with what the
2120
+ * reachable loaders hold rather than throwing.
2121
+ *
2122
+ * [#5253] Reads of one type are single-flight — concurrent callers join the
2123
+ * read already running instead of each walking every loader. What they are
2124
+ * promised, and what happens when a write lands mid-read, is the contract on
2125
+ * `inflightListReads`; what is memoized afterwards is the contract on
2126
+ * `listCache`.
1778
2127
  */
1779
2128
  async list(type) {
1780
- const cached = this.listCache.get(type);
1781
- if (cached && Date.now() - cached.ts < _MetadataManager.LIST_CACHE_TTL_MS) {
2129
+ const cached = this.readCachedList(type);
2130
+ if (cached) {
1782
2131
  return cached.items;
1783
2132
  }
2133
+ const joined = this.inflightListReads.get(type);
2134
+ if (joined) {
2135
+ return joined;
2136
+ }
2137
+ const shared = this.readListUncached(type).then(({ items, degraded }) => {
2138
+ if (this.inflightListReads.get(type) === shared) {
2139
+ this.cacheListResult(type, items, degraded);
2140
+ }
2141
+ return items;
2142
+ });
2143
+ this.inflightListReads.set(type, shared);
2144
+ try {
2145
+ return await shared;
2146
+ } finally {
2147
+ if (this.inflightListReads.get(type) === shared) {
2148
+ this.inflightListReads.delete(type);
2149
+ }
2150
+ }
2151
+ }
2152
+ /**
2153
+ * Assemble the `list()` answer for `type` from the in-memory registry plus
2154
+ * every loader, reporting (but not rethrowing) loaders that could not be
2155
+ * read.
2156
+ *
2157
+ * The body {@link list} used to inline, extracted so the caching and
2158
+ * single-flight bookkeeping around it has one thing to run at most once per
2159
+ * type (#5253). Deliberately does NOT touch `listCache` itself: whether this
2160
+ * result may be memoized depends on what happened to the read's registration
2161
+ * while it ran, which only `list()` can see.
2162
+ */
2163
+ async readListUncached(type) {
1784
2164
  const items = /* @__PURE__ */ new Map();
1785
2165
  const typeStore = this.registry.get(type);
1786
2166
  if (typeStore) {
@@ -1788,6 +2168,7 @@ var _MetadataManager = class _MetadataManager {
1788
2168
  items.set(name, data);
1789
2169
  }
1790
2170
  }
2171
+ let degraded = false;
1791
2172
  for (const loader of this.loaders.values()) {
1792
2173
  try {
1793
2174
  const loaderItems = await loader.loadMany(type);
@@ -1797,20 +2178,161 @@ var _MetadataManager = class _MetadataManager {
1797
2178
  items.set(itemAny.name, item);
1798
2179
  }
1799
2180
  }
2181
+ this.reportLoaderReadRecovered(loader.contract.name);
1800
2182
  } catch (e) {
1801
- this.logger.warn(`Loader ${loader.contract.name} failed to loadMany ${type}`, { error: e });
2183
+ degraded = true;
2184
+ this.reportLoaderReadFailure(loader.contract.name, type, e);
1802
2185
  }
1803
2186
  }
1804
- const result = Array.from(items.values());
1805
- this.cacheListResult(type, result);
1806
- return result;
2187
+ return { items: Array.from(items.values()), degraded };
1807
2188
  }
1808
- cacheListResult(type, items) {
1809
- this.listCache.set(type, { ts: Date.now(), items });
2189
+ /**
2190
+ * Report at `error`, once per outage episode — that a loader could not be
2191
+ * read while serving {@link list}.
2192
+ *
2193
+ * [#5108] This branch used to be dead for the loader that matters. Before
2194
+ * #5108 `DatabaseLoader` caught its own read failures and answered `[]`, so
2195
+ * `list()` received a *successful empty read* and never entered this `catch`
2196
+ * at all: an unreachable `sys_metadata` and "this environment declares no
2197
+ * `permission`" produced byte-identical results with not one line logged.
2198
+ * With the loader rethrowing everything but the benign not-provisioned case,
2199
+ * this is where the outage finally becomes speakable.
2200
+ *
2201
+ * `error`, not `warn`, per AGENTS.md → "Degradation log levels". Apply its
2202
+ * one question — *does the system still look normal from outside while
2203
+ * something it claims to know has not actually landed?* — and the answer is
2204
+ * yes: `list()` still returns, callers still get an array, nothing 500s, and
2205
+ * the set they gate on is quietly short. Which way that cuts depends on the
2206
+ * consumer, and both ways are silent (#3935 is the fail-open precedent).
2207
+ *
2208
+ * Said **once** per loader, and un-said on recovery, because `list()` is a
2209
+ * hot path — one line per outage, not one per read.
2210
+ *
2211
+ * [#5184] The once-only guard carries more weight than it used to: a
2212
+ * degraded `list()` result is now memoized for `DEGRADED_LIST_CACHE_TTL_MS`
2213
+ * rather than `LIST_CACHE_TTL_MS`, so during an outage the loader is
2214
+ * re-asked (and this method re-entered) roughly every 2s instead of every
2215
+ * 30s. That is the point — the outage stops being a 30s silent window and
2216
+ * recovery is noticed within seconds — and it costs nothing in log volume
2217
+ * precisely because `loaderReadFailureReported` still speaks only once.
2218
+ *
2219
+ * Deliberately does NOT rethrow: `list()` is the best-effort listing seam and
2220
+ * must keep serving what the reachable loaders hold. The strict counterpart
2221
+ * for callers whose answer is a security decision is `listForIndex()` (no
2222
+ * `catch`, feeding `matchEndpoint`) and {@link loadDiagnosed} (ADR-0110 D3)
2223
+ * for the singular read — both of which only became honest for
2224
+ * `DatabaseLoader` with the same #5108 change.
2225
+ */
2226
+ reportLoaderReadFailure(loaderName, type, error) {
2227
+ if (this.loaderReadFailureReported.has(loaderName)) return;
2228
+ this.loaderReadFailureReported.add(loaderName);
2229
+ this.logger.error(
2230
+ `[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.`,
2231
+ error instanceof Error ? error : void 0,
2232
+ { loader: loaderName, type, error }
2233
+ );
2234
+ }
2235
+ /** Un-say {@link reportLoaderReadFailure} once the loader answers again. */
2236
+ reportLoaderReadRecovered(loaderName) {
2237
+ if (!this.loaderReadFailureReported.delete(loaderName)) return;
2238
+ this.logger.info(
2239
+ `[MetadataManager] Loader \`${loaderName}\` is readable again \u2014 listings are complete once more.`
2240
+ );
1810
2241
  }
1811
- /** Internal helper: drop the cached `list()` result for a type. */
2242
+ /**
2243
+ * Memoize a completed {@link list} result.
2244
+ *
2245
+ * [#5184] `degraded` is not optional at the call site by accident — it is the
2246
+ * one thing this cache used to throw away. A result assembled while a loader
2247
+ * was unreadable is stored, but stored *as* what it is, so it expires on the
2248
+ * degraded TTL and any reader can tell it apart from a complete answer.
2249
+ */
2250
+ cacheListResult(type, items, degraded) {
2251
+ this.listCache.set(type, { ts: Date.now(), items, degraded });
2252
+ }
2253
+ /**
2254
+ * Read a still-fresh {@link listCache} entry, or `undefined` when there is
2255
+ * none / it has expired.
2256
+ *
2257
+ * [#5184] The single place the TTL policy is applied, so "a degraded entry
2258
+ * expires sooner" cannot be forgotten by a second reader. Returns the whole
2259
+ * entry rather than just `items` so callers keep access to `degraded`.
2260
+ */
2261
+ readCachedList(type) {
2262
+ const cached = this.listCache.get(type);
2263
+ if (!cached) return void 0;
2264
+ const ttl = cached.degraded ? _MetadataManager.DEGRADED_LIST_CACHE_TTL_MS : _MetadataManager.LIST_CACHE_TTL_MS;
2265
+ return Date.now() - cached.ts < ttl ? cached : void 0;
2266
+ }
2267
+ /**
2268
+ * Internal helper: drop every memoized or in-progress `list()` answer for a
2269
+ * type, so the next read observes the write that called this.
2270
+ *
2271
+ * [#5253] Retracting the in-flight read (not just the finished entry) is the
2272
+ * whole mid-read story, and it is pinned by test: the read keeps running for
2273
+ * the callers already waiting on it, but it loses the right to memoize its
2274
+ * pre-write answer, and a caller arriving after this point gets a fresh read
2275
+ * instead of joining a pre-write one. The reasoning — including why waiting
2276
+ * callers are NOT restarted — is on the `inflightListReads` field.
2277
+ *
2278
+ * [#5259] Both halves are only as good as WHEN the caller invokes this. This
2279
+ * clears what is stale *as of now*; it cannot pre-empt a store the caller has
2280
+ * not finished updating yet. Callers must therefore invalidate only once
2281
+ * every store already holds the state they are about to announce — see the
2282
+ * `listCache` field comment and {@link unregister}, whose pre-#5259 ordering
2283
+ * invalidated one await too early and let the next read cache a view in which
2284
+ * the registry was empty and the loader was not.
2285
+ */
1812
2286
  invalidateListCache(type) {
1813
2287
  this.listCache.delete(type);
2288
+ this.inflightListReads.delete(type);
2289
+ if (type === _MetadataManager.ENDPOINT_METADATA_TYPE) {
2290
+ this.endpointMatcher.invalidate();
2291
+ }
2292
+ }
2293
+ /**
2294
+ * Enumerate stored items of `type` for an index build — like {@link list},
2295
+ * but a store that cannot be read THROWS instead of contributing nothing.
2296
+ *
2297
+ * [#5089] `list()` deliberately logs a failing loader and skips it so a
2298
+ * partially-available metadata plane still serves what it can. That posture
2299
+ * is wrong for `matchEndpoint`: its `undefined` becomes an HTTP 404, and a
2300
+ * store outage that silently yields "zero declarations" would turn every
2301
+ * declared endpoint into a semantic "nothing declares this route". Same
2302
+ * distinction {@link loadDiagnosed} draws on the singular read (ADR-0110
2303
+ * D3) — a miss and an outage are different facts with opposite meanings.
2304
+ *
2305
+ * Deliberately private and single-purpose: it is not a second `list()`, it
2306
+ * is `list()`'s failure posture inverted for the one caller whose answer is
2307
+ * a security/availability decision rather than a best-effort listing.
2308
+ *
2309
+ * This surfaces only failures a loader actually reports — which, since
2310
+ * #5108, includes `DatabaseLoader`: it used to swallow its own read errors
2311
+ * into `[]`, making a DB outage invisible even here. It now rethrows every
2312
+ * read failure except the benign "table not provisioned yet", so this seam
2313
+ * holds against the real datasource-backed loader and not just the memory /
2314
+ * remote ones. (`database-loader.test.ts` pins that end to end: a broken
2315
+ * driver behind a real `DatabaseLoader` makes `matchEndpoint` reject rather
2316
+ * than answer a 404-shaped `undefined`.)
2317
+ */
2318
+ async listForIndex(type) {
2319
+ const items = /* @__PURE__ */ new Map();
2320
+ const typeStore = this.registry.get(type);
2321
+ if (typeStore) {
2322
+ for (const [name, data] of typeStore) {
2323
+ items.set(name, data);
2324
+ }
2325
+ }
2326
+ for (const loader of this.loaders.values()) {
2327
+ const loaderItems = await loader.loadMany(type);
2328
+ for (const item of loaderItems) {
2329
+ const itemAny = item;
2330
+ if (itemAny && typeof itemAny.name === "string" && !items.has(itemAny.name)) {
2331
+ items.set(itemAny.name, item);
2332
+ }
2333
+ }
2334
+ }
2335
+ return Array.from(items.values());
1814
2336
  }
1815
2337
  /**
1816
2338
  * Unregister/remove a metadata item by type and name.
@@ -1820,8 +2342,74 @@ var _MetadataManager = class _MetadataManager {
1820
2342
  * {@link MetadataWatchEvent} — the delete half of the {@link register}
1821
2343
  * contract. Pass `{ notify: false }` only for teardown that announces by
1822
2344
  * other means.
2345
+ *
2346
+ * ## [#5259] Storage FIRST, in-memory second — the order is the fix
2347
+ *
2348
+ * This method used to drop the registry entry and call
2349
+ * {@link invalidateListCache} *before* awaiting `loader.delete()`. Those two
2350
+ * steps are separated by a real await window (one DB round-trip per writable
2351
+ * loader), and inside it the manager was in a state that exists nowhere else:
2352
+ * **registry already empty, loader not yet empty**. `list()` merges the two,
2353
+ * so a read arriving in that window
2354
+ *
2355
+ * • missed the cache (it had just been invalidated),
2356
+ * • assembled the still-stored row into its answer, and
2357
+ * • memoized that answer as a COMPLETE read — the full 30s healthy TTL,
2358
+ * because no loader threw, so #5184's 2s degraded TTL never applied.
2359
+ *
2360
+ * Nothing invalidated again afterwards ({@link notifyWatchers} does not touch
2361
+ * `listCache`), so a row that was gone from storage kept being enumerated for
2362
+ * up to 30s — and `get()`, which never consulted that cache, disagreed with
2363
+ * `list()` the whole time. For a gating type (`permission`, `api`) the two
2364
+ * faces of the same manager answered opposite questions about whether a
2365
+ * declaration exists.
2366
+ *
2367
+ * {@link register} never had this defect, and the reason is instructive: it
2368
+ * writes the registry *first*, and the registry outranks every loader in the
2369
+ * merge, so throughout its own save window the merged view already equals the
2370
+ * post-write state. The invariant that makes register correct is not "where
2371
+ * the invalidate sits" but **the invalidate must be the last thing after
2372
+ * every store already holds the announced state**. Restated for delete, that
2373
+ * means storage first:
2374
+ *
2375
+ * 1. `await loader.delete()` on every writable loader. Throughout this
2376
+ * window registry AND loaders still hold the item, so a concurrent
2377
+ * `list()` observes a coherent pre-delete state — which is the truth,
2378
+ * because the delete has not landed and has not been announced.
2379
+ * 2. Drop the registry entry and `invalidateListCache(type)` — with **no
2380
+ * await between them**, so no read can interleave and observe the
2381
+ * half-applied state that produced the bug. Everything cached or
2382
+ * in-flight from step 1 is dropped here, at the moment the final state
2383
+ * becomes true.
2384
+ * 3. Publish + announce. #5219's invalidate-before-notify bar, unchanged:
2385
+ * a watcher woken by the `deleted` event and re-reading through `list()`
2386
+ * gets a fresh read of the post-delete state.
2387
+ *
2388
+ * **Composition with #5253's single-flight (this is the load-bearing half).**
2389
+ * A `list()` that is still walking the loaders when step 2 runs cannot be
2390
+ * fixed by dropping `listCache` alone — it has not written its entry yet, and
2391
+ * it would write the pre-delete answer *after* the invalidation. The
2392
+ * mechanism that covers it is `invalidateListCache()` also retracting the
2393
+ * read's registration in `inflightListReads`: a retracted read still resolves
2394
+ * for the callers already waiting on it (they asked before the delete) but
2395
+ * loses the right to memoize, and any caller arriving after step 2 starts a
2396
+ * fresh read rather than joining the pre-delete one. So every read is
2397
+ * covered: one that FINISHED in the window has its entry deleted, one still
2398
+ * IN FLIGHT loses its permission to cache, and one starting later reads the
2399
+ * post-delete state. That is why the invalidate must come after the deletes
2400
+ * rather than being duplicated on both sides of them — a second invalidate
2401
+ * before the await would buy nothing and would re-open step 1's window.
1823
2402
  */
1824
2403
  async unregister(type, name, options) {
2404
+ for (const loader of this.loaders.values()) {
2405
+ if (loader.contract.protocol !== "datasource:" || !loader.contract.capabilities.write) continue;
2406
+ if (typeof loader.delete !== "function") continue;
2407
+ try {
2408
+ await this.deleteMetaItemFromLoader(loader, type, name);
2409
+ } catch (error) {
2410
+ this.reportMetaItemDeleteFailure(loader.contract.name, type, name, error);
2411
+ }
2412
+ }
1825
2413
  const typeStore = this.registry.get(type);
1826
2414
  if (typeStore) {
1827
2415
  typeStore.delete(name);
@@ -1830,16 +2418,6 @@ var _MetadataManager = class _MetadataManager {
1830
2418
  }
1831
2419
  }
1832
2420
  this.invalidateListCache(type);
1833
- for (const loader of this.loaders.values()) {
1834
- if (loader.contract.protocol !== "datasource:" || !loader.contract.capabilities.write) continue;
1835
- if (typeof loader.delete === "function") {
1836
- try {
1837
- await loader.delete(type, name);
1838
- } catch (error) {
1839
- this.logger.warn(`Failed to delete ${type}/${name} from loader ${loader.contract.name}`, { error });
1840
- }
1841
- }
1842
- }
1843
2421
  await this.publishRealtimeMetadataEvent("deleted", type, name, {
1844
2422
  userId: options?.userId
1845
2423
  });
@@ -1854,6 +2432,73 @@ var _MetadataManager = class _MetadataManager {
1854
2432
  });
1855
2433
  }
1856
2434
  }
2435
+ /**
2436
+ * Delete one metadata item from one writable loader — the storage half of
2437
+ * {@link unregister}.
2438
+ *
2439
+ * A one-line wrapper on purpose: it gives this durability seam a **name**.
2440
+ * `check:durability-log-level` matches by callee name against an explicit
2441
+ * vocabulary, and the raw call is `loader.delete(...)` — putting `delete` in
2442
+ * that vocabulary would claim every `.delete()` in the monorepo (`Map`,
2443
+ * `Set`, cache handles, `URLSearchParams`) and the gate would drown in false
2444
+ * positives, which is exactly the failure mode its own header warns about.
2445
+ * Named here, `deleteMetaItemFromLoader` is in `DURABILITY_CRITICAL_CALLEES`
2446
+ * with a blast radius of precisely this call site, mirroring `saveMetaItem`
2447
+ * on the write side (#4754).
2448
+ *
2449
+ * [#5276] `MetadataLoader` now declares `delete?`, so no cast is left here.
2450
+ * It stays *optional* on the interface — `file:`/`memory:`/`http:`/`s3:`
2451
+ * loaders legitimately have none — and the guard below is therefore a type
2452
+ * narrowing rather than a policy decision. The policy lives at
2453
+ * `registerLoader()`: a `datasource:` loader that declares
2454
+ * `capabilities.write` cannot be registered without a `delete()`, which is
2455
+ * exactly the set of loaders this method is ever called for.
2456
+ */
2457
+ async deleteMetaItemFromLoader(loader, type, name) {
2458
+ const del = loader.delete;
2459
+ if (typeof del !== "function") return;
2460
+ await del.call(loader, type, name);
2461
+ }
2462
+ /**
2463
+ * Report — at `error` — that a loader refused to delete an item the runtime
2464
+ * has already dropped and announced as deleted.
2465
+ *
2466
+ * [#5259] This used to be a `logger.warn('Failed to delete …')` and continue.
2467
+ * AGENTS.md → "Degradation log levels" decides the level with one question:
2468
+ * *after the degradation, does the system still look normal from the outside
2469
+ * while something it claims is persisted has not actually landed?* Here it is
2470
+ * the deletion that did not land, which is the same class and the same
2471
+ * silence: `unregister()` resolves normally, the caller is told the delete
2472
+ * succeeded, and the surviving row is read straight back out of storage —
2473
+ * permanently, since nothing ever retries this. Durability/consistency
2474
+ * degradation ⇒ `error`, naming the **consequence** and the **fix**.
2475
+ *
2476
+ * **Why the registry entry is still dropped when this fires.** The
2477
+ * alternative — keep the item registered so runtime state matches storage —
2478
+ * looks safer and is not. The loader still holds the row, and `list()`/`get()`
2479
+ * merge registry ∪ loaders, so the item is served either way; the only thing
2480
+ * the surviving registry entry would change is *which copy wins*, pinning an
2481
+ * in-memory definition that outranks the stored row nobody is maintaining
2482
+ * anymore. Dropping it makes the very next read fall through to storage,
2483
+ * which is the actual truth after a failed delete — the item still exists —
2484
+ * and it surfaces that immediately (the item visibly reappears) instead of at
2485
+ * the next restart. One truth, read from where it lives; the divergence is
2486
+ * reported here rather than papered over with a second in-memory copy.
2487
+ *
2488
+ * **Said once per un-deleted item, not once per loader.** The once-per-outage
2489
+ * discipline of {@link reportLoaderReadFailure} exists because `list()` is hot
2490
+ * and its repeats are *identical*; these are not. Each line names a different
2491
+ * item that is still in storage and that nothing will ever retry, so
2492
+ * collapsing them would hand an operator the first casualty and silently drop
2493
+ * the rest of the list — the failure this level was raised to prevent.
2494
+ */
2495
+ reportMetaItemDeleteFailure(loaderName, type, name, error) {
2496
+ this.logger.error(
2497
+ `[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.`,
2498
+ error instanceof Error ? error : void 0,
2499
+ { loader: loaderName, type, name, error }
2500
+ );
2501
+ }
1857
2502
  /**
1858
2503
  * Check if a metadata item exists
1859
2504
  */
@@ -1974,6 +2619,12 @@ var _MetadataManager = class _MetadataManager {
1974
2619
  * 2. Snapshot all items in the package (publishedDefinition = clone(metadata))
1975
2620
  * 3. Increment version
1976
2621
  * 4. Set all items state → active
2622
+ *
2623
+ * [#5189, #5040 E7b] Step 1 additionally runs the **endpoint publish gates**
2624
+ * over every `api` item — see {@link gateApiItemsForPublish}. That pass is
2625
+ * NOT governed by `options.validate`: the gates are a contract, not a
2626
+ * lint (ADR-0121 D6 says publish REJECTS an unmetered anonymous endpoint),
2627
+ * and an opt-out flag on a security gate is the bypass this issue closed.
1977
2628
  */
1978
2629
  async publishPackage(packageId, options) {
1979
2630
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1998,8 +2649,9 @@ var _MetadataManager = class _MetadataManager {
1998
2649
  validationErrors: [{ type: "", name: "", message: `No metadata items found for package '${packageId}'` }]
1999
2650
  };
2000
2651
  }
2652
+ const validationErrors = [];
2653
+ validationErrors.push(...this.gateApiItemsForPublish(packageItems, options?.namespace));
2001
2654
  if (shouldValidate) {
2002
- const validationErrors = [];
2003
2655
  for (const item of packageItems) {
2004
2656
  const result = await this.validate(item.type, item.data);
2005
2657
  if (!result.valid && result.errors) {
@@ -2037,16 +2689,16 @@ var _MetadataManager = class _MetadataManager {
2037
2689
  }
2038
2690
  }
2039
2691
  }
2040
- if (validationErrors.length > 0) {
2041
- return {
2042
- success: false,
2043
- packageId,
2044
- version: 0,
2045
- publishedAt: now,
2046
- itemsPublished: 0,
2047
- validationErrors
2048
- };
2049
- }
2692
+ }
2693
+ if (validationErrors.length > 0) {
2694
+ return {
2695
+ success: false,
2696
+ packageId,
2697
+ version: 0,
2698
+ publishedAt: now,
2699
+ itemsPublished: 0,
2700
+ validationErrors
2701
+ };
2050
2702
  }
2051
2703
  let maxVersion = 0;
2052
2704
  for (const item of packageItems) {
@@ -2073,6 +2725,82 @@ var _MetadataManager = class _MetadataManager {
2073
2725
  itemsPublished: packageItems.length
2074
2726
  };
2075
2727
  }
2728
+ /**
2729
+ * [#5189, #5040 E7b] Run the endpoint publish gates over a package's `api`
2730
+ * items and report every failure as a publish-blocking validation error.
2731
+ *
2732
+ * ## Why this exists at all
2733
+ *
2734
+ * E7 (#5111) hung the five per-endpoint gates on
2735
+ * `ObjectStackDefinitionSchema`, which covers every path that parses a
2736
+ * STACK — `defineStack`, `os validate`, the lint scorer, artifact ingest,
2737
+ * `EnvironmentArtifactSchema.metadata`. It does not cover this one: an `api`
2738
+ * item can be minted item-by-item (`metadata.register()`, a Studio write)
2739
+ * and published here without a stack ever being parsed. Three of the gates
2740
+ * degrade safely when bypassed (the executor answers a structured 501; a
2741
+ * mis-namespaced path matches nothing), but **ADR-0121 D6 has no runtime
2742
+ * counterpart**: `authRequired: false` is honoured faithfully and an
2743
+ * unarmed `rateLimit` meters nothing, so the bypass mints an anonymous,
2744
+ * zero-quota execution entry point. Hence a gate here, on the same
2745
+ * function, rather than a second set of criteria that would drift.
2746
+ *
2747
+ * ## What it judges, and on what
2748
+ *
2749
+ * The registry stores either a raw spec document or a publish envelope
2750
+ * (`{ name, packageId, state, metadata: {…spec} }`); the endpoint is read
2751
+ * out with the SAME rule this method's caller uses for
2752
+ * `publishedDefinition` (`data.metadata ?? data`), so publish gates exactly
2753
+ * the document publish is about to snapshot. An item that does not satisfy
2754
+ * `ApiEndpointSchema` fails here too — not extra strictness but a
2755
+ * precondition: an unparsed shape cannot be gated, and it could never be
2756
+ * served either (the matcher's own loud skip refuses it at load).
2757
+ *
2758
+ * @param packageItems every item collected for this package (all types).
2759
+ * @param namespace the caller-supplied `manifest.namespace`; `undefined`
2760
+ * fails the namespace gate, deliberately — see `publishPackage`'s option.
2761
+ * @returns one entry per gate failure, `[]` when the package declares no
2762
+ * `api` items (a package without endpoints is untouched by this pass).
2763
+ */
2764
+ gateApiItemsForPublish(packageItems, namespace) {
2765
+ const apiItems = packageItems.filter((i) => i.type === _MetadataManager.ENDPOINT_METADATA_TYPE);
2766
+ if (apiItems.length === 0) return [];
2767
+ const errors = [];
2768
+ const endpoints = [];
2769
+ const gatedItems = [];
2770
+ for (const item of apiItems) {
2771
+ const document = item.data?.metadata ?? item.data;
2772
+ const parsed = import_api3.ApiEndpointSchema.safeParse(document);
2773
+ if (!parsed.success) {
2774
+ for (const issue of parsed.error.issues) {
2775
+ errors.push({
2776
+ type: item.type,
2777
+ name: item.name,
2778
+ 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.`
2779
+ });
2780
+ }
2781
+ continue;
2782
+ }
2783
+ endpoints.push(parsed.data);
2784
+ gatedItems.push({ name: item.name });
2785
+ }
2786
+ for (const issue of (0, import_api3.validateApiEndpointDeclarations)(endpoints, { namespace })) {
2787
+ const index = typeof issue.path[1] === "number" ? issue.path[1] : void 0;
2788
+ if (index === void 0) {
2789
+ errors.push({
2790
+ type: _MetadataManager.ENDPOINT_METADATA_TYPE,
2791
+ name: "",
2792
+ message: `${issue.message} ${PUBLISH_NAMESPACE_REMEDY}`
2793
+ });
2794
+ continue;
2795
+ }
2796
+ errors.push({
2797
+ type: _MetadataManager.ENDPOINT_METADATA_TYPE,
2798
+ name: gatedItems[index]?.name ?? "",
2799
+ message: issue.message
2800
+ });
2801
+ }
2802
+ return errors;
2803
+ }
2076
2804
  /**
2077
2805
  * Revert entire package to last published state.
2078
2806
  * Restores all metadata definitions from their published snapshots.
@@ -2547,6 +3275,38 @@ var _MetadataManager = class _MetadataManager {
2547
3275
  }
2548
3276
  }
2549
3277
  // ==========================================
3278
+ // API Endpoint Resolution
3279
+ // ==========================================
3280
+ /**
3281
+ * Resolve a request's `method`+`path` to the declared `api` metadata item
3282
+ * that owns it — `IMetadataService.matchEndpoint` (#5080 contract, #5089
3283
+ * implementation, #5040 E2).
3284
+ *
3285
+ * The behaviour is specified by the contract text in
3286
+ * `packages/spec/src/contracts/metadata-service.ts`; the mechanics
3287
+ * (normalization, lazy index, loud parse-skip, duplicate resolution) live in
3288
+ * `./endpoint-matcher.ts` and are documented there.
3289
+ *
3290
+ * Scope is THIS instance. There is no environment parameter, because callers
3291
+ * already resolve the `metadata` service for the environment they serve —
3292
+ * adding one here would create a second scoping mechanism.
3293
+ *
3294
+ * This method is reached over HTTP on a real boot. The dispatcher seam
3295
+ * landed as #5090 (`packages/runtime/src/api-endpoint-step.ts`, called from
3296
+ * the `setFallbackHandler` the dispatcher plugin installs), and #4936's
3297
+ * wholesale publish refusal of a non-empty `apis:` was replaced by the
3298
+ * #5040 E7 per-shape gates (`packages/spec/src/api/endpoint-publish-gate.ts`)
3299
+ * — so declarations exist and requests arrive here. The showcase's two
3300
+ * declared endpoints are matched and executed through this path in
3301
+ * `packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`.
3302
+ *
3303
+ * @throws when the metadata store cannot be read — an outage must never be
3304
+ * reported as a miss, because a miss becomes a 404.
3305
+ */
3306
+ async matchEndpoint(query) {
3307
+ return this.endpointMatcher.match(query);
3308
+ }
3309
+ // ==========================================
2550
3310
  // Legacy Loader API (backward compatible)
2551
3311
  // ==========================================
2552
3312
  /**
@@ -2608,8 +3368,9 @@ var _MetadataManager = class _MetadataManager {
2608
3368
  }
2609
3369
  results.push(item);
2610
3370
  }
3371
+ this.reportLoaderReadRecovered(loader.contract.name);
2611
3372
  } catch (e) {
2612
- this.logger.warn(`Loader ${loader.contract.name} failed to loadMany ${type}`, { error: e });
3373
+ this.reportLoaderReadFailure(loader.contract.name, type, e);
2613
3374
  }
2614
3375
  }
2615
3376
  return results;
@@ -2735,6 +3496,7 @@ var _MetadataManager = class _MetadataManager {
2735
3496
  await this.stopWatching().catch(() => void 0);
2736
3497
  await this.stopRepositoryWatch().catch(() => void 0);
2737
3498
  this.listCache.clear();
3499
+ this.endpointMatcher.invalidate();
2738
3500
  }
2739
3501
  async startRepositoryWatch() {
2740
3502
  const repo = this.repository;
@@ -2764,17 +3526,57 @@ var _MetadataManager = class _MetadataManager {
2764
3526
  if (this.repoWatchIter === iter) this.repoWatchIter = void 0;
2765
3527
  }
2766
3528
  }
3529
+ /**
3530
+ * Drop every local cache of `type` (and of `name` within it) that a change
3531
+ * we did not perform ourselves has just invalidated, so the next read falls
3532
+ * through to the source of truth.
3533
+ *
3534
+ * The callers are the manager's *foreign-write* seams — the repository watch
3535
+ * loop ({@link applyRepoEvent}), the cluster peer replay in
3536
+ * {@link attachClusterPubSub}, and — since #5218 — `NodeMetadataManager`'s
3537
+ * chokidar handler, which is why this is `protected` rather than `private`.
3538
+ * All three learn about a write that landed somewhere else (the repo head;
3539
+ * another node's `sys_metadata`; an editor writing `rootDir/view/x.json`) and
3540
+ * hold caches that the write silently aged out. A file event qualifies on
3541
+ * exactly the definition that matters here: it did not come through this
3542
+ * manager's write API, so nothing has updated the caches on its behalf.
3543
+ * Local writes do not come through here: `register()` / `unregister()` /
3544
+ * `registerInMemory()` update the registry to the value they just wrote and
3545
+ * call `invalidateListCache()` themselves.
3546
+ *
3547
+ * **Delete, do not pre-fill.** Even when the event carries a body we drop the
3548
+ * registry entry rather than writing the body into it: the body reaching us
3549
+ * is a snapshot of *someone else's* write, already possibly superseded, and
3550
+ * pre-filling would race with the true head and require us to re-canonicalise
3551
+ * a definition we did not load. Lazy invalidation is the safer default —
3552
+ * `get()` then falls through to the loaders / repository, which is where the
3553
+ * truth is. (This paragraph is the rationale `applyRepoEvent` carried since
3554
+ * ADR-0008 PR-6; #5109 extended the same choice to the cluster path, #5218 to
3555
+ * the filesystem watcher — where "the truth" is the file chokidar just
3556
+ * reported, served by the `FilesystemLoader` the registry entry was shadowing.)
3557
+ *
3558
+ * `name` is optional because `MetadataWatchEvent.name` is: a nameless event
3559
+ * cannot address a registry entry, so it invalidates the list cache only.
3560
+ * Dropping the whole type store instead would evict `registerInMemory()`
3561
+ * artefacts (code-owned datasources, ADR-0015 Addendum) that no loader can
3562
+ * restore — an unrecoverable loss in exchange for a guess.
3563
+ */
3564
+ invalidateForForeignWrite(type, name) {
3565
+ if (name) {
3566
+ const typeStore = this.registry.get(type);
3567
+ if (typeStore) {
3568
+ typeStore.delete(name);
3569
+ if (typeStore.size === 0) this.registry.delete(type);
3570
+ }
3571
+ }
3572
+ this.invalidateListCache(type);
3573
+ }
2767
3574
  /** Translate a repo event to the legacy MetadataWatchEvent + invalidate caches. */
2768
3575
  applyRepoEvent(evt) {
2769
3576
  const ref = evt.ref;
2770
3577
  const type = ref.type;
2771
3578
  const name = ref.name;
2772
- const typeStore = this.registry.get(type);
2773
- if (typeStore) {
2774
- typeStore.delete(name);
2775
- if (typeStore.size === 0) this.registry.delete(type);
2776
- }
2777
- this.listCache.delete(type);
3579
+ this.invalidateForForeignWrite(type, name);
2778
3580
  const legacyType = evt.op === "create" ? "added" : evt.op === "delete" ? "deleted" : "changed";
2779
3581
  const legacyEvent = {
2780
3582
  type: legacyType,
@@ -2848,6 +3650,14 @@ var _MetadataManager = class _MetadataManager {
2848
3650
  const p = msg.payload;
2849
3651
  if (p?.originNode && p.originNode === this.clusterNodeId) return;
2850
3652
  if (!p?.type || !p.event) return;
3653
+ try {
3654
+ this.invalidateForForeignWrite(p.type, p.event.name);
3655
+ } catch (err) {
3656
+ this.logger.error("Cluster remote invalidation failed", void 0, {
3657
+ type: p.type,
3658
+ error: err instanceof Error ? err.message : String(err)
3659
+ });
3660
+ }
2851
3661
  setImmediate(() => {
2852
3662
  try {
2853
3663
  this.notifyWatchersLocal(p.type, p.event);
@@ -2978,7 +3788,24 @@ var _MetadataManager = class _MetadataManager {
2978
3788
  }
2979
3789
  };
2980
3790
  _MetadataManager.LIST_CACHE_TTL_MS = 3e4;
3791
+ /**
3792
+ * [#5184] TTL for an entry produced by a degraded read (≥1 loader threw).
3793
+ *
3794
+ * Deliberately at the top of the 1–2s band: the point of keeping degraded
3795
+ * results cached at all is to absorb a burst of `list()` calls issued from
3796
+ * inside one open transaction, and those bursts are milliseconds apart but
3797
+ * can be spread by per-row work. Two seconds covers that while still being
3798
+ * 15× shorter than the healthy TTL.
3799
+ */
3800
+ _MetadataManager.DEGRADED_LIST_CACHE_TTL_MS = 2e3;
2981
3801
  _MetadataManager.CLUSTER_CHANNEL = "metadata.changed";
3802
+ // ── #5089 (#5040 E2): declared-endpoint index ────────────────────────
3803
+ // Backs `matchEndpoint`. Lazily built from `api` items on the first call
3804
+ // and invalidated by every path that can change them — see
3805
+ // `invalidateListCache` (local writes, repo events, HMR/artifact ingest
3806
+ // which registers with `notify:false`, and — since #5109 — cluster peer
3807
+ // replay) and the `subscribe('api', …)` registration below.
3808
+ _MetadataManager.ENDPOINT_METADATA_TYPE = "api";
2982
3809
  var MetadataManager = _MetadataManager;
2983
3810
 
2984
3811
  // src/plugin.ts
@@ -3372,17 +4199,20 @@ var NodeMetadataManager = class extends MetadataManager {
3372
4199
  const type = parts[0];
3373
4200
  const fileName = parts[parts.length - 1];
3374
4201
  const name = path2.basename(fileName, path2.extname(fileName));
4202
+ this.invalidateForForeignWrite(type, name);
3375
4203
  let data = void 0;
3376
4204
  if (eventType !== "deleted") {
3377
- try {
3378
- data = await this.load(type, name, { useCache: false });
3379
- } catch (error) {
4205
+ const read = await this.loadDiagnosed(type, name, { useCache: false });
4206
+ if (read.degraded) {
3380
4207
  this.logger.error("Failed to load changed file", void 0, {
3381
4208
  filePath,
3382
- error: error instanceof Error ? error.message : String(error)
4209
+ metadataType: type,
4210
+ name,
4211
+ errors: read.errors
3383
4212
  });
3384
4213
  return;
3385
4214
  }
4215
+ data = read.data;
3386
4216
  }
3387
4217
  const event = {
3388
4218
  type: eventType,
@@ -4142,7 +4972,6 @@ var HistoryCleanupManager = class {
4142
4972
  const baseWhere = {};
4143
4973
  if (organizationId) baseWhere.organization_id = organizationId;
4144
4974
  const metaItems = await driver.find(historyTableName, {
4145
- object: historyTableName,
4146
4975
  where: baseWhere,
4147
4976
  fields: ["type", "name"]
4148
4977
  });
@@ -4159,7 +4988,6 @@ var HistoryCleanupManager = class {
4159
4988
  const filter = { type, name, ...baseWhere };
4160
4989
  try {
4161
4990
  const historyRecords = await driver.find(historyTableName, {
4162
- object: historyTableName,
4163
4991
  where: filter,
4164
4992
  orderBy: [{ field: "version", order: "desc" }],
4165
4993
  fields: ["id"]
@@ -4194,7 +5022,7 @@ var HistoryCleanupManager = class {
4194
5022
  const count = await driverAny.deleteMany(table, filter);
4195
5023
  return { deleted: typeof count === "number" ? count : 0, errors: 0 };
4196
5024
  }
4197
- const records = await driver.find(table, { object: table, where: filter, fields: ["id"] });
5025
+ const records = await driver.find(table, { where: filter, fields: ["id"] });
4198
5026
  const ids = records.map((r) => r.id).filter(Boolean);
4199
5027
  return this.bulkDeleteByIds(driver, table, ids);
4200
5028
  }
@@ -4250,13 +5078,11 @@ var HistoryCleanupManager = class {
4250
5078
  filter.type = { $nin: pinnedTypes };
4251
5079
  }
4252
5080
  recordsByAge = await driver.count(historyTableName, {
4253
- object: historyTableName,
4254
5081
  where: filter
4255
5082
  });
4256
5083
  }
4257
5084
  if (this.policy.maxVersions) {
4258
5085
  const metaItems = await driver.find(historyTableName, {
4259
- object: historyTableName,
4260
5086
  where: baseWhere,
4261
5087
  fields: ["type", "name"]
4262
5088
  });
@@ -4272,7 +5098,6 @@ var HistoryCleanupManager = class {
4272
5098
  const [type, name] = key.split("");
4273
5099
  const filter = { type, name, ...baseWhere };
4274
5100
  const count = await driver.count(historyTableName, {
4275
- object: historyTableName,
4276
5101
  where: filter
4277
5102
  });
4278
5103
  if (count > this.policy.maxVersions) {