@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/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 templateFields(fields: Record<string, FieldDef>, entity: string): Record<string, FieldDef> {
95
- const out: Record<string, FieldDef> = {};
96
- for (const [name, def] of Object.entries(fields)) {
97
- const key = applyTemplate(name, entity);
98
- const next: FieldDef = { ...def };
99
- if (typeof next.description === "string") next.description = applyTemplate(next.description, entity);
100
- out[key] = next;
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
- return out;
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; later definitions win on key collisions.
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
- return expandTraitInner(traitCd, entityName, localTraits, new Set());
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
- ): Record<string, FieldDef> {
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
- const trait = localTraits?.[traitCd] ?? byCd.get(traitCd);
144
- const result: Record<string, FieldDef> = {};
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
- Object.assign(result, expandTraitInner(included, entityName, localTraits, path));
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 result: Record<string, FieldDef> = {};
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
- Object.assign(result, expandTraitInner(cd, entityName, localTraits, new Set()));
275
+ expandTraitInner(cd, entityName, opts.localTraits, new Set(), sink);
165
276
  }
166
- return result;
277
+ return sink.conflicts!;
167
278
  }