add recipe recommendations
Deploy to Cloudflare Pages / deploy (push) Failing after 1m2s

This commit is contained in:
2026-08-24 04:38:06 +00:00 Unverified
parent e351416b59
commit 8b44ddd06b
25 changed files with 2836 additions and 151 deletions
+28 -9
View File
@@ -1,14 +1,33 @@
import { defineRelations } from "drizzle-orm";
import { tImages, tItems, type TImage, type TItem } from "./schema";
import {
tImages,
tItems,
tRecommendationBatches,
tRecommendations,
type TImage,
type TItem,
} from "./schema";
export const relations = defineRelations({ tItems, tImages }, (r) => ({
tItems: {
icon: r.one.tImages({
from: r.tItems.iconId,
to: r.tImages.id,
}),
},
}));
export const relations = defineRelations(
{ tItems, tImages, tRecommendationBatches, tRecommendations },
(r) => ({
tItems: {
icon: r.one.tImages({
from: r.tItems.iconId,
to: r.tImages.id,
}),
},
tRecommendationBatches: {
recommendations: r.many.tRecommendations(),
},
tRecommendations: {
batch: r.one.tRecommendationBatches({
from: r.tRecommendations.batchId,
to: r.tRecommendationBatches.id,
}),
},
}),
);
export type WithIcon<T extends TItem> = T & { icon: TImage | null };
+46
View File
@@ -3,6 +3,10 @@ import { sqliteTable } from "drizzle-orm/sqlite-core/table";
export type ItemId = number & { __brand: "item_id" };
export type ImageId = number & { __brand: "image_id" };
export type RecommendationBatchId = number & {
__brand: "recommendation_batch_id";
};
export type RecommendationId = number & { __brand: "recommendation_id" };
export const tItems = sqliteTable("items", {
id: integer().$type<ItemId>().primaryKey({ autoIncrement: true }),
@@ -22,5 +26,47 @@ export const tImages = sqliteTable("images", {
size: integer().notNull(),
});
export const tRecommendationBatches = sqliteTable("recommendation_batches", {
id: integer().$type<RecommendationBatchId>().primaryKey({ autoIncrement: true }),
sessionId: text("session_id").notNull(),
locale: text().notNull(),
generatedAt: integer("generated_at", { mode: "timestamp" }).notNull(),
});
export const tRecommendations = sqliteTable("recommendations", {
id: integer().$type<RecommendationId>().primaryKey({ autoIncrement: true }),
batchId: integer("batch_id")
.$type<RecommendationBatchId>()
.notNull()
.references(() => tRecommendationBatches.id, {
onUpdate: "cascade",
onDelete: "cascade",
}),
tier: text().notNull(),
title: text().notNull(),
description: text().notNull(),
cookMinutes: integer("cook_minutes").notNull(),
instructions: text().notNull(),
ingredients: text().notNull(),
imageUrl: text("image_url"),
});
export const tRecommendationJobs = sqliteTable("recommendation_jobs", {
sessionId: text("session_id").primaryKey(),
locale: text().notNull().default("en"),
dueAt: integer("due_at", { mode: "timestamp" }).notNull(),
attempts: integer().notNull().default(0),
revision: integer().notNull().default(1),
rateLimitKey: text("rate_limit_key").notNull().default("unknown"),
lastError: text("last_error"),
});
export const tRecommendationRateLimits = sqliteTable("recommendation_rate_limits", {
key: text().primaryKey(),
windowStartedAt: integer("window_started_at", { mode: "timestamp" }).notNull(),
count: integer().notNull().default(0),
});
export type TItem = typeof tItems.$inferSelect;
export type TImage = typeof tImages.$inferSelect;
export type TRecommendation = typeof tRecommendations.$inferSelect;
@@ -0,0 +1,27 @@
CREATE TABLE `recommendation_batches` (
`id` integer PRIMARY KEY AUTOINCREMENT,
`session_id` text NOT NULL,
`locale` text NOT NULL,
`generated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `recommendation_jobs` (
`session_id` text PRIMARY KEY,
`locale` text DEFAULT 'en' NOT NULL,
`due_at` integer NOT NULL,
`attempts` integer DEFAULT 0 NOT NULL,
`last_error` text
);
--> statement-breakpoint
CREATE TABLE `recommendations` (
`id` integer PRIMARY KEY AUTOINCREMENT,
`batch_id` integer NOT NULL,
`tier` text NOT NULL,
`title` text NOT NULL,
`description` text NOT NULL,
`cook_minutes` integer NOT NULL,
`instructions` text NOT NULL,
`ingredients` text NOT NULL,
`image_url` text,
CONSTRAINT `fk_recommendations_batch_id_recommendation_batches_id_fk` FOREIGN KEY (`batch_id`) REFERENCES `recommendation_batches`(`id`) ON UPDATE CASCADE ON DELETE CASCADE
);
@@ -0,0 +1,386 @@
{
"version": "7",
"dialect": "sqlite",
"id": "14fc125c-3c68-42d8-93f5-23fc9f580a4d",
"prevIds": [
"2ae7f3f4-d589-4da7-b78a-392fd615f656"
],
"ddl": [
{
"name": "images",
"entityType": "tables"
},
{
"name": "items",
"entityType": "tables"
},
{
"name": "recommendation_batches",
"entityType": "tables"
},
{
"name": "recommendation_jobs",
"entityType": "tables"
},
{
"name": "recommendations",
"entityType": "tables"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "images"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "mime",
"entityType": "columns",
"table": "images"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "images"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "size",
"entityType": "columns",
"table": "images"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_id",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expiry",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "category",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "locale",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "generated_at",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'en'",
"generated": null,
"name": "locale",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "due_at",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "attempts",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "last_error",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "batch_id",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "tier",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "title",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "description",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "cook_minutes",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "instructions",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ingredients",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "image_url",
"entityType": "columns",
"table": "recommendations"
},
{
"columns": [
"icon_id"
],
"tableTo": "images",
"columnsTo": [
"id"
],
"onUpdate": "CASCADE",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_items_icon_id_images_id_fk",
"entityType": "fks",
"table": "items"
},
{
"columns": [
"batch_id"
],
"tableTo": "recommendation_batches",
"columnsTo": [
"id"
],
"onUpdate": "CASCADE",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_recommendations_batch_id_recommendation_batches_id_fk",
"entityType": "fks",
"table": "recommendations"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "images_pk",
"table": "images",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "items_pk",
"table": "items",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "recommendation_batches_pk",
"table": "recommendation_batches",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "recommendation_jobs_pk",
"table": "recommendation_jobs",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "recommendations_pk",
"table": "recommendations",
"entityType": "pks"
}
],
"renames": []
}
@@ -0,0 +1 @@
ALTER TABLE `recommendation_jobs` ADD `revision` integer DEFAULT 1 NOT NULL;
@@ -0,0 +1,396 @@
{
"version": "7",
"dialect": "sqlite",
"id": "f6169acd-f73e-4e45-9ef8-b35a9e38d6bc",
"prevIds": [
"14fc125c-3c68-42d8-93f5-23fc9f580a4d"
],
"ddl": [
{
"name": "images",
"entityType": "tables"
},
{
"name": "items",
"entityType": "tables"
},
{
"name": "recommendation_batches",
"entityType": "tables"
},
{
"name": "recommendation_jobs",
"entityType": "tables"
},
{
"name": "recommendations",
"entityType": "tables"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "images"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "mime",
"entityType": "columns",
"table": "images"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "images"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "size",
"entityType": "columns",
"table": "images"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_id",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expiry",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "category",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "locale",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "generated_at",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'en'",
"generated": null,
"name": "locale",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "due_at",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "attempts",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "1",
"generated": null,
"name": "revision",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "last_error",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "batch_id",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "tier",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "title",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "description",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "cook_minutes",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "instructions",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ingredients",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "image_url",
"entityType": "columns",
"table": "recommendations"
},
{
"columns": [
"icon_id"
],
"tableTo": "images",
"columnsTo": [
"id"
],
"onUpdate": "CASCADE",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_items_icon_id_images_id_fk",
"entityType": "fks",
"table": "items"
},
{
"columns": [
"batch_id"
],
"tableTo": "recommendation_batches",
"columnsTo": [
"id"
],
"onUpdate": "CASCADE",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_recommendations_batch_id_recommendation_batches_id_fk",
"entityType": "fks",
"table": "recommendations"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "images_pk",
"table": "images",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "items_pk",
"table": "items",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "recommendation_batches_pk",
"table": "recommendation_batches",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "recommendation_jobs_pk",
"table": "recommendation_jobs",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "recommendations_pk",
"table": "recommendations",
"entityType": "pks"
}
],
"renames": []
}
@@ -0,0 +1,5 @@
CREATE TABLE `recommendation_rate_limits` (
`key` text PRIMARY KEY,
`window_started_at` integer NOT NULL,
`count` integer DEFAULT 0 NOT NULL
);
@@ -0,0 +1,439 @@
{
"version": "7",
"dialect": "sqlite",
"id": "7b679a63-4e38-4c77-9596-a5b8fc36512d",
"prevIds": [
"f6169acd-f73e-4e45-9ef8-b35a9e38d6bc"
],
"ddl": [
{
"name": "images",
"entityType": "tables"
},
{
"name": "items",
"entityType": "tables"
},
{
"name": "recommendation_batches",
"entityType": "tables"
},
{
"name": "recommendation_jobs",
"entityType": "tables"
},
{
"name": "recommendation_rate_limits",
"entityType": "tables"
},
{
"name": "recommendations",
"entityType": "tables"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "images"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "mime",
"entityType": "columns",
"table": "images"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "images"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "size",
"entityType": "columns",
"table": "images"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_id",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expiry",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "category",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "locale",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "generated_at",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'en'",
"generated": null,
"name": "locale",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "due_at",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "attempts",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "1",
"generated": null,
"name": "revision",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "last_error",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "recommendation_rate_limits"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "window_started_at",
"entityType": "columns",
"table": "recommendation_rate_limits"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "count",
"entityType": "columns",
"table": "recommendation_rate_limits"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "batch_id",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "tier",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "title",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "description",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "cook_minutes",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "instructions",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ingredients",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "image_url",
"entityType": "columns",
"table": "recommendations"
},
{
"columns": [
"icon_id"
],
"tableTo": "images",
"columnsTo": [
"id"
],
"onUpdate": "CASCADE",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_items_icon_id_images_id_fk",
"entityType": "fks",
"table": "items"
},
{
"columns": [
"batch_id"
],
"tableTo": "recommendation_batches",
"columnsTo": [
"id"
],
"onUpdate": "CASCADE",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_recommendations_batch_id_recommendation_batches_id_fk",
"entityType": "fks",
"table": "recommendations"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "images_pk",
"table": "images",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "items_pk",
"table": "items",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "recommendation_batches_pk",
"table": "recommendation_batches",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "recommendation_jobs_pk",
"table": "recommendation_jobs",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "recommendation_rate_limits_pk",
"table": "recommendation_rate_limits",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "recommendations_pk",
"table": "recommendations",
"entityType": "pks"
}
],
"renames": []
}
@@ -0,0 +1 @@
ALTER TABLE `recommendation_jobs` ADD `rate_limit_key` text DEFAULT 'unknown' NOT NULL;
@@ -0,0 +1,449 @@
{
"version": "7",
"dialect": "sqlite",
"id": "cd16457a-37ac-4d7b-9fc7-b10efa42c7e7",
"prevIds": [
"7b679a63-4e38-4c77-9596-a5b8fc36512d"
],
"ddl": [
{
"name": "images",
"entityType": "tables"
},
{
"name": "items",
"entityType": "tables"
},
{
"name": "recommendation_batches",
"entityType": "tables"
},
{
"name": "recommendation_jobs",
"entityType": "tables"
},
{
"name": "recommendation_rate_limits",
"entityType": "tables"
},
{
"name": "recommendations",
"entityType": "tables"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "images"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "mime",
"entityType": "columns",
"table": "images"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "images"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "size",
"entityType": "columns",
"table": "images"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_id",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expiry",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "category",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "locale",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "generated_at",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'en'",
"generated": null,
"name": "locale",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "due_at",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "attempts",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "1",
"generated": null,
"name": "revision",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'unknown'",
"generated": null,
"name": "rate_limit_key",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "last_error",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "recommendation_rate_limits"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "window_started_at",
"entityType": "columns",
"table": "recommendation_rate_limits"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "count",
"entityType": "columns",
"table": "recommendation_rate_limits"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "batch_id",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "tier",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "title",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "description",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "cook_minutes",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "instructions",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ingredients",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "image_url",
"entityType": "columns",
"table": "recommendations"
},
{
"columns": [
"icon_id"
],
"tableTo": "images",
"columnsTo": [
"id"
],
"onUpdate": "CASCADE",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_items_icon_id_images_id_fk",
"entityType": "fks",
"table": "items"
},
{
"columns": [
"batch_id"
],
"tableTo": "recommendation_batches",
"columnsTo": [
"id"
],
"onUpdate": "CASCADE",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_recommendations_batch_id_recommendation_batches_id_fk",
"entityType": "fks",
"table": "recommendations"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "images_pk",
"table": "images",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "items_pk",
"table": "items",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "recommendation_batches_pk",
"table": "recommendation_batches",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "recommendation_jobs_pk",
"table": "recommendation_jobs",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "recommendation_rate_limits_pk",
"table": "recommendation_rate_limits",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "recommendations_pk",
"table": "recommendations",
"entityType": "pks"
}
],
"renames": []
}
+33 -9
View File
@@ -1,6 +1,14 @@
import { command, getRequestEvent, query } from "$app/server";
import { tImages, tItems, type ItemId } from "$lib/db/schema";
import {
tImages,
tItems,
type ItemId,
} from "$lib/db/schema";
import { getDb } from "$lib/server/db";
import {
getRecommendationRateLimitKey,
scheduleRecommendationJob,
} from "$lib/server/recommendations";
import { eq } from "drizzle-orm";
export const getItems = query(() => {
@@ -22,14 +30,20 @@ export const getItem = query("unchecked", (id: ItemId) => {
export const addItems = command(
"unchecked",
async (
items: {
name: string;
category: string;
expiry: Date;
icon: { mime: string; data: Uint8Array; size: number };
}[],
{
items,
locale,
}: {
items: {
name: string;
category: string;
expiry: Date;
icon: { mime: string; data: Uint8Array; size: number };
}[];
locale: string;
},
): Promise<ItemId[]> => {
const { locals } = getRequestEvent();
const { locals, request } = getRequestEvent();
const db = getDb();
const ids: ItemId[] = [];
@@ -47,12 +61,18 @@ export const addItems = command(
ids.push(inserted.id);
}
await scheduleRecommendationJob(db, locals.sessionId, {
locale,
delay: 5 * 60 * 1000,
rateLimitKey: await getRecommendationRateLimitKey(request),
});
return ids;
},
);
export const removeItem = command("unchecked", async (id: ItemId) => {
const { locals } = getRequestEvent();
const { locals, request } = getRequestEvent();
const db = getDb();
const item = await db.query.tItems.findFirst({
where: { id: { eq: id }, sessionId: { eq: locals.sessionId } },
@@ -65,4 +85,8 @@ export const removeItem = command("unchecked", async (id: ItemId) => {
} else {
await db.delete(tItems).where(eq(tItems.id, id));
}
await scheduleRecommendationJob(db, locals.sessionId, {
delay: 5 * 60 * 1000,
rateLimitKey: await getRecommendationRateLimitKey(request),
});
});
+27 -1
View File
@@ -34,7 +34,33 @@
"saving": "Saving...",
"cancel": "Cancel",
"delete": "Delete",
"reload": "Reload"
"reload": "Reload",
"close": "Close"
},
"guide": {
"eyebrow": "Your kitchen, planned",
"title": "What should we cook?",
"description": "Fresh ideas built around what is in your fridge and what should be used first.",
"refresh": "Refresh recommendations",
"generating": "Planning your next meals",
"generating_note": "This can take a moment while we consider your fridge.",
"generation_failed": "We couldn't prepare recommendations right now.",
"view_recipe": "View recipe",
"minutes": "{count} minutes",
"ingredients": "Ingredients",
"instructions": "Instructions",
"tier": {
"near_expiry_food": "Use these first",
"near_expiry_ingredients": "Rescue these ingredients",
"discovery": "Something different",
"fridge": "More from your fridge"
},
"tier_note": {
"near_expiry_food": "Meals centered on food closest to its expiry.",
"near_expiry_ingredients": "Ideas that put vulnerable ingredients to work.",
"discovery": "Interesting dishes for when you want a change.",
"fridge": "Good combinations from everything else you have."
}
},
"label": {
"food": "Food",
+27 -1
View File
@@ -34,7 +34,33 @@
"saving": "กำลังบันทึก...",
"cancel": "ยกเลิก",
"delete": "ลบ",
"reload": "รีโหลด"
"reload": "รีโหลด",
"close": "ปิด"
},
"guide": {
"eyebrow": "วางแผนจากของในครัว",
"title": "วันนี้ทำอะไรกินดี?",
"description": "เมนูแนะนำจากของในตู้เย็น โดยเน้นวัตถุดิบที่ควรใช้ก่อน",
"refresh": "สร้างคำแนะนำใหม่",
"generating": "กำลังวางแผนมื้อถัดไป",
"generating_note": "ระบบกำลังพิจารณาของในตู้เย็น อาจใช้เวลาสักครู่",
"generation_failed": "ไม่สามารถสร้างคำแนะนำได้ในขณะนี้",
"view_recipe": "ดูสูตรอาหาร",
"minutes": "{count} นาที",
"ingredients": "วัตถุดิบ",
"instructions": "วิธีทำ",
"tier": {
"near_expiry_food": "ควรใช้ก่อน",
"near_expiry_ingredients": "ช่วยวัตถุดิบเหล่านี้",
"discovery": "ลองอะไรใหม่ๆ",
"fridge": "เมนูจากของที่เหลือ"
},
"tier_note": {
"near_expiry_food": "เมนูจากอาหารที่ใกล้หมดอายุมากที่สุด",
"near_expiry_ingredients": "ไอเดียสำหรับใช้วัตถุดิบที่ควรรีบใช้",
"discovery": "เมนูน่าสนใจสำหรับวันที่อยากลองสิ่งใหม่",
"fridge": "จับคู่ของอื่นๆ ที่มีอยู่ในตู้เย็น"
}
},
"label": {
"food": "อาหาร",
+134
View File
@@ -0,0 +1,134 @@
import { command, getRequestEvent, query } from "$app/server";
import {
tRecommendationBatches,
tRecommendationJobs,
tRecommendations,
} from "$lib/db/schema";
import { getDb } from "$lib/server/db";
import {
getRecommendationApiKey,
getRecommendationRateLimitKey,
RecommendationRateLimitError,
runRecommendationJob,
scheduleRecommendationJob,
} from "$lib/server/recommendations";
import type { MenuIngredient, MenuTier } from "$lib/recommendations";
import { desc, eq } from "drizzle-orm";
import { error } from "@sveltejs/kit";
export const getRecommendations = query(async () => {
const { locals } = getRequestEvent();
const db = getDb();
const [[batch], [job]] = await Promise.all([
db
.select()
.from(tRecommendationBatches)
.where(eq(tRecommendationBatches.sessionId, locals.sessionId))
.orderBy(desc(tRecommendationBatches.generatedAt))
.limit(1),
db
.select({ attempts: tRecommendationJobs.attempts })
.from(tRecommendationJobs)
.where(eq(tRecommendationJobs.sessionId, locals.sessionId))
.limit(1),
]);
if (!batch) {
return {
generatedAt: null,
pending: Boolean(job && job.attempts < 3),
menus: [],
};
}
const [rows, items] = await Promise.all([
db
.select()
.from(tRecommendations)
.where(eq(tRecommendations.batchId, batch.id)),
db.query.tItems.findMany({
with: { icon: true },
where: { sessionId: { eq: locals.sessionId } },
}),
]);
const itemById = new Map(items.map((item) => [Number(item.id), item]));
return {
generatedAt: batch.generatedAt,
pending: Boolean(job && job.attempts < 3),
menus: rows.map((row) => {
const ingredients = JSON.parse(row.ingredients) as MenuIngredient[];
return {
id: row.id,
tier: row.tier as MenuTier,
title: row.title,
description: row.description,
cookMinutes: row.cookMinutes,
imageUrl: row.imageUrl ?? undefined,
instructions: JSON.parse(row.instructions) as string[],
ingredients: ingredients.map((ingredient) => ({
...ingredient,
fridgeItem: ingredient.itemId
? itemById.get(Number(ingredient.itemId))
: undefined,
})),
};
}),
};
});
export const generateRecommendations = command(
"unchecked",
async ({ locale, force = false }: { locale: string; force?: boolean }) => {
const event = getRequestEvent();
const db = getDb();
const normalizedLocale = /^[a-z]{2}(?:-[A-Z]{2})?$/.test(locale) ? locale : "en";
const [existing] = await db
.select({
id: tRecommendationBatches.id,
generatedAt: tRecommendationBatches.generatedAt,
})
.from(tRecommendationBatches)
.where(eq(tRecommendationBatches.sessionId, event.locals.sessionId))
.orderBy(desc(tRecommendationBatches.generatedAt))
.limit(1);
if (
existing &&
(!force || existing.generatedAt.getTime() > Date.now() - 5 * 60 * 1000)
) {
return existing.id;
}
const [activeJob] = await db
.select({ dueAt: tRecommendationJobs.dueAt, attempts: tRecommendationJobs.attempts })
.from(tRecommendationJobs)
.where(eq(tRecommendationJobs.sessionId, event.locals.sessionId))
.limit(1);
if (activeJob && activeJob.attempts > 0 && activeJob.dueAt > new Date()) {
return existing?.id ?? null;
}
const rateLimitKey = await getRecommendationRateLimitKey(event.request);
await scheduleRecommendationJob(db, event.locals.sessionId, {
locale: normalizedLocale,
rateLimitKey,
});
let id: number | null;
try {
id = await runRecommendationJob(
db,
event.locals.sessionId,
getRecommendationApiKey(event.platform),
);
} catch (cause) {
if (cause instanceof RecommendationRateLimitError) {
error(429, "Recommendation generation limit reached. Try again later.");
}
throw cause;
}
void getRecommendations().refresh();
return id ?? existing?.id ?? null;
},
);
+30
View File
@@ -0,0 +1,30 @@
import type { ItemId, RecommendationId } from "$lib/db/schema";
export const menuTiers = [
"near_expiry_food",
"near_expiry_ingredients",
"discovery",
"fridge",
] as const;
export type MenuTier = (typeof menuTiers)[number];
export type MenuIngredient = {
amount: string;
itemId?: ItemId;
externalName?: string;
};
export type GeneratedMenu = {
tier: MenuTier;
title: string;
description: string;
cookMinutes: number;
instructions: string[];
ingredients: MenuIngredient[];
imageUrl?: string;
};
export type RecommendationMenu = GeneratedMenu & {
id: RecommendationId;
};
+6 -1
View File
@@ -1,7 +1,12 @@
import { getRequestEvent } from "$app/server";
import { relations } from "$lib/db/relations";
import type { D1Database } from "@cloudflare/workers-types";
import { drizzle } from "drizzle-orm/d1";
export function createDb(database: D1Database) {
return drizzle(database, { relations, logger: true });
}
export function getDb() {
const { platform } = getRequestEvent();
if (!platform) {
@@ -9,5 +14,5 @@ export function getDb() {
"platform.env is unavailable — is the D1 binding configured in wrangler.jsonc?",
);
}
return drizzle(platform.env.DB, { relations, logger: true });
return createDb(platform.env.DB);
}
+412
View File
@@ -0,0 +1,412 @@
import {
tItems,
tRecommendationBatches,
tRecommendationJobs,
tRecommendationRateLimits,
type ItemId,
} from "$lib/db/schema";
import {
menuTiers,
type GeneratedMenu,
type MenuIngredient,
type MenuTier,
} from "$lib/recommendations";
import { and, desc, eq, lt, lte, or, sql } from "drizzle-orm";
import type { createDb } from "$lib/server/db";
type Db = ReturnType<typeof createDb>;
const MODEL = "gemini-3.6-flash";
const RETRY_DELAY = 30 * 60 * 1000;
const JOB_LEASE = 15 * 60 * 1000;
const MAX_ATTEMPTS = 3;
const RATE_LIMIT_WINDOW = 60 * 60 * 1000;
const RATE_LIMIT_MAX = 6;
const RESPONSE_SCHEMA = {
type: "object",
properties: {
menus: {
type: "array",
minItems: 8,
maxItems: 16,
items: {
type: "object",
properties: {
tier: { type: "string", enum: menuTiers },
title: { type: "string" },
description: { type: "string" },
cookMinutes: { type: "integer" },
instructions: { type: "array", items: { type: "string" } },
ingredients: {
type: "array",
items: {
type: "object",
properties: {
amount: { type: "string" },
itemId: { type: "integer", nullable: true },
externalName: { type: "string", nullable: true },
},
required: ["amount"],
},
},
imageUrl: { type: "string", nullable: true },
},
required: [
"tier",
"title",
"description",
"cookMinutes",
"instructions",
"ingredients",
],
},
},
},
required: ["menus"],
};
function isTier(value: unknown): value is MenuTier {
return typeof value === "string" && menuTiers.includes(value as MenuTier);
}
function normalizeMenus(value: unknown, validItemIds: Set<number>) {
if (!value || typeof value !== "object") throw new Error("Invalid Gemini response");
const rawMenus = (value as { menus?: unknown }).menus;
if (!Array.isArray(rawMenus)) throw new Error("Gemini returned no menus");
return rawMenus.flatMap((raw): GeneratedMenu[] => {
if (!raw || typeof raw !== "object") return [];
const menu = raw as Record<string, unknown>;
if (
!isTier(menu.tier) ||
typeof menu.title !== "string" ||
typeof menu.description !== "string" ||
!Number.isFinite(menu.cookMinutes) ||
!Array.isArray(menu.instructions) ||
!Array.isArray(menu.ingredients)
) {
return [];
}
const ingredients = menu.ingredients.flatMap((rawIngredient): MenuIngredient[] => {
if (!rawIngredient || typeof rawIngredient !== "object") return [];
const ingredient = rawIngredient as Record<string, unknown>;
const itemId = Number(ingredient.itemId);
const externalName =
typeof ingredient.externalName === "string"
? ingredient.externalName.trim()
: "";
const amount =
typeof ingredient.amount === "string" ? ingredient.amount.trim() : "";
if (validItemIds.has(itemId)) {
return [{ amount, itemId: itemId as ItemId }];
}
if (externalName) return [{ amount, externalName }];
return [];
});
return [
{
tier: menu.tier,
title: menu.title.trim(),
description: menu.description.trim(),
cookMinutes: Math.max(1, Math.round(Number(menu.cookMinutes))),
instructions: menu.instructions.filter(
(step): step is string => typeof step === "string" && step.trim().length > 0,
),
ingredients,
imageUrl:
typeof menu.imageUrl === "string" && menu.imageUrl.startsWith("https://")
? menu.imageUrl
: undefined,
},
];
});
}
async function requestMenus(
items: (typeof tItems.$inferSelect)[],
locale: string,
apiKey: string,
) {
const inventory = items.map((item) => ({
id: item.id,
name: item.name,
category: item.category,
expiry: item.expiry?.toISOString() ?? null,
}));
const prompt = [
"You create practical recipe recommendations for a fridge inventory app.",
`Write all user-facing content in locale ${locale}.`,
"Return 2-4 menus in each of these exact tiers:",
"near_expiry_food: complete meals centered on food expiring soon.",
"near_expiry_ingredients: recipes that consume individual ingredients expiring soon.",
"discovery: interesting unrelated dishes worth trying, even if groceries are needed.",
"fridge: recipes that combine other available fridge items.",
"For an ingredient already in inventory, return its numeric itemId and do not return externalName.",
"For anything that must be bought, return externalName and no itemId.",
"Only provide imageUrl when it is a direct, durable HTTPS image URL you are confident exists; otherwise omit it.",
`Today is ${new Date().toISOString()}. Inventory: ${JSON.stringify(inventory)}`,
].join("\n");
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${apiKey}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
generationConfig: {
responseMimeType: "application/json",
responseSchema: RESPONSE_SCHEMA,
},
}),
},
);
if (!response.ok) {
throw new Error(`Gemini request failed (${response.status}): ${await response.text()}`);
}
const body = (await response.json()) as {
candidates?: { content?: { parts?: { text?: string }[] } }[];
};
const text = body.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) throw new Error("Gemini returned no recommendation content");
const menus = normalizeMenus(
JSON.parse(text),
new Set(items.map((item) => Number(item.id))),
);
if (menus.length === 0) throw new Error("Gemini returned no valid menus");
return menus;
}
export function getRecommendationApiKey(platform?: App.Platform) {
const key = platform?.env.GEMINI_API_KEY ?? process.env.GEMINI_API_KEY;
if (!key) throw new Error("GEMINI_API_KEY is not configured");
return key;
}
export class RecommendationRateLimitError extends Error {}
export async function getRecommendationRateLimitKey(request: Request) {
const source =
request.headers.get("cf-connecting-ip") ??
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
"unknown";
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(source),
);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
export async function consumeRecommendationQuota(db: Db, key: string) {
const now = new Date();
const cutoff = new Date(now.getTime() - RATE_LIMIT_WINDOW);
await db
.insert(tRecommendationRateLimits)
.values({ key, windowStartedAt: now, count: 0 })
.onConflictDoNothing();
const [updated] = await db
.update(tRecommendationRateLimits)
.set({
count: sql`case when ${tRecommendationRateLimits.windowStartedAt} <= ${cutoff} then 1 else ${tRecommendationRateLimits.count} + 1 end`,
windowStartedAt: sql`case when ${tRecommendationRateLimits.windowStartedAt} <= ${cutoff} then ${now} else ${tRecommendationRateLimits.windowStartedAt} end`,
})
.where(
and(
eq(tRecommendationRateLimits.key, key),
or(
lte(tRecommendationRateLimits.windowStartedAt, cutoff),
lt(tRecommendationRateLimits.count, RATE_LIMIT_MAX),
),
),
)
.returning({ count: tRecommendationRateLimits.count });
return Boolean(updated);
}
export async function generateRecommendationsForSession(
db: Db,
sessionId: string,
locale: string,
apiKey: string,
revision: number,
) {
const items = await db.select().from(tItems).where(eq(tItems.sessionId, sessionId));
const menus = await requestMenus(items, locale, apiKey);
const batchId = (crypto.getRandomValues(new Uint32Array(1))[0] & 0x7fffffff) || 1;
const statements = [
db.$client
.prepare(
"INSERT INTO recommendation_batches (id, session_id, locale, generated_at) " +
"SELECT ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM recommendation_jobs WHERE session_id = ? AND revision = ?)",
)
.bind(batchId, sessionId, locale, Math.floor(Date.now() / 1000), sessionId, revision),
...menus.map((menu) =>
db.$client
.prepare(
"INSERT INTO recommendations " +
"(batch_id, tier, title, description, cook_minutes, instructions, ingredients, image_url) " +
"SELECT ?, ?, ?, ?, ?, ?, ?, ? WHERE EXISTS " +
"(SELECT 1 FROM recommendation_batches WHERE id = ?)",
)
.bind(
batchId,
menu.tier,
menu.title,
menu.description,
menu.cookMinutes,
JSON.stringify(menu.instructions),
JSON.stringify(menu.ingredients),
menu.imageUrl ?? null,
batchId,
),
),
db.$client
.prepare(
"DELETE FROM recommendation_batches WHERE session_id = ? AND id <> ? " +
"AND EXISTS (SELECT 1 FROM recommendation_batches WHERE id = ?)",
)
.bind(sessionId, batchId, batchId),
db.$client
.prepare("DELETE FROM recommendation_jobs WHERE session_id = ? AND revision = ?")
.bind(sessionId, revision),
];
const results = await db.$client.batch(statements);
return results[0]?.meta.changes ? batchId : null;
}
export async function scheduleRecommendationJob(
db: Db,
sessionId: string,
options: { locale?: string; delay?: number; rateLimitKey?: string } = {},
) {
let locale = options.locale;
if (!locale) {
const [latest] = await db
.select({ locale: tRecommendationBatches.locale })
.from(tRecommendationBatches)
.where(eq(tRecommendationBatches.sessionId, sessionId))
.orderBy(desc(tRecommendationBatches.generatedAt))
.limit(1);
locale = latest?.locale ?? "en";
}
const normalizedLocale = /^[a-z]{2}(?:-[A-Z]{2})?$/.test(locale) ? locale : "en";
const dueAt = new Date(Date.now() + (options.delay ?? 0));
await db
.insert(tRecommendationJobs)
.values({
sessionId,
locale: normalizedLocale,
dueAt,
rateLimitKey: options.rateLimitKey ?? "unknown",
})
.onConflictDoUpdate({
target: tRecommendationJobs.sessionId,
set: {
locale: normalizedLocale,
dueAt,
attempts: 0,
revision: sql`${tRecommendationJobs.revision} + 1`,
rateLimitKey: options.rateLimitKey ?? "unknown",
lastError: null,
},
});
}
async function claimRecommendationJob(db: Db, sessionId: string) {
const [job] = await db
.update(tRecommendationJobs)
.set({
dueAt: new Date(Date.now() + JOB_LEASE),
attempts: sql`${tRecommendationJobs.attempts} + 1`,
})
.where(
and(
eq(tRecommendationJobs.sessionId, sessionId),
lte(tRecommendationJobs.dueAt, new Date()),
lt(tRecommendationJobs.attempts, MAX_ATTEMPTS),
),
)
.returning();
return job;
}
export async function runRecommendationJob(
db: Db,
sessionId: string,
apiKey: string,
) {
const job = await claimRecommendationJob(db, sessionId);
if (!job) return null;
try {
if (!(await consumeRecommendationQuota(db, job.rateLimitKey))) {
await db
.update(tRecommendationJobs)
.set({ attempts: MAX_ATTEMPTS, lastError: "Generation rate limit reached" })
.where(
and(
eq(tRecommendationJobs.sessionId, job.sessionId),
eq(tRecommendationJobs.revision, job.revision),
),
);
throw new RecommendationRateLimitError("Generation rate limit reached");
}
return await generateRecommendationsForSession(
db,
job.sessionId,
job.locale,
apiKey,
job.revision,
);
} catch (error) {
await db
.update(tRecommendationJobs)
.set({
dueAt: new Date(Date.now() + RETRY_DELAY),
lastError: error instanceof Error ? error.message.slice(0, 1000) : String(error),
})
.where(
and(
eq(tRecommendationJobs.sessionId, job.sessionId),
eq(tRecommendationJobs.revision, job.revision),
),
);
throw error;
}
}
export async function processDueRecommendationJobs(db: Db, apiKey: string) {
const jobs = await db
.select()
.from(tRecommendationJobs)
.where(
and(
lte(tRecommendationJobs.dueAt, new Date()),
lt(tRecommendationJobs.attempts, MAX_ATTEMPTS),
),
)
.limit(10);
let completed = 0;
for (const job of jobs) {
try {
if ((await runRecommendationJob(db, job.sessionId, apiKey)) !== null) {
completed += 1;
}
} catch {
// The claimed job records its own failure and retry time.
}
}
return { processed: jobs.length, completed };
}