@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.
Files changed (117) hide show
  1. package/README.md +72 -29
  2. package/dist/composable.js +1 -0
  3. package/dist/composables/lazy-link.js +125 -0
  4. package/dist/composables/stonecrop.js +123 -68
  5. package/dist/composables/use-lazy-link-state.js +125 -0
  6. package/dist/composables/use-stonecrop.js +476 -0
  7. package/dist/doctype.js +10 -2
  8. package/dist/field-triggers.js +15 -3
  9. package/dist/index.js +4 -3
  10. package/dist/operation-log-DB-dGNT9.js +593 -0
  11. package/dist/operation-log-DB-dGNT9.js.map +1 -0
  12. package/dist/registry.js +261 -101
  13. package/dist/schema-validator.js +105 -1
  14. package/dist/src/composable.d.ts +11 -0
  15. package/dist/src/composable.d.ts.map +1 -0
  16. package/dist/src/composable.js +477 -0
  17. package/dist/src/composables/lazy-link.d.ts +25 -0
  18. package/dist/src/composables/lazy-link.d.ts.map +1 -0
  19. package/dist/src/composables/operation-log.d.ts +5 -5
  20. package/dist/src/composables/operation-log.d.ts.map +1 -1
  21. package/dist/src/composables/operation-log.js +224 -0
  22. package/dist/src/composables/stonecrop.d.ts +11 -1
  23. package/dist/src/composables/stonecrop.d.ts.map +1 -1
  24. package/dist/src/composables/stonecrop.js +574 -0
  25. package/dist/src/composables/use-lazy-link-state.d.ts +25 -0
  26. package/dist/src/composables/use-lazy-link-state.d.ts.map +1 -0
  27. package/dist/src/composables/use-stonecrop.d.ts +93 -0
  28. package/dist/src/composables/use-stonecrop.d.ts.map +1 -0
  29. package/dist/src/composables/useNestedSchema.d.ts +110 -0
  30. package/dist/src/composables/useNestedSchema.d.ts.map +1 -0
  31. package/dist/src/composables/useNestedSchema.js +155 -0
  32. package/dist/src/doctype.d.ts +9 -1
  33. package/dist/src/doctype.d.ts.map +1 -1
  34. package/dist/src/doctype.js +234 -0
  35. package/dist/src/exceptions.js +16 -0
  36. package/dist/src/field-triggers.d.ts +6 -0
  37. package/dist/src/field-triggers.d.ts.map +1 -1
  38. package/dist/src/field-triggers.js +567 -0
  39. package/dist/src/index.d.ts +3 -2
  40. package/dist/src/index.d.ts.map +1 -1
  41. package/dist/src/index.js +23 -0
  42. package/dist/src/plugins/index.js +96 -0
  43. package/dist/src/registry.d.ts +102 -23
  44. package/dist/src/registry.d.ts.map +1 -1
  45. package/dist/src/registry.js +246 -0
  46. package/dist/src/schema-validator.d.ts +8 -1
  47. package/dist/src/schema-validator.d.ts.map +1 -1
  48. package/dist/src/schema-validator.js +315 -0
  49. package/dist/src/stonecrop.d.ts +73 -28
  50. package/dist/src/stonecrop.d.ts.map +1 -1
  51. package/dist/src/stonecrop.js +339 -0
  52. package/dist/src/stores/data.d.ts +11 -0
  53. package/dist/src/stores/data.d.ts.map +1 -0
  54. package/dist/src/stores/hst.d.ts +5 -75
  55. package/dist/src/stores/hst.d.ts.map +1 -1
  56. package/dist/src/stores/hst.js +495 -0
  57. package/dist/src/stores/index.js +12 -0
  58. package/dist/src/stores/operation-log.d.ts +14 -14
  59. package/dist/src/stores/operation-log.d.ts.map +1 -1
  60. package/dist/src/stores/operation-log.js +568 -0
  61. package/dist/src/stores/xstate.d.ts +31 -0
  62. package/dist/src/stores/xstate.d.ts.map +1 -0
  63. package/dist/src/tsdoc-metadata.json +11 -0
  64. package/dist/src/types/composable.d.ts +50 -12
  65. package/dist/src/types/composable.d.ts.map +1 -1
  66. package/dist/src/types/doctype.d.ts +6 -7
  67. package/dist/src/types/doctype.d.ts.map +1 -1
  68. package/dist/src/types/field-triggers.d.ts +1 -1
  69. package/dist/src/types/field-triggers.d.ts.map +1 -1
  70. package/dist/src/types/field-triggers.js +4 -0
  71. package/dist/src/types/hst.d.ts +70 -0
  72. package/dist/src/types/hst.d.ts.map +1 -0
  73. package/dist/src/types/index.d.ts +1 -0
  74. package/dist/src/types/index.d.ts.map +1 -1
  75. package/dist/src/types/index.js +4 -0
  76. package/dist/src/types/operation-log.d.ts +4 -4
  77. package/dist/src/types/operation-log.d.ts.map +1 -1
  78. package/dist/src/types/operation-log.js +0 -0
  79. package/dist/src/types/registry.js +0 -0
  80. package/dist/src/types/schema-validator.d.ts +2 -0
  81. package/dist/src/types/schema-validator.d.ts.map +1 -1
  82. package/dist/src/utils.d.ts +24 -0
  83. package/dist/src/utils.d.ts.map +1 -0
  84. package/dist/stonecrop.d.ts +317 -99
  85. package/dist/stonecrop.js +2191 -1897
  86. package/dist/stonecrop.js.map +1 -1
  87. package/dist/stonecrop.umd.cjs +6 -0
  88. package/dist/stonecrop.umd.cjs.map +1 -0
  89. package/dist/stores/data.js +7 -0
  90. package/dist/stores/hst.js +27 -25
  91. package/dist/stores/operation-log.js +59 -47
  92. package/dist/stores/xstate.js +29 -0
  93. package/dist/tests/setup.d.ts +5 -0
  94. package/dist/tests/setup.d.ts.map +1 -0
  95. package/dist/tests/setup.js +15 -0
  96. package/dist/types/hst.js +0 -0
  97. package/dist/types/index.js +1 -0
  98. package/dist/utils.js +46 -0
  99. package/package.json +4 -4
  100. package/src/composables/lazy-link.ts +146 -0
  101. package/src/composables/operation-log.ts +1 -1
  102. package/src/composables/stonecrop.ts +142 -73
  103. package/src/doctype.ts +13 -4
  104. package/src/field-triggers.ts +18 -4
  105. package/src/index.ts +4 -2
  106. package/src/registry.ts +289 -111
  107. package/src/schema-validator.ts +120 -1
  108. package/src/stonecrop.ts +230 -106
  109. package/src/stores/hst.ts +29 -104
  110. package/src/stores/operation-log.ts +64 -50
  111. package/src/types/composable.ts +55 -12
  112. package/src/types/doctype.ts +6 -7
  113. package/src/types/field-triggers.ts +1 -1
  114. package/src/types/hst.ts +77 -0
  115. package/src/types/index.ts +1 -0
  116. package/src/types/operation-log.ts +4 -4
  117. 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
- * @remarks
91
- * Walks the schema array and for each field with `fieldtype: 'Doctype'` and a string
92
- * `options` value, looks up the referenced doctype in the registry and:
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
- * - If `cardinality: 'many'`: auto-derives `columns` from the child doctype's schema,
95
- * sets `component: 'ATable'`, `config: { view: 'list' }`, and initializes `rows: []`.
96
- * - Otherwise (default/`cardinality: 'one'`): embeds the child schema as the field's
97
- * `schema` property for 1:1 nested forms.
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
- * Returns a new array does not mutate the original schema.
102
- *
103
- * @param schema - The schema array to resolve
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(schema: SchemaTypes[], visited?: Set<string>): SchemaTypes[] {
119
- const seen = visited || new Set<string>()
120
-
121
- return schema.map(field => {
122
- // Check for Doctype fieldtype with a string options (slug reference)
123
- if (
124
- 'fieldtype' in field &&
125
- field.fieldtype === 'Doctype' &&
126
- 'options' in field &&
127
- typeof field.options === 'string'
128
- ) {
129
- const doctypeSlug = field.options
130
-
131
- // Circular reference protection
132
- if (seen.has(doctypeSlug)) {
133
- return { ...field }
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 doctype = this.registry[doctypeSlug]
137
- if (doctype && doctype.schema) {
138
- // Convert Immutable.List to plain array if needed
139
- const childSchema: SchemaTypes[] = Array.isArray(doctype.schema) ? doctype.schema : Array.from(doctype.schema)
140
-
141
- // Check cardinality to determine handling
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
- return { ...field, schema: resolvedChild }
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
- return { ...field }
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: 'many'` → `[]`
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 ? (field.fieldtype as string) : 'Data'
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 = {}
@@ -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