pulse-framework 0.1.44__py3-none-any.whl → 0.1.46__py3-none-any.whl

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.
pulse/queries/query.py CHANGED
@@ -5,6 +5,7 @@ import time
5
5
  from collections.abc import Awaitable, Callable
6
6
  from dataclasses import dataclass
7
7
  from typing import (
8
+ TYPE_CHECKING,
8
9
  Any,
9
10
  Generic,
10
11
  TypeVar,
@@ -33,9 +34,12 @@ from pulse.queries.common import (
33
34
  bind_state,
34
35
  )
35
36
  from pulse.queries.effect import AsyncQueryEffect
36
- from pulse.reactive import AsyncEffect, Computed, Effect, Signal, Untrack
37
+ from pulse.reactive import Computed, Effect, Signal, Untrack
37
38
  from pulse.state import InitializableProperty, State
38
39
 
40
+ if TYPE_CHECKING:
41
+ from pulse.queries.protocol import QueryResult
42
+
39
43
  T = TypeVar("T")
40
44
  TState = TypeVar("TState", bound=State)
41
45
 
@@ -52,14 +56,12 @@ class QueryConfig(Generic[T]):
52
56
  on_dispose: Callable[[Any], None] | None
53
57
 
54
58
 
55
- class Query(Generic[T], Disposable):
59
+ class QueryState(Generic[T]):
56
60
  """
57
- Represents a single query instance in a store.
58
- Manages the async effect, data/status signals, and observer tracking.
61
+ Container for query state signals and manipulation methods.
62
+ Used by both KeyedQuery and UnkeyedQuery via composition.
59
63
  """
60
64
 
61
- key: QueryKey | None
62
- fn: Callable[[], Awaitable[T]]
63
65
  cfg: QueryConfig[T]
64
66
 
65
67
  # Reactive signals for query state
@@ -71,14 +73,9 @@ class Query(Generic[T], Disposable):
71
73
  retries: Signal[int]
72
74
  retry_reason: Signal[Exception | None]
73
75
 
74
- _observers: "list[QueryResult[T]]"
75
- _effect: AsyncEffect | None
76
- _gc_handle: asyncio.TimerHandle | None
77
-
78
76
  def __init__(
79
77
  self,
80
- key: QueryKey | None,
81
- fn: Callable[[], Awaitable[T]],
78
+ name: str,
82
79
  retries: int = 3,
83
80
  retry_delay: float = RETRY_DELAY_DEFAULT,
84
81
  initial_data: T | None = MISSING,
@@ -86,8 +83,6 @@ class Query(Generic[T], Disposable):
86
83
  gc_time: float = 300.0,
87
84
  on_dispose: Callable[[Any], None] | None = None,
88
85
  ):
89
- self.key = key
90
- self.fn = fn
91
86
  self.cfg = QueryConfig(
92
87
  retries=retries,
93
88
  retry_delay=retry_delay,
@@ -99,29 +94,25 @@ class Query(Generic[T], Disposable):
99
94
 
100
95
  # Initialize reactive signals
101
96
  self.data = Signal(
102
- None if initial_data is MISSING else initial_data, name=f"query.data({key})"
97
+ None if initial_data is MISSING else initial_data,
98
+ name=f"query.data({name})",
103
99
  )
104
- self.error = Signal(None, name=f"query.error({key})")
100
+ self.error = Signal(None, name=f"query.error({name})")
105
101
 
106
102
  self.last_updated = Signal(
107
103
  0.0,
108
- name=f"query.last_updated({key})",
104
+ name=f"query.last_updated({name})",
109
105
  )
110
106
  if initial_data_updated_at:
111
107
  self.set_updated_at(initial_data_updated_at)
112
108
 
113
109
  self.status = Signal(
114
110
  "loading" if initial_data is MISSING else "success",
115
- name=f"query.status({key})",
111
+ name=f"query.status({name})",
116
112
  )
117
- self.is_fetching = Signal(False, name=f"query.is_fetching({key})")
118
- self.retries = Signal(0, name=f"query.retries({key})")
119
- self.retry_reason = Signal(None, name=f"query.retry_reason({key})")
120
-
121
- self._observers = []
122
- self._gc_handle = None
123
- # Effect is created lazily on first observation
124
- self._effect = None
113
+ self.is_fetching = Signal(False, name=f"query.is_fetching({name})")
114
+ self.retries = Signal(0, name=f"query.retries({name})")
115
+ self.retry_reason = Signal(None, name=f"query.retry_reason({name})")
125
116
 
126
117
  def set_data(
127
118
  self,
@@ -132,7 +123,7 @@ class Query(Generic[T], Disposable):
132
123
  """Set data manually, accepting a value or updater function."""
133
124
  current = self.data.read()
134
125
  new_value = cast(T, data(current) if callable(data) else data)
135
- self._set_success(new_value, manual=True)
126
+ self.set_success(new_value, manual=True)
136
127
  if updated_at is not None:
137
128
  self.set_updated_at(updated_at)
138
129
 
@@ -158,11 +149,12 @@ class Query(Generic[T], Disposable):
158
149
  def set_error(
159
150
  self, error: Exception, *, updated_at: float | dt.datetime | None = None
160
151
  ):
161
- self._set_error(error, manual=True)
152
+ self.apply_error(error, manual=True)
162
153
  if updated_at is not None:
163
154
  self.set_updated_at(updated_at)
164
155
 
165
- def _set_success(self, data: T, manual: bool = False):
156
+ def set_success(self, data: T, manual: bool = False):
157
+ """Set success state with data."""
166
158
  self.data.write(data)
167
159
  self.last_updated.write(time.time())
168
160
  self.error.write(None)
@@ -172,105 +164,304 @@ class Query(Generic[T], Disposable):
172
164
  self.retries.write(0)
173
165
  self.retry_reason.write(None)
174
166
 
175
- def _set_error(self, error: Exception, manual: bool = False):
167
+ def apply_error(self, error: Exception, manual: bool = False):
168
+ """Apply error state to the query."""
176
169
  self.error.write(error)
177
170
  self.last_updated.write(time.time())
178
171
  self.status.write("error")
179
172
  if not manual:
180
173
  self.is_fetching.write(False)
181
174
  # Don't reset retries on final error - preserve for debugging
182
- # retry_reason is updated to the final error in _run
183
175
 
184
- def _failed_retry(self, reason: Exception):
176
+ def failed_retry(self, reason: Exception):
177
+ """Record a failed retry attempt."""
185
178
  self.retries.write(self.retries.read() + 1)
186
179
  self.retry_reason.write(reason)
187
180
 
188
- @property
189
- def effect(self) -> AsyncEffect:
190
- """Lazy property that creates the query effect on first access."""
191
- if self._effect is None:
192
- self._effect = AsyncQueryEffect(
193
- self._run,
194
- fetcher=self,
195
- name=f"query_effect({self.key})",
196
- deps=[] if self.key is not None else None,
197
- )
198
- return self._effect
199
-
200
- async def _run(self):
201
- # Reset retries at start of run
181
+ def reset_retries(self):
182
+ """Reset retry state at start of fetch."""
202
183
  self.retries.write(0)
203
184
  self.retry_reason.write(None)
204
185
 
205
- while True:
206
- try:
207
- result = await self.fn()
208
- self._set_success(result)
209
- for obs in self._observers:
210
- if obs._on_success: # pyright: ignore[reportPrivateUsage]
211
- await maybe_await(call_flexible(obs._on_success, result)) # pyright: ignore[reportPrivateUsage]
186
+
187
+ async def run_fetch_with_retries(
188
+ state: QueryState[T],
189
+ fetch_fn: Callable[[], Awaitable[T]],
190
+ on_success: Callable[[T], Awaitable[None] | None] | None = None,
191
+ on_error: Callable[[Exception], Awaitable[None] | None] | None = None,
192
+ untrack: bool = False,
193
+ ) -> None:
194
+ """
195
+ Execute a fetch with retry logic, updating QueryState.
196
+
197
+ Args:
198
+ state: The QueryState to update
199
+ fetch_fn: Async function to fetch data
200
+ on_success: Optional callback on success
201
+ on_error: Optional callback on error
202
+ untrack: If True, wrap fetch_fn in Untrack() to prevent dependency tracking.
203
+ Use for keyed queries where fetch is triggered via asyncio.create_task.
204
+ """
205
+ state.reset_retries()
206
+
207
+ while True:
208
+ try:
209
+ if untrack:
210
+ with Untrack():
211
+ result = await fetch_fn()
212
+ else:
213
+ result = await fetch_fn()
214
+ state.set_success(result)
215
+ if on_success:
216
+ await maybe_await(call_flexible(on_success, result))
217
+ return
218
+ except asyncio.CancelledError:
219
+ raise
220
+ except Exception as e:
221
+ current_retries = state.retries.read()
222
+ if current_retries < state.cfg.retries:
223
+ state.failed_retry(e)
224
+ await asyncio.sleep(state.cfg.retry_delay)
225
+ else:
226
+ state.retry_reason.write(e)
227
+ state.apply_error(e)
228
+ if on_error:
229
+ await maybe_await(call_flexible(on_error, e))
212
230
  return
231
+
232
+
233
+ class KeyedQuery(Generic[T], Disposable):
234
+ """
235
+ Query for keyed queries (shared across observers).
236
+ Uses direct task management without dependency tracking.
237
+ Multiple observers can share the same query.
238
+ """
239
+
240
+ key: QueryKey
241
+ state: QueryState[T]
242
+ observers: "list[KeyedQueryResult[T]]"
243
+ _task: asyncio.Task[None] | None
244
+ _task_initiator: "KeyedQueryResult[T] | None"
245
+ _gc_handle: asyncio.TimerHandle | None
246
+
247
+ def __init__(
248
+ self,
249
+ key: QueryKey,
250
+ retries: int = 3,
251
+ retry_delay: float = RETRY_DELAY_DEFAULT,
252
+ initial_data: T | None = MISSING,
253
+ initial_data_updated_at: float | dt.datetime | None = None,
254
+ gc_time: float = 300.0,
255
+ on_dispose: Callable[[Any], None] | None = None,
256
+ ):
257
+ self.key = key
258
+ self.state = QueryState(
259
+ name=str(key),
260
+ retries=retries,
261
+ retry_delay=retry_delay,
262
+ initial_data=initial_data,
263
+ initial_data_updated_at=initial_data_updated_at,
264
+ gc_time=gc_time,
265
+ on_dispose=on_dispose,
266
+ )
267
+ self.observers = []
268
+ self._task = None
269
+ self._task_initiator = None
270
+ self._gc_handle = None
271
+
272
+ # --- Delegate signal access to state ---
273
+ @property
274
+ def data(self) -> Signal[T | None]:
275
+ return self.state.data
276
+
277
+ @property
278
+ def error(self) -> Signal[Exception | None]:
279
+ return self.state.error
280
+
281
+ @property
282
+ def last_updated(self) -> Signal[float]:
283
+ return self.state.last_updated
284
+
285
+ @property
286
+ def status(self) -> Signal[QueryStatus]:
287
+ return self.state.status
288
+
289
+ @property
290
+ def is_fetching(self) -> Signal[bool]:
291
+ return self.state.is_fetching
292
+
293
+ @property
294
+ def retries(self) -> Signal[int]:
295
+ return self.state.retries
296
+
297
+ @property
298
+ def retry_reason(self) -> Signal[Exception | None]:
299
+ return self.state.retry_reason
300
+
301
+ @property
302
+ def cfg(self) -> QueryConfig[T]:
303
+ return self.state.cfg
304
+
305
+ # --- Delegate state methods ---
306
+ def set_data(
307
+ self,
308
+ data: T | Callable[[T | None], T],
309
+ *,
310
+ updated_at: float | dt.datetime | None = None,
311
+ ):
312
+ self.state.set_data(data, updated_at=updated_at)
313
+
314
+ def set_initial_data(
315
+ self,
316
+ data: T | Callable[[], T],
317
+ *,
318
+ updated_at: float | dt.datetime | None = None,
319
+ ):
320
+ self.state.set_initial_data(data, updated_at=updated_at)
321
+
322
+ def set_error(
323
+ self, error: Exception, *, updated_at: float | dt.datetime | None = None
324
+ ):
325
+ self.state.set_error(error, updated_at=updated_at)
326
+
327
+ # --- Query-specific methods ---
328
+ @property
329
+ def is_scheduled(self) -> bool:
330
+ """Check if a fetch is currently scheduled/running."""
331
+ return self._task is not None and not self._task.done()
332
+
333
+ async def _run_fetch(
334
+ self,
335
+ fetch_fn: Callable[[], Awaitable[T]],
336
+ observers: "list[KeyedQueryResult[T]]",
337
+ ) -> None:
338
+ """Execute the fetch with retry logic."""
339
+
340
+ async def on_success(result: T):
341
+ for obs in observers:
342
+ if obs._on_success: # pyright: ignore[reportPrivateUsage]
343
+ await maybe_await(call_flexible(obs._on_success, result)) # pyright: ignore[reportPrivateUsage]
344
+
345
+ async def on_error(e: Exception):
346
+ for obs in observers:
347
+ if obs._on_error: # pyright: ignore[reportPrivateUsage]
348
+ await maybe_await(call_flexible(obs._on_error, e)) # pyright: ignore[reportPrivateUsage]
349
+
350
+ await run_fetch_with_retries(
351
+ self.state,
352
+ fetch_fn,
353
+ on_success=on_success,
354
+ on_error=on_error,
355
+ untrack=True, # Keyed queries use asyncio.create_task, need to untrack
356
+ )
357
+
358
+ def run_fetch(
359
+ self,
360
+ fetch_fn: Callable[[], Awaitable[T]],
361
+ cancel_previous: bool = True,
362
+ initiator: "KeyedQueryResult[T] | None" = None,
363
+ ) -> asyncio.Task[None]:
364
+ """
365
+ Start a fetch with the given fetch function.
366
+ Cancels any in-flight fetch if cancel_previous is True.
367
+
368
+ Args:
369
+ fetch_fn: The async function to fetch data.
370
+ cancel_previous: If True, cancels any in-flight fetch before starting.
371
+ initiator: The KeyedQueryResult observer that initiated this fetch (for cancellation tracking).
372
+ """
373
+ if cancel_previous and self._task and not self._task.done():
374
+ self._task.cancel()
375
+
376
+ self.state.is_fetching.write(True)
377
+ # Capture current observers at fetch start
378
+ observers = list(self.observers)
379
+ self._task = asyncio.create_task(self._run_fetch(fetch_fn, observers))
380
+ self._task_initiator = initiator
381
+ return self._task
382
+
383
+ async def wait(self) -> ActionResult[T]:
384
+ """Wait for the current fetch to complete."""
385
+ while self._task and not self._task.done():
386
+ try:
387
+ await self._task
213
388
  except asyncio.CancelledError:
214
- raise
215
- except Exception as e:
216
- current_retries = self.retries.read()
217
- if current_retries < self.cfg.retries:
218
- # Record failed retry attempt and retry
219
- self._failed_retry(e)
220
- # Wait before retrying
221
- await asyncio.sleep(self.cfg.retry_delay)
222
- else:
223
- # All retries exhausted - update retry_reason to final error
224
- self.retry_reason.write(e)
225
- self._set_error(e)
226
- for obs in self._observers:
227
- if obs._on_error: # pyright: ignore[reportPrivateUsage]
228
- await maybe_await(call_flexible(obs._on_error, e)) # pyright: ignore[reportPrivateUsage]
229
- return
389
+ # Task was cancelled (probably by a new refetch).
390
+ # If there's a new task, wait for that one instead.
391
+ # If no new task, re-raise the cancellation.
392
+ # Note: self._task may have been reassigned by run_fetch() after await
393
+ if self._task is None or self._task.done(): # pyright: ignore[reportUnnecessaryComparison]
394
+ raise
395
+ # Otherwise, loop and wait for the new task
396
+ # Return result based on current state
397
+ if self.state.status() == "error":
398
+ return ActionError(cast(Exception, self.state.error.read()))
399
+ return ActionSuccess(cast(T, self.state.data.read()))
400
+
401
+ def cancel(self) -> None:
402
+ """Cancel the current fetch if running."""
403
+ if self._task and not self._task.done():
404
+ self._task.cancel()
405
+ self._task = None
406
+ self._task_initiator = None
407
+
408
+ def _get_first_observer_fetch_fn(self) -> Callable[[], Awaitable[T]]:
409
+ """Get the fetch function from the first observer."""
410
+ if len(self.observers) == 0:
411
+ raise RuntimeError(
412
+ f"Query '{self.key}' has no observers. Cannot access fetch function."
413
+ )
414
+ return self.observers[0]._fetch_fn # pyright: ignore[reportPrivateUsage]
230
415
 
231
416
  async def refetch(self, cancel_refetch: bool = True) -> ActionResult[T]:
232
417
  """
233
418
  Reruns the query and returns the result.
234
- If cancel_refetch is True (default), cancels any in-flight request and starts a new one.
235
- If cancel_refetch is False, deduplicates requests if one is already in flight.
419
+ Uses the first observer's fetch function.
420
+
421
+ Note: Prefer calling refetch() on KeyedQueryResult to ensure the correct fetch function is used.
236
422
  """
423
+ fetch_fn = self._get_first_observer_fetch_fn()
237
424
  if cancel_refetch or not self.is_fetching():
238
- self.effect.schedule()
425
+ self.run_fetch(fetch_fn, cancel_previous=cancel_refetch)
239
426
  return await self.wait()
240
427
 
241
- async def wait(self) -> ActionResult[T]:
242
- # If loading and no task, schedule a refetch
243
- if self.status() == "loading" and not self.is_fetching():
244
- self.effect.schedule()
245
- await self.effect.wait()
246
- # Return result based on current state
247
- if self.status() == "error":
248
- return ActionError(cast(Exception, self.error.read()))
249
- return ActionSuccess(cast(T, self.data.read()))
250
-
251
428
  def invalidate(self, cancel_refetch: bool = False):
252
429
  """
253
430
  Marks query as stale. If there are active observers, triggers a refetch.
254
- """
255
- should_schedule = not self.effect.is_scheduled or cancel_refetch
256
- if should_schedule and len(self._observers) > 0:
257
- self.effect.schedule()
431
+ Uses the first observer's fetch function.
258
432
 
259
- def observe(
260
- self,
261
- observer: "QueryResult[T]",
262
- ):
263
- _ = self.effect # ensure effect is created
264
- self._observers.append(observer)
433
+ Note: Prefer calling invalidate() on KeyedQueryResult to ensure the correct fetch function is used.
434
+ """
435
+ if len(self.observers) > 0:
436
+ fetch_fn = self._get_first_observer_fetch_fn()
437
+ if not self.is_scheduled or cancel_refetch:
438
+ self.run_fetch(fetch_fn, cancel_previous=cancel_refetch)
439
+
440
+ def observe(self, observer: "KeyedQueryResult[T]"):
441
+ """Register an observer."""
442
+ self.observers.append(observer)
265
443
  self.cancel_gc()
266
444
  if observer._gc_time > 0: # pyright: ignore[reportPrivateUsage]
267
445
  self.cfg.gc_time = max(self.cfg.gc_time, observer._gc_time) # pyright: ignore[reportPrivateUsage]
268
446
 
269
- def unobserve(self, observer: "QueryResult[T]"):
447
+ def unobserve(self, observer: "KeyedQueryResult[T]"):
270
448
  """Unregister an observer. Schedules GC if no observers remain."""
271
- if observer in self._observers:
272
- self._observers.remove(observer)
273
- if len(self._observers) == 0:
449
+ if observer in self.observers:
450
+ self.observers.remove(observer)
451
+
452
+ # If the departing observer initiated the ongoing fetch, cancel it
453
+ if self._task_initiator is observer and self._task and not self._task.done():
454
+ self._task.cancel()
455
+ self._task = None
456
+ self._task_initiator = None
457
+ # Reschedule from another observer if any remain
458
+ if len(self.observers) > 0:
459
+ fetch_fn = self._get_first_observer_fetch_fn()
460
+ self.run_fetch(
461
+ fetch_fn, cancel_previous=False, initiator=self.observers[0]
462
+ )
463
+
464
+ if len(self.observers) == 0:
274
465
  self.schedule_gc()
275
466
 
276
467
  def schedule_gc(self):
@@ -287,43 +478,252 @@ class Query(Generic[T], Disposable):
287
478
 
288
479
  @override
289
480
  def dispose(self):
290
- """
291
- Cleans up the query entry, removing it from the store.
292
- """
293
- if self._effect:
294
- self._effect.dispose()
295
-
481
+ """Clean up the query, cancelling any in-flight fetch."""
482
+ self.cancel()
296
483
  if self.cfg.on_dispose:
297
484
  self.cfg.on_dispose(self)
298
485
 
299
486
 
300
- class QueryResult(Generic[T], Disposable):
487
+ class UnkeyedQueryResult(Generic[T], Disposable):
488
+ """
489
+ Query for unkeyed queries (single observer with dependency tracking).
490
+ Uses an AsyncEffect to track dependencies and re-run on changes.
491
+
492
+ Unlike KeyedQuery which separates the query from its observer (KeyedQueryResult),
493
+ UnkeyedQuery combines both since there's always exactly one observer.
301
494
  """
302
- Thin wrapper around Query that adds callbacks, staleness tracking,
303
- and observation lifecycle.
304
495
 
305
- For keyed queries, uses a Computed to resolve the correct query based on the key.
496
+ state: QueryState[T]
497
+ _effect: AsyncQueryEffect
498
+ _fetch_fn: Callable[[], Awaitable[T]]
499
+ _on_success: Callable[[T], Awaitable[None] | None] | None
500
+ _on_error: Callable[[Exception], Awaitable[None] | None] | None
501
+ _stale_time: float
502
+ _refetch_interval: float | None
503
+ _keep_previous_data: bool
504
+ _enabled: Signal[bool]
505
+ _interval_effect: Effect | None
506
+ _data_computed: Computed[T | None]
507
+
508
+ def __init__(
509
+ self,
510
+ fetch_fn: Callable[[], Awaitable[T]],
511
+ on_success: Callable[[T], Awaitable[None] | None] | None = None,
512
+ on_error: Callable[[Exception], Awaitable[None] | None] | None = None,
513
+ retries: int = 3,
514
+ retry_delay: float = RETRY_DELAY_DEFAULT,
515
+ initial_data: T | None = MISSING,
516
+ initial_data_updated_at: float | dt.datetime | None = None,
517
+ gc_time: float = 300.0,
518
+ stale_time: float = 0.0,
519
+ refetch_interval: float | None = None,
520
+ keep_previous_data: bool = False,
521
+ enabled: bool = True,
522
+ fetch_on_mount: bool = True,
523
+ ):
524
+ self.state = QueryState(
525
+ name="unkeyed",
526
+ retries=retries,
527
+ retry_delay=retry_delay,
528
+ initial_data=initial_data,
529
+ initial_data_updated_at=initial_data_updated_at,
530
+ gc_time=gc_time,
531
+ on_dispose=None,
532
+ )
533
+ self._fetch_fn = fetch_fn
534
+ self._on_success = on_success
535
+ self._on_error = on_error
536
+ self._stale_time = stale_time
537
+ self._refetch_interval = refetch_interval
538
+ self._keep_previous_data = keep_previous_data
539
+ self._enabled = Signal(enabled, name="query.enabled(unkeyed)")
540
+ self._interval_effect = None
541
+
542
+ # Create effect with auto-tracking (deps=None)
543
+ # Pass state as fetcher since it has the Signal attributes directly
544
+ self._effect = AsyncQueryEffect(
545
+ self._run,
546
+ fetcher=self.state,
547
+ name="unkeyed_query_effect",
548
+ deps=None, # Auto-track dependencies
549
+ lazy=True,
550
+ )
551
+
552
+ # Computed for keep_previous_data logic
553
+ self._data_computed = Computed(
554
+ self._data_computed_fn, name="query_data(unkeyed)"
555
+ )
556
+
557
+ # Schedule initial fetch if stale (untracked to avoid reactive loop)
558
+ with Untrack():
559
+ if enabled and fetch_on_mount and self.is_stale():
560
+ self.schedule()
561
+
562
+ # Set up interval effect if interval is specified
563
+ if refetch_interval is not None and refetch_interval > 0:
564
+ self._setup_interval_effect(refetch_interval)
565
+
566
+ def _setup_interval_effect(self, interval: float):
567
+ """Create an effect that invalidates the query at the specified interval."""
568
+
569
+ def interval_fn():
570
+ if self._enabled():
571
+ self.schedule()
572
+
573
+ self._interval_effect = Effect(
574
+ interval_fn,
575
+ name="query_interval(unkeyed)",
576
+ interval=interval,
577
+ immediate=True,
578
+ )
579
+
580
+ def _data_computed_fn(self, prev: T | None) -> T | None:
581
+ if self._keep_previous_data and self.state.status() != "success":
582
+ return prev
583
+ raw = self.state.data()
584
+ if raw is None:
585
+ return None
586
+ return raw
587
+
588
+ # --- Status properties ---
589
+ @property
590
+ def status(self) -> QueryStatus:
591
+ return self.state.status()
592
+
593
+ @property
594
+ def is_loading(self) -> bool:
595
+ return self.status == "loading"
596
+
597
+ @property
598
+ def is_success(self) -> bool:
599
+ return self.status == "success"
600
+
601
+ @property
602
+ def is_error(self) -> bool:
603
+ return self.status == "error"
604
+
605
+ @property
606
+ def is_fetching(self) -> bool:
607
+ return self.state.is_fetching()
608
+
609
+ @property
610
+ def error(self) -> Exception | None:
611
+ return self.state.error.read()
612
+
613
+ @property
614
+ def data(self) -> T | None:
615
+ return self._data_computed()
616
+
617
+ # --- State methods ---
618
+ def set_data(self, data: T | Callable[[T | None], T]):
619
+ """Optimistically set data without changing loading/error state."""
620
+ self.state.set_data(data)
621
+
622
+ def set_initial_data(
623
+ self,
624
+ data: T | Callable[[], T],
625
+ *,
626
+ updated_at: float | dt.datetime | None = None,
627
+ ):
628
+ """Seed initial data and optional freshness timestamp."""
629
+ self.state.set_initial_data(data, updated_at=updated_at)
630
+
631
+ def set_error(self, error: Exception):
632
+ """Set error state on the query."""
633
+ self.state.set_error(error)
634
+
635
+ def enable(self):
636
+ """Enable the query."""
637
+ self._enabled.write(True)
638
+
639
+ def disable(self):
640
+ """Disable the query, preventing it from fetching."""
641
+ self._enabled.write(False)
642
+
643
+ # --- Query operations ---
644
+ def is_stale(self) -> bool:
645
+ """Check if the query data is stale based on stale_time."""
646
+ return (time.time() - self.state.last_updated.read()) > self._stale_time
647
+
648
+ async def _run(self):
649
+ """Run the fetch through the effect (for dependency tracking)."""
650
+ # Unkeyed queries run inside AsyncEffect which has its own scope,
651
+ # so we don't need untrack=True here - deps should be tracked
652
+ await run_fetch_with_retries(
653
+ self.state,
654
+ self._fetch_fn,
655
+ on_success=self._on_success,
656
+ on_error=self._on_error,
657
+ untrack=False,
658
+ )
659
+
660
+ def schedule(self):
661
+ """Schedule the effect to run."""
662
+ self._effect.schedule()
663
+
664
+ @property
665
+ def is_scheduled(self) -> bool:
666
+ """Check if a fetch is currently scheduled/running."""
667
+ return self._effect.is_scheduled
668
+
669
+ async def refetch(self, cancel_refetch: bool = True) -> ActionResult[T]:
670
+ """Refetch the query data through the effect."""
671
+ if cancel_refetch:
672
+ self.cancel()
673
+ self.schedule()
674
+ return await self.wait()
675
+
676
+ async def wait(self) -> ActionResult[T]:
677
+ """Wait for the current query to complete."""
678
+ # If loading and no task, schedule a fetch
679
+ if self.state.status() == "loading" and not self.state.is_fetching():
680
+ self.schedule()
681
+ await self._effect.wait()
682
+ if self.state.status() == "error":
683
+ return ActionError(cast(Exception, self.state.error.read()))
684
+ return ActionSuccess(cast(T, self.state.data.read()))
685
+
686
+ def invalidate(self):
687
+ """Mark the query as stale and refetch through the effect."""
688
+ if not self.is_scheduled:
689
+ self.schedule()
690
+
691
+ def cancel(self) -> None:
692
+ """Cancel the current fetch if running."""
693
+ self._effect.cancel(cancel_interval=False)
694
+
695
+ @override
696
+ def dispose(self):
697
+ """Clean up the query and its effect."""
698
+ if self._interval_effect is not None:
699
+ self._interval_effect.dispose()
700
+ self._effect.dispose()
701
+
702
+
703
+ class KeyedQueryResult(Generic[T], Disposable):
704
+ """
705
+ Observer wrapper for keyed queries.
706
+ Handles observation lifecycle, staleness tracking, and provides query operations.
306
707
  """
307
708
 
308
- _query: Computed[Query[T]]
709
+ _query: Computed[KeyedQuery[T]]
710
+ _fetch_fn: Callable[[], Awaitable[T]]
309
711
  _stale_time: float
310
712
  _gc_time: float
311
713
  _refetch_interval: float | None
312
714
  _keep_previous_data: bool
313
715
  _on_success: Callable[[T], Awaitable[None] | None] | None
314
716
  _on_error: Callable[[Exception], Awaitable[None] | None] | None
315
- _callback_effect: Effect
316
717
  _observe_effect: Effect
317
718
  _interval_effect: Effect | None
318
719
  _data_computed: Computed[T | None]
319
- _disposed_data: T | None
320
720
  _enabled: Signal[bool]
321
721
  _fetch_on_mount: bool
322
- _is_observing: bool
323
722
 
324
723
  def __init__(
325
724
  self,
326
- query: Computed[Query[T]],
725
+ query: Computed[KeyedQuery[T]],
726
+ fetch_fn: Callable[[], Awaitable[T]],
327
727
  stale_time: float = 0.0,
328
728
  gc_time: float = 300.0,
329
729
  refetch_interval: float | None = None,
@@ -334,29 +734,30 @@ class QueryResult(Generic[T], Disposable):
334
734
  fetch_on_mount: bool = True,
335
735
  ):
336
736
  self._query = query
737
+ self._fetch_fn = fetch_fn
337
738
  self._stale_time = stale_time
338
739
  self._gc_time = gc_time
339
740
  self._refetch_interval = refetch_interval
340
741
  self._keep_previous_data = keep_previous_data
341
742
  self._on_success = on_success
342
743
  self._on_error = on_error
343
- self._disposed_data = None
344
744
  self._enabled = Signal(enabled, name=f"query.enabled({query().key})")
345
745
  self._interval_effect = None
346
746
 
347
747
  def observe_effect():
348
- query = self._query()
748
+ q = self._query()
349
749
  enabled = self._enabled()
750
+
350
751
  with Untrack():
351
- query.observe(self)
752
+ q.observe(self)
352
753
 
353
- # If stale or loading, schedule refetch (only when enabled)
354
- if enabled and fetch_on_mount and self.is_stale():
355
- query.invalidate()
754
+ # If stale or loading, schedule refetch (only when enabled)
755
+ if enabled and fetch_on_mount and self.is_stale():
756
+ self.invalidate()
356
757
 
357
- # Return cleanup function that captures the observer
758
+ # Return cleanup function that captures the query (old query on key change)
358
759
  def cleanup():
359
- query.unobserve(self)
760
+ q.unobserve(self)
360
761
 
361
762
  return cleanup
362
763
 
@@ -379,7 +780,7 @@ class QueryResult(Generic[T], Disposable):
379
780
  def interval_fn():
380
781
  # Read enabled to make this effect reactive to enabled changes
381
782
  if self._enabled():
382
- self._query().invalidate()
783
+ self.invalidate()
383
784
 
384
785
  self._interval_effect = Effect(
385
786
  interval_fn,
@@ -392,7 +793,6 @@ class QueryResult(Generic[T], Disposable):
392
793
  def status(self) -> QueryStatus:
393
794
  return self._query().status()
394
795
 
395
- # Forward property reads to the query's signals (with automatic reactive tracking)
396
796
  @property
397
797
  def is_loading(self) -> bool:
398
798
  return self.status == "loading"
@@ -409,6 +809,10 @@ class QueryResult(Generic[T], Disposable):
409
809
  def is_fetching(self) -> bool:
410
810
  return self._query().is_fetching()
411
811
 
812
+ @property
813
+ def is_scheduled(self) -> bool:
814
+ return self._query().is_scheduled
815
+
412
816
  @property
413
817
  def error(self) -> Exception | None:
414
818
  return self._query().error.read()
@@ -432,16 +836,31 @@ class QueryResult(Generic[T], Disposable):
432
836
  return (time.time() - query.last_updated.read()) > self._stale_time
433
837
 
434
838
  async def refetch(self, cancel_refetch: bool = True) -> ActionResult[T]:
435
- """Refetch the query data."""
436
- return await self._query().refetch(cancel_refetch=cancel_refetch)
839
+ """
840
+ Refetch the query data using this observer's fetch function.
841
+ If cancel_refetch is True (default), cancels any in-flight request and starts a new one.
842
+ If cancel_refetch is False, deduplicates requests if one is already in flight.
843
+ """
844
+ query = self._query()
845
+ if cancel_refetch or not query.is_fetching():
846
+ query.run_fetch(
847
+ self._fetch_fn, cancel_previous=cancel_refetch, initiator=self
848
+ )
849
+ return await self.wait()
437
850
 
438
851
  async def wait(self) -> ActionResult[T]:
439
- return await self._query().wait()
852
+ """Wait for the current query to complete."""
853
+ query = self._query()
854
+ # If loading and no task, start a fetch with this observer's fetch function
855
+ if query.status() == "loading" and not query.is_fetching():
856
+ query.run_fetch(self._fetch_fn, initiator=self)
857
+ return await query.wait()
440
858
 
441
859
  def invalidate(self):
442
- """Mark the query as stale and refetch if there are observers."""
860
+ """Mark the query as stale and refetch using this observer's fetch function."""
443
861
  query = self._query()
444
- query.invalidate()
862
+ if not query.is_scheduled and len(query.observers) > 0:
863
+ query.run_fetch(self._fetch_fn, cancel_previous=False, initiator=self)
445
864
 
446
865
  def set_data(self, data: T | Callable[[T | None], T]):
447
866
  """Optimistically set data without changing loading/error state."""
@@ -474,9 +893,10 @@ class QueryResult(Generic[T], Disposable):
474
893
  @override
475
894
  def dispose(self):
476
895
  """Clean up the result and its observe effect."""
477
- if self._interval_effect is not None:
896
+ if self._interval_effect is not None and not self._interval_effect.__disposed__:
478
897
  self._interval_effect.dispose()
479
- self._observe_effect.dispose()
898
+ if not self._observe_effect.__disposed__:
899
+ self._observe_effect.dispose()
480
900
 
481
901
 
482
902
  class QueryProperty(Generic[T, TState], InitializableProperty):
@@ -583,9 +1003,13 @@ class QueryProperty(Generic[T, TState], InitializableProperty):
583
1003
  return fn
584
1004
 
585
1005
  @override
586
- def initialize(self, state: Any, name: str) -> QueryResult[T]:
1006
+ def initialize(
1007
+ self, state: Any, name: str
1008
+ ) -> KeyedQueryResult[T] | UnkeyedQueryResult[T]:
587
1009
  # Return cached query instance if present
588
- result: QueryResult[T] | None = getattr(state, self._priv_result, None)
1010
+ result: KeyedQueryResult[T] | UnkeyedQueryResult[T] | None = getattr(
1011
+ state, self._priv_result, None
1012
+ )
589
1013
  if result:
590
1014
  # Don't re-initialize, just return the cached instance
591
1015
  return result
@@ -602,50 +1026,34 @@ class QueryProperty(Generic[T, TState], InitializableProperty):
602
1026
  )
603
1027
 
604
1028
  if self._key is None:
605
- # Unkeyed query: create private Query
606
- query = self._resolve_unkeyed(
1029
+ # Unkeyed query: create UnkeyedQuery with single observer
1030
+ result = self._create_unkeyed(
607
1031
  fetch_fn,
608
1032
  initial_data,
609
1033
  self._initial_data_updated_at,
1034
+ state,
610
1035
  )
611
1036
  else:
612
1037
  # Keyed query: use session-wide QueryStore
613
- query = self._resolve_keyed(
1038
+ result = self._create_keyed(
614
1039
  state,
615
1040
  fetch_fn,
616
1041
  initial_data,
617
1042
  self._initial_data_updated_at,
618
1043
  )
619
1044
 
620
- # Wrap query in QueryResult
621
- result = QueryResult[T](
622
- query=query,
623
- stale_time=self._stale_time,
624
- keep_previous_data=self._keep_previous_data,
625
- gc_time=self._gc_time,
626
- refetch_interval=self._refetch_interval,
627
- on_success=bind_state(state, self._on_success_fn)
628
- if self._on_success_fn
629
- else None,
630
- on_error=bind_state(state, self._on_error_fn)
631
- if self._on_error_fn
632
- else None,
633
- enabled=self._enabled,
634
- fetch_on_mount=self._fetch_on_mount,
635
- )
636
-
637
1045
  # Store result on the instance
638
1046
  setattr(state, self._priv_result, result)
639
1047
  return result
640
1048
 
641
- def _resolve_keyed(
1049
+ def _create_keyed(
642
1050
  self,
643
1051
  state: TState,
644
1052
  fetch_fn: Callable[[], Awaitable[T]],
645
1053
  initial_data: T | None,
646
1054
  initial_data_updated_at: float | dt.datetime | None,
647
- ) -> Computed[Query[T]]:
648
- """Create or get a keyed query from the session store using a Computed."""
1055
+ ) -> KeyedQueryResult[T]:
1056
+ """Create or get a keyed query from the session store."""
649
1057
  assert self._key is not None
650
1058
 
651
1059
  # Create a Computed for the key - passthrough for constant keys, reactive for function keys
@@ -662,39 +1070,67 @@ class QueryProperty(Generic[T, TState], InitializableProperty):
662
1070
  raise RuntimeError("No render session available")
663
1071
  store = render.query_store
664
1072
 
665
- def query() -> Query[T]:
1073
+ def query() -> KeyedQuery[T]:
666
1074
  key = key_computed()
667
- return store.ensure(
668
- key,
669
- fetch_fn,
670
- initial_data,
671
- initial_data_updated_at=initial_data_updated_at,
672
- gc_time=self._gc_time,
673
- retries=self._retries,
674
- retry_delay=self._retry_delay,
675
- )
676
-
677
- return Computed(query, name=f"query.{self.name}")
1075
+ # Use Untrack to avoid an error due to creating an Effect within a computed
1076
+ with Untrack():
1077
+ return store.ensure(
1078
+ key,
1079
+ initial_data,
1080
+ initial_data_updated_at=initial_data_updated_at,
1081
+ gc_time=self._gc_time,
1082
+ retries=self._retries,
1083
+ retry_delay=self._retry_delay,
1084
+ )
1085
+
1086
+ query_computed = Computed(query, name=f"query.{self.name}")
1087
+
1088
+ return KeyedQueryResult[T](
1089
+ query=query_computed,
1090
+ fetch_fn=fetch_fn,
1091
+ stale_time=self._stale_time,
1092
+ keep_previous_data=self._keep_previous_data,
1093
+ gc_time=self._gc_time,
1094
+ refetch_interval=self._refetch_interval,
1095
+ on_success=bind_state(state, self._on_success_fn)
1096
+ if self._on_success_fn
1097
+ else None,
1098
+ on_error=bind_state(state, self._on_error_fn)
1099
+ if self._on_error_fn
1100
+ else None,
1101
+ enabled=self._enabled,
1102
+ fetch_on_mount=self._fetch_on_mount,
1103
+ )
678
1104
 
679
- def _resolve_unkeyed(
1105
+ def _create_unkeyed(
680
1106
  self,
681
1107
  fetch_fn: Callable[[], Awaitable[T]],
682
1108
  initial_data: T | None,
683
1109
  initial_data_updated_at: float | dt.datetime | None,
684
- ) -> Computed[Query[T]]:
1110
+ state: TState,
1111
+ ) -> UnkeyedQueryResult[T]:
685
1112
  """Create a private unkeyed query."""
686
- query = Query[T](
687
- key=None,
688
- fn=fetch_fn,
1113
+ return UnkeyedQueryResult[T](
1114
+ fetch_fn=fetch_fn,
1115
+ on_success=bind_state(state, self._on_success_fn)
1116
+ if self._on_success_fn
1117
+ else None,
1118
+ on_error=bind_state(state, self._on_error_fn)
1119
+ if self._on_error_fn
1120
+ else None,
1121
+ retries=self._retries,
1122
+ retry_delay=self._retry_delay,
689
1123
  initial_data=initial_data,
690
1124
  initial_data_updated_at=initial_data_updated_at,
691
1125
  gc_time=self._gc_time,
692
- retries=self._retries,
693
- retry_delay=self._retry_delay,
1126
+ stale_time=self._stale_time,
1127
+ keep_previous_data=self._keep_previous_data,
1128
+ refetch_interval=self._refetch_interval,
1129
+ enabled=self._enabled,
1130
+ fetch_on_mount=self._fetch_on_mount,
694
1131
  )
695
- return Computed(lambda: query, name=f"query.{self.name}")
696
1132
 
697
- def __get__(self, obj: Any, objtype: Any = None) -> QueryResult[T]:
1133
+ def __get__(self, obj: Any, objtype: Any = None) -> "QueryResult[T]":
698
1134
  if obj is None:
699
1135
  return self # pyright: ignore[reportReturnType]
700
1136
  return self.initialize(obj, self.name)