@stonecrop/stonecrop 0.10.15 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +72 -29
  2. package/dist/composable.js +1 -0
  3. package/dist/composables/lazy-link.js +125 -0
  4. package/dist/composables/stonecrop.js +123 -68
  5. package/dist/composables/use-lazy-link-state.js +125 -0
  6. package/dist/composables/use-stonecrop.js +476 -0
  7. package/dist/doctype.js +10 -2
  8. package/dist/field-triggers.js +15 -3
  9. package/dist/index.js +4 -3
  10. package/dist/operation-log-DB-dGNT9.js +593 -0
  11. package/dist/operation-log-DB-dGNT9.js.map +1 -0
  12. package/dist/registry.js +261 -101
  13. package/dist/schema-validator.js +105 -1
  14. package/dist/src/composable.d.ts +11 -0
  15. package/dist/src/composable.d.ts.map +1 -0
  16. package/dist/src/composable.js +477 -0
  17. package/dist/src/composables/lazy-link.d.ts +25 -0
  18. package/dist/src/composables/lazy-link.d.ts.map +1 -0
  19. package/dist/src/composables/operation-log.d.ts +5 -5
  20. package/dist/src/composables/operation-log.d.ts.map +1 -1
  21. package/dist/src/composables/operation-log.js +224 -0
  22. package/dist/src/composables/stonecrop.d.ts +11 -1
  23. package/dist/src/composables/stonecrop.d.ts.map +1 -1
  24. package/dist/src/composables/stonecrop.js +574 -0
  25. package/dist/src/composables/use-lazy-link-state.d.ts +25 -0
  26. package/dist/src/composables/use-lazy-link-state.d.ts.map +1 -0
  27. package/dist/src/composables/use-stonecrop.d.ts +93 -0
  28. package/dist/src/composables/use-stonecrop.d.ts.map +1 -0
  29. package/dist/src/composables/useNestedSchema.d.ts +110 -0
  30. package/dist/src/composables/useNestedSchema.d.ts.map +1 -0
  31. package/dist/src/composables/useNestedSchema.js +155 -0
  32. package/dist/src/doctype.d.ts +9 -1
  33. package/dist/src/doctype.d.ts.map +1 -1
  34. package/dist/src/doctype.js +234 -0
  35. package/dist/src/exceptions.js +16 -0
  36. package/dist/src/field-triggers.d.ts +6 -0
  37. package/dist/src/field-triggers.d.ts.map +1 -1
  38. package/dist/src/field-triggers.js +567 -0
  39. package/dist/src/index.d.ts +3 -2
  40. package/dist/src/index.d.ts.map +1 -1
  41. package/dist/src/index.js +23 -0
  42. package/dist/src/plugins/index.js +96 -0
  43. package/dist/src/registry.d.ts +102 -23
  44. package/dist/src/registry.d.ts.map +1 -1
  45. package/dist/src/registry.js +246 -0
  46. package/dist/src/schema-validator.d.ts +8 -1
  47. package/dist/src/schema-validator.d.ts.map +1 -1
  48. package/dist/src/schema-validator.js +315 -0
  49. package/dist/src/stonecrop.d.ts +73 -28
  50. package/dist/src/stonecrop.d.ts.map +1 -1
  51. package/dist/src/stonecrop.js +339 -0
  52. package/dist/src/stores/data.d.ts +11 -0
  53. package/dist/src/stores/data.d.ts.map +1 -0
  54. package/dist/src/stores/hst.d.ts +5 -75
  55. package/dist/src/stores/hst.d.ts.map +1 -1
  56. package/dist/src/stores/hst.js +495 -0
  57. package/dist/src/stores/index.js +12 -0
  58. package/dist/src/stores/operation-log.d.ts +14 -14
  59. package/dist/src/stores/operation-log.d.ts.map +1 -1
  60. package/dist/src/stores/operation-log.js +568 -0
  61. package/dist/src/stores/xstate.d.ts +31 -0
  62. package/dist/src/stores/xstate.d.ts.map +1 -0
  63. package/dist/src/tsdoc-metadata.json +11 -0
  64. package/dist/src/types/composable.d.ts +50 -12
  65. package/dist/src/types/composable.d.ts.map +1 -1
  66. package/dist/src/types/doctype.d.ts +6 -7
  67. package/dist/src/types/doctype.d.ts.map +1 -1
  68. package/dist/src/types/field-triggers.d.ts +1 -1
  69. package/dist/src/types/field-triggers.d.ts.map +1 -1
  70. package/dist/src/types/field-triggers.js +4 -0
  71. package/dist/src/types/hst.d.ts +70 -0
  72. package/dist/src/types/hst.d.ts.map +1 -0
  73. package/dist/src/types/index.d.ts +1 -0
  74. package/dist/src/types/index.d.ts.map +1 -1
  75. package/dist/src/types/index.js +4 -0
  76. package/dist/src/types/operation-log.d.ts +4 -4
  77. package/dist/src/types/operation-log.d.ts.map +1 -1
  78. package/dist/src/types/operation-log.js +0 -0
  79. package/dist/src/types/registry.js +0 -0
  80. package/dist/src/types/schema-validator.d.ts +2 -0
  81. package/dist/src/types/schema-validator.d.ts.map +1 -1
  82. package/dist/src/utils.d.ts +24 -0
  83. package/dist/src/utils.d.ts.map +1 -0
  84. package/dist/stonecrop.d.ts +317 -99
  85. package/dist/stonecrop.js +2191 -1897
  86. package/dist/stonecrop.js.map +1 -1
  87. package/dist/stonecrop.umd.cjs +6 -0
  88. package/dist/stonecrop.umd.cjs.map +1 -0
  89. package/dist/stores/data.js +7 -0
  90. package/dist/stores/hst.js +27 -25
  91. package/dist/stores/operation-log.js +59 -47
  92. package/dist/stores/xstate.js +29 -0
  93. package/dist/tests/setup.d.ts +5 -0
  94. package/dist/tests/setup.d.ts.map +1 -0
  95. package/dist/tests/setup.js +15 -0
  96. package/dist/types/hst.js +0 -0
  97. package/dist/types/index.js +1 -0
  98. package/dist/utils.js +46 -0
  99. package/package.json +4 -4
  100. package/src/composables/lazy-link.ts +146 -0
  101. package/src/composables/operation-log.ts +1 -1
  102. package/src/composables/stonecrop.ts +142 -73
  103. package/src/doctype.ts +13 -4
  104. package/src/field-triggers.ts +18 -4
  105. package/src/index.ts +4 -2
  106. package/src/registry.ts +289 -111
  107. package/src/schema-validator.ts +120 -1
  108. package/src/stonecrop.ts +230 -106
  109. package/src/stores/hst.ts +29 -104
  110. package/src/stores/operation-log.ts +64 -50
  111. package/src/types/composable.ts +55 -12
  112. package/src/types/doctype.ts +6 -7
  113. package/src/types/field-triggers.ts +1 -1
  114. package/src/types/hst.ts +77 -0
  115. package/src/types/index.ts +1 -0
  116. package/src/types/operation-log.ts +4 -4
  117. package/src/types/schema-validator.ts +2 -0
package/src/stonecrop.ts CHANGED
@@ -1,17 +1,12 @@
1
- import {
2
- type DoctypeManySchema,
3
- type DoctypeOneSchema,
4
- type DoctypeSchema,
5
- type SchemaTypes,
6
- isDoctypeMany,
7
- } from '@stonecrop/aform'
8
1
  import type { DataClient } from '@stonecrop/schema'
9
2
  import { reactive } from 'vue'
10
3
 
11
4
  import Doctype from './doctype'
5
+ import { getGlobalTriggerEngine } from './field-triggers'
12
6
  import Registry from './registry'
13
7
  import { createHST, type HSTNode } from './stores/hst'
14
8
  import { useOperationLogStore } from './stores/operation-log'
9
+ import type { FieldChangeContext } from './types/field-triggers'
15
10
  import type { OperationLogConfig } from './types/operation-log'
16
11
  import type { RouteContext } from './types/registry'
17
12
  import type { StonecropOptions } from './types/stonecrop'
@@ -29,13 +24,14 @@ export class Stonecrop {
29
24
  */
30
25
  static _root: Stonecrop
31
26
 
32
- private hstStore: HSTNode
27
+ /** The HST store instance for reactive state management */
28
+ private hstStore!: HSTNode
33
29
  private _operationLogStore?: ReturnType<typeof useOperationLogStore>
34
30
  private _operationLogConfig?: Partial<OperationLogConfig>
35
31
  private _client?: DataClient
36
32
 
37
33
  /** The registry instance containing all doctype definitions */
38
- readonly registry: Registry
34
+ readonly registry!: Registry
39
35
 
40
36
  /**
41
37
  * Creates a new Stonecrop instance with HST integration (singleton pattern)
@@ -184,7 +180,7 @@ export class Stonecrop {
184
180
  return undefined
185
181
  }
186
182
 
187
- // Use getNode to get the properly wrapped HST node with correct parent relationships
183
+ // Use getNode to get the properly wrapped HST node with correct ancestor relationships
188
184
  return this.hstStore.getNode(`${slug}.${recordId}`)
189
185
  }
190
186
 
@@ -251,10 +247,25 @@ export class Stonecrop {
251
247
  * @param action - The action to run
252
248
  * @param args - Action arguments (typically record IDs)
253
249
  */
254
- runAction(doctype: Doctype, action: string, args?: any[]): void {
250
+ runAction(doctype: Doctype, action: string, args?: string[]): void {
255
251
  const registry = this.registry.registry[doctype.slug]
256
252
  const actions = registry?.actions?.get(action)
257
253
  const recordIds = Array.isArray(args) ? args.filter((arg): arg is string => typeof arg === 'string') : undefined
254
+ const recordId = recordIds?.[0]
255
+
256
+ // Check if workflow is ready (all blocked links have data)
257
+ const workflowStatus = recordId ? this.isWorkflowReady(doctype, recordId) : { ready: true }
258
+ if (!workflowStatus.ready) {
259
+ const opLogStore = this.getOperationLogStore()
260
+ opLogStore.logAction(
261
+ doctype.doctype,
262
+ action,
263
+ recordIds,
264
+ 'failure',
265
+ `BLOCKED: missing data for links: ${workflowStatus.blockedLinks?.join(', ')}`
266
+ )
267
+ throw new Error(`Workflow blocked: missing data for links: ${workflowStatus.blockedLinks?.join(', ')}`)
268
+ }
258
269
 
259
270
  // Log action execution start
260
271
  const opLogStore = this.getOperationLogStore()
@@ -264,12 +275,22 @@ export class Stonecrop {
264
275
  try {
265
276
  // Execute action functions
266
277
  if (actions && actions.length > 0) {
278
+ const engine = getGlobalTriggerEngine()
267
279
  actions.forEach(actionStr => {
268
280
  try {
269
- // eslint-disable-next-line @typescript-eslint/no-implied-eval
270
- const actionFn = new Function('args', actionStr)
271
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call
272
- actionFn(args)
281
+ const actionFn = engine.getAction(actionStr)
282
+ if (!actionFn) throw new Error(`Action "${actionStr}" is not registered in FieldTriggerEngine`)
283
+ const context = {
284
+ path: `${doctype.slug}.${recordIds?.[0] ?? ''}`,
285
+ fieldname: action,
286
+ beforeValue: undefined,
287
+ afterValue: args,
288
+ operation: 'set',
289
+ doctype: doctype.doctype,
290
+ recordId: recordId,
291
+ timestamp: new Date(),
292
+ } as FieldChangeContext
293
+ void actionFn(context)
273
294
  } catch (error) {
274
295
  actionResult = 'failure'
275
296
  actionError = error instanceof Error ? error.message : 'Unknown error'
@@ -285,6 +306,54 @@ export class Stonecrop {
285
306
  }
286
307
  }
287
308
 
309
+ /**
310
+ * Get the effective blockWorkflows value for a link.
311
+ * Returns true if blockWorkflows is explicitly true, or if it's absent and fetch method is 'sync'.
312
+ * @param link - The link declaration
313
+ * @returns Whether workflows should be blocked until this link is loaded
314
+ */
315
+ private getEffectiveBlockWorkflows(link: { blockWorkflows?: boolean; fetch?: { method?: string } }): boolean {
316
+ if (link.blockWorkflows !== undefined) {
317
+ return link.blockWorkflows
318
+ }
319
+ // TODO: For custom fetch handlers, this returns false (not blocking), but the custom handler
320
+ // may still be invoked by useLazyLink. Future: custom handlers should be able to declare they
321
+ // satisfy blockWorkflows, or validation should reject custom + blockWorkflows: true.
322
+ // See: relationships.md Phase 6 "Open Question: blockWorkflows + custom fetch"
323
+ return link.fetch?.method === 'sync'
324
+ }
325
+
326
+ /**
327
+ * Check if workflow actions are ready to run (all required link data is loaded).
328
+ * A link's data is considered loaded if it exists in HST at `slug.recordId.linkname`.
329
+ * @param doctype - The doctype to check
330
+ * @param recordId - The record ID
331
+ * @returns Object with `ready: true` if all blocked links are loaded, or `ready: false` with `blockedLinks` array
332
+ */
333
+ isWorkflowReady(doctype: Doctype, recordId: string): { ready: boolean; blockedLinks?: string[] } {
334
+ // New records don't block workflows - they haven't been saved yet
335
+ if (recordId === 'new') {
336
+ return { ready: true }
337
+ }
338
+
339
+ const links = this.registry.getDescendantLinks(doctype.slug)
340
+ const blockedLinks: string[] = []
341
+
342
+ for (const link of links) {
343
+ if (this.getEffectiveBlockWorkflows(link)) {
344
+ const linkPath = `${doctype.slug}.${recordId}.${link.fieldname}`
345
+ if (!this.hstStore.has(linkPath)) {
346
+ blockedLinks.push(link.fieldname)
347
+ }
348
+ }
349
+ }
350
+
351
+ if (blockedLinks.length > 0) {
352
+ return { ready: false, blockedLinks }
353
+ }
354
+ return { ready: true }
355
+ }
356
+
288
357
  /**
289
358
  * Get records from server using the configured data client.
290
359
  * @param doctype - The doctype
@@ -436,39 +505,25 @@ export class Stonecrop {
436
505
 
437
506
  const payload: Record<string, any> = { ...recordData }
438
507
 
439
- const schemaArray = doctype.schema
440
- ? Array.isArray(doctype.schema)
441
- ? doctype.schema
442
- : Array.from(doctype.schema)
443
- : []
444
- const resolved = this.registry.resolveSchema(schemaArray)
445
-
446
- const doctypeFields = resolved.filter(
447
- field =>
448
- 'fieldtype' in field &&
449
- field.fieldtype === 'Doctype' &&
450
- !isDoctypeMany(field as DoctypeSchema) &&
451
- 'schema' in field &&
452
- Array.isArray(field.schema)
453
- )
454
-
455
- for (const field of doctypeFields) {
456
- const doctypeField = field as DoctypeOneSchema
457
- const fieldPath = `${recordPath}.${doctypeField.fieldname}`
458
- const nestedData = collectNestedData(doctypeField.schema!, fieldPath, this.hstStore)
459
- payload[doctypeField.fieldname] = nestedData
460
- }
461
-
462
- const doctypeManyFields = resolved.filter(
463
- field => 'fieldtype' in field && field.fieldtype === 'Doctype' && isDoctypeMany(field as DoctypeSchema)
464
- )
508
+ // Collect nested data from links
509
+ if (doctype.links) {
510
+ for (const [fieldname, link] of Object.entries(doctype.links)) {
511
+ const fieldPath = `${recordPath}.${fieldname}`
512
+ const isMany = link.cardinality === 'noneOrMany' || link.cardinality === 'atLeastOne'
465
513
 
466
- for (const field of doctypeManyFields) {
467
- const doctypeField = field as DoctypeManySchema
468
- const fieldPath = `${recordPath}.${doctypeField.fieldname}`
469
- const arrayData = this.hstStore.get(fieldPath)
470
- if (Array.isArray(arrayData)) {
471
- payload[doctypeField.fieldname] = arrayData
514
+ if (isMany) {
515
+ const arrayData = this.hstStore.get(fieldPath)
516
+ if (Array.isArray(arrayData)) {
517
+ payload[fieldname] = arrayData
518
+ }
519
+ } else {
520
+ const targetDoctype = this.registry.getDoctype(link.target)
521
+ if (targetDoctype?.links) {
522
+ payload[fieldname] = this.collectNestedData(fieldPath, targetDoctype)
523
+ } else {
524
+ payload[fieldname] = this.hstStore.get(fieldPath) || {}
525
+ }
526
+ }
472
527
  }
473
528
  }
474
529
 
@@ -476,76 +531,145 @@ export class Stonecrop {
476
531
  }
477
532
 
478
533
  /**
479
- * Load nested data from HST or initialize with defaults
480
- * @param parentPath - The HST path to check for existing data
481
- * @param childDoctype - The child doctype metadata
482
- * @param _recordId - Optional record ID to load
483
- * @returns The loaded or initialized data
534
+ * Scaffold empty descendant records from defaults for all descendant links.
535
+ *
536
+ * Initializes all scalar and link fields at their HST paths with default values.
537
+ * For new records, call this after setting up the doctype to ensure all paths exist.
538
+ *
539
+ * @param path - HST path (e.g., "customer.new")
540
+ * @param doctype - The doctype to initialize
484
541
  * @public
485
542
  */
486
- loadNestedData(parentPath: string, childDoctype: Doctype, _recordId?: string): Record<string, any> {
487
- // Check if data already exists in HST
488
- const existingData = this.hstStore.get(parentPath)
489
- if (existingData && typeof existingData === 'object') {
490
- return existingData as Record<string, any>
543
+ initializeNestedData(path: string, doctype: Doctype): void {
544
+ const slug = doctype.slug
545
+ this.ensureDoctypeExists(slug)
546
+
547
+ // Resolve schema and initialize with defaults
548
+ const resolvedSchema = this.registry.resolveSchema(doctype)
549
+ const record = this.registry.initializeRecord(resolvedSchema)
550
+
551
+ // Ensure the ancestor path exists in HST before setting descendant fields
552
+ const existingData = this.hstStore.get(path)
553
+ if (!existingData) {
554
+ this.hstStore.set(path, {}, 'system')
491
555
  }
492
556
 
493
- // TODO: If recordId provided and no HST data, fetch from API using this._client
494
- // For now, always fall through to initialize new record
557
+ // Store each field at its own HST path
558
+ for (const [key, value] of Object.entries(record)) {
559
+ this.hstStore.set(`${path}.${key}`, value, 'system')
560
+ }
561
+ }
495
562
 
496
- // Resolve schema and initialize with defaults
497
- const schemaArray = childDoctype.schema
498
- ? Array.isArray(childDoctype.schema)
499
- ? childDoctype.schema
500
- : Array.from(childDoctype.schema)
501
- : []
502
- const resolvedSchema = this.registry.resolveSchema(schemaArray)
503
- return this.registry.initializeRecord(resolvedSchema)
563
+ /**
564
+ * Fetch a record and its nested data from the server.
565
+ *
566
+ * Calls `_client.getRecord()` with nested sub-selections and stores each scalar field at its own HST path
567
+ * (`slug.recordId.fieldname`), descendants at the link-level path (`slug.recordId.linkname`).
568
+ *
569
+ * @param path - HST path (e.g., "recipe.r1")
570
+ * @param doctype - The doctype to fetch
571
+ * @param recordId - Record ID to fetch
572
+ * @param options - Query options (includeNested to control which links are fetched)
573
+ * @throws Error with code `"CLIENT_REQUIRED"` if no data client is configured
574
+ * @throws Error with code `"RECORD_NOT_FOUND"` if the server returns null
575
+ * @public
576
+ */
577
+ async fetchNestedData(
578
+ path: string,
579
+ doctype: Doctype,
580
+ recordId: string,
581
+ options?: { includeNested?: boolean | string[] }
582
+ ): Promise<void> {
583
+ if (!this._client) {
584
+ throw createCodedError(
585
+ 'No data client configured. Call setClient() with a DataClient implementation ' +
586
+ '(e.g., StonecropClient from @stonecrop/graphql-client) before fetching records.',
587
+ 'CLIENT_REQUIRED'
588
+ )
589
+ }
590
+
591
+ const record = await this._client.getRecord({ name: doctype.doctype }, recordId, {
592
+ includeNested: options?.includeNested ?? true,
593
+ })
594
+
595
+ if (!record) {
596
+ throw createCodedError(`Record not found: ${doctype.doctype} ${recordId}`, 'RECORD_NOT_FOUND')
597
+ }
598
+
599
+ // Store each scalar field at its own HST path, descendants at link-level path
600
+ const slug = doctype.slug
601
+ this.ensureDoctypeExists(slug)
602
+
603
+ // Ensure the ancestor path exists in HST before setting descendant fields
604
+ const existingData = this.hstStore.get(`${slug}.${recordId}`)
605
+ if (!existingData) {
606
+ this.hstStore.set(`${slug}.${recordId}`, {}, 'system')
607
+ }
608
+
609
+ for (const [key, value] of Object.entries(record)) {
610
+ this.hstStore.set(`${slug}.${recordId}.${key}`, value, 'system')
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Recursively collect nested data from HST
616
+ * @param basePath - The base path in HST (e.g., "customer.123.address")
617
+ * @param doctype - The doctype whose links drive the recursive traversal
618
+ * @returns The collected data object
619
+ */
620
+ private collectNestedData(basePath: string, doctype: Doctype): Record<string, any> {
621
+ const data = this.hstStore.get(basePath) || {}
622
+ const payload: Record<string, any> = { ...data }
623
+
624
+ if (!doctype.links) return payload
625
+
626
+ for (const [fieldname, link] of Object.entries(doctype.links)) {
627
+ const fieldPath = `${basePath}.${fieldname}`
628
+ const isMany = link.cardinality === 'noneOrMany' || link.cardinality === 'atLeastOne'
629
+
630
+ if (isMany) {
631
+ const arrayData = this.hstStore.get(fieldPath)
632
+ if (Array.isArray(arrayData)) {
633
+ payload[fieldname] = arrayData
634
+ }
635
+ } else {
636
+ const targetDoctype = this.registry.getDoctype(link.target)
637
+ if (targetDoctype?.links) {
638
+ payload[fieldname] = this.collectNestedData(fieldPath, targetDoctype)
639
+ } else {
640
+ payload[fieldname] = this.hstStore.get(fieldPath) || {}
641
+ }
642
+ }
643
+ }
644
+
645
+ return payload
504
646
  }
505
647
  }
506
648
 
507
649
  /**
508
- * Recursively collect nested data from HST using pre-resolved schemas
509
- * @param resolvedSchema - The already-resolved schema (with nested schemas embedded)
510
- * @param basePath - The base path in HST (e.g., "customer.123.address")
511
- * @param hstStore - The HST store instance
512
- * @returns The collected data object
650
+ * Returns the global Stonecrop singleton instance, or `undefined` if no
651
+ * instance has been created yet.
652
+ *
653
+ * Use this when you need the Stonecrop instance outside a Vue component
654
+ * context (e.g., in workflow action handlers, plugin setup code, or
655
+ * non-component utilities). Inside a component, prefer `useStonecrop()`.
656
+ *
513
657
  * @public
514
658
  */
515
- function collectNestedData(resolvedSchema: SchemaTypes[], basePath: string, hstStore: HSTNode): Record<string, any> {
516
- const data = hstStore.get(basePath) || {}
517
- const payload: Record<string, any> = { ...data }
518
-
519
- const doctypeFields = resolvedSchema.filter(
520
- field =>
521
- 'fieldtype' in field &&
522
- field.fieldtype === 'Doctype' &&
523
- !isDoctypeMany(field as DoctypeSchema) &&
524
- 'schema' in field &&
525
- Array.isArray(field.schema)
526
- )
527
-
528
- for (const field of doctypeFields) {
529
- const doctypeField = field as DoctypeOneSchema
530
- const fieldPath = `${basePath}.${doctypeField.fieldname}`
531
- const nestedData = collectNestedData(doctypeField.schema!, fieldPath, hstStore)
532
- payload[doctypeField.fieldname] = nestedData
533
- }
534
-
535
- const doctypeManyFields = resolvedSchema.filter(
536
- field => 'fieldtype' in field && field.fieldtype === 'Doctype' && isDoctypeMany(field as DoctypeSchema)
537
- )
538
-
539
- for (const field of doctypeManyFields) {
540
- const doctypeField = field as DoctypeManySchema
541
- const fieldPath = `${basePath}.${doctypeField.fieldname}`
542
- const arrayData = hstStore.get(fieldPath)
543
- if (Array.isArray(arrayData)) {
544
- payload[doctypeField.fieldname] = arrayData
545
- }
546
- }
659
+ export function getStonecrop(): Stonecrop | undefined {
660
+ return Stonecrop._root
661
+ }
547
662
 
548
- return payload
663
+ /**
664
+ * Create an Error with a `code` property for programmatic error handling.
665
+ * @internal
666
+ */
667
+ interface CodedError extends Error {
668
+ code: string
549
669
  }
550
670
 
551
- export { collectNestedData }
671
+ function createCodedError(message: string, code: string): CodedError {
672
+ const error = new Error(message) as CodedError
673
+ error.code = code
674
+ return error
675
+ }
package/src/stores/hst.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { getGlobalTriggerEngine } from '../field-triggers'
2
- import type { FieldChangeContext, TransitionChangeContext } from '../types/field-triggers'
3
2
  import { useOperationLogStore } from './operation-log'
3
+ import type { FieldChangeContext, TransitionChangeContext } from '../types/field-triggers'
4
+ import type { HSTNode } from '../types/hst'
4
5
 
5
6
  /**
6
7
  * Get the operation log store if available
@@ -14,84 +15,6 @@ function getOperationLogStore() {
14
15
  }
15
16
  }
16
17
 
17
- /**
18
- * Core HST Interface - enhanced with tree navigation
19
- * Provides a hierarchical state tree interface for navigating and manipulating nested data structures.
20
- *
21
- * @public
22
- */
23
- interface HSTNode {
24
- /**
25
- * Gets a value at the specified path
26
- * @param path - The dot-separated path to the value
27
- * @returns The value at the specified path
28
- */
29
- get(path: string): any
30
-
31
- /**
32
- * Sets a value at the specified path
33
- * @param path - The dot-separated path where to set the value
34
- * @param value - The value to set
35
- * @param source - Optional source of the operation (user, system, sync, undo, redo)
36
- */
37
- set(path: string, value: any, source?: 'user' | 'system' | 'sync' | 'undo' | 'redo'): void
38
-
39
- /**
40
- * Checks if a value exists at the specified path
41
- * @param path - The dot-separated path to check
42
- * @returns True if the path exists, false otherwise
43
- */
44
- has(path: string): boolean
45
-
46
- /**
47
- * Gets the parent node in the tree hierarchy
48
- * @returns The parent HSTNode or null if this is the root
49
- */
50
- getParent(): HSTNode | null
51
-
52
- /**
53
- * Gets the root node of the tree
54
- * @returns The root HSTNode
55
- */
56
- getRoot(): HSTNode
57
-
58
- /**
59
- * Gets the full path from root to this node
60
- * @returns The dot-separated path string
61
- */
62
- getPath(): string
63
-
64
- /**
65
- * Gets the depth level of this node in the tree
66
- * @returns The depth as a number (0 for root)
67
- */
68
- getDepth(): number
69
-
70
- /**
71
- * Gets an array of path segments from root to this node
72
- * @returns Array of path segments representing breadcrumbs
73
- */
74
- getBreadcrumbs(): string[]
75
-
76
- /**
77
- * Gets a child node at the specified relative path
78
- * @param path - The relative path to the child node
79
- * @returns The child HSTNode
80
- */
81
- getNode(path: string): HSTNode
82
-
83
- /**
84
- * Trigger an XState transition with optional context data
85
- * @param transition - The transition name (should be uppercase per convention)
86
- * @param context - Optional additional FSM context data
87
- * @returns Promise resolving to the transition execution results
88
- */
89
- triggerTransition(
90
- transition: string,
91
- context?: { currentState?: string; targetState?: string; fsmContext?: Record<string, any> }
92
- ): Promise<any>
93
- }
94
-
95
18
  // Type definitions for global Registry
96
19
  interface RegistryGlobal {
97
20
  Registry?: {
@@ -215,18 +138,16 @@ class HST {
215
138
  // Enhanced HST Proxy with tree navigation
216
139
  class HSTProxy implements HSTNode {
217
140
  private target: any
218
- private parentPath: string
141
+ private ancestorPath: string
219
142
  private rootNode: HSTNode | null
220
143
  private doctype: string
221
- private parentDoctype?: string
222
144
  private hst: HST
223
145
 
224
- constructor(target: any, doctype: string, parentPath = '', rootNode: HSTNode | null = null, parentDoctype?: string) {
146
+ constructor(target: any, doctype: string, ancestorPath = '', rootNode: HSTNode | null = null) {
225
147
  this.target = target
226
- this.parentPath = parentPath
148
+ this.ancestorPath = ancestorPath
227
149
  this.rootNode = rootNode || this
228
150
  this.doctype = doctype
229
- this.parentDoctype = parentDoctype
230
151
  this.hst = HST.getInstance()
231
152
 
232
153
  return new Proxy(this, {
@@ -267,16 +188,21 @@ class HSTProxy implements HSTNode {
267
188
 
268
189
  // Always wrap in HSTProxy for tree navigation
269
190
  if (typeof value === 'object' && value !== null && !this.isPrimitive(value)) {
270
- return new HSTProxy(value, nodeDoctype, fullPath, this.rootNode, this.parentDoctype)
191
+ return new HSTProxy(value, nodeDoctype, fullPath, this.rootNode)
271
192
  }
272
193
 
273
194
  // For primitives, return a minimal wrapper that throws on tree operations
274
- return new HSTProxy(value, nodeDoctype, fullPath, this.rootNode, this.parentDoctype)
195
+ return new HSTProxy(value, nodeDoctype, fullPath, this.rootNode)
275
196
  }
276
197
 
277
198
  set(path: string, value: any, source: 'user' | 'system' | 'sync' | 'undo' | 'redo' = 'user'): void {
278
199
  // Get current value for change context
279
200
  const fullPath = this.resolvePath(path)
201
+ if (fullPath === undefined) {
202
+ // eslint-disable-next-line no-console
203
+ console.warn('HST.set: resolved path is undefined, skipping operation')
204
+ return
205
+ }
280
206
  const beforeValue = this.has(path) ? this.get(path) : undefined
281
207
 
282
208
  // Log operation if not from undo/redo and store is available
@@ -355,18 +281,18 @@ class HSTProxy implements HSTNode {
355
281
  }
356
282
 
357
283
  // Tree navigation methods
358
- getParent(): HSTNode | null {
359
- if (!this.parentPath) return null
284
+ getAncestor(): HSTNode | null {
285
+ if (!this.ancestorPath) return null
360
286
 
361
- const parentSegments = this.parentPath.split('.').slice(0, -1)
362
- const parentPath = parentSegments.join('.')
287
+ const ancestorSegments = this.ancestorPath.split('.').slice(0, -1)
288
+ const ancestorPath = ancestorSegments.join('.')
363
289
 
364
- if (parentPath === '') {
290
+ if (ancestorPath === '') {
365
291
  return this.rootNode
366
292
  }
367
293
 
368
294
  // Return a wrapped node, not raw data
369
- return this.rootNode!.getNode(parentPath)
295
+ return this.rootNode!.getNode(ancestorPath)
370
296
  }
371
297
 
372
298
  getRoot(): HSTNode {
@@ -374,15 +300,15 @@ class HSTProxy implements HSTNode {
374
300
  }
375
301
 
376
302
  getPath(): string {
377
- return this.parentPath
303
+ return this.ancestorPath
378
304
  }
379
305
 
380
306
  getDepth(): number {
381
- return this.parentPath ? this.parentPath.split('.').length : 0
307
+ return this.ancestorPath ? this.ancestorPath.split('.').length : 0
382
308
  }
383
309
 
384
310
  getBreadcrumbs(): string[] {
385
- return this.parentPath ? this.parentPath.split('.') : []
311
+ return this.ancestorPath ? this.ancestorPath.split('.') : []
386
312
  }
387
313
 
388
314
  /**
@@ -395,7 +321,7 @@ class HSTProxy implements HSTNode {
395
321
  const triggerEngine = getGlobalTriggerEngine()
396
322
 
397
323
  // Determine doctype and recordId from the current path
398
- const pathSegments = this.parentPath.split('.')
324
+ const pathSegments = this.ancestorPath.split('.')
399
325
  let doctype = this.doctype
400
326
  let recordId: string | undefined
401
327
 
@@ -411,7 +337,7 @@ class HSTProxy implements HSTNode {
411
337
 
412
338
  // Build transition context
413
339
  const transitionContext: TransitionChangeContext = {
414
- path: this.parentPath,
340
+ path: this.ancestorPath,
415
341
  fieldname: '', // No specific field for transitions
416
342
  beforeValue: undefined,
417
343
  afterValue: undefined,
@@ -432,7 +358,7 @@ class HSTProxy implements HSTNode {
432
358
  logStore.addOperation(
433
359
  {
434
360
  type: 'transition' as const,
435
- path: this.parentPath,
361
+ path: this.ancestorPath,
436
362
  fieldname: transition,
437
363
  beforeValue: context?.currentState,
438
364
  afterValue: context?.targetState,
@@ -456,8 +382,8 @@ class HSTProxy implements HSTNode {
456
382
 
457
383
  // Private helper methods
458
384
  private resolvePath(path: string): string {
459
- if (path === '') return this.parentPath
460
- return this.parentPath ? `${this.parentPath}.${path}` : path
385
+ if (path === '') return this.ancestorPath ?? ''
386
+ return this.ancestorPath ? `${this.ancestorPath}.${path}` : path
461
387
  }
462
388
 
463
389
  private resolveValue(path: string): any {
@@ -490,7 +416,7 @@ class HSTProxy implements HSTNode {
490
416
  const lastSegment = segments.pop()!
491
417
  let current = this.target
492
418
 
493
- // Navigate to parent object
419
+ // Navigate to ancestor object
494
420
  for (const segment of segments) {
495
421
  current = this.getProperty(current, segment)
496
422
  if (current === null || current === undefined) {
@@ -705,13 +631,12 @@ class HSTProxy implements HSTNode {
705
631
  *
706
632
  * @param target - The target object to wrap with HST functionality
707
633
  * @param doctype - The document type identifier
708
- * @param parentDoctype - Optional parent document type identifier
709
634
  * @returns A new HSTNode proxy instance
710
635
  *
711
636
  * @public
712
637
  */
713
- function createHST(target: any, doctype: string, parentDoctype?: string): HSTNode {
714
- return new HSTProxy(target, doctype, '', null, parentDoctype)
638
+ function createHST(target: any, doctype: string): HSTNode {
639
+ return new HSTProxy(target, doctype, '', null)
715
640
  }
716
641
 
717
642
  // Export everything