@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/index.cjs CHANGED
@@ -1783,16 +1783,40 @@ var _MetadataManager = class _MetadataManager {
1783
1783
  // above. The concurrent half is delivered by `inflightListReads` below, which
1784
1784
  // is why the two fields are one policy and are documented together.
1785
1785
  //
1786
- // [#5184] That hazard is NOT historical — it was re-verified on the current
1787
- // driver stack before this policy was chosen. `DatabaseLoader._find()` still
1788
- // issues `engine.find('sys_metadata', …)` without threading the caller's
1789
- // transaction, and `driver-sql` still treats SQLite as a single-connection
1790
- // pool (`activeTransactions`, `assertBareKnexSafe` the latter a dev/test
1791
- // guard that is a no-op in production, so production still waits the timeout
1792
- // out). `plugin-audit`'s `captureBefore` threads the transaction by hand for
1793
- // exactly this reason. Hence the policy below keeps caching degraded reads
1794
- // rather than skipping them: "don't cache a degraded read" would trade one
1795
- // 30s silent window for a fresh 60s stall per call.
1786
+ // [#5184; re-measured under #7708 on 2026-08-11] That hazard is NOT
1787
+ // historical and the re-measurement NARROWED it rather than retiring it.
1788
+ // Measured on the current stack: real `ObjectQL` + real `SqlDriver`
1789
+ // (better-sqlite3), a real `DatabaseLoader.list()` with the loader's own
1790
+ // cache off, `knex.client.pool.max === 1` confirmed for the SQLite dialect
1791
+ // and `acquireConnectionTimeout` left at the knex default of 60s.
1792
+ //
1793
+ // Transaction opened DIRECTLY on the driver (`driver.beginTransaction()`)
1794
+ // the read STALLS for the full timeout and then throws knex's "Timeout
1795
+ // acquiring a connection" (measured: 60_085ms). `DatabaseLoader._find()`
1796
+ // forwards no options, so nothing threads the caller's transaction, and
1797
+ // `driver-sql` still models SQLite as a single-connection pool
1798
+ // (`activeTransactions`, `assertBareKnexSafe` — the latter a dev/test
1799
+ // guard that is a no-op in production, so production still waits the
1800
+ // timeout out). `list()` catches that throw and degrades, which is
1801
+ // precisely the entry whose TTL this policy is choosing.
1802
+ // • Transaction opened through `engine.transaction()` / `ScopedContext`
1803
+ // → returns immediately (measured: 12ms, with `activeTransactions === 1`
1804
+ // and the driver observably receiving the handle on the call). Those
1805
+ // publish the transaction into the engine's ambient `txStore` (ADR-0034)
1806
+ // and `buildDriverOptions` threads it onto the read for the loader.
1807
+ //
1808
+ // So the stall shape is live but CONDITIONAL: it needs an open transaction
1809
+ // that the engine's ambient store cannot see. `SqlDriver.ensureSequencesTable()`
1810
+ // is the live witness that this is worth designing against — it takes
1811
+ // `parentTrx` and runs its DDL on the caller's transaction for exactly this
1812
+ // reason, with `assertBareKnexSafe` as the tripwire for callers that forget;
1813
+ // `sql-driver-sqlite-tx-guard.test.ts` pins both halves. (Until #7708 the
1814
+ // witness cited here was `plugin-audit`'s `captureBefore`, retired by #6656.
1815
+ // It was REPLACED rather than dropped: the example died, the hazard did not.)
1816
+ //
1817
+ // Hence the policy below keeps caching degraded reads rather than skipping
1818
+ // them: "don't cache a degraded read" would trade one 30s silent window for
1819
+ // a fresh 60s stall per call on every caller in the first bullet.
1796
1820
  //
1797
1821
  // [#5184] WHAT IS ACTUALLY CACHED, AND FOR HOW LONG — this paragraph is the
1798
1822
  // contract, and it describes `cacheListResult()` / `readCachedList()` below.
@@ -1879,6 +1903,15 @@ var _MetadataManager = class _MetadataManager {
1879
1903
  * only, so a fresh read that already replaced it keeps its slot. Nothing
1880
1904
  * accumulates — a wave of callers arriving after settle finds the cache the
1881
1905
  * settle just wrote, and once that lapses it starts one new read.
1906
+ *
1907
+ * [#6504] The shared value is the whole {@link ListReadResult}, not just
1908
+ * `items`. "Sharers share the outcome" above is stated about the answer *and*
1909
+ * its degraded verdict, and while the promise carried only `items` that was
1910
+ * true of `list()` alone: a {@link listDiagnosed} caller joining an in-flight
1911
+ * read had no way to reach the verdict that read had already computed, and
1912
+ * would have had to either re-walk the loaders (defeating this map) or invent
1913
+ * a second, unmemoized answer. `list()` narrows to `.items` at its own return
1914
+ * instead, so every sharer still receives the same array instance.
1882
1915
  */
1883
1916
  this.inflightListReads = /* @__PURE__ */ new Map();
1884
1917
  // [#5108] Loader names whose read failure has already been reported at
@@ -2075,6 +2108,8 @@ var _MetadataManager = class _MetadataManager {
2075
2108
  * {@link MetadataWriteOptions.notify} before doing so.
2076
2109
  */
2077
2110
  async register(type, name, data, options) {
2111
+ (0, import_core.assertMetadataRegisterContract)(type, name, data);
2112
+ type = (0, import_core.canonicalMetadataServiceType)(type);
2078
2113
  if (this.config.persistence?.writable === false) {
2079
2114
  const msg = `MetadataManager is read-only (persistence.writable=false); refusing to register ${type}/${name}`;
2080
2115
  if (this.config.validation?.throwOnError) {
@@ -2127,6 +2162,7 @@ var _MetadataManager = class _MetadataManager {
2127
2162
  * consumers will read the pre-write definition until restart.
2128
2163
  */
2129
2164
  registerInMemory(type, name, data) {
2165
+ type = (0, import_core.canonicalMetadataServiceType)(type);
2130
2166
  if (!this.registry.has(type)) {
2131
2167
  this.registry.set(type, /* @__PURE__ */ new Map());
2132
2168
  }
@@ -2169,6 +2205,7 @@ var _MetadataManager = class _MetadataManager {
2169
2205
  * `metadata-manager-get-diagnosed.test.ts`.
2170
2206
  */
2171
2207
  async get(type, name) {
2208
+ type = (0, import_core.canonicalMetadataServiceType)(type);
2172
2209
  const typeStore = this.registry.get(type);
2173
2210
  if (typeStore?.has(name)) {
2174
2211
  return typeStore.get(name);
@@ -2200,6 +2237,7 @@ var _MetadataManager = class _MetadataManager {
2200
2237
  * cannot prove the item is absent, so we decline to claim it is.
2201
2238
  */
2202
2239
  async getDiagnosed(type, name) {
2240
+ type = (0, import_core.canonicalMetadataServiceType)(type);
2203
2241
  const typeStore = this.registry.get(type);
2204
2242
  if (typeStore?.has(name)) {
2205
2243
  return { data: typeStore.get(name), degraded: false, errors: [] };
@@ -2221,19 +2259,64 @@ var _MetadataManager = class _MetadataManager {
2221
2259
  * `listCache`.
2222
2260
  */
2223
2261
  async list(type) {
2262
+ return (await this.readList((0, import_core.canonicalMetadataServiceType)(type))).items;
2263
+ }
2264
+ /**
2265
+ * `list`, plus whether the answer can be trusted as complete.
2266
+ *
2267
+ * [#6504] The plural counterpart of {@link getDiagnosed}, and the same defect
2268
+ * one read over: `readListUncached` has computed this verdict since #5184 and
2269
+ * `list()` spent it entirely on a cache TTL, so a consumer receiving a short
2270
+ * set could not ask whether it was short because that is all anyone declared
2271
+ * or because a loader was down. {@link reportLoaderReadFailure}'s own message
2272
+ * says what that costs — "every list served from now on is a PARTIAL set
2273
+ * presented as a complete one, and the server keeps reporting healthy" — and
2274
+ * until this member existed that sentence was addressed to a log reader only,
2275
+ * because no caller had a way to ask.
2276
+ *
2277
+ * Sharper than the singular case rather than merely analogous: `list` is the
2278
+ * read whose answer carries a **count**, and a consumer restating
2279
+ * `items.length` as "this environment contains N items" makes a positive,
2280
+ * numeric claim about what an author declared out of a read that partly did
2281
+ * not happen.
2282
+ *
2283
+ * Reads through exactly the same cache and single-flight machinery `list()`
2284
+ * does — same entry, same TTLs, same in-flight join — so asking for the
2285
+ * verdict costs no extra loader walk, and `list()` and
2286
+ * `listDiagnosed().items` cannot drift: they are the same read, narrowed at
2287
+ * different points. `degraded` is true when at least one loader threw while
2288
+ * this set was assembled; unlike {@link getDiagnosed} it does NOT additionally
2289
+ * require that nothing answered, because a plural read that lost one loader is
2290
+ * partial even when the others answered plenty — which is the whole fact.
2291
+ */
2292
+ async listDiagnosed(type) {
2293
+ const { items, degraded, errors } = await this.readList((0, import_core.canonicalMetadataServiceType)(type));
2294
+ return { items, degraded, errors };
2295
+ }
2296
+ /**
2297
+ * The cached / single-flight read behind {@link list} and
2298
+ * {@link listDiagnosed}.
2299
+ *
2300
+ * [#6504] Extracted so the two members are one read seen at two widths rather
2301
+ * than two implementations that have to be kept in agreement — the shape
2302
+ * `get`/`getDiagnosed` pay for with a duplicated body and a test pinning them
2303
+ * to each other. Everything below is unchanged in behaviour from when it was
2304
+ * inlined in `list()`; only the verdict now survives the return.
2305
+ */
2306
+ async readList(type) {
2224
2307
  const cached = this.readCachedList(type);
2225
2308
  if (cached) {
2226
- return cached.items;
2309
+ return cached;
2227
2310
  }
2228
2311
  const joined = this.inflightListReads.get(type);
2229
2312
  if (joined) {
2230
2313
  return joined;
2231
2314
  }
2232
- const shared = this.readListUncached(type).then(({ items, degraded }) => {
2315
+ const shared = this.readListUncached(type).then((result) => {
2233
2316
  if (this.inflightListReads.get(type) === shared) {
2234
- this.cacheListResult(type, items, degraded);
2317
+ this.cacheListResult(type, result);
2235
2318
  }
2236
- return items;
2319
+ return result;
2237
2320
  });
2238
2321
  this.inflightListReads.set(type, shared);
2239
2322
  try {
@@ -2264,6 +2347,7 @@ var _MetadataManager = class _MetadataManager {
2264
2347
  }
2265
2348
  }
2266
2349
  let degraded = false;
2350
+ const errors = [];
2267
2351
  for (const loader of this.loaders.values()) {
2268
2352
  try {
2269
2353
  const loaderItems = await loader.loadMany(type);
@@ -2276,10 +2360,11 @@ var _MetadataManager = class _MetadataManager {
2276
2360
  this.reportLoaderReadRecovered(loader.contract.name);
2277
2361
  } catch (e) {
2278
2362
  degraded = true;
2363
+ errors.push(`${loader.contract.name}: ${e instanceof Error ? e.message : String(e)}`);
2279
2364
  this.reportLoaderReadFailure(loader.contract.name, type, e);
2280
2365
  }
2281
2366
  }
2282
- return { items: Array.from(items.values()), degraded };
2367
+ return { items: Array.from(items.values()), degraded, errors };
2283
2368
  }
2284
2369
  /**
2285
2370
  * Report — at `error`, once per outage episode — that a loader could not be
@@ -2341,9 +2426,14 @@ var _MetadataManager = class _MetadataManager {
2341
2426
  * one thing this cache used to throw away. A result assembled while a loader
2342
2427
  * was unreadable is stored, but stored *as* what it is, so it expires on the
2343
2428
  * degraded TTL and any reader can tell it apart from a complete answer.
2429
+ *
2430
+ * [#6504] Takes the whole read result rather than its parts for the same
2431
+ * reason: a signature that spreads the verdict across positional arguments is
2432
+ * one a later caller can quietly fill with `false`, which is how the verdict
2433
+ * was lost on the way out in the first place.
2344
2434
  */
2345
- cacheListResult(type, items, degraded) {
2346
- this.listCache.set(type, { ts: Date.now(), items, degraded });
2435
+ cacheListResult(type, result) {
2436
+ this.listCache.set(type, { ts: Date.now(), ...result });
2347
2437
  }
2348
2438
  /**
2349
2439
  * Read a still-fresh {@link listCache} entry, or `undefined` when there is
@@ -2496,6 +2586,7 @@ var _MetadataManager = class _MetadataManager {
2496
2586
  * before the await would buy nothing and would re-open step 1's window.
2497
2587
  */
2498
2588
  async unregister(type, name, options) {
2589
+ type = (0, import_core.canonicalMetadataServiceType)(type);
2499
2590
  for (const loader of this.loaders.values()) {
2500
2591
  if (loader.contract.protocol !== "datasource:" || !loader.contract.capabilities.write) continue;
2501
2592
  if (typeof loader.delete !== "function") continue;
@@ -2598,6 +2689,7 @@ var _MetadataManager = class _MetadataManager {
2598
2689
  * Check if a metadata item exists
2599
2690
  */
2600
2691
  async exists(type, name) {
2692
+ type = (0, import_core.canonicalMetadataServiceType)(type);
2601
2693
  if (this.registry.get(type)?.has(name)) {
2602
2694
  return true;
2603
2695
  }
@@ -2612,6 +2704,7 @@ var _MetadataManager = class _MetadataManager {
2612
2704
  * List all names of metadata items of a given type
2613
2705
  */
2614
2706
  async listNames(type) {
2707
+ type = (0, import_core.canonicalMetadataServiceType)(type);
2615
2708
  const names = /* @__PURE__ */ new Set();
2616
2709
  const typeStore = this.registry.get(type);
2617
2710
  if (typeStore) {
@@ -2917,11 +3010,21 @@ var _MetadataManager = class _MetadataManager {
2917
3010
  }
2918
3011
  }
2919
3012
  if (packageItems.length === 0) {
2920
- throw new Error(`No metadata items found for package '${packageId}'`);
3013
+ const err = new Error(
3014
+ `No metadata items found for package '${packageId}'`
3015
+ );
3016
+ err.code = "RESOURCE_NOT_FOUND";
3017
+ err.status = 404;
3018
+ throw err;
2921
3019
  }
2922
3020
  const hasPublished = packageItems.some((item) => item.data.publishedDefinition !== void 0);
2923
3021
  if (!hasPublished) {
2924
- throw new Error(`Package '${packageId}' has never been published`);
3022
+ const err = new Error(
3023
+ `Package '${packageId}' has never been published`
3024
+ );
3025
+ err.code = "RESOURCE_CONFLICT";
3026
+ err.status = 409;
3027
+ throw err;
2925
3028
  }
2926
3029
  for (const item of packageItems) {
2927
3030
  if (item.data.publishedDefinition !== void 0) {
@@ -3174,6 +3277,7 @@ var _MetadataManager = class _MetadataManager {
3174
3277
  * @returns An unsubscribe function.
3175
3278
  */
3176
3279
  subscribe(type, callback) {
3280
+ type = (0, import_core.canonicalMetadataServiceType)(type);
3177
3281
  this.addWatchCallback(type, callback);
3178
3282
  return () => this.removeWatchCallback(type, callback);
3179
3283
  }