@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,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
+ }