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