@stonecrop/stonecrop 0.10.16 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -29
- package/dist/composable.js +1 -0
- package/dist/composables/lazy-link.js +125 -0
- package/dist/composables/stonecrop.js +123 -68
- package/dist/composables/use-lazy-link-state.js +125 -0
- package/dist/composables/use-stonecrop.js +476 -0
- package/dist/doctype.js +10 -2
- package/dist/field-triggers.js +15 -3
- package/dist/index.js +4 -3
- package/dist/operation-log-DB-dGNT9.js +593 -0
- package/dist/operation-log-DB-dGNT9.js.map +1 -0
- package/dist/registry.js +261 -101
- package/dist/schema-validator.js +105 -1
- package/dist/src/composable.d.ts +11 -0
- package/dist/src/composable.d.ts.map +1 -0
- package/dist/src/composable.js +477 -0
- package/dist/src/composables/lazy-link.d.ts +25 -0
- package/dist/src/composables/lazy-link.d.ts.map +1 -0
- package/dist/src/composables/operation-log.d.ts +5 -5
- package/dist/src/composables/operation-log.d.ts.map +1 -1
- package/dist/src/composables/operation-log.js +224 -0
- package/dist/src/composables/stonecrop.d.ts +11 -1
- package/dist/src/composables/stonecrop.d.ts.map +1 -1
- package/dist/src/composables/stonecrop.js +574 -0
- package/dist/src/composables/use-lazy-link-state.d.ts +25 -0
- package/dist/src/composables/use-lazy-link-state.d.ts.map +1 -0
- package/dist/src/composables/use-stonecrop.d.ts +93 -0
- package/dist/src/composables/use-stonecrop.d.ts.map +1 -0
- package/dist/src/composables/useNestedSchema.d.ts +110 -0
- package/dist/src/composables/useNestedSchema.d.ts.map +1 -0
- package/dist/src/composables/useNestedSchema.js +155 -0
- package/dist/src/doctype.d.ts +9 -1
- package/dist/src/doctype.d.ts.map +1 -1
- package/dist/src/doctype.js +234 -0
- package/dist/src/exceptions.js +16 -0
- package/dist/src/field-triggers.d.ts +6 -0
- package/dist/src/field-triggers.d.ts.map +1 -1
- package/dist/src/field-triggers.js +567 -0
- package/dist/src/index.d.ts +3 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +23 -0
- package/dist/src/plugins/index.js +96 -0
- package/dist/src/registry.d.ts +102 -23
- package/dist/src/registry.d.ts.map +1 -1
- package/dist/src/registry.js +246 -0
- package/dist/src/schema-validator.d.ts +8 -1
- package/dist/src/schema-validator.d.ts.map +1 -1
- package/dist/src/schema-validator.js +315 -0
- package/dist/src/stonecrop.d.ts +73 -28
- package/dist/src/stonecrop.d.ts.map +1 -1
- package/dist/src/stonecrop.js +339 -0
- package/dist/src/stores/data.d.ts +11 -0
- package/dist/src/stores/data.d.ts.map +1 -0
- package/dist/src/stores/hst.d.ts +5 -75
- package/dist/src/stores/hst.d.ts.map +1 -1
- package/dist/src/stores/hst.js +495 -0
- package/dist/src/stores/index.js +12 -0
- package/dist/src/stores/operation-log.d.ts +14 -14
- package/dist/src/stores/operation-log.d.ts.map +1 -1
- package/dist/src/stores/operation-log.js +568 -0
- package/dist/src/stores/xstate.d.ts +31 -0
- package/dist/src/stores/xstate.d.ts.map +1 -0
- package/dist/src/tsdoc-metadata.json +11 -0
- package/dist/src/types/composable.d.ts +50 -12
- package/dist/src/types/composable.d.ts.map +1 -1
- package/dist/src/types/doctype.d.ts +6 -7
- package/dist/src/types/doctype.d.ts.map +1 -1
- package/dist/src/types/field-triggers.d.ts +1 -1
- package/dist/src/types/field-triggers.d.ts.map +1 -1
- package/dist/src/types/field-triggers.js +4 -0
- package/dist/src/types/hst.d.ts +70 -0
- package/dist/src/types/hst.d.ts.map +1 -0
- package/dist/src/types/index.d.ts +1 -0
- package/dist/src/types/index.d.ts.map +1 -1
- package/dist/src/types/index.js +4 -0
- package/dist/src/types/operation-log.d.ts +4 -4
- package/dist/src/types/operation-log.d.ts.map +1 -1
- package/dist/src/types/operation-log.js +0 -0
- package/dist/src/types/registry.js +0 -0
- package/dist/src/types/schema-validator.d.ts +2 -0
- package/dist/src/types/schema-validator.d.ts.map +1 -1
- package/dist/src/utils.d.ts +24 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/stonecrop.d.ts +317 -99
- package/dist/stonecrop.js +2191 -1897
- package/dist/stonecrop.js.map +1 -1
- package/dist/stonecrop.umd.cjs +6 -0
- package/dist/stonecrop.umd.cjs.map +1 -0
- package/dist/stores/data.js +7 -0
- package/dist/stores/hst.js +27 -25
- package/dist/stores/operation-log.js +59 -47
- package/dist/stores/xstate.js +29 -0
- package/dist/tests/setup.d.ts +5 -0
- package/dist/tests/setup.d.ts.map +1 -0
- package/dist/tests/setup.js +15 -0
- package/dist/types/hst.js +0 -0
- package/dist/types/index.js +1 -0
- package/dist/utils.js +46 -0
- package/package.json +5 -5
- package/src/composables/lazy-link.ts +146 -0
- package/src/composables/operation-log.ts +1 -1
- package/src/composables/stonecrop.ts +142 -73
- package/src/doctype.ts +13 -4
- package/src/field-triggers.ts +18 -4
- package/src/index.ts +4 -2
- package/src/registry.ts +289 -111
- package/src/schema-validator.ts +120 -1
- package/src/stonecrop.ts +230 -106
- package/src/stores/hst.ts +29 -104
- package/src/stores/operation-log.ts +64 -50
- package/src/types/composable.ts +55 -12
- package/src/types/doctype.ts +6 -7
- package/src/types/field-triggers.ts +1 -1
- package/src/types/hst.ts +77 -0
- package/src/types/index.ts +1 -0
- package/src/types/operation-log.ts +4 -4
- package/src/types/schema-validator.ts +2 -0
package/dist/registry.js
CHANGED
|
@@ -13,12 +13,30 @@ export default class Registry {
|
|
|
13
13
|
*
|
|
14
14
|
* @defaultValue 'Registry'
|
|
15
15
|
*/
|
|
16
|
-
name;
|
|
16
|
+
name = 'Registry';
|
|
17
17
|
/**
|
|
18
18
|
* The registry property contains a collection of doctypes
|
|
19
|
+
*
|
|
20
|
+
* @defaultValue `{}`
|
|
19
21
|
* @see {@link Doctype}
|
|
20
22
|
*/
|
|
21
|
-
registry;
|
|
23
|
+
registry = {};
|
|
24
|
+
/**
|
|
25
|
+
* Reverse index: backlink fieldname → list of \{ doctype slug, link fieldname \}.
|
|
26
|
+
* Multiple doctypes can declare a link with the same backlink name, so each key
|
|
27
|
+
* maps to an array. Built at schema load time for O(1) ancestor lookups.
|
|
28
|
+
*
|
|
29
|
+
* @defaultValue `new Map()`
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
_ancestorIndex = new Map();
|
|
33
|
+
/**
|
|
34
|
+
* Whether the ancestor index needs rebuilding
|
|
35
|
+
*
|
|
36
|
+
* @defaultValue `true`
|
|
37
|
+
* @internal
|
|
38
|
+
*/
|
|
39
|
+
_ancestorIndexDirty = true;
|
|
22
40
|
/**
|
|
23
41
|
* The Vue router instance
|
|
24
42
|
* @see {@link https://router.vuejs.org/}
|
|
@@ -34,8 +52,6 @@ export default class Registry {
|
|
|
34
52
|
return Registry._root;
|
|
35
53
|
}
|
|
36
54
|
Registry._root = this;
|
|
37
|
-
this.name = 'Registry';
|
|
38
|
-
this.registry = {};
|
|
39
55
|
this.router = router;
|
|
40
56
|
this.getMeta = getMeta;
|
|
41
57
|
}
|
|
@@ -53,6 +69,7 @@ export default class Registry {
|
|
|
53
69
|
addDoctype(doctype) {
|
|
54
70
|
if (!(doctype.slug in this.registry)) {
|
|
55
71
|
this.registry[doctype.slug] = doctype;
|
|
72
|
+
this._ancestorIndexDirty = true;
|
|
56
73
|
}
|
|
57
74
|
// Register actions (including field triggers) with the field trigger engine
|
|
58
75
|
const triggerEngine = getGlobalTriggerEngine();
|
|
@@ -72,95 +89,120 @@ export default class Registry {
|
|
|
72
89
|
/**
|
|
73
90
|
* Resolve nested Doctype fields in a schema by embedding child schemas inline.
|
|
74
91
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
92
|
+
* Accepts a Doctype and extracts `fields` and `links` internally.
|
|
93
|
+
* Fields array contains both scalar fields and link fields (with fieldtype: 'Link').
|
|
94
|
+
* Render order is determined by the order of fields in the fields array.
|
|
78
95
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* -
|
|
82
|
-
* `
|
|
96
|
+
* For each link field:
|
|
97
|
+
* - Looks up the corresponding link declaration in `links` by fieldname
|
|
98
|
+
* - `cardinality: 'noneOrMany'` or `'atLeastOne'`: auto-derives `columns` from the target's schema,
|
|
99
|
+
* sets `component` to `link.component ?? 'ATable'`, `config: { view: 'list' }`, `rows: []`.
|
|
100
|
+
* - `cardinality: 'one'` or `'atMostOne'`: embeds the target schema as the entry's
|
|
101
|
+
* `schema` property, sets `component` to `link.component ?? 'AForm'`.
|
|
83
102
|
*
|
|
84
103
|
* Recurses for deeply nested doctypes. Circular references are protected against.
|
|
104
|
+
* Returns a new array — does not mutate the original.
|
|
85
105
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* @
|
|
89
|
-
* @returns A new schema array with nested Doctype fields resolved
|
|
90
|
-
*
|
|
91
|
-
* @example
|
|
92
|
-
* ```ts
|
|
93
|
-
* registry.addDoctype(addressDoctype)
|
|
94
|
-
* registry.addDoctype(customerDoctype)
|
|
95
|
-
*
|
|
96
|
-
* // Before: customer schema has { fieldname: 'address', fieldtype: 'Doctype', options: 'address' }
|
|
97
|
-
* const resolved = registry.resolveSchema(customerSchema)
|
|
98
|
-
* // After: address field now has schema: [...address fields...]
|
|
99
|
-
* ```
|
|
106
|
+
* @param doctype - The doctype to resolve
|
|
107
|
+
* @param visited - Internal — set of already-visited doctype slugs for cycle detection
|
|
108
|
+
* @returns A new schema array with nested links resolved
|
|
100
109
|
*
|
|
101
110
|
* @public
|
|
102
111
|
*/
|
|
103
|
-
resolveSchema(
|
|
104
|
-
const seen = visited
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
resolveSchema(doctype, visited) {
|
|
113
|
+
const seen = visited ?? new Set();
|
|
114
|
+
const slug = doctype.slug;
|
|
115
|
+
// Prevent circular resolution
|
|
116
|
+
if (seen.has(slug)) {
|
|
117
|
+
return doctype.schema ? (Array.isArray(doctype.schema) ? doctype.schema : Array.from(doctype.schema)) : [];
|
|
118
|
+
}
|
|
119
|
+
seen.add(slug);
|
|
120
|
+
// Convert schema to array
|
|
121
|
+
const schemaArray = doctype.schema
|
|
122
|
+
? Array.isArray(doctype.schema)
|
|
123
|
+
? doctype.schema
|
|
124
|
+
: Array.from(doctype.schema)
|
|
125
|
+
: [];
|
|
126
|
+
// Build a map of link declarations by fieldname for quick lookup
|
|
127
|
+
// Use the link's fieldname property if set, otherwise use the key
|
|
128
|
+
const linksByFieldname = new Map();
|
|
129
|
+
if (doctype.links) {
|
|
130
|
+
for (const [key, link] of Object.entries(doctype.links)) {
|
|
131
|
+
const linkFieldname = link.fieldname ?? key;
|
|
132
|
+
linksByFieldname.set(linkFieldname, link);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Process fields in order: scalar fields copied as-is, link fields resolved
|
|
136
|
+
const resolvedFields = [];
|
|
137
|
+
for (const field of schemaArray) {
|
|
138
|
+
// Check if this field is a link field (fieldtype: 'Link')
|
|
139
|
+
if ('fieldtype' in field && field.fieldtype === 'Link') {
|
|
140
|
+
const link = linksByFieldname.get(field.fieldname);
|
|
141
|
+
if (!link) {
|
|
142
|
+
// Link field without corresponding link declaration - copy as-is
|
|
143
|
+
resolvedFields.push({ ...field });
|
|
144
|
+
continue;
|
|
115
145
|
}
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}));
|
|
136
|
-
}
|
|
137
|
-
// Set default component if not already specified
|
|
138
|
-
if (!resolved.component) {
|
|
139
|
-
resolved.component = 'ATable';
|
|
140
|
-
}
|
|
141
|
-
// Set default config if not already specified
|
|
142
|
-
if (!('config' in field) || !field.config) {
|
|
143
|
-
resolved.config = { view: 'list' };
|
|
144
|
-
}
|
|
145
|
-
// Initialize rows to empty array so componentProps fallback
|
|
146
|
-
// routes data from the form's dataModel[fieldname]
|
|
147
|
-
if (!('rows' in field) || !field.rows) {
|
|
148
|
-
resolved.rows = [];
|
|
149
|
-
}
|
|
150
|
-
return resolved;
|
|
151
|
-
}
|
|
152
|
-
else {
|
|
153
|
-
// 1:1 nested form (default cardinality: 'one')
|
|
154
|
-
// Recurse into child schema to resolve deeply nested doctypes
|
|
155
|
-
seen.add(doctypeSlug);
|
|
156
|
-
const resolvedChild = this.resolveSchema(childSchema, seen);
|
|
157
|
-
seen.delete(doctypeSlug);
|
|
158
|
-
return { ...field, schema: resolvedChild };
|
|
159
|
-
}
|
|
146
|
+
const targetDoctype = this.registry[link.target];
|
|
147
|
+
if (!targetDoctype) {
|
|
148
|
+
// Target not found - copy as-is
|
|
149
|
+
resolvedFields.push({ ...field });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const childSchema = this.resolveSchema(targetDoctype, seen);
|
|
153
|
+
if (link.cardinality === 'noneOrMany' || link.cardinality === 'atLeastOne') {
|
|
154
|
+
// Many relationship — build table config
|
|
155
|
+
resolvedFields.push(this.buildTableConfig({ fieldname: field.fieldname, label: field.label || field.fieldname }, childSchema, link.component));
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// One relationship — embed form schema
|
|
159
|
+
resolvedFields.push({
|
|
160
|
+
fieldname: field.fieldname,
|
|
161
|
+
label: field.label || field.fieldname,
|
|
162
|
+
component: link.component || 'AForm',
|
|
163
|
+
schema: childSchema,
|
|
164
|
+
});
|
|
160
165
|
}
|
|
161
166
|
}
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
else {
|
|
168
|
+
// Scalar field — copy as-is
|
|
169
|
+
resolvedFields.push({ ...field });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
seen.delete(slug);
|
|
173
|
+
return resolvedFields;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Build an ATable configuration from a field and child schema
|
|
177
|
+
* @internal
|
|
178
|
+
*/
|
|
179
|
+
buildTableConfig(field, childSchema, component) {
|
|
180
|
+
const resolved = {
|
|
181
|
+
fieldname: field.fieldname,
|
|
182
|
+
component: component || field.component || 'ATable',
|
|
183
|
+
columns: field.columns,
|
|
184
|
+
config: field.config,
|
|
185
|
+
rows: field.rows,
|
|
186
|
+
};
|
|
187
|
+
if (!resolved.columns) {
|
|
188
|
+
resolved.columns = childSchema
|
|
189
|
+
.filter(childField => 'fieldtype' in childField)
|
|
190
|
+
.map(childField => ({
|
|
191
|
+
name: childField.fieldname,
|
|
192
|
+
label: ('label' in childField && childField.label) || childField.fieldname,
|
|
193
|
+
fieldtype: 'fieldtype' in childField ? childField.fieldtype : 'Data',
|
|
194
|
+
align: 'align' in childField ? childField.align : 'left',
|
|
195
|
+
edit: 'edit' in childField ? childField.edit : true,
|
|
196
|
+
width: ('width' in childField && childField.width) || '20ch',
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
if (!resolved.config) {
|
|
200
|
+
resolved.config = { view: 'list' };
|
|
201
|
+
}
|
|
202
|
+
if (!resolved.rows) {
|
|
203
|
+
resolved.rows = [];
|
|
204
|
+
}
|
|
205
|
+
return resolved;
|
|
164
206
|
}
|
|
165
207
|
/**
|
|
166
208
|
* Initialize a new record with default values based on a schema.
|
|
@@ -172,7 +214,7 @@ export default class Registry {
|
|
|
172
214
|
* - Check → `false`
|
|
173
215
|
* - Int, Float, Decimal, Currency, Quantity → `0`
|
|
174
216
|
* - JSON → `{}`
|
|
175
|
-
* - Doctype with `cardinality: '
|
|
217
|
+
* - Doctype with `cardinality: 'noneOrMany'` or `'atLeastOne'` → `[]`
|
|
176
218
|
* - Doctype without `cardinality` or `cardinality: 'one'` → recursively initializes nested record
|
|
177
219
|
* - All others → `null`
|
|
178
220
|
*
|
|
@@ -194,6 +236,22 @@ export default class Registry {
|
|
|
194
236
|
const record = {};
|
|
195
237
|
schema.forEach(field => {
|
|
196
238
|
const fieldtype = 'fieldtype' in field ? field.fieldtype : 'Data';
|
|
239
|
+
const cardinality = 'cardinality' in field ? field.cardinality : undefined;
|
|
240
|
+
// 1:many — cardinality signals an array
|
|
241
|
+
if (cardinality === 'noneOrMany' || cardinality === 'atLeastOne') {
|
|
242
|
+
record[field.fieldname] = [];
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// Resolved 1:many table entry — has rows property
|
|
246
|
+
if ('rows' in field) {
|
|
247
|
+
record[field.fieldname] = [];
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// Resolved 1:1 link entry — has schema property (e.g., FieldsetSchema with nested schema)
|
|
251
|
+
if ('schema' in field && Array.isArray(field.schema)) {
|
|
252
|
+
record[field.fieldname] = this.initializeRecord(field.schema);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
197
255
|
switch (fieldtype) {
|
|
198
256
|
case 'Data':
|
|
199
257
|
case 'Text':
|
|
@@ -213,23 +271,6 @@ export default class Registry {
|
|
|
213
271
|
case 'JSON':
|
|
214
272
|
record[field.fieldname] = {};
|
|
215
273
|
break;
|
|
216
|
-
case 'Doctype': {
|
|
217
|
-
// Check cardinality to determine initial value
|
|
218
|
-
const cardinality = 'cardinality' in field ? field.cardinality : undefined;
|
|
219
|
-
if (cardinality === 'many') {
|
|
220
|
-
// 1:many child table - initialize as empty array
|
|
221
|
-
record[field.fieldname] = [];
|
|
222
|
-
}
|
|
223
|
-
else if ('schema' in field && Array.isArray(field.schema)) {
|
|
224
|
-
// 1:1 nested form with resolved schema - recursively initialize
|
|
225
|
-
record[field.fieldname] = this.initializeRecord(field.schema);
|
|
226
|
-
}
|
|
227
|
-
else {
|
|
228
|
-
// 1:1 without resolved schema - empty object
|
|
229
|
-
record[field.fieldname] = {};
|
|
230
|
-
}
|
|
231
|
-
break;
|
|
232
|
-
}
|
|
233
274
|
default:
|
|
234
275
|
record[field.fieldname] = null;
|
|
235
276
|
}
|
|
@@ -245,4 +286,123 @@ export default class Registry {
|
|
|
245
286
|
getDoctype(slug) {
|
|
246
287
|
return this.registry[slug];
|
|
247
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* Get all links declared on a doctype.
|
|
291
|
+
*
|
|
292
|
+
* @param doctypeSlug - The doctype slug to get links for
|
|
293
|
+
* @returns Array of link declarations with fieldname, or empty array if none
|
|
294
|
+
*
|
|
295
|
+
* @example
|
|
296
|
+
* ```ts
|
|
297
|
+
* const links = registry.getDescendantLinks('recipe')
|
|
298
|
+
* // [{ fieldname: 'tasks', target: 'recipe-task', cardinality: 'noneOrMany', backlink: 'recipe' }]
|
|
299
|
+
* ```
|
|
300
|
+
*
|
|
301
|
+
* @public
|
|
302
|
+
*/
|
|
303
|
+
getDescendantLinks(doctypeSlug) {
|
|
304
|
+
const doctype = this.registry[doctypeSlug];
|
|
305
|
+
if (!doctype?.links)
|
|
306
|
+
return [];
|
|
307
|
+
return Object.entries(doctype.links).map(([fieldname, link]) => ({
|
|
308
|
+
...link,
|
|
309
|
+
fieldname,
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Get links on other doctypes that target the given doctype.
|
|
314
|
+
*
|
|
315
|
+
* @param doctypeSlug - The doctype slug to find ancestor links for
|
|
316
|
+
* @returns Array of link declarations with fieldname and declaring doctype slug, or empty array
|
|
317
|
+
*
|
|
318
|
+
* @example
|
|
319
|
+
* ```ts
|
|
320
|
+
* const ancestors = registry.getAncestorLinks('recipe-task')
|
|
321
|
+
* // [{ fieldname: 'tasks', target: 'recipe-task', cardinality: 'noneOrMany', backlink: 'recipe', doctype: 'recipe' }]
|
|
322
|
+
* ```
|
|
323
|
+
*
|
|
324
|
+
* @public
|
|
325
|
+
*/
|
|
326
|
+
getAncestorLinks(doctypeSlug) {
|
|
327
|
+
this._ensureAncestorIndex();
|
|
328
|
+
const results = [];
|
|
329
|
+
for (const [_backlink, entries] of this._ancestorIndex) {
|
|
330
|
+
for (const { slug: declaringSlug, fieldname } of entries) {
|
|
331
|
+
const declaringDoctype = this.registry[declaringSlug];
|
|
332
|
+
if (!declaringDoctype?.links)
|
|
333
|
+
continue;
|
|
334
|
+
const link = declaringDoctype.links[fieldname];
|
|
335
|
+
if (link?.target === doctypeSlug) {
|
|
336
|
+
results.push({
|
|
337
|
+
...link,
|
|
338
|
+
fieldname,
|
|
339
|
+
doctype: declaringSlug,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return results;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Ensure the ancestor index is up to date
|
|
348
|
+
* @internal
|
|
349
|
+
*/
|
|
350
|
+
_ensureAncestorIndex() {
|
|
351
|
+
if (!this._ancestorIndexDirty)
|
|
352
|
+
return;
|
|
353
|
+
this._ancestorIndexDirty = false;
|
|
354
|
+
this._ancestorIndex.clear();
|
|
355
|
+
for (const [slug, doctype] of Object.entries(this.registry)) {
|
|
356
|
+
if (!doctype.links)
|
|
357
|
+
continue;
|
|
358
|
+
for (const [fieldname, link] of Object.entries(doctype.links)) {
|
|
359
|
+
if (link.backlink) {
|
|
360
|
+
const existing = this._ancestorIndex.get(link.backlink);
|
|
361
|
+
if (existing) {
|
|
362
|
+
existing.push({ slug, fieldname });
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
this._ancestorIndex.set(link.backlink, [{ slug, fieldname }]);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Convert the registry to a Map of DoctypeMeta objects for use with StonecropClient.
|
|
373
|
+
*
|
|
374
|
+
* This allows passing a Registry instance to StonecropClient by deriving the
|
|
375
|
+
* Map\<string, DoctypeMeta\> that StonecropClient needs for building nested GraphQL queries.
|
|
376
|
+
*
|
|
377
|
+
* @returns Map of doctype metadata keyed by doctype name
|
|
378
|
+
*
|
|
379
|
+
* @example
|
|
380
|
+
* ```typescript
|
|
381
|
+
* const registry = new Registry()
|
|
382
|
+
* registry.addDoctype(Doctype.fromObject(customerSchema))
|
|
383
|
+
* registry.addDoctype(Doctype.fromObject(orderSchema))
|
|
384
|
+
*
|
|
385
|
+
* const client = new StonecropClient({
|
|
386
|
+
* endpoint: '/graphql',
|
|
387
|
+
* registry: registry.toMetaMap(), // Convert once, use with client
|
|
388
|
+
* })
|
|
389
|
+
* ```
|
|
390
|
+
*
|
|
391
|
+
* @public
|
|
392
|
+
*/
|
|
393
|
+
toMetaMap() {
|
|
394
|
+
const map = new Map();
|
|
395
|
+
for (const [slug, doctype] of Object.entries(this.registry)) {
|
|
396
|
+
const fields = doctype.schema ? doctype.schema.toArray() : [];
|
|
397
|
+
const meta = {
|
|
398
|
+
name: doctype.name,
|
|
399
|
+
slug: slug,
|
|
400
|
+
fields: fields,
|
|
401
|
+
links: doctype.links,
|
|
402
|
+
workflow: doctype.workflow,
|
|
403
|
+
};
|
|
404
|
+
map.set(doctype.name, meta);
|
|
405
|
+
}
|
|
406
|
+
return map;
|
|
407
|
+
}
|
|
248
408
|
}
|
package/dist/schema-validator.js
CHANGED
|
@@ -19,6 +19,7 @@ export class SchemaValidator {
|
|
|
19
19
|
this.options = {
|
|
20
20
|
registry: options.registry || null,
|
|
21
21
|
validateLinkTargets: options.validateLinkTargets ?? true,
|
|
22
|
+
validateLinks: options.validateLinks ?? true,
|
|
22
23
|
validateActions: options.validateActions ?? true,
|
|
23
24
|
validateWorkflows: options.validateWorkflows ?? true,
|
|
24
25
|
validateRequiredProperties: options.validateRequiredProperties ?? true,
|
|
@@ -30,9 +31,10 @@ export class SchemaValidator {
|
|
|
30
31
|
* @param schema - Schema fields (List or Array)
|
|
31
32
|
* @param workflow - Optional workflow configuration
|
|
32
33
|
* @param actions - Optional actions map
|
|
34
|
+
* @param links - Optional links object
|
|
33
35
|
* @returns Validation result
|
|
34
36
|
*/
|
|
35
|
-
validate(doctype, schema, workflow, actions) {
|
|
37
|
+
validate(doctype, schema, workflow, actions, links) {
|
|
36
38
|
const issues = [];
|
|
37
39
|
// Convert schema to array for easier iteration
|
|
38
40
|
const schemaArray = schema ? (Array.isArray(schema) ? schema : schema.toArray()) : [];
|
|
@@ -44,6 +46,10 @@ export class SchemaValidator {
|
|
|
44
46
|
if (this.options.validateLinkTargets && this.options.registry) {
|
|
45
47
|
issues.push(...this.validateLinkFields(doctype, schemaArray, this.options.registry));
|
|
46
48
|
}
|
|
49
|
+
// Validate links object
|
|
50
|
+
if (this.options.validateLinks && this.options.registry && links) {
|
|
51
|
+
issues.push(...this.validateLinkDeclarations(doctype, links, schemaArray, this.options.registry));
|
|
52
|
+
}
|
|
47
53
|
// Validate workflow configuration
|
|
48
54
|
if (this.options.validateWorkflows && workflow) {
|
|
49
55
|
issues.push(...this.validateWorkflow(doctype, workflow));
|
|
@@ -157,6 +163,104 @@ export class SchemaValidator {
|
|
|
157
163
|
}
|
|
158
164
|
return issues;
|
|
159
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Validates link declarations: target resolution, backlink consistency, Link field correspondence
|
|
168
|
+
* @internal
|
|
169
|
+
*/
|
|
170
|
+
validateLinkDeclarations(doctype, links, schema, registry) {
|
|
171
|
+
const issues = [];
|
|
172
|
+
// Build a map of Link fields by fieldname for quick lookup
|
|
173
|
+
const linkFieldsByFieldname = new Map();
|
|
174
|
+
for (const field of schema) {
|
|
175
|
+
if ('fieldtype' in field && field.fieldtype === 'Link') {
|
|
176
|
+
linkFieldsByFieldname.set(field.fieldname, field);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const [fieldname, link] of Object.entries(links)) {
|
|
180
|
+
// Check target resolves in registry
|
|
181
|
+
const targetDoctype = registry.registry[link.target];
|
|
182
|
+
if (!targetDoctype) {
|
|
183
|
+
issues.push({
|
|
184
|
+
severity: ValidationSeverity.ERROR,
|
|
185
|
+
rule: 'link-invalid-target',
|
|
186
|
+
message: `Link "${fieldname}" references non-existent doctype: "${link.target}"`,
|
|
187
|
+
doctype,
|
|
188
|
+
fieldname,
|
|
189
|
+
context: { target: link.target },
|
|
190
|
+
});
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
// Warn on self-referential target
|
|
194
|
+
if (link.target === doctype) {
|
|
195
|
+
issues.push({
|
|
196
|
+
severity: ValidationSeverity.WARNING,
|
|
197
|
+
rule: 'link-self-referential',
|
|
198
|
+
message: `Link "${fieldname}" is self-referential (target: "${link.target}")`,
|
|
199
|
+
doctype,
|
|
200
|
+
fieldname,
|
|
201
|
+
context: { target: link.target },
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
// Check backlink consistency
|
|
205
|
+
if (link.backlink && targetDoctype.links) {
|
|
206
|
+
const reciprocalLink = targetDoctype.links[link.backlink];
|
|
207
|
+
if (!reciprocalLink) {
|
|
208
|
+
issues.push({
|
|
209
|
+
severity: ValidationSeverity.ERROR,
|
|
210
|
+
rule: 'link-backlink-missing',
|
|
211
|
+
message: `Backlink "${link.backlink}" not found on target doctype "${link.target}"`,
|
|
212
|
+
doctype,
|
|
213
|
+
fieldname,
|
|
214
|
+
context: { backlink: link.backlink, target: link.target },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
else if (reciprocalLink.target !== doctype) {
|
|
218
|
+
issues.push({
|
|
219
|
+
severity: ValidationSeverity.WARNING,
|
|
220
|
+
rule: 'link-backlink-mismatch',
|
|
221
|
+
message: `Backlink "${link.backlink}" on "${link.target}" points to "${reciprocalLink.target}" instead of "${doctype}"`,
|
|
222
|
+
doctype,
|
|
223
|
+
fieldname,
|
|
224
|
+
context: { backlink: link.backlink, target: link.target, actualTarget: reciprocalLink.target },
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// If Link field exists with same fieldname, verify it has matching target
|
|
229
|
+
// Only check if link has fieldname set (otherwise it's a standalone link without a field)
|
|
230
|
+
if (link.fieldname) {
|
|
231
|
+
const linkField = linkFieldsByFieldname.get(link.fieldname);
|
|
232
|
+
if (linkField) {
|
|
233
|
+
const linkFieldOptions = 'options' in linkField ? linkField.options : undefined;
|
|
234
|
+
const linkFieldTarget = typeof linkFieldOptions === 'string' ? linkFieldOptions : undefined;
|
|
235
|
+
if (linkFieldTarget && linkFieldTarget !== link.target) {
|
|
236
|
+
issues.push({
|
|
237
|
+
severity: ValidationSeverity.ERROR,
|
|
238
|
+
rule: 'link-field-target-mismatch',
|
|
239
|
+
message: `Link field "${link.fieldname}" targets "${linkFieldTarget}" but link declaration targets "${link.target}"`,
|
|
240
|
+
doctype,
|
|
241
|
+
fieldname: link.fieldname,
|
|
242
|
+
context: { linkFieldTarget, linkTarget: link.target },
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Check that every Link field has a corresponding link declaration
|
|
249
|
+
// A Link field corresponds to a link if the link's fieldname property matches the field's fieldname
|
|
250
|
+
for (const [fieldname, _field] of linkFieldsByFieldname) {
|
|
251
|
+
const hasCorrespondingLink = Object.values(links).some(link => link.fieldname === fieldname);
|
|
252
|
+
if (!hasCorrespondingLink) {
|
|
253
|
+
issues.push({
|
|
254
|
+
severity: ValidationSeverity.ERROR,
|
|
255
|
+
rule: 'link-field-without-declaration',
|
|
256
|
+
message: `Link field "${fieldname}" has no corresponding link declaration`,
|
|
257
|
+
doctype,
|
|
258
|
+
fieldname,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return issues;
|
|
263
|
+
}
|
|
160
264
|
/**
|
|
161
265
|
* Validates workflow state machine configuration
|
|
162
266
|
* @internal
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export shim — the implementation has moved to `composables/use-stonecrop.ts`.
|
|
3
|
+
*
|
|
4
|
+
* This file is kept so that any existing relative imports of `./composable` or
|
|
5
|
+
* `../composable` continue to resolve without changes. All public consumers
|
|
6
|
+
* should import from `@stonecrop/stonecrop` (the package root), which
|
|
7
|
+
* re-exports everything from `./composables/use-stonecrop` via `index.ts`.
|
|
8
|
+
*/
|
|
9
|
+
export type { OperationLogAPI, BaseStonecropReturn, HSTStonecropReturn, HSTChangeData } from './composables/stonecrop';
|
|
10
|
+
export { useStonecrop } from './composables/stonecrop';
|
|
11
|
+
//# sourceMappingURL=composable.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"composable.d.ts","sourceRoot":"","sources":["../../src/composable.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,YAAY,EAAE,eAAe,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAA;AACtH,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA"}
|