@@ -11,8 +11,10 @@ applyTo: 'projects/client/src/lib/requests/**'
1111
1212` lib/requests/ ` integrates with two sources:
1313
14- - ** ` @trakt/api ` ** - Typed SDK for Trakt v2 REST API. Use ` api({ fetch }) ` to call it.
15- - ** ` v3/ ` endpoints** - Untyped internal endpoints accessed via ` rawApiFetch ` . Always define a local Zod schema for their response.
14+ - ** ` @trakt/api ` ** - Typed SDK for Trakt v2 REST API. Use ` api({ fetch }) ` to
15+ call it.
16+ - ** ` v3/ ` endpoints** - Untyped internal endpoints accessed via ` rawApiFetch ` .
17+ Always define a local Zod schema for their response.
1618
1719---
1820
@@ -69,8 +71,10 @@ export const someQuery = defineQuery({
6971- ` request ` receives full ` params ` object - destructure only what you need.
7072- ` mapper ` transforms ` response.body ` (typed by SDK) into domain model.
7173- ` schema ` is Zod schema for ** output** - validates what ` mapper ` returns.
72- - ` dependencies ` must list every param that, when changed, should refetch. Use spread helpers for filters/search (see below).
73- - ` invalidations ` lists ` InvalidateAction.* ` tokens that bust this cache when mutations fire.
74+ - ` dependencies ` must list every param that, when changed, should refetch. Use
75+ spread helpers for filters/search (see below).
76+ - ` invalidations ` lists ` InvalidateAction.* ` tokens that bust this cache when
77+ mutations fire.
7478
7579---
7680
@@ -123,7 +127,9 @@ export const someListQuery = defineInfiniteQuery({
123127
124128## Pattern 3 - ` v3/ ` Endpoint Query
125129
126- ` v3/ ` endpoints not covered by ` @trakt/api ` 's type system. ** Always** define a Zod schema locally for their response, parse with it before returning from request function.
130+ ` v3/ ` endpoints not covered by ` @trakt/api ` 's type system. ** Always** define a
131+ Zod schema locally for their response, parse with it before returning from
132+ request function.
127133
128134``` ts
129135import { defineQuery } from ' $lib/features/query/defineQuery.ts' ;
@@ -174,15 +180,19 @@ export const someV3Query = defineQuery({
174180### Key rules
175181
176182- ** Always** ` .parse() ` raw JSON - never trust unvalidated ` v3/ ` responses.
177- - If endpoint can return empty/error body, guard with ` response.ok ` and return ` { body: undefined, status: 200 } ` as fallback so ` mapper ` receives ` undefined ` and handles it gracefully.
183+ - If endpoint can return empty/error body, guard with ` response.ok ` and return
184+ ` { body: undefined, status: 200 } ` as fallback so ` mapper ` receives
185+ ` undefined ` and handles it gracefully.
178186- Export ` ResponseSchema ` when other files need to reference it.
179- - Keep raw response schema (` ...ResponseSchema ` ) and domain schema (` ...Schema ` ) separate.
187+ - Keep raw response schema (` ...ResponseSchema ` ) and domain schema (` ...Schema ` )
188+ separate.
180189
181190---
182191
183192## Pattern 4 - Mutation Request
184193
185- Use for write operations (add, remove, update). Plain async functions - not queries.
194+ Use for write operations (add, remove, update). Plain async functions - not
195+ queries.
186196
187197``` ts
188198import { api , type ApiParams } from ' $lib/requests/api.ts' ;
@@ -202,16 +212,71 @@ export function someActionRequest(
202212}
203213```
204214
205- For ` v3/ ` mutations use ` rawApiFetch ` with ` method: 'POST' ` / ` 'DELETE' ` and validate with Zod if response body matters.
215+ For ` v3/ ` mutations use ` rawApiFetch ` with ` method: 'POST' ` / ` 'DELETE' ` and
216+ validate with Zod if response body matters.
217+
218+ ---
219+
220+ ## Pattern 5 - Offline-Aware Tracking Mutation
221+
222+ User tracking mutations (history, watchlist, ratings, favorites) must survive a
223+ dead connection: they queue in IndexedDB and replay when connectivity returns.
224+ The infrastructure lives in ` lib/features/offline/ ` .
225+
226+ ** Hooks never call these request functions directly.** They go through
227+ ` executeOrEnqueue ` , which runs the request when online and queues it (deduped by
228+ media key set, latest intent wins) on network failure:
229+
230+ ``` ts
231+ import { executeOrEnqueue } from ' $lib/features/offline/executeOrEnqueue.ts' ;
232+ import { toMediaKey } from ' $lib/features/offline/toMediaKey.ts' ;
233+
234+ await executeOrEnqueue ({
235+ endpoint: ' watchlist:add' ,
236+ keys: media .map ((item ) => toMediaKey (type , item .id )),
237+ body ,
238+ invalidations: [InvalidateAction .Watchlisted (type )],
239+ });
240+ await invalidate (InvalidateAction .Watchlisted (type ));
241+ ```
242+
243+ The matching read store overlays the pending queue so the UI reflects the action
244+ while offline (see ` useIsWatched ` / ` useIsWatchlisted ` ):
245+
246+ ``` ts
247+ const pending = findPendingOverride ({ actions: $actions , domain , keys });
248+ if (pending ) return isAddEndpoint (pending .endpoint );
249+ // ...fall through to server state
250+ ```
251+
252+ ### Adding a new offline-queueable action
253+
254+ 1 . Add the endpoint to ` OfflineActionEndpointSchema `
255+ (` offline/models/OfflineActionEndpoint.ts ` ) - format ` {domain}:{add|remove} ` .
256+ 2 . Map its body type in ` offline/models/OfflineActionBody.ts ` .
257+ 3 . Register the executor in ` offline/_internal/executeOfflineAction.ts `
258+ (endpoint -> existing ` *Request ` function).
259+ 4 . Route the hook's write through ` executeOrEnqueue ` with ` toMediaKey ` keys and
260+ the same ` InvalidateAction ` tokens the hook already fires.
261+ 5 . Overlay the read store with ` findPendingOverride ` + ` isAddEndpoint ` .
262+ 6 . If replaying the action can duplicate server-side effects (like extra history
263+ plays), extend the reconcile step
264+ (` offline/_internal/findDuplicateWatch.ts ` ); idempotent actions (watchlist,
265+ ratings, favorites) skip it.
266+
267+ Keep real-time actions (e.g. check-in) out of the queue - replaying them after
268+ the fact is semantically wrong.
206269
207270---
208271
209272## Mapper Functions
210273
211274- Pure functions - no side effects, no API calls.
212275- Named ` mapTo{DomainType}(apiResponse): DomainType ` .
213- - Live in ` _internal/ ` if reused across queries; inline or exported from query file if used only once or twice.
214- - When extending existing mapper (e.g., adding a field), prefer ` .extend() ` on schema rather than duplicating.
276+ - Live in ` _internal/ ` if reused across queries; inline or exported from query
277+ file if used only once or twice.
278+ - When extending existing mapper (e.g., adding a field), prefer ` .extend() ` on
279+ schema rather than duplicating.
215280
216281``` ts
217282// _internal/mapToEntry.ts
@@ -229,7 +294,8 @@ export function mapToEntry(response: EntryResponse): Entry {
229294## Models
230295
231296- Define Zod schema first; derive TypeScript type with ` z.infer ` .
232- - Use ` .nullish() ` for optional nullable fields, ` .optional() ` for fields that may be absent.
297+ - Use ` .nullish() ` for optional nullable fields, ` .optional() ` for fields that
298+ may be absent.
233299- Export both schema (` EntitySchema ` ) and type (` Entity ` ) from same file.
234300
235301``` ts
@@ -281,7 +347,8 @@ Leave `invalidations: []` for queries that never need external cache busting.
281347
282348## Filter & Search Dependencies
283349
284- When a query accepts ` FilterParams ` or ` SearchParams ` , spread the helper functions in ` dependencies ` :
350+ When a query accepts ` FilterParams ` or ` SearchParams ` , spread the helper
351+ functions in ` dependencies ` :
285352
286353``` ts
287354import { getGlobalFilterDependencies } from ' $lib/requests/_internal/getGlobalFilterDependencies.ts' ;
@@ -335,3 +402,5 @@ Before submitting a new query or request, verify:
335402- [ ] ` invalidations ` lists relevant ` InvalidateAction.* ` tokens
336403- [ ] Mapper is pure function - no side effects
337404- [ ] ` ttl ` is appropriate for data's freshness requirements
405+ - [ ] Tracking mutation: hook routes through ` executeOrEnqueue ` (Pattern 5),
406+ read store overlays the pending queue via ` findPendingOverride `
0 commit comments