···11+@document.meta
22+title: Arabica Recipes
33+authors: @pdewey.com
44+created: 03/17/26
55+categories: [spec, lexicon]
66+@end
77+88+* Recipe Records
99+1010+** Motivation
1111+1212+ When inputting a new brew, it is tedious to type out all the fields when many
1313+ of them are always the same. A "recipe" is supposed to solve parts of this
1414+ tedium by auto-filling a number of the fields (most notably pours; count,
1515+ water amount, and time). A user should be able to create/save a recipe from
1616+ one of their brews (or maybe another users brews as well?).
1717+1818+ Recipes are also have potential as a social construct that can be easily
1919+ shared around and used. Saving another users recipe is something I would like
2020+ to build around in the future. Analytics would also be super cool for
2121+ recipes.
2222+2323+** Lexicon Fields
2424+2525+ - `name` (string)
2626+ - `brewerRef` (ref)
2727+ - `brewerType` (string)
2828+ - `coffeeAmount` (int -- should be * 10 but isn't)
2929+ - `waterAmount` (int * 10)
3030+ - `grindSize` (int * 10)
3131+ - `pours` (array of `#pour`) (references {*** Pour Schema})`
3232+ - `notes`: (string) reeform description field
3333+3434+ It would probably also make sense to ceraete a `brewRef` field that draws a
3535+ link to the brew that the original version of the recipe was created from.
3636+3737+*** Pour Schema
3838+3939+ Both required:
4040+ - `waterAmount` (int - multiplied by 10)
4141+ - `timeSeconds` (int)
4242+4343+** Implementation
4444+4545+ - Once selected, a recipe should autofill all non-null fields in the brew
4646+ - A user should be able to save a recipe off of any brew (by them or by
4747+ another user)
4848+ - Users should be able to create new recipes from scratch (requires new modal
4949+ and view page)
5050+5151+** Debugging and Changes
5252+5353+*** Explore page
5454+5555+ The explore page filters are a bit weird, and values between certain ones
5656+ seem a bit wonky (i.e. a 15g dose brew shows up in single, small, and large
5757+ filters when it should probably only show up in the single cup one (small
5858+ should probably be 12 or less? -- not sure about the exact values).
5959+6060+ Recipes should also show the profile picture and username of the creator of
6161+ the recipe.
6262+6363+ Some recipe fields should be interpolated from other fields when missing
6464+ (i.e. water amount and ratio, from pours and coffee amount. Ratio may be
6565+ transitive)
+23
internal/atproto/cache.go
···1818 Roasters []*models.Roaster
1919 Grinders []*models.Grinder
2020 Brewers []*models.Brewer
2121+ Recipes []*models.Recipe
2122 Brews []*models.Brew
2223 Timestamp time.Time
2324}
···4041 Roasters: c.Roasters,
4142 Grinders: c.Grinders,
4243 Brewers: c.Brewers,
4444+ Recipes: c.Recipes,
4345 Brews: c.Brews,
4446 Timestamp: c.Timestamp,
4547 }
···119121 defer sc.mu.Unlock()
120122 newCache := sc.caches[sessionID].clone()
121123 newCache.Brewers = brewers
124124+ newCache.Timestamp = time.Now()
125125+ sc.caches[sessionID] = newCache
126126+}
127127+128128+// SetRecipes updates just the recipes in the cache using copy-on-write
129129+func (sc *SessionCache) SetRecipes(sessionID string, recipes []*models.Recipe) {
130130+ sc.mu.Lock()
131131+ defer sc.mu.Unlock()
132132+ newCache := sc.caches[sessionID].clone()
133133+ newCache.Recipes = recipes
122134 newCache.Timestamp = time.Now()
123135 sc.caches[sessionID] = newCache
124136}
···175187 if cache, ok := sc.caches[sessionID]; ok {
176188 newCache := cache.clone()
177189 newCache.Brewers = nil
190190+ sc.caches[sessionID] = newCache
191191+ }
192192+}
193193+194194+// InvalidateRecipes marks that recipes need to be refreshed using copy-on-write
195195+func (sc *SessionCache) InvalidateRecipes(sessionID string) {
196196+ sc.mu.Lock()
197197+ defer sc.mu.Unlock()
198198+ if cache, ok := sc.caches[sessionID]; ok {
199199+ newCache := cache.clone()
200200+ newCache.Recipes = nil
178201 sc.caches[sessionID] = newCache
179202 }
180203}
+1
internal/atproto/nsid.go
···1919 NSIDComment = NSIDBase + ".comment"
2020 NSIDGrinder = NSIDBase + ".grinder"
2121 NSIDLike = NSIDBase + ".like"
2222+ NSIDRecipe = NSIDBase + ".recipe"
2223 NSIDRoaster = NSIDBase + ".roaster"
23242425 // MaxRKeyLength is the maximum allowed length for a record key
···139139 return resolveRef(ctx, client, atURI, sessionID, NSIDBrewer, RecordToBrewer)
140140}
141141142142+// ResolveRecipeRef fetches a recipe record from an AT-URI
143143+func ResolveRecipeRef(ctx context.Context, client *Client, atURI string, sessionID string) (*models.Recipe, error) {
144144+ return resolveRef(ctx, client, atURI, sessionID, NSIDRecipe, RecordToRecipe)
145145+}
146146+142147// ResolveBrewRefs resolves all references within a brew record
143143-// This is a convenience function that resolves bean, grinder, and brewer refs in one call
148148+// This is a convenience function that resolves bean, grinder, brewer, and recipe refs in one call
144149func ResolveBrewRefs(ctx context.Context, client *Client, brew *models.Brew, beanRef, grinderRef, brewerRef, sessionID string) error {
145150 var err error
146151
+267-5
internal/atproto/store.go
···5252 brew.BrewerRKey = c.RKey
5353 }
5454 }
5555+ if recipeRef, _ := record["recipeRef"].(string); recipeRef != "" {
5656+ if c, err := ResolveATURI(recipeRef); err == nil {
5757+ brew.RecipeRKey = c.RKey
5858+ }
5959+ }
5560}
56615762// brewModelFromRequest converts a CreateBrewRequest into a Brew model with the given creation time.
5863func brewModelFromRequest(req *models.CreateBrewRequest, createdAt time.Time) *models.Brew {
5964 brew := &models.Brew{
6065 BeanRKey: req.BeanRKey,
6666+ RecipeRKey: req.RecipeRKey,
6167 GrinderRKey: req.GrinderRKey,
6268 BrewerRKey: req.BrewerRKey,
6369 Method: req.Method,
···92989399 beanURI := BuildATURI(s.did.String(), NSIDBean, brew.BeanRKey)
941009595- var grinderURI, brewerURI string
101101+ var grinderURI, brewerURI, recipeURI string
96102 if brew.GrinderRKey != "" {
97103 grinderURI = BuildATURI(s.did.String(), NSIDGrinder, brew.GrinderRKey)
98104 }
99105 if brew.BrewerRKey != "" {
100106 brewerURI = BuildATURI(s.did.String(), NSIDBrewer, brew.BrewerRKey)
107107+ }
108108+ if brew.RecipeRKey != "" {
109109+ recipeURI = BuildATURI(s.did.String(), NSIDRecipe, brew.RecipeRKey)
101110 }
102111103112 // Convert to models.Brew for record conversion
104113 brewModel := brewModelFromRequest(brew, time.Now().UTC())
105114106115 // Convert to atproto record
107107- record, err := BrewToRecord(brewModel, beanURI, grinderURI, brewerURI)
116116+ record, err := BrewToRecord(brewModel, beanURI, grinderURI, brewerURI, recipeURI)
108117 if err != nil {
109118 return nil, fmt.Errorf("failed to convert brew to record: %w", err)
110119 }
···171180 if err != nil {
172181 log.Warn().Err(err).Str("brew_rkey", rkey).Msg("Failed to resolve brew references")
173182 }
183183+ if recipeRef, _ := output.Value["recipeRef"].(string); recipeRef != "" {
184184+ brew.RecipeObj, err = ResolveRecipeRef(ctx, s.client, recipeRef, s.sessionID)
185185+ if err != nil {
186186+ log.Warn().Err(err).Str("brew_rkey", rkey).Msg("Failed to resolve recipe reference")
187187+ }
188188+ }
174189175190 return brew, nil
176191}
···213228 if err != nil {
214229 log.Warn().Err(err).Str("brew_rkey", rkey).Msg("Failed to resolve brew references")
215230 }
231231+ if recipeRef, _ := output.Value["recipeRef"].(string); recipeRef != "" {
232232+ brew.RecipeObj, err = ResolveRecipeRef(ctx, s.client, recipeRef, s.sessionID)
233233+ if err != nil {
234234+ log.Warn().Err(err).Str("brew_rkey", rkey).Msg("Failed to resolve recipe reference")
235235+ }
236236+ }
216237217238 return &BrewRecord{
218239 Brew: brew,
···255276 }
256277257278 // Resolve references using cached data instead of N+1 queries
258258- // This fetches beans/grinders/brewers once (from cache if available)
279279+ // This fetches beans/grinders/brewers/recipes once (from cache if available)
259280 // then links them to brews in memory
260281 beans, _ := s.ListBeans(ctx)
261282 grinders, _ := s.ListGrinders(ctx)
262283 brewers, _ := s.ListBrewers(ctx)
263284 roasters, _ := s.ListRoasters(ctx)
285285+ recipes, _ := s.ListRecipes(ctx)
264286265287 // Build lookup maps
266288 beanMap := make(map[string]*models.Bean)
···278300 roasterMap := make(map[string]*models.Roaster)
279301 for _, r := range roasters {
280302 roasterMap[r.RKey] = r
303303+ }
304304+ recipeMap := make(map[string]*models.Recipe)
305305+ for _, r := range recipes {
306306+ recipeMap[r.RKey] = r
281307 }
282308283309 // Link references
···295321 if brew.BrewerRKey != "" {
296322 brew.BrewerObj = brewerMap[brew.BrewerRKey]
297323 }
324324+ if brew.RecipeRKey != "" {
325325+ brew.RecipeObj = recipeMap[brew.RecipeRKey]
326326+ }
298327 }
299328300329 // Update cache
···311340312341 beanURI := BuildATURI(s.did.String(), NSIDBean, brew.BeanRKey)
313342314314- var grinderURI, brewerURI string
343343+ var grinderURI, brewerURI, recipeURI string
315344 if brew.GrinderRKey != "" {
316345 grinderURI = BuildATURI(s.did.String(), NSIDGrinder, brew.GrinderRKey)
317346 }
318347 if brew.BrewerRKey != "" {
319348 brewerURI = BuildATURI(s.did.String(), NSIDBrewer, brew.BrewerRKey)
320349 }
350350+ if brew.RecipeRKey != "" {
351351+ recipeURI = BuildATURI(s.did.String(), NSIDRecipe, brew.RecipeRKey)
352352+ }
321353322354 // Get the existing record to preserve createdAt
323355 existing, err := s.GetBrewByRKey(ctx, rkey)
···329361 brewModel := brewModelFromRequest(brew, existing.CreatedAt)
330362331363 // Convert to atproto record
332332- record, err := BrewToRecord(brewModel, beanURI, grinderURI, brewerURI)
364364+ record, err := BrewToRecord(brewModel, beanURI, grinderURI, brewerURI, recipeURI)
333365 if err != nil {
334366 return fmt.Errorf("failed to convert brew to record: %w", err)
335367 }
···1143117511441176 // Invalidate cache
11451177 s.cache.InvalidateBrewers(s.sessionID)
11781178+11791179+ return nil
11801180+}
11811181+11821182+// ========== Recipe Operations ==========
11831183+11841184+// RecipeRecord contains a recipe with its AT Protocol metadata
11851185+type RecipeRecord struct {
11861186+ Recipe *models.Recipe
11871187+ URI string
11881188+ CID string
11891189+}
11901190+11911191+func (s *AtprotoStore) CreateRecipe(ctx context.Context, req *models.CreateRecipeRequest) (*models.Recipe, error) {
11921192+ var brewerURI string
11931193+ if req.BrewerRKey != "" {
11941194+ brewerURI = BuildATURI(s.did.String(), NSIDBrewer, req.BrewerRKey)
11951195+ }
11961196+11971197+ recipeModel := &models.Recipe{
11981198+ Name: req.Name,
11991199+ BrewerRKey: req.BrewerRKey,
12001200+ BrewerType: req.BrewerType,
12011201+ CoffeeAmount: req.CoffeeAmount,
12021202+ WaterAmount: req.WaterAmount,
12031203+ GrindSize: req.GrindSize,
12041204+ Notes: req.Notes,
12051205+ CreatedAt: time.Now(),
12061206+ }
12071207+ if len(req.Pours) > 0 {
12081208+ recipeModel.Pours = make([]*models.Pour, len(req.Pours))
12091209+ for i, pour := range req.Pours {
12101210+ recipeModel.Pours[i] = &models.Pour{
12111211+ WaterAmount: pour.WaterAmount,
12121212+ TimeSeconds: pour.TimeSeconds,
12131213+ }
12141214+ }
12151215+ }
12161216+12171217+ record, err := RecipeToRecord(recipeModel, brewerURI)
12181218+ if err != nil {
12191219+ return nil, fmt.Errorf("failed to convert recipe to record: %w", err)
12201220+ }
12211221+12221222+ output, err := s.client.CreateRecord(ctx, s.did, s.sessionID, &CreateRecordInput{
12231223+ Collection: NSIDRecipe,
12241224+ Record: record,
12251225+ })
12261226+ if err != nil {
12271227+ return nil, fmt.Errorf("failed to create recipe record: %w", err)
12281228+ }
12291229+12301230+ atURI, err := syntax.ParseATURI(output.URI)
12311231+ if err != nil {
12321232+ return nil, fmt.Errorf("failed to parse returned AT-URI: %w", err)
12331233+ }
12341234+12351235+ recipeModel.RKey = atURI.RecordKey().String()
12361236+12371237+ s.cache.InvalidateRecipes(s.sessionID)
12381238+12391239+ return recipeModel, nil
12401240+}
12411241+12421242+func (s *AtprotoStore) GetRecipeByRKey(ctx context.Context, rkey string) (*models.Recipe, error) {
12431243+ output, err := s.client.GetRecord(ctx, s.did, s.sessionID, &GetRecordInput{
12441244+ Collection: NSIDRecipe,
12451245+ RKey: rkey,
12461246+ })
12471247+ if err != nil {
12481248+ return nil, fmt.Errorf("failed to get recipe record: %w", err)
12491249+ }
12501250+12511251+ atURI := BuildATURI(s.did.String(), NSIDRecipe, rkey)
12521252+ recipe, err := RecordToRecipe(output.Value, atURI)
12531253+ if err != nil {
12541254+ return nil, fmt.Errorf("failed to convert recipe record: %w", err)
12551255+ }
12561256+12571257+ recipe.RKey = rkey
12581258+12591259+ // Resolve brewer reference if present
12601260+ if brewerRef, ok := output.Value["brewerRef"].(string); ok && brewerRef != "" {
12611261+ if components, err := ResolveATURI(brewerRef); err == nil {
12621262+ recipe.BrewerRKey = components.RKey
12631263+ }
12641264+ recipe.BrewerObj, err = ResolveBrewerRef(ctx, s.client, brewerRef, s.sessionID)
12651265+ if err != nil {
12661266+ log.Warn().Err(err).Str("recipe_rkey", rkey).Msg("Failed to resolve brewer reference")
12671267+ }
12681268+ }
12691269+12701270+ return recipe, nil
12711271+}
12721272+12731273+// GetRecipeRecordByRKey fetches a recipe by rkey and returns it with its AT Protocol metadata
12741274+func (s *AtprotoStore) GetRecipeRecordByRKey(ctx context.Context, rkey string) (*RecipeRecord, error) {
12751275+ output, err := s.client.GetRecord(ctx, s.did, s.sessionID, &GetRecordInput{
12761276+ Collection: NSIDRecipe,
12771277+ RKey: rkey,
12781278+ })
12791279+ if err != nil {
12801280+ return nil, fmt.Errorf("failed to get recipe record: %w", err)
12811281+ }
12821282+12831283+ atURI := BuildATURI(s.did.String(), NSIDRecipe, rkey)
12841284+ recipe, err := RecordToRecipe(output.Value, atURI)
12851285+ if err != nil {
12861286+ return nil, fmt.Errorf("failed to convert recipe record: %w", err)
12871287+ }
12881288+12891289+ recipe.RKey = rkey
12901290+12911291+ if brewerRef, ok := output.Value["brewerRef"].(string); ok && brewerRef != "" {
12921292+ if components, err := ResolveATURI(brewerRef); err == nil {
12931293+ recipe.BrewerRKey = components.RKey
12941294+ }
12951295+ recipe.BrewerObj, err = ResolveBrewerRef(ctx, s.client, brewerRef, s.sessionID)
12961296+ if err != nil {
12971297+ log.Warn().Err(err).Str("recipe_rkey", rkey).Msg("Failed to resolve brewer reference")
12981298+ }
12991299+ }
13001300+13011301+ return &RecipeRecord{
13021302+ Recipe: recipe,
13031303+ URI: output.URI,
13041304+ CID: output.CID,
13051305+ }, nil
13061306+}
13071307+13081308+func (s *AtprotoStore) ListRecipes(ctx context.Context) ([]*models.Recipe, error) {
13091309+ // Check cache first
13101310+ userCache := s.cache.Get(s.sessionID)
13111311+ if userCache != nil && userCache.Recipes != nil && userCache.IsValid() {
13121312+ return userCache.Recipes, nil
13131313+ }
13141314+13151315+ output, err := s.client.ListAllRecords(ctx, s.did, s.sessionID, NSIDRecipe)
13161316+ if err != nil {
13171317+ return nil, fmt.Errorf("failed to list recipe records: %w", err)
13181318+ }
13191319+13201320+ recipes := make([]*models.Recipe, 0, len(output.Records))
13211321+13221322+ for _, rec := range output.Records {
13231323+ recipe, err := RecordToRecipe(rec.Value, rec.URI)
13241324+ if err != nil {
13251325+ log.Warn().Err(err).Str("uri", rec.URI).Msg("Failed to convert recipe record")
13261326+ continue
13271327+ }
13281328+13291329+ if components, err := ResolveATURI(rec.URI); err == nil {
13301330+ recipe.RKey = components.RKey
13311331+ }
13321332+13331333+ // Extract brewer rkey from reference
13341334+ if brewerRef, ok := rec.Value["brewerRef"].(string); ok && brewerRef != "" {
13351335+ if components, err := ResolveATURI(brewerRef); err == nil {
13361336+ recipe.BrewerRKey = components.RKey
13371337+ }
13381338+ }
13391339+13401340+ recipes = append(recipes, recipe)
13411341+ }
13421342+13431343+ s.cache.SetRecipes(s.sessionID, recipes)
13441344+13451345+ return recipes, nil
13461346+}
13471347+13481348+func (s *AtprotoStore) UpdateRecipeByRKey(ctx context.Context, rkey string, req *models.UpdateRecipeRequest) error {
13491349+ existing, err := s.GetRecipeByRKey(ctx, rkey)
13501350+ if err != nil {
13511351+ return fmt.Errorf("failed to get existing recipe: %w", err)
13521352+ }
13531353+13541354+ var brewerURI string
13551355+ if req.BrewerRKey != "" {
13561356+ brewerURI = BuildATURI(s.did.String(), NSIDBrewer, req.BrewerRKey)
13571357+ }
13581358+13591359+ recipeModel := &models.Recipe{
13601360+ Name: req.Name,
13611361+ BrewerRKey: req.BrewerRKey,
13621362+ BrewerType: req.BrewerType,
13631363+ CoffeeAmount: req.CoffeeAmount,
13641364+ WaterAmount: req.WaterAmount,
13651365+ GrindSize: req.GrindSize,
13661366+ Notes: req.Notes,
13671367+ CreatedAt: existing.CreatedAt,
13681368+ }
13691369+ if len(req.Pours) > 0 {
13701370+ recipeModel.Pours = make([]*models.Pour, len(req.Pours))
13711371+ for i, pour := range req.Pours {
13721372+ recipeModel.Pours[i] = &models.Pour{
13731373+ WaterAmount: pour.WaterAmount,
13741374+ TimeSeconds: pour.TimeSeconds,
13751375+ }
13761376+ }
13771377+ }
13781378+13791379+ record, err := RecipeToRecord(recipeModel, brewerURI)
13801380+ if err != nil {
13811381+ return fmt.Errorf("failed to convert recipe to record: %w", err)
13821382+ }
13831383+13841384+ err = s.client.PutRecord(ctx, s.did, s.sessionID, &PutRecordInput{
13851385+ Collection: NSIDRecipe,
13861386+ RKey: rkey,
13871387+ Record: record,
13881388+ })
13891389+ if err != nil {
13901390+ return fmt.Errorf("failed to update recipe record: %w", err)
13911391+ }
13921392+13931393+ s.cache.InvalidateRecipes(s.sessionID)
13941394+13951395+ return nil
13961396+}
13971397+13981398+func (s *AtprotoStore) DeleteRecipeByRKey(ctx context.Context, rkey string) error {
13991399+ err := s.client.DeleteRecord(ctx, s.did, s.sessionID, &DeleteRecordInput{
14001400+ Collection: NSIDRecipe,
14011401+ RKey: rkey,
14021402+ })
14031403+ if err != nil {
14041404+ return fmt.Errorf("failed to delete recipe record: %w", err)
14051405+ }
14061406+14071407+ s.cache.InvalidateRecipes(s.sessionID)
1146140811471409 return nil
11481410}