Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/funny-planes-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@tailor-platform/sdk": minor
---

Add `toResolverOutput` function to convert TailorDBType to resolver output field

- `toResolverOutput(type)` converts a TailorDBType to generated query output type
- Simplifies using TailorDB types as resolver outputs with proper type names
174 changes: 174 additions & 0 deletions example/e2e/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ describe("controlplane", async () => {
// Verify field descriptions from TailorDBField
const inputType = passThrough?.inputs?.find((i) => i.name === "input");
expect(inputType?.type?.kind).toBe("UserDefined");
expect(inputType?.type?.name).toBe("NestedProfileInput");

// Verify response field descriptions
const userInfoResponse = passThrough?.response?.type?.fields?.find(
Expand Down Expand Up @@ -385,6 +386,179 @@ describe("dataplane", () => {
const result = await graphQLClient.rawRequest(query);
expect(result.errors).toBeDefined();
});

test("verifies output fields via GraphQL introspection", async () => {
const introspectionQuery = gql`
query {
__type(name: "Query") {
fields {
name
type {
name
kind
ofType {
name
}
}
}
}
}
`;
const result = await graphQLClient.rawRequest(introspectionQuery);
expect(result.errors).toBeUndefined();

const queryType = result.data as {
__type: {
fields: {
name: string;
type: { name: string | null; kind: string; ofType: { name: string } | null };
}[];
};
};
const passThroughField = queryType.__type.fields.find((f) => f.name === "passThrough");
expect(passThroughField).toBeDefined();

// Verify the output type exists (platform generates name from resolver name)
const outputTypeName = passThroughField?.type.name ?? passThroughField?.type.ofType?.name;
expect(outputTypeName).toBeDefined();

// Verify the output type has the expected fields from NestedProfile
const typeQuery = gql`
query GetType($name: String!) {
__type(name: $name) {
fields {
name
}
}
}
`;
const typeResult = await graphQLClient.rawRequest(typeQuery, { name: outputTypeName });
const fieldNames = (
typeResult.data as {
__type: { fields: { name: string }[] };
}
).__type.fields.map((f) => f.name);

// toResolverOutput should produce the same fields as the TailorDB type
expect(fieldNames).toContain("userInfo");
expect(fieldNames).toContain("metadata");
expect(fieldNames).toContain("archived");
});

test("toResolverOutput produces fields matching TailorDB structure", async () => {
// Helper to get field type kind
const getTypeKind = (
field:
| {
type: {
name: string | null;
kind: string;
ofType: { name: string; kind: string } | null;
};
}
| undefined,
) => field?.type?.kind ?? field?.type?.ofType?.kind;

// Get the passThrough resolver's output type name
const schemaQuery = gql`
query {
__schema {
queryType {
fields {
name
type {
name
ofType {
name
}
}
}
}
}
}
`;
const schemaResult = await graphQLClient.rawRequest(schemaQuery);
const queryFields = (
schemaResult.data as {
__schema: {
queryType: {
fields: {
name: string;
type: { name: string | null; ofType: { name: string } | null };
}[];
};
};
}
).__schema.queryType.fields;
const passThroughField = queryFields.find((f) => f.name === "passThrough");
const passThroughTypeName =
passThroughField?.type.name ?? passThroughField?.type.ofType?.name;

// Get nested field details for passThrough output
const typeQuery = gql`
query GetType($name: String!) {
__type(name: $name) {
fields {
name
type {
name
kind
ofType {
name
kind
}
}
}
}
}
`;
const passThroughTypeResult = await graphQLClient.rawRequest(typeQuery, {
name: passThroughTypeName,
});
const passThroughFields = (
passThroughTypeResult.data as {
__type: {
fields: {
name: string;
type: {
name: string | null;
kind: string;
ofType: { name: string; kind: string } | null;
};
}[];
};
}
).__type.fields;

// Get TailorDB NestedProfile type for comparison
const tailorDbResult = await graphQLClient.rawRequest(typeQuery, { name: "NestedProfile" });
const tailorDbFields = (
tailorDbResult.data as {
__type: {
fields: {
name: string;
type: {
name: string | null;
kind: string;
ofType: { name: string; kind: string } | null;
};
}[];
};
}
).__type.fields;

// toResolverOutput should produce the same field names as the TailorDB type
// for fields that are directly defined on the type (not backward relations)
const directFields = ["userInfo", "metadata", "archived", "ownerID"];
for (const fieldName of directFields) {
const passThroughFieldEntry = passThroughFields.find((f) => f.name === fieldName);
const tailorDbFieldEntry = tailorDbFields.find((f) => f.name === fieldName);
expect(passThroughFieldEntry).toBeDefined();
expect(tailorDbFieldEntry).toBeDefined();
// Verify the field type kind matches (OBJECT, SCALAR, etc.)
expect(getTypeKind(passThroughFieldEntry)).toBe(getTypeKind(tailorDbFieldEntry));
}
});
});

test("env", async () => {
Expand Down
4 changes: 4 additions & 0 deletions example/generated/files.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
export interface TypeWithFiles {
NestedProfile: {
fields: "avatar";
};
SalesOrder: {
fields: "receipt" | "form";
};
Expand All @@ -11,6 +14,7 @@ export interface TypeWithFiles {
}

const namespaces: Record<keyof TypeWithFiles, string> = {
NestedProfile: "tailordb",
SalesOrder: "tailordb",
User: "tailordb",
Event: "analyticsdb",
Expand Down
17 changes: 17 additions & 0 deletions example/generated/tailordb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,23 @@ export interface Namespace {
version: number;
};
archived: boolean | null;
ownerID: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Timestamp | null;
}

ProfileComment: {
id: Generated<string>;
content: string;
profileID: string;
createdAt: Generated<Timestamp>;
updatedAt: Timestamp | null;
}

ProfileDetail: {
id: Generated<string>;
bio: string | null;
profileID: string;
createdAt: Generated<Timestamp>;
updatedAt: Timestamp | null;
}
Expand Down
107 changes: 107 additions & 0 deletions example/migrations/0001/diff.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
{
"version": 1,
"namespace": "tailordb",
"createdAt": "2026-02-05T02:04:29.299Z",
"changes": [
{
"kind": "type_added",
"typeName": "ProfileComment",
"after": {
"name": "ProfileComment",
"fields": {
"id": {
"type": "uuid",
"required": true
},
"content": {
"type": "string",
"required": true
},
"profileID": {
"type": "uuid",
"required": true,
"index": true,
"foreignKey": true,
"foreignKeyType": "NestedProfile",
"foreignKeyField": "id"
},
"createdAt": {
"type": "datetime",
"required": true
},
"updatedAt": {
"type": "datetime",
"required": false
}
},
"pluralForm": "ProfileComments",
"description": "Comment on a profile",
"settings": {}
}
},
{
"kind": "type_added",
"typeName": "ProfileDetail",
"after": {
"name": "ProfileDetail",
"fields": {
"id": {
"type": "uuid",
"required": true
},
"bio": {
"type": "string",
"required": false
},
"profileID": {
"type": "uuid",
"required": true,
"index": true,
"unique": true,
"foreignKey": true,
"foreignKeyType": "NestedProfile",
"foreignKeyField": "id"
},
"createdAt": {
"type": "datetime",
"required": true
},
"updatedAt": {
"type": "datetime",
"required": false
}
},
"pluralForm": "ProfileDetails",
"description": "Additional detail for a profile",
"settings": {}
}
},
{
"kind": "field_added",
"typeName": "NestedProfile",
"fieldName": "ownerID",
"after": {
"type": "uuid",
"required": false,
"index": true,
"foreignKey": true,
"foreignKeyType": "User",
"foreignKeyField": "id"
}
},
{
"kind": "type_modified",
"typeName": "NestedProfile",
"reason": "File field \"avatar\" added",
"before": {},
"after": {
"files": {
"avatar": "profile avatar image"
}
}
}
],
"hasBreakingChanges": false,
"breakingChanges": [],
"requiresMigrationScript": false
}
Loading
Loading