Skip to content

Commit 11b7293

Browse files
committed
docs: add Cloud Code Adapter design specification
Defines the architecture for a next-generation Parse.Cloud system that replaces triggers.js with a CloudCodeManager, supports BYO adapters, and enables multi-language cloud code via three built-in adapter types (Legacy, InProcess, ExternalProcess).
1 parent 6476c57 commit 11b7293

1 file changed

Lines changed: 336 additions & 0 deletions

File tree

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
# Cloud Code Adapter — Design Specification
2+
3+
**Status:** Approved
4+
**Target:** Parse Server 10.x
5+
**Date:** 2026-03-16
6+
**Related:** [ParseCloud/1.0 Protocol](../../../parse-lite-sdks/docs/cloud-code-protocol.md), [Adapter Proposal](../../../parse-lite-sdks/docs/cloud-code-adapter-proposal.md)
7+
8+
---
9+
10+
## 1. Problem Statement
11+
12+
Parse Server's cloud code system (`Parse.Cloud.define`, `Parse.Cloud.beforeSave`, etc.) has fundamental limitations:
13+
14+
1. **JavaScript only** — No support for cloud code in Swift, C#, Go, or other languages.
15+
2. **Global singleton** — All cloud code shares `Parse.Cloud` namespace. No composition, difficult testing.
16+
3. **In-process only** — No supported mechanism for cloud code as a separate process or service.
17+
4. **No adapter pattern** — Hard-wired implementation with no pluggable interface.
18+
5. **Manual webhook registration** — External webhooks require manual REST API calls.
19+
20+
## 2. Design Decisions
21+
22+
| Decision | Choice | Rationale |
23+
|----------|--------|-----------|
24+
| Adapter composition | Multiple adapters coexist | Users can run legacy JS + external Swift + custom adapters simultaneously |
25+
| Hook conflicts | Error on conflict at startup | Fail fast, no ambiguity about which adapter handles a hook |
26+
| Hot reload | Startup-only for v1 | Simpler implementation; can be added later |
27+
| Registry API | Adapters only (no public registry) | Clean boundary, single integration point |
28+
| Implementation location | In parse-server directly | Core server functionality |
29+
| Webhook key | Explicitly configured (required) | No auto-generation, no persistence question |
30+
| Language | TypeScript | Type safety throughout |
31+
| Architecture | Replace triggers.js entirely | CloudCodeManager becomes single source of truth |
32+
33+
## 3. Architecture
34+
35+
### 3.1 CloudCodeManager — The New Core
36+
37+
`CloudCodeManager` replaces `triggers.js` as the single source of truth for all hook registration, lookup, and execution.
38+
39+
```typescript
40+
class CloudCodeManager {
41+
private adapters: Map<string, CloudCodeAdapter>;
42+
private store: HookStore;
43+
44+
// Lifecycle
45+
async initialize(adapterConfigs: AdapterConfig[], serverConfig: ParseServerConfig): Promise<void>;
46+
async shutdown(): Promise<void>;
47+
async healthCheck(): Promise<Map<string, boolean>>;
48+
49+
// Registration (called by adapters via CloudCodeRegistry)
50+
defineFunction(source: string, name: string, handler: CloudFunctionHandler, validator?: ValidatorHandler): void;
51+
defineTrigger(source: string, className: string, triggerName: TriggerName, handler: CloudTriggerHandler, validator?: ValidatorHandler): void;
52+
defineJob(source: string, name: string, handler: CloudJobHandler): void;
53+
unregisterAll(source: string): void;
54+
55+
// Lookup (consumed by routers, rest of Parse Server)
56+
getFunction(name: string, applicationId: string): CloudFunctionHandler | undefined;
57+
getTrigger(className: string, triggerType: string, applicationId: string): CloudTriggerHandler | undefined;
58+
getJob(name: string, applicationId: string): CloudJobHandler | undefined;
59+
getFunctionNames(applicationId: string): string[];
60+
getValidator(functionName: string, applicationId: string): ValidatorHandler | undefined;
61+
62+
// Execution (replaces maybeRunTrigger, maybeRunValidator)
63+
async runTrigger(triggerType: string, auth: Auth, parseObject: ParseObject, ...): Promise<any>;
64+
async runValidator(request: any, functionName: string, auth: Auth): Promise<void>;
65+
}
66+
```
67+
68+
### 3.2 HookStore
69+
70+
Typed internal structure replacing `Object.create(null)` pattern:
71+
72+
```typescript
73+
interface HookStore {
74+
functions: Map<string, { handler: CloudFunctionHandler; source: string; validator?: ValidatorHandler }>;
75+
triggers: Map<string, { handler: CloudTriggerHandler; source: string; validator?: ValidatorHandler }>;
76+
// key format: `${triggerType}.${className}`
77+
jobs: Map<string, { handler: CloudJobHandler; source: string }>;
78+
liveQueryHandlers: Array<{ handler: LiveQueryHandler; source: string }>;
79+
}
80+
```
81+
82+
### 3.3 CloudCodeAdapter Interface
83+
84+
```typescript
85+
interface CloudCodeAdapter {
86+
/** Unique identifier for this adapter instance */
87+
readonly name: string;
88+
89+
/** Register all hooks with the registry. Called once at startup. */
90+
initialize(registry: CloudCodeRegistry, config: ParseServerConfig): Promise<void>;
91+
92+
/** Return true if adapter is healthy and ready. */
93+
isHealthy(): Promise<boolean>;
94+
95+
/** Clean up resources. Called during Parse Server shutdown. */
96+
shutdown(): Promise<void>;
97+
}
98+
```
99+
100+
### 3.4 CloudCodeRegistry
101+
102+
Scoped per-adapter. Created by `CloudCodeManager` with the adapter's `name` bound as `source`:
103+
104+
```typescript
105+
interface CloudCodeRegistry {
106+
defineFunction(name: string, handler: CloudFunctionHandler, validator?: ValidatorHandler): void;
107+
defineTrigger(className: string, triggerName: TriggerName, handler: CloudTriggerHandler, validator?: ValidatorHandler): void;
108+
defineJob(name: string, handler: CloudJobHandler): void;
109+
}
110+
111+
type TriggerName =
112+
| 'beforeSave' | 'afterSave'
113+
| 'beforeDelete' | 'afterDelete'
114+
| 'beforeFind' | 'afterFind'
115+
| 'beforeLogin' | 'afterLogin' | 'afterLogout'
116+
| 'beforeConnect' | 'beforeSubscribe' | 'afterEvent'
117+
| 'beforeSaveFile' | 'afterSaveFile'
118+
| 'beforeDeleteFile' | 'afterDeleteFile';
119+
```
120+
121+
## 4. Built-in Adapter Implementations
122+
123+
### 4.1 LegacyAdapter
124+
125+
Wraps `cloud: './main.js'` or `cloud: (parse) => {}`. Zero breaking changes.
126+
127+
- `initialize()` temporarily patches `Parse.Cloud.*` methods to route through the registry, loads the user's cloud code file, then restores originals.
128+
- `isHealthy()` always returns `true` (in-process).
129+
- `shutdown()` is a no-op.
130+
131+
### 4.2 InProcessAdapter
132+
133+
Wraps `cloud: cloudInstance` where `cloudInstance` has a `getRouter()` method (duck-typed).
134+
135+
- `initialize()` calls `getRouter().getManifest()`, creates bridge handlers for each hook that convert Parse Server requests to webhook body format and call `dispatchFunction`/`dispatchTrigger`/`dispatchJob`.
136+
- `isHealthy()` always returns `true` (in-process).
137+
- `shutdown()` is a no-op.
138+
139+
**Duck-typed interface:**
140+
141+
```typescript
142+
interface InProcessCloudCode {
143+
getRouter(): {
144+
getManifest(): CloudManifest;
145+
dispatchFunction(name: string, body: Record<string, unknown>): Promise<WebhookResponse>;
146+
dispatchTrigger(className: string, triggerName: string, body: Record<string, unknown>): Promise<WebhookResponse>;
147+
dispatchJob(name: string, body: Record<string, unknown>): Promise<WebhookResponse>;
148+
};
149+
}
150+
151+
interface CloudManifest {
152+
protocol: string;
153+
hooks: {
154+
functions: Array<{ name: string }>;
155+
triggers: Array<{ className: string; triggerName: string }>;
156+
jobs: Array<{ name: string }>;
157+
};
158+
}
159+
160+
type WebhookResponse =
161+
| { success: unknown }
162+
| { error: { code: number; message: string } };
163+
```
164+
165+
### 4.3 ExternalProcessAdapter
166+
167+
Wraps `cloudCodeCommand: 'swift run CloudCode'`.
168+
169+
- `initialize()` spawns child process with environment variables, waits for `PARSE_CLOUD_READY:<port>` on stdout, fetches manifest via `GET http://localhost:<port>/`, registers bridge handlers.
170+
- `isHealthy()` calls `GET http://localhost:<port>/health`.
171+
- `shutdown()` sends `SIGTERM`, waits `shutdownTimeout`, then `SIGKILL`.
172+
- Crash recovery: unregisters hooks, restarts with exponential backoff (1s, 2s, 4s, 8s, capped at `maxRestartDelay`).
173+
174+
**Environment variables passed to child process:**
175+
176+
| Variable | Source |
177+
|----------|--------|
178+
| `PARSE_SERVER_URL` | Parse Server's own URL |
179+
| `PARSE_APPLICATION_ID` | `appId` from config |
180+
| `PARSE_MASTER_KEY` | `masterKey` from config |
181+
| `PARSE_WEBHOOK_KEY` | `webhookKey` from config (required) |
182+
| `PARSE_CLOUD_PORT` | `0` (OS-assigned) |
183+
184+
## 5. Configuration
185+
186+
### 5.1 ParseServerOptions Extension
187+
188+
```typescript
189+
interface ParseServerOptions {
190+
// Existing (unchanged, routes through LegacyAdapter):
191+
cloud?: string | ((parse: any) => void) | InProcessCloudCode;
192+
193+
// New — external process:
194+
cloudCodeCommand?: string;
195+
webhookKey?: string; // Required when cloudCodeCommand is set
196+
cloudCodeOptions?: {
197+
startupTimeout?: number; // default 30000ms
198+
healthCheckInterval?: number; // default 30000ms
199+
shutdownTimeout?: number; // default 5000ms
200+
maxRestartDelay?: number; // default 30000ms
201+
};
202+
203+
// New — explicit BYO adapter(s):
204+
cloudCodeAdapters?: CloudCodeAdapter[];
205+
}
206+
```
207+
208+
### 5.2 Resolution Order
209+
210+
All sources compose. Any hook collision throws at startup.
211+
212+
```typescript
213+
function resolveAdapters(options: ParseServerOptions): CloudCodeAdapter[] {
214+
const adapters: CloudCodeAdapter[] = [];
215+
216+
if (options.cloudCodeAdapters) {
217+
adapters.push(...options.cloudCodeAdapters);
218+
}
219+
220+
if (options.cloud) {
221+
if (typeof options.cloud === 'object' && typeof options.cloud.getRouter === 'function') {
222+
adapters.push(new InProcessAdapter(options.cloud));
223+
} else {
224+
adapters.push(new LegacyAdapter(options.cloud));
225+
}
226+
}
227+
228+
if (options.cloudCodeCommand) {
229+
if (!options.webhookKey) {
230+
throw new Error('webhookKey is required when using cloudCodeCommand');
231+
}
232+
adapters.push(new ExternalProcessAdapter(
233+
options.cloudCodeCommand,
234+
options.webhookKey,
235+
options.cloudCodeOptions
236+
));
237+
}
238+
239+
return adapters;
240+
}
241+
```
242+
243+
### 5.3 Startup Sequence
244+
245+
1. `ParseServer` constructor
246+
2. `resolveAdapters(options)``CloudCodeAdapter[]`
247+
3. `CloudCodeManager.initialize(adapters, config)`
248+
- For each adapter: create scoped `CloudCodeRegistry`, call `adapter.initialize(registry, config)`
249+
- Registry calls flow into `HookStore` with conflict checks
250+
4. If any conflict → throw, server does not start
251+
5. All routers use `CloudCodeManager` for lookups
252+
253+
### 5.4 Conflict Error Format
254+
255+
```
256+
"Cloud code conflict: beforeSave on 'Todo' registered by both 'legacy' and 'external-process'"
257+
```
258+
259+
## 6. Migration Strategy — Replacing triggers.js
260+
261+
### 6.1 Current Consumers
262+
263+
| Consumer | triggers.js Usage | Migration |
264+
|----------|-------------------|-----------|
265+
| `Parse.Cloud.js` | `addFunction`, `addTrigger`, `addJob`, `addConnectTrigger`, `addLiveQueryEventHandler` | LegacyAdapter delegates to `CloudCodeRegistry` |
266+
| `FunctionsRouter.js` | `getFunction`, `getJob`, `getFunctionNames`, `maybeRunValidator` | Import from `CloudCodeManager` |
267+
| `CloudCodeRouter.js` | `getJob` (scheduled jobs) | Import from `CloudCodeManager` |
268+
| `RestWrite.js` | `getTrigger`, `maybeRunTrigger`, `getRequestObject` | Import from `CloudCodeManager` |
269+
| `RestQuery.js` | `getTrigger`, `maybeRunTrigger` | Import from `CloudCodeManager` |
270+
| `UsersRouter.js` | `getTrigger` (login/logout) | Import from `CloudCodeManager` |
271+
| `FilesRouter.js` | `getTrigger` (file triggers) | Import from `CloudCodeManager` |
272+
| `LiveQuery/` | `getTrigger`, `maybeRunTrigger`, connect/subscribe | Import from `CloudCodeManager` |
273+
| `Config.js` | Validates cloud config | Updated for new options |
274+
275+
### 6.2 Migration Approach
276+
277+
1. **`triggers.ts` becomes a thin re-export facade** — all exports delegate to `CloudCodeManager` on the current app's `Config`. Existing import sites work without immediate changes.
278+
2. **Incremental consumer migration** — update consumers one file at a time from `triggers.*` to `config.cloud.*` (the `CloudCodeManager` instance on `Config`).
279+
3. **Facade removal** — once all consumers are migrated, delete `triggers.ts`.
280+
281+
### 6.3 Parse.Cloud.js Transformation
282+
283+
`LegacyAdapter` temporarily patches `Parse.Cloud.*` during `initialize()`:
284+
285+
```typescript
286+
class LegacyAdapter implements CloudCodeAdapter {
287+
readonly name = 'legacy';
288+
289+
async initialize(registry: CloudCodeRegistry, config: ParseServerConfig): Promise<void> {
290+
const originalDefine = Parse.Cloud.define;
291+
Parse.Cloud.define = (name, handler, validator) => {
292+
registry.defineFunction(name, handler, validator);
293+
};
294+
// ... same for beforeSave, afterSave, etc.
295+
296+
if (typeof this.cloud === 'string') {
297+
require(this.cloud);
298+
} else if (typeof this.cloud === 'function') {
299+
this.cloud(Parse);
300+
}
301+
302+
Parse.Cloud.define = originalDefine;
303+
// ...
304+
}
305+
}
306+
```
307+
308+
### 6.4 Utility Functions
309+
310+
Pure data transformation helpers from `triggers.js` (`getRequestObject()`, `getResponseObject()`, `resolveError()`, `toJSONwithObjects()`) move to `src/cloud-code/request-utils.ts`. They have no dependency on the hook store.
311+
312+
## 7. Request/Response Bridge
313+
314+
For `InProcessAdapter` and `ExternalProcessAdapter`, a bridge converts between Parse Server's internal request objects and the webhook body format.
315+
316+
### Parse Request → Webhook Body
317+
318+
Converts `Parse.Object` instances to JSON, maps all trigger-specific fields (object, original, query, file, context, etc.).
319+
320+
### Webhook Response → Parse Result
321+
322+
- `{ success: <value> }` → return value
323+
- `{ error: { code, message } }` → throw `Parse.Error`
324+
325+
### beforeSave Special Case
326+
327+
- Empty object `{}` → accept original (no changes)
328+
- Object with fields → apply field changes to `request.object`
329+
- Error → reject save
330+
331+
## 8. Non-Goals (v1)
332+
333+
- **Hot reload** — hooks registered once at startup
334+
- **Public CloudCodeRegistry API** — all registration through adapters
335+
- **Multi-process orchestration** — one external process per adapter
336+
- **Auto-generated webhook key** — must be explicitly configured

0 commit comments

Comments
 (0)