Skip to content

Commit 260daf9

Browse files
authored
Update runtime validation tests (#49)
1 parent af51a5a commit 260daf9

File tree

15 files changed

+477
-125
lines changed

15 files changed

+477
-125
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"tangrams": patch
3+
---
4+
5+
Fix TypeScript compilation errors for ArkType and Effect validators:
6+
7+
- **ArkType**: Fix fragment spread handling in GraphQL schemas. Previously, spreading `.infer` inside `type({})` caused TypeScript to interpret property names like `"id"` as type keywords. Now uses `.and()` to properly merge fragment schemas.
8+
9+
- **Effect**: Fix array and optional field compatibility with TanStack DB collections:
10+
- Wrap arrays with `Schema.mutable()` to produce mutable array types (`T[]` instead of `readonly T[]`)
11+
- Change optional fields from `Schema.NullishOr(T)` to `Schema.optional(Schema.NullOr(T))` to make keys truly optional in TypeScript, matching `Partial<T>` semantics

bun.lock

Lines changed: 13 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@
6262
"zod": "^4.0.0"
6363
},
6464
"devDependencies": {
65+
"@better-fetch/fetch": "^1.1.21",
66+
"@tanstack/db": "^0.5.15",
67+
"@tanstack/query-db-collection": "^1.0.11",
68+
"@tanstack/react-db": "^0.1.59",
69+
"@tanstack/react-form": "^1.27.5",
6570
"@tanstack/react-query": "^5.80.6",
6671
"@types/bun": "latest",
6772
"@types/micromatch": "^4.0.9",
@@ -71,6 +76,7 @@
7176
"effect": "^3.12.0",
7277
"graphql-request": "^7.1.2",
7378
"tsup": "^8.5.0",
79+
"typescript": "^5.9.3",
7480
"valibot": "^1.1.0",
7581
"vitest": "^3.2.3",
7682
"zod": "^4.0.0"

packages/cli/src/adapters/graphql/collections.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,16 +124,17 @@ describe("GraphQL Collection Discovery", () => {
124124
expect(userEntity).toBeDefined();
125125
});
126126

127-
it("has no selectorPath for direct array responses", async () => {
127+
it("has selectorPath matching the response key for direct array responses", async () => {
128128
const schema = await graphqlAdapter.loadSchema(directArrayConfig);
129129
const result = graphqlAdapter.discoverCollectionEntities(
130130
schema,
131131
directArrayConfig,
132132
);
133133

134134
const userEntity = result.entities.find((e) => e.name === "User");
135-
// Direct arrays should have undefined selectorPath
136-
expect(userEntity?.listQuery.selectorPath).toBeUndefined();
135+
// Direct arrays should have selectorPath equal to the response key (field name)
136+
// because GraphQL responses are always { fieldName: data }, not just data
137+
expect(userEntity?.listQuery.selectorPath).toBe("users");
137138
});
138139
});
139140

packages/cli/src/adapters/graphql/collections.ts

Lines changed: 119 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,6 @@ import type {
4848
/** Hardcoded import path for functions (always ../functions from db/) */
4949
const FUNCTIONS_IMPORT_PATH = "../functions";
5050

51-
/** Maximum depth to search for arrays in nested types */
52-
const MAX_ARRAY_SEARCH_DEPTH = 3;
53-
5451
/**
5552
* Result of finding an array in a type
5653
*/
@@ -132,11 +129,19 @@ export function discoverGraphQLEntities(
132129
}
133130

134131
/**
135-
* Find all queries that return list types (directly or wrapped in objects)
132+
* Find all queries that return list types (directly or wrapped in pagination objects)
133+
*
134+
* This function identifies queries that are suitable for collection generation:
135+
* 1. Fields that return a list directly (e.g., `users: [User!]!`)
136+
* 2. Fields that return a wrapper object with a data/items array (e.g., pagination wrappers)
137+
*
138+
* It does NOT consider nested arrays on returned objects as list queries.
139+
* For example, `user(id: ID!): User` where `User` has `posts: [Post!]!` is NOT
140+
* a list query for Posts - it's a single-item query that happens to have a nested array.
136141
*/
137142
function findListQueries(
138143
queryType: GraphQLObjectType,
139-
schema: GraphQLSchema,
144+
_schema: GraphQLSchema,
140145
documents: ParsedDocuments,
141146
warnings: string[],
142147
): ListQueryMatch[] {
@@ -150,36 +155,38 @@ function findListQueries(
150155

151156
if (directListInfo.isList && directListInfo.itemTypeName) {
152157
// Direct list return - find matching operation
153-
const match = findMatchingOperation(
158+
// For direct lists, the selectorPath is just the response key
159+
// e.g., `query { users { id } }` returns `{ users: [...] }` -> selectorPath = "users"
160+
const match = findMatchingOperationForDirectList(
154161
fieldName,
155162
field,
156163
directListInfo.itemTypeName,
157164
documents,
158-
undefined,
159165
);
160166
if (match) {
161167
results.push(match);
162168
}
163169
continue;
164170
}
165171

166-
// Check if the return type is an object that contains a list (wrapped response)
172+
// Check if the return type is a wrapper object (like a pagination envelope)
173+
// that contains a data/items array field
174+
//
175+
// NOTE: We only look for pagination-style wrappers, NOT arbitrary nested arrays.
176+
// A query like `user(id: ID!): User` where User has `posts: [Post!]!` should NOT
177+
// be treated as a list query for Posts - that would require a dedicated `posts` query.
167178
const unwrappedType = unwrapType(field.type);
168179
if (isObjectType(unwrappedType)) {
169-
const arrayPath = findArrayInObjectType(
170-
unwrappedType,
171-
schema,
172-
warnings,
173-
MAX_ARRAY_SEARCH_DEPTH,
174-
);
180+
// Only look for common pagination wrapper patterns (data, items, edges, nodes, results)
181+
const arrayPath = findPaginationArrayField(unwrappedType, warnings);
175182
if (arrayPath) {
176-
// Found a wrapped array - find matching operation
183+
// Found a pagination wrapper - find matching operation
177184
const match = findMatchingOperation(
178185
fieldName,
179186
field,
180187
arrayPath.itemTypeName,
181188
documents,
182-
arrayPath.path,
189+
`${fieldName}.${arrayPath.path}`,
183190
);
184191
if (match) {
185192
results.push(match);
@@ -192,40 +199,90 @@ function findListQueries(
192199
}
193200

194201
/**
195-
* Find the matching document operation for a schema field
202+
* Find a pagination-style array field in an object type
203+
* Only looks for common wrapper patterns like { data: [...] }, { items: [...] }, etc.
204+
*
205+
* This is more conservative than findArrayInObjectType - it doesn't recursively
206+
* search nested objects for arrays, which prevents incorrectly treating single-item
207+
* queries with nested arrays as list queries.
196208
*/
197-
function findMatchingOperation(
209+
function findPaginationArrayField(
210+
type: GraphQLObjectType,
211+
warnings: string[],
212+
): ArrayPathResult | null {
213+
const fields = type.getFields();
214+
const arrayFields: Array<{ fieldName: string; result: ArrayPathResult }> = [];
215+
216+
// Common pagination wrapper field names
217+
const paginationFieldNames = new Set([
218+
"data",
219+
"items",
220+
"edges",
221+
"nodes",
222+
"results",
223+
"records",
224+
"list",
225+
"rows",
226+
]);
227+
228+
for (const [fieldName, field] of Object.entries(fields)) {
229+
// Only consider known pagination field names
230+
if (!paginationFieldNames.has(fieldName.toLowerCase())) {
231+
continue;
232+
}
233+
234+
// Check if this field is a list
235+
const listInfo = analyzeReturnType(field.type);
236+
if (listInfo.isList && listInfo.itemTypeName) {
237+
arrayFields.push({
238+
fieldName,
239+
result: { path: fieldName, itemTypeName: listInfo.itemTypeName },
240+
});
241+
}
242+
}
243+
244+
// If multiple arrays found, warn and take the first
245+
if (arrayFields.length > 1) {
246+
const firstField = arrayFields[0];
247+
const fieldNames = arrayFields.map((f) => f.fieldName).join(", ");
248+
warnings.push(
249+
`Multiple pagination array fields found in type "${type.name}": ${fieldNames}. Using first found: "${firstField?.fieldName}". ` +
250+
`If this is incorrect, configure selectorPath in overrides.db.collections.`,
251+
);
252+
}
253+
254+
return arrayFields[0]?.result ?? null;
255+
}
256+
257+
/**
258+
* Find the matching document operation for a schema field that returns a direct list.
259+
* The selectorPath is just the response key (field name or alias).
260+
*
261+
* e.g., `query { users { id } }` returns `{ users: [...] }` -> selectorPath = "users"
262+
*/
263+
function findMatchingOperationForDirectList(
198264
schemaFieldName: string,
199265
field: GraphQLField<unknown, unknown>,
200266
itemTypeName: string,
201267
documents: ParsedDocuments,
202-
innerPath: string | undefined,
203268
): ListQueryMatch | null {
204-
// Find operation that queries this field
205269
for (const op of documents.operations) {
206270
if (op.operation !== "query") continue;
207271

208-
// Find the field selection that matches this schema field
209272
for (const sel of op.node.selectionSet.selections) {
210273
if (sel.kind !== Kind.FIELD) continue;
211274

212275
const fieldNode = sel as FieldNode;
213-
// Check if this selection targets our schema field
214276
if (fieldNode.name.value === schemaFieldName) {
215-
// Get the response key (alias if present, otherwise field name)
216277
const responseKey = fieldNode.alias?.value || fieldNode.name.value;
217278

218-
// Build the full selector path
219-
const selectorPath = innerPath
220-
? `${responseKey}.${innerPath}`
221-
: undefined;
222-
223279
return {
224280
field,
225281
typeName: itemTypeName,
226282
operation: op,
227283
responseKey,
228-
selectorPath,
284+
// For direct lists, the selectorPath is just the response key
285+
selectorPath: responseKey,
229286
};
230287
}
231288
}
@@ -235,71 +292,48 @@ function findMatchingOperation(
235292
}
236293

237294
/**
238-
* Recursively search an object type for a list field
239-
* Returns the path to the list and the item type name
295+
* Find the matching document operation for a schema field with a nested/wrapped array.
296+
* The selectorPath includes the full path to the array.
297+
*
298+
* e.g., pagination wrapper: `query { users { data { id } } }` returns
299+
* `{ users: { data: [...] } }` -> selectorPath = "users.data"
240300
*/
241-
function findArrayInObjectType(
242-
type: GraphQLObjectType,
243-
schema: GraphQLSchema,
244-
warnings: string[],
245-
maxDepth: number,
246-
currentDepth: number = 0,
247-
visitedTypes: Set<string> = new Set(),
248-
): ArrayPathResult | null {
249-
if (currentDepth >= maxDepth) return null;
301+
function findMatchingOperation(
302+
schemaFieldName: string,
303+
field: GraphQLField<unknown, unknown>,
304+
itemTypeName: string,
305+
documents: ParsedDocuments,
306+
selectorPath: string,
307+
): ListQueryMatch | null {
308+
for (const op of documents.operations) {
309+
if (op.operation !== "query") continue;
250310

251-
// Prevent infinite recursion on cyclic types
252-
if (visitedTypes.has(type.name)) return null;
253-
visitedTypes.add(type.name);
311+
for (const sel of op.node.selectionSet.selections) {
312+
if (sel.kind !== Kind.FIELD) continue;
254313

255-
const fields = type.getFields();
256-
const arrayFields: Array<{ fieldName: string; result: ArrayPathResult }> = [];
314+
const fieldNode = sel as FieldNode;
315+
if (fieldNode.name.value === schemaFieldName) {
316+
const responseKey = fieldNode.alias?.value || fieldNode.name.value;
257317

258-
for (const [fieldName, field] of Object.entries(fields)) {
259-
// Check if this field is a list
260-
const listInfo = analyzeReturnType(field.type);
261-
if (listInfo.isList && listInfo.itemTypeName) {
262-
arrayFields.push({
263-
fieldName,
264-
result: { path: fieldName, itemTypeName: listInfo.itemTypeName },
265-
});
266-
continue;
267-
}
318+
// For wrapped arrays, replace the schema field name with the response key in the path
319+
// e.g., if field is "users" but aliased as "allUsers", and selectorPath is "users.data",
320+
// we want "allUsers.data"
321+
const adjustedPath = selectorPath.startsWith(schemaFieldName)
322+
? responseKey + selectorPath.slice(schemaFieldName.length)
323+
: selectorPath;
268324

269-
// If it's an object type, recurse
270-
const unwrapped = unwrapType(field.type);
271-
if (isObjectType(unwrapped)) {
272-
const nested = findArrayInObjectType(
273-
unwrapped,
274-
schema,
275-
warnings,
276-
maxDepth,
277-
currentDepth + 1,
278-
visitedTypes,
279-
);
280-
if (nested) {
281-
arrayFields.push({
282-
fieldName,
283-
result: {
284-
path: `${fieldName}.${nested.path}`,
285-
itemTypeName: nested.itemTypeName,
286-
},
287-
});
325+
return {
326+
field,
327+
typeName: itemTypeName,
328+
operation: op,
329+
responseKey,
330+
selectorPath: adjustedPath,
331+
};
288332
}
289333
}
290334
}
291335

292-
// If multiple arrays found at this level, warn and take the first
293-
if (arrayFields.length > 1) {
294-
const firstField = arrayFields[0];
295-
const fieldNames = arrayFields.map((f) => f.fieldName).join(", ");
296-
warnings.push(
297-
`Multiple array fields found in type "${type.name}": ${fieldNames}. Using first found: "${firstField?.fieldName}". ` +
298-
`If this is incorrect, configure selectorPath in overrides.db.collections.`,
299-
);
300-
}
301-
302-
return arrayFields[0]?.result ?? null;
336+
return null;
303337
}
304338

305339
/**

0 commit comments

Comments
 (0)