@rvoh/psychic 3.3.0 → 3.4.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.
@@ -208,6 +208,7 @@ export class OpenApiSpecDiff {
208
208
  return cp.execFileSync('git', ['show', `${branchRef}:${gitPath}`], {
209
209
  encoding: 'utf8',
210
210
  cwd: gitRepoRoot,
211
+ maxBuffer: 1024 * 1024 * 50,
211
212
  });
212
213
  }
213
214
  /**
@@ -8,7 +8,7 @@ import generateOpenapiZustandBindings from '../generate/openapi/zustandBindings.
8
8
  import generateResource from '../generate/resource.js';
9
9
  import Watcher from '../watcher/Watcher.js';
10
10
  const INDENT = ' ';
11
- const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name) properties like this:
11
+ const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name, which may take an @alias suffix to rename the FK) properties like this:
12
12
  ${INDENT} title:citext subtitle:string body_markdown:text style:enum:post_styles:formal,informal User:belongs_to
13
13
  ${INDENT}
14
14
  ${INDENT}all properties default to not nullable; null can be allowed by appending ':optional':
@@ -68,7 +68,16 @@ ${INDENT}
68
68
  ${INDENT} use the fully qualified model name (matching its path under src/app/models/):
69
69
  ${INDENT} User:belongs_to # creates user_id column + BelongsTo association
70
70
  ${INDENT} Health/Coach:belongs_to # creates health_coach_id column + BelongsTo association
71
- ${INDENT} User:belongs_to:optional # nullable foreign key (for optional associations)`;
71
+ ${INDENT} User:belongs_to:optional # nullable foreign key (for optional associations)
72
+ ${INDENT}
73
+ ${INDENT} rename the association with Model@alias — the snake_case alias drives the FK column name AND the
74
+ ${INDENT} @deco.BelongsTo association + typed FK property on the generated model:
75
+ ${INDENT} InternalUser@canceled_by:belongs_to:optional # canceled_by_id column, canceledById property, canceledBy association,
76
+ ${INDENT} # @deco.BelongsTo('InternalUser', { on: 'canceledById', optional: true })
77
+ ${INDENT} Messaging/Template@template:belongs_to # template_id column, templateId property, template association
78
+ ${INDENT} # (strips the namespace from the property/association names while keeping
79
+ ${INDENT} # the namespaced model reference intact)
80
+ ${INDENT} Aliasing also lets you declare multiple FKs to the same model in one generator call without column collisions.`;
72
81
  export default class PsychicCLI {
73
82
  static provide(program, { initializePsychicApp, seedDb, }) {
74
83
  DreamCLI.generateDreamCli(program, {
@@ -141,8 +150,6 @@ ${INDENT}Example:
141
150
  ${INDENT} pnpm psy g:resource --model-name=GroupDanceLesson v1/lessons/dance/groups Lesson/Dance/Group
142
151
  ${INDENT} # model is named GroupDanceLesson instead of LessonDanceGroup`)
143
152
  .option('--no-soft-delete', `skip generating the @SoftDelete() decorator and the corresponding nullable \`deleted_at\` column. By default, generated models use soft-delete semantics (rows are marked deleted via \`deleted_at\` instead of being removed from the database). Pass this flag when you want records to be hard-deleted.`)
144
- .option('--with-extract-params', `override the default scaffold to emit \`this.extractParams(Model, [...])\` (explicit allowlist). Only valid for admin-namespaced resources, which otherwise default to \`this.extractImplicitParams(Model)\`. Mutually exclusive with --with-extract-implicit-params.`, false)
145
- .option('--with-extract-implicit-params', `override the default scaffold to emit \`this.extractImplicitParams(Model)\` (model-declared allowlist). Only useful for non-admin-namespaced resources, which otherwise default to \`this.extractParams(Model, [...])\`. Mutually exclusive with --with-extract-params.`, false)
146
153
  .argument('<path>', `The URL path for this resource's routes, relative to the root domain. Use \`\\{\\}\` as a placeholder for a parent resource's ID parameter when nesting.
147
154
  ${INDENT}
148
155
  ${INDENT}The path determines the controller namespace hierarchy. Paths that begin with "admin" and "internal" remove the \`currentUser\` scoping of queries (\`--owning-model\` may be provided to apply query scoping). Each segment maps to a directory level in the controllers folder.
@@ -403,13 +403,13 @@ export default class PsychicController {
403
403
  return this._castParam(keys, nestedParams, expectedType, opts);
404
404
  }
405
405
  /**
406
- * @deprecated Prefer {@link extractParams} (explicit allowlist at the call
407
- * site) or {@link extractImplicitParams} (same implicit-allowlist behavior,
408
- * new name). Security-relevant: on models with permission-bearing fields,
409
- * `paramsFor(Model)` without `only` extracts every column in
410
- * `paramSafeColumnsOrFallback()` — which may be too broad unless the model
411
- * declares `paramSafeColumns` explicitly. `extractParams` makes the
412
- * allowlist visible to reviewers at the call site.
406
+ * @deprecated Prefer {@link extractParams} — the explicit-allowlist form
407
+ * keeps the permitted columns visible at the call site. Security-relevant:
408
+ * on models with permission-bearing fields, `paramsFor(Model)` without
409
+ * `only` extracts every column in `paramSafeColumnsOrFallback()` — which
410
+ * may be too broad unless the model declares `paramSafeColumns` explicitly.
411
+ * `extractParams` makes the allowlist visible to reviewers at the call
412
+ * site.
413
413
  *
414
414
  * Captures and validates parameters for the provided Dream model. Will
415
415
  * exclude parameters that are not considered "safe" by default (based on
@@ -429,11 +429,7 @@ export default class PsychicController {
429
429
  * ```ts
430
430
  * class MyController extends ApplicationController {
431
431
  * public create() {
432
- * // Preferred: explicit allowlist via extractParams
433
432
  * const safe = this.extractParams(User, ['email', 'name'])
434
- *
435
- * // Equivalent of the legacy `this.paramsFor(User)` under the new name:
436
- * const viaModel = this.extractImplicitParams(User)
437
433
  * }
438
434
  * }
439
435
  * ```
@@ -458,10 +454,6 @@ export default class PsychicController {
458
454
  * `paramSafeColumnsOrFallback()` further strips anything a caller bypasses
459
455
  * the type system to include.
460
456
  *
461
- * Use {@link extractImplicitParams} when the model's declared
462
- * `paramSafeColumns` is the canonical allowlist and duplicating it at each
463
- * call site would create drift.
464
- *
465
457
  * @param dreamClass - The Dream model class to retrieve params for
466
458
  * @param allowed - Required. The columns permitted from the request.
467
459
  * @param opts - Optional configuration
@@ -485,39 +477,6 @@ export default class PsychicController {
485
477
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
486
478
  return Params.extract(source, dreamClass, allowed, opts);
487
479
  }
488
- /**
489
- * Captures and validates parameters for the provided Dream model using the
490
- * model's declared `paramSafeColumns` (or, when undeclared, the framework's
491
- * default-safe fallback from `paramSafeColumnsOrFallback()`).
492
- *
493
- * Prefer {@link extractParams} when the caller can enumerate the allowed
494
- * columns at the call site — explicit allowlists are more visible to
495
- * reviewers. Reach for `extractImplicitParams` when the model-level
496
- * declaration is the canonical allowlist and you want to avoid duplicating
497
- * it at every call site.
498
- *
499
- * @param dreamClass - The Dream model class to retrieve params for
500
- * @param opts - Optional configuration
501
- * @param opts.key - Extract params from a nested key in the params object instead of root level
502
- * @param opts.array - If true, expects and returns an array of param objects
503
- * @returns A typed object containing the validated and casted params
504
- * @throws {ParamValidationError} When any parameter validation fails
505
- *
506
- * @example
507
- * ```ts
508
- * class AdminBalloonsController extends ApplicationController {
509
- * public create() {
510
- * const params = this.extractImplicitParams(Balloon)
511
- * const balloon = await Balloon.create(params)
512
- * }
513
- * }
514
- * ```
515
- */
516
- extractImplicitParams(dreamClass, opts) {
517
- const source = opts?.key ? this.params[opts.key] || {} : this.params;
518
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
519
- return Params.extractImplicit(source, dreamClass, opts);
520
- }
521
480
  /**
522
481
  * Gets a cookie value from the request and casts it to the specified type.
523
482
  *
@@ -9,7 +9,7 @@ import psychicPath from '../helpers/path/psychicPath.js';
9
9
  import generateControllerContent from './helpers/generateControllerContent.js';
10
10
  import generateControllerSpecContent from './helpers/generateControllerSpecContent.js';
11
11
  import generateResourceControllerSpecContent from './helpers/generateResourceControllerSpecContent.js';
12
- export default async function generateController({ fullyQualifiedControllerName, fullyQualifiedModelName, actions, columnsWithTypes = [], resourceSpecs = false, owningModel, singular, paramExtractionStrategy, }) {
12
+ export default async function generateController({ fullyQualifiedControllerName, fullyQualifiedModelName, actions, columnsWithTypes = [], resourceSpecs = false, owningModel, singular, }) {
13
13
  fullyQualifiedModelName = fullyQualifiedModelName
14
14
  ? DreamApp.system.standardizeFullyQualifiedModelName(fullyQualifiedModelName)
15
15
  : fullyQualifiedModelName;
@@ -71,7 +71,6 @@ export default async function generateController({ fullyQualifiedControllerName,
71
71
  forInternal,
72
72
  singular,
73
73
  columnsWithTypes,
74
- paramExtractionStrategy,
75
74
  }));
76
75
  }
77
76
  catch (error) {
@@ -2,36 +2,14 @@ import { DreamApp } from '@rvoh/dream';
2
2
  import { camelize, hyphenize } from '@rvoh/dream/utils';
3
3
  import pluralize from 'pluralize-esm';
4
4
  import paramSafeColumnNamesFromCliTokens from './paramSafeColumnNamesFromCliTokens.js';
5
- export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions = [], omitOpenApi = false, owningModel, forAdmin, forInternal = false, singular, columnsWithTypes = [], paramExtractionStrategy, }) {
6
- // Admin scaffolds lean on the model's declared paramSafeColumns (implicit);
7
- // non-admin scaffolds require an explicit allowlist at the call site so the
8
- // permitted columns are visible to reviewers. Either default can be overridden
9
- // via the `--with-extract-params` / `--with-extract-implicit-params` CLI flags,
10
- // which get materialized into `paramExtractionStrategy` by the caller.
11
- const resolvedExtractionStrategy = paramExtractionStrategy ?? (forAdmin ? 'implicit' : 'explicit');
12
- /**
13
- * Returns the `this.extract*Params(...)` expression that replaces the legacy
14
- * `this.paramsFor(Model)` in the scaffold's commented hints. Does NOT include
15
- * the outer closing paren that the surrounding call expects (e.g. the one
16
- * closing `update(...)` or `create(...)` or `createAssociation(...)`); the
17
- * caller appends that as part of its own template.
18
- */
19
- const extractCallExpression = (modelClass) => {
20
- if (resolvedExtractionStrategy === 'implicit') {
21
- return `this.extractImplicitParams(${modelClass})`;
22
- }
23
- const safeColumns = paramSafeColumnNamesFromCliTokens(columnsWithTypes);
24
- const serializedSafeColumns = safeColumns.length
25
- ? `[${safeColumns.map(name => `'${name}'`).join(', ')}]`
26
- : '[]';
27
- // The emitted list contains every implicitly-allowed column. When
28
- // uncommenting the action body, the developer or agent is responsible
29
- // for narrowing it down to only the columns this action should actually
30
- // accept.
31
- return `this.extractParams(${modelClass},
32
- // ${serializedSafeColumns},
33
- // )`;
34
- };
5
+ export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions = [], omitOpenApi = false, owningModel, forAdmin, forInternal = false, singular, columnsWithTypes = [], }) {
6
+ // The scaffold emits a `paramSafeColumns` const at the top of the file
7
+ // (alongside `openApiTags`) and references it from both the `create` and
8
+ // `update` action hints. The list contains every implicitly-allowed column;
9
+ // when uncommenting the action body, the developer or agent is responsible
10
+ // for narrowing the const down to only the columns the actions should
11
+ // actually accept.
12
+ const extractCallExpression = (modelClass) => `this.extractParams(${modelClass}, paramSafeColumns)`;
35
13
  fullyQualifiedControllerName = DreamApp.system.standardizeFullyQualifiedModelName(fullyQualifiedControllerName);
36
14
  const additionalImports = [];
37
15
  const controllerClassName = DreamApp.system.globalClassNameFromFullyQualifiedModelName(fullyQualifiedControllerName);
@@ -70,6 +48,9 @@ export default function generateControllerContent({ ancestorName, ancestorImport
70
48
  tags: openApiTags,
71
49
  description: 'Create ${aOrAnDreamModelName(modelClassName)}',${defaultOpenapiSerializerKeyProperty}
72
50
  fastJsonStringify: true,
51
+ requestBody: {
52
+ only: paramSafeColumns,
53
+ },
73
54
  })
74
55
  public async create() {
75
56
  // let ${modelAttributeName} = await ${useDirectModelAccess ? `${modelClassName}.create(` : `this.${owningModelProperty}.createAssociation('${pluralizedModelAttributeName}', `}${extractCallExpression(modelClassName)})
@@ -146,6 +127,9 @@ export default function generateControllerContent({ ancestorName, ancestorImport
146
127
  tags: openApiTags,
147
128
  description: 'Update ${aOrAnDreamModelName(modelClassName)}',
148
129
  fastJsonStringify: true,
130
+ requestBody: {
131
+ only: paramSafeColumns,
132
+ },
149
133
  })
150
134
  public async update() {
151
135
  // const ${modelAttributeName} = await this.${modelAttributeName}()
@@ -213,8 +197,20 @@ export default function generateControllerContent({ ancestorName, ancestorImport
213
197
  });
214
198
  const openApiImport = `import { OpenAPI } from '@rvoh/psychic'`;
215
199
  const openApiTags = `const openApiTags = ['${hyphenize(pluralizedModelAttributeName || controllerClassName.replace(/Controller$/, ''))}']`;
200
+ const emitParamSafeColumns = !!modelClassName && actions.some(action => action === 'create' || action === 'update');
201
+ const safeColumns = emitParamSafeColumns ? paramSafeColumnNamesFromCliTokens(columnsWithTypes) : [];
202
+ // The const is typed against the model's safe-column names rather than
203
+ // `as const`, so editing the array literal gives the developer (or agent)
204
+ // autocomplete of valid columns and a compile error on anything that is
205
+ // not param-safe — directly in the assignment, no call-site round-trip.
206
+ const paramSafeColumnsDecl = !emitParamSafeColumns
207
+ ? ''
208
+ : `\n\nconst paramSafeColumns: DreamParamSafeColumnNames<${modelClassName}>[] = [${safeColumns.map(name => `'${name}'`).join(', ')}]`;
209
+ const dreamTypesImport = emitParamSafeColumns
210
+ ? `import { DreamParamSafeColumnNames } from '@rvoh/dream/types'\n`
211
+ : '';
216
212
  return `\
217
- ${omitOpenApi ? '' : openApiImport + '\n'}${ancestorImportStatement}${additionalImports.length ? '\n' + additionalImports.join('\n') : ''}${omitOpenApi ? '' : '\n\n' + openApiTags}
213
+ ${omitOpenApi ? '' : openApiImport + '\n' + dreamTypesImport}${ancestorImportStatement}${additionalImports.length ? '\n' + additionalImports.join('\n') : ''}${omitOpenApi ? '' : '\n\n' + openApiTags}${paramSafeColumnsDecl}
218
214
 
219
215
  export default class ${controllerClassName} extends ${ancestorName} {
220
216
  ${methodDefs.join('\n\n')}${modelClassName ? privateMethods(forAdmin, forInternal, modelClassName, actions, loadQueryBase, singular) : ''}
@@ -9,16 +9,46 @@ import { camelize } from '@rvoh/dream/utils';
9
9
  * paramSafe column allowlist) share one canonical interpretation of the
10
10
  * tokens — and one canonical casing (camelCase) for the resulting attribute
11
11
  * name.
12
+ *
13
+ * Supports the `Model@alias:belongs_to` shorthand for renaming the FK
14
+ * association. When `@alias` is present, the camelized alias becomes
15
+ * `attributeName`; otherwise (legacy form `Model:belongs_to`) the
16
+ * attribute name is derived from the model's last namespace segment.
17
+ *
18
+ * Mirrors Dream's CLI-side `parseAttribute` (kept as a parallel
19
+ * implementation rather than a shared import so neither package leaks an
20
+ * internal CLI helper through its public API surface).
12
21
  */
13
22
  export default function parseAttribute(attribute) {
14
- const [rawAttributeName, rawAttributeType, , enumValues] = attribute.split(':');
15
- if (!rawAttributeName || !rawAttributeType)
23
+ const segments = attribute.split(':');
24
+ const rawSegmentOne = segments[0];
25
+ const rawAttributeType = segments[1];
26
+ if (!rawSegmentOne || !rawAttributeType)
16
27
  return null;
28
+ // Extract optional `@alias` from segment-1 (used by the belongs_to FK alias
29
+ // shorthand, e.g., `InternalUser@canceled_by:belongs_to`).
30
+ let rawAttributeName = rawSegmentOne;
31
+ let aliasName;
32
+ const atIndex = rawSegmentOne.indexOf('@');
33
+ if (atIndex !== -1) {
34
+ rawAttributeName = rawSegmentOne.slice(0, atIndex);
35
+ const rawAlias = rawSegmentOne.slice(atIndex + 1);
36
+ if (!rawAttributeName || !rawAlias)
37
+ return null;
38
+ aliasName = rawAlias;
39
+ }
40
+ // Pop trailing `optional` keyword from descriptors so it doesn't get
41
+ // mistaken for enum values or other positional descriptors.
42
+ const descriptors = segments.slice(2);
43
+ if (descriptors[descriptors.length - 1] === 'optional')
44
+ descriptors.pop();
45
+ const enumValues = descriptors[1];
17
46
  const sanitizedAttrType = camelize(rawAttributeType)?.toLowerCase();
18
47
  // Handle belongs_to relationships
19
48
  if (sanitizedAttrType === 'belongsto') {
20
- // For belongs_to relationships, convert "Ticketing/Ticket" to "ticket"
21
- const attributeName = camelize(rawAttributeName.split('/').pop());
49
+ // For belongs_to relationships, prefer the explicit alias when present;
50
+ // otherwise convert "Ticketing/Ticket" → "ticket".
51
+ const attributeName = aliasName ? camelize(aliasName) : camelize(rawAttributeName.split('/').pop());
22
52
  return { attributeName, attributeType: 'belongs_to', isArray: false, enumValues };
23
53
  }
24
54
  // Skip _type and _id columns, but not belongs_to relationships
@@ -32,14 +32,6 @@ export default async function generateResource({ route, fullyQualifiedModelName,
32
32
  softDelete: options.softDelete,
33
33
  },
34
34
  });
35
- if (options.withExtractParams && options.withExtractImplicitParams) {
36
- throw new Error('--with-extract-params and --with-extract-implicit-params are mutually exclusive; pass only one.');
37
- }
38
- const paramExtractionStrategy = options.withExtractParams
39
- ? 'explicit'
40
- : options.withExtractImplicitParams
41
- ? 'implicit'
42
- : undefined;
43
35
  await generateController({
44
36
  fullyQualifiedControllerName,
45
37
  fullyQualifiedModelName,
@@ -50,7 +42,6 @@ export default async function generateResource({ route, fullyQualifiedModelName,
50
42
  resourceSpecs: true,
51
43
  singular: options.singular,
52
44
  owningModel: options.owningModel,
53
- paramExtractionStrategy,
54
45
  });
55
46
  await addResourceToRoutes(route, {
56
47
  singular: options.singular,
@@ -108,25 +108,6 @@ export default class Params {
108
108
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
109
109
  return Params.for(params, dreamClass, { ...(opts ?? {}), only: allowed });
110
110
  }
111
- /**
112
- * ### .extractImplicit
113
- *
114
- * Implicit-allowlist extraction driven by the model's `paramSafeColumns`
115
- * declaration (or, when undeclared, the framework's default-safe fallback
116
- * from `paramSafeColumnsOrFallback()`). Equivalent to {@link Params.for}
117
- * without `only`. Use from a controller via
118
- * {@link PsychicController.extractImplicitParams}.
119
- *
120
- * Prefer {@link Params.extract} when the caller can enumerate the allowed
121
- * columns at the call site — it makes the allowlist visible to reviewers
122
- * at the point of use. Reach for this method when the model-level
123
- * `paramSafeColumns` declaration is the canonical allowlist and duplicating
124
- * it at each call site would create maintenance drift.
125
- */
126
- static extractImplicit(params, dreamClass, opts) {
127
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
128
- return Params.for(params, dreamClass, (opts ?? {}));
129
- }
130
111
  static restrict(params, allowed) {
131
112
  if (params === null || params === undefined)
132
113
  return {};
@@ -208,6 +208,7 @@ export class OpenApiSpecDiff {
208
208
  return cp.execFileSync('git', ['show', `${branchRef}:${gitPath}`], {
209
209
  encoding: 'utf8',
210
210
  cwd: gitRepoRoot,
211
+ maxBuffer: 1024 * 1024 * 50,
211
212
  });
212
213
  }
213
214
  /**
@@ -8,7 +8,7 @@ import generateOpenapiZustandBindings from '../generate/openapi/zustandBindings.
8
8
  import generateResource from '../generate/resource.js';
9
9
  import Watcher from '../watcher/Watcher.js';
10
10
  const INDENT = ' ';
11
- const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name) properties like this:
11
+ const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name, which may take an @alias suffix to rename the FK) properties like this:
12
12
  ${INDENT} title:citext subtitle:string body_markdown:text style:enum:post_styles:formal,informal User:belongs_to
13
13
  ${INDENT}
14
14
  ${INDENT}all properties default to not nullable; null can be allowed by appending ':optional':
@@ -68,7 +68,16 @@ ${INDENT}
68
68
  ${INDENT} use the fully qualified model name (matching its path under src/app/models/):
69
69
  ${INDENT} User:belongs_to # creates user_id column + BelongsTo association
70
70
  ${INDENT} Health/Coach:belongs_to # creates health_coach_id column + BelongsTo association
71
- ${INDENT} User:belongs_to:optional # nullable foreign key (for optional associations)`;
71
+ ${INDENT} User:belongs_to:optional # nullable foreign key (for optional associations)
72
+ ${INDENT}
73
+ ${INDENT} rename the association with Model@alias — the snake_case alias drives the FK column name AND the
74
+ ${INDENT} @deco.BelongsTo association + typed FK property on the generated model:
75
+ ${INDENT} InternalUser@canceled_by:belongs_to:optional # canceled_by_id column, canceledById property, canceledBy association,
76
+ ${INDENT} # @deco.BelongsTo('InternalUser', { on: 'canceledById', optional: true })
77
+ ${INDENT} Messaging/Template@template:belongs_to # template_id column, templateId property, template association
78
+ ${INDENT} # (strips the namespace from the property/association names while keeping
79
+ ${INDENT} # the namespaced model reference intact)
80
+ ${INDENT} Aliasing also lets you declare multiple FKs to the same model in one generator call without column collisions.`;
72
81
  export default class PsychicCLI {
73
82
  static provide(program, { initializePsychicApp, seedDb, }) {
74
83
  DreamCLI.generateDreamCli(program, {
@@ -141,8 +150,6 @@ ${INDENT}Example:
141
150
  ${INDENT} pnpm psy g:resource --model-name=GroupDanceLesson v1/lessons/dance/groups Lesson/Dance/Group
142
151
  ${INDENT} # model is named GroupDanceLesson instead of LessonDanceGroup`)
143
152
  .option('--no-soft-delete', `skip generating the @SoftDelete() decorator and the corresponding nullable \`deleted_at\` column. By default, generated models use soft-delete semantics (rows are marked deleted via \`deleted_at\` instead of being removed from the database). Pass this flag when you want records to be hard-deleted.`)
144
- .option('--with-extract-params', `override the default scaffold to emit \`this.extractParams(Model, [...])\` (explicit allowlist). Only valid for admin-namespaced resources, which otherwise default to \`this.extractImplicitParams(Model)\`. Mutually exclusive with --with-extract-implicit-params.`, false)
145
- .option('--with-extract-implicit-params', `override the default scaffold to emit \`this.extractImplicitParams(Model)\` (model-declared allowlist). Only useful for non-admin-namespaced resources, which otherwise default to \`this.extractParams(Model, [...])\`. Mutually exclusive with --with-extract-params.`, false)
146
153
  .argument('<path>', `The URL path for this resource's routes, relative to the root domain. Use \`\\{\\}\` as a placeholder for a parent resource's ID parameter when nesting.
147
154
  ${INDENT}
148
155
  ${INDENT}The path determines the controller namespace hierarchy. Paths that begin with "admin" and "internal" remove the \`currentUser\` scoping of queries (\`--owning-model\` may be provided to apply query scoping). Each segment maps to a directory level in the controllers folder.
@@ -403,13 +403,13 @@ export default class PsychicController {
403
403
  return this._castParam(keys, nestedParams, expectedType, opts);
404
404
  }
405
405
  /**
406
- * @deprecated Prefer {@link extractParams} (explicit allowlist at the call
407
- * site) or {@link extractImplicitParams} (same implicit-allowlist behavior,
408
- * new name). Security-relevant: on models with permission-bearing fields,
409
- * `paramsFor(Model)` without `only` extracts every column in
410
- * `paramSafeColumnsOrFallback()` — which may be too broad unless the model
411
- * declares `paramSafeColumns` explicitly. `extractParams` makes the
412
- * allowlist visible to reviewers at the call site.
406
+ * @deprecated Prefer {@link extractParams} — the explicit-allowlist form
407
+ * keeps the permitted columns visible at the call site. Security-relevant:
408
+ * on models with permission-bearing fields, `paramsFor(Model)` without
409
+ * `only` extracts every column in `paramSafeColumnsOrFallback()` — which
410
+ * may be too broad unless the model declares `paramSafeColumns` explicitly.
411
+ * `extractParams` makes the allowlist visible to reviewers at the call
412
+ * site.
413
413
  *
414
414
  * Captures and validates parameters for the provided Dream model. Will
415
415
  * exclude parameters that are not considered "safe" by default (based on
@@ -429,11 +429,7 @@ export default class PsychicController {
429
429
  * ```ts
430
430
  * class MyController extends ApplicationController {
431
431
  * public create() {
432
- * // Preferred: explicit allowlist via extractParams
433
432
  * const safe = this.extractParams(User, ['email', 'name'])
434
- *
435
- * // Equivalent of the legacy `this.paramsFor(User)` under the new name:
436
- * const viaModel = this.extractImplicitParams(User)
437
433
  * }
438
434
  * }
439
435
  * ```
@@ -458,10 +454,6 @@ export default class PsychicController {
458
454
  * `paramSafeColumnsOrFallback()` further strips anything a caller bypasses
459
455
  * the type system to include.
460
456
  *
461
- * Use {@link extractImplicitParams} when the model's declared
462
- * `paramSafeColumns` is the canonical allowlist and duplicating it at each
463
- * call site would create drift.
464
- *
465
457
  * @param dreamClass - The Dream model class to retrieve params for
466
458
  * @param allowed - Required. The columns permitted from the request.
467
459
  * @param opts - Optional configuration
@@ -485,39 +477,6 @@ export default class PsychicController {
485
477
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
486
478
  return Params.extract(source, dreamClass, allowed, opts);
487
479
  }
488
- /**
489
- * Captures and validates parameters for the provided Dream model using the
490
- * model's declared `paramSafeColumns` (or, when undeclared, the framework's
491
- * default-safe fallback from `paramSafeColumnsOrFallback()`).
492
- *
493
- * Prefer {@link extractParams} when the caller can enumerate the allowed
494
- * columns at the call site — explicit allowlists are more visible to
495
- * reviewers. Reach for `extractImplicitParams` when the model-level
496
- * declaration is the canonical allowlist and you want to avoid duplicating
497
- * it at every call site.
498
- *
499
- * @param dreamClass - The Dream model class to retrieve params for
500
- * @param opts - Optional configuration
501
- * @param opts.key - Extract params from a nested key in the params object instead of root level
502
- * @param opts.array - If true, expects and returns an array of param objects
503
- * @returns A typed object containing the validated and casted params
504
- * @throws {ParamValidationError} When any parameter validation fails
505
- *
506
- * @example
507
- * ```ts
508
- * class AdminBalloonsController extends ApplicationController {
509
- * public create() {
510
- * const params = this.extractImplicitParams(Balloon)
511
- * const balloon = await Balloon.create(params)
512
- * }
513
- * }
514
- * ```
515
- */
516
- extractImplicitParams(dreamClass, opts) {
517
- const source = opts?.key ? this.params[opts.key] || {} : this.params;
518
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
519
- return Params.extractImplicit(source, dreamClass, opts);
520
- }
521
480
  /**
522
481
  * Gets a cookie value from the request and casts it to the specified type.
523
482
  *
@@ -9,7 +9,7 @@ import psychicPath from '../helpers/path/psychicPath.js';
9
9
  import generateControllerContent from './helpers/generateControllerContent.js';
10
10
  import generateControllerSpecContent from './helpers/generateControllerSpecContent.js';
11
11
  import generateResourceControllerSpecContent from './helpers/generateResourceControllerSpecContent.js';
12
- export default async function generateController({ fullyQualifiedControllerName, fullyQualifiedModelName, actions, columnsWithTypes = [], resourceSpecs = false, owningModel, singular, paramExtractionStrategy, }) {
12
+ export default async function generateController({ fullyQualifiedControllerName, fullyQualifiedModelName, actions, columnsWithTypes = [], resourceSpecs = false, owningModel, singular, }) {
13
13
  fullyQualifiedModelName = fullyQualifiedModelName
14
14
  ? DreamApp.system.standardizeFullyQualifiedModelName(fullyQualifiedModelName)
15
15
  : fullyQualifiedModelName;
@@ -71,7 +71,6 @@ export default async function generateController({ fullyQualifiedControllerName,
71
71
  forInternal,
72
72
  singular,
73
73
  columnsWithTypes,
74
- paramExtractionStrategy,
75
74
  }));
76
75
  }
77
76
  catch (error) {
@@ -2,36 +2,14 @@ import { DreamApp } from '@rvoh/dream';
2
2
  import { camelize, hyphenize } from '@rvoh/dream/utils';
3
3
  import pluralize from 'pluralize-esm';
4
4
  import paramSafeColumnNamesFromCliTokens from './paramSafeColumnNamesFromCliTokens.js';
5
- export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions = [], omitOpenApi = false, owningModel, forAdmin, forInternal = false, singular, columnsWithTypes = [], paramExtractionStrategy, }) {
6
- // Admin scaffolds lean on the model's declared paramSafeColumns (implicit);
7
- // non-admin scaffolds require an explicit allowlist at the call site so the
8
- // permitted columns are visible to reviewers. Either default can be overridden
9
- // via the `--with-extract-params` / `--with-extract-implicit-params` CLI flags,
10
- // which get materialized into `paramExtractionStrategy` by the caller.
11
- const resolvedExtractionStrategy = paramExtractionStrategy ?? (forAdmin ? 'implicit' : 'explicit');
12
- /**
13
- * Returns the `this.extract*Params(...)` expression that replaces the legacy
14
- * `this.paramsFor(Model)` in the scaffold's commented hints. Does NOT include
15
- * the outer closing paren that the surrounding call expects (e.g. the one
16
- * closing `update(...)` or `create(...)` or `createAssociation(...)`); the
17
- * caller appends that as part of its own template.
18
- */
19
- const extractCallExpression = (modelClass) => {
20
- if (resolvedExtractionStrategy === 'implicit') {
21
- return `this.extractImplicitParams(${modelClass})`;
22
- }
23
- const safeColumns = paramSafeColumnNamesFromCliTokens(columnsWithTypes);
24
- const serializedSafeColumns = safeColumns.length
25
- ? `[${safeColumns.map(name => `'${name}'`).join(', ')}]`
26
- : '[]';
27
- // The emitted list contains every implicitly-allowed column. When
28
- // uncommenting the action body, the developer or agent is responsible
29
- // for narrowing it down to only the columns this action should actually
30
- // accept.
31
- return `this.extractParams(${modelClass},
32
- // ${serializedSafeColumns},
33
- // )`;
34
- };
5
+ export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions = [], omitOpenApi = false, owningModel, forAdmin, forInternal = false, singular, columnsWithTypes = [], }) {
6
+ // The scaffold emits a `paramSafeColumns` const at the top of the file
7
+ // (alongside `openApiTags`) and references it from both the `create` and
8
+ // `update` action hints. The list contains every implicitly-allowed column;
9
+ // when uncommenting the action body, the developer or agent is responsible
10
+ // for narrowing the const down to only the columns the actions should
11
+ // actually accept.
12
+ const extractCallExpression = (modelClass) => `this.extractParams(${modelClass}, paramSafeColumns)`;
35
13
  fullyQualifiedControllerName = DreamApp.system.standardizeFullyQualifiedModelName(fullyQualifiedControllerName);
36
14
  const additionalImports = [];
37
15
  const controllerClassName = DreamApp.system.globalClassNameFromFullyQualifiedModelName(fullyQualifiedControllerName);
@@ -70,6 +48,9 @@ export default function generateControllerContent({ ancestorName, ancestorImport
70
48
  tags: openApiTags,
71
49
  description: 'Create ${aOrAnDreamModelName(modelClassName)}',${defaultOpenapiSerializerKeyProperty}
72
50
  fastJsonStringify: true,
51
+ requestBody: {
52
+ only: paramSafeColumns,
53
+ },
73
54
  })
74
55
  public async create() {
75
56
  // let ${modelAttributeName} = await ${useDirectModelAccess ? `${modelClassName}.create(` : `this.${owningModelProperty}.createAssociation('${pluralizedModelAttributeName}', `}${extractCallExpression(modelClassName)})
@@ -146,6 +127,9 @@ export default function generateControllerContent({ ancestorName, ancestorImport
146
127
  tags: openApiTags,
147
128
  description: 'Update ${aOrAnDreamModelName(modelClassName)}',
148
129
  fastJsonStringify: true,
130
+ requestBody: {
131
+ only: paramSafeColumns,
132
+ },
149
133
  })
150
134
  public async update() {
151
135
  // const ${modelAttributeName} = await this.${modelAttributeName}()
@@ -213,8 +197,20 @@ export default function generateControllerContent({ ancestorName, ancestorImport
213
197
  });
214
198
  const openApiImport = `import { OpenAPI } from '@rvoh/psychic'`;
215
199
  const openApiTags = `const openApiTags = ['${hyphenize(pluralizedModelAttributeName || controllerClassName.replace(/Controller$/, ''))}']`;
200
+ const emitParamSafeColumns = !!modelClassName && actions.some(action => action === 'create' || action === 'update');
201
+ const safeColumns = emitParamSafeColumns ? paramSafeColumnNamesFromCliTokens(columnsWithTypes) : [];
202
+ // The const is typed against the model's safe-column names rather than
203
+ // `as const`, so editing the array literal gives the developer (or agent)
204
+ // autocomplete of valid columns and a compile error on anything that is
205
+ // not param-safe — directly in the assignment, no call-site round-trip.
206
+ const paramSafeColumnsDecl = !emitParamSafeColumns
207
+ ? ''
208
+ : `\n\nconst paramSafeColumns: DreamParamSafeColumnNames<${modelClassName}>[] = [${safeColumns.map(name => `'${name}'`).join(', ')}]`;
209
+ const dreamTypesImport = emitParamSafeColumns
210
+ ? `import { DreamParamSafeColumnNames } from '@rvoh/dream/types'\n`
211
+ : '';
216
212
  return `\
217
- ${omitOpenApi ? '' : openApiImport + '\n'}${ancestorImportStatement}${additionalImports.length ? '\n' + additionalImports.join('\n') : ''}${omitOpenApi ? '' : '\n\n' + openApiTags}
213
+ ${omitOpenApi ? '' : openApiImport + '\n' + dreamTypesImport}${ancestorImportStatement}${additionalImports.length ? '\n' + additionalImports.join('\n') : ''}${omitOpenApi ? '' : '\n\n' + openApiTags}${paramSafeColumnsDecl}
218
214
 
219
215
  export default class ${controllerClassName} extends ${ancestorName} {
220
216
  ${methodDefs.join('\n\n')}${modelClassName ? privateMethods(forAdmin, forInternal, modelClassName, actions, loadQueryBase, singular) : ''}
@@ -9,16 +9,46 @@ import { camelize } from '@rvoh/dream/utils';
9
9
  * paramSafe column allowlist) share one canonical interpretation of the
10
10
  * tokens — and one canonical casing (camelCase) for the resulting attribute
11
11
  * name.
12
+ *
13
+ * Supports the `Model@alias:belongs_to` shorthand for renaming the FK
14
+ * association. When `@alias` is present, the camelized alias becomes
15
+ * `attributeName`; otherwise (legacy form `Model:belongs_to`) the
16
+ * attribute name is derived from the model's last namespace segment.
17
+ *
18
+ * Mirrors Dream's CLI-side `parseAttribute` (kept as a parallel
19
+ * implementation rather than a shared import so neither package leaks an
20
+ * internal CLI helper through its public API surface).
12
21
  */
13
22
  export default function parseAttribute(attribute) {
14
- const [rawAttributeName, rawAttributeType, , enumValues] = attribute.split(':');
15
- if (!rawAttributeName || !rawAttributeType)
23
+ const segments = attribute.split(':');
24
+ const rawSegmentOne = segments[0];
25
+ const rawAttributeType = segments[1];
26
+ if (!rawSegmentOne || !rawAttributeType)
16
27
  return null;
28
+ // Extract optional `@alias` from segment-1 (used by the belongs_to FK alias
29
+ // shorthand, e.g., `InternalUser@canceled_by:belongs_to`).
30
+ let rawAttributeName = rawSegmentOne;
31
+ let aliasName;
32
+ const atIndex = rawSegmentOne.indexOf('@');
33
+ if (atIndex !== -1) {
34
+ rawAttributeName = rawSegmentOne.slice(0, atIndex);
35
+ const rawAlias = rawSegmentOne.slice(atIndex + 1);
36
+ if (!rawAttributeName || !rawAlias)
37
+ return null;
38
+ aliasName = rawAlias;
39
+ }
40
+ // Pop trailing `optional` keyword from descriptors so it doesn't get
41
+ // mistaken for enum values or other positional descriptors.
42
+ const descriptors = segments.slice(2);
43
+ if (descriptors[descriptors.length - 1] === 'optional')
44
+ descriptors.pop();
45
+ const enumValues = descriptors[1];
17
46
  const sanitizedAttrType = camelize(rawAttributeType)?.toLowerCase();
18
47
  // Handle belongs_to relationships
19
48
  if (sanitizedAttrType === 'belongsto') {
20
- // For belongs_to relationships, convert "Ticketing/Ticket" to "ticket"
21
- const attributeName = camelize(rawAttributeName.split('/').pop());
49
+ // For belongs_to relationships, prefer the explicit alias when present;
50
+ // otherwise convert "Ticketing/Ticket" → "ticket".
51
+ const attributeName = aliasName ? camelize(aliasName) : camelize(rawAttributeName.split('/').pop());
22
52
  return { attributeName, attributeType: 'belongs_to', isArray: false, enumValues };
23
53
  }
24
54
  // Skip _type and _id columns, but not belongs_to relationships
@@ -32,14 +32,6 @@ export default async function generateResource({ route, fullyQualifiedModelName,
32
32
  softDelete: options.softDelete,
33
33
  },
34
34
  });
35
- if (options.withExtractParams && options.withExtractImplicitParams) {
36
- throw new Error('--with-extract-params and --with-extract-implicit-params are mutually exclusive; pass only one.');
37
- }
38
- const paramExtractionStrategy = options.withExtractParams
39
- ? 'explicit'
40
- : options.withExtractImplicitParams
41
- ? 'implicit'
42
- : undefined;
43
35
  await generateController({
44
36
  fullyQualifiedControllerName,
45
37
  fullyQualifiedModelName,
@@ -50,7 +42,6 @@ export default async function generateResource({ route, fullyQualifiedModelName,
50
42
  resourceSpecs: true,
51
43
  singular: options.singular,
52
44
  owningModel: options.owningModel,
53
- paramExtractionStrategy,
54
45
  });
55
46
  await addResourceToRoutes(route, {
56
47
  singular: options.singular,
@@ -108,25 +108,6 @@ export default class Params {
108
108
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
109
109
  return Params.for(params, dreamClass, { ...(opts ?? {}), only: allowed });
110
110
  }
111
- /**
112
- * ### .extractImplicit
113
- *
114
- * Implicit-allowlist extraction driven by the model's `paramSafeColumns`
115
- * declaration (or, when undeclared, the framework's default-safe fallback
116
- * from `paramSafeColumnsOrFallback()`). Equivalent to {@link Params.for}
117
- * without `only`. Use from a controller via
118
- * {@link PsychicController.extractImplicitParams}.
119
- *
120
- * Prefer {@link Params.extract} when the caller can enumerate the allowed
121
- * columns at the call site — it makes the allowlist visible to reviewers
122
- * at the point of use. Reach for this method when the model-level
123
- * `paramSafeColumns` declaration is the canonical allowlist and duplicating
124
- * it at each call site would create maintenance drift.
125
- */
126
- static extractImplicit(params, dreamClass, opts) {
127
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
128
- return Params.for(params, dreamClass, (opts ?? {}));
129
- }
130
111
  static restrict(params, allowed) {
131
112
  if (params === null || params === undefined)
132
113
  return {};
@@ -10,8 +10,6 @@ export default class PsychicBin {
10
10
  modelName?: string;
11
11
  tableName?: string;
12
12
  softDelete: boolean;
13
- withExtractParams?: boolean;
14
- withExtractImplicitParams?: boolean;
15
13
  }): Promise<void>;
16
14
  static printRoutes(): void;
17
15
  static printControllerHierarchy(controllersPath?: string): void;
@@ -32,8 +32,6 @@ export default class PsychicCLI {
32
32
  tableName?: string;
33
33
  modelName?: string;
34
34
  softDelete: boolean;
35
- withExtractParams?: boolean;
36
- withExtractImplicitParams?: boolean;
37
35
  };
38
36
  columnsWithTypes: string[];
39
37
  }): Promise<void>;
@@ -251,13 +251,13 @@ export default class PsychicController {
251
251
  castParam<const EnumType extends readonly string[], OptsType extends StrictInterface<OptsType, ParamsCastOptions<EnumType>>, const ExpectedType extends PsychicParamsPrimitiveLiteral | RegExp | OpenapiSchemaBody>(key: string, expectedType: ExpectedType, opts?: OptsType): ValidatedAllowsNull<ExpectedType, OptsType> extends infer T ? T extends ValidatedAllowsNull<ExpectedType, OptsType> ? T extends true ? ValidatedReturnType<ExpectedType, OptsType> | null | undefined : ValidatedReturnType<ExpectedType, OptsType> : never : never;
252
252
  private _castParam;
253
253
  /**
254
- * @deprecated Prefer {@link extractParams} (explicit allowlist at the call
255
- * site) or {@link extractImplicitParams} (same implicit-allowlist behavior,
256
- * new name). Security-relevant: on models with permission-bearing fields,
257
- * `paramsFor(Model)` without `only` extracts every column in
258
- * `paramSafeColumnsOrFallback()` — which may be too broad unless the model
259
- * declares `paramSafeColumns` explicitly. `extractParams` makes the
260
- * allowlist visible to reviewers at the call site.
254
+ * @deprecated Prefer {@link extractParams} — the explicit-allowlist form
255
+ * keeps the permitted columns visible at the call site. Security-relevant:
256
+ * on models with permission-bearing fields, `paramsFor(Model)` without
257
+ * `only` extracts every column in `paramSafeColumnsOrFallback()` — which
258
+ * may be too broad unless the model declares `paramSafeColumns` explicitly.
259
+ * `extractParams` makes the allowlist visible to reviewers at the call
260
+ * site.
261
261
  *
262
262
  * Captures and validates parameters for the provided Dream model. Will
263
263
  * exclude parameters that are not considered "safe" by default (based on
@@ -277,11 +277,7 @@ export default class PsychicController {
277
277
  * ```ts
278
278
  * class MyController extends ApplicationController {
279
279
  * public create() {
280
- * // Preferred: explicit allowlist via extractParams
281
280
  * const safe = this.extractParams(User, ['email', 'name'])
282
- *
283
- * // Equivalent of the legacy `this.paramsFor(User)` under the new name:
284
- * const viaModel = this.extractImplicitParams(User)
285
281
  * }
286
282
  * }
287
283
  * ```
@@ -306,10 +302,6 @@ export default class PsychicController {
306
302
  * `paramSafeColumnsOrFallback()` further strips anything a caller bypasses
307
303
  * the type system to include.
308
304
  *
309
- * Use {@link extractImplicitParams} when the model's declared
310
- * `paramSafeColumns` is the canonical allowlist and duplicating it at each
311
- * call site would create drift.
312
- *
313
305
  * @param dreamClass - The Dream model class to retrieve params for
314
306
  * @param allowed - Required. The columns permitted from the request.
315
307
  * @param opts - Optional configuration
@@ -331,37 +323,6 @@ export default class PsychicController {
331
323
  extractParams<T extends typeof Dream, I extends InstanceType<T>, const AllowedArray extends readonly (keyof DreamParamSafeAttributes<I>)[], OptsType extends StrictInterface<OptsType, ExtractParamsOpts>, ParamSafeAttrs extends DreamParamSafeAttributes<I>, ReturnPartial extends Partial<{
332
324
  [K in AllowedArray[number] & keyof ParamSafeAttrs]: ParamSafeAttrs[K & keyof ParamSafeAttrs];
333
325
  }>, ReturnPayload extends OptsType['array'] extends true ? ReturnPartial[] : ReturnPartial>(this: PsychicController, dreamClass: T, allowed: AllowedArray, opts?: OptsType): ReturnPayload;
334
- /**
335
- * Captures and validates parameters for the provided Dream model using the
336
- * model's declared `paramSafeColumns` (or, when undeclared, the framework's
337
- * default-safe fallback from `paramSafeColumnsOrFallback()`).
338
- *
339
- * Prefer {@link extractParams} when the caller can enumerate the allowed
340
- * columns at the call site — explicit allowlists are more visible to
341
- * reviewers. Reach for `extractImplicitParams` when the model-level
342
- * declaration is the canonical allowlist and you want to avoid duplicating
343
- * it at every call site.
344
- *
345
- * @param dreamClass - The Dream model class to retrieve params for
346
- * @param opts - Optional configuration
347
- * @param opts.key - Extract params from a nested key in the params object instead of root level
348
- * @param opts.array - If true, expects and returns an array of param objects
349
- * @returns A typed object containing the validated and casted params
350
- * @throws {ParamValidationError} When any parameter validation fails
351
- *
352
- * @example
353
- * ```ts
354
- * class AdminBalloonsController extends ApplicationController {
355
- * public create() {
356
- * const params = this.extractImplicitParams(Balloon)
357
- * const balloon = await Balloon.create(params)
358
- * }
359
- * }
360
- * ```
361
- */
362
- extractImplicitParams<T extends typeof Dream, I extends InstanceType<T>, OptsType extends StrictInterface<OptsType, ExtractParamsOpts>, ParamSafeColumnsOverride extends I['paramSafeColumns' & keyof I] extends never ? undefined : I['paramSafeColumns' & keyof I] & string[], ParamSafeColumns extends ParamSafeColumnsOverride extends string[] | Readonly<string[]> ? Extract<DreamParamSafeColumnNames<I>, ParamSafeColumnsOverride[number] & DreamParamSafeColumnNames<I>>[] : DreamParamSafeColumnNames<I>[], ParamSafeAttrs extends DreamParamSafeAttributes<I>, ReturnPartial extends Partial<{
363
- [K in ParamSafeColumns[number & keyof ParamSafeColumns] & string]: ParamSafeAttrs[K & keyof ParamSafeAttrs];
364
- }>, ReturnPayload extends OptsType['array'] extends true ? ReturnPartial[] : ReturnPartial>(this: PsychicController, dreamClass: T, opts?: OptsType): ReturnPayload;
365
326
  /**
366
327
  * Gets a cookie value from the request and casts it to the specified type.
367
328
  *
@@ -1,5 +1,4 @@
1
- import { ParamExtractionStrategy } from './helpers/generateControllerContent.js';
2
- export default function generateController({ fullyQualifiedControllerName, fullyQualifiedModelName, actions, columnsWithTypes, resourceSpecs, owningModel, singular, paramExtractionStrategy, }: {
1
+ export default function generateController({ fullyQualifiedControllerName, fullyQualifiedModelName, actions, columnsWithTypes, resourceSpecs, owningModel, singular, }: {
3
2
  fullyQualifiedControllerName: string;
4
3
  fullyQualifiedModelName?: string;
5
4
  actions: string[];
@@ -7,5 +6,4 @@ export default function generateController({ fullyQualifiedControllerName, fully
7
6
  resourceSpecs?: boolean;
8
7
  owningModel?: string | undefined;
9
8
  singular: boolean;
10
- paramExtractionStrategy?: ParamExtractionStrategy | undefined;
11
9
  }): Promise<void>;
@@ -1,5 +1,4 @@
1
- export type ParamExtractionStrategy = 'explicit' | 'implicit';
2
- export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions, omitOpenApi, owningModel, forAdmin, forInternal, singular, columnsWithTypes, paramExtractionStrategy, }: {
1
+ export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions, omitOpenApi, owningModel, forAdmin, forInternal, singular, columnsWithTypes, }: {
3
2
  ancestorName: string;
4
3
  ancestorImportStatement: string;
5
4
  fullyQualifiedControllerName: string;
@@ -11,5 +10,4 @@ export default function generateControllerContent({ ancestorName, ancestorImport
11
10
  forInternal?: boolean;
12
11
  singular: boolean;
13
12
  columnsWithTypes?: string[];
14
- paramExtractionStrategy?: ParamExtractionStrategy | undefined;
15
13
  }): string;
@@ -14,5 +14,14 @@ export interface ParsedAttribute {
14
14
  * paramSafe column allowlist) share one canonical interpretation of the
15
15
  * tokens — and one canonical casing (camelCase) for the resulting attribute
16
16
  * name.
17
+ *
18
+ * Supports the `Model@alias:belongs_to` shorthand for renaming the FK
19
+ * association. When `@alias` is present, the camelized alias becomes
20
+ * `attributeName`; otherwise (legacy form `Model:belongs_to`) the
21
+ * attribute name is derived from the model's last namespace segment.
22
+ *
23
+ * Mirrors Dream's CLI-side `parseAttribute` (kept as a parallel
24
+ * implementation rather than a shared import so neither package leaks an
25
+ * internal CLI helper through its public API surface).
17
26
  */
18
27
  export default function parseAttribute(attribute: string): ParsedAttribute | null;
@@ -12,8 +12,6 @@ export default function generateResource({ route, fullyQualifiedModelName, optio
12
12
  tableName?: string;
13
13
  modelName?: string;
14
14
  softDelete: boolean;
15
- withExtractParams?: boolean;
16
- withExtractImplicitParams?: boolean;
17
15
  };
18
16
  columnsWithTypes: string[];
19
17
  }): Promise<void>;
@@ -42,24 +42,6 @@ export default class Params {
42
42
  static extract<T extends typeof Dream, I extends InstanceType<T>, const AllowedArray extends readonly (keyof DreamParamSafeAttributes<I>)[], OptsType extends StrictInterface<OptsType, ExtractParamsOpts>, ParamSafeAttrs extends DreamParamSafeAttributes<I>, ReturnPartial extends Partial<{
43
43
  [K in AllowedArray[number] & keyof ParamSafeAttrs]: ParamSafeAttrs[K & keyof ParamSafeAttrs];
44
44
  }>, ReturnPayload extends OptsType['array'] extends true ? ReturnPartial[] : ReturnPartial>(params: object, dreamClass: T, allowed: AllowedArray, opts?: OptsType): ReturnPayload;
45
- /**
46
- * ### .extractImplicit
47
- *
48
- * Implicit-allowlist extraction driven by the model's `paramSafeColumns`
49
- * declaration (or, when undeclared, the framework's default-safe fallback
50
- * from `paramSafeColumnsOrFallback()`). Equivalent to {@link Params.for}
51
- * without `only`. Use from a controller via
52
- * {@link PsychicController.extractImplicitParams}.
53
- *
54
- * Prefer {@link Params.extract} when the caller can enumerate the allowed
55
- * columns at the call site — it makes the allowlist visible to reviewers
56
- * at the point of use. Reach for this method when the model-level
57
- * `paramSafeColumns` declaration is the canonical allowlist and duplicating
58
- * it at each call site would create maintenance drift.
59
- */
60
- static extractImplicit<T extends typeof Dream, I extends InstanceType<T>, OptsType extends StrictInterface<OptsType, ExtractParamsOpts>, ParamSafeColumnsOverride extends I['paramSafeColumns' & keyof I] extends never ? undefined : I['paramSafeColumns' & keyof I] & string[], ParamSafeColumns extends ParamSafeColumnsOverride extends string[] | Readonly<string[]> ? Extract<DreamParamSafeColumnNames<I>, ParamSafeColumnsOverride[number] & DreamParamSafeColumnNames<I>>[] : DreamParamSafeColumnNames<I>[], ParamSafeAttrs extends DreamParamSafeAttributes<I>, ReturnPartial extends Partial<{
61
- [K in ParamSafeColumns[number & keyof ParamSafeColumns] & string]: ParamSafeAttrs[K & keyof ParamSafeAttrs];
62
- }>, ReturnPayload extends OptsType['array'] extends true ? ReturnPartial[] : ReturnPartial>(params: object, dreamClass: T, opts?: OptsType): ReturnPayload;
63
45
  static restrict<T extends typeof Params>(this: T, params: PsychicParamsPrimitive | PsychicParamsDictionary | PsychicParamsDictionary[], allowed: string[]): PsychicParamsDictionary;
64
46
  /**
65
47
  * ### .cast
@@ -139,10 +121,9 @@ export interface ParamsForOpts<OnlyArray> extends ParamsForOptsBase<OnlyArray> {
139
121
  key?: string;
140
122
  }
141
123
  /**
142
- * Options for {@link Params.extract} / {@link Params.extractImplicit} and the
143
- * corresponding controller methods. Mirrors {@link ParamsForOpts} minus
144
- * `only` — the explicit allowlist moved to a required positional argument on
145
- * `extractParams`, and `extractImplicitParams` has no allowlist argument.
124
+ * Options for {@link Params.extract} and the corresponding controller method.
125
+ * Mirrors {@link ParamsForOpts} minus `only` — the explicit allowlist moved
126
+ * to a required positional argument on `extractParams`.
146
127
  */
147
128
  export interface ExtractParamsOpts {
148
129
  array?: boolean;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "type": "module",
3
3
  "name": "@rvoh/psychic",
4
4
  "description": "Typescript web framework",
5
- "version": "3.3.0",
5
+ "version": "3.4.1",
6
6
  "author": "RVOHealth",
7
7
  "repository": {
8
8
  "type": "git",
@@ -75,7 +75,7 @@
75
75
  "@koa/cors": "^5.0.0",
76
76
  "@koa/etag": "^5.0.2",
77
77
  "@koa/router": "^15.3.0",
78
- "@rvoh/dream": "^2.9.0",
78
+ "@rvoh/dream": "^2.10.0",
79
79
  "@types/koa": "^3.0.1",
80
80
  "@types/koa-bodyparser": "^4.3.12",
81
81
  "@types/koa-conditional-get": "^2.0.3",
@@ -92,7 +92,7 @@
92
92
  "@koa/cors": "^5.0.0",
93
93
  "@koa/etag": "^5.0.2",
94
94
  "@koa/router": "^15.3.1",
95
- "@rvoh/dream": "^2.9.0",
95
+ "@rvoh/dream": "^2.10.0",
96
96
  "@rvoh/dream-spec-helpers": "^2.1.1",
97
97
  "@rvoh/psychic-spec-helpers": "3.0.0",
98
98
  "@types/koa": "^3.0.1",
@@ -118,8 +118,8 @@
118
118
  "koa-conditional-get": "^3.0.0",
119
119
  "koa-passport": "^6.0.0",
120
120
  "koa-session": "^7.0.2",
121
- "kysely": "^0.28.14",
122
- "kysely-codegen": "~0.19.0",
121
+ "kysely": "^0.29.0",
122
+ "kysely-codegen": "~0.20.0",
123
123
  "luxon-jest-matchers": "^0.1.14",
124
124
  "nodemon": "^3.1.11",
125
125
  "openapi-typescript": "^7.13.0",