@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 +21 -0
- package/README.md +183 -0
- package/dist/index.cjs +836 -0
- package/dist/index.d.cts +438 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +438 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +810 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +65 -0
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
|