@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.
@@ -0,0 +1,35 @@
1
+ import type { DoctypeMeta, GetRecordOptions, GetRecordsOptions } from '@stonecrop/schema';
2
+ /**
3
+ * Build a GraphQL connection query to fetch a list of records.
4
+ *
5
+ * Only declares variables ($limit, $offset, $orderBy) that are actually used,
6
+ * avoiding GraphQL spec violations from unused variable declarations.
7
+ *
8
+ * @param meta - Doctype metadata
9
+ * @param connectionFieldName - Function to derive the connection field name from a table name
10
+ * @param orderByTypeName - Function to derive the order-by type name from a table name
11
+ * @param options - Query options (limit, offset, orderBy)
12
+ * @returns GraphQL query string
13
+ *
14
+ * @public
15
+ */
16
+ export declare function buildListQuery(meta: DoctypeMeta, connectionFieldName: (t: string) => string, orderByTypeName: (t: string) => string, options?: GetRecordsOptions): string;
17
+ /**
18
+ * Build a GraphQL query string from doctype metadata.
19
+ *
20
+ * Generates scalar field selections. When `includeNested` is set,
21
+ * recursively includes descendant link sub-selections derived from
22
+ * the doctype's `links` object.
23
+ *
24
+ * @param meta - Doctype metadata to build the query from
25
+ * @param recordFieldName - Function to derive the query field name from a table name
26
+ * @param recordArgName - Function to derive the argument name from a table name
27
+ * @param recordArgType - Function to derive the argument type from a table name
28
+ * @param registry - Doctype registry for resolving link targets. Required when includeNested is set.
29
+ * @param options - Query options (includeNested, maxDepth)
30
+ * @returns GraphQL query string
31
+ *
32
+ * @public
33
+ */
34
+ 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;
35
+ //# sourceMappingURL=query.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../../src/query.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACX,WAAW,EACX,gBAAgB,EAChB,iBAAiB,EAIjB,MAAM,mBAAmB,CAAA;AAe1B;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAC7B,IAAI,EAAE,WAAW,EACjB,mBAAmB,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,EAC1C,eAAe,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,EACtC,OAAO,CAAC,EAAE,iBAAiB,GACzB,MAAM,CAgCR;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,CAC/B,IAAI,EAAE,WAAW,EACjB,eAAe,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,EACtC,aAAa,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,EACpC,aAAa,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,EACpC,QAAQ,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,EACnC,OAAO,CAAC,EAAE,gBAAgB,GACxB,MAAM,CAkCR"}
@@ -0,0 +1,87 @@
1
+ import type { DoctypeMeta as DoctypeMetaType } from '@stonecrop/schema';
2
+ /**
3
+ * Route context for identifying what doctype/record we're working with
4
+ * @public
5
+ */
6
+ export interface RouteContext {
7
+ /** Doctype name (e.g., 'Task', 'Customer') */
8
+ doctype: string;
9
+ /** Optional record ID for viewing/editing a specific record */
10
+ recordId?: string;
11
+ /** Additional context properties */
12
+ [key: string]: unknown;
13
+ }
14
+ /**
15
+ * Options for creating a Stonecrop client
16
+ * @public
17
+ */
18
+ export interface StonecropClientOptions {
19
+ /** GraphQL endpoint URL */
20
+ endpoint: string;
21
+ /** Additional HTTP headers to include in requests */
22
+ headers?: Record<string, string>;
23
+ }
24
+ /**
25
+ * Client for interacting with Stonecrop GraphQL API
26
+ * @public
27
+ */
28
+ export declare class StonecropClient {
29
+ private endpoint;
30
+ private headers;
31
+ private metaCache;
32
+ constructor(options: StonecropClientOptions);
33
+ /**
34
+ * Execute a GraphQL query
35
+ * @param query - GraphQL query string
36
+ * @param variables - Query variables
37
+ */
38
+ query<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T>;
39
+ /**
40
+ * Execute a GraphQL mutation
41
+ * @param mutation - GraphQL mutation string
42
+ * @param variables - Mutation variables
43
+ */
44
+ mutate<T = unknown>(mutation: string, variables?: Record<string, unknown>): Promise<T>;
45
+ /**
46
+ * Get doctype metadata
47
+ * @param context - Route context containing doctype name
48
+ */
49
+ getMeta(context: RouteContext): Promise<DoctypeMetaType | null>;
50
+ /**
51
+ * Get all doctype metadata
52
+ */
53
+ getAllMeta(): Promise<DoctypeMetaType[]>;
54
+ /**
55
+ * Get a single record by ID
56
+ * @param doctype - Doctype metadata
57
+ * @param recordId - Record ID to fetch
58
+ */
59
+ getRecord(doctype: DoctypeMetaType, recordId: string): Promise<Record<string, unknown> | null>;
60
+ /**
61
+ * Get multiple records with optional filtering and pagination
62
+ * @param doctype - Doctype metadata
63
+ * @param options - Query options (filters, orderBy, limit, offset)
64
+ */
65
+ getRecords(doctype: DoctypeMetaType, options?: {
66
+ filters?: Record<string, unknown>;
67
+ orderBy?: string;
68
+ limit?: number;
69
+ offset?: number;
70
+ }): Promise<Record<string, unknown>[]>;
71
+ /**
72
+ * Execute a doctype action
73
+ * @param doctype - Doctype metadata
74
+ * @param action - Action name to execute
75
+ * @param args - Action arguments
76
+ */
77
+ runAction(doctype: DoctypeMetaType, action: string, args?: unknown[]): Promise<{
78
+ success: boolean;
79
+ data: unknown;
80
+ error: string | null;
81
+ }>;
82
+ /**
83
+ * Clear the cached doctype metadata
84
+ */
85
+ clearMetaCache(): void;
86
+ }
87
+ //# sourceMappingURL=stonecrop-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stonecrop-client.d.ts","sourceRoot":"","sources":["../../src/stonecrop-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,IAAI,eAAe,EAAE,MAAM,mBAAmB,CAAA;AAEvE;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC5B,8CAA8C;IAC9C,OAAO,EAAE,MAAM,CAAA;IACf,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,oCAAoC;IACpC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACtB;AAED;;;GAGG;AACH,MAAM,WAAW,sBAAsB;IACtC,2BAA2B;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAChC;AAED;;;GAGG;AACH,qBAAa,eAAe;IAC3B,OAAO,CAAC,QAAQ,CAAQ;IACxB,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,SAAS,CAA0C;gBAE/C,OAAO,EAAE,sBAAsB;IAQ3C;;;;OAIG;IACG,KAAK,CAAC,CAAC,GAAG,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAmBxF;;;;OAIG;IACG,MAAM,CAAC,CAAC,GAAG,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAI5F;;;OAGG;IACG,OAAO,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC;IA0CrE;;OAEG;IACG,UAAU,IAAI,OAAO,CAAC,eAAe,EAAE,CAAC;IAsC9C;;;;OAIG;IACG,SAAS,CAAC,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAiBpG;;;;OAIG;IACG,UAAU,CACf,OAAO,EAAE,eAAe,EACxB,OAAO,CAAC,EAAE;QACT,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;QACjC,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,MAAM,CAAC,EAAE,MAAM,CAAA;KACf,GACC,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAiCrC;;;;;OAKG;IACG,SAAS,CACd,OAAO,EAAE,eAAe,EACxB,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,OAAO,EAAE,GACd,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,IAAI,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IAuBrE;;OAEG;IACH,cAAc,IAAI,IAAI;CAGtB"}
@@ -0,0 +1,11 @@
1
+ // This file is read by tools that parse documentation comments conforming to the TSDoc standard.
2
+ // It should be published with your NPM package. It should not be tracked by Git.
3
+ {
4
+ "tsdocVersion": "0.12",
5
+ "toolPackages": [
6
+ {
7
+ "packageName": "@microsoft/api-extractor",
8
+ "packageVersion": "7.57.0"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * @file This file contains all the types that are used in the application.
3
+ * @public
4
+ */
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Client for interacting with Stonecrop GraphQL API
3
+ * @public
4
+ */
5
+ export class StonecropClient {
6
+ endpoint;
7
+ headers;
8
+ metaCache = new Map();
9
+ constructor(options) {
10
+ this.endpoint = options.endpoint;
11
+ this.headers = {
12
+ 'Content-Type': 'application/json',
13
+ ...options.headers,
14
+ };
15
+ }
16
+ /**
17
+ * Execute a GraphQL query
18
+ * @param query - GraphQL query string
19
+ * @param variables - Query variables
20
+ */
21
+ async query(query, variables) {
22
+ const response = await fetch(this.endpoint, {
23
+ method: 'POST',
24
+ headers: this.headers,
25
+ body: JSON.stringify({ query, variables }),
26
+ });
27
+ const json = (await response.json());
28
+ if (json.errors?.length) {
29
+ throw new Error(json.errors[0].message);
30
+ }
31
+ return json.data;
32
+ }
33
+ /**
34
+ * Execute a GraphQL mutation
35
+ * @param mutation - GraphQL mutation string
36
+ * @param variables - Mutation variables
37
+ */
38
+ async mutate(mutation, variables) {
39
+ return this.query(mutation, variables);
40
+ }
41
+ /**
42
+ * Get doctype metadata
43
+ * @param context - Route context containing doctype name
44
+ */
45
+ async getMeta(context) {
46
+ const cached = this.metaCache.get(context.doctype);
47
+ if (cached)
48
+ return cached;
49
+ const result = await this.query(`
50
+ query GetMeta($doctype: String!) {
51
+ stonecropMeta(doctype: $doctype) {
52
+ name
53
+ slug
54
+ tableName
55
+ fields {
56
+ fieldname
57
+ fieldtype
58
+ component
59
+ label
60
+ required
61
+ readOnly
62
+ options
63
+ precision
64
+ scale
65
+ }
66
+ workflow {
67
+ states
68
+ actions
69
+ }
70
+ inherits
71
+ listDoctype
72
+ parentDoctype
73
+ }
74
+ }
75
+ `, { doctype: context.doctype });
76
+ if (result.stonecropMeta) {
77
+ this.metaCache.set(context.doctype, result.stonecropMeta);
78
+ }
79
+ return result.stonecropMeta;
80
+ }
81
+ /**
82
+ * Get all doctype metadata
83
+ */
84
+ async getAllMeta() {
85
+ const result = await this.query(`
86
+ query GetAllMeta {
87
+ stonecropAllMeta {
88
+ name
89
+ slug
90
+ tableName
91
+ fields {
92
+ fieldname
93
+ fieldtype
94
+ component
95
+ label
96
+ required
97
+ readOnly
98
+ options
99
+ precision
100
+ scale
101
+ }
102
+ workflow {
103
+ states
104
+ actions
105
+ }
106
+ inherits
107
+ listDoctype
108
+ parentDoctype
109
+ }
110
+ }
111
+ `);
112
+ for (const meta of result.stonecropAllMeta) {
113
+ this.metaCache.set(meta.name, meta);
114
+ }
115
+ return result.stonecropAllMeta;
116
+ }
117
+ /**
118
+ * Get a single record by ID
119
+ * @param doctype - Doctype metadata
120
+ * @param recordId - Record ID to fetch
121
+ */
122
+ async getRecord(doctype, recordId) {
123
+ const result = await this.query(`
124
+ query GetRecord($doctype: String!, $id: String!) {
125
+ stonecropRecord(doctype: $doctype, id: $id) {
126
+ data
127
+ }
128
+ }
129
+ `, { doctype: doctype.name, id: recordId });
130
+ return result.stonecropRecord.data;
131
+ }
132
+ /**
133
+ * Get multiple records with optional filtering and pagination
134
+ * @param doctype - Doctype metadata
135
+ * @param options - Query options (filters, orderBy, limit, offset)
136
+ */
137
+ async getRecords(doctype, options) {
138
+ const result = await this.query(`
139
+ query GetRecords(
140
+ $doctype: String!
141
+ $filters: JSON
142
+ $orderBy: String
143
+ $limit: Int
144
+ $offset: Int
145
+ ) {
146
+ stonecropRecords(
147
+ doctype: $doctype
148
+ filters: $filters
149
+ orderBy: $orderBy
150
+ limit: $limit
151
+ offset: $offset
152
+ ) {
153
+ data
154
+ count
155
+ }
156
+ }
157
+ `, {
158
+ doctype: doctype.name,
159
+ ...options,
160
+ });
161
+ return result.stonecropRecords.data;
162
+ }
163
+ /**
164
+ * Execute a doctype action
165
+ * @param doctype - Doctype metadata
166
+ * @param action - Action name to execute
167
+ * @param args - Action arguments
168
+ */
169
+ async runAction(doctype, action, args) {
170
+ const result = await this.query(`
171
+ mutation RunAction($doctype: String!, $action: String!, $args: JSON) {
172
+ stonecropAction(doctype: $doctype, action: $action, args: $args) {
173
+ success
174
+ data
175
+ error
176
+ }
177
+ }
178
+ `, {
179
+ doctype: doctype.name,
180
+ action,
181
+ args,
182
+ });
183
+ return result.stonecropAction;
184
+ }
185
+ /**
186
+ * Clear the cached doctype metadata
187
+ */
188
+ clearMetaCache() {
189
+ this.metaCache.clear();
190
+ }
191
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonecrop/graphql-client",
3
- "version": "0.10.16",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "author": {
@@ -33,14 +33,17 @@
33
33
  "decimal.js": "^10.6.0",
34
34
  "graphql": "^16.12.0",
35
35
  "graphql-request": "^7.4.0",
36
- "@stonecrop/schema": "0.10.16",
37
- "@stonecrop/stonecrop": "0.10.16"
36
+ "pluralize": "^8.0.0",
37
+ "@stonecrop/schema": "0.11.0",
38
+ "@stonecrop/stonecrop": "0.11.0"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@eslint/js": "^9.39.2",
41
42
  "@microsoft/api-documenter": "^7.28.2",
42
43
  "@miragejs/graphql": "^0.1.13",
43
44
  "@rushstack/heft": "^1.2.0",
45
+ "@types/node": "^22.19.5",
46
+ "@types/pluralize": "^0.0.33",
44
47
  "@vitejs/plugin-vue": "^6.0.3",
45
48
  "@vitest/coverage-istanbul": "^4.0.18",
46
49
  "eslint": "^9.39.2",
package/src/client.ts CHANGED
@@ -1,7 +1,31 @@
1
- import type { DataClient, DoctypeMeta, DoctypeContext, DoctypeRef } from '@stonecrop/schema'
1
+ import type {
2
+ DataClient,
3
+ DoctypeMeta,
4
+ DoctypeContext,
5
+ DoctypeRef,
6
+ GetRecordOptions,
7
+ GetRecordsOptions,
8
+ } from '@stonecrop/schema'
9
+ import { snakeToCamel, toPascalCase } from '@stonecrop/schema'
10
+ import pluralize from 'pluralize'
11
+
12
+ import { buildRecordQuery } from './query'
2
13
 
3
14
  export type { DoctypeContext, DoctypeRef }
4
15
 
16
+ /**
17
+ * Default inflection functions for PostGraphile Amber preset conventions.
18
+ * These match the middleware's default inflection so the client builds
19
+ * queries the server can execute.
20
+ * @internal
21
+ */
22
+ const defaultRecordFieldName = (tableName: string): string => {
23
+ const singularName = pluralize.singular(tableName)
24
+ return `${snakeToCamel(singularName)}ById`
25
+ }
26
+ const defaultRecordArgName = (_tableName: string): string => 'id'
27
+ const defaultRecordArgType = (_tableName: string): string => 'UUID!'
28
+
5
29
  /**
6
30
  * Options for creating a Stonecrop client
7
31
  * @public
@@ -11,6 +35,8 @@ export interface StonecropClientOptions {
11
35
  endpoint: string
12
36
  /** Additional HTTP headers to include in requests */
13
37
  headers?: Record<string, string>
38
+ /** Doctype registry for nested query building */
39
+ registry?: Map<string, DoctypeMeta>
14
40
  }
15
41
 
16
42
  /**
@@ -21,6 +47,7 @@ export class StonecropClient implements DataClient {
21
47
  private endpoint: string
22
48
  private headers: Record<string, string>
23
49
  private metaCache: Map<string, DoctypeMeta> = new Map()
50
+ private registry?: Map<string, DoctypeMeta>
24
51
 
25
52
  constructor(options: StonecropClientOptions) {
26
53
  this.endpoint = options.endpoint
@@ -28,6 +55,7 @@ export class StonecropClient implements DataClient {
28
55
  'Content-Type': 'application/json',
29
56
  ...options.headers,
30
57
  }
58
+ this.registry = options.registry
31
59
  }
32
60
 
33
61
  /**
@@ -109,8 +137,6 @@ export class StonecropClient implements DataClient {
109
137
  }
110
138
  }
111
139
  inherits
112
- listDoctype
113
- parentDoctype
114
140
  }
115
141
  }
116
142
  `,
@@ -166,8 +192,6 @@ export class StonecropClient implements DataClient {
166
192
  }
167
193
  }
168
194
  inherits
169
- listDoctype
170
- parentDoctype
171
195
  }
172
196
  }
173
197
  `
@@ -181,11 +205,49 @@ export class StonecropClient implements DataClient {
181
205
  }
182
206
 
183
207
  /**
184
- * Get a single record by ID
208
+ * Get a single record by ID.
209
+ *
210
+ * When `includeNested` is set, builds a query with sub-selections for descendant
211
+ * links and returns ancestor + merged descendants. When omitted, returns flat scalar data.
212
+ *
185
213
  * @param doctype - Doctype reference (name and optional slug)
186
214
  * @param recordId - Record ID to fetch
215
+ * @param options - Query options (includeNested, maxDepth)
187
216
  */
188
- async getRecord(doctype: DoctypeRef, recordId: string): Promise<Record<string, unknown> | null> {
217
+ async getRecord(
218
+ doctype: DoctypeRef,
219
+ recordId: string,
220
+ options?: GetRecordOptions
221
+ ): Promise<Record<string, unknown> | null> {
222
+ // Nested path: build query with sub-selections
223
+ if (options?.includeNested) {
224
+ const meta = await this.getMeta({ doctype: doctype.name })
225
+ if (!meta) return null
226
+
227
+ const query = buildRecordQuery(
228
+ meta,
229
+ defaultRecordFieldName,
230
+ defaultRecordArgName,
231
+ defaultRecordArgType,
232
+ this.registry,
233
+ options
234
+ )
235
+
236
+ const result = await this.query<Record<string, unknown>>(query, { id: recordId })
237
+
238
+ const queryName = defaultRecordFieldName(meta.tableName || doctype.name)
239
+ const record = result[queryName] as Record<string, unknown> | undefined
240
+
241
+ if (!record) return null
242
+
243
+ if (meta.links && this.registry) {
244
+ return mergeNestedResults(record, meta, this.registry)
245
+ }
246
+
247
+ return record
248
+ }
249
+
250
+ // Flat path: original query
189
251
  const result = await this.query<{
190
252
  stonecropRecord: { data: Record<string, unknown> | null }
191
253
  }>(
@@ -207,15 +269,7 @@ export class StonecropClient implements DataClient {
207
269
  * @param doctype - Doctype reference (name and optional slug)
208
270
  * @param options - Query options (filters, orderBy, limit, offset)
209
271
  */
210
- async getRecords(
211
- doctype: DoctypeRef,
212
- options?: {
213
- filters?: Record<string, unknown>
214
- orderBy?: string
215
- limit?: number
216
- offset?: number
217
- }
218
- ): Promise<Record<string, unknown>[]> {
272
+ async getRecords(doctype: DoctypeRef, options?: GetRecordsOptions): Promise<Record<string, unknown>[]> {
219
273
  const result = await this.query<{
220
274
  stonecropRecords: { data: Record<string, unknown>[] }
221
275
  }>(
@@ -288,3 +342,57 @@ export class StonecropClient implements DataClient {
288
342
  this.metaCache.clear()
289
343
  }
290
344
  }
345
+
346
+ /**
347
+ * Merge nested connection results into flat arrays.
348
+ *
349
+ * For `noneOrMany`/`atLeastOne` links, the query returns `{ nodes: [...] }`.
350
+ * This flattens them to just `[]` for easier consumption.
351
+ *
352
+ * For `one`/`atMostOne` links, the result is already flat.
353
+ *
354
+ * @internal
355
+ */
356
+ function mergeNestedResults(
357
+ record: Record<string, unknown>,
358
+ meta: DoctypeMeta,
359
+ registry: Map<string, DoctypeMeta>
360
+ ): Record<string, unknown> {
361
+ if (!meta.links) return record
362
+
363
+ const merged = { ...record }
364
+
365
+ for (const [fieldname, link] of Object.entries(meta.links)) {
366
+ const isMany = link.cardinality === 'noneOrMany' || link.cardinality === 'atLeastOne'
367
+
368
+ if (isMany) {
369
+ // Connection result: { nodes: [...] } → flatten to []
370
+ const targetMeta = registry.get(link.target)
371
+ if (!targetMeta) continue
372
+
373
+ const connectionField = getConnectionFieldFromTarget(targetMeta, meta.tableName || '')
374
+ const connectionResult = merged[connectionField] as { nodes?: unknown[] } | undefined
375
+ if (connectionResult?.nodes) {
376
+ merged[fieldname] = connectionResult.nodes
377
+ delete merged[connectionField]
378
+ } else {
379
+ merged[fieldname] = []
380
+ delete merged[connectionField]
381
+ }
382
+ }
383
+ // 'one'/'atMostOne' links are already at the right fieldname
384
+ }
385
+
386
+ return merged
387
+ }
388
+
389
+ /**
390
+ * Derive the connection field name matching the query builder's convention.
391
+ * @internal
392
+ */
393
+ function getConnectionFieldFromTarget(targetMeta: DoctypeMeta, parentTableName: string): string {
394
+ const targetPlural = pluralize.plural(targetMeta.tableName || '')
395
+ const targetPascal = toPascalCase(targetPlural)
396
+ const fkPascal = toPascalCase(parentTableName) + 'Id'
397
+ return `${targetPascal}By${fkPascal}`
398
+ }
package/src/index.ts CHANGED
@@ -65,6 +65,7 @@ const methods = {
65
65
 
66
66
  export { queries, typeDefs, methods }
67
67
  export { StonecropClient } from './client'
68
+ export { buildRecordQuery, buildListQuery } from './query'
68
69
  export type { StonecropClientOptions, DoctypeContext } from './client'
69
70
  export type { Meta, MetaParser, MetaResponse } from './types'
70
71
  export type { DoctypeMeta } from '@stonecrop/schema'