@kontsedal/olas-core 0.0.2 → 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 (43) hide show
  1. package/dist/index.cjs +34 -1
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +52 -2
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +52 -2
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +33 -2
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-De-6KWIZ.mjs → root-Cnkb3I--.mjs} +248 -18
  10. package/dist/root-Cnkb3I--.mjs.map +1 -0
  11. package/dist/{root-XKEsSmcd.cjs → root-D_xAdcom.cjs} +248 -18
  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-C-zV1JZA.d.mts → types-CRn4UoLn.d.mts} +64 -6
  22. package/dist/types-CRn4UoLn.d.mts.map +1 -0
  23. package/dist/{types-DKfpkm17.d.cts → types-r_TVaRkD.d.cts} +64 -6
  24. package/dist/types-r_TVaRkD.d.cts.map +1 -0
  25. package/package.json +1 -1
  26. package/src/controller/types.ts +20 -0
  27. package/src/forms/field.ts +42 -9
  28. package/src/forms/form-types.ts +37 -0
  29. package/src/forms/form.ts +118 -0
  30. package/src/forms/index.ts +12 -1
  31. package/src/forms/standard-schema.ts +37 -0
  32. package/src/forms/validators.ts +31 -0
  33. package/src/index.ts +13 -3
  34. package/src/query/entry.ts +10 -3
  35. package/src/query/infinite.ts +8 -1
  36. package/src/query/structural-share.ts +114 -0
  37. package/src/query/types.ts +15 -2
  38. package/src/query/use.ts +47 -13
  39. package/src/testing.ts +2 -0
  40. package/dist/root-De-6KWIZ.mjs.map +0 -1
  41. package/dist/root-XKEsSmcd.cjs.map +0 -1
  42. package/dist/types-C-zV1JZA.d.mts.map +0 -1
  43. package/dist/types-DKfpkm17.d.cts.map +0 -1
@@ -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
@@ -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
+ }
@@ -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 { email, max, maxLength, min, minLength, pattern, required } from './forms'
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,
@@ -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(result)
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?.(result)
199
- return result
205
+ this.onSuccessData?.(shared)
206
+ return shared
200
207
  }
201
208
 
202
209
  private applyFailure(err: unknown): never {
@@ -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([page])
216
+ this.pages.set([sharedPage])
210
217
  this.pageParams.set([param])
211
218
  this.error.set(undefined)
212
219
  this.status.set('success')
@@ -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
+ }
@@ -159,10 +159,23 @@ export type QuerySubscription<T> = AsyncState<T>
159
159
 
160
160
  /**
161
161
  * Options passed to `ctx.use(query, opts)` to control the subscription
162
- * (reactive key, enabled-gating). The `key` thunk reads signals — re-evaluating
163
- * when they change re-keys the subscription.
162
+ * (reactive key, enabled-gating). The `key` thunk reads signals —
163
+ * re-evaluating when they change re-keys the subscription.
164
+ *
165
+ * A `select` projection that maps the underlying data shape to a view
166
+ * shape is accepted via a dedicated overload on `Ctx.use` rather than this
167
+ * options bag — the overload threads `T → U` types through cleanly.
164
168
  */
165
169
  export type UseOptions<Args extends readonly unknown[]> = {
166
170
  key?: () => Args
167
171
  enabled?: () => boolean
168
172
  }
173
+
174
+ /**
175
+ * Internal shape — what `createUse` accepts. Includes the optional `select`
176
+ * field used by the `select` overload on `Ctx.use`. Not exported on the
177
+ * public surface; consumers use the typed overload.
178
+ */
179
+ export type UseInternalOptions<Args extends readonly unknown[], T, U> = UseOptions<Args> & {
180
+ select?: (data: T) => U
181
+ }
package/src/query/use.ts CHANGED
@@ -2,17 +2,24 @@ import { computed, effect, type Signal, signal, untracked } from '../signals'
2
2
  import type { ReadSignal } from '../signals/types'
3
3
  import type { ClientEntry, InfiniteClientEntry, QueryClient } from './client'
4
4
  import type { InfiniteQuery, InfiniteQuerySpec, InfiniteQuerySubscription } from './infinite'
5
- import type { AsyncStatus, Query, QuerySpec, QuerySubscription, UseOptions } from './types'
5
+ import type {
6
+ AsyncStatus,
7
+ Query,
8
+ QuerySpec,
9
+ QuerySubscription,
10
+ UseInternalOptions,
11
+ UseOptions,
12
+ } from './types'
6
13
 
7
14
  type QueryInternal<Args extends unknown[], T> = Query<Args, T> & {
8
15
  readonly __spec: QuerySpec<Args, T>
9
16
  }
10
17
 
11
- class SubscriptionImpl<T> implements QuerySubscription<T> {
18
+ class SubscriptionImpl<T, U = T> implements QuerySubscription<U> {
12
19
  private readonly current$: Signal<ClientEntry<T> | null> = signal(null)
13
20
  private readonly previousData$: Signal<T | undefined> = signal(undefined)
14
21
 
15
- readonly data: ReadSignal<T | undefined>
22
+ readonly data: ReadSignal<U | undefined>
16
23
  readonly error: ReadSignal<unknown | undefined>
17
24
  readonly status: ReadSignal<AsyncStatus>
18
25
  readonly isLoading: ReadSignal<boolean>
@@ -21,14 +28,30 @@ class SubscriptionImpl<T> implements QuerySubscription<T> {
21
28
  readonly lastUpdatedAt: ReadSignal<number | undefined>
22
29
  readonly hasPendingMutations: ReadSignal<boolean>
23
30
 
24
- constructor(private readonly keepPreviousData: boolean) {
25
- this.data = computed(() => {
31
+ constructor(
32
+ private readonly keepPreviousData: boolean,
33
+ private readonly select?: (data: T) => U,
34
+ ) {
35
+ // The underlying entry stores `T`. The subscription's `data` is `U`
36
+ // (or `T` when no projection). We compute the raw `T` once, then layer
37
+ // `select` in a second computed so the projection's `Object.is` dedup
38
+ // applies BEFORE downstream subscribers run — combined with structural
39
+ // sharing on the entry, an unchanged payload + a stable `select`
40
+ // outputs the same `U` reference and doesn't churn the React tree.
41
+ const rawData = computed(() => {
26
42
  const cur = this.current$.value
27
43
  const curData = cur?.entry.data.value
28
44
  if (curData !== undefined) return curData
29
45
  if (keepPreviousData) return this.previousData$.value
30
46
  return undefined
31
47
  })
48
+ this.data =
49
+ select === undefined
50
+ ? (rawData as unknown as ReadSignal<U | undefined>)
51
+ : computed<U | undefined>(() => {
52
+ const raw = rawData.value
53
+ return raw === undefined ? undefined : select(raw)
54
+ })
32
55
  this.error = computed(() => this.current$.value?.entry.error.value)
33
56
  this.status = computed<AsyncStatus>(() => this.current$.value?.entry.status.value ?? 'idle')
34
57
  this.isLoading = computed(() => {
@@ -59,33 +82,42 @@ class SubscriptionImpl<T> implements QuerySubscription<T> {
59
82
  this.current$.set(null)
60
83
  }
61
84
 
62
- refetch = (): Promise<T> => {
85
+ refetch = (): Promise<U> => {
63
86
  const cur = this.current$.peek()
64
87
  if (!cur) return Promise.reject(new Error('[olas] no active subscription'))
65
- return cur.entry.refetch()
88
+ return cur.entry.refetch().then((v) => this.project(v))
66
89
  }
67
90
 
68
91
  reset = (): void => {
69
92
  this.current$.peek()?.entry.reset()
70
93
  }
71
94
 
72
- firstValue = (): Promise<T> => {
95
+ firstValue = (): Promise<U> => {
73
96
  const cur = this.current$.peek()
74
97
  if (!cur) return Promise.reject(new Error('[olas] no active subscription'))
75
- return cur.entry.firstValue()
98
+ return cur.entry.firstValue().then((v) => this.project(v))
99
+ }
100
+
101
+ private project(v: T): U {
102
+ return this.select === undefined ? (v as unknown as U) : this.select(v)
76
103
  }
77
104
  }
78
105
 
79
106
  /**
80
107
  * Build a subscription + the effect that keeps it bound to the right entry.
81
108
  * The controller container wires the disposer into the lifecycle.
109
+ *
110
+ * `keyOrOptions` may carry an optional `select` projection that maps the
111
+ * underlying `T` to a view `U`; the returned subscription's data shape
112
+ * widens accordingly. Without `select`, `U = T` and the projection
113
+ * computed is skipped.
82
114
  */
83
- export function createUse<Args extends unknown[], T>(
115
+ export function createUse<Args extends unknown[], T, U = T>(
84
116
  client: QueryClient,
85
117
  query: Query<Args, T>,
86
- keyOrOptions?: (() => Args) | UseOptions<Args>,
118
+ keyOrOptions?: (() => Args) | UseInternalOptions<Args, T, U>,
87
119
  ): {
88
- subscription: QuerySubscription<T>
120
+ subscription: QuerySubscription<U>
89
121
  dispose: () => void
90
122
  /** Suspend the subscription — release the entry (its refetchInterval +
91
123
  * focus/online listeners pause) without disposing it. Spec §4.1. */
@@ -100,8 +132,10 @@ export function createUse<Args extends unknown[], T>(
100
132
  const keyFn = typeof keyOrOptions === 'function' ? keyOrOptions : keyOrOptions?.key
101
133
  const enabledFn =
102
134
  typeof keyOrOptions === 'object' && keyOrOptions !== null ? keyOrOptions.enabled : undefined
135
+ const select =
136
+ typeof keyOrOptions === 'object' && keyOrOptions !== null ? keyOrOptions.select : undefined
103
137
 
104
- const sub = new SubscriptionImpl<T>(keepPreviousData)
138
+ const sub = new SubscriptionImpl<T, U>(keepPreviousData, select)
105
139
  let currentEntry: ClientEntry<T> | null = null
106
140
  let suspended = false
107
141
 
package/src/testing.ts CHANGED
@@ -47,6 +47,7 @@ export function fakeField<T>(
47
47
  reset: () => void
48
48
  markTouched: () => void
49
49
  revalidate: () => Promise<boolean>
50
+ setErrors: (errors: ReadonlyArray<string>) => void
50
51
  dispose: () => void
51
52
  }>,
52
53
  ): Field<T> {
@@ -85,6 +86,7 @@ export function fakeField<T>(
85
86
  reset: overrides?.reset ?? (() => value$.set(currentInitial)),
86
87
  markTouched: overrides?.markTouched ?? (() => touched$.set(true)),
87
88
  revalidate: overrides?.revalidate ?? (async () => errors$.peek().length === 0),
89
+ setErrors: overrides?.setErrors ?? ((errs) => errors$.set([...errs])),
88
90
  dispose: overrides?.dispose ?? (() => {}),
89
91
  }
90
92
  return fake