@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.
- package/dist/index.cjs +0 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +109 -27
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +109 -27
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +0 -0
- package/dist/index.mjs.map +1 -1
- package/dist/{root-B6pNq75w.mjs → root-Byq-QYTp.mjs} +1465 -347
- package/dist/root-Byq-QYTp.mjs.map +1 -0
- package/dist/{root-CFgYhBd-.cjs → root-CPSL5kpR.cjs} +1470 -346
- package/dist/root-CPSL5kpR.cjs.map +1 -0
- package/dist/testing.cjs +5 -1
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +3 -2
- package/dist/testing.d.cts.map +1 -1
- package/dist/testing.d.mts +3 -2
- package/dist/testing.d.mts.map +1 -1
- package/dist/testing.mjs +5 -2
- package/dist/testing.mjs.map +1 -1
- package/dist/{types-Bmi04hDI.d.mts → types-Brp-4WaZ.d.mts} +233 -23
- package/dist/types-Brp-4WaZ.d.mts.map +1 -0
- package/dist/{types-BsZMPPOE.d.cts → types-DzDIypPo.d.cts} +233 -23
- package/dist/types-DzDIypPo.d.cts.map +1 -0
- package/package.json +4 -1
- package/src/controller/instance.ts +345 -94
- package/src/controller/root.ts +52 -30
- package/src/controller/types.ts +55 -4
- package/src/emitter.ts +8 -2
- package/src/errors.ts +39 -3
- package/src/forms/field.ts +219 -25
- package/src/forms/form-types.ts +16 -3
- package/src/forms/form.ts +360 -38
- package/src/forms/index.ts +3 -1
- package/src/forms/types.ts +27 -1
- package/src/forms/validators.ts +45 -11
- package/src/index.ts +13 -7
- package/src/query/client.ts +237 -58
- package/src/query/define.ts +0 -0
- package/src/query/entry.ts +259 -15
- package/src/query/focus-online.ts +53 -11
- package/src/query/infinite.ts +351 -47
- package/src/query/keys.ts +33 -2
- package/src/query/local.ts +3 -0
- package/src/query/mutation.ts +27 -6
- package/src/query/plugin.ts +43 -4
- package/src/query/types.ts +76 -6
- package/src/query/use.ts +53 -10
- package/src/selection.ts +49 -12
- package/src/signals/readonly.ts +3 -0
- package/src/signals/runtime.ts +27 -0
- package/src/signals/types.ts +10 -0
- package/src/testing.ts +9 -0
- package/src/timing/debounced.ts +106 -23
- package/src/timing/index.ts +1 -1
- package/src/timing/throttled.ts +85 -28
- package/src/utils.ts +15 -2
- package/dist/root-B6pNq75w.mjs.map +0 -1
- package/dist/root-CFgYhBd-.cjs.map +0 -1
- package/dist/types-Bmi04hDI.d.mts.map +0 -1
- package/dist/types-BsZMPPOE.d.cts.map +0 -1
package/src/query/mutation.ts
CHANGED
|
@@ -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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
package/src/query/plugin.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
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 {
|
package/src/query/types.ts
CHANGED
|
@@ -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
|
|
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`).
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
package/src/signals/readonly.ts
CHANGED
|
@@ -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
|
}
|
package/src/signals/runtime.ts
CHANGED
|
@@ -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
|
/**
|
package/src/signals/types.ts
CHANGED
|
@@ -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,
|