feat: use Supabase for flower job storage
This commit is contained in:
@@ -107,9 +107,10 @@ export async function fetchJob(jobId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ mimeType?: string, base64?: string } | null | undefined} image
|
||||
* @param {{ mimeType?: string, base64?: string, url?: string } | null | undefined} image
|
||||
*/
|
||||
export function toDataUrl(image) {
|
||||
if (image?.url) return image.url;
|
||||
if (!image?.base64) return '';
|
||||
return `data:${image.mimeType || 'image/png'};base64,${image.base64}`;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mockMoodAnalysis,
|
||||
mockRecipe
|
||||
} from '$lib/server/gemini/mock.js';
|
||||
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
|
||||
import { loadDevBouquetImages } from './loadFixtureImages.js';
|
||||
|
||||
/** @typedef {'options' | 'result'} DevSeedStage */
|
||||
@@ -14,15 +15,15 @@ import { loadDevBouquetImages } from './loadFixtureImages.js';
|
||||
* @param {Record<string, unknown>} userInput
|
||||
* @param {DevSeedStage} [stage='result']
|
||||
*/
|
||||
export function seedDevJob(userInput, stage = 'result') {
|
||||
export async function seedDevJob(userInput, stage = 'result') {
|
||||
const moodAnalysis = mockMoodAnalysis();
|
||||
const recipe = mockRecipe(userInput);
|
||||
const imagePrompt = mockImagePrompt(recipe);
|
||||
const images = loadDevBouquetImages();
|
||||
const floristNote = stage === 'result' ? mockFloristNote(recipe) : null;
|
||||
|
||||
const job = createJob(userInput);
|
||||
updateJob(job.id, {
|
||||
const job = await createJob(userInput);
|
||||
const images = await uploadGeneratedImages(job.id, loadDevBouquetImages());
|
||||
await updateJob(job.id, {
|
||||
moodAnalysis,
|
||||
recipe,
|
||||
imagePrompt,
|
||||
|
||||
75
src/lib/server/flowerFlow/imageStorage.js
Normal file
75
src/lib/server/flowerFlow/imageStorage.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
getSupabaseClient,
|
||||
getSupabaseStorageBucket,
|
||||
throwSupabaseError
|
||||
} from '$lib/server/supabase.js';
|
||||
|
||||
/** @typedef {import('./jobStore.js').BouquetSize} BouquetSize */
|
||||
/** @typedef {import('./jobStore.js').GeneratedImage} GeneratedImage */
|
||||
|
||||
const EXTENSION_BY_MIME = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/svg+xml': 'svg'
|
||||
};
|
||||
|
||||
/** @param {string} mimeType */
|
||||
function extensionForMime(mimeType) {
|
||||
return EXTENSION_BY_MIME[mimeType] ?? 'bin';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} jobId
|
||||
* @param {BouquetSize} size
|
||||
* @param {GeneratedImage} image
|
||||
* @returns {Promise<GeneratedImage>}
|
||||
*/
|
||||
export async function uploadGeneratedImage(jobId, size, image) {
|
||||
if (!image.base64) {
|
||||
return image;
|
||||
}
|
||||
|
||||
const supabase = getSupabaseClient();
|
||||
const bucket = getSupabaseStorageBucket();
|
||||
const mimeType = image.mimeType || 'image/png';
|
||||
const path = `${jobId}/${size.toLowerCase()}.${extensionForMime(mimeType)}`;
|
||||
const bytes = Buffer.from(image.base64, 'base64');
|
||||
|
||||
const { error } = await supabase.storage.from(bucket).upload(path, bytes, {
|
||||
contentType: mimeType,
|
||||
upsert: true
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throwSupabaseError(error, 'upload bouquet image');
|
||||
}
|
||||
|
||||
const { data } = supabase.storage.from(bucket).getPublicUrl(path);
|
||||
|
||||
return {
|
||||
mimeType,
|
||||
url: data.publicUrl,
|
||||
path
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} jobId
|
||||
* @param {Partial<Record<BouquetSize, GeneratedImage>>} images
|
||||
* @returns {Promise<Partial<Record<BouquetSize, GeneratedImage>>>}
|
||||
*/
|
||||
export async function uploadGeneratedImages(jobId, images) {
|
||||
/** @type {Partial<Record<BouquetSize, GeneratedImage>>} */
|
||||
const uploaded = {};
|
||||
const sizes = /** @type {BouquetSize[]} */ (['S', 'M', 'L']);
|
||||
|
||||
for (const size of sizes) {
|
||||
const image = images[size];
|
||||
if (image) {
|
||||
uploaded[size] = await uploadGeneratedImage(jobId, size, image);
|
||||
}
|
||||
}
|
||||
|
||||
return uploaded;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getSupabaseClient, throwSupabaseError } from '$lib/server/supabase.js';
|
||||
|
||||
/** @typedef {'S' | 'M' | 'L'} BouquetSize */
|
||||
|
||||
@@ -36,7 +37,9 @@ import { randomUUID } from 'node:crypto';
|
||||
/**
|
||||
* @typedef {Object} GeneratedImage
|
||||
* @property {string} mimeType
|
||||
* @property {string} base64
|
||||
* @property {string} [base64]
|
||||
* @property {string} [url]
|
||||
* @property {string} [path]
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -52,16 +55,51 @@ import { randomUUID } from 'node:crypto';
|
||||
* @property {string | null} floristNote
|
||||
*/
|
||||
|
||||
/** @type {Map<string, FlowerJob>} */
|
||||
const jobs = new Map();
|
||||
/**
|
||||
* @param {any} row
|
||||
* @returns {FlowerJob}
|
||||
*/
|
||||
function fromRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
createdAt: new Date(row.created_at).getTime(),
|
||||
userInput: row.user_input ?? {},
|
||||
moodAnalysis: row.mood_analysis ?? null,
|
||||
recipe: row.recipe ?? null,
|
||||
imagePrompt: row.image_prompt ?? null,
|
||||
images: row.images ?? {},
|
||||
selectedSize: row.selected_size ?? null,
|
||||
floristNote: row.florist_note ?? null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Partial<FlowerJob>} patch
|
||||
*/
|
||||
function toRowPatch(patch) {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const row = {};
|
||||
|
||||
if ('userInput' in patch) row.user_input = patch.userInput ?? {};
|
||||
if ('moodAnalysis' in patch) row.mood_analysis = patch.moodAnalysis;
|
||||
if ('recipe' in patch) row.recipe = patch.recipe;
|
||||
if ('imagePrompt' in patch) row.image_prompt = patch.imagePrompt;
|
||||
if ('images' in patch) row.images = patch.images ?? {};
|
||||
if ('selectedSize' in patch) row.selected_size = patch.selectedSize;
|
||||
if ('floristNote' in patch) row.florist_note = patch.floristNote;
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
/** @param {Partial<UserInput>} [userInput] */
|
||||
export function createJob(userInput = {}) {
|
||||
export async function createJob(userInput = {}) {
|
||||
const id = randomUUID();
|
||||
const createdAt = Date.now();
|
||||
|
||||
/** @type {FlowerJob} */
|
||||
const job = {
|
||||
id,
|
||||
createdAt: Date.now(),
|
||||
createdAt,
|
||||
userInput,
|
||||
moodAnalysis: null,
|
||||
recipe: null,
|
||||
@@ -71,31 +109,61 @@ export function createJob(userInput = {}) {
|
||||
floristNote: null
|
||||
};
|
||||
|
||||
jobs.set(id, job);
|
||||
const { error } = await getSupabaseClient()
|
||||
.from('flower_jobs')
|
||||
.insert({
|
||||
id,
|
||||
created_at: new Date(createdAt).toISOString(),
|
||||
user_input: userInput,
|
||||
images: {}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throwSupabaseError(error, 'insert flower job');
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/** @param {string} jobId */
|
||||
export function getJob(jobId) {
|
||||
return jobs.get(jobId) ?? null;
|
||||
export async function getJob(jobId) {
|
||||
const { data, error } = await getSupabaseClient()
|
||||
.from('flower_jobs')
|
||||
.select('*')
|
||||
.eq('id', jobId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
throwSupabaseError(error, 'select flower job');
|
||||
}
|
||||
|
||||
return data ? fromRow(data) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} jobId
|
||||
* @param {Partial<FlowerJob>} patch
|
||||
*/
|
||||
export function updateJob(jobId, patch) {
|
||||
const job = jobs.get(jobId);
|
||||
if (!job) return null;
|
||||
export async function updateJob(jobId, patch) {
|
||||
const rowPatch = toRowPatch(patch);
|
||||
|
||||
const updated = { ...job, ...patch };
|
||||
jobs.set(jobId, updated);
|
||||
return updated;
|
||||
const { data, error } = await getSupabaseClient()
|
||||
.from('flower_jobs')
|
||||
.update(rowPatch)
|
||||
.eq('id', jobId)
|
||||
.select('*')
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
throwSupabaseError(error, 'update flower job');
|
||||
}
|
||||
|
||||
return data ? fromRow(data) : null;
|
||||
}
|
||||
|
||||
/** @param {string} jobId */
|
||||
export function requireJob(jobId) {
|
||||
const job = getJob(jobId);
|
||||
export async function requireJob(jobId) {
|
||||
const job = await getJob(jobId);
|
||||
if (!job) {
|
||||
throw new JobNotFoundError(jobId);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ import { describeAiError } from '$lib/server/aiError.js';
|
||||
*/
|
||||
export function toErrorResponse(error) {
|
||||
if (error instanceof JobNotFoundError) {
|
||||
console.warn(
|
||||
`[flower-flow] job_not_found (404) — ${error.message} (server restart wipes jobs)`
|
||||
);
|
||||
console.warn(`[flower-flow] job_not_found (404) — ${error.message}`);
|
||||
return new Response(JSON.stringify({ error: error.message, code: 'job_not_found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
|
||||
49
src/lib/server/supabase.js
Normal file
49
src/lib/server/supabase.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
let client = null;
|
||||
|
||||
export function getSupabaseClient() {
|
||||
if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
|
||||
throw new Error('SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be configured');
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
client = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export function getSupabaseStorageBucket() {
|
||||
return env.SUPABASE_STORAGE_BUCKET || 'flower-bouquets';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} error
|
||||
* @param {string} action
|
||||
* @returns {never}
|
||||
*/
|
||||
export function throwSupabaseError(error, action) {
|
||||
/** @type {any} */
|
||||
const supabaseError = error;
|
||||
const message =
|
||||
typeof supabaseError?.message === 'string'
|
||||
? supabaseError.message
|
||||
: 'Unexpected Supabase error';
|
||||
const detail = typeof supabaseError?.details === 'string' ? ` ${supabaseError.details}` : '';
|
||||
const hint = typeof supabaseError?.hint === 'string' ? ` Hint: ${supabaseError.hint}` : '';
|
||||
const wrapped = new Error(`Supabase ${action} failed: ${message}.${detail}${hint}`);
|
||||
|
||||
wrapped.name = 'SupabaseError';
|
||||
// Preserve the PostgREST/storage code for server logs and future classifiers.
|
||||
// @ts-expect-error - attach provider-specific metadata to a standard Error.
|
||||
wrapped.code = supabaseError?.code;
|
||||
|
||||
throw wrapped;
|
||||
}
|
||||
Reference in New Issue
Block a user