@kontsedal/olas-core 0.0.1-rc.0 → 0.0.1

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 (40) hide show
  1. package/dist/index.cjs +2 -1
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +13 -2
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +13 -2
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +2 -2
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-BImHnGj1.mjs → root-BCZDC5Fv.mjs} +442 -139
  10. package/dist/root-BCZDC5Fv.mjs.map +1 -0
  11. package/dist/{root-Bazp5_Ik.cjs → root-DXV1gVbQ.cjs} +447 -138
  12. package/dist/root-DXV1gVbQ.cjs.map +1 -0
  13. package/dist/testing.cjs +1 -1
  14. package/dist/testing.d.cts +1 -1
  15. package/dist/testing.d.mts +1 -1
  16. package/dist/testing.mjs +1 -1
  17. package/dist/{types-CAMgqCMz.d.mts → types-CffZ1QXt.d.cts} +82 -10
  18. package/dist/types-CffZ1QXt.d.cts.map +1 -0
  19. package/dist/{types-emq_lZd7.d.cts → types-DSlDowpE.d.mts} +82 -10
  20. package/dist/types-DSlDowpE.d.mts.map +1 -0
  21. package/package.json +28 -2
  22. package/src/controller/instance.ts +115 -15
  23. package/src/controller/root.ts +9 -1
  24. package/src/controller/types.ts +17 -7
  25. package/src/forms/field.ts +73 -8
  26. package/src/forms/form-types.ts +16 -0
  27. package/src/forms/form.ts +171 -21
  28. package/src/index.ts +5 -0
  29. package/src/query/client.ts +161 -6
  30. package/src/query/define.ts +14 -0
  31. package/src/query/entry.ts +64 -42
  32. package/src/query/infinite.ts +77 -55
  33. package/src/query/mutation.ts +11 -21
  34. package/src/query/plugin.ts +50 -0
  35. package/src/query/use.ts +80 -3
  36. package/src/utils.ts +24 -0
  37. package/dist/root-BImHnGj1.mjs.map +0 -1
  38. package/dist/root-Bazp5_Ik.cjs.map +0 -1
  39. package/dist/types-CAMgqCMz.d.mts.map +0 -1
  40. package/dist/types-emq_lZd7.d.cts.map +0 -1
@@ -48,7 +48,15 @@ export function createRootWithProps<Props, Api, TDeps extends Record<string, unk
48
48
  )
49
49
 
50
50
  // Bootstrap failure throws straight out of createRoot. Spec §12.1.5.
51
- const api = instance.construct(getFactory(def), props)
51
+ // Tear down the QueryClient and any plugins it spawned (window/storage
52
+ // listeners, transports) before re-throwing so the failure doesn't leak.
53
+ let api: Api
54
+ try {
55
+ api = instance.construct(getFactory(def), props)
56
+ } catch (err) {
57
+ queryClient.dispose()
58
+ throw err
59
+ }
52
60
 
53
61
  if (typeof api !== 'object' || api === null) {
54
62
  // Allow primitive APIs in principle but root controls must live somewhere.
@@ -127,18 +127,28 @@ export type Ctx<TDeps = AmbientDeps> = {
127
127
  ): Api
128
128
 
129
129
  /**
130
- * Like `child(...)` but additionally returns a `dispose()` handle so the
131
- * parent can tear down this specific sub-tree earlye.g. when the user
132
- * closes a details panel. The child is still disposed automatically when
133
- * the parent disposes; `dispose()` is idempotent and only earlies the
134
- * teardown. Useful for "openable" sub-controllers whose lifetime is driven
135
- * by a user gesture rather than the parent's lifetime alone.
130
+ * Like `child(...)` but additionally returns a handle that lets the parent
131
+ * control the attached sub-tree's lifecycle independently `dispose()`
132
+ * tears it down early, and `suspend()` / `resume()` freeze and thaw it.
133
+ * The child is still disposed automatically when the parent disposes;
134
+ * `dispose()` / `suspend()` / `resume()` are idempotent.
135
+ *
136
+ * `<KeepAlive controller={…}>` in `@kontsedal/olas-react` consumes the
137
+ * returned `{ suspend, resume }` directly — no hand-rolled `isPaused`
138
+ * signal needed on the child's `Api`. Useful for "openable" sub-
139
+ * controllers driven by a user gesture (modal, side panel, wizard).
140
+ *
141
+ * `suspend()` cascades through the attached controller's lifecycle
142
+ * entries: cache subscriptions pause `refetchInterval` and release the
143
+ * entry, effects are torn down, `onSuspend(...)` handlers fire. `resume()`
144
+ * re-runs effects, re-acquires cache entries (a stale entry refetches),
145
+ * and fires `onResume(...)`. Spec §4.1, §16.5.
136
146
  */
137
147
  attach<Props, Api>(
138
148
  def: ControllerDef<Props, Api>,
139
149
  props: Props,
140
150
  options?: { deps?: Partial<TDeps> },
141
- ): { api: Api; dispose: () => void }
151
+ ): { api: Api; dispose: () => void; suspend: () => void; resume: () => void }
142
152
 
143
153
  effect(fn: () => void | (() => void)): void
144
154
 
@@ -23,6 +23,17 @@ export type FieldDevtoolsOwner = {
23
23
  emitter: DevtoolsEmitter
24
24
  }
25
25
 
26
+ /**
27
+ * Optional reporter for synchronous validator throws — wired in by `ctx.field`
28
+ * (and `createForm` for leaf fields inside a form) so a thrown validator
29
+ * doesn't escape the signal effect silently. Without this, a buggy validator
30
+ * just stops contributing to `errors` and the field reads as "valid" while
31
+ * silently broken. With it, the throw is routed through `root.onError` as
32
+ * `kind: 'effect'` AND the throw's message lands in the field's `errors`
33
+ * array so the UI surfaces the problem.
34
+ */
35
+ export type ValidatorErrorReporter = (err: unknown) => void
36
+
26
37
  class FieldImpl<T> implements Field<T> {
27
38
  private readonly value$: Signal<T>
28
39
  private readonly errors$: Signal<string[]>
@@ -41,10 +52,20 @@ class FieldImpl<T> implements Field<T> {
41
52
  private runId = 0
42
53
  private disposed = false
43
54
  private devtoolsOwner: FieldDevtoolsOwner | null = null
55
+ private onValidatorError: ValidatorErrorReporter | null = null
44
56
 
45
- constructor(initial: T, validators: ReadonlyArray<Validator<T>> = []) {
57
+ constructor(
58
+ initial: T,
59
+ validators: ReadonlyArray<Validator<T>> = [],
60
+ options?: { onValidatorError?: ValidatorErrorReporter },
61
+ ) {
46
62
  this.initial = initial
47
63
  this.validators = validators
64
+ // Capture the reporter BEFORE the validator effect kicks off so a sync
65
+ // throw on the very first pass routes through `onError` instead of
66
+ // disappearing into the effect (`bindValidatorErrorReporter` is a
67
+ // post-construct hook so it can't catch the first run).
68
+ this.onValidatorError = options?.onValidatorError ?? null
48
69
  this.value$ = signal(initial)
49
70
  this.errors$ = signal<string[]>([])
50
71
  this.touched$ = signal(false)
@@ -60,6 +81,14 @@ class FieldImpl<T> implements Field<T> {
60
81
  }
61
82
  }
62
83
 
84
+ /**
85
+ * Internal hook for `ctx.field` / `createForm` to route synchronous
86
+ * validator throws through `root.onError`. See `ValidatorErrorReporter`.
87
+ */
88
+ bindValidatorErrorReporter(reporter: ValidatorErrorReporter | null): void {
89
+ this.onValidatorError = reporter
90
+ }
91
+
63
92
  // --- ReadSignal<T> ---
64
93
  get value(): T {
65
94
  return this.value$.value
@@ -207,11 +236,27 @@ class FieldImpl<T> implements Field<T> {
207
236
  const asyncPromises: Promise<string | null>[] = []
208
237
 
209
238
  for (const validator of this.validators) {
210
- const result = validator(value, abort.signal)
211
- if (result instanceof Promise) {
212
- asyncPromises.push(result)
213
- } else if (result != null) {
214
- syncErrors.push(result)
239
+ try {
240
+ const result = validator(value, abort.signal)
241
+ if (result instanceof Promise) {
242
+ // Defend against the validator promise rejecting *synchronously*
243
+ // with a thrown error (rare but legal) — the catch-handler in
244
+ // `Promise.allSettled` covers true async rejection.
245
+ asyncPromises.push(result)
246
+ } else if (result != null) {
247
+ syncErrors.push(result)
248
+ }
249
+ } catch (err) {
250
+ // A buggy validator that throws synchronously: surface it twice.
251
+ // (1) Route through `onError` so the user knows something is wrong.
252
+ // (2) Convert to a validation error string so the field reads invalid
253
+ // until the bug is fixed (don't pretend everything's OK).
254
+ try {
255
+ this.onValidatorError?.(err)
256
+ } catch {
257
+ // The reporter must not propagate.
258
+ }
259
+ syncErrors.push(err instanceof Error ? err.message : String(err))
215
260
  }
216
261
  }
217
262
 
@@ -270,8 +315,28 @@ export function bindFieldDevtoolsOwner<T>(field: Field<T>, owner: FieldDevtoolsO
270
315
  }
271
316
  }
272
317
 
273
- export function createField<T>(initial: T, validators?: ReadonlyArray<Validator<T>>): Field<T> {
274
- return new FieldImpl(initial, validators)
318
+ /**
319
+ * Internal — install a synchronous-validator-throw reporter on a `Field`
320
+ * (matched structurally to keep the public `Field<T>` surface stable).
321
+ * Called by `ctx.field` and `bindTreeToDevtools` so leaves inside a form/
322
+ * field-array tree get the same reporting as a standalone field.
323
+ */
324
+ export function bindFieldValidatorErrorReporter<T>(
325
+ field: Field<T>,
326
+ reporter: ValidatorErrorReporter | null,
327
+ ): void {
328
+ const impl = field as { bindValidatorErrorReporter?: (r: ValidatorErrorReporter | null) => void }
329
+ if (typeof impl.bindValidatorErrorReporter === 'function') {
330
+ impl.bindValidatorErrorReporter(reporter)
331
+ }
332
+ }
333
+
334
+ export function createField<T>(
335
+ initial: T,
336
+ validators?: ReadonlyArray<Validator<T>>,
337
+ options?: { onValidatorError?: ValidatorErrorReporter },
338
+ ): Field<T> {
339
+ return new FieldImpl(initial, validators, options)
275
340
  }
276
341
 
277
342
  /**
@@ -45,8 +45,24 @@ export type FormValidator<S extends FormSchema> = Validator<FormValue<S>>
45
45
  export type FieldArrayValidator<I> = Validator<FieldArrayValue<I>>
46
46
 
47
47
  export type FormOptions<S extends FormSchema> = {
48
+ /**
49
+ * Initial values for the form. A function form is **tracked** — if the
50
+ * function reads reactive signals (e.g. a query's `data`), the form re-seats
51
+ * itself when those signals change, but only while the form is not dirty
52
+ * (so a user mid-edit isn't clobbered by a background refetch). See
53
+ * `resetOnInitialChange` for opt-out. Spec §8.4.
54
+ */
48
55
  initial?: (() => DeepPartial<FormValue<S>> | undefined) | DeepPartial<FormValue<S>>
49
56
  validators?: FormValidator<S>[]
57
+ /**
58
+ * When `initial` is a function and one of its tracked deps changes:
59
+ * - `'when-clean'` (default) — re-seat only if the form is not dirty.
60
+ * - `'never'` — never re-seat; `initial()` runs once at construction.
61
+ * - `'always'` — re-seat unconditionally (dirty state is discarded).
62
+ *
63
+ * Spec §20.7.
64
+ */
65
+ resetOnInitialChange?: 'when-clean' | 'never' | 'always'
50
66
  }
51
67
 
52
68
  export type FieldArrayOptions<I> = {
package/src/forms/form.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import type { Field } from '../controller/types'
2
2
  import { batch, computed, effect, type Signal, signal, untracked } from '../signals'
3
3
  import type { ReadSignal } from '../signals/types'
4
- import { bindFieldDevtoolsOwner, createField } from './field'
4
+ import {
5
+ bindFieldDevtoolsOwner,
6
+ bindFieldValidatorErrorReporter,
7
+ createField,
8
+ type ValidatorErrorReporter,
9
+ } from './field'
5
10
  import type {
6
11
  DeepPartial,
7
12
  FieldArray,
@@ -49,21 +54,59 @@ class FormImpl<S extends FormSchema> implements Form<S> {
49
54
  private readonly validators: ReadonlyArray<FormValidator<S>>
50
55
  private readonly options: FormOptions<S> | undefined
51
56
  private validatorDispose: (() => void) | null = null
57
+ private initialDispose: (() => void) | null = null
52
58
  private currentValidatorRun = 0
53
59
  private currentValidatorAbort: AbortController | null = null
54
60
  private disposed = false
61
+ private onValidatorError: ((err: unknown) => void) | null = null
55
62
 
56
- constructor(schema: S, options?: FormOptions<S>) {
63
+ /** Internal wire a sync-throw reporter for the top-level validators. */
64
+ bindValidatorErrorReporter(reporter: ((err: unknown) => void) | null): void {
65
+ this.onValidatorError = reporter
66
+ }
67
+
68
+ constructor(
69
+ schema: S,
70
+ options?: FormOptions<S>,
71
+ internalOptions?: { onValidatorError?: (err: unknown) => void },
72
+ ) {
57
73
  this.fields = schema
58
74
  this.options = options
59
75
  this.validators = options?.validators ?? []
60
-
61
- // Apply initial values (one-shot or initial snapshot from a function).
62
- // `asInitial: true` flag tells leaf fields to set their value AND re-anchor
63
- // their `reset()` target without marking themselves dirty.
76
+ // Capture reporter BEFORE the top-level validator effect kicks off in
77
+ // this constructor mirrors the FieldImpl fix.
78
+ this.onValidatorError = internalOptions?.onValidatorError ?? null
79
+
80
+ // Initial values — supports both the static shape and the tracked-function
81
+ // shape from spec §8.4. For the function form, wrap in an effect so a
82
+ // change to any tracked signal re-seats the form (subject to the dirty
83
+ // guard from `resetOnInitialChange`).
64
84
  if (options?.initial !== undefined) {
65
- const ini = typeof options.initial === 'function' ? options.initial() : options.initial
66
- if (ini !== undefined) this.applyPartial(ini as DeepPartial<FormValue<S>>, true)
85
+ if (typeof options.initial === 'function') {
86
+ const initialFn = options.initial
87
+ const mode = options.resetOnInitialChange ?? 'when-clean'
88
+ let firstRun = true
89
+ this.initialDispose = effect(() => {
90
+ // Track signals read by `initialFn`. The dirty-guard MUST run
91
+ // untracked — otherwise `isDirty` would become a dep and re-seating
92
+ // on user input would cascade.
93
+ const ini = initialFn()
94
+ if (ini === undefined) return
95
+ untracked(() => {
96
+ if (this.disposed) return
97
+ if (firstRun) {
98
+ firstRun = false
99
+ this.applyPartial(ini as DeepPartial<FormValue<S>>, true)
100
+ return
101
+ }
102
+ if (mode === 'never') return
103
+ if (mode === 'when-clean' && this.isDirty.peek()) return
104
+ this.applyPartial(ini as DeepPartial<FormValue<S>>, true)
105
+ })
106
+ })
107
+ } else {
108
+ this.applyPartial(options.initial as DeepPartial<FormValue<S>>, true)
109
+ }
67
110
  }
68
111
 
69
112
  this.value = computed(() => this.computeValue())
@@ -145,6 +188,10 @@ class FormImpl<S extends FormSchema> implements Form<S> {
145
188
  for (const [k, val] of Object.entries(partial)) {
146
189
  const child = (this.fields as Record<string, unknown>)[k]
147
190
  if (!child) continue
191
+ // `partial.someNestedForm === undefined` means "leave this subtree
192
+ // alone", not "reset it with undefined" — which would crash on
193
+ // `Object.entries(undefined)`.
194
+ if (val === undefined) continue
148
195
  if (isForm(child)) {
149
196
  // Nested form: recurse via its own `set` (user) or rebuild via reset
150
197
  // through the same `applyPartial`-with-`asInitial` flag (initial).
@@ -214,6 +261,12 @@ class FormImpl<S extends FormSchema> implements Form<S> {
214
261
  }
215
262
  }
216
263
  await Promise.all(tasks)
264
+ // Kick a fresh top-level run so the surface matches "re-run every
265
+ // validator" — without this, `validate()` would skip top-level if it
266
+ // settled before the call and the value hasn't tracked-changed since.
267
+ if (this.validators.length > 0) {
268
+ this.runTopLevelValidators()
269
+ }
217
270
  // Wait for top-level validators to finish.
218
271
  if (this.topLevelValidating$.peek()) {
219
272
  await new Promise<void>((resolve) => {
@@ -232,6 +285,7 @@ class FormImpl<S extends FormSchema> implements Form<S> {
232
285
  if (this.disposed) return
233
286
  this.disposed = true
234
287
  this.validatorDispose?.()
288
+ this.initialDispose?.()
235
289
  this.currentValidatorAbort?.abort()
236
290
  for (const child of Object.values(this.fields)) {
237
291
  ;(child as { dispose?: () => void }).dispose?.()
@@ -249,9 +303,18 @@ class FormImpl<S extends FormSchema> implements Form<S> {
249
303
  const syncErrors: string[] = []
250
304
  const asyncPromises: Promise<string | null>[] = []
251
305
  for (const v of this.validators) {
252
- const r = v(value, abort.signal)
253
- if (r instanceof Promise) asyncPromises.push(r)
254
- else if (r != null) syncErrors.push(r)
306
+ try {
307
+ const r = v(value, abort.signal)
308
+ if (r instanceof Promise) asyncPromises.push(r)
309
+ else if (r != null) syncErrors.push(r)
310
+ } catch (err) {
311
+ try {
312
+ this.onValidatorError?.(err)
313
+ } catch {
314
+ // The reporter must not propagate.
315
+ }
316
+ syncErrors.push(err instanceof Error ? err.message : String(err))
317
+ }
255
318
  }
256
319
 
257
320
  if (syncErrors.length > 0) {
@@ -346,10 +409,21 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
346
409
  private currentValidatorAbort: AbortController | null = null
347
410
  private validatorDispose: (() => void) | null = null
348
411
  private disposed = false
412
+ private onValidatorError: ((err: unknown) => void) | null = null
349
413
 
350
- constructor(itemFactory: (initial?: ItemInitial<I>) => I, options?: FieldArrayOptions<I>) {
414
+ /** Internal see `FormImpl.bindValidatorErrorReporter`. */
415
+ bindValidatorErrorReporter(reporter: ((err: unknown) => void) | null): void {
416
+ this.onValidatorError = reporter
417
+ }
418
+
419
+ constructor(
420
+ itemFactory: (initial?: ItemInitial<I>) => I,
421
+ options?: FieldArrayOptions<I>,
422
+ internalOptions?: { onValidatorError?: (err: unknown) => void },
423
+ ) {
351
424
  this.itemFactory = itemFactory
352
425
  this.validators = options?.validators ?? []
426
+ this.onValidatorError = internalOptions?.onValidatorError ?? null
353
427
  this.items$ = signal<I[]>([])
354
428
  if (options?.initial) {
355
429
  this.initialItems = options.initial
@@ -480,6 +554,10 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
480
554
  else tasks.push((item as Field<unknown>).revalidate())
481
555
  }
482
556
  await Promise.all(tasks)
557
+ // Fresh top-level run — see `FormImpl.validate` for the rationale.
558
+ if (this.validators.length > 0) {
559
+ this.runTopLevelValidators()
560
+ }
483
561
  if (this.topLevelValidating$.peek()) {
484
562
  await new Promise<void>((resolve) => {
485
563
  const unsub = this.topLevelValidating$.subscribe((v) => {
@@ -514,9 +592,18 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
514
592
  const syncErrors: string[] = []
515
593
  const asyncPromises: Promise<string | null>[] = []
516
594
  for (const v of this.validators) {
517
- const r = v(value, abort.signal)
518
- if (r instanceof Promise) asyncPromises.push(r)
519
- else if (r != null) syncErrors.push(r)
595
+ try {
596
+ const r = v(value, abort.signal)
597
+ if (r instanceof Promise) asyncPromises.push(r)
598
+ else if (r != null) syncErrors.push(r)
599
+ } catch (err) {
600
+ try {
601
+ this.onValidatorError?.(err)
602
+ } catch {
603
+ // The reporter must not propagate.
604
+ }
605
+ syncErrors.push(err instanceof Error ? err.message : String(err))
606
+ }
520
607
  }
521
608
 
522
609
  if (syncErrors.length > 0) {
@@ -554,15 +641,20 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
554
641
  }
555
642
  }
556
643
 
557
- export function createForm<S extends FormSchema>(schema: S, options?: FormOptions<S>): Form<S> {
558
- return new FormImpl(schema, options)
644
+ export function createForm<S extends FormSchema>(
645
+ schema: S,
646
+ options?: FormOptions<S>,
647
+ internalOptions?: { onValidatorError?: (err: unknown) => void },
648
+ ): Form<S> {
649
+ return new FormImpl(schema, options, internalOptions)
559
650
  }
560
651
 
561
652
  export function createFieldArray<I extends Field<any> | Form<any>>(
562
653
  itemFactory: (initial?: ItemInitial<I>) => I,
563
654
  options?: FieldArrayOptions<I>,
655
+ internalOptions?: { onValidatorError?: (err: unknown) => void },
564
656
  ): FieldArray<I> {
565
- return new FieldArrayImpl<I>(itemFactory, options)
657
+ return new FieldArrayImpl<I>(itemFactory, options, internalOptions)
566
658
  }
567
659
 
568
660
  /**
@@ -614,16 +706,40 @@ function bindTreeToDevtoolsInto(
614
706
  }
615
707
  if (isFieldArray(node)) {
616
708
  // Re-bind on every items change so dynamically-added entries get tracked.
617
- // `effect()` returns its own disposer; capture it so the controller can
618
- // tear it down on dispose (otherwise dynamic forms leak reactive work).
709
+ // Each re-bind has its own disposer set scoped to that pass; on the next
710
+ // items change we flush the previous pass's disposers BEFORE creating the
711
+ // new effects, so a churning array doesn't accumulate reactive work.
712
+ // (Pre-fix, every items mutation appended fresh effects to the outer
713
+ // `disposers` array and never released the old ones.)
619
714
  const arr = node as FieldArray<Field<unknown> | Form<FormSchema>>
715
+ let perPass: Array<() => void> = []
620
716
  const stop = effect(() => {
621
717
  const items = arr.items.value
718
+ // Flush previous pass before rebinding the new item set.
719
+ for (const d of perPass) {
720
+ try {
721
+ d()
722
+ } catch {
723
+ // Disposer failures must not break sibling cleanup.
724
+ }
725
+ }
726
+ perPass = []
622
727
  items.forEach((item, idx) => {
623
- bindTreeToDevtoolsInto(item, `${prefix}[${idx}]`, controllerPath, emitter, disposers)
728
+ bindTreeToDevtoolsInto(item, `${prefix}[${idx}]`, controllerPath, emitter, perPass)
624
729
  })
625
730
  })
626
731
  disposers.push(stop)
732
+ // On final dispose, drain the per-pass disposers too.
733
+ disposers.push(() => {
734
+ for (const d of perPass) {
735
+ try {
736
+ d()
737
+ } catch {
738
+ // Ignore.
739
+ }
740
+ }
741
+ perPass = []
742
+ })
627
743
  return
628
744
  }
629
745
  // Leaf Field.
@@ -634,6 +750,40 @@ function bindTreeToDevtoolsInto(
634
750
  })
635
751
  }
636
752
 
753
+ /**
754
+ * Walk a Form/FieldArray subtree and install `reporter` on every level —
755
+ * leaf fields, nested forms' top-level validators, and field-arrays' top-level
756
+ * validators. Called by `ctx.form` / `ctx.fieldArray` so synchronous validator
757
+ * throws anywhere in the tree route through `root.onError`. See
758
+ * `ValidatorErrorReporter` in `./field.ts`.
759
+ */
760
+ export function bindTreeValidatorErrorReporter(
761
+ node: Field<unknown> | Form<FormSchema> | FieldArray<Field<unknown> | Form<FormSchema>>,
762
+ reporter: ValidatorErrorReporter | null,
763
+ ): void {
764
+ if (isForm(node)) {
765
+ const impl = node as { bindValidatorErrorReporter?: (r: ValidatorErrorReporter | null) => void }
766
+ impl.bindValidatorErrorReporter?.(reporter)
767
+ for (const child of Object.values(node.fields)) {
768
+ bindTreeValidatorErrorReporter(child, reporter)
769
+ }
770
+ return
771
+ }
772
+ if (isFieldArray(node)) {
773
+ const impl = node as { bindValidatorErrorReporter?: (r: ValidatorErrorReporter | null) => void }
774
+ impl.bindValidatorErrorReporter?.(reporter)
775
+ // Items currently in the array. (Items added later won't get the reporter
776
+ // unless `ctx.fieldArray` is wrapped to rebind — but the leaf items in the
777
+ // typical pattern come from a user factory that constructs through
778
+ // `createField` and is bound here by the parent traversal.)
779
+ for (const item of node.items.value) {
780
+ bindTreeValidatorErrorReporter(item, reporter)
781
+ }
782
+ return
783
+ }
784
+ bindFieldValidatorErrorReporter(node as Field<unknown>, reporter)
785
+ }
786
+
637
787
  // Quiet unused-import linter without exporting these symbols publicly.
638
788
  void createField
639
789
  void untracked
package/src/index.ts CHANGED
@@ -44,6 +44,11 @@ export type {
44
44
  InfiniteQuerySpec,
45
45
  InfiniteQuerySubscription,
46
46
  } from './query/infinite'
47
+ // Key hashing — exported so plugins (entities, etc.) that need a stable
48
+ // per-`keyArgs` index key reuse the canonical implementation instead of
49
+ // rolling their own ad-hoc JSON.stringify (which mishandles Date, key
50
+ // ordering, and `undefined`).
51
+ export { stableHash } from './query/keys'
47
52
  export type {
48
53
  Mutation,
49
54
  MutationConcurrency,