@objectstack/metadata 17.0.0-rc.3 → 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/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as System from '@objectstack/spec/system';
2
2
  import { MetadataFormat, MetadataLoaderContract, MetadataLoadOptions, MetadataLoadResult, MetadataStats, MetadataSaveOptions, MetadataSaveResult, MetadataWatchEvent, MetadataManagerConfig, PackagePublishResult, MetadataHistoryQueryOptions, MetadataHistoryQueryResult, MetadataDiffResult, MetadataHistoryRecord, MetadataHistoryRetentionPolicy } from '@objectstack/spec/system';
3
3
  export { MetadataCollectionInfo, MetadataDiffResult, MetadataFormat, MetadataHistoryQueryOptions, MetadataHistoryQueryResult, MetadataHistoryRecord, MetadataHistoryRetentionPolicy, MetadataLoadOptions, MetadataLoadResult, MetadataLoaderContract, MetadataManagerConfig, MetadataSaveOptions, MetadataSaveResult, MetadataStats, MetadataWatchEvent } from '@objectstack/spec/system';
4
- import { IMetadataService, IDataDriver, IDataEngine, IRealtimeService, MetadataWriteOptions, MetadataWatchCallback, MetadataWatchHandle, MetadataExportOptions, MetadataImportOptions, MetadataImportResult, MetadataTypeInfo, IPubSub, ISchemaDriver } from '@objectstack/spec/contracts';
4
+ import { IMetadataService, IDataDriver, IDataEngine, IRealtimeService, MetadataWriteOptions, MetadataWatchCallback, MetadataWatchHandle, MetadataExportOptions, MetadataImportOptions, MetadataImportResult, MetadataTypeInfo, ApiEndpointMatch, IPubSub, ISchemaDriver } from '@objectstack/spec/contracts';
5
5
  export { IMetadataService, MetadataExportOptions, MetadataImportOptions, MetadataImportResult, MetadataTypeInfo, MetadataWatchCallback, MetadataWatchHandle } from '@objectstack/spec/contracts';
6
6
  import { MetadataTypeRegistryEntry, MetadataQuery, MetadataQueryResult, MetadataBulkResult, MetadataOverlay, MetadataValidationResult, MetadataDependency, MetadataPluginConfig } from '@objectstack/spec/kernel';
7
7
  export { MetadataBulkResult, MetadataDependency, MetadataPluginConfig, MetadataPluginManifest, MetadataQuery, MetadataQueryResult, MetadataType, MetadataTypeRegistryEntry, MetadataValidationResult } from '@objectstack/spec/kernel';
@@ -117,13 +117,66 @@ interface MetadataLoader {
117
117
  */
118
118
  list(type: string): Promise<string[]>;
119
119
  /**
120
- * Save metadata item
120
+ * Save metadata item into this loader's store.
121
+ *
122
+ * [#5654] Optional on the interface, **mandatory for a `datasource:` loader
123
+ * that declares `capabilities.write`** — `MetadataManager.registerLoader()`
124
+ * refuses to register such a loader when this method is missing, so the
125
+ * combination "declared writable, cannot persist" never reaches the runtime.
126
+ *
127
+ * The reason it is enforced at registration rather than tolerated at the write
128
+ * site: `MetadataManager.register()` persists into every writable
129
+ * `datasource:` loader, and it used to read `loader.save &&` first — a loader
130
+ * that declares it can be written to but has no `save()` made every write a
131
+ * silent lie. `register()` would skip it, then write the in-memory registry,
132
+ * invalidate the list cache, announce a `created`/`updated` event and notify
133
+ * watchers, so the caller is told the write succeeded; the item reads back
134
+ * correctly for the life of the process and is **gone at the next restart**,
135
+ * with nothing to retry it.
136
+ *
137
+ * Loaders on the other protocols (`file:`, `memory:`, `http:`, `s3:`) are not
138
+ * gated: `MetadataManager` never persists to them at runtime — `register()`
139
+ * filters on `datasource:` — so a missing `save()` there loses nothing.
140
+ *
121
141
  * @param type The metadata type
122
142
  * @param name The item name
123
143
  * @param data The data to save
124
144
  * @param options Save options
125
145
  */
126
146
  save?(type: string, name: string, data: any, options?: MetadataSaveOptions): Promise<MetadataSaveResult>;
147
+ /**
148
+ * Delete a metadata item from this loader's store.
149
+ *
150
+ * [#5276, #5654] Optional on the interface, **mandatory for a `datasource:`
151
+ * loader that declares `capabilities.write`** — `MetadataManager.registerLoader()`
152
+ * refuses to register such a loader when this method is missing, so the
153
+ * combination "declared writable, cannot delete" never reaches the runtime.
154
+ *
155
+ * The reason it is enforced at registration rather than tolerated at the
156
+ * delete site: `MetadataManager.register()` persists into every writable
157
+ * `datasource:` loader, and `unregister()` has to take those rows back out
158
+ * again. A loader that can be written to but not deleted from makes every
159
+ * deletion a silent lie — `unregister()` would skip it, then drop the
160
+ * registry entry, invalidate the list cache and announce a `deleted` event,
161
+ * so the caller is told the delete succeeded while the row is read straight
162
+ * back out of this loader by the next `list()`/`get()`. `capabilities.write`
163
+ * therefore means *both* directions of the write, on both ends of the item's
164
+ * life — declared = enforced.
165
+ *
166
+ * One gate covers both halves: `assertWritableLoaderContract` in
167
+ * `metadata-manager.ts` requires `save()` **and** `delete()` for this
168
+ * combination and names whichever is missing. #5276 built it for `delete`;
169
+ * #5654 widened it to `save`, which had the identical silent skip in
170
+ * `register()` — see the note on `save?` above.
171
+ *
172
+ * Loaders on the other protocols (`file:`, `memory:`, `http:`, `s3:`) are not
173
+ * gated: `MetadataManager` never writes to them at runtime, so it never has a
174
+ * deletion of its own to take back.
175
+ *
176
+ * @param type The metadata type
177
+ * @param name The item name
178
+ */
179
+ delete?(type: string, name: string): Promise<void>;
127
180
  }
128
181
 
129
182
  /**
@@ -159,6 +212,67 @@ declare class MetadataManager implements IMetadataService {
159
212
  private dependencies;
160
213
  private listCache;
161
214
  private static readonly LIST_CACHE_TTL_MS;
215
+ /**
216
+ * [#5184] TTL for an entry produced by a degraded read (≥1 loader threw).
217
+ *
218
+ * Deliberately at the top of the 1–2s band: the point of keeping degraded
219
+ * results cached at all is to absorb a burst of `list()` calls issued from
220
+ * inside one open transaction, and those bursts are milliseconds apart but
221
+ * can be spread by per-row work. Two seconds covers that while still being
222
+ * 15× shorter than the healthy TTL.
223
+ */
224
+ private static readonly DEGRADED_LIST_CACHE_TTL_MS;
225
+ /**
226
+ * [#5253] The `list()` read currently in flight for a metadata type — the
227
+ * concurrent half of the `listCache` policy above.
228
+ *
229
+ * `listCache` memoizes an answer only once a read has *finished*, so it can
230
+ * absorb the caller that arrives second in time but never the caller that
231
+ * arrives second in flight. Everything issued while the first read is still
232
+ * walking the loaders used to miss and start its own identical walk; on the
233
+ * knex/SQLite path the field comment above is built for, that is 60s burned
234
+ * per concurrent caller instead of once for all of them. A type is read once
235
+ * at a time: whoever finds a read already running joins it.
236
+ *
237
+ * **Sharers share the outcome. This is a contract, not an accident.** Every
238
+ * caller joining an in-flight read receives that read's exact result — the
239
+ * same array instance, and, when a loader was unreadable, the same
240
+ * known-partial set that gets memoized `degraded: true` on the short TTL.
241
+ * There is no per-caller retry: `list()` is the best-effort listing seam and
242
+ * does not throw (see {@link reportLoaderReadFailure}; the strict
243
+ * counterparts are `listForIndex()` and {@link loadDiagnosed}), so a lost
244
+ * loader is not an error to fail over from — it is the answer. Re-running the
245
+ * read privately for a joiner would walk the same loaders in the same window
246
+ * against the same outage, which is precisely what this map exists to
247
+ * prevent. Should the seam ever acquire a rejecting path, that rejection is
248
+ * shared by the same mechanism and for the same reason.
249
+ *
250
+ * **The registration is also the permission to cache.** An entry here says
251
+ * "this read still describes the current state". {@link invalidateListCache}
252
+ * retracts it, which is what makes a write landing mid-read safe in both
253
+ * directions:
254
+ * • the retracted read does NOT write its result into `listCache` when it
255
+ * settles, so an answer assembled before the write cannot outlive the
256
+ * write it predates (the invalidation wins — it is the later, better
257
+ * informed fact);
258
+ * • a `list()` issued after the invalidation starts a FRESH read instead of
259
+ * joining one that predates the write.
260
+ * That second point is the #5219 / #5229 ordering bar restated for
261
+ * concurrency: a consumer woken by a metadata change must not observe the
262
+ * event and pre-event state together, and handing a woken watcher an
263
+ * in-flight read that began before the event would be exactly that.
264
+ * Callers *already waiting* on the retracted read still receive its (now
265
+ * possibly stale) result — they asked before the write, and restarting the
266
+ * read under them would turn a write burst into an unbounded retry loop on
267
+ * the one path the cache exists to keep off the loaders.
268
+ *
269
+ * Self-cleaning: the entry is dropped when the read settles, by that read
270
+ * only, so a fresh read that already replaced it keeps its slot. Nothing
271
+ * accumulates — a wave of callers arriving after settle finds the cache the
272
+ * settle just wrote, and once that lapses it starts one new read.
273
+ */
274
+ private readonly inflightListReads;
275
+ private readonly loaderReadFailureReported;
162
276
  private realtimeService?;
163
277
  private clusterPubSub?;
164
278
  private clusterNodeId?;
@@ -167,6 +281,8 @@ declare class MetadataManager implements IMetadataService {
167
281
  protected repository?: MetadataRepository;
168
282
  private repoWatchIter?;
169
283
  private repoWatchClosed;
284
+ private static readonly ENDPOINT_METADATA_TYPE;
285
+ private readonly endpointMatcher;
170
286
  constructor(config: MetadataManagerOptions);
171
287
  /**
172
288
  * Set the type registry for metadata type discovery.
@@ -223,6 +339,13 @@ declare class MetadataManager implements IMetadataService {
223
339
  private publishRealtimeMetadataEvent;
224
340
  /**
225
341
  * Register a new metadata loader (data source)
342
+ *
343
+ * [#5276, #5654] Rejects — loudly, before the loader is stored — a
344
+ * `datasource:` loader that declares `capabilities.write` without
345
+ * implementing `save()` **and** `delete()`. This is the **only** way into
346
+ * `this.loaders` (the constructor's `config.loaders` come through here too),
347
+ * which is what lets every later write-capability guard be defensive rather
348
+ * than load-bearing.
226
349
  */
227
350
  registerLoader(loader: MetadataLoader): void;
228
351
  /**
@@ -259,15 +382,183 @@ declare class MetadataManager implements IMetadataService {
259
382
  /**
260
383
  * Get a metadata item by type and name.
261
384
  * Checks in-memory registry first, then falls back to loaders.
385
+ *
386
+ * Returns `undefined` both when nothing declares the item and when every
387
+ * loader that could have held it FAILED — see {@link getDiagnosed} when the
388
+ * caller must tell those apart. This is the same relationship {@link load}
389
+ * has with {@link loadDiagnosed}, so every existing caller keeps its exact
390
+ * behaviour and only callers that ASK for the verdict pay for it.
391
+ *
392
+ * [#5840] Deliberately NOT expressed as `(await getDiagnosed(…)).data`,
393
+ * although that is what it computes. The obvious delegation adds one
394
+ * `await` hop, and a registry hit here is observed one microtask sooner than
395
+ * it would be through a second async frame — which `register()`'s watchers
396
+ * depend on, because `notifyWatchers` does not await its handlers and
397
+ * ObjectQL's bridge re-reads through `get()` on the event rather than
398
+ * trusting the payload (`register-notifies-watchers.test.ts` pins it, and
399
+ * went red on the delegating version). The duplication is three lines and is
400
+ * pinned from the other side: `get()` and `getDiagnosed().data` are asserted
401
+ * to agree on every case in `metadata-manager-get-diagnosed.test.ts`.
262
402
  */
263
403
  get(type: string, name: string): Promise<unknown | undefined>;
264
404
  /**
265
- * List all metadata items of a given type
405
+ * `get`, plus whether the answer can be trusted as complete.
406
+ *
407
+ * [#5840] {@link loadDiagnosed} already computes this verdict — and `get()`
408
+ * threw it away two hops later (`load` kept only `.data`, `get` turned that
409
+ * `null` into `undefined`), so no caller of `get` could reach the one fact
410
+ * ADR-0110 D3 exists to preserve: **a miss and an outage are different facts
411
+ * with opposite security meanings.** A consumer that gates on a declaration
412
+ * MUST NOT read `undefined` as "the author declared nothing" — an
413
+ * availability failure would silently widen access (the REST `/actions`
414
+ * fail-open branch, #3935) or make a positive claim about authorship from a
415
+ * read that never happened (`code: null` in the layered read, #5707/#5532).
416
+ *
417
+ * This is the registry-first counterpart of {@link loadDiagnosed}, and that
418
+ * difference is why callers of `get` cannot simply switch to `loadDiagnosed`:
419
+ * doing so would skip the in-memory registry and change what they resolve.
420
+ *
421
+ * `degraded` is true when at least one loader threw AND nothing answered with
422
+ * the item — never when the in-memory registry answered, because that answer
423
+ * needed no loader. A clean miss (every loader answered, none had it) is NOT
424
+ * degraded. The posture is deliberately conservative: with a loader down we
425
+ * cannot prove the item is absent, so we decline to claim it is.
426
+ */
427
+ getDiagnosed(type: string, name: string): Promise<{
428
+ data: unknown | undefined;
429
+ degraded: boolean;
430
+ errors: string[];
431
+ }>;
432
+ /**
433
+ * List all metadata items of a given type.
434
+ *
435
+ * Best-effort by contract: a loader that cannot be read is reported once and
436
+ * skipped ({@link reportLoaderReadFailure}), so this resolves with what the
437
+ * reachable loaders hold rather than throwing.
438
+ *
439
+ * [#5253] Reads of one type are single-flight — concurrent callers join the
440
+ * read already running instead of each walking every loader. What they are
441
+ * promised, and what happens when a write lands mid-read, is the contract on
442
+ * `inflightListReads`; what is memoized afterwards is the contract on
443
+ * `listCache`.
266
444
  */
267
445
  list(type: string): Promise<unknown[]>;
446
+ /**
447
+ * Assemble the `list()` answer for `type` from the in-memory registry plus
448
+ * every loader, reporting (but not rethrowing) loaders that could not be
449
+ * read.
450
+ *
451
+ * The body {@link list} used to inline, extracted so the caching and
452
+ * single-flight bookkeeping around it has one thing to run at most once per
453
+ * type (#5253). Deliberately does NOT touch `listCache` itself: whether this
454
+ * result may be memoized depends on what happened to the read's registration
455
+ * while it ran, which only `list()` can see.
456
+ */
457
+ private readListUncached;
458
+ /**
459
+ * Report — at `error`, once per outage episode — that a loader could not be
460
+ * read while serving {@link list}.
461
+ *
462
+ * [#5108] This branch used to be dead for the loader that matters. Before
463
+ * #5108 `DatabaseLoader` caught its own read failures and answered `[]`, so
464
+ * `list()` received a *successful empty read* and never entered this `catch`
465
+ * at all: an unreachable `sys_metadata` and "this environment declares no
466
+ * `permission`" produced byte-identical results with not one line logged.
467
+ * With the loader rethrowing everything but the benign not-provisioned case,
468
+ * this is where the outage finally becomes speakable.
469
+ *
470
+ * `error`, not `warn`, per AGENTS.md → "Degradation log levels". Apply its
471
+ * one question — *does the system still look normal from outside while
472
+ * something it claims to know has not actually landed?* — and the answer is
473
+ * yes: `list()` still returns, callers still get an array, nothing 500s, and
474
+ * the set they gate on is quietly short. Which way that cuts depends on the
475
+ * consumer, and both ways are silent (#3935 is the fail-open precedent).
476
+ *
477
+ * Said **once** per loader, and un-said on recovery, because `list()` is a
478
+ * hot path — one line per outage, not one per read.
479
+ *
480
+ * [#5184] The once-only guard carries more weight than it used to: a
481
+ * degraded `list()` result is now memoized for `DEGRADED_LIST_CACHE_TTL_MS`
482
+ * rather than `LIST_CACHE_TTL_MS`, so during an outage the loader is
483
+ * re-asked (and this method re-entered) roughly every 2s instead of every
484
+ * 30s. That is the point — the outage stops being a 30s silent window and
485
+ * recovery is noticed within seconds — and it costs nothing in log volume
486
+ * precisely because `loaderReadFailureReported` still speaks only once.
487
+ *
488
+ * Deliberately does NOT rethrow: `list()` is the best-effort listing seam and
489
+ * must keep serving what the reachable loaders hold. The strict counterpart
490
+ * for callers whose answer is a security decision is `listForIndex()` (no
491
+ * `catch`, feeding `matchEndpoint`) and {@link loadDiagnosed} (ADR-0110 D3)
492
+ * for the singular read — both of which only became honest for
493
+ * `DatabaseLoader` with the same #5108 change.
494
+ */
495
+ private reportLoaderReadFailure;
496
+ /** Un-say {@link reportLoaderReadFailure} once the loader answers again. */
497
+ private reportLoaderReadRecovered;
498
+ /**
499
+ * Memoize a completed {@link list} result.
500
+ *
501
+ * [#5184] `degraded` is not optional at the call site by accident — it is the
502
+ * one thing this cache used to throw away. A result assembled while a loader
503
+ * was unreadable is stored, but stored *as* what it is, so it expires on the
504
+ * degraded TTL and any reader can tell it apart from a complete answer.
505
+ */
268
506
  private cacheListResult;
269
- /** Internal helper: drop the cached `list()` result for a type. */
507
+ /**
508
+ * Read a still-fresh {@link listCache} entry, or `undefined` when there is
509
+ * none / it has expired.
510
+ *
511
+ * [#5184] The single place the TTL policy is applied, so "a degraded entry
512
+ * expires sooner" cannot be forgotten by a second reader. Returns the whole
513
+ * entry rather than just `items` so callers keep access to `degraded`.
514
+ */
515
+ private readCachedList;
516
+ /**
517
+ * Internal helper: drop every memoized or in-progress `list()` answer for a
518
+ * type, so the next read observes the write that called this.
519
+ *
520
+ * [#5253] Retracting the in-flight read (not just the finished entry) is the
521
+ * whole mid-read story, and it is pinned by test: the read keeps running for
522
+ * the callers already waiting on it, but it loses the right to memoize its
523
+ * pre-write answer, and a caller arriving after this point gets a fresh read
524
+ * instead of joining a pre-write one. The reasoning — including why waiting
525
+ * callers are NOT restarted — is on the `inflightListReads` field.
526
+ *
527
+ * [#5259] Both halves are only as good as WHEN the caller invokes this. This
528
+ * clears what is stale *as of now*; it cannot pre-empt a store the caller has
529
+ * not finished updating yet. Callers must therefore invalidate only once
530
+ * every store already holds the state they are about to announce — see the
531
+ * `listCache` field comment and {@link unregister}, whose pre-#5259 ordering
532
+ * invalidated one await too early and let the next read cache a view in which
533
+ * the registry was empty and the loader was not.
534
+ */
270
535
  private invalidateListCache;
536
+ /**
537
+ * Enumerate stored items of `type` for an index build — like {@link list},
538
+ * but a store that cannot be read THROWS instead of contributing nothing.
539
+ *
540
+ * [#5089] `list()` deliberately logs a failing loader and skips it so a
541
+ * partially-available metadata plane still serves what it can. That posture
542
+ * is wrong for `matchEndpoint`: its `undefined` becomes an HTTP 404, and a
543
+ * store outage that silently yields "zero declarations" would turn every
544
+ * declared endpoint into a semantic "nothing declares this route". Same
545
+ * distinction {@link loadDiagnosed} draws on the singular read (ADR-0110
546
+ * D3) — a miss and an outage are different facts with opposite meanings.
547
+ *
548
+ * Deliberately private and single-purpose: it is not a second `list()`, it
549
+ * is `list()`'s failure posture inverted for the one caller whose answer is
550
+ * a security/availability decision rather than a best-effort listing.
551
+ *
552
+ * This surfaces only failures a loader actually reports — which, since
553
+ * #5108, includes `DatabaseLoader`: it used to swallow its own read errors
554
+ * into `[]`, making a DB outage invisible even here. It now rethrows every
555
+ * read failure except the benign "table not provisioned yet", so this seam
556
+ * holds against the real datasource-backed loader and not just the memory /
557
+ * remote ones. (`database-loader.test.ts` pins that end to end: a broken
558
+ * driver behind a real `DatabaseLoader` makes `matchEndpoint` reject rather
559
+ * than answer a 404-shaped `undefined`.)
560
+ */
561
+ private listForIndex;
271
562
  /**
272
563
  * Unregister/remove a metadata item by type and name.
273
564
  * Deletes from database-backed loaders only (same rationale as register()).
@@ -276,8 +567,122 @@ declare class MetadataManager implements IMetadataService {
276
567
  * {@link MetadataWatchEvent} — the delete half of the {@link register}
277
568
  * contract. Pass `{ notify: false }` only for teardown that announces by
278
569
  * other means.
570
+ *
571
+ * ## [#5259] Storage FIRST, in-memory second — the order is the fix
572
+ *
573
+ * This method used to drop the registry entry and call
574
+ * {@link invalidateListCache} *before* awaiting `loader.delete()`. Those two
575
+ * steps are separated by a real await window (one DB round-trip per writable
576
+ * loader), and inside it the manager was in a state that exists nowhere else:
577
+ * **registry already empty, loader not yet empty**. `list()` merges the two,
578
+ * so a read arriving in that window
579
+ *
580
+ * • missed the cache (it had just been invalidated),
581
+ * • assembled the still-stored row into its answer, and
582
+ * • memoized that answer as a COMPLETE read — the full 30s healthy TTL,
583
+ * because no loader threw, so #5184's 2s degraded TTL never applied.
584
+ *
585
+ * Nothing invalidated again afterwards ({@link notifyWatchers} does not touch
586
+ * `listCache`), so a row that was gone from storage kept being enumerated for
587
+ * up to 30s — and `get()`, which never consulted that cache, disagreed with
588
+ * `list()` the whole time. For a gating type (`permission`, `api`) the two
589
+ * faces of the same manager answered opposite questions about whether a
590
+ * declaration exists.
591
+ *
592
+ * {@link register} never had this defect, and the reason is instructive: it
593
+ * writes the registry *first*, and the registry outranks every loader in the
594
+ * merge, so throughout its own save window the merged view already equals the
595
+ * post-write state. The invariant that makes register correct is not "where
596
+ * the invalidate sits" but **the invalidate must be the last thing after
597
+ * every store already holds the announced state**. Restated for delete, that
598
+ * means storage first:
599
+ *
600
+ * 1. `await loader.delete()` on every writable loader. Throughout this
601
+ * window registry AND loaders still hold the item, so a concurrent
602
+ * `list()` observes a coherent pre-delete state — which is the truth,
603
+ * because the delete has not landed and has not been announced.
604
+ * 2. Drop the registry entry and `invalidateListCache(type)` — with **no
605
+ * await between them**, so no read can interleave and observe the
606
+ * half-applied state that produced the bug. Everything cached or
607
+ * in-flight from step 1 is dropped here, at the moment the final state
608
+ * becomes true.
609
+ * 3. Publish + announce. #5219's invalidate-before-notify bar, unchanged:
610
+ * a watcher woken by the `deleted` event and re-reading through `list()`
611
+ * gets a fresh read of the post-delete state.
612
+ *
613
+ * **Composition with #5253's single-flight (this is the load-bearing half).**
614
+ * A `list()` that is still walking the loaders when step 2 runs cannot be
615
+ * fixed by dropping `listCache` alone — it has not written its entry yet, and
616
+ * it would write the pre-delete answer *after* the invalidation. The
617
+ * mechanism that covers it is `invalidateListCache()` also retracting the
618
+ * read's registration in `inflightListReads`: a retracted read still resolves
619
+ * for the callers already waiting on it (they asked before the delete) but
620
+ * loses the right to memoize, and any caller arriving after step 2 starts a
621
+ * fresh read rather than joining the pre-delete one. So every read is
622
+ * covered: one that FINISHED in the window has its entry deleted, one still
623
+ * IN FLIGHT loses its permission to cache, and one starting later reads the
624
+ * post-delete state. That is why the invalidate must come after the deletes
625
+ * rather than being duplicated on both sides of them — a second invalidate
626
+ * before the await would buy nothing and would re-open step 1's window.
279
627
  */
280
628
  unregister(type: string, name: string, options?: MetadataWriteOptions): Promise<void>;
629
+ /**
630
+ * Delete one metadata item from one writable loader — the storage half of
631
+ * {@link unregister}.
632
+ *
633
+ * A one-line wrapper on purpose: it gives this durability seam a **name**.
634
+ * `check:durability-log-level` matches by callee name against an explicit
635
+ * vocabulary, and the raw call is `loader.delete(...)` — putting `delete` in
636
+ * that vocabulary would claim every `.delete()` in the monorepo (`Map`,
637
+ * `Set`, cache handles, `URLSearchParams`) and the gate would drown in false
638
+ * positives, which is exactly the failure mode its own header warns about.
639
+ * Named here, `deleteMetaItemFromLoader` is in `DURABILITY_CRITICAL_CALLEES`
640
+ * with a blast radius of precisely this call site, mirroring `saveMetaItem`
641
+ * on the write side (#4754).
642
+ *
643
+ * [#5276] `MetadataLoader` now declares `delete?`, so no cast is left here.
644
+ * It stays *optional* on the interface — `file:`/`memory:`/`http:`/`s3:`
645
+ * loaders legitimately have none — and the guard below is therefore a type
646
+ * narrowing rather than a policy decision. The policy lives at
647
+ * `registerLoader()`: a `datasource:` loader that declares
648
+ * `capabilities.write` cannot be registered without a `delete()`, which is
649
+ * exactly the set of loaders this method is ever called for.
650
+ */
651
+ private deleteMetaItemFromLoader;
652
+ /**
653
+ * Report — at `error` — that a loader refused to delete an item the runtime
654
+ * has already dropped and announced as deleted.
655
+ *
656
+ * [#5259] This used to be a `logger.warn('Failed to delete …')` and continue.
657
+ * AGENTS.md → "Degradation log levels" decides the level with one question:
658
+ * *after the degradation, does the system still look normal from the outside
659
+ * while something it claims is persisted has not actually landed?* Here it is
660
+ * the deletion that did not land, which is the same class and the same
661
+ * silence: `unregister()` resolves normally, the caller is told the delete
662
+ * succeeded, and the surviving row is read straight back out of storage —
663
+ * permanently, since nothing ever retries this. Durability/consistency
664
+ * degradation ⇒ `error`, naming the **consequence** and the **fix**.
665
+ *
666
+ * **Why the registry entry is still dropped when this fires.** The
667
+ * alternative — keep the item registered so runtime state matches storage —
668
+ * looks safer and is not. The loader still holds the row, and `list()`/`get()`
669
+ * merge registry ∪ loaders, so the item is served either way; the only thing
670
+ * the surviving registry entry would change is *which copy wins*, pinning an
671
+ * in-memory definition that outranks the stored row nobody is maintaining
672
+ * anymore. Dropping it makes the very next read fall through to storage,
673
+ * which is the actual truth after a failed delete — the item still exists —
674
+ * and it surfaces that immediately (the item visibly reappears) instead of at
675
+ * the next restart. One truth, read from where it lives; the divergence is
676
+ * reported here rather than papered over with a second in-memory copy.
677
+ *
678
+ * **Said once per un-deleted item, not once per loader.** The once-per-outage
679
+ * discipline of {@link reportLoaderReadFailure} exists because `list()` is hot
680
+ * and its repeats are *identical*; these are not. Each line names a different
681
+ * item that is still in storage and that nothing will ever retry, so
682
+ * collapsing them would hand an operator the first casualty and silently drop
683
+ * the rest of the list — the failure this level was raised to prevent.
684
+ */
685
+ private reportMetaItemDeleteFailure;
281
686
  /**
282
687
  * Check if a metadata item exists
283
688
  */
@@ -333,12 +738,70 @@ declare class MetadataManager implements IMetadataService {
333
738
  * 2. Snapshot all items in the package (publishedDefinition = clone(metadata))
334
739
  * 3. Increment version
335
740
  * 4. Set all items state → active
741
+ *
742
+ * [#5189, #5040 E7b] Step 1 additionally runs the **endpoint publish gates**
743
+ * over every `api` item — see {@link gateApiItemsForPublish}. That pass is
744
+ * NOT governed by `options.validate`: the gates are a contract, not a
745
+ * lint (ADR-0121 D6 says publish REJECTS an unmetered anonymous endpoint),
746
+ * and an opt-out flag on a security gate is the bypass this issue closed.
336
747
  */
337
748
  publishPackage(packageId: string, options?: {
338
749
  changeNote?: string;
339
750
  publishedBy?: string;
340
751
  validate?: boolean;
752
+ /**
753
+ * [#5189] The package manifest's EXPLICIT `manifest.namespace` (ADR-0121
754
+ * D2), supplied by a caller that holds the manifest.
755
+ *
756
+ * `MetadataManager` has no manifest concept — it indexes items by
757
+ * `packageId` and nothing else — so it cannot prove a namespace on its
758
+ * own, and an `api` item's own `namespace`-ish fields are author-supplied
759
+ * data, not identity (reading them would make the D1/D2 carve-out gate
760
+ * vacuous: an author would simply declare the namespace their path
761
+ * already uses). Absent this option the namespace gate fails and `api`
762
+ * items in the package cannot publish through this path — which is the
763
+ * correct outcome, not a limitation to route around: a publish that
764
+ * cannot prove a namespace must not mint a URL under one.
765
+ */
766
+ namespace?: string;
341
767
  }): Promise<PackagePublishResult>;
768
+ /**
769
+ * [#5189, #5040 E7b] Run the endpoint publish gates over a package's `api`
770
+ * items and report every failure as a publish-blocking validation error.
771
+ *
772
+ * ## Why this exists at all
773
+ *
774
+ * E7 (#5111) hung the five per-endpoint gates on
775
+ * `ObjectStackDefinitionSchema`, which covers every path that parses a
776
+ * STACK — `defineStack`, `os validate`, the lint scorer, artifact ingest,
777
+ * `EnvironmentArtifactSchema.metadata`. It does not cover this one: an `api`
778
+ * item can be minted item-by-item (`metadata.register()`, a Studio write)
779
+ * and published here without a stack ever being parsed. Three of the gates
780
+ * degrade safely when bypassed (the executor answers a structured 501; a
781
+ * mis-namespaced path matches nothing), but **ADR-0121 D6 has no runtime
782
+ * counterpart**: `authRequired: false` is honoured faithfully and an
783
+ * unarmed `rateLimit` meters nothing, so the bypass mints an anonymous,
784
+ * zero-quota execution entry point. Hence a gate here, on the same
785
+ * function, rather than a second set of criteria that would drift.
786
+ *
787
+ * ## What it judges, and on what
788
+ *
789
+ * The registry stores either a raw spec document or a publish envelope
790
+ * (`{ name, packageId, state, metadata: {…spec} }`); the endpoint is read
791
+ * out with the SAME rule this method's caller uses for
792
+ * `publishedDefinition` (`data.metadata ?? data`), so publish gates exactly
793
+ * the document publish is about to snapshot. An item that does not satisfy
794
+ * `ApiEndpointSchema` fails here too — not extra strictness but a
795
+ * precondition: an unparsed shape cannot be gated, and it could never be
796
+ * served either (the matcher's own loud skip refuses it at load).
797
+ *
798
+ * @param packageItems every item collected for this package (all types).
799
+ * @param namespace the caller-supplied `manifest.namespace`; `undefined`
800
+ * fails the namespace gate, deliberately — see `publishPackage`'s option.
801
+ * @returns one entry per gate failure, `[]` when the package declares no
802
+ * `api` items (a package without endpoints is untouched by this pass).
803
+ */
804
+ private gateApiItemsForPublish;
342
805
  /**
343
806
  * Revert entire package to last published state.
344
807
  * Restores all metadata definitions from their published snapshots.
@@ -463,6 +926,36 @@ declare class MetadataManager implements IMetadataService {
463
926
  * Duplicate dependencies (same source, target, and kind) are ignored.
464
927
  */
465
928
  addDependency(dep: MetadataDependency): void;
929
+ /**
930
+ * Resolve a request's `method`+`path` to the declared `api` metadata item
931
+ * that owns it — `IMetadataService.matchEndpoint` (#5080 contract, #5089
932
+ * implementation, #5040 E2).
933
+ *
934
+ * The behaviour is specified by the contract text in
935
+ * `packages/spec/src/contracts/metadata-service.ts`; the mechanics
936
+ * (normalization, lazy index, loud parse-skip, duplicate resolution) live in
937
+ * `./endpoint-matcher.ts` and are documented there.
938
+ *
939
+ * Scope is THIS instance. There is no environment parameter, because callers
940
+ * already resolve the `metadata` service for the environment they serve —
941
+ * adding one here would create a second scoping mechanism.
942
+ *
943
+ * This method is reached over HTTP on a real boot. The dispatcher seam
944
+ * landed as #5090 (`packages/runtime/src/api-endpoint-step.ts`, called from
945
+ * the `setFallbackHandler` the dispatcher plugin installs), and #4936's
946
+ * wholesale publish refusal of a non-empty `apis:` was replaced by the
947
+ * #5040 E7 per-shape gates (`packages/spec/src/api/endpoint-publish-gate.ts`)
948
+ * — so declarations exist and requests arrive here. The showcase's two
949
+ * declared endpoints are matched and executed through this path in
950
+ * `packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`.
951
+ *
952
+ * @throws when the metadata store cannot be read — an outage must never be
953
+ * reported as a miss, because a miss becomes a 404.
954
+ */
955
+ matchEndpoint(query: {
956
+ path: string;
957
+ method: string;
958
+ }): Promise<ApiEndpointMatch | undefined>;
466
959
  /**
467
960
  * Load a single metadata item from loaders.
468
961
  * Iterates through registered loaders until found.
@@ -539,6 +1032,42 @@ declare class MetadataManager implements IMetadataService {
539
1032
  */
540
1033
  dispose(): Promise<void>;
541
1034
  private startRepositoryWatch;
1035
+ /**
1036
+ * Drop every local cache of `type` (and of `name` within it) that a change
1037
+ * we did not perform ourselves has just invalidated, so the next read falls
1038
+ * through to the source of truth.
1039
+ *
1040
+ * The callers are the manager's *foreign-write* seams — the repository watch
1041
+ * loop ({@link applyRepoEvent}), the cluster peer replay in
1042
+ * {@link attachClusterPubSub}, and — since #5218 — `NodeMetadataManager`'s
1043
+ * chokidar handler, which is why this is `protected` rather than `private`.
1044
+ * All three learn about a write that landed somewhere else (the repo head;
1045
+ * another node's `sys_metadata`; an editor writing `rootDir/view/x.json`) and
1046
+ * hold caches that the write silently aged out. A file event qualifies on
1047
+ * exactly the definition that matters here: it did not come through this
1048
+ * manager's write API, so nothing has updated the caches on its behalf.
1049
+ * Local writes do not come through here: `register()` / `unregister()` /
1050
+ * `registerInMemory()` update the registry to the value they just wrote and
1051
+ * call `invalidateListCache()` themselves.
1052
+ *
1053
+ * **Delete, do not pre-fill.** Even when the event carries a body we drop the
1054
+ * registry entry rather than writing the body into it: the body reaching us
1055
+ * is a snapshot of *someone else's* write, already possibly superseded, and
1056
+ * pre-filling would race with the true head and require us to re-canonicalise
1057
+ * a definition we did not load. Lazy invalidation is the safer default —
1058
+ * `get()` then falls through to the loaders / repository, which is where the
1059
+ * truth is. (This paragraph is the rationale `applyRepoEvent` carried since
1060
+ * ADR-0008 PR-6; #5109 extended the same choice to the cluster path, #5218 to
1061
+ * the filesystem watcher — where "the truth" is the file chokidar just
1062
+ * reported, served by the `FilesystemLoader` the registry entry was shadowing.)
1063
+ *
1064
+ * `name` is optional because `MetadataWatchEvent.name` is: a nameless event
1065
+ * cannot address a registry entry, so it invalidates the list cache only.
1066
+ * Dropping the whole type store instead would evict `registerInMemory()`
1067
+ * artefacts (code-owned datasources, ADR-0015 Addendum) that no loader can
1068
+ * restore — an unrecoverable loss in exchange for a guess.
1069
+ */
1070
+ protected invalidateForForeignWrite(type: string, name?: string): void;
542
1071
  /** Translate a repo event to the legacy MetadataWatchEvent + invalidate caches. */
543
1072
  private applyRepoEvent;
544
1073
  protected notifyWatchers(type: string, event: MetadataWatchEvent): void;
@@ -1052,6 +1581,51 @@ declare class DatabaseLoader implements MetadataLoader {
1052
1581
  * Convert a database row to a MetadataRecord-like object.
1053
1582
  */
1054
1583
  private rowToRecord;
1584
+ /**
1585
+ * Decide what a failed READ against {@link tableName} means, and rethrow
1586
+ * unless it is the ONE benign reason.
1587
+ *
1588
+ * #5108 (rule from #4632; same shape as #4728 and #4825) — discriminate by
1589
+ * error TYPE. Every read method below used to `catch {}` into its own empty
1590
+ * value: `load` → `null`, `loadMany` → `[]`, `exists` → `false`, `stat` →
1591
+ * `null`, `list` → `[]`. That made a database the metadata plane cannot
1592
+ * reach **indistinguishable** from an environment where nothing of that type
1593
+ * was ever declared — and it erased the failure *inside the loader*, so
1594
+ * neither `MetadataManager`'s own `try/catch` degradation branches nor
1595
+ * {@link import('../metadata-manager.js').MetadataManager.loadDiagnosed}
1596
+ * (ADR-0110 D3, whose whole purpose is to tell a miss from an outage) could
1597
+ * report anything. Nowhere on the chain was there a line saying the read
1598
+ * failed.
1599
+ *
1600
+ * Why that is worse than a noisy error: every consumer that gates on a
1601
+ * *declared set* — permissions, sharing rules, policies, endpoint
1602
+ * declarations — reads the empty answer as "the author declared none". Some
1603
+ * then fail open (grant), some fail closed (lock out); both look healthy
1604
+ * from outside. This is the AGENTS.md → "Degradation log levels" shape the
1605
+ * repo has already paid for twice, one layer up from #4825.
1606
+ *
1607
+ * Exactly one failure reason is benign: `sys_metadata` has not been
1608
+ * provisioned yet. There are then genuinely no rows, so "nothing declared"
1609
+ * IS the truth, and a first boot must not explode. Every other reason —
1610
+ * connection drop, timeout, insufficient privileges, malformed query — means
1611
+ * the rows may well be there and simply were not seen.
1612
+ *
1613
+ * Classification is conservative in the same direction as
1614
+ * {@link isMissingTableError} itself: an unrecognised error is NOT benign.
1615
+ * A false "benign" silently mis-answers a security question; a false "real"
1616
+ * costs one loud error.
1617
+ *
1618
+ * @param error The value thrown by `_find` / `_findOne` / `_count`.
1619
+ * @throws The underlying driver error, unchanged — deliberately, matching
1620
+ * {@link nextEventSeq}. The loader does not log it: the caller owns
1621
+ * the consequence and is the only layer that knows what an
1622
+ * incomplete answer costs it (`MetadataManager.list()` reports it at
1623
+ * `error`; `listForIndex()`/`matchEndpoint` let it propagate so an
1624
+ * outage can never be served as a 404).
1625
+ * @returns normally ONLY for the benign case, licensing the caller to answer
1626
+ * with its empty value.
1627
+ */
1628
+ private rethrowUnlessTableUnprovisioned;
1055
1629
  load(type: string, name: string, _options?: MetadataLoadOptions): Promise<MetadataLoadResult>;
1056
1630
  loadMany<T = any>(type: string, _options?: MetadataLoadOptions): Promise<T[]>;
1057
1631
  exists(type: string, name: string): Promise<boolean>;