@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,154 @@
|
|
|
1
|
+
import type { QueryClient } from './client'
|
|
2
|
+
import type { InfiniteQuery, InfiniteQuerySpec } from './infinite'
|
|
3
|
+
import { type RegisteredQuery, registerQueryById } from './plugin'
|
|
4
|
+
import type { Query, QuerySpec, Snapshot } from './types'
|
|
5
|
+
|
|
6
|
+
type QueryInternal<Args extends unknown[], T> = Query<Args, T> & {
|
|
7
|
+
readonly __spec: QuerySpec<Args, T>
|
|
8
|
+
__clients: Set<QueryClient>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const warnedMissingId = new WeakSet<object>()
|
|
12
|
+
|
|
13
|
+
function registerQueryId(spec: { queryId?: string; crossTab?: boolean }, query: object): void {
|
|
14
|
+
if (spec.queryId != null) {
|
|
15
|
+
registerQueryById(spec.queryId, query as RegisteredQuery)
|
|
16
|
+
} else if (spec.crossTab === true) {
|
|
17
|
+
// Plugins can't route a message without a `queryId`. Warn once per
|
|
18
|
+
// offending spec — repeated warnings on every render would be noisy.
|
|
19
|
+
if (__DEV__ && !warnedMissingId.has(spec as object)) {
|
|
20
|
+
warnedMissingId.add(spec as object)
|
|
21
|
+
console.warn(
|
|
22
|
+
'[olas] defineQuery({ crossTab: true }) requires a stable `queryId`. ' +
|
|
23
|
+
'Add `queryId: "<unique-string>"` to the spec. Cross-tab sync is disabled for this query.',
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Define a keyed, shared query. The returned Query value lives at module
|
|
31
|
+
* scope; per-root QueryClients bind their own entry registries to it.
|
|
32
|
+
*/
|
|
33
|
+
export function defineQuery<Args extends unknown[], T>(spec: QuerySpec<Args, T>): Query<Args, T> {
|
|
34
|
+
const clients = new Set<QueryClient>()
|
|
35
|
+
const query = {
|
|
36
|
+
__olas: 'query' as const,
|
|
37
|
+
__spec: spec,
|
|
38
|
+
__clients: clients,
|
|
39
|
+
|
|
40
|
+
invalidate(...args: Args): void {
|
|
41
|
+
for (const client of clients) {
|
|
42
|
+
client.invalidate(query as Query<Args, T>, args)
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
invalidateAll(): void {
|
|
47
|
+
for (const client of clients) {
|
|
48
|
+
client.invalidateAll(query as Query<Args, T>)
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
setData(...rest: [...Args, updater: (prev: T | undefined) => T]): Snapshot {
|
|
53
|
+
const updater = rest[rest.length - 1] as (prev: T | undefined) => T
|
|
54
|
+
const keyArgs = rest.slice(0, -1) as unknown as Args
|
|
55
|
+
const childSnapshots: Snapshot[] = []
|
|
56
|
+
for (const client of clients) {
|
|
57
|
+
childSnapshots.push(client.setData(query as Query<Args, T>, keyArgs, updater))
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
rollback: () => {
|
|
61
|
+
for (const s of childSnapshots) s.rollback()
|
|
62
|
+
},
|
|
63
|
+
finalize: () => {
|
|
64
|
+
for (const s of childSnapshots) s.finalize()
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
prefetch(...args: Args): Promise<T> {
|
|
70
|
+
// Single-client common case; if none, throw.
|
|
71
|
+
const [first] = clients
|
|
72
|
+
if (!first) {
|
|
73
|
+
return Promise.reject(new Error('[olas] prefetch called before any root has subscribed'))
|
|
74
|
+
}
|
|
75
|
+
return first.prefetch(query as Query<Args, T>, args)
|
|
76
|
+
},
|
|
77
|
+
} satisfies QueryInternal<Args, T>
|
|
78
|
+
|
|
79
|
+
registerQueryId(spec, query)
|
|
80
|
+
return query as Query<Args, T>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type InfiniteQueryInternal<Args extends unknown[], TPage, TItem> = InfiniteQuery<
|
|
84
|
+
Args,
|
|
85
|
+
TPage,
|
|
86
|
+
TItem
|
|
87
|
+
> & {
|
|
88
|
+
readonly __spec: InfiniteQuerySpec<Args, any, TPage, TItem>
|
|
89
|
+
__clients: Set<QueryClient>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Define a paginated query (chat-style "load more", infinite scrolling). Pages
|
|
94
|
+
* are kept in order and concatenated via `getNextPageParam` /
|
|
95
|
+
* `getPreviousPageParam`. The returned handle is module-scoped — bind
|
|
96
|
+
* subscribers via `ctx.use(infiniteQuery, () => [...args])`. Spec §5.7,
|
|
97
|
+
* §20.4.
|
|
98
|
+
*/
|
|
99
|
+
export function defineInfiniteQuery<Args extends unknown[], PageParam, TPage, TItem = TPage>(
|
|
100
|
+
spec: InfiniteQuerySpec<Args, PageParam, TPage, TItem>,
|
|
101
|
+
): InfiniteQuery<Args, TPage, TItem> {
|
|
102
|
+
const clients = new Set<QueryClient>()
|
|
103
|
+
const query = {
|
|
104
|
+
__olas: 'infiniteQuery' as const,
|
|
105
|
+
__spec: spec,
|
|
106
|
+
__clients: clients,
|
|
107
|
+
|
|
108
|
+
invalidate(...args: Args): void {
|
|
109
|
+
for (const client of clients) {
|
|
110
|
+
client.invalidateInfinite(query as InfiniteQuery<Args, TPage, TItem>, args)
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
invalidateAll(): void {
|
|
115
|
+
for (const client of clients) {
|
|
116
|
+
client.invalidateAllInfinite(query as InfiniteQuery<Args, TPage, TItem>)
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
setData(...rest: [...Args, updater: (prev: TPage[] | undefined) => TPage[]]): Snapshot {
|
|
121
|
+
const updater = rest[rest.length - 1] as (prev: TPage[] | undefined) => TPage[]
|
|
122
|
+
const keyArgs = rest.slice(0, -1) as unknown as Args
|
|
123
|
+
const childSnapshots: Snapshot[] = []
|
|
124
|
+
for (const client of clients) {
|
|
125
|
+
childSnapshots.push(
|
|
126
|
+
client.setInfiniteData<Args, TPage>(
|
|
127
|
+
query as InfiniteQuery<Args, TPage, TItem>,
|
|
128
|
+
keyArgs,
|
|
129
|
+
updater,
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
rollback: () => {
|
|
135
|
+
for (const s of childSnapshots) s.rollback()
|
|
136
|
+
},
|
|
137
|
+
finalize: () => {
|
|
138
|
+
for (const s of childSnapshots) s.finalize()
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
prefetch(...args: Args): Promise<TPage> {
|
|
144
|
+
const [first] = clients
|
|
145
|
+
if (!first) {
|
|
146
|
+
return Promise.reject(new Error('[olas] prefetch called before any root has subscribed'))
|
|
147
|
+
}
|
|
148
|
+
return first.prefetchInfinite(query as InfiniteQuery<Args, TPage, TItem>, args)
|
|
149
|
+
},
|
|
150
|
+
} satisfies InfiniteQueryInternal<Args, TPage, TItem>
|
|
151
|
+
|
|
152
|
+
registerQueryId(spec, query)
|
|
153
|
+
return query as InfiniteQuery<Args, TPage, TItem>
|
|
154
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { batch, type Signal, signal } from '../signals'
|
|
2
|
+
import { isAbortError } from '../utils'
|
|
3
|
+
import type { AsyncStatus, RetryDelay, RetryPolicy, Snapshot } from './types'
|
|
4
|
+
|
|
5
|
+
export type EntryEvents = {
|
|
6
|
+
onFetchStart?: () => void
|
|
7
|
+
onFetchSuccess?: (durationMs: number) => void
|
|
8
|
+
onFetchError?: (durationMs: number, error: unknown) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type EntryOptions<T> = {
|
|
12
|
+
fetcher: () => (signal: AbortSignal) => Promise<T>
|
|
13
|
+
staleTime?: number
|
|
14
|
+
initialData?: T | undefined
|
|
15
|
+
initialUpdatedAt?: number | undefined
|
|
16
|
+
retry?: RetryPolicy
|
|
17
|
+
retryDelay?: RetryDelay
|
|
18
|
+
events?: EntryEvents
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type SnapshotRecord<T> = {
|
|
22
|
+
id: number
|
|
23
|
+
prev: T | undefined
|
|
24
|
+
live: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One cache entry's state machine. Owns the AsyncState signals, race
|
|
29
|
+
* protection, retry loop, optimistic-update snapshot stack.
|
|
30
|
+
*
|
|
31
|
+
* Internal — not exported from the public surface.
|
|
32
|
+
*/
|
|
33
|
+
export class Entry<T> {
|
|
34
|
+
readonly data: Signal<T | undefined>
|
|
35
|
+
readonly error: Signal<unknown | undefined> = signal(undefined)
|
|
36
|
+
readonly status: Signal<AsyncStatus>
|
|
37
|
+
readonly isLoading: Signal<boolean> = signal(false)
|
|
38
|
+
readonly isFetching: Signal<boolean> = signal(false)
|
|
39
|
+
readonly lastUpdatedAt: Signal<number | undefined>
|
|
40
|
+
readonly hasPendingMutations: Signal<boolean> = signal(false)
|
|
41
|
+
readonly isStale: Signal<boolean> = signal(true)
|
|
42
|
+
|
|
43
|
+
fetcherProvider: () => (signal: AbortSignal) => Promise<T>
|
|
44
|
+
private staleTime: number
|
|
45
|
+
private retry: RetryPolicy
|
|
46
|
+
private retryDelay: RetryDelay
|
|
47
|
+
private currentFetchId = 0
|
|
48
|
+
private currentAbort: AbortController | null = null
|
|
49
|
+
private staleTimer: ReturnType<typeof setTimeout> | null = null
|
|
50
|
+
private snapshots: Array<SnapshotRecord<T>> = []
|
|
51
|
+
private nextSnapshotId = 0
|
|
52
|
+
private disposed = false
|
|
53
|
+
private readonly events: EntryEvents
|
|
54
|
+
private fetchStartTime = 0
|
|
55
|
+
|
|
56
|
+
constructor(options: EntryOptions<T>) {
|
|
57
|
+
this.fetcherProvider = options.fetcher
|
|
58
|
+
this.staleTime = options.staleTime ?? 0
|
|
59
|
+
this.retry = options.retry ?? 0
|
|
60
|
+
this.retryDelay = options.retryDelay ?? 1000
|
|
61
|
+
this.events = options.events ?? {}
|
|
62
|
+
this.data = signal<T | undefined>(options.initialData)
|
|
63
|
+
if (options.initialData !== undefined) {
|
|
64
|
+
this.status = signal<AsyncStatus>('success')
|
|
65
|
+
this.scheduleStaleness()
|
|
66
|
+
this.isStale.set(this.staleTime === 0)
|
|
67
|
+
} else {
|
|
68
|
+
this.status = signal<AsyncStatus>('idle')
|
|
69
|
+
}
|
|
70
|
+
this.lastUpdatedAt = signal<number | undefined>(options.initialUpdatedAt)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
startFetch(): Promise<T> {
|
|
74
|
+
if (this.disposed) {
|
|
75
|
+
return Promise.reject(new Error('Entry disposed'))
|
|
76
|
+
}
|
|
77
|
+
const myId = ++this.currentFetchId
|
|
78
|
+
this.currentAbort?.abort()
|
|
79
|
+
const abort = new AbortController()
|
|
80
|
+
this.currentAbort = abort
|
|
81
|
+
|
|
82
|
+
const previouslyHadData = this.data.peek() !== undefined
|
|
83
|
+
batch(() => {
|
|
84
|
+
this.status.set('pending')
|
|
85
|
+
this.isFetching.set(true)
|
|
86
|
+
this.isLoading.set(!previouslyHadData)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
this.fetchStartTime = Date.now()
|
|
90
|
+
try {
|
|
91
|
+
this.events.onFetchStart?.()
|
|
92
|
+
} catch {
|
|
93
|
+
// devtools handlers must not break the program.
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return this.runWithRetry(myId, abort)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async runWithRetry(myId: number, abort: AbortController): Promise<T> {
|
|
100
|
+
let attempt = 0
|
|
101
|
+
while (true) {
|
|
102
|
+
if (myId !== this.currentFetchId || this.disposed) {
|
|
103
|
+
throw new DOMException('Superseded', 'AbortError')
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const fetcher = this.fetcherProvider()
|
|
107
|
+
const result = await fetcher(abort.signal)
|
|
108
|
+
if (myId !== this.currentFetchId || this.disposed) {
|
|
109
|
+
throw new DOMException('Superseded', 'AbortError')
|
|
110
|
+
}
|
|
111
|
+
return this.applySuccess(result)
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (myId !== this.currentFetchId || this.disposed || isAbortError(err)) {
|
|
114
|
+
throw err
|
|
115
|
+
}
|
|
116
|
+
if (!this.shouldRetry(attempt, err)) {
|
|
117
|
+
return this.applyFailure(err)
|
|
118
|
+
}
|
|
119
|
+
const delay = this.computeDelay(attempt)
|
|
120
|
+
await abortableSleep(delay, abort.signal)
|
|
121
|
+
attempt += 1
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private shouldRetry(attempt: number, err: unknown): boolean {
|
|
127
|
+
const retry = this.retry
|
|
128
|
+
if (retry === 0) return false
|
|
129
|
+
if (typeof retry === 'number') return attempt < retry
|
|
130
|
+
return retry(attempt, err)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private computeDelay(attempt: number): number {
|
|
134
|
+
const d = this.retryDelay
|
|
135
|
+
return typeof d === 'function' ? d(attempt) : d
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private applySuccess(result: T): T {
|
|
139
|
+
batch(() => {
|
|
140
|
+
this.data.set(result)
|
|
141
|
+
this.error.set(undefined)
|
|
142
|
+
this.status.set('success')
|
|
143
|
+
this.isLoading.set(false)
|
|
144
|
+
this.isFetching.set(false)
|
|
145
|
+
this.lastUpdatedAt.set(Date.now())
|
|
146
|
+
this.isStale.set(this.staleTime === 0)
|
|
147
|
+
})
|
|
148
|
+
if (this.staleTime > 0) this.scheduleStaleness()
|
|
149
|
+
try {
|
|
150
|
+
this.events.onFetchSuccess?.(Date.now() - this.fetchStartTime)
|
|
151
|
+
} catch {
|
|
152
|
+
// devtools handlers must not break the program.
|
|
153
|
+
}
|
|
154
|
+
return result
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private applyFailure(err: unknown): never {
|
|
158
|
+
batch(() => {
|
|
159
|
+
this.error.set(err)
|
|
160
|
+
this.status.set('error')
|
|
161
|
+
this.isLoading.set(false)
|
|
162
|
+
this.isFetching.set(false)
|
|
163
|
+
})
|
|
164
|
+
try {
|
|
165
|
+
this.events.onFetchError?.(Date.now() - this.fetchStartTime, err)
|
|
166
|
+
} catch {
|
|
167
|
+
// devtools handlers must not break the program.
|
|
168
|
+
}
|
|
169
|
+
throw err
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private scheduleStaleness(): void {
|
|
173
|
+
if (this.staleTimer != null) clearTimeout(this.staleTimer)
|
|
174
|
+
if (this.staleTime > 0) {
|
|
175
|
+
this.staleTimer = setTimeout(() => {
|
|
176
|
+
this.staleTimer = null
|
|
177
|
+
if (!this.disposed) this.isStale.set(true)
|
|
178
|
+
}, this.staleTime)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
refetch(): Promise<T> {
|
|
183
|
+
return this.startFetch()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
invalidate(): Promise<T> {
|
|
187
|
+
if (this.staleTimer != null) {
|
|
188
|
+
clearTimeout(this.staleTimer)
|
|
189
|
+
this.staleTimer = null
|
|
190
|
+
}
|
|
191
|
+
this.isStale.set(true)
|
|
192
|
+
return this.startFetch()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
reset(): void {
|
|
196
|
+
if (this.disposed) return
|
|
197
|
+
batch(() => {
|
|
198
|
+
this.error.set(undefined)
|
|
199
|
+
this.status.set(this.data.peek() !== undefined ? 'success' : 'idle')
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
setData(updater: (prev: T | undefined) => T): Snapshot {
|
|
204
|
+
if (this.disposed) {
|
|
205
|
+
return { rollback: () => {}, finalize: () => {} }
|
|
206
|
+
}
|
|
207
|
+
const prev = this.data.peek()
|
|
208
|
+
const next = updater(prev)
|
|
209
|
+
const id = this.nextSnapshotId++
|
|
210
|
+
const record: SnapshotRecord<T> = { id, prev, live: true }
|
|
211
|
+
this.snapshots.push(record)
|
|
212
|
+
|
|
213
|
+
batch(() => {
|
|
214
|
+
this.data.set(next)
|
|
215
|
+
if (this.status.peek() === 'idle' || this.status.peek() === 'pending') {
|
|
216
|
+
this.status.set('success')
|
|
217
|
+
}
|
|
218
|
+
this.lastUpdatedAt.set(Date.now())
|
|
219
|
+
this.hasPendingMutations.set(true)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
rollback: () => {
|
|
224
|
+
if (!record.live || this.disposed) return
|
|
225
|
+
record.live = false
|
|
226
|
+
batch(() => {
|
|
227
|
+
this.data.set(record.prev as T)
|
|
228
|
+
this.snapshots = this.snapshots.filter((s) => s.id !== id)
|
|
229
|
+
const anyLive = this.snapshots.some((s) => s.live)
|
|
230
|
+
this.hasPendingMutations.set(anyLive)
|
|
231
|
+
})
|
|
232
|
+
},
|
|
233
|
+
finalize: () => {
|
|
234
|
+
if (!record.live || this.disposed) return
|
|
235
|
+
record.live = false
|
|
236
|
+
this.snapshots = this.snapshots.filter((s) => s.id !== id)
|
|
237
|
+
if (!this.snapshots.some((s) => s.live)) {
|
|
238
|
+
this.hasPendingMutations.set(false)
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
finalizeSnapshot(snapshot: Snapshot): void {
|
|
245
|
+
const id = snapshotIds.get(snapshot)
|
|
246
|
+
if (id === undefined) return
|
|
247
|
+
const record = this.snapshots.find((s) => s.live && s.id === id)
|
|
248
|
+
if (!record) return
|
|
249
|
+
record.live = false
|
|
250
|
+
this.snapshots = this.snapshots.filter((s) => s !== record)
|
|
251
|
+
if (!this.snapshots.some((s) => s.live)) {
|
|
252
|
+
this.hasPendingMutations.set(false)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
firstValue(): Promise<T> {
|
|
257
|
+
if (this.status.peek() === 'success') {
|
|
258
|
+
return Promise.resolve(this.data.peek() as T)
|
|
259
|
+
}
|
|
260
|
+
if (this.status.peek() === 'error') {
|
|
261
|
+
return Promise.reject(this.error.peek())
|
|
262
|
+
}
|
|
263
|
+
return new Promise<T>((resolve, reject) => {
|
|
264
|
+
const unsub = this.status.subscribe((s) => {
|
|
265
|
+
if (s === 'success') {
|
|
266
|
+
unsub()
|
|
267
|
+
resolve(this.data.peek() as T)
|
|
268
|
+
} else if (s === 'error') {
|
|
269
|
+
unsub()
|
|
270
|
+
reject(this.error.peek())
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* True iff data is older than `staleTime` (or no data has been fetched yet).
|
|
278
|
+
* Used by the query client to decide whether to refetch on subscribe.
|
|
279
|
+
*/
|
|
280
|
+
isStaleNow(): boolean {
|
|
281
|
+
const last = this.lastUpdatedAt.peek()
|
|
282
|
+
if (last === undefined) return true
|
|
283
|
+
return Date.now() - last >= this.staleTime
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
dispose(): void {
|
|
287
|
+
if (this.disposed) return
|
|
288
|
+
this.disposed = true
|
|
289
|
+
if (this.staleTimer != null) {
|
|
290
|
+
clearTimeout(this.staleTimer)
|
|
291
|
+
this.staleTimer = null
|
|
292
|
+
}
|
|
293
|
+
this.currentAbort?.abort()
|
|
294
|
+
this.currentAbort = null
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const snapshotIds = new WeakMap<Snapshot, number>()
|
|
299
|
+
|
|
300
|
+
export function tagSnapshot(snapshot: Snapshot, id: number): Snapshot {
|
|
301
|
+
snapshotIds.set(snapshot, id)
|
|
302
|
+
return snapshot
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
306
|
+
return new Promise((resolve, reject) => {
|
|
307
|
+
if (signal.aborted) {
|
|
308
|
+
reject(new DOMException('Aborted', 'AbortError'))
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
const timer = setTimeout(() => {
|
|
312
|
+
signal.removeEventListener('abort', onAbort)
|
|
313
|
+
resolve()
|
|
314
|
+
}, ms)
|
|
315
|
+
const onAbort = () => {
|
|
316
|
+
clearTimeout(timer)
|
|
317
|
+
signal.removeEventListener('abort', onAbort)
|
|
318
|
+
reject(new DOMException('Aborted', 'AbortError'))
|
|
319
|
+
}
|
|
320
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
321
|
+
})
|
|
322
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy pub/sub for window-focus and reconnect events.
|
|
3
|
+
*
|
|
4
|
+
* Each `ClientEntry` with `refetchOnWindowFocus` or `refetchOnReconnect` set
|
|
5
|
+
* subscribes here on its first subscriber and unsubscribes when it has none.
|
|
6
|
+
* We install a single window/document listener for each event the first time
|
|
7
|
+
* anyone subscribes; after that, we fan out to all subscribers ourselves.
|
|
8
|
+
*
|
|
9
|
+
* SSR-safe: no-ops when `window` is undefined. Spec §5.9.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type Sub = () => void
|
|
13
|
+
|
|
14
|
+
const focusSubs = new Set<Sub>()
|
|
15
|
+
const onlineSubs = new Set<Sub>()
|
|
16
|
+
|
|
17
|
+
let focusInstalled = false
|
|
18
|
+
let onlineInstalled = false
|
|
19
|
+
|
|
20
|
+
function fireFocus(): void {
|
|
21
|
+
for (const fn of focusSubs) {
|
|
22
|
+
try {
|
|
23
|
+
fn()
|
|
24
|
+
} catch {
|
|
25
|
+
// Subscriber failures must not break the fan-out.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fireOnline(): void {
|
|
31
|
+
for (const fn of onlineSubs) {
|
|
32
|
+
try {
|
|
33
|
+
fn()
|
|
34
|
+
} catch {
|
|
35
|
+
// ditto
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ensureFocusInstalled(): void {
|
|
41
|
+
if (focusInstalled) return
|
|
42
|
+
if (typeof window === 'undefined') return
|
|
43
|
+
window.addEventListener('focus', fireFocus)
|
|
44
|
+
if (typeof document !== 'undefined') {
|
|
45
|
+
document.addEventListener('visibilitychange', () => {
|
|
46
|
+
if (document.visibilityState === 'visible') fireFocus()
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
focusInstalled = true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ensureOnlineInstalled(): void {
|
|
53
|
+
if (onlineInstalled) return
|
|
54
|
+
if (typeof window === 'undefined') return
|
|
55
|
+
window.addEventListener('online', fireOnline)
|
|
56
|
+
onlineInstalled = true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function subscribeWindowFocus(fn: Sub): () => void {
|
|
60
|
+
ensureFocusInstalled()
|
|
61
|
+
focusSubs.add(fn)
|
|
62
|
+
return () => {
|
|
63
|
+
focusSubs.delete(fn)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function subscribeReconnect(fn: Sub): () => void {
|
|
68
|
+
ensureOnlineInstalled()
|
|
69
|
+
onlineSubs.add(fn)
|
|
70
|
+
return () => {
|
|
71
|
+
onlineSubs.delete(fn)
|
|
72
|
+
}
|
|
73
|
+
}
|