Guide
Transactions
Everything the body writes lands together, or none of it does — without threading a session through every call.
Setup
Transactions are powered by a database adapter. Pass it to Parse Server instead of a plain URI:
import {
createVersionedMongoAdapter, CloudFunction, Transactional,
withTransaction,
} from 'parse-server-kit';
// parse-server refuses to boot with an explicit database adapter unless a
// files adapter is named too. GridFS is what a Mongo install used anyway.
const {GridFSBucketAdapter} =
require('parse-server/lib/Adapters/Files/GridFSBucketAdapter');
// Built as an object and cast — see the note below.
const options = {
databaseAdapter: createVersionedMongoAdapter({
uri: process.env.DATABASE_URI,
collectionPrefix: '',
mongoOptions: {},
}),
filesAdapter: new GridFSBucketAdapter(process.env.DATABASE_URI),
directAccess: true, // REQUIRED - see below
// ...the rest of your options
};
const parseServer = ParseServer(options as any);
Two upstream quirks you will hit here
Neither is something this library can fix, but both stop a transaction setup from
starting, and neither message says what to do about it.
1. An explicit database adapter forces an explicit files adapter. parse-server 9 exits with "When using an explicit database adapter, you must also use an explicit filesAdapter." Naming
2. The types and the runtime disagree about
1. An explicit database adapter forces an explicit files adapter. parse-server 9 exits with "When using an explicit database adapter, you must also use an explicit filesAdapter." Naming
GridFSBucketAdapter reproduces
what you had before, since GridFS is the default for MongoDB.
2. The types and the runtime disagree about
databaseURI.
ParseServerOptions marks it required and does not special-case
databaseAdapter, so leaving it out is a compile error. Passing both throws
at runtime: "You cannot specify both a databaseAdapter and a
databaseURI/databaseOptions/collectionPrefix." Nothing satisfies both, which is
why the options object above is cast.
| MongoDB only | On Postgres the feature is unavailable and says so at boot |
| Replica set required | A standalone mongod refuses to open a transaction |
directAccess: true | Parse Server's default, but pin it explicitly |
Using it
@CloudFunction({ methods: ['POST'] })
@Transactional()
static async placeOrder(req: Parse.Cloud.FunctionRequest) {
await order.save(null, { useMasterKey: true });
await inventory.save(null, { useMasterKey: true });
} // both land, or neither
// Outside a cloud function, or for part of one:
await withTransaction(async () => { … });
Every save(), destroy() and query inside the body joins automatically. The session travels with the call chain through AsyncLocalStorage, so nothing has to be passed down.
Decorator order no longer matters
@CloudFunction used to capture the method at the moment it was applied, so
writing @Transactional() above it meant the registry kept the unwrapped
method and the transaction silently never opened. Since 3.0 the registry re-reads the
method when it registers it, so either order works. Below is still marginally better —
the role check then runs outside the transaction, so an unauthorised request never
opens one.
What it guarantees
- Nested calls join the outer transaction; the outermost caller commits.
- The body may re-run — up to three attempts on a transient conflict — so it must be safe to repeat. After three losses the caller gets
CONFLICT. - Each request gets its own session. Parse Server's built-in transaction keeps the open session on a shared controller, so one caller's transaction swallows every unrelated request running at the same time. This one cannot.
- System classes never join —
_SCHEMA,_Idempotency,_Hooks,_JobStatus,_GlobalConfig. Schema creation has to survive a rollback. - Unfiltered counts read outside the transaction, because MongoDB refuses the
countcommand inside one.
Without directAccess there is no symptom
A
save() in cloud code becomes an internal HTTP request that arrives in a
fresh async context with an empty store — so it writes outside the transaction.
Every write succeeds and the rollback rolls nothing back. Nothing is logged, which is
why the option is worth pinning rather than relying on its default.
Handling a conflict
try {
await job.save(null, { useMasterKey: true });
} catch (error) {
if (error instanceof Parse.Error && error.code === CONFLICT) {
// Reload, re-apply, retry - or show CONFLICT_MESSAGE,
// which is already written for an end user.
}
throw error;
}
A lost transaction race and a lost optimistic lock report the same code, because to the person looking at the screen they are the same event: somebody else got there first.