@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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -0
  3. package/dist/index.cjs +363 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +178 -0
  6. package/dist/index.d.cts.map +1 -0
  7. package/dist/index.d.mts +178 -0
  8. package/dist/index.d.mts.map +1 -0
  9. package/dist/index.mjs +339 -0
  10. package/dist/index.mjs.map +1 -0
  11. package/dist/root-BImHnGj1.mjs +3270 -0
  12. package/dist/root-BImHnGj1.mjs.map +1 -0
  13. package/dist/root-Bazp5_Ik.cjs +3347 -0
  14. package/dist/root-Bazp5_Ik.cjs.map +1 -0
  15. package/dist/testing.cjs +81 -0
  16. package/dist/testing.cjs.map +1 -0
  17. package/dist/testing.d.cts +56 -0
  18. package/dist/testing.d.cts.map +1 -0
  19. package/dist/testing.d.mts +56 -0
  20. package/dist/testing.d.mts.map +1 -0
  21. package/dist/testing.mjs +78 -0
  22. package/dist/testing.mjs.map +1 -0
  23. package/dist/types-CAMgqCMz.d.mts +816 -0
  24. package/dist/types-CAMgqCMz.d.mts.map +1 -0
  25. package/dist/types-emq_lZd7.d.cts +816 -0
  26. package/dist/types-emq_lZd7.d.cts.map +1 -0
  27. package/package.json +47 -0
  28. package/src/__dev__.d.ts +8 -0
  29. package/src/controller/define.ts +50 -0
  30. package/src/controller/index.ts +12 -0
  31. package/src/controller/instance.ts +499 -0
  32. package/src/controller/root.ts +160 -0
  33. package/src/controller/types.ts +195 -0
  34. package/src/devtools.ts +0 -0
  35. package/src/emitter.ts +79 -0
  36. package/src/errors.ts +49 -0
  37. package/src/forms/field.ts +303 -0
  38. package/src/forms/form-types.ts +130 -0
  39. package/src/forms/form.ts +640 -0
  40. package/src/forms/index.ts +2 -0
  41. package/src/forms/types.ts +1 -0
  42. package/src/forms/validators.ts +70 -0
  43. package/src/index.ts +89 -0
  44. package/src/query/client.ts +934 -0
  45. package/src/query/define.ts +154 -0
  46. package/src/query/entry.ts +322 -0
  47. package/src/query/focus-online.ts +73 -0
  48. package/src/query/index.ts +3 -0
  49. package/src/query/infinite.ts +462 -0
  50. package/src/query/keys.ts +33 -0
  51. package/src/query/local.ts +113 -0
  52. package/src/query/mutation.ts +384 -0
  53. package/src/query/plugin.ts +135 -0
  54. package/src/query/types.ts +168 -0
  55. package/src/query/use.ts +321 -0
  56. package/src/scope.ts +42 -0
  57. package/src/selection.ts +146 -0
  58. package/src/signals/index.ts +3 -0
  59. package/src/signals/readonly.ts +22 -0
  60. package/src/signals/runtime.ts +115 -0
  61. package/src/signals/types.ts +31 -0
  62. package/src/testing.ts +142 -0
  63. package/src/timing/debounced.ts +32 -0
  64. package/src/timing/index.ts +2 -0
  65. package/src/timing/throttled.ts +46 -0
  66. 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
+ }
@@ -0,0 +1,3 @@
1
+ export type { LocalCacheOptions } from './local'
2
+ export { createLocalCache } from './local'
3
+ export type { AsyncState, AsyncStatus, LocalCache, Snapshot } from './types'