Cache all postgres requests

This commit is contained in:
Sam Becker 2023-09-20 21:25:47 -05:00
parent 7bb1d7d6b4
commit 97bc58bd8a
19 changed files with 207 additions and 146 deletions

View File

@ -33,13 +33,18 @@ export default async function AdminPage({
}) {
const { offset, limit } = getPhotosLimitForQuery(searchParams.next);
const photos = await getPhotos('createdAt', limit);
const count = await getPhotosCount();
const [
photos,
count,
blobUploadUrls,
] = await Promise.all([
await getPhotos({ sortBy: 'createdAt', limit }),
await getPhotosCount(),
await getBlobUploadUrls(),
]);
const showMorePhotos = count > photos.length;
const blobUploadUrls = await getBlobUploadUrls();
const blobPhotoUrls = DEBUG_PHOTO_BLOBS
? await getBlobPhotoUrls()
: [];

View File

@ -1,17 +1,22 @@
import {
getPhotosCached,
getPhotosCountCached,
getUniqueTagsCached,
} from '@/cache';
import AnimateItems from '@/components/AnimateItems';
import MorePhotos from '@/components/MorePhotos';
import SiteGrid from '@/components/SiteGrid';
import { generateOgImageMetaForPhotos, getPhotosLimitForQuery } from '@/photo';
import PhotoGrid from '@/photo/PhotoGrid';
import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { getPhotos, getPhotosCount, getUniqueTags } from '@/services/postgres';
import { MAX_PHOTOS_TO_SHOW_HOME } from '@/photo/image-response';
import PhotoTag from '@/tag/PhotoTag';
import { Metadata } from 'next';
export const runtime = 'edge';
export async function generateMetadata(): Promise<Metadata> {
const photos = await getPhotos();
const photos = await getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_HOME});
return generateOgImageMetaForPhotos(photos);
}
@ -22,11 +27,11 @@ export default async function GridPage({
}) {
const { offset, limit } = getPhotosLimitForQuery(searchParams.next);
const photos = await getPhotos(undefined, limit);
const photos = await getPhotosCached({ limit });
const count = await getPhotosCount();
const count = await getPhotosCountCached();
const tags = await getUniqueTags();
const tags = await getUniqueTagsCached();
const showMorePhotos = count > photos.length;

View File

@ -1,20 +1,18 @@
import { auth } from '@/auth';
import { getImageCacheHeadersForAuth } from '@/cache';
import { getImageCacheHeadersForAuth, getPhotosCached } from '@/cache';
import {
IMAGE_OG_SMALL_SIZE,
MAX_PHOTOS_TO_SHOW_HOME,
} from '@/photo/image-response';
import HomeImageResponse from '@/photo/image-response/HomeImageResponse';
import { getPhotos } from '@/services/postgres';
import { ImageResponse } from 'next/server';
export const runtime = 'edge';
export async function GET(request: Request) {
const photos = await getPhotos(
undefined,
MAX_PHOTOS_TO_SHOW_HOME,
);
const photos = await getPhotosCached({
limit: MAX_PHOTOS_TO_SHOW_HOME,
});
const headers = await getImageCacheHeadersForAuth(await auth());

View File

@ -1,7 +1,7 @@
import { getPhotosCached, getPhotosCountCached } from '@/cache';
import MorePhotos from '@/components/MorePhotos';
import { getPhotosLimitForQuery } from '@/photo';
import StaggeredOgPhotos from '@/photo/StaggeredOgPhotos';
import { getPhotos, getPhotosCount } from '@/services/postgres';
export const runtime = 'edge';
@ -12,9 +12,9 @@ export default async function GridPage({
}) {
const { offset, limit } = getPhotosLimitForQuery(searchParams.next);
const photos = await getPhotos(undefined, limit);
const photos = await getPhotosCached({ limit });
const count = await getPhotosCount();
const count = await getPhotosCountCached();
const showMorePhotos = count > photos.length;

View File

@ -1,15 +1,14 @@
import { auth } from '@/auth';
import { getImageCacheHeadersForAuth } from '@/cache';
import { getImageCacheHeadersForAuth, getPhotoCached } from '@/cache';
import { IMAGE_OG_SIZE } from '@/photo/image-response';
import PhotoImageResponse from '@/photo/image-response/PhotoImageResponse';
import { getPhoto } from '@/services/postgres';
import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/server';
export const runtime = 'edge';
export async function GET(request: Request, context: any){
const photo = await getPhoto(context.params.photoId);
const photo = await getPhotoCached(context.params.photoId);
const {
fontFamily,

View File

@ -4,14 +4,10 @@ import {
titleForPhoto,
} from '@/photo';
import { Metadata } from 'next';
import {
getPhoto,
getPhotosTakenAfterPhotoInclusive,
getPhotosTakenBeforePhoto,
} from '@/services/postgres';
import { redirect } from 'next/navigation';
import { absolutePathForPhoto, absolutePathForPhotoImage } from '@/site/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotoCached, getPhotosCached } from '@/cache';
export const runtime = 'edge';
@ -20,7 +16,7 @@ export async function generateMetadata({
}: {
params: { photoId: string }
}): Promise<Metadata> {
const photo = await getPhoto(photoId);
const photo = await getPhotoCached(photoId);
if (!photo) { return {}; }
@ -54,15 +50,21 @@ export default async function PhotoPage({
params: { photoId: string }
children: React.ReactNode
}) {
const photo = await getPhoto(photoId);
const photo = await getPhotoCached(photoId);
if (!photo) { redirect('/'); }
const photosBefore = await getPhotosTakenBeforePhoto(photo, 1);
const photosAfter = await getPhotosTakenAfterPhotoInclusive(
photo,
GRID_THUMBNAILS_TO_SHOW_MAX + 1,
);
const [
photosBefore,
photosAfter,
] = await Promise.all([
getPhotosCached({ takenBefore: photo.takenAt, limit: 1 }),
getPhotosCached({
takenAfterInclusive: photo.takenAt,
limit: GRID_THUMBNAILS_TO_SHOW_MAX + 1,
}),
]);
const photos = photosBefore.concat(photosAfter);
return <>

View File

@ -1,5 +1,5 @@
import { getPhotoCached } from '@/cache';
import PhotoModal from '@/photo/PhotoModal';
import { getPhoto } from '@/services/postgres';
import { redirect } from 'next/navigation';
export const runtime = 'edge';
@ -9,7 +9,7 @@ export default async function Share({
}: {
params: { photoId: string }
}) {
const photo = await getPhoto(photoId);
const photo = await getPhotoCached(photoId);
if (!photo) { return redirect('/'); }

View File

@ -1,16 +1,16 @@
import { getPhotosCached, getPhotosCountCached } from '@/cache';
import AnimateItems from '@/components/AnimateItems';
import MorePhotos from '@/components/MorePhotos';
import SiteGrid from '@/components/SiteGrid';
import { generateOgImageMetaForPhotos, getPhotosLimitForQuery } from '@/photo';
import PhotoLarge from '@/photo/PhotoLarge';
import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { getPhotos, getPhotosCount } from '@/services/postgres';
import { Metadata } from 'next';
export const dynamic = 'force-static';
export const runtime = 'edge';
export async function generateMetadata(): Promise<Metadata> {
const photos = await getPhotos();
const photos = await getPhotosCached();
return generateOgImageMetaForPhotos(photos);
}
@ -21,9 +21,9 @@ export default async function HomePage({
}) {
const { offset, limit } = getPhotosLimitForQuery(searchParams.next, 12);
const photos = await getPhotos(undefined, limit);
const photos = await getPhotosCached({ limit });
const count = await getPhotosCount();
const count = await getPhotosCountCached();
const showMorePhotos = count > photos.length;

View File

@ -3,13 +3,10 @@ import {
titleForPhoto,
} from '@/photo';
import { Metadata } from 'next';
import {
getPhoto,
getPhotos,
} from '@/services/postgres';
import { redirect } from 'next/navigation';
import { absolutePathForPhoto, absolutePathForPhotoImage } from '@/site/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotoCached, getPhotosCached } from '@/cache';
export const runtime = 'edge';
@ -18,7 +15,7 @@ export async function generateMetadata({
}: {
params: { photoId: string, tag: string }
}): Promise<Metadata> {
const photo = await getPhoto(photoId);
const photo = await getPhotoCached(photoId);
if (!photo) { return {}; }
@ -52,11 +49,11 @@ export default async function PhotoTagPage({
params: { photoId: string, tag: string }
children: React.ReactNode
}) {
const photo = await getPhoto(photoId);
const photo = await getPhotoCached(photoId);
if (!photo) { redirect('/'); }
const photos = await getPhotos(undefined, undefined, undefined, tag);
const photos = await getPhotosCached({ tag });
return <>
{children}

View File

@ -1,5 +1,5 @@
import { getPhotoCached } from '@/cache';
import PhotoModal from '@/photo/PhotoModal';
import { getPhoto } from '@/services/postgres';
import { redirect } from 'next/navigation';
export const runtime = 'edge';
@ -9,7 +9,7 @@ export default async function Share({
}: {
params: { photoId: string, tag: string }
}) {
const photo = await getPhoto(photoId);
const photo = await getPhotoCached(photoId);
if (!photo) { return redirect('/'); }

View File

@ -1,23 +1,20 @@
import { auth } from '@/auth';
import { getImageCacheHeadersForAuth } from '@/cache';
import { getImageCacheHeadersForAuth, getPhotosCached } from '@/cache';
import {
IMAGE_OG_SMALL_SIZE,
MAX_PHOTOS_TO_SHOW_PER_TAG,
} from '@/photo/image-response';
import TagImageResponse from '@/photo/image-response/TagImageResponse';
import { getPhotos } from '@/services/postgres';
import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/server';
export const runtime = 'edge';
export async function GET(request: Request, context: any) {
const photos = await getPhotos(
undefined,
MAX_PHOTOS_TO_SHOW_PER_TAG,
undefined,
context.params.tag,
);
const photos = await getPhotosCached({
limit: MAX_PHOTOS_TO_SHOW_PER_TAG,
tag: context.params.tag,
});
const {
fontFamily,

View File

@ -1,6 +1,6 @@
import { getPhotosCached } from '@/cache';
import SiteGrid from '@/components/SiteGrid';
import PhotoGrid from '@/photo/PhotoGrid';
import { getPhotos } from '@/services/postgres';
import { absolutePathForTag, absolutePathForTagImage } from '@/site/paths';
import { descriptionForTaggedPhotos, titleForTag } from '@/tag';
import TagHeader from '@/tag/TagHeader';
@ -13,7 +13,7 @@ interface TagProps {
export async function generateMetadata({
params: { tag },
}: TagProps): Promise<Metadata> {
const photos = await getPhotos(undefined, undefined, undefined, tag);
const photos = await getPhotosCached({ tag });
const url = absolutePathForTag(tag);
const title = titleForTag(tag, photos);
@ -38,7 +38,7 @@ export async function generateMetadata({
}
export default async function TagPage({ params: { tag } }: TagProps) {
const photos = await getPhotos(undefined, undefined, undefined, tag);
const photos = await getPhotosCached({ tag });
return (
<SiteGrid

View File

@ -1,19 +1,21 @@
import { auth } from '@/auth';
import { getImageCacheHeadersForAuth } from '@/cache';
import { getImageCacheHeadersForAuth, getPhotosCached } from '@/cache';
import {
IMAGE_OG_SIZE,
MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT,
} from '@/photo/image-response';
import TemplateImageResponse from
'@/photo/image-response/TemplateImageResponse';
import { getPhotos } from '@/services/postgres';
import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/server';
export const runtime = 'edge';
export async function GET(request: Request) {
const photos = await getPhotos('priority', MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT);
const photos = await getPhotosCached({
sortBy: 'priority',
limit: MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT,
});
const {
fontFamily,

View File

@ -1,23 +1,27 @@
import { auth } from '@/auth';
import { getImageCacheHeadersForAuth } from '@/cache';
import { getImageCacheHeadersForAuth, getPhotosCached } from '@/cache';
import {
GRID_OG_SIZE,
MAX_PHOTOS_TO_SHOW_TEMPLATE,
} from '@/photo/image-response';
import TemplateImageResponse from
'@/photo/image-response/TemplateImageResponse';
import { getPhotos } from '@/services/postgres';
import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/server';
export const runtime = 'edge';
export async function GET(request: Request) {
const photos = await getPhotos('priority', MAX_PHOTOS_TO_SHOW_TEMPLATE);
const photos = await getPhotosCached({
sortBy: 'priority',
limit: MAX_PHOTOS_TO_SHOW_TEMPLATE,
});
const {
fontFamily,
fonts,
} = await getIBMPlexMonoMedium();
const headers = await getImageCacheHeadersForAuth(await auth());
const { width, height } = GRID_OG_SIZE;

80
src/cache/index.ts vendored
View File

@ -1,40 +1,60 @@
import { getPhoto, getPhotos } from '@/services/postgres';
import type { Session } from 'next-auth/types';
import { revalidatePath, revalidateTag, unstable_cache } from 'next/cache';
import { revalidateTag, unstable_cache } from 'next/cache';
import {
GetPhotosOptions,
getPhoto,
getPhotos,
getPhotosCount,
getUniqueTags,
} from '@/services/postgres';
import { parseCachedPhotosDates, parseCachedPhotoDates } from '@/photo';
const TAG_PHOTOS = 'photos';
const TAG_PHOTOS = 'photos';
const TAG_PHOTOS_COUNT = 'photos-count';
const TAG_TAGS = 'tags';
const PHOTO_PATHS = [
'/',
'/grid',
'/p/[photoId]',
'/p/[photoId]/share',
'/p/[photoId]/image',
'/t/[tag]',
'/t/[tag]/[photoId]',
'/t/[tag]/[photoId]/share',
'/admin/photos',
'/admin/photos/[photoId]',
'/admin/photos/[photoId]/edit',
];
const getPhotosCacheTags = (options: GetPhotosOptions = {}) => {
const tags = [TAG_PHOTOS];
const {
sortBy,
limit,
offset,
tag,
takenAfterInclusive,
takenBefore,
} = options;
if (sortBy !== undefined) { tags.push(`sortBy-${sortBy}`); }
if (limit !== undefined) { tags.push(`limit-${sortBy}`); }
if (offset !== undefined) { tags.push(`offset-${sortBy}`); }
if (tag !== undefined) { tags.push(`tag-${sortBy}`); }
// eslint-disable-next-line max-len
if (takenBefore !== undefined) { tags.push(`takenBefore-${takenBefore.toISOString()}`); }
// eslint-disable-next-line max-len
if (takenAfterInclusive !== undefined) { tags.push(`takenAfterInclusive-${takenAfterInclusive.toISOString()}`); }
return tags;
};
const tagForPhoto = (photoId: string) => `photo-${photoId}`;
export const revalidatePhotosTag = (
includePhotoPaths?: boolean
) => {
export const revalidatePhotosTag = () =>
revalidateTag(TAG_PHOTOS);
if (includePhotoPaths) { revalidateAllPhotoPaths(); }
};
export const revalidateAllPhotoPaths = () =>
PHOTO_PATHS.forEach(path => revalidatePath(path));
export const getPhotosCached: typeof getPhotos = (...args) =>
unstable_cache(
() => getPhotos(...args),
[TAG_PHOTOS], {
tags: [TAG_PHOTOS],
getPhotosCacheTags(...args), {
tags: getPhotosCacheTags(...args),
}
)().then(parseCachedPhotosDates);
export const getPhotosCountCached: typeof getPhotosCount = (...args) =>
unstable_cache(
() => getPhotosCount(...args),
[TAG_PHOTOS, TAG_PHOTOS_COUNT], {
tags: [TAG_PHOTOS, TAG_PHOTOS_COUNT],
}
)();
@ -44,6 +64,14 @@ export const getPhotoCached: typeof getPhoto = (...args) =>
[TAG_PHOTOS, tagForPhoto(...args)], {
tags: [TAG_PHOTOS, tagForPhoto(...args)],
}
)().then(photo => photo ? parseCachedPhotoDates(photo) : undefined);
export const getUniqueTagsCached: typeof getUniqueTags = (...args) =>
unstable_cache(
() => getUniqueTags(...args),
[TAG_PHOTOS, TAG_TAGS], {
tags: [TAG_PHOTOS, TAG_TAGS],
}
)();
export const getImageCacheHeadersForAuth = async (session?: Session) => {

View File

@ -1,6 +1,6 @@
import { auth } from './auth';
import { NextRequest, NextResponse } from 'next/server';
import { NextApiRequest, NextApiResponse } from 'next';
import type { NextApiRequest, NextApiResponse } from 'next';
export default function middleware(req: NextRequest, res:NextResponse) {
const pathname = req.nextUrl.pathname;

View File

@ -25,7 +25,7 @@ export async function createPhotoAction(formData: FormData) {
if (updatedUrl) { photo.url = updatedUrl; }
await sqlInsertPhotoIntoDb(photo);
revalidatePhotosTag(true);
revalidatePhotosTag();
redirect('/admin/photos');
}
@ -35,7 +35,7 @@ export async function updatePhotoAction(formData: FormData) {
await sqlUpdatePhotoInDb(photo);
revalidatePhotosTag(true);
revalidatePhotosTag();
redirect('/admin/photos');
}
@ -44,7 +44,7 @@ export async function deletePhotoAction(formData: FormData) {
await deleteBlobPhoto(formData.get('url') as string);
await sqlDeletePhoto(formData.get('id') as string);
revalidatePhotosTag(true);
revalidatePhotosTag();
};
export async function deleteBlobPhotoAction(formData: FormData) {

View File

@ -91,6 +91,16 @@ export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
};
};
export const parseCachedPhotoDates = (photo: Photo) => ({
...photo,
takenAt: new Date(photo.takenAt),
updatedAt: new Date(photo.updatedAt),
createdAt: new Date(photo.createdAt),
});
export const parseCachedPhotosDates = (photos: Photo[]) =>
photos.map(parseCachedPhotoDates);
export const convertPhotoToPhotoDbInsert = (
photo: Photo,
): PhotoDbInsert => ({

View File

@ -136,8 +136,7 @@ const sqlGetPhotosFromDb = (
SELECT * FROM photos
ORDER BY taken_at DESC
LIMIT ${limit} OFFSET ${offset}
`
.then(({ rows }) => rows.map(parsePhotoFromDb));
`;
const sqlGetPhotosFromDbSortedByCreatedAt = (
limit = PHOTO_DEFAULT_LIMIT,
@ -147,8 +146,7 @@ const sqlGetPhotosFromDbSortedByCreatedAt = (
SELECT * FROM photos
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}
`
.then(({ rows }) => rows.map(parsePhotoFromDb));
`;
const sqlGetPhotosFromDbSortedByPriority = (
limit = PHOTO_DEFAULT_LIMIT,
@ -158,8 +156,7 @@ const sqlGetPhotosFromDbSortedByPriority = (
SELECT * FROM photos
ORDER BY priority_order ASC, taken_at DESC
LIMIT ${limit} OFFSET ${offset}
`
.then(({ rows }) => rows.map(parsePhotoFromDb));
`;
const sqlGetPhotosFromDbByTag = (
limit = PHOTO_DEFAULT_LIMIT,
@ -170,43 +167,83 @@ const sqlGetPhotosFromDbByTag = (
SELECT * FROM photos WHERE ${tag}=ANY(tags)
ORDER BY taken_at ASC
LIMIT ${limit} OFFSET ${offset}
`
.then(({ rows }) => rows.map(parsePhotoFromDb));
`;
const sqlGetPhotosTakenAfterDateInclusive = (
takenAt: Date,
limit?: number,
) =>
sql<PhotoDb>`
SELECT * FROM photos
WHERE taken_at <= ${takenAt.toISOString()}
ORDER BY taken_at DESC
LIMIT ${limit}
`;
const sqlGetPhotosTakenBeforeDate = (
takenAt: Date,
limit?: number,
) =>
sql<PhotoDb>`
SELECT * FROM photos
WHERE taken_at > ${takenAt.toISOString()}
ORDER BY taken_at ASC
LIMIT ${limit}
`;
const sqlGetPhotoFromDb = (id: string) =>
sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} LIMIT 1`
.then(({ rows }) => rows.map(parsePhotoFromDb));
sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} LIMIT 1`;
export type GetPhotosOptions = {
sortBy?: 'createdAt' | 'takenAt' | 'priority'
limit?: number
offset?: number
tag?: string
takenBefore?: Date
takenAfterInclusive?: Date
}
export const getPhotos = async (options: GetPhotosOptions = {}) => {
const {
sortBy = 'takenAt',
limit,
offset,
tag,
takenBefore,
takenAfterInclusive,
} = options;
export const getPhotos = async (
sortBy: 'createdAt' | 'takenAt' | 'priority' = 'takenAt',
limit?: number,
offset?: number,
tag?: string,
) => {
let photos;
const getPhotosRequest = tag
? () => sqlGetPhotosFromDbByTag(limit, offset, tag)
: sortBy === 'createdAt'
? () => sqlGetPhotosFromDbSortedByCreatedAt(limit, offset)
: sortBy === 'priority'
? () => sqlGetPhotosFromDbSortedByPriority(limit, offset)
: () => sqlGetPhotosFromDb(limit, offset);
const getPhotosRequest = takenBefore
? () => sqlGetPhotosTakenBeforeDate(takenBefore, limit)
: takenAfterInclusive
? () => sqlGetPhotosTakenAfterDateInclusive(takenAfterInclusive, limit)
: tag
? () => sqlGetPhotosFromDbByTag(limit, offset, tag)
: sortBy === 'createdAt'
? () => sqlGetPhotosFromDbSortedByCreatedAt(limit, offset)
: sortBy === 'priority'
? () => sqlGetPhotosFromDbSortedByPriority(limit, offset)
: () => sqlGetPhotosFromDb(limit, offset);
const getPhotosRequestAndParse = () =>
getPhotosRequest().then(({ rows }) => rows.map(parsePhotoFromDb));
try {
photos = await getPhotosRequest();
photos = await getPhotosRequestAndParse();
} catch (e: any) {
if (/relation "photos" does not exist/i.test(e.message)) {
console.log(
'Creating table "photos" because it did not exist',
);
await sqlCreatePhotosTable();
photos = await getPhotosRequest();
photos = await getPhotosRequestAndParse();
} else if (/endpoint is in transition/i.test(e.message)) {
// Wait 5 seconds and try again
await new Promise(resolve => setTimeout(resolve, 5000));
try {
photos = await getPhotosRequest();
photos = await getPhotosRequestAndParse();
} catch (e: any) {
console.log(`sql get error on retry (after 5000ms): ${e.message} `);
throw e;
@ -220,30 +257,6 @@ export const getPhotos = async (
return photos;
};
export const getPhotosTakenAfterPhotoInclusive = (
photo: Photo,
limit?: number,
) =>
sql<PhotoDb>`
SELECT * FROM photos
WHERE taken_at <= ${photo.takenAt.toISOString()}
ORDER BY taken_at DESC
LIMIT ${limit}
`
.then(({ rows }) => rows.map(parsePhotoFromDb));
export const getPhotosTakenBeforePhoto = (
photo: Photo,
limit?: number,
) =>
sql<PhotoDb>`
SELECT * FROM photos
WHERE taken_at > ${photo.takenAt.toISOString()}
ORDER BY taken_at ASC
LIMIT ${limit}
`
.then(({ rows }) => rows.map(parsePhotoFromDb));
export const getPhotosCount = async () => sql`
SELECT COUNT(*) FROM photos
`.then(({ rows }) => parseInt(rows[0].count, 10));
@ -257,5 +270,6 @@ export const getPhoto = async (id: string): Promise<Photo | undefined> => {
// and convert short ids to uuids
const photoId = translatePhotoId(id);
return sqlGetPhotoFromDb(photoId)
.then(({ rows }) => rows.map(parsePhotoFromDb))
.then(photos => photos.length > 0 ? photos[0] : undefined);
};