The recipes.blue monorepo recipes.blue
recipes appview atproto
at main 114 lines 4.4 kB view raw
1import { customType, index, integer, primaryKey, pgTable, text, jsonb, varchar } from "drizzle-orm/pg-core"; 2import { BlueRecipesFeedRecipe } from "@cookware/lexicons"; 3import { Cid, isCid, ResourceUri, type AtprotoDid } from "@atcute/lexicons/syntax"; 4import { Blob, LegacyBlob } from "@atcute/lexicons"; 5import { relations, sql, type SQL } from "drizzle-orm"; 6import { isBlob, isCidLink, isLegacyBlob } from "@atcute/lexicons/interfaces"; 7 8const dateIsoText = customType<{ data: Date; driverData: string }>({ 9 dataType() { 10 return "text"; 11 }, 12 toDriver: (value) => value.toISOString(), 13 fromDriver: (value) => new Date(value), 14}); 15 16const atBlob = customType<{ data: Blob | LegacyBlob; driverData: string; }>({ 17 dataType() { 18 return "text"; 19 }, 20 toDriver: (value) => { 21 if (isLegacyBlob(value)) { 22 return `l:${value.cid}:${value.mimeType}`; 23 } else if (isBlob(value)) { 24 return `b:${value.ref.$link}:${value.mimeType}:${value.size}`; 25 } else { 26 throw new Error('Invalid blob value'); 27 } 28 }, 29 fromDriver: (value): Blob | LegacyBlob => { 30 if (typeof value !== 'string') throw new Error('Invalid blob ref data type'); 31 var parts = value.split(':'); 32 if (value.startsWith('l:')) { 33 if (parts.length !== 3) throw new Error('Invalid legacy blob ref format'); 34 if (!isCid(parts[1]!)) throw new Error('Invalid CID in legacy blob ref'); 35 return { 36 cid: parts[1]!, 37 mimeType: parts[2]!, 38 } as LegacyBlob; 39 } else if (value.startsWith('b:')) { 40 if (parts.length !== 4) throw new Error('Invalid blob ref format'); 41 if (!isCidLink({ $link: parts[1] })) throw new Error('Invalid CID link in blob ref'); 42 if (isNaN(parseInt(parts[3]!, 10))) throw new Error('Invalid size in blob ref'); 43 44 return { 45 $type: 'blob', 46 mimeType: parts[2], 47 size: parseInt(parts[3]!), 48 ref: { $link: parts[1] } 49 } as Blob; 50 } else { 51 throw new Error('Invalid blob ref prefix'); 52 } 53 }, 54}); 55 56export const profilesTable = pgTable("profiles", { 57 uri: text('uri') 58 .generatedAlwaysAs((): SQL => sql`'at://' || ${profilesTable.did} || '/blue.recipes.actor.profile/self'`) 59 .$type<ResourceUri>(), 60 61 cid: text("cid").$type<Cid>().notNull(), 62 did: text("did").$type<AtprotoDid>().notNull().primaryKey(), 63 ingestedAt: dateIsoText("ingested_at").notNull().default(sql`CURRENT_TIMESTAMP`), 64 65 displayName: varchar('display_name', { length: 640 }).notNull(), 66 description: varchar('description', { length: 2500 }), 67 pronouns: varchar('pronouns', { length: 200 }), 68 website: text('website'), 69 avatarRef: atBlob('avatar'), 70 bannerRef: atBlob('banner'), 71 createdAt: dateIsoText("created_at").notNull(), 72}, t => ([ 73 index('profiles_cid_idx').on(t.cid), 74 index('profiles_cat_idx').on(t.createdAt), 75 index('profiles_iat_idx').on(t.ingestedAt), 76])); 77 78export const recipeTable = pgTable("recipes", { 79 uri: text('uri') 80 .generatedAlwaysAs((): SQL => sql`'at://' || ${recipeTable.did} || '/blue.recipes.feed.recipe/' || ${recipeTable.rkey}`), 81 82 cid: text("cid").$type<Cid>().notNull(), 83 did: text("author_did") 84 .$type<AtprotoDid>() 85 .notNull() 86 .references(() => profilesTable.did, { onDelete: 'cascade' }), 87 rkey: text('rkey').notNull(), 88 89 imageRef: atBlob('image'), 90 91 title: text('title').notNull(), 92 time: integer('time').notNull().default(0), 93 serves: integer('serves'), 94 description: text('description'), 95 96 ingredients: jsonb('ingredients').$type<BlueRecipesFeedRecipe.Main['ingredients']>().notNull(), 97 ingredientsCount: integer('ingredients_count').generatedAlwaysAs((): SQL => sql`jsonb_array_length(${recipeTable.ingredients})`), 98 99 steps: jsonb('steps').$type<BlueRecipesFeedRecipe.Main['steps']>().notNull(), 100 stepsCount: integer('steps_count').generatedAlwaysAs((): SQL => sql`jsonb_array_length(${recipeTable.steps})`), 101 102 createdAt: dateIsoText("created_at").notNull(), 103 ingestedAt: dateIsoText("ingested_at").notNull().default(sql`CURRENT_TIMESTAMP`), 104}, t => ([ 105 index('recipes_title_idx').on(t.title), 106 index('recipes_cid_idx').on(t.cid), 107 index('recipes_cat_idx').on(t.createdAt), 108 index('recipes_iat_idx').on(t.ingestedAt), 109 primaryKey({ columns: [ t.did, t.rkey ] }), 110])); 111 112export const recipeTableRelations = relations(recipeTable, ({ one }) => ({ 113 author: one(profilesTable, { fields: [recipeTable.did], references: [profilesTable.did] }) 114}));