@haddowg/json-api-query 1.0.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gregory Haddow
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # @haddowg/json-api-query
2
+
3
+ > **Part of the [jsonapi.rest](https://jsonapi.rest) suite** — a complete, spec-compliant
4
+ > JSON:API 1.1 stack: a framework-agnostic PHP [core](https://github.com/haddowg/json-api), a
5
+ > [Symfony bundle](https://github.com/haddowg/json-api-symfony), a
6
+ > [Laravel package](https://github.com/haddowg/json-api-laravel), and this **typed TypeScript
7
+ > client**, bound together by one conformance-tested OpenAPI 3.1 contract.
8
+ > Full documentation: **[haddowg.github.io/json-api-ts](https://haddowg.github.io/json-api-ts/)**.
9
+
10
+ TanStack Query bindings for the [`@haddowg/json-api-client`](../client) runtime: query/mutation
11
+ **option factories** (not pre-bound hooks), a deterministic query-key factory, and bespoke
12
+ `type:id` write-through cache normalization.
13
+
14
+ Because the surface is option factories over `@tanstack/query-core`, one binding covers
15
+ React / Vue / Svelte / Solid — you pass the returned `{ queryKey, queryFn }` / `MutationOptions`
16
+ to your framework's `useQuery` / `useMutation`.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pnpm add @haddowg/json-api-query @tanstack/query-core
22
+ ```
23
+
24
+ `@tanstack/query-core` is a peer dependency.
25
+
26
+ ## Binding the client
27
+
28
+ The read factories accept `Client<D, A>` and the write factories `Client<D, A, W>` — neither
29
+ carries the client's custom-action types (actions aren't cacheable resources). The generated
30
+ `createClient` returns a client that _also_ carries action types, so view it through the
31
+ action-agnostic `Client` types at the query boundary — one cast per surface:
32
+
33
+ ```ts
34
+ import type { Client } from '@haddowg/json-api-client'
35
+ import { createClient } from './api/music.gen'
36
+ import type { Attributes, ResourceMap, WriteAttributes } from './api/music.gen'
37
+
38
+ const client = createClient({ baseUrl: 'https://music.example' })
39
+
40
+ type ReadClient = Client<ResourceMap, Attributes>
41
+ type WriteClient = Client<ResourceMap, Attributes, WriteAttributes>
42
+ ```
43
+
44
+ ## Reads — query option factories
45
+
46
+ Each factory returns a plain `{ queryKey, queryFn }`, structurally compatible with query-core's
47
+ `QueryOptions`, preserving the client's full static narrowing (`include` widens, `fields`
48
+ narrows). Two equivalent forms:
49
+
50
+ ```ts
51
+ import { createQueryApi, listQueryOptions } from '@haddowg/json-api-query'
52
+
53
+ // Bound API — the cleanest per-type call shape.
54
+ const reads = createQueryApi(client as unknown as ReadClient)
55
+ const listOpts = reads.albums.list({ include: ['artist'] })
56
+ const getOpts = reads.albums.get('1')
57
+ const relOpts = reads.albums.relationship('1', 'tracks') // linkage
58
+ const relatedOpts = reads.albums.related('1', 'tracks') // related collection
59
+
60
+ // Standalone factories — equivalent, with `client`/`type` passed explicitly.
61
+ const listOpts2 = listQueryOptions(client as unknown as ReadClient, 'albums', {
62
+ include: ['artist'],
63
+ })
64
+
65
+ // Pass to your framework's useQuery, or fetch directly:
66
+ const albums = await queryClient.fetchQuery(listOpts)
67
+ ```
68
+
69
+ The standalone factories are `listQueryOptions`, `getQueryOptions`, `relationshipQueryOptions`,
70
+ `relatedQueryOptions`.
71
+
72
+ ## The query-key factory
73
+
74
+ Keys are stable, hierarchical readonly tuples — `[type, operation, id?, rel?, params?]`. The
75
+ hierarchy is the contract targeted invalidation leans on (TanStack matches by prefix): `[type]`
76
+ invalidates a whole type, `[type, operation]` an operation's subtree, `[type, 'fetchOne', id]` a
77
+ single resource. Params ride the last segment as a normalised object, so two semantically-equal
78
+ queries (keys reordered, equal values) share one key.
79
+
80
+ ```ts
81
+ import { keyFor, operationKey, resourceKey, typeKey } from '@haddowg/json-api-query'
82
+
83
+ keyFor({ type: 'albums', operation: 'fetchOne', id: '1' }) // ['albums', 'fetchOne', '1']
84
+ typeKey('albums') // ['albums']
85
+ operationKey('albums', 'fetchMany') // ['albums', 'fetchMany']
86
+ resourceKey('albums', '1') // ['albums', 'fetchOne', '1']
87
+
88
+ queryClient.invalidateQueries({ queryKey: typeKey('albums') }) // every albums read
89
+ ```
90
+
91
+ Also exported: `relationKey`, `normalizeParams`.
92
+
93
+ ## Normalization (`type:id` write-through patching)
94
+
95
+ TanStack keeps denormalized results; this package indexes every resource (`data` + `included`) by
96
+ `type:id` and, on a change, **patches every cached query holding that `type:id` in place** — "edit
97
+ once, updates everywhere". Patching replaces a node's _attributes_ (the descriptor knows attributes
98
+ vs relations) while preserving edge-local `$pivot` / `$edge`.
99
+
100
+ `installNormalization` auto-runs the patch on every successful query/mutation:
101
+
102
+ ```ts
103
+ import { installNormalization } from '@haddowg/json-api-query'
104
+ import { resourceMap } from './api/music.gen'
105
+ import { QueryClient } from '@tanstack/query-core'
106
+
107
+ const queryClient = new QueryClient()
108
+ const teardown = installNormalization(queryClient, resourceMap)
109
+ // … later: teardown() removes the subscriptions.
110
+ ```
111
+
112
+ > **One descriptor per QueryClient.** The patch matches by `type:id` alone, so two descriptors
113
+ > sharing a QueryClient would cross-contaminate attributes. Installing a _different_ descriptor on
114
+ > the same QueryClient throws. Give each typed client/server its own QueryClient.
115
+
116
+ Prefer explicit control? Skip the install and call `normalize(queryClient, result, resourceMap)`
117
+ from an option factory's `onSuccess` — same write-through patch.
118
+
119
+ ## Writes — mutation option factories
120
+
121
+ Each returns a `MutationOptions` (`mutationFn` + the lifecycle callbacks the binding wires for
122
+ normalization / invalidation / optimism), structurally compatible with query-core. The
123
+ **patch-vs-invalidate split**:
124
+
125
+ - an **update** (or a relationship `set` / `replace`) only changes a node's attributes/linkage →
126
+ on success it patches via `normalize` (no refetch);
127
+ - a **create** / **delete** changes collection membership → on settle it invalidates the relevant
128
+ list / relationship subtrees so they refetch.
129
+
130
+ ```ts
131
+ import { createMutationApi } from '@haddowg/json-api-query'
132
+
133
+ const writes = createMutationApi(queryClient, client as unknown as WriteClient, resourceMap)
134
+
135
+ // Create — invalidates the type's lists on settle; normalizes the new resource on success.
136
+ const createOpts = writes.albums.create()
137
+
138
+ // Update — patches across the cache on success. `{ optimistic: true }` pre-applies the patch's
139
+ // attributes through the normalized patch and rolls back on error.
140
+ const updateOpts = writes.albums.id('1').update({ optimistic: true })
141
+
142
+ // Delete — invalidates the type's lists + the resource's own reads.
143
+ const deleteOpts = writes.albums.id('1').delete()
144
+
145
+ // Relationship mutations — invalidate the parent's relation reads (never the type's lists).
146
+ const addOpts = writes.albums.id('1').rel('tracks').add()
147
+ const setOpts = writes.albums.id('1').rel('artist').set()
148
+ ```
149
+
150
+ The variables a mutation takes mirror the client: `create()` takes the flat create input,
151
+ `update()` the patch, `add/remove/replace()` the linkage refs, `set()` a ref or `null`.
152
+
153
+ The standalone factories are `createMutationOptions`, `updateMutationOptions`,
154
+ `deleteMutationOptions`, `addRelationshipMutationOptions`, `removeRelationshipMutationOptions`,
155
+ `replaceRelationshipMutationOptions`, `setRelationshipMutationOptions`, and a thin
156
+ `atomicMutationOptions` passthrough.
157
+
158
+ ### Worked example (query-core directly)
159
+
160
+ ```ts
161
+ const reads = createQueryApi(client as unknown as ReadClient)
162
+ const writes = createMutationApi(queryClient, client as unknown as WriteClient, resourceMap)
163
+
164
+ const listOpts = reads.albums.list()
165
+ const getOpts = reads.albums.get('1')
166
+ await queryClient.fetchQuery(listOpts)
167
+ await queryClient.fetchQuery(getOpts)
168
+
169
+ const updateOpts = writes.albums.id('1').update()
170
+ await queryClient
171
+ .getMutationCache()
172
+ .build(queryClient, updateOpts as never)
173
+ .execute({ title: 'Fresh title' })
174
+
175
+ // Both the cached list AND the cached get now reflect the fresh title — patched by `type:id`,
176
+ // not refetched.
177
+ queryClient.getQueryData(listOpts.queryKey)
178
+ queryClient.getQueryData(getOpts.queryKey)
179
+ ```
180
+
181
+ ## License
182
+
183
+ MIT