@stonecrop/stonecrop 0.12.8 → 0.13.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 (55) hide show
  1. package/dist/composable.js +1 -0
  2. package/dist/composables/lazy-link.js +125 -0
  3. package/dist/composables/operation-log.js +224 -0
  4. package/dist/composables/stonecrop.js +504 -0
  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 +242 -0
  8. package/dist/exceptions.js +16 -0
  9. package/dist/field-triggers.js +575 -0
  10. package/dist/index.js +27 -0
  11. package/dist/operation-log-DB-dGNT9.js +593 -0
  12. package/dist/operation-log-DB-dGNT9.js.map +1 -0
  13. package/dist/plugins/index.js +99 -0
  14. package/dist/registry.js +423 -0
  15. package/dist/schema-validator.js +407 -0
  16. package/dist/src/composable.d.ts +11 -0
  17. package/dist/src/composable.d.ts.map +1 -0
  18. package/dist/src/composable.js +477 -0
  19. package/dist/src/composables/use-lazy-link-state.d.ts +25 -0
  20. package/dist/src/composables/use-lazy-link-state.d.ts.map +1 -0
  21. package/dist/src/composables/use-stonecrop.d.ts +93 -0
  22. package/dist/src/composables/use-stonecrop.d.ts.map +1 -0
  23. package/dist/src/composables/useNestedSchema.d.ts +110 -0
  24. package/dist/src/composables/useNestedSchema.d.ts.map +1 -0
  25. package/dist/src/composables/useNestedSchema.js +155 -0
  26. package/dist/src/stores/data.d.ts +11 -0
  27. package/dist/src/stores/data.d.ts.map +1 -0
  28. package/dist/src/stores/xstate.d.ts +31 -0
  29. package/dist/src/stores/xstate.d.ts.map +1 -0
  30. package/dist/src/tsdoc-metadata.json +11 -0
  31. package/dist/src/utils.d.ts +24 -0
  32. package/dist/src/utils.d.ts.map +1 -0
  33. package/dist/stonecrop.css +1 -0
  34. package/dist/stonecrop.umd.cjs +6 -0
  35. package/dist/stonecrop.umd.cjs.map +1 -0
  36. package/dist/stores/data.js +7 -0
  37. package/dist/stores/hst.js +496 -0
  38. package/dist/stores/index.js +12 -0
  39. package/dist/stores/operation-log.js +580 -0
  40. package/dist/stores/xstate.js +29 -0
  41. package/dist/tests/setup.d.ts +5 -0
  42. package/dist/tests/setup.d.ts.map +1 -0
  43. package/dist/tests/setup.js +15 -0
  44. package/dist/types/composable.js +0 -0
  45. package/dist/types/doctype.js +0 -0
  46. package/dist/types/field-triggers.js +4 -0
  47. package/dist/types/hst.js +0 -0
  48. package/dist/types/index.js +10 -0
  49. package/dist/types/operation-log.js +0 -0
  50. package/dist/types/plugin.js +0 -0
  51. package/dist/types/registry.js +0 -0
  52. package/dist/types/schema-validator.js +13 -0
  53. package/dist/types/stonecrop.js +0 -0
  54. package/dist/utils.js +46 -0
  55. package/package.json +4 -4
@@ -0,0 +1,423 @@
1
+ import { getGlobalTriggerEngine } from './field-triggers';
2
+ /**
3
+ * Stonecrop Registry class
4
+ * @public
5
+ */
6
+ export default class Registry {
7
+ /**
8
+ * The root Registry instance
9
+ */
10
+ static _root;
11
+ /**
12
+ * The name of the Registry instance
13
+ *
14
+ * @defaultValue 'Registry'
15
+ */
16
+ name = 'Registry';
17
+ /**
18
+ * The registry property contains a collection of doctypes
19
+ *
20
+ * @defaultValue `{}`
21
+ * @see {@link Doctype}
22
+ */
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;
40
+ /**
41
+ * The Vue router instance
42
+ * @see {@link https://router.vuejs.org/}
43
+ */
44
+ router;
45
+ /**
46
+ * Creates a new Registry instance (singleton pattern)
47
+ * @param router - Optional Vue router instance for route management
48
+ * @param getMeta - Optional function to fetch doctype metadata from an API
49
+ */
50
+ constructor(router, getMeta) {
51
+ if (Registry._root) {
52
+ return Registry._root;
53
+ }
54
+ Registry._root = this;
55
+ this.router = router;
56
+ this.getMeta = getMeta;
57
+ }
58
+ /**
59
+ * The getMeta function fetches doctype metadata from an API based on route context
60
+ * @see {@link Doctype}
61
+ */
62
+ getMeta;
63
+ /**
64
+ * Get doctype metadata
65
+ * @param doctype - The doctype to fetch metadata for
66
+ * @returns The doctype metadata
67
+ * @see {@link Doctype}
68
+ */
69
+ addDoctype(doctype) {
70
+ if (!(doctype.slug in this.registry)) {
71
+ this.registry[doctype.slug] = doctype;
72
+ this._ancestorIndexDirty = true;
73
+ }
74
+ // Register actions (including field triggers) with the field trigger engine
75
+ const triggerEngine = getGlobalTriggerEngine();
76
+ // Register under both doctype name and slug to handle different lookup patterns
77
+ triggerEngine.registerDoctypeActions(doctype.doctype, doctype.actions);
78
+ if (doctype.slug !== doctype.doctype) {
79
+ triggerEngine.registerDoctypeActions(doctype.slug, doctype.actions);
80
+ }
81
+ if (doctype.component && this.router && !this.router.hasRoute(doctype.doctype)) {
82
+ this.router.addRoute({
83
+ path: `/${doctype.slug}`,
84
+ name: doctype.slug,
85
+ component: doctype.component,
86
+ });
87
+ }
88
+ }
89
+ /**
90
+ * Resolve nested Doctype fields in a schema by embedding child schemas inline.
91
+ *
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.
95
+ *
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' }`.
100
+ * - `cardinality: 'one'` or `'atMostOne'`: embeds the target schema as the entry's
101
+ * `schema` property, sets `component` to `link.component ?? 'AForm'`.
102
+ *
103
+ * Recurses for deeply nested doctypes. Circular references are protected against.
104
+ * Returns a new array — does not mutate the original.
105
+ *
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
109
+ *
110
+ * @public
111
+ */
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;
145
+ }
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
+ // Extract properties consumed by resolution; preserve everything else
154
+ // TODO: options and cardinality are untyped runtime properties on link fields; add them to
155
+ // FormSchema (or a dedicated link field type) to remove this cast
156
+ const { fieldtype: _ft, options: _opt, cardinality: _card, ...fieldRest } = field;
157
+ if (link.cardinality === 'noneOrMany' || link.cardinality === 'atLeastOne') {
158
+ // Many relationship — build table config
159
+ resolvedFields.push(this.buildTableConfig({ ...fieldRest, label: fieldRest.label || field.fieldname }, childSchema, link.component));
160
+ }
161
+ else {
162
+ // One relationship — embed form schema
163
+ // TODO: remove assertion once resolved link output has a dedicated type separate from input schema
164
+ resolvedFields.push({
165
+ ...fieldRest,
166
+ label: fieldRest.label || field.fieldname,
167
+ component: link.component || fieldRest.component || 'AForm',
168
+ schema: childSchema,
169
+ });
170
+ }
171
+ }
172
+ else if ('schema' in field && Array.isArray(field.schema)) {
173
+ // Fieldset — recursively resolve nested fields
174
+ const resolvedChildren = this.resolveFields(field.schema, linksByFieldname, seen);
175
+ resolvedFields.push({ ...field, schema: resolvedChildren });
176
+ }
177
+ else {
178
+ // Scalar field — copy as-is
179
+ resolvedFields.push({ ...field });
180
+ }
181
+ }
182
+ seen.delete(slug);
183
+ return resolvedFields;
184
+ }
185
+ /**
186
+ * Recursively resolve a flat fields array using the provided link context.
187
+ * Used by resolveSchema to handle fieldset children.
188
+ * @internal
189
+ */
190
+ resolveFields(fields, links, visited) {
191
+ const resolved = [];
192
+ for (const field of fields) {
193
+ if ('fieldtype' in field && field.fieldtype === 'Link') {
194
+ const link = links.get(field.fieldname);
195
+ if (!link) {
196
+ resolved.push({ ...field });
197
+ continue;
198
+ }
199
+ const targetDoctype = this.registry[link.target];
200
+ if (!targetDoctype) {
201
+ resolved.push({ ...field });
202
+ continue;
203
+ }
204
+ const childSchema = this.resolveSchema(targetDoctype, new Set(visited));
205
+ const { fieldtype: _ft, options: _opt, cardinality: _card, ...fieldRest } = field;
206
+ if (link.cardinality === 'noneOrMany' || link.cardinality === 'atLeastOne') {
207
+ resolved.push(this.buildTableConfig({ ...fieldRest, label: fieldRest.label || field.fieldname }, childSchema, link.component));
208
+ }
209
+ else {
210
+ // TODO: remove assertion once resolved link output has a dedicated type separate from input schema
211
+ resolved.push({
212
+ ...fieldRest,
213
+ label: fieldRest.label || field.fieldname,
214
+ component: link.component || fieldRest.component || 'AForm',
215
+ schema: childSchema,
216
+ });
217
+ }
218
+ }
219
+ else if ('schema' in field && Array.isArray(field.schema)) {
220
+ resolved.push({ ...field, schema: this.resolveFields(field.schema, links, visited) });
221
+ }
222
+ else {
223
+ resolved.push({ ...field });
224
+ }
225
+ }
226
+ return resolved;
227
+ }
228
+ /**
229
+ * Build an ATable configuration from a field and child schema.
230
+ * Data-model properties from the source field are preserved via the spread `field` argument.
231
+ * @internal
232
+ */
233
+ buildTableConfig(field, childSchema, component) {
234
+ const resolved = {
235
+ ...field,
236
+ fieldname: field.fieldname,
237
+ component: component || field.component || 'ATable',
238
+ columns: field.columns,
239
+ config: field.config,
240
+ };
241
+ if (!resolved.columns) {
242
+ resolved.columns = childSchema
243
+ .filter(childField => 'fieldtype' in childField)
244
+ .map(childField => ({
245
+ name: childField.fieldname,
246
+ label: ('label' in childField && childField.label) || childField.fieldname,
247
+ fieldtype: 'fieldtype' in childField ? childField.fieldtype : 'Data',
248
+ align: 'align' in childField ? childField.align : 'left',
249
+ edit: 'edit' in childField ? childField.edit : true,
250
+ width: ('width' in childField && childField.width) || '20ch',
251
+ }));
252
+ }
253
+ if (!resolved.config) {
254
+ resolved.config = { view: 'list' };
255
+ }
256
+ return resolved;
257
+ }
258
+ /**
259
+ * Initialize a new record with default values based on a schema.
260
+ *
261
+ * @remarks
262
+ * Creates a plain object with keys from the schema's fieldnames and default values
263
+ * derived from each field's `fieldtype`:
264
+ * - Data, Text → `''`
265
+ * - Check → `false`
266
+ * - Int, Float, Decimal, Currency, Quantity → `0`
267
+ * - JSON → `{}`
268
+ * - Doctype with `cardinality: 'noneOrMany'` or `'atLeastOne'` → `[]`
269
+ * - Doctype without `cardinality` or `cardinality: 'one'` → recursively initializes nested record
270
+ * - All others → `null`
271
+ *
272
+ * For Doctype fields with a resolved `schema` array (cardinality: 'one'), recursively
273
+ * initializes the nested record.
274
+ *
275
+ * @param schema - The schema array to derive defaults from
276
+ * @returns A plain object with default values for each field
277
+ *
278
+ * @example
279
+ * ```ts
280
+ * const defaults = registry.initializeRecord(addressSchema)
281
+ * // { street: '', city: '', state: '', zip_code: '' }
282
+ * ```
283
+ *
284
+ * @public
285
+ */
286
+ initializeRecord(schema) {
287
+ const record = {};
288
+ schema.forEach(field => {
289
+ const fieldtype = 'fieldtype' in field ? field.fieldtype : 'Data';
290
+ const cardinality = 'cardinality' in field ? field.cardinality : undefined;
291
+ // 1:many — cardinality signals an array
292
+ if (cardinality === 'noneOrMany' || cardinality === 'atLeastOne') {
293
+ record[field.fieldname] = [];
294
+ return;
295
+ }
296
+ // Resolved 1:many table entry — structural detection via columns
297
+ // TODO: replace 'columns' presence check with a type discriminant on SchemaTypes once one exists
298
+ if ('columns' in field) {
299
+ record[field.fieldname] = [];
300
+ return;
301
+ }
302
+ // Resolved 1:1 link entry — has schema property (e.g., FieldsetSchema with nested schema)
303
+ if ('schema' in field && Array.isArray(field.schema)) {
304
+ record[field.fieldname] = this.initializeRecord(field.schema);
305
+ return;
306
+ }
307
+ switch (fieldtype) {
308
+ case 'Data':
309
+ case 'Text':
310
+ case 'Code':
311
+ record[field.fieldname] = '';
312
+ break;
313
+ case 'Check':
314
+ record[field.fieldname] = false;
315
+ break;
316
+ case 'Int':
317
+ case 'Float':
318
+ case 'Decimal':
319
+ case 'Currency':
320
+ case 'Quantity':
321
+ record[field.fieldname] = 0;
322
+ break;
323
+ case 'JSON':
324
+ record[field.fieldname] = {};
325
+ break;
326
+ default:
327
+ record[field.fieldname] = null;
328
+ }
329
+ });
330
+ return record;
331
+ }
332
+ /**
333
+ * Get a registered doctype by slug
334
+ * @param slug - The doctype slug to look up
335
+ * @returns The Doctype instance if found, or undefined
336
+ * @public
337
+ */
338
+ getDoctype(slug) {
339
+ return this.registry[slug];
340
+ }
341
+ /**
342
+ * Get all links declared on a doctype.
343
+ *
344
+ * @param doctypeSlug - The doctype slug to get links for
345
+ * @returns Array of link declarations with fieldname, or empty array if none
346
+ *
347
+ * @example
348
+ * ```ts
349
+ * const links = registry.getDescendantLinks('recipe')
350
+ * // [{ fieldname: 'tasks', target: 'recipe-task', cardinality: 'noneOrMany', backlink: 'recipe' }]
351
+ * ```
352
+ *
353
+ * @public
354
+ */
355
+ getDescendantLinks(doctypeSlug) {
356
+ const doctype = this.registry[doctypeSlug];
357
+ if (!doctype?.links)
358
+ return [];
359
+ return Object.entries(doctype.links).map(([fieldname, link]) => ({
360
+ ...link,
361
+ fieldname,
362
+ }));
363
+ }
364
+ /**
365
+ * Get links on other doctypes that target the given doctype.
366
+ *
367
+ * @param doctypeSlug - The doctype slug to find ancestor links for
368
+ * @returns Array of link declarations with fieldname and declaring doctype slug, or empty array
369
+ *
370
+ * @example
371
+ * ```ts
372
+ * const ancestors = registry.getAncestorLinks('recipe-task')
373
+ * // [{ fieldname: 'tasks', target: 'recipe-task', cardinality: 'noneOrMany', backlink: 'recipe', doctype: 'recipe' }]
374
+ * ```
375
+ *
376
+ * @public
377
+ */
378
+ getAncestorLinks(doctypeSlug) {
379
+ this._ensureAncestorIndex();
380
+ const results = [];
381
+ for (const [_backlink, entries] of this._ancestorIndex) {
382
+ for (const { slug: declaringSlug, fieldname } of entries) {
383
+ const declaringDoctype = this.registry[declaringSlug];
384
+ if (!declaringDoctype?.links)
385
+ continue;
386
+ const link = declaringDoctype.links[fieldname];
387
+ if (link?.target === doctypeSlug) {
388
+ results.push({
389
+ ...link,
390
+ fieldname,
391
+ doctype: declaringSlug,
392
+ });
393
+ }
394
+ }
395
+ }
396
+ return results;
397
+ }
398
+ /**
399
+ * Ensure the ancestor index is up to date
400
+ * @internal
401
+ */
402
+ _ensureAncestorIndex() {
403
+ if (!this._ancestorIndexDirty)
404
+ return;
405
+ this._ancestorIndexDirty = false;
406
+ this._ancestorIndex.clear();
407
+ for (const [slug, doctype] of Object.entries(this.registry)) {
408
+ if (!doctype.links)
409
+ continue;
410
+ for (const [fieldname, link] of Object.entries(doctype.links)) {
411
+ if (link.backlink) {
412
+ const existing = this._ancestorIndex.get(link.backlink);
413
+ if (existing) {
414
+ existing.push({ slug, fieldname });
415
+ }
416
+ else {
417
+ this._ancestorIndex.set(link.backlink, [{ slug, fieldname }]);
418
+ }
419
+ }
420
+ }
421
+ }
422
+ }
423
+ }