@stonecrop/graphql-client 0.10.16 → 0.11.1
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 +154 -81
- package/dist/client.js +87 -6
- package/dist/graphql-client.d.ts +48 -8
- package/dist/graphql-client.js +4884 -1563
- package/dist/graphql-client.js.map +1 -1
- package/dist/index.js +1 -0
- package/dist/query.js +231 -0
- package/dist/src/client.d.ts +12 -9
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/query.d.ts +35 -0
- package/dist/src/query.d.ts.map +1 -0
- package/package.json +6 -3
- package/src/client.ts +124 -16
- package/src/index.ts +1 -0
- package/src/query.ts +303 -0
package/README.md
CHANGED
|
@@ -1,81 +1,154 @@
|
|
|
1
|
-
# @stonecrop/graphql-client
|
|
2
|
-
|
|
3
|
-
Client-side TypeScript
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
1
|
+
# @stonecrop/graphql-client
|
|
2
|
+
|
|
3
|
+
Client-side TypeScript implementation of the `DataClient` interface for Stonecrop's PostGraphile-based GraphQL API. `StonecropClient` handles HTTP transport, response unwrapping, query building, and metadata caching so application code works with plain TypeScript objects.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @stonecrop/graphql-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { StonecropClient } from '@stonecrop/graphql-client'
|
|
15
|
+
import { Registry, getStonecrop } from '@stonecrop/stonecrop'
|
|
16
|
+
import type { DoctypeMeta } from '@stonecrop/schema'
|
|
17
|
+
|
|
18
|
+
const registry = new Registry()
|
|
19
|
+
// ... register doctypes ...
|
|
20
|
+
|
|
21
|
+
// Build a DoctypeMeta map — StonecropClient expects DoctypeMeta, not Doctype instances
|
|
22
|
+
const metaMap = new Map<string, DoctypeMeta>()
|
|
23
|
+
for (const [slug, doctype] of Object.entries(registry.registry)) {
|
|
24
|
+
metaMap.set(slug, {
|
|
25
|
+
name: doctype.doctype,
|
|
26
|
+
slug,
|
|
27
|
+
tableName: slug.replace(/-/g, '_'),
|
|
28
|
+
fields: doctype.getSchemaArray(),
|
|
29
|
+
links: doctype.links || {},
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const client = new StonecropClient({
|
|
34
|
+
endpoint: 'http://localhost:4000/graphql',
|
|
35
|
+
headers: { Authorization: `Bearer ${token}` }, // optional
|
|
36
|
+
registry: metaMap, // for nested query support
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Wire up the client to the Stonecrop instance
|
|
40
|
+
const stonecrop = getStonecrop()
|
|
41
|
+
if (stonecrop) {
|
|
42
|
+
stonecrop.setClient(client)
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Metadata
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
// Fetch DoctypeMeta for a single doctype (cached after first call)
|
|
50
|
+
const meta = await client.getMeta({ doctype: 'SalesOrder' })
|
|
51
|
+
|
|
52
|
+
// Fetch all registered doctypes
|
|
53
|
+
const allMeta = await client.getAllMeta()
|
|
54
|
+
|
|
55
|
+
// Bust the in-memory cache
|
|
56
|
+
client.clearMetaCache()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Reading Records
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
import { GetRecordOptions, GetRecordsOptions } from '@stonecrop/schema'
|
|
63
|
+
|
|
64
|
+
// Single record by ID (flat — scalar fields only)
|
|
65
|
+
const order = await client.getRecord({ name: 'SalesOrder' }, 'uuid-here')
|
|
66
|
+
|
|
67
|
+
// Single record with all nested descendant links
|
|
68
|
+
const recipe = await client.getRecord({ name: 'Recipe' }, 'r1', {
|
|
69
|
+
includeNested: true,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Single record with specific nested links only
|
|
73
|
+
const recipe = await client.getRecord({ name: 'Recipe' }, 'r1', {
|
|
74
|
+
includeNested: ['tasks'],
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// Single record with limited nesting depth
|
|
78
|
+
const recipe = await client.getRecord({ name: 'Recipe' }, 'r1', {
|
|
79
|
+
includeNested: true,
|
|
80
|
+
maxDepth: 2,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// List with optional filtering and pagination
|
|
84
|
+
const orders = await client.getRecords(
|
|
85
|
+
{ name: 'SalesOrder' },
|
|
86
|
+
{
|
|
87
|
+
filters: { status: 'Draft' },
|
|
88
|
+
orderBy: 'createdAt',
|
|
89
|
+
limit: 20,
|
|
90
|
+
offset: 0,
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Actions
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
// Dispatch any registered action
|
|
99
|
+
const result = await client.runAction({ name: 'SalesOrder' }, 'submit', ['uuid-here'])
|
|
100
|
+
// → { success: boolean; data: unknown; error: string | null }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Raw GraphQL
|
|
104
|
+
|
|
105
|
+
For queries or mutations not covered by the helpers:
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
const data = await client.query<{ myTable: unknown[] }>(`query { myTable { id name } }`)
|
|
109
|
+
|
|
110
|
+
const result = await client.mutate<{ createFoo: unknown }>(
|
|
111
|
+
`mutation CreateFoo($input: CreateFooInput!) { createFoo(input: $input) { foo { id } } }`,
|
|
112
|
+
{ input: { foo: { name: 'bar' } } }
|
|
113
|
+
)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### How Nested Queries Work
|
|
117
|
+
|
|
118
|
+
When `includeNested` is set on `getRecord`:
|
|
119
|
+
|
|
120
|
+
1. The client fetches doctype metadata (including `links`)
|
|
121
|
+
2. Builds a GraphQL query with sub-selections for descendant links
|
|
122
|
+
3. Connection fields (`noneOrMany`/`atLeastOne`) emit `{ nodes { ... } }` sub-selections
|
|
123
|
+
4. Direct fields (`one`/`atMostOne`) emit object sub-selections
|
|
124
|
+
5. Results with connection fields are merged to flat arrays
|
|
125
|
+
|
|
126
|
+
Example query generated for a Recipe with `tasks` (noneOrMany) and `supersededBy` (atMostOne):
|
|
127
|
+
|
|
128
|
+
```graphql
|
|
129
|
+
query GetRecord($id: UUID!) {
|
|
130
|
+
recipeById(id: $id) {
|
|
131
|
+
id
|
|
132
|
+
name
|
|
133
|
+
status
|
|
134
|
+
RecipeTasksByRecipeId {
|
|
135
|
+
nodes {
|
|
136
|
+
id
|
|
137
|
+
name
|
|
138
|
+
description
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
supersededBy {
|
|
142
|
+
id
|
|
143
|
+
name
|
|
144
|
+
status
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The response is merged so `result.tasks` is a flat array and `result.supersededBy` is a direct object.
|
|
151
|
+
|
|
152
|
+
## References
|
|
153
|
+
|
|
154
|
+
For full method signatures and parameter details, see [API Reference](./api.md).
|
package/dist/client.js
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
import { snakeToCamel, toPascalCase } from '@stonecrop/schema';
|
|
2
|
+
import pluralize from 'pluralize';
|
|
3
|
+
import { buildRecordQuery } from './query';
|
|
4
|
+
/**
|
|
5
|
+
* Default inflection functions for PostGraphile Amber preset conventions.
|
|
6
|
+
* These match the middleware's default inflection so the client builds
|
|
7
|
+
* queries the server can execute.
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
const defaultRecordFieldName = (tableName) => {
|
|
11
|
+
const singularName = pluralize.singular(tableName);
|
|
12
|
+
return `${snakeToCamel(singularName)}ById`;
|
|
13
|
+
};
|
|
14
|
+
const defaultRecordArgName = (_tableName) => 'id';
|
|
15
|
+
const defaultRecordArgType = (_tableName) => 'UUID!';
|
|
1
16
|
/**
|
|
2
17
|
* Client for interacting with Stonecrop GraphQL API
|
|
3
18
|
* @public
|
|
@@ -6,12 +21,14 @@ export class StonecropClient {
|
|
|
6
21
|
endpoint;
|
|
7
22
|
headers;
|
|
8
23
|
metaCache = new Map();
|
|
24
|
+
registry;
|
|
9
25
|
constructor(options) {
|
|
10
26
|
this.endpoint = options.endpoint;
|
|
11
27
|
this.headers = {
|
|
12
28
|
'Content-Type': 'application/json',
|
|
13
29
|
...options.headers,
|
|
14
30
|
};
|
|
31
|
+
this.registry = options.registry;
|
|
15
32
|
}
|
|
16
33
|
/**
|
|
17
34
|
* Execute a GraphQL query
|
|
@@ -83,8 +100,6 @@ export class StonecropClient {
|
|
|
83
100
|
}
|
|
84
101
|
}
|
|
85
102
|
inherits
|
|
86
|
-
listDoctype
|
|
87
|
-
parentDoctype
|
|
88
103
|
}
|
|
89
104
|
}
|
|
90
105
|
`, { doctype: context.doctype });
|
|
@@ -134,8 +149,6 @@ export class StonecropClient {
|
|
|
134
149
|
}
|
|
135
150
|
}
|
|
136
151
|
inherits
|
|
137
|
-
listDoctype
|
|
138
|
-
parentDoctype
|
|
139
152
|
}
|
|
140
153
|
}
|
|
141
154
|
`);
|
|
@@ -145,11 +158,33 @@ export class StonecropClient {
|
|
|
145
158
|
return result.stonecropAllMeta;
|
|
146
159
|
}
|
|
147
160
|
/**
|
|
148
|
-
* Get a single record by ID
|
|
161
|
+
* Get a single record by ID.
|
|
162
|
+
*
|
|
163
|
+
* When `includeNested` is set, builds a query with sub-selections for descendant
|
|
164
|
+
* links and returns ancestor + merged descendants. When omitted, returns flat scalar data.
|
|
165
|
+
*
|
|
149
166
|
* @param doctype - Doctype reference (name and optional slug)
|
|
150
167
|
* @param recordId - Record ID to fetch
|
|
168
|
+
* @param options - Query options (includeNested, maxDepth)
|
|
151
169
|
*/
|
|
152
|
-
async getRecord(doctype, recordId) {
|
|
170
|
+
async getRecord(doctype, recordId, options) {
|
|
171
|
+
// Nested path: build query with sub-selections
|
|
172
|
+
if (options?.includeNested) {
|
|
173
|
+
const meta = await this.getMeta({ doctype: doctype.name });
|
|
174
|
+
if (!meta)
|
|
175
|
+
return null;
|
|
176
|
+
const query = buildRecordQuery(meta, defaultRecordFieldName, defaultRecordArgName, defaultRecordArgType, this.registry, options);
|
|
177
|
+
const result = await this.query(query, { id: recordId });
|
|
178
|
+
const queryName = defaultRecordFieldName(meta.tableName || doctype.name);
|
|
179
|
+
const record = result[queryName];
|
|
180
|
+
if (!record)
|
|
181
|
+
return null;
|
|
182
|
+
if (meta.links && this.registry) {
|
|
183
|
+
return mergeNestedResults(record, meta, this.registry);
|
|
184
|
+
}
|
|
185
|
+
return record;
|
|
186
|
+
}
|
|
187
|
+
// Flat path: original query
|
|
153
188
|
const result = await this.query(`
|
|
154
189
|
query GetRecord($doctype: String!, $id: String!) {
|
|
155
190
|
stonecropRecord(doctype: $doctype, id: $id) {
|
|
@@ -219,3 +254,49 @@ export class StonecropClient {
|
|
|
219
254
|
this.metaCache.clear();
|
|
220
255
|
}
|
|
221
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Merge nested connection results into flat arrays.
|
|
259
|
+
*
|
|
260
|
+
* For `noneOrMany`/`atLeastOne` links, the query returns `{ nodes: [...] }`.
|
|
261
|
+
* This flattens them to just `[]` for easier consumption.
|
|
262
|
+
*
|
|
263
|
+
* For `one`/`atMostOne` links, the result is already flat.
|
|
264
|
+
*
|
|
265
|
+
* @internal
|
|
266
|
+
*/
|
|
267
|
+
function mergeNestedResults(record, meta, registry) {
|
|
268
|
+
if (!meta.links)
|
|
269
|
+
return record;
|
|
270
|
+
const merged = { ...record };
|
|
271
|
+
for (const [fieldname, link] of Object.entries(meta.links)) {
|
|
272
|
+
const isMany = link.cardinality === 'noneOrMany' || link.cardinality === 'atLeastOne';
|
|
273
|
+
if (isMany) {
|
|
274
|
+
// Connection result: { nodes: [...] } → flatten to []
|
|
275
|
+
const targetMeta = registry.get(link.target);
|
|
276
|
+
if (!targetMeta)
|
|
277
|
+
continue;
|
|
278
|
+
const connectionField = getConnectionFieldFromTarget(targetMeta, meta.tableName || '');
|
|
279
|
+
const connectionResult = merged[connectionField];
|
|
280
|
+
if (connectionResult?.nodes) {
|
|
281
|
+
merged[fieldname] = connectionResult.nodes;
|
|
282
|
+
delete merged[connectionField];
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
merged[fieldname] = [];
|
|
286
|
+
delete merged[connectionField];
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// 'one'/'atMostOne' links are already at the right fieldname
|
|
290
|
+
}
|
|
291
|
+
return merged;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Derive the connection field name matching the query builder's convention.
|
|
295
|
+
* @internal
|
|
296
|
+
*/
|
|
297
|
+
function getConnectionFieldFromTarget(targetMeta, parentTableName) {
|
|
298
|
+
const targetPlural = pluralize.plural(targetMeta.tableName || '');
|
|
299
|
+
const targetPascal = toPascalCase(targetPlural);
|
|
300
|
+
const fkPascal = toPascalCase(parentTableName) + 'Id';
|
|
301
|
+
return `${targetPascal}By${fkPascal}`;
|
|
302
|
+
}
|
package/dist/graphql-client.d.ts
CHANGED
|
@@ -2,6 +2,43 @@ import type { DataClient } from '@stonecrop/schema';
|
|
|
2
2
|
import type { DoctypeContext } from '@stonecrop/schema';
|
|
3
3
|
import { DoctypeMeta } from '@stonecrop/schema';
|
|
4
4
|
import type { DoctypeRef } from '@stonecrop/schema';
|
|
5
|
+
import type { GetRecordOptions } from '@stonecrop/schema';
|
|
6
|
+
import type { GetRecordsOptions } from '@stonecrop/schema';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build a GraphQL connection query to fetch a list of records.
|
|
10
|
+
*
|
|
11
|
+
* Only declares variables ($limit, $offset, $orderBy) that are actually used,
|
|
12
|
+
* avoiding GraphQL spec violations from unused variable declarations.
|
|
13
|
+
*
|
|
14
|
+
* @param meta - Doctype metadata
|
|
15
|
+
* @param connectionFieldName - Function to derive the connection field name from a table name
|
|
16
|
+
* @param orderByTypeName - Function to derive the order-by type name from a table name
|
|
17
|
+
* @param options - Query options (limit, offset, orderBy)
|
|
18
|
+
* @returns GraphQL query string
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
export declare function buildListQuery(meta: DoctypeMeta, connectionFieldName: (t: string) => string, orderByTypeName: (t: string) => string, options?: GetRecordsOptions): string;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a GraphQL query string from doctype metadata.
|
|
26
|
+
*
|
|
27
|
+
* Generates scalar field selections. When `includeNested` is set,
|
|
28
|
+
* recursively includes descendant link sub-selections derived from
|
|
29
|
+
* the doctype's `links` object.
|
|
30
|
+
*
|
|
31
|
+
* @param meta - Doctype metadata to build the query from
|
|
32
|
+
* @param recordFieldName - Function to derive the query field name from a table name
|
|
33
|
+
* @param recordArgName - Function to derive the argument name from a table name
|
|
34
|
+
* @param recordArgType - Function to derive the argument type from a table name
|
|
35
|
+
* @param registry - Doctype registry for resolving link targets. Required when includeNested is set.
|
|
36
|
+
* @param options - Query options (includeNested, maxDepth)
|
|
37
|
+
* @returns GraphQL query string
|
|
38
|
+
*
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
export declare function buildRecordQuery(meta: DoctypeMeta, recordFieldName: (t: string) => string, recordArgName: (t: string) => string, recordArgType: (t: string) => string, registry?: Map<string, DoctypeMeta>, options?: GetRecordOptions): string;
|
|
5
42
|
|
|
6
43
|
export { DoctypeContext }
|
|
7
44
|
|
|
@@ -81,6 +118,7 @@ export declare class StonecropClient implements DataClient {
|
|
|
81
118
|
private endpoint;
|
|
82
119
|
private headers;
|
|
83
120
|
private metaCache;
|
|
121
|
+
private registry?;
|
|
84
122
|
constructor(options: StonecropClientOptions);
|
|
85
123
|
/**
|
|
86
124
|
* Execute a GraphQL query
|
|
@@ -104,22 +142,22 @@ export declare class StonecropClient implements DataClient {
|
|
|
104
142
|
*/
|
|
105
143
|
getAllMeta(): Promise<DoctypeMeta[]>;
|
|
106
144
|
/**
|
|
107
|
-
* Get a single record by ID
|
|
145
|
+
* Get a single record by ID.
|
|
146
|
+
*
|
|
147
|
+
* When `includeNested` is set, builds a query with sub-selections for descendant
|
|
148
|
+
* links and returns ancestor + merged descendants. When omitted, returns flat scalar data.
|
|
149
|
+
*
|
|
108
150
|
* @param doctype - Doctype reference (name and optional slug)
|
|
109
151
|
* @param recordId - Record ID to fetch
|
|
152
|
+
* @param options - Query options (includeNested, maxDepth)
|
|
110
153
|
*/
|
|
111
|
-
getRecord(doctype: DoctypeRef, recordId: string): Promise<Record<string, unknown> | null>;
|
|
154
|
+
getRecord(doctype: DoctypeRef, recordId: string, options?: GetRecordOptions): Promise<Record<string, unknown> | null>;
|
|
112
155
|
/**
|
|
113
156
|
* Get multiple records with optional filtering and pagination
|
|
114
157
|
* @param doctype - Doctype reference (name and optional slug)
|
|
115
158
|
* @param options - Query options (filters, orderBy, limit, offset)
|
|
116
159
|
*/
|
|
117
|
-
getRecords(doctype: DoctypeRef, options?:
|
|
118
|
-
filters?: Record<string, unknown>;
|
|
119
|
-
orderBy?: string;
|
|
120
|
-
limit?: number;
|
|
121
|
-
offset?: number;
|
|
122
|
-
}): Promise<Record<string, unknown>[]>;
|
|
160
|
+
getRecords(doctype: DoctypeRef, options?: GetRecordsOptions): Promise<Record<string, unknown>[]>;
|
|
123
161
|
/**
|
|
124
162
|
* Execute a doctype action
|
|
125
163
|
* @param doctype - Doctype reference (name and optional slug)
|
|
@@ -146,6 +184,8 @@ export declare interface StonecropClientOptions {
|
|
|
146
184
|
endpoint: string;
|
|
147
185
|
/** Additional HTTP headers to include in requests */
|
|
148
186
|
headers?: Record<string, string>;
|
|
187
|
+
/** Doctype registry for nested query building */
|
|
188
|
+
registry?: Map<string, DoctypeMeta>;
|
|
149
189
|
}
|
|
150
190
|
|
|
151
191
|
/**
|