@veloxts/router 0.6.97 → 0.6.99

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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # @veloxts/router
2
2
 
3
+ ## 0.6.99
4
+
5
+ ### Patch Changes
6
+
7
+ - Updates to documentation and mark as beta 0.6.x
8
+ - Updated dependencies
9
+ - @veloxts/core@0.6.99
10
+ - @veloxts/validation@0.6.99
11
+
12
+ ## 0.6.98
13
+
14
+ ### Patch Changes
15
+
16
+ - feat(router): nested resource schema relations with .hasOne() / .hasMany()
17
+ - Updated dependencies
18
+ - @veloxts/core@0.6.98
19
+ - @veloxts/validation@0.6.98
20
+
3
21
  ## 0.6.97
4
22
 
5
23
  ### Patch Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @veloxts/router
2
2
 
3
- > **Early Preview (v0.6.x)** - APIs are stabilizing but may still change. Do not use in production yet.
3
+ > **Beta (v0.6.x)**
4
4
 
5
5
  Procedure-based routing for VeloxTS Framework - provides hybrid tRPC and REST adapters with naming convention-based HTTP method inference. Learn more at [@veloxts/velox](https://www.npmjs.com/package/@veloxts/velox).
6
6
 
package/dist/index.d.ts CHANGED
@@ -83,7 +83,7 @@ export { serve } from './expose.js';
83
83
  */
84
84
  export type { BuildParametersOptions, BuildParametersResult, GuardMappingOptions, JSONSchema, OpenAPIComponents, OpenAPIContact, OpenAPIEncoding, OpenAPIExample, OpenAPIExternalDocs, OpenAPIGeneratorOptions, OpenAPIHeader, OpenAPIHttpMethod, OpenAPIInfo, OpenAPILicense, OpenAPILink, OpenAPIMediaType, OpenAPIOAuthFlow, OpenAPIOAuthFlows, OpenAPIOperation, OpenAPIParameter, OpenAPIPathItem, OpenAPIRequestBody, OpenAPIResponse, OpenAPISecurityRequirement, OpenAPISecurityScheme, OpenAPIServer, OpenAPISpec, OpenAPITag, ParameterIn, QueryParamExtractionOptions, RouteInfo, SchemaConversionOptions, SecuritySchemeType, SwaggerUIConfig, SwaggerUIHtmlOptions, SwaggerUIPluginOptions, } from './openapi/index.js';
85
85
  export { buildParameters, convertFromOpenAPIPath, convertToOpenAPIPath, createSecurityRequirement, createStringSchema, createSwaggerUI, DEFAULT_GUARD_MAPPINGS, DEFAULT_SECURITY_SCHEMES, DEFAULT_UI_CONFIG, escapeHtml, extractGuardScopes, extractPathParamNames, extractQueryParameters, extractResourceFromPath, extractSchemaProperties, extractUsedSecuritySchemes, filterUsedSecuritySchemes, generateOpenApiSpec, generateSwaggerUIHtml, getOpenApiRouteSummary, getOpenApiSpec, guardsRequireAuth, guardsToSecurity, hasPathParameters, joinPaths, mapGuardToSecurity, mergeSchemas, mergeSecuritySchemes, normalizePath, parsePathParameters, registerDocs, removeSchemaProperties, SWAGGER_UI_CDN, schemaHasProperties, swaggerUIPlugin, validateOpenApiSpec, zodSchemaToJsonSchema, } from './openapi/index.js';
86
- export type { AccessLevel, ADMIN, AdminOutput, AdminTaggedContext, ANONYMOUS, AnonymousOutput, AnonymousTaggedContext, AnyResourceOutput, AUTHENTICATED, AuthenticatedOutput, AuthenticatedTaggedContext, ContextTag, ExtractTag, HasTag, IfAdmin, IfAuthenticated, InferResourceData, InferResourceOutput, IsVisibleToTag, OutputForTag, ResourceField, ResourceSchema, RuntimeField, TaggedContext, VisibilityLevel, WithTag, } from './resource/index.js';
86
+ export type { AccessLevel, ADMIN, AdminOutput, AdminTaggedContext, ANONYMOUS, AnonymousOutput, AnonymousTaggedContext, AnyResourceOutput, AUTHENTICATED, AuthenticatedOutput, AuthenticatedTaggedContext, BuilderField, ContextTag, ExtractTag, HasTag, IfAdmin, IfAuthenticated, InferResourceData, InferResourceOutput, IsVisibleToTag, OutputForTag, RelationField, ResourceField, ResourceSchema, RuntimeField, TaggedContext, VisibilityLevel, WithTag, } from './resource/index.js';
87
87
  /**
88
88
  * Resource API for context-dependent output types using phantom types.
89
89
  *
@@ -10,7 +10,7 @@
10
10
  import { ConfigurationError, logWarning } from '@veloxts/core';
11
11
  import { GuardError } from '../errors.js';
12
12
  import { createMiddlewareExecutor, executeMiddlewareChain } from '../middleware/chain.js';
13
- import { Resource, } from '../resource/index.js';
13
+ import { isTaggedResourceSchema, Resource, } from '../resource/index.js';
14
14
  import { deriveParentParamName } from '../utils/pluralization.js';
15
15
  import { analyzeNamingConvention, isDevelopment, normalizeWarningOption, } from '../warnings.js';
16
16
  // ============================================================================
@@ -202,15 +202,35 @@ function createBuilder(state) {
202
202
  /**
203
203
  * Sets the output type based on a resource schema
204
204
  *
205
- * This method stores the resource schema for potential OpenAPI generation
206
- * and narrows the output type based on the context's phantom tag.
205
+ * Accepts either a plain `ResourceSchema` or a tagged schema
206
+ * (e.g., `UserSchema.authenticated`) for declarative auto-projection.
207
+ *
208
+ * @example
209
+ * ```typescript
210
+ * // Tagged schema — auto-projects to authenticated fields
211
+ * procedure()
212
+ * .guard(authenticated)
213
+ * .resource(UserSchema.authenticated)
214
+ * .query(handler);
215
+ *
216
+ * // Plain schema — defaults to public, or derives from guardNarrow
217
+ * procedure()
218
+ * .resource(UserSchema)
219
+ * .query(handler);
220
+ * ```
221
+ */
222
+ /**
223
+ * Sets the output type based on a resource schema
224
+ *
225
+ * Accepts either a plain `ResourceSchema` or a tagged schema
226
+ * (e.g., `UserSchema.authenticated`) for declarative auto-projection.
207
227
  */
208
228
  resource(schema) {
209
- // Store the resource schema for OpenAPI generation
210
- // The actual output type is computed at the type level
229
+ const level = isTaggedResourceSchema(schema) ? schema._level : undefined;
211
230
  return createBuilder({
212
231
  ...state,
213
232
  resourceSchema: schema,
233
+ resourceLevel: level,
214
234
  });
215
235
  },
216
236
  };
@@ -248,6 +268,8 @@ function compileProcedure(type, handler, state) {
248
268
  _precompiledExecutor: precompiledExecutor,
249
269
  // Store resource schema for auto-projection
250
270
  _resourceSchema: state.resourceSchema,
271
+ // Store explicit resource level from tagged schema (e.g., UserSchema.authenticated)
272
+ _resourceLevel: state.resourceLevel,
251
273
  };
252
274
  }
253
275
  /**
@@ -460,17 +482,24 @@ export async function executeProcedure(procedure, rawInput, ctx) {
460
482
  // Step 4: Auto-project if resource schema is set
461
483
  if (procedure._resourceSchema) {
462
484
  const schema = procedure._resourceSchema;
463
- const resourceInstance = new Resource(result, schema);
464
- // Project based on access level
465
- switch (accessLevel) {
466
- case 'admin':
467
- result = resourceInstance.forAdmin();
468
- break;
469
- case 'authenticated':
470
- result = resourceInstance.forAuthenticated();
471
- break;
472
- default:
473
- result = resourceInstance.forAnonymous();
485
+ // Prefer explicit level from tagged schema over guard-derived level
486
+ const finalLevel = procedure._resourceLevel ?? accessLevel;
487
+ const projectOne = (item) => {
488
+ const r = new Resource(item, schema);
489
+ switch (finalLevel) {
490
+ case 'admin':
491
+ return r.forAdmin();
492
+ case 'authenticated':
493
+ return r.forAuthenticated();
494
+ default:
495
+ return r.forAnonymous();
496
+ }
497
+ };
498
+ if (Array.isArray(result)) {
499
+ result = result.map((item) => projectOne(item));
500
+ }
501
+ else {
502
+ result = projectOne(result);
474
503
  }
475
504
  }
476
505
  // Step 5: Validate output if schema provided
@@ -11,8 +11,8 @@
11
11
  */
12
12
  import type { BaseContext } from '@veloxts/core';
13
13
  import type { ZodType, ZodTypeDef } from 'zod';
14
- import type { OutputForTag, ResourceSchema } from '../resource/index.js';
15
- import type { ContextTag, ExtractTag, TaggedContext } from '../resource/tags.js';
14
+ import type { OutputForTag, ResourceSchema, TaggedResourceSchema } from '../resource/index.js';
15
+ import type { ContextTag, ExtractTag, LevelToTag, TaggedContext } from '../resource/tags.js';
16
16
  import type { CompiledProcedure, GuardLike, MiddlewareFunction, ParentResourceConfig, ProcedureHandler, RestRouteOverride } from '../types.js';
17
17
  /**
18
18
  * Internal state type that accumulates type information through the builder chain
@@ -372,34 +372,31 @@ export interface ProcedureBuilder<TInput = unknown, TOutput = unknown, TContext
372
372
  */
373
373
  mutation(handler: ProcedureHandler<TInput, TOutput, TContext>): CompiledProcedure<TInput, TOutput, TContext, 'mutation'>;
374
374
  /**
375
- * Sets the output type based on a resource schema and context tag
375
+ * Sets the output type based on a resource schema
376
376
  *
377
- * This method enables context-dependent output types using phantom types.
378
- * The output type is computed based on the fields visible to the context's
379
- * access level (anonymous, authenticated, or admin).
377
+ * Accepts either a tagged schema (e.g., `UserSchema.authenticated`) for
378
+ * explicit auto-projection, or a plain schema for backward compatibility.
380
379
  *
381
- * **IMPORTANT**: This method is for type documentation only. You must still
382
- * call `.forAnonymous()`, `.forAuthenticated()`, or `.forAdmin()` on the
383
- * resource instance in your handler to perform the actual field filtering.
384
- *
385
- * @template TSchema - The resource schema type
386
- * @param schema - The resource schema defining field visibility
387
- * @returns New builder with output type set based on context's tag
380
+ * When a tagged schema is used, the output type is computed from the
381
+ * tag's access level. When a plain schema is used, the output type is
382
+ * derived from the context's phantom tag (set by `guardNarrow`).
388
383
  *
389
384
  * @example
390
385
  * ```typescript
391
- * // The output type is automatically computed from the context tag
392
- * const getUser = procedure()
393
- * .guardNarrow(authenticatedNarrow) // Tags context with AUTHENTICATED
394
- * .input(z.object({ id: z.string() }))
395
- * .resource(UserSchema) // Output type: { id, name, email }
396
- * .query(async ({ input, ctx }) => {
397
- * const user = await ctx.db.user.findUnique({ where: { id: input.id } });
398
- * return resource(user, UserSchema).forAuthenticated();
399
- * });
386
+ * // Tagged schema explicit projection level (recommended)
387
+ * procedure()
388
+ * .guard(authenticated)
389
+ * .resource(UserSchema.authenticated)
390
+ * .query(async ({ ctx }) => ctx.db.user.findUnique(...));
391
+ *
392
+ * // Plain schema derives level from guardNarrow or defaults to public
393
+ * procedure()
394
+ * .guardNarrow(authenticatedNarrow)
395
+ * .resource(UserSchema)
396
+ * .query(async ({ ctx }) => ctx.db.user.findUnique(...));
400
397
  * ```
401
398
  */
402
- resource<TSchema extends ResourceSchema>(schema: TSchema): ProcedureBuilder<TInput, TContext extends TaggedContext<infer TTag> ? TTag extends ContextTag ? OutputForTag<TSchema, TTag> : OutputForTag<TSchema, ExtractTag<TContext>> : OutputForTag<TSchema, ExtractTag<TContext>>, TContext>;
399
+ resource<TSchema extends ResourceSchema>(schema: TSchema): ProcedureBuilder<TInput, TSchema extends TaggedResourceSchema<infer TFields, infer TLevel> ? OutputForTag<ResourceSchema<TFields>, LevelToTag<TLevel>> : TContext extends TaggedContext<infer TTag> ? TTag extends ContextTag ? OutputForTag<TSchema, TTag> : OutputForTag<TSchema, ExtractTag<TContext>> : OutputForTag<TSchema, ExtractTag<TContext>>, TContext>;
403
400
  }
404
401
  /**
405
402
  * Internal runtime state for the procedure builder
@@ -414,6 +411,8 @@ export interface BuilderRuntimeState {
414
411
  outputSchema?: ValidSchema;
415
412
  /** Resource schema for context-dependent output */
416
413
  resourceSchema?: ResourceSchema;
414
+ /** Explicit resource level from tagged schema (e.g., UserSchema.authenticated) */
415
+ resourceLevel?: 'public' | 'authenticated' | 'admin';
417
416
  /** Middleware chain */
418
417
  middlewares: MiddlewareFunction<unknown, BaseContext, BaseContext, unknown>[];
419
418
  /** Guards to execute before handler */
@@ -60,9 +60,9 @@
60
60
  * @module resource
61
61
  */
62
62
  export { Resource, ResourceCollection, resource, resourceCollection } from './instance.js';
63
- export type { AdminOutput, AnonymousOutput, AuthenticatedOutput, OutputForTag, ResourceField, ResourceSchema, RuntimeField, } from './schema.js';
64
- export { isResourceSchema, ResourceSchemaBuilder, resourceSchema } from './schema.js';
65
- export type { AccessLevel, ADMIN, ANONYMOUS, AUTHENTICATED, ContextTag, ExtractTag, HasTag, TaggedContext, WithTag, } from './tags.js';
63
+ export type { AdminOutput, AnonymousOutput, AuthenticatedOutput, BuilderField, OutputForLevel, OutputForTag, RelationField, ResourceField, ResourceSchema, ResourceSchemaWithViews, RuntimeField, TaggedResourceSchema, } from './schema.js';
64
+ export { isResourceSchema, isTaggedResourceSchema, ResourceSchemaBuilder, resourceSchema, } from './schema.js';
65
+ export type { AccessLevel, ADMIN, ANONYMOUS, AUTHENTICATED, ContextTag, ExtractTag, HasTag, LevelToTag, TaggedContext, WithTag, } from './tags.js';
66
66
  export type { AdminTaggedContext, AnonymousTaggedContext, AnyResourceOutput, AuthenticatedTaggedContext, IfAdmin, IfAuthenticated, InferResourceData, InferResourceOutput, } from './types.js';
67
67
  export type { IsVisibleToTag, VisibilityLevel } from './visibility.js';
68
68
  export { getAccessibleLevels, getVisibilityForTag, isVisibleAtLevel } from './visibility.js';
@@ -65,6 +65,6 @@
65
65
  // Resource instances
66
66
  export { Resource, ResourceCollection, resource, resourceCollection } from './instance.js';
67
67
  // Schema builder
68
- export { isResourceSchema, ResourceSchemaBuilder, resourceSchema } from './schema.js';
68
+ export { isResourceSchema, isTaggedResourceSchema, ResourceSchemaBuilder, resourceSchema, } from './schema.js';
69
69
  // Visibility
70
70
  export { getAccessibleLevels, getVisibilityForTag, isVisibleAtLevel } from './visibility.js';
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @module resource/instance
8
8
  */
9
- import type { OutputForTag, ResourceSchema } from './schema.js';
9
+ import type { OutputForLevel, OutputForTag, ResourceSchema, TaggedResourceSchema } from './schema.js';
10
10
  import type { ADMIN, ANONYMOUS, AUTHENTICATED, ContextTag, ExtractTag, TaggedContext } from './tags.js';
11
11
  /**
12
12
  * A resource instance that can project data based on access level
@@ -73,12 +73,11 @@ export declare class Resource<TSchema extends ResourceSchema> {
73
73
  /**
74
74
  * Projects data for an explicit visibility level
75
75
  *
76
- * This is a runtime method that filters fields based on the given level.
77
- * Uses Object.create(null) and filters dangerous property names to prevent
78
- * prototype pollution attacks.
76
+ * Delegates to the standalone `projectData()` function which supports
77
+ * recursive projection of nested relations.
79
78
  *
80
79
  * @param level - The visibility level to project for
81
- * @returns Object with only the visible fields
80
+ * @returns Object with only the visible fields (null prototype)
82
81
  */
83
82
  private _project;
84
83
  /**
@@ -141,44 +140,53 @@ export declare class ResourceCollection<TSchema extends ResourceSchema> {
141
140
  isEmpty(): boolean;
142
141
  }
143
142
  /**
144
- * Creates a Resource instance for a single data object
143
+ * Creates a projected view or Resource instance for a single data object
144
+ *
145
+ * When called with a tagged schema (e.g., `UserSchema.authenticated`),
146
+ * returns the projected data directly. When called with an untagged schema,
147
+ * returns a Resource instance with `.forAnonymous()`, `.forAuthenticated()`,
148
+ * `.forAdmin()` methods.
145
149
  *
146
150
  * @param data - The raw data object
147
- * @param schema - The resource schema defining field visibility
148
- * @returns Resource instance with projection methods
151
+ * @param schema - Resource schema (tagged for direct projection, untagged for Resource instance)
152
+ * @returns Projected data or Resource instance
149
153
  *
150
154
  * @example
151
155
  * ```typescript
152
156
  * const user = await db.user.findUnique({ where: { id } });
153
- * if (!user) throw new NotFoundError();
154
157
  *
155
- * // In a public endpoint
156
- * return resource(user, UserSchema).forAnonymous();
158
+ * // Tagged schema returns projected data directly
159
+ * return resource(user, UserSchema.authenticated);
160
+ * // → { id, name, email }
157
161
  *
158
- * // In an authenticated endpoint
162
+ * // Untagged schema returns Resource with projection methods
159
163
  * return resource(user, UserSchema).forAuthenticated();
160
- *
161
- * // In an admin endpoint
162
- * return resource(user, UserSchema).forAdmin();
164
+ * // → { id, name, email }
163
165
  * ```
164
166
  */
167
+ export declare function resource<TSchema extends TaggedResourceSchema>(data: Record<string, unknown>, schema: TSchema): OutputForLevel<TSchema>;
165
168
  export declare function resource<TSchema extends ResourceSchema>(data: Record<string, unknown>, schema: TSchema): Resource<TSchema>;
166
169
  /**
167
- * Creates a ResourceCollection for an array of data objects
170
+ * Creates a projected array or ResourceCollection for an array of data objects
171
+ *
172
+ * When called with a tagged schema (e.g., `UserSchema.authenticated`),
173
+ * returns the projected array directly. When called with an untagged schema,
174
+ * returns a ResourceCollection with projection methods.
168
175
  *
169
176
  * @param items - Array of raw data objects
170
- * @param schema - The resource schema defining field visibility
171
- * @returns ResourceCollection instance with batch projection methods
177
+ * @param schema - Resource schema (tagged for direct projection, untagged for collection)
178
+ * @returns Projected array or ResourceCollection instance
172
179
  *
173
180
  * @example
174
181
  * ```typescript
175
182
  * const users = await db.user.findMany({ take: 10 });
176
183
  *
177
- * // In a public endpoint
178
- * return resourceCollection(users, UserSchema).forAnonymous();
184
+ * // Tagged schema returns projected array directly
185
+ * return resourceCollection(users, UserSchema.authenticated);
179
186
  *
180
- * // In an authenticated endpoint
187
+ * // Untagged schema returns ResourceCollection with methods
181
188
  * return resourceCollection(users, UserSchema).forAuthenticated();
182
189
  * ```
183
190
  */
191
+ export declare function resourceCollection<TSchema extends TaggedResourceSchema>(items: Array<Record<string, unknown>>, schema: TSchema): Array<OutputForLevel<TSchema>>;
184
192
  export declare function resourceCollection<TSchema extends ResourceSchema>(items: Array<Record<string, unknown>>, schema: TSchema): ResourceCollection<TSchema>;
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * @module resource/instance
8
8
  */
9
+ import { isTaggedResourceSchema } from './schema.js';
9
10
  import { isVisibleAtLevel } from './visibility.js';
10
11
  // ============================================================================
11
12
  // Security Constants
@@ -24,6 +25,71 @@ const DANGEROUS_PROPERTIES = new Set([
24
25
  '__lookupSetter__',
25
26
  ]);
26
27
  // ============================================================================
28
+ // Recursive Projection
29
+ // ============================================================================
30
+ /** Maximum allowed nesting depth for recursive relation projection */
31
+ const MAX_PROJECTION_DEPTH = 10;
32
+ /**
33
+ * Recursively projects data through a resource schema, including nested relations
34
+ *
35
+ * Tracks recursion depth and visited objects to guard against circular
36
+ * references and excessively deep nesting.
37
+ *
38
+ * @internal
39
+ * @param data - The raw data object
40
+ * @param schema - The resource schema to project against
41
+ * @param level - The visibility level to project for
42
+ * @param depth - Current recursion depth (defaults to 0)
43
+ * @param visited - Set of already-visited data objects for cycle detection
44
+ * @returns Projected object with null prototype
45
+ */
46
+ function projectData(data, schema, level, depth = 0, visited) {
47
+ if (depth >= MAX_PROJECTION_DEPTH) {
48
+ return Object.create(null);
49
+ }
50
+ const seen = visited ?? new WeakSet();
51
+ if (seen.has(data)) {
52
+ return Object.create(null);
53
+ }
54
+ seen.add(data);
55
+ const result = Object.create(null);
56
+ for (const field of schema.fields) {
57
+ if (!isVisibleAtLevel(field.visibility, level))
58
+ continue;
59
+ if (DANGEROUS_PROPERTIES.has(field.name))
60
+ continue;
61
+ const value = data[field.name];
62
+ if (field.nestedSchema && field.cardinality) {
63
+ // Relation field — recursive projection
64
+ if (field.cardinality === 'one') {
65
+ if (value != null && typeof value === 'object' && !Array.isArray(value)) {
66
+ result[field.name] = projectData(value, field.nestedSchema, level, depth + 1, seen);
67
+ }
68
+ else {
69
+ result[field.name] = null;
70
+ }
71
+ }
72
+ else {
73
+ // 'many' — only project valid object items; skip primitives / nulls
74
+ if (Array.isArray(value)) {
75
+ const nested = field.nestedSchema;
76
+ result[field.name] = value
77
+ .filter((item) => item != null && typeof item === 'object' && !Array.isArray(item))
78
+ .map((item) => projectData(item, nested, level, depth + 1, seen));
79
+ }
80
+ else {
81
+ result[field.name] = [];
82
+ }
83
+ }
84
+ }
85
+ else if (value !== undefined) {
86
+ // Scalar field — existing behavior
87
+ result[field.name] = value;
88
+ }
89
+ }
90
+ return result;
91
+ }
92
+ // ============================================================================
27
93
  // Resource Instance
28
94
  // ============================================================================
29
95
  /**
@@ -105,29 +171,14 @@ export class Resource {
105
171
  /**
106
172
  * Projects data for an explicit visibility level
107
173
  *
108
- * This is a runtime method that filters fields based on the given level.
109
- * Uses Object.create(null) and filters dangerous property names to prevent
110
- * prototype pollution attacks.
174
+ * Delegates to the standalone `projectData()` function which supports
175
+ * recursive projection of nested relations.
111
176
  *
112
177
  * @param level - The visibility level to project for
113
- * @returns Object with only the visible fields
178
+ * @returns Object with only the visible fields (null prototype)
114
179
  */
115
180
  _project(level) {
116
- // Use null prototype to prevent prototype pollution
117
- const result = Object.create(null);
118
- for (const field of this._schema.fields) {
119
- if (isVisibleAtLevel(field.visibility, level)) {
120
- // Skip dangerous prototype properties to prevent pollution attacks
121
- if (DANGEROUS_PROPERTIES.has(field.name)) {
122
- continue;
123
- }
124
- const value = this._data[field.name];
125
- if (value !== undefined) {
126
- result[field.name] = value;
127
- }
128
- }
129
- }
130
- return result;
181
+ return projectData(this._data, this._schema, level);
131
182
  }
132
183
  /**
133
184
  * Infers the visibility level from a context object
@@ -231,52 +282,33 @@ export class ResourceCollection {
231
282
  return this._items.length === 0;
232
283
  }
233
284
  }
234
- // ============================================================================
235
- // Factory Functions
236
- // ============================================================================
237
- /**
238
- * Creates a Resource instance for a single data object
239
- *
240
- * @param data - The raw data object
241
- * @param schema - The resource schema defining field visibility
242
- * @returns Resource instance with projection methods
243
- *
244
- * @example
245
- * ```typescript
246
- * const user = await db.user.findUnique({ where: { id } });
247
- * if (!user) throw new NotFoundError();
248
- *
249
- * // In a public endpoint
250
- * return resource(user, UserSchema).forAnonymous();
251
- *
252
- * // In an authenticated endpoint
253
- * return resource(user, UserSchema).forAuthenticated();
254
- *
255
- * // In an admin endpoint
256
- * return resource(user, UserSchema).forAdmin();
257
- * ```
258
- */
259
285
  export function resource(data, schema) {
286
+ if (isTaggedResourceSchema(schema)) {
287
+ const r = new Resource(data, schema);
288
+ switch (schema._level) {
289
+ case 'admin':
290
+ return r.forAdmin();
291
+ case 'authenticated':
292
+ return r.forAuthenticated();
293
+ default:
294
+ return r.forAnonymous();
295
+ }
296
+ }
260
297
  return new Resource(data, schema);
261
298
  }
262
- /**
263
- * Creates a ResourceCollection for an array of data objects
264
- *
265
- * @param items - Array of raw data objects
266
- * @param schema - The resource schema defining field visibility
267
- * @returns ResourceCollection instance with batch projection methods
268
- *
269
- * @example
270
- * ```typescript
271
- * const users = await db.user.findMany({ take: 10 });
272
- *
273
- * // In a public endpoint
274
- * return resourceCollection(users, UserSchema).forAnonymous();
275
- *
276
- * // In an authenticated endpoint
277
- * return resourceCollection(users, UserSchema).forAuthenticated();
278
- * ```
279
- */
280
299
  export function resourceCollection(items, schema) {
300
+ if (isTaggedResourceSchema(schema)) {
301
+ return items.map((item) => {
302
+ const r = new Resource(item, schema);
303
+ switch (schema._level) {
304
+ case 'admin':
305
+ return r.forAdmin();
306
+ case 'authenticated':
307
+ return r.forAuthenticated();
308
+ default:
309
+ return r.forAnonymous();
310
+ }
311
+ });
312
+ }
281
313
  return new ResourceCollection(items, schema);
282
314
  }
@@ -8,7 +8,7 @@
8
8
  * @module resource/schema
9
9
  */
10
10
  import type { ZodType, ZodTypeDef } from 'zod';
11
- import type { ADMIN, ANONYMOUS, AUTHENTICATED, ContextTag } from './tags.js';
11
+ import type { AccessLevel, ADMIN, ANONYMOUS, AUTHENTICATED, ContextTag, LevelToTag } from './tags.js';
12
12
  import type { IsVisibleToTag, VisibilityLevel } from './visibility.js';
13
13
  /**
14
14
  * A single field definition in a resource schema
@@ -25,13 +25,35 @@ export interface ResourceField<TName extends string = string, TSchema extends Zo
25
25
  /** Visibility level */
26
26
  readonly visibility: TLevel;
27
27
  }
28
+ /**
29
+ * A relation field that references a nested resource schema
30
+ *
31
+ * @template TName - The field name as a literal type
32
+ * @template TNestedFields - The nested schema's field definitions
33
+ * @template TLevel - The visibility level controlling WHETHER the relation is included
34
+ * @template TCardinality - 'one' for nullable object, 'many' for array
35
+ */
36
+ export interface RelationField<TName extends string = string, TNestedFields extends readonly BuilderField[] = readonly BuilderField[], TLevel extends VisibilityLevel = VisibilityLevel, TCardinality extends 'one' | 'many' = 'one' | 'many'> {
37
+ readonly name: TName;
38
+ readonly nestedSchema: ResourceSchema<TNestedFields>;
39
+ readonly visibility: TLevel;
40
+ readonly cardinality: TCardinality;
41
+ /** Brand discriminator — distinguishes from ResourceField */
42
+ readonly _relation: true;
43
+ }
44
+ /**
45
+ * Union of scalar and relation fields in a resource schema
46
+ */
47
+ export type BuilderField = ResourceField | RelationField;
28
48
  /**
29
49
  * Runtime representation of a field (without generics for storage)
30
50
  */
31
51
  export interface RuntimeField {
32
52
  readonly name: string;
33
- readonly schema: ZodType;
53
+ readonly schema?: ZodType;
34
54
  readonly visibility: VisibilityLevel;
55
+ readonly nestedSchema?: ResourceSchema;
56
+ readonly cardinality?: 'one' | 'many';
35
57
  }
36
58
  /**
37
59
  * A completed resource schema with all field definitions
@@ -41,12 +63,65 @@ export interface RuntimeField {
41
63
  *
42
64
  * @template TFields - Tuple of ResourceField types
43
65
  */
44
- export interface ResourceSchema<TFields extends readonly ResourceField[] = readonly ResourceField[]> {
66
+ export interface ResourceSchema<TFields extends readonly BuilderField[] = readonly BuilderField[]> {
45
67
  /** Runtime field definitions */
46
68
  readonly fields: readonly RuntimeField[];
47
69
  /** Phantom type for field type information */
48
70
  readonly __fields?: TFields;
49
71
  }
72
+ /**
73
+ * A resource schema tagged with an explicit access level
74
+ *
75
+ * Created by accessing `.public`, `.authenticated`, or `.admin` on a built schema.
76
+ * Used in both procedure definitions (auto-projection) and handler-level
77
+ * projection via `resource(data, Schema.authenticated)`.
78
+ *
79
+ * @template TFields - The field definitions from the base schema
80
+ * @template TLevel - The access level for projection
81
+ *
82
+ * @example
83
+ * ```typescript
84
+ * const UserSchema = resourceSchema()
85
+ * .public('id', z.string())
86
+ * .authenticated('email', z.string())
87
+ * .build();
88
+ *
89
+ * // Tagged views
90
+ * UserSchema.public // TaggedResourceSchema<..., 'public'>
91
+ * UserSchema.authenticated // TaggedResourceSchema<..., 'authenticated'>
92
+ * UserSchema.admin // TaggedResourceSchema<..., 'admin'>
93
+ * ```
94
+ */
95
+ export interface TaggedResourceSchema<TFields extends readonly BuilderField[] = readonly BuilderField[], TLevel extends AccessLevel = AccessLevel> extends ResourceSchema<TFields> {
96
+ readonly _level: TLevel;
97
+ }
98
+ /**
99
+ * A completed resource schema with pre-built tagged views
100
+ *
101
+ * Returned by `resourceSchema().build()`. Extends `ResourceSchema` (backward
102
+ * compatible) and adds `.public`, `.authenticated`, `.admin` properties
103
+ * for declarative projection.
104
+ *
105
+ * @template TFields - The field definitions
106
+ */
107
+ export interface ResourceSchemaWithViews<TFields extends readonly BuilderField[] = readonly BuilderField[]> extends ResourceSchema<TFields> {
108
+ readonly public: TaggedResourceSchema<TFields, 'public'>;
109
+ readonly authenticated: TaggedResourceSchema<TFields, 'authenticated'>;
110
+ readonly admin: TaggedResourceSchema<TFields, 'admin'>;
111
+ }
112
+ /**
113
+ * Computes the output type for a tagged resource schema
114
+ *
115
+ * Maps the access level to the corresponding phantom tag and
116
+ * computes the projected output type.
117
+ *
118
+ * @template TSchema - A tagged resource schema
119
+ */
120
+ export type OutputForLevel<TSchema extends TaggedResourceSchema> = TSchema extends TaggedResourceSchema<infer TFields, infer TLevel> ? OutputForTag<ResourceSchema<TFields>, LevelToTag<TLevel>> : never;
121
+ /**
122
+ * Type guard to check if a schema is a TaggedResourceSchema (has _level)
123
+ */
124
+ export declare function isTaggedResourceSchema(value: unknown): value is TaggedResourceSchema;
50
125
  /**
51
126
  * Helper to infer the output type of a Zod schema
52
127
  */
@@ -55,11 +130,14 @@ type InferZodOutput<T> = T extends ZodType<infer O, ZodTypeDef, unknown> ? O : n
55
130
  * Filters fields by visibility and extracts their types
56
131
  *
57
132
  * This type iterates over the fields tuple and includes only those
58
- * that are visible to the given context tag.
133
+ * that are visible to the given context tag. RelationField entries
134
+ * are recursively projected using the same tag.
59
135
  */
60
- type FilterFieldsByTag<TFields extends readonly ResourceField[], TTag extends ContextTag> = TFields extends readonly [infer First, ...infer Rest] ? First extends ResourceField<infer Name, infer Schema, infer Level> ? Rest extends readonly ResourceField[] ? IsVisibleToTag<Level, TTag> extends true ? {
136
+ type FilterFieldsByTag<TFields extends readonly BuilderField[], TTag extends ContextTag> = TFields extends readonly [infer First, ...infer Rest] ? Rest extends readonly BuilderField[] ? First extends RelationField<infer Name, infer NestedFields, infer Level, infer Card> ? IsVisibleToTag<Level, TTag> extends true ? {
137
+ [K in Name]: Card extends 'one' ? Simplify<FilterFieldsByTag<NestedFields, TTag>> | null : Array<Simplify<FilterFieldsByTag<NestedFields, TTag>>>;
138
+ } & FilterFieldsByTag<Rest, TTag> : FilterFieldsByTag<Rest, TTag> : First extends ResourceField<infer Name, infer Schema, infer Level> ? IsVisibleToTag<Level, TTag> extends true ? {
61
139
  [K in Name]: InferZodOutput<Schema>;
62
- } & FilterFieldsByTag<Rest, TTag> : FilterFieldsByTag<Rest, TTag> : unknown : unknown : unknown;
140
+ } & FilterFieldsByTag<Rest, TTag> : FilterFieldsByTag<Rest, TTag> : FilterFieldsByTag<Rest, TTag> : unknown : unknown;
63
141
  /**
64
142
  * Simplifies an intersection type to a cleaner object type
65
143
  *
@@ -108,7 +186,7 @@ export type AdminOutput<TSchema extends ResourceSchema> = OutputForTag<TSchema,
108
186
  * .build();
109
187
  * ```
110
188
  */
111
- export declare class ResourceSchemaBuilder<TFields extends readonly ResourceField[] = readonly []> {
189
+ export declare class ResourceSchemaBuilder<TFields extends readonly BuilderField[] = readonly []> {
112
190
  private readonly _fields;
113
191
  private constructor();
114
192
  /**
@@ -139,6 +217,56 @@ export declare class ResourceSchemaBuilder<TFields extends readonly ResourceFiel
139
217
  * @returns New builder with the field added
140
218
  */
141
219
  admin<TName extends string, TSchema extends ZodType>(name: TName, schema: TSchema): ResourceSchemaBuilder<readonly [...TFields, ResourceField<TName, TSchema, 'admin'>]>;
220
+ /**
221
+ * Adds a has-one relation (nullable nested object)
222
+ *
223
+ * The relation's visibility controls WHETHER it appears in the output.
224
+ * The parent's projection level controls WHAT fields of the nested schema are shown.
225
+ *
226
+ * **Note:** The nested schema's generic field types are tracked at compile time
227
+ * for output type computation, but the runtime field stores an untyped
228
+ * `ResourceSchema` reference. Always pass the direct result of `.build()`
229
+ * to ensure the compile-time and runtime schemas stay in sync.
230
+ *
231
+ * @param name - Relation field name
232
+ * @param nestedSchema - The nested resource schema (result of `.build()`)
233
+ * @param visibility - Visibility level for this relation
234
+ * @returns New builder with the relation added
235
+ *
236
+ * @example
237
+ * ```typescript
238
+ * const UserSchema = resourceSchema()
239
+ * .public('id', z.string())
240
+ * .hasOne('organization', OrgSchema, 'public')
241
+ * .build();
242
+ * ```
243
+ */
244
+ hasOne<TName extends string, TNestedFields extends readonly BuilderField[], TLevel extends VisibilityLevel>(name: TName, nestedSchema: ResourceSchema<TNestedFields>, visibility: TLevel): ResourceSchemaBuilder<readonly [...TFields, RelationField<TName, TNestedFields, TLevel, 'one'>]>;
245
+ /**
246
+ * Adds a has-many relation (array of nested objects)
247
+ *
248
+ * The relation's visibility controls WHETHER it appears in the output.
249
+ * The parent's projection level controls WHAT fields of the nested schema are shown.
250
+ *
251
+ * **Note:** The nested schema's generic field types are tracked at compile time
252
+ * for output type computation, but the runtime field stores an untyped
253
+ * `ResourceSchema` reference. Always pass the direct result of `.build()`
254
+ * to ensure the compile-time and runtime schemas stay in sync.
255
+ *
256
+ * @param name - Relation field name
257
+ * @param nestedSchema - The nested resource schema (result of `.build()`)
258
+ * @param visibility - Visibility level for this relation
259
+ * @returns New builder with the relation added
260
+ *
261
+ * @example
262
+ * ```typescript
263
+ * const UserSchema = resourceSchema()
264
+ * .public('id', z.string())
265
+ * .hasMany('posts', PostSchema, 'authenticated')
266
+ * .build();
267
+ * ```
268
+ */
269
+ hasMany<TName extends string, TNestedFields extends readonly BuilderField[], TLevel extends VisibilityLevel>(name: TName, nestedSchema: ResourceSchema<TNestedFields>, visibility: TLevel): ResourceSchemaBuilder<readonly [...TFields, RelationField<TName, TNestedFields, TLevel, 'many'>]>;
142
270
  /**
143
271
  * Adds a field with explicit visibility level
144
272
  *
@@ -149,11 +277,28 @@ export declare class ResourceSchemaBuilder<TFields extends readonly ResourceFiel
149
277
  */
150
278
  field<TName extends string, TSchema extends ZodType, TLevel extends VisibilityLevel>(name: TName, schema: TSchema, visibility: TLevel): ResourceSchemaBuilder<readonly [...TFields, ResourceField<TName, TSchema, TLevel>]>;
151
279
  /**
152
- * Builds the final resource schema
280
+ * Builds the final resource schema with tagged views
281
+ *
282
+ * Returns a schema with `.public`, `.authenticated`, and `.admin`
283
+ * properties for declarative projection in procedures.
284
+ *
285
+ * @returns Completed resource schema with tagged views
286
+ *
287
+ * @example
288
+ * ```typescript
289
+ * const UserSchema = resourceSchema()
290
+ * .public('id', z.string())
291
+ * .authenticated('email', z.string())
292
+ * .build();
293
+ *
294
+ * // Use tagged views in procedures
295
+ * procedure().resource(UserSchema.authenticated).query(handler);
153
296
  *
154
- * @returns Completed resource schema with full type information
297
+ * // Or in handlers
298
+ * resource(data, UserSchema.authenticated);
299
+ * ```
155
300
  */
156
- build(): ResourceSchema<TFields>;
301
+ build(): ResourceSchemaWithViews<TFields>;
157
302
  }
158
303
  /**
159
304
  * Creates a new resource schema builder
@@ -7,6 +7,18 @@
7
7
  *
8
8
  * @module resource/schema
9
9
  */
10
+ /**
11
+ * Type guard to check if a schema is a TaggedResourceSchema (has _level)
12
+ */
13
+ export function isTaggedResourceSchema(value) {
14
+ if (typeof value !== 'object' || value === null) {
15
+ return false;
16
+ }
17
+ const obj = value;
18
+ return (Array.isArray(obj.fields) &&
19
+ typeof obj._level === 'string' &&
20
+ (obj._level === 'public' || obj._level === 'authenticated' || obj._level === 'admin'));
21
+ }
10
22
  // ============================================================================
11
23
  // Schema Builder
12
24
  // ============================================================================
@@ -78,6 +90,76 @@ export class ResourceSchemaBuilder {
78
90
  { name, schema, visibility: 'admin' },
79
91
  ]);
80
92
  }
93
+ /**
94
+ * Adds a has-one relation (nullable nested object)
95
+ *
96
+ * The relation's visibility controls WHETHER it appears in the output.
97
+ * The parent's projection level controls WHAT fields of the nested schema are shown.
98
+ *
99
+ * **Note:** The nested schema's generic field types are tracked at compile time
100
+ * for output type computation, but the runtime field stores an untyped
101
+ * `ResourceSchema` reference. Always pass the direct result of `.build()`
102
+ * to ensure the compile-time and runtime schemas stay in sync.
103
+ *
104
+ * @param name - Relation field name
105
+ * @param nestedSchema - The nested resource schema (result of `.build()`)
106
+ * @param visibility - Visibility level for this relation
107
+ * @returns New builder with the relation added
108
+ *
109
+ * @example
110
+ * ```typescript
111
+ * const UserSchema = resourceSchema()
112
+ * .public('id', z.string())
113
+ * .hasOne('organization', OrgSchema, 'public')
114
+ * .build();
115
+ * ```
116
+ */
117
+ hasOne(name, nestedSchema, visibility) {
118
+ return new ResourceSchemaBuilder([
119
+ ...this._fields,
120
+ {
121
+ name,
122
+ visibility,
123
+ nestedSchema: nestedSchema,
124
+ cardinality: 'one',
125
+ },
126
+ ]);
127
+ }
128
+ /**
129
+ * Adds a has-many relation (array of nested objects)
130
+ *
131
+ * The relation's visibility controls WHETHER it appears in the output.
132
+ * The parent's projection level controls WHAT fields of the nested schema are shown.
133
+ *
134
+ * **Note:** The nested schema's generic field types are tracked at compile time
135
+ * for output type computation, but the runtime field stores an untyped
136
+ * `ResourceSchema` reference. Always pass the direct result of `.build()`
137
+ * to ensure the compile-time and runtime schemas stay in sync.
138
+ *
139
+ * @param name - Relation field name
140
+ * @param nestedSchema - The nested resource schema (result of `.build()`)
141
+ * @param visibility - Visibility level for this relation
142
+ * @returns New builder with the relation added
143
+ *
144
+ * @example
145
+ * ```typescript
146
+ * const UserSchema = resourceSchema()
147
+ * .public('id', z.string())
148
+ * .hasMany('posts', PostSchema, 'authenticated')
149
+ * .build();
150
+ * ```
151
+ */
152
+ hasMany(name, nestedSchema, visibility) {
153
+ return new ResourceSchemaBuilder([
154
+ ...this._fields,
155
+ {
156
+ name,
157
+ visibility,
158
+ nestedSchema: nestedSchema,
159
+ cardinality: 'many',
160
+ },
161
+ ]);
162
+ }
81
163
  /**
82
164
  * Adds a field with explicit visibility level
83
165
  *
@@ -93,14 +175,35 @@ export class ResourceSchemaBuilder {
93
175
  ]);
94
176
  }
95
177
  /**
96
- * Builds the final resource schema
178
+ * Builds the final resource schema with tagged views
179
+ *
180
+ * Returns a schema with `.public`, `.authenticated`, and `.admin`
181
+ * properties for declarative projection in procedures.
182
+ *
183
+ * @returns Completed resource schema with tagged views
184
+ *
185
+ * @example
186
+ * ```typescript
187
+ * const UserSchema = resourceSchema()
188
+ * .public('id', z.string())
189
+ * .authenticated('email', z.string())
190
+ * .build();
191
+ *
192
+ * // Use tagged views in procedures
193
+ * procedure().resource(UserSchema.authenticated).query(handler);
97
194
  *
98
- * @returns Completed resource schema with full type information
195
+ * // Or in handlers
196
+ * resource(data, UserSchema.authenticated);
197
+ * ```
99
198
  */
100
199
  build() {
101
- return {
102
- fields: [...this._fields],
103
- };
200
+ const fields = [...this._fields];
201
+ const base = { fields };
202
+ return Object.assign(base, {
203
+ public: Object.assign({ fields }, { _level: 'public' }),
204
+ authenticated: Object.assign({ fields }, { _level: 'authenticated' }),
205
+ admin: Object.assign({ fields }, { _level: 'admin' }),
206
+ });
104
207
  }
105
208
  }
106
209
  // ============================================================================
@@ -16,6 +16,20 @@
16
16
  * automatic resource projection in the procedure builder.
17
17
  */
18
18
  export type AccessLevel = 'public' | 'authenticated' | 'admin';
19
+ /**
20
+ * Maps an AccessLevel string to its corresponding phantom ContextTag
21
+ *
22
+ * Used to bridge the runtime level declarations (e.g., `UserSchema.authenticated`)
23
+ * to the compile-time phantom type system for output type computation.
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * type Tag = LevelToTag<'authenticated'>; // typeof AUTHENTICATED
28
+ * type Tag = LevelToTag<'admin'>; // typeof ADMIN
29
+ * type Tag = LevelToTag<'public'>; // typeof ANONYMOUS
30
+ * ```
31
+ */
32
+ export type LevelToTag<TLevel extends AccessLevel> = TLevel extends 'admin' ? typeof ADMIN : TLevel extends 'authenticated' ? typeof AUTHENTICATED : typeof ANONYMOUS;
19
33
  /**
20
34
  * Phantom symbol for anonymous (unauthenticated) context
21
35
  * @internal Compile-time only - never used at runtime
package/dist/types.d.ts CHANGED
@@ -343,6 +343,15 @@ export interface CompiledProcedure<TInput = unknown, TOutput = unknown, TContext
343
343
  * @internal
344
344
  */
345
345
  readonly _resourceSchema?: any;
346
+ /**
347
+ * Explicit resource projection level from tagged schema
348
+ *
349
+ * Set when using `.resource(UserSchema.authenticated)` etc.
350
+ * Takes precedence over guard-derived access level.
351
+ *
352
+ * @internal
353
+ */
354
+ readonly _resourceLevel?: 'public' | 'authenticated' | 'admin';
346
355
  }
347
356
  /**
348
357
  * Record of named procedures
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veloxts/router",
3
- "version": "0.6.97",
3
+ "version": "0.6.99",
4
4
  "description": "Procedure definitions with tRPC and REST routing for VeloxTS framework",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -40,8 +40,8 @@
40
40
  "@trpc/server": "11.9.0",
41
41
  "fastify": "5.7.2",
42
42
  "zod-to-json-schema": "3.25.1",
43
- "@veloxts/validation": "0.6.97",
44
- "@veloxts/core": "0.6.97"
43
+ "@veloxts/core": "0.6.99",
44
+ "@veloxts/validation": "0.6.99"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@vitest/coverage-v8": "4.0.18",