diff --git a/src/app/admin/photos/page.tsx b/src/app/admin/photos/page.tsx index 87ec26db..66609ca0 100644 --- a/src/app/admin/photos/page.tsx +++ b/src/app/admin/photos/page.tsx @@ -4,7 +4,7 @@ import SiteGrid from '@/components/SiteGrid'; import AdminUploadsTable from '@/admin/AdminUploadsTable'; import { PRO_MODE_ENABLED } from '@/site/config'; import { getStoragePhotoUrlsNoStore } from '@/services/storage/cache'; -import { getPhotos } from '@/photo/db'; +import { getPhotos } from '@/photo/db/query'; import { revalidatePath } from 'next/cache'; import AdminPhotosTable from '@/admin/AdminPhotosTable'; import AdminPhotosTableInfinite from diff --git a/src/app/admin/tags/[tag]/edit/page.tsx b/src/app/admin/tags/[tag]/edit/page.tsx index ffcfe16c..bfeee049 100644 --- a/src/app/admin/tags/[tag]/edit/page.tsx +++ b/src/app/admin/tags/[tag]/edit/page.tsx @@ -4,7 +4,7 @@ import { getPhotosCached } from '@/photo/cache'; import TagForm from '@/tag/TagForm'; import { PATH_ADMIN, PATH_ADMIN_TAGS, pathForTag } from '@/site/paths'; import PhotoLightbox from '@/photo/PhotoLightbox'; -import { getPhotosMeta } from '@/photo/db'; +import { getPhotosMeta } from '@/photo/db/query'; import AdminTagBadge from '@/admin/AdminTagBadge'; const MAX_PHOTO_TO_SHOW = 6; diff --git a/src/app/grid/page.tsx b/src/app/grid/page.tsx index aa5b2328..0b0cb4ab 100644 --- a/src/app/grid/page.tsx +++ b/src/app/grid/page.tsx @@ -6,7 +6,7 @@ import PhotosEmptyState from '@/photo/PhotosEmptyState'; import { Metadata } from 'next/types'; import PhotoGridSidebar from '@/photo/PhotoGridSidebar'; import { getPhotoSidebarData } from '@/photo/data'; -import { getPhotos } from '@/photo/db'; +import { getPhotos } from '@/photo/db/query'; import { cache } from 'react'; import PhotoGridPage from '@/photo/PhotoGridPage'; import { PATH_GRID } from '@/site/paths'; diff --git a/src/app/og/page.tsx b/src/app/og/page.tsx index c86a4584..1394c43f 100644 --- a/src/app/og/page.tsx +++ b/src/app/og/page.tsx @@ -3,7 +3,7 @@ import { INFINITE_SCROLL_GRID_PHOTO_MULTIPLE, } from '@/photo'; import { getPhotosCached } from '@/photo/cache'; -import { getPhotosMeta } from '@/photo/db'; +import { getPhotosMeta } from '@/photo/db/query'; import StaggeredOgPhotos from '@/photo/StaggeredOgPhotos'; import StaggeredOgPhotosInfinite from '@/photo/StaggeredOgPhotosInfinite'; diff --git a/src/app/p/[photoId]/image/route.tsx b/src/app/p/[photoId]/image/route.tsx index 73dbdfdf..60ceefd1 100644 --- a/src/app/p/[photoId]/image/route.tsx +++ b/src/app/p/[photoId]/image/route.tsx @@ -5,7 +5,8 @@ import { getIBMPlexMonoMedium } from '@/site/font'; import { ImageResponse } from 'next/og'; import { getImageResponseCacheControlHeaders } from '@/image-response/cache'; import { IS_PRODUCTION, STATICALLY_OPTIMIZED_OG_IMAGES } from '@/site/config'; -import { GENERATE_STATIC_PARAMS_LIMIT, getPhotoIds } from '@/photo/db'; +import { getPhotoIds } from '@/photo/db/query'; +import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db'; import { isNextImageReadyBasedOnPhotos } from '@/photo'; export let generateStaticParams: diff --git a/src/app/p/[photoId]/layout.tsx b/src/app/p/[photoId]/layout.tsx index 26415c8a..e40e7d9c 100644 --- a/src/app/p/[photoId]/layout.tsx +++ b/src/app/p/[photoId]/layout.tsx @@ -13,7 +13,8 @@ import { import PhotoDetailPage from '@/photo/PhotoDetailPage'; import { getPhotosNearIdCached } from '@/photo/cache'; import { IS_PRODUCTION, STATICALLY_OPTIMIZED_PAGES } from '@/site/config'; -import { GENERATE_STATIC_PARAMS_LIMIT, getPhotoIds } from '@/photo/db'; +import { getPhotoIds } from '@/photo/db/query'; +import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db'; import { ReactNode, cache } from 'react'; const getPhotosNearIdCachedCached = cache((photoId: string) => diff --git a/src/app/page.tsx b/src/app/page.tsx index c9e2097a..c9dfc2bc 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -8,7 +8,7 @@ import { Metadata } from 'next/types'; import { MAX_PHOTOS_TO_SHOW_OG } from '@/image-response'; import PhotosLarge from '@/photo/PhotosLarge'; import { cache } from 'react'; -import { getPhotos, getPhotosMeta } from '@/photo/db'; +import { getPhotos, getPhotosMeta } from '@/photo/db/query'; import PhotosLargeInfinite from '@/photo/PhotosLargeInfinite'; export const dynamic = 'force-static'; diff --git a/src/app/tag/[tag]/[photoId]/layout.tsx b/src/app/tag/[tag]/[photoId]/layout.tsx index 4ff975ad..5de147fd 100644 --- a/src/app/tag/[tag]/[photoId]/layout.tsx +++ b/src/app/tag/[tag]/[photoId]/layout.tsx @@ -13,7 +13,7 @@ import { import PhotoDetailPage from '@/photo/PhotoDetailPage'; import { getPhotosNearIdCached } from '@/photo/cache'; import { ReactNode, cache } from 'react'; -import { getPhotosMeta } from '@/photo/db'; +import { getPhotosMeta } from '@/photo/db/query'; const getPhotosNearIdCachedCached = cache((photoId: string, tag: string) => getPhotosNearIdCached( diff --git a/src/app/tag/hidden/[photoId]/page.tsx b/src/app/tag/hidden/[photoId]/page.tsx index bdde572b..954fca0c 100644 --- a/src/app/tag/hidden/[photoId]/page.tsx +++ b/src/app/tag/hidden/[photoId]/page.tsx @@ -7,7 +7,7 @@ import PhotoDetailPage from '@/photo/PhotoDetailPage'; import { getPhotosNearIdCached, } from '@/photo/cache'; -import { getPhotosMeta } from '@/photo/db'; +import { getPhotosMeta } from '@/photo/db/query'; import { PATH_ROOT, absolutePathForPhoto } from '@/site/paths'; import { TAG_HIDDEN } from '@/tag'; import { Metadata } from 'next'; diff --git a/src/app/tag/hidden/page.tsx b/src/app/tag/hidden/page.tsx index 0d0929ab..ae5b896e 100644 --- a/src/app/tag/hidden/page.tsx +++ b/src/app/tag/hidden/page.tsx @@ -3,7 +3,7 @@ import Banner from '@/components/Banner'; import SiteGrid from '@/components/SiteGrid'; import PhotoGrid from '@/photo/PhotoGrid'; import { getPhotosNoStore } from '@/photo/cache'; -import { getPhotosMeta } from '@/photo/db'; +import { getPhotosMeta } from '@/photo/db/query'; import { absolutePathForTag } from '@/site/paths'; import { TAG_HIDDEN, descriptionForTaggedPhotos, titleForTag } from '@/tag'; import HiddenHeader from '@/tag/HiddenHeader'; diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 64c7bed0..d22568e2 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -1,7 +1,6 @@ 'use server'; import { - GetPhotosOptions, deletePhoto, insertPhoto, deletePhotoTagGlobally, @@ -9,7 +8,8 @@ import { renamePhotoTagGlobally, getPhoto, getPhotos, -} from '@/photo/db'; +} from '@/photo/db/query'; +import { GetPhotosOptions } from './db'; import { PhotoFormData, convertFormDataToPhotoDbInsert, diff --git a/src/photo/cache.ts b/src/photo/cache.ts index 1d0d5bc4..e9d075d7 100644 --- a/src/photo/cache.ts +++ b/src/photo/cache.ts @@ -5,7 +5,6 @@ import { unstable_noStore, } from 'next/cache'; import { - GetPhotosOptions, getPhoto, getPhotos, getUniqueCameras, @@ -15,7 +14,8 @@ import { getPhotosNearId, getPhotosMostRecentUpdate, getPhotosMeta, -} from '@/photo/db'; +} from '@/photo/db/query'; +import { GetPhotosOptions } from './db'; import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo'; import { createCameraKey } from '@/camera'; import { diff --git a/src/photo/data.ts b/src/photo/data.ts index ef00ca27..3c008ef9 100644 --- a/src/photo/data.ts +++ b/src/photo/data.ts @@ -9,7 +9,7 @@ import { getUniqueCameras, getUniqueFilmSimulations, getUniqueTags, -} from '@/photo/db'; +} from '@/photo/db/query'; import { SHOW_FILM_SIMULATIONS } from '@/site/config'; import { sortTagsObject } from '@/tag'; diff --git a/src/photo/db/index.ts b/src/photo/db/index.ts new file mode 100644 index 00000000..8094d545 --- /dev/null +++ b/src/photo/db/index.ts @@ -0,0 +1,116 @@ +import { Camera } from '@/camera'; +import { FilmSimulation } from '@/simulation'; +import { PRIORITY_ORDER_ENABLED } from '@/site/config'; +import { parameterize } from '@/utility/string'; + +export const GENERATE_STATIC_PARAMS_LIMIT = 1000; +export const PHOTO_DEFAULT_LIMIT = 100; + +export type GetPhotosOptions = { + sortBy?: 'createdAt' | 'takenAt' | 'priority'; + limit?: number; + offset?: number; + query?: string; + tag?: string; + camera?: Camera; + simulation?: FilmSimulation; + takenBefore?: Date; + takenAfterInclusive?: Date; + hidden?: 'exclude' | 'include' | 'only'; +}; + +export const getWheresFromOptions = ( + options: GetPhotosOptions, + initialValuesIndex = 1 +) => { + const { + hidden = 'exclude', + takenBefore, + takenAfterInclusive, + query, + tag, + camera, + simulation, + } = options; + + const wheres = [] as string[]; + const wheresValues = [] as (string | number)[]; + let valuesIndex = initialValuesIndex; + + switch (hidden) { + case 'exclude': + wheres.push('hidden IS NOT TRUE'); + break; + case 'only': + wheres.push('hidden IS TRUE'); + break; + } + + if (takenBefore) { + wheres.push(`taken_at > $${valuesIndex++}`); + wheresValues.push(takenBefore.toISOString()); + } + if (takenAfterInclusive) { + wheres.push(`taken_at <= $${valuesIndex++}`); + wheresValues.push(takenAfterInclusive.toISOString()); + } + if (query) { + // eslint-disable-next-line max-len + wheres.push(`CONCAT(title, ' ', caption, ' ', semantic_description) ILIKE $${valuesIndex++}`); + wheresValues.push(`%${query.toLocaleLowerCase()}%`); + } + if (tag) { + wheres.push(`$${valuesIndex++}=ANY(tags)`); + wheresValues.push(tag); + } + if (camera) { + wheres.push(`LOWER(REPLACE(make, ' ', '-'))=$${valuesIndex++}`); + wheres.push(`LOWER(REPLACE(model, ' ', '-'))=$${valuesIndex++}`); + wheresValues.push(parameterize(camera.make, true)); + wheresValues.push(parameterize(camera.model, true)); + } + if (simulation) { + wheres.push(`film_simulation=$${valuesIndex++}`); + wheresValues.push(simulation); + } + + return { + wheres: wheres.length > 0 + ? `WHERE ${wheres.join(' AND ')}` + : '', + wheresValues, + lastValuesIndex: valuesIndex, + }; +}; + +export const getOrderByFromOptions = (options: GetPhotosOptions) => { + const { + sortBy = PRIORITY_ORDER_ENABLED ? 'priority' : 'takenAt', + } = options; + + switch (sortBy) { + case 'createdAt': + return 'ORDER BY created_at DESC'; + case 'takenAt': + return 'ORDER BY taken_at DESC'; + case 'priority': + return 'ORDER BY priority_order ASC, taken_at DESC'; + } +}; + +export const getLimitAndOffsetFromOptions = ( + options: GetPhotosOptions, + initialValuesIndex = 1, +) => { + const { + limit = PHOTO_DEFAULT_LIMIT, + offset = 0, + } = options; + + let valuesIndex = initialValuesIndex; + + return { + limitAndOffset: `LIMIT $${valuesIndex++} OFFSET $${valuesIndex++}`, + limitAndOffsetValues: [limit, offset], + }; +}; diff --git a/src/photo/db.ts b/src/photo/db/query.ts similarity index 77% rename from src/photo/db.ts rename to src/photo/db/query.ts index 949d085e..cda55524 100644 --- a/src/photo/db.ts +++ b/src/photo/db/query.ts @@ -11,28 +11,16 @@ import { Photo, PhotoDateRange, } from '@/photo'; -import { Camera, Cameras, createCameraKey } from '@/camera'; -import { parameterize } from '@/utility/string'; +import { Cameras, createCameraKey } from '@/camera'; import { TagsWithMeta } from '@/tag'; import { FilmSimulation, FilmSimulations } from '@/simulation'; -import { SHOULD_DEBUG_SQL, PRIORITY_ORDER_ENABLED } from '@/site/config'; - -export const GENERATE_STATIC_PARAMS_LIMIT = 1000; - -const PHOTO_DEFAULT_LIMIT = 100; - -export type GetPhotosOptions = { - sortBy?: 'createdAt' | 'takenAt' | 'priority' - limit?: number - offset?: number - query?: string - tag?: string - camera?: Camera - simulation?: FilmSimulation - takenBefore?: Date - takenAfterInclusive?: Date - hidden?: 'exclude' | 'include' | 'only' -} +import { SHOULD_DEBUG_SQL } from '@/site/config'; +import { + GetPhotosOptions, + getLimitAndOffsetFromOptions, + getOrderByFromOptions, +} from '.'; +import { getWheresFromOptions } from '.'; const createPhotosTable = () => sql` @@ -76,6 +64,55 @@ const runMigration01 = () => ADD COLUMN IF NOT EXISTS semantic_description TEXT `; +// Wrapper for most queries for JIT table creation/migration running +const safelyQueryPhotos = async ( + callback: () => Promise, + debugMessage: string +): Promise => { + let result: T; + + const start = new Date(); + + try { + result = await callback(); + } catch (e: any) { + if (MIGRATION_FIELDS_01.some(field => new RegExp( + `column "${field}" of relation "photos" does not exist`, + 'i', + ).test(e.message))) { + console.log('Running migration 01 ...'); + await runMigration01(); + result = await callback(); + } else if (/relation "photos" does not exist/i.test(e.message)) { + // If the table does not exist, create it + console.log('Creating photos table ...'); + await createPhotosTable(); + result = await callback(); + } else if (/endpoint is in transition/i.test(e.message)) { + console.log('sql get error: endpoint is in transition (setting timeout)'); + // Wait 5 seconds and try again + await new Promise(resolve => setTimeout(resolve, 5000)); + try { + result = await callback(); + } catch (e: any) { + console.log(`sql get error on retry (after 5000ms): ${e.message} `); + throw e; + } + } else { + console.log(`sql get error: ${e.message} `); + throw e; + } + } + + if (SHOULD_DEBUG_SQL && debugMessage) { + const time = + (((new Date()).getTime() - start.getTime()) / 1000).toFixed(2); + console.log(`Executing sql query: ${debugMessage} (${time} seconds)`); + } + + return result; +}; + // Must provide id as 8-character nanoid export const insertPhoto = (photo: PhotoDbInsert) => safelyQueryPhotos(() => sql` @@ -181,16 +218,15 @@ export const renamePhotoTagGlobally = (tag: string, updatedTag: string) => `, 'renamePhotoTagGlobally'); export const deletePhoto = (id: string) => - safelyQueryPhotos( - () => sql`DELETE FROM photos WHERE id=${id}`, - 'deletePhoto', - ); + safelyQueryPhotos(() => sql` + DELETE FROM photos WHERE id=${id} + `, 'deletePhoto'); export const getPhotosMostRecentUpdate = async () => safelyQueryPhotos(() => sql` SELECT updated_at FROM photos ORDER BY updated_at DESC LIMIT 1 - `.then(({ rows }) => rows[0] ? rows[0].updated_at as Date : undefined), - 'getPhotosMostRecentUpdate'); + `.then(({ rows }) => rows[0] ? rows[0].updated_at as Date : undefined) + , 'getPhotosMostRecentUpdate'); export const getUniqueTags = async () => safelyQueryPhotos(() => sql` @@ -202,8 +238,8 @@ export const getUniqueTags = async () => `.then(({ rows }): TagsWithMeta => rows.map(({ tag, count }) => ({ tag: tag as string, count: parseInt(count, 10), - }))), - 'getUniqueTags'); + }))) + , 'getUniqueTags'); export const getUniqueTagsHidden = async () => safelyQueryPhotos(() => sql` @@ -214,8 +250,8 @@ export const getUniqueTagsHidden = async () => `.then(({ rows }): TagsWithMeta => rows.map(({ tag, count }) => ({ tag: tag as string, count: parseInt(count, 10), - }))), - 'getUniqueTagsHidden'); + }))) + , 'getUniqueTagsHidden'); export const getUniqueCameras = async () => safelyQueryPhotos(() => sql` @@ -230,8 +266,8 @@ export const getUniqueCameras = async () => cameraKey: createCameraKey({ make, model }), camera: { make, model }, count: parseInt(count, 10), - }))), - 'getUniqueCameras'); + }))) + , 'getUniqueCameras'); export const getUniqueFilmSimulations = async () => safelyQueryPhotos(() => sql` @@ -244,151 +280,8 @@ export const getUniqueFilmSimulations = async () => .map(({ film_simulation, count }) => ({ simulation: film_simulation as FilmSimulation, count: parseInt(count, 10), - }))), - 'getUniqueFilmSimulations'); - -const safelyQueryPhotos = async ( - callback: () => Promise, - debugMessage: string -): Promise => { - let result: T; - - const start = new Date(); - - try { - result = await callback(); - } catch (e: any) { - if (MIGRATION_FIELDS_01.some(field => new RegExp( - `column "${field}" of relation "photos" does not exist`, - 'i', - ).test(e.message))) { - console.log('Running migration 01 ...'); - await runMigration01(); - result = await callback(); - } else if (/relation "photos" does not exist/i.test(e.message)) { - // If the table does not exist, create it - console.log('Creating photos table ...'); - await createPhotosTable(); - result = await callback(); - } else if (/endpoint is in transition/i.test(e.message)) { - console.log('sql get error: endpoint is in transition (setting timeout)'); - // Wait 5 seconds and try again - await new Promise(resolve => setTimeout(resolve, 5000)); - try { - result = await callback(); - } catch (e: any) { - console.log(`sql get error on retry (after 5000ms): ${e.message} `); - throw e; - } - } else { - console.log(`sql get error: ${e.message} `); - throw e; - } - } - - if (SHOULD_DEBUG_SQL && debugMessage) { - const time = - (((new Date()).getTime() - start.getTime()) / 1000).toFixed(2); - console.log(`Executing sql query: ${debugMessage} (${time} seconds)`); - } - - return result; -}; - -const getWheresFromOptions = ( - options: GetPhotosOptions, - initialValuesIndex = 1, -) => { - const { - hidden = 'exclude', - takenBefore, - takenAfterInclusive, - query, - tag, - camera, - simulation, - } = options; - - const wheres = [] as string[]; - const wheresValues = [] as (string | number)[]; - let valuesIndex = initialValuesIndex; - - switch (hidden) { - case 'exclude': - wheres.push('hidden IS NOT TRUE'); - break; - case 'only': - wheres.push('hidden IS TRUE'); - break; - } - if (takenBefore) { - wheres.push(`taken_at > $${valuesIndex++}`); - wheresValues.push(takenBefore.toISOString()); - } - if (takenAfterInclusive) { - wheres.push(`taken_at <= $${valuesIndex++}`); - wheresValues.push(takenAfterInclusive.toISOString()); - } - if (query) { - // eslint-disable-next-line max-len - wheres.push(`CONCAT(title, ' ', caption, ' ', semantic_description) ILIKE $${valuesIndex++}`); - wheresValues.push(`%${query.toLocaleLowerCase()}%`); - } - if (tag) { - wheres.push(`$${valuesIndex++}=ANY(tags)`); - wheresValues.push(tag); - } - if (camera) { - wheres.push(`LOWER(REPLACE(make, ' ', '-'))=$${valuesIndex++}`); - wheres.push(`LOWER(REPLACE(model, ' ', '-'))=$${valuesIndex++}`); - wheresValues.push(parameterize(camera.make, true)); - wheresValues.push(parameterize(camera.model, true)); - } - if (simulation) { - wheres.push(`film_simulation=$${valuesIndex++}`); - wheresValues.push(simulation); - } - - return { - wheres: wheres.length > 0 - ? `WHERE ${wheres.join(' AND ')}` - : '', - wheresValues, - lastValuesIndex: valuesIndex, - }; -}; - -const getOrderByFromOptions = (options: GetPhotosOptions) => { - const { - sortBy = PRIORITY_ORDER_ENABLED ? 'priority' : 'takenAt', - } = options; - - switch (sortBy) { - case 'createdAt': - return 'ORDER BY created_at DESC'; - case 'takenAt': - return 'ORDER BY taken_at DESC'; - case 'priority': - return 'ORDER BY priority_order ASC, taken_at DESC'; - } -}; - -const getLimitAndOffsetFromOptions = ( - options: GetPhotosOptions, - initialValuesIndex = 1, -) => { - const { - limit = PHOTO_DEFAULT_LIMIT, - offset = 0, - } = options; - - let valuesIndex = initialValuesIndex; - - return { - limitAndOffset: `LIMIT $${valuesIndex++} OFFSET $${valuesIndex++}`, - limitAndOffsetValues: [limit, offset], - }; -}; + }))) + , 'getUniqueFilmSimulations'); export const getPhotos = async (options: GetPhotosOptions = {}) => safelyQueryPhotos(async () => { @@ -483,8 +376,8 @@ export const getPhotoIds = async ({ limit }: { limit?: number }) => safelyQueryPhotos(() => (limit ? sql`SELECT id FROM photos LIMIT ${limit}` : sql`SELECT id FROM photos`) - .then(({ rows }) => rows.map(({ id }) => id as string)), - 'getPhotoIds'); + .then(({ rows }) => rows.map(({ id }) => id as string)) + , 'getPhotoIds'); export const getPhoto = async ( id: string,