@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/dist/index.js CHANGED
@@ -59,3 +59,4 @@ const methods = {
59
59
  };
60
60
  export { queries, typeDefs, methods };
61
61
  export { StonecropClient } from './client';
62
+ export { buildRecordQuery, buildListQuery } from './query';
package/dist/query.js ADDED
@@ -0,0 +1,231 @@
1
+ import { toPascalCase } from '@stonecrop/schema';
2
+ import pluralize from 'pluralize';
3
+ /**
4
+ * Default sync limit for many-cardinality links
5
+ */
6
+ const DEFAULT_SYNC_LIMIT = 50;
7
+ /**
8
+ * Field types that are not scalar queryable fields.
9
+ * Link fields are handled separately via sub-selections; relationship fields live in `links`.
10
+ */
11
+ const RELATION_FIELDTYPES = new Set(['Link']);
12
+ /**
13
+ * Build a GraphQL connection query to fetch a list of records.
14
+ *
15
+ * Only declares variables ($limit, $offset, $orderBy) that are actually used,
16
+ * avoiding GraphQL spec violations from unused variable declarations.
17
+ *
18
+ * @param meta - Doctype metadata
19
+ * @param connectionFieldName - Function to derive the connection field name from a table name
20
+ * @param orderByTypeName - Function to derive the order-by type name from a table name
21
+ * @param options - Query options (limit, offset, orderBy)
22
+ * @returns GraphQL query string
23
+ *
24
+ * @public
25
+ */
26
+ export function buildListQuery(meta, connectionFieldName, orderByTypeName, options) {
27
+ const fieldNames = queryableFieldNames(meta);
28
+ const connectionName = connectionFieldName(meta.tableName);
29
+ const orderByType = orderByTypeName(meta.tableName);
30
+ const varDecls = [];
31
+ const queryArgs = [];
32
+ if (options?.limit) {
33
+ varDecls.push('$limit: Int');
34
+ queryArgs.push(`first: $limit`);
35
+ }
36
+ if (options?.offset) {
37
+ varDecls.push('$offset: Int');
38
+ queryArgs.push(`offset: $offset`);
39
+ }
40
+ if (options?.orderBy) {
41
+ varDecls.push(`$orderBy: [${orderByType}!]`);
42
+ queryArgs.push(`orderBy: $orderBy`);
43
+ }
44
+ const varStr = varDecls.length > 0 ? `(${varDecls.join(', ')})` : '';
45
+ const argsStr = queryArgs.length > 0 ? `(${queryArgs.join(', ')})` : '';
46
+ return `
47
+ query GetRecords${varStr} {
48
+ ${connectionName}${argsStr} {
49
+ nodes {
50
+ ${fieldNames}
51
+ }
52
+ }
53
+ }
54
+ `;
55
+ }
56
+ /**
57
+ * Build a GraphQL query string from doctype metadata.
58
+ *
59
+ * Generates scalar field selections. When `includeNested` is set,
60
+ * recursively includes descendant link sub-selections derived from
61
+ * the doctype's `links` object.
62
+ *
63
+ * @param meta - Doctype metadata to build the query from
64
+ * @param recordFieldName - Function to derive the query field name from a table name
65
+ * @param recordArgName - Function to derive the argument name from a table name
66
+ * @param recordArgType - Function to derive the argument type from a table name
67
+ * @param registry - Doctype registry for resolving link targets. Required when includeNested is set.
68
+ * @param options - Query options (includeNested, maxDepth)
69
+ * @returns GraphQL query string
70
+ *
71
+ * @public
72
+ */
73
+ export function buildRecordQuery(meta, recordFieldName, recordArgName, recordArgType, registry, options) {
74
+ const queryName = recordFieldName(meta.tableName);
75
+ const argName = recordArgName(meta.tableName);
76
+ const argType = recordArgType(meta.tableName);
77
+ const seen = new Set([meta.slug || meta.name]);
78
+ let selection = queryableFieldNames(meta);
79
+ if (options?.includeNested && meta.links && registry) {
80
+ const includeSet = Array.isArray(options.includeNested) ? new Set(options.includeNested) : null;
81
+ const nestedSelections = buildNestedSelections(meta.links, meta.tableName, includeSet, registry, seen, 0, options.maxDepth);
82
+ if (nestedSelections) {
83
+ selection += '\n ' + nestedSelections;
84
+ }
85
+ }
86
+ return `
87
+ query GetRecord($${argName}: ${argType}) {
88
+ ${queryName}(${argName}: $${argName}) {
89
+ ${selection}
90
+ }
91
+ }
92
+ `;
93
+ }
94
+ /**
95
+ * Build nested sub-selections for descendant links
96
+ * @internal
97
+ */
98
+ function buildNestedSelections(links, parentTableName, includeSet, registry, seen, depth, maxDepth) {
99
+ if (maxDepth !== undefined && depth >= maxDepth)
100
+ return '';
101
+ const selections = [];
102
+ for (const [fieldname, link] of Object.entries(links)) {
103
+ if (maxDepth !== undefined && depth >= maxDepth)
104
+ break;
105
+ // Check blockWorkflows first - if true, it overrides the includeSet filter
106
+ const effectiveBlockWorkflows = getEffectiveBlockWorkflows(link);
107
+ const linkBlockWorkflowsExplicitTrue = link.blockWorkflows === true;
108
+ // Check includeSet filter - but blockWorkflows: true bypasses this filter
109
+ if (includeSet && !includeSet.has(fieldname) && !linkBlockWorkflowsExplicitTrue) {
110
+ continue;
111
+ }
112
+ // Check fetch strategy - skip if not sync (unless blockWorkflows overrides)
113
+ const effectiveFetch = getEffectiveFetchStrategy(link);
114
+ const shouldSkip = effectiveBlockWorkflows === false || (effectiveFetch.method !== 'sync' && !linkBlockWorkflowsExplicitTrue);
115
+ if (shouldSkip) {
116
+ continue;
117
+ }
118
+ // TODO: When blockWorkflows is true with custom fetch, this currently forces the link into
119
+ // GraphQL queries, bypassing the custom handler. This is a workaround — custom handlers
120
+ // should be able to satisfy blockWorkflows on their own schedule. Future enhancement:
121
+ // - Option: Add SchemaValidator error for custom + blockWorkflows (blocking is impossible)
122
+ // - Option: Track pending custom fetches and only unblock when all custom handlers complete
123
+ // See: relationships.md Phase 6 "Open Question: blockWorkflows + custom fetch"
124
+ const targetMeta = registry.get(link.target);
125
+ if (!targetMeta)
126
+ continue;
127
+ const alreadySeen = seen.has(link.target);
128
+ if (alreadySeen) {
129
+ // Self-referential: include scalar fields only, don't modify seen
130
+ }
131
+ else {
132
+ seen.add(link.target);
133
+ }
134
+ const scalarFields = queryableFieldNames(targetMeta);
135
+ let nestedLinks = '';
136
+ if (!alreadySeen && targetMeta.links && targetMeta.tableName && (maxDepth === undefined || depth + 1 < maxDepth)) {
137
+ const innerSelections = buildNestedSelections(targetMeta.links, targetMeta.tableName, null, registry, seen, depth + 1, maxDepth);
138
+ if (innerSelections) {
139
+ nestedLinks = '\n ' + innerSelections;
140
+ }
141
+ seen.delete(link.target);
142
+ }
143
+ const fullSelection = scalarFields + nestedLinks;
144
+ if (isManyCardinality(link.cardinality)) {
145
+ const connectionField = getConnectionFieldName(targetMeta, parentTableName);
146
+ const limitArg = effectiveFetch.method === 'sync' && effectiveFetch.limit !== undefined
147
+ ? `first: ${effectiveFetch.limit}`
148
+ : effectiveFetch.method === 'sync'
149
+ ? `first: ${DEFAULT_SYNC_LIMIT}`
150
+ : '';
151
+ selections.push(`
152
+ ${connectionField}${limitArg ? `(${limitArg})` : ''} {
153
+ nodes {
154
+ ${fullSelection}
155
+ }
156
+ }`);
157
+ }
158
+ else {
159
+ selections.push(`
160
+ ${fieldname} {
161
+ ${fullSelection}
162
+ }`);
163
+ }
164
+ }
165
+ return selections.join('');
166
+ }
167
+ /**
168
+ * Get the effective fetch strategy for a link, applying cardinality-based defaults.
169
+ *
170
+ * - `fetch` explicitly set → use it
171
+ * - `fetch` absent → apply defaults:
172
+ * - `noneOrMany`/`atLeastOne` → `{ method: 'sync', limit: 50 }`
173
+ * - `one`/`atMostOne` → `{ method: 'lazy' }`
174
+ *
175
+ * @internal
176
+ */
177
+ function getEffectiveFetchStrategy(link) {
178
+ if (link.fetch !== undefined) {
179
+ return link.fetch;
180
+ }
181
+ // Apply cardinality-based defaults
182
+ if (isManyCardinality(link.cardinality)) {
183
+ return { method: 'sync', limit: DEFAULT_SYNC_LIMIT };
184
+ }
185
+ else {
186
+ return { method: 'lazy' };
187
+ }
188
+ }
189
+ /**
190
+ * Get the effective blockWorkflows value for a link.
191
+ * Returns true if blockWorkflows is explicitly true, or if it's absent and fetch method is 'sync'.
192
+ * @internal
193
+ */
194
+ function getEffectiveBlockWorkflows(link) {
195
+ if (link.blockWorkflows !== undefined) {
196
+ return link.blockWorkflows;
197
+ }
198
+ const effectiveFetch = getEffectiveFetchStrategy(link);
199
+ return effectiveFetch.method === 'sync';
200
+ }
201
+ /**
202
+ * Get scalar field names for a doctype, excluding Link and Doctype fields
203
+ * @internal
204
+ */
205
+ function queryableFieldNames(meta) {
206
+ return meta.fields
207
+ .filter(f => !RELATION_FIELDTYPES.has(f.fieldtype))
208
+ .map(f => f.fieldname)
209
+ .join('\n ');
210
+ }
211
+ /**
212
+ * Check if a cardinality value represents a 1:many relationship
213
+ * @internal
214
+ */
215
+ function isManyCardinality(cardinality) {
216
+ return cardinality === 'noneOrMany' || cardinality === 'atLeastOne';
217
+ }
218
+ /**
219
+ * Derive a PostGraphile connection field name from a target doctype and parent table name.
220
+ *
221
+ * PostGraphile convention: `{targetPlural}By{ParentTablePascal}Id`
222
+ * Example: recipe_task with parent recipe → `recipeTasksByRecipeId`
223
+ *
224
+ * @internal
225
+ */
226
+ function getConnectionFieldName(targetMeta, parentTableName) {
227
+ const targetPlural = pluralize.plural(targetMeta.tableName);
228
+ const targetPascal = toPascalCase(targetPlural);
229
+ const fkPascal = toPascalCase(parentTableName) + 'Id';
230
+ return `${targetPascal}By${fkPascal}`;
231
+ }
@@ -1,4 +1,4 @@
1
- import type { DataClient, DoctypeMeta, DoctypeContext, DoctypeRef } from '@stonecrop/schema';
1
+ import type { DataClient, DoctypeMeta, DoctypeContext, DoctypeRef, GetRecordOptions, GetRecordsOptions } from '@stonecrop/schema';
2
2
  export type { DoctypeContext, DoctypeRef };
3
3
  /**
4
4
  * Options for creating a Stonecrop client
@@ -9,6 +9,8 @@ export interface StonecropClientOptions {
9
9
  endpoint: string;
10
10
  /** Additional HTTP headers to include in requests */
11
11
  headers?: Record<string, string>;
12
+ /** Doctype registry for nested query building */
13
+ registry?: Map<string, DoctypeMeta>;
12
14
  }
13
15
  /**
14
16
  * Client for interacting with Stonecrop GraphQL API
@@ -18,6 +20,7 @@ export declare class StonecropClient implements DataClient {
18
20
  private endpoint;
19
21
  private headers;
20
22
  private metaCache;
23
+ private registry?;
21
24
  constructor(options: StonecropClientOptions);
22
25
  /**
23
26
  * Execute a GraphQL query
@@ -41,22 +44,22 @@ export declare class StonecropClient implements DataClient {
41
44
  */
42
45
  getAllMeta(): Promise<DoctypeMeta[]>;
43
46
  /**
44
- * Get a single record by ID
47
+ * Get a single record by ID.
48
+ *
49
+ * When `includeNested` is set, builds a query with sub-selections for descendant
50
+ * links and returns ancestor + merged descendants. When omitted, returns flat scalar data.
51
+ *
45
52
  * @param doctype - Doctype reference (name and optional slug)
46
53
  * @param recordId - Record ID to fetch
54
+ * @param options - Query options (includeNested, maxDepth)
47
55
  */
48
- getRecord(doctype: DoctypeRef, recordId: string): Promise<Record<string, unknown> | null>;
56
+ getRecord(doctype: DoctypeRef, recordId: string, options?: GetRecordOptions): Promise<Record<string, unknown> | null>;
49
57
  /**
50
58
  * Get multiple records with optional filtering and pagination
51
59
  * @param doctype - Doctype reference (name and optional slug)
52
60
  * @param options - Query options (filters, orderBy, limit, offset)
53
61
  */
54
- getRecords(doctype: DoctypeRef, options?: {
55
- filters?: Record<string, unknown>;
56
- orderBy?: string;
57
- limit?: number;
58
- offset?: number;
59
- }): Promise<Record<string, unknown>[]>;
62
+ getRecords(doctype: DoctypeRef, options?: GetRecordsOptions): Promise<Record<string, unknown>[]>;
60
63
  /**
61
64
  * Execute a doctype action
62
65
  * @param doctype - Doctype reference (name and optional slug)
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAE5F,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,CAAA;AAE1C;;;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,eAAgB,YAAW,UAAU;IACjD,OAAO,CAAC,QAAQ,CAAQ;IACxB,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,SAAS,CAAsC;gBAE3C,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,cAAc,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;IAyDnE;;OAEG;IACG,UAAU,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;IAqD1C;;;;OAIG;IACG,SAAS,CAAC,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAiB/F;;;;OAIG;IACG,UAAU,CACf,OAAO,EAAE,UAAU,EACnB,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,UAAU,EACnB,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"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACX,UAAU,EACV,WAAW,EACX,cAAc,EACd,UAAU,EACV,gBAAgB,EAChB,iBAAiB,EACjB,MAAM,mBAAmB,CAAA;AAM1B,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,CAAA;AAe1C;;;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;IAChC,iDAAiD;IACjD,QAAQ,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;CACnC;AAED;;;GAGG;AACH,qBAAa,eAAgB,YAAW,UAAU;IACjD,OAAO,CAAC,QAAQ,CAAQ;IACxB,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,SAAS,CAAsC;IACvD,OAAO,CAAC,QAAQ,CAAC,CAA0B;gBAE/B,OAAO,EAAE,sBAAsB;IAS3C;;;;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,cAAc,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;IAuDnE;;OAEG;IACG,UAAU,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;IAmD1C;;;;;;;;;OASG;IACG,SAAS,CACd,OAAO,EAAE,UAAU,EACnB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,gBAAgB,GACxB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IA8C1C;;;;OAIG;IACG,UAAU,CAAC,OAAO,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAiCtG;;;;;OAKG;IACG,SAAS,CACd,OAAO,EAAE,UAAU,EACnB,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"}
@@ -13,6 +13,7 @@ declare const methods: {
13
13
  };
14
14
  export { queries, typeDefs, methods };
15
15
  export { StonecropClient } from './client';
16
+ export { buildRecordQuery, buildListQuery } from './query';
16
17
  export type { StonecropClientOptions, DoctypeContext } from './client';
17
18
  export type { Meta, MetaParser, MetaResponse } from './types';
18
19
  export type { DoctypeMeta } from '@stonecrop/schema';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AACnC,OAAO,QAAQ,MAAM,cAAc,CAAA;AACnC,OAAO,KAAK,EAAoB,YAAY,EAAE,MAAM,SAAS,CAAA;AAkC7D;;;;;;GAMG;AACH,QAAA,MAAM,OAAO;uBACa,MAAM,QAAQ,MAAM,KAAG,OAAO,CAAC,YAAY,CAAC;CAgBrE,CAAA;AAED,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAA;AACrC,OAAO,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAC1C,YAAY,EAAE,sBAAsB,EAAE,cAAc,EAAE,MAAM,UAAU,CAAA;AACtE,YAAY,EAAE,IAAI,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAC7D,YAAY,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AACnC,OAAO,QAAQ,MAAM,cAAc,CAAA;AACnC,OAAO,KAAK,EAAoB,YAAY,EAAE,MAAM,SAAS,CAAA;AAkC7D;;;;;;GAMG;AACH,QAAA,MAAM,OAAO;uBACa,MAAM,QAAQ,MAAM,KAAG,OAAO,CAAC,YAAY,CAAC;CAgBrE,CAAA;AAED,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAA;AACrC,OAAO,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAC1C,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC1D,YAAY,EAAE,sBAAsB,EAAE,cAAc,EAAE,MAAM,UAAU,CAAA;AACtE,YAAY,EAAE,IAAI,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAC7D,YAAY,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA"}
@@ -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"}
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.1",
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.1",
38
+ "@stonecrop/stonecrop": "0.11.1"
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'