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