@stonecrop/stonecrop 0.10.15 → 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 +4 -4
- 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/src/registry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { SchemaTypes } from '@stonecrop/aform'
|
|
1
|
+
import type { SchemaTypes, TableSchema } from '@stonecrop/aform'
|
|
2
|
+
import type { DoctypeMeta, LinkDeclaration } from '@stonecrop/schema'
|
|
2
3
|
import { Router } from 'vue-router'
|
|
3
4
|
|
|
4
5
|
import Doctype from './doctype'
|
|
@@ -20,13 +21,33 @@ export default class Registry {
|
|
|
20
21
|
*
|
|
21
22
|
* @defaultValue 'Registry'
|
|
22
23
|
*/
|
|
23
|
-
readonly name: string
|
|
24
|
+
readonly name: string = 'Registry'
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* The registry property contains a collection of doctypes
|
|
28
|
+
*
|
|
29
|
+
* @defaultValue `{}`
|
|
27
30
|
* @see {@link Doctype}
|
|
28
31
|
*/
|
|
29
|
-
readonly registry: Record<string, Doctype>
|
|
32
|
+
readonly registry: Record<string, Doctype> = {}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reverse index: backlink fieldname → list of \{ doctype slug, link fieldname \}.
|
|
36
|
+
* Multiple doctypes can declare a link with the same backlink name, so each key
|
|
37
|
+
* maps to an array. Built at schema load time for O(1) ancestor lookups.
|
|
38
|
+
*
|
|
39
|
+
* @defaultValue `new Map()`
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
private _ancestorIndex: Map<string, Array<{ slug: string; fieldname: string }>> = new Map()
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether the ancestor index needs rebuilding
|
|
46
|
+
*
|
|
47
|
+
* @defaultValue `true`
|
|
48
|
+
* @internal
|
|
49
|
+
*/
|
|
50
|
+
private _ancestorIndexDirty: boolean = true
|
|
30
51
|
|
|
31
52
|
/**
|
|
32
53
|
* The Vue router instance
|
|
@@ -44,8 +65,6 @@ export default class Registry {
|
|
|
44
65
|
return Registry._root
|
|
45
66
|
}
|
|
46
67
|
Registry._root = this
|
|
47
|
-
this.name = 'Registry'
|
|
48
|
-
this.registry = {}
|
|
49
68
|
this.router = router
|
|
50
69
|
this.getMeta = getMeta
|
|
51
70
|
}
|
|
@@ -65,6 +84,7 @@ export default class Registry {
|
|
|
65
84
|
addDoctype(doctype: Doctype) {
|
|
66
85
|
if (!(doctype.slug in this.registry)) {
|
|
67
86
|
this.registry[doctype.slug] = doctype
|
|
87
|
+
this._ancestorIndexDirty = true
|
|
68
88
|
}
|
|
69
89
|
|
|
70
90
|
// Register actions (including field triggers) with the field trigger engine
|
|
@@ -87,108 +107,138 @@ export default class Registry {
|
|
|
87
107
|
/**
|
|
88
108
|
* Resolve nested Doctype fields in a schema by embedding child schemas inline.
|
|
89
109
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
110
|
+
* Accepts a Doctype and extracts `fields` and `links` internally.
|
|
111
|
+
* Fields array contains both scalar fields and link fields (with fieldtype: 'Link').
|
|
112
|
+
* Render order is determined by the order of fields in the fields array.
|
|
93
113
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* -
|
|
97
|
-
* `
|
|
114
|
+
* For each link field:
|
|
115
|
+
* - Looks up the corresponding link declaration in `links` by fieldname
|
|
116
|
+
* - `cardinality: 'noneOrMany'` or `'atLeastOne'`: auto-derives `columns` from the target's schema,
|
|
117
|
+
* sets `component` to `link.component ?? 'ATable'`, `config: { view: 'list' }`, `rows: []`.
|
|
118
|
+
* - `cardinality: 'one'` or `'atMostOne'`: embeds the target schema as the entry's
|
|
119
|
+
* `schema` property, sets `component` to `link.component ?? 'AForm'`.
|
|
98
120
|
*
|
|
99
121
|
* Recurses for deeply nested doctypes. Circular references are protected against.
|
|
122
|
+
* Returns a new array — does not mutate the original.
|
|
100
123
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* @
|
|
104
|
-
* @returns A new schema array with nested Doctype fields resolved
|
|
105
|
-
*
|
|
106
|
-
* @example
|
|
107
|
-
* ```ts
|
|
108
|
-
* registry.addDoctype(addressDoctype)
|
|
109
|
-
* registry.addDoctype(customerDoctype)
|
|
110
|
-
*
|
|
111
|
-
* // Before: customer schema has { fieldname: 'address', fieldtype: 'Doctype', options: 'address' }
|
|
112
|
-
* const resolved = registry.resolveSchema(customerSchema)
|
|
113
|
-
* // After: address field now has schema: [...address fields...]
|
|
114
|
-
* ```
|
|
124
|
+
* @param doctype - The doctype to resolve
|
|
125
|
+
* @param visited - Internal — set of already-visited doctype slugs for cycle detection
|
|
126
|
+
* @returns A new schema array with nested links resolved
|
|
115
127
|
*
|
|
116
128
|
* @public
|
|
117
129
|
*/
|
|
118
|
-
resolveSchema(
|
|
119
|
-
const seen = visited
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
130
|
+
resolveSchema(doctype: Doctype, visited?: Set<string>): SchemaTypes[] {
|
|
131
|
+
const seen = visited ?? new Set<string>()
|
|
132
|
+
const slug = doctype.slug
|
|
133
|
+
|
|
134
|
+
// Prevent circular resolution
|
|
135
|
+
if (seen.has(slug)) {
|
|
136
|
+
return doctype.schema ? (Array.isArray(doctype.schema) ? doctype.schema : Array.from(doctype.schema)) : []
|
|
137
|
+
}
|
|
138
|
+
seen.add(slug)
|
|
139
|
+
|
|
140
|
+
// Convert schema to array
|
|
141
|
+
const schemaArray: SchemaTypes[] = doctype.schema
|
|
142
|
+
? Array.isArray(doctype.schema)
|
|
143
|
+
? doctype.schema
|
|
144
|
+
: Array.from(doctype.schema)
|
|
145
|
+
: []
|
|
146
|
+
|
|
147
|
+
// Build a map of link declarations by fieldname for quick lookup
|
|
148
|
+
// Use the link's fieldname property if set, otherwise use the key
|
|
149
|
+
const linksByFieldname = new Map<string, LinkDeclaration>()
|
|
150
|
+
if (doctype.links) {
|
|
151
|
+
for (const [key, link] of Object.entries(doctype.links)) {
|
|
152
|
+
const linkFieldname = link.fieldname ?? key
|
|
153
|
+
linksByFieldname.set(linkFieldname, link)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Process fields in order: scalar fields copied as-is, link fields resolved
|
|
158
|
+
const resolvedFields: SchemaTypes[] = []
|
|
159
|
+
for (const field of schemaArray) {
|
|
160
|
+
// Check if this field is a link field (fieldtype: 'Link')
|
|
161
|
+
if ('fieldtype' in field && field.fieldtype === 'Link') {
|
|
162
|
+
const link = linksByFieldname.get(field.fieldname)
|
|
163
|
+
if (!link) {
|
|
164
|
+
// Link field without corresponding link declaration - copy as-is
|
|
165
|
+
resolvedFields.push({ ...field })
|
|
166
|
+
continue
|
|
134
167
|
}
|
|
135
168
|
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const cardinality = 'cardinality' in field ? field.cardinality : undefined
|
|
143
|
-
|
|
144
|
-
if (cardinality === 'many') {
|
|
145
|
-
// 1:many child table - derive columns, set component, config, rows
|
|
146
|
-
const resolved: Record<string, any> = { ...field }
|
|
147
|
-
|
|
148
|
-
// Auto-derive columns from child schema fields if not already provided
|
|
149
|
-
if (!('columns' in field) || !field.columns) {
|
|
150
|
-
resolved.columns = childSchema.map(childField => ({
|
|
151
|
-
name: childField.fieldname,
|
|
152
|
-
fieldname: childField.fieldname,
|
|
153
|
-
label: ('label' in childField && childField.label) || childField.fieldname,
|
|
154
|
-
fieldtype: 'fieldtype' in childField ? childField.fieldtype : 'Data',
|
|
155
|
-
align: ('align' in childField && childField.align) || 'left',
|
|
156
|
-
edit: 'edit' in childField ? childField.edit : true,
|
|
157
|
-
width: ('width' in childField && childField.width) || '20ch',
|
|
158
|
-
}))
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Set default component if not already specified
|
|
162
|
-
if (!resolved.component) {
|
|
163
|
-
resolved.component = 'ATable'
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Set default config if not already specified
|
|
167
|
-
if (!('config' in field) || !field.config) {
|
|
168
|
-
resolved.config = { view: 'list' }
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Initialize rows to empty array so componentProps fallback
|
|
172
|
-
// routes data from the form's dataModel[fieldname]
|
|
173
|
-
if (!('rows' in field) || !field.rows) {
|
|
174
|
-
resolved.rows = []
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return resolved as SchemaTypes
|
|
178
|
-
} else {
|
|
179
|
-
// 1:1 nested form (default cardinality: 'one')
|
|
180
|
-
// Recurse into child schema to resolve deeply nested doctypes
|
|
181
|
-
seen.add(doctypeSlug)
|
|
182
|
-
const resolvedChild = this.resolveSchema(childSchema, seen)
|
|
183
|
-
seen.delete(doctypeSlug)
|
|
169
|
+
const targetDoctype = this.registry[link.target]
|
|
170
|
+
if (!targetDoctype) {
|
|
171
|
+
// Target not found - copy as-is
|
|
172
|
+
resolvedFields.push({ ...field })
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
184
175
|
|
|
185
|
-
|
|
186
|
-
|
|
176
|
+
const childSchema = this.resolveSchema(targetDoctype, seen)
|
|
177
|
+
|
|
178
|
+
if (link.cardinality === 'noneOrMany' || link.cardinality === 'atLeastOne') {
|
|
179
|
+
// Many relationship — build table config
|
|
180
|
+
resolvedFields.push(
|
|
181
|
+
this.buildTableConfig(
|
|
182
|
+
{ fieldname: field.fieldname, label: field.label || field.fieldname },
|
|
183
|
+
childSchema,
|
|
184
|
+
link.component
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
} else {
|
|
188
|
+
// One relationship — embed form schema
|
|
189
|
+
resolvedFields.push({
|
|
190
|
+
fieldname: field.fieldname,
|
|
191
|
+
label: field.label || field.fieldname,
|
|
192
|
+
component: link.component || 'AForm',
|
|
193
|
+
schema: childSchema,
|
|
194
|
+
})
|
|
187
195
|
}
|
|
196
|
+
} else {
|
|
197
|
+
// Scalar field — copy as-is
|
|
198
|
+
resolvedFields.push({ ...field })
|
|
188
199
|
}
|
|
200
|
+
}
|
|
189
201
|
|
|
190
|
-
|
|
191
|
-
|
|
202
|
+
seen.delete(slug)
|
|
203
|
+
|
|
204
|
+
return resolvedFields
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Build an ATable configuration from a field and child schema
|
|
209
|
+
* @internal
|
|
210
|
+
*/
|
|
211
|
+
private buildTableConfig(field: Record<string, any>, childSchema: SchemaTypes[], component?: string): TableSchema {
|
|
212
|
+
const resolved: TableSchema = {
|
|
213
|
+
fieldname: field.fieldname,
|
|
214
|
+
component: component || field.component || 'ATable',
|
|
215
|
+
columns: field.columns,
|
|
216
|
+
config: field.config,
|
|
217
|
+
rows: field.rows,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!resolved.columns) {
|
|
221
|
+
resolved.columns = childSchema
|
|
222
|
+
.filter(childField => 'fieldtype' in childField)
|
|
223
|
+
.map(childField => ({
|
|
224
|
+
name: childField.fieldname,
|
|
225
|
+
label: ('label' in childField && childField.label) || childField.fieldname,
|
|
226
|
+
fieldtype: 'fieldtype' in childField ? childField.fieldtype : 'Data',
|
|
227
|
+
align: 'align' in childField ? childField.align : 'left',
|
|
228
|
+
edit: 'edit' in childField ? childField.edit : true,
|
|
229
|
+
width: ('width' in childField && childField.width) || '20ch',
|
|
230
|
+
}))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!resolved.config) {
|
|
234
|
+
resolved.config = { view: 'list' }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!resolved.rows) {
|
|
238
|
+
resolved.rows = []
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return resolved
|
|
192
242
|
}
|
|
193
243
|
|
|
194
244
|
/**
|
|
@@ -201,7 +251,7 @@ export default class Registry {
|
|
|
201
251
|
* - Check → `false`
|
|
202
252
|
* - Int, Float, Decimal, Currency, Quantity → `0`
|
|
203
253
|
* - JSON → `{}`
|
|
204
|
-
* - Doctype with `cardinality: '
|
|
254
|
+
* - Doctype with `cardinality: 'noneOrMany'` or `'atLeastOne'` → `[]`
|
|
205
255
|
* - Doctype without `cardinality` or `cardinality: 'one'` → recursively initializes nested record
|
|
206
256
|
* - All others → `null`
|
|
207
257
|
*
|
|
@@ -223,7 +273,26 @@ export default class Registry {
|
|
|
223
273
|
const record: Record<string, any> = {}
|
|
224
274
|
|
|
225
275
|
schema.forEach(field => {
|
|
226
|
-
const fieldtype = 'fieldtype' in field ?
|
|
276
|
+
const fieldtype = 'fieldtype' in field ? field.fieldtype : 'Data'
|
|
277
|
+
const cardinality = 'cardinality' in field ? field.cardinality : undefined
|
|
278
|
+
|
|
279
|
+
// 1:many — cardinality signals an array
|
|
280
|
+
if (cardinality === 'noneOrMany' || cardinality === 'atLeastOne') {
|
|
281
|
+
record[field.fieldname] = []
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Resolved 1:many table entry — has rows property
|
|
286
|
+
if ('rows' in field) {
|
|
287
|
+
record[field.fieldname] = []
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Resolved 1:1 link entry — has schema property (e.g., FieldsetSchema with nested schema)
|
|
292
|
+
if ('schema' in field && Array.isArray(field.schema)) {
|
|
293
|
+
record[field.fieldname] = this.initializeRecord(field.schema)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
227
296
|
|
|
228
297
|
switch (fieldtype) {
|
|
229
298
|
case 'Data':
|
|
@@ -244,21 +313,6 @@ export default class Registry {
|
|
|
244
313
|
case 'JSON':
|
|
245
314
|
record[field.fieldname] = {}
|
|
246
315
|
break
|
|
247
|
-
case 'Doctype': {
|
|
248
|
-
// Check cardinality to determine initial value
|
|
249
|
-
const cardinality = 'cardinality' in field ? field.cardinality : undefined
|
|
250
|
-
if (cardinality === 'many') {
|
|
251
|
-
// 1:many child table - initialize as empty array
|
|
252
|
-
record[field.fieldname] = []
|
|
253
|
-
} else if ('schema' in field && Array.isArray(field.schema)) {
|
|
254
|
-
// 1:1 nested form with resolved schema - recursively initialize
|
|
255
|
-
record[field.fieldname] = this.initializeRecord(field.schema)
|
|
256
|
-
} else {
|
|
257
|
-
// 1:1 without resolved schema - empty object
|
|
258
|
-
record[field.fieldname] = {}
|
|
259
|
-
}
|
|
260
|
-
break
|
|
261
|
-
}
|
|
262
316
|
default:
|
|
263
317
|
record[field.fieldname] = null
|
|
264
318
|
}
|
|
@@ -277,6 +331,130 @@ export default class Registry {
|
|
|
277
331
|
return this.registry[slug]
|
|
278
332
|
}
|
|
279
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Get all links declared on a doctype.
|
|
336
|
+
*
|
|
337
|
+
* @param doctypeSlug - The doctype slug to get links for
|
|
338
|
+
* @returns Array of link declarations with fieldname, or empty array if none
|
|
339
|
+
*
|
|
340
|
+
* @example
|
|
341
|
+
* ```ts
|
|
342
|
+
* const links = registry.getDescendantLinks('recipe')
|
|
343
|
+
* // [{ fieldname: 'tasks', target: 'recipe-task', cardinality: 'noneOrMany', backlink: 'recipe' }]
|
|
344
|
+
* ```
|
|
345
|
+
*
|
|
346
|
+
* @public
|
|
347
|
+
*/
|
|
348
|
+
getDescendantLinks(doctypeSlug: string): Array<LinkDeclaration & { fieldname: string }> {
|
|
349
|
+
const doctype = this.registry[doctypeSlug]
|
|
350
|
+
if (!doctype?.links) return []
|
|
351
|
+
|
|
352
|
+
return Object.entries(doctype.links).map(([fieldname, link]) => ({
|
|
353
|
+
...link,
|
|
354
|
+
fieldname,
|
|
355
|
+
}))
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Get links on other doctypes that target the given doctype.
|
|
360
|
+
*
|
|
361
|
+
* @param doctypeSlug - The doctype slug to find ancestor links for
|
|
362
|
+
* @returns Array of link declarations with fieldname and declaring doctype slug, or empty array
|
|
363
|
+
*
|
|
364
|
+
* @example
|
|
365
|
+
* ```ts
|
|
366
|
+
* const ancestors = registry.getAncestorLinks('recipe-task')
|
|
367
|
+
* // [{ fieldname: 'tasks', target: 'recipe-task', cardinality: 'noneOrMany', backlink: 'recipe', doctype: 'recipe' }]
|
|
368
|
+
* ```
|
|
369
|
+
*
|
|
370
|
+
* @public
|
|
371
|
+
*/
|
|
372
|
+
getAncestorLinks(doctypeSlug: string): Array<LinkDeclaration & { fieldname: string; doctype: string }> {
|
|
373
|
+
this._ensureAncestorIndex()
|
|
374
|
+
|
|
375
|
+
const results: Array<LinkDeclaration & { fieldname: string; doctype: string }> = []
|
|
376
|
+
|
|
377
|
+
for (const [_backlink, entries] of this._ancestorIndex) {
|
|
378
|
+
for (const { slug: declaringSlug, fieldname } of entries) {
|
|
379
|
+
const declaringDoctype = this.registry[declaringSlug]
|
|
380
|
+
if (!declaringDoctype?.links) continue
|
|
381
|
+
|
|
382
|
+
const link = declaringDoctype.links[fieldname]
|
|
383
|
+
if (link?.target === doctypeSlug) {
|
|
384
|
+
results.push({
|
|
385
|
+
...link,
|
|
386
|
+
fieldname,
|
|
387
|
+
doctype: declaringSlug,
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return results
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Ensure the ancestor index is up to date
|
|
398
|
+
* @internal
|
|
399
|
+
*/
|
|
400
|
+
private _ensureAncestorIndex(): void {
|
|
401
|
+
if (!this._ancestorIndexDirty) return
|
|
402
|
+
this._ancestorIndexDirty = false
|
|
403
|
+
this._ancestorIndex.clear()
|
|
404
|
+
|
|
405
|
+
for (const [slug, doctype] of Object.entries(this.registry)) {
|
|
406
|
+
if (!doctype.links) continue
|
|
407
|
+
for (const [fieldname, link] of Object.entries(doctype.links)) {
|
|
408
|
+
if (link.backlink) {
|
|
409
|
+
const existing = this._ancestorIndex.get(link.backlink)
|
|
410
|
+
if (existing) {
|
|
411
|
+
existing.push({ slug, fieldname })
|
|
412
|
+
} else {
|
|
413
|
+
this._ancestorIndex.set(link.backlink, [{ slug, fieldname }])
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Convert the registry to a Map of DoctypeMeta objects for use with StonecropClient.
|
|
422
|
+
*
|
|
423
|
+
* This allows passing a Registry instance to StonecropClient by deriving the
|
|
424
|
+
* Map\<string, DoctypeMeta\> that StonecropClient needs for building nested GraphQL queries.
|
|
425
|
+
*
|
|
426
|
+
* @returns Map of doctype metadata keyed by doctype name
|
|
427
|
+
*
|
|
428
|
+
* @example
|
|
429
|
+
* ```typescript
|
|
430
|
+
* const registry = new Registry()
|
|
431
|
+
* registry.addDoctype(Doctype.fromObject(customerSchema))
|
|
432
|
+
* registry.addDoctype(Doctype.fromObject(orderSchema))
|
|
433
|
+
*
|
|
434
|
+
* const client = new StonecropClient({
|
|
435
|
+
* endpoint: '/graphql',
|
|
436
|
+
* registry: registry.toMetaMap(), // Convert once, use with client
|
|
437
|
+
* })
|
|
438
|
+
* ```
|
|
439
|
+
*
|
|
440
|
+
* @public
|
|
441
|
+
*/
|
|
442
|
+
toMetaMap(): Map<string, DoctypeMeta> {
|
|
443
|
+
const map = new Map<string, DoctypeMeta>()
|
|
444
|
+
for (const [slug, doctype] of Object.entries(this.registry)) {
|
|
445
|
+
const fields = doctype.schema ? doctype.schema.toArray() : []
|
|
446
|
+
const meta: DoctypeMeta = {
|
|
447
|
+
name: doctype.name,
|
|
448
|
+
slug: slug,
|
|
449
|
+
fields: fields as DoctypeMeta['fields'],
|
|
450
|
+
links: doctype.links,
|
|
451
|
+
workflow: doctype.workflow as DoctypeMeta['workflow'],
|
|
452
|
+
}
|
|
453
|
+
map.set(doctype.name, meta)
|
|
454
|
+
}
|
|
455
|
+
return map
|
|
456
|
+
}
|
|
457
|
+
|
|
280
458
|
// TODO: should we allow clearing the registry at all?
|
|
281
459
|
// clear() {
|
|
282
460
|
// this.registry = {}
|
package/src/schema-validator.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { SchemaTypes } from '@stonecrop/aform'
|
|
8
|
+
import type { LinkDeclaration } from '@stonecrop/schema'
|
|
8
9
|
import type { List, Map as ImmutableMap } from 'immutable'
|
|
9
10
|
import type { AnyStateNodeConfig } from 'xstate'
|
|
10
11
|
|
|
@@ -28,6 +29,7 @@ export class SchemaValidator {
|
|
|
28
29
|
this.options = {
|
|
29
30
|
registry: options.registry || null!,
|
|
30
31
|
validateLinkTargets: options.validateLinkTargets ?? true,
|
|
32
|
+
validateLinks: options.validateLinks ?? true,
|
|
31
33
|
validateActions: options.validateActions ?? true,
|
|
32
34
|
validateWorkflows: options.validateWorkflows ?? true,
|
|
33
35
|
validateRequiredProperties: options.validateRequiredProperties ?? true,
|
|
@@ -40,13 +42,15 @@ export class SchemaValidator {
|
|
|
40
42
|
* @param schema - Schema fields (List or Array)
|
|
41
43
|
* @param workflow - Optional workflow configuration
|
|
42
44
|
* @param actions - Optional actions map
|
|
45
|
+
* @param links - Optional links object
|
|
43
46
|
* @returns Validation result
|
|
44
47
|
*/
|
|
45
48
|
validate(
|
|
46
49
|
doctype: string,
|
|
47
50
|
schema: List<SchemaTypes> | SchemaTypes[] | undefined,
|
|
48
51
|
workflow?: AnyStateNodeConfig,
|
|
49
|
-
actions?: ImmutableMap<string, string[]> | Map<string, string[]
|
|
52
|
+
actions?: ImmutableMap<string, string[]> | Map<string, string[]>,
|
|
53
|
+
links?: Record<string, LinkDeclaration>
|
|
50
54
|
): ValidationResult {
|
|
51
55
|
const issues: ValidationIssue[] = []
|
|
52
56
|
|
|
@@ -63,6 +67,11 @@ export class SchemaValidator {
|
|
|
63
67
|
issues.push(...this.validateLinkFields(doctype, schemaArray, this.options.registry))
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
// Validate links object
|
|
71
|
+
if (this.options.validateLinks && this.options.registry && links) {
|
|
72
|
+
issues.push(...this.validateLinkDeclarations(doctype, links, schemaArray, this.options.registry))
|
|
73
|
+
}
|
|
74
|
+
|
|
66
75
|
// Validate workflow configuration
|
|
67
76
|
if (this.options.validateWorkflows && workflow) {
|
|
68
77
|
issues.push(...this.validateWorkflow(doctype, workflow))
|
|
@@ -196,6 +205,116 @@ export class SchemaValidator {
|
|
|
196
205
|
return issues
|
|
197
206
|
}
|
|
198
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Validates link declarations: target resolution, backlink consistency, Link field correspondence
|
|
210
|
+
* @internal
|
|
211
|
+
*/
|
|
212
|
+
private validateLinkDeclarations(
|
|
213
|
+
doctype: string,
|
|
214
|
+
links: Record<string, LinkDeclaration>,
|
|
215
|
+
schema: SchemaTypes[],
|
|
216
|
+
registry: Registry
|
|
217
|
+
): ValidationIssue[] {
|
|
218
|
+
const issues: ValidationIssue[] = []
|
|
219
|
+
|
|
220
|
+
// Build a map of Link fields by fieldname for quick lookup
|
|
221
|
+
const linkFieldsByFieldname = new Map<string, SchemaTypes>()
|
|
222
|
+
for (const field of schema) {
|
|
223
|
+
if ('fieldtype' in field && field.fieldtype === 'Link') {
|
|
224
|
+
linkFieldsByFieldname.set(field.fieldname, field)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const [fieldname, link] of Object.entries(links)) {
|
|
229
|
+
// Check target resolves in registry
|
|
230
|
+
const targetDoctype = registry.registry[link.target]
|
|
231
|
+
if (!targetDoctype) {
|
|
232
|
+
issues.push({
|
|
233
|
+
severity: ValidationSeverity.ERROR,
|
|
234
|
+
rule: 'link-invalid-target',
|
|
235
|
+
message: `Link "${fieldname}" references non-existent doctype: "${link.target}"`,
|
|
236
|
+
doctype,
|
|
237
|
+
fieldname,
|
|
238
|
+
context: { target: link.target },
|
|
239
|
+
})
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Warn on self-referential target
|
|
244
|
+
if (link.target === doctype) {
|
|
245
|
+
issues.push({
|
|
246
|
+
severity: ValidationSeverity.WARNING,
|
|
247
|
+
rule: 'link-self-referential',
|
|
248
|
+
message: `Link "${fieldname}" is self-referential (target: "${link.target}")`,
|
|
249
|
+
doctype,
|
|
250
|
+
fieldname,
|
|
251
|
+
context: { target: link.target },
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Check backlink consistency
|
|
256
|
+
if (link.backlink && targetDoctype.links) {
|
|
257
|
+
const reciprocalLink = targetDoctype.links[link.backlink]
|
|
258
|
+
if (!reciprocalLink) {
|
|
259
|
+
issues.push({
|
|
260
|
+
severity: ValidationSeverity.ERROR,
|
|
261
|
+
rule: 'link-backlink-missing',
|
|
262
|
+
message: `Backlink "${link.backlink}" not found on target doctype "${link.target}"`,
|
|
263
|
+
doctype,
|
|
264
|
+
fieldname,
|
|
265
|
+
context: { backlink: link.backlink, target: link.target },
|
|
266
|
+
})
|
|
267
|
+
} else if (reciprocalLink.target !== doctype) {
|
|
268
|
+
issues.push({
|
|
269
|
+
severity: ValidationSeverity.WARNING,
|
|
270
|
+
rule: 'link-backlink-mismatch',
|
|
271
|
+
message: `Backlink "${link.backlink}" on "${link.target}" points to "${reciprocalLink.target}" instead of "${doctype}"`,
|
|
272
|
+
doctype,
|
|
273
|
+
fieldname,
|
|
274
|
+
context: { backlink: link.backlink, target: link.target, actualTarget: reciprocalLink.target },
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// If Link field exists with same fieldname, verify it has matching target
|
|
280
|
+
// Only check if link has fieldname set (otherwise it's a standalone link without a field)
|
|
281
|
+
if (link.fieldname) {
|
|
282
|
+
const linkField = linkFieldsByFieldname.get(link.fieldname)
|
|
283
|
+
if (linkField) {
|
|
284
|
+
const linkFieldOptions = 'options' in linkField ? (linkField as { options: unknown }).options : undefined
|
|
285
|
+
const linkFieldTarget = typeof linkFieldOptions === 'string' ? linkFieldOptions : undefined
|
|
286
|
+
if (linkFieldTarget && linkFieldTarget !== link.target) {
|
|
287
|
+
issues.push({
|
|
288
|
+
severity: ValidationSeverity.ERROR,
|
|
289
|
+
rule: 'link-field-target-mismatch',
|
|
290
|
+
message: `Link field "${link.fieldname}" targets "${linkFieldTarget}" but link declaration targets "${link.target}"`,
|
|
291
|
+
doctype,
|
|
292
|
+
fieldname: link.fieldname,
|
|
293
|
+
context: { linkFieldTarget, linkTarget: link.target },
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Check that every Link field has a corresponding link declaration
|
|
301
|
+
// A Link field corresponds to a link if the link's fieldname property matches the field's fieldname
|
|
302
|
+
for (const [fieldname, _field] of linkFieldsByFieldname) {
|
|
303
|
+
const hasCorrespondingLink = Object.values(links).some(link => link.fieldname === fieldname)
|
|
304
|
+
if (!hasCorrespondingLink) {
|
|
305
|
+
issues.push({
|
|
306
|
+
severity: ValidationSeverity.ERROR,
|
|
307
|
+
rule: 'link-field-without-declaration',
|
|
308
|
+
message: `Link field "${fieldname}" has no corresponding link declaration`,
|
|
309
|
+
doctype,
|
|
310
|
+
fieldname,
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return issues
|
|
316
|
+
}
|
|
317
|
+
|
|
199
318
|
/**
|
|
200
319
|
* Validates workflow state machine configuration
|
|
201
320
|
* @internal
|