<!-- json
{
  "sourceId": "recipe-indexed-model-pattern",
  "repositoryId": "recipe-manager",
  "filePath": "modules/api/src/model/recipe.ts",
  "capabilityTags": ["model", "indexed", "query"],
  "operationIds": ["model-indexed-assistant", "model-query-assistant"],
  "applicability": ["sample-project"],
  "notes": [
    "Reference sorted/keyed/unique index usage and query-centric model design."
  ]
}
-->

# recipe-indexed-model-pattern

- Source: `modules/api/src/model/recipe.ts`
- Capability tags: model, indexed, query
- Applicability: sample-project
- Notes:
  - Reference sorted/keyed/unique index usage and query-centric model design.

## Reference Code

```typescript
import { Model, type ModelType, PersistValue } from '@travetto/model';
import { keyedIndex, sortedIndex } from '@travetto/model-indexed';
import { Ignore, Integer, Max, MaxLength, Min, MinLength, Precision, Required, Schema, Specifier } from '@travetto/schema';

import type { DifficultyLevel, MeasurementUnit, RecipeStepType } from './common';
import type { Ingredient } from './ingredient';

type OptionalIngredient = Partial<Ingredient>;

@Schema()
export class RecipeIngredient {
  ingredientId?: string;
  unmatchedReference?: string;
  @Specifier('extract') name: string;
  @Specifier('extract') @Min(0.01) quantity: number;
  @Specifier('extract') unit: MeasurementUnit;
  @Specifier('extract') modifier?: string;
  @Specifier('extract') notes?: string;
  @Specifier('extract') optional: boolean = false;
  @Specifier('extract') @Precision(4, 2) @Min(0.01) @Max(1) absorptionRate?: number;
  ingredient?: OptionalIngredient;
}

@Schema()
export class RecipeStep {
  @Specifier('extract') description: string;
  @Specifier('extract') durationMinutes?: number;
  imageUrl?: string;
  @Specifier('extract') type?: RecipeStepType;
  @Specifier('extract') optional?: boolean = false;
}

@Schema()
export class NutritionInfo {
  @Integer() calories: number;
  proteinGrams: number;
  fatGrams: number;
  fiberGrams?: number;
  sugarGrams?: number;
  sodiumMg?: number;
}

@Schema()
export class RecipeSection {
  @Specifier('extract') name?: string;
  @Specifier('extract', 'extract:required') ingredients?: RecipeIngredient[] = [];
  @Specifier('extract') steps?: RecipeStep[] = [];
  @Specifier('extract') notes?: string;
}

// ─── Core Models ──────────────────────────────────────────────────────────────

export const searchTitleCleaner = (str: string): string => str.toLowerCase().replace(/[^a-z0-9]/g, '');

@Model()
export class Recipe implements ModelType {
  id: string;

  @Required(false) userId: string;
  @Required(false) userName: string;

  get searchTitle(): string {
    return this.title ? searchTitleCleaner(this.title) : '';
  }

  @Ignore()
  get steps(): RecipeStep[] {
    return (this.sections ?? []).flatMap(section => section.steps ?? []);
  }

  get totalTimeMinutes(): number {
    return this.steps.map(x => x.durationMinutes ?? 0).reduce((a, b) => a + b, 0);
  }

  get cookTimeMinutes(): number {
    return this.steps
      .filter(x => x.type === 'cook')
      .map(x => x.durationMinutes ?? 0)
      .reduce((a, b) => a + b, 0);
  }

  get prepTimeMinutes(): number {
    return this.steps
      .filter(x => x.type === 'prep')
      .map(x => x.durationMinutes ?? 0)
      .reduce((a, b) => a + b, 0);
  }

  get waitingTimeMinutes(): number {
    return this.steps
      .filter(x => x.type === 'waiting')
      .map(x => x.durationMinutes ?? 0)
      .reduce((a, b) => a + b, 0);
  }

  get activeTimeMinutes(): number {
    return this.steps
      .filter(x => x.type !== 'waiting')
      .map(x => x.durationMinutes ?? 0)
      .reduce((a, b) => a + b, 0);
  }

  @Specifier('extract') @MinLength(3) @MaxLength(150) title: string;
  @Specifier('extract') @MaxLength(500) description?: string;
  @Specifier('extract') @MaxLength(500) source?: string;

  @Specifier('extract') difficulty?: DifficultyLevel;
  @Specifier('extract') cuisine: string;
  @Specifier('extract') tags: string[] = [];

  @Specifier('extract') @Integer() @Min(1) servings: number;

  @Specifier('extract') sections: RecipeSection[];
  @Specifier('extract') equipment?: string[] = [];
  nutrition?: NutritionInfo;

  sourceArtifacts: string[] = [];
  imageUrls: string[] = [];
  thumbnailUrl?: string;
  familyIds: string[] = [];
  isPublished: boolean = false;
  publishedAt?: Date;

  ratingCount: number = 0;

  @Precision(3, 2) @Min(0) @Max(5) rating: number = 0;

  @PersistValue(v => v ?? new Date(), 'full')
  createdAt?: Date;
  @PersistValue(() => new Date())
  updatedAt?: Date;
}

export const recipesByUserIdUpdatedAtDesc = sortedIndex(Recipe, {
  name: 'recipe_userId_index__sorted_updated_at',
  key: { userId: true },
  sort: { updatedAt: -1 }
});

export const recipesByUserId = keyedIndex(Recipe, {
  name: 'recipe_userId_index',
  key: { userId: true }
});

export const publishedRecipes = sortedIndex(Recipe, {
  name: 'recipe_published_index',
  key: { isPublished: true },
  sort: { publishedAt: -1 }
});

export const recipesByUserIdAndTitle = sortedIndex(Recipe, {
  name: 'recipe_userId_title_index',
  key: { userId: true },
  sort: { searchTitle: 1 }
});

export const recipesByPublishedAndTitle = sortedIndex(Recipe, {
  name: 'recipe_published_title_index',
  key: { isPublished: true },
  sort: { searchTitle: 1 }
});

@Schema()
export class RenderedNutritionInfo {
  calories: number;
  protein: number;
  fat: number;
  fiber: number;
  sugar: number;
  sodium: number;
  isIncomplete?: boolean;
}

@Schema()
export class RenderedRecipeIngredient {
  ingredientId?: string;
  unmatchedReference?: string;
  name: string;
  shortDescription?: string;
  optional: boolean = false;
  modifier?: string;
  notes?: string;
  quantity: number;
  unit: MeasurementUnit;
  grams?: number;
  absorptionRate?: number;
}

@Schema()
export class RenderedRecipeStep extends RecipeStep {
  shortDescription?: string;
  isSetup?: boolean = false;
  relatedIngredientIndices?: number[] = [];
}

@Schema()
export class RenderedRecipeSection {
  name?: string;
  ingredients: RenderedRecipeIngredient[] = [];
  steps: RenderedRecipeStep[] = [];
  notes?: string;
}

@Schema()
export class RenderedRecipe {
  id: string;
  userId: string;
  userName: string;
  title: string;
  description?: string;
  source?: string;
  difficulty?: DifficultyLevel;
  cuisine?: string;
  tags: string[] = [];
  servings: number;
  prepTimeMinutes: number;
  cookTimeMinutes: number;
  waitingTimeMinutes: number;
  totalTimeMinutes: number;
  activeTimeMinutes: number;
  imageUrls: string[] = [];
  thumbnailUrl?: string;
  familyIds: string[] = [];
  isPublished: boolean = false;
  ratingCount: number = 0;
  rating: number = 0;
  isOwner: boolean = false;
  sections: RenderedRecipeSection[] = [];
  equipment?: string[] = [];
  sourceArtifacts?: string[] = [];
  nutrition?: RenderedNutritionInfo;
}
```
