@stonecrop/graphql-client 0.10.16 → 0.11.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/README.md CHANGED
@@ -1,81 +1,154 @@
1
- # @stonecrop/graphql-client
2
-
3
- Client-side TypeScript interface to the Stonecrop GraphQL API. `StonecropClient` wraps the `stonecrop*` operations added to a PostGraphile schema by `@stonecrop/graphql-middleware`, handling HTTP transport, response unwrapping, and metadata caching so application code works with plain TypeScript objects rather than raw GraphQL.
4
-
5
- The client is intentionally thin — it has no knowledge of doctype definitions itself and fetches metadata from the server on demand, caching it in memory for the lifetime of the instance.
6
-
7
- While designed to pair with `@stonecrop/graphql-middleware`, the client works against any GraphQL endpoint that implements the `stonecrop*` operation conventions (`stonecropMeta`, `stonecropRecord`, `stonecropRecords`, `stonecropAction`). You can use your own server implementation as long as it conforms to those operation names and the expected response shapes. The `query` and `mutate` methods are also available for interacting with any other operations your schema exposes.
8
-
9
- ## Installation
10
-
11
- ```bash
12
- pnpm add @stonecrop/graphql-client
13
- ```
14
-
15
- ## Usage
16
-
17
- ```typescript
18
- import { StonecropClient } from '@stonecrop/graphql-client'
19
-
20
- const client = new StonecropClient({
21
- endpoint: 'http://localhost:4000/graphql',
22
- headers: { Authorization: `Bearer ${token}` }, // optional
23
- })
24
- ```
25
-
26
- ### Metadata
27
-
28
- ```typescript
29
- // Fetch DoctypeMeta for a single doctype (cached after first call)
30
- const meta = await client.getMeta({ doctype: 'SalesOrder' })
31
-
32
- // Fetch all registered doctypes
33
- const allMeta = await client.getAllMeta()
34
-
35
- // Bust the in-memory cache
36
- client.clearMetaCache()
37
- ```
38
-
39
- ### Reading records
40
-
41
- ```typescript
42
- // Single record by ID
43
- const order = await client.getRecord(meta, 'uuid-here')
44
- // → Record<string, unknown> | null
45
-
46
- // List with optional filtering and pagination
47
- const orders = await client.getRecords(meta, {
48
- filters: { status: 'Draft' },
49
- orderBy: 'createdAt',
50
- limit: 20,
51
- offset: 0,
52
- })
53
- // Record<string, unknown>[]
54
- ```
55
-
56
- ### Actions
57
-
58
- ```typescript
59
- // Dispatch any registered action
60
- const result = await client.runAction(meta, 'submit', ['uuid-here'])
61
- // → { success: boolean; data: unknown; error: string | null }
62
- ```
63
-
64
- ### Raw GraphQL
65
-
66
- For queries or mutations not covered by the helpers:
67
-
68
- ```typescript
69
- const data = await client.query<{ myTable: unknown[] }>(
70
- `query { myTable { id name } }`
71
- )
72
-
73
- const result = await client.mutate<{ createFoo: unknown }>(
74
- `mutation CreateFoo($input: CreateFooInput!) { createFoo(input: $input) { foo { id } } }`,
75
- { input: { foo: { name: 'bar' } } }
76
- )
77
- ```
78
-
79
- ## References
80
-
81
- For full method signatures and parameter details, see [API Reference](./api.md).
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
+ }
@@ -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
  /**