stash-graphql-client 0.31.1 → 0.31.2
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/README.md +86 -64
- package/dist/request.js +12 -1
- package/dist/types.d.ts +10 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
# stash-graphql-client
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/stash-graphql-client)
|
|
4
|
+
[](https://github.com/shalemore/stash-graphql-client/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
3
7
|
A fully typed client for the [Stash](https://github.com/stashapp/stash) GraphQL API, without writing GraphQL.
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
- **
|
|
9
|
+
> This version of the package is generated from the GraphQL schema of [Stash v0.31.1](https://github.com/stashapp/stash/releases/tag/v0.31.1).
|
|
10
|
+
|
|
11
|
+
- **Typed field selection.** Fields are selected with callbacks, and the result type contains exactly the selected fields.
|
|
12
|
+
- **Generated from Stash's own schema** (the latest release, see [Stash versions](#stash-versions)), so the names match.
|
|
13
|
+
- **Small.** Built on `fetch`, about 6 KB gzipped. The only dependency, `graphql-ws`, is loaded on the first subscription.
|
|
8
14
|
|
|
9
15
|
```sh
|
|
10
|
-
|
|
16
|
+
npm install stash-graphql-client
|
|
11
17
|
```
|
|
12
18
|
|
|
13
19
|
## Quick start
|
|
@@ -17,51 +23,51 @@ import { createStash } from "stash-graphql-client";
|
|
|
17
23
|
|
|
18
24
|
const stash = createStash({
|
|
19
25
|
endpoint: "http://localhost:9999/graphql",
|
|
20
|
-
apiKey: process.env.STASH_API_KEY,
|
|
26
|
+
apiKey: process.env.STASH_API_KEY,
|
|
21
27
|
});
|
|
22
28
|
|
|
23
29
|
// Every root field is a method: (arguments, what to select)
|
|
24
30
|
const { count, performers } = await stash.findPerformers(
|
|
25
31
|
{ filter: { per_page: 20, sort: "name", direction: "ASC" } },
|
|
26
32
|
(result) => ({
|
|
27
|
-
|
|
28
|
-
performers: result.performers((performer) => (
|
|
29
|
-
id: performer.id,
|
|
30
|
-
name: performer.name,
|
|
31
|
-
favorite: performer.favorite,
|
|
32
|
-
})),
|
|
33
|
+
...result.$pick("count"),
|
|
34
|
+
performers: result.performers((performer) => performer.$pick("id", "name", "favorite")),
|
|
33
35
|
}),
|
|
34
36
|
);
|
|
35
37
|
// count: number
|
|
36
38
|
// performers: { id: string; name: string; favorite: boolean }[]
|
|
37
39
|
|
|
38
|
-
await stash.findPerformers((result) => (
|
|
39
|
-
//
|
|
40
|
+
await stash.findPerformers((result) => result.$pick("cont"));
|
|
41
|
+
// ~~~~~~ unknown field
|
|
40
42
|
```
|
|
41
43
|
|
|
42
|
-
##
|
|
44
|
+
## Selecting fields
|
|
43
45
|
|
|
44
|
-
|
|
45
|
-
- **Read scalars, call objects.** `found.title` selects a scalar; `found.studio(...)` selects an object with a nested selection.
|
|
46
|
-
- **Return only selections.** The returned object may contain just selected fields, nested selections and `$on` spreads. Compute things after the request, not inside the callback.
|
|
47
|
-
- **Reach for plain GraphQL last.** Use `stash.request` only for something the typed API can't express.
|
|
46
|
+
`$pick` selects scalar fields by name; an object field is called with its own selection:
|
|
48
47
|
|
|
49
|
-
|
|
48
|
+
```ts
|
|
49
|
+
const scene = await stash.findScene({ id: "42" }, (found) => ({
|
|
50
|
+
...found.$pick("id", "title"), // title: string | null, same as in the schema
|
|
51
|
+
studio: found.studio((studio) => studio.$pick("id", "name")),
|
|
52
|
+
}));
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A scalar can also be read directly, which selects it under the same name:
|
|
50
56
|
|
|
51
57
|
```ts
|
|
52
58
|
const scene = await stash.findScene({ id: "42" }, (found) => ({
|
|
53
59
|
id: found.id,
|
|
54
|
-
title: found.title,
|
|
55
|
-
studio: found.studio((studio) => ({ id: studio.id, name: studio.name })),
|
|
60
|
+
title: found.title,
|
|
61
|
+
studio: found.studio((studio) => ({ id: studio.id, name: studio.name })),
|
|
56
62
|
}));
|
|
57
63
|
```
|
|
58
64
|
|
|
59
|
-
|
|
65
|
+
Use a different key to rename a field. A field with arguments can be selected more than once under different keys:
|
|
60
66
|
|
|
61
67
|
```ts
|
|
62
68
|
const { groups } = await stash.findGroups((result) => ({
|
|
63
69
|
groups: result.groups((group) => ({
|
|
64
|
-
|
|
70
|
+
...group.$pick("id"),
|
|
65
71
|
rating: group.rating100,
|
|
66
72
|
directScenes: group.scene_count({ depth: 0 }),
|
|
67
73
|
allScenes: group.scene_count({ depth: -1 }),
|
|
@@ -69,13 +75,13 @@ const { groups } = await stash.findGroups((result) => ({
|
|
|
69
75
|
}));
|
|
70
76
|
```
|
|
71
77
|
|
|
72
|
-
For unions and interfaces, use `$on` to pick fields of one type. `__typename` is added
|
|
78
|
+
For unions and interfaces, use `$on` to pick fields of one type. `__typename` is added automatically, so the result can be narrowed on it:
|
|
73
79
|
|
|
74
80
|
```ts
|
|
75
81
|
const image = await stash.findImage({ id: "7" }, (found) => ({
|
|
76
82
|
visual_files: found.visual_files((file) => ({
|
|
77
|
-
...file.$on("ImageFile", (picture) => (
|
|
78
|
-
...file.$on("VideoFile", (video) => (
|
|
83
|
+
...file.$on("ImageFile", (picture) => picture.$pick("path", "width")),
|
|
84
|
+
...file.$on("VideoFile", (video) => video.$pick("path", "duration")),
|
|
79
85
|
})),
|
|
80
86
|
}));
|
|
81
87
|
|
|
@@ -94,21 +100,20 @@ const favorites: SceneFilterType = {
|
|
|
94
100
|
rating100: { value: 80, modifier: "GREATER_THAN" satisfies CriterionModifier },
|
|
95
101
|
};
|
|
96
102
|
|
|
97
|
-
await stash.findScenes({ scene_filter: favorites }, (result) => (
|
|
103
|
+
await stash.findScenes({ scene_filter: favorites }, (result) => result.$pick("count"));
|
|
98
104
|
```
|
|
99
105
|
|
|
100
106
|
## Reusing selections
|
|
101
107
|
|
|
102
|
-
A selection is
|
|
108
|
+
A selection is a plain function, so it can be shared like a GraphQL fragment. `Fields` and `Result` are types only and have no runtime cost:
|
|
103
109
|
|
|
104
110
|
```ts
|
|
105
111
|
import type { Args, Fields, Result } from "stash-graphql-client";
|
|
106
112
|
|
|
107
|
-
const tagFields = (
|
|
113
|
+
const tagFields = (tag: Fields<"Tag">) => tag.$pick("id", "name");
|
|
108
114
|
|
|
109
115
|
export const performerCard = (performer: Fields<"Performer">) => ({
|
|
110
|
-
|
|
111
|
-
name: performer.name,
|
|
116
|
+
...performer.$pick("id", "name"),
|
|
112
117
|
image: performer.image_path,
|
|
113
118
|
tags: performer.tags(tagFields),
|
|
114
119
|
});
|
|
@@ -116,49 +121,59 @@ export const performerCard = (performer: Fields<"Performer">) => ({
|
|
|
116
121
|
export type PerformerCard = Result<typeof performerCard>;
|
|
117
122
|
// { id: string; name: string; image: string | null; tags: { id: string; name: string }[] }
|
|
118
123
|
|
|
119
|
-
type ScenesArgs = Args<"findScenes">;
|
|
120
|
-
|
|
121
124
|
// Add parameters to make it configurable
|
|
122
125
|
const sceneCard = (scene: Fields<"Scene">, withFiles: boolean) => ({
|
|
123
|
-
|
|
126
|
+
...scene.$pick("id"),
|
|
124
127
|
tags: scene.tags(tagFields),
|
|
125
|
-
...(withFiles ? { files: scene.files((
|
|
128
|
+
...(withFiles ? { files: scene.files((file) => file.$pick("path", "size")) } : {}), // files?: ...
|
|
126
129
|
});
|
|
130
|
+
|
|
131
|
+
// Args is the argument type of a root field, handy for wrapping a call in your own function
|
|
132
|
+
const listScenes = (args: Args<"findScenes">) =>
|
|
133
|
+
stash.findScenes(args, (result) => ({
|
|
134
|
+
...result.$pick("count"),
|
|
135
|
+
scenes: result.scenes((scene) => sceneCard(scene, false)),
|
|
136
|
+
}));
|
|
127
137
|
```
|
|
128
138
|
|
|
129
|
-
Callbacks run once,
|
|
139
|
+
Callbacks run once, only to build the request. They must return only fields, nested selections and `$on` spreads. Don't `await` or compute anything inside them; do that with the result instead.
|
|
140
|
+
|
|
141
|
+
> [!WARNING]
|
|
142
|
+
> Don't use `...rest` on the callback parameter. It is always empty, so nothing is selected.
|
|
130
143
|
|
|
131
144
|
## Several root fields in one request
|
|
132
145
|
|
|
133
146
|
```ts
|
|
134
147
|
const totals = await stash.query((query) => ({
|
|
135
|
-
scenes: query.findScenes((result) => (
|
|
136
|
-
favorites: query.findScenes({ scene_filter: { performer_favorite: true } }, (result) =>
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
performers: query.findPerformers((result) => (
|
|
148
|
+
scenes: query.findScenes((result) => result.$pick("count")),
|
|
149
|
+
favorites: query.findScenes({ scene_filter: { performer_favorite: true } }, (result) =>
|
|
150
|
+
result.$pick("count"),
|
|
151
|
+
),
|
|
152
|
+
performers: query.findPerformers((result) => result.$pick("count")),
|
|
140
153
|
}));
|
|
141
154
|
|
|
142
155
|
totals.favorites.count; // number
|
|
143
156
|
```
|
|
144
157
|
|
|
145
|
-
|
|
158
|
+
This sends a single HTTP request, and the keys become aliases. `mutation` works the same way and runs the fields in the order written. A query and a mutation can't share a request, and Stash doesn't accept batched HTTP, so use `Promise.all` to run them concurrently.
|
|
146
159
|
|
|
147
160
|
## Mutations and file uploads
|
|
148
161
|
|
|
149
162
|
```ts
|
|
163
|
+
import { openAsBlob } from "node:fs";
|
|
164
|
+
|
|
150
165
|
const updated = await stash.performerUpdate(
|
|
151
166
|
{ input: { id: "3", favorite: true, rating100: 90 } },
|
|
152
|
-
(performer) => (
|
|
167
|
+
(performer) => performer.$pick("id", "favorite"),
|
|
153
168
|
);
|
|
154
169
|
|
|
155
170
|
// Returns a plain boolean, so there's nothing to select
|
|
156
171
|
const deleted: boolean = await stash.performerDestroy({ input: { id: "3" } });
|
|
157
172
|
|
|
158
|
-
// Pass a Blob or File for uploads
|
|
173
|
+
// Pass a Blob or File for uploads: openAsBlob on Node, Bun.file on Bun, or a file input in the browser
|
|
159
174
|
const jobId = await stash.importObjects({
|
|
160
175
|
input: {
|
|
161
|
-
file:
|
|
176
|
+
file: await openAsBlob("export.zip"),
|
|
162
177
|
duplicateBehaviour: "IGNORE",
|
|
163
178
|
missingRefBehaviour: "IGNORE",
|
|
164
179
|
},
|
|
@@ -170,8 +185,8 @@ const jobId = await stash.importObjects({
|
|
|
170
185
|
```ts
|
|
171
186
|
// Returns an async iterator; leaving the loop unsubscribes
|
|
172
187
|
const jobs = stash.jobsSubscribe((update) => ({
|
|
173
|
-
|
|
174
|
-
job: update.job((job) => (
|
|
188
|
+
...update.$pick("type"),
|
|
189
|
+
job: update.job((job) => job.$pick("id", "progress")),
|
|
175
190
|
}));
|
|
176
191
|
|
|
177
192
|
for await (const update of jobs) {
|
|
@@ -180,13 +195,31 @@ for await (const update of jobs) {
|
|
|
180
195
|
}
|
|
181
196
|
```
|
|
182
197
|
|
|
198
|
+
Subscriptions go over a WebSocket through [`graphql-ws`](https://github.com/enisdenjo/graphql-ws). The client is created on the first subscription, at the same address as `endpoint` (`http` becomes `ws`, `https` becomes `wss`).
|
|
199
|
+
|
|
200
|
+
- **Authentication.** On Node.js and Bun, `apiKey` and `headers` are sent with the WebSocket handshake too. Browsers can't set headers on a WebSocket, so there Stash has to recognize the session cookie.
|
|
201
|
+
- **Needs a `WebSocket`.** That is global in browsers, on Node.js 22+ and on Bun. On older versions, or to configure reconnecting, pass your own `graphql-ws` client. A custom client must also handle authentication.
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
import { createClient } from "graphql-ws";
|
|
205
|
+
|
|
206
|
+
const stash = createStash({
|
|
207
|
+
endpoint: "http://localhost:9999/graphql",
|
|
208
|
+
subscriptions: createClient({
|
|
209
|
+
url: "ws://localhost:9999/graphql",
|
|
210
|
+
webSocketImpl: WebSocket, // e.g. the `ws` package on older Node.js
|
|
211
|
+
retryAttempts: Infinity,
|
|
212
|
+
}),
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
183
216
|
## Raw GraphQL and errors
|
|
184
217
|
|
|
185
218
|
```ts
|
|
186
219
|
import { StashError } from "stash-graphql-client";
|
|
187
220
|
|
|
188
221
|
try {
|
|
189
|
-
//
|
|
222
|
+
// For what the typed API can't express; the result is untyped
|
|
190
223
|
await stash.request(`query ($id: ID!) { scene: findScene(id: $id) { id title } }`, { id: "42" });
|
|
191
224
|
} catch (error) {
|
|
192
225
|
// If part of the request worked, that data is in error.data
|
|
@@ -202,25 +235,14 @@ createStash({
|
|
|
202
235
|
apiKey: "...",
|
|
203
236
|
headers: { "X-Proxy-Token": "..." }, // e.g. for a reverse proxy
|
|
204
237
|
fetch: (url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(10_000) }), // or any fetch-like function
|
|
205
|
-
// subscriptions: your own graphql-ws client
|
|
238
|
+
// subscriptions: your own graphql-ws client, see Subscriptions
|
|
206
239
|
});
|
|
207
240
|
```
|
|
208
241
|
|
|
209
|
-
##
|
|
242
|
+
## Stash versions
|
|
210
243
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
```sh
|
|
214
|
-
bun install
|
|
215
|
-
bun check # type check
|
|
216
|
-
bun run lint # oxfmt and oxlint
|
|
217
|
-
bun test # also type-checks the examples in this README
|
|
218
|
-
bun run build # writes dist
|
|
219
|
-
bun run schema # update to the latest Stash release (or: bun run schema v0.30.0)
|
|
220
|
-
```
|
|
244
|
+
Package `0.31.x` tracks the Stash `v0.31` series; patch numbers are independent. `STASH_VERSION` is the Stash release the schema comes from.
|
|
221
245
|
|
|
222
|
-
|
|
223
|
-
> `@defer` isn't supported. Stash declares it but ignores it and always sends the full result.
|
|
246
|
+
## License
|
|
224
247
|
|
|
225
|
-
|
|
226
|
-
> This project was written with AI, and it runs in my own projects day to day. If you hit a bug, please [open an issue](https://github.com/shalemore/stash-graphql-client/issues/new/choose).
|
|
248
|
+
[MIT](LICENSE)
|
package/dist/request.js
CHANGED
|
@@ -5,7 +5,15 @@ const nodes = new WeakMap();
|
|
|
5
5
|
* The fields of any object type: reading `fields.name` selects that field, and calling it adds
|
|
6
6
|
* arguments and a nested selection. It needs no schema, because the types already check usage.
|
|
7
7
|
*/
|
|
8
|
-
export const fields = new Proxy({}, {
|
|
8
|
+
export const fields = new Proxy({}, {
|
|
9
|
+
get: (_, key) => typeof key !== "string"
|
|
10
|
+
? undefined
|
|
11
|
+
: key === "$on"
|
|
12
|
+
? on
|
|
13
|
+
: key === "$pick"
|
|
14
|
+
? pick
|
|
15
|
+
: field(key),
|
|
16
|
+
});
|
|
9
17
|
/** Selects a field; also called by the client for root fields. */
|
|
10
18
|
export function field(name, args, select) {
|
|
11
19
|
const call = (first, second) => isCallback(first)
|
|
@@ -14,6 +22,9 @@ export function field(name, args, select) {
|
|
|
14
22
|
nodes.set(call, { kind: "field", name, args, select });
|
|
15
23
|
return call;
|
|
16
24
|
}
|
|
25
|
+
function pick(...names) {
|
|
26
|
+
return Object.fromEntries(names.map((name) => [name, field(name)]));
|
|
27
|
+
}
|
|
17
28
|
function on(type, select) {
|
|
18
29
|
const fragment = {};
|
|
19
30
|
nodes.set(fragment, { kind: "fragment", type, select: select(fields) });
|
package/dist/types.d.ts
CHANGED
|
@@ -71,15 +71,25 @@ type FieldOf<Definition> = Definition extends F<infer A, infer T> ? Unwrap<T> ex
|
|
|
71
71
|
type On<Name extends keyof Abstract> = <M extends Abstract[Name] & ObjectName, S extends Selection>(type: M, select: Select<M, S>) => {
|
|
72
72
|
[K in `... on ${M}`]: Fragment<Output<M, S>>;
|
|
73
73
|
};
|
|
74
|
+
/** Fields that can be selected by name alone: scalars without required arguments. */
|
|
75
|
+
type Pickable<Name extends ObjectName> = {
|
|
76
|
+
[K in keyof Objects[Name]]: FieldOf<Objects[Name][K]> extends Field<unknown> ? K : never;
|
|
77
|
+
}[keyof Objects[Name]];
|
|
78
|
+
/** Selects scalar fields by name. Spread the result into a selection, or return it as is. */
|
|
79
|
+
type PickFields<Name extends ObjectName> = <K extends Pickable<Name>>(...names: K[]) => {
|
|
80
|
+
[P in K]: FieldOf<Objects[Name][P]>;
|
|
81
|
+
};
|
|
74
82
|
/**
|
|
75
83
|
* The fields of object type `Name`, passed to selection callbacks. Reading a field selects it;
|
|
76
84
|
* fields with arguments are called with them, and object fields with a nested selection.
|
|
85
|
+
* Don't use `...rest` on it: the object is empty at runtime, so a rest element selects nothing.
|
|
77
86
|
*/
|
|
78
87
|
export type Fields<Name extends ObjectName> = {
|
|
79
88
|
readonly [K in keyof Objects[Name]]: FieldOf<Objects[Name][K]>;
|
|
80
89
|
} & {
|
|
81
90
|
/** The name of the object type. */
|
|
82
91
|
readonly __typename: Field<Name extends keyof Abstract ? Abstract[Name] : Name>;
|
|
92
|
+
readonly $pick: PickFields<Name>;
|
|
83
93
|
/** Type-only: lets `Result` find the object type of a selection callback. */
|
|
84
94
|
readonly [fieldsOf]?: Name;
|
|
85
95
|
} & (Name extends keyof Abstract ? {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stash-graphql-client",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.2",
|
|
4
4
|
"description": "Fully typed client for the Stash GraphQL API, without writing GraphQL",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"graphql",
|
|
@@ -49,5 +49,8 @@
|
|
|
49
49
|
"typescript": "^7.0.2",
|
|
50
50
|
"zod": "^4.6.7"
|
|
51
51
|
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=20"
|
|
54
|
+
},
|
|
52
55
|
"packageManager": "bun@1.4.3"
|
|
53
56
|
}
|