@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.
- package/dist/index.cjs +72 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +72 -12
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +72 -12
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +71 -11
- package/dist/index.mjs.map +1 -1
- package/dist/{root-BCZDC5Fv.mjs → root-Cnkb3I--.mjs} +556 -28
- package/dist/root-Cnkb3I--.mjs.map +1 -0
- package/dist/{root-DXV1gVbQ.cjs → root-D_xAdcom.cjs} +556 -28
- package/dist/root-D_xAdcom.cjs.map +1 -0
- package/dist/testing.cjs +2 -1
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +2 -1
- package/dist/testing.d.cts.map +1 -1
- package/dist/testing.d.mts +2 -1
- package/dist/testing.d.mts.map +1 -1
- package/dist/testing.mjs +2 -1
- package/dist/testing.mjs.map +1 -1
- package/dist/{types-CffZ1QXt.d.cts → types-CRn4UoLn.d.mts} +196 -8
- package/dist/types-CRn4UoLn.d.mts.map +1 -0
- package/dist/{types-DSlDowpE.d.mts → types-r_TVaRkD.d.cts} +196 -8
- package/dist/types-r_TVaRkD.d.cts.map +1 -0
- package/package.json +1 -1
- package/src/controller/index.ts +6 -0
- package/src/controller/instance.ts +317 -3
- package/src/controller/types.ts +151 -0
- package/src/emitter.ts +34 -3
- package/src/forms/field.ts +42 -9
- package/src/forms/form-types.ts +37 -0
- package/src/forms/form.ts +165 -5
- package/src/forms/index.ts +12 -1
- package/src/forms/standard-schema.ts +37 -0
- package/src/forms/validators.ts +31 -0
- package/src/index.ts +20 -4
- package/src/query/entry.ts +10 -3
- package/src/query/infinite.ts +8 -1
- package/src/query/structural-share.ts +114 -0
- package/src/query/types.ts +15 -2
- package/src/query/use.ts +47 -13
- package/src/signals/readonly.ts +3 -3
- package/src/testing.ts +2 -0
- package/src/timing/debounced.ts +24 -4
- package/src/timing/throttled.ts +22 -3
- package/src/utils.ts +8 -4
- package/dist/root-BCZDC5Fv.mjs.map +0 -1
- package/dist/root-DXV1gVbQ.cjs.map +0 -1
- package/dist/types-CffZ1QXt.d.cts.map +0 -1
- package/dist/types-DSlDowpE.d.mts.map +0 -1
package/src/controller/types.ts
CHANGED
|
@@ -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
|
-
|
|
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),
|
package/src/forms/field.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
130
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
331
|
+
this.validatorErrors$.set(asyncErrors)
|
|
299
332
|
this.validating$.set(false)
|
|
300
333
|
})
|
|
301
334
|
this.emitValidated(asyncErrors.length === 0, asyncErrors)
|
package/src/forms/form-types.ts
CHANGED
|
@@ -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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
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(() => {
|
package/src/forms/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|