├── .prettierrc ├── .gitignore ├── tsconfig.build.json ├── knip.ts ├── tsconfig.build.esm.json ├── tsconfig.build.cjs.json ├── tsconfig.build.types.json ├── .github └── workflows │ ├── knip.yaml │ ├── jest.yaml │ └── lint.yaml ├── jest.config.ts ├── src ├── graphql │ ├── resolvers │ │ ├── types.ts │ │ ├── query-mutation-resolvers │ │ │ ├── query-type-by-id-resolver.ts │ │ │ ├── query-by-id-resolver.ts │ │ │ ├── query-every-resolver.ts │ │ │ ├── union-type-resolver.ts │ │ │ ├── mutation-delete-resolver.ts │ │ │ ├── mutation-create-resolver.ts │ │ │ └── mutation-update-resolver.ts │ │ ├── field-resolvers │ │ │ ├── types.ts │ │ │ ├── entry-reference-resolver.ts │ │ │ ├── union-value-resolver.ts │ │ │ └── field-default-value-resolver.ts │ │ └── object-type-field-default-value-resolver-generator.ts │ ├── apollo-config-factory.ts │ ├── schema-root-type-generator.ts │ ├── schema-utils │ │ ├── entry-type-util.ts │ │ ├── union-type-util.ts │ │ └── entry-reference-util.ts │ ├── schema-validator.ts │ ├── errors.ts │ ├── schema-analyzer.ts │ ├── input-type-generator.ts │ ├── queries-mutations-generator.ts │ └── schema-generator.ts ├── index.ts ├── persistence │ ├── persistence.ts │ └── cache.ts └── client.ts ├── eslint.config.mjs ├── tsconfig.json ├── LICENSE ├── package.json ├── tests ├── unit │ └── persistence │ │ ├── persistence.test.ts │ │ └── cache.test.ts └── integration │ ├── mutation │ ├── delete.test.ts │ ├── create.test.ts │ └── update.test.ts │ ├── schema │ └── schema-generator.test.ts │ └── query │ └── query.test.ts ├── CHANGELOG.md └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # JetBrains 2 | .idea/ 3 | 4 | # Visual Studio Code 5 | .vscode/ 6 | 7 | ########################## 8 | 9 | **/.env.yaml 10 | 11 | dist/ 12 | node_modules/ 13 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.json", 4 | "exclude": [ 5 | "node_modules", 6 | "dist", 7 | "tests" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /knip.ts: -------------------------------------------------------------------------------- 1 | import type { KnipConfig } from 'knip' 2 | 3 | const config: KnipConfig = { 4 | ignoreDependencies: [ 5 | 'ts-node', // required for Jest to read `jest.config.ts` 6 | ], 7 | } 8 | 9 | export default config 10 | -------------------------------------------------------------------------------- /tsconfig.build.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.build.json", 4 | "compilerOptions": { 5 | "outDir": "./dist/esm", 6 | "declarationDir": "./dist/types" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.build.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.build.json", 4 | "compilerOptions": { 5 | "module": "commonjs", 6 | "target": "es6", 7 | "outDir": "./dist/cjs", 8 | "declarationDir": "./dist/types", 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.build.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.build.json", 4 | "compilerOptions": { 5 | "declaration": true, 6 | "declarationMap": true, 7 | "emitDeclarationOnly": true, 8 | "outDir": "./dist/types" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/knip.yaml: -------------------------------------------------------------------------------- 1 | name: Lint dependencies with knip 2 | 3 | on: push 4 | 5 | jobs: 6 | knip: 7 | runs-on: ubuntu-22.04 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | - name: Install dependencies 12 | run: npm install --ignore-scripts 13 | - name: Run knip 14 | run: npm run knip 15 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | import type { Config } from 'jest' 6 | 7 | const jestConfig: Config = { 8 | moduleFileExtensions: ['js', 'ts'], 9 | transform: { 10 | '^.+\\.tsx?$': 'ts-jest', 11 | }, 12 | } 13 | 14 | export default jestConfig 15 | -------------------------------------------------------------------------------- /src/graphql/resolvers/types.ts: -------------------------------------------------------------------------------- 1 | import { ApolloContext } from '../../client' 2 | import { GraphQLNamedType } from 'graphql/type' 3 | import { GraphQLFieldResolver } from 'graphql' 4 | 5 | export interface QueryMutationResolverContext extends ApolloContext { 6 | type: GraphQLNamedType 7 | } 8 | 9 | export type QueryMutationResolver = GraphQLFieldResolver< 10 | any, 11 | ApolloContext, 12 | any, 13 | Promise 14 | > 15 | -------------------------------------------------------------------------------- /.github/workflows/jest.yaml: -------------------------------------------------------------------------------- 1 | name: Jest 2 | on: push 3 | jobs: 4 | test: 5 | runs-on: ubuntu-22.04 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: Setup Node.js 9 | uses: actions/setup-node@v1 10 | with: 11 | node-version: "18" 12 | 13 | - name: Install dependencies 14 | run: npm install 15 | 16 | - name: Run tests 17 | run: npm test 18 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: push 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-22.04 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: Setup Node.js 9 | uses: actions/setup-node@v1 10 | with: 11 | node-version: "18" 12 | 13 | - name: Install dependencies 14 | run: npm install 15 | 16 | - name: Run linter 17 | run: npm run lint 18 | -------------------------------------------------------------------------------- /src/graphql/resolvers/query-mutation-resolvers/query-type-by-id-resolver.ts: -------------------------------------------------------------------------------- 1 | import { getTypeById } from '../../../persistence/persistence' 2 | import { GraphQLFieldResolver } from 'graphql' 3 | import { ApolloContext } from '../../../client' 4 | 5 | export const queryTypeByIdResolver: GraphQLFieldResolver< 6 | any, 7 | ApolloContext, 8 | any, 9 | Promise 10 | > = async (_source, args, context, info) => { 11 | void info 12 | return getTypeById(context, args.id) 13 | } 14 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'eslint/config' 2 | import globals from 'globals' 3 | import tseslint from 'typescript-eslint' 4 | 5 | export default defineConfig([ 6 | { files: ['src/**/*.{ts}', 'tests/**/*.{ts}'] }, 7 | { 8 | files: ['src/**/*.{ts}'], 9 | languageOptions: { globals: { ...globals.browser, ...globals.node } }, 10 | }, 11 | tseslint.configs.recommended, 12 | { 13 | rules: { 14 | '@typescript-eslint/no-explicit-any': 'warn', 15 | }, 16 | }, 17 | ]) 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "module": "ES2015", 5 | "target": "ES2022", 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "resolveJsonModule": true, 9 | 10 | "composite": true, 11 | "strict": true, 12 | "incremental": true, 13 | "declaration": true, 14 | "sourceMap": true, 15 | 16 | "rootDir": "./src", 17 | "outDir": "./dist" 18 | }, 19 | "include": ["./src/**/*.ts", "./tests/**/*.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /src/graphql/resolvers/query-mutation-resolvers/query-by-id-resolver.ts: -------------------------------------------------------------------------------- 1 | import { EntryData } from '@commitspark/git-adapter' 2 | import { findByTypeId } from '../../../persistence/persistence' 3 | import { GraphQLFieldResolver } from 'graphql' 4 | import { QueryMutationResolverContext } from '../types' 5 | 6 | export const queryByIdResolver: GraphQLFieldResolver< 7 | any, 8 | QueryMutationResolverContext, 9 | any, 10 | Promise 11 | > = async (_obj, args, context, info) => { 12 | void info 13 | const entry = await findByTypeId(context, context.type.name, args.id) 14 | 15 | return { ...entry.data, id: entry.id } 16 | } 17 | -------------------------------------------------------------------------------- /src/graphql/resolvers/query-mutation-resolvers/query-every-resolver.ts: -------------------------------------------------------------------------------- 1 | import { EntryData } from '@commitspark/git-adapter' 2 | import { findByType } from '../../../persistence/persistence' 3 | import { GraphQLFieldResolver } from 'graphql' 4 | import { QueryMutationResolverContext } from '../types' 5 | 6 | export const queryEveryResolver: GraphQLFieldResolver< 7 | any, 8 | QueryMutationResolverContext, 9 | any, 10 | Promise 11 | > = async (_obj, _args, context, info) => { 12 | void info 13 | const entries = await findByType(context, context.type.name) 14 | 15 | return entries.map((entry) => { 16 | return { ...entry.data, id: entry.id } 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /src/graphql/resolvers/field-resolvers/types.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldResolver } from 'graphql/type/definition' 2 | import { EntryData } from '@commitspark/git-adapter' 3 | import { GraphQLOutputType } from 'graphql' 4 | import { ApolloContext } from '../../../client' 5 | 6 | export interface FieldResolverContext extends ApolloContext { 7 | currentType: GraphQLOutputType 8 | hasNonNullParent?: boolean 9 | } 10 | 11 | export type FieldResolver< 12 | TSource, 13 | TContext = FieldResolverContext, 14 | TArgs = any, 15 | TResult = Promise>, 16 | > = GraphQLFieldResolver 17 | 18 | export type ResolvedEntryData = T | Array> 19 | -------------------------------------------------------------------------------- /src/graphql/apollo-config-factory.ts: -------------------------------------------------------------------------------- 1 | import { makeExecutableSchema } from '@graphql-tools/schema' 2 | import { generateSchema } from './schema-generator' 3 | import { ApolloContext } from '../client' 4 | import { ApolloServerOptions } from '@apollo/server' 5 | import { ApolloServerPluginUsageReportingDisabled } from '@apollo/server/plugin/disabled' 6 | 7 | export async function createApolloConfig( 8 | context: ApolloContext, 9 | ): Promise> { 10 | const schemaDefinition = await generateSchema(context) 11 | const schema = makeExecutableSchema(schemaDefinition) 12 | 13 | return { 14 | schema: schema, 15 | plugins: [ApolloServerPluginUsageReportingDisabled()], 16 | } as ApolloServerOptions 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2023 Markus Weiland 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /src/graphql/resolvers/field-resolvers/entry-reference-resolver.ts: -------------------------------------------------------------------------------- 1 | import { FieldResolver, FieldResolverContext } from './types' 2 | import { GraphQLResolveInfo, isNamedType } from 'graphql' 3 | import { findById } from '../../../persistence/persistence' 4 | import { createError, ErrorCode } from '../../errors' 5 | 6 | export const resolveEntryReference: FieldResolver = async ( 7 | fieldValue: any, 8 | args: any, 9 | context: FieldResolverContext, 10 | info: GraphQLResolveInfo, 11 | ) => { 12 | void info 13 | if (!isNamedType(context.currentType)) { 14 | throw createError( 15 | `Expected context.currentType type to be a named type.`, 16 | ErrorCode.INTERNAL_ERROR, 17 | { 18 | fieldValue: fieldValue, 19 | }, 20 | ) 21 | } 22 | 23 | const entry = await findById(context, fieldValue.id) 24 | 25 | return { ...entry.data, id: entry.id } 26 | } 27 | -------------------------------------------------------------------------------- /src/graphql/resolvers/query-mutation-resolvers/union-type-resolver.ts: -------------------------------------------------------------------------------- 1 | import { ApolloContext } from '../../../client' 2 | import { getTypeById } from '../../../persistence/persistence' 3 | import { GraphQLTypeResolver } from 'graphql' 4 | import { buildsOnTypeWithEntryDirective } from '../../schema-utils/entry-type-util' 5 | 6 | export const unionTypeResolver: GraphQLTypeResolver< 7 | any, 8 | ApolloContext 9 | > = async (obj, context, info, abstractType) => { 10 | if (buildsOnTypeWithEntryDirective(abstractType)) { 11 | return getTypeById(context, obj.id) 12 | } else { 13 | // We have injected an internal `__typename` field into the data of fields pointing to a non-entry union 14 | // in UnionValueResolver. This artificial field holds the type information we need here. 15 | return obj.__typename 16 | } 17 | 18 | // TODO same for interface type: https://www.apollographql.com/docs/apollo-server/data/resolvers/#resolving-unions-and-interfaces 19 | } 20 | -------------------------------------------------------------------------------- /src/graphql/resolvers/field-resolvers/union-value-resolver.ts: -------------------------------------------------------------------------------- 1 | import { EntryData } from '@commitspark/git-adapter' 2 | import { FieldResolver, ResolvedEntryData } from './types' 3 | import { 4 | getUnionTypeNameFromFieldValue, 5 | getUnionValue, 6 | } from '../../schema-utils/union-type-util' 7 | 8 | export const resolveUnionValue: FieldResolver = async ( 9 | fieldValue: any, 10 | ): Promise> => { 11 | const typeName = getUnionTypeNameFromFieldValue(fieldValue) 12 | const unionValue = getUnionValue(fieldValue) 13 | 14 | // We replace the helper type name field that holds our field's actual data 15 | // with this actual data and add a `__typename` field, so that our output data 16 | // corresponds to the output schema provided by the user (i.e. there is 17 | // no additional nesting level there). 18 | const res: EntryData = { 19 | ...unionValue, 20 | __typename: typeName, 21 | } 22 | 23 | return new Promise((resolve) => resolve(res)) 24 | } 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloExecuteOperationRequest, 3 | getSchema, 4 | GraphQLResponse, 5 | postGraphQL, 6 | SchemaResponse, 7 | VariableValues, 8 | } from './client' 9 | import { GitAdapter } from '@commitspark/git-adapter' 10 | import { ErrorCode, ErrorMetadata } from './graphql/errors' 11 | import { createCacheHandler } from './persistence/cache' 12 | 13 | interface Client { 14 | postGraphQL< 15 | TData = Record, 16 | TVariables extends VariableValues = VariableValues, 17 | >( 18 | ref: string, 19 | request: ApolloExecuteOperationRequest, 20 | ): Promise> 21 | getSchema(ref: string): Promise 22 | } 23 | 24 | export { Client, GraphQLResponse, SchemaResponse, ErrorCode, ErrorMetadata } 25 | 26 | export async function createClient(gitAdapter: GitAdapter): Promise { 27 | const repositoryCache = createCacheHandler() 28 | return { 29 | postGraphQL: (...args) => postGraphQL(gitAdapter, repositoryCache, ...args), 30 | getSchema: (...args) => getSchema(gitAdapter, repositoryCache, ...args), 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/graphql/schema-root-type-generator.ts: -------------------------------------------------------------------------------- 1 | import { GeneratedQuery, GeneratedSchema } from './queries-mutations-generator' 2 | 3 | export function generateSchemaRootTypeStrings( 4 | generatedSchemas: GeneratedSchema[], 5 | typeQuery: GeneratedQuery>, 6 | ): string { 7 | return ( 8 | `type Query {\n` + 9 | generatedSchemas 10 | .map((generated) => ' ' + generated.queryEvery.schemaString) 11 | .join('\n') + 12 | '\n' + 13 | generatedSchemas 14 | .map((generated) => ' ' + generated.queryById.schemaString) 15 | .join('\n') + 16 | '\n' + 17 | ` ${typeQuery.schemaString}` + 18 | '\n' + 19 | '}\n\n' + 20 | 'type Mutation {\n' + 21 | generatedSchemas 22 | .map((generated) => ' ' + generated.createMutation.schemaString) 23 | .join('\n') + 24 | '\n' + 25 | generatedSchemas 26 | .map((generated) => ' ' + generated.updateMutation.schemaString) 27 | .join('\n') + 28 | '\n' + 29 | generatedSchemas 30 | .map((generated) => ' ' + generated.deleteMutation.schemaString) 31 | .join('\n') + 32 | '\n' + 33 | '}\n' 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/graphql/schema-utils/entry-type-util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLNullableType, 3 | GraphQLObjectType, 4 | GraphQLUnionType, 5 | isListType, 6 | isNonNullType, 7 | isObjectType, 8 | isUnionType, 9 | } from 'graphql' 10 | 11 | export function hasEntryDirective(type: GraphQLObjectType): boolean { 12 | return ( 13 | !!type.astNode && 14 | type.astNode.directives?.find( 15 | (directive) => directive.name.value === 'Entry', 16 | ) !== undefined 17 | ) 18 | } 19 | 20 | export function isUnionOfEntryTypes(type: GraphQLUnionType): boolean { 21 | return type 22 | .getTypes() 23 | .every((unionType) => buildsOnTypeWithEntryDirective(unionType)) 24 | } 25 | 26 | export function buildsOnTypeWithEntryDirective( 27 | type: GraphQLNullableType, 28 | ): boolean { 29 | if (isNonNullType(type)) { 30 | return buildsOnTypeWithEntryDirective(type.ofType) 31 | } else if (isListType(type)) { 32 | return buildsOnTypeWithEntryDirective(type.ofType) 33 | } else if (isUnionType(type)) { 34 | return isUnionOfEntryTypes(type) 35 | } else if (isObjectType(type)) { 36 | return hasEntryDirective(type) 37 | } 38 | return false 39 | } 40 | -------------------------------------------------------------------------------- /src/graphql/schema-utils/union-type-util.ts: -------------------------------------------------------------------------------- 1 | import { EntryData } from '@commitspark/git-adapter' 2 | import { createError, ErrorCode } from '../errors' 3 | 4 | export function getUnionTypeNameFromFieldValue(fieldValue: unknown): string { 5 | if (typeof fieldValue !== 'object' || fieldValue === null) { 6 | throw createError( 7 | `Expected object value in order to determine union type name.`, 8 | ErrorCode.BAD_REPOSITORY_DATA, 9 | { 10 | fieldValue: fieldValue, 11 | }, 12 | ) 13 | } 14 | 15 | // Based on our @oneOf directive, we expect only one field whose name 16 | // corresponds to the concrete type's name. 17 | return Object.keys(fieldValue)[0] 18 | } 19 | 20 | export function getUnionValue(fieldValue: unknown): EntryData { 21 | if (typeof fieldValue !== 'object' || fieldValue === null) { 22 | throw createError( 23 | `Expected object value in order to determine union value.`, 24 | ErrorCode.BAD_REPOSITORY_DATA, 25 | { 26 | fieldValue: fieldValue, 27 | }, 28 | ) 29 | } 30 | 31 | const firstKey = Object.keys(fieldValue)[0] 32 | return (fieldValue as Record)[firstKey] 33 | } 34 | -------------------------------------------------------------------------------- /src/graphql/schema-validator.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLSchema, GraphQLUnionType, Kind } from 'graphql' 2 | 3 | function checkUnionMembersConsistentUseOfEntryDirective( 4 | schema: GraphQLSchema, 5 | ): string { 6 | const typeMap = schema.getTypeMap() 7 | 8 | for (const type of Object.values(typeMap)) { 9 | if (type.astNode?.kind !== Kind.UNION_TYPE_DEFINITION) { 10 | continue 11 | } 12 | const innerTypes = (type as GraphQLUnionType).getTypes() 13 | 14 | const numberUnionMembersWithEntryDirective = innerTypes.filter( 15 | (innerType) => 16 | !!innerType.astNode && 17 | innerType.astNode.directives?.find( 18 | (directive) => directive.name.value === 'Entry', 19 | ) !== undefined, 20 | ).length 21 | 22 | if ( 23 | numberUnionMembersWithEntryDirective !== 0 && 24 | numberUnionMembersWithEntryDirective !== innerTypes.length 25 | ) { 26 | return `Either all union members of "${type.name}" must have "@Entry" directive or none.` 27 | } 28 | } 29 | 30 | return '' 31 | } 32 | 33 | export function getValidationResult(schema: GraphQLSchema): string[] { 34 | const results = [] 35 | results.push(checkUnionMembersConsistentUseOfEntryDirective(schema)) 36 | return results.filter((result) => result !== '') 37 | } 38 | -------------------------------------------------------------------------------- /src/graphql/errors.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql' 2 | import { ErrorCode as AdapterErrorCode } from '@commitspark/git-adapter' 3 | 4 | export const enum ErrorCode { 5 | BAD_USER_INPUT = 'BAD_USER_INPUT', 6 | NOT_FOUND = 'NOT_FOUND', 7 | BAD_REPOSITORY_DATA = 'BAD_REPOSITORY_DATA', 8 | SCHEMA_DATA_MISMATCH = 'SCHEMA_DATA_MISMATCH', 9 | BAD_SCHEMA = 'BAD_SCHEMA', 10 | INTERNAL_ERROR = 'INTERNAL_ERROR', 11 | IN_USE = 'IN_USE', 12 | } 13 | 14 | export interface ErrorMetadata { 15 | typeName?: string 16 | fieldName?: string 17 | fieldValue?: unknown 18 | argumentName?: string 19 | argumentValue?: unknown 20 | schema?: string 21 | } 22 | 23 | export const createError = ( 24 | message: string, 25 | code: ErrorCode | AdapterErrorCode, 26 | metaData: ErrorMetadata, 27 | ): GraphQLError => { 28 | const serializedFieldValue = 29 | typeof metaData.fieldValue === 'object' || 30 | Array.isArray(metaData.fieldValue) 31 | ? JSON.stringify(metaData.fieldValue) 32 | : metaData.fieldValue 33 | const updatedMetaData = { ...metaData } 34 | if (serializedFieldValue !== undefined) { 35 | updatedMetaData.fieldValue = serializedFieldValue 36 | } 37 | 38 | return new GraphQLError(message, { 39 | extensions: { 40 | code: code, 41 | commitspark: updatedMetaData, 42 | }, 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /src/graphql/schema-analyzer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLInterfaceType, 3 | GraphQLObjectType, 4 | GraphQLSchema, 5 | GraphQLUnionType, 6 | isInterfaceType, 7 | isObjectType, 8 | isUnionType, 9 | } from 'graphql' 10 | import { getDirective } from '@graphql-tools/utils' 11 | 12 | export function analyzeSchema(schema: GraphQLSchema): SchemaAnalyzerResult { 13 | const result: SchemaAnalyzerResult = { 14 | entryDirectiveTypes: [], 15 | objectTypes: [], 16 | interfaceTypes: [], 17 | unionTypes: [], 18 | } 19 | 20 | const typeMap = schema.getTypeMap() 21 | 22 | for (const [key, type] of Object.entries(typeMap)) { 23 | if (key.startsWith('__')) { 24 | continue 25 | } 26 | 27 | if (isObjectType(type)) { 28 | const objectType = type as GraphQLObjectType 29 | result.objectTypes.push(objectType) 30 | const entityDirective = getDirective(schema, objectType, 'Entry')?.[0] 31 | if (entityDirective) { 32 | result.entryDirectiveTypes.push(objectType) 33 | } 34 | } 35 | 36 | if (isInterfaceType(type)) { 37 | const interfaceType = type as GraphQLInterfaceType 38 | result.interfaceTypes.push(interfaceType) 39 | } 40 | 41 | if (isUnionType(type)) { 42 | const unionType = type as GraphQLUnionType 43 | result.unionTypes.push(unionType) 44 | } 45 | } 46 | 47 | return result 48 | } 49 | 50 | export interface SchemaAnalyzerResult { 51 | entryDirectiveTypes: GraphQLObjectType[] 52 | objectTypes: GraphQLObjectType[] 53 | interfaceTypes: GraphQLInterfaceType[] 54 | unionTypes: GraphQLUnionType[] 55 | } 56 | -------------------------------------------------------------------------------- /src/persistence/persistence.ts: -------------------------------------------------------------------------------- 1 | import { Entry } from '@commitspark/git-adapter' 2 | import { createError, ErrorCode } from '../graphql/errors' 3 | import { ApolloContext } from '../client' 4 | 5 | export async function getTypeById( 6 | context: ApolloContext, 7 | id: string, 8 | ): Promise { 9 | const requestedEntry = await findById(context, id) 10 | return requestedEntry.metadata.type 11 | } 12 | 13 | export async function findById( 14 | context: ApolloContext, 15 | id: string, 16 | ): Promise { 17 | const entriesRecord = await context.repositoryCache.getEntriesRecord( 18 | context, 19 | context.getCurrentHash(), 20 | ) 21 | const requestedEntry = entriesRecord.byId.get(id) 22 | if (requestedEntry === undefined) { 23 | throw createError(`No entry with ID "${id}" exists.`, ErrorCode.NOT_FOUND, { 24 | argumentName: 'id', 25 | argumentValue: id, 26 | }) 27 | } 28 | 29 | return requestedEntry 30 | } 31 | 32 | export async function findByType( 33 | context: ApolloContext, 34 | type: string, 35 | ): Promise { 36 | const entriesRecord = await context.repositoryCache.getEntriesRecord( 37 | context, 38 | context.getCurrentHash(), 39 | ) 40 | return entriesRecord.byType.get(type) ?? [] 41 | } 42 | 43 | export async function findByTypeId( 44 | context: ApolloContext, 45 | type: string, 46 | id: string, 47 | ): Promise { 48 | const entryById = await findById(context, id) 49 | if (entryById === undefined || entryById.metadata.type !== type) { 50 | throw createError( 51 | `No entry of type "${type}" with ID "${id}" exists.`, 52 | ErrorCode.NOT_FOUND, 53 | { 54 | typeName: type, 55 | argumentName: 'id', 56 | argumentValue: id, 57 | }, 58 | ) 59 | } 60 | 61 | return entryById 62 | } 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@commitspark/graphql-api", 3 | "description": "GraphQL API to store and manage structured data with Git", 4 | "keywords": [ 5 | "graphql", 6 | "graphql-api", 7 | "graphql-schema", 8 | "git", 9 | "gitops", 10 | "api" 11 | ], 12 | "version": "1.0.0-beta.6", 13 | "license": "ISC", 14 | "private": false, 15 | "files": [ 16 | "dist/**", 17 | "src/**", 18 | "README.md", 19 | "LICENSE", 20 | "package.json", 21 | "CHANGELOG.md" 22 | ], 23 | "main": "./dist/cjs/index.js", 24 | "module": "./dist/esm/index.js", 25 | "types": "./dist/types/index.d.ts", 26 | "exports": { 27 | ".": { 28 | "types": "./dist/types/index.d.ts", 29 | "require": "./dist/cjs/index.js", 30 | "import": "./dist/esm/index.js" 31 | } 32 | }, 33 | "scripts": { 34 | "build": "npm run build:cjs && npm run build:esm && npm run build:types", 35 | "build:cjs": "tsc --project tsconfig.build.cjs.json", 36 | "build:esm": "tsc --project tsconfig.build.esm.json", 37 | "build:types": "tsc --project tsconfig.build.types.json", 38 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 39 | "lint": "eslint \"{src,tests}/**/*.ts\"", 40 | "test": "jest", 41 | "knip": "knip", 42 | "prepublishOnly": "npm run build" 43 | }, 44 | "dependencies": { 45 | "@apollo/server": "~5.0.0", 46 | "@commitspark/git-adapter": "^0.20.0", 47 | "@graphql-tools/schema": "^9.0.0", 48 | "@graphql-tools/utils": "^9.0.0" 49 | }, 50 | "peerDependencies": { 51 | "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" 52 | }, 53 | "devDependencies": { 54 | "@types/jest": "^29.5.2", 55 | "@types/node": "^22.14.0", 56 | "eslint": "^9.24.0", 57 | "globals": "^16.0.0", 58 | "jest": "^29.5.0", 59 | "jest-mock-extended": "^3.0.4", 60 | "knip": "^5.46.5", 61 | "prettier": "^2.4.1", 62 | "ts-jest": "^29.1.0", 63 | "ts-node": "^10.9.2", 64 | "typescript": "^5.0.0", 65 | "typescript-eslint": "^8.29.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/graphql/resolvers/query-mutation-resolvers/mutation-delete-resolver.ts: -------------------------------------------------------------------------------- 1 | import { findById, findByTypeId } from '../../../persistence/persistence' 2 | import { Entry, EntryData, EntryDraft } from '@commitspark/git-adapter' 3 | import { getReferencedEntryIds } from '../../schema-utils/entry-reference-util' 4 | import { GraphQLFieldResolver, isObjectType } from 'graphql' 5 | import { QueryMutationResolverContext } from '../types' 6 | import { createError, ErrorCode } from '../../errors' 7 | 8 | export const mutationDeleteResolver: GraphQLFieldResolver< 9 | any, 10 | QueryMutationResolverContext, 11 | any, 12 | Promise 13 | > = async (source, args, context, info) => { 14 | const entry: Entry = await findByTypeId(context, context.type.name, args.id) 15 | 16 | if (entry.metadata.referencedBy && entry.metadata.referencedBy.length > 0) { 17 | const otherIds = entry.metadata.referencedBy 18 | .map((referenceId) => `"${referenceId}"`) 19 | .join(', ') 20 | throw createError( 21 | `Entry with ID "${args.id}" is still referenced by entries [${otherIds}].`, 22 | ErrorCode.IN_USE, 23 | { 24 | argumentName: 'id', 25 | argumentValue: args.id, 26 | }, 27 | ) 28 | } 29 | 30 | const entryType = info.schema.getType(context.type.name) 31 | if (!isObjectType(entryType)) { 32 | throw createError( 33 | `Type "${context.type.name}" is not an ObjectType.`, 34 | ErrorCode.INTERNAL_ERROR, 35 | {}, 36 | ) 37 | } 38 | 39 | const referencedEntryIds = await getReferencedEntryIds( 40 | entryType, 41 | context, 42 | null, 43 | entryType, 44 | entry.data, 45 | ) 46 | const referencedEntryUpdates: EntryDraft[] = [] 47 | for (const referencedEntryId of referencedEntryIds) { 48 | const noLongerReferencedEntry = await findById(context, referencedEntryId) 49 | referencedEntryUpdates.push({ 50 | ...noLongerReferencedEntry, 51 | metadata: { 52 | ...noLongerReferencedEntry.metadata, 53 | referencedBy: noLongerReferencedEntry.metadata.referencedBy?.filter( 54 | (entryId) => entryId !== args.id, 55 | ), 56 | }, 57 | deletion: false, 58 | }) 59 | } 60 | 61 | const commit = await context.gitAdapter.createCommit({ 62 | ref: context.branch, 63 | parentSha: context.getCurrentHash(), 64 | entries: [ 65 | { 66 | ...entry, 67 | deletion: true, 68 | }, 69 | ...referencedEntryUpdates, 70 | ], 71 | message: args.commitMessage, 72 | }) 73 | context.setCurrentHash(commit.ref) 74 | 75 | return args.id 76 | } 77 | -------------------------------------------------------------------------------- /src/graphql/resolvers/object-type-field-default-value-resolver-generator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLField, 3 | GraphQLFieldResolver, 4 | GraphQLObjectType, 5 | GraphQLOutputType, 6 | GraphQLSchema, 7 | isListType, 8 | isNonNullType, 9 | isObjectType, 10 | isUnionType, 11 | } from 'graphql' 12 | import { EntryData } from '@commitspark/git-adapter' 13 | import { ResolvedEntryData } from './field-resolvers/types' 14 | import { resolveFieldDefaultValue } from './field-resolvers/field-default-value-resolver' 15 | import { ApolloContext } from '../../client' 16 | 17 | function requiresCustomDefaultValueResolver(type: GraphQLOutputType): boolean { 18 | if (isNonNullType(type)) { 19 | return requiresCustomDefaultValueResolver(type.ofType) 20 | } else if (isListType(type)) { 21 | return true 22 | } else if (isUnionType(type)) { 23 | return true 24 | } else if (isObjectType(type)) { 25 | return true 26 | } 27 | return false 28 | } 29 | 30 | function getFieldsForCustomDefaultValueResolver( 31 | objectType: GraphQLObjectType, 32 | ): GraphQLField[] { 33 | const fields = [] 34 | for (const fieldsKey in objectType.getFields()) { 35 | const field: GraphQLField = objectType.getFields()[fieldsKey] 36 | if (requiresCustomDefaultValueResolver(field.type)) { 37 | fields.push(field) 38 | } 39 | } 40 | return fields 41 | } 42 | 43 | type FieldResolversRecord = Record< 44 | string, 45 | GraphQLFieldResolver< 46 | any, 47 | ApolloContext, 48 | any, 49 | Promise> 50 | > 51 | > 52 | 53 | export function createObjectTypeFieldResolvers( 54 | schema: GraphQLSchema, 55 | ): Record { 56 | const fieldResolversByType: Record = {} 57 | for (const typeName of Object.keys(schema.getTypeMap())) { 58 | const type = schema.getType(typeName) 59 | if (!isObjectType(type) || type.name.startsWith('__')) { 60 | continue 61 | } 62 | const fieldsForCustomDefaultValueResolver = 63 | getFieldsForCustomDefaultValueResolver(type) 64 | 65 | const fieldResolversByFieldName: FieldResolversRecord = {} 66 | for (const field of fieldsForCustomDefaultValueResolver) { 67 | fieldResolversByFieldName[field.name] = ( 68 | obj, 69 | args, 70 | context, 71 | info, 72 | ): Promise> => 73 | resolveFieldDefaultValue( 74 | field.name in obj ? obj[field.name] : undefined, 75 | args, 76 | { 77 | ...context, 78 | currentType: field.type, 79 | }, 80 | info, 81 | ) 82 | } 83 | 84 | if (Object.keys(fieldResolversByFieldName).length > 0) { 85 | fieldResolversByType[typeName] = fieldResolversByFieldName 86 | } 87 | } 88 | 89 | return fieldResolversByType 90 | } 91 | -------------------------------------------------------------------------------- /src/graphql/resolvers/query-mutation-resolvers/mutation-create-resolver.ts: -------------------------------------------------------------------------------- 1 | import { findById, findByTypeId } from '../../../persistence/persistence' 2 | import { GraphQLFieldResolver, isObjectType } from 'graphql' 3 | import { getReferencedEntryIds } from '../../schema-utils/entry-reference-util' 4 | import { 5 | ENTRY_ID_INVALID_CHARACTERS, 6 | EntryData, 7 | EntryDraft, 8 | } from '@commitspark/git-adapter' 9 | import { QueryMutationResolverContext } from '../types' 10 | import { createError, ErrorCode } from '../../errors' 11 | 12 | export const mutationCreateResolver: GraphQLFieldResolver< 13 | any, 14 | QueryMutationResolverContext, 15 | any, 16 | Promise 17 | > = async (source, args, context, info) => { 18 | if (!isObjectType(context.type)) { 19 | throw createError( 20 | `Entry of type "${context.type.name}" cannot be created as is not an ObjectType.`, 21 | ErrorCode.INTERNAL_ERROR, 22 | {}, 23 | ) 24 | } 25 | 26 | const idValidationResult = args.id.match(ENTRY_ID_INVALID_CHARACTERS) 27 | if (idValidationResult) { 28 | throw createError( 29 | `Field "id" contains invalid characters "${idValidationResult.join( 30 | ', ', 31 | )}".`, 32 | ErrorCode.BAD_USER_INPUT, 33 | { 34 | argumentName: 'id', 35 | argumentValue: args.id, 36 | }, 37 | ) 38 | } 39 | 40 | let existingEntry 41 | try { 42 | existingEntry = await findById(context, args.id) 43 | } catch {} 44 | if (existingEntry) { 45 | throw createError( 46 | `An entry with id "${args.id}" already exists.`, 47 | ErrorCode.BAD_USER_INPUT, 48 | { 49 | argumentName: 'id', 50 | argumentValue: args.id, 51 | }, 52 | ) 53 | } 54 | 55 | const referencedEntryIds = await getReferencedEntryIds( 56 | context.type, 57 | context, 58 | null, 59 | info.returnType, 60 | args.data, 61 | ) 62 | 63 | const referencedEntryUpdates: EntryDraft[] = [] 64 | for (const referencedEntryId of referencedEntryIds) { 65 | const referencedEntry = await findById(context, referencedEntryId) 66 | const newReferencedEntryIds: string[] = [ 67 | ...(referencedEntry.metadata.referencedBy ?? []), 68 | args.id, 69 | ].sort() 70 | const newReferencedEntryDraft: EntryDraft = { 71 | ...referencedEntry, 72 | metadata: { 73 | ...referencedEntry.metadata, 74 | referencedBy: newReferencedEntryIds, 75 | }, 76 | deletion: false, 77 | } 78 | referencedEntryUpdates.push(newReferencedEntryDraft) 79 | } 80 | 81 | const newEntryDraft: EntryDraft = { 82 | id: args.id, 83 | metadata: { 84 | type: context.type.name, 85 | referencedBy: [], 86 | }, 87 | data: args.data, 88 | deletion: false, 89 | } 90 | 91 | const commit = await context.gitAdapter.createCommit({ 92 | ref: context.branch, 93 | parentSha: context.getCurrentHash(), 94 | entries: [newEntryDraft, ...referencedEntryUpdates], 95 | message: args.commitMessage, 96 | }) 97 | context.setCurrentHash(commit.ref) 98 | 99 | const newEntry = await findByTypeId(context, context.type.name, args.id) 100 | return { ...newEntry.data, id: newEntry.id } 101 | } 102 | -------------------------------------------------------------------------------- /tests/unit/persistence/persistence.test.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql' 2 | import { ErrorCode } from '../../../src' 3 | import { findById, findByTypeId } from '../../../src/persistence/persistence' 4 | 5 | // Helper to create a minimal ApolloContext-like object the functions expect 6 | function makeContext(entries: Array<{ id: string; type: string }>) { 7 | const byId = new Map() 8 | const byType = new Map() 9 | 10 | for (const e of entries) { 11 | const entry = { 12 | metadata: { id: e.id, type: e.type }, 13 | } 14 | byId.set(e.id, entry) 15 | const list = byType.get(e.type) ?? [] 16 | list.push(entry) 17 | byType.set(e.type, list) 18 | } 19 | 20 | return { 21 | getCurrentHash: jest.fn().mockReturnValue('dummy-hash'), 22 | repositoryCache: { 23 | getEntriesRecord: jest.fn().mockResolvedValue({ byId, byType }), 24 | }, 25 | } 26 | } 27 | 28 | describe('Persistence', () => { 29 | it('findById throws GraphQLError when entry does not exist', async () => { 30 | const context: any = makeContext([]) 31 | const missingId = 'missing-id' 32 | 33 | try { 34 | await findById(context, missingId) 35 | fail('Expected findById to throw') 36 | } catch (error) { 37 | expect(error).toBeInstanceOf(GraphQLError) 38 | const gqlError = error as GraphQLError 39 | expect(gqlError.message).toBe(`No entry with ID "${missingId}" exists.`) 40 | expect(gqlError.extensions?.code).toBe(ErrorCode.NOT_FOUND) 41 | expect(gqlError.extensions?.commitspark).toEqual({ 42 | argumentName: 'id', 43 | argumentValue: missingId, 44 | }) 45 | } 46 | }) 47 | 48 | it('findByTypeId propagates GraphQLError when id does not exist', async () => { 49 | const context: any = makeContext([]) 50 | const missingId = '42' 51 | 52 | await expect( 53 | findByTypeId(context, 'AnyType', missingId), 54 | ).rejects.toBeInstanceOf(GraphQLError) 55 | 56 | // Inspect full error details 57 | try { 58 | await findByTypeId(context, 'AnyType', missingId) 59 | fail('Expected findByTypeId to throw') 60 | } catch (error) { 61 | const gqlError = error as GraphQLError 62 | expect(gqlError.message).toBe(`No entry with ID "${missingId}" exists.`) 63 | expect(gqlError.extensions?.code).toBe(ErrorCode.NOT_FOUND) 64 | expect(gqlError.extensions?.commitspark).toEqual({ 65 | argumentName: 'id', 66 | argumentValue: missingId, 67 | }) 68 | } 69 | }) 70 | 71 | it('findByTypeId throws GraphQLError when type does not match', async () => { 72 | const context: any = makeContext([{ id: '1', type: 'Post' }]) 73 | 74 | const requestedType = 'User' 75 | const id = '1' 76 | 77 | try { 78 | await findByTypeId(context, requestedType, id) 79 | fail('Expected findByTypeId to throw') 80 | } catch (error) { 81 | expect(error).toBeInstanceOf(GraphQLError) 82 | const gqlError = error as GraphQLError 83 | expect(gqlError.message).toBe( 84 | `No entry of type "${requestedType}" with ID "${id}" exists.`, 85 | ) 86 | expect(gqlError.extensions?.code).toBe(ErrorCode.NOT_FOUND) 87 | expect(gqlError.extensions?.commitspark).toEqual({ 88 | typeName: requestedType, 89 | argumentName: 'id', 90 | argumentValue: id, 91 | }) 92 | } 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /src/graphql/resolvers/query-mutation-resolvers/mutation-update-resolver.ts: -------------------------------------------------------------------------------- 1 | import { findById, findByTypeId } from '../../../persistence/persistence' 2 | import { getReferencedEntryIds } from '../../schema-utils/entry-reference-util' 3 | import { GraphQLFieldResolver, isObjectType } from 'graphql' 4 | import { EntryData, EntryDraft } from '@commitspark/git-adapter' 5 | import { QueryMutationResolverContext } from '../types' 6 | import { createError, ErrorCode } from '../../errors' 7 | 8 | function mergeData( 9 | existingEntryData: EntryData, 10 | updateData: EntryData, 11 | ): EntryData { 12 | return { 13 | ...existingEntryData, 14 | ...updateData, 15 | } 16 | } 17 | 18 | export const mutationUpdateResolver: GraphQLFieldResolver< 19 | any, 20 | QueryMutationResolverContext, 21 | any, 22 | Promise 23 | > = async (source, args, context, info) => { 24 | if (!isObjectType(context.type)) { 25 | throw createError( 26 | `Type "${context.type.name}" cannot be mutated as is not an ObjectType.`, 27 | ErrorCode.INTERNAL_ERROR, 28 | {}, 29 | ) 30 | } 31 | 32 | const existingEntry = await findByTypeId(context, context.type.name, args.id) 33 | 34 | const existingReferencedEntryIds = await getReferencedEntryIds( 35 | context.type, 36 | context, 37 | null, 38 | info.returnType, 39 | existingEntry.data, 40 | ) 41 | 42 | const mergedData = mergeData(existingEntry.data ?? null, args.data) 43 | const updatedReferencedEntryIds = await getReferencedEntryIds( 44 | context.type, 45 | context, 46 | null, 47 | info.returnType, 48 | mergedData, 49 | ) 50 | 51 | const noLongerReferencedIds = existingReferencedEntryIds.filter( 52 | (entryId) => !updatedReferencedEntryIds.includes(entryId), 53 | ) 54 | const newlyReferencedIds = updatedReferencedEntryIds.filter( 55 | (entryId) => !existingReferencedEntryIds.includes(entryId), 56 | ) 57 | 58 | const referencedEntryUpdates: EntryDraft[] = [] 59 | for (const noLongerReferencedEntryId of noLongerReferencedIds) { 60 | const noLongerReferencedEntry = await findById( 61 | context, 62 | noLongerReferencedEntryId, 63 | ) 64 | referencedEntryUpdates.push({ 65 | ...noLongerReferencedEntry, 66 | metadata: { 67 | ...noLongerReferencedEntry.metadata, 68 | referencedBy: noLongerReferencedEntry.metadata.referencedBy?.filter( 69 | (entryId) => entryId !== args.id, 70 | ), 71 | }, 72 | deletion: false, 73 | }) 74 | } 75 | for (const newlyReferencedEntryId of newlyReferencedIds) { 76 | const newlyReferencedEntry = await findById(context, newlyReferencedEntryId) 77 | const updatedReferenceList: string[] = 78 | newlyReferencedEntry.metadata.referencedBy ?? [] 79 | updatedReferenceList.push(args.id) 80 | updatedReferenceList.sort() 81 | referencedEntryUpdates.push({ 82 | ...newlyReferencedEntry, 83 | metadata: { 84 | ...newlyReferencedEntry.metadata, 85 | referencedBy: updatedReferenceList, 86 | }, 87 | deletion: false, 88 | }) 89 | } 90 | 91 | const commit = await context.gitAdapter.createCommit({ 92 | ref: context.branch, 93 | parentSha: context.getCurrentHash(), 94 | entries: [ 95 | { ...existingEntry, data: mergedData, deletion: false }, 96 | ...referencedEntryUpdates, 97 | ], 98 | message: args.commitMessage, 99 | }) 100 | context.setCurrentHash(commit.ref) 101 | 102 | const updatedEntry = await findByTypeId(context, context.type.name, args.id) 103 | return { ...updatedEntry.data, id: updatedEntry.id } 104 | } 105 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { createApolloConfig } from './graphql/apollo-config-factory' 2 | import { generateSchema } from './graphql/schema-generator' 3 | import { 4 | DocumentNode, 5 | GraphQLFormattedError, 6 | TypedQueryDocumentNode, 7 | } from 'graphql' 8 | import { GitAdapter } from '@commitspark/git-adapter' 9 | import { 10 | ApolloServer, 11 | ApolloServerOptions, 12 | GraphQLRequest, 13 | } from '@apollo/server' 14 | import { RepositoryCacheHandler } from './persistence/cache' 15 | 16 | export type VariableValues = { [name: string]: unknown } 17 | 18 | export type ApolloExecuteOperationRequest< 19 | TData = Record, 20 | TVariables extends VariableValues = VariableValues, 21 | > = Omit, 'query'> & { 22 | query?: string | DocumentNode | TypedQueryDocumentNode 23 | } 24 | 25 | export const postGraphQL = async < 26 | TData = Record, 27 | TVariables extends VariableValues = VariableValues, 28 | >( 29 | gitAdapter: GitAdapter, 30 | repositoryCache: RepositoryCacheHandler, 31 | ref: string, 32 | request: ApolloExecuteOperationRequest, 33 | ): Promise> => { 34 | let currentHash = await gitAdapter.getLatestCommitHash(ref) 35 | const context: ApolloContext = { 36 | branch: ref, 37 | gitAdapter: gitAdapter, 38 | getCurrentHash(): string { 39 | return currentHash 40 | }, 41 | setCurrentHash(refArg: string) { 42 | currentHash = refArg 43 | }, 44 | repositoryCache: repositoryCache, 45 | } 46 | 47 | const apolloDriverConfig: ApolloServerOptions = 48 | await createApolloConfig(context) 49 | 50 | const apolloServer = new ApolloServer({ 51 | ...apolloDriverConfig, 52 | }) 53 | 54 | const result = await apolloServer.executeOperation( 55 | request, 56 | { 57 | contextValue: context, 58 | }, 59 | ) 60 | 61 | await apolloServer.stop() 62 | 63 | return { 64 | ref: context.getCurrentHash(), 65 | data: 66 | result.body.kind === 'single' ? result.body.singleResult.data : undefined, 67 | errors: 68 | result.body.kind === 'single' 69 | ? result.body.singleResult.errors 70 | : undefined, 71 | } 72 | } 73 | 74 | export const getSchema = async ( 75 | gitAdapter: GitAdapter, 76 | repositoryCache: RepositoryCacheHandler, 77 | ref: string, 78 | ): Promise => { 79 | let currentHash = await gitAdapter.getLatestCommitHash(ref) 80 | const context: ApolloContext = { 81 | branch: ref, 82 | gitAdapter: gitAdapter, 83 | getCurrentHash(): string { 84 | return currentHash 85 | }, 86 | setCurrentHash(refArg: string) { 87 | currentHash = refArg 88 | }, 89 | repositoryCache: repositoryCache, 90 | } 91 | 92 | const typeDefinitionStrings = (await generateSchema(context)).typeDefs 93 | if (!Array.isArray(typeDefinitionStrings)) { 94 | throw new Error('Expected array of typeDefinition strings.') 95 | } 96 | 97 | return { 98 | ref: context.getCurrentHash(), 99 | data: typeDefinitionStrings.join('\n'), 100 | } 101 | } 102 | 103 | export interface ApolloContext { 104 | branch: string 105 | gitAdapter: GitAdapter 106 | getCurrentHash(): string 107 | setCurrentHash(sha: string): void 108 | repositoryCache: RepositoryCacheHandler 109 | } 110 | 111 | export interface GraphQLResponse { 112 | ref: string 113 | data?: TData 114 | errors: ReadonlyArray | undefined 115 | } 116 | 117 | export interface SchemaResponse { 118 | ref: string 119 | data: string 120 | } 121 | -------------------------------------------------------------------------------- /src/graphql/input-type-generator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLEnumType, 3 | GraphQLInputObjectType, 4 | GraphQLInterfaceType, 5 | GraphQLNullableType, 6 | GraphQLObjectType, 7 | GraphQLScalarType, 8 | GraphQLUnionType, 9 | isInterfaceType, 10 | isListType, 11 | isNonNullType, 12 | isObjectType, 13 | isScalarType, 14 | isUnionType, 15 | } from 'graphql' 16 | import { SchemaAnalyzerResult } from './schema-analyzer' 17 | import { 18 | buildsOnTypeWithEntryDirective, 19 | hasEntryDirective, 20 | } from './schema-utils/entry-type-util' 21 | 22 | function generateFieldInputTypeString(type: GraphQLNullableType): string { 23 | if (isListType(type)) { 24 | return `[${generateFieldInputTypeString(type.ofType)}]` 25 | } else if (isNonNullType(type)) { 26 | return `${generateFieldInputTypeString(type.ofType)}!` 27 | } else if (isObjectType(type)) { 28 | if (hasEntryDirective(type)) { 29 | return `${type.name}IdInput` 30 | } else { 31 | return `${type.name}Input` 32 | } 33 | } else if (isInterfaceType(type)) { 34 | // TODO 35 | return '' 36 | } else if (isUnionType(type)) { 37 | if (buildsOnTypeWithEntryDirective(type)) { 38 | return `${type.name}IdInput` 39 | } else { 40 | return `${type.name}Input` 41 | } 42 | } else { 43 | return ( 44 | type as GraphQLScalarType | GraphQLEnumType | GraphQLInputObjectType 45 | ).name 46 | } 47 | } 48 | 49 | export function generateIdInputTypeStrings( 50 | schemaAnalyzerResult: SchemaAnalyzerResult, 51 | ): string[] { 52 | let typesWithIdField: ( 53 | | GraphQLObjectType 54 | | GraphQLInterfaceType 55 | | GraphQLUnionType 56 | )[] = [] 57 | typesWithIdField = typesWithIdField.concat( 58 | schemaAnalyzerResult.entryDirectiveTypes, 59 | ) 60 | typesWithIdField = typesWithIdField.concat( 61 | schemaAnalyzerResult.interfaceTypes, 62 | ) 63 | typesWithIdField = typesWithIdField.concat(schemaAnalyzerResult.unionTypes) 64 | 65 | return typesWithIdField.map((type): string => { 66 | return `input ${type.name}IdInput {\n` + ' id: ID!\n' + '}\n' 67 | }) 68 | } 69 | 70 | export function generateObjectInputTypeStrings( 71 | objectTypes: GraphQLObjectType[], 72 | ): string[] { 73 | return objectTypes.map((objectType): string => { 74 | const name = objectType.name 75 | const inputTypeName = `${name}Input` 76 | let inputType = `input ${inputTypeName} {\n` 77 | let inputTypeFieldStrings = '' 78 | for (const fieldsKey in objectType.getFields()) { 79 | const field = objectType.getFields()[fieldsKey] 80 | if ( 81 | (isNonNullType(field.type) && 82 | isScalarType(field.type.ofType) && 83 | field.type.ofType.name === 'ID') || 84 | (isScalarType(field.type) && field.type.name === 'ID') 85 | ) { 86 | continue 87 | } 88 | const inputTypeString = generateFieldInputTypeString(field.type) 89 | inputTypeFieldStrings += ` ${field.name}: ${inputTypeString}\n` 90 | } 91 | 92 | if (inputTypeFieldStrings.length > 0) { 93 | inputType += `${inputTypeFieldStrings}` 94 | } else { 95 | // generate a dummy field as empty input types are not permitted 96 | inputType += ' _: Boolean\n' 97 | } 98 | inputType += '}\n' 99 | 100 | return inputType 101 | }) 102 | } 103 | 104 | export function generateUnionInputTypeStrings( 105 | unionTypes: GraphQLUnionType[], 106 | ): string[] { 107 | const typeStrings = unionTypes.map((unionType): string => { 108 | if (buildsOnTypeWithEntryDirective(unionType)) { 109 | return '' 110 | } 111 | 112 | // see https://github.com/graphql/graphql-spec/pull/825 113 | let unionInputType = `input ${unionType.name}Input @oneOf {\n` 114 | for (const innerType of unionType.getTypes()) { 115 | unionInputType += ` ${innerType.name}: ${innerType.name}Input\n` 116 | } 117 | unionInputType += '}\n' 118 | return unionInputType 119 | }) 120 | 121 | if (typeStrings.join('').length > 0) { 122 | typeStrings.push('directive @oneOf on INPUT_OBJECT\n') 123 | } 124 | 125 | return typeStrings 126 | } 127 | -------------------------------------------------------------------------------- /src/graphql/resolvers/field-resolvers/field-default-value-resolver.ts: -------------------------------------------------------------------------------- 1 | import { FieldResolver, FieldResolverContext, ResolvedEntryData } from './types' 2 | import { EntryData } from '@commitspark/git-adapter' 3 | import { 4 | GraphQLNonNull, 5 | GraphQLResolveInfo, 6 | GraphQLUnionType, 7 | isListType, 8 | isNonNullType, 9 | isObjectType, 10 | isUnionType, 11 | } from 'graphql' 12 | import { 13 | buildsOnTypeWithEntryDirective, 14 | hasEntryDirective, 15 | } from '../../schema-utils/entry-type-util' 16 | import { resolveEntryReference } from './entry-reference-resolver' 17 | import { resolveUnionValue } from './union-value-resolver' 18 | import { createError, ErrorCode } from '../../errors' 19 | 20 | export const resolveFieldDefaultValue: FieldResolver = async ( 21 | fieldValue: any, 22 | args: any, 23 | context: FieldResolverContext, 24 | info: GraphQLResolveInfo, 25 | ): Promise> => { 26 | if (isNonNullType(context.currentType)) { 27 | return resolveFieldDefaultValue( 28 | fieldValue, 29 | args, 30 | { 31 | ...context, 32 | currentType: context.currentType.ofType, 33 | hasNonNullParent: true, 34 | }, 35 | info, 36 | ) 37 | } else if (isListType(context.currentType)) { 38 | if (fieldValue === undefined || fieldValue === null) { 39 | if (context.hasNonNullParent) { 40 | return new Promise((resolve) => resolve([])) 41 | } else { 42 | return new Promise((resolve) => resolve(null)) 43 | } 44 | } 45 | 46 | if (!Array.isArray(fieldValue)) { 47 | throw createError( 48 | `Expected array while resolving value for field "${info.fieldName}".`, 49 | ErrorCode.SCHEMA_DATA_MISMATCH, 50 | { 51 | fieldName: info.fieldName, 52 | fieldValue: fieldValue, 53 | }, 54 | ) 55 | } 56 | 57 | const resultPromises = [] 58 | for (const item of fieldValue) { 59 | const resultPromise = resolveFieldDefaultValue( 60 | item, 61 | args, 62 | { 63 | ...context, 64 | currentType: context.currentType.ofType, 65 | hasNonNullParent: context.hasNonNullParent, 66 | }, 67 | info, 68 | ) 69 | resultPromises.push(resultPromise) 70 | } 71 | return Promise.all(resultPromises) 72 | } else if (isUnionType(context.currentType)) { 73 | if (fieldValue === undefined || fieldValue === null) { 74 | if (context.hasNonNullParent) { 75 | throw createError( 76 | `Cannot generate a default value for NonNull field "${ 77 | info.parentType.name 78 | }.${info.fieldName}" because it is a union field of type "${ 79 | (info.returnType as GraphQLNonNull).ofType.name 80 | }". Set a value in your repository data or remove the NonNull declaration in your schema.`, 81 | ErrorCode.BAD_REPOSITORY_DATA, 82 | { 83 | typeName: info.parentType.name, 84 | fieldName: info.fieldName, 85 | }, 86 | ) 87 | } else { 88 | return new Promise((resolve) => resolve(null)) 89 | } 90 | } 91 | 92 | if (buildsOnTypeWithEntryDirective(context.currentType)) { 93 | return resolveEntryReference(fieldValue, args, context, info) 94 | } 95 | 96 | return resolveUnionValue(fieldValue, args, context, info) 97 | } else if (isObjectType(context.currentType)) { 98 | if (fieldValue === undefined || fieldValue === null) { 99 | if (context.hasNonNullParent) { 100 | throw createError( 101 | `Cannot generate a default value for NonNull field "${info.fieldName}" of type ` + 102 | `"${info.parentType.name}". Set a value in your repository data or remove the NonNull ` + 103 | `declaration in your schema.`, 104 | ErrorCode.BAD_REPOSITORY_DATA, 105 | { 106 | typeName: info.parentType.name, 107 | fieldName: info.fieldName, 108 | }, 109 | ) 110 | } else { 111 | return new Promise((resolve) => resolve(null)) 112 | } 113 | } 114 | 115 | if (hasEntryDirective(context.currentType)) { 116 | return resolveEntryReference(fieldValue, args, context, info) 117 | } 118 | 119 | return fieldValue 120 | } 121 | 122 | return fieldValue 123 | } 124 | -------------------------------------------------------------------------------- /src/persistence/cache.ts: -------------------------------------------------------------------------------- 1 | import { Entry, GitAdapterError } from '@commitspark/git-adapter' 2 | import { ApolloContext } from '../client' 3 | import { createError, ErrorCode } from '../graphql/errors' 4 | 5 | type Ref = string 6 | type EntriesCache = Map 7 | type SchemaCache = Map 8 | type InflightEntries = Map> 9 | 10 | interface EntriesRecord { 11 | byId: Map 12 | byType: Map 13 | } 14 | 15 | export interface RepositoryCacheHandler { 16 | getEntriesRecord: ( 17 | context: ApolloContext, 18 | ref: string, 19 | ) => Promise 20 | getSchema: (context: ApolloContext, ref: string) => Promise 21 | } 22 | 23 | const MAX_CACHE_ENTRIES = 50 24 | 25 | // intentionally not async so that the same in-flight promise is returned to all callers 26 | const getEntriesRecordByRef = ( 27 | entriesCache: EntriesCache, 28 | inflightEntries: InflightEntries, 29 | cacheSize: number, 30 | context: ApolloContext, 31 | ref: string, 32 | ): Promise => { 33 | const cacheRecord = entriesCache.get(ref) 34 | if (cacheRecord !== undefined) { 35 | // move map key back to end of list (newest) 36 | entriesCache.delete(ref) 37 | entriesCache.set(ref, cacheRecord) 38 | 39 | return Promise.resolve(cacheRecord) 40 | } 41 | 42 | const inflightPromise = inflightEntries.get(ref) 43 | if (inflightPromise) { 44 | return inflightPromise 45 | } 46 | 47 | const fetchPromise = fetchAndCacheEntries( 48 | entriesCache, 49 | cacheSize, 50 | context, 51 | ref, 52 | ).finally(() => inflightEntries.delete(ref)) 53 | 54 | inflightEntries.set(ref, fetchPromise) 55 | 56 | return fetchPromise 57 | } 58 | 59 | const fetchAndCacheEntries = async ( 60 | entriesCache: EntriesCache, 61 | cacheSize: number, 62 | context: ApolloContext, 63 | ref: string, 64 | ): Promise => { 65 | let allEntries: Entry[] 66 | try { 67 | allEntries = await context.gitAdapter.getEntries(ref) 68 | } catch (err) { 69 | if (err instanceof GitAdapterError) { 70 | throw createError(err.message, err.code, {}) 71 | } 72 | const message = err instanceof Error ? err.message : String(err) 73 | throw createError(message, ErrorCode.INTERNAL_ERROR, {}) 74 | } 75 | 76 | const entriesById = new Map(allEntries.map((entry) => [entry.id, entry])) 77 | const entriesByType = new Map() 78 | for (const entry of allEntries) { 79 | const existingEntriesOfType = entriesByType.get(entry.metadata.type) || [] 80 | existingEntriesOfType.push(entry) 81 | entriesByType.set(entry.metadata.type, existingEntriesOfType) 82 | } 83 | 84 | const newCacheRecord = { 85 | byType: entriesByType, 86 | byId: entriesById, 87 | } 88 | 89 | entriesCache.set(ref, newCacheRecord) 90 | 91 | if (entriesCache.size > cacheSize) { 92 | // get first map key (oldest) 93 | const oldestKey = entriesCache.keys().next().value as Ref | undefined 94 | if (oldestKey !== undefined) { 95 | entriesCache.delete(oldestKey) 96 | } 97 | } 98 | 99 | return newCacheRecord 100 | } 101 | 102 | const getSchemaStringByRef = async ( 103 | schemaCache: SchemaCache, 104 | cacheSize: number, 105 | context: ApolloContext, 106 | ref: string, 107 | ): Promise => { 108 | const schemaCacheRecord = schemaCache.get(ref) 109 | if (schemaCacheRecord !== undefined) { 110 | // move map key back to end of list (newest) 111 | schemaCache.delete(ref) 112 | schemaCache.set(ref, schemaCacheRecord) 113 | 114 | return schemaCacheRecord 115 | } 116 | 117 | let schemaString 118 | try { 119 | schemaString = await context.gitAdapter.getSchema(ref) 120 | } catch (err) { 121 | if (err instanceof GitAdapterError) { 122 | throw createError(err.message, err.code, {}) 123 | } 124 | const message = err instanceof Error ? err.message : String(err) 125 | throw createError(message, ErrorCode.INTERNAL_ERROR, {}) 126 | } 127 | 128 | schemaCache.set(ref, schemaString) 129 | 130 | if (schemaCache.size > cacheSize) { 131 | // get first map key (oldest) 132 | const oldestKey = schemaCache.keys().next().value as Ref | undefined 133 | if (oldestKey !== undefined) { 134 | schemaCache.delete(oldestKey) 135 | } 136 | } 137 | 138 | return schemaString 139 | } 140 | 141 | export const createCacheHandler = ( 142 | cacheSize: number = MAX_CACHE_ENTRIES, 143 | ): RepositoryCacheHandler => { 144 | const entriesCache: EntriesCache = new Map() 145 | const schemaCache: SchemaCache = new Map() 146 | const inflightEntries: InflightEntries = new Map< 147 | Ref, 148 | Promise 149 | >() 150 | return { 151 | getEntriesRecord: (...args) => 152 | getEntriesRecordByRef(entriesCache, inflightEntries, cacheSize, ...args), 153 | getSchema: (...args) => 154 | getSchemaStringByRef(schemaCache, cacheSize, ...args), 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/graphql/queries-mutations-generator.ts: -------------------------------------------------------------------------------- 1 | import { EntryData } from '@commitspark/git-adapter' 2 | import { ApolloContext } from '../client' 3 | import { GraphQLFieldResolver, GraphQLObjectType } from 'graphql' 4 | import { queryEveryResolver } from './resolvers/query-mutation-resolvers/query-every-resolver' 5 | import { queryByIdResolver } from './resolvers/query-mutation-resolvers/query-by-id-resolver' 6 | import { mutationCreateResolver } from './resolvers/query-mutation-resolvers/mutation-create-resolver' 7 | import { mutationUpdateResolver } from './resolvers/query-mutation-resolvers/mutation-update-resolver' 8 | import { mutationDeleteResolver } from './resolvers/query-mutation-resolvers/mutation-delete-resolver' 9 | import { queryTypeByIdResolver } from './resolvers/query-mutation-resolvers/query-type-by-id-resolver' 10 | import { QueryMutationResolver } from './resolvers/types' 11 | 12 | export function generateQueriesAndMutations( 13 | entryDirectiveTypes: GraphQLObjectType[], 14 | ): GeneratedSchema[] { 15 | return entryDirectiveTypes.map((objectType): GeneratedSchema => { 16 | const typeName = objectType.name 17 | 18 | // The semantics of `every` aren't a perfect replacement of `all` but this way we don't need to do non-trivial 19 | // pluralization of user-provided typeName 20 | const queryEveryName = `every${typeName}` 21 | const queryEveryString = `${queryEveryName}: [${objectType.name}!]` 22 | const queryEveryResolverFunc: GraphQLFieldResolver< 23 | any, 24 | ApolloContext, 25 | any, 26 | Promise 27 | > = (source, args, context, info) => 28 | queryEveryResolver(source, args, { ...context, type: objectType }, info) 29 | 30 | const queryByIdName = typeName 31 | const queryByIdString = `${queryByIdName}(id: ID!): ${objectType.name}` 32 | const queryByIdResolverFunc: QueryMutationResolver = ( 33 | source, 34 | args, 35 | context, 36 | info, 37 | ) => queryByIdResolver(source, args, { ...context, type: objectType }, info) 38 | 39 | const inputTypeName = `${typeName}Input` 40 | const createMutationName = `create${typeName}` 41 | const createMutationString = `${createMutationName}(id: ID!, data: ${inputTypeName}!, commitMessage: String): ${typeName}` 42 | const createMutationResolver: QueryMutationResolver = ( 43 | source, 44 | args, 45 | context, 46 | info, 47 | ) => 48 | mutationCreateResolver( 49 | source, 50 | args, 51 | { ...context, type: objectType }, 52 | info, 53 | ) 54 | 55 | const updateMutationName = `update${typeName}` 56 | const updateMutationString = `${updateMutationName}(id: ID!, data: ${inputTypeName}!, commitMessage: String): ${typeName}` 57 | const updateMutationResolver: QueryMutationResolver = ( 58 | source, 59 | args, 60 | context, 61 | info, 62 | ) => 63 | mutationUpdateResolver( 64 | source, 65 | args, 66 | { ...context, type: objectType }, 67 | info, 68 | ) 69 | 70 | const deleteMutationName = `delete${typeName}` 71 | const deleteMutationString = `${deleteMutationName}(id: ID!, commitMessage: String): ID` 72 | const deleteMutationResolver: QueryMutationResolver = ( 73 | source, 74 | args, 75 | context, 76 | info, 77 | ) => 78 | mutationDeleteResolver( 79 | source, 80 | args, 81 | { ...context, type: objectType }, 82 | info, 83 | ) 84 | 85 | return { 86 | queryEvery: { 87 | name: queryEveryName, 88 | schemaString: queryEveryString, 89 | resolver: queryEveryResolverFunc, 90 | }, 91 | queryById: { 92 | name: queryByIdName, 93 | schemaString: queryByIdString, 94 | resolver: queryByIdResolverFunc, 95 | }, 96 | createMutation: { 97 | name: createMutationName, 98 | schemaString: createMutationString, 99 | resolver: createMutationResolver, 100 | }, 101 | updateMutation: { 102 | name: updateMutationName, 103 | schemaString: updateMutationString, 104 | resolver: updateMutationResolver, 105 | }, 106 | deleteMutation: { 107 | name: deleteMutationName, 108 | schemaString: deleteMutationString, 109 | resolver: deleteMutationResolver, 110 | }, 111 | } 112 | }) 113 | } 114 | 115 | export function generateTypeNameQuery(): GeneratedQuery> { 116 | const entryTypeQueryName = '_typeName' 117 | const entryTypeQueryString = `${entryTypeQueryName}(id: ID!): String!` 118 | 119 | return { 120 | name: entryTypeQueryName, 121 | schemaString: entryTypeQueryString, 122 | resolver: queryTypeByIdResolver, 123 | } 124 | } 125 | 126 | export interface GeneratedSchema { 127 | queryEvery: GeneratedQuery> 128 | queryById: GeneratedQuery> 129 | createMutation: GeneratedQuery> 130 | updateMutation: GeneratedQuery> 131 | deleteMutation: GeneratedQuery> 132 | } 133 | 134 | export interface GeneratedQuery { 135 | name: string 136 | schemaString: string 137 | resolver: GraphQLFieldResolver 138 | } 139 | -------------------------------------------------------------------------------- /src/graphql/schema-generator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | generateQueriesAndMutations, 3 | generateTypeNameQuery, 4 | } from './queries-mutations-generator' 5 | import { 6 | IExecutableSchemaDefinition, 7 | makeExecutableSchema, 8 | } from '@graphql-tools/schema' 9 | import { analyzeSchema } from './schema-analyzer' 10 | import { 11 | generateIdInputTypeStrings, 12 | generateObjectInputTypeStrings, 13 | generateUnionInputTypeStrings, 14 | } from './input-type-generator' 15 | import { generateSchemaRootTypeStrings } from './schema-root-type-generator' 16 | import { printSchemaWithDirectives } from '@graphql-tools/utils' 17 | import { unionTypeResolver } from './resolvers/query-mutation-resolvers/union-type-resolver' 18 | import { ApolloContext } from '../client' 19 | import { GraphQLFieldResolver, GraphQLTypeResolver } from 'graphql' 20 | import { getValidationResult } from './schema-validator' 21 | import { EntryData } from '@commitspark/git-adapter' 22 | import { createObjectTypeFieldResolvers } from './resolvers/object-type-field-default-value-resolver-generator' 23 | import { createError, ErrorCode } from './errors' 24 | 25 | export async function generateSchema( 26 | context: ApolloContext, 27 | ): Promise { 28 | const originalSchemaString = await context.repositoryCache.getSchema( 29 | context, 30 | context.getCurrentHash(), 31 | ) 32 | const schema = makeExecutableSchema({ 33 | typeDefs: originalSchemaString, 34 | }) 35 | 36 | const validationResult = getValidationResult(schema) 37 | if (validationResult.length > 0) { 38 | throw createError(`Invalid schema.`, ErrorCode.BAD_SCHEMA, { 39 | schema: printSchemaWithDirectives(schema), 40 | argumentValue: validationResult.join('\n'), 41 | }) 42 | } 43 | const schemaAnalyzerResult = analyzeSchema(schema) 44 | 45 | const filteredOriginalSchemaString = printSchemaWithDirectives(schema) + '\n' 46 | 47 | const generatedIdInputTypeStrings = 48 | generateIdInputTypeStrings(schemaAnalyzerResult) 49 | 50 | const generatedQueriesMutations = generateQueriesAndMutations( 51 | schemaAnalyzerResult.entryDirectiveTypes, 52 | ) 53 | const generatedTypeNameQuery = generateTypeNameQuery() 54 | 55 | const generatedEntryReferenceResolvers: Record< 56 | string, 57 | Record< 58 | string, 59 | GraphQLFieldResolver< 60 | Record, 61 | ApolloContext, 62 | any, 63 | Promise 64 | > 65 | > 66 | > = {} 67 | 68 | const generatedObjectInputTypeStrings = generateObjectInputTypeStrings( 69 | schemaAnalyzerResult.objectTypes, 70 | ) 71 | 72 | const generatedUnionInputTypeStrings = generateUnionInputTypeStrings( 73 | schemaAnalyzerResult.unionTypes, 74 | ) 75 | 76 | const generatedSchemaRootTypeStrings = generateSchemaRootTypeStrings( 77 | generatedQueriesMutations, 78 | generatedTypeNameQuery, 79 | ) 80 | 81 | const generatedSchemaString = `schema { 82 | query: Query 83 | mutation: Mutation 84 | } 85 | 86 | ${generatedSchemaRootTypeStrings}` 87 | 88 | const generatedUnionTypeResolvers: Record = {} 89 | for (const unionType of schemaAnalyzerResult.unionTypes) { 90 | generatedUnionTypeResolvers[unionType.name] = { 91 | __resolveType: unionTypeResolver, 92 | } 93 | } 94 | const generatedObjectTypeFieldResolvers = 95 | createObjectTypeFieldResolvers(schema) 96 | 97 | const generatedQueryResolvers: Record< 98 | string, 99 | GraphQLFieldResolver 100 | > = {} 101 | const generatedMutationResolvers: Record< 102 | string, 103 | GraphQLFieldResolver 104 | > = {} 105 | 106 | for (const element of generatedQueriesMutations) { 107 | generatedQueryResolvers[element.queryEvery.name] = 108 | element.queryEvery.resolver 109 | generatedQueryResolvers[element.queryById.name] = element.queryById.resolver 110 | generatedMutationResolvers[element.createMutation.name] = 111 | element.createMutation.resolver 112 | generatedMutationResolvers[element.updateMutation.name] = 113 | element.updateMutation.resolver 114 | generatedMutationResolvers[element.deleteMutation.name] = 115 | element.deleteMutation.resolver 116 | } 117 | generatedQueryResolvers[generatedTypeNameQuery.name] = 118 | generatedTypeNameQuery.resolver 119 | 120 | const allGeneratedResolvers: Record< 121 | string, 122 | Record< 123 | string, 124 | | GraphQLFieldResolver 125 | | GraphQLTypeResolver 126 | > 127 | > = { 128 | Query: generatedQueryResolvers, 129 | Mutation: generatedMutationResolvers, 130 | } 131 | 132 | for (const typeName of Object.keys(generatedUnionTypeResolvers)) { 133 | allGeneratedResolvers[typeName] = { 134 | ...(allGeneratedResolvers[typeName] ?? {}), 135 | ...generatedUnionTypeResolvers[typeName], 136 | } 137 | } 138 | for (const typeName of Object.keys(generatedObjectTypeFieldResolvers)) { 139 | allGeneratedResolvers[typeName] = { 140 | ...(allGeneratedResolvers[typeName] ?? {}), 141 | ...generatedObjectTypeFieldResolvers[typeName], 142 | } 143 | } 144 | for (const typeName of Object.keys(generatedEntryReferenceResolvers)) { 145 | allGeneratedResolvers[typeName] = { 146 | ...(allGeneratedResolvers[typeName] ?? {}), 147 | ...generatedEntryReferenceResolvers[typeName], 148 | } 149 | } 150 | 151 | const typeDefs = [ 152 | filteredOriginalSchemaString, 153 | generatedSchemaString, 154 | generatedIdInputTypeStrings.join('\n'), 155 | generatedObjectInputTypeStrings.join('\n'), 156 | generatedUnionInputTypeStrings.join('\n'), 157 | ].filter((typeDef) => typeDef.length > 0) 158 | 159 | return { 160 | typeDefs: typeDefs, 161 | resolvers: allGeneratedResolvers, 162 | } 163 | } 164 | 165 | interface UnionTypeResolver { 166 | __resolveType: GraphQLTypeResolver 167 | } 168 | -------------------------------------------------------------------------------- /src/graphql/schema-utils/entry-reference-util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLNullableType, 3 | GraphQLObjectType, 4 | isListType, 5 | isNonNullType, 6 | isObjectType, 7 | isUnionType, 8 | } from 'graphql' 9 | import { ApolloContext } from '../../client' 10 | import { getTypeById } from '../../persistence/persistence' 11 | import { 12 | getUnionTypeNameFromFieldValue, 13 | getUnionValue, 14 | } from './union-type-util' 15 | import { hasEntryDirective, isUnionOfEntryTypes } from './entry-type-util' 16 | import { createError, ErrorCode } from '../errors' 17 | 18 | function isPermittedReferenceType( 19 | referencedTypeName: string, 20 | fieldType: GraphQLNullableType, 21 | ): boolean { 22 | if (isNonNullType(fieldType)) { 23 | return isPermittedReferenceType(referencedTypeName, fieldType.ofType) 24 | } else if (isListType(fieldType)) { 25 | return isPermittedReferenceType(referencedTypeName, fieldType.ofType) 26 | } else if (isUnionType(fieldType)) { 27 | return fieldType 28 | .getTypes() 29 | .map((concreteType) => concreteType.name) 30 | .includes(referencedTypeName) 31 | } else if (isObjectType(fieldType)) { 32 | return fieldType.name === referencedTypeName 33 | } 34 | return false 35 | } 36 | 37 | async function validateReference( 38 | context: ApolloContext, 39 | fieldName: string, 40 | fieldType: GraphQLNullableType, 41 | fieldValue: any, 42 | ): Promise { 43 | if (isNonNullType(fieldType)) { 44 | await validateReference(context, fieldName, fieldType.ofType, fieldValue) 45 | return 46 | } else if (isListType(fieldType)) { 47 | if (!Array.isArray(fieldValue)) { 48 | throw createError( 49 | `Expected array while validation references for field "${fieldName}".`, 50 | ErrorCode.SCHEMA_DATA_MISMATCH, 51 | { 52 | fieldName: fieldName, 53 | fieldValue: fieldValue, 54 | }, 55 | ) 56 | } 57 | for (const fieldListElement of fieldValue) { 58 | await validateReference( 59 | context, 60 | fieldName, 61 | fieldType.ofType, 62 | fieldListElement, 63 | ) 64 | } 65 | return 66 | } else if (isUnionType(fieldType) || isObjectType(fieldType)) { 67 | if (!('id' in fieldValue)) { 68 | throw createError( 69 | `Expected key "id" in data while validating reference for field "${fieldName}".`, 70 | ErrorCode.SCHEMA_DATA_MISMATCH, 71 | { 72 | fieldName: fieldName, 73 | fieldValue: fieldValue, 74 | }, 75 | ) 76 | } 77 | const referencedId = fieldValue.id 78 | let referencedTypeName 79 | try { 80 | referencedTypeName = await getTypeById(context, referencedId) 81 | } catch { 82 | throw createError( 83 | `Failed to resolve entry reference "${referencedId}".`, 84 | ErrorCode.BAD_USER_INPUT, 85 | { 86 | fieldName: fieldName, 87 | fieldValue: referencedId, 88 | }, 89 | ) 90 | } 91 | if (!isPermittedReferenceType(referencedTypeName, fieldType)) { 92 | throw createError( 93 | `Reference with ID "${referencedId}" points to entry of incompatible type "${referencedTypeName}".`, 94 | ErrorCode.BAD_USER_INPUT, 95 | { 96 | fieldName: fieldName, 97 | fieldValue: referencedId, 98 | }, 99 | ) 100 | } 101 | } 102 | } 103 | 104 | export async function getReferencedEntryIds( 105 | rootType: GraphQLObjectType, 106 | context: ApolloContext, 107 | fieldName: string | null, 108 | type: GraphQLNullableType, 109 | data: any, 110 | ): Promise { 111 | if (isNonNullType(type)) { 112 | return getReferencedEntryIds( 113 | rootType, 114 | context, 115 | fieldName, 116 | type.ofType, 117 | data, 118 | ) 119 | } else if (isListType(type)) { 120 | let referencedEntryIds: string[] = [] 121 | for (const element of data) { 122 | referencedEntryIds = [ 123 | ...referencedEntryIds, 124 | ...(await getReferencedEntryIds( 125 | rootType, 126 | context, 127 | fieldName, 128 | type.ofType, 129 | element, 130 | )), 131 | ] 132 | } 133 | // deduplicate 134 | referencedEntryIds = [...new Set(referencedEntryIds)] 135 | return referencedEntryIds 136 | } else if (isUnionType(type)) { 137 | if (isUnionOfEntryTypes(type)) { 138 | await validateReference(context, '', type, data) 139 | return [data.id] 140 | } 141 | 142 | const requestedUnionTypeName = getUnionTypeNameFromFieldValue(data) 143 | const concreteFieldUnionType = type 144 | .getTypes() 145 | .find( 146 | (concreteFieldType) => 147 | concreteFieldType.name === requestedUnionTypeName, 148 | ) 149 | if (!concreteFieldUnionType) { 150 | throw createError( 151 | `Type "${requestedUnionTypeName}" found in field data is not a valid type for ` + 152 | `union type "${type.name}".`, 153 | ErrorCode.BAD_REPOSITORY_DATA, 154 | { 155 | typeName: type.name, 156 | fieldName: fieldName ? fieldName : undefined, 157 | fieldValue: data, 158 | }, 159 | ) 160 | } 161 | const unionValue = getUnionValue(data) 162 | return getReferencedEntryIds( 163 | rootType, 164 | context, 165 | fieldName, 166 | concreteFieldUnionType, 167 | unionValue, 168 | ) 169 | } else if (isObjectType(type)) { 170 | if (type.name !== rootType.name && hasEntryDirective(type)) { 171 | await validateReference(context, fieldName ?? '', type, data) 172 | return [data.id] 173 | } else { 174 | let referencedEntryIds: string[] = [] 175 | for (const [fieldsKey, field] of Object.entries(type.getFields())) { 176 | const fieldValue = data[fieldsKey] ?? undefined 177 | if (fieldValue !== undefined) { 178 | const nestedResult = await getReferencedEntryIds( 179 | rootType, 180 | context, 181 | fieldsKey, 182 | field.type, 183 | fieldValue, 184 | ) 185 | referencedEntryIds = [...referencedEntryIds, ...nestedResult] 186 | } 187 | } 188 | // deduplicate 189 | referencedEntryIds = [...new Set(referencedEntryIds)] 190 | return referencedEntryIds 191 | } 192 | } 193 | 194 | return [] 195 | } 196 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.0.0-beta.6] - 2025-11-28 9 | 10 | ### Changed 11 | 12 | - Ensure single adapter call when GraphQL request contains multiple queries 13 | - Ensure all adapter errors are re-thrown as GraphQLError 14 | - Upgrade dependencies 15 | 16 | ## [1.0.0-beta.5] - 2025-11-25 17 | 18 | ### Added 19 | 20 | - Internal caching of entries and schema per Git ref to increase performance 21 | 22 | ### Changed 23 | 24 | - Upgrade dependencies 25 | 26 | ## [1.0.0-beta.4] - 2025-11-12 27 | 28 | ### Added 29 | 30 | - Expose GitAdapterErrors as GraphQLErrors to enable consistent error handling for callers 31 | 32 | ## [1.0.0-beta.3] - 2025-11-08 33 | 34 | ### Changed 35 | 36 | - Restrict Apollo Server to version 5.0 due to build failures introduced by 5.1 when using this library in Next.js 37 | (see [apollographql/apollo-server #8159](https://github.com/apollographql/apollo-server/issues/8159)) 38 | - Upgrade to `@commitspark/git-adapter` 0.20.0 39 | 40 | ## [1.0.0-beta.2] - 2025-11-07 41 | 42 | ### Changed 43 | 44 | - Expose errors explicitly and with metadata 45 | - Rename generated `all...` queries to `every...` to avoid having to pluralize entry type names 46 | 47 | ## [1.0.0-beta.1] - 2025-11-05 48 | 49 | ### Changed 50 | 51 | - Refactor implementation to drop dependency injection pattern 52 | - Rename main entry point `getApiService()` to `createClient()` to better match purpose 53 | - Move `gitAdapter` argument into `createClient()` 54 | - Remove query `_all...Meta` in favor of future pagination support 55 | 56 | ## [0.90.0] - 2025-09-07 57 | 58 | ### Changed 59 | 60 | - Upgrade to supported Apollo Server 5 61 | - Upgrade dependencies 62 | 63 | ## [0.81.2] - 2025-04-16 64 | 65 | ### Changed 66 | 67 | - Improve library exports 68 | 69 | ## [0.81.1] - 2025-04-16 70 | 71 | ### Fixed 72 | 73 | - Fix incorrect types export 74 | 75 | ### Changed 76 | 77 | - Improve library exports 78 | 79 | ## [0.81.0] - 2025-04-13 80 | 81 | ### Changed 82 | 83 | - Refactor library packaging to support ESM and CJS 84 | - Clean up dependencies and relax version constraints 85 | 86 | ## [0.80.0] - 2025-03-19 87 | 88 | ### Changed 89 | 90 | - Rewrite README for clarity 91 | - Remove superfluous `DeletionResult` type and replace with `ID` scalar in order to simplify API 92 | - Rename mutation `message` argument to `commitMessage` for more intuitive API use 93 | - Make mutation `data` argument non-null for more intuitive API use 94 | 95 | ## [0.12.0] - 2025-03-17 96 | 97 | ### Changed 98 | 99 | - Stop transforming field name case for concrete union types so that relationship to concrete type is more clear in 100 | serialized data 101 | - Upgrade dependencies 102 | 103 | ## [0.11.1] - 2024-12-29 104 | 105 | ### Added 106 | 107 | - Export API response types 108 | 109 | ### Changed 110 | 111 | - Upgrade dependencies 112 | 113 | ## [0.11.0] - 2024-08-17 114 | 115 | ### Changed 116 | 117 | - Upgrade to `@commitspark/git-adapter` 0.13.0 118 | 119 | ## [0.10.0] - 2024-06-15 120 | 121 | ### Added 122 | 123 | - Add referential integrity by tracking and validating references between entries 124 | - Add support of partial updates 125 | 126 | ### Fixed 127 | 128 | - [#18](https://github.com/commitspark/graphql-api/issues/18), [#25](https://github.com/commitspark/graphql-api/issues/25) 129 | Fix handling of entries that only have an `id` field 130 | 131 | ### Changed 132 | 133 | - Return default data where possible when entry data is incomplete 134 | - Improve content retrieval performance 135 | 136 | ## [0.9.4] - 2023-11-14 137 | 138 | ### Fixed 139 | 140 | - Fix invalid schema built when running under Next.js 14 141 | 142 | ## [0.9.3] - 2023-09-06 143 | 144 | ### Fixed 145 | 146 | - Fix failure to resolve array of non-@Entry union types that is null 147 | 148 | ### Changed 149 | 150 | - Update dependencies 151 | 152 | ## [0.9.2] - 2023-07-22 153 | 154 | ### Fixed 155 | 156 | - Fix documentation to match implementation 157 | - Fix missing numeric character permission in entry ID validation regex 158 | 159 | ## [0.9.1] - 2023-07-09 160 | 161 | ### Fixed 162 | 163 | - Fix broken NPM package build 164 | 165 | ## [0.9.0] - 2023-07-09 166 | 167 | ### Changed 168 | 169 | - Improve schema formatting 170 | - Expose entry ID as argument of "create" mutation instead of automatic ID generation 171 | - Check ID of entry before executing an update, delete mutation 172 | - Update dependencies 173 | 174 | ### Fixed 175 | 176 | - [#32](https://github.com/commitspark/graphql-api/issues/32) Fix memory leak triggered by API calls 177 | 178 | ## [0.8.0] - 2023-06-17 179 | 180 | ### Fixed 181 | 182 | - [#26](https://github.com/commitspark/graphql-api/issues/26) Querying optional reference field with null value causes 183 | exception 184 | 185 | ## [0.7.0] - 2023-05-12 186 | 187 | ### Changed 188 | 189 | - Rename organization 190 | - Remove deprecated code 191 | 192 | ## [0.6.1] - 2023-05-07 193 | 194 | ### Changed 195 | 196 | - Improve documentation 197 | 198 | ## [0.6.0] - 2023-04-27 199 | 200 | ### Changed 201 | 202 | - Enable strict TypeScript for improved type safety 203 | - Update packages to address NPM security audit 204 | 205 | ### Fixed 206 | 207 | - [#23](https://github.com/commitspark/graphql-api/issues/23) Data of unions with non-Entry members is 208 | not discernible after serialization 209 | 210 | ## [0.5.4] - 2023-03-15 211 | 212 | ### Changed 213 | 214 | - Remove dependency injection package to support bundling with webpack & co. 215 | - Upgrade to Apollo Server 4.5 216 | 217 | ## [0.5.3] - 2023-03-12 218 | 219 | ### Changed 220 | 221 | - Improve GraphQL endpoint type definition 222 | 223 | ## [0.5.2] - 2022-11-14 224 | 225 | ### Changed 226 | 227 | - Update dependencies 228 | - Upgrade to Apollo Server 4 229 | 230 | ## [0.5.1] - 2022-11-04 231 | 232 | ### Changed 233 | 234 | - Clean up unused dependencies 235 | - Extensive update of README 236 | 237 | ### Fixed 238 | 239 | - Fix omission of providing preceding commit hash when requesting a new commit from Git adapter 240 | 241 | ## [0.5.0] - 2022-11-01 242 | 243 | ### Changed 244 | 245 | - Move NPM package to organization namespace 246 | - Update to organization-based `git-adapter` 247 | 248 | ## [0.4.1] - 2022-10-25 249 | 250 | ### Changed 251 | 252 | - Update to Git Adapter interface 0.4.0 253 | 254 | ## [0.4.0] - 2022-10-25 255 | 256 | ### Changed 257 | 258 | - Move responsibility for Git adapter lifecycle out of library 259 | - Make type query use commit hash for better performance 260 | 261 | ## [0.3.0] - 2022-10-24 262 | 263 | ### Changed 264 | 265 | - Drop NestJS in favor of awilix due to https://github.com/nestjs/nest/issues/9622 266 | 267 | ## [0.2.1] - 2022-10-11 268 | 269 | ### Fixed 270 | 271 | - Export signature of `ApiService` 272 | 273 | ### Changed 274 | 275 | - Move example application into [separate repository](https://github.com/commitspark/example-code-serverless) 276 | 277 | ## [0.2.0] - 2022-10-07 278 | 279 | ### Changed 280 | 281 | - Move GitLab (SaaS) implementation into [separate repository](https://github.com/commitspark/git-adapter-gitlab) 282 | - Refactor code to be used as library 283 | - Move application-specific code to example directory 284 | - Upgrade to NestJS 9 285 | - Refactor code to be truly stateless 286 | 287 | ## [0.1.0] - 2022-07-15 288 | 289 | ### Added 290 | 291 | - Initial release 292 | -------------------------------------------------------------------------------- /tests/integration/mutation/delete.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Commit, 3 | CommitDraft, 4 | Entry, 5 | GitAdapter, 6 | } from '@commitspark/git-adapter' 7 | import { Matcher, mock } from 'jest-mock-extended' 8 | import { createClient } from '../../../src' 9 | 10 | describe('"Delete" mutation resolvers', () => { 11 | it('should delete an entry', async () => { 12 | const gitAdapter = mock() 13 | const gitRef = 'myRef' 14 | const commitHash = 'abcd' 15 | const originalSchema = `directive @Entry on OBJECT 16 | 17 | type EntryA @Entry { 18 | id: ID! 19 | name: String 20 | }` 21 | 22 | const commitMessage = 'My message' 23 | const entryAId = 'A' 24 | const postCommitHash = 'ef01' 25 | 26 | const commitResult: Commit = { 27 | ref: postCommitHash, 28 | } 29 | const entry: Entry = { 30 | id: entryAId, 31 | metadata: { 32 | type: 'EntryA', 33 | }, 34 | data: { 35 | name: 'My name', 36 | }, 37 | } 38 | 39 | const commitDraft: CommitDraft = { 40 | ref: gitRef, 41 | parentSha: commitHash, 42 | entries: [ 43 | { 44 | ...entry, 45 | deletion: true, 46 | }, 47 | ], 48 | message: commitMessage, 49 | } 50 | 51 | const commitDraftMatcher = new Matcher((actualValue) => { 52 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 53 | }, '') 54 | 55 | gitAdapter.getLatestCommitHash 56 | .calledWith(gitRef) 57 | .mockResolvedValue(commitHash) 58 | gitAdapter.getSchema 59 | .calledWith(commitHash) 60 | .mockResolvedValue(originalSchema) 61 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue([entry]) 62 | gitAdapter.createCommit 63 | .calledWith(commitDraftMatcher) 64 | .mockResolvedValue(commitResult) 65 | 66 | const client = await createClient(gitAdapter) 67 | const result = await client.postGraphQL(gitRef, { 68 | query: `mutation ($id: ID!, $commitMessage: String!) { 69 | data: deleteEntryA(id: $id, commitMessage: $commitMessage) 70 | }`, 71 | variables: { 72 | id: entryAId, 73 | commitMessage: commitMessage, 74 | }, 75 | }) 76 | 77 | expect(result.errors).toBeUndefined() 78 | expect(result.data).toEqual({ 79 | data: entryAId, 80 | }) 81 | expect(result.ref).toBe(postCommitHash) 82 | }) 83 | 84 | it('should return an error when trying to delete a non-existent entry', async () => { 85 | const gitAdapter = mock() 86 | const gitRef = 'myRef' 87 | const commitHash = 'abcd' 88 | const originalSchema = `directive @Entry on OBJECT 89 | 90 | type EntryA @Entry { 91 | id: ID! 92 | name: String 93 | }` 94 | 95 | const commitMessage = 'My message' 96 | const entryAId = 'A' 97 | 98 | gitAdapter.getLatestCommitHash 99 | .calledWith(gitRef) 100 | .mockResolvedValue(commitHash) 101 | gitAdapter.getSchema 102 | .calledWith(commitHash) 103 | .mockResolvedValue(originalSchema) 104 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue([]) 105 | 106 | const client = await createClient(gitAdapter) 107 | const result = await client.postGraphQL(gitRef, { 108 | query: `mutation ($id: ID!, $commitMessage: String!) { 109 | data: deleteEntryA(id: $id, commitMessage: $commitMessage) 110 | }`, 111 | variables: { 112 | id: entryAId, 113 | commitMessage: commitMessage, 114 | }, 115 | }) 116 | 117 | expect(result.errors).toMatchObject([ 118 | { 119 | extensions: { 120 | code: 'NOT_FOUND', 121 | commitspark: { 122 | argumentName: 'id', 123 | argumentValue: entryAId, 124 | }, 125 | }, 126 | }, 127 | ]) 128 | expect(result.data).toEqual({ data: null }) 129 | expect(result.ref).toBe(commitHash) 130 | }) 131 | 132 | it('should return an error when trying to delete an entry that is referenced elsewhere', async () => { 133 | const gitAdapter = mock() 134 | const gitRef = 'myRef' 135 | const commitHash = 'abcd' 136 | const originalSchema = `directive @Entry on OBJECT 137 | 138 | type EntryA @Entry { 139 | id: ID! 140 | reference: EntryB! 141 | } 142 | 143 | type EntryB @Entry { 144 | id: ID! 145 | }` 146 | 147 | const commitMessage = 'My message' 148 | const entryAId = 'A' 149 | const entryBId = 'B' 150 | 151 | const entries: Entry[] = [ 152 | { 153 | id: entryAId, 154 | metadata: { 155 | type: 'EntryA', 156 | }, 157 | data: { 158 | reference: { 159 | id: entryBId, 160 | }, 161 | }, 162 | }, 163 | { 164 | id: entryBId, 165 | metadata: { 166 | type: 'EntryB', 167 | referencedBy: [entryAId], 168 | }, 169 | }, 170 | ] 171 | 172 | gitAdapter.getLatestCommitHash 173 | .calledWith(gitRef) 174 | .mockResolvedValue(commitHash) 175 | gitAdapter.getSchema 176 | .calledWith(commitHash) 177 | .mockResolvedValue(originalSchema) 178 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 179 | 180 | const client = await createClient(gitAdapter) 181 | const result = await client.postGraphQL(gitRef, { 182 | query: `mutation ($id: ID!, $commitMessage: String!) { 183 | data: deleteEntryB(id: $id, commitMessage: $commitMessage) 184 | }`, 185 | variables: { 186 | id: entryBId, 187 | commitMessage: commitMessage, 188 | }, 189 | }) 190 | 191 | expect(result.errors).toMatchObject([ 192 | { 193 | extensions: { 194 | code: 'IN_USE', 195 | commitspark: { 196 | argumentName: 'id', 197 | argumentValue: entryBId, 198 | }, 199 | }, 200 | }, 201 | ]) 202 | expect(result.data).toEqual({ data: null }) 203 | expect(result.ref).toBe(commitHash) 204 | }) 205 | 206 | it('should remove references from metadata of other entries when deleting an entry', async () => { 207 | const gitAdapter = mock() 208 | const gitRef = 'myRef' 209 | const commitHash = 'abcd' 210 | const originalSchema = `directive @Entry on OBJECT 211 | 212 | type Item @Entry { 213 | id: ID! 214 | box: Box! 215 | } 216 | 217 | type Box @Entry { 218 | id: ID! 219 | }` 220 | 221 | const commitMessage = 'My message' 222 | const boxId = 'box' 223 | const item1Id = 'item1' 224 | const item2Id = 'item2' 225 | const postCommitHash = 'ef01' 226 | 227 | const commitResult: Commit = { 228 | ref: postCommitHash, 229 | } 230 | const box: Entry = { 231 | id: boxId, 232 | metadata: { 233 | type: 'Box', 234 | referencedBy: [item1Id, item2Id], 235 | }, 236 | } 237 | const item1: Entry = { 238 | id: item1Id, 239 | metadata: { 240 | type: 'Item', 241 | }, 242 | data: { 243 | box: { id: boxId }, 244 | }, 245 | } 246 | const item2: Entry = { 247 | id: item2Id, 248 | metadata: { 249 | type: 'Item', 250 | }, 251 | data: { 252 | box: { id: boxId }, 253 | }, 254 | } 255 | const updatedBox: Entry = { 256 | id: boxId, 257 | metadata: { 258 | type: 'Box', 259 | referencedBy: [item2Id], 260 | }, 261 | } 262 | 263 | const commitDraft: CommitDraft = { 264 | ref: gitRef, 265 | parentSha: commitHash, 266 | entries: [ 267 | { ...item1, deletion: true }, 268 | { ...updatedBox, deletion: false }, 269 | ], 270 | message: commitMessage, 271 | } 272 | 273 | const commitDraftMatcher = new Matcher((actualValue) => { 274 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 275 | }, '') 276 | 277 | gitAdapter.getLatestCommitHash 278 | .calledWith(gitRef) 279 | .mockResolvedValue(commitHash) 280 | gitAdapter.getSchema 281 | .calledWith(commitHash) 282 | .mockResolvedValue(originalSchema) 283 | gitAdapter.getEntries 284 | .calledWith(commitHash) 285 | .mockResolvedValue([box, item1, item2]) 286 | gitAdapter.createCommit 287 | .calledWith(commitDraftMatcher) 288 | .mockResolvedValue(commitResult) 289 | gitAdapter.getEntries 290 | .calledWith(postCommitHash) 291 | .mockResolvedValue([updatedBox, item2]) 292 | 293 | const client = await createClient(gitAdapter) 294 | const result = await client.postGraphQL(gitRef, { 295 | query: `mutation ($id: ID!, $commitMessage: String!) { 296 | data: deleteItem(id: $id, commitMessage: $commitMessage) 297 | }`, 298 | variables: { 299 | id: item1Id, 300 | commitMessage: commitMessage, 301 | }, 302 | }) 303 | 304 | expect(result.errors).toBeUndefined() 305 | expect(result.data).toEqual({ 306 | data: item1Id, 307 | }) 308 | expect(result.ref).toBe(postCommitHash) 309 | }) 310 | }) 311 | -------------------------------------------------------------------------------- /tests/integration/schema/schema-generator.test.ts: -------------------------------------------------------------------------------- 1 | import { GitAdapter } from '@commitspark/git-adapter' 2 | import { mock } from 'jest-mock-extended' 3 | import { createClient } from '../../../src' 4 | 5 | describe('Schema generator', () => { 6 | it('should extend schema with a CRUD API for an @Entry type', async () => { 7 | const gitAdapter = mock() 8 | const gitRef = 'myRef' 9 | const commitHash = 'abcd' 10 | const originalSchema = `directive @Entry on OBJECT 11 | directive @Ui(visibleList:Boolean) on FIELD_DEFINITION 12 | 13 | type MyEntry @Entry { 14 | id: ID! 15 | name: String @Ui(visibleList:true) 16 | nestedType: NestedType 17 | } 18 | 19 | type NestedType { 20 | nestedField: String 21 | }` 22 | 23 | gitAdapter.getLatestCommitHash 24 | .calledWith(gitRef) 25 | .mockResolvedValue(commitHash) 26 | gitAdapter.getSchema 27 | .calledWith(commitHash) 28 | .mockResolvedValue(originalSchema) 29 | 30 | const client = await createClient(gitAdapter) 31 | const result = await client.getSchema(gitRef) 32 | 33 | const expectedSchema = `directive @Entry on OBJECT 34 | 35 | directive @Ui(visibleList: Boolean) on FIELD_DEFINITION 36 | 37 | type MyEntry @Entry { 38 | id: ID! 39 | name: String @Ui(visibleList: true) 40 | nestedType: NestedType 41 | } 42 | 43 | type NestedType { 44 | nestedField: String 45 | } 46 | 47 | schema { 48 | query: Query 49 | mutation: Mutation 50 | } 51 | 52 | type Query { 53 | everyMyEntry: [MyEntry!] 54 | MyEntry(id: ID!): MyEntry 55 | _typeName(id: ID!): String! 56 | } 57 | 58 | type Mutation { 59 | createMyEntry(id: ID!, data: MyEntryInput!, commitMessage: String): MyEntry 60 | updateMyEntry(id: ID!, data: MyEntryInput!, commitMessage: String): MyEntry 61 | deleteMyEntry(id: ID!, commitMessage: String): ID 62 | } 63 | 64 | input MyEntryIdInput { 65 | id: ID! 66 | } 67 | 68 | input MyEntryInput { 69 | name: String 70 | nestedType: NestedTypeInput 71 | } 72 | 73 | input NestedTypeInput { 74 | nestedField: String 75 | } 76 | ` 77 | 78 | expect(result.data).toBe(expectedSchema) 79 | expect(result.ref).toBe(commitHash) 80 | }) 81 | 82 | it('should extend schema with a CRUD API for two @Entry types with reference', async () => { 83 | const gitAdapter = mock() 84 | const gitRef = 'myRef' 85 | const commitHash = 'abcd' 86 | const originalSchema = `directive @Entry on OBJECT 87 | 88 | type EntryA @Entry { 89 | id: ID! 90 | name: String 91 | } 92 | 93 | type EntryB @Entry { 94 | id: ID! 95 | entryA: EntryA 96 | }` 97 | 98 | gitAdapter.getLatestCommitHash 99 | .calledWith(gitRef) 100 | .mockResolvedValue(commitHash) 101 | gitAdapter.getSchema 102 | .calledWith(commitHash) 103 | .mockResolvedValue(originalSchema) 104 | 105 | const client = await createClient(gitAdapter) 106 | const result = await client.getSchema(gitRef) 107 | 108 | const expectedSchema = `directive @Entry on OBJECT 109 | 110 | type EntryA @Entry { 111 | id: ID! 112 | name: String 113 | } 114 | 115 | type EntryB @Entry { 116 | id: ID! 117 | entryA: EntryA 118 | } 119 | 120 | schema { 121 | query: Query 122 | mutation: Mutation 123 | } 124 | 125 | type Query { 126 | everyEntryA: [EntryA!] 127 | everyEntryB: [EntryB!] 128 | EntryA(id: ID!): EntryA 129 | EntryB(id: ID!): EntryB 130 | _typeName(id: ID!): String! 131 | } 132 | 133 | type Mutation { 134 | createEntryA(id: ID!, data: EntryAInput!, commitMessage: String): EntryA 135 | createEntryB(id: ID!, data: EntryBInput!, commitMessage: String): EntryB 136 | updateEntryA(id: ID!, data: EntryAInput!, commitMessage: String): EntryA 137 | updateEntryB(id: ID!, data: EntryBInput!, commitMessage: String): EntryB 138 | deleteEntryA(id: ID!, commitMessage: String): ID 139 | deleteEntryB(id: ID!, commitMessage: String): ID 140 | } 141 | 142 | input EntryAIdInput { 143 | id: ID! 144 | } 145 | 146 | input EntryBIdInput { 147 | id: ID! 148 | } 149 | 150 | input EntryAInput { 151 | name: String 152 | } 153 | 154 | input EntryBInput { 155 | entryA: EntryAIdInput 156 | } 157 | ` 158 | 159 | expect(result.data).toBe(expectedSchema) 160 | expect(result.ref).toBe(commitHash) 161 | }) 162 | 163 | it('should extend schema with a CRUD API for an @Entry-based union type', async () => { 164 | const gitAdapter = mock() 165 | const gitRef = 'myRef' 166 | const commitHash = 'abcd' 167 | const originalSchema = `directive @Entry on OBJECT 168 | 169 | type MyEntry @Entry { 170 | id: ID! 171 | union: MyUnion 172 | } 173 | 174 | union MyUnion = 175 | | EntryA 176 | | EntryB 177 | 178 | type EntryA @Entry { 179 | id: ID! 180 | field1: String 181 | } 182 | 183 | type EntryB @Entry { 184 | id: ID! 185 | field2: String 186 | }` 187 | 188 | gitAdapter.getLatestCommitHash 189 | .calledWith(gitRef) 190 | .mockResolvedValue(commitHash) 191 | gitAdapter.getSchema 192 | .calledWith(commitHash) 193 | .mockResolvedValue(originalSchema) 194 | 195 | const client = await createClient(gitAdapter) 196 | const result = await client.getSchema(gitRef) 197 | 198 | const expectedSchema = `directive @Entry on OBJECT 199 | 200 | type MyEntry @Entry { 201 | id: ID! 202 | union: MyUnion 203 | } 204 | 205 | union MyUnion = EntryA | EntryB 206 | 207 | type EntryA @Entry { 208 | id: ID! 209 | field1: String 210 | } 211 | 212 | type EntryB @Entry { 213 | id: ID! 214 | field2: String 215 | } 216 | 217 | schema { 218 | query: Query 219 | mutation: Mutation 220 | } 221 | 222 | type Query { 223 | everyMyEntry: [MyEntry!] 224 | everyEntryA: [EntryA!] 225 | everyEntryB: [EntryB!] 226 | MyEntry(id: ID!): MyEntry 227 | EntryA(id: ID!): EntryA 228 | EntryB(id: ID!): EntryB 229 | _typeName(id: ID!): String! 230 | } 231 | 232 | type Mutation { 233 | createMyEntry(id: ID!, data: MyEntryInput!, commitMessage: String): MyEntry 234 | createEntryA(id: ID!, data: EntryAInput!, commitMessage: String): EntryA 235 | createEntryB(id: ID!, data: EntryBInput!, commitMessage: String): EntryB 236 | updateMyEntry(id: ID!, data: MyEntryInput!, commitMessage: String): MyEntry 237 | updateEntryA(id: ID!, data: EntryAInput!, commitMessage: String): EntryA 238 | updateEntryB(id: ID!, data: EntryBInput!, commitMessage: String): EntryB 239 | deleteMyEntry(id: ID!, commitMessage: String): ID 240 | deleteEntryA(id: ID!, commitMessage: String): ID 241 | deleteEntryB(id: ID!, commitMessage: String): ID 242 | } 243 | 244 | input MyEntryIdInput { 245 | id: ID! 246 | } 247 | 248 | input EntryAIdInput { 249 | id: ID! 250 | } 251 | 252 | input EntryBIdInput { 253 | id: ID! 254 | } 255 | 256 | input MyUnionIdInput { 257 | id: ID! 258 | } 259 | 260 | input MyEntryInput { 261 | union: MyUnionIdInput 262 | } 263 | 264 | input EntryAInput { 265 | field1: String 266 | } 267 | 268 | input EntryBInput { 269 | field2: String 270 | } 271 | ` 272 | 273 | expect(result.data).toBe(expectedSchema) 274 | expect(result.ref).toBe(commitHash) 275 | }) 276 | 277 | it('should create a CRUD API for an inline union type', async () => { 278 | const gitAdapter = mock() 279 | const gitRef = 'myRef' 280 | const commitHash = 'abcd' 281 | const originalSchema = `directive @Entry on OBJECT 282 | 283 | type MyEntry @Entry { 284 | id: ID! 285 | union: MyUnion 286 | } 287 | 288 | union MyUnion = 289 | | TypeA 290 | | TypeB 291 | 292 | type TypeA { 293 | field1: String 294 | } 295 | 296 | type TypeB { 297 | field2: String 298 | }` 299 | 300 | gitAdapter.getLatestCommitHash 301 | .calledWith(gitRef) 302 | .mockResolvedValue(commitHash) 303 | gitAdapter.getSchema 304 | .calledWith(commitHash) 305 | .mockResolvedValue(originalSchema) 306 | 307 | const client = await createClient(gitAdapter) 308 | const result = await client.getSchema(gitRef) 309 | 310 | const expectedSchema = `directive @Entry on OBJECT 311 | 312 | type MyEntry @Entry { 313 | id: ID! 314 | union: MyUnion 315 | } 316 | 317 | union MyUnion = TypeA | TypeB 318 | 319 | type TypeA { 320 | field1: String 321 | } 322 | 323 | type TypeB { 324 | field2: String 325 | } 326 | 327 | schema { 328 | query: Query 329 | mutation: Mutation 330 | } 331 | 332 | type Query { 333 | everyMyEntry: [MyEntry!] 334 | MyEntry(id: ID!): MyEntry 335 | _typeName(id: ID!): String! 336 | } 337 | 338 | type Mutation { 339 | createMyEntry(id: ID!, data: MyEntryInput!, commitMessage: String): MyEntry 340 | updateMyEntry(id: ID!, data: MyEntryInput!, commitMessage: String): MyEntry 341 | deleteMyEntry(id: ID!, commitMessage: String): ID 342 | } 343 | 344 | input MyEntryIdInput { 345 | id: ID! 346 | } 347 | 348 | input MyUnionIdInput { 349 | id: ID! 350 | } 351 | 352 | input MyEntryInput { 353 | union: MyUnionInput 354 | } 355 | 356 | input TypeAInput { 357 | field1: String 358 | } 359 | 360 | input TypeBInput { 361 | field2: String 362 | } 363 | 364 | input MyUnionInput @oneOf { 365 | TypeA: TypeAInput 366 | TypeB: TypeBInput 367 | } 368 | 369 | directive @oneOf on INPUT_OBJECT 370 | ` 371 | 372 | expect(result.data).toBe(expectedSchema) 373 | expect(result.ref).toBe(commitHash) 374 | }) 375 | }) 376 | -------------------------------------------------------------------------------- /tests/unit/persistence/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { createCacheHandler } from '../../../src/persistence/cache' 2 | import type { ApolloContext } from '../../../src/client' 3 | import { 4 | Entry, 5 | ErrorCode, 6 | GitAdapter, 7 | GitAdapterError, 8 | } from '@commitspark/git-adapter' 9 | 10 | jest.mock('../../../src/graphql/errors', () => { 11 | return { 12 | createError: jest.fn( 13 | (message: string, code: string, extensions: Record) => { 14 | // mark unused parameters as used to satisfy eslint no-unused-vars 15 | void message 16 | void code 17 | void extensions 18 | return new Error(`wrapped-error`) 19 | }, 20 | ), 21 | } 22 | }) 23 | 24 | const { createError } = jest.requireMock('../../../src/graphql/errors') as { 25 | createError: jest.Mock 26 | } 27 | 28 | const makeContext = (getEntriesImpl: (ref: string) => Promise) => { 29 | // partial mocks 30 | const gitAdapter = { 31 | getEntries: jest.fn(getEntriesImpl), 32 | } as unknown as GitAdapter 33 | const context: ApolloContext = { 34 | gitAdapter: gitAdapter, 35 | } as unknown as ApolloContext 36 | 37 | return { context, gitAdapter } 38 | } 39 | 40 | const makeContextForSchema = ( 41 | getSchemaImpl: (ref: string) => Promise, 42 | ) => { 43 | // partial mocks 44 | const gitAdapter = { 45 | getSchema: jest.fn(getSchemaImpl), 46 | } as unknown as GitAdapter 47 | const context: ApolloContext = { 48 | gitAdapter: gitAdapter, 49 | } as unknown as ApolloContext 50 | 51 | return { context, gitAdapter } 52 | } 53 | 54 | describe('Cache', () => { 55 | beforeEach(() => { 56 | jest.clearAllMocks() 57 | }) 58 | 59 | it('fetches gitAdapter entries only on cache miss', async () => { 60 | const entries: Entry[] = [ 61 | { id: '1', metadata: { type: 'A' } }, 62 | { id: '2', metadata: { type: 'B' } }, 63 | ] 64 | const { context, gitAdapter } = makeContext(async () => entries) 65 | const cache = createCacheHandler() 66 | 67 | const record1 = await cache.getEntriesRecord(context, 'ref-1') 68 | expect(gitAdapter.getEntries).toHaveBeenCalledTimes(1) 69 | expect(gitAdapter.getEntries).toHaveBeenLastCalledWith('ref-1') 70 | 71 | const record2 = await cache.getEntriesRecord(context, 'ref-1') 72 | // still one call: second is a cache hit 73 | expect(gitAdapter.getEntries).toHaveBeenCalledTimes(1) 74 | // same object identity for Maps confirms cached result 75 | expect(record2.byId).toBe(record1.byId) 76 | expect(record2.byType).toBe(record1.byType) 77 | }) 78 | 79 | it('caches entries by ID and by type', async () => { 80 | const entry1: Entry = { id: 'id-1', metadata: { type: 'A' } } 81 | const entry2: Entry = { id: 'id-2', metadata: { type: 'A' } } 82 | const entry3: Entry = { id: 'id-3', metadata: { type: 'B' } } 83 | const { context } = makeContext(async () => [entry1, entry2, entry3]) 84 | const cache = createCacheHandler() 85 | 86 | const record = await cache.getEntriesRecord(context, 'ref-x') 87 | 88 | // byId 89 | expect(record.byId.get('id-1')).toBe(entry1) 90 | expect(record.byId.get('id-2')).toBe(entry2) 91 | expect(record.byId.get('id-3')).toBe(entry3) 92 | 93 | // byType with stable input order 94 | expect(record.byType.get('A')).toEqual([entry1, entry2]) 95 | expect(record.byType.get('B')).toEqual([entry3]) 96 | }) 97 | 98 | it('wraps GitAdapterError via createError', async () => { 99 | const errorCode = ErrorCode.NOT_FOUND 100 | const error = new GitAdapterError(errorCode, '') 101 | const { context } = makeContext(async () => { 102 | throw error 103 | }) 104 | const cache = createCacheHandler() 105 | 106 | await expect(cache.getEntriesRecord(context, 'ref-err')).rejects.toEqual( 107 | // our mocked createError returns this wrapped Error instance 108 | expect.objectContaining({ message: 'wrapped-error' }), 109 | ) 110 | 111 | expect(createError).toHaveBeenCalledTimes(1) 112 | expect(createError).toHaveBeenCalledWith('', errorCode, {}) 113 | }) 114 | 115 | it('evicts using LRU (least recently used) order', async () => { 116 | const { context, gitAdapter } = makeContext(async () => []) 117 | const CACHE_SIZE = 5 118 | const cache = createCacheHandler(CACHE_SIZE) 119 | 120 | // ensure full cache 121 | for (let i = 0; i < CACHE_SIZE; i++) { 122 | await cache.getEntriesRecord(context, `r${i}`) 123 | } 124 | 125 | expect(gitAdapter.getEntries).toHaveBeenCalledTimes(CACHE_SIZE) 126 | 127 | // Access the oldest key (r0) so it should now be the most recently used 128 | await cache.getEntriesRecord(context, 'r0') // cache hit, no new call 129 | expect(gitAdapter.getEntries).toHaveBeenCalledTimes(CACHE_SIZE) 130 | 131 | // Add a new key to trigger eviction of the current oldest (r1) 132 | await cache.getEntriesRecord(context, 'r-new') 133 | expect(gitAdapter.getEntries).toHaveBeenCalledTimes(CACHE_SIZE + 1) 134 | 135 | // Now, r0 should still be cached, r1 should have been evicted 136 | await cache.getEntriesRecord(context, 'r0') 137 | expect(gitAdapter.getEntries).toHaveBeenCalledTimes(CACHE_SIZE + 1) 138 | 139 | // Asking for r1 now should miss and trigger a re-fetch 140 | await cache.getEntriesRecord(context, 'r1') 141 | expect(gitAdapter.getEntries).toHaveBeenCalledTimes(CACHE_SIZE + 2) 142 | }) 143 | 144 | it('fetches gitAdapter schema only on cache miss', async () => { 145 | const schema = 'type Query { ok: Boolean! }' 146 | const { context, gitAdapter } = makeContextForSchema(async () => schema) 147 | const cache = createCacheHandler() 148 | 149 | const s1 = await cache.getSchema(context, 'ref-1') 150 | expect(gitAdapter.getSchema).toHaveBeenCalledTimes(1) 151 | expect(gitAdapter.getSchema).toHaveBeenLastCalledWith('ref-1') 152 | expect(s1).toBe(schema) 153 | 154 | const s2 = await cache.getSchema(context, 'ref-1') 155 | // still one call: second is a cache hit 156 | expect(gitAdapter.getSchema).toHaveBeenCalledTimes(1) 157 | expect(s2).toBe(schema) 158 | }) 159 | 160 | it('wraps GitAdapterError via createError for schema', async () => { 161 | const errorCode = ErrorCode.NOT_FOUND 162 | const error = new GitAdapterError(errorCode, '') 163 | const { context } = makeContextForSchema(async () => { 164 | throw error 165 | }) 166 | const cache = createCacheHandler() 167 | 168 | await expect(cache.getSchema(context, 'ref-err')).rejects.toEqual( 169 | // our mocked createError returns this wrapped Error instance 170 | expect.objectContaining({ message: 'wrapped-error' }), 171 | ) 172 | 173 | expect(createError).toHaveBeenCalledTimes(1) 174 | expect(createError).toHaveBeenCalledWith('', errorCode, {}) 175 | }) 176 | 177 | it('evicts schema using LRU (least recently used) order', async () => { 178 | const { context, gitAdapter } = makeContextForSchema(async () => 's') 179 | const CACHE_SIZE = 5 180 | const cache = createCacheHandler(CACHE_SIZE) 181 | 182 | // ensure full cache 183 | for (let i = 0; i < CACHE_SIZE; i++) { 184 | await cache.getSchema(context, `r${i}`) 185 | } 186 | 187 | expect(gitAdapter.getSchema).toHaveBeenCalledTimes(CACHE_SIZE) 188 | 189 | // Access the oldest key (r0) so it should now be the most recently used 190 | await cache.getSchema(context, 'r0') // cache hit, no new call 191 | expect(gitAdapter.getSchema).toHaveBeenCalledTimes(CACHE_SIZE) 192 | 193 | // Add a new key to trigger eviction of the current oldest (r1) 194 | await cache.getSchema(context, 'r-new') 195 | expect(gitAdapter.getSchema).toHaveBeenCalledTimes(CACHE_SIZE + 1) 196 | 197 | // Now, r0 should still be cached, r1 should have been evicted 198 | await cache.getSchema(context, 'r0') 199 | expect(gitAdapter.getSchema).toHaveBeenCalledTimes(CACHE_SIZE + 1) 200 | 201 | // Asking for r1 now should miss and trigger a re-fetch 202 | await cache.getSchema(context, 'r1') 203 | expect(gitAdapter.getSchema).toHaveBeenCalledTimes(CACHE_SIZE + 2) 204 | }) 205 | 206 | it('serves the same in-flight promise to concurrent calls from resolvers', async () => { 207 | let resolveEntries!: (value: Entry[]) => void 208 | const entriesPromise = new Promise((resolve) => { 209 | resolveEntries = resolve 210 | }) 211 | 212 | const { context, gitAdapter } = makeContext(async () => entriesPromise) 213 | const cache = createCacheHandler() 214 | 215 | const p1 = cache.getEntriesRecord(context, 'ref-concurrent') 216 | const p2 = cache.getEntriesRecord(context, 'ref-concurrent') 217 | 218 | expect(gitAdapter.getEntries).toHaveBeenCalledTimes(1) 219 | expect(gitAdapter.getEntries).toHaveBeenLastCalledWith('ref-concurrent') 220 | expect(p1).toBe(p2) 221 | 222 | const entries: Entry[] = [ 223 | { id: '1', metadata: { type: 'A' } }, 224 | { id: '2', metadata: { type: 'B' } }, 225 | ] 226 | resolveEntries(entries) 227 | 228 | const [record1, record2] = await Promise.all([p1, p2]) 229 | expect(record2.byId).toBe(record1.byId) 230 | expect(record2.byType).toBe(record1.byType) 231 | 232 | expect(gitAdapter.getEntries).toHaveBeenCalledTimes(1) 233 | }) 234 | }) 235 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | [Commitspark](https://commitspark.com) is a set of tools to manage structured data with Git through a GraphQL API. 4 | 5 | This library provides the GraphQL API that allows reading and writing structured data (entries) from and to a Git 6 | repository. 7 | 8 | Queries and mutations offered by the API are determined by a standard GraphQL type definition file (schema) inside the 9 | Git repository. 10 | 11 | Entries (data) are stored using plain YAML text files in the same Git repository. No other data store is needed. 12 | 13 | # Installation 14 | 15 | There are two common ways to use this library: 16 | 17 | 1. By making GraphQL calls directly to the library as a code dependency in your own JavaScript / TypeScript / 18 | Node.js application. 19 | 20 | To do this, simply install the library with 21 | 22 | ```shell 23 | npm i @commitspark/graphql-api 24 | ``` 25 | 2. By making GraphQL calls over HTTP to this library wrapped in a webserver or Lambda function of choice. 26 | 27 | Please see the [Node.js Express server example](https://github.com/commitspark/example-http-express) 28 | or [Lambda function example](https://github.com/commitspark/example-code-serverless) for details. 29 | 30 | ## Installing Git provider support 31 | 32 | This library is agnostic to where a Git repository is stored and relies on separate adapters for repository access. To 33 | access a Git repository, use one of the pre-built adapters listed below or build your own using the interfaces 34 | in [this repository](https://github.com/commitspark/git-adapter). 35 | 36 | | Adapter | Description | Install with | 37 | |---------------------------------------------------------------------|------------------------------------------------------------|---------------------------------------------| 38 | | [GitHub](https://github.com/commitspark/git-adapter-github) | Provides support for Git repositories hosted on github.com | `npm i @commitspark/git-adapter-github` | 39 | | [GitLab (SaaS)](https://github.com/commitspark/git-adapter-gitlab) | Provides support for Git repositories hosted on gitlab.com | `npm i @commitspark/git-adapter-gitlab` | 40 | | [Filesystem](https://github.com/commitspark/git-adapter-filesystem) | Provides read-only access to files on the filesystem level | `npm i @commitspark/git-adapter-filesystem` | 41 | 42 | # Building your GraphQL API 43 | 44 | Commitspark builds a GraphQL data management API with create, read, update, and delete (CRUD) functionality that is 45 | solely driven by data types you define in a standard GraphQL schema file in your Git repository. 46 | 47 | Commitspark achieves this by extending the types in your schema file at runtime with queries, mutations, and additional 48 | helper types. 49 | 50 | Let's assume you want to manage information about rocket flights and have already defined the following simple GraphQL 51 | schema in your Git repository: 52 | 53 | ```graphql 54 | # commitspark/schema/schema.graphql 55 | 56 | directive @Entry on OBJECT 57 | 58 | type RocketFlight @Entry { 59 | id: ID! 60 | vehicleName: String! 61 | payloads: [Payload!] 62 | } 63 | 64 | type Payload { 65 | weight: Int! 66 | } 67 | ``` 68 | 69 | At runtime, when sending a GraphQL request to Commitspark, these are the queries, mutations, and helper types that are 70 | added by Commitspark to your schema for the duration of request execution: 71 | 72 | ```graphql 73 | schema { 74 | query: Query 75 | mutation: Mutation 76 | } 77 | 78 | type Query { 79 | everyRocketFlight: [RocketFlight!] 80 | RocketFlight(id: ID!): RocketFlight 81 | _typeName(id: ID!): String 82 | } 83 | 84 | type Mutation { 85 | createRocketFlight(id: ID!, data: RocketFlightInput!, commitMessage: String): RocketFlight 86 | updateRocketFlight(id: ID!, data: RocketFlightInput!, commitMessage: String): RocketFlight 87 | deleteRocketFlight(id: ID!, commitMessage: String): ID 88 | } 89 | 90 | input RocketFlightInput { 91 | vehicleName: String! 92 | payloads: [PayloadInput!] 93 | } 94 | 95 | input PayloadInput { 96 | weight: Int! 97 | } 98 | ``` 99 | 100 | # Making GraphQL calls 101 | 102 | Let's now assume your repository is located on GitHub and you want to query for a single rocket flight. 103 | 104 | The code to do so could look like this: 105 | 106 | ```typescript 107 | import { createAdapter } from '@commitspark/git-adapter-github' 108 | import { createClient } from '@commitspark/graphql-api' 109 | 110 | const gitHubAdapter = createAdapter({ 111 | repositoryOwner: process.env.GITHUB_REPOSITORY_OWNER, 112 | repositoryName: process.env.GITHUB_REPOSITORY_NAME, 113 | accessToken: process.env.GITHUB_ACCESS_TOKEN, 114 | }) 115 | 116 | const client = await createClient(gitHubAdapter) 117 | 118 | const response = await client.postGraphQL( 119 | process.env.GIT_BRANCH ?? 'main', 120 | { 121 | query: `query ($rocketFlightId: ID!) { 122 | rocketFlight: RocketFlight(id: $rocketFlightId) { 123 | vehicleName 124 | payloads { 125 | weight 126 | } 127 | } 128 | }`, 129 | variables: { 130 | rocketFlightId: 'VA256', 131 | } 132 | }, 133 | ) 134 | 135 | const rocketFlight = response.data.rocketFlight 136 | // ... 137 | ``` 138 | 139 | # Technical documentation 140 | 141 | ## API 142 | 143 | ### Client 144 | 145 | #### postGraphQL() 146 | 147 | This function is used to make GraphQL requests. 148 | 149 | Request execution is handled by ApolloServer behind the scenes. 150 | 151 | Argument `request` expects a conventional GraphQL query and supports query variables as well as introspection. 152 | 153 | #### getSchema() 154 | 155 | This function allows retrieving the GraphQL schema extended by Commitspark as a string. 156 | 157 | Compared to schema data obtained through GraphQL introspection, the schema returned by this function also includes 158 | directive declarations and annotations, allowing for development of additional tools that require this information. 159 | 160 | ## Picking from the Git tree 161 | 162 | As Commitspark is Git-based, all GraphQL requests support traversing the Git commit tree by setting the `ref` argument 163 | in library calls to a 164 | 165 | * ref (i.e. commit hash), 166 | * branch name, or 167 | * tag name (light or regular) 168 | 169 | This enables great flexibility, e.g. to use branches in order to enable data (entry) development workflows, to retrieve 170 | a specific (historic) commit where it is guaranteed that entries are immutable, or to retrieve entries by tag such as 171 | one that marks the latest reviewed and approved version in a repository. 172 | 173 | ### Writing data 174 | 175 | Mutation operations work on branch names only and (when successful) each append a new commit on 176 | HEAD in the given branch. 177 | 178 | To guarantee deterministic results, mutations in calls with multiple mutations are processed sequentially (see 179 | the [official GraphQL documentation](https://graphql.org/learn/queries/#multiple-fields-in-mutations) for details). 180 | 181 | ## Data model 182 | 183 | The data model (i.e. schema) is defined in a single GraphQL type definition text file using the 184 | [GraphQL type system](https://graphql.org/learn/schema/). 185 | 186 | The schema file must be located at `commitspark/schema/schema.graphql` inside the Git repository (unless otherwise 187 | configured in your Git adapter). 188 | 189 | Commitspark currently supports the following GraphQL types: 190 | 191 | * `type` 192 | * `union` 193 | * `enum` 194 | 195 | ### Data entries 196 | 197 | To denote which data is to be given a unique identity for referencing, Commitspark expects type annotation with 198 | directive `@Entry`: 199 | 200 | ```graphql 201 | directive @Entry on OBJECT # Important: You must declare this for your schema to be valid 202 | 203 | type MyType @Entry { 204 | id: ID! # Important: Any type annotated with `@Entry` must have such a field 205 | # ... 206 | } 207 | ``` 208 | 209 | **Note:** As a general guideline, you should only apply `@Entry` to data types that meet one of the following 210 | conditions: 211 | 212 | * You want to independently create and query instances of this type 213 | * You want to reference or link to an instance of such a type from multiple other entries 214 | 215 | This keeps the number of entries low and performance up. 216 | 217 | ## Entry storage 218 | 219 | Entries, i.e. instances of data types annotated with `@Entry`, are stored as `.yaml` YAML text files inside 220 | folder `commitspark/entries/` in the given Git repository (unless otherwise configured in your Git adapter). 221 | 222 | The filename (excluding file extension) constitutes the entry ID. 223 | 224 | Entry files have the following structure: 225 | 226 | ```yaml 227 | metadata: 228 | type: MyType # name of type as defined in your schema 229 | referencedBy: [ ] # array of entry IDs that hold a reference to this entry 230 | data: 231 | # ... fields of the type as defined in your schema 232 | ``` 233 | 234 | ### Serialization / Deserialization 235 | 236 | #### References 237 | 238 | References to types annotated with `@Entry` are serialized using a sub-field `id`. 239 | 240 | For example, consider this variation of our rocket flight schema above: 241 | 242 | ```graphql 243 | type RocketFlight @Entry { 244 | id: ID! 245 | operator: Operator 246 | } 247 | 248 | type Operator @Entry { 249 | id: ID! 250 | fullName: String! 251 | } 252 | ``` 253 | 254 | An entry YAML file for a `RocketFlight` with ID `VA256` referencing an `Operator` with ID `Arianespace` will look 255 | like this: 256 | 257 | ```yaml 258 | # commitspark/entries/VA256.yaml 259 | metadata: 260 | type: RocketFlight 261 | referencedBy: [ ] 262 | data: 263 | operator: 264 | id: Arianespace 265 | ``` 266 | 267 | The YAML file of referenced `Operator` with ID `Arianespace` will then look like this: 268 | 269 | ```yaml 270 | # commitspark/entries/Arianespace.yaml 271 | metadata: 272 | type: Operator 273 | referencedBy: 274 | - VA256 275 | data: 276 | fullName: Arianespace SA 277 | ``` 278 | 279 | When this data is deserialized, Commitspark transparently resolves references to other `@Entry` instances, allowing for 280 | retrieval of complex, linked data in a single query such as this one: 281 | 282 | ```graphql 283 | query { 284 | RocketFlight(id: "VA256") { 285 | id 286 | operator { 287 | fullName 288 | } 289 | } 290 | } 291 | ``` 292 | 293 | This returns the following data: 294 | 295 | ```json 296 | { 297 | "id": "VA256", 298 | "operator": { 299 | "fullName": "Arianespace SA" 300 | } 301 | } 302 | ``` 303 | 304 | #### Unions 305 | 306 | Consider this example of a schema for storing content for a marketing website built out of modular content elements, 307 | where field `contentElements` is an array of Union type `ContentElement`, allowing different concrete types `Hero` or 308 | `Text` to be applied: 309 | 310 | ```graphql 311 | type Page @Entry { 312 | id: ID! 313 | contentElements: [ContentElement!] 314 | } 315 | 316 | union ContentElement = 317 | | Hero 318 | | Text 319 | 320 | type Hero { 321 | heroText: String! 322 | } 323 | 324 | type Text { 325 | bodyText: String! 326 | } 327 | ``` 328 | 329 | During serialization, concrete type instances are represented through an additional nested level of data, using the 330 | concrete instance's type name as field name: 331 | 332 | ```yaml 333 | metadata: 334 | type: Page 335 | referencedBy: [ ] 336 | data: 337 | contentElements: 338 | - Hero: 339 | heroText: "..." 340 | - Text: 341 | bodyText: "..." 342 | ``` 343 | 344 | When querying data through the API, this additional level of nesting is transparently removed and not visible. 345 | 346 | # Error handling 347 | 348 | Instead of throwing errors, this library catches known error cases and returns error information for GraphQL calls via 349 | the `errors` response field. The type of error is indicated in error field `extensions.code`, with additional 350 | information in error field `extensions.commitspark` (where available). This allows API callers to determine the cause of 351 | errors and take appropriate action. 352 | 353 | Example GraphQL response with error: 354 | 355 | ```json 356 | { 357 | "errors": [ 358 | { 359 | "message": "No entry with ID \"SOME_UNKNOWN_ID\" exists.", 360 | "extensions": { 361 | "code": "NOT_FOUND", 362 | "commitspark": { 363 | "argumentName": "id", 364 | "argumentValue": "SOME_UNKNOWN_ID" 365 | } 366 | } 367 | } 368 | ] 369 | } 370 | ``` 371 | 372 | The following error codes are returned together with error codes of Git adapters as 373 | documented [here](https://github.com/commitspark/git-adapter): 374 | 375 | | Error code | Description | 376 | |------------------------|-------------------------------------------------------------------| 377 | | `BAD_USER_INPUT` | Invalid input data provided by the caller | 378 | | `NOT_FOUND` | Requested resource (entry, type, etc.) does not exist | 379 | | `BAD_REPOSITORY_DATA` | Data in the repository is malformed or invalid | 380 | | `SCHEMA_DATA_MISMATCH` | Data in the repository does not match the schema definition | 381 | | `BAD_SCHEMA` | Schema definition is malformed or invalid | 382 | | `IN_USE` | Entry cannot be deleted because it is referenced by other entries | 383 | | `INTERNAL_ERROR` | Internal processing error | 384 | 385 | # License 386 | 387 | The code in this repository is licensed under the permissive ISC license (see [LICENSE](LICENSE)). 388 | -------------------------------------------------------------------------------- /tests/integration/mutation/create.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Commit, 3 | CommitDraft, 4 | Entry, 5 | GitAdapter, 6 | } from '@commitspark/git-adapter' 7 | import { Matcher, mock } from 'jest-mock-extended' 8 | import { createClient } from '../../../src' 9 | 10 | describe('"Create" mutation resolvers', () => { 11 | it('should create an entry', async () => { 12 | const gitAdapter = mock() 13 | const gitRef = 'myRef' 14 | const commitHash = 'abcd' 15 | const schema = `directive @Entry on OBJECT 16 | 17 | type EntryA @Entry { 18 | id: ID! 19 | name: String 20 | }` 21 | 22 | const commitMessage = 'My message' 23 | const entryAId = 'd92f77d2-be9b-429f-877d-5c400ea9ce78' 24 | const postCommitHash = 'ef01' 25 | 26 | const mutationData = { 27 | name: 'My name', 28 | } 29 | const commitResult: Commit = { 30 | ref: postCommitHash, 31 | } 32 | const newEntry: Entry = { 33 | id: entryAId, 34 | metadata: { 35 | type: 'EntryA', 36 | referencedBy: [], 37 | }, 38 | data: { 39 | name: mutationData.name, 40 | }, 41 | } 42 | 43 | const commitDraft: CommitDraft = { 44 | ref: gitRef, 45 | parentSha: commitHash, 46 | entries: [{ ...newEntry, deletion: false }], 47 | message: commitMessage, 48 | } 49 | 50 | const commitDraftMatcher = new Matcher((actualValue) => { 51 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 52 | }, '') 53 | 54 | gitAdapter.getLatestCommitHash 55 | .calledWith(gitRef) 56 | .mockResolvedValue(commitHash) 57 | gitAdapter.getSchema.calledWith(commitHash).mockResolvedValue(schema) 58 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue([]) 59 | gitAdapter.createCommit 60 | .calledWith(commitDraftMatcher) 61 | .mockResolvedValue(commitResult) 62 | gitAdapter.getEntries 63 | .calledWith(postCommitHash) 64 | .mockResolvedValue([newEntry]) 65 | 66 | const client = await createClient(gitAdapter) 67 | const result = await client.postGraphQL(gitRef, { 68 | query: `mutation ($id: ID!, $mutationData: EntryAInput!, $commitMessage: String!) { 69 | data: createEntryA(id: $id, data: $mutationData, commitMessage: $commitMessage) { 70 | id 71 | } 72 | }`, 73 | variables: { 74 | id: entryAId, 75 | mutationData: mutationData, 76 | commitMessage: commitMessage, 77 | }, 78 | }) 79 | 80 | expect(result.errors).toBeUndefined() 81 | expect(result.data).toEqual({ 82 | data: { 83 | id: entryAId, 84 | }, 85 | }) 86 | expect(result.ref).toBe(postCommitHash) 87 | }) 88 | 89 | it('should create an entry that references other entries', async () => { 90 | const gitAdapter = mock() 91 | const gitRef = 'myRef' 92 | const commitHash = 'abcd' 93 | const schema = `directive @Entry on OBJECT 94 | 95 | type EntryA @Entry { 96 | id: ID! 97 | optionalReference: OptionalReference1 98 | nonNullReference: NonNullReference! 99 | arrayReference: [ArrayReference!]! 100 | unionReference: UnionReference! 101 | unionNestedReference: UnionNestedReference! 102 | circularReferenceEntryReference: CircularReferenceEntry 103 | } 104 | 105 | type OptionalReference1 { 106 | nestedReference: OptionalReference2! 107 | } 108 | 109 | type OptionalReference2 @Entry { 110 | id: ID! 111 | } 112 | 113 | type NonNullReference @Entry { 114 | id: ID! 115 | } 116 | 117 | type ArrayReference @Entry { 118 | id: ID! 119 | } 120 | 121 | union UnionReference = 122 | | UnionEntryType1 123 | | UnionEntryType2 124 | 125 | type UnionEntryType1 @Entry { 126 | id: ID! 127 | } 128 | 129 | type UnionEntryType2 @Entry { 130 | id: ID! 131 | } 132 | 133 | union UnionNestedReference = 134 | | UnionType1 135 | | UnionType2 136 | 137 | type UnionType1 { 138 | otherField: String 139 | } 140 | 141 | type UnionType2 { 142 | nestedReference: UnionNestedEntry 143 | } 144 | 145 | type UnionNestedEntry @Entry { 146 | id: ID! 147 | } 148 | 149 | type CircularReferenceEntry @Entry { 150 | id: ID! 151 | next: CircularReferenceEntry 152 | }` 153 | 154 | const commitMessage = 'My message' 155 | const entryAId = 'A' 156 | const optionalReference2EntryId = 'optionalReference2EntryId' 157 | const nonNullReferenceEntryId = 'nonNullReferenceEntryId' 158 | const arrayReferenceEntry1Id = 'arrayReferenceEntry1Id' 159 | const arrayReferenceEntry2Id = 'arrayReferenceEntry2Id' 160 | const unionEntryType1Id = 'unionEntryType1Id' 161 | const unionEntryType2Id = 'unionEntryType2Id' 162 | const unionNestedEntryId = 'unionNestedEntryId' 163 | const circularReferenceEntry1Id = 'circularReferenceEntry1Id' 164 | const circularReferenceEntry2Id = 'circularReferenceEntry2Id' 165 | const postCommitHash = 'ef01' 166 | 167 | const mutationData = { 168 | optionalReference: { 169 | nestedReference: { id: optionalReference2EntryId }, 170 | }, 171 | nonNullReference: { id: nonNullReferenceEntryId }, 172 | arrayReference: [ 173 | { id: arrayReferenceEntry1Id }, 174 | { id: arrayReferenceEntry2Id }, 175 | ], 176 | unionReference: { 177 | id: unionEntryType2Id, 178 | }, 179 | unionNestedReference: { 180 | UnionType2: { 181 | nestedReference: { 182 | id: unionNestedEntryId, 183 | }, 184 | }, 185 | }, 186 | circularReferenceEntryReference: { 187 | id: circularReferenceEntry1Id, 188 | }, 189 | } 190 | 191 | const commitResult: Commit = { 192 | ref: postCommitHash, 193 | } 194 | 195 | const existingCircularReference2Entry = { 196 | id: circularReferenceEntry2Id, 197 | metadata: { 198 | type: 'CircularReferenceEntry', 199 | referencedBy: [circularReferenceEntry1Id], 200 | }, 201 | } 202 | 203 | const existingEntries: Entry[] = [ 204 | { 205 | id: optionalReference2EntryId, 206 | metadata: { type: 'OptionalReference2' }, 207 | }, 208 | { id: nonNullReferenceEntryId, metadata: { type: 'NonNullReference' } }, 209 | { id: arrayReferenceEntry1Id, metadata: { type: 'ArrayReference' } }, 210 | { id: arrayReferenceEntry2Id, metadata: { type: 'ArrayReference' } }, 211 | { id: unionEntryType1Id, metadata: { type: 'UnionEntryType1' } }, 212 | { id: unionEntryType2Id, metadata: { type: 'UnionEntryType2' } }, 213 | { id: unionNestedEntryId, metadata: { type: 'UnionNestedEntry' } }, 214 | { 215 | id: circularReferenceEntry1Id, 216 | metadata: { 217 | type: 'CircularReferenceEntry', 218 | referencedBy: [circularReferenceEntry2Id], 219 | }, 220 | }, 221 | existingCircularReference2Entry, 222 | ] 223 | const newEntryA: Entry = { 224 | id: entryAId, 225 | metadata: { 226 | type: 'EntryA', 227 | referencedBy: [], 228 | }, 229 | data: mutationData, 230 | } 231 | const updatedOptionalReference2: Entry = { 232 | id: optionalReference2EntryId, 233 | metadata: { 234 | type: 'OptionalReference2', 235 | referencedBy: [entryAId], 236 | }, 237 | } 238 | const updatedNonNullReference: Entry = { 239 | id: nonNullReferenceEntryId, 240 | metadata: { 241 | type: 'NonNullReference', 242 | referencedBy: [entryAId], 243 | }, 244 | } 245 | const updatedArrayReference1: Entry = { 246 | id: arrayReferenceEntry1Id, 247 | metadata: { 248 | type: 'ArrayReference', 249 | referencedBy: [entryAId], 250 | }, 251 | } 252 | const updatedArrayReference2: Entry = { 253 | id: arrayReferenceEntry2Id, 254 | metadata: { 255 | type: 'ArrayReference', 256 | referencedBy: [entryAId], 257 | }, 258 | } 259 | const updatedUnionEntryType2: Entry = { 260 | id: unionEntryType2Id, 261 | metadata: { 262 | type: 'UnionEntryType2', 263 | referencedBy: [entryAId], 264 | }, 265 | } 266 | const updatedUnionNestedEntry: Entry = { 267 | id: unionNestedEntryId, 268 | metadata: { 269 | type: 'UnionNestedEntry', 270 | referencedBy: [entryAId], 271 | }, 272 | } 273 | const updatedCircularReference1Entry: Entry = { 274 | id: circularReferenceEntry1Id, 275 | metadata: { 276 | type: 'CircularReferenceEntry', 277 | referencedBy: [entryAId, circularReferenceEntry2Id], 278 | }, 279 | } 280 | 281 | const commitDraft: CommitDraft = { 282 | ref: gitRef, 283 | parentSha: commitHash, 284 | entries: [ 285 | { ...newEntryA, deletion: false }, 286 | { ...updatedOptionalReference2, deletion: false }, 287 | { ...updatedNonNullReference, deletion: false }, 288 | { ...updatedArrayReference1, deletion: false }, 289 | { ...updatedArrayReference2, deletion: false }, 290 | { ...updatedUnionEntryType2, deletion: false }, 291 | { ...updatedUnionNestedEntry, deletion: false }, 292 | { ...updatedCircularReference1Entry, deletion: false }, 293 | ], 294 | message: commitMessage, 295 | } 296 | 297 | const commitDraftMatcher = new Matcher((actualValue) => { 298 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 299 | }, '') 300 | 301 | gitAdapter.getLatestCommitHash 302 | .calledWith(gitRef) 303 | .mockResolvedValue(commitHash) 304 | gitAdapter.getSchema.calledWith(commitHash).mockResolvedValue(schema) 305 | gitAdapter.getEntries 306 | .calledWith(commitHash) 307 | .mockResolvedValue(existingEntries) 308 | gitAdapter.createCommit 309 | .calledWith(commitDraftMatcher) 310 | .mockResolvedValue(commitResult) 311 | gitAdapter.getEntries 312 | .calledWith(postCommitHash) 313 | .mockResolvedValue([ 314 | newEntryA, 315 | updatedOptionalReference2, 316 | updatedNonNullReference, 317 | updatedArrayReference1, 318 | updatedArrayReference2, 319 | updatedUnionEntryType2, 320 | updatedUnionNestedEntry, 321 | updatedCircularReference1Entry, 322 | existingCircularReference2Entry, 323 | ]) 324 | 325 | const client = await createClient(gitAdapter) 326 | const result = await client.postGraphQL(gitRef, { 327 | query: `mutation ($id: ID!, $mutationData: EntryAInput!, $commitMessage: String!) { 328 | data: createEntryA(id: $id, data: $mutationData, commitMessage: $commitMessage) { 329 | id 330 | } 331 | }`, 332 | variables: { 333 | id: entryAId, 334 | mutationData: mutationData, 335 | commitMessage: commitMessage, 336 | }, 337 | }) 338 | 339 | expect(result.errors).toBeUndefined() 340 | expect(result.data).toEqual({ 341 | data: { 342 | id: entryAId, 343 | }, 344 | }) 345 | expect(result.ref).toBe(postCommitHash) 346 | }) 347 | 348 | it('should not create an entry that references a non-existent entry', async () => { 349 | const gitAdapter = mock() 350 | const gitRef = 'myRef' 351 | const commitHash = 'abcd' 352 | const schema = `directive @Entry on OBJECT 353 | 354 | type EntryA @Entry { 355 | id: ID! 356 | reference: EntryB! 357 | } 358 | 359 | type EntryB @Entry { 360 | id: ID! 361 | }` 362 | 363 | const commitMessage = 'My message' 364 | const entryAId = 'A' 365 | const entryBId = 'B' 366 | 367 | const existingEntries: Entry[] = [ 368 | { id: entryBId, metadata: { type: 'EntryB' } }, 369 | ] 370 | 371 | gitAdapter.getLatestCommitHash 372 | .calledWith(gitRef) 373 | .mockResolvedValue(commitHash) 374 | gitAdapter.getSchema.calledWith(commitHash).mockResolvedValue(schema) 375 | gitAdapter.getEntries 376 | .calledWith(commitHash) 377 | .mockResolvedValue(existingEntries) 378 | 379 | const client = await createClient(gitAdapter) 380 | const result = await client.postGraphQL(gitRef, { 381 | query: `mutation ($id: ID!, $mutationData: EntryAInput!, $commitMessage: String!) { 382 | data: createEntryA(id: $id, data: $mutationData, commitMessage: $commitMessage) { 383 | id 384 | } 385 | }`, 386 | variables: { 387 | id: entryAId, 388 | mutationData: { reference: { id: 'someUnknownId' } }, 389 | commitMessage: commitMessage, 390 | }, 391 | }) 392 | 393 | expect(result.errors).toMatchObject([ 394 | { 395 | extensions: { 396 | code: 'BAD_USER_INPUT', 397 | commitspark: { 398 | fieldName: 'reference', 399 | fieldValue: 'someUnknownId', 400 | }, 401 | }, 402 | }, 403 | ]) 404 | expect(result.data).toEqual({ data: null }) 405 | expect(result.ref).toBe(commitHash) 406 | }) 407 | 408 | it('should not create an entry that references an entry of incorrect type', async () => { 409 | const gitAdapter = mock() 410 | const gitRef = 'myRef' 411 | const commitHash = 'abcd' 412 | const schema = `directive @Entry on OBJECT 413 | 414 | type EntryA @Entry { 415 | id: ID! 416 | reference: EntryB! 417 | } 418 | 419 | type EntryB @Entry { 420 | id: ID! 421 | } 422 | 423 | type OtherEntry @Entry { 424 | id: ID! 425 | }` 426 | 427 | const commitMessage = 'My message' 428 | const entryAId = 'A' 429 | const entryBId = 'B' 430 | const otherEntryId = 'otherEntryId' 431 | 432 | const existingEntries: Entry[] = [ 433 | { id: entryBId, metadata: { type: 'EntryB' } }, 434 | { id: otherEntryId, metadata: { type: 'OtherEntry' } }, 435 | ] 436 | 437 | gitAdapter.getLatestCommitHash 438 | .calledWith(gitRef) 439 | .mockResolvedValue(commitHash) 440 | gitAdapter.getSchema.calledWith(commitHash).mockResolvedValue(schema) 441 | gitAdapter.getEntries 442 | .calledWith(commitHash) 443 | .mockResolvedValue(existingEntries) 444 | 445 | const client = await createClient(gitAdapter) 446 | const result = await client.postGraphQL(gitRef, { 447 | query: `mutation ($id: ID!, $mutationData: EntryAInput!, $commitMessage: String!) { 448 | data: createEntryA(id: $id, data: $mutationData, commitMessage: $commitMessage) { 449 | id 450 | } 451 | }`, 452 | variables: { 453 | id: entryAId, 454 | mutationData: { reference: { id: otherEntryId } }, 455 | commitMessage: commitMessage, 456 | }, 457 | }) 458 | 459 | expect(result.errors).toMatchObject([ 460 | { 461 | extensions: { 462 | code: 'BAD_USER_INPUT', 463 | commitspark: { 464 | fieldName: 'reference', 465 | fieldValue: 'otherEntryId', 466 | }, 467 | }, 468 | }, 469 | ]) 470 | expect(result.data).toEqual({ data: null }) 471 | expect(result.ref).toBe(commitHash) 472 | }) 473 | }) 474 | -------------------------------------------------------------------------------- /tests/integration/mutation/update.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Commit, 3 | CommitDraft, 4 | Entry, 5 | GitAdapter, 6 | } from '@commitspark/git-adapter' 7 | import { Matcher, mock } from 'jest-mock-extended' 8 | import { createClient } from '../../../src' 9 | 10 | describe('"Update" mutation resolvers', () => { 11 | it('should update an entry', async () => { 12 | const gitAdapter = mock() 13 | const gitRef = 'myRef' 14 | const commitHash = 'abcd' 15 | const originalSchema = `directive @Entry on OBJECT 16 | 17 | type EntryA @Entry { 18 | id: ID! 19 | name: String 20 | }` 21 | 22 | const commitMessage = 'My message' 23 | const entryAId = 'A' 24 | const postCommitHash = 'ef01' 25 | 26 | const mutationData = { 27 | name: 'My name', 28 | } 29 | const commitResult: Commit = { 30 | ref: postCommitHash, 31 | } 32 | const originalEntry: Entry = { 33 | id: entryAId, 34 | metadata: { 35 | type: 'EntryA', 36 | }, 37 | data: { 38 | name: `${mutationData.name}1`, 39 | }, 40 | } 41 | 42 | const updatedEntry: Entry = { 43 | id: entryAId, 44 | metadata: { 45 | type: 'EntryA', 46 | }, 47 | data: { 48 | name: `${mutationData.name}2`, 49 | }, 50 | } 51 | 52 | const commitDraft: CommitDraft = { 53 | ref: gitRef, 54 | parentSha: commitHash, 55 | entries: [{ ...updatedEntry, deletion: false }], 56 | message: commitMessage, 57 | } 58 | 59 | const commitDraftMatcher = new Matcher((actualValue) => { 60 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 61 | }, '') 62 | 63 | gitAdapter.getLatestCommitHash 64 | .calledWith(gitRef) 65 | .mockResolvedValue(commitHash) 66 | gitAdapter.getSchema 67 | .calledWith(commitHash) 68 | .mockResolvedValue(originalSchema) 69 | gitAdapter.getEntries 70 | .calledWith(commitHash) 71 | .mockResolvedValue([originalEntry]) 72 | gitAdapter.createCommit 73 | .calledWith(commitDraftMatcher) 74 | .mockResolvedValue(commitResult) 75 | gitAdapter.getEntries 76 | .calledWith(postCommitHash) 77 | .mockResolvedValue([updatedEntry]) 78 | 79 | const client = await createClient(gitAdapter) 80 | const result = await client.postGraphQL(gitRef, { 81 | query: `mutation ($id: ID!, $mutationData: EntryAInput!, $commitMessage: String!) { 82 | data: updateEntryA(id: $id, data: $mutationData, commitMessage: $commitMessage) { 83 | id 84 | name 85 | } 86 | }`, 87 | variables: { 88 | id: entryAId, 89 | mutationData: { 90 | name: `${mutationData.name}2`, 91 | }, 92 | commitMessage: commitMessage, 93 | }, 94 | }) 95 | 96 | expect(result.errors).toBeUndefined() 97 | expect(result.data).toEqual({ 98 | data: { 99 | id: entryAId, 100 | name: `${mutationData.name}2`, 101 | }, 102 | }) 103 | expect(result.ref).toBe(postCommitHash) 104 | }) 105 | 106 | it('should update an entry only where data was provided (partial update)', async () => { 107 | const gitAdapter = mock() 108 | const gitRef = 'myRef' 109 | const commitHash = 'abcd' 110 | const originalSchema = `directive @Entry on OBJECT 111 | 112 | type EntryA @Entry { 113 | id: ID! 114 | fieldChanged: String 115 | fieldNulled: String 116 | fieldNotSpecified: String 117 | fieldUndefinedData: String 118 | subTypeChanged: SubType 119 | subTypeNulled: SubType 120 | subTypeNotSpecified: SubType 121 | subTypeUndefinedData: SubType 122 | arrayChanged: [SubType!] 123 | arrayNulled: [SubType!] 124 | arrayNotSpecified: [SubType!] 125 | arrayUndefinedData: [SubType!] 126 | } 127 | 128 | type SubType { 129 | field1: String 130 | field2: String 131 | }` 132 | 133 | const commitMessage = 'My message' 134 | const entryId = 'A' 135 | const postCommitHash = 'ef01' 136 | 137 | const originalValue = 'original' 138 | const commitResult: Commit = { 139 | ref: postCommitHash, 140 | } 141 | const originalEntry: Entry = { 142 | id: entryId, 143 | metadata: { 144 | type: 'EntryA', 145 | }, 146 | data: { 147 | fieldChanged: originalValue, 148 | fieldNulled: originalValue, 149 | fieldNotSpecified: originalValue, 150 | subTypeChanged: { 151 | field1: originalValue, 152 | }, 153 | subTypeNulled: { 154 | field1: originalValue, 155 | }, 156 | subTypeNotSpecified: { 157 | field1: originalValue, 158 | }, 159 | arrayChanged: [{ field1: originalValue }], 160 | arrayNulled: [{ field1: originalValue }], 161 | arrayNotSpecified: [{ field1: originalValue }], 162 | }, 163 | } 164 | 165 | const changedValue = 'changed' 166 | const mutationData = { 167 | fieldChanged: changedValue, 168 | fieldNulled: null, 169 | fieldUndefinedData: changedValue, 170 | subTypeChanged: { field2: changedValue }, 171 | subTypeNulled: null, 172 | subTypeUndefinedData: { field2: changedValue }, 173 | arrayChanged: [{ field2: changedValue }], 174 | arrayNulled: null, 175 | arrayUndefinedData: [{ field2: changedValue }], 176 | } 177 | 178 | const updatedEntry: Entry = { 179 | id: entryId, 180 | metadata: { 181 | type: 'EntryA', 182 | }, 183 | data: { 184 | fieldChanged: changedValue, 185 | fieldNulled: null, 186 | fieldNotSpecified: originalValue, 187 | subTypeChanged: { 188 | field2: changedValue, 189 | }, 190 | subTypeNulled: null, 191 | subTypeNotSpecified: { 192 | field1: originalValue, 193 | }, 194 | arrayChanged: [{ field2: changedValue }], 195 | arrayNulled: null, 196 | arrayNotSpecified: [{ field1: originalValue }], 197 | // we only do a dumb equality check using JSON below, so order matters and these fields were added 198 | fieldUndefinedData: changedValue, 199 | subTypeUndefinedData: { 200 | field2: changedValue, 201 | }, 202 | arrayUndefinedData: [{ field2: changedValue }], 203 | }, 204 | } 205 | 206 | const commitDraft: CommitDraft = { 207 | ref: gitRef, 208 | parentSha: commitHash, 209 | entries: [{ ...updatedEntry, deletion: false }], 210 | message: commitMessage, 211 | } 212 | 213 | const commitDraftMatcher = new Matcher((actualValue) => { 214 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 215 | }, '') 216 | 217 | gitAdapter.getLatestCommitHash 218 | .calledWith(gitRef) 219 | .mockResolvedValue(commitHash) 220 | gitAdapter.getSchema 221 | .calledWith(commitHash) 222 | .mockResolvedValue(originalSchema) 223 | gitAdapter.getEntries 224 | .calledWith(commitHash) 225 | .mockResolvedValue([originalEntry]) 226 | gitAdapter.createCommit 227 | .calledWith(commitDraftMatcher) 228 | .mockResolvedValue(commitResult) 229 | gitAdapter.getEntries 230 | .calledWith(postCommitHash) 231 | .mockResolvedValue([updatedEntry]) 232 | 233 | const client = await createClient(gitAdapter) 234 | const result = await client.postGraphQL(gitRef, { 235 | query: `mutation ($id: ID!, $mutationData: EntryAInput!, $commitMessage: String!) { 236 | data: updateEntryA(id: $id, data: $mutationData, commitMessage: $commitMessage) { 237 | id 238 | } 239 | }`, 240 | variables: { 241 | id: entryId, 242 | mutationData: mutationData, 243 | commitMessage: commitMessage, 244 | }, 245 | }) 246 | 247 | expect(result.errors).toBeUndefined() 248 | expect(result.data).toEqual({ data: { id: entryId } }) 249 | expect(result.ref).toBe(postCommitHash) 250 | }) 251 | 252 | it('should return an error when trying to update a non-existent entry', async () => { 253 | const gitAdapter = mock() 254 | const gitRef = 'myRef' 255 | const commitHash = 'abcd' 256 | const originalSchema = `directive @Entry on OBJECT 257 | 258 | type EntryA @Entry { 259 | id: ID! 260 | name: String 261 | }` 262 | 263 | const commitMessage = 'My message' 264 | const entryAId = 'A' 265 | 266 | gitAdapter.getLatestCommitHash 267 | .calledWith(gitRef) 268 | .mockResolvedValue(commitHash) 269 | gitAdapter.getSchema 270 | .calledWith(commitHash) 271 | .mockResolvedValue(originalSchema) 272 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue([]) 273 | 274 | const client = await createClient(gitAdapter) 275 | const result = await client.postGraphQL(gitRef, { 276 | query: `mutation ($id: ID!, $mutationData: EntryAInput!, $commitMessage: String!) { 277 | data: updateEntryA(id: $id, data: $mutationData, commitMessage: $commitMessage) { 278 | id 279 | name 280 | } 281 | }`, 282 | variables: { 283 | id: entryAId, 284 | mutationData: { 285 | name: '2', 286 | }, 287 | commitMessage: commitMessage, 288 | }, 289 | }) 290 | 291 | expect(result.errors).toMatchObject([ 292 | { 293 | extensions: { 294 | code: 'NOT_FOUND', 295 | commitspark: { 296 | argumentName: 'id', 297 | argumentValue: entryAId, 298 | }, 299 | }, 300 | }, 301 | ]) 302 | expect(result.data).toEqual({ data: null }) 303 | expect(result.ref).toBe(commitHash) 304 | }) 305 | 306 | it('should update reference metadata of other entries when updating an entry', async () => { 307 | const gitAdapter = mock() 308 | const gitRef = 'myRef' 309 | const commitHash = 'abcd' 310 | const originalSchema = `directive @Entry on OBJECT 311 | 312 | type Item @Entry { 313 | id: ID! 314 | box: Box! 315 | } 316 | 317 | type Box @Entry { 318 | id: ID! 319 | }` 320 | 321 | const commitMessage = 'My message' 322 | const box1Id = 'box1' 323 | const box2Id = 'box2' 324 | const item1Id = 'item1' 325 | const item2Id = 'item2' 326 | const postCommitHash = 'ef01' 327 | 328 | const commitResult: Commit = { 329 | ref: postCommitHash, 330 | } 331 | const box1: Entry = { 332 | id: box1Id, 333 | metadata: { 334 | type: 'Box', 335 | referencedBy: [item1Id], 336 | }, 337 | } 338 | const box2: Entry = { 339 | id: box2Id, 340 | metadata: { 341 | type: 'Box', 342 | referencedBy: [item2Id], 343 | }, 344 | } 345 | const item1: Entry = { 346 | id: item1Id, 347 | metadata: { 348 | type: 'Item', 349 | }, 350 | data: { 351 | box: { id: box1Id }, 352 | }, 353 | } 354 | const item2: Entry = { 355 | id: item2Id, 356 | metadata: { 357 | type: 'Item', 358 | }, 359 | data: { 360 | box: { id: box2Id }, 361 | }, 362 | } 363 | const updatedItem1: Entry = { 364 | id: item1Id, 365 | metadata: { 366 | type: 'Item', 367 | }, 368 | data: { 369 | box: { id: box2Id }, 370 | }, 371 | } 372 | const updatedBox1: Entry = { 373 | id: box1Id, 374 | metadata: { 375 | type: 'Box', 376 | referencedBy: [], 377 | }, 378 | } 379 | const updatedBox2: Entry = { 380 | id: box2Id, 381 | metadata: { 382 | type: 'Box', 383 | referencedBy: [item1Id, item2Id], 384 | }, 385 | } 386 | 387 | const commitDraft: CommitDraft = { 388 | ref: gitRef, 389 | parentSha: commitHash, 390 | entries: [ 391 | { ...updatedItem1, deletion: false }, 392 | { ...updatedBox1, deletion: false }, 393 | { ...updatedBox2, deletion: false }, 394 | ], 395 | message: commitMessage, 396 | } 397 | 398 | const commitDraftMatcher = new Matcher((actualValue) => { 399 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 400 | }, '') 401 | 402 | gitAdapter.getLatestCommitHash 403 | .calledWith(gitRef) 404 | .mockResolvedValue(commitHash) 405 | gitAdapter.getSchema 406 | .calledWith(commitHash) 407 | .mockResolvedValue(originalSchema) 408 | gitAdapter.getEntries 409 | .calledWith(commitHash) 410 | .mockResolvedValue([box1, box2, item1, item2]) 411 | gitAdapter.createCommit 412 | .calledWith(commitDraftMatcher) 413 | .mockResolvedValue(commitResult) 414 | gitAdapter.getEntries 415 | .calledWith(postCommitHash) 416 | .mockResolvedValue([updatedBox1, updatedBox2, updatedItem1, item2]) 417 | 418 | const client = await createClient(gitAdapter) 419 | const result = await client.postGraphQL(gitRef, { 420 | query: `mutation ($id: ID!, $mutationData: ItemInput!, $commitMessage: String!) { 421 | data: updateItem(id: $id, data: $mutationData, commitMessage: $commitMessage) { 422 | id 423 | } 424 | }`, 425 | variables: { 426 | id: item1Id, 427 | mutationData: { 428 | box: { id: box2Id }, 429 | }, 430 | commitMessage: commitMessage, 431 | }, 432 | }) 433 | 434 | expect(result.errors).toBeUndefined() 435 | expect(result.data).toEqual({ 436 | data: { 437 | id: item1Id, 438 | }, 439 | }) 440 | expect(result.ref).toBe(postCommitHash) 441 | }) 442 | 443 | it('should not add more than one reference in metadata of referenced entries when setting a second reference from an entry already having a reference in place', async () => { 444 | const gitAdapter = mock() 445 | const gitRef = 'myRef' 446 | const commitHash = 'abcd' 447 | const originalSchema = `directive @Entry on OBJECT 448 | 449 | type Item @Entry { 450 | id: ID! 451 | box: Box 452 | boxAlias: Box 453 | } 454 | 455 | type Box @Entry { 456 | id: ID! 457 | }` 458 | 459 | const commitMessage = 'My message' 460 | const boxId = 'box' 461 | const itemId = 'item' 462 | const postCommitHash = 'ef01' 463 | 464 | const commitResult: Commit = { 465 | ref: postCommitHash, 466 | } 467 | const box: Entry = { 468 | id: boxId, 469 | metadata: { 470 | type: 'Box', 471 | referencedBy: [itemId], 472 | }, 473 | } 474 | const item: Entry = { 475 | id: itemId, 476 | metadata: { 477 | type: 'Item', 478 | }, 479 | data: { 480 | box: { id: boxId }, 481 | // boxAlias is intentionally not referencing anything 482 | }, 483 | } 484 | const updatedItem: Entry = { 485 | id: itemId, 486 | metadata: { 487 | type: 'Item', 488 | }, 489 | data: { 490 | box: { id: boxId }, 491 | boxAlias: { id: boxId }, // now reference same box as `box` field 492 | }, 493 | } 494 | const updatedBox: Entry = { 495 | id: boxId, 496 | metadata: { 497 | type: 'Box', 498 | referencedBy: [itemId], // expect a single record per incoming reference 499 | }, 500 | } 501 | 502 | const commitDraft: CommitDraft = { 503 | ref: gitRef, 504 | parentSha: commitHash, 505 | entries: [{ ...updatedItem, deletion: false }], 506 | message: commitMessage, 507 | } 508 | 509 | const commitDraftMatcher = new Matcher((actualValue) => { 510 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 511 | }, '') 512 | 513 | gitAdapter.getLatestCommitHash 514 | .calledWith(gitRef) 515 | .mockResolvedValue(commitHash) 516 | gitAdapter.getSchema 517 | .calledWith(commitHash) 518 | .mockResolvedValue(originalSchema) 519 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue([box, item]) 520 | gitAdapter.createCommit 521 | .calledWith(commitDraftMatcher) 522 | .mockResolvedValue(commitResult) 523 | gitAdapter.getEntries 524 | .calledWith(postCommitHash) 525 | .mockResolvedValue([updatedBox, updatedItem]) 526 | 527 | const client = await createClient(gitAdapter) 528 | const result = await client.postGraphQL(gitRef, { 529 | query: `mutation ($id: ID!, $mutationData: ItemInput!, $commitMessage: String!) { 530 | data: updateItem(id: $id, data: $mutationData, commitMessage: $commitMessage) { 531 | id 532 | } 533 | }`, 534 | variables: { 535 | id: itemId, 536 | mutationData: { 537 | boxAlias: { id: boxId }, 538 | }, 539 | commitMessage: commitMessage, 540 | }, 541 | }) 542 | 543 | expect(result.errors).toBeUndefined() 544 | expect(result.data).toEqual({ 545 | data: { 546 | id: itemId, 547 | }, 548 | }) 549 | expect(result.ref).toBe(postCommitHash) 550 | }) 551 | 552 | it('should not add more than one reference in metadata of referenced entries when changing an entry having two different references to now reference the same entry twice', async () => { 553 | const gitAdapter = mock() 554 | const gitRef = 'myRef' 555 | const commitHash = 'abcd' 556 | const originalSchema = `directive @Entry on OBJECT 557 | 558 | type Item @Entry { 559 | id: ID! 560 | box: Box 561 | boxAlias: Box 562 | } 563 | 564 | type Box @Entry { 565 | id: ID! 566 | }` 567 | 568 | const commitMessage = 'My message' 569 | const boxId = 'box' 570 | const otherBoxId = 'otherBox' 571 | const itemId = 'item' 572 | const postCommitHash = 'ef01' 573 | 574 | const commitResult: Commit = { 575 | ref: postCommitHash, 576 | } 577 | const box: Entry = { 578 | id: boxId, 579 | metadata: { 580 | type: 'Box', 581 | referencedBy: [itemId], 582 | }, 583 | } 584 | const otherBox: Entry = { 585 | id: otherBoxId, 586 | metadata: { 587 | type: 'Box', 588 | referencedBy: [itemId], 589 | }, 590 | } 591 | const item: Entry = { 592 | id: itemId, 593 | metadata: { 594 | type: 'Item', 595 | }, 596 | data: { 597 | box: { id: boxId }, 598 | boxAlias: { id: otherBoxId }, 599 | }, 600 | } 601 | const updatedItem: Entry = { 602 | id: itemId, 603 | metadata: { 604 | type: 'Item', 605 | }, 606 | data: { 607 | box: { id: boxId }, 608 | boxAlias: { id: boxId }, 609 | }, 610 | } 611 | const updatedBox: Entry = { 612 | id: boxId, 613 | metadata: { 614 | type: 'Box', 615 | referencedBy: [itemId], // expect a single record per incoming reference 616 | }, 617 | } 618 | const updatedOtherBox: Entry = { 619 | id: otherBoxId, 620 | metadata: { 621 | type: 'Box', 622 | referencedBy: [], // no longer referenced 623 | }, 624 | } 625 | 626 | const commitDraft: CommitDraft = { 627 | ref: gitRef, 628 | parentSha: commitHash, 629 | entries: [ 630 | { ...updatedItem, deletion: false }, 631 | { ...updatedOtherBox, deletion: false }, 632 | ], 633 | message: commitMessage, 634 | } 635 | 636 | const commitDraftMatcher = new Matcher((actualValue) => { 637 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 638 | }, '') 639 | 640 | gitAdapter.getLatestCommitHash 641 | .calledWith(gitRef) 642 | .mockResolvedValue(commitHash) 643 | gitAdapter.getSchema 644 | .calledWith(commitHash) 645 | .mockResolvedValue(originalSchema) 646 | gitAdapter.getEntries 647 | .calledWith(commitHash) 648 | .mockResolvedValue([box, otherBox, item]) 649 | gitAdapter.createCommit 650 | .calledWith(commitDraftMatcher) 651 | .mockResolvedValue(commitResult) 652 | gitAdapter.getEntries 653 | .calledWith(postCommitHash) 654 | .mockResolvedValue([updatedBox, updatedOtherBox, updatedItem]) 655 | 656 | const client = await createClient(gitAdapter) 657 | const result = await client.postGraphQL(gitRef, { 658 | query: `mutation ($id: ID!, $mutationData: ItemInput!, $commitMessage: String!) { 659 | data: updateItem(id: $id, data: $mutationData, commitMessage: $commitMessage) { 660 | id 661 | } 662 | }`, 663 | variables: { 664 | id: itemId, 665 | mutationData: { 666 | boxAlias: { id: boxId }, 667 | }, 668 | commitMessage: commitMessage, 669 | }, 670 | }) 671 | 672 | expect(result.errors).toBeUndefined() 673 | expect(result.data).toEqual({ 674 | data: { 675 | id: itemId, 676 | }, 677 | }) 678 | expect(result.ref).toBe(postCommitHash) 679 | }) 680 | }) 681 | -------------------------------------------------------------------------------- /tests/integration/query/query.test.ts: -------------------------------------------------------------------------------- 1 | import { Entry, GitAdapter } from '@commitspark/git-adapter' 2 | import { mock } from 'jest-mock-extended' 3 | import { createClient } from '../../../src' 4 | 5 | describe('Query resolvers', () => { 6 | it('should return every entry', async () => { 7 | const gitAdapter = mock() 8 | const gitRef = 'myRef' 9 | const commitHash = 'abcd' 10 | const originalSchema = `directive @Entry on OBJECT 11 | 12 | type EntryA @Entry { 13 | id: ID! 14 | } 15 | 16 | type EntryB @Entry { 17 | id: ID! 18 | }` 19 | 20 | const entryA1Id = 'A1' 21 | const entryA2Id = 'A2' 22 | const entryBId = 'B' 23 | 24 | const entries = [ 25 | { 26 | id: entryA1Id, 27 | metadata: { 28 | type: 'EntryA', 29 | }, 30 | data: {}, 31 | } as Entry, 32 | { 33 | id: entryBId, 34 | metadata: { 35 | type: 'EntryB', 36 | }, 37 | data: {}, 38 | } as Entry, 39 | { 40 | id: entryA2Id, 41 | metadata: { 42 | type: 'EntryA', 43 | }, 44 | data: {}, 45 | } as Entry, 46 | ] 47 | 48 | gitAdapter.getLatestCommitHash 49 | .calledWith(gitRef) 50 | .mockResolvedValue(commitHash) 51 | gitAdapter.getSchema 52 | .calledWith(commitHash) 53 | .mockResolvedValue(originalSchema) 54 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 55 | 56 | const client = await createClient(gitAdapter) 57 | const result = await client.postGraphQL(gitRef, { 58 | query: `query { 59 | data: everyEntryA { 60 | id 61 | } 62 | }`, 63 | variables: {}, 64 | }) 65 | 66 | expect(result.errors).toBeUndefined() 67 | expect(result.data).toEqual({ 68 | data: [ 69 | { 70 | id: entryA1Id, 71 | }, 72 | { 73 | id: entryA2Id, 74 | }, 75 | ], 76 | }) 77 | expect(result.ref).toBe(commitHash) 78 | }) 79 | 80 | it('should return an entry with nested data', async () => { 81 | const gitAdapter = mock() 82 | const gitRef = 'myRef' 83 | const commitHash = 'abcd' 84 | const originalSchema = `directive @Entry on OBJECT 85 | 86 | type MyEntry @Entry { 87 | id: ID! 88 | firstField: NestedData 89 | secondField: NestedData 90 | } 91 | 92 | type NestedData { 93 | nested: String 94 | }` 95 | 96 | const entryId = '1' 97 | 98 | const entries = [ 99 | { 100 | id: entryId, 101 | metadata: { 102 | type: 'MyEntry', 103 | }, 104 | data: { 105 | firstField: { 106 | nested: 'value', 107 | }, 108 | // second field is intentionally not set 109 | }, 110 | } as Entry, 111 | ] 112 | 113 | gitAdapter.getLatestCommitHash 114 | .calledWith(gitRef) 115 | .mockResolvedValue(commitHash) 116 | gitAdapter.getSchema 117 | .calledWith(commitHash) 118 | .mockResolvedValue(originalSchema) 119 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 120 | 121 | const client = await createClient(gitAdapter) 122 | const result = await client.postGraphQL(gitRef, { 123 | query: `query ($entryId: ID!) { 124 | data: MyEntry (id: $entryId) { 125 | id 126 | firstField { 127 | nested 128 | } 129 | secondField { 130 | nested 131 | } 132 | } 133 | }`, 134 | variables: { 135 | entryId: entryId, 136 | }, 137 | }) 138 | 139 | expect(result.errors).toBeUndefined() 140 | expect(result.data).toEqual({ 141 | data: { 142 | id: entryId, 143 | firstField: { 144 | nested: 'value', 145 | }, 146 | secondField: null, // as it is a nullable field, expect silent fallback to null 147 | }, 148 | }) 149 | expect(result.ref).toBe(commitHash) 150 | }) 151 | 152 | it('should resolve references to a second @Entry', async () => { 153 | const gitAdapter = mock() 154 | const gitRef = 'myRef' 155 | const commitHash = 'abcd' 156 | const originalSchema = `directive @Entry on OBJECT 157 | 158 | type EntryA @Entry { 159 | id: ID! 160 | name: String 161 | } 162 | 163 | type EntryB @Entry { 164 | id: ID! 165 | entryA: EntryA 166 | entryAList: [EntryA!]! 167 | }` 168 | 169 | const entryAId = 'A' 170 | const entryBId = 'B' 171 | 172 | const entries = [ 173 | { 174 | id: entryAId, 175 | metadata: { 176 | type: 'EntryA', 177 | }, 178 | data: { 179 | name: 'My name', 180 | }, 181 | } as Entry, 182 | { 183 | id: entryBId, 184 | metadata: { 185 | type: 'EntryB', 186 | }, 187 | data: { 188 | entryA: { 189 | id: entryAId, 190 | }, 191 | entryAList: [ 192 | { 193 | id: entryAId, 194 | }, 195 | ], 196 | }, 197 | } as Entry, 198 | ] 199 | 200 | gitAdapter.getLatestCommitHash 201 | .calledWith(gitRef) 202 | .mockResolvedValue(commitHash) 203 | gitAdapter.getSchema 204 | .calledWith(commitHash) 205 | .mockResolvedValue(originalSchema) 206 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 207 | 208 | const client = await createClient(gitAdapter) 209 | const result = await client.postGraphQL(gitRef, { 210 | query: `query ($entryId: ID!) { 211 | data: EntryB(id: $entryId) { 212 | id 213 | entryA { 214 | id 215 | name 216 | } 217 | entryAList { 218 | id 219 | name 220 | } 221 | } 222 | }`, 223 | variables: { 224 | entryId: entryBId, 225 | }, 226 | }) 227 | 228 | expect(result.errors).toBeUndefined() 229 | expect(result.data).toEqual({ 230 | data: { 231 | id: entryBId, 232 | entryA: { 233 | id: entryAId, 234 | name: 'My name', 235 | }, 236 | entryAList: [ 237 | { 238 | id: entryAId, 239 | name: 'My name', 240 | }, 241 | ], 242 | }, 243 | }) 244 | expect(result.ref).toBe(commitHash) 245 | }) 246 | 247 | it('should resolve a non-@Entry-based union', async () => { 248 | const gitAdapter = mock() 249 | const gitRef = 'myRef' 250 | const commitHash = 'abcd' 251 | const originalSchema = `directive @Entry on OBJECT 252 | 253 | type MyEntry @Entry { 254 | id: ID! 255 | union: MyUnion 256 | } 257 | 258 | union MyUnion = 259 | | TypeA 260 | | TypeB 261 | 262 | type TypeA { 263 | field1: String 264 | } 265 | 266 | type TypeB { 267 | field2: String 268 | }` 269 | 270 | const entryId = 'A' 271 | const field2Value = 'Field2 value' 272 | 273 | const entries = [ 274 | { 275 | id: entryId, 276 | metadata: { 277 | type: 'MyEntry', 278 | }, 279 | data: { 280 | union: { 281 | TypeB: { 282 | field2: field2Value, 283 | }, 284 | }, 285 | }, 286 | } as Entry, 287 | ] 288 | 289 | gitAdapter.getLatestCommitHash 290 | .calledWith(gitRef) 291 | .mockResolvedValue(commitHash) 292 | gitAdapter.getSchema 293 | .calledWith(commitHash) 294 | .mockResolvedValue(originalSchema) 295 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 296 | 297 | const client = await createClient(gitAdapter) 298 | const result = await client.postGraphQL(gitRef, { 299 | query: `query ($entryId: ID!) { 300 | data: MyEntry(id: $entryId) { 301 | id 302 | union { 303 | __typename 304 | ... on TypeB { 305 | field2 306 | } 307 | } 308 | } 309 | }`, 310 | variables: { 311 | entryId: entryId, 312 | }, 313 | }) 314 | 315 | expect(result.errors).toBeUndefined() 316 | expect(result.data).toEqual({ 317 | data: { 318 | id: entryId, 319 | union: { 320 | __typename: 'TypeB', 321 | field2: field2Value, 322 | }, 323 | }, 324 | }) 325 | expect(result.ref).toBe(commitHash) 326 | }) 327 | 328 | it('should resolve an array of non-@Entry-based unions that is null', async () => { 329 | const gitAdapter = mock() 330 | const gitRef = 'myRef' 331 | const commitHash = 'abcd' 332 | const originalSchema = `directive @Entry on OBJECT 333 | 334 | type MyEntry @Entry { 335 | id: ID! 336 | union: [MyUnion!] 337 | } 338 | 339 | union MyUnion = 340 | | TypeA 341 | | TypeB 342 | 343 | type TypeA { 344 | field1: String 345 | } 346 | 347 | type TypeB { 348 | field2: String 349 | }` 350 | 351 | const entryId = 'A' 352 | 353 | const entries = [ 354 | { 355 | id: entryId, 356 | metadata: { 357 | type: 'MyEntry', 358 | }, 359 | data: { 360 | union: null, 361 | }, 362 | } as Entry, 363 | ] 364 | 365 | gitAdapter.getLatestCommitHash 366 | .calledWith(gitRef) 367 | .mockResolvedValue(commitHash) 368 | gitAdapter.getSchema 369 | .calledWith(commitHash) 370 | .mockResolvedValue(originalSchema) 371 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 372 | 373 | const client = await createClient(gitAdapter) 374 | const result = await client.postGraphQL(gitRef, { 375 | query: `query ($entryId: ID!) { 376 | data: MyEntry(id: $entryId) { 377 | id 378 | union { 379 | __typename 380 | } 381 | } 382 | }`, 383 | variables: { 384 | entryId: entryId, 385 | }, 386 | }) 387 | 388 | expect(result.errors).toBeUndefined() 389 | expect(result.data).toEqual({ 390 | data: { 391 | id: entryId, 392 | union: null, 393 | }, 394 | }) 395 | expect(result.ref).toBe(commitHash) 396 | }) 397 | 398 | it('should resolve an empty array of non-@Entry-based unions', async () => { 399 | const gitAdapter = mock() 400 | const gitRef = 'myRef' 401 | const commitHash = 'abcd' 402 | const originalSchema = `directive @Entry on OBJECT 403 | 404 | type MyEntry @Entry { 405 | id: ID! 406 | union: [MyUnion!] 407 | } 408 | 409 | union MyUnion = 410 | | TypeA 411 | | TypeB 412 | 413 | type TypeA { 414 | field1: String 415 | } 416 | 417 | type TypeB { 418 | field2: String 419 | }` 420 | 421 | const entryId = 'A' 422 | 423 | const entries = [ 424 | { 425 | id: entryId, 426 | metadata: { 427 | type: 'MyEntry', 428 | }, 429 | data: { 430 | union: [], 431 | }, 432 | } as Entry, 433 | ] 434 | 435 | gitAdapter.getLatestCommitHash 436 | .calledWith(gitRef) 437 | .mockResolvedValue(commitHash) 438 | gitAdapter.getSchema 439 | .calledWith(commitHash) 440 | .mockResolvedValue(originalSchema) 441 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 442 | 443 | const client = await createClient(gitAdapter) 444 | const result = await client.postGraphQL(gitRef, { 445 | query: `query ($entryId: ID!) { 446 | data: MyEntry(id: $entryId) { 447 | id 448 | union { 449 | __typename 450 | } 451 | } 452 | }`, 453 | variables: { 454 | entryId: entryId, 455 | }, 456 | }) 457 | 458 | expect(result.errors).toBeUndefined() 459 | expect(result.data).toEqual({ 460 | data: { 461 | id: entryId, 462 | union: [], 463 | }, 464 | }) 465 | expect(result.ref).toBe(commitHash) 466 | }) 467 | 468 | it('should resolve @Entry-based unions', async () => { 469 | const gitAdapter = mock() 470 | const gitRef = 'myRef' 471 | const commitHash = 'abcd' 472 | const originalSchema = `directive @Entry on OBJECT 473 | 474 | type MyEntry @Entry { 475 | id: ID! 476 | union: MyUnion 477 | nonNullUnion: MyUnion! 478 | listUnion: [MyUnion] 479 | listNonNullUnion: [MyUnion!] 480 | nonNullListUnion: [MyUnion]! 481 | nonNullListNonNullUnion: [MyUnion!]! 482 | } 483 | 484 | union MyUnion = 485 | | EntryA 486 | | EntryB 487 | 488 | type EntryA @Entry { 489 | id: ID! 490 | field1: String 491 | } 492 | 493 | type EntryB @Entry { 494 | id: ID! 495 | field2: String 496 | }` 497 | 498 | const myEntryId = 'My' 499 | const entryBId = 'B' 500 | const field2Value = 'Field2 value' 501 | 502 | const entries = [ 503 | { 504 | id: myEntryId, 505 | metadata: { 506 | type: 'MyEntry', 507 | }, 508 | data: { 509 | union: { 510 | id: entryBId, 511 | }, 512 | nonNullUnion: { 513 | id: entryBId, 514 | }, 515 | listUnion: [ 516 | { 517 | id: entryBId, 518 | }, 519 | ], 520 | listNonNullUnion: [ 521 | { 522 | id: entryBId, 523 | }, 524 | ], 525 | nonNullListUnion: [ 526 | { 527 | id: entryBId, 528 | }, 529 | ], 530 | nonNullListNonNullUnion: [ 531 | { 532 | id: entryBId, 533 | }, 534 | ], 535 | }, 536 | } as Entry, 537 | { 538 | id: entryBId, 539 | metadata: { 540 | type: 'EntryB', 541 | }, 542 | data: { 543 | field2: field2Value, 544 | }, 545 | } as Entry, 546 | ] 547 | 548 | gitAdapter.getLatestCommitHash 549 | .calledWith(gitRef) 550 | .mockResolvedValue(commitHash) 551 | gitAdapter.getSchema 552 | .calledWith(commitHash) 553 | .mockResolvedValue(originalSchema) 554 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 555 | 556 | const client = await createClient(gitAdapter) 557 | const result = await client.postGraphQL(gitRef, { 558 | query: `query ($entryId: ID!) { 559 | data: MyEntry(id: $entryId) { 560 | id 561 | union { 562 | __typename 563 | ... on EntryB { 564 | field2 565 | } 566 | } 567 | union { 568 | __typename 569 | ... on EntryB { 570 | field2 571 | } 572 | } 573 | nonNullUnion { 574 | __typename 575 | ... on EntryB { 576 | field2 577 | } 578 | } 579 | listUnion { 580 | __typename 581 | ... on EntryB { 582 | field2 583 | } 584 | } 585 | listNonNullUnion { 586 | __typename 587 | ... on EntryB { 588 | field2 589 | } 590 | } 591 | nonNullListUnion { 592 | __typename 593 | ... on EntryB { 594 | field2 595 | } 596 | } 597 | nonNullListNonNullUnion { 598 | __typename 599 | ... on EntryB { 600 | field2 601 | } 602 | } 603 | } 604 | }`, 605 | variables: { 606 | entryId: myEntryId, 607 | }, 608 | }) 609 | 610 | expect(result.errors).toBeUndefined() 611 | expect(result.data).toEqual({ 612 | data: { 613 | id: myEntryId, 614 | union: { 615 | __typename: 'EntryB', 616 | field2: field2Value, 617 | }, 618 | nonNullUnion: { 619 | __typename: 'EntryB', 620 | field2: field2Value, 621 | }, 622 | listUnion: [ 623 | { 624 | __typename: 'EntryB', 625 | field2: field2Value, 626 | }, 627 | ], 628 | listNonNullUnion: [ 629 | { 630 | __typename: 'EntryB', 631 | field2: field2Value, 632 | }, 633 | ], 634 | nonNullListUnion: [ 635 | { 636 | __typename: 'EntryB', 637 | field2: field2Value, 638 | }, 639 | ], 640 | nonNullListNonNullUnion: [ 641 | { 642 | __typename: 'EntryB', 643 | field2: field2Value, 644 | }, 645 | ], 646 | }, 647 | }) 648 | expect(result.ref).toBe(commitHash) 649 | }) 650 | 651 | it('should resolve missing optional data to null', async () => { 652 | // the behavior asserted here is meant to reduce the need to migrate existing entries after adding 653 | // new object type fields to a schema; we expect this to be done by resolving missing (undefined) 654 | // nullable data to null 655 | const gitAdapter = mock() 656 | const gitRef = 'myRef' 657 | const commitHash = 'abcd' 658 | const originalSchema = `directive @Entry on OBJECT 659 | 660 | type MyEntry @Entry { 661 | id: ID! 662 | oldField: String 663 | newField: String 664 | newNestedTypeField: NestedType 665 | } 666 | 667 | type NestedType { 668 | myField: String 669 | }` 670 | 671 | const entryId = 'A' 672 | 673 | const entries = [ 674 | { 675 | id: entryId, 676 | metadata: { 677 | type: 'MyEntry', 678 | }, 679 | data: { 680 | oldField: 'Old value', 681 | // we pretend that this entry was committed when only `id` and `oldField` existed in the schema 682 | }, 683 | } as Entry, 684 | ] 685 | 686 | gitAdapter.getLatestCommitHash 687 | .calledWith(gitRef) 688 | .mockResolvedValue(commitHash) 689 | gitAdapter.getSchema 690 | .calledWith(commitHash) 691 | .mockResolvedValue(originalSchema) 692 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 693 | 694 | const client = await createClient(gitAdapter) 695 | const result = await client.postGraphQL(gitRef, { 696 | query: `query ($entryId: ID!) { 697 | data: MyEntry(id: $entryId) { 698 | id 699 | oldField 700 | newField 701 | newNestedTypeField { 702 | myField 703 | } 704 | } 705 | }`, 706 | variables: { 707 | entryId: entryId, 708 | }, 709 | }) 710 | 711 | expect(result.errors).toBeUndefined() 712 | expect(result.data).toEqual({ 713 | data: { 714 | id: entryId, 715 | oldField: 'Old value', 716 | newField: null, 717 | newNestedTypeField: null, 718 | }, 719 | }) 720 | expect(result.ref).toBe(commitHash) 721 | }) 722 | 723 | it('should resolve missing array data to a default value', async () => { 724 | // the behavior asserted here is meant to reduce the need to migrate existing entries after adding 725 | // new array fields to a schema; we expect this to be done by resolving missing (undefined) 726 | // array data to an empty array 727 | const gitAdapter = mock() 728 | const gitRef = 'myRef' 729 | const commitHash = 'abcd' 730 | const originalSchema = `directive @Entry on OBJECT 731 | 732 | type MyEntry @Entry { 733 | id: ID! 734 | oldField: String 735 | newNestedTypeArrayField: [NestedType] 736 | newNestedTypeNonNullArrayField: [NestedType]! 737 | } 738 | 739 | type NestedType { 740 | myField: String 741 | }` 742 | 743 | const entryId = 'A' 744 | 745 | const entries = [ 746 | { 747 | id: entryId, 748 | metadata: { 749 | type: 'MyEntry', 750 | }, 751 | data: { 752 | oldField: 'Old value', 753 | // we pretend that this entry was committed when only `id` and `oldField` existed in the schema 754 | }, 755 | } as Entry, 756 | ] 757 | 758 | gitAdapter.getLatestCommitHash 759 | .calledWith(gitRef) 760 | .mockResolvedValue(commitHash) 761 | gitAdapter.getSchema 762 | .calledWith(commitHash) 763 | .mockResolvedValue(originalSchema) 764 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 765 | 766 | const client = await createClient(gitAdapter) 767 | const result = await client.postGraphQL(gitRef, { 768 | query: `query ($entryId: ID!) { 769 | data: MyEntry(id: $entryId) { 770 | id 771 | oldField 772 | newNestedTypeArrayField { 773 | myField 774 | } 775 | newNestedTypeNonNullArrayField { 776 | myField 777 | } 778 | } 779 | }`, 780 | variables: { 781 | entryId: entryId, 782 | }, 783 | }) 784 | 785 | expect(result.errors).toBeUndefined() 786 | expect(result.data).toEqual({ 787 | data: { 788 | id: entryId, 789 | oldField: 'Old value', 790 | newNestedTypeArrayField: null, 791 | newNestedTypeNonNullArrayField: [], 792 | }, 793 | }) 794 | expect(result.ref).toBe(commitHash) 795 | }) 796 | 797 | it('should resolve missing non-null object type data to an error', async () => { 798 | const gitAdapter = mock() 799 | const gitRef = 'myRef' 800 | const commitHash = 'abcd' 801 | const originalSchema = `directive @Entry on OBJECT 802 | 803 | type MyEntry @Entry { 804 | id: ID! 805 | oldField: String 806 | newNestedTypeField: NestedType! 807 | } 808 | 809 | type NestedType { 810 | myField: String 811 | }` 812 | 813 | const entryId = 'A' 814 | 815 | const entries = [ 816 | { 817 | id: entryId, 818 | metadata: { 819 | type: 'MyEntry', 820 | }, 821 | data: { 822 | oldField: 'Old value', 823 | // we pretend that this entry was committed when only `id` and `oldField` existed in the schema 824 | }, 825 | } as Entry, 826 | ] 827 | 828 | gitAdapter.getLatestCommitHash 829 | .calledWith(gitRef) 830 | .mockResolvedValue(commitHash) 831 | gitAdapter.getSchema 832 | .calledWith(commitHash) 833 | .mockResolvedValue(originalSchema) 834 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 835 | 836 | const client = await createClient(gitAdapter) 837 | const result = await client.postGraphQL(gitRef, { 838 | query: `query ($entryId: ID!) { 839 | data: MyEntry(id: $entryId) { 840 | id 841 | oldField 842 | newNestedTypeField { 843 | myField 844 | } 845 | } 846 | }`, 847 | variables: { 848 | entryId: entryId, 849 | }, 850 | }) 851 | 852 | expect(result.errors).toHaveLength(1) 853 | expect(result.data).toEqual({ data: null }) 854 | expect(result.ref).toBe(commitHash) 855 | }) 856 | 857 | it('should resolve missing optional enum data to null', async () => { 858 | const gitAdapter = mock() 859 | const gitRef = 'myRef' 860 | const commitHash = 'abcd' 861 | const originalSchema = `directive @Entry on OBJECT 862 | 863 | type MyEntry @Entry { 864 | id: ID! 865 | oldField: String 866 | newEnumField: EnumType 867 | } 868 | 869 | enum EnumType { 870 | A 871 | B 872 | }` 873 | 874 | const entryId = 'A' 875 | 876 | const entries = [ 877 | { 878 | id: entryId, 879 | metadata: { 880 | type: 'MyEntry', 881 | }, 882 | data: { 883 | oldField: 'Old value', 884 | // we pretend that this entry was committed when only `id` and `oldField` existed in the schema 885 | }, 886 | } as Entry, 887 | ] 888 | 889 | gitAdapter.getLatestCommitHash 890 | .calledWith(gitRef) 891 | .mockResolvedValue(commitHash) 892 | gitAdapter.getSchema 893 | .calledWith(commitHash) 894 | .mockResolvedValue(originalSchema) 895 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 896 | 897 | const client = await createClient(gitAdapter) 898 | const result = await client.postGraphQL(gitRef, { 899 | query: `query ($entryId: ID!) { 900 | data: MyEntry(id: $entryId) { 901 | id 902 | oldField 903 | newEnumField 904 | } 905 | }`, 906 | variables: { 907 | entryId: entryId, 908 | }, 909 | }) 910 | 911 | expect(result.errors).toBeUndefined() 912 | expect(result.data).toEqual({ 913 | data: { 914 | id: entryId, 915 | oldField: 'Old value', 916 | newEnumField: null, 917 | }, 918 | }) 919 | }) 920 | 921 | it('should resolve missing non-null enum data to an error', async () => { 922 | const gitAdapter = mock() 923 | const gitRef = 'myRef' 924 | const commitHash = 'abcd' 925 | const originalSchema = `directive @Entry on OBJECT 926 | 927 | type MyEntry @Entry { 928 | id: ID! 929 | oldField: String 930 | newEnumField: EnumType! 931 | } 932 | 933 | enum EnumType { 934 | A 935 | B 936 | }` 937 | 938 | const entryId = 'A' 939 | 940 | const entries = [ 941 | { 942 | id: entryId, 943 | metadata: { 944 | type: 'MyEntry', 945 | }, 946 | data: { 947 | oldField: 'Old value', 948 | // we pretend that this entry was committed when only `id` and `oldField` existed in the schema 949 | }, 950 | } as Entry, 951 | ] 952 | 953 | gitAdapter.getLatestCommitHash 954 | .calledWith(gitRef) 955 | .mockResolvedValue(commitHash) 956 | gitAdapter.getSchema 957 | .calledWith(commitHash) 958 | .mockResolvedValue(originalSchema) 959 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 960 | 961 | const client = await createClient(gitAdapter) 962 | const result = await client.postGraphQL(gitRef, { 963 | query: `query ($entryId: ID!) { 964 | data: MyEntry(id: $entryId) { 965 | id 966 | oldField 967 | newEnumField 968 | } 969 | }`, 970 | variables: { 971 | entryId: entryId, 972 | }, 973 | }) 974 | 975 | expect(result.errors).toHaveLength(1) 976 | expect(result.data).toEqual({ data: null }) 977 | expect(result.ref).toBe(commitHash) 978 | }) 979 | 980 | it('should resolve missing non-null union data to an error', async () => { 981 | const gitAdapter = mock() 982 | const gitRef = 'myRef' 983 | const commitHash = 'abcd' 984 | const originalSchema = `directive @Entry on OBJECT 985 | 986 | type MyEntry @Entry { 987 | id: ID! 988 | oldField: String 989 | newUnionField: MyUnion! 990 | } 991 | 992 | union MyUnion = 993 | | TypeA 994 | | TypeB 995 | 996 | type TypeA { 997 | field1: String 998 | } 999 | 1000 | type TypeB { 1001 | field2: String 1002 | }` 1003 | 1004 | const entryId = 'A' 1005 | 1006 | const entries = [ 1007 | { 1008 | id: entryId, 1009 | metadata: { 1010 | type: 'MyEntry', 1011 | }, 1012 | data: { 1013 | oldField: 'Old value', 1014 | // we pretend that this entry was committed when only `id` and `oldField` existed in the schema 1015 | }, 1016 | } as Entry, 1017 | ] 1018 | 1019 | gitAdapter.getLatestCommitHash 1020 | .calledWith(gitRef) 1021 | .mockResolvedValue(commitHash) 1022 | gitAdapter.getSchema 1023 | .calledWith(commitHash) 1024 | .mockResolvedValue(originalSchema) 1025 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 1026 | 1027 | const client = await createClient(gitAdapter) 1028 | const result = await client.postGraphQL(gitRef, { 1029 | query: `query ($entryId: ID!) { 1030 | data: MyEntry(id: $entryId) { 1031 | id 1032 | oldField 1033 | newUnionField { 1034 | __typename 1035 | } 1036 | } 1037 | }`, 1038 | variables: { 1039 | entryId: entryId, 1040 | }, 1041 | }) 1042 | 1043 | expect(result.errors).toHaveLength(1) 1044 | expect(result.data).toEqual({ data: null }) 1045 | expect(result.ref).toBe(commitHash) 1046 | }) 1047 | 1048 | it('should return entries that only have an `id` field', async () => { 1049 | const gitAdapter = mock() 1050 | const gitRef = 'myRef' 1051 | const commitHash = 'abcd' 1052 | const originalSchema = `directive @Entry on OBJECT 1053 | 1054 | type MyEntry @Entry { 1055 | id: ID! 1056 | }` 1057 | 1058 | const entryId = 'A' 1059 | 1060 | const entries = [ 1061 | { 1062 | id: entryId, 1063 | metadata: { 1064 | type: 'MyEntry', 1065 | }, 1066 | } as Entry, 1067 | ] 1068 | 1069 | gitAdapter.getLatestCommitHash 1070 | .calledWith(gitRef) 1071 | .mockResolvedValue(commitHash) 1072 | gitAdapter.getSchema 1073 | .calledWith(commitHash) 1074 | .mockResolvedValue(originalSchema) 1075 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 1076 | 1077 | const client = await createClient(gitAdapter) 1078 | const result = await client.postGraphQL(gitRef, { 1079 | query: `query ($entryId: ID!) { 1080 | data: MyEntry(id: $entryId) { 1081 | id 1082 | } 1083 | }`, 1084 | variables: { 1085 | entryId: entryId, 1086 | }, 1087 | }) 1088 | 1089 | expect(result.errors).toBeUndefined() 1090 | expect(result.data).toEqual({ 1091 | data: { 1092 | id: entryId, 1093 | }, 1094 | }) 1095 | expect(result.ref).toBe(commitHash) 1096 | }) 1097 | }) 1098 | 1099 | it('should return an error for queries for non-existent entries', async () => { 1100 | const gitAdapter = mock() 1101 | const gitRef = 'myRef' 1102 | const commitHash = 'abcd' 1103 | const originalSchema = `directive @Entry on OBJECT 1104 | 1105 | type MyEntry @Entry { 1106 | id: ID! 1107 | }` 1108 | 1109 | const entryId = 'unknown-entry' 1110 | const entries: Entry[] = [] 1111 | 1112 | gitAdapter.getLatestCommitHash 1113 | .calledWith(gitRef) 1114 | .mockResolvedValue(commitHash) 1115 | gitAdapter.getSchema.calledWith(commitHash).mockResolvedValue(originalSchema) 1116 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 1117 | 1118 | const client = await createClient(gitAdapter) 1119 | const result = await client.postGraphQL(gitRef, { 1120 | query: `query ($entryId: ID!) { 1121 | data: MyEntry (id: $entryId) { 1122 | id 1123 | } 1124 | }`, 1125 | variables: { 1126 | entryId: entryId, 1127 | }, 1128 | }) 1129 | 1130 | expect(result.errors).toEqual([ 1131 | expect.objectContaining({ 1132 | extensions: { 1133 | code: 'NOT_FOUND', 1134 | commitspark: { 1135 | argumentName: 'id', 1136 | argumentValue: entryId, 1137 | }, 1138 | }, 1139 | }), 1140 | ]) 1141 | expect(result.data).toEqual({ data: null }) 1142 | expect(result.ref).toBe(commitHash) 1143 | }) 1144 | --------------------------------------------------------------------------------