@kontsedal/olas-core 0.0.6 → 0.1.0

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 (61) hide show
  1. package/dist/index.cjs +0 -0
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +109 -27
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +109 -27
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +0 -0
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-B6pNq75w.mjs → root-Byq-QYTp.mjs} +1465 -347
  10. package/dist/root-Byq-QYTp.mjs.map +1 -0
  11. package/dist/{root-CFgYhBd-.cjs → root-CPSL5kpR.cjs} +1470 -346
  12. package/dist/root-CPSL5kpR.cjs.map +1 -0
  13. package/dist/testing.cjs +5 -1
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.d.cts +3 -2
  16. package/dist/testing.d.cts.map +1 -1
  17. package/dist/testing.d.mts +3 -2
  18. package/dist/testing.d.mts.map +1 -1
  19. package/dist/testing.mjs +5 -2
  20. package/dist/testing.mjs.map +1 -1
  21. package/dist/{types-Bmi04hDI.d.mts → types-Brp-4WaZ.d.mts} +233 -23
  22. package/dist/types-Brp-4WaZ.d.mts.map +1 -0
  23. package/dist/{types-BsZMPPOE.d.cts → types-DzDIypPo.d.cts} +233 -23
  24. package/dist/types-DzDIypPo.d.cts.map +1 -0
  25. package/package.json +4 -1
  26. package/src/controller/instance.ts +345 -94
  27. package/src/controller/root.ts +52 -30
  28. package/src/controller/types.ts +55 -4
  29. package/src/emitter.ts +8 -2
  30. package/src/errors.ts +39 -3
  31. package/src/forms/field.ts +219 -25
  32. package/src/forms/form-types.ts +16 -3
  33. package/src/forms/form.ts +360 -38
  34. package/src/forms/index.ts +3 -1
  35. package/src/forms/types.ts +27 -1
  36. package/src/forms/validators.ts +45 -11
  37. package/src/index.ts +13 -7
  38. package/src/query/client.ts +237 -58
  39. package/src/query/define.ts +0 -0
  40. package/src/query/entry.ts +259 -15
  41. package/src/query/focus-online.ts +53 -11
  42. package/src/query/infinite.ts +351 -47
  43. package/src/query/keys.ts +33 -2
  44. package/src/query/local.ts +3 -0
  45. package/src/query/mutation.ts +27 -6
  46. package/src/query/plugin.ts +43 -4
  47. package/src/query/types.ts +76 -6
  48. package/src/query/use.ts +53 -10
  49. package/src/selection.ts +49 -12
  50. package/src/signals/readonly.ts +3 -0
  51. package/src/signals/runtime.ts +27 -0
  52. package/src/signals/types.ts +10 -0
  53. package/src/testing.ts +9 -0
  54. package/src/timing/debounced.ts +106 -23
  55. package/src/timing/index.ts +1 -1
  56. package/src/timing/throttled.ts +85 -28
  57. package/src/utils.ts +15 -2
  58. package/dist/root-B6pNq75w.mjs.map +0 -1
  59. package/dist/root-CFgYhBd-.cjs.map +0 -1
  60. package/dist/types-Bmi04hDI.d.mts.map +0 -1
  61. package/dist/types-BsZMPPOE.d.cts.map +0 -1
@@ -4,7 +4,7 @@ import { batch, type Signal, signal } from '../signals'
4
4
  import type { ReadSignal } from '../signals/types'
5
5
  import { abortableSleep, isAbortError } from '../utils'
6
6
  import { registerMutationById } from './plugin'
7
- import type { RetryDelay, RetryPolicy, Snapshot } from './types'
7
+ import type { AsyncStatus, RetryDelay, RetryPolicy, Snapshot } from './types'
8
8
 
9
9
  /**
10
10
  * How concurrent calls to `mutation.run(...)` interact:
@@ -148,8 +148,17 @@ export type Mutation<V, R> = {
148
148
  data: ReadSignal<R | undefined>
149
149
  error: ReadSignal<unknown | undefined>
150
150
  isPending: ReadSignal<boolean>
151
+ /**
152
+ * Outcome of the latest run: `'idle'` (never run / reset), `'pending'`
153
+ * (in flight), `'success'`, `'error'`. Distinct from `isPending` (which
154
+ * stays true while ANY run is in flight, for parallel mode) and from `data`
155
+ * — a `void` mutation still reports `status: 'success'` after it resolves.
156
+ * A superseded `latest-wins` run does NOT flip status to `'error'`; the
157
+ * superseder owns the final status.
158
+ */
159
+ status: ReadSignal<AsyncStatus>
151
160
  lastVariables: ReadSignal<V | undefined>
152
- /** Clear `data` / `error` / `lastVariables` without aborting in-flight runs. */
161
+ /** Clear `data` / `error` / `lastVariables` / `status` without aborting in-flight runs. */
153
162
  reset(): void
154
163
  /** Abort in-flight runs and tear down. Idempotent. Called by the parent controller's dispose. */
155
164
  dispose(): void
@@ -190,6 +199,7 @@ class MutationImpl<V, R> implements Mutation<V, R> {
190
199
  readonly data: Signal<R | undefined> = signal(undefined)
191
200
  readonly error: Signal<unknown | undefined> = signal(undefined)
192
201
  readonly isPending: Signal<boolean> = signal(false)
202
+ readonly status: Signal<AsyncStatus> = signal<AsyncStatus>('idle')
193
203
  readonly lastVariables: Signal<V | undefined> = signal(undefined)
194
204
 
195
205
  private inflight = new Set<RunHandle>()
@@ -296,10 +306,17 @@ class MutationImpl<V, R> implements Mutation<V, R> {
296
306
  const raw = this.spec.onMutate?.(vars) ?? undefined
297
307
  snapshot = raw === undefined ? undefined : this.wrapSnapshot(raw)
298
308
  } catch (err) {
299
- dispatchError(this.onError, err, {
300
- kind: 'mutation',
301
- controllerPath: this.controllerPath,
302
- })
309
+ // onMutate threw — the optimistic setup failed, so running `mutate`
310
+ // against a half-applied state is unsafe. Abort the whole run: surface
311
+ // via the error signal, the mutation's onError/onSettled, and the
312
+ // rejected run promise (mirrors the mutate-failure path). No snapshot
313
+ // exists yet, so nothing to roll back; `mutate` is never called. T3.9.
314
+ this.error.set(err)
315
+ this.status.set('error')
316
+ if (__DEV__) this.emit({ type: 'mutation:error', error: err })
317
+ this.safeCall(() => this.spec.onError?.(err, vars, undefined), 'mutation')
318
+ this.safeCall(() => this.spec.onSettled?.(undefined, err, vars), 'mutation')
319
+ throw err
303
320
  }
304
321
 
305
322
  const handle: RunHandle = { abort, snapshot }
@@ -307,6 +324,7 @@ class MutationImpl<V, R> implements Mutation<V, R> {
307
324
  this.inflightCounter?.update((n) => n + 1)
308
325
  batch(() => {
309
326
  this.isPending.set(true)
327
+ this.status.set('pending')
310
328
  this.lastVariables.set(vars)
311
329
  })
312
330
 
@@ -341,6 +359,7 @@ class MutationImpl<V, R> implements Mutation<V, R> {
341
359
  batch(() => {
342
360
  this.data.set(result)
343
361
  this.error.set(undefined)
362
+ this.status.set('success')
344
363
  })
345
364
  if (__DEV__) this.emit({ type: 'mutation:success', result })
346
365
  this.safeCall(() => this.spec.onSuccess?.(result, vars), 'mutation')
@@ -363,6 +382,7 @@ class MutationImpl<V, R> implements Mutation<V, R> {
363
382
  throw err
364
383
  }
365
384
  this.error.set(err)
385
+ this.status.set('error')
366
386
  if (__DEV__) this.emit({ type: 'mutation:error', error: err })
367
387
  this.safeCall(() => this.spec.onError?.(err, vars, snapshot), 'mutation')
368
388
  // Auto-rollback after the user's onError. The wrapped snapshot is
@@ -467,6 +487,7 @@ class MutationImpl<V, R> implements Mutation<V, R> {
467
487
  this.error.set(undefined)
468
488
  this.lastVariables.set(undefined)
469
489
  this.isPending.set(false)
490
+ this.status.set('idle')
470
491
  })
471
492
  }
472
493
 
@@ -93,7 +93,9 @@ export type SetDataEvent = {
93
93
  isRemote: boolean
94
94
  /**
95
95
  * Origin of the write. `'set'` covers explicit `client.setData` (mutations,
96
- * optimistic updates, plugin-initiated patches). `'fetch'` fires when the
96
+ * optimistic updates AND their `snapshot.rollback()`, plugin-initiated
97
+ * patches) — a rollback re-broadcasts the restored value so peers drop the
98
+ * failed optimistic state (T3.6). `'fetch'` fires when the
97
99
  * query fetcher resolved successfully and wrote the result into the entry
98
100
  * — emitted after the data signal is settled. `'remote'` is the
99
101
  * `applyRemoteSetData` path (cross-tab / server-push); equivalent to
@@ -154,6 +156,13 @@ export type MutationSettleEvent = {
154
156
  * `onError` handler with `kind: 'plugin'`.
155
157
  */
156
158
  export type QueryClientPlugin = {
159
+ /**
160
+ * Optional human-readable identifier surfaced in `ErrorContext.pluginName`
161
+ * when this plugin's callback throws. Without it, `pluginName` is left
162
+ * `undefined`. Recommended for shipped plugins so Sentry/OTel adapters
163
+ * can attribute errors back to the right package.
164
+ */
165
+ readonly name?: string
157
166
  /**
158
167
  * Called once after the `QueryClient` is constructed. Use it to wire up
159
168
  * transport listeners and capture the `QueryClientPluginApi`. SPEC §13.2.
@@ -197,15 +206,45 @@ export type RegisteredQuery = {
197
206
  readonly __spec: { queryId?: string; crossTab?: boolean }
198
207
  }
199
208
 
200
- const queryRegistry = new Map<string, RegisteredQuery>()
209
+ /**
210
+ * Back a module-level registry on `globalThis` under a shared `Symbol.for`
211
+ * key. Guards the dual-package hazard: if a consumer ends up with both the ESM
212
+ * and CJS build of `@kontsedal/olas-core` (common with mixed bundler configs),
213
+ * each copy would otherwise own a SEPARATE registry `Map` — a mutation
214
+ * registered via the ESM copy would be invisible to the mutation-queue plugin
215
+ * loaded against the CJS copy, and cross-tab query lookups would miss. Sharing
216
+ * one Map on `globalThis` makes both copies agree. T3.9.
217
+ */
218
+ function globalRegistry<V>(key: symbol): Map<string, V> {
219
+ const store = globalThis as unknown as Record<symbol, Map<string, V> | undefined>
220
+ const existing = store[key]
221
+ if (existing) return existing
222
+ const created = new Map<string, V>()
223
+ store[key] = created
224
+ return created
225
+ }
226
+
227
+ const queryRegistry = globalRegistry<RegisteredQuery>(Symbol.for('olas.queryRegistry'))
201
228
 
202
229
  /**
203
230
  * Register a query by its `queryId`. Internal — called from `defineQuery` /
204
231
  * `defineInfiniteQuery`. Replaces any previous registration with the same
205
232
  * id (matches Olas's "full root rebuild" HMR story; a mid-flight remote
206
- * message routed against the old `Query` simply misses).
233
+ * message routed against the old `Query` simply misses). Dev-warns when a
234
+ * *different* query overwrites an existing id — a genuine collision, since
235
+ * cross-tab / persistence route by `queryId` (expected during HMR).
207
236
  */
208
237
  export function registerQueryById(queryId: string, query: RegisteredQuery): void {
238
+ if (__DEV__) {
239
+ const existing = queryRegistry.get(queryId)
240
+ if (existing !== undefined && existing !== query) {
241
+ console.warn(
242
+ `[olas] duplicate queryId "${queryId}": a different query is replacing an existing ` +
243
+ 'registration. `queryId` must be unique per query (cross-tab / persistence route by it). ' +
244
+ 'Expected during HMR; a collision bug otherwise.',
245
+ )
246
+ }
247
+ }
209
248
  queryRegistry.set(queryId, query)
210
249
  }
211
250
 
@@ -251,7 +290,7 @@ export type RegisteredMutation = {
251
290
  readonly mutate: (vars: unknown, signal: AbortSignal) => Promise<unknown>
252
291
  }
253
292
 
254
- const mutationRegistry = new Map<string, RegisteredMutation>()
293
+ const mutationRegistry = globalRegistry<RegisteredMutation>(Symbol.for('olas.mutationRegistry'))
255
294
 
256
295
  /** Register a mutation by its `mutationId`. Internal — called from `defineMutation`. */
257
296
  export function registerMutationById(mutationId: string, entry: RegisteredMutation): void {
@@ -4,7 +4,7 @@ import type { ReadSignal } from '../signals/types'
4
4
  export type AsyncStatus = 'idle' | 'pending' | 'success' | 'error'
5
5
 
6
6
  /**
7
- * The eight reactive signals + three actions a subscriber sees for any async
7
+ * The nine reactive signals + three actions a subscriber sees for any async
8
8
  * resource (`LocalCache<T>` or a `Query` subscription). Spec §20.4.
9
9
  *
10
10
  * - `data` / `error` / `status` — current outcome.
@@ -13,6 +13,7 @@ export type AsyncStatus = 'idle' | 'pending' | 'success' | 'error'
13
13
  * - `isStale` — true when `staleTime` has elapsed since `lastUpdatedAt`.
14
14
  * - `lastUpdatedAt` — epoch ms of last success.
15
15
  * - `hasPendingMutations` — at least one mutation has a snapshot on this entry.
16
+ * - `isPaused` — a fetch is parked waiting for network reconnect.
16
17
  *
17
18
  * Actions:
18
19
  * - `refetch()` — force a fetch; resolves with the result.
@@ -28,6 +29,15 @@ export type AsyncState<T> = {
28
29
  isStale: ReadSignal<boolean>
29
30
  lastUpdatedAt: ReadSignal<number | undefined>
30
31
  hasPendingMutations: ReadSignal<boolean>
32
+ /**
33
+ * True while a fetch is parked waiting for network reconnect — either an
34
+ * `online`-mode fetch deferred because `navigator.onLine` was `false`, or an
35
+ * `offlineFirst` fetch that hit a network error while offline and is waiting
36
+ * to retry on reconnect. Distinct from `isFetching` (nothing is in flight
37
+ * while parked) and from `status` (stays `idle` / last-success, never
38
+ * flips to `error` for the parked attempt). Spec §5.5.
39
+ */
40
+ isPaused: ReadSignal<boolean>
31
41
 
32
42
  refetch: () => Promise<T>
33
43
  reset: () => void
@@ -75,6 +85,13 @@ export type LocalCache<T> = AsyncState<T> & {
75
85
 
76
86
  /** One entry inside a `DehydratedState`. */
77
87
  export type DehydratedEntry = {
88
+ /**
89
+ * Stable query identity — `spec.queryId` when set, else an auto-assigned
90
+ * registration id. Namespaces the hydration buffer so a subscriber of query
91
+ * B can't adopt query A's payload just because their `key()` outputs hash
92
+ * the same (spec §15).
93
+ */
94
+ id: string
78
95
  key: readonly unknown[]
79
96
  data: unknown
80
97
  lastUpdatedAt: number
@@ -117,6 +134,26 @@ export type FetchCtx = {
117
134
  * cache args come after. This shape lets module-scoped queries read
118
135
  * `ctx.deps.api` etc. — no `setApiForQuery(api)` module-level capture needed.
119
136
  */
137
+ /**
138
+ * How a query behaves with respect to the network reachability signal.
139
+ *
140
+ * - `online` (default) — pause fetches while `navigator.onLine` is `false`;
141
+ * automatically resume when reconnect fires (via `subscribeReconnect`).
142
+ * Inflight fetches are NOT aborted on offline; a `bindEntry` / `acquire`
143
+ * that lands while offline simply defers the initial fetch. The deferred
144
+ * entry reports `isPaused: true` until reconnect.
145
+ * - `always` — never gate on connectivity; fetcher runs whenever requested.
146
+ * Useful for queries against `localhost` / IPC / a service worker that
147
+ * doesn't surface through `navigator.onLine`.
148
+ * - `offlineFirst` — start the fetch regardless; if it rejects with a
149
+ * network-shaped error (a `fetch` `TypeError`; `AbortError` excluded) while
150
+ * `navigator.onLine` is `false`, park the entry (`isPaused: true`, status
151
+ * stays `idle` / last-success) and retry on reconnect rather than surfacing
152
+ * the error. Matches TanStack's `offlineFirst` policy for app-shell-first
153
+ * PWAs.
154
+ */
155
+ export type NetworkMode = 'online' | 'always' | 'offlineFirst'
156
+
120
157
  export type QuerySpec<Args extends unknown[], T> = {
121
158
  key: (...args: Args) => unknown[]
122
159
  fetcher: (ctx: FetchCtx, ...args: Args) => Promise<T>
@@ -128,6 +165,20 @@ export type QuerySpec<Args extends unknown[], T> = {
128
165
  keepPreviousData?: boolean
129
166
  retry?: RetryPolicy
130
167
  retryDelay?: RetryDelay
168
+ /**
169
+ * Network-mode policy. Defaults to `'online'`. See `NetworkMode` for the
170
+ * three policies. Override per-query for special cases (e.g. `'always'`
171
+ * for `localhost` queries that don't surface through `navigator.onLine`).
172
+ */
173
+ networkMode?: NetworkMode
174
+ /**
175
+ * Whether to run `structuralShare(prev, next)` after a successful fetch.
176
+ * Defaults to `true`. Set `false` for queries returning large payloads
177
+ * (e.g. a 100k-row table) where the O(payload) deep walk on every poll
178
+ * is more expensive than re-rendering. With `false`, `applySuccess`
179
+ * writes the fetcher's result directly.
180
+ */
181
+ structuralShare?: boolean
131
182
  /**
132
183
  * Stable identifier used by `QueryClientPlugin`s (e.g. `@kontsedal/olas-cross-tab`)
133
184
  * to locate the same query across tabs / processes / persistence layers.
@@ -138,10 +189,17 @@ export type QuerySpec<Args extends unknown[], T> = {
138
189
  */
139
190
  queryId?: string
140
191
  /**
141
- * Opt this query into cross-tab cache sync (`@kontsedal/olas-cross-tab`). No effect
142
- * without a `queryId` and without a plugin installed. SPEC §13.2.
192
+ * Opt this query into cross-tab cache sync (`@kontsedal/olas-cross-tab`).
193
+ * No effect without a `queryId` and without a plugin installed. SPEC §13.2.
194
+ *
195
+ * - `true` (legacy) — equivalent to `'data'`.
196
+ * - `'data'` — propagate explicit `setData`/`invalidate` writes.
197
+ *
198
+ * The `'infinite'` / `'both'` values were removed (T6.4): peers can't apply
199
+ * infinite-query page arrays cross-tab, so those broadcasts were channel
200
+ * noise. Infinite cross-tab is tracked in `BACKLOG.md`.
143
201
  */
144
- crossTab?: boolean
202
+ crossTab?: boolean | 'data'
145
203
  }
146
204
 
147
205
  /**
@@ -157,12 +215,24 @@ export type Query<Args extends unknown[], T> = {
157
215
  invalidateAll(): void
158
216
  /** Patch the current data for a specific key. Returns a `Snapshot` for rollback. */
159
217
  setData(...args: [...Args, updater: (prev: T | undefined) => T]): Snapshot
218
+ /**
219
+ * Cancel the in-flight fetch for a specific key (if any). Aborts + supersedes
220
+ * it, restores a settled status (`'success'` if data exists, else `'idle'`),
221
+ * and does NOT touch data. Use before an optimistic `setData` so an
222
+ * outgoing refetch's stale response can't clobber it (spec §5, §6.4).
223
+ */
224
+ cancel(...args: Args): void
225
+ /** Cancel in-flight fetches for every keyed entry of this query. */
226
+ cancelAll(): void
160
227
  /** Eagerly fetch into the cache without subscribing. */
161
228
  prefetch(...args: Args): Promise<T>
162
229
  }
163
230
 
164
- /** What `ctx.use(query, ...)` returns. Alias of `AsyncState<T>`. */
165
- export type QuerySubscription<T> = AsyncState<T>
231
+ /** What `ctx.use(query, ...)` returns `AsyncState<T>` plus `cancel()`. */
232
+ export type QuerySubscription<T> = AsyncState<T> & {
233
+ /** Cancel this subscription's in-flight fetch (if any). See `Query.cancel`. */
234
+ cancel: () => void
235
+ }
166
236
 
167
237
  /**
168
238
  * Options passed to `ctx.use(query, opts)` to control the subscription
package/src/query/use.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { computed, effect, type Signal, signal, untracked } from '../signals'
2
2
  import type { ReadSignal } from '../signals/types'
3
+ import { isAbortError } from '../utils'
3
4
  import type { ClientEntry, InfiniteClientEntry, QueryClient } from './client'
4
5
  import type { InfiniteQuery, InfiniteQuerySpec, InfiniteQuerySubscription } from './infinite'
5
6
  import type {
@@ -27,6 +28,7 @@ class SubscriptionImpl<T, U = T> implements QuerySubscription<U> {
27
28
  readonly isStale: ReadSignal<boolean>
28
29
  readonly lastUpdatedAt: ReadSignal<number | undefined>
29
30
  readonly hasPendingMutations: ReadSignal<boolean>
31
+ readonly isPaused: ReadSignal<boolean>
30
32
 
31
33
  constructor(
32
34
  private readonly keepPreviousData: boolean,
@@ -66,6 +68,7 @@ class SubscriptionImpl<T, U = T> implements QuerySubscription<U> {
66
68
  this.hasPendingMutations = computed(
67
69
  () => this.current$.value?.entry.hasPendingMutations.value ?? false,
68
70
  )
71
+ this.isPaused = computed(() => this.current$.value?.entry.isPaused.value ?? false)
69
72
  }
70
73
 
71
74
  attach(entry: ClientEntry<T>): void {
@@ -85,13 +88,26 @@ class SubscriptionImpl<T, U = T> implements QuerySubscription<U> {
85
88
  refetch = (): Promise<U> => {
86
89
  const cur = this.current$.peek()
87
90
  if (!cur) return Promise.reject(new Error('[olas] no active subscription'))
88
- return cur.entry.refetch().then((v) => this.project(v))
91
+ return cur.entry.refetch().then(
92
+ (v) => this.project(v),
93
+ (err) => {
94
+ // A supersede (newer refetch / key change) aborts this fetch. Don't
95
+ // surface the spurious AbortError — resolve with the superseding
96
+ // fetch's eventual outcome instead (T3.9). Real errors still reject.
97
+ if (isAbortError(err)) return this.firstValue()
98
+ throw err
99
+ },
100
+ )
89
101
  }
90
102
 
91
103
  reset = (): void => {
92
104
  this.current$.peek()?.entry.reset()
93
105
  }
94
106
 
107
+ cancel = (): void => {
108
+ this.current$.peek()?.entry.cancel()
109
+ }
110
+
95
111
  firstValue = (): Promise<U> => {
96
112
  const cur = this.current$.peek()
97
113
  if (!cur) return Promise.reject(new Error('[olas] no active subscription'))
@@ -143,8 +159,19 @@ export function createUse<Args extends unknown[], T, U = T>(
143
159
  let suspended = false
144
160
 
145
161
  const effectDispose = effect(() => {
146
- if (suspended) return
162
+ // Read tracked signals (enabled, then key when enabled) BEFORE the
163
+ // `suspended` early-return, so the effect keeps its dependency set while
164
+ // suspended. Otherwise a key/enabled change during suspension re-runs the
165
+ // effect, it reads nothing, its deps go empty, and it stays inert forever
166
+ // — resume() then only rebinds to the key as of suspend time, silently
167
+ // dropping every later change (T2.1). Key is read only when enabled,
168
+ // preserving the `enabled`-guards-`key` pattern (the key thunk may deref
169
+ // state that only exists once enabled).
147
170
  const isEnabled = enabledFn ? enabledFn() : true
171
+ const args = isEnabled ? ((keyFn ? keyFn() : ([] as unknown as Args)) as Args) : undefined
172
+
173
+ if (suspended) return
174
+
148
175
  if (!isEnabled) {
149
176
  untracked(() => {
150
177
  if (currentEntry) {
@@ -156,10 +183,8 @@ export function createUse<Args extends unknown[], T, U = T>(
156
183
  return
157
184
  }
158
185
 
159
- const args = (keyFn ? keyFn() : ([] as unknown as Args)) as Args
160
-
161
186
  untracked(() => {
162
- const entry = client.bindEntry<Args, T>(query, args)
187
+ const entry = client.bindEntry<Args, T>(query, args as Args)
163
188
  if (currentEntry === entry) return
164
189
  if (currentEntry) currentEntry.release()
165
190
  entry.acquire()
@@ -247,6 +272,7 @@ class InfiniteSubscriptionImpl<TPage, TItem> implements InfiniteQuerySubscriptio
247
272
  readonly isStale: ReadSignal<boolean>
248
273
  readonly lastUpdatedAt: ReadSignal<number | undefined>
249
274
  readonly hasPendingMutations: ReadSignal<boolean>
275
+ readonly isPaused: ReadSignal<boolean>
250
276
  readonly hasNextPage: ReadSignal<boolean>
251
277
  readonly hasPreviousPage: ReadSignal<boolean>
252
278
  readonly isFetchingNextPage: ReadSignal<boolean>
@@ -288,6 +314,7 @@ class InfiniteSubscriptionImpl<TPage, TItem> implements InfiniteQuerySubscriptio
288
314
  this.hasPendingMutations = computed(
289
315
  () => this.current$.value?.entry.hasPendingMutations.value ?? false,
290
316
  )
317
+ this.isPaused = computed(() => this.current$.value?.entry.isPaused.value ?? false)
291
318
  this.hasNextPage = computed(() => this.current$.value?.entry.hasNextPage.value ?? false)
292
319
  this.hasPreviousPage = computed(() => this.current$.value?.entry.hasPreviousPage.value ?? false)
293
320
  this.isFetchingNextPage = computed(
@@ -315,13 +342,24 @@ class InfiniteSubscriptionImpl<TPage, TItem> implements InfiniteQuerySubscriptio
315
342
  refetch = (): Promise<TPage[]> => {
316
343
  const cur = this.current$.peek()
317
344
  if (!cur) return Promise.reject(new Error('[olas] no active subscription'))
318
- return cur.entry.refetch().then(() => cur.entry.pages.peek())
345
+ return cur.entry.refetch().then(
346
+ () => cur.entry.pages.peek(),
347
+ (err) => {
348
+ // Supersede → resolve with the superseder's outcome, not AbortError (T3.9).
349
+ if (isAbortError(err)) return this.firstValue()
350
+ throw err
351
+ },
352
+ )
319
353
  }
320
354
 
321
355
  reset = (): void => {
322
356
  this.current$.peek()?.entry.reset()
323
357
  }
324
358
 
359
+ cancel = (): void => {
360
+ this.current$.peek()?.entry.cancel()
361
+ }
362
+
325
363
  firstValue = (): Promise<TPage[]> => {
326
364
  const cur = this.current$.peek()
327
365
  if (!cur) return Promise.reject(new Error('[olas] no active subscription'))
@@ -365,8 +403,15 @@ export function createInfiniteUse<Args extends unknown[], TPage, TItem>(
365
403
  let suspended = false
366
404
 
367
405
  const effectDispose = effect(() => {
368
- if (suspended) return
406
+ // See the regular-query variant above: read enabled + key (when enabled)
407
+ // BEFORE the `suspended` early-return so the effect keeps its deps through
408
+ // suspension; otherwise a key change during suspend empties them and the
409
+ // subscription goes inert after resume (T2.1).
369
410
  const isEnabled = enabledFn ? enabledFn() : true
411
+ const args = isEnabled ? ((keyFn ? keyFn() : ([] as unknown as Args)) as Args) : undefined
412
+
413
+ if (suspended) return
414
+
370
415
  if (!isEnabled) {
371
416
  untracked(() => {
372
417
  if (currentEntry) {
@@ -378,10 +423,8 @@ export function createInfiniteUse<Args extends unknown[], TPage, TItem>(
378
423
  return
379
424
  }
380
425
 
381
- const args = (keyFn ? keyFn() : ([] as unknown as Args)) as Args
382
-
383
426
  untracked(() => {
384
- const entry = client.bindInfiniteEntry<Args, TPage, TItem>(query, args)
427
+ const entry = client.bindInfiniteEntry<Args, TPage, TItem>(query, args as Args)
385
428
  if (currentEntry === entry) return
386
429
  if (currentEntry) currentEntry.release()
387
430
  entry.acquire()
package/src/selection.ts CHANGED
@@ -24,7 +24,7 @@ export type Selection<T = unknown> = {
24
24
  handleClick(
25
25
  id: string,
26
26
  mods: { shift?: boolean; meta?: boolean },
27
- ordered: readonly string[],
27
+ ordered: readonly string[] | ReadonlyMap<string, number>,
28
28
  ): void
29
29
  }
30
30
 
@@ -69,6 +69,9 @@ export function selection<T = unknown>(options?: { initial?: readonly string[] }
69
69
  const next = new Set(prev)
70
70
  next.delete(id)
71
71
  ids.set(next)
72
+ // Clear the anchor if we just removed it — a subsequent shift-click
73
+ // would otherwise range from an id that's no longer in the selection.
74
+ if (anchor === id) anchor = null
72
75
  }
73
76
 
74
77
  const toggle = (id: string): void => {
@@ -100,24 +103,58 @@ export function selection<T = unknown>(options?: { initial?: readonly string[] }
100
103
  const handleClick = (
101
104
  id: string,
102
105
  mods: { shift?: boolean; meta?: boolean },
103
- ordered: readonly string[],
106
+ ordered: readonly string[] | ReadonlyMap<string, number>,
104
107
  ): void => {
105
108
  if (mods.shift && anchor !== null) {
106
- const anchorIdx = ordered.indexOf(anchor)
107
- const targetIdx = ordered.indexOf(id)
108
- if (anchorIdx === -1 || targetIdx === -1) {
109
- // anchor or target not visiblefall back to plain select
110
- ids.set(new Set([id]))
111
- anchor = id
112
- preShiftSelection = null
113
- return
109
+ // Accept either a positional array (back-compat, O(n) lookup) OR a
110
+ // precomputed `Map<id, index>` for O(1) shift-click on large lists
111
+ // (a 100k-row virtualized table doesn't want to scan the array twice
112
+ // per click). The caller decides which to pass `Map` is cheap to
113
+ // build once when the row list changes.
114
+ let anchorIdx: number
115
+ let targetIdx: number
116
+ let slice: readonly string[]
117
+ if (Array.isArray(ordered)) {
118
+ const arr = ordered as readonly string[]
119
+ anchorIdx = arr.indexOf(anchor)
120
+ targetIdx = arr.indexOf(id)
121
+ if (anchorIdx === -1 || targetIdx === -1) {
122
+ ids.set(new Set([id]))
123
+ anchor = id
124
+ preShiftSelection = null
125
+ return
126
+ }
127
+ const [lo, hi] = anchorIdx < targetIdx ? [anchorIdx, targetIdx] : [targetIdx, anchorIdx]
128
+ slice = arr.slice(lo, hi + 1)
129
+ } else {
130
+ const map = ordered as ReadonlyMap<string, number>
131
+ anchorIdx = map.get(anchor) ?? -1
132
+ targetIdx = map.get(id) ?? -1
133
+ if (anchorIdx === -1 || targetIdx === -1) {
134
+ ids.set(new Set([id]))
135
+ anchor = id
136
+ preShiftSelection = null
137
+ return
138
+ }
139
+ const [lo, hi] = anchorIdx < targetIdx ? [anchorIdx, targetIdx] : [targetIdx, anchorIdx]
140
+ // The Map gives O(1) index lookup. To materialise the range we
141
+ // still need keys at [lo, hi]; iterate the insertion-ordered Map
142
+ // once and bail when we've collected enough. The 0..hi prefix is
143
+ // O(hi) — bounded by the range length, not the full list.
144
+ const keys: string[] = []
145
+ let i = 0
146
+ for (const k of map.keys()) {
147
+ if (i >= lo && i <= hi) keys.push(k)
148
+ if (i >= hi) break
149
+ i += 1
150
+ }
151
+ slice = keys
114
152
  }
115
153
  if (preShiftSelection === null) {
116
154
  preShiftSelection = ids.peek()
117
155
  }
118
- const [lo, hi] = anchorIdx < targetIdx ? [anchorIdx, targetIdx] : [targetIdx, anchorIdx]
119
156
  const next = new Set(preShiftSelection)
120
- for (const k of ordered.slice(lo, hi + 1)) next.add(k)
157
+ for (const k of slice) next.add(k)
121
158
  ids.set(next)
122
159
  // Anchor stays — subsequent shift-clicks extend from the same origin.
123
160
  return
@@ -18,5 +18,8 @@ export function readOnly<T>(source: ReadSignal<T>): ReadSignal<T> {
18
18
  subscribe(handler: (value: T) => void) {
19
19
  return source.subscribe(handler)
20
20
  },
21
+ subscribeChanges(handler: (value: T) => void) {
22
+ return source.subscribeChanges(handler)
23
+ },
21
24
  })
22
25
  }
@@ -33,6 +33,10 @@ class SignalImpl<T> implements Signal<T> {
33
33
  return this.inner.subscribe(handler)
34
34
  }
35
35
 
36
+ subscribeChanges(handler: (value: T) => void): () => void {
37
+ return subscribeChangesImpl(this.inner, handler)
38
+ }
39
+
36
40
  set(value: T): void {
37
41
  this.inner.value = value
38
42
  }
@@ -60,6 +64,29 @@ class ComputedImpl<T> implements Computed<T> {
60
64
  subscribe(handler: (value: T) => void): () => void {
61
65
  return this.inner.subscribe(handler)
62
66
  }
67
+
68
+ subscribeChanges(handler: (value: T) => void): () => void {
69
+ return subscribeChangesImpl(this.inner, handler)
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Shared skip-initial wrapper around `signal.subscribe`. Preact fires the
75
+ * subscribe callback synchronously with the current value (the "initial
76
+ * fire"); the wrapper swallows it and forwards every subsequent change.
77
+ */
78
+ function subscribeChangesImpl<T>(
79
+ inner: PreactSignal<T> | PreactReadonlySignal<T>,
80
+ handler: (value: T) => void,
81
+ ): () => void {
82
+ let initial = true
83
+ return inner.subscribe((value) => {
84
+ if (initial) {
85
+ initial = false
86
+ return
87
+ }
88
+ handler(value)
89
+ })
63
90
  }
64
91
 
65
92
  /**
@@ -14,6 +14,16 @@ export type ReadSignal<T> = {
14
14
  * unsubscribe function.
15
15
  */
16
16
  subscribe(handler: (value: T) => void): () => void
17
+ /**
18
+ * Same as `subscribe`, but does NOT fire the synchronous "current value"
19
+ * callback on subscribe — only fires on subsequent changes. Useful for
20
+ * "react to user changes" semantics where the initial fire would be a
21
+ * spurious notification (the most common consumer pattern — `useField`,
22
+ * `usePersisted`, and the React adapter all hand-rolled this).
23
+ *
24
+ * Returns the unsubscribe function.
25
+ */
26
+ subscribeChanges(handler: (value: T) => void): () => void
17
27
  }
18
28
 
19
29
  /**
package/src/testing.ts CHANGED
@@ -3,6 +3,11 @@ import type { ControllerDef, Field, Root, RootOptions } from './controller/types
3
3
  import type { AsyncState, AsyncStatus } from './query/types'
4
4
  import { computed, type ReadSignal, type Signal, signal } from './signals'
5
5
 
6
+ // Test-only registry teardown — lives on the `@kontsedal/olas-core/testing`
7
+ // sub-path, NOT the public entry (T3.9). Lets tests reusing a `mutationId`
8
+ // across cases avoid registry bleed.
9
+ export { _unregisterMutationById } from './query/plugin'
10
+
6
11
  /**
7
12
  * Construct an isolated root wrapping a single controller. The returned object
8
13
  * is the controller's api plus the standard Root lifecycle controls
@@ -76,6 +81,7 @@ export function fakeField<T>(
76
81
  },
77
82
  peek: () => value$.peek(),
78
83
  subscribe: (handler) => value$.subscribe(handler),
84
+ subscribeChanges: (handler) => value$.subscribeChanges(handler),
79
85
  errors: errors$,
80
86
  isValid: isValid$,
81
87
  isDirty: dirty$,
@@ -108,6 +114,7 @@ export function fakeAsyncState<T>(
108
114
  isStale: boolean
109
115
  lastUpdatedAt: number | undefined
110
116
  hasPendingMutations: boolean
117
+ isPaused: boolean
111
118
  refetch: () => Promise<T>
112
119
  reset: () => void
113
120
  firstValue: () => Promise<T>
@@ -124,6 +131,7 @@ export function fakeAsyncState<T>(
124
131
  const isStale$: ReadSignal<boolean> = signal(overrides?.isStale ?? false)
125
132
  const lastUpdatedAt$: ReadSignal<number | undefined> = signal(overrides?.lastUpdatedAt)
126
133
  const hasPendingMutations$: ReadSignal<boolean> = signal(overrides?.hasPendingMutations ?? false)
134
+ const isPaused$: ReadSignal<boolean> = signal(overrides?.isPaused ?? false)
127
135
 
128
136
  const refetch = overrides?.refetch ?? (async () => data$.peek() as T)
129
137
  const reset = overrides?.reset ?? (() => {})
@@ -139,6 +147,7 @@ export function fakeAsyncState<T>(
139
147
  isStale: isStale$,
140
148
  lastUpdatedAt: lastUpdatedAt$,
141
149
  hasPendingMutations: hasPendingMutations$,
150
+ isPaused: isPaused$,
142
151
  refetch,
143
152
  reset,
144
153
  firstValue,