tempestweb.query¶
The read side of remote data: a cache with hierarchical keys, prefix invalidation, single-flight, both pagination shapes, and optimistic mutation with an exact rollback. Modes A and B (Mode C refuses the import at build time). It does not replace native.sync, which is still the way to reconcile a large collection.
Tutorial with examples: Reading remote data.
tempestweb.query ¶
The read side of remote data: cache, keys, pagination, optimistic updates.
tempestweb had both hard ends and nothing in between. native.http retries with
backoff and idempotency, native.offline holds a durable FIFO of mutations, and
native.sync reconciles a collection by watermark. Reading had nothing:
nowhere to keep the answer to a GET under a key, invalidate it when a mutation
lands, paginate, or put a change on screen before the server agreed to it.
Every app wrote that as a dict inside its own State, and the part that always
came out wrong was the invalidation.
Modules
* :mod:`keys` — hierarchical keys, so invalidation is by prefix.
* :mod:`cache` — :class:`QueryCache`: staleness, single-flight, rollback.
* :mod:`pagination` — the offset and cursor shapes, typed.
* :mod:`optimistic` — `upsert_by_id` / `remove_by_id` over a cached list.
* :mod:`policy` — how long an answer is fresh, and what is worth retrying.
* :mod:`persistence` — writing the cache to the store the app already has.
Example
from tempestweb import native
from tempestweb.query import QueryCache, keys, offset_page, upsert_by_id
USERS = keys("users")
CACHE = QueryCache()
response = await CACHE.fetch(
USERS.list(page=1),
lambda: native.http.request("GET", "/api/users?page=1"),
)
page = offset_page(response.json)
with CACHE.optimistic(USERS.all(), lambda rows: upsert_by_id(rows, edited)):
await native.http.request("PATCH", "/api/users/7", json=edited)
If the PATCH raises, the block's rollback puts back exactly the entries it
replaced — no refetch needed to undo something the server never accepted.
The cache is app state, not a hidden singleton
A QueryCache is created by the app and kept in its State. There is no
module-level instance and no implicit context: the view reads from the cache
it was handed, and a test builds its own with a fake clock.
Modes A and B only
Mode C transpiles the app's own Python into JavaScript and serves a fixed set
of modules — tempest_core, tempestweb.components and tempestweb.native.
Importing this package from a Mode C app is refused at build time with a
named error.
This does not replace native.sync
Delta-sync is still the way to reconcile a large collection against a watermark. This cache is for reading a screen.
Import everything from this package level rather than from submodules.
QueryCache ¶
Keyed cache of read answers, with staleness, single-flight and rollback.
The second read below never runs its loader: the first answer is still inside the staleness window, so the cache answers it.
Example
Source code in tempestweb/query/cache.py
106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 | |
keys
property
¶
Every key currently held, in insertion order.
Returns:
| Type | Description |
|---|---|
tuple[QueryKey, ...]
|
The keys. |
fetch
async
¶
fetch(key: QueryKey, loader: Callable[[], Awaitable[T]], *, stale_ms: float | None = None, force: bool = False) -> T
Answer from cache when fresh, otherwise run the loader once.
Concurrent calls for the same key share one loader run: the second caller awaits the first one's result rather than issuing a second request. That is single-flight, and it is the behaviour a screen with three widgets reading the same query needs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
QueryKey
|
The cache key, from :func: |
required |
loader
|
Callable[[], Awaitable[T]]
|
Called to produce the value when the cache cannot answer. |
required |
stale_ms
|
float | None
|
Override the cache's staleness window for this read. |
None
|
force
|
bool
|
Skip the freshness check and load anyway. The in-flight share still applies, so forcing twice concurrently still loads once. |
False
|
Returns:
| Type | Description |
|---|---|
T
|
The value, cached or freshly loaded. |
Raises:
| Type | Description |
|---|---|
Exception
|
Whatever the loader raises, to every caller sharing the run. A failed load leaves the previous entry alone — showing the last good answer beats blanking the screen because a refetch failed. |
Source code in tempestweb/query/cache.py
get ¶
Read a cached value without loading anything.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
QueryKey
|
The cache key. |
required |
Returns:
| Type | Description |
|---|---|
object | None
|
The value, or |
object | None
|
entry has aged past the cache window. |
Source code in tempestweb/query/cache.py
is_stale ¶
Report whether a key needs a trip to the network.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
QueryKey
|
The cache key. |
required |
stale_ms
|
float | None
|
Override the cache's staleness window. |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
|
bool
|
window. |
Source code in tempestweb/query/cache.py
set ¶
Store a value, stamping it fresh.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
QueryKey
|
The cache key. |
required |
value
|
object
|
The value to store. |
required |
Source code in tempestweb/query/cache.py
invalidate ¶
Mark everything under a prefix stale, keeping the values on screen.
This is the operation the hierarchy exists for: invalidate(("users",))
reaches ("users", "list", "page=1"), ("users", "detail", "7") and
everything else about users, without the caller keeping a second registry
of which keys mean users.
The values stay, so a screen keeps showing the last good answer while the
refetch is in flight. Use :meth:drop when the value is known to be
wrong rather than merely old.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prefix
|
QueryKey
|
The prefix to invalidate. The empty tuple reaches everything. |
required |
Returns:
| Type | Description |
|---|---|
int
|
How many entries were marked. |
Source code in tempestweb/query/cache.py
drop ¶
Remove everything under a prefix.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prefix
|
QueryKey
|
The prefix to drop. The empty tuple clears the cache. |
required |
Returns:
| Type | Description |
|---|---|
int
|
How many entries were removed. |
Source code in tempestweb/query/cache.py
patch ¶
Apply an optimistic change to every entry under a prefix.
A prefix rather than one key, because a rename has to reach every cached
page the row appears on — patching only ("users", "list", "page=1")
leaves page 2 showing the old name until something else invalidates it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prefix
|
QueryKey
|
The prefix whose entries are patched. |
required |
patcher
|
Patcher
|
Turns each entry's value into its replacement. Must not mutate the value it is handed. |
required |
Returns:
| Type | Description |
|---|---|
Rollback
|
A callable restoring exactly the entries this patch replaced, |
Rollback
|
timestamps included. Calling it twice is harmless. |
Raises:
| Type | Description |
|---|---|
Exception
|
Whatever |
Source code in tempestweb/query/cache.py
optimistic ¶
Apply a patch, and undo it if the block raises.
The shape a mutation wants, because the rollback cannot be forgotten:
with cache.optimistic(USERS.all(), rename) as rollback:
await native.http.request("PATCH", f"/api/users/{user_id}", json=body)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prefix
|
QueryKey
|
The prefix whose entries are patched. |
required |
patcher
|
Patcher
|
Turns each entry's value into its replacement. |
required |
Yields:
| Type | Description |
|---|---|
Rollback
|
The rollback, for a block that decides to undo without raising. |
Source code in tempestweb/query/cache.py
clear ¶
on_change ¶
Register a callback fired after any change to the cache.
This is how a cached read reaches the screen: the app subscribes once and asks for a rebuild.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
listener
|
Listener
|
Called with no arguments after each change. |
required |
Returns:
| Type | Description |
|---|---|
Callable[[], None]
|
A callable that unsubscribes. |
Source code in tempestweb/query/cache.py
QueryEntry
dataclass
¶
One cached answer.
Attributes:
| Name | Type | Description |
|---|---|---|
value |
object
|
Whatever the loader returned. |
updated_at |
float
|
When it was stored, in milliseconds from :data: |
Source code in tempestweb/query/cache.py
QueryKeys
dataclass
¶
A key factory rooted at one resource.
Attributes:
| Name | Type | Description |
|---|---|---|
root |
QueryKey
|
The segments every key from this factory starts with. |
Source code in tempestweb/query/keys.py
all ¶
The root key, which is a prefix of every other key from here.
Returns:
| Type | Description |
|---|---|
QueryKey
|
The root segments — pass this to |
QueryKey
|
meth: |
QueryKey
|
about this resource. |
Source code in tempestweb/query/keys.py
list ¶
A key for a listing, parameterized.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
**params
|
object
|
Query parameters — page, filters, sort. Sorted by name before joining, so argument order never splits the cache. |
{}
|
Returns:
| Type | Description |
|---|---|
QueryKey
|
The key, under :meth: |
Source code in tempestweb/query/keys.py
detail ¶
A key for a single record.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
identifier
|
object
|
The record's id, rendered with |
required |
**params
|
object
|
Any extra parameters, sorted as in :meth: |
{}
|
Returns:
| Type | Description |
|---|---|
QueryKey
|
The key, under :meth: |
Source code in tempestweb/query/keys.py
sub ¶
A key for anything the other two do not name.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*segments
|
object
|
Extra segments, rendered with |
()
|
**params
|
object
|
Any parameters, sorted as in :meth: |
{}
|
Returns:
| Type | Description |
|---|---|
QueryKey
|
The key, under :meth: |
Source code in tempestweb/query/keys.py
CursorPage
dataclass
¶
One page of a cursor-paginated answer.
Attributes:
| Name | Type | Description |
|---|---|---|
items |
tuple[object, ...]
|
The rows on this page. |
next_cursor |
str | None
|
The cursor to ask for the next page, or |
Source code in tempestweb/query/pagination.py
has_next
property
¶
Whether a page follows this one.
Returns:
| Type | Description |
|---|---|
bool
|
Whether a cursor was handed back. |
OffsetPage
dataclass
¶
One page of an offset-paginated answer.
Attributes:
| Name | Type | Description |
|---|---|---|
items |
tuple[object, ...]
|
The rows on this page. |
total |
int
|
How many rows exist across every page. |
page |
int
|
This page's 1-based number. |
page_size |
int
|
How many rows a full page holds. |
Source code in tempestweb/query/pagination.py
pages
property
¶
How many pages the total spans.
Returns:
| Type | Description |
|---|---|
int
|
The page count, or |
int
|
it would raise, and a screen asking "how many pages" before the first |
int
|
answer arrives is normal, not exceptional. |
has_next
property
¶
Whether a page follows this one.
Returns:
| Name | Type | Description |
|---|---|---|
Whether |
bool
|
attr: |
has_previous
property
¶
Whether a page precedes this one.
Returns:
| Name | Type | Description |
|---|---|---|
Whether |
bool
|
attr: |
PageKeys
dataclass
¶
Which payload keys to read, for a server naming them differently.
Attributes:
| Name | Type | Description |
|---|---|---|
items |
str
|
The key holding the rows. |
total |
str
|
The key holding the overall count. |
page |
str
|
The key holding the page number. |
page_size |
str
|
The key holding the page size. |
cursor |
str
|
The key holding the next cursor. |
Source code in tempestweb/query/pagination.py
PersistResult
dataclass
¶
What :func:persist did.
Attributes:
| Name | Type | Description |
|---|---|---|
written |
int
|
How many entries reached the store. |
skipped |
int
|
How many were left behind because their value is not JSON-able. |
Source code in tempestweb/query/persistence.py
QueryStorage ¶
Bases: Protocol
The slice of native.storage this module needs.
Declared as a Protocol rather than importing native.storage directly so
the module runs without a browser bridge — a test passes a fake, and the
dependency arrow never points from query into native.
Source code in tempestweb/query/persistence.py
put ¶
Store a string under a key.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The storage key. |
required |
content
|
str
|
The string to store. |
required |
Returns:
| Type | Description |
|---|---|
Awaitable[None]
|
An awaitable completing when the write lands. |
get ¶
Read the string stored under a key.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The storage key. |
required |
Returns:
| Type | Description |
|---|---|
Awaitable[str]
|
An awaitable resolving to the stored string. |
remove ¶
Delete the value stored under a key.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The storage key. |
required |
Returns:
| Type | Description |
|---|---|
Awaitable[None]
|
An awaitable completing when the delete lands. |
list_keys ¶
List every key the store holds.
Returns:
| Type | Description |
|---|---|
Awaitable[list[str]]
|
An awaitable resolving to the keys. |
RestoreResult
dataclass
¶
What :func:restore did.
Attributes:
| Name | Type | Description |
|---|---|---|
restored |
int
|
How many entries were read back into the cache. |
discarded |
int
|
How many stored records could not be read and were deleted. |
Source code in tempestweb/query/persistence.py
is_under ¶
Report whether a key lives under a prefix.
Segment-wise, never character-wise: ("users",) is a prefix of
("users", "list") and is not a prefix of ("users-archive",).
A startswith on joined strings would get that second one wrong, and it
would get it wrong silently — invalidating a resource that merely shares a
name.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prefix
|
QueryKey
|
The prefix to test against. |
required |
key
|
QueryKey
|
The key to test. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
Whether |
bool
|
everything, which is how "invalidate the whole cache" is spelled. |
Source code in tempestweb/query/keys.py
remove_by_id ¶
remove_by_id(rows: Iterable[object], identifier: object, *, id_field: str = ID_FIELD) -> tuple[object, ...]
Drop every row carrying an id.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rows
|
Iterable[object]
|
The rows currently cached. |
required |
identifier
|
object
|
The id to drop. |
required |
id_field
|
str
|
The field the rows are identified by. |
ID_FIELD
|
Returns:
| Type | Description |
|---|---|
object
|
A new tuple without those rows. Removing an id that is not there is not |
...
|
an error — it answers the same rows back, which is what a double-click on |
tuple[object, ...]
|
Delete should do. |
Source code in tempestweb/query/optimistic.py
replace_where ¶
Replace every row a predicate accepts.
For the cases upsert_by_id does not cover — a composite key, a row
identified by something other than a field.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rows
|
Iterable[object]
|
The rows currently cached. |
required |
matches
|
object
|
A callable answering whether a row should be replaced. |
required |
row
|
object
|
The replacement. |
required |
Returns:
| Type | Description |
|---|---|
tuple[object, ...]
|
A new tuple. |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
Source code in tempestweb/query/optimistic.py
upsert_by_id ¶
upsert_by_id(rows: Iterable[object], row: object, *, id_field: str = ID_FIELD) -> tuple[object, ...]
Replace a row with the same id, or append it when there is none.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rows
|
Iterable[object]
|
The rows currently cached. |
required |
row
|
object
|
The row to put in. |
required |
id_field
|
str
|
The field the rows are identified by. |
ID_FIELD
|
Returns:
| Type | Description |
|---|---|
object
|
A new tuple. The replaced row keeps its position; a new row goes last. |
...
|
When |
tuple[object, ...]
|
replace anything, and dropping it silently would lose the user's edit. |
Source code in tempestweb/query/optimistic.py
cursor_page ¶
cursor_page(payload: Mapping[str, object], *, page_keys: PageKeys = DEFAULT_PAGE_KEYS) -> CursorPage
Read a cursor-paginated payload.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
Mapping[str, object]
|
The decoded JSON body. |
required |
page_keys
|
PageKeys
|
Which keys to read, for a server naming them differently. |
DEFAULT_PAGE_KEYS
|
Returns:
| Name | Type | Description |
|---|---|---|
The |
CursorPage
|
class: |
Source code in tempestweb/query/pagination.py
empty_cursor_page ¶
A cursor page with no rows, for the state before the first answer.
Returns:
| Type | Description |
|---|---|
CursorPage
|
The empty page. |
empty_offset_page ¶
An offset page with no rows, for the state before the first answer.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
page
|
int
|
The page number the screen is on. |
1
|
page_size
|
int
|
The page size the screen asked for. |
0
|
Returns:
| Type | Description |
|---|---|
OffsetPage
|
The empty page. Preferred over |
OffsetPage
|
|
Source code in tempestweb/query/pagination.py
is_cursor_page ¶
Report whether a payload looks cursor-paginated.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
object
|
The decoded JSON body. |
required |
page_keys
|
PageKeys
|
Which keys identify the shape. |
DEFAULT_PAGE_KEYS
|
Returns:
| Type | Description |
|---|---|
bool
|
Whether it carries both the rows key and the cursor key. The cursor key |
bool
|
being present with a |
bool
|
page announces itself. |
Source code in tempestweb/query/pagination.py
is_offset_page ¶
Report whether a payload looks offset-paginated.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
object
|
The decoded JSON body. |
required |
page_keys
|
PageKeys
|
Which keys identify the shape. |
DEFAULT_PAGE_KEYS
|
Returns:
| Type | Description |
|---|---|
bool
|
Whether it carries both the rows key and the total key. |
Source code in tempestweb/query/pagination.py
offset_page ¶
offset_page(payload: Mapping[str, object], *, page_keys: PageKeys = DEFAULT_PAGE_KEYS) -> OffsetPage
Read an offset-paginated payload.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
Mapping[str, object]
|
The decoded JSON body. |
required |
page_keys
|
PageKeys
|
Which keys to read, for a server naming them differently. |
DEFAULT_PAGE_KEYS
|
Returns:
| Name | Type | Description |
|---|---|---|
The |
OffsetPage
|
class: |
OffsetPage
|
dataclass defaults rather than raising — a listing that renders empty is |
|
OffsetPage
|
recoverable; a screen that raised on the way to rendering is not. |
Source code in tempestweb/query/pagination.py
persist
async
¶
Write every JSON-able cache entry to the store.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cache
|
QueryCache
|
The cache to write out. |
required |
storage
|
QueryStorage
|
Where to write — |
required |
prefix
|
str
|
The storage-key prefix, so :func: |
STORAGE_PREFIX
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
PersistResult
|
class: |
Source code in tempestweb/query/persistence.py
restore
async
¶
Read persisted entries back into a cache.
Entries land fresh, stamped with the cache's clock at restore time.
Reviving them stale would send a boot screen straight back to the network,
which is the thing persisting was supposed to avoid; a screen that wants the
network anyway calls :meth:~tempestweb.query.QueryCache.invalidate right
after.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cache
|
QueryCache
|
The cache to fill. |
required |
storage
|
QueryStorage
|
Where to read from. |
required |
prefix
|
str
|
The storage-key prefix written by :func: |
STORAGE_PREFIX
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
RestoreResult
|
class: |
RestoreResult
|
away. A record that no longer parses is deleted rather than left to |
|
RestoreResult
|
fail on every boot — the shape of a cached value changes when the app |
|
RestoreResult
|
does, and a store that cannot be read is a store that must be cleared. |
Source code in tempestweb/query/persistence.py
should_retry_query ¶
Report whether a failed read is worth attempting again.
A read — this is the query side. Retrying a GET is free; the write side
is :func:tempestweb.native.http.request, which retries only what carries
an idempotency key.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
attempt
|
int
|
How many attempts have already happened, the failed one
included. The first failure passes |
required |
status
|
int | None
|
The HTTP status the server answered, or |
required |
Returns:
| Type | Description |
|---|---|
bool
|
Whether to try again. A network-level failure is retried; a status the |
bool
|
server chose is retried only when it means "later" — a 404 or a 403 will |
bool
|
answer the same way forever, and retrying it just makes the user wait |
bool
|
three times as long for the same error. |