@stonecrop/graphql-client 0.11.1 → 0.11.3

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,154 +1,59 @@
1
1
  # @stonecrop/graphql-client
2
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.
3
+ Transport layer for Stonecrop GraphQL APIs. Handles HTTP communication, response parsing, and metadata caching.
4
4
 
5
- ## Installation
5
+ ## Responsibilities
6
6
 
7
- ```bash
8
- pnpm add @stonecrop/graphql-client
9
- ```
7
+ **Transport** — The client sends requests and parses responses. It doesn't construct queries.
10
8
 
11
- ## Usage
9
+ **Caching** — Metadata is cached in memory after first fetch.
12
10
 
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
- }
11
+ **Contract** — The client expects the server to expose these operations:
32
12
 
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
- })
13
+ | Operation | Arguments | Returns |
14
+ |-----------|-----------|---------|
15
+ | `stonecropRecord` | `doctype`, `id`, `options?` | `{ record, unknownLinks? }` |
16
+ | `stonecropRecords` | `doctype`, `filters?`, `orderBy?`, `limit?`, `offset?` | `{ data[], count }` |
17
+ | `stonecropMeta` | `doctype` | `DoctypeMeta` |
18
+ | `stonecropAllMeta` | — | `DoctypeMeta[]` |
19
+ | `stonecropAction` | `doctype`, `action`, `args?` | `{ success, data, error }` |
38
20
 
39
- // Wire up the client to the Stonecrop instance
40
- const stonecrop = getStonecrop()
41
- if (stonecrop) {
42
- stonecrop.setClient(client)
43
- }
44
- ```
21
+ The client has no opinions about how the server implements these — naming conventions, query construction, nested data merging are all the server's concern.
45
22
 
46
- ### Metadata
23
+ ## Assumptions
47
24
 
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
- ```
25
+ - All record operations accept a `doctype` string argument
26
+ - `stonecropRecord` accepts `options: { includeNested?, maxDepth? }`
27
+ - The server handles query building and field naming
58
28
 
59
- ### Reading Records
29
+ ## Usage
60
30
 
61
31
  ```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')
32
+ import { StonecropClient } from '@stonecrop/graphql-client'
66
33
 
67
- // Single record with all nested descendant links
68
- const recipe = await client.getRecord({ name: 'Recipe' }, 'r1', {
69
- includeNested: true,
34
+ const client = new StonecropClient({
35
+ endpoint: 'http://localhost:4000/graphql',
36
+ headers: { Authorization: `Bearer ${token}` }, // optional
70
37
  })
71
38
 
72
- // Single record with specific nested links only
73
- const recipe = await client.getRecord({ name: 'Recipe' }, 'r1', {
74
- includeNested: ['tasks'],
75
- })
39
+ // Fetch a record
40
+ const result = await client.getRecord({ name: 'Recipe' }, 'r1')
41
+ result.record // plain object with the record fields
42
+ result.unknownLinks // links requested but not found in schema
76
43
 
77
- // Single record with limited nesting depth
78
- const recipe = await client.getRecord({ name: 'Recipe' }, 'r1', {
44
+ // Fetch with nested links
45
+ const withNested = await client.getRecord({ name: 'Recipe' }, 'r1', {
79
46
  includeNested: true,
80
- maxDepth: 2,
81
47
  })
82
48
 
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
- }
49
+ // Custom queries
50
+ const custom = await client.query<{ myData: unknown[] }>(`query { myData { id } }`)
148
51
  ```
149
52
 
150
- The response is merged so `result.tasks` is a flat array and `result.supersededBy` is a direct object.
53
+ ## Data Shapes
151
54
 
152
- ## References
55
+ - `getRecord` returns `{ record: Record<string, unknown> | null, unknownLinks?: string[] }`. The `record` field contains the record's fields. Nested links are merged into the same object when `includeNested` is used.
56
+ - `getRecords` returns `Record<string, unknown>[]` — an array of flat objects. Pagination metadata (`count`) is available in the server response but not currently exposed by the client.
57
+ - `unknownLinks` will contain link names you requested that don't exist in the doctype schema — useful for catching typos.
153
58
 
154
- For full method signatures and parameter details, see [API Reference](./api.md).
59
+ See [API Reference](./api.md) for full method signatures.
package/dist/client.js CHANGED
@@ -1,39 +1,28 @@
1
- import { snakeToCamel, toPascalCase } from '@stonecrop/schema';
2
- import pluralize from 'pluralize';
3
- import { buildRecordQuery } from './query';
4
1
  /**
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!';
16
- /**
17
- * Client for interacting with Stonecrop GraphQL API
2
+ * Client for interacting with Stonecrop GraphQL API.
3
+ *
4
+ * Acts as a transport layer — it passes requests to the middleware and returns
5
+ * merged results. Does not construct queries itself.
6
+ *
18
7
  * @public
19
8
  */
20
9
  export class StonecropClient {
21
10
  endpoint;
22
11
  headers;
23
12
  metaCache = new Map();
24
- registry;
25
13
  constructor(options) {
26
14
  this.endpoint = options.endpoint;
27
15
  this.headers = {
28
16
  'Content-Type': 'application/json',
29
17
  ...options.headers,
30
18
  };
31
- this.registry = options.registry;
32
19
  }
33
20
  /**
34
- * Execute a GraphQL query
21
+ * Execute a GraphQL query against the configured endpoint.
22
+ *
35
23
  * @param query - GraphQL query string
36
24
  * @param variables - Query variables
25
+ * @throws Error if the GraphQL response contains errors
37
26
  */
38
27
  async query(query, variables) {
39
28
  const response = await fetch(this.endpoint, {
@@ -48,7 +37,8 @@ export class StonecropClient {
48
37
  return json.data;
49
38
  }
50
39
  /**
51
- * Execute a GraphQL mutation
40
+ * Execute a GraphQL mutation. Delegates to query() since both use POST.
41
+ *
52
42
  * @param mutation - GraphQL mutation string
53
43
  * @param variables - Mutation variables
54
44
  */
@@ -160,42 +150,40 @@ export class StonecropClient {
160
150
  /**
161
151
  * Get a single record by ID.
162
152
  *
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.
153
+ * Routes through the stonecropRecord resolver which handles nested data
154
+ * fetching based on the includeNested option.
165
155
  *
166
156
  * @param doctype - Doctype reference (name and optional slug)
167
157
  * @param recordId - Record ID to fetch
168
158
  * @param options - Query options (includeNested, maxDepth)
169
159
  */
170
160
  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
188
- const result = await this.query(`
189
- query GetRecord($doctype: String!, $id: String!) {
190
- stonecropRecord(doctype: $doctype, id: $id) {
161
+ const result = await this.query(`query GetRecord($doctype: String!, $id: String!, $options: JSON) {
162
+ stonecropRecord(doctype: $doctype, id: $id, options: $options) {
191
163
  data
164
+ unknownLinks
192
165
  }
193
- }
194
- `, { doctype: doctype.name, id: recordId });
195
- return result.stonecropRecord.data;
166
+ }`, {
167
+ doctype: doctype.name,
168
+ id: recordId,
169
+ options: options?.includeNested
170
+ ? {
171
+ includeNested: options.includeNested,
172
+ maxDepth: options.maxDepth,
173
+ }
174
+ : undefined,
175
+ });
176
+ return {
177
+ record: result.stonecropRecord?.data ?? null,
178
+ unknownLinks: result.stonecropRecord?.unknownLinks,
179
+ };
196
180
  }
197
181
  /**
198
- * Get multiple records with optional filtering and pagination
182
+ * Get multiple records with optional filtering and pagination.
183
+ *
184
+ * Returns flat arrays — the middleware merges connection format (\{ nodes: [...] \})
185
+ * into plain arrays before returning.
186
+ *
199
187
  * @param doctype - Doctype reference (name and optional slug)
200
188
  * @param options - Query options (filters, orderBy, limit, offset)
201
189
  */
@@ -248,55 +236,12 @@ export class StonecropClient {
248
236
  return result.stonecropAction;
249
237
  }
250
238
  /**
251
- * Clear the cached doctype metadata
239
+ * Clear the cached doctype metadata.
240
+ *
241
+ * Call this if the server-side doctype schema has changed and you need
242
+ * to fetch fresh metadata (e.g., after adding a new field).
252
243
  */
253
244
  clearMetaCache() {
254
245
  this.metaCache.clear();
255
246
  }
256
247
  }
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
- }
@@ -3,131 +3,46 @@ import type { DoctypeContext } from '@stonecrop/schema';
3
3
  import { DoctypeMeta } from '@stonecrop/schema';
4
4
  import type { DoctypeRef } from '@stonecrop/schema';
5
5
  import type { GetRecordOptions } from '@stonecrop/schema';
6
+ import type { GetRecordResult as GetRecordResult_2 } from '@stonecrop/schema';
6
7
  import type { GetRecordsOptions } from '@stonecrop/schema';
7
8
 
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;
42
-
43
9
  export { DoctypeContext }
44
10
 
45
11
  export { DoctypeMeta }
46
12
 
47
13
  /**
48
- * @file This file contains all the types that are used in the application.
49
- * @public
50
- */
51
- /**
52
- * The type of the response from the `getMeta` query.
14
+ * Result from getRecord - includes the record data and any unknown links requested
53
15
  * @public
54
16
  */
55
- export declare type Meta = {
56
- variables: {
57
- doctype: string;
58
- };
59
- response: {
60
- getMeta: MetaResponse;
61
- };
62
- };
63
-
64
- /**
65
- * The type of the response from the `getMeta` query.
66
- * @public
67
- */
68
- export declare type MetaParser = {
69
- data: Meta['response'];
70
- };
71
-
72
- /**
73
- * The type of the response from the `getRecords` query.
74
- * @public
75
- */
76
- export declare type MetaResponse = {
77
- id: string;
78
- name: string;
79
- workflow: {
80
- id: string;
81
- name: string;
82
- machineId?: string;
83
- };
84
- schema: {
85
- id: string;
86
- label: string;
87
- }[];
88
- actions: {
89
- id: string;
90
- eventName: string;
91
- }[];
92
- };
93
-
94
- /**
95
- * Get meta information for a doctype
96
- * @param doctype - The doctype to get meta information for
97
- * @param url - The URL to send the request to
98
- * @returns The meta information for the doctype
99
- * @public
100
- */
101
- export declare const methods: {
102
- getMeta: (doctype: string, url?: string) => Promise<MetaResponse>;
103
- };
104
-
105
- /**
106
- * Queries for the GraphQL API.
107
- * @public
108
- */
109
- export declare const queries: {
110
- getMeta: string;
111
- };
17
+ export declare interface GetRecordResult extends GetRecordResult_2 {
18
+ /** Link names that were requested but don't exist in the doctype schema */
19
+ unknownLinks?: string[];
20
+ }
112
21
 
113
22
  /**
114
- * Client for interacting with Stonecrop GraphQL API
23
+ * Client for interacting with Stonecrop GraphQL API.
24
+ *
25
+ * Acts as a transport layer — it passes requests to the middleware and returns
26
+ * merged results. Does not construct queries itself.
27
+ *
115
28
  * @public
116
29
  */
117
30
  export declare class StonecropClient implements DataClient {
118
31
  private endpoint;
119
32
  private headers;
120
33
  private metaCache;
121
- private registry?;
122
34
  constructor(options: StonecropClientOptions);
123
35
  /**
124
- * Execute a GraphQL query
36
+ * Execute a GraphQL query against the configured endpoint.
37
+ *
125
38
  * @param query - GraphQL query string
126
39
  * @param variables - Query variables
40
+ * @throws Error if the GraphQL response contains errors
127
41
  */
128
42
  query<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T>;
129
43
  /**
130
- * Execute a GraphQL mutation
44
+ * Execute a GraphQL mutation. Delegates to query() since both use POST.
45
+ *
131
46
  * @param mutation - GraphQL mutation string
132
47
  * @param variables - Mutation variables
133
48
  */
@@ -144,16 +59,20 @@ export declare class StonecropClient implements DataClient {
144
59
  /**
145
60
  * Get a single record by ID.
146
61
  *
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.
62
+ * Routes through the stonecropRecord resolver which handles nested data
63
+ * fetching based on the includeNested option.
149
64
  *
150
65
  * @param doctype - Doctype reference (name and optional slug)
151
66
  * @param recordId - Record ID to fetch
152
67
  * @param options - Query options (includeNested, maxDepth)
153
68
  */
154
- getRecord(doctype: DoctypeRef, recordId: string, options?: GetRecordOptions): Promise<Record<string, unknown> | null>;
69
+ getRecord(doctype: DoctypeRef, recordId: string, options?: GetRecordOptions): Promise<GetRecordResult>;
155
70
  /**
156
- * Get multiple records with optional filtering and pagination
71
+ * Get multiple records with optional filtering and pagination.
72
+ *
73
+ * Returns flat arrays — the middleware merges connection format (\{ nodes: [...] \})
74
+ * into plain arrays before returning.
75
+ *
157
76
  * @param doctype - Doctype reference (name and optional slug)
158
77
  * @param options - Query options (filters, orderBy, limit, offset)
159
78
  */
@@ -170,7 +89,10 @@ export declare class StonecropClient implements DataClient {
170
89
  error: string | null;
171
90
  }>;
172
91
  /**
173
- * Clear the cached doctype metadata
92
+ * Clear the cached doctype metadata.
93
+ *
94
+ * Call this if the server-side doctype schema has changed and you need
95
+ * to fetch fresh metadata (e.g., after adding a new field).
174
96
  */
175
97
  clearMetaCache(): void;
176
98
  }
@@ -184,14 +106,6 @@ export declare interface StonecropClientOptions {
184
106
  endpoint: string;
185
107
  /** Additional HTTP headers to include in requests */
186
108
  headers?: Record<string, string>;
187
- /** Doctype registry for nested query building */
188
- registry?: Map<string, DoctypeMeta>;
189
109
  }
190
110
 
191
- /**
192
- * This is the schema for the GraphQL API.
193
- * @public
194
- */
195
- export declare const typeDefs: string;
196
-
197
111
  export { }