Compare commits

...

2 Commits

Author SHA1 Message Date
Dan Ribbens
26a2f8d0d7 test(db-postgres): reproduce graphql transaction bug 2023-10-19 16:23:31 -04:00
Dan Ribbens
c7a02803a0 wip configurable mongodb transactions 2023-10-19 10:19:55 -04:00
7 changed files with 197 additions and 111 deletions

View File

@@ -6,6 +6,12 @@ module.exports = {
extends: ['plugin:@typescript-eslint/disable-type-checked'],
files: ['*.js', '*.cjs', '*.json', '*.md', '*.yml', '*.yaml'],
},
{
files: ['**/*.ts'],
rules: {
'@typescript-eslint/no-redundant-type-constituents': 'off',
},
},
{
files: ['package.json', 'tsconfig.json'],
rules: {

View File

@@ -29,9 +29,12 @@
"prompts": "2.4.2",
"uuid": "9.0.0"
},
"peerDependencies": {
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/mongoose-aggregate-paginate-v2": "1.0.9",
"mongodb": "^6.1.0",
"mongodb-memory-server": "8.13.0",
"payload": "workspace:*"
},

View File

@@ -1,3 +1,4 @@
import type { TransactionOptions } from 'mongodb'
import type { ClientSession, ConnectOptions, Connection } from 'mongoose'
import type { Payload } from 'payload'
import type { BaseDatabaseAdapter } from 'payload/database'
@@ -6,8 +7,6 @@ import mongoose from 'mongoose'
import path from 'path'
import { createDatabaseAdapter } from 'payload/database'
export type { MigrateDownArgs, MigrateUpArgs } from './types'
import type { CollectionModel, GlobalModel } from './types'
import { connect } from './connect'
@@ -38,6 +37,8 @@ import { updateGlobalVersion } from './updateGlobalVersion'
import { updateOne } from './updateOne'
import { updateVersion } from './updateVersion'
export type { MigrateDownArgs, MigrateUpArgs } from './types'
export interface Args {
/** Set to false to disable auto-pluralization of collection names, Defaults to true */
autoPluralization?: boolean
@@ -47,6 +48,10 @@ export interface Args {
useFacet?: boolean
}
migrationDir?: string
/**
* set to false to disable using transactions
*/
transactions?: TransactionOptions | false
/** The URL to connect to MongoDB or false to start payload and prevent connecting */
url: false | string
}
@@ -60,6 +65,7 @@ export type MongooseAdapter = BaseDatabaseAdapter &
globals: GlobalModel
mongoMemoryServer: any
sessions: Record<number | string, ClientSession>
transactionOptions: TransactionOptions | false
versions: {
[slug: string]: CollectionModel
}
@@ -70,7 +76,7 @@ type MongooseAdapterResult = (args: { payload: Payload }) => MongooseAdapter
declare module 'payload' {
export interface DatabaseAdapter
extends Omit<BaseDatabaseAdapter, 'sessions'>,
Omit<Args, 'migrationDir'> {
Omit<Args, 'migrationDir' | 'transactions'> {
collections: {
[slug: string]: CollectionModel
}
@@ -88,6 +94,7 @@ export function mongooseAdapter({
autoPluralization = true,
connectOptions,
migrationDir: migrationDirArg,
transactions,
url,
}: Args): MongooseAdapterResult {
function adapter({ payload }: { payload: Payload }) {
@@ -108,6 +115,12 @@ export function mongooseAdapter({
globals: undefined,
mongoMemoryServer: undefined,
sessions: {},
transactionOptions: transactions ?? {
readConcern: { level: 'local' },
// TODO: this needs to be dynamic based on the operation
readPreference: 'nearest',
// readPreference: 'primary', // primary for write
},
url,
versions: {},

View File

@@ -1,4 +1,3 @@
// @ts-expect-error // TODO: Fix this import
import type { TransactionOptions } from 'mongodb'
import type { BeginTransaction } from 'payload/database'
@@ -7,8 +6,11 @@ import { v4 as uuid } from 'uuid'
let transactionsNotAvailable: boolean
export const beginTransaction: BeginTransaction = async function beginTransaction(
options: TransactionOptions = {},
options?: TransactionOptions,
) {
if (this.transactionOptions === false) {
return null
}
let id = null
if (!this.connection) {
throw new APIError('beginTransaction called while no connection to the database exists')
@@ -27,7 +29,7 @@ export const beginTransaction: BeginTransaction = async function beginTransactio
if (this.sessions[id].inTransaction()) {
this.payload.logger.warn('beginTransaction called while transaction already exists')
} else {
await this.sessions[id].startTransaction(options)
await this.sessions[id].startTransaction(options || this.transactionOptions)
}
}
return id

View File

@@ -3,7 +3,6 @@ import type { EditorProps } from '@monaco-editor/react'
import type { TFunction } from 'i18next'
import type { CSSProperties } from 'react'
import monacoeditor from 'monaco-editor' // IMPORTANT - DO NOT REMOVE: This is required for pnpm's default isolated mode to work - even though the import is not used. This is due to a typescript bug: https://github.com/microsoft/TypeScript/issues/47663#issuecomment-1519138189. (tsbugisolatedmode)
import type { ConditionalDateProps } from '../../admin/components/elements/DatePicker/types'
import type { Description } from '../../admin/components/forms/FieldDescription/types'
import type { RowLabel } from '../../admin/components/forms/RowLabel/types'

View File

@@ -12,14 +12,13 @@ export interface Relation {
const openAccess = {
create: () => true,
delete: () => true,
read: () => true,
update: () => true,
delete: () => true,
}
const collectionWithName = (collectionSlug: string): CollectionConfig => {
return {
slug: collectionSlug,
access: openAccess,
fields: [
{
@@ -27,55 +26,36 @@ const collectionWithName = (collectionSlug: string): CollectionConfig => {
type: 'text',
},
],
slug: collectionSlug,
}
}
export const slug = 'posts'
export const relationSlug = 'relation'
export const transactionSlug = 'transactions'
export const pointSlug = 'point'
export default buildConfigWithDefaults({
graphQL: {
schemaOutputFile: path.resolve(__dirname, 'schema.graphql'),
queries: (GraphQL) => {
return {
QueryWithInternalError: {
type: new GraphQL.GraphQLObjectType({
name: 'QueryWithInternalError',
fields: {
text: {
type: GraphQL.GraphQLString,
},
},
}),
resolve: () => {
// Throwing an internal error with potentially sensitive data
throw new Error('Lost connection to the Pentagon. Secret data: ******')
},
},
}
},
},
collections: [
{
slug: 'users',
auth: true,
access: openAccess,
auth: true,
fields: [],
slug: 'users',
},
{
slug: pointSlug,
access: openAccess,
fields: [
{
type: 'point',
name: 'point',
type: 'point',
},
],
slug: pointSlug,
},
{
slug,
access: openAccess,
fields: [
{
@@ -92,173 +72,173 @@ export default buildConfigWithDefaults({
},
{
name: 'min',
type: 'number',
min: 10,
type: 'number',
},
// Relationship
{
name: 'relationField',
type: 'relationship',
relationTo: relationSlug,
type: 'relationship',
},
{
name: 'relationToCustomID',
type: 'relationship',
relationTo: 'custom-ids',
type: 'relationship',
},
// Relation hasMany
{
name: 'relationHasManyField',
type: 'relationship',
relationTo: relationSlug,
hasMany: true,
relationTo: relationSlug,
type: 'relationship',
},
// Relation multiple relationTo
{
name: 'relationMultiRelationTo',
type: 'relationship',
relationTo: [relationSlug, 'dummy'],
type: 'relationship',
},
// Relation multiple relationTo hasMany
{
name: 'relationMultiRelationToHasMany',
type: 'relationship',
relationTo: [relationSlug, 'dummy'],
hasMany: true,
relationTo: [relationSlug, 'dummy'],
type: 'relationship',
},
{
name: 'A1',
type: 'group',
fields: [
{
type: 'text',
name: 'A2',
defaultValue: 'textInRowInGroup',
type: 'text',
},
],
type: 'group',
},
{
name: 'B1',
type: 'group',
fields: [
{
type: 'collapsible',
label: 'Collapsible',
fields: [
{
type: 'text',
name: 'B2',
defaultValue: 'textInRowInGroup',
type: 'text',
},
],
label: 'Collapsible',
type: 'collapsible',
},
],
type: 'group',
},
{
name: 'C1',
type: 'group',
fields: [
{
type: 'text',
name: 'C2Text',
type: 'text',
},
{
type: 'row',
fields: [
{
type: 'collapsible',
label: 'Collapsible2',
fields: [
{
name: 'C2',
type: 'group',
fields: [
{
type: 'row',
fields: [
{
type: 'collapsible',
label: 'Collapsible2',
fields: [
{
type: 'text',
name: 'C3',
type: 'text',
},
],
label: 'Collapsible2',
type: 'collapsible',
},
],
type: 'row',
},
],
type: 'group',
},
],
label: 'Collapsible2',
type: 'collapsible',
},
],
type: 'row',
},
],
type: 'group',
},
{
type: 'tabs',
tabs: [
{
label: 'Tab1',
name: 'D1',
fields: [
{
name: 'D2',
type: 'group',
fields: [
{
type: 'row',
fields: [
{
type: 'collapsible',
label: 'Collapsible2',
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Tab1',
fields: [
{
name: 'D3',
type: 'group',
fields: [
{
type: 'row',
fields: [
{
type: 'collapsible',
label: 'Collapsible2',
fields: [
{
type: 'text',
name: 'D4',
type: 'text',
},
],
label: 'Collapsible2',
type: 'collapsible',
},
],
type: 'row',
},
],
type: 'group',
},
],
label: 'Tab1',
},
],
type: 'tabs',
},
],
label: 'Collapsible2',
type: 'collapsible',
},
],
type: 'row',
},
],
type: 'group',
},
],
label: 'Tab1',
},
],
type: 'tabs',
},
],
slug,
},
{
slug: 'custom-ids',
access: {
read: () => true,
},
@@ -272,45 +252,87 @@ export default buildConfigWithDefaults({
type: 'text',
},
],
slug: 'custom-ids',
},
collectionWithName(relationSlug),
collectionWithName('dummy'),
{
slug: 'payload-api-test-ones',
access: {
read: () => true,
},
fields: [
{
name: 'payloadAPI',
type: 'text',
hooks: {
afterRead: [({ req }) => req.payloadAPI],
},
type: 'text',
},
],
slug: 'payload-api-test-ones',
},
{
slug: 'payload-api-test-twos',
access: {
read: () => true,
},
fields: [
{
name: 'payloadAPI',
type: 'text',
hooks: {
afterRead: [({ req }) => req.payloadAPI],
},
type: 'text',
},
{
name: 'relation',
type: 'relationship',
relationTo: 'payload-api-test-ones',
type: 'relationship',
},
],
slug: 'payload-api-test-twos',
},
{
access: openAccess,
fields: [
{
name: 'transactionID',
hooks: {
beforeChange: [({ req }) => req.transactionID],
},
type: 'text',
},
{
name: 'sessions',
hooks: {
beforeChange: [({ req }) => Object.keys(req.payload.db.sessions)],
},
type: 'json',
},
],
slug: transactionSlug,
},
],
graphQL: {
queries: (GraphQL) => {
return {
QueryWithInternalError: {
resolve: () => {
// Throwing an internal error with potentially sensitive data
throw new Error('Lost connection to the Pentagon. Secret data: ******')
},
type: new GraphQL.GraphQLObjectType({
name: 'QueryWithInternalError',
fields: {
text: {
type: GraphQL.GraphQLString,
},
},
}),
},
}
},
schemaOutputFile: path.resolve(__dirname, 'schema.graphql'),
},
onInit: async (payload) => {
const user = await payload.create({
collection: 'users',
@@ -331,8 +353,8 @@ export default buildConfigWithDefaults({
await payload.create({
collection: slug,
data: {
title: 'has custom ID relation',
relationToCustomID: 1,
title: 'has custom ID relation',
},
})
@@ -353,23 +375,23 @@ export default buildConfigWithDefaults({
await payload.create({
collection: slug,
data: {
title: 'with-description',
description: 'description',
title: 'with-description',
},
})
await payload.create({
collection: slug,
data: {
title: 'numPost1',
number: 1,
title: 'numPost1',
},
})
await payload.create({
collection: slug,
data: {
title: 'numPost2',
number: 2,
title: 'numPost2',
},
})
@@ -390,15 +412,15 @@ export default buildConfigWithDefaults({
await payload.create({
collection: slug,
data: {
title: 'rel to hasMany',
relationHasManyField: rel1.id,
title: 'rel to hasMany',
},
})
await payload.create({
collection: slug,
data: {
title: 'rel to hasMany 2',
relationHasManyField: rel2.id,
title: 'rel to hasMany 2',
},
})
@@ -406,11 +428,11 @@ export default buildConfigWithDefaults({
await payload.create({
collection: slug,
data: {
title: 'rel to multi',
relationMultiRelationTo: {
relationTo: relationSlug,
value: rel2.id,
},
title: 'rel to multi',
},
})
@@ -418,7 +440,6 @@ export default buildConfigWithDefaults({
await payload.create({
collection: slug,
data: {
title: 'rel to multi hasMany',
relationMultiRelationToHasMany: [
{
relationTo: relationSlug,
@@ -429,6 +450,7 @@ export default buildConfigWithDefaults({
value: rel2.id,
},
],
title: 'rel to multi hasMany',
},
})

View File

@@ -4,6 +4,7 @@ import type { Post } from './payload-types'
import payload from '../../packages/payload/src'
import { mapAsync } from '../../packages/payload/src/utilities/mapAsync'
import { devUser } from '../credentials'
import { initPayloadTest } from '../helpers/configHelpers'
import configPromise, { pointSlug, slug } from './config'
@@ -685,11 +686,11 @@ describe('collections-graphql', () => {
// language=graphQL
const query = `query {
Posts(where: { title: { exists: true }}) {
docs {
badFieldName
Posts(where: { title: { exists: true }}) {
docs {
badFieldName
}
}
}
}`
await client.request(query).catch((err) => {
error = err
@@ -702,12 +703,12 @@ describe('collections-graphql', () => {
let error
// language=graphQL
const query = `mutation {
createPost(data: {min: 1}) {
id
min
createdAt
updatedAt
}
createPost(data: {min: 1}) {
id
min
createdAt
updatedAt
}
}`
await client.request(query).catch((err) => {
@@ -722,21 +723,21 @@ describe('collections-graphql', () => {
let error
// language=graphQL
const query = `mutation createTest {
test1:createUser(data: { email: "test@test.com", password: "test" }) {
email
}
test1:createUser(data: { email: "test@test.com", password: "test" }) {
email
}
test2:createUser(data: { email: "test2@test.com", password: "" }) {
email
}
test2:createUser(data: { email: "test2@test.com", password: "" }) {
email
}
test3:createUser(data: { email: "test@test.com", password: "test" }) {
email
}
test3:createUser(data: { email: "test@test.com", password: "test" }) {
email
}
test4:createUser(data: { email: "", password: "test" }) {
email
}
test4:createUser(data: { email: "", password: "test" }) {
email
}
}`
await client.request(query).catch((err) => {
@@ -775,9 +776,9 @@ describe('collections-graphql', () => {
let error
// language=graphQL
const query = `query {
QueryWithInternalError {
text
}
QueryWithInternalError {
text
}
}`
await client.request(query).catch((err) => {
@@ -792,6 +793,46 @@ describe('collections-graphql', () => {
expect(error.response.errors[0].extensions.name).toEqual('Error')
})
})
if (['postgres'].includes(process.env.PAYLOAD_DATABASE)) {
describe('Transactions', () => {
let token
let user
beforeAll(async () => {
// language=graphQL
const query = `mutation {
loginUser(email: "${devUser.email}", password: "${devUser.password}") {
token
user {
id
email
}
}
}`
const response = await client.request(query)
user = response.loginUser.user
token = response.loginUser.token
client.setHeaders({ Authorization: `JWT ${token}` })
})
it('should use transaction', async () => {
const query = `mutation {
createTransaction(data: {}) {
id
transactionID
sessions
}
}`
const response = await client.request(query)
const doc = response.createTransaction
expect(doc.transactionID).toBeDefined()
expect(doc.sessions).toBeDefined()
expect(doc.sessions).toContain(doc.transactionID)
})
})
}
})
async function createPost(overrides?: Partial<Post>) {