@kontsedal/olas-core 0.0.1 → 0.0.3

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 (50) hide show
  1. package/dist/index.cjs +72 -10
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +72 -12
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +72 -12
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +71 -11
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-BCZDC5Fv.mjs → root-Cnkb3I--.mjs} +556 -28
  10. package/dist/root-Cnkb3I--.mjs.map +1 -0
  11. package/dist/{root-DXV1gVbQ.cjs → root-D_xAdcom.cjs} +556 -28
  12. package/dist/root-D_xAdcom.cjs.map +1 -0
  13. package/dist/testing.cjs +2 -1
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.d.cts +2 -1
  16. package/dist/testing.d.cts.map +1 -1
  17. package/dist/testing.d.mts +2 -1
  18. package/dist/testing.d.mts.map +1 -1
  19. package/dist/testing.mjs +2 -1
  20. package/dist/testing.mjs.map +1 -1
  21. package/dist/{types-CffZ1QXt.d.cts → types-CRn4UoLn.d.mts} +196 -8
  22. package/dist/types-CRn4UoLn.d.mts.map +1 -0
  23. package/dist/{types-DSlDowpE.d.mts → types-r_TVaRkD.d.cts} +196 -8
  24. package/dist/types-r_TVaRkD.d.cts.map +1 -0
  25. package/package.json +1 -1
  26. package/src/controller/index.ts +6 -0
  27. package/src/controller/instance.ts +317 -3
  28. package/src/controller/types.ts +151 -0
  29. package/src/emitter.ts +34 -3
  30. package/src/forms/field.ts +42 -9
  31. package/src/forms/form-types.ts +37 -0
  32. package/src/forms/form.ts +165 -5
  33. package/src/forms/index.ts +12 -1
  34. package/src/forms/standard-schema.ts +37 -0
  35. package/src/forms/validators.ts +31 -0
  36. package/src/index.ts +20 -4
  37. package/src/query/entry.ts +10 -3
  38. package/src/query/infinite.ts +8 -1
  39. package/src/query/structural-share.ts +114 -0
  40. package/src/query/types.ts +15 -2
  41. package/src/query/use.ts +47 -13
  42. package/src/signals/readonly.ts +3 -3
  43. package/src/testing.ts +2 -0
  44. package/src/timing/debounced.ts +24 -4
  45. package/src/timing/throttled.ts +22 -3
  46. package/src/utils.ts +8 -4
  47. package/dist/root-BCZDC5Fv.mjs.map +0 -1
  48. package/dist/root-DXV1gVbQ.cjs.map +0 -1
  49. package/dist/types-CffZ1QXt.d.cts.map +0 -1
  50. package/dist/types-DSlDowpE.d.mts.map +0 -1
@@ -42,6 +42,10 @@ export interface AmbientDeps {
42
42
  * `ctx.field(initial, validators?)`. Spec §8, §20.7.
43
43
  */
44
44
  export type Field<T> = ReadSignal<T> & {
45
+ /**
46
+ * All errors currently surfaced on this field — validator errors first,
47
+ * server errors after. See `setErrors` for the server-error channel.
48
+ */
45
49
  errors: ReadSignal<string[]>
46
50
  isValid: ReadSignal<boolean>
47
51
  isDirty: ReadSignal<boolean>
@@ -59,6 +63,15 @@ export type Field<T> = ReadSignal<T> & {
59
63
  reset(): void
60
64
  markTouched(): void
61
65
  revalidate(): Promise<boolean>
66
+ /**
67
+ * Pin externally-sourced errors on the field — typically server-side
68
+ * validation results returned from a failed submit. These errors live in
69
+ * a separate channel from validator output, so a re-run of local
70
+ * validators (triggered by a new value or `revalidate()`) does NOT clear
71
+ * them. They're cleared automatically the next time the user writes to
72
+ * the field (via `set`), or explicitly via `setErrors([])` / `reset()`.
73
+ */
74
+ setErrors(errors: ReadonlyArray<string>): void
62
75
  /** Idempotent. Called by the owning controller's dispose. */
63
76
  dispose(): void
64
77
  }
@@ -79,6 +92,80 @@ export type CtrlProps<C> = C extends ControllerDef<infer P, unknown> ? P : never
79
92
  /** Extract a controller's Api type. */
80
93
  export type CtrlApi<C> = C extends ControllerDef<unknown, infer A> ? A : never
81
94
 
95
+ /**
96
+ * The reactive surface returned by `ctx.collection(...)`. `items` is the
97
+ * canonical ordered view (source-order, with any construction-failed items
98
+ * filtered out); `size` mirrors `items.length`; `get` / `has` are
99
+ * imperative key lookups. SPEC §11.1.
100
+ */
101
+ export type Collection<K, Api> = {
102
+ readonly items: ReadSignal<ReadonlyArray<{ readonly key: K; readonly api: Api }>>
103
+ readonly size: ReadSignal<number>
104
+ get(key: K): Api | undefined
105
+ has(key: K): boolean
106
+ }
107
+
108
+ /**
109
+ * Homogeneous form of `ctx.collection`: one controller def for every item,
110
+ * with `propsOf` projecting each item to the controller's `Props`. Construct
111
+ * happens once per new key — `propsOf` is **not** re-applied for unchanged
112
+ * keys.
113
+ */
114
+ export type CollectionHomogeneousOptions<Item, K, Props, Api, TDeps = AmbientDeps> = {
115
+ readonly source: ReadSignal<readonly Item[]>
116
+ readonly keyOf: (item: Item) => K
117
+ readonly controller: ControllerDef<Props, Api>
118
+ readonly propsOf: (item: Item) => Props
119
+ readonly factory?: never
120
+ readonly propsFor?: never
121
+ readonly deps?: Partial<TDeps>
122
+ }
123
+
124
+ /**
125
+ * Heterogeneous form of `ctx.collection`: a single `factory` decides per-item
126
+ * which controller + props to construct. When a key's factory result picks a
127
+ * *different* controller than last time, the existing child is disposed and
128
+ * the new one constructed (type-discriminant rebuild).
129
+ *
130
+ * `R` is the factory's *return type* (typically inferred as the union of the
131
+ * branches' `{ controller, props }` shapes). `Api` is then projected out as
132
+ * the union of every branch's controller Api via `CollectionFactoryApi<R>` —
133
+ * unlike a single `Api` generic, the union doesn't collapse to the first
134
+ * branch.
135
+ */
136
+ export type CollectionFactoryOptions<Item, K, R, TDeps = AmbientDeps> = {
137
+ readonly source: ReadSignal<readonly Item[]>
138
+ readonly keyOf: (item: Item) => K
139
+ readonly controller?: never
140
+ readonly propsOf?: never
141
+ readonly factory: (item: Item) => R
142
+ readonly deps?: Partial<TDeps>
143
+ }
144
+
145
+ /** Constraint for the factory form's return shape. */
146
+ // biome-ignore lint/suspicious/noExplicitAny: per-branch types vary
147
+ export type CollectionFactoryResult = { controller: ControllerDef<any, any>; props: any }
148
+
149
+ /** Extract the union of every branch's controller Api. Distributes over R. */
150
+ export type CollectionFactoryApi<R> = R extends {
151
+ // biome-ignore lint/suspicious/noExplicitAny: distributive infer across the union
152
+ controller: ControllerDef<any, infer A>
153
+ }
154
+ ? A
155
+ : never
156
+
157
+ /**
158
+ * Handle returned by `ctx.lazyChild(...)`. `status` walks `idle → loading →
159
+ * (ready | error)`; `api` becomes defined once `status === 'ready'`. SPEC §16.5.
160
+ */
161
+ export type LazyChild<Api> = {
162
+ readonly status: ReadSignal<'idle' | 'loading' | 'ready' | 'error'>
163
+ readonly api: ReadSignal<Api | undefined>
164
+ readonly error: ReadSignal<unknown | undefined>
165
+ load(): Promise<Api>
166
+ dispose(): void
167
+ }
168
+
82
169
  /**
83
170
  * `ctx` is the lifecycle-bound surface every controller factory receives.
84
171
  * Every primitive constructed through `ctx` is owned by the controller and
@@ -106,6 +193,13 @@ export type Ctx<TDeps = AmbientDeps> = {
106
193
  source: InfiniteQuery<Args, TPage, TItem>,
107
194
  keyOrOptions?: (() => Args) | UseOptions<Args>,
108
195
  ): InfiniteQuerySubscription<TPage, TItem>
196
+ // Overload — `select` projects T → U; the returned subscription's `data`
197
+ // is `U | undefined`. The required `select` field is the discriminator
198
+ // that picks this overload over the plain-key one above.
199
+ use<Args extends unknown[], T, U>(
200
+ source: Query<Args, T>,
201
+ options: { key?: () => Args; enabled?: () => boolean; select: (data: T) => U },
202
+ ): QuerySubscription<U>
109
203
 
110
204
  mutation<V, R>(spec: MutationSpec<V, R>): Mutation<V, R>
111
205
 
@@ -150,6 +244,63 @@ export type Ctx<TDeps = AmbientDeps> = {
150
244
  options?: { deps?: Partial<TDeps> },
151
245
  ): { api: Api; dispose: () => void; suspend: () => void; resume: () => void }
152
246
 
247
+ /**
248
+ * Ephemeral child controller bound to either (a) the explicit `dispose()`
249
+ * call returned in the tuple, or (b) the parent's disposal — whichever
250
+ * comes first. Same lifecycle semantics as `ctx.attach` minus suspend /
251
+ * resume (sessions are short-lived, not pause-able). Returns a `[api,
252
+ * dispose]` tuple so the api shape is exactly the controller's return
253
+ * type, with no wrapper to unpack.
254
+ *
255
+ * Use cases: modal forms, inline edit sessions, wizards, command palette.
256
+ * SPEC §11.1.
257
+ */
258
+ session<Props, Api>(
259
+ def: ControllerDef<Props, Api>,
260
+ props: Props,
261
+ options?: { deps?: Partial<TDeps> },
262
+ ): readonly [api: Api, dispose: () => void]
263
+
264
+ /**
265
+ * Diff-by-key set of child controllers driven by a reactive `source`.
266
+ * On every change to `source`, the collection:
267
+ * - **new keys** → construct a child via `controller` + `propsOf(item)`
268
+ * (or `factory(item)` for the heterogeneous form);
269
+ * - **removed keys** → dispose that child;
270
+ * - **unchanged keys** → leave it alone (`propsOf` is NOT re-applied).
271
+ *
272
+ * For per-item type-discriminated children, use the `factory` form —
273
+ * type changes for an existing key dispose and reconstruct.
274
+ *
275
+ * Construction errors (factory or controller throw) are routed to
276
+ * `onError` with `kind: 'construction'` and the item is **skipped** —
277
+ * the collection's surface shows one fewer entry. The diff loop does
278
+ * not re-throw. SPEC §11.1, §12.1.6.
279
+ */
280
+ collection<Item, K, Props, Api>(
281
+ options: CollectionHomogeneousOptions<Item, K, Props, Api, TDeps>,
282
+ ): Collection<K, Api>
283
+ collection<Item, K, R extends CollectionFactoryResult>(
284
+ options: CollectionFactoryOptions<Item, K, R, TDeps>,
285
+ ): Collection<K, CollectionFactoryApi<R>>
286
+
287
+ /**
288
+ * Code-split child controller. The loader is invoked on `load()`
289
+ * (idempotent), then the controller is constructed with the supplied
290
+ * `props`. `status` / `api` / `error` are reactive signals; subscribe
291
+ * via `use(child.api)` in your view layer.
292
+ *
293
+ * Parent disposal disposes the loaded child (if any) and flags any
294
+ * in-flight load so its eventual settle is dropped on the floor.
295
+ * Construction or import failures route through `onError` with
296
+ * `kind: 'construction'`. SPEC §16.5.
297
+ */
298
+ lazyChild<Props, Api>(
299
+ loader: () => Promise<ControllerDef<Props, Api>>,
300
+ props: Props,
301
+ options?: { deps?: Partial<TDeps> },
302
+ ): LazyChild<Api>
303
+
153
304
  effect(fn: () => void | (() => void)): void
154
305
 
155
306
  on<T>(emitter: Emitter<T>, handler: (value: T) => void): void
package/src/emitter.ts CHANGED
@@ -20,17 +20,43 @@ export type Emitter<T> = {
20
20
 
21
21
  type AnyHandler = (value: unknown) => void
22
22
 
23
+ /**
24
+ * Optional escape hatch for emit-time handler throws. If supplied, a thrown
25
+ * handler is reported here and emission continues with the remaining handlers
26
+ * (spec §20.6 — one throwing handler must not block the rest). If absent,
27
+ * the throw is logged via `console.error`.
28
+ */
29
+ export type EmitterErrorReporter = (err: unknown) => void
30
+
23
31
  class EmitterImpl<T> {
24
32
  private handlers = new Set<AnyHandler>()
25
33
  private disposed = false
26
34
 
35
+ constructor(private onError?: EmitterErrorReporter) {}
36
+
27
37
  emit(value: T): void {
28
38
  if (this.disposed) return
29
39
  // Snapshot so a handler that unsubscribes itself (or another) doesn't
30
40
  // mutate the set mid-iteration.
31
41
  const snapshot = Array.from(this.handlers)
32
42
  for (const handler of snapshot) {
33
- handler(value as unknown)
43
+ try {
44
+ handler(value as unknown)
45
+ } catch (err) {
46
+ // Spec §20.6: isolate handler throws so siblings still fire.
47
+ if (this.onError) {
48
+ try {
49
+ this.onError(err)
50
+ } catch {
51
+ // Reporter itself threw — last resort.
52
+ // eslint-disable-next-line no-console
53
+ console.error('[olas] emitter handler threw and reporter threw:', err)
54
+ }
55
+ } else {
56
+ // eslint-disable-next-line no-console
57
+ console.error('[olas] emitter handler threw:', err)
58
+ }
59
+ }
34
60
  }
35
61
  }
36
62
 
@@ -67,9 +93,14 @@ class EmitterImpl<T> {
67
93
  * (or the emitter is disposed). Use this for emitters that live outside any
68
94
  * single controller — typically in deps. Use `ctx.emitter()` for emitters that
69
95
  * should auto-clean with a controller.
96
+ *
97
+ * Pass `onError` to receive emit-time handler throws (spec §20.6 — one
98
+ * throwing handler must not block the rest of the fan-out). `ctx.emitter()`
99
+ * wires this to the root's `onError` so deps-level emitters get isolation
100
+ * by default when constructed via `ctx`.
70
101
  */
71
- export function createEmitter<T = void>(): Emitter<T> {
72
- const impl = new EmitterImpl<T>()
102
+ export function createEmitter<T = void>(options?: { onError?: EmitterErrorReporter }): Emitter<T> {
103
+ const impl = new EmitterImpl<T>(options?.onError)
73
104
  return {
74
105
  emit: ((value?: T) => impl.emit(value as T)) as Emitter<T>['emit'],
75
106
  on: (handler) => impl.on(handler),
@@ -36,7 +36,18 @@ export type ValidatorErrorReporter = (err: unknown) => void
36
36
 
37
37
  class FieldImpl<T> implements Field<T> {
38
38
  private readonly value$: Signal<T>
39
- private readonly errors$: Signal<string[]>
39
+ /**
40
+ * Validator-produced errors. The public `errors` getter merges this with
41
+ * `serverErrors$` so consumers see a single flat array. Kept separate so a
42
+ * re-run of validators (after a new value) doesn't clobber server errors.
43
+ */
44
+ private readonly validatorErrors$: Signal<string[]>
45
+ /**
46
+ * Externally-injected errors — see `setErrors`. Cleared on the next user
47
+ * `set()`, on `reset()`, or via an explicit `setErrors([])`.
48
+ */
49
+ private readonly serverErrors$: Signal<string[]>
50
+ private readonly errors$: Computed<string[]>
40
51
  private readonly touched$: Signal<boolean>
41
52
  private readonly dirty$: Signal<boolean>
42
53
  private readonly validating$: Signal<boolean>
@@ -67,11 +78,19 @@ class FieldImpl<T> implements Field<T> {
67
78
  // post-construct hook so it can't catch the first run).
68
79
  this.onValidatorError = options?.onValidatorError ?? null
69
80
  this.value$ = signal(initial)
70
- this.errors$ = signal<string[]>([])
81
+ this.validatorErrors$ = signal<string[]>([])
82
+ this.serverErrors$ = signal<string[]>([])
71
83
  this.touched$ = signal(false)
72
84
  this.dirty$ = signal(false)
73
85
  this.validating$ = signal(false)
74
86
  this.revalidateTrigger$ = signal(0)
87
+ this.errors$ = computed(() => {
88
+ const v = this.validatorErrors$.value
89
+ const s = this.serverErrors$.value
90
+ if (s.length === 0) return v
91
+ if (v.length === 0) return s
92
+ return [...v, ...s]
93
+ })
75
94
  this.isValid$ = computed(() => this.errors$.value.length === 0 && !this.validating$.value)
76
95
 
77
96
  if (validators.length > 0) {
@@ -126,8 +145,21 @@ class FieldImpl<T> implements Field<T> {
126
145
  // --- mutating methods ---
127
146
  set(value: T): void {
128
147
  if (this.disposed) return
129
- this.value$.set(value)
130
- this.dirty$.set(true)
148
+ batch(() => {
149
+ this.value$.set(value)
150
+ this.dirty$.set(true)
151
+ // Server errors are pinned externally and survive validator re-runs,
152
+ // but they MUST clear when the user edits the field — otherwise a
153
+ // server error like "username taken" would persist after the user
154
+ // typed a different username.
155
+ if (this.serverErrors$.peek().length > 0) this.serverErrors$.set([])
156
+ })
157
+ }
158
+
159
+ setErrors(errors: ReadonlyArray<string>): void {
160
+ if (this.disposed) return
161
+ const next = errors.length === 0 ? [] : [...errors]
162
+ this.serverErrors$.set(next)
131
163
  }
132
164
 
133
165
  /**
@@ -154,7 +186,8 @@ class FieldImpl<T> implements Field<T> {
154
186
  this.value$.set(this.initial)
155
187
  this.dirty$.set(false)
156
188
  this.touched$.set(false)
157
- this.errors$.set([])
189
+ this.validatorErrors$.set([])
190
+ this.serverErrors$.set([])
158
191
  this.validating$.set(false)
159
192
  })
160
193
  }
@@ -262,7 +295,7 @@ class FieldImpl<T> implements Field<T> {
262
295
 
263
296
  if (syncErrors.length > 0) {
264
297
  batch(() => {
265
- this.errors$.set(syncErrors)
298
+ this.validatorErrors$.set(syncErrors)
266
299
  this.validating$.set(false)
267
300
  })
268
301
  this.emitValidated(false, syncErrors)
@@ -271,7 +304,7 @@ class FieldImpl<T> implements Field<T> {
271
304
 
272
305
  if (asyncPromises.length === 0) {
273
306
  batch(() => {
274
- this.errors$.set([])
307
+ this.validatorErrors$.set([])
275
308
  this.validating$.set(false)
276
309
  })
277
310
  this.emitValidated(true, [])
@@ -279,7 +312,7 @@ class FieldImpl<T> implements Field<T> {
279
312
  }
280
313
 
281
314
  batch(() => {
282
- this.errors$.set([])
315
+ this.validatorErrors$.set([])
283
316
  this.validating$.set(true)
284
317
  })
285
318
 
@@ -295,7 +328,7 @@ class FieldImpl<T> implements Field<T> {
295
328
  }
296
329
  }
297
330
  batch(() => {
298
- this.errors$.set(asyncErrors)
331
+ this.validatorErrors$.set(asyncErrors)
299
332
  this.validating$.set(false)
300
333
  })
301
334
  this.emitValidated(asyncErrors.length === 0, asyncErrors)
@@ -90,6 +90,22 @@ export type Form<S extends FormSchema> = {
90
90
  readonly touched: ReadSignal<boolean>
91
91
  readonly isValidating: ReadSignal<boolean>
92
92
 
93
+ /**
94
+ * `true` while a `submit(...)` is in flight. Clears when the handler
95
+ * resolves, throws, or pre-submit validation fails.
96
+ */
97
+ readonly isSubmitting: ReadSignal<boolean>
98
+ /** Number of times `submit(...)` has been called. Bumps before the handler runs. */
99
+ readonly submitCount: ReadSignal<number>
100
+ /**
101
+ * The thrown value from the most recent failed submission, if any.
102
+ * Cleared at the start of each new `submit(...)` call and on `reset()`.
103
+ * Note that a validation failure ("submit blocked because the form is
104
+ * invalid") is NOT a thrown error — `submitError` stays whatever it
105
+ * was, and the returned promise resolves with `{ ok: false }`.
106
+ */
107
+ readonly submitError: ReadSignal<unknown>
108
+
93
109
  /** Deep-merge a partial value into the form, batched. */
94
110
  set(partial: DeepPartial<FormValue<S>>): void
95
111
  /**
@@ -105,6 +121,27 @@ export type Form<S extends FormSchema> = {
105
121
  markAllTouched(): void
106
122
  /** Re-run every leaf's validators. Resolves with true if all leaves are valid. */
107
123
  validate(): Promise<boolean>
124
+ /**
125
+ * Run a submission. Pre-validates the form (unless `validateBeforeSubmit: false`),
126
+ * then calls `handler(value)`. Maintains `isSubmitting` / `submitCount` /
127
+ * `submitError`. Returns `{ ok, data?, error? }` — see `FormImpl.submit`
128
+ * for the full contract.
129
+ */
130
+ submit(
131
+ handler: (value: FormValue<S>) => unknown | Promise<unknown>,
132
+ options?: {
133
+ validateBeforeSubmit?: boolean
134
+ resetOnSuccess?: boolean
135
+ onError?: 'rethrow' | 'capture'
136
+ },
137
+ ): Promise<{ ok: boolean; data?: unknown; error?: unknown }>
138
+ /**
139
+ * Pin externally-sourced errors on specific fields. Keys are dot-separated
140
+ * paths through nested forms / field arrays (numeric segments are array
141
+ * indices). Errors land in each field's `serverErrors` channel — kept
142
+ * separate from validator output and auto-cleared on the next user write.
143
+ */
144
+ setErrors(errors: Record<string, ReadonlyArray<string>>): void
108
145
  /** Idempotent. Called by the owning controller's dispose. */
109
146
  dispose(): void
110
147
  }
package/src/forms/form.ts CHANGED
@@ -51,6 +51,14 @@ class FormImpl<S extends FormSchema> implements Form<S> {
51
51
  readonly topLevelErrors: ReadSignal<string[]> = this.topLevelErrors$
52
52
  private readonly topLevelValidating$: Signal<boolean> = signal(false)
53
53
 
54
+ // Submission lifecycle.
55
+ private readonly isSubmitting$: Signal<boolean> = signal(false)
56
+ private readonly submitCount$: Signal<number> = signal(0)
57
+ private readonly submitError$: Signal<unknown> = signal(undefined)
58
+ readonly isSubmitting: ReadSignal<boolean> = this.isSubmitting$
59
+ readonly submitCount: ReadSignal<number> = this.submitCount$
60
+ readonly submitError: ReadSignal<unknown> = this.submitError$
61
+
54
62
  private readonly validators: ReadonlyArray<FormValidator<S>>
55
63
  private readonly options: FormOptions<S> | undefined
56
64
  private validatorDispose: (() => void) | null = null
@@ -202,10 +210,42 @@ class FormImpl<S extends FormSchema> implements Form<S> {
202
210
  }
203
211
  } else if (isFieldArray(child)) {
204
212
  const arr = child
205
- // Replace items: clear, then add each
206
- arr.clear()
207
- for (const itemVal of val as unknown[]) {
208
- arr.add(itemVal as ItemInitial<Field<unknown>>)
213
+ const newValues = val as unknown[]
214
+ if (asInitial) {
215
+ // Reset-style application: replace items wholesale and re-anchor
216
+ // them as the new initial so a later `reset()` returns here.
217
+ arr.clear()
218
+ for (const itemVal of newValues) {
219
+ arr.add(itemVal as ItemInitial<Field<unknown>>)
220
+ }
221
+ // Internal: re-anchor the initialItems list. `replaceInitialItems`
222
+ // is only exposed for this exact use case.
223
+ ;(
224
+ arr as unknown as {
225
+ replaceInitialItems: (items: ReadonlyArray<unknown>) => void
226
+ }
227
+ ).replaceInitialItems(newValues)
228
+ } else {
229
+ // User-driven patch: preserve item identity where the lengths
230
+ // overlap so touched / dirty / in-flight validators on existing
231
+ // items survive. Tail diff handles grow / shrink.
232
+ const current = arr.items.peek() as ReadonlyArray<Field<unknown> | Form<FormSchema>>
233
+ const overlap = Math.min(current.length, newValues.length)
234
+ for (let i = 0; i < overlap; i++) {
235
+ const item = current[i]
236
+ const v = newValues[i]
237
+ if (isForm(item)) {
238
+ item.set(v as DeepPartial<FormValue<FormSchema>>)
239
+ } else {
240
+ ;(item as Field<unknown>).set(v)
241
+ }
242
+ }
243
+ for (let i = current.length; i < newValues.length; i++) {
244
+ arr.add(newValues[i] as ItemInitial<Field<unknown>>)
245
+ }
246
+ for (let i = current.length - 1; i >= newValues.length; i--) {
247
+ arr.remove(i)
248
+ }
209
249
  }
210
250
  } else {
211
251
  const f = child as Field<unknown>
@@ -281,6 +321,116 @@ class FormImpl<S extends FormSchema> implements Form<S> {
281
321
  return this.isValid.peek()
282
322
  }
283
323
 
324
+ /**
325
+ * Run a submission against this form. Wraps `handler(value)` with:
326
+ * - `isSubmitting` set true while the handler is in flight.
327
+ * - `submitCount` incremented before the handler runs.
328
+ * - `submitError` set to the throw, if any.
329
+ * - Optional pre-submit `validate()` (default true). When invalid every
330
+ * field is marked touched and the handler is skipped — the returned
331
+ * promise resolves with `{ ok: false }` and `submitError` is left
332
+ * untouched (validation failure is not a thrown error).
333
+ *
334
+ * The handler may return a value (synchronously or via Promise); it's
335
+ * captured in the resolved object's `data` field. Throws are captured
336
+ * unless `onError: 'rethrow'`. A `resetOnSuccess: true` option calls
337
+ * `reset()` after the handler resolves successfully.
338
+ */
339
+ async submit(
340
+ handler: (value: FormValue<S>) => unknown | Promise<unknown>,
341
+ options?: {
342
+ validateBeforeSubmit?: boolean
343
+ resetOnSuccess?: boolean
344
+ onError?: 'rethrow' | 'capture'
345
+ },
346
+ ): Promise<{ ok: boolean; data?: unknown; error?: unknown }> {
347
+ if (this.disposed) return { ok: false, error: new Error('form is disposed') }
348
+
349
+ // Double-submit guard — refusing to start a second submission while one
350
+ // is in flight matches RHF / TanStack-Form. Consumers wanting parallel
351
+ // submits should run them off the form directly.
352
+ if (this.isSubmitting$.peek()) {
353
+ return { ok: false, error: new Error('submit already in progress') }
354
+ }
355
+
356
+ const validateFirst = options?.validateBeforeSubmit ?? true
357
+ const onErrorMode = options?.onError ?? 'capture'
358
+
359
+ batch(() => {
360
+ this.submitCount$.update((n) => n + 1)
361
+ this.submitError$.set(undefined)
362
+ this.isSubmitting$.set(true)
363
+ })
364
+
365
+ try {
366
+ if (validateFirst) {
367
+ const ok = await this.validate()
368
+ if (!ok) {
369
+ this.markAllTouched()
370
+ this.isSubmitting$.set(false)
371
+ return { ok: false }
372
+ }
373
+ }
374
+ const result = await handler(this.value.peek())
375
+ if (options?.resetOnSuccess) this.reset()
376
+ this.isSubmitting$.set(false)
377
+ return { ok: true, data: result }
378
+ } catch (err) {
379
+ batch(() => {
380
+ this.submitError$.set(err)
381
+ this.isSubmitting$.set(false)
382
+ })
383
+ if (onErrorMode === 'rethrow') throw err
384
+ return { ok: false, error: err }
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Pin externally-sourced errors on specific fields — typically server-side
390
+ * validation results from a failed submit. Paths are dot-separated and
391
+ * traverse nested `Form` / `FieldArray` children (numeric segments are
392
+ * array indices). Errors land in the field's `serverErrors` channel and
393
+ * clear automatically on the next user write to that field. Passing an
394
+ * empty array for a path clears that field's server errors immediately.
395
+ */
396
+ setErrors(errors: Record<string, ReadonlyArray<string>>): void {
397
+ if (this.disposed) return
398
+ batch(() => {
399
+ for (const [path, msgs] of Object.entries(errors)) {
400
+ const target = this.resolvePath(path)
401
+ if (target === undefined) continue
402
+ if ((target as { setErrors?: unknown }).setErrors === undefined) continue
403
+ ;(target as { setErrors: (e: ReadonlyArray<string>) => void }).setErrors(msgs)
404
+ }
405
+ })
406
+ }
407
+
408
+ private resolvePath(path: string): unknown {
409
+ if (path === '') return undefined
410
+ const segments = path.split('.')
411
+ let cursor: unknown = this
412
+ for (const seg of segments) {
413
+ if (cursor === undefined || cursor === null) return undefined
414
+ if (isForm(cursor)) {
415
+ cursor = (cursor.fields as Record<string, unknown>)[seg]
416
+ continue
417
+ }
418
+ if (isFieldArray(cursor)) {
419
+ const idx = Number(seg)
420
+ if (!Number.isInteger(idx) || idx < 0) return undefined
421
+ cursor = (cursor as { at(i: number): unknown }).at(idx)
422
+ continue
423
+ }
424
+ // Top-level dispatch — `this` is the FormImpl; walk via `fields`.
425
+ if (cursor === this) {
426
+ cursor = (this.fields as Record<string, unknown>)[seg]
427
+ continue
428
+ }
429
+ return undefined
430
+ }
431
+ return cursor
432
+ }
433
+
284
434
  dispose(): void {
285
435
  if (this.disposed) return
286
436
  this.disposed = true
@@ -403,7 +553,7 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
403
553
  private readonly topLevelValidating$: Signal<boolean> = signal(false)
404
554
 
405
555
  private readonly itemFactory: (initial?: ItemInitial<I>) => I
406
- private readonly initialItems: Array<ItemInitial<I>> = []
556
+ private initialItems: Array<ItemInitial<I>> = []
407
557
  private readonly validators: ReadonlyArray<FieldArrayValidator<I>>
408
558
  private currentValidatorRun = 0
409
559
  private currentValidatorAbort: AbortController | null = null
@@ -528,6 +678,16 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
528
678
  this.items$.set([])
529
679
  }
530
680
 
681
+ /**
682
+ * Internal — used by `Form.resetWithInitial` to re-anchor the array's
683
+ * initial items after a parent-driven `applyPartial(..., asInitial: true)`.
684
+ * Without this, a subsequent `reset()` would revert to the construction-
685
+ * time initials rather than the most-recently-applied ones.
686
+ */
687
+ replaceInitialItems(items: ReadonlyArray<ItemInitial<I>>): void {
688
+ this.initialItems = [...items]
689
+ }
690
+
531
691
  reset(): void {
532
692
  if (this.disposed) return
533
693
  batch(() => {
@@ -1,2 +1,13 @@
1
+ export type { StandardSchemaV1 } from './standard-schema'
2
+ export { isStandardSchema } from './standard-schema'
1
3
  export type { Validator } from './types'
2
- export { email, max, maxLength, min, minLength, pattern, required } from './validators'
4
+ export {
5
+ email,
6
+ max,
7
+ maxLength,
8
+ min,
9
+ minLength,
10
+ pattern,
11
+ required,
12
+ validator,
13
+ } from './validators'
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Standard Schema v1 — the cross-library validation contract adopted by
3
+ * Zod 4, Valibot 1, ArkType 2, and others. See https://standardschema.dev.
4
+ *
5
+ * We type-only-import the shape so consumers don't take a new runtime dep:
6
+ * any object with a `~standard.validate(value)` method conforming to this
7
+ * structure works.
8
+ */
9
+ export type StandardSchemaV1Issue = {
10
+ readonly message: string
11
+ readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }>
12
+ }
13
+
14
+ export type StandardSchemaV1Result<O> =
15
+ | { readonly value: O; readonly issues?: undefined }
16
+ | { readonly issues: ReadonlyArray<StandardSchemaV1Issue> }
17
+
18
+ export type StandardSchemaV1<I = unknown, O = I> = {
19
+ readonly '~standard': {
20
+ readonly version: 1
21
+ readonly vendor: string
22
+ validate(value: unknown): StandardSchemaV1Result<O> | Promise<StandardSchemaV1Result<O>>
23
+ readonly types?: { readonly input: I; readonly output: O } | undefined
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Heuristic: does `x` look like a Standard Schema?
29
+ */
30
+ export function isStandardSchema(x: unknown): x is StandardSchemaV1<unknown, unknown> {
31
+ return (
32
+ x !== null &&
33
+ typeof x === 'object' &&
34
+ '~standard' in x &&
35
+ typeof (x as { '~standard': { validate?: unknown } })['~standard']?.validate === 'function'
36
+ )
37
+ }