@objectstack/metadata 17.0.0-rc.6 → 17.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/node.js CHANGED
@@ -177,7 +177,11 @@ import {
177
177
  ApiEndpointSchema as ApiEndpointSchema2,
178
178
  validateApiEndpointDeclarations
179
179
  } from "@objectstack/spec/api";
180
- import { createLogger } from "@objectstack/core";
180
+ import {
181
+ assertMetadataRegisterContract,
182
+ canonicalMetadataServiceType,
183
+ createLogger
184
+ } from "@objectstack/core";
181
185
 
182
186
  // src/serializers/json-serializer.ts
183
187
  var JSONSerializer = class {
@@ -1750,16 +1754,40 @@ var _MetadataManager = class _MetadataManager {
1750
1754
  // above. The concurrent half is delivered by `inflightListReads` below, which
1751
1755
  // is why the two fields are one policy and are documented together.
1752
1756
  //
1753
- // [#5184] That hazard is NOT historical — it was re-verified on the current
1754
- // driver stack before this policy was chosen. `DatabaseLoader._find()` still
1755
- // issues `engine.find('sys_metadata', …)` without threading the caller's
1756
- // transaction, and `driver-sql` still treats SQLite as a single-connection
1757
- // pool (`activeTransactions`, `assertBareKnexSafe` the latter a dev/test
1758
- // guard that is a no-op in production, so production still waits the timeout
1759
- // out). `plugin-audit`'s `captureBefore` threads the transaction by hand for
1760
- // exactly this reason. Hence the policy below keeps caching degraded reads
1761
- // rather than skipping them: "don't cache a degraded read" would trade one
1762
- // 30s silent window for a fresh 60s stall per call.
1757
+ // [#5184; re-measured under #7708 on 2026-08-11] That hazard is NOT
1758
+ // historical and the re-measurement NARROWED it rather than retiring it.
1759
+ // Measured on the current stack: real `ObjectQL` + real `SqlDriver`
1760
+ // (better-sqlite3), a real `DatabaseLoader.list()` with the loader's own
1761
+ // cache off, `knex.client.pool.max === 1` confirmed for the SQLite dialect
1762
+ // and `acquireConnectionTimeout` left at the knex default of 60s.
1763
+ //
1764
+ // Transaction opened DIRECTLY on the driver (`driver.beginTransaction()`)
1765
+ // the read STALLS for the full timeout and then throws knex's "Timeout
1766
+ // acquiring a connection" (measured: 60_085ms). `DatabaseLoader._find()`
1767
+ // forwards no options, so nothing threads the caller's transaction, and
1768
+ // `driver-sql` still models SQLite as a single-connection pool
1769
+ // (`activeTransactions`, `assertBareKnexSafe` — the latter a dev/test
1770
+ // guard that is a no-op in production, so production still waits the
1771
+ // timeout out). `list()` catches that throw and degrades, which is
1772
+ // precisely the entry whose TTL this policy is choosing.
1773
+ // • Transaction opened through `engine.transaction()` / `ScopedContext`
1774
+ // → returns immediately (measured: 12ms, with `activeTransactions === 1`
1775
+ // and the driver observably receiving the handle on the call). Those
1776
+ // publish the transaction into the engine's ambient `txStore` (ADR-0034)
1777
+ // and `buildDriverOptions` threads it onto the read for the loader.
1778
+ //
1779
+ // So the stall shape is live but CONDITIONAL: it needs an open transaction
1780
+ // that the engine's ambient store cannot see. `SqlDriver.ensureSequencesTable()`
1781
+ // is the live witness that this is worth designing against — it takes
1782
+ // `parentTrx` and runs its DDL on the caller's transaction for exactly this
1783
+ // reason, with `assertBareKnexSafe` as the tripwire for callers that forget;
1784
+ // `sql-driver-sqlite-tx-guard.test.ts` pins both halves. (Until #7708 the
1785
+ // witness cited here was `plugin-audit`'s `captureBefore`, retired by #6656.
1786
+ // It was REPLACED rather than dropped: the example died, the hazard did not.)
1787
+ //
1788
+ // Hence the policy below keeps caching degraded reads rather than skipping
1789
+ // them: "don't cache a degraded read" would trade one 30s silent window for
1790
+ // a fresh 60s stall per call on every caller in the first bullet.
1763
1791
  //
1764
1792
  // [#5184] WHAT IS ACTUALLY CACHED, AND FOR HOW LONG — this paragraph is the
1765
1793
  // contract, and it describes `cacheListResult()` / `readCachedList()` below.
@@ -1846,6 +1874,15 @@ var _MetadataManager = class _MetadataManager {
1846
1874
  * only, so a fresh read that already replaced it keeps its slot. Nothing
1847
1875
  * accumulates — a wave of callers arriving after settle finds the cache the
1848
1876
  * settle just wrote, and once that lapses it starts one new read.
1877
+ *
1878
+ * [#6504] The shared value is the whole {@link ListReadResult}, not just
1879
+ * `items`. "Sharers share the outcome" above is stated about the answer *and*
1880
+ * its degraded verdict, and while the promise carried only `items` that was
1881
+ * true of `list()` alone: a {@link listDiagnosed} caller joining an in-flight
1882
+ * read had no way to reach the verdict that read had already computed, and
1883
+ * would have had to either re-walk the loaders (defeating this map) or invent
1884
+ * a second, unmemoized answer. `list()` narrows to `.items` at its own return
1885
+ * instead, so every sharer still receives the same array instance.
1849
1886
  */
1850
1887
  this.inflightListReads = /* @__PURE__ */ new Map();
1851
1888
  // [#5108] Loader names whose read failure has already been reported at
@@ -2042,6 +2079,8 @@ var _MetadataManager = class _MetadataManager {
2042
2079
  * {@link MetadataWriteOptions.notify} before doing so.
2043
2080
  */
2044
2081
  async register(type, name, data, options) {
2082
+ assertMetadataRegisterContract(type, name, data);
2083
+ type = canonicalMetadataServiceType(type);
2045
2084
  if (this.config.persistence?.writable === false) {
2046
2085
  const msg = `MetadataManager is read-only (persistence.writable=false); refusing to register ${type}/${name}`;
2047
2086
  if (this.config.validation?.throwOnError) {
@@ -2094,6 +2133,7 @@ var _MetadataManager = class _MetadataManager {
2094
2133
  * consumers will read the pre-write definition until restart.
2095
2134
  */
2096
2135
  registerInMemory(type, name, data) {
2136
+ type = canonicalMetadataServiceType(type);
2097
2137
  if (!this.registry.has(type)) {
2098
2138
  this.registry.set(type, /* @__PURE__ */ new Map());
2099
2139
  }
@@ -2136,6 +2176,7 @@ var _MetadataManager = class _MetadataManager {
2136
2176
  * `metadata-manager-get-diagnosed.test.ts`.
2137
2177
  */
2138
2178
  async get(type, name) {
2179
+ type = canonicalMetadataServiceType(type);
2139
2180
  const typeStore = this.registry.get(type);
2140
2181
  if (typeStore?.has(name)) {
2141
2182
  return typeStore.get(name);
@@ -2167,6 +2208,7 @@ var _MetadataManager = class _MetadataManager {
2167
2208
  * cannot prove the item is absent, so we decline to claim it is.
2168
2209
  */
2169
2210
  async getDiagnosed(type, name) {
2211
+ type = canonicalMetadataServiceType(type);
2170
2212
  const typeStore = this.registry.get(type);
2171
2213
  if (typeStore?.has(name)) {
2172
2214
  return { data: typeStore.get(name), degraded: false, errors: [] };
@@ -2188,19 +2230,64 @@ var _MetadataManager = class _MetadataManager {
2188
2230
  * `listCache`.
2189
2231
  */
2190
2232
  async list(type) {
2233
+ return (await this.readList(canonicalMetadataServiceType(type))).items;
2234
+ }
2235
+ /**
2236
+ * `list`, plus whether the answer can be trusted as complete.
2237
+ *
2238
+ * [#6504] The plural counterpart of {@link getDiagnosed}, and the same defect
2239
+ * one read over: `readListUncached` has computed this verdict since #5184 and
2240
+ * `list()` spent it entirely on a cache TTL, so a consumer receiving a short
2241
+ * set could not ask whether it was short because that is all anyone declared
2242
+ * or because a loader was down. {@link reportLoaderReadFailure}'s own message
2243
+ * says what that costs — "every list served from now on is a PARTIAL set
2244
+ * presented as a complete one, and the server keeps reporting healthy" — and
2245
+ * until this member existed that sentence was addressed to a log reader only,
2246
+ * because no caller had a way to ask.
2247
+ *
2248
+ * Sharper than the singular case rather than merely analogous: `list` is the
2249
+ * read whose answer carries a **count**, and a consumer restating
2250
+ * `items.length` as "this environment contains N items" makes a positive,
2251
+ * numeric claim about what an author declared out of a read that partly did
2252
+ * not happen.
2253
+ *
2254
+ * Reads through exactly the same cache and single-flight machinery `list()`
2255
+ * does — same entry, same TTLs, same in-flight join — so asking for the
2256
+ * verdict costs no extra loader walk, and `list()` and
2257
+ * `listDiagnosed().items` cannot drift: they are the same read, narrowed at
2258
+ * different points. `degraded` is true when at least one loader threw while
2259
+ * this set was assembled; unlike {@link getDiagnosed} it does NOT additionally
2260
+ * require that nothing answered, because a plural read that lost one loader is
2261
+ * partial even when the others answered plenty — which is the whole fact.
2262
+ */
2263
+ async listDiagnosed(type) {
2264
+ const { items, degraded, errors } = await this.readList(canonicalMetadataServiceType(type));
2265
+ return { items, degraded, errors };
2266
+ }
2267
+ /**
2268
+ * The cached / single-flight read behind {@link list} and
2269
+ * {@link listDiagnosed}.
2270
+ *
2271
+ * [#6504] Extracted so the two members are one read seen at two widths rather
2272
+ * than two implementations that have to be kept in agreement — the shape
2273
+ * `get`/`getDiagnosed` pay for with a duplicated body and a test pinning them
2274
+ * to each other. Everything below is unchanged in behaviour from when it was
2275
+ * inlined in `list()`; only the verdict now survives the return.
2276
+ */
2277
+ async readList(type) {
2191
2278
  const cached = this.readCachedList(type);
2192
2279
  if (cached) {
2193
- return cached.items;
2280
+ return cached;
2194
2281
  }
2195
2282
  const joined = this.inflightListReads.get(type);
2196
2283
  if (joined) {
2197
2284
  return joined;
2198
2285
  }
2199
- const shared = this.readListUncached(type).then(({ items, degraded }) => {
2286
+ const shared = this.readListUncached(type).then((result) => {
2200
2287
  if (this.inflightListReads.get(type) === shared) {
2201
- this.cacheListResult(type, items, degraded);
2288
+ this.cacheListResult(type, result);
2202
2289
  }
2203
- return items;
2290
+ return result;
2204
2291
  });
2205
2292
  this.inflightListReads.set(type, shared);
2206
2293
  try {
@@ -2231,6 +2318,7 @@ var _MetadataManager = class _MetadataManager {
2231
2318
  }
2232
2319
  }
2233
2320
  let degraded = false;
2321
+ const errors = [];
2234
2322
  for (const loader of this.loaders.values()) {
2235
2323
  try {
2236
2324
  const loaderItems = await loader.loadMany(type);
@@ -2243,10 +2331,11 @@ var _MetadataManager = class _MetadataManager {
2243
2331
  this.reportLoaderReadRecovered(loader.contract.name);
2244
2332
  } catch (e) {
2245
2333
  degraded = true;
2334
+ errors.push(`${loader.contract.name}: ${e instanceof Error ? e.message : String(e)}`);
2246
2335
  this.reportLoaderReadFailure(loader.contract.name, type, e);
2247
2336
  }
2248
2337
  }
2249
- return { items: Array.from(items.values()), degraded };
2338
+ return { items: Array.from(items.values()), degraded, errors };
2250
2339
  }
2251
2340
  /**
2252
2341
  * Report — at `error`, once per outage episode — that a loader could not be
@@ -2308,9 +2397,14 @@ var _MetadataManager = class _MetadataManager {
2308
2397
  * one thing this cache used to throw away. A result assembled while a loader
2309
2398
  * was unreadable is stored, but stored *as* what it is, so it expires on the
2310
2399
  * degraded TTL and any reader can tell it apart from a complete answer.
2400
+ *
2401
+ * [#6504] Takes the whole read result rather than its parts for the same
2402
+ * reason: a signature that spreads the verdict across positional arguments is
2403
+ * one a later caller can quietly fill with `false`, which is how the verdict
2404
+ * was lost on the way out in the first place.
2311
2405
  */
2312
- cacheListResult(type, items, degraded) {
2313
- this.listCache.set(type, { ts: Date.now(), items, degraded });
2406
+ cacheListResult(type, result) {
2407
+ this.listCache.set(type, { ts: Date.now(), ...result });
2314
2408
  }
2315
2409
  /**
2316
2410
  * Read a still-fresh {@link listCache} entry, or `undefined` when there is
@@ -2463,6 +2557,7 @@ var _MetadataManager = class _MetadataManager {
2463
2557
  * before the await would buy nothing and would re-open step 1's window.
2464
2558
  */
2465
2559
  async unregister(type, name, options) {
2560
+ type = canonicalMetadataServiceType(type);
2466
2561
  for (const loader of this.loaders.values()) {
2467
2562
  if (loader.contract.protocol !== "datasource:" || !loader.contract.capabilities.write) continue;
2468
2563
  if (typeof loader.delete !== "function") continue;
@@ -2565,6 +2660,7 @@ var _MetadataManager = class _MetadataManager {
2565
2660
  * Check if a metadata item exists
2566
2661
  */
2567
2662
  async exists(type, name) {
2663
+ type = canonicalMetadataServiceType(type);
2568
2664
  if (this.registry.get(type)?.has(name)) {
2569
2665
  return true;
2570
2666
  }
@@ -2579,6 +2675,7 @@ var _MetadataManager = class _MetadataManager {
2579
2675
  * List all names of metadata items of a given type
2580
2676
  */
2581
2677
  async listNames(type) {
2678
+ type = canonicalMetadataServiceType(type);
2582
2679
  const names = /* @__PURE__ */ new Set();
2583
2680
  const typeStore = this.registry.get(type);
2584
2681
  if (typeStore) {
@@ -2884,11 +2981,21 @@ var _MetadataManager = class _MetadataManager {
2884
2981
  }
2885
2982
  }
2886
2983
  if (packageItems.length === 0) {
2887
- throw new Error(`No metadata items found for package '${packageId}'`);
2984
+ const err = new Error(
2985
+ `No metadata items found for package '${packageId}'`
2986
+ );
2987
+ err.code = "RESOURCE_NOT_FOUND";
2988
+ err.status = 404;
2989
+ throw err;
2888
2990
  }
2889
2991
  const hasPublished = packageItems.some((item) => item.data.publishedDefinition !== void 0);
2890
2992
  if (!hasPublished) {
2891
- throw new Error(`Package '${packageId}' has never been published`);
2993
+ const err = new Error(
2994
+ `Package '${packageId}' has never been published`
2995
+ );
2996
+ err.code = "RESOURCE_CONFLICT";
2997
+ err.status = 409;
2998
+ throw err;
2892
2999
  }
2893
3000
  for (const item of packageItems) {
2894
3001
  if (item.data.publishedDefinition !== void 0) {
@@ -3141,6 +3248,7 @@ var _MetadataManager = class _MetadataManager {
3141
3248
  * @returns An unsubscribe function.
3142
3249
  */
3143
3250
  subscribe(type, callback) {
3251
+ type = canonicalMetadataServiceType(type);
3144
3252
  this.addWatchCallback(type, callback);
3145
3253
  return () => this.removeWatchCallback(type, callback);
3146
3254
  }