@dforge-core/metadata 0.0.25 → 0.0.27

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.
@@ -0,0 +1,103 @@
1
+ // Card layouts — authoring shape of `ui/card_layouts.json`.
2
+ // Mirror of card_layouts.schema.json, and of the runtime CardLayout in
3
+ // @dforge/data's layouts.ts — the installer stores the body verbatim, so the
4
+ // two must not drift.
5
+
6
+ /** A field in a card section — either a bare column code or pixel sizing with it. */
7
+ export type CardFieldEntry = string | CardFieldDef;
8
+
9
+ export interface CardFieldDef {
10
+ column_cd: string;
11
+ /** Field width in px. */
12
+ width?: number;
13
+ /** Field height in px — for multiline/textarea fields. */
14
+ height?: number;
15
+ }
16
+
17
+ /** A group of fields laid out in 1, 2 or 3 columns. */
18
+ export interface ColumnGroupSection {
19
+ type: "columnGroup";
20
+ code: string;
21
+ /** Section heading, in the module's authoring language. The installer gives it an
22
+ * `entity_column_group` row keyed on this section's `code`, so
23
+ * `translations/<locale>.json` localizes it under
24
+ * `entities.<entity>.columnGroups.<code>.label` and the card renders that instead. */
25
+ label?: string;
26
+ columns: CardFieldEntry[];
27
+ /** Field columns across the group (1-3). Defaults to the card's own setting. */
28
+ cols?: number;
29
+ }
30
+
31
+ /**
32
+ * An embedded detail grid for a 1:N set column — the master/detail building
33
+ * block. The label comes from the set column's own metadata.
34
+ */
35
+ export interface SetSection {
36
+ type: "set";
37
+ code: string;
38
+ /** Set column code on this entity (columnType 'S'). */
39
+ setField: string;
40
+ }
41
+
42
+ /** Reference to a tab item: a columnGroup section's code, or a set column's code. */
43
+ export interface CardTabRef {
44
+ type: "section" | "set";
45
+ code: string;
46
+ }
47
+
48
+ /** A tab bar whose tabs are other sections — several detail grids without one long form. */
49
+ export interface TabGroupSection {
50
+ type: "tabGroup";
51
+ code: string;
52
+ tabs: CardTabRef[];
53
+ }
54
+
55
+ export type CardSection = ColumnGroupSection | SetSection | TabGroupSection;
56
+
57
+ /** Per-set-field renderer choice, keyed by the set column code. */
58
+ export interface CardSetConfig {
59
+ viewType?: "grid" | "list";
60
+ /** Opaque per-renderer options, passed verbatim to the set registration. */
61
+ options?: Record<string, unknown>;
62
+ }
63
+
64
+ /** The layout body, stored verbatim into `dForge.entity_view_layout.layout`. */
65
+ export interface CardLayout {
66
+ sections: CardSection[];
67
+ sets?: Record<string, CardSetConfig>;
68
+ }
69
+
70
+ /**
71
+ * A module-shipped card layout (value in the card_layouts map; the key is the
72
+ * layout name).
73
+ *
74
+ * Rows written from here carry `module_id`, so the installer recreates them on
75
+ * every install and removes them on uninstall. A layout the tenant drew in the
76
+ * card editor has `module_id` NULL and is never touched.
77
+ */
78
+ export interface CardLayoutDef {
79
+ /**
80
+ * Entity code the layout belongs to. Unqualified means this module owns the
81
+ * entity; `module.entity` targets another module's (the bridge case).
82
+ */
83
+ entity: string;
84
+ /**
85
+ * Entity view the layout hangs off. Defaults to `"default"` — the same
86
+ * fallback the runtime uses for an entity with no folder binding of its own.
87
+ * Matched against the entity's declared views case-insensitively, and filed
88
+ * under their spelling.
89
+ */
90
+ view?: string;
91
+ /**
92
+ * Whether this layout opens by default. Honoured on FIRST install only, and
93
+ * only when the view has no default yet: once a tenant has chosen their own,
94
+ * an upgrade does not take it back.
95
+ */
96
+ isDefault?: boolean;
97
+ /** Human-readable note for the package author. Not stored. */
98
+ description?: string;
99
+ layout: CardLayout;
100
+ }
101
+
102
+ /** `ui/card_layouts.json` — layout name → definition. */
103
+ export type CardLayoutsFile = Record<string, CardLayoutDef>;
package/src/data-views.ts CHANGED
@@ -17,7 +17,6 @@ export type ViewType =
17
17
  | "gallery"
18
18
  | "tree-grid"
19
19
  | "diagram"
20
- | "master-detail"
21
20
  | "library"
22
21
  | "matrix";
23
22
 
@@ -30,7 +29,6 @@ export const dataViewKinds: readonly NamedKind[] = [
30
29
  { cd: "gallery", name: "Gallery" },
31
30
  { cd: "tree-grid", name: "Tree Grid" },
32
31
  { cd: "diagram", name: "Diagram" },
33
- { cd: "master-detail", name: "Master / Detail" },
34
32
  { cd: "library", name: "Library" },
35
33
  { cd: "matrix", name: "Matrix" },
36
34
  ] as const;
@@ -55,7 +53,7 @@ export interface DataSource {
55
53
  entityCode: string;
56
54
  /** Nesting level (0 = root, 1+ = detail). */
57
55
  level?: number;
58
- /** Human-readable label (used in master-detail tabs). */
56
+ /** Human-readable label for this data source. */
59
57
  label?: string;
60
58
  /** Column configuration. */
61
59
  columns?: ViewColumn[];
@@ -121,7 +119,7 @@ export interface DataViewDef {
121
119
  /** Bootstrap icon class (e.g. 'bi-bounding-box'). */
122
120
  icon?: string;
123
121
  description?: string;
124
- /** Data sources (at least one; master-detail needs two). */
122
+ /** Data sources (at least one). Only the first is rendered today. */
125
123
  dataSources: DataSource[];
126
124
  /** Default filter applied to all sources unless one declares its own. */
127
125
  filter?: Filter;
package/src/entity.ts CHANGED
@@ -130,8 +130,8 @@ export interface AccumulationConfig {
130
130
  sign?: SignConfig;
131
131
  autoCreateBalance?: boolean;
132
132
  allowNegative?: boolean;
133
- /** Document fields locked once posted. */
134
- lockedFields?: string[];
133
+ /** Document fields locked once posted. Required; `["*"]` freezes the whole row. */
134
+ lockedFields: string[];
135
135
  }
136
136
 
137
137
  /** One registry target within an L-column's `registries` array. */
@@ -167,6 +167,8 @@ export interface EntityDef {
167
167
  viewSql?: string;
168
168
  /** Display pattern using column placeholders, e.g. "{first_name} {last_name}". */
169
169
  toString?: string;
170
+ /** Records of this entity accept comments (composer + thread on the card). */
171
+ comments?: boolean;
170
172
  /** Traits expanded at install time. */
171
173
  traits?: TraitCd[];
172
174
  /** Column definitions keyed by column code. Optional when a trait (e.g. `period`) supplies all columns. */
package/src/index.ts CHANGED
@@ -36,7 +36,8 @@ export { AggType, AGG_TYPE_LIST } from "./aggregation";
36
36
  export { fieldTypes, fieldTypeCds, isFieldTypeCd, getFieldType, fieldTypesByColumnType } from "./field-types";
37
37
  export { baseDatatypes, baseToDbDatatype } from "./base-datatypes";
38
38
  export { columnTypes, getColumnType } from "./column-types";
39
- export { traits, getTrait, expandTrait, expandTraits } from "./traits";
39
+ export { traits, getTrait, expandTrait, expandTraits, traitFieldConflicts } from "./traits";
40
+ export type { TraitFieldConflict } from "./traits";
40
41
  export {
41
42
  deriveDbDatatype,
42
43
  deriveBaseDatatype,
@@ -116,6 +117,19 @@ export type { JobsFile, JobDef } from "./jobs";
116
117
  export type { TriggersFile, TriggerDef, EntityEvent } from "./triggers";
117
118
  export type { WebhooksFile, WebhookSubscription, WebhookPayload } from "./webhooks";
118
119
  export type { PrintTemplatesFile, PrintTemplateDef, PrintPageSettings, PrintMargins } from "./print-templates";
120
+ export type {
121
+ CardLayoutsFile,
122
+ CardLayoutDef,
123
+ CardLayout,
124
+ CardSection,
125
+ CardSetConfig,
126
+ CardTabRef,
127
+ CardFieldEntry,
128
+ CardFieldDef,
129
+ ColumnGroupSection,
130
+ SetSection,
131
+ TabGroupSection,
132
+ } from "./card-layouts";
119
133
  export type { SeedDataFile } from "./seed-data";
120
134
  export type { ActionsFile, ActionDef, ActionExecutionMode } from "./actions";
121
135
 
package/src/manifest.ts CHANGED
@@ -44,6 +44,8 @@ export interface ManifestDef {
44
44
  dependencies?: Record<string, ModuleDependency>;
45
45
  /** Default audit history mode for this module's entities. */
46
46
  auditHistory?: "none" | "minimal" | "full";
47
+ /** Default for `comments` on this module's entities; each entity may override. */
48
+ comments?: boolean;
47
49
  /** Entity code → relative path of its JSON file (dotted keys = extensions). */
48
50
  entities?: Record<string, string>;
49
51
  /** Module category for display (e.g. 'Integration', 'Finance'). */
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
  }