@90soft/parse-server-kit 2.9.0 → 2.10.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 (43) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/CLAUDE.md +698 -698
  3. package/dist/config.d.ts +68 -0
  4. package/dist/config.d.ts.map +1 -0
  5. package/dist/config.js +35 -0
  6. package/dist/config.js.map +1 -0
  7. package/dist/database/indexes.d.ts.map +1 -1
  8. package/dist/database/indexes.js +14 -1
  9. package/dist/database/indexes.js.map +1 -1
  10. package/dist/decorators/cloudDecorator.d.ts.map +1 -1
  11. package/dist/decorators/cloudDecorator.js +19 -0
  12. package/dist/decorators/cloudDecorator.js.map +1 -1
  13. package/dist/decorators/cloudRegistry.d.ts +13 -0
  14. package/dist/decorators/cloudRegistry.d.ts.map +1 -1
  15. package/dist/decorators/cloudRegistry.js +23 -1
  16. package/dist/decorators/cloudRegistry.js.map +1 -1
  17. package/dist/decorators/parseDecorators.d.ts +1 -1
  18. package/dist/decorators/parseDecorators.d.ts.map +1 -1
  19. package/dist/decorators/parseDecorators.js +131 -4
  20. package/dist/decorators/parseDecorators.js.map +1 -1
  21. package/dist/decorators/triggerDecorator.d.ts +14 -0
  22. package/dist/decorators/triggerDecorator.d.ts.map +1 -1
  23. package/dist/decorators/triggerDecorator.js +31 -2
  24. package/dist/decorators/triggerDecorator.js.map +1 -1
  25. package/dist/decorators/triggerRegistry.d.ts +8 -0
  26. package/dist/decorators/triggerRegistry.d.ts.map +1 -1
  27. package/dist/decorators/triggerRegistry.js +18 -0
  28. package/dist/decorators/triggerRegistry.js.map +1 -1
  29. package/dist/decorators/types/cloudTypes.d.ts +11 -0
  30. package/dist/decorators/types/cloudTypes.d.ts.map +1 -1
  31. package/dist/middleware/middleware.d.ts.map +1 -1
  32. package/dist/middleware/middleware.js +66 -5
  33. package/dist/middleware/middleware.js.map +1 -1
  34. package/dist/models/BaseModel.d.ts.map +1 -1
  35. package/dist/models/BaseModel.js +5 -2
  36. package/dist/models/BaseModel.js.map +1 -1
  37. package/dist/utils/dynamicImport.d.ts.map +1 -1
  38. package/dist/utils/dynamicImport.js +33 -11
  39. package/dist/utils/dynamicImport.js.map +1 -1
  40. package/dist/utils/roleCache.d.ts.map +1 -1
  41. package/dist/utils/roleCache.js +4 -1
  42. package/dist/utils/roleCache.js.map +1 -1
  43. package/package.json +3 -2
package/CLAUDE.md CHANGED
@@ -1,698 +1,698 @@
1
- # @90soft/parse-server-kit — Complete Reference
2
-
3
- Decorator-driven toolkit for Parse Server. **v2.8.0**
4
-
5
- This document is the full API contract. Every signature here is taken from the
6
- source, not paraphrased. If you are generating code against this library, read
7
- [Silent failures](#silent-failures) first — most of the ways this library goes
8
- wrong produce no error at all.
9
-
10
- ---
11
-
12
- ## Contents
13
-
14
- - [Requirements](#requirements) · [Boot order](#boot-order)
15
- - [Models](#models) — `@ParseClass`, `@ParseField`, `BaseModel`
16
- - [Cloud functions](#cloud-functions) — `@CloudFunction`, `@Route`
17
- - [Triggers](#triggers) · [Cron](#cron)
18
- - [Transactions](#transactions) · [Optimistic locking](#optimistic-locking)
19
- - [ACL](#acl) · [Middleware](#middleware) · [Indexes](#indexes)
20
- - [Schema](#schema) · [Validation](#validation) · [Swagger](#swagger)
21
- - [Utilities](#utilities) · [Constants](#constants) · [Types](#types)
22
- - [Silent failures](#silent-failures) · [Version matrix](#version-matrix)
23
-
24
- ---
25
-
26
- ## Requirements
27
-
28
- **tsconfig — `experimentalDecorators` is mandatory:**
29
-
30
- ```jsonc
31
- {"compilerOptions": {"experimentalDecorators": true}}
32
- ```
33
-
34
- This library uses **legacy** decorators. TypeScript 5 defaults to standard
35
- (TC39) decorators, which are a different feature. Without the flag, every
36
- decorator misbehaves. `emitDecoratorMetadata` is **not** needed — the library
37
- reads no `design:type` metadata.
38
-
39
- **`Parse` is a global.** The library never imports `parse`; it reads
40
- `global.Parse` the way cloud code sees it. Install `@types/parse` for the
41
- type declarations. Do not declare your own global `Parse` namespace — it will
42
- collide.
43
-
44
- **Peers:** `parse >=5`, `express >=4`, `reflect-metadata >=0.1.13`.
45
- **Optional:** `parse-server >=8.3`, `node-cron >=3`, `swagger-ui-express >=4`,
46
- `@types/parse >=3`.
47
-
48
- ---
49
-
50
- ## Boot order
51
-
52
- Order is load-bearing. This sequence is the contract:
53
-
54
- ```ts
55
- // 1. Models FIRST — @ParseClass calls Parse.Object.registerSubclass at
56
- // decorator-evaluation time, so Parse must already be global.
57
- importFiles(join(__dirname, 'cloudCode/models'));
58
- importFiles(join(__dirname, 'cloudCode/modules'));
59
- import './cloudCode/cron'; // @Cron definitions
60
-
61
- // 2. Parse Server
62
- const parseServer = await initializeParseServer();
63
-
64
- // 3. Middleware — this order matters
65
- app.use(removeResultMiddleware);
66
- app.use(cors());
67
- app.use(process.env.mountPath, validateEntityRoutes);
68
- app.use(conditionalJsonMiddleware);
69
- app.use(process.env.mountPath, restrictRoutes);
70
-
71
- // 4. Mount
72
- app.use(process.env.mountPath, parseServer.app);
73
-
74
- // 5. Registries — AFTER mount
75
- CloudFunctionRegistry.initialize(); // also runs RouteRegistry + version check
76
- TriggerRegistry.initialize();
77
- CronRegistry.initialize();
78
-
79
- // 6. Docs
80
- setupSwagger(app, {title: 'My API', version: '1.0.0'});
81
-
82
- // 7. After listen
83
- await applyAllIndexes(parseServer);
84
- await applyMongoValidators(parseServer);
85
- ```
86
-
87
- ---
88
-
89
- ## Models
90
-
91
- ### `@ParseClass(className, options?)`
92
-
93
- Class decorator. Registers the Parse subclass, CLP, ACL template, compound
94
- indexes; auto-registers the model with Swagger and flushes any pending triggers.
95
-
96
- ```ts
97
- interface ParseClassOptions {
98
- clp?: classLevelPermissions;
99
- protectedFields?: {[role: string]: string[]};
100
- ACL?: ClassAclTemplate;
101
- description?: string; // Swagger
102
- compoundIndexes?: CompoundIndex[];
103
- }
104
- ```
105
-
106
- ```ts
107
- @ParseClass('Product', {
108
- clp: {
109
- find: {[roleKey(UserRoles.ADMIN)]: true},
110
- get: {[roleKey(UserRoles.ADMIN)]: true},
111
- create: {[roleKey(UserRoles.ADMIN)]: true},
112
- update: {[roleKey(UserRoles.ADMIN)]: true},
113
- delete: {[roleKey(UserRoles.ADMIN)]: true},
114
- count: {[roleKey(UserRoles.ADMIN)]: true},
115
- },
116
- compoundIndexes: [{fields: ['status', 'createdAt'], unique: false}],
117
- })
118
- export default class Product extends BaseModel { ... }
119
- ```
120
-
121
- A `Parse.Role` subclass is detected and **not** registered via
122
- `registerSubclass` (Parse rejects that).
123
-
124
- ### `@ParseField(options)`
125
-
126
- Property decorator. Defines a getter/setter on the prototype mapping to
127
- `Parse.Object.get/set`, so `product.name` **is** the supported API — not
128
- `product.get('name')`.
129
-
130
- ```ts
131
- interface ParseFieldOptions {
132
- type: 'String' | 'Number' | 'Boolean' | 'Date' | 'Object' | 'Array'
133
- | 'GeoPoint' | 'File' | 'Bytes' | 'Polygon' | 'Pointer' | 'Relation';
134
- required?: boolean;
135
- targetClass?: string; // REQUIRED for Pointer / Relation
136
- description?: string; // Swagger
137
- indexName?: string; // override generated index name
138
- min?: number; max?: number; // Number only
139
- minLength?: number; maxLength?: number; // String only
140
- enum?: string[]; // String only
141
- pattern?: string; // String only, valid RegExp
142
- geo?: boolean; // GeoPoint only → 2dsphere index
143
- ttlSeconds?: number; // Date only → TTL index
144
- index?: boolean | 1 | -1; // mutually exclusive with `unique`
145
- unique?: boolean; // mutually exclusive with `index`
146
- }
147
- ```
148
-
149
- **Options are validated at import time** and throw immediately — a Pointer with
150
- no `targetClass`, `min` on a String, an invalid regex, `geo` combined with
151
- `index`, a negative `ttlSeconds`. Failures happen at boot, not in production.
152
-
153
- `index` and `unique` are mutually exclusive in the type system. `geo` and
154
- `ttlSeconds` cannot be combined with either.
155
-
156
- ### `BaseModel`
157
-
158
- ```ts
159
- class BaseModel extends Parse.Object {
160
- protected static EXCLUDED_POINTER_CLASSES: string[]; // default ['IMG','File']
161
- static pointer<T>(id: string): InstanceType<T>;
162
- static fromParams<T>(params: any): InstanceType<T>;
163
- }
164
- ```
165
-
166
- - **`Model.pointer(id)`** — a reference by id, no fetch.
167
- - **`Model.fromParams(req.params)`** — builds an instance from request params,
168
- reading `@ParseField` metadata to convert Pointers, Arrays-of-Pointers, Dates
169
- and GeoPoints. Use this in create/update rather than manual `set()`.
170
- - Pointer fields whose `targetClass` is in `EXCLUDED_POINTER_CLASSES` are
171
- **skipped** (`IMG`, `File` need their own handling).
172
- - An Array field converts to pointers **only if `targetClass` is set**.
173
- - A Pointer given `null` or `{}` is set to `null` (explicit clear).
174
-
175
- `Parse.Query` returns `Parse.Object`, not your typed class — cast the result
176
- (`row as Product`) or direct property access is unavailable.
177
-
178
- ---
179
-
180
- ## Cloud functions
181
-
182
- ### `@CloudFunction(config)`
183
-
184
- Static-method decorator. Registers the function and wraps it with role checking.
185
-
186
- ```ts
187
- interface RouteConfig {
188
- methods: HttpMethod[]; // 'GET'|'POST'|'PUT'|'DELETE'|'PATCH'
189
- requiresAuth?: boolean;
190
- rateLimit?: {windowMs: number; max: number};
191
- validation?: Parse.Cloud.Validator;
192
- description?: string;
193
- requireRoles?: string[];
194
- requireAllRoles?: boolean; // default false = ANY role
195
- customErrorMessage?: string;
196
- swagger?: {summary?; description?; tags?; deprecated?; responses?};
197
- }
198
- ```
199
-
200
- `requireRoles` is enforced by the wrapper before your body runs: no user →
201
- `OBJECT_NOT_FOUND 'Authentication required'`; wrong roles →
202
- `OPERATION_FORBIDDEN`. `requireAllRoles: true` demands every listed role.
203
-
204
- **GET params arrive as strings** — `validateEntityRoutes` merges the query
205
- string into the body. Declare them `{type: String}` and convert:
206
-
207
- ```ts
208
- const limit = Number(req.params.limit) || 10;
209
- const active = req.params.isActive === 'true';
210
- ```
211
-
212
- ### `@ProtectedCloudFunction(config?)`
213
-
214
- `@CloudFunction` with `methods: ['POST']` and `validation: {requireUser: true}`
215
- pre-applied; your config overrides.
216
-
217
- ### `@Route(ModelOrString)`
218
-
219
- Class decorator mapping methods to REST routes. Captures **both** prototype and
220
- static method names.
221
-
222
- ```ts
223
- @Route(Product) // → /api/products/* (kebab-plural of the JS class name)
224
- @Route('menu-items') // → /api/menu-items/*
225
- ```
226
-
227
- The route is `/{prefix}/{methodName}` — **the method name is the route**, matched
228
- against the class's real method list, so there is no string parsing and no
229
- collision between `getProduct` and `getProductCategory`.
230
-
231
- ```
232
- createProduct → POST /api/products/createProduct
233
- listProducts → GET /api/products/listProducts
234
- ```
235
-
236
- ---
237
-
238
- ## Triggers
239
-
240
- All are static-method decorators taking `{description?, validation?}`.
241
-
242
- > **Triggers only register if the class also has `@ParseClass`.** They park in
243
- > metadata until `@ParseClass` names the class and flushes them. On a plain
244
- > class they are silently never registered.
245
-
246
- | Decorator | Registers as |
247
- |---|---|
248
- | `@BeforeSave` `@AfterSave` | `Parse.Cloud.beforeSave(className, …)` |
249
- | `@BeforeDelete` `@AfterDelete` | `beforeDelete` / `afterDelete` |
250
- | `@BeforeFind` `@AfterFind` | `beforeFind` / `afterFind` |
251
- | `@BeforeLogin` `@AfterLogin` `@AfterLogout` | auth triggers (no className) |
252
- | `@BeforePasswordResetRequest` | `beforePasswordResetRequest` — parse-server 8.5+ |
253
- | `@BeforeSaveFile` `@AfterSaveFile` | `beforeSave(Parse.File, …)` |
254
- | `@BeforeDeleteFile` `@AfterDeleteFile` | `beforeDelete(Parse.File, …)` |
255
- | `@BeforeFindFile` `@AfterFindFile` | `beforeFind(Parse.File, …)` — 8.1+ |
256
- | `@BeforeSaveConfig` `@AfterSaveConfig` | `beforeSave(Parse.Config, …)` — 7.3+ |
257
- | `@BeforeConnect` `@BeforeSubscribe` `@AfterEvent` | LiveQuery (`afterEvent` → `afterLiveQueryEvent`) |
258
-
259
- File and Config triggers pass the **class itself**, not a name. parse-server
260
- removed the old `beforeSaveFile()` style methods; this library handles the
261
- translation.
262
-
263
- One trigger per `className:type` — a second registration warns and overwrites.
264
-
265
- ```ts
266
- @ParseClass('Product', {...})
267
- export default class Product extends BaseModel {
268
- @BeforeSave()
269
- static async onBeforeSave(req: Parse.Cloud.BeforeSaveRequest<Product>) {
270
- if (!req.object.get('name')) throw new Parse.Error(142, 'Name is required');
271
- }
272
- }
273
- ```
274
-
275
- ---
276
-
277
- ## Cron
278
-
279
- ```ts
280
- interface CronConfig {
281
- schedule: string; // cron expression, validated at initialize()
282
- description?: string;
283
- enabled?: boolean; // default true
284
- timezone?: string; // default 'UTC'
285
- }
286
- ```
287
-
288
- ```ts
289
- class Jobs {
290
- @Cron({schedule: CronSchedule.DAILY_MIDNIGHT, description: 'Cleanup'})
291
- static async cleanup() { ... }
292
- }
293
- ```
294
-
295
- `CronSchedule`: `EVERY_MINUTE`, `EVERY_5_MINUTES`, `EVERY_10_MINUTES`,
296
- `EVERY_15_MINUTES`, `EVERY_30_MINUTES`, `EVERY_HOUR`, `EVERY_2_HOURS`,
297
- `EVERY_6_HOURS`, `EVERY_12_HOURS`, `DAILY_MIDNIGHT`, `DAILY_NOON`,
298
- `WEEKLY_SUNDAY`, `WEEKLY_MONDAY`, `MONTHLY_FIRST`, `YEARLY`.
299
-
300
- `CronRegistry`: `initialize()`, `getJobs()`, `getJob(name)`, `stopJob(name)`,
301
- `startJob(name)`, `stopAll()`, `runNow(name)`. Without `node-cron` installed,
302
- `initialize()` warns and skips — jobs never run.
303
-
304
- ---
305
-
306
- ## Transactions
307
-
308
- MongoDB only. Requires a **replica set** and parse-server `directAccess: true`.
309
-
310
- ```ts
311
- const parseServer = new ParseServer({
312
- databaseAdapter: createVersionedMongoAdapter({
313
- uri: process.env.DATABASE_URI, collectionPrefix: '', mongoOptions: {},
314
- }),
315
- directAccess: true, // REQUIRED — see below
316
- });
317
- ```
318
-
319
- ```ts
320
- function Transactional(): MethodDecorator;
321
- function withTransaction<T>(body: () => Promise<T>): Promise<T>;
322
- function inTransaction(): boolean;
323
- function currentSession(): unknown;
324
- const CONFLICT = 5001;
325
- const CONFLICT_MESSAGE: string;
326
- ```
327
-
328
- ### ⚠️ Decorator order
329
-
330
- ```ts
331
- @CloudFunction({...}) // MUST be above — sees the wrapped method
332
- @Transactional() // MUST be below — does the wrapping
333
- static async submitJob(req) { ... }
334
- ```
335
-
336
- Decorators apply bottom-up and `@CloudFunction` captures the method when
337
- applied. Reversed, the registry keeps the **unwrapped** method and the
338
- transaction **never opens** — no error, no log.
339
-
340
- ### Semantics
341
-
342
- - Every `save()` / `destroy()` / query inside the body joins automatically
343
- (`AsyncLocalStorage` follows the call chain). No session threading.
344
- - **Nested calls join** the outer transaction; the outermost caller commits.
345
- - **The body may re-run** (up to 3 attempts) on a transient conflict, so it must
346
- be safe to repeat. After 3 losses the caller gets `CONFLICT`.
347
- - Each concurrent request gets its **own** session — unlike Parse Server's
348
- built-in transaction, which is `DatabaseController`-global.
349
- - Never joined: `_SCHEMA`, `_Idempotency`, `_Hooks`, `_JobStatus`,
350
- `_GlobalConfig` — schema creation must survive a rollback.
351
- - Unfiltered `count()` reads **outside** the transaction (MongoDB refuses
352
- `count` inside one).
353
-
354
- Without `directAccess`, a `save()` in cloud code becomes an internal HTTP
355
- request landing in a fresh async context, and writes **outside** the
356
- transaction. That failure has no symptom.
357
-
358
- ---
359
-
360
- ## Optimistic locking
361
-
362
- ```ts
363
- @ParseClass('Job')
364
- class Job extends BaseModel {
365
- @ParseVersionField() // declares the Number field itself — no @ParseField
366
- version!: number;
367
- }
368
- ```
369
-
370
- That is the entire feature. No endpoint reads or writes the field:
371
-
372
- - every object **read** carries the version it was read at; every `save()` /
373
- `saveAll()` asserts it automatically;
374
- - the adapter moves the assertion into the write's **filter** and `$inc`s the
375
- field, so the next reader gets a fresh number;
376
- - a lost race is refused with `CONFLICT` (5001); a genuinely missing row still
377
- reads as missing;
378
- - creates get version `1` from the adapter — callers never supply one;
379
- - an object built from a bare id (never read) has nothing to assert and is
380
- **not protected**.
381
-
382
- ```ts
383
- try {
384
- await job.save(null, {useMasterKey: true});
385
- } catch (error) {
386
- if (error instanceof Parse.Error && error.code === CONFLICT) {
387
- // Reload, re-apply, retry — or surface CONFLICT_MESSAGE, already
388
- // written for the end user.
389
- }
390
- throw error;
391
- }
392
- ```
393
-
394
- `VERSION_CONFLICT` === `CONFLICT` — a lost lock and a lost transaction race are
395
- the same event to the person on the screen.
396
-
397
- `VersionRegistry`: `fieldFor(className)`, `isVersioned(className)`,
398
- `classNames()`, `adapterIsInstalled()`, `verify()`.
399
-
400
- ---
401
-
402
- ## ACL
403
-
404
- ```ts
405
- function implementACL(
406
- params: {
407
- publicRead?: boolean;
408
- publicWrite?: boolean;
409
- roleRules?: {role: string; read?: boolean; write?: boolean}[];
410
- excludedRoles?: string[];
411
- owner?: {user?: string | any; read?: boolean; write?: boolean}[];
412
- },
413
- existingACL?: Parse.ACL
414
- ): Parse.ACL;
415
- ```
416
-
417
- **It takes one params object and RETURNS an ACL.** It does not take the object
418
- as an argument.
419
-
420
- ```ts
421
- // CORRECT
422
- product.setACL(implementACL({
423
- roleRules: [
424
- {role: UserRoles.ADMIN, read: true, write: true},
425
- {role: UserRoles.EMPLOYEE, read: true},
426
- ],
427
- publicRead: status === 'active',
428
- owner: [{user: userId, read: true, write: true}],
429
- }));
430
-
431
- // WRONG — no such signature, will not compile
432
- implementACL(product, {readRoles: [...], writeRoles: [...]});
433
- ```
434
-
435
- ```ts
436
- function syncImageAcl(parent: Parse.Object, fields: string[], acl?: Parse.ACL): void;
437
- function cloneAcl(acl: Parse.ACL): Parse.ACL;
438
- ```
439
-
440
- `syncImageAcl` copies the parent's ACL onto nested image pointers (single or
441
- array) so they follow the parent's visibility. Call it **after** setting the
442
- parent's final ACL and **before** saving. For status-derived visibility set in a
443
- `beforeSave` trigger, pass `publicRead` into `implementACL` at the call site —
444
- `syncImageAcl` runs before the trigger.
445
-
446
- ---
447
-
448
- ## Middleware
449
-
450
- | Export | Purpose |
451
- |---|---|
452
- | `validateEntityRoutes` | Resolves `/api/{entity}/{action}` → cloud function; enforces the declared HTTP method (405); applies `rateLimit`; merges GET query into body; rewrites to `/functions/{name}` as POST |
453
- | `restrictRoutes` | Blocks `/classes`, `/schemas`, `/batch`. Allows `/health`, `/serverInfo`, `/files`, registered prefixes, `/functions`. Master key bypasses |
454
- | `removeResultMiddleware` | Unwraps Parse's `{result: …}` response |
455
- | `conditionalJsonMiddleware` | `express.json({limit:'10mb', type:['text/plain']})`, skipping `{mountPath}/files` |
456
- | `validateFunctionRoutes` | Legacy `/functions/*` validation |
457
- | `checkRateLimit(req,res,name,cfg)` | In-process token bucket; returns `false` after sending 429 |
458
-
459
- **Master key** is read from the `X-Parse-Master-Key` header (preferred) or the
460
- request body (deprecated, warns once). Rate limiting is **per process** — no
461
- enforcement across instances.
462
-
463
- ---
464
-
465
- ## Indexes
466
-
467
- ```ts
468
- function applyAllIndexes(parseServerInstance?: any): Promise<void>;
469
- const applyUniqueIndexes = applyAllIndexes; // permanent alias
470
- function getUniqueIndexes(): UniqueIndexInfo[];
471
- function getCompoundIndexes(): CompoundIndexInfo[];
472
- function getFieldIndexes(): FieldIndexInfo[];
473
- ```
474
-
475
- Call after `listen()`. Creates, from decorator metadata:
476
-
477
- | Source | Index |
478
- |---|---|
479
- | `@ParseField({unique: true})` | unique (drops a conflicting non-unique index first) |
480
- | `@ParseField({index: true \| 1 \| -1})` | B-tree asc/desc |
481
- | `@ParseField({geo: true})` on GeoPoint | 2dsphere |
482
- | `@ParseField({ttlSeconds: N})` on Date | TTL |
483
- | `@ParseClass({compoundIndexes})` | compound |
484
-
485
- ```ts
486
- interface CompoundIndex {
487
- fields: string[];
488
- unique?: boolean;
489
- name?: string;
490
- sparse?: boolean;
491
- partialFilterNulls?: boolean; // only index docs where all fields exist
492
- fieldTypes?: Record<string, 1|-1|'text'|'2dsphere'|'hashed'>;
493
- options?: Record<string, unknown>; // e.g. default_language
494
- }
495
- ```
496
-
497
- Existing indexes (codes 85/86) are reported, not treated as errors. If the
498
- adapter can't be reached it prints the equivalent `db.x.createIndex(...)`
499
- commands. MongoDB allows only **one text index per collection**.
500
-
501
- ---
502
-
503
- ## Schema
504
-
505
- ```ts
506
- function createSchemaConfig(options?: SchemaConfigOptions): {...};
507
-
508
- interface SchemaConfigOptions {
509
- adminRole?: string; // default 'SuperAdmin'
510
- lockSchemas?: boolean; // default false
511
- strict?: boolean; // default true
512
- recreateModifiedFields?: boolean; // default false — DANGEROUS
513
- deleteExtraFields?: boolean; // default false — DANGEROUS
514
- keepUnknownIndexes?: boolean; // default TRUE — parse-server 8.3+
515
- }
516
- ```
517
-
518
- Builds the `schema` option from every registered `@ParseClass`, plus a `_Role`
519
- definition locked to `adminRole`.
520
-
521
- `keepUnknownIndexes` defaults to **true** because `applyAllIndexes` creates
522
- indexes out-of-band; parse-server's default (`false`) drops indexes it cannot
523
- account for during schema migration. **Requires parse-server 8.3+** — it refuses
524
- to start on an unrecognised option.
525
-
526
- ---
527
-
528
- ## Validation
529
-
530
- ```ts
531
- function validateObject(object: Parse.Object): {valid: boolean; errors: {field, message, value?}[]};
532
- function validateOrThrow(object: Parse.Object): void; // throws Parse.Error VALIDATION_ERROR
533
- function getValidators(): {className, validator}[];
534
- function applyMongoValidators(parseServerInstance?: any): Promise<void>;
535
- ```
536
-
537
- Checks `required`, `min`/`max`, `minLength`/`maxLength`, `enum`, `pattern` from
538
- `@ParseField`. `validateOrThrow` is intended for `@BeforeSave`.
539
- `applyMongoValidators` pushes the same constraints into MongoDB `$jsonSchema`
540
- validators (`validationLevel: 'moderate'`, `validationAction: 'error'`), so the
541
- database enforces them even for writes that bypass your code.
542
-
543
- ---
544
-
545
- ## Swagger
546
-
547
- ```ts
548
- function setupSwagger(app: Express, config: SwaggerConfig, path?: string): void; // default '/api-docs'
549
- function generateSwaggerSpec(config: SwaggerConfig): object;
550
- function getSwaggerJson(config: SwaggerConfig): string;
551
-
552
- interface SwaggerConfig {
553
- title: string; version: string;
554
- description?: string; basePath?: string; host?: string; schemes?: string[];
555
- }
556
- ```
557
-
558
- Models and endpoints are registered automatically from `@ParseClass` /
559
- `@CloudFunction` — no annotation needed. Endpoints document their real `@Route`
560
- path. **GET/HEAD emit query `parameters`; everything else emits a JSON
561
- `requestBody`** (a browser cannot send a GET body). Security schemes derive from
562
- `requireUser` / `requireMaster` / `requireRoles`.
563
-
564
- The spec is always served at `{path}/json`. Without `swagger-ui-express`
565
- installed, the browser UI is skipped with a warning; the spec still serves.
566
-
567
- ---
568
-
569
- ## Utilities
570
-
571
- ```ts
572
- function catchError<T>(p: Promise<T>): Promise<[undefined, T] | [Error]>;
573
- function getUserRoles(user: Parse.User): Promise<string[]>;
574
- function getUsersRoles(users: Parse.User[]): Promise<Map<string, string[]>>;
575
- function importFiles(dir: string, options?: {extensions?: string[]; verbose?: boolean}): void;
576
- function generateRandomPassword(length?, includeNumbers?, includeSymbols?): string;
577
- function generateRandomString(length?: number): string;
578
- function generateRandomInteger(length: number): string;
579
- function sleep(ms: number): Promise<unknown>;
580
- function formatCount(num: any, locale?: string): string; // compact notation
581
- ```
582
-
583
- **`catchError` is the error convention** — use it for all async work rather than
584
- `try/catch` around `await`:
585
-
586
- ```ts
587
- const [err, saved] = await catchError(product.save(null, {sessionToken}));
588
- if (err) throw err;
589
- ```
590
-
591
- **`importFiles` defaults to `.js` only.** Under ts-node/tsx pass
592
- `{extensions: ['.js', '.ts']}` or nothing is imported at all. Never point it at
593
- a tree holding both compiled and source copies — every class registers twice.
594
-
595
- `getUsersRoles` costs one query per **role** (capped at 100), not per user.
596
-
597
- ### Role cache (opt-in, off by default)
598
-
599
- ```ts
600
- function configureRoleCache(config: {ttlMs: number; maxUsers?: number} | false): void;
601
- function invalidateRoles(userId?: string): void; // no arg = everyone
602
- function roleCacheEnabled(): boolean;
603
- function roleCacheStats(): {enabled: boolean; size: number; ttlMs: number | null};
604
- ```
605
-
606
- `@CloudFunction({requireRoles})` and `getUserRoles()` each cost a database
607
- round-trip per call. Role membership rarely changes, so it caches well — but a
608
- **revoked role keeps working until the entry expires**, which is why nothing
609
- happens until you opt in:
610
-
611
- ```ts
612
- configureRoleCache({ttlMs: 30_000}); // global
613
- @CloudFunction({requireRoles: [...], roleCacheMs: 0}) // this endpoint never caches
614
- ```
615
-
616
- Call `invalidateRoles(userId)` wherever you grant or revoke, and the TTL becomes
617
- a backstop for changes made outside your code rather than the primary control.
618
- `maxUsers` (default 10000) bounds memory; the oldest entry is evicted past it.
619
- There is no timer — entries expire on read.
620
-
621
- ---
622
-
623
- ## Constants
624
-
625
- ```ts
626
- enum UserRoles {ADMIN = 'SuperAdmin', EMPLOYEE = 'Employee'}
627
- function roleKey(role: UserRoles): `role:${UserRoles}`;
628
- const MAX_QUERY_LIMIT = 10000;
629
- ```
630
-
631
- Use `roleKey(UserRoles.ADMIN)` for CLP/ACL keys — never hardcode
632
- `'role:SuperAdmin'`.
633
-
634
- ---
635
-
636
- ## Types
637
-
638
- `ClassNameType`, `CLPParamsOption`, `classLevelPermissions`, `ClassAclTemplate`,
639
- `AclTemplatePermissions`, `ProtectedFields`, `CompoundIndex`, `IndexFieldType`,
640
- `Fields`, `Indexes`, `SchemaDefinition`, `HttpMethod`, `RouteConfig`,
641
- `CloudFunctionMetadata`, `SwaggerDocConfig`, `TriggerType`, `TriggerMetadata`,
642
- `TriggerConfig`, `CronConfig`, `CronJobMetadata`, `RoleString`,
643
- `ParseClassOptions`, `ParseFieldOptions`, `AllowedFieldType`,
644
- `SwaggerModelSchema`, `SwaggerPropertySchema`, `SwaggerFunctionSchema`,
645
- `SchemaConfigOptions`, `TransactionalAdapter`, `VersionedMongoAdapter`,
646
- `MongoAdapterConstructor`.
647
-
648
- ```ts
649
- interface AuthRole {id: string; name: string}
650
- interface MultiLangs {ar?: string; en?: string}
651
- interface Filter {key: string; value: string|number|string[];
652
- type: 'string'|'min'|'max'|'array'|'text'|'dropdown'}
653
- ```
654
-
655
- **Registration hooks** (for custom integrations):
656
- `onClassRegistered`, `onFieldRegistered`, `onFunctionRegistered`.
657
- Also exported: `getSchemaDefinition(target)`, `classNames`.
658
-
659
- ---
660
-
661
- ## Silent failures
662
-
663
- Every item here produces **no error**. This is the list to check when something
664
- "doesn't work" but nothing is logged.
665
-
666
- | Symptom | Cause |
667
- |---|---|
668
- | Decorators do nothing / odd behaviour | `experimentalDecorators` not set — TS 5 defaults to standard decorators |
669
- | Transaction never opens | `@Transactional()` placed **above** `@CloudFunction` instead of below |
670
- | Writes land outside the transaction | parse-server `directAccess` not `true` |
671
- | No models, empty schema, no routes | `importFiles` ran over `.ts` sources with the default `['.js']` |
672
- | Every class registered twice | `importFiles` pointed at a tree with both compiled and source copies |
673
- | Trigger never fires | Declared on a class without `@ParseClass` — pending triggers are never flushed |
674
- | Unique indexes vanish after restart | `keepUnknownIndexes` false, or parse-server < 8.3 |
675
- | Pointer field ignored by `fromParams` | `targetClass` missing, or it is `IMG`/`File` (excluded by design) |
676
- | Array of pointers stays raw | Array field has no `targetClass` |
677
- | Direct property access unavailable | Query result not cast to the typed model |
678
- | Cron jobs never run | `node-cron` not installed — `initialize()` warns and skips |
679
- | Object not protected by version field | Built from a bare id, never read, so there is nothing to assert |
680
-
681
- Three of these now **do** log as of v2.8.0: `@ParseVersionField` with no adapter
682
- (or on Postgres), and a Parse SDK where `_getSaveJSON` has moved. Check the boot
683
- output for `[Versioning]`.
684
-
685
- ---
686
-
687
- ## Version matrix
688
-
689
- | | Requirement |
690
- |---|---|
691
- | Node | ≥ 18 for this package; **≥ 20.19** with parse-server 9 |
692
- | parse-server | ≥ 8.3 (optional peer) — `keepUnknownIndexes`, adapter internals |
693
- | MongoDB | ≥ 7.0.16 for parse-server 9; **replica set** for transactions |
694
- | Parse SDK | ≥ 5; verified on 5.3.0 and 8.6.0 |
695
- | TypeScript | any, with `experimentalDecorators: true` |
696
-
697
- **Transactions and `@ParseVersionField` are MongoDB-only.** On Postgres the
698
- version field is declared but never enforced.
1
+ # @90soft/parse-server-kit — Complete Reference
2
+
3
+ Decorator-driven toolkit for Parse Server. **v2.10.1**
4
+
5
+ This document is the full API contract. Every signature here is taken from the
6
+ source, not paraphrased. If you are generating code against this library, read
7
+ [Silent failures](#silent-failures) first — most of the ways this library goes
8
+ wrong produce no error at all.
9
+
10
+ ---
11
+
12
+ ## Contents
13
+
14
+ - [Requirements](#requirements) · [Boot order](#boot-order)
15
+ - [Models](#models) — `@ParseClass`, `@ParseField`, `BaseModel`
16
+ - [Cloud functions](#cloud-functions) — `@CloudFunction`, `@Route`
17
+ - [Triggers](#triggers) · [Cron](#cron)
18
+ - [Transactions](#transactions) · [Optimistic locking](#optimistic-locking)
19
+ - [ACL](#acl) · [Middleware](#middleware) · [Indexes](#indexes)
20
+ - [Schema](#schema) · [Validation](#validation) · [Swagger](#swagger)
21
+ - [Utilities](#utilities) · [Constants](#constants) · [Types](#types)
22
+ - [Silent failures](#silent-failures) · [Version matrix](#version-matrix)
23
+
24
+ ---
25
+
26
+ ## Requirements
27
+
28
+ **tsconfig — `experimentalDecorators` is mandatory:**
29
+
30
+ ```jsonc
31
+ {"compilerOptions": {"experimentalDecorators": true}}
32
+ ```
33
+
34
+ This library uses **legacy** decorators. TypeScript 5 defaults to standard
35
+ (TC39) decorators, which are a different feature. Without the flag, every
36
+ decorator misbehaves. `emitDecoratorMetadata` is **not** needed — the library
37
+ reads no `design:type` metadata.
38
+
39
+ **`Parse` is a global.** The library never imports `parse`; it reads
40
+ `global.Parse` the way cloud code sees it. Install `@types/parse` for the
41
+ type declarations. Do not declare your own global `Parse` namespace — it will
42
+ collide.
43
+
44
+ **Peers:** `parse >=5`, `express >=4`, `reflect-metadata >=0.1.13`.
45
+ **Optional:** `parse-server >=8.3`, `node-cron >=3`, `swagger-ui-express >=4`,
46
+ `@types/parse >=3`.
47
+
48
+ ---
49
+
50
+ ## Boot order
51
+
52
+ Order is load-bearing. This sequence is the contract:
53
+
54
+ ```ts
55
+ // 1. Models FIRST — @ParseClass calls Parse.Object.registerSubclass at
56
+ // decorator-evaluation time, so Parse must already be global.
57
+ importFiles(join(__dirname, 'cloudCode/models'));
58
+ importFiles(join(__dirname, 'cloudCode/modules'));
59
+ import './cloudCode/cron'; // @Cron definitions
60
+
61
+ // 2. Parse Server
62
+ const parseServer = await initializeParseServer();
63
+
64
+ // 3. Middleware — this order matters
65
+ app.use(removeResultMiddleware);
66
+ app.use(cors());
67
+ app.use(process.env.mountPath, validateEntityRoutes);
68
+ app.use(conditionalJsonMiddleware);
69
+ app.use(process.env.mountPath, restrictRoutes);
70
+
71
+ // 4. Mount
72
+ app.use(process.env.mountPath, parseServer.app);
73
+
74
+ // 5. Registries — AFTER mount
75
+ CloudFunctionRegistry.initialize(); // also runs RouteRegistry + version check
76
+ TriggerRegistry.initialize();
77
+ CronRegistry.initialize();
78
+
79
+ // 6. Docs
80
+ setupSwagger(app, {title: 'My API', version: '1.0.0'});
81
+
82
+ // 7. After listen
83
+ await applyAllIndexes(parseServer);
84
+ await applyMongoValidators(parseServer);
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Models
90
+
91
+ ### `@ParseClass(className, options?)`
92
+
93
+ Class decorator. Registers the Parse subclass, CLP, ACL template, compound
94
+ indexes; auto-registers the model with Swagger and flushes any pending triggers.
95
+
96
+ ```ts
97
+ interface ParseClassOptions {
98
+ clp?: classLevelPermissions;
99
+ protectedFields?: {[role: string]: string[]};
100
+ ACL?: ClassAclTemplate;
101
+ description?: string; // Swagger
102
+ compoundIndexes?: CompoundIndex[];
103
+ }
104
+ ```
105
+
106
+ ```ts
107
+ @ParseClass('Product', {
108
+ clp: {
109
+ find: {[roleKey(UserRoles.ADMIN)]: true},
110
+ get: {[roleKey(UserRoles.ADMIN)]: true},
111
+ create: {[roleKey(UserRoles.ADMIN)]: true},
112
+ update: {[roleKey(UserRoles.ADMIN)]: true},
113
+ delete: {[roleKey(UserRoles.ADMIN)]: true},
114
+ count: {[roleKey(UserRoles.ADMIN)]: true},
115
+ },
116
+ compoundIndexes: [{fields: ['status', 'createdAt'], unique: false}],
117
+ })
118
+ export default class Product extends BaseModel { ... }
119
+ ```
120
+
121
+ A `Parse.Role` subclass is detected and **not** registered via
122
+ `registerSubclass` (Parse rejects that).
123
+
124
+ ### `@ParseField(options)`
125
+
126
+ Property decorator. Defines a getter/setter on the prototype mapping to
127
+ `Parse.Object.get/set`, so `product.name` **is** the supported API — not
128
+ `product.get('name')`.
129
+
130
+ ```ts
131
+ interface ParseFieldOptions {
132
+ type: 'String' | 'Number' | 'Boolean' | 'Date' | 'Object' | 'Array'
133
+ | 'GeoPoint' | 'File' | 'Bytes' | 'Polygon' | 'Pointer' | 'Relation';
134
+ required?: boolean;
135
+ targetClass?: string; // REQUIRED for Pointer / Relation
136
+ description?: string; // Swagger
137
+ indexName?: string; // override generated index name
138
+ min?: number; max?: number; // Number only
139
+ minLength?: number; maxLength?: number; // String only
140
+ enum?: string[]; // String only
141
+ pattern?: string; // String only, valid RegExp
142
+ geo?: boolean; // GeoPoint only → 2dsphere index
143
+ ttlSeconds?: number; // Date only → TTL index
144
+ index?: boolean | 1 | -1; // mutually exclusive with `unique`
145
+ unique?: boolean; // mutually exclusive with `index`
146
+ }
147
+ ```
148
+
149
+ **Options are validated at import time** and throw immediately — a Pointer with
150
+ no `targetClass`, `min` on a String, an invalid regex, `geo` combined with
151
+ `index`, a negative `ttlSeconds`. Failures happen at boot, not in production.
152
+
153
+ `index` and `unique` are mutually exclusive in the type system. `geo` and
154
+ `ttlSeconds` cannot be combined with either.
155
+
156
+ ### `BaseModel`
157
+
158
+ ```ts
159
+ class BaseModel extends Parse.Object {
160
+ protected static EXCLUDED_POINTER_CLASSES: string[]; // default ['IMG','File']
161
+ static pointer<T>(id: string): InstanceType<T>;
162
+ static fromParams<T>(params: any): InstanceType<T>;
163
+ }
164
+ ```
165
+
166
+ - **`Model.pointer(id)`** — a reference by id, no fetch.
167
+ - **`Model.fromParams(req.params)`** — builds an instance from request params,
168
+ reading `@ParseField` metadata to convert Pointers, Arrays-of-Pointers, Dates
169
+ and GeoPoints. Use this in create/update rather than manual `set()`.
170
+ - Pointer fields whose `targetClass` is in `EXCLUDED_POINTER_CLASSES` are
171
+ **skipped** (`IMG`, `File` need their own handling).
172
+ - An Array field converts to pointers **only if `targetClass` is set**.
173
+ - A Pointer given `null` or `{}` is set to `null` (explicit clear).
174
+
175
+ `Parse.Query` returns `Parse.Object`, not your typed class — cast the result
176
+ (`row as Product`) or direct property access is unavailable.
177
+
178
+ ---
179
+
180
+ ## Cloud functions
181
+
182
+ ### `@CloudFunction(config)`
183
+
184
+ Static-method decorator. Registers the function and wraps it with role checking.
185
+
186
+ ```ts
187
+ interface RouteConfig {
188
+ methods: HttpMethod[]; // 'GET'|'POST'|'PUT'|'DELETE'|'PATCH'
189
+ requiresAuth?: boolean;
190
+ rateLimit?: {windowMs: number; max: number};
191
+ validation?: Parse.Cloud.Validator;
192
+ description?: string;
193
+ requireRoles?: string[];
194
+ requireAllRoles?: boolean; // default false = ANY role
195
+ customErrorMessage?: string;
196
+ swagger?: {summary?; description?; tags?; deprecated?; responses?};
197
+ }
198
+ ```
199
+
200
+ `requiresAuth` and `requireRoles` are both enforced by the wrapper before your body runs: no user →
201
+ `OBJECT_NOT_FOUND 'Authentication required'`; wrong roles →
202
+ `OPERATION_FORBIDDEN`. `requireAllRoles: true` demands every listed role.
203
+
204
+ **GET params arrive as strings** — `validateEntityRoutes` merges the query
205
+ string into the body. Declare them `{type: String}` and convert:
206
+
207
+ ```ts
208
+ const limit = Number(req.params.limit) || 10;
209
+ const active = req.params.isActive === 'true';
210
+ ```
211
+
212
+ ### `@ProtectedCloudFunction(config?)`
213
+
214
+ `@CloudFunction` with `methods: ['POST']` and `validation: {requireUser: true}`
215
+ pre-applied; your config overrides.
216
+
217
+ ### `@Route(ModelOrString)`
218
+
219
+ Class decorator mapping methods to REST routes. Captures **both** prototype and
220
+ static method names.
221
+
222
+ ```ts
223
+ @Route(Product) // → /api/products/* (kebab-plural of the JS class name)
224
+ @Route('menu-items') // → /api/menu-items/*
225
+ ```
226
+
227
+ The route is `/{prefix}/{methodName}` — **the method name is the route**, matched
228
+ against the class's real method list, so there is no string parsing and no
229
+ collision between `getProduct` and `getProductCategory`.
230
+
231
+ ```
232
+ createProduct → POST /api/products/createProduct
233
+ listProducts → GET /api/products/listProducts
234
+ ```
235
+
236
+ ---
237
+
238
+ ## Triggers
239
+
240
+ All are static-method decorators taking `{description?, validation?}`.
241
+
242
+ > **Triggers only register if the class also has `@ParseClass`.** They park in
243
+ > metadata until `@ParseClass` names the class and flushes them. On a plain
244
+ > class they are silently never registered.
245
+
246
+ | Decorator | Registers as |
247
+ |---|---|
248
+ | `@BeforeSave` `@AfterSave` | `Parse.Cloud.beforeSave(className, …)` |
249
+ | `@BeforeDelete` `@AfterDelete` | `beforeDelete` / `afterDelete` |
250
+ | `@BeforeFind` `@AfterFind` | `beforeFind` / `afterFind` |
251
+ | `@BeforeLogin` `@AfterLogin` `@AfterLogout` | auth triggers (no className) |
252
+ | `@BeforePasswordResetRequest` | `beforePasswordResetRequest` — parse-server 8.5+ |
253
+ | `@BeforeSaveFile` `@AfterSaveFile` | `beforeSave(Parse.File, …)` |
254
+ | `@BeforeDeleteFile` `@AfterDeleteFile` | `beforeDelete(Parse.File, …)` |
255
+ | `@BeforeFindFile` `@AfterFindFile` | `beforeFind(Parse.File, …)` — 8.1+ |
256
+ | `@BeforeSaveConfig` `@AfterSaveConfig` | `beforeSave(Parse.Config, …)` — 7.3+ |
257
+ | `@BeforeConnect` `@BeforeSubscribe` `@AfterEvent` | LiveQuery (`afterEvent` → `afterLiveQueryEvent`) |
258
+
259
+ File and Config triggers pass the **class itself**, not a name. parse-server
260
+ removed the old `beforeSaveFile()` style methods; this library handles the
261
+ translation.
262
+
263
+ One trigger per `className:type` — a second registration warns and overwrites.
264
+
265
+ ```ts
266
+ @ParseClass('Product', {...})
267
+ export default class Product extends BaseModel {
268
+ @BeforeSave()
269
+ static async onBeforeSave(req: Parse.Cloud.BeforeSaveRequest<Product>) {
270
+ if (!req.object.get('name')) throw new Parse.Error(142, 'Name is required');
271
+ }
272
+ }
273
+ ```
274
+
275
+ ---
276
+
277
+ ## Cron
278
+
279
+ ```ts
280
+ interface CronConfig {
281
+ schedule: string; // cron expression, validated at initialize()
282
+ description?: string;
283
+ enabled?: boolean; // default true
284
+ timezone?: string; // default 'UTC'
285
+ }
286
+ ```
287
+
288
+ ```ts
289
+ class Jobs {
290
+ @Cron({schedule: CronSchedule.DAILY_MIDNIGHT, description: 'Cleanup'})
291
+ static async cleanup() { ... }
292
+ }
293
+ ```
294
+
295
+ `CronSchedule`: `EVERY_MINUTE`, `EVERY_5_MINUTES`, `EVERY_10_MINUTES`,
296
+ `EVERY_15_MINUTES`, `EVERY_30_MINUTES`, `EVERY_HOUR`, `EVERY_2_HOURS`,
297
+ `EVERY_6_HOURS`, `EVERY_12_HOURS`, `DAILY_MIDNIGHT`, `DAILY_NOON`,
298
+ `WEEKLY_SUNDAY`, `WEEKLY_MONDAY`, `MONTHLY_FIRST`, `YEARLY`.
299
+
300
+ `CronRegistry`: `initialize()`, `getJobs()`, `getJob(name)`, `stopJob(name)`,
301
+ `startJob(name)`, `stopAll()`, `runNow(name)`. Without `node-cron` installed,
302
+ `initialize()` warns and skips — jobs never run.
303
+
304
+ ---
305
+
306
+ ## Transactions
307
+
308
+ MongoDB only. Requires a **replica set** and parse-server `directAccess: true`.
309
+
310
+ ```ts
311
+ const parseServer = new ParseServer({
312
+ databaseAdapter: createVersionedMongoAdapter({
313
+ uri: process.env.DATABASE_URI, collectionPrefix: '', mongoOptions: {},
314
+ }),
315
+ directAccess: true, // REQUIRED — see below
316
+ });
317
+ ```
318
+
319
+ ```ts
320
+ function Transactional(): MethodDecorator;
321
+ function withTransaction<T>(body: () => Promise<T>): Promise<T>;
322
+ function inTransaction(): boolean;
323
+ function currentSession(): unknown;
324
+ const CONFLICT = 5001;
325
+ const CONFLICT_MESSAGE: string;
326
+ ```
327
+
328
+ ### ⚠️ Decorator order
329
+
330
+ ```ts
331
+ @CloudFunction({...}) // MUST be above — sees the wrapped method
332
+ @Transactional() // MUST be below — does the wrapping
333
+ static async submitJob(req) { ... }
334
+ ```
335
+
336
+ Decorators apply bottom-up and `@CloudFunction` captures the method when
337
+ applied. Reversed, the registry keeps the **unwrapped** method and the
338
+ transaction **never opens** — no error, no log.
339
+
340
+ ### Semantics
341
+
342
+ - Every `save()` / `destroy()` / query inside the body joins automatically
343
+ (`AsyncLocalStorage` follows the call chain). No session threading.
344
+ - **Nested calls join** the outer transaction; the outermost caller commits.
345
+ - **The body may re-run** (up to 3 attempts) on a transient conflict, so it must
346
+ be safe to repeat. After 3 losses the caller gets `CONFLICT`.
347
+ - Each concurrent request gets its **own** session — unlike Parse Server's
348
+ built-in transaction, which is `DatabaseController`-global.
349
+ - Never joined: `_SCHEMA`, `_Idempotency`, `_Hooks`, `_JobStatus`,
350
+ `_GlobalConfig` — schema creation must survive a rollback.
351
+ - Unfiltered `count()` reads **outside** the transaction (MongoDB refuses
352
+ `count` inside one).
353
+
354
+ Without `directAccess`, a `save()` in cloud code becomes an internal HTTP
355
+ request landing in a fresh async context, and writes **outside** the
356
+ transaction. That failure has no symptom.
357
+
358
+ ---
359
+
360
+ ## Optimistic locking
361
+
362
+ ```ts
363
+ @ParseClass('Job')
364
+ class Job extends BaseModel {
365
+ @ParseVersionField() // declares the Number field itself — no @ParseField
366
+ version!: number;
367
+ }
368
+ ```
369
+
370
+ That is the entire feature. No endpoint reads or writes the field:
371
+
372
+ - every object **read** carries the version it was read at; every `save()` /
373
+ `saveAll()` asserts it automatically;
374
+ - the adapter moves the assertion into the write's **filter** and `$inc`s the
375
+ field, so the next reader gets a fresh number;
376
+ - a lost race is refused with `CONFLICT` (5001); a genuinely missing row still
377
+ reads as missing;
378
+ - creates get version `1` from the adapter — callers never supply one;
379
+ - an object built from a bare id (never read) has nothing to assert and is
380
+ **not protected**.
381
+
382
+ ```ts
383
+ try {
384
+ await job.save(null, {useMasterKey: true});
385
+ } catch (error) {
386
+ if (error instanceof Parse.Error && error.code === CONFLICT) {
387
+ // Reload, re-apply, retry — or surface CONFLICT_MESSAGE, already
388
+ // written for the end user.
389
+ }
390
+ throw error;
391
+ }
392
+ ```
393
+
394
+ `VERSION_CONFLICT` === `CONFLICT` — a lost lock and a lost transaction race are
395
+ the same event to the person on the screen.
396
+
397
+ `VersionRegistry`: `fieldFor(className)`, `isVersioned(className)`,
398
+ `classNames()`, `adapterIsInstalled()`, `verify()`.
399
+
400
+ ---
401
+
402
+ ## ACL
403
+
404
+ ```ts
405
+ function implementACL(
406
+ params: {
407
+ publicRead?: boolean;
408
+ publicWrite?: boolean;
409
+ roleRules?: {role: string; read?: boolean; write?: boolean}[];
410
+ excludedRoles?: string[];
411
+ owner?: {user?: string | any; read?: boolean; write?: boolean}[];
412
+ },
413
+ existingACL?: Parse.ACL
414
+ ): Parse.ACL;
415
+ ```
416
+
417
+ **It takes one params object and RETURNS an ACL.** It does not take the object
418
+ as an argument.
419
+
420
+ ```ts
421
+ // CORRECT
422
+ product.setACL(implementACL({
423
+ roleRules: [
424
+ {role: UserRoles.ADMIN, read: true, write: true},
425
+ {role: UserRoles.EMPLOYEE, read: true},
426
+ ],
427
+ publicRead: status === 'active',
428
+ owner: [{user: userId, read: true, write: true}],
429
+ }));
430
+
431
+ // WRONG — no such signature, will not compile
432
+ implementACL(product, {readRoles: [...], writeRoles: [...]});
433
+ ```
434
+
435
+ ```ts
436
+ function syncImageAcl(parent: Parse.Object, fields: string[], acl?: Parse.ACL): void;
437
+ function cloneAcl(acl: Parse.ACL): Parse.ACL;
438
+ ```
439
+
440
+ `syncImageAcl` copies the parent's ACL onto nested image pointers (single or
441
+ array) so they follow the parent's visibility. Call it **after** setting the
442
+ parent's final ACL and **before** saving. For status-derived visibility set in a
443
+ `beforeSave` trigger, pass `publicRead` into `implementACL` at the call site —
444
+ `syncImageAcl` runs before the trigger.
445
+
446
+ ---
447
+
448
+ ## Middleware
449
+
450
+ | Export | Purpose |
451
+ |---|---|
452
+ | `validateEntityRoutes` | Resolves `/api/{entity}/{action}` → cloud function; enforces the declared HTTP method (405); applies `rateLimit`; merges GET query into body; rewrites to `/functions/{name}` as POST |
453
+ | `restrictRoutes` | Blocks `/classes`, `/schemas`, `/batch`. Allows `/health`, `/serverInfo`, `/files`, registered prefixes, `/functions`. Master key bypasses |
454
+ | `removeResultMiddleware` | Unwraps Parse's `{result: …}` response |
455
+ | `conditionalJsonMiddleware` | `express.json({limit:'10mb', type:['text/plain']})`, skipping `{mountPath}/files` |
456
+ | `validateFunctionRoutes` | Legacy `/functions/*` validation |
457
+ | `checkRateLimit(req,res,name,cfg)` | In-process token bucket; returns `false` after sending 429 |
458
+
459
+ **Master key** is read from the `X-Parse-Master-Key` header (preferred) or the
460
+ request body (deprecated, warns once). Rate limiting is **per process** — no
461
+ enforcement across instances.
462
+
463
+ ---
464
+
465
+ ## Indexes
466
+
467
+ ```ts
468
+ function applyAllIndexes(parseServerInstance?: any): Promise<void>;
469
+ const applyUniqueIndexes = applyAllIndexes; // permanent alias
470
+ function getUniqueIndexes(): UniqueIndexInfo[];
471
+ function getCompoundIndexes(): CompoundIndexInfo[];
472
+ function getFieldIndexes(): FieldIndexInfo[];
473
+ ```
474
+
475
+ Call after `listen()`. Creates, from decorator metadata:
476
+
477
+ | Source | Index |
478
+ |---|---|
479
+ | `@ParseField({unique: true})` | unique (drops a conflicting non-unique index first) |
480
+ | `@ParseField({index: true \| 1 \| -1})` | B-tree asc/desc |
481
+ | `@ParseField({geo: true})` on GeoPoint | 2dsphere |
482
+ | `@ParseField({ttlSeconds: N})` on Date | TTL |
483
+ | `@ParseClass({compoundIndexes})` | compound |
484
+
485
+ ```ts
486
+ interface CompoundIndex {
487
+ fields: string[];
488
+ unique?: boolean;
489
+ name?: string;
490
+ sparse?: boolean;
491
+ partialFilterNulls?: boolean; // only index docs where all fields exist
492
+ fieldTypes?: Record<string, 1|-1|'text'|'2dsphere'|'hashed'>;
493
+ options?: Record<string, unknown>; // e.g. default_language
494
+ }
495
+ ```
496
+
497
+ Existing indexes (codes 85/86) are reported, not treated as errors. If the
498
+ adapter can't be reached it prints the equivalent `db.x.createIndex(...)`
499
+ commands. MongoDB allows only **one text index per collection**.
500
+
501
+ ---
502
+
503
+ ## Schema
504
+
505
+ ```ts
506
+ function createSchemaConfig(options?: SchemaConfigOptions): {...};
507
+
508
+ interface SchemaConfigOptions {
509
+ adminRole?: string; // default 'SuperAdmin'
510
+ lockSchemas?: boolean; // default false
511
+ strict?: boolean; // default true
512
+ recreateModifiedFields?: boolean; // default false — DANGEROUS
513
+ deleteExtraFields?: boolean; // default false — DANGEROUS
514
+ keepUnknownIndexes?: boolean; // default TRUE — parse-server 8.3+
515
+ }
516
+ ```
517
+
518
+ Builds the `schema` option from every registered `@ParseClass`, plus a `_Role`
519
+ definition locked to `adminRole`.
520
+
521
+ `keepUnknownIndexes` defaults to **true** because `applyAllIndexes` creates
522
+ indexes out-of-band; parse-server's default (`false`) drops indexes it cannot
523
+ account for during schema migration. **Requires parse-server 8.3+** — it refuses
524
+ to start on an unrecognised option.
525
+
526
+ ---
527
+
528
+ ## Validation
529
+
530
+ ```ts
531
+ function validateObject(object: Parse.Object): {valid: boolean; errors: {field, message, value?}[]};
532
+ function validateOrThrow(object: Parse.Object): void; // throws Parse.Error VALIDATION_ERROR
533
+ function getValidators(): {className, validator}[];
534
+ function applyMongoValidators(parseServerInstance?: any): Promise<void>;
535
+ ```
536
+
537
+ Checks `required`, `min`/`max`, `minLength`/`maxLength`, `enum`, `pattern` from
538
+ `@ParseField`. `validateOrThrow` is intended for `@BeforeSave`.
539
+ `applyMongoValidators` pushes the same constraints into MongoDB `$jsonSchema`
540
+ validators (`validationLevel: 'moderate'`, `validationAction: 'error'`), so the
541
+ database enforces them even for writes that bypass your code.
542
+
543
+ ---
544
+
545
+ ## Swagger
546
+
547
+ ```ts
548
+ function setupSwagger(app: Express, config: SwaggerConfig, path?: string): void; // default '/api-docs'
549
+ function generateSwaggerSpec(config: SwaggerConfig): object;
550
+ function getSwaggerJson(config: SwaggerConfig): string;
551
+
552
+ interface SwaggerConfig {
553
+ title: string; version: string;
554
+ description?: string; basePath?: string; host?: string; schemes?: string[];
555
+ }
556
+ ```
557
+
558
+ Models and endpoints are registered automatically from `@ParseClass` /
559
+ `@CloudFunction` — no annotation needed. Endpoints document their real `@Route`
560
+ path. **GET/HEAD emit query `parameters`; everything else emits a JSON
561
+ `requestBody`** (a browser cannot send a GET body). Security schemes derive from
562
+ `requireUser` / `requireMaster` / `requireRoles`.
563
+
564
+ The spec is always served at `{path}/json`. Without `swagger-ui-express`
565
+ installed, the browser UI is skipped with a warning; the spec still serves.
566
+
567
+ ---
568
+
569
+ ## Utilities
570
+
571
+ ```ts
572
+ function catchError<T>(p: Promise<T>): Promise<[undefined, T] | [Error]>;
573
+ function getUserRoles(user: Parse.User): Promise<string[]>;
574
+ function getUsersRoles(users: Parse.User[]): Promise<Map<string, string[]>>;
575
+ function importFiles(dir: string, options?: {extensions?: string[]; verbose?: boolean}): void;
576
+ function generateRandomPassword(length?, includeNumbers?, includeSymbols?): string;
577
+ function generateRandomString(length?: number): string;
578
+ function generateRandomInteger(length: number): string;
579
+ function sleep(ms: number): Promise<unknown>;
580
+ function formatCount(num: any, locale?: string): string; // compact notation
581
+ ```
582
+
583
+ **`catchError` is the error convention** — use it for all async work rather than
584
+ `try/catch` around `await`:
585
+
586
+ ```ts
587
+ const [err, saved] = await catchError(product.save(null, {sessionToken}));
588
+ if (err) throw err;
589
+ ```
590
+
591
+ **`importFiles` defaults to `.js` only.** Under ts-node/tsx pass
592
+ `{extensions: ['.js', '.ts']}` or nothing is imported at all. Never point it at
593
+ a tree holding both compiled and source copies — every class registers twice.
594
+
595
+ `getUsersRoles` costs one query per **role** (capped at 100), not per user.
596
+
597
+ ### Role cache (opt-in, off by default)
598
+
599
+ ```ts
600
+ function configureRoleCache(config: {ttlMs: number; maxUsers?: number} | false): void;
601
+ function invalidateRoles(userId?: string): void; // no arg = everyone
602
+ function roleCacheEnabled(): boolean;
603
+ function roleCacheStats(): {enabled: boolean; size: number; ttlMs: number | null};
604
+ ```
605
+
606
+ `@CloudFunction({requireRoles})` and `getUserRoles()` each cost a database
607
+ round-trip per call. Role membership rarely changes, so it caches well — but a
608
+ **revoked role keeps working until the entry expires**, which is why nothing
609
+ happens until you opt in:
610
+
611
+ ```ts
612
+ configureRoleCache({ttlMs: 30_000}); // global
613
+ @CloudFunction({requireRoles: [...], roleCacheMs: 0}) // this endpoint never caches
614
+ ```
615
+
616
+ Call `invalidateRoles(userId)` wherever you grant or revoke, and the TTL becomes
617
+ a backstop for changes made outside your code rather than the primary control.
618
+ `maxUsers` (default 10000) bounds memory; the oldest entry is evicted past it.
619
+ There is no timer — entries expire on read.
620
+
621
+ ---
622
+
623
+ ## Constants
624
+
625
+ ```ts
626
+ enum UserRoles {ADMIN = 'SuperAdmin', EMPLOYEE = 'Employee'}
627
+ function roleKey(role: UserRoles): `role:${UserRoles}`;
628
+ const MAX_QUERY_LIMIT = 10000;
629
+ ```
630
+
631
+ Use `roleKey(UserRoles.ADMIN)` for CLP/ACL keys — never hardcode
632
+ `'role:SuperAdmin'`.
633
+
634
+ ---
635
+
636
+ ## Types
637
+
638
+ `ClassNameType`, `CLPParamsOption`, `classLevelPermissions`, `ClassAclTemplate`,
639
+ `AclTemplatePermissions`, `ProtectedFields`, `CompoundIndex`, `IndexFieldType`,
640
+ `Fields`, `Indexes`, `SchemaDefinition`, `HttpMethod`, `RouteConfig`,
641
+ `CloudFunctionMetadata`, `SwaggerDocConfig`, `TriggerType`, `TriggerMetadata`,
642
+ `TriggerConfig`, `CronConfig`, `CronJobMetadata`, `RoleString`,
643
+ `ParseClassOptions`, `ParseFieldOptions`, `AllowedFieldType`,
644
+ `SwaggerModelSchema`, `SwaggerPropertySchema`, `SwaggerFunctionSchema`,
645
+ `SchemaConfigOptions`, `TransactionalAdapter`, `VersionedMongoAdapter`,
646
+ `MongoAdapterConstructor`.
647
+
648
+ ```ts
649
+ interface AuthRole {id: string; name: string}
650
+ interface MultiLangs {ar?: string; en?: string}
651
+ interface Filter {key: string; value: string|number|string[];
652
+ type: 'string'|'min'|'max'|'array'|'text'|'dropdown'}
653
+ ```
654
+
655
+ **Registration hooks** (for custom integrations):
656
+ `onClassRegistered`, `onFieldRegistered`, `onFunctionRegistered`.
657
+ Also exported: `getSchemaDefinition(target)`, `classNames`.
658
+
659
+ ---
660
+
661
+ ## Silent failures
662
+
663
+ Every item here produces **no error**. This is the list to check when something
664
+ "doesn't work" but nothing is logged.
665
+
666
+ | Symptom | Cause |
667
+ |---|---|
668
+ | Decorators do nothing / odd behaviour | `experimentalDecorators` not set — TS 5 defaults to standard decorators |
669
+ | Transaction never opens | `@Transactional()` placed **above** `@CloudFunction` instead of below |
670
+ | Writes land outside the transaction | parse-server `directAccess` not `true` |
671
+ | No models, empty schema, no routes | `importFiles` ran over `.ts` sources with the default `['.js']` |
672
+ | Every class registered twice | `importFiles` pointed at a tree with both compiled and source copies |
673
+ | Trigger never fires | Declared on a class without `@ParseClass` — pending triggers are never flushed |
674
+ | Unique indexes vanish after restart | `keepUnknownIndexes` false, or parse-server < 8.3 |
675
+ | Pointer field ignored by `fromParams` | `targetClass` missing, or it is `IMG`/`File` (excluded by design) |
676
+ | Array of pointers stays raw | Array field has no `targetClass` |
677
+ | Direct property access unavailable | Query result not cast to the typed model |
678
+ | Cron jobs never run | `node-cron` not installed — `initialize()` warns and skips |
679
+ | Object not protected by version field | Built from a bare id, never read, so there is nothing to assert |
680
+
681
+ Three of these now **do** log as of v2.8.0: `@ParseVersionField` with no adapter
682
+ (or on Postgres), and a Parse SDK where `_getSaveJSON` has moved. Check the boot
683
+ output for `[Versioning]`.
684
+
685
+ ---
686
+
687
+ ## Version matrix
688
+
689
+ | | Requirement |
690
+ |---|---|
691
+ | Node | ≥ 18 for this package; **≥ 20.19** with parse-server 9 |
692
+ | parse-server | ≥ 8.3 (optional peer) — `keepUnknownIndexes`, adapter internals |
693
+ | MongoDB | ≥ 7.0.16 for parse-server 9; **replica set** for transactions |
694
+ | Parse SDK | ≥ 5; verified on 5.3.0 and 8.6.0 |
695
+ | TypeScript | any, with `experimentalDecorators: true` |
696
+
697
+ **Transactions and `@ParseVersionField` are MongoDB-only.** On Postgres the
698
+ version field is declared but never enforced.