@dforge-core/metadata 0.0.24 → 0.0.26
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/CHANGELOG.md +55 -0
- package/dist/dsl/index.js +17 -7
- package/dist/dsl/index.js.map +1 -1
- package/dist/index.d.ts +42 -2
- package/dist/index.js +54 -19
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/dsl/check.ts +28 -7
- package/src/index.ts +2 -1
- package/src/traits.ts +131 -20
package/src/traits.ts
CHANGED
|
@@ -91,22 +91,93 @@ function applyTemplate(value: string, entity: string): string {
|
|
|
91
91
|
return value.replace(/\{entity\}/g, local).replace(/\{Entity\}/g, titleCaseEntity(entity));
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
function
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
94
|
+
function templateField(def: FieldDef, entity: string): FieldDef {
|
|
95
|
+
const next: FieldDef = { ...def };
|
|
96
|
+
if (typeof next.description === "string") next.description = applyTemplate(next.description, entity);
|
|
97
|
+
return next;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The merged field map. Prototype-less: a field code is author-supplied, so on a
|
|
102
|
+
* plain object one named `constructor` would read back as a field already there
|
|
103
|
+
* and one named `__proto__` would not write at all.
|
|
104
|
+
*/
|
|
105
|
+
function emptyFields(): Record<string, FieldDef> {
|
|
106
|
+
return Object.create(null) as Record<string, FieldDef>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The identity the installer compares on a key collision — `TryAddField` reads
|
|
111
|
+
* `ColumnType ?? DbDatatype ?? FieldTypeCd` and treats two definitions as the
|
|
112
|
+
* same field only when these match.
|
|
113
|
+
*/
|
|
114
|
+
function typeKeyOf(def: FieldDef): string | undefined {
|
|
115
|
+
return def.columnType ?? def.dbDatatype ?? def.fieldTypeCd;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Where a field already in the merge came from, for conflict wording. */
|
|
119
|
+
type FieldOrigin = "field" | "trait";
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A trait contributing a field the entity already has under a DIFFERENT type.
|
|
123
|
+
* The installer throws on this (`Field 'x' conflicts with trait: ...`), so a
|
|
124
|
+
* module that hits one cannot install — offline readers report it instead.
|
|
125
|
+
*/
|
|
126
|
+
export interface TraitFieldConflict {
|
|
127
|
+
/** Column code, after `{entity}` / `{Entity}` resolution. */
|
|
128
|
+
field: string;
|
|
129
|
+
/** Whether the definition being kept is an authored field or a trait's. */
|
|
130
|
+
existingFrom: FieldOrigin;
|
|
131
|
+
/** Type identity of the definition being kept. */
|
|
132
|
+
existingType?: string;
|
|
133
|
+
/** Type identity of the trait definition being dropped. */
|
|
134
|
+
traitType?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface MergeSink {
|
|
138
|
+
fields: Record<string, FieldDef>;
|
|
139
|
+
origin: Map<string, FieldOrigin>;
|
|
140
|
+
conflicts?: TraitFieldConflict[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Merge one trait-contributed field in, the way the installer does: the FIRST
|
|
145
|
+
* definition wins, and a later one of a different type is a conflict rather
|
|
146
|
+
* than an overwrite. See `TraitExpander.TryAddField`.
|
|
147
|
+
*/
|
|
148
|
+
function addTraitField(sink: MergeSink, key: string, def: FieldDef): void {
|
|
149
|
+
const existing = sink.fields[key];
|
|
150
|
+
if (!Object.hasOwn(sink.fields, key)) {
|
|
151
|
+
sink.fields[key] = def;
|
|
152
|
+
sink.origin.set(key, "trait");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (sink.conflicts && !sink.conflicts.some((c) => c.field === key)) {
|
|
156
|
+
const existingType = typeKeyOf(existing);
|
|
157
|
+
const traitType = typeKeyOf(def);
|
|
158
|
+
if (existingType !== traitType) {
|
|
159
|
+
sink.conflicts.push({
|
|
160
|
+
field: key,
|
|
161
|
+
existingFrom: sink.origin.get(key) ?? "trait",
|
|
162
|
+
existingType,
|
|
163
|
+
traitType,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
101
166
|
}
|
|
102
|
-
|
|
167
|
+
// Same type (or a conflict already recorded) — keep the first definition,
|
|
168
|
+
// which is what the installer does.
|
|
103
169
|
}
|
|
104
170
|
|
|
105
171
|
/**
|
|
106
172
|
* Expand a trait (and any traits it includes) into the concrete fields it adds
|
|
107
173
|
* for `entityName`, with `{entity}` / `{Entity}` tokens resolved. Returns an
|
|
108
174
|
* empty object for unknown or marker traits. Field order follows include-depth
|
|
109
|
-
* first, then declaration order
|
|
175
|
+
* first, then declaration order.
|
|
176
|
+
*
|
|
177
|
+
* The FIRST definition of a key wins, mirroring the installer's
|
|
178
|
+
* `TraitExpander.TryAddField`. A later definition of the same key under a
|
|
179
|
+
* different type is one the installer refuses to expand at all — see
|
|
180
|
+
* {@link traitFieldConflicts}, which reports those without throwing.
|
|
110
181
|
*
|
|
111
182
|
* `localTraits` is a module's own `traits.json`. A module may declare traits of
|
|
112
183
|
* its own, and an entity in it names them exactly like a platform trait — so a
|
|
@@ -120,7 +191,9 @@ export function expandTrait(
|
|
|
120
191
|
entityName: string,
|
|
121
192
|
localTraits?: TraitsFile,
|
|
122
193
|
): Record<string, FieldDef> {
|
|
123
|
-
|
|
194
|
+
const sink: MergeSink = { fields: emptyFields(), origin: new Map() };
|
|
195
|
+
expandTraitInner(traitCd, entityName, localTraits, new Set(), sink);
|
|
196
|
+
return sink.fields;
|
|
124
197
|
}
|
|
125
198
|
|
|
126
199
|
function expandTraitInner(
|
|
@@ -128,7 +201,8 @@ function expandTraitInner(
|
|
|
128
201
|
entityName: string,
|
|
129
202
|
localTraits: TraitsFile | undefined,
|
|
130
203
|
path: Set<string>,
|
|
131
|
-
|
|
204
|
+
sink: MergeSink,
|
|
205
|
+
): void {
|
|
132
206
|
// `includes` is author-supplied once local traits are in play, so a cycle is
|
|
133
207
|
// reachable from a module file. Unguarded that is an editor hang, not a bad
|
|
134
208
|
// expansion.
|
|
@@ -137,20 +211,27 @@ function expandTraitInner(
|
|
|
137
211
|
// diamond — two includes that both reach a third — the third has to expand
|
|
138
212
|
// under each of them, or which definition wins a key collision would depend
|
|
139
213
|
// on which branch got there first.
|
|
140
|
-
if (path.has(traitCd)) return
|
|
214
|
+
if (path.has(traitCd)) return;
|
|
141
215
|
path.add(traitCd);
|
|
142
216
|
|
|
143
|
-
|
|
144
|
-
|
|
217
|
+
// Own-property lookup: `localTraits` is parsed JSON, so a code like
|
|
218
|
+
// `constructor` would otherwise resolve to something off Object.prototype.
|
|
219
|
+
const trait =
|
|
220
|
+
localTraits && Object.hasOwn(localTraits, traitCd) ? localTraits[traitCd] : byCd.get(traitCd);
|
|
145
221
|
if (trait) {
|
|
146
222
|
for (const included of trait.includes ?? []) {
|
|
147
|
-
|
|
223
|
+
expandTraitInner(included, entityName, localTraits, path, sink);
|
|
224
|
+
}
|
|
225
|
+
// Each field is templated and merged on its own: two keys in one trait can
|
|
226
|
+
// resolve to the same code for a given entity (`{entity}_id` and `x_id`
|
|
227
|
+
// for entity `x`), and the installer, which resolves and adds one key at a
|
|
228
|
+
// time, sees that collision as a conflict.
|
|
229
|
+
for (const [name, def] of Object.entries(trait.fields ?? {})) {
|
|
230
|
+
addTraitField(sink, applyTemplate(name, entityName), templateField(def, entityName));
|
|
148
231
|
}
|
|
149
|
-
Object.assign(result, templateFields(trait.fields ?? {}, entityName));
|
|
150
232
|
}
|
|
151
233
|
|
|
152
234
|
path.delete(traitCd);
|
|
153
|
-
return result;
|
|
154
235
|
}
|
|
155
236
|
|
|
156
237
|
/** Expand several traits in order into one merged field map. */
|
|
@@ -159,9 +240,39 @@ export function expandTraits(
|
|
|
159
240
|
entityName: string,
|
|
160
241
|
localTraits?: TraitsFile,
|
|
161
242
|
): Record<string, FieldDef> {
|
|
162
|
-
const
|
|
243
|
+
const sink: MergeSink = { fields: emptyFields(), origin: new Map() };
|
|
244
|
+
for (const cd of traitCds) {
|
|
245
|
+
expandTraitInner(cd, entityName, localTraits, new Set(), sink);
|
|
246
|
+
}
|
|
247
|
+
return sink.fields;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The collisions that make the installer refuse an entity: a trait contributing
|
|
252
|
+
* a field the entity already has under a different type identity.
|
|
253
|
+
*
|
|
254
|
+
* `fields` is the entity's own authored fields. The installer seeds its merge
|
|
255
|
+
* with them and expands traits into it (`TraitExpander.ExpandAll`), so an
|
|
256
|
+
* authored field can conflict with a trait's exactly as two traits can — and
|
|
257
|
+
* either way install fails with `Field 'x' conflicts with trait`. Omit it to
|
|
258
|
+
* check the traits against each other alone.
|
|
259
|
+
*
|
|
260
|
+
* Returns one entry per field, in the order the conflicts are reached. An empty
|
|
261
|
+
* array means the expansion the installer performs is the one
|
|
262
|
+
* {@link expandTraits} returns.
|
|
263
|
+
*/
|
|
264
|
+
export function traitFieldConflicts(
|
|
265
|
+
traitCds: readonly string[],
|
|
266
|
+
entityName: string,
|
|
267
|
+
opts: { localTraits?: TraitsFile; fields?: Record<string, FieldDef> } = {},
|
|
268
|
+
): TraitFieldConflict[] {
|
|
269
|
+
const sink: MergeSink = { fields: emptyFields(), origin: new Map(), conflicts: [] };
|
|
270
|
+
for (const [key, def] of Object.entries(opts.fields ?? {})) {
|
|
271
|
+
sink.fields[key] = def;
|
|
272
|
+
sink.origin.set(key, "field");
|
|
273
|
+
}
|
|
163
274
|
for (const cd of traitCds) {
|
|
164
|
-
|
|
275
|
+
expandTraitInner(cd, entityName, opts.localTraits, new Set(), sink);
|
|
165
276
|
}
|
|
166
|
-
return
|
|
277
|
+
return sink.conflicts!;
|
|
167
278
|
}
|