Service Execution Pipeline
Purpose: Deep dive into how Webspresso services validate input, enforce security guards, propagate database transactions, and execute business logic.
1. Pipeline Overview #
When ctx.service('order.create', input) or req.service('order.create', input) is invoked, execution flows through a hardened, sequential 6-stage pipeline:
View diagram source
graph TD
Call["serviceRegistry.call(name, input, ctx)"] --> S1["1. Prototype Poisoning Defense (sanitizeInput)"]
S1 --> S2["2. Zod Schema Compilation & Validation"]
S2 --> S3["3. Authorization & RBAC Guard (checkServiceAuth)"]
S3 --> S4{"4. Memoization / Cache Check"}
S4 -->|Cache Hit| ReturnCache["Return Cached Result"]
S4 -->|Cache Miss / Bypass| S5{"5. Transaction Propagation (transaction: true)"}
S5 -->|Active Trx Needed| S5A["Wrap in runWithAmbientTransaction(trx)"]
S5 -->|Standard| S6["6. Handler Execution (handler(input, ctx))"]
S5A --> S6
S6 --> S7["Timeout Race (opts.timeout || def.timeout)"]
S7 --> CacheSave["Set Cache (if def.cache configured)"]
CacheSave --> Result["Return Validated Result"]
2. Pipeline Stages in Detail #
Stage 1: Prototype Pollution Defense #
Before schema evaluation, the input payload passes through core/validation/index.js: sanitizeInput().
Any object containing dangerous prototype poisoning keys (__proto__, constructor, prototype) is recursively sanitized to prevent prototype pollution attacks.
Stage 2: Declarative Zod Validation #
The service definition schema can be specified in three flexible formats:
- Zod Schema:
schema: z.object({ email: z.string().email(), amount: z.number().positive() }) - Functional Compiler:
schema: ({ z }) => z.object({ id: z.nanoid(), name: z.string() }) - Type Descriptor Map:
schema: { email: 'email', name: 'string', age: 'number?' }
If validation fails, a structured ValidationError is thrown with an HTTP 422 mapping and detailed field-by-field error messages.
Stage 3: Declarative Authorization & RBAC #
The auth property in defineService enforces declarative access control:
auth: true— Requiresctx.userorctx.req.userto be authenticated.auth: 'admin'/auth: ['admin', 'manager']— Requires the user to have one of the specified roles (user.roleoruser.roles).auth: (user, ctx) => boolean | Promise<boolean>— Custom predicate function for dynamic authorization logic.
If authorization fails, a SecurityError (mapped to HTTP 403 Forbidden) is thrown immediately before executing the service handler.
Stage 4: Service-Level Memoization & Caching #
If cache is configured in defineService:
cache: {
ttl: '5m', // Cache for 5 minutes
key: (input) => `user-profile:${input.userId}` // Dynamic cache key
}
The pipeline checks the in-memory/provider cache. On a cache hit, the cached result is returned immediately without invoking the handler or creating a database transaction.
Stage 5: Ambient Transaction Wrapping (transaction: true)
#
When a service declares transaction: true:
- The pipeline checks if an ambient database transaction is already running in the current
AsyncLocalStoragescope. - If an active transaction exists (e.g. from an outer caller), the service seamlessly participates in the outer transaction without nested commit conflicts.
- If no active transaction exists, the pipeline opens a new Knex transaction and wraps execution in
runWithAmbientTransaction(trx, callback). - If the service handler throws an unhandled error, the transaction automatically rolls back. On clean completion, it commits.
Stage 6: Timeout Enforcement #
Services can specify a maximum execution duration (e.g. timeout: 5000 or timeout: '5s').
The handler execution races against a timer. If the duration is exceeded, a TimeoutError is thrown, aborting the pending promise and triggering a transaction rollback.