@kontsedal/olas-core 0.0.1-rc.1 → 0.0.2

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 (45) hide show
  1. package/dist/index.cjs +40 -10
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +32 -11
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +32 -11
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +40 -11
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-BImHnGj1.mjs → root-De-6KWIZ.mjs} +750 -149
  10. package/dist/root-De-6KWIZ.mjs.map +1 -0
  11. package/dist/{root-Bazp5_Ik.cjs → root-XKEsSmcd.cjs} +755 -148
  12. package/dist/root-XKEsSmcd.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-C-zV1JZA.d.mts} +215 -13
  18. package/dist/types-C-zV1JZA.d.mts.map +1 -0
  19. package/dist/{types-emq_lZd7.d.cts → types-DKfpkm17.d.cts} +215 -13
  20. package/dist/types-DKfpkm17.d.cts.map +1 -0
  21. package/package.json +1 -1
  22. package/src/controller/index.ts +6 -0
  23. package/src/controller/instance.ts +432 -18
  24. package/src/controller/root.ts +9 -1
  25. package/src/controller/types.ts +148 -7
  26. package/src/emitter.ts +34 -3
  27. package/src/forms/field.ts +73 -8
  28. package/src/forms/form-types.ts +16 -0
  29. package/src/forms/form.ts +218 -26
  30. package/src/index.ts +12 -1
  31. package/src/query/client.ts +161 -6
  32. package/src/query/define.ts +14 -0
  33. package/src/query/entry.ts +64 -42
  34. package/src/query/infinite.ts +77 -55
  35. package/src/query/mutation.ts +11 -21
  36. package/src/query/plugin.ts +50 -0
  37. package/src/query/use.ts +80 -3
  38. package/src/signals/readonly.ts +3 -3
  39. package/src/timing/debounced.ts +24 -4
  40. package/src/timing/throttled.ts +22 -3
  41. package/src/utils.ts +32 -4
  42. package/dist/root-BImHnGj1.mjs.map +0 -1
  43. package/dist/root-Bazp5_Ik.cjs.map +0 -1
  44. package/dist/types-CAMgqCMz.d.mts.map +0 -1
  45. package/dist/types-emq_lZd7.d.cts.map +0 -1
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).
@@ -155,10 +202,42 @@ class FormImpl<S extends FormSchema> implements Form<S> {
155
202
  }
156
203
  } else if (isFieldArray(child)) {
157
204
  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>>)
205
+ const newValues = val as unknown[]
206
+ if (asInitial) {
207
+ // Reset-style application: replace items wholesale and re-anchor
208
+ // them as the new initial so a later `reset()` returns here.
209
+ arr.clear()
210
+ for (const itemVal of newValues) {
211
+ arr.add(itemVal as ItemInitial<Field<unknown>>)
212
+ }
213
+ // Internal: re-anchor the initialItems list. `replaceInitialItems`
214
+ // is only exposed for this exact use case.
215
+ ;(
216
+ arr as unknown as {
217
+ replaceInitialItems: (items: ReadonlyArray<unknown>) => void
218
+ }
219
+ ).replaceInitialItems(newValues)
220
+ } else {
221
+ // User-driven patch: preserve item identity where the lengths
222
+ // overlap so touched / dirty / in-flight validators on existing
223
+ // items survive. Tail diff handles grow / shrink.
224
+ const current = arr.items.peek() as ReadonlyArray<Field<unknown> | Form<FormSchema>>
225
+ const overlap = Math.min(current.length, newValues.length)
226
+ for (let i = 0; i < overlap; i++) {
227
+ const item = current[i]
228
+ const v = newValues[i]
229
+ if (isForm(item)) {
230
+ item.set(v as DeepPartial<FormValue<FormSchema>>)
231
+ } else {
232
+ ;(item as Field<unknown>).set(v)
233
+ }
234
+ }
235
+ for (let i = current.length; i < newValues.length; i++) {
236
+ arr.add(newValues[i] as ItemInitial<Field<unknown>>)
237
+ }
238
+ for (let i = current.length - 1; i >= newValues.length; i--) {
239
+ arr.remove(i)
240
+ }
162
241
  }
163
242
  } else {
164
243
  const f = child as Field<unknown>
@@ -214,6 +293,12 @@ class FormImpl<S extends FormSchema> implements Form<S> {
214
293
  }
215
294
  }
216
295
  await Promise.all(tasks)
296
+ // Kick a fresh top-level run so the surface matches "re-run every
297
+ // validator" — without this, `validate()` would skip top-level if it
298
+ // settled before the call and the value hasn't tracked-changed since.
299
+ if (this.validators.length > 0) {
300
+ this.runTopLevelValidators()
301
+ }
217
302
  // Wait for top-level validators to finish.
218
303
  if (this.topLevelValidating$.peek()) {
219
304
  await new Promise<void>((resolve) => {
@@ -232,6 +317,7 @@ class FormImpl<S extends FormSchema> implements Form<S> {
232
317
  if (this.disposed) return
233
318
  this.disposed = true
234
319
  this.validatorDispose?.()
320
+ this.initialDispose?.()
235
321
  this.currentValidatorAbort?.abort()
236
322
  for (const child of Object.values(this.fields)) {
237
323
  ;(child as { dispose?: () => void }).dispose?.()
@@ -249,9 +335,18 @@ class FormImpl<S extends FormSchema> implements Form<S> {
249
335
  const syncErrors: string[] = []
250
336
  const asyncPromises: Promise<string | null>[] = []
251
337
  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)
338
+ try {
339
+ const r = v(value, abort.signal)
340
+ if (r instanceof Promise) asyncPromises.push(r)
341
+ else if (r != null) syncErrors.push(r)
342
+ } catch (err) {
343
+ try {
344
+ this.onValidatorError?.(err)
345
+ } catch {
346
+ // The reporter must not propagate.
347
+ }
348
+ syncErrors.push(err instanceof Error ? err.message : String(err))
349
+ }
255
350
  }
256
351
 
257
352
  if (syncErrors.length > 0) {
@@ -340,16 +435,27 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
340
435
  private readonly topLevelValidating$: Signal<boolean> = signal(false)
341
436
 
342
437
  private readonly itemFactory: (initial?: ItemInitial<I>) => I
343
- private readonly initialItems: Array<ItemInitial<I>> = []
438
+ private initialItems: Array<ItemInitial<I>> = []
344
439
  private readonly validators: ReadonlyArray<FieldArrayValidator<I>>
345
440
  private currentValidatorRun = 0
346
441
  private currentValidatorAbort: AbortController | null = null
347
442
  private validatorDispose: (() => void) | null = null
348
443
  private disposed = false
444
+ private onValidatorError: ((err: unknown) => void) | null = null
349
445
 
350
- constructor(itemFactory: (initial?: ItemInitial<I>) => I, options?: FieldArrayOptions<I>) {
446
+ /** Internal see `FormImpl.bindValidatorErrorReporter`. */
447
+ bindValidatorErrorReporter(reporter: ((err: unknown) => void) | null): void {
448
+ this.onValidatorError = reporter
449
+ }
450
+
451
+ constructor(
452
+ itemFactory: (initial?: ItemInitial<I>) => I,
453
+ options?: FieldArrayOptions<I>,
454
+ internalOptions?: { onValidatorError?: (err: unknown) => void },
455
+ ) {
351
456
  this.itemFactory = itemFactory
352
457
  this.validators = options?.validators ?? []
458
+ this.onValidatorError = internalOptions?.onValidatorError ?? null
353
459
  this.items$ = signal<I[]>([])
354
460
  if (options?.initial) {
355
461
  this.initialItems = options.initial
@@ -454,6 +560,16 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
454
560
  this.items$.set([])
455
561
  }
456
562
 
563
+ /**
564
+ * Internal — used by `Form.resetWithInitial` to re-anchor the array's
565
+ * initial items after a parent-driven `applyPartial(..., asInitial: true)`.
566
+ * Without this, a subsequent `reset()` would revert to the construction-
567
+ * time initials rather than the most-recently-applied ones.
568
+ */
569
+ replaceInitialItems(items: ReadonlyArray<ItemInitial<I>>): void {
570
+ this.initialItems = [...items]
571
+ }
572
+
457
573
  reset(): void {
458
574
  if (this.disposed) return
459
575
  batch(() => {
@@ -480,6 +596,10 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
480
596
  else tasks.push((item as Field<unknown>).revalidate())
481
597
  }
482
598
  await Promise.all(tasks)
599
+ // Fresh top-level run — see `FormImpl.validate` for the rationale.
600
+ if (this.validators.length > 0) {
601
+ this.runTopLevelValidators()
602
+ }
483
603
  if (this.topLevelValidating$.peek()) {
484
604
  await new Promise<void>((resolve) => {
485
605
  const unsub = this.topLevelValidating$.subscribe((v) => {
@@ -514,9 +634,18 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
514
634
  const syncErrors: string[] = []
515
635
  const asyncPromises: Promise<string | null>[] = []
516
636
  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)
637
+ try {
638
+ const r = v(value, abort.signal)
639
+ if (r instanceof Promise) asyncPromises.push(r)
640
+ else if (r != null) syncErrors.push(r)
641
+ } catch (err) {
642
+ try {
643
+ this.onValidatorError?.(err)
644
+ } catch {
645
+ // The reporter must not propagate.
646
+ }
647
+ syncErrors.push(err instanceof Error ? err.message : String(err))
648
+ }
520
649
  }
521
650
 
522
651
  if (syncErrors.length > 0) {
@@ -554,15 +683,20 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
554
683
  }
555
684
  }
556
685
 
557
- export function createForm<S extends FormSchema>(schema: S, options?: FormOptions<S>): Form<S> {
558
- return new FormImpl(schema, options)
686
+ export function createForm<S extends FormSchema>(
687
+ schema: S,
688
+ options?: FormOptions<S>,
689
+ internalOptions?: { onValidatorError?: (err: unknown) => void },
690
+ ): Form<S> {
691
+ return new FormImpl(schema, options, internalOptions)
559
692
  }
560
693
 
561
694
  export function createFieldArray<I extends Field<any> | Form<any>>(
562
695
  itemFactory: (initial?: ItemInitial<I>) => I,
563
696
  options?: FieldArrayOptions<I>,
697
+ internalOptions?: { onValidatorError?: (err: unknown) => void },
564
698
  ): FieldArray<I> {
565
- return new FieldArrayImpl<I>(itemFactory, options)
699
+ return new FieldArrayImpl<I>(itemFactory, options, internalOptions)
566
700
  }
567
701
 
568
702
  /**
@@ -614,16 +748,40 @@ function bindTreeToDevtoolsInto(
614
748
  }
615
749
  if (isFieldArray(node)) {
616
750
  // 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).
751
+ // Each re-bind has its own disposer set scoped to that pass; on the next
752
+ // items change we flush the previous pass's disposers BEFORE creating the
753
+ // new effects, so a churning array doesn't accumulate reactive work.
754
+ // (Pre-fix, every items mutation appended fresh effects to the outer
755
+ // `disposers` array and never released the old ones.)
619
756
  const arr = node as FieldArray<Field<unknown> | Form<FormSchema>>
757
+ let perPass: Array<() => void> = []
620
758
  const stop = effect(() => {
621
759
  const items = arr.items.value
760
+ // Flush previous pass before rebinding the new item set.
761
+ for (const d of perPass) {
762
+ try {
763
+ d()
764
+ } catch {
765
+ // Disposer failures must not break sibling cleanup.
766
+ }
767
+ }
768
+ perPass = []
622
769
  items.forEach((item, idx) => {
623
- bindTreeToDevtoolsInto(item, `${prefix}[${idx}]`, controllerPath, emitter, disposers)
770
+ bindTreeToDevtoolsInto(item, `${prefix}[${idx}]`, controllerPath, emitter, perPass)
624
771
  })
625
772
  })
626
773
  disposers.push(stop)
774
+ // On final dispose, drain the per-pass disposers too.
775
+ disposers.push(() => {
776
+ for (const d of perPass) {
777
+ try {
778
+ d()
779
+ } catch {
780
+ // Ignore.
781
+ }
782
+ }
783
+ perPass = []
784
+ })
627
785
  return
628
786
  }
629
787
  // Leaf Field.
@@ -634,6 +792,40 @@ function bindTreeToDevtoolsInto(
634
792
  })
635
793
  }
636
794
 
795
+ /**
796
+ * Walk a Form/FieldArray subtree and install `reporter` on every level —
797
+ * leaf fields, nested forms' top-level validators, and field-arrays' top-level
798
+ * validators. Called by `ctx.form` / `ctx.fieldArray` so synchronous validator
799
+ * throws anywhere in the tree route through `root.onError`. See
800
+ * `ValidatorErrorReporter` in `./field.ts`.
801
+ */
802
+ export function bindTreeValidatorErrorReporter(
803
+ node: Field<unknown> | Form<FormSchema> | FieldArray<Field<unknown> | Form<FormSchema>>,
804
+ reporter: ValidatorErrorReporter | null,
805
+ ): void {
806
+ if (isForm(node)) {
807
+ const impl = node as { bindValidatorErrorReporter?: (r: ValidatorErrorReporter | null) => void }
808
+ impl.bindValidatorErrorReporter?.(reporter)
809
+ for (const child of Object.values(node.fields)) {
810
+ bindTreeValidatorErrorReporter(child, reporter)
811
+ }
812
+ return
813
+ }
814
+ if (isFieldArray(node)) {
815
+ const impl = node as { bindValidatorErrorReporter?: (r: ValidatorErrorReporter | null) => void }
816
+ impl.bindValidatorErrorReporter?.(reporter)
817
+ // Items currently in the array. (Items added later won't get the reporter
818
+ // unless `ctx.fieldArray` is wrapped to rebind — but the leaf items in the
819
+ // typical pattern come from a user factory that constructs through
820
+ // `createField` and is bound here by the parent traversal.)
821
+ for (const item of node.items.value) {
822
+ bindTreeValidatorErrorReporter(item, reporter)
823
+ }
824
+ return
825
+ }
826
+ bindFieldValidatorErrorReporter(node as Field<unknown>, reporter)
827
+ }
828
+
637
829
  // Quiet unused-import linter without exporting these symbols publicly.
638
830
  void createField
639
831
  void untracked
package/src/index.ts CHANGED
@@ -3,11 +3,17 @@
3
3
  // Controller container
4
4
  export type {
5
5
  AmbientDeps,
6
+ Collection,
7
+ CollectionFactoryApi,
8
+ CollectionFactoryOptions,
9
+ CollectionFactoryResult,
10
+ CollectionHomogeneousOptions,
6
11
  ControllerDef,
7
12
  CtrlApi,
8
13
  CtrlProps,
9
14
  Ctx,
10
15
  Field,
16
+ LazyChild,
11
17
  Root,
12
18
  RootOptions,
13
19
  } from './controller'
@@ -16,7 +22,7 @@ export { createRoot, defineController } from './controller'
16
22
  export type { DebugBus, DebugCacheEntry, DebugEvent } from './devtools'
17
23
 
18
24
  // Emitter
19
- export type { Emitter } from './emitter'
25
+ export type { Emitter, EmitterErrorReporter } from './emitter'
20
26
  export { createEmitter } from './emitter'
21
27
  export type { ErrorContext } from './errors'
22
28
  // Forms — stdlib validators + debouncedValidator
@@ -44,6 +50,11 @@ export type {
44
50
  InfiniteQuerySpec,
45
51
  InfiniteQuerySubscription,
46
52
  } from './query/infinite'
53
+ // Key hashing — exported so plugins (entities, etc.) that need a stable
54
+ // per-`keyArgs` index key reuse the canonical implementation instead of
55
+ // rolling their own ad-hoc JSON.stringify (which mishandles Date, key
56
+ // ordering, and `undefined`).
57
+ export { stableHash } from './query/keys'
47
58
  export type {
48
59
  Mutation,
49
60
  MutationConcurrency,