@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
@@ -0,0 +1,7 @@
1
+ import { defineStore } from 'pinia';
2
+ import { ref } from 'vue';
3
+ export const useDataStore = defineStore('data', () => {
4
+ const records = ref([]);
5
+ const record = ref({});
6
+ return { records, record };
7
+ });
@@ -77,17 +77,15 @@ class HST {
77
77
  // Enhanced HST Proxy with tree navigation
78
78
  class HSTProxy {
79
79
  target;
80
- parentPath;
80
+ ancestorPath;
81
81
  rootNode;
82
82
  doctype;
83
- parentDoctype;
84
83
  hst;
85
- constructor(target, doctype, parentPath = '', rootNode = null, parentDoctype) {
84
+ constructor(target, doctype, ancestorPath = '', rootNode = null) {
86
85
  this.target = target;
87
- this.parentPath = parentPath;
86
+ this.ancestorPath = ancestorPath;
88
87
  this.rootNode = rootNode || this;
89
88
  this.doctype = doctype;
90
- this.parentDoctype = parentDoctype;
91
89
  this.hst = HST.getInstance();
92
90
  return new Proxy(this, {
93
91
  get(hst, prop) {
@@ -121,14 +119,19 @@ class HSTProxy {
121
119
  }
122
120
  // Always wrap in HSTProxy for tree navigation
123
121
  if (typeof value === 'object' && value !== null && !this.isPrimitive(value)) {
124
- return new HSTProxy(value, nodeDoctype, fullPath, this.rootNode, this.parentDoctype);
122
+ return new HSTProxy(value, nodeDoctype, fullPath, this.rootNode);
125
123
  }
126
124
  // For primitives, return a minimal wrapper that throws on tree operations
127
- return new HSTProxy(value, nodeDoctype, fullPath, this.rootNode, this.parentDoctype);
125
+ return new HSTProxy(value, nodeDoctype, fullPath, this.rootNode);
128
126
  }
129
127
  set(path, value, source = 'user') {
130
128
  // Get current value for change context
131
129
  const fullPath = this.resolvePath(path);
130
+ if (fullPath === undefined) {
131
+ // eslint-disable-next-line no-console
132
+ console.warn('HST.set: resolved path is undefined, skipping operation');
133
+ return;
134
+ }
132
135
  const beforeValue = this.has(path) ? this.get(path) : undefined;
133
136
  // Log operation if not from undo/redo and store is available
134
137
  if (source !== 'undo' && source !== 'redo') {
@@ -194,28 +197,28 @@ class HSTProxy {
194
197
  }
195
198
  }
196
199
  // Tree navigation methods
197
- getParent() {
198
- if (!this.parentPath)
200
+ getAncestor() {
201
+ if (!this.ancestorPath)
199
202
  return null;
200
- const parentSegments = this.parentPath.split('.').slice(0, -1);
201
- const parentPath = parentSegments.join('.');
202
- if (parentPath === '') {
203
+ const ancestorSegments = this.ancestorPath.split('.').slice(0, -1);
204
+ const ancestorPath = ancestorSegments.join('.');
205
+ if (ancestorPath === '') {
203
206
  return this.rootNode;
204
207
  }
205
208
  // Return a wrapped node, not raw data
206
- return this.rootNode.getNode(parentPath);
209
+ return this.rootNode.getNode(ancestorPath);
207
210
  }
208
211
  getRoot() {
209
212
  return this.rootNode;
210
213
  }
211
214
  getPath() {
212
- return this.parentPath;
215
+ return this.ancestorPath;
213
216
  }
214
217
  getDepth() {
215
- return this.parentPath ? this.parentPath.split('.').length : 0;
218
+ return this.ancestorPath ? this.ancestorPath.split('.').length : 0;
216
219
  }
217
220
  getBreadcrumbs() {
218
- return this.parentPath ? this.parentPath.split('.') : [];
221
+ return this.ancestorPath ? this.ancestorPath.split('.') : [];
219
222
  }
220
223
  /**
221
224
  * Trigger an XState transition with optional context data
@@ -223,7 +226,7 @@ class HSTProxy {
223
226
  async triggerTransition(transition, context) {
224
227
  const triggerEngine = getGlobalTriggerEngine();
225
228
  // Determine doctype and recordId from the current path
226
- const pathSegments = this.parentPath.split('.');
229
+ const pathSegments = this.ancestorPath.split('.');
227
230
  let doctype = this.doctype;
228
231
  let recordId;
229
232
  // If we're at the root level and this is a StonecropStore, use the first path segment as the doctype
@@ -236,7 +239,7 @@ class HSTProxy {
236
239
  }
237
240
  // Build transition context
238
241
  const transitionContext = {
239
- path: this.parentPath,
242
+ path: this.ancestorPath,
240
243
  fieldname: '', // No specific field for transitions
241
244
  beforeValue: undefined,
242
245
  afterValue: undefined,
@@ -255,7 +258,7 @@ class HSTProxy {
255
258
  if (logStore && typeof logStore.addOperation === 'function') {
256
259
  logStore.addOperation({
257
260
  type: 'transition',
258
- path: this.parentPath,
261
+ path: this.ancestorPath,
259
262
  fieldname: transition,
260
263
  beforeValue: context?.currentState,
261
264
  afterValue: context?.targetState,
@@ -276,8 +279,8 @@ class HSTProxy {
276
279
  // Private helper methods
277
280
  resolvePath(path) {
278
281
  if (path === '')
279
- return this.parentPath;
280
- return this.parentPath ? `${this.parentPath}.${path}` : path;
282
+ return this.ancestorPath ?? '';
283
+ return this.ancestorPath ? `${this.ancestorPath}.${path}` : path;
281
284
  }
282
285
  resolveValue(path) {
283
286
  // Handle empty path - return the target object
@@ -302,7 +305,7 @@ class HSTProxy {
302
305
  const segments = this.parsePath(path);
303
306
  const lastSegment = segments.pop();
304
307
  let current = this.target;
305
- // Navigate to parent object
308
+ // Navigate to ancestor object
306
309
  for (const segment of segments) {
307
310
  current = this.getProperty(current, segment);
308
311
  if (current === null || current === undefined) {
@@ -482,13 +485,12 @@ class HSTProxy {
482
485
  *
483
486
  * @param target - The target object to wrap with HST functionality
484
487
  * @param doctype - The document type identifier
485
- * @param parentDoctype - Optional parent document type identifier
486
488
  * @returns A new HSTNode proxy instance
487
489
  *
488
490
  * @public
489
491
  */
490
- function createHST(target, doctype, parentDoctype) {
491
- return new HSTProxy(target, doctype, '', null, parentDoctype);
492
+ function createHST(target, doctype) {
493
+ return new HSTProxy(target, doctype, '', null);
492
494
  }
493
495
  // Export everything
494
496
  export { HSTProxy, HST, createHST };
@@ -75,8 +75,7 @@ export const useOperationLogStore = defineStore('hst-operation-log', () => {
75
75
  const currentIndex = ref(-1); // Points to the last applied operation
76
76
  const clientId = ref(generateId());
77
77
  const batchMode = ref(false);
78
- const currentBatch = ref([]);
79
- const currentBatchId = ref(null);
78
+ const batchStack = ref([]);
80
79
  // Computed
81
80
  const canUndo = computed(() => {
82
81
  // Can undo if there are operations and we're not at the beginning
@@ -141,9 +140,9 @@ export const useOperationLogStore = defineStore('hst-operation-log', () => {
141
140
  if (config.value.operationFilter && !config.value.operationFilter(fullOperation)) {
142
141
  return fullOperation.id;
143
142
  }
144
- // If in batch mode, collect operations
145
- if (batchMode.value) {
146
- currentBatch.value.push(fullOperation);
143
+ // If in batch mode, collect operations in current batch
144
+ if (batchMode.value && batchStack.value.length > 0) {
145
+ batchStack.value[batchStack.value.length - 1].operations.push(fullOperation);
147
146
  return fullOperation.id;
148
147
  }
149
148
  // Remove any operations after current index (they become invalid after new operation)
@@ -170,22 +169,30 @@ export const useOperationLogStore = defineStore('hst-operation-log', () => {
170
169
  */
171
170
  function startBatch() {
172
171
  batchMode.value = true;
173
- currentBatch.value = [];
174
- currentBatchId.value = generateId();
172
+ batchStack.value.push({
173
+ id: generateId(),
174
+ operations: [],
175
+ });
175
176
  }
176
177
  /**
177
178
  * Commit batch - create a single batch operation
178
179
  */
179
180
  function commitBatch(description) {
180
- if (!batchMode.value || currentBatch.value.length === 0) {
181
- batchMode.value = false;
182
- currentBatch.value = [];
183
- currentBatchId.value = null;
181
+ if (!batchMode.value || batchStack.value.length === 0) {
182
+ return null;
183
+ }
184
+ const currentBatchData = batchStack.value.pop();
185
+ const batchOperations = currentBatchData.operations;
186
+ if (batchOperations.length === 0) {
187
+ // Empty batch - just pop and continue if there are outer batches
188
+ if (batchStack.value.length === 0) {
189
+ batchMode.value = false;
190
+ }
184
191
  return null;
185
192
  }
186
- const batchId = currentBatchId.value;
187
- const allReversible = currentBatch.value.every(op => op.reversible);
188
- // Create parent batch operation
193
+ const batchId = currentBatchData.id;
194
+ const allReversible = batchOperations.every(op => op.reversible);
195
+ // Create ancestor batch operation
189
196
  const batchOperation = {
190
197
  id: batchId,
191
198
  type: 'batch',
@@ -193,39 +200,44 @@ export const useOperationLogStore = defineStore('hst-operation-log', () => {
193
200
  fieldname: '',
194
201
  beforeValue: null,
195
202
  afterValue: null,
196
- doctype: currentBatch.value[0]?.doctype || '',
203
+ doctype: batchOperations[0]?.doctype || '',
197
204
  timestamp: new Date(),
198
205
  source: 'user',
199
206
  reversible: allReversible,
200
207
  irreversibleReason: allReversible ? undefined : 'Contains irreversible operations',
201
- childOperationIds: currentBatch.value.map(op => op.id),
208
+ descendantOperationIds: batchOperations.map(op => op.id),
202
209
  metadata: { description },
203
210
  };
204
- // Add parent operation ID to all children
205
- currentBatch.value.forEach(op => {
206
- op.parentOperationId = batchId;
211
+ // Add ancestor operation ID to all descendants
212
+ batchOperations.forEach(op => {
213
+ op.ancestorOperationId = batchId;
207
214
  });
208
- // Add all operations to the log
209
- operations.value.push(...currentBatch.value, batchOperation);
210
- currentIndex.value = operations.value.length - 1;
215
+ // If we're inside a ancestor batch, add this batch as a descendant of the ancestor
216
+ if (batchStack.value.length > 0) {
217
+ // Nested batch - add the batch operation to the ancestor batch
218
+ batchStack.value[batchStack.value.length - 1].operations.push(batchOperation);
219
+ }
220
+ else {
221
+ // Top-level batch - add to the operations log
222
+ operations.value.push(...batchOperations, batchOperation);
223
+ currentIndex.value = operations.value.length - 1;
224
+ }
211
225
  // Broadcast batch
212
226
  if (config.value.enableCrossTabSync) {
213
- broadcastBatch(currentBatch.value, batchOperation);
227
+ broadcastBatch(batchOperations, batchOperation);
214
228
  }
215
- // Reset batch state
216
- const result = batchId;
217
- batchMode.value = false;
218
- currentBatch.value = [];
219
- currentBatchId.value = null;
220
- return result;
229
+ // If no more batches on stack, exit batch mode
230
+ if (batchStack.value.length === 0) {
231
+ batchMode.value = false;
232
+ }
233
+ return batchId;
221
234
  }
222
235
  /**
223
236
  * Cancel batch mode without committing
224
237
  */
225
238
  function cancelBatch() {
239
+ batchStack.value = [];
226
240
  batchMode.value = false;
227
- currentBatch.value = [];
228
- currentBatchId.value = null;
229
241
  }
230
242
  /**
231
243
  * Undo the last operation
@@ -244,13 +256,13 @@ export const useOperationLogStore = defineStore('hst-operation-log', () => {
244
256
  }
245
257
  try {
246
258
  // Handle batch operations
247
- if (operation.type === 'batch' && operation.childOperationIds) {
248
- // Undo all child operations in reverse order
249
- for (let i = operation.childOperationIds.length - 1; i >= 0; i--) {
250
- const childId = operation.childOperationIds[i];
251
- const childOp = operations.value.find(op => op.id === childId);
252
- if (childOp) {
253
- revertOperation(childOp, store);
259
+ if (operation.type === 'batch' && operation.descendantOperationIds) {
260
+ // Undo all descendant operations in reverse order
261
+ for (let i = operation.descendantOperationIds.length - 1; i >= 0; i--) {
262
+ const descendantId = operation.descendantOperationIds[i];
263
+ const descendantOp = operations.value.find(op => op.id === descendantId);
264
+ if (descendantOp) {
265
+ revertOperation(descendantOp, store);
254
266
  }
255
267
  }
256
268
  }
@@ -283,12 +295,12 @@ export const useOperationLogStore = defineStore('hst-operation-log', () => {
283
295
  const operation = operations.value[currentIndex.value + 1];
284
296
  try {
285
297
  // Handle batch operations
286
- if (operation.type === 'batch' && operation.childOperationIds) {
287
- // Redo all child operations in order
288
- for (const childId of operation.childOperationIds) {
289
- const childOp = operations.value.find(op => op.id === childId);
290
- if (childOp) {
291
- applyOperation(childOp, store);
298
+ if (operation.type === 'batch' && operation.descendantOperationIds) {
299
+ // Redo all descendant operations in order
300
+ for (const descendantId of operation.descendantOperationIds) {
301
+ const descendantOp = operations.value.find(op => op.id === descendantId);
302
+ if (descendantOp) {
303
+ applyOperation(descendantOp, store);
292
304
  }
293
305
  }
294
306
  }
@@ -440,12 +452,12 @@ export const useOperationLogStore = defineStore('hst-operation-log', () => {
440
452
  };
441
453
  broadcastChannel.postMessage(serializeForBroadcast(message));
442
454
  }
443
- function broadcastBatch(childOps, batchOp) {
455
+ function broadcastBatch(descendantOps, batchOp) {
444
456
  if (!broadcastChannel)
445
457
  return;
446
458
  const message = {
447
459
  type: 'operation',
448
- operations: [...childOps, batchOp],
460
+ operations: [...descendantOps, batchOp],
449
461
  clientId: clientId.value,
450
462
  timestamp: new Date(),
451
463
  };
@@ -473,7 +485,7 @@ export const useOperationLogStore = defineStore('hst-operation-log', () => {
473
485
  };
474
486
  broadcastChannel.postMessage(serializeForBroadcast(message));
475
487
  }
476
- const persistedData = useLocalStorage('stonecrop-ops-operations', null, {
488
+ const persistedData = useLocalStorage('stonecrop-operations', null, {
477
489
  serializer: {
478
490
  read: (v) => {
479
491
  try {
@@ -0,0 +1,29 @@
1
+ import { defineStore } from 'pinia';
2
+ import { xstate } from 'pinia-xstate';
3
+ import { createMachine } from 'xstate';
4
+ export const counterMachine = createMachine({
5
+ id: 'counter',
6
+ initial: 'active',
7
+ context: {
8
+ count: 0,
9
+ },
10
+ states: {
11
+ active: {
12
+ on: {
13
+ INC: { actions: 'increment' },
14
+ DEC: { actions: 'decrement' },
15
+ },
16
+ },
17
+ },
18
+ }, {
19
+ actions: {
20
+ increment: context => {
21
+ context.count = context.count + 1;
22
+ },
23
+ decrement: context => {
24
+ context.count = context.count - 1;
25
+ },
26
+ },
27
+ });
28
+ // create a store using the xstate middleware
29
+ export const useCounterStore = defineStore(counterMachine.id, xstate(counterMachine));
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Vitest setup file
3
+ * Runs before all test files
4
+ */
5
+ //# sourceMappingURL=setup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setup.d.ts","sourceRoot":"","sources":["../../tests/setup.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Vitest setup file
3
+ * Runs before all test files
4
+ */
5
+ // Remove Node.js's native BroadcastChannel to avoid conflicts with mocks
6
+ // Tests that need BroadcastChannel will mock it themselves
7
+ if (typeof global !== 'undefined' && 'BroadcastChannel' in global) {
8
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
9
+ delete global.BroadcastChannel;
10
+ }
11
+ // Also remove from globalThis if it exists there
12
+ if (typeof globalThis !== 'undefined' && 'BroadcastChannel' in globalThis) {
13
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
14
+ delete globalThis.BroadcastChannel;
15
+ }
File without changes
@@ -2,6 +2,7 @@
2
2
  export * from './composable';
3
3
  export * from './doctype';
4
4
  export * from './field-triggers';
5
+ export * from './hst';
5
6
  export * from './operation-log';
6
7
  export * from './plugin';
7
8
  export * from './registry';
package/dist/utils.js ADDED
@@ -0,0 +1,46 @@
1
+ import { isDoctypeMany, } from '@stonecrop/aform';
2
+ /**
3
+ * Recursively collect nested data from HST using pre-resolved schemas.
4
+ *
5
+ * Walks through a resolved schema and collects all values from the HST store,
6
+ * including nested 1:1 Doctype fields and 1:many child arrays.
7
+ *
8
+ * @param resolvedSchema - The already-resolved schema (with nested schemas embedded)
9
+ * @param basePath - The base path in HST (e.g., "customer.123.address")
10
+ * @param hstStore - The HST store instance
11
+ * @returns The collected data object with all nested fields
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * const addressSchema = registry.resolveSchema(addressDoctype.schema)
16
+ * const addressData = collectNestedData(addressSchema, 'customer.123.address', hstStore)
17
+ * // Returns: { street: '123 Main St', city: 'Portland', ... }
18
+ * ```
19
+ *
20
+ * @public
21
+ */
22
+ export function collectNestedData(resolvedSchema, basePath, hstStore) {
23
+ const data = hstStore.get(basePath) || {};
24
+ const payload = { ...data };
25
+ const doctypeFields = resolvedSchema.filter(field => 'fieldtype' in field &&
26
+ field.fieldtype === 'Doctype' &&
27
+ !isDoctypeMany(field) &&
28
+ 'schema' in field &&
29
+ Array.isArray(field.schema));
30
+ for (const field of doctypeFields) {
31
+ const doctypeField = field;
32
+ const fieldPath = `${basePath}.${doctypeField.fieldname}`;
33
+ const nestedData = collectNestedData(doctypeField.schema, fieldPath, hstStore);
34
+ payload[doctypeField.fieldname] = nestedData;
35
+ }
36
+ const doctypeManyFields = resolvedSchema.filter(field => 'fieldtype' in field && field.fieldtype === 'Doctype' && isDoctypeMany(field));
37
+ for (const field of doctypeManyFields) {
38
+ const doctypeField = field;
39
+ const fieldPath = `${basePath}.${doctypeField.fieldname}`;
40
+ const arrayData = hstStore.get(fieldPath);
41
+ if (Array.isArray(arrayData)) {
42
+ payload[doctypeField.fieldname] = arrayData;
43
+ }
44
+ }
45
+ return payload;
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonecrop/stonecrop",
3
- "version": "0.10.15",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "author": {
@@ -34,7 +34,7 @@
34
34
  "pinia-shared-state": "^1.0.1",
35
35
  "pinia-xstate": "^3.0.0",
36
36
  "xstate": "^5.25.0",
37
- "@stonecrop/schema": "0.10.15"
37
+ "@stonecrop/schema": "0.11.0"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "pinia": "^3.0.4",
@@ -60,8 +60,8 @@
60
60
  "vue-router": "^5.0.2",
61
61
  "vite": "^7.3.1",
62
62
  "vitest": "^4.0.18",
63
- "@stonecrop/aform": "0.10.15",
64
- "@stonecrop/atable": "0.10.15",
63
+ "@stonecrop/aform": "0.11.0",
64
+ "@stonecrop/atable": "0.11.0",
65
65
  "stonecrop-rig": "0.7.0"
66
66
  },
67
67
  "description": "Schema-driven framework with XState workflows and HST state management",
@@ -0,0 +1,146 @@
1
+ import type { FetchStrategy, CustomFetch } from '@stonecrop/schema'
2
+ import { computed, inject, ref } from 'vue'
3
+
4
+ import Doctype from '../doctype'
5
+ import { Stonecrop } from '../stonecrop'
6
+ import type { LazyLink } from '../types/composable'
7
+
8
+ /**
9
+ * Get the lazy link state for a specific link field on a doctype record.
10
+ *
11
+ * This composable provides reactive state for lazy-loaded links:
12
+ * - `loading`: true while fetching
13
+ * - `loaded`: true after successful fetch (permanent until reload)
14
+ * - `error`: error state if any
15
+ * - `reload()`: explicitly trigger a fetch
16
+ * - `data`: computed from HST, or undefined if not loaded
17
+ *
18
+ * The reload() function respects the link's fetch strategy:
19
+ * - `sync`: fetches via GraphQL query through fetchNestedData
20
+ * - `lazy`: fetches via GraphQL query through fetchNestedData
21
+ * - `custom`: invokes the serialized handler function directly
22
+ *
23
+ * @param doctype - The doctype instance
24
+ * @param recordId - The record ID
25
+ * @param linkFieldname - The link fieldname to load
26
+ * @returns LazyLink with loading, loaded, error, reload, and data
27
+ * @public
28
+ */
29
+ export function useLazyLink(doctype: Doctype, recordId: string, linkFieldname: string): LazyLink {
30
+ const stonecropInstance = inject<Stonecrop>('$stonecrop') || Stonecrop._root
31
+
32
+ if (!stonecropInstance) {
33
+ throw new Error('Stonecrop instance not available. Ensure useStonecrop() has been called first.')
34
+ }
35
+
36
+ const loading = ref(false)
37
+ const loaded = ref(false)
38
+ const error = ref<Error | null>(null)
39
+
40
+ const hstStore = stonecropInstance.getStore()
41
+
42
+ /**
43
+ * Build the HST path for a lazy link field
44
+ */
45
+ const getLinkPath = (): string => {
46
+ const slug = doctype.slug || doctype.doctype
47
+ return `${slug}.${recordId}.${linkFieldname}`
48
+ }
49
+
50
+ /**
51
+ * Get the link declaration from the doctype schema
52
+ */
53
+ const getLinkDeclaration = (): FetchStrategy | undefined => {
54
+ return doctype.links?.[linkFieldname]?.fetch
55
+ }
56
+
57
+ /**
58
+ * Invoke a custom fetch handler
59
+ * The handler is a serialized function string that we execute via new Function()
60
+ */
61
+ const invokeCustomHandler = async (handler: CustomFetch['handler']): Promise<any> => {
62
+ try {
63
+ // Create function from serialized string and invoke it
64
+ // The function receives the stonecrop instance and path as parameters
65
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
66
+ const fn = new Function(
67
+ 'stonecrop',
68
+ 'path',
69
+ 'hst',
70
+ `
71
+ return (${handler})(stonecrop, path, hst)
72
+ `
73
+ )
74
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
75
+ return await fn(stonecropInstance, getLinkPath(), hstStore)
76
+ } catch (err) {
77
+ throw new Error(`Custom handler failed: ${err instanceof Error ? err.message : String(err)}`)
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Fetch the link data using the appropriate strategy
83
+ */
84
+ const fetchLinkData = async (): Promise<void> => {
85
+ const linkFetch = getLinkDeclaration()
86
+ const ancestorPath = `${doctype.slug || doctype.doctype}.${recordId}`
87
+
88
+ if (linkFetch?.method === 'custom') {
89
+ // Ensure ancestor path exists before invoking custom handler
90
+ if (!hstStore.has(ancestorPath)) {
91
+ hstStore.set(ancestorPath, {}, 'system')
92
+ }
93
+
94
+ // Custom handler - invoke directly
95
+ const result = await invokeCustomHandler(linkFetch.handler)
96
+
97
+ // Store result in HST at the link path
98
+ hstStore.set(getLinkPath(), result, 'system')
99
+ } else {
100
+ // sync or lazy (both use fetchNestedData but with different includeNested)
101
+ // For lazy links, we still use fetchNestedData but only for this specific link
102
+ await stonecropInstance.fetchNestedData(ancestorPath, doctype, recordId, { includeNested: [linkFieldname] })
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Explicitly reload the lazy link data
108
+ */
109
+ const reload = async (): Promise<void> => {
110
+ if (loading.value) return
111
+
112
+ loading.value = true
113
+ error.value = null
114
+
115
+ try {
116
+ await fetchLinkData()
117
+ loaded.value = true
118
+ } catch (err) {
119
+ error.value = err instanceof Error ? err : new Error(String(err))
120
+ throw err
121
+ } finally {
122
+ loading.value = false
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Computed property that returns the loaded data from HST
128
+ */
129
+ const data = computed(() => {
130
+ if (!loaded.value) return undefined
131
+ return hstStore.get(getLinkPath())
132
+ })
133
+
134
+ return {
135
+ // State
136
+ loading,
137
+ loaded,
138
+ error,
139
+
140
+ // Computed
141
+ data,
142
+
143
+ // Actions
144
+ reload,
145
+ }
146
+ }
@@ -2,7 +2,7 @@ import { useMagicKeys, whenever } from '@vueuse/core'
2
2
  import { storeToRefs } from 'pinia'
3
3
  import { getCurrentInstance, inject } from 'vue'
4
4
 
5
- import type { HSTNode } from '../stores/hst'
5
+ import type { HSTNode } from '../types/hst'
6
6
  import { useOperationLogStore } from '../stores/operation-log'
7
7
  import type { OperationLogConfig } from '../types/operation-log'
8
8