@stonecrop/stonecrop 0.12.7 → 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,407 @@
1
+ /**
2
+ * Schema Validation Utilities
3
+ * Validates Stonecrop schemas for integrity and consistency
4
+ * @packageDocumentation
5
+ */
6
+ import { getGlobalTriggerEngine } from './field-triggers';
7
+ import { ValidationSeverity } from './types/schema-validator';
8
+ /**
9
+ * Schema validator class
10
+ * @public
11
+ */
12
+ export class SchemaValidator {
13
+ options;
14
+ /**
15
+ * Creates a new SchemaValidator instance
16
+ * @param options - Validator configuration options
17
+ */
18
+ constructor(options = {}) {
19
+ this.options = {
20
+ registry: options.registry || null,
21
+ validateLinkTargets: options.validateLinkTargets ?? true,
22
+ validateLinks: options.validateLinks ?? true,
23
+ validateActions: options.validateActions ?? true,
24
+ validateWorkflows: options.validateWorkflows ?? true,
25
+ validateRequiredProperties: options.validateRequiredProperties ?? true,
26
+ };
27
+ }
28
+ /**
29
+ * Validates a complete doctype schema
30
+ * @param doctype - Doctype name
31
+ * @param schema - Schema fields (List or Array)
32
+ * @param workflow - Optional workflow configuration
33
+ * @param actions - Optional actions map
34
+ * @param links - Optional links object
35
+ * @returns Validation result
36
+ */
37
+ validate(doctype, schema, workflow, actions, links) {
38
+ const issues = [];
39
+ // Convert schema to array for easier iteration
40
+ const schemaArray = schema ? (Array.isArray(schema) ? schema : schema.toArray()) : [];
41
+ // Validate required properties
42
+ if (this.options.validateRequiredProperties) {
43
+ issues.push(...this.validateRequiredProperties(doctype, schemaArray));
44
+ }
45
+ // Validate Link field targets
46
+ if (this.options.validateLinkTargets && this.options.registry) {
47
+ issues.push(...this.validateLinkFields(doctype, schemaArray, this.options.registry));
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
+ }
53
+ // Validate workflow configuration
54
+ if (this.options.validateWorkflows && workflow) {
55
+ issues.push(...this.validateWorkflow(doctype, workflow));
56
+ }
57
+ // Validate action registration
58
+ if (this.options.validateActions && actions) {
59
+ const actionsMap = actions instanceof Map ? actions : actions.toObject();
60
+ issues.push(...this.validateActionRegistration(doctype, actionsMap));
61
+ }
62
+ // Calculate counts
63
+ const errorCount = issues.filter(i => i.severity === ValidationSeverity.ERROR).length;
64
+ const warningCount = issues.filter(i => i.severity === ValidationSeverity.WARNING).length;
65
+ const infoCount = issues.filter(i => i.severity === ValidationSeverity.INFO).length;
66
+ return {
67
+ valid: errorCount === 0,
68
+ issues,
69
+ errorCount,
70
+ warningCount,
71
+ infoCount,
72
+ };
73
+ }
74
+ /**
75
+ * Validates that required schema properties are present
76
+ * @internal
77
+ */
78
+ validateRequiredProperties(doctype, schema) {
79
+ const issues = [];
80
+ for (const field of schema) {
81
+ // Check for fieldname
82
+ if (!field.fieldname) {
83
+ issues.push({
84
+ severity: ValidationSeverity.ERROR,
85
+ rule: 'required-fieldname',
86
+ message: 'Field is missing required property: fieldname',
87
+ doctype,
88
+ context: { field },
89
+ });
90
+ continue;
91
+ }
92
+ // Check for component or fieldtype
93
+ if (!field.component && !('fieldtype' in field)) {
94
+ issues.push({
95
+ severity: ValidationSeverity.ERROR,
96
+ rule: 'required-component-or-fieldtype',
97
+ message: `Field "${field.fieldname}" must have either component or fieldtype property`,
98
+ doctype,
99
+ fieldname: field.fieldname,
100
+ });
101
+ }
102
+ // Validate nested schemas (recursively)
103
+ if ('schema' in field) {
104
+ const nestedSchema = field.schema;
105
+ const nestedArray = (Array.isArray(nestedSchema) ? nestedSchema : nestedSchema.toArray?.() || []);
106
+ issues.push(...this.validateRequiredProperties(doctype, nestedArray));
107
+ }
108
+ }
109
+ return issues;
110
+ }
111
+ /**
112
+ * Validates Link field targets exist in registry
113
+ * @internal
114
+ */
115
+ validateLinkFields(doctype, schema, registry) {
116
+ const issues = [];
117
+ for (const field of schema) {
118
+ const fieldtype = 'fieldtype' in field ? field.fieldtype : undefined;
119
+ // Check Link fields
120
+ if (fieldtype === 'Link') {
121
+ const options = 'options' in field ? field.options : undefined;
122
+ if (!options) {
123
+ issues.push({
124
+ severity: ValidationSeverity.ERROR,
125
+ rule: 'link-missing-options',
126
+ message: `Link field "${field.fieldname}" is missing options property (target doctype)`,
127
+ doctype,
128
+ fieldname: field.fieldname,
129
+ });
130
+ continue;
131
+ }
132
+ // Check if target doctype exists in registry
133
+ // Options should be a string representing the target doctype name
134
+ const targetDoctype = typeof options === 'string' ? options : '';
135
+ if (!targetDoctype) {
136
+ issues.push({
137
+ severity: ValidationSeverity.ERROR,
138
+ rule: 'link-invalid-options',
139
+ message: `Link field "${field.fieldname}" has invalid options format (expected string doctype name)`,
140
+ doctype,
141
+ fieldname: field.fieldname,
142
+ });
143
+ continue;
144
+ }
145
+ const targetMeta = registry.registry[targetDoctype] || registry.registry[targetDoctype.toLowerCase()];
146
+ if (!targetMeta) {
147
+ issues.push({
148
+ severity: ValidationSeverity.ERROR,
149
+ rule: 'link-invalid-target',
150
+ message: `Link field "${field.fieldname}" references non-existent doctype: "${targetDoctype}"`,
151
+ doctype,
152
+ fieldname: field.fieldname,
153
+ context: { targetDoctype },
154
+ });
155
+ }
156
+ }
157
+ // Recursively check nested schemas
158
+ if ('schema' in field) {
159
+ const nestedSchema = field.schema;
160
+ const nestedArray = (Array.isArray(nestedSchema) ? nestedSchema : nestedSchema.toArray?.() || []);
161
+ issues.push(...this.validateLinkFields(doctype, nestedArray, registry));
162
+ }
163
+ }
164
+ return issues;
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
+ }
264
+ /**
265
+ * Validates workflow state machine configuration
266
+ * @internal
267
+ */
268
+ validateWorkflow(doctype, workflow) {
269
+ const issues = [];
270
+ // Check for initial state
271
+ if (!workflow.initial && !workflow.type) {
272
+ issues.push({
273
+ severity: ValidationSeverity.WARNING,
274
+ rule: 'workflow-missing-initial',
275
+ message: 'Workflow is missing initial state property',
276
+ doctype,
277
+ });
278
+ }
279
+ // Check for states
280
+ if (!workflow.states || Object.keys(workflow.states).length === 0) {
281
+ issues.push({
282
+ severity: ValidationSeverity.WARNING,
283
+ rule: 'workflow-no-states',
284
+ message: 'Workflow has no states defined',
285
+ doctype,
286
+ });
287
+ return issues;
288
+ }
289
+ // Validate initial state exists
290
+ if (workflow.initial && typeof workflow.initial === 'string' && !workflow.states[workflow.initial]) {
291
+ issues.push({
292
+ severity: ValidationSeverity.ERROR,
293
+ rule: 'workflow-invalid-initial',
294
+ message: `Workflow initial state "${workflow.initial}" does not exist in states`,
295
+ doctype,
296
+ context: { initialState: workflow.initial },
297
+ });
298
+ }
299
+ // Check state reachability (simple check - all states should have at least one incoming transition or be initial)
300
+ const stateNames = Object.keys(workflow.states);
301
+ const reachableStates = new Set();
302
+ // Initial state is always reachable
303
+ if (workflow.initial && typeof workflow.initial === 'string') {
304
+ reachableStates.add(workflow.initial);
305
+ }
306
+ // Find all target states from transitions
307
+ for (const [_stateName, stateConfig] of Object.entries(workflow.states)) {
308
+ const state = stateConfig;
309
+ if (state.on) {
310
+ for (const [_event, transition] of Object.entries(state.on)) {
311
+ if (typeof transition === 'string') {
312
+ reachableStates.add(transition);
313
+ }
314
+ else if (transition && typeof transition === 'object') {
315
+ const target = 'target' in transition ? transition.target : undefined;
316
+ if (typeof target === 'string') {
317
+ reachableStates.add(target);
318
+ }
319
+ else if (Array.isArray(target)) {
320
+ target.forEach((t) => {
321
+ if (typeof t === 'string') {
322
+ reachableStates.add(t);
323
+ }
324
+ });
325
+ }
326
+ }
327
+ }
328
+ }
329
+ }
330
+ // Check for unreachable states
331
+ for (const stateName of stateNames) {
332
+ if (!reachableStates.has(stateName)) {
333
+ issues.push({
334
+ severity: ValidationSeverity.WARNING,
335
+ rule: 'workflow-unreachable-state',
336
+ message: `Workflow state "${stateName}" may not be reachable`,
337
+ doctype,
338
+ context: { stateName },
339
+ });
340
+ }
341
+ }
342
+ return issues;
343
+ }
344
+ /**
345
+ * Validates that actions are registered in the FieldTriggerEngine
346
+ * @internal
347
+ */
348
+ validateActionRegistration(doctype, actions) {
349
+ const issues = [];
350
+ const triggerEngine = getGlobalTriggerEngine();
351
+ for (const [triggerName, actionNames] of Object.entries(actions)) {
352
+ if (!Array.isArray(actionNames)) {
353
+ issues.push({
354
+ severity: ValidationSeverity.ERROR,
355
+ rule: 'action-invalid-format',
356
+ message: `Action configuration for "${triggerName}" must be an array`,
357
+ doctype,
358
+ context: { triggerName, actionNames },
359
+ });
360
+ continue;
361
+ }
362
+ // Check each action name
363
+ for (const actionName of actionNames) {
364
+ // Check if action is registered globally
365
+ const engine = triggerEngine;
366
+ const isRegistered = engine.globalActions?.has(actionName) || engine.globalTransitionActions?.has(actionName);
367
+ if (!isRegistered) {
368
+ issues.push({
369
+ severity: ValidationSeverity.WARNING,
370
+ rule: 'action-not-registered',
371
+ message: `Action "${actionName}" referenced in "${triggerName}" is not registered in FieldTriggerEngine`,
372
+ doctype,
373
+ context: { triggerName, actionName },
374
+ });
375
+ }
376
+ }
377
+ }
378
+ return issues;
379
+ }
380
+ }
381
+ /**
382
+ * Creates a validator with the given registry
383
+ * @param registry - Registry instance
384
+ * @param options - Additional validator options
385
+ * @returns SchemaValidator instance
386
+ * @public
387
+ */
388
+ export function createValidator(registry, options) {
389
+ return new SchemaValidator({
390
+ registry,
391
+ ...options,
392
+ });
393
+ }
394
+ /**
395
+ * Quick validation helper
396
+ * @param doctype - Doctype name
397
+ * @param schema - Schema fields
398
+ * @param registry - Registry instance
399
+ * @param workflow - Optional workflow configuration
400
+ * @param actions - Optional actions map
401
+ * @returns Validation result
402
+ * @public
403
+ */
404
+ export function validateSchema(doctype, schema, registry, workflow, actions) {
405
+ const validator = createValidator(registry);
406
+ return validator.validate(doctype, schema, workflow, actions);
407
+ }
@@ -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"}