···11+{
22+ "lexicon": 1,
33+ "id": "social.drydown.house",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "description": "A fragrance house or brand",
88+ "key": "tid",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["name", "createdAt"],
1212+ "properties": {
1313+ "name": {
1414+ "type": "string",
1515+ "minLength": 1,
1616+ "maxLength": 100,
1717+ "description": "House/brand name (must be unique per user)"
1818+ },
1919+ "createdAt": {
2020+ "type": "string",
2121+ "format": "datetime",
2222+ "description": "Timestamp when house was created"
2323+ },
2424+ "updatedAt": {
2525+ "type": "string",
2626+ "format": "datetime",
2727+ "description": "Timestamp of last update (for name corrections)"
2828+ }
2929+ }
3030+ }
3131+ }
3232+ }
3333+}
+26
src/types/lexicon-types.ts
···11+/**
22+ * AT Protocol URI format: at://did/collection/rkey
33+ */
44+export type AtUri = string
55+66+/**
77+ * House/brand record (social.drydown.house)
88+ */
99+export interface House {
1010+ uri?: AtUri // AT Protocol record URI (assigned after creation)
1111+ name: string // 1-100 chars, required, unique per user
1212+ createdAt: string // ISO 8601
1313+ updatedAt?: string // ISO 8601
1414+}
1515+1616+/**
1717+ * Fragrance record (social.drydown.fragrance)
1818+ */
1919+export interface Fragrance {
2020+ uri?: AtUri // AT Protocol record URI
2121+ name: string // 1-200 chars, required
2222+ house: AtUri // Reference to house record (required)
2323+ year?: number // 1000-2100, optional
2424+ createdAt: string // ISO 8601
2525+ updatedAt?: string // ISO 8601
2626+}
+21
src/validation/cascade.ts
···11+import type { Fragrance, AtUri } from '@/types/lexicon-types'
22+33+/**
44+ * Check if house can be deleted (no fragrances reference it)
55+ */
66+export function canDeleteHouse(
77+ houseUri: AtUri,
88+ fragrances: Fragrance[]
99+): { canDelete: boolean; reason?: string; count?: number } {
1010+ const referencingFragrances = fragrances.filter(f => f.house === houseUri)
1111+1212+ if (referencingFragrances.length > 0) {
1313+ return {
1414+ canDelete: false,
1515+ reason: `${referencingFragrances.length} fragrance(s) reference this house`,
1616+ count: referencingFragrances.length
1717+ }
1818+ }
1919+2020+ return { canDelete: true }
2121+}
+20
src/validation/uniqueness.ts
···11+import type { House, AtUri } from '@/types/lexicon-types'
22+33+/**
44+ * Check if house name is unique (case-insensitive)
55+ */
66+export function isHouseNameUnique(
77+ name: string,
88+ existingHouses: House[],
99+ excludeUri?: AtUri
1010+): boolean {
1111+ const normalized = name.trim().toLowerCase()
1212+ return !existingHouses.some(h =>
1313+ h.uri !== excludeUri &&
1414+ h.name.toLowerCase() === normalized
1515+ )
1616+}
1717+1818+// NOTE: Fragrances do NOT need to be unique
1919+// The same house can have multiple fragrances with the same name but different years
2020+// Example: "Creed Aventus 2004" and "Creed Aventus 2024" are both valid
+65
src/validation/validators.ts
···11+import type { House, Fragrance } from '@/types/lexicon-types'
22+import { isHouseNameUnique } from './uniqueness'
33+44+export interface ValidationError {
55+ field: string
66+ message: string
77+}
88+99+export interface ValidationResult {
1010+ valid: boolean
1111+ errors: ValidationError[]
1212+}
1313+1414+export function validateHouse(
1515+ house: House,
1616+ existingHouses: House[]
1717+): ValidationResult {
1818+ const errors: ValidationError[] = []
1919+2020+ if (!house.name || house.name.trim().length === 0) {
2121+ errors.push({ field: 'name', message: 'House name is required' })
2222+ }
2323+2424+ if (house.name && house.name.length > 100) {
2525+ errors.push({ field: 'name', message: 'House name must be 100 characters or less' })
2626+ }
2727+2828+ if (house.name && !isHouseNameUnique(house.name, existingHouses, house.uri)) {
2929+ errors.push({ field: 'name', message: 'A house with this name already exists' })
3030+ }
3131+3232+ return { valid: errors.length === 0, errors }
3333+}
3434+3535+export function validateFragrance(
3636+ fragrance: Fragrance,
3737+ houses: House[]
3838+): ValidationResult {
3939+ const errors: ValidationError[] = []
4040+4141+ if (!fragrance.name || fragrance.name.trim().length === 0) {
4242+ errors.push({ field: 'name', message: 'Fragrance name is required' })
4343+ }
4444+4545+ if (fragrance.name && fragrance.name.length > 200) {
4646+ errors.push({ field: 'name', message: 'Fragrance name must be 200 characters or less' })
4747+ }
4848+4949+ if (!fragrance.house) {
5050+ errors.push({ field: 'house', message: 'House is required' })
5151+ }
5252+5353+ if (fragrance.house && !houses.some(h => h.uri === fragrance.house)) {
5454+ errors.push({ field: 'house', message: 'Selected house does not exist' })
5555+ }
5656+5757+ // NOTE: No uniqueness check for (house, name) pairs
5858+ // Multiple fragrances can have the same name under the same house (different years/batches)
5959+6060+ if (fragrance.year !== undefined && (fragrance.year < 1000 || fragrance.year > 2100)) {
6161+ errors.push({ field: 'year', message: 'Year must be between 1000 and 2100' })
6262+ }
6363+6464+ return { valid: errors.length === 0, errors }
6565+}