@objectstack/metadata 17.0.0-rc.2 → 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/CHANGELOG.md +789 -0
- package/dist/errors.cjs +61 -0
- package/dist/errors.cjs.map +1 -0
- package/dist/errors.d.cts +18 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +34 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.cjs +885 -60
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +578 -4
- package/dist/index.d.ts +578 -4
- package/dist/index.js +889 -57
- package/dist/index.js.map +1 -1
- package/dist/node.cjs +885 -60
- package/dist/node.cjs.map +1 -1
- package/dist/node.js +889 -57
- package/dist/node.js.map +1 -1
- package/package.json +13 -8
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
|
|
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 (!
|
|
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 =
|
|
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.
|
|
1722
|
-
|
|
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
|
-
*
|
|
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.
|
|
1781
|
-
if (cached
|
|
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
|
-
|
|
2183
|
+
degraded = true;
|
|
2184
|
+
this.reportLoaderReadFailure(loader.contract.name, type, e);
|
|
1802
2185
|
}
|
|
1803
2186
|
}
|
|
1804
|
-
|
|
1805
|
-
this.cacheListResult(type, result);
|
|
1806
|
-
return result;
|
|
2187
|
+
return { items: Array.from(items.values()), degraded };
|
|
1807
2188
|
}
|
|
1808
|
-
|
|
1809
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
3378
|
-
|
|
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
|
-
|
|
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, {
|
|
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) {
|