@kontsedal/olas-core 0.0.1-rc.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/LICENSE +21 -0
- package/README.md +64 -0
- package/dist/index.cjs +363 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +178 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +178 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +339 -0
- package/dist/index.mjs.map +1 -0
- package/dist/root-BImHnGj1.mjs +3270 -0
- package/dist/root-BImHnGj1.mjs.map +1 -0
- package/dist/root-Bazp5_Ik.cjs +3347 -0
- package/dist/root-Bazp5_Ik.cjs.map +1 -0
- package/dist/testing.cjs +81 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +56 -0
- package/dist/testing.d.cts.map +1 -0
- package/dist/testing.d.mts +56 -0
- package/dist/testing.d.mts.map +1 -0
- package/dist/testing.mjs +78 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types-CAMgqCMz.d.mts +816 -0
- package/dist/types-CAMgqCMz.d.mts.map +1 -0
- package/dist/types-emq_lZd7.d.cts +816 -0
- package/dist/types-emq_lZd7.d.cts.map +1 -0
- package/package.json +47 -0
- package/src/__dev__.d.ts +8 -0
- package/src/controller/define.ts +50 -0
- package/src/controller/index.ts +12 -0
- package/src/controller/instance.ts +499 -0
- package/src/controller/root.ts +160 -0
- package/src/controller/types.ts +195 -0
- package/src/devtools.ts +0 -0
- package/src/emitter.ts +79 -0
- package/src/errors.ts +49 -0
- package/src/forms/field.ts +303 -0
- package/src/forms/form-types.ts +130 -0
- package/src/forms/form.ts +640 -0
- package/src/forms/index.ts +2 -0
- package/src/forms/types.ts +1 -0
- package/src/forms/validators.ts +70 -0
- package/src/index.ts +89 -0
- package/src/query/client.ts +934 -0
- package/src/query/define.ts +154 -0
- package/src/query/entry.ts +322 -0
- package/src/query/focus-online.ts +73 -0
- package/src/query/index.ts +3 -0
- package/src/query/infinite.ts +462 -0
- package/src/query/keys.ts +33 -0
- package/src/query/local.ts +113 -0
- package/src/query/mutation.ts +384 -0
- package/src/query/plugin.ts +135 -0
- package/src/query/types.ts +168 -0
- package/src/query/use.ts +321 -0
- package/src/scope.ts +42 -0
- package/src/selection.ts +146 -0
- package/src/signals/index.ts +3 -0
- package/src/signals/readonly.ts +22 -0
- package/src/signals/runtime.ts +115 -0
- package/src/signals/types.ts +31 -0
- package/src/testing.ts +142 -0
- package/src/timing/debounced.ts +32 -0
- package/src/timing/index.ts +2 -0
- package/src/timing/throttled.ts +46 -0
- package/src/utils.ts +13 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import type { DevtoolsEmitter } from '../devtools'
|
|
2
|
+
import { dispatchError, type ErrorHandler } from '../errors'
|
|
3
|
+
import { batch, type Signal, signal } from '../signals'
|
|
4
|
+
import type { ReadSignal } from '../signals/types'
|
|
5
|
+
import { isAbortError } from '../utils'
|
|
6
|
+
import type { RetryDelay, RetryPolicy, Snapshot } from './types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How concurrent calls to `mutation.run(...)` interact:
|
|
10
|
+
* - `parallel` (default): every call runs concurrently.
|
|
11
|
+
* - `latest-wins`: a new call aborts any in-flight previous call (`AbortSignal` fires).
|
|
12
|
+
* - `serial`: calls queue and run one at a time in order.
|
|
13
|
+
*
|
|
14
|
+
* Spec §6.3.
|
|
15
|
+
*/
|
|
16
|
+
export type MutationConcurrency = 'parallel' | 'latest-wins' | 'serial'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The configuration object passed to `ctx.mutation(spec)`. See spec §20.5 for
|
|
20
|
+
* the full lifecycle semantics. `onMutate` may return a `Snapshot` (from
|
|
21
|
+
* `query.setData(...)`) to enable automatic rollback on error.
|
|
22
|
+
*/
|
|
23
|
+
export type MutationSpec<V, R> = {
|
|
24
|
+
/**
|
|
25
|
+
* A short human-readable name. Surfaces in the devtools mutation log so the
|
|
26
|
+
* user sees `moveCard` instead of just the controller path. Strongly
|
|
27
|
+
* recommended in app code; cosmetic only — no runtime semantics depend on it.
|
|
28
|
+
*/
|
|
29
|
+
name?: string
|
|
30
|
+
/** The actual write. Receives the user-supplied vars and an `AbortSignal`. */
|
|
31
|
+
mutate: (vars: V, signal: AbortSignal) => Promise<R>
|
|
32
|
+
/**
|
|
33
|
+
* Runs before `mutate`. Return a `Snapshot` from `query.setData(...)` to
|
|
34
|
+
* apply an optimistic update; the snapshot is rolled back on error.
|
|
35
|
+
*/
|
|
36
|
+
onMutate?: (vars: V) => Snapshot | void
|
|
37
|
+
onSuccess?: (result: R, vars: V) => void
|
|
38
|
+
onError?: (err: unknown, vars: V, snapshot: Snapshot | undefined) => void
|
|
39
|
+
onSettled?: (result: R | undefined, err: unknown | undefined, vars: V) => void
|
|
40
|
+
concurrency?: MutationConcurrency
|
|
41
|
+
retry?: RetryPolicy
|
|
42
|
+
retryDelay?: RetryDelay
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A running mutation. Created via `ctx.mutation(spec)` — the controller owns
|
|
47
|
+
* its lifetime. Each `run(vars)` returns a Promise; the four signals reflect
|
|
48
|
+
* the last-resolved run for UI binding.
|
|
49
|
+
*
|
|
50
|
+
* Spec §6, §20.5.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* Call signature for `mutation.run`:
|
|
54
|
+
* - When `V` is `void` → no args. (`mutation.run()`)
|
|
55
|
+
* - When `V` was not constrained (default-inferred as `unknown`) → optional
|
|
56
|
+
* arg. Lets `ctx.mutation({ mutate: async () => 1 })` call `run()` *or*
|
|
57
|
+
* `run(anything)` without a type error.
|
|
58
|
+
* - Otherwise → arg required. (`mutation.run(vars)`)
|
|
59
|
+
*
|
|
60
|
+
* Defined as a variadic-tuple conditional so consumers see the right shape
|
|
61
|
+
* without writing `run(undefined as unknown as void)`.
|
|
62
|
+
*/
|
|
63
|
+
export type MutationRun<V, R> = (
|
|
64
|
+
...args: unknown extends V ? [V?] : [V] extends [void] ? [] : [V]
|
|
65
|
+
) => Promise<R>
|
|
66
|
+
|
|
67
|
+
export type Mutation<V, R> = {
|
|
68
|
+
/** Trigger a run. Returns a Promise that resolves with the mutate result. */
|
|
69
|
+
run: MutationRun<V, R>
|
|
70
|
+
data: ReadSignal<R | undefined>
|
|
71
|
+
error: ReadSignal<unknown | undefined>
|
|
72
|
+
isPending: ReadSignal<boolean>
|
|
73
|
+
lastVariables: ReadSignal<V | undefined>
|
|
74
|
+
/** Clear `data` / `error` / `lastVariables` without aborting in-flight runs. */
|
|
75
|
+
reset(): void
|
|
76
|
+
/** Abort in-flight runs and tear down. Idempotent. Called by the parent controller's dispose. */
|
|
77
|
+
dispose(): void
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type RunHandle = {
|
|
81
|
+
abort: AbortController
|
|
82
|
+
snapshot: Snapshot | undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type SerialEntry<V, R> = {
|
|
86
|
+
vars: V
|
|
87
|
+
resolve: (value: R) => void
|
|
88
|
+
reject: (err: unknown) => void
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
class MutationImpl<V, R> implements Mutation<V, R> {
|
|
92
|
+
readonly data: Signal<R | undefined> = signal(undefined)
|
|
93
|
+
readonly error: Signal<unknown | undefined> = signal(undefined)
|
|
94
|
+
readonly isPending: Signal<boolean> = signal(false)
|
|
95
|
+
readonly lastVariables: Signal<V | undefined> = signal(undefined)
|
|
96
|
+
|
|
97
|
+
private inflight = new Set<RunHandle>()
|
|
98
|
+
private serialQueue: Array<SerialEntry<V, R>> = []
|
|
99
|
+
private serialActive = false
|
|
100
|
+
private disposed = false
|
|
101
|
+
|
|
102
|
+
constructor(
|
|
103
|
+
private readonly spec: MutationSpec<V, R>,
|
|
104
|
+
private readonly onError: ErrorHandler | undefined,
|
|
105
|
+
private readonly controllerPath: readonly string[],
|
|
106
|
+
private readonly inflightCounter?: {
|
|
107
|
+
update(fn: (n: number) => number): void
|
|
108
|
+
},
|
|
109
|
+
private readonly devtools?: DevtoolsEmitter,
|
|
110
|
+
) {}
|
|
111
|
+
|
|
112
|
+
private emit(event: { type: 'mutation:run'; vars: unknown }): void
|
|
113
|
+
private emit(event: { type: 'mutation:success'; result: unknown }): void
|
|
114
|
+
private emit(event: { type: 'mutation:error'; error: unknown }): void
|
|
115
|
+
private emit(event: { type: 'mutation:rollback' }): void
|
|
116
|
+
private emit(
|
|
117
|
+
event:
|
|
118
|
+
| { type: 'mutation:run'; vars: unknown }
|
|
119
|
+
| { type: 'mutation:success'; result: unknown }
|
|
120
|
+
| { type: 'mutation:error'; error: unknown }
|
|
121
|
+
| { type: 'mutation:rollback' },
|
|
122
|
+
): void {
|
|
123
|
+
if (!__DEV__) return
|
|
124
|
+
if (this.devtools === undefined) return
|
|
125
|
+
const out: Record<string, unknown> = { ...event, path: this.controllerPath }
|
|
126
|
+
if (this.spec.name !== undefined) out.name = this.spec.name
|
|
127
|
+
this.devtools.emit(out as Parameters<DevtoolsEmitter['emit']>[0])
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Implementation-side signature accepts an optional `vars` (defaults to
|
|
131
|
+
// `undefined`) so call sites for `Mutation<void, R>` can call `.run()` with
|
|
132
|
+
// no args. The public type forces the right shape per `V`.
|
|
133
|
+
run = ((vars: V = undefined as V): Promise<R> => {
|
|
134
|
+
if (this.disposed) {
|
|
135
|
+
return Promise.reject(new Error('Mutation disposed'))
|
|
136
|
+
}
|
|
137
|
+
const mode = this.spec.concurrency ?? 'parallel'
|
|
138
|
+
switch (mode) {
|
|
139
|
+
case 'parallel':
|
|
140
|
+
return this.executeRun(vars)
|
|
141
|
+
case 'latest-wins':
|
|
142
|
+
// Spec §6.1: rollback the superseded run's snapshot BEFORE the new
|
|
143
|
+
// run's onMutate runs, so the new optimistic update doesn't stack on
|
|
144
|
+
// top of the obsolete one.
|
|
145
|
+
for (const handle of this.inflight) {
|
|
146
|
+
handle.abort.abort()
|
|
147
|
+
handle.snapshot?.rollback()
|
|
148
|
+
handle.snapshot = undefined
|
|
149
|
+
}
|
|
150
|
+
return this.executeRun(vars)
|
|
151
|
+
case 'serial':
|
|
152
|
+
return this.enqueueSerial(vars)
|
|
153
|
+
}
|
|
154
|
+
}) as MutationRun<V, R>
|
|
155
|
+
|
|
156
|
+
private enqueueSerial(vars: V): Promise<R> {
|
|
157
|
+
if (this.serialActive) {
|
|
158
|
+
return new Promise<R>((resolve, reject) => {
|
|
159
|
+
this.serialQueue.push({ vars, resolve, reject })
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
this.serialActive = true
|
|
163
|
+
return this.executeRun(vars).finally(() => this.advanceSerialQueue())
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private advanceSerialQueue(): void {
|
|
167
|
+
const next = this.serialQueue.shift()
|
|
168
|
+
if (!next) {
|
|
169
|
+
this.serialActive = false
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
this.executeRun(next.vars).then(
|
|
173
|
+
(result) => {
|
|
174
|
+
next.resolve(result)
|
|
175
|
+
this.advanceSerialQueue()
|
|
176
|
+
},
|
|
177
|
+
(err) => {
|
|
178
|
+
next.reject(err)
|
|
179
|
+
this.advanceSerialQueue()
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private async executeRun(vars: V): Promise<R> {
|
|
185
|
+
const abort = new AbortController()
|
|
186
|
+
let snapshot: Snapshot | undefined
|
|
187
|
+
try {
|
|
188
|
+
const raw = this.spec.onMutate?.(vars) ?? undefined
|
|
189
|
+
snapshot = raw === undefined ? undefined : this.wrapSnapshot(raw)
|
|
190
|
+
} catch (err) {
|
|
191
|
+
dispatchError(this.onError, err, {
|
|
192
|
+
kind: 'mutation',
|
|
193
|
+
controllerPath: this.controllerPath,
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const handle: RunHandle = { abort, snapshot }
|
|
198
|
+
this.inflight.add(handle)
|
|
199
|
+
this.inflightCounter?.update((n) => n + 1)
|
|
200
|
+
batch(() => {
|
|
201
|
+
this.isPending.set(true)
|
|
202
|
+
this.lastVariables.set(vars)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
if (__DEV__) this.emit({ type: 'mutation:run', vars })
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const result = await raceAbort(this.runWithRetry(vars, abort.signal), abort.signal)
|
|
209
|
+
if (abort.signal.aborted || this.disposed) {
|
|
210
|
+
snapshot?.rollback()
|
|
211
|
+
throw new DOMException('Superseded', 'AbortError')
|
|
212
|
+
}
|
|
213
|
+
batch(() => {
|
|
214
|
+
this.data.set(result)
|
|
215
|
+
this.error.set(undefined)
|
|
216
|
+
})
|
|
217
|
+
if (__DEV__) this.emit({ type: 'mutation:success', result })
|
|
218
|
+
this.safeCall(() => this.spec.onSuccess?.(result, vars), 'mutation')
|
|
219
|
+
// Commit the optimistic snapshot so `hasPendingMutations` clears on the
|
|
220
|
+
// affected entry. Symmetric to the auto-rollback in the error path.
|
|
221
|
+
// Spec §6.4.
|
|
222
|
+
snapshot?.finalize()
|
|
223
|
+
this.safeCall(() => this.spec.onSettled?.(result, undefined, vars), 'mutation')
|
|
224
|
+
return result
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (isAbortError(err) || abort.signal.aborted) {
|
|
227
|
+
snapshot?.rollback()
|
|
228
|
+
// Reserve `error` signal for genuine failures.
|
|
229
|
+
throw err
|
|
230
|
+
}
|
|
231
|
+
this.error.set(err)
|
|
232
|
+
if (__DEV__) this.emit({ type: 'mutation:error', error: err })
|
|
233
|
+
this.safeCall(() => this.spec.onError?.(err, vars, snapshot), 'mutation')
|
|
234
|
+
// Auto-rollback after the user's onError. The wrapped snapshot is
|
|
235
|
+
// single-consume, so an `onError` that already called `snapshot.rollback()`
|
|
236
|
+
// turns the auto-call into a no-op. Spec §6.4.
|
|
237
|
+
snapshot?.rollback()
|
|
238
|
+
this.safeCall(() => this.spec.onSettled?.(undefined, err, vars), 'mutation')
|
|
239
|
+
throw err
|
|
240
|
+
} finally {
|
|
241
|
+
this.inflight.delete(handle)
|
|
242
|
+
this.inflightCounter?.update((n) => Math.max(0, n - 1))
|
|
243
|
+
if (this.inflight.size === 0) {
|
|
244
|
+
this.isPending.set(false)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Wrap so any rollback / finalize path runs the raw operation at most
|
|
250
|
+
// once. The mutation auto-finalizes on success and auto-rolls-back on
|
|
251
|
+
// error; user code may also call rollback() from onError. Whichever
|
|
252
|
+
// happens first wins; subsequent calls (including the auto-call) no-op.
|
|
253
|
+
private wrapSnapshot(raw: Snapshot): Snapshot {
|
|
254
|
+
let consumed = false
|
|
255
|
+
return {
|
|
256
|
+
rollback: () => {
|
|
257
|
+
if (consumed) return
|
|
258
|
+
consumed = true
|
|
259
|
+
raw.rollback()
|
|
260
|
+
if (__DEV__) this.emit({ type: 'mutation:rollback' })
|
|
261
|
+
},
|
|
262
|
+
finalize: () => {
|
|
263
|
+
if (consumed) return
|
|
264
|
+
consumed = true
|
|
265
|
+
raw.finalize()
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private async runWithRetry(vars: V, signal: AbortSignal): Promise<R> {
|
|
271
|
+
const retry = this.spec.retry ?? 0
|
|
272
|
+
const retryDelay = this.spec.retryDelay ?? 1000
|
|
273
|
+
let attempt = 0
|
|
274
|
+
while (true) {
|
|
275
|
+
try {
|
|
276
|
+
return await this.spec.mutate(vars, signal)
|
|
277
|
+
} catch (err) {
|
|
278
|
+
if (signal.aborted || isAbortError(err)) throw err
|
|
279
|
+
const shouldRetry = typeof retry === 'number' ? attempt < retry : retry(attempt, err)
|
|
280
|
+
if (!shouldRetry) throw err
|
|
281
|
+
const delay = typeof retryDelay === 'function' ? retryDelay(attempt) : retryDelay
|
|
282
|
+
await abortableSleep(delay, signal)
|
|
283
|
+
attempt += 1
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private safeCall(fn: () => void, kind: 'mutation'): void {
|
|
289
|
+
try {
|
|
290
|
+
fn()
|
|
291
|
+
} catch (err) {
|
|
292
|
+
dispatchError(this.onError, err, {
|
|
293
|
+
kind,
|
|
294
|
+
controllerPath: this.controllerPath,
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
reset(): void {
|
|
300
|
+
if (this.disposed) return
|
|
301
|
+
for (const handle of this.inflight) handle.abort.abort()
|
|
302
|
+
this.serialQueue.length = 0
|
|
303
|
+
batch(() => {
|
|
304
|
+
this.data.set(undefined)
|
|
305
|
+
this.error.set(undefined)
|
|
306
|
+
this.lastVariables.set(undefined)
|
|
307
|
+
this.isPending.set(false)
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
dispose(): void {
|
|
312
|
+
if (this.disposed) return
|
|
313
|
+
this.disposed = true
|
|
314
|
+
for (const handle of this.inflight) handle.abort.abort()
|
|
315
|
+
for (const queued of this.serialQueue) {
|
|
316
|
+
queued.reject(new DOMException('Disposed', 'AbortError'))
|
|
317
|
+
}
|
|
318
|
+
this.serialQueue.length = 0
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function createMutation<V, R>(
|
|
323
|
+
spec: MutationSpec<V, R>,
|
|
324
|
+
onError: ErrorHandler | undefined,
|
|
325
|
+
controllerPath: readonly string[],
|
|
326
|
+
inflightCounter?: { update(fn: (n: number) => number): void },
|
|
327
|
+
devtools?: DevtoolsEmitter,
|
|
328
|
+
): Mutation<V, R> {
|
|
329
|
+
return new MutationImpl<V, R>(spec, onError, controllerPath, inflightCounter, devtools)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Race a promise against an AbortSignal. If the signal fires before the
|
|
334
|
+
* promise settles, the returned promise rejects with AbortError — regardless
|
|
335
|
+
* of whether the underlying promise ever resolves. Protects against
|
|
336
|
+
* misbehaving mutate fns that ignore their signal.
|
|
337
|
+
*/
|
|
338
|
+
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
339
|
+
if (signal.aborted) {
|
|
340
|
+
return Promise.reject(new DOMException('Aborted', 'AbortError'))
|
|
341
|
+
}
|
|
342
|
+
return new Promise<T>((resolve, reject) => {
|
|
343
|
+
let settled = false
|
|
344
|
+
const onAbort = () => {
|
|
345
|
+
if (settled) return
|
|
346
|
+
settled = true
|
|
347
|
+
reject(new DOMException('Aborted', 'AbortError'))
|
|
348
|
+
}
|
|
349
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
350
|
+
promise.then(
|
|
351
|
+
(v) => {
|
|
352
|
+
if (settled) return
|
|
353
|
+
settled = true
|
|
354
|
+
signal.removeEventListener('abort', onAbort)
|
|
355
|
+
resolve(v)
|
|
356
|
+
},
|
|
357
|
+
(e) => {
|
|
358
|
+
if (settled) return
|
|
359
|
+
settled = true
|
|
360
|
+
signal.removeEventListener('abort', onAbort)
|
|
361
|
+
reject(e)
|
|
362
|
+
},
|
|
363
|
+
)
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
368
|
+
return new Promise((resolve, reject) => {
|
|
369
|
+
if (signal.aborted) {
|
|
370
|
+
reject(new DOMException('Aborted', 'AbortError'))
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
const timer = setTimeout(() => {
|
|
374
|
+
signal.removeEventListener('abort', onAbort)
|
|
375
|
+
resolve()
|
|
376
|
+
}, ms)
|
|
377
|
+
const onAbort = () => {
|
|
378
|
+
clearTimeout(timer)
|
|
379
|
+
signal.removeEventListener('abort', onAbort)
|
|
380
|
+
reject(new DOMException('Aborted', 'AbortError'))
|
|
381
|
+
}
|
|
382
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
383
|
+
})
|
|
384
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `QueryClientPlugin` — a slot for layered cache concerns (cross-tab sync,
|
|
3
|
+
* server-push patches, persistence-like layers) that need to observe
|
|
4
|
+
* `setData` / `invalidate` / `gc` and apply remote writes back into the
|
|
5
|
+
* cache without re-triggering their own outbound side-effects.
|
|
6
|
+
*
|
|
7
|
+
* Plugins are installed via `RootOptions.plugins[]`; lifecycle is bound to
|
|
8
|
+
* the root's `QueryClient` (`init` is called once after construction;
|
|
9
|
+
* `dispose` is called from `QueryClient.dispose`).
|
|
10
|
+
*
|
|
11
|
+
* SPEC §13.2.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Surface the plugin gets at `init` time. Used to push remote-originated
|
|
16
|
+
* cache writes through the normal `setData`/`invalidate` paths without
|
|
17
|
+
* triggering the plugin's own outbound hooks for those writes (the inbound
|
|
18
|
+
* writes are marked `isRemote: true` and rebroadcast must be skipped).
|
|
19
|
+
*
|
|
20
|
+
* `subscribedKeys(queryId)` walks the per-root entry registry for the
|
|
21
|
+
* matching query and returns every bound entry's `keyArgs`. Cross-tab
|
|
22
|
+
* plugins use this to scope outbound traffic (e.g. only echo invalidations
|
|
23
|
+
* for queries the local tab actually has entries for).
|
|
24
|
+
*/
|
|
25
|
+
export type QueryClientPluginApi = {
|
|
26
|
+
/**
|
|
27
|
+
* Apply a remote snapshot. The plugin's own `onSetData` IS fired for the
|
|
28
|
+
* resulting cache write, but the event carries `isRemote: true` — plugins
|
|
29
|
+
* MUST skip rebroadcast in that case.
|
|
30
|
+
*/
|
|
31
|
+
applyRemoteSetData(queryId: string, keyArgs: readonly unknown[], data: unknown): void
|
|
32
|
+
applyRemoteInvalidate(queryId: string, keyArgs: readonly unknown[]): void
|
|
33
|
+
/**
|
|
34
|
+
* Snapshot of currently bound entry keys for a query (by `queryId`). Empty
|
|
35
|
+
* array when the query isn't registered, has no client entries, or the
|
|
36
|
+
* `queryId` doesn't match any registered query.
|
|
37
|
+
*/
|
|
38
|
+
subscribedKeys(queryId: string): readonly (readonly unknown[])[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SetDataEvent = {
|
|
42
|
+
queryId: string
|
|
43
|
+
keyArgs: readonly unknown[]
|
|
44
|
+
data: unknown
|
|
45
|
+
/**
|
|
46
|
+
* `'data'` for regular queries, `'infinite'` for paginated queries.
|
|
47
|
+
* Cross-tab plugins skip `'infinite'` in v1 — page-array payloads are
|
|
48
|
+
* too heavy to be a safe default.
|
|
49
|
+
*/
|
|
50
|
+
kind: 'data' | 'infinite'
|
|
51
|
+
/**
|
|
52
|
+
* True iff this write originated from `applyRemoteSetData`. Plugins MUST
|
|
53
|
+
* skip rebroadcast in that case — otherwise the message would echo back.
|
|
54
|
+
*/
|
|
55
|
+
isRemote: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type InvalidateEvent = {
|
|
59
|
+
queryId: string
|
|
60
|
+
keyArgs: readonly unknown[]
|
|
61
|
+
kind: 'data' | 'infinite'
|
|
62
|
+
isRemote: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type GcEvent = {
|
|
66
|
+
queryId: string
|
|
67
|
+
keyArgs: readonly unknown[]
|
|
68
|
+
kind: 'data' | 'infinite'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Plugin contract. Every hook is optional. Hooks are wrapped in try/catch
|
|
73
|
+
* by `QueryClient`; thrown exceptions are routed through the root's
|
|
74
|
+
* `onError` handler with `kind: 'plugin'`.
|
|
75
|
+
*/
|
|
76
|
+
export type QueryClientPlugin = {
|
|
77
|
+
/**
|
|
78
|
+
* Called once after the `QueryClient` is constructed. Use it to wire up
|
|
79
|
+
* transport listeners and capture the `QueryClientPluginApi`.
|
|
80
|
+
*/
|
|
81
|
+
init?(api: QueryClientPluginApi): void
|
|
82
|
+
onSetData?(event: SetDataEvent): void
|
|
83
|
+
onInvalidate?(event: InvalidateEvent): void
|
|
84
|
+
onGc?(event: GcEvent): void
|
|
85
|
+
/** Called from `QueryClient.dispose`. Tear down transports / listeners here. */
|
|
86
|
+
dispose?(): void
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Internal helper — fetch the `queryId` from a query's spec without
|
|
91
|
+
* peeking into private types. Returns `undefined` for queries that didn't
|
|
92
|
+
* declare a `queryId`; plugin events are then skipped (a plugin can't route
|
|
93
|
+
* by name without one).
|
|
94
|
+
*/
|
|
95
|
+
export function readQueryId(query: { readonly __spec: { queryId?: string } }): string | undefined {
|
|
96
|
+
return query.__spec.queryId
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Shape of values stored in the `queryId → Query` registry. Either a
|
|
101
|
+
* regular `Query` or an `InfiniteQuery`, both branded by `__olas`.
|
|
102
|
+
*/
|
|
103
|
+
export type RegisteredQuery = {
|
|
104
|
+
readonly __olas: 'query' | 'infiniteQuery'
|
|
105
|
+
readonly __spec: { queryId?: string; crossTab?: boolean }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const queryRegistry = new Map<string, RegisteredQuery>()
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Register a query by its `queryId`. Internal — called from `defineQuery` /
|
|
112
|
+
* `defineInfiniteQuery`. Replaces any previous registration with the same
|
|
113
|
+
* id (matches Olas's "full root rebuild" HMR story; a mid-flight remote
|
|
114
|
+
* message routed against the old `Query` simply misses).
|
|
115
|
+
*/
|
|
116
|
+
export function registerQueryById(queryId: string, query: RegisteredQuery): void {
|
|
117
|
+
queryRegistry.set(queryId, query)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Look up a query by its declared `queryId`. Returns `undefined` when no
|
|
122
|
+
* query with that id has been defined yet (e.g. the module isn't imported
|
|
123
|
+
* in the receiving tab).
|
|
124
|
+
*/
|
|
125
|
+
export function lookupRegisteredQuery(queryId: string): RegisteredQuery | undefined {
|
|
126
|
+
return queryRegistry.get(queryId)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Test-only — drop a registered entry. Lets tests defining the same
|
|
131
|
+
* `queryId` across cases avoid bleed. Not exported from `@kontsedal/olas-core`.
|
|
132
|
+
*/
|
|
133
|
+
export function _unregisterQueryById(queryId: string): void {
|
|
134
|
+
queryRegistry.delete(queryId)
|
|
135
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { ReadSignal } from '../signals/types'
|
|
2
|
+
|
|
3
|
+
/** Lifecycle phase of an async resource. */
|
|
4
|
+
export type AsyncStatus = 'idle' | 'pending' | 'success' | 'error'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The eight reactive signals + three actions a subscriber sees for any async
|
|
8
|
+
* resource (`LocalCache<T>` or a `Query` subscription). Spec §20.4.
|
|
9
|
+
*
|
|
10
|
+
* - `data` / `error` / `status` — current outcome.
|
|
11
|
+
* - `isLoading` — true only on the first pending fetch (no `data` yet).
|
|
12
|
+
* - `isFetching` — true on any pending fetch.
|
|
13
|
+
* - `isStale` — true when `staleTime` has elapsed since `lastUpdatedAt`.
|
|
14
|
+
* - `lastUpdatedAt` — epoch ms of last success.
|
|
15
|
+
* - `hasPendingMutations` — at least one mutation has a snapshot on this entry.
|
|
16
|
+
*
|
|
17
|
+
* Actions:
|
|
18
|
+
* - `refetch()` — force a fetch; resolves with the result.
|
|
19
|
+
* - `reset()` — clear `error` + `status` without re-fetching.
|
|
20
|
+
* - `firstValue()` — resolves on the first success after subscribe.
|
|
21
|
+
*/
|
|
22
|
+
export type AsyncState<T> = {
|
|
23
|
+
data: ReadSignal<T | undefined>
|
|
24
|
+
error: ReadSignal<unknown | undefined>
|
|
25
|
+
status: ReadSignal<AsyncStatus>
|
|
26
|
+
isLoading: ReadSignal<boolean>
|
|
27
|
+
isFetching: ReadSignal<boolean>
|
|
28
|
+
isStale: ReadSignal<boolean>
|
|
29
|
+
lastUpdatedAt: ReadSignal<number | undefined>
|
|
30
|
+
hasPendingMutations: ReadSignal<boolean>
|
|
31
|
+
|
|
32
|
+
refetch: () => Promise<T>
|
|
33
|
+
reset: () => void
|
|
34
|
+
firstValue: () => Promise<T>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returned by `query.setData(...)` or `localCache.setData(...)`. Used by
|
|
39
|
+
* `mutation.onMutate` for optimistic-update rollback (spec §6.4).
|
|
40
|
+
*
|
|
41
|
+
* - `rollback()` restores the previous data state (and clears the
|
|
42
|
+
* "pending mutation" flag on the entry if no other snapshots are live).
|
|
43
|
+
* - `finalize()` commits the snapshot as the new truth — no rollback,
|
|
44
|
+
* `hasPendingMutations` clears once all live snapshots on the entry
|
|
45
|
+
* are finalized or rolled back. The mutation runner calls this on
|
|
46
|
+
* success; user code rarely needs to.
|
|
47
|
+
*
|
|
48
|
+
* Both are idempotent and mutually exclusive (calling one disables the
|
|
49
|
+
* other). Safe to call after the owning entry has been disposed.
|
|
50
|
+
*/
|
|
51
|
+
export type Snapshot = {
|
|
52
|
+
rollback: () => void
|
|
53
|
+
finalize: () => void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A cache owned by one controller — no sharing across the tree. Returned by
|
|
58
|
+
* `ctx.cache(fetcher, options?)`. Disposed automatically with the controller.
|
|
59
|
+
*/
|
|
60
|
+
export type LocalCache<T> = AsyncState<T> & {
|
|
61
|
+
/** Mark stale and trigger an immediate refetch. */
|
|
62
|
+
invalidate(): void
|
|
63
|
+
/** Patch the current data. Returns a `Snapshot` for rollback. */
|
|
64
|
+
setData(updater: (prev: T | undefined) => T): Snapshot
|
|
65
|
+
/** Idempotent — also called when the owning controller disposes. */
|
|
66
|
+
dispose(): void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One entry inside a `DehydratedState`. */
|
|
70
|
+
export type DehydratedEntry = {
|
|
71
|
+
key: readonly unknown[]
|
|
72
|
+
data: unknown
|
|
73
|
+
lastUpdatedAt: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* SSR-serializable snapshot of a root's `QueryClient`. Produced by
|
|
78
|
+
* `root.dehydrate()` on the server; consumed by
|
|
79
|
+
* `createRoot(def, { hydrate: state })` on the client. Spec §15, §20.9.
|
|
80
|
+
*/
|
|
81
|
+
export type DehydratedState = {
|
|
82
|
+
version: 1
|
|
83
|
+
entries: DehydratedEntry[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Retry policy for queries and mutations. A number is a max-attempt count
|
|
88
|
+
* (default backoff). A function decides per-attempt (return `true` to retry).
|
|
89
|
+
*/
|
|
90
|
+
export type RetryPolicy = number | ((attempt: number, error: unknown) => boolean)
|
|
91
|
+
|
|
92
|
+
/** Backoff in ms. A number is constant delay; a function computes per-attempt. */
|
|
93
|
+
export type RetryDelay = number | ((attempt: number) => number)
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Per-fetch context: the `AbortSignal` to honor + the root's `deps`. Passed
|
|
97
|
+
* as the first argument to every `QuerySpec.fetcher` invocation so module-
|
|
98
|
+
* level queries can reach their dependencies without resorting to globals.
|
|
99
|
+
*/
|
|
100
|
+
export type FetchCtx = {
|
|
101
|
+
signal: AbortSignal
|
|
102
|
+
deps: import('../controller/types').AmbientDeps
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Configuration passed to `defineQuery({ ... })`. The `Args` tuple is what
|
|
107
|
+
* callers pass as cache keys and to the fetcher. Spec §20.4.
|
|
108
|
+
*
|
|
109
|
+
* The fetcher's first argument is a `FetchCtx` (signal + deps); positional
|
|
110
|
+
* cache args come after. This shape lets module-scoped queries read
|
|
111
|
+
* `ctx.deps.api` etc. — no `setApiForQuery(api)` module-level capture needed.
|
|
112
|
+
*/
|
|
113
|
+
export type QuerySpec<Args extends unknown[], T> = {
|
|
114
|
+
key: (...args: Args) => unknown[]
|
|
115
|
+
fetcher: (ctx: FetchCtx, ...args: Args) => Promise<T>
|
|
116
|
+
staleTime?: number
|
|
117
|
+
gcTime?: number
|
|
118
|
+
refetchInterval?: number
|
|
119
|
+
refetchOnWindowFocus?: boolean
|
|
120
|
+
refetchOnReconnect?: boolean
|
|
121
|
+
keepPreviousData?: boolean
|
|
122
|
+
retry?: RetryPolicy
|
|
123
|
+
retryDelay?: RetryDelay
|
|
124
|
+
/**
|
|
125
|
+
* Stable identifier used by `QueryClientPlugin`s (e.g. `@kontsedal/olas-cross-tab`)
|
|
126
|
+
* to locate the same query across tabs / processes / persistence layers.
|
|
127
|
+
* REQUIRED for queries with `crossTab: true`. SPEC §13.2.
|
|
128
|
+
*
|
|
129
|
+
* Don't auto-derive from `fetcher.name` or argument hashing — both are
|
|
130
|
+
* fragile under minification.
|
|
131
|
+
*/
|
|
132
|
+
queryId?: string
|
|
133
|
+
/**
|
|
134
|
+
* Opt this query into cross-tab cache sync (`@kontsedal/olas-cross-tab`). No effect
|
|
135
|
+
* without a `queryId` and without a plugin installed. SPEC §13.2.
|
|
136
|
+
*/
|
|
137
|
+
crossTab?: boolean
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A module-scoped shared query handle. Bind a subscriber via
|
|
142
|
+
* `ctx.use(query, () => [...args])`. The same `Query` value can be used by
|
|
143
|
+
* many controllers across many roots — each root has its own cache.
|
|
144
|
+
*/
|
|
145
|
+
export type Query<Args extends unknown[], T> = {
|
|
146
|
+
readonly __olas: 'query'
|
|
147
|
+
/** Mark a specific keyed entry stale + trigger refetch if any subscribers. */
|
|
148
|
+
invalidate(...args: Args): void
|
|
149
|
+
/** Mark every keyed entry stale + trigger refetch on all subscribers. */
|
|
150
|
+
invalidateAll(): void
|
|
151
|
+
/** Patch the current data for a specific key. Returns a `Snapshot` for rollback. */
|
|
152
|
+
setData(...args: [...Args, updater: (prev: T | undefined) => T]): Snapshot
|
|
153
|
+
/** Eagerly fetch into the cache without subscribing. */
|
|
154
|
+
prefetch(...args: Args): Promise<T>
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** What `ctx.use(query, ...)` returns. Alias of `AsyncState<T>`. */
|
|
158
|
+
export type QuerySubscription<T> = AsyncState<T>
|
|
159
|
+
|
|
160
|
+
/**
|
|
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.
|
|
164
|
+
*/
|
|
165
|
+
export type UseOptions<Args extends readonly unknown[]> = {
|
|
166
|
+
key?: () => Args
|
|
167
|
+
enabled?: () => boolean
|
|
168
|
+
}
|