@stonecrop/stonecrop 0.10.16 → 0.11.1

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 (71) hide show
  1. package/README.md +72 -29
  2. package/dist/composables/lazy-link.js +125 -0
  3. package/dist/composables/stonecrop.js +123 -68
  4. package/dist/doctype.js +10 -2
  5. package/dist/field-triggers.js +15 -3
  6. package/dist/index.js +4 -3
  7. package/dist/registry.js +261 -101
  8. package/dist/schema-validator.js +105 -1
  9. package/dist/src/composables/lazy-link.d.ts +25 -0
  10. package/dist/src/composables/lazy-link.d.ts.map +1 -0
  11. package/dist/src/composables/operation-log.d.ts +5 -5
  12. package/dist/src/composables/operation-log.d.ts.map +1 -1
  13. package/dist/src/composables/stonecrop.d.ts +11 -1
  14. package/dist/src/composables/stonecrop.d.ts.map +1 -1
  15. package/dist/src/doctype.d.ts +9 -1
  16. package/dist/src/doctype.d.ts.map +1 -1
  17. package/dist/src/field-triggers.d.ts +6 -0
  18. package/dist/src/field-triggers.d.ts.map +1 -1
  19. package/dist/src/index.d.ts +3 -2
  20. package/dist/src/index.d.ts.map +1 -1
  21. package/dist/src/registry.d.ts +102 -23
  22. package/dist/src/registry.d.ts.map +1 -1
  23. package/dist/src/schema-validator.d.ts +8 -1
  24. package/dist/src/schema-validator.d.ts.map +1 -1
  25. package/dist/src/stonecrop.d.ts +73 -28
  26. package/dist/src/stonecrop.d.ts.map +1 -1
  27. package/dist/src/stores/hst.d.ts +5 -75
  28. package/dist/src/stores/hst.d.ts.map +1 -1
  29. package/dist/src/stores/operation-log.d.ts +14 -14
  30. package/dist/src/stores/operation-log.d.ts.map +1 -1
  31. package/dist/src/types/composable.d.ts +50 -12
  32. package/dist/src/types/composable.d.ts.map +1 -1
  33. package/dist/src/types/doctype.d.ts +6 -7
  34. package/dist/src/types/doctype.d.ts.map +1 -1
  35. package/dist/src/types/field-triggers.d.ts +1 -1
  36. package/dist/src/types/field-triggers.d.ts.map +1 -1
  37. package/dist/src/types/hst.d.ts +70 -0
  38. package/dist/src/types/hst.d.ts.map +1 -0
  39. package/dist/src/types/index.d.ts +1 -0
  40. package/dist/src/types/index.d.ts.map +1 -1
  41. package/dist/src/types/operation-log.d.ts +4 -4
  42. package/dist/src/types/operation-log.d.ts.map +1 -1
  43. package/dist/src/types/schema-validator.d.ts +2 -0
  44. package/dist/src/types/schema-validator.d.ts.map +1 -1
  45. package/dist/stonecrop.d.ts +317 -99
  46. package/dist/stonecrop.js +2191 -1897
  47. package/dist/stonecrop.js.map +1 -1
  48. package/dist/stores/hst.js +27 -25
  49. package/dist/stores/operation-log.js +59 -47
  50. package/dist/types/hst.js +0 -0
  51. package/dist/types/index.js +1 -0
  52. package/package.json +5 -5
  53. package/src/composables/lazy-link.ts +146 -0
  54. package/src/composables/operation-log.ts +1 -1
  55. package/src/composables/stonecrop.ts +142 -73
  56. package/src/doctype.ts +13 -4
  57. package/src/field-triggers.ts +18 -4
  58. package/src/index.ts +4 -2
  59. package/src/registry.ts +289 -111
  60. package/src/schema-validator.ts +120 -1
  61. package/src/stonecrop.ts +230 -106
  62. package/src/stores/hst.ts +29 -104
  63. package/src/stores/operation-log.ts +64 -50
  64. package/src/types/composable.ts +55 -12
  65. package/src/types/doctype.ts +6 -7
  66. package/src/types/field-triggers.ts +1 -1
  67. package/src/types/hst.ts +77 -0
  68. package/src/types/index.ts +1 -0
  69. package/src/types/operation-log.ts +4 -4
  70. package/src/types/schema-validator.ts +2 -0
  71. package/dist/stonecrop.css +0 -1
@@ -39,6 +39,14 @@ export class FieldTriggerEngine {
39
39
  registerAction(name, fn) {
40
40
  this.globalActions.set(name, fn);
41
41
  }
42
+ /**
43
+ * Look up a registered action function by name.
44
+ * Returns `undefined` if the action has not been registered.
45
+ * @param name - The action name
46
+ */
47
+ getAction(name) {
48
+ return this.globalActions.get(name);
49
+ }
42
50
  /**
43
51
  * Register a global XState transition action function
44
52
  * @param name - The name of the transition action
@@ -188,11 +196,13 @@ export class FieldTriggerEngine {
188
196
  }
189
197
  const totalExecutionTime = performance.now() - startTime;
190
198
  // Call global error handler if configured and errors occurred
191
- const failedResults = actionResults.filter(r => !r.success);
199
+ const failedResults = actionResults.filter(r => !r.success && r.error != null);
192
200
  if (failedResults.length > 0 && this.options.errorHandler) {
193
201
  for (const failedResult of failedResults) {
194
202
  try {
195
- this.options.errorHandler(failedResult.error, context, failedResult.action);
203
+ if (failedResult.error) {
204
+ this.options.errorHandler(failedResult.error, context, failedResult.action);
205
+ }
196
206
  }
197
207
  catch (handlerError) {
198
208
  // eslint-disable-next-line no-console
@@ -253,7 +263,9 @@ export class FieldTriggerEngine {
253
263
  for (const failedResult of failedResults) {
254
264
  try {
255
265
  // Call with FieldChangeContext (base context type)
256
- this.options.errorHandler(failedResult.error, context, failedResult.action);
266
+ if (failedResult.error) {
267
+ this.options.errorHandler(failedResult.error, context, failedResult.action);
268
+ }
257
269
  }
258
270
  catch (handlerError) {
259
271
  // eslint-disable-next-line no-console
package/dist/index.js CHANGED
@@ -1,17 +1,18 @@
1
+ import { useLazyLink } from './composables/lazy-link';
1
2
  import { useStonecrop } from './composables/stonecrop';
2
3
  import { useOperationLog, useUndoRedoShortcuts, withBatch } from './composables/operation-log';
3
4
  import Doctype from './doctype';
4
5
  import { FieldTriggerEngine, getGlobalTriggerEngine, markOperationIrreversible, registerGlobalAction, registerTransitionAction, setFieldRollback, triggerTransition, } from './field-triggers';
5
6
  import plugin from './plugins';
6
7
  import Registry from './registry';
7
- import { Stonecrop, collectNestedData } from './stonecrop';
8
+ import { Stonecrop, getStonecrop } from './stonecrop';
8
9
  import { HST, createHST } from './stores/hst';
9
10
  import { useOperationLogStore } from './stores/operation-log';
10
11
  import { SchemaValidator, createValidator, validateSchema } from './schema-validator';
11
12
  import { ValidationSeverity } from './types/schema-validator';
12
13
  // Export enum as value (enums need runtime export, not just type)
13
14
  export { ValidationSeverity };
14
- export { Doctype, Registry, Stonecrop, useStonecrop,
15
+ export { Doctype, Registry, Stonecrop, useLazyLink, useStonecrop,
15
16
  // HST exports for advanced usage
16
17
  HST, createHST,
17
18
  // Field trigger system exports
@@ -21,6 +22,6 @@ SchemaValidator, createValidator, validateSchema,
21
22
  // Operation log exports
22
23
  useOperationLog, useOperationLogStore, useUndoRedoShortcuts, withBatch,
23
24
  // Utility functions
24
- collectNestedData, };
25
+ getStonecrop, };
25
26
  // Default export is the Vue plugin
26
27
  export default plugin;
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,25 @@
1
+ import Doctype from '../doctype';
2
+ import type { LazyLink } from '../types/composable';
3
+ /**
4
+ * Get the lazy link state for a specific link field on a doctype record.
5
+ *
6
+ * This composable provides reactive state for lazy-loaded links:
7
+ * - `loading`: true while fetching
8
+ * - `loaded`: true after successful fetch (permanent until reload)
9
+ * - `error`: error state if any
10
+ * - `reload()`: explicitly trigger a fetch
11
+ * - `data`: computed from HST, or undefined if not loaded
12
+ *
13
+ * The reload() function respects the link's fetch strategy:
14
+ * - `sync`: fetches via GraphQL query through fetchNestedData
15
+ * - `lazy`: fetches via GraphQL query through fetchNestedData
16
+ * - `custom`: invokes the serialized handler function directly
17
+ *
18
+ * @param doctype - The doctype instance
19
+ * @param recordId - The record ID
20
+ * @param linkFieldname - The link fieldname to load
21
+ * @returns LazyLink with loading, loaded, error, reload, and data
22
+ * @public
23
+ */
24
+ export declare function useLazyLink(doctype: Doctype, recordId: string, linkFieldname: string): LazyLink;
25
+ //# sourceMappingURL=lazy-link.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lazy-link.d.ts","sourceRoot":"","sources":["../../../src/composables/lazy-link.ts"],"names":[],"mappings":"AAGA,OAAO,OAAO,MAAM,YAAY,CAAA;AAEhC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAEnD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,QAAQ,CAqH/F"}