@kontsedal/olas-core 0.0.5 → 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-DqWolle_.mjs → root-Byq-QYTp.mjs} +1478 -347
- package/dist/root-Byq-QYTp.mjs.map +1 -0
- package/dist/{root-lBp7qziQ.cjs → root-CPSL5kpR.cjs} +1483 -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-BH1o6nYa.d.mts → types-Brp-4WaZ.d.mts} +244 -23
- package/dist/types-Brp-4WaZ.d.mts.map +1 -0
- package/dist/{types-C4Vtkxbn.d.cts → types-DzDIypPo.d.cts} +244 -23
- package/dist/types-DzDIypPo.d.cts.map +1 -0
- package/package.json +4 -1
- package/src/controller/instance.ts +360 -94
- package/src/controller/root.ts +58 -30
- package/src/controller/types.ts +66 -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-DqWolle_.mjs.map +0 -1
- package/dist/root-lBp7qziQ.cjs.map +0 -1
- package/dist/types-BH1o6nYa.d.mts.map +0 -1
- package/dist/types-C4Vtkxbn.d.cts.map +0 -1
package/src/forms/validators.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { Validator } from './types'
|
|
1
|
+
import type { StandardSchemaV1, StandardSchemaV1Issue } from './standard-schema'
|
|
2
|
+
import type { FormIssue, Validator } from './types'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Wrap any Standard-Schema-compatible schema (Zod 4, Valibot 1, ArkType 2,
|
|
6
|
-
* …) as an Olas validator.
|
|
7
|
-
*
|
|
6
|
+
* …) as an Olas validator. Returns **all** issues as `FormIssue[]`, each
|
|
7
|
+
* carrying the schema's own `path` — so a whole-form schema used as a
|
|
8
|
+
* form-level validator routes each issue onto the matching field (T5.2),
|
|
9
|
+
* and a leaf schema (whose issues have empty paths) still reports on the
|
|
10
|
+
* field it's attached to. An empty array means "valid".
|
|
8
11
|
*
|
|
9
12
|
* Standard Schema validators may be sync or async; this wrapper threads
|
|
10
|
-
* through whichever the schema returns — `Promise<
|
|
13
|
+
* through whichever the schema returns — `Promise<FormIssue[]>` only when
|
|
11
14
|
* the underlying validate call is itself async.
|
|
12
15
|
*
|
|
13
16
|
* `signal` is accepted to match the `Validator<T>` shape but isn't forwarded
|
|
@@ -18,15 +21,33 @@ export function validator<I, O>(schema: StandardSchemaV1<I, O>): Validator<I> {
|
|
|
18
21
|
void signal
|
|
19
22
|
const result = schema['~standard'].validate(value)
|
|
20
23
|
if (result instanceof Promise) {
|
|
21
|
-
return result.then(
|
|
24
|
+
return result.then(issuesFromResult)
|
|
22
25
|
}
|
|
23
|
-
return
|
|
26
|
+
return issuesFromResult(result)
|
|
24
27
|
}
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
function
|
|
28
|
-
if (result.issues === undefined || result.issues.length === 0) return
|
|
29
|
-
return result.issues
|
|
30
|
+
function issuesFromResult(result: { issues?: ReadonlyArray<StandardSchemaV1Issue> }): FormIssue[] {
|
|
31
|
+
if (result.issues === undefined || result.issues.length === 0) return []
|
|
32
|
+
return result.issues.map((issue) => ({
|
|
33
|
+
path: normalizeIssuePath(issue.path),
|
|
34
|
+
message: issue.message ?? 'Invalid',
|
|
35
|
+
}))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Standard Schema issue paths are `(PropertyKey | { key: PropertyKey })[]`.
|
|
40
|
+
* Flatten to the `(string | number)[]` shape `FormIssue` uses: numeric keys
|
|
41
|
+
* stay numeric (array indices), everything else stringifies.
|
|
42
|
+
*/
|
|
43
|
+
function normalizeIssuePath(path: StandardSchemaV1Issue['path']): (string | number)[] {
|
|
44
|
+
if (path === undefined) return []
|
|
45
|
+
const out: (string | number)[] = []
|
|
46
|
+
for (const seg of path) {
|
|
47
|
+
const key = typeof seg === 'object' && seg !== null ? seg.key : seg
|
|
48
|
+
out.push(typeof key === 'number' ? key : String(key))
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
30
51
|
}
|
|
31
52
|
|
|
32
53
|
export { isStandardSchema, type StandardSchemaV1 } from './standard-schema'
|
|
@@ -35,15 +56,28 @@ const isEmpty = (value: unknown): boolean => {
|
|
|
35
56
|
if (value === undefined || value === null) return true
|
|
36
57
|
if (typeof value === 'string') return value.length === 0
|
|
37
58
|
if (Array.isArray(value)) return value.length === 0
|
|
59
|
+
// A boolean `false` is a legitimate value (a toggle set to "off"), NOT empty
|
|
60
|
+
// — `required` accepts it. For a consent / confirm checkbox that MUST be
|
|
61
|
+
// ticked, use `mustBeTrue` instead (T5.3).
|
|
38
62
|
return false
|
|
39
63
|
}
|
|
40
64
|
|
|
41
|
-
/** Reject empty values (undefined, null, empty string, empty array). */
|
|
65
|
+
/** Reject empty values (undefined, null, empty string, empty array). Booleans always pass. */
|
|
42
66
|
export const required =
|
|
43
67
|
<T>(message = 'Required'): Validator<T> =>
|
|
44
68
|
(value) =>
|
|
45
69
|
isEmpty(value) ? message : null
|
|
46
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Reject any value that isn't boolean `true` — the "you must check this box"
|
|
73
|
+
* rule for consent / terms-of-service / confirm checkboxes. Unlike `required`
|
|
74
|
+
* (which now accepts `false` as a valid boolean), this fails on `false`.
|
|
75
|
+
*/
|
|
76
|
+
export const mustBeTrue =
|
|
77
|
+
(message = 'Required'): Validator<boolean> =>
|
|
78
|
+
(value) =>
|
|
79
|
+
value === true ? null : message
|
|
80
|
+
|
|
47
81
|
/** Reject strings / arrays shorter than `n`. Allows null/undefined (use with `required` to forbid). */
|
|
48
82
|
export const minLength =
|
|
49
83
|
(n: number, message?: string): Validator<string | readonly unknown[]> =>
|
package/src/index.ts
CHANGED
|
@@ -24,9 +24,16 @@ export type { DebugBus, DebugCacheEntry, DebugEvent } from './devtools'
|
|
|
24
24
|
// Emitter
|
|
25
25
|
export type { Emitter, EmitterErrorReporter } from './emitter'
|
|
26
26
|
export { createEmitter } from './emitter'
|
|
27
|
-
export type { ErrorContext } from './errors'
|
|
27
|
+
export type { ErrorContext, ErrorContextInput, ErrorHandler } from './errors'
|
|
28
28
|
// Forms — stdlib validators + Standard Schema adapter + debouncedValidator
|
|
29
|
-
export type {
|
|
29
|
+
export type {
|
|
30
|
+
FieldTransform,
|
|
31
|
+
FormIssue,
|
|
32
|
+
StandardSchemaV1,
|
|
33
|
+
ValidateOn,
|
|
34
|
+
Validator,
|
|
35
|
+
ValidatorResult,
|
|
36
|
+
} from './forms'
|
|
30
37
|
export {
|
|
31
38
|
email,
|
|
32
39
|
isStandardSchema,
|
|
@@ -34,6 +41,7 @@ export {
|
|
|
34
41
|
maxLength,
|
|
35
42
|
min,
|
|
36
43
|
minLength,
|
|
44
|
+
mustBeTrue,
|
|
37
45
|
pattern,
|
|
38
46
|
required,
|
|
39
47
|
validator,
|
|
@@ -84,11 +92,7 @@ export type {
|
|
|
84
92
|
RegisteredQuery,
|
|
85
93
|
SetDataEvent,
|
|
86
94
|
} from './query/plugin'
|
|
87
|
-
export {
|
|
88
|
-
_unregisterMutationById,
|
|
89
|
-
lookupRegisteredMutation,
|
|
90
|
-
lookupRegisteredQuery,
|
|
91
|
-
} from './query/plugin'
|
|
95
|
+
export { lookupRegisteredMutation, lookupRegisteredQuery } from './query/plugin'
|
|
92
96
|
// Query primitives
|
|
93
97
|
export type {
|
|
94
98
|
AsyncState,
|
|
@@ -96,6 +100,7 @@ export type {
|
|
|
96
100
|
DehydratedEntry,
|
|
97
101
|
DehydratedState,
|
|
98
102
|
LocalCache,
|
|
103
|
+
NetworkMode,
|
|
99
104
|
Query,
|
|
100
105
|
QuerySpec,
|
|
101
106
|
QuerySubscription,
|
|
@@ -113,6 +118,7 @@ export { selection } from './selection'
|
|
|
113
118
|
export type { Computed, ReadSignal, Signal } from './signals'
|
|
114
119
|
export { batch, computed, effect, signal, untracked } from './signals'
|
|
115
120
|
// Timing
|
|
121
|
+
export type { TimingSignal } from './timing'
|
|
116
122
|
export { debounced, throttled } from './timing'
|
|
117
123
|
|
|
118
124
|
// Utilities
|
package/src/query/client.ts
CHANGED
|
@@ -20,14 +20,25 @@ const DEFAULT_GC_TIME = 5 * 60_000
|
|
|
20
20
|
|
|
21
21
|
type AnyQuery = Query<any, any> & {
|
|
22
22
|
readonly __spec: QuerySpec<any, any>
|
|
23
|
+
readonly __id: string
|
|
23
24
|
__clients: Set<QueryClient>
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
type AnyInfiniteQuery = InfiniteQuery<any, any, any> & {
|
|
27
28
|
readonly __spec: InfiniteQuerySpec<any, any, any, any>
|
|
29
|
+
readonly __id: string
|
|
28
30
|
__clients: Set<QueryClient>
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Composite hydration-buffer key: query identity + key hash. Namespacing by
|
|
35
|
+
* identity stops a dehydrated entry for query A being adopted by query B that
|
|
36
|
+
* merely hashes to the same key (spec §15, T1.2). `JSON.stringify` of the pair
|
|
37
|
+
* is used rather than string concatenation so no separator char is ambiguous —
|
|
38
|
+
* both `id` (an arbitrary user `queryId`) and `hash` are unbounded strings.
|
|
39
|
+
*/
|
|
40
|
+
const hydrationKey = (id: string, hash: string): string => JSON.stringify([id, hash])
|
|
41
|
+
|
|
31
42
|
export class ClientEntry<T> {
|
|
32
43
|
readonly entry: Entry<T>
|
|
33
44
|
/** The result of `spec.key(...args)` — used for hashing/identity. */
|
|
@@ -80,6 +91,8 @@ export class ClientEntry<T> {
|
|
|
80
91
|
staleTime: spec.staleTime,
|
|
81
92
|
retry: spec.retry as RetryPolicy | undefined,
|
|
82
93
|
retryDelay: spec.retryDelay as RetryDelay | undefined,
|
|
94
|
+
networkMode: spec.networkMode,
|
|
95
|
+
structuralShare: spec.structuralShare,
|
|
83
96
|
initialData: hydrated?.data,
|
|
84
97
|
initialUpdatedAt: hydrated?.lastUpdatedAt,
|
|
85
98
|
events:
|
|
@@ -142,6 +155,20 @@ export class ClientEntry<T> {
|
|
|
142
155
|
if (this.refetchInterval == null) return
|
|
143
156
|
if (this.intervalTimer != null) return
|
|
144
157
|
this.intervalTimer = setInterval(() => {
|
|
158
|
+
// Skip when the tab is hidden — refetching while in background wastes
|
|
159
|
+
// battery and network. `refetchOnWindowFocus` (when enabled) will
|
|
160
|
+
// catch the entry up on visibility return; pure-interval users without
|
|
161
|
+
// focus-refetch get the catch-up via `acquire()` re-arming the timer
|
|
162
|
+
// when they next mount.
|
|
163
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
// Join an in-flight fetch instead of aborting it. `startFetch()` aborts
|
|
167
|
+
// the current request, so a fetch slower than the interval would
|
|
168
|
+
// livelock — abort→restart every tick, never completing, hammering one
|
|
169
|
+
// aborted request per interval (T3.2). Skip the tick; the running fetch
|
|
170
|
+
// will finish and the next tick re-arms once it's idle.
|
|
171
|
+
if (this.entry.isFetching.peek()) return
|
|
145
172
|
this.entry.startFetch().catch(() => {
|
|
146
173
|
/* error already captured on entry */
|
|
147
174
|
})
|
|
@@ -195,6 +222,9 @@ export class ClientEntry<T> {
|
|
|
195
222
|
/** Refetch on focus / reconnect, but only if the data is actually stale. */
|
|
196
223
|
private triggerEventRefetch(): void {
|
|
197
224
|
if (!this.entry.isStaleNow()) return
|
|
225
|
+
// Join an in-flight fetch instead of aborting + restarting it — a focus /
|
|
226
|
+
// reconnect landing mid-fetch shouldn't cancel it (T3.9, cf. T3.2 interval).
|
|
227
|
+
if (this.entry.isFetching.peek()) return
|
|
198
228
|
this.entry.startFetch().catch(() => {
|
|
199
229
|
/* error already captured on entry */
|
|
200
230
|
})
|
|
@@ -255,6 +285,8 @@ export class InfiniteClientEntry<TPage, TItem, PageParam> {
|
|
|
255
285
|
staleTime: spec.staleTime,
|
|
256
286
|
retry: spec.retry as RetryPolicy | undefined,
|
|
257
287
|
retryDelay: spec.retryDelay as RetryDelay | undefined,
|
|
288
|
+
networkMode: spec.networkMode,
|
|
289
|
+
structuralShare: spec.structuralShare,
|
|
258
290
|
// Fire SetDataEvent { kind: 'infinite', source: 'fetch' } whenever a
|
|
259
291
|
// fetch settles successfully. Plugins (e.g. entity normalization) use
|
|
260
292
|
// this to walk the pages and update their normalized stores. Mirrors
|
|
@@ -274,6 +306,10 @@ export class InfiniteClientEntry<TPage, TItem, PageParam> {
|
|
|
274
306
|
}
|
|
275
307
|
}
|
|
276
308
|
|
|
309
|
+
hasSubscribers(): boolean {
|
|
310
|
+
return this.subscriberCount > 0
|
|
311
|
+
}
|
|
312
|
+
|
|
277
313
|
release(): void {
|
|
278
314
|
this.subscriberCount -= 1
|
|
279
315
|
if (this.subscriberCount <= 0) {
|
|
@@ -296,6 +332,13 @@ export class InfiniteClientEntry<TPage, TItem, PageParam> {
|
|
|
296
332
|
private startIntervalTimer(): void {
|
|
297
333
|
if (this.refetchInterval == null || this.intervalTimer != null) return
|
|
298
334
|
this.intervalTimer = setInterval(() => {
|
|
335
|
+
// Same visibility-gate as the regular `ClientEntry.startIntervalTimer`.
|
|
336
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
// Join an in-flight fetch instead of aborting it (T3.2) — see the
|
|
340
|
+
// regular `ClientEntry.startIntervalTimer` for the livelock rationale.
|
|
341
|
+
if (this.entry.isFetching.peek()) return
|
|
299
342
|
this.entry.startFetch().catch(() => {
|
|
300
343
|
/* error captured on entry */
|
|
301
344
|
})
|
|
@@ -399,7 +442,7 @@ export class QueryClient {
|
|
|
399
442
|
if (opts?.hydrate) this.hydrate(opts.hydrate)
|
|
400
443
|
const api = this.makePluginApi()
|
|
401
444
|
for (const plugin of this.plugins) {
|
|
402
|
-
this.callPlugin(() => plugin.init?.(api))
|
|
445
|
+
this.callPlugin(plugin, () => plugin.init?.(api))
|
|
403
446
|
}
|
|
404
447
|
}
|
|
405
448
|
|
|
@@ -427,13 +470,14 @@ export class QueryClient {
|
|
|
427
470
|
}
|
|
428
471
|
|
|
429
472
|
/** Invoke a plugin callback; route exceptions through `onError`. */
|
|
430
|
-
private callPlugin(fn: () => void): void {
|
|
473
|
+
private callPlugin(plugin: QueryClientPlugin, fn: () => void): void {
|
|
431
474
|
try {
|
|
432
475
|
fn()
|
|
433
476
|
} catch (err) {
|
|
434
477
|
dispatchError(this.onError, err, {
|
|
435
478
|
kind: 'plugin',
|
|
436
479
|
controllerPath: [],
|
|
480
|
+
pluginName: plugin.name,
|
|
437
481
|
})
|
|
438
482
|
}
|
|
439
483
|
}
|
|
@@ -476,7 +520,7 @@ export class QueryClient {
|
|
|
476
520
|
for (const plugin of this.plugins) {
|
|
477
521
|
if (plugin.onSetData) {
|
|
478
522
|
const cb = plugin.onSetData
|
|
479
|
-
this.callPlugin(() => cb.call(plugin, event))
|
|
523
|
+
this.callPlugin(plugin, () => cb.call(plugin, event))
|
|
480
524
|
}
|
|
481
525
|
}
|
|
482
526
|
}
|
|
@@ -498,7 +542,7 @@ export class QueryClient {
|
|
|
498
542
|
for (const plugin of this.plugins) {
|
|
499
543
|
if (plugin.onInvalidate) {
|
|
500
544
|
const cb = plugin.onInvalidate
|
|
501
|
-
this.callPlugin(() => cb.call(plugin, event))
|
|
545
|
+
this.callPlugin(plugin, () => cb.call(plugin, event))
|
|
502
546
|
}
|
|
503
547
|
}
|
|
504
548
|
}
|
|
@@ -515,7 +559,7 @@ export class QueryClient {
|
|
|
515
559
|
for (const plugin of this.plugins) {
|
|
516
560
|
if (plugin.onGc) {
|
|
517
561
|
const cb = plugin.onGc
|
|
518
|
-
this.callPlugin(() => cb.call(plugin, event))
|
|
562
|
+
this.callPlugin(plugin, () => cb.call(plugin, event))
|
|
519
563
|
}
|
|
520
564
|
}
|
|
521
565
|
}
|
|
@@ -535,7 +579,7 @@ export class QueryClient {
|
|
|
535
579
|
for (const plugin of this.plugins) {
|
|
536
580
|
if (plugin.onMutationEnqueue) {
|
|
537
581
|
const cb = plugin.onMutationEnqueue
|
|
538
|
-
this.callPlugin(() => cb.call(plugin, event))
|
|
582
|
+
this.callPlugin(plugin, () => cb.call(plugin, event))
|
|
539
583
|
}
|
|
540
584
|
}
|
|
541
585
|
}
|
|
@@ -551,7 +595,7 @@ export class QueryClient {
|
|
|
551
595
|
for (const plugin of this.plugins) {
|
|
552
596
|
if (plugin.onMutationSettle) {
|
|
553
597
|
const cb = plugin.onMutationSettle
|
|
554
|
-
this.callPlugin(() => cb.call(plugin, event))
|
|
598
|
+
this.callPlugin(plugin, () => cb.call(plugin, event))
|
|
555
599
|
}
|
|
556
600
|
}
|
|
557
601
|
}
|
|
@@ -601,13 +645,60 @@ export class QueryClient {
|
|
|
601
645
|
if (!entry) return
|
|
602
646
|
this.applyingRemote = true
|
|
603
647
|
try {
|
|
604
|
-
entry.entry.setData(() => data as never)
|
|
648
|
+
entry.entry.setData(() => data as never, { track: false })
|
|
605
649
|
this.emitSetData(internal, entry.keyArgs, data, 'data', 'remote')
|
|
606
650
|
} finally {
|
|
607
651
|
this.applyingRemote = false
|
|
608
652
|
}
|
|
609
653
|
}
|
|
610
654
|
|
|
655
|
+
/**
|
|
656
|
+
* Apply a single dehydrated entry to the cache. Idempotent across
|
|
657
|
+
* pre-bind / post-bind:
|
|
658
|
+
*
|
|
659
|
+
* - If the matching `ClientEntry` already exists (a subscriber has
|
|
660
|
+
* bound this key), `Entry.applyHydration(data, lastUpdatedAt)` writes
|
|
661
|
+
* through with the server's timestamp + supersedes any inflight
|
|
662
|
+
* fetch. Plugins see a `SetDataEvent` with `source: 'remote'`.
|
|
663
|
+
* - If no `ClientEntry` exists yet (the subscribing component hasn't
|
|
664
|
+
* mounted), the entry is buffered in `hydratedData` so the next
|
|
665
|
+
* `bindEntry` for that hash picks it up — same path the constructor
|
|
666
|
+
* `hydrate(state)` uses.
|
|
667
|
+
*
|
|
668
|
+
* Designed for streaming SSR: each `<Suspense>` boundary that resolves
|
|
669
|
+
* on the server pushes its dehydrated entry through this method on the
|
|
670
|
+
* client as the bootstrap script executes.
|
|
671
|
+
*/
|
|
672
|
+
applyDehydratedEntry(
|
|
673
|
+
queryId: string,
|
|
674
|
+
keyArgs: readonly unknown[],
|
|
675
|
+
data: unknown,
|
|
676
|
+
lastUpdatedAt: number,
|
|
677
|
+
): void {
|
|
678
|
+
const query = lookupRegisteredQuery(queryId)
|
|
679
|
+
const hash = stableHash(keyArgs)
|
|
680
|
+
if (query && query.__olas === 'query') {
|
|
681
|
+
const internal = query as unknown as AnyQuery
|
|
682
|
+
const map = this.maps.get(internal)
|
|
683
|
+
const entry = map?.get(hash)
|
|
684
|
+
if (entry !== undefined) {
|
|
685
|
+
this.applyingRemote = true
|
|
686
|
+
try {
|
|
687
|
+
entry.entry.applyHydration(data, lastUpdatedAt)
|
|
688
|
+
this.emitSetData(internal, entry.keyArgs, data, 'data', 'remote')
|
|
689
|
+
} finally {
|
|
690
|
+
this.applyingRemote = false
|
|
691
|
+
}
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// No local entry yet — buffer in the same map the constructor uses.
|
|
696
|
+
// The next bindEntry for this query + key will adopt the buffered payload
|
|
697
|
+
// and clear the slot. Namespaced by queryId so a colliding-key query can't
|
|
698
|
+
// steal it (spec §15, T1.2).
|
|
699
|
+
this.hydratedData.set(hydrationKey(queryId, hash), { data, lastUpdatedAt })
|
|
700
|
+
}
|
|
701
|
+
|
|
611
702
|
/**
|
|
612
703
|
* Local-originated `setData` keyed by `queryId + keyArgs`. Plugin-facing
|
|
613
704
|
* (exposed via `QueryClientPluginApi.setEntryData`); used by the
|
|
@@ -633,7 +724,7 @@ export class QueryClient {
|
|
|
633
724
|
if (!map) return
|
|
634
725
|
const entry = map.get(hash)
|
|
635
726
|
if (!entry) return
|
|
636
|
-
entry.entry.setData(updater as (prev: unknown) => never)
|
|
727
|
+
entry.entry.setData(updater as (prev: unknown) => never, { track: false })
|
|
637
728
|
this.emitSetData(internal, entry.keyArgs, entry.entry.data.peek(), 'data', 'set')
|
|
638
729
|
return
|
|
639
730
|
}
|
|
@@ -646,7 +737,7 @@ export class QueryClient {
|
|
|
646
737
|
if (!map) return
|
|
647
738
|
const entry = map.get(hash)
|
|
648
739
|
if (!entry) return
|
|
649
|
-
entry.entry.setData(updater as (prev: unknown[] | undefined) => unknown[])
|
|
740
|
+
entry.entry.setData(updater as (prev: unknown[] | undefined) => unknown[], { track: false })
|
|
650
741
|
this.emitSetData(internal, entry.keyArgs, entry.entry.pages.peek(), 'infinite', 'set')
|
|
651
742
|
}
|
|
652
743
|
|
|
@@ -696,7 +787,7 @@ export class QueryClient {
|
|
|
696
787
|
}
|
|
697
788
|
for (const entry of state.entries) {
|
|
698
789
|
const hash = stableHash(entry.key)
|
|
699
|
-
this.hydratedData.set(hash, {
|
|
790
|
+
this.hydratedData.set(hydrationKey(entry.id, hash), {
|
|
700
791
|
data: entry.data,
|
|
701
792
|
lastUpdatedAt: entry.lastUpdatedAt,
|
|
702
793
|
})
|
|
@@ -745,10 +836,11 @@ export class QueryClient {
|
|
|
745
836
|
|
|
746
837
|
dehydrate(): DehydratedState {
|
|
747
838
|
const entries: DehydratedState['entries'] = []
|
|
748
|
-
for (const map of this.maps
|
|
839
|
+
for (const [query, map] of this.maps) {
|
|
749
840
|
for (const ce of map.values()) {
|
|
750
841
|
if (ce.entry.status.peek() === 'success') {
|
|
751
842
|
entries.push({
|
|
843
|
+
id: query.__id,
|
|
752
844
|
key: ce.keyArgs,
|
|
753
845
|
data: ce.entry.data.peek(),
|
|
754
846
|
lastUpdatedAt: ce.entry.lastUpdatedAt.peek() ?? Date.now(),
|
|
@@ -792,16 +884,27 @@ export class QueryClient {
|
|
|
792
884
|
await Promise.all(tasks)
|
|
793
885
|
}
|
|
794
886
|
// The 100-iteration safety bound exists so a pathological setup that
|
|
795
|
-
// keeps starting new fetches doesn't lock the dehydrate path.
|
|
796
|
-
//
|
|
797
|
-
// incomplete payload
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
887
|
+
// keeps starting new fetches doesn't lock the dehydrate path. This is
|
|
888
|
+
// a correctness failure for SSR: silently returning would let
|
|
889
|
+
// `dehydrate()` ship an incomplete payload that looks clean. Throw so
|
|
890
|
+
// the SSR boundary surfaces it instead of pretending success.
|
|
891
|
+
const unsettled: Array<{ key: readonly unknown[]; kind: 'data' | 'infinite' }> = []
|
|
892
|
+
for (const map of this.maps.values()) {
|
|
893
|
+
for (const ce of map.values()) {
|
|
894
|
+
if (ce.entry.isFetching.peek()) unsettled.push({ key: ce.keyArgs, kind: 'data' })
|
|
895
|
+
}
|
|
804
896
|
}
|
|
897
|
+
for (const map of this.infiniteMaps.values()) {
|
|
898
|
+
for (const ce of map.values()) {
|
|
899
|
+
if (ce.entry.isFetching.peek()) unsettled.push({ key: ce.keyArgs, kind: 'infinite' })
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const err = new Error(
|
|
903
|
+
'[olas] waitForIdle: exceeded 100-iteration safety bound — cache or mutations keep restarting',
|
|
904
|
+
) as Error & { unsettled: typeof unsettled; mutationsInflight: number }
|
|
905
|
+
err.unsettled = unsettled
|
|
906
|
+
err.mutationsInflight = this.mutationsInflight$.peek()
|
|
907
|
+
throw err
|
|
805
908
|
}
|
|
806
909
|
|
|
807
910
|
bindEntry<Args extends unknown[], T>(query: Query<Args, T>, args: Args): ClientEntry<T> {
|
|
@@ -817,8 +920,9 @@ export class QueryClient {
|
|
|
817
920
|
const hash = stableHash(keyArgs)
|
|
818
921
|
let entry = map.get(hash) as ClientEntry<T> | undefined
|
|
819
922
|
if (!entry) {
|
|
820
|
-
const
|
|
821
|
-
|
|
923
|
+
const hkey = hydrationKey(internal.__id, hash)
|
|
924
|
+
const hydrated = this.hydratedData.get(hkey) as { data: T; lastUpdatedAt: number } | undefined
|
|
925
|
+
if (hydrated) this.hydratedData.delete(hkey)
|
|
822
926
|
// Build the fetcher-success emitter here so `emitSetData` can stay
|
|
823
927
|
// `private` — `ClientEntry` doesn't reach back into the client to call
|
|
824
928
|
// it; the closure captures (query, keyArgs, this) in this scope and
|
|
@@ -853,6 +957,29 @@ export class QueryClient {
|
|
|
853
957
|
if (hydrated !== undefined) {
|
|
854
958
|
this.emitSetData(internal, keyArgs, hydrated.data, 'data', 'fetch')
|
|
855
959
|
}
|
|
960
|
+
} else if (__DEV__) {
|
|
961
|
+
// The fetcher closure is captured on first `bindEntry`. If a later
|
|
962
|
+
// subscriber binds the SAME `keyArgs` (same hash) but DIFFERENT
|
|
963
|
+
// `callArgs`, the next refetch will still use the first caller's
|
|
964
|
+
// arguments — a silent staleness footgun when a `key()` function
|
|
965
|
+
// collapses non-key args (e.g. headers, abort tokens). Warn so the
|
|
966
|
+
// mismatch is surfaced; the consumer should either include the diff
|
|
967
|
+
// in the key or accept the documented "first wins" behavior.
|
|
968
|
+
const prev = entry.callArgs
|
|
969
|
+
const len = Math.max(prev.length, args.length)
|
|
970
|
+
let mismatch = prev.length !== args.length
|
|
971
|
+
for (let i = 0; i < len && !mismatch; i++) {
|
|
972
|
+
if (!Object.is(prev[i], args[i])) mismatch = true
|
|
973
|
+
}
|
|
974
|
+
if (mismatch) {
|
|
975
|
+
// eslint-disable-next-line no-console
|
|
976
|
+
console.warn(
|
|
977
|
+
`[olas] bindEntry: hash collision with diverging callArgs for query` +
|
|
978
|
+
` ${internal.__spec.queryId ?? '<anonymous>'} key=${JSON.stringify(keyArgs)}.` +
|
|
979
|
+
` First bind's args are used by the fetcher; later args ignored.` +
|
|
980
|
+
` Either include the difference in spec.key(...) or pass identical args.`,
|
|
981
|
+
)
|
|
982
|
+
}
|
|
856
983
|
}
|
|
857
984
|
return entry
|
|
858
985
|
}
|
|
@@ -873,6 +1000,32 @@ export class QueryClient {
|
|
|
873
1000
|
this.emitGc(entry.query, entry.keyArgs, 'data')
|
|
874
1001
|
}
|
|
875
1002
|
|
|
1003
|
+
/**
|
|
1004
|
+
* Invalidate one entry: if it has subscribers, mark stale AND refetch; if
|
|
1005
|
+
* not, mark stale only — the next subscriber refetches. Spec §5.7 says
|
|
1006
|
+
* invalidate refetches only IF subscribed; the old always-fetch behavior woke
|
|
1007
|
+
* orphaned entries that no one was watching (T3.9). Works for both
|
|
1008
|
+
* `ClientEntry` and `InfiniteClientEntry`.
|
|
1009
|
+
*/
|
|
1010
|
+
private invalidateEntry(entry: {
|
|
1011
|
+
hasSubscribers(): boolean
|
|
1012
|
+
keyArgs: readonly unknown[]
|
|
1013
|
+
entry: { invalidate(): Promise<unknown>; markStale(): void }
|
|
1014
|
+
}): void {
|
|
1015
|
+
if (entry.hasSubscribers()) {
|
|
1016
|
+
entry.entry.invalidate().catch((err) => {
|
|
1017
|
+
if (isAbortError(err)) return
|
|
1018
|
+
dispatchError(this.onError, err, {
|
|
1019
|
+
kind: 'cache',
|
|
1020
|
+
controllerPath: [],
|
|
1021
|
+
queryKey: entry.keyArgs,
|
|
1022
|
+
})
|
|
1023
|
+
})
|
|
1024
|
+
} else {
|
|
1025
|
+
entry.entry.markStale()
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
876
1029
|
invalidate<Args extends unknown[]>(query: Query<Args, any>, args: Args): void {
|
|
877
1030
|
const internal = query as AnyQuery
|
|
878
1031
|
const map = this.maps.get(internal)
|
|
@@ -884,14 +1037,7 @@ export class QueryClient {
|
|
|
884
1037
|
if (__DEV__) {
|
|
885
1038
|
this.devtools?.emit({ type: 'cache:invalidated', queryKey: keyArgs })
|
|
886
1039
|
}
|
|
887
|
-
|
|
888
|
-
if (isAbortError(err)) return
|
|
889
|
-
dispatchError(this.onError, err, {
|
|
890
|
-
kind: 'cache',
|
|
891
|
-
controllerPath: [],
|
|
892
|
-
queryKey: keyArgs,
|
|
893
|
-
})
|
|
894
|
-
})
|
|
1040
|
+
this.invalidateEntry(entry)
|
|
895
1041
|
this.emitInvalidate(internal, keyArgs, 'data')
|
|
896
1042
|
}
|
|
897
1043
|
|
|
@@ -904,18 +1050,25 @@ export class QueryClient {
|
|
|
904
1050
|
if (__DEV__) {
|
|
905
1051
|
this.devtools?.emit({ type: 'cache:invalidated', queryKey: entry.keyArgs })
|
|
906
1052
|
}
|
|
907
|
-
|
|
908
|
-
if (isAbortError(err)) return
|
|
909
|
-
dispatchError(this.onError, err, {
|
|
910
|
-
kind: 'cache',
|
|
911
|
-
controllerPath: [],
|
|
912
|
-
queryKey: entry.keyArgs,
|
|
913
|
-
})
|
|
914
|
-
})
|
|
1053
|
+
this.invalidateEntry(entry)
|
|
915
1054
|
this.emitInvalidate(internal, entry.keyArgs, 'data')
|
|
916
1055
|
}
|
|
917
1056
|
}
|
|
918
1057
|
|
|
1058
|
+
cancel<Args extends unknown[]>(query: Query<Args, any>, args: Args): void {
|
|
1059
|
+
const internal = query as AnyQuery
|
|
1060
|
+
const map = this.maps.get(internal)
|
|
1061
|
+
if (!map) return
|
|
1062
|
+
const hash = stableHash(internal.__spec.key(...args))
|
|
1063
|
+
map.get(hash)?.entry.cancel()
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
cancelAll(query: Query<any, any>): void {
|
|
1067
|
+
const map = this.maps.get(query as AnyQuery)
|
|
1068
|
+
if (!map) return
|
|
1069
|
+
for (const entry of map.values()) entry.entry.cancel()
|
|
1070
|
+
}
|
|
1071
|
+
|
|
919
1072
|
setData<Args extends unknown[], T>(
|
|
920
1073
|
query: Query<Args, T>,
|
|
921
1074
|
args: Args,
|
|
@@ -927,7 +1080,21 @@ export class QueryClient {
|
|
|
927
1080
|
// not the updater function (which would be uncloneable across
|
|
928
1081
|
// BroadcastChannel).
|
|
929
1082
|
this.emitSetData(entry.query, entry.keyArgs, entry.entry.data.peek(), 'data', 'set')
|
|
930
|
-
|
|
1083
|
+
// Re-broadcast on rollback so cross-tab / entity plugins drop the failed
|
|
1084
|
+
// optimistic value instead of keeping it (T3.6). Guard on an actual data
|
|
1085
|
+
// change: a non-top chain-splice rollback (§6.4) leaves current data
|
|
1086
|
+
// untouched, so there is nothing new to broadcast.
|
|
1087
|
+
return {
|
|
1088
|
+
rollback: () => {
|
|
1089
|
+
const before = entry.entry.data.peek()
|
|
1090
|
+
snapshot.rollback()
|
|
1091
|
+
const after = entry.entry.data.peek()
|
|
1092
|
+
if (!Object.is(before, after)) {
|
|
1093
|
+
this.emitSetData(entry.query, entry.keyArgs, after, 'data', 'set')
|
|
1094
|
+
}
|
|
1095
|
+
},
|
|
1096
|
+
finalize: () => snapshot.finalize(),
|
|
1097
|
+
}
|
|
931
1098
|
}
|
|
932
1099
|
|
|
933
1100
|
bindInfiniteEntry<Args extends unknown[], TPage, TItem>(
|
|
@@ -993,14 +1160,7 @@ export class QueryClient {
|
|
|
993
1160
|
const hash = stableHash(keyArgs)
|
|
994
1161
|
const entry = map.get(hash)
|
|
995
1162
|
if (!entry) return
|
|
996
|
-
|
|
997
|
-
if (isAbortError(err)) return
|
|
998
|
-
dispatchError(this.onError, err, {
|
|
999
|
-
kind: 'cache',
|
|
1000
|
-
controllerPath: [],
|
|
1001
|
-
queryKey: entry.keyArgs,
|
|
1002
|
-
})
|
|
1003
|
-
})
|
|
1163
|
+
this.invalidateEntry(entry)
|
|
1004
1164
|
this.emitInvalidate(internal, keyArgs, 'infinite')
|
|
1005
1165
|
}
|
|
1006
1166
|
|
|
@@ -1009,18 +1169,25 @@ export class QueryClient {
|
|
|
1009
1169
|
const map = this.infiniteMaps.get(internal)
|
|
1010
1170
|
if (!map) return
|
|
1011
1171
|
for (const entry of map.values()) {
|
|
1012
|
-
|
|
1013
|
-
if (isAbortError(err)) return
|
|
1014
|
-
dispatchError(this.onError, err, {
|
|
1015
|
-
kind: 'cache',
|
|
1016
|
-
controllerPath: [],
|
|
1017
|
-
queryKey: entry.keyArgs,
|
|
1018
|
-
})
|
|
1019
|
-
})
|
|
1172
|
+
this.invalidateEntry(entry)
|
|
1020
1173
|
this.emitInvalidate(internal, entry.keyArgs, 'infinite')
|
|
1021
1174
|
}
|
|
1022
1175
|
}
|
|
1023
1176
|
|
|
1177
|
+
cancelInfinite<Args extends unknown[]>(query: InfiniteQuery<Args, any, any>, args: Args): void {
|
|
1178
|
+
const internal = query as AnyInfiniteQuery
|
|
1179
|
+
const map = this.infiniteMaps.get(internal)
|
|
1180
|
+
if (!map) return
|
|
1181
|
+
const hash = stableHash(internal.__spec.key(...args))
|
|
1182
|
+
map.get(hash)?.entry.cancel()
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
cancelAllInfinite(query: InfiniteQuery<any, any, any>): void {
|
|
1186
|
+
const map = this.infiniteMaps.get(query as AnyInfiniteQuery)
|
|
1187
|
+
if (!map) return
|
|
1188
|
+
for (const entry of map.values()) entry.entry.cancel()
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1024
1191
|
setInfiniteData<Args extends unknown[], TPage>(
|
|
1025
1192
|
query: InfiniteQuery<Args, TPage, any>,
|
|
1026
1193
|
args: Args,
|
|
@@ -1029,7 +1196,19 @@ export class QueryClient {
|
|
|
1029
1196
|
const entry = this.bindInfiniteEntry(query, args)
|
|
1030
1197
|
const snapshot = entry.entry.setData(updater)
|
|
1031
1198
|
this.emitSetData(entry.query, entry.keyArgs, entry.entry.pages.peek(), 'infinite', 'set')
|
|
1032
|
-
|
|
1199
|
+
// Re-broadcast on rollback so peers drop the failed optimistic pages
|
|
1200
|
+
// (T3.6); guard on an actual change (non-top chain-splice is a no-op).
|
|
1201
|
+
return {
|
|
1202
|
+
rollback: () => {
|
|
1203
|
+
const before = entry.entry.pages.peek()
|
|
1204
|
+
snapshot.rollback()
|
|
1205
|
+
const after = entry.entry.pages.peek()
|
|
1206
|
+
if (!Object.is(before, after)) {
|
|
1207
|
+
this.emitSetData(entry.query, entry.keyArgs, after, 'infinite', 'set')
|
|
1208
|
+
}
|
|
1209
|
+
},
|
|
1210
|
+
finalize: () => snapshot.finalize(),
|
|
1211
|
+
}
|
|
1033
1212
|
}
|
|
1034
1213
|
|
|
1035
1214
|
prefetchInfinite<Args extends unknown[], TPage>(
|
|
@@ -1103,7 +1282,7 @@ export class QueryClient {
|
|
|
1103
1282
|
for (const plugin of this.plugins) {
|
|
1104
1283
|
if (plugin.dispose) {
|
|
1105
1284
|
const cb = plugin.dispose
|
|
1106
|
-
this.callPlugin(() => cb.call(plugin))
|
|
1285
|
+
this.callPlugin(plugin, () => cb.call(plugin))
|
|
1107
1286
|
}
|
|
1108
1287
|
}
|
|
1109
1288
|
}
|
package/src/query/define.ts
CHANGED
|
Binary file
|