feat: use Supabase for flower job storage

This commit is contained in:
Chaewon Lee
2026-06-12 16:19:36 +09:00
committed by GitHub
parent 922320d59a
commit 5d65a5ffae
18 changed files with 364 additions and 55 deletions

View File

@@ -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}`;
}

View File

@@ -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,

View 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;
}

View File

@@ -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);
}

View File

@@ -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' }

View 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;
}