@objectstack/metadata 17.2.0 → 17.4.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.d.ts CHANGED
@@ -3,12 +3,13 @@ import { MetadataFormat, MetadataLoaderContract, MetadataLoadOptions, MetadataLo
3
3
  export { MetadataCollectionInfo, MetadataDiffResult, MetadataFormat, MetadataHistoryQueryOptions, MetadataHistoryQueryResult, MetadataHistoryRecord, MetadataHistoryRetentionPolicy, MetadataLoadOptions, MetadataLoadResult, MetadataLoaderContract, MetadataManagerConfig, MetadataSaveOptions, MetadataSaveResult, MetadataStats, MetadataWatchEvent } from '@objectstack/spec/system';
4
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
- import { MetadataTypeRegistryEntryParsed, MetadataQuery, MetadataQueryResult, MetadataBulkResult, MetadataOverlay, MetadataValidationResult, MetadataDependency, MetadataPluginConfig } from '@objectstack/spec/kernel';
6
+ import { MetadataTypeRegistryEntryParsed, MetadataQuery, MetadataQueryResult, MetadataBulkResult, MetadataValidationResult, MetadataDependency, MetadataPluginConfig } from '@objectstack/spec/kernel';
7
7
  export { MetadataBulkResult, MetadataDependency, MetadataPluginConfig, MetadataPluginManifest, MetadataQuery, MetadataQueryResult, MetadataType, MetadataTypeRegistryEntry, MetadataValidationResult } from '@objectstack/spec/kernel';
8
8
  import { Logger, Plugin, PluginContext } from '@objectstack/core';
9
9
  import { z } from 'zod';
10
10
  import { MetadataRepository } from '@objectstack/metadata-core';
11
11
  export { HistoryOptions, MetaRef, MetadataEvent, MetadataItem, MetadataItemHeader, MetadataRepository, SysMetadataHistoryObject, SysMetadataObject, WatchFilter } from '@objectstack/metadata-core';
12
+ export { deriveViewContainerObject } from './view-container.js';
12
13
 
13
14
  /**
14
15
  * Metadata Serializer Interface
@@ -72,6 +73,29 @@ interface MetadataSerializer {
72
73
  * Defines the contract for loading metadata from various sources
73
74
  */
74
75
 
76
+ /**
77
+ * [#14205] One loaded item paired with the KEY its store holds it under.
78
+ *
79
+ * The pair exists because a metadata body is not required to name itself. Most
80
+ * do — and for those the key and `data.name` agree, because
81
+ * `assertMetadataRegisterContract` refuses a `register(type, name, data)` whose
82
+ * `data.name` disagrees with the `name` argument. But an aggregated `defineView`
83
+ * container has no own `name` BY DESIGN (its identity is the target object), and
84
+ * `register()` explicitly allows that: "A document with NO `name` of its own is
85
+ * fine — the argument is the key".
86
+ *
87
+ * So the key is a fact about the STORE, not about the body, and it is the only
88
+ * identity a nameless item has. Carrying it BESIDE `data` rather than folding it
89
+ * into `data` is the whole point: the body stays byte-identical to what was
90
+ * stored, so no consumer sees a synthesised `name` and the register contract's
91
+ * `data.name` check keeps meaning what it means.
92
+ */
93
+ interface MetadataKeyedItem<T = any> {
94
+ /** The key this item is stored under — `register()`'s `name` argument. */
95
+ readonly name: string;
96
+ /** The stored body, exactly as {@link MetadataLoader.loadMany} would return it. */
97
+ readonly data: T;
98
+ }
75
99
  /**
76
100
  * Abstract interface for metadata loaders
77
101
  * Implementations can load from filesystem, HTTP, S3, databases, etc.
@@ -96,6 +120,34 @@ interface MetadataLoader {
96
120
  * @returns Array of loaded items
97
121
  */
98
122
  loadMany<T = any>(type: string, options?: MetadataLoadOptions): Promise<T[]>;
123
+ /**
124
+ * Load multiple items of a type, each paired with the KEY this loader holds
125
+ * it under.
126
+ *
127
+ * [#14205] Optional, and the reason it is a second method rather than a
128
+ * widened `loadMany()`: `MetadataLoader` is exported from this package's
129
+ * public entry, with implementors outside it (`packages/objectql`'s
130
+ * conformance fixtures among them). Changing `loadMany()`'s return type would
131
+ * break every one of them; an optional member breaks none, and a loader that
132
+ * cannot produce keys — `RemoteLoader`, whose wire format carries bodies only
133
+ * — simply does not declare it.
134
+ *
135
+ * `MetadataManager` prefers this method wherever it merges a loader's answer
136
+ * into a keyed set (`list()`, and the endpoint index), and falls back to
137
+ * `loadMany()` keyed by `data.name` when it is absent. That fallback is
138
+ * exactly the pre-#14205 behaviour, so it drops items whose body has no
139
+ * top-level `name`: implement this method on any loader that can be asked to
140
+ * hold one.
141
+ *
142
+ * `data` MUST be the same body `loadMany()` would return for the item —
143
+ * unmodified, in particular with no `name` folded in. `name` is the store's
144
+ * key, carried beside the body, never written into it.
145
+ *
146
+ * @param type The metadata type
147
+ * @param options Load options with patterns
148
+ * @returns Array of (key, body) pairs
149
+ */
150
+ loadManyKeyed?<T = any>(type: string, options?: MetadataLoadOptions): Promise<MetadataKeyedItem<T>[]>;
99
151
  /**
100
152
  * Check if item exists
101
153
  * @param type The metadata type
@@ -224,7 +276,6 @@ declare class MetadataManager implements IMetadataService {
224
276
  protected watchCallbacks: Map<string, Set<WatchCallback>>;
225
277
  protected config: MetadataManagerOptions;
226
278
  private registry;
227
- private overlays;
228
279
  private typeRegistry;
229
280
  private dependencies;
230
281
  private listCache;
@@ -534,6 +585,50 @@ declare class MetadataManager implements IMetadataService {
534
585
  * result may be memoized depends on what happened to the read's registration
535
586
  * while it ran, which only `list()` can see.
536
587
  */
588
+ /**
589
+ * Merge one loader's answer for `type` into `items`, under the identity that
590
+ * loader holds each item by.
591
+ *
592
+ * ## [#14205] The identity of a loader-held item is its ROW KEY
593
+ *
594
+ * Both plural readers used to key a loader's items by `body.name`, and admit
595
+ * an item only when the body carried a string one:
596
+ *
597
+ * ```ts
598
+ * if (itemAny && typeof itemAny.name === 'string' && !items.has(itemAny.name))
599
+ * ```
600
+ *
601
+ * A body is not required to name itself. `register(type, name, data)` takes
602
+ * the key as its ARGUMENT, and `assertMetadataRegisterContract` says in as
603
+ * many words that "A document with NO `name` of its own is fine — the argument
604
+ * is the key". An aggregated `defineView` container is exactly that: no own
605
+ * `name` by design, identity carried in the row's `name` column.
606
+ *
607
+ * So the old gate dropped every such item the moment the registry went cold
608
+ * and only the loader could answer — a persisted view container vanished from
609
+ * `list('view')` after a restart, and `listDiagnosed()` called the short
610
+ * answer complete because no loader had thrown. Same gate, same effect, in
611
+ * `listForIndex()`: a nameless `api` row fell out of the endpoint index, where
612
+ * a miss reads as "nothing declares this route".
613
+ *
614
+ * The repair is to ask the loader for the key instead of guessing it from the
615
+ * body ({@link MetadataLoader.loadManyKeyed}), and to keep the key BESIDE the
616
+ * body: nothing is written into a body that deliberately has none, so the
617
+ * register contract's refusal of a disagreeing `data.name` still means what it
618
+ * says.
619
+ *
620
+ * Nothing consumers see today changes shape. For any item that went through
621
+ * `register()`, a `data.name` that exists is required to EQUAL the key, so the
622
+ * keyed merge produces the identical map entry; what is new is only the
623
+ * entries the old gate refused. The `loadMany()` fallback below is the
624
+ * pre-#14205 behaviour verbatim, for loaders that cannot produce keys
625
+ * (`RemoteLoader`'s wire format carries bodies only).
626
+ *
627
+ * Read failures are NOT caught here: `readListUncached` warns-and-continues,
628
+ * `listForIndex` deliberately throws, and that difference is each caller's to
629
+ * keep.
630
+ */
631
+ private admitLoaderItems;
537
632
  private readListUncached;
538
633
  /**
539
634
  * Report — at `error`, once per outage episode — that a loader could not be
@@ -774,6 +869,30 @@ declare class MetadataManager implements IMetadataService {
774
869
  exists(type: string, name: string): Promise<boolean>;
775
870
  /**
776
871
  * List all names of metadata items of a given type
872
+ *
873
+ * ## [#14423] One loader's fault does not take the whole enumeration down
874
+ *
875
+ * This loop used to be bare — `const result = await loader.list(type)` with
876
+ * no `try`, while the two sibling plural reads (`list()` via
877
+ * {@link admitLoaderItems}, and {@link loadMany}) have carried a per-loader
878
+ * `catch` since #5108. That asymmetry is the defect, independent of any one
879
+ * caller: the SAME storage outage was swallowed by one plural read and
880
+ * thrown out of the other, so which answer a caller got depended only on
881
+ * which method it happened to call. A caller reading both — the action
882
+ * governance audit is one — saw `loadMany` report a short-but-successful
883
+ * set and `listNames` throw, and had no way to tell that one fact was
884
+ * behind both.
885
+ *
886
+ * Same shape as `loadMany`'s, deliberately, down to the helpers: the outage
887
+ * is spoken once per loader through {@link reportLoaderReadFailure} and
888
+ * un-said through {@link reportLoaderReadRecovered}. ⛔ Not a third spelling
889
+ * for "a loader faulted" — a second vocabulary for one event is how the two
890
+ * reads drifted apart in the first place.
891
+ *
892
+ * The degradation is the same one `list()` documents and is graded the same
893
+ * way (AGENTS.md → "Degradation log levels"): the caller still gets an
894
+ * array, nothing 500s, and the set is quietly short — so it is reported at
895
+ * `error`, by the shared helper, rather than being re-graded here.
777
896
  */
778
897
  listNames(type: string): Promise<string[]>;
779
898
  /**
@@ -803,6 +922,35 @@ declare class MetadataManager implements IMetadataService {
803
922
  * Runtime-authored `shared` / `personal` views (`sys_view_definition`) are
804
923
  * merged in by the REST layer; this method returns the `package` layer that
805
924
  * was registered from source.
925
+ *
926
+ * ## [#13913] Aggregated containers are expanded inline, per read
927
+ *
928
+ * `this.list('view')` is `MetadataManager`'s OWN loader-based store — the
929
+ * in-memory registry plus every registered loader — and is a completely
930
+ * different store from the `sys_metadata` rows `getMetaItems` reads. #13407
931
+ * taught `getMetaItems` to expand a runtime-authored aggregated container
932
+ * inline; this exit never called it and had no equivalent step, so a
933
+ * container that `GET /meta/view?object=` now serves still answered **empty**
934
+ * here.
935
+ *
936
+ * Merely getting the container into the store would not have helped: the
937
+ * filter also requires `viewKind`, and a container has none. Loosening that
938
+ * requirement is NOT the repair — it would answer with the container itself
939
+ * as a view, the behaviour #7163 ruled wrong — so what is added below is the
940
+ * container's **expansion**, whose items each carry the `viewKind` + `object`
941
+ * pair this filter has always tested. The filter itself is untouched: it
942
+ * reads the top-level `object`, exactly as `ViewSchema.object` declares.
943
+ *
944
+ * Registry-free and per-read, mirroring #13407's choice at the other exit and
945
+ * for the same reason — the registry is process-wide, so a read must not
946
+ * graft rows into it (see `view-container-expansion.ts`'s header, which also
947
+ * records why the protocol's copy of this logic cannot be imported).
948
+ *
949
+ * Already-present items win: an expansion contributes only names the store
950
+ * does not already hold, so a container whose expanded ViewItems were
951
+ * registered by a source registrar (the ObjectQL boot loop, the artifact/HMR
952
+ * loader) still answers with those registered, fully-enriched items and this
953
+ * step adds nothing.
806
954
  */
807
955
  getViewsByObject(object: string): Promise<unknown[]>;
808
956
  /**
@@ -931,29 +1079,6 @@ declare class MetadataManager implements IMetadataService {
931
1079
  type: string;
932
1080
  name: string;
933
1081
  }>, options?: MetadataWriteOptions): Promise<MetadataBulkResult>;
934
- private overlayKey;
935
- /**
936
- * Get the active overlay for a metadata item
937
- */
938
- getOverlay(type: string, name: string, scope?: 'platform' | 'user'): Promise<MetadataOverlay | undefined>;
939
- /**
940
- * Save/update an overlay for a metadata item
941
- */
942
- saveOverlay(overlay: MetadataOverlay): Promise<void>;
943
- /**
944
- * Remove an overlay, reverting to the base definition
945
- */
946
- removeOverlay(type: string, name: string, scope?: 'platform' | 'user'): Promise<void>;
947
- /**
948
- * Get the effective (merged) metadata after applying all overlays.
949
- * Resolution order: system ← merge(platform) ← merge(user)
950
- */
951
- getEffective(type: string, name: string, context?: {
952
- userId?: string;
953
- tenantId?: string;
954
- roles?: string[];
955
- permissions?: string[];
956
- }): Promise<unknown | undefined>;
957
1082
  /**
958
1083
  * Watch for metadata changes (IMetadataService contract).
959
1084
  * Returns a handle for unsubscribing.
@@ -1081,6 +1206,72 @@ declare class MetadataManager implements IMetadataService {
1081
1206
  * Aggregates results from all loaders.
1082
1207
  */
1083
1208
  loadMany<T = any>(type: string, options?: MetadataLoadOptions): Promise<T[]>;
1209
+ /**
1210
+ * [#14423] {@link loadMany}, read under the identity the STORE holds each
1211
+ * item by — the keyed plural read, beside the unkeyed one.
1212
+ *
1213
+ * ## Why a second method and not a widened `loadMany`
1214
+ *
1215
+ * `loadMany` keys nothing: it returns bodies, and every consumer that needs
1216
+ * an identity reads `body.name` off them. #14205 already ruled what identity
1217
+ * IS — the key the store holds the item under (`register(type, name, data)`
1218
+ * takes it as the ARGUMENT, and a body is not required to name itself) — so
1219
+ * `body.name` is a guess that happens to be right for most items and drops
1220
+ * the rest ENTIRELY: an item whose body carries no `name` is served by
1221
+ * `load(type, name)` and is not nameable from `loadMany`'s answer at all.
1222
+ *
1223
+ * Widening `loadMany`'s return would fix that and break every consumer of a
1224
+ * published shape (the ones counted on this card all read `body.name` as the
1225
+ * identity). So this is additive: `loadMany`'s return shape is untouched,
1226
+ * and a caller that needs the key asks for the key.
1227
+ *
1228
+ * ## What it reads — the same population `loadMany` reads
1229
+ *
1230
+ * Loaders only, deliberately, so this is `loadMany` keyed and nothing more.
1231
+ * It is NOT `list()`/{@link listNames}, which also merge the in-memory
1232
+ * `register()` registry; a caller wanting that set has those. Reading the
1233
+ * loaders alone is also what makes this the enumerable twin of
1234
+ * {@link loadDiagnosed}, which walks the same loaders by name — that pairing
1235
+ * is the point on the audit side of #14423, where an enumeration and a
1236
+ * by-name read that disagree about a population make one subsystem accuse
1237
+ * another of a defect neither has.
1238
+ *
1239
+ * ## Delegate first, fall back second — and why that order is not a style
1240
+ *
1241
+ * Per loader: {@link MetadataLoader.loadManyKeyed} where the loader offers
1242
+ * one, else its `list()` + a per-name `load()`. Measured, on
1243
+ * `DatabaseLoader`: the keyed method shares `loadMany`'s single query
1244
+ * (`{find:1, findOne:0}` — zero extra cost), while enumerate-then-read-each
1245
+ * on that same loader is a real N+1 (`{find:1, findOne:5}` for five items).
1246
+ * The fallback exists for loaders that cannot produce keys at all
1247
+ * (`RemoteLoader`'s wire format carries bodies only), and it recovers the
1248
+ * nameless item the pre-#14205 `loadMany`-and-key-by-`body.name` fallback
1249
+ * drops — which is why it is `list()` + `load()` and not `loadMany()`.
1250
+ *
1251
+ * ## Failure posture
1252
+ *
1253
+ * Per-loader `try`/`catch`, the same seam and the same helpers as
1254
+ * {@link loadMany} and `list()` — one loader's outage does not take the
1255
+ * enumeration down, and it is reported once through
1256
+ * {@link reportLoaderReadFailure} rather than in a third vocabulary.
1257
+ * Earlier loaders win a key collision, mirroring `list()`.
1258
+ */
1259
+ loadManyKeyed<T = any>(type: string, options?: MetadataLoadOptions): Promise<MetadataKeyedItem<T>[]>;
1260
+ /**
1261
+ * Merge ONE loader's answer for `type` into `items`, keyed by that loader's
1262
+ * own key for each item — {@link loadManyKeyed}'s per-loader body.
1263
+ *
1264
+ * Distinct from {@link admitLoaderItems} on exactly one axis, and that axis
1265
+ * is the whole of #14423: the fallback for a loader with no
1266
+ * `loadManyKeyed`. `admitLoaderItems` falls back to `loadMany` keyed by
1267
+ * `data.name` — the pre-#14205 behaviour, verbatim, which drops a nameless
1268
+ * body. Here the fallback is `list()` + a per-name `load()`, so a loader
1269
+ * that cannot enumerate keys and bodies together still answers with both.
1270
+ *
1271
+ * Read failures are NOT caught here — the caller owns that verdict, as in
1272
+ * {@link admitLoaderItems}.
1273
+ */
1274
+ private admitKeyedLoaderItems;
1084
1275
  /**
1085
1276
  * Save metadata item to a loader
1086
1277
  */
@@ -1294,7 +1485,7 @@ interface MetadataPluginOptions {
1294
1485
  }
1295
1486
  declare class MetadataPlugin implements Plugin {
1296
1487
  name: string;
1297
- type: string;
1488
+ type: "standard";
1298
1489
  version: string;
1299
1490
  /**
1300
1491
  * Services init() UNCONDITIONALLY registers (ADR-0116, #4131) — lets the
@@ -1330,6 +1521,21 @@ declare class MetadataPlugin implements Plugin {
1330
1521
  * `name` and are skipped by `_parseAndRegisterArtifact`'s register loop.
1331
1522
  */
1332
1523
  private lastParsedMetadata?;
1524
+ /**
1525
+ * Once-per-process dedupe for the summaries the versioned artifact window
1526
+ * emits. The artifact watcher replays `_parseAndRegisterArtifact` on every
1527
+ * file change, so without this a dev loop over a legacy artifact would
1528
+ * re-announce the same finding on every reload — the same shape
1529
+ * `Protocol.storedConversionWarned` guards on the stored-row pass, which
1530
+ * this surfacing is modeled on.
1531
+ *
1532
+ * Two key families share the set, because they share the replay:
1533
+ * `<conversionId>|<label>` for a forward-conversion summary (#12772), and
1534
+ * `unbound-form-predicate-root|<label>` for the unbound-root notice
1535
+ * (#12915) — one line per artifact there, not one per conversion, since
1536
+ * the notice already aggregates every finding it made.
1537
+ */
1538
+ private artifactConversionWarned;
1333
1539
  constructor(options?: MetadataPluginOptions);
1334
1540
  init: (ctx: PluginContext) => Promise<void>;
1335
1541
  start: (ctx: PluginContext) => Promise<void>;
@@ -1363,6 +1569,69 @@ declare class MetadataPlugin implements Plugin {
1363
1569
  * Fetch JSON content from a URL with configurable timeout.
1364
1570
  */
1365
1571
  private _fetchJson;
1572
+ /**
1573
+ * Versioned ADR-0087 forward conversion at the artifact-ingestion door
1574
+ * (#12772) — runs BEFORE the strict schema parse below, because the parse
1575
+ * is the refusal point.
1576
+ *
1577
+ * A compiled artifact is data at rest with a version stamp: built by
1578
+ * released tooling, then unchanged while the platform moves on. When a
1579
+ * spec release retires an authorable key inside a protocol line (spec
1580
+ * 17.1 → 17.2 retired the `allowRestore`/`allowPurge` permission bits),
1581
+ * every already-built artifact carrying the key becomes unbootable at the
1582
+ * tombstone — with no operator remedy, since `os migrate meta` targets
1583
+ * sources, not built artifacts. The stored-row read path already replays
1584
+ * the conversion chain for exactly this reason
1585
+ * (`applyConversionsToStoredItem`, ADR-0087 addendum); this is the same
1586
+ * policy at the artifact door, **keyed off the artifact's own declared
1587
+ * `engines.protocol` floor**: an artifact authored below the running spec
1588
+ * version converts forward, an artifact authored at the current (or a
1589
+ * newer) surface converts nothing and answers to the strict parse,
1590
+ * tombstones included. The version key is what keeps this a conversion
1591
+ * rather than an amnesty — the retired keys return with the M2 lifecycle
1592
+ * batch (#1883), and artifacts authored against that surface must never
1593
+ * have them stripped by history.
1594
+ *
1595
+ * Notices surface the way the stored-row pass's do — operator-visible and
1596
+ * deduped — as one summary line per conversion per artifact rather than
1597
+ * one per rewritten path (a real 17.1 artifact carried 150 strips of the
1598
+ * same two keys; 150 identical warn lines would bury the boot log).
1599
+ */
1600
+ private _convertArtifactForward;
1601
+ /**
1602
+ * Operator-facing boot notice for form-view predicates that fault OPEN on
1603
+ * this runtime (#12915 scope C — maintainer ruling 2026-08-28, 「同意C」).
1604
+ *
1605
+ * A form-view predicate binds `record` / `previous` / `parent` (runtime
1606
+ * record forms) or `data` (metadata-editing forms) — and a FIELD-level one
1607
+ * also binds `current_user` and its ADR-0068 aliases (objectui#6010),
1608
+ * which a SECTION-level one does not. The contract states beside that
1609
+ * vocabulary that a bare identifier is UNBOUND and the predicate faults,
1610
+ * and `visibleWhen`'s fault fallback is `true`. On a real
1611
+ * 17.1-built artifact that combination dead-ends record creation in the
1612
+ * console: the conditionally hidden field renders, and its unconditional
1613
+ * `required: true` — authored to be gated by the visibility that no longer
1614
+ * applies — blocks every submit, while the same payload POSTs 201 through
1615
+ * REST. Nothing refused, nothing logged, and only the operator can fix it
1616
+ * (by rebuilding the artifact), so this is the channel the ruling picked:
1617
+ * service startup, server-side, never a console surface — the person at
1618
+ * the form cannot act on "your artifact is stale".
1619
+ *
1620
+ * **Detection only.** No refusal, no rewrite, no behaviour change: the
1621
+ * predicate keeps faulting open exactly as before. Rewriting a bare root to
1622
+ * `record.` is the ADR-0087 conversion (#12915 scope A), deferred by the
1623
+ * same ruling with an explicit start line.
1624
+ *
1625
+ * **Same versioned window as the conversion replay above** — and read off
1626
+ * that pass's own verdict rather than recomputed, so the two can never
1627
+ * disagree about which artifacts are "old". An artifact declaring the
1628
+ * current (or a newer) floor answers to the strict parse and gets nothing
1629
+ * from here even when it does carry bare roots; that boundary is what keeps
1630
+ * a notice about legacy artifacts out of contract territory. An undeclared
1631
+ * range is treated as old data at rest, matching the grandfathering posture
1632
+ * the window already takes (`converted-undeclared`).
1633
+ */
1634
+ private _warnUnboundFormPredicateRoots;
1366
1635
  /**
1367
1636
  * Parse raw artifact JSON (envelope or bare definition) and register all
1368
1637
  * metadata items into the MetadataManager.
@@ -1376,6 +1645,35 @@ declare class MetadataPlugin implements Plugin {
1376
1645
  * landing.
1377
1646
  */
1378
1647
  private _parseAndRegisterArtifact;
1648
+ /**
1649
+ * Register ONE artifact body's collections into the MetadataManager.
1650
+ *
1651
+ * A "body" is either the whole artifact (the single-package branch, where
1652
+ * the artifact and its one package are the same object) or one entry of
1653
+ * `packages[]` (ADR-0130 D4), which is an assembled
1654
+ * `{ ...manifest, ...collections }` payload carrying the same collection
1655
+ * keys the top level does. The loop is identical for both — that is the
1656
+ * point: there is one ingestion of a collection here, not one per shape.
1657
+ *
1658
+ * @param provenance - The `(packageId, packageVersion)` every item found in
1659
+ * this body is stamped with (ADR-0010 §3.7, via `applyProtection`). It is
1660
+ * the body's OWN identity, never the enclosing artifact's, which is what
1661
+ * makes a multi-package artifact's items agree with the registry and with
1662
+ * `GET /api/v1/packages` about who owns them.
1663
+ * @param slots.claim - Called with every `(type, name)` this pass
1664
+ * registered. Passed when reading package bodies; the residual sweep uses
1665
+ * what it recorded.
1666
+ * @param slots.skip - Consulted before registering each `(type, name)`.
1667
+ * Passed ONLY by the residual sweep, so a package body's copy is never
1668
+ * overwritten by the flattened top-level copy of the same definition —
1669
+ * the overwrite that re-attributed the item to the artifact's manifest.
1670
+ * ⛔ It is never passed while reading the bodies themselves: two items of
1671
+ * one name inside one body still register as they always have (last
1672
+ * wins), because suppressing that would be a behaviour change on the
1673
+ * single-package branch D7 pins.
1674
+ * @returns How many items this body registered.
1675
+ */
1676
+ private _registerArtifactBodyCollections;
1379
1677
  /**
1380
1678
  * Reload the artifact from disk into the MetadataManager, then announce a
1381
1679
  * generic `metadata:reloaded` hook. Used by BOTH reload paths (the HMR POST
@@ -1418,6 +1716,16 @@ declare class MemoryLoader implements MetadataLoader {
1418
1716
  private storage;
1419
1717
  load(type: string, name: string, _options?: MetadataLoadOptions): Promise<MetadataLoadResult>;
1420
1718
  loadMany<T = any>(type: string, _options?: MetadataLoadOptions): Promise<T[]>;
1719
+ /**
1720
+ * [#14205] The keyed half of {@link loadMany}. The storage map is already
1721
+ * `Type -> Name -> Data`, so the key this loader holds an item under is the
1722
+ * map key — `loadMany()` was simply discarding it, which dropped every
1723
+ * nameless body out of `MetadataManager.list()` and out of the endpoint index.
1724
+ *
1725
+ * The body is handed back by reference, unchanged: the key travels beside it,
1726
+ * never folded into it.
1727
+ */
1728
+ loadManyKeyed<T = any>(type: string, _options?: MetadataLoadOptions): Promise<MetadataKeyedItem<T>[]>;
1421
1729
  exists(type: string, name: string): Promise<boolean>;
1422
1730
  stat(type: string, name: string): Promise<MetadataStats | null>;
1423
1731
  list(type: string): Promise<string[]>;
@@ -1445,6 +1753,40 @@ declare class RemoteLoader implements MetadataLoader {
1445
1753
  loadMany<T = any>(type: string, _options?: MetadataLoadOptions): Promise<T[]>;
1446
1754
  exists(type: string, name: string): Promise<boolean>;
1447
1755
  stat(type: string, name: string): Promise<MetadataStats | null>;
1756
+ /**
1757
+ * [#15037] Report only the names that ARE names.
1758
+ *
1759
+ * This read used to be `loadMany<{ name: string }>(type)` mapped straight to
1760
+ * `items.map(i => i.name)`. That type argument is an ASSERTION about bodies
1761
+ * that arrived over HTTP, and nothing checked it: a body with no top-level
1762
+ * `name` yielded `undefined`, which went into an array this signature
1763
+ * declares as `string[]` and reached consumers through
1764
+ * `MetadataManager.listNames()` — a runtime violation of a declared type,
1765
+ * not an untidy entry. A consumer that keys by it, lower-cases it, or feeds
1766
+ * it back to a by-name `load()` gets `undefined` where the type says it
1767
+ * cannot be.
1768
+ *
1769
+ * The guard is `DatabaseLoader.list()`'s, one file away: same cast-then-map
1770
+ * spelling, one `typeof` filter behind it. Silently dropping is the landed
1771
+ * direction, not a preference — `DatabaseLoader` drops rather than throws,
1772
+ * and `FilesystemLoader`'s narrowing carries a maintainer ruling (via the
1773
+ * director seat on #14486, 2026-09-02) that chose narrowing (A) over
1774
+ * refusing loudly (B), because a name in the list that the door answers
1775
+ * `null` for is the silent failure an author reads as their own typo. An
1776
+ * `undefined` here is the extreme form of that name.
1777
+ *
1778
+ * ⛔ NOT copied from the siblings: `MemoryLoader` answers with its store
1779
+ * keys, and #14205 ruled that identity is the key the store holds an item
1780
+ * under rather than `body.name`. This loader reads over HTTP and holds no
1781
+ * store key, so `body.name` is the only identity it has — the list is
1782
+ * narrowed to agree with the door instead. `loadMany()` is deliberately
1783
+ * untouched: it keys nothing, so a nameless body is still served there.
1784
+ *
1785
+ * The predicate is spelled as a type guard, and the mapped element type left
1786
+ * `unknown`, so `tsc` PROVES the declared `string[]` instead of a cast
1787
+ * asserting it — otherwise the compiler reads the filter as always-true and
1788
+ * a later reader deletes it as dead.
1789
+ */
1448
1790
  list(type: string): Promise<string[]>;
1449
1791
  save(type: string, name: string, data: any, _options?: MetadataSaveOptions): Promise<MetadataSaveResult>;
1450
1792
  }
@@ -1523,8 +1865,14 @@ interface DatabaseLoaderCacheOptions {
1523
1865
  enabled?: boolean;
1524
1866
  /** Max number of cached `(type, name)` entries. Default: `500`. */
1525
1867
  maxSize?: number;
1526
- /** TTL in milliseconds. Set to `0` to disable expiry. Default: `60_000`. */
1527
- ttl?: number;
1868
+ /**
1869
+ * TTL in milliseconds. Set to `0` to disable expiry. Default: `60_000`.
1870
+ *
1871
+ * Renamed from `ttl` (#14478) in lockstep with the spec key it mirrors,
1872
+ * `MetadataManagerConfig.cache.databaseLoader.ttlMs`: the unit now lives in
1873
+ * the name, not only in this comment.
1874
+ */
1875
+ ttlMs?: number;
1528
1876
  }
1529
1877
  /**
1530
1878
  * Configuration for the DatabaseLoader.
@@ -1750,7 +2098,33 @@ declare class DatabaseLoader implements MetadataLoader {
1750
2098
  */
1751
2099
  private rethrowUnlessTableUnprovisioned;
1752
2100
  load(type: string, name: string, _options?: MetadataLoadOptions): Promise<MetadataLoadResult>;
2101
+ /**
2102
+ * The one type-wide read both plural readers share: every row of `type`, each
2103
+ * body paired with the `name` COLUMN it was stored under.
2104
+ *
2105
+ * [#14205] `name` is `null` only for a row whose key column does not hold a
2106
+ * string. Such a row is still a body {@link loadMany} must return — dropping
2107
+ * it would change what consumers see today — but it has no usable identity,
2108
+ * so {@link loadManyKeyed} filters it out rather than invent one.
2109
+ *
2110
+ * One query and one cache entry serve both methods: `loadMany()` used to own
2111
+ * them, and splitting them would have made every keyed `list()` read miss the
2112
+ * cache and re-hit the database.
2113
+ */
2114
+ private readTypeRows;
1753
2115
  loadMany<T = any>(type: string, _options?: MetadataLoadOptions): Promise<T[]>;
2116
+ /**
2117
+ * [#14205] The keyed half of {@link loadMany} — see
2118
+ * {@link MetadataKeyedItem} for why the row key travels beside the body
2119
+ * instead of inside it.
2120
+ *
2121
+ * `DatabaseLoader` is where the defect was measured: an aggregated view
2122
+ * container is written by `register('view', OBJECT, container)` and stored
2123
+ * verbatim, so its `sys_metadata` row carries the identity in the `name`
2124
+ * COLUMN and the body has none. {@link rowToData} returns that body without
2125
+ * folding the column in — deliberately, and unchanged here.
2126
+ */
2127
+ loadManyKeyed<T = any>(type: string, _options?: MetadataLoadOptions): Promise<MetadataKeyedItem<T>[]>;
1754
2128
  exists(type: string, name: string): Promise<boolean>;
1755
2129
  stat(type: string, name: string): Promise<MetadataStats | null>;
1756
2130
  list(type: string): Promise<string[]>;
@@ -1791,6 +2165,99 @@ declare class DatabaseLoader implements MetadataLoader {
1791
2165
  delete(type: string, name: string): Promise<void>;
1792
2166
  }
1793
2167
 
2168
+ /**
2169
+ * [#14921] The refusal a metadata source tree earns by naming one item twice.
2170
+ *
2171
+ * ## The invariant this restores
2172
+ *
2173
+ * *What is listed is what is loadable.* `FilesystemLoader` derives a metadata
2174
+ * name by stripping the extension from a flat file's basename, and resolves a
2175
+ * name back to a file under a FIXED extension precedence (`.json` → `.yaml` →
2176
+ * `.yml` → `.ts` → `.js`). Two files sharing a stem therefore produced one name
2177
+ * TWICE in `list()` while only the first-precedence file was reachable through
2178
+ * any name at all: the listed set and the addressable set stopped being the
2179
+ * same set, and `loadMany()` kept returning both bodies. The loser was
2180
+ * invisible — not missing, not reported, just never served.
2181
+ *
2182
+ * The failure is silent in the direction that matters for authoring, and the
2183
+ * trigger is a move authors (human and AI) make constantly: convert
2184
+ * `twin.json` to `twin.yaml` and leave the old file behind, or land one from
2185
+ * each of two packages. Today the JSON one is served forever with no
2186
+ * diagnostic anywhere, and `MetadataManager.admitLoaderItems()`'s documented
2187
+ * "keep the first and say nothing" absorbs the collision a second time.
2188
+ *
2189
+ * ## The ruling (maintainer, via the director seat on #14921, 2026-09-05)
2190
+ *
2191
+ * Option 1 of three: **refuse the ambiguous stem loudly at list time.** Two
2192
+ * files sharing a stem across the registered extensions is an AUTHORING ERROR,
2193
+ * reported with both paths named, never resolved by precedence. Not taken:
2194
+ * option 2 (keep the precedence and log at `warn` — with zero instances in any
2195
+ * measured tree, nobody reads that log, and the invariant stays broken) and
2196
+ * option 3 (make the extension part of the name for the non-first file — a
2197
+ * naming rule invented for an error state, grown into the contract).
2198
+ *
2199
+ * The narrowing is cheap for the reason the grade records: no measured
2200
+ * production or example tree carries two files with one stem, so no existing
2201
+ * tree goes red. It is a narrowing with almost no migration account.
2202
+ *
2203
+ * ## Why a brand and a predicate rather than bare `instanceof`
2204
+ *
2205
+ * `MetadataManager`'s plural reads catch per loader on purpose (#5108/#14423):
2206
+ * a storage outage must degrade to a short-but-served list rather than take the
2207
+ * whole enumeration down. This refusal is the opposite kind of fact — an
2208
+ * author's tree is malformed and no retry fixes it — so those seams have to
2209
+ * re-raise THIS error while still absorbing every other one. A predicate over
2210
+ * a `Symbol.for` brand is the discrimination that survives duplicate copies of
2211
+ * this module in a consumer's dependency graph, where `instanceof` does not.
2212
+ * Same shape, and for the same reason, as `@objectstack/core`'s
2213
+ * `isAuthzStoreUnavailableError`.
2214
+ */
2215
+ /** ADR-0112 wire code for the refusal. */
2216
+ declare const AMBIGUOUS_METADATA_STEM_CODE: "AMBIGUOUS_METADATA_STEM";
2217
+ /**
2218
+ * HTTP status a transport should answer.
2219
+ *
2220
+ * 500, deliberately: the REQUEST is well formed and no caller can fix it by
2221
+ * sending something else — the deployment's own metadata source tree is
2222
+ * ambiguous. Not 503 (nothing is transient here; a retry answers identically
2223
+ * until a file is deleted or renamed) and not 4xx (the caller did nothing
2224
+ * wrong).
2225
+ */
2226
+ declare const AMBIGUOUS_METADATA_STEM_STATUS: 500;
2227
+ declare const AMBIGUOUS_METADATA_STEM_BRAND: unique symbol;
2228
+ /**
2229
+ * Thrown when one metadata name is derived from more than one file among a
2230
+ * loader's REGISTERED extensions.
2231
+ *
2232
+ * The message names every colliding path and the metadata type, because those
2233
+ * are exactly the two things an author needs and neither is recoverable from
2234
+ * the name alone: a bare "duplicate `twin`" sends them looking through a tree
2235
+ * for something they already believe they deleted.
2236
+ */
2237
+ declare class AmbiguousMetadataStemError extends Error {
2238
+ /** Brand — see the module doc on why this is not `instanceof`. */
2239
+ readonly [AMBIGUOUS_METADATA_STEM_BRAND]: true;
2240
+ /** ADR-0112 wire code. */
2241
+ readonly code: "AMBIGUOUS_METADATA_STEM";
2242
+ /** HTTP status a transport should answer. */
2243
+ readonly status: 500;
2244
+ /** The metadata type whose directory holds the collision (e.g. `object`). */
2245
+ readonly type: string;
2246
+ /** The one name both files derive to. */
2247
+ readonly stem: string;
2248
+ /** Every colliding file, absolute, sorted — never just the winner. */
2249
+ readonly paths: readonly string[];
2250
+ constructor(type: string, stem: string, paths: readonly string[]);
2251
+ }
2252
+ /**
2253
+ * True when `err` is the ambiguous-stem refusal above.
2254
+ *
2255
+ * The predicate every catch-and-degrade seam uses to re-raise THIS one without
2256
+ * loosening its handling of anything else — a storage outage still degrades, an
2257
+ * author's malformed tree does not.
2258
+ */
2259
+ declare function isAmbiguousMetadataStemError(err: unknown): err is AmbiguousMetadataStemError;
2260
+
1794
2261
  /**
1795
2262
  * Metadata History Utilities
1796
2263
  *
@@ -1935,4 +2402,4 @@ declare class TypeScriptSerializer implements MetadataSerializer {
1935
2402
  getFormat(): MetadataFormat;
1936
2403
  }
1937
2404
 
1938
- export { DatabaseLoader, type DatabaseLoaderOptions, HistoryCleanupManager, JSONSerializer, MemoryLoader, type MetadataLoader, MetadataManager, type MetadataManagerOptions, MetadataPlugin, type MetadataSerializer, index as Migration, RemoteLoader, type SerializeOptions, TypeScriptSerializer, type WatchCallback, YAMLSerializer, calculateChecksum, generateDiffSummary, generateSimpleDiff };
2405
+ export { AMBIGUOUS_METADATA_STEM_CODE, AMBIGUOUS_METADATA_STEM_STATUS, AmbiguousMetadataStemError, DatabaseLoader, type DatabaseLoaderOptions, HistoryCleanupManager, JSONSerializer, MemoryLoader, type MetadataKeyedItem, type MetadataLoader, MetadataManager, type MetadataManagerOptions, MetadataPlugin, type MetadataSerializer, index as Migration, RemoteLoader, type SerializeOptions, TypeScriptSerializer, type WatchCallback, YAMLSerializer, calculateChecksum, generateDiffSummary, generateSimpleDiff, isAmbiguousMetadataStemError };