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

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