import { customType, index, integer, primaryKey, pgTable, text, jsonb, varchar } from "drizzle-orm/pg-core"; import { BlueRecipesFeedRecipe } from "@cookware/lexicons"; import { Cid, isCid, ResourceUri, type AtprotoDid } from "@atcute/lexicons/syntax"; import { Blob, LegacyBlob } from "@atcute/lexicons"; import { relations, sql, type SQL } from "drizzle-orm"; import { isBlob, isCidLink, isLegacyBlob } from "@atcute/lexicons/interfaces"; const dateIsoText = customType<{ data: Date; driverData: string }>({ dataType() { return "text"; }, toDriver: (value) => value.toISOString(), fromDriver: (value) => new Date(value), }); const atBlob = customType<{ data: Blob | LegacyBlob; driverData: string; }>({ dataType() { return "text"; }, toDriver: (value) => { if (isLegacyBlob(value)) { return `l:${value.cid}:${value.mimeType}`; } else if (isBlob(value)) { return `b:${value.ref.$link}:${value.mimeType}:${value.size}`; } else { throw new Error('Invalid blob value'); } }, fromDriver: (value): Blob | LegacyBlob => { if (typeof value !== 'string') throw new Error('Invalid blob ref data type'); var parts = value.split(':'); if (value.startsWith('l:')) { if (parts.length !== 3) throw new Error('Invalid legacy blob ref format'); if (!isCid(parts[1]!)) throw new Error('Invalid CID in legacy blob ref'); return { cid: parts[1]!, mimeType: parts[2]!, } as LegacyBlob; } else if (value.startsWith('b:')) { if (parts.length !== 4) throw new Error('Invalid blob ref format'); if (!isCidLink({ $link: parts[1] })) throw new Error('Invalid CID link in blob ref'); if (isNaN(parseInt(parts[3]!, 10))) throw new Error('Invalid size in blob ref'); return { $type: 'blob', mimeType: parts[2], size: parseInt(parts[3]!), ref: { $link: parts[1] } } as Blob; } else { throw new Error('Invalid blob ref prefix'); } }, }); export const profilesTable = pgTable("profiles", { uri: text('uri') .generatedAlwaysAs((): SQL => sql`'at://' || ${profilesTable.did} || '/blue.recipes.actor.profile/self'`) .$type(), cid: text("cid").$type().notNull(), did: text("did").$type().notNull().primaryKey(), ingestedAt: dateIsoText("ingested_at").notNull().default(sql`CURRENT_TIMESTAMP`), displayName: varchar('display_name', { length: 640 }).notNull(), description: varchar('description', { length: 2500 }), pronouns: varchar('pronouns', { length: 200 }), website: text('website'), avatarRef: atBlob('avatar'), bannerRef: atBlob('banner'), createdAt: dateIsoText("created_at").notNull(), }, t => ([ index('profiles_cid_idx').on(t.cid), index('profiles_cat_idx').on(t.createdAt), index('profiles_iat_idx').on(t.ingestedAt), ])); export const recipeTable = pgTable("recipes", { uri: text('uri') .generatedAlwaysAs((): SQL => sql`'at://' || ${recipeTable.did} || '/blue.recipes.feed.recipe/' || ${recipeTable.rkey}`), cid: text("cid").$type().notNull(), did: text("author_did") .$type() .notNull() .references(() => profilesTable.did, { onDelete: 'cascade' }), rkey: text('rkey').notNull(), imageRef: atBlob('image'), title: text('title').notNull(), time: integer('time').notNull().default(0), serves: integer('serves'), description: text('description'), ingredients: jsonb('ingredients').$type().notNull(), ingredientsCount: integer('ingredients_count').generatedAlwaysAs((): SQL => sql`jsonb_array_length(${recipeTable.ingredients})`), steps: jsonb('steps').$type().notNull(), stepsCount: integer('steps_count').generatedAlwaysAs((): SQL => sql`jsonb_array_length(${recipeTable.steps})`), createdAt: dateIsoText("created_at").notNull(), ingestedAt: dateIsoText("ingested_at").notNull().default(sql`CURRENT_TIMESTAMP`), }, t => ([ index('recipes_title_idx').on(t.title), index('recipes_cid_idx').on(t.cid), index('recipes_cat_idx').on(t.createdAt), index('recipes_iat_idx').on(t.ingestedAt), primaryKey({ columns: [ t.did, t.rkey ] }), ])); export const recipeTableRelations = relations(recipeTable, ({ one }) => ({ author: one(profilesTable, { fields: [recipeTable.did], references: [profilesTable.did] }) }));