@emeryld/rrroutes-contract 2.4.13 → 2.4.15

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/README.md CHANGED
@@ -37,8 +37,8 @@ import { z } from 'zod'
37
37
 
38
38
  // 1) Describe your API
39
39
  const leaves = resource('/v1')
40
- .sub('users', (users) =>
41
- users
40
+ .sub(
41
+ resource('users')
42
42
  .get({
43
43
  querySchema: z.object({
44
44
  search: z.string().optional(),
@@ -49,11 +49,13 @@ const leaves = resource('/v1')
49
49
  ),
50
50
  description: 'Find users',
51
51
  })
52
- .subId('userId', z.string().uuid(), (user) =>
53
- user.patch({
54
- bodySchema: z.object({ name: z.string().min(1) }),
55
- outputSchema: z.object({ ok: z.literal(true) }),
56
- }),
52
+ .sub(
53
+ resource(':userId', undefined, z.string().uuid())
54
+ .patch({
55
+ bodySchema: z.object({ name: z.string().min(1) }),
56
+ outputSchema: z.object({ ok: z.literal(true) }),
57
+ })
58
+ .done(),
57
59
  )
58
60
  .done(),
59
61
  )
@@ -76,7 +78,7 @@ const key = buildCacheKey({
76
78
  leaf,
77
79
  params: { userId: 'f2b2e72a-7f6d-4c3f-9c6f-7f0d8f3ac9e2' },
78
80
  })
79
- // key => ['patch', 'v1', 'users', 'f2b2e72a-7f6d-4c3f-9c6f-7f0d8f3ac9e2', { userId: 'f2b2e72a-7f6d-4c3f-9c6f-7f0d8f3ac9e2' }]
81
+ // key => ['patch', 'v1', 'users', ['f2b2e72a-7f6d-4c3f-9c6f-7f0d8f3ac9e2'], {}]
80
82
  ```
81
83
 
82
84
  ## Detailed usage
@@ -87,10 +89,9 @@ const key = buildCacheKey({
87
89
  import { resource } from '@emeryld/rrroutes-contract'
88
90
  import { z } from 'zod'
89
91
 
90
- const leaves = resource('/api') // base path, optional inherited cfg
91
- .with({ feed: false }) // merges flags into descendants (extend NodeCfg via declaration merging if you add your own)
92
- .sub('projects', (projects) =>
93
- projects
92
+ const leaves = resource('/api')
93
+ .sub(
94
+ resource('projects')
94
95
  .get({
95
96
  feed: true, // infinite/feed for clients
96
97
  querySchema: z.object({
@@ -107,15 +108,15 @@ const leaves = resource('/api') // base path, optional inherited cfg
107
108
  outputSchema: z.object({ id: z.string(), name: z.string() }),
108
109
  description: 'Create a project',
109
110
  })
110
- .subId('projectId', z.string().uuid(), (project) =>
111
- project
111
+ .sub(
112
+ resource(':projectId', undefined, z.string().uuid())
112
113
  .get({ outputSchema: z.object({ id: z.string(), name: z.string() }) })
113
114
  .patch({
114
115
  bodySchema: z.object({ name: z.string().min(1) }),
115
116
  outputSchema: z.object({ id: z.string(), name: z.string() }),
116
117
  })
117
- .sub('avatar', (avatar) =>
118
- avatar
118
+ .sub(
119
+ resource('avatar')
119
120
  .put({
120
121
  bodyFiles: [{ name: 'avatar', maxCount: 1 }], // signals multipart upload
121
122
  bodySchema: z.object({ avatar: z.instanceof(Blob) }),
@@ -130,8 +131,8 @@ const leaves = resource('/api') // base path, optional inherited cfg
130
131
  .done()
131
132
  ```
132
133
 
133
- - `sub(name, [cfg], builder?)` nests paths (`/api/projects`).
134
- - `subId(name, schema, builder)` creates `/:name` segments and merges param schemas downward.
134
+ - `resource(segment, nodeCfg?, idSchema?)` scopes a branch. Pass a segment name (e.g. `'projects'`, `':projectId'`) plus optional per-node config. Supplying an `idSchema` along with a `:param` segment wires up the params schema for all descendants.
135
+ - `sub(childA, childB, ...)` mounts one or more child resources built elsewhere via `resource(...).get(...).done()`. Call it once per branch; pass multiple children at once when needed.
135
136
  - Methods (`get/post/put/patch/delete`) merge the active param schema unless you override via `paramsSchema`.
136
137
  - `done()` closes a branch and returns the collected readonly tuple of leaves.
137
138
 
@@ -157,6 +158,7 @@ type Output = InferOutput<typeof leaf> // { id: string; name: string }
157
158
  // Runtime helpers
158
159
  const url = compilePath(leaf.path, { projectId: '123' }) // "/api/projects/123"
159
160
  const cacheKey = buildCacheKey({ leaf, params: { projectId: '123' } })
161
+ // cacheKey => ['patch', 'api', 'projects', ['123'], {}]
160
162
 
161
163
  // Typed subsets for routers/microfrontends
162
164
  type ProjectRoutes = SubsetRoutes<typeof registry.all, '/api/projects'>
@@ -201,8 +203,8 @@ const leaves = r
201
203
  },
202
204
  ({ collection }) =>
203
205
  collection
204
- .sub('stats', (stats) =>
205
- stats
206
+ .sub(
207
+ resource('stats')
206
208
  .get({
207
209
  outputSchema: z.object({ total: z.number() }),
208
210
  description: 'Extra endpoint alongside CRUD',
@@ -280,7 +282,7 @@ function onChatMessage(raw: unknown) {
280
282
 
281
283
  ### Edge cases and notes
282
284
 
283
- - `paramsSchema` on a method overrides the merged schema from `subId`—useful when you need stricter validation per verb.
285
+ - `paramsSchema` on a method overrides the merged schema from parent segments—useful when you need stricter validation per verb.
284
286
  - `bodyFiles` marks a route as multipart; servers can attach upload middleware, and clients should send `FormData`.
285
287
  - CRUD helper only emits create/update routes when the matching `bodySchema` is provided; delete can be disabled via `enable.remove: false`.
286
288
  - Feed-only behavior (`feed: true`) is intended for GET endpoints; clients treat them as infinite queries.
@@ -4,7 +4,6 @@ type ZodTypeAny = z.ZodTypeAny;
4
4
  type ParamZod<Name extends string, S extends ZodTypeAny> = z.ZodObject<{
5
5
  [K in Name]: S;
6
6
  }>;
7
- type MergedParamsResult<PS, Name extends string, P extends ZodTypeAny> = PS extends ZodTypeAny ? z.ZodIntersection<PS, ParamZod<Name, P>> : ParamZod<Name, P>;
8
7
  type BaseMethodCfg<C extends MethodCfg> = Merge<Omit<MethodCfg, 'querySchema' | 'outputSchema' | 'feed'>, Omit<C, 'querySchema' | 'outputSchema' | 'feed'>>;
9
8
  type FeedField<C extends MethodCfg> = C['feed'] extends true ? {
10
9
  feed: true;
@@ -22,9 +21,29 @@ type ParamsField<C extends MethodCfg, PS> = C['paramsSchema'] extends ZodTypeAny
22
21
  paramsSchema: PS;
23
22
  };
24
23
  type EffectiveFeedFields<C extends MethodCfg, PS> = C['feed'] extends true ? FeedField<C> & FeedQueryField<C> & OutputField<C> & ParamsField<C, PS> : FeedField<C> & NonFeedQueryField<C> & OutputField<C> & ParamsField<C, PS>;
25
- type EffectiveCfg<C extends MethodCfg, PS> = Prettify<Merge<MethodCfg, BaseMethodCfg<C> & EffectiveFeedFields<C, PS>>>;
26
- type BuiltLeaf<M extends HttpMethod, Base extends string, I extends NodeCfg, C extends MethodCfg, PS> = Leaf<M, Base, Merge<I, LowProfileCfg<EffectiveCfg<C, PS>>>>;
27
- type MethodFns<Base extends string, Acc extends readonly AnyLeafLowProfile[], I extends NodeCfg, PS extends ZodTypeAny | undefined, Used extends HttpMethod | 'add'> = {
24
+ type WithNodeDefaults<C extends MethodCfg, I extends NodeCfg> = Merge<C, {
25
+ queryExtensionSchema: C['queryExtensionSchema'] extends ZodTypeAny ? C['queryExtensionSchema'] : NodeQueryExtension<I>;
26
+ outputMetaSchema: C['outputMetaSchema'] extends ZodTypeAny ? C['outputMetaSchema'] : NodeOutputMeta<I>;
27
+ }>;
28
+ type EffectiveCfg<C extends MethodCfg, PS, I extends NodeCfg> = Prettify<Merge<MethodCfg, BaseMethodCfg<WithNodeDefaults<C, I>> & EffectiveFeedFields<WithNodeDefaults<C, I>, PS>>>;
29
+ type NodeWithoutSchemas<I extends NodeCfg> = Omit<I, 'queryExtensionSchema' | 'outputMetaSchema'>;
30
+ type BuiltLeaf<M extends HttpMethod, Base extends string, I extends NodeCfg, C extends MethodCfg, PS> = Leaf<M, Base, Merge<NodeWithoutSchemas<I>, LowProfileCfg<EffectiveCfg<C, PS, I>>>>;
31
+ type StripParamName<Name extends string> = Name extends `:${infer P}` ? P : Name;
32
+ type NormalizeBaseLiteral<Base extends string, HasParam extends boolean> = HasParam extends true ? Base extends `:${string}` ? Base : Base extends '' ? '' : `:${Base}` : Base;
33
+ type ParamSchemaForBase<Base extends string, Param extends ZodTypeAny | undefined> = Param extends ZodTypeAny ? ParamZod<StripParamName<Base>, Param> : undefined;
34
+ type ResourceBase<Base extends string, Param extends ZodTypeAny | undefined> = NormalizeBaseLiteral<Base, Param extends ZodTypeAny ? true : false>;
35
+ type NodeQueryExtension<I extends NodeCfg> = I extends {
36
+ queryExtensionSchema?: infer QE;
37
+ } ? QE extends ZodTypeAny ? QE : undefined : undefined;
38
+ type NodeOutputMeta<I extends NodeCfg> = I extends {
39
+ outputMetaSchema?: infer OE;
40
+ } ? OE extends ZodTypeAny ? OE : undefined : undefined;
41
+ type SubResourceCollections = readonly [
42
+ ReadonlyArray<AnyLeafLowProfile>,
43
+ ...ReadonlyArray<AnyLeafLowProfile>[]
44
+ ];
45
+ type MergeAugmentedCollections<Base extends string, Param extends ZodTypeAny | undefined, Collections extends ReadonlyArray<ReadonlyArray<AnyLeafLowProfile>>, Acc extends readonly AnyLeafLowProfile[] = []> = Collections extends readonly [infer First, ...infer Rest] ? First extends ReadonlyArray<AnyLeafLowProfile> ? Rest extends ReadonlyArray<ReadonlyArray<AnyLeafLowProfile>> ? MergeAugmentedCollections<Base, Param, Rest, MergeArray<Acc, AugmentLeaves<Base, Param, First>>> : MergeArray<Acc, AugmentLeaves<Base, Param, First>> : MergeAugmentedCollections<Base, Param, Rest extends ReadonlyArray<ReadonlyArray<AnyLeafLowProfile>> ? Rest : [], Acc> : Acc;
46
+ type MethodFns<Base extends string, Acc extends readonly AnyLeafLowProfile[], I extends NodeCfg, PS extends ZodTypeAny | undefined, Used extends HttpMethod | 'add' | 'sub'> = {
28
47
  /**
29
48
  * Register a GET leaf at the current path.
30
49
  */
@@ -55,24 +74,13 @@ type MethodFns<Base extends string, Acc extends readonly AnyLeafLowProfile[], I
55
74
  }>, PS>>>, I, PS, Used | 'delete'>;
56
75
  };
57
76
  /** Builder surface used by `resource(...)` to accumulate leaves. */
58
- export interface Branch<Base extends string, Acc extends readonly AnyLeafLowProfile[], I extends NodeCfg, PS extends ZodTypeAny | undefined, Used extends HttpMethod | 'add' = never> extends MethodFns<Base, Acc, I, PS, Used> {
59
- /**
60
- * Mount a static subtree under `name`.
61
- * The `leaves` are built externally via `resource(...)` and will be
62
- * rebased so that their paths become `${Base}/${name}${leaf.path}` and their
63
- * paramsSchemas are merged with the parameters already accumulated on this branch.
64
- */
65
- sub<Name extends string, const R extends readonly AnyLeafLowProfile[]>(name: Name, leaves: R): Branch<Base, MergeArray<Acc, AugmentLeaves<`${Base}/${Name}`, PS, R>>, I, PS, Used>;
66
- /**
67
- * Mount a static subtree under `name` and merge extra node-level config.
68
- */
69
- sub<Name extends string, J extends NodeCfg, const R extends readonly AnyLeafLowProfile[]>(name: Name, cfg: J, leaves: R): Branch<Base, MergeArray<Acc, AugmentLeaves<`${Base}/${Name}`, PS, R>>, Merge<I, J>, PS, Used>;
77
+ export interface Branch<Base extends string, Acc extends readonly AnyLeafLowProfile[], I extends NodeCfg, PS extends ZodTypeAny | undefined, Used extends HttpMethod | 'add' | 'sub' = never> extends MethodFns<Base, Acc, I, PS, Used> {
70
78
  /**
71
- * Introduce a `:param` segment and mount a pre-built subtree beneath it.
72
- * The subtree paths are rebased to `${Base}/:${Name}${leaf.path}` and
73
- * their paramsSchemas are intersected with the accumulated params plus this new param.
79
+ * Mount one or more sub-resources that were built via `resource(...)`.
80
+ * Each resource carries its own base path (e.g. `users`, `:userId`, `posts`)
81
+ * so this branch simply prefixes its current `Base` and merges param schemas.
74
82
  */
75
- subId<Name extends string, P extends ZodTypeAny, const R extends readonly AnyLeafLowProfile[]>(name: Name, paramsSchema: P, leaves: R): Branch<Base, MergeArray<Acc, AugmentLeaves<`${Base}/:${Name}`, MergedParamsResult<PS, Name, P>, R>>, I, PS, Used>;
83
+ sub: 'sub' extends Used ? never : <const Collections extends SubResourceCollections, NextAcc extends readonly AnyLeafLowProfile[] = MergeArray<Acc, MergeAugmentedCollections<Base, PS, Collections>>>(...collections: Collections) => Branch<Base, NextAcc, I, PS, Used | 'sub'>;
76
84
  /**
77
85
  * Finish the branch and return the collected leaves.
78
86
  * @returns Readonly tuple of accumulated leaves.
@@ -85,7 +93,7 @@ export interface Branch<Base extends string, Acc extends readonly AnyLeafLowProf
85
93
  * @param inherited Optional node configuration applied to all descendants.
86
94
  * @returns Root `Branch` instance used to compose the route tree.
87
95
  */
88
- export declare function resource<Base extends string = '', I extends NodeCfg = {}>(base?: Base, inherited?: I): Branch<Base, readonly [], I, undefined>;
96
+ export declare function resource<Base extends string = '', I extends NodeCfg = {}, Param extends ZodTypeAny | undefined = undefined>(base?: Base, inherited?: I, idSchema?: Param): Branch<ResourceBase<Base, Param>, readonly [], I, ParamSchemaForBase<Base, Param>>;
89
97
  /**
90
98
  * Merge two readonly tuples (preserves literal tuple information).
91
99
  * @param arr1 First tuple.
@@ -9,10 +9,16 @@ export type FileField = {
9
9
  maxCount: number;
10
10
  };
11
11
  /** Configuration that applies to an entire branch of the route tree. */
12
- export type NodeCfg = {
13
- /** @deprecated. Does nothing. */
14
- authenticated?: boolean;
15
- };
12
+ export interface NodeCfg {
13
+ /**
14
+ * Feed-specific query schema applied to all descendants unless they override it.
15
+ */
16
+ queryExtensionSchema?: ZodType;
17
+ /**
18
+ * Feed meta schema applied to all descendants unless they override it.
19
+ */
20
+ outputMetaSchema?: ZodType;
21
+ }
16
22
  export type RouteSchema<Output = unknown, Input = Output> = {
17
23
  __out: Output;
18
24
  __in?: Input;
@@ -52,7 +58,7 @@ export declare function mergeSchemas(a: ZodType | undefined, b: ZodType | undefi
52
58
  export declare function getZodShape(schema: ZodObject): any;
53
59
  export declare function collectNestedFieldSuggestions(shape: Record<string, ZodType> | undefined, prefix?: string[]): string[];
54
60
  export type ToRouteSchema<S> = S extends ZodType<infer Out, infer In> ? RouteSchema<Out, In> : S;
55
- export type LowProfileCfg<Cfg extends MethodCfg> = Prettify<Omit<Cfg, 'bodySchema' | 'querySchema' | 'paramsSchema' | 'outputSchema' | 'outputMetaSchema'> & {
61
+ export type LowProfileCfg<Cfg extends MethodCfg = MethodCfg> = Prettify<Omit<Cfg, 'bodySchema' | 'querySchema' | 'paramsSchema' | 'outputSchema' | 'outputMetaSchema'> & {
56
62
  bodySchema: ToRouteSchema<Cfg['bodySchema']>;
57
63
  querySchema: ToRouteSchema<Cfg['querySchema']>;
58
64
  paramsSchema: ToRouteSchema<Cfg['paramsSchema']>;
@@ -66,7 +72,7 @@ export type MethodCfg = {
66
72
  bodySchema?: ZodType;
67
73
  /** Zod schema describing the query string. */
68
74
  querySchema?: ZodType;
69
- /** Zod schema describing path params (Internal only, set through sub and subId). */
75
+ /** Zod schema describing path params (internal only, set through resource segments). */
70
76
  paramsSchema?: ZodType;
71
77
  /** Zod schema describing the response payload. */
72
78
  outputSchema?: ZodType;
@@ -131,12 +137,25 @@ export type Merge<A, B> = Prettify<Omit<A, keyof B> & B>;
131
137
  export type Append<T extends readonly unknown[], X> = [...T, X];
132
138
  /** Concatenate two readonly tuple types. */
133
139
  export type MergeArray<A extends readonly unknown[], B extends readonly unknown[]> = [...A, ...B];
140
+ type TrimTrailingSlash<S extends string> = S extends `${infer R}/` ? TrimTrailingSlash<R> : S;
141
+ type TrimLeadingSlash<S extends string> = S extends `/${infer R}` ? TrimLeadingSlash<R> : S;
142
+ type NormalizedBase<Base extends string> = TrimTrailingSlash<Base>;
143
+ type NormalizedChild<Child extends string> = TrimLeadingSlash<Child>;
144
+ export type JoinPath<Base extends string, Child extends string> = NormalizedBase<Base> extends '' ? Child : NormalizedChild<Child> extends '' ? NormalizedBase<Base> : `${NormalizedBase<Base>}/${NormalizedChild<Child>}`;
134
145
  export type IntersectZod<A extends ZodType | undefined, B extends ZodType | undefined> = B extends ZodType ? A extends ZodType ? z.ZodIntersection<A, B> : B : A extends ZodType ? A : undefined;
135
146
  type MergeRouteSchemas<Existing extends RouteSchema | undefined, Parent extends ZodType | undefined> = Existing extends RouteSchema<infer ExistingOut> ? Parent extends ZodType<infer ParentOut> ? RouteSchema<ExistingOut & ParentOut> : Existing : Parent extends ZodType<infer ParentOut> ? RouteSchema<ParentOut> : undefined;
136
147
  type AugmentedCfg<Cfg extends MethodCfgLowProfile, Param extends ZodType | undefined> = Prettify<Omit<Cfg, 'paramsSchema'> & {
137
148
  paramsSchema: MergeRouteSchemas<Cfg['paramsSchema'], Param>;
138
149
  }>;
139
- export type AugmentLeaves<P extends string, Param extends ZodType | undefined, R extends readonly LeafLowProfile[], Acc extends readonly LeafLowProfile[] = []> = R extends readonly [infer First, ...infer Rest] ? First extends LeafLowProfile ? AugmentLeaves<P, Param, Rest extends readonly LeafLowProfile[] ? Rest : [], Append<Acc, LeafLowProfile<First['method'], `${P}${First['path']}`, AugmentedCfg<First['cfg'], Param>>>> : never : Acc;
150
+ export type AugmentLeaves<P extends string, Param extends ZodType | undefined, R extends readonly LeafLowProfile[], Acc extends readonly LeafLowProfile[] = []> = R extends readonly [infer First, ...infer Rest] ? First extends LeafLowProfile ? AugmentLeaves<P, Param, Rest extends readonly LeafLowProfile[] ? Rest : [], Append<Acc, LeafLowProfile<First['method'], JoinPath<P, First['path']>, AugmentedCfg<First['cfg'], Param>>>> : never : Acc;
151
+ type NodeQueryExtension<Node> = Node extends {
152
+ queryExtensionSchema?: infer QE;
153
+ } ? QE extends ZodType ? QE : undefined : undefined;
154
+ type NodeOutputMeta<Node> = Node extends {
155
+ outputMetaSchema?: infer OM;
156
+ } ? OM extends ZodType ? OM : undefined : undefined;
157
+ type EffectiveQueryExtensionSchema<Method extends ZodType | undefined, Node extends NodeCfg | undefined> = Method extends ZodType ? Method : NodeQueryExtension<Node>;
158
+ type EffectiveOutputMetaSchema<Method extends ZodType | undefined, Node extends NodeCfg | undefined> = Method extends ZodType ? Method : NodeOutputMeta<Node>;
140
159
  type SegmentParams<S extends string> = S extends `:${infer P}` ? P : never;
141
160
  type Split<S extends string> = S extends '' ? [] : S extends `${infer A}/${infer B}` ? [A, ...Split<B>] : [S];
142
161
  type ExtractParamNames<Path extends string> = SegmentParams<Split<Path>[number]>;
@@ -149,11 +168,35 @@ export type ExtractParamsFromPath<Path extends string> = ExtractParamNames<Path>
149
168
  * @returns Path string with parameters substituted.
150
169
  */
151
170
  export declare function compilePath<Path extends string>(path: Path, params: ExtractParamsFromPath<Path>): string;
171
+ /**
172
+ * Build a deterministic cache key for the given leaf.
173
+ * The key matches the shape consumed by React Query helpers.
174
+ * @param args.leaf Leaf describing the endpoint.
175
+ * @param args.params Optional params used to build the path.
176
+ * @param args.query Optional query payload.
177
+ * @returns Tuple suitable for React Query cache keys.
178
+ */
179
+ type SplitPath<P extends string> = P extends '' ? [] : P extends `${infer A}/${infer B}` ? [A, ...SplitPath<B>] : [P];
180
+ type ParamValue<Params, Key extends string> = Params extends Record<Key, infer V> ? V : undefined;
181
+ type MapKeySegments<Segments extends readonly string[], Params> = Segments extends [infer A, ...infer Rest] ? A extends string ? A extends `:${infer Key}` ? [
182
+ [
183
+ ParamValue<Params, Key>
184
+ ],
185
+ ...MapKeySegments<Rest extends readonly string[] ? Rest : [], Params>
186
+ ] : [
187
+ A,
188
+ ...MapKeySegments<Rest extends readonly string[] ? Rest : [], Params>
189
+ ] : [] : [];
190
+ type CacheKeyForLeaf<L extends AnyLeafLowProfile> = readonly [
191
+ L['method'],
192
+ ...MapKeySegments<SplitPath<L['path']>, ExtractParamsFromPath<L['path']>>,
193
+ InferQuery<L> extends never ? {} : InferQuery<L>
194
+ ];
152
195
  export declare function buildCacheKey<L extends AnyLeafLowProfile>(args: {
153
196
  leaf: L;
154
197
  params?: ExtractParamsFromPath<L['path']>;
155
198
  query?: InferQuery<L>;
156
- }): readonly [HttpMethod, string, {}];
199
+ }): CacheKeyForLeaf<L>;
157
200
  /** Definition-time method config (for clarity). */
158
201
  export type MethodCfgDef = MethodCfg;
159
202
  /** Low-profile method config where schemas carry a phantom __out like SocketSchema. */
@@ -166,9 +209,10 @@ export type MethodCfgLowProfile = Omit<MethodCfg, 'bodySchema' | 'querySchema' |
166
209
  queryExtensionSchema?: RouteSchema;
167
210
  };
168
211
  export type AnyLeafLowProfile = LeafLowProfile<HttpMethod, string, MethodCfgLowProfile>;
169
- export declare function buildLowProfileLeaf<const M extends HttpMethod, const Path extends string, const O extends ZodType | undefined = undefined, const P extends ZodType | undefined = undefined, const Q extends ZodType | undefined = undefined, const B extends ZodType | undefined = undefined, const FO extends ZodType | undefined = undefined, const FQ extends ZodType | undefined = undefined, const Feed extends boolean = false>(leaf: {
212
+ export declare function buildLowProfileLeaf<const M extends HttpMethod, const Path extends string, const Node extends NodeCfg | undefined = undefined, const O extends ZodType | undefined = undefined, const P extends ZodType | undefined = undefined, const Q extends ZodType | undefined = undefined, const B extends ZodType | undefined = undefined, const FO extends ZodType | undefined = undefined, const FQ extends ZodType | undefined = undefined, const Feed extends boolean = false>(leaf: {
170
213
  method: M;
171
214
  path: Path;
215
+ node?: Node;
172
216
  cfg: Omit<MethodCfg, 'bodySchema' | 'querySchema' | 'paramsSchema' | 'outputSchema' | 'feed' | 'outputMetaSchema' | 'queryExtensionSchema'> & {
173
217
  feed?: Feed;
174
218
  bodySchema?: B;
@@ -183,15 +227,15 @@ export declare function buildLowProfileLeaf<const M extends HttpMethod, const Pa
183
227
  bodySchema: B extends ZodType<infer BOut, infer BIn> ? RouteSchema<BOut, BIn> : undefined;
184
228
  querySchema: Feed extends true ? FeedQueryField<{
185
229
  querySchema: Q;
186
- queryExtensionSchema: FQ;
230
+ queryExtensionSchema: EffectiveQueryExtensionSchema<FQ, Node>;
187
231
  }>['querySchema'] extends ZodType<infer QOut, infer QIn> ? RouteSchema<QOut, QIn> : never : Q extends ZodType<infer QOut, infer QIn> ? RouteSchema<QOut, QIn> : undefined;
188
232
  paramsSchema: P extends ZodType<infer POut, infer PIn> ? RouteSchema<POut, PIn> : undefined;
189
233
  outputSchema: OutputField<{
190
234
  outputSchema: O;
191
- outputMetaSchema: FO;
235
+ outputMetaSchema: EffectiveOutputMetaSchema<FO, Node>;
192
236
  }>['outputSchema'] extends ZodType<infer OOut, infer OIn> ? RouteSchema<OOut, OIn> : never;
193
- outputMetaSchema: FO extends ZodType<infer OOut, infer OIn> ? RouteSchema<OOut, OIn> : undefined;
194
- queryExtensionSchema: FQ extends ZodType<infer QOut, infer QIn> ? RouteSchema<QOut, QIn> : undefined;
237
+ outputMetaSchema: EffectiveOutputMetaSchema<FO, Node> extends ZodType<infer OOut, infer OIn> ? RouteSchema<OOut, OIn> : undefined;
238
+ queryExtensionSchema: EffectiveQueryExtensionSchema<FQ, Node> extends ZodType<infer QOut, infer QIn> ? RouteSchema<QOut, QIn> : undefined;
195
239
  }>>;
196
240
  export type LeafLowProfile<M extends HttpMethod = HttpMethod, P extends string = string, C extends MethodCfgLowProfile = MethodCfgLowProfile> = {
197
241
  /** Lowercase HTTP method (get/post/...). */
@@ -208,7 +252,16 @@ export type InferQuery<L extends AnyLeafLowProfile, Fallback = never> = L['cfg']
208
252
  /** Infer request body shape from a Zod schema when present. */
209
253
  export type InferBody<L extends AnyLeafLowProfile, Fallback = never> = L['cfg']['bodySchema'] extends RouteSchema<any, infer BIn> ? BIn : L['cfg']['bodySchema'] extends ZodType<any, infer BIn> ? BIn : Fallback;
210
254
  /** Infer handler output shape from a Zod schema. Defaults to unknown. */
211
- export type InferOutput<L extends AnyLeafLowProfile, Fallback = never> = L['cfg']['outputSchema'] extends RouteSchema<infer O> ? O : L['cfg']['outputSchema'] extends ZodType<infer O> ? O : Fallback;
255
+ type InferMetaFromCfg<L extends AnyLeafLowProfile> = L['cfg']['outputMetaSchema'] extends RouteSchema<infer O> ? O : L['cfg']['outputMetaSchema'] extends ZodType<infer O> ? O : string | undefined;
256
+ type ResolvedMeta<L extends AnyLeafLowProfile, M> = [M] extends [never] | [unknown] ? InferMetaFromCfg<L> : M;
257
+ type ApplyMetaFallback<L extends AnyLeafLowProfile, O> = O extends {
258
+ meta: infer M;
259
+ } ? (undefined extends ResolvedMeta<L, M> ? {
260
+ meta?: ResolvedMeta<L, M>;
261
+ } : {
262
+ meta: ResolvedMeta<L, M>;
263
+ }) & Omit<O, 'meta'> : O;
264
+ export type InferOutput<L extends AnyLeafLowProfile, Fallback = never> = L['cfg']['outputSchema'] extends RouteSchema<infer O> ? ApplyMetaFallback<L, O> : L['cfg']['outputSchema'] extends ZodType<infer O> ? ApplyMetaFallback<L, O> : Fallback;
212
265
  export type InferFeedOutputMeta<L extends AnyLeafLowProfile, Fallback = never> = L['cfg']['outputMetaSchema'] extends RouteSchema<infer O> ? O : L['cfg']['outputMetaSchema'] extends ZodType<infer O> ? O : Fallback;
213
266
  export type InferFeedQuery<L extends AnyLeafLowProfile, Fallback = never> = L['cfg']['queryExtensionSchema'] extends RouteSchema<any, infer QIn> ? QIn : L['cfg']['queryExtensionSchema'] extends ZodType<any, infer QIn> ? QIn : Fallback;
214
267
  /** Render a type as if it were a simple object literal. */
package/dist/index.cjs CHANGED
@@ -87,25 +87,31 @@ function compilePath(path, params) {
87
87
  });
88
88
  }
89
89
  function buildCacheKey(args) {
90
- let p = args.leaf.path;
91
- if (args.params) {
92
- p = compilePath(p, args.params);
90
+ const segments = args.leaf.path.split("/").filter(Boolean);
91
+ const keyParts = [args.leaf.method];
92
+ for (const segment of segments) {
93
+ if (segment.startsWith(":")) {
94
+ const paramName = segment.slice(1);
95
+ const value = args.params && paramName in args.params ? args.params[paramName] : void 0;
96
+ keyParts.push([value]);
97
+ continue;
98
+ }
99
+ keyParts.push(segment);
93
100
  }
94
- return [
95
- args.leaf.method,
96
- ...p.split("/").filter(Boolean),
97
- args.query ?? {}
98
- ];
101
+ keyParts.push(args.query ?? {});
102
+ return keyParts;
99
103
  }
100
104
  function buildLowProfileLeaf(leaf) {
101
- const effectiveQuerySchema = leaf.cfg.feed === true ? mergeSchemas(leaf.cfg.querySchema, leaf.cfg.queryExtensionSchema) : leaf.cfg.querySchema;
102
- const effectiveOutputSchema = leaf.cfg.outputSchema ? import_zod.z.object({
103
- out: leaf.cfg.outputSchema,
104
- meta: leaf.cfg.outputMetaSchema ?? import_zod.z.string().optional()
105
+ const nodeCfg = leaf.node ?? {};
106
+ const mergedCfg = { ...nodeCfg, ...leaf.cfg };
107
+ const effectiveQuerySchema = mergedCfg.feed === true ? mergeSchemas(mergedCfg.querySchema, mergedCfg.queryExtensionSchema) : mergedCfg.querySchema;
108
+ const effectiveOutputSchema = mergedCfg.outputSchema ? import_zod.z.object({
109
+ out: mergedCfg.outputSchema,
110
+ meta: mergedCfg.outputMetaSchema ?? import_zod.z.string().optional()
105
111
  }) : import_zod.z.object({
106
- meta: leaf.cfg.outputMetaSchema ?? import_zod.z.string().optional()
112
+ meta: mergedCfg.outputMetaSchema ?? import_zod.z.string().optional()
107
113
  });
108
- if (leaf.cfg.feed === true && effectiveQuerySchema instanceof import_zod.ZodObject) {
114
+ if (mergedCfg.feed === true && effectiveQuerySchema instanceof import_zod.ZodObject) {
109
115
  const shape = getZodShape(effectiveQuerySchema);
110
116
  const nestedFieldSuggestions = collectNestedFieldSuggestions(shape);
111
117
  if (nestedFieldSuggestions.length > 0) {
@@ -117,41 +123,41 @@ function buildLowProfileLeaf(leaf) {
117
123
  }
118
124
  }
119
125
  return {
120
- ...leaf,
126
+ method: leaf.method,
127
+ path: leaf.path,
121
128
  cfg: {
122
- ...leaf.cfg,
123
- bodySchema: leaf.cfg.bodySchema,
129
+ ...mergedCfg,
130
+ bodySchema: mergedCfg.bodySchema,
124
131
  querySchema: effectiveQuerySchema,
125
- paramsSchema: leaf.cfg.paramsSchema,
132
+ paramsSchema: mergedCfg.paramsSchema,
126
133
  outputSchema: effectiveOutputSchema,
127
- outputMetaSchema: leaf.cfg.outputMetaSchema,
128
- queryExtensionSchema: leaf.cfg.queryExtensionSchema
134
+ outputMetaSchema: mergedCfg.outputMetaSchema,
135
+ queryExtensionSchema: mergedCfg.queryExtensionSchema
129
136
  }
130
137
  };
131
138
  }
132
139
 
133
140
  // src/core/routesV3.builder.ts
134
- function resource(base, inherited) {
135
- const rootBase = base ?? "";
136
- const rootInherited = { ...inherited };
141
+ function resource(base, inherited, idSchema) {
142
+ const rawBase = base ?? "";
143
+ const normalizedBase = normalizeBaseSegment(
144
+ rawBase,
145
+ Boolean(idSchema)
146
+ );
147
+ const rootInherited = inherited === void 0 ? {} : { ...inherited };
148
+ const rootParams = createParamSchema(rawBase, idSchema);
137
149
  function makeBranch(base2, inherited2, mergedParamsSchema) {
138
150
  const stack = [];
139
- let currentBase = base2;
140
- let inheritedCfg = { ...inherited2 };
151
+ const currentBase = base2;
152
+ const inheritedCfg = inherited2;
141
153
  let currentParamsSchema = mergedParamsSchema;
142
154
  function add(method, cfg) {
143
155
  const effectiveParamsSchema = cfg.paramsSchema ?? currentParamsSchema;
144
- const fullCfg = effectiveParamsSchema ? {
145
- ...inheritedCfg,
146
- ...cfg,
147
- paramsSchema: effectiveParamsSchema
148
- } : {
149
- ...inheritedCfg,
150
- ...cfg
151
- };
156
+ const fullCfg = effectiveParamsSchema ? { ...cfg, paramsSchema: effectiveParamsSchema } : { ...cfg };
152
157
  const leaf = buildLowProfileLeaf({
153
158
  method,
154
159
  path: currentBase,
160
+ node: inheritedCfg,
155
161
  cfg: fullCfg
156
162
  });
157
163
  stack.push(leaf);
@@ -159,88 +165,42 @@ function resource(base, inherited) {
159
165
  }
160
166
  const api = {
161
167
  /**
162
- * Mount a subtree built elsewhere.
163
- *
168
+ * Mount one or more subtrees built elsewhere.
164
169
  * Usage:
165
- * const users = resource('').get(...).done()
166
- * resource('/api').sub('users', users).done()
170
+ * resource('/api').sub(
171
+ * resource('users').get(...).done(),
172
+ * resource('projects').get(...).done()
173
+ * )
167
174
  */
168
- sub(name, cfgOrLeaves, maybeLeaves) {
169
- let cfg;
170
- let leaves;
171
- if (Array.isArray(cfgOrLeaves)) {
172
- leaves = cfgOrLeaves;
173
- } else {
174
- cfg = cfgOrLeaves;
175
- leaves = maybeLeaves;
176
- }
177
- if (!leaves) {
178
- throw new Error("sub() expects a leaves array as the last argument");
179
- }
180
- const childInherited = {
181
- ...inheritedCfg,
182
- ...cfg ?? {}
183
- };
184
- const baseForChildren = `${currentBase}/${name}`;
185
- for (const leafLow of leaves) {
186
- const leaf = leafLow;
187
- const leafCfg = leaf.cfg;
188
- const leafParams = leafCfg.paramsSchema;
189
- const effectiveParams = mergeSchemas(
190
- currentParamsSchema,
191
- leafParams
192
- );
193
- const newCfg = {
194
- ...childInherited,
195
- ...leafCfg
196
- };
197
- if (effectiveParams) {
198
- newCfg.paramsSchema = effectiveParams;
199
- } else if ("paramsSchema" in newCfg) {
200
- delete newCfg.paramsSchema;
201
- }
202
- const newLeaf = {
203
- method: leaf.method,
204
- path: `${baseForChildren}${leaf.path}`,
205
- cfg: newCfg
206
- };
207
- stack.push(newLeaf);
175
+ sub(...collections) {
176
+ if (collections.length === 0) {
177
+ throw new Error("sub() expects at least one resource");
208
178
  }
209
- return api;
210
- },
211
- /**
212
- * Introduce a :param segment and mount a subtree under it.
213
- *
214
- * The subtree is built independently (e.g. resource('').get(...).done())
215
- * and its paths become `${currentBase}/:${name}${leaf.path}`.
216
- * Params schemas are intersected with the accumulated params plus the new param.
217
- */
218
- subId(name, paramsSchema, leaves) {
219
- const paramObj = import_zod2.z.object({
220
- [name]: paramsSchema
221
- });
222
- const mergedParams = currentParamsSchema ? mergeSchemas(currentParamsSchema, paramObj) : paramObj;
223
- const baseForChildren = `${currentBase}/:${name}`;
224
- for (const leafLow of leaves) {
225
- const leaf = leafLow;
226
- const leafCfg = leaf.cfg;
227
- const leafParams = leafCfg.paramsSchema;
228
- const effectiveParams = mergeSchemas(mergedParams, leafParams);
229
- const newCfg = {
230
- ...inheritedCfg,
231
- ...leafCfg
232
- };
233
- if (effectiveParams) {
234
- newCfg.paramsSchema = effectiveParams;
235
- } else if ("paramsSchema" in newCfg) {
236
- delete newCfg.paramsSchema;
179
+ for (const leaves of collections) {
180
+ for (const leafLow of leaves) {
181
+ const leaf = leafLow;
182
+ const leafCfg = leaf.cfg;
183
+ const leafParams = leafCfg.paramsSchema;
184
+ const effectiveParams = mergeSchemas(
185
+ currentParamsSchema,
186
+ leafParams
187
+ );
188
+ const newCfg = {
189
+ ...inheritedCfg,
190
+ ...leafCfg
191
+ };
192
+ if (effectiveParams) {
193
+ newCfg.paramsSchema = effectiveParams;
194
+ } else if ("paramsSchema" in newCfg) {
195
+ delete newCfg.paramsSchema;
196
+ }
197
+ const newLeaf = {
198
+ method: leaf.method,
199
+ path: joinPaths(currentBase, leaf.path),
200
+ cfg: newCfg
201
+ };
202
+ stack.push(newLeaf);
237
203
  }
238
- const newLeaf = {
239
- method: leaf.method,
240
- path: `${baseForChildren}${leaf.path}`,
241
- cfg: newCfg
242
- };
243
- stack.push(newLeaf);
244
204
  }
245
205
  return api;
246
206
  },
@@ -266,11 +226,47 @@ function resource(base, inherited) {
266
226
  };
267
227
  return api;
268
228
  }
269
- return makeBranch(rootBase, rootInherited, void 0);
229
+ return makeBranch(normalizedBase, rootInherited, rootParams);
270
230
  }
271
231
  var mergeArrays = (arr1, arr2) => {
272
232
  return [...arr1, ...arr2];
273
233
  };
234
+ function normalizeBaseSegment(base, hasParam) {
235
+ const value = base ?? "";
236
+ if (!hasParam) return value;
237
+ if (!value) {
238
+ throw new Error("resource() requires a segment name when defining an id schema");
239
+ }
240
+ if (value.includes("/")) {
241
+ const segments = value.split("/").filter(Boolean);
242
+ if (segments.length === 0) {
243
+ throw new Error("resource() received an invalid segment for param injection");
244
+ }
245
+ const lastIndex = segments.length - 1;
246
+ const last = segments[lastIndex];
247
+ segments[lastIndex] = last.startsWith(":") ? last : `:${last}`;
248
+ return `${value.startsWith("/") ? "/" : ""}${segments.join("/")}`;
249
+ }
250
+ return value.startsWith(":") ? value : `:${value}`;
251
+ }
252
+ function createParamSchema(nameSegment, schema) {
253
+ if (!schema) return void 0;
254
+ const segments = nameSegment.split("/").filter(Boolean);
255
+ const rawName = segments.length > 0 ? segments[segments.length - 1] : nameSegment;
256
+ const normalized = rawName.startsWith(":") ? rawName.slice(1) : rawName;
257
+ if (!normalized) {
258
+ throw new Error("resource() requires a non-empty name for id schema");
259
+ }
260
+ return import_zod2.z.object({ [normalized]: schema });
261
+ }
262
+ function joinPaths(parent, child) {
263
+ if (!parent) return child;
264
+ if (!child) return parent;
265
+ const trimmedParent = parent.endsWith("/") ? parent.replace(/\/+$/, "") : parent;
266
+ const trimmedChild = child.startsWith("/") ? child.replace(/^\/+/, "") : child;
267
+ if (!trimmedChild) return trimmedParent;
268
+ return `${trimmedParent}/${trimmedChild}`;
269
+ }
274
270
 
275
271
  // src/core/routesV3.finalize.ts
276
272
  function finalize(leaves) {