pulse-framework 0.1.54__py3-none-any.whl → 0.1.56__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 +5 -6
  2. pulse/app.py +144 -57
  3. pulse/channel.py +139 -7
  4. pulse/cli/cmd.py +16 -2
  5. pulse/code_analysis.py +38 -0
  6. pulse/codegen/codegen.py +61 -62
  7. pulse/codegen/templates/route.py +100 -56
  8. pulse/component.py +128 -6
  9. pulse/components/for_.py +30 -4
  10. pulse/components/if_.py +28 -5
  11. pulse/components/react_router.py +61 -3
  12. pulse/context.py +39 -5
  13. pulse/cookies.py +108 -4
  14. pulse/decorators.py +193 -24
  15. pulse/env.py +56 -2
  16. pulse/form.py +198 -5
  17. pulse/helpers.py +7 -1
  18. pulse/hooks/core.py +135 -5
  19. pulse/hooks/effects.py +61 -77
  20. pulse/hooks/init.py +60 -1
  21. pulse/hooks/runtime.py +241 -0
  22. pulse/hooks/setup.py +77 -0
  23. pulse/hooks/stable.py +58 -1
  24. pulse/hooks/state.py +107 -20
  25. pulse/js/__init__.py +41 -25
  26. pulse/js/array.py +9 -6
  27. pulse/js/console.py +15 -12
  28. pulse/js/date.py +9 -6
  29. pulse/js/document.py +5 -2
  30. pulse/js/error.py +7 -4
  31. pulse/js/json.py +9 -6
  32. pulse/js/map.py +8 -5
  33. pulse/js/math.py +9 -6
  34. pulse/js/navigator.py +5 -2
  35. pulse/js/number.py +9 -6
  36. pulse/js/obj.py +16 -13
  37. pulse/js/object.py +9 -6
  38. pulse/js/promise.py +19 -13
  39. pulse/js/pulse.py +28 -25
  40. pulse/js/react.py +190 -44
  41. pulse/js/regexp.py +7 -4
  42. pulse/js/set.py +8 -5
  43. pulse/js/string.py +9 -6
  44. pulse/js/weakmap.py +8 -5
  45. pulse/js/weakset.py +8 -5
  46. pulse/js/window.py +6 -3
  47. pulse/messages.py +5 -0
  48. pulse/middleware.py +147 -76
  49. pulse/plugin.py +76 -5
  50. pulse/queries/client.py +186 -39
  51. pulse/queries/common.py +52 -3
  52. pulse/queries/infinite_query.py +154 -2
  53. pulse/queries/mutation.py +127 -7
  54. pulse/queries/query.py +112 -11
  55. pulse/react_component.py +66 -3
  56. pulse/reactive.py +314 -30
  57. pulse/reactive_extensions.py +106 -26
  58. pulse/render_session.py +304 -173
  59. pulse/request.py +46 -11
  60. pulse/routing.py +140 -4
  61. pulse/serializer.py +71 -0
  62. pulse/state.py +177 -9
  63. pulse/test_helpers.py +15 -0
  64. pulse/transpiler/__init__.py +13 -3
  65. pulse/transpiler/assets.py +66 -0
  66. pulse/transpiler/dynamic_import.py +131 -0
  67. pulse/transpiler/emit_context.py +49 -0
  68. pulse/transpiler/function.py +6 -2
  69. pulse/transpiler/imports.py +33 -27
  70. pulse/transpiler/js_module.py +64 -8
  71. pulse/transpiler/py_module.py +1 -7
  72. pulse/transpiler/transpiler.py +4 -0
  73. pulse/user_session.py +119 -18
  74. {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/METADATA +5 -5
  75. pulse_framework-0.1.56.dist-info/RECORD +127 -0
  76. pulse/js/react_dom.py +0 -30
  77. pulse/transpiler/react_component.py +0 -51
  78. pulse_framework-0.1.54.dist-info/RECORD +0 -124
  79. {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/WHEEL +0 -0
  80. {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/entry_points.txt +0 -0
pulse/queries/client.py CHANGED
@@ -6,6 +6,7 @@ from pulse.context import PulseContext
6
6
  from pulse.queries.common import ActionResult, QueryKey
7
7
  from pulse.queries.infinite_query import InfiniteQuery, Page
8
8
  from pulse.queries.query import KeyedQuery
9
+ from pulse.queries.store import QueryStore
9
10
 
10
11
  T = TypeVar("T")
11
12
 
@@ -42,17 +43,41 @@ def _prefix_filter(prefix: tuple[Any, ...]) -> Callable[[QueryKey], bool]:
42
43
 
43
44
 
44
45
  class QueryClient:
45
- """
46
- Client for managing queries and infinite queries in a session.
46
+ """Client for managing queries and infinite queries in a session.
47
47
 
48
48
  Provides methods to get, set, invalidate, and refetch queries by key
49
- or using filter predicates.
49
+ or using filter predicates. Automatically resolves to the current
50
+ RenderSession's query store.
51
+
52
+ Access via ``ps.queries`` singleton:
53
+
54
+ Example:
55
+
56
+ ```python
57
+ # Get query data
58
+ user = ps.queries.get_data(("user", user_id))
50
59
 
51
- Automatically resolves to the current RenderSession's query store.
60
+ # Invalidate queries by prefix
61
+ ps.queries.invalidate_prefix(("users",))
62
+
63
+ # Set data optimistically
64
+ ps.queries.set_data(("user", user_id), updated_user)
65
+
66
+ # Check if any query is fetching
67
+ if ps.queries.is_fetching(("user", user_id)):
68
+ show_loading()
69
+ ```
52
70
  """
53
71
 
54
- def _get_store(self):
55
- """Get the query store from the current PulseContext."""
72
+ def _get_store(self) -> QueryStore:
73
+ """Get the query store from the current PulseContext.
74
+
75
+ Returns:
76
+ The QueryStore from the active render session.
77
+
78
+ Raises:
79
+ RuntimeError: If no render session is available.
80
+ """
56
81
  render = PulseContext.get().render
57
82
  if render is None:
58
83
  raise RuntimeError("No render session available")
@@ -62,12 +87,26 @@ class QueryClient:
62
87
  # Query accessors
63
88
  # ─────────────────────────────────────────────────────────────────────────
64
89
 
65
- def get(self, key: QueryKey):
66
- """Get an existing regular query by key, or None if not found."""
90
+ def get(self, key: QueryKey) -> KeyedQuery[Any] | None:
91
+ """Get an existing regular query by key.
92
+
93
+ Args:
94
+ key: The query key tuple to look up.
95
+
96
+ Returns:
97
+ The KeyedQuery instance, or None if not found.
98
+ """
67
99
  return self._get_store().get(key)
68
100
 
69
- def get_infinite(self, key: QueryKey):
70
- """Get an existing infinite query by key, or None if not found."""
101
+ def get_infinite(self, key: QueryKey) -> InfiniteQuery[Any, Any] | None:
102
+ """Get an existing infinite query by key.
103
+
104
+ Args:
105
+ key: The query key tuple to look up.
106
+
107
+ Returns:
108
+ The InfiniteQuery instance, or None if not found.
109
+ """
71
110
  return self._get_store().get_infinite(key)
72
111
 
73
112
  def get_all(
@@ -101,7 +140,15 @@ class QueryClient:
101
140
  return results
102
141
 
103
142
  def get_queries(self, filter: QueryFilter | None = None) -> list[KeyedQuery[Any]]:
104
- """Get all regular queries matching the filter."""
143
+ """Get all regular queries matching the filter.
144
+
145
+ Args:
146
+ filter: Optional filter - exact key, list of keys, or predicate.
147
+ If None, returns all regular queries.
148
+
149
+ Returns:
150
+ List of matching KeyedQuery instances (excludes infinite queries).
151
+ """
105
152
  store = self._get_store()
106
153
  predicate = _normalize_filter(filter)
107
154
  results: list[KeyedQuery[Any]] = []
@@ -118,7 +165,15 @@ class QueryClient:
118
165
  def get_infinite_queries(
119
166
  self, filter: QueryFilter | None = None
120
167
  ) -> list[InfiniteQuery[Any, Any]]:
121
- """Get all infinite queries matching the filter."""
168
+ """Get all infinite queries matching the filter.
169
+
170
+ Args:
171
+ filter: Optional filter - exact key, list of keys, or predicate.
172
+ If None, returns all infinite queries.
173
+
174
+ Returns:
175
+ List of matching InfiniteQuery instances.
176
+ """
122
177
  store = self._get_store()
123
178
  predicate = _normalize_filter(filter)
124
179
  results: list[InfiniteQuery[Any, Any]] = []
@@ -137,14 +192,28 @@ class QueryClient:
137
192
  # ─────────────────────────────────────────────────────────────────────────
138
193
 
139
194
  def get_data(self, key: QueryKey) -> Any | None:
140
- """Get the data for a query by key. Returns None if not found or no data."""
195
+ """Get the data for a query by key.
196
+
197
+ Args:
198
+ key: The query key tuple to look up.
199
+
200
+ Returns:
201
+ The query data, or None if query not found or has no data.
202
+ """
141
203
  query = self.get(key)
142
204
  if query is None:
143
205
  return None
144
206
  return query.data.read()
145
207
 
146
208
  def get_infinite_data(self, key: QueryKey) -> list[Page[Any, Any]] | None:
147
- """Get the pages for an infinite query by key."""
209
+ """Get the pages for an infinite query by key.
210
+
211
+ Args:
212
+ key: The query key tuple to look up.
213
+
214
+ Returns:
215
+ List of Page objects, or None if query not found.
216
+ """
148
217
  query = self.get_infinite(key)
149
218
  if query is None:
150
219
  return None
@@ -215,7 +284,16 @@ class QueryClient:
215
284
  *,
216
285
  updated_at: float | dt.datetime | None = None,
217
286
  ) -> bool:
218
- """Set pages for an infinite query by key."""
287
+ """Set pages for an infinite query by key.
288
+
289
+ Args:
290
+ key: The query key tuple.
291
+ pages: New pages list or updater function.
292
+ updated_at: Optional timestamp to set.
293
+
294
+ Returns:
295
+ True if query was found and updated, False otherwise.
296
+ """
219
297
  query = self.get_infinite(key)
220
298
  if query is None:
221
299
  return False
@@ -291,11 +369,21 @@ class QueryClient:
291
369
  *,
292
370
  cancel_refetch: bool = False,
293
371
  ) -> int:
294
- """
295
- Invalidate all queries whose keys start with the given prefix.
372
+ """Invalidate all queries whose keys start with the given prefix.
373
+
374
+ Args:
375
+ prefix: Tuple prefix to match against query keys.
376
+ cancel_refetch: Cancel in-flight requests before refetch.
377
+
378
+ Returns:
379
+ Count of invalidated queries.
296
380
 
297
381
  Example:
298
- ps.queries.invalidate_prefix(("users",)) # invalidates ("users",), ("users", 1), etc.
382
+
383
+ ```python
384
+ # Invalidates ("users",), ("users", 1), ("users", 2, "posts"), etc.
385
+ ps.queries.invalidate_prefix(("users",))
386
+ ```
299
387
  """
300
388
  return self.invalidate(_prefix_filter(prefix), cancel_refetch=cancel_refetch)
301
389
 
@@ -309,10 +397,14 @@ class QueryClient:
309
397
  *,
310
398
  cancel_refetch: bool = True,
311
399
  ) -> ActionResult[Any] | None:
312
- """
313
- Refetch a query by key and return the result.
400
+ """Refetch a query by key and return the result.
314
401
 
315
- Returns None if the query doesn't exist.
402
+ Args:
403
+ key: The query key tuple to refetch.
404
+ cancel_refetch: Cancel in-flight request before refetching (default True).
405
+
406
+ Returns:
407
+ ActionResult with data or error, or None if query doesn't exist.
316
408
  """
317
409
  query = self.get(key)
318
410
  if query is not None:
@@ -330,10 +422,15 @@ class QueryClient:
330
422
  *,
331
423
  cancel_refetch: bool = True,
332
424
  ) -> list[ActionResult[Any]]:
333
- """
334
- Refetch all queries matching the filter.
425
+ """Refetch all queries matching the filter.
426
+
427
+ Args:
428
+ filter: Optional filter - exact key, list of keys, or predicate.
429
+ If None, refetches all queries.
430
+ cancel_refetch: Cancel in-flight requests before refetching.
335
431
 
336
- Returns list of ActionResult for each refetched query.
432
+ Returns:
433
+ List of ActionResult for each refetched query.
337
434
  """
338
435
  queries = self.get_all(filter)
339
436
  results: list[ActionResult[Any]] = []
@@ -353,7 +450,15 @@ class QueryClient:
353
450
  *,
354
451
  cancel_refetch: bool = True,
355
452
  ) -> list[ActionResult[Any]]:
356
- """Refetch all queries whose keys start with the given prefix."""
453
+ """Refetch all queries whose keys start with the given prefix.
454
+
455
+ Args:
456
+ prefix: Tuple prefix to match against query keys.
457
+ cancel_refetch: Cancel in-flight requests before refetching.
458
+
459
+ Returns:
460
+ List of ActionResult for each refetched query.
461
+ """
357
462
  return await self.refetch_all(
358
463
  _prefix_filter(prefix), cancel_refetch=cancel_refetch
359
464
  )
@@ -369,7 +474,16 @@ class QueryClient:
369
474
  *,
370
475
  updated_at: float | dt.datetime | None = None,
371
476
  ) -> bool:
372
- """Set error state on a query by key."""
477
+ """Set error state on a query by key.
478
+
479
+ Args:
480
+ key: The query key tuple.
481
+ error: The exception to set.
482
+ updated_at: Optional timestamp to set.
483
+
484
+ Returns:
485
+ True if query was found and error was set, False otherwise.
486
+ """
373
487
  query = self.get(key)
374
488
  if query is not None:
375
489
  query.set_error(error, updated_at=updated_at)
@@ -387,10 +501,13 @@ class QueryClient:
387
501
  # ─────────────────────────────────────────────────────────────────────────
388
502
 
389
503
  def remove(self, key: QueryKey) -> bool:
390
- """
391
- Remove a query from the store, disposing it.
504
+ """Remove a query from the store, disposing it.
505
+
506
+ Args:
507
+ key: The query key tuple to remove.
392
508
 
393
- Returns True if query existed and was removed.
509
+ Returns:
510
+ True if query existed and was removed, False otherwise.
394
511
  """
395
512
  store = self._get_store()
396
513
  entry = store.get_any(key)
@@ -400,10 +517,14 @@ class QueryClient:
400
517
  return True
401
518
 
402
519
  def remove_all(self, filter: QueryFilter | None = None) -> int:
403
- """
404
- Remove all queries matching the filter.
520
+ """Remove all queries matching the filter.
521
+
522
+ Args:
523
+ filter: Optional filter - exact key, list of keys, or predicate.
524
+ If None, removes all queries.
405
525
 
406
- Returns count of removed queries.
526
+ Returns:
527
+ Count of removed queries.
407
528
  """
408
529
  queries = self.get_all(filter)
409
530
  for q in queries:
@@ -411,7 +532,14 @@ class QueryClient:
411
532
  return len(queries)
412
533
 
413
534
  def remove_prefix(self, prefix: tuple[Any, ...]) -> int:
414
- """Remove all queries whose keys start with the given prefix."""
535
+ """Remove all queries whose keys start with the given prefix.
536
+
537
+ Args:
538
+ prefix: Tuple prefix to match against query keys.
539
+
540
+ Returns:
541
+ Count of removed queries.
542
+ """
415
543
  return self.remove_all(_prefix_filter(prefix))
416
544
 
417
545
  # ─────────────────────────────────────────────────────────────────────────
@@ -419,7 +547,15 @@ class QueryClient:
419
547
  # ─────────────────────────────────────────────────────────────────────────
420
548
 
421
549
  def is_fetching(self, filter: QueryFilter | None = None) -> bool:
422
- """Check if any query matching the filter is currently fetching."""
550
+ """Check if any query matching the filter is currently fetching.
551
+
552
+ Args:
553
+ filter: Optional filter - exact key, list of keys, or predicate.
554
+ If None, checks all queries.
555
+
556
+ Returns:
557
+ True if any matching query is fetching.
558
+ """
423
559
  queries = self.get_all(filter)
424
560
  for q in queries:
425
561
  if q.is_fetching():
@@ -427,7 +563,15 @@ class QueryClient:
427
563
  return False
428
564
 
429
565
  def is_loading(self, filter: QueryFilter | None = None) -> bool:
430
- """Check if any query matching the filter is in loading state."""
566
+ """Check if any query matching the filter is in loading state.
567
+
568
+ Args:
569
+ filter: Optional filter - exact key, list of keys, or predicate.
570
+ If None, checks all queries.
571
+
572
+ Returns:
573
+ True if any matching query has status "loading".
574
+ """
431
575
  queries = self.get_all(filter)
432
576
  for q in queries:
433
577
  if isinstance(q, InfiniteQuery):
@@ -442,10 +586,13 @@ class QueryClient:
442
586
  # ─────────────────────────────────────────────────────────────────────────
443
587
 
444
588
  async def wait(self, key: QueryKey) -> ActionResult[Any] | None:
445
- """
446
- Wait for a query to complete and return the result.
589
+ """Wait for a query to complete and return the result.
447
590
 
448
- Returns None if the query doesn't exist.
591
+ Args:
592
+ key: The query key tuple to wait for.
593
+
594
+ Returns:
595
+ ActionResult with data or error, or None if query doesn't exist.
449
596
  """
450
597
  query = self.get(key)
451
598
  if query is not None:
@@ -458,5 +605,5 @@ class QueryClient:
458
605
  return None
459
606
 
460
607
 
461
- # Singleton instance
608
+ # Singleton instance accessible via ps.queries
462
609
  queries = QueryClient()
pulse/queries/common.py CHANGED
@@ -19,13 +19,41 @@ P = ParamSpec("P")
19
19
  R = TypeVar("R")
20
20
 
21
21
  QueryKey: TypeAlias = tuple[Hashable, ...]
22
+ """Tuple of hashable values identifying a query in the store.
23
+
24
+ Used to uniquely identify queries for caching, deduplication, and invalidation.
25
+ Keys are hierarchical tuples like ``("user", user_id)`` or ``("posts", "feed")``.
26
+ """
27
+
22
28
  QueryStatus: TypeAlias = Literal["loading", "success", "error"]
29
+ """Current status of a query.
30
+
31
+ Values:
32
+ - ``"loading"``: Query is fetching data (initial load or refetch).
33
+ - ``"success"``: Query has successfully fetched data.
34
+ - ``"error"``: Query encountered an error during fetch.
35
+ """
23
36
 
24
37
 
25
- # Discriminated union result types for query actions
26
38
  @dataclass(slots=True, frozen=True)
27
39
  class ActionSuccess(Generic[T]):
28
- """Successful query action result."""
40
+ """Successful query action result.
41
+
42
+ Returned by query operations like ``refetch()`` and ``wait()`` when the
43
+ operation completes successfully.
44
+
45
+ Attributes:
46
+ data: The fetched data of type T.
47
+ status: Always ``"success"`` for discriminated union matching.
48
+
49
+ Example:
50
+
51
+ ```python
52
+ result = await state.user.refetch()
53
+ if result.status == "success":
54
+ print(result.data)
55
+ ```
56
+ """
29
57
 
30
58
  data: T
31
59
  status: Literal["success"] = "success"
@@ -33,13 +61,34 @@ class ActionSuccess(Generic[T]):
33
61
 
34
62
  @dataclass(slots=True, frozen=True)
35
63
  class ActionError:
36
- """Failed query action result."""
64
+ """Failed query action result.
65
+
66
+ Returned by query operations like ``refetch()`` and ``wait()`` when the
67
+ operation fails after exhausting retries.
68
+
69
+ Attributes:
70
+ error: The exception that caused the failure.
71
+ status: Always ``"error"`` for discriminated union matching.
72
+
73
+ Example:
74
+
75
+ ```python
76
+ result = await state.user.refetch()
77
+ if result.status == "error":
78
+ print(f"Failed: {result.error}")
79
+ ```
80
+ """
37
81
 
38
82
  error: Exception
39
83
  status: Literal["error"] = "error"
40
84
 
41
85
 
42
86
  ActionResult: TypeAlias = ActionSuccess[T] | ActionError
87
+ """Union type for query action results.
88
+
89
+ Either ``ActionSuccess[T]`` with data or ``ActionError`` with an exception.
90
+ Use the ``status`` field to discriminate between success and error cases.
91
+ """
43
92
 
44
93
  OnSuccessFn = Callable[[TState], Any] | Callable[[TState, T], Any]
45
94
  OnErrorFn = Callable[[TState], Any] | Callable[[TState, Exception], Any]
@@ -44,6 +44,26 @@ TState = TypeVar("TState", bound=State)
44
44
 
45
45
 
46
46
  class Page(NamedTuple, Generic[T, TParam]):
47
+ """Named tuple representing a page in an infinite query.
48
+
49
+ Each page contains the fetched data and the parameter used to fetch it,
50
+ enabling cursor-based or offset-based pagination.
51
+
52
+ Attributes:
53
+ data: The fetched page data of type T.
54
+ param: The page parameter (cursor, offset, etc.) used to fetch this page.
55
+
56
+ Example:
57
+
58
+ ```python
59
+ # Access pages from infinite query result
60
+ for page in state.posts.data:
61
+ print(f"Page param: {page.param}")
62
+ for post in page.data:
63
+ print(post.title)
64
+ ```
65
+ """
66
+
47
67
  data: T
48
68
  param: TParam
49
69
 
@@ -714,8 +734,39 @@ def none_if_missing(value: Any):
714
734
 
715
735
 
716
736
  class InfiniteQueryResult(Generic[T, TParam], Disposable):
717
- """
718
- Observer wrapper for InfiniteQuery with lifecycle and stale tracking.
737
+ """Observer wrapper for InfiniteQuery with lifecycle and stale tracking.
738
+
739
+ InfiniteQueryResult provides the interface for interacting with paginated
740
+ queries. It manages observation lifecycle, staleness tracking, and exposes
741
+ reactive properties and methods for pagination.
742
+
743
+ Attributes:
744
+ data: List of Page objects or None if not loaded.
745
+ pages: List of page data only (without params) or None.
746
+ page_params: List of page parameters only or None.
747
+ error: The last error encountered, or None.
748
+ status: Current QueryStatus ("loading", "success", "error").
749
+ is_loading: Whether status is "loading".
750
+ is_success: Whether status is "success".
751
+ is_error: Whether status is "error".
752
+ is_fetching: Whether any fetch is in progress.
753
+ has_next_page: Whether more pages are available forward.
754
+ has_previous_page: Whether previous pages are available.
755
+ is_fetching_next_page: Whether fetching the next page.
756
+ is_fetching_previous_page: Whether fetching the previous page.
757
+
758
+ Example:
759
+
760
+ ```python
761
+ # Access infinite query result
762
+ if state.posts.is_loading:
763
+ show_skeleton()
764
+ elif state.posts.data:
765
+ for page in state.posts.data:
766
+ render_posts(page.data)
767
+ if state.posts.has_next_page:
768
+ Button("Load More", on_click=state.posts.fetch_next_page)
769
+ ```
719
770
  """
720
771
 
721
772
  _query: Computed[InfiniteQuery[T, TParam]]
@@ -957,6 +1008,47 @@ class InfiniteQueryResult(Generic[T, TParam], Disposable):
957
1008
 
958
1009
 
959
1010
  class InfiniteQueryProperty(Generic[T, TParam, TState], InitializableProperty):
1011
+ """Descriptor for state-bound infinite queries created by the @infinite_query decorator.
1012
+
1013
+ InfiniteQueryProperty is the return type of the ``@infinite_query`` decorator.
1014
+ It acts as a descriptor that creates and manages InfiniteQueryResult instances
1015
+ for each State object.
1016
+
1017
+ When accessed on a State instance, returns an InfiniteQueryResult with reactive
1018
+ properties for pagination state and methods for fetching pages.
1019
+
1020
+ Required decorators:
1021
+ - ``@infinite_query_prop.key``: Define the query key (required).
1022
+ - ``@infinite_query_prop.get_next_page_param``: Define how to get next page param.
1023
+
1024
+ Optional decorators:
1025
+ - ``@infinite_query_prop.get_previous_page_param``: For bi-directional pagination.
1026
+ - ``@infinite_query_prop.on_success``: Handle successful fetch.
1027
+ - ``@infinite_query_prop.on_error``: Handle fetch errors.
1028
+
1029
+ Example:
1030
+
1031
+ ```python
1032
+ class FeedState(ps.State):
1033
+ feed_type: str = "home"
1034
+
1035
+ @ps.infinite_query(initial_page_param=None)
1036
+ async def posts(self, cursor: str | None) -> list[Post]:
1037
+ return await api.get_posts(cursor=cursor)
1038
+
1039
+ @posts.key
1040
+ def _posts_key(self):
1041
+ return ("feed", self.feed_type)
1042
+
1043
+ @posts.get_next_page_param
1044
+ def _next_cursor(self, pages: list[Page]) -> str | None:
1045
+ if not pages:
1046
+ return None
1047
+ last = pages[-1]
1048
+ return last.data[-1].id if last.data else None
1049
+ ```
1050
+ """
1051
+
960
1052
  name: str
961
1053
  _fetch_fn: "Callable[[TState, TParam], Awaitable[T]]"
962
1054
  _keep_alive: bool
@@ -1233,7 +1325,67 @@ def infinite_query(
1233
1325
  enabled: bool = True,
1234
1326
  fetch_on_mount: bool = True,
1235
1327
  key: QueryKey | None = None,
1328
+ ) -> (
1329
+ InfiniteQueryProperty[T, TParam, TState]
1330
+ | Callable[
1331
+ [Callable[[TState, Any], Awaitable[T]]],
1332
+ InfiniteQueryProperty[T, TParam, TState],
1333
+ ]
1236
1334
  ):
1335
+ """Decorator for paginated queries on State methods.
1336
+
1337
+ Creates a reactive infinite query that supports cursor-based or offset-based
1338
+ pagination. Data is stored as a list of pages, each with its data and the
1339
+ parameter used to fetch it.
1340
+
1341
+ Requires ``@query_prop.key`` and ``@query_prop.get_next_page_param`` decorators.
1342
+
1343
+ Args:
1344
+ fn: The async method to decorate (when used without parentheses).
1345
+ initial_page_param: The parameter for fetching the first page (required).
1346
+ max_pages: Maximum pages to keep in memory (0 = unlimited).
1347
+ stale_time: Seconds before data is considered stale (default 0.0).
1348
+ gc_time: Seconds to keep unused query in cache (default 300.0).
1349
+ refetch_interval: Auto-refetch interval in seconds (default None).
1350
+ keep_previous_data: Keep previous data while refetching (default False).
1351
+ retries: Number of retry attempts on failure (default 3).
1352
+ retry_delay: Delay between retries in seconds (default 2.0).
1353
+ initial_data_updated_at: Timestamp for initial data staleness.
1354
+ enabled: Whether query is enabled (default True).
1355
+ fetch_on_mount: Fetch when component mounts (default True).
1356
+ key: Static query key for sharing across instances.
1357
+
1358
+ Returns:
1359
+ InfiniteQueryProperty that creates InfiniteQueryResult instances when accessed.
1360
+
1361
+ Example:
1362
+
1363
+ ```python
1364
+ class FeedState(ps.State):
1365
+ @ps.infinite_query(initial_page_param=None, key=("feed",))
1366
+ async def posts(self, cursor: str | None) -> list[Post]:
1367
+ return await api.get_posts(cursor=cursor)
1368
+
1369
+ @posts.key
1370
+ def _posts_key(self):
1371
+ return ("feed", self.feed_type)
1372
+
1373
+ @posts.get_next_page_param
1374
+ def _next_cursor(self, pages: list[Page]) -> str | None:
1375
+ if not pages:
1376
+ return None
1377
+ last = pages[-1]
1378
+ return last.data[-1].id if last.data else None
1379
+
1380
+ @posts.get_previous_page_param
1381
+ def _prev_cursor(self, pages: list[Page]) -> str | None:
1382
+ if not pages:
1383
+ return None
1384
+ first = pages[0]
1385
+ return first.data[0].id if first.data else None
1386
+ ```
1387
+ """
1388
+
1237
1389
  def decorator(
1238
1390
  func: Callable[[TState, TParam], Awaitable[T]], /
1239
1391
  ) -> InfiniteQueryProperty[T, TParam, TState]: