@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.
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 +5 -5
  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/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
- * @remarks
76
- * Walks the schema array and for each field with `fieldtype: 'Doctype'` and a string
77
- * `options` value, looks up the referenced doctype in the registry and:
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
- * - If `cardinality: 'many'`: auto-derives `columns` from the child doctype's schema,
80
- * sets `component: 'ATable'`, `config: { view: 'list' }`, and initializes `rows: []`.
81
- * - Otherwise (default/`cardinality: 'one'`): embeds the child schema as the field's
82
- * `schema` property for 1:1 nested forms.
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
- * Returns a new array does not mutate the original schema.
87
- *
88
- * @param schema - The schema array to resolve
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(schema, visited) {
104
- const seen = visited || new Set();
105
- return schema.map(field => {
106
- // Check for Doctype fieldtype with a string options (slug reference)
107
- if ('fieldtype' in field &&
108
- field.fieldtype === 'Doctype' &&
109
- 'options' in field &&
110
- typeof field.options === 'string') {
111
- const doctypeSlug = field.options;
112
- // Circular reference protection
113
- if (seen.has(doctypeSlug)) {
114
- return { ...field };
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 doctype = this.registry[doctypeSlug];
117
- if (doctype && doctype.schema) {
118
- // Convert Immutable.List to plain array if needed
119
- const childSchema = Array.isArray(doctype.schema) ? doctype.schema : Array.from(doctype.schema);
120
- // Check cardinality to determine handling
121
- const cardinality = 'cardinality' in field ? field.cardinality : undefined;
122
- if (cardinality === 'many') {
123
- // 1:many child table - derive columns, set component, config, rows
124
- const resolved = { ...field };
125
- // Auto-derive columns from child schema fields if not already provided
126
- if (!('columns' in field) || !field.columns) {
127
- resolved.columns = childSchema.map(childField => ({
128
- name: childField.fieldname,
129
- fieldname: childField.fieldname,
130
- label: ('label' in childField && childField.label) || childField.fieldname,
131
- fieldtype: 'fieldtype' in childField ? childField.fieldtype : 'Data',
132
- align: ('align' in childField && childField.align) || 'left',
133
- edit: 'edit' in childField ? childField.edit : true,
134
- width: ('width' in childField && childField.width) || '20ch',
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
- return { ...field };
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: 'many'` → `[]`
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
  }
@@ -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"}