@kontsedal/olas-core 0.0.1-rc.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -0
  3. package/dist/index.cjs +363 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +178 -0
  6. package/dist/index.d.cts.map +1 -0
  7. package/dist/index.d.mts +178 -0
  8. package/dist/index.d.mts.map +1 -0
  9. package/dist/index.mjs +339 -0
  10. package/dist/index.mjs.map +1 -0
  11. package/dist/root-BImHnGj1.mjs +3270 -0
  12. package/dist/root-BImHnGj1.mjs.map +1 -0
  13. package/dist/root-Bazp5_Ik.cjs +3347 -0
  14. package/dist/root-Bazp5_Ik.cjs.map +1 -0
  15. package/dist/testing.cjs +81 -0
  16. package/dist/testing.cjs.map +1 -0
  17. package/dist/testing.d.cts +56 -0
  18. package/dist/testing.d.cts.map +1 -0
  19. package/dist/testing.d.mts +56 -0
  20. package/dist/testing.d.mts.map +1 -0
  21. package/dist/testing.mjs +78 -0
  22. package/dist/testing.mjs.map +1 -0
  23. package/dist/types-CAMgqCMz.d.mts +816 -0
  24. package/dist/types-CAMgqCMz.d.mts.map +1 -0
  25. package/dist/types-emq_lZd7.d.cts +816 -0
  26. package/dist/types-emq_lZd7.d.cts.map +1 -0
  27. package/package.json +47 -0
  28. package/src/__dev__.d.ts +8 -0
  29. package/src/controller/define.ts +50 -0
  30. package/src/controller/index.ts +12 -0
  31. package/src/controller/instance.ts +499 -0
  32. package/src/controller/root.ts +160 -0
  33. package/src/controller/types.ts +195 -0
  34. package/src/devtools.ts +0 -0
  35. package/src/emitter.ts +79 -0
  36. package/src/errors.ts +49 -0
  37. package/src/forms/field.ts +303 -0
  38. package/src/forms/form-types.ts +130 -0
  39. package/src/forms/form.ts +640 -0
  40. package/src/forms/index.ts +2 -0
  41. package/src/forms/types.ts +1 -0
  42. package/src/forms/validators.ts +70 -0
  43. package/src/index.ts +89 -0
  44. package/src/query/client.ts +934 -0
  45. package/src/query/define.ts +154 -0
  46. package/src/query/entry.ts +322 -0
  47. package/src/query/focus-online.ts +73 -0
  48. package/src/query/index.ts +3 -0
  49. package/src/query/infinite.ts +462 -0
  50. package/src/query/keys.ts +33 -0
  51. package/src/query/local.ts +113 -0
  52. package/src/query/mutation.ts +384 -0
  53. package/src/query/plugin.ts +135 -0
  54. package/src/query/types.ts +168 -0
  55. package/src/query/use.ts +321 -0
  56. package/src/scope.ts +42 -0
  57. package/src/selection.ts +146 -0
  58. package/src/signals/index.ts +3 -0
  59. package/src/signals/readonly.ts +22 -0
  60. package/src/signals/runtime.ts +115 -0
  61. package/src/signals/types.ts +31 -0
  62. package/src/testing.ts +142 -0
  63. package/src/timing/debounced.ts +32 -0
  64. package/src/timing/index.ts +2 -0
  65. package/src/timing/throttled.ts +46 -0
  66. package/src/utils.ts +13 -0
@@ -0,0 +1,640 @@
1
+ import type { Field } from '../controller/types'
2
+ import { batch, computed, effect, type Signal, signal, untracked } from '../signals'
3
+ import type { ReadSignal } from '../signals/types'
4
+ import { bindFieldDevtoolsOwner, createField } from './field'
5
+ import type {
6
+ DeepPartial,
7
+ FieldArray,
8
+ FieldArrayItemErrors,
9
+ FieldArrayOptions,
10
+ FieldArrayValidator,
11
+ FieldArrayValue,
12
+ Form,
13
+ FormErrors,
14
+ FormOptions,
15
+ FormSchema,
16
+ FormValidator,
17
+ FormValue,
18
+ ItemInitial,
19
+ } from './form-types'
20
+
21
+ const FORM_BRAND = Symbol.for('olas.form')
22
+ const FIELD_ARRAY_BRAND = Symbol.for('olas.fieldArray')
23
+
24
+ const isForm = (x: unknown): x is Form<FormSchema> =>
25
+ typeof x === 'object' && x !== null && (x as Record<symbol, unknown>)[FORM_BRAND] === true
26
+
27
+ const isFieldArray = (x: unknown): x is FieldArray<Field<unknown> | Form<FormSchema>> =>
28
+ typeof x === 'object' && x !== null && (x as Record<symbol, unknown>)[FIELD_ARRAY_BRAND] === true
29
+
30
+ const isField = (x: unknown): x is Field<unknown> =>
31
+ typeof x === 'object' && x !== null && !isForm(x) && !isFieldArray(x)
32
+
33
+ class FormImpl<S extends FormSchema> implements Form<S> {
34
+ readonly [FORM_BRAND] = true
35
+
36
+ readonly fields: S
37
+ readonly value: ReadSignal<FormValue<S>>
38
+ readonly errors: ReadSignal<FormErrors<S>>
39
+ readonly isValid: ReadSignal<boolean>
40
+ readonly isDirty: ReadSignal<boolean>
41
+ readonly touched: ReadSignal<boolean>
42
+ readonly isValidating: ReadSignal<boolean>
43
+ readonly flatErrors: ReadSignal<Array<{ path: string; errors: string[] }>>
44
+
45
+ private readonly topLevelErrors$: Signal<string[]> = signal([])
46
+ readonly topLevelErrors: ReadSignal<string[]> = this.topLevelErrors$
47
+ private readonly topLevelValidating$: Signal<boolean> = signal(false)
48
+
49
+ private readonly validators: ReadonlyArray<FormValidator<S>>
50
+ private readonly options: FormOptions<S> | undefined
51
+ private validatorDispose: (() => void) | null = null
52
+ private currentValidatorRun = 0
53
+ private currentValidatorAbort: AbortController | null = null
54
+ private disposed = false
55
+
56
+ constructor(schema: S, options?: FormOptions<S>) {
57
+ this.fields = schema
58
+ this.options = options
59
+ 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.
64
+ 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)
67
+ }
68
+
69
+ this.value = computed(() => this.computeValue())
70
+ this.errors = computed(() => this.computeErrors())
71
+ this.isDirty = computed(() => this.computeBool('isDirty'))
72
+ this.touched = computed(() => this.computeBool('touched'))
73
+ this.isValidating = computed(() => {
74
+ if (this.topLevelValidating$.value) return true
75
+ for (const child of Object.values(this.fields)) {
76
+ if ((child as { isValidating: ReadSignal<boolean> }).isValidating.value) return true
77
+ }
78
+ return false
79
+ })
80
+ this.isValid = computed(() => {
81
+ if (this.topLevelErrors$.value.length > 0) return false
82
+ if (this.isValidating.value) return false
83
+ for (const child of Object.values(this.fields)) {
84
+ if (!(child as { isValid: ReadSignal<boolean> }).isValid.value) return false
85
+ }
86
+ return true
87
+ })
88
+ this.flatErrors = computed(() => this.computeFlatErrors())
89
+
90
+ if (this.validators.length > 0) {
91
+ this.validatorDispose = effect(() => this.runTopLevelValidators())
92
+ }
93
+ }
94
+
95
+ private computeValue(): FormValue<S> {
96
+ const out: Record<string, unknown> = {}
97
+ for (const [k, child] of Object.entries(this.fields)) {
98
+ if (isForm(child) || isFieldArray(child)) {
99
+ out[k] = (child as { value: ReadSignal<unknown> }).value.value
100
+ } else {
101
+ // Field<T> is itself a ReadSignal<T>; .value returns T (tracked).
102
+ out[k] = (child as Field<unknown>).value
103
+ }
104
+ }
105
+ return out as FormValue<S>
106
+ }
107
+
108
+ private computeErrors(): FormErrors<S> {
109
+ const out: Record<string, unknown> = {}
110
+ for (const [k, child] of Object.entries(this.fields)) {
111
+ if (isForm(child)) {
112
+ out[k] = child.errors.value
113
+ } else if (isFieldArray(child)) {
114
+ out[k] = child.errors.value
115
+ } else {
116
+ const errs = (child as Field<unknown>).errors.value
117
+ out[k] = errs.length > 0 ? errs : undefined
118
+ }
119
+ }
120
+ return out as FormErrors<S>
121
+ }
122
+
123
+ private computeBool(key: 'isDirty' | 'touched'): boolean {
124
+ for (const child of Object.values(this.fields)) {
125
+ const sig = (child as unknown as Record<string, ReadSignal<boolean>>)[key]
126
+ if (sig?.value) return true
127
+ }
128
+ return false
129
+ }
130
+
131
+ private computeFlatErrors(): Array<{ path: string; errors: string[] }> {
132
+ const out: Array<{ path: string; errors: string[] }> = []
133
+ const tle = this.topLevelErrors$.value
134
+ if (tle.length > 0) out.push({ path: '', errors: tle })
135
+ walkErrors(this.fields, '', out)
136
+ return out
137
+ }
138
+
139
+ set(partial: DeepPartial<FormValue<S>>): void {
140
+ if (this.disposed) return
141
+ batch(() => this.applyPartial(partial, false))
142
+ }
143
+
144
+ private applyPartial(partial: DeepPartial<FormValue<S>>, asInitial: boolean): void {
145
+ for (const [k, val] of Object.entries(partial)) {
146
+ const child = (this.fields as Record<string, unknown>)[k]
147
+ if (!child) continue
148
+ if (isForm(child)) {
149
+ // Nested form: recurse via its own `set` (user) or rebuild via reset
150
+ // through the same `applyPartial`-with-`asInitial` flag (initial).
151
+ if (asInitial) {
152
+ ;(child as Form<FormSchema>).resetWithInitial(val as DeepPartial<FormValue<FormSchema>>)
153
+ } else {
154
+ child.set(val as DeepPartial<FormValue<FormSchema>>)
155
+ }
156
+ } else if (isFieldArray(child)) {
157
+ const arr = child
158
+ // Replace items: clear, then add each
159
+ arr.clear()
160
+ for (const itemVal of val as unknown[]) {
161
+ arr.add(itemVal as ItemInitial<Field<unknown>>)
162
+ }
163
+ } else {
164
+ const f = child as Field<unknown>
165
+ if (asInitial) f.setAsInitial(val)
166
+ else f.set(val)
167
+ }
168
+ }
169
+ }
170
+
171
+ /** Internal: re-seat this form's leaves from `partial` as their new initial. */
172
+ resetWithInitial(partial: DeepPartial<FormValue<S>>): void {
173
+ if (this.disposed) return
174
+ batch(() => this.applyPartial(partial, true))
175
+ }
176
+
177
+ reset(): void {
178
+ if (this.disposed) return
179
+ batch(() => {
180
+ for (const child of Object.values(this.fields)) {
181
+ if (isForm(child) || isFieldArray(child)) {
182
+ ;(child as { reset: () => void }).reset()
183
+ } else {
184
+ ;(child as Field<unknown>).reset()
185
+ }
186
+ }
187
+ this.topLevelErrors$.set([])
188
+ })
189
+ // Re-apply initial if provided — as initial (no dirty bump).
190
+ if (this.options?.initial !== undefined) {
191
+ const ini =
192
+ typeof this.options.initial === 'function' ? this.options.initial() : this.options.initial
193
+ if (ini !== undefined) this.applyPartial(ini as DeepPartial<FormValue<S>>, true)
194
+ }
195
+ }
196
+
197
+ markAllTouched(): void {
198
+ if (this.disposed) return
199
+ for (const child of Object.values(this.fields)) {
200
+ if (isForm(child)) child.markAllTouched()
201
+ else if (isFieldArray(child)) child.markAllTouched()
202
+ else (child as Field<unknown>).markTouched()
203
+ }
204
+ }
205
+
206
+ async validate(): Promise<boolean> {
207
+ if (this.disposed) return this.isValid.peek()
208
+ const tasks: Promise<unknown>[] = []
209
+ for (const child of Object.values(this.fields)) {
210
+ if (isForm(child) || isFieldArray(child)) {
211
+ tasks.push((child as { validate: () => Promise<boolean> }).validate())
212
+ } else {
213
+ tasks.push((child as Field<unknown>).revalidate())
214
+ }
215
+ }
216
+ await Promise.all(tasks)
217
+ // Wait for top-level validators to finish.
218
+ if (this.topLevelValidating$.peek()) {
219
+ await new Promise<void>((resolve) => {
220
+ const unsub = this.topLevelValidating$.subscribe((v) => {
221
+ if (!v) {
222
+ unsub()
223
+ resolve()
224
+ }
225
+ })
226
+ })
227
+ }
228
+ return this.isValid.peek()
229
+ }
230
+
231
+ dispose(): void {
232
+ if (this.disposed) return
233
+ this.disposed = true
234
+ this.validatorDispose?.()
235
+ this.currentValidatorAbort?.abort()
236
+ for (const child of Object.values(this.fields)) {
237
+ ;(child as { dispose?: () => void }).dispose?.()
238
+ }
239
+ }
240
+
241
+ private runTopLevelValidators(): void {
242
+ if (this.disposed) return
243
+ const value = this.value.value
244
+ this.currentValidatorAbort?.abort()
245
+ const abort = new AbortController()
246
+ this.currentValidatorAbort = abort
247
+ const myId = ++this.currentValidatorRun
248
+
249
+ const syncErrors: string[] = []
250
+ const asyncPromises: Promise<string | null>[] = []
251
+ 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)
255
+ }
256
+
257
+ if (syncErrors.length > 0) {
258
+ batch(() => {
259
+ this.topLevelErrors$.set(syncErrors)
260
+ this.topLevelValidating$.set(false)
261
+ })
262
+ return
263
+ }
264
+
265
+ if (asyncPromises.length === 0) {
266
+ batch(() => {
267
+ this.topLevelErrors$.set([])
268
+ this.topLevelValidating$.set(false)
269
+ })
270
+ return
271
+ }
272
+
273
+ batch(() => {
274
+ this.topLevelErrors$.set([])
275
+ this.topLevelValidating$.set(true)
276
+ })
277
+
278
+ Promise.allSettled(asyncPromises).then((results) => {
279
+ if (myId !== this.currentValidatorRun || this.disposed) return
280
+ const errs: string[] = []
281
+ for (const r of results) {
282
+ if (r.status === 'fulfilled' && r.value != null) errs.push(r.value)
283
+ }
284
+ batch(() => {
285
+ this.topLevelErrors$.set(errs)
286
+ this.topLevelValidating$.set(false)
287
+ })
288
+ })
289
+ }
290
+ }
291
+
292
+ function walkErrors(
293
+ fields: FormSchema,
294
+ prefix: string,
295
+ out: Array<{ path: string; errors: string[] }>,
296
+ ): void {
297
+ for (const [k, child] of Object.entries(fields)) {
298
+ const path = prefix ? `${prefix}.${k}` : k
299
+ if (isForm(child)) {
300
+ const tle = child.topLevelErrors.value
301
+ if (tle.length > 0) out.push({ path, errors: tle })
302
+ walkErrors(child.fields, path, out)
303
+ } else if (isFieldArray(child)) {
304
+ const tle = child.topLevelErrors.value
305
+ if (tle.length > 0) out.push({ path, errors: tle })
306
+ const items = child.items.value
307
+ items.forEach((item, idx) => {
308
+ const itemPath = `${path}[${idx}]`
309
+ if (isForm(item)) {
310
+ const itle = item.topLevelErrors.value
311
+ if (itle.length > 0) out.push({ path: itemPath, errors: itle })
312
+ walkErrors(item.fields, itemPath, out)
313
+ } else {
314
+ const errs = (item as Field<unknown>).errors.value
315
+ if (errs.length > 0) out.push({ path: itemPath, errors: errs })
316
+ }
317
+ })
318
+ } else {
319
+ const errs = (child as Field<unknown>).errors.value
320
+ if (errs.length > 0) out.push({ path, errors: errs })
321
+ }
322
+ }
323
+ }
324
+
325
+ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I> {
326
+ readonly [FIELD_ARRAY_BRAND] = true
327
+
328
+ readonly items: ReadSignal<ReadonlyArray<I>>
329
+ readonly value: ReadSignal<FieldArrayValue<I>>
330
+ readonly errors: ReadSignal<Array<FieldArrayItemErrors<I> | undefined>>
331
+ readonly size: ReadSignal<number>
332
+ readonly isValid: ReadSignal<boolean>
333
+ readonly isDirty: ReadSignal<boolean>
334
+ readonly touched: ReadSignal<boolean>
335
+ readonly isValidating: ReadSignal<boolean>
336
+
337
+ private readonly items$: Signal<I[]>
338
+ private readonly topLevelErrors$: Signal<string[]> = signal([])
339
+ readonly topLevelErrors: ReadSignal<string[]> = this.topLevelErrors$
340
+ private readonly topLevelValidating$: Signal<boolean> = signal(false)
341
+
342
+ private readonly itemFactory: (initial?: ItemInitial<I>) => I
343
+ private readonly initialItems: Array<ItemInitial<I>> = []
344
+ private readonly validators: ReadonlyArray<FieldArrayValidator<I>>
345
+ private currentValidatorRun = 0
346
+ private currentValidatorAbort: AbortController | null = null
347
+ private validatorDispose: (() => void) | null = null
348
+ private disposed = false
349
+
350
+ constructor(itemFactory: (initial?: ItemInitial<I>) => I, options?: FieldArrayOptions<I>) {
351
+ this.itemFactory = itemFactory
352
+ this.validators = options?.validators ?? []
353
+ this.items$ = signal<I[]>([])
354
+ if (options?.initial) {
355
+ this.initialItems = options.initial
356
+ for (const ini of options.initial) {
357
+ this.items$.peek().push(itemFactory(ini))
358
+ }
359
+ // re-set to trigger subscribers
360
+ this.items$.set([...this.items$.peek()])
361
+ }
362
+
363
+ this.items = this.items$
364
+ this.size = computed(() => this.items$.value.length)
365
+ this.value = computed(
366
+ () =>
367
+ this.items$.value.map((item) => {
368
+ if (isForm(item)) return item.value.value
369
+ // Field is a ReadSignal — `.value` is the actual value.
370
+ return (item as Field<unknown>).value
371
+ }) as FieldArrayValue<I>,
372
+ )
373
+ this.errors = computed(() =>
374
+ this.items$.value.map((item) => {
375
+ if (isForm(item)) return item.errors.value as FieldArrayItemErrors<I>
376
+ const errs = (item as Field<unknown>).errors.value
377
+ return (errs.length > 0 ? errs : undefined) as FieldArrayItemErrors<I> | undefined
378
+ }),
379
+ )
380
+ this.isDirty = computed(() => {
381
+ for (const item of this.items$.value) {
382
+ if ((item as { isDirty: ReadSignal<boolean> }).isDirty.value) return true
383
+ }
384
+ return false
385
+ })
386
+ this.touched = computed(() => {
387
+ for (const item of this.items$.value) {
388
+ if ((item as { touched: ReadSignal<boolean> }).touched.value) return true
389
+ }
390
+ return false
391
+ })
392
+ this.isValidating = computed(() => {
393
+ if (this.topLevelValidating$.value) return true
394
+ for (const item of this.items$.value) {
395
+ if ((item as { isValidating: ReadSignal<boolean> }).isValidating.value) return true
396
+ }
397
+ return false
398
+ })
399
+ this.isValid = computed(() => {
400
+ if (this.topLevelErrors$.value.length > 0) return false
401
+ if (this.isValidating.value) return false
402
+ for (const item of this.items$.value) {
403
+ if (!(item as { isValid: ReadSignal<boolean> }).isValid.value) return false
404
+ }
405
+ return true
406
+ })
407
+
408
+ if (this.validators.length > 0) {
409
+ this.validatorDispose = effect(() => this.runTopLevelValidators())
410
+ }
411
+ }
412
+
413
+ at(index: number): I | undefined {
414
+ return this.items$.peek()[index]
415
+ }
416
+
417
+ add(initial?: ItemInitial<I>): void {
418
+ if (this.disposed) return
419
+ const item = this.itemFactory(initial)
420
+ this.items$.set([...this.items$.peek(), item])
421
+ }
422
+
423
+ insert(index: number, initial?: ItemInitial<I>): void {
424
+ if (this.disposed) return
425
+ const item = this.itemFactory(initial)
426
+ const next = [...this.items$.peek()]
427
+ next.splice(index, 0, item)
428
+ this.items$.set(next)
429
+ }
430
+
431
+ remove(index: number): void {
432
+ if (this.disposed) return
433
+ const next = [...this.items$.peek()]
434
+ const [removed] = next.splice(index, 1)
435
+ if (removed) {
436
+ ;(removed as { dispose?: () => void }).dispose?.()
437
+ }
438
+ this.items$.set(next)
439
+ }
440
+
441
+ move(from: number, to: number): void {
442
+ if (this.disposed) return
443
+ const next = [...this.items$.peek()]
444
+ const [item] = next.splice(from, 1)
445
+ if (item) next.splice(to, 0, item)
446
+ this.items$.set(next)
447
+ }
448
+
449
+ clear(): void {
450
+ if (this.disposed) return
451
+ for (const item of this.items$.peek()) {
452
+ ;(item as { dispose?: () => void }).dispose?.()
453
+ }
454
+ this.items$.set([])
455
+ }
456
+
457
+ reset(): void {
458
+ if (this.disposed) return
459
+ batch(() => {
460
+ this.clear()
461
+ for (const ini of this.initialItems) {
462
+ this.add(ini)
463
+ }
464
+ this.topLevelErrors$.set([])
465
+ })
466
+ }
467
+
468
+ markAllTouched(): void {
469
+ for (const item of this.items$.peek()) {
470
+ if (isForm(item)) item.markAllTouched()
471
+ else (item as Field<unknown>).markTouched()
472
+ }
473
+ }
474
+
475
+ async validate(): Promise<boolean> {
476
+ if (this.disposed) return this.isValid.peek()
477
+ const tasks: Promise<unknown>[] = []
478
+ for (const item of this.items$.peek()) {
479
+ if (isForm(item)) tasks.push(item.validate())
480
+ else tasks.push((item as Field<unknown>).revalidate())
481
+ }
482
+ await Promise.all(tasks)
483
+ if (this.topLevelValidating$.peek()) {
484
+ await new Promise<void>((resolve) => {
485
+ const unsub = this.topLevelValidating$.subscribe((v) => {
486
+ if (!v) {
487
+ unsub()
488
+ resolve()
489
+ }
490
+ })
491
+ })
492
+ }
493
+ return this.isValid.peek()
494
+ }
495
+
496
+ dispose(): void {
497
+ if (this.disposed) return
498
+ this.disposed = true
499
+ this.validatorDispose?.()
500
+ this.currentValidatorAbort?.abort()
501
+ for (const item of this.items$.peek()) {
502
+ ;(item as { dispose?: () => void }).dispose?.()
503
+ }
504
+ }
505
+
506
+ private runTopLevelValidators(): void {
507
+ if (this.disposed) return
508
+ const value = this.value.value
509
+ this.currentValidatorAbort?.abort()
510
+ const abort = new AbortController()
511
+ this.currentValidatorAbort = abort
512
+ const myId = ++this.currentValidatorRun
513
+
514
+ const syncErrors: string[] = []
515
+ const asyncPromises: Promise<string | null>[] = []
516
+ 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)
520
+ }
521
+
522
+ if (syncErrors.length > 0) {
523
+ batch(() => {
524
+ this.topLevelErrors$.set(syncErrors)
525
+ this.topLevelValidating$.set(false)
526
+ })
527
+ return
528
+ }
529
+
530
+ if (asyncPromises.length === 0) {
531
+ batch(() => {
532
+ this.topLevelErrors$.set([])
533
+ this.topLevelValidating$.set(false)
534
+ })
535
+ return
536
+ }
537
+
538
+ batch(() => {
539
+ this.topLevelErrors$.set([])
540
+ this.topLevelValidating$.set(true)
541
+ })
542
+
543
+ Promise.allSettled(asyncPromises).then((results) => {
544
+ if (myId !== this.currentValidatorRun || this.disposed) return
545
+ const errs: string[] = []
546
+ for (const r of results) {
547
+ if (r.status === 'fulfilled' && r.value != null) errs.push(r.value)
548
+ }
549
+ batch(() => {
550
+ this.topLevelErrors$.set(errs)
551
+ this.topLevelValidating$.set(false)
552
+ })
553
+ })
554
+ }
555
+ }
556
+
557
+ export function createForm<S extends FormSchema>(schema: S, options?: FormOptions<S>): Form<S> {
558
+ return new FormImpl(schema, options)
559
+ }
560
+
561
+ export function createFieldArray<I extends Field<any> | Form<any>>(
562
+ itemFactory: (initial?: ItemInitial<I>) => I,
563
+ options?: FieldArrayOptions<I>,
564
+ ): FieldArray<I> {
565
+ return new FieldArrayImpl<I>(itemFactory, options)
566
+ }
567
+
568
+ /**
569
+ * Recursively wire every leaf `Field` in a form / field-array tree to a
570
+ * devtools emitter. Returns a single disposer that tears down every standalone
571
+ * `effect()` registered along the way (used for FieldArray watching), so the
572
+ * caller — `ctx.form` / `ctx.fieldArray` in the controller — can register one
573
+ * cleanup entry and have the whole subtree's reactive work die with the
574
+ * controller. Spec §20.9.
575
+ */
576
+ export function bindTreeToDevtools(
577
+ node: Field<unknown> | Form<FormSchema> | FieldArray<Field<unknown> | Form<FormSchema>>,
578
+ prefix: string,
579
+ controllerPath: readonly string[],
580
+ emitter: import('../devtools').DevtoolsEmitter,
581
+ ): () => void {
582
+ const disposers: Array<() => void> = []
583
+ bindTreeToDevtoolsInto(node, prefix, controllerPath, emitter, disposers)
584
+ return () => {
585
+ for (const d of disposers) {
586
+ try {
587
+ d()
588
+ } catch {
589
+ // Disposer failures must not break sibling cleanup.
590
+ }
591
+ }
592
+ disposers.length = 0
593
+ }
594
+ }
595
+
596
+ function bindTreeToDevtoolsInto(
597
+ node: Field<unknown> | Form<FormSchema> | FieldArray<Field<unknown> | Form<FormSchema>>,
598
+ prefix: string,
599
+ controllerPath: readonly string[],
600
+ emitter: import('../devtools').DevtoolsEmitter,
601
+ disposers: Array<() => void>,
602
+ ): void {
603
+ if (isForm(node)) {
604
+ for (const [key, child] of Object.entries(node.fields)) {
605
+ bindTreeToDevtoolsInto(
606
+ child,
607
+ prefix === '' ? key : `${prefix}.${key}`,
608
+ controllerPath,
609
+ emitter,
610
+ disposers,
611
+ )
612
+ }
613
+ return
614
+ }
615
+ if (isFieldArray(node)) {
616
+ // 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).
619
+ const arr = node as FieldArray<Field<unknown> | Form<FormSchema>>
620
+ const stop = effect(() => {
621
+ const items = arr.items.value
622
+ items.forEach((item, idx) => {
623
+ bindTreeToDevtoolsInto(item, `${prefix}[${idx}]`, controllerPath, emitter, disposers)
624
+ })
625
+ })
626
+ disposers.push(stop)
627
+ return
628
+ }
629
+ // Leaf Field.
630
+ bindFieldDevtoolsOwner(node as Field<unknown>, {
631
+ controllerPath,
632
+ fieldName: prefix,
633
+ emitter,
634
+ })
635
+ }
636
+
637
+ // Quiet unused-import linter without exporting these symbols publicly.
638
+ void createField
639
+ void untracked
640
+ void isField
@@ -0,0 +1,2 @@
1
+ export type { Validator } from './types'
2
+ export { email, max, maxLength, min, minLength, pattern, required } from './validators'
@@ -0,0 +1 @@
1
+ export type Validator<T> = (value: T, signal: AbortSignal) => string | null | Promise<string | null>