diff --git a/.changeset/funny-planes-admire.md b/.changeset/funny-planes-admire.md new file mode 100644 index 000000000..77b1840cf --- /dev/null +++ b/.changeset/funny-planes-admire.md @@ -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 diff --git a/example/e2e/resolver.test.ts b/example/e2e/resolver.test.ts index 46e82f678..adb312b35 100644 --- a/example/e2e/resolver.test.ts +++ b/example/e2e/resolver.test.ts @@ -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( @@ -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 () => { diff --git a/example/generated/files.ts b/example/generated/files.ts index 052806afd..3d680bb79 100644 --- a/example/generated/files.ts +++ b/example/generated/files.ts @@ -1,4 +1,7 @@ export interface TypeWithFiles { + NestedProfile: { + fields: "avatar"; + }; SalesOrder: { fields: "receipt" | "form"; }; @@ -11,6 +14,7 @@ export interface TypeWithFiles { } const namespaces: Record = { + NestedProfile: "tailordb", SalesOrder: "tailordb", User: "tailordb", Event: "analyticsdb", diff --git a/example/generated/tailordb.ts b/example/generated/tailordb.ts index 123311590..e4b145d76 100644 --- a/example/generated/tailordb.ts +++ b/example/generated/tailordb.ts @@ -55,6 +55,23 @@ export interface Namespace { version: number; }; archived: boolean | null; + ownerID: string | null; + createdAt: Generated; + updatedAt: Timestamp | null; + } + + ProfileComment: { + id: Generated; + content: string; + profileID: string; + createdAt: Generated; + updatedAt: Timestamp | null; + } + + ProfileDetail: { + id: Generated; + bio: string | null; + profileID: string; createdAt: Generated; updatedAt: Timestamp | null; } diff --git a/example/migrations/0001/diff.json b/example/migrations/0001/diff.json new file mode 100644 index 000000000..5cfea1b3d --- /dev/null +++ b/example/migrations/0001/diff.json @@ -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 +} diff --git a/example/migrations/0002/diff.json b/example/migrations/0002/diff.json new file mode 100644 index 000000000..3c1570414 --- /dev/null +++ b/example/migrations/0002/diff.json @@ -0,0 +1,394 @@ +{ + "version": 1, + "namespace": "tailordb", + "createdAt": "2026-02-21T12:31:41.542Z", + "changes": [ + { + "kind": "relationship_added", + "typeName": "NestedProfile", + "relationshipName": "owner", + "relationshipType": "forward", + "after": { + "targetType": "User", + "targetField": "ownerID", + "sourceField": "id", + "isArray": false, + "description": "" + } + }, + { + "kind": "relationship_added", + "typeName": "NestedProfile", + "relationshipName": "comments", + "relationshipType": "backward", + "after": { + "targetType": "ProfileComment", + "targetField": "profileID", + "sourceField": "id", + "isArray": true, + "description": "Comment on a profile" + } + }, + { + "kind": "relationship_added", + "typeName": "NestedProfile", + "relationshipName": "detail", + "relationshipType": "backward", + "after": { + "targetType": "ProfileDetail", + "targetField": "profileID", + "sourceField": "id", + "isArray": false, + "description": "Additional detail for a profile" + } + }, + { + "kind": "field_modified", + "typeName": "ProfileComment", + "fieldName": "content", + "before": { + "type": "string", + "required": true + }, + "after": { + "type": "string", + "required": true, + "description": "Comment content" + } + }, + { + "kind": "field_modified", + "typeName": "ProfileComment", + "fieldName": "profileID", + "before": { + "type": "uuid", + "required": true, + "index": true, + "foreignKey": true, + "foreignKeyType": "NestedProfile", + "foreignKeyField": "id" + }, + "after": { + "type": "uuid", + "required": true, + "index": true, + "foreignKey": true, + "foreignKeyType": "NestedProfile", + "foreignKeyField": "id", + "description": "Referenced profile" + } + }, + { + "kind": "field_modified", + "typeName": "ProfileComment", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "ProfileComment", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + } + }, + { + "kind": "relationship_added", + "typeName": "ProfileComment", + "relationshipName": "profile", + "relationshipType": "forward", + "after": { + "targetType": "NestedProfile", + "targetField": "profileID", + "sourceField": "id", + "isArray": false, + "description": "Nested Profile Type" + } + }, + { + "kind": "permission_modified", + "typeName": "ProfileComment", + "reason": "record permission changed", + "before": {}, + "after": { + "recordPermission": { + "create": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "read": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + }, + { + "conditions": [ + [ + { + "user": "_loggedIn" + }, + "eq", + true + ] + ], + "permit": "allow" + } + ], + "update": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "delete": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ] + } + } + }, + { + "kind": "field_modified", + "typeName": "ProfileDetail", + "fieldName": "bio", + "before": { + "type": "string", + "required": false + }, + "after": { + "type": "string", + "required": false, + "description": "Extended biography" + } + }, + { + "kind": "field_modified", + "typeName": "ProfileDetail", + "fieldName": "profileID", + "before": { + "type": "uuid", + "required": true, + "index": true, + "unique": true, + "foreignKey": true, + "foreignKeyType": "NestedProfile", + "foreignKeyField": "id" + }, + "after": { + "type": "uuid", + "required": true, + "index": true, + "unique": true, + "foreignKey": true, + "foreignKeyType": "NestedProfile", + "foreignKeyField": "id", + "description": "Referenced profile" + } + }, + { + "kind": "field_modified", + "typeName": "ProfileDetail", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "ProfileDetail", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + } + }, + { + "kind": "relationship_added", + "typeName": "ProfileDetail", + "relationshipName": "profile", + "relationshipType": "forward", + "after": { + "targetType": "NestedProfile", + "targetField": "profileID", + "sourceField": "id", + "isArray": false, + "description": "Nested Profile Type" + } + }, + { + "kind": "permission_modified", + "typeName": "ProfileDetail", + "reason": "record permission changed", + "before": {}, + "after": { + "recordPermission": { + "create": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "read": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + }, + { + "conditions": [ + [ + { + "user": "_loggedIn" + }, + "eq", + true + ] + ], + "permit": "allow" + } + ], + "update": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "delete": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ] + } + } + }, + { + "kind": "relationship_added", + "typeName": "User", + "relationshipName": "nestedProfiles", + "relationshipType": "backward", + "after": { + "targetType": "NestedProfile", + "targetField": "ownerID", + "sourceField": "id", + "isArray": true, + "description": "Nested Profile Type" + } + } + ], + "hasBreakingChanges": false, + "breakingChanges": [], + "requiresMigrationScript": false +} diff --git a/example/resolvers/passThrough.ts b/example/resolvers/passThrough.ts index adeaa78e7..643cc4436 100644 --- a/example/resolvers/passThrough.ts +++ b/example/resolvers/passThrough.ts @@ -1,4 +1,4 @@ -import { createResolver, t } from "@tailor-platform/sdk"; +import { createResolver, t, toResolverOutput } from "@tailor-platform/sdk"; import { nestedProfile } from "../tailordb/nested"; const inputFields = { @@ -11,12 +11,12 @@ export default createResolver({ description: "Pass Through - Nested Profile Type(Create)", input: { id: t.uuid({ optional: true }), - input: t.object(inputFields), + input: t.object(inputFields).typeName("NestedProfileInput"), }, body: ({ input }) => ({ ...input.input, id: input.id ?? crypto.randomUUID(), createdAt: new Date(), }), - output: nestedProfile.fields, + output: toResolverOutput(nestedProfile), }); diff --git a/example/seed/data/NestedProfile.schema.ts b/example/seed/data/NestedProfile.schema.ts index c45955780..f57178cb5 100644 --- a/example/seed/data/NestedProfile.schema.ts +++ b/example/seed/data/NestedProfile.schema.ts @@ -12,4 +12,9 @@ const hook = createTailorDBHook(nestedProfile); export const schema = defineSchema( createStandardSchema(schemaType, hook), + { + foreignKeys: [ + {"column":"ownerID","references":{"table":"User","column":"id"}}, + ], + } ); diff --git a/example/seed/data/ProfileComment.jsonl b/example/seed/data/ProfileComment.jsonl new file mode 100644 index 000000000..e69de29bb diff --git a/example/seed/data/ProfileComment.schema.ts b/example/seed/data/ProfileComment.schema.ts new file mode 100644 index 000000000..81a8d8c04 --- /dev/null +++ b/example/seed/data/ProfileComment.schema.ts @@ -0,0 +1,20 @@ +import { t } from "@tailor-platform/sdk"; +import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/test"; +import { defineSchema } from "@toiroakr/lines-db"; +import { profileComment } from "../../tailordb/profileReference"; + +const schemaType = t.object({ + ...profileComment.pickFields(["id","createdAt"], { optional: true }), + ...profileComment.omitFields(["id","createdAt"]), +}); + +const hook = createTailorDBHook(profileComment); + +export const schema = defineSchema( + createStandardSchema(schemaType, hook), + { + foreignKeys: [ + {"column":"profileID","references":{"table":"NestedProfile","column":"id"}}, + ], + } +); diff --git a/example/seed/data/ProfileDetail.jsonl b/example/seed/data/ProfileDetail.jsonl new file mode 100644 index 000000000..e69de29bb diff --git a/example/seed/data/ProfileDetail.schema.ts b/example/seed/data/ProfileDetail.schema.ts new file mode 100644 index 000000000..8d7c528ce --- /dev/null +++ b/example/seed/data/ProfileDetail.schema.ts @@ -0,0 +1,23 @@ +import { t } from "@tailor-platform/sdk"; +import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/test"; +import { defineSchema } from "@toiroakr/lines-db"; +import { profileDetail } from "../../tailordb/profileReference"; + +const schemaType = t.object({ + ...profileDetail.pickFields(["id","createdAt"], { optional: true }), + ...profileDetail.omitFields(["id","createdAt"]), +}); + +const hook = createTailorDBHook(profileDetail); + +export const schema = defineSchema( + createStandardSchema(schemaType, hook), + { + foreignKeys: [ + {"column":"profileID","references":{"table":"NestedProfile","column":"id"}}, + ], + indexes: [ + {"name":"profiledetail_profileID_unique_idx","columns":["profileID"],"unique":true}, + ], + } +); diff --git a/example/seed/exec.mjs b/example/seed/exec.mjs index 178c429cb..8d6f448b2 100644 --- a/example/seed/exec.mjs +++ b/example/seed/exec.mjs @@ -87,6 +87,8 @@ const namespaceEntities = { "Customer", "Invoice", "NestedProfile", + "ProfileComment", + "ProfileDetail", "PurchaseOrder", "SalesOrder", "SalesOrderCreated", @@ -104,7 +106,9 @@ const namespaceDeps = { "tailordb": { "Customer": [], "Invoice": ["SalesOrder"], - "NestedProfile": [], + "NestedProfile": ["User"], + "ProfileComment": ["NestedProfile"], + "ProfileDetail": ["NestedProfile"], "PurchaseOrder": ["Supplier"], "SalesOrder": ["Customer", "User"], "SalesOrderCreated": [], diff --git a/example/seed/graphql/ProfileComment.graphql b/example/seed/graphql/ProfileComment.graphql new file mode 100644 index 000000000..c53348739 --- /dev/null +++ b/example/seed/graphql/ProfileComment.graphql @@ -0,0 +1,5 @@ +mutation CreateProfileComment($input: ProfileCommentCreateInput!) { + createProfileComment(input: $input) { + id + } +} diff --git a/example/seed/graphql/ProfileDetail.graphql b/example/seed/graphql/ProfileDetail.graphql new file mode 100644 index 000000000..53611ecc1 --- /dev/null +++ b/example/seed/graphql/ProfileDetail.graphql @@ -0,0 +1,5 @@ +mutation CreateProfileDetail($input: ProfileDetailCreateInput!) { + createProfileDetail(input: $input) { + id + } +} diff --git a/example/seed/mappings/ProfileComment.json b/example/seed/mappings/ProfileComment.json new file mode 100644 index 000000000..afd268487 --- /dev/null +++ b/example/seed/mappings/ProfileComment.json @@ -0,0 +1,8 @@ +{ + "dataFile": "data/ProfileComment.jsonl", + "dataFormat": "jsonl", + "graphqlFile": "graphql/ProfileComment.graphql", + "mapping": { + "input": "$" + } +} diff --git a/example/seed/mappings/ProfileDetail.json b/example/seed/mappings/ProfileDetail.json new file mode 100644 index 000000000..9a4b41dba --- /dev/null +++ b/example/seed/mappings/ProfileDetail.json @@ -0,0 +1,8 @@ +{ + "dataFile": "data/ProfileDetail.jsonl", + "dataFormat": "jsonl", + "graphqlFile": "graphql/ProfileDetail.graphql", + "mapping": { + "input": "$" + } +} diff --git a/example/tailordb/nested.ts b/example/tailordb/nested.ts index b3fa800ba..2f3e3aa6d 100644 --- a/example/tailordb/nested.ts +++ b/example/tailordb/nested.ts @@ -1,5 +1,6 @@ import { db } from "@tailor-platform/sdk"; import { defaultGqlPermission, defaultPermission } from "./permissions"; +import { user } from "./user"; export const nestedProfile = db .type("NestedProfile", "Nested Profile Type", { @@ -20,7 +21,12 @@ export const nestedProfile = db }) .description("Profile metadata"), archived: db.bool({ optional: true }).description("Archive status"), + ownerID: db.uuid({ optional: true }).relation({ + type: "n-1", + toward: { type: user, as: "owner" }, + }), ...db.fields.timestamps(), }) + .files({ avatar: "profile avatar image" }) .permission(defaultPermission) .gqlPermission(defaultGqlPermission); diff --git a/example/tailordb/profileReference.ts b/example/tailordb/profileReference.ts new file mode 100644 index 000000000..9cf58bd50 --- /dev/null +++ b/example/tailordb/profileReference.ts @@ -0,0 +1,36 @@ +import { db } from "@tailor-platform/sdk"; +import { nestedProfile } from "./nested"; +import { defaultPermission } from "./permissions"; + +// Test type for backward relation testing +// n-1 relation to NestedProfile (creates backward relation on NestedProfile) +export const profileComment = db + .type("ProfileComment", "Comment on a profile", { + content: db.string().description("Comment content"), + profileID: db + .uuid() + .relation({ + type: "n-1", + toward: { type: nestedProfile, as: "profile" }, + backward: "comments", + }) + .description("Referenced profile"), + ...db.fields.timestamps(), + }) + .permission(defaultPermission); + +// 1-1 relation to NestedProfile (creates backward relation on NestedProfile) +export const profileDetail = db + .type("ProfileDetail", "Additional detail for a profile", { + bio: db.string({ optional: true }).description("Extended biography"), + profileID: db + .uuid() + .relation({ + type: "1-1", + toward: { type: nestedProfile, as: "profile" }, + backward: "detail", + }) + .description("Referenced profile"), + ...db.fields.timestamps(), + }) + .permission(defaultPermission); diff --git a/example/tests/fixtures/expected/db.ts b/example/tests/fixtures/expected/db.ts index 123311590..e4b145d76 100644 --- a/example/tests/fixtures/expected/db.ts +++ b/example/tests/fixtures/expected/db.ts @@ -55,6 +55,23 @@ export interface Namespace { version: number; }; archived: boolean | null; + ownerID: string | null; + createdAt: Generated; + updatedAt: Timestamp | null; + } + + ProfileComment: { + id: Generated; + content: string; + profileID: string; + createdAt: Generated; + updatedAt: Timestamp | null; + } + + ProfileDetail: { + id: Generated; + bio: string | null; + profileID: string; createdAt: Generated; updatedAt: Timestamp | null; } diff --git a/packages/sdk/docs/services/resolver.md b/packages/sdk/docs/services/resolver.md index e472d7ef2..235ead959 100644 --- a/packages/sdk/docs/services/resolver.md +++ b/packages/sdk/docs/services/resolver.md @@ -120,6 +120,62 @@ createResolver({ }); ``` +## Custom Type Names + +### Using `typeName()` for nested objects + +When defining nested objects in input or output schemas, you can specify a custom GraphQL type name using the `typeName()` method. This is useful when you want to control the exact type name that appears in the GraphQL schema. + +```typescript +createResolver({ + name: "createProfile", + operation: "mutation", + input: { + profile: t + .object({ + name: t.string(), + email: t.string(), + }) + .typeName("ProfileInput"), // GraphQL type will be "ProfileInput" + }, + body: (context) => context.input.profile, + output: t + .object({ + name: t.string(), + email: t.string(), + }) + .typeName("ProfileOutput"), // GraphQL type will be "ProfileOutput" +}); +``` + +Without `typeName()`, the SDK generates type names automatically (e.g., `CreateProfileProfile` for input). + +### Using `toResolverOutput()` for TailorDB types + +`toResolverOutput()` makes the resolver's output type match the TailorDB auto-generated query's return type. For example, if you have a `User` type in TailorDB, `toResolverOutput(user)` produces the same GraphQL type as the auto-generated `user` and `users` queries. + +```typescript +import { createResolver, t, toResolverOutput } from "@tailor-platform/sdk"; +import { user } from "../tailordb/user"; + +export default createResolver({ + name: "getUser", + operation: "query", + input: { + id: t.uuid(), + }, + body: async (context) => { + const db = getDB("tailordb"); + return await db + .selectFrom("User") + .selectAll() + .where("id", "=", context.input.id) + .executeTakeFirstOrThrow(); + }, + output: toResolverOutput(user), // Same type as TailorDB's `user` query returns +}); +``` + ## Input Validation Add validation rules to input fields using the `validate` method: diff --git a/packages/sdk/src/cli/index.ts b/packages/sdk/src/cli/index.ts index 4dcc8c15f..a29269e91 100644 --- a/packages/sdk/src/cli/index.ts +++ b/packages/sdk/src/cli/index.ts @@ -2,7 +2,7 @@ import { register } from "node:module"; import { defineCommand, runMain } from "politty"; -import { withCompletionCommand } from "politty/completion"; +import { createCompletionCommand } from "politty/completion"; import { apiCommand } from "./api"; import { applyCommand } from "./apply"; import { executorCommand } from "./executor"; @@ -24,40 +24,40 @@ import { userCommand } from "./user"; import { readPackageJson } from "./utils/package-json"; import { workflowCommand } from "./workflow"; import { workspaceCommand } from "./workspace"; +import type { AnyCommand } from "politty"; register("tsx", import.meta.url, { data: {} }); const packageJson = await readPackageJson(); const cliName = Object.keys(packageJson.bin ?? {})[0] || "tailor-sdk"; -export const mainCommand = withCompletionCommand( - defineCommand({ - name: cliName, - description: - packageJson.description || "Tailor CLI for managing Tailor Platform SDK applications", - subCommands: { - api: apiCommand, - apply: applyCommand, - executor: executorCommand, - function: functionCommand, - generate: generateCommand, - init: initCommand, - login: loginCommand, - logout: logoutCommand, - machineuser: machineuserCommand, - oauth2client: oauth2clientCommand, - open: openCommand, - profile: profileCommand, - remove: removeCommand, - secret: secretCommand, - show: showCommand, - staticwebsite: staticwebsiteCommand, - tailordb: tailordbCommand, - user: userCommand, - workflow: workflowCommand, - workspace: workspaceCommand, - }, - }), -); +export const mainCommand: AnyCommand = defineCommand({ + name: cliName, + description: + packageJson.description || "Tailor CLI for managing Tailor Platform SDK applications", + subCommands: { + api: apiCommand, + apply: applyCommand, + executor: executorCommand, + function: functionCommand, + generate: generateCommand, + init: initCommand, + login: loginCommand, + logout: logoutCommand, + machineuser: machineuserCommand, + oauth2client: oauth2clientCommand, + open: openCommand, + profile: profileCommand, + remove: removeCommand, + secret: secretCommand, + show: showCommand, + staticwebsite: staticwebsiteCommand, + tailordb: tailordbCommand, + user: userCommand, + workflow: workflowCommand, + workspace: workspaceCommand, + completion: async () => createCompletionCommand(mainCommand, cliName), + }, +}); runMain(mainCommand, { version: packageJson.version }); diff --git a/packages/sdk/src/configure/services/resolver/__tests__/typename-pipeline.test.ts b/packages/sdk/src/configure/services/resolver/__tests__/typename-pipeline.test.ts new file mode 100644 index 000000000..2445f3613 --- /dev/null +++ b/packages/sdk/src/configure/services/resolver/__tests__/typename-pipeline.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { db } from "@/configure/services/tailordb"; +import { t } from "@/configure/types/type"; +import { ResolverSchema } from "@/parser/service/resolver/schema"; +import { createResolver, toResolverOutput } from "../resolver"; + +describe("typeName preservation through pipeline", () => { + it("toResolverOutput preserves typeName in metadata", () => { + const testType = db.type("TestProfile", "Test", { + name: db.string(), + age: db.int({ optional: true }), + }); + + const output = toResolverOutput(testType); + expect(output.type).toBe("nested"); + expect(output.metadata.typeName).toBe("TestProfile"); + expect(output._metadata.typeName).toBe("TestProfile"); + }); + + it("typeName survives Zod ResolverSchema parsing", () => { + const testType = db.type("TestProfile", "Test", { + name: db.string(), + age: db.int({ optional: true }), + }); + + const resolver = createResolver({ + name: "testResolver", + operation: "query", + input: { + name: t.string(), + data: t.object({ x: t.int() }).typeName("CustomInput"), + }, + body: () => ({ id: crypto.randomUUID(), name: "test", age: 1 }), + output: toResolverOutput(testType), + }); + + // Verify before parsing + expect(resolver.output.metadata.typeName).toBe("TestProfile"); + + // Parse through Zod (same as what the apply command does) + const parsed = ResolverSchema.safeParse(resolver); + expect(parsed.success).toBe(true); + + if (parsed.success) { + // Verify output typeName survives + expect(parsed.data.output.metadata.typeName).toBe("TestProfile"); + + // Verify input typeName survives + expect(parsed.data.input?.data?.metadata?.typeName).toBe("CustomInput"); + } + }); +}); diff --git a/packages/sdk/src/configure/services/resolver/index.ts b/packages/sdk/src/configure/services/resolver/index.ts index 8e01d1f04..e78427fbe 100644 --- a/packages/sdk/src/configure/services/resolver/index.ts +++ b/packages/sdk/src/configure/services/resolver/index.ts @@ -1,4 +1,4 @@ -export { createResolver } from "./resolver"; +export { createResolver, toResolverOutput } from "./resolver"; export type { QueryType, diff --git a/packages/sdk/src/configure/services/resolver/resolver.ts b/packages/sdk/src/configure/services/resolver/resolver.ts index 32d7a256a..dca4c1d0b 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.ts @@ -1,4 +1,5 @@ import { t } from "@/configure/types/type"; +import type { TailorDBType, TailorAnyDBField } from "@/configure/services/tailordb/schema"; import type { TailorAnyField, TailorUser } from "@/configure/types"; import type { TailorEnv } from "@/configure/types/env"; import type { InferFieldsOutput, output } from "@/configure/types/helpers"; @@ -78,3 +79,18 @@ export function createResolver< // A loose config alias for userland use-cases // oxlint-disable-next-line no-explicit-any export type ResolverConfig = ReturnType>; + +/** + * Convert a TailorDBType to a TailorField for use as resolver output. + * Equivalent to: t.object(type.fields).typeName(type.name) + * @param type - The TailorDBType to convert + * @returns A TailorField with the type's fields and name as typeName + */ +export function toResolverOutput>( + type: TailorDBType, +): TailorField<{ type: "nested"; array: false; typeName: true }, InferFieldsOutput> { + return t.object(type.fields).typeName(type.name) as TailorField< + { type: "nested"; array: false; typeName: true }, + InferFieldsOutput + >; +}