@kontsedal/olas-core 0.0.5 → 0.1.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 (61) hide show
  1. package/dist/index.cjs +0 -0
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +109 -27
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +109 -27
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +0 -0
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-DqWolle_.mjs → root-Byq-QYTp.mjs} +1478 -347
  10. package/dist/root-Byq-QYTp.mjs.map +1 -0
  11. package/dist/{root-lBp7qziQ.cjs → root-CPSL5kpR.cjs} +1483 -346
  12. package/dist/root-CPSL5kpR.cjs.map +1 -0
  13. package/dist/testing.cjs +5 -1
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.d.cts +3 -2
  16. package/dist/testing.d.cts.map +1 -1
  17. package/dist/testing.d.mts +3 -2
  18. package/dist/testing.d.mts.map +1 -1
  19. package/dist/testing.mjs +5 -2
  20. package/dist/testing.mjs.map +1 -1
  21. package/dist/{types-BH1o6nYa.d.mts → types-Brp-4WaZ.d.mts} +244 -23
  22. package/dist/types-Brp-4WaZ.d.mts.map +1 -0
  23. package/dist/{types-C4Vtkxbn.d.cts → types-DzDIypPo.d.cts} +244 -23
  24. package/dist/types-DzDIypPo.d.cts.map +1 -0
  25. package/package.json +4 -1
  26. package/src/controller/instance.ts +360 -94
  27. package/src/controller/root.ts +58 -30
  28. package/src/controller/types.ts +66 -4
  29. package/src/emitter.ts +8 -2
  30. package/src/errors.ts +39 -3
  31. package/src/forms/field.ts +219 -25
  32. package/src/forms/form-types.ts +16 -3
  33. package/src/forms/form.ts +360 -38
  34. package/src/forms/index.ts +3 -1
  35. package/src/forms/types.ts +27 -1
  36. package/src/forms/validators.ts +45 -11
  37. package/src/index.ts +13 -7
  38. package/src/query/client.ts +237 -58
  39. package/src/query/define.ts +0 -0
  40. package/src/query/entry.ts +259 -15
  41. package/src/query/focus-online.ts +53 -11
  42. package/src/query/infinite.ts +351 -47
  43. package/src/query/keys.ts +33 -2
  44. package/src/query/local.ts +3 -0
  45. package/src/query/mutation.ts +27 -6
  46. package/src/query/plugin.ts +43 -4
  47. package/src/query/types.ts +76 -6
  48. package/src/query/use.ts +53 -10
  49. package/src/selection.ts +49 -12
  50. package/src/signals/readonly.ts +3 -0
  51. package/src/signals/runtime.ts +27 -0
  52. package/src/signals/types.ts +10 -0
  53. package/src/testing.ts +9 -0
  54. package/src/timing/debounced.ts +106 -23
  55. package/src/timing/index.ts +1 -1
  56. package/src/timing/throttled.ts +85 -28
  57. package/src/utils.ts +15 -2
  58. package/dist/root-DqWolle_.mjs.map +0 -1
  59. package/dist/root-lBp7qziQ.cjs.map +0 -1
  60. package/dist/types-BH1o6nYa.d.mts.map +0 -1
  61. package/dist/types-C4Vtkxbn.d.cts.map +0 -1
package/src/forms/form.ts CHANGED
@@ -22,6 +22,7 @@ import type {
22
22
  FormValue,
23
23
  ItemInitial,
24
24
  } from './form-types'
25
+ import type { FormIssue, ValidatorResult } from './types'
25
26
 
26
27
  const FORM_BRAND = Symbol.for('olas.form')
27
28
  const FIELD_ARRAY_BRAND = Symbol.for('olas.fieldArray')
@@ -35,6 +36,87 @@ const isFieldArray = (x: unknown): x is FieldArray<Field<unknown> | Form<FormSch
35
36
  const isField = (x: unknown): x is Field<unknown> =>
36
37
  typeof x === 'object' && x !== null && !isForm(x) && !isFieldArray(x)
37
38
 
39
+ /** Any node that can receive parent-form-validator-routed errors (T5.2). */
40
+ type FormErrorTarget = { setFormErrors?: (msgs: ReadonlyArray<string>) => void }
41
+
42
+ /**
43
+ * Walk a form tree from `root` following `FormIssue.path` segments. Forms walk
44
+ * keys; field-arrays walk numeric indices. Returns the resolved node, or
45
+ * `undefined` if the path runs off the tree (bad index, or a leaf reached with
46
+ * an unconsumed path segment). Reads are untracked (`fields` is a static object;
47
+ * `at()` peeks) so calling this inside a validator effect adds no dependencies.
48
+ */
49
+ function resolveNode(root: unknown, segments: ReadonlyArray<string | number>): unknown {
50
+ let cursor: unknown = root
51
+ for (const seg of segments) {
52
+ if (cursor === undefined || cursor === null) return undefined
53
+ if (isForm(cursor)) {
54
+ cursor = (cursor.fields as Record<string, unknown>)[String(seg)]
55
+ } else if (isFieldArray(cursor)) {
56
+ const idx = Number(seg)
57
+ if (!Number.isInteger(idx) || idx < 0) return undefined
58
+ cursor = (cursor as { at(i: number): unknown }).at(idx)
59
+ } else {
60
+ return undefined
61
+ }
62
+ }
63
+ return cursor
64
+ }
65
+
66
+ /**
67
+ * Normalize one validator result into the shared `FormIssue[]` shape. A plain
68
+ * string is a message on the node itself (empty path); `null` contributes
69
+ * nothing; a `FormIssue[]` is appended verbatim.
70
+ */
71
+ function appendIssues(out: FormIssue[], result: ValidatorResult): void {
72
+ if (result == null) return
73
+ if (typeof result === 'string') {
74
+ out.push({ path: [], message: result })
75
+ return
76
+ }
77
+ for (const issue of result) out.push(issue)
78
+ }
79
+
80
+ /**
81
+ * Route a fully-collected issue set for one form-level validation run:
82
+ * - empty-path (and unresolvable) issues → `topLevelErrors$` on the owning node
83
+ * - path issues → the resolved descendant's `setFormErrors`
84
+ *
85
+ * Targets that received an error last run but not this one are cleared, so a
86
+ * fixed cross-field rule removes its message from the field it landed on.
87
+ * Returns the new target set for the caller to retain. MUST run inside a batch.
88
+ */
89
+ function routeFormIssues(
90
+ root: unknown,
91
+ issues: FormIssue[],
92
+ topLevelErrors$: Signal<string[]>,
93
+ lastTargets: Set<FormErrorTarget>,
94
+ ): Set<FormErrorTarget> {
95
+ const topLevel: string[] = []
96
+ const byTarget = new Map<FormErrorTarget, string[]>()
97
+ for (const issue of issues) {
98
+ if (issue.path.length === 0) {
99
+ topLevel.push(issue.message)
100
+ continue
101
+ }
102
+ const target = resolveNode(root, issue.path) as FormErrorTarget | undefined
103
+ if (target === undefined || typeof target.setFormErrors !== 'function') {
104
+ // Unresolvable path — surface at the top level rather than dropping it.
105
+ topLevel.push(issue.message)
106
+ continue
107
+ }
108
+ const list = byTarget.get(target)
109
+ if (list) list.push(issue.message)
110
+ else byTarget.set(target, [issue.message])
111
+ }
112
+ topLevelErrors$.set(topLevel)
113
+ for (const t of lastTargets) {
114
+ if (!byTarget.has(t)) t.setFormErrors?.([])
115
+ }
116
+ for (const [t, msgs] of byTarget) t.setFormErrors?.(msgs)
117
+ return new Set(byTarget.keys())
118
+ }
119
+
38
120
  class FormImpl<S extends FormSchema> implements Form<S> {
39
121
  readonly [FORM_BRAND] = true
40
122
 
@@ -46,10 +128,34 @@ class FormImpl<S extends FormSchema> implements Form<S> {
46
128
  readonly touched: ReadSignal<boolean>
47
129
  readonly isValidating: ReadSignal<boolean>
48
130
  readonly flatErrors: ReadSignal<Array<{ path: string; errors: string[] }>>
131
+ /**
132
+ * Dotted paths of every leaf whose `isDirty` is `true`. Recomputes when
133
+ * any child's dirty state flips; ordered by depth-first traversal so two
134
+ * snapshots of a stable tree compare with `===`-friendly references on
135
+ * unchanged subsets. Useful for partial-update PATCH payloads and
136
+ * "highlight the changed inputs" UIs.
137
+ */
138
+ readonly dirtyFields: ReadSignal<string[]>
49
139
 
50
140
  private readonly topLevelErrors$: Signal<string[]> = signal([])
51
- readonly topLevelErrors: ReadSignal<string[]> = this.topLevelErrors$
141
+ /**
142
+ * Errors routed to THIS form by an ancestor form-level validator (a
143
+ * `FormIssue` whose path resolves to this node). Merged into `topLevelErrors`
144
+ * beside this form's own validator output, so `topLevelErrors` means "errors
145
+ * attached to this node itself, whatever their source". Owned by the ancestor
146
+ * router (T5.2) — see `setFormErrors`.
147
+ */
148
+ private readonly parentFormErrors$: Signal<string[]> = signal([])
149
+ readonly topLevelErrors: ReadSignal<string[]> = computed(() => {
150
+ const own = this.topLevelErrors$.value
151
+ const parent = this.parentFormErrors$.value
152
+ if (parent.length === 0) return own
153
+ if (own.length === 0) return parent
154
+ return [...own, ...parent]
155
+ })
52
156
  private readonly topLevelValidating$: Signal<boolean> = signal(false)
157
+ /** Targets written by the last form-level run — cleared next run if absent. */
158
+ private lastFormErrorTargets: Set<FormErrorTarget> = new Set()
53
159
 
54
160
  // Submission lifecycle.
55
161
  private readonly isSubmitting$: Signal<boolean> = signal(false)
@@ -129,7 +235,9 @@ class FormImpl<S extends FormSchema> implements Form<S> {
129
235
  return false
130
236
  })
131
237
  this.isValid = computed(() => {
132
- if (this.topLevelErrors$.value.length > 0) return false
238
+ // Merged view: this form's own top-level validators AND any errors an
239
+ // ancestor form-level validator routed onto this node (T5.2).
240
+ if (this.topLevelErrors.value.length > 0) return false
133
241
  if (this.isValidating.value) return false
134
242
  for (const child of Object.values(this.fields)) {
135
243
  if (!(child as { isValid: ReadSignal<boolean> }).isValid.value) return false
@@ -137,6 +245,11 @@ class FormImpl<S extends FormSchema> implements Form<S> {
137
245
  return true
138
246
  })
139
247
  this.flatErrors = computed(() => this.computeFlatErrors())
248
+ this.dirtyFields = computed(() => {
249
+ const out: string[] = []
250
+ collectDirtyFields(this.fields, '', out)
251
+ return out
252
+ })
140
253
 
141
254
  if (this.validators.length > 0) {
142
255
  this.validatorDispose = effect(() => this.runTopLevelValidators())
@@ -181,7 +294,7 @@ class FormImpl<S extends FormSchema> implements Form<S> {
181
294
 
182
295
  private computeFlatErrors(): Array<{ path: string; errors: string[] }> {
183
296
  const out: Array<{ path: string; errors: string[] }> = []
184
- const tle = this.topLevelErrors$.value
297
+ const tle = this.topLevelErrors.value
185
298
  if (tle.length > 0) out.push({ path: '', errors: tle })
186
299
  walkErrors(this.fields, '', out)
187
300
  return out
@@ -272,13 +385,24 @@ class FormImpl<S extends FormSchema> implements Form<S> {
272
385
  }
273
386
  }
274
387
  this.topLevelErrors$.set([])
388
+ this.parentFormErrors$.set([])
389
+ // Submission lifecycle is conceptually part of "form state"; resetting
390
+ // a form means the user is starting over. Without these clears, a UI
391
+ // bound to `submitCount`/`submitError` would show stale state after
392
+ // `reset()`. `isSubmitting` is deliberately NOT cleared — only the
393
+ // owning submit() flow can flip it back.
394
+ this.submitCount$.set(0)
395
+ this.submitError$.set(undefined)
396
+ // Re-apply initial (as initial, no dirty bump) INSIDE the batch — a
397
+ // separate pass would fire a second notification and briefly expose the
398
+ // "reset to construction seed, then re-seat to current initial" tearing
399
+ // (visible with a reactive `initial: () => …` whose deps changed) (T5.3).
400
+ if (this.options?.initial !== undefined) {
401
+ const ini =
402
+ typeof this.options.initial === 'function' ? this.options.initial() : this.options.initial
403
+ if (ini !== undefined) this.applyPartial(ini as DeepPartial<FormValue<S>>, true)
404
+ }
275
405
  })
276
- // Re-apply initial if provided — as initial (no dirty bump).
277
- if (this.options?.initial !== undefined) {
278
- const ini =
279
- typeof this.options.initial === 'function' ? this.options.initial() : this.options.initial
280
- if (ini !== undefined) this.applyPartial(ini as DeepPartial<FormValue<S>>, true)
281
- }
282
406
  }
283
407
 
284
408
  markAllTouched(): void {
@@ -336,14 +460,14 @@ class FormImpl<S extends FormSchema> implements Form<S> {
336
460
  * unless `onError: 'rethrow'`. A `resetOnSuccess: true` option calls
337
461
  * `reset()` after the handler resolves successfully.
338
462
  */
339
- async submit(
340
- handler: (value: FormValue<S>) => unknown | Promise<unknown>,
463
+ async submit<R = unknown>(
464
+ handler: (value: FormValue<S>) => R | Promise<R>,
341
465
  options?: {
342
466
  validateBeforeSubmit?: boolean
343
467
  resetOnSuccess?: boolean
344
468
  onError?: 'rethrow' | 'capture'
345
469
  },
346
- ): Promise<{ ok: boolean; data?: unknown; error?: unknown }> {
470
+ ): Promise<{ ok: boolean; data?: Awaited<R>; error?: unknown }> {
347
471
  if (this.disposed) return { ok: false, error: new Error('form is disposed') }
348
472
 
349
473
  // Double-submit guard — refusing to start a second submission while one
@@ -371,7 +495,7 @@ class FormImpl<S extends FormSchema> implements Form<S> {
371
495
  return { ok: false }
372
496
  }
373
497
  }
374
- const result = await handler(this.value.peek())
498
+ const result = (await handler(this.value.peek())) as Awaited<R>
375
499
  if (options?.resetOnSuccess) this.reset()
376
500
  this.isSubmitting$.set(false)
377
501
  return { ok: true, data: result }
@@ -405,9 +529,45 @@ class FormImpl<S extends FormSchema> implements Form<S> {
405
529
  })
406
530
  }
407
531
 
532
+ /**
533
+ * Internal — receive errors routed here by an ancestor form-level validator
534
+ * (a `FormIssue` whose path resolved to this nested form). Guards against
535
+ * spurious writes so a whole-tree clear pass doesn't wake subscribers. See
536
+ * `routeFormIssues`.
537
+ */
538
+ setFormErrors(errors: ReadonlyArray<string>): void {
539
+ if (this.disposed) return
540
+ if (this.parentFormErrors$.peek().length === 0 && errors.length === 0) return
541
+ this.parentFormErrors$.set(errors.length === 0 ? [] : [...errors])
542
+ }
543
+
544
+ /**
545
+ * Reset a named subtree to its initial. `path` uses the same dotted /
546
+ * bracket notation as `setErrors` / `flatErrors`. Useful when
547
+ * `Form.set({foo: undefined})` would be ambiguous ("clear" vs "leave
548
+ * alone" — the latter is what `set` does today).
549
+ *
550
+ * Walks via `resolvePath`; the resolved target must expose `reset()`
551
+ * (Field, Form, or FieldArray all do). Unknown paths are silently
552
+ * ignored — `flatErrors`-shaped paths from upstream code shouldn't
553
+ * crash this. Pass an empty string to reset the whole form (same as
554
+ * `form.reset()`).
555
+ */
556
+ clearSubtree(path: string): void {
557
+ if (this.disposed) return
558
+ if (path === '') {
559
+ this.reset()
560
+ return
561
+ }
562
+ const target = this.resolvePath(path) as { reset?: () => void } | undefined
563
+ if (target === undefined) return
564
+ if (typeof target.reset === 'function') target.reset()
565
+ }
566
+
408
567
  private resolvePath(path: string): unknown {
409
568
  if (path === '') return undefined
410
- const segments = path.split('.')
569
+ const segments = splitPath(path)
570
+ if (segments === null) return undefined
411
571
  let cursor: unknown = this
412
572
  for (const seg of segments) {
413
573
  if (cursor === undefined || cursor === null) return undefined
@@ -450,26 +610,40 @@ class FormImpl<S extends FormSchema> implements Form<S> {
450
610
  this.currentValidatorAbort = abort
451
611
  const myId = ++this.currentValidatorRun
452
612
 
453
- const syncErrors: string[] = []
454
- const asyncPromises: Promise<string | null>[] = []
613
+ const syncIssues: FormIssue[] = []
614
+ const asyncPromises: Promise<ValidatorResult>[] = []
455
615
  for (const v of this.validators) {
456
616
  try {
457
617
  const r = v(value, abort.signal)
458
618
  if (r instanceof Promise) asyncPromises.push(r)
459
- else if (r != null) syncErrors.push(r)
619
+ else appendIssues(syncIssues, r)
460
620
  } catch (err) {
461
621
  try {
462
622
  this.onValidatorError?.(err)
463
623
  } catch {
464
624
  // The reporter must not propagate.
465
625
  }
466
- syncErrors.push(err instanceof Error ? err.message : String(err))
626
+ // Prod shows a generic message (don't leak internal error text into
627
+ // form errors); the real error still reaches `onValidatorError` (T5.3).
628
+ syncIssues.push({
629
+ path: [],
630
+ message: __DEV__
631
+ ? err instanceof Error
632
+ ? err.message
633
+ : String(err)
634
+ : 'Validation failed',
635
+ })
467
636
  }
468
637
  }
469
638
 
470
- if (syncErrors.length > 0) {
639
+ if (syncIssues.length > 0) {
471
640
  batch(() => {
472
- this.topLevelErrors$.set(syncErrors)
641
+ this.lastFormErrorTargets = routeFormIssues(
642
+ this,
643
+ syncIssues,
644
+ this.topLevelErrors$,
645
+ this.lastFormErrorTargets,
646
+ )
473
647
  this.topLevelValidating$.set(false)
474
648
  })
475
649
  return
@@ -477,31 +651,105 @@ class FormImpl<S extends FormSchema> implements Form<S> {
477
651
 
478
652
  if (asyncPromises.length === 0) {
479
653
  batch(() => {
480
- this.topLevelErrors$.set([])
654
+ this.lastFormErrorTargets = routeFormIssues(
655
+ this,
656
+ [],
657
+ this.topLevelErrors$,
658
+ this.lastFormErrorTargets,
659
+ )
481
660
  this.topLevelValidating$.set(false)
482
661
  })
483
662
  return
484
663
  }
485
664
 
486
665
  batch(() => {
487
- this.topLevelErrors$.set([])
666
+ this.lastFormErrorTargets = routeFormIssues(
667
+ this,
668
+ [],
669
+ this.topLevelErrors$,
670
+ this.lastFormErrorTargets,
671
+ )
488
672
  this.topLevelValidating$.set(true)
489
673
  })
490
674
 
491
675
  Promise.allSettled(asyncPromises).then((results) => {
492
676
  if (myId !== this.currentValidatorRun || this.disposed) return
493
- const errs: string[] = []
677
+ const issues: FormIssue[] = []
494
678
  for (const r of results) {
495
- if (r.status === 'fulfilled' && r.value != null) errs.push(r.value)
679
+ if (r.status === 'fulfilled') appendIssues(issues, r.value)
496
680
  }
497
681
  batch(() => {
498
- this.topLevelErrors$.set(errs)
682
+ this.lastFormErrorTargets = routeFormIssues(
683
+ this,
684
+ issues,
685
+ this.topLevelErrors$,
686
+ this.lastFormErrorTargets,
687
+ )
499
688
  this.topLevelValidating$.set(false)
500
689
  })
501
690
  })
502
691
  }
503
692
  }
504
693
 
694
+ /**
695
+ * Split a path string into segments, accepting both dot syntax (`users.0.name`)
696
+ * and the bracket syntax `walkErrors` emits for array items (`users[0].name`).
697
+ * Returns `null` if the path is malformed (unclosed bracket, empty bracket,
698
+ * non-numeric bracket index).
699
+ */
700
+ function splitPath(path: string): string[] | null {
701
+ const out: string[] = []
702
+ let current = ''
703
+ for (let i = 0; i < path.length; i++) {
704
+ const ch = path[i]
705
+ if (ch === '.') {
706
+ if (current !== '') {
707
+ out.push(current)
708
+ current = ''
709
+ }
710
+ continue
711
+ }
712
+ if (ch === '[') {
713
+ if (current !== '') {
714
+ out.push(current)
715
+ current = ''
716
+ }
717
+ const close = path.indexOf(']', i + 1)
718
+ if (close === -1) return null
719
+ const idx = path.slice(i + 1, close)
720
+ if (idx === '' || !/^\d+$/.test(idx)) return null
721
+ out.push(idx)
722
+ i = close
723
+ continue
724
+ }
725
+ if (ch === ']') return null
726
+ current += ch
727
+ }
728
+ if (current !== '') out.push(current)
729
+ return out
730
+ }
731
+
732
+ function collectDirtyFields(fields: FormSchema, prefix: string, out: string[]): void {
733
+ for (const [k, child] of Object.entries(fields)) {
734
+ const path = prefix ? `${prefix}.${k}` : k
735
+ if (isForm(child)) {
736
+ collectDirtyFields(child.fields, path, out)
737
+ } else if (isFieldArray(child)) {
738
+ const items = child.items.value
739
+ items.forEach((item, idx) => {
740
+ const itemPath = `${path}[${idx}]`
741
+ if (isForm(item)) {
742
+ collectDirtyFields(item.fields, itemPath, out)
743
+ } else if ((item as Field<unknown>).isDirty.value) {
744
+ out.push(itemPath)
745
+ }
746
+ })
747
+ } else if ((child as Field<unknown>).isDirty.value) {
748
+ out.push(path)
749
+ }
750
+ }
751
+ }
752
+
505
753
  function walkErrors(
506
754
  fields: FormSchema,
507
755
  prefix: string,
@@ -548,9 +796,28 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
548
796
  readonly isValidating: ReadSignal<boolean>
549
797
 
550
798
  private readonly items$: Signal<I[]>
799
+ /**
800
+ * Structural dirtiness — flipped by `add`/`insert`/`remove`/`move`/`clear`.
801
+ * Item-level `isDirty` alone misses these, so a reactive `initial` + the
802
+ * default `resetOnInitialChange: 'when-clean'` would re-seat the array on a
803
+ * background refetch and delete rows the user just added (T5.1). Reset by
804
+ * `reset()` and by an initial-driven re-anchor (`replaceInitialItems`).
805
+ */
806
+ private readonly structurallyDirty$: Signal<boolean> = signal(false)
551
807
  private readonly topLevelErrors$: Signal<string[]> = signal([])
552
- readonly topLevelErrors: ReadSignal<string[]> = this.topLevelErrors$
808
+ /** Errors routed to this array by an ancestor form-level validator (T5.2) —
809
+ * merged into `topLevelErrors` beside the array's own validator output. */
810
+ private readonly parentFormErrors$: Signal<string[]> = signal([])
811
+ readonly topLevelErrors: ReadSignal<string[]> = computed(() => {
812
+ const own = this.topLevelErrors$.value
813
+ const parent = this.parentFormErrors$.value
814
+ if (parent.length === 0) return own
815
+ if (own.length === 0) return parent
816
+ return [...own, ...parent]
817
+ })
553
818
  private readonly topLevelValidating$: Signal<boolean> = signal(false)
819
+ /** Targets written by the last array-level run — cleared next run if absent. */
820
+ private lastFormErrorTargets: Set<FormErrorTarget> = new Set()
554
821
 
555
822
  private readonly itemFactory: (initial?: ItemInitial<I>) => I
556
823
  private initialItems: Array<ItemInitial<I>> = []
@@ -602,6 +869,7 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
602
869
  }),
603
870
  )
604
871
  this.isDirty = computed(() => {
872
+ if (this.structurallyDirty$.value) return true // add/remove/move/clear
605
873
  for (const item of this.items$.value) {
606
874
  if ((item as { isDirty: ReadSignal<boolean> }).isDirty.value) return true
607
875
  }
@@ -621,7 +889,9 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
621
889
  return false
622
890
  })
623
891
  this.isValid = computed(() => {
624
- if (this.topLevelErrors$.value.length > 0) return false
892
+ // Merged view: the array's own top-level validators AND any errors an
893
+ // ancestor form-level validator routed onto this node (T5.2).
894
+ if (this.topLevelErrors.value.length > 0) return false
625
895
  if (this.isValidating.value) return false
626
896
  for (const item of this.items$.value) {
627
897
  if (!(item as { isValid: ReadSignal<boolean> }).isValid.value) return false
@@ -638,10 +908,21 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
638
908
  return this.items$.peek()[index]
639
909
  }
640
910
 
911
+ /**
912
+ * Internal — receive errors routed here by an ancestor form-level validator
913
+ * (a `FormIssue` whose path resolved to this array). See `routeFormIssues`.
914
+ */
915
+ setFormErrors(errors: ReadonlyArray<string>): void {
916
+ if (this.disposed) return
917
+ if (this.parentFormErrors$.peek().length === 0 && errors.length === 0) return
918
+ this.parentFormErrors$.set(errors.length === 0 ? [] : [...errors])
919
+ }
920
+
641
921
  add(initial?: ItemInitial<I>): void {
642
922
  if (this.disposed) return
643
923
  const item = this.itemFactory(initial)
644
924
  this.items$.set([...this.items$.peek(), item])
925
+ this.structurallyDirty$.set(true)
645
926
  }
646
927
 
647
928
  insert(index: number, initial?: ItemInitial<I>): void {
@@ -650,6 +931,7 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
650
931
  const next = [...this.items$.peek()]
651
932
  next.splice(index, 0, item)
652
933
  this.items$.set(next)
934
+ this.structurallyDirty$.set(true)
653
935
  }
654
936
 
655
937
  remove(index: number): void {
@@ -660,6 +942,7 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
660
942
  ;(removed as { dispose?: () => void }).dispose?.()
661
943
  }
662
944
  this.items$.set(next)
945
+ this.structurallyDirty$.set(true)
663
946
  }
664
947
 
665
948
  move(from: number, to: number): void {
@@ -668,6 +951,7 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
668
951
  const [item] = next.splice(from, 1)
669
952
  if (item) next.splice(to, 0, item)
670
953
  this.items$.set(next)
954
+ this.structurallyDirty$.set(true)
671
955
  }
672
956
 
673
957
  clear(): void {
@@ -676,6 +960,7 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
676
960
  ;(item as { dispose?: () => void }).dispose?.()
677
961
  }
678
962
  this.items$.set([])
963
+ this.structurallyDirty$.set(true)
679
964
  }
680
965
 
681
966
  /**
@@ -686,6 +971,10 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
686
971
  */
687
972
  replaceInitialItems(items: ReadonlyArray<ItemInitial<I>>): void {
688
973
  this.initialItems = [...items]
974
+ // The array was just re-seated from `initial` (reactive-initial re-apply or
975
+ // `resetWithInitial`) — this is the new clean baseline, so the clear()/add()
976
+ // that drove it must not leave the array structurally dirty (T5.1).
977
+ this.structurallyDirty$.set(false)
689
978
  }
690
979
 
691
980
  reset(): void {
@@ -696,6 +985,10 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
696
985
  this.add(ini)
697
986
  }
698
987
  this.topLevelErrors$.set([])
988
+ this.parentFormErrors$.set([])
989
+ // clear()/add() above flipped structural dirt; reset() lands on the
990
+ // clean initial baseline (T5.1).
991
+ this.structurallyDirty$.set(false)
699
992
  })
700
993
  }
701
994
 
@@ -749,26 +1042,40 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
749
1042
  this.currentValidatorAbort = abort
750
1043
  const myId = ++this.currentValidatorRun
751
1044
 
752
- const syncErrors: string[] = []
753
- const asyncPromises: Promise<string | null>[] = []
1045
+ const syncIssues: FormIssue[] = []
1046
+ const asyncPromises: Promise<ValidatorResult>[] = []
754
1047
  for (const v of this.validators) {
755
1048
  try {
756
1049
  const r = v(value, abort.signal)
757
1050
  if (r instanceof Promise) asyncPromises.push(r)
758
- else if (r != null) syncErrors.push(r)
1051
+ else appendIssues(syncIssues, r)
759
1052
  } catch (err) {
760
1053
  try {
761
1054
  this.onValidatorError?.(err)
762
1055
  } catch {
763
1056
  // The reporter must not propagate.
764
1057
  }
765
- syncErrors.push(err instanceof Error ? err.message : String(err))
1058
+ // Prod shows a generic message (don't leak internal error text into
1059
+ // form errors); the real error still reaches `onValidatorError` (T5.3).
1060
+ syncIssues.push({
1061
+ path: [],
1062
+ message: __DEV__
1063
+ ? err instanceof Error
1064
+ ? err.message
1065
+ : String(err)
1066
+ : 'Validation failed',
1067
+ })
766
1068
  }
767
1069
  }
768
1070
 
769
- if (syncErrors.length > 0) {
1071
+ if (syncIssues.length > 0) {
770
1072
  batch(() => {
771
- this.topLevelErrors$.set(syncErrors)
1073
+ this.lastFormErrorTargets = routeFormIssues(
1074
+ this,
1075
+ syncIssues,
1076
+ this.topLevelErrors$,
1077
+ this.lastFormErrorTargets,
1078
+ )
772
1079
  this.topLevelValidating$.set(false)
773
1080
  })
774
1081
  return
@@ -776,25 +1083,40 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
776
1083
 
777
1084
  if (asyncPromises.length === 0) {
778
1085
  batch(() => {
779
- this.topLevelErrors$.set([])
1086
+ this.lastFormErrorTargets = routeFormIssues(
1087
+ this,
1088
+ [],
1089
+ this.topLevelErrors$,
1090
+ this.lastFormErrorTargets,
1091
+ )
780
1092
  this.topLevelValidating$.set(false)
781
1093
  })
782
1094
  return
783
1095
  }
784
1096
 
785
1097
  batch(() => {
786
- this.topLevelErrors$.set([])
1098
+ this.lastFormErrorTargets = routeFormIssues(
1099
+ this,
1100
+ [],
1101
+ this.topLevelErrors$,
1102
+ this.lastFormErrorTargets,
1103
+ )
787
1104
  this.topLevelValidating$.set(true)
788
1105
  })
789
1106
 
790
1107
  Promise.allSettled(asyncPromises).then((results) => {
791
1108
  if (myId !== this.currentValidatorRun || this.disposed) return
792
- const errs: string[] = []
1109
+ const issues: FormIssue[] = []
793
1110
  for (const r of results) {
794
- if (r.status === 'fulfilled' && r.value != null) errs.push(r.value)
1111
+ if (r.status === 'fulfilled') appendIssues(issues, r.value)
795
1112
  }
796
1113
  batch(() => {
797
- this.topLevelErrors$.set(errs)
1114
+ this.lastFormErrorTargets = routeFormIssues(
1115
+ this,
1116
+ issues,
1117
+ this.topLevelErrors$,
1118
+ this.lastFormErrorTargets,
1119
+ )
798
1120
  this.topLevelValidating$.set(false)
799
1121
  })
800
1122
  })
@@ -1,12 +1,14 @@
1
+ export type { FieldTransform, ValidateOn } from './field'
1
2
  export type { StandardSchemaV1 } from './standard-schema'
2
3
  export { isStandardSchema } from './standard-schema'
3
- export type { Validator } from './types'
4
+ export type { FormIssue, Validator, ValidatorResult } from './types'
4
5
  export {
5
6
  email,
6
7
  max,
7
8
  maxLength,
8
9
  min,
9
10
  minLength,
11
+ mustBeTrue,
10
12
  pattern,
11
13
  required,
12
14
  validator,
@@ -1 +1,27 @@
1
- export type Validator<T> = (value: T, signal: AbortSignal) => string | null | Promise<string | null>
1
+ /**
2
+ * A single validation issue, optionally targeting a descendant of a form tree.
3
+ *
4
+ * - An **empty** `path` means the node the validator is attached to itself —
5
+ * for a leaf `Field` that's the field; for a `Form` / `FieldArray` it's the
6
+ * node's `topLevelErrors`.
7
+ * - A **non-empty** `path` routes the `message` to the matching descendant
8
+ * (`Form` walks keys, `FieldArray` walks numeric indices). Unresolvable
9
+ * paths fall back to the owning node's `topLevelErrors` rather than vanishing.
10
+ *
11
+ * Segments are object keys (`string`) or array indices (`number`). Returned by
12
+ * form-level validators (cross-field rules) and by the Standard-Schema
13
+ * `validator(...)` adapter, which maps each `issue.path` here. See SPEC §8.3.
14
+ */
15
+ export type FormIssue = { path: (string | number)[]; message: string }
16
+
17
+ /**
18
+ * What a {@link Validator} may return synchronously. A plain `string` is a
19
+ * message on the node itself (equivalent to a `FormIssue` with an empty path);
20
+ * `null` means "no error"; a `FormIssue[]` targets specific descendants.
21
+ */
22
+ export type ValidatorResult = string | null | FormIssue[]
23
+
24
+ export type Validator<T> = (
25
+ value: T,
26
+ signal: AbortSignal,
27
+ ) => ValidatorResult | Promise<ValidatorResult>