@objectstack/metadata 17.3.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.cts CHANGED
@@ -9,6 +9,7 @@ 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.cjs';
12
13
 
13
14
  /**
14
15
  * Metadata Serializer Interface
@@ -868,6 +869,30 @@ declare class MetadataManager implements IMetadataService {
868
869
  exists(type: string, name: string): Promise<boolean>;
869
870
  /**
870
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.
871
896
  */
872
897
  listNames(type: string): Promise<string[]>;
873
898
  /**
@@ -1181,6 +1206,72 @@ declare class MetadataManager implements IMetadataService {
1181
1206
  * Aggregates results from all loaders.
1182
1207
  */
1183
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;
1184
1275
  /**
1185
1276
  * Save metadata item to a loader
1186
1277
  */
@@ -1662,6 +1753,40 @@ declare class RemoteLoader implements MetadataLoader {
1662
1753
  loadMany<T = any>(type: string, _options?: MetadataLoadOptions): Promise<T[]>;
1663
1754
  exists(type: string, name: string): Promise<boolean>;
1664
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
+ */
1665
1790
  list(type: string): Promise<string[]>;
1666
1791
  save(type: string, name: string, data: any, _options?: MetadataSaveOptions): Promise<MetadataSaveResult>;
1667
1792
  }
@@ -1740,8 +1865,14 @@ interface DatabaseLoaderCacheOptions {
1740
1865
  enabled?: boolean;
1741
1866
  /** Max number of cached `(type, name)` entries. Default: `500`. */
1742
1867
  maxSize?: number;
1743
- /** TTL in milliseconds. Set to `0` to disable expiry. Default: `60_000`. */
1744
- 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;
1745
1876
  }
1746
1877
  /**
1747
1878
  * Configuration for the DatabaseLoader.
@@ -2034,6 +2165,99 @@ declare class DatabaseLoader implements MetadataLoader {
2034
2165
  delete(type: string, name: string): Promise<void>;
2035
2166
  }
2036
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
+
2037
2261
  /**
2038
2262
  * Metadata History Utilities
2039
2263
  *
@@ -2178,61 +2402,4 @@ declare class TypeScriptSerializer implements MetadataSerializer {
2178
2402
  getFormat(): MetadataFormat;
2179
2403
  }
2180
2404
 
2181
- /**
2182
- * [#13913] Registry-free expansion of an aggregated `defineView` container
2183
- * that reached `MetadataManager`'s OWN backing store.
2184
- *
2185
- * ---------------------------------------------------------------------------
2186
- * Why this lives in `packages/metadata` and does NOT import the protocol's copy
2187
- * ---------------------------------------------------------------------------
2188
- * `packages/metadata-protocol` grew `expandRuntimeViewContainer` (#13407) for
2189
- * `getMetaItems`' own inline expansion, and the obvious move would be to share
2190
- * it. **The dependency edge forbids it**: `@objectstack/metadata-protocol`
2191
- * declares `@objectstack/metadata` as a dependency, so an import in this
2192
- * direction inverts an existing edge and closes a cycle. Promoting the
2193
- * protocol's private method to a public export would not help either — it
2194
- * would widen that package's surface *and* still be unreachable from here.
2195
- *
2196
- * The reusable substance is therefore taken from where it already is: the
2197
- * canonical expansion primitives (`isAggregatedViewContainer`,
2198
- * `expandViewContainer`) live in `@objectstack/spec`, one level BELOW both
2199
- * packages, and this package already imports them (`plugin.ts`). Nothing is
2200
- * duplicated except the ~6-line object-derivation chain — which is exactly the
2201
- * part that has silently drifted three ways (the ObjectQL boot loop keys off
2202
- * the registration name, `plugin.ts` walks two levels, and `protocol.ts` walks
2203
- * four since #13407). {@link deriveViewContainerObject} is the one spelling of
2204
- * it for this package, so the drift has a single place to be repaired rather
2205
- * than a third private copy to fall behind.
2206
- *
2207
- * ---------------------------------------------------------------------------
2208
- * Registry-free, on purpose
2209
- * ---------------------------------------------------------------------------
2210
- * Nothing here REGISTERS. `protocol.ts`'s own header records why its
2211
- * registry-MUTATING path (`hydrateExpandedViewItems`) could not simply be
2212
- * widened — it is gated off for every org-scoped row (ADR-0005: the registry
2213
- * is shared by every org a kernel serves) — and why #13407's actual repair was
2214
- * a separate, registry-free, per-request expansion. The same reasoning applies
2215
- * one exit over: `MetadataManager`'s registry is process-wide too, so the
2216
- * repair for {@link MetadataManager.getViewsByObject} is a pure function over
2217
- * what that one read already holds.
2218
- */
2219
-
2220
- /**
2221
- * Which object an aggregated view container binds to.
2222
- *
2223
- * The container's OWN top-level `object` field — `ViewSchema.object`,
2224
- * documented there as "how a stack-level `views: [...]` entry says which object
2225
- * its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`"
2226
- * — is the authorial, explicit signal and is consulted FIRST (#13407). The
2227
- * three-deep fallback below it is kept unchanged for every container written
2228
- * before that field was read here: `list.data.object`, then `form.data.object`,
2229
- * then the row's own `name` — which is the bound object only by convention, and
2230
- * is why a container that set the top-level field but not `list.data.object`
2231
- * used to bind under the wrong key or not at all.
2232
- *
2233
- * Returns `undefined` when no binding can be derived; every caller treats that
2234
- * as "no expansion" rather than an error.
2235
- */
2236
- declare function deriveViewContainerObject(container: unknown): string | undefined;
2237
-
2238
- export { 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, deriveViewContainerObject, 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 };
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ 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
@@ -868,6 +869,30 @@ declare class MetadataManager implements IMetadataService {
868
869
  exists(type: string, name: string): Promise<boolean>;
869
870
  /**
870
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.
871
896
  */
872
897
  listNames(type: string): Promise<string[]>;
873
898
  /**
@@ -1181,6 +1206,72 @@ declare class MetadataManager implements IMetadataService {
1181
1206
  * Aggregates results from all loaders.
1182
1207
  */
1183
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;
1184
1275
  /**
1185
1276
  * Save metadata item to a loader
1186
1277
  */
@@ -1662,6 +1753,40 @@ declare class RemoteLoader implements MetadataLoader {
1662
1753
  loadMany<T = any>(type: string, _options?: MetadataLoadOptions): Promise<T[]>;
1663
1754
  exists(type: string, name: string): Promise<boolean>;
1664
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
+ */
1665
1790
  list(type: string): Promise<string[]>;
1666
1791
  save(type: string, name: string, data: any, _options?: MetadataSaveOptions): Promise<MetadataSaveResult>;
1667
1792
  }
@@ -1740,8 +1865,14 @@ interface DatabaseLoaderCacheOptions {
1740
1865
  enabled?: boolean;
1741
1866
  /** Max number of cached `(type, name)` entries. Default: `500`. */
1742
1867
  maxSize?: number;
1743
- /** TTL in milliseconds. Set to `0` to disable expiry. Default: `60_000`. */
1744
- 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;
1745
1876
  }
1746
1877
  /**
1747
1878
  * Configuration for the DatabaseLoader.
@@ -2034,6 +2165,99 @@ declare class DatabaseLoader implements MetadataLoader {
2034
2165
  delete(type: string, name: string): Promise<void>;
2035
2166
  }
2036
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
+
2037
2261
  /**
2038
2262
  * Metadata History Utilities
2039
2263
  *
@@ -2178,61 +2402,4 @@ declare class TypeScriptSerializer implements MetadataSerializer {
2178
2402
  getFormat(): MetadataFormat;
2179
2403
  }
2180
2404
 
2181
- /**
2182
- * [#13913] Registry-free expansion of an aggregated `defineView` container
2183
- * that reached `MetadataManager`'s OWN backing store.
2184
- *
2185
- * ---------------------------------------------------------------------------
2186
- * Why this lives in `packages/metadata` and does NOT import the protocol's copy
2187
- * ---------------------------------------------------------------------------
2188
- * `packages/metadata-protocol` grew `expandRuntimeViewContainer` (#13407) for
2189
- * `getMetaItems`' own inline expansion, and the obvious move would be to share
2190
- * it. **The dependency edge forbids it**: `@objectstack/metadata-protocol`
2191
- * declares `@objectstack/metadata` as a dependency, so an import in this
2192
- * direction inverts an existing edge and closes a cycle. Promoting the
2193
- * protocol's private method to a public export would not help either — it
2194
- * would widen that package's surface *and* still be unreachable from here.
2195
- *
2196
- * The reusable substance is therefore taken from where it already is: the
2197
- * canonical expansion primitives (`isAggregatedViewContainer`,
2198
- * `expandViewContainer`) live in `@objectstack/spec`, one level BELOW both
2199
- * packages, and this package already imports them (`plugin.ts`). Nothing is
2200
- * duplicated except the ~6-line object-derivation chain — which is exactly the
2201
- * part that has silently drifted three ways (the ObjectQL boot loop keys off
2202
- * the registration name, `plugin.ts` walks two levels, and `protocol.ts` walks
2203
- * four since #13407). {@link deriveViewContainerObject} is the one spelling of
2204
- * it for this package, so the drift has a single place to be repaired rather
2205
- * than a third private copy to fall behind.
2206
- *
2207
- * ---------------------------------------------------------------------------
2208
- * Registry-free, on purpose
2209
- * ---------------------------------------------------------------------------
2210
- * Nothing here REGISTERS. `protocol.ts`'s own header records why its
2211
- * registry-MUTATING path (`hydrateExpandedViewItems`) could not simply be
2212
- * widened — it is gated off for every org-scoped row (ADR-0005: the registry
2213
- * is shared by every org a kernel serves) — and why #13407's actual repair was
2214
- * a separate, registry-free, per-request expansion. The same reasoning applies
2215
- * one exit over: `MetadataManager`'s registry is process-wide too, so the
2216
- * repair for {@link MetadataManager.getViewsByObject} is a pure function over
2217
- * what that one read already holds.
2218
- */
2219
-
2220
- /**
2221
- * Which object an aggregated view container binds to.
2222
- *
2223
- * The container's OWN top-level `object` field — `ViewSchema.object`,
2224
- * documented there as "how a stack-level `views: [...]` entry says which object
2225
- * its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`"
2226
- * — is the authorial, explicit signal and is consulted FIRST (#13407). The
2227
- * three-deep fallback below it is kept unchanged for every container written
2228
- * before that field was read here: `list.data.object`, then `form.data.object`,
2229
- * then the row's own `name` — which is the bound object only by convention, and
2230
- * is why a container that set the top-level field but not `list.data.object`
2231
- * used to bind under the wrong key or not at all.
2232
- *
2233
- * Returns `undefined` when no binding can be derived; every caller treats that
2234
- * as "no expansion" rather than an error.
2235
- */
2236
- declare function deriveViewContainerObject(container: unknown): string | undefined;
2237
-
2238
- export { 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, deriveViewContainerObject, 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 };