@kontsedal/olas-core 0.0.2 → 0.0.4
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 +34 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +52 -2
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +52 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +33 -2
- package/dist/index.mjs.map +1 -1
- package/dist/{root-De-6KWIZ.mjs → root-BBSlzvJ2.mjs} +251 -18
- package/dist/root-BBSlzvJ2.mjs.map +1 -0
- package/dist/{root-XKEsSmcd.cjs → root-CoafhkTg.cjs} +251 -18
- package/dist/root-CoafhkTg.cjs.map +1 -0
- package/dist/testing.cjs +23 -11
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +3 -1
- package/dist/testing.d.cts.map +1 -1
- package/dist/testing.d.mts +3 -1
- package/dist/testing.d.mts.map +1 -1
- package/dist/testing.mjs +23 -11
- package/dist/testing.mjs.map +1 -1
- package/dist/{types-C-zV1JZA.d.mts → types-BCf2nB2N.d.mts} +73 -8
- package/dist/types-BCf2nB2N.d.mts.map +1 -0
- package/dist/{types-DKfpkm17.d.cts → types-Ijeun3qo.d.cts} +73 -8
- package/dist/types-Ijeun3qo.d.cts.map +1 -0
- package/package.json +1 -1
- package/src/controller/types.ts +26 -2
- package/src/forms/field.ts +42 -9
- package/src/forms/form-types.ts +37 -0
- package/src/forms/form.ts +118 -0
- 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 +13 -3
- package/src/query/entry.ts +10 -3
- package/src/query/infinite.ts +8 -1
- package/src/query/local.ts +1 -0
- package/src/query/structural-share.ts +114 -0
- package/src/query/types.ts +22 -2
- package/src/query/use.ts +53 -13
- package/src/testing.ts +5 -0
- package/dist/root-De-6KWIZ.mjs.map +0 -1
- package/dist/root-XKEsSmcd.cjs.map +0 -1
- package/dist/types-C-zV1JZA.d.mts.map +0 -1
- package/dist/types-DKfpkm17.d.cts.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
|
}
|
|
@@ -172,13 +185,24 @@ export type Ctx<TDeps = AmbientDeps> = {
|
|
|
172
185
|
},
|
|
173
186
|
): LocalCache<T>
|
|
174
187
|
|
|
188
|
+
// Select-projecting overload — picked when the options object has a
|
|
189
|
+
// required `select` field. `key`'s return is `readonly [...Args]` so
|
|
190
|
+
// callers writing `() => [id] as const` flow through cleanly.
|
|
191
|
+
use<Args extends unknown[], T, U>(
|
|
192
|
+
source: Query<Args, T>,
|
|
193
|
+
options: {
|
|
194
|
+
key?: () => readonly [...Args]
|
|
195
|
+
enabled?: () => boolean
|
|
196
|
+
select: (data: T) => U
|
|
197
|
+
},
|
|
198
|
+
): QuerySubscription<U>
|
|
175
199
|
use<Args extends unknown[], T>(
|
|
176
200
|
source: Query<Args, T>,
|
|
177
|
-
keyOrOptions?: (() => Args) | UseOptions<Args>,
|
|
201
|
+
keyOrOptions?: (() => readonly [...Args]) | UseOptions<Args>,
|
|
178
202
|
): QuerySubscription<T>
|
|
179
203
|
use<Args extends unknown[], TPage, TItem>(
|
|
180
204
|
source: InfiniteQuery<Args, TPage, TItem>,
|
|
181
|
-
keyOrOptions?: (() => Args) | UseOptions<Args>,
|
|
205
|
+
keyOrOptions?: (() => readonly [...Args]) | UseOptions<Args>,
|
|
182
206
|
): InfiniteQuerySubscription<TPage, TItem>
|
|
183
207
|
|
|
184
208
|
mutation<V, R>(spec: MutationSpec<V, R>): Mutation<V, R>
|
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
|
|
@@ -313,6 +321,116 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
313
321
|
return this.isValid.peek()
|
|
314
322
|
}
|
|
315
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
|
+
|
|
316
434
|
dispose(): void {
|
|
317
435
|
if (this.disposed) return
|
|
318
436
|
this.disposed = true
|
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
|
+
}
|
package/src/forms/validators.ts
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
|
+
import { isStandardSchema, type StandardSchemaV1 } from './standard-schema'
|
|
1
2
|
import type { Validator } from './types'
|
|
2
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Wrap any Standard-Schema-compatible schema (Zod 4, Valibot 1, ArkType 2,
|
|
6
|
+
* …) as an Olas validator. The validator returns the first issue's message
|
|
7
|
+
* on failure (or `'Invalid'` if no issues are produced), `null` on success.
|
|
8
|
+
*
|
|
9
|
+
* Standard Schema validators may be sync or async; this wrapper threads
|
|
10
|
+
* through whichever the schema returns — `Promise<string|null>` only when
|
|
11
|
+
* the underlying validate call is itself async.
|
|
12
|
+
*
|
|
13
|
+
* `signal` is accepted to match the `Validator<T>` shape but isn't forwarded
|
|
14
|
+
* — Standard Schema v1 has no cancellation surface.
|
|
15
|
+
*/
|
|
16
|
+
export function validator<I, O>(schema: StandardSchemaV1<I, O>): Validator<I> {
|
|
17
|
+
return (value, signal) => {
|
|
18
|
+
void signal
|
|
19
|
+
const result = schema['~standard'].validate(value)
|
|
20
|
+
if (result instanceof Promise) {
|
|
21
|
+
return result.then(messageFromResult)
|
|
22
|
+
}
|
|
23
|
+
return messageFromResult(result)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function messageFromResult(result: { issues?: ReadonlyArray<{ message: string }> }): string | null {
|
|
28
|
+
if (result.issues === undefined || result.issues.length === 0) return null
|
|
29
|
+
return result.issues[0]?.message ?? 'Invalid'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { isStandardSchema, type StandardSchemaV1 } from './standard-schema'
|
|
33
|
+
|
|
3
34
|
const isEmpty = (value: unknown): boolean => {
|
|
4
35
|
if (value === undefined || value === null) return true
|
|
5
36
|
if (typeof value === 'string') return value.length === 0
|
package/src/index.ts
CHANGED
|
@@ -25,9 +25,19 @@ export type { DebugBus, DebugCacheEntry, DebugEvent } from './devtools'
|
|
|
25
25
|
export type { Emitter, EmitterErrorReporter } from './emitter'
|
|
26
26
|
export { createEmitter } from './emitter'
|
|
27
27
|
export type { ErrorContext } from './errors'
|
|
28
|
-
// Forms — stdlib validators + debouncedValidator
|
|
29
|
-
export type { Validator } from './forms'
|
|
30
|
-
export {
|
|
28
|
+
// Forms — stdlib validators + Standard Schema adapter + debouncedValidator
|
|
29
|
+
export type { StandardSchemaV1, Validator } from './forms'
|
|
30
|
+
export {
|
|
31
|
+
email,
|
|
32
|
+
isStandardSchema,
|
|
33
|
+
max,
|
|
34
|
+
maxLength,
|
|
35
|
+
min,
|
|
36
|
+
minLength,
|
|
37
|
+
pattern,
|
|
38
|
+
required,
|
|
39
|
+
validator,
|
|
40
|
+
} from './forms'
|
|
31
41
|
export { debouncedValidator } from './forms/field'
|
|
32
42
|
export type {
|
|
33
43
|
DeepPartial,
|
package/src/query/entry.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { batch, type Signal, signal } from '../signals'
|
|
2
2
|
import { abortableSleep, isAbortError } from '../utils'
|
|
3
|
+
import { structuralShare } from './structural-share'
|
|
3
4
|
import type { AsyncStatus, RetryDelay, RetryPolicy, Snapshot } from './types'
|
|
4
5
|
|
|
5
6
|
export type EntryEvents = {
|
|
@@ -180,8 +181,14 @@ export class Entry<T> {
|
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
private applySuccess(result: T): T {
|
|
184
|
+
// Structurally share with the previous value so unchanged sub-trees
|
|
185
|
+
// keep their `===` identity. Downstream `computed`s and React snapshots
|
|
186
|
+
// stop thrashing on no-op refetches. Bails on Maps/Sets/class instances
|
|
187
|
+
// — see `structural-share.ts`.
|
|
188
|
+
const prev = this.data.peek() as T | undefined
|
|
189
|
+
const shared = prev === undefined ? result : structuralShare(prev, result)
|
|
183
190
|
batch(() => {
|
|
184
|
-
this.data.set(
|
|
191
|
+
this.data.set(shared)
|
|
185
192
|
this.error.set(undefined)
|
|
186
193
|
this.status.set('success')
|
|
187
194
|
this.isLoading.set(false)
|
|
@@ -195,8 +202,8 @@ export class Entry<T> {
|
|
|
195
202
|
} catch {
|
|
196
203
|
// devtools handlers must not break the program.
|
|
197
204
|
}
|
|
198
|
-
this.onSuccessData?.(
|
|
199
|
-
return
|
|
205
|
+
this.onSuccessData?.(shared)
|
|
206
|
+
return shared
|
|
200
207
|
}
|
|
201
208
|
|
|
202
209
|
private applyFailure(err: unknown): never {
|
package/src/query/infinite.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { batch, computed, type Signal, signal } from '../signals'
|
|
2
2
|
import type { ReadSignal } from '../signals/types'
|
|
3
3
|
import { abortableSleep, isAbortError } from '../utils'
|
|
4
|
+
import { structuralShare } from './structural-share'
|
|
4
5
|
import type { AsyncState, AsyncStatus, RetryDelay, RetryPolicy, Snapshot } from './types'
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -205,8 +206,14 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
|
|
|
205
206
|
this.initialPageParam,
|
|
206
207
|
(page, param) => {
|
|
207
208
|
if (myId !== this.currentFetchId || this.disposed) return
|
|
209
|
+
// Structurally share with the previous first-page on refresh, so
|
|
210
|
+
// unchanged pages keep their refs. We only share the head page —
|
|
211
|
+
// initial fetch wipes the rest of the array by definition.
|
|
212
|
+
const prevPages = this.pages.peek()
|
|
213
|
+
const sharedPage =
|
|
214
|
+
prevPages.length > 0 ? structuralShare(prevPages[0] as TPage, page) : page
|
|
208
215
|
batch(() => {
|
|
209
|
-
this.pages.set([
|
|
216
|
+
this.pages.set([sharedPage])
|
|
210
217
|
this.pageParams.set([param])
|
|
211
218
|
this.error.set(undefined)
|
|
212
219
|
this.status.set('success')
|
package/src/query/local.ts
CHANGED
|
@@ -83,6 +83,7 @@ class LocalCacheImpl<T> implements LocalCache<T> {
|
|
|
83
83
|
refetch = (): Promise<T> => this.entry.refetch()
|
|
84
84
|
reset = (): void => this.entry.reset()
|
|
85
85
|
firstValue = (): Promise<T> => this.entry.firstValue()
|
|
86
|
+
promise = (): Promise<T> => this.entry.firstValue()
|
|
86
87
|
invalidate = (): void => {
|
|
87
88
|
this.entry.invalidate().catch(() => {})
|
|
88
89
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk `prev` and `next` in parallel. Wherever a sub-tree in `next` is
|
|
3
|
+
* structurally equal to the corresponding sub-tree in `prev`, return `prev`'s
|
|
4
|
+
* reference for that sub-tree. Otherwise return `next`'s.
|
|
5
|
+
*
|
|
6
|
+
* Result: a value that is `===` to `prev` on every refetch where the payload
|
|
7
|
+
* didn't actually change, and shares maximum ref-identity on partial changes.
|
|
8
|
+
* Downstream `computed`s and React `useSyncExternalStore` snapshots stop
|
|
9
|
+
* thrashing because reference equality holds where content equality holds.
|
|
10
|
+
*
|
|
11
|
+
* Bails (returns the `next` ref unchanged, no recursion) on:
|
|
12
|
+
* - Mismatched constructors / different `typeof` between `prev` and `next`
|
|
13
|
+
* - `Map`, `Set`, `Date`, `RegExp`, class instances (anything where the
|
|
14
|
+
* plain-object / array fast path isn't safe)
|
|
15
|
+
* - Functions, symbols, promises
|
|
16
|
+
*
|
|
17
|
+
* Handles cycles via a `WeakSet` of in-progress objects — a self-referential
|
|
18
|
+
* payload that compares structurally identical against itself won't loop.
|
|
19
|
+
*/
|
|
20
|
+
export function structuralShare<T>(prev: T, next: T): T {
|
|
21
|
+
// Identity short-circuit — both branches see the exact same allocation.
|
|
22
|
+
if (Object.is(prev, next)) return prev
|
|
23
|
+
return walk(prev, next, new WeakSet<object>()) as T
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function walk(prev: unknown, next: unknown, seen: WeakSet<object>): unknown {
|
|
27
|
+
if (Object.is(prev, next)) return prev
|
|
28
|
+
if (prev === null || next === null) return next
|
|
29
|
+
if (typeof prev !== 'object' || typeof next !== 'object') return next
|
|
30
|
+
|
|
31
|
+
// Cycle guard. If either side is already on the in-flight stack, we can't
|
|
32
|
+
// safely recurse — fall back to `next`'s ref. Real cyclic payloads are
|
|
33
|
+
// exceedingly rare in HTTP responses; defensive bail.
|
|
34
|
+
if (seen.has(prev as object) || seen.has(next as object)) return next
|
|
35
|
+
|
|
36
|
+
// Arrays — only matched against arrays.
|
|
37
|
+
if (Array.isArray(prev) && Array.isArray(next)) {
|
|
38
|
+
return walkArray(prev, next, seen)
|
|
39
|
+
}
|
|
40
|
+
if (Array.isArray(prev) !== Array.isArray(next)) return next
|
|
41
|
+
|
|
42
|
+
// Constructor / prototype check. Plain objects have `Object.prototype`
|
|
43
|
+
// (and a Map/Set/Date/RegExp/class instance does not). We require an exact
|
|
44
|
+
// prototype match on both sides AND `Object.prototype` so we never deep-
|
|
45
|
+
// walk into class instances whose identity might encode hidden state.
|
|
46
|
+
const prevProto = Object.getPrototypeOf(prev)
|
|
47
|
+
if (prevProto !== Object.getPrototypeOf(next)) return next
|
|
48
|
+
if (prevProto !== Object.prototype && prevProto !== null) return next
|
|
49
|
+
|
|
50
|
+
return walkPlainObject(prev as Record<string, unknown>, next as Record<string, unknown>, seen)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function walkArray(
|
|
54
|
+
prev: ReadonlyArray<unknown>,
|
|
55
|
+
next: ReadonlyArray<unknown>,
|
|
56
|
+
seen: WeakSet<object>,
|
|
57
|
+
): ReadonlyArray<unknown> {
|
|
58
|
+
if (prev.length !== next.length) {
|
|
59
|
+
// Length changed — we can still preserve refs for matching prefixes via
|
|
60
|
+
// index-aligned walking. That's the right trade-off for tables / lists:
|
|
61
|
+
// appending an item keeps the head's refs stable, prepending invalidates
|
|
62
|
+
// everything (which it does anyway — items shifted).
|
|
63
|
+
}
|
|
64
|
+
seen.add(prev)
|
|
65
|
+
seen.add(next)
|
|
66
|
+
try {
|
|
67
|
+
const out: unknown[] = new Array(next.length)
|
|
68
|
+
let changed = next.length !== prev.length
|
|
69
|
+
for (let i = 0; i < next.length; i++) {
|
|
70
|
+
const prevItem = i < prev.length ? prev[i] : undefined
|
|
71
|
+
const shared = walk(prevItem, next[i], seen)
|
|
72
|
+
out[i] = shared
|
|
73
|
+
if (shared !== prev[i]) changed = true
|
|
74
|
+
}
|
|
75
|
+
if (!changed) return prev
|
|
76
|
+
return out
|
|
77
|
+
} finally {
|
|
78
|
+
seen.delete(prev)
|
|
79
|
+
seen.delete(next)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function walkPlainObject(
|
|
84
|
+
prev: Record<string, unknown>,
|
|
85
|
+
next: Record<string, unknown>,
|
|
86
|
+
seen: WeakSet<object>,
|
|
87
|
+
): Record<string, unknown> {
|
|
88
|
+
const prevKeys = Object.keys(prev)
|
|
89
|
+
const nextKeys = Object.keys(next)
|
|
90
|
+
let changed = prevKeys.length !== nextKeys.length
|
|
91
|
+
|
|
92
|
+
seen.add(prev)
|
|
93
|
+
seen.add(next)
|
|
94
|
+
try {
|
|
95
|
+
const out: Record<string, unknown> = {}
|
|
96
|
+
// Iterate `next`'s keys in order so the output preserves payload's
|
|
97
|
+
// key ordering (matters for downstream `JSON.stringify` callers and
|
|
98
|
+
// for predictable React reconciliation when an object is rendered).
|
|
99
|
+
for (const key of nextKeys) {
|
|
100
|
+
const shared = walk(prev[key], next[key], seen)
|
|
101
|
+
out[key] = shared
|
|
102
|
+
if (shared !== prev[key]) changed = true
|
|
103
|
+
else if (!(key in prev)) changed = true
|
|
104
|
+
}
|
|
105
|
+
// Keys present in `prev` but not in `next` are dropped — that's already
|
|
106
|
+
// expressed by `next.keys`. But the length-mismatch flag above catches
|
|
107
|
+
// the changed shape.
|
|
108
|
+
if (!changed) return prev
|
|
109
|
+
return out
|
|
110
|
+
} finally {
|
|
111
|
+
seen.delete(prev)
|
|
112
|
+
seen.delete(next)
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/query/types.ts
CHANGED
|
@@ -32,6 +32,13 @@ export type AsyncState<T> = {
|
|
|
32
32
|
refetch: () => Promise<T>
|
|
33
33
|
reset: () => void
|
|
34
34
|
firstValue: () => Promise<T>
|
|
35
|
+
/**
|
|
36
|
+
* Alias of `firstValue()` — clearer name for Suspense / `React.use(...)`
|
|
37
|
+
* use cases. Resolves with `data` on first success (short-circuits if
|
|
38
|
+
* already settled), rejects with `error` on the first failure. Use this
|
|
39
|
+
* to suspend a React tree until the query lands its first value.
|
|
40
|
+
*/
|
|
41
|
+
promise: () => Promise<T>
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
/**
|
|
@@ -159,10 +166,23 @@ export type QuerySubscription<T> = AsyncState<T>
|
|
|
159
166
|
|
|
160
167
|
/**
|
|
161
168
|
* Options passed to `ctx.use(query, opts)` to control the subscription
|
|
162
|
-
* (reactive key, enabled-gating). The `key` thunk reads signals —
|
|
163
|
-
* when they change re-keys the subscription.
|
|
169
|
+
* (reactive key, enabled-gating). The `key` thunk reads signals —
|
|
170
|
+
* re-evaluating when they change re-keys the subscription.
|
|
171
|
+
*
|
|
172
|
+
* A `select` projection that maps the underlying data shape to a view
|
|
173
|
+
* shape is accepted via a dedicated overload on `Ctx.use` rather than this
|
|
174
|
+
* options bag — the overload threads `T → U` types through cleanly.
|
|
164
175
|
*/
|
|
165
176
|
export type UseOptions<Args extends readonly unknown[]> = {
|
|
166
177
|
key?: () => Args
|
|
167
178
|
enabled?: () => boolean
|
|
168
179
|
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Internal shape — what `createUse` accepts. Includes the optional `select`
|
|
183
|
+
* field used by the `select` overload on `Ctx.use`. Not exported on the
|
|
184
|
+
* public surface; consumers use the typed overload.
|
|
185
|
+
*/
|
|
186
|
+
export type UseInternalOptions<Args extends readonly unknown[], T, U> = UseOptions<Args> & {
|
|
187
|
+
select?: (data: T) => U
|
|
188
|
+
}
|