@90soft/parse-server-kit
v2.9.0
Published
Complete decorator-driven development kit for Parse Server — models, cloud functions, triggers, cron, routing, ACL, Swagger, and more.
Maintainers
Readme
@90soft/parse-server-kit
Decorator-driven development kit for Parse Server. Write models and cloud functions with TypeScript decorators — no boilerplate.
Install
npm install @90soft/parse-server-kitRequired peers: parse, reflect-metadata, express
Optional peers — install only what you use. Each degrades with a warning rather than failing:
| Package | Needed for | Without it |
|---|---|---|
| node-cron | @Cron | Cron jobs are skipped |
| swagger-ui-express | setupSwagger browser UI | The spec is still served at /api-docs/json |
| parse-server | createVersionedMongoAdapter, createSchemaConfig | Transactions and @ParseVersionField are unavailable. 8.3+ required — createSchemaConfig emits keepUnknownIndexes, and parse-server refuses to start on an option it does not recognise |
| @types/parse | TypeScript builds | See below |
TypeScript setup
Two things are required in your tsconfig.json, and neither fails loudly if
you miss it:
{
"compilerOptions": {
"experimentalDecorators": true, // REQUIRED
"emitDecoratorMetadata": true // optional — see below
}
}experimentalDecorators: trueis mandatory. This package uses legacy decorators. TypeScript 5.0+ defaults to the standard (TC39) decorators, which are a different feature with different semantics — without this flag every@ParseClass/@ParseFieldin your project silently misbehaves.emitDecoratorMetadatais not required. The package reads nodesign:typemetadata; it stores its own keys. Turn it on only if something else in your project needs it.
This package's published type declarations refer to a global Parse
namespace, the way cloud code sees it. Install @types/parse to provide it:
npm install --save-dev @types/parseWithout it you will see Cannot find namespace 'Parse' when compiling against
the kit. If you already have @types/parse, nothing changes — do not also
declare your own global Parse namespace, or the two will collide.
Runtime requirements
| | |
|---|---|
| Node | ≥ 18 for this package; ≥ 20.19 if you are on parse-server 9 |
| MongoDB | ≥ 7.0.16 for parse-server 9; replica set required for transactions |
| Database | Transactions and @ParseVersionField are MongoDB-only — they have no effect on Postgres |
Quick Start
1. Define a Model
import { ParseClass, ParseField, BaseModel } from '@90soft/parse-server-kit';
import { UserRoles, roleKey } from '@90soft/parse-server-kit';
@ParseClass('Product', {
clp: {
find: { [roleKey(UserRoles.ADMIN)]: true, [roleKey(UserRoles.EMPLOYEE)]: true },
get: { [roleKey(UserRoles.ADMIN)]: true, [roleKey(UserRoles.EMPLOYEE)]: true },
create: { [roleKey(UserRoles.ADMIN)]: true, [roleKey(UserRoles.EMPLOYEE)]: true },
update: { [roleKey(UserRoles.ADMIN)]: true, [roleKey(UserRoles.EMPLOYEE)]: true },
delete: { [roleKey(UserRoles.ADMIN)]: true },
count: { [roleKey(UserRoles.ADMIN)]: true, [roleKey(UserRoles.EMPLOYEE)]: true },
},
})
export default class Product extends BaseModel {
@ParseField({ type: 'String', required: true })
name!: string;
@ParseField({ type: 'Number', required: true, min: 0 })
price!: number;
@ParseField({ type: 'String', enum: ['active', 'draft', 'archived'] })
status!: string;
@ParseField({ type: 'Pointer', targetClass: 'Category', required: true })
category!: any;
@ParseField({ type: 'Boolean' })
available!: boolean;
@ParseField({ type: 'Date' })
createdDate!: Date;
}2. Write Cloud Functions
import { CloudFunction, Route, catchError, UserRoles } from '@90soft/parse-server-kit';
import Product from '../../models/Product';
import User from '../../models/User';
@Route(Product) // → /api/products/createProduct, /api/products/listProducts, …
class ProductFunctions {
@CloudFunction({
methods: ['POST'],
validation: { requireUser: true, fields: { name: { required: true } } },
})
static async createProduct(req: Parse.Cloud.FunctionRequest) {
const user = req.user! as User;
const product = Product.fromParams(req.params);
const [err, saved] = await catchError(
product.save(null, { sessionToken: user.getSessionToken() })
);
if (err) throw err;
return saved;
}
@CloudFunction({
methods: ['GET'],
validation: { requireUser: true, fields: { skip: { type: String }, limit: { type: String } } },
})
static async listProducts(req: Parse.Cloud.FunctionRequest) {
const query = new Parse.Query(Product);
query.include('category');
const skip = Number(req.params.skip) || 0;
const limit = Number(req.params.limit) || 10;
query.skip(skip).limit(limit);
if (req.params.search) {
const search = req.params.search;
query.matches('name', new RegExp(search, 'i'));
}
const [err, results] = await catchError(query.find({ sessionToken: req.user!.getSessionToken() }));
if (err) throw err;
const [countErr, count] = await catchError(query.count({ sessionToken: req.user!.getSessionToken() }));
return { results, count: countErr ? 0 : count };
}
@CloudFunction({
methods: ['POST'],
validation: { requireUser: true, fields: { id: { required: true } } },
})
static async getProduct(req: Parse.Cloud.FunctionRequest) {
const query = new Parse.Query(Product);
query.include('category');
const [err, product] = await catchError(query.get(req.params.id, { sessionToken: req.user!.getSessionToken() }));
if (err) throw err;
return product;
}
@CloudFunction({
methods: ['POST'],
validation: { requireUser: true, fields: { id: { required: true } } },
})
static async updateProduct(req: Parse.Cloud.FunctionRequest) {
const user = req.user! as User;
const product = Product.fromParams(req.params);
const [err, saved] = await catchError(
product.save(null, { sessionToken: user.getSessionToken() })
);
if (err) throw err;
return saved;
}
@CloudFunction({
methods: ['POST'],
validation: { requireUser: true, fields: { id: { required: true } } },
})
static async deleteProduct(req: Parse.Cloud.FunctionRequest) {
const query = new Parse.Query(Product);
const [err, product] = await catchError(query.get(req.params.id, { useMasterKey: true }));
if (err) throw err;
const [delErr] = await catchError(product!.destroy({ useMasterKey: true }));
if (delErr) throw delErr;
return { success: true };
}
}Note on the search above.
query.matches()compiles to a Mongo$regex. parse-server 9.8+ can disable that operator entirely viarequestComplexity.allowRegex: false— a worthwhile hardening option, since an unanchored user-supplied regex is both a scan and a ReDoS surface. If you turn it on, replace this with a text index (@ParseClass({compoundIndexes: [{fields: ['name'], fieldTypes: {name: 'text'}}]})) andquery.fullText('name', search).
3. Add Triggers
import { ParseClass, ParseField, BaseModel, BeforeSave, AfterDelete } from '@90soft/parse-server-kit';
@ParseClass('Product', { /* clp... */ })
export default class Product extends BaseModel {
@ParseField({ type: 'String', required: true })
name!: string;
@BeforeSave()
static async onBeforeSave(req: Parse.Cloud.BeforeSaveRequest<Product>) {
// Validate or modify before saving
if (!req.object.get('name')) {
throw new Parse.Error(142, 'Name is required');
}
}
@AfterDelete()
static async onAfterDelete(req: Parse.Cloud.AfterDeleteRequest<Product>) {
// Cleanup after deletion
console.log(`Product ${req.object.id} deleted`);
}
}4. Add Cron Jobs
import { Cron, CronSchedule } from '@90soft/parse-server-kit';
class MyCronJobs {
@Cron({ schedule: CronSchedule.DAILY_MIDNIGHT, description: 'Cleanup expired sessions' })
static async cleanupSessions() {
// job logic
}
@Cron({ schedule: '*/30 * * * *', description: 'Sync data every 30 minutes' })
static async syncData() {
// job logic
}
}5. Server Setup (app.ts)
import express from 'express';
import {
CloudFunctionRegistry, TriggerRegistry, CronRegistry,
importFiles, catchError, applyMongoValidators,
validateEntityRoutes, restrictRoutes, removeResultMiddleware,
conditionalJsonMiddleware, setupSwagger,
} from '@90soft/parse-server-kit';
const app = express();
// 1. Load models (must be before Parse Server init)
importFiles(join(__dirname, 'cloudCode/models'));
// 2. Init Parse Server
const parseServer = await initializeParseServer();
// 3. Middleware
app.use(removeResultMiddleware);
app.use(cors());
app.use(process.env.mountPath, validateEntityRoutes);
app.use(conditionalJsonMiddleware);
app.use(process.env.mountPath, restrictRoutes);
// 4. Mount Parse Server
app.use(process.env.mountPath, parseServer.app);
// 5. Initialize registries (after Parse Server mount)
CloudFunctionRegistry.initialize(); // also initializes RouteRegistry
TriggerRegistry.initialize();
CronRegistry.initialize();
// 6. Swagger docs
setupSwagger(app, { title: 'My API', version: '1.0.0' });
// 7. Start server
server.listen(1337);Transactions & Optimistic Locking
Two guards against concurrent writes, both enforced at the database adapter so no endpoint has to remember them. Both report the same error — code 5001 (CONFLICT) with a user-facing message — because to the person on the screen, "somebody else got there first" is one event.
Setup
Both features are powered by one adapter. Pass it to Parse Server instead of a Mongo URI:
import {createVersionedMongoAdapter} from '@90soft/parse-server-kit';
const parseServer = new ParseServer({
databaseAdapter: createVersionedMongoAdapter({
uri: process.env.DATABASE_URI,
collectionPrefix: '',
mongoOptions: {},
}),
directAccess: true, // REQUIRED for transactions — see below
// ...
});Requirements:
- MongoDB must run as a replica set — a standalone server refuses to open a transaction.
directAccess: true(Parse Server's default). Without it, asave()in cloud code goes through an internal HTTP request, arrives in a fresh async context, and silently writes outside the transaction. Pin it in your config — that failure has no symptom.
@Transactional / withTransaction
Everything the body writes lands together, or none of it does. The transaction follows the async call chain (AsyncLocalStorage), so every save(), destroy() and query inside the body joins automatically — no session objects to thread through.
import {Transactional, withTransaction} from '@90soft/parse-server-kit';
class JobFunctions {
@CloudFunction({...}) // sees the wrapped method
@Transactional() // wraps it — must sit BELOW @CloudFunction
static async submitJob(req: Parse.Cloud.FunctionRequest) {
// every write in here commits or rolls back as one
}
}
// Outside a cloud function, or for part of one:
await withTransaction(async () => {
await order.save(null, {useMasterKey: true});
await inventory.save(null, {useMasterKey: true});
});- Decorator order matters. Decorators apply bottom-up and
@CloudFunctioncaptures the method when applied, so@Transactional()must sit below it — otherwise the registry keeps the unwrapped method and the transaction silently never opens. - Nested calls join the outer transaction; the outermost caller commits.
- The body may be re-run (up to 3 attempts) if it loses a race with another transaction, so it must be safe to repeat. After 3 losses the caller gets the
CONFLICTerror. - System classes (
_SCHEMA,_Idempotency,_Hooks,_JobStatus,_GlobalConfig) are never dragged into a transaction — schema creation and idempotency keys must survive a rollback. - Each concurrent request gets its own session — unlike Parse Server's built-in transaction support, one caller's transaction cannot swallow another's writes.
@ParseVersionField
Optimistic locking declared on the field. Two people reading the same record and both saving is a lost update — the second write silently overwrites the first. With a version field, the stale save is refused instead.
import {ParseVersionField} from '@90soft/parse-server-kit';
@ParseClass('Job')
class Job extends BaseModel {
@ParseField({type: 'String'})
title!: string;
@ParseVersionField() // declares the Number field itself — no @ParseField needed
version!: number;
}That is the whole feature. No endpoint reads or writes the field:
- every object read from the database carries the version it was read at, and every
save()/saveAll()asserts it automatically; - the adapter turns the assertion into the write's filter (the update only lands if the row is still at that version) and increments the field on every update, so the next reader gets a fresh number;
- a save that lost the race is refused with the
CONFLICTerror (5001); a genuinely missing row still reads as missing; - creates get version
1from the adapter — callers never supply one; - an object built from a bare id (never read) has nothing to assert, and simply isn't protected.
Handling the conflict
import {CONFLICT} from '@90soft/parse-server-kit';
try {
await job.save(null, {useMasterKey: true});
} catch (error) {
if (error instanceof Parse.Error && error.code === CONFLICT) {
// Reload, re-apply the change, save again — or surface the message,
// which is already written for the end user.
}
throw error;
}API Reference
Decorators
| Decorator | Target | Description |
|---|---|---|
| @ParseClass(name, options) | Class | Registers a Parse model with CLP, ACL, indexes |
| @ParseField(options) | Property | Defines a field with type, validation, index |
| @CloudFunction(config) | Static method | Registers a cloud function with HTTP method, validation, roles |
| @ProtectedCloudFunction(config) | Static method | Same as @CloudFunction but requires auth by default |
| @Route(ModelOrString) | Class | Maps cloud functions to /api/{entity}/{action} routes |
| @Cron(config) | Static method | Registers a cron job with schedule |
| @BeforeSave(config?) | Static method | Before save trigger |
| @AfterSave(config?) | Static method | After save trigger |
| @BeforeDelete(config?) | Static method | Before delete trigger |
| @AfterDelete(config?) | Static method | After delete trigger |
| @BeforeFind(config?) | Static method | Before find trigger |
| @AfterFind(config?) | Static method | After find trigger |
| @BeforeLogin(config?) | Static method | Before login trigger |
| @AfterLogin(config?) | Static method | After login trigger |
| @AfterLogout(config?) | Static method | After logout trigger |
| @BeforePasswordResetRequest(config?) | Static method | Before a password reset email is sent (parse-server 8.5+) |
| @BeforeSaveFile / @AfterSaveFile | Static method | File save triggers |
| @BeforeDeleteFile / @AfterDeleteFile | Static method | File delete triggers |
| @BeforeFindFile / @AfterFindFile | Static method | File find triggers (parse-server 8.1+) |
| @BeforeSaveConfig / @AfterSaveConfig | Static method | Parse Config triggers (parse-server 7.3+) |
| @BeforeConnect / @BeforeSubscribe / @AfterEvent | Static method | LiveQuery triggers |
| @Transactional() | Static method | Runs the method in a MongoDB transaction (place below @CloudFunction) |
| @ParseVersionField(options?) | Property | Declares the class's optimistic-lock version field |
@ParseField Options
| Option | Type | Description |
|---|---|---|
| type | 'String' \| 'Number' \| 'Boolean' \| 'Date' \| 'Pointer' \| 'Array' \| ... | Field type |
| required | boolean | Whether the field is required |
| targetClass | string | Target class for Pointer/Relation |
| index | boolean \| 1 \| -1 | Create an index |
| unique | boolean | Create a unique index |
| min / max | number | Number range validation |
| minLength / maxLength | number | String length validation |
| enum | string[] | Allowed values for String fields |
| pattern | string | Regex pattern for String fields |
| description | string | Swagger documentation |
@Route
// Import model class — auto-generates route from className
@Route(Product) // → /api/products/*
// Custom string — full control over route prefix
@Route('menu-items') // → /api/menu-items/*The method name IS the route — no parsing, no prefix stripping, no
collisions. The route is /{prefix}/{methodName}:
createProduct → POST /api/products/createProduct
getProduct → POST /api/products/getProduct
listProducts → GET /api/products/listProducts
updateProduct → POST /api/products/updateProduct
deleteProduct → POST /api/products/deleteProductMatching is done against the class's real method list, so getProduct and
getProductCategory can coexist. Renaming a method renames its route.
BaseModel
// Create from request params (auto-maps fields, handles pointers)
const product = Product.fromParams(req.params);
// Create a pointer reference by ID
const category = Category.pointer('abc123');
// Override excluded pointer classes (default: ['IMG', 'File'])
class MyModel extends BaseModel {
protected static EXCLUDED_POINTER_CLASSES = ['IMG', 'File', 'CustomFile'];
}ACL
import { implementACL, UserRoles } from '@90soft/parse-server-kit';
obj.setACL(implementACL({
roleRules: [
{ role: UserRoles.ADMIN, read: true, write: true },
{ role: UserRoles.EMPLOYEE, read: true },
],
owner: [
{ user: userId, read: true, write: true },
],
}));Utilities
import {
catchError, // Wraps promises: const [err, data] = await catchError(promise)
getUserRoles, // Get role names for a user
getUsersRoles, // Roles for many users — one query per role, not per user
importFiles, // Auto-import a directory, for its decorator side effects
generateRandomPassword,
generateRandomString,
sleep,
formatCount,
} from '@90soft/parse-server-kit';importFiles loads .js only by default — the compiled output a production
server runs. If you start straight from source under ts-node or tsx, say so, or
nothing is imported and the server boots with an empty schema:
importFiles(join(__dirname, 'cloudCode/models'), {extensions: ['.js', '.ts']});Point it at one directory or the other, never a tree holding both a compiled and a source copy — that registers every class twice.
Role Cache (opt-in)
@CloudFunction({requireRoles}) and getUserRoles() each cost a database
round-trip. Role membership rarely changes, so caching it removes that
round-trip — at the cost of a revoked role continuing to work until the entry
expires. That trade is yours to make, so the cache does nothing until you turn
it on:
import {configureRoleCache, invalidateRoles} from '@90soft/parse-server-kit';
configureRoleCache({ttlMs: 30_000}); // global; omit entirely to stay off
// Opt a sensitive endpoint out, whatever the global policy says
@CloudFunction({requireRoles: [UserRoles.ADMIN], roleCacheMs: 0})
static async deleteEverything(req) { ... }
// Wherever you grant or revoke, drop the entry immediately
await role.getUsers().remove(user);
await role.save(null, {useMasterKey: true});
invalidateRoles(user.id);With invalidateRoles wired into your own grant/revoke paths, the TTL only
covers changes made outside your code (a dashboard edit, a direct database
write). configureRoleCache(false) disables it and clears everything held.
Middleware
import {
validateEntityRoutes, // Maps /api/{entity}/{action} → cloud functions
restrictRoutes, // Blocks /classes, /schemas, etc.
removeResultMiddleware, // Unwraps Parse {result: ...} wrapper
conditionalJsonMiddleware, // JSON parsing with master key extraction
validateFunctionRoutes, // Legacy /functions/* validation
} from '@90soft/parse-server-kit';Swagger
import { setupSwagger, SwaggerRegistry } from '@90soft/parse-server-kit';
// Auto-generates API docs from @ParseClass and @CloudFunction metadata
setupSwagger(app, {
title: 'My API',
version: '1.0.0',
description: 'Auto-generated docs',
});
// Access at: http://localhost:1337/api-docsHooks (for integrations)
import { onClassRegistered, onFieldRegistered, onFunctionRegistered } from '@90soft/parse-server-kit';
// Runs when @ParseClass is applied
onClassRegistered((className, constructor, fields, options) => {
// Register with Swagger, setup triggers, etc.
});
// Runs when @CloudFunction is applied
onFunctionRegistered((name, config, target) => {
// Register with Swagger docs
});Constants
import { UserRoles, roleKey, MAX_QUERY_LIMIT, CronSchedule } from '@90soft/parse-server-kit';
UserRoles.ADMIN // 'SuperAdmin'
UserRoles.EMPLOYEE // 'Employee'
roleKey(UserRoles.ADMIN) // 'role:SuperAdmin'
MAX_QUERY_LIMIT // 10000
CronSchedule.EVERY_HOUR // '0 * * * *'
CronSchedule.DAILY_MIDNIGHT // '0 0 * * *'
CronSchedule.WEEKLY_MONDAY // '0 0 * * 1'Validation
import { validateObject, validateOrThrow, applyMongoValidators } from '@90soft/parse-server-kit';
// Validate against @ParseField constraints (min, max, enum, pattern, etc.)
const result = validateObject(parseObject);
// { valid: false, errors: [{ field: 'price', message: 'must be at least 0' }] }
// Throw Parse.Error if invalid (use in BeforeSave triggers)
validateOrThrow(parseObject);
// Apply MongoDB schema validators from decorator metadata
await applyMongoValidators(parseServerInstance);File Structure Convention
backend/src/cloudCode/
├── models/
│ └── Product.ts ← @ParseClass + @ParseField + triggers
├── modules/
│ └── Product/
│ └── functions.ts ← @Route + @CloudFunction
├── cron.ts ← @Cron jobs
├── main.ts ← importFiles for models + modules
└── decorator/
└── setupHooks.ts ← connects package hooks to Swagger + TriggersTesting
npm test # unit tests — fast, no database
npm run test:integration # real parse-server + in-memory MongoDB replica set
npm run test:allThe integration suite exists to catch parse-server upgrades that move the
internals the transactions and versioning features lean on: the
MongoStorageAdapter module path, _adaptiveCollection/_mongoCollection,
_getSaveJSON, and — verified method by method against the mongodb driver
parse-server actually ships — every options position in OPTIONS_ARGUMENT.
It boots a real parse-server on mongodb-memory-server and exercises
versioned saves, stale-save conflicts, and cloud-function transaction
commit/rollback end to end. The first run downloads a MongoDB binary.
License
MIT
