@90soft/parse-server-kit 2.6.0 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CLAUDE.md +674 -0
  2. package/README.md +618 -424
  3. package/dist/database/schema.d.ts +14 -0
  4. package/dist/database/schema.d.ts.map +1 -1
  5. package/dist/database/schema.js +2 -1
  6. package/dist/database/schema.js.map +1 -1
  7. package/dist/database/versionRegistry.d.ts +35 -0
  8. package/dist/database/versionRegistry.d.ts.map +1 -0
  9. package/dist/database/versionRegistry.js +153 -0
  10. package/dist/database/versionRegistry.js.map +1 -0
  11. package/dist/database/versionedMongoAdapter.d.ts +51 -0
  12. package/dist/database/versionedMongoAdapter.d.ts.map +1 -0
  13. package/dist/database/versionedMongoAdapter.js +131 -0
  14. package/dist/database/versionedMongoAdapter.js.map +1 -0
  15. package/dist/decorators/cloudRegistry.d.ts.map +1 -1
  16. package/dist/decorators/cloudRegistry.js +5 -0
  17. package/dist/decorators/cloudRegistry.js.map +1 -1
  18. package/dist/decorators/triggerDecorator.d.ts +8 -0
  19. package/dist/decorators/triggerDecorator.d.ts.map +1 -1
  20. package/dist/decorators/triggerDecorator.js +10 -1
  21. package/dist/decorators/triggerDecorator.js.map +1 -1
  22. package/dist/decorators/triggerRegistry.d.ts.map +1 -1
  23. package/dist/decorators/triggerRegistry.js +52 -8
  24. package/dist/decorators/triggerRegistry.js.map +1 -1
  25. package/dist/decorators/types/triggerTypes.d.ts +1 -1
  26. package/dist/decorators/types/triggerTypes.d.ts.map +1 -1
  27. package/dist/index.d.ts +7 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +27 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/middleware/middleware.d.ts.map +1 -1
  32. package/dist/middleware/middleware.js +34 -1
  33. package/dist/middleware/middleware.js.map +1 -1
  34. package/dist/middleware/rateLimit.d.ts.map +1 -1
  35. package/dist/middleware/rateLimit.js +7 -1
  36. package/dist/middleware/rateLimit.js.map +1 -1
  37. package/dist/swagger/index.d.ts +5 -1
  38. package/dist/swagger/index.d.ts.map +1 -1
  39. package/dist/swagger/index.js +32 -8
  40. package/dist/swagger/index.js.map +1 -1
  41. package/dist/transactions/ambientSession.d.ts +39 -0
  42. package/dist/transactions/ambientSession.d.ts.map +1 -0
  43. package/dist/transactions/ambientSession.js +88 -0
  44. package/dist/transactions/ambientSession.js.map +1 -0
  45. package/dist/transactions/context.d.ts +86 -0
  46. package/dist/transactions/context.d.ts.map +1 -0
  47. package/dist/transactions/context.js +124 -0
  48. package/dist/transactions/context.js.map +1 -0
  49. package/dist/utils/dynamicImport.d.ts +22 -1
  50. package/dist/utils/dynamicImport.d.ts.map +1 -1
  51. package/dist/utils/dynamicImport.js +13 -4
  52. package/dist/utils/dynamicImport.js.map +1 -1
  53. package/dist/utils/helper.d.ts +10 -1
  54. package/dist/utils/helper.d.ts.map +1 -1
  55. package/dist/utils/helper.js +10 -1
  56. package/dist/utils/helper.js.map +1 -1
  57. package/package.json +76 -54
package/README.md CHANGED
@@ -1,424 +1,618 @@
1
- # @90soft/parse-server-kit
2
-
3
- Decorator-driven development kit for Parse Server. Write models and cloud functions with TypeScript decorators — no boilerplate.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install @90soft/parse-server-kit
9
- ```
10
-
11
- Peer dependencies: `parse`, `reflect-metadata`, `express`
12
- Optional: `node-cron` (for @Cron), `swagger-ui-express` (for Swagger)
13
-
14
- ---
15
-
16
- ## Quick Start
17
-
18
- ### 1. Define a Model
19
-
20
- ```ts
21
- import { ParseClass, ParseField, BaseModel } from '@90soft/parse-server-kit';
22
- import { UserRoles, roleKey } from '@90soft/parse-server-kit';
23
-
24
- @ParseClass('Product', {
25
- clp: {
26
- find: { [roleKey(UserRoles.ADMIN)]: true, [roleKey(UserRoles.EMPLOYEE)]: true },
27
- get: { [roleKey(UserRoles.ADMIN)]: true, [roleKey(UserRoles.EMPLOYEE)]: true },
28
- create: { [roleKey(UserRoles.ADMIN)]: true, [roleKey(UserRoles.EMPLOYEE)]: true },
29
- update: { [roleKey(UserRoles.ADMIN)]: true, [roleKey(UserRoles.EMPLOYEE)]: true },
30
- delete: { [roleKey(UserRoles.ADMIN)]: true },
31
- count: { [roleKey(UserRoles.ADMIN)]: true, [roleKey(UserRoles.EMPLOYEE)]: true },
32
- },
33
- })
34
- export default class Product extends BaseModel {
35
- @ParseField({ type: 'String', required: true })
36
- name!: string;
37
-
38
- @ParseField({ type: 'Number', required: true, min: 0 })
39
- price!: number;
40
-
41
- @ParseField({ type: 'String', enum: ['active', 'draft', 'archived'] })
42
- status!: string;
43
-
44
- @ParseField({ type: 'Pointer', targetClass: 'Category', required: true })
45
- category!: any;
46
-
47
- @ParseField({ type: 'Boolean' })
48
- available!: boolean;
49
-
50
- @ParseField({ type: 'Date' })
51
- createdDate!: Date;
52
- }
53
- ```
54
-
55
- ### 2. Write Cloud Functions
56
-
57
- ```ts
58
- import { CloudFunction, Route, catchError, UserRoles } from '@90soft/parse-server-kit';
59
- import Product from '../../models/Product';
60
- import User from '../../models/User';
61
-
62
- @Route(Product) // → /api/products/create, /api/products/list, etc.
63
- class ProductFunctions {
64
-
65
- @CloudFunction({
66
- methods: ['POST'],
67
- validation: { requireUser: true, fields: { name: { required: true } } },
68
- })
69
- static async createProduct(req: Parse.Cloud.FunctionRequest) {
70
- const user = req.user! as User;
71
- const product = Product.fromParams(req.params);
72
- const [err, saved] = await catchError(
73
- product.save(null, { sessionToken: user.getSessionToken() })
74
- );
75
- if (err) throw err;
76
- return saved;
77
- }
78
-
79
- @CloudFunction({
80
- methods: ['GET'],
81
- validation: { requireUser: true, fields: { skip: { type: String }, limit: { type: String } } },
82
- })
83
- static async listProducts(req: Parse.Cloud.FunctionRequest) {
84
- const query = new Parse.Query(Product);
85
- query.include('category');
86
- const skip = Number(req.params.skip) || 0;
87
- const limit = Number(req.params.limit) || 10;
88
- query.skip(skip).limit(limit);
89
-
90
- if (req.params.search) {
91
- const search = req.params.search;
92
- query.matches('name', new RegExp(search, 'i'));
93
- }
94
-
95
- const [err, results] = await catchError(query.find({ sessionToken: req.user!.getSessionToken() }));
96
- if (err) throw err;
97
-
98
- const [countErr, count] = await catchError(query.count({ sessionToken: req.user!.getSessionToken() }));
99
- return { results, count: countErr ? 0 : count };
100
- }
101
-
102
- @CloudFunction({
103
- methods: ['POST'],
104
- validation: { requireUser: true, fields: { id: { required: true } } },
105
- })
106
- static async getProduct(req: Parse.Cloud.FunctionRequest) {
107
- const query = new Parse.Query(Product);
108
- query.include('category');
109
- const [err, product] = await catchError(query.get(req.params.id, { sessionToken: req.user!.getSessionToken() }));
110
- if (err) throw err;
111
- return product;
112
- }
113
-
114
- @CloudFunction({
115
- methods: ['POST'],
116
- validation: { requireUser: true, fields: { id: { required: true } } },
117
- })
118
- static async updateProduct(req: Parse.Cloud.FunctionRequest) {
119
- const user = req.user! as User;
120
- const product = Product.fromParams(req.params);
121
- const [err, saved] = await catchError(
122
- product.save(null, { sessionToken: user.getSessionToken() })
123
- );
124
- if (err) throw err;
125
- return saved;
126
- }
127
-
128
- @CloudFunction({
129
- methods: ['POST'],
130
- validation: { requireUser: true, fields: { id: { required: true } } },
131
- })
132
- static async deleteProduct(req: Parse.Cloud.FunctionRequest) {
133
- const query = new Parse.Query(Product);
134
- const [err, product] = await catchError(query.get(req.params.id, { useMasterKey: true }));
135
- if (err) throw err;
136
- const [delErr] = await catchError(product!.destroy({ useMasterKey: true }));
137
- if (delErr) throw delErr;
138
- return { success: true };
139
- }
140
- }
141
- ```
142
-
143
- ### 3. Add Triggers
144
-
145
- ```ts
146
- import { ParseClass, ParseField, BaseModel, BeforeSave, AfterDelete } from '@90soft/parse-server-kit';
147
-
148
- @ParseClass('Product', { /* clp... */ })
149
- export default class Product extends BaseModel {
150
- @ParseField({ type: 'String', required: true })
151
- name!: string;
152
-
153
- @BeforeSave()
154
- static async onBeforeSave(req: Parse.Cloud.BeforeSaveRequest<Product>) {
155
- // Validate or modify before saving
156
- if (!req.object.get('name')) {
157
- throw new Parse.Error(142, 'Name is required');
158
- }
159
- }
160
-
161
- @AfterDelete()
162
- static async onAfterDelete(req: Parse.Cloud.AfterDeleteRequest<Product>) {
163
- // Cleanup after deletion
164
- console.log(`Product ${req.object.id} deleted`);
165
- }
166
- }
167
- ```
168
-
169
- ### 4. Add Cron Jobs
170
-
171
- ```ts
172
- import { Cron, CronSchedule } from '@90soft/parse-server-kit';
173
-
174
- class MyCronJobs {
175
- @Cron({ schedule: CronSchedule.DAILY_MIDNIGHT, description: 'Cleanup expired sessions' })
176
- static async cleanupSessions() {
177
- // job logic
178
- }
179
-
180
- @Cron({ schedule: '*/30 * * * *', description: 'Sync data every 30 minutes' })
181
- static async syncData() {
182
- // job logic
183
- }
184
- }
185
- ```
186
-
187
- ### 5. Server Setup (app.ts)
188
-
189
- ```ts
190
- import express from 'express';
191
- import {
192
- CloudFunctionRegistry, TriggerRegistry, CronRegistry,
193
- importFiles, catchError, applyMongoValidators,
194
- validateEntityRoutes, restrictRoutes, removeResultMiddleware,
195
- conditionalJsonMiddleware, setupSwagger,
196
- } from '@90soft/parse-server-kit';
197
-
198
- const app = express();
199
-
200
- // 1. Load models (must be before Parse Server init)
201
- importFiles(join(__dirname, 'cloudCode/models'));
202
-
203
- // 2. Init Parse Server
204
- const parseServer = await initializeParseServer();
205
-
206
- // 3. Middleware
207
- app.use(removeResultMiddleware);
208
- app.use(cors());
209
- app.use(process.env.mountPath, validateEntityRoutes);
210
- app.use(conditionalJsonMiddleware);
211
- app.use(process.env.mountPath, restrictRoutes);
212
-
213
- // 4. Mount Parse Server
214
- app.use(process.env.mountPath, parseServer.app);
215
-
216
- // 5. Initialize registries (after Parse Server mount)
217
- CloudFunctionRegistry.initialize(); // also initializes RouteRegistry
218
- TriggerRegistry.initialize();
219
- CronRegistry.initialize();
220
-
221
- // 6. Swagger docs
222
- setupSwagger(app, { title: 'My API', version: '1.0.0' });
223
-
224
- // 7. Start server
225
- server.listen(1337);
226
- ```
227
-
228
- ---
229
-
230
- ## API Reference
231
-
232
- ### Decorators
233
-
234
- | Decorator | Target | Description |
235
- |---|---|---|
236
- | `@ParseClass(name, options)` | Class | Registers a Parse model with CLP, ACL, indexes |
237
- | `@ParseField(options)` | Property | Defines a field with type, validation, index |
238
- | `@CloudFunction(config)` | Static method | Registers a cloud function with HTTP method, validation, roles |
239
- | `@ProtectedCloudFunction(config)` | Static method | Same as @CloudFunction but requires auth by default |
240
- | `@Route(ModelOrString)` | Class | Maps cloud functions to `/api/{entity}/{action}` routes |
241
- | `@Cron(config)` | Static method | Registers a cron job with schedule |
242
- | `@BeforeSave(config?)` | Static method | Before save trigger |
243
- | `@AfterSave(config?)` | Static method | After save trigger |
244
- | `@BeforeDelete(config?)` | Static method | Before delete trigger |
245
- | `@AfterDelete(config?)` | Static method | After delete trigger |
246
- | `@BeforeFind(config?)` | Static method | Before find trigger |
247
- | `@AfterFind(config?)` | Static method | After find trigger |
248
- | `@BeforeLogin(config?)` | Static method | Before login trigger |
249
- | `@AfterLogin(config?)` | Static method | After login trigger |
250
- | `@AfterLogout(config?)` | Static method | After logout trigger |
251
-
252
- ### @ParseField Options
253
-
254
- | Option | Type | Description |
255
- |---|---|---|
256
- | `type` | `'String' \| 'Number' \| 'Boolean' \| 'Date' \| 'Pointer' \| 'Array' \| ...` | Field type |
257
- | `required` | `boolean` | Whether the field is required |
258
- | `targetClass` | `string` | Target class for Pointer/Relation |
259
- | `index` | `boolean \| 1 \| -1` | Create an index |
260
- | `unique` | `boolean` | Create a unique index |
261
- | `min` / `max` | `number` | Number range validation |
262
- | `minLength` / `maxLength` | `number` | String length validation |
263
- | `enum` | `string[]` | Allowed values for String fields |
264
- | `pattern` | `string` | Regex pattern for String fields |
265
- | `description` | `string` | Swagger documentation |
266
-
267
- ### @Route
268
-
269
- ```ts
270
- // Import model class — auto-generates route from className
271
- @Route(Product) // → /api/products/*
272
-
273
- // Custom string — full control over route prefix
274
- @Route('menu-items') // → /api/menu-items/*
275
- ```
276
-
277
- Routes are automatically mapped:
278
- ```
279
- createProduct → POST /api/products/create
280
- getProduct → POST /api/products/get
281
- listProducts → GET /api/products/list
282
- updateProduct → POST /api/products/update
283
- deleteProduct → POST /api/products/delete
284
- ```
285
-
286
- ### BaseModel
287
-
288
- ```ts
289
- // Create from request params (auto-maps fields, handles pointers)
290
- const product = Product.fromParams(req.params);
291
-
292
- // Create a pointer reference by ID
293
- const category = Category.pointer('abc123');
294
-
295
- // Override excluded pointer classes (default: ['IMG', 'File'])
296
- class MyModel extends BaseModel {
297
- protected static EXCLUDED_POINTER_CLASSES = ['IMG', 'File', 'CustomFile'];
298
- }
299
- ```
300
-
301
- ### ACL
302
-
303
- ```ts
304
- import { implementACL, UserRoles } from '@90soft/parse-server-kit';
305
-
306
- obj.setACL(implementACL({
307
- roleRules: [
308
- { role: UserRoles.ADMIN, read: true, write: true },
309
- { role: UserRoles.EMPLOYEE, read: true },
310
- ],
311
- owner: [
312
- { user: userId, read: true, write: true },
313
- ],
314
- }));
315
- ```
316
-
317
- ### Utilities
318
-
319
- ```ts
320
- import {
321
- catchError, // Wraps promises: const [err, data] = await catchError(promise)
322
- getUserRoles, // Get role names for a user
323
- getUsersRoles, // Batch get roles for multiple users
324
- importFiles, // Auto-import all .js files in a directory
325
- generateRandomPassword,
326
- generateRandomString,
327
- sleep,
328
- formatCount,
329
- } from '@90soft/parse-server-kit';
330
- ```
331
-
332
- ### Middleware
333
-
334
- ```ts
335
- import {
336
- validateEntityRoutes, // Maps /api/{entity}/{action} → cloud functions
337
- restrictRoutes, // Blocks /classes, /schemas, etc.
338
- removeResultMiddleware, // Unwraps Parse {result: ...} wrapper
339
- conditionalJsonMiddleware, // JSON parsing with master key extraction
340
- validateFunctionRoutes, // Legacy /functions/* validation
341
- } from '@90soft/parse-server-kit';
342
- ```
343
-
344
- ### Swagger
345
-
346
- ```ts
347
- import { setupSwagger, SwaggerRegistry } from '@90soft/parse-server-kit';
348
-
349
- // Auto-generates API docs from @ParseClass and @CloudFunction metadata
350
- setupSwagger(app, {
351
- title: 'My API',
352
- version: '1.0.0',
353
- description: 'Auto-generated docs',
354
- });
355
- // Access at: http://localhost:1337/api-docs
356
- ```
357
-
358
- ### Hooks (for integrations)
359
-
360
- ```ts
361
- import { onClassRegistered, onFieldRegistered, onFunctionRegistered } from '@90soft/parse-server-kit';
362
-
363
- // Runs when @ParseClass is applied
364
- onClassRegistered((className, constructor, fields, options) => {
365
- // Register with Swagger, setup triggers, etc.
366
- });
367
-
368
- // Runs when @CloudFunction is applied
369
- onFunctionRegistered((name, config, target) => {
370
- // Register with Swagger docs
371
- });
372
- ```
373
-
374
- ### Constants
375
-
376
- ```ts
377
- import { UserRoles, roleKey, MAX_QUERY_LIMIT, CronSchedule } from '@90soft/parse-server-kit';
378
-
379
- UserRoles.ADMIN // 'SuperAdmin'
380
- UserRoles.EMPLOYEE // 'Employee'
381
- roleKey(UserRoles.ADMIN) // 'role:SuperAdmin'
382
- MAX_QUERY_LIMIT // 10000
383
-
384
- CronSchedule.EVERY_HOUR // '0 * * * *'
385
- CronSchedule.DAILY_MIDNIGHT // '0 0 * * *'
386
- CronSchedule.WEEKLY_MONDAY // '0 0 * * 1'
387
- ```
388
-
389
- ### Validation
390
-
391
- ```ts
392
- import { validateObject, validateOrThrow, applyMongoValidators } from '@90soft/parse-server-kit';
393
-
394
- // Validate against @ParseField constraints (min, max, enum, pattern, etc.)
395
- const result = validateObject(parseObject);
396
- // { valid: false, errors: [{ field: 'price', message: 'must be at least 0' }] }
397
-
398
- // Throw Parse.Error if invalid (use in BeforeSave triggers)
399
- validateOrThrow(parseObject);
400
-
401
- // Apply MongoDB schema validators from decorator metadata
402
- await applyMongoValidators(parseServerInstance);
403
- ```
404
-
405
- ---
406
-
407
- ## File Structure Convention
408
-
409
- ```
410
- backend/src/cloudCode/
411
- ├── models/
412
- │ └── Product.ts ← @ParseClass + @ParseField + triggers
413
- ├── modules/
414
- │ └── Product/
415
- │ └── functions.ts ← @Route + @CloudFunction
416
- ├── cron.ts ← @Cron jobs
417
- ├── main.ts ← importFiles for models + modules
418
- └── decorator/
419
- └── setupHooks.ts ← connects package hooks to Swagger + Triggers
420
- ```
421
-
422
- ## License
423
-
424
- MIT
1
+ # @90soft/parse-server-kit
2
+
3
+ Decorator-driven development kit for Parse Server. Write models and cloud functions with TypeScript decorators — no boilerplate.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @90soft/parse-server-kit
9
+ ```
10
+
11
+ **Required peers:** `parse`, `reflect-metadata`, `express`
12
+
13
+ **Optional peers** — install only what you use. Each degrades with a warning rather than failing:
14
+
15
+ | Package | Needed for | Without it |
16
+ |---|---|---|
17
+ | `node-cron` | `@Cron` | Cron jobs are skipped |
18
+ | `swagger-ui-express` | `setupSwagger` browser UI | The spec is still served at `/api-docs/json` |
19
+ | `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 |
20
+ | `@types/parse` | TypeScript builds | See below |
21
+
22
+ ### TypeScript setup
23
+
24
+ Two things are **required** in your `tsconfig.json`, and neither fails loudly if
25
+ you miss it:
26
+
27
+ ```jsonc
28
+ {
29
+ "compilerOptions": {
30
+ "experimentalDecorators": true, // REQUIRED
31
+ "emitDecoratorMetadata": true // optional — see below
32
+ }
33
+ }
34
+ ```
35
+
36
+ - **`experimentalDecorators: true` is mandatory.** This package uses *legacy*
37
+ decorators. TypeScript 5.0+ defaults to the standard (TC39) decorators, which
38
+ are a different feature with different semantics — without this flag every
39
+ `@ParseClass` / `@ParseField` in your project silently misbehaves.
40
+ - **`emitDecoratorMetadata` is not required.** The package reads no `design:type`
41
+ metadata; it stores its own keys. Turn it on only if something else in your
42
+ project needs it.
43
+
44
+ This package's published type declarations refer to a **global** `Parse`
45
+ namespace, the way cloud code sees it. Install `@types/parse` to provide it:
46
+
47
+ ```bash
48
+ npm install --save-dev @types/parse
49
+ ```
50
+
51
+ Without it you will see `Cannot find namespace 'Parse'` when compiling against
52
+ the kit. If you already have `@types/parse`, nothing changes — do not also
53
+ declare your own global `Parse` namespace, or the two will collide.
54
+
55
+ ### Runtime requirements
56
+
57
+ | | |
58
+ |---|---|
59
+ | Node | ≥ 18 for this package; **≥ 20.19** if you are on parse-server 9 |
60
+ | MongoDB | ≥ 7.0.16 for parse-server 9; **replica set** required for transactions |
61
+ | Database | Transactions and `@ParseVersionField` are **MongoDB-only** — they have no effect on Postgres |
62
+
63
+ ---
64
+
65
+ ## Quick Start
66
+
67
+ ### 1. Define a Model
68
+
69
+ ```ts
70
+ import { ParseClass, ParseField, BaseModel } from '@90soft/parse-server-kit';
71
+ import { UserRoles, roleKey } from '@90soft/parse-server-kit';
72
+
73
+ @ParseClass('Product', {
74
+ clp: {
75
+ find: { [roleKey(UserRoles.ADMIN)]: true, [roleKey(UserRoles.EMPLOYEE)]: true },
76
+ get: { [roleKey(UserRoles.ADMIN)]: true, [roleKey(UserRoles.EMPLOYEE)]: true },
77
+ create: { [roleKey(UserRoles.ADMIN)]: true, [roleKey(UserRoles.EMPLOYEE)]: true },
78
+ update: { [roleKey(UserRoles.ADMIN)]: true, [roleKey(UserRoles.EMPLOYEE)]: true },
79
+ delete: { [roleKey(UserRoles.ADMIN)]: true },
80
+ count: { [roleKey(UserRoles.ADMIN)]: true, [roleKey(UserRoles.EMPLOYEE)]: true },
81
+ },
82
+ })
83
+ export default class Product extends BaseModel {
84
+ @ParseField({ type: 'String', required: true })
85
+ name!: string;
86
+
87
+ @ParseField({ type: 'Number', required: true, min: 0 })
88
+ price!: number;
89
+
90
+ @ParseField({ type: 'String', enum: ['active', 'draft', 'archived'] })
91
+ status!: string;
92
+
93
+ @ParseField({ type: 'Pointer', targetClass: 'Category', required: true })
94
+ category!: any;
95
+
96
+ @ParseField({ type: 'Boolean' })
97
+ available!: boolean;
98
+
99
+ @ParseField({ type: 'Date' })
100
+ createdDate!: Date;
101
+ }
102
+ ```
103
+
104
+ ### 2. Write Cloud Functions
105
+
106
+ ```ts
107
+ import { CloudFunction, Route, catchError, UserRoles } from '@90soft/parse-server-kit';
108
+ import Product from '../../models/Product';
109
+ import User from '../../models/User';
110
+
111
+ @Route(Product) // → /api/products/createProduct, /api/products/listProducts, …
112
+ class ProductFunctions {
113
+
114
+ @CloudFunction({
115
+ methods: ['POST'],
116
+ validation: { requireUser: true, fields: { name: { required: true } } },
117
+ })
118
+ static async createProduct(req: Parse.Cloud.FunctionRequest) {
119
+ const user = req.user! as User;
120
+ const product = Product.fromParams(req.params);
121
+ const [err, saved] = await catchError(
122
+ product.save(null, { sessionToken: user.getSessionToken() })
123
+ );
124
+ if (err) throw err;
125
+ return saved;
126
+ }
127
+
128
+ @CloudFunction({
129
+ methods: ['GET'],
130
+ validation: { requireUser: true, fields: { skip: { type: String }, limit: { type: String } } },
131
+ })
132
+ static async listProducts(req: Parse.Cloud.FunctionRequest) {
133
+ const query = new Parse.Query(Product);
134
+ query.include('category');
135
+ const skip = Number(req.params.skip) || 0;
136
+ const limit = Number(req.params.limit) || 10;
137
+ query.skip(skip).limit(limit);
138
+
139
+ if (req.params.search) {
140
+ const search = req.params.search;
141
+ query.matches('name', new RegExp(search, 'i'));
142
+ }
143
+
144
+ const [err, results] = await catchError(query.find({ sessionToken: req.user!.getSessionToken() }));
145
+ if (err) throw err;
146
+
147
+ const [countErr, count] = await catchError(query.count({ sessionToken: req.user!.getSessionToken() }));
148
+ return { results, count: countErr ? 0 : count };
149
+ }
150
+
151
+ @CloudFunction({
152
+ methods: ['POST'],
153
+ validation: { requireUser: true, fields: { id: { required: true } } },
154
+ })
155
+ static async getProduct(req: Parse.Cloud.FunctionRequest) {
156
+ const query = new Parse.Query(Product);
157
+ query.include('category');
158
+ const [err, product] = await catchError(query.get(req.params.id, { sessionToken: req.user!.getSessionToken() }));
159
+ if (err) throw err;
160
+ return product;
161
+ }
162
+
163
+ @CloudFunction({
164
+ methods: ['POST'],
165
+ validation: { requireUser: true, fields: { id: { required: true } } },
166
+ })
167
+ static async updateProduct(req: Parse.Cloud.FunctionRequest) {
168
+ const user = req.user! as User;
169
+ const product = Product.fromParams(req.params);
170
+ const [err, saved] = await catchError(
171
+ product.save(null, { sessionToken: user.getSessionToken() })
172
+ );
173
+ if (err) throw err;
174
+ return saved;
175
+ }
176
+
177
+ @CloudFunction({
178
+ methods: ['POST'],
179
+ validation: { requireUser: true, fields: { id: { required: true } } },
180
+ })
181
+ static async deleteProduct(req: Parse.Cloud.FunctionRequest) {
182
+ const query = new Parse.Query(Product);
183
+ const [err, product] = await catchError(query.get(req.params.id, { useMasterKey: true }));
184
+ if (err) throw err;
185
+ const [delErr] = await catchError(product!.destroy({ useMasterKey: true }));
186
+ if (delErr) throw delErr;
187
+ return { success: true };
188
+ }
189
+ }
190
+ ```
191
+
192
+ > **Note on the search above.** `query.matches()` compiles to a Mongo `$regex`.
193
+ > parse-server 9.8+ can disable that operator entirely via
194
+ > `requestComplexity.allowRegex: false` — a worthwhile hardening option, since an
195
+ > unanchored user-supplied regex is both a scan and a ReDoS surface. If you turn
196
+ > it on, replace this with a text index (`@ParseClass({compoundIndexes: [{fields:
197
+ > ['name'], fieldTypes: {name: 'text'}}]})`) and `query.fullText('name', search)`.
198
+
199
+ ### 3. Add Triggers
200
+
201
+ ```ts
202
+ import { ParseClass, ParseField, BaseModel, BeforeSave, AfterDelete } from '@90soft/parse-server-kit';
203
+
204
+ @ParseClass('Product', { /* clp... */ })
205
+ export default class Product extends BaseModel {
206
+ @ParseField({ type: 'String', required: true })
207
+ name!: string;
208
+
209
+ @BeforeSave()
210
+ static async onBeforeSave(req: Parse.Cloud.BeforeSaveRequest<Product>) {
211
+ // Validate or modify before saving
212
+ if (!req.object.get('name')) {
213
+ throw new Parse.Error(142, 'Name is required');
214
+ }
215
+ }
216
+
217
+ @AfterDelete()
218
+ static async onAfterDelete(req: Parse.Cloud.AfterDeleteRequest<Product>) {
219
+ // Cleanup after deletion
220
+ console.log(`Product ${req.object.id} deleted`);
221
+ }
222
+ }
223
+ ```
224
+
225
+ ### 4. Add Cron Jobs
226
+
227
+ ```ts
228
+ import { Cron, CronSchedule } from '@90soft/parse-server-kit';
229
+
230
+ class MyCronJobs {
231
+ @Cron({ schedule: CronSchedule.DAILY_MIDNIGHT, description: 'Cleanup expired sessions' })
232
+ static async cleanupSessions() {
233
+ // job logic
234
+ }
235
+
236
+ @Cron({ schedule: '*/30 * * * *', description: 'Sync data every 30 minutes' })
237
+ static async syncData() {
238
+ // job logic
239
+ }
240
+ }
241
+ ```
242
+
243
+ ### 5. Server Setup (app.ts)
244
+
245
+ ```ts
246
+ import express from 'express';
247
+ import {
248
+ CloudFunctionRegistry, TriggerRegistry, CronRegistry,
249
+ importFiles, catchError, applyMongoValidators,
250
+ validateEntityRoutes, restrictRoutes, removeResultMiddleware,
251
+ conditionalJsonMiddleware, setupSwagger,
252
+ } from '@90soft/parse-server-kit';
253
+
254
+ const app = express();
255
+
256
+ // 1. Load models (must be before Parse Server init)
257
+ importFiles(join(__dirname, 'cloudCode/models'));
258
+
259
+ // 2. Init Parse Server
260
+ const parseServer = await initializeParseServer();
261
+
262
+ // 3. Middleware
263
+ app.use(removeResultMiddleware);
264
+ app.use(cors());
265
+ app.use(process.env.mountPath, validateEntityRoutes);
266
+ app.use(conditionalJsonMiddleware);
267
+ app.use(process.env.mountPath, restrictRoutes);
268
+
269
+ // 4. Mount Parse Server
270
+ app.use(process.env.mountPath, parseServer.app);
271
+
272
+ // 5. Initialize registries (after Parse Server mount)
273
+ CloudFunctionRegistry.initialize(); // also initializes RouteRegistry
274
+ TriggerRegistry.initialize();
275
+ CronRegistry.initialize();
276
+
277
+ // 6. Swagger docs
278
+ setupSwagger(app, { title: 'My API', version: '1.0.0' });
279
+
280
+ // 7. Start server
281
+ server.listen(1337);
282
+ ```
283
+
284
+ ---
285
+
286
+ ## Transactions & Optimistic Locking
287
+
288
+ 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.
289
+
290
+ ### Setup
291
+
292
+ Both features are powered by one adapter. Pass it to Parse Server instead of a Mongo URI:
293
+
294
+ ```ts
295
+ import {createVersionedMongoAdapter} from '@90soft/parse-server-kit';
296
+
297
+ const parseServer = new ParseServer({
298
+ databaseAdapter: createVersionedMongoAdapter({
299
+ uri: process.env.DATABASE_URI,
300
+ collectionPrefix: '',
301
+ mongoOptions: {},
302
+ }),
303
+ directAccess: true, // REQUIRED for transactions — see below
304
+ // ...
305
+ });
306
+ ```
307
+
308
+ **Requirements:**
309
+ - MongoDB must run as a **replica set** — a standalone server refuses to open a transaction.
310
+ - `directAccess: true` (Parse Server's default). Without it, a `save()` 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.
311
+
312
+ ### `@Transactional` / `withTransaction`
313
+
314
+ 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.
315
+
316
+ ```ts
317
+ import {Transactional, withTransaction} from '@90soft/parse-server-kit';
318
+
319
+ class JobFunctions {
320
+ @CloudFunction({...}) // sees the wrapped method
321
+ @Transactional() // wraps it — must sit BELOW @CloudFunction
322
+ static async submitJob(req: Parse.Cloud.FunctionRequest) {
323
+ // every write in here commits or rolls back as one
324
+ }
325
+ }
326
+
327
+ // Outside a cloud function, or for part of one:
328
+ await withTransaction(async () => {
329
+ await order.save(null, {useMasterKey: true});
330
+ await inventory.save(null, {useMasterKey: true});
331
+ });
332
+ ```
333
+
334
+ - **Decorator order matters.** Decorators apply bottom-up and `@CloudFunction` captures the method when applied, so `@Transactional()` must sit *below* it — otherwise the registry keeps the unwrapped method and the transaction silently never opens.
335
+ - **Nested calls join** the outer transaction; the outermost caller commits.
336
+ - **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 `CONFLICT` error.
337
+ - System classes (`_SCHEMA`, `_Idempotency`, `_Hooks`, `_JobStatus`, `_GlobalConfig`) are never dragged into a transaction — schema creation and idempotency keys must survive a rollback.
338
+ - Each concurrent request gets its **own** session — unlike Parse Server's built-in transaction support, one caller's transaction cannot swallow another's writes.
339
+
340
+ ### `@ParseVersionField`
341
+
342
+ 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.
343
+
344
+ ```ts
345
+ import {ParseVersionField} from '@90soft/parse-server-kit';
346
+
347
+ @ParseClass('Job')
348
+ class Job extends BaseModel {
349
+ @ParseField({type: 'String'})
350
+ title!: string;
351
+
352
+ @ParseVersionField() // declares the Number field itself — no @ParseField needed
353
+ version!: number;
354
+ }
355
+ ```
356
+
357
+ That is the whole feature. No endpoint reads or writes the field:
358
+
359
+ - every object **read** from the database carries the version it was read at, and every `save()` / `saveAll()` asserts it automatically;
360
+ - 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;
361
+ - a save that lost the race is refused with the `CONFLICT` error (`5001`); a genuinely missing row still reads as missing;
362
+ - creates get version `1` from the adapter — callers never supply one;
363
+ - an object built from a bare id (never read) has nothing to assert, and simply isn't protected.
364
+
365
+ ### Handling the conflict
366
+
367
+ ```ts
368
+ import {CONFLICT} from '@90soft/parse-server-kit';
369
+
370
+ try {
371
+ await job.save(null, {useMasterKey: true});
372
+ } catch (error) {
373
+ if (error instanceof Parse.Error && error.code === CONFLICT) {
374
+ // Reload, re-apply the change, save again — or surface the message,
375
+ // which is already written for the end user.
376
+ }
377
+ throw error;
378
+ }
379
+ ```
380
+
381
+ ---
382
+
383
+ ## API Reference
384
+
385
+ ### Decorators
386
+
387
+ | Decorator | Target | Description |
388
+ |---|---|---|
389
+ | `@ParseClass(name, options)` | Class | Registers a Parse model with CLP, ACL, indexes |
390
+ | `@ParseField(options)` | Property | Defines a field with type, validation, index |
391
+ | `@CloudFunction(config)` | Static method | Registers a cloud function with HTTP method, validation, roles |
392
+ | `@ProtectedCloudFunction(config)` | Static method | Same as @CloudFunction but requires auth by default |
393
+ | `@Route(ModelOrString)` | Class | Maps cloud functions to `/api/{entity}/{action}` routes |
394
+ | `@Cron(config)` | Static method | Registers a cron job with schedule |
395
+ | `@BeforeSave(config?)` | Static method | Before save trigger |
396
+ | `@AfterSave(config?)` | Static method | After save trigger |
397
+ | `@BeforeDelete(config?)` | Static method | Before delete trigger |
398
+ | `@AfterDelete(config?)` | Static method | After delete trigger |
399
+ | `@BeforeFind(config?)` | Static method | Before find trigger |
400
+ | `@AfterFind(config?)` | Static method | After find trigger |
401
+ | `@BeforeLogin(config?)` | Static method | Before login trigger |
402
+ | `@AfterLogin(config?)` | Static method | After login trigger |
403
+ | `@AfterLogout(config?)` | Static method | After logout trigger |
404
+ | `@BeforePasswordResetRequest(config?)` | Static method | Before a password reset email is sent (parse-server 8.5+) |
405
+ | `@BeforeSaveFile` / `@AfterSaveFile` | Static method | File save triggers |
406
+ | `@BeforeDeleteFile` / `@AfterDeleteFile` | Static method | File delete triggers |
407
+ | `@BeforeFindFile` / `@AfterFindFile` | Static method | File find triggers (parse-server 8.1+) |
408
+ | `@BeforeSaveConfig` / `@AfterSaveConfig` | Static method | Parse Config triggers (parse-server 7.3+) |
409
+ | `@BeforeConnect` / `@BeforeSubscribe` / `@AfterEvent` | Static method | LiveQuery triggers |
410
+ | `@Transactional()` | Static method | Runs the method in a MongoDB transaction (place below `@CloudFunction`) |
411
+ | `@ParseVersionField(options?)` | Property | Declares the class's optimistic-lock version field |
412
+
413
+ ### @ParseField Options
414
+
415
+ | Option | Type | Description |
416
+ |---|---|---|
417
+ | `type` | `'String' \| 'Number' \| 'Boolean' \| 'Date' \| 'Pointer' \| 'Array' \| ...` | Field type |
418
+ | `required` | `boolean` | Whether the field is required |
419
+ | `targetClass` | `string` | Target class for Pointer/Relation |
420
+ | `index` | `boolean \| 1 \| -1` | Create an index |
421
+ | `unique` | `boolean` | Create a unique index |
422
+ | `min` / `max` | `number` | Number range validation |
423
+ | `minLength` / `maxLength` | `number` | String length validation |
424
+ | `enum` | `string[]` | Allowed values for String fields |
425
+ | `pattern` | `string` | Regex pattern for String fields |
426
+ | `description` | `string` | Swagger documentation |
427
+
428
+ ### @Route
429
+
430
+ ```ts
431
+ // Import model class — auto-generates route from className
432
+ @Route(Product) // → /api/products/*
433
+
434
+ // Custom string — full control over route prefix
435
+ @Route('menu-items') // → /api/menu-items/*
436
+ ```
437
+
438
+ **The method name IS the route** — no parsing, no prefix stripping, no
439
+ collisions. The route is `/{prefix}/{methodName}`:
440
+
441
+ ```
442
+ createProduct → POST /api/products/createProduct
443
+ getProduct → POST /api/products/getProduct
444
+ listProducts → GET /api/products/listProducts
445
+ updateProduct → POST /api/products/updateProduct
446
+ deleteProduct → POST /api/products/deleteProduct
447
+ ```
448
+
449
+ Matching is done against the class's real method list, so `getProduct` and
450
+ `getProductCategory` can coexist. Renaming a method renames its route.
451
+
452
+ ### BaseModel
453
+
454
+ ```ts
455
+ // Create from request params (auto-maps fields, handles pointers)
456
+ const product = Product.fromParams(req.params);
457
+
458
+ // Create a pointer reference by ID
459
+ const category = Category.pointer('abc123');
460
+
461
+ // Override excluded pointer classes (default: ['IMG', 'File'])
462
+ class MyModel extends BaseModel {
463
+ protected static EXCLUDED_POINTER_CLASSES = ['IMG', 'File', 'CustomFile'];
464
+ }
465
+ ```
466
+
467
+ ### ACL
468
+
469
+ ```ts
470
+ import { implementACL, UserRoles } from '@90soft/parse-server-kit';
471
+
472
+ obj.setACL(implementACL({
473
+ roleRules: [
474
+ { role: UserRoles.ADMIN, read: true, write: true },
475
+ { role: UserRoles.EMPLOYEE, read: true },
476
+ ],
477
+ owner: [
478
+ { user: userId, read: true, write: true },
479
+ ],
480
+ }));
481
+ ```
482
+
483
+ ### Utilities
484
+
485
+ ```ts
486
+ import {
487
+ catchError, // Wraps promises: const [err, data] = await catchError(promise)
488
+ getUserRoles, // Get role names for a user
489
+ getUsersRoles, // Roles for many users — one query per role, not per user
490
+ importFiles, // Auto-import a directory, for its decorator side effects
491
+ generateRandomPassword,
492
+ generateRandomString,
493
+ sleep,
494
+ formatCount,
495
+ } from '@90soft/parse-server-kit';
496
+ ```
497
+
498
+ `importFiles` loads `.js` only by default — the compiled output a production
499
+ server runs. If you start straight from source under ts-node or tsx, say so, or
500
+ **nothing is imported and the server boots with an empty schema**:
501
+
502
+ ```ts
503
+ importFiles(join(__dirname, 'cloudCode/models'), {extensions: ['.js', '.ts']});
504
+ ```
505
+
506
+ Point it at one directory or the other, never a tree holding both a compiled and
507
+ a source copy — that registers every class twice.
508
+
509
+ ### Middleware
510
+
511
+ ```ts
512
+ import {
513
+ validateEntityRoutes, // Maps /api/{entity}/{action} → cloud functions
514
+ restrictRoutes, // Blocks /classes, /schemas, etc.
515
+ removeResultMiddleware, // Unwraps Parse {result: ...} wrapper
516
+ conditionalJsonMiddleware, // JSON parsing with master key extraction
517
+ validateFunctionRoutes, // Legacy /functions/* validation
518
+ } from '@90soft/parse-server-kit';
519
+ ```
520
+
521
+ ### Swagger
522
+
523
+ ```ts
524
+ import { setupSwagger, SwaggerRegistry } from '@90soft/parse-server-kit';
525
+
526
+ // Auto-generates API docs from @ParseClass and @CloudFunction metadata
527
+ setupSwagger(app, {
528
+ title: 'My API',
529
+ version: '1.0.0',
530
+ description: 'Auto-generated docs',
531
+ });
532
+ // Access at: http://localhost:1337/api-docs
533
+ ```
534
+
535
+ ### Hooks (for integrations)
536
+
537
+ ```ts
538
+ import { onClassRegistered, onFieldRegistered, onFunctionRegistered } from '@90soft/parse-server-kit';
539
+
540
+ // Runs when @ParseClass is applied
541
+ onClassRegistered((className, constructor, fields, options) => {
542
+ // Register with Swagger, setup triggers, etc.
543
+ });
544
+
545
+ // Runs when @CloudFunction is applied
546
+ onFunctionRegistered((name, config, target) => {
547
+ // Register with Swagger docs
548
+ });
549
+ ```
550
+
551
+ ### Constants
552
+
553
+ ```ts
554
+ import { UserRoles, roleKey, MAX_QUERY_LIMIT, CronSchedule } from '@90soft/parse-server-kit';
555
+
556
+ UserRoles.ADMIN // 'SuperAdmin'
557
+ UserRoles.EMPLOYEE // 'Employee'
558
+ roleKey(UserRoles.ADMIN) // 'role:SuperAdmin'
559
+ MAX_QUERY_LIMIT // 10000
560
+
561
+ CronSchedule.EVERY_HOUR // '0 * * * *'
562
+ CronSchedule.DAILY_MIDNIGHT // '0 0 * * *'
563
+ CronSchedule.WEEKLY_MONDAY // '0 0 * * 1'
564
+ ```
565
+
566
+ ### Validation
567
+
568
+ ```ts
569
+ import { validateObject, validateOrThrow, applyMongoValidators } from '@90soft/parse-server-kit';
570
+
571
+ // Validate against @ParseField constraints (min, max, enum, pattern, etc.)
572
+ const result = validateObject(parseObject);
573
+ // { valid: false, errors: [{ field: 'price', message: 'must be at least 0' }] }
574
+
575
+ // Throw Parse.Error if invalid (use in BeforeSave triggers)
576
+ validateOrThrow(parseObject);
577
+
578
+ // Apply MongoDB schema validators from decorator metadata
579
+ await applyMongoValidators(parseServerInstance);
580
+ ```
581
+
582
+ ---
583
+
584
+ ## File Structure Convention
585
+
586
+ ```
587
+ backend/src/cloudCode/
588
+ ├── models/
589
+ │ └── Product.ts ← @ParseClass + @ParseField + triggers
590
+ ├── modules/
591
+ │ └── Product/
592
+ │ └── functions.ts ← @Route + @CloudFunction
593
+ ├── cron.ts ← @Cron jobs
594
+ ├── main.ts ← importFiles for models + modules
595
+ └── decorator/
596
+ └── setupHooks.ts ← connects package hooks to Swagger + Triggers
597
+ ```
598
+
599
+ ## Testing
600
+
601
+ ```bash
602
+ npm test # unit tests — fast, no database
603
+ npm run test:integration # real parse-server + in-memory MongoDB replica set
604
+ npm run test:all
605
+ ```
606
+
607
+ The integration suite exists to catch parse-server upgrades that move the
608
+ internals the transactions and versioning features lean on: the
609
+ `MongoStorageAdapter` module path, `_adaptiveCollection`/`_mongoCollection`,
610
+ `_getSaveJSON`, and — verified method by method against the mongodb driver
611
+ parse-server actually ships — every options position in `OPTIONS_ARGUMENT`.
612
+ It boots a real parse-server on `mongodb-memory-server` and exercises
613
+ versioned saves, stale-save conflicts, and cloud-function transaction
614
+ commit/rollback end to end. The first run downloads a MongoDB binary.
615
+
616
+ ## License
617
+
618
+ MIT