@90soft/parse-server-kit 2.9.0 → 2.11.0

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 (47) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/CLAUDE.md +734 -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 +23 -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/middleware/rateLimit.d.ts +25 -6
  35. package/dist/middleware/rateLimit.d.ts.map +1 -1
  36. package/dist/middleware/rateLimit.js +59 -13
  37. package/dist/middleware/rateLimit.js.map +1 -1
  38. package/dist/models/BaseModel.d.ts.map +1 -1
  39. package/dist/models/BaseModel.js +5 -2
  40. package/dist/models/BaseModel.js.map +1 -1
  41. package/dist/utils/dynamicImport.d.ts.map +1 -1
  42. package/dist/utils/dynamicImport.js +33 -11
  43. package/dist/utils/dynamicImport.js.map +1 -1
  44. package/dist/utils/roleCache.d.ts.map +1 -1
  45. package/dist/utils/roleCache.js +4 -1
  46. package/dist/utils/roleCache.js.map +1 -1
  47. package/package.json +8 -7
package/CLAUDE.md CHANGED
@@ -1,698 +1,734 @@
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; keyBy?: (req) => string}; // keyed on client ADDRESS
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 keyed on client address; 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).
461
+
462
+ ### Rate limiting — read before relying on it
463
+
464
+ The bucket key is the **client address** (`req.ip`, falling back to the socket
465
+ address). Pass `keyBy` only if the app can derive an identity it actually trusts.
466
+
467
+ > Before 2.11.0 the key was `x-parse-session-token || req.ip`. That header is
468
+ > caller-supplied and unvalidated here — this middleware runs **before** Parse
469
+ > resolves the session — so a rotating random token minted a fresh bucket per
470
+ > request and the limit did nothing. Ten requests passed a limit of three.
471
+
472
+ Three constraints:
473
+
474
+ 1. **`trust proxy` must be exact** — a hop count or the proxy's address — when
475
+ behind nginx or a load balancer. `req.ip` honours it, so a permissive `true`
476
+ lets a caller spoof the key via `X-Forwarded-For` (the bypass Parse Server
477
+ fixed for its own limiter in 9.10.1). Unset is safe: Express ignores the
478
+ header.
479
+ 2. **Per process.** With N instances the effective limit is N × `max`. For
480
+ cross-instance enforcement use Parse Server's `rateLimit` option with
481
+ `redisUrl`.
482
+ 3. **Per address, not per user.** Users behind one NAT share a bucket.
483
+
484
+ The store is bounded at 10,000 clients per function, oldest evicted, so a caller
485
+ rotating addresses cannot grow it without limit.
486
+
487
+ ---
488
+
489
+ ## Indexes
490
+
491
+ ```ts
492
+ function applyAllIndexes(parseServerInstance?: any): Promise<void>;
493
+ const applyUniqueIndexes = applyAllIndexes; // permanent alias
494
+ function getUniqueIndexes(): UniqueIndexInfo[];
495
+ function getCompoundIndexes(): CompoundIndexInfo[];
496
+ function getFieldIndexes(): FieldIndexInfo[];
497
+ ```
498
+
499
+ Call after `listen()`. Creates, from decorator metadata:
500
+
501
+ | Source | Index |
502
+ |---|---|
503
+ | `@ParseField({unique: true})` | unique (drops a conflicting non-unique index first) |
504
+ | `@ParseField({index: true \| 1 \| -1})` | B-tree asc/desc |
505
+ | `@ParseField({geo: true})` on GeoPoint | 2dsphere |
506
+ | `@ParseField({ttlSeconds: N})` on Date | TTL |
507
+ | `@ParseClass({compoundIndexes})` | compound |
508
+
509
+ ```ts
510
+ interface CompoundIndex {
511
+ fields: string[];
512
+ unique?: boolean;
513
+ name?: string;
514
+ sparse?: boolean;
515
+ partialFilterNulls?: boolean; // only index docs where all fields EXIST
516
+ // For a VALUE predicate (e.g. one active row per pair) use the options escape hatch:
517
+ // options: {partialFilterExpression: {status: 'active'}} -> passed straight to MongoDB
518
+ fieldTypes?: Record<string, 1|-1|'text'|'2dsphere'|'hashed'>;
519
+ options?: Record<string, unknown>; // e.g. default_language
520
+ }
521
+ ```
522
+
523
+ Existing indexes (codes 85/86) are reported, not treated as errors. If the
524
+ adapter can't be reached it prints the equivalent `db.x.createIndex(...)`
525
+ commands. MongoDB allows only **one text index per collection**.
526
+
527
+ ---
528
+
529
+ ## Schema
530
+
531
+ ```ts
532
+ function createSchemaConfig(options?: SchemaConfigOptions): {...};
533
+
534
+ interface SchemaConfigOptions {
535
+ adminRole?: string; // default 'SuperAdmin'
536
+ lockSchemas?: boolean; // default false
537
+ strict?: boolean; // default true
538
+ recreateModifiedFields?: boolean; // default false — DANGEROUS
539
+ deleteExtraFields?: boolean; // default false — DANGEROUS
540
+ keepUnknownIndexes?: boolean; // default TRUE — parse-server 8.3+
541
+ }
542
+ ```
543
+
544
+ Builds the `schema` option from every registered `@ParseClass`, plus a `_Role`
545
+ definition locked to `adminRole`.
546
+
547
+ `keepUnknownIndexes` defaults to **true** because `applyAllIndexes` creates
548
+ indexes out-of-band; parse-server's default (`false`) drops indexes it cannot
549
+ account for during schema migration. **Requires parse-server 8.3+** — it refuses
550
+ to start on an unrecognised option.
551
+
552
+ ---
553
+
554
+ ## Validation
555
+
556
+ ```ts
557
+ function validateObject(object: Parse.Object): {valid: boolean; errors: {field, message, value?}[]};
558
+ function validateOrThrow(object: Parse.Object): void; // throws Parse.Error VALIDATION_ERROR
559
+ function getValidators(): {className, validator}[];
560
+ function applyMongoValidators(parseServerInstance?: any): Promise<void>;
561
+ ```
562
+
563
+ Checks `required`, `min`/`max`, `minLength`/`maxLength`, `enum`, `pattern` from
564
+ `@ParseField`. `validateOrThrow` is intended for `@BeforeSave`.
565
+ `applyMongoValidators` pushes the same constraints into MongoDB `$jsonSchema`
566
+ validators (`validationLevel: 'moderate'`, `validationAction: 'error'`), so the
567
+ database enforces them even for writes that bypass your code.
568
+
569
+ ---
570
+
571
+ ## Swagger
572
+
573
+ ```ts
574
+ function setupSwagger(app: Express, config: SwaggerConfig, path?: string): void; // default '/api-docs'
575
+ function generateSwaggerSpec(config: SwaggerConfig): object;
576
+ function getSwaggerJson(config: SwaggerConfig): string;
577
+
578
+ interface SwaggerConfig {
579
+ title: string; version: string;
580
+ description?: string; basePath?: string; host?: string; schemes?: string[];
581
+ }
582
+ ```
583
+
584
+ Models and endpoints are registered automatically from `@ParseClass` /
585
+ `@CloudFunction` — no annotation needed. Endpoints document their real `@Route`
586
+ path. **GET/HEAD emit query `parameters`; everything else emits a JSON
587
+ `requestBody`** (a browser cannot send a GET body). Security schemes derive from
588
+ `requireUser` / `requireMaster` / `requireRoles`.
589
+
590
+ The spec is always served at `{path}/json`. Without `swagger-ui-express`
591
+ installed, the browser UI is skipped with a warning; the spec still serves.
592
+
593
+ ---
594
+
595
+ ## Utilities
596
+
597
+ ```ts
598
+ function catchError<T>(p: Promise<T>): Promise<[undefined, T] | [Error]>;
599
+ function getUserRoles(user: Parse.User): Promise<string[]>;
600
+ function getUsersRoles(users: Parse.User[]): Promise<Map<string, string[]>>;
601
+ function importFiles(dir: string, options?: {extensions?: string[]; verbose?: boolean}): void;
602
+ function generateRandomPassword(length?, includeNumbers?, includeSymbols?): string;
603
+ function generateRandomString(length?: number): string;
604
+ function generateRandomInteger(length: number): string;
605
+ function sleep(ms: number): Promise<unknown>;
606
+ function formatCount(num: any, locale?: string): string; // compact notation
607
+ ```
608
+
609
+ **`catchError` is the error convention** — use it for all async work rather than
610
+ `try/catch` around `await`:
611
+
612
+ ```ts
613
+ const [err, saved] = await catchError(product.save(null, {sessionToken}));
614
+ if (err) throw err;
615
+ ```
616
+
617
+ **`importFiles` defaults to `.js` only.** Under ts-node/tsx pass
618
+ `{extensions: ['.js', '.ts']}` or nothing is imported at all. Never point it at
619
+ a tree holding both compiled and source copies — every class registers twice.
620
+
621
+ `getUsersRoles` costs one query per **role** (capped at 100), not per user.
622
+
623
+ ### Role cache (opt-in, off by default)
624
+
625
+ ```ts
626
+ function configureRoleCache(config: {ttlMs: number; maxUsers?: number} | false): void;
627
+ function invalidateRoles(userId?: string): void; // no arg = everyone
628
+ function roleCacheEnabled(): boolean;
629
+ function roleCacheStats(): {enabled: boolean; size: number; ttlMs: number | null};
630
+ ```
631
+
632
+ `@CloudFunction({requireRoles})` and `getUserRoles()` each cost a database
633
+ round-trip per call. Role membership rarely changes, so it caches well — but a
634
+ **revoked role keeps working until the entry expires**, which is why nothing
635
+ happens until you opt in:
636
+
637
+ ```ts
638
+ configureRoleCache({ttlMs: 30_000}); // global
639
+ @CloudFunction({requireRoles: [...], roleCacheMs: 0}) // this endpoint never caches
640
+ ```
641
+
642
+ Call `invalidateRoles(userId)` wherever you grant or revoke, and the TTL becomes
643
+ a backstop for changes made outside your code rather than the primary control.
644
+ `maxUsers` (default 10000) bounds memory; the oldest entry is evicted past it.
645
+ There is no timer — entries expire on read.
646
+
647
+ ---
648
+
649
+ ## Constants
650
+
651
+ ```ts
652
+ enum UserRoles {ADMIN = 'SuperAdmin', EMPLOYEE = 'Employee'}
653
+ function roleKey(role: UserRoles): `role:${UserRoles}`;
654
+ const MAX_QUERY_LIMIT = 10000;
655
+ ```
656
+
657
+ Use `roleKey(UserRoles.ADMIN)` for CLP/ACL keys — never hardcode
658
+ `'role:SuperAdmin'`.
659
+
660
+ ---
661
+
662
+ ## Types
663
+
664
+ `ClassNameType`, `CLPParamsOption`, `classLevelPermissions`, `ClassAclTemplate`,
665
+ `AclTemplatePermissions`, `ProtectedFields`, `CompoundIndex`, `IndexFieldType`,
666
+ `Fields`, `Indexes`, `SchemaDefinition`, `HttpMethod`, `RouteConfig`,
667
+ `CloudFunctionMetadata`, `SwaggerDocConfig`, `TriggerType`, `TriggerMetadata`,
668
+ `TriggerConfig`, `CronConfig`, `CronJobMetadata`, `RoleString`,
669
+ `ParseClassOptions`, `ParseFieldOptions`, `AllowedFieldType`,
670
+ `SwaggerModelSchema`, `SwaggerPropertySchema`, `SwaggerFunctionSchema`,
671
+ `SchemaConfigOptions`, `TransactionalAdapter`, `VersionedMongoAdapter`,
672
+ `MongoAdapterConstructor`.
673
+
674
+ ```ts
675
+ interface AuthRole {id: string; name: string}
676
+ interface MultiLangs {ar?: string; en?: string}
677
+ interface Filter {key: string; value: string|number|string[];
678
+ type: 'string'|'min'|'max'|'array'|'text'|'dropdown'}
679
+ ```
680
+
681
+ **Registration hooks** (for custom integrations):
682
+ `onClassRegistered`, `onFieldRegistered`, `onFunctionRegistered`.
683
+ Also exported: `getSchemaDefinition(target)`, `classNames`.
684
+
685
+ ---
686
+
687
+ ## Silent failures
688
+
689
+ Every item here produces **no error**. This is the list to check when something
690
+ "doesn't work" but nothing is logged.
691
+
692
+ | Symptom | Cause |
693
+ |---|---|
694
+ | Decorators do nothing / odd behaviour | `experimentalDecorators` not set — TS 5 defaults to standard decorators |
695
+ | Transaction never opens | `@Transactional()` placed **above** `@CloudFunction` instead of below |
696
+ | Writes land outside the transaction | parse-server `directAccess` not `true` |
697
+ | No models, empty schema, no routes | `importFiles` ran over `.ts` sources with the default `['.js']` |
698
+ | Every class registered twice | `importFiles` pointed at a tree with both compiled and source copies |
699
+ | Trigger never fires | Declared on a class without `@ParseClass` — pending triggers are never flushed |
700
+ | Unique indexes vanish after restart | `keepUnknownIndexes` false, or parse-server < 8.3 |
701
+ | Pointer field ignored by `fromParams` | `targetClass` missing, or it is `IMG`/`File` (excluded by design) |
702
+ | Array of pointers stays raw | Array field has no `targetClass` |
703
+ | Direct property access unavailable | Query result not cast to the typed model |
704
+ | Cron jobs never run | `node-cron` not installed — `initialize()` warns and skips |
705
+ | Object not protected by version field | Built from a bare id, never read, so there is nothing to assert |
706
+
707
+ Three of these now **do** log as of v2.8.0: `@ParseVersionField` with no adapter
708
+ (or on Postgres), and a Parse SDK where `_getSaveJSON` has moved. Check the boot
709
+ output for `[Versioning]`.
710
+
711
+ ---
712
+
713
+ ## Version matrix
714
+
715
+ | | Requirement |
716
+ |---|---|
717
+ | Node | **≥ 20.19** |
718
+ | parse-server | ≥ 9.0 (optional peer); **≥ 9.10.1 strongly recommended** — see below. ≥ 9.10.0 is required on MongoDB 8.3+ (GeoPoint distance queries 500 otherwise) |
719
+ | MongoDB | ≥ 7.0.16 for parse-server 9; **replica set** for transactions |
720
+ | Parse SDK | ≥ 8; developed and tested on 8.6.2 |
721
+ | TypeScript | any, with `experimentalDecorators: true` |
722
+
723
+ **Why ≥ 9.10.1 rather than merely 9.x:** the 9.5→9.10 releases are almost
724
+ entirely security fixes — account takeover through auth-adapter identity
725
+ spoofing, protected-field bypasses via logical operators, dot-notation, relation
726
+ counts and LiveQuery, multiple rate-limit bypasses, and several stored-XSS routes
727
+ through file upload. An older 9.x is not "stable", it is unpatched.
728
+
729
+ **`@types/parse` is far behind the runtime** (3.0.x against an 8.x SDK), which is
730
+ why reaching newer Parse APIs sometimes needs an `as unknown as` cast. That is
731
+ the types package, not this kit.
732
+
733
+ **Transactions and `@ParseVersionField` are MongoDB-only.** On Postgres the
734
+ version field is declared but never enforced.