The recipes.blue monorepo
recipes.blue
recipes
appview
atproto
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}));