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 CHANGED
@@ -1,13 +1,19 @@
1
1
  # stash-graphql-client
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/stash-graphql-client)](https://www.npmjs.com/package/stash-graphql-client)
4
+ [![CI](https://github.com/shalemore/stash-graphql-client/actions/workflows/ci.yml/badge.svg)](https://github.com/shalemore/stash-graphql-client/actions/workflows/ci.yml)
5
+ [![License](https://img.shields.io/npm/l/stash-graphql-client)](LICENSE)
6
+
3
7
  A fully typed client for the [Stash](https://github.com/stashapp/stash) GraphQL API, without writing GraphQL.
4
8
 
5
- - **Pick fields by reading them.** Your editor autocompletes them, and the result type has exactly the fields you picked.
6
- - **Generated from Stash's own schema** (latest release, see `STASH_VERSION`), so the names match.
7
- - **No dependencies.** It just uses `fetch`, and is about 4.5 KB gzipped.
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
- bun add stash-graphql-client
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, // sent as the ApiKey header
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
- count: result.count,
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) => ({ count: result.cont }));
39
- // ~~~~ unknown field
40
+ await stash.findPerformers((result) => result.$pick("cont"));
41
+ // ~~~~~~ unknown field
40
42
  ```
41
43
 
42
- ## Good to know
44
+ ## Selecting fields
43
45
 
44
- - **Looking for a field or argument?** Everything is typed, with the schema's docs as JSDoc. In the installed package, see `dist/generated/schema.d.ts` for object and input types and `dist/generated/roots.d.ts` for the root fields.
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
- ## Selecting fields
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, // string | null, same as in the schema
55
- studio: found.studio((studio) => ({ id: studio.id, name: studio.name })), // call it to pick nested fields
60
+ title: found.title,
61
+ studio: found.studio((studio) => ({ id: studio.id, name: studio.name })),
56
62
  }));
57
63
  ```
58
64
 
59
- Want a different name? Just use a different key. Fields with arguments can even be picked twice:
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
- id: group.id,
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 for you, so you can narrow on it:
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) => ({ path: picture.path, width: picture.width })),
78
- ...file.$on("VideoFile", (video) => ({ path: video.path, duration: video.duration })),
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) => ({ count: result.count }));
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 just a function, so you can share it like a fragment. `Fields` and `Result` are only types, so they cost nothing at runtime:
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 = ({ id, name }: Fields<"Tag">) => ({ id, name });
113
+ const tagFields = (tag: Fields<"Tag">) => tag.$pick("id", "name");
108
114
 
109
115
  export const performerCard = (performer: Fields<"Performer">) => ({
110
- id: performer.id,
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
- id: scene.id,
126
+ ...scene.$pick("id"),
124
127
  tags: scene.tags(tagFields),
125
- ...(withFiles ? { files: scene.files(({ path, size }) => ({ path, size })) } : {}), // 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, just to build the request, so don't `await` or do side effects in them. Destructuring the parameter picks those fields, but `...rest` picks nothing.
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) => ({ count: result.count })),
136
- favorites: query.findScenes({ scene_filter: { performer_favorite: true } }, (result) => ({
137
- count: result.count,
138
- })),
139
- performers: query.findPerformers((result) => ({ count: result.count })),
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
- That's a single HTTP request, and the keys become aliases. `mutation` works the same way and runs in the order you wrote. A query and a mutation can't share a request, and Stash doesn't accept batched HTTP, so use `Promise.all` to run them side by side.
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) => ({ id: performer.id, favorite: performer.favorite }),
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: Bun.file("export.zip"),
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
- type: update.type,
174
- job: update.job((job) => ({ id: job.id, progress: job.progress })),
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
- // Send any GraphQL you like; the result is untyped
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
- ## Development
242
+ ## Stash versions
210
243
 
211
- There's a script you can run against your own server in [`example/index.ts`](example/index.ts): `STASH_URL=http://localhost:9999 STASH_API_KEY=... bun example/index.ts`.
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
- > [!NOTE]
223
- > `@defer` isn't supported. Stash declares it but ignores it and always sends the full result.
246
+ ## License
224
247
 
225
- > [!NOTE]
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({}, { get: (_, key) => (typeof key !== "string" ? undefined : key === "$on" ? on : field(key)) });
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.1",
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
  }