fix: synchronous transaction errors (#4164)
Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
This commit is contained in:
@@ -2,7 +2,10 @@ import type { PayloadRequest } from '../../express/types'
|
||||
import type { AllOperations } from '../../types'
|
||||
import type { Permissions } from '../types'
|
||||
|
||||
import { commitTransaction } from '../../utilities/commitTransaction'
|
||||
import { getEntityPolicies } from '../../utilities/getEntityPolicies'
|
||||
import { initTransaction } from '../../utilities/initTransaction'
|
||||
import { killTransaction } from '../../utilities/killTransaction'
|
||||
import { adminInit as adminInitTelemetry } from '../../utilities/telemetry/events/adminInit'
|
||||
|
||||
const allOperations: AllOperations[] = ['create', 'read', 'update', 'delete']
|
||||
@@ -38,6 +41,8 @@ async function accessOperation(args: Arguments): Promise<Permissions> {
|
||||
results.canAccessAdmin = false
|
||||
}
|
||||
|
||||
try {
|
||||
const shouldCommit = await initTransaction(req)
|
||||
await Promise.all(
|
||||
config.collections.map(async (collection) => {
|
||||
const collectionOperations = [...allOperations]
|
||||
@@ -88,7 +93,12 @@ async function accessOperation(args: Arguments): Promise<Permissions> {
|
||||
}),
|
||||
)
|
||||
|
||||
if (shouldCommit) await commitTransaction(req)
|
||||
return results
|
||||
} catch (e: unknown) {
|
||||
await killTransaction(req)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export default accessOperation
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { CollectionPermission } from '../../auth'
|
||||
import type { PayloadRequest } from '../../express/types'
|
||||
import type { AllOperations } from '../../types'
|
||||
|
||||
import { commitTransaction } from '../../utilities/commitTransaction'
|
||||
import { getEntityPolicies } from '../../utilities/getEntityPolicies'
|
||||
import { initTransaction } from '../../utilities/initTransaction'
|
||||
import { killTransaction } from '../../utilities/killTransaction'
|
||||
|
||||
const allOperations: AllOperations[] = ['create', 'read', 'update', 'delete']
|
||||
|
||||
@@ -34,11 +37,22 @@ export async function docAccess(args: Arguments): Promise<CollectionPermission>
|
||||
collectionOperations.push('readVersions')
|
||||
}
|
||||
|
||||
return getEntityPolicies({
|
||||
try {
|
||||
const shouldCommit = await initTransaction(req)
|
||||
|
||||
const result = await getEntityPolicies({
|
||||
id,
|
||||
entity: config,
|
||||
operations: collectionOperations,
|
||||
req,
|
||||
type: 'collection',
|
||||
})
|
||||
|
||||
if (shouldCommit) await commitTransaction(req)
|
||||
|
||||
return result
|
||||
} catch (e: unknown) {
|
||||
await killTransaction(req)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ export declare type PayloadRequest<U = any> = Request & {
|
||||
* Identifier for the database transaction for interactions in a single, all-or-nothing operation.
|
||||
*/
|
||||
transactionID?: number | string
|
||||
/**
|
||||
* Used to ensure consistency when multiple operations try to create a transaction concurrently on the same request
|
||||
*/
|
||||
transactionIDPromise?: Promise<void>
|
||||
/** The signed in user */
|
||||
user: (U & User) | null
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { PayloadRequest } from '../../express/types'
|
||||
import type { AllOperations } from '../../types'
|
||||
import type { SanitizedGlobalConfig } from '../config/types'
|
||||
|
||||
import { commitTransaction } from '../../utilities/commitTransaction'
|
||||
import { getEntityPolicies } from '../../utilities/getEntityPolicies'
|
||||
import { initTransaction } from '../../utilities/initTransaction'
|
||||
import { killTransaction } from '../../utilities/killTransaction'
|
||||
|
||||
type Arguments = {
|
||||
globalConfig: SanitizedGlobalConfig
|
||||
@@ -19,10 +22,18 @@ export async function docAccess(args: Arguments): Promise<GlobalPermission> {
|
||||
globalOperations.push('readVersions')
|
||||
}
|
||||
|
||||
return getEntityPolicies({
|
||||
try {
|
||||
const shouldCommit = await initTransaction(req)
|
||||
const result = await getEntityPolicies({
|
||||
entity: globalConfig,
|
||||
operations: globalOperations,
|
||||
req,
|
||||
type: 'global',
|
||||
})
|
||||
if (shouldCommit) await commitTransaction(req)
|
||||
return result
|
||||
} catch (e: unknown) {
|
||||
await killTransaction(req)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,24 @@ import type { PayloadRequest } from '../express/types'
|
||||
* @returns true if beginning a transaction and false when req already has a transaction to use
|
||||
*/
|
||||
export async function initTransaction(req: PayloadRequest): Promise<boolean> {
|
||||
const { payload, transactionID } = req
|
||||
if (!transactionID && typeof payload.db.beginTransaction === 'function') {
|
||||
req.transactionID = await payload.db.beginTransaction()
|
||||
if (req.transactionID) {
|
||||
return true
|
||||
const { payload, transactionID, transactionIDPromise } = req
|
||||
if (transactionID) {
|
||||
// we already have a transaction, we're not in charge of committing it
|
||||
return false
|
||||
}
|
||||
if (transactionIDPromise) {
|
||||
// wait for whoever else is already creating the transaction
|
||||
await transactionIDPromise
|
||||
return false
|
||||
}
|
||||
if (typeof payload.db.beginTransaction === 'function') {
|
||||
// create a new transaction
|
||||
req.transactionIDPromise = payload.db.beginTransaction().then((transactionID) => {
|
||||
req.transactionID = transactionID
|
||||
delete req.transactionIDPromise
|
||||
})
|
||||
await req.transactionIDPromise
|
||||
return !!req.transactionID
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
80
test/database/config.ts
Normal file
80
test/database/config.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { buildConfigWithDefaults } from '../buildConfigWithDefaults'
|
||||
import { devUser } from '../credentials'
|
||||
|
||||
export default buildConfigWithDefaults({
|
||||
collections: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
required: true,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'throwAfterChange',
|
||||
defaultValue: false,
|
||||
hooks: {
|
||||
afterChange: [
|
||||
({ value }) => {
|
||||
if (value) {
|
||||
throw new Error('throw after change')
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'checkbox',
|
||||
},
|
||||
],
|
||||
slug: 'posts',
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
name: 'relationship',
|
||||
relationTo: 'relation-b',
|
||||
type: 'relationship',
|
||||
},
|
||||
{
|
||||
name: 'richText',
|
||||
type: 'richText',
|
||||
},
|
||||
],
|
||||
labels: {
|
||||
plural: 'Relation As',
|
||||
singular: 'Relation A',
|
||||
},
|
||||
slug: 'relation-a',
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
name: 'relationship',
|
||||
relationTo: 'relation-a',
|
||||
type: 'relationship',
|
||||
},
|
||||
{
|
||||
name: 'richText',
|
||||
type: 'richText',
|
||||
},
|
||||
],
|
||||
labels: {
|
||||
plural: 'Relation Bs',
|
||||
singular: 'Relation B',
|
||||
},
|
||||
slug: 'relation-b',
|
||||
},
|
||||
],
|
||||
onInit: async (payload) => {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: devUser.email,
|
||||
password: devUser.password,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const postDoc = {
|
||||
title: 'test post',
|
||||
}
|
||||
195
test/database/int.spec.ts
Normal file
195
test/database/int.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { GraphQLClient } from 'graphql-request'
|
||||
|
||||
import type { TypeWithID } from '../../packages/payload/src/collections/config/types'
|
||||
import type { PayloadRequest } from '../../packages/payload/src/express/types'
|
||||
|
||||
import payload from '../../packages/payload/src'
|
||||
import { commitTransaction } from '../../packages/payload/src/utilities/commitTransaction'
|
||||
import { initTransaction } from '../../packages/payload/src/utilities/initTransaction'
|
||||
import { devUser } from '../credentials'
|
||||
import { initPayloadTest } from '../helpers/configHelpers'
|
||||
|
||||
describe('database', () => {
|
||||
let serverURL
|
||||
let client: GraphQLClient
|
||||
let token: string
|
||||
const collection = 'posts'
|
||||
const title = 'title'
|
||||
let user: TypeWithID & Record<string, unknown>
|
||||
let useTransactions = true
|
||||
|
||||
beforeAll(async () => {
|
||||
const init = await initPayloadTest({ __dirname, init: { local: false } })
|
||||
serverURL = init.serverURL
|
||||
const url = `${serverURL}/api/graphql`
|
||||
client = new GraphQLClient(url)
|
||||
if (payload.db.name === 'mongoose') {
|
||||
useTransactions = false
|
||||
}
|
||||
|
||||
const loginResult = await payload.login({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: devUser.email,
|
||||
password: devUser.password,
|
||||
},
|
||||
})
|
||||
|
||||
if (loginResult.token) token = loginResult.token
|
||||
user = loginResult.user
|
||||
})
|
||||
|
||||
describe('transactions', () => {
|
||||
describe('local api', () => {
|
||||
it('should commit multiple operations in isolation', async () => {
|
||||
const req = {
|
||||
payload,
|
||||
user,
|
||||
} as PayloadRequest
|
||||
|
||||
await initTransaction(req)
|
||||
|
||||
const first = await payload.create({
|
||||
collection,
|
||||
data: {
|
||||
title,
|
||||
},
|
||||
req,
|
||||
})
|
||||
|
||||
if (useTransactions) {
|
||||
await expect(() =>
|
||||
payload.findByID({
|
||||
id: first.id,
|
||||
collection,
|
||||
// omitting req for isolation
|
||||
}),
|
||||
).rejects.toThrow('The requested resource was not found.')
|
||||
}
|
||||
|
||||
const second = await payload.create({
|
||||
collection,
|
||||
data: {
|
||||
title,
|
||||
},
|
||||
req,
|
||||
})
|
||||
|
||||
await commitTransaction(req)
|
||||
expect(req.transactionID).toBeUndefined()
|
||||
|
||||
const firstResult = await payload.findByID({
|
||||
id: first.id,
|
||||
collection,
|
||||
req,
|
||||
})
|
||||
const secondResult = await payload.findByID({
|
||||
id: second.id,
|
||||
collection,
|
||||
req,
|
||||
})
|
||||
|
||||
expect(firstResult.id).toStrictEqual(first.id)
|
||||
expect(secondResult.id).toStrictEqual(second.id)
|
||||
})
|
||||
|
||||
it('should commit multiple operations async', async () => {
|
||||
const req = {
|
||||
payload,
|
||||
user,
|
||||
} as PayloadRequest
|
||||
|
||||
let first
|
||||
let second
|
||||
|
||||
const firstReq = payload
|
||||
.create({
|
||||
collection,
|
||||
data: {
|
||||
title,
|
||||
},
|
||||
req,
|
||||
})
|
||||
.then((res) => {
|
||||
first = res
|
||||
})
|
||||
|
||||
const secondReq = payload
|
||||
.create({
|
||||
collection,
|
||||
data: {
|
||||
title,
|
||||
},
|
||||
req,
|
||||
})
|
||||
.then((res) => {
|
||||
second = res
|
||||
})
|
||||
|
||||
await Promise.all([firstReq, secondReq])
|
||||
|
||||
await commitTransaction(req)
|
||||
expect(req.transactionID).toBeUndefined()
|
||||
|
||||
const firstResult = await payload.findByID({
|
||||
id: first.id,
|
||||
collection,
|
||||
req,
|
||||
})
|
||||
const secondResult = await payload.findByID({
|
||||
id: second.id,
|
||||
collection,
|
||||
req,
|
||||
})
|
||||
|
||||
expect(firstResult.id).toStrictEqual(first.id)
|
||||
expect(secondResult.id).toStrictEqual(second.id)
|
||||
})
|
||||
|
||||
it('should rollback operations on failure', async () => {
|
||||
const req = {
|
||||
payload,
|
||||
user,
|
||||
} as PayloadRequest
|
||||
|
||||
await initTransaction(req)
|
||||
|
||||
const first = await payload.create({
|
||||
collection,
|
||||
data: {
|
||||
title,
|
||||
},
|
||||
req,
|
||||
})
|
||||
|
||||
try {
|
||||
await payload.create({
|
||||
collection,
|
||||
data: {
|
||||
throwAfterChange: true,
|
||||
title,
|
||||
},
|
||||
req,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// catch error and carry on
|
||||
}
|
||||
|
||||
expect(req.transactionID).toBeFalsy()
|
||||
|
||||
// this should not do anything but is needed to be certain about the next assertion
|
||||
await commitTransaction(req)
|
||||
|
||||
if (useTransactions) {
|
||||
await expect(() =>
|
||||
payload.findByID({
|
||||
id: first.id,
|
||||
collection,
|
||||
req,
|
||||
}),
|
||||
).rejects.toThrow('The requested resource was not found.')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
90
test/database/payload-types.ts
Normal file
90
test/database/payload-types.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by Payload.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||
* and re-run `payload generate:types` to regenerate this file.
|
||||
*/
|
||||
|
||||
export interface Config {
|
||||
collections: {
|
||||
posts: Post
|
||||
'relation-a': RelationA
|
||||
'relation-b': RelationB
|
||||
users: User
|
||||
'payload-preferences': PayloadPreference
|
||||
'payload-migrations': PayloadMigration
|
||||
}
|
||||
globals: {}
|
||||
}
|
||||
export interface Post {
|
||||
id: string
|
||||
title: string
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
}
|
||||
export interface RelationA {
|
||||
id: string
|
||||
relationship?: (string | null) | RelationB
|
||||
richText?:
|
||||
| {
|
||||
[k: string]: unknown
|
||||
}[]
|
||||
| null
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
}
|
||||
export interface RelationB {
|
||||
id: string
|
||||
relationship?: (string | null) | RelationA
|
||||
richText?:
|
||||
| {
|
||||
[k: string]: unknown
|
||||
}[]
|
||||
| null
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
}
|
||||
export interface User {
|
||||
id: string
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
email: string
|
||||
resetPasswordToken?: string | null
|
||||
resetPasswordExpiration?: string | null
|
||||
salt?: string | null
|
||||
hash?: string | null
|
||||
loginAttempts?: number | null
|
||||
lockUntil?: string | null
|
||||
password: string | null
|
||||
}
|
||||
export interface PayloadPreference {
|
||||
id: string
|
||||
user: {
|
||||
relationTo: 'users'
|
||||
value: string | User
|
||||
}
|
||||
key?: string | null
|
||||
value?:
|
||||
| {
|
||||
[k: string]: unknown
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
}
|
||||
export interface PayloadMigration {
|
||||
id: string
|
||||
name?: string | null
|
||||
batch?: number | null
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
declare module 'payload' {
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
Reference in New Issue
Block a user