@metaobjectsdev/metadata 1.0.3 → 1.0.4-rc.1
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/core/relationship/derive-m2m-fields.d.ts +3 -1
- package/dist/core/relationship/derive-m2m-fields.d.ts.map +1 -1
- package/dist/core/relationship/derive-m2m-fields.js +110 -15
- package/dist/core/relationship/derive-m2m-fields.js.map +1 -1
- package/dist/core/relationship/find-reference.d.ts +21 -5
- package/dist/core/relationship/find-reference.d.ts.map +1 -1
- package/dist/core/relationship/find-reference.js +29 -10
- package/dist/core/relationship/find-reference.js.map +1 -1
- package/dist/core/relationship/relationship-definition.embedded.js +8 -8
- package/dist/core/relationship/relationship-definition.embedded.js.map +1 -1
- package/dist/core/relationship/resolve-relationship-reference.d.ts +27 -0
- package/dist/core/relationship/resolve-relationship-reference.d.ts.map +1 -0
- package/dist/core/relationship/resolve-relationship-reference.js +84 -0
- package/dist/core/relationship/resolve-relationship-reference.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/library/embedded-library.generated.d.ts +2 -0
- package/dist/library/embedded-library.generated.d.ts.map +1 -1
- package/dist/library/embedded-library.generated.js +11 -1
- package/dist/library/embedded-library.generated.js.map +1 -1
- package/dist/library/index.d.ts +2 -0
- package/dist/library/index.d.ts.map +1 -0
- package/dist/library/index.js +24 -0
- package/dist/library/index.js.map +1 -0
- package/dist/library/library-sources.d.ts +99 -6
- package/dist/library/library-sources.d.ts.map +1 -1
- package/dist/library/library-sources.js +165 -43
- package/dist/library/library-sources.js.map +1 -1
- package/dist/loader/meta-data-loader.d.ts.map +1 -1
- package/dist/loader/meta-data-loader.js +6 -1
- package/dist/loader/meta-data-loader.js.map +1 -1
- package/dist/loader/validation-passes.d.ts +2 -0
- package/dist/loader/validation-passes.d.ts.map +1 -1
- package/dist/loader/validation-passes.js +162 -22
- package/dist/loader/validation-passes.js.map +1 -1
- package/dist/parser-core.d.ts.map +1 -1
- package/dist/parser-core.js +13 -2
- package/dist/parser-core.js.map +1 -1
- package/dist/vocabulary-rewrite-yaml.d.ts +2 -0
- package/dist/vocabulary-rewrite-yaml.d.ts.map +1 -0
- package/dist/vocabulary-rewrite-yaml.js +22 -0
- package/dist/vocabulary-rewrite-yaml.js.map +1 -0
- package/package.json +7 -7
- package/src/core/relationship/derive-m2m-fields.ts +115 -15
- package/src/core/relationship/find-reference.ts +34 -11
- package/src/core/relationship/relationship-definition.embedded.ts +8 -8
- package/src/core/relationship/resolve-relationship-reference.ts +99 -0
- package/src/errors.ts +10 -0
- package/src/index.ts +6 -1
- package/src/library/embedded-library.generated.ts +12 -1
- package/src/library/index.ts +23 -0
- package/src/library/library-sources.ts +197 -47
- package/src/loader/meta-data-loader.ts +7 -1
- package/src/loader/validation-passes.ts +181 -23
- package/src/parser-core.ts +15 -2
- package/src/vocabulary-rewrite-yaml.ts +21 -0
|
@@ -13,7 +13,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = {
|
|
|
13
13
|
"type": "relationship",
|
|
14
14
|
"subType": "base",
|
|
15
15
|
"description": "Abstract relationship base — shared shape for the concrete association/aggregation/composition subtypes; not authored directly. A `relationship.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED): this subtype is a registry anchor concrete subtypes inherit from, never a node in a document.",
|
|
16
|
-
"rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).",
|
|
16
|
+
"rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).",
|
|
17
17
|
"children": [
|
|
18
18
|
{
|
|
19
19
|
"type": "attr",
|
|
@@ -45,7 +45,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = {
|
|
|
45
45
|
"name": "sourceRefField",
|
|
46
46
|
"min": 0,
|
|
47
47
|
"max": 1,
|
|
48
|
-
"description": "
|
|
48
|
+
"description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely."
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"type": "attr",
|
|
@@ -90,7 +90,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = {
|
|
|
90
90
|
"subType": "association",
|
|
91
91
|
"description": "A plain reference to another entity — no ownership; the target has an independent lifecycle (default @onDelete restrict).",
|
|
92
92
|
"whenToUse": "A plain directed reference to another entity, no ownership or cascade. The lightest link — when you just need to point at another entity.",
|
|
93
|
-
"rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).",
|
|
93
|
+
"rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).",
|
|
94
94
|
"children": [
|
|
95
95
|
{
|
|
96
96
|
"type": "attr",
|
|
@@ -122,7 +122,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = {
|
|
|
122
122
|
"name": "sourceRefField",
|
|
123
123
|
"min": 0,
|
|
124
124
|
"max": 1,
|
|
125
|
-
"description": "
|
|
125
|
+
"description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely."
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
"type": "attr",
|
|
@@ -167,7 +167,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = {
|
|
|
167
167
|
"subType": "aggregation",
|
|
168
168
|
"description": "A shared/independent containment — the parent groups the target but does not own its lifecycle (default @onDelete set-null).",
|
|
169
169
|
"whenToUse": "One entity groups others it does NOT own (children outlive the parent; delete sets the FK null). Use instead of composition when there is no ownership.",
|
|
170
|
-
"rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).",
|
|
170
|
+
"rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).",
|
|
171
171
|
"children": [
|
|
172
172
|
{
|
|
173
173
|
"type": "attr",
|
|
@@ -199,7 +199,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = {
|
|
|
199
199
|
"name": "sourceRefField",
|
|
200
200
|
"min": 0,
|
|
201
201
|
"max": 1,
|
|
202
|
-
"description": "
|
|
202
|
+
"description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely."
|
|
203
203
|
},
|
|
204
204
|
{
|
|
205
205
|
"type": "attr",
|
|
@@ -244,7 +244,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = {
|
|
|
244
244
|
"subType": "composition",
|
|
245
245
|
"description": "An owned containment — the parent owns the target's lifecycle; deleting the parent deletes the children (default @onDelete cascade).",
|
|
246
246
|
"whenToUse": "You need a parent that OWNS a child collection (one-to-many, cascade on delete). Declare it to generate the FK + typed navigation instead of a bare FK field + hand-written joins.",
|
|
247
|
-
"rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).",
|
|
247
|
+
"rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).",
|
|
248
248
|
"children": [
|
|
249
249
|
{
|
|
250
250
|
"type": "attr",
|
|
@@ -276,7 +276,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = {
|
|
|
276
276
|
"name": "sourceRefField",
|
|
277
277
|
"min": 0,
|
|
278
278
|
"max": 1,
|
|
279
|
-
"description": "
|
|
279
|
+
"description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely."
|
|
280
280
|
},
|
|
281
281
|
{
|
|
282
282
|
"type": "attr",
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Association -> identity.reference resolution (issue #368).
|
|
2
|
+
//
|
|
3
|
+
// An entity may declare more than one identity.reference onto the SAME target
|
|
4
|
+
// entity (Match.homeTeamRef and Match.awayTeamRef both -> Team). A
|
|
5
|
+
// `@cardinality: one` relationship names only its target, so when two
|
|
6
|
+
// references match, the target alone cannot say which FK the navigation uses.
|
|
7
|
+
// Taking the first match emits a join on the wrong column that typechecks, has
|
|
8
|
+
// correct DDL and passes verify — so the ladder below resolves it explicitly or
|
|
9
|
+
// not at all. ADR-0029 §5: ambiguity is a load error naming the candidates.
|
|
10
|
+
|
|
11
|
+
import type { MetaObject } from "../object/meta-object.js";
|
|
12
|
+
import type { MetaReferenceIdentity } from "../identity/meta-identity.js";
|
|
13
|
+
import { stripPackage } from "../../naming.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Trailing suffixes stripped from a CANDIDATE's name/FK field when building its
|
|
17
|
+
* pairing keys. Ordered — first match wins, so "reference" is tested before
|
|
18
|
+
* "ref". Never applied to the relationship name (see referencePairingKeys).
|
|
19
|
+
*/
|
|
20
|
+
const PAIRING_SUFFIXES = ["reference", "ref", "id", "key"] as const;
|
|
21
|
+
|
|
22
|
+
function stripOneSuffix(value: string): string {
|
|
23
|
+
for (const suffix of PAIRING_SUFFIXES) {
|
|
24
|
+
if (value.length > suffix.length && value.endsWith(suffix)) {
|
|
25
|
+
return value.slice(0, value.length - suffix.length);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The FK field a reference is anchored on (first field; composite FKs pair on their first column). */
|
|
32
|
+
function refFkField(ref: MetaReferenceIdentity): string | undefined {
|
|
33
|
+
return ref.fields.length > 0 ? ref.fields[0] : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The set of lowercased names a candidate reference answers to: its own name
|
|
38
|
+
* and its FK field, each with and without one stripped suffix.
|
|
39
|
+
*/
|
|
40
|
+
export function referencePairingKeys(ref: MetaReferenceIdentity): Set<string> {
|
|
41
|
+
const keys = new Set<string>();
|
|
42
|
+
const add = (value: string | undefined): void => {
|
|
43
|
+
if (!value) return;
|
|
44
|
+
const lower = value.toLowerCase();
|
|
45
|
+
keys.add(lower);
|
|
46
|
+
keys.add(stripOneSuffix(lower));
|
|
47
|
+
};
|
|
48
|
+
add(ref.name);
|
|
49
|
+
add(refFkField(ref));
|
|
50
|
+
return keys;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every identity.reference on `holder` whose @references targets `targetEntity`.
|
|
55
|
+
* Package-insensitive on both sides: @references and @objectRef may each be bare
|
|
56
|
+
* or fully qualified.
|
|
57
|
+
*/
|
|
58
|
+
export function referenceCandidatesFor(
|
|
59
|
+
holder: MetaObject,
|
|
60
|
+
targetEntity: string,
|
|
61
|
+
): MetaReferenceIdentity[] {
|
|
62
|
+
const target = stripPackage(targetEntity);
|
|
63
|
+
// ADR-0039: resolving — referenceIdentities() honors references inherited via extends.
|
|
64
|
+
return holder
|
|
65
|
+
.referenceIdentities()
|
|
66
|
+
.filter((ref) => stripPackage(ref.targetEntity ?? "") === target)
|
|
67
|
+
.filter((ref) => refFkField(ref) !== undefined);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Which identity.reference does this `@cardinality: one` relationship navigate
|
|
72
|
+
* through? The ladder, in order:
|
|
73
|
+
*
|
|
74
|
+
* 1. exactly one candidate -> that one (the common case; unchanged behaviour)
|
|
75
|
+
* 2. `@sourceRefField` declared -> the candidate whose FK field it names
|
|
76
|
+
* 3. exactly one candidate name-pairs -> that one
|
|
77
|
+
* 4. otherwise -> undefined (caller reports the ambiguity)
|
|
78
|
+
*
|
|
79
|
+
* Returns undefined for "no candidate" and "cannot choose" alike; callers that
|
|
80
|
+
* need to tell them apart use referenceCandidatesFor().
|
|
81
|
+
*/
|
|
82
|
+
export function resolveRelationshipReference(
|
|
83
|
+
holder: MetaObject,
|
|
84
|
+
relationshipName: string,
|
|
85
|
+
targetEntity: string,
|
|
86
|
+
sourceRefField?: string,
|
|
87
|
+
): MetaReferenceIdentity | undefined {
|
|
88
|
+
const candidates = referenceCandidatesFor(holder, targetEntity);
|
|
89
|
+
if (candidates.length === 0) return undefined;
|
|
90
|
+
if (candidates.length === 1) return candidates[0];
|
|
91
|
+
|
|
92
|
+
if (sourceRefField !== undefined && sourceRefField !== "") {
|
|
93
|
+
return candidates.find((ref) => refFkField(ref) === sourceRefField);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const wanted = relationshipName.toLowerCase();
|
|
97
|
+
const paired = candidates.filter((ref) => referencePairingKeys(ref).has(wanted));
|
|
98
|
+
return paired.length === 1 ? paired[0] : undefined;
|
|
99
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -260,6 +260,16 @@ export const ERROR_CODES = [
|
|
|
260
260
|
// Phase-1 metadata-source-resolution — no metadata collection was discovered:
|
|
261
261
|
// no config declaring sources, and no default metaobjects/ directory.
|
|
262
262
|
"ERR_COLLECTION_NOT_FOUND",
|
|
263
|
+
// FR-043 — `.metaobjects/config.json`'s `libraries` names a shipped library or layer
|
|
264
|
+
// this build does not have. A HUMAN typed it, so it is refused with the available
|
|
265
|
+
// tokens rather than skipped: skipped, it resurfaces as ERR_UNRESOLVED_SUPER against
|
|
266
|
+
// the adopter's own metadata, which is the wrong place to send someone looking.
|
|
267
|
+
"ERR_UNKNOWN_LIBRARY",
|
|
268
|
+
// FR-043 — a node is declared by BOTH an adopter's own metadata and a shipped library the project opts into — the `meta eject <lib>` copy with the library still in `libraries`. The two merge silently and ASYMMETRICALLY: additions take, deletions do not, because the library still declares what was removed.
|
|
269
|
+
// Raised by the TypeScript SDK's load path; registered in every port so the shared corpus list stays one set.
|
|
270
|
+
"ERR_LIBRARY_PACKAGE_COLLISION",
|
|
271
|
+
// FR-043 — a NEW top-level node is declared into a package a shipped library owns while that library is opted in — a later release of the library may ship a node of that name and merge into it. `overlay: true` on one of the library's OWN nodes is the documented amendment door and is untouched.
|
|
272
|
+
"ERR_LIBRARY_PACKAGE_NOT_OWNED",
|
|
263
273
|
// FR-023 — a declared dependency's transport could not locate a directory holding
|
|
264
274
|
// metaobjects.pkg.json.
|
|
265
275
|
"ERR_DEPENDENCY_UNRESOLVED",
|
package/src/index.ts
CHANGED
|
@@ -114,8 +114,13 @@ export type { IdentityPassthroughResolution } from "./core/identity/validate-ide
|
|
|
114
114
|
// Relationship
|
|
115
115
|
export { MetaRelationship } from "./core/relationship/meta-relationship.js";
|
|
116
116
|
// Cross-entity reference lookup
|
|
117
|
-
export { findReferenceBetween } from "./core/relationship/find-reference.js";
|
|
117
|
+
export { findReferenceBetween, findReferencesBetween } from "./core/relationship/find-reference.js";
|
|
118
118
|
export type { ReferenceLookup } from "./core/relationship/find-reference.js";
|
|
119
|
+
export {
|
|
120
|
+
referenceCandidatesFor,
|
|
121
|
+
referencePairingKeys,
|
|
122
|
+
resolveRelationshipReference,
|
|
123
|
+
} from "./core/relationship/resolve-relationship-reference.js";
|
|
119
124
|
// FR-017 — M:N junction FK derivation (hetero / directed-self-join / symmetric)
|
|
120
125
|
export { deriveM2MFields, M2MDerivationError } from "./core/relationship/derive-m2m-fields.js";
|
|
121
126
|
export type { M2MFields } from "./core/relationship/derive-m2m-fields.js";
|
|
@@ -6,5 +6,16 @@
|
|
|
6
6
|
// wherever the on-disk library/ directory is unavailable.
|
|
7
7
|
// Keys are refs: path under library/ minus the .yaml extension.
|
|
8
8
|
export const EMBEDDED_LIBRARY: Record<string, string> = {
|
|
9
|
-
"ai/
|
|
9
|
+
"ai/db": "# library/ai/db.yaml — the DB PERSISTENCE layer for metaobjects::ai.\n#\n# Opted into as `\"ai/db\"`, which IMPLIES `\"ai\"`: `LlmCall` is declared in model.yaml and\n# this file only re-opens it, so without the core layer the overlay has no target.\n#\n# `LlmCall` is the concrete, table-backed instance of the abstract envelope. An adopter\n# who wants their OWN table (a different name, extra columns, a different id strategy)\n# extends `LlmCallBase` in their own metadata and never opts into this layer at all.\nmetadata:\n package: metaobjects::ai\n children:\n - object.entity:\n name: LlmCall\n overlay: true\n children:\n - source.rdb: { table: llm_call, role: primary }\n",
|
|
10
|
+
"ai/model": "# library/ai/model.yaml — the CORE layer: the LLM-call trace envelope.\n#\n# Adopters opt in via `libraries: [\"ai\"]`, then `extends: \"metaobjects::ai::LlmCallBase\"`.\n#\n# This layer declares NO `source.rdb`, so opting into `\"ai\"` alone adds zero tables and\n# zero generated code — the design is present and resolvable, and nothing else happens\n# until the adopter adds `\"ai/db\"`. See library/iam/model.yaml for the full rationale.\n#\n# This file was split out of the former `library/ai/llm-call.yaml`, which shipped the\n# abstract base and a concrete `LlmCall` carrying `source.rdb` together. That was\n# recorded as an accepted wart on the grounds that splitting would change what existing\n# `ai` adopters get; a sweep of the estate found there are none, so it was closed rather\n# than documented (FR-043 Amendment 1).\nmetadata:\n package: metaobjects::ai\n children:\n - object.entity:\n name: LlmCallBase\n abstract: true\n children:\n - field.uuid: { name: traceId }\n - field.uuid: { name: spanId }\n - field.uuid: { name: parentSpanId }\n - field.string: { name: sessionId }\n - field.string: { name: callType }\n - field.string: { name: system }\n - field.string: { name: requestModel }\n - field.string: { name: responseModel }\n - field.int: { name: inputTokens }\n - field.int: { name: outputTokens }\n - field.currency: { name: costMinor, currency: USD }\n - field.int: { name: latencyMs }\n - field.string: { name: finishReason }\n - field.string: { name: status }\n - field.string: { name: errorDetail }\n - field.timestamp: { name: startedAt }\n - field.string: { name: llmRequest, dbColumnType: jsonb } # generic jsonb (no objectRef)\n - field.string: { name: llmResponse, dbColumnType: jsonb }\n - object.entity:\n name: LlmCall\n extends: metaobjects::ai::LlmCallBase\n description: The concrete trace row. Its `source.rdb` lives in db.yaml, so opting into the core layer alone declares the shape without proposing a table.\n children:\n - identity.primary: { name: id, fields: [\"spanId\"] }\n",
|
|
11
|
+
"ai/requirements": "# library/ai/requirements.yaml — what the LLM-call trace envelope PROMISES.\n#\n# A RETROFIT, not new design: llm-call.yaml landed 2026-06-03 and `requirement.functional`\n# first appears 2026-08-11, so the library could not have carried requirements when it was\n# written. That is why this file is worth reading as a worked example — it shows what\n# declaring the design of something that already exists actually turns up.\n#\n# The entry that earns its keep is `typedIo`, honestly `partial` + `accepted`: the library\n# declares the ENVELOPE and the adopter declares the typed VO columns. Recording that seam\n# in the ledger is where an agent meets it, before adding a fourth trace column.\n#\n# HIERARCHY IS NESTING, and the L4/L5 split is grain. An L4 names the OBJECT it is about;\n# the fields that carry it hang off it as an L5 child. Writing the fields at L4 is\n# ERR_REQUIREMENT_L4_NOT_OBJECT, and writing the concerns as SIBLINGS of the L2 leaves the\n# L2 claiming nothing — both of which this file did until the standalone verify gate\n# existed (`cli/test/shipped-library-verify.test.ts`).\nmetadata:\n package: metaobjects::ai\n children:\n - requirement.functional:\n name: llmTracing\n level: 2\n status: live\n statement: Every call to a language model leaves a row that says what was asked, what came back, what it cost and how long it took.\n counterexample: A spend figure nobody can attribute to a call.\n description: The segment this library covers. Its three children below are the concerns it decomposes into.\n children:\n - requirement.functional:\n name: envelope\n level: 4\n status: live\n statement: A trace row identifies its call and its place in a trace — trace, span, parent span, session, call type, system.\n counterexample: A log line that cannot be joined to the request that produced it.\n implementedBy: [LlmCallBase]\n children:\n - requirement.functional:\n name: traceAddressing\n level: 5\n status: live\n statement: The four addressing columns are declared on the base — trace, span, parent span and session.\n counterexample: A row whose place in a trace is inferred from insertion order.\n description: >-\n The member grain exists here so the claim RESOLVES against the fields\n themselves: renaming or dropping one of them dangles this reference and\n fails the build, which naming the object alone would not.\n implementedBy: [LlmCallBase.traceId, LlmCallBase.spanId, LlmCallBase.parentSpanId, LlmCallBase.sessionId]\n\n - requirement.functional:\n name: accounting\n level: 4\n status: live\n statement: A trace row carries the tokens in, the tokens out, and the cost in integer minor units.\n counterexample: A cost stored as a float.\n description: >-\n `field.currency` — integer minor units on the wire, always. Float arithmetic for\n money is forbidden by the cross-port wire contract, and a spend total is exactly\n the sum that exposes it.\n implementedBy: [LlmCallBase]\n children:\n - requirement.functional:\n name: tokenAndCostColumns\n level: 5\n status: live\n statement: Tokens in, tokens out and cost are three declared columns, the cost a field.currency.\n counterexample: A cost column declared as a double.\n implementedBy: [LlmCallBase.inputTokens, LlmCallBase.outputTokens, LlmCallBase.costMinor]\n\n - requirement.functional:\n name: typedIo\n level: 4\n status: partial\n disposition: accepted\n statement: The request and response bodies are stored as structured jsonb, not as opaque text.\n counterexample: A prompt stored as a string nobody can query a field out of.\n notes: >-\n The library declares the two columns as generic jsonb with no `@objectRef`,\n because it cannot know the adopter's request/response shape. Typing them is the\n ADOPTER's move: declare an `object.value` and overlay the field with\n `@objectRef` + `@storage: jsonb`. This is the seam ADR-0024 drew, recorded here\n rather than in prose so it is in the ledger an agent reads before adding a\n fourth trace column of its own.\n implementedBy: [LlmCallBase]\n children:\n - requirement.functional:\n name: jsonbBodies\n level: 5\n status: live\n statement: The request and response bodies are declared as jsonb columns on the base.\n counterexample: A prompt stored in a text column.\n description: >-\n `live` where its parent is `partial`, and the split is the point: the\n COLUMNS are shipped and this claim is fully realised; what is outstanding\n is the TYPING of them, which is the parent's gap and the adopter's move.\n implementedBy: [LlmCallBase.llmRequest, LlmCallBase.llmResponse]\n\n - requirement.architectural:\n name: traceRowsCarryTiming\n status: live\n statement: Every trace row records when the call started and how long it took.\n counterexample: A latency figure derived from log timestamps after the fact.\n description: >-\n Architectural, so it propagates down `extends` to every adopter entity deriving\n from LlmCallBase — which is the point: an adopter's own trace table is claimed\n by this requirement for free, and dropping the columns breaks the build.\n implementedBy: [LlmCallBase]\n\n - requirement.architectural:\n name: traceRowsCarryOutcome\n status: live\n statement: Every trace row records how the call ended — a status, a finish reason, and the error detail when there was one.\n counterexample: A failed call indistinguishable from one that never happened.\n implementedBy: [LlmCallBase]\n",
|
|
12
|
+
"iam/db": "# library/iam/db.yaml — the DB PERSISTENCE layer for metaobjects::iam.\n#\n# Opted into as `\"iam/db\"`, which IMPLIES `\"iam\"`: this file is nothing but\n# `overlay: true` redeclarations, and an overlay whose target was never declared is\n# ERR_OVERLAY_NO_TARGET.\n#\n# It carries exactly two kinds of child — `source.rdb` and `index.lookup` — and nothing\n# else. The field set, the identities and the relationships all live in model.yaml,\n# because they are the DESIGN; what lives here is where the rows go and which lookups are\n# worth an index. Add a field here and the core layer stops being the whole model, which\n# is the thing the split exists to guarantee.\n#\n# Physical names are `iam_`-prefixed. Two reasons, both real: `user` and `group` are\n# reserved words in Postgres, and an adopter very likely has tables of their own by those\n# names. A library that collides on a table name is a library nobody can adopt.\nmetadata:\n package: metaobjects::iam\n children:\n - object.entity:\n name: User\n overlay: true\n children:\n - source.rdb: { table: iam_user, role: primary }\n\n - object.entity:\n name: GroupType\n overlay: true\n children:\n - source.rdb: { table: iam_group_type, role: primary }\n\n - object.entity:\n name: Group\n overlay: true\n children:\n - source.rdb: { table: iam_group, role: primary }\n # Nesting is walked parent-ward constantly; the FK alone gives no index.\n - index.lookup: { name: ixParent, fields: [parentId] }\n\n - object.entity:\n name: Role\n overlay: true\n children:\n - source.rdb: { table: iam_role, role: primary }\n\n - object.entity:\n name: Permission\n overlay: true\n children:\n - source.rdb: { table: iam_permission, role: primary }\n\n - object.entity:\n name: GroupMember\n overlay: true\n children:\n - source.rdb: { table: iam_group_member, role: primary }\n # The composite PK covers (userId, groupId), so \"who is in this group?\" —\n # the other direction — has no index without this one. Same reasoning for\n # every ixSecond below.\n - index.lookup: { name: ixGroup, fields: [groupId] }\n\n - object.entity:\n name: RolePermission\n overlay: true\n children:\n - source.rdb: { table: iam_role_permission, role: primary }\n - index.lookup: { name: ixPermission, fields: [permissionId] }\n\n - object.entity:\n name: UserRole\n overlay: true\n children:\n - source.rdb: { table: iam_user_role, role: primary }\n - index.lookup: { name: ixRole, fields: [roleId] }\n\n - object.entity:\n name: GroupMemberRole\n overlay: true\n children:\n - source.rdb: { table: iam_group_member_role, role: primary }\n # \"who holds this role in this group?\" — the scoped-grant read.\n - index.lookup: { name: ixGroupRole, fields: [groupId, roleId] }\n",
|
|
13
|
+
"iam/model": "# library/iam/model.yaml — the CORE layer: identity and access management.\n#\n# Adopters opt in via `libraries: [\"iam\"]` in .metaobjects/config.json.\n#\n# This layer declares NO `source.rdb`, and that is the whole point of the split. A\n# sourceless object is inert by a contract that already ships: migrate skips an object\n# with no writable source, and codegen emits no route, queries, hooks, grid or form for\n# one (both citing #248 — persistability derives from source presence, never from the\n# object subtype). It still gets a type-only interface, so `extends` and reference work.\n#\n# So `libraries: [\"iam\"]` adds ZERO tables and ZERO generated code. What an adopter gains\n# is the design being present and resolvable: an agent working in the repo knows the\n# capability exists and can draw on it, and nothing else happens until the adopter adds\n# `\"iam/db\"`.\n#\n# Authoring discipline (FR-043 §3.1), so the departures are visible:\n# - `field.uuid` + `generation: uuid` on principals; composite ASSIGNED keys on\n# junctions. Never `increment` — a library cannot know the adopter's id strategy.\n# - Physical names carry the `iam_` prefix (in db.yaml): `user` and `group` are\n# reserved words in Postgres, and an adopter has tables of their own.\n# - No adopter-facing profile data. That arrives by `overlay: true`.\n# - No credentials. See requirements.yaml → `noCredentialsOnUser`.\nmetadata:\n package: metaobjects::iam\n children:\n - object.entity:\n name: IamBase\n abstract: true\n description: Shared shape of every iam principal and definition — a stable uuid plus change timestamps. Junctions do not extend it; they are addressed by their participants.\n children:\n - field.uuid: { name: id, required: true }\n - field.timestamp: { name: createdAt, autoSet: onCreate }\n - field.timestamp: { name: updatedAt, autoSet: onUpdate }\n\n - object.entity:\n name: User\n extends: IamBase\n description: A person or service account that can be granted access. Carries no authentication secret of any kind — see the noCredentialsOnUser requirement.\n children:\n - field.string: { name: username, required: true, maxLength: 64, filterable: true }\n - field.string: { name: email, required: true, maxLength: 254, stringFormat: email, filterable: true }\n - field.string: { name: displayName, maxLength: 120 }\n # NOT `filterable: true`, deliberately. The loader warns when a filterable\n # field is in no identity — filtering on it sequential-scans — and a library\n # must not ship a warning to every adopter. `username` and `email` carry it\n # because they have identity.secondary; `status` does not. An adopter who\n # wants to filter on status overlays `filterable` AND an index together,\n # which is exactly what the layer split is for.\n - field.enum: { name: status, required: true, values: [invited, active, suspended, closed], default: active }\n - field.timestamp: { name: emailVerifiedAt }\n - field.timestamp: { name: lastSeenAt }\n - identity.primary: { name: pk, fields: [id], generation: uuid }\n - identity.secondary: { name: uqUsername, fields: [username] }\n - identity.secondary: { name: uqEmail, fields: [email] }\n - relationship.association: { name: groups, objectRef: Group, cardinality: many, through: GroupMember }\n - relationship.association: { name: roles, objectRef: Role, cardinality: many, through: UserRole }\n\n - object.entity:\n name: GroupType\n extends: IamBase\n description: What KIND of group this is — a team, a tenant, a project. An entity rather than an enum, because \"which roles may be held in this kind of group\" is data an adopter extends, and an enum's values cannot be extended by overlay.\n children:\n - field.string: { name: key, required: true, maxLength: 64 }\n - field.string: { name: name, required: true, maxLength: 120 }\n - field.string: { name: description, maxLength: 500 }\n - identity.primary: { name: pk, fields: [id], generation: uuid }\n - identity.secondary: { name: uqKey, fields: [key] }\n\n - object.entity:\n name: Group\n extends: IamBase\n description: A nestable collection of users, of a declared GroupType. Nesting is by parentId; acyclicity is an invariant the schema cannot express — see the acyclicGroupNesting requirement.\n children:\n - field.uuid: { name: groupTypeId, required: true }\n - field.uuid: { name: parentId }\n - field.string: { name: key, required: true, maxLength: 64 }\n # Not filterable for the same reason as User.status above.\n - field.string: { name: name, required: true, maxLength: 120 }\n - field.string: { name: description, maxLength: 500 }\n - identity.primary: { name: pk, fields: [id], generation: uuid }\n - identity.secondary: { name: uqKey, fields: [key] }\n - identity.reference: { name: fkGroupType, fields: [groupTypeId], references: GroupType, onDelete: restrict }\n - identity.reference: { name: fkParent, fields: [parentId], references: Group, onDelete: restrict }\n\n - object.entity:\n name: Role\n extends: IamBase\n description: A reusable bundle of permissions. Code never compares a role NAME to a literal — it asks whether a user holds a permission, and the mapping is data.\n children:\n - field.string: { name: key, required: true, maxLength: 64 }\n - field.string: { name: name, required: true, maxLength: 120 }\n - field.string: { name: description, maxLength: 500 }\n - field.uuid: { name: groupTypeId, description: \"When set, this role may be held only within groups of this type; absent means grantable anywhere.\" }\n - identity.primary: { name: pk, fields: [id], generation: uuid }\n - identity.secondary: { name: uqKey, fields: [key] }\n - identity.reference: { name: fkGroupType, fields: [groupTypeId], references: GroupType, onDelete: restrict }\n - relationship.association: { name: permissions, objectRef: Permission, cardinality: many, through: RolePermission }\n\n - object.entity:\n name: Permission\n extends: IamBase\n description: \"The assignable unit — a stable <resource>:<action> key the application checks against. An entity, not an enum, on ADR-0037's own reasoning: it has its own identity, its own lifecycle, and a junction with real foreign keys.\"\n children:\n - field.string: { name: key, required: true, maxLength: 128, description: \"Stable <resource>:<action> key the application checks against.\" }\n - field.string: { name: description, maxLength: 500 }\n - identity.primary: { name: pk, fields: [id], generation: uuid }\n - identity.secondary: { name: uqKey, fields: [key] }\n\n # ---- grant surface: every grant is a row, addressed by its participants ----\n #\n # Junctions do NOT extend IamBase: they have no identity of their own, and adding a\n # surrogate uuid to a row whose identity IS its participants invites a duplicate.\n\n - object.entity:\n name: GroupMember\n description: A user's membership of a group.\n children:\n - field.uuid: { name: userId, required: true }\n - field.uuid: { name: groupId, required: true }\n - field.timestamp: { name: joinedAt, autoSet: onCreate }\n - identity.primary: { name: pk, fields: [userId, groupId], generation: assigned }\n - identity.reference: { name: fkUser, fields: [userId], references: User, onDelete: cascade }\n - identity.reference: { name: fkGroup, fields: [groupId], references: Group, onDelete: cascade }\n\n - object.entity:\n name: RolePermission\n description: A permission granted by a role.\n children:\n - field.uuid: { name: roleId, required: true }\n - field.uuid: { name: permissionId, required: true }\n - identity.primary: { name: pk, fields: [roleId, permissionId], generation: assigned }\n - identity.reference: { name: fkRole, fields: [roleId], references: Role, onDelete: cascade }\n - identity.reference: { name: fkPermission, fields: [permissionId], references: Permission, onDelete: restrict }\n\n - object.entity:\n name: UserRole\n description: A system-wide grant of a role to a user.\n children:\n - field.uuid: { name: userId, required: true }\n - field.uuid: { name: roleId, required: true }\n - field.timestamp: { name: grantedAt, autoSet: onCreate }\n - identity.primary: { name: pk, fields: [userId, roleId], generation: assigned }\n - identity.reference: { name: fkUser, fields: [userId], references: User, onDelete: cascade }\n - identity.reference: { name: fkRole, fields: [roleId], references: Role, onDelete: restrict }\n\n - object.entity:\n name: GroupMemberRole\n description: A grant of a role to a user WITHIN one group. Three foreign keys, so it is not an M:N @through junction (which must declare exactly two identity.reference children); it is read by explicit finders.\n children:\n - field.uuid: { name: userId, required: true }\n - field.uuid: { name: groupId, required: true }\n - field.uuid: { name: roleId, required: true }\n - field.timestamp: { name: grantedAt, autoSet: onCreate }\n - identity.primary: { name: pk, fields: [userId, groupId, roleId], generation: assigned }\n - identity.reference: { name: fkUser, fields: [userId], references: User, onDelete: cascade }\n - identity.reference: { name: fkGroup, fields: [groupId], references: Group, onDelete: cascade }\n - identity.reference: { name: fkRole, fields: [roleId], references: Role, onDelete: restrict }\n",
|
|
14
|
+
"iam/requirements": "# library/iam/requirements.yaml — what this library's design PROMISES.\n#\n# This is what makes iam a library rather than a schema snippet. Without requirements an\n# adopter gets nine tables; with them they get nine tables plus a build that is held to\n# \"no authorization decision is hard-wired to a name\", which no snippet can do.\n#\n# Two reading rules, both load-bearing:\n#\n# `live` here means \"the model AS SHIPPED realises this\" — never \"your application\n# does\". A ledger binds to model nodes; runtime guarantees are the runtime's tests, and\n# this library does not invent a way to point a requirement at code (@verifiedBy was\n# retired for exactly that). Behaviour the model cannot carry ships as `partial` +\n# `disposition: accepted` with a notes sentence naming what the adopter must do.\n#\n# The functional tree roots at L2, not L1. L1 is the adopter's SOLUTION, and a library\n# is by definition a segment of someone else's. Architectural claims ship flat.\n#\n# HIERARCHY IS NESTING, and the L4/L5 split is grain. The concerns are CHILDREN of the L2\n# rather than its siblings, and an L4 names the OBJECT it is about while the field that\n# carries it hangs off it as an L5 child. Written flat, the L2 claims nothing in its whole\n# subtree; written at L4, a field reference is ERR_REQUIREMENT_L4_NOT_OBJECT. Both shipped\n# here until the standalone verify gate existed (`cli/test/shipped-library-verify.test.ts`).\nmetadata:\n package: metaobjects::iam\n children:\n # ---- functional: the L2 segment and the concerns nested under it --------\n - requirement.functional:\n name: accessControl\n level: 2\n status: live\n statement: Who may do what is answered from stored grants, never from a name compared to a literal in code.\n counterexample: A branch that reads `if (user.role === \"admin\")`.\n description: The segment this library covers. The concerns beneath it are what it decomposes into.\n children:\n - requirement.functional:\n name: identity\n level: 4\n status: live\n statement: A person or service account is represented once, addressed by a uuid, and reachable by username or email.\n counterexample: Two rows for the same person because the email changed.\n implementedBy: [User]\n\n - requirement.functional:\n name: grouping\n level: 4\n status: live\n statement: Users are collected into typed, nestable groups, and the kind of group is data rather than a hard-coded set.\n counterexample: A `teamOrTenant` boolean.\n implementedBy: [Group, GroupType, GroupMember]\n\n - requirement.functional:\n name: acyclicGroupNesting\n level: 4\n status: partial\n disposition: accepted\n statement: A group is never its own ancestor.\n counterexample: Two groups each naming the other as parent.\n notes: >-\n The schema cannot express this — a self-referencing FK admits a cycle, and the\n only relational forms that would catch it (a recursive CHECK, a closure table\n maintained by trigger) are DB-specific and would not survive three dialects.\n The adopter enforces it where the write happens. Recorded rather than omitted\n so an agent reading the ledger before adding a parent-setting endpoint sees the\n obligation.\n implementedBy: [Group]\n\n - requirement.functional:\n name: grants\n level: 4\n status: live\n statement: A role is granted to a user either system-wide or scoped to one group, and both are ordinary rows.\n counterexample: A nullable `groupId` on one grant table, where NULL means \"everywhere\".\n description: >-\n Two junctions, not one with a nullable scope. A NULL in a unique key is DISTINCT\n from every other NULL in SQL, so a nullable-scope design lets the same global\n grant be inserted twice; the fix needs a partial index whose expression carries\n a physical column name. Two composite-keyed tables need no escape hatch and\n survive three dialects and five ports unchanged.\n implementedBy: [UserRole, GroupMemberRole]\n\n - requirement.functional:\n name: roleScopedToGroupType\n level: 4\n status: partial\n disposition: accepted\n statement: A role bound to a group type is granted only within groups of that type.\n counterexample: A \"tenant admin\" role granted inside a project group.\n notes: >-\n Expressing this relationally needs the grant row to carry the group's type and\n a composite FK back to (group, type) — three foreign keys deep, unverified\n across five ports' DDL and ORM paths. The adopter checks it at the point of\n grant. The declared half is the L5 child below; the enforcement is not.\n implementedBy: [Role, GroupMemberRole]\n children:\n - requirement.functional:\n name: roleDeclaresItsGroupType\n level: 5\n status: live\n statement: A role declares the group type it is bound to, as a nullable reference.\n counterexample: A role whose intended scope is recoverable only from its name.\n description: >-\n `live` where its parent is `partial`, and the split is grain as much as\n verdict: the DECLARATION is shipped and resolves against the field itself,\n so dropping the column fails the build — while the ENFORCEMENT, which no\n schema here can carry, stays the parent's accepted gap.\n implementedBy: [Role.groupTypeId]\n\n - requirement.functional:\n name: decision\n level: 4\n status: live\n statement: An authorization decision is the question \"does this user hold this permission key\", answered from rows.\n counterexample: A hard-coded list of usernames that bypass a check.\n implementedBy: [Permission, RolePermission]\n\n # ---- architectural: prohibitions in force --------------------------------\n\n - requirement.architectural:\n name: grantsAreRows\n status: live\n statement: A grant exists only as a stored row; nothing is granted by naming, position or convention.\n counterexample: A superuser recognised by username.\n implementedBy: [UserRole, GroupMemberRole, RolePermission, GroupMember]\n\n - requirement.architectural:\n name: noCredentialsOnUser\n status: live\n statement: A user row carries no authentication secret — no password, no hash, no knowledge-based question or answer.\n counterexample: A password or secret-answer column on the user table.\n description: >-\n Authentication is a separate capability with an entity per factor; this library\n is identity and authorization only.\n notes: >-\n This is the one thing every reader of a user table proposes adding, and a real\n legacy model of this shape stored a length-bounded plaintext password and a\n knowledge-based secret pair on the user row. Stating it as a prohibition IN\n FORCE — claimable, and rendered on agent/requirements.md — is what stops an\n agent extending \"the user model\" from re-deriving it on sight. It is\n `architectural`, not `retired`: retired is chartered for a capability built\n here and removed, and this library never built one.\n implementedBy: [User]\n\n - requirement.architectural:\n name: principalDeletionRevokesGrants\n status: live\n statement: Deleting a user or group removes its grants; deleting a role or permission still in use is refused.\n counterexample: A grant row pointing at a user who no longer exists.\n description: The referential rule in one sentence — cascade from a principal, restrict from a definition.\n implementedBy: [GroupMember, UserRole, GroupMemberRole, RolePermission]\n\n - requirement.architectural:\n name: stableIdentifiers\n status: live\n statement: Every principal and definition is addressed by a uuid that never changes; every grant by its participants.\n counterexample: A group referenced by its display name.\n implementedBy: [IamBase]\n",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Library NAME -> the exact text of its `library.json` manifest. */
|
|
18
|
+
export const EMBEDDED_LIBRARY_MANIFESTS: Record<string, string> = {
|
|
19
|
+
"ai": "{\n \"$comment\": \"Library manifest (FR-043 §4). Embedded beside the YAML in every port. Every fact here is RESOLVED by a test, never trusted: `packages` against the library loaded standalone, `layers[].refs` against the embedded set, `generators[].name` against the generator registry, `generators[].anchor` against the library's own nodes, and `name` against the last package segment.\",\n \"name\": \"ai\",\n \"kind\": \"feature\",\n \"stability\": \"stable\",\n \"since\": \"0.20.0\",\n \"description\": \"The LLM-call trace envelope: what was asked, what came back, what it cost, how long it took.\",\n \"useWhen\": \"the application calls a language model and someone will ask what it cost or why a call failed\",\n \"packages\": [\"metaobjects::ai\"],\n \"layers\": {\n \"\": { \"refs\": [\"ai/model\", \"ai/requirements\"], \"description\": \"the core model and its requirements — sourceless, so it adds no tables\" },\n \"db\": { \"refs\": [\"ai/db\"], \"description\": \"the concrete llm_call table\" }\n },\n \"generators\": [\n { \"name\": \"trace-helper\", \"anchor\": \"metaobjects::ai::LlmCallBase\" }\n ],\n \"runtime\": {\n \"typescript\": [\"@metaobjectsdev/runtime-ts\"]\n }\n}\n",
|
|
20
|
+
"iam": "{\n \"$comment\": \"Library manifest (FR-043 §4). Embedded beside the YAML in every port. Every fact here is RESOLVED by a test, never trusted: `packages` against the library loaded standalone, `layers[].refs` against the embedded set, `generators[].name` against the generator registry, `generators[].anchor` against the library's own nodes, and `name` against the last package segment.\",\n \"name\": \"iam\",\n \"kind\": \"feature\",\n \"stability\": \"preview\",\n \"since\": \"1.1.0\",\n \"description\": \"Users, nestable typed groups, roles as permission bundles, grants global or scoped to a group.\",\n \"useWhen\": \"the application has people who log in and things some of them may not do\",\n \"packages\": [\"metaobjects::iam\"],\n \"layers\": {\n \"\": { \"refs\": [\"iam/model\", \"iam/requirements\"], \"description\": \"the core model and its requirements — sourceless, so it adds no tables\" },\n \"db\": { \"refs\": [\"iam/db\"], \"description\": \"nine tables, iam_-prefixed, plus the lookup indexes the composite keys do not cover\" }\n },\n \"generators\": [],\n \"runtime\": {}\n}\n",
|
|
10
21
|
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @metaobjectsdev/metadata/library — the shipped-library surface (FR-043).
|
|
2
|
+
//
|
|
3
|
+
// The invariant is not "every subpath has an index" — `./constants` is a plain
|
|
4
|
+
// `src/constants.ts` and is fine — nor is it "no nesting", since the `paths` `*` matches
|
|
5
|
+
// across `/`. It is that the subpath NAME must mirror the layout under `src/`. A subpath
|
|
6
|
+
// named `library` substitutes to `src/library`, so the module has to be `src/library.ts`
|
|
7
|
+
// or `src/library/index.ts`; it was `src/library/library-sources.ts`, which the
|
|
8
|
+
// substitution never reaches. That was not cosmetic: the repo-root
|
|
9
|
+
// `tsconfig.scripts.json` maps `@metaobjectsdev/metadata/*` to
|
|
10
|
+
// `packages/metadata/src/*` so that `scripts/` typechecks against workspace
|
|
11
|
+
// SOURCE rather than a build output. With no `index.ts` here that mapping had
|
|
12
|
+
// nothing to land on, so resolution fell through to `node_modules` and found
|
|
13
|
+
// `dist/library/library-sources.d.ts` — which exists on a developer's machine
|
|
14
|
+
// and does NOT exist on a fresh CI checkout, where the `gates` lane runs
|
|
15
|
+
// `bun install` and never builds. The gate was green locally and red on the
|
|
16
|
+
// runner for exactly that reason.
|
|
17
|
+
//
|
|
18
|
+
// So this file is the subpath's entry, and `package.json` names it. Adding a
|
|
19
|
+
// module under `library/` means re-exporting it here.
|
|
20
|
+
//
|
|
21
|
+
// `cli/test/subpath-resolves-under-scripts-paths.test.ts` now gates the whole class,
|
|
22
|
+
// across every package the scripts typecheck maps.
|
|
23
|
+
export * from "./library-sources.js";
|
|
@@ -11,20 +11,57 @@ import { fileURLToPath } from "node:url";
|
|
|
11
11
|
import { FileSource } from "../loader/sources/file-source.js";
|
|
12
12
|
import { InMemoryStringSource } from "../loader/meta-data-source.js";
|
|
13
13
|
import type { MetaDataSource } from "../loader/meta-data-source.js";
|
|
14
|
-
import { EMBEDDED_LIBRARY } from "./embedded-library.generated.js";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
14
|
+
import { EMBEDDED_LIBRARY, EMBEDDED_LIBRARY_MANIFESTS } from "./embedded-library.generated.js";
|
|
15
|
+
|
|
16
|
+
/** One layer of a library, as its manifest declares it. */
|
|
17
|
+
export interface LibraryLayer {
|
|
18
|
+
/** Refs (path under `library/` minus `.yaml`) this layer contributes, in order. */
|
|
19
|
+
readonly refs: readonly string[];
|
|
20
|
+
readonly description?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A library's `library.json`, parsed. Only the fields this module reads are typed;
|
|
24
|
+
* the catalog reads the rest off the same text. */
|
|
25
|
+
export interface LibraryManifest {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly kind?: string;
|
|
28
|
+
readonly stability?: string;
|
|
29
|
+
readonly since?: string;
|
|
30
|
+
readonly description?: string;
|
|
31
|
+
readonly useWhen?: string;
|
|
32
|
+
readonly packages?: readonly string[];
|
|
33
|
+
/** Layer token → layer. The CORE layer's token is the empty string. */
|
|
34
|
+
readonly layers?: Readonly<Record<string, LibraryLayer>>;
|
|
35
|
+
readonly generators?: ReadonlyArray<{ readonly name: string; readonly anchor?: string }>;
|
|
36
|
+
readonly runtime?: Readonly<Record<string, readonly string[]>>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const MANIFESTS: Readonly<Record<string, LibraryManifest>> = (() => {
|
|
40
|
+
const out: Record<string, LibraryManifest> = {};
|
|
41
|
+
for (const [name, text] of Object.entries(EMBEDDED_LIBRARY_MANIFESTS)) {
|
|
42
|
+
out[name] = JSON.parse(text) as LibraryManifest;
|
|
24
43
|
}
|
|
25
|
-
return
|
|
44
|
+
return out;
|
|
26
45
|
})();
|
|
27
46
|
|
|
47
|
+
/** Every shipped library's parsed manifest, keyed by name. */
|
|
48
|
+
export function libraryManifests(): Readonly<Record<string, LibraryManifest>> {
|
|
49
|
+
return MANIFESTS;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Split a selection token into `[library, layer]` — `"iam"` → `["iam", ""]`,
|
|
54
|
+
* `"iam/db"` → `["iam", "db"]`.
|
|
55
|
+
*
|
|
56
|
+
* Path-like, so `libraries` stays `string[]` and no config schema moves. Only ONE
|
|
57
|
+
* separator is meaningful; anything after a second is part of the layer token, which
|
|
58
|
+
* keeps a typo failing loudly rather than resolving to a prefix.
|
|
59
|
+
*/
|
|
60
|
+
export function splitLayerToken(token: string): [string, string] {
|
|
61
|
+
const i = token.indexOf("/");
|
|
62
|
+
return i === -1 ? [token, ""] : [token.slice(0, i), token.slice(i + 1)];
|
|
63
|
+
}
|
|
64
|
+
|
|
28
65
|
/**
|
|
29
66
|
* Locate the repo-root `library/` directory by walking up from this module's
|
|
30
67
|
* location until a directory contains BOTH `library/` and `server/` (the two
|
|
@@ -62,50 +99,163 @@ function getLibraryDir(): string | undefined {
|
|
|
62
99
|
* available (Python's `project_config` draws the same line, in the same place).
|
|
63
100
|
*/
|
|
64
101
|
export function knownLibraryPackages(): string[] {
|
|
65
|
-
return Object.keys(
|
|
102
|
+
return Object.keys(MANIFESTS).sort();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Every package name a shipped library OWNS, across every library and layer.
|
|
107
|
+
*
|
|
108
|
+
* The provenance key for FR-043 §5.4 — object coverage activates on adopter-authored
|
|
109
|
+
* requirements only, and "adopter-authored" means "declared outside every library
|
|
110
|
+
* package". It reads the manifests rather than node source ids deliberately: `packages`
|
|
111
|
+
* is a manifest fact the standalone gate resolves against the library loaded alone,
|
|
112
|
+
* while a source id differs between the on-disk dev layout (an absolute path) and the
|
|
113
|
+
* embedded one (`library:<ref>.yaml`), so a rule keyed on that would hold here and stop
|
|
114
|
+
* holding in an installed build.
|
|
115
|
+
*/
|
|
116
|
+
/**
|
|
117
|
+
* The source id a library file loads under, in EVERY build — `library:iam/model.yaml`.
|
|
118
|
+
*
|
|
119
|
+
* Stable rather than path-derived so a library node's ADR-0009 provenance envelope reads
|
|
120
|
+
* the same from a checkout and from an installed package, carries no absolute path, and
|
|
121
|
+
* cannot be confused with an adopter file that happens to share a basename. The
|
|
122
|
+
* `library:` prefix is the discriminator {@link isLibraryFileId} reads.
|
|
123
|
+
*/
|
|
124
|
+
export function libraryFileId(ref: string): string {
|
|
125
|
+
return `${LIBRARY_FILE_ID_PREFIX}${ref}.yaml`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The prefix every library source id carries. */
|
|
129
|
+
export const LIBRARY_FILE_ID_PREFIX = "library:";
|
|
130
|
+
|
|
131
|
+
/** True when a source id names a file a shipped library contributed. */
|
|
132
|
+
export function isLibraryFileId(id: string): boolean {
|
|
133
|
+
return id.startsWith(LIBRARY_FILE_ID_PREFIX);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function libraryPackages(): ReadonlySet<string> {
|
|
137
|
+
const out = new Set<string>();
|
|
138
|
+
for (const manifest of Object.values(MANIFESTS)) {
|
|
139
|
+
for (const pkg of manifest.packages ?? []) out.add(pkg);
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Every selection token this build accepts, sorted — `["ai", "ai/db", "iam", "iam/db"]`.
|
|
146
|
+
*
|
|
147
|
+
* What a config error message should print, so an adopter who typed `iam/database` is
|
|
148
|
+
* shown the layer they meant rather than only the library they got right.
|
|
149
|
+
*/
|
|
150
|
+
export function knownLibraryTokens(): string[] {
|
|
151
|
+
const out: string[] = [];
|
|
152
|
+
for (const [name, manifest] of Object.entries(MANIFESTS)) {
|
|
153
|
+
for (const layer of Object.keys(manifest.layers ?? { "": { refs: [] } })) {
|
|
154
|
+
out.push(layer === "" ? name : `${name}/${layer}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out.sort();
|
|
66
158
|
}
|
|
67
159
|
|
|
68
160
|
/**
|
|
69
|
-
*
|
|
161
|
+
* `MetaDataSource` instances for the requested library selection.
|
|
162
|
+
*
|
|
163
|
+
* **Layer-granular.** A token is `<library>` or `<library>/<layer>`; the CORE layer is
|
|
164
|
+
* the bare name. This used to be package-granular — every ref under a library came back
|
|
165
|
+
* for a bare `"iam"` — which under the layered design would have handed an adopter the
|
|
166
|
+
* db and ui layers they did not ask for, and with them a migration proposing nine tables.
|
|
167
|
+
*
|
|
168
|
+
* **`"iam/db"` IMPLIES `"iam"`**, and the implication is not a convenience: a db layer is
|
|
169
|
+
* nothing but `overlay: true` redeclarations, and an overlay whose target was never
|
|
170
|
+
* declared is `ERR_OVERLAY_NO_TARGET`. Resolving the layer without its core would produce
|
|
171
|
+
* exactly that error, so implying it is the only coherent reading.
|
|
172
|
+
*
|
|
173
|
+
* Refs are de-duplicated and returned in a stable order — core first, then each requested
|
|
174
|
+
* layer in the manifest's own order — because an overlay must be parsed after its base
|
|
175
|
+
* even though ADR-0055 applies overlays in a deferred pass.
|
|
70
176
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
177
|
+
* An unrecognised token contributes nothing and is skipped silently: that is right for a
|
|
178
|
+
* programmatic caller asking for something a given version may not ship. A name a HUMAN
|
|
179
|
+
* typed is a different case and is refused by the config readers, which call
|
|
180
|
+
* {@link knownLibraryTokens} to say what is available.
|
|
74
181
|
*
|
|
75
|
-
* @param
|
|
182
|
+
* @param selection - Tokens, e.g. `["iam", "iam/db"]`.
|
|
76
183
|
*/
|
|
77
|
-
export function librarySources(
|
|
184
|
+
export function librarySources(selection: string[]): MetaDataSource[] {
|
|
185
|
+
const refs: string[] = [];
|
|
186
|
+
const seen = new Set<string>();
|
|
187
|
+
|
|
188
|
+
const add = (ref: string): void => {
|
|
189
|
+
if (seen.has(ref)) return;
|
|
190
|
+
seen.add(ref);
|
|
191
|
+
refs.push(ref);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Core layers first, across every requested library, so a db layer named before its
|
|
195
|
+
// core in the config still parses after it.
|
|
196
|
+
//
|
|
197
|
+
// A token whose LAYER is unknown is dropped whole, not reduced to its core. The core is
|
|
198
|
+
// implied by a VALID layer token; implying it from an invalid one would answer a
|
|
199
|
+
// mistyped `iam/database` with an inert core and no tables — "I asked for the db layer
|
|
200
|
+
// and got nothing" with no diagnostic, which is the worst of the available outcomes.
|
|
201
|
+
const wanted = selection
|
|
202
|
+
.map(splitLayerToken)
|
|
203
|
+
.filter(([lib, layer]) => lib in MANIFESTS && (MANIFESTS[lib]!.layers ?? {})[layer] !== undefined);
|
|
204
|
+
for (const [lib] of wanted) {
|
|
205
|
+
for (const ref of MANIFESTS[lib]!.layers?.[""]?.refs ?? []) add(ref);
|
|
206
|
+
}
|
|
207
|
+
for (const [lib, layer] of wanted) {
|
|
208
|
+
if (layer === "") continue;
|
|
209
|
+
for (const ref of MANIFESTS[lib]!.layers?.[layer]?.refs ?? []) add(ref);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return refs.map(libraryRefSource);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* One library ref as a source — on-disk first, embedded otherwise.
|
|
217
|
+
*
|
|
218
|
+
* Factored out of {@link librarySources} because `meta eject <library>` needs the TEXT
|
|
219
|
+
* of one ref and must resolve it exactly the way a load does: an adopter ejecting from a
|
|
220
|
+
* checkout must get the file they can see, and from an installed package the embedded
|
|
221
|
+
* copy, with no third rule to keep in step.
|
|
222
|
+
*/
|
|
223
|
+
export function libraryRefSource(ref: string): MetaDataSource {
|
|
78
224
|
const dir = getLibraryDir();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
out.push(new FileSource(path));
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
const embedded = EMBEDDED_LIBRARY[ref];
|
|
94
|
-
if (embedded !== undefined) {
|
|
95
|
-
out.push(
|
|
96
|
-
new InMemoryStringSource(embedded, {
|
|
97
|
-
id: `library:${ref}.yaml`,
|
|
98
|
-
format: "yaml",
|
|
99
|
-
}),
|
|
100
|
-
);
|
|
101
|
-
} else {
|
|
102
|
-
throw new Error(
|
|
103
|
-
`library ref "${ref}" (package "${pkg}") has no on-disk file and no embedded entry — ` +
|
|
104
|
-
`the embedded library module is stale; run scripts/generate-embedded-library.ts`,
|
|
105
|
-
);
|
|
106
|
-
}
|
|
225
|
+
if (dir !== undefined) {
|
|
226
|
+
const path = join(dir, `${ref}.yaml`);
|
|
227
|
+
if (existsSync(path)) {
|
|
228
|
+
// The SAME id the embedded branch below uses, deliberately. A `FileSource`
|
|
229
|
+
// defaults its id to the file's BASENAME, which would make a library node's error
|
|
230
|
+
// envelope read `model.yaml` in a checkout and `library:iam/model.yaml` in an
|
|
231
|
+
// installed build — and would collide outright with an adopter file of that name.
|
|
232
|
+
// One stable id makes the two builds report identically and gives anything asking
|
|
233
|
+
// "did a library declare this node" an unambiguous answer.
|
|
234
|
+
return new FileSource(path, { id: libraryFileId(ref) });
|
|
107
235
|
}
|
|
108
236
|
}
|
|
109
237
|
|
|
110
|
-
|
|
238
|
+
const embedded = EMBEDDED_LIBRARY[ref];
|
|
239
|
+
if (embedded === undefined) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`library ref "${ref}" has no on-disk file and no embedded entry — ` +
|
|
242
|
+
`the embedded library module is stale; run scripts/generate-embedded-library.ts`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return new InMemoryStringSource(embedded, { id: libraryFileId(ref), format: "yaml" });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Every ref one library contributes, core layer first — what `meta eject` copies. */
|
|
249
|
+
export function libraryRefs(name: string): string[] {
|
|
250
|
+
const layers = MANIFESTS[name]?.layers ?? {};
|
|
251
|
+
const refs: string[] = [];
|
|
252
|
+
const seen = new Set<string>();
|
|
253
|
+
for (const token of ["", ...Object.keys(layers).filter((k) => k !== "")]) {
|
|
254
|
+
for (const ref of layers[token]?.refs ?? []) {
|
|
255
|
+
if (seen.has(ref)) continue;
|
|
256
|
+
seen.add(ref);
|
|
257
|
+
refs.push(ref);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return refs;
|
|
111
261
|
}
|