@veloxts/router 0.7.9 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/GUIDE.md +30 -48
- package/README.md +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +2 -1
- package/dist/openapi/generator.js +5 -1
- package/dist/openapi/schema-converter.d.ts +11 -0
- package/dist/openapi/schema-converter.js +47 -0
- package/dist/procedure/builder.d.ts +1 -1
- package/dist/procedure/builder.js +383 -53
- package/dist/procedure/pipeline-executor.d.ts +69 -0
- package/dist/procedure/pipeline-executor.js +134 -0
- package/dist/procedure/pipeline.d.ts +98 -0
- package/dist/procedure/pipeline.js +50 -0
- package/dist/procedure/types.d.ts +255 -99
- package/dist/resource/index.d.ts +4 -4
- package/dist/resource/index.js +3 -3
- package/dist/resource/instance.d.ts +1 -1
- package/dist/resource/instance.js +1 -1
- package/dist/resource/levels.d.ts +93 -11
- package/dist/resource/levels.js +78 -3
- package/dist/resource/schema.d.ts +2 -0
- package/dist/resource/schema.js +4 -4
- package/dist/resource/tags.d.ts +5 -7
- package/dist/resource/tags.js +1 -1
- package/dist/trpc/adapter.js +5 -0
- package/dist/types.d.ts +172 -18
- package/package.json +8 -8
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @module procedure/builder
|
|
9
9
|
*/
|
|
10
|
-
import { ConfigurationError, logWarning } from '@veloxts/core';
|
|
10
|
+
import { ConfigurationError, createLogger, ForbiddenError, logWarning, } from '@veloxts/core';
|
|
11
11
|
import { GuardError } from '../errors.js';
|
|
12
12
|
import { createMiddlewareExecutor, executeMiddlewareChain } from '../middleware/chain.js';
|
|
13
|
-
import { isTaggedResourceSchema, Resource, } from '../resource/index.js';
|
|
13
|
+
import { isResourceSchema, isTaggedResourceSchema, Resource, } from '../resource/index.js';
|
|
14
14
|
import { deriveParentParamName } from '../utils/pluralization.js';
|
|
15
15
|
import { analyzeNamingConvention, isDevelopment, normalizeWarningOption, } from '../warnings.js';
|
|
16
|
+
import { executeExternalSteps, executePipeline, splitPipelineSteps } from './pipeline-executor.js';
|
|
17
|
+
const log = createLogger('router');
|
|
16
18
|
// ============================================================================
|
|
17
19
|
// Builder Factory
|
|
18
20
|
// ============================================================================
|
|
@@ -85,24 +87,41 @@ function createBuilder(state) {
|
|
|
85
87
|
});
|
|
86
88
|
},
|
|
87
89
|
/**
|
|
88
|
-
* Sets the output
|
|
90
|
+
* Sets the output schema.
|
|
91
|
+
*
|
|
92
|
+
* Accepts either:
|
|
93
|
+
* - A Zod schema (Level 1) — validates output after handler
|
|
94
|
+
* - A tagged resource view (Level 2) — auto-projects handler result
|
|
95
|
+
* through the tagged level's field visibility
|
|
89
96
|
*/
|
|
90
97
|
output(schema) {
|
|
98
|
+
// Level 2: tagged resource view — set up auto-projection
|
|
99
|
+
if (isTaggedResourceSchema(schema)) {
|
|
100
|
+
return createBuilder({
|
|
101
|
+
...state,
|
|
102
|
+
resourceSchema: schema,
|
|
103
|
+
resourceLevel: schema._level,
|
|
104
|
+
outputSchema: undefined,
|
|
105
|
+
branchingMode: undefined,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
// Level 3: untagged resource schema — enables branching mode
|
|
109
|
+
if (isResourceSchema(schema)) {
|
|
110
|
+
return createBuilder({
|
|
111
|
+
...state,
|
|
112
|
+
resourceSchema: schema,
|
|
113
|
+
resourceLevel: undefined,
|
|
114
|
+
outputSchema: undefined,
|
|
115
|
+
branchingMode: true,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// Level 1: plain Zod schema — validate output
|
|
91
119
|
return createBuilder({
|
|
92
120
|
...state,
|
|
93
121
|
outputSchema: schema,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
* Sets field-level visibility via a resource schema
|
|
98
|
-
*/
|
|
99
|
-
expose(schema) {
|
|
100
|
-
const level = isTaggedResourceSchema(schema) ? schema._level : undefined;
|
|
101
|
-
return createBuilder({
|
|
102
|
-
...state,
|
|
103
|
-
resourceSchema: schema,
|
|
104
|
-
resourceLevel: level,
|
|
105
|
-
outputSchema: undefined,
|
|
122
|
+
resourceSchema: undefined,
|
|
123
|
+
resourceLevel: undefined,
|
|
124
|
+
branchingMode: undefined,
|
|
106
125
|
});
|
|
107
126
|
},
|
|
108
127
|
/**
|
|
@@ -130,27 +149,33 @@ function createBuilder(state) {
|
|
|
130
149
|
});
|
|
131
150
|
},
|
|
132
151
|
/**
|
|
133
|
-
* Adds
|
|
152
|
+
* Adds multiple authorization guards at once
|
|
134
153
|
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
154
|
+
* Convenience method equivalent to chaining multiple `.guard()` calls.
|
|
155
|
+
* Guards execute left-to-right. All must pass for the procedure to execute.
|
|
137
156
|
*/
|
|
138
|
-
|
|
157
|
+
guards(...guardDefs) {
|
|
139
158
|
return createBuilder({
|
|
140
159
|
...state,
|
|
141
|
-
guards: [...state.guards,
|
|
160
|
+
guards: [...state.guards, ...guardDefs],
|
|
142
161
|
});
|
|
143
162
|
},
|
|
144
163
|
/**
|
|
145
|
-
* Adds
|
|
146
|
-
*
|
|
147
|
-
* Convenience method equivalent to chaining multiple `.guard()` calls.
|
|
148
|
-
* Guards execute left-to-right. All must pass for the procedure to execute.
|
|
164
|
+
* Adds a policy action check to the procedure
|
|
149
165
|
*/
|
|
150
|
-
|
|
166
|
+
policy(action) {
|
|
151
167
|
return createBuilder({
|
|
152
168
|
...state,
|
|
153
|
-
|
|
169
|
+
policyAction: action,
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
/**
|
|
173
|
+
* Declares domain error classes this procedure may throw
|
|
174
|
+
*/
|
|
175
|
+
throws(...errorClasses) {
|
|
176
|
+
return createBuilder({
|
|
177
|
+
...state,
|
|
178
|
+
errorClasses: [...(state.errorClasses ?? []), ...errorClasses],
|
|
154
179
|
});
|
|
155
180
|
},
|
|
156
181
|
/**
|
|
@@ -209,31 +234,87 @@ function createBuilder(state) {
|
|
|
209
234
|
});
|
|
210
235
|
},
|
|
211
236
|
/**
|
|
212
|
-
*
|
|
237
|
+
* Declares a domain event to emit after successful handler execution
|
|
213
238
|
*/
|
|
214
|
-
|
|
215
|
-
|
|
239
|
+
emits(eventClass, mapper) {
|
|
240
|
+
// Cast is safe: the typed TEventData and TOutput are erased at runtime.
|
|
241
|
+
// BuilderRuntimeState stores the widened types for uniform handling.
|
|
242
|
+
const entry = { eventClass, mapper };
|
|
243
|
+
return createBuilder({
|
|
244
|
+
...state,
|
|
245
|
+
emittedEvents: [...(state.emittedEvents ?? []), entry],
|
|
246
|
+
});
|
|
216
247
|
},
|
|
217
248
|
/**
|
|
218
|
-
*
|
|
249
|
+
* Wraps the handler in a database transaction
|
|
219
250
|
*/
|
|
220
|
-
|
|
221
|
-
return
|
|
251
|
+
transactional(options) {
|
|
252
|
+
return createBuilder({
|
|
253
|
+
...state,
|
|
254
|
+
transactional: true,
|
|
255
|
+
transactionalOptions: options,
|
|
256
|
+
});
|
|
222
257
|
},
|
|
223
258
|
/**
|
|
224
|
-
*
|
|
259
|
+
* Adds pipeline steps that execute before the handler
|
|
225
260
|
*/
|
|
226
|
-
|
|
227
|
-
const level = isTaggedResourceSchema(schema) ? schema._level : undefined;
|
|
261
|
+
through(...steps) {
|
|
228
262
|
return createBuilder({
|
|
229
263
|
...state,
|
|
230
|
-
|
|
231
|
-
resourceLevel: level,
|
|
232
|
-
outputSchema: undefined,
|
|
264
|
+
pipelineSteps: [...(state.pipelineSteps ?? []), ...steps],
|
|
233
265
|
});
|
|
234
266
|
},
|
|
267
|
+
/**
|
|
268
|
+
* Finalizes as a query procedure
|
|
269
|
+
*
|
|
270
|
+
* In branching mode (Level 3), accepts a handler map keyed by schema level keys.
|
|
271
|
+
*/
|
|
272
|
+
query(handlerOrMap) {
|
|
273
|
+
return compileProcedureOrBranching('query', handlerOrMap, state);
|
|
274
|
+
},
|
|
275
|
+
/**
|
|
276
|
+
* Finalizes as a mutation procedure
|
|
277
|
+
*
|
|
278
|
+
* In branching mode (Level 3), accepts a handler map keyed by schema level keys.
|
|
279
|
+
*/
|
|
280
|
+
mutation(handlerOrMap) {
|
|
281
|
+
return compileProcedureOrBranching('mutation', handlerOrMap, state);
|
|
282
|
+
},
|
|
235
283
|
};
|
|
236
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* Routes to the correct compile strategy based on branching mode
|
|
287
|
+
*
|
|
288
|
+
* In branching mode (Level 3), validates the handler map and synthesizes
|
|
289
|
+
* a dispatch handler. In normal mode, delegates to compileProcedure.
|
|
290
|
+
*
|
|
291
|
+
* @internal
|
|
292
|
+
*/
|
|
293
|
+
function compileProcedureOrBranching(type, handlerOrMap, state) {
|
|
294
|
+
if (state.branchingMode) {
|
|
295
|
+
// Level 3: handler map required
|
|
296
|
+
if (typeof handlerOrMap === 'function') {
|
|
297
|
+
throw new ConfigurationError('Handler map required when .output() receives a resource schema. ' +
|
|
298
|
+
'Use { [Schema.level.key]: handler } syntax.');
|
|
299
|
+
}
|
|
300
|
+
if (state.guards.length > 0) {
|
|
301
|
+
throw new ConfigurationError('Cannot use handler map with .guard(). ' +
|
|
302
|
+
'Guards come from defineAccessLevels() in Level 3 branching mode.');
|
|
303
|
+
}
|
|
304
|
+
// Synthesize a dispatch handler so CompiledProcedure.handler is always defined.
|
|
305
|
+
// The real branch selection happens in executeProcedure.
|
|
306
|
+
const dispatchHandler = async () => {
|
|
307
|
+
throw new ConfigurationError('Level 3 branched procedures must be executed via executeProcedure(). ' +
|
|
308
|
+
'Direct handler invocation is not supported.');
|
|
309
|
+
};
|
|
310
|
+
return compileProcedureWithHandlerMap(type, dispatchHandler, handlerOrMap, state);
|
|
311
|
+
}
|
|
312
|
+
// Not in branching mode: handler map is not allowed
|
|
313
|
+
if (typeof handlerOrMap !== 'function') {
|
|
314
|
+
throw new ConfigurationError('Handler map can only be used when .output() receives an untagged resource schema.');
|
|
315
|
+
}
|
|
316
|
+
return compileProcedure(type, handlerOrMap, state);
|
|
317
|
+
}
|
|
237
318
|
/**
|
|
238
319
|
* Compiles a procedure from the builder state
|
|
239
320
|
*
|
|
@@ -248,8 +329,8 @@ function compileProcedure(type, handler, state) {
|
|
|
248
329
|
// Pre-compile the middleware chain executor if middlewares exist
|
|
249
330
|
// This avoids rebuilding the chain on every request
|
|
250
331
|
const precompiledExecutor = typedMiddlewares.length > 0 ? createMiddlewareExecutor(typedMiddlewares, handler) : undefined;
|
|
251
|
-
// Create the final procedure object
|
|
252
|
-
return {
|
|
332
|
+
// Create the final procedure object with .useAfter() support
|
|
333
|
+
return createPostHandlerBuilder({
|
|
253
334
|
type,
|
|
254
335
|
handler,
|
|
255
336
|
inputSchema: state.inputSchema,
|
|
@@ -268,6 +349,85 @@ function compileProcedure(type, handler, state) {
|
|
|
268
349
|
_resourceSchema: state.resourceSchema,
|
|
269
350
|
// Store explicit resource level from tagged schema (e.g., UserSchema.authenticated)
|
|
270
351
|
_resourceLevel: state.resourceLevel,
|
|
352
|
+
// Store error classes declared via .throws()
|
|
353
|
+
errorClasses: state.errorClasses,
|
|
354
|
+
// Store transactional configuration
|
|
355
|
+
transactional: state.transactional,
|
|
356
|
+
transactionalOptions: state.transactionalOptions,
|
|
357
|
+
// Store emitted events declared via .emits()
|
|
358
|
+
emittedEvents: state.emittedEvents,
|
|
359
|
+
// Store pipeline steps declared via .through()
|
|
360
|
+
pipelineSteps: state.pipelineSteps,
|
|
361
|
+
// Store policy action declared via .policy()
|
|
362
|
+
policyAction: state.policyAction,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Compiles a Level 3 branched procedure with a handler map
|
|
367
|
+
*
|
|
368
|
+
* Creates a CompiledProcedure with both a synthesized dispatch handler
|
|
369
|
+
* (so .handler is always defined) and the _handlerMap for branch selection.
|
|
370
|
+
*
|
|
371
|
+
* @internal
|
|
372
|
+
*/
|
|
373
|
+
function compileProcedureWithHandlerMap(type, dispatchHandler, handlerMap, state) {
|
|
374
|
+
const typedMiddlewares = state.middlewares;
|
|
375
|
+
return createPostHandlerBuilder({
|
|
376
|
+
type,
|
|
377
|
+
handler: dispatchHandler,
|
|
378
|
+
inputSchema: state.inputSchema,
|
|
379
|
+
outputSchema: undefined, // Level 3 uses resource schema projection, not Zod output validation
|
|
380
|
+
middlewares: typedMiddlewares,
|
|
381
|
+
guards: [], // Guards come from access level config
|
|
382
|
+
restOverride: state.restOverride,
|
|
383
|
+
deprecated: state.deprecated,
|
|
384
|
+
deprecationMessage: state.deprecationMessage,
|
|
385
|
+
isWebhook: state.isWebhook,
|
|
386
|
+
parentResource: state.parentResource,
|
|
387
|
+
parentResources: state.parentResources,
|
|
388
|
+
_precompiledExecutor: undefined, // Branched procedures don't use precompiled chains
|
|
389
|
+
_resourceSchema: state.resourceSchema,
|
|
390
|
+
_resourceLevel: undefined, // No fixed level — determined at runtime by branch selection
|
|
391
|
+
_handlerMap: handlerMap,
|
|
392
|
+
// Store error classes declared via .throws()
|
|
393
|
+
errorClasses: state.errorClasses,
|
|
394
|
+
// Store transactional configuration
|
|
395
|
+
transactional: state.transactional,
|
|
396
|
+
transactionalOptions: state.transactionalOptions,
|
|
397
|
+
// Store pipeline steps declared via .through()
|
|
398
|
+
pipelineSteps: state.pipelineSteps,
|
|
399
|
+
// Store policy action declared via .policy()
|
|
400
|
+
policyAction: state.policyAction,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
// ============================================================================
|
|
404
|
+
// PostHandlerBuilder Factory
|
|
405
|
+
// ============================================================================
|
|
406
|
+
/**
|
|
407
|
+
* Creates a PostHandlerBuilder from a CompiledProcedure's fields
|
|
408
|
+
*
|
|
409
|
+
* Wraps a compiled procedure object with a `.useAfter()` method that
|
|
410
|
+
* appends after-handler hooks. Each call returns a new PostHandlerBuilder
|
|
411
|
+
* with the hook added, preserving immutability.
|
|
412
|
+
*
|
|
413
|
+
* The resulting object passes `isCompiledProcedure` because it retains
|
|
414
|
+
* all required fields (type, handler, middlewares, guards).
|
|
415
|
+
*
|
|
416
|
+
* @internal
|
|
417
|
+
*/
|
|
418
|
+
function createPostHandlerBuilder(compiled) {
|
|
419
|
+
return {
|
|
420
|
+
...compiled,
|
|
421
|
+
useAfter(handler) {
|
|
422
|
+
const afterHandlers = [
|
|
423
|
+
...(compiled.afterHandlers ?? []),
|
|
424
|
+
handler,
|
|
425
|
+
];
|
|
426
|
+
return createPostHandlerBuilder({
|
|
427
|
+
...compiled,
|
|
428
|
+
afterHandlers,
|
|
429
|
+
});
|
|
430
|
+
},
|
|
271
431
|
};
|
|
272
432
|
}
|
|
273
433
|
/**
|
|
@@ -437,7 +597,7 @@ export async function executeProcedure(procedure, rawInput, ctx) {
|
|
|
437
597
|
// IMPORTANT: last guard's accessLevel wins. With custom levels that
|
|
438
598
|
// have no inherent hierarchy, ordering of guards matters.
|
|
439
599
|
// Guards without accessLevel (e.g. plain `authenticated`) do NOT
|
|
440
|
-
// update the level — it stays at 'public' for .
|
|
600
|
+
// update the level — it stays at 'public' for .output() projection.
|
|
441
601
|
const guardWithLevel = guard;
|
|
442
602
|
if (guardWithLevel.accessLevel) {
|
|
443
603
|
accessLevel = guardWithLevel.accessLevel;
|
|
@@ -451,21 +611,101 @@ export async function executeProcedure(procedure, rawInput, ctx) {
|
|
|
451
611
|
const input = procedure.inputSchema
|
|
452
612
|
? procedure.inputSchema.parse(rawInput)
|
|
453
613
|
: rawInput;
|
|
614
|
+
// Step 2.3: Policy check — if .policy() was used
|
|
615
|
+
if (procedure.policyAction) {
|
|
616
|
+
const ctxRecord = ctxWithLevel;
|
|
617
|
+
const user = ctxRecord.user;
|
|
618
|
+
if (user == null) {
|
|
619
|
+
throw new ForbiddenError('Policy check failed: no authenticated user');
|
|
620
|
+
}
|
|
621
|
+
const resourceNameRaw = procedure.policyAction.resourceName;
|
|
622
|
+
const resourceName = resourceNameRaw.charAt(0).toLowerCase() + resourceNameRaw.slice(1);
|
|
623
|
+
const policyResource = ctxRecord[resourceName];
|
|
624
|
+
const allowed = await procedure.policyAction.check(user, policyResource);
|
|
625
|
+
if (!allowed) {
|
|
626
|
+
throw new ForbiddenError(`Policy check failed: cannot ${procedure.policyAction.actionName} ${procedure.policyAction.resourceName}`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
// Step 2.5: Level 3 branch selection — if handler map is present
|
|
630
|
+
if (procedure._handlerMap) {
|
|
631
|
+
return executeBranchedProcedure(procedure, input, ctxWithLevel);
|
|
632
|
+
}
|
|
633
|
+
// Step 2.7: Execute pipeline steps if .through() was used
|
|
634
|
+
// Pipeline transforms input before the handler. Runs inside transaction when applicable.
|
|
635
|
+
const pipelineSteps = procedure.pipelineSteps;
|
|
636
|
+
const hasPipeline = pipelineSteps !== undefined && pipelineSteps.length > 0;
|
|
454
637
|
// Step 3: Execute handler (with or without middleware)
|
|
638
|
+
// Helper that runs a given set of pipeline steps + middleware chain + handler
|
|
639
|
+
const executeWithSteps = async (steps, execCtx) => {
|
|
640
|
+
const hasSteps = steps !== undefined && steps.length > 0;
|
|
641
|
+
// Run pipeline to transform input before handler
|
|
642
|
+
const handlerInput = hasSteps
|
|
643
|
+
? (await executePipeline(steps, input, execCtx))
|
|
644
|
+
: input;
|
|
645
|
+
enrichedInput = handlerInput;
|
|
646
|
+
if (procedure._precompiledExecutor) {
|
|
647
|
+
// PERFORMANCE: Use pre-compiled middleware chain executor
|
|
648
|
+
return procedure._precompiledExecutor(handlerInput, execCtx);
|
|
649
|
+
}
|
|
650
|
+
if (procedure.middlewares.length === 0) {
|
|
651
|
+
// No middleware - execute handler directly
|
|
652
|
+
return procedure.handler({ input: handlerInput, ctx: execCtx });
|
|
653
|
+
}
|
|
654
|
+
// Fallback: Build middleware chain dynamically (should not normally happen)
|
|
655
|
+
return executeMiddlewareChain(procedure.middlewares, handlerInput, execCtx, async () => procedure.handler({ input: handlerInput, ctx: execCtx }));
|
|
656
|
+
};
|
|
455
657
|
let result;
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
658
|
+
let enrichedInput = input;
|
|
659
|
+
// Wrap in transaction if .transactional() was called and ctx.db.$transaction exists
|
|
660
|
+
const ctxRecord = ctxWithLevel;
|
|
661
|
+
const db = ctxRecord.db;
|
|
662
|
+
const isTransactional = procedure.transactional && db && typeof db.$transaction === 'function';
|
|
663
|
+
if (isTransactional) {
|
|
664
|
+
const $transaction = db.$transaction;
|
|
665
|
+
if (hasPipeline) {
|
|
666
|
+
// Two-phase model: split steps into DB (inside tx) and external (after commit)
|
|
667
|
+
const { dbSteps, externalSteps } = splitPipelineSteps(pipelineSteps);
|
|
668
|
+
// Phase A: Run DB steps + handler inside transaction
|
|
669
|
+
result = await $transaction(async (tx) => {
|
|
670
|
+
const txCtx = { ...ctxWithLevel, db: tx };
|
|
671
|
+
return executeWithSteps(dbSteps.length > 0 ? dbSteps : undefined, txCtx);
|
|
672
|
+
}, procedure.transactionalOptions);
|
|
673
|
+
// Phase B: Run external steps after commit (outside transaction)
|
|
674
|
+
if (externalSteps.length > 0) {
|
|
675
|
+
await executeExternalSteps(externalSteps, input, ctxWithLevel);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
// Transactional, no pipeline — wrap handler in $transaction (current behavior)
|
|
680
|
+
result = await $transaction(async (tx) => {
|
|
681
|
+
const txCtx = { ...ctxWithLevel, db: tx };
|
|
682
|
+
return executeWithSteps(undefined, txCtx);
|
|
683
|
+
}, procedure.transactionalOptions);
|
|
684
|
+
}
|
|
463
685
|
}
|
|
464
686
|
else {
|
|
465
|
-
//
|
|
466
|
-
result = await
|
|
687
|
+
// Not transactional — run all steps in declaration order (regardless of external flag)
|
|
688
|
+
result = await executeWithSteps(hasPipeline ? pipelineSteps : undefined, ctxWithLevel);
|
|
689
|
+
}
|
|
690
|
+
// Step 4: Emit domain events if .emits() was used
|
|
691
|
+
if (procedure.emittedEvents) {
|
|
692
|
+
const ctxEvents = ctxRecord
|
|
693
|
+
.events;
|
|
694
|
+
if (ctxEvents?.emit) {
|
|
695
|
+
for (const { eventClass, mapper } of procedure.emittedEvents) {
|
|
696
|
+
try {
|
|
697
|
+
const eventData = mapper
|
|
698
|
+
? mapper(result)
|
|
699
|
+
: result;
|
|
700
|
+
await ctxEvents.emit(new eventClass(eventData));
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
log.error('Event emission error:', error);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
467
707
|
}
|
|
468
|
-
// Step
|
|
708
|
+
// Step 5: Auto-project if resource schema is set
|
|
469
709
|
if (procedure._resourceSchema) {
|
|
470
710
|
const schema = procedure._resourceSchema;
|
|
471
711
|
// Prefer explicit level from tagged schema over guard-derived level
|
|
@@ -480,9 +720,99 @@ export async function executeProcedure(procedure, rawInput, ctx) {
|
|
|
480
720
|
result = projectOne(result);
|
|
481
721
|
}
|
|
482
722
|
}
|
|
483
|
-
// Step
|
|
723
|
+
// Step 6: Validate output if schema provided
|
|
484
724
|
if (procedure.outputSchema) {
|
|
485
|
-
|
|
725
|
+
result = procedure.outputSchema.parse(result);
|
|
726
|
+
}
|
|
727
|
+
// Step 7: Execute after-handler hooks if .useAfter() was used
|
|
728
|
+
if (procedure.afterHandlers?.length) {
|
|
729
|
+
for (const afterHandler of procedure.afterHandlers) {
|
|
730
|
+
try {
|
|
731
|
+
await afterHandler({ input: enrichedInput, result, ctx: ctxWithLevel });
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
log.error('useAfter hook error:', error);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// Return the ORIGINAL result (hooks cannot modify it)
|
|
739
|
+
return result;
|
|
740
|
+
}
|
|
741
|
+
// ============================================================================
|
|
742
|
+
// Level 3 Branch Selection
|
|
743
|
+
// ============================================================================
|
|
744
|
+
/**
|
|
745
|
+
* Executes a Level 3 branched procedure
|
|
746
|
+
*
|
|
747
|
+
* Evaluates guards from the resource schema's access level config
|
|
748
|
+
* most-privileged-first to select the matching branch handler, then
|
|
749
|
+
* auto-projects the result through that level's field visibility.
|
|
750
|
+
*
|
|
751
|
+
* @internal
|
|
752
|
+
*/
|
|
753
|
+
async function executeBranchedProcedure(procedure, input, ctx) {
|
|
754
|
+
const handlerMap = procedure._handlerMap;
|
|
755
|
+
const schema = procedure._resourceSchema;
|
|
756
|
+
const levelConfig = schema?._levelConfig;
|
|
757
|
+
if (!levelConfig) {
|
|
758
|
+
throw new ConfigurationError('Resource schema must be built with defineAccessLevels() containing guards for Level 3 branching.');
|
|
759
|
+
}
|
|
760
|
+
// Evaluate guards most-privileged-first (reverse level order)
|
|
761
|
+
const levels = [...levelConfig.levels];
|
|
762
|
+
const reversedLevels = [...levels].reverse();
|
|
763
|
+
let selectedLevel;
|
|
764
|
+
let selectedHandler;
|
|
765
|
+
for (const level of reversedLevels) {
|
|
766
|
+
const guard = levelConfig.guards[level];
|
|
767
|
+
if (!guard) {
|
|
768
|
+
// No guard = public/fallback level — only select if handler exists
|
|
769
|
+
const key = `__velox_level_${level}`;
|
|
770
|
+
if (handlerMap[key]) {
|
|
771
|
+
// Don't select yet — keep looking for a higher-privilege match.
|
|
772
|
+
// This fallback is used if no guarded level matches.
|
|
773
|
+
if (!selectedHandler) {
|
|
774
|
+
selectedLevel = level;
|
|
775
|
+
selectedHandler = handlerMap[key];
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
const passed = await guard(ctx);
|
|
781
|
+
if (passed) {
|
|
782
|
+
// Guard passed — find the best handler at or below this level
|
|
783
|
+
const levelIndex = levels.indexOf(level);
|
|
784
|
+
for (let i = levelIndex; i >= 0; i--) {
|
|
785
|
+
const candidateLevel = levels[i];
|
|
786
|
+
const key = `__velox_level_${candidateLevel}`;
|
|
787
|
+
if (handlerMap[key]) {
|
|
788
|
+
selectedHandler = handlerMap[key];
|
|
789
|
+
selectedLevel = candidateLevel;
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (!selectedHandler || !selectedLevel) {
|
|
797
|
+
throw new GuardError('access', 'No matching branch for this access level', 403);
|
|
798
|
+
}
|
|
799
|
+
// Execute middleware chain if any, then the selected handler
|
|
800
|
+
let result;
|
|
801
|
+
if (procedure.middlewares.length > 0) {
|
|
802
|
+
result = await executeMiddlewareChain(procedure.middlewares, input, ctx, async () => selectedHandler({ input, ctx }));
|
|
803
|
+
}
|
|
804
|
+
else {
|
|
805
|
+
result = await selectedHandler({ input, ctx });
|
|
806
|
+
}
|
|
807
|
+
// Auto-project through the selected level's visibility
|
|
808
|
+
const projectOne = (item) => {
|
|
809
|
+
return new Resource(item, schema).forLevel(selectedLevel);
|
|
810
|
+
};
|
|
811
|
+
if (Array.isArray(result)) {
|
|
812
|
+
result = result.map((item) => projectOne(item));
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
result = projectOne(result);
|
|
486
816
|
}
|
|
487
817
|
return result;
|
|
488
818
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline executor for .through() steps
|
|
3
|
+
*
|
|
4
|
+
* Executes pipeline steps in declaration order. Each step's output becomes
|
|
5
|
+
* the next step's input. On failure, runs revert actions for completed
|
|
6
|
+
* steps in reverse order (compensation pattern).
|
|
7
|
+
*
|
|
8
|
+
* When `.transactional()` is combined with `.through()`, the executor
|
|
9
|
+
* implements a two-phase model:
|
|
10
|
+
* - Phase A: DB steps (non-external) run inside the transaction
|
|
11
|
+
* - Phase B: External steps run after the transaction commits
|
|
12
|
+
*
|
|
13
|
+
* @module procedure/pipeline-executor
|
|
14
|
+
*/
|
|
15
|
+
import type { BaseContext } from '@veloxts/core';
|
|
16
|
+
import type { PipelineStep } from './pipeline.js';
|
|
17
|
+
/**
|
|
18
|
+
* Result of splitting pipeline steps into DB and external phases
|
|
19
|
+
*/
|
|
20
|
+
export interface SplitPipelineSteps {
|
|
21
|
+
/** Steps that run inside the DB transaction (external === false) */
|
|
22
|
+
readonly dbSteps: ReadonlyArray<PipelineStep>;
|
|
23
|
+
/** Steps that run after transaction commit (external === true) */
|
|
24
|
+
readonly externalSteps: ReadonlyArray<PipelineStep>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Splits pipeline steps into DB (non-external) and external phases
|
|
28
|
+
*
|
|
29
|
+
* Validates the ordering constraint: when transactional, all external steps
|
|
30
|
+
* must come AFTER all DB steps. If an external step appears before a DB step,
|
|
31
|
+
* a ConfigurationError is thrown.
|
|
32
|
+
*
|
|
33
|
+
* @param steps - Pipeline steps to split
|
|
34
|
+
* @returns Object with `dbSteps` and `externalSteps` arrays
|
|
35
|
+
* @throws ConfigurationError if an external step precedes a DB step
|
|
36
|
+
*/
|
|
37
|
+
export declare function splitPipelineSteps(steps: ReadonlyArray<PipelineStep>): SplitPipelineSteps;
|
|
38
|
+
/**
|
|
39
|
+
* Executes a sequence of pipeline steps
|
|
40
|
+
*
|
|
41
|
+
* Steps run in declaration order. Each step receives the previous step's
|
|
42
|
+
* output as its input (the first step receives the original validated input).
|
|
43
|
+
*
|
|
44
|
+
* If a step fails:
|
|
45
|
+
* 1. Collects all completed steps that have a revertAction
|
|
46
|
+
* 2. Runs their revert handlers in REVERSE order
|
|
47
|
+
* 3. Each revert receives the output of the step being reverted
|
|
48
|
+
* 4. Revert errors are logged but do not suppress the original error
|
|
49
|
+
* 5. Rethrows the original error
|
|
50
|
+
*
|
|
51
|
+
* @param steps - Pipeline steps to execute in order
|
|
52
|
+
* @param input - Initial input (typically the validated procedure input)
|
|
53
|
+
* @param ctx - Request context
|
|
54
|
+
* @returns The output of the last step
|
|
55
|
+
*/
|
|
56
|
+
export declare function executePipeline(steps: ReadonlyArray<PipelineStep>, input: unknown, ctx: BaseContext): Promise<unknown>;
|
|
57
|
+
/**
|
|
58
|
+
* Executes external pipeline steps after a transaction has committed
|
|
59
|
+
*
|
|
60
|
+
* External steps run in declaration order. If a step fails, revert actions
|
|
61
|
+
* are run for all previously completed EXTERNAL steps in reverse order.
|
|
62
|
+
* The DB transaction is already committed — DB changes persist.
|
|
63
|
+
*
|
|
64
|
+
* @param steps - External pipeline steps to execute in order
|
|
65
|
+
* @param input - Input for the first external step (output of last DB step, or original input)
|
|
66
|
+
* @param ctx - Request context (with the ORIGINAL db, not the transactional client)
|
|
67
|
+
* @returns The output of the last external step (ignored by the caller, since handler already ran)
|
|
68
|
+
*/
|
|
69
|
+
export declare function executeExternalSteps(steps: ReadonlyArray<PipelineStep>, input: unknown, ctx: BaseContext): Promise<void>;
|