pulse-framework 0.1.44__py3-none-any.whl → 0.1.47__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.
Files changed (80) hide show
  1. pulse/__init__.py +10 -24
  2. pulse/app.py +3 -25
  3. pulse/codegen/codegen.py +43 -88
  4. pulse/codegen/js.py +35 -5
  5. pulse/codegen/templates/route.py +341 -254
  6. pulse/form.py +1 -1
  7. pulse/helpers.py +40 -8
  8. pulse/hooks/core.py +2 -2
  9. pulse/hooks/effects.py +1 -1
  10. pulse/hooks/init.py +2 -1
  11. pulse/hooks/setup.py +1 -1
  12. pulse/hooks/stable.py +2 -2
  13. pulse/hooks/states.py +2 -2
  14. pulse/html/props.py +3 -2
  15. pulse/html/tags.py +135 -0
  16. pulse/html/tags.pyi +4 -0
  17. pulse/js/__init__.py +110 -0
  18. pulse/js/__init__.pyi +95 -0
  19. pulse/js/_types.py +297 -0
  20. pulse/js/array.py +253 -0
  21. pulse/js/console.py +47 -0
  22. pulse/js/date.py +113 -0
  23. pulse/js/document.py +138 -0
  24. pulse/js/error.py +139 -0
  25. pulse/js/json.py +62 -0
  26. pulse/js/map.py +84 -0
  27. pulse/js/math.py +66 -0
  28. pulse/js/navigator.py +76 -0
  29. pulse/js/number.py +54 -0
  30. pulse/js/object.py +173 -0
  31. pulse/js/promise.py +150 -0
  32. pulse/js/regexp.py +54 -0
  33. pulse/js/set.py +109 -0
  34. pulse/js/string.py +35 -0
  35. pulse/js/weakmap.py +50 -0
  36. pulse/js/weakset.py +45 -0
  37. pulse/js/window.py +199 -0
  38. pulse/messages.py +22 -3
  39. pulse/queries/client.py +7 -7
  40. pulse/queries/effect.py +16 -0
  41. pulse/queries/infinite_query.py +138 -29
  42. pulse/queries/mutation.py +1 -15
  43. pulse/queries/protocol.py +136 -0
  44. pulse/queries/query.py +610 -174
  45. pulse/queries/store.py +11 -14
  46. pulse/react_component.py +167 -14
  47. pulse/reactive.py +19 -1
  48. pulse/reactive_extensions.py +5 -5
  49. pulse/render_session.py +185 -59
  50. pulse/renderer.py +80 -158
  51. pulse/routing.py +1 -18
  52. pulse/transpiler/__init__.py +131 -0
  53. pulse/transpiler/builtins.py +731 -0
  54. pulse/transpiler/constants.py +110 -0
  55. pulse/transpiler/context.py +26 -0
  56. pulse/transpiler/errors.py +2 -0
  57. pulse/transpiler/function.py +250 -0
  58. pulse/transpiler/ids.py +16 -0
  59. pulse/transpiler/imports.py +409 -0
  60. pulse/transpiler/js_module.py +274 -0
  61. pulse/transpiler/modules/__init__.py +30 -0
  62. pulse/transpiler/modules/asyncio.py +38 -0
  63. pulse/transpiler/modules/json.py +20 -0
  64. pulse/transpiler/modules/math.py +320 -0
  65. pulse/transpiler/modules/re.py +466 -0
  66. pulse/transpiler/modules/tags.py +268 -0
  67. pulse/transpiler/modules/typing.py +59 -0
  68. pulse/transpiler/nodes.py +1216 -0
  69. pulse/transpiler/py_module.py +119 -0
  70. pulse/transpiler/transpiler.py +938 -0
  71. pulse/transpiler/utils.py +4 -0
  72. pulse/types/event_handler.py +3 -2
  73. pulse/vdom.py +212 -13
  74. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/METADATA +1 -1
  75. pulse_framework-0.1.47.dist-info/RECORD +119 -0
  76. pulse/codegen/imports.py +0 -204
  77. pulse/css.py +0 -155
  78. pulse_framework-0.1.44.dist-info/RECORD +0 -79
  79. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/WHEEL +0 -0
  80. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/entry_points.txt +0 -0
@@ -57,6 +57,8 @@ class Page(NamedTuple, Generic[T, TParam]):
57
57
  class FetchNext(Generic[T, TParam]):
58
58
  """Fetch the next page."""
59
59
 
60
+ fetch_fn: Callable[[TParam], Awaitable[T]]
61
+ observer: "InfiniteQueryResult[T, TParam] | None" = None
60
62
  future: "asyncio.Future[ActionResult[Page[T, TParam] | None]]" = field(
61
63
  default_factory=asyncio.Future
62
64
  )
@@ -66,6 +68,8 @@ class FetchNext(Generic[T, TParam]):
66
68
  class FetchPrevious(Generic[T, TParam]):
67
69
  """Fetch the previous page."""
68
70
 
71
+ fetch_fn: Callable[[TParam], Awaitable[T]]
72
+ observer: "InfiniteQueryResult[T, TParam] | None" = None
69
73
  future: "asyncio.Future[ActionResult[Page[T, TParam] | None]]" = field(
70
74
  default_factory=asyncio.Future
71
75
  )
@@ -75,6 +79,8 @@ class FetchPrevious(Generic[T, TParam]):
75
79
  class Refetch(Generic[T, TParam]):
76
80
  """Refetch all pages."""
77
81
 
82
+ fetch_fn: Callable[[TParam], Awaitable[T]]
83
+ observer: "InfiniteQueryResult[T, TParam] | None" = None
78
84
  refetch_page: Callable[[T, int, list[T]], bool] | None = None
79
85
  future: "asyncio.Future[ActionResult[list[Page[T, TParam]]]]" = field(
80
86
  default_factory=asyncio.Future
@@ -85,7 +91,9 @@ class Refetch(Generic[T, TParam]):
85
91
  class RefetchPage(Generic[T, TParam]):
86
92
  """Refetch a single page by param."""
87
93
 
94
+ fetch_fn: Callable[[TParam], Awaitable[T]]
88
95
  param: TParam
96
+ observer: "InfiniteQueryResult[T, TParam] | None" = None
89
97
  future: "asyncio.Future[ActionResult[T | None]]" = field(
90
98
  default_factory=asyncio.Future
91
99
  )
@@ -113,9 +121,17 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
113
121
  """Paginated query that stores data as a list of Page(data, param)."""
114
122
 
115
123
  key: QueryKey
116
- fn: Callable[[TParam], Awaitable[T]]
117
124
  cfg: InfiniteQueryConfig[T, TParam]
118
125
 
126
+ @property
127
+ def fn(self) -> Callable[[TParam], Awaitable[T]]:
128
+ """Get the fetch function from the first observer."""
129
+ if len(self._observers) == 0:
130
+ raise RuntimeError(
131
+ f"InfiniteQuery '{self.key}' has no observers. Cannot access fetch function."
132
+ )
133
+ return self._observers[0]._fetch_fn # pyright: ignore[reportPrivateUsage]
134
+
119
135
  # Reactive state
120
136
  pages: ReactiveList[Page[T, TParam]]
121
137
  error: Signal[Exception | None]
@@ -139,7 +155,6 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
139
155
  def __init__(
140
156
  self,
141
157
  key: QueryKey,
142
- fn: Callable[[TParam], Awaitable[T]],
143
158
  *,
144
159
  initial_page_param: TParam,
145
160
  get_next_page_param: Callable[[list[Page[T, TParam]]], TParam | None],
@@ -155,7 +170,6 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
155
170
  on_dispose: Callable[[Any], None] | None = None,
156
171
  ):
157
172
  self.key = key
158
- self.fn = fn
159
173
 
160
174
  self.cfg = InfiniteQueryConfig(
161
175
  retries=retries,
@@ -287,12 +301,18 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
287
301
  if updated_at is not None:
288
302
  self.set_updated_at(updated_at)
289
303
 
290
- async def wait(self) -> ActionResult[list[Page[T, TParam]]]:
304
+ async def wait(
305
+ self,
306
+ fetch_fn: Callable[[TParam], Awaitable[T]] | None = None,
307
+ observer: "InfiniteQueryResult[T, TParam] | None" = None,
308
+ ) -> ActionResult[list[Page[T, TParam]]]:
291
309
  """Wait for initial data or until queue is empty."""
292
310
  # If no data and loading, enqueue initial fetch (unless already processing)
293
311
  if len(self.pages) == 0 and self.status() == "loading":
294
312
  if self._queue_task is None or self._queue_task.done():
295
- self._enqueue(Refetch())
313
+ # Use provided fetch_fn or fall back to first observer's fetch_fn
314
+ fn = fetch_fn if fetch_fn is not None else self.fn
315
+ self._enqueue(Refetch(fetch_fn=fn, observer=observer))
296
316
  # Wait for any in-progress queue processing
297
317
  if self._queue_task and not self._queue_task.done():
298
318
  await self._queue_task
@@ -308,9 +328,14 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
308
328
  if gc_time and gc_time > 0:
309
329
  self.cfg.gc_time = max(self.cfg.gc_time, gc_time)
310
330
 
311
- def unobserve(self, observer: Any):
331
+ def unobserve(self, observer: "InfiniteQueryResult[T, TParam]"):
332
+ """Unregister an observer. Cancels pending actions. Schedules GC if no observers remain."""
312
333
  if observer in self._observers:
313
334
  self._observers.remove(observer)
335
+
336
+ # Cancel pending actions from this observer
337
+ self._cancel_observer_actions(observer)
338
+
314
339
  if len(self._observers) == 0:
315
340
  self.schedule_gc()
316
341
 
@@ -319,12 +344,18 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
319
344
  *,
320
345
  cancel_fetch: bool = False,
321
346
  refetch_page: Callable[[T, int, list[T]], bool] | None = None,
347
+ fetch_fn: Callable[[TParam], Awaitable[T]] | None = None,
348
+ observer: "InfiniteQueryResult[T, TParam] | None" = None,
322
349
  ):
323
350
  """Enqueue a refetch. Synchronous - does not wait for completion."""
324
351
  if cancel_fetch:
325
352
  self._cancel_queue()
326
353
  if len(self._observers) > 0:
327
- self._enqueue(Refetch(refetch_page=refetch_page))
354
+ # Use provided fetch_fn or fall back to first observer's fetch_fn
355
+ fn = fetch_fn if fetch_fn is not None else self.fn
356
+ self._enqueue(
357
+ Refetch(fetch_fn=fn, observer=observer, refetch_page=refetch_page)
358
+ )
328
359
 
329
360
  def schedule_gc(self):
330
361
  self.cancel_gc()
@@ -403,6 +434,26 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
403
434
  self._queue_task.cancel()
404
435
  self._queue_task = None
405
436
 
437
+ def _cancel_observer_actions(
438
+ self, observer: "InfiniteQueryResult[T, TParam]"
439
+ ) -> None:
440
+ """Cancel pending actions from a specific observer.
441
+
442
+ Note: Does not cancel the currently executing action to avoid disrupting the
443
+ queue processor. The fetch will complete but results will be ignored since
444
+ the observer is disposed.
445
+ """
446
+ # Cancel pending actions from this observer (not the currently executing one)
447
+ remaining: deque[Action[T, TParam]] = deque()
448
+ while self._queue:
449
+ action = self._queue.popleft()
450
+ if action.observer is observer:
451
+ if not action.future.done():
452
+ action.future.cancel()
453
+ else:
454
+ remaining.append(action)
455
+ self._queue = remaining
456
+
406
457
  def _enqueue(
407
458
  self,
408
459
  action: "FetchNext[T, TParam] | FetchPrevious[T, TParam] | Refetch[T, TParam] | RefetchPage[T, TParam]",
@@ -493,7 +544,7 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
493
544
  self.has_next_page.write(False)
494
545
  return None
495
546
 
496
- page = await self.fn(next_param)
547
+ page = await action.fetch_fn(next_param)
497
548
  page = Page(page, next_param)
498
549
  self.pages.append(page)
499
550
  self._trim_front()
@@ -508,7 +559,7 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
508
559
  self.has_previous_page.write(False)
509
560
  return None
510
561
 
511
- data = await self.fn(prev_param)
562
+ data = await action.fetch_fn(prev_param)
512
563
  page = Page(data, prev_param)
513
564
  self.pages.insert(0, page)
514
565
  self._trim_back()
@@ -519,7 +570,7 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
519
570
  self, action: "Refetch[T, TParam]"
520
571
  ) -> list[Page[T, TParam]]:
521
572
  if len(self.pages) == 0:
522
- page = await self.fn(self.cfg.initial_page_param)
573
+ page = await action.fetch_fn(self.cfg.initial_page_param)
523
574
  self.pages.append(Page(page, self.cfg.initial_page_param))
524
575
  await self.commit()
525
576
  return self.pages
@@ -538,7 +589,7 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
538
589
  )
539
590
 
540
591
  if should_refetch:
541
- page = await self.fn(page_param)
592
+ page = await action.fetch_fn(page_param)
542
593
  else:
543
594
  page = old_page.data
544
595
  self.pages[idx] = Page(page, page_param)
@@ -562,7 +613,7 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
562
613
  if idx is None:
563
614
  return None
564
615
 
565
- page = await self.fn(action.param)
616
+ page = await action.fetch_fn(action.param)
566
617
  self.pages[idx] = Page(page, action.param)
567
618
  await self.commit()
568
619
  return page
@@ -573,40 +624,80 @@ class InfiniteQuery(Generic[T, TParam], Disposable):
573
624
 
574
625
  async def fetch_next_page(
575
626
  self,
627
+ fetch_fn: Callable[[TParam], Awaitable[T]] | None = None,
576
628
  *,
629
+ observer: "InfiniteQueryResult[T, TParam] | None" = None,
577
630
  cancel_fetch: bool = False,
578
631
  ) -> ActionResult[Page[T, TParam] | None]:
579
- """Fetch the next page. Queued for sequential execution."""
580
- action: FetchNext[T, TParam] = FetchNext()
632
+ """
633
+ Fetch the next page. Queued for sequential execution.
634
+
635
+ Note: Prefer calling fetch_next_page() on InfiniteQueryResult to ensure the
636
+ correct fetch function is used. When called directly on InfiniteQuery, uses
637
+ the first observer's fetch function if not provided.
638
+ """
639
+ fn = fetch_fn if fetch_fn is not None else self.fn
640
+ action: FetchNext[T, TParam] = FetchNext(fetch_fn=fn, observer=observer)
581
641
  return await self._enqueue(action, cancel_fetch=cancel_fetch)
582
642
 
583
643
  async def fetch_previous_page(
584
644
  self,
645
+ fetch_fn: Callable[[TParam], Awaitable[T]] | None = None,
585
646
  *,
647
+ observer: "InfiniteQueryResult[T, TParam] | None" = None,
586
648
  cancel_fetch: bool = False,
587
649
  ) -> ActionResult[Page[T, TParam] | None]:
588
- """Fetch the previous page. Queued for sequential execution."""
589
- action: FetchPrevious[T, TParam] = FetchPrevious()
650
+ """
651
+ Fetch the previous page. Queued for sequential execution.
652
+
653
+ Note: Prefer calling fetch_previous_page() on InfiniteQueryResult to ensure
654
+ the correct fetch function is used. When called directly on InfiniteQuery,
655
+ uses the first observer's fetch function if not provided.
656
+ """
657
+ fn = fetch_fn if fetch_fn is not None else self.fn
658
+ action: FetchPrevious[T, TParam] = FetchPrevious(fetch_fn=fn, observer=observer)
590
659
  return await self._enqueue(action, cancel_fetch=cancel_fetch)
591
660
 
592
661
  async def refetch(
593
662
  self,
663
+ fetch_fn: Callable[[TParam], Awaitable[T]] | None = None,
594
664
  *,
665
+ observer: "InfiniteQueryResult[T, TParam] | None" = None,
595
666
  cancel_fetch: bool = False,
596
667
  refetch_page: Callable[[T, int, list[T]], bool] | None = None,
597
668
  ) -> ActionResult[list[Page[T, TParam]]]:
598
- """Refetch all pages. Queued for sequential execution."""
599
- action: Refetch[T, TParam] = Refetch(refetch_page=refetch_page)
669
+ """
670
+ Refetch all pages. Queued for sequential execution.
671
+
672
+ Note: Prefer calling refetch() on InfiniteQueryResult to ensure the correct
673
+ fetch function is used. When called directly on InfiniteQuery, uses the first
674
+ observer's fetch function if not provided.
675
+ """
676
+ fn = fetch_fn if fetch_fn is not None else self.fn
677
+ action: Refetch[T, TParam] = Refetch(
678
+ fetch_fn=fn, observer=observer, refetch_page=refetch_page
679
+ )
600
680
  return await self._enqueue(action, cancel_fetch=cancel_fetch)
601
681
 
602
682
  async def refetch_page(
603
683
  self,
604
684
  param: TParam,
685
+ fetch_fn: Callable[[TParam], Awaitable[T]] | None = None,
605
686
  *,
687
+ observer: "InfiniteQueryResult[T, TParam] | None" = None,
606
688
  cancel_fetch: bool = False,
607
689
  ) -> ActionResult[T | None]:
608
- """Refetch an existing page by its param. Queued for sequential execution."""
609
- action: RefetchPage[T, TParam] = RefetchPage(param=param)
690
+ """
691
+ Refetch an existing page by its param. Queued for sequential execution.
692
+
693
+ Note: Prefer calling refetch_page() on InfiniteQueryResult to ensure the
694
+ correct fetch function is used. When called directly on InfiniteQuery, uses
695
+ the first observer's fetch function if not provided.
696
+ """
697
+ fn = fetch_fn if fetch_fn is not None else self.fn
698
+ action: RefetchPage[T, TParam] = RefetchPage(
699
+ fetch_fn=fn, param=param, observer=observer
700
+ )
610
701
  return await self._enqueue(action, cancel_fetch=cancel_fetch)
611
702
 
612
703
  @override
@@ -628,6 +719,7 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
628
719
  """
629
720
 
630
721
  _query: Computed[InfiniteQuery[T, TParam]]
722
+ _fetch_fn: Callable[[TParam], Awaitable[T]]
631
723
  _stale_time: float
632
724
  _gc_time: float
633
725
  _refetch_interval: float | None
@@ -643,6 +735,7 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
643
735
  def __init__(
644
736
  self,
645
737
  query: Computed[InfiniteQuery[T, TParam]],
738
+ fetch_fn: Callable[[TParam], Awaitable[T]],
646
739
  stale_time: float = 0.0,
647
740
  gc_time: float = 300.0,
648
741
  refetch_interval: float | None = None,
@@ -654,6 +747,7 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
654
747
  fetch_on_mount: bool = True,
655
748
  ):
656
749
  self._query = query
750
+ self._fetch_fn = fetch_fn
657
751
  self._stale_time = stale_time
658
752
  self._gc_time = gc_time
659
753
  self._refetch_interval = refetch_interval
@@ -667,12 +761,14 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
667
761
  def observe_effect():
668
762
  q = self._query()
669
763
  enabled = self._enabled()
764
+
670
765
  with Untrack():
671
766
  q.observe(self)
672
767
 
673
- if enabled and fetch_on_mount and self.is_stale():
674
- q.invalidate()
768
+ if enabled and fetch_on_mount and self.is_stale():
769
+ q.invalidate()
675
770
 
771
+ # Return cleanup function that captures the query (old query on key change)
676
772
  def cleanup():
677
773
  q.unobserve(self)
678
774
 
@@ -781,14 +877,18 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
781
877
  *,
782
878
  cancel_fetch: bool = False,
783
879
  ) -> ActionResult[Page[T, TParam] | None]:
784
- return await self._query().fetch_next_page(cancel_fetch=cancel_fetch)
880
+ return await self._query().fetch_next_page(
881
+ self._fetch_fn, observer=self, cancel_fetch=cancel_fetch
882
+ )
785
883
 
786
884
  async def fetch_previous_page(
787
885
  self,
788
886
  *,
789
887
  cancel_fetch: bool = False,
790
888
  ) -> ActionResult[Page[T, TParam] | None]:
791
- return await self._query().fetch_previous_page(cancel_fetch=cancel_fetch)
889
+ return await self._query().fetch_previous_page(
890
+ self._fetch_fn, observer=self, cancel_fetch=cancel_fetch
891
+ )
792
892
 
793
893
  async def fetch_page(
794
894
  self,
@@ -796,7 +896,12 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
796
896
  *,
797
897
  cancel_fetch: bool = False,
798
898
  ) -> ActionResult[T | None]:
799
- return await self._query().refetch_page(page_param, cancel_fetch=cancel_fetch)
899
+ return await self._query().refetch_page(
900
+ page_param,
901
+ fetch_fn=self._fetch_fn,
902
+ observer=self,
903
+ cancel_fetch=cancel_fetch,
904
+ )
800
905
 
801
906
  def set_initial_data(
802
907
  self,
@@ -820,15 +925,18 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
820
925
  refetch_page: Callable[[T, int, list[T]], bool] | None = None,
821
926
  ) -> ActionResult[list[Page[T, TParam]]]:
822
927
  return await self._query().refetch(
823
- cancel_fetch=cancel_fetch, refetch_page=refetch_page
928
+ self._fetch_fn,
929
+ observer=self,
930
+ cancel_fetch=cancel_fetch,
931
+ refetch_page=refetch_page,
824
932
  )
825
933
 
826
934
  async def wait(self) -> ActionResult[list[Page[T, TParam]]]:
827
- return await self._query().wait()
935
+ return await self._query().wait(fetch_fn=self._fetch_fn, observer=self)
828
936
 
829
937
  def invalidate(self):
830
938
  query = self._query()
831
- query.invalidate()
939
+ query.invalidate(fetch_fn=self._fetch_fn, observer=self)
832
940
 
833
941
  def enable(self):
834
942
  self._enabled.write(True)
@@ -842,6 +950,7 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
842
950
 
843
951
  @override
844
952
  def dispose(self):
953
+ """Clean up the result and its observe effect."""
845
954
  if self._interval_effect is not None:
846
955
  self._interval_effect.dispose()
847
956
  self._observe_effect.dispose()
@@ -1001,6 +1110,7 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1001
1110
 
1002
1111
  result = InfiniteQueryResult(
1003
1112
  query=query,
1113
+ fetch_fn=fetch_fn,
1004
1114
  stale_time=self._stale_time,
1005
1115
  keep_previous_data=self._keep_previous_data,
1006
1116
  gc_time=self._gc_time,
@@ -1048,7 +1158,6 @@ class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1048
1158
  InfiniteQuery[T, TParam],
1049
1159
  store.ensure_infinite(
1050
1160
  key,
1051
- fetch_fn,
1052
1161
  initial_page_param=self._initial_page_param,
1053
1162
  get_next_page_param=next_fn,
1054
1163
  get_previous_page_param=prev_fn,
pulse/queries/mutation.py CHANGED
@@ -147,18 +147,12 @@ class MutationProperty(Generic[T, TState, P], InitializableProperty):
147
147
  @overload
148
148
  def mutation(
149
149
  fn: Callable[Concatenate[TState, P], Awaitable[T]],
150
- *,
151
- on_success: OnSuccessFn[TState, T] | None = None,
152
- on_error: OnErrorFn[TState] | None = None,
153
150
  ) -> MutationProperty[T, TState, P]: ...
154
151
 
155
152
 
156
153
  @overload
157
154
  def mutation(
158
155
  fn: None = None,
159
- *,
160
- on_success: OnSuccessFn[TState, T] | None = None,
161
- on_error: OnErrorFn[TState] | None = None,
162
156
  ) -> Callable[
163
157
  [Callable[Concatenate[TState, P], Awaitable[T]]], MutationProperty[T, TState, P]
164
158
  ]: ...
@@ -166,9 +160,6 @@ def mutation(
166
160
 
167
161
  def mutation(
168
162
  fn: Callable[Concatenate[TState, P], Awaitable[T]] | None = None,
169
- *,
170
- on_success: OnSuccessFn[TState, T] | None = None,
171
- on_error: OnErrorFn[TState] | None = None,
172
163
  ):
173
164
  def decorator(func: Callable[Concatenate[TState, P], Awaitable[T]], /):
174
165
  sig = inspect.signature(func)
@@ -177,12 +168,7 @@ def mutation(
177
168
  if len(params) == 0 or params[0].name != "self":
178
169
  raise TypeError("@mutation method must have 'self' as first argument")
179
170
 
180
- return MutationProperty(
181
- func.__name__,
182
- func,
183
- on_success=on_success,
184
- on_error=on_error,
185
- )
171
+ return MutationProperty(func.__name__, func)
186
172
 
187
173
  if fn:
188
174
  return decorator(fn)
@@ -0,0 +1,136 @@
1
+ import datetime as dt
2
+ from collections.abc import Callable
3
+ from typing import Protocol, TypeVar, runtime_checkable
4
+
5
+ from pulse.queries.common import ActionResult, QueryStatus
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ @runtime_checkable
11
+ class QueryResult(Protocol[T]):
12
+ """
13
+ Unified query result interface for both keyed and unkeyed queries.
14
+
15
+ This protocol defines the public API that all query results expose,
16
+ regardless of whether they use keyed (cached/shared) or unkeyed
17
+ (dependency-tracked) execution strategies.
18
+
19
+ Keyed queries use a session-wide cache and explicit key functions to
20
+ determine when to refetch. Unkeyed queries automatically track reactive
21
+ dependencies and refetch when those dependencies change.
22
+ """
23
+
24
+ # Status properties
25
+ @property
26
+ def status(self) -> QueryStatus:
27
+ """Current query status: 'loading', 'success', or 'error'."""
28
+ ...
29
+
30
+ @property
31
+ def is_loading(self) -> bool:
32
+ """True if the query has not yet completed its initial fetch."""
33
+ ...
34
+
35
+ @property
36
+ def is_success(self) -> bool:
37
+ """True if the query completed successfully."""
38
+ ...
39
+
40
+ @property
41
+ def is_error(self) -> bool:
42
+ """True if the query completed with an error."""
43
+ ...
44
+
45
+ @property
46
+ def is_fetching(self) -> bool:
47
+ """True if a fetch is currently in progress (including refetches)."""
48
+ ...
49
+
50
+ @property
51
+ def is_scheduled(self) -> bool:
52
+ """True if a fetch is scheduled or currently running."""
53
+ ...
54
+
55
+ # Data properties
56
+ @property
57
+ def data(self) -> T | None:
58
+ """The query result data, or None if not yet available."""
59
+ ...
60
+
61
+ @property
62
+ def error(self) -> Exception | None:
63
+ """The error from the last fetch, or None if no error."""
64
+ ...
65
+
66
+ # Query operations
67
+ def is_stale(self) -> bool:
68
+ """Check if the query data is stale based on stale_time."""
69
+ ...
70
+
71
+ async def refetch(self, cancel_refetch: bool = True) -> ActionResult[T]:
72
+ """
73
+ Refetch the query data.
74
+
75
+ Args:
76
+ cancel_refetch: If True (default), cancels any in-flight request
77
+ before starting a new one. If False, deduplicates requests.
78
+
79
+ Returns:
80
+ ActionResult containing either the data or an error.
81
+ """
82
+ ...
83
+
84
+ async def wait(self) -> ActionResult[T]:
85
+ """
86
+ Wait for the current fetch to complete.
87
+
88
+ Returns:
89
+ ActionResult containing either the data or an error.
90
+ """
91
+ ...
92
+
93
+ def invalidate(self) -> None:
94
+ """Mark the query as stale and trigger a refetch if observed."""
95
+ ...
96
+
97
+ # Data manipulation
98
+ def set_data(self, data: T | Callable[[T | None], T]) -> None:
99
+ """
100
+ Optimistically set data without changing loading/error state.
101
+
102
+ Args:
103
+ data: The new data value, or a function that receives the current
104
+ data and returns the new data.
105
+ """
106
+ ...
107
+
108
+ def set_initial_data(
109
+ self,
110
+ data: T | Callable[[], T],
111
+ *,
112
+ updated_at: float | dt.datetime | None = None,
113
+ ) -> None:
114
+ """
115
+ Set data as if it were provided as initial_data.
116
+
117
+ Only takes effect if the query is still in 'loading' state.
118
+
119
+ Args:
120
+ data: The initial data value, or a function that returns it.
121
+ updated_at: Optional timestamp to seed staleness calculations.
122
+ """
123
+ ...
124
+
125
+ def set_error(self, error: Exception) -> None:
126
+ """Set error state on the query."""
127
+ ...
128
+
129
+ # Enable/disable
130
+ def enable(self) -> None:
131
+ """Enable the query, allowing it to fetch."""
132
+ ...
133
+
134
+ def disable(self) -> None:
135
+ """Disable the query, preventing it from fetching."""
136
+ ...